Skip to content

fix(rollup): read the inbox cursor off the rows so an L1 rollback cannot strand it - #51

Merged
Wizard1209 merged 4 commits into
masterfrom
fix/inbox-cursor-from-db
Sep 21, 2026
Merged

Wizard1209 merged 4 commits into
masterfrom
fix/inbox-cursor-from-db

Conversation

@Wizard1209

Copy link
Copy Markdown
Member

Summary

  • RollupMessageIndex walks the TzKT inbox with an id.gt= cursor that lived in memory, while its rows are written inside the tezos_head handler and therefore journaled by DipDup. An L1 head rollback reverts those rows (the page and the level-0 sentinel alike) but not the cursor, so the reverted messages were never asked for again — silently: the index stays realtime, dipdup_head tracks the chain, and the only symptom is an L1 deposit at that level that never matches (mainnet, 2026-09-20, inbox id 92881426 at level 15024458; 10–24 head rollbacks a day on mainnet).
  • The cursor is now read back from rollup_inbox_message on every page, never carried between passes: the journal revert moves it by construction. The window floor used while the table is empty is computed on first need on either boot path, so a resumed process whose rows were all reverted resumes from the window and not from id 0.
  • Cost: one primary-key read of the id per fetched page, nothing per row. _inbox_level_cursor (write-only) is gone.

Commits

Red-then-green pairs, to be merged as a merge commit:

  1. test(rollup): a rolled-back head loses a message for good (two shapes: cursor on a row, cursor on the sentinel) — red on master, uses DipDup's own ModelUpdate.revert over a real versioned transaction.
  2. fix(rollup): the cursor is derived from the rows.
  3. test(rollup): an emptied table on a resumed database must resume from the window — red at (2).
  4. fix(rollup): window floor on first need; id-only cursor read.

Verification

make test 140 passed (sqlite), make test-pg 140 passed, make lint + uv run ruff check . clean. Two independent implementations were built and graded against each other; this one is the one whose repair writes are themselves subject to the journal (the alternative, a resync inside on_index_rollback, loses the message again on two consecutive rollbacks — the exact production sequence).

Not in this PR

rollup_outbox_message rows are journaled the same way and their level has already left PendingOutboxLevels (immune dipdup_meta) when a rollback reverts them — the same defect one table over, for a follow-up with its own red test.

🤖 Generated with Claude Code

Wizard1209 and others added 4 commits September 20, 2026 19:57
The rollup-message index is pumped from the `tezos_head` handler, so DipDup
journals every inbox row it writes in realtime and `ctx.rollback` deletes them
again when the head is rolled back — the level-0 cursor sentinel included. The
`id.gt=` cursor lives only in the process, so it stays above the hole the revert
opened and nobody ever asks for those messages again.

Both tests drive the real index over a universe-fake TzKT, open the versioned
transaction DipDup opens for a handler, and then run DipDup's own
`ModelUpdate.revert` over the journal exactly as `ctx.rollback` does — one for a
page whose cursor a real row carries, one for a page whose cursor only the
sentinel can carry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The index is not the only writer of `rollup_inbox_message`: it is pumped from
the `tezos_head` handler, so DipDup journals its realtime writes and an L1 head
rollback reverts them — the page's rows and the level-0 sentinel alike. The
`id.gt=` cursor lived in the process and never learned about it, so the walk
resumed above the hole the revert opened and those messages were never requested
again. The L1 deposit came back with the rolled-back head; its inbox message did
not, and the deposit can no longer match.

`_inbox_cursor` derives the cursor from the rows the journal governs — the max
id, sentinel included — at the start of each pass, so a revert moves it back by
construction. `_prepare_new_index` no longer copies it out; it only settles what
the rows cannot say, the pending outbox set or, for a database with no rows at
all, the window to start from. `_inbox_level_cursor` went with it: nothing read
it.

One extra primary-key lookup per fetched page, none per row, and the ordering
contract in `_commit_inbox_page` is untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… id 0

A rollback that reverts every surviving row leaves the cursor with no row to
stand on. On a resumed database the fallback is the start id computed at boot,
and the resume branch never computes it — so the walk asks for id.gt=0 and
re-reads the whole shared inbox.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ursor id alone

The floor is what a walk with no row to stand on resumes from. Computing it
only on a fresh database left a resumed process at id 0 once a rollback had
reverted every row it had — a re-walk of the whole shared inbox. It is now
derived from the window whenever the table is empty, on either boot path.

The per-page cursor read fetches the id alone instead of the whole row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@Wizard1209
Wizard1209 merged commit d2ef0fb into master Sep 21, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant