DEV Community

pathvector-dev
pathvector-dev

Posted on • Originally published at blog.pathvector.dev

Which router wins the segment? Read the election, not the docs

Originally published at https://blog.pathvector.dev/protocol-in-code-ospf-03/ — part of the free Protocol Lab series.

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. Every module points at one real Python file and walks its control flow. The source lives at pathvector-studio/protocol-in-code. If you're earlier in the journey and want to touch the protocols first — capture packets, poke at daemons, break things on purpose — start with the companion Protocol Lab series instead, then come back here.

The question

If several routers share one broadcast segment, how does the code pick DR and BDR?

Hold onto that as you read. Not "what is a DR" — you can get that from any certification slide deck. The question is how a deterministic function turns a bag of interface states into exactly one designated router and at most one backup, with no coordination beyond what every router can already see.

The file we're reading is src/protocol_in_code/ospf/dr_election.py. It is under sixty lines. Almost all of the interesting behavior lives in two of them.

Where this comes from

Session 02 left us with a loose end. On a broadcast segment, some neighbors reach 2-Way and then simply stop — they never progress to full adjacency. That looks like a bug until you know what comes next. Those routers stopped because they aren't the DR or the BDR, and on a shared segment you only form full adjacencies with those two. So the "stuck at 2-Way" observation from last session is really a pointer to this session's branch: something has to decide who the DR and BDR are, and everyone else settles into 2-Way on purpose.

That's the shape worth carrying with you: an election exists to collapse an N×N mesh of adjacencies into a hub. Without it, ten routers on one Ethernet segment would form 45 adjacencies and flood 45 copies of everything.

Read it like code

The module gives a read order, and it's the right one: the candidate shape first, then the picker, then the election that uses both.

1. What a candidate is

@dataclass(frozen=True)
class InterfaceCandidate:
    router_id: str
    priority: int
    declared_dr: bool = False
    declared_bdr: bool = False
Enter fullscreen mode Exit fullscreen mode

Four fields, and each one is doing a distinct job.

priority is the operator's thumb on the scale. router_id is the tie-break of last resort — it's guaranteed unique on the segment, so it's guaranteed to produce a total order. And then there's the interesting pair: declared_dr and declared_bdr. These are not "should this router be DR." They're "this router is currently claiming to be DR" — a self-report that arrives in the neighbor's Hello packet. That distinction is the whole reason OSPF elections are sticky rather than churning every time a better router shows up.

Note that the dataclass is frozen=True. Candidates are inputs to a pure function, not mutable state being edited in place. The election reads; it does not write.

2. The comparison

def _router_id_key(router_id: str) -> tuple[int, ...]:
    return tuple(int(part) for part in router_id.split("."))


def _pick_highest(candidates: list[InterfaceCandidate]) -> InterfaceCandidate | None:
    if not candidates:
        return None
    return max(candidates, key=lambda candidate: (candidate.priority, _router_id_key(candidate.router_id)))
Enter fullscreen mode Exit fullscreen mode

The entire ranking rule of this election is one tuple: (priority, router_id_key). Python's tuple comparison does the rest — compare priority first, and only if priority ties does router_id get consulted. This is the "why does router ID still matter after priority" answer, sitting right there in the key function.

_router_id_key deserves a second look, because it's the kind of thing that's easy to get wrong and never notice. A router ID looks like an IPv4 address, but it's a string. Compare "10.0.0.2" and "10.0.0.10" as strings and "10.0.0.2" wins, because "2" > "1" lexicographically. Splitting on . and mapping to int gives you (10, 0, 0, 2) vs (10, 0, 0, 10), and now the numeric order is correct. The election is deterministic either way — every router would agree on the wrong answer consistently — but it wouldn't match what a real OSPF implementation picks.

Note: The if not candidates: return None guard is not decoration. max() on an empty sequence raises ValueError, and an empty candidate list is a completely legitimate state — a segment where every router is configured with priority 0. The None return is what lets the caller express "no DR on this segment" as a value rather than an exception.

3. The election

def elect_dr_bdr(candidates: tuple[InterfaceCandidate, ...]) -> ElectionResult:
    eligible = [candidate for candidate in candidates if candidate.priority > 0]
    declared_dr = [candidate for candidate in eligible if candidate.declared_dr]
    designated = _pick_highest(declared_dr) or _pick_highest(eligible)

    remaining = [candidate for candidate in eligible if designated is None or candidate.router_id != designated.router_id]
    declared_bdr = [candidate for candidate in remaining if candidate.declared_bdr]
    backup = _pick_highest(declared_bdr) or _pick_highest(remaining)
    # ...
Enter fullscreen mode Exit fullscreen mode

Read it as three filters and two picks.

The eligibility filter. priority > 0 is the first line, and it's absolute. A router with priority 0 doesn't lose the election — it never enters it. It's gone from eligible, which means it's gone from declared_dr, gone from remaining, gone from declared_bdr, and gone from eligible_routers in the result. Priority 0 is not "lowest priority," it's opt-out. If you've ever wondered why the OSPF docs describe priority 0 as "ineligible" rather than "last in line," this list comprehension is the reason: there is no code path that can reach a zero-priority router after line one.

The declared-role preference. This is the line to slow down on:

    designated = _pick_highest(declared_dr) or _pick_highest(eligible)
Enter fullscreen mode Exit fullscreen mode

or short-circuits on truthiness. If anyone on the segment is already claiming to be DR, declared_dr is non-empty, _pick_highest returns a candidate, and the second _pick_highest(eligible) never runs. The general field is only consulted when nobody is already claiming the role.

That single or is the seed of non-preemption. A brand-new router with priority 255 boots onto a segment where a priority-1 router is already declaring itself DR. It does not win. It isn't in declared_dr, so it's never compared against the incumbent at all. Priority only decides who becomes DR, not who stays DR — and the code makes that structural rather than conditional. There's no if incumbent_exists branch; the preference falls out of which list gets passed to max() first.

The exclusion, then the same pattern again. BDR selection is the DR logic run a second time over a smaller set:

    remaining = [candidate for candidate in eligible if designated is None or candidate.router_id != designated.router_id]
Enter fullscreen mode Exit fullscreen mode

The filter excludes the winner by router ID, not by object identity. Since router_id is unique on a segment, that's the correct key — and it's more robust than is, which would break the moment a candidate was reconstructed rather than passed through. The designated is None clause keeps remaining equal to eligible when there was no DR to exclude, though in practice if _pick_highest(eligible) returned None then eligible was empty and remaining will be too.

Then declared_bdr filtered from remaining, _pick_highest(...) or _pick_highest(...) again. Same shape, one level down. Note what this doesn't do: the current DR is excluded from BDR consideration, but there's no promotion logic — if the DR disappears, this function just re-runs and the old BDR, still carrying declared_bdr=True, wins the declared_dr check... except it doesn't, because it declared BDR, not DR. Sit with that one; it's a real gap between this model and the RFC, and we'll come back to it.

4. The result

    return ElectionResult(
        designated_router=designated.router_id if designated else None,
        backup_designated_router=backup.router_id if backup else None,
        eligible_routers=tuple(candidate.router_id for candidate in sorted(eligible, key=lambda item: _router_id_key(item.router_id))),
    )
Enter fullscreen mode Exit fullscreen mode

Two Optional[str] slots and a sorted tuple. None for designated_router is a first-class outcome, not an error — an all-priority-0 segment genuinely has no DR.

eligible_routers is sorted by the same _router_id_key, which makes the result stable and comparable across runs regardless of the input ordering. That's a small thing that matters a lot: an election whose output representation depends on input order is an election you can't diff, snapshot, or test reliably.

Same shape, different protocol

Once you've read this election as filter → prefer-incumbent → max → tie-break, you start seeing it everywhere.

The _pick_highest(declared) or _pick_highest(all) pattern — prefer whoever is already holding the role, fall back to the best available — is structurally the same as a BGP route-selection tie-break chain, where the oldest established path wins among otherwise-equal candidates specifically to stop routes from flapping. Different protocol, different fields, identical motivation: stability is worth more than optimality once you're already converged.

And the priority == 0 filter is the same move as a DNS nameserver marked unreachable, or a TCP connection with cwnd collapsed to the point of exclusion — a participant removing itself from consideration entirely rather than competing badly. Filtering before ranking is cheaper and safer than ranking with a special-case loser.

Run it

The walkthrough is executable. From the repo root:

PYTHONPATH=src python3 examples/ospf/session_03_walkthrough.py
Enter fullscreen mode Exit fullscreen mode

Read the script at examples/ospf/session_03_walkthrough.py, then change the inputs. The instructive experiments:

  • Build a candidate set where the highest-priority router has declared_dr=False and a low-priority router has declared_dr=True. Confirm the low-priority router wins.
  • Set every priority to 0 and check what comes back in all three fields of ElectionResult.
  • Give two routers the same priority and different router IDs, one of them 10.0.0.9 and the other 10.0.0.10. Verify which wins, and convince yourself it's _router_id_key doing it.

Toy model boundary

This is a teaching model, and being clear about the gap is the point.

It's a snapshot, not a state machine. The real election in RFC 2328 §9.4 runs as an eight-step algorithm inside the interface state machine, and it runs twice — the second pass exists precisely so that a router that just made itself DR can then correctly select a BDR from the updated set. elect_dr_bdr() is a single pure pass over immutable inputs. There is no re-invocation, no convergence loop.

Non-preemption is only half-modeled. The declared_dr preference captures the spirit of non-preemption, but the real rules are more specific: a router that is already DR does not give up the role, and a router that becomes BDR is promoted to DR when the DR fails. This model has no promotion path — a former BDR carries declared_bdr=True, which does nothing for it in the declared_dr filter. Re-running the election after a DR failure gives you a plausible answer, but not the RFC's answer.

No Wait Timer. Real OSPF interfaces sit in Waiting state for RouterDeadInterval before electing anything, specifically to avoid a router electing itself DR just because it booted first and hasn't heard anyone else yet. There's no time in this model at all.

No Hello packet, no priority advertisement. InterfaceCandidate is handed to us pre-assembled. In reality every field here is learned from Hello packets on the segment, and the declared_dr/declared_bdr values are literally the Designated Router and Backup Designated Router fields of those packets — which means they can be stale, inconsistent between routers, or briefly contradictory during convergence. This model assumes every router sees the same candidate tuple.

No interface types. DR/BDR election applies to broadcast and NBMA networks. Point-to-point links don't elect anything. The model has no concept of network type, so it will happily elect a DR for a segment that shouldn't have one.

Check yourself

Don't answer these from memory — answer them by pointing at a line in dr_election.py.

  1. A router configured with priority 0 sends a Hello with declared_dr=True. Trace it through elect_dr_bdr(): at which exact line does it stop mattering, and what does eligible_routers contain?
  2. Two routers on the segment both have declared_dr=True — a genuinely possible state during convergence. Which one becomes DR, and which comparison in _pick_highest decides it? Can the loser end up as BDR?
  3. The current DR fails and drops off the segment. You re-run elect_dr_bdr() with the surviving candidates, one of which has declared_bdr=True. Does that router become the new DR? Read the filters and follow the or, then say what the RFC would have done instead.

Done when you can explain, without looking, why priority 0 disappears from the candidate set entirely rather than losing, and why router_id still decides the outcome after priority has had its say.

Further reading

  • RFC 2328 — OSPF Version 2. §9.4 is the DR election algorithm; §7.3 explains why the Designated Router exists at all.
  • RFC 2328 §9.5 — Hello packet contents, including the Designated Router and Backup Designated Router fields this model receives as declared_dr / declared_bdr.
  • src/protocol_in_code/ospf/dr_election.py — the file this article reads.

Top comments (0)