Repository navigation
fix(rollup): read the inbox cursor off the rows so an L1 rollback cannot strand it - #51
Merged
Merged
Conversation
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>
This was referenced Sep 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
RollupMessageIndexwalks the TzKT inbox with anid.gt=cursor that lived in memory, while its rows are written inside thetezos_headhandler 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 staysrealtime,dipdup_headtracks 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).rollup_inbox_messageon 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._inbox_level_cursor(write-only) is gone.Commits
Red-then-green pairs, to be merged as a merge commit:
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 ownModelUpdate.revertover a real versioned transaction.fix(rollup): the cursor is derived from the rows.test(rollup): an emptied table on a resumed database must resume from the window — red at (2).fix(rollup): window floor on first need; id-only cursor read.Verification
make test140 passed (sqlite),make test-pg140 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 insideon_index_rollback, loses the message again on two consecutive rollbacks — the exact production sequence).Not in this PR
rollup_outbox_messagerows are journaled the same way and their level has already leftPendingOutboxLevels(immunedipdup_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