Skip to content

Commit 7c5ff88

Browse files
feat(ingest): subscription-driven feed reconciler (#62)
Activate only the feeds this host is subscribed to, and add/remove them at runtime as subscriptions change — all feed detection in one place. - ingest::subscriptions: the single detector, reading host subscriptions from `doublezero status --json` (the `multicast_groups` S:<code> entries — the authoritative per-host view), with shred-group IPs resolved via `multicast group list`. feeds.rs gains a group `code` (tiredsolid/scottsdale). - ingest::reconcile: a periodic reconciler (--subscription-refresh-secs, default 30) diffs desired-vs-running and spawns/aborts market-data receivers, the WS sink, and the shred forwarder. The WS sink activates only when a market-data feed is subscribed (so a shreds-only host serves no WS and can't collide with an existing :8081 service); shred sources track the subscribed edge-solana-* groups. Teardown is JoinHandle::abort(); a died feed self-heals next tick. - Default-on with fail-open: no `doublezero` CLI -> static always-on set; a transient CLI error -> keep current activations. --subscription-gating-disable forces the static model (the e2e harness uses it — it feeds synthetic multicast). - Supersedes the shred forwarder's one-shot discovery: remove discover_groups / resolve_sources / decide_activation in favour of the reconciler. Docs + CHANGELOG updated; cargo build/clippy/test all green.
1 parent 78258fe commit 7c5ff88

13 files changed

Lines changed: 978 additions & 348 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- **Subscription-driven feed activation** — the bridge now activates only the feeds this host is
12+
actually subscribed to, and adds/removes them at runtime as subscriptions change:
13+
- A single detector (`src/ingest/subscriptions.rs`) reads the host's subscriptions from
14+
`doublezero status --json` (`multicast_groups`, the `S:<code>` entries — the authoritative
15+
per-host view, unlike the network-wide `multicast group list`), resolving shred-group IPs via
16+
`multicast group list` (`src/shred/discovery.rs::parse_group_code_ips`). Each market-data feed
17+
now carries its group `code` (`src/ingest/feeds.rs`: `tiredsolid` = Hyperliquid, `scottsdale` =
18+
Phoenix).
19+
- A periodic reconciler (`src/ingest/reconcile.rs`) polls every `--subscription-refresh-secs`
20+
(`DZ_SUBSCRIPTION_REFRESH_SECS`, default 30) and diffs desired-vs-running, spawning/aborting
21+
market-data receivers, the WebSocket sink, and the shred forwarder. The **WebSocket sink comes
22+
up only when ≥1 market-data feed is subscribed** (so a shreds-only host serves no WS and can't
23+
collide with an existing `:8081` service); shred sources come from the subscribed
24+
`edge-solana-*` groups.
25+
- **Default-on with fail-open**: with no `doublezero` CLI (running from source) gating falls open
26+
to the static always-on set; a transient CLI failure keeps the current activations rather than
27+
flapping. `--subscription-gating-disable` (`DZ_SUBSCRIPTION_GATING_DISABLE`) forces the static
28+
model. A single feed dying no longer exits the process — the reconciler respawns it.
1129
- Cross-source de-duplication **win metrics**, surfacing how the edge feed beats the
1230
original/public sources in both quantity and latency at each de-dup contest:
1331
- Quotes/trades (`src/ingest/arbiter.rs`): the staleness floor and windowed dedup now report

CLAUDE.md

Lines changed: 49 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,26 @@ defaults to `warn,doublezero_edge_connect=info` (our crate at `info`, deps quiet
3535

3636
## Architecture
3737

38-
One WS-server task plus **one receiver task per selected feed** share a single
38+
A WS-server task plus **one receiver task per active feed** share a single
3939
`tokio::sync::broadcast` channel of `FeedMessage` (the fan-out backbone) and a `Mutex<HashMap>`
40-
instrument snapshot. `main.rs` selects feeds (`--feed`, or all of `ingest::feeds::FEEDS` by
41-
default), builds the shared `Arbiter` around the broadcast `Sender`, spawns the receivers into a
42-
`JoinSet` (and the optional public WS input feeders), and exits if the WS server, any receiver, or a
43-
feeder task returns. **Exception:** the WS listener is bound *eagerly* before its task is spawned,
44-
and a bind failure (e.g. the port is already taken) is logged as a warning and the process runs
45-
without the sink — never fatal, so a taken WS port can't crash-loop the container and tear down the
46-
DoubleZero tunnel.
40+
instrument snapshot. `main.rs` selects the *candidate* feeds (`--feed`, or all of
41+
`ingest::feeds::FEEDS` by default), builds the shared `Arbiter` around the broadcast `Sender`, and
42+
hands everything to the **subscription reconciler** (`ingest::reconcile`), which is the single
43+
activation authority: it decides *which* of those feeds (plus the WS sink and the shred forwarder)
44+
actually run, based on what the host is subscribed to. `main.rs`'s top-level `select!` then awaits
45+
the reconciler plus the independently-spawned public WS input feeders and metrics endpoint; the
46+
process exits only if one of those tasks panics.
47+
48+
**Activation is subscription-driven and dynamic** (`ingest::reconcile` + `ingest::subscriptions`):
49+
the reconciler polls the host's multicast subscriptions from `doublezero status --json` every
50+
`--subscription-refresh-secs` and reconciles the running task set — spawning receivers for
51+
newly-subscribed feeds, aborting ones that go away, bringing the **WS sink up only when ≥1
52+
market-data feed is subscribed** (bound non-fatally: a taken port disables the sink but never
53+
crash-loops the tunnel), and restarting the shred forwarder when its subscribed source set changes.
54+
It is **default-on with fail-open**: no `doublezero` CLI (running from source) → the static
55+
always-on set; a transient CLI error → keep current activations. `--subscription-gating-disable`
56+
forces the static model. A single feed dying no longer exits the process — the reconciler respawns
57+
it on the next tick.
4758

4859
Ingest has **two source transports** that converge on one shared `arbiter` before the broadcast:
4960
the always-on DZ Edge **multicast** receivers, and optional **public WebSocket** feeders (off by
@@ -53,8 +64,9 @@ race in the arbiter's one per-`(venue, symbol)` floor (see `ingest/arbiter.rs` b
5364
cross-source duplicates collapse and the public copy fills in only when the edge gaps.
5465

5566
Modules are grouped by role under `src/`:
56-
- **`ingest/`** — the source→`FeedMessage` pipeline (always on): `feeds`, `receiver`,
57-
`processor`, `book`, `subscriber`, `arbiter`, the optional public feeders (`public_feeder`
67+
- **`ingest/`** — the source→`FeedMessage` pipeline: `feeds`, `receiver`, `processor`, `book`,
68+
`subscriber`, `arbiter`, the **`subscriptions`** detector + **`reconcile`** activation loop (which
69+
decide what runs — see Architecture above), the optional public feeders (`public_feeder`
5870
scaffolding + `ws_feeder`/`phoenix_feeder` venues), and the codecs (`codec`, `codec_common`,
5971
`codec_midpoint`, `codec_mbo`). Intra-pipeline references use `crate::ingest::*`; this half knows
6072
nothing about how the data is re-served.
@@ -91,10 +103,24 @@ Modules are grouped by role under `src/`:
91103
- **root**`model` (shared wire types/clocks/snapshots) and `main`.
92104

93105
- **`ingest/feeds.rs`** — the hardcoded feed registry: each `Feed` is one multicast group mapped to one
94-
venue, with a `FeedKind` (which protocol) and `FeedPorts` (`TwoPort` for TOB/Midpoint, or
95-
`ThreePort` adding a snapshot port for MBO). `FEEDS` is the built-in list; add a row to ingest
96-
another venue (sibling-protocol rows are added once their live endpoints are known). `--feed
97-
<venue>` selects a subset; consumers then filter by venue over the WS.
106+
venue, with a group `code` (`tiredsolid`/`scottsdale` — the identifier `doublezero status` reports,
107+
matched by the reconciler), a `FeedKind` (which protocol) and `FeedPorts` (`TwoPort` for
108+
TOB/Midpoint, or `ThreePort` adding a snapshot port for MBO). `FEEDS` is the built-in list; add a
109+
row to ingest another venue (sibling-protocol rows are added once their live endpoints are known).
110+
`--feed <venue>` selects a subset; consumers then filter by venue over the WS.
111+
- **`ingest/subscriptions.rs`** — the single **detection** place. `detect()` shells out to
112+
`doublezero status --json` and returns the host's subscribed group **codes** (the `S:<code>`
113+
entries of `multicast_groups` — the authoritative per-host view), plus a code→IP map from
114+
`multicast group list` (`shred::discovery::parse_group_code_ips`) for the shred groups (market-data
115+
IPs come from `FEEDS`). Classifies into `market_data_feeds()` (subscribed enabled feeds) and
116+
`shred_sources()` (subscribed `edge-solana-*``ip:port`). Sync `Command` soft-fail; the
117+
`Detected` enum distinguishes `CliMissing` (fail open) from `Unavailable` (transient, keep current).
118+
- **`ingest/reconcile.rs`** — the **activation authority**. `Reconciler::run()` polls `detect()`
119+
every `--subscription-refresh-secs`, computes the desired set (market-data receivers, WS on iff a
120+
market-data feed is subscribed, shred sources), and applies the diff via a pure `plan()`
121+
(spawn/abort). Owns all `JoinHandle`s; teardown is `abort()` (clean — sockets close on drop). Reaps
122+
finished handles so a died feed respawns. Fail-open / `--subscription-gating-disable` route through
123+
one `static_desired()`.
98124
- **`ingest/receiver.rs`** — the ingest hot path. All socket plumbing is **protocol-agnostic and shared**:
99125
`bind_multicast`, `recv_with_ts` (kernel timestamps), `wait_for_interface_ip`, the `IDLE_REJOIN`
100126
watchdog, `emit_status`, and `SeqTracker`. `drive()` is a generic receive loop over **N ports**
@@ -167,8 +193,9 @@ Modules are grouped by role under `src/`:
167193
PROTOCOL.md v1 surface: optional per-client subscribe/unsubscribe filtering (empty filter list =
168194
firehose), app ping/pong + server WS-ping heartbeat with idle-timeout reaping, and the limits
169195
(max clients/subs/inbound-rate, broadcast backpressure where a slow client drops oldest). The
170-
listener is bound via `ws::bind()` (separate from `ws::serve()`) so `main.rs` can treat a bind
171-
failure as non-fatal — a taken port disables the sink but leaves the tunnel running.
196+
listener is bound via `ws::bind()` (separate from `ws::serve()`) so the reconciler can treat a bind
197+
failure as non-fatal — a taken port disables the sink but leaves the tunnel running — and activate
198+
the sink only once a market-data feed is subscribed.
172199
- **`model.rs`** — wire types (`NormalizedQuote`/`NormalizedTrade`/`NormalizedMidpoint`/
173200
`NormalizedDepth`/`NormalizedInstrument`, the `FeedMessage` tagged enum) and the `now_ns()` /
174201
`now_mono_ns()` clocks. The `InstrumentSnapshot` and `DepthSnapshot` are both keyed by
@@ -209,9 +236,12 @@ Modules are grouped by role under `src/`:
209236
- `--iface` accepts an interface name (resolved to its IPv4 via `ip -4 -o addr show`) or an IPv4
210237
literal directly.
211238
- **Source/sink activation is uniform**: a source or sink runs when its key config value is
212-
non-empty/present. ws (output) is on by default (non-empty default `--ws-bind`; `--ws-bind ""`
213-
disables it); the public WS input feeder is **off** by default (on when `--ws-input-coins` is
214-
non-empty). README has the full activation tables.
239+
non-empty/present, **and** (for the subscription-gated ones — market-data receivers, the WS sink,
240+
the shred forwarder) when the reconciler sees the host subscribed to the relevant group. ws
241+
(output) is *configured* by a non-empty `--ws-bind` (`--ws-bind ""` disables it outright) but only
242+
*activated* when a market-data feed is subscribed; the public WS input feeder is **off** by
243+
default (on when `--ws-input-coins` is non-empty) and is **not** subscription-gated. README has the
244+
full activation tables.
215245
- No TLS on the **service surface** — the WebSocket output and multicast input target a trusted/local
216246
network; terminate TLS at a reverse proxy if exposed. The **one** exception is the outbound
217247
`wss://` client in `ingest/ws_feeder.rs` (public HL feed), which uses rustls + bundled webpki roots.

README.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,19 @@ DZ_SECRET=DZ_… DZ_NAME=Custom-Container-Name curl -fsSL https://get.doublezero
9494
**Bridge variables.** The installer relays **any** non-empty bridge env var straight through to
9595
the container, so the bridge is tuned entirely from the one-liner. Common ones: `DZ_IFACE`,
9696
`DZ_RECV_BUF`, `WS_BIND` and the `WS_*` limits, `METRICS_BIND` (turn on the Prometheus `/metrics`
97-
endpoint — off by default), `RUST_LOG`, and the shred forwarder's `DZ_SHRED_*` (notably
98-
`DZ_SHRED_DEDUP_MODE` and `DZ_SHRED_RPC_URL`). The full list with defaults is the `Args`
97+
endpoint — off by default), `RUST_LOG`, the shred forwarder's `DZ_SHRED_*` (notably
98+
`DZ_SHRED_DEDUP_MODE` and `DZ_SHRED_RPC_URL`), and the reconciler's `DZ_SUBSCRIPTION_REFRESH_SECS`
99+
/ `DZ_SUBSCRIPTION_GATING_DISABLE`. The full list with defaults is the `Args`
99100
struct in [`src/main.rs`](src/main.rs); per-feature config lives in the [docs](docs/) (see below).
100101

102+
> **Subscription-driven activation.** The bridge only runs the feeds this host is actually
103+
> subscribed to: a reconciler polls `doublezero status` every `DZ_SUBSCRIPTION_REFRESH_SECS`
104+
> (default 30) and activates/deactivates market-data receivers, the shred forwarder, and the
105+
> WebSocket sink as subscriptions change. The **WebSocket sink comes up only when a market-data feed
106+
> is subscribed** — so a shreds-only host serves no WS (and won't collide with an existing `:8081`
107+
> service) with no config. Running from source without the `doublezero` CLI, gating falls open to
108+
> the static always-on behaviour; `DZ_SUBSCRIPTION_GATING_DISABLE=1` forces that model explicitly.
109+
101110
> **Logging defaults.** Unset, `RUST_LOG` defaults to `warn,doublezero_edge_connect=info`: the
102111
> bridge's own startup/operational lines stay at `info` while noisy dependency chatter is held to
103112
> `warn`. Set `RUST_LOG=debug` for verbose output. The installer also caps the container log on

docs/output-sinks.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,18 @@ sink can't stall ingest. Every flag also reads from the env var shown.
77

88
| Sink | Default | Enable / disable | Config flags (env) |
99
|------|---------|------------------|--------------------|
10-
| **WebSocket** (`sinks::ws`) | **on** | on unless `--ws-bind` is empty; `--ws-bind ""` disables it | `--ws-bind` (`WS_BIND`, default `0.0.0.0:8081`) + the `--ws-*` limits |
10+
| **WebSocket** (`sinks::ws`) | **on when subscribed** | configured unless `--ws-bind` is empty (`--ws-bind ""` disables it); *activated* only when ≥1 market-data feed is subscribed | `--ws-bind` (`WS_BIND`, default `0.0.0.0:8081`) + the `--ws-*` limits |
1111
| **Metrics** (`sinks::metrics`) | **off** | on when `--metrics-bind` is non-empty | `--metrics-bind` (`METRICS_BIND`, default empty) |
1212

13-
A sink is active when its key config value is non-empty/present; the WebSocket sink simply ships
14-
a non-empty default bind, so it is on unless you explicitly clear it, while the metrics endpoint
15-
ships an empty default, so it is off unless you give it a bind.
13+
The metrics endpoint is active when its key config value is non-empty. The WebSocket sink ships a
14+
non-empty default bind (so it's *configured* unless you clear it), but the **subscription
15+
reconciler** only *activates* it once this host is actually subscribed to a market-data feed —
16+
so a shreds-only host serves no WebSocket and can't collide with an existing `:8081` service, with
17+
no manual config. Its listener is bound non-fatally: a taken port disables the sink for that cycle
18+
(retried on the next reconcile) but never crash-loops the process or the DoubleZero tunnel. Running
19+
from source without the `doublezero` CLI, gating falls open and the sink is active whenever
20+
configured. See the main README for the reconciler flags (`--subscription-refresh-secs`,
21+
`--subscription-gating-disable`).
1622

1723
## Metrics (Prometheus)
1824

docs/shred-forwarding.md

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,15 @@ and never drops a datagram bound for a healthy one. `--shred-forward` targets sh
1313
local/fast sinks: sends are sequential per destination, so a slow or remote sink throttles the
1414
whole forwarder (and sheds load); the send sockets pin no egress interface.
1515

16-
It **activates on discovery**: by default it shells out to `doublezero multicast group list
17-
--json-compact` and selects the activated groups whose `code` starts with `--shred-code-prefix`
18-
(default `edge-solana-`), binding each on `--shred-port` (default `7733`). If the CLI is missing,
19-
errors, or finds no matching group, the forwarder stays off. Pass `--shred-source GROUP:PORT`
20-
(repeatable) to override discovery entirely.
16+
It **activates on subscription**: source resolution is driven by the shared subscription reconciler
17+
(`ingest::reconcile`, see the main README/CLAUDE.md), which reads the groups **this host is
18+
subscribed to** from `doublezero status` every `--subscription-refresh-secs` and picks the
19+
subscribed groups whose `code` starts with `--shred-code-prefix` (default `edge-solana-`), resolving
20+
each to its multicast IP via `doublezero multicast group list --json-compact` and binding it on
21+
`--shred-port` (default `7733`). The forwarder starts when ≥1 such group is subscribed, and is
22+
**restarted whenever that set changes** (a newly-subscribed group is picked up, a dropped one torn
23+
down) — no process restart needed. If the `doublezero` CLI is missing, the forwarder runs only from
24+
an explicit `--shred-source GROUP:PORT` (repeatable), which bypasses subscription discovery entirely.
2125

2226
## Deduplication
2327

@@ -100,7 +104,8 @@ forwarder backpressure the **newest** datagram is shed (with a periodic drop-cou
100104
than blocking ingest. Discovery binds every matched group; a group this host isn't actually
101105
receiving on simply stays idle and periodically rejoins (harmless).
102106

103-
Source resolution is **one-shot at startup**: if the `doublezero` CLI isn't ready when the bridge
104-
boots, or a group activates later, those groups aren't picked up until the process restarts
105-
(periodic re-discovery is a follow-up). Once a group is resolved, its receiver survives interface
106-
flap via the rejoin watchdog.
107+
Source resolution is **refreshed periodically** (every `--subscription-refresh-secs`): the bridge
108+
starts before `doublezero connect multicast` runs, so subscriptions aren't present at boot — the
109+
reconciler picks them up on a later tick, and likewise reacts to a group subscribed/unsubscribed at
110+
runtime by restarting the forwarder with the new source set. Once a group is running, its receiver
111+
survives interface flap via the rejoin watchdog.

src/ingest/feeds.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ pub struct Feed {
6969
/// Venue name stamped on every instrument and message from this feed. Matches the
7070
/// edge-feed-spec source registry name (e.g. "Hyperliquid" for SourceID 1).
7171
pub venue: &'static str,
72+
/// The DoubleZero multicast group **code** for this feed's group (e.g. "tiredsolid",
73+
/// "scottsdale") — the identifier `doublezero status`/`multicast group list` report. The
74+
/// subscription reconciler matches this against the host's subscribed `S:<code>` entries to
75+
/// decide whether to activate the feed. Multiple feeds (e.g. a venue's TOB + MBO) can share one
76+
/// code, since they ride the same multicast group.
77+
pub code: &'static str,
7278
/// Which edge-feed-spec protocol this feed speaks (selects decoder + processor).
7379
pub kind: FeedKind,
7480
/// Multicast group for the feed.
@@ -99,6 +105,7 @@ pub const FEEDS: &[Feed] = &[
99105
// tiredsolid). Each feed gets its own receiver + reference-data state, keyed by group address.
100106
Feed {
101107
venue: "Hyperliquid",
108+
code: "tiredsolid",
102109
kind: FeedKind::TopOfBook,
103110
group: Ipv4Addr::new(233, 84, 178, 15),
104111
ports: FeedPorts::TwoPort {
@@ -112,6 +119,7 @@ pub const FEEDS: &[Feed] = &[
112119
// Depth-only: TOB owns this venue's trades.
113120
Feed {
114121
venue: "Hyperliquid",
122+
code: "tiredsolid",
115123
kind: FeedKind::MarketByOrder,
116124
group: Ipv4Addr::new(233, 84, 178, 15),
117125
ports: FeedPorts::ThreePort {
@@ -123,6 +131,7 @@ pub const FEEDS: &[Feed] = &[
123131
},
124132
Feed {
125133
venue: "Phoenix",
134+
code: "scottsdale",
126135
kind: FeedKind::TopOfBook,
127136
group: Ipv4Addr::new(233, 84, 178, 18),
128137
ports: FeedPorts::TwoPort {
@@ -150,6 +159,23 @@ mod tests {
150159
}
151160
}
152161

162+
#[test]
163+
fn every_feed_has_a_group_code() {
164+
// The reconciler matches `code` against `doublezero status` subscriptions, so every row
165+
// must carry one. Both Hyperliquid rows share the group `tiredsolid`; Phoenix is `scottsdale`.
166+
for f in FEEDS {
167+
assert!(!f.code.is_empty(), "{} {:?} has no code", f.venue, f.kind);
168+
}
169+
for f in FEEDS {
170+
let expected = match f.venue {
171+
"Hyperliquid" => "tiredsolid",
172+
"Phoenix" => "scottsdale",
173+
other => panic!("unexpected venue {other}"),
174+
};
175+
assert_eq!(f.code, expected, "{} has wrong code", f.venue);
176+
}
177+
}
178+
153179
#[test]
154180
fn hyperliquid_has_tob_and_mbo() {
155181
let kinds: std::collections::HashSet<FeedKind> = FEEDS

src/ingest/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,7 @@ pub mod phoenix_feeder;
1515
pub mod processor;
1616
pub mod public_feeder;
1717
pub mod receiver;
18+
pub mod reconcile;
1819
pub mod subscriber;
20+
pub mod subscriptions;
1921
pub mod ws_feeder;

0 commit comments

Comments
 (0)