fix: reset fetch_notes cursor stranded above the seq high-water - #97
WiktorStarczewski 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 |
Dominik1999
left a comment
There was a problem hiding this comment.
Full review across five lenses: logical correctness, adversarial/security, API design, client-side semantics (verified against the rust-sdk consumer code), and hands-on verification (branch built and tested in isolation, including reverting each component of the fix to prove the new tests are non-vacuous).
Verdict
The approach is right and the core logic is verified sound - approving the design, but requesting changes on three items below before merge.
What was verified positively:
- The invariant is airtight: any echoed cursor is
<=the AUTOINCREMENT high-water, which is monotonic within a DB lifetime and survives retentionDELETEs andVACUUM, so strict>only fires cross-epoch - no false positives. The high-water read and the notesSELECTshare one transaction snapshot (no race), and the streaming heal converges (reset fires at most once per stranding; no replay storm). - 21/21 tests pass on the branch; each of the five new tests fails when its specific component of the fix is reverted (checked component-by-component). Extra probes confirmed:
cursor == high_wateris not reset; deleting all rows via retention does not cause a false reset (sqlite_sequenceretains the high water); legacy-and-stranded cursors take the legacy path exactly once with a sane echoed cursor. - Client behavior confirmed in the rust-sdk source: the client adopts a decreased cursor unconditionally and dedupes by
NoteId, so the heal works end to end. TheStreamNotesUpdatecursor never reaches client persistence, so streaming resets cannot re-strand a client.
Required changes
-
Note-volume disclosure oracle (new with this PR) -
crates/node/src/node/grpc/mod.rs/crates/node/src/database/sqlite/mod.rs. Echoing the reset cursor when zero notes match creates an unauthenticated probe: pick a tag with no notes, binary-search cursor values, andresponse.cursor == cursorvs== 0distinguishes below/above the high-water - the exact cumulativesqlite_sequencevalue (notes ever stored, which survives retention and is strictly more thanStatsexposes) leaks in ~40 requests, plus DB-recreation timing. For a privacy-focused transport this is meaningful metadata. Suggested fix: only surfaceeffective_cursorwhen notes were actually returned; on a no-match query echo the claimed cursor unchanged. Healing is unaffected - it only matters when there is something to deliver, and the first note that exists makes the reset query find it and heal the echo. Note this invertstest_fetch_notes_response_cursor_heals_when_no_matching_notes, which currently pins the leaking behavior. -
Clippy failure on current stable -
crates/node/src/database/sqlite/mod.rs. The nestedifin the stranded check firesclippy::collapsible_ifunder current stable clippy (let-chains era) and the workspace promotes warnings to errors (already reproduced by a commenter here). It passes on 1.96.1, which is why local runs look clean. Collapse into a let-chain, keeping the boundary comment on the>comparison. -
Document the cursor contract; withdraw the client-side guard suggestion -
proto/proto/miden_note_transport.proto. This PR makesFetchNotesResponse.cursorable to decrease relative to the request cursor - a semver-invisible behavioral change on a public contract. Add to the proto docs (also onStreamNotesUpdate.cursor): the response cursor MAY be lower than the request cursor and clients MUST persist it verbatim, MUST NOT clamp it to a monotonic maximum. Related: the PR description proposes a client-side "never-regress guard" as defense-in-depth - applied to the global cursor, that guard would silently re-introduce the exact stranding this PR fixes (the SDK's unguarded global cursor is a correctness requirement, not an accident). Recommend removing that suggestion from the description.
Recommended (non-blocking)
- Replace the
(Vec<StoredNote>, u64)return with a named struct (FetchResult { notes, effective_cursor }) - the API break is happening anyway; a struct makes it the last one and gives fix (1) a natural home. - Reword the "one-insert-wide coincidence" comment: the miss window is "any first poll after the regrown high-water reaches the stranded cursor" - still low-probability given polling frequency, but the current framing understates it.
- The "server seq space regressed; DB recreated" WARN is client-forgeable on demand (any crafted cursor) - consider DEBUG or rate-limiting it, and dashboard
db_fetch_notes_stranded_cursor_reset_count: it is the only operator signal for a restore or a misconfigured load balancer. - Follow-ups worth filing: reset policy now lives in the DB trait return, which a future Postgres backend (#157) must reimplement per backend - exposing
high_water_seq()as the primitive and moving the policy to the handler would keep it written once; an additivebool cursor_resetresponse field for client debuggability; thundering-herd after a restore (all clients replay simultaneously, compounded by #121's missing byte caps).
Checked and fine
Replay amplification is unchanged by this PR (cursor 0 always returned the full backlog; caps are #121, pre-existing). The fail-safe None path is not attacker-inducible. Streaming subscriber cursors cannot be client-injected. Pushing the high water via SendNote can suppress a victim's heal, but never leaves them worse than pre-PR (worth a one-line note that "always heals" is best-effort against concurrent writers). The waker-gated delivery drop in forward_updates is pre-existing #122, not this PR's problem.
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.