Pipeline Walkthrough
The Scene: A Packet Arrives
An Interest for /ndn/edu/ucla/cs/class arrives on a UDP face. The consumer application – maybe a student’s laptop running a content-fetching tool – has just expressed its desire for some course data. The Interest is nothing but raw bytes in a UDP datagram, sitting in a kernel socket buffer. It has no idea what’s about to happen to it.
Let’s follow it through every stage of the ndn-rs forwarding pipeline, from raw bytes to satisfied consumer.
The Cast of Characters
The pipeline is a fixed sequence of stages compiled at build time. Packets flow through stages as a PacketContext value that is passed by ownership – Rust’s move semantics ensure that exactly one stage owns the packet at any given moment. Each stage returns an Action enum that tells the pipeline runner what to do next:
Continue(ctx)– pass to the next stageSatisfy(ctx)– Data found, send it backSend(ctx)– forward out a faceDrop(reason)– discard the packetNack(reason)– send a Nack upstream
💡 Key insight: The pipeline is not a runtime plugin system. Stages are fixed at compile time, which lets the compiler inline aggressively and eliminate virtual dispatch overhead. The
Actionenum drives control flow without dynamic trait objects on the hot path.
The Full Journey
Here’s the complete sequence, from the consumer sending an Interest to receiving the Data back. We’ll walk through each box in detail below.
sequenceDiagram
participant App as Consumer App
participant FaceA as UdpFace (in)
participant Pipeline as Pipeline Runner
participant Decode as TlvDecodeStage
participant CS as CsLookupStage
participant PIT as PitCheckStage
participant Strategy as StrategyStage
participant FIB as FIB
participant FaceB as UdpFace (out)
participant Producer as Producer App
App->>FaceA: Interest (wire bytes)
FaceA->>Pipeline: InboundPacket { raw, face_id, arrival }
Pipeline->>Decode: process(ctx)
Note over Decode: LP-unwrap + TLV parse<br/>Set ctx.name, ctx.packet
Pipeline->>CS: process(ctx)
Note over CS: Lookup by name<br/>(miss path)
Pipeline->>PIT: process(ctx)
Note over PIT: Create PIT entry<br/>Record in-face, nonce<br/>Check for duplicate nonce
Pipeline->>Strategy: process(ctx)
Strategy->>FIB: lpm(name)
FIB-->>Strategy: nexthops
Note over Strategy: BestRoute selects<br/>lowest-cost nexthop
Strategy->>FaceB: send(Interest bytes)
FaceB->>Producer: Interest
Producer->>FaceB: Data (wire bytes)
FaceB->>Pipeline: InboundPacket { raw, face_id, arrival }
Pipeline->>Decode: process(ctx)
Note over Decode: Parse Data, set ctx.name
Pipeline->>PIT: PitMatchStage::process(ctx)
Note over PIT: Match by name<br/>Collect in-record faces<br/>Remove PIT entry
Pipeline->>Strategy: Validation
Note over Strategy: Signature/chain validation<br/>(optional)
Pipeline->>CS: CsInsertStage::process(ctx)
Note over CS: Insert Data into cache<br/>Fan-out to in-record faces
CS->>FaceA: send(Data bytes)
FaceA->>App: Data
Act I: Arrival
Bytes Hit the Wire
Our Interest for /ndn/edu/ucla/cs/class arrives as a UDP datagram on port 6363. The UdpFace has been waiting for exactly this moment. Each face runs its own Tokio task – a tight loop calling face.recv() and pushing results into a shared channel:
#![allow(unused)]
fn main() {
pub struct InboundPacket {
pub raw: Bytes, // Raw wire bytes (zero-copy from socket)
pub face_id: FaceId, // Which face received this
pub arrival: Instant, // Arrival timestamp
pub meta: InboundMeta, // Link-layer metadata (source MAC, etc.)
}
}
The raw field is a bytes::Bytes – a reference-counted, zero-copy buffer. The kernel wrote the datagram into a buffer, and Bytes lets us slice and share it without ever copying the actual packet data. This matters: our Interest might pass through six pipeline stages, and none of them will memcpy the wire bytes.
🔧 Implementation note: The
InboundPacketcaptures anInstantat arrival time, not a wall-clock timestamp. This is used later for PIT expiry calculations and RTT measurement – both of which need monotonic time, not time-of-day.
The Batch Drain: Amortizing Overhead
Here’s where the first clever optimization lives. The pipeline runner doesn’t process packets one at a time. It drains them in batches.
The runner blocks on the channel waiting for the first packet. But as soon as one arrives, it greedily pulls up to 63 more with non-blocking try_recv() calls. In a burst scenario – say, a hundred Interests arrive in quick succession – this amortizes the tokio::select! wakeup cost across the entire batch:
#![allow(unused)]
fn main() {
const BATCH_SIZE: usize = 64;
let first = tokio::select! {
_ = cancel.cancelled() => break,
pkt = rx.recv() => match pkt { ... },
};
batch.push(first);
while batch.len() < BATCH_SIZE {
match rx.try_recv() {
Ok(p) => batch.push(p),
Err(_) => break,
}
}
}
📊 Performance: Without batch drain, every packet pays the full cost of a
tokio::select!wakeup – parking and unparking the task, checking the cancellation token, etc. With batch drain, 64 packets share a single wakeup. Under load, this reduces per-packet scheduling overhead by up to 60x.
Here’s the task topology. Every face feeds into one shared channel, and the pipeline runner fans out to per-packet processing:
%%{init: {"layout": "elk"}}%%
flowchart LR
subgraph Face Reader Tasks
F1["UdpFace\nrecv loop"]
F2["TcpFace\nrecv loop"]
F3["EtherFace\nrecv loop"]
Fn["..."]
end
CH[/"mpsc channel\n(InboundPacket queue)"/]
F1 --> CH
F2 --> CH
F3 --> CH
Fn --> CH
subgraph Pipeline Runner
PR["run_pipeline\n(batch drain up to 64)"]
end
CH --> PR
subgraph Per-Packet Processing
PP1["spawn: process_packet"]
PP2["spawn: process_packet"]
PP3["spawn: process_packet"]
end
PR --> PP1
PR --> PP2
PR --> PP3
subgraph Face Send Tasks
S1["FaceA.send()"]
S2["FaceB.send()"]
S3["FaceC.send()"]
end
PP1 --> S1
PP2 --> S2
PP3 --> S3
style CH fill:#c90,color:#000
style PR fill:#2d5a8c,color:#fff
style PP1 fill:#5a2d8c,color:#fff
style PP2 fill:#5a2d8c,color:#fff
style PP3 fill:#5a2d8c,color:#fff
💡 Key insight: The single
mpscchannel is intentional. All faces – UDP, TCP, Ethernet, shared memory – feed into the same queue. This means Interest aggregation works correctly even when the same Interest arrives on different face types simultaneously. One channel, one PIT, one truth.
Parallel vs. Single-Threaded Mode
Before the runner dispatches each packet, it makes a choice based on the pipeline_threads configuration:
#![allow(unused)]
fn main() {
if parallel {
let d = Arc::clone(self);
tokio::spawn(async move { d.process_packet(pkt).await });
} else {
self.process_packet(pkt).await;
}
}
- Single-threaded (
pipeline_threads == 1): packets are processed inline in the pipeline runner task. No task spawn overhead, no cross-thread synchronization. Best for embedded devices, low-traffic deployments, or when you want deterministic packet ordering. - Parallel (
pipeline_threads > 1): each packet is spawned as a separate Tokio task and can run on any worker thread. Higher throughput under load, at the cost of task spawn overhead (~200ns per packet) and non-deterministic ordering.
⚠️ Important: In parallel mode, two Interests for the same name can race through the PIT check stage concurrently. The
DashMap-based PIT handles this correctly through fine-grained locking, but the ordering of in-records may differ between runs. If your application depends on Interest ordering, use single-threaded mode.
🔄 What happens next: The batch is drained, the mode is selected. Now each packet enters the pipeline proper. Our Interest for
/ndn/edu/ucla/cs/classis about to be decoded.
Act II: The Interest Pipeline
The Fragment Sieve: Reassembly on the Fast Path
Before our Interest enters the full pipeline, it passes through the fragment sieve. NDN Link Protocol (LP) packets can be fragmented – a single NDN packet split across multiple LP frames. The sieve collects fragments and only passes reassembled packets forward.
For our Interest, this is a no-op. A typical Interest for /ndn/edu/ucla/cs/class is well under the MTU, so it arrives as a single LP frame. The sieve checks the fragment header, sees it’s a complete packet, and passes it through immediately.
📊 Performance: The fragment sieve adds roughly 2 microseconds per packet, even on the fast path (no fragmentation). This is cheap insurance – without it, a fragmented packet would enter the TLV parser as a truncated blob and fail with a confusing decode error.
🔄 What happens next: Our Interest has survived the sieve intact. Time to find out what it actually says.
Decode: From Wire Bytes to Structured Packet
The TlvDecodeStage is where raw bytes become a structured NDN packet. It does two things:
- LP-unwraps the packet – strips the NDN Link Protocol header, extracting any LP fields (congestion marks, next-hop face hints, etc.)
- TLV-parses the bare NDN packet – determines that this is an Interest (type 0x05), decodes the name
/ndn/edu/ucla/cs/class, and creates a partially-decodedIntereststruct
#![allow(unused)]
fn main() {
let ctx = match self.decode.process(ctx) {
Action::Continue(ctx) => ctx,
Action::Drop(DropReason::FragmentCollect) => return,
Action::Drop(r) => { debug!(reason=?r, "drop at decode"); return; }
other => { self.dispatch_action(other); return; }
};
}
The word “partially” is critical here. The name is always decoded eagerly – every subsequent stage needs it. But other Interest fields like the Nonce (4 random bytes for loop detection) and InterestLifetime are behind OnceLock<T> – they’ll be decoded on first access, if and when they’re needed.
🔧 Implementation note: The
OnceLock<T>lazy decoding pattern means that a Content Store hit (coming up next) might satisfy the Interest without ever parsing the Nonce or Lifetime fields. On a cache-heavy workload, this saves measurable CPU time.
🔄 What happens next: We now have a decoded Interest with name
/ndn/edu/ucla/cs/class. But before it enters the forwarding path, someone else gets first dibs.
Discovery Hook: The Gatekeeper
After decode, the discovery subsystem gets first look at the packet. Hello Interests, service record browse packets, and SWIM protocol probes are consumed here and never enter the forwarding pipeline:
#![allow(unused)]
fn main() {
if self.discovery.on_inbound(&ctx.raw_bytes, ctx.face_id, &meta, &*self.discovery_ctx) {
return; // Consumed by discovery
}
}
Our Interest for /ndn/edu/ucla/cs/class is a normal forwarding packet, so discovery lets it pass. But if it had been a /localhop/_discovery/hello Interest, the journey would end here – handled entirely by the discovery subsystem.
🔄 What happens next: Discovery waves our Interest through. Now the real forwarding begins.
CS Lookup: The Triumphant Short-Circuit
The Content Store is checked first. If someone recently fetched /ndn/edu/ucla/cs/class and the Data is still cached, the Interest never even makes it to the PIT. The pipeline returns Action::Satisfy, and the cached Data is sent directly back to the consumer on the face the Interest arrived from.
This is the fastest possible path through the forwarder. No PIT entry created, no FIB lookup, no strategy consultation, no outbound Interest sent. The packet came in, found its answer in the cache, and went home. Total time: single-digit microseconds.
Because of the OnceLock<T> lazy decoding, a CS hit is even cheaper than it looks. The Interest’s Nonce and Lifetime fields were never decoded – they’re irrelevant when the answer is already sitting in the cache. The only field that mattered was the name, and that was decoded eagerly in the previous stage.
But today, we’re not so lucky. The Content Store doesn’t have /ndn/edu/ucla/cs/class cached. Action::Continue passes the Interest to the next stage.
💡 Key insight: Checking the Content Store before the PIT is a deliberate design choice. A CS hit satisfies the Interest with zero PIT state. If the PIT were checked first, we’d create (and then immediately clean up) a PIT entry for every cache hit – wasted work on what should be the fastest path.
🔄 What happens next: Cache miss. Our Interest needs to actually be forwarded. First, we need to record who’s waiting for the answer.
PIT Check: Leaving Breadcrumbs
The Pending Interest Table is the forwarder’s memory. It records which faces are waiting for which data, so that when a Data packet eventually arrives, the forwarder knows where to send it.
The PIT stage does three things for our Interest:
- Creates a PIT entry keyed by
(Name, Option<Selector>)– in our case,(/ndn/edu/ucla/cs/class, None) - Records the in-face – the UDP face our Interest arrived on. This is the breadcrumb: when Data comes back, follow this trail.
- Checks the nonce for duplicate suppression. Every Interest carries a random 4-byte nonce. If the same nonce for the same name has been seen recently from a different face, this Interest is a loop – drop it.
Our Interest has a fresh nonce, and there’s no existing PIT entry for this name. The stage creates a new entry and returns Action::Continue.
⚠️ Important: Interest aggregation happens here too. If a second Interest for
/ndn/edu/ucla/cs/classarrives (with a different nonce) while the first is still pending, the PIT stage adds a second in-record to the existing entry but does not forward the Interest again. When the Data returns, both consumers get it. This is one of NDN’s most powerful features – natural multicast without any special configuration.
🔄 What happens next: PIT entry created, breadcrumb laid. Now we need to figure out where to send this Interest.
Strategy Stage: The Forwarding Decision
This is where the forwarder’s intelligence lives. The strategy stage has two jobs: find the routes, then pick the best one.
FIB longest-prefix match. The Name trie is walked from the root, matching one component at a time: ndn -> edu -> ucla -> cs -> class. At each level, the trie follows HashMap<Component, Arc<RwLock<TrieNode>>> links. The longest matching prefix wins – maybe there’s a FIB entry for /ndn/edu/ucla pointing to two nexthops.
Strategy selection. A parallel name trie maps prefixes to Arc<dyn Strategy> implementations. The strategy for /ndn/edu/ucla might be the default BestRoute, or it might be a custom multicast strategy. The strategy receives an immutable StrategyContext – it can see the FIB entry, the PIT token, and the measurements table, but it cannot mutate global state.
Forwarding decision. The strategy returns one of:
Forward(faces)– send the Interest to these facesForwardAfter { delay, faces }– probe-and-fallback: try the primary face, and if no Data arrives withindelay, try the fallback facesNack(reason)– no route, or strategy decides to suppressSuppress– do nothing (used for Interest management)
The default BestRoute strategy selects the lowest-cost nexthop from the FIB entry. Our Interest for /ndn/edu/ucla/cs/class is enqueued on the selected outgoing face – say, another UDP face pointing toward a UCLA campus router.
The Interest leaves the forwarder. Now we wait.
🔄 What happens next: The Interest has been forwarded. Somewhere upstream, a producer (or another forwarder’s cache) will generate a Data packet. When it arrives, Act III begins.
The Packet Lifecycle at a Glance
Before we follow the Data return path, here’s the full state machine. Every packet eventually reaches one of the terminal states: Satisfied, Dropped, or Expired.
stateDiagram-v2
[*] --> Received: Raw bytes arrive on face
Received --> Decoded: TlvDecodeStage\n(LP unwrap + TLV parse)
Decoded --> Discovery: Discovery hook check
Discovery --> Consumed: Discovery packet\n(hello/probe/service)
Consumed --> [*]
Discovery --> Checked: Forwarding packet
state Checked {
[*] --> CSLookup
CSLookup --> CacheHit: Name match found
CSLookup --> PITCheck: Cache miss
PITCheck --> Duplicate: Same nonce detected
PITCheck --> Aggregated: Existing PIT out-record
PITCheck --> StrategyStage: New Interest
StrategyStage --> Forwarded: Forward to nexthop(s)
StrategyStage --> Nacked: No route / suppressed
}
CacheHit --> Satisfied: Data sent to in-face
Forwarded --> WaitingForData: PIT entry active
Duplicate --> Dropped
Nacked --> Dropped
WaitingForData --> Cached: Data arrives, CS insert
Cached --> Satisfied: Fan-out to in-record faces
WaitingForData --> Expired: PIT timeout
Satisfied --> [*]
Dropped --> [*]
Expired --> [*]
Aggregated --> [*]
Act III: The Data Returns
Closing the Loop
A Data packet for /ndn/edu/ucla/cs/class arrives on the outgoing face. A producer upstream – or another forwarder’s cache – has answered our Interest. The Data enters the pipeline through the same front door: face recv loop, mpsc channel, batch drain, fragment sieve, decode stage.
But this time, the decode stage identifies the packet as a Data (TLV type 0x06) instead of an Interest. The pipeline switches to the data path.
💡 Key insight: Interests and Data share the same inbound path through decode. The fork happens after decoding, based on the packet type. This means a single pipeline runner handles both directions – there’s no separate “data pipeline process.”
PIT Match: Following the Breadcrumbs
Remember the PIT entry we created in Act II? The Data’s name is /ndn/edu/ucla/cs/class – exactly matching our pending entry. The PIT match stage:
- Looks up the entry by name
- Collects all in-record faces – these are the consumers waiting for this Data. In our case, it’s the UDP face the original Interest arrived on. If Interest aggregation had occurred, there might be multiple faces here.
- Removes the PIT entry – its job is done
On no match, the Data is unsolicited – nobody asked for it. Unsolicited Data is dropped immediately. This is a security feature: the forwarder never forwards Data that wasn’t requested, preventing cache pollution attacks.
⚠️ Important: The PIT entry removal is atomic with the match. Between the match and the removal, no new Interest for the same name can sneak in and miss the Data. The
DashMapprovides this guarantee through its entry API.
🔄 What happens next: We have the Data and we know who’s waiting for it. But before we deliver it, we might want to verify it and cache it.
Validation: Trust but Verify
ValidationStage always runs. Security is on by default: if no SecurityManager is configured, the stage still verifies cryptographic signatures (AcceptSigned behaviour) — it just skips the certificate chain walk and namespace hierarchy check. To turn off all validation, you must explicitly set SecurityProfile::Disabled.
The SecurityProfile enum controls what the stage does:
| Profile | Behaviour |
|---|---|
Default (no SecurityManager) | Crypto-verify the signature; accept if valid. No chain walk. |
Default (with SecurityManager) | Full chain validation: trust schema + cert fetching + trust anchor check. |
AcceptSigned | Crypto-verify only; no chain walk. Explicit equivalent of the fallback above. |
Disabled | Skip all validation — every Data passes through unchecked. Must be set explicitly. |
Custom(validator) | Caller-supplied Validator with full control. |
The stage may produce three outcomes for each Data packet:
Action::Satisfy(valid) – the signature checks out; the Data is promoted toSafeDataAction::Drop(invalid signature) – cryptographic verification failed; discardAction::Pending(certificate needed) – the certificate for the signing key isn’t in the cache yet; a side-channel Interest is issued to fetch it
Pending packets are queued and re-validated by a periodic drain task once the missing certificate arrives. See the Security Model deep dive for the full certificate chain walk.
🔧 Implementation note: The
SafeDatatypestate ensures that code expecting verified data cannot accidentally receive unverified data – the compiler enforces the boundary regardless of whichSecurityProfilewas chosen.
CS Insert: Caching for the Future
The Data is inserted into the Content Store. The next Interest for /ndn/edu/ucla/cs/class – maybe from a different consumer, maybe from the same one a minute later – will get a cache hit in the CS lookup stage and never need to be forwarded at all.
#![allow(unused)]
fn main() {
let action = self.cs_insert.process(ctx).await;
self.dispatch_action(action);
}
Then comes the fan-out. The dispatch_action method sends the Data wireformat to every in-record face collected from the PIT match. Our original consumer’s UDP face receives the Data bytes. The face’s send task transmits them back to the consumer application.
The loop is closed. The student’s laptop receives the course data it asked for. The Interest left a breadcrumb trail through the PIT, the Data followed it home, and a cached copy now sits in the Content Store for the next person who asks.
📊 Performance: The Content Store stores wire-format
Bytes, not decoded structures. A future cache hit can send the cached bytes directly to a face without re-encoding. Zero-copy in, zero-copy out.
Act IV: When Things Go Wrong
The Nack Pipeline
Not every Interest gets answered with Data. Sometimes the upstream forwarder has no route, or the producer is unreachable. In these cases, a Nack arrives – a negative acknowledgment carrying the original Interest and a reason code.
When a Nack arrives for our previously forwarded Interest, the nack pipeline:
- Looks up the PIT entry by the nacked Interest’s name – the entry is still there, waiting
- Builds a
StrategyContextwith the FIB entry and measurements – the strategy needs the full picture - Asks the strategy what to do via
on_nack_erased:Forward(faces)– try alternate nexthops. The original Interest bytes are re-sent on a different face. Maybe there’s a backup route to UCLA.Nack(reason)– give up. Propagate the Nack back to all in-record consumers. The student’s application receives a Nack and can decide whether to retry.Suppress– silently drop. Used when the strategy has already initiated a retry viaForwardAfter.
💡 Key insight: The strategy’s ability to retry on alternate nexthops is what makes NDN resilient to path failures. A
BestRoutestrategy with two nexthops will try the second one when the first returns a Nack – automatic failover without any application-level retry logic.
Epilogue: The Architecture in Perspective
What we’ve traced is a single Interest-Data exchange, but the design scales to millions of concurrent flows. The key structural decisions that make this possible:
Arc<Name>– names are shared across PIT, FIB, and pipeline stages without copyingbytes::Bytes– zero-copy slicing from socket buffer through to Content StoreDashMapfor PIT – no global lock on the hot path; concurrent Interests for different names never contendOnceLock<T>for lazy decode – fields parsed only when accessed, saving CPU on cache hitsSmallVec<[NameComponent; 8]>for names – stack-allocated for typical 4-8 component names, heap-allocated only for unusually deep hierarchies- Compile-time pipeline – no virtual dispatch on the per-packet hot path; the compiler sees through every stage boundary
The pipeline processes each packet in isolation, but the shared state – PIT, FIB, CS, measurements – creates the emergent behavior that makes NDN work: Interest aggregation, multipath forwarding, ubiquitous caching, and loop-free routing. All from following a packet through a few stages.