Summary
Every storage manager in src/inscriptive/ commits its ephemeral delta the same way: iterate the delta maps and, for each entry, write to disk then to in-memory, returning early on the first error. Because each write is an independent sled operation (no transaction()), a failure mid-apply_changes leaves the manager in a half-applied state that rollback_last cannot recover — rollback only restores the in-memory delta from the backup; it does not undo the disk writes that already succeeded.
Affected file(s)
All managers with an apply_changes:
src/inscriptive/coin_manager/coin_manager.rs (~apply_changes, ~lines 1470-1985)
src/inscriptive/state_manager/state_manager.rs (~lines 247-346)
src/inscriptive/flame_manager/flame_manager.rs
src/inscriptive/registry/registry.rs (~lines 1093-1598)
src/inscriptive/privileges_manager/privileges_manager.rs (~lines 836-1054)
src/inscriptive/graveyard/graveyard.rs (~lines 236-272)
src/inscriptive/params_manager/params_manager.rs (~lines 937-2189)
Location / pattern
Representative example, coin_manager.rs (account-balance commit):
for (account_key, ephemeral_account_balance) in self.delta.updated_account_balances.iter() {
// 3.1 On-disk insertion.
{
let tree = self.on_disk_accounts.open_tree(account_key).map_err(...)?; // may fail
tree.insert(ACCOUNT_BALANCE_SPECIAL_DB_KEY, ephemeral_account_balance.to_le_bytes().to_vec())
.map_err(...)?; // may fail
}
// 3.2 In-memory insertion.
{
let mut_permanent_account_body = self.in_memory_accounts.get_mut(account_key)
.ok_or(...)?; // may fail
mut_permanent_account_body.update_balance(*ephemeral_account_balance);
}
}
The same shape repeats across the other delta maps (contracts, shadow spaces, deallocations, …) and across every other manager.
The engine lifecycle (driven from src/executive/exec_ctx/) is:
pre_execution() → delta.clone() into backup_of_delta
- mutations write to
delta only
- commit →
apply_changes(), or abort → rollback_last() → delta = backup_of_delta.clone()
flush_delta()
Root cause / analysis
There are two separate gaps:
Gap A — no transactional batch. apply_changes performs N independent sled writes. sled does batch writes via tree.transaction(...) / Batch, but none of the managers use it. If write k of N fails (disk full, I/O error), writes 1..k-1 are already on disk and in memory, and the function returns Err. The caller cannot distinguish "nothing applied" from "partially applied."
Gap B — rollback only covers the delta. rollback_last (coin_manager.rs ~line 1464, and equivalents) does self.restore_delta() which clones backup_of_delta back over delta. This reverts uncommitted ephemeral changes — it does not touch on-disk state or the in-memory permanent maps. So rollback is a no-op against a partially-applied apply_changes: the half-applied writes stay. (In normal use rollback_last is called instead of apply_changes, never after a failed apply_changes, so this is latent rather than active — but the contract is misleading.)
A related smell: the comment in state_manager.rs ~line 271 — "critical: apply on-disk before in-memory for atomicity" — implies atomicity that does not actually exist; the on-disk-first ordering just changes the failure mode, it doesn't provide a transaction.
Impact
- Corruption on partial failure. If any sled write fails partway through a commit, the database is left with an inconsistent mix of old and new values (e.g. an account balance updated but its shadow allocations not), and there is no recovery path.
- In-memory / on-disk divergence. When the on-disk write succeeds but the in-memory
get_mut fails (or vice versa across iterations), the two views disagree until restart.
- For a Bitcoin rollup, half-applied balance/ allocation state is the worst-case failure mode.
Summary
Every storage manager in
src/inscriptive/commits its ephemeral delta the same way: iterate the delta maps and, for each entry, write to disk then to in-memory, returning early on the first error. Because each write is an independent sled operation (notransaction()), a failure mid-apply_changesleaves the manager in a half-applied state thatrollback_lastcannot recover — rollback only restores the in-memory delta from the backup; it does not undo the disk writes that already succeeded.Affected file(s)
All managers with an
apply_changes:src/inscriptive/coin_manager/coin_manager.rs(~apply_changes, ~lines 1470-1985)src/inscriptive/state_manager/state_manager.rs(~lines 247-346)src/inscriptive/flame_manager/flame_manager.rssrc/inscriptive/registry/registry.rs(~lines 1093-1598)src/inscriptive/privileges_manager/privileges_manager.rs(~lines 836-1054)src/inscriptive/graveyard/graveyard.rs(~lines 236-272)src/inscriptive/params_manager/params_manager.rs(~lines 937-2189)Location / pattern
Representative example,
coin_manager.rs(account-balance commit):The same shape repeats across the other delta maps (contracts, shadow spaces, deallocations, …) and across every other manager.
The engine lifecycle (driven from
src/executive/exec_ctx/) is:pre_execution()→delta.clone()intobackup_of_deltadeltaonlyapply_changes(), or abort →rollback_last()→delta = backup_of_delta.clone()flush_delta()Root cause / analysis
There are two separate gaps:
Gap A — no transactional batch.
apply_changesperforms N independent sled writes. sled does batch writes viatree.transaction(...)/Batch, but none of the managers use it. If writekofNfails (disk full, I/O error), writes1..k-1are already on disk and in memory, and the function returnsErr. The caller cannot distinguish "nothing applied" from "partially applied."Gap B — rollback only covers the delta.
rollback_last(coin_manager.rs~line 1464, and equivalents) doesself.restore_delta()which clonesbackup_of_deltaback overdelta. This reverts uncommitted ephemeral changes — it does not touch on-disk state or the in-memory permanent maps. So rollback is a no-op against a partially-appliedapply_changes: the half-applied writes stay. (In normal userollback_lastis called instead ofapply_changes, never after a failedapply_changes, so this is latent rather than active — but the contract is misleading.)A related smell: the comment in
state_manager.rs~line 271 — "critical: apply on-disk before in-memory for atomicity" — implies atomicity that does not actually exist; the on-disk-first ordering just changes the failure mode, it doesn't provide a transaction.Impact
get_mutfails (or vice versa across iterations), the two views disagree until restart.