tursodatabase/turso · critical
post_balancing_seek_key should be Some
Error message
post_balancing_seek_key should be Some
What it means
When a delete decides rebalancing is needed, it takes post_balancing_seek_key - the key saved at delete start (DeleteState::LoadPage carries Some(target_key)) so the cursor can re-position after balancing. post_balancing_seek_key.take().expect("post_balancing_seek_key should be Some") fires when that Option was already consumed or was never set on this path.
Source
Thrown at core/storage/btree.rs:7177
}) = self.state
else {
unreachable!("expected check needs balancing state");
};
if needs_balancing {
let balance_only_ancestor =
!leaf_underflows && interior_overflows_or_underflows;
if balance_only_ancestor {
// Only need to balance the ancestor page; move there immediately.
while self.stack.current() > btree_depth {
self.stack.pop();
}
}
let balance_both = leaf_underflows && interior_overflows_or_underflows;
turso_assert!(matches!(self.balance_state.sub_state, BalanceSubState::Start), "no balancing operation should be in progress during delete", { "sub_state": self.balance_state.sub_state });
let post_balancing_seek_key = post_balancing_seek_key
.take()
.expect("post_balancing_seek_key should be Some");
self.save_context(post_balancing_seek_key);
self.state = CursorState::Delete(DeleteState::Balancing {
balance_ancestor_at_depth: if balance_both {
Some(btree_depth)
} else {
None
},
});
} else {
// No balancing needed.
if interior_node_was_replaced {
// If we did replace an interior node, we need to advance the cursor once to
// get back at the interior node that now has the replaced content.
// The reason it is important to land here is that the replaced cell was smaller (LT) than the deleted cell,
// so we must ensure we skip over it. I.e., when BTreeCursor::next() is called, it will move past the cell
// that holds the replaced content.
self.state =
CursorState::Delete(DeleteState::PostInteriorNodeReplacement);View on GitHub (pinned to 492c4a71cd)
Solutions
- Report to Turso with the statement sequence - double-take or missing key in the delete state machine
- Retry with a freshly prepared DELETE statement and cursor
- Run PRAGMA integrity_check after the panic to confirm the tree is intact
- Upgrade the engine version
Defensive patterns
Strategy: try-catch
Try / catch
let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
conn.execute("DELETE FROM t WHERE rowid = ?", [rowid])
}));
if result.is_err() { conn.close().ok(); /* reopen and retry once */ } Prevention
- Prepare a new statement per logical delete batch rather than re-binding one cursor for many deletes
- Keep engine version current; DeleteState key plumbing sees frequent fixes
- Wrap deletes in transactions to define clean retry boundaries
- Report double-take style repros upstream
When it happens
Trigger: A delete whose leaf or interior page under/overflows (triggering DeleteState::Balancing) after the seek key was already taken by an earlier state transition, or a delete initiated without a target key so the Option was None from the start.
Common situations: Repeated deletes reusing one cursor where a duplicate take() races IO re-entry, deletes driven by UPDATE on indexed columns, engine regressions in DeleteState field movement (post_balancing_seek_key.take() chains).
Related errors
- ancestor page should be on the stack
- invalid cell payload
- parent page should be on the stack
- there should be a pointer
- invalid position to read current mvcc row
AI-assisted analysis of tursodatabase/turso@492c4a71cd (2026-08-20).
Data as JSON: /api/errors/f96093dfdc3a6d5b.
Report an issue: GitHub.