diff --git a/core/blockchain_stats_test.go b/core/blockchain_stats_test.go new file mode 100644 index 0000000000..7e64a9b1b9 --- /dev/null +++ b/core/blockchain_stats_test.go @@ -0,0 +1,258 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package core + +import ( + "encoding/json" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/core/state" + "github.com/ethereum/go-ethereum/core/types" +) + +// TestStateCountsAdd locks down the count merge primitive used to aggregate +// per-tx, pre-tx and post-tx counters in the BAL parallel path. +func TestStateCountsAdd(t *testing.T) { + a := state.StateCounts{ + AccountLoaded: 1, + AccountUpdated: 2, + AccountDeleted: 3, + StorageLoaded: 4, + StorageUpdated: 5, + StorageDeleted: 6, + CodeLoaded: 7, + CodeLoadBytes: 8, + CodeUpdated: 9, + CodeUpdateBytes: 10, + } + b := state.StateCounts{ + AccountLoaded: 100, + AccountUpdated: 200, + AccountDeleted: 300, + StorageLoaded: 400, + StorageUpdated: 500, + StorageDeleted: 600, + CodeLoaded: 700, + CodeLoadBytes: 800, + CodeUpdated: 900, + CodeUpdateBytes: 1000, + } + a.Add(&b) + want := state.StateCounts{ + AccountLoaded: 101, + AccountUpdated: 202, + AccountDeleted: 303, + StorageLoaded: 404, + StorageUpdated: 505, + StorageDeleted: 606, + CodeLoaded: 707, + CodeLoadBytes: 808, + CodeUpdated: 909, + CodeUpdateBytes: 1010, + } + if a != want { + t.Fatalf("Add mismatch: got %+v, want %+v", a, want) + } +} + +// fixtureBlock builds a minimal *types.Block usable as the slow-block log +// subject. Only the header fields read by buildSlowBlockLog matter +// (Number, GasUsed, plus Transactions count via Body). +func fixtureBlock(number uint64, gasUsed uint64) *types.Block { + header := &types.Header{ + Number: new(big.Int).SetUint64(number), + GasUsed: gasUsed, + } + return types.NewBlockWithHeader(header) +} + +// TestBuildSlowBlockLog_NonBALShape ensures non-BAL output doesn't include +// the optional `bal` block (omitempty contract). +func TestBuildSlowBlockLog_NonBALShape(t *testing.T) { + stats := &ExecuteStats{ + AccountReads: 3 * time.Millisecond, + StorageReads: 4 * time.Millisecond, + AccountHashes: 5 * time.Millisecond, + Execution: 7 * time.Millisecond, + TotalTime: 20 * time.Millisecond, + MgasPerSecond: 12.5, + StateCounts: state.StateCounts{ + AccountLoaded: 9, + StorageLoaded: 21, + StorageUpdated: 42, + }, + // balTransitionStats deliberately nil — non-BAL block. + } + block := fixtureBlock(1234, 21000) + logEntry := buildSlowBlockLog(stats, block) + + if logEntry.BAL != nil { + t.Fatalf("non-BAL log unexpectedly has bal extension: %+v", logEntry.BAL) + } + jsonBytes, err := json.Marshal(logEntry) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + var decoded map[string]any + if err := json.Unmarshal(jsonBytes, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + wantKeys := map[string]bool{ + "level": true, "msg": true, "block": true, "timing": true, + "throughput": true, "state_reads": true, "state_writes": true, "cache": true, + } + for k := range decoded { + if !wantKeys[k] { + t.Errorf("unexpected top-level key %q in non-BAL output (full JSON: %s)", k, string(jsonBytes)) + } + delete(wantKeys, k) + } + if len(wantKeys) != 0 { + t.Errorf("missing top-level keys: %v", wantKeys) + } + // Spot-check a count field that exercises the int64→int conversion in + // state_writes.storage_slots (StorageUpdated is int64 on StateCounts). + writes := decoded["state_writes"].(map[string]any) + if got := writes["storage_slots"].(float64); got != 42 { + t.Errorf("storage_slots: got %v, want 42", got) + } +} + +// TestBuildSlowBlockLog_BALShape ensures BAL output includes the bal extension +// with all expected sub-keys. +func TestBuildSlowBlockLog_BALShape(t *testing.T) { + balMetrics := &state.BALStateTransitionMetrics{ + StatePrefetch: 1 * time.Millisecond, + AccountUpdate: 2 * time.Millisecond, + StateUpdate: 3 * time.Millisecond, + StateHash: 4 * time.Millisecond, + AccountCommits: 5 * time.Millisecond, + StorageCommits: 6 * time.Millisecond, + TrieDBCommits: 7 * time.Millisecond, + SnapshotCommits: 8 * time.Millisecond, + } + stats := &ExecuteStats{ + AccountReads: 11 * time.Millisecond, + StorageReads: 22 * time.Millisecond, + CodeReads: 3 * time.Millisecond, + Execution: 15 * time.Millisecond, + TotalTime: 20 * time.Millisecond, + MgasPerSecond: 30.0, + ExecWall: 15 * time.Millisecond, + PostProcess: 2 * time.Millisecond, + Prefetch: 1 * time.Millisecond, + balTransitionStats: balMetrics, + StateCounts: state.StateCounts{ + AccountUpdated: 3, + AccountDeleted: 1, + CodeUpdated: 2, + CodeUpdateBytes: 1024, + }, + StateReadCacheStats: state.ReaderStats{ + StateStats: state.StateReaderStats{ + AccountCacheHit: 10, + AccountCacheMiss: 5, + StorageCacheHit: 20, + StorageCacheMiss: 8, + }, + }, + } + block := fixtureBlock(7, 100000) + logEntry := buildSlowBlockLog(stats, block) + + if logEntry.BAL == nil { + t.Fatal("BAL log missing the bal extension") + } + jsonBytes, err := json.Marshal(logEntry) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + var decoded map[string]any + if err := json.Unmarshal(jsonBytes, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + bal, ok := decoded["bal"].(map[string]any) + if !ok { + t.Fatalf("bal key not present in JSON or wrong type; full JSON: %s", string(jsonBytes)) + } + wantSubkeys := []string{ + "exec_wall_ms", "post_process_ms", "prefetch_ms", + "state_prefetch_ms", "account_update_ms", "state_update_ms", "state_hash_ms", + "account_commit_ms", "storage_commit_ms", "triedb_commit_ms", "snapshot_commit_ms", + } + for _, k := range wantSubkeys { + if _, present := bal[k]; !present { + t.Errorf("bal extension missing key %q", k) + } + } + // Spot-check a value: exec_wall_ms should be 15.0 (15ms). + if got := bal["exec_wall_ms"].(float64); got != 15.0 { + t.Errorf("exec_wall_ms: got %v, want 15.0", got) + } + + // state_read_ms = AccountReads + StorageReads + CodeReads = 11 + 22 + 3 = 36 ms + timing := decoded["timing"].(map[string]any) + if got := timing["state_read_ms"].(float64); got != 36 { + t.Errorf("timing.state_read_ms: got %v, want 36", got) + } + + writes := decoded["state_writes"].(map[string]any) + if got := writes["accounts"].(float64); got != 3 { + t.Errorf("state_writes.accounts: got %v, want 3", got) + } + if got := writes["accounts_deleted"].(float64); got != 1 { + t.Errorf("state_writes.accounts_deleted: got %v, want 1", got) + } + if got := writes["code"].(float64); got != 2 { + t.Errorf("state_writes.code: got %v, want 2", got) + } + if got := writes["code_bytes"].(float64); got != 1024 { + t.Errorf("state_writes.code_bytes: got %v, want 1024", got) + } + + cache := decoded["cache"].(map[string]any) + acct := cache["account"].(map[string]any) + if got := acct["hits"].(float64); got != 10 { + t.Errorf("cache.account.hits: got %v, want 10", got) + } + if got := acct["misses"].(float64); got != 5 { + t.Errorf("cache.account.misses: got %v, want 5", got) + } + storage := cache["storage"].(map[string]any) + if got := storage["hits"].(float64); got != 20 { + t.Errorf("cache.storage.hits: got %v, want 20", got) + } + if got := storage["misses"].(float64); got != 8 { + t.Errorf("cache.storage.misses: got %v, want 8", got) + } +} + +// TestBuildSlowBlockLog_EmptyBlock ensures the helper handles a zero-tx, +// zero-counts block without panic and produces marshalable JSON. +func TestBuildSlowBlockLog_EmptyBlock(t *testing.T) { + stats := &ExecuteStats{} + block := fixtureBlock(0, 0) + logEntry := buildSlowBlockLog(stats, block) + if _, err := json.Marshal(logEntry); err != nil { + t.Fatalf("empty block marshal failed: %v", err) + } + if logEntry.BAL != nil { + t.Errorf("empty block should not have bal extension") + } +}