Skip to main content

satd_events_client/
resilience.rs

1//! Reconnect, replay, and lag-recovery layer over [`StreamClient::subscribe`].
2//!
3//! A raw [`EventStream`](crate::EventStream) stops at the first transport error
4//! or server close, and a [`Lagged`](crate::Event::Lagged) notice leaves it to
5//! the consumer to reconnect with the resume cursor. [`ResilientSubscription`]
6//! wraps the firehose so the consumer just calls [`next`](ResilientSubscription::next)
7//! in a loop and the SDK handles:
8//!
9//! - **Reconnect with backoff** — transport errors and server-side closes
10//!   trigger an exponential-backoff reconnect from the last persisted cursor.
11//! - **Cursor persistence** — confirmed-side cursors are written to a
12//!   [`CursorStore`] so a resume survives both reconnects and process restarts.
13//! - **Lag recovery** — a `Lagged` notice is, by default
14//!   ([`LagPolicy::AutoResume`]), transparently turned into a reconnect from the
15//!   notice's `resume_cursor`; [`LagPolicy::Surface`] hands it to the caller.
16//! - **Replay-truncation detection** — the server clamps a far-behind cursor's
17//!   replay to the most recent `MAX_REPLAY_BLOCKS` (10,000) blocks. When the
18//!   first replayed confirmed height jumps past `cursor.height + 1`, a synthetic
19//!   [`Event::ReplayGap`](crate::Event::ReplayGap) is emitted before the block so
20//!   the consumer can full-resync the skipped range from another source.
21//! - **`instance_id` handling** — the full cursor (including `instance_id`) is
22//!   replayed verbatim; the server discards a stale `mempool_seq` on an
23//!   instance mismatch (daemon restart) while confirmed replay is unaffected.
24
25use std::io::Write as _;
26use std::sync::atomic::{AtomicU64, Ordering};
27use std::sync::Arc;
28use std::time::Duration;
29
30use crate::client::{StreamClient, SubscribeOptions};
31use crate::error::StreamError;
32use crate::event::{Cursor, Event};
33
34/// Persists the durable resume [`Cursor`] across reconnects and process
35/// restarts. The resilience loop loads it on (re)connect and persists it
36/// **commit-on-poll**: a delivered event's cursor is written only on the *next*
37/// [`next`](ResilientSubscription::next) call, i.e. once the caller has come
38/// back for the following event (an implicit ack). The store therefore never
39/// advances past an event the caller has not yet received, so a crash
40/// mid-processing replays that event on resume — at-least-once, not at-most-once.
41/// (A consumer that needs exactly-once still dedups on its own side keyed by the
42/// `(height, hash)` it processes.)
43///
44/// Implementations must be cheap to call and may be invoked roughly once per
45/// delivered confirmed cursor (redundant writes for unchanged cursors are
46/// elided by the loop); back them with interior mutability (the methods take
47/// `&self`). A failing `save` is surfaced to the caller of
48/// [`next`](ResilientSubscription::next) rather than silently swallowed — a
49/// store that cannot persist would otherwise replay from a stale anchor after a
50/// crash.
51pub trait CursorStore: Send + Sync {
52    /// Load the last persisted cursor, or `None` if none has been saved.
53    fn load(&self) -> Result<Option<Cursor>, StreamError>;
54    /// Persist `cursor` as the new resume anchor.
55    fn save(&self, cursor: Cursor) -> Result<(), StreamError>;
56}
57
58/// A [`CursorStore`] that persists nothing: `load` is always `None`, `save` is a
59/// no-op. The default — reconnects still resume from the in-memory last cursor,
60/// but a process restart starts forward-only.
61#[derive(Debug, Clone, Copy, Default)]
62pub struct NoopCursorStore;
63
64impl CursorStore for NoopCursorStore {
65    fn load(&self) -> Result<Option<Cursor>, StreamError> {
66        Ok(None)
67    }
68    fn save(&self, _cursor: Cursor) -> Result<(), StreamError> {
69        Ok(())
70    }
71}
72
73/// A [`CursorStore`] backed by a single file, written atomically (temp file +
74/// rename) so a crash mid-write never leaves a torn cursor. The on-disk format
75/// is one line of four whitespace-separated integers —
76/// `height tx_index mempool_seq instance_id` — stable and trivially
77/// inspectable. A missing file loads as `None`.
78#[derive(Debug, Clone)]
79pub struct FileCursorStore {
80    path: std::path::PathBuf,
81}
82
83impl FileCursorStore {
84    /// Back the store with `path`. The file is created on the first `save`; a
85    /// missing file is a clean "no cursor yet".
86    pub fn new(path: impl Into<std::path::PathBuf>) -> Self {
87        FileCursorStore { path: path.into() }
88    }
89}
90
91impl CursorStore for FileCursorStore {
92    fn load(&self) -> Result<Option<Cursor>, StreamError> {
93        let text = match std::fs::read_to_string(&self.path) {
94            Ok(t) => t,
95            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
96            Err(e) => return Err(StreamError::Decode(format!("cursor store read: {e}"))),
97        };
98        parse_cursor_line(&text)
99            .map(Some)
100            .map_err(StreamError::Decode)
101    }
102
103    fn save(&self, cursor: Cursor) -> Result<(), StreamError> {
104        let line = format!(
105            "{} {} {} {}\n",
106            cursor.height, cursor.tx_index, cursor.mempool_seq, cursor.instance_id
107        );
108        // Write to a *unique* sibling temp file then rename: rename is atomic on
109        // the same filesystem, so a reader never observes a partial line. The
110        // temp name is qualified by pid + a process-local counter so two writers
111        // sharing one cursor path (two subscriptions, or two processes) cannot
112        // clobber each other's in-flight temp and rename a foreign/partial file.
113        static SEQ: AtomicU64 = AtomicU64::new(0);
114        let n = SEQ.fetch_add(1, Ordering::Relaxed);
115        let tmp = self
116            .path
117            .with_extension(format!("tmp.{}.{n}", std::process::id()));
118        // fsync the temp before the rename and the directory after it. A rename
119        // is atomic with respect to *readers*, but atomicity is not durability:
120        // without the first fsync the rename can reach the disk while the bytes
121        // it points at have not, and without the second the rename itself can be
122        // lost. Either way a power loss resurrects the previous cursor — or an
123        // empty file — and surviving exactly that is the store's whole purpose.
124        let res = (|| -> std::io::Result<()> {
125            // 0600: this file records how far a wallet has scanned, which on a
126            // shared machine says a silent-payment wallet lives here and when it
127            // last ran. `File::create` would leave it at 0644 under the usual
128            // umask; the Go store already sets 0600, and the two must not
129            // disagree about a privacy property.
130            //
131            // `create_new`, not `create`: the mode is applied by the kernel only
132            // when the open actually creates the file, so opening an existing
133            // path leaves whatever permissions it already had and the rename
134            // then installs them on the cursor. The temp name embeds our pid and
135            // a counter, which is predictable enough for someone with write
136            // access to the directory to pre-create it world-readable — and a
137            // stale temp from a previous run that reused the pid gets there
138            // without any malice at all. `create_new` also passes `O_EXCL`,
139            // which refuses to follow a symlink planted at that path.
140            let mut opts = std::fs::OpenOptions::new();
141            opts.write(true).create_new(true);
142            #[cfg(unix)]
143            {
144                use std::os::unix::fs::OpenOptionsExt as _;
145                opts.mode(0o600);
146            }
147            let mut f = match opts.open(&tmp) {
148                Ok(f) => f,
149                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
150                    // Ours to clear: the name is scoped to this pid and this
151                    // process's counter, so anything sitting there is a leftover.
152                    // Re-create exclusively rather than truncating in place, so a
153                    // hostile pre-creation cannot survive as permissions.
154                    std::fs::remove_file(&tmp)?;
155                    opts.open(&tmp)?
156                }
157                Err(e) => return Err(e),
158            };
159            f.write_all(line.as_bytes())?;
160            f.sync_all()?;
161            drop(f);
162            std::fs::rename(&tmp, &self.path)?;
163            if let Some(dir) = self.path.parent() {
164                // Best-effort: a filesystem that refuses to open a directory
165                // read-only must not fail an otherwise-complete write.
166                if let Ok(d) = std::fs::File::open(dir) {
167                    let _ = d.sync_all();
168                }
169            }
170            Ok(())
171        })()
172        .map_err(|e| StreamError::Decode(format!("cursor store write: {e}")));
173        if res.is_err() {
174            // Best-effort cleanup of the temp on a failed rename.
175            let _ = std::fs::remove_file(&tmp);
176        }
177        res
178    }
179}
180
181/// Parse the four-integer cursor line written by [`FileCursorStore`]. Each field
182/// is parsed at its real width — `height`/`tx_index` as `u32`, not `u64`-then-
183/// truncate — so a corrupt out-of-range value is a clean `Decode` error rather
184/// than a silently truncated cursor that resumes from the wrong height.
185fn parse_cursor_line(text: &str) -> Result<Cursor, String> {
186    let mut it = text.split_whitespace();
187    let mut next_field = |field: &str| -> Result<&str, String> {
188        it.next().ok_or_else(|| format!("cursor store: missing {field}"))
189    };
190    let parse = |s: &str, field: &str| -> Result<u64, String> {
191        s.parse::<u64>().map_err(|e| format!("cursor store: bad {field}: {e}"))
192    };
193    let parse32 = |s: &str, field: &str| -> Result<u32, String> {
194        s.parse::<u32>().map_err(|e| format!("cursor store: bad {field}: {e}"))
195    };
196    let height = parse32(next_field("height")?, "height")?;
197    let tx_index = parse32(next_field("tx_index")?, "tx_index")?;
198    let mempool_seq = parse(next_field("mempool_seq")?, "mempool_seq")?;
199    let instance_id = parse(next_field("instance_id")?, "instance_id")?;
200    Ok(Cursor { height, tx_index, mempool_seq, instance_id })
201}
202
203/// What the resilience loop does with a [`Lagged`](crate::Event::Lagged) notice.
204#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
205pub enum LagPolicy {
206    /// Transparently reconnect from the notice's `resume_cursor` and rejoin
207    /// live; the `Lagged` event is not surfaced. The default.
208    #[default]
209    AutoResume,
210    /// Hand the `Lagged` event to the caller unchanged; the caller decides
211    /// whether to keep consuming or to re-anchor. The loop keeps running on the
212    /// same connection afterward.
213    Surface,
214}
215
216/// Exponential reconnect backoff. Delays grow `initial * multiplier^attempt`,
217/// capped at `max`. No jitter is applied (a single client reconnecting to a
218/// single node needs none); add it externally if fanning many clients at one
219/// server.
220#[derive(Debug, Clone, Copy)]
221pub struct Backoff {
222    /// Delay before the first retry.
223    pub initial: Duration,
224    /// Upper bound on any single delay.
225    pub max: Duration,
226    /// Per-attempt growth factor.
227    pub multiplier: f64,
228    /// Give up after this many *consecutive* reconnect attempts produce no event,
229    /// surfacing the last error from [`next`](ResilientSubscription::next). The
230    /// initial connect is not counted; a connection that delivers any event
231    /// resets the count. `None` retries forever.
232    pub max_retries: Option<u32>,
233}
234
235impl Default for Backoff {
236    fn default() -> Self {
237        Backoff {
238            initial: Duration::from_millis(500),
239            max: Duration::from_secs(30),
240            multiplier: 2.0,
241            max_retries: None,
242        }
243    }
244}
245
246impl Backoff {
247    /// Delay before retry `attempt` (0-based: `attempt = 0` is the first retry).
248    pub fn delay_for(&self, attempt: u32) -> Duration {
249        // Compute in f64 then clamp; saturate rather than overflow on a large
250        // attempt count. Clamp the exponent before the `as i32` cast: a raw
251        // `u32::MAX as i32` wraps to -1 (which would *shrink* the delay), and 64
252        // doublings already dwarf any sane `max` so the result clamps anyway.
253        let exp = attempt.min(64) as i32;
254        let scaled = self.initial.as_secs_f64() * self.multiplier.powi(exp);
255        let capped = scaled.min(self.max.as_secs_f64());
256        if capped.is_finite() && capped >= 0.0 {
257            Duration::from_secs_f64(capped).min(self.max)
258        } else {
259            self.max
260        }
261    }
262}
263
264/// Bundles the resilience knobs for [`StreamClient::resilient_subscribe`].
265pub struct ResilientConfig {
266    /// Reconnect backoff schedule.
267    pub backoff: Backoff,
268    /// What to do with `Lagged` notices.
269    pub lag_policy: LagPolicy,
270    /// Where the resume cursor is persisted.
271    pub cursor_store: Arc<dyn CursorStore>,
272}
273
274impl Default for ResilientConfig {
275    fn default() -> Self {
276        ResilientConfig {
277            backoff: Backoff::default(),
278            lag_policy: LagPolicy::AutoResume,
279            cursor_store: Arc::new(NoopCursorStore),
280        }
281    }
282}
283
284impl std::fmt::Debug for ResilientConfig {
285    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286        f.debug_struct("ResilientConfig")
287            .field("backoff", &self.backoff)
288            .field("lag_policy", &self.lag_policy)
289            .field("cursor_store", &"<dyn CursorStore>")
290            .finish()
291    }
292}
293
294impl ResilientConfig {
295    /// Start from the defaults (forever-retry backoff, [`LagPolicy::AutoResume`],
296    /// no persistence).
297    pub fn new() -> Self {
298        Self::default()
299    }
300
301    /// Persist the resume cursor through `store`.
302    pub fn cursor_store(mut self, store: Arc<dyn CursorStore>) -> Self {
303        self.cursor_store = store;
304        self
305    }
306
307    /// Override the reconnect backoff.
308    pub fn backoff(mut self, backoff: Backoff) -> Self {
309        self.backoff = backoff;
310        self
311    }
312
313    /// Override the lag policy.
314    pub fn lag_policy(mut self, policy: LagPolicy) -> Self {
315        self.lag_policy = policy;
316        self
317    }
318}
319
320/// A firehose that reconnects, replays from a persisted cursor, and recovers
321/// from lag on the consumer's behalf. Construct it with
322/// [`StreamClient::resilient_subscribe`] and drive it by calling
323/// [`next`](Self::next) in a loop.
324///
325/// The first [`next`](Self::next) connects lazily. Each subsequent call yields
326/// the next [`Event`], reconnecting underneath as needed; it only returns `Err`
327/// when reconnect retries are exhausted (see [`Backoff::max_retries`]) or a
328/// non-retryable error occurs (bad endpoint/token, `PERMISSION_DENIED`, a
329/// failed cursor `save`).
330pub struct ResilientSubscription {
331    client: StreamClient,
332    base: SubscribeOptions,
333    config: ResilientConfig,
334    stream: Option<crate::client::EventStream>,
335    /// Resume anchor for the next (re)connect: the most recent confirmed cursor,
336    /// seeded from the store / base options.
337    resume: Option<Cursor>,
338    /// `cursor.height + 1` captured at the last (re)connect — the height the
339    /// first replayed confirmed event should carry if the replay was not
340    /// clamped. `None` once the first confirmed event after a resume has been
341    /// checked (live blocks are contiguous; we only test the replay seam).
342    expect_first_height: Option<u32>,
343    /// An event held back so a synthetic [`Event::ReplayGap`] can be delivered
344    /// ahead of the block that triggered it, paired with that block's cursor
345    /// (applied to the high-water only when the block is actually delivered, so
346    /// the commit never runs ahead of delivery).
347    pending: Option<(Event, Option<Cursor>)>,
348    /// The high-water cursor of the most recently *delivered* event, to be
349    /// persisted on the next [`next`](Self::next) call. Commit-on-poll: the
350    /// caller returning for the next event is its ack of the previous one, so the
351    /// store never advances past an event the caller has not yet had in hand —
352    /// giving at-least-once delivery across a crash rather than at-most-once.
353    commit_next: Option<Cursor>,
354    /// The cursor last written to the store, to skip redundant writes (e.g. a run
355    /// of mempool events that do not move the confirmed high-water).
356    committed: Option<Cursor>,
357    /// Count of consecutive reconnects that have produced **no** event yet. Reset
358    /// to 0 the moment a connection delivers any event ("made progress"), and
359    /// incremented every time a connection fails to establish or ends without
360    /// progress. Drives both the backoff delay and the `max_retries` give-up
361    /// bound, so a server that accepts a subscribe and then immediately closes it
362    /// cannot induce a no-delay reconnect storm.
363    reconnect_attempts: u32,
364    /// The most recent retryable error, surfaced if `max_retries` is exhausted.
365    last_error: Option<StreamError>,
366}
367
368impl ResilientSubscription {
369    pub(crate) fn new(
370        client: StreamClient,
371        base: SubscribeOptions,
372        config: ResilientConfig,
373    ) -> Self {
374        ResilientSubscription {
375            client,
376            base,
377            config,
378            stream: None,
379            resume: None,
380            expect_first_height: None,
381            pending: None,
382            commit_next: None,
383            committed: None,
384            reconnect_attempts: 0,
385            last_error: None,
386        }
387    }
388
389    /// The resume cursor that the next reconnect would use. Updated as confirmed
390    /// events advance; useful for diagnostics or an external checkpoint.
391    pub fn resume_cursor(&self) -> Option<&Cursor> {
392        self.resume.as_ref()
393    }
394
395    /// Persist the most-recently-delivered event's cursor now, rather than
396    /// waiting for the implicit ack on the next [`next`](Self::next). Call this
397    /// before a clean shutdown so the last event the caller durably processed is
398    /// not replayed on the next start. Idempotent — a no-op when nothing new is
399    /// armed or the store already holds the armed cursor. A failing store `save`
400    /// is surfaced.
401    pub fn commit(&mut self) -> Result<(), StreamError> {
402        self.commit_due()
403    }
404
405    /// Commit-on-poll flush: persist the armed high-water cursor (the previously
406    /// delivered event's) if it differs from what the store already holds. The
407    /// single point on the delivery path where the store advances, called at the
408    /// top of every [`next`](Self::next) (and by [`commit`](Self::commit)); it
409    /// therefore can never run ahead of an event the caller has received.
410    fn commit_due(&mut self) -> Result<(), StreamError> {
411        let Some(c) = self.commit_next else { return Ok(()) };
412        if self.committed == Some(c) {
413            self.commit_next = None;
414            return Ok(());
415        }
416        // The arm STAYS armed until the write lands. Taking it first turned a
417        // transient store failure into a false durability ack: the error
418        // propagated, but the retried `commit()` found nothing armed and
419        // returned `Ok(())`. A caller that commits explicitly before shutdown —
420        // the one path where no further event re-arms the cursor — then exits
421        // believing the cursor is durable. The restart finds an empty store and
422        // resumes forward-only, losing every event that occurred while the
423        // process was down. That is an at-least-once violation, not a replay.
424        self.config.cursor_store.save(c)?;
425        // Retire only the arm actually written, and only if nothing superseded
426        // it while the write was in flight.
427        if self.commit_next == Some(c) {
428            self.commit_next = None;
429        }
430        self.committed = Some(c);
431        Ok(())
432    }
433
434    /// Deliver a block stashed behind a synthetic [`Event::ReplayGap`]: advance
435    /// the high-water to the block's cursor and arm *that* cursor for commit on
436    /// the following poll (never before — the gap notice itself committed only
437    /// the pre-gap anchor). Returns the stashed event, or `None` if none is held.
438    fn take_pending(&mut self) -> Option<Event> {
439        let (ev, cur) = self.pending.take()?;
440        if let Some(c) = cur {
441            self.resume = Some(c);
442        }
443        self.arm_commit();
444        Some(ev)
445    }
446
447    /// Arm the just-delivered event's high-water cursor for commit on the next
448    /// poll (commit-on-poll). The `ReplayGap` path leaves `resume` unchanged and
449    /// stashes the triggering block, so calling this after a gap notice arms only
450    /// the pre-gap anchor; the block's own cursor is armed when it is delivered.
451    fn arm_commit(&mut self) {
452        self.commit_next = self.resume;
453    }
454
455    /// Yield the next event, reconnecting and replaying underneath as needed.
456    ///
457    /// Loops internally: a transient failure becomes a backoff + reconnect, an
458    /// auto-resumed `Lagged` becomes a re-anchor, and only a real event (or a
459    /// surfaced `Lagged`, or a synthetic `ReplayGap`) returns to the caller.
460    ///
461    /// Backoff is applied before **every** reconnect that follows a connection
462    /// which produced no event — whether it failed to establish, closed cleanly,
463    /// or errored — not only the subscribe-error path. A connection that delivers
464    /// an event resets the counter, so a healthy stream that occasionally drops
465    /// reconnects promptly while a flapping server is backed off and eventually
466    /// bounded by `max_retries`.
467    pub async fn next(&mut self) -> Result<Event, StreamError> {
468        // Commit-on-poll: persist the previously-delivered event's high-water
469        // cursor now that the caller has come back for the next one (an implicit
470        // ack). A crash mid-processing leaves the store at the prior event, which
471        // the server replays on resume — at-least-once, not at-most-once.
472        self.commit_due()?;
473        // A block stashed behind a synthetic `ReplayGap` is delivered first.
474        if let Some(ev) = self.take_pending() {
475            return Ok(ev);
476        }
477        loop {
478            if self.stream.is_none() {
479                // Back off before reconnecting if the previous connection made no
480                // progress (failed to establish, or established and immediately
481                // ended). The first connect (`reconnect_attempts == 0`) is
482                // immediate.
483                if self.reconnect_attempts > 0 {
484                    if let Some(max) = self.config.backoff.max_retries
485                        && self.reconnect_attempts > max
486                    {
487                        return Err(self
488                            .last_error
489                            .take()
490                            .unwrap_or(StreamError::ControlClosed));
491                    }
492                    let delay = self.config.backoff.delay_for(self.reconnect_attempts - 1);
493                    tokio::time::sleep(delay).await;
494                }
495                match self.connect_once().await {
496                    Ok(()) => {}
497                    Err(e) if e.is_retryable() => {
498                        self.reconnect_attempts = self.reconnect_attempts.saturating_add(1);
499                        self.last_error = Some(e);
500                        continue;
501                    }
502                    Err(e) => return Err(e),
503                }
504            }
505            // `connect_once` guarantees `self.stream` is `Some`.
506            let stream = self.stream.as_mut().expect("connected");
507            match stream.message().await {
508                Ok(Some(ev)) => {
509                    // A delivered event is progress: clear the backoff counter so
510                    // the next reconnect (if any) starts fresh.
511                    self.reconnect_attempts = 0;
512                    self.last_error = None;
513                    // Capture the cursor this message carries before handling it,
514                    // so `handle_event` can decide the gap seam without advancing
515                    // the high-water prematurely.
516                    let cur = self.stream.as_ref().and_then(|s| s.cursor().copied());
517                    if let Some(out) = self.handle_event(ev, cur).await? {
518                        // Arm the delivered event's high-water for commit on the
519                        // next poll (the ReplayGap path leaves `resume` unchanged
520                        // and stashes the block, so its cursor commits only when
521                        // the block itself is delivered).
522                        self.arm_commit();
523                        return Ok(out);
524                    }
525                    // Event consumed internally (auto-resumed lag): loop.
526                }
527                Ok(None) => {
528                    // Server closed the stream cleanly; reconnect from resume,
529                    // backing off since this connection yielded nothing new.
530                    self.stream = None;
531                    self.reconnect_attempts = self.reconnect_attempts.saturating_add(1);
532                }
533                Err(e) if e.is_retryable() => {
534                    self.stream = None;
535                    self.reconnect_attempts = self.reconnect_attempts.saturating_add(1);
536                    self.last_error = Some(e);
537                }
538                Err(e) => return Err(e),
539            }
540        }
541    }
542
543    /// Process one inbound event, given the cursor (`cur`) the carrying message
544    /// advanced the stream to. Returns `Ok(Some(ev))` to hand to the caller,
545    /// `Ok(None)` if it was handled internally (auto-resume), or an error to
546    /// propagate (a failed cursor `save`).
547    ///
548    /// The confirmed high-water (`self.resume`) is advanced here but **not**
549    /// persisted — persistence is deferred to the next [`next`](Self::next) poll
550    /// (commit-on-poll). The gap check runs *before* the advance so a clamped
551    /// replay stashes the triggering block without moving the high-water past it.
552    async fn handle_event(
553        &mut self,
554        ev: Event,
555        cur: Option<Cursor>,
556    ) -> Result<Option<Event>, StreamError> {
557        // Replay-truncation check: only on the first confirmed-height event after
558        // a resume. A `BlockConnected` whose height exceeds the expected next
559        // height means the server clamped the replay window. Stash the block with
560        // its cursor and emit the gap notice first — the high-water is advanced to
561        // this block only when it is actually delivered (next poll), so the commit
562        // never runs ahead of delivery.
563        if let Some(expect) = self.expect_first_height
564            && let Event::BlockConnected { height, .. } = ev
565        {
566            self.expect_first_height = None;
567            if height > expect {
568                self.pending = Some((ev, cur));
569                return Ok(Some(Event::ReplayGap {
570                    resume_height: expect,
571                    first_height: height,
572                }));
573            }
574        }
575
576        // Advance the in-memory high-water (used for live reconnect); not yet
577        // persisted.
578        if let Some(c) = cur {
579            self.resume = Some(c);
580        }
581
582        match ev {
583            Event::Lagged { resume_cursor, .. } if self.config.lag_policy == LagPolicy::AutoResume => {
584                // Re-anchor from the notice's cursor (if any), then reconnect. A
585                // lag re-anchor is a recovery point the server handed us, not
586                // caller-delivered data, so persist it immediately (and supersede
587                // any deferred commit) — a crash then resumes from the same place
588                // the live re-anchor would.
589                if let Some(c) = resume_cursor {
590                    self.resume = Some(c);
591                    self.config.cursor_store.save(c)?;
592                    self.committed = Some(c);
593                    self.commit_next = None;
594                }
595                self.stream = None;
596                // Count the re-anchor as a reconnect attempt. Receiving the
597                // `Lagged` notice reset `reconnect_attempts` to 0 (an event is
598                // progress), so without this the loop re-subscribes IMMEDIATELY
599                // and `max_retries` never bounds it — a consumer that is
600                // chronically too slow lags, re-anchors, lags again, and spins,
601                // with each cycle costing the node a full confirmed-history
602                // replay. Backing off turns that into pressure the server can
603                // absorb.
604                self.reconnect_attempts = self.reconnect_attempts.saturating_add(1);
605                Ok(None)
606            }
607            // Deterministic re-anchor acks (#441) are a `Watch`-only signal — a
608            // `set_cursor` is never sent on the `Subscribe` firehose this layer
609            // wraps, so they cannot arrive here today. Match them explicitly so a
610            // future carrier that did surface them could not have them mistaken
611            // for confirmed data: they carry no durable cursor, so the high-water
612            // (advanced above only from a message's own cursor) is untouched, and
613            // they pass straight through to the caller.
614            e @ (Event::CursorAccepted { .. } | Event::CursorRejected { .. }) => Ok(Some(e)),
615            other => Ok(Some(other)),
616        }
617    }
618
619    /// Establish the resume anchor on the first connect (no I/O): effective
620    /// `resume` = in-memory anchor, else the persisted cursor, else the caller's
621    /// base `from_cursor`. A cursor read back from the store is by definition
622    /// already durably committed, so it also seeds `committed` — that way the
623    /// commit-on-poll write-elision recognizes it and the first post-restart
624    /// commit of an unchanged anchor is a no-op, not a redundant write.
625    fn seed_resume(&mut self) -> Result<(), StreamError> {
626        if self.resume.is_none() {
627            let loaded = self.config.cursor_store.load()?;
628            if let Some(c) = loaded {
629                self.committed.get_or_insert(c);
630            }
631            self.resume = loaded.or(self.base.from_cursor);
632        }
633        Ok(())
634    }
635
636    /// Open a single subscription, replaying from the resume cursor. Backoff and
637    /// retry accounting live in [`next`](Self::next); this performs exactly one
638    /// `subscribe` attempt and returns its result (a retryable `Err` is the
639    /// signal for `next` to back off and try again).
640    async fn connect_once(&mut self) -> Result<(), StreamError> {
641        self.seed_resume()?;
642        let mut opts = self.base.clone();
643        opts.from_cursor = self.resume;
644        let stream = self.client.subscribe(opts).await?;
645        self.stream = Some(stream);
646        // Arm the truncation check for the first confirmed event of this resume
647        // (only when we actually asked to replay).
648        //
649        // Detection depends on the server replaying confirmed history as
650        // `BlockConnected` events in height order ahead of the live tail — see
651        // `build_cursor_replay` in `node/src/events/replay.rs`, which synthesizes
652        // only `BlockConnected` for the confirmed span. The check below therefore
653        // matches `BlockConnected`; if a future carrier reordered replay this
654        // would need to key on the first confirmed-cursor-bearing event instead.
655        self.expect_first_height = self.resume.map(|c| c.height.saturating_add(1));
656        Ok(())
657    }
658}
659
660#[cfg(test)]
661mod tests {
662    /// The cursor file records how far a wallet has scanned. On a shared host a
663    /// world-readable one tells every other user that a silent-payment wallet
664    /// lives here and when it last ran, so the mode is part of the contract —
665    /// and it has to match the Go store, which sets 0600.
666    #[cfg(unix)]
667    #[test]
668    fn file_cursor_store_is_not_world_readable() {
669        use std::os::unix::fs::PermissionsExt as _;
670        let dir = std::env::temp_dir().join(format!(
671            "satd-cursor-mode-{}-{}",
672            std::process::id(),
673            line!()
674        ));
675        std::fs::create_dir_all(&dir).expect("temp dir");
676        let path = dir.join("scan.cursor");
677        let store = FileCursorStore::new(&path);
678        store
679            .save(Cursor { height: 7, ..Default::default() })
680            .expect("save");
681        let mode = std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777;
682        assert_eq!(mode, 0o600, "cursor file must not be group- or world-readable");
683        let _ = std::fs::remove_dir_all(&dir);
684    }
685
686    #[test]
687    #[cfg(unix)]
688    fn a_pre_existing_temp_file_cannot_smuggle_its_permissions_onto_the_cursor() {
689        // `OpenOptions::mode` is applied by the kernel only when the open
690        // creates the file. With `create(true)` an existing temp path keeps the
691        // permissions it already had, and the rename then installs them on the
692        // cursor -- so the 0600 the other test checks holds only on a clean
693        // directory. The temp name is `<path>.tmp.<pid>.<n>`, which anyone with
694        // write access to the directory can predict, and a crashed run whose pid
695        // is reused gets there without any malice at all.
696        use std::os::unix::fs::PermissionsExt as _;
697        let dir = std::env::temp_dir().join(format!(
698            "satd-cursor-tmp-reuse-{}-{}",
699            std::process::id(),
700            line!()
701        ));
702        std::fs::create_dir_all(&dir).expect("temp dir");
703        let path = dir.join("scan.cursor");
704
705        // Plant a world-readable decoy at every temp name `save` might pick.
706        // The counter is process-local, so one of these is the one it takes.
707        const DECOYS: u64 = 128;
708        for n in 0..DECOYS {
709            let decoy = path.with_extension(format!("tmp.{}.{n}", std::process::id()));
710            std::fs::write(&decoy, b"stale").expect("plant decoy");
711            std::fs::set_permissions(&decoy, std::fs::Permissions::from_mode(0o666))
712                .expect("chmod decoy");
713        }
714
715        FileCursorStore::new(&path)
716            .save(Cursor { height: 7, ..Default::default() })
717            .expect("save");
718
719        // Exactly one decoy is consumed -- renamed onto the cursor. If none was,
720        // the counter has run past the planted range and this test proves
721        // nothing, so say that rather than passing quietly.
722        let consumed = (0..DECOYS)
723            .filter(|n| {
724                !path.with_extension(format!("tmp.{}.{n}", std::process::id())).exists()
725            })
726            .count();
727        assert_eq!(
728            consumed, 1,
729            "expected save to take exactly one planted temp name; \
730             0 means the counter passed the decoy range and the test is vacuous",
731        );
732
733        let mode = std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777;
734        assert_eq!(
735            mode, 0o600,
736            "a reused temp path must not carry its permissions onto the cursor",
737        );
738        let _ = std::fs::remove_dir_all(&dir);
739    }
740
741    use super::*;
742    use std::sync::Mutex;
743
744    #[test]
745    fn backoff_grows_then_caps() {
746        let b = Backoff {
747            initial: Duration::from_millis(500),
748            max: Duration::from_secs(30),
749            multiplier: 2.0,
750            max_retries: None,
751        };
752        assert_eq!(b.delay_for(0), Duration::from_millis(500));
753        assert_eq!(b.delay_for(1), Duration::from_secs(1));
754        assert_eq!(b.delay_for(2), Duration::from_secs(2));
755        // 500ms * 2^10 = 512s, clamped to the 30s ceiling.
756        assert_eq!(b.delay_for(10), Duration::from_secs(30));
757        // A huge attempt count saturates to the cap rather than overflowing.
758        assert_eq!(b.delay_for(u32::MAX), Duration::from_secs(30));
759    }
760
761    #[test]
762    fn defaults_are_autoresume_and_noop() {
763        let cfg = ResilientConfig::default();
764        assert_eq!(cfg.lag_policy, LagPolicy::AutoResume);
765        assert!(cfg.cursor_store.load().unwrap().is_none());
766        assert_eq!(LagPolicy::default(), LagPolicy::AutoResume);
767    }
768
769    #[test]
770    fn noop_store_roundtrips_to_none() {
771        let s = NoopCursorStore;
772        s.save(Cursor { height: 9, tx_index: 1, mempool_seq: 2, instance_id: 3 })
773            .unwrap();
774        assert!(s.load().unwrap().is_none());
775    }
776
777    #[test]
778    fn file_store_roundtrips_and_missing_is_none() {
779        let dir = std::env::temp_dir();
780        let path = dir.join(format!("satd-sdk-cursor-test-{}.cur", std::process::id()));
781        let _ = std::fs::remove_file(&path);
782        let store = FileCursorStore::new(&path);
783
784        // Missing file loads cleanly as None.
785        assert!(store.load().unwrap().is_none());
786
787        let c = Cursor { height: 951_577, tx_index: 4, mempool_seq: 1234, instance_id: 99 };
788        store.save(c).unwrap();
789        let got = store.load().unwrap().expect("cursor present after save");
790        assert_eq!(got, c);
791
792        // Overwrite is atomic and reflects the latest.
793        let c2 = Cursor { height: 951_578, tx_index: 0, mempool_seq: 5, instance_id: 99 };
794        store.save(c2).unwrap();
795        assert_eq!(store.load().unwrap().unwrap(), c2);
796
797        let _ = std::fs::remove_file(&path);
798        let _ = std::fs::remove_file(path.with_extension("tmp"));
799    }
800
801    #[test]
802    fn file_store_rejects_garbage() {
803        let dir = std::env::temp_dir();
804        let path = dir.join(format!("satd-sdk-cursor-bad-{}.cur", std::process::id()));
805        std::fs::write(&path, b"not a cursor").unwrap();
806        let store = FileCursorStore::new(&path);
807        assert!(matches!(store.load(), Err(StreamError::Decode(_))));
808        let _ = std::fs::remove_file(&path);
809    }
810
811    #[test]
812    fn parse_cursor_line_exact() {
813        let c = parse_cursor_line("951577 4 1234 99\n").unwrap();
814        assert_eq!(c, Cursor { height: 951_577, tx_index: 4, mempool_seq: 1234, instance_id: 99 });
815        assert!(parse_cursor_line("1 2 3").is_err()); // missing field
816    }
817
818    #[test]
819    fn parse_cursor_line_rejects_out_of_range_height() {
820        // A corrupt height beyond u32::MAX must be a clean error, not a silent
821        // truncation to a wrong (small) height that resumes from the wrong place.
822        let too_big = (u32::MAX as u64) + 1; // 4_294_967_296
823        assert!(parse_cursor_line(&format!("{too_big} 0 0 0")).is_err());
824        // u64-width fields still accept large values.
825        let c = parse_cursor_line(&format!("1 0 {too_big} {too_big}")).unwrap();
826        assert_eq!(c.mempool_seq, too_big);
827        assert_eq!(c.instance_id, too_big);
828    }
829
830    /// A store we can assert against from tests.
831    #[derive(Default)]
832    struct MemStore(Mutex<Option<Cursor>>);
833    impl CursorStore for MemStore {
834        fn load(&self) -> Result<Option<Cursor>, StreamError> {
835            Ok(*self.0.lock().unwrap())
836        }
837        fn save(&self, cursor: Cursor) -> Result<(), StreamError> {
838            *self.0.lock().unwrap() = Some(cursor);
839            Ok(())
840        }
841    }
842
843    /// A store that fails the first `n` saves, then behaves. Models a transient
844    /// I/O failure (a full disk that is then cleared, an EINTR, a briefly
845    /// unwritable mount) rather than a permanent one.
846    #[derive(Default)]
847    struct FlakyStore {
848        inner: Mutex<Option<Cursor>>,
849        fail_next: Mutex<u32>,
850        saves_attempted: Mutex<u32>,
851    }
852    impl FlakyStore {
853        fn failing(n: u32) -> Self {
854            Self {
855                fail_next: Mutex::new(n),
856                ..Default::default()
857            }
858        }
859    }
860    impl CursorStore for FlakyStore {
861        fn load(&self) -> Result<Option<Cursor>, StreamError> {
862            Ok(*self.inner.lock().unwrap())
863        }
864        fn save(&self, cursor: Cursor) -> Result<(), StreamError> {
865            *self.saves_attempted.lock().unwrap() += 1;
866            let mut left = self.fail_next.lock().unwrap();
867            if *left > 0 {
868                *left -= 1;
869                return Err(StreamError::Decode("disk full".into()));
870            }
871            *self.inner.lock().unwrap() = Some(cursor);
872            Ok(())
873        }
874    }
875
876    /// A store write that fails must leave the cursor ARMED, so the retry
877    /// actually writes it.
878    ///
879    /// The bug this pins: `commit_due` took the arm before attempting the save,
880    /// so a transient failure propagated the error *and* discarded the arm. The
881    /// retried `commit()` then found nothing armed and returned `Ok(())` — a
882    /// false durability ack. The window is an idle stream, which is exactly the
883    /// graceful-shutdown path where a caller commits explicitly and no further
884    /// event re-arms the cursor. The process exits believing the cursor is
885    /// durable; the restart finds an empty store and resumes forward-only,
886    /// losing everything that happened while it was down.
887    #[tokio::test]
888    async fn commit_retry_after_a_store_failure_actually_writes() {
889        let store = Arc::new(FlakyStore::failing(1));
890        let mut sub = ResilientSubscription::new(
891            StreamClient::for_test(),
892            SubscribeOptions::default(),
893            ResilientConfig::new().cursor_store(store.clone()),
894        );
895        let c100 = cur(100);
896
897        sub.handle_event(
898            Event::BlockConnected {
899                hash: vec![0xaa],
900                height: 100,
901            },
902            Some(c100),
903        )
904        .await
905        .unwrap();
906        sub.arm_commit();
907
908        // First flush fails. The error must surface...
909        let err = sub.commit_due().unwrap_err();
910        assert!(
911            matches!(err, StreamError::Decode(_)),
912            "transient store failure surfaces"
913        );
914        assert_eq!(store.load().unwrap(), None, "nothing was written");
915
916        // ...and the retry must actually write, not report a vacuous success.
917        sub.commit_due().expect("retry after a transient failure");
918        assert_eq!(
919            store.load().unwrap(),
920            Some(c100),
921            "the retried commit must persist the cursor the failed one armed; \
922             returning Ok() with an empty store is a false durability ack"
923        );
924        assert_eq!(
925            *store.saves_attempted.lock().unwrap(),
926            2,
927            "the retry reached the store"
928        );
929    }
930
931    /// An auto-resumed lag must count as a reconnect attempt, so backoff and
932    /// `max_retries` apply to it.
933    ///
934    /// The bug this pins: receiving the `Lagged` notice reset
935    /// `reconnect_attempts` to 0 (an event is progress), and the auto-resume arm
936    /// then dropped the stream and returned to the loop — which skips the
937    /// backoff sleep entirely when the counter is 0. A consumer that is
938    /// chronically too slow therefore lagged, re-subscribed instantly, lagged
939    /// again, and spun, with each cycle costing the node a full
940    /// confirmed-history replay and nothing ever bounding it.
941    #[tokio::test]
942    async fn lag_auto_resume_counts_a_reconnect_attempt() {
943        let store = Arc::new(MemStore::default());
944        let mut sub = ResilientSubscription::new(
945            StreamClient::for_test(),
946            SubscribeOptions::default(),
947            ResilientConfig::new()
948                .cursor_store(store.clone())
949                .lag_policy(LagPolicy::AutoResume),
950        );
951        // The state `next()` is in when a Lagged arrives: the notice itself was
952        // an event, so progress cleared the counter.
953        sub.reconnect_attempts = 0;
954
955        let out = sub
956            .handle_event(
957                Event::Lagged {
958                    dropped_count: 500,
959                    resume_cursor: Some(cur(42)),
960                },
961                Some(cur(42)),
962            )
963            .await
964            .unwrap();
965
966        assert!(out.is_none(), "an auto-resumed lag is consumed internally");
967        assert!(
968            sub.stream.is_none(),
969            "the stream is dropped so the loop reconnects"
970        );
971        assert_eq!(
972            sub.reconnect_attempts, 1,
973            "the re-anchor must count as an attempt; at 0 the loop skips the backoff \
974             sleep and max_retries never bounds a chronically slow consumer"
975        );
976    }
977
978    #[test]
979    fn mem_store_observes_saves() {
980        let s = MemStore::default();
981        assert!(s.load().unwrap().is_none());
982        let c = Cursor { height: 1, tx_index: 0, mempool_seq: 0, instance_id: 7 };
983        s.save(c).unwrap();
984        assert_eq!(s.load().unwrap(), Some(c));
985    }
986
987    // --- commit-on-poll semantics ---------------------------------------------
988    //
989    // `next()` is welded to a live transport, so these drive the same extracted
990    // steps it calls (`commit_due` / `take_pending` / `handle_event` /
991    // `arm_commit`) in the same order, with the test standing in for the poll
992    // loop. They assert the durability contract the commit-on-poll fix
993    // introduced: the store advances only on the poll *after* delivery.
994
995    fn sub_with(store: &Arc<MemStore>) -> ResilientSubscription {
996        ResilientSubscription::new(
997            StreamClient::for_test(),
998            SubscribeOptions::default(),
999            ResilientConfig::new().cursor_store(store.clone()),
1000        )
1001    }
1002
1003    fn cur(height: u32) -> Cursor {
1004        Cursor { height, tx_index: 0, mempool_seq: 0, instance_id: 1 }
1005    }
1006
1007    #[tokio::test]
1008    async fn commit_on_poll_defers_store_write_until_next_poll() {
1009        let store = Arc::new(MemStore::default());
1010        let mut sub = sub_with(&store);
1011        let (c100, c101) = (cur(100), cur(101));
1012
1013        // Poll 1: nothing armed, no stash; deliver block@100.
1014        sub.commit_due().unwrap();
1015        assert!(sub.take_pending().is_none());
1016        let out = sub
1017            .handle_event(Event::BlockConnected { hash: vec![0xaa], height: 100 }, Some(c100))
1018            .await
1019            .unwrap();
1020        assert!(matches!(out, Some(Event::BlockConnected { height: 100, .. })));
1021        sub.arm_commit();
1022        // The caller holds block@100 but has not returned: the store must NOT have
1023        // advanced — a crash here replays block@100 (at-least-once).
1024        assert_eq!(store.load().unwrap(), None, "cursor must not commit before the next poll");
1025
1026        // Poll 2: returning for the next event acks block@100; deliver block@101.
1027        sub.commit_due().unwrap();
1028        assert_eq!(store.load().unwrap(), Some(c100), "block@100 committed only on the next poll");
1029        assert!(sub.take_pending().is_none());
1030        let out = sub
1031            .handle_event(Event::BlockConnected { hash: vec![0xbb], height: 101 }, Some(c101))
1032            .await
1033            .unwrap();
1034        assert!(out.is_some());
1035        sub.arm_commit();
1036        assert_eq!(store.load().unwrap(), Some(c100), "block@101 still not committed");
1037
1038        // Poll 3: acks block@101.
1039        sub.commit_due().unwrap();
1040        assert_eq!(store.load().unwrap(), Some(c101));
1041    }
1042
1043    #[tokio::test]
1044    async fn explicit_commit_checkpoints_last_delivered_event() {
1045        let store = Arc::new(MemStore::default());
1046        let mut sub = sub_with(&store);
1047        let c = cur(10);
1048        sub.handle_event(Event::BlockConnected { hash: vec![1], height: 10 }, Some(c))
1049            .await
1050            .unwrap();
1051        sub.arm_commit();
1052        assert_eq!(store.load().unwrap(), None);
1053        // A clean shutdown checkpoints the last delivered event so it is not
1054        // replayed on the next start.
1055        sub.commit().unwrap();
1056        assert_eq!(store.load().unwrap(), Some(c));
1057        // Idempotent: a second commit writes nothing new.
1058        sub.commit().unwrap();
1059        assert_eq!(store.load().unwrap(), Some(c));
1060    }
1061
1062    #[tokio::test]
1063    async fn lag_autoresume_persists_immediately_and_supersedes_deferred_commit() {
1064        let store = Arc::new(MemStore::default());
1065        let mut sub = sub_with(&store); // AutoResume is the default
1066        // A prior delivered event has armed a deferred commit.
1067        let armed = cur(50);
1068        sub.resume = Some(armed);
1069        sub.commit_next = Some(armed);
1070
1071        let recover = cur(75);
1072        let out = sub
1073            .handle_event(
1074                Event::Lagged { dropped_count: 3, resume_cursor: Some(recover) },
1075                None,
1076            )
1077            .await
1078            .unwrap();
1079        assert!(out.is_none(), "an auto-resumed lag is consumed internally");
1080        // The re-anchor is a recovery point, persisted now (not deferred), and it
1081        // supersedes the armed commit so the deferred write cannot clobber it.
1082        assert_eq!(store.load().unwrap(), Some(recover), "lag re-anchor persisted immediately");
1083        assert_eq!(sub.committed, Some(recover));
1084        assert!(sub.commit_next.is_none(), "deferred commit superseded");
1085        assert!(sub.stream.is_none(), "stream torn down for reconnect");
1086    }
1087
1088    #[tokio::test]
1089    async fn replay_gap_defers_commit_until_stashed_block_delivered() {
1090        let store = Arc::new(MemStore::default());
1091        let mut sub = sub_with(&store);
1092        // State just after resuming from height 100, with the store already at the
1093        // pre-gap anchor.
1094        let pre = cur(100);
1095        store.save(pre).unwrap();
1096        sub.resume = Some(pre);
1097        sub.committed = Some(pre);
1098        sub.expect_first_height = Some(101);
1099
1100        // The server clamped replay: first confirmed block jumps to 5000.
1101        let gap_cur = cur(5000);
1102        let out = sub
1103            .handle_event(Event::BlockConnected { hash: vec![0xcc], height: 5000 }, Some(gap_cur))
1104            .await
1105            .unwrap();
1106        // `resume_height` is the height the cursor expected next (`100 + 1`).
1107        assert!(
1108            matches!(out, Some(Event::ReplayGap { resume_height: 101, first_height: 5000 })),
1109            "a clamped replay surfaces a ReplayGap before the block"
1110        );
1111        sub.arm_commit(); // next() arms after delivering the gap notice
1112        assert_eq!(sub.resume, Some(pre), "high-water stays at the pre-gap anchor across the gap");
1113
1114        // Next poll: commit the pre-gap anchor (not the post-gap block), then
1115        // deliver the stashed block.
1116        sub.commit_due().unwrap();
1117        assert_eq!(store.load().unwrap(), Some(pre), "must not commit past the gap");
1118        let blk = sub.take_pending().expect("the stashed block is delivered next");
1119        assert!(matches!(blk, Event::BlockConnected { height: 5000, .. }));
1120        assert_eq!(sub.resume, Some(gap_cur), "high-water advances to the delivered block");
1121
1122        // The poll after that acks the post-gap block.
1123        sub.commit_due().unwrap();
1124        assert_eq!(store.load().unwrap(), Some(gap_cur));
1125    }
1126
1127    #[tokio::test]
1128    async fn committed_seeded_from_store_on_first_connect() {
1129        let store = Arc::new(MemStore::default());
1130        let persisted = cur(900);
1131        store.save(persisted).unwrap();
1132        let mut sub = sub_with(&store);
1133        // The first connect loads the persisted cursor and treats it as already
1134        // committed, so re-arming the same anchor elides the redundant write.
1135        sub.seed_resume().unwrap();
1136        assert_eq!(sub.resume, Some(persisted));
1137        assert_eq!(sub.committed, Some(persisted), "loaded cursor seeds `committed`");
1138        // Re-arming the seeded anchor and polling writes nothing new.
1139        sub.arm_commit();
1140        sub.commit_due().unwrap();
1141        assert_eq!(store.load().unwrap(), Some(persisted));
1142    }
1143}