From d85aa984a553865388f5024ee74343509314a00a Mon Sep 17 00:00:00 2001 From: syntrust Date: Wed, 15 Jul 2026 17:30:46 +0800 Subject: [PATCH 1/8] qkc/shard: name shard chaindb directories through DBDirName helpers DBDirName and ParseDBDirName make the shard-0x{full_shard_id} naming convention a single point of truth: boot builds paths through it and the inspect subcommand parses directory listings back through it. Co-Authored-By: Claude Fable 5 --- qkc/shard/shard.go | 23 ++++++++++++++++++++++- qkc/shard/shard_test.go | 20 ++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/qkc/shard/shard.go b/qkc/shard/shard.go index 39aa65345eb3..d4a80a8d47f4 100644 --- a/qkc/shard/shard.go +++ b/qkc/shard/shard.go @@ -9,6 +9,8 @@ package shard import ( "fmt" "path/filepath" + "strconv" + "strings" "sync" "github.com/ethereum/go-ethereum/core/rawdb" @@ -27,6 +29,25 @@ const ( dbHandles = 16 ) +// DBDirName returns the chaindb directory name one shard uses under the datadir. +func DBDirName(fullShardID uint32) string { + return fmt.Sprintf("shard-0x%08x", fullShardID) +} + +// ParseDBDirName reports the full shard id a chaindb directory name encodes, or +// ok=false when the name is not a canonical shard chaindb directory name. +func ParseDBDirName(name string) (fullShardID uint32, ok bool) { + hexPart, found := strings.CutPrefix(name, "shard-0x") + if !found || len(hexPart) != 8 { + return 0, false + } + id, err := strconv.ParseUint(hexPart, 16, 32) + if err != nil { + return 0, false + } + return uint32(id), true +} + // Shard is one shard chain hosted by the slave: its Branch (the registry key), its // resolved config, an isolated chaindb, and the chain behind the ShardChain seam. type Shard struct { @@ -63,7 +84,7 @@ func New(ctx *config.SlaveContext, branch account.Branch, rootGenesis *types.Roo } else { // A directory per shard (not pyquarkchain's shard-{id}.db file): the // directory form is what geth's rawdb expects. - dbPath = filepath.Join(datadir, fmt.Sprintf("shard-0x%08x", fullShardID)) + dbPath = filepath.Join(datadir, DBDirName(fullShardID)) kv, err := pebble.New(dbPath, dbCacheMB, dbHandles, fmt.Sprintf("qkc/shard/0x%08x/", fullShardID), false) if err != nil { return nil, fmt.Errorf("shard 0x%08x: open chaindb %s: %w", fullShardID, dbPath, err) diff --git a/qkc/shard/shard_test.go b/qkc/shard/shard_test.go index d931c4168176..dcab15dc1408 100644 --- a/qkc/shard/shard_test.go +++ b/qkc/shard/shard_test.go @@ -242,3 +242,23 @@ func TestShardStopIdempotent(t *testing.T) { t.Fatalf("Stop(again): %v", err) } } + +func TestDBDirNameRoundTrip(t *testing.T) { + for _, id := range []uint32{0, 1, 0x00040001, 0xffffffff} { + name := DBDirName(id) + if got, ok := ParseDBDirName(name); !ok || got != id { + t.Errorf("ParseDBDirName(%q) = (0x%08x, %v), want (0x%08x, true)", name, got, ok, id) + } + } + for _, name := range []string{ + "shard-0x1", // not zero-padded + "shard-0x000000012", // too long + "shard-00000001", // missing 0x + "shard-0xzzzzzzzz", // not hex + "chaindata", + } { + if id, ok := ParseDBDirName(name); ok { + t.Errorf("ParseDBDirName(%q) = (0x%08x, true), want rejection", name, id) + } + } +} From 5758a2f1ebeb0b1b4dda1231b5626da62f574f2b Mon Sep 17 00:00:00 2001 From: syntrust Date: Wed, 15 Jul 2026 17:30:56 +0800 Subject: [PATCH 2/8] cmd/slave: add read-only inspect subcommand slave inspect --datadir is read-only and config-free: it scans for shard-0x{full_shard_id}/ chaindb directories, opens each in pebble read-only mode, and prints the stored genesis metadata record and chain head. A shard that cannot be opened or read is reported inline without aborting the remaining shards, with a non-zero exit if any failed. An absent metadata record is reported as an interrupted bootstrap rather than an error, since the next boot re-runs the fresh path. The README gains the inspect section and a fixtures section covering provenance and the pyquarkchain cross-validation command. Co-Authored-By: Claude Fable 5 --- cmd/slave/README.md | 56 ++++++++++++++++-- cmd/slave/inspect_test.go | 104 +++++++++++++++++++++++++++++++++ cmd/slave/inspectcmd.go | 119 ++++++++++++++++++++++++++++++++++++++ cmd/slave/main.go | 1 + cmd/slave/slave_test.go | 16 +++-- 5 files changed, 287 insertions(+), 9 deletions(-) create mode 100644 cmd/slave/inspect_test.go create mode 100644 cmd/slave/inspectcmd.go diff --git a/cmd/slave/README.md b/cmd/slave/README.md index 8080abdf048f..c3de30bd9e3c 100644 --- a/cmd/slave/README.md +++ b/cmd/slave/README.md @@ -38,6 +38,37 @@ Only geth's logging and file-based profiling debug flags are exposed. The debug flags that would open a socket — the `--pprof` HTTP server and `--pyroscope.*` push — are deliberately not registered, keeping the process free of network I/O. +## Inspecting a datadir (milestone M4) + +`slave inspect` is read-only and needs no config: it scans `--datadir` for shard +chaindb directories (`shard-0x{full_shard_id}/`), opens each in read-only mode, +and prints the stored genesis metadata record and chain head. A shard that +cannot be opened or read is reported without aborting the others, and the exit +status is non-zero if any shard failed. A running slave holds its chaindb locks +(each shard then reports `resource temporarily unavailable`), so inspect a +stopped node. The report goes to stdout; log lines go to stderr. + +``` +./build/bin/slave inspect --datadir ./qkc-data/devnet +``` + +``` +shard 0x00000001 (qkc-data/devnet/shard-0x00000001): + meta version: 1 + chain genesis: 0xea741742184975635c2eb1ba468e7b7f58156025517eee3d7583f4ca0ad2dbca + root genesis: 0x5ad443efb7cf5246a3d1bbc1734bd02bf3a5d83bedeccfcfe707d0ebee03780d + hash_prev_root_block: 0x5ad443efb7cf5246a3d1bbc1734bd02bf3a5d83bedeccfcfe707d0ebee03780d + xshard cursor: root=0 minor=0 deposit=0 + head block: none recorded (stub chain persists no head) +shard 0x00040001 (qkc-data/devnet/shard-0x00040001): + ... +2 shard(s) inspected, 0 failed +``` + +A chaindb whose bootstrap was interrupted before the metadata record committed +prints `genesis metadata: none (bootstrap never completed; next boot +re-initializes)` — the next `slave` run re-runs the fresh initialization path. + ## Subcommands (milestone M1) ### `slave config` @@ -95,11 +126,28 @@ The running devnet config works the same way: prints `hash: 0x5ad443efb7cf5246a3d1bbc1734bd02bf3a5d83bedeccfcfe707d0ebee03780d`. +## Fixtures and pyquarkchain cross-validation + Both real (singularity) cluster configs are checked in under [`qkc/config/singularity/`](../../qkc/config/singularity/) — `mainnet.json` and -`devnet.json` (provenance/regeneration in that directory's README). +`devnet.json`. They are copied verbatim from pyquarkchain; provenance and the +regeneration steps live in [that directory's README](../../qkc/config/singularity/README.md). -## Not yet implemented +To cross-validate a `slave genesis` run against pyquarkchain, derive the same +header there (swap the path for devnet) and compare with the `hash:` line: + +``` +# from the root of a pyquarkchain checkout: +python -c " +import json +from quarkchain.cluster.cluster_config import ClusterConfig +from quarkchain.genesis import GenesisManager +raw = json.load(open('mainnet/singularity/cluster_config_template.json')) +h = GenesisManager(ClusterConfig.from_dict(raw).QUARKCHAIN).create_root_block().header +print('hash', h.get_hash().hex()) +" +``` -The `inspect` subcommand (read-only per-shard state dump from a datadir, no -config needed) lands in milestone M4. +The shard-level `chain genesis` printed by `slave inspect` is the config +descriptor's fingerprint, not a pyquarkchain minor-block hash; it becomes the +real shard genesis block hash when the QKC block format (#1) lands. diff --git a/cmd/slave/inspect_test.go b/cmd/slave/inspect_test.go new file mode 100644 index 000000000000..fad63d204964 --- /dev/null +++ b/cmd/slave/inspect_test.go @@ -0,0 +1,104 @@ +// Copyright 2026-2027, QuarkChain. + +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/qkc/config" +) + +// initDataDir boots S0 from a fixture into dbRoot and stops it, leaving behind +// initialized shard chaindbs to inspect. +func initDataDir(t *testing.T, fixturePath, dbRoot string) { + t.Helper() + cfg, err := config.LoadClusterConfig(writeFixtureWithDBRoot(t, fixturePath, dbRoot)) + if err != nil { + t.Fatalf("load config: %v", err) + } + backend, err := bootSlave(cfg, "S0") + if err != nil { + t.Fatalf("bootSlave: %v", err) + } + if err := backend.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } +} + +func TestInspectDataDir(t *testing.T) { + dbRoot := t.TempDir() + initDataDir(t, fixtures[0].path, dbRoot) + + var buf bytes.Buffer + if err := inspectDataDir(&buf, dbRoot); err != nil { + t.Fatalf("inspectDataDir: %v\noutput:\n%s", err, buf.String()) + } + out := buf.String() + for _, want := range []string{ + "shard 0x00000001 (", + "shard 0x00040001 (", + "meta version: 1", + "chain genesis: 0x", + "root genesis: 0x4036783e441eb5057bf2be96bf1fd4585ac49824de15c0d92a4c14a97886ca51", + "xshard cursor: root=0 minor=0 deposit=0", + "head block: none recorded", + "2 shard(s) inspected, 0 failed", + } { + if !strings.Contains(out, want) { + t.Errorf("inspect output missing %q:\n%s", want, out) + } + } +} + +// TestInspectReportsBrokenShardAndContinues plants a shard-named directory that +// is not a chaindb next to two healthy shards: the broken one is reported, the +// healthy ones still print, and the joined error is non-nil. +func TestInspectReportsBrokenShardAndContinues(t *testing.T) { + dbRoot := t.TempDir() + initDataDir(t, fixtures[0].path, dbRoot) + if err := os.Mkdir(filepath.Join(dbRoot, "shard-0xdeadbeef"), 0o755); err != nil { + t.Fatal(err) + } + // Neither a stray file with a shard name nor an unrelated directory is a + // shard chaindb; both must be skipped silently. + if err := os.WriteFile(filepath.Join(dbRoot, "shard-0x000000ff"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(dbRoot, "not-a-shard"), 0o755); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + err := inspectDataDir(&buf, dbRoot) + if err == nil || !strings.Contains(err.Error(), "0xdeadbeef") { + t.Fatalf("inspectDataDir err = %v, want failure naming 0xdeadbeef", err) + } + out := buf.String() + for _, want := range []string{ + "shard 0x00000001 (", + "shard 0x00040001 (", + "shard 0xdeadbeef:", + "3 shard(s) inspected, 1 failed", + } { + if !strings.Contains(out, want) { + t.Errorf("inspect output missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "0x000000ff") { + t.Errorf("stray file inspected as a shard:\n%s", out) + } +} + +func TestInspectRejectsUnusableDatadir(t *testing.T) { + var buf bytes.Buffer + if err := inspectDataDir(&buf, t.TempDir()); err == nil || !strings.Contains(err.Error(), "no shard chaindbs") { + t.Errorf("empty datadir err = %v, want no-shard-chaindbs error", err) + } + if err := inspectDataDir(&buf, filepath.Join(t.TempDir(), "missing")); err == nil { + t.Error("missing datadir did not error") + } +} diff --git a/cmd/slave/inspectcmd.go b/cmd/slave/inspectcmd.go new file mode 100644 index 000000000000..d4a68b498313 --- /dev/null +++ b/cmd/slave/inspectcmd.go @@ -0,0 +1,119 @@ +// Copyright 2026-2027, QuarkChain. + +package main + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/ethdb/pebble" + "github.com/ethereum/go-ethereum/qkc/shard" + "github.com/urfave/cli/v2" +) + +var datadirFlag = &cli.StringFlag{ + Name: "datadir", + Usage: "Directory holding the per-shard chaindbs (the cluster config's DB_PATH_ROOT)", +} + +var inspectCommand = &cli.Command{ + Name: "inspect", + Usage: "Print the stored genesis metadata and head of every shard chaindb under a datadir", + ArgsUsage: " ", + Flags: []cli.Flag{ + datadirFlag, + }, + Description: `Read-only and config-free: scans --datadir for shard chaindb directories +(shard-0x{full_shard_id}/), opens each in read-only mode, and prints the stored +genesis metadata record and chain head. A shard that cannot be opened or read is +reported inline without aborting the remaining shards; the exit status is +non-zero if any shard failed. A running slave holds its chaindb locks, so +inspect a stopped node.`, + Action: runInspect, +} + +func runInspect(ctx *cli.Context) error { + datadir := ctx.String(datadirFlag.Name) + if datadir == "" { + return fmt.Errorf("--%s is required", datadirFlag.Name) + } + return inspectDataDir(os.Stdout, datadir) +} + +// inspectDataDir prints one block per shard chaindb found under datadir, in +// directory-name order. A shard that fails to open or read is reported inline +// and folded into the returned error; the other shards still print. +func inspectDataDir(out io.Writer, datadir string) error { + entries, err := os.ReadDir(datadir) + if err != nil { + return err + } + var ( + inspected int + errs []error + ) + for _, entry := range entries { + id, ok := shard.ParseDBDirName(entry.Name()) + if !ok || !entry.IsDir() { + continue + } + inspected++ + if err := inspectShardDB(out, filepath.Join(datadir, entry.Name()), id); err != nil { + fmt.Fprintf(out, "shard 0x%08x: %v\n", id, err) + errs = append(errs, fmt.Errorf("shard 0x%08x: %w", id, err)) + } + } + if inspected == 0 { + return fmt.Errorf("no shard chaindbs (shard-0x{full_shard_id}/) under %s", datadir) + } + fmt.Fprintf(out, "%d shard(s) inspected, %d failed\n", inspected, len(errs)) + return errors.Join(errs...) +} + +// inspectShardDB opens one shard chaindb read-only and prints its stored genesis +// metadata record and chain head. An absent metadata record is not an error: it +// is the expected state of a chaindb whose bootstrap was interrupted before the +// record was committed (the next boot re-runs the fresh path). +// Modest fixed sizing for a short-lived read-only open. +const ( + inspectDBCacheMB = 16 + inspectDBHandles = 16 +) + +func inspectShardDB(out io.Writer, path string, id uint32) error { + kv, err := pebble.New(path, inspectDBCacheMB, inspectDBHandles, fmt.Sprintf("qkc/inspect/0x%08x/", id), true) + if err != nil { + return fmt.Errorf("open chaindb %s: %w", path, err) + } + defer kv.Close() + + meta, err := shard.ReadGenesisMeta(kv) + if err != nil { + return fmt.Errorf("read genesis metadata (db %s): %w", path, err) + } + fmt.Fprintf(out, "shard 0x%08x (%s):\n", id, path) + if meta == nil { + fmt.Fprintln(out, " genesis metadata: none (bootstrap never completed; next boot re-initializes)") + } else { + fmt.Fprintf(out, " meta version: %d\n", meta.Version) + fmt.Fprintf(out, " chain genesis: %s\n", meta.ChainGenesisHash) + fmt.Fprintf(out, " root genesis: %s\n", meta.RootGenesisHash) + fmt.Fprintf(out, " hash_prev_root_block: %s\n", meta.HashPrevRootBlock) + fmt.Fprintf(out, " xshard cursor: root=%d minor=%d deposit=%d\n", + meta.XShardCursor.RootBlockHeight, meta.XShardCursor.MinorBlockIndex, meta.XShardCursor.XShardDepositIndex) + } + if head := rawdb.ReadHeadBlockHash(kv); head != (common.Hash{}) { + fmt.Fprintf(out, " head block: %s\n", head) + } else { + fmt.Fprintln(out, " head block: none recorded (stub chain persists no head)") + } + if meta != nil && meta.FullShardID != id { + return fmt.Errorf("metadata records shard 0x%08x but the directory name says 0x%08x (db %s) — misplaced chaindb", meta.FullShardID, id, path) + } + return nil +} diff --git a/cmd/slave/main.go b/cmd/slave/main.go index 8fcc1df6a29b..5c35fe06bf25 100644 --- a/cmd/slave/main.go +++ b/cmd/slave/main.go @@ -85,6 +85,7 @@ func newApp() *cli.App { app.Commands = []*cli.Command{ configCommand, genesisCommand, + inspectCommand, } return app } diff --git a/cmd/slave/slave_test.go b/cmd/slave/slave_test.go index c07ba92a3826..6577094c4963 100644 --- a/cmd/slave/slave_test.go +++ b/cmd/slave/slave_test.go @@ -50,10 +50,9 @@ func TestConfigSummaryOutput(t *testing.T) { } } -// writeFixtureWithTempDBRoot copies a fixture with its DB_PATH_ROOT redirected -// into t.TempDir(), so booting from it never writes into the repo tree, and -// returns the rewritten config's path. -func writeFixtureWithTempDBRoot(t *testing.T, path string) string { +// writeFixtureWithDBRoot copies a fixture with its DB_PATH_ROOT redirected to +// dbRoot and returns the rewritten config's path. +func writeFixtureWithDBRoot(t *testing.T, path, dbRoot string) string { t.Helper() raw, err := os.ReadFile(path) if err != nil { @@ -63,7 +62,7 @@ func writeFixtureWithTempDBRoot(t *testing.T, path string) string { if err := json.Unmarshal(raw, &doc); err != nil { t.Fatalf("unmarshal fixture: %v", err) } - doc["DB_PATH_ROOT"], _ = json.Marshal(t.TempDir()) + doc["DB_PATH_ROOT"], _ = json.Marshal(dbRoot) rewritten, err := json.Marshal(doc) if err != nil { t.Fatalf("marshal fixture: %v", err) @@ -75,6 +74,13 @@ func writeFixtureWithTempDBRoot(t *testing.T, path string) string { return tmpPath } +// writeFixtureWithTempDBRoot is writeFixtureWithDBRoot into a fresh t.TempDir(), +// so booting from it never writes into the repo tree. +func writeFixtureWithTempDBRoot(t *testing.T, path string) string { + t.Helper() + return writeFixtureWithDBRoot(t, path, t.TempDir()) +} + func loadFixtureWithTempDBRoot(t *testing.T, path string) *config.ClusterConfig { t.Helper() cfg, err := config.LoadClusterConfig(writeFixtureWithTempDBRoot(t, path)) From 354a86491df7df4306281953f1e3a667bc55b09d Mon Sep 17 00:00:00 2001 From: syntrust Date: Wed, 15 Jul 2026 17:31:06 +0800 Subject: [PATCH 3/8] cmd/slave, qkc/slave: verify goroutine hygiene and loud genesis mismatch goleak now wraps both boot/shutdown smoke-test packages. The ignore list is empty on purpose: with metrics disabled neither geth nor pebble leaves a background goroutine behind after Stop(), and keeping it empty means the real chain's background work is heard here the day it lands. A new subprocess test initializes a datadir from the mainnet config and reruns the slave against it with the devnet config, asserting the run exits 1 and names the stored genesis, the config-derived genesis, the db path, and 'cluster config changed since initialization'. Co-Authored-By: Claude Fable 5 --- cmd/slave/run_test.go | 40 ++++++++++++++++++++++++++++++++++++++-- qkc/slave/main_test.go | 13 +++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 qkc/slave/main_test.go diff --git a/cmd/slave/run_test.go b/cmd/slave/run_test.go index c77f61b7d21e..98c3abb4f3f0 100644 --- a/cmd/slave/run_test.go +++ b/cmd/slave/run_test.go @@ -4,8 +4,10 @@ package main import ( "bufio" - "os" + "context" + "errors" "os/exec" + "path/filepath" "runtime" "strings" "syscall" @@ -14,6 +16,7 @@ import ( "github.com/ethereum/go-ethereum/internal/reexec" "github.com/ethereum/go-ethereum/qkc/config" + "go.uber.org/goleak" ) func TestMain(m *testing.M) { @@ -21,7 +24,10 @@ func TestMain(m *testing.M) { if reexec.Init() { return } - os.Exit(m.Run()) + // No ignore list: with metrics disabled (no --metrics flag registered at + // all) neither geth nor pebble leaves a background goroutine behind after + // Stop(), and this stays pinned so the real chain's arrival is heard here. + goleak.VerifyTestMain(m) } // TestRunHonorsSignalDuringStartup sends SIGTERM as soon as the run action @@ -84,3 +90,33 @@ func TestRunHonorsSignalDuringStartup(t *testing.T) { t.Fatalf("Stop: %v", err) } } + +// TestRunGenesisMismatchExitsLoudly initializes a datadir from the mainnet +// config, then reruns the slave against the same datadir with the devnet +// config: the run must exit 1 and say which genesis is stored, which one the +// config derives, and which db holds the stored one. +func TestRunGenesisMismatchExitsLoudly(t *testing.T) { + dbRoot := t.TempDir() + initDataDir(t, fixtures[0].path, dbRoot) + devnetCfg := writeFixtureWithDBRoot(t, fixtures[1].path, dbRoot) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, reexec.Self(), "--cluster_config", devnetCfg, "--node_id", "S0") + cmd.Args[0] = "slave-test" + out, err := cmd.CombinedOutput() + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != 1 { + t.Fatalf("slave exited with %v, want exit 1; output:\n%s", err, out) + } + for _, want := range []string{ + "stored genesis 0x", + "does not match config genesis 0x", + "cluster config changed since initialization", + filepath.Join(dbRoot, "shard-0x00000001"), + } { + if !strings.Contains(string(out), want) { + t.Errorf("mismatch report missing %q:\n%s", want, out) + } + } +} diff --git a/qkc/slave/main_test.go b/qkc/slave/main_test.go new file mode 100644 index 000000000000..db83c41941d3 --- /dev/null +++ b/qkc/slave/main_test.go @@ -0,0 +1,13 @@ +// Copyright 2026-2027, QuarkChain. + +package slave + +import ( + "testing" + + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} From 6957b22fae0b8b60674f23aa3ed5cd363cb1f812 Mon Sep 17 00:00:00 2001 From: syntrust Date: Wed, 15 Jul 2026 17:58:38 +0800 Subject: [PATCH 4/8] cmd/slave, qkc/config: note the virtualenv in the cross-validation command Running the pyquarkchain cross-validation snippet with a bare system python fails at import time (no aiohttp); it needs a virtualenv with pyquarkchain's requirements installed. Both READMEs carrying the command now say so. Verified against both networks: the venv run reproduces the pinned mainnet 4036783e... and devnet 5ad443ef... hashes. Co-Authored-By: Claude Fable 5 --- cmd/slave/README.md | 3 ++- qkc/config/singularity/README.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/slave/README.md b/cmd/slave/README.md index c3de30bd9e3c..62d972dd1ae9 100644 --- a/cmd/slave/README.md +++ b/cmd/slave/README.md @@ -137,7 +137,8 @@ To cross-validate a `slave genesis` run against pyquarkchain, derive the same header there (swap the path for devnet) and compare with the `hash:` line: ``` -# from the root of a pyquarkchain checkout: +# from the root of a pyquarkchain checkout, inside a virtualenv with its +# requirements installed (bare system python lacks e.g. aiohttp): python -c " import json from quarkchain.cluster.cluster_config import ClusterConfig diff --git a/qkc/config/singularity/README.md b/qkc/config/singularity/README.md index e8524deff22c..7d6ff25bb9db 100644 --- a/qkc/config/singularity/README.md +++ b/qkc/config/singularity/README.md @@ -27,7 +27,8 @@ byte-identically to pyquarkchain's path for devnet): ``` -# from the root of a pyquarkchain checkout: +# from the root of a pyquarkchain checkout, inside a virtualenv with its +# requirements installed (bare system python lacks e.g. aiohttp): python -c " import json from quarkchain.cluster.cluster_config import ClusterConfig From 01ef951efa0e1815ca69fca201e4da51e23dd8f9 Mon Sep 17 00:00:00 2001 From: syntrust Date: Wed, 15 Jul 2026 19:36:18 +0800 Subject: [PATCH 5/8] cmd/slave, qkc/shard, qkc/slave: document follow-up integration work --- cmd/slave/README.md | 30 +++++++++++++++++++++++++++--- cmd/slave/inspect_test.go | 2 ++ cmd/slave/inspectcmd.go | 4 ++++ cmd/slave/run.go | 2 ++ cmd/slave/run_test.go | 5 ++++- qkc/shard/genesis.go | 2 ++ qkc/shard/genesis_test.go | 2 ++ qkc/shard/rawdb_test.go | 3 +++ qkc/shard/services.go | 2 ++ qkc/shard/shard.go | 2 ++ qkc/shard/shard_test.go | 8 ++++---- qkc/slave/backend.go | 2 ++ qkc/slave/backend_test.go | 8 ++++---- qkc/slave/main_test.go | 2 ++ 14 files changed, 62 insertions(+), 12 deletions(-) diff --git a/cmd/slave/README.md b/cmd/slave/README.md index 62d972dd1ae9..59a145cf1123 100644 --- a/cmd/slave/README.md +++ b/cmd/slave/README.md @@ -14,7 +14,7 @@ make slave This installs the binary to `./build/bin/slave` (the same convention as `geth`). The commands below are run from the repo root so the relative config paths resolve. -## Running a slave (milestone M3) +## Running a slave The default action boots every shard assigned to `--node_id` and runs until interrupted — a drop-in for how pyquarkchain's `cluster.py` starts a slave: @@ -38,7 +38,7 @@ Only geth's logging and file-based profiling debug flags are exposed. The debug flags that would open a socket — the `--pprof` HTTP server and `--pyroscope.*` push — are deliberately not registered, keeping the process free of network I/O. -## Inspecting a datadir (milestone M4) +## Inspecting a datadir `slave inspect` is read-only and needs no config: it scans `--datadir` for shard chaindb directories (`shard-0x{full_shard_id}/`), opens each in read-only mode, @@ -69,7 +69,7 @@ A chaindb whose bootstrap was interrupted before the metadata record committed prints `genesis metadata: none (bootstrap never completed; next boot re-initializes)` — the next `slave` run re-runs the fresh initialization path. -## Subcommands (milestone M1) +## Subcommands ### `slave config` @@ -152,3 +152,27 @@ print('hash', h.get_hash().hex()) The shard-level `chain genesis` printed by `slave inspect` is the config descriptor's fingerprint, not a pyquarkchain minor-block hash; it becomes the real shard genesis block hash when the QKC block format (#1) lands. + +## Follow-up integration checklist + +The slave currently provides the process, per-shard database ownership, and +lifecycle around a stub chain. The following replacement points are deliberate: + +- **Real shard chain:** when the `qkc/core` shard chain, QKC block format (#1), + and genesis state materialization are ready, inject its `ChainService` from + `cmd/slave` instead of relying on `StubChainService`. Adapt `GenesisHash`, + `Head`, and `Stop`; `Stop` must wait for every chain-owned goroutine before the + shard database closes. +- **Genesis persistence and inspection:** at the same integration point, delete + the temporary `GenesisMeta`, descriptor `Fingerprint`, and metadata + reconciliation path rather than migrating them. Re-bootstrap the genesis-only + databases, make the real chain reject both genesis and chain-rule changes, and + update `slave inspect` to read the canonical QKC minor genesis/head through + `qkc/core/rawdb`, including the branch, previous root block, and x-shard cursor. +- **Integration tests:** switch the boot/reopen, inspect, mismatch, and goleak + coverage to the real chain. Keep the goleak allowlist empty for slave-owned + goroutines; fix their `Stop` path instead of ignoring them. +- **Master-driven creation:** when the cluster protocol (#5) lands, replace eager + shard creation with the pyquarkchain-compatible `PING(root_tip)` trigger while + preserving partial-boot rollback, idempotent blocking shutdown, and the + `DBDirName` datadir convention. diff --git a/cmd/slave/inspect_test.go b/cmd/slave/inspect_test.go index fad63d204964..9bec56dd39b0 100644 --- a/cmd/slave/inspect_test.go +++ b/cmd/slave/inspect_test.go @@ -29,6 +29,8 @@ func initDataDir(t *testing.T, fixturePath, dbRoot string) { } } +// TODO(#1): initialize the real QKC shard chain and assert its canonical minor +// genesis/head once inspectShardDB stops reading the temporary GenesisMeta. func TestInspectDataDir(t *testing.T) { dbRoot := t.TempDir() initDataDir(t, fixtures[0].path, dbRoot) diff --git a/cmd/slave/inspectcmd.go b/cmd/slave/inspectcmd.go index d4a68b498313..44e177eedaf2 100644 --- a/cmd/slave/inspectcmd.go +++ b/cmd/slave/inspectcmd.go @@ -92,6 +92,10 @@ func inspectShardDB(out io.Writer, path string, id uint32) error { } defer kv.Close() + // TODO(#1): when the real QKC shard chain lands, replace GenesisMeta and + // geth rawdb reads with qkc/core/rawdb's canonical minor genesis and head. + // Read branch/prev-root/x-shard fields from the real block and update the + // stub-specific output and tests with it. meta, err := shard.ReadGenesisMeta(kv) if err != nil { return fmt.Errorf("read genesis metadata (db %s): %w", path, err) diff --git a/cmd/slave/run.go b/cmd/slave/run.go index a18a197da8dd..7793ff03922d 100644 --- a/cmd/slave/run.go +++ b/cmd/slave/run.go @@ -64,5 +64,7 @@ func bootSlave(cfg *config.ClusterConfig, nodeID string) (*slave.SlaveBackend, e if err != nil { return nil, err } + // TODO(real shard chain): inject the qkc/core ChainService here once QKC + // block genesis and state materialization are ready; Options{} uses the stub. return slave.New(slaveCtx, root, shard.Options{}) } diff --git a/cmd/slave/run_test.go b/cmd/slave/run_test.go index 98c3abb4f3f0..09ba96eeadbe 100644 --- a/cmd/slave/run_test.go +++ b/cmd/slave/run_test.go @@ -26,7 +26,8 @@ func TestMain(m *testing.M) { } // No ignore list: with metrics disabled (no --metrics flag registered at // all) neither geth nor pebble leaves a background goroutine behind after - // Stop(), and this stays pinned so the real chain's arrival is heard here. + // Stop(). When bootSlave wires the real chain, fix its Stop path instead of + // allowlisting slave-owned goroutines here. goleak.VerifyTestMain(m) } @@ -91,6 +92,8 @@ func TestRunHonorsSignalDuringStartup(t *testing.T) { } } +// TODO(real shard chain): retain this binary-level contract with a real QKC +// block 0, and add a case where chain rules change without changing block 0. // TestRunGenesisMismatchExitsLoudly initializes a datadir from the mainnet // config, then reruns the slave against the same datadir with the devnet // config: the run must exit 1 and say which genesis is stored, which one the diff --git a/qkc/shard/genesis.go b/qkc/shard/genesis.go index 09b0b36134da..f36e49db3341 100644 --- a/qkc/shard/genesis.go +++ b/qkc/shard/genesis.go @@ -77,6 +77,8 @@ func petersburgChainConfig(ethChainID uint64) *params.ChainConfig { } } +// TODO(#1): remove this surrogate when the real chain commits the QKC minor +// genesis; preserve its chain-rule compatibility check in the native setup path. // Fingerprint returns a deterministic identity hash of the descriptor. It is what // the stub chain reports as its genesis hash and what the genesis metadata records // as ChainGenesisHash, so a config change is caught on reopen. It is not diff --git a/qkc/shard/genesis_test.go b/qkc/shard/genesis_test.go index ce4da423c24e..10f2295cbdb6 100644 --- a/qkc/shard/genesis_test.go +++ b/qkc/shard/genesis_test.go @@ -109,6 +109,8 @@ func TestNewGenesisEthChainIDNoOverflow(t *testing.T) { } } +// TODO(#1): replace this surrogate-identity coverage with the real minor genesis +// hash plus a separate native chain-rule compatibility test. // TestGenesisFingerprint: the fingerprint is deterministic and sensitive to every // descriptor field Reconcile must catch a change in. func TestGenesisFingerprint(t *testing.T) { diff --git a/qkc/shard/rawdb_test.go b/qkc/shard/rawdb_test.go index 10af00202d51..e9a5980ca94e 100644 --- a/qkc/shard/rawdb_test.go +++ b/qkc/shard/rawdb_test.go @@ -10,6 +10,9 @@ import ( "github.com/ethereum/go-ethereum/core/rawdb" ) +// TODO(#1): delete these GenesisMeta tests with the temporary record and move +// reopen/mismatch coverage to the real QKC genesis setup path. + func testMeta() *GenesisMeta { return &GenesisMeta{ Version: genesisMetaVersion, diff --git a/qkc/shard/services.go b/qkc/shard/services.go index f8dc62889e98..b80801349158 100644 --- a/qkc/shard/services.go +++ b/qkc/shard/services.go @@ -49,6 +49,8 @@ func (o Options) chainService() ChainService { if o.Chain != nil { return o.Chain } + // TODO(real shard chain): production wiring must inject the qkc/core service + // once it can commit the real QKC genesis; keep this stub only as a test seam. return StubChainService{} } diff --git a/qkc/shard/shard.go b/qkc/shard/shard.go index d4a80a8d47f4..e0bf985c0ff1 100644 --- a/qkc/shard/shard.go +++ b/qkc/shard/shard.go @@ -93,6 +93,8 @@ func New(ctx *config.SlaveContext, branch account.Branch, rootGenesis *types.Roo } rootHash := rootGenesis.Hash() + // TODO(#1): delete this metadata reconciliation/write path when the real + // chain owns canonical QKC genesis and chain-config compatibility checks. expected := &GenesisMeta{ Version: genesisMetaVersion, FullShardID: fullShardID, diff --git a/qkc/shard/shard_test.go b/qkc/shard/shard_test.go index dcab15dc1408..2ea30dcf80ec 100644 --- a/qkc/shard/shard_test.go +++ b/qkc/shard/shard_test.go @@ -35,10 +35,10 @@ func bootEnv(t *testing.T, path string) (*config.SlaveContext, *types.RootBlockH return ctx, root } -// TestShardNewAndReopen is the milestone demo: construct a single shard from each -// real network config into t.TempDir(), assert the stub chain reports head height -// 0 at the genesis descriptor's identity and the metadata record is stored, then -// stop and reopen from the same directory — Reconcile passes. +// TODO(#1): replace the stub fingerprint and GenesisMeta assertions with the +// real QKC minor genesis/head and native reopen compatibility checks. +// TestShardNewAndReopen constructs a single shard from each real network config, +// stops it, and verifies that the same database reopens cleanly. func TestShardNewAndReopen(t *testing.T) { for _, path := range []string{fixtureMainnet, fixtureDevnet} { t.Run(filepath.Base(path), func(t *testing.T) { diff --git a/qkc/slave/backend.go b/qkc/slave/backend.go index dc83b31257a6..fdcc221e4f32 100644 --- a/qkc/slave/backend.go +++ b/qkc/slave/backend.go @@ -33,6 +33,8 @@ type SlaveBackend struct { // Eager construction is interim scaffolding: with no master in the cluster yet it // is the only way to bring the shards up; the master's PING(root_tip) trigger // (#5) replaces it (pyquarkchain slave.py:927). +// TODO(#5): move shard creation behind PING(root_tip), preserving rollback and +// blocking shutdown for dynamically created shards. // // On any shard failure the shards already started are stopped and their databases // closed before the error returns, so the datadir stays reopenable. diff --git a/qkc/slave/backend_test.go b/qkc/slave/backend_test.go index f7f1e6f98c58..516d171255c0 100644 --- a/qkc/slave/backend_test.go +++ b/qkc/slave/backend_test.go @@ -41,10 +41,10 @@ func bootEnv(t *testing.T, path string) (*config.SlaveContext, *types.RootBlockH return ctx, root } -// TestSlaveBootAndReopen is the milestone demo: boot S0's shards from each real -// network config, assert the registry matches the config assignment with every -// shard at head height 0, stop, and boot again from the same datadir — the -// reopen reconciles against the stored genesis metadata and passes. +// TODO(real shard chain): inject the qkc/core service here and assert its +// canonical genesis/head plus blocking shutdown of its background work. +// TestSlaveBootAndReopen boots S0 from each real network config, checks its shard +// registry, stops it, and verifies that the same databases reopen cleanly. func TestSlaveBootAndReopen(t *testing.T) { for _, path := range []string{fixtureMainnet, fixtureDevnet} { t.Run(filepath.Base(path), func(t *testing.T) { diff --git a/qkc/slave/main_test.go b/qkc/slave/main_test.go index db83c41941d3..8b86b9935476 100644 --- a/qkc/slave/main_test.go +++ b/qkc/slave/main_test.go @@ -9,5 +9,7 @@ import ( ) func TestMain(m *testing.M) { + // When the real chain is injected into these smoke tests, keep the allowlist + // empty for slave-owned goroutines and fix their blocking Stop path instead. goleak.VerifyTestMain(m) } From 5f92ac0abbed76630ebd85b82f9d213b8456cf75 Mon Sep 17 00:00:00 2001 From: syntrust Date: Thu, 16 Jul 2026 19:01:29 +0800 Subject: [PATCH 6/8] cmd/slave: follow the GenesisRecord rename in inspect The inspect labels move with it (meta version -> record version, genesis metadata -> genesis record), keeping the value column aligned. Co-Authored-By: Claude Fable 5 --- cmd/slave/README.md | 10 +++++----- cmd/slave/inspect_test.go | 4 ++-- cmd/slave/inspectcmd.go | 34 +++++++++++++++++----------------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/cmd/slave/README.md b/cmd/slave/README.md index d63fea15deb0..233dbc1b19af 100644 --- a/cmd/slave/README.md +++ b/cmd/slave/README.md @@ -42,7 +42,7 @@ push — are deliberately not registered, keeping the process free of network I/ `slave inspect` is read-only and needs no config: it scans `--datadir` for shard chaindb directories (`shard-0x{full_shard_id}/`), opens each in read-only mode, -and prints the stored genesis metadata record and chain head. A shard that +and prints the stored genesis record and chain head. A shard that cannot be opened or read is reported without aborting the others, and the exit status is non-zero if any shard failed. A running slave holds its chaindb locks (each shard then reports `resource temporarily unavailable`), so inspect a @@ -54,7 +54,7 @@ stopped node. The report goes to stdout; log lines go to stderr. ``` shard 0x00000001 (qkc-data/devnet/shard-0x00000001): - meta version: 1 + record version: 1 chain genesis: 0xea741742184975635c2eb1ba468e7b7f58156025517eee3d7583f4ca0ad2dbca root genesis: 0x5ad443efb7cf5246a3d1bbc1734bd02bf3a5d83bedeccfcfe707d0ebee03780d hash_prev_root_block: 0x5ad443efb7cf5246a3d1bbc1734bd02bf3a5d83bedeccfcfe707d0ebee03780d @@ -65,8 +65,8 @@ shard 0x00040001 (qkc-data/devnet/shard-0x00040001): 2 shard(s) inspected, 0 failed ``` -A chaindb whose bootstrap was interrupted before the metadata record committed -prints `genesis metadata: none (bootstrap never completed; next boot +A chaindb whose bootstrap was interrupted before the genesis record committed +prints `genesis record: none (bootstrap never completed; next boot re-initializes)` — the next `slave` run re-runs the fresh initialization path. ## Subcommands @@ -153,7 +153,7 @@ lifecycle around a stub chain. The following replacement points are deliberate: `Head`, and `Stop`; `Stop` must wait for every chain-owned goroutine before the shard database closes. - **Genesis persistence and inspection:** at the same integration point, delete - the temporary `GenesisMeta`, descriptor `Fingerprint`, and metadata + the temporary `GenesisRecord`, descriptor `Fingerprint`, and record reconciliation path rather than migrating them. Re-bootstrap the genesis-only databases, make the real chain reject both genesis and chain-rule changes, and update `slave inspect` to read the canonical QKC minor genesis/head through diff --git a/cmd/slave/inspect_test.go b/cmd/slave/inspect_test.go index fea1578a4709..02aba14a12d6 100644 --- a/cmd/slave/inspect_test.go +++ b/cmd/slave/inspect_test.go @@ -67,7 +67,7 @@ func TestRunGenesisMismatchExitsLoudly(t *testing.T) { } // TODO(#1): initialize the real QKC shard chain and assert its canonical minor -// genesis/head once inspectShardDB stops reading the temporary GenesisMeta. +// genesis/head once inspectShardDB stops reading the temporary GenesisRecord. func TestInspectDataDir(t *testing.T) { dbRoot := t.TempDir() initDataDir(t, fixtures[0].path, dbRoot) @@ -80,7 +80,7 @@ func TestInspectDataDir(t *testing.T) { for _, want := range []string{ "shard 0x00000001 (", "shard 0x00040001 (", - "meta version: 1", + "record version: 1", "chain genesis: 0x", "root genesis: 0x4036783e441eb5057bf2be96bf1fd4585ac49824de15c0d92a4c14a97886ca51", "xshard cursor: root=0 minor=0 deposit=0", diff --git a/cmd/slave/inspectcmd.go b/cmd/slave/inspectcmd.go index 44e177eedaf2..af539653e7c6 100644 --- a/cmd/slave/inspectcmd.go +++ b/cmd/slave/inspectcmd.go @@ -23,14 +23,14 @@ var datadirFlag = &cli.StringFlag{ var inspectCommand = &cli.Command{ Name: "inspect", - Usage: "Print the stored genesis metadata and head of every shard chaindb under a datadir", + Usage: "Print the stored genesis record and head of every shard chaindb under a datadir", ArgsUsage: " ", Flags: []cli.Flag{ datadirFlag, }, Description: `Read-only and config-free: scans --datadir for shard chaindb directories (shard-0x{full_shard_id}/), opens each in read-only mode, and prints the stored -genesis metadata record and chain head. A shard that cannot be opened or read is +genesis record and chain head. A shard that cannot be opened or read is reported inline without aborting the remaining shards; the exit status is non-zero if any shard failed. A running slave holds its chaindb locks, so inspect a stopped node.`, @@ -76,9 +76,9 @@ func inspectDataDir(out io.Writer, datadir string) error { } // inspectShardDB opens one shard chaindb read-only and prints its stored genesis -// metadata record and chain head. An absent metadata record is not an error: it -// is the expected state of a chaindb whose bootstrap was interrupted before the -// record was committed (the next boot re-runs the fresh path). +// record and chain head. An absent record is not an error: it is the expected +// state of a chaindb whose bootstrap was interrupted before the record was +// committed (the next boot re-runs the fresh path). // Modest fixed sizing for a short-lived read-only open. const ( inspectDBCacheMB = 16 @@ -92,32 +92,32 @@ func inspectShardDB(out io.Writer, path string, id uint32) error { } defer kv.Close() - // TODO(#1): when the real QKC shard chain lands, replace GenesisMeta and + // TODO(#1): when the real QKC shard chain lands, replace GenesisRecord and // geth rawdb reads with qkc/core/rawdb's canonical minor genesis and head. // Read branch/prev-root/x-shard fields from the real block and update the // stub-specific output and tests with it. - meta, err := shard.ReadGenesisMeta(kv) + rec, err := shard.ReadGenesisRecord(kv) if err != nil { - return fmt.Errorf("read genesis metadata (db %s): %w", path, err) + return fmt.Errorf("read genesis record (db %s): %w", path, err) } fmt.Fprintf(out, "shard 0x%08x (%s):\n", id, path) - if meta == nil { - fmt.Fprintln(out, " genesis metadata: none (bootstrap never completed; next boot re-initializes)") + if rec == nil { + fmt.Fprintln(out, " genesis record: none (bootstrap never completed; next boot re-initializes)") } else { - fmt.Fprintf(out, " meta version: %d\n", meta.Version) - fmt.Fprintf(out, " chain genesis: %s\n", meta.ChainGenesisHash) - fmt.Fprintf(out, " root genesis: %s\n", meta.RootGenesisHash) - fmt.Fprintf(out, " hash_prev_root_block: %s\n", meta.HashPrevRootBlock) + fmt.Fprintf(out, " record version: %d\n", rec.Version) + fmt.Fprintf(out, " chain genesis: %s\n", rec.ChainGenesisHash) + fmt.Fprintf(out, " root genesis: %s\n", rec.RootGenesisHash) + fmt.Fprintf(out, " hash_prev_root_block: %s\n", rec.HashPrevRootBlock) fmt.Fprintf(out, " xshard cursor: root=%d minor=%d deposit=%d\n", - meta.XShardCursor.RootBlockHeight, meta.XShardCursor.MinorBlockIndex, meta.XShardCursor.XShardDepositIndex) + rec.XShardCursor.RootBlockHeight, rec.XShardCursor.MinorBlockIndex, rec.XShardCursor.XShardDepositIndex) } if head := rawdb.ReadHeadBlockHash(kv); head != (common.Hash{}) { fmt.Fprintf(out, " head block: %s\n", head) } else { fmt.Fprintln(out, " head block: none recorded (stub chain persists no head)") } - if meta != nil && meta.FullShardID != id { - return fmt.Errorf("metadata records shard 0x%08x but the directory name says 0x%08x (db %s) — misplaced chaindb", meta.FullShardID, id, path) + if rec != nil && rec.FullShardID != id { + return fmt.Errorf("genesis record names shard 0x%08x but the directory name says 0x%08x (db %s) — misplaced chaindb", rec.FullShardID, id, path) } return nil } From 69b5e91f616555bffbbf49dd4f7a061dbad56949 Mon Sep 17 00:00:00 2001 From: syntrust Date: Fri, 14 Aug 2026 16:02:18 +0800 Subject: [PATCH 7/8] cmd/slave: validate a shard's genesis before reporting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inspect deserialized the stored genesis block and printed its fields, checking only that the block's branch matched the directory. A minor block's hash is its header's hash alone, so that left the fields hanging off the header unchecked: a database whose meta had been replaced still reported the original, authentic-looking block hash next to the substituted state root, and the command exited 0 with "0 failed". The boot path catches this by comparing the stored encoding against the config-derived block (shard.ReconcileGenesisBlock), but inspect is config-free and has no such comparison to fall back on. Rehash the meta and check it against the hash_meta the header commits to, which is the check that closes the gap, and require block 0 to be block 0 with an empty body and both sub-structs present. None of these needs the cluster config. Move every check, including the misplaced-chaindb one, ahead of all printing, so a shard that fails is described by its error alone. Printing first and reporting after put a state root nobody should trust under a hash that looked genuine, and contradicted what the README already claimed. Stop reading "no genesis block" as an interrupted bootstrap unconditionally. The block is written last, once the chain stands, so its absence means an interrupted bootstrap only on a database holding nothing else; a head pointer with no genesis under it is unreachable for this lifecycle, and may not be a shard chaindb at all. Report it instead of claiming the next boot re-initializes it safely. Read the head hash through Has/Get rather than rawdb.ReadHeadBlockHash, which discards its Get error and answers a failed read with the zero hash — printed as "none recorded", a claim about a database that could not be read. Latch report write errors in a sticky writer and fail on them, so a report that never reached the reader is not summarized as a success. Co-Authored-By: Claude Opus 5 --- cmd/slave/README.md | 38 ++++++--- cmd/slave/inspect_test.go | 129 +++++++++++++++++++++++++++++++ cmd/slave/inspectcmd.go | 158 +++++++++++++++++++++++++++++++++----- 3 files changed, 294 insertions(+), 31 deletions(-) diff --git a/cmd/slave/README.md b/cmd/slave/README.md index acc1e109fa03..f3e6670def92 100644 --- a/cmd/slave/README.md +++ b/cmd/slave/README.md @@ -119,11 +119,11 @@ prints `hash: 0x5ad443efb7cf5246a3d1bbc1734bd02bf3a5d83bedeccfcfe707d0ebee03780d Read-only and config-free: scan `--datadir` for shard chaindb directories (`shard-0x{full_shard_id}/`), open each in read-only mode, and print the stored -minor genesis block and chain head. A shard that cannot be opened or read is -reported without aborting the others, and the exit status is non-zero if any -shard failed. A running slave holds its chaindb locks (each shard then reports -`resource temporarily unavailable`), so inspect a stopped node. The report goes -to stdout; log lines go to stderr. +minor genesis block and chain head. A shard that cannot be opened, read, or +validated is reported without aborting the others, and the exit status is +non-zero if any shard failed. A running slave holds its chaindb locks (each shard +then reports `resource temporarily unavailable`), so inspect a stopped node. The +report goes to stdout; log lines go to stderr. ``` ./build/bin/slave inspect --datadir ./qkc-data/devnet @@ -146,12 +146,28 @@ shard 0x00040001 (qkc-data/devnet/shard-0x00040001): 2 shard(s) inspected, 0 failed ``` -The stored block names its own shard through its branch, so a chaindb sitting in -another shard's directory is reported as a misplaced chaindb instead of being -presented as that shard's genesis. A chaindb whose bootstrap was interrupted -before the block committed prints `genesis block: none (bootstrap never -completed; next boot re-initializes)` — the next `slave` run re-runs the fresh -initialization path. +Nothing is printed for a shard until everything the report would assert has been +checked, so a database that fails is described by its error alone rather than +having its fields presented as that shard's genesis: + +- **The block must hold together.** A minor block's hash is its header's hash + alone, and without the cluster config there is no derived encoding to compare + against, so the meta is rehashed and checked against the `hash_meta` the header + commits to. Otherwise a database whose meta was replaced would report the + original, authentic-looking block hash next to a substituted state root. Block 0 + must also be block 0, with an empty body. +- **The block must belong here.** The stored block names its own shard through its + branch; a chaindb sitting in another shard's directory is reported as a misplaced + chaindb. +- **A missing block must be the only thing missing.** The genesis block is written + last, once the chain stands, so its absence is an interrupted bootstrap — + `genesis block: none (bootstrap never completed; next boot re-initializes)`, and + the next `slave` run re-runs the fresh path. A head pointer with no genesis under + it is not a state this lifecycle produces, and is reported rather than described + as safely re-initializable. + +A report that cannot be written fails the command instead of being summarized as a +success. ## Fixtures and pyquarkchain cross-validation diff --git a/cmd/slave/inspect_test.go b/cmd/slave/inspect_test.go index daf935450cc1..388633528d82 100644 --- a/cmd/slave/inspect_test.go +++ b/cmd/slave/inspect_test.go @@ -6,6 +6,7 @@ import ( "bytes" "context" "errors" + "io" "os" "os/exec" "path/filepath" @@ -13,8 +14,13 @@ import ( "testing" "time" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/ethdb/pebble" "github.com/ethereum/go-ethereum/internal/reexec" "github.com/ethereum/go-ethereum/qkc/config" + "github.com/ethereum/go-ethereum/qkc/shard" + "github.com/ethereum/go-ethereum/qkc/types" ) // initDataDir boots S0 from a fixture into dbRoot and stops it, leaving behind @@ -100,6 +106,129 @@ func TestInspectDataDir(t *testing.T) { } } +// rewriteGenesisBlock reopens an initialized shard chaindb writable and replaces its +// stored genesis block, standing in for a database that was corrupted underneath the +// slave. +func rewriteGenesisBlock(t *testing.T, dbPath string, mutate func(*types.MinorBlock)) { + t.Helper() + kv, err := pebble.New(dbPath, 16, 16, "qkc/test/", false) + if err != nil { + t.Fatalf("open %s: %v", dbPath, err) + } + defer kv.Close() + block, err := shard.ReadGenesisBlock(kv) + if err != nil || block == nil { + t.Fatalf("read genesis block: %v (block %v)", err, block) + } + mutate(block) + if err := shard.WriteGenesisBlock(kv, block); err != nil { + t.Fatalf("write genesis block: %v", err) + } +} + +// TestInspectRejectsInconsistentGenesis is the reason inspect validates before it +// prints. A minor block's hash is its header's hash alone, so a database whose meta +// was replaced still holds the original, authentic-looking block hash — and inspect, +// being config-free, has no config-derived encoding to compare against. Each mutation +// here leaves the header untouched, so only recomputing what the header commits to +// can catch it. +func TestInspectRejectsInconsistentGenesis(t *testing.T) { + for _, tc := range []struct { + name string + want string + mutate func(*types.MinorBlock) + }{ + { + name: "substituted state root", + want: "stored genesis meta hashes to", + mutate: func(b *types.MinorBlock) { b.Meta.Root = common.HexToHash("0xdeadbeef") }, + }, + { + name: "rewound xshard cursor", + want: "stored genesis meta hashes to", + mutate: func(b *types.MinorBlock) { b.Meta.XShardTxCursor.MinorBlockIndex = 7 }, + }, + { + name: "transactions in the genesis body", + want: "the genesis body is empty", + mutate: func(b *types.MinorBlock) { b.TrackingData = []byte{0x01} }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + dbRoot := t.TempDir() + initDataDir(t, fixtures[0].path, dbRoot) + dbPath := filepath.Join(dbRoot, "shard-0x00000001") + rewriteGenesisBlock(t, dbPath, tc.mutate) + + var buf bytes.Buffer + err := inspectDataDir(&buf, dbRoot) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("inspectDataDir err = %v, want %q", err, tc.want) + } + if !strings.Contains(err.Error(), "corrupt chaindb") { + t.Errorf("error does not name the cause: %v", err) + } + out := buf.String() + // No field of the mutated block may be presented as that shard's genesis: + // its report opens with the error line, not with the "shard 0x… (path):" + // header that introduces a field block. The healthy shard still prints. + if strings.Contains(out, "shard 0x00000001 ("+dbPath) { + t.Errorf("printed fields of a block that does not hold together:\n%s", out) + } + for _, want := range []string{ + "shard 0x00000001: ", + "shard 0x00040001 (", + "2 shard(s) inspected, 1 failed", + } { + if !strings.Contains(out, want) { + t.Errorf("inspect output missing %q:\n%s", want, out) + } + } + }) + } +} + +// TestInspectRejectsHeadWithoutGenesis plants a head pointer in a chaindb that has no +// genesis block. The slave writes the genesis block last, so a missing block means an +// interrupted bootstrap — but only on a database holding nothing else. Reporting this +// one as safely re-initializable would be wrong twice over: the state is unreachable +// for this lifecycle, and the directory may not be a shard chaindb at all. +func TestInspectRejectsHeadWithoutGenesis(t *testing.T) { + dbRoot := t.TempDir() + dbPath := filepath.Join(dbRoot, "shard-0x00000001") + kv, err := pebble.New(dbPath, 16, 16, "qkc/test/", false) + if err != nil { + t.Fatal(err) + } + rawdb.WriteHeadBlockHash(kv, common.HexToHash("0xabc")) + if err := kv.Close(); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + err = inspectDataDir(&buf, dbRoot) + if err == nil || !strings.Contains(err.Error(), "with no genesis block under it") { + t.Fatalf("inspectDataDir err = %v, want head-without-genesis failure", err) + } + if strings.Contains(buf.String(), "next boot re-initializes") { + t.Errorf("claimed a safe re-initialization for an impossible state:\n%s", buf.String()) + } +} + +// TestInspectReportsWriteFailure pins that a report which never reached the reader +// fails the command instead of being summarized as a success. +func TestInspectReportsWriteFailure(t *testing.T) { + dbRoot := t.TempDir() + initDataDir(t, fixtures[0].path, dbRoot) + if err := inspectDataDir(errWriter{}, dbRoot); !errors.Is(err, io.ErrClosedPipe) { + t.Errorf("inspectDataDir err = %v, want the write error", err) + } +} + +type errWriter struct{} + +func (errWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe } + // TestInspectReportsMisplacedChaindb renames an initialized shard's chaindb to // another shard's directory name. The stored block names its own shard through // its branch, so inspect must report the directory as misplaced rather than diff --git a/cmd/slave/inspectcmd.go b/cmd/slave/inspectcmd.go index 56c9dd31b026..752f4a21c250 100644 --- a/cmd/slave/inspectcmd.go +++ b/cmd/slave/inspectcmd.go @@ -12,7 +12,7 @@ import ( "strings" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/ethdb" "github.com/ethereum/go-ethereum/ethdb/pebble" qkcCommon "github.com/ethereum/go-ethereum/qkc/common" "github.com/ethereum/go-ethereum/qkc/serialize" @@ -35,10 +35,10 @@ var inspectCommand = &cli.Command{ }, Description: `Read-only and config-free: scans --datadir for shard chaindb directories (shard-0x{full_shard_id}/), opens each in read-only mode, and prints the stored -minor genesis block and chain head. A shard that cannot be opened or read is -reported inline without aborting the remaining shards; the exit status is -non-zero if any shard failed. A running slave holds its chaindb locks, so -inspect a stopped node.`, +minor genesis block and chain head once the block is shown to hold together. A +shard that cannot be opened, read, or validated is reported inline without +aborting the remaining shards; the exit status is non-zero if any shard failed. A +running slave holds its chaindb locks, so inspect a stopped node.`, Action: runInspect, } @@ -51,13 +51,14 @@ func runInspect(ctx *cli.Context) error { } // inspectDataDir prints one block per shard chaindb found under datadir, in -// directory-name order. A shard that fails to open or read is reported inline -// and folded into the returned error; the other shards still print. +// directory-name order. A shard that fails to open, read or validate is reported +// inline and folded into the returned error; the other shards still print. func inspectDataDir(out io.Writer, datadir string) error { entries, err := os.ReadDir(datadir) if err != nil { return err } + report := &reportWriter{w: out} var ( inspected int errs []error @@ -68,18 +69,45 @@ func inspectDataDir(out io.Writer, datadir string) error { continue } inspected++ - if err := inspectShardDB(out, filepath.Join(datadir, entry.Name()), id); err != nil { - fmt.Fprintf(out, "shard 0x%08x: %v\n", id, err) + if err := inspectShardDB(report, filepath.Join(datadir, entry.Name()), id); err != nil { + fmt.Fprintf(report, "shard 0x%08x: %v\n", id, err) errs = append(errs, fmt.Errorf("shard 0x%08x: %w", id, err)) } + // A report that did not reach the reader is not a report: stop rather than + // walk the remaining shards and summarize findings nobody can see. + if err := report.err; err != nil { + return fmt.Errorf("write report: %w", err) + } } if inspected == 0 { return fmt.Errorf("no shard chaindbs (shard-0x{full_shard_id}/) under %s", datadir) } - fmt.Fprintf(out, "%d shard(s) inspected, %d failed\n", inspected, len(errs)) + fmt.Fprintf(report, "%d shard(s) inspected, %d failed\n", inspected, len(errs)) + if err := report.err; err != nil { + return fmt.Errorf("write report: %w", err) + } return errors.Join(errs...) } +// reportWriter latches the first write error so the printers below stay free of +// error plumbing while a truncated report still fails the command. Nothing in +// this file writes to the caller's writer directly. +type reportWriter struct { + w io.Writer + err error +} + +func (r *reportWriter) Write(p []byte) (int, error) { + if r.err != nil { + return 0, r.err + } + n, err := r.w.Write(p) + if err != nil { + r.err = err + } + return n, err +} + // Modest fixed sizing for a short-lived read-only open. const ( inspectDBCacheMB = 16 @@ -87,9 +115,14 @@ const ( ) // inspectShardDB opens one shard chaindb read-only and prints its stored genesis -// block and chain head. An absent block is not an error: it is the expected state -// of a chaindb whose bootstrap was interrupted before the block was committed -// (the next boot re-runs the fresh path). +// block and chain head. An absent block is not an error on its own: it is the +// expected state of a chaindb whose bootstrap was interrupted before the block was +// committed (the next boot re-runs the fresh path). +// +// Nothing is printed until everything the report would assert has been checked, so +// a database that fails is described by its error alone. Printing a block's fields +// first and reporting the trouble after would put a state root nobody should trust +// under a block hash that looks authentic. func inspectShardDB(out io.Writer, path string, id uint32) error { kv, err := pebble.New(path, inspectDBCacheMB, inspectDBHandles, fmt.Sprintf("qkc/inspect/0x%08x/", id), true) if err != nil { @@ -104,27 +137,112 @@ func inspectShardDB(out io.Writer, path string, id uint32) error { if err != nil { return fmt.Errorf("read genesis block (db %s): %w", path, err) } + head, err := readHeadBlockHash(kv) + if err != nil { + return fmt.Errorf("read head block hash (db %s): %w", path, err) + } + + if block == nil { + // The slave writes the genesis block last, once the chain stands, so a missing + // block means an interrupted bootstrap — but only on a database that holds + // nothing else. A head pointer with no genesis under it is not a state this + // lifecycle can produce; it is a database that lost its genesis, or one that + // was never this shard's. + // + // TODO(#1): the real chain writes block 0 and its head pointer together, ahead + // of the scaffolding key, which makes this pairing a legitimate interrupted + // state — this check moves into the chain's own reopen path then. + if head != (common.Hash{}) { + return fmt.Errorf("head block %s is recorded with no genesis block under it (db %s) — not an interrupted bootstrap; corrupt or foreign chaindb", head, path) + } + } else { + if err := checkGenesisSelfConsistent(block); err != nil { + return fmt.Errorf("%w (db %s) — corrupt chaindb", err, path) + } + // The stored block names its own shard through its branch; a chaindb holding + // another shard's genesis is a misplaced directory, not a config change. + if storedID := block.Header.Branch.GetFullShardID(); storedID != id { + return fmt.Errorf("stored genesis belongs to shard 0x%08x but the directory name says 0x%08x (db %s) — misplaced chaindb", storedID, id, path) + } + } + fmt.Fprintf(out, "shard 0x%08x (%s):\n", id, path) if block == nil { fmt.Fprintln(out, " genesis block: none (bootstrap never completed; next boot re-initializes)") } else { printGenesisBlock(out, block) } - if head := rawdb.ReadHeadBlockHash(kv); head != (common.Hash{}) { + if head != (common.Hash{}) { fmt.Fprintf(out, " head block: %s\n", head) } else { fmt.Fprintln(out, " head block: none recorded (stub chain persists no head)") } - // The stored block names its own shard through its branch; a chaindb holding - // another shard's genesis is a misplaced directory, not a config change. - if block != nil { - if storedID := block.Header.Branch.GetFullShardID(); storedID != id { - return fmt.Errorf("stored genesis belongs to shard 0x%08x but the directory name says 0x%08x (db %s) — misplaced chaindb", storedID, id, path) - } + return nil +} + +// checkGenesisSelfConsistent verifies what a reader without the cluster config can: +// that the stored block holds together. Whether it is the genesis a given config +// derives is a different question, and the boot path answers it by comparing the +// stored encoding (shard.ReconcileGenesisBlock); these invariants hold for every QKC +// minor genesis block regardless of config. +// +// The meta hash is the check that earns its place. A minor block's hash is its +// header's hash alone, so it says nothing about the meta hanging off that header: a +// database whose meta was replaced still reports the original block hash next to the +// substituted state root, and inspect has no config-derived encoding to compare +// against. Recomputing the meta's hash is what closes that gap. +func checkGenesisSelfConsistent(block *types.MinorBlock) error { + // Every field read below hangs off one of these two. A decode that succeeded + // should have allocated both, but this is the one place that answers to bytes + // nobody derived, so it does not assume it. + if block.Header == nil || block.Meta == nil { + return errors.New("stored genesis has no header or no meta") + } + if block.Header.Number != 0 { + return fmt.Errorf("stored genesis is block %d, not block 0", block.Header.Number) + } + if got, want := block.Meta.Hash(), block.Header.MetaHash; got != want { + return fmt.Errorf("stored genesis meta hashes to %s but its header commits to %s", got, want) + } + // The meta's tx merkle root comes from GENESIS.HASH_MERKLE_ROOT rather than from + // the body, so it cannot be recomputed here; that the body is empty is the part + // this can check, and pyquarkchain seals no transactions into block 0. + if len(block.Transactions) != 0 || len(block.TrackingData) != 0 { + return fmt.Errorf("stored genesis carries %d transaction(s) and %d tracking byte(s), but the genesis body is empty", + len(block.Transactions), len(block.TrackingData)) } return nil } +// headBlockKey mirrors geth's unexported rawdb.headBlockKey (core/rawdb/schema.go). +// +// rawdb.ReadHeadBlockHash discards the error from its Get and answers a failed read +// with the zero hash, which inspect would print as "none recorded" — a claim about a +// database it could not actually read. A short value would likewise be padded into a +// plausible-looking hash. This key is part of the on-disk schema, so reproducing it +// is stable in the way that accessor's error handling is not. +var headBlockKey = []byte("LastBlock") + +// readHeadBlockHash returns the recorded head block hash, the zero hash if none is +// recorded, and an error if the database cannot answer. +func readHeadBlockHash(db ethdb.KeyValueReader) (common.Hash, error) { + has, err := db.Has(headBlockKey) + if err != nil { + return common.Hash{}, err + } + if !has { + return common.Hash{}, nil + } + data, err := db.Get(headBlockKey) + if err != nil { + return common.Hash{}, err + } + if len(data) != common.HashLength { + return common.Hash{}, fmt.Errorf("head block hash is %d bytes, want %d", len(data), common.HashLength) + } + return common.BytesToHash(data), nil +} + // printGenesisBlock prints the identity a reopened datadir is reconciled against: // the block hash, the state root its meta commits to, and the root-chain linkage // and cross-shard cursor the block was derived from. From 0ab5121fc9074886e9753a3e38cae3a4a8e7f825 Mon Sep 17 00:00:00 2001 From: syntrust Date: Fri, 14 Aug 2026 17:01:25 +0800 Subject: [PATCH 8/8] cmd/slave: report the shard's stored rule set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An initialized shard chaindb holds three things: the genesis block, the state it commits to, and the EVM rule set. inspect reported the first two and said nothing about the third, although it is the other half of what a reopen is checked against — a shard can stand on exactly the right genesis and still refuse to boot on an incompatible fork schedule, and nothing showed what schedule was stored. Print the chain id and the fork schedule alongside the block. The schedule is rendered from the stored encoding rather than a fixed list of fork fields, so a rule set carrying a fork this build does not know about is shown rather than silently dropped, and it is ordered by activation, which is how a schedule reads. A datadir initialized before its rule set was written reports that instead of looking complete. It is not a failure: ReconcileChainConfig treats a missing rule set as recoverable and answers it by warning and writing one, and inspect follows that judgement rather than inventing a stricter one. Read the key directly instead of through rawdb.ReadChainConfig, which answers a failed read and a malformed encoding alike with a nil config — indistinguishable here from a rule set that was never stored, the same way ReadHeadBlockHash cannot tell an unreadable head from an absent one. Co-Authored-By: Claude Opus 5 --- cmd/slave/README.md | 9 ++++ cmd/slave/inspect_test.go | 47 ++++++++++++++++++ cmd/slave/inspectcmd.go | 102 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+) diff --git a/cmd/slave/README.md b/cmd/slave/README.md index f3e6670def92..4779305468ac 100644 --- a/cmd/slave/README.md +++ b/cmd/slave/README.md @@ -140,6 +140,8 @@ shard 0x00000001 (qkc-data/devnet/shard-0x00000001): evm_xshard_gas_limit: 6000000 hash_prev_root_block: 0x5ad443efb7cf5246a3d1bbc1734bd02bf3a5d83bedeccfcfe707d0ebee03780d xshard cursor: root=0 minor=0 deposit=0 + chain id: 110001 + fork schedule: byzantium=0 constantinople=0 eip150=0 eip155=0 eip158=0 homestead=0 petersburg=0 head block: none recorded (stub chain persists no head) shard 0x00040001 (qkc-data/devnet/shard-0x00040001): ... @@ -166,6 +168,13 @@ having its fields presented as that shard's genesis: it is not a state this lifecycle produces, and is reported rather than described as safely re-initializable. +The EVM rule set is stored apart from the genesis block, keyed by its hash, and is the +other half of what a reopen is checked against — so it is reported too: the shard's +chain id (`BASE_ETH_CHAIN_ID + CHAIN_ID + 1`) and its fork schedule, which for every +QuarkChain shard sits entirely at block 0. A datadir initialized before the rule set was +written prints `rule set: none stored`; that one is recoverable, and the next `slave` +run warns and writes it rather than refusing to boot. + A report that cannot be written fails the command instead of being summarized as a success. diff --git a/cmd/slave/inspect_test.go b/cmd/slave/inspect_test.go index 388633528d82..f6940e5335f3 100644 --- a/cmd/slave/inspect_test.go +++ b/cmd/slave/inspect_test.go @@ -97,6 +97,10 @@ func TestInspectDataDir(t *testing.T) { "evm_xshard_gas_limit: 6000000", "hash_prev_root_block: 0x4036783e441eb5057bf2be96bf1fd4585ac49824de15c0d92a4c14a97886ca51", "xshard cursor: root=0 minor=0 deposit=0", + // The rule set is stored apart from the block: BASE_ETH_CHAIN_ID + chain 0 + 1, + // and the Petersburg-only schedule every QKC shard fork sits at block 0 of. + "chain id: 100001", + "fork schedule: byzantium=0 constantinople=0 eip150=0 eip155=0 eip158=0 homestead=0 petersburg=0", "head block: none recorded", "2 shard(s) inspected, 0 failed", } { @@ -126,6 +130,49 @@ func rewriteGenesisBlock(t *testing.T, dbPath string, mutate func(*types.MinorBl } } +// dropChainConfig deletes the rule set an initialized shard chaindb stores under its +// genesis hash, standing in for a datadir initialized before the rule set was written. +func dropChainConfig(t *testing.T, dbPath string) { + t.Helper() + kv, err := pebble.New(dbPath, 16, 16, "qkc/test/", false) + if err != nil { + t.Fatalf("open %s: %v", dbPath, err) + } + defer kv.Close() + block, err := shard.ReadGenesisBlock(kv) + if err != nil || block == nil { + t.Fatalf("read genesis block: %v (block %v)", err, block) + } + if err := kv.Delete(append(bytes.Clone(configPrefix), block.Hash().Bytes()...)); err != nil { + t.Fatalf("delete chain config: %v", err) + } +} + +// TestInspectReportsMissingRuleSet covers the one anomaly the boot path treats as +// recoverable: a genesis that was stored before its rule set was. The slave answers it +// by warning and writing one, so inspect reports it rather than failing — but it does +// report it, instead of leaving the shard looking complete. +func TestInspectReportsMissingRuleSet(t *testing.T) { + dbRoot := t.TempDir() + initDataDir(t, fixtures[0].path, dbRoot) + dropChainConfig(t, filepath.Join(dbRoot, "shard-0x00000001")) + + var buf bytes.Buffer + if err := inspectDataDir(&buf, dbRoot); err != nil { + t.Fatalf("inspectDataDir: %v\noutput:\n%s", err, buf.String()) + } + out := buf.String() + for _, want := range []string{ + "rule set: none stored", + "chain id: 100005", // shard 0x00040001, untouched, still reports its rule set + "2 shard(s) inspected, 0 failed", + } { + if !strings.Contains(out, want) { + t.Errorf("inspect output missing %q:\n%s", want, out) + } + } +} + // TestInspectRejectsInconsistentGenesis is the reason inspect validates before it // prints. A minor block's hash is its header's hash alone, so a database whose meta // was replaced still holds the original, authentic-looking block hash — and inspect, diff --git a/cmd/slave/inspectcmd.go b/cmd/slave/inspectcmd.go index 752f4a21c250..bda3139e4219 100644 --- a/cmd/slave/inspectcmd.go +++ b/cmd/slave/inspectcmd.go @@ -3,6 +3,8 @@ package main import ( + "bytes" + "encoding/json" "errors" "fmt" "io" @@ -14,6 +16,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethdb" "github.com/ethereum/go-ethereum/ethdb/pebble" + "github.com/ethereum/go-ethereum/params" qkcCommon "github.com/ethereum/go-ethereum/qkc/common" "github.com/ethereum/go-ethereum/qkc/serialize" "github.com/ethereum/go-ethereum/qkc/shard" @@ -166,11 +169,21 @@ func inspectShardDB(out io.Writer, path string, id uint32) error { } } + // The rule set is keyed by the genesis hash, so there is one to look for only once + // the block it hangs off has been read and vouched for. + var rules storedRules + if block != nil { + if rules, err = readChainConfig(kv, block.Hash()); err != nil { + return fmt.Errorf("read chain config (db %s): %w", path, err) + } + } + fmt.Fprintf(out, "shard 0x%08x (%s):\n", id, path) if block == nil { fmt.Fprintln(out, " genesis block: none (bootstrap never completed; next boot re-initializes)") } else { printGenesisBlock(out, block) + printChainConfig(out, rules) } if head != (common.Hash{}) { fmt.Fprintf(out, " head block: %s\n", head) @@ -214,6 +227,95 @@ func checkGenesisSelfConsistent(block *types.MinorBlock) error { return nil } +// storedRules is the EVM rule set found under a shard's genesis hash: the parsed +// config, and the encoding it was parsed from. Both are kept because the schedule is +// rendered from the encoding rather than from a fixed list of fork fields. +type storedRules struct { + config *params.ChainConfig + raw []byte +} + +// configPrefix mirrors geth's unexported rawdb.configPrefix (core/rawdb/schema.go). +// +// rawdb.ReadChainConfig answers a failed read and a malformed encoding alike with a +// nil config, which inspect would print as "none stored" — the same false claim about +// a database it could not read that ReadHeadBlockHash would make about the head. +var configPrefix = []byte("ethereum-config-") + +// readChainConfig returns the rule set stored under genesisHash, or a zero +// storedRules if none is stored. +func readChainConfig(db ethdb.KeyValueReader, genesisHash common.Hash) (storedRules, error) { + key := append(bytes.Clone(configPrefix), genesisHash.Bytes()...) + has, err := db.Has(key) + if err != nil || !has { + return storedRules{}, err + } + data, err := db.Get(key) + if err != nil { + return storedRules{}, err + } + config := new(params.ChainConfig) + if err := json.Unmarshal(data, config); err != nil { + return storedRules{}, fmt.Errorf("decode chain config: %w", err) + } + return storedRules{config: config, raw: data}, nil +} + +// printChainConfig reports the rule set a reopen is checked against. It is stored +// apart from the genesis block, so it can be missing on a datadir initialized before +// it was written — recoverable, and the boot path answers it by warning and writing +// one, but worth saying out loud here rather than leaving to a log line. +func printChainConfig(out io.Writer, rules storedRules) { + if rules.config == nil { + fmt.Fprintln(out, " rule set: none stored (recoverable; the next boot warns and writes it)") + return + } + fmt.Fprintf(out, " chain id: %s\n", rules.config.ChainID) + fmt.Fprintf(out, " fork schedule: %s\n", formatForkSchedule(rules.raw)) +} + +// formatForkSchedule renders the fork activations out of the stored encoding rather +// than a fixed field list, so a rule set carrying a fork this build does not know +// about is still shown instead of silently dropped. Ordering is by activation, which +// is how a schedule is read; every QKC shard fork sits at block 0. +func formatForkSchedule(raw []byte) string { + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return "unreadable" + } + type fork struct { + name string + at uint64 + } + var forks []fork + for name, value := range fields { + if name == "chainId" { + continue + } + var at uint64 + // Anything that is not a plain number is not a scheduled fork: an engine + // section, a boolean switch, a total difficulty. + if err := json.Unmarshal(value, &at); err != nil { + continue + } + forks = append(forks, fork{strings.TrimSuffix(name, "Block"), at}) + } + if len(forks) == 0 { + return "none scheduled" + } + sort.Slice(forks, func(i, j int) bool { + if forks[i].at != forks[j].at { + return forks[i].at < forks[j].at + } + return forks[i].name < forks[j].name + }) + parts := make([]string, 0, len(forks)) + for _, f := range forks { + parts = append(parts, fmt.Sprintf("%s=%d", f.name, f.at)) + } + return strings.Join(parts, " ") +} + // headBlockKey mirrors geth's unexported rawdb.headBlockKey (core/rawdb/schema.go). // // rawdb.ReadHeadBlockHash discards the error from its Get and answers a failed read