Skip to content

Commit 4568808

Browse files
committed
feat(runtime): add an opt-in per-subscription max_lookback for bounded resume
1 parent fa332a2 commit 4568808

3 files changed

Lines changed: 64 additions & 19 deletions

File tree

crates/nexum-runtime/src/manifest/types.rs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,18 @@ pub enum Subscription {
7171
event_signature: Option<String>,
7272
/// Resume across engine restarts. When `true` the host persists a
7373
/// durable per-subscription cursor and re-opens the log poller
74-
/// from just after the last dispatched block (clamped), instead of
75-
/// at the current head. Delivery is then at-least-once, so the
76-
/// module must tolerate redelivery (the chassis idempotency
77-
/// journal already dedups it).
74+
/// from just after the last dispatched block, instead of at the
75+
/// current head. Delivery is then at-least-once, so the module must
76+
/// tolerate redelivery (the chassis idempotency journal already
77+
/// dedups it).
7878
#[serde(default)]
7979
resume: bool,
80+
/// Optional cap on how far back a `resume` subscription will
81+
/// backfill, in blocks. `None` (the default) backfills the entire
82+
/// gap with no loss; set it only for a consumer that explicitly
83+
/// tolerates dropping the oldest missed blocks.
84+
#[serde(default)]
85+
max_lookback: Option<u64>,
8086
},
8187
/// Cron-scheduled tick. 0.2 parses but does not dispatch; the
8288
/// supervisor emits a warning so the operator knows the

crates/nexum-runtime/src/runtime/event_loop.rs

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -130,17 +130,15 @@ where
130130
for sub in subs {
131131
let (tx, rx) = mpsc::channel::<TaggedChainLog>(RECONNECT_CHANNEL_BUF);
132132
let pool = pool.clone();
133-
// The cursor key is constant per subscription and cloned onto every
134-
// log; `Arc` keeps that clone cheap.
135-
let cursor_key: Option<Arc<str>> = sub.cursor_key.map(Arc::from);
133+
let resume = ChainLogResume {
134+
// The cursor key is constant per subscription and cloned onto every
135+
// log; `Arc` keeps that clone cheap.
136+
cursor_key: sub.cursor_key.map(Arc::from),
137+
initial_cursor: sub.initial_cursor,
138+
max_lookback: sub.max_lookback,
139+
};
136140
tasks.push(executor.spawn(Box::pin(reconnecting_chain_log_task(
137-
pool,
138-
sub.module,
139-
sub.chain,
140-
sub.filter,
141-
cursor_key,
142-
sub.initial_cursor,
143-
tx,
141+
pool, sub.module, sub.chain, sub.filter, resume, tx,
144142
))));
145143
let tagged: TaggedChainLogStream = Box::pin(receiver_stream(rx));
146144
streams.push(tagged);
@@ -245,6 +243,18 @@ where
245243
}
246244
}
247245

246+
/// Per-subscription resume and backfill knobs for a chain-log task.
247+
struct ChainLogResume {
248+
/// Durable cursor key, `Some` for a `resume` subscription; the block
249+
/// under it seeds `initial_cursor`.
250+
cursor_key: Option<Arc<str>>,
251+
/// Persisted resume block read at boot; the first open starts here.
252+
initial_cursor: Option<u64>,
253+
/// Opt-in cap (in blocks) on how far back the poller backfills; `None`
254+
/// backfills the whole gap.
255+
max_lookback: Option<u64>,
256+
}
257+
248258
/// Poller-backed loop for a single (module, chain) chain-log
249259
/// subscription. Instead of `eth_subscribe(logs)` - which silently
250260
/// drops events emitted during a WebSocket reconnect - it drives
@@ -254,19 +264,25 @@ where
254264
/// transport's own retries), or a reorg deeper than the poller's
255265
/// retained history, ends the poller stream; this loop then re-opens
256266
/// from the block after the last one it delivered and backfills the
257-
/// entire missed range (nothing skipped) with exponential backoff.
267+
/// entire missed range (nothing skipped) with exponential backoff -
268+
/// unless the subscription set `max_lookback`, which bounds how far back
269+
/// the backfill reaches.
258270
async fn reconnecting_chain_log_task<C>(
259271
pool: C,
260272
module: String,
261273
chain: Chain,
262274
filter: alloy_rpc_types_eth::Filter,
263-
cursor_key: Option<Arc<str>>,
264-
initial_cursor: Option<u64>,
275+
resume: ChainLogResume,
265276
tx: mpsc::Sender<TaggedChainLog>,
266277
) -> TaskExit
267278
where
268279
C: ChainProvider + Send + Sync + 'static,
269280
{
281+
let ChainLogResume {
282+
cursor_key,
283+
initial_cursor,
284+
max_lookback,
285+
} = resume;
270286
let chain_id = chain.id();
271287
let mut attempt: u32 = 0;
272288
let mut last_event: Option<Instant> = None;
@@ -304,11 +320,28 @@ where
304320
// - otherwise a re-open resumes just after the last delivered block
305321
// (within-process gap-free), or at head on the very first open.
306322
// Either way the whole gap is backfilled with no lower floor, so
307-
// nothing is ever skipped.
308-
let start_block = match boot_resume.take() {
323+
// nothing is skipped unless the subscription set `max_lookback`.
324+
let mut start_block = match boot_resume.take() {
309325
Some(resume) => resume.min(head),
310326
None => poller_resume_block(last_seen_block, head),
311327
};
328+
// Opt-in bound: `max_lookback` caps how far back a resume
329+
// subscription backfills. The default (`None`) backfills fully; a
330+
// set cap clamps the start up to `head - cap` and surfaces the
331+
// dropped oldest blocks.
332+
if let Some(cap) = max_lookback {
333+
let floor = head.saturating_sub(cap);
334+
if start_block < floor {
335+
warn!(
336+
module = %module,
337+
chain_id,
338+
skipped_from = start_block,
339+
skipped_to = floor,
340+
"chain-log gap exceeds max_lookback - skipping the oldest missed blocks",
341+
);
342+
start_block = floor;
343+
}
344+
}
312345
// A large gap is backfilled in full (never skipped); surface it so a long
313346
// catch-up is visible rather than looking like a stall.
314347
if head.saturating_sub(start_block) >= LARGE_GAP_LOG_THRESHOLD {

crates/nexum-runtime/src/supervisor.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,7 @@ impl<T: RuntimeTypes> Supervisor<T> {
549549
address,
550550
event_signature,
551551
resume,
552+
max_lookback,
552553
} = sub
553554
{
554555
match build_alloy_filter(address.as_deref(), event_signature.as_deref()) {
@@ -574,6 +575,7 @@ impl<T: RuntimeTypes> Supervisor<T> {
574575
filter,
575576
cursor_key,
576577
initial_cursor,
578+
max_lookback: *max_lookback,
577579
});
578580
}
579581
Err(err) => warn!(
@@ -1111,6 +1113,10 @@ pub struct ChainLogSub {
11111113
/// The persisted resume block, read at boot for a `resume`
11121114
/// subscription; `None` on first run or when `resume` is off.
11131115
pub initial_cursor: Option<u64>,
1116+
/// Opt-in cap on how far back the poller backfills, in blocks. `None`
1117+
/// backfills the whole gap; `Some(cap)` bounds the start to
1118+
/// `head - cap`, dropping the oldest missed blocks.
1119+
pub max_lookback: Option<u64>,
11141120
}
11151121

11161122
/// Durable resume-cursor key for a chain-log subscription. Derived from

0 commit comments

Comments
 (0)