clawft_kernel/chain_subscribe.rs
1//! Chain subscription: replay-from-sequence plus live events (ADR-103 D).
2//!
3//! [`ChainManager::subscribe`](crate::chain::ChainManager::subscribe)
4//! returns a [`ChainSubscription`]. It yields the retained events from
5//! `from_seq`, then live events, with no gap and no duplicate between the
6//! two:
7//!
8//! 1. under the chain lock, `subscribe` creates the live receiver and
9//! records the snapshot end (the chain's next sequence); every append
10//! publishes to the hub under that same lock, so they cannot interleave;
11//! 2. the replay is then paged in short lock holds (`REPLAY_PAGE` events,
12//! at most `REPLAY_SCAN` scanned per hold) over `[from_seq, end)`;
13//! 3. live events below `end` are dropped (belt and braces).
14//!
15//! A deep `from_seq` therefore never clones the chain under the lock, and a
16//! `from_seq` older than the replay window is refused with a typed error.
17//!
18//! Live delivery uses a bounded `tokio::sync::broadcast`. `send` never
19//! blocks, so a stalled subscriber cannot slow an append; it falls behind,
20//! the oldest items are overwritten, and its next read yields
21//! [`ChainItem::Lagged`] carrying `resume_from`, the sequence to
22//! resubscribe from to recover. The subscription holds only a `Weak`
23//! reference to the manager and ends when the manager is dropped.
24
25use crate::chain::ChainEvent;
26
27/// Live buffer per subscriber (events). A subscriber slower than this
28/// many events behind sees [`ChainItem::Lagged`].
29pub const CHAIN_SUBSCRIBE_CAPACITY: usize = 1024;
30
31/// Default replay window: the furthest back (in events behind the head) a
32/// subscriber may start. Adjust with `ChainManager::set_max_replay_window`.
33pub const DEFAULT_MAX_REPLAY_WINDOW: u64 = 100_000;
34
35/// Events returned per replay lock hold.
36#[cfg(feature = "native")]
37const REPLAY_PAGE: usize = 256;
38/// Retained events examined per replay lock hold (bounds hold time when
39/// `kind_prefix` rejects most events).
40#[cfg(feature = "native")]
41const REPLAY_SCAN: usize = 4096;
42
43/// Which events a subscription yields.
44#[derive(Debug, Clone, Default, PartialEq, Eq)]
45pub struct ChainFilter {
46 /// Only events whose `kind` starts with this prefix (`None` = all).
47 pub kind_prefix: Option<String>,
48 /// Replay retained events with `sequence >= from_seq` before going
49 /// live. `None` = live only.
50 pub from_seq: Option<u64>,
51}
52
53impl ChainFilter {
54 #[cfg(feature = "native")]
55 fn matches(&self, e: &ChainEvent) -> bool {
56 self.kind_prefix
57 .as_deref()
58 .is_none_or(|p| e.kind.starts_with(p))
59 }
60}
61
62/// Why a subscription was refused.
63#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
64pub enum SubscribeError {
65 /// `from_seq` is further back than the replay window allows.
66 #[error("from_seq {requested} is older than the replay window (oldest allowed {oldest_allowed})")]
67 ReplayWindowExceeded { requested: u64, oldest_allowed: u64 },
68}
69
70/// One item of a subscription.
71#[derive(Debug, Clone)]
72// Public fan-out item cloned per subscriber; boxing the event would change the public API and add an allocation per event.
73#[allow(clippy::large_enum_variant)]
74pub enum ChainItem {
75 /// A chain event matching the filter.
76 Event(ChainEvent),
77 /// The subscriber fell behind and `missed` events were dropped.
78 /// Resubscribe with `from_seq = resume_from` to recover them.
79 Lagged { missed: u64, resume_from: u64 },
80}
81
82/// Live fan-out owned by the chain. No-op without the `native` feature.
83#[derive(Debug)]
84pub(crate) struct EventHub {
85 #[cfg(feature = "native")]
86 tx: tokio::sync::broadcast::Sender<ChainEvent>,
87}
88
89impl Default for EventHub {
90 fn default() -> Self {
91 Self {
92 #[cfg(feature = "native")]
93 tx: tokio::sync::broadcast::channel(CHAIN_SUBSCRIBE_CAPACITY).0,
94 }
95 }
96}
97
98impl EventHub {
99 /// Publish an appended event. Call under the chain lock, before the
100 /// next append. Never blocks; clones only when someone is listening.
101 #[cfg_attr(not(feature = "native"), allow(unused_variables))]
102 pub(crate) fn publish(&self, event: &ChainEvent) {
103 #[cfg(feature = "native")]
104 if self.tx.receiver_count() > 0 {
105 let _ = self.tx.send(event.clone());
106 }
107 }
108
109 #[cfg(feature = "native")]
110 pub(crate) fn subscribe(&self) -> tokio::sync::broadcast::Receiver<ChainEvent> {
111 self.tx.subscribe()
112 }
113}
114
115/// A replay-then-live stream of chain events.
116#[cfg(feature = "native")]
117#[derive(Debug)]
118pub struct ChainSubscription {
119 filter: ChainFilter,
120 chain: std::sync::Weak<crate::chain::ChainManager>,
121 /// Next sequence to page from the replay; replay is done at `end`.
122 cursor: u64,
123 /// Snapshot end: replay covers `[from_seq, end)`, live starts at `end`.
124 end: u64,
125 page: std::collections::VecDeque<ChainEvent>,
126 rx: tokio::sync::broadcast::Receiver<ChainEvent>,
127 /// Sequence after the last event this subscription has passed.
128 resume_from: u64,
129}
130
131#[cfg(feature = "native")]
132impl ChainSubscription {
133 pub(crate) fn new(
134 filter: ChainFilter,
135 chain: std::sync::Weak<crate::chain::ChainManager>,
136 rx: tokio::sync::broadcast::Receiver<ChainEvent>,
137 end: u64,
138 ) -> Self {
139 let cursor = filter.from_seq.unwrap_or(end).min(end);
140 Self {
141 filter,
142 chain,
143 cursor,
144 end,
145 page: Default::default(),
146 rx,
147 resume_from: cursor,
148 }
149 }
150
151 /// Next item, or `None` when the chain is gone.
152 pub async fn next(&mut self) -> Option<ChainItem> {
153 use tokio::sync::broadcast::error::RecvError;
154 loop {
155 if let Some(e) = self.page.pop_front() {
156 self.resume_from = e.sequence + 1;
157 return Some(ChainItem::Event(e));
158 }
159 if self.cursor >= self.end {
160 break;
161 }
162 let chain = self.chain.upgrade()?;
163 let (page, next) = chain.replay_page(
164 self.cursor,
165 self.end,
166 self.filter.kind_prefix.as_deref(),
167 REPLAY_PAGE,
168 REPLAY_SCAN,
169 );
170 // Always make progress; a page that did not advance means
171 // nothing retained is left below `end`.
172 self.cursor = if next > self.cursor { next } else { self.end };
173 self.page.extend(page);
174 }
175 loop {
176 match self.rx.recv().await {
177 Ok(e) if e.sequence < self.end => continue,
178 Ok(e) => {
179 self.resume_from = e.sequence + 1;
180 if !self.filter.matches(&e) {
181 continue;
182 }
183 return Some(ChainItem::Event(e));
184 }
185 Err(RecvError::Lagged(n)) => {
186 return Some(ChainItem::Lagged {
187 missed: n,
188 resume_from: self.resume_from,
189 });
190 }
191 Err(RecvError::Closed) => return None,
192 }
193 }
194 }
195}