Skip to content

Commit efe3688

Browse files
authored
Merge pull request #20 from FastNetMon/fix/rib-gc-replay-lifetimes
Bound retired-ingress GC and preserve BMP replay identities
2 parents 052c6f9 + aa97820 commit efe3688

12 files changed

Lines changed: 1181 additions & 137 deletions

File tree

‎Cargo.lock‎

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ default-members = [".", "tools/bmpdump"]
3939
[workspace.dependencies]
4040
routecore = { git = "https://github.com/FastNetMon/routecore.git", features = ["bgp", "bmp", "serde", "fsm", "mrt"] }
4141
inetnum = { git = "https://github.com/FastNetMon/inetnum.git", tag = "v0.1.1-fnm1", features = ["arbitrary", "serde"] }
42-
rotonda-store = { git = "https://github.com/FastNetMon/netom-store.git", branch = "fnm-gc" } # fork: Layer A remove_mui
42+
rotonda-store = { git = "https://github.com/FastNetMon/netom-store.git", rev = "1a58fe44b4eb5b0e68a7b0a07558ebfb692b6209" } # batched, concurrent-safe reclamation
4343
log = "0.4"
4444

4545

@@ -51,6 +51,7 @@ chrono = { version = "0.4", default-features = false, features = ["s
5151
# always documented but that a features = ["cargo"]-only clap never generated.
5252
clap = { version = "4.5", default-features = false, features = ["cargo", "help", "usage", "error-context", "std"] }
5353
crossbeam-utils = "0.8"
54+
roaring = "0.10"
5455
dashmap = "6.1" # O(1) concurrent map for the per-router metrics hot path
5556
fern = "0.7"
5657
futures = "0.3"

‎docs/planning/RIB_GC.md‎

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# Batched RIB reclamation and BMP replay safety
2+
3+
## Problem and change
4+
5+
Reclaiming retired ingress IDs previously traversed the complete table for
6+
each ID while holding the withdrawal mutex across the batch. Large retirement
7+
batches could delay unrelated withdrawals and reclamation. Delayed BMP replay
8+
also depended on mutable registrations that could disappear before queued
9+
updates were emitted.
10+
11+
The replacement scans prefix keys once for a batch of permanently retired IDs.
12+
It deletes matching records under each prefix's mutation lock without cloning
13+
path attributes. Netom releases its withdrawal lock after each prefix, drops
14+
all epoch guards before scheduling pauses, and pauses for 1 ms every 512 prefixes.
15+
This is cooperative pacing, not a hard real-time latency guarantee. A single
16+
expensive prefix operation or an unrelated session reset can still take longer.
17+
18+
Reusable-session reset remains synchronous and distinct from permanent
19+
retirement. It must finish before that session's fresh routes are accepted.
20+
The store fixes concurrent existence-bit/counter changes and serializes bitmap
21+
writes internally. Reannouncements no longer increment the total record gauge.
22+
23+
## Ownership and ordering invariants
24+
25+
- A retirement writer fence drains admitted RIB writes before unlinking an
26+
eligible registration. Later writes to that ID are rejected. Numerical
27+
retired-ID tombstones carry no metadata or route references. IDs never wrap
28+
and get reused within the same process.
29+
- Reconnect claims retain their existing atomic registry checks. A claimed live
30+
session is excluded from GC. Whole-session resets preserve existing ordering;
31+
family-specific reset behavior is unchanged.
32+
- Pending cleanup tickets remain owned by the RIB until every store succeeds.
33+
Retired routes are marked withdrawn in batches before physical removal. A
34+
failed pass keeps its ticket and can restart idempotently. Progress is not
35+
persisted across process restart because the RIB itself is memory-only.
36+
- Per-prefix insert/delete locks cover record mutation, existence bits and
37+
counters. Empty-to-nonempty reinsertion restores both sets of store counters.
38+
Physical removal rejects persistent stores rather than silently leaving a
39+
disk copy that might reappear.
40+
- BMP dump-buffer entries own immutable snapshots of their original ingress,
41+
parent peer and router metadata. Shared snapshots use weak cache entries;
42+
they do not own the RIB, client, input handler or mutable registry.
43+
- Replay looks up metadata only in its owned snapshot, never in the current
44+
registration. Session generation changes invalidate cached output headers;
45+
an older generation cannot reopen a peer after a newer one was emitted.
46+
The per-client output mutex orders cache transitions and message enqueueing.
47+
- A Peer Down uses the header previously emitted to that consumer when available.
48+
Missing identity causes a reconnect, never an unspecified/default header.
49+
Child-to-parent/path mapping and raw-forwarding coverage are invalidated at
50+
their corresponding lifecycle boundaries.
51+
- On writer disconnect the client is removed and its dump buffer is discarded
52+
immediately. Failed/expired dump walks do not emit successful End-of-RIB
53+
completion. Existing bounded queues, write timeouts and nonblocking live
54+
sends remain in effect.
55+
56+
## Costs and limits
57+
58+
Prefix-key snapshots use O(prefix count) memory, not O(route variants); they are
59+
not a strict constant-memory cursor. Deletion target sets use O(retired IDs).
60+
The numerical retired-ID bitmap grows with lifetime churn, but does not pin
61+
session metadata or route attributes. A future change to recycle IDs would
62+
require a different lifetime scheme, not just removing the overflow check.
63+
64+
Mutation stripes add an uncontended lock to insertions. Reclamation yields
65+
reduce its throughput in exchange for responsiveness. Reconnect reset and
66+
withdrawn-attribute compaction can still hold the withdrawal lock for a scan;
67+
this patch does not make those operations asynchronous.
68+
69+
Replay metadata is conservatively charged to each queued event, even when
70+
several events share one allocation. Consumers may reach the configured byte
71+
cap sooner and reconnect. Output generation history and snapshot lookup caches
72+
are bounded; a consumer exceeding generation-history capacity requests a fresh
73+
dump rather than forgetting the ordering fence. Already active dump peers can
74+
exceed the nominal history cap; new identities trigger a reconnect at the cap.
75+
76+
Missing metadata before an event reaches the restreamer's buffering boundary
77+
still requires a consumer reconnect. This patch does not provide a durable
78+
archive of all upstream events. It protects owned queued snapshots and rejects
79+
unsafe identity reconstruction.
80+
81+
## Monitoring
82+
83+
The `/metrics` endpoint exports:
84+
85+
- `netom_rib_gc_pending_ids_total`: outstanding retirement tickets (gauge).
86+
- `netom_rib_gc_prefixes_scanned_total`: cumulative scanned prefixes.
87+
- `netom_rib_gc_records_removed_total`: cumulative physically removed records.
88+
- `netom_rib_gc_completed_batches_total`: successful passes.
89+
- `netom_rib_gc_failed_batches_total`: failed passes kept for retry.
90+
91+
Progress is logged approximately every five seconds during a long pass.
92+
Check that the backlog drains while ingestion counters continue advancing;
93+
watch reconnect frequency, BMP receive queues, RSS and process CPU as well.
94+
A lower CPU number alone is not evidence of correct reclamation.
95+
96+
## Validation and deployment
97+
98+
Regression coverage includes batch/default-route removal across both families,
99+
concurrent insertion/deletion, bitmap writers, duplicate announcements, a
100+
retirement admission barrier, owned route snapshots, deferred and partially
101+
failed cleanup, replay after registry deletion and same-ID reconnect, rejection
102+
of old generations, and missing Peer Down identity.
103+
104+
Manual performance case in netom-store:
105+
106+
```sh
107+
cargo test --release --test remove_mui batch_reclamation_workload -- --ignored --nocapture
108+
```
109+
110+
For 2,048 prefixes and 128 IDs, repeated single-ID calls took approximately
111+
112 ms versus 6.7 ms for one batch on the development machine. This is a small
112+
algorithm comparison, not a production-table latency or memory guarantee.
113+
114+
Before production rollout, replay representative traffic on an isolated
115+
instance and compare route sets, BMP identities, update latency and memory.
116+
The library commit must be pushed before publishing a Netom commit that pins
117+
it. Deploy by restarting Netom with the new binary; its existing in-process
118+
collector cannot be replaced or safely killed independently. A restart needs
119+
an upstream replay and downstream redump. Roll back the binary and restart if
120+
route/peer identity, progress or latency checks regress. No production service
121+
was changed during development.
122+
123+
### Development verification (2026-10-22)
124+
125+
- `cargo test --locked --offline --lib`: 364 passed, 31 existing ignored,
126+
zero failures, using the pinned store revision.
127+
- Full `netom-store` unit/integration/doc suite: passed; the manual benchmark
128+
remains ignored by default.
129+
- `scripts/e2e-addpath-bmp.sh`: passed for raw and rebuilt output, unicast and
130+
FlowSpec path IDs, reconnect dumps, and exactly-once Peer Down.
131+
- `CHURN=5000 scripts/addpath-churn.sh`: passed; retired child entries and route
132+
records returned to zero with the live parent/session retained.
133+
- `python3 scripts/bmp-peer-churn.py --binary target/debug/netom --cycles 20`:
134+
all eight scenarios passed, including silent and partial-frame recovery.
135+
- Changed Rust files pass rustfmt; both repositories pass `git diff --check`.
136+
137+
No production-scale canary or deployment has been performed. The initial
138+
sandboxed suite was unable to bind test sockets; the results above are from
139+
rerunning with loopback access.
140+
141+
### PR preparation verification (2026-11-09)
142+
143+
The patch excludes the separate BMP automatic-reconciliation feature. The store dependency is pinned to published
144+
commit `1a58fe44`, proposed against the consumed `fnm-gc` branch in
145+
[netom-store PR #2](https://github.com/FastNetMon/netom-store/pull/2).
146+
147+
- Netom library tests: 353 passed, 31 existing ignored.
148+
- Store default suite: 31 passed; full feature suite: 32 passed. Each retains
149+
one ignored manual performance test.
150+
- BMP ADD-PATH live/rebuilt forwarding, FlowSpec, reconnect dump and exactly-once
151+
Peer Down checks passed against the release build.
152+
- The 5,000-path churn regression reclaimed every withdrawn child and record.
153+
- Build-source checks, release metadata tests, changed-file rustfmt checks and
154+
`git diff --check` passed. Store Clippy passes with warnings denied.
155+
156+
These are development checks. Steady-state memory and deployment-scale replay remain rollout checks.

‎src/ingress/register.rs‎

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,54 @@ impl Register {
270270
}
271271
}
272272

273+
pub(crate) fn session_identity(
274+
&self,
275+
id: IngressId,
276+
) -> Option<(IngressId, u64)> {
277+
let entries = self.info.read().unwrap();
278+
let source = entries.get(&id)?;
279+
let emit = if source.ingress_type == Some(IngressType::BgpPath) {
280+
source.parent_ingress?
281+
} else {
282+
id
283+
};
284+
Some((emit, entries.get(&emit)?.history_generation))
285+
}
286+
287+
/// Copy a bounded lineage under one lock: child -> peer -> router.
288+
/// Missing ancestors or cycles cannot be silently encoded as default peers.
289+
pub(crate) fn snapshot_lineage(
290+
&self,
291+
id: IngressId,
292+
inline: Option<&IngressInfo>,
293+
) -> Option<Vec<(IngressId, IngressInfo)>> {
294+
let entries = self.info.read().unwrap();
295+
let first = inline.cloned().or_else(|| entries.get(&id).cloned())?;
296+
let mut snapshot = vec![(id, first)];
297+
while let Some(parent) = snapshot.last()?.1.parent_ingress {
298+
if snapshot.len() >= 8
299+
|| snapshot.iter().any(|(id, _)| *id == parent)
300+
{
301+
return None;
302+
}
303+
snapshot.push((parent, entries.get(&parent)?.clone()));
304+
}
305+
Some(snapshot)
306+
}
307+
308+
pub(crate) fn snapshot_matches(
309+
&self,
310+
snapshot: &[(IngressId, IngressInfo)],
311+
) -> bool {
312+
let entries = self.info.read().unwrap();
313+
snapshot.iter().all(|(id, old)| {
314+
entries.get(id).is_some_and(|new| {
315+
new.history_generation == old.history_generation
316+
&& new.history_revision == old.history_revision
317+
})
318+
})
319+
}
320+
273321
pub fn current_serial(&self) -> u32 {
274322
self.serial.load(Ordering::Relaxed)
275323
}
@@ -438,7 +486,7 @@ impl Register {
438486

439487
/// Request a new, unique [`IngressId`]
440488
pub(crate) fn register(&self) -> IngressId {
441-
self.serial.fetch_add(1, Ordering::Relaxed)
489+
self.serial.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |id| id.checked_add(1)).expect("ingress ID space exhausted; refusing to reuse a live-process identity")
442490
}
443491

444492
/// Retrieve the information for the given [`IngressId`]
@@ -1106,7 +1154,8 @@ mod tests {
11061154
res.update_info(id, info.clone());
11071155
assert_eq!(res.get(id), Some(info));
11081156

1109-
let mut newinfo = IngressInfo::new().with_local_asn(Asn::from_u32(65537));
1157+
let mut newinfo =
1158+
IngressInfo::new().with_local_asn(Asn::from_u32(65537));
11101159
res.update_info(id, newinfo.clone());
11111160
newinfo.history_revision = 1;
11121161
assert_eq!(res.get(id), Some(newinfo));

0 commit comments

Comments
 (0)