Skip to content

feat: persistent-prover support — proof cycle, rewind path, storage flush - #27

Open
mwaddip wants to merge 4 commits into
ergoplatform:mainfrom
mwaddip:feat/persistent-prover-support
Open

feat: persistent-prover support — proof cycle, rewind path, storage flush#27
mwaddip wants to merge 4 commits into
ergoplatform:mainfrom
mwaddip:feat/persistent-prover-support

Conversation

@mwaddip

@mwaddip mwaddip commented Aug 12, 2026

Copy link
Copy Markdown

Supersedes #11, #18, #19, #22 — the same change split four ways — rebased onto current main, plus a defect that appears only once they are combined.

  • VersionedAVLStorage::flush(): default no-op hook, non-breaking.
  • pack_tree gates on modified_nodes rather than the persistence flags, so tree.reset() no longer perturbs a proof cycle.
  • restore_root installs a storage-loaded root and rebases the proof baseline atomically.
  • The defect: a rewind cleared the changed-node buffers but not modified_nodes, which only generate_proof() clears — and an abandoned cycle never reaches it. If the restored root is still address-live, the next pack_tree expands nodes it should have labelled: a different proof for identical state (740 vs 735 bytes). rollback now delegates to restore_root, so there is one rewind path.

Each commit is individually green; the three new tests fail on the parent commit.

mwaddip and others added 4 commits August 13, 2026 00:12
Adds a default-no-op method on the storage trait so callers can force a
durable commit (fsync) of outstanding writes. Implementations using
deferred writes (e.g. redb Durability::None) should override this to
fsync the backing store. In-memory or always-durable storages can keep
the default no-op.

Motivation: PersistentBatchAVLProver::generate_proof_and_update_storage
uses non-durable writes for throughput. Without a caller-triggered flush,
graceful SIGTERM leaves uncommitted state pending; on reopen, redb sees
no valid commit and treats the storage as empty. Periodic flush calls
bound crash data loss.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
(cherry picked from commit 9a67695)
Proof generation (pack_tree) and persistence (collect_changed_nodes)
both relied on the tree's visited/is_new flags, creating a conflict:
generate_proof() called tree.reset(), clearing flags that a subsequent
storage.update() needed to find changed nodes. This caused internal
nodes created during AVL rebalancing to never be persisted.

Decouple them:

1. Add modified_nodes tracking (AuthenticatedTreeOpsBase) — populated
   unconditionally in on_node_visit, cleared by generate_proof().
   was_modified() keys on Rc::as_ptr, independent of tree flags.

2. Switch pack_tree from tree.visited() to was_modified() — proof
   generation no longer touches visited/is_new.

3. Replace tree.reset() in generate_proof() with modified_nodes.clear()
   + a needs_cycle_reset flag. The next perform_one_operation() resets
   visited/is_new (persistence flags) at the cycle boundary, after
   update() had a chance to collect them.

4. Relax check_tree_helper's post_proof assertion — flags are now
   cleared by update() or perform_one_operation(), not generate_proof().

5. Reset the cloned tree in generate_proof_for_operations() so the
   clone's on_node_visit starts with fresh flags.

modified_nodes is a BTreeMap keyed on the node's address rather than a
list, because pack_tree asks was_modified() once per node it walks: a
linear scan there makes proof generation quadratic in the number of
visits, which on a remove-heavy benchmark costs minutes rather than
seconds. The map's NodeId values keep each node alive, so an address
cannot be recycled while it is still a key, and the set stays per-prover
— a tree clone shares its nodes, so per-node state would leak proof
bookkeeping between the original and the clone.

(cherry picked from commit a42be22)
Add a public method that installs a persisted root and rebases the
proof cycle atomically. Replaces the ad-hoc field assignments currently
spread across four restore sites in the node.

After restoring a storage-loaded root, callers must:
- Clear is_new/visited flags on the fresh tree
- Drop stale changed-node buffers from the previous cycle
- Rebase old_top_node to the new root
- Clear accumulated directions from any prior (failed) cycle

restore_root() does all of this in one place, owning the proof-cycle
invariant.

(cherry picked from commit 87da2da)
restore_root dropped the changed-node buffers but not modified_nodes, the
map pack_tree gates on. Only generate_proof() clears that map, and a cycle
that is rewound never reaches it -- so a block that is applied and then
rejected leaves its entire visited set marked. The next pack_tree() then
expands nodes it should have labelled.

Where the rewind reinstalls a root that is still address-live -- which is
what a storage layer holding node handles does -- that is a different proof
for identical tree state, not merely retained memory: 740 vs 735 bytes in
the round-trip test below.

PersistentBatchAVLProver::rollback had drifted the same way, by
re-implementing the rewind by hand instead of calling it; it had already
missed the old_top_node rebase once for the same reason. It now delegates
to restore_root, so there is a single rewind implementation that cannot
drift from itself.

Each clause is pinned: with only the modified_nodes clear, the two rollback
tests still fail.
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