use std::io::Write as _;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use crate::client::{StreamClient, SubscribeOptions};
use crate::error::StreamError;
use crate::event::{Cursor, Event};
pub trait CursorStore: Send + Sync {
fn load(&self) -> Result<Option<Cursor>, StreamError>;
fn save(&self, cursor: Cursor) -> Result<(), StreamError>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct NoopCursorStore;
impl CursorStore for NoopCursorStore {
fn load(&self) -> Result<Option<Cursor>, StreamError> {
Ok(None)
}
fn save(&self, _cursor: Cursor) -> Result<(), StreamError> {
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct FileCursorStore {
path: std::path::PathBuf,
}
impl FileCursorStore {
pub fn new(path: impl Into<std::path::PathBuf>) -> Self {
FileCursorStore { path: path.into() }
}
}
impl CursorStore for FileCursorStore {
fn load(&self) -> Result<Option<Cursor>, StreamError> {
let text = match std::fs::read_to_string(&self.path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(StreamError::Decode(format!("cursor store read: {e}"))),
};
parse_cursor_line(&text)
.map(Some)
.map_err(StreamError::Decode)
}
fn save(&self, cursor: Cursor) -> Result<(), StreamError> {
let line = format!(
"{} {} {} {}\n",
cursor.height, cursor.tx_index, cursor.mempool_seq, cursor.instance_id
);
static SEQ: AtomicU64 = AtomicU64::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let tmp = self
.path
.with_extension(format!("tmp.{}.{n}", std::process::id()));
let res = (|| -> std::io::Result<()> {
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
opts.mode(0o600);
}
let mut f = match opts.open(&tmp) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
std::fs::remove_file(&tmp)?;
opts.open(&tmp)?
}
Err(e) => return Err(e),
};
f.write_all(line.as_bytes())?;
f.sync_all()?;
drop(f);
std::fs::rename(&tmp, &self.path)?;
if let Some(dir) = self.path.parent() {
if let Ok(d) = std::fs::File::open(dir) {
let _ = d.sync_all();
}
}
Ok(())
})()
.map_err(|e| StreamError::Decode(format!("cursor store write: {e}")));
if res.is_err() {
let _ = std::fs::remove_file(&tmp);
}
res
}
}
fn parse_cursor_line(text: &str) -> Result<Cursor, String> {
let mut it = text.split_whitespace();
let mut next_field = |field: &str| -> Result<&str, String> {
it.next().ok_or_else(|| format!("cursor store: missing {field}"))
};
let parse = |s: &str, field: &str| -> Result<u64, String> {
s.parse::<u64>().map_err(|e| format!("cursor store: bad {field}: {e}"))
};
let parse32 = |s: &str, field: &str| -> Result<u32, String> {
s.parse::<u32>().map_err(|e| format!("cursor store: bad {field}: {e}"))
};
let height = parse32(next_field("height")?, "height")?;
let tx_index = parse32(next_field("tx_index")?, "tx_index")?;
let mempool_seq = parse(next_field("mempool_seq")?, "mempool_seq")?;
let instance_id = parse(next_field("instance_id")?, "instance_id")?;
Ok(Cursor { height, tx_index, mempool_seq, instance_id })
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum LagPolicy {
#[default]
AutoResume,
Surface,
}
#[derive(Debug, Clone, Copy)]
pub struct Backoff {
pub initial: Duration,
pub max: Duration,
pub multiplier: f64,
pub max_retries: Option<u32>,
}
impl Default for Backoff {
fn default() -> Self {
Backoff {
initial: Duration::from_millis(500),
max: Duration::from_secs(30),
multiplier: 2.0,
max_retries: None,
}
}
}
impl Backoff {
pub fn delay_for(&self, attempt: u32) -> Duration {
let exp = attempt.min(64) as i32;
let scaled = self.initial.as_secs_f64() * self.multiplier.powi(exp);
let capped = scaled.min(self.max.as_secs_f64());
if capped.is_finite() && capped >= 0.0 {
Duration::from_secs_f64(capped).min(self.max)
} else {
self.max
}
}
}
pub struct ResilientConfig {
pub backoff: Backoff,
pub lag_policy: LagPolicy,
pub cursor_store: Arc<dyn CursorStore>,
}
impl Default for ResilientConfig {
fn default() -> Self {
ResilientConfig {
backoff: Backoff::default(),
lag_policy: LagPolicy::AutoResume,
cursor_store: Arc::new(NoopCursorStore),
}
}
}
impl std::fmt::Debug for ResilientConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ResilientConfig")
.field("backoff", &self.backoff)
.field("lag_policy", &self.lag_policy)
.field("cursor_store", &"<dyn CursorStore>")
.finish()
}
}
impl ResilientConfig {
pub fn new() -> Self {
Self::default()
}
pub fn cursor_store(mut self, store: Arc<dyn CursorStore>) -> Self {
self.cursor_store = store;
self
}
pub fn backoff(mut self, backoff: Backoff) -> Self {
self.backoff = backoff;
self
}
pub fn lag_policy(mut self, policy: LagPolicy) -> Self {
self.lag_policy = policy;
self
}
}
pub struct ResilientSubscription {
client: StreamClient,
base: SubscribeOptions,
config: ResilientConfig,
stream: Option<crate::client::EventStream>,
resume: Option<Cursor>,
expect_first_height: Option<u32>,
pending: Option<(Event, Option<Cursor>)>,
commit_next: Option<Cursor>,
committed: Option<Cursor>,
reconnect_attempts: u32,
last_error: Option<StreamError>,
}
impl ResilientSubscription {
pub(crate) fn new(
client: StreamClient,
base: SubscribeOptions,
config: ResilientConfig,
) -> Self {
ResilientSubscription {
client,
base,
config,
stream: None,
resume: None,
expect_first_height: None,
pending: None,
commit_next: None,
committed: None,
reconnect_attempts: 0,
last_error: None,
}
}
pub fn resume_cursor(&self) -> Option<&Cursor> {
self.resume.as_ref()
}
pub fn commit(&mut self) -> Result<(), StreamError> {
self.commit_due()
}
fn commit_due(&mut self) -> Result<(), StreamError> {
let Some(c) = self.commit_next else { return Ok(()) };
if self.committed == Some(c) {
self.commit_next = None;
return Ok(());
}
self.config.cursor_store.save(c)?;
if self.commit_next == Some(c) {
self.commit_next = None;
}
self.committed = Some(c);
Ok(())
}
fn take_pending(&mut self) -> Option<Event> {
let (ev, cur) = self.pending.take()?;
if let Some(c) = cur {
self.resume = Some(c);
}
self.arm_commit();
Some(ev)
}
fn arm_commit(&mut self) {
self.commit_next = self.resume;
}
pub async fn next(&mut self) -> Result<Event, StreamError> {
self.commit_due()?;
if let Some(ev) = self.take_pending() {
return Ok(ev);
}
loop {
if self.stream.is_none() {
if self.reconnect_attempts > 0 {
if let Some(max) = self.config.backoff.max_retries
&& self.reconnect_attempts > max
{
return Err(self
.last_error
.take()
.unwrap_or(StreamError::ControlClosed));
}
let delay = self.config.backoff.delay_for(self.reconnect_attempts - 1);
tokio::time::sleep(delay).await;
}
match self.connect_once().await {
Ok(()) => {}
Err(e) if e.is_retryable() => {
self.reconnect_attempts = self.reconnect_attempts.saturating_add(1);
self.last_error = Some(e);
continue;
}
Err(e) => return Err(e),
}
}
let stream = self.stream.as_mut().expect("connected");
match stream.message().await {
Ok(Some(ev)) => {
self.reconnect_attempts = 0;
self.last_error = None;
let cur = self.stream.as_ref().and_then(|s| s.cursor().copied());
if let Some(out) = self.handle_event(ev, cur).await? {
self.arm_commit();
return Ok(out);
}
}
Ok(None) => {
self.stream = None;
self.reconnect_attempts = self.reconnect_attempts.saturating_add(1);
}
Err(e) if e.is_retryable() => {
self.stream = None;
self.reconnect_attempts = self.reconnect_attempts.saturating_add(1);
self.last_error = Some(e);
}
Err(e) => return Err(e),
}
}
}
async fn handle_event(
&mut self,
ev: Event,
cur: Option<Cursor>,
) -> Result<Option<Event>, StreamError> {
if let Some(expect) = self.expect_first_height
&& let Event::BlockConnected { height, .. } = ev
{
self.expect_first_height = None;
if height > expect {
self.pending = Some((ev, cur));
return Ok(Some(Event::ReplayGap {
resume_height: expect,
first_height: height,
}));
}
}
if let Some(c) = cur {
self.resume = Some(c);
}
match ev {
Event::Lagged { resume_cursor, .. } if self.config.lag_policy == LagPolicy::AutoResume => {
if let Some(c) = resume_cursor {
self.resume = Some(c);
self.config.cursor_store.save(c)?;
self.committed = Some(c);
self.commit_next = None;
}
self.stream = None;
self.reconnect_attempts = self.reconnect_attempts.saturating_add(1);
Ok(None)
}
e @ (Event::CursorAccepted { .. } | Event::CursorRejected { .. }) => Ok(Some(e)),
other => Ok(Some(other)),
}
}
fn seed_resume(&mut self) -> Result<(), StreamError> {
if self.resume.is_none() {
let loaded = self.config.cursor_store.load()?;
if let Some(c) = loaded {
self.committed.get_or_insert(c);
}
self.resume = loaded.or(self.base.from_cursor);
}
Ok(())
}
async fn connect_once(&mut self) -> Result<(), StreamError> {
self.seed_resume()?;
let mut opts = self.base.clone();
opts.from_cursor = self.resume;
let stream = self.client.subscribe(opts).await?;
self.stream = Some(stream);
self.expect_first_height = self.resume.map(|c| c.height.saturating_add(1));
Ok(())
}
}
#[cfg(test)]
mod tests {
#[cfg(unix)]
#[test]
fn file_cursor_store_is_not_world_readable() {
use std::os::unix::fs::PermissionsExt as _;
let dir = std::env::temp_dir().join(format!(
"satd-cursor-mode-{}-{}",
std::process::id(),
line!()
));
std::fs::create_dir_all(&dir).expect("temp dir");
let path = dir.join("scan.cursor");
let store = FileCursorStore::new(&path);
store
.save(Cursor { height: 7, ..Default::default() })
.expect("save");
let mode = std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "cursor file must not be group- or world-readable");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[cfg(unix)]
fn a_pre_existing_temp_file_cannot_smuggle_its_permissions_onto_the_cursor() {
use std::os::unix::fs::PermissionsExt as _;
let dir = std::env::temp_dir().join(format!(
"satd-cursor-tmp-reuse-{}-{}",
std::process::id(),
line!()
));
std::fs::create_dir_all(&dir).expect("temp dir");
let path = dir.join("scan.cursor");
const DECOYS: u64 = 128;
for n in 0..DECOYS {
let decoy = path.with_extension(format!("tmp.{}.{n}", std::process::id()));
std::fs::write(&decoy, b"stale").expect("plant decoy");
std::fs::set_permissions(&decoy, std::fs::Permissions::from_mode(0o666))
.expect("chmod decoy");
}
FileCursorStore::new(&path)
.save(Cursor { height: 7, ..Default::default() })
.expect("save");
let consumed = (0..DECOYS)
.filter(|n| {
!path.with_extension(format!("tmp.{}.{n}", std::process::id())).exists()
})
.count();
assert_eq!(
consumed, 1,
"expected save to take exactly one planted temp name; \
0 means the counter passed the decoy range and the test is vacuous",
);
let mode = std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"a reused temp path must not carry its permissions onto the cursor",
);
let _ = std::fs::remove_dir_all(&dir);
}
use super::*;
use std::sync::Mutex;
#[test]
fn backoff_grows_then_caps() {
let b = Backoff {
initial: Duration::from_millis(500),
max: Duration::from_secs(30),
multiplier: 2.0,
max_retries: None,
};
assert_eq!(b.delay_for(0), Duration::from_millis(500));
assert_eq!(b.delay_for(1), Duration::from_secs(1));
assert_eq!(b.delay_for(2), Duration::from_secs(2));
assert_eq!(b.delay_for(10), Duration::from_secs(30));
assert_eq!(b.delay_for(u32::MAX), Duration::from_secs(30));
}
#[test]
fn defaults_are_autoresume_and_noop() {
let cfg = ResilientConfig::default();
assert_eq!(cfg.lag_policy, LagPolicy::AutoResume);
assert!(cfg.cursor_store.load().unwrap().is_none());
assert_eq!(LagPolicy::default(), LagPolicy::AutoResume);
}
#[test]
fn noop_store_roundtrips_to_none() {
let s = NoopCursorStore;
s.save(Cursor { height: 9, tx_index: 1, mempool_seq: 2, instance_id: 3 })
.unwrap();
assert!(s.load().unwrap().is_none());
}
#[test]
fn file_store_roundtrips_and_missing_is_none() {
let dir = std::env::temp_dir();
let path = dir.join(format!("satd-sdk-cursor-test-{}.cur", std::process::id()));
let _ = std::fs::remove_file(&path);
let store = FileCursorStore::new(&path);
assert!(store.load().unwrap().is_none());
let c = Cursor { height: 951_577, tx_index: 4, mempool_seq: 1234, instance_id: 99 };
store.save(c).unwrap();
let got = store.load().unwrap().expect("cursor present after save");
assert_eq!(got, c);
let c2 = Cursor { height: 951_578, tx_index: 0, mempool_seq: 5, instance_id: 99 };
store.save(c2).unwrap();
assert_eq!(store.load().unwrap().unwrap(), c2);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(path.with_extension("tmp"));
}
#[test]
fn file_store_rejects_garbage() {
let dir = std::env::temp_dir();
let path = dir.join(format!("satd-sdk-cursor-bad-{}.cur", std::process::id()));
std::fs::write(&path, b"not a cursor").unwrap();
let store = FileCursorStore::new(&path);
assert!(matches!(store.load(), Err(StreamError::Decode(_))));
let _ = std::fs::remove_file(&path);
}
#[test]
fn parse_cursor_line_exact() {
let c = parse_cursor_line("951577 4 1234 99\n").unwrap();
assert_eq!(c, Cursor { height: 951_577, tx_index: 4, mempool_seq: 1234, instance_id: 99 });
assert!(parse_cursor_line("1 2 3").is_err()); }
#[test]
fn parse_cursor_line_rejects_out_of_range_height() {
let too_big = (u32::MAX as u64) + 1; assert!(parse_cursor_line(&format!("{too_big} 0 0 0")).is_err());
let c = parse_cursor_line(&format!("1 0 {too_big} {too_big}")).unwrap();
assert_eq!(c.mempool_seq, too_big);
assert_eq!(c.instance_id, too_big);
}
#[derive(Default)]
struct MemStore(Mutex<Option<Cursor>>);
impl CursorStore for MemStore {
fn load(&self) -> Result<Option<Cursor>, StreamError> {
Ok(*self.0.lock().unwrap())
}
fn save(&self, cursor: Cursor) -> Result<(), StreamError> {
*self.0.lock().unwrap() = Some(cursor);
Ok(())
}
}
#[derive(Default)]
struct FlakyStore {
inner: Mutex<Option<Cursor>>,
fail_next: Mutex<u32>,
saves_attempted: Mutex<u32>,
}
impl FlakyStore {
fn failing(n: u32) -> Self {
Self {
fail_next: Mutex::new(n),
..Default::default()
}
}
}
impl CursorStore for FlakyStore {
fn load(&self) -> Result<Option<Cursor>, StreamError> {
Ok(*self.inner.lock().unwrap())
}
fn save(&self, cursor: Cursor) -> Result<(), StreamError> {
*self.saves_attempted.lock().unwrap() += 1;
let mut left = self.fail_next.lock().unwrap();
if *left > 0 {
*left -= 1;
return Err(StreamError::Decode("disk full".into()));
}
*self.inner.lock().unwrap() = Some(cursor);
Ok(())
}
}
#[tokio::test]
async fn commit_retry_after_a_store_failure_actually_writes() {
let store = Arc::new(FlakyStore::failing(1));
let mut sub = ResilientSubscription::new(
StreamClient::for_test(),
SubscribeOptions::default(),
ResilientConfig::new().cursor_store(store.clone()),
);
let c100 = cur(100);
sub.handle_event(
Event::BlockConnected {
hash: vec![0xaa],
height: 100,
},
Some(c100),
)
.await
.unwrap();
sub.arm_commit();
let err = sub.commit_due().unwrap_err();
assert!(
matches!(err, StreamError::Decode(_)),
"transient store failure surfaces"
);
assert_eq!(store.load().unwrap(), None, "nothing was written");
sub.commit_due().expect("retry after a transient failure");
assert_eq!(
store.load().unwrap(),
Some(c100),
"the retried commit must persist the cursor the failed one armed; \
returning Ok() with an empty store is a false durability ack"
);
assert_eq!(
*store.saves_attempted.lock().unwrap(),
2,
"the retry reached the store"
);
}
#[tokio::test]
async fn lag_auto_resume_counts_a_reconnect_attempt() {
let store = Arc::new(MemStore::default());
let mut sub = ResilientSubscription::new(
StreamClient::for_test(),
SubscribeOptions::default(),
ResilientConfig::new()
.cursor_store(store.clone())
.lag_policy(LagPolicy::AutoResume),
);
sub.reconnect_attempts = 0;
let out = sub
.handle_event(
Event::Lagged {
dropped_count: 500,
resume_cursor: Some(cur(42)),
},
Some(cur(42)),
)
.await
.unwrap();
assert!(out.is_none(), "an auto-resumed lag is consumed internally");
assert!(
sub.stream.is_none(),
"the stream is dropped so the loop reconnects"
);
assert_eq!(
sub.reconnect_attempts, 1,
"the re-anchor must count as an attempt; at 0 the loop skips the backoff \
sleep and max_retries never bounds a chronically slow consumer"
);
}
#[test]
fn mem_store_observes_saves() {
let s = MemStore::default();
assert!(s.load().unwrap().is_none());
let c = Cursor { height: 1, tx_index: 0, mempool_seq: 0, instance_id: 7 };
s.save(c).unwrap();
assert_eq!(s.load().unwrap(), Some(c));
}
fn sub_with(store: &Arc<MemStore>) -> ResilientSubscription {
ResilientSubscription::new(
StreamClient::for_test(),
SubscribeOptions::default(),
ResilientConfig::new().cursor_store(store.clone()),
)
}
fn cur(height: u32) -> Cursor {
Cursor { height, tx_index: 0, mempool_seq: 0, instance_id: 1 }
}
#[tokio::test]
async fn commit_on_poll_defers_store_write_until_next_poll() {
let store = Arc::new(MemStore::default());
let mut sub = sub_with(&store);
let (c100, c101) = (cur(100), cur(101));
sub.commit_due().unwrap();
assert!(sub.take_pending().is_none());
let out = sub
.handle_event(Event::BlockConnected { hash: vec![0xaa], height: 100 }, Some(c100))
.await
.unwrap();
assert!(matches!(out, Some(Event::BlockConnected { height: 100, .. })));
sub.arm_commit();
assert_eq!(store.load().unwrap(), None, "cursor must not commit before the next poll");
sub.commit_due().unwrap();
assert_eq!(store.load().unwrap(), Some(c100), "block@100 committed only on the next poll");
assert!(sub.take_pending().is_none());
let out = sub
.handle_event(Event::BlockConnected { hash: vec![0xbb], height: 101 }, Some(c101))
.await
.unwrap();
assert!(out.is_some());
sub.arm_commit();
assert_eq!(store.load().unwrap(), Some(c100), "block@101 still not committed");
sub.commit_due().unwrap();
assert_eq!(store.load().unwrap(), Some(c101));
}
#[tokio::test]
async fn explicit_commit_checkpoints_last_delivered_event() {
let store = Arc::new(MemStore::default());
let mut sub = sub_with(&store);
let c = cur(10);
sub.handle_event(Event::BlockConnected { hash: vec![1], height: 10 }, Some(c))
.await
.unwrap();
sub.arm_commit();
assert_eq!(store.load().unwrap(), None);
sub.commit().unwrap();
assert_eq!(store.load().unwrap(), Some(c));
sub.commit().unwrap();
assert_eq!(store.load().unwrap(), Some(c));
}
#[tokio::test]
async fn lag_autoresume_persists_immediately_and_supersedes_deferred_commit() {
let store = Arc::new(MemStore::default());
let mut sub = sub_with(&store); let armed = cur(50);
sub.resume = Some(armed);
sub.commit_next = Some(armed);
let recover = cur(75);
let out = sub
.handle_event(
Event::Lagged { dropped_count: 3, resume_cursor: Some(recover) },
None,
)
.await
.unwrap();
assert!(out.is_none(), "an auto-resumed lag is consumed internally");
assert_eq!(store.load().unwrap(), Some(recover), "lag re-anchor persisted immediately");
assert_eq!(sub.committed, Some(recover));
assert!(sub.commit_next.is_none(), "deferred commit superseded");
assert!(sub.stream.is_none(), "stream torn down for reconnect");
}
#[tokio::test]
async fn replay_gap_defers_commit_until_stashed_block_delivered() {
let store = Arc::new(MemStore::default());
let mut sub = sub_with(&store);
let pre = cur(100);
store.save(pre).unwrap();
sub.resume = Some(pre);
sub.committed = Some(pre);
sub.expect_first_height = Some(101);
let gap_cur = cur(5000);
let out = sub
.handle_event(Event::BlockConnected { hash: vec![0xcc], height: 5000 }, Some(gap_cur))
.await
.unwrap();
assert!(
matches!(out, Some(Event::ReplayGap { resume_height: 101, first_height: 5000 })),
"a clamped replay surfaces a ReplayGap before the block"
);
sub.arm_commit(); assert_eq!(sub.resume, Some(pre), "high-water stays at the pre-gap anchor across the gap");
sub.commit_due().unwrap();
assert_eq!(store.load().unwrap(), Some(pre), "must not commit past the gap");
let blk = sub.take_pending().expect("the stashed block is delivered next");
assert!(matches!(blk, Event::BlockConnected { height: 5000, .. }));
assert_eq!(sub.resume, Some(gap_cur), "high-water advances to the delivered block");
sub.commit_due().unwrap();
assert_eq!(store.load().unwrap(), Some(gap_cur));
}
#[tokio::test]
async fn committed_seeded_from_store_on_first_connect() {
let store = Arc::new(MemStore::default());
let persisted = cur(900);
store.save(persisted).unwrap();
let mut sub = sub_with(&store);
sub.seed_resume().unwrap();
assert_eq!(sub.resume, Some(persisted));
assert_eq!(sub.committed, Some(persisted), "loaded cursor seeds `committed`");
sub.arm_commit();
sub.commit_due().unwrap();
assert_eq!(store.load().unwrap(), Some(persisted));
}
}