Skip to content

Commit 3cc4985

Browse files
feat(metrics): cross-source de-dup win metrics (time & quantity) (#60)
* feat(metrics): cross-source de-dup win metrics (time & quantity) Record, at each de-dup contest, which source delivered first and by how much, so the edge feed can be shown winning against the original/public sources in both quantity and latency. - arbiter: StalenessFloor/WindowedDedup return an `Admit` outcome that reports the first cross-source follower per tick/trade as a `Contest` carrying the winning publisher and lead_ns. `Arbiter::emit` records dz_quote_lead_ns / dz_trade_lead_ns (histograms) and dz_trades_admitted_total. - shred: ShredPacket carries the source group + monotonic arrival; DedupWindow tracks the winning group and returns DropDuplicate{winner_group, lead_ns} on cross-group duplicates, recorded as dz_shred_wins_total / dz_shred_lead_ns. - metrics: histogram_vec helper + shared ns lead-time buckets. Recording is always on (only the HTTP exposer stays gated by --metrics-bind), matching the existing metrics design. lead_ns is clamped non-negative. * fix(metrics): split dedup lead histograms by loser; test the emit arm Address PR #60 review: - M1: add a `loser` label to dz_quote_lead_ns / dz_trade_lead_ns so an edge-vs-edge mirror race ({winner="edge",loser="edge"}) no longer dilutes the headline edge-vs-public margin ({winner="edge",loser="public"}) in multi-mirror deployments. The loser is the non-leader `publisher` already in scope at the Contest arm, so no `Admit` change is needed. - M2: add end-to-end tests through `Arbiter::emit` asserting a cross-source contest reaches the lead-time histogram (right winner/loser child) and the drop counter, not just returns Admit::Contest. Keyed on per-test unique venues so absolute counts hold without #[serial]. * test(shred): exercise cross-group DropDuplicate metric end-to-end Close the M2 gap on the shred side from the PR #60 review: the forwarder tests wrapped packets via ShredPacket::from(Vec<u8>) (sentinel group), so every duplicate was same-group and the Action::DropDuplicate -> dz_shred_wins_total / dz_shred_lead_ns arm was never driven end-to-end. Add run_forwarder_packets (explicit per-packet group + arrival) and a test sending the same shred from two distinct groups, asserting one forward plus the win-count and lead-time histogram deltas for the group that delivered first.
1 parent 700ef13 commit 3cc4985

7 files changed

Lines changed: 779 additions & 147 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- Cross-source de-duplication **win metrics**, surfacing how the edge feed beats the
12+
original/public sources in both quantity and latency at each de-dup contest:
13+
- Quotes/trades (`src/ingest/arbiter.rs`): the staleness floor and windowed dedup now report
14+
the first cross-source follower of a `source_ts` tick / `trade_id` as a contest, recording
15+
`dz_quote_lead_ns` and `dz_trade_lead_ns` histograms (labelled by `winner` **and** `loser`,
16+
each `edge`/`public`; `_count` is the head-to-head win count, the buckets are the lead margin)
17+
plus `dz_trades_admitted_total` (the trade-side mirror of `dz_quotes_admitted_total`). The
18+
`loser` label keeps an edge-vs-edge mirror race (`{winner="edge",loser="edge"}`) out of the
19+
headline edge-vs-public margin (`{winner="edge",loser="public"}`) in multi-mirror deployments.
20+
- Shreds (`src/shred/`): each datagram now carries its source multicast group and a monotonic
21+
arrival timestamp, and the dedup window records the winning group, so a duplicate from a
22+
*different* group emits `dz_shred_wins_total{winner}` and `dz_shred_lead_ns{winner}` (how far
23+
the group that delivered first led by). A same-group retransmit stays a plain drop.
24+
- Recording is always on (only the `/metrics` exposer stays gated by `--metrics-bind`); lead
25+
times are clamped non-negative.
26+
1027
### Changed
1128
- Container logs can no longer fill the host disk, and the default is quieter:
1229
- The installer's `docker run` (`scripts/connect.sh`) now pins the `json-file` log driver with

src/ingest/arbiter.rs

Lines changed: 351 additions & 95 deletions
Large diffs are not rendered by default.

src/metrics.rs

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,30 @@
1717
1818
use std::sync::OnceLock;
1919

20-
use prometheus::{IntCounter, IntCounterVec, IntGauge, IntGaugeVec, Opts, Registry};
20+
use prometheus::{
21+
HistogramOpts, HistogramVec, IntCounter, IntCounterVec, IntGauge, IntGaugeVec, Opts, Registry,
22+
};
23+
24+
/// Buckets (nanoseconds) for the de-dup *lead-time* histograms: how far ahead the winning source
25+
/// was when the losing duplicate arrived. Spans ~50µs … 1s, dense in the sub-millisecond range
26+
/// where the edge feed beats the public/cross-group copy in steady state, with a long tail for the
27+
/// tens-to-hundreds-of-ms inter-feed skew seen when a path is slow.
28+
const LEAD_NS_BUCKETS: &[f64] = &[
29+
50_000.0,
30+
100_000.0,
31+
250_000.0,
32+
500_000.0,
33+
1_000_000.0,
34+
2_500_000.0,
35+
5_000_000.0,
36+
10_000_000.0,
37+
25_000_000.0,
38+
50_000_000.0,
39+
100_000_000.0,
40+
250_000_000.0,
41+
500_000_000.0,
42+
1_000_000_000.0,
43+
];
2144

2245
/// Every metric the bridge exports, plus the [`Registry`] they are registered against. Built once
2346
/// via [`Metrics::new`] and reachable through [`metrics`].
@@ -51,8 +74,23 @@ pub struct Metrics {
5174
pub quotes_admitted: IntCounterVec,
5275
/// Quotes dropped by the staleness floor (stale tick, non-leader, or exact repeat — collapsed).
5376
pub quotes_dropped: IntCounterVec,
77+
/// Trades admitted by the windowed dedup, attributed to the winning `publisher` (edge/public) —
78+
/// the trade-side mirror of [`quotes_admitted`].
79+
pub trades_admitted: IntCounterVec,
5480
/// Trades dropped by the windowed dedup (a duplicate `trade_id` still inside the window).
5581
pub trades_dropped: IntCounterVec,
82+
/// Quote-tick *cross-source* contest lead time (ns): on a `source_ts` tick another publisher
83+
/// already led, how far ahead the leader was when this publisher's first copy arrived, labelled
84+
/// by the `winner` **and** `loser` (edge/public). Its `_count` is the head-to-head contest
85+
/// count. Labelling both ends keeps an edge-vs-edge mirror race (small, sub-ms leads in a
86+
/// multi-mirror deployment) from diluting the headline edge-vs-public margin: the buckets of
87+
/// `{winner="edge",loser="public"}` are the margin by which DZ (edge) beats the public feed,
88+
/// while `{winner="edge",loser="edge"}` is the inter-mirror skew. See [`LEAD_NS_BUCKETS`].
89+
pub quote_lead_ns: HistogramVec,
90+
/// Trade *cross-source* contest lead time (ns): when a duplicate `trade_id` arrives from a
91+
/// different publisher than the one that first delivered it, how far ahead the first was,
92+
/// labelled by the `winner` and `loser` (see [`quote_lead_ns`](Self::quote_lead_ns)).
93+
pub trade_lead_ns: HistogramVec,
5694
/// Quotes rejected for an implausibly-far-future `source_ts` before they could advance the floor.
5795
pub quotes_future_rejected: IntCounterVec,
5896
/// Quotes forwarded with the `source_ts == 0` "not available" sentinel (bypass the floor).
@@ -111,6 +149,14 @@ pub struct Metrics {
111149
pub shred_no_leader: IntCounter,
112150
/// Slots currently tracked by the dedup window.
113151
pub shred_dedup_tracked_slots: IntGauge,
152+
/// Cross-group shred contests won, by the multicast `winner` group that delivered first. Each
153+
/// increment is a duplicate from a *different* group dropped because this group's copy already
154+
/// forwarded — the head-to-head "this group beat the others" count.
155+
pub shred_wins: IntCounterVec,
156+
/// Cross-group shred contest lead time (ns): when a duplicate arrives from a different group
157+
/// than the one that first forwarded the shred, how far ahead the winner was, labelled by the
158+
/// `winner` group. See [`LEAD_NS_BUCKETS`].
159+
pub shred_lead_ns: HistogramVec,
114160
/// Per-destination forward sends, by `dest` and `outcome` (ok/error).
115161
pub shred_sends: IntCounterVec,
116162
/// Bytes successfully forwarded to each destination, by `dest` (sum of datagram lengths on a
@@ -146,6 +192,23 @@ fn gauge(reg: &Registry, name: &str, help: &str) -> IntGauge {
146192
g
147193
}
148194

195+
fn histogram_vec(
196+
reg: &Registry,
197+
name: &str,
198+
help: &str,
199+
labels: &[&str],
200+
buckets: &[f64],
201+
) -> HistogramVec {
202+
let h = HistogramVec::new(
203+
HistogramOpts::new(name, help).buckets(buckets.to_vec()),
204+
labels,
205+
)
206+
.expect("valid histogram vec");
207+
reg.register(Box::new(h.clone()))
208+
.expect("register histogram vec");
209+
h
210+
}
211+
149212
impl Metrics {
150213
fn new() -> Self {
151214
let registry = Registry::new();
@@ -220,12 +283,37 @@ impl Metrics {
220283
"Quotes dropped by the staleness floor",
221284
&["venue"],
222285
),
286+
trades_admitted: counter_vec(
287+
&registry,
288+
"dz_trades_admitted_total",
289+
"Trades admitted by the windowed dedup, by winning publisher (edge/public)",
290+
&["venue", "publisher"],
291+
),
223292
trades_dropped: counter_vec(
224293
&registry,
225294
"dz_trades_dropped_total",
226295
"Trades dropped by the windowed dedup",
227296
&["venue"],
228297
),
298+
quote_lead_ns: histogram_vec(
299+
&registry,
300+
"dz_quote_lead_ns",
301+
"Nanoseconds the winning publisher led the losing duplicate by, per quote-tick \
302+
cross-source contest, by winner and loser (edge/public). Splitting on both ends \
303+
keeps an edge-vs-edge mirror race out of the headline edge-vs-public margin: \
304+
{winner=\"edge\",loser=\"public\"} is 'DZ beats the public feed'.",
305+
&["venue", "winner", "loser"],
306+
LEAD_NS_BUCKETS,
307+
),
308+
trade_lead_ns: histogram_vec(
309+
&registry,
310+
"dz_trade_lead_ns",
311+
"Nanoseconds the winning publisher led the losing duplicate by, per trade \
312+
cross-source contest, by winner and loser (edge/public). See dz_quote_lead_ns \
313+
for why both ends are labelled.",
314+
&["venue", "winner", "loser"],
315+
LEAD_NS_BUCKETS,
316+
),
229317
quotes_future_rejected: counter_vec(
230318
&registry,
231319
"dz_quotes_future_rejected_total",
@@ -361,6 +449,20 @@ impl Metrics {
361449
"dz_shred_dedup_tracked_slots",
362450
"Slots currently tracked by the dedup window",
363451
),
452+
shred_wins: counter_vec(
453+
&registry,
454+
"dz_shred_wins_total",
455+
"Cross-group shred contests won, by the multicast group that delivered first",
456+
&["winner"],
457+
),
458+
shred_lead_ns: histogram_vec(
459+
&registry,
460+
"dz_shred_lead_ns",
461+
"Nanoseconds the winning group led the losing duplicate by, per cross-group shred \
462+
contest, by winner group",
463+
&["winner"],
464+
LEAD_NS_BUCKETS,
465+
),
364466
shred_sends: counter_vec(
365467
&registry,
366468
"dz_shred_sends_total",
@@ -411,6 +513,19 @@ mod tests {
411513
m.emit.with_label_values(&["Hyperliquid", "quote"]).inc();
412514
m.ws_clients.set(0);
413515
m.shred_processed.inc();
516+
m.trades_admitted
517+
.with_label_values(&["Hyperliquid", "edge"])
518+
.inc();
519+
m.quote_lead_ns
520+
.with_label_values(&["Hyperliquid", "edge", "public"])
521+
.observe(123_456.0);
522+
m.trade_lead_ns
523+
.with_label_values(&["Hyperliquid", "edge", "public"])
524+
.observe(123_456.0);
525+
m.shred_wins.with_label_values(&["239.0.0.1"]).inc();
526+
m.shred_lead_ns
527+
.with_label_values(&["239.0.0.1"])
528+
.observe(123_456.0);
414529

415530
let mut buf = Vec::new();
416531
let encoder = TextEncoder::new();
@@ -424,6 +539,11 @@ mod tests {
424539
"dz_emit_total",
425540
"dz_ws_clients",
426541
"dz_shred_processed_total",
542+
"dz_trades_admitted_total",
543+
"dz_quote_lead_ns",
544+
"dz_trade_lead_ns",
545+
"dz_shred_wins_total",
546+
"dz_shred_lead_ns",
427547
] {
428548
assert!(out.contains(name), "expected `{name}` in metrics output");
429549
}

src/model.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,8 +210,8 @@ pub fn now_ns() -> u64 {
210210
/// `clock_gettime` value is **comparable across processes on the same kernel**, so two
211211
/// collectors (e.g. doublezero-edge-connect and hl-collector) can measure an inter-feed delta immune to
212212
/// NTP steps/slew. Pair with `now_ns()` (wall clock) only to correlate with `source_ts`.
213-
/// Provided for standalone collectors / null tests; not referenced by the bridge itself.
214-
#[allow(dead_code)]
213+
/// Also the arrival clock the shred forwarder stamps per datagram for its cross-group lead-time
214+
/// metric (single process, so monotonic ns are directly comparable and immune to NTP steps).
215215
pub fn now_mono_ns() -> u64 {
216216
use nix::time::{clock_gettime, ClockId};
217217
clock_gettime(ClockId::CLOCK_MONOTONIC)

0 commit comments

Comments
 (0)