fix: reset fetch_notes cursor stranded above the seq high-water - #97
fix: reset fetch_notes cursor stranded above the seq high-water#97WiktorStarczewski wants to merge 4 commits into
Conversation
Review round (two independent passes)Ran an internal adversarial code review and an independent Codex review. Both concluded the primary logic is correct — neither could construct a false-positive reset for a legitimate client (within a single shared seq space, every client cursor derives from a server-echoed [MEDIUM — Codex] Streaming reset-storm. In [SUGGESTION — internal] gRPC echo-heal was untested. Added [NIT] Docs/comments. Documented that an empty tag set short-circuits before the stranded check; added a comment that the strict Not changed (reviewer agreed no action): two now-dead defensive int conversions ( Follow-up: a streaming-path integration test for the heal would lock in the Finding-2 fix, but there's no existing Verification after fixes: |
…high_water_seq docs
Review round 2 (both passes, focused on the streaming heal)Re-ran an internal
Not changed (both reviewers agreed no action): the two dead defensive int conversions; the metric-description "at or below" wording (accurate). Verification after fixes: This resolves the round-1 follow-up ("no streaming test harness") — the streaming manager now has direct heal tests. |
|
Heads up: this does not currently pass Reproduced on this branch rebased onto current Collapsing the if effective > 0
&& let Some(high_water) = high_water_seq(conn)
// Strict `>` is deliberate: ...
&& effective > high_water
{
effective = 0;
}Context for why I was in here: I have been reconciling this with a per-subscriber stream cursor for #96, #122 and finding 3 of #123, discussed on #96. The two conflict in |
Problem
A wallet whose stored transport cursor is higher than the server's current max
seqfetches zero notes forever. Everyfetch_notes(seq > cursor)matches nothing, and the handler echoesrcursor = max(cursor, max_seq_returned)— so the too-high cursor is returned verbatim and never decreases. Notes correctly addressed to that wallet sit on the server, undeliverable, with no self-healing on either side.This was found in the wild: a testnet wallet holding cursor
3487against a server whose maxseqis812.FetchNotes(tags, cursor=0)returns the notes;cursor=3487returns nothing. Full root-cause writeup: the mechanism is verified againstorigin/main(client persists only server-echoed cursors;LEGACY_CURSOR_THRESHOLD = 1e12doesn't catch a small value like3487).How a cursor ends up above the max seq
The client only ever persists a server-echoed
rcursor(= max(seen seqs)), so a stored cursor above the current maxseqcan only mean the server's seq space regressed: the backing DB was recreated (volume reset / restore-from-empty / endpoint swap) andAUTOINCREMENTrestarted low, while the client still holds a cursor from the previous, larger epoch. Theadd_seq_cursormigration itself backfillsseqincreated_atorder and its comment assumes a single deployment lifetime — this is the blind spot of that assumption.Fix
Detect a stranded cursor — one at/below the legacy threshold but strictly above the current
seqhigh-water — and reset it to0so the client re-scans the current epoch.sqlite_sequence.seq(theAUTOINCREMENThigh-water) is the right signal: it only decreases across a DB recreation, never within a lifetime (survivesDELETE/VACUUM/cleanup_old_notes). A legitimately caught-up client sits atcursor == high_waterand is untouched — so no false positives.Crucially, the reset must also heal the echoed cursor:
fetch_notes_by_tagsnow returns the effective cursor it used, and the gRPC handler basesrcursoron that (not the client's claimed cursor). Without this, the handler would keep echoing the stranded3487and the client would re-download the whole epoch on every poll and never converge. Basing the echo on the effective cursor lets a stranded client heal in 1–2 polls and paginate normally.Bonus: this same echo change fixes the pre-existing legacy-µs-cursor path, which had the identical "re-download every poll, never heal" behavior.
Both the pull path (
fetch_notes) and the push path (streaming.rs) are covered.Changes
sqlite/mod.rs:high_water_seq()helper (readssqlite_sequence, fail-safeNoneon any error → never falsely resets); stranded-cursor detection infetch_notes_by_tags, run in the same snapshot as the query; returns(notes, effective_cursor).grpc/mod.rs: base the response cursor on the effective cursor.streaming.rs: advance the subscription cursor from the effective cursor.metrics.rs:db_fetch_notes_stranded_cursor_reset_countcounter (mirrors the legacy-reset counter) so operators can see it fire.database/mod.rs: trait/wrapper signature update; new testtest_fetch_notes_resets_cursor_stranded_above_high_water; updated the legacy-reset test's sanity check (see below).Behavior change (intentional)
A
fetch_notescursor strictly above the current high-water is now reset to0instead of returning empty. The existingtest_fetch_notes_resets_legacy_cursorhad a sanity check asserting thatcursor=1000against a one-note DB (seq1) returns empty; that scenario is exactly a stranded cursor, so its assertion was updated to use a caught-up cursor (== high_water), which is the genuine "no reset" case.Assumption
The reset assumes a single shared seq space (single writer / shared volume). A sharded deployment with independent per-instance seq spaces behind a naive load balancer would thrash — but such a deployment is already incompatible with cursor semantics, and the analysis confirmed a single logical seq space (all backend IPs returned identical results). A sharded setup would need epoch-in-cursor instead; noted as a follow-up if that ever changes.
Verification
cargo test -p miden-note-transport-node— 17/17 pass (new + updated tests included;test_fetch_notes_paginates_at_batch_limitconfirms normal backlog pagination is undisturbed — during pagination the cursor is always ≤ high-water).CLIPPY_CONF_DIR=configs cargo clippy --locked --all-targets --workspace -- -D warnings— clean.cargo +nightly fmt --all --check(repo config) — clean.Draft: opening for review. No migration and no wire-format change; fixes every already-deployed client with no client update. A complementary client-side change (SDK
fetch_all_private_notesnever-regress guard) is optional defense-in-depth but not required once this lands.