doublezerod: add periodic kernel route reconciliation#3672
Conversation
bd203a8 to
99d373a
Compare
Route Reconciliation Performance Analysis
CPU cost per reconciliation cycle
Estimation methodologyLock hold (step 1): Map iteration over Netlink dump (step 2): Map build + diff (step 3): For each kernel route, we call Amortized CPU: Lock contention with HandleRxThe lock is not held during the expensive netlink syscall (step 2). The snapshot in step 1 holds Practical impact on doublezerod CPU usageGiven a ~3% baseline CPU on a modern x86 core, this change adds effectively zero overhead at realistic route counts (low hundreds). The 1M route case is pathological for a doublezerod client and would have other scaling bottlenecks (BGP convergence, session state memory, netlink install throughput) long before reconciliation matters. |
There was a problem hiding this comment.
- The
0 disableskill switch doesn't work —Validate()rewrites 0 to the 30s default, so there's no way to turn this new periodic dataplane writer off during staged rollout. - Excluded routes churn forever — on any host using a route exclude list, the reinstall counter and a Warn log fire every tick, permanently, defeating the metric's purpose.
Details inline.
PR-description corrections (not line-anchored):
- The description claims a unit test for "incrementing the install failure metric on RouteAdd error". No such test exists — the PR adds three (ReinstallsMissing, SkipsPresent, SkipsUninstalled). Either add it (a few lines with the existing mock) or drop the claim.
- It claims to add
doublezero_liveness_route_install_failures_total. That metric already existed; this PR only adds a new increment site (the new metric isdoublezero_liveness_route_reinstalls_total). - "Prevent TOCTOU race" overstates it — see the inline comment; the race is narrowed, not closed.
|
@ben-dz thanks for the thorough review — all points addressed in 789f442, with per-comment replies inline. Summary: Should-fix (inline):
PR-description corrections:
|
ben-dz
left a comment
There was a problem hiding this comment.
Post-fix review at af90168. The six fixes from the prior review round all hold up, and production correctness is sound for the IBRL case (verified Table=254/RT_TABLE_MAIN, Protocol=RTPROT_BGP, explicit Src round-trip; RouteAdd is idempotent RouteReplace). No critical/high issues. Three findings worth attention: (M1) the resurrection race is closed for onSessionDown but a symmetric narrow window remains on the passive-mode WithdrawRoute path, which deletes the kernel route before clearing installed — so the PR's "Prevent TOCTOU race" claim is accurate only for the onSessionDown ordering; (M2) the new reconcile tests are partly fictional — they use table 100 (production is 254, which RouteByProtocol filters out) and the SkipsPresent test returns the identical *Route pointer, so kernel-vs-desired key matching is never genuinely exercised; (L1) the RouteAdd syscall is run while holding m.mu, a deviation from the file's no-lock-across-syscall convention (a documented, reviewer-endorsed tradeoff).
Findings not anchored to the current diff:
client/doublezerod/internal/liveness/manager.go:451— medium: Resurrection race still open on the passive-mode WithdrawRoute path. This branch issuesRouteDelete(line 451) before clearinginstalled[rk](line 463) — the opposite ordering fromonSessionDown(clears at :838, deletes at :890), which is the ordering the TOCTOU fix depends on. If reconcile snapshots the route as installed, then passive WithdrawRoute runs its RouteDelete here but is preempted before acquiring the lock at :459, reconcile's kernel query (:945) sees the route missing, takes the lock at :991 withinstalled[rk]still true, and re-adds the route. WithdrawRoute then clears the maps, leaving a permanent stale kernel route the manager believes is withdrawn. Likelihood is low but the consequence is a stale dataplane route to a withdrawn destination. Fix: clearinstalled[rk]/desired[rk]under the lock before RouteDelete here, mirroring onSessionDown. The PR's "Prevent TOCTOU race" claim is accurate only for the onSessionDown ordering.
| return true | ||
| } | ||
|
|
||
| func TestClient_Liveness_Manager_ReconcileRoutes_ReinstallsMissing(t *testing.T) { |
There was a problem hiding this comment.
Reconcile tests don't reproduce production kernel filtering or representation. newTestRoute (main_test.go:71) defaults to Table:100, but liveness runs only in IBRL mode (RT_TABLE_MAIN=254, services/ibrl.go:64), and RouteByProtocol sets only RT_FILTER_PROTOCOL so it returns only main-table routes — a table-100 route would never come back from the real backend. SkipsPresent passes only because the mock returns the identical &r.Route pointer the manager installed, guaranteeing a key match regardless of table and bypassing real representation differences (4-byte vs 16-byte net.IP, prefsrc echo, mask normalization). Recommend using Table:254 and having the mock return a freshly-constructed *routing.Route with the same field values so key construction is genuinely exercised on both sides.
There was a problem hiding this comment.
Fixed in ecca28b. newTestRoute now defaults to Table: unix.RT_TABLE_MAIN (254) so the reconcile tests use the same table liveness actually attaches to in IBRL mode and that RouteByProtocol returns. SkipsPresent now returns a freshly-constructed *routing.Route with the same field values instead of the identical &r.Route pointer, so kernelKey construction is genuinely exercised on both the kernel and desired sides (4-byte vs 16-byte net.IP, prefsrc echo, mask normalization) rather than matching by pointer identity.
af90168 to
daddc70
Compare
|
Addressed the post-fix review findings in daddc70: M1 — passive-mode M2 — reconcile tests now reflect production. Changed the default test-route table to L1 — |
5f28638 to
ecca28b
Compare
|
Addressed the post-fix review (M1/M2/L1) in ecca28b, and rebased the branch onto latest
The six fixes from the prior round are unchanged and intact through the rebase. Full |
|
Restructured in 4a4ffc9: route reconciliation is now fully independent of the route-liveness subsystem. Why. In the prior revisions reconciliation lived inside the liveness manager, so disabling liveness ( How. New Prior review feedback carries over structurally:
New lifecycle handling the decorator position surfaced:
Ten unit tests in the new package cover all of the above; the liveness manager's reconcile code, metric, and tests are removed (the passive |
Add a reconciliation loop to the liveness manager that periodically scans the kernel routing table for missing BGP routes and reinstalls them, mitigating connectivity loss caused by external processes removing routes. Also promote liveness session down logs from DEBUG to INFO for passive/peer-passive modes so operators can see the full up/down lifecycle.
Increment RouteInstallFailures counter when a reconciliation reinstall fails, matching the observability pattern in onSessionUp. Also pre-allocate the toCheck slice.
- Re-check installed state under lock before RouteAdd to prevent resurrecting routes intentionally withdrawn by onSessionDown - Add SrcIP to kernel route lookup key for tighter matching in multi-interface setups - Reject negative RouteReconcileInterval in Validate() - Use named const for reconcile interval flag default - Log when route reconciliation is enabled at startup
- Let RouteReconcileInterval=0 disable reconciliation (restore the kill switch); drop the duplicate default constant in the liveness package. - Skip excluded destinations in reconcileRoutes so they no longer churn the reinstall counter and logs every tick. - Hold m.mu across the installed re-check and RouteAdd to close the reconcile/onSessionDown TOCTOU race. - Match kernel routes by full destination prefix (Dst.String()) instead of IP only. - Document the main-table assumption in Netlink.RouteByProtocol. - Add tests for excluded-route skip, install-failure metric on reinstall error, and the 0-disables validation.
The isis_global_state_latest / isis_overload_bit_latest assertions read the views immediately after inserting, and under CI load the just-inserted rows were not yet visible on the pooled read connection, returning 0 rows. Retry the read until the expected rows appear so the test is deterministic.
A multi-row VALUES list with placeholders is not reliably bound by the clickhouse-go database/sql driver and can silently drop rows, leaving the isis_global_state_latest / isis_overload_bit_latest views empty so the test times out waiting for 2 rows. Insert each row in its own single-row INSERT, which the driver binds reliably.
The read polling added earlier was based on a misdiagnosis: rows appeared missing not because of read-after-write visibility delay but because the multi-row placeholder INSERT silently dropped rows. With single-row inserts the acked data is immediately queryable, so revert selectAll to a direct read.
… reconcile tests Clear installed[rk]/desired[rk] under the lock before the kernel RouteDelete in passive-mode WithdrawRoute, mirroring onSessionDown. The previous ordering deleted the kernel route first, leaving a window where reconcileRoutes could observe the route missing while installed[rk] was still true and resurrect a route the manager believed was withdrawn. Make the reconcile tests reflect production: default test routes to RT_TABLE_MAIN (RouteByProtocol only returns main-table routes) and have SkipsPresent return a freshly-constructed *routing.Route instead of the identical pointer, so kernelKey matching is genuinely exercised on both sides. Add a test asserting the passive WithdrawRoute clear-before-delete ordering.
Move periodic kernel route reconciliation out of the liveness manager into a standalone internal/reconcile package: a transparent routing.Netlinker decorator at the base of the routing chain (just above raw netlink). It records every BGP route actually pushed to the kernel and reinstalls any that go missing on a configurable tick. Because every kernel route write bottoms out at this layer, reconciliation now works with liveness in passive or active mode and equally with liveness disabled entirely, so a tenant opting out of route liveness is not prevented from using route reconciliation. Lifecycle correctness carried over from the manager-based design and covered by tests: - RouteDelete untracks under the lock before the kernel delete, and the reinstall re-checks the tracked set under the same lock, closing the withdraw/reconcile resurrection race on every withdrawal path. - Untracking is protocol-agnostic because BGP withdraw-driven deletes are constructed without a Protocol; filtering would leak entries. - TunnelDelete purges tracked routes via the tunnel's remote overlay nexthop, since NoUninstall teardowns (IBRL-with-allocated-IP) never issue a RouteDelete and the kernel drops routes with their link. - The decorator sits below ConfiguredRouteReaderWriter, so excluded destinations never reach it and are never tracked or churned. Rename the flag to -route-reconcile-interval (default 30s, 0 disables) since it is no longer a liveness knob, and rename the metrics to doublezero_route_reconcile_reinstalls_total and doublezero_route_reconcile_failures_total to avoid confusion with the onchain reconciler's doublezero_reconciler_* metrics.
4a4ffc9 to
ea190d3
Compare
The runtime container tests (build tag container_tests) call runtime.Run directly and were not updated for the new routeReconcileInterval parameter. Pass 0 to keep reconciliation disabled in these tests, matching their behavior before the parameter existed.
Resolves: #3669
Summary of Changes
doublezerod: BGP routes deleted from the kernel by another process or an administrator are detected and reinstalled on a configurable tick (-route-reconcile-interval, default 30s;0disables)routing.Netlinkerdecorator (internal/reconcile) at the base of the routing chain, just above raw netlink. Every kernel route write bottoms out at this layer, so it works with liveness in passive or active mode and equally with liveness disabled — a tenant opting out of route liveness can still use route reconciliationdoublezero_route_reconcile_reinstalls_totalanddoublezero_route_reconcile_failures_total. Theroute_reconcilenaming (flag, metrics, log prefix) is deliberately distinct from the existing onchain services reconciler (-reconciler-poll-interval,doublezero_reconciler_*) to avoid confusionRouteDeleteuntracks under the lock before the kernel delete, and the reinstall re-checks the tracked set under the same lock, closing the withdraw/reconcile resurrection race on every withdrawal pathProtocol, so a protocol filter would leak entries and resurrect withdrawn routesTunnelDeletepurges tracked routes via the tunnel's remote-overlay nexthop:NoUninstallteardowns (IBRL-with-allocated-IP) never issue aRouteDelete, and the kernel drops routes with their link — without the purge the reconciler would try to reinstall onto a dead tunnel foreverConfiguredRouteReaderWriter, so excluded destinations never reach it and are never tracked or falsely reinstalledDst.String()) plus normalized source/nexthop IPs, so a10.0.0.0/16kernel route does not satisfy a tracked10.0.0.0/24, and 4-byte vs 16-bytenet.IPforms compare equalDesign history
Earlier revisions implemented reconciliation inside the liveness manager (keyed off its
installed[]map); review feedback on that design (kill switch, excluded-route churn, TOCTOU ordering, prefix matching, main-table filtering) is preserved in the review threads and all of it carries over into the decorator implementation. The restructure was prompted by the manager-based design making reconciliation unusable when route liveness is disabled.Testing Verification
internal/reconcileunit tests cover: reinstalling a missing route (+ reinstall metric), skipping a route present in the kernel (kernel echo is a freshly-constructed route in 16-byte IP form, exercising key normalization rather than pointer identity), prefix-mismatch reinstall (/24kernel route does not satisfy tracked/32), protocol-agnostic untrack on withdraw-shaped deletes, withdraw-during-reconcile not resurrected, non-BGP routes untracked, failure metric increments and the route stays tracked for retry,TunnelDeletepurging only that tunnel's routes, excluded destinations never tracked (verified through a realConfiguredRouteReaderWriterlayered above the reconciler), and0interval disabling the tickerWithdrawRouteclear-before-delete ordering test remainsRouteByProtocol's main-table-only behavior is documented on the method and noted at the reconcile call site