Skip to content

feat: add canonical state-diff rewriter - #3

Draft
littlehand26 wants to merge 15 commits into
mainfrom
fix/bugb-statediff-rewriter
Draft

littlehand26 wants to merge 15 commits into
mainfrom
fix/bugb-statediff-rewriter

Conversation

@littlehand26

@littlehand26 littlehand26 commented Jul 21, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Historical Cronos BlockStorageDiff objects contain replay-derived storage that misses module writes. Bug B recovery needs a fail-closed replacement sourced from a frozen IAVL archive while changing only StorageDiff.

Changes

  • pin the reviewed Ethermint canonical storage converter and fixed AWS SDK v2 versions
  • extract canonical EVM storage changes directly from IAVL: ordered legacy r/o/n scans plus version-root traversal where historical roots are available
  • stage the full-range legacy prefix with dump --stop-after-legacy; a later full dump validates and reuses every completed chunk before skipping the expensive legacy scan/sort
  • seal source identity, dump chunks, roots, manifests, packs, and sidecar indexes with strict closed-set and hash checks
  • replace only top-level RLP field index 4; verify the other five raw RLP fields byte-for-byte and treat only an identical full body as a no-op
  • keep old/new bodies, hashes, old ETag, object metadata, roots, and rollback data in the sealed pack; schema-v1 comparison counters remain zero
  • on fresh apply, persist the batch intent and issue one PutObject If-Match: OldETag with CRC32C; validate response CRC32C and ETag, with no normal pre-GET or post-GET
  • use GET only to classify 409/412, timeout/network, 408/429/5xx, or unverifiable responses as exact-old, exact-new, or third content
  • persist confirmed, uncertain, already-target, and observed conditional-conflict audit counters across journal/checkpoint recovery
  • keep verify as an optional read-only diagnostic; it is not a production completion gate
  • require a read-only sealed plan and independently restored read-only backup before apply
  • require a RocksDB-linked build, record build provenance, and build/run the amd64 one-shot image in PR CI without pushing it

Verification

  • go test -mod=readonly -tags=objstore ./cmd/statediff-rewriter -count=1
  • targeted go test -mod=readonly -tags=objstore -race ./cmd/statediff-rewriter
  • go vet -mod=readonly -tags=objstore ./cmd/statediff-rewriter
  • go test -mod=readonly -tags=objstore ./...
  • go mod verify
  • direct-replacement, 1000-record 1000 PUT / 0 GET, anomaly reconciliation, interruption, checkpoint, journal, and rollback tests
  • current-head RocksDB/Docker CI: run 29927429188

Draft gates

This remains draft until the final-F archive pilots, real S3 scratch-prefix conditional-write test, storage sizing, sealed backup restore, and production write approval are complete. No production S3 mutation is authorized by this PR.

Known repository CI configuration failures are tracked separately: dependency review cannot run while Dependency graph is disabled, and integration tests cannot start without the Cachix credentials. Both reproduce on main and are unrelated to this diff.

Signed-off-by: lihe <he.li@debank.com>
Signed-off-by: lihe <he.li@debank.com>
Signed-off-by: lihe <he.li@debank.com>
Signed-off-by: lihe <he.li@debank.com>
Comment on lines +195 to +203
for key, newValue := range newValues.Values {
oldValue, ok := oldValues.Values[key]
switch {
case !ok:
stats.Added++
case oldValue != newValue:
stats.Changed++
}
}
Comment on lines +204 to +208
for key := range oldValues.Values {
if _, ok := newValues.Values[key]; !ok {
stats.Removed++
}
}
Comment thread cmd/statediff-rewriter/operations.go Fixed
Comment thread cmd/statediff-rewriter/operations.go Fixed

@littlehand26 littlehand26 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Review

Reviewed at 19b6aed4. Findings below are ordered by blast radius. Everything marked "verified" was checked against source, a real build, or production data — not inferred.


1. Blocking — height==2 parentRoot is wrong; a full plan aborts on its first processed height

cmd/statediff-rewriter/plan.go#L234-L235

if height == 2 {
    return root, common.BytesToHash((storetypes.CommitInfo{}).Hash()), nil
}

CommitInfo{}.Hash() special-cases the empty store set and returns sha256("") = 0xe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 (cosmos-sdk/store/types/commit_info.go:33-37).

The producer derives ParentHash differently: trace/api.go:183 calls BuildBlockStateDiff(parentHeader.Root, ...), and parentHeader.Root comes from rpc/types/utils.go:87 — Root: common.BytesToHash(header.AppHash), i.e. block H−1's Tendermint app_hash.

Verified: cronosmainnet_25-1's genesis app_hash is the empty string (fetched via /genesis_chunked). So block 1's header.AppHash is empty and common.BytesToHash([]byte{}) is the zero hash. The stored object for height 2 therefore carries ParentHash = 0x000…0, which can never equal 0xe3b0c442….

Result: plan.go#L197 returns root mismatch and runPlan terminates at height 2. Fail-closed, so no data is corrupted — but the plan can never complete.

Note the design doc has the same formula (§5.3.2, parentRoot(H) = H==2 ? CommitInfo{}.Hash() : …), so the doc needs the same correction: for this chain the height-2 parent root is the genesis app_hash, i.e. the zero hash.

Why no test caught it: archiveRoots takes a concrete *archiveReader, which is rocksdb-only, so the height↔CommitInfo↔S3-key mapping test required by §7.1 cannot be written at all. runPlan currently has zero test coverage. Suggest narrowing archiveRoots to a minimal interface and adding that mapping test.

Positive result from the same check: the key formula itself is correct. GET 25/4cc51d2e/0x9d565d6c3d91b141770ef3a71a2a956d1706ca030f7e35aed1b8318f7b18c98d/stateDiff (231 B) decodes to Hash = block 83908244's app_hash, ParentHash = block 83908243's app_hash, and an empty StorageDiff — confirming both the mapping and Bug B on the documented fixture block.


2. apply's independent-backup gate compares paths, not storage

cmd/statediff-rewriter/operations.go#L44-L54

The only independence test is EvalSymlinks(sourceDir) != EvalSymlinks(backupDir); proof.Kind is checked for non-emptiness only. A cp -r to a sibling directory on the same work EBS, plus a hand-written proof JSON, satisfies the gate — which is exactly the case §5.3.3 says must be rejected ("只有同机工作卷一份副本时拒绝 apply").

Directory buckets have no versioning, so the pack's OldBody is the only surviving copy of the old bytes. Suggest comparing syscall.Stat_t.Dev (the package already uses syscall.Statfs) and constraining Kind to an allowlist.

The existing negative test only covers Location == the plan directory, so the same-volume case is untested — and the passing test copies the plan into another t.TempDir() on the same filesystem.


3. Any failed PUT is reclassified as success when the target bytes are visible

cmd/statediff-rewriter/operations.go#L105-L111

Every PutObject error is funnelled through the indeterminate-PUT reclassification without inspecting it, so a definitive 412 (or an unexplained 409) is silently absorbed and counted in report.PUTs whenever the follow-up GET happens to show the target bytes. §5.3.3 requires the run to stop in that case.

Object content is still guaranteed by the post-write verification at operations.go:115-124, so this is a lost concurrency signal and an inaccurate PUT count rather than a correctness defect. Suggest branching on the error type.


4. Test gaps against §7.1

  • runPlan and runVerify have no test coverage at all.
  • No equal-root pair (empty storage → skip; non-empty → hard fail).
  • No chunk rollover, no cross-chunk ordinal continuity, no checkpoint resume from a non-zero frontier — i.e. the >512 MiB production path and the "apply 中断、checkpoint 恢复" case are never executed.
  • No mainnet fixture (83908244 P1, or a P9 bridge-out block).
  • No plan → apply → verify → plan idempotency test.

The dump-sealing tests are the strong part: 1–15 byte partial headers, declared-length overrun, bad zlib checksum, missing trailer, trailing garbage, and numeric-vs-lexicographic file ordering are all covered.


5. Build / CI

Verified by actually running things, not by reading:

  • No dependency drift. Diffed against the real base 2ec3621b: go.mod / go.sum / gomod2nix.toml contain only the aws-sdk-go-v2 additions, the iavl + holiman/uint256 indirect→direct promotion (byte-identical versions), and the ethermint replace bump 60189fd0e284 → e017d5ee7c8f. Zero hits for cosmos-sdk, cosmossdk.io/store, cometbft, go-ethereum, ibc-go, cronos-store. As a bonus, this also fixes a pre-existing inconsistency where base gomod2nix.toml still resolved ethermint to crypto-org-chain/ethermint while go.mod had the Chaintable replace.
  • Build tags are right. make -n build-statediff-rewriter COSMOS_BUILD_OPTIONS=rocksdb expands to -tags "netgo objstore pebbledb mainnet ledger rocksdb grocksdb_clean_link nativebyteorder", so rocksDBBuild == true. COMMIT reaches version.Commit via -X (confirmed with an override and in the built binary).
  • The image runs. Built Dockerfile.statediff-rewriter verbatim on an amd64 host and ran it: full help, exit 0, ldd shows no unresolved libraries. librocksdb.so needs at most GLIBC_2.34 / GLIBCXX_3.4.30; ubuntu:22.04 provides 2.35 / 3.4.30. It works — but GLIBCXX has zero headroom: moving the builder base to gcc 13+ (e.g. a trixie-based golang image) will break the runtime with GLIBCXX_3.4.31 not found.
  • CI builds the image but never runs it (push: false, no docker run), and no job declares needs: build-statediff-rewriter; the repo has no branch protection, so it blocks nothing. Adding a docker run <image> --help smoke step is the highest-value change here, since it would cover both the build-tag guard and the runtime linkage.
  • jlumbroso/free-disk-space@main is a mutable third-party branch ref (lines 28 and 65).
  • Minor: Makefile:68 sets CGO_ENABLED=1 as a plain make variable with no export, so it never reaches the go build environment — harmless, since cgo defaults on for native builds, but the line is a no-op.
  • Minor: CI stamps GIT_COMMIT=${{ github.sha }}, which on pull_request is the ephemeral merge commit. Harmless while push: false, but would record a non-existent commit if the job ever starts publishing.

6. Two premises worth revisiting

Multistream(false) is dead code. dump.go#L61-L63 — compress/zlib's reader has no Multistream method (only *gzip.Reader does), so the type assertion is statically false and the block never executes. The concatenated-stream case is in fact still caught, by the counter.n != stat.Size() check and the trailing-data Peek, so nothing is unprotected — but the code does not do what it reads as doing. Suggest deleting it or replacing it with a real check.

The cost model needs updating. Measured on this chain: every block carries an EIP-2935 storage write (0x0000F90827F1C53a10cb7A02335B175320002935) that the old S3 objects are missing — 1000/1000 over a contiguous sample. So the "语义相同不 PUT" saving that §5.3.5 leans on will save essentially nothing, and sizing should assume close to 84M PUTs and old+new bodies for nearly every object, not a small changed set.


7. Scale note (draft gates acknowledged)

The PR defers bounded concurrency, so this is context rather than a finding: plan and verify are currently fully serial S3 GETs, and each processed unit does an atomicWrite checkpoint (two fsyncs, ~7 syscalls) — runVerify's block loop does that for every height in [2,F]. At 84M heights both dominate wall-clock well before the network does, so even a pilot may not finish in the serial form. Persisting the checkpoint every N units rather than every unit would cost nothing in correctness given the resume logic is already idempotent.

Also worth noting: verify GETs each changed object twice per run — once in the pack loop for the exact-bytes check, then again in the block-height loop, which re-derives the key for every height with root != parent.


Refuted during review (raised, then disproved — recording so they don't get re-raised): missing S3 retry/backoff (the pinned SDK's default retry.NewStandard() covers it), unbounded new-body size (unreachable), sealed-plan enforcement being name-only (the manifest is written once, after all validation), pack hash TOCTOU (the write path is fail-closed on data a substituted pack does not control), fd leaks in mergeRootChunks (both functions run once per process and every error path exits), and the ethermint pin being unfetchable because it only lives on an unmerged PR branch (GitHub allows fetching by raw SHA; verified empirically).

@littlehand26

Copy link
Copy Markdown
Contributor Author

Addendum — CI status

Of the 4 failing checks, only golangci-lint is caused by this PR. All 11 findings are in the new package:

cmd/statediff-rewriter/operations.go:66:13  string `apply` has 3 occurrences, make it a constant  (goconst)
cmd/statediff-rewriter/operations.go:90:4   ineffectual assignment to sourceBody                 (ineffassign)
cmd/statediff-rewriter/main.go:23:3         exitAfterDefer: os.Exit will exit, and `defer cancel()`
                                            will not run                                         (gocritic)
cmd/statediff-rewriter/dump_test.go:57,86,97,106,114,122     G306: expect WriteFile perms ≤ 0600  (gosec)
cmd/statediff-rewriter/operations_test.go:123,136,187        G306: expect WriteFile perms ≤ 0600  (gosec)

Two are worth more than a mechanical fix:

  • operations.go:90 sourceBody is genuinely dead. The rollback swap at operations.go:89-92 swaps both the bodies and the SHAs, but only targetBody is ever used (the Put at :105); sourceBody is never read. The logic is correct — the source side is matched purely by SHA at :98-99 — so the fix is to drop sourceBody from the swap rather than to start using it.
  • main.go:23 exitAfterDefer. defer cancel() on the signal.NotifyContext never runs because of the os.Exit(1). Benign at process exit, but it does mean the signal handler is never deregistered on the error path.

The other three failures are not from this PR:

  • dependency-review — "Dependency review is not supported on this repository. Please ensure that Dependency graph is enabled." Repo-level setting, not a code problem.
  • integration_tests (upgrade) and (gov) — both die ~10s in on tar: Error is not recoverable: exiting now during cache/artifact restore, before any test runs. Environmental.

build-statediff-rewriter itself passed (11m39s), as did unittest, build-rocksdb on both arches, Gosec, CodeQL and Analyze (go).

Signed-off-by: lihe <he.li@debank.com>
Signed-off-by: lihe <he.li@debank.com>
if err != nil {
return err
}
defer file.Close()
Comment on lines +134 to +138
for label, diff := range map[string]dtypes.BlockStorageDiff{"old": oldDiff, "new": newDiff} {
if diff.Hash != root || diff.ParentHash != parent {
return fmt.Errorf("%s body root mismatch", label)
}
}
"io"
"os"
"path/filepath"
"reflect"
"io"
"os"
"path/filepath"
"runtime"
Files: len(ranges), GeneratedFiles: generatedFiles.Load(), ReusedFiles: reusedFiles.Load(), Records: records,
GeneratedRecords: generatedRecords.Load(), ReusedRecords: reusedRecords.Load(),
Concurrency: workerCount, CacheSize: options.CacheSize, ZlibLevel: options.ZlibLevel,
DurationSeconds: duration.Seconds(), HandledBlocksPerSecond: float64(records) / duration.Seconds(),
Comment thread cmd/statediff-rewriter/operations.go Fixed
report.SemanticChanges = semanticChanges.Load()
report.DurationSeconds = time.Since(started).Seconds()
if report.DurationSeconds > 0 {
report.ObjectsPerSecond = float64(report.S3GETs) / report.DurationSeconds
Comment on lines +136 to +164
go func() {
expected := firstSequence
var count uint64
emit := func(sequence uint64, value Input) error {
if sequence != expected {
return fmt.Errorf("producer emitted sequence %d, want %d", sequence, expected)
}
select {
case tokens <- struct{}{}:
case <-pipelineCtx.Done():
return pipelineCtx.Err()
}
select {
case jobs <- orderedTask[Input]{sequence: sequence, value: value}:
expected++
count++
return nil
case <-pipelineCtx.Done():
<-tokens
return pipelineCtx.Err()
}
}
err := produce(pipelineCtx, emit)
producerDone <- producerSummary{count: count, err: err}
close(jobs)
if err != nil {
cancel()
}
}()
Comment on lines +169 to +186
go func() {
defer workerGroup.Done()
for task := range jobs {
value, err := work(pipelineCtx, task.value)
result := orderedResult[Output]{sequence: task.sequence, value: value, err: err}
if err != nil {
// Deliver the causal error before cancellation makes peers exit.
results <- result
cancel()
return
}
select {
case results <- result:
case <-pipelineCtx.Done():
return
}
}
}()
Comment on lines +188 to +191
go func() {
workerGroup.Wait()
close(results)
}()
Signed-off-by: lihe <he.li@debank.com>
Signed-off-by: lihe <he.li@debank.com>
report.Conflicts = metrics.conflicts.Load()
report.DurationSeconds = time.Since(started).Seconds()
if report.DurationSeconds > 0 {
report.ObjectsPerSecond = float64(report.CompletedThisRun) / report.DurationSeconds
Signed-off-by: lihe <he.li@debank.com>
Signed-off-by: lihe <he.li@debank.com>
Signed-off-by: lihe <he.li@debank.com>
Files: len(legacyRanges), GeneratedFiles: generatedFiles.Load(), ReusedFiles: reusedFiles.Load(), Records: records,
GeneratedRecords: generatedRecords.Load(), ReusedRecords: reusedRecords.Load(), Concurrency: 1,
CacheSize: options.CacheSize, ZlibLevel: options.ZlibLevel, DurationSeconds: duration.Seconds(),
HandledBlocksPerSecond: float64(records) / duration.Seconds(),
GeneratedRecords: generatedRecords.Load(), ReusedRecords: reusedRecords.Load(), Concurrency: 1,
CacheSize: options.CacheSize, ZlibLevel: options.ZlibLevel, DurationSeconds: duration.Seconds(),
HandledBlocksPerSecond: float64(records) / duration.Seconds(),
GeneratedBlocksPerSecond: float64(generatedRecords.Load()) / duration.Seconds(),
GeneratedRecords: generatedRecords.Load(), ReusedRecords: reusedRecords.Load(),
Concurrency: workerCount, CacheSize: options.CacheSize, ZlibLevel: options.ZlibLevel,
DurationSeconds: duration.Seconds(), HandledBlocksPerSecond: float64(records) / duration.Seconds(),
GeneratedBlocksPerSecond: float64(generatedRecords.Load()) / duration.Seconds(),
Signed-off-by: lihe <he.li@debank.com>
Signed-off-by: lihe <he.li@debank.com>
Signed-off-by: lihe <he.li@debank.com>
Comment on lines +141 to +144
go func() {
groupDone <- group.Wait()
close(results)
}()
Signed-off-by: lihe <he.li@debank.com>

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants