Skip to content

feat(node): keep intra-block ledger states live instead of persisting them - #2050

Draft
ozgb wants to merge 27 commits into
mainfrom
ozgb-ledger-intermediate-states-less-persists
Draft

feat(node): keep intra-block ledger states live instead of persisting them#2050
ozgb wants to merge 27 commits into
mainfrom
ozgb-ledger-intermediate-states-less-persists

Conversation

@ozgb

@ozgb ozgb commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Overview

Alternative implementation of #1442, replacing the approach in #1443 (that
branch's LedgerStateKey enum and per-transaction persist/unpersist are
reverted here — see "Relationship to #1443" below).

Every intra-block ledger state was made a GC root so that it stayed addressable
across the host_api boundary: apply_transaction / apply_system_transaction
persisted the state they produced and unpersisted their predecessor. The persist
existed for one mechanical reason — Sp::drop runs when the Bridge method
returns, and uncache on a non-persisted CacheValue::Create removes it from
the arena entirely — so rooting it was the only thing keeping it addressable for
the next call.

This holds the Sp in a process-global keep-alive cache instead, released by the
successor call. One persist() per block (the post-block tip) replaces one
persist plus one unpersist per transaction.

The win is fewer deserializations, not fewer DB transactions

Worth stating plainly because it changes what to benchmark. Sp::persist /
StorageBackend::unpersist were already single-key in-memory refcount updates on
the write cache, and the only parity-db commit is flush_all_changes_to_db, once
per block from on_finalize. observe_storage_flush_time is not expected to
move.

What is removed: the arena's caches hold binary OnDiskObjects and sp_cache
holds only Weak refs, so dropping the Sp tree at every host-call boundary
meant every host call re-deserialized the ledger working set from scratch
(Arena::get_lazyforce_as_arcT::from_binary_repr). One transaction
crosses that boundary ~3x — get_tx_weightget_transaction_cost,
pre_dispatchvalidate_guaranteed_execution, then apply_transaction
plus once at on_finalize. Holding the live Sp, for the intra-block
intermediates and for the post-block tip, collapses that to one materialisation
per block.

The two caches

transient anchored
holds intra-block intermediates post-block tips
persisted? no — the cache is the only thing keeping them addressable yes
capacity / TTI 1024 / 60s 4 / 300s
release explicit, by the successor call none needed
refcounted yes no
a miss is fatal (NoLedgerState) one re-materialisation

The transient half is refcounted because two block executions can be in flight at
once (authoring alongside import, unguarded outside warp recovery) and two forks
off the same parent applying the same transaction produce the same content
hash; without a count the first release kills the second execution's keep-alive.
This is not new bookkeeping so much as relocated bookkeeping — today's safety in
that scenario comes from the arena's root counter being a counter rather than a
flag. Its capacity is deliberately ~1000x a resident set of one: moka's TinyLFU
admission filter can reject an insert when the cache is at capacity, and here a
rejected insert is a fatal mid-block miss. The TTI is the leak bound, for an
execution that abandons its tail (an unsealed proposal, a failed import).

ledger_state_cache_size{cache_type="transient"|"anchored"} reports both,
sampled once per block at the post-block flush — after the block's last
intermediate has been released, which is what makes transient == 0 the clean
signal.

Consensus safety

The emitted state key cannot change. The Ledger root is always an
ArenaKey::Ref, so Sp::persist's Direct → Ref promotion never fired and
as_typed_key() is byte-identical with or without the persist.

Relationship to #1443

With release keyed on cache membership, the Anchored/Transient tag gates
nothing: the output variant was already a per-function constant, so the decision
came from which Bridge fn you are in and never from the input. Reverting it
deletes a permanent storage migration (pallet-midnight v2→v3) and a permanent
host-fn ABI version at zero behavioural cost. pallet_midnight::StateKey is
Vec<u8> again, STORAGE_VERSION is 2 again, and the four #[version(2)]
ledger-9 host functions are gone.

git diff origin/main -- runtime/ pallets/ primitives/ ledger/src/{common,host_api}/
is empty apart from a comment, the test-only mock / test-utils additions and
a host-side metric, so the runtime's SCALE surface is unchanged from main and
the checked-in .scale metadata files are back to main's bytes. Will still run
/bot rebuild-metadata to confirm.

Also fixes has_ledger_state, which is documented to return false for an
unresolvable state key (and used that way by warp ledger-sync's recovery monitor)
but reached that branch by panic.

Drive-by: deletes pre_fetch_storage, which had no caller anywhere in the tree.

Dependency note: moka 0.11.3 → 0.12 (needed for the atomic entry().and_compute_with
refcount, and 0.12 dropped the background housekeeping thread, so an Sp can no
longer be dropped — i.e. mutate the arena — from outside block execution). It is
a net dependency reduction: 0.12.15 was already in the lock via
hickory-resolver, so this collapses two vendored copies into one and drops 9
transitive crates.

🗹 TODO before merging

  • Performance evaluation (see "Not measured here" below) — this is why the PR is a draft
  • /bot rebuild-metadata to confirm the metadata revert
    • Not needed - no metadata changes, no need to rebuild
  • Confirm no qanet/preview/dev chain is running fix: stop persisting intermediate ledger states #1443's runtime (reverting STORAGE_VERSION 3→2 is only safe on chains that never applied v3; no tag or release contains those commits and spec_version never changed, but an ad-hoc devnet from that branch would need a v3→v4 down-migration instead)
  • fix: stop persisting intermediate ledger states #1443 closed or retargeted, and christos-ab-control-no-unpersist (which builds on the enum) rebased
    • Pending performance eval of this branch
  • Ready

📌 Submission Checklist

  • All commits are signed off (git commit -s) for the DCO
  • Changes are backward-compatible (or flagged if breaking)
  • Pull request description explains why the change is needed
  • Self-reviewed the diff
  • I have included a change file, or skipped for this reason:
  • If the changes introduce a new feature, I have bumped the node minor version
  • Update documentation (if relevant)
  • Updated AGENTS.md if build commands, architecture, or workflows changed
  • No new todos introduced

🧪 Testing Evidence

pallets/midnight/tests/persist_refcount.rs is rewritten around the keep-alive
contract and asserts, at every transition of a three-block lifecycle:

  • intermediates are never GC roots (get_state_root_count == None) and are
    addressable only because the cache holds them (transient_state_is_retained),
  • the successor call releases its predecessor, so the transient cache is empty
    after on_finalize — the unit-test mirror of the gauge,
  • reads through an intermediate tip mid-block succeed:
    get_ledger_state_root(), get_transaction_cost() (the #[pallet::weight]
    path), and validate_unsigned / pre_dispatch resolving the state rather than
    failing NoLedgerState,
  • a released intermediate is gone from the arena entirely after the block's
    flush (has_ledger_state == false) while the persisted tip survives — i.e.
    intermediates never reach disk,
  • the refcount: apply tx to parent → H; rewind StateKey and apply the
    same tx again → the same H; advance one branch → H still retained; advance
    the other → released.

Both mechanisms were mutation-tested — making release_transient unconditionally
Remove fails the fork assertion, stubbing out retain_transient fails the
keep-alive assertion.

cargo test -p midnight-node-ledger                     78 passed
cargo test -p pallet-midnight --features test-utils     24 passed, 2 ignored
cargo test -p midnight-node --test storage_migration     1 passed
cargo test -p midnight-node warp_ledger_sync            14 passed
cargo test -p midnight-node --lib                      148 passed, 2 ignored
cargo clippy --workspace --all-targets                   0 warnings (1 pre-existing profile-spec warning)
cargo fmt --all --check                                  clean

storage_migration is the regression guard that matters for teardown: each
retained Sp holds Arcs into Storage<D>, so a surviving cache entry keeps the
parity-db handle and its exclusive file lock alive past
unsafe_drop_default_storage. It caught two real bugs during development —
clearing the caches only inside the per-DB branches (ledger 8 and 9 share one
storage backend but have their own copies of the statics), and relying on moka's
invalidate_all, which only stamps a valid_after marker instead of dropping
values.

Local dev node (CFG_PRESET=dev, release build, --alice --validator), authoring
and finalizing normally, no ledger errors:

ledger_state_cache_size{cache_type="transient"} 0
ledger_state_cache_size{cache_type="anchored"}  4

transient at 0 at every block boundary is the leak check. anchored sits at its
capacity of 4 rather than 1 — with no explicit release, consecutive tips
accumulate until the LRU evicts, which is by design and always safe there since
they are persisted (four consecutive tips share nearly all structure).

Not measured here — over to the performance team

  1. The actual win. With RUST_LOG=midnight::ledger_v2=trace, submit a burst
    so several txs land in one block and diff the ⏱️ Ledger loaded elapsed for
    the 1st vs 2nd+ tx of a block, and for the 1st tx of consecutive blocks.
    Before, all three are comparable; after, the 2nd+ within a block should be
    ~0 (transient cache) and the 1st of each block ~0 too (anchored cache). Those
    two numbers are the whole justification for the change. They also confirm the
    design assumption that arena.get_lazy resolves through sp_cache to the
    retained Arc — if they don't move, an explicit cache lookup in get_ledger
    is needed after all.
  2. observe_txs_processing_time should drop;
    observe_storage_flush_time("ledger_state") should be flat (if it moves,
    something else changed).
  3. RSS across a few hundred blocks of catch-up sync. Retention now grows
    monotonically within a block rather than resetting at each host boundary, so
    peak could step up by roughly one block working set plus the retained tips.
    Measure, don't argue.

hardfork_e2e has not been run locally (needs CI images); the enum revert
restores main's host ABI and StateKey layout exactly, so the fork path is
byte-identical to main.

  • Additional tests are provided (if possible)

🔱 Fork Strategy

  • Node Runtime Update
  • Node Client Update
  • Other:
  • N/A

Client-side only. The runtime's SCALE surface, StateKey layout, host-function
ABI and emitted state keys are all identical to main, so this needs no runtime
upgrade and no migration, and a node running it can sync a chain built by one
that isn't.

Links

Closes #1442
Supersedes #1443

ozgb added 26 commits April 29, 2026 12:30
…tion (#1442)

Previously every successful apply_transaction left its predecessor's ledger
state rooted as a persist refcount, so all intermediate per-tx states piled
up as GC roots forever and would block any future garbage collection.

apply_transaction and apply_system_transaction now unpersist their input
state_key after persisting the new state (net zero per call within a block).
post_block_update unpersists its input and double-persists its output, so
the post-block tip lands at rc=2 — the next block's first apply unpersists
it once, leaving it at rc=1 for RPC and history queries.
alloc_with_initial_state likewise double-persists genesis (the block-0
post-block state) so it survives block 1's first apply at rc=1.

Adds a test-utils inspection helper (get_state_root_count) and a pallet-
level integration test that walks DEPLOY -> finalize -> STORE -> finalize
-> CHECK across three blocks, asserting refcounts at every transition and
guarding that validate_unsigned and pre_dispatch leave refcounts unchanged.

Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
Issue: #1442
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
Replaces the raw Vec<u8> ledger state key encoding with a typed enum so the
Bridge distinguishes states that must be retained for history (post-block
tips, genesis) from intra-block intermediates that can be cleaned up by
their successor — at the type level rather than by convention.

  pub enum LedgerStateKey {
      Anchored(Vec<u8>),  // never unpersisted on input
      Transient(Vec<u8>), // unpersisted by successor
  }

apply_transaction and apply_system_transaction now take &LedgerStateKey,
return Transient, and only call unpersist_state when the input was
Transient. post_block_update returns Anchored. Anchored inputs are left
alone, which makes sibling forks safe (importing two blocks built on the
same Anchored parent does not unpersist it twice) and shrinks the
failed-block leak from K states to 1.

The extra persist in post_block_update and the genesis double-persist are
no longer needed: Anchored states sit at rc=1 and the Bridge never
unpersists them.

Pallet StateKey<T> is typed as LedgerStateKey; STORAGE_VERSION bumped 1->2;
v2 migration uses VersionedMigration to wrap existing bytes as Anchored,
wired into frame_system::Config::SingleBlockMigrations in the runtime.

The integration test now asserts the Anchored/Transient invariants
explicitly — rc=1 forever for Anchored, rc=1 then unpersisted for
Transient.

Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
The pallet's `StateKey` storage was changed from `Vec<u8>` to
`LedgerStateKey` (a SCALE enum) in 005358b. The toolkit still decoded
the raw bytes as `Vec<u8>`, which silently produced an empty vec because
the leading `0x00` Anchored variant tag was read as a compact length of
0 — surfacing as `StateRootMismatch { expected: "", actual: <root> }`
during block verification.

Decode as a local mirror of `LedgerStateKey` and unwrap to the inner
typed-key bytes. The mirror's variant order must match the on-chain
definition.

Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
The toolkit's `get_state_root_at` was returning the bytes from
`get_ledger_state_root` runtime API on V0_22_0+, but the consumer
(`LedgerContext::compute_state_root` in helpers) was still computing
a tagged-serialized typed-key over a `StorableLedgerState` wrapper —
so verification always failed with `StateRootMismatch`.

Drop the wrapper and untagged-serialize `LedgerState<D>::as_typed_key()`
directly, matching what the runtime API produces.

V0_21_0 predates the runtime API and stored the typed key of `Ledger<D>`
(LedgerState + block_fullness wrapper), which hashes a different struct
and cannot be transformed into the new shape. Return None from the
V0_21_0 path so verification is skipped on those blocks rather than
producing a guaranteed mismatch.

Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
…ate-states

Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>

# Conflicts:
#	ledger/src/host_api/ledger_7.rs
#	ledger/src/host_api/ledger_8.rs
#	pallets/midnight/src/lib.rs
…ate-states

Resolves conflicts between this branch's typed `LedgerStateKey` state key and
main's ledger 8->9 hardfork:

- Migration renumbering: main took pallet-midnight's v1->v2 slot for the ledger
  v8->v9 state translation, so the `StateKey` `Vec<u8>` -> `LedgerStateKey`
  re-encoding becomes v2->v3 and `STORAGE_VERSION` goes to 3. Main's v2 now
  reads/writes through a raw `Vec<u8>` storage alias, since the live `StateKey`
  item is already the typed enum at that point. Both are declared in the
  runtime `Migrations` tuple (v2 before v3) so their order is guaranteed.
- `apply_post_block_update` (added by main, now the `on_finalize` entry point)
  takes `&LedgerStateKey` and returns `Anchored`, unpersisting a `Transient`
  input — the same contract as `post_block_update`.
- `ledger_9` bridge (added by main) threads `LedgerStateKey` through
  `post_block_update`, `apply_post_block_update`, `apply_transaction` and
  `apply_system_transaction`, matching bridges 7 and 8.
- Toolkit `get_state_root_at` gains `V2_0_0`/`V2_1_0` arms for the runtime
  versions main added.
- `persist_refcount` test moved from the ledger_8 to the ledger_9 helpers.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
`LedgerStateKey` replaced the raw-bytes state key in `post_block_update`,
`apply_post_block_update`, `apply_transaction` and `apply_system_transaction`
by editing the version-1 signatures in place. Host functions resolve by name
and version, so this broke every runtime built before the change — and a node
must keep executing those, since each historical block is replayed with the
runtime it was authored with. `hardfork_e2e::hardfork_single_tx` caught it:
the node came up against the 1.0.1 chain-spec, served RPC, and never authored,
timing out at `finalized #0`.

The break is invisible to the compiler and to the linker. Both state-key forms
marshal as fat pointers, so the wasm signature is unchanged and the import
still resolves; the old runtime then hands an unprefixed byte slice to a host
that SCALE-decodes an enum, and the decode fails mid-block.

Expose the new signatures as `#[version(2)]` on the ledger-9 bridge and
restore version 1 there. Version 1 wraps its input as `Anchored`, which
reproduces the previous behaviour exactly: successor persisted, predecessor
never unpersisted. Confirmed against the shipped runtime wasms — 2.0.0 imports
`post_block_update_version_1`, 2.1.0 imports `apply_post_block_update_version_1`,
and both import `apply_transaction_version_1` and
`apply_system_transaction_version_1`.

The ledger-7 and ledger-8 bridges keep the legacy ABI and nothing else: the
current runtime only ever calls the ledger-9 bridge (`active_ledger_bridge`),
so a `LedgerStateKey` version there would be dead code.

`TransactionAppliedStateRootBytes` and `SystemTransactionAppliedStateRootBytes`
carry the version-1 return shapes.

Verified by running the step that failed: a release node built from this tree,
started against the 1.0.1 dev chain-spec, imports blocks and finalizes past #1.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
`get_state_root_at` dispatched on the block's runtime version and called
`get_ledger_state_root` through the matching `midnight_metadata_<version>`
codegen. subxt validates a static payload's type hash against the metadata of
the block being queried, and the checked-in snapshots are not faithful to the
releases they are named after: `midnight_metadata_1.0.0.scale` carries
ledger-9 API types and a `C2MBridge` pallet that the 1.0.1 release does not
have. Against a real 1.0.1 node every ledger-carrying method mismatches —

  method                  static 1.0.0   live 1.0.1
  get_ledger_state_root   751fc7297fe7   6079172628d6
  get_ledger_parameters   49fcd0c5809c   b8b75ed36d8f
  get_network_id          25b958eebafa   25b958eebafa

— so `hardfork_e2e::hardfork_single_tx` failed its pre-fork `single-tx` with
`RuntimeApiError(IncompatibleCodegen)` while fetching block state roots. This
is the failure class the raw storage query this code replaced was written to
avoid; its comment named it.

Use `call_raw`, which skips validation. The wire shape is stable across
V0_22_0+ so the per-version arms collapse to one; V0_21_0 still returns None
(it predates the API). The `Result` error variant's payload differs per
runtime version, so decode it as opaque and surface the raw bytes instead.

Verified against `midnightntwrk/midnight-node:1.0.1`: the call that threw
`IncompatibleCodegen` now returns a 33-byte root. Node-side and toolkit-side
shapes agree — the bridge untagged-serializes `LedgerState::as_typed_key()`
(ledger/src/versions/common/mod.rs:848) and so does the consumer
(`LedgerContext::compute_state_root`).

`get_ledger_parameters` has the same flaw but is reached only by
generate-intent / show-ledger-parameters / update-ledger-parameters, not by
this test path; it is tracked separately as #1969.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
…soft-error

Toolkit no longer hard-errors on failed state key deserialization (expected
on the fork block due to runtime code/ledger data mismatch)

Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
The hardfork's `set_code` block commits the new runtime code alongside the
old raw-bytes `StateKey` — the v2/v3 migrations only run in the next block's
`initialize_block` — so runtime APIs queried at that block hash run the new
runtime against pre-v3 storage forever. Decoding those bytes as
`LedgerStateKey` fails, `ValueQuery` substitutes the default, and the host
function is handed an empty key, losing the tag the ledger-8 dispatch needs.

Gate the read on the pallet's on-chain storage version rather than
introspecting the value's bytes: v3 is the migration that re-encoded the
item, and `VersionedMigration` bumps the version in the same write, so the
version is an exact discriminator for which layout is in state. All reads go
through `Pallet::state_key()`; writes are always the new layout.

`LedgerStateKey` goes back to a derived `Decode`, which also takes the layout
ambiguity off the host-function ABI — a legacy value whose length is a
multiple of 256 decodes *successfully* as `Transient` of its first byte.

`initialize_state` now writes `STORAGE_VERSION` as well: mocks that call it
directly never run frame's `on_genesis`, so they left the on-chain version at
0, which the gate reads as the legacy layout.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
87be60b softened `MidnightNodeClient::get_state_root_at` to `log::warn!` +
`Ok(None)` because the hardfork's `set_code` block always failed with `0x010005`
(`Deserialization(TypedArenaKey)`) — it commits the new `:code` over still-old
ledger data — and one unverifiable block was aborting a whole fetch.

That block now answers, so the fallback is dead code with a bad failure mode:
`None` makes `LedgerContext::verify_state_root` skip verification entirely,
turning the one check that would catch ledger divergence into a log line.

Two changes had to land for it to answer, and neither is sufficient alone.
6c0b5c8 gates the `StateKey` read on the pallet's on-chain storage version, so
the new runtime queried at that block hash reads the pre-v3 raw-bytes layout and
the ledger-8 arena tag survives. 54dffa0 (#1985) then made the ledger-9 host
API's read accessors dispatch on that tag, serving the read from the ledger-8
bridge — before it, the tag was preserved but nothing consumed it, and the block
still failed.

Restore the error, carrying the block hash and raw bytes so a CI failure is
diagnosable without a rerun.

Not reverted: the `call_raw` switch from 04695c8. The checked-in
`midnight_metadata_<version>.scale` snapshots aren't faithful to the releases
they name, so subxt's static codegen fails `IncompatibleCodegen` against a real
pre-fork node.

Verified against a local fork: 1.0.1 dev chain-spec, upgrade to this tree's wasm
(spec 1000000 -> 2001000, new runtime from finalized #35), then a post-fork
`single-tx` fetch reports "fetched 36 blocks ... all blocks verified" — walking
past the `set_code` block at #34 with no `LedgerApi` error and no
`StateRootMismatch`. On the pre-54dffa0 tree the same fetch fails loudly at
exactly that block, confirming the error still bites.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
Warp ledger-sync's `read_state_key` decoded `pallet_midnight::StateKey`
as `Vec<u8>`, but this branch re-encoded that storage item to
`LedgerStateKey`. The `Anchored` variant byte reads as a compact length
of zero, so every read returned an empty key: `has_ledger_state` found
no `ledger-state[vNN]` tag in it, the recovery monitor concluded the
arena was missing the state at the finalized block, and armed the gate.
A fresh dev node then gated authoring and import forever, with no peers
to recover an arena it already had.

Decode the layout the pallet's on-chain storage version says is there —
the same authority `Pallet::state_key` uses — rather than sniffing the
bytes: a pre-v3 raw `Vec<u8>` whose length is a multiple of 256 decodes
cleanly, and wrongly, as `LedgerStateKey::Transient`. The pinned
threshold moves to `pallet_midnight::STATE_KEY_ENUM_VERSION` so both
readers share one definition.

Both storage keys now come from substrate (`StateKey::hashed_key()`,
`StorageVersion::storage_key`), dropping the hand-rolled twox_128
prefixes and the `:__STORAGE_VERSION__:` literal.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
… them

Every intra-block ledger state was made a GC root so that it stayed addressable
across the host_api boundary: `apply_transaction` / `apply_system_transaction`
persisted the state they produced and unpersisted their predecessor, with a
typed `LedgerStateKey` {Anchored, Transient} enum threaded through the host ABI
and pallet storage so the Bridge knew which inputs it was allowed to unpersist.

Hold the `Sp` in a process-global keep-alive cache instead, released by the
successor call. One `persist()` per block (the post-block tip) replaces one
persist plus one unpersist per transaction — and because cache membership
answers "was the predecessor an intra-block intermediate?" directly, the
`LedgerStateKey` enum, its permanent storage migration and the versioned host
functions all come back out. `StateKey` and the host ABI are byte-identical to
main again.

The win is fewer deserializations, not fewer DB transactions. `persist` /
`unpersist` were already single-key in-memory refcount updates, and the one
parity-db commit per block is unchanged. But the arena's caches hold binary
objects and its `sp_cache` holds only weak refs, so dropping the `Sp` tree at
every host-call boundary re-materialised the ledger working set from scratch —
roughly three times per transaction (`get_tx_weight`, `pre_dispatch`,
`apply_transaction`) plus once at `on_finalize`. Holding the live `Sp`, for the
intermediates and for the post-block tip, collapses that to one materialisation
per block.

Two caches, with different contracts:

- transient (intra-block, 1024 entries / 60s TTI) is not persisted, so it is the
  only thing keeping those states addressable. Entries are refcounted, because
  two executions can be in flight at once (authoring alongside import) and two
  forks off the same parent applying the same transaction produce the same
  content hash. The TTI bounds the leak if an execution abandons its tail.
- anchored (post-block tips, 4 entries / 300s TTI) is persisted, so a miss just
  costs one re-materialisation; no refcount, no release, eviction always safe.

`ledger_state_cache_size{cache_type}` reports both, sampled once per block at
the post-block flush where "transient" must read 0.

Also fixes `has_ledger_state`, which is documented to return false for an
unresolvable state key but reached that branch by panic: `get_ledger` now probes
the arena root instead of handing back a lazy pointer that panics on first
deref.

The emitted state key cannot change: the `Ledger` root is always an
`ArenaKey::Ref`, so `Sp::persist`'s Direct -> Ref promotion never fired and
`as_typed_key()` is byte-identical with or without the persist.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
@ozgb ozgb added the bot:ai-assisted Authored or substantially edited by an AI agent label Aug 19, 2026
Assisted-by: Claude:claude-opus-5
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
@gilescope

Copy link
Copy Markdown
Contributor

I can attest that this is a problem worth solving. @ozgb I'd suggest syncbox testing this PR to make sure it doesn't upset anything. If that's fine then gets a thumbs up from me.

chrispalaskas added a commit that referenced this pull request Sep 1, 2026
Reverts c6590ef in preparation for replacing it with PR #2050
(ozgb-ledger-intermediate-states-less-persists), which supersedes #1443
with a keep-alive-cache approach that needs none of #1443's runtime
storage / state-key-type changes.

pallets/midnight/Cargo.toml is deliberately NOT reverted: #2050 carries
the identical test-utils feature block and [[test]] stanza for the same
persist_refcount integration test, and ca3e1ab's optional-dependency
promotion is still required.

Assisted-by: Claude:claude-opus-5
Signed-off-by: chrispalaskas <chris.palaskas@gmail.com>
chrispalaskas added a commit that referenced this pull request Sep 1, 2026
Replaces PR #1443 on the combined test branch. Applied as a squashed
three-way patch of 8d3e0cf..1da3723 (PR #2050 head) rather than a
git merge, for the same criss-cross-history reason as the original #1443
application.

Unlike #1443, #2050 needs no runtime storage or state-key-type changes:
intra-block intermediates are held live in a refcounted keep-alive cache
in the ledger crate instead of being persisted, so the pallet's
LedgerStateKey typing, migration v3, metadata rebuild and runtime
changes are all gone.

Adaptations to the combined branch:
- Cargo.lock regenerated rather than taken from #2050: this branch pins
  midnight-ledger by rev (#1872's 2026-08-17 update), not by the
  ledger-9.1.0.0-rc.4 tag main uses. The only real change is moka
  0.11.3 -> 0.12.15, which #2050 needs for `Cache::and_compute_with`.
- pallets/midnight/Cargo.toml kept from ca3e1ab: #2050 carries the
  identical test-utils feature block, and the optional-dependency
  promotion for #744's mock additions is still required.
- ledger/src/versions/common/mod.rs: kept both #1872's proof-verification
  cache helpers and #2050's keep-alive cache block (adjacency conflict
  only — they touch different caches).
- primitives/ledger/src/lib.rs: kept both #1872's proof_verify_duration /
  proof_verify_txs metrics and #2050's ledger_state_cache_size gauge.

Assisted-by: Claude:claude-opus-5
Signed-off-by: chrispalaskas <chris.palaskas@gmail.com>
chrispalaskas added a commit that referenced this pull request Sep 1, 2026
Two pre-existing formatting diffs in ledger/src/versions/common/mod.rs
from the #1872 batch-verification code; unrelated to the #2050 swap but
they fail CI's fmt check.

Assisted-by: Claude:claude-opus-5
Signed-off-by: chrispalaskas <chris.palaskas@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:ai-assisted Authored or substantially edited by an AI agent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ledger Storage: Stop persisting intermediate states

2 participants