feat(node): keep intra-block ledger states live instead of persisting them - #2050
Draft
ozgb wants to merge 27 commits into
Draft
feat(node): keep intra-block ledger states live instead of persisting them#2050ozgb wants to merge 27 commits into
ozgb wants to merge 27 commits into
Conversation
…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>
Assisted-by: Claude:claude-opus-5 Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
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>
18 tasks
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.
Overview
Alternative implementation of #1442, replacing the approach in #1443 (that
branch's
LedgerStateKeyenum and per-transaction persist/unpersist arereverted here — see "Relationship to #1443" below).
Every intra-block ledger state was made a GC root so that it stayed addressable
across the
host_apiboundary:apply_transaction/apply_system_transactionpersisted the state they produced and unpersisted their predecessor. The persist
existed for one mechanical reason —
Sp::dropruns when the Bridge methodreturns, and
uncacheon a non-persistedCacheValue::Createremoves it fromthe arena entirely — so rooting it was the only thing keeping it addressable for
the next call.
This holds the
Spin a process-global keep-alive cache instead, released by thesuccessor call. One
persist()per block (the post-block tip) replaces onepersist 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::unpersistwere already single-key in-memory refcount updates onthe write cache, and the only parity-db commit is
flush_all_changes_to_db, onceper block from
on_finalize.observe_storage_flush_timeis not expected tomove.
What is removed: the arena's caches hold binary
OnDiskObjects andsp_cacheholds only
Weakrefs, so dropping theSptree at every host-call boundarymeant every host call re-deserialized the ledger working set from scratch
(
Arena::get_lazy→force_as_arc→T::from_binary_repr). One transactioncrosses that boundary ~3x —
get_tx_weight→get_transaction_cost,pre_dispatch→validate_guaranteed_execution, thenapply_transaction—plus once at
on_finalize. Holding the liveSp, for the intra-blockintermediates and for the post-block tip, collapses that to one materialisation
per block.
The two caches
NoLedgerState)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 == 0the cleansignal.
Consensus safety
The emitted state key cannot change. The
Ledgerroot is always anArenaKey::Ref, soSp::persist'sDirect → Refpromotion never fired andas_typed_key()is byte-identical with or without the persist.Relationship to #1443
With release keyed on cache membership, the
Anchored/Transienttag gatesnothing: 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-midnightv2→v3) and a permanenthost-fn ABI version at zero behavioural cost.
pallet_midnight::StateKeyisVec<u8>again,STORAGE_VERSIONis 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-utilsadditions anda host-side metric, so the runtime's SCALE surface is unchanged from main and
the checked-in
.scalemetadata files are back to main's bytes. Will still run/bot rebuild-metadatato confirm.Also fixes
has_ledger_state, which is documented to returnfalsefor anunresolvable 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_withrefcount, and 0.12 dropped the background housekeeping thread, so an
Spcan nolonger 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 9transitive crates.
🗹 TODO before merging
/bot rebuild-metadatato confirm the metadata revertSTORAGE_VERSION3→2 is only safe on chains that never applied v3; no tag or release contains those commits andspec_versionnever changed, but an ad-hoc devnet from that branch would need a v3→v4 down-migration instead)christos-ab-control-no-unpersist(which builds on the enum) rebased📌 Submission Checklist
git commit -s) for the DCO🧪 Testing Evidence
pallets/midnight/tests/persist_refcount.rsis rewritten around the keep-alivecontract and asserts, at every transition of a three-block lifecycle:
get_state_root_count == None) and areaddressable only because the cache holds them (
transient_state_is_retained),after
on_finalize— the unit-test mirror of the gauge,get_ledger_state_root(),get_transaction_cost()(the#[pallet::weight]path), and
validate_unsigned/pre_dispatchresolving the state rather thanfailing
NoLedgerState,flush (
has_ledger_state == false) while the persisted tip survives — i.e.intermediates never reach disk,
H; rewindStateKeyand apply thesame tx again → the same
H; advance one branch →Hstill retained; advancethe other → released.
Both mechanisms were mutation-tested — making
release_transientunconditionallyRemovefails the fork assertion, stubbing outretain_transientfails thekeep-alive assertion.
storage_migrationis the regression guard that matters for teardown: eachretained
SpholdsArcs intoStorage<D>, so a surviving cache entry keeps theparity-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 avalid_aftermarker instead of droppingvalues.
Local dev node (
CFG_PRESET=dev, release build,--alice --validator), authoringand finalizing normally, no ledger errors:
transientat 0 at every block boundary is the leak check.anchoredsits at itscapacity 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
RUST_LOG=midnight::ledger_v2=trace, submit a burstso several txs land in one block and diff the
⏱️ Ledger loadedelapsed forthe 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_lazyresolves throughsp_cacheto theretained
Arc— if they don't move, an explicit cache lookup inget_ledgeris needed after all.
observe_txs_processing_timeshould drop;observe_storage_flush_time("ledger_state")should be flat (if it moves,something else changed).
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_e2ehas not been run locally (needs CI images); the enum revertrestores main's host ABI and
StateKeylayout exactly, so the fork path isbyte-identical to main.
🔱 Fork Strategy
Client-side only. The runtime's SCALE surface,
StateKeylayout, host-functionABI 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