Skip to content

feat(migration): trie migration - #3659

Open
MaksymMalicki wants to merge 9 commits into
maksym/statehistory-migrationfrom
maksym/trie-migration
Open

feat(migration): trie migration#3659
MaksymMalicki wants to merge 9 commits into
maksym/statehistory-migrationfrom
maksym/trie-migration

Conversation

@MaksymMalicki

@MaksymMalicki MaksymMalicki commented May 19, 2026

Copy link
Copy Markdown
Contributor

User description

Summary

One-shot migration that converts every deprecated Starknet trie on disk into the equivalent trie2 layout. After this migration runs, the new state package can read directly from the new buckets and the deprecated buckets are wiped.

Bucket mapping:

Deprecated New Hash
ClassesTrie ClassTrie Poseidon
StateTrie ContractTrieContract Pedersen
ContractStorage ContractTrieStorage Pedersen (per-owner)

Format differences

Three things change between the two layouts: how nodes are keyed on disk, how each node's value is encoded, and how path compression is expressed.

On-disk keying

Both layouts share a common prefix; only the suffix differs:

common (both) suffix
───────────── ─────────────────────────────────────────
bucket [|| owner] → path-length-byte || path-bytes (deprecated)
→ nodeType-byte || path-length-byte || path-bytes (new)

owner is present only for storage tries. The new layout's extra nodeType byte splits leaves from internal nodes into two index slices within the same bucket — trie2 state lookups use this to short-circuit between leaf reads and internal-node traversals.

Node encoding

Both layouts are raw byte streams (no length prefixes, no varints — field-element widths are fixed).

Deprecated: nodes are self-contained. Internal binary nodes embed the compressed paths to their children inline:

leaf value
binary value || left-child-path || right-child-path
[|| left-hash || right-hash, optional cache, ignored here]

value is the node's own Starknet trie hash, or the stored value when the node is a leaf. The trailing hash pair was a denormalised cache; the migrator does not read it (hashes are recomputed from scratch — see below).

New: every node has an explicit type tag and path compression lives in dedicated edge nodes:

value value
binary 0x01 || left-edge-hash || right-edge-hash
edge 0x02 || child-hash || encoded-path-segment

Path compression — the key structural change

The deprecated format compresses paths inside the parent binary node (via its embedded child-path fields). The new format moves compression into dedicated edge nodes sitting between binary nodes and their children:

deprecated: binary ──────── child-path ────────► child
new: binary ──► edge ──► child

One consequence: the deprecated root marker (a single entry at the bare bucket prefix recording the root's path) disappears. Whatever the deprecated root embedded becomes either a direct binary/leaf at the empty path or, when the deprecated root path is itself non-empty, an edge node at the empty path that points "down" to the real root.

Traversal

DFS is a natural fit here. In the new layout a binary node's payload is left-edge-hash || right-edge-hash — so before we can write the parent, we need both children's hashes. A bottom-up walk reads each deprecated node exactly once and produces the child hash that the parent needs at the moment the parent is encoded; no separate hashing pass, no intermediate caches sized to the trie. Going top-down would force us to either re-read every child later or hold every visited node in memory until its subtree is hashed.

For each trie:

  1. Enumerate — scan the deprecated bucket once. Class and state tries each occupy a whole bucket; storage tries share ContractStorage keyed by bucket || owner, so the enumerator splits them into per-owner descriptors. Each descriptor records the root path (from the bare-prefix marker entry) and the node count.
  2. DFS — recurse from the root path. A deprecated leaf becomes a value node at the same path. An internal binary node, after both subtrees have been visited, becomes a binary node plus up to two edge nodes (one per non-empty child segment). If the trie's stored root path is itself non-empty, a single edge node at the empty path is written after the traversal completes, replacing the root marker.
  3. Resumability check — before doing any work for a trie, the migrator looks up its new-format root key. If present, the trie is credited toward progress and skipped.

Pipeline: IngestorCount worker goroutines pull descriptors from the enumeration source; a single committer flushes filled batches to disk. A semaphore caps in-flight batches at IngestorCount * 2. Every flush and every channel send observes ctx.Done; on cancel Migrate returns the shouldRerun sentinel and the migration runner re-invokes on the next process start.

After the full pipeline finishes, the three deprecated buckets are wiped via DeleteRange. The wipe is gated on full success — a crashed mid-migration leaves the deprecated source intact, so partially migrated tries either have a new-format root (skipped on the next pass) or don't (re-migrated from scratch).

Alternative considered: reverse-iteration BFS

An earlier attempt used reverse-iteration BFS over the deprecated bucket — iterating keys from longest-path to shortest so leaves are processed first, then their parents, then their parents, and so on. Each level's hashes are buffered until the next level up consumes them.

In practice it performed comparably to DFS on wall-clock time but with substantially higher peak memory — the buffer of "hashes waiting for their parent" grows roughly with the widest level of the trie, which for full or near-full subtrees is most of the leaves. DFS keeps only the current root-to-leaf path in flight, which is bounded by the trie depth (≤ 251), so memory stays flat regardless of trie size. Same correctness, worse memory profile, no speed win — dropped.

Hashing

Starknet trie hashes for the new layout:

leaf value
binary hashFn(left-edge-hash, right-edge-hash)
edge hashFn(child-hash, path-segment-as-felt) + segment-length

A zero-length edge short-circuits to the bare child-hash — the convention for absent edges. Class tries hash with Poseidon; contract and storage tries with Pedersen.

Performance: for small tries (below SmallTrieThreshold nodes) every edge hash is computed inline. Above the threshold, edge-hash jobs are batched (parallelHashBatchSize) and dispatched to a fixed-size worker pool for parallel computation. The scheduler preserves the original job order so the persisted bytes are byte-identical to a natively-built trie2 — verified end-to-end in the tests by comparing the migrated DB against one built directly through trie2.Trie.Update (see TestMigrationEndToEnd).


PR Type

Enhancement


Description

  • Migrate Starknet tries to trie2 layout.

    • Converts ClassesTrie, StateTrie, and ContractStorage.
    • Separates path compression into dedicated edge nodes.
  • Build parallel hash and write pipeline.

    • Computes edge hashes using concurrent worker pools.
    • Buffers writes to database for higher throughput.
  • Add state migration orchestrator and safeguards.

    • Cleans up old buckets only upon success.
    • Skips already processed tries on resume.
  • Register new migration phase.


File Walkthrough

Relevant files
Utilities
1 files
codec.go
Encode and decode node blobs, paths, and edge hashes         
+123/-0 
Database operations
1 files
committer.go
Write database batches and handle concurrency semaphores 
+39/-0   
Logging
1 files
counter.go
Track and log migration progress and speed metrics             
+75/-0   
Concurrency
2 files
hashpool.go
Worker pool for parallel trie edge hash computation           
+61/-0   
hashworker.go
Schedule and buffer parallel or synchronous hash jobs       
+166/-0 
Enhancement
2 files
ingestor.go
Traverse deprecated tries and stream nodes to database batches
+364/-0 
trie.go
Main migration orchestrator and trie bucket enumerator pipeline
+299/-0 
Tests
1 files
trie_test.go
Tests for trie migration, idempotency, and context cancellation
+441/-0 
Configuration changes
1 files
migrator.go
Register trie migration phase in the main state migrator 
+2/-0     

@MaksymMalicki
MaksymMalicki marked this pull request as draft May 19, 2026 11:25
@codecov

codecov Bot commented May 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 71.64502% with 131 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.03%. Comparing base (886d3f9) to head (1df3dd8).

Files with missing lines Patch % Lines
...gration/state/newstate/internal/trie/hashworker.go 36.84% 48 Missing ⚠️
migration/state/newstate/internal/trie/ingestor.go 81.02% 26 Missing ⚠️
migration/state/newstate/internal/trie/trie.go 85.07% 20 Missing ⚠️
migration/state/newstate/internal/trie/counter.go 47.05% 18 Missing ⚠️
migration/state/newstate/internal/trie/hashpool.go 34.78% 15 Missing ⚠️
migration/state/newstate/internal/trie/codec.go 95.74% 2 Missing ⚠️
...igration/state/newstate/internal/trie/committer.go 90.00% 1 Missing ⚠️
migration/state/newstate/migrator.go 0.00% 1 Missing ⚠️
Additional details and impacted files
@@                        Coverage Diff                        @@
##           maksym/statehistory-migration    #3659      +/-   ##
=================================================================
- Coverage                          79.11%   79.03%   -0.09%     
=================================================================
  Files                                471      478       +7     
  Lines                              36091    36559     +468     
=================================================================
+ Hits                               28555    28895     +340     
- Misses                              7527     7655     +128     
  Partials                               9        9              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@MaksymMalicki
MaksymMalicki force-pushed the maksym/trie-migration branch from b23a1b7 to 3640393 Compare May 19, 2026 23:11
@MaksymMalicki
MaksymMalicki force-pushed the maksym/trie-migration branch from f6ba2c8 to 4aac6ce Compare May 20, 2026 12:56
@MaksymMalicki
MaksymMalicki marked this pull request as ready for review May 20, 2026 22:49
@github-actions

Copy link
Copy Markdown
Contributor

This pull request is stale because it has been open 35 days with no activity. Remove stale label or comment or this will be closed in 14 days.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Claude finished @NazariiDenha's task in 5m 16s —— View job


Review of PR #3659 (trie migration)

Todo list

  • Gather context (diff, changed files, CLAUDE.md)
  • Review migration/trie/* (codec, ingestor, trie, hashworker, hashpool, committer, counter)
  • Review migration/statehistory/* and migration/headstate/* for concurrency/correctness
  • Review core/state/accessors.go and node/migration.go changes
  • Check tests for meaningful coverage
  • Post inline comments for concrete issues
  • Post final summary grouped by severity

Summary

Reviewed the DFS trie migrator (enumerate → traverse → hash → commit, with a parallel edge-hashing pool for large tries), the resumability/wipe logic in trie.go, and did a lighter pass over statehistory/headstate and the small core/state/node changes.

Important

  • migration/trie/ingestor.go (migrateTrie/traverse, lines 176-197): when traversal of a large trie (NodeCount >= SmallTrieThreshold) returns an error — from ctx cancellation or any other error (e.g. a transient batch write failure) — sched.sync() is skipped, leaving an in-flight parallel hash batch undrained. The background goroutine spawned by hashWorkerPool.submit (hashpool.go) keeps running independently and can still be sending to the pool's shared work channel when runMigration's deferred pool.close() closes it, racing a send against a close (panic: send on closed channel). Left inline detail on migration/trie/ingestor.go. This path is currently untested — SmallTrieThreshold is 100k nodes, well above any fixture trie in trie_test.go, matching the low patch coverage Codecov reported for hashworker.go/hashpool.go.

Nit / observation

  • Recursive DFS in traverse is fine given Starknet's max path depth (≤251), no stack-depth concern.
  • enumerateStorageTries/scanTrie iterator handoff between owners looks correct (iterator is left positioned at the first non-matching key, which the next loop iteration consumes as the new owner's start).
  • encodeNodeKey's owner == zero treated as "no owner" mirrors the existing convention in core/trie2/trieutils/accessors.go, so not a new issue.
  • core/state/accessors.go and node/migration.go changes are small/mechanical and look correct (gating the new migrator behind cfg.NewState, matching the existing newstate migration).

Everything else in the trie package (codec, root-edge handling for non-empty deprecated root paths, resumability check via rootProcessed, bucket wipe gated on full pipeline success) matches the documented design and looks correct; the sequential (non-parallel) path is well covered by trie_test.go (fresh DB no-op, end-to-end hash-identity vs. native trie2.Trie.Update, resumability, multi-owner storage, cancellation-before-start).

Branch: maksym/trie-migration

Comment on lines +176 to +197
func (i *ingestor) migrateTrie(t *task, desc TrieDesc, outputs chan<- task) error {
if desc.NodeCount == 0 {
return nil
}
parallelDispatch := desc.NodeCount >= SmallTrieThreshold
prefix := deprecatedTriePrefix(desc)
sched := newHashScheduler(desc.HashFn, parallelDispatch, desc.TrieBucket, desc.Owner, i.pool)

rootHash, err := i.traverse(t, outputs, prefix, *desc.RootPath, sched)
if err != nil {
return err
}
if err := sched.sync(t.batch); err != nil {
return err
}
if desc.RootPath.Len() > 0 {
if err := writeRootEdgeNode(desc.RootPath, rootHash, sched, t.batch); err != nil {
return err
}
}
return nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential panic: orphaned hashWorkerPool.submit goroutine races with pool.close() when a large trie's traversal errors out.

For tries at/above SmallTrieThreshold, hashScheduler.fire (hashworker.go:71-84) submits a batch to the shared hashWorkerPool and only drains it (drainInFlight) on the next fire call or in sched.sync (hashworker.go:106-129). sync is only reached here on the success path of traverse — if traverse returns an error (line 184-187), migrateTrie returns immediately and sched.sync is never called, so any in-flight batch is abandoned with its done channel never consumed.

That abandoned batch corresponds to a still-running background goroutine spawned inside hashWorkerPool.submit (hashpool.go:46-58), which is independently sending chunks to p.work and doing wg.Wait(). Meanwhile the error propagates up through the pipeline, runMigration returns early, and its defer pool.close() (trie.go:118) closes p.work — with no synchronization against that orphaned goroutine. If the orphaned goroutine hasn't finished its sends yet, close(p.work) races with p.work <- hashWork{...}, which panics with "send on closed channel".

This isn't limited to ctx cancellation — any error during traversal of a large (parallel-dispatch) trie (e.g. a transient batch Put/db error) hits the same path, since traverse's error return skips sync() unconditionally.

Notably, SmallTrieThreshold is 100_000 nodes, so none of the current tests (which use tiny fixture tries) exercise this code path at all — consistent with hashworker.go/hashpool.go showing the lowest patch coverage in this PR (36% / 32% per Codecov).

Suggest draining any in-flight batch before returning an error from migrateTrie/traverse (e.g. defer-driven drain, or have pool.close() wait for outstanding submissions via a sync.WaitGroup tracked by the pool itself), so shutdown/error paths can't race with close(p.work).

Fix this →

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

 Estimated effort to review: 4 🔵🔵🔵🔵⚪
🏅 Score: 85
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Data Corruption

In TransformStorageHistory, the iterators deprecatedHistoryIt and headStorageTrieIt traverse keys in ascending slot order. However, if headStorageTrieIt is at a slot that has a leaf but no history entries (i.e., leafSlot is lexicographically less than slot), the code fails to advance headStorageTrieIt. As a result, leafSlot will permanently remain less than slot, causing bytes.Equal(leafSlot, slot.Marshal()) to evaluate to false for all subsequent slots in the history iterator. This causes the migration to incorrectly assign 0 instead of the actual trie head value to the remaining history entries. You must advance headStorageTrieIt in a loop as long as bytes.Compare(leafSlot, slot.Marshal()) < 0 before checking for equality.

case leafValid:
	leafSlot := headStorageTrieIt.Key()[len(leafPrefix):]
	if bytes.Equal(leafSlot, slot.Marshal()) {
		raw, err := headStorageTrieIt.Value()
		if err != nil {
			return fmt.Errorf("storage: leaf(%s, slot=%s): %w", &addrFelt, &slot, err)
		}
		var node trie.Node
		if err := node.UnmarshalBinary(raw); err != nil {
			return fmt.Errorf("storage: decode leaf(%s, slot=%s): %w", &addrFelt, &slot, err)
		}
		historyValue = *node.Value
		leafValid = headStorageTrieIt.Next()
	}

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @NazariiDenha's task in 3m 20s —— View job


Review of PR #3659 (trie migration)

Todo list

  • Gather context (diff vs origin/maksym/statehistory-migration, changed files)
  • Review migration/state/newstate/internal/trie/codec.go
  • Review migration/state/newstate/internal/trie/ingestor.go (DFS traversal, error/cancellation paths)
  • Review migration/state/newstate/internal/trie/hashworker.go + hashpool.go (parallel hashing, previously-flagged panic)
  • Review migration/state/newstate/internal/trie/trie.go (pipeline orchestration, resumability, bucket wipe)
  • Review migration/state/newstate/internal/trie/committer.go + counter.go
  • Review migration/state/newstate/internal/trie/trie_test.go for coverage of concurrency paths
  • Check migration/state/newstate/migrator.go wiring
  • Post inline comments for concrete issues
  • Post final summary grouped by severity

Summary

This diff (git diff origin/maksym/statehistory-migration...HEAD) is a pure relocation: migration/trie/*migration/state/newstate/internal/trie/* (confirmed via git show 1df3dd83c, which is a 100% rename with no content changes) plus a 2-line wiring addition in migration/state/newstate/migrator.go registering trie.Migrator as the third phase after headstate/history. go build ./migration/... passes.

Important

  • migration/state/newstate/internal/trie/ingestor.go:176-197 — the "send on closed channel" panic risk flagged in the previous review pass is still present, unchanged by this move. migrateTrie skips hashScheduler.sync whenever traverse returns an error (ctx cancellation or any read/decode failure on a large trie), which can leave an in-flight parallel hash batch's background goroutine (hashWorkerPool.submit, hashpool.go) still sending to the shared work channel when runMigration's deferred pool.close() closes it. Left a fresh inline comment at the new path with a suggested fix (track outstanding submissions in the pool itself via a WaitGroup that close() waits on). Still untested — SmallTrieThreshold (100k nodes) is far above any fixture trie in trie_test.go.

Nit / observation

  • No other logic changed in this diff, so the rest of the previous review's findings still stand: the sequential (non-parallel) path is well covered by trie_test.go (fresh-DB no-op, end-to-end hash-identity vs. native trie2.Trie.Update, resumability, multi-owner storage, cancellation-before-start); recursive DFS depth is bounded by Starknet's max path depth (≤251) so no stack concern; bucket-wipe-on-full-success and root-edge handling look correct.
  • The new migrator.go wiring is mechanical and matches the existing phase-checkpoint pattern used by headstate/history.

//
// In-flight batches flush at target size; cancellation is observed at
// every flush and every channel send.
func (i *ingestor) migrateTrie(t *task, desc TrieDesc, outputs chan<- task) error {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carried over from the previous review: still-unresolved panic risk (send on closed channel) when a large trie's traversal errors out.

migrateTrie only calls sched.sync(t.batch) on the success path (line 188). If i.traverse returns an error at line 184-187 — which happens on ctx cancellation (via flush's select) or any other read/decode error — the function returns immediately, and sched.sync is skipped.

For a trie at/above SmallTrieThreshold (parallel dispatch), hashScheduler.fire (hashworker.go:71-84) may already have an in-flight batch submitted to the shared hashWorkerPool via pool.submit (hashpool.go:41-59). That submission spawns a background goroutine that sends chunks to p.work and then does wg.Wait(); it is only drained by drainInFlight, which is reached from sync(). Skipping sync() on error abandons that goroutine — it's not tied to ctx at all, so it keeps running independently.

Meanwhile the error propagates up through pipeline.New (migration/pipeline/pipeline.go:65-68), which calls r.cancel(); runMigration (trie.go:108-165) returns shouldRerun, err, and its defer pool.close() (trie.go:118) calls close(p.work). If the orphaned goroutine hasn't finished sending its remaining chunks yet, that send races with the close and panics with "send on closed channel".

This is unchanged since it was flagged in the last review pass (then at migration/trie/ingestor.go) — this commit only moved the file to migration/state/newstate/internal/trie/ (git show 1df3dd83c is a pure rename + 2-line wiring change in migrator.go), the logic itself is untouched. Still no test exercises SmallTrieThreshold/parallel dispatch (grep -n parallel trie_test.go finds nothing), consistent with the low patch coverage Codecov reported for hashworker.go/hashpool.go.

Suggest having hashWorkerPool track outstanding submit calls itself (e.g. its own sync.WaitGroup that close() waits on before closing p.work), so an error/cancellation path in migrateTrie can't race with pool shutdown regardless of whether sync() gets called.

Fix this →

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants