Skip to content

Commit 10b65d0

Browse files
fix: TemplateStore eviction cleanup, error reporting, and doc accurac… (#294)
* fix: TemplateStore eviction cleanup, error reporting, and doc accuracy (1.0.4) - AutoScopedParser source eviction now clears the evicted parser's templates from the store before drop (prevents monotonic keyspace growth in long-running multi-tenant deployments). - clear_v9_templates / clear_ipfix_templates record template_store_backend_errors on remove failures (previously swallowed via let _ = ...). - Honest doc comment on clear_*_templates in-LRU-only semantics. - set_template_store_scope / with_template_store_scope rustdoc warnings about scope-change orphan windows and AutoScopedParser overrides. - template_store_restored doc clarifies hit-not-miss semantics and TTL re-stamping behavior. Tests: - Rewrote vacuous read_through_drives_pending_flow_replay to actually queue a pending flow before any template is known, then verify read-through restores the template AND replays the queued flow. - Strengthened auto_scoped_parser_uses_per_source_scope with a cross-replica round-trip read-through assertion. - New tests: AutoScopedParser eviction store cleanup, clear_*_templates backend-error counting, IPFIX codec corruption, IPFIX LRU eviction propagation, IPFIX TemplateEvent::Restored firing. - inject_remove_failures is no longer dead code. Example: - New horizontal_scale_out_template_store example demonstrates two parser replicas sharing an InMemoryTemplateStore — replica A learns and goes away, replica B starts cold and decodes via read-through. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style: cargo fmt * test: tighten auto_scoped_parser cross-replica assertion to require Data flowset NetflowPacket::V9 can wrap a FlowSetBody::NoTemplate without returning an error, so matching only on NetflowPacket::V9(_) would have passed even if the read-through silently missed or hit the wrong scope's template. Assert FlowSetBody::Data(_) on the first flowset to actually verify the scoped read-through resolved. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9eb2db9 commit 10b65d0

7 files changed

Lines changed: 625 additions & 64 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[package]
22
name = "netflow_parser"
33
description = "Parser for Netflow Cisco V5, V7, V9, IPFIX"
4-
version = "1.0.3"
4+
version = "1.0.4"
55
edition = "2024"
66
rust-version = "1.88"
77
authors = ["Michael Mileusnich <michael.mileusnich@gmail.com>"]

RELEASES.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,76 @@
1+
# 1.0.4
2+
3+
## Fixes
4+
5+
* **`TemplateStore`: source-eviction in `AutoScopedParser` no longer leaks
6+
store entries.** When `max_sources` capacity was reached and a per-source
7+
parser was popped from the LRU, every template that source had written
8+
under its scope (e.g. `v9:10.0.0.1:2055/0`) was orphaned in the store
9+
forever. `evict_global_lru` now calls `clear_v9_templates` /
10+
`clear_ipfix_templates` on the evicted parser before dropping it, so the
11+
external store keyspace tracks the live source set.
12+
13+
* **`TemplateStore`: `clear_v9_templates` / `clear_ipfix_templates` now record
14+
backend errors.** Previously these methods called `let _ = store.remove(...)`,
15+
silently swallowing failures. They now bump
16+
`template_store_backend_errors` on each failed `remove`, matching every
17+
other store call site.
18+
19+
* **`TemplateStore`: misleading inline doc comment on `clear_*_templates`
20+
removed.** The comment claimed that after a clear, "subsequent reads do
21+
not transparently repopulate the in-process cache via read-through" — true
22+
only for templates that were in the in-process LRU at clear time. Templates
23+
evicted from the LRU before the call, or written by another parser instance
24+
under the same scope, remain reachable via read-through. The trait
25+
intentionally exposes only `get`/`put`/`remove`, not a per-scope wipe; the
26+
comment now documents this honestly.
27+
28+
* **`set_template_store_scope` rustdoc**: clarified that the scope must be
29+
set before the first `parse_bytes` call to avoid orphaning entries written
30+
under the previous scope, and that `with_template_store_scope` on a builder
31+
fed into `AutoScopedParser` is overridden by the auto-derived per-source
32+
scope.
33+
34+
* **Read-through hit semantics documented.** Clarified that a successful
35+
`TemplateStore` read-through increments `hits` (counted as a hit, not a
36+
miss) and that restored templates have their TTL re-stamped to
37+
`Instant::now()`.
38+
39+
## Tests
40+
41+
* **Rewrote `read_through_drives_pending_flow_replay`.** The previous test
42+
never queued a pending flow before the template arrived — it would have
43+
passed even if the read-through-driven pending-flow replay code were
44+
deleted. Now exercises the full path: data record arrives before any
45+
template is known → queued → template is written to the store by another
46+
replica → next data record's read-through restores the template AND
47+
triggers replay of the queued flow.
48+
49+
* **Strengthened `auto_scoped_parser_uses_per_source_scope`.** Now also
50+
validates cross-replica round-trip: a fresh `AutoScopedParser` reading the
51+
store must decode each source's data record against the correctly-scoped
52+
template.
53+
54+
* **Added eviction-cleanup test for `AutoScopedParser`** verifying that
55+
evicted source parsers' store entries are removed.
56+
57+
* **Coverage filled in** for backend `remove` failures (`inject_remove_failures`
58+
is now exercised), IPFIX-side codec corruption rejection, IPFIX-side LRU
59+
eviction propagation to the store, and IPFIX-side `TemplateEvent::Restored`
60+
firing.
61+
62+
## Examples
63+
64+
* **New example: `horizontal_scale_out_template_store`.** Demonstrates the
65+
feature's headline use case — two `NetflowParser` instances sharing an
66+
`InMemoryTemplateStore`. Replica A learns a template and goes away;
67+
replica B starts cold and decodes a data record against the template via
68+
read-through. Run with:
69+
70+
```sh
71+
cargo run --example horizontal_scale_out_template_store
72+
```
73+
174
# 1.0.3
275

376
## Features
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
//! Horizontal scale-out via a shared `TemplateStore`.
2+
//!
3+
//! Demonstrates the use case the `TemplateStore` extension point exists for:
4+
//! running multiple stateless parser replicas behind a UDP load balancer
5+
//! without source-IP-affinity routing.
6+
//!
7+
//! Replica A learns a template, writes it through to a shared store, and
8+
//! goes away. Replica B starts cold — its in-process template cache is
9+
//! empty — and immediately receives a data record for that template.
10+
//! With the store configured, replica B transparently restores the template
11+
//! from the store and decodes the record. Without the store, the same data
12+
//! record would queue in pending flows or fail to decode.
13+
//!
14+
//! In production you would back the `TemplateStore` with Redis, NATS KV,
15+
//! or similar; the in-memory store used here keeps the example self
16+
//! contained. The trait sees only opaque `Vec<u8>` payloads, so the
17+
//! protocol is identical regardless of backend.
18+
//!
19+
//! Run with:
20+
//! ```sh
21+
//! cargo run --example horizontal_scale_out_template_store
22+
//! ```
23+
24+
use netflow_parser::{
25+
InMemoryTemplateStore, NetflowPacket, NetflowParser, TemplateEvent, TemplateProtocol,
26+
};
27+
use std::sync::Arc;
28+
use std::sync::Mutex;
29+
30+
fn main() {
31+
println!("=== Horizontal scale-out demo: two parsers sharing a TemplateStore ===\n");
32+
33+
// The store any production deployment would back with Redis, NATS KV,
34+
// DynamoDB, etc. Implements the `TemplateStore` trait — get / put /
35+
// remove on opaque byte payloads.
36+
let store = Arc::new(InMemoryTemplateStore::new());
37+
38+
// ------------------------------------------------------------------
39+
// Replica A: learns a template, persists it via write-through.
40+
// ------------------------------------------------------------------
41+
println!("[replica A] starting up, will learn one template");
42+
let mut replica_a = NetflowParser::builder()
43+
.with_template_store(Arc::clone(&store) as _)
44+
.build()
45+
.expect("build replica A");
46+
47+
let template_packet = build_v9_template_packet(256, &[(8, 4), (12, 4), (1, 8)]);
48+
let result = replica_a.parse_bytes(&template_packet);
49+
if let Some(err) = result.error {
50+
panic!("template parse failed: {err}");
51+
}
52+
println!(
53+
"[replica A] learned template 256, store now has {} entr(ies)\n",
54+
store.len()
55+
);
56+
57+
// Replica A goes away — drop it. The store is the only surviving
58+
// record of the template. A real deployment might drop replica A
59+
// because it crashed, scaled down, or rolled.
60+
drop(replica_a);
61+
62+
// ------------------------------------------------------------------
63+
// Replica B: starts cold, no in-process templates. Receives a data
64+
// record for template 256 and must decode it via read-through.
65+
// ------------------------------------------------------------------
66+
println!("[replica B] starting cold (no in-process template cache)");
67+
68+
// Wire up a hook so we can observe the Restored event. In production
69+
// this is how an observability system would distinguish "template
70+
// recovered from secondary tier" from "template freshly learned from
71+
// exporter announce" — both look like cache hits in the basic metric.
72+
let restored_log: Arc<Mutex<Vec<(TemplateProtocol, u16)>>> =
73+
Arc::new(Mutex::new(Vec::new()));
74+
let restored_log_for_hook = Arc::clone(&restored_log);
75+
76+
let mut replica_b = NetflowParser::builder()
77+
.with_template_store(Arc::clone(&store) as _)
78+
.on_template_event(move |event| {
79+
if let TemplateEvent::Restored {
80+
template_id: Some(id),
81+
protocol,
82+
} = event
83+
{
84+
restored_log_for_hook
85+
.lock()
86+
.expect("poisoned")
87+
.push((*protocol, *id));
88+
}
89+
Ok(())
90+
})
91+
.build()
92+
.expect("build replica B");
93+
94+
// 16 bytes = three fields (4 + 4 + 8) matching the template above.
95+
let data_payload = [
96+
// src IP = 10.0.0.1
97+
0x0A, 0x00, 0x00, 0x01, // dst IP = 10.0.0.2
98+
0x0A, 0x00, 0x00, 0x02, // bytes = 4096
99+
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00,
100+
];
101+
let data_packet = build_v9_data_packet(256, &data_payload);
102+
103+
let result = replica_b.parse_bytes(&data_packet);
104+
if let Some(err) = result.error {
105+
panic!("data parse on replica B failed: {err}");
106+
}
107+
108+
let v9 = result
109+
.packets
110+
.into_iter()
111+
.find_map(|p| match p {
112+
NetflowPacket::V9(v) => Some(v),
113+
_ => None,
114+
})
115+
.expect("expected a V9 packet");
116+
let flowset_count = v9.flowsets.len();
117+
println!(
118+
"[replica B] decoded data packet against restored template ({} flowset(s))",
119+
flowset_count
120+
);
121+
122+
// ------------------------------------------------------------------
123+
// Observability — what metrics and events fired?
124+
// ------------------------------------------------------------------
125+
let metrics = replica_b.v9_cache_info().metrics;
126+
println!("\n[replica B] cache metrics after read-through:");
127+
println!(" hits = {}", metrics.hits);
128+
println!(" misses = {}", metrics.misses);
129+
println!(
130+
" template_store_restored = {}",
131+
metrics.template_store_restored
132+
);
133+
println!(
134+
" template_store_codec_err = {}",
135+
metrics.template_store_codec_errors
136+
);
137+
println!(
138+
" template_store_backend_err = {}",
139+
metrics.template_store_backend_errors
140+
);
141+
142+
let restored = restored_log.lock().expect("poisoned");
143+
println!("\n[replica B] TemplateEvent::Restored events:");
144+
for (protocol, id) in restored.iter() {
145+
println!(" {:?} template_id={}", protocol, id);
146+
}
147+
148+
println!("\nDone. The same protocol works for IPFIX and IPFIX-options templates.");
149+
println!(
150+
"Hot-path overhead when no store is configured is a single Option::is_none branch."
151+
);
152+
}
153+
154+
// --- packet builders --------------------------------------------------------
155+
// Minimal V9 packet construction for the demo. In production these come from
156+
// the wire — exporters announce templates, then send data records that
157+
// reference them.
158+
159+
fn build_v9_template_packet(template_id: u16, fields: &[(u16, u16)]) -> Vec<u8> {
160+
let template_record_len = 4 + fields.len() * 4; // template header + fields
161+
let flowset_len = 4 + template_record_len; // set header + record
162+
let mut pkt = Vec::new();
163+
// V9 header (20 bytes)
164+
pkt.extend_from_slice(&9u16.to_be_bytes()); // version
165+
pkt.extend_from_slice(&1u16.to_be_bytes()); // count
166+
pkt.extend_from_slice(&0u32.to_be_bytes()); // sys_up_time
167+
pkt.extend_from_slice(&0u32.to_be_bytes()); // unix_secs
168+
pkt.extend_from_slice(&0u32.to_be_bytes()); // sequence
169+
pkt.extend_from_slice(&0u32.to_be_bytes()); // source_id
170+
// Template flowset
171+
pkt.extend_from_slice(&0u16.to_be_bytes()); // flowset_id = 0 (template)
172+
pkt.extend_from_slice(&(flowset_len as u16).to_be_bytes());
173+
pkt.extend_from_slice(&template_id.to_be_bytes());
174+
pkt.extend_from_slice(&(fields.len() as u16).to_be_bytes());
175+
for &(ft, fl) in fields {
176+
pkt.extend_from_slice(&ft.to_be_bytes());
177+
pkt.extend_from_slice(&fl.to_be_bytes());
178+
}
179+
pkt
180+
}
181+
182+
fn build_v9_data_packet(template_id: u16, payload: &[u8]) -> Vec<u8> {
183+
let flowset_len = 4 + payload.len();
184+
let mut pkt = Vec::new();
185+
pkt.extend_from_slice(&9u16.to_be_bytes());
186+
pkt.extend_from_slice(&1u16.to_be_bytes());
187+
pkt.extend_from_slice(&0u32.to_be_bytes());
188+
pkt.extend_from_slice(&0u32.to_be_bytes());
189+
pkt.extend_from_slice(&0u32.to_be_bytes());
190+
pkt.extend_from_slice(&0u32.to_be_bytes());
191+
pkt.extend_from_slice(&template_id.to_be_bytes());
192+
pkt.extend_from_slice(&(flowset_len as u16).to_be_bytes());
193+
pkt.extend_from_slice(payload);
194+
pkt
195+
}

0 commit comments

Comments
 (0)