When Loc-RIB changes, every peer needs a different answer

This post is part of Protocol in Code, a free series that reads network protocols as logic — inputs, state, and branches — rather than as configuration examples. The source lives at pathvector-studio/protocol-in-code, and every module points at a real Python file you can open, read, and run. If you're earlier in the journey and want hands-on packet-level exercises first, start with the companion Protocol Lab series and come back here.

Today's module is BGP Session 12: export refresh after recompute.

The question

When Loc-RIB changes, how do outbound advertisements get refreshed per peer?

Hold that question while you read. It sounds like plumbing, but it's the question that separates two things people often collapse into one: what this router believes is the best path, and what this router tells each neighbor. Those are different pieces of state, updated at different times, by different code.

Two earlier sessions set this up. Session 08 showed how a single installed route becomes a single exported route — the policy pass that turns "I use this path" into "here is what I'll say about it." Session 09 showed how Loc-RIB can change after a session goes down and best-path selection reruns. Session 12 is the seam between them: recompute already happened, Loc-RIB already moved, and now something has to walk the peers and fix up Adj-RIB-Out.

The file to open is src/protocol_in_code/bgp/export_refresh.py, with export_policy.py as the companion it leans on.

Export is peer-specific, and the types say so

Start with the data, not the function. The first thing worth noticing is that there is no such thing as "the export" of a prefix:

@dataclass(frozen=True)
class ExportTarget:
    peer_id: str
    peer_type: PeerType
    policy: ExportPolicy

An ExportTarget bundles three things: who the peer is, what kind of peer it is (customer, upstream, and so on), and which policy applies. Nothing about the route is in here. That asymmetry is the design statement: the route is global to the router, the export decision is local to the peer.

The output type mirrors it:

class ExportChangeKind(str, Enum):
    ADVERTISE = "advertise"
    WITHDRAW = "withdraw"


@dataclass(frozen=True)
class ExportChange:
    peer_id: str
    prefix: str
    kind: ExportChangeKind
    path: PathCandidate | None

Two kinds of change, and path is None for one of them. A withdrawal carries no path — you're not telling the peer about a route, you're telling it to forget one. The type makes that impossible to get wrong.

Note: ExportChangeKind subclasses str as well as Enum. That's a small ergonomic choice — it means the value compares equal to "advertise" and serializes cleanly — not a protocol fact. Don't read anything into it.

Three values, one peer

Now the function. The whole module is one loop, and the loop body juggles exactly three values per peer. Get those three straight and you've got the session.

def refresh_exports_for_prefix(
    prefix: str,
    loc_rib: LocRIB,
    adj_rib_out: AdjRIBOut,
    targets: tuple[ExportTarget, ...],
) -> list[ExportChange]:
    installed = loc_rib.best_paths.get(prefix)
    changes: list[ExportChange] = []

installed is read once, before the loop. That's the "what this router believes" value, and it's the same for every peer — that's the whole point of Loc-RIB. Note the .get(): if recompute removed the prefix entirely, installed is None, and the loop below still runs. A prefix disappearing is not a special case here; it's the same code path with a different input.

Then, per peer:

    for target in targets:
        current_by_prefix = adj_rib_out.advertisements_by_peer.setdefault(target.peer_id, {})
        current = current_by_prefix.get(prefix)
        desired = None
        if installed is not None:
            desired = prepare_export(installed, target.peer_type, target.policy)
  • installed — what Loc-RIB says the router uses. One value, shared.
  • current — what this peer has already been told. Read out of Adj-RIB-Out, per peer.
  • desired — what this peer should be told. installed run through prepare_export() with this peer's type and policy.

desired starts at None and only becomes a path if installed exists. And even when installed exists, prepare_export() may return None — that's policy denying the export. Both roads lead to the same place, which is why the next branch doesn't care which one you took.

The branch that matters

Here's the reconciliation:

        if desired is None:
            if current is not None:
                current_by_prefix.pop(prefix, None)
                changes.append(
                    ExportChange(
                        peer_id=target.peer_id,
                        prefix=prefix,
                        kind=ExportChangeKind.WITHDRAW,
                        path=None,
                    )
                )
            continue

        if current != desired:
            current_by_prefix[prefix] = desired
            changes.append(
                ExportChange(
                    peer_id=target.peer_id,
                    prefix=prefix,
                    kind=ExportChangeKind.ADVERTISE,
                    path=desired,
                )
            )

Read it as a two-by-two table of current against desired:

current desired outcome
None None nothing — peer never knew, still doesn't
set None withdraw, and drop the entry from Adj-RIB-Out
None set advertise
set set, equal nothing — no wire churn
set set, different advertise the new value

The two "nothing" rows are the ones worth pausing on. The inner if current is not None guard means you don't emit a withdrawal for a route the peer was never told about — BGP has no way to un-say something you never said. And if current != desired means an unchanged export produces no ExportChange at all. That second one is why this is called a refresh and not a resend: recompute might touch a prefix and, after policy, produce byte-identical output for a peer. That peer hears nothing.

The desired is None branch is where withdrawal is born. Nothing upstream decided "send a withdraw" — it's an emergent consequence of Adj-RIB-Out holding a value that policy no longer justifies. Notice too that both the state mutation and the change record happen together in each branch: Adj-RIB-Out is the durable state, the returned list[ExportChange] is the delta. They never diverge because they're written in the same two lines.

That structure also answers the "why a separate pass?" part of the session. Best-path selection produces one answer per prefix. Export produces N answers per prefix, one per peer, and each one depends on peer-local state (current) that best-path selection has no business knowing about. Fusing them would mean best-path selection carrying a peer loop inside it. Splitting them means recompute can run, settle, and then hand a stable Loc-RIB to a pass whose only job is per-peer reconciliation.

Run it

The module ships a runnable walkthrough:

PYTHONPATH=src python3 examples/bgp/session_12_walkthrough.py

Three things to look for in the output:

  1. An advertisement to the customer peer. installed exists, policy permits, current was empty → ADVERTISE.
  2. No advertisement to the denied upstream peer. installed exists, but prepare_export() returns None. desired is None, current is None, so the continue fires and nothing is emitted. Silence, not a withdraw.
  3. A withdrawal when the installed path disappears. loc_rib.best_paths.get(prefix) returns None, so desired is None for every peer — but only the peers with a non-None current get a WITHDRAW.

Case 2 and case 3 both hit desired is None. Only one of them produces output. That difference is entirely current, and it's the single most useful thing to internalize from this file.

Toy model boundary

This is a toy model, and being precise about what it isn't is the point of this section.

It refreshes one prefix at a time. refresh_exports_for_prefix() takes a single prefix. A real implementation after a session teardown has thousands to tens of thousands of prefixes to reconsider, and the interesting engineering is entirely in how you batch, prioritize, and pace that work — not in the per-prefix decision.

It does not build UPDATE messages. There is no packet here. No path attribute encoding, no NLRI packing, no grouping of prefixes that share an attribute set into one UPDATE — which is the main reason real BGP UPDATEs are efficient at all. The function returns a list[ExportChange], a description of what should be true, and stops. Turning that list into wire bytes is a separate problem this module deliberately doesn't touch.

No timing. Real BGP spaces out advertisements with MRAI (Minimum Route Advertisement Interval) timers, precisely so that a flapping route doesn't turn into a flood of UPDATEs. There is no clock in this file. Every change is emitted immediately.

No withdrawal suppression or route damping. Nothing here notices that a prefix has advertised and withdrawn ten times in a minute.

Comparison by !=. if current != desired relies on PathCandidate equality. Real implementations compare over attribute sets with rules about which attributes are transitive, which are optional, and which get rewritten on export.

What the module does model faithfully is the decision itself: given what Loc-RIB holds, what Adj-RIB-Out holds, and this peer's policy, should this peer see an advertise, a withdraw, or nothing? That decision is real. Everything around it is stripped.

The same shape shows up elsewhere

Once you've seen current vs. desired reconciled against per-consumer state, you start seeing it in tracks that have nothing to do with BGP.

It's a reconciliation loop: read the intended state, read the observed state, emit the minimal set of changes to close the gap — and emit nothing when they already agree. That last property is what makes the operation safe to run repeatedly, which is the same property Kubernetes controllers are built on.

It also rhymes with cache invalidation across the series. A DNS resolver holding a record that the authoritative zone has since changed is in the same position as a peer holding a stale entry in Adj-RIB-Out: some pass has to notice the divergence and push a correction. The difference is direction — DNS pulls on TTL expiry, BGP pushes on recompute — but the state comparison at the center is identical.

Check yourself

Close the article and open the file. Can you answer these from the code alone?

  1. A peer's export policy is edited so a prefix that was permitted is now denied, but Loc-RIB is unchanged. Walk refresh_exports_for_prefix() for that peer — which branch fires, and what ends up in the returned list?
  2. refresh_exports_for_prefix() is called twice in a row with identical inputs. What does the second call return, and what in the code guarantees it?
  3. A peer has never been advertised a prefix, and best-path selection removes that prefix from Loc-RIB. Does that peer get a WITHDRAW? Which line decides, and why is that the correct protocol behavior rather than an optimization?

You're done with this session when you can explain why export refresh is a separate pass after recompute, and when you can look at current and desired for a peer and say — without running anything — whether that peer sees an advertise, a withdraw, or silence.

Further reading

Read more