Skip to main content

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}