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}