Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Interest and Data Lifecycle

Every NDN packet that enters ndn-rs follows a carefully choreographed journey. Let’s trace an Interest from the moment it arrives as raw bytes to the moment matching Data flows back to the consumer.

The Pipeline Machine

At the core of this journey is a pipeline – a fixed sequence of PipelineStage trait objects determined at build time so the compiler can monomorphize the hot path. A runner loop drains a shared mpsc channel fed by all face tasks, picks up each packet, and drives it through the appropriate pipeline. There are no hidden callbacks or middleware chains; the runner simply matches on the Action returned by each stage and decides what happens next.

💡 Key insight: PacketContext is passed by value (moved) through each stage. This means ownership transfers at every step – a stage that short-circuits the pipeline consumes the context, and the compiler prevents any subsequent stage from accidentally using it. In C++, this invariant would require runtime checks; in Rust, it is a compile-time guarantee.

The stage contract is minimal:

#![allow(unused)]
fn main() {
pub trait PipelineStage: Send + Sync + 'static {
    fn process(
        &self,
        ctx: PacketContext,
    ) -> impl Future<Output = Result<Action, DropReason>> + Send;
}
}

And the Action enum gives each stage explicit control over what comes next:

#![allow(unused)]
fn main() {
pub enum Action {
    Continue(PacketContext),  // pass to next stage
    Send(PacketContext, SmallVec<[FaceId; 4]>),  // forward and exit
    Satisfy(PacketContext),   // satisfy PIT entries and exit
    Drop(DropReason),        // discard silently
    Nack(PacketContext, NackReason),  // send Nack to incoming face
}
}

The Traveling Context

Before we follow a packet through the pipeline, it helps to understand what it carries. A PacketContext is born the moment bytes arrive on a face, and it accumulates information as each stage does its work:

#![allow(unused)]
fn main() {
pub struct PacketContext {
    pub raw_bytes: Bytes,              // original wire bytes
    pub face_id:   FaceId,            // face the packet arrived on
    pub name:      Option<Arc<Name>>,  // None until TlvDecodeStage
    pub packet:    DecodedPacket,      // Raw -> Interest/Data after decode
    pub pit_token: Option<PitToken>,   // set by PitCheckStage
    pub out_faces: SmallVec<[FaceId; 4]>,  // populated by StrategyStage
    pub lp_pit_token: Option<Bytes>,  // LP PIT token echoed in responses
    pub cs_hit:    bool,
    pub verified:  bool,
    pub arrival:   u64,                // ns since Unix epoch
    pub tags:      AnyMap,             // extensible per-packet metadata
}
}

Notice that name starts as None – it won’t be populated until the TLV decode stage runs. This progressive population is deliberate: a Content Store hit can short-circuit the pipeline before expensive fields like nonce or lifetime are ever accessed.

flowchart LR
    FC["FaceCheck\n──────────\nface_id\nraw_bytes\narrival"]
    -->|"Continue"| DEC["TlvDecode\n──────────\nname\npacket"]
    -->|"Continue"| CS["CsLookup\n──────────\ncs_hit"]
    -->|"Continue"| PIT["PitCheck\n──────────\npit_token\nnonce check"]
    -->|"Continue"| STR["Strategy\n──────────\nout_faces\nFIB match"]
    -->|"Forward"| DSP["Dispatch\n──────────\nsend to\nout_faces"]

    style FC  fill:#e8f4fd,stroke:#2196F3
    style DEC fill:#e8f4fd,stroke:#2196F3
    style CS  fill:#fff3e0,stroke:#FF9800
    style PIT fill:#e8f4fd,stroke:#2196F3
    style STR fill:#f3e5f5,stroke:#9C27B0
    style DSP fill:#e8f5e9,stroke:#4CAF50

Now let’s follow an Interest through the full journey.

The Interest’s Journey

An Interest packet materializes on a face – perhaps a UDP datagram from a downstream consumer, or bytes pushed through an in-process InProcFace. Its journey begins.

flowchart TD
    A["Packet arrives on Face"] --> B["FaceCheck"]
    B -->|"face valid"| C["TlvDecode"]
    B -->|"face down/invalid"| X1["Drop"]
    C -->|"valid Interest"| D["CsLookup"]
    C -->|"malformed TLV"| X2["Drop"]
    D -->|"CS hit"| Y1["Send cached Data\nback to incoming face"]
    D -->|"CS miss"| E["PitCheck"]
    E -->|"duplicate nonce\n(loop detected)"| X3["Nack: Duplicate"]
    E -->|"existing PIT entry\n(aggregate)"| X4["Suppress\n(add in-record, do not forward)"]
    E -->|"new PIT entry"| F["Strategy"]
    F --> G{"ForwardingAction?"}
    G -->|"Forward"| H["Dispatch to\nnexthop face(s)"]
    G -->|"ForwardAfter"| I["Schedule delayed\nforward + probe"]
    G -->|"Nack"| X5["Nack to\nincoming face"]
    G -->|"Suppress"| X6["Drop\n(policy decision)"]

First contact: FaceCheck

The very first thing the forwarder does is verify that the face the packet arrived on is still alive. If the face has been torn down or is in the process of shutting down, there’s no point proceeding – the packet is dropped immediately. This is a cheap guard that prevents stale packets from wasting pipeline resources.

Making sense of the bytes: TlvDecode

With the face confirmed, the raw Bytes are parsed into a typed Interest struct. The context’s name field is populated with an Arc<Name>, giving subsequent stages a shared, zero-copy reference to the packet’s identity. Malformed TLV drops the packet here.

🔧 Implementation note: Fields like nonce and lifetime are decoded lazily via OnceLock<T>. They sit dormant inside the Interest struct, only computed when a later stage actually reads them. If the pipeline short-circuits before that happens, the CPU cycles are never spent.

The fast path: CsLookup

Now comes the moment that can make the entire rest of the pipeline irrelevant. The forwarder checks its Content Store for a cached Data packet matching this Interest’s name. If one is found, the cached wire-format Bytes are sent directly back to the incoming face. The pipeline short-circuits – no PIT entry is created, no FIB lookup occurs, no upstream forwarding happens.

📊 Performance: The CS short-circuit is the single most important optimization in the pipeline. A cache hit skips PIT insertion, FIB lookup, strategy invocation, and upstream forwarding – reducing a multi-stage pipeline to a hash lookup and a reference-count increment. With lazy OnceLock decoding, even the Interest’s nonce and lifetime fields are never parsed on this path.

Tracking the request: PitCheck

On a cache miss, the Interest reaches the Pending Interest Table. Here the forwarder must answer three questions at once. Has this exact Interest been seen before with the same nonce? If so, there’s a forwarding loop – the packet is Nacked with Duplicate. Is there already an outstanding PIT entry for this name from a different consumer? If so, the Interest is aggregated: its face is added as an in-record to the existing entry, but no new upstream Interest is sent. This is the mechanism behind NDN’s built-in multicast efficiency. Only if the entry is genuinely new does the Interest proceed to the next stage.

Deciding where to go: Strategy

For a fresh Interest that needs forwarding, the forwarder performs a longest-prefix match against the FIB to discover nexthop faces, then invokes the strategy assigned to that prefix. The strategy receives an immutable StrategyContext – it can observe the forwarder’s state but cannot mutate it – and returns a forwarding decision:

#![allow(unused)]
fn main() {
pub enum ForwardingAction {
    Forward(SmallVec<[FaceId; 4]>),
    ForwardAfter { faces: SmallVec<[FaceId; 4]>, delay: Duration },
    Nack(NackReason),
    Suppress,
}
}

Forward sends the Interest immediately. ForwardAfter enables probe-and-fallback patterns without the strategy needing to spawn its own timers – the forwarder handles the scheduling. Nack and Suppress end the journey here.

⚠️ Strategy isolation: Strategies cannot mutate global state. They receive a read-only snapshot and return a decision. This makes strategies safe to swap at runtime and prevents a buggy strategy from corrupting the FIB or PIT.

The final hop: Dispatch

The Interest is sent out on the selected nexthop face(s), and out-records are created in the PIT entry to track when each was sent. The Interest is now in flight, and the forwarder waits for a response.

The Satisfying Return: Data Pipeline

Somewhere upstream – perhaps one hop away, perhaps many – a producer or another router’s cache generates a Data packet matching the Interest. That Data now makes its way back through the network to our router.

flowchart TD
    A["Data arrives on Face"] --> B["FaceCheck"]
    B -->|"face valid"| C["TlvDecode"]
    B -->|"face down/invalid"| X1["Drop"]
    C -->|"valid Data"| D["PitMatch"]
    C -->|"malformed TLV"| X2["Drop"]
    D -->|"no PIT entry\n(unsolicited)"| X3["Drop"]
    D -->|"PIT match found"| E["Strategy\n(after_receive_data)"]
    E --> F["MeasurementsUpdate"]
    F --> G["CsInsert"]
    G --> H["Dispatch Data to\nall PIT in-record faces"]
    H --> I["Remove PIT entry"]

The Data’s journey begins with the same FaceCheck and TlvDecode stages that every packet passes through. But after decoding, the paths diverge.

Finding who asked: PitMatch

The forwarder looks up the PIT for an entry matching this Data’s name. If no entry exists, the Data is unsolicited – nobody asked for it, so it is dropped. This is a fundamental NDN security property: routers only accept Data that was explicitly requested. When a match is found, the PIT entry reveals which downstream faces are waiting for this content.

Learning from success: Strategy and Measurements

The strategy is notified that Data arrived via after_receive_data. This allows it to update its internal state – mark a path as working, cancel retransmission timers, adjust preferences. Then the MeasurementsUpdate stage computes per-face, per-prefix statistics: EWMA RTT (derived from the gap between the out-record’s send timestamp and this Data’s arrival) and satisfaction rate. These measurements feed back into future strategy decisions, letting the forwarder learn which paths perform best.

📊 Performance: Measurements are stored in a DashMap-backed MeasurementsTable, so updating statistics for one prefix never blocks lookups for another. The EWMA computation is a single multiply-and-add – negligible cost for valuable routing intelligence.

Caching for the future: CsInsert

Before the Data reaches its final recipients, it is inserted into the Content Store. The wire-format Bytes are stored directly – no re-encoding – so future cache hits can be served as zero-copy sends. The FreshnessPeriod is decoded once at insert time to compute a stale_at timestamp.

Delivering the payload: Dispatch

Finally, the Data is sent to every face listed in the PIT entry’s in-records. If three consumers requested the same content, all three receive it now. The PIT entry is consumed – removed from the table – and the lifecycle is complete.

The Power of Aggregation

The interplay between the Interest and Data pipelines reveals one of NDN’s most powerful properties. When multiple consumers request the same data, the PIT aggregates their Interests so that only a single Interest is forwarded upstream. When the Data returns, it fans out to all of them:

💡 Key insight: PIT aggregation is what makes NDN inherently multicast-friendly. Three consumers requesting the same video segment generate only one upstream Interest and one Data packet over the bottleneck link. The router’s PIT entry fans the Data out locally. This is fundamentally different from IP, where each consumer opens a separate connection and the same data traverses the network three times.

flowchart LR
    c1["Consumer 1"] -->|"Interest\n/app/data/1"| router["Router"]
    c2["Consumer 2"] -->|"Interest\n/app/data/1"| router
    c3["Consumer 3"] -->|"Interest\n/app/data/1"| router

    router -->|"Single Interest\n/app/data/1"| producer["Producer"]

    producer -->|"Data\n/app/data/1"| router2["Router"]

    router2 -->|"Data"| c1b["Consumer 1"]
    router2 -->|"Data"| c2b["Consumer 2"]
    router2 -->|"Data"| c3b["Consumer 3"]

    subgraph PIT Entry
        direction TB
        pit_name["Name: /app/data/1"]
        in1["InRecord: face 1 (Consumer 1)"]
        in2["InRecord: face 2 (Consumer 2)"]
        in3["InRecord: face 3 (Consumer 3)"]
        out1["OutRecord: face 4 (upstream)"]
    end

Consumer 1’s Interest arrives first and creates a new PIT entry. Consumer 2’s Interest for the same name finds the existing entry and is aggregated – an in-record is added, but no second Interest goes upstream. Consumer 3 is aggregated the same way. When the Data returns, the forwarder reads all three in-records and delivers the Data to each consumer. One upstream packet, three downstream deliveries.

When Things Go Wrong: The Nack Pipeline

Not every Interest finds its Data. Sometimes there is no route in the FIB. Sometimes the upstream path is congested. Sometimes the producer is unreachable. NDN handles these failures with Network Nacks – negative acknowledgements that flow back toward the consumer.

Nacks can be generated at two points in the Interest pipeline. A strategy that finds no viable nexthop returns ForwardingAction::Nack(reason), and any pipeline stage can return Action::Nack(ctx, reason) to signal a problem. The PitCheck stage does this when it detects a loop via duplicate nonce.

When a Nack arrives from upstream, it follows a shortened pipeline: decode, PIT match, and strategy notification. The strategy then faces a choice – try an alternative nexthop if one exists, or propagate the Nack downstream to the consumer. If no alternatives remain, the Nack flows back to every face in the PIT entry’s in-records, and the entry is removed.

⚠️ Nack propagation is conservative: A strategy will exhaust all available nexthops before propagating a Nack downstream. Only when every path has failed does the consumer learn that its Interest cannot be satisfied. This gives the network maximum opportunity to find the data through alternative routes.

The Full Picture

The two pipelines are symmetric and complementary. Interests flow upstream from consumer toward producer, driven by the FIB. Data flows downstream from producer toward consumer, guided by the PIT entries that Interests left behind. The Content Store sits at the junction, short-circuiting the loop when it can. And the strategy system ties it all together, learning from every Data arrival and every Nack to make better forwarding decisions over time.

This is the packet lifecycle in ndn-rs: a pipeline that is small enough to reason about stage by stage, yet powerful enough to express multicast aggregation, in-network caching, and adaptive forwarding – all without a single IP address in sight.