From ead463b9db87175194ba814c54fd08493e2903d7 Mon Sep 17 00:00:00 2001 From: "Masih H. Derkani" Date: Tue, 7 Jul 2026 15:20:08 +0100 Subject: [PATCH 1/7] Disable JS debug tracers by default with native tracer allowlist Add EVM RPC configuration for trace_allowed_tracers and trace_allow_js_tracers. The native tracer allowlist is validated at startup and remains native-only, while JavaScript tracer execution requires an explicit separate opt-in. Enforce the tracer policy across debug_traceCall, debug_traceTransaction, debug_traceBlockByNumber, debug_traceBlockByHash, and debug_traceTransactionProfile. Validate muxTracer nested configs as well so request-supplied JavaScript cannot bypass the top-level check through nested tracer names. Default the allowlist to the registered native tracer names and keep JS tracers disabled in generated and Docker configs. Add focused tests for config parsing, native allowlist behavior, JS opt-in behavior, and muxTracer nesting. --- docker/localnode/config/app.toml | 4 ++ docker/rpcnode/config/app.toml | 4 ++ evmrpc/config/config.go | 94 ++++++++++++++++++++++++++ evmrpc/config/config_test.go | 62 ++++++++++++++++++ evmrpc/trace_profile.go | 3 + evmrpc/tracers.go | 94 ++++++++++++++++++++++++-- evmrpc/tracers_allowlist_test.go | 109 +++++++++++++++++++++++++++++++ 7 files changed, 364 insertions(+), 6 deletions(-) create mode 100644 evmrpc/tracers_allowlist_test.go diff --git a/docker/localnode/config/app.toml b/docker/localnode/config/app.toml index 89ed5e9b22..dadff7f527 100644 --- a/docker/localnode/config/app.toml +++ b/docker/localnode/config/app.toml @@ -278,6 +278,10 @@ enable_test_api = true # Set to 0 to disable request limiter, otherwise this limits the number of concurrent simulation calls. max_concurrent_simulation_calls = 0 +# Native debug tracers allowed in TraceConfig.Tracer. JavaScript tracer source is disabled. +trace_allowed_tracers = ["callTracer", "prestateTracer", "flatCallTracer", "4byteTracer", "noopTracer", "muxTracer"] +trace_allow_js_tracers = false + # Legacy sei_* / sei2_* JSON-RPC (EVM HTTP only). # DEPRECATION: All sei_* and sei2_* methods are deprecated and scheduled for removal - no new integrations. # HTTP 200; gate errors: JSON-RPC error (data legacy_sei_deprecated). Success: unchanged body; optional header Sei-Legacy-RPC-Deprecation. diff --git a/docker/rpcnode/config/app.toml b/docker/rpcnode/config/app.toml index c8134e320a..d4a6556c20 100644 --- a/docker/rpcnode/config/app.toml +++ b/docker/rpcnode/config/app.toml @@ -266,6 +266,10 @@ enable_test_api = true # Set to 0 to disable request limiter, otherwise this limits the number of concurrent simulation calls. max_concurrent_simulation_calls = 0 +# Native debug tracers allowed in TraceConfig.Tracer. JavaScript tracer source is disabled. +trace_allowed_tracers = ["callTracer", "prestateTracer", "flatCallTracer", "4byteTracer", "noopTracer", "muxTracer"] +trace_allow_js_tracers = false + # Legacy sei_* / sei2_* JSON-RPC (EVM HTTP only). # DEPRECATION: All sei_* and sei2_* methods are deprecated and scheduled for removal - no new integrations. # HTTP 200; gate errors: JSON-RPC error (data legacy_sei_deprecated). Success: unchanged body; optional header Sei-Legacy-RPC-Deprecation. diff --git a/evmrpc/config/config.go b/evmrpc/config/config.go index e4a3d07f44..1b5c761f62 100644 --- a/evmrpc/config/config.go +++ b/evmrpc/config/config.go @@ -3,6 +3,7 @@ package config import ( "fmt" "runtime" + "strings" "time" "github.com/ethereum/go-ethereum/rpc" @@ -31,8 +32,43 @@ const ( // goroutine creation on high-core machines. Tasks are primarily I/O bound // (fetching and processing block logs), so 2x CPU cores can be excessive. MaxWorkerPoolSize = 64 + + TraceTracer4Byte = "4byteTracer" + TraceTracerCall = "callTracer" + TraceTracerFlatCall = "flatCallTracer" + TraceTracerMux = "muxTracer" + TraceTracerNoop = "noopTracer" + TraceTracerPrestate = "prestateTracer" ) +var nativeTraceTracers = map[string]struct{}{ + TraceTracer4Byte: {}, + TraceTracerCall: {}, + TraceTracerFlatCall: {}, + TraceTracerMux: {}, + TraceTracerNoop: {}, + TraceTracerPrestate: {}, +} + +// DefaultTraceAllowedTracers returns the native debug tracers allowed by default. +func DefaultTraceAllowedTracers() []string { + return []string{ + TraceTracerCall, + TraceTracerPrestate, + TraceTracerFlatCall, + TraceTracer4Byte, + TraceTracerNoop, + TraceTracerMux, + } +} + +// IsNativeTraceTracer reports whether name is a registered native geth tracer +// name that can be safely allowlisted without enabling request-supplied JS. +func IsNativeTraceTracer(name string) bool { + _, ok := nativeTraceTracers[name] + return ok +} + // EVMRPC Config defines configurations for EVM RPC server on this node type Config struct { // controls whether an HTTP EVM server is enabled @@ -141,6 +177,17 @@ type Config struct { // (matches upstream geth behavior). MaxTraceStructLogBytes uint64 `mapstructure:"max_trace_struct_log_bytes"` + // TraceAllowedTracers lists native debug tracer names that callers may + // request with TraceConfig.Tracer. The default struct logger remains + // available when Tracer is omitted. Request-supplied JavaScript tracers are + // never allowed through this list. + TraceAllowedTracers []string `mapstructure:"trace_allowed_tracers"` + + // TraceAllowJSTracers permits callers to supply JavaScript tracer source in + // TraceConfig.Tracer. This executes request-supplied code in-process and is + // disabled by default. + TraceAllowJSTracers bool `mapstructure:"trace_allow_js_tracers"` + // EnableParallelizedBlockTrace enables the parallelized default debug_traceBlock* path. EnableParallelizedBlockTrace bool `mapstructure:"enable_parallelized_block_trace"` @@ -238,6 +285,8 @@ var DefaultConfig = Config{ MaxTraceLookbackBlocks: 10000, TraceTimeout: 30 * time.Second, MaxTraceStructLogBytes: 32 * 1024 * 1024, // 32 MiB + TraceAllowedTracers: DefaultTraceAllowedTracers(), + TraceAllowJSTracers: false, EnableParallelizedBlockTrace: false, RPCStatsInterval: 10 * time.Second, WorkerPoolSize: min(MaxWorkerPoolSize, runtime.NumCPU()*2), // Default: min(64, CPU cores × 2) @@ -292,6 +341,8 @@ const ( flagMaxTraceLookbackBlocks = "evm.max_trace_lookback_blocks" flagTraceTimeout = "evm.trace_timeout" flagMaxTraceStructLogBytes = "evm.max_trace_struct_log_bytes" + flagTraceAllowedTracers = "evm.trace_allowed_tracers" + flagTraceAllowJSTracers = "evm.trace_allow_js_tracers" flagEnableParallelizedBlockTrace = "evm.enable_parallelized_block_trace" flagRPCStatsInterval = "evm.rpc_stats_interval" flagWorkerPoolSize = "evm.worker_pool_size" @@ -456,6 +507,19 @@ func ReadConfig(opts servertypes.AppOptions) (Config, error) { return cfg, err } } + if v := opts.Get(flagTraceAllowedTracers); v != nil { + if cfg.TraceAllowedTracers, err = cast.ToStringSliceE(v); err != nil { + return cfg, err + } + } + if cfg.TraceAllowedTracers, err = normalizeTraceAllowedTracers(cfg.TraceAllowedTracers); err != nil { + return cfg, err + } + if v := opts.Get(flagTraceAllowJSTracers); v != nil { + if cfg.TraceAllowJSTracers, err = cast.ToBoolE(v); err != nil { + return cfg, err + } + } if v := opts.Get(flagEnableParallelizedBlockTrace); v != nil { if cfg.EnableParallelizedBlockTrace, err = cast.ToBoolE(v); err != nil { return cfg, err @@ -557,6 +621,26 @@ func ReadConfig(opts servertypes.AppOptions) (Config, error) { return cfg, nil } +func normalizeTraceAllowedTracers(names []string) ([]string, error) { + out := make([]string, 0, len(names)) + seen := make(map[string]struct{}, len(names)) + for _, name := range names { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return nil, fmt.Errorf("%s entries must not be empty", flagTraceAllowedTracers) + } + if !IsNativeTraceTracer(trimmed) { + return nil, fmt.Errorf("%s contains non-native tracer %q", flagTraceAllowedTracers, trimmed) + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + out = append(out, trimmed) + } + return out, nil +} + // ConfigTemplate defines the TOML configuration template for EVM RPC const ConfigTemplate = ` ############################################################################### @@ -709,6 +793,16 @@ trace_timeout = "{{ .EVM.TraceTimeout }}" # upstream geth behavior). max_trace_struct_log_bytes = {{ .EVM.MaxTraceStructLogBytes }} +# Native debug tracers that may be requested with TraceConfig.Tracer. The default +# struct logger remains available when Tracer is omitted. Request-supplied +# JavaScript tracer source is disabled and cannot be enabled through this list. +# Set to [] to disable all named tracers. +trace_allowed_tracers = [{{- range $i, $t := .EVM.TraceAllowedTracers }}{{- if $i }}, {{ end }}"{{ $t }}"{{- end }}] + +# Allow request-supplied JavaScript tracer source in TraceConfig.Tracer. This +# executes untrusted code in-process; keep disabled on public/default RPC nodes. +trace_allow_js_tracers = {{ .EVM.TraceAllowJSTracers }} + # Enable the parallelized default debug_traceBlock* path. enable_parallelized_block_trace = {{ .EVM.EnableParallelizedBlockTrace }} diff --git a/evmrpc/config/config_test.go b/evmrpc/config/config_test.go index 6e41dd607d..cfc077ad9d 100644 --- a/evmrpc/config/config_test.go +++ b/evmrpc/config/config_test.go @@ -49,6 +49,8 @@ type opts struct { maxConcurrentRequestBytes interface{} maxOpenConnections interface{} maxTraceStructLogBytes interface{} + traceAllowedTracers interface{} + traceAllowJSTracers interface{} } func (o *opts) Get(k string) interface{} { @@ -184,6 +186,12 @@ func (o *opts) Get(k string) interface{} { if k == "evm.max_trace_struct_log_bytes" { return o.maxTraceStructLogBytes } + if k == "evm.trace_allowed_tracers" { + return o.traceAllowedTracers + } + if k == "evm.trace_allow_js_tracers" { + return o.traceAllowJSTracers + } panic("unknown key") } @@ -230,6 +238,8 @@ func getDefaultOpts() opts { int64(128 * 1024 * 1024), 2000, uint64(256 * 1024 * 1024), + []string{"callTracer", "prestateTracer"}, + false, } } @@ -240,6 +250,8 @@ func TestReadConfig(t *testing.T) { require.False(t, cfg.EnableParallelizedBlockTrace) // Round-trip: an explicitly-supplied value overrides the default. require.Equal(t, uint64(256*1024*1024), cfg.MaxTraceStructLogBytes) + require.Equal(t, []string{"callTracer", "prestateTracer"}, cfg.TraceAllowedTracers) + require.False(t, cfg.TraceAllowJSTracers) // The shipped default (used when the operator supplies no value). require.Equal(t, uint64(32*1024*1024), config.DefaultConfig.MaxTraceStructLogBytes) badOpts := goodOpts @@ -343,6 +355,21 @@ func TestReadConfig(t *testing.T) { _, err = config.ReadConfig(&badOpts) require.NotNil(t, err) + badOpts = goodOpts + badOpts.traceAllowedTracers = map[string]interface{}{} + _, err = config.ReadConfig(&badOpts) + require.NotNil(t, err) + + badOpts = goodOpts + badOpts.traceAllowedTracers = []string{"callTracer", "function() { return {}; }"} + _, err = config.ReadConfig(&badOpts) + require.NotNil(t, err) + + badOpts = goodOpts + badOpts.traceAllowJSTracers = "bad" + _, err = config.ReadConfig(&badOpts) + require.NotNil(t, err) + // Test bad types for worker pool config badOpts = goodOpts badOpts.workerPoolSize = "bad" @@ -502,6 +529,41 @@ func TestReadConfigEnableParallelizedBlockTrace(t *testing.T) { require.True(t, cfg.EnableParallelizedBlockTrace) } +func TestReadConfigTraceAllowedTracers(t *testing.T) { + cfg, err := config.ReadConfig(&opts{}) + require.NoError(t, err) + require.Equal(t, config.DefaultTraceAllowedTracers(), cfg.TraceAllowedTracers) + + opts := getDefaultOpts() + opts.traceAllowedTracers = []string{ + "callTracer", + "callTracer", + " flatCallTracer ", + } + cfg, err = config.ReadConfig(&opts) + require.NoError(t, err) + require.Equal(t, []string{"callTracer", "flatCallTracer"}, cfg.TraceAllowedTracers) + + opts.traceAllowedTracers = []string{"callTracer", ""} + _, err = config.ReadConfig(&opts) + require.Error(t, err) + + opts.traceAllowedTracers = []string{"callTracer", "badTracer"} + _, err = config.ReadConfig(&opts) + require.Error(t, err) + + opts.traceAllowedTracers = []string{"callTracer", "function() { return {}; }"} + opts.traceAllowJSTracers = true + _, err = config.ReadConfig(&opts) + require.Error(t, err, "trace_allowed_tracers remains native-only even when JS tracers are allowed") + + opts.traceAllowedTracers = []string{"callTracer"} + opts.traceAllowJSTracers = true + cfg, err = config.ReadConfig(&opts) + require.NoError(t, err) + require.True(t, cfg.TraceAllowJSTracers) +} + func TestReadConfigMaxSubscriptionsLogs(t *testing.T) { opts := getDefaultOpts() opts.maxSubscriptionsLogs = uint64(42) diff --git a/evmrpc/trace_profile.go b/evmrpc/trace_profile.go index f061808ecd..244836c5c6 100644 --- a/evmrpc/trace_profile.go +++ b/evmrpc/trace_profile.go @@ -48,6 +48,9 @@ func (api *DebugAPI) TraceTransactionProfile(ctx context.Context, hash common.Ha recordMetricsWithError(ctx, "debug_traceTransactionProfile", api.connectionType, startTime, returnErr, recover()) }() + if returnErr = api.validateTraceTracer(config); returnErr != nil { + return nil, returnErr + } if returnErr = api.guardHistoricalDebugTraceByTxHash(ctx, "debug_traceTransactionProfile", hash); returnErr != nil { return nil, returnErr } diff --git a/evmrpc/tracers.go b/evmrpc/tracers.go index 807ed72094..f7383d8b69 100644 --- a/evmrpc/tracers.go +++ b/evmrpc/tracers.go @@ -33,9 +33,10 @@ const ( IsPanicCacheSize = 5000 IsPanicCacheTTL = 1 * time.Minute - callTracerName = "callTracer" - prestateTracerName = "prestateTracer" - flatCallTracerName = "flatCallTracer" + callTracerName = evmrpcconfig.TraceTracerCall + prestateTracerName = evmrpcconfig.TraceTracerPrestate + flatCallTracerName = evmrpcconfig.TraceTracerFlatCall + muxTracerName = evmrpcconfig.TraceTracerMux ) var errTraceConcurrencyLimit = errors.New("trace request rejected due to concurrency limit: server busy") @@ -53,6 +54,8 @@ type DebugAPI struct { maxBlockLookback int64 traceTimeout time.Duration maxStructLogBytes int // per-call cap on retained default struct-logger output; 0 = unlimited + allowedTracers map[string]struct{} + allowJSTracers bool profiledBlockTrace bool } @@ -207,6 +210,8 @@ func NewDebugAPI( maxBlockLookback: debugCfg.MaxTraceLookbackBlocks, traceTimeout: debugCfg.TraceTimeout, maxStructLogBytes: clampUint64ToInt(debugCfg.MaxTraceStructLogBytes), + allowedTracers: buildAllowedTracerSet(debugCfg.TraceAllowedTracers), + allowJSTracers: debugCfg.TraceAllowJSTracers, profiledBlockTrace: debugCfg.EnableParallelizedBlockTrace, } } @@ -237,12 +242,78 @@ func (api *DebugAPI) clampDefaultStructLogLimit(config *tracers.TraceConfig) { } } +func buildAllowedTracerSet(names []string) map[string]struct{} { + allowed := make(map[string]struct{}, len(names)) + for _, name := range names { + allowed[name] = struct{}{} + } + return allowed +} + +func (api *DebugAPI) validateTraceTracer(config *tracers.TraceConfig) error { + if config == nil || config.Tracer == nil { + return nil + } + name := *config.Tracer + if name == "" { + return fmt.Errorf("debug tracer name must not be empty") + } + if _, ok := api.allowedTracers[name]; !ok { + if api.allowJSTracers && !evmrpcconfig.IsNativeTraceTracer(name) { + return nil + } + if api.allowJSTracers { + return fmt.Errorf("debug native tracer %q is not listed in evm.trace_allowed_tracers", name) + } + return fmt.Errorf("debug tracer %q is not allowed; JavaScript tracers are disabled and only native tracers listed in evm.trace_allowed_tracers may be used", name) + } + if name == muxTracerName { + if err := validateMuxTraceConfig(config.TracerConfig, api.allowedTracers, api.allowJSTracers); err != nil { + return fmt.Errorf("invalid muxTracer config: %w", err) + } + } + return nil +} + +func validateMuxTraceConfig(raw json.RawMessage, allowed map[string]struct{}, allowJS bool) error { + if len(raw) == 0 { + return nil + } + var nested map[string]json.RawMessage + if err := json.Unmarshal(raw, &nested); err != nil { + return err + } + for name, cfg := range nested { + if name == "" { + return fmt.Errorf("nested debug tracer name must not be empty") + } + if _, ok := allowed[name]; !ok { + if allowJS && !evmrpcconfig.IsNativeTraceTracer(name) { + continue + } + if allowJS { + return fmt.Errorf("nested native debug tracer %q is not listed in evm.trace_allowed_tracers", name) + } + return fmt.Errorf("nested debug tracer %q is not allowed; JavaScript tracers are disabled", name) + } + if name == muxTracerName { + if err := validateMuxTraceConfig(cfg, allowed, allowJS); err != nil { + return err + } + } + } + return nil +} + func (api *DebugAPI) TraceTransaction(ctx context.Context, hash common.Hash, config *tracers.TraceConfig) (result interface{}, returnErr error) { startTime := time.Now() defer func() { recordMetricsWithError(ctx, "debug_traceTransaction", api.connectionType, startTime, returnErr, recover()) }() + if returnErr = api.validateTraceTracer(config); returnErr != nil { + return nil, returnErr + } if returnErr = api.guardHistoricalDebugTraceByTxHash(ctx, "debug_traceTransaction", hash); returnErr != nil { return nil, returnErr } @@ -450,6 +521,9 @@ func (api *DebugAPI) TraceBlockByNumber(ctx context.Context, number rpc.BlockNum recordMetricsWithError(ctx, "debug_traceBlockByNumber", api.connectionType, startTime, returnErr, recover()) }() + if returnErr = api.validateTraceTracer(config); returnErr != nil { + return nil, returnErr + } if returnErr = api.guardHistoricalDebugTraceByNumber(ctx, "debug_traceBlockByNumber", number); returnErr != nil { return nil, returnErr } @@ -482,6 +556,10 @@ func (api *DebugAPI) TraceBlockByHash(ctx context.Context, hash common.Hash, con recordMetricsWithError(ctx, "debug_traceBlockByHash", api.connectionType, startTime, returnErr, recover()) }() + if returnErr = api.validateTraceTracer(config); returnErr != nil { + return nil, returnErr + } + ctx, done, err := api.prepareTraceContext(ctx) if err != nil { return nil, err @@ -514,6 +592,13 @@ func (api *DebugAPI) TraceCall(ctx context.Context, args export.TransactionArgs, recordMetricsWithError(ctx, "debug_traceCall", api.connectionType, startTime, returnErr, recover()) }() + if config == nil { + config = &tracers.TraceCallConfig{} + } + if returnErr = api.validateTraceTracer(&config.TraceConfig); returnErr != nil { + return nil, returnErr + } + ctx, done, err := api.prepareTraceContext(ctx) if err != nil { return nil, err @@ -524,9 +609,6 @@ func (api *DebugAPI) TraceCall(ctx context.Context, args export.TransactionArgs, return nil, returnErr } - if config == nil { - config = &tracers.TraceCallConfig{} - } api.clampDefaultStructLogLimit(&config.TraceConfig) result, returnErr = api.tracersAPI.TraceCall(ctx, args, blockNrOrHash, config) return diff --git a/evmrpc/tracers_allowlist_test.go b/evmrpc/tracers_allowlist_test.go new file mode 100644 index 0000000000..9e721e28cb --- /dev/null +++ b/evmrpc/tracers_allowlist_test.go @@ -0,0 +1,109 @@ +package evmrpc + +import ( + "encoding/json" + "testing" + + "github.com/ethereum/go-ethereum/eth/tracers" + "github.com/stretchr/testify/require" +) + +func TestValidateTraceTracerAllowlist(t *testing.T) { + api := &DebugAPI{ + allowedTracers: buildAllowedTracerSet([]string{ + callTracerName, + prestateTracerName, + muxTracerName, + }), + } + + require.NoError(t, api.validateTraceTracer(nil)) + require.NoError(t, api.validateTraceTracer(&tracers.TraceConfig{})) + + name := callTracerName + require.NoError(t, api.validateTraceTracer(&tracers.TraceConfig{Tracer: &name})) + + name = flatCallTracerName + require.ErrorContains(t, api.validateTraceTracer(&tracers.TraceConfig{Tracer: &name}), "is not allowed") + + name = "function() { return {}; }" + require.ErrorContains(t, api.validateTraceTracer(&tracers.TraceConfig{Tracer: &name}), "JavaScript tracers are disabled") + + name = "" + require.ErrorContains(t, api.validateTraceTracer(&tracers.TraceConfig{Tracer: &name}), "must not be empty") +} + +func TestValidateTraceTracerAllowsJSWhenConfigured(t *testing.T) { + api := &DebugAPI{ + allowedTracers: buildAllowedTracerSet([]string{ + callTracerName, + muxTracerName, + }), + allowJSTracers: true, + } + + name := "function() { return {}; }" + require.NoError(t, api.validateTraceTracer(&tracers.TraceConfig{Tracer: &name})) + + name = flatCallTracerName + require.ErrorContains(t, api.validateTraceTracer(&tracers.TraceConfig{Tracer: &name}), "native tracer") + + name = "" + require.ErrorContains(t, api.validateTraceTracer(&tracers.TraceConfig{Tracer: &name}), "must not be empty") +} + +func TestValidateMuxTraceConfig(t *testing.T) { + api := &DebugAPI{ + allowedTracers: buildAllowedTracerSet([]string{ + callTracerName, + prestateTracerName, + muxTracerName, + }), + } + + name := muxTracerName + require.NoError(t, api.validateTraceTracer(&tracers.TraceConfig{ + Tracer: &name, + TracerConfig: json.RawMessage(`{"callTracer":{},"prestateTracer":{}}`), + })) + require.NoError(t, api.validateTraceTracer(&tracers.TraceConfig{ + Tracer: &name, + TracerConfig: json.RawMessage(`{"muxTracer":{"callTracer":{}}}`), + })) + + err := api.validateTraceTracer(&tracers.TraceConfig{ + Tracer: &name, + TracerConfig: json.RawMessage(`{"function() { return {}; }":{}}`), + }) + require.ErrorContains(t, err, "nested debug tracer") + require.ErrorContains(t, err, "JavaScript tracers are disabled") + + err = api.validateTraceTracer(&tracers.TraceConfig{ + Tracer: &name, + TracerConfig: json.RawMessage(`{"muxTracer":{"badTracer":{}}}`), + }) + require.ErrorContains(t, err, "nested debug tracer") + require.ErrorContains(t, err, "badTracer") +} + +func TestValidateMuxTraceConfigAllowsJSWhenConfigured(t *testing.T) { + api := &DebugAPI{ + allowedTracers: buildAllowedTracerSet([]string{ + callTracerName, + muxTracerName, + }), + allowJSTracers: true, + } + + name := muxTracerName + require.NoError(t, api.validateTraceTracer(&tracers.TraceConfig{ + Tracer: &name, + TracerConfig: json.RawMessage(`{"function() { return {}; }":{}}`), + })) + + err := api.validateTraceTracer(&tracers.TraceConfig{ + Tracer: &name, + TracerConfig: json.RawMessage(`{"flatCallTracer":{}}`), + }) + require.ErrorContains(t, err, "nested native debug tracer") +} From 0f2b11f97eae888ec3b50162a01cb5ee1b40a910 Mon Sep 17 00:00:00 2001 From: "Masih H. Derkani" Date: Wed, 8 Jul 2026 14:37:31 +0100 Subject: [PATCH 2/7] Address review comment --- evmrpc/tracers.go | 11 ++++++++++- evmrpc/tracers_allowlist_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/evmrpc/tracers.go b/evmrpc/tracers.go index f7383d8b69..518120495c 100644 --- a/evmrpc/tracers.go +++ b/evmrpc/tracers.go @@ -37,6 +37,8 @@ const ( prestateTracerName = evmrpcconfig.TraceTracerPrestate flatCallTracerName = evmrpcconfig.TraceTracerFlatCall muxTracerName = evmrpcconfig.TraceTracerMux + + maxMuxTracerNestingDepth = 16 ) var errTraceConcurrencyLimit = errors.New("trace request rejected due to concurrency limit: server busy") @@ -276,9 +278,16 @@ func (api *DebugAPI) validateTraceTracer(config *tracers.TraceConfig) error { } func validateMuxTraceConfig(raw json.RawMessage, allowed map[string]struct{}, allowJS bool) error { + return validateMuxTraceConfigDepth(raw, allowed, allowJS, 1) +} + +func validateMuxTraceConfigDepth(raw json.RawMessage, allowed map[string]struct{}, allowJS bool, depth int) error { if len(raw) == 0 { return nil } + if depth > maxMuxTracerNestingDepth { + return fmt.Errorf("muxTracer nesting depth exceeds maximum of %d", maxMuxTracerNestingDepth) + } var nested map[string]json.RawMessage if err := json.Unmarshal(raw, &nested); err != nil { return err @@ -297,7 +306,7 @@ func validateMuxTraceConfig(raw json.RawMessage, allowed map[string]struct{}, al return fmt.Errorf("nested debug tracer %q is not allowed; JavaScript tracers are disabled", name) } if name == muxTracerName { - if err := validateMuxTraceConfig(cfg, allowed, allowJS); err != nil { + if err := validateMuxTraceConfigDepth(cfg, allowed, allowJS, depth+1); err != nil { return err } } diff --git a/evmrpc/tracers_allowlist_test.go b/evmrpc/tracers_allowlist_test.go index 9e721e28cb..38e6764d8e 100644 --- a/evmrpc/tracers_allowlist_test.go +++ b/evmrpc/tracers_allowlist_test.go @@ -2,6 +2,7 @@ package evmrpc import ( "encoding/json" + "strings" "testing" "github.com/ethereum/go-ethereum/eth/tracers" @@ -86,6 +87,27 @@ func TestValidateMuxTraceConfig(t *testing.T) { require.ErrorContains(t, err, "badTracer") } +func TestValidateMuxTraceConfigRejectsDeepNesting(t *testing.T) { + api := &DebugAPI{ + allowedTracers: buildAllowedTracerSet([]string{ + callTracerName, + muxTracerName, + }), + } + + name := muxTracerName + require.NoError(t, api.validateTraceTracer(&tracers.TraceConfig{ + Tracer: &name, + TracerConfig: nestedMuxTraceConfig(maxMuxTracerNestingDepth - 1), + })) + + err := api.validateTraceTracer(&tracers.TraceConfig{ + Tracer: &name, + TracerConfig: nestedMuxTraceConfig(maxMuxTracerNestingDepth), + }) + require.ErrorContains(t, err, "muxTracer nesting depth exceeds maximum") +} + func TestValidateMuxTraceConfigAllowsJSWhenConfigured(t *testing.T) { api := &DebugAPI{ allowedTracers: buildAllowedTracerSet([]string{ @@ -107,3 +129,13 @@ func TestValidateMuxTraceConfigAllowsJSWhenConfigured(t *testing.T) { }) require.ErrorContains(t, err, "nested native debug tracer") } + +func nestedMuxTraceConfig(nestedMuxTracers int) json.RawMessage { + var builder strings.Builder + for range nestedMuxTracers { + builder.WriteString(`{"muxTracer":`) + } + builder.WriteString(`{"callTracer":{}}`) + builder.WriteString(strings.Repeat("}", nestedMuxTracers)) + return json.RawMessage(builder.String()) +} From 4011e432050a75ede9fdc14f2dea099604390c9a Mon Sep 17 00:00:00 2001 From: "Masih H. Derkani" Date: Wed, 8 Jul 2026 15:21:43 +0100 Subject: [PATCH 3/7] Harden validation and update agent files while at it --- evmrpc/AGENTS.md | 2 ++ evmrpc/config/config.go | 18 +++++++++++++----- evmrpc/config/config_test.go | 28 +++++++++++++++++++++++++++- evmrpc/tracers.go | 30 +++++++++++++++++++----------- evmrpc/tracers_allowlist_test.go | 25 +++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 17 deletions(-) diff --git a/evmrpc/AGENTS.md b/evmrpc/AGENTS.md index 666a655992..315eef3f5b 100644 --- a/evmrpc/AGENTS.md +++ b/evmrpc/AGENTS.md @@ -23,5 +23,7 @@ Legacy **`sei_*` and `sei2_*`** JSON-RPC (EVM HTTP only) are **gated** by the sa ## `debug_` prefixed endpoints `debug_trace*` endpoints should faithfully replay historical execution. If a transaction encountered an error during its actual execution, a `debug_trace*` call for it should reflect so. If a transction consumed X amount of gas during its actual execution, a `debug_trace*` call should show that exact amount as well. +**Tracer gating (deviation from geth defaults):** caller-supplied `TraceConfig.Tracer` values on `debug_traceCall` / `debug_traceTransaction` / `debug_traceBlockBy*` / `debug_traceTransactionProfile` are gated by `[evm]` config in `app.toml`. `trace_allowed_tracers` lists the native geth tracer names callers may request (validated native-only at startup; `muxTracer` nested tracer names are validated recursively with a bounded depth). `trace_allow_js_tracers` (default `false`) is a separate explicit opt-in for request-supplied JavaScript tracer source — upstream geth accepts JS tracers by default, Sei does not. Enabling JS does **not** widen the native allowlist. Validation runs in `validateTraceTracer` (`tracers.go`) before trace-cache lookups and before any tracer is constructed; the default struct logger (no `tracer` field) is always available. `trace_bake_tracers` is held to the same native-only rule at startup. + ## Consistency RPC responses for historical heights should never change as the blockchain progresses, or as the blockchain code gets upgraded. diff --git a/evmrpc/config/config.go b/evmrpc/config/config.go index 1b5c761f62..c0960a341d 100644 --- a/evmrpc/config/config.go +++ b/evmrpc/config/config.go @@ -512,7 +512,7 @@ func ReadConfig(opts servertypes.AppOptions) (Config, error) { return cfg, err } } - if cfg.TraceAllowedTracers, err = normalizeTraceAllowedTracers(cfg.TraceAllowedTracers); err != nil { + if cfg.TraceAllowedTracers, err = normalizeNativeTracerNames(flagTraceAllowedTracers, cfg.TraceAllowedTracers); err != nil { return cfg, err } if v := opts.Get(flagTraceAllowJSTracers); v != nil { @@ -565,6 +565,12 @@ func ReadConfig(opts servertypes.AppOptions) (Config, error) { return cfg, err } } + // The baker feeds these names straight into geth tracing, so hold them to + // the same native-only rule as request-supplied tracers: a typo'd name must + // fail at startup instead of being evaluated as JS source on every block. + if cfg.TraceBakeTracers, err = normalizeNativeTracerNames(flagTraceBakeTracers, cfg.TraceBakeTracers); err != nil { + return cfg, err + } if v := opts.Get(flagTraceBakeWindowBlocks); v != nil { if cfg.TraceBakeWindowBlocks, err = cast.ToInt64E(v); err != nil { return cfg, err @@ -621,16 +627,16 @@ func ReadConfig(opts servertypes.AppOptions) (Config, error) { return cfg, nil } -func normalizeTraceAllowedTracers(names []string) ([]string, error) { +func normalizeNativeTracerNames(flagName string, names []string) ([]string, error) { out := make([]string, 0, len(names)) seen := make(map[string]struct{}, len(names)) for _, name := range names { trimmed := strings.TrimSpace(name) if trimmed == "" { - return nil, fmt.Errorf("%s entries must not be empty", flagTraceAllowedTracers) + return nil, fmt.Errorf("%s entries must not be empty", flagName) } if !IsNativeTraceTracer(trimmed) { - return nil, fmt.Errorf("%s contains non-native tracer %q", flagTraceAllowedTracers, trimmed) + return nil, fmt.Errorf("%s contains non-native tracer %q", flagName, trimmed) } if _, ok := seen[trimmed]; ok { continue @@ -801,6 +807,8 @@ trace_allowed_tracers = [{{- range $i, $t := .EVM.TraceAllowedTracers }}{{- if $ # Allow request-supplied JavaScript tracer source in TraceConfig.Tracer. This # executes untrusted code in-process; keep disabled on public/default RPC nodes. +# Enabling this does not widen trace_allowed_tracers: native tracer names must +# still be listed there to be usable. trace_allow_js_tracers = {{ .EVM.TraceAllowJSTracers }} # Enable the parallelized default debug_traceBlock* path. @@ -828,7 +836,7 @@ trace_bake_workers = {{ .EVM.TraceBakeWorkers }} # Bounded in-flight height queue. Drops on full so consensus never blocks. trace_bake_queue_size = {{ .EVM.TraceBakeQueueSize }} -# Which tracers to bake per block; only standard named tracers are eligible. +# Which tracers to bake per block; must be native tracer names (validated at startup). trace_bake_tracers = [{{- range $i, $t := .EVM.TraceBakeTracers }}{{- if $i }}, {{ end }}"{{ $t }}"{{- end }}] # Rolling cache window: prune blocks older than (latest - this). diff --git a/evmrpc/config/config_test.go b/evmrpc/config/config_test.go index cfc077ad9d..9da73d5ebe 100644 --- a/evmrpc/config/config_test.go +++ b/evmrpc/config/config_test.go @@ -51,6 +51,7 @@ type opts struct { maxTraceStructLogBytes interface{} traceAllowedTracers interface{} traceAllowJSTracers interface{} + traceBakeTracers interface{} } func (o *opts) Get(k string) interface{} { @@ -153,10 +154,12 @@ func (o *opts) Get(k string) interface{} { if k == "evm.enabled_legacy_sei_apis" { return nil } + if k == "evm.trace_bake_tracers" { + return o.traceBakeTracers + } if k == "evm.trace_bake_enabled" || k == "evm.trace_bake_workers" || k == "evm.trace_bake_queue_size" || - k == "evm.trace_bake_tracers" || k == "evm.trace_bake_window_blocks" || k == "evm.trace_bake_use_snapshot" || k == "evm.trace_bake_snapshot_window" { @@ -240,6 +243,7 @@ func getDefaultOpts() opts { uint64(256 * 1024 * 1024), []string{"callTracer", "prestateTracer"}, false, + nil, } } @@ -564,6 +568,28 @@ func TestReadConfigTraceAllowedTracers(t *testing.T) { require.True(t, cfg.TraceAllowJSTracers) } +func TestReadConfigTraceBakeTracers(t *testing.T) { + cfg, err := config.ReadConfig(&opts{}) + require.NoError(t, err) + require.Equal(t, []string{"callTracer"}, cfg.TraceBakeTracers) + + o := getDefaultOpts() + o.traceBakeTracers = []string{"prestateTracer", " callTracer ", "prestateTracer"} + cfg, err = config.ReadConfig(&o) + require.NoError(t, err) + require.Equal(t, []string{"prestateTracer", "callTracer"}, cfg.TraceBakeTracers) + + o.traceBakeTracers = []string{"callTracer", "badTracer"} + _, err = config.ReadConfig(&o) + require.Error(t, err) + + // A JS-looking name must fail at startup rather than being evaluated as + // JS source by the baker on every committed block. + o.traceBakeTracers = []string{"function() { return {}; }"} + _, err = config.ReadConfig(&o) + require.Error(t, err) +} + func TestReadConfigMaxSubscriptionsLogs(t *testing.T) { opts := getDefaultOpts() opts.maxSubscriptionsLogs = uint64(42) diff --git a/evmrpc/tracers.go b/evmrpc/tracers.go index 518120495c..21054a7a85 100644 --- a/evmrpc/tracers.go +++ b/evmrpc/tracers.go @@ -7,6 +7,7 @@ import ( "fmt" "math" "runtime/debug" + "strings" "sync" "time" @@ -256,10 +257,14 @@ func (api *DebugAPI) validateTraceTracer(config *tracers.TraceConfig) error { if config == nil || config.Tracer == nil { return nil } - name := *config.Tracer + name := strings.TrimSpace(*config.Tracer) if name == "" { - return fmt.Errorf("debug tracer name must not be empty") + return errors.New("debug tracer name must not be empty") } + // Write the trimmed name back so geth resolves the exact tracer that was + // validated; otherwise a padded native name (e.g. " callTracer") would pass + // validation here yet be treated as JS source by the tracer directory. + *config.Tracer = name if _, ok := api.allowedTracers[name]; !ok { if api.allowJSTracers && !evmrpcconfig.IsNativeTraceTracer(name) { return nil @@ -270,18 +275,14 @@ func (api *DebugAPI) validateTraceTracer(config *tracers.TraceConfig) error { return fmt.Errorf("debug tracer %q is not allowed; JavaScript tracers are disabled and only native tracers listed in evm.trace_allowed_tracers may be used", name) } if name == muxTracerName { - if err := validateMuxTraceConfig(config.TracerConfig, api.allowedTracers, api.allowJSTracers); err != nil { + if err := validateMuxTraceConfig(config.TracerConfig, api.allowedTracers, api.allowJSTracers, 1); err != nil { return fmt.Errorf("invalid muxTracer config: %w", err) } } return nil } -func validateMuxTraceConfig(raw json.RawMessage, allowed map[string]struct{}, allowJS bool) error { - return validateMuxTraceConfigDepth(raw, allowed, allowJS, 1) -} - -func validateMuxTraceConfigDepth(raw json.RawMessage, allowed map[string]struct{}, allowJS bool, depth int) error { +func validateMuxTraceConfig(raw json.RawMessage, allowed map[string]struct{}, allowJS bool, depth int) error { if len(raw) == 0 { return nil } @@ -293,8 +294,15 @@ func validateMuxTraceConfigDepth(raw json.RawMessage, allowed map[string]struct{ return err } for name, cfg := range nested { - if name == "" { - return fmt.Errorf("nested debug tracer name must not be empty") + if strings.TrimSpace(name) == "" { + return errors.New("nested debug tracer name must not be empty") + } + // Nested names live inside caller-supplied JSON that is forwarded to + // geth verbatim, so a padded name cannot be rewritten the way the + // top-level tracer is. Reject it instead of validating a name geth + // would resolve differently. + if name != strings.TrimSpace(name) { + return fmt.Errorf("nested debug tracer name %q must not have leading or trailing whitespace", name) } if _, ok := allowed[name]; !ok { if allowJS && !evmrpcconfig.IsNativeTraceTracer(name) { @@ -306,7 +314,7 @@ func validateMuxTraceConfigDepth(raw json.RawMessage, allowed map[string]struct{ return fmt.Errorf("nested debug tracer %q is not allowed; JavaScript tracers are disabled", name) } if name == muxTracerName { - if err := validateMuxTraceConfigDepth(cfg, allowed, allowJS, depth+1); err != nil { + if err := validateMuxTraceConfig(cfg, allowed, allowJS, depth+1); err != nil { return err } } diff --git a/evmrpc/tracers_allowlist_test.go b/evmrpc/tracers_allowlist_test.go index 38e6764d8e..f74b373791 100644 --- a/evmrpc/tracers_allowlist_test.go +++ b/evmrpc/tracers_allowlist_test.go @@ -6,9 +6,21 @@ import ( "testing" "github.com/ethereum/go-ethereum/eth/tracers" + evmrpcconfig "github.com/sei-protocol/sei-chain/evmrpc/config" "github.com/stretchr/testify/require" ) +// TestDefaultAllowedTracersAreNativeInGethRegistry pins the hardcoded native +// tracer list to the actual go-ethereum registry (whose init()s this package +// imports): every default-allowlisted name must be registered and non-JS, so a +// geth upgrade that changes the registry fails CI instead of silently +// weakening the JS-tracer gate. +func TestDefaultAllowedTracersAreNativeInGethRegistry(t *testing.T) { + for _, name := range evmrpcconfig.DefaultTraceAllowedTracers() { + require.False(t, tracers.DefaultDirectory.IsJS(name), "tracer %q must be registered as a native (non-JS) tracer in go-ethereum", name) + } +} + func TestValidateTraceTracerAllowlist(t *testing.T) { api := &DebugAPI{ allowedTracers: buildAllowedTracerSet([]string{ @@ -24,6 +36,11 @@ func TestValidateTraceTracerAllowlist(t *testing.T) { name := callTracerName require.NoError(t, api.validateTraceTracer(&tracers.TraceConfig{Tracer: &name})) + name = " callTracer " + paddedCfg := &tracers.TraceConfig{Tracer: &name} + require.NoError(t, api.validateTraceTracer(paddedCfg)) + require.Equal(t, callTracerName, *paddedCfg.Tracer, "validated name must be written back trimmed so geth resolves the native tracer") + name = flatCallTracerName require.ErrorContains(t, api.validateTraceTracer(&tracers.TraceConfig{Tracer: &name}), "is not allowed") @@ -85,6 +102,14 @@ func TestValidateMuxTraceConfig(t *testing.T) { }) require.ErrorContains(t, err, "nested debug tracer") require.ErrorContains(t, err, "badTracer") + + // Nested names are forwarded to geth inside the raw JSON, so a padded + // native name cannot be rewritten and must be rejected outright. + err = api.validateTraceTracer(&tracers.TraceConfig{ + Tracer: &name, + TracerConfig: json.RawMessage(`{" callTracer":{}}`), + }) + require.ErrorContains(t, err, "must not have leading or trailing whitespace") } func TestValidateMuxTraceConfigRejectsDeepNesting(t *testing.T) { From 10080bd329a89e25afcda72e75742bb0e6118d6f Mon Sep 17 00:00:00 2001 From: "Masih H. Derkani" Date: Mon, 20 Jul 2026 12:58:30 +0100 Subject: [PATCH 4/7] Resolve conflict --- evmrpc/config/config_test.go | 25 +++++++++++++++++++++++++ evmrpc/tracers.go | 3 +++ 2 files changed, 28 insertions(+) diff --git a/evmrpc/config/config_test.go b/evmrpc/config/config_test.go index 9da73d5ebe..1ea6128558 100644 --- a/evmrpc/config/config_test.go +++ b/evmrpc/config/config_test.go @@ -52,6 +52,8 @@ type opts struct { traceAllowedTracers interface{} traceAllowJSTracers interface{} traceBakeTracers interface{} + maxStateOverrideAccounts interface{} + maxStateOverrideSlots interface{} } func (o *opts) Get(k string) interface{} { @@ -195,6 +197,12 @@ func (o *opts) Get(k string) interface{} { if k == "evm.trace_allow_js_tracers" { return o.traceAllowJSTracers } + if k == "evm.max_state_override_accounts" { + return o.maxStateOverrideAccounts + } + if k == "evm.max_state_override_slots" { + return o.maxStateOverrideSlots + } panic("unknown key") } @@ -244,6 +252,8 @@ func getDefaultOpts() opts { []string{"callTracer", "prestateTracer"}, false, nil, + 7, + 9, } } @@ -258,6 +268,11 @@ func TestReadConfig(t *testing.T) { require.False(t, cfg.TraceAllowJSTracers) // The shipped default (used when the operator supplies no value). require.Equal(t, uint64(32*1024*1024), config.DefaultConfig.MaxTraceStructLogBytes) + // State override caps: round-trip the supplied values, and assert shipped defaults. + require.Equal(t, 7, cfg.MaxStateOverrideAccounts) + require.Equal(t, 9, cfg.MaxStateOverrideSlots) + require.Equal(t, 100, config.DefaultConfig.MaxStateOverrideAccounts) + require.Equal(t, 1000, config.DefaultConfig.MaxStateOverrideSlots) badOpts := goodOpts badOpts.httpEnabled = "bad" _, err = config.ReadConfig(&badOpts) @@ -374,6 +389,16 @@ func TestReadConfig(t *testing.T) { _, err = config.ReadConfig(&badOpts) require.NotNil(t, err) + badOpts = goodOpts + badOpts.maxStateOverrideAccounts = "bad" + _, err = config.ReadConfig(&badOpts) + require.NotNil(t, err) + + badOpts = goodOpts + badOpts.maxStateOverrideSlots = "bad" + _, err = config.ReadConfig(&badOpts) + require.NotNil(t, err) + // Test bad types for worker pool config badOpts = goodOpts badOpts.workerPoolSize = "bad" diff --git a/evmrpc/tracers.go b/evmrpc/tracers.go index 21054a7a85..b32e369a22 100644 --- a/evmrpc/tracers.go +++ b/evmrpc/tracers.go @@ -626,6 +626,9 @@ func (api *DebugAPI) TraceCall(ctx context.Context, args export.TransactionArgs, return nil, returnErr } + if returnErr = validateStateOverrides(config.StateOverrides, api.backend.MaxStateOverrideAccounts(), api.backend.MaxStateOverrideSlots()); returnErr != nil { + return nil, returnErr + } api.clampDefaultStructLogLimit(&config.TraceConfig) result, returnErr = api.tracersAPI.TraceCall(ctx, args, blockNrOrHash, config) return From a3f3cfe513043b6bf02eed700148c037864f6f1c Mon Sep 17 00:00:00 2001 From: "Masih H. Derkani" Date: Mon, 20 Jul 2026 13:00:36 +0100 Subject: [PATCH 5/7] Merge branch 'main' into masih/config-allowed-tracers --- .github/actions/go-lint-setup/action.yml | 14 - .github/scripts/ci-free-disk.sh | 36 - .../scripts/ci-relocate-docker-data-root.sh | 64 - .github/scripts/ci-relocate-go-cache.sh | 42 - .github/scripts/docker-registry-retry.sh | 27 - .github/workflows/cross-arch-build.yml | 42 +- .../workflows/integration-test-matrix.json | 2 +- .github/workflows/integration-test.yml | 63 +- .github/workflows/uci-release-publish.yml | 23 +- .gitignore | 2 +- .goreleaser.yaml | 3 - CHANGELOG.md | 8 - Makefile | 23 +- REVIEW.md | 63 - app/abci.go | 2 +- app/app.go | 192 +- app/mock_balances_buildtag.go | 20 - app/mock_balances_buildtag_default.go | 8 - app/mock_balances_buildtag_test.go | 22 - app/seidb.go | 54 +- app/seidb_test.go | 75 - app/setcode_authority_test.go | 96 - app/test_helpers.go | 9 +- cmd/seid/cmd/genaccounts.go | 4 - .../scripts/step4_config_override.sh | 50 +- .../dashboards/blocksim-dashboard.json | 3139 ----------------- evmrpc/server.go | 4 - evmrpc/setup_test.go | 2 +- evmrpc/simulate.go | 37 - evmrpc/simulate_test.go | 25 - evmrpc/state_override_validate_test.go | 44 - evmrpc/tracers.go | 3 - giga/deps/store/cachekv.go | 52 +- giga/deps/store/frozen_skip_test.go | 57 - giga/deps/xbank/keeper/keeper.go | 2 +- giga/deps/xevm/state/mock_balances.go | 6 +- giga/deps/xevm/state/state.go | 20 +- giga/executor/utils/errors.go | 19 +- giga/tests/giga_mock_balances_test.go | 32 - giga/tests/giga_test.go | 249 +- go.mod | 4 +- go.sum | 5 + inprocess/harness.go | 2 +- .../verify_cross_validator_flatkv_digest.sh | 4 +- .../contracts/verify_flatkv_crash_recovery.sh | 2 +- .../contracts/verify_flatkv_only_statesync.sh | 2 +- ...verify_flatkv_partial_loss_fails_loudly.sh | 2 +- .../verify_flatkv_statesync_crash_recovery.sh | 4 +- .../verify_flatkv_total_loss_recovery.sh | 2 +- .../verify_statesync_flatkv_digest.sh | 4 +- .../evm_module/rpc_io_test/RPC_IO_README.md | 2 +- .../scripts/evm_giga_mixed_tests.sh | 58 - ratelimiter/method_parser.go | 219 -- ratelimiter/method_parser_test.go | 287 -- scripts/boot-smoke.sh | 50 - scripts/build-static.sh | 28 +- scripts/rpc-sc-read-probe.sh | 1192 ------- sei-cosmos/server/config/config.go | 47 +- sei-cosmos/server/config/config_test.go | 91 +- sei-cosmos/server/start.go | 20 +- sei-cosmos/store/cachekv/frozen_skip_test.go | 241 -- sei-cosmos/store/cachekv/store.go | 101 +- sei-cosmos/store/cachemulti/store.go | 76 +- .../storev2/rootmulti/flatkv_helpers_test.go | 22 +- .../rootmulti/hash_consistency_test.go | 8 - sei-cosmos/storev2/rootmulti/store.go | 9 +- sei-cosmos/telemetry/metrics_test.go | 32 +- sei-cosmos/testutil/network/util.go | 1 + sei-cosmos/types/coin.go | 18 +- sei-cosmos/types/coin_test.go | 50 - sei-cosmos/types/dec_coin.go | 12 +- sei-cosmos/types/dec_coin_test.go | 12 - sei-cosmos/types/decimal.go | 13 +- sei-cosmos/types/decimal_internal_test.go | 39 - sei-cosmos/types/decimal_test.go | 25 - sei-cosmos/x/auth/vesting/client/cli/tx.go | 5 +- .../auth/vesting/client/testutil/cli_test.go | 2 +- .../x/auth/vesting/client/testutil/suite.go | 24 +- sei-cosmos/x/auth/vesting/handler.go | 8 +- sei-cosmos/x/auth/vesting/handler_test.go | 112 +- sei-cosmos/x/auth/vesting/metrics.go | 33 + sei-cosmos/x/auth/vesting/module.go | 19 +- sei-cosmos/x/auth/vesting/msg_server.go | 82 +- sei-cosmos/x/auth/vesting/types/errors.go | 11 - .../x/auth/vesting/types/expected_keepers.go | 6 - sei-cosmos/x/bank/keeper/keeper.go | 2 +- sei-cosmos/x/distribution/keeper/hooks.go | 21 +- .../x/distribution/keeper/keeper_test.go | 79 - sei-cosmos/x/feegrant/basic_fee.go | 7 - sei-cosmos/x/feegrant/basic_fee_test.go | 60 - sei-cosmos/x/feegrant/errors.go | 2 - sei-cosmos/x/feegrant/periodic_fee.go | 6 - sei-db/common/keys/evm.go | 18 +- sei-db/common/keys/evm_test.go | 16 +- sei-db/config/toml.go | 25 +- sei-db/config/toml_test.go | 9 +- sei-db/db_engine/litt/README.md | 19 +- .../litt/benchmark/config/benchmark_config.go | 3 - .../db_engine/litt/disktable/control_loop.go | 63 +- sei-db/db_engine/litt/disktable/disk_table.go | 12 +- sei-db/db_engine/litt/disktable/flush_loop.go | 6 +- .../litt/disktable/keymap_manager.go | 33 +- .../litt/disktable/keymap_manager_test.go | 2 - .../litt/disktable/segment/rollback.go | 101 - .../litt/disktable/segment/segment.go | 35 +- .../litt/disktable/segment/segment_test.go | 16 +- .../segment/shard_id_validation_test.go | 3 +- .../litt/disktable/segment_rollover_test.go | 177 - .../db_engine/litt/littbuilder/build_utils.go | 18 +- sei-db/db_engine/litt/littdb_config.go | 88 +- sei-db/db_engine/litt/rollback/rollback.go | 320 -- .../db_engine/litt/rollback/rollback_test.go | 312 -- sei-db/db_engine/litt/table.go | 22 +- sei-db/ledger_db/block/block_db_test.go | 495 +-- .../block/blocksim/block_generator.go | 125 +- sei-db/ledger_db/block/blocksim/blocksim.go | 33 +- .../block/blocksim/blocksim_config.go | 33 +- .../block/blocksim/blocksim_metrics.go | 42 +- .../block/blocksim/config/debug.json | 6 +- .../block/blocksim/config/standard.json | 7 - .../ledger_db/block/blocksim/resume_test.go | 19 +- sei-db/ledger_db/block/littblock/codec.go | 66 +- .../ledger_db/block/littblock/codec_test.go | 35 +- .../block/littblock/litt_block_db.go | 168 +- .../block/littblock/litt_block_iterator.go | 43 +- .../littblock/litt_block_stranding_test.go | 346 -- .../ledger_db/block/memblock/mem_block_db.go | 68 +- sei-db/seiwal/metrics.go | 96 - sei-db/seiwal/seiwal.go | 130 - sei-db/seiwal/seiwal_config.go | 88 - sei-db/seiwal/seiwal_config_test.go | 40 - sei-db/seiwal/seiwal_file.go | 583 --- sei-db/seiwal/seiwal_file_test.go | 185 - sei-db/seiwal/seiwal_impl.go | 809 ----- sei-db/seiwal/seiwal_impl_test.go | 1344 ------- sei-db/seiwal/seiwal_iterator.go | 316 -- sei-db/seiwal/seiwal_iterator_test.go | 485 --- sei-db/seiwal/seiwal_lock.go | 48 - sei-db/seiwal/seiwal_lock_test.go | 96 - sei-db/seiwal/seiwal_offline.go | 136 - sei-db/seiwal/seiwal_serializing.go | 412 --- sei-db/seiwal/seiwal_serializing_test.go | 322 -- sei-db/state_db/bench/cryptosim/util.go | 2 +- .../bench/wrappers/db_implementations.go | 6 +- .../state_db/bench/wrappers/wrappers_test.go | 13 - .../composite/random_test_framework_test.go | 6 +- sei-db/state_db/sc/composite/store.go | 45 +- sei-db/state_db/sc/composite/store_test.go | 66 - sei-db/state_db/sc/flatkv/api.go | 8 +- sei-db/state_db/sc/flatkv/config/config.go | 34 +- .../state_db/sc/flatkv/config/config_test.go | 4 +- .../sc/flatkv/config/flatkv_test_config.go | 5 +- sei-db/state_db/sc/flatkv/hashlog_test.go | 2 +- .../state_db/sc/flatkv/import_export_test.go | 18 +- .../state_db/sc/flatkv/import_translator.go | 16 +- .../sc/flatkv/import_translator_test.go | 8 +- sei-db/state_db/sc/flatkv/importer.go | 61 +- sei-db/state_db/sc/flatkv/importer_test.go | 23 - sei-db/state_db/sc/flatkv/ktype/ktype.go | 6 +- sei-db/state_db/sc/flatkv/ktype/meta.go | 80 +- .../state_db/sc/flatkv/ktype/metakey_test.go | 42 - .../sc/flatkv/lthash/hash_calculator.go | 444 --- sei-db/state_db/sc/flatkv/lthash/stats.go | 50 - .../state_db/sc/flatkv/lthash/stats_test.go | 124 - .../sc/flatkv/lthash_correctness_test.go | 59 +- .../state_db/sc/flatkv/perdb_lthash_test.go | 16 +- .../sc/flatkv/permodule_lthash_test.go | 354 -- .../sc/flatkv/permodule_stats_test.go | 255 -- sei-db/state_db/sc/flatkv/snapshot.go | 8 +- sei-db/state_db/sc/flatkv/snapshot_test.go | 2 +- sei-db/state_db/sc/flatkv/store.go | 148 +- sei-db/state_db/sc/flatkv/store_apply.go | 284 +- sei-db/state_db/sc/flatkv/store_catchup.go | 2 +- sei-db/state_db/sc/flatkv/store_iteration.go | 32 +- .../sc/flatkv/store_iteration_test.go | 90 +- sei-db/state_db/sc/flatkv/store_lifecycle.go | 20 +- sei-db/state_db/sc/flatkv/store_meta.go | 191 +- sei-db/state_db/sc/flatkv/store_meta_test.go | 119 - sei-db/state_db/sc/flatkv/store_read.go | 24 +- sei-db/state_db/sc/flatkv/store_read_test.go | 54 +- sei-db/state_db/sc/flatkv/store_test.go | 32 +- sei-db/state_db/sc/flatkv/store_write.go | 235 +- sei-db/state_db/sc/flatkv/store_write_test.go | 106 +- sei-db/state_db/sc/flatkv/verify.go | 201 +- sei-db/state_db/sc/flatkv/verify_test.go | 67 - .../state_db/sc/flatkv/vtype/legacy_data.go | 153 + .../sc/flatkv/vtype/legacy_data_test.go | 233 ++ sei-db/state_db/sc/flatkv/vtype/misc_data.go | 153 - .../sc/flatkv/vtype/misc_data_test.go | 233 -- .../{misc_data_v0.hex => legacy_data_v0.hex} | 0 sei-db/state_db/sc/memiavl/config.go | 11 +- sei-db/state_db/sc/memiavl/config_test.go | 31 - sei-db/state_db/sc/memiavl/db_test.go | 12 +- sei-db/state_db/sc/memiavl/opts.go | 3 - .../migration_iterator_paired_test.go | 15 +- .../sc/migration/migration_manager.go | 15 +- .../migration_test_framework_test.go | 2 +- .../state_db/sc/migration/router_builder.go | 7 - sei-db/state_db/ss/composite/store.go | 6 +- sei-db/state_db/ss/composite/store_test.go | 4 +- sei-db/state_db/ss/evm/config_test.go | 4 +- sei-db/state_db/ss/evm/db_test.go | 6 +- sei-db/state_db/ss/evm/types.go | 6 +- sei-db/state_db/statewal/state_wal.go | 95 - .../state_db/statewal/state_wal_brick_test.go | 163 - sei-db/state_db/statewal/state_wal_config.go | 78 - .../statewal/state_wal_config_test.go | 40 - sei-db/state_db/statewal/state_wal_impl.go | 281 -- .../state_db/statewal/state_wal_impl_test.go | 349 -- .../statewal/state_wal_iterator_test.go | 159 - .../statewal/state_wal_serialization.go | 77 - .../statewal/state_wal_serialization_test.go | 79 - .../tools/cmd/seidb/operations/dump_flatkv.go | 10 +- .../cmd/seidb/operations/dump_flatkv_test.go | 4 +- .../seidb/operations/evm_logical_digest.go | 802 ++--- .../operations/evm_logical_digest_test.go | 88 +- .../cmd/seidb/operations/flatkv_state_size.go | 10 +- .../operations/flatkv_state_size_test.go | 26 +- .../operations/import_flatkv_from_memiavl.go | 2 +- .../import_flatkv_from_memiavl_test.go | 10 +- .../tools/cmd/seidb/operations/state_size.go | 2 +- .../07-tendermint/types/header.go | 19 +- .../07-tendermint/types/misbehaviour.go | 2 +- .../types/misbehaviour_handle.go | 2 +- .../07-tendermint/types/update.go | 6 +- sei-ibc-go/testing/simapp/app.go | 2 +- sei-tendermint/AGENTS.md | 10 - .../abci/example/kvstore/kvstore.go | 2 +- sei-tendermint/autobahn/types/app_proposal.go | 24 +- sei-tendermint/autobahn/types/block.go | 14 - sei-tendermint/autobahn/types/block_db.go | 98 +- sei-tendermint/autobahn/types/commit_qc.go | 25 +- sei-tendermint/autobahn/types/committee.go | 31 +- .../autobahn/types/committee_test.go | 119 +- sei-tendermint/autobahn/types/epoch.go | 51 - sei-tendermint/autobahn/types/opt.go | 10 + sei-tendermint/autobahn/types/prepare_qc.go | 9 +- sei-tendermint/autobahn/types/proposal.go | 222 +- .../autobahn/types/proposal_test.go | 429 +-- sei-tendermint/autobahn/types/testonly.go | 163 +- sei-tendermint/autobahn/types/timeout.go | 49 +- sei-tendermint/autobahn/types/types_test.go | 55 +- .../autobahn/types/wireguard_test.go | 5 +- .../cmd/tendermint/commands/reindex_event.go | 6 +- sei-tendermint/config/config.go | 11 +- sei-tendermint/config/testonly.go | 10 - sei-tendermint/config/toml.go | 5 + .../internal/autobahn/autobahn.proto | 4 - .../internal/autobahn/avail/block_votes.go | 4 +- .../internal/autobahn/avail/conv_test.go | 17 +- .../internal/autobahn/avail/inner.go | 24 +- .../internal/autobahn/avail/inner_test.go | 181 +- .../autobahn/avail/metrics/metrics.gen.go | 87 - .../autobahn/avail/metrics/metrics.go | 78 - .../internal/autobahn/avail/state.go | 119 +- .../internal/autobahn/avail/state_test.go | 183 +- .../internal/autobahn/consensus/inner.go | 41 +- .../internal/autobahn/consensus/inner_test.go | 212 +- .../consensus/persist/commitqcs_test.go | 43 +- .../consensus/persist/fullcommitqcs.go | 27 +- .../consensus/persist/fullcommitqcs_test.go | 181 +- .../consensus/persist/globalblocks.go | 19 +- .../consensus/persist/globalblocks_test.go | 113 +- .../autobahn/consensus/persisted_inner.go | 24 +- .../internal/autobahn/consensus/state.go | 30 +- .../internal/autobahn/consensus/state_test.go | 39 +- .../internal/autobahn/data/metrics.go | 59 + .../autobahn/data/metrics/metrics.gen.go | 32 - .../internal/autobahn/data/metrics/metrics.go | 39 - .../internal/autobahn/data/state.go | 129 +- .../internal/autobahn/data/state_test.go | 318 +- .../internal/autobahn/data/testonly.go | 65 +- .../internal/autobahn/epoch/registry.go | 83 - .../internal/autobahn/epoch/registry_test.go | 39 - .../internal/autobahn/epoch/testonly.go | 23 - .../internal/autobahn/pb/autobahn.pb.go | 63 +- .../autobahn/pb/autobahn.wireguard.go | 35 +- .../autobahn/producer/mempool_test.go | 14 +- sei-tendermint/internal/blocksync/reactor.go | 5 +- .../internal/blocksync/reactor_test.go | 7 +- .../reactor_validation_failure_test.go | 7 +- .../internal/consensus/byzantine_test.go | 4 +- .../internal/consensus/common_test.go | 8 +- .../internal/consensus/mempool_test.go | 10 +- .../internal/consensus/metrics.gen.go | 559 ++- sei-tendermint/internal/consensus/metrics.go | 156 +- .../internal/consensus/pbts_test.go | 2 +- sei-tendermint/internal/consensus/reactor.go | 17 +- .../internal/consensus/reactor_test.go | 10 +- sei-tendermint/internal/consensus/replay.go | 6 +- .../internal/consensus/replay_stubs.go | 2 +- .../internal/consensus/replay_test.go | 33 +- sei-tendermint/internal/consensus/state.go | 121 +- .../state_badproposal_default_test.go | 8 +- .../consensus/state_empty_valset_test.go | 76 - .../internal/consensus/state_test.go | 240 +- .../consensus/types/height_vote_set_test.go | 2 +- .../internal/consensus/types/round_state.go | 41 +- .../consensus/types/round_state_test.go | 19 - sei-tendermint/internal/eventlog/eventlog.go | 8 + .../internal/eventlog/metrics.gen.go | 31 +- sei-tendermint/internal/eventlog/metrics.go | 10 +- sei-tendermint/internal/eventlog/prune.go | 4 +- .../internal/evidence/metrics.gen.go | 31 +- sei-tendermint/internal/evidence/metrics.go | 8 +- sei-tendermint/internal/evidence/pool.go | 11 +- sei-tendermint/internal/evidence/pool_test.go | 12 +- .../internal/evidence/reactor_test.go | 2 +- .../internal/evidence/verify_test.go | 18 +- sei-tendermint/internal/mempool/mempool.go | 63 +- .../internal/mempool/mempool_bench_test.go | 2 +- .../internal/mempool/mempool_test.go | 38 +- .../internal/mempool/metrics.gen.go | 286 +- sei-tendermint/internal/mempool/metrics.go | 60 +- .../internal/mempool/reactor/reactor_test.go | 6 +- .../internal/mempool/recheck_drain_test.go | 6 +- sei-tendermint/internal/mempool/tx.go | 28 +- sei-tendermint/internal/mempool/tx_test.go | 20 +- sei-tendermint/internal/p2p/channel.go | 6 +- sei-tendermint/internal/p2p/giga/api.go | 18 +- .../internal/p2p/giga/avail_test.go | 8 +- .../internal/p2p/giga/consensus_test.go | 11 +- sei-tendermint/internal/p2p/giga/data_test.go | 22 +- .../internal/p2p/giga/pb/api.wireguard.go | 4 +- .../internal/p2p/giga_router_common.go | 31 +- .../internal/p2p/giga_router_fullnode_test.go | 4 +- .../internal/p2p/giga_router_validator.go | 2 +- .../p2p/giga_router_validator_test.go | 7 +- sei-tendermint/internal/p2p/metrics.gen.go | 142 +- sei-tendermint/internal/p2p/metrics.go | 40 +- .../internal/p2p/mux/metrics/metrics.gen.go | 87 - .../internal/p2p/mux/metrics/metrics.go | 84 - sei-tendermint/internal/p2p/mux/mux.go | 23 +- sei-tendermint/internal/p2p/mux/stream.go | 4 - .../internal/p2p/mux/stream_state.go | 12 +- sei-tendermint/internal/p2p/router.go | 11 +- sei-tendermint/internal/p2p/router_test.go | 3 + sei-tendermint/internal/p2p/rpc/rpc.go | 25 +- sei-tendermint/internal/p2p/testonly.go | 1 + sei-tendermint/internal/p2p/transport.go | 13 +- sei-tendermint/internal/proxy/metrics.gen.go | 34 +- sei-tendermint/internal/proxy/metrics.go | 6 +- sei-tendermint/internal/proxy/proxy.go | 39 +- sei-tendermint/internal/proxy/proxy_test.go | 8 +- sei-tendermint/internal/state/errors.go | 12 - sei-tendermint/internal/state/execution.go | 28 +- .../internal/state/execution_test.go | 89 +- sei-tendermint/internal/state/helpers_test.go | 2 +- .../internal/state/indexer/indexer_service.go | 14 +- .../internal/state/indexer/metrics.gen.go | 69 +- .../internal/state/indexer/metrics.go | 18 +- sei-tendermint/internal/state/metrics.gen.go | 183 +- sei-tendermint/internal/state/metrics.go | 29 +- sei-tendermint/internal/state/state.go | 12 - sei-tendermint/internal/state/state_test.go | 47 +- sei-tendermint/internal/state/store.go | 9 - sei-tendermint/internal/state/store_test.go | 25 - .../state/validation_header_default_test.go | 3 +- .../internal/state/validation_test.go | 6 +- .../internal/statesync/metrics.gen.go | 103 +- sei-tendermint/internal/statesync/metrics.go | 19 +- sei-tendermint/internal/statesync/reactor.go | 10 +- .../internal/statesync/reactor_test.go | 6 +- sei-tendermint/internal/statesync/syncer.go | 17 +- .../internal/test/factory/genesis.go | 6 +- .../libs/utils/prometheus/histogram.go | 132 - .../libs/utils/prometheus/histogram_test.go | 155 - .../libs/utils/prometheus/prometheus.go | 113 - .../libs/utils/prometheus/prometheus_test.go | 95 - sei-tendermint/light/example_test.go | 3 +- sei-tendermint/light/light_test.go | 7 +- sei-tendermint/node/node.go | 112 +- sei-tendermint/node/node_test.go | 15 +- sei-tendermint/node/public.go | 5 +- sei-tendermint/node/seed.go | 4 +- sei-tendermint/node/setup.go | 5 +- sei-tendermint/rpc/client/rpc_test.go | 2 +- .../rpc/jsonrpc/client/integration_test.go | 1 + sei-tendermint/rpc/test/helpers.go | 1 + .../scripts/metricsgen/metricsgen.go | 435 +-- .../scripts/metricsgen/metricsgen_test.go | 330 +- .../metricsgen/testdata/basic/metrics.gen.go | 30 +- .../metricsgen/testdata/basic/metrics.go | 9 +- .../testdata/commented/metrics.gen.go | 30 +- .../metricsgen/testdata/commented/metrics.go | 9 +- .../metricsgen/testdata/tags/metrics.gen.go | 84 +- .../metricsgen/testdata/tags/metrics.go | 19 +- sei-tendermint/test/e2e/node/main.go | 3 +- .../test/fuzz/tests/mempool_test.go | 2 +- sei-tendermint/types/light.go | 7 - sei-tendermint/types/validator_set.go | 9 - sei-tendermint/types/validator_set_test.go | 31 +- sei-wasmd/app/app.go | 2 +- third_party/alpine-gcc10-libgcc/README.md | 41 - third_party/alpine-gcc10-libgcc/libgcc.a | Bin 3030162 -> 0 bytes third_party/alpine-gcc10-libgcc/libgcc_eh.a | Bin 405740 -> 0 bytes utils/helpers/address.go | 81 - utils/helpers/address_test.go | 65 - x/evm/ante/preprocess.go | 43 - x/evm/ante/preprocess_test.go | 131 - x/evm/module_test.go | 4 +- x/evm/state/journal.go | 29 - x/evm/state/mock_balances.go | 6 +- x/evm/state/state.go | 74 +- x/evm/state/state_test.go | 109 - x/evm/state/statedb.go | 30 - 406 files changed, 5090 insertions(+), 26409 deletions(-) delete mode 100644 .github/actions/go-lint-setup/action.yml delete mode 100755 .github/scripts/ci-free-disk.sh delete mode 100755 .github/scripts/ci-relocate-docker-data-root.sh delete mode 100755 .github/scripts/ci-relocate-go-cache.sh delete mode 100644 .github/scripts/docker-registry-retry.sh delete mode 100644 REVIEW.md delete mode 100644 app/mock_balances_buildtag_test.go delete mode 100644 app/setcode_authority_test.go delete mode 100644 docker/monitornode/dashboards/blocksim-dashboard.json delete mode 100644 evmrpc/state_override_validate_test.go delete mode 100644 giga/deps/store/frozen_skip_test.go delete mode 100644 giga/tests/giga_mock_balances_test.go delete mode 100644 ratelimiter/method_parser.go delete mode 100644 ratelimiter/method_parser_test.go delete mode 100755 scripts/boot-smoke.sh delete mode 100755 scripts/rpc-sc-read-probe.sh delete mode 100644 sei-cosmos/store/cachekv/frozen_skip_test.go create mode 100644 sei-cosmos/x/auth/vesting/metrics.go delete mode 100644 sei-cosmos/x/auth/vesting/types/errors.go delete mode 100644 sei-db/db_engine/litt/disktable/segment/rollback.go delete mode 100644 sei-db/db_engine/litt/disktable/segment_rollover_test.go delete mode 100644 sei-db/db_engine/litt/rollback/rollback.go delete mode 100644 sei-db/db_engine/litt/rollback/rollback_test.go delete mode 100644 sei-db/ledger_db/block/blocksim/config/standard.json delete mode 100644 sei-db/ledger_db/block/littblock/litt_block_stranding_test.go delete mode 100644 sei-db/seiwal/metrics.go delete mode 100644 sei-db/seiwal/seiwal.go delete mode 100644 sei-db/seiwal/seiwal_config.go delete mode 100644 sei-db/seiwal/seiwal_config_test.go delete mode 100644 sei-db/seiwal/seiwal_file.go delete mode 100644 sei-db/seiwal/seiwal_file_test.go delete mode 100644 sei-db/seiwal/seiwal_impl.go delete mode 100644 sei-db/seiwal/seiwal_impl_test.go delete mode 100644 sei-db/seiwal/seiwal_iterator.go delete mode 100644 sei-db/seiwal/seiwal_iterator_test.go delete mode 100644 sei-db/seiwal/seiwal_lock.go delete mode 100644 sei-db/seiwal/seiwal_lock_test.go delete mode 100644 sei-db/seiwal/seiwal_offline.go delete mode 100644 sei-db/seiwal/seiwal_serializing.go delete mode 100644 sei-db/seiwal/seiwal_serializing_test.go delete mode 100644 sei-db/state_db/sc/flatkv/lthash/hash_calculator.go delete mode 100644 sei-db/state_db/sc/flatkv/lthash/stats.go delete mode 100644 sei-db/state_db/sc/flatkv/lthash/stats_test.go delete mode 100644 sei-db/state_db/sc/flatkv/permodule_lthash_test.go delete mode 100644 sei-db/state_db/sc/flatkv/permodule_stats_test.go delete mode 100644 sei-db/state_db/sc/flatkv/verify_test.go create mode 100644 sei-db/state_db/sc/flatkv/vtype/legacy_data.go create mode 100644 sei-db/state_db/sc/flatkv/vtype/legacy_data_test.go delete mode 100644 sei-db/state_db/sc/flatkv/vtype/misc_data.go delete mode 100644 sei-db/state_db/sc/flatkv/vtype/misc_data_test.go rename sei-db/state_db/sc/flatkv/vtype/testdata/{misc_data_v0.hex => legacy_data_v0.hex} (100%) delete mode 100644 sei-db/state_db/sc/memiavl/config_test.go delete mode 100644 sei-db/state_db/statewal/state_wal.go delete mode 100644 sei-db/state_db/statewal/state_wal_brick_test.go delete mode 100644 sei-db/state_db/statewal/state_wal_config.go delete mode 100644 sei-db/state_db/statewal/state_wal_config_test.go delete mode 100644 sei-db/state_db/statewal/state_wal_impl.go delete mode 100644 sei-db/state_db/statewal/state_wal_impl_test.go delete mode 100644 sei-db/state_db/statewal/state_wal_iterator_test.go delete mode 100644 sei-db/state_db/statewal/state_wal_serialization.go delete mode 100644 sei-db/state_db/statewal/state_wal_serialization_test.go delete mode 100644 sei-tendermint/autobahn/types/epoch.go delete mode 100644 sei-tendermint/config/testonly.go delete mode 100644 sei-tendermint/internal/autobahn/avail/metrics/metrics.gen.go delete mode 100644 sei-tendermint/internal/autobahn/avail/metrics/metrics.go create mode 100644 sei-tendermint/internal/autobahn/data/metrics.go delete mode 100644 sei-tendermint/internal/autobahn/data/metrics/metrics.gen.go delete mode 100644 sei-tendermint/internal/autobahn/data/metrics/metrics.go delete mode 100644 sei-tendermint/internal/autobahn/epoch/registry.go delete mode 100644 sei-tendermint/internal/autobahn/epoch/registry_test.go delete mode 100644 sei-tendermint/internal/autobahn/epoch/testonly.go delete mode 100644 sei-tendermint/internal/consensus/state_empty_valset_test.go delete mode 100644 sei-tendermint/internal/p2p/mux/metrics/metrics.gen.go delete mode 100644 sei-tendermint/internal/p2p/mux/metrics/metrics.go delete mode 100644 sei-tendermint/libs/utils/prometheus/histogram.go delete mode 100644 sei-tendermint/libs/utils/prometheus/histogram_test.go delete mode 100644 sei-tendermint/libs/utils/prometheus/prometheus.go delete mode 100644 sei-tendermint/libs/utils/prometheus/prometheus_test.go delete mode 100644 third_party/alpine-gcc10-libgcc/README.md delete mode 100644 third_party/alpine-gcc10-libgcc/libgcc.a delete mode 100644 third_party/alpine-gcc10-libgcc/libgcc_eh.a diff --git a/.github/actions/go-lint-setup/action.yml b/.github/actions/go-lint-setup/action.yml deleted file mode 100644 index 2dd40272ce..0000000000 --- a/.github/actions/go-lint-setup/action.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: 'Go lint repo-specific setup' -description: >- - Sei-chain setup for the shared uci go-lint workflow: reclaim runner disk so - `go vet ./...` over the full tree does not exhaust the root filesystem and - crash the runner. Picked up automatically by sei-protocol/uci go-lint.yml's - repo-specific setup hook. -runs: - using: composite - steps: - # Reclaim only. This hook runs after uci's setup-go, which already restored - # the Go cache to '/'; relocating it to /mnt here would force a cold vet. - - name: Reclaim runner disk - shell: bash - run: bash ./.github/scripts/ci-free-disk.sh diff --git a/.github/scripts/ci-free-disk.sh b/.github/scripts/ci-free-disk.sh deleted file mode 100755 index 09eea1fa01..0000000000 --- a/.github/scripts/ci-free-disk.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash -# -# Reclaim disk on GitHub-hosted runners before disk-heavy CI work. -# -# Root cause: the integration and lint jobs exhaust the runner's root -# filesystem. When '/' fills, the Actions runner *worker process itself* crashes -# with "No space left on device" mid-step. -# -# Safe on any ubuntu-* runner: every removal is guarded so a change to the -# runner image layout can never fail the job. -set -euo pipefail - -echo "::group::Disk usage before reclaim" -df -h / /mnt 2>/dev/null || df -h / -echo "::endgroup::" - -# Large preinstalled bundles unused by Sei's Go/Docker/Node CI. -junk=( - /usr/share/dotnet - /usr/local/lib/android - /opt/ghc - /usr/local/.ghcup - /opt/hostedtoolcache/CodeQL - /usr/local/share/boost - /usr/share/swift -) -for dir in "${junk[@]}"; do - if [ -d "$dir" ]; then - echo "Removing $dir" - sudo rm -rf "$dir" || true - fi -done - -echo "::group::Disk usage after reclaim" -df -h / /mnt 2>/dev/null || df -h / -echo "::endgroup::" diff --git a/.github/scripts/ci-relocate-docker-data-root.sh b/.github/scripts/ci-relocate-docker-data-root.sh deleted file mode 100755 index e588a20e7c..0000000000 --- a/.github/scripts/ci-relocate-docker-data-root.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env bash -# -# Move Docker's data-root onto the runner's large ephemeral disk (/mnt) so that -# image layers and container writable layers (the running Sei cluster's state) -# accumulate off the small root filesystem. -# -# No-op (clean exit) when /mnt is unavailable or Docker is not present, so it is -# safe across runner classes. Must run BEFORE any docker pull/build so images -# land on /mnt from the start. -set -euo pipefail - -target=/mnt/docker - -if [ ! -d /mnt ] || ! sudo test -w /mnt; then - echo "/mnt is not available/writable; leaving Docker data-root on '/'." - exit 0 -fi -# GitHub larger runners (e.g. ubuntu-large) expose /mnt as a directory on the -# single OS disk, not a separate ephemeral volume. Relocating within the same -# filesystem frees no capacity and still costs a docker daemon restart, so skip -# it when /mnt and / are the same device. -if [ "$(stat -c '%d' /mnt 2>/dev/null)" = "$(stat -c '%d' / 2>/dev/null)" ]; then - echo "/mnt shares the root filesystem; leaving Docker data-root on '/'." - exit 0 -fi -if ! command -v docker >/dev/null 2>&1; then - echo "docker not installed; nothing to relocate." - exit 0 -fi - -echo "Relocating Docker data-root to ${target}" -sudo mkdir -p "${target}" /etc/docker - -# Merge into any existing daemon.json rather than clobbering it. python3 is -# preinstalled on all GitHub-hosted ubuntu images. -sudo python3 - "${target}" <<'PY' -import json, os, sys - -path = "/etc/docker/daemon.json" -cfg = {} -if os.path.exists(path): - try: - with open(path) as fh: - cfg = json.load(fh) or {} - except ValueError: - cfg = {} -cfg["data-root"] = sys.argv[1] -with open(path, "w") as fh: - json.dump(cfg, fh, indent=2) -print("daemon.json ->", cfg) -PY - -sudo systemctl restart docker - -# Wait for the daemon to accept connections again before the next step runs a -# docker command. -for _ in $(seq 1 30); do - if docker info >/dev/null 2>&1; then - break - fi - sleep 1 -done - -docker info --format 'Docker data-root is now: {{.DockerRootDir}}' diff --git a/.github/scripts/ci-relocate-go-cache.sh b/.github/scripts/ci-relocate-go-cache.sh deleted file mode 100755 index 6afb2fa36f..0000000000 --- a/.github/scripts/ci-relocate-go-cache.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bash -# -# Point the Go build/module/tmp caches at the runner's large ephemeral disk -# (/mnt) so that compiling the full tree — `go vet ./...` on the lint job, the -# in-container seid build on prepare-cluster — does not fill the small root -# filesystem. -# -# Writes the new locations to $GITHUB_ENV so every subsequent step inherits -# them. GOCACHE is the dominant consumer (the build cache); GOMODCACHE and -# GOTMPDIR are moved too. -# -# The Makefile derives its container/compose module-cache mount from -# `go env GOMODCACHE` (via GO_PKG_PATH), so relocating GOMODCACHE here also -# moves the in-container module cache to /mnt and keeps it aligned with the -# cache actions/setup-go restores. GOPATH is deliberately left untouched (it -# only affects binary/tool install paths we do not relocate). No-op when /mnt -# is unavailable. -set -euo pipefail - -if [ ! -d /mnt ] || ! sudo test -w /mnt; then - echo "/mnt is not available/writable; leaving Go caches on '/'." - exit 0 -fi -# GitHub larger runners (e.g. ubuntu-large) expose /mnt as a directory on the -# single OS disk, not a separate ephemeral volume. Relocating within the same -# filesystem frees no capacity, so skip it when /mnt and / are the same device. -if [ "$(stat -c '%d' /mnt 2>/dev/null)" = "$(stat -c '%d' / 2>/dev/null)" ]; then - echo "/mnt shares the root filesystem; leaving Go caches on '/'." - exit 0 -fi - -base=/mnt/go -sudo mkdir -p "${base}/pkg/mod" "${base}/cache" "${base}/tmp" -sudo chown -R "$(id -u):$(id -g)" "${base}" - -{ - echo "GOMODCACHE=${base}/pkg/mod" - echo "GOCACHE=${base}/cache" - echo "GOTMPDIR=${base}/tmp" -} >> "${GITHUB_ENV}" - -echo "Go caches relocated to ${base} (GOCACHE/GOMODCACHE/GOTMPDIR)." diff --git a/.github/scripts/docker-registry-retry.sh b/.github/scripts/docker-registry-retry.sh deleted file mode 100644 index 2cca35f553..0000000000 --- a/.github/scripts/docker-registry-retry.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env bash -# Retry helpers for transient GHCR/docker registry errors (e.g. "unknown blob"). - -_registry_retry() { - local op="$1" - local ref="$2" - local attempt - for attempt in 1 2 3 4 5; do - if docker "$op" "$ref"; then - return 0 - fi - if [ "$attempt" -lt 5 ]; then - echo "docker $op $ref failed (attempt $attempt), retrying in $((attempt * 5))s..." - sleep $((attempt * 5)) - fi - done - echo "docker $op $ref failed after 5 attempts" - return 1 -} - -push_with_retry() { - _registry_retry push "$1" -} - -pull_with_retry() { - _registry_retry pull "$1" -} diff --git a/.github/workflows/cross-arch-build.yml b/.github/workflows/cross-arch-build.yml index e93f9e7fbd..29327fce46 100644 --- a/.github/workflows/cross-arch-build.yml +++ b/.github/workflows/cross-arch-build.yml @@ -54,44 +54,16 @@ jobs: - name: Verify build binary run: ./build/seid version --long - # Detect PRs that change the static-build inputs so they run the full static - # job below even when targeting main; otherwise a change to the static build - # only gets exercised once it reaches a release branch. - static-paths: - name: "Static build inputs changed?" - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - outputs: - changed: ${{ steps.diff.outputs.changed }} - steps: - - id: diff - env: - GH_TOKEN: ${{ github.token }} - run: | - PATTERN='^(scripts/(build-static|boot-smoke|check-libwasmvm-static)\.sh|Makefile|third_party/alpine-gcc10-libgcc/|\.goreleaser\.yaml|\.github/workflows/cross-arch-build\.yml)' - FILES=$(gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" --paginate -q '.[].filename') - if echo "$FILES" | grep -qE "$PATTERN"; then - echo "changed=true" >> "$GITHUB_OUTPUT" - else - echo "changed=false" >> "$GITHUB_OUTPUT" - fi - echo "static-relevant files: $(echo "$FILES" | grep -cE "$PATTERN" || true)" - linux-amd64-static: name: "Linux AMD64 (static)" runs-on: ubuntu-latest - needs: [static-paths] - # The static seid is a release artefact: build it for release work (PRs into - # release/**, pushes to release/**, merge-queue entries targeting release/**) - # and for any PR that changes the static-build inputs themselves. The - # !cancelled() keeps this job eligible when static-paths is skipped (push and - # merge_group events). + # The static seid is a release artefact, so only build it for release work, + # not on every PR: PRs into release/**, pushes to release/**, or merge-queue + # entries targeting release/**. if: >- - !cancelled() && ( startsWith(github.base_ref, 'release/') || startsWith(github.ref, 'refs/heads/release/') || - startsWith(github.event.merge_group.base_ref, 'refs/heads/release/') || - needs.static-paths.outputs.changed == 'true' ) + startsWith(github.event.merge_group.base_ref, 'refs/heads/release/') steps: - name: Checkout code # See: https://github.com/actions/checkout/releases/tag/v7.0.0 @@ -121,12 +93,6 @@ jobs: - name: Smoke test run: ./build/seid version --long - # Repeated boots: the gcc>=12 unwind b-tree crash killed ~70% of boots at the - # genesis wasm store, so a single boot (let alone `seid version`, which never - # touches the wasm VM) can pass on luck. See scripts/boot-smoke.sh. - - name: Boot smoke (repeated) - run: bash scripts/boot-smoke.sh ./build/seid 8 - - name: Upload artifact for inspection uses: actions/upload-artifact@v5 with: diff --git a/.github/workflows/integration-test-matrix.json b/.github/workflows/integration-test-matrix.json index 949cc720f0..19cfbce804 100644 --- a/.github/workflows/integration-test-matrix.json +++ b/.github/workflows/integration-test-matrix.json @@ -236,7 +236,7 @@ }, { "name": "Autobahn RPC .io/.iox (spec fixtures)", - "env": "AUTOBAHN=true GIGA_EXECUTOR=true GIGA_OCC=false GIGA_STORAGE=true", + "env": "AUTOBAHN=true GIGA_EXECUTOR=true GIGA_STORAGE=true", "scripts": [ "./integration_test/evm_module/scripts/evm_rpc_tests.sh" ] diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 6c5c0d68c2..4c93d8d036 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -77,15 +77,6 @@ jobs: steps: # See: https://github.com/actions/checkout/releases/tag/v7.0.0 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - # Reclaim disk and push Docker + Go caches onto /mnt before the - # image builds so the runner's small root filesystem does not fill up and - # crash the runner mid-job. - - name: Reclaim runner disk - run: bash .github/scripts/ci-free-disk.sh - - name: Relocate Docker data-root to /mnt - run: bash .github/scripts/ci-relocate-docker-data-root.sh - - name: Relocate Go caches to /mnt - run: bash .github/scripts/ci-relocate-go-cache.sh - name: Login to Docker Hub # See: https://github.com/docker/login-action/releases/tag/v4.2.0 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee @@ -132,13 +123,6 @@ jobs: labels: sei-chain.ci-run-id=${{ github.run_id }} cache-from: type=registry,ref=${{ env.GHCR_RPCNODE }}:cache cache-to: ${{ github.event_name == 'push' && format('type=registry,ref={0}:cache,mode=max', env.GHCR_RPCNODE) || '' }} - # rpcnode is independent of the seid build, so push it now: push failures - # (retried below) surface before the expensive seid build rather than after. - - name: Push rpcnode image to GHCR for test jobs - run: | - source .github/scripts/docker-registry-retry.sh - docker tag sei-chain/rpcnode "${GHCR_RPCNODE}:${{ github.run_id }}" - push_with_retry "${GHCR_RPCNODE}:${{ github.run_id }}" - uses: actions/setup-go@v6 with: go-version: '1.25.6' @@ -146,11 +130,13 @@ jobs: env: DOCKER_PLATFORM: linux/amd64 run: make build-seid-in-localnode-ci - - name: Push localnode image to GHCR for test jobs + # Registry pulls are content-addressed and retried per layer by the docker client + - name: Push images to GHCR for test jobs run: | - source .github/scripts/docker-registry-retry.sh docker tag sei-chain/localnode "${GHCR_LOCALNODE}:${{ github.run_id }}" - push_with_retry "${GHCR_LOCALNODE}:${{ github.run_id }}" + docker tag sei-chain/rpcnode "${GHCR_RPCNODE}:${{ github.run_id }}" + docker push "${GHCR_LOCALNODE}:${{ github.run_id }}" + docker push "${GHCR_RPCNODE}:${{ github.run_id }}" - name: Package CI artifacts run: tar -czf integration-build.tar.gz build/seid - name: Upload integration CI artifacts @@ -189,12 +175,6 @@ jobs: steps: # See: https://github.com/actions/checkout/releases/tag/v7.0.0 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - - name: Reclaim runner disk - run: bash .github/scripts/ci-free-disk.sh - - name: Relocate Docker data-root to /mnt - run: bash .github/scripts/ci-relocate-docker-data-root.sh - - name: Relocate Go caches to /mnt - run: bash .github/scripts/ci-relocate-go-cache.sh - uses: actions/setup-node@v4 with: node-version: '22' @@ -229,17 +209,19 @@ jobs: - name: Load prebuilt seid and pull Docker images run: | tar -xzf integration-build.tar.gz - # The seid binary is now extracted to build/seid; drop the - # tarball so its copy does not sit on disk for the rest of the job. - rm -f integration-build.tar.gz - source .github/scripts/docker-registry-retry.sh + pull_with_retry() { + local ref="$1" + for attempt in 1 2 3 4 5; do + if docker pull "$ref"; then return 0; fi + echo "docker pull $ref failed (attempt $attempt), retrying in $((attempt*5))s..." + sleep $((attempt*5)) + done + echo "docker pull $ref failed after 5 attempts"; return 1 + } pull_with_retry "${GHCR_LOCALNODE}:${{ github.run_id }}" pull_with_retry "${GHCR_RPCNODE}:${{ github.run_id }}" docker tag "${GHCR_LOCALNODE}:${{ github.run_id }}" sei-chain/localnode docker tag "${GHCR_RPCNODE}:${{ github.run_id }}" sei-chain/rpcnode - # Drop the redundant run-id-tagged references now that the images are - # retagged as sei-chain/*; keeps a single tag per image. - docker image rm "${GHCR_LOCALNODE}:${{ github.run_id }}" "${GHCR_RPCNODE}:${{ github.run_id }}" || true - name: Start 4 node docker cluster run: DOCKER_DETACH=true INVARIANT_CHECK_INTERVAL=10 ${{matrix.test.env}} make docker-cluster-start-ci @@ -339,12 +321,6 @@ jobs: steps: # See: https://github.com/actions/checkout/releases/tag/v7.0.0 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - - name: Reclaim runner disk - run: bash .github/scripts/ci-free-disk.sh - - name: Relocate Docker data-root to /mnt - run: bash .github/scripts/ci-relocate-docker-data-root.sh - - name: Relocate Go caches to /mnt - run: bash .github/scripts/ci-relocate-go-cache.sh - uses: actions/setup-go@v6 with: go-version: '1.25.6' @@ -371,17 +347,10 @@ jobs: - name: Load prebuilt seid and pull Docker images run: | tar -xzf integration-build.tar.gz - # The seid binary is now extracted to build/seid; drop the - # tarball so its copy does not sit on disk for the rest of the job. - rm -f integration-build.tar.gz - source .github/scripts/docker-registry-retry.sh - pull_with_retry "${GHCR_LOCALNODE}:${{ github.run_id }}" - pull_with_retry "${GHCR_RPCNODE}:${{ github.run_id }}" + docker pull "${GHCR_LOCALNODE}:${{ github.run_id }}" + docker pull "${GHCR_RPCNODE}:${{ github.run_id }}" docker tag "${GHCR_LOCALNODE}:${{ github.run_id }}" sei-chain/localnode docker tag "${GHCR_RPCNODE}:${{ github.run_id }}" sei-chain/rpcnode - # Drop the redundant run-id-tagged references now that the images are - # retagged as sei-chain/*; keeps a single tag per image. - docker image rm "${GHCR_LOCALNODE}:${{ github.run_id }}" "${GHCR_RPCNODE}:${{ github.run_id }}" || true - name: Run autobahn integration tests run: make autobahn-integration-test - name: Print node logs on failure diff --git a/.github/workflows/uci-release-publish.yml b/.github/workflows/uci-release-publish.yml index 7cc9e2893c..a0a2ffa29d 100644 --- a/.github/workflows/uci-release-publish.yml +++ b/.github/workflows/uci-release-publish.yml @@ -3,11 +3,6 @@ run-name: UCI / Release Publish on: push: - # Note: `paths` filters do not apply to tag pushes, so this workflow also fires - # when a version tag is pushed manually. That is the normal release path here: - # the "Restrict tagging" ruleset blocks this workflow's token from creating tags, - # so the publish job fails on branch pushes and a maintainer pushes the tag by - # hand. The goreleaser job below handles that path explicitly. paths: [ 'version.json' ] workflow_dispatch: @@ -15,11 +10,8 @@ permissions: contents: write concurrency: - # Keyed per event+ref, never cancelled: a version.json branch push and the - # follow-up manual tag push share the same SHA, and an in-flight release build - # must not be cancelled by the later event. - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} - cancel-in-progress: false + group: ${{ github.workflow }}-${{ github.sha }} + cancel-in-progress: true jobs: publish: @@ -29,17 +21,10 @@ jobs: goreleaser: name: GoReleaser needs: publish - # Build and attach when this run created the release, or when a maintainer - # pushed a version tag by hand (release_created is false on that path because - # the tag already exists; publish then no-ops and this job attaches the - # binaries). Re-pushing an existing tag re-appends the goreleaser notes block - # to the release body, so treat this as run-once per tag. - if: >- - needs.publish.outputs.release_created == 'true' || - (github.ref_type == 'tag' && startsWith(github.ref_name, 'v')) + if: needs.publish.outputs.release_created == 'true' uses: sei-protocol/uci/.github/workflows/goreleaser-release.yml@v0.0.11 with: - ref: ${{ github.ref_type == 'tag' && github.ref_name || needs.publish.outputs.version }} + ref: ${{ needs.publish.outputs.version }} go-version: '1.25.6' # Repo submodules are Solidity test libs (forge-std/openzeppelin/solmate); the # seid binary build doesn't use them, so skip the fetch. diff --git a/.gitignore b/.gitignore index 30c6efe10c..c259bafa2d 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,4 @@ sei-db/state_db/bench/cryptosim/data/** sei-db/state_db/bench/cryptosim/bin/ sei-db/state_db/bench/cryptosim/logs/ sei-db/ledger_db/block/blocksim/bin/ -sei-db/db_engine/litt/bin/ +sei-db/db_engine/litt/bin/ \ No newline at end of file diff --git a/.goreleaser.yaml b/.goreleaser.yaml index f6f14ebc59..9eb7682cf2 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -7,9 +7,6 @@ before: - go mod download # build-static.sh self-verifies (libwasmvm archives present + output is static). - bash scripts/build-static.sh - # Boot the binary before packaging: catches crash-at-first-wasm-use defects that - # neither a successful link nor `seid version` can (see scripts/boot-smoke.sh). - - bash scripts/boot-smoke.sh build/seid 4 builds: # The static seid is built in Alpine (musl-native) by the `before:` hook above diff --git a/CHANGELOG.md b/CHANGELOG.md index befb7bfd86..b334168599 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,14 +29,6 @@ Ref: https://keepachangelog.com/en/1.0.0/ # Changelog ## v6.6 sei-chain -* [#3743](https://github.com/sei-protocol/sei-chain/pull/3743) Backport `release/v6.6`: feat(cosmos): range-check Dec conversions and DecCoin validation (CON-369) -* [#3732](https://github.com/sei-protocol/sei-chain/pull/3732) Bump version in prep to release v6.6-rc2 -* [#3731](https://github.com/sei-protocol/sei-chain/pull/3731) Backport `release/v6.6`: Update v6.6 changelog in prep to cut RC2 -* [#3717](https://github.com/sei-protocol/sei-chain/pull/3717) Backport `release/v6.6`: Accept legacy cosmos_only SC write mode -* [#3697](https://github.com/sei-protocol/sei-chain/pull/3697) Backport `release/v6.6`: Fix rpc hash race condition -* [#3694](https://github.com/sei-protocol/sei-chain/pull/3694) Backport `release/v6.6`: Add GoReleaser release pipeline for static seid binaries (#3425) -* [#3688](https://github.com/sei-protocol/sei-chain/pull/3688) Bump version file to v6.6.0-rc1 in prep for release -* [#3686](https://github.com/sei-protocol/sei-chain/pull/3686) Backport `release/v6.6`: Update changelog in prep to cut 6.6 RC1 * [#3679](https://github.com/sei-protocol/sei-chain/pull/3679) Backport `release/v6.6`: State Store: Compact pruned key range after each prune * [#3673](https://github.com/sei-protocol/sei-chain/pull/3673) Backport `release/v6.6`: fix(metrics): Prometheus metrics output * [#3672](https://github.com/sei-protocol/sei-chain/pull/3672) Backport `release/v6.6`: [codex] Harden multiversion iterator validation diff --git a/Makefile b/Makefile index b0d5ebf2eb..085bf60949 100644 --- a/Makefile +++ b/Makefile @@ -20,11 +20,7 @@ COMMIT := $(shell git log -1 --format='%H') BUILDDIR ?= $(CURDIR)/build INVARIANT_CHECK_INTERVAL ?= $(INVARIANT_CHECK_INTERVAL:-0) export PROJECT_HOME=$(shell git rev-parse --show-toplevel) -# Parent of the Go module cache. Derived from `go env GOMODCACHE` so that the -# container/compose mounts (`$(GO_PKG_PATH)/mod`) follow a relocated GOMODCACHE -# (e.g. CI pointing it at /mnt) the same way the GOCACHE mounts follow -# `go env GOCACHE`. Defaults to $HOME/go/pkg when GOMODCACHE is unset. -export GO_PKG_PATH=$(shell dirname $(shell go env GOMODCACHE)) +export GO_PKG_PATH=$(HOME)/go/pkg export GO111MODULE = on # process build tags @@ -77,10 +73,7 @@ ifeq ($(firstword $(sort go1.23 $(shell go env GOVERSION))), go1.23) ldflags += -checklinkname=0 endif ifeq ($(LINK_STATICALLY),true) - # STATIC_EXTRA_LDFLAGS lets the static build inject linker search paths, e.g. - # scripts/build-static.sh points it at the pinned pre-gcc-12 libgcc (see - # third_party/alpine-gcc10-libgcc/README.md). - ldflags += -linkmode=external -extldflags "-Wl,-z,muldefs -static $(STATIC_EXTRA_LDFLAGS)" + ldflags += -linkmode=external -extldflags "-Wl,-z,muldefs -static" endif ldflags += $(LDFLAGS) ldflags := $(strip $(ldflags)) @@ -243,7 +236,7 @@ ensure-integration-ci-images: # Build seid once inside the localnode image (integration-test prepare job). build-seid-in-localnode: build-docker-node - @mkdir -p build $(shell go env GOMODCACHE) $(shell go env GOCACHE) + @mkdir -p build $(shell go env GOPATH)/pkg/mod $(shell go env GOCACHE) @docker run --rm \ --user="$(shell id -u):$(shell id -g)" \ -v $(PROJECT_HOME):/sei-protocol/sei-chain:Z \ @@ -258,7 +251,7 @@ build-seid-in-localnode: build-docker-node # CI variant: assumes localnode image already built by Buildx in prepare-cluster (skips docker build). build-seid-in-localnode-ci: ensure-integration-ci-images - @mkdir -p build $(shell go env GOMODCACHE) $(shell go env GOCACHE) + @mkdir -p build $(shell go env GOPATH)/pkg/mod $(shell go env GOCACHE) @docker run --rm \ --user="$(shell id -u):$(shell id -g)" \ -v $(PROJECT_HOME):/sei-protocol/sei-chain:Z \ @@ -386,7 +379,7 @@ CLUSTER_ENV_VARS = DOCKER_PLATFORM=$(DOCKER_PLATFORM) USERID=$(shell id -u) GROU # Run a 4-node docker containers docker-cluster-start: docker-cluster-stop build-docker-node @rm -rf $(PROJECT_HOME)/build/generated - @mkdir -p $(shell go env GOMODCACHE) + @mkdir -p $(shell go env GOPATH)/pkg/mod @mkdir -p $(shell go env GOCACHE) @cd docker && \ if [ "$${DOCKER_DETACH:-}" = "true" ]; then \ @@ -414,7 +407,7 @@ docker-cluster-start-skipbuild: docker-cluster-stop build-docker-node docker-cluster-start-ci: docker-cluster-stop ensure-integration-ci-images @rm -rf $(PROJECT_HOME)/build/generated @test -f $(PROJECT_HOME)/build/seid || (echo "build/seid missing; download integration-build.tar.gz from prepare-cluster" && exit 1) - @mkdir -p $(shell go env GOMODCACHE) + @mkdir -p $(shell go env GOPATH)/pkg/mod @mkdir -p $(shell go env GOCACHE) @cd docker && \ if [ "$${DOCKER_DETACH:-}" = "true" ]; then \ @@ -433,7 +426,7 @@ docker-cluster-stop: # Start 4-node cluster with Prometheus and Grafana monitoring docker-cluster-start-monitoring: docker-cluster-stop-monitoring build-docker-node @rm -rf $(PROJECT_HOME)/build/generated - @mkdir -p $(shell go env GOMODCACHE) + @mkdir -p $(shell go env GOPATH)/pkg/mod @mkdir -p $(shell go env GOCACHE) @cd docker && \ if [ "$${DOCKER_DETACH:-}" = "true" ]; then \ @@ -495,7 +488,7 @@ autobahn-integration-test: # Any determinism divergence between giga and V2 will cause the giga node to halt. docker-cluster-start-giga-mixed: docker-cluster-stop build-docker-node @rm -rf $(PROJECT_HOME)/build/generated - @mkdir -p $(shell go env GOMODCACHE) + @mkdir -p $(shell go env GOPATH)/pkg/mod @mkdir -p $(shell go env GOCACHE) @cd docker && \ if [ "$${DOCKER_DETACH:-}" = "true" ]; then \ diff --git a/REVIEW.md b/REVIEW.md deleted file mode 100644 index b1d268b76d..0000000000 --- a/REVIEW.md +++ /dev/null @@ -1,63 +0,0 @@ -# Review guidelines for AI agents - -Repo-specific conventions for automated PR review (Codex, Cursor, Claude, and -any other AI reviewer). The two patterns below look like bugs in isolation -but are normal consequences of how Sei cuts releases and rolls out upgrades. -Do not report them as findings unless a concrete additional signal (see the -caveats under each) makes them real. - -## 1. Referencing a not-yet-created release tag is expected - -Sei only creates a new release tag — and registers it in `app/tags` with a -handler in `app/upgrades.go` — *after* the code that depends on it has -already been merged to main, never before. So it is completely normal for a -PR to introduce a version-gated constant (e.g. `const FooUpgrade = "v6.7"`) -that: - -- does not yet appear anywhere in `app/tags`, -- has no corresponding upgrade handler in `app/upgrades.go`, and -- therefore cannot currently be produced by `ctx.ClosestUpgradeName()` / - `ctx.LatestUpgrade()`. - -Do **not** flag this as "the tag/handler is never registered," "this branch -is permanently unreachable/dead," or "this constant doesn't match anything -in the tree." The tag is cut and the handler wired up in a follow-up step -after this PR lands, using the same version string, following the existing -`app/tags` naming convention. - -This only becomes a real finding when: -- the PR description or diff explicitly claims *this* PR adds the tag/handler - (i.e. it's the release-cut PR) and it doesn't, or -- the referenced version string doesn't follow Sei's tag naming convention - (compare against the existing entries in `app/tags`). - -## 2. Version-gated logic and block/state sync: don't assume cross-version execution - -Do not flag scenarios along the lines of "new code could process a -pre-upgrade height and diverge from what was originally committed," or -"this upgrade gate can never activate because the upgrade hasn't run yet," -as correctness bugs — unless the diff itself has a concrete logic bug in the -gate (e.g. an inverted comparison, wrong field, off-by-one on the height). - -Operationally, a given binary is never used to execute or re-execute a -height range that predates its own earliest registered upgrade. Block/state -sync always proceeds version-by-version: e.g. if a node's target height -spans releases v6.0 and v6.1, the node first syncs with the v6.0 binary up -to v6.1's upgrade height, halts, switches to the v6.1 binary, and continues -syncing from there. New code never processes old, not-yet-upgraded state. - -Consequences for review: -- Height/upgrade-name gates (`ctx.ClosestUpgradeName()`, `ctx.IsTracing()`, - semver comparisons against an upgrade constant, etc.) do not need extra - defenses against "a newer binary ran against pre-upgrade state" — that - situation does not occur in Sei's deployment model. -- By the time a binary is live (or tracing/replaying) at or after its own - upgrade height, that upgrade's handler has necessarily already applied on - that node, so upgrade-gated branches are reachable for those blocks. Don't - call them "permanently unreachable" solely because the tag/handler isn't - registered yet at PR-review time — see §1. - -If you believe a version gate is genuinely broken on its own logic (wrong -comparison direction, wrong constant, wrong context field), still report -it — this guidance only rules out the "the tag doesn't exist yet" and -"old code might run against post-upgrade state" false positives. diff --git a/app/abci.go b/app/abci.go index 34b54f6079..cc9a111cbd 100644 --- a/app/abci.go +++ b/app/abci.go @@ -192,7 +192,7 @@ func (app *App) EvmBalance(evmAddr common.Address, seiAddrBz []byte) uint256.Int if !seiAddr.Equals(sdk.AccAddress(evmAddr[:])) { balance = new(big.Int).Add(balance, app.EvmKeeper.GetBalance(ctx, seiAddr)) } - return bigIntToUint256(mempoolBalanceFloor(balance)) + return bigIntToUint256(balance) } func bigIntToUint256(x *big.Int) uint256.Int { diff --git a/app/app.go b/app/app.go index 818c1a6bf8..c251f19791 100644 --- a/app/app.go +++ b/app/app.go @@ -142,7 +142,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-cosmos/storev2/rootmulti" "github.com/sei-protocol/sei-chain/utils" - "github.com/sei-protocol/sei-chain/utils/helpers" utilmetrics "github.com/sei-protocol/sei-chain/utils/metrics" "github.com/sei-protocol/sei-chain/wasmbinding" epochmodule "github.com/sei-protocol/sei-chain/x/epoch" @@ -824,7 +823,6 @@ func New( } else { logger.Debug("failed to load evmone VM", "error", err) } - // evm_giga_mixed_tests.sh matches these ENABLED/DISABLED strings to guard node roles; keep them in sync. if gigaExecutorConfig.OCCEnabled { logger.Info("benchmark: Giga Executor with OCC is ENABLED - using new EVM execution path with parallel execution") } else { @@ -890,7 +888,7 @@ func New( encodingConfig.TxConfig, ), auth.NewAppModule(appCodec, app.AccountKeeper, nil), - vesting.NewAppModule(app.AccountKeeper, app.BankKeeper, app.UpgradeKeeper), + vesting.NewAppModule(app.AccountKeeper, app.BankKeeper), bank.NewAppModule(appCodec, app.BankKeeper, app.AccountKeeper), capability.NewAppModule(appCodec, *app.CapabilityKeeper), feegrantmodule.NewAppModule(appCodec, app.AccountKeeper, app.BankKeeper, app.FeeGrantKeeper, app.interfaceRegistry), @@ -1611,10 +1609,7 @@ func (app *App) ProcessTxsSynchronousGiga(ctx sdk.Context, txs [][]byte, typedTx // Store-iteration panic: re-run via v2 so the result matches v2 exactly. if fallbackToV2 { utilmetrics.IncrGigaFallbackToV2Counter() // TODO(PLT-327): remove once app_giga_fallback_to_v2_total verified - appMetrics.gigaFallback.Add(ctx.Context(), 1, - otelmetric.WithAttributes( - attribute.String("reason", gigaFallbackStoreIterator), - attribute.String("scope", "tx"))) + appMetrics.gigaFallback.Add(ctx.Context(), 1) res := app.DeliverTxWithResult(ctx, tx, typedTxs[i]) txResults[i] = res ms.Write() @@ -1622,14 +1617,8 @@ func (app *App) ProcessTxsSynchronousGiga(ctx sdk.Context, txs [][]byte, typedTx } if execErr != nil { - // Abort errors (validation failure, balance migration, self-destruct, - // cosmos-precompile interop) re-run this tx via v2. + // Check if this is a fail-fast error (Cosmos precompile interop detected) if gigautils.ShouldExecutionAbort(execErr) { - utilmetrics.IncrGigaFallbackToV2Counter() // TODO(PLT-327): remove once app_giga_fallback_to_v2_total verified - appMetrics.gigaFallback.Add(ctx.Context(), 1, - otelmetric.WithAttributes( - attribute.String("reason", gigaFallbackReason(execErr)), - attribute.String("scope", "tx"))) res := app.DeliverTxWithResult(ctx, tx, typedTxs[i]) txResults[i] = res ms.Write() @@ -1847,23 +1836,16 @@ func (app *App) ProcessTXsWithOCCGiga(ctx sdk.Context, txs [][]byte, typedTxs [] return nil, ctx } - fallbackReason := gigaFallbackOther for _, r := range evmBatchResult { if r.Code == gigautils.GigaAbortCode && r.Codespace == gigautils.GigaAbortCodespace { fallbackToV2 = true - if r.Info != "" { - fallbackReason = r.Info - } break } } if fallbackToV2 { utilmetrics.IncrGigaFallbackToV2Counter() // TODO(PLT-327): remove once app_giga_fallback_to_v2_total verified - appMetrics.gigaFallback.Add(ctx.Context(), 1, - otelmetric.WithAttributes( - attribute.String("reason", fallbackReason), - attribute.String("scope", "batch"))) + appMetrics.gigaFallback.Add(ctx.Context(), 1) // Discard all EVM changes by skipping cache writes, then re-run all txs via DeliverTx. evmBatchResult = nil v2Entries = make([]*sdk.DeliverTxEntry, len(txs)) @@ -2013,34 +1995,6 @@ func (app *App) ProcessBlock(ctx sdk.Context, txs [][]byte, req *BlockProcessReq return events, txResults, endBlockResp, nil } -// Fallback-cause labels for the app_giga_fallback_to_v2 metric. -const ( - gigaFallbackValidationFailed = "validation_failed" - gigaFallbackBalanceMigration = "balance_migration" - gigaFallbackSelfDestruct = "self_destruct" - gigaFallbackInvalidPrecompile = "invalid_precompile" - gigaFallbackStoreIterator = "store_iterator" - gigaFallbackOther = "other" -) - -// gigaFallbackReason labels the app_giga_fallback_to_v2 metric with which -// abort sentinel routed a tx to v2, so operators can tell a validation-failure -// wave from balance-migration churn. -func gigaFallbackReason(err error) string { - switch err.(type) { - case *gigautils.ValidationFailedAbortError: - return gigaFallbackValidationFailed - case *gigaprecompiles.BalanceMigrationAbortError: - return gigaFallbackBalanceMigration - case *gigaprecompiles.SelfDestructAbortError: - return gigaFallbackSelfDestruct - case *gigaprecompiles.InvalidPrecompileCallError: - return gigaFallbackInvalidPrecompile - default: - return gigaFallbackOther - } -} - // executeEVMTxWithGigaExecutor executes a single EVM transaction using the giga executor. // The sender address is recovered directly from the transaction signature - no Cosmos SDK ante handlers needed. func (app *App) executeEVMTxWithGigaExecutor(ctx sdk.Context, msg *evmtypes.MsgEVMTransaction, cache *gigaBlockCache) (*abci.ExecTxResult, error) { @@ -2061,24 +2015,28 @@ func (app *App) executeEVMTxWithGigaExecutor(ctx sdk.Context, msg *evmtypes.MsgE }, nil } - // Prepare context for EVM transaction (set infinite gas meter like original flow) - ctx = ctx.WithGasMeter(sdk.NewInfiniteGasMeterWithMultiplier(ctx)) - _, isAssociated := app.GigaEvmKeeper.GetEVMAddress(ctx, seiAddr) - // Create state DB before validation so balance checks use DBImpl hooks. - stateDB := gigaevmstate.NewDBImpl(ctx, &app.GigaEvmKeeper, false) - defer stateDB.Cleanup() - // Run validation checks (fee/nonce/balance - stateless checks done earlier) - validation := app.validateGigaEVMTx(ctx, stateDB, ethTx, sender, seiAddr, isAssociated) + validation := app.validateGigaEVMTx(ctx, ethTx, sender, seiAddr, isAssociated) + + // Prepare context for EVM transaction (set infinite gas meter like original flow) + ctx = ctx.WithGasMeter(sdk.NewInfiniteGasMeterWithMultiplier(ctx)) if validation.err != nil { - // v2 rejects fee/nonce/balance failures in its ante chain, whose - // receipt gas fields giga cannot reconstruct; a giga-side receipt here - // diverges LastResultsHash on mixed fleets (CON-368). Fall back to v2, - // which also owns the nonce bump. - return nil, gigautils.ErrValidationFailed + // Validation failed - bump nonce via keeper if it was valid (matches V2's DeliverTxCallback + // behavior where nonce is incremented even on fee validation failures). + // For successful txs, the nonce is bumped by the EVM during execution. + if validation.bumpNonce { + app.GigaEvmKeeper.SetNonce(ctx, sender, validation.currentNonce+1) + app.EvmKeeper.SetNonceBumped(ctx) + } + // V2 reports intrinsic gas as gasUsed even on validation failure (for metrics), + // but no actual balance is deducted + intrinsicGas, _ := core.IntrinsicGas(ethTx.Data(), ethTx.AccessList(), ethTx.SetCodeAuthorizations(), ethTx.To() == nil, true, true, true) + validation.err.GasUsed = int64(intrinsicGas) //nolint:gosec + validation.err.GasWanted = int64(ethTx.Gas()) //nolint:gosec + return validation.err, nil } if !isAssociated { @@ -2087,14 +2045,9 @@ func (app *App) executeEVMTxWithGigaExecutor(ctx sdk.Context, msg *evmtypes.MsgE return nil, gigaprecompiles.ErrBalanceMigrationRequired } - // EIP-7702 authorization authorities must be associated with their true (pubkey-derived) - // Sei address before SetCode installs delegation code, otherwise SetCode creates a mutable - // direct-cast mapping that a later associatePubKey can remap (orphaning staking/distribution - // state). That pre-association is done in the V2 ante handler and requires balance migration, - // which giga's cachekv cannot perform, so defer such transactions to V2. - if app.setCodeTxRequiresAuthorityAssociation(ctx, ethTx) { - return nil, gigaprecompiles.ErrBalanceMigrationRequired - } + // Create state DB for this transaction (only for valid transactions) + stateDB := gigaevmstate.NewDBImpl(ctx, &app.GigaEvmKeeper, false) + defer stateDB.Cleanup() // Pre-charge gas fee (like V2's ante handler), then execute with feeAlreadyCharged=true. // V2 charges fees in the ante handler, then runs the EVM with feeAlreadyCharged=true @@ -2294,22 +2247,6 @@ func (app *App) executeEVMTxWithGigaExecutor(ctx sdk.Context, msg *evmtypes.MsgE }, nil } -// setCodeTxRequiresAuthorityAssociation reports whether an EIP-7702 transaction has any -// authorization authority that the EVM will apply and that is not yet associated with its -// true (pubkey-derived) Sei address. Such an authority must be associated (with balance -// migration) before SetCode runs, which the V2 ante handler does but the giga executor -// cannot; callers use this to defer the transaction to V2. Authorizations the EVM would -// skip (wrong chain id, bad nonce, authority has code) are ignored: no mapping is created -// for them. It returns false for non-SetCode transactions (which carry no authorizations). -func (app *App) setCodeTxRequiresAuthorityAssociation(ctx sdk.Context, ethTx *ethtypes.Transaction) bool { - for _, auth := range ethTx.SetCodeAuthorizations() { - if _, _, _, ok := helpers.AuthorityToPreAssociate(ctx, &app.GigaEvmKeeper, auth); ok { - return true - } - } - return false -} - // gigaDeliverTx is the OCC-compatible deliverTx function for the giga executor. // makeGigaDeliverTx returns an OCC-compatible deliverTx callback that captures the given // block cache, avoiding mutable state on App for cache lifecycle management. @@ -2339,7 +2276,7 @@ func (app *App) makeGigaDeliverTx(cache *gigaBlockCache) func(sdk.Context, abci. resp = abci.ResponseDeliverTx{ Code: gigautils.GigaAbortCode, Codespace: gigautils.GigaAbortCodespace, - Info: gigaFallbackStoreIterator, + Info: gigautils.GigaAbortInfo, Log: "giga executor abort: store iteration unsupported, fall back to v2", } return @@ -2378,7 +2315,7 @@ func (app *App) makeGigaDeliverTx(cache *gigaBlockCache) func(sdk.Context, abci. return abci.ResponseDeliverTx{ Code: gigautils.GigaAbortCode, Codespace: gigautils.GigaAbortCodespace, - Info: gigaFallbackReason(err), + Info: gigautils.GigaAbortInfo, Log: "giga executor abort: fall back to v2", } } @@ -3068,8 +3005,10 @@ func (app *App) inplacetestnetInitializer(pk cryptotypes.PubKey) error { // gigaValidationResult holds the result of EVM transaction validation. type gigaValidationResult struct { - err *abci.ExecTxResult // nil if validation passed - baseFee *big.Int // the base fee used for validation + err *abci.ExecTxResult // nil if validation passed + bumpNonce bool // true if tx nonce matches expected nonce + currentNonce uint64 // the expected nonce at time of validation + baseFee *big.Int // the base fee used for validation } // validateGigaEVMTx validates an EVM tx for fee, nonce, and stateless checks. @@ -3085,7 +3024,6 @@ type gigaValidationResult struct { // 7. Balance check func (app *App) validateGigaEVMTx( ctx sdk.Context, - stateDB *gigaevmstate.DBImpl, ethTx *ethtypes.Transaction, sender common.Address, seiAddr sdk.AccAddress, @@ -3096,8 +3034,10 @@ func (app *App) validateGigaEVMTx( baseFee = new(big.Int) } + // Check nonce validity - determines if we bump nonce on fee/balance failures currentNonce := app.GigaEvmKeeper.GetNonce(ctx, sender) txNonce := ethTx.Nonce() + bumpNonce := txNonce == currentNonce // Fee cap below base fee if ethTx.GasFeeCap().Cmp(baseFee) < 0 { @@ -3106,7 +3046,9 @@ func (app *App) validateGigaEVMTx( Code: sdkerrors.ErrInsufficientFee.ABCICode(), Log: "max fee per gas less than block base fee", }, - baseFee: baseFee, + bumpNonce: bumpNonce, + currentNonce: currentNonce, + baseFee: baseFee, } } @@ -3118,7 +3060,9 @@ func (app *App) validateGigaEVMTx( Code: sdkerrors.ErrInsufficientFee.ABCICode(), Log: "max fee per gas less than minimum fee", }, - baseFee: baseFee, + bumpNonce: bumpNonce, + currentNonce: currentNonce, + baseFee: baseFee, } } @@ -3133,7 +3077,9 @@ func (app *App) validateGigaEVMTx( Code: sdkerrors.ErrWrongSequence.ABCICode(), Log: fmt.Sprintf("nonce too high: address %s, tx: %d state: %d", sender.Hex(), txNonce, currentNonce), }, - baseFee: baseFee, + bumpNonce: bumpNonce, + currentNonce: currentNonce, + baseFee: baseFee, } } if txNonce < currentNonce { @@ -3142,7 +3088,9 @@ func (app *App) validateGigaEVMTx( Code: sdkerrors.ErrWrongSequence.ABCICode(), Log: fmt.Sprintf("nonce too low: address %s, tx: %d state: %d", sender.Hex(), txNonce, currentNonce), }, - baseFee: baseFee, + bumpNonce: bumpNonce, + currentNonce: currentNonce, + baseFee: baseFee, } } // Nonce overflow guard (currentNonce + 1 would overflow) @@ -3152,7 +3100,9 @@ func (app *App) validateGigaEVMTx( Code: sdkerrors.ErrWrongSequence.ABCICode(), Log: fmt.Sprintf("nonce max: address %s, nonce: %d", sender.Hex(), currentNonce), }, - baseFee: baseFee, + bumpNonce: bumpNonce, + currentNonce: currentNonce, + baseFee: baseFee, } } @@ -3166,7 +3116,9 @@ func (app *App) validateGigaEVMTx( Code: sdkerrors.ErrWrongSequence.ABCICode(), Log: fmt.Sprintf("sender not an eoa: address %s, len(code): %d", sender.Hex(), len(senderCode)), }, - baseFee: baseFee, + bumpNonce: bumpNonce, + currentNonce: currentNonce, + baseFee: baseFee, } } } @@ -3178,7 +3130,9 @@ func (app *App) validateGigaEVMTx( Code: sdkerrors.ErrWrongSequence.ABCICode(), Log: fmt.Sprintf("max fee per gas higher than 2^256-1: address %s, maxFeePerGas bit length: %d", sender.Hex(), l), }, - baseFee: baseFee, + bumpNonce: bumpNonce, + currentNonce: currentNonce, + baseFee: baseFee, } } @@ -3189,7 +3143,9 @@ func (app *App) validateGigaEVMTx( Code: sdkerrors.ErrWrongSequence.ABCICode(), Log: fmt.Sprintf("max priority fee per gas higher than 2^256-1: address %s, maxPriorityFeePerGas bit length: %d", sender.Hex(), l), }, - baseFee: baseFee, + bumpNonce: bumpNonce, + currentNonce: currentNonce, + baseFee: baseFee, } } @@ -3200,7 +3156,9 @@ func (app *App) validateGigaEVMTx( Code: sdkerrors.ErrWrongSequence.ABCICode(), Log: fmt.Sprintf("max priority fee per gas higher than max fee per gas: address %s, maxPriorityFeePerGas: %s, maxFeePerGas: %s", sender.Hex(), ethTx.GasTipCap(), ethTx.GasFeeCap()), }, - baseFee: baseFee, + bumpNonce: bumpNonce, + currentNonce: currentNonce, + baseFee: baseFee, } } @@ -3213,7 +3171,9 @@ func (app *App) validateGigaEVMTx( Code: sdkerrors.ErrWrongSequence.ABCICode(), Log: fmt.Sprintf("set-code transaction must not be a create transaction: sender %s", sender.Hex()), }, - baseFee: baseFee, + bumpNonce: bumpNonce, + currentNonce: currentNonce, + baseFee: baseFee, } } // Set-code tx auth list must be non-empty @@ -3223,7 +3183,9 @@ func (app *App) validateGigaEVMTx( Code: sdkerrors.ErrWrongSequence.ABCICode(), Log: fmt.Sprintf("set-code transaction with empty auth list: sender %s", sender.Hex()), }, - baseFee: baseFee, + bumpNonce: bumpNonce, + currentNonce: currentNonce, + baseFee: baseFee, } } } @@ -3236,17 +3198,13 @@ func (app *App) validateGigaEVMTx( balanceCheck := new(big.Int).Mul(new(big.Int).SetUint64(ethTx.Gas()), ethTx.GasFeeCap()) balanceCheck.Add(balanceCheck, ethTx.Value()) - // Route validation balance reads through DBImpl so mock_balances follows - // the same ensureMinimumBalance behavior as V2's StateTransition.BuyGas. - senderBalance := stateDB.GetBalance(sender).ToBig() + senderBalance := app.GigaEvmKeeper.GetBalance(ctx, seiAddr) - // Include the derived Sei address balance for unassociated addresses (matches V2 PreprocessDecorator). + // Include cast address balance for unassociated addresses (matches V2 PreprocessDecorator) if !isAssociated { - seiEVMAddr := common.BytesToAddress(seiAddr) - if seiEVMAddr != sender { - seiBalance := stateDB.GetBalance(seiEVMAddr).ToBig() - senderBalance = new(big.Int).Add(senderBalance, seiBalance) - } + castAddr := sdk.AccAddress(sender[:]) + castBalance := app.GigaEvmKeeper.GetBalance(ctx, castAddr) + senderBalance = new(big.Int).Add(senderBalance, castBalance) } if senderBalance.Cmp(balanceCheck) < 0 { @@ -3255,14 +3213,18 @@ func (app *App) validateGigaEVMTx( Code: sdkerrors.ErrInsufficientFunds.ABCICode(), Log: fmt.Sprintf("insufficient funds for gas * price + value: address %s have %v want %v: insufficient funds", sender.Hex(), senderBalance, balanceCheck), }, - baseFee: baseFee, + bumpNonce: bumpNonce, + currentNonce: currentNonce, + baseFee: baseFee, } } // All checks passed return gigaValidationResult{ - err: nil, - baseFee: baseFee, + err: nil, + bumpNonce: bumpNonce, + currentNonce: currentNonce, + baseFee: baseFee, } } diff --git a/app/mock_balances_buildtag.go b/app/mock_balances_buildtag.go index 9f9a8f5150..67ac187264 100644 --- a/app/mock_balances_buildtag.go +++ b/app/mock_balances_buildtag.go @@ -2,25 +2,5 @@ package app -import ( - "math/big" - - "github.com/sei-protocol/sei-chain/x/evm/state" -) - // MockBalancesEnabled indicates whether mock_balances build tag is set. const MockBalancesEnabled = true - -// mempoolBalanceFloor lifts the balance the mempool readiness gate sees to at -// least the mock top-off amount. The CheckTx top-off writes to a discarded -// cache, so the check-state balance behind EvmBalance never reflects it — -// without this floor a mock-funded sender never satisfies requiredBalance, -// its txs are never promoted to ready, and ready-only gossip/reaping means -// they can never reach a block. Execution is unaffected: the real top-off -// runs in the delivery path, where writes persist. -func mempoolBalanceFloor(balance *big.Int) *big.Int { - if balance.Cmp(state.TopOffAmount) < 0 { - return new(big.Int).Set(state.TopOffAmount) - } - return balance -} diff --git a/app/mock_balances_buildtag_default.go b/app/mock_balances_buildtag_default.go index 68e248cabe..2320769031 100644 --- a/app/mock_balances_buildtag_default.go +++ b/app/mock_balances_buildtag_default.go @@ -2,13 +2,5 @@ package app -import "math/big" - // MockBalancesEnabled indicates whether mock_balances build tag is set. const MockBalancesEnabled = false - -// mempoolBalanceFloor is the identity in production builds: the mempool -// readiness gate sees the real check-state balance. -func mempoolBalanceFloor(balance *big.Int) *big.Int { - return balance -} diff --git a/app/mock_balances_buildtag_test.go b/app/mock_balances_buildtag_test.go deleted file mode 100644 index d019762ea0..0000000000 --- a/app/mock_balances_buildtag_test.go +++ /dev/null @@ -1,22 +0,0 @@ -//go:build mock_balances - -package app - -import ( - "math/big" - "testing" - - "github.com/sei-protocol/sei-chain/x/evm/state" -) - -func TestMempoolBalanceFloor(t *testing.T) { - below := big.NewInt(0) - if got := mempoolBalanceFloor(below); got.Cmp(state.TopOffAmount) != 0 { - t.Errorf("floor(0) = %s; want TopOffAmount %s", got, state.TopOffAmount) - } - - above := new(big.Int).Add(state.TopOffAmount, big.NewInt(1)) - if got := mempoolBalanceFloor(above); got.Cmp(above) != 0 { - t.Errorf("floor(above) = %s; want unchanged %s", got, above) - } -} diff --git a/app/seidb.go b/app/seidb.go index 9307af2bb0..046fca0c14 100644 --- a/app/seidb.go +++ b/app/seidb.go @@ -53,6 +53,9 @@ const ( FlagEVMSSDirectory = "state-store.evm-ss-db-directory" FlagEVMSSSplit = "state-store.evm-ss-split" FlagEVMSSSeparateDBs = "state-store.evm-ss-separate-dbs" + + // Other configs + FlagSnapshotInterval = "state-sync.snapshot-interval" ) var GigaKeys = []string{"evm", "bank"} @@ -77,6 +80,7 @@ func SetupSeiDB( "separateDBs", ssConfig.SeparateEVMSubDBs, ) } + validateConfigs(appOpts) gigaExecutorConfig, err := gigaconfig.ReadConfig(appOpts) if err != nil { panic(fmt.Sprintf("error reading giga executor config due to %s", err)) @@ -101,36 +105,14 @@ func parseSCConfigs(appOpts servertypes.AppOptions) config.StateCommitConfig { scConfig := config.DefaultStateCommitConfig() scConfig.Enable = cast.ToBool(appOpts.Get(FlagSCEnable)) scConfig.Directory = cast.ToString(appOpts.Get(FlagSCDirectory)) - // Guard every read with a presence check: an absent app.toml key must - // preserve the in-code default from DefaultStateCommitConfig above rather - // than reading back the zero value (cast.To*(nil) == 0/false) and clobbering - // it. This matters for keys whose default is non-zero (async-commit-buffer - // 100, snapshot-interval 10000, keep-recent 1, ...) so a config that omits a - // key does not silently downgrade the node (e.g. to synchronous commits). - if v := appOpts.Get(FlagSCAsyncCommitBuffer); v != nil { - scConfig.MemIAVLConfig.AsyncCommitBuffer = cast.ToInt(v) - } - if v := appOpts.Get(FlagSCSnapshotKeepRecent); v != nil { - scConfig.MemIAVLConfig.SnapshotKeepRecent = cast.ToUint32(v) - } - if v := appOpts.Get(FlagSCSnapshotInterval); v != nil { - scConfig.MemIAVLConfig.SnapshotInterval = cast.ToUint32(v) - } - if v := appOpts.Get(FlagSCSnapshotMinTimeInterval); v != nil { - scConfig.MemIAVLConfig.SnapshotMinTimeInterval = cast.ToUint32(v) - } - if v := appOpts.Get(FlagSCSnapshotWriterLimit); v != nil { - scConfig.MemIAVLConfig.SnapshotWriterLimit = cast.ToInt(v) - } - if v := appOpts.Get(FlagSCSnapshotPrefetchThreshold); v != nil { - scConfig.MemIAVLConfig.SnapshotPrefetchThreshold = cast.ToFloat64(v) - } - if v := appOpts.Get(FlagSCSnapshotWriteRateMBps); v != nil { - scConfig.MemIAVLConfig.SnapshotWriteRateMBps = cast.ToInt(v) - } - if v := appOpts.Get(FlagSCFlatKVReadWriteMetrics); v != nil { - scConfig.FlatKVConfig.EnableReadWriteMetrics = cast.ToBool(v) - } + scConfig.MemIAVLConfig.AsyncCommitBuffer = cast.ToInt(appOpts.Get(FlagSCAsyncCommitBuffer)) + scConfig.MemIAVLConfig.SnapshotKeepRecent = cast.ToUint32(appOpts.Get(FlagSCSnapshotKeepRecent)) + scConfig.MemIAVLConfig.SnapshotInterval = cast.ToUint32(appOpts.Get(FlagSCSnapshotInterval)) + scConfig.MemIAVLConfig.SnapshotMinTimeInterval = cast.ToUint32(appOpts.Get(FlagSCSnapshotMinTimeInterval)) + scConfig.MemIAVLConfig.SnapshotWriterLimit = cast.ToInt(appOpts.Get(FlagSCSnapshotWriterLimit)) + scConfig.MemIAVLConfig.SnapshotPrefetchThreshold = cast.ToFloat64(appOpts.Get(FlagSCSnapshotPrefetchThreshold)) + scConfig.MemIAVLConfig.SnapshotWriteRateMBps = cast.ToInt(appOpts.Get(FlagSCSnapshotWriteRateMBps)) + scConfig.FlatKVConfig.EnableReadWriteMetrics = cast.ToBool(appOpts.Get(FlagSCFlatKVReadWriteMetrics)) // sc-write-mode-enable-auto (default true) decides whether the node may run // in auto. An ABSENT key keeps the default (true): nodes provisioned by @@ -210,3 +192,15 @@ func parseSSConfigs(appOpts servertypes.AppOptions) config.StateStoreConfig { ssConfig.EVMSplit = cast.ToBool(appOpts.Get(FlagEVMSSSplit)) return ssConfig } + +func validateConfigs(appOpts servertypes.AppOptions) { + scEnabled := cast.ToBool(appOpts.Get(FlagSCEnable)) + ssEnabled := cast.ToBool(appOpts.Get(FlagSSEnable)) + snapshotExportInterval := cast.ToUint64(appOpts.Get(FlagSnapshotInterval)) + // Make sure when snapshot is enabled, we should enable SS store + if snapshotExportInterval > 0 && scEnabled { + if !ssEnabled { + panic(fmt.Sprintf("Config validation failed, SeiDB SS store needs to be enabled when snapshot interval %d > 0", snapshotExportInterval)) + } + } +} diff --git a/app/seidb_test.go b/app/seidb_test.go index f089826f2b..6589a86cd6 100644 --- a/app/seidb_test.go +++ b/app/seidb_test.go @@ -80,9 +80,6 @@ func TestNewDefaultConfig(t *testing.T) { // WriteMode to auto, overriding the fixed-fallback default (memiavl_only). expectedSC := config.DefaultStateCommitConfig() expectedSC.WriteMode = sctypes.Auto - // parseSCConfigs is a raw parse and does not align FlatKV with memIAVL (that - // happens in composite.NewCompositeCommitStore), so the parsed config matches - // the in-code defaults verbatim apart from the resolved write mode. assert.Equal(t, expectedSC, scConfig) assert.Equal(t, ssConfig, config.DefaultStateStoreConfig()) assert.Equal(t, receiptConfig, config.DefaultReceiptStoreConfig()) @@ -118,52 +115,6 @@ func TestParseSCConfigs_FlatKVReadWriteMetrics(t *testing.T) { assert.True(t, scConfig.FlatKVConfig.EnableReadWriteMetrics) } -func TestParseSCConfigs_DoesNotAlignFlatKV(t *testing.T) { - // parseSCConfigs is a raw parse: memIAVL takes the flag values while FlatKV - // keeps its own in-code defaults. The FlatKV<-memIAVL alignment happens later - // in composite.NewCompositeCommitStore, not here. - scConfig := parseSCConfigs(mapAppOpts{ - FlagSCEnable: true, - FlagSCSnapshotInterval: uint32(5000), - FlagSCSnapshotKeepRecent: uint32(3), - }) - - assert.Equal(t, uint32(5000), scConfig.MemIAVLConfig.SnapshotInterval) - assert.Equal(t, uint32(3), scConfig.MemIAVLConfig.SnapshotKeepRecent) - def := config.DefaultStateCommitConfig().FlatKVConfig - assert.Equal(t, def.SnapshotInterval, scConfig.FlatKVConfig.SnapshotInterval) - assert.Equal(t, def.SnapshotKeepRecent, scConfig.FlatKVConfig.SnapshotKeepRecent) -} - -func TestParseSCConfigs_SnapshotKeepRecentParsedRaw(t *testing.T) { - // An explicitly configured value is preserved verbatim; the min-clamp (0 -> 1) - // happens later in composite.NewCompositeCommitStore, not here. - scConfig := parseSCConfigs(mapAppOpts{ - FlagSCEnable: true, - FlagSCSnapshotKeepRecent: uint32(0), - }) - - assert.Equal(t, uint32(0), scConfig.MemIAVLConfig.SnapshotKeepRecent) -} - -func TestParseSCConfigs_AbsentKeysKeepDefaults(t *testing.T) { - // Keys omitted entirely must fall back to the in-code defaults rather than - // being clobbered to the zero value (cast.To*(nil) == 0). Only sc-enable is - // set here. - scConfig := parseSCConfigs(mapAppOpts{ - FlagSCEnable: true, - }) - - def := config.DefaultStateCommitConfig().MemIAVLConfig - assert.Equal(t, def.AsyncCommitBuffer, scConfig.MemIAVLConfig.AsyncCommitBuffer) - assert.Equal(t, def.SnapshotKeepRecent, scConfig.MemIAVLConfig.SnapshotKeepRecent) - assert.Equal(t, def.SnapshotInterval, scConfig.MemIAVLConfig.SnapshotInterval) - assert.Equal(t, def.SnapshotMinTimeInterval, scConfig.MemIAVLConfig.SnapshotMinTimeInterval) - assert.Equal(t, def.SnapshotWriterLimit, scConfig.MemIAVLConfig.SnapshotWriterLimit) - assert.Equal(t, def.SnapshotPrefetchThreshold, scConfig.MemIAVLConfig.SnapshotPrefetchThreshold) - assert.Equal(t, def.SnapshotWriteRateMBps, scConfig.MemIAVLConfig.SnapshotWriteRateMBps) -} - func TestParseSCConfigs_LegacyCosmosOnlyWriteMode(t *testing.T) { scConfig := parseSCConfigs(mapAppOpts{ FlagSCEnable: true, @@ -213,32 +164,6 @@ func TestParseSSConfigs_ReadWriteMetrics(t *testing.T) { assert.True(t, ssConfig.EnableReadWriteMetrics) } -// TestSetupSeiDB_StateSyncSnapshotWithoutSSDoesNotPanic guards the removal of -// the old validateConfigs check, which panicked whenever a state-sync snapshot -// interval was configured (> 0) while SC was enabled but SS was disabled. -// State-sync snapshot creation does not read from SS, so that coupling was -// dropped; this test asserts the previously-forbidden combination now boots -// cleanly (no panic) and, with SS off, yields a nil state store. -func TestSetupSeiDB_StateSyncSnapshotWithoutSSDoesNotPanic(t *testing.T) { - homePath := t.TempDir() - appOpts := mapAppOpts{ - FlagSCEnable: true, - FlagSSEnable: false, - // The combination that used to trip validateConfigs: a non-zero - // state-sync snapshot interval with SS disabled. - "state-sync.snapshot-interval": uint64(10), - // Keep the giga executor out of this boot path so the test exercises the - // plain SC-only store construction. - "giga_executor.enabled": false, - } - - require.NotPanics(t, func() { - opts, ss := SetupSeiDB(homePath, appOpts, nil) - require.NotNil(t, opts) - require.Nil(t, ss, "state store must be nil when SS is disabled") - }) -} - func TestParseReceiptConfigs_DefaultsToPebbleWhenUnset(t *testing.T) { receiptConfig, err := config.ReadReceiptConfig(mapAppOpts{}) assert.NoError(t, err) diff --git a/app/setcode_authority_test.go b/app/setcode_authority_test.go deleted file mode 100644 index 1fca71bdf0..0000000000 --- a/app/setcode_authority_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package app - -import ( - "math/big" - "testing" - "time" - - "github.com/ethereum/go-ethereum/common" - ethtypes "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/crypto" - "github.com/holiman/uint256" - tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types" - "github.com/sei-protocol/sei-chain/utils/helpers" - "github.com/stretchr/testify/require" -) - -// TestSetCodeTxRequiresAuthorityAssociation verifies the Giga-path guard for the EIP-7702 -// root-cause fix: a SetCode transaction whose authorization authority is not yet associated -// with its true Sei address must be deferred to V2 (where the ante handler pre-associates -// it), while an already-associated authority or a non-SetCode transaction need not. -func TestSetCodeTxRequiresAuthorityAssociation(t *testing.T) { - a := Setup(t, false, false, false) - ctx := a.NewContext(false, tmproto.Header{Height: 1, ChainID: "sei-test", Time: time.Now()}) - chainID := a.EvmKeeper.ChainID(ctx) - - victimKey, err := crypto.GenerateKey() - require.NoError(t, err) - victimEVM := crypto.PubkeyToAddress(victimKey.PublicKey) - _, victimTrueSei, _, err := helpers.GetAddressesFromPubkeyBytes(crypto.FromECDSAPub(&victimKey.PublicKey)) - require.NoError(t, err) - - auth, err := ethtypes.SignSetCode(victimKey, ethtypes.SetCodeAuthorization{ - ChainID: *uint256.MustFromBig(chainID), - Address: common.HexToAddress("0x000000000000000000000000000000000000c0de"), - Nonce: 0, - }) - require.NoError(t, err) - - sponsorKey, err := crypto.GenerateKey() - require.NoError(t, err) - to := common.HexToAddress("0x00000000000000000000000000000000000000aa") - setCodeTx, err := ethtypes.SignNewTx(sponsorKey, ethtypes.NewPragueSigner(chainID), ðtypes.SetCodeTx{ - ChainID: uint256.MustFromBig(chainID), - Nonce: 0, - GasTipCap: uint256.NewInt(1), - GasFeeCap: uint256.NewInt(1), - Gas: 100000, - To: to, - Value: uint256.NewInt(0), - AuthList: []ethtypes.SetCodeAuthorization{auth}, - }) - require.NoError(t, err) - - // Unassociated authority => giga must defer to V2 so the ante can pre-associate it. - require.True(t, a.setCodeTxRequiresAuthorityAssociation(ctx, setCodeTx)) - - // Once the authority is associated to its true Sei address, giga can execute directly - // (SetCode will see the association and skip the direct-cast mapping). - a.GigaEvmKeeper.SetAddressMapping(ctx, victimTrueSei, victimEVM) - require.False(t, a.setCodeTxRequiresAuthorityAssociation(ctx, setCodeTx)) - - // A SetCode tx carrying an unassociated authority signed for a FOREIGN chain does not - // require deferral: the EVM skips the wrong-chain authorization, so no association (and - // no direct-cast mapping) is ever created for it. - foreignVictimKey, err := crypto.GenerateKey() - require.NoError(t, err) - foreignAuth, err := ethtypes.SignSetCode(foreignVictimKey, ethtypes.SetCodeAuthorization{ - ChainID: *uint256.MustFromBig(new(big.Int).Add(chainID, big.NewInt(1))), - Address: common.HexToAddress("0x000000000000000000000000000000000000c0de"), - Nonce: 0, - }) - require.NoError(t, err) - foreignTx, err := ethtypes.SignNewTx(sponsorKey, ethtypes.NewPragueSigner(chainID), ðtypes.SetCodeTx{ - ChainID: uint256.MustFromBig(chainID), - Nonce: 1, - GasTipCap: uint256.NewInt(1), - GasFeeCap: uint256.NewInt(1), - Gas: 100000, - To: to, - Value: uint256.NewInt(0), - AuthList: []ethtypes.SetCodeAuthorization{foreignAuth}, - }) - require.NoError(t, err) - require.False(t, a.setCodeTxRequiresAuthorityAssociation(ctx, foreignTx)) - - // A non-SetCode transaction carries no authorizations and never needs deferral. - legacyTx, err := ethtypes.SignNewTx(sponsorKey, ethtypes.NewPragueSigner(chainID), ðtypes.LegacyTx{ - Nonce: 0, - GasPrice: big.NewInt(1), - Gas: 21000, - To: &to, - Value: big.NewInt(0), - }) - require.NoError(t, err) - require.False(t, a.setCodeTxRequiresAuthorityAssociation(ctx, legacyTx)) -} diff --git a/app/test_helpers.go b/app/test_helpers.go index bdaa60f237..8d25f981b0 100644 --- a/app/test_helpers.go +++ b/app/test_helpers.go @@ -141,7 +141,10 @@ func NewTestWrapperWithSc(t *testing.T, tm time.Time, valPub cryptotypes.PubKey, } func NewGigaTestWrapper(t *testing.T, tm time.Time, valPub cryptotypes.PubKey, enableEVMCustomPrecompiles bool, useOcc bool, baseAppOptions ...func(*bam.BaseApp)) *TestWrapper { - return newTestWrapper(t, tm, valPub, enableEVMCustomPrecompiles, true, TestAppOpts{UseSc: true, EnableGiga: true, EnableGigaOCC: useOcc}, baseAppOptions...) + wrapper := newTestWrapper(t, tm, valPub, enableEVMCustomPrecompiles, true, TestAppOpts{UseSc: true, EnableGiga: true, EnableGigaOCC: useOcc}, baseAppOptions...) + genState := evmtypes.DefaultGenesis() + wrapper.App.EvmKeeper.InitGenesis(wrapper.Ctx, *genState) + return wrapper } // NewGigaTestWrapperWithRegularStore creates a test wrapper that runs Giga executor @@ -174,6 +177,10 @@ func NewGigaTestWrapperWithRegularStore(t *testing.T, tm time.Time, valPub crypt } } + // Init genesis for GigaEvmKeeper (now uses regular KVStore) + genState := evmtypes.DefaultGenesis() + wrapper.App.EvmKeeper.InitGenesis(wrapper.Ctx, *genState) + return wrapper } diff --git a/cmd/seid/cmd/genaccounts.go b/cmd/seid/cmd/genaccounts.go index 800f0ef1bf..7a55402598 100644 --- a/cmd/seid/cmd/genaccounts.go +++ b/cmd/seid/cmd/genaccounts.go @@ -204,10 +204,6 @@ The association between the sei address and the eth address will also be created cmd.Flags().String(flagVestingAmt, "", "amount of coins for vesting accounts") cmd.Flags().Int64(flagVestingStart, 0, "schedule start time (unix epoch) for vesting accounts") cmd.Flags().Int64(flagVestingEnd, 0, "schedule end time (unix epoch) for vesting accounts") - deprecationNotice := "the vesting module is deprecated; vesting parameters are kept only for legacy genesis tooling" - _ = cmd.Flags().MarkDeprecated(flagVestingAmt, deprecationNotice) - _ = cmd.Flags().MarkDeprecated(flagVestingStart, deprecationNotice) - _ = cmd.Flags().MarkDeprecated(flagVestingEnd, deprecationNotice) flags.AddQueryFlagsToCmd(cmd) return cmd diff --git a/docker/localnode/scripts/step4_config_override.sh b/docker/localnode/scripts/step4_config_override.sh index c3b35cc3d5..0dac4f7caa 100755 --- a/docker/localnode/scripts/step4_config_override.sh +++ b/docker/localnode/scripts/step4_config_override.sh @@ -1,10 +1,8 @@ #!/usr/bin/env sh NODE_ID=${ID:-0} -# Defaults mirror the app's DefaultConfig (giga+OCC on): unset runs what an -# unconfigured seid would; only an explicit false selects V2. -GIGA_EXECUTOR=${GIGA_EXECUTOR:-true} -GIGA_OCC=${GIGA_OCC:-true} +GIGA_EXECUTOR=${GIGA_EXECUTOR:-false} +GIGA_OCC=${GIGA_OCC:-false} AUTOBAHN=${AUTOBAHN:-false} GIGA_STORAGE=${GIGA_STORAGE:-false} # GIGA_FLATKV_ONLY=true boots the cluster directly in the terminal v3 @@ -107,34 +105,28 @@ if [ "$GIGA_STORAGE" = "true" ] && [ "$GIGA_MIGRATE_FROM_MEMIAVL" != "true" ] && sed -i 's/^evm-ss-split[[:space:]]*=.*/evm-ss-split = true/' ~/.sei/config/app.toml fi -# Write the [giga_executor] section explicitly for every node. The Go config -# default is Enabled=true and ReadConfig only overrides keys that are present, -# so a node that never writes the section silently runs giga regardless of its -# intended role. Emitting enabled/occ_enabled unconditionally keeps each node's -# executor mode self-describing in app.toml and immune to that default flip. +# Enable Giga Executor if requested if [ "$GIGA_EXECUTOR" = "true" ]; then - GIGA_ENABLED_VALUE=true echo "Enabling Giga Executor for node $NODE_ID..." -else - GIGA_ENABLED_VALUE=false - echo "Configuring node $NODE_ID with the V2 executor (Giga disabled)..." -fi -if [ "$GIGA_OCC" = "true" ]; then - GIGA_OCC_VALUE=true -else - GIGA_OCC_VALUE=false -fi + if grep -q "\[giga_executor\]" ~/.sei/config/app.toml; then + # If the section exists, update enabled to true + sed -i 's/enabled = false/enabled = true/' ~/.sei/config/app.toml + else + # If section doesn't exist, append it + echo "" >> ~/.sei/config/app.toml + echo "[giga_executor]" >> ~/.sei/config/app.toml + echo "enabled = true" >> ~/.sei/config/app.toml + echo "occ_enabled = false" >> ~/.sei/config/app.toml + fi -if grep -q "\[giga_executor\]" ~/.sei/config/app.toml; then - # Section already present: rewrite its keys in place. The range address scopes - # the substitutions to the giga block so an identically-named key in another - # section (e.g. [telemetry] enabled) is never rewritten. - sed -i "/^\[giga_executor\]/,/^\[/{s/^enabled = .*/enabled = $GIGA_ENABLED_VALUE/;s/^occ_enabled = .*/occ_enabled = $GIGA_OCC_VALUE/;}" ~/.sei/config/app.toml -else - echo "" >> ~/.sei/config/app.toml - echo "[giga_executor]" >> ~/.sei/config/app.toml - echo "enabled = $GIGA_ENABLED_VALUE" >> ~/.sei/config/app.toml - echo "occ_enabled = $GIGA_OCC_VALUE" >> ~/.sei/config/app.toml + # Set OCC based on GIGA_OCC flag + if [ "$GIGA_OCC" = "true" ]; then + echo "Enabling OCC for Giga Executor on node $NODE_ID..." + sed -i 's/occ_enabled = false/occ_enabled = true/' ~/.sei/config/app.toml + else + echo "Disabling OCC for Giga Executor (sequential mode) on node $NODE_ID..." + sed -i 's/occ_enabled = true/occ_enabled = false/' ~/.sei/config/app.toml + fi fi # Override receipt store backend if requested diff --git a/docker/monitornode/dashboards/blocksim-dashboard.json b/docker/monitornode/dashboards/blocksim-dashboard.json deleted file mode 100644 index 70037e6f2a..0000000000 --- a/docker/monitornode/dashboards/blocksim-dashboard.json +++ /dev/null @@ -1,3139 +0,0 @@ -{ - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "description": "BlockSim benchmark: block-store throughput, main-thread phases, LittDB internals, and host resources.", - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 1, - "links": [], - "panels": [ - { - "type": "row", - "title": "Blocksim \u2014 Throughput & Chain State", - "collapsed": false, - "id": 1, - "panels": [], - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 0 - } - }, - { - "type": "timeseries", - "title": "Blocks Written / sec", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 2, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 1 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 15, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "ops" - }, - "overrides": [] - }, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "rate(blocksim_blocks_written_total[$__rate_interval])", - "range": true, - "refId": "A", - "legendFormat": "blocks/s" - } - ] - }, - { - "type": "timeseries", - "title": "Write Throughput", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 3, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 1 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 15, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "binBps" - }, - "overrides": [] - }, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "rate(blocksim_bytes_written_total[$__rate_interval])", - "range": true, - "refId": "A", - "legendFormat": "bytes/s" - } - ] - }, - { - "type": "timeseries", - "title": "QCs Written / sec", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 4, - "gridPos": { - "h": 8, - "w": 8, - "x": 0, - "y": 9 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 15, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "ops" - }, - "overrides": [] - }, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "rate(blocksim_qcs_written_total[$__rate_interval])", - "range": true, - "refId": "A", - "legendFormat": "qcs/s" - } - ] - }, - { - "type": "timeseries", - "title": "Flush & Prune Calls / sec", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 5, - "gridPos": { - "h": 8, - "w": 8, - "x": 8, - "y": 9 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 15, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "ops" - }, - "overrides": [] - }, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "rate(blocksim_flush_calls_total[$__rate_interval])", - "range": true, - "refId": "A", - "legendFormat": "flush/s" - }, - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "rate(blocksim_prune_calls_total[$__rate_interval])", - "range": true, - "refId": "B", - "legendFormat": "prune/s" - } - ] - }, - { - "type": "stat", - "title": "Chain Heights", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 6, - "gridPos": { - "h": 8, - "w": 8, - "x": 16, - "y": 9 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "textMode": "auto", - "showPercentChange": false, - "wideLayout": true - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "blocksim_highest_block_height", - "range": true, - "refId": "A", - "legendFormat": "highest" - }, - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "blocksim_lowest_block_height", - "range": true, - "refId": "B", - "legendFormat": "lowest" - }, - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "blocksim_highest_block_height - blocksim_lowest_block_height", - "range": true, - "refId": "C", - "legendFormat": "retained window" - } - ] - }, - { - "type": "stat", - "title": "Block Size", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 7, - "gridPos": { - "h": 8, - "w": 8, - "x": 0, - "y": 17 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "textMode": "auto", - "showPercentChange": false, - "wideLayout": true - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "blocksim_block_size_bytes", - "range": true, - "refId": "A", - "legendFormat": "block size" - } - ] - }, - { - "type": "stat", - "title": "Uptime", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 8, - "gridPos": { - "h": 8, - "w": 8, - "x": 8, - "y": 17 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "s" - }, - "overrides": [] - }, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "textMode": "auto", - "showPercentChange": false, - "wideLayout": true - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "blocksim_uptime_seconds", - "range": true, - "refId": "A", - "legendFormat": "uptime" - } - ] - }, - { - "type": "timeseries", - "title": "Transactions Written / sec", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 36, - "gridPos": { - "h": 8, - "w": 8, - "x": 16, - "y": 17 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 15, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "ops" - }, - "overrides": [] - }, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "rate(blocksim_transactions_written_total[$__rate_interval])", - "range": true, - "refId": "A", - "legendFormat": "txs/s" - } - ] - }, - { - "type": "row", - "title": "Blocksim \u2014 Main Thread", - "collapsed": false, - "id": 9, - "panels": [], - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 25 - } - }, - { - "type": "piechart", - "title": "Main-Thread Time Distribution", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 10, - "gridPos": { - "h": 8, - "w": 8, - "x": 0, - "y": 26 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "mappings": [], - "custom": { - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - } - }, - "unit": "s" - }, - "overrides": [] - }, - "options": { - "legend": { - "displayMode": "list", - "placement": "right", - "showLegend": true, - "values": [] - }, - "pieType": "pie", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "sort": "desc", - "tooltip": { - "hideZeros": false, - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "rate(blocksim_main_thread_phase_duration_seconds_total[$__rate_interval])", - "range": true, - "refId": "A", - "legendFormat": "{{phase}}" - } - ] - }, - { - "type": "timeseries", - "title": "Main-Thread Time Spent (fraction)", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 11, - "gridPos": { - "h": 8, - "w": 16, - "x": 8, - "y": 26 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 60, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "rate(blocksim_main_thread_phase_duration_seconds_total[$__rate_interval])", - "range": true, - "refId": "A", - "legendFormat": "{{phase}}" - } - ] - }, - { - "type": "timeseries", - "title": "Main-Thread Phase Latency (p99)", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 12, - "gridPos": { - "h": 8, - "w": 24, - "x": 0, - "y": 34 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 15, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "s" - }, - "overrides": [] - }, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.99, sum by (le, phase) (rate(blocksim_main_thread_phase_latency_seconds_bucket[$__rate_interval])))", - "range": true, - "refId": "A", - "legendFormat": "{{phase}}" - } - ] - }, - { - "type": "row", - "title": "LittDB (ledger table)", - "collapsed": false, - "id": 13, - "panels": [], - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 42 - } - }, - { - "type": "timeseries", - "title": "Table Size", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 14, - "gridPos": { - "h": 8, - "w": 8, - "x": 0, - "y": 43 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 15, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "litt_table_size_bytes{table=\"ledger\"}", - "range": true, - "refId": "A", - "legendFormat": "size" - } - ] - }, - { - "type": "timeseries", - "title": "Key Count", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 15, - "gridPos": { - "h": 8, - "w": 8, - "x": 8, - "y": 43 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 15, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "litt_table_key_count{table=\"ledger\"}", - "range": true, - "refId": "A", - "legendFormat": "keys" - } - ] - }, - { - "type": "timeseries", - "title": "Open Iterators", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 16, - "gridPos": { - "h": 8, - "w": 8, - "x": 16, - "y": 43 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 15, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "litt_open_iterator_count{table=\"ledger\"}", - "range": true, - "refId": "A", - "legendFormat": "iterators" - } - ] - }, - { - "type": "timeseries", - "title": "Write Throughput", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 17, - "gridPos": { - "h": 8, - "w": 8, - "x": 0, - "y": 51 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 15, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "binBps" - }, - "overrides": [] - }, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "rate(litt_bytes_written_total{table=\"ledger\"}[$__rate_interval])", - "range": true, - "refId": "A", - "legendFormat": "bytes/s" - } - ] - }, - { - "type": "timeseries", - "title": "Read Throughput", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 18, - "gridPos": { - "h": 8, - "w": 8, - "x": 8, - "y": 51 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 15, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "binBps" - }, - "overrides": [] - }, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "rate(litt_bytes_read_total{table=\"ledger\"}[$__rate_interval])", - "range": true, - "refId": "A", - "legendFormat": "bytes/s" - } - ] - }, - { - "type": "timeseries", - "title": "Keys Written / Read per sec", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 19, - "gridPos": { - "h": 8, - "w": 8, - "x": 16, - "y": 51 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 15, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "ops" - }, - "overrides": [] - }, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "rate(litt_keys_written_total{table=\"ledger\"}[$__rate_interval])", - "range": true, - "refId": "A", - "legendFormat": "written/s" - }, - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "rate(litt_keys_read_total{table=\"ledger\"}[$__rate_interval])", - "range": true, - "refId": "B", - "legendFormat": "read/s" - } - ] - }, - { - "type": "timeseries", - "title": "Cache Hit Ratio", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 20, - "gridPos": { - "h": 8, - "w": 8, - "x": 0, - "y": 59 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 15, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "percentunit", - "min": 0 - }, - "overrides": [] - }, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "rate(litt_cache_hits_total{table=\"ledger\"}[$__rate_interval]) / (rate(litt_cache_hits_total{table=\"ledger\"}[$__rate_interval]) + rate(litt_cache_misses_total{table=\"ledger\"}[$__rate_interval]))", - "range": true, - "refId": "A", - "legendFormat": "hit ratio" - } - ] - }, - { - "type": "timeseries", - "title": "Flush Count / sec", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 21, - "gridPos": { - "h": 8, - "w": 8, - "x": 8, - "y": 59 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 15, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "ops" - }, - "overrides": [] - }, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "rate(litt_flush_count_total{table=\"ledger\"}[$__rate_interval])", - "range": true, - "refId": "A", - "legendFormat": "flushes/s" - } - ] - }, - { - "type": "timeseries", - "title": "Chunk Cache Weight", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 22, - "gridPos": { - "h": 8, - "w": 8, - "x": 16, - "y": 59 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 15, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "litt_chunk_cache_weight_bytes", - "range": true, - "refId": "A", - "legendFormat": "{{cache}}" - } - ] - }, - { - "type": "timeseries", - "title": "Write Latency", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 23, - "gridPos": { - "h": 8, - "w": 8, - "x": 0, - "y": 67 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 15, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "s" - }, - "overrides": [] - }, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.5, sum by (le) (rate(litt_write_latency_seconds_bucket{table=\"ledger\"}[$__rate_interval])))", - "range": true, - "refId": "A", - "legendFormat": "p50" - }, - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.95, sum by (le) (rate(litt_write_latency_seconds_bucket{table=\"ledger\"}[$__rate_interval])))", - "range": true, - "refId": "B", - "legendFormat": "p95" - }, - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.99, sum by (le) (rate(litt_write_latency_seconds_bucket{table=\"ledger\"}[$__rate_interval])))", - "range": true, - "refId": "C", - "legendFormat": "p99" - } - ] - }, - { - "type": "timeseries", - "title": "Read Latency", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 24, - "gridPos": { - "h": 8, - "w": 8, - "x": 8, - "y": 67 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 15, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "s" - }, - "overrides": [] - }, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.5, sum by (le) (rate(litt_read_latency_seconds_bucket{table=\"ledger\"}[$__rate_interval])))", - "range": true, - "refId": "A", - "legendFormat": "p50" - }, - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.95, sum by (le) (rate(litt_read_latency_seconds_bucket{table=\"ledger\"}[$__rate_interval])))", - "range": true, - "refId": "B", - "legendFormat": "p95" - }, - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.99, sum by (le) (rate(litt_read_latency_seconds_bucket{table=\"ledger\"}[$__rate_interval])))", - "range": true, - "refId": "C", - "legendFormat": "p99" - } - ] - }, - { - "type": "timeseries", - "title": "Cache-Miss Latency", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 25, - "gridPos": { - "h": 8, - "w": 8, - "x": 16, - "y": 67 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 15, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "s" - }, - "overrides": [] - }, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.5, sum by (le) (rate(litt_cache_miss_latency_seconds_bucket{table=\"ledger\"}[$__rate_interval])))", - "range": true, - "refId": "A", - "legendFormat": "p50" - }, - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.95, sum by (le) (rate(litt_cache_miss_latency_seconds_bucket{table=\"ledger\"}[$__rate_interval])))", - "range": true, - "refId": "B", - "legendFormat": "p95" - }, - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.99, sum by (le) (rate(litt_cache_miss_latency_seconds_bucket{table=\"ledger\"}[$__rate_interval])))", - "range": true, - "refId": "C", - "legendFormat": "p99" - } - ] - }, - { - "type": "timeseries", - "title": "Flush Latency (p99)", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 26, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 75 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 15, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "s" - }, - "overrides": [] - }, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.99, sum by (le) (rate(litt_flush_latency_seconds_bucket{table=\"ledger\"}[$__rate_interval])))", - "range": true, - "refId": "A", - "legendFormat": "total" - }, - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.99, sum by (le) (rate(litt_segment_flush_latency_seconds_bucket{table=\"ledger\"}[$__rate_interval])))", - "range": true, - "refId": "B", - "legendFormat": "segment" - }, - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.99, sum by (le) (rate(litt_keymap_flush_latency_seconds_bucket{table=\"ledger\"}[$__rate_interval])))", - "range": true, - "refId": "C", - "legendFormat": "keymap" - } - ] - }, - { - "type": "timeseries", - "title": "Garbage Collection Latency", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 27, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 75 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 15, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "s" - }, - "overrides": [] - }, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.5, sum by (le) (rate(litt_garbage_collection_latency_seconds_bucket{table=\"ledger\"}[$__rate_interval])))", - "range": true, - "refId": "A", - "legendFormat": "p50" - }, - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.95, sum by (le) (rate(litt_garbage_collection_latency_seconds_bucket{table=\"ledger\"}[$__rate_interval])))", - "range": true, - "refId": "B", - "legendFormat": "p95" - }, - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.99, sum by (le) (rate(litt_garbage_collection_latency_seconds_bucket{table=\"ledger\"}[$__rate_interval])))", - "range": true, - "refId": "C", - "legendFormat": "p99" - } - ] - }, - { - "type": "row", - "title": "System / Host", - "collapsed": false, - "id": 28, - "panels": [], - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 83 - } - }, - { - "type": "timeseries", - "title": "Disk Usage", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 29, - "gridPos": { - "h": 8, - "w": 8, - "x": 0, - "y": 84 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 15, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "blocksim_data_dir_size_bytes", - "range": true, - "refId": "A", - "legendFormat": "data dir" - }, - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "blocksim_log_dir_size_bytes", - "range": true, - "refId": "B", - "legendFormat": "log dir" - } - ] - }, - { - "type": "timeseries", - "title": "Data Dir Available", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 30, - "gridPos": { - "h": 8, - "w": 8, - "x": 8, - "y": 84 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 15, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "blocksim_data_dir_available_bytes", - "range": true, - "refId": "A", - "legendFormat": "available" - } - ] - }, - { - "type": "timeseries", - "title": "Process IO Throughput (Linux)", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 31, - "gridPos": { - "h": 8, - "w": 8, - "x": 16, - "y": 84 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 15, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "binBps" - }, - "overrides": [] - }, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "rate(blocksim_process_read_bytes_total[$__rate_interval])", - "range": true, - "refId": "A", - "legendFormat": "read/s" - }, - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "rate(blocksim_process_write_bytes_total[$__rate_interval])", - "range": true, - "refId": "B", - "legendFormat": "write/s" - } - ] - }, - { - "type": "timeseries", - "title": "Goroutines", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 32, - "gridPos": { - "h": 8, - "w": 6, - "x": 0, - "y": 92 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 15, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "go_goroutines", - "range": true, - "refId": "A", - "legendFormat": "goroutines" - } - ] - }, - { - "type": "timeseries", - "title": "Go Heap In-Use", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 33, - "gridPos": { - "h": 8, - "w": 6, - "x": 6, - "y": 92 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 15, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "go_memstats_heap_inuse_bytes", - "range": true, - "refId": "A", - "legendFormat": "heap in-use" - } - ] - }, - { - "type": "timeseries", - "title": "Process RSS", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 34, - "gridPos": { - "h": 8, - "w": 6, - "x": 12, - "y": 92 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 15, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "process_resident_memory_bytes", - "range": true, - "refId": "A", - "legendFormat": "rss" - } - ] - }, - { - "type": "timeseries", - "title": "Process CPU (cores)", - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "id": 35, - "gridPos": { - "h": 8, - "w": 6, - "x": 18, - "y": 92 - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 15, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "12.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "rate(process_cpu_seconds_total[$__rate_interval])", - "range": true, - "refId": "A", - "legendFormat": "cpu" - } - ] - } - ], - "preload": false, - "refresh": "10s", - "schemaVersion": 42, - "tags": [ - "blocksim", - "sei-db", - "littdb" - ], - "templating": { - "list": [] - }, - "time": { - "from": "now-1h", - "to": "now" - }, - "timepicker": {}, - "timezone": "browser", - "title": "BlockSim", - "uid": "blocksim-main", - "version": 1, - "weekStart": "" -} diff --git a/evmrpc/server.go b/evmrpc/server.go index 0a03573df0..babd2fdc92 100644 --- a/evmrpc/server.go +++ b/evmrpc/server.go @@ -82,8 +82,6 @@ func NewEVMHTTPServer( EVMTimeout: config.SimulationEVMTimeout, MaxConcurrentSimulationCalls: config.MaxConcurrentSimulationCalls, MaxEstimateGasCalls: config.MaxEstimateGasCalls, - MaxStateOverrideAccounts: config.MaxStateOverrideAccounts, - MaxStateOverrideSlots: config.MaxStateOverrideSlots, } watermarks := NewWatermarkManager(tmClient, ctxProvider, stateStore, k.ReceiptStore()) @@ -276,8 +274,6 @@ func NewEVMWebSocketServer( EVMTimeout: config.SimulationEVMTimeout, MaxConcurrentSimulationCalls: config.MaxConcurrentSimulationCalls, MaxEstimateGasCalls: config.MaxEstimateGasCalls, - MaxStateOverrideAccounts: config.MaxStateOverrideAccounts, - MaxStateOverrideSlots: config.MaxStateOverrideSlots, } watermarks := NewWatermarkManager(tmClient, ctxProvider, stateStore, k.ReceiptStore()) // DB semaphore aligned with worker count diff --git a/evmrpc/setup_test.go b/evmrpc/setup_test.go index 1f2c093a17..7385c12f67 100644 --- a/evmrpc/setup_test.go +++ b/evmrpc/setup_test.go @@ -118,7 +118,7 @@ var TxNonEvm sdk.Tx var TxNonEvmWithSyntheticLog sdk.Tx var UnconfirmedTx sdk.Tx -var SConfig = evmrpc.SimulateConfig{GasCap: 10000000, MaxStateOverrideAccounts: 100, MaxStateOverrideSlots: 1000} +var SConfig = evmrpc.SimulateConfig{GasCap: 10000000} var filterTimeoutDuration = 500 * time.Millisecond var TotalTxCount int = 11 diff --git a/evmrpc/simulate.go b/evmrpc/simulate.go index 6315a1e51b..8f2785a925 100644 --- a/evmrpc/simulate.go +++ b/evmrpc/simulate.go @@ -117,9 +117,6 @@ func (s *SimulationAPI) EstimateGas(ctx context.Context, args export.Transaction defer func() { recordMetricsWithError(ctx, "eth_estimateGas", s.connectionType, startTime, returnErr, recover()) }() - if returnErr = validateStateOverrides(overrides, s.backend.MaxStateOverrideAccounts(), s.backend.MaxStateOverrideSlots()); returnErr != nil { - return - } /* ---------- fail‑fast limiter ---------- */ if s.requestLimiter != nil { if !s.requestLimiter.TryAcquire(1) { @@ -147,9 +144,6 @@ func (s *SimulationAPI) EstimateGasAfterCalls(ctx context.Context, args export.T returnErr = fmt.Errorf("eth_estimateGasAfterCalls: too many calls (%d > %d)", len(calls), maxCalls) return } - if returnErr = validateStateOverrides(overrides, s.backend.MaxStateOverrideAccounts(), s.backend.MaxStateOverrideSlots()); returnErr != nil { - return - } /* ---------- fail‑fast limiter ---------- */ if s.requestLimiter != nil { if !s.requestLimiter.TryAcquire(1) { @@ -172,9 +166,6 @@ func (s *SimulationAPI) Call(ctx context.Context, args export.TransactionArgs, b defer func() { recordMetricsWithError(ctx, "eth_call", s.connectionType, startTime, returnErr, recover()) }() - if returnErr = validateStateOverrides(overrides, s.backend.MaxStateOverrideAccounts(), s.backend.MaxStateOverrideSlots()); returnErr != nil { - return - } /* ---------- fail‑fast limiter ---------- */ if s.requestLimiter != nil { if !s.requestLimiter.TryAcquire(1) { @@ -243,8 +234,6 @@ type SimulateConfig struct { EVMTimeout time.Duration MaxConcurrentSimulationCalls int MaxEstimateGasCalls int - MaxStateOverrideAccounts int - MaxStateOverrideSlots int } var _ tracers.Backend = (*Backend)(nil) @@ -474,32 +463,6 @@ func (b *Backend) RPCEVMTimeout() time.Duration { return b.config.EVMTimeout } func (b *Backend) MaxEstimateGasCalls() int { return b.config.MaxEstimateGasCalls } -func (b *Backend) MaxStateOverrideAccounts() int { return b.config.MaxStateOverrideAccounts } - -func (b *Backend) MaxStateOverrideSlots() int { return b.config.MaxStateOverrideSlots } - -// validateStateOverrides bounds the size of a state override to protect against -// requests that allocate unbounded overlay memory during simulation. -func validateStateOverrides(overrides *export.StateOverride, maxAccounts, maxSlots int) error { - if overrides == nil { - return nil - } - if maxAccounts > 0 && len(*overrides) > maxAccounts { - return fmt.Errorf("state override has too many accounts (%d > %d)", len(*overrides), maxAccounts) - } - if maxSlots > 0 { - for addr, account := range *overrides { - if len(account.State) > maxSlots { - return fmt.Errorf("state override for %s has too many slots (%d > %d)", addr.Hex(), len(account.State), maxSlots) - } - if len(account.StateDiff) > maxSlots { - return fmt.Errorf("stateDiff override for %s has too many slots (%d > %d)", addr.Hex(), len(account.StateDiff), maxSlots) - } - } - } - return nil -} - func (b *Backend) chainConfigForHeight(height int64) *params.ChainConfig { ctx := b.ctxProvider(height) sstore := b.keeper.GetSstoreSetGasEIP2200(ctx) diff --git a/evmrpc/simulate_test.go b/evmrpc/simulate_test.go index 831a7aa1d0..4dcc5f5bb2 100644 --- a/evmrpc/simulate_test.go +++ b/evmrpc/simulate_test.go @@ -331,31 +331,6 @@ func TestCall(t *testing.T) { Ctx = Ctx.WithBlockHeight(8) } -func TestCallStateOverrideTooManySlots(t *testing.T) { - Ctx = Ctx.WithBlockHeight(1) - _, from := testkeeper.MockAddressPair() - _, to := testkeeper.MockAddressPair() - txArgs := map[string]any{ - "from": from.Hex(), - "to": to.Hex(), - "value": "0x0", - "nonce": "0x2", - "chainId": fmt.Sprintf("%#x", EVMKeeper.ChainID(Ctx)), - } - - slots := map[string]any{} - for i := 0; i <= SConfig.MaxStateOverrideSlots; i++ { - slots[common.BigToHash(big.NewInt(int64(i))).Hex()] = common.Hash{}.Hex() - } - overrides := map[string]map[string]any{to.Hex(): {"state": slots}} - - resObj := sendRequestGood(t, "call", txArgs, "latest", overrides) - errMap := resObj["error"].(map[string]any) - require.Contains(t, errMap["message"].(string), "too many slots") - - Ctx = Ctx.WithBlockHeight(8) -} - func TestEthCallHighAmount(t *testing.T) { Ctx = Ctx.WithBlockHeight(1) _, from := testkeeper.MockAddressPair() diff --git a/evmrpc/state_override_validate_test.go b/evmrpc/state_override_validate_test.go deleted file mode 100644 index 331044ddc7..0000000000 --- a/evmrpc/state_override_validate_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package evmrpc - -import ( - "testing" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/export" - "github.com/stretchr/testify/require" -) - -func TestValidateStateOverrides(t *testing.T) { - addr := common.HexToAddress("0x1") - slots := func(n int) map[common.Hash]common.Hash { - m := make(map[common.Hash]common.Hash, n) - for i := 0; i < n; i++ { - m[common.BytesToHash([]byte{byte(i)})] = common.Hash{} - } - return m - } - - require.NoError(t, validateStateOverrides(nil, 10, 10)) - - // within limits - ok := export.StateOverride{addr: {State: slots(5)}} - require.NoError(t, validateStateOverrides(&ok, 10, 10)) - - // too many slots via state - tooManyState := export.StateOverride{addr: {State: slots(11)}} - require.Error(t, validateStateOverrides(&tooManyState, 10, 10)) - - // too many slots via stateDiff - tooManyDiff := export.StateOverride{addr: {StateDiff: slots(11)}} - require.Error(t, validateStateOverrides(&tooManyDiff, 10, 10)) - - // too many accounts - tooManyAccounts := export.StateOverride{ - common.HexToAddress("0x1"): {}, - common.HexToAddress("0x2"): {}, - } - require.Error(t, validateStateOverrides(&tooManyAccounts, 1, 10)) - - // zero disables limit - require.NoError(t, validateStateOverrides(&tooManyState, 0, 0)) -} diff --git a/evmrpc/tracers.go b/evmrpc/tracers.go index c0cdec65cd..b32e369a22 100644 --- a/evmrpc/tracers.go +++ b/evmrpc/tracers.go @@ -626,9 +626,6 @@ func (api *DebugAPI) TraceCall(ctx context.Context, args export.TransactionArgs, return nil, returnErr } - if returnErr = validateStateOverrides(config.StateOverrides, api.backend.MaxStateOverrideAccounts(), api.backend.MaxStateOverrideSlots()); returnErr != nil { - return nil, returnErr - } if returnErr = validateStateOverrides(config.StateOverrides, api.backend.MaxStateOverrideAccounts(), api.backend.MaxStateOverrideSlots()); returnErr != nil { return nil, returnErr } diff --git a/giga/deps/store/cachekv.go b/giga/deps/store/cachekv.go index d38f0b9615..9ee5f20c37 100644 --- a/giga/deps/store/cachekv.go +++ b/giga/deps/store/cachekv.go @@ -6,7 +6,6 @@ import ( "io" "sort" "sync" - "sync/atomic" "github.com/sei-protocol/sei-chain/sei-cosmos/store/tracekv" "github.com/sei-protocol/sei-chain/sei-cosmos/store/types" @@ -22,14 +21,6 @@ type Store struct { parent types.KVStore storeKey types.StoreKey cacheSize int - - // frozen/dirty/readParent implement the same "skip frozen empty layers on - // read" optimization as sei-cosmos/store/cachekv, so that a deep stack of - // empty EVM snapshot layers does not make each Get O(depth). See Freeze and - // readThroughParent there for the full invariant. - frozen atomic.Bool - dirty atomic.Bool - readParent atomic.Pointer[types.KVStore] } var _ types.CacheKVStore = (*Store)(nil) @@ -59,45 +50,7 @@ func (store *Store) getFromCache(key []byte) []byte { if cv, ok := store.cache.Load(UnsafeBytesToStr(key)); ok { return cv.(*types.CValue).Value() } - return store.readThroughParent().Get(key) -} - -// readThroughParent returns the store a cache-missing read must fall through to, -// skipping any run of frozen empty layers. See the equivalently named method in -// sei-cosmos/store/cachekv for the full correctness argument; the invariant is -// identical (a frozen empty layer never gains writes, and RevertToSnapshot -// discards any layer that memoized a skip over a re-exposed one). -func (store *Store) readThroughParent() types.KVStore { - cp, ok := store.parent.(*Store) - if !ok || !cp.frozen.Load() || cp.dirty.Load() { - return store.parent - } - if p := store.readParent.Load(); p != nil { - return *p - } - p := store.parent - for { - next, ok := p.(*Store) - if !ok || !next.frozen.Load() || next.dirty.Load() { - break - } - p = next.parent - } - store.readParent.Store(&p) - return p -} - -// Freeze marks the store as superseded by a newer cache layer so deeper layers -// may skip it for reads while it is empty. Opt-in and idempotent. -func (store *Store) Freeze() { - store.frozen.Store(true) -} - -// Unfreeze reverts Freeze, marking the store writable again (e.g. when a layer is -// re-exposed by RevertToSnapshot). Deeper stores gate on the parent's live frozen -// bit, so unfreezing here also bypasses any stale memo that skipped this store. -func (store *Store) Unfreeze() { - store.frozen.Store(false) + return store.parent.Get(key) } // Get implements types.KVStore. @@ -162,8 +115,6 @@ func (store *Store) Write() { store.cache = &sync.Map{} store.deleted = &sync.Map{} - store.dirty.Store(false) - store.readParent.Store(nil) } // CacheWrap implements CacheWrapper. @@ -191,7 +142,6 @@ func (store *Store) setCacheValue(key, value []byte, deleted bool, dirty bool) { } else { store.deleted.Delete(keyStr) } - store.dirty.Store(true) } func (store *Store) isDeleted(key string) bool { diff --git a/giga/deps/store/frozen_skip_test.go b/giga/deps/store/frozen_skip_test.go deleted file mode 100644 index 3b641bb68e..0000000000 --- a/giga/deps/store/frozen_skip_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package store_test - -import ( - "testing" - - "github.com/stretchr/testify/require" - dbm "github.com/tendermint/tm-db" - - gigastore "github.com/sei-protocol/sei-chain/giga/deps/store" - "github.com/sei-protocol/sei-chain/sei-cosmos/store/dbadapter" - "github.com/sei-protocol/sei-chain/sei-cosmos/store/types" -) - -func bz(s string) []byte { return []byte(s) } - -// TestGigaFrozenEmptyLayerSkip verifies that Get through a deep stack of frozen, -// empty giga cache layers returns exactly the base value (the giga store is used -// for the evm/bank stores, so EVM SLOAD at call depth N would otherwise walk N -// layers). -func TestGigaFrozenEmptyLayerSkip(t *testing.T) { - mem := dbadapter.Store{DB: dbm.NewMemDB()} - mem.Set(bz("k1"), bz("v1")) - mem.Set(bz("k2"), bz("v2")) - - var parent types.KVStore = mem - layers := make([]*gigastore.Store, 32) - for i := 0; i < 32; i++ { - s := gigastore.NewStore(parent, types.NewKVStoreKey("giga"), types.DefaultCacheSizeLimit) - layers[i] = s - parent = s - } - for i := 0; i < len(layers)-1; i++ { - layers[i].Freeze() - } - top := layers[len(layers)-1] - - require.Equal(t, bz("v1"), top.Get(bz("k1"))) - require.Equal(t, bz("v2"), top.Get(bz("k2"))) - require.Nil(t, top.Get(bz("missing"))) -} - -// TestGigaFrozenLayerWithWriteNotSkipped verifies a frozen giga layer with a write -// (or delete) still shadows the base and is never skipped. -func TestGigaFrozenLayerWithWriteNotSkipped(t *testing.T) { - mem := dbadapter.Store{DB: dbm.NewMemDB()} - mem.Set(bz("k1"), bz("v1")) - mem.Set(bz("k2"), bz("v2")) - - mid := gigastore.NewStore(mem, types.NewKVStoreKey("giga"), types.DefaultCacheSizeLimit) - mid.Set(bz("k1"), bz("override")) - mid.Delete(bz("k2")) - mid.Freeze() - - top := gigastore.NewStore(mid, types.NewKVStoreKey("giga"), types.DefaultCacheSizeLimit) - require.Equal(t, bz("override"), top.Get(bz("k1"))) - require.Nil(t, top.Get(bz("k2"))) -} diff --git a/giga/deps/xbank/keeper/keeper.go b/giga/deps/xbank/keeper/keeper.go index 57d74e0744..54cff2ef59 100644 --- a/giga/deps/xbank/keeper/keeper.go +++ b/giga/deps/xbank/keeper/keeper.go @@ -494,7 +494,7 @@ func (k BaseKeeper) createCoins(ctx sdk.Context, moduleName string, amounts sdk. k.SetSupply(ctx, supply) } - logger.Debug("minted coins from module account", "amount", amounts, "from", moduleName) + logger.Info("minted coins from module account", "amount", amounts, "from", moduleName) // emit mint event ctx.EventManager().EmitEvent( diff --git a/giga/deps/xevm/state/mock_balances.go b/giga/deps/xevm/state/mock_balances.go index d2b6a4ba3d..5fe2bfef28 100644 --- a/giga/deps/xevm/state/mock_balances.go +++ b/giga/deps/xevm/state/mock_balances.go @@ -59,11 +59,13 @@ func (s *DBImpl) topOffAccount(seiAddr sdk.AccAddress, amt *big.Int) { s.k.AccountKeeper().SetAccount(s.ctx, s.k.AccountKeeper().NewAccountWithAddress(s.ctx, seiAddr)) } + // Mint and send (use NopLogger to suppress log spam) usei, wei := SplitUseiWeiAmount(amt) coinsAmt := sdk.NewCoins(sdk.NewCoin(s.k.GetBaseDenom(s.ctx), usei.Add(sdk.OneInt()))) - if err := s.k.BankKeeper().MintCoins(s.ctx, types.ModuleName, coinsAmt); err != nil { + ctx := s.ctx + if err := s.k.BankKeeper().MintCoins(ctx, types.ModuleName, coinsAmt); err != nil { return } moduleAddr := s.k.AccountKeeper().GetModuleAddress(types.ModuleName) - _ = s.k.BankKeeper().SendCoinsAndWei(s.ctx, moduleAddr, seiAddr, usei, wei) + _ = s.k.BankKeeper().SendCoinsAndWei(ctx, moduleAddr, seiAddr, usei, wei) } diff --git a/giga/deps/xevm/state/state.go b/giga/deps/xevm/state/state.go index b96fe41ff2..e1a8d15b96 100644 --- a/giga/deps/xevm/state/state.go +++ b/giga/deps/xevm/state/state.go @@ -113,18 +113,7 @@ func (s *DBImpl) AnySelfDestructed() bool { } func (s *DBImpl) Snapshot() int { - oldMS := s.ctx.MultiStore() - newCtx := s.ctx.WithMultiStore(oldMS.CacheMultiStore()).WithEventManager(sdk.NewEventManager()) - // Freeze the superseded layer so deeper layers can skip it for reads while it - // stays empty; this keeps a Cosmos read at call depth N from walking all N - // nested cache layers (quadratic). Never freeze the base layer - // (snapshottedCtxs[0]) — it is the flush target and is read directly by - // GetCommittedState. See x/evm/state/state.go for the full rationale. - if len(s.snapshottedCtxs) > 0 { - if f, ok := oldMS.(interface{ Freeze() }); ok { - f.Freeze() - } - } + newCtx := s.ctx.WithMultiStore(s.ctx.MultiStore().CacheMultiStore()).WithEventManager(sdk.NewEventManager()) s.snapshottedCtxs = append(s.snapshottedCtxs, s.ctx) s.ctx = newCtx version := len(s.snapshottedCtxs) - 1 @@ -141,13 +130,6 @@ func (s *DBImpl) RevertToSnapshot(rev int) { s.ctx = s.snapshottedCtxs[rev] s.snapshottedCtxs = s.snapshottedCtxs[:rev] - // The re-exposed layer is the writable top again; unfreeze it so reads no - // longer skip it (Snapshot froze it when it was superseded). See - // x/evm/state/state.go for the rationale. - if f, ok := s.ctx.MultiStore().(interface{ Unfreeze() }); ok { - f.Unfreeze() - } - // Find the watermark index to truncate the journal watermarkIndex := -1 for i := len(s.journal) - 1; i >= 0; i-- { diff --git a/giga/executor/utils/errors.go b/giga/executor/utils/errors.go index b42276fad0..182bd998a1 100644 --- a/giga/executor/utils/errors.go +++ b/giga/executor/utils/errors.go @@ -6,27 +6,12 @@ import ( const ( // GigaAbortCodespace and GigaAbortCode identify a sentinel ResponseDeliverTx - // that signals the caller to fall back to the v2 execution path. The - // response's Info field carries the abort cause for the fallback metric. + // that signals the caller to fall back to the v2 execution path. GigaAbortCodespace = "giga" GigaAbortCode = uint32(1) + GigaAbortInfo = "giga_fallback_to_v2" ) -// ValidationFailedAbortError signals an EVM validation failure (fee/nonce/ -// balance), whose canonical failure receipt only v2's ante chain can produce; -// the caller should fall back to v2. -type ValidationFailedAbortError struct{} - -func (e *ValidationFailedAbortError) Error() string { - return "EVM validation failed; v2 produces the canonical failure receipt" -} - -func (e *ValidationFailedAbortError) IsAbortError() bool { - return true -} - -var ErrValidationFailed error = &ValidationFailedAbortError{} - // ShouldExecutionAbort checks if the given error is an AbortError that should // cause Giga execution to abort and fall back to standard execution. func ShouldExecutionAbort(err error) bool { diff --git a/giga/tests/giga_mock_balances_test.go b/giga/tests/giga_mock_balances_test.go deleted file mode 100644 index 76dbdafe00..0000000000 --- a/giga/tests/giga_mock_balances_test.go +++ /dev/null @@ -1,32 +0,0 @@ -//go:build mock_balances - -package giga_test - -import ( - "math/big" - "testing" - "time" - - "github.com/sei-protocol/sei-chain/occ_tests/utils" - "github.com/stretchr/testify/require" -) - -func TestGigaMockBalancesValidationUsesDBImpl(t *testing.T) { - blockTime := time.Now() - accts := utils.NewTestAccounts(3) - signer := utils.NewSigner() - recipient := utils.NewSigner() - - gigaCtx := NewGigaTestContext(t, accts, blockTime, 1, ModeGigaSequential) - gigaCtx.TestApp.GigaEvmKeeper.SetAddressMapping(gigaCtx.Ctx, signer.AccountAddress, signer.EvmAddress) - - to := recipient.EvmAddress - value := big.NewInt(1_000_000_000_000_000_000) - fee := big.NewInt(100000000000) - tx := createCustomEVMTx(t, gigaCtx, signer, &to, value, 21000, fee, fee, 0) - - _, results, err := RunBlock(t, gigaCtx, [][]byte{tx}) - require.NoError(t, err) - require.Len(t, results, 1) - require.Equal(t, uint32(0), results[0].Code, results[0].Log) -} diff --git a/giga/tests/giga_test.go b/giga/tests/giga_test.go index 11210b1e21..715eb14187 100644 --- a/giga/tests/giga_test.go +++ b/giga/tests/giga_test.go @@ -2411,12 +2411,6 @@ func TestGigaValidation_InsufficientBalance(t *testing.T) { gigaNonce := gigaCtx.TestApp.GigaEvmKeeper.GetNonce(gigaCtx.Ctx, signer.EvmAddress) require.Equal(t, v2Nonce, gigaNonce, "Nonce should match after balance validation failure") require.Equal(t, uint64(1), gigaNonce, "Nonce should be bumped to 1") - - // The failure receipt must be byte-identical across executors: its - // Code/Data/GasWanted/GasUsed feed LastResultsHash, and any divergence - // splits consensus on a mixed fleet (CON-368). - CompareDeterministicFields(t, "InsufficientBalance", v2Results, gigaResults) - CompareLastResultsHash(t, "InsufficientBalance", v2Results, gigaResults) } // Note: TestGigaValidation_TipCapGreaterThanFeeCap is not possible because @@ -2424,12 +2418,11 @@ func TestGigaValidation_InsufficientBalance(t *testing.T) { // The check in validateGigaEVMTx exists for defense-in-depth but can't be tested // through the normal transaction creation flow. -// TestGigaValidation_FailureReceiptParity asserts a validation-failed tx -// produces a byte-identical receipt under both executors. Giga routes -// validation failures to v2 (per-tx fallback, same doctrine as balance -// migration), so the ante-abort receipt (including the gas fields that feed -// LastResultsHash) matches by construction (CON-368). -func TestGigaValidation_FailureReceiptParity(t *testing.T) { +// TestGigaValidation_GasReportedOnFailure tests that gas is reported on validation failure. +// Note: V2 and Giga may report different GasUsed values on validation failures due to +// differences in how the ante handler chain reports gas consumption. The critical parity +// requirement is that error codes match (for consensus on tx success/failure). +func TestGigaValidation_GasReportedOnFailure(t *testing.T) { blockTime := time.Now() accts := utils.NewTestAccounts(3) @@ -2459,15 +2452,26 @@ func TestGigaValidation_FailureReceiptParity(t *testing.T) { require.Len(t, v2Results, 1) require.Len(t, gigaResults, 1) - require.NotEqual(t, uint32(0), v2Results[0].Code, "tx should fail validation") - CompareDeterministicFields(t, "FailureReceiptParity", v2Results, gigaResults) - CompareLastResultsHash(t, "FailureReceiptParity", v2Results, gigaResults) + // Error codes must match (critical for consensus) + require.Equal(t, v2Results[0].Code, gigaResults[0].Code, + "Error codes should match: V2=%d Giga=%d", v2Results[0].Code, gigaResults[0].Code) + + // Both should report non-zero gas values + require.Greater(t, gigaResults[0].GasUsed, int64(0), "Giga should report GasUsed > 0") + require.Greater(t, gigaResults[0].GasWanted, int64(0), "Giga should report GasWanted > 0") + + // Log the difference for visibility (not a failure) + if v2Results[0].GasUsed != gigaResults[0].GasUsed { + t.Logf("Note: GasUsed differs on validation failure - V2=%d, Giga=%d (expected due to ante handler differences)", + v2Results[0].GasUsed, gigaResults[0].GasUsed) + } } -// TestGigaValidation_MixedBlockReceiptParity runs a mixed valid/invalid block -// and asserts the full receipts match between V2 and Giga, since every -// deterministic receipt field feeds LastResultsHash. -func TestGigaValidation_MixedBlockReceiptParity(t *testing.T) { +// TestGigaValidation_ErrorCodeParity tests that validation failures produce +// identical error codes between V2 and Giga (critical for consensus on tx success/failure). +// Note: GasUsed may differ between V2 and Giga on validation failures due to ante handler +// differences, but this doesn't affect consensus on whether a tx succeeded or failed. +func TestGigaValidation_ErrorCodeParity(t *testing.T) { blockTime := time.Now() accts := utils.NewTestAccounts(5) @@ -2516,10 +2520,17 @@ func TestGigaValidation_MixedBlockReceiptParity(t *testing.T) { require.Len(t, v2Results, 3) require.Len(t, gigaResults, 3) - // Receipts must be byte-identical: Code/Data/GasWanted/GasUsed feed - // LastResultsHash (CON-368). - CompareDeterministicFields(t, "MixedBlockReceiptParity", v2Results, gigaResults) - CompareLastResultsHash(t, "MixedBlockReceiptParity", v2Results, gigaResults) + // Compare error codes (critical for consensus) + for i := range v2Results { + require.Equal(t, v2Results[i].Code, gigaResults[i].Code, + "tx[%d] Code mismatch: V2=%d Giga=%d", i, v2Results[i].Code, gigaResults[i].Code) + + // Log gas differences for visibility + if v2Results[i].GasUsed != gigaResults[i].GasUsed { + t.Logf("tx[%d] GasUsed differs: V2=%d Giga=%d (expected for validation failures)", + i, v2Results[i].GasUsed, gigaResults[i].GasUsed) + } + } // Verify expected outcomes require.Equal(t, uint32(0), v2Results[0].Code, "tx[0] should succeed") @@ -2527,198 +2538,6 @@ func TestGigaValidation_MixedBlockReceiptParity(t *testing.T) { require.NotEqual(t, uint32(0), v2Results[2].Code, "tx[2] should fail (nonce too high)") } -// TestGigaValidation_DrainRace_LastResultsHash reproduces CON-368's block -// shape: tx0 drains the sender far enough that tx1 (same sender, next nonce) -// fails its fee check at delivery, a tx that passes admission but fails in -// the block. Receipts must hash identically across executors; under OCC the -// giga batch re-runs through the v2 OCC scheduler. -func TestGigaValidation_DrainRace_LastResultsHash(t *testing.T) { - runDrainRace := func(t *testing.T, gigaMode ExecutorMode) { - blockTime := time.Now() - accts := utils.NewTestAccounts(3) - signer := utils.NewSigner() - sink := utils.NewSigner() - to := sink.EvmAddress - - normalFee := big.NewInt(100000000000) // 21000 gas => 2.1e15 wei max fee - fund := big.NewInt(3e15) // covers tx0's value+fee, not tx1's fee - drain := big.NewInt(5e14) - - v2Ctx := NewGigaTestContext(t, accts, blockTime, 1, ModeV2Sequential) - fundAccount(t, v2Ctx, signer.AccountAddress, fund) - v2Ctx.TestApp.EvmKeeper.SetAddressMapping(v2Ctx.Ctx, signer.AccountAddress, signer.EvmAddress) - v2Txs := [][]byte{ - createCustomEVMTx(t, v2Ctx, signer, &to, drain, 21000, normalFee, normalFee, 0), - createCustomEVMTx(t, v2Ctx, signer, &to, big.NewInt(1), 21000, normalFee, normalFee, 1), - } - _, v2Results, _ := RunBlock(t, v2Ctx, v2Txs) - - gigaCtx := NewGigaTestContext(t, accts, blockTime, 1, gigaMode) - fundAccount(t, gigaCtx, signer.AccountAddress, fund) - gigaCtx.TestApp.GigaEvmKeeper.SetAddressMapping(gigaCtx.Ctx, signer.AccountAddress, signer.EvmAddress) - gigaTxs := [][]byte{ - createCustomEVMTx(t, gigaCtx, signer, &to, drain, 21000, normalFee, normalFee, 0), - createCustomEVMTx(t, gigaCtx, signer, &to, big.NewInt(1), 21000, normalFee, normalFee, 1), - } - _, gigaResults, _ := RunBlock(t, gigaCtx, gigaTxs) - - require.Len(t, v2Results, 2) - require.Len(t, gigaResults, 2) - require.Equal(t, uint32(0), v2Results[0].Code, "tx0 (drain) should succeed") - require.NotEqual(t, uint32(0), v2Results[1].Code, "tx1 should fail its fee check at delivery") - CompareDeterministicFields(t, "DrainRace", v2Results, gigaResults) - CompareLastResultsHash(t, "DrainRace", v2Results, gigaResults) - } - for _, mode := range []ExecutorMode{ModeGigaSequential, ModeGigaOCC} { - t.Run(mode.String(), func(t *testing.T) { runDrainRace(t, mode) }) - } -} - -// TestGigaValidation_ValueExceedsBalance_LastResultsHash covers the balance -// check's scope mismatch: giga's pre-check rejects fee+value > balance while -// v2's ante only requires the fee, running the tx to its EVM outcome. The -// fallback makes giga adopt v2's receipt, keeping the executors code- and -// hash-identical for this class too. -func TestGigaValidation_ValueExceedsBalance_LastResultsHash(t *testing.T) { - blockTime := time.Now() - accts := utils.NewTestAccounts(3) - signer := utils.NewSigner() - recipient := utils.NewSigner() - to := recipient.EvmAddress - - normalFee := big.NewInt(100000000000) - fund := big.NewInt(3e15) // covers the 2.1e15 max fee... - value := big.NewInt(2e15) // ...but not fee+value - - v2Ctx := NewGigaTestContext(t, accts, blockTime, 1, ModeV2Sequential) - fundAccount(t, v2Ctx, signer.AccountAddress, fund) - v2Ctx.TestApp.EvmKeeper.SetAddressMapping(v2Ctx.Ctx, signer.AccountAddress, signer.EvmAddress) - v2Tx := createCustomEVMTx(t, v2Ctx, signer, &to, value, 21000, normalFee, normalFee, 0) - _, v2Results, _ := RunBlock(t, v2Ctx, [][]byte{v2Tx}) - - gigaCtx := NewGigaTestContext(t, accts, blockTime, 1, ModeGigaSequential) - fundAccount(t, gigaCtx, signer.AccountAddress, fund) - gigaCtx.TestApp.GigaEvmKeeper.SetAddressMapping(gigaCtx.Ctx, signer.AccountAddress, signer.EvmAddress) - gigaTx := createCustomEVMTx(t, gigaCtx, signer, &to, value, 21000, normalFee, normalFee, 0) - _, gigaResults, _ := RunBlock(t, gigaCtx, [][]byte{gigaTx}) - - require.Len(t, v2Results, 1) - require.Len(t, gigaResults, 1) - CompareDeterministicFields(t, "ValueExceedsBalance", v2Results, gigaResults) - CompareLastResultsHash(t, "ValueExceedsBalance", v2Results, gigaResults) -} - -// TestGigaValidation_FailureClassParity_AllModes is the forward-looking -// coverage net for CON-368's class: every delivery-time EVM failure it can -// construct through the normal signing flow, run under both giga modes, -// asserting full receipt and LastResultsHash parity against v2. New -// validation branches in validateGigaEVMTx should gain a case here. -// -// Not constructible through this harness (documented, not silently skipped): -// tip > feeCap (go-ethereum rejects at signing), sender-not-EOA (requires -// code at a signing address), and fee-cap-below-base-fee when the test -// chain's base fee is zero. -func TestGigaValidation_FailureClassParity_AllModes(t *testing.T) { - require.False(t, app.MockBalancesEnabled, - "parity tests are meaningless under the mock_balances build tag: it tops off the very accounts whose failures are under test") - - type failureCase struct { - name string - // build returns the tx bytes for a context; called once per context so - // signing uses each context's chain setup. - build func(t *testing.T, tCtx *GigaTestContext, signer utils.TestAcct, to common.Address) [][]byte - } - - normalFee := big.NewInt(100000000000) // 21000 gas => 2.1e15 wei max fee - - cases := []failureCase{ - { - // fee alone exceeds balance: v2's ante-abort receipt (CON-368's tx) - name: "FeeExceedsBalance", - build: func(t *testing.T, tCtx *GigaTestContext, signer utils.TestAcct, to common.Address) [][]byte { - fundAccount(t, tCtx, signer.AccountAddress, big.NewInt(1e12)) - return [][]byte{createCustomEVMTx(t, tCtx, signer, &to, big.NewInt(1), 21000, normalFee, normalFee, 0)} - }, - }, - { - // fee affordable, fee+value not: giga's pre-check scope differs - // from v2's ante (fee-only), so parity relies on the fallback - name: "ValueExceedsBalance", - build: func(t *testing.T, tCtx *GigaTestContext, signer utils.TestAcct, to common.Address) [][]byte { - fundAccount(t, tCtx, signer.AccountAddress, big.NewInt(3e15)) - return [][]byte{createCustomEVMTx(t, tCtx, signer, &to, big.NewInt(2e15), 21000, normalFee, normalFee, 0)} - }, - }, - { - name: "NonceTooHigh", - build: func(t *testing.T, tCtx *GigaTestContext, signer utils.TestAcct, to common.Address) [][]byte { - fundAccount(t, tCtx, signer.AccountAddress, big.NewInt(1e18)) - return [][]byte{createCustomEVMTx(t, tCtx, signer, &to, big.NewInt(1), 21000, normalFee, normalFee, 7)} - }, - }, - { - // nonce 0 succeeds, then a second tx replays nonce 0 - name: "NonceTooLow", - build: func(t *testing.T, tCtx *GigaTestContext, signer utils.TestAcct, to common.Address) [][]byte { - fundAccount(t, tCtx, signer.AccountAddress, big.NewInt(1e18)) - return [][]byte{ - createCustomEVMTx(t, tCtx, signer, &to, big.NewInt(1), 21000, normalFee, normalFee, 0), - createCustomEVMTx(t, tCtx, signer, &to, big.NewInt(2), 21000, normalFee, normalFee, 0), - } - }, - }, - { - // CON-368's exact block shape: tx0 drains the fee balance out - // from under tx1 - name: "DrainRace", - build: func(t *testing.T, tCtx *GigaTestContext, signer utils.TestAcct, to common.Address) [][]byte { - fundAccount(t, tCtx, signer.AccountAddress, big.NewInt(3e15)) - return [][]byte{ - createCustomEVMTx(t, tCtx, signer, &to, big.NewInt(5e14), 21000, normalFee, normalFee, 0), - createCustomEVMTx(t, tCtx, signer, &to, big.NewInt(1), 21000, normalFee, normalFee, 1), - } - }, - }, - } - - gigaModes := []ExecutorMode{ModeGigaSequential, ModeGigaOCC} - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - for _, gm := range gigaModes { - t.Run(gm.String(), func(t *testing.T) { - blockTime := time.Now() - accts := utils.NewTestAccounts(3) - signer := utils.NewSigner() - recipient := utils.NewSigner() - to := recipient.EvmAddress - - v2Ctx := NewGigaTestContext(t, accts, blockTime, 1, ModeV2Sequential) - v2Ctx.TestApp.EvmKeeper.SetAddressMapping(v2Ctx.Ctx, signer.AccountAddress, signer.EvmAddress) - v2Txs := tc.build(t, v2Ctx, signer, to) - _, v2Results, _ := RunBlock(t, v2Ctx, v2Txs) - - gigaCtx := NewGigaTestContext(t, accts, blockTime, 1, gm) - gigaCtx.TestApp.GigaEvmKeeper.SetAddressMapping(gigaCtx.Ctx, signer.AccountAddress, signer.EvmAddress) - gigaTxs := tc.build(t, gigaCtx, signer, to) - _, gigaResults, _ := RunBlock(t, gigaCtx, gigaTxs) - - require.Len(t, gigaResults, len(v2Results)) - failed := 0 - for _, r := range v2Results { - if r.Code != 0 { - failed++ - } - } - require.Greater(t, failed, 0, "case must produce at least one failed tx under v2, or it covers nothing") - CompareDeterministicFields(t, tc.name+"/"+gm.String(), v2Results, gigaResults) - CompareLastResultsHash(t, tc.name+"/"+gm.String(), v2Results, gigaResults) - }) - } - }) - } -} - // TestGigaValidation_FeeCapBelowMinimumFee tests that fee cap < minimum fee is rejected. func TestGigaValidation_FeeCapBelowMinimumFee(t *testing.T) { blockTime := time.Now() diff --git a/go.mod b/go.mod index 66bfbda919..1b94a8aef9 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/ethereum/go-ethereum v1.16.8 github.com/fortytw2/leaktest v1.3.0 github.com/go-git/go-git/v5 v5.17.2 + github.com/go-kit/kit v0.13.0 github.com/gofrs/flock v0.13.0 github.com/gogo/gateway v1.1.0 github.com/gogo/protobuf v1.3.3 @@ -106,6 +107,7 @@ require ( google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v3 v3.0.1 + k8s.io/component-base v0.35.0 pgregory.net/rapid v1.2.0 ) @@ -121,8 +123,6 @@ require ( github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/moby/term v0.5.0 // indirect - github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect diff --git a/go.sum b/go.sum index 86a857402a..f9a82a3eff 100644 --- a/go.sum +++ b/go.sum @@ -656,6 +656,7 @@ github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMx github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/VictoriaMetrics/fastcache v1.12.2 h1:N0y9ASrJ0F6h0QaC3o6uJb3NIZ9VKLjCM7NQbSmF7WI= github.com/VictoriaMetrics/fastcache v1.12.2/go.mod h1:AmC+Nzz1+3G2eCPapF6UcsnkThDcMsQicp4xDukwJYI= +github.com/VividCortex/gohistogram v1.0.0 h1:6+hBz+qvs0JOrrNhhmR7lFxo5sINxBCGXrdtl/UvroE= github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g= github.com/Zilliqa/gozilliqa-sdk v1.2.1-0.20201201074141-dd0ecada1be6/go.mod h1:eSYp2T6f0apnuW8TzhV3f6Aff2SE8Dwio++U4ha4yEM= github.com/aclements/go-gg v0.0.0-20170118225347-6dbb4e4fefb0/go.mod h1:55qNq4vcpkIuHowELi5C8e+1yUHtoLoOUR9QU5j7Tes= @@ -1039,6 +1040,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2 github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o= +github.com/go-kit/kit v0.13.0 h1:OoneCcHKHQ03LfBpoQCUfCluwd2Vt3ohz+kvbJneZAU= +github.com/go-kit/kit v0.13.0/go.mod h1:phqEHMMUbyrCFCTgH48JueqrM3md2HcAZ8N3XE4FKDg= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-latex/latex v0.0.0-20210118124228-b3d85cf34e07/go.mod h1:CO1AlKB2CSIqUrmQPqA0gdRIlnLEY0gK5JGjh37zN5U= github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81/go.mod h1:SX0U8uGpxhq9o2S/CELCSUxEWWAuoCUcVCQWv7G2OCk= @@ -2898,6 +2901,8 @@ honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las= +k8s.io/component-base v0.35.0 h1:+yBrOhzri2S1BVqyVSvcM3PtPyx5GUxCK2tinZz1G94= +k8s.io/component-base v0.35.0/go.mod h1:85SCX4UCa6SCFt6p3IKAPej7jSnF3L8EbfSyMZayJR0= lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= modernc.org/cc/v3 v3.36.0/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= diff --git a/inprocess/harness.go b/inprocess/harness.go index 7b75f134f6..c581ce02d7 100644 --- a/inprocess/harness.go +++ b/inprocess/harness.go @@ -384,7 +384,7 @@ func (net *Network) startNode(ctx context.Context, n *node, enc encoding) error tmNode, err := tmnode.New( ctx, n.tmCfg, func() {}, theApp, genDoc, - []trace.TracerProviderOption{}, + []trace.TracerProviderOption{}, tmnode.NoOpMetricsProvider(), tmtypes.DefaultConsensusPolicy(), ) if err != nil { diff --git a/integration_test/contracts/verify_cross_validator_flatkv_digest.sh b/integration_test/contracts/verify_cross_validator_flatkv_digest.sh index 7ef88bcc2a..3cf9000af8 100755 --- a/integration_test/contracts/verify_cross_validator_flatkv_digest.sh +++ b/integration_test/contracts/verify_cross_validator_flatkv_digest.sh @@ -28,7 +28,7 @@ # input, or a write path bypassing the hash entirely) would not halt # consensus. This script provides an independent physical-level check # against that whole class of silent drift. It is intended for -# GIGA_STORAGE=true jobs, so misc is intentionally included in the digest. +# GIGA_STORAGE=true jobs, so legacy is intentionally included in the digest. set -euo pipefail @@ -138,7 +138,7 @@ flatkv_dump_digest() { --db-dir $FLATKV_DIR \ --output-dir \"\$out_dir\" \ --height $version > /dev/null - tail -q -n +2 \"\$out_dir/account\" \"\$out_dir/code\" \"\$out_dir/storage\" \"\$out_dir/misc\" \ + tail -q -n +2 \"\$out_dir/account\" \"\$out_dir/code\" \"\$out_dir/storage\" \"\$out_dir/legacy\" \ | sha256sum | cut -d' ' -f1 " } diff --git a/integration_test/contracts/verify_flatkv_crash_recovery.sh b/integration_test/contracts/verify_flatkv_crash_recovery.sh index 611257d598..a56ce79448 100755 --- a/integration_test/contracts/verify_flatkv_crash_recovery.sh +++ b/integration_test/contracts/verify_flatkv_crash_recovery.sh @@ -137,7 +137,7 @@ flatkv_dump_digest() { --db-dir $FLATKV_DIR \ --output-dir \"\$out_dir\" \ --height $version > /dev/null - # Hash canonical EVM buckets only. The misc bucket is a fallback path for + # Hash canonical EVM buckets only. The legacy bucket is a fallback path for # non-EVM module-prefixed rows and can contain validator-local dual-write # noise in post-import test clusters. tail -q -n +2 \"\$out_dir/account\" \"\$out_dir/code\" \"\$out_dir/storage\" \ diff --git a/integration_test/contracts/verify_flatkv_only_statesync.sh b/integration_test/contracts/verify_flatkv_only_statesync.sh index c35caa1bf1..5f65e1c2a6 100755 --- a/integration_test/contracts/verify_flatkv_only_statesync.sh +++ b/integration_test/contracts/verify_flatkv_only_statesync.sh @@ -353,7 +353,7 @@ flatkv_dump_digest() { --db-dir $FLATKV_DIR \ --output-dir \"\$out_dir\" \ --height $version > /dev/null - tail -q -n +2 \"\$out_dir/account\" \"\$out_dir/code\" \"\$out_dir/storage\" \"\$out_dir/misc\" \ + tail -q -n +2 \"\$out_dir/account\" \"\$out_dir/code\" \"\$out_dir/storage\" \"\$out_dir/legacy\" \ | sha256sum | cut -d' ' -f1 " } diff --git a/integration_test/contracts/verify_flatkv_partial_loss_fails_loudly.sh b/integration_test/contracts/verify_flatkv_partial_loss_fails_loudly.sh index 1836bfeded..9d0b6e8b56 100755 --- a/integration_test/contracts/verify_flatkv_partial_loss_fails_loudly.sh +++ b/integration_test/contracts/verify_flatkv_partial_loss_fails_loudly.sh @@ -137,7 +137,7 @@ flatkv_dump_digest() { --db-dir $FLATKV_DIR \ --output-dir \"\$out_dir\" \ --height $version > /dev/null - # Hash canonical EVM buckets only. The misc bucket is a fallback path for + # Hash canonical EVM buckets only. The legacy bucket is a fallback path for # non-EVM module-prefixed rows and can contain validator-local dual-write # noise in post-import test clusters. tail -q -n +2 \"\$out_dir/account\" \"\$out_dir/code\" \"\$out_dir/storage\" \ diff --git a/integration_test/contracts/verify_flatkv_statesync_crash_recovery.sh b/integration_test/contracts/verify_flatkv_statesync_crash_recovery.sh index 29c457fba6..20607a089b 100755 --- a/integration_test/contracts/verify_flatkv_statesync_crash_recovery.sh +++ b/integration_test/contracts/verify_flatkv_statesync_crash_recovery.sh @@ -179,7 +179,7 @@ dump_flatkv_bucket_summary() { contract_hex=\$(tail -1 integration_test/contracts/flatkv_evm_contract_addr.txt 2>/dev/null | sed 's/^0x//' | tr '[:lower:]' '[:upper:]') storage_hex=\$(tail -1 integration_test/contracts/flatkv_evm_storage_expected.txt 2>/dev/null | sed 's/^0x//' | tr '[:lower:]' '[:upper:]') code_hex=\$(tail -1 integration_test/contracts/flatkv_evm_code_expected.txt 2>/dev/null | sed 's/^0x//' | tr '[:lower:]' '[:upper:]') - for b in account code storage misc; do + for b in account code storage legacy; do f=\"\$out_dir/\$b\" if [ -s \"\$f\" ]; then n=\$(wc -l < \"\$f\") @@ -484,7 +484,7 @@ assert_flatkv_dump_contains_fixture() { # default-value EVM state (nonce=0, codehash=keccak('')) is never # persisted by Sei's EVM keeper, so the recipient never appears in # FlatKV's account bucket on any node, including donors -- diagnostics - # confirmed donor itself has 0 hits in account/code/storage/misc. + # confirmed donor itself has 0 hits in account/code/storage/legacy. # The native-transfer balance is held in the bank module, whose # changesets are not routed to FlatKV in dual_write mode at all # (only EVM-named changesets are). Recipient liveness is instead diff --git a/integration_test/contracts/verify_flatkv_total_loss_recovery.sh b/integration_test/contracts/verify_flatkv_total_loss_recovery.sh index 9faed5e5a8..6ba870bb80 100755 --- a/integration_test/contracts/verify_flatkv_total_loss_recovery.sh +++ b/integration_test/contracts/verify_flatkv_total_loss_recovery.sh @@ -345,7 +345,7 @@ assert_flatkv_dump_contains_fixture() { # bucket=account recipient_hits=0 contract_hits=1 # bucket=code recipient_hits=0 contract_hits=1 code_hits=1 # bucket=storage recipient_hits=0 contract_hits=1 storage_hits=1 - # bucket=misc recipient_hits=0 contract_hits=3 + # bucket=legacy recipient_hits=0 contract_hits=3 # Reason: a fresh-EOA recipient of a native EVM transfer keeps the # default nonce=0 / codehash=keccak('') values that Sei's EVM keeper # never persists, so memiavl never holds a row for it (offline import diff --git a/integration_test/contracts/verify_statesync_flatkv_digest.sh b/integration_test/contracts/verify_statesync_flatkv_digest.sh index 68ea46d8c3..ba2cf5a10d 100755 --- a/integration_test/contracts/verify_statesync_flatkv_digest.sh +++ b/integration_test/contracts/verify_statesync_flatkv_digest.sh @@ -25,7 +25,7 @@ # produces wrong content at H_sync also produces wrong content at every # height > H_sync (replay is a pure function of state at H_sync), so # comparing at any shared post-sync height is sufficient. This script is -# intended for GIGA_STORAGE=true jobs; all FlatKV buckets, including misc, +# intended for GIGA_STORAGE=true jobs; all FlatKV buckets, including legacy, # are included in the digest. set -euo pipefail @@ -127,7 +127,7 @@ flatkv_dump_digest() { --db-dir $FLATKV_DIR \ --output-dir \"\$out_dir\" \ --height $version > /dev/null - tail -q -n +2 \"\$out_dir/account\" \"\$out_dir/code\" \"\$out_dir/storage\" \"\$out_dir/misc\" \ + tail -q -n +2 \"\$out_dir/account\" \"\$out_dir/code\" \"\$out_dir/storage\" \"\$out_dir/legacy\" \ | sha256sum | cut -d' ' -f1 " } diff --git a/integration_test/evm_module/rpc_io_test/RPC_IO_README.md b/integration_test/evm_module/rpc_io_test/RPC_IO_README.md index 32869e249f..e6a2404cd5 100644 --- a/integration_test/evm_module/rpc_io_test/RPC_IO_README.md +++ b/integration_test/evm_module/rpc_io_test/RPC_IO_README.md @@ -41,7 +41,7 @@ GIGA_EXECUTOR=true GIGA_OCC=true DOCKER_DETACH=true make docker-cluster-start ./integration_test/evm_module/scripts/evm_rpc_tests.sh ``` -Leaving `GIGA_EXECUTOR` unset runs giga (the app's `DefaultConfig` is `Enabled: true`); set `GIGA_EXECUTOR=false` for the legacy (V2) executor. The Makefile passes these through to `docker compose`; the node image uses them in `docker/localnode/scripts/step4_config_override.sh`. +Without `GIGA_EXECUTOR` and `GIGA_OCC`, the cluster uses the legacy (V2) executor. The Makefile passes these through to `docker compose`; the node image uses them in `docker/localnode/scripts/step4_config_override.sh`. 1. Run the suite against the **legacy** endpoint and record the final report: ```bash diff --git a/integration_test/evm_module/scripts/evm_giga_mixed_tests.sh b/integration_test/evm_module/scripts/evm_giga_mixed_tests.sh index a9ff8dc991..561d4043a4 100755 --- a/integration_test/evm_module/scripts/evm_giga_mixed_tests.sh +++ b/integration_test/evm_module/scripts/evm_giga_mixed_tests.sh @@ -49,64 +49,6 @@ fi echo "Waiting 10s for nodes to stabilize..." sleep 10 -# Assert each node's EFFECTIVE giga executor mode before running the test. The -# mixed cluster only exercises giga-vs-V2 determinism if node 0 actually runs -# giga and nodes 1-3 actually run V2; a config default silently flipping a V2 -# node to giga makes "mixed" homogeneous and the divergence check never fires. -# Read the startup signal each seid logs after reading its config (app.go), so -# this checks the effective runtime mode rather than what the config intended. -assert_giga_mode() { - node_id="$1" - expected="$2" # "occ", "sequential", or "disabled" - log="build/generated/logs/seid-${node_id}.log" - - # Poll: a slow boot must not read as a missing signal. Match the LAST - # "Giga Executor" line so a restart under a different config can't leave an - # earlier, stale signal to read ambiguously. OCC must be tested before the - # bare ENABLED pattern — "with OCC is ENABLED" also contains "is ENABLED". - deadline=60 - waited=0 - actual="" - while [ "$waited" -lt "$deadline" ]; do - if [ -f "$log" ]; then - signal=$(grep "Giga Executor" "$log" | tail -1) - case "$signal" in - *"with OCC is ENABLED"*) actual="occ"; break ;; - *"is ENABLED"*) actual="sequential"; break ;; - *"is DISABLED"*) actual="disabled"; break ;; - esac - fi - sleep 2 - waited=$((waited + 2)) - done - - if [ -z "$actual" ]; then - echo "GUARD FAILURE: node ${node_id} logged no giga executor startup signal within ${deadline}s (log: ${log}); expected ${expected}" - return 1 - fi - - if [ "$actual" != "$expected" ]; then - echo "GUARD FAILURE: node ${node_id} giga executor mode mismatch: expected ${expected}, got ${actual}" - echo " A homogeneous cluster makes the mixed-determinism test vacuous; refusing to run." - return 1 - fi - - echo " node ${node_id}: giga executor ${actual} (expected ${expected}) OK" - return 0 -} - -echo "=== Verifying mixed-mode roles (node 0 giga+OCC, nodes 1-3 V2) ===" -GUARD_FAILED=0 -assert_giga_mode 0 occ || GUARD_FAILED=1 -assert_giga_mode 1 disabled || GUARD_FAILED=1 -assert_giga_mode 2 disabled || GUARD_FAILED=1 -assert_giga_mode 3 disabled || GUARD_FAILED=1 -if [ "$GUARD_FAILED" -ne 0 ]; then - echo "ERROR: giga mixed-mode role guard failed; aborting before tests." - make docker-cluster-stop || true - exit 1 -fi - # Run the same giga EVM tests — they hit node 0 (giga) via seilocal RPC echo "=== Running GIGA EVM Tests against mixed cluster ===" ./integration_test/evm_module/scripts/evm_giga_tests.sh diff --git a/ratelimiter/method_parser.go b/ratelimiter/method_parser.go deleted file mode 100644 index addd961d4f..0000000000 --- a/ratelimiter/method_parser.go +++ /dev/null @@ -1,219 +0,0 @@ -package ratelimiter - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "math" - "strings" -) - -// DefaultMaxProbeBytes bounds how far MethodParser reads into a request body. Each object -// is read to its end (to detect duplicate "method" keys). A body larger than -// this limit yields ErrProbeLimit. -const DefaultMaxProbeBytes = 1 << 20 // 1 MiB - -var ( - // ErrNoMethod is returned when a JSON-RPC request object has no "method" field. - ErrNoMethod = errors.New("ratelimiter: JSON-RPC request has no method field") - // ErrMethodNotString is returned when the "method" field is present but not a string. - ErrMethodNotString = errors.New("ratelimiter: JSON-RPC method field is not a string") - // ErrNotObject is returned when the request (or a batch element) is not a JSON object. - ErrNotObject = errors.New("ratelimiter: JSON-RPC request is not an object") - // ErrEmptyBatch is returned when the request is a top-level array with no elements. - ErrEmptyBatch = errors.New("ratelimiter: JSON-RPC batch is empty") - // ErrDuplicateMethod is returned when a request object carries more than one - // "method" field (matched case-insensitively, as encoding/json does). - ErrDuplicateMethod = errors.New("ratelimiter: JSON-RPC request has duplicate method field") - // ErrTrailingData is returned when non-whitespace data follows the top-level - // JSON-RPC value; the downstream encoding/json decode would reject such a body. - ErrTrailingData = errors.New("ratelimiter: JSON-RPC request has trailing data") - // ErrProbeLimit is returned when the method field is not found within MaxProbeBytes. - ErrProbeLimit = errors.New("ratelimiter: JSON-RPC method not found within probe limit") -) - -// MethodParser extracts JSON-RPC "method" name(s) from a request body via a -// streaming partial read. -type MethodParser struct { - maxProbeBytes int64 -} - -// NewMethodParser returns a MethodParser that reads at most maxProbeBytes from -// any single request body. -func NewMethodParser(maxProbeBytes int64) *MethodParser { - if maxProbeBytes <= 0 { - maxProbeBytes = DefaultMaxProbeBytes - } - if maxProbeBytes == math.MaxInt64 { - //preventing overflow error - maxProbeBytes = math.MaxInt64 - 1 - } - return &MethodParser{maxProbeBytes: maxProbeBytes} -} - -// Parse reads a JSON-RPC request body from r and returns the method name(s) it -// carries. A single request object yields a one-element slice with batch=false; -// a top-level array yields one method per element in order with batch=true. -// -// Fail-closed contract: callers must reject on every returned error except -// ErrProbeLimit, and must fall back to a full decode (never reject) on -// ErrProbeLimit. Mapping any error to "admit with a default method" defeats -// the point of this probe. -func (p *MethodParser) Parse(r io.Reader) (methods []string, batch bool, err error) { - // N is maxProbeBytes+1 so that lr.N reaching 0 unambiguously means the body - // exceeded the budget. - lr := &io.LimitedReader{R: r, N: p.maxProbeBytes + 1} - dec := json.NewDecoder(lr) - - tok, err := dec.Token() - if err != nil { - return nil, false, classifyErr(err, lr) - } - - delim, ok := tok.(json.Delim) - if !ok { - return nil, false, ErrNotObject - } - - switch delim { - case '{': - method, err := readMethodFromObject(dec) - if err != nil { - return nil, false, classifyErr(err, lr) - } - if err := expectEOF(dec, lr); err != nil { - return nil, false, classifyErr(err, lr) - } - return []string{method}, false, nil - case '[': - out, err := readBatchMethods(dec) - if err != nil { - return nil, true, classifyErr(err, lr) - } - if err := expectEOF(dec, lr); err != nil { - return nil, true, classifyErr(err, lr) - } - return out, true, nil - default: - return nil, false, ErrNotObject - } -} - -// expectEOF confirms the decoder has consumed the whole body, so we can reject -// trailing non-whitespace data. -func expectEOF(dec *json.Decoder, lr *io.LimitedReader) error { - if _, err := dec.Token(); err != nil { - if errors.Is(err, io.EOF) && lr.N > 0 { - return nil - } - return err - } - return ErrTrailingData -} - -// readBatchMethods reads the method of every element of a JSON array whose -// opening '[' has already been consumed from dec. -func readBatchMethods(dec *json.Decoder) ([]string, error) { - var out []string - for dec.More() { - // Each batch element must itself be a JSON object. - tok, err := dec.Token() - if err != nil { - return out, err - } - if delim, ok := tok.(json.Delim); !ok || delim != '{' { - return out, ErrNotObject - } - method, err := readMethodFromObject(dec) - if err != nil { - return out, err - } - out = append(out, method) - } - // Consume and validate the closing ']' before deciding the batch is empty. - tok, err := dec.Token() - if err != nil { - return out, err - } - if delim, ok := tok.(json.Delim); !ok || delim != ']' { - return out, ErrNotObject - } - if len(out) == 0 { - return out, ErrEmptyBatch - } - return out, nil -} - -// readMethodFromObject reads the key/value pairs of a JSON object whose opening -// '{' has already been consumed from dec, and returns the value of its "method" -// field. The key is matched case-insensitively. -func readMethodFromObject(dec *json.Decoder) (string, error) { - var ( - method string - found bool - ) - for dec.More() { - keyTok, err := dec.Token() - if err != nil { - return "", err - } - key, ok := keyTok.(string) - if !ok { - // A valid JSON object always has string keys; anything else is malformed. - return "", ErrNotObject - } - if strings.EqualFold(key, "method") { - if found { - return "", ErrDuplicateMethod - } - valTok, err := dec.Token() - if err != nil { - return "", err - } - s, ok := valTok.(string) - if !ok { - return "", ErrMethodNotString - } - method, found = s, true - continue - } - if err := skipValue(dec); err != nil { - return "", err - } - } - // Consume the closing '}'. - if _, err := dec.Token(); err != nil { - return "", err - } - if !found { - return "", ErrNoMethod - } - return method, nil -} - -// skipValue consumes exactly one complete JSON value from dec — the value whose -// first token dec is positioned at. It decodes into a json.RawMessage so the -// value's bytes are validated and drained off the stream without allocating a -// Go value per scalar the value contains. -func skipValue(dec *json.Decoder) error { - var raw json.RawMessage - return dec.Decode(&raw) -} - -// classifyErr maps a decoder error to ErrProbeLimit when it was caused by the -// probe budget being exhausted, and otherwise returns it wrapped. -func classifyErr(err error, lr *io.LimitedReader) error { - if err == nil { - return nil - } - if (errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) && lr.N <= 0 { - return ErrProbeLimit - } - if errors.Is(err, ErrNoMethod) || errors.Is(err, ErrMethodNotString) || - errors.Is(err, ErrNotObject) || errors.Is(err, ErrEmptyBatch) || - errors.Is(err, ErrDuplicateMethod) || errors.Is(err, ErrTrailingData) { - return err - } - return fmt.Errorf("ratelimiter: parse JSON-RPC method: %w", err) -} diff --git a/ratelimiter/method_parser_test.go b/ratelimiter/method_parser_test.go deleted file mode 100644 index 024dcd936a..0000000000 --- a/ratelimiter/method_parser_test.go +++ /dev/null @@ -1,287 +0,0 @@ -package ratelimiter - -import ( - "math" - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -func parseSingle(t *testing.T, body string) string { - t.Helper() - methods, batch, err := NewMethodParser(0).Parse(strings.NewReader(body)) - require.NoError(t, err) - require.False(t, batch) - require.Len(t, methods, 1) - return methods[0] -} - -// --- single request, EVM + CometBFT framings --- - -func TestParse_EVMRequest(t *testing.T) { - body := `{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":"0xabc","data":"0x00"},"latest"]}` - require.Equal(t, "eth_call", parseSingle(t, body)) -} - -func TestParse_CometBFTRequest(t *testing.T) { - body := `{"jsonrpc":"2.0","id":"abc","method":"abci_query","params":{"path":"/store/x/subspace","data":"deadbeef"}}` - require.Equal(t, "abci_query", parseSingle(t, body)) -} - -func TestParse_MethodBeforeParams(t *testing.T) { - // Field order both go-ethereum and CometBFT emit: method ahead of params. - body := `{"method":"eth_getLogs","params":[{"fromBlock":"0x0"}],"id":1,"jsonrpc":"2.0"}` - require.Equal(t, "eth_getLogs", parseSingle(t, body)) -} - -func TestParse_MethodAfterParams(t *testing.T) { - // Order is not guaranteed by JSON; a huge params before method must still work. - body := `{"jsonrpc":"2.0","params":[1,2,3,{"nested":{"a":[true,false,null]}}],"id":7,"method":"eth_chainId"}` - require.Equal(t, "eth_chainId", parseSingle(t, body)) -} - -func TestParse_EmptyParams(t *testing.T) { - require.Equal(t, "net_version", parseSingle(t, `{"jsonrpc":"2.0","id":1,"method":"net_version","params":[]}`)) -} - -func TestParse_NoParamsField(t *testing.T) { - require.Equal(t, "eth_blockNumber", parseSingle(t, `{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber"}`)) -} - -func TestParse_LeadingWhitespace(t *testing.T) { - require.Equal(t, "eth_call", parseSingle(t, " \n\t {\"method\":\"eth_call\",\"id\":1}")) -} - -func TestParse_EscapedMethodName(t *testing.T) { - // The decoder unescapes the string value, as a full decode would. - require.Equal(t, "weird\"name", parseSingle(t, `{"method":"weird\"name","id":1}`)) -} - -func TestParse_NestedParamsSkipped(t *testing.T) { - body := `{"params":{"a":{"b":{"c":[1,[2,[3]]]}},"d":"x"},"method":"trace_block","id":1}` - require.Equal(t, "trace_block", parseSingle(t, body)) -} - -func TestParse_DuplicateMethodRejected(t *testing.T) { - // encoding/json (used by the downstream handlers) keeps the last value for a - // duplicate key, so charging the first would be a rate-limit bypass; reject. - _, _, err := NewMethodParser(0).Parse(strings.NewReader(`{"method":"first","x":1,"method":"second"}`)) - require.ErrorIs(t, err, ErrDuplicateMethod) -} - -func TestParse_BatchDuplicateMethodRejected(t *testing.T) { - body := `[{"method":"eth_call"},{"method":"a","method":"b"}]` - _, _, err := NewMethodParser(0).Parse(strings.NewReader(body)) - require.ErrorIs(t, err, ErrDuplicateMethod) -} - -// --- case-insensitive method key (matches encoding/json struct-tag decoding) --- - -func TestParse_MethodKeyCaseInsensitive(t *testing.T) { - // Downstream dispatchers decode into `Method string `json:"method"``, whose - // tag match is case-insensitive; the parser must see these too, else a - // capitalized key is invisible here but still dispatched downstream. - require.Equal(t, "eth_call", parseSingle(t, `{"Method":"eth_call","id":1}`)) - require.Equal(t, "eth_call", parseSingle(t, `{"METHOD":"eth_call","id":1}`)) -} - -func TestParse_MixedCaseDuplicateMethodRejected(t *testing.T) { - // {"method":"cheap","Method":"expensive"} decodes to "expensive" downstream; - // treating the two case variants as distinct would let it be charged as cheap. - _, _, err := NewMethodParser(0).Parse(strings.NewReader(`{"method":"cheap","Method":"expensive"}`)) - require.ErrorIs(t, err, ErrDuplicateMethod) -} - -// --- batch --- - -func TestParse_Batch(t *testing.T) { - body := `[ - {"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{},"latest"]}, - {"jsonrpc":"2.0","id":2,"method":"eth_getLogs","params":[{"fromBlock":"0x0"}]}, - {"jsonrpc":"2.0","id":3,"method":"eth_blockNumber"} - ]` - methods, batch, err := NewMethodParser(0).Parse(strings.NewReader(body)) - require.NoError(t, err) - require.True(t, batch) - require.Equal(t, []string{"eth_call", "eth_getLogs", "eth_blockNumber"}, methods) -} - -func TestParse_BatchSingleElement(t *testing.T) { - // A one-element array is still a batch (framing differs from a bare object). - methods, batch, err := NewMethodParser(0).Parse(strings.NewReader(`[{"method":"eth_call","id":1}]`)) - require.NoError(t, err) - require.True(t, batch) - require.Equal(t, []string{"eth_call"}, methods) -} - -func TestParse_BatchMethodAfterParams(t *testing.T) { - body := `[{"params":[{"big":[1,2,3]}],"method":"m1"},{"params":{"x":1},"method":"m2"}]` - methods, batch, err := NewMethodParser(0).Parse(strings.NewReader(body)) - require.NoError(t, err) - require.True(t, batch) - require.Equal(t, []string{"m1", "m2"}, methods) -} - -// --- errors --- - -func TestParse_EmptyBatch(t *testing.T) { - _, batch, err := NewMethodParser(0).Parse(strings.NewReader(`[]`)) - require.ErrorIs(t, err, ErrEmptyBatch) - require.True(t, batch) -} - -func TestParse_TruncatedEmptyBatchNotEmptyBatch(t *testing.T) { - // A body cut off right after '[' has no closing ']' and must not be reported - // as a well-formed empty batch; it is a parse error (budget never exhausted). - for _, body := range []string{`[`, `[ `} { - _, _, err := NewMethodParser(0).Parse(strings.NewReader(body)) - require.Error(t, err) - require.NotErrorIs(t, err, ErrEmptyBatch) - require.NotErrorIs(t, err, ErrProbeLimit) - } -} - -func TestParse_TrailingData(t *testing.T) { - // Non-whitespace after the top-level value is rejected, matching the stricter - // downstream encoding/json decode. - _, _, err := NewMethodParser(0).Parse(strings.NewReader(`{"method":"eth_call"}{}`)) - require.ErrorIs(t, err, ErrTrailingData) - - _, _, err = NewMethodParser(0).Parse(strings.NewReader(`[{"method":"eth_call"}] 5`)) - require.ErrorIs(t, err, ErrTrailingData) - - // Trailing garbage that is not valid JSON is still rejected (as a parse error). - _, _, err = NewMethodParser(0).Parse(strings.NewReader(`{"method":"eth_call"} garbage`)) - require.Error(t, err) -} - -func TestParse_TrailingDataBeyondProbeLimit(t *testing.T) { - body := `{"method":"eth_call"}` + strings.Repeat(" ", 100_000) + `{}` - _, _, err := NewMethodParser(32).Parse(strings.NewReader(body)) - require.ErrorIs(t, err, ErrProbeLimit) -} - -func TestParse_NoMethodField(t *testing.T) { - _, _, err := NewMethodParser(0).Parse(strings.NewReader(`{"jsonrpc":"2.0","id":1,"params":[]}`)) - require.ErrorIs(t, err, ErrNoMethod) -} - -func TestParse_MethodNotString(t *testing.T) { - _, _, err := NewMethodParser(0).Parse(strings.NewReader(`{"method":123,"id":1}`)) - require.ErrorIs(t, err, ErrMethodNotString) -} - -func TestParse_TopLevelScalar(t *testing.T) { - _, _, err := NewMethodParser(0).Parse(strings.NewReader(`"eth_call"`)) - require.ErrorIs(t, err, ErrNotObject) -} - -func TestParse_TopLevelNumber(t *testing.T) { - _, _, err := NewMethodParser(0).Parse(strings.NewReader(`42`)) - require.ErrorIs(t, err, ErrNotObject) -} - -func TestParse_BatchElementNotObject(t *testing.T) { - _, _, err := NewMethodParser(0).Parse(strings.NewReader(`[1, 2, 3]`)) - require.ErrorIs(t, err, ErrNotObject) -} - -func TestParse_TruncatedBatchRejected(t *testing.T) { - // Missing closing ']'. dec.More() reports false at EOF without erroring, so - // the malformed body must be caught by validating the closing delim — and it - // is a parse error, not a probe-limit hit (the budget was never exhausted). - _, _, err := NewMethodParser(0).Parse(strings.NewReader(`[{"method":"eth_call"}`)) - require.Error(t, err) - require.NotErrorIs(t, err, ErrProbeLimit) -} - -func TestParse_EmptyBody(t *testing.T) { - _, _, err := NewMethodParser(0).Parse(strings.NewReader(``)) - require.Error(t, err) - require.NotErrorIs(t, err, ErrProbeLimit) -} - -func TestParse_MalformedJSON(t *testing.T) { - _, _, err := NewMethodParser(0).Parse(strings.NewReader(`{"method":`)) - require.Error(t, err) -} - -func TestParse_TruncatedBodyNotProbeLimit(t *testing.T) { - // A short body that ends before "method" is a genuinely truncated request, - // not a probe-limit hit — the budget was never exhausted. - _, _, err := NewMethodParser(0).Parse(strings.NewReader(`{"params":[1,2,3]`)) - require.Error(t, err) - require.NotErrorIs(t, err, ErrProbeLimit) -} - -func TestParse_TruncatedAtExactProbeLimitNotProbeLimit(t *testing.T) { - // A truncated body whose length is exactly the probe budget must still be a - // parse error, not ErrProbeLimit: with N = maxProbeBytes+1 the budget is not - // exhausted, distinguishing a cut-off body from an oversized one. - body := `{"method":"a"` // 13 bytes, missing the closing '}' - _, _, err := NewMethodParser(int64(len(body))).Parse(strings.NewReader(body)) - require.Error(t, err) - require.NotErrorIs(t, err, ErrProbeLimit) -} - -func TestParse_ExactProbeLimitBodyParses(t *testing.T) { - // A complete body of exactly maxProbeBytes bytes parses successfully; the - // +1 budget byte means it is never misread as a probe-limit hit. - body := `{"method":"a"}` // 14 bytes, complete - methods, _, err := NewMethodParser(int64(len(body))).Parse(strings.NewReader(body)) - require.NoError(t, err) - require.Equal(t, []string{"a"}, methods) -} - -// --- probe limit / partial read --- - -func TestParse_ProbeLimitExceeded(t *testing.T) { - // "method" sits after a params array larger than the probe budget. - body := `{"params":[` + strings.Repeat("1,", 500) + `1],"method":"eth_call"}` - _, _, err := NewMethodParser(64).Parse(strings.NewReader(body)) - require.ErrorIs(t, err, ErrProbeLimit) -} - -func TestParse_LargeTrailingParamsHitProbeLimit(t *testing.T) { - // The whole object is read (bounded by the probe) so a trailing duplicate - // "method" can be rejected; a params array larger than a tiny probe therefore - // yields ErrProbeLimit even though "method" appears first. - body := `{"method":"eth_call","params":[` + strings.Repeat("9,", 100_000) + `9]}` - _, _, err := NewMethodParser(128).Parse(strings.NewReader(body)) - require.ErrorIs(t, err, ErrProbeLimit) -} - -func TestParse_DefaultProbeLimitApplied(t *testing.T) { - require.Equal(t, int64(DefaultMaxProbeBytes), NewMethodParser(0).maxProbeBytes) - require.Equal(t, int64(DefaultMaxProbeBytes), NewMethodParser(-5).maxProbeBytes) - require.Equal(t, int64(256), NewMethodParser(256).maxProbeBytes) -} - -func TestParse_MaxInt64ProbeLimitClamped(t *testing.T) { - // math.MaxInt64 is clamped so the maxProbeBytes+1 sentinel in Parse cannot - // overflow to a negative LimitedReader budget. - require.Equal(t, int64(math.MaxInt64-1), NewMethodParser(math.MaxInt64).maxProbeBytes) - - methods, batch, err := NewMethodParser(math.MaxInt64).Parse(strings.NewReader(`{"method":"eth_call"}`)) - require.NoError(t, err) - require.False(t, batch) - require.Equal(t, []string{"eth_call"}, methods) -} - -func TestParse_LargeParamsWithinDefaultProbe(t *testing.T) { - // A realistic ~200 KiB params well under the 1 MiB default probe: method - // found regardless of whether it precedes or follows params. - big := strings.Repeat("a", 200*1024) - body := `{"jsonrpc":"2.0","id":1,"params":["0x` + big + `"],"method":"eth_sendRawTransaction"}` - require.Equal(t, "eth_sendRawTransaction", parseSingle(t, body)) -} - -// --- reader-only interface: Parse consumes r, callers rewind/tee separately --- - -func TestParse_ReadsFromReader(t *testing.T) { - r := strings.NewReader(`{"method":"eth_call","id":1,"params":[]}`) - methods, _, err := NewMethodParser(0).Parse(r) - require.NoError(t, err) - require.Equal(t, []string{"eth_call"}, methods) -} diff --git a/scripts/boot-smoke.sh b/scripts/boot-smoke.sh deleted file mode 100755 index aa03393770..0000000000 --- a/scripts/boot-smoke.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env bash -# Boot the given seid binary N times against throwaway single-node genesis homes and -# assert every boot gets through the genesis wasm store (the first wasmer JIT compile) -# and completes the ABCI handshake. -# -# Why repeated boots: the class of defect this guards against crashes probabilistically -# at first wasm use (the gcc>=12 unwind b-tree bug killed ~70% of boots, so any single -# boot check can pass on luck). N clean boots make a lucky pass vanishingly unlikely. -# -# Why "handshake completed" is the success marker: a lone node cannot leave blocksync on -# this codebase (blocksync's IsCaughtUp requires more than one peer), so block production -# is not observable single-node. The handshake only completes after the genesis wasm -# StoreCode calls succeed, which is exactly the code path that crashes. -# -# Linux-only (runs the linux/amd64 binary natively; uses GNU timeout). -# -# Usage: boot-smoke.sh [boots] -set -euo pipefail - -BIN=${1:?usage: boot-smoke.sh [boots]} -BOOTS=${2:-8} -CHAIN_ID=boot-smoke-1 - -for i in $(seq 1 "$BOOTS"); do - H=$(mktemp -d) - "$BIN" init smoke --chain-id "$CHAIN_ID" --home "$H" >/dev/null 2>&1 - sed -i 's/"stake"/"usei"/g' "$H/config/genesis.json" - "$BIN" keys add val --keyring-backend test --home "$H" >/dev/null 2>&1 - ADDR=$("$BIN" keys show val -a --keyring-backend test --home "$H") - "$BIN" add-genesis-account "$ADDR" 100000000000000usei --home "$H" >/dev/null - "$BIN" gentx val 10000000000000usei --chain-id "$CHAIN_ID" --keyring-backend test --home "$H" >/dev/null 2>&1 - "$BIN" collect-gentxs --home "$H" >/dev/null 2>&1 - - LOG="$H/start.log" - timeout -k 5 25 "$BIN" start --home "$H" >"$LOG" 2>&1 || true - - if grep -qE "SIGSEGV|SIGILL|SIGBUS|panic:" "$LOG"; then - echo "boot-smoke: boot $i/$BOOTS CRASHED:" >&2 - tail -25 "$LOG" >&2 - exit 1 - fi - if ! grep -q "Completed ABCI Handshake" "$LOG"; then - echo "boot-smoke: boot $i/$BOOTS did not reach the ABCI handshake:" >&2 - tail -25 "$LOG" >&2 - exit 1 - fi - echo "boot-smoke: boot $i/$BOOTS ok" - rm -rf "$H" -done -echo "boot-smoke: all $BOOTS boots clean" diff --git a/scripts/build-static.sh b/scripts/build-static.sh index 6c99e06877..a96930c2c8 100755 --- a/scripts/build-static.sh +++ b/scripts/build-static.sh @@ -9,35 +9,15 @@ # absent in musl) and zig cc rejects the -z muldefs flag needed for the libwasmvm # v152/v155 archives; Alpine's GNU ld + musl links cleanly. --platform linux/amd64 is a # no-op on amd64 CI and forces the right arch on Apple Silicon for local runs. -# -# The link takes libgcc from third_party/alpine-gcc10-libgcc instead of the build -# image's toolchain: gcc >= 12's unwind-frame registry (a lock-free b-tree) corrupts -# under wasmer's JIT frame registration and SIGSEGVs at the genesis wasm store on most -# boots, so the static binary must carry the pre-b-tree registry. See that directory's -# README.md for the full story and provenance. The nm assertion below keeps a toolchain -# upgrade from silently reintroducing the b-tree. set -euo pipefail # Fail fast if the required static libwasmvm archives are missing. bash "$(dirname "$0")/check-libwasmvm-static.sh" -LIBGCC_DIR="third_party/alpine-gcc10-libgcc" - -docker run --rm --platform linux/amd64 -v "$PWD":/src -w /src golang:1.25.6-alpine@sha256:98e6cffc31ccc44c7c15d83df1d69891efee8115a5bb7ede2bf30a38af3e3c92 sh -c ' - set -e - apk add --no-cache build-base git - git config --global --add safe.directory /src - printf "%s %s\n%s %s\n" \ - d3e066fafde74d53a89d48f2ceb9ed9934249a5d450e281edd22947a829469d8 '"$LIBGCC_DIR"'/libgcc.a \ - d14c9973a735909e11a863b0c850300bfd3aa683ef4689cbe76a53139766ed79 '"$LIBGCC_DIR"'/libgcc_eh.a \ - | sha256sum -c - - LINK_STATICALLY=true BUILD_TAGS=muslc LEDGER_ENABLED=false \ - STATIC_EXTRA_LDFLAGS="-L/src/'"$LIBGCC_DIR"'" make build - if nm build/seid | grep -q version_lock_lock_exclusive; then - echo "build-static: ERROR: binary contains the gcc>=12 unwind b-tree (libgcc pin not applied)" >&2 - exit 1 - fi - echo "build-static: pre-b-tree unwinder confirmed (no version_lock symbols)"' +docker run --rm --platform linux/amd64 -v "$PWD":/src -w /src golang:1.25.6-alpine sh -c ' + apk add --no-cache build-base git && + git config --global --add safe.directory /src && + LINK_STATICALLY=true BUILD_TAGS=muslc LEDGER_ENABLED=false make build' # Assert the output really is statically linked, so a regression fails here rather than # shipping a dynamically-linked binary advertised as static. diff --git a/scripts/rpc-sc-read-probe.sh b/scripts/rpc-sc-read-probe.sh deleted file mode 100755 index 9bd1077fc0..0000000000 --- a/scripts/rpc-sc-read-probe.sh +++ /dev/null @@ -1,1192 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# ============================================================================= -# rpc-sc-read-probe.sh -# -# Send EVM JSON-RPC read traffic at one or more Sei nodes to (a) verify the -# state-commitment read path is consistent across backends and (b) measure its -# latency/throughput. State reads use the "latest" block tag so they exercise -# the live SC path (memIAVL / FlatKV) rather than the historical state store. -# -# ----------------------------------------------------------------------------- -# TARGETS -# ----------------------------------------------------------------------------- -# --targets accepts a comma-separated list. Each entry is one of: -# -# name=SPEC explicit name, e.g. node-a=http://1.2.3.4:8545 -# SPEC name auto-derived from the host -# -# SPEC is one of: -# http://host:port | https://host:port plain IP or DNS URL -# host:port | host bare IP/DNS (defaults to http, :8545) -# k8s://[svc/|pod/]name[:port] a Kubernetes service (default) or pod -# -# k8s:// targets resolve by RUNNER (see below): -# RUNNER=k8s -> in-cluster DNS http://name:port -# RUNNER=local -> auto kubectl port-forward to 127.0.0.1 (torn down on exit) -# -# ----------------------------------------------------------------------------- -# RUNNER (where the load generator executes) -# ----------------------------------------------------------------------------- -# k8s (default) run the generator in an ephemeral in-cluster pod. Reaches -# in-cluster services directly; reaches external IP/DNS if the pod has -# egress. Requires kubectl + cluster access. -# local run the generator on this machine (needs python3). Reaches any -# IP/DNS directly; reaches k8s:// targets via auto port-forward. -# -# ----------------------------------------------------------------------------- -# MODES -# ----------------------------------------------------------------------------- -# 1. check (default): correctness. For each target, send one batch of "latest" -# state reads bracketed by eth_blockNumber sentinels; if the node reports the -# same height at the start and end of the batch, all reads were served at that -# height -- independent of network latency. Compare responses captured at the -# SAME height across targets. Batches that span a block boundary are discarded, -# not reported as mismatches. Requires >= 2 targets. -# -# MODE=check CHECK_DURATION=600 REQUESTS_PER_HEIGHT=5 \ -# TARGETS=a=http://10.0.0.1:8545,b=http://10.0.0.2:8545 \ -# ./scripts/rpc-sc-read-probe.sh -# -# 2. monitor: loop check forever (or MONITOR_ITERATIONS times), sleeping -# MONITOR_INTERVAL seconds between rounds. -# -# MODE=monitor MONITOR_INTERVAL=60 \ -# TARGETS=a=http://10.0.0.1:8545,b=http://10.0.0.2:8545 \ -# ./scripts/rpc-sc-read-probe.sh -# -# 3. load: per-target throughput/latency. Concurrent workers over keep-alive -# connections, optional RPS cap and warmup, per-method percentiles. -# -# MODE=load REQUESTS=5000 LOAD_CONCURRENCY=32 LOAD_SC_ONLY=1 \ -# TARGETS=a=http://10.0.0.1:8545 ./scripts/rpc-sc-read-probe.sh -# -# MODE=load LOAD_DURATION=300 LOAD_RPS=450 LOAD_CONCURRENCY=32 \ -# TARGETS=a=http://10.0.0.1:8545 ./scripts/rpc-sc-read-probe.sh -# -# ----------------------------------------------------------------------------- -# EXAMPLES across target types -# ----------------------------------------------------------------------------- -# # k8s service, in-cluster generator (default runner): -# MODE=load NS=my-namespace TARGETS='n0=k8s://my-rpc-svc:8545' \ -# ./scripts/rpc-sc-read-probe.sh -# -# # k8s service from your laptop (auto port-forward): -# RUNNER=local MODE=load NS=my-namespace TARGETS='n0=k8s://my-rpc-svc:8545' \ -# ./scripts/rpc-sc-read-probe.sh -# -# # external IP/DNS from your laptop: -# RUNNER=local MODE=load TARGETS='rpc=https://evm-rpc.sei-apis.com' \ -# ./scripts/rpc-sc-read-probe.sh -# -# # cross-target consistency check over IP + k8s service: -# RUNNER=local MODE=check NS=my-namespace \ -# TARGETS='a=http://10.0.0.1:8545,b=k8s://my-rpc-svc:8545' \ -# ./scripts/rpc-sc-read-probe.sh -# -# Machine-readable output: set OUTPUT_JSON=/path to write one JSON object per -# target (load) or per run (check) as JSONL, in addition to the human log. -# -# Every long-option below also has an identically named UPPERCASE env var, e.g. -# REQUESTS=200 ./scripts/rpc-sc-read-probe.sh -# ============================================================================= - -# -- runner / cluster -------------------------------------------------------- -RUNNER=${RUNNER:-k8s} # k8s | local -NS=${NS:-} # k8s namespace; empty => kubectl current-context namespace -CHECK_IMAGE=${CHECK_IMAGE:-python:3.12-alpine} - -# -- targets ----------------------------------------------------------------- -TARGETS=${TARGETS:-} # required; comma-separated list (see --help) -DEFAULT_PORT=${DEFAULT_PORT:-8545} - -# -- mode -------------------------------------------------------------------- -MODE=${MODE:-check} - -# -- shared RPC knobs -------------------------------------------------------- -RPC_TIMEOUT=${RPC_TIMEOUT:-5} -RPC_RETRIES=${RPC_RETRIES:-3} -PROGRESS_EVERY=${PROGRESS_EVERY:-10} - -# -- check knobs ------------------------------------------------------------- -CHECK_DURATION=${CHECK_DURATION:-600} -REQUESTS_PER_HEIGHT=${REQUESTS_PER_HEIGHT:-5} -CHECK_POLL=${CHECK_POLL:-0.2} -VERBOSE=${VERBOSE:-0} - -# -- monitor knobs ----------------------------------------------------------- -MONITOR_INTERVAL=${MONITOR_INTERVAL:-60} -MONITOR_ITERATIONS=${MONITOR_ITERATIONS:-0} - -# -- load knobs -------------------------------------------------------------- -REQUESTS=${REQUESTS:-50} -LOAD_CONCURRENCY=${LOAD_CONCURRENCY:-8} -LOAD_DURATION=${LOAD_DURATION:-0} # >0 => run for seconds instead of REQUESTS count -LOAD_RPS=${LOAD_RPS:-0} # >0 => cap requests/sec per target -LOAD_SC_ONLY=${LOAD_SC_ONLY:-0} -WARMUP=${WARMUP:-50} # unmeasured warmup requests per target (0 disables) -MAX_ERROR_RATE=${MAX_ERROR_RATE:-0} # load fails (exit 1) if error_rate exceeds this (0..1) - -# -- output ------------------------------------------------------------------ -OUTPUT_JSON=${OUTPUT_JSON:-} - -# -- probe payload defaults (override for a known-hot contract/account) ------ -CALL_TO=${CALL_TO:-0x0000000000000000000000000000000000000000} -STATE_ADDRESS=${STATE_ADDRESS:-0x0000000000000000000000000000000000000000} -STORAGE_SLOT=${STORAGE_SLOT:-0x0000000000000000000000000000000000000000000000000000000000000000} - -usage() { - cat <<'EOF' -Usage: rpc-sc-read-probe.sh [options] - -Send EVM JSON-RPC read requests at one or more nodes to check SC read-path -consistency (check/monitor) or measure latency/throughput (load). State reads -use the "latest" tag to exercise the live state-commitment path. - -Runner: - --runner MODE k8s | local (default: k8s) - --namespace NS Kubernetes namespace for the k8s runner / port-forward - (default: kubectl current-context namespace) - -Targets (required): - --targets LIST Comma-separated. Each entry: name=SPEC | SPEC. - SPEC: http(s)://host:port | host[:port] | - k8s://[svc/|pod/]name[:port] - --default-port N Port used for bare/k8s specs without one (default: 8545) - -Mode: - --mode MODE check | monitor | load (default: check) - --compare Alias for --mode check - -Shared: - --rpc-timeout SEC Per-RPC timeout (default: 5) - --rpc-retries N Per-RPC retries (default: 3) - --progress-every N Progress interval; seconds (check) or count (load) (default: 10) - --output-json PATH Write machine-readable JSONL results to PATH - (one JSON object per target for load / per run for check) - -Check/monitor: - --check-duration SEC Sampling duration (default: 600) - --requests-per-height N Requests sampled per node/height (default: 5) - --check-poll SEC Backoff between sample attempts (default: 0.2) - --verbose Print per-height sampling detail - --monitor-interval SEC Seconds between monitor rounds (default: 60) - --monitor-iterations N Monitor rounds; 0 = forever (default: 0) - -Load: - --requests N Requests per target (default: 50) - --load-concurrency N Concurrent workers per target (default: 8) - --load-duration SEC Run for seconds instead of --requests; 0 disables (default: 0) - --load-rps N Max requests/sec per target; 0 = unlimited - --load-sc-only Only send latest state-read RPCs - --warmup N Unmeasured warmup requests per target (default: 50) - --max-error-rate F Fail if per-target error rate exceeds F (0..1) (default: 0) - -Probe payload: - --call-to ADDR eth_call target address - --state-address ADDR Address for balance/code/storage probes - --storage-slot HEX Storage slot for eth_getStorageAt - -Every option has an UPPERCASE env-var equivalent, e.g. REQUESTS=200 ... -EOF -} - -while [[ $# -gt 0 ]]; do - case "$1" in - --runner) RUNNER=$2; shift 2 ;; - --namespace) NS=$2; shift 2 ;; - --mode) MODE=$2; shift 2 ;; - --compare) MODE=check; shift ;; - --targets) TARGETS=$2; shift 2 ;; - --default-port) DEFAULT_PORT=$2; shift 2 ;; - --rpc-timeout) RPC_TIMEOUT=$2; shift 2 ;; - --rpc-retries) RPC_RETRIES=$2; shift 2 ;; - --progress-every) PROGRESS_EVERY=$2; shift 2 ;; - --output-json) OUTPUT_JSON=$2; shift 2 ;; - --check-duration) CHECK_DURATION=$2; shift 2 ;; - --requests-per-height) REQUESTS_PER_HEIGHT=$2; shift 2 ;; - --check-poll) CHECK_POLL=$2; shift 2 ;; - --verbose) VERBOSE=1; shift ;; - --monitor-interval) MONITOR_INTERVAL=$2; shift 2 ;; - --monitor-iterations) MONITOR_ITERATIONS=$2; shift 2 ;; - --requests) REQUESTS=$2; shift 2 ;; - --load-concurrency) LOAD_CONCURRENCY=$2; shift 2 ;; - --load-duration) LOAD_DURATION=$2; shift 2 ;; - --load-rps) LOAD_RPS=$2; shift 2 ;; - --load-sc-only) LOAD_SC_ONLY=1; shift ;; - --warmup) WARMUP=$2; shift 2 ;; - --max-error-rate) MAX_ERROR_RATE=$2; shift 2 ;; - --call-to) CALL_TO=$2; shift 2 ;; - --state-address) STATE_ADDRESS=$2; shift 2 ;; - --storage-slot) STORAGE_SLOT=$2; shift 2 ;; - -h|--help) usage; exit 0 ;; - *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;; - esac -done - -case "$MODE" in - compare) MODE=check ;; - check|monitor|load) ;; - *) echo "unknown --mode $MODE (want check|monitor|load)" >&2; exit 2 ;; -esac - -case "$RUNNER" in - k8s|local) ;; - *) echo "unknown --runner $RUNNER (want k8s|local)" >&2; exit 2 ;; -esac - -if [ -z "${TARGETS//[, ]/}" ]; then - echo "no targets specified; pass --targets or set TARGETS (see --help)" >&2 - exit 2 -fi - -if [ "$RUNNER" = "local" ] && ! command -v python3 >/dev/null 2>&1; then - echo "RUNNER=local requires python3 on PATH" >&2 - exit 2 -fi - -# --------------------------------------------------------------------------- -# Cleanup: kill any port-forwards and remove temp files on exit. -# --------------------------------------------------------------------------- -PF_PIDS=() -CAPTURE_FILE=$(mktemp) -cleanup() { - local pid - for pid in "${PF_PIDS[@]:-}"; do - [ -n "$pid" ] && kill "$pid" >/dev/null 2>&1 || true - done - rm -f "$CAPTURE_FILE" >/dev/null 2>&1 || true -} -trap cleanup EXIT INT TERM - -# kubectl wrapper: pass -n only when NS is set, else use the context namespace. -kctl() { - if [ -n "$NS" ]; then kubectl -n "$NS" "$@"; else kubectl "$@"; fi -} - -# start_port_forward KIND NAME REMOTE_PORT -> sets global PF_LOCAL_PORT. -# Lets kubectl pick a free local port (":remote") and parses it from the log. -# MUST run in the main shell (not a $(...) subshell) so the background PID is -# tracked in PF_PIDS for cleanup and the process is not reaped early. -PF_LOCAL_PORT="" -start_port_forward() { - local kind=$1 name=$2 rport=$3 - local logf - logf=$(mktemp) - kctl port-forward "$kind/$name" ":$rport" >"$logf" 2>&1 & - local pid=$! - PF_PIDS+=("$pid") - PF_LOCAL_PORT="" - local i - for ((i = 0; i < 100; i++)); do - PF_LOCAL_PORT=$(sed -n 's/.*Forwarding from 127\.0\.0\.1:\([0-9]\{1,\}\) ->.*/\1/p' "$logf" | head -n1) - [ -n "$PF_LOCAL_PORT" ] && break - if ! kill -0 "$pid" >/dev/null 2>&1; then - echo "port-forward $kind/$name failed:" >&2; cat "$logf" >&2; rm -f "$logf"; return 1 - fi - sleep 0.1 - done - rm -f "$logf" - if [ -z "$PF_LOCAL_PORT" ]; then - echo "timed out waiting for port-forward $kind/$name" >&2; return 1 - fi -} - -# resolve_spec SPEC -> sets global RESOLVED_URL to a URL reachable by RUNNER. -# May start a port-forward (local runner + k8s:// target); must not run inside -# command substitution. -RESOLVED_URL="" -resolve_spec() { - local spec=$1 - case "$spec" in - http://*|https://*) RESOLVED_URL=$spec ;; - k8s://*) - local rest=${spec#k8s://} kind=svc host port - case "$rest" in - svc/*) kind=svc; rest=${rest#svc/} ;; - pod/*) kind=pod; rest=${rest#pod/} ;; - esac - if [[ "$rest" == *:* ]]; then host=${rest%%:*}; port=${rest##*:}; else host=$rest; port=$DEFAULT_PORT; fi - if [ "$RUNNER" = "local" ]; then - start_port_forward "$kind" "$host" "$port" || exit 1 - echo "port-forward $kind/$host:$port -> 127.0.0.1:$PF_LOCAL_PORT" >&2 - RESOLVED_URL="http://127.0.0.1:$PF_LOCAL_PORT" - else - RESOLVED_URL="http://$host:$port" - fi - ;; - *) - if [[ "$spec" == *:* ]]; then RESOLVED_URL="http://$spec"; else RESOLVED_URL="http://$spec:$DEFAULT_PORT"; fi - ;; - esac -} - -# derive_name SPEC -> a sanitized short name from the host[:port] portion. The -# port is kept (rendered as a '-') so two targets on the same host but different -# ports (e.g. 10.0.0.1:8545 and 10.0.0.1:9545) derive distinct names instead of -# colliding -- a collision would silently drop one target in check mode. -derive_name() { - local s=$1 - s=${s#*://}; s=${s#svc/}; s=${s#pod/} - s=${s%%/*} - printf '%s' "$s" | tr -c 'A-Za-z0-9_.-' '-' | sed 's/-*$//' -} - -# Parse TARGETS into parallel arrays target_names / target_urls. -target_names=() -target_urls=() -parse_targets() { - local entry name spec existing - local -a entries - # IFS=',' is scoped to this read only (bash prefix assignment), so the global - # IFS is left untouched for whitespace splitting elsewhere. - IFS=',' read -r -a entries <<< "$TARGETS" - for entry in "${entries[@]}"; do - [ -z "$entry" ] && continue - case "$entry" in - *=*) name=${entry%%=*}; spec=${entry#*=} ;; - *) spec=$entry; name=$(derive_name "$spec") ;; - esac - # Names key the per-target samples in check mode; a duplicate would collapse - # two targets to one entry and make the comparison silently pass. Reject it - # rather than report a misconfiguration as CONSISTENT. (Guard the loop for - # the empty-array case so it is safe under `set -u` on bash 3.2.) - if [ "${#target_names[@]}" -gt 0 ]; then - for existing in "${target_names[@]}"; do - if [ "$existing" = "$name" ]; then - echo "duplicate target name '$name' in TARGETS: each target must resolve to a unique name (use name=SPEC to disambiguate)" >&2 - exit 2 - fi - done - fi - resolve_spec "$spec" - target_names+=("$name") - target_urls+=("$RESOLVED_URL") - done - if [ "${#target_names[@]}" -eq 0 ]; then - echo "no targets parsed from TARGETS='$TARGETS'" >&2; exit 2 - fi -} - -# =========================================================================== -# Shared Python preamble, prepended to both LOAD_PY and CHECK_PY so load and -# check exercise the exact same request generator (no silent drift between the -# two method mixes). All requests use the "latest" tag to hit the live SC path. -# choose_request references CALL_TO/STATE_ADDRESS/STORAGE_SLOT, which each -# program defines from the environment before the generator is ever called. -# =========================================================================== -read -r -d '' COMMON_PY <<'PYEOF' || true -def choose_request(i, sc_only=False): - bucket = (i * 37) % 100 - if sc_only: - if bucket < 44: - return "eth_call", [{"to": CALL_TO, "data": "0x"}, "latest"] - if bucket < 64: - return "eth_getBalance", [STATE_ADDRESS, "latest"] - if bucket < 82: - return "eth_getStorageAt", [STATE_ADDRESS, STORAGE_SLOT, "latest"] - return "eth_getCode", [STATE_ADDRESS, "latest"] - if bucket < 44: - return "eth_call", [{"to": CALL_TO, "data": "0x"}, "latest"] - if bucket < 64: - return "eth_getBlockByNumber", ["latest", False] - if bucket < 70: - return "eth_getLogs", [{"fromBlock": "latest", "toBlock": "latest", "address": STATE_ADDRESS}] - if bucket < 75: - return "eth_getStorageAt", [STATE_ADDRESS, STORAGE_SLOT, "latest"] - if bucket < 79: - return "eth_blockNumber", [] - if bucket < 82: - return "eth_feeHistory", ["0x5", "latest", []] - if bucket < 92: - return "eth_getBalance", [STATE_ADDRESS, "latest"] - return "eth_getCode", [STATE_ADDRESS, "latest"] -PYEOF - -# =========================================================================== -# Load generator (Python). Keep-alive connections, optional warmup + RPS cap, -# interpolated percentiles, human + JSON output. -# =========================================================================== -read -r -d '' LOAD_PY <<'PYEOF' || true -import json -import os -import threading -import time -import http.client -from urllib.parse import urlparse - -TARGET_NAME = os.environ["TARGET_NAME"] -RPC_URL = os.environ["RPC_URL"] -REQUESTS = int(os.environ["REQUESTS"]) -LOAD_CONCURRENCY = int(os.environ["LOAD_CONCURRENCY"]) -LOAD_DURATION = float(os.environ["LOAD_DURATION"]) -LOAD_RPS = float(os.environ["LOAD_RPS"]) -LOAD_SC_ONLY = os.environ["LOAD_SC_ONLY"] == "1" -WARMUP = int(os.environ["WARMUP"]) -PROGRESS_EVERY = int(os.environ["PROGRESS_EVERY"]) -RPC_TIMEOUT = float(os.environ["RPC_TIMEOUT"]) -RPC_RETRIES = int(os.environ["RPC_RETRIES"]) -MAX_ERROR_RATE = float(os.environ["MAX_ERROR_RATE"]) -CALL_TO = os.environ["CALL_TO"] -STATE_ADDRESS = os.environ["STATE_ADDRESS"] -STORAGE_SLOT = os.environ["STORAGE_SLOT"] - -_p = urlparse(RPC_URL) -IS_HTTPS = _p.scheme == "https" -HOST = _p.hostname -PORT = _p.port or (443 if IS_HTTPS else 80) -PATH = (_p.path or "/") + (("?" + _p.query) if _p.query else "") - -# Must match exactly the methods choose_request() can emit: METHOD_COUNTS is -# printed for every entry, so a method that is never sent would misleadingly -# report =0, and a method that is sent but missing here would KeyError. -methods = [ - "eth_call", "eth_getBalance", "eth_getStorageAt", "eth_getCode", - "eth_getBlockByNumber", "eth_getLogs", "eth_blockNumber", "eth_feeHistory", -] - - -class Client: - """Per-thread persistent HTTP/1.1 connection (keep-alive) with reconnect.""" - - def __init__(self): - self.conn = None - - def _connect(self): - cls = http.client.HTTPSConnection if IS_HTTPS else http.client.HTTPConnection - self.conn = cls(HOST, PORT, timeout=RPC_TIMEOUT) - - def post(self, body): - last = None - for attempt in range(1, RPC_RETRIES + 1): - try: - if self.conn is None: - self._connect() - self.conn.request( - "POST", PATH, body=body, - headers={"Content-Type": "application/json", "Connection": "keep-alive"}, - ) - resp = self.conn.getresponse() - data = resp.read() - if resp.status != 200: - raise RuntimeError("http %d" % resp.status) - return data - except Exception as exc: # noqa: BLE001 - stdlib script - last = exc - try: - if self.conn is not None: - self.conn.close() - except Exception: - pass - self.conn = None - if attempt < RPC_RETRIES: - time.sleep(0.05) - raise last - - -_tls = threading.local() - - -def client(): - c = getattr(_tls, "client", None) - if c is None: - c = Client() - _tls.client = c - return c - - -def encode(i): - method, params = choose_request(i, LOAD_SC_ONLY) - body = json.dumps( - {"jsonrpc": "2.0", "id": i, "method": method, "params": params}, - separators=(",", ":"), - ).encode() - return method, body - - -def do_request(i): - method, body = encode(i) - try: - text = client().post(body) - obj = json.loads(text) - success = "error" not in obj - except Exception: - success = False - return method, success - - -def warmup(): - if WARMUP <= 0: - return - counter = [0] - lock = threading.Lock() - - def w(): - while True: - with lock: - if counter[0] >= WARMUP: - return - counter[0] += 1 - i = counter[0] - do_request(i) - - n = min(LOAD_CONCURRENCY, max(1, WARMUP)) - ts = [threading.Thread(target=w, daemon=True) for _ in range(n)] - for t in ts: - t.start() - for t in ts: - t.join() - print("LOAD_WARMUP target=%s warmup_requests=%d done" % (TARGET_NAME, WARMUP), flush=True) - - -method_counts = {m: 0 for m in methods} -method_ok = {m: 0 for m in methods} -method_err = {m: 0 for m in methods} -method_latencies_ms = {m: [] for m in methods} -latencies_ms = [] -ok = 0 -err = 0 -next_id = 1 -lock = threading.Lock() -stop_at = None -rate_lock = threading.Lock() -next_send_at = 0.0 - - -def next_request_id(): - global next_id - with lock: - if stop_at is None and next_id > REQUESTS: - return None - if stop_at is not None and time.monotonic() >= stop_at: - return None - i = next_id - next_id += 1 - return i - - -def wait_for_rate_slot(): - global next_send_at - if LOAD_RPS <= 0: - return - interval = 1.0 / LOAD_RPS - with rate_lock: - now = time.monotonic() - scheduled = max(now, next_send_at) - next_send_at = scheduled + interval - delay = scheduled - time.monotonic() - if delay > 0: - time.sleep(delay) - - -def worker(): - global ok, err - while True: - i = next_request_id() - if i is None: - return - # Build the request and parse the response OUTSIDE the timed region so - # the measurement reflects network/server latency, not client-side JSON - # serialization/parsing (which also holds the GIL). - method, body = encode(i) - c = client() - wait_for_rate_slot() - # The rate slot can sleep past the duration deadline: with - # LOAD_CONCURRENCY >> LOAD_RPS every worker reserves a slot up front and - # would otherwise fire it after stop_at, overrunning LOAD_DURATION by - # ~(concurrency-1)/LOAD_RPS seconds. Re-check the deadline here so a - # duration-bounded run stops near it. - if stop_at is not None and time.monotonic() >= stop_at: - return - start = time.perf_counter() - try: - text = c.post(body) - failed = False - except Exception: # noqa: BLE001 - stdlib script - text, failed = None, True - elapsed = (time.perf_counter() - start) * 1000.0 - if failed: - success = False - else: - try: - success = "error" not in json.loads(text) - except Exception: # noqa: BLE001 - stdlib script - success = False - with lock: - latencies_ms.append(elapsed) - method_latencies_ms[method].append(elapsed) - method_counts[method] += 1 - if success: - ok += 1 - method_ok[method] += 1 - else: - err += 1 - method_err[method] += 1 - completed = ok + err - # Print progress outside the lock so the stdout write never stalls other - # workers waiting on the shared stats lock. - if PROGRESS_EVERY > 0 and completed % PROGRESS_EVERY == 0: - print("LOAD_PROGRESS target=%s completed=%d ok=%d error=%d" - % (TARGET_NAME, completed, ok, err), flush=True) - - -def percentile(sorted_values, pct): - if not sorted_values: - return 0.0 - if len(sorted_values) == 1: - return sorted_values[0] - k = (len(sorted_values) - 1) * pct / 100.0 - f = int(k) - c = min(f + 1, len(sorted_values) - 1) - if f == c: - return sorted_values[f] - return sorted_values[f] + (sorted_values[c] - sorted_values[f]) * (k - f) - - -print( - "LOAD_START target=%s url=%s requests=%d duration=%.1fs concurrency=%d " - "rps_limit=%.1f timeout=%.1fs sc_only=%d warmup=%d" - % (TARGET_NAME, RPC_URL, REQUESTS, LOAD_DURATION, LOAD_CONCURRENCY, - LOAD_RPS, RPC_TIMEOUT, int(LOAD_SC_ONLY), WARMUP), - flush=True, -) - -warmup() - -if LOAD_DURATION > 0: - stop_at = time.monotonic() + LOAD_DURATION -if LOAD_RPS > 0: - next_send_at = time.monotonic() - -started = time.perf_counter() -threads = [threading.Thread(target=worker, daemon=True) for _ in range(LOAD_CONCURRENCY)] -for t in threads: - t.start() -for t in threads: - t.join() -elapsed_s = time.perf_counter() - started - -latencies_ms.sort() -total = ok + err -rps = total / elapsed_s if elapsed_s > 0 else 0.0 -error_rate = (err / total) if total else 0.0 - -print( - "LOAD_SUMMARY target=%s requests=%d ok=%d error=%d elapsed_s=%.3f rps=%.2f " - "latency_ms_p50=%.2f p95=%.2f p99=%.2f max=%.2f" - % (TARGET_NAME, total, ok, err, elapsed_s, rps, - percentile(latencies_ms, 50), percentile(latencies_ms, 95), - percentile(latencies_ms, 99), max(latencies_ms) if latencies_ms else 0.0), - flush=True, -) -print("METHOD_COUNTS " + " ".join("%s=%d" % (m, method_counts[m]) for m in methods), flush=True) - -method_json = {} -for method in methods: - values = sorted(method_latencies_ms[method]) - if not values: - continue - method_json[method] = { - "count": len(values), "ok": method_ok[method], "error": method_err[method], - "p50": round(percentile(values, 50), 3), "p95": round(percentile(values, 95), 3), - "p99": round(percentile(values, 99), 3), "max": round(max(values), 3), - } - print( - "METHOD_LATENCY target=%s method=%s count=%d ok=%d error=%d p50=%.2f p95=%.2f p99=%.2f max=%.2f" - % (TARGET_NAME, method, len(values), method_ok[method], method_err[method], - percentile(values, 50), percentile(values, 95), percentile(values, 99), max(values)), - flush=True, - ) - -summary = { - "kind": "load", "target": TARGET_NAME, "url": RPC_URL, - "requests": total, "ok": ok, "error": err, "error_rate": round(error_rate, 6), - "elapsed_s": round(elapsed_s, 3), "rps": round(rps, 2), - "concurrency": LOAD_CONCURRENCY, "rps_limit": LOAD_RPS, "sc_only": int(LOAD_SC_ONLY), - "latency_ms": { - "p50": round(percentile(latencies_ms, 50), 3), - "p95": round(percentile(latencies_ms, 95), 3), - "p99": round(percentile(latencies_ms, 99), 3), - "max": round(max(latencies_ms) if latencies_ms else 0.0, 3), - }, - "methods": method_json, -} -print("LOAD_RESULT_JSON " + json.dumps(summary, separators=(",", ":")), flush=True) - -if total and error_rate > MAX_ERROR_RATE: - print("LOAD_FAIL target=%s error_rate=%.6f exceeds max_error_rate=%.6f" - % (TARGET_NAME, error_rate, MAX_ERROR_RATE), flush=True) - raise SystemExit(1) -PYEOF - -# =========================================================================== -# Cross-target consistency checker (Python). Height-synchronized sampling with -# keep-alive connections; human + JSON output. -# =========================================================================== -read -r -d '' CHECK_PY <<'PYEOF' || true -import json -import os -import threading -import time -import http.client -from urllib.parse import urlparse - -CHECK_DURATION = float(os.environ["CHECK_DURATION"]) -REQUESTS_PER_HEIGHT = int(os.environ["REQUESTS_PER_HEIGHT"]) -CHECK_POLL = float(os.environ["CHECK_POLL"]) -RPC_TIMEOUT = float(os.environ["RPC_TIMEOUT"]) -RPC_RETRIES = int(os.environ["RPC_RETRIES"]) -PROGRESS_EVERY = int(os.environ["PROGRESS_EVERY"]) -VERBOSE = os.environ["VERBOSE"] == "1" -CALL_TO = os.environ["CALL_TO"] -STATE_ADDRESS = os.environ["STATE_ADDRESS"] -STORAGE_SLOT = os.environ["STORAGE_SLOT"] - -_tls = threading.local() - - -def _conns(): - d = getattr(_tls, "d", None) - if d is None: - d = {} - _tls.d = d - return d - - -def post(url, body): - p = urlparse(url) - https = p.scheme == "https" - host = p.hostname - port = p.port or (443 if https else 80) - path = (p.path or "/") + (("?" + p.query) if p.query else "") - key = (host, port, https) - d = _conns() - last = None - for attempt in range(1, RPC_RETRIES + 1): - try: - conn = d.get(key) - if conn is None: - cls = http.client.HTTPSConnection if https else http.client.HTTPConnection - conn = cls(host, port, timeout=RPC_TIMEOUT) - d[key] = conn - conn.request( - "POST", path, body=body, - headers={"Content-Type": "application/json", "Connection": "keep-alive"}, - ) - resp = conn.getresponse() - data = resp.read() - if resp.status != 200: - raise RuntimeError("http %d" % resp.status) - return data.decode() - except Exception as exc: # noqa: BLE001 - stdlib script - last = exc - c = d.pop(key, None) - try: - if c is not None: - c.close() - except Exception: - pass - if attempt < RPC_RETRIES: - time.sleep(0.2) - raise last - - -def rpc_json(url, method, params, request_id): - text = post(url, json.dumps( - {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params}, - separators=(",", ":")).encode()) - return text, json.loads(text) - - -def block_number(url): - _, obj = rpc_json(url, "eth_blockNumber", [], 0) - return int(obj["result"], 16) - - -def parse_targets(): - targets = [] - for line in os.environ["TARGETS_PAYLOAD"].splitlines(): - parts = line.split() - if len(parts) >= 2: - targets.append((parts[0], parts[1])) - if len(targets) < 2: - # A misconfiguration, not a mismatch: exit 2 so it never masquerades as - # MISMATCH (exit 1). - print("check mode requires at least two targets", flush=True) - raise SystemExit(2) - # Samples are keyed by name, so duplicate names would collapse two targets to - # one entry and compare the baseline against itself -- a silent false pass. - # The bash layer already rejects this; guard here too since the comparison's - # correctness depends on it. - names = [name for name, _ in targets] - if len(set(names)) != len(names): - print("check mode requires unique target names; got duplicates in %r" % names, flush=True) - raise SystemExit(2) - return targets - - -TARGETS = parse_targets() - - -def vlog(message): - if VERBOSE: - print(message, flush=True) - - -def short(text, limit=220): - return text if len(text) <= limit else text[:limit] + "..." - - -def capture_coherent(name, url): - """Capture one latency-independent sample of REQUESTS_PER_HEIGHT reads. - - Send a single batch of "latest" state reads bracketed by eth_blockNumber - sentinels. The node processes the batch server-side (near-instant), so if - the height reported at the start and end of the batch is identical, every - "latest" read in between was served at that same height -- regardless of - client/network latency. - - Returns (height, rows) on success, ("crossed", None) if the batch spanned a - block boundary, or (None, None) on error. - """ - batch = [{"jsonrpc": "2.0", "id": "bn_start", "method": "eth_blockNumber", "params": []}] - method_by_id = {} - for i in range(1, REQUESTS_PER_HEIGHT + 1): - method, params = choose_request(i) - method_by_id[i] = method - batch.append({"jsonrpc": "2.0", "id": i, "method": method, "params": params}) - batch.append({"jsonrpc": "2.0", "id": "bn_end", "method": "eth_blockNumber", "params": []}) - - try: - batch_text = post(url, json.dumps(batch, separators=(",", ":")).encode()) - batch_obj = json.loads(batch_text) - if not isinstance(batch_obj, list): - batch_obj = [batch_obj] - except Exception as exc: # noqa: BLE001 - vlog("SAMPLE_RPC_ERROR node=%s error=%s" % (name, exc)) - return None, None - - by_id = {} - for item in batch_obj: - if isinstance(item, dict) and "id" in item: - by_id[item["id"]] = item - - def sentinel_height(key): - item = by_id.get(key) - if not isinstance(item, dict) or "result" not in item: - return None - try: - return int(item["result"], 16) - except (ValueError, TypeError): - return None - - h_start = sentinel_height("bn_start") - h_end = sentinel_height("bn_end") - if h_start is None or h_end is None: - vlog("SAMPLE_RPC_ERROR node=%s error=missing/invalid blockNumber sentinel" % name) - return None, None - if h_start != h_end: - return "crossed", None - - rows = [] - for i in range(1, REQUESTS_PER_HEIGHT + 1): - item = by_id.get(i, {"jsonrpc": "2.0", "id": i, "error": "missing batch response"}) - text = json.dumps(item, sort_keys=True, separators=(",", ":")) - rows.append({"id": i, "method": method_by_id[i], "text": text}) - return h_start, rows - - -target_names = [name for name, _ in TARGETS] - - -def initial_height(): - """Highest current height across targets, tolerating pre-flight failures. - - block_number() raises on an unreachable target, a non-200, a JSON-RPC error - object, or a non-hex result. Letting that propagate would abort with a - Python traceback and exit code 1 -- indistinguishable from a MISMATCH. Skip - the targets that fail here; if none respond, bail as INCONCLUSIVE (exit 2) - so the 0/1/2 verdict contract holds. - """ - heights = [] - for name, url in TARGETS: - try: - heights.append(block_number(url)) - except Exception as exc: # noqa: BLE001 - stdlib script - print("CHECK_PREFLIGHT_ERROR node=%s error=%s" % (name, exc), flush=True) - if not heights: - print( - "CHECK_VERDICT verdict=INCONCLUSIVE nothing_compared=1 -- no target answered the " - "pre-flight eth_blockNumber, so consistency was NOT verified (this is not a pass).", - flush=True, - ) - raise SystemExit(2) - return max(heights) - - -start_height = initial_height() -deadline = time.time() + CHECK_DURATION -samples = {} -sample_lock = threading.Lock() -stop_event = threading.Event() -per_node_samples = {name: 0 for name in target_names} -per_node_crossed = {name: 0 for name in target_names} -per_node_errors = {name: 0 for name in target_names} - -print( - "CHECK_START duration=%.0fs requests_per_height=%d targets=%d start_height=%d" - % (CHECK_DURATION, REQUESTS_PER_HEIGHT, len(TARGETS), start_height), - flush=True, -) - - -def watch_worker(name, url): - seen = set() - while time.time() < deadline and not stop_event.is_set(): - height, rows = capture_coherent(name, url) - if height is None: - per_node_errors[name] += 1 - time.sleep(CHECK_POLL) - continue - if height == "crossed": - per_node_crossed[name] += 1 - vlog("SAMPLE_CROSSED node=%s (batch spanned a block boundary)" % name) - time.sleep(CHECK_POLL) - continue - if height < start_height or height in seen: - time.sleep(CHECK_POLL) - continue - seen.add(height) - with sample_lock: - samples.setdefault(height, {})[name] = rows - per_node_samples[name] += 1 - vlog("SAMPLE height=%d node=%s requests=%d" % (height, name, len(rows))) - - -threads = [threading.Thread(target=watch_worker, args=(name, url), daemon=True) for name, url in TARGETS] -for thread in threads: - thread.start() - -next_progress = time.time() + max(1, PROGRESS_EVERY) -while time.time() < deadline: - time.sleep(0.5) - if time.time() >= next_progress: - with sample_lock: - comparable_now = sum(1 for node_rows in samples.values() if all(n in node_rows for n in target_names)) - heights_seen = len(samples) - elapsed = int(CHECK_DURATION - max(0, deadline - time.time())) - print( - "CHECK_PROGRESS elapsed_s=%d heights_seen=%d comparable_heights=%d " - % (elapsed, heights_seen, comparable_now) - + " ".join("%s_samples=%d" % (n, per_node_samples[n]) for n in target_names), - flush=True, - ) - next_progress = time.time() + max(1, PROGRESS_EVERY) - -stop_event.set() -for thread in threads: - thread.join(timeout=2) - -matches = 0 -mismatches = 0 -comparable_heights = 0 -incomplete_heights = 0 -baseline_name = target_names[0] - -with sample_lock: - snapshot = dict(samples) - -for height in sorted(snapshot): - node_rows = snapshot[height] - if not all(name in node_rows for name in target_names): - incomplete_heights += 1 - continue - comparable_heights += 1 - baseline_rows = node_rows[baseline_name] - for idx, baseline in enumerate(baseline_rows): - request_ok = True - for name in target_names[1:]: - row = node_rows[name][idx] - if row["text"] != baseline["text"]: - request_ok = False - if mismatches < 5: - print( - "MISMATCH height=%d id=%s method=%s baseline=%s target=%s" - % (height, baseline["id"], baseline["method"], baseline_name, name), - flush=True, - ) - print(" %s=%s" % (baseline_name, short(baseline["text"])), flush=True) - print(" %s=%s" % (name, short(row["text"])), flush=True) - if request_ok: - matches += 1 - else: - mismatches += 1 - -print( - "CHECK_SUMMARY duration=%.0fs start_height=%d heights_seen=%d comparable_heights=%d " - "skipped_incomplete=%d requests_per_height=%d matched=%d mismatched=%d " - % (CHECK_DURATION, start_height, len(snapshot), comparable_heights, - incomplete_heights, REQUESTS_PER_HEIGHT, matches, mismatches) - + " ".join("%s_samples=%d" % (n, per_node_samples[n]) for n in target_names) + " " - + " ".join("%s_crossed=%d" % (n, per_node_crossed[n]) for n in target_names) + " " - + " ".join("%s_errors=%d" % (n, per_node_errors[n]) for n in target_names), - flush=True, -) - -# Verdict + distinct exit codes so callers can tell the three outcomes apart: -# CONSISTENT (0) we compared samples and everything agreed -# MISMATCH (1) targets returned different results at the SAME height -# INCONCLUSIVE (2) nothing was comparable, so consistency was NOT verified -if mismatches: - verdict, exit_code = "MISMATCH", 1 -elif comparable_heights == 0: - verdict, exit_code = "INCONCLUSIVE", 2 -else: - verdict, exit_code = "CONSISTENT", 0 - -summary = { - "kind": "check", "verdict": verdict, - "start_height": start_height, "heights_seen": len(snapshot), - "comparable_heights": comparable_heights, "skipped_incomplete": incomplete_heights, - "requests_per_height": REQUESTS_PER_HEIGHT, "matched": matches, "mismatched": mismatches, - "targets": target_names, - "per_node_samples": per_node_samples, "per_node_crossed": per_node_crossed, - "per_node_errors": per_node_errors, -} -print("CHECK_RESULT_JSON " + json.dumps(summary, separators=(",", ":")), flush=True) - -if verdict == "INCONCLUSIVE": - print( - "CHECK_VERDICT verdict=INCONCLUSIVE nothing_compared=1 -- no height was sampled " - "on ALL targets, so consistency was NOT verified (this is not a pass). Likely " - "causes: targets too far apart in height, slow methods causing crossed batches, " - "or --check-duration too short. Inspect per-node samples/crossed/errors above.", - flush=True, - ) -elif verdict == "MISMATCH": - print( - "CHECK_VERDICT verdict=MISMATCH matched=%d mismatched=%d -- targets returned " - "different results at the SAME height (see MISMATCH lines above)." - % (matches, mismatches), - flush=True, - ) -else: - print( - "CHECK_VERDICT verdict=CONSISTENT matched=%d comparable_heights=%d -- all reads " - "agreed at every compared height." % (matches, comparable_heights), - flush=True, - ) - -raise SystemExit(exit_code) -PYEOF - -# Prepend the shared generator so both programs define choose_request(). -LOAD_PY="$COMMON_PY -$LOAD_PY" -CHECK_PY="$COMMON_PY -$CHECK_PY" - -# --------------------------------------------------------------------------- -# dispatch_python CODE KEY=VAL... -> runs CODE locally or in an ephemeral pod, -# streaming stdout to the terminal and appending it to CAPTURE_FILE. -# --------------------------------------------------------------------------- -dispatch_python() { - local code=$1 - shift - if [ "$RUNNER" = "local" ]; then - printf '%s' "$code" | env "$@" python3 - | tee -a "$CAPTURE_FILE" - else - local runner="rpc-probe-$(date +%s)-$$-${RANDOM:-0}" - local -a envflags=() - local kv - for kv in "$@"; do - envflags+=(--env="$kv") - done - printf '%s' "$code" | kctl run "$runner" \ - --rm -i --restart=Never --image="$CHECK_IMAGE" "${envflags[@]}" \ - --command -- python3 - \ - 2> >(sed -e '/websocket.go.*Unknown stream id/d' -e '/^pod ".*" deleted$/d' -e "/^If you don't see a command prompt, try pressing enter\\.$/d" >&2) \ - | sed -e '/^pod ".*" deleted$/d' -e "/^If you don't see a command prompt, try pressing enter\\.$/d" \ - | tee -a "$CAPTURE_FILE" - fi -} - -run_target() { - local name=$1 url=$2 - echo "== load target=$name url=$url requests=$REQUESTS concurrency=$LOAD_CONCURRENCY duration=$LOAD_DURATION rps_limit=$LOAD_RPS sc_only=$LOAD_SC_ONLY warmup=$WARMUP ==" - dispatch_python "$LOAD_PY" \ - "TARGET_NAME=$name" "RPC_URL=$url" "REQUESTS=$REQUESTS" \ - "LOAD_CONCURRENCY=$LOAD_CONCURRENCY" "LOAD_DURATION=$LOAD_DURATION" \ - "LOAD_RPS=$LOAD_RPS" "LOAD_SC_ONLY=$LOAD_SC_ONLY" "WARMUP=$WARMUP" \ - "PROGRESS_EVERY=$PROGRESS_EVERY" "RPC_TIMEOUT=$RPC_TIMEOUT" "RPC_RETRIES=$RPC_RETRIES" \ - "MAX_ERROR_RATE=$MAX_ERROR_RATE" "CALL_TO=$CALL_TO" \ - "STATE_ADDRESS=$STATE_ADDRESS" "STORAGE_SLOT=$STORAGE_SLOT" -} - -run_load_targets() { - local rc=0 i - for i in "${!target_names[@]}"; do - run_target "${target_names[$i]}" "${target_urls[$i]}" || rc=1 - done - return $rc -} - -build_check_targets_payload() { - check_targets_payload="" - local i - for i in "${!target_names[@]}"; do - check_targets_payload="${check_targets_payload}${target_names[$i]} ${target_urls[$i]} -" - done -} - -run_check_once() { - build_check_targets_payload - echo "== check targets=${TARGETS} duration=${CHECK_DURATION}s requests_per_height=$REQUESTS_PER_HEIGHT ==" - dispatch_python "$CHECK_PY" \ - "CHECK_DURATION=$CHECK_DURATION" "REQUESTS_PER_HEIGHT=$REQUESTS_PER_HEIGHT" \ - "CHECK_POLL=$CHECK_POLL" "RPC_TIMEOUT=$RPC_TIMEOUT" "RPC_RETRIES=$RPC_RETRIES" \ - "PROGRESS_EVERY=$PROGRESS_EVERY" "VERBOSE=$VERBOSE" "CALL_TO=$CALL_TO" \ - "STATE_ADDRESS=$STATE_ADDRESS" "STORAGE_SLOT=$STORAGE_SLOT" \ - "TARGETS_PAYLOAD=$check_targets_payload" -} - -run_monitor() { - local iteration=1 failures=0 - while :; do - printf 'MONITOR_ITERATION iteration=%s timestamp=%s mode=check\n' "$iteration" "$(date -u +%FT%TZ)" - if run_check_once; then - printf 'MONITOR_RESULT iteration=%s result=ok\n' "$iteration" - else - failures=$((failures + 1)) - printf 'MONITOR_RESULT iteration=%s result=failed failures=%s\n' "$iteration" "$failures" - fi - if [ "$MONITOR_ITERATIONS" -gt 0 ] && [ "$iteration" -ge "$MONITOR_ITERATIONS" ]; then - break - fi - iteration=$((iteration + 1)) - sleep "$MONITOR_INTERVAL" - done - [ "$failures" -eq 0 ] -} - -parse_targets - -rc=0 -case "$MODE" in - check) run_check_once || rc=$? ;; - monitor) run_monitor || rc=$? ;; - load) - echo "== load mode: per-target request run; increase REQUESTS or set LOAD_DURATION for volume ==" - run_load_targets || rc=$? - ;; -esac - -if [ -n "$OUTPUT_JSON" ]; then - # -E for portable alternation (BSD/macOS sed lacks \| in basic regex). - sed -E -n 's/^(LOAD|CHECK)_RESULT_JSON //p' "$CAPTURE_FILE" > "$OUTPUT_JSON" - echo "wrote $(wc -l < "$OUTPUT_JSON" | tr -d ' ') machine-readable result(s) (JSONL) to $OUTPUT_JSON" >&2 -fi - -exit "$rc" diff --git a/sei-cosmos/server/config/config.go b/sei-cosmos/server/config/config.go index c46a296125..6701ace3d1 100644 --- a/sei-cosmos/server/config/config.go +++ b/sei-cosmos/server/config/config.go @@ -11,6 +11,7 @@ import ( sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" sdkerrors "github.com/sei-protocol/sei-chain/sei-cosmos/types/errors" "github.com/sei-protocol/sei-chain/sei-db/config" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/memiavl" tmcfg "github.com/sei-protocol/sei-chain/sei-tendermint/config" "github.com/spf13/viper" ) @@ -456,12 +457,6 @@ func GetConfig(v *viper.Viper) (Config, error) { } scWriteMode = config.ApplyWriteModeAuto(scWriteModeEnableAuto, scWriteMode) - // FlatKV knobs are not rendered in the default app.toml template. GetConfig - // is a faithful parse of app.toml/flags: it only reads the explicit - // state-commit.flatkv.* keys (if an operator adds them by hand) on top of the - // in-code defaults. The FlatKV-follows-memIAVL mirror (and snapshot cadence - // normalization) is applied later by composite.alignFlatKVSnapshotWithMemIAVL - // at store construction, so we deliberately do not mirror the sc-* keys here. flatKVConfig := config.DefaultStateCommitConfig().FlatKVConfig if v.IsSet("state-commit.flatkv.fsync") { flatKVConfig.Fsync = v.GetBool("state-commit.flatkv.fsync") @@ -479,35 +474,6 @@ func GetConfig(v *viper.Viper) (Config, error) { flatKVConfig.EnableReadWriteMetrics = v.GetBool("state-commit.flatkv.enable-read-write-metrics") } - // Guard every memIAVL read with a presence check so that an absent - // state-commit.sc-* key preserves the in-code default from - // DefaultStateCommitConfig rather than reading back the zero value - // (v.Get*(absent) == 0) and clobbering it. This matters for the keys whose - // default is non-zero (async-commit-buffer 100, snapshot-interval 10000, - // keep-recent 1, ...): a config that omits a key must not silently downgrade - // the node (e.g. to synchronous commits or disabled snapshots). Mirrors the - // guarded parse in app/seidb.go's parseSCConfigs. An explicit key (including - // an explicit 0) is IsSet == true and is read verbatim. - memIAVLConfig := config.DefaultStateCommitConfig().MemIAVLConfig - if v.IsSet("state-commit.sc-async-commit-buffer") { - memIAVLConfig.AsyncCommitBuffer = v.GetInt("state-commit.sc-async-commit-buffer") - } - if v.IsSet("state-commit.sc-keep-recent") { - memIAVLConfig.SnapshotKeepRecent = v.GetUint32("state-commit.sc-keep-recent") - } - if v.IsSet("state-commit.sc-snapshot-interval") { - memIAVLConfig.SnapshotInterval = v.GetUint32("state-commit.sc-snapshot-interval") - } - if v.IsSet("state-commit.sc-snapshot-min-time-interval") { - memIAVLConfig.SnapshotMinTimeInterval = v.GetUint32("state-commit.sc-snapshot-min-time-interval") - } - if v.IsSet("state-commit.sc-snapshot-writer-limit") { - memIAVLConfig.SnapshotWriterLimit = v.GetInt("state-commit.sc-snapshot-writer-limit") - } - if v.IsSet("state-commit.sc-snapshot-prefetch-threshold") { - memIAVLConfig.SnapshotPrefetchThreshold = v.GetFloat64("state-commit.sc-snapshot-prefetch-threshold") - } - // Apply the in-code default when the key is absent so that nodes upgrading // with an older app.toml (which lacks this key) are still bounded rather // than running with unlimited connections. @@ -622,8 +588,15 @@ func GetConfig(v *viper.Viper) (Config, error) { Directory: v.GetString("state-commit.sc-directory"), WriteMode: scWriteMode, WriteModeEnableAuto: scWriteModeEnableAuto, - MemIAVLConfig: memIAVLConfig, - FlatKVConfig: flatKVConfig, + MemIAVLConfig: memiavl.Config{ + AsyncCommitBuffer: v.GetInt("state-commit.sc-async-commit-buffer"), + SnapshotKeepRecent: v.GetUint32("state-commit.sc-keep-recent"), + SnapshotInterval: v.GetUint32("state-commit.sc-snapshot-interval"), + SnapshotMinTimeInterval: v.GetUint32("state-commit.sc-snapshot-min-time-interval"), + SnapshotWriterLimit: v.GetInt("state-commit.sc-snapshot-writer-limit"), + SnapshotPrefetchThreshold: v.GetFloat64("state-commit.sc-snapshot-prefetch-threshold"), + }, + FlatKVConfig: flatKVConfig, }, StateStore: config.StateStoreConfig{ Enable: v.GetBool("state-store.ss-enable"), diff --git a/sei-cosmos/server/config/config_test.go b/sei-cosmos/server/config/config_test.go index 9f3f76c7b6..0d9d567b3a 100644 --- a/sei-cosmos/server/config/config_test.go +++ b/sei-cosmos/server/config/config_test.go @@ -314,13 +314,8 @@ func TestGetConfig(t *testing.T) { require.Equal(t, DefaultMinGasPrices, cfg.MinGasPrices) require.True(t, cfg.Telemetry.Enabled) require.False(t, cfg.API.Enable) - // sc-snapshot-interval / sc-keep-recent are absent here, so FlatKV must keep - // its in-code defaults rather than being clobbered to 0 (which would disable - // FlatKV snapshots / drop all old snapshots). The memIAVL mirror only applies - // when the keys are explicitly set. - defaultFlatKV := seidbconfig.DefaultStateCommitConfig().FlatKVConfig - require.Equal(t, defaultFlatKV.SnapshotInterval, cfg.StateCommit.FlatKVConfig.SnapshotInterval) - require.Equal(t, defaultFlatKV.SnapshotKeepRecent, cfg.StateCommit.FlatKVConfig.SnapshotKeepRecent) + require.Equal(t, seidbconfig.DefaultStateCommitConfig().FlatKVConfig.SnapshotInterval, cfg.StateCommit.FlatKVConfig.SnapshotInterval) + require.Equal(t, seidbconfig.DefaultStateCommitConfig().FlatKVConfig.SnapshotKeepRecent, cfg.StateCommit.FlatKVConfig.SnapshotKeepRecent) } func TestConfigTemplate(t *testing.T) { @@ -471,88 +466,6 @@ func TestGetConfigStateCommit(t *testing.T) { require.Equal(t, 0.9, cfg.StateCommit.MemIAVLConfig.SnapshotPrefetchThreshold) } -func TestGetConfigParsesRawSnapshotKeepRecent(t *testing.T) { - v := viper.New() - v.Set("minimum-gas-prices", DefaultMinGasPrices) - v.Set("telemetry.global-labels", []interface{}{}) - v.Set("state-commit.sc-keep-recent", 0) - - cfg, err := GetConfig(v) - require.NoError(t, err) - // GetConfig is a faithful parse of app.toml/flags: the raw 0 is preserved for - // memIAVL here and only floored later at store construction. FlatKV does not - // mirror the sc-* keys in GetConfig (that is composite.alignFlatKVSnapshotWithMemIAVL's - // job), so it keeps its in-code default. - require.Equal(t, uint32(0), cfg.StateCommit.MemIAVLConfig.SnapshotKeepRecent) - require.Equal(t, seidbconfig.DefaultStateCommitConfig().FlatKVConfig.SnapshotKeepRecent, cfg.StateCommit.FlatKVConfig.SnapshotKeepRecent) -} - -func TestGetConfigHonorsExplicitFlatKVOverrides(t *testing.T) { - v := viper.New() - v.Set("minimum-gas-prices", DefaultMinGasPrices) - v.Set("telemetry.global-labels", []interface{}{}) - // Explicit (hidden) FlatKV overrides must win over the in-code defaults. - v.Set("state-commit.flatkv.fsync", true) - v.Set("state-commit.flatkv.async-write-buffer", 128) - v.Set("state-commit.flatkv.snapshot-interval", 7000) - v.Set("state-commit.flatkv.snapshot-keep-recent", 9) - v.Set("state-commit.flatkv.enable-read-write-metrics", true) - - cfg, err := GetConfig(v) - require.NoError(t, err) - fk := cfg.StateCommit.FlatKVConfig - require.True(t, fk.Fsync) - require.Equal(t, 128, fk.AsyncWriteBuffer) - require.Equal(t, uint32(7000), fk.SnapshotInterval) - require.Equal(t, uint32(9), fk.SnapshotKeepRecent) - require.True(t, fk.EnableReadWriteMetrics) -} - -// TestGetConfigFlatKVDefaultsWhenSCSnapshotAbsent locks in the regression fix: -// GetConfig does not mirror the sc-* keys onto FlatKV (that is -// composite.alignFlatKVSnapshotWithMemIAVL's job at store construction), and an -// absent sc-snapshot-interval / sc-keep-recent must preserve the in-code FlatKV -// defaults rather than reading back 0 (which disables FlatKV snapshots and drops -// all old snapshots). -func TestGetConfigFlatKVDefaultsWhenSCSnapshotAbsent(t *testing.T) { - v := viper.New() - v.Set("minimum-gas-prices", DefaultMinGasPrices) - v.Set("telemetry.global-labels", []interface{}{}) - - cfg, err := GetConfig(v) - require.NoError(t, err) - defaultFlatKV := seidbconfig.DefaultStateCommitConfig().FlatKVConfig - require.Equal(t, defaultFlatKV.SnapshotInterval, cfg.StateCommit.FlatKVConfig.SnapshotInterval) - require.Equal(t, defaultFlatKV.SnapshotKeepRecent, cfg.StateCommit.FlatKVConfig.SnapshotKeepRecent) - require.NotZero(t, cfg.StateCommit.FlatKVConfig.SnapshotInterval) -} - -// TestGetConfigMemIAVLDefaultsWhenSCKeysAbsent asserts that, with no -// state-commit.sc-* keys set, GetConfig resolves the memIAVL config to its -// in-code defaults rather than clobbering the non-zero ones to the zero value. -func TestGetConfigMemIAVLDefaultsWhenSCKeysAbsent(t *testing.T) { - v := viper.New() - v.Set("minimum-gas-prices", DefaultMinGasPrices) - v.Set("telemetry.global-labels", []interface{}{}) - - cfg, err := GetConfig(v) - require.NoError(t, err) - - def := seidbconfig.DefaultStateCommitConfig().MemIAVLConfig - mem := cfg.StateCommit.MemIAVLConfig - require.Equal(t, def.AsyncCommitBuffer, mem.AsyncCommitBuffer) - require.Equal(t, def.SnapshotKeepRecent, mem.SnapshotKeepRecent) - require.Equal(t, def.SnapshotInterval, mem.SnapshotInterval) - require.Equal(t, def.SnapshotMinTimeInterval, mem.SnapshotMinTimeInterval) - require.Equal(t, def.SnapshotWriterLimit, mem.SnapshotWriterLimit) - require.Equal(t, def.SnapshotPrefetchThreshold, mem.SnapshotPrefetchThreshold) - // The defaults that matter most are the non-zero ones: an absent key must - // not silently downgrade the node (e.g. async-commit-buffer to 0 = - // synchronous commits, or snapshot-interval to 0). - require.NotZero(t, mem.AsyncCommitBuffer) - require.NotZero(t, mem.SnapshotInterval) -} - func TestGetConfigRejectsInvalidWriteMode(t *testing.T) { v := viper.New() diff --git a/sei-cosmos/server/start.go b/sei-cosmos/server/start.go index 48e91448c7..05e602d107 100644 --- a/sei-cosmos/server/start.go +++ b/sei-cosmos/server/start.go @@ -165,11 +165,13 @@ is performed. Note, when enabled, gRPC will also be automatically enabled. tracerProviderOptions = []trace.TracerProviderOption{} } + logger.Info("Creating node metrics provider") + nodeMetricsProvider := node.DefaultMetricsProvider(serverCtx.Config.Instrumentation)(clientCtx.ChainID) + config, err := config.GetConfig(serverCtx.Viper) if err != nil { return err } - config.Telemetry = injectTelemetryChainID(config.Telemetry, clientCtx.ChainID) apiMetrics, err := telemetry.New(config.Telemetry) if err != nil { return fmt.Errorf("failed to initialize telemetry: %w", err) @@ -188,6 +190,7 @@ is performed. Note, when enabled, gRPC will also be automatically enabled. clientCtx, appCreator, tracerProviderOptions, + nodeMetricsProvider, apiMetrics, ) if !errors.Is(err, ErrShouldRestart) { @@ -245,19 +248,6 @@ func addStartNodeFlags(cmd *cobra.Command, defaultNodeHome string) { mustMarkDeprecated(cmd, flagTransport, "out-of-process ABCI has been removed; this flag is ignored") } -func injectTelemetryChainID(cfg telemetry.Config, chainID string) telemetry.Config { - if chainID == "" { - return cfg - } - for _, label := range cfg.GlobalLabels { - if len(label) > 0 && label[0] == "chain_id" { - return cfg - } - } - cfg.GlobalLabels = append(cfg.GlobalLabels, []string{"chain_id", chainID}) - return cfg -} - func mustMarkDeprecated(cmd *cobra.Command, name, message string) { cmd.Flags().String(name, "", "") if err := cmd.Flags().MarkDeprecated(name, message); err != nil { @@ -270,6 +260,7 @@ func startInProcess( clientCtx client.Context, appCreator types.AppCreator, tracerProviderOptions []trace.TracerProviderOption, + nodeMetricsProvider *node.NodeMetrics, apiMetrics *telemetry.Metrics, ) error { cfg := ctx.Config @@ -358,6 +349,7 @@ func startInProcess( app, gen, tracerProviderOptions, + nodeMetricsProvider, tmtypes.DefaultConsensusPolicy(), ) if err != nil { diff --git a/sei-cosmos/store/cachekv/frozen_skip_test.go b/sei-cosmos/store/cachekv/frozen_skip_test.go deleted file mode 100644 index 372d7c58c4..0000000000 --- a/sei-cosmos/store/cachekv/frozen_skip_test.go +++ /dev/null @@ -1,241 +0,0 @@ -package cachekv_test - -import ( - "fmt" - "testing" - - "github.com/stretchr/testify/require" - dbm "github.com/tendermint/tm-db" - - "github.com/sei-protocol/sei-chain/sei-cosmos/store/cachekv" - "github.com/sei-protocol/sei-chain/sei-cosmos/store/dbadapter" - "github.com/sei-protocol/sei-chain/sei-cosmos/store/types" -) - -// stack builds n nested cachekv layers on top of base and returns them -// bottom-first (result[0] is the deepest layer over base, result[n-1] is the top). -func stack(base types.KVStore, n int) []*cachekv.Store { - layers := make([]*cachekv.Store, n) - parent := base - for i := 0; i < n; i++ { - s := cachekv.NewStore(parent, types.NewKVStoreKey("CacheKvTest"), types.DefaultCacheSizeLimit) - layers[i] = s - parent = s - } - return layers -} - -// freezeAllButTop freezes every layer except the topmost, mirroring how the EVM -// snapshot stack freezes a layer once a newer one is stacked on top of it. -func freezeAllButTop(layers []*cachekv.Store) { - for i := 0; i < len(layers)-1; i++ { - layers[i].Freeze() - } -} - -// TestFrozenEmptyLayerSkipEquivalence verifies that reads through a deep stack of -// frozen, empty cache layers return exactly what a read against the base returns. -func TestFrozenEmptyLayerSkipEquivalence(t *testing.T) { - mem := dbadapter.Store{DB: dbm.NewMemDB()} - for i := 0; i < 20; i++ { - mem.Set(keyFmt(i), valFmt(i)) - } - - layers := stack(mem, 64) - freezeAllButTop(layers) - top := layers[len(layers)-1] - - // Point reads through the empty frozen stack must match the base exactly. - for i := 0; i < 20; i++ { - require.Equal(t, valFmt(i), top.Get(keyFmt(i)), "key %d", i) - } - require.Nil(t, top.Get(bz("missing"))) - require.True(t, top.Has(keyFmt(0))) - require.False(t, top.Has(bz("missing"))) - - // Full iteration through the empty frozen stack must yield every base key. - itr := top.Iterator(nil, nil) - got := 0 - for ; itr.Valid(); itr.Next() { - require.Equal(t, keyFmt(got), itr.Key()) - require.Equal(t, valFmt(got), itr.Value()) - got++ - } - require.NoError(t, itr.Close()) - require.Equal(t, 20, got) - - // Reverse iteration too. - ritr := top.ReverseIterator(nil, nil) - rgot := 0 - for ; ritr.Valid(); ritr.Next() { - exp := 19 - rgot - require.Equal(t, keyFmt(exp), ritr.Key()) - require.Equal(t, valFmt(exp), ritr.Value()) - rgot++ - } - require.NoError(t, ritr.Close()) - require.Equal(t, 20, rgot) -} - -// TestFrozenLayerWithWritesIsNotSkipped verifies that a frozen layer holding a -// write (a set or a delete) still shadows the base — it must never be skipped. -func TestFrozenLayerWithWritesIsNotSkipped(t *testing.T) { - mem := dbadapter.Store{DB: dbm.NewMemDB()} - mem.Set(keyFmt(1), valFmt(1)) - mem.Set(keyFmt(2), valFmt(2)) - mem.Set(keyFmt(3), valFmt(3)) - - layers := stack(mem, 8) - - // A middle layer overrides key 1 and deletes key 2, then is frozen (dirty). - mid := layers[3] - mid.Set(keyFmt(1), valFmt(100)) - mid.Delete(keyFmt(2)) - freezeAllButTop(layers) - - top := layers[len(layers)-1] - - // Point reads must reflect the middle layer, not the base. - require.Equal(t, valFmt(100), top.Get(keyFmt(1)), "shadowed set must win") - require.Nil(t, top.Get(keyFmt(2)), "delete in a frozen layer must hide the base key") - require.Equal(t, valFmt(3), top.Get(keyFmt(3))) - - // Iteration must reflect the delete and the shadowing set. - itr := top.Iterator(nil, nil) - seen := map[string]string{} - for ; itr.Valid(); itr.Next() { - seen[string(itr.Key())] = string(itr.Value()) - } - require.NoError(t, itr.Close()) - require.Equal(t, map[string]string{ - string(keyFmt(1)): string(valFmt(100)), - string(keyFmt(3)): string(valFmt(3)), - }, seen) -} - -// TestSkipRecomputedAfterFrozenLayerBecomesDirty covers the RevertToSnapshot -// re-exposure case at the store level: a layer that was frozen-empty (and thus -// skippable) later receives writes, and any child created afterwards must consult -// it. In the real EVM flow, children created before the write are discarded by the -// revert, so no already-memoized skip can go stale. -func TestSkipRecomputedAfterFrozenLayerBecomesDirty(t *testing.T) { - mem := dbadapter.Store{DB: dbm.NewMemDB()} - mem.Set(keyFmt(1), valFmt(1)) - - frozenEmpty := cachekv.NewStore(mem, types.NewKVStoreKey("CacheKvTest"), types.DefaultCacheSizeLimit) - frozenEmpty.Freeze() - - // Child created while the layer is frozen+empty skips it and reads the base. - early := cachekv.NewStore(frozenEmpty, types.NewKVStoreKey("CacheKvTest"), types.DefaultCacheSizeLimit) - require.Equal(t, valFmt(1), early.Get(keyFmt(1))) - - // The layer is re-exposed (as after a revert) and written to. - frozenEmpty.Set(keyFmt(1), valFmt(2)) - - // A child created after the write must observe it (skip must not apply). - late := cachekv.NewStore(frozenEmpty, types.NewKVStoreKey("CacheKvTest"), types.DefaultCacheSizeLimit) - require.Equal(t, valFmt(2), late.Get(keyFmt(1))) -} - -// TestRevertWriteSnapshotSequence models the exact DBImpl flow that motivated the -// Unfreeze-on-revert fix: RevertToSnapshot re-exposes a frozen layer, execution -// writes to it, then Snapshot re-freezes it and stacks a new layer. A read from -// the new top must observe the post-revert write for the written key while still -// short-circuiting reads of keys the layer does not hold. -func TestRevertWriteSnapshotSequence(t *testing.T) { - mem := dbadapter.Store{DB: dbm.NewMemDB()} - mem.Set(keyFmt(1), valFmt(1)) - mem.Set(keyFmt(2), valFmt(2)) - - // L is a snapshot layer that was frozen when it was superseded. - L := cachekv.NewStore(mem, types.NewKVStoreKey("CacheKvTest"), types.DefaultCacheSizeLimit) - L.Freeze() - - // (1) RevertToSnapshot re-exposes L as the writable top: unfreeze it. - L.Unfreeze() - // (2) Execution writes key 1 into the re-exposed layer. - L.Set(keyFmt(1), valFmt(100)) - // (3) Snapshot stacks a new layer and re-freezes L. - L.Freeze() - C := cachekv.NewStore(L, types.NewKVStoreKey("CacheKvTest"), types.DefaultCacheSizeLimit) - - // The written key reflects the post-revert write (L is dirty, not skipped); - // the untouched key still resolves through to the base. - require.Equal(t, valFmt(100), C.Get(keyFmt(1))) - require.Equal(t, valFmt(2), C.Get(keyFmt(2))) -} - -// TestUnfreezeBypassesStaleSkipMemo covers the concurrent-copy hazard: a store -// memoizes a skip over a frozen-empty parent, then that parent is re-exposed -// (unfrozen) and written. Because reads gate on the parent's live frozen bit, the -// same store must observe the write despite its stale memo. -func TestUnfreezeBypassesStaleSkipMemo(t *testing.T) { - mem := dbadapter.Store{DB: dbm.NewMemDB()} - mem.Set(keyFmt(1), valFmt(1)) - - parent := cachekv.NewStore(mem, types.NewKVStoreKey("CacheKvTest"), types.DefaultCacheSizeLimit) - parent.Freeze() - - child := cachekv.NewStore(parent, types.NewKVStoreKey("CacheKvTest"), types.DefaultCacheSizeLimit) - // Read while parent is frozen+empty: child memoizes a skip over parent. - require.Equal(t, valFmt(1), child.Get(keyFmt(1))) - - // Parent is re-exposed and written (the case the single-DBImpl revert would - // otherwise discard the child for, but a live Copy would not). - parent.Unfreeze() - parent.Set(keyFmt(1), valFmt(2)) - - // The child must now see the write even though it memoized a skip earlier. - require.Equal(t, valFmt(2), child.Get(keyFmt(1))) -} - -// TestWriteClearsFrozenSkipState verifies Write() resets emptiness bookkeeping so -// a flushed layer is treated as empty again. -func TestWriteClearsFrozenSkipState(t *testing.T) { - mem := dbadapter.Store{DB: dbm.NewMemDB()} - mem.Set(keyFmt(1), valFmt(1)) - - a := cachekv.NewStore(mem, types.NewKVStoreKey("CacheKvTest"), types.DefaultCacheSizeLimit) - a.Set(keyFmt(2), valFmt(2)) // a is dirty - a.Freeze() - a.Write() // flushes into mem and clears dirty - - b := cachekv.NewStore(a, types.NewKVStoreKey("CacheKvTest"), types.DefaultCacheSizeLimit) - // a is frozen and empty again after Write; b must still read through correctly. - require.Equal(t, valFmt(1), b.Get(keyFmt(1))) - require.Equal(t, valFmt(2), b.Get(keyFmt(2))) -} - -// buildEmptyStackOverBase is a benchmark helper. -func buildEmptyStackOverBase(depth int, freeze bool) *cachekv.Store { - mem := dbadapter.Store{DB: dbm.NewMemDB()} - for i := 0; i < 50; i++ { - mem.Set(keyFmt(i), valFmt(i)) - } - layers := stack(mem, depth) - if freeze { - freezeAllButTop(layers) - } - return layers[len(layers)-1] -} - -// BenchmarkDeepStackGet demonstrates that freezing empty layers keeps a read O(1) -// in stack depth instead of O(depth). -func BenchmarkDeepStackGet(b *testing.B) { - for _, depth := range []int{16, 256, 1024} { - b.Run(fmt.Sprintf("depth=%d/frozen", depth), func(b *testing.B) { - top := buildEmptyStackOverBase(depth, true) - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = top.Get(keyFmt(i % 50)) - } - }) - b.Run(fmt.Sprintf("depth=%d/unfrozen", depth), func(b *testing.B) { - top := buildEmptyStackOverBase(depth, false) - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = top.Get(keyFmt(i % 50)) - } - }) - } -} diff --git a/sei-cosmos/store/cachekv/store.go b/sei-cosmos/store/cachekv/store.go index da09b01478..920ee9a076 100644 --- a/sei-cosmos/store/cachekv/store.go +++ b/sei-cosmos/store/cachekv/store.go @@ -5,7 +5,6 @@ import ( "io" "sort" "sync" - "sync/atomic" "github.com/sei-protocol/sei-chain/sei-cosmos/internal/conv" "github.com/sei-protocol/sei-chain/sei-cosmos/store/tracekv" @@ -24,20 +23,6 @@ type Store struct { parent types.KVStore storeKey types.StoreKey cacheSize int - - // frozen marks a layer that has been superseded by a newer cache layer and - // will therefore never receive another write via this store reference (writes - // go to the newest layer). It is set opt-in via Freeze() — callers that never - // call Freeze (all non-EVM code) get exactly the previous behavior. - frozen atomic.Bool - // dirty is set the first time a key is written (Set/Delete) and cleared on - // Write(). A frozen layer with dirty==false is empty and, by the freeze - // invariant, stays empty, so reads may skip it entirely. - dirty atomic.Bool - // readParent memoizes the nearest ancestor a read must consult, skipping any - // run of frozen empty layers. Valid for the store's lifetime because a frozen - // empty layer never gains writes (see readThroughParent). - readParent atomic.Pointer[types.KVStore] } var _ types.CacheKVStore = (*Store)(nil) @@ -69,58 +54,7 @@ func (store *Store) getFromCache(key []byte) []byte { if cv, ok := store.cache.Load(conv.UnsafeBytesToStr(key)); ok { return cv.(*types.CValue).Value() } - return store.readThroughParent().Get(key) -} - -// readThroughParent returns the store a cache-missing read must fall through to. -// It normally returns store.parent, but when the parent is a frozen empty cache -// layer it walks up, skipping every consecutive frozen empty layer, and returns -// the first ancestor that could actually hold data. Empty layers contribute -// nothing to a point read (getFromCache falls straight through them), so the -// result is identical to walking one layer at a time — only O(1) instead of -// O(depth) once a deep stack of empty snapshot layers has accumulated. -// -// The result is safe to memoize: a layer is only skipped when it is frozen AND -// empty, and a frozen layer never gains writes (writes always target the newest, -// unfrozen layer). The single exception is RevertToSnapshot re-exposing a layer -// as the newest layer, but that discards every layer above it — including any -// store that memoized a skip over it — so no live memo can go stale. -func (store *Store) readThroughParent() types.KVStore { - // Cheap gate: only nested cache layers can be skipped. The common single-layer - // case (parent is an iavl/dbadapter store) never enters the skip path. - cp, ok := store.parent.(*Store) - if !ok || !cp.frozen.Load() || cp.dirty.Load() { - return store.parent - } - if p := store.readParent.Load(); p != nil { - return *p - } - p := store.parent - for { - next, ok := p.(*Store) - if !ok || !next.frozen.Load() || next.dirty.Load() { - break - } - p = next.parent - } - store.readParent.Store(&p) - return p -} - -// Freeze marks the store as superseded by a newer cache layer. After Freeze the -// caller must not write to the store again (writes go to the newer layer); this -// lets deeper layers skip it for reads while it is empty. Freeze is idempotent. -func (store *Store) Freeze() { - store.frozen.Store(true) -} - -// Unfreeze reverts Freeze, marking the store writable again. It is called when a -// layer is re-exposed as the newest layer (e.g. RevertToSnapshot), so that reads -// stop skipping it — a writable top must never be treated as a frozen empty layer. -// Deeper stores gate on the parent's live frozen bit, so unfreezing here also -// bypasses any stale memo that skipped this store while it was frozen. -func (store *Store) Unfreeze() { - store.frozen.Store(false) + return store.parent.Get(key) } // Get implements types.KVStore. @@ -187,10 +121,6 @@ func (store *Store) Write() { store.deleted = &sync.Map{} store.unsortedCache = &sync.Map{} store.sortedCache = nil - // The layer is empty again; drop the memoized skip so a subsequent read - // recomputes it against the current parent chain. - store.dirty.Store(false) - store.readParent.Store(nil) } // CacheWrap implements CacheWrapper. @@ -230,15 +160,10 @@ func (store *Store) iterator(start, end []byte, ascending bool) types.Iterator { var parent, cache types.Iterator - // Iterate the nearest ancestor that can hold data, skipping any run of frozen - // empty layers. An empty layer has no sets and no deletes, so it contributes - // nothing to iteration; skipping it avoids building an O(depth) chain of - // cacheMergeIterators over a deep snapshot stack. - parentStore := store.readThroughParent() if ascending { - parent = parentStore.Iterator(start, end) + parent = store.parent.Iterator(start, end) } else { - parent = parentStore.ReverseIterator(start, end) + parent = store.parent.ReverseIterator(start, end) } defer func() { if err := recover(); err != nil { @@ -251,24 +176,6 @@ func (store *Store) iterator(start, end []byte, ascending bool) types.Iterator { }() store.dirtyItems(start, end) cache = newMemIterator(start, end, store.getOrInitSortedCache(), store.deleted, ascending) - // Fast path: when this layer has no cached writes or deletes within [start, end), - // the merge is a pure pass-through of the parent iterator. Returning the parent - // directly avoids wrapping every empty cache layer in a cacheMergeIterator. Deeply - // nested cache stacks (e.g. one CacheMultiStore layer per EVM call frame) would - // otherwise force each Value()/Next()/Valid() to recurse through O(depth) merge - // iterators, turning a linear number of reads into quadratic work. Because empty - // layers pass through, nested empty layers collapse the whole chain to the base - // iterator. Note dirtyItems materializes in-range deletes into sortedCache, so a - // non-Valid cache correctly means "no sets and no deletes in range". - if !cache.Valid() { - if err := cache.Close(); err != nil { - if parent != nil { - _ = parent.Close() - } - panic(err) - } - return parent - } return NewCacheMergeIterator(parent, cache, ascending, store.storeKey) } @@ -434,8 +341,6 @@ func (store *Store) setCacheValue(key, value []byte, deleted bool, dirty bool) { if dirty { store.unsortedCache.Store(keyStr, struct{}{}) } - // Mark the layer non-empty so deeper layers stop skipping it for reads. - store.dirty.Store(true) } func (store *Store) isDeleted(key string) bool { diff --git a/sei-cosmos/store/cachemulti/store.go b/sei-cosmos/store/cachemulti/store.go index 627d5a39f0..d6f0e8f93c 100644 --- a/sei-cosmos/store/cachemulti/store.go +++ b/sei-cosmos/store/cachemulti/store.go @@ -4,7 +4,6 @@ import ( "fmt" "io" "sync" - "sync/atomic" dbm "github.com/tendermint/tm-db" @@ -15,14 +14,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-cosmos/store/types" ) -// freezable is implemented by cache stores that can be marked as superseded by a -// newer layer so that deeper layers may skip them for reads while they are empty, -// and reverted back to writable when a layer is re-exposed. -type freezable interface { - Freeze() - Unfreeze() -} - //---------------------------------------- // Store @@ -45,11 +36,6 @@ type Store struct { mu *sync.RWMutex // protects stores and parents during lazy creation materializeOnce *sync.Once - // frozen marks this layer as superseded by a newer cache layer. Set opt-in via - // Freeze(); shared (pointer) across value copies of the same layer so that - // stores materialized lazily after freezing are also frozen. - frozen *atomic.Bool - closers []io.Closer } @@ -91,7 +77,6 @@ func newStoreWithoutGiga(store types.KVStore, stores map[types.StoreKey]types.Ca traceContext: traceContext, mu: &sync.RWMutex{}, materializeOnce: &sync.Once{}, - frozen: &atomic.Bool{}, closers: []io.Closer{}, } @@ -118,7 +103,6 @@ func newCacheMultiStoreFromCMS(cms Store) Store { cms.materializeOnce.Do(func() { // Lock held for bulk materialization to avoid per-key lock overhead. cms.mu.Lock() - frozen := cms.frozen != nil && cms.frozen.Load() for k := range cms.parents { // Inline the creation here — we already hold the write lock. parent := cms.parents[k] @@ -126,11 +110,7 @@ func newCacheMultiStoreFromCMS(cms Store) Store { if cms.TracingEnabled() { cw = tracekv.NewStore(parent.(types.KVStore), cms.traceWriter, cms.traceContext) } - s := cachekv.NewStore(cw.(types.KVStore), k, types.DefaultCacheSizeLimit) - if frozen { - s.Freeze() - } - cms.stores[k] = s + cms.stores[k] = cachekv.NewStore(cw.(types.KVStore), k, types.DefaultCacheSizeLimit) delete(cms.parents, k) } cms.mu.Unlock() @@ -181,65 +161,11 @@ func (cms Store) getOrCreateStore(key types.StoreKey) types.CacheWrap { cw = tracekv.NewStore(parent.(types.KVStore), cms.traceWriter, cms.traceContext) } s := cachekv.NewStore(cw.(types.KVStore), key, types.DefaultCacheSizeLimit) - if cms.frozen != nil && cms.frozen.Load() { - s.Freeze() - } cms.stores[key] = s delete(cms.parents, key) return s } -// Freeze marks the layer (and every materialized cache store within it) as -// superseded by a newer cache layer, so deeper layers may skip it for reads while -// it is empty. Stores materialized after Freeze inherit the frozen state via the -// shared flag. Freeze is opt-in and idempotent; layers that are never frozen keep -// the prior behavior exactly. -func (cms Store) Freeze() { - if cms.frozen != nil { - cms.frozen.Store(true) - } - if f, ok := cms.db.(freezable); ok { - f.Freeze() - } - cms.mu.RLock() - for _, s := range cms.stores { - if f, ok := s.(freezable); ok { - f.Freeze() - } - } - cms.mu.RUnlock() - for _, s := range cms.gigaStores { - if f, ok := s.(freezable); ok { - f.Freeze() - } - } -} - -// Unfreeze reverts Freeze for the layer and every materialized cache store within -// it. It is called when the layer is re-exposed as the newest (writable) layer, -// e.g. by RevertToSnapshot, so that it is no longer skipped for reads. Stores -// materialized after Unfreeze see the cleared flag and are not frozen. -func (cms Store) Unfreeze() { - if cms.frozen != nil { - cms.frozen.Store(false) - } - if f, ok := cms.db.(freezable); ok { - f.Unfreeze() - } - cms.mu.RLock() - for _, s := range cms.stores { - if f, ok := s.(freezable); ok { - f.Unfreeze() - } - } - cms.mu.RUnlock() - for _, s := range cms.gigaStores { - if f, ok := s.(freezable); ok { - f.Unfreeze() - } - } -} - // SetTracer sets the tracer for the MultiStore that the underlying // stores will utilize to trace operations. A MultiStore is returned. func (cms Store) SetTracer(w io.Writer) types.MultiStore { diff --git a/sei-cosmos/storev2/rootmulti/flatkv_helpers_test.go b/sei-cosmos/storev2/rootmulti/flatkv_helpers_test.go index f963d73d4f..d3dd70a761 100644 --- a/sei-cosmos/storev2/rootmulti/flatkv_helpers_test.go +++ b/sei-cosmos/storev2/rootmulti/flatkv_helpers_test.go @@ -33,16 +33,6 @@ func withTestMemIAVL(cfg seidbconfig.StateCommitConfig) seidbconfig.StateCommitC cfg.MemIAVLConfig.SnapshotInterval = 1 cfg.MemIAVLConfig.SnapshotMinTimeInterval = 0 cfg.MemIAVLConfig.AsyncCommitBuffer = 0 - // Snapshotting every block (interval 1) combined with the default - // keep-recent of 1 would prune all but the two newest snapshots. FlatKV, - // which mirrors this cadence, needs a retained snapshot at-or-below the - // rollback target to reconstruct that version, so aggressive pruning would - // make the rollback/recovery tests unable to target older versions. In - // production (interval 10000) a small rollback always lands within a - // retained interval; here we retain all snapshots across the small test - // version ranges to model that guarantee. Tests that specifically exercise - // pruning override this explicitly. - cfg.MemIAVLConfig.SnapshotKeepRecent = 1000 cfg.HistoricalProofRateLimit = 0 cfg.HistoricalProofMaxInFlight = 100 return cfg @@ -355,12 +345,6 @@ func openFlatKVReadOnly(t *testing.T, dir string, cfg seidbconfig.StateCommitCon require.NoError(t, err) ro, err := store.LoadVersion(version, true) require.NoError(t, err) - // Close the parent immediately: the returned read-only view owns an - // independent context and resources, so it must survive the parent's - // teardown. This doubles as a regression guard — if the clone is ever - // re-rooted at the parent's context, cold reads on ro (those that miss the - // warm cache and hit the async pebble loader) will fail with "context - // canceled". require.NoError(t, store.Close()) return ro } @@ -483,9 +467,9 @@ func addFlatKVNodeToMap(t *testing.T, out map[string][]byte, node *sctypes.Snaps require.NoErrorf(t, err, "deserialize CodeData for key %x", node.Key) out[string(bytes.Clone(innerKey))] = bytes.Clone(cd.GetBytecode()) - case keys.EVMKeyMisc: - ld, err := vtype.DeserializeMiscData(node.Value) - require.NoErrorf(t, err, "deserialize MiscData for key %x", node.Key) + case keys.EVMKeyLegacy: + ld, err := vtype.DeserializeLegacyData(node.Value) + require.NoErrorf(t, err, "deserialize LegacyData for key %x", node.Key) out[string(bytes.Clone(innerKey))] = bytes.Clone(ld.GetValue()) default: diff --git a/sei-cosmos/storev2/rootmulti/hash_consistency_test.go b/sei-cosmos/storev2/rootmulti/hash_consistency_test.go index 9af9ec8d13..7ab5b95064 100644 --- a/sei-cosmos/storev2/rootmulti/hash_consistency_test.go +++ b/sei-cosmos/storev2/rootmulti/hash_consistency_test.go @@ -17,10 +17,6 @@ func TestCommitAndHistoricalQueryHashConsistency(t *testing.T) { ssConfig := seidbconfig.StateStoreConfig{} store := NewStore(t.TempDir(), scConfig, ssConfig, nil) - // Close the store so the hash-logger background goroutine stops writing to - // the temp dir before t.TempDir()'s RemoveAll runs; otherwise the leaked - // goroutine races the cleanup ("directory not empty" on data/hash.log). - defer func() { _ = store.Close() }() keys := []string{"acc", "bank", "distribution", "staking", "ibc", "upgrade"} storeKeys := make(map[string]*types.KVStoreKey) @@ -106,10 +102,6 @@ func TestCommitAndHistoricalQueryWithDoubleFlush(t *testing.T) { ssConfig := seidbconfig.StateStoreConfig{} store := NewStore(t.TempDir(), scConfig, ssConfig, nil) - // Close the store so the hash-logger background goroutine stops writing to - // the temp dir before t.TempDir()'s RemoveAll runs; otherwise the leaked - // goroutine races the cleanup ("directory not empty" on data/hash.log). - defer func() { _ = store.Close() }() keys := []string{"acc", "bank", "distribution", "staking", "ibc", "upgrade"} storeKeys := make(map[string]*types.KVStoreKey) diff --git a/sei-cosmos/storev2/rootmulti/store.go b/sei-cosmos/storev2/rootmulti/store.go index e8165b1bf7..3d9e5b79e3 100644 --- a/sei-cosmos/storev2/rootmulti/store.go +++ b/sei-cosmos/storev2/rootmulti/store.go @@ -531,15 +531,9 @@ func (rs *Store) SetWriteMode(mode sctypes.WriteMode) error { rs.mtx.Lock() defer rs.mtx.Unlock() - prev, hadPrev := rs.GetWriteMode() - if err := rs.scStore.SetWriteMode(mode); err != nil { return fmt.Errorf("failed to set SC store write mode: %w", err) } - - if hadPrev && prev != mode { - logger.Info("SC store write mode changed", "from", prev, "to", mode) - } return nil } @@ -733,7 +727,7 @@ func (rs *Store) SetMigrationBatchSize(batchSize int) error { return nil } if configured != sctypes.Auto { - logger.Error( + logger.Info( "migration requested (batch size > 0) but the SC write mode is pinned to fixed "+ "memiavl_only by configuration; skipping migration kick-off. This node opts out of "+ "the migration and will intentionally diverge from the network at the activation "+ @@ -744,7 +738,6 @@ func (rs *Store) SetMigrationBatchSize(batchSize int) error { if err := rs.SetWriteMode(sctypes.MigrateEVM); err != nil { return fmt.Errorf("failed to start EVM migration (memiavl_only -> migrate_evm): %w", err) } - return nil } diff --git a/sei-cosmos/telemetry/metrics_test.go b/sei-cosmos/telemetry/metrics_test.go index e88b09387f..45104d7ab6 100644 --- a/sei-cosmos/telemetry/metrics_test.go +++ b/sei-cosmos/telemetry/metrics_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "strings" "testing" + "time" metrics "github.com/armon/go-metrics" "github.com/prometheus/common/expfmt" @@ -25,23 +26,18 @@ func TestMetrics_InMem(t *testing.T) { require.NoError(t, err) require.NotNil(t, m) - emitMetrics(10) + emitMetrics() gr, err := m.Gather(FormatText) require.NoError(t, err) require.Equal(t, gr.ContentType, "application/json") - var jsonMetrics struct { - Counters []struct { - Name string - Count int - } - } + jsonMetrics := make(map[string]interface{}) require.NoError(t, json.Unmarshal(gr.Metrics, &jsonMetrics)) - require.Len(t, jsonMetrics.Counters, 1) - require.Equal(t, "test.dummy_counter", jsonMetrics.Counters[0].Name) - require.Equal(t, 10, jsonMetrics.Counters[0].Count) + counters := jsonMetrics["Counters"].([]interface{}) + require.Equal(t, counters[0].(map[string]interface{})["Count"].(float64), 10.0) + require.Equal(t, counters[0].(map[string]interface{})["Name"].(string), "test.dummy_counter") } func TestMetrics_Prom(t *testing.T) { @@ -56,7 +52,7 @@ func TestMetrics_Prom(t *testing.T) { require.NotNil(t, m) require.True(t, m.prometheusEnabled) - emitMetrics(30) + emitMetrics() gr, err := m.Gather(FormatPrometheus) require.NoError(t, err) @@ -65,8 +61,16 @@ func TestMetrics_Prom(t *testing.T) { require.True(t, strings.Contains(string(gr.Metrics), "test_dummy_counter 30")) } -func emitMetrics(count int) { - for range count { - metrics.IncrCounter([]string{"dummy_counter"}, 1.0) +func emitMetrics() { + ticker := time.NewTicker(time.Second) + timeout := time.After(30 * time.Second) + + for { + select { + case <-ticker.C: + metrics.IncrCounter([]string{"dummy_counter"}, 1.0) + case <-timeout: + return + } } } diff --git a/sei-cosmos/testutil/network/util.go b/sei-cosmos/testutil/network/util.go index 9a4f6f385b..71c589ae63 100644 --- a/sei-cosmos/testutil/network/util.go +++ b/sei-cosmos/testutil/network/util.go @@ -57,6 +57,7 @@ func startInProcess(cfg Config, val *Validator) error { app, defaultGensis, []trace.TracerProviderOption{}, + node.NoOpMetricsProvider(), types.DefaultConsensusPolicy(), ) diff --git a/sei-cosmos/types/coin.go b/sei-cosmos/types/coin.go index 8a5edcb595..af54d6e53e 100644 --- a/sei-cosmos/types/coin.go +++ b/sei-cosmos/types/coin.go @@ -358,30 +358,16 @@ func (coins Coins) safeAdd(coinsB Coins) Coins { // DenomsSubsetOf returns true if receiver's denom set // is subset of coinsB's denoms. -// -// Both coin sets — the receiver and coinsB — are assumed to be sorted ascending -// by denomination and free of zero amounts (the Coins invariant enforced by -// Validate/IsValid). The receiver must be sorted too: the merge walk only -// advances forward through coinsB, so an unsorted receiver can produce a false -// negative. Under the sorted invariant a denom is "present" iff it appears, so -// this merge walk can run in O(len(coins) + len(coinsB)). func (coins Coins) DenomsSubsetOf(coinsB Coins) bool { - // more denoms in receiver than in B => cannot be a subset + // more denoms in B than in receiver if len(coins) > len(coinsB) { return false } - indexB := 0 for _, coin := range coins { - // advance B until it reaches or passes coin's denom - for indexB < len(coinsB) && coinsB[indexB].Denom < coin.Denom { - indexB++ - } - if indexB == len(coinsB) || coinsB[indexB].Denom != coin.Denom { + if coinsB.AmountOf(coin.Denom).IsZero() { return false } - // matched; both lists are duplicate-free so B never re-matches this denom - indexB++ } return true diff --git a/sei-cosmos/types/coin_test.go b/sei-cosmos/types/coin_test.go index da0ff5be9f..f1e55b41c3 100644 --- a/sei-cosmos/types/coin_test.go +++ b/sei-cosmos/types/coin_test.go @@ -932,56 +932,6 @@ func (s *coinTestSuite) TestCoinsIsAllGTE() { s.Require().False(sdk.Coins{{"xxx", one}, {"yyy", one}}.IsAllGTE(sdk.Coins{{testDenom2, one}, {"ccc", one}, {"yyy", one}, {"zzz", one}})) } -func (s *coinTestSuite) TestCoinsDenomsSubsetOf() { - one := sdk.OneInt() - two := sdk.NewInt(2) - - // empty receiver is a subset of anything (including empty) - s.Require().True(sdk.Coins{}.DenomsSubsetOf(sdk.Coins{})) - s.Require().True(sdk.Coins{}.DenomsSubsetOf(sdk.Coins{{testDenom1, one}})) - - // a larger receiver can never be a subset - s.Require().False(sdk.Coins{{testDenom1, one}}.DenomsSubsetOf(sdk.Coins{})) - - // amounts are irrelevant, only denom membership matters - s.Require().True(sdk.Coins{{testDenom1, one}}.DenomsSubsetOf(sdk.Coins{{testDenom1, one}})) - s.Require().True(sdk.Coins{{testDenom1, two}}.DenomsSubsetOf(sdk.Coins{{testDenom1, one}})) - - // proper subset (receiver denoms all present in B) - s.Require().True(sdk.Coins{{testDenom1, one}}.DenomsSubsetOf(sdk.Coins{{testDenom1, one}, {testDenom2, one}})) - s.Require().True(sdk.Coins{{testDenom1, one}, {testDenom2, one}}.DenomsSubsetOf(sdk.Coins{{testDenom1, one}, {testDenom2, one}})) - s.Require().True(sdk.Coins{{testDenom1, one}, {testDenom3, one}}.DenomsSubsetOf(sdk.Coins{{testDenom1, one}, {testDenom2, one}, {testDenom3, one}})) - - // receiver contains a denom missing from B - s.Require().False(sdk.Coins{{testDenom1, one}}.DenomsSubsetOf(sdk.Coins{{testDenom2, one}})) - s.Require().False(sdk.Coins{{testDenom1, one}, {testDenom2, one}}.DenomsSubsetOf(sdk.Coins{{testDenom1, one}, {testDenom3, one}})) - // same length, one denom differs (guards the merge-walk termination) - s.Require().False(sdk.Coins{{testDenom2, one}, {testDenom3, one}}.DenomsSubsetOf(sdk.Coins{{testDenom1, one}, {testDenom2, one}})) - // receiver denom sorts after everything in B - s.Require().False(sdk.Coins{{"zzz", one}}.DenomsSubsetOf(sdk.Coins{{testDenom1, one}, {testDenom2, one}})) - - // large sorted inputs: subset and non-subset - const n = 5000 - full := make(sdk.Coins, n) - for i := 0; i < n; i++ { - full[i] = sdk.NewCoin(fmt.Sprintf("coin%08d", i), one) - } - s.Require().NoError(full.Validate(), "generated coins must satisfy the sorted/no-dup invariant") - - half := make(sdk.Coins, 0, n/2) - for i := 0; i < n; i += 2 { - half = append(half, full[i]) - } - s.Require().True(half.DenomsSubsetOf(full)) - - // flip one denom to something not in full => no longer a subset - notSubset := make(sdk.Coins, len(half)) - copy(notSubset, half) - notSubset[len(notSubset)-1] = sdk.NewCoin("zzz99999999", one) - s.Require().True(notSubset.IsValid()) - s.Require().False(notSubset.DenomsSubsetOf(full)) -} - func (s *coinTestSuite) TestNewCoins() { tenatom := sdk.NewInt64Coin("atom", 10) tenbtc := sdk.NewInt64Coin("btc", 10) diff --git a/sei-cosmos/types/dec_coin.go b/sei-cosmos/types/dec_coin.go index 1186da8adb..dd40e37b20 100644 --- a/sei-cosmos/types/dec_coin.go +++ b/sei-cosmos/types/dec_coin.go @@ -135,8 +135,7 @@ func (coin DecCoin) String() string { return fmt.Sprintf("%v%v", coin.Amount, coin.Denom) } -// Validate returns an error if the DecCoin has a negative amount, an -// out-of-range amount, or if the denom is invalid. +// Validate returns an error if the DecCoin has a negative amount or if the denom is invalid. func (coin DecCoin) Validate() error { if err := ValidateDenom(coin.Denom); err != nil { return err @@ -144,9 +143,6 @@ func (coin DecCoin) Validate() error { if coin.IsNegative() { return fmt.Errorf("decimal coin %s amount cannot be negative", coin) } - if !coin.Amount.IsInValidRange() { - return fmt.Errorf("decimal coin %s amount is out of range", coin) - } return nil } @@ -531,9 +527,6 @@ func (coins DecCoins) Validate() error { if !coins[0].IsPositive() { return fmt.Errorf("coin %s amount is not positive", coins[0]) } - if !coins[0].Amount.IsInValidRange() { - return fmt.Errorf("coin %s amount is out of range", coins[0]) - } return nil default: // check single coin case @@ -558,9 +551,6 @@ func (coins DecCoins) Validate() error { if !coin.IsPositive() { return fmt.Errorf("coin %s amount is not positive", coin.Denom) } - if !coin.Amount.IsInValidRange() { - return fmt.Errorf("coin %s amount is out of range", coin.Denom) - } // we compare each coin against the last denom lowDenom = coin.Denom diff --git a/sei-cosmos/types/dec_coin_test.go b/sei-cosmos/types/dec_coin_test.go index cd213adac6..ade744d5f7 100644 --- a/sei-cosmos/types/dec_coin_test.go +++ b/sei-cosmos/types/dec_coin_test.go @@ -4,23 +4,11 @@ import ( "strings" "testing" - "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" ) -// TestNewDecCoinConversionRange covers the Coin -> DecCoin conversion boundary: -// a max-magnitude Int coin cannot be scaled to a whole-coin Dec within range, so -// every Coin -> DecCoin constructor must reject it before it can reach state. -// This is the shared conversion used by the community-pool, fee-collector and -// denom-normalization paths. -func TestNewDecCoinConversionRange(t *testing.T) { - require.Panics(t, func() { sdk.NewDecCoin("stake", maxInt()) }, "NewDecCoin must reject max Int") - require.Panics(t, func() { sdk.NewDecCoinFromCoin(sdk.NewCoin("stake", maxInt())) }, "NewDecCoinFromCoin must reject max Int") - require.Panics(t, func() { sdk.NewDecCoinsFromCoins(sdk.NewCoin("stake", maxInt())) }, "NewDecCoinsFromCoins must reject max Int") -} - type decCoinTestSuite struct { suite.Suite } diff --git a/sei-cosmos/types/decimal.go b/sei-cosmos/types/decimal.go index 4b4e1053f1..b36e8ab377 100644 --- a/sei-cosmos/types/decimal.go +++ b/sei-cosmos/types/decimal.go @@ -126,16 +126,10 @@ func NewDecFromInt(i Int) Dec { // NewDecFromIntWithPrec creates a new Dec from Int with decimal place at prec. // CONTRACT: prec <= Precision -// -// Scaling by 10^Precision can take a max-magnitude Int (bitLen 256) beyond -// maxDecBitLen (315), so the result is range-checked to keep the conversion -// within the canonical Dec range. func NewDecFromIntWithPrec(i Int, prec int64) Dec { - result := Dec{ + return Dec{ new(big.Int).Mul(i.BigInt(), precisionMultiplier(prec)), } - result.assertInValidRange() - return result } // create a decimal from an input decimal string. @@ -713,11 +707,6 @@ func (d Dec) Marshal() ([]byte, error) { if d.i == nil { d.i = new(big.Int) } - // Enforce the canonical range on encode so Marshal and Unmarshal accept the - // same set of values. - if !d.IsInValidRange() { - return nil, fmt.Errorf("decimal out of range; bitLen: got %d, max %d", d.i.BitLen(), maxDecBitLen) - } return d.i.MarshalText() } diff --git a/sei-cosmos/types/decimal_internal_test.go b/sei-cosmos/types/decimal_internal_test.go index 9438d968f9..43968d8071 100644 --- a/sei-cosmos/types/decimal_internal_test.go +++ b/sei-cosmos/types/decimal_internal_test.go @@ -21,33 +21,6 @@ func (s *decimalInternalTestSuite) TestPrecisionMultiplier() { s.Require().Equal(0, res.Cmp(exp), "equality was incorrect, res %v, exp %v", res, exp) } -// outOfRangeDec builds a Dec whose backing integer is one bit past maxDecBitLen, -// bypassing the public constructors (which now reject such values). -func outOfRangeDec() Dec { - return Dec{new(big.Int).Lsh(big.NewInt(1), maxDecBitLen+1)} -} - -// Marshal and Unmarshal must accept the same set of values: an out-of-range -// decimal is rejected by both ends of serialization. -func (s *decimalInternalTestSuite) TestMarshalUnmarshalRangeParity() { - d := outOfRangeDec() - s.Require().False(d.IsInValidRange()) - - _, err := d.Marshal() - s.Require().Error(err, "Marshal must reject an out-of-range decimal") - - // MarshalTo funnels non-zero values through Marshal. - _, err = (&d).MarshalTo(make([]byte, 200)) - s.Require().Error(err, "MarshalTo must reject an out-of-range decimal") - - // The same textual value must also be rejected by Unmarshal (the read side - // of the invariant). - text, err := d.i.MarshalText() - s.Require().NoError(err) - var back Dec - s.Require().Error((&back).Unmarshal(text), "Unmarshal must reject an out-of-range decimal") -} - func (s *decimalInternalTestSuite) TestZeroDeserializationJSON() { d := Dec{new(big.Int)} err := cdc.UnmarshalAsJSON([]byte(`"0"`), &d) @@ -56,18 +29,6 @@ func (s *decimalInternalTestSuite) TestZeroDeserializationJSON() { s.Require().NotNil(err) } -// DecCoin(s).Validate must flag an out-of-range amount, complementing the -// constructor-level range checks. -func (s *decimalInternalTestSuite) TestDecCoinValidateRange() { - coin := DecCoin{Denom: "stake", Amount: outOfRangeDec()} - s.Require().Error(coin.Validate(), "DecCoin.Validate must reject an out-of-range amount") - s.Require().Error(DecCoins{coin}.Validate(), "DecCoins.Validate must reject an out-of-range amount") - - // A larger set (exercises the multi-coin branch) is rejected too. - ok := DecCoin{Denom: "aaa", Amount: NewDec(1)} - s.Require().Error(DecCoins{ok, coin}.Validate()) -} - func (s *decimalInternalTestSuite) TestSerializationGocodecJSON() { d := MustNewDecFromStr("0.333") diff --git a/sei-cosmos/types/decimal_test.go b/sei-cosmos/types/decimal_test.go index d02c1be5d9..fe8f77c50a 100644 --- a/sei-cosmos/types/decimal_test.go +++ b/sei-cosmos/types/decimal_test.go @@ -718,28 +718,3 @@ func BenchmarkIsInValidRange(b *testing.B) { }) } } - -// maxInt returns the maximum protocol-valid sdk.Int, 2^256 - 1. -func maxInt() sdk.Int { - return sdk.NewIntFromBigInt(new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))) -} - -// TestIntToDecConversionRange checks the Int -> Dec boundary: an out-of-range -// conversion is rejected, and an in-range value round-trips unchanged. -func TestIntToDecConversionRange(t *testing.T) { - require.Panics(t, func() { sdk.NewDecFromInt(maxInt()) }, "NewDecFromInt must reject max Int") - require.Panics(t, func() { _ = maxInt().ToDec() }, "Int.ToDec must reject max Int") - require.Panics(t, func() { sdk.NewDecFromIntWithPrec(maxInt(), 0) }, "NewDecFromIntWithPrec must reject max Int") - - // A whole-coin amount that stays within range converts and round-trips - // through Marshal/Unmarshal unchanged. - valid := sdk.NewIntFromBigInt(new(big.Int).Lsh(big.NewInt(1), 200)) - d := valid.ToDec() - require.True(t, d.IsInValidRange()) - - bz, err := d.Marshal() - require.NoError(t, err) - var back sdk.Dec - require.NoError(t, (&back).Unmarshal(bz)) - require.True(t, d.Equal(back)) -} diff --git a/sei-cosmos/x/auth/vesting/client/cli/tx.go b/sei-cosmos/x/auth/vesting/client/cli/tx.go index 9ec31a5185..c55422715c 100644 --- a/sei-cosmos/x/auth/vesting/client/cli/tx.go +++ b/sei-cosmos/x/auth/vesting/client/cli/tx.go @@ -22,7 +22,7 @@ const ( func GetTxCmd() *cobra.Command { txCmd := &cobra.Command{ Use: types.ModuleName, - Short: "Vesting transaction subcommands (deprecated)", + Short: "Vesting transaction subcommands", DisableFlagParsing: true, SuggestionsMinimumDistance: 2, RunE: client.ValidateCmd, @@ -46,8 +46,7 @@ account can either be a delayed or continuous vesting account, which is determin by the '--delayed' flag. All vesting accouts created will have their start time set by the committed block's time. The end_time must be provided as a UNIX epoch timestamp. You can also optionally configure the 'admin' field using the flag '--admin {addr}. This admin will be able to perform some administrative actions on the vesting account if set.`, - Deprecated: "the vesting module is deprecated; the chain rejects this message, so new vesting accounts can no longer be created.", - Args: cobra.ExactArgs(3), + Args: cobra.ExactArgs(3), RunE: func(cmd *cobra.Command, args []string) error { clientCtx, err := client.GetClientTxContext(cmd) if err != nil { diff --git a/sei-cosmos/x/auth/vesting/client/testutil/cli_test.go b/sei-cosmos/x/auth/vesting/client/testutil/cli_test.go index ac86f359c6..9126c139f8 100644 --- a/sei-cosmos/x/auth/vesting/client/testutil/cli_test.go +++ b/sei-cosmos/x/auth/vesting/client/testutil/cli_test.go @@ -12,7 +12,7 @@ import ( ) func TestIntegrationTestSuite(t *testing.T) { - cfg := network.DefaultConfig(t) + cfg := network.DefaultConfig() cfg.NumValidators = 1 suite.Run(t, NewIntegrationTestSuite(cfg)) } diff --git a/sei-cosmos/x/auth/vesting/client/testutil/suite.go b/sei-cosmos/x/auth/vesting/client/testutil/suite.go index 963a329eba..08d2d26e96 100644 --- a/sei-cosmos/x/auth/vesting/client/testutil/suite.go +++ b/sei-cosmos/x/auth/vesting/client/testutil/suite.go @@ -1,7 +1,6 @@ package testutil import ( - "bytes" "fmt" "github.com/gogo/protobuf/proto" @@ -12,7 +11,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-cosmos/testutil/network" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" "github.com/sei-protocol/sei-chain/sei-cosmos/x/auth/vesting/client/cli" - "github.com/sei-protocol/sei-chain/sei-cosmos/x/auth/vesting/types" ) type IntegrationTestSuite struct { @@ -49,7 +47,7 @@ func (s *IntegrationTestSuite) TestNewMsgCreateVestingAccountCmd() { expectedCode uint32 respType proto.Message }{ - "create a continuous vesting account is rejected as deprecated": { + "create a continuous vesting account": { args: []string{ sdk.AccAddress("addr2_______________").String(), sdk.NewCoins(sdk.NewCoin(s.cfg.BondDenom, sdk.NewInt(10))).String(), @@ -57,13 +55,13 @@ func (s *IntegrationTestSuite) TestNewMsgCreateVestingAccountCmd() { fmt.Sprintf("--%s=%s", flags.FlagFrom, val.Address), fmt.Sprintf("--%s=true", flags.FlagSkipConfirmation), fmt.Sprintf("--%s=%s", flags.FlagBroadcastMode, flags.BroadcastBlock), - fmt.Sprintf("--%s=%s", flags.FlagFees, sdk.NewCoins(sdk.NewCoin(s.cfg.BondDenom, sdk.NewInt(2000))).String()), + fmt.Sprintf("--%s=%s", flags.FlagFees, sdk.NewCoins(sdk.NewCoin(s.cfg.BondDenom, sdk.NewInt(10))).String()), }, expectErr: false, - expectedCode: types.ErrVestingDeprecated.ABCICode(), + expectedCode: 0, respType: &sdk.TxResponse{}, }, - "create a delayed vesting account is rejected as deprecated": { + "create a delayed vesting account": { args: []string{ sdk.AccAddress("addr3_______________").String(), sdk.NewCoins(sdk.NewCoin(s.cfg.BondDenom, sdk.NewInt(10))).String(), @@ -72,10 +70,10 @@ func (s *IntegrationTestSuite) TestNewMsgCreateVestingAccountCmd() { fmt.Sprintf("--%s=true", cli.FlagDelayed), fmt.Sprintf("--%s=true", flags.FlagSkipConfirmation), fmt.Sprintf("--%s=%s", flags.FlagBroadcastMode, flags.BroadcastBlock), - fmt.Sprintf("--%s=%s", flags.FlagFees, sdk.NewCoins(sdk.NewCoin(s.cfg.BondDenom, sdk.NewInt(2000))).String()), + fmt.Sprintf("--%s=%s", flags.FlagFees, sdk.NewCoins(sdk.NewCoin(s.cfg.BondDenom, sdk.NewInt(10))).String()), }, expectErr: false, - expectedCode: types.ErrVestingDeprecated.ABCICode(), + expectedCode: 0, respType: &sdk.TxResponse{}, }, "invalid address": { @@ -114,6 +112,8 @@ func (s *IntegrationTestSuite) TestNewMsgCreateVestingAccountCmd() { } for name, tc := range testCases { + tc := tc + s.Run(name, func() { clientCtx := val.ClientCtx @@ -122,13 +122,7 @@ func (s *IntegrationTestSuite) TestNewMsgCreateVestingAccountCmd() { s.Require().Error(err) } else { s.Require().NoError(err) - // the mock IO buffer captures cobra's deprecation notice ahead - // of the JSON response; skip past it before unmarshalling - out := bw.Bytes() - if i := bytes.IndexByte(out, '{'); i > 0 { - out = out[i:] - } - s.Require().NoError(clientCtx.Codec.UnmarshalAsJSON(out, tc.respType), bw.String()) + s.Require().NoError(clientCtx.Codec.UnmarshalAsJSON(bw.Bytes(), tc.respType), bw.String()) txResp := tc.respType.(*sdk.TxResponse) s.Require().Equal(tc.expectedCode, txResp.Code) diff --git a/sei-cosmos/x/auth/vesting/handler.go b/sei-cosmos/x/auth/vesting/handler.go index 5b44543b4b..18033be66a 100644 --- a/sei-cosmos/x/auth/vesting/handler.go +++ b/sei-cosmos/x/auth/vesting/handler.go @@ -7,11 +7,9 @@ import ( "github.com/sei-protocol/sei-chain/sei-cosmos/x/auth/vesting/types" ) -// NewHandler returns a handler for x/auth message types. The vesting module is -// deprecated: once the deprecation gate is active, every message is rejected -// with types.ErrVestingDeprecated. -func NewHandler(ak keeper.AccountKeeper, bk types.BankKeeper, uk types.UpgradeKeeper) sdk.Handler { - msgServer := NewMsgServerImpl(ak, bk, uk) +// NewHandler returns a handler for x/auth message types. +func NewHandler(ak keeper.AccountKeeper, bk types.BankKeeper) sdk.Handler { + msgServer := NewMsgServerImpl(ak, bk) return func(ctx sdk.Context, msg sdk.Msg) (*sdk.Result, error) { ctx = ctx.WithEventManager(sdk.NewEventManager()) diff --git a/sei-cosmos/x/auth/vesting/handler_test.go b/sei-cosmos/x/auth/vesting/handler_test.go index 0527da80bd..f0694c8a0f 100644 --- a/sei-cosmos/x/auth/vesting/handler_test.go +++ b/sei-cosmos/x/auth/vesting/handler_test.go @@ -9,7 +9,6 @@ import ( "github.com/stretchr/testify/suite" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" - authtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/auth/types" "github.com/sei-protocol/sei-chain/sei-cosmos/x/auth/vesting" "github.com/sei-protocol/sei-chain/sei-cosmos/x/auth/vesting/types" ) @@ -25,86 +24,15 @@ func (suite *HandlerTestSuite) SetupTest() { checkTx := false app := app.Setup(suite.T(), checkTx, false, false) - suite.handler = vesting.NewHandler(app.AccountKeeper, app.BankKeeper, app.UpgradeKeeper) + suite.handler = vesting.NewHandler(app.AccountKeeper, app.BankKeeper) suite.app = app } -func (suite *HandlerTestSuite) fundedContext(chainID string) (sdk.Context, sdk.AccAddress, sdk.Coins) { - ctx := suite.app.BaseApp.NewContext(false, tmproto.Header{Height: suite.app.LastBlockHeight() + 1, ChainID: chainID}) +func (suite *HandlerTestSuite) TestMsgCreateVestingAccount() { + ctx := suite.app.BaseApp.NewContext(false, tmproto.Header{Height: suite.app.LastBlockHeight() + 1}) balances := sdk.NewCoins(sdk.NewInt64Coin("test", 1000)) - funder := sdk.AccAddress([]byte("addr1_______________")) - acc := suite.app.AccountKeeper.NewAccountWithAddress(ctx, funder) - suite.app.AccountKeeper.SetAccount(ctx, acc) - suite.Require().NoError(apptesting.FundAccount(suite.app.BankKeeper, ctx, funder, balances)) - - return ctx, funder, balances -} - -// TestMsgCreateVestingAccountDeprecated verifies that on chains without -// pre-deprecation history (any chain-id outside the allowlist) the deprecated -// vesting module rejects account creation without mutating any state. -func (suite *HandlerTestSuite) TestMsgCreateVestingAccountDeprecated() { - ctx, addr1, balances := suite.fundedContext("") - addr2 := sdk.AccAddress([]byte("addr2_______________")) - - testCases := []struct { - name string - msg *types.MsgCreateVestingAccount - }{ - { - name: "delayed vesting account", - msg: types.NewMsgCreateVestingAccount(addr1, addr2, sdk.NewCoins(sdk.NewInt64Coin("test", 100)), ctx.BlockTime().Unix()+10000, true, nil), - }, - { - name: "continuous vesting account", - msg: types.NewMsgCreateVestingAccount(addr1, addr2, sdk.NewCoins(sdk.NewInt64Coin("test", 100)), ctx.BlockTime().Unix()+10000, false, nil), - }, - { - name: "delayed vesting account with admin", - msg: types.NewMsgCreateVestingAccount(addr1, addr2, sdk.NewCoins(sdk.NewInt64Coin("test", 100)), ctx.BlockTime().Unix()+10000, true, addr1), - }, - } - - for _, tc := range testCases { - suite.Run(tc.name, func() { - res, err := suite.handler(ctx, tc.msg) - suite.Require().ErrorIs(err, types.ErrVestingDeprecated) - suite.Require().Nil(res) - - // no account is created and no funds move - suite.Require().Nil(suite.app.AccountKeeper.GetAccount(ctx, addr2)) - suite.Require().Equal(balances, suite.app.BankKeeper.GetAllBalances(ctx, addr1)) - }) - } -} - -// TestMsgCreateVestingAccountPostUpgrade verifies that on chains with -// pre-deprecation history the gate activates once the deprecation upgrade has -// executed. -func (suite *HandlerTestSuite) TestMsgCreateVestingAccountPostUpgrade() { - ctx, addr1, balances := suite.fundedContext("pacific-1") - addr2 := sdk.AccAddress([]byte("addr2_______________")) - - suite.app.UpgradeKeeper.SetDone(ctx, vesting.DeprecationUpgradeName) - - msg := types.NewMsgCreateVestingAccount(addr1, addr2, sdk.NewCoins(sdk.NewInt64Coin("test", 100)), ctx.BlockTime().Unix()+10000, true, nil) - res, err := suite.handler(ctx, msg) - suite.Require().ErrorIs(err, types.ErrVestingDeprecated) - suite.Require().Nil(res) - - // no account is created and no funds move - suite.Require().Nil(suite.app.AccountKeeper.GetAccount(ctx, addr2)) - suite.Require().Equal(balances, suite.app.BankKeeper.GetAllBalances(ctx, addr1)) -} - -// TestMsgCreateVestingAccountPreUpgradeHistory verifies that on chains with -// pre-deprecation history the original behavior is preserved below the -// deprecation upgrade height, so replaying historical blocks yields identical -// state. -func (suite *HandlerTestSuite) TestMsgCreateVestingAccountPreUpgradeHistory() { - ctx, addr1, _ := suite.fundedContext("pacific-1") - + addr1 := sdk.AccAddress([]byte("addr1_______________")) addr2 := sdk.AccAddress([]byte("addr2_______________")) addr3 := sdk.AccAddress([]byte("addr3_______________")) addr4 := sdk.AccAddress([]byte("addr4_______________")) @@ -113,7 +41,9 @@ func (suite *HandlerTestSuite) TestMsgCreateVestingAccountPreUpgradeHistory() { addr7 := sdk.AccAddress([]byte("addr7_______________")) addr8 := sdk.AccAddress([]byte("addr8_______________")) - balances := sdk.NewCoins(sdk.NewInt64Coin("test", 1000)) + acc1 := suite.app.AccountKeeper.NewAccountWithAddress(ctx, addr1) + suite.app.AccountKeeper.SetAccount(ctx, acc1) + suite.Require().NoError(apptesting.FundAccount(suite.app.BankKeeper, ctx, addr1, balances)) acc4 := suite.app.AccountKeeper.NewAccountWithAddress(ctx, addr4) suite.app.AccountKeeper.SetAccount(ctx, acc4) suite.Require().NoError(apptesting.FundAccount(suite.app.BankKeeper, ctx, addr4, balances)) @@ -156,6 +86,8 @@ func (suite *HandlerTestSuite) TestMsgCreateVestingAccountPreUpgradeHistory() { } for _, tc := range testCases { + tc := tc + suite.Run(tc.name, func() { res, err := suite.handler(ctx, tc.msg) if tc.expectErr { @@ -183,32 +115,6 @@ func (suite *HandlerTestSuite) TestMsgCreateVestingAccountPreUpgradeHistory() { } } -// TestExistingVestingAccountsStillSupported verifies that vesting accounts -// already in state remain decodable and keep vesting after the module's -// deprecation. -func (suite *HandlerTestSuite) TestExistingVestingAccountsStillSupported() { - ctx := suite.app.BaseApp.NewContext(false, tmproto.Header{Height: suite.app.LastBlockHeight() + 1}) - - addr := sdk.AccAddress([]byte("vestacct____________")) - origVesting := sdk.NewCoins(sdk.NewInt64Coin("test", 100)) - endTime := ctx.BlockTime().Unix() + 10000 - - baseAcc, ok := suite.app.AccountKeeper.NewAccountWithAddress(ctx, addr).(*authtypes.BaseAccount) - suite.Require().True(ok) - - vestingAcc := types.NewDelayedVestingAccountRaw(types.NewBaseVestingAccount(baseAcc, origVesting, endTime, nil)) - suite.app.AccountKeeper.SetAccount(ctx, vestingAcc) - suite.Require().NoError(apptesting.FundAccount(suite.app.BankKeeper, ctx, addr, origVesting)) - - accI := suite.app.AccountKeeper.GetAccount(ctx, addr) - suite.Require().NotNil(accI) - - acc, ok := accI.(*types.DelayedVestingAccount) - suite.Require().True(ok) - suite.Require().Equal(origVesting, acc.GetVestingCoins(ctx.BlockTime())) - suite.Require().True(suite.app.BankKeeper.SpendableCoins(ctx, addr).IsZero()) -} - func TestHandlerTestSuite(t *testing.T) { suite.Run(t, new(HandlerTestSuite)) } diff --git a/sei-cosmos/x/auth/vesting/metrics.go b/sei-cosmos/x/auth/vesting/metrics.go new file mode 100644 index 0000000000..6e7bc0c748 --- /dev/null +++ b/sei-cosmos/x/auth/vesting/metrics.go @@ -0,0 +1,33 @@ +package vesting + +import ( + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/metric" +) + +var ( + meter = otel.Meter("seicosmos_x_auth_vesting") + + vestingMetrics = struct { + newAccount metric.Int64Counter + accountAmount metric.Int64Gauge + }{ + newAccount: must(meter.Int64Counter( + "new_account", + metric.WithDescription("Number of new vesting accounts created"), + metric.WithUnit("{count}"), + )), + accountAmount: must(meter.Int64Gauge( + "account_amount", + metric.WithDescription("Amount funded into the last new vesting account by denomination"), + metric.WithUnit("{utoken}"), + )), + } +) + +func must[V any](v V, err error) V { + if err != nil { + panic(err) + } + return v +} diff --git a/sei-cosmos/x/auth/vesting/module.go b/sei-cosmos/x/auth/vesting/module.go index f0af1bca1b..a9e8dea87f 100644 --- a/sei-cosmos/x/auth/vesting/module.go +++ b/sei-cosmos/x/auth/vesting/module.go @@ -26,14 +26,6 @@ var ( // AppModuleBasic defines the basic application module used by the sub-vesting // module. The module itself contain no special logic or state other than message // handling. -// -// The vesting module is deprecated: once the deprecation gate is active (the -// DeprecationUpgradeName upgrade on chains with pre-deprecation history, -// genesis everywhere else), its message handlers reject all messages, so new -// vesting accounts can no longer be created. The module must remain wired into -// the app so its codec and interface registrations stay in place: they are -// required to decode existing vesting accounts in the auth store and -// historical transactions. type AppModuleBasic struct{} // Name returns the module's name. @@ -91,23 +83,18 @@ func (AppModuleBasic) GetQueryCmd() *cobra.Command { // AppModule extends the AppModuleBasic implementation by implementing the // AppModule interface. -// -// The vesting module is deprecated; see AppModuleBasic. Once the deprecation -// gate is active, all message handlers return types.ErrVestingDeprecated. type AppModule struct { AppModuleBasic accountKeeper keeper.AccountKeeper bankKeeper types.BankKeeper - upgradeKeeper types.UpgradeKeeper } -func NewAppModule(ak keeper.AccountKeeper, bk types.BankKeeper, uk types.UpgradeKeeper) AppModule { +func NewAppModule(ak keeper.AccountKeeper, bk types.BankKeeper) AppModule { return AppModule{ AppModuleBasic: AppModuleBasic{}, accountKeeper: ak, bankKeeper: bk, - upgradeKeeper: uk, } } @@ -116,7 +103,7 @@ func (AppModule) RegisterInvariants(_ sdk.InvariantRegistry) {} // Route returns the module's message router and handler. func (am AppModule) Route() sdk.Route { - return sdk.NewRoute(types.RouterKey, NewHandler(am.accountKeeper, am.bankKeeper, am.upgradeKeeper)) + return sdk.NewRoute(types.RouterKey, NewHandler(am.accountKeeper, am.bankKeeper)) } // QuerierRoute returns an empty string as the module contains no query @@ -125,7 +112,7 @@ func (AppModule) QuerierRoute() string { return "" } // RegisterServices registers module services. func (am AppModule) RegisterServices(cfg module.Configurator) { - types.RegisterMsgServer(cfg.MsgServer(), NewMsgServerImpl(am.accountKeeper, am.bankKeeper, am.upgradeKeeper)) + types.RegisterMsgServer(cfg.MsgServer(), NewMsgServerImpl(am.accountKeeper, am.bankKeeper)) } // LegacyQuerierHandler performs a no-op. diff --git a/sei-cosmos/x/auth/vesting/msg_server.go b/sei-cosmos/x/auth/vesting/msg_server.go index 09393d1cb5..1bd1994d27 100644 --- a/sei-cosmos/x/auth/vesting/msg_server.go +++ b/sei-cosmos/x/auth/vesting/msg_server.go @@ -3,76 +3,34 @@ package vesting import ( "context" + authtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/auth/types" + + "github.com/armon/go-metrics" + + "github.com/sei-protocol/sei-chain/sei-cosmos/telemetry" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" sdkerrors "github.com/sei-protocol/sei-chain/sei-cosmos/types/errors" "github.com/sei-protocol/sei-chain/sei-cosmos/x/auth/keeper" - authtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/auth/types" "github.com/sei-protocol/sei-chain/sei-cosmos/x/auth/vesting/types" + "go.opentelemetry.io/otel/attribute" + otelmetric "go.opentelemetry.io/otel/metric" ) -// DeprecationUpgradeName is the upgrade plan that activates the vesting -// module's deprecation on chains with pre-deprecation history. It must match -// the plan name of the release that ships the deprecation; if this change -// slips to a later release, bump this constant to that release's name. -const DeprecationUpgradeName = "v6.7" - -// chainsWithVestingHistory are the public chains whose history may contain -// successful MsgCreateVestingAccount transactions. On these chains the -// deprecation activates at the height the DeprecationUpgradeName upgrade -// executes, so replaying historical blocks produces identical state, gas, and -// app hashes. On every other chain (fresh networks, tests) the deprecation is -// active from genesis. -var chainsWithVestingHistory = map[string]struct{}{ - "pacific-1": {}, // mainnet - "atlantic-2": {}, // testnet - "arctic-1": {}, // devnet -} - type msgServer struct { keeper.AccountKeeper types.BankKeeper - upgradeKeeper types.UpgradeKeeper } -// NewMsgServerImpl returns an implementation of the vesting MsgServer -// interface, wrapping the corresponding AccountKeeper and BankKeeper. -// -// The vesting module is deprecated: once the deprecation gate is active (see -// creationDeprecated), every handler rejects its message with -// types.ErrVestingDeprecated. Existing vesting accounts in state remain fully -// supported. -func NewMsgServerImpl(k keeper.AccountKeeper, bk types.BankKeeper, uk types.UpgradeKeeper) types.MsgServer { - return &msgServer{AccountKeeper: k, BankKeeper: bk, upgradeKeeper: uk} +// NewMsgServerImpl returns an implementation of the vesting MsgServer interface, +// wrapping the corresponding AccountKeeper and BankKeeper. +func NewMsgServerImpl(k keeper.AccountKeeper, bk types.BankKeeper) types.MsgServer { + return &msgServer{AccountKeeper: k, BankKeeper: bk} } var _ types.MsgServer = msgServer{} -// creationDeprecated reports whether vesting account creation is disabled at -// the current block. Chains with pre-deprecation history keep the original -// behavior below the deprecation upgrade height so historical replay is -// unchanged; all other chains reject immediately. -func (s msgServer) creationDeprecated(ctx sdk.Context) bool { - if _, ok := chainsWithVestingHistory[ctx.ChainID()]; !ok { - return true - } - // The done-height lookup must not consume gas: the pre-deprecation handler - // performed no store reads before its first bank check, so charging gas - // here would alter gas usage of historical transactions during replay. - gasFreeCtx := ctx.WithGasMeter(sdk.NewInfiniteGasMeter(1, 1)) - return s.upgradeKeeper.IsUpgradeActiveAtHeight(gasFreeCtx, DeprecationUpgradeName, ctx.BlockHeight()) -} - -// CreateVestingAccount is deprecated and returns types.ErrVestingDeprecated -// once the deprecation gate is active; the original behavior is preserved -// below the gate so chains with pre-deprecation history replay identically. -// Existing vesting accounts remain in state and continue to vest according to -// their schedules regardless of the gate. func (s msgServer) CreateVestingAccount(goCtx context.Context, msg *types.MsgCreateVestingAccount) (*types.MsgCreateVestingAccountResponse, error) { ctx := sdk.UnwrapSDKContext(goCtx) - if s.creationDeprecated(ctx) { - return nil, types.ErrVestingDeprecated - } - ak := s.AccountKeeper bk := s.BankKeeper @@ -125,6 +83,24 @@ func (s msgServer) CreateVestingAccount(goCtx context.Context, msg *types.MsgCre ak.SetAccount(ctx, acc) + defer func() { + vestingMetrics.newAccount.Add(goCtx, 1) + // TODO(PLT-353): remove once vesting_new_account verified + telemetry.IncrCounter(1, "new", "account") + + for _, a := range msg.Amount { + if a.Amount.IsInt64() { + vestingMetrics.accountAmount.Record(goCtx, a.Amount.Int64(), otelmetric.WithAttributes(attribute.String("denom_class", telemetry.DenomClass(a.Denom)))) + // TODO(PLT-353): remove once vesting_account_amount verified + telemetry.SetGaugeWithLabels( + []string{"tx", "msg", "create_vesting_account"}, + float32(a.Amount.Int64()), + []metrics.Label{telemetry.NewLabel("denom", a.Denom)}, + ) + } + } + }() + err = bk.SendCoins(ctx, from, to, msg.Amount) if err != nil { return nil, err diff --git a/sei-cosmos/x/auth/vesting/types/errors.go b/sei-cosmos/x/auth/vesting/types/errors.go deleted file mode 100644 index 16635a6ab5..0000000000 --- a/sei-cosmos/x/auth/vesting/types/errors.go +++ /dev/null @@ -1,11 +0,0 @@ -package types - -import ( - sdkerrors "github.com/sei-protocol/sei-chain/sei-cosmos/types/errors" -) - -// ErrVestingDeprecated is returned by every vesting message handler now that -// the module is deprecated. Existing vesting accounts remain in state and -// continue to vest according to their schedules; only the creation of new -// vesting accounts is disabled. -var ErrVestingDeprecated = sdkerrors.Register(ModuleName, 2, "vesting module is deprecated; creating new vesting accounts is disabled") diff --git a/sei-cosmos/x/auth/vesting/types/expected_keepers.go b/sei-cosmos/x/auth/vesting/types/expected_keepers.go index c3bfe37ace..ee77cd747d 100644 --- a/sei-cosmos/x/auth/vesting/types/expected_keepers.go +++ b/sei-cosmos/x/auth/vesting/types/expected_keepers.go @@ -11,9 +11,3 @@ type BankKeeper interface { SendCoins(ctx sdk.Context, fromAddr sdk.AccAddress, toAddr sdk.AccAddress, amt sdk.Coins) error BlockedAddr(addr sdk.AccAddress) bool } - -// UpgradeKeeper defines the expected interface contract the vesting module -// requires for checking whether the deprecation upgrade has executed. -type UpgradeKeeper interface { - IsUpgradeActiveAtHeight(ctx sdk.Context, name string, height int64) bool -} diff --git a/sei-cosmos/x/bank/keeper/keeper.go b/sei-cosmos/x/bank/keeper/keeper.go index 63b69461b2..eff2b7b1ca 100644 --- a/sei-cosmos/x/bank/keeper/keeper.go +++ b/sei-cosmos/x/bank/keeper/keeper.go @@ -586,7 +586,7 @@ func (k BaseKeeper) createCoins(ctx sdk.Context, moduleName string, amounts sdk. k.SetSupply(ctx, supply) } - logger.Debug("minted coins from module account", "amount", amounts, "from", moduleName) + logger.Info("minted coins from module account", "amount", amounts, "from", moduleName) // emit mint event ctx.EventManager().EmitEvent( diff --git a/sei-cosmos/x/distribution/keeper/hooks.go b/sei-cosmos/x/distribution/keeper/hooks.go index ee00bc6f63..7b5e3d44f4 100644 --- a/sei-cosmos/x/distribution/keeper/hooks.go +++ b/sei-cosmos/x/distribution/keeper/hooks.go @@ -46,25 +46,8 @@ func (h Hooks) AfterValidatorRemoved(ctx sdk.Context, _ sdk.ConsAddress, valAddr accAddr := sdk.AccAddress(valAddr) withdrawAddr := h.k.GetDelegatorWithdrawAddr(ctx, accAddr) - // GetDelegatorWithdrawAddr falls back to the delegator (accAddr) when the - // configured withdraw address cannot receive funds, but that fallback can - // itself be unable to receive — e.g. accAddr is an EVM address whose Sei - // mapping was re-associated to a different address, so CanAddressReceive - // rejects it. This hook runs in EndBlock, so attempting the send and - // panicking on the resulting bank error would halt the chain. Check - // receivability first: when the recipient cannot receive, route the - // commission to the community pool instead. The coins already back the - // distribution module account (where community pool funds are held), so this - // conserves value and avoids the partial module-account debit that a failed - // SendCoins leaves behind. - if h.k.canReceiveWithdrawAddr(ctx, withdrawAddr) { - if err := h.k.bankKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleName, withdrawAddr, coins); err != nil { - panic(err) - } - } else { - feePool := h.k.GetFeePool(ctx) - feePool.CommunityPool = feePool.CommunityPool.Add(sdk.NewDecCoinsFromCoins(coins...)...) - h.k.SetFeePool(ctx, feePool) + if err := h.k.bankKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleName, withdrawAddr, coins); err != nil { + panic(err) } } } diff --git a/sei-cosmos/x/distribution/keeper/keeper_test.go b/sei-cosmos/x/distribution/keeper/keeper_test.go index 88637bae86..ad0dc3a002 100644 --- a/sei-cosmos/x/distribution/keeper/keeper_test.go +++ b/sei-cosmos/x/distribution/keeper/keeper_test.go @@ -1,7 +1,6 @@ package keeper_test import ( - "math/big" "testing" "github.com/ethereum/go-ethereum/common" @@ -83,61 +82,6 @@ func TestAfterValidatorRemovedFallsBackForInvalidWithdrawAddress(t *testing.T) { require.Equal(t, balanceBefore.Amount.Add(sdk.NewInt(10)), balanceAfter.Amount) } -// TestAfterValidatorRemovedRoutesToCommunityPoolForUnreceivableValidator covers the -// case where the validator operator address itself cannot receive funds — its EVM -// address was re-associated (e.g. via associatePubKey) away from the direct-cast Sei -// address it was created under. The withdraw-address fallback in GetDelegatorWithdrawAddr -// resolves back to that same unreceivable operator address, so the commission -// force-withdraw fails. AfterValidatorRemoved runs during EndBlock, so it must not panic: -// the commission is routed to the community pool instead, which conserves value because -// the coins already back the distribution module account. -func TestAfterValidatorRemovedRoutesToCommunityPoolForUnreceivableValidator(t *testing.T) { - app := seiapp.Setup(t, false, false, false) - ctx := app.BaseApp.NewContext(false, tmproto.Header{}) - - // The validator operator is the direct-cast Sei address of an EVM address. - evmAddr := common.HexToAddress("0x3333333333333333333333333333333333333333") - castAddr := sdk.AccAddress(evmAddr[:]) - valAddr := sdk.ValAddress(castAddr) - valAccAddr := sdk.AccAddress(valAddr) // == castAddr - - require.True(t, app.BankKeeper.CanSendTo(ctx, castAddr)) - - // Re-associate the EVM address to a different true Sei address, mirroring - // associatePubKey after a validator was created under the direct-cast address. - associatedAddr := seiapp.AddTestAddrs(app, ctx, 1, sdk.NewInt(1000000000))[0] - app.EvmKeeper.SetAddressMapping(ctx, associatedAddr, evmAddr) - - // The operator/delegator address can no longer receive funds, and the - // withdraw-address fallback resolves back to that same unreceivable address. - require.False(t, app.BankKeeper.CanSendTo(ctx, castAddr)) - require.Equal(t, valAccAddr.String(), app.DistrKeeper.GetDelegatorWithdrawAddr(ctx, valAccAddr).String()) - - commission := sdk.DecCoins{sdk.NewDecCoin("usei", sdk.NewInt(10))} - coins := sdk.NewCoins(sdk.NewCoin("usei", sdk.NewInt(10))) - distrAcc := app.DistrKeeper.GetDistributionAccount(ctx) - require.NoError(t, apptesting.FundModuleAccount(app.BankKeeper, ctx, distrAcc.GetName(), coins)) - app.AccountKeeper.SetModuleAccount(ctx, distrAcc) - - app.DistrKeeper.SetValidatorOutstandingRewards(ctx, valAddr, types.ValidatorOutstandingRewards{Rewards: commission}) - app.DistrKeeper.SetValidatorAccumulatedCommission(ctx, valAddr, types.ValidatorAccumulatedCommission{Commission: commission}) - - communityBefore := app.DistrKeeper.GetFeePool(ctx).CommunityPool.AmountOf("usei") - moduleBalanceBefore := app.BankKeeper.GetBalance(ctx, distrAcc.GetAddress(), "usei") - - require.NotPanics(t, func() { - app.DistrKeeper.Hooks().AfterValidatorRemoved(ctx, sdk.ConsAddress{}, valAddr) - }) - - // The commission could not be paid out, so it stays in the distribution module - // account and is accounted to the community pool. No value leaves the module and - // the unreceivable operator address receives nothing. - communityAfter := app.DistrKeeper.GetFeePool(ctx).CommunityPool.AmountOf("usei") - require.Equal(t, communityBefore.Add(sdk.NewDec(10)), communityAfter) - require.True(t, app.BankKeeper.GetBalance(ctx, castAddr, "usei").IsZero()) - require.Equal(t, moduleBalanceBefore, app.BankKeeper.GetBalance(ctx, distrAcc.GetAddress(), "usei")) -} - func TestWithdrawValidatorCommission(t *testing.T) { app := seiapp.Setup(t, false, false, false) ctx := app.BaseApp.NewContext(false, tmproto.Header{}) @@ -229,26 +173,3 @@ func TestFundCommunityPool(t *testing.T) { assert.Equal(t, initPool.CommunityPool.Add(sdk.NewDecCoinsFromCoins(amount...)...), app.DistrKeeper.GetFeePool(ctx).CommunityPool) assert.Empty(t, app.BankKeeper.GetAllBalances(ctx, addr[0])) } - -// TestFundCommunityPoolRejectsOutOfRangeAmount verifies that funding the -// community pool with an amount too large to convert to a whole-coin Dec is -// rejected at the conversion boundary and leaves the stored fee pool -// unchanged. -func TestFundCommunityPoolRejectsOutOfRangeAmount(t *testing.T) { - app := seiapp.Setup(t, false, false, false) - ctx := app.BaseApp.NewContext(false, tmproto.Header{}) - - addr := seiapp.AddTestAddrs(app, ctx, 1, sdk.ZeroInt()) - - maxAmt := sdk.NewIntFromBigInt(new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))) - coins := sdk.NewCoins(sdk.NewCoin("bigcoin", maxAmt)) - require.NoError(t, apptesting.FundAccount(app.BankKeeper, ctx, addr[0], coins)) - - require.Panics(t, func() { - _ = app.DistrKeeper.FundCommunityPool(ctx, coins, addr[0]) - }, "funding the community pool with an out-of-range amount must be rejected") - - // The stored fee pool must be unchanged after the rejected attempt. - require.NotPanics(t, func() { app.DistrKeeper.GetFeePool(ctx) }) - require.True(t, app.DistrKeeper.GetFeePool(ctx).CommunityPool.IsZero()) -} diff --git a/sei-cosmos/x/feegrant/basic_fee.go b/sei-cosmos/x/feegrant/basic_fee.go index 854ced6437..3df5d264e6 100644 --- a/sei-cosmos/x/feegrant/basic_fee.go +++ b/sei-cosmos/x/feegrant/basic_fee.go @@ -7,10 +7,6 @@ import ( var _ FeeAllowanceI = (*BasicAllowance)(nil) -// MaxAllowanceDenoms bounds the number of coins allowed in a single allowance -// spend-limit list. -const MaxAllowanceDenoms = 100 - // Accept can use fee payment requested as well as timestamp of the current block // to determine whether or not to process this. This is checked in // Keeper.UseGrantedFees and the return values should match how it is handled there. @@ -42,9 +38,6 @@ func (a *BasicAllowance) Accept(ctx sdk.Context, fee sdk.Coins, _ []sdk.Msg) (bo // ValidateBasic implements FeeAllowance and enforces basic sanity checks func (a BasicAllowance) ValidateBasic() error { if a.SpendLimit != nil { - if len(a.SpendLimit) > MaxAllowanceDenoms { - return sdkerrors.Wrapf(ErrTooManyDenoms, "spend limit has %d denoms, max %d", len(a.SpendLimit), MaxAllowanceDenoms) - } if !a.SpendLimit.IsValid() { return sdkerrors.Wrapf(sdkerrors.ErrInvalidCoins, "send amount is invalid: %s", a.SpendLimit) } diff --git a/sei-cosmos/x/feegrant/basic_fee_test.go b/sei-cosmos/x/feegrant/basic_fee_test.go index 4b6d546b41..5a8ead554b 100644 --- a/sei-cosmos/x/feegrant/basic_fee_test.go +++ b/sei-cosmos/x/feegrant/basic_fee_test.go @@ -1,7 +1,6 @@ package feegrant_test import ( - "fmt" "testing" "time" @@ -149,62 +148,3 @@ func TestBasicFeeValidAllow(t *testing.T) { }) } } - -// sortedCoins builds a valid, sorted, duplicate-free Coins list of n denoms -// (zero-padded so lexical order matches numeric order). -func sortedCoins(n int) sdk.Coins { - coins := make(sdk.Coins, n) - for i := 0; i < n; i++ { - coins[i] = sdk.NewInt64Coin(fmt.Sprintf("coin%08d", i), 1) - } - return coins.Sort() -} - -func TestBasicAllowanceMaxDenoms(t *testing.T) { - atCap := &feegrant.BasicAllowance{SpendLimit: sortedCoins(feegrant.MaxAllowanceDenoms)} - require.NoError(t, atCap.ValidateBasic()) - - overCap := &feegrant.BasicAllowance{SpendLimit: sortedCoins(feegrant.MaxAllowanceDenoms + 1)} - err := overCap.ValidateBasic() - require.Error(t, err) - require.ErrorIs(t, err, feegrant.ErrTooManyDenoms) -} - -func TestPeriodicAllowanceMaxDenoms(t *testing.T) { - atCap := sortedCoins(feegrant.MaxAllowanceDenoms) - over := sortedCoins(feegrant.MaxAllowanceDenoms + 1) - - // Each case keeps the other allowance fields under cap so that a single - // over-cap field is the only thing that can trip ErrTooManyDenoms. This - // isolates the periodic-specific checks; if they only oversized every field - // at once, the embedded Basic.ValidateBasic() would short-circuit and the - // periodic checks would never be exercised. - cases := map[string]*feegrant.PeriodicAllowance{ - "basic spend limit over cap": { - Basic: feegrant.BasicAllowance{SpendLimit: over}, - PeriodSpendLimit: atCap, - PeriodCanSpend: atCap, - Period: time.Hour, - }, - "period spend limit over cap": { - Basic: feegrant.BasicAllowance{SpendLimit: atCap}, - PeriodSpendLimit: over, - PeriodCanSpend: atCap, - Period: time.Hour, - }, - "period can spend over cap": { - Basic: feegrant.BasicAllowance{SpendLimit: atCap}, - PeriodSpendLimit: atCap, - PeriodCanSpend: over, - Period: time.Hour, - }, - } - - for name, allowance := range cases { - t.Run(name, func(t *testing.T) { - err := allowance.ValidateBasic() - require.Error(t, err) - require.ErrorIs(t, err, feegrant.ErrTooManyDenoms) - }) - } -} diff --git a/sei-cosmos/x/feegrant/errors.go b/sei-cosmos/x/feegrant/errors.go index ce19e425f2..cb79f07f3a 100644 --- a/sei-cosmos/x/feegrant/errors.go +++ b/sei-cosmos/x/feegrant/errors.go @@ -22,6 +22,4 @@ var ( ErrNoMessages = sdkerrors.Register(DefaultCodespace, 6, "allowed messages are empty") // ErrMessageNotAllowed error if message is not allowed ErrMessageNotAllowed = sdkerrors.Register(DefaultCodespace, 7, "message not allowed") - // ErrTooManyDenoms error if an allowance coin list exceeds MaxAllowanceDenoms - ErrTooManyDenoms = sdkerrors.Register(DefaultCodespace, 8, "too many denominations in allowance") ) diff --git a/sei-cosmos/x/feegrant/periodic_fee.go b/sei-cosmos/x/feegrant/periodic_fee.go index 3dc146d1f8..637a89e4b2 100644 --- a/sei-cosmos/x/feegrant/periodic_fee.go +++ b/sei-cosmos/x/feegrant/periodic_fee.go @@ -79,18 +79,12 @@ func (a PeriodicAllowance) ValidateBasic() error { return err } - if len(a.PeriodSpendLimit) > MaxAllowanceDenoms { - return sdkerrors.Wrapf(ErrTooManyDenoms, "period spend limit has %d denoms, max %d", len(a.PeriodSpendLimit), MaxAllowanceDenoms) - } if !a.PeriodSpendLimit.IsValid() { return sdkerrors.Wrapf(sdkerrors.ErrInvalidCoins, "spend amount is invalid: %s", a.PeriodSpendLimit) } if !a.PeriodSpendLimit.IsAllPositive() { return sdkerrors.Wrap(sdkerrors.ErrInvalidCoins, "spend limit must be positive") } - if len(a.PeriodCanSpend) > MaxAllowanceDenoms { - return sdkerrors.Wrapf(ErrTooManyDenoms, "period can spend has %d denoms, max %d", len(a.PeriodCanSpend), MaxAllowanceDenoms) - } if !a.PeriodCanSpend.IsValid() { return sdkerrors.Wrapf(sdkerrors.ErrInvalidCoins, "can spend amount is invalid: %s", a.PeriodCanSpend) } diff --git a/sei-db/common/keys/evm.go b/sei-db/common/keys/evm.go index 660dcca8a0..9571dd765f 100644 --- a/sei-db/common/keys/evm.go +++ b/sei-db/common/keys/evm.go @@ -40,13 +40,13 @@ const ( EVMKeyCodeHash // Stripped key: 20-byte address EVMKeyCode // Stripped key: 20-byte address EVMKeyStorage // Stripped key: addr||slot (20+32 bytes) - EVMKeyMisc // Full original key preserved (address mappings, codesize, etc.) + EVMKeyLegacy // Full original key preserved (address mappings, codesize, etc.) ) // ParseEVMKey parses an EVM key from the x/evm store keyspace. // // For optimized keys (nonce, code, codehash, storage), keyBytes is the stripped key. -// For misc keys (all other EVM data including codesize), keyBytes is the full original key. +// For legacy keys (all other EVM data including codesize), keyBytes is the full original key. // Only returns EVMKeyEmpty for zero-length keys. func ParseEVMKey(key []byte) (kind EVMKeyKind, keyBytes []byte) { if len(key) == 0 { @@ -56,35 +56,35 @@ func ParseEVMKey(key []byte) (kind EVMKeyKind, keyBytes []byte) { switch { case bytes.HasPrefix(key, nonceKeyPrefix): if len(key) != len(nonceKeyPrefix)+AddressLen { - return EVMKeyMisc, key // Malformed but still EVM data + return EVMKeyLegacy, key // Malformed but still EVM data } return EVMKeyNonce, key[len(nonceKeyPrefix):] case bytes.HasPrefix(key, codeHashKeyPrefix): if len(key) != len(codeHashKeyPrefix)+AddressLen { - return EVMKeyMisc, key + return EVMKeyLegacy, key } return EVMKeyCodeHash, key[len(codeHashKeyPrefix):] case bytes.HasPrefix(key, codeKeyPrefix): if len(key) != len(codeKeyPrefix)+AddressLen { - return EVMKeyMisc, key + return EVMKeyLegacy, key } return EVMKeyCode, key[len(codeKeyPrefix):] case bytes.HasPrefix(key, stateKeyPrefix): if len(key) != len(stateKeyPrefix)+AddressLen+slotLen { - return EVMKeyMisc, key + return EVMKeyLegacy, key } return EVMKeyStorage, key[len(stateKeyPrefix):] } - // All other EVM keys go to the misc store (address mappings, codesize, etc.) - return EVMKeyMisc, key + // All other EVM keys go to legacy store (address mappings, codesize, etc.) + return EVMKeyLegacy, key } // EVMKeyPrefixByte returns the single-byte on-disk prefix for a given key kind. -// Returns (0, false) for kinds that have no fixed prefix (e.g. EVMKeyMisc). +// Returns (0, false) for kinds that have no fixed prefix (e.g. EVMKeyLegacy). func EVMKeyPrefixByte(kind EVMKeyKind) (byte, bool) { switch kind { case EVMKeyStorage: diff --git a/sei-db/common/keys/evm_test.go b/sei-db/common/keys/evm_test.go index 9f011e8efe..ee38ae3d93 100644 --- a/sei-db/common/keys/evm_test.go +++ b/sei-db/common/keys/evm_test.go @@ -68,19 +68,19 @@ func TestParseEVMKey(t *testing.T) { { name: "EVMAddressToSeiAddress goes to Legacy", key: concat(testEVMAddrToSeiPrefix, addr), - wantKind: EVMKeyMisc, + wantKind: EVMKeyLegacy, wantBytes: concat(testEVMAddrToSeiPrefix, addr), // Full key preserved }, { name: "SeiAddressToEVMAddress goes to Legacy", key: concat(testSeiAddrToEVMPrefix, addr), - wantKind: EVMKeyMisc, + wantKind: EVMKeyLegacy, wantBytes: concat(testSeiAddrToEVMPrefix, addr), // Full key preserved }, { name: "UnknownPrefix goes to Legacy", key: []byte{0xFF, 0xAA}, - wantKind: EVMKeyMisc, + wantKind: EVMKeyLegacy, wantBytes: []byte{0xFF, 0xAA}, // Full key preserved }, { @@ -92,31 +92,31 @@ func TestParseEVMKey(t *testing.T) { { name: "NonceTooShort goes to Legacy", key: nonceKeyPrefix, - wantKind: EVMKeyMisc, + wantKind: EVMKeyLegacy, wantBytes: nonceKeyPrefix, }, { name: "NonceWrongLenShort goes to Legacy", key: concat(nonceKeyPrefix, addr[:AddressLen-1]), - wantKind: EVMKeyMisc, + wantKind: EVMKeyLegacy, wantBytes: concat(nonceKeyPrefix, addr[:AddressLen-1]), }, { name: "NonceWrongLenLong goes to Legacy", key: concat(nonceKeyPrefix, concat(addr, []byte{0x00})), - wantKind: EVMKeyMisc, + wantKind: EVMKeyLegacy, wantBytes: concat(nonceKeyPrefix, concat(addr, []byte{0x00})), }, { name: "StorageTooShort goes to Legacy", key: concat(stateKeyPrefix, addr), - wantKind: EVMKeyMisc, + wantKind: EVMKeyLegacy, wantBytes: concat(stateKeyPrefix, addr), }, { name: "StorageWrongLenLong goes to Legacy", key: concat(concat(concat(stateKeyPrefix, addr), slot), []byte{0x00}), - wantKind: EVMKeyMisc, + wantKind: EVMKeyLegacy, wantBytes: concat(concat(concat(stateKeyPrefix, addr), slot), []byte{0x00}), }, } diff --git a/sei-db/config/toml.go b/sei-db/config/toml.go index d7e782bb89..42d2e8480f 100644 --- a/sei-db/config/toml.go +++ b/sei-db/config/toml.go @@ -26,8 +26,8 @@ sc-historical-proof-burst = {{ .StateCommit.HistoricalProofBurst }} # performance, setting to 0 means synchronous commit. sc-async-commit-buffer = {{ .StateCommit.MemIAVLConfig.AsyncCommitBuffer }} -# KeepRecent defines how many state-commit snapshots (besides the latest one) to keep. -# Defaults to 1: a configured value of 0 is overridden to 1. +# KeepRecent defines how many state-commit snapshots (besides the latest one) to keep +# defaults to 0 to only keep one current snapshot sc-keep-recent = {{ .StateCommit.MemIAVLConfig.SnapshotKeepRecent }} # SnapshotInterval defines the block interval the snapshot is taken, default to 10000 blocks. @@ -93,7 +93,26 @@ sc-hash-logger-max-disk-size = {{ .StateCommit.HashLogger.MaxDiskSize }} ############################################################################### [state-commit.flatkv] -# FlatKV runs with in-code defaults for now; no options are exposed here yet. +# Fsync controls whether PebbleDB writes (data DBs + metadataDB) use fsync. +# WAL always uses NoSync (matching memiavl); crash recovery relies on +# WAL catchup, which is idempotent. Default: false. +fsync = {{ .StateCommit.FlatKVConfig.Fsync }} + +# AsyncWriteBuffer defines the size of the async write buffer for data DBs. +# Set <= 0 for synchronous writes. +async-write-buffer = {{ .StateCommit.FlatKVConfig.AsyncWriteBuffer }} + +# SnapshotInterval defines how often (in blocks) a PebbleDB checkpoint is taken. +# 0 disables auto-snapshots. Default: 10000. +snapshot-interval = {{ .StateCommit.FlatKVConfig.SnapshotInterval }} + +# SnapshotKeepRecent defines how many old snapshots to keep besides the latest one. +# 0 = keep only the current snapshot. Default: 2. +snapshot-keep-recent = {{ .StateCommit.FlatKVConfig.SnapshotKeepRecent }} + +# EnableReadWriteMetrics emits estimated read/write counters for FlatKV's Pebble DBs. +# Default: false. +enable-read-write-metrics = {{ .StateCommit.FlatKVConfig.EnableReadWriteMetrics }} ` // StateStoreConfigTemplate defines the configuration template for state-store diff --git a/sei-db/config/toml_test.go b/sei-db/config/toml_test.go index deee57a2ea..83fe11cf33 100644 --- a/sei-db/config/toml_test.go +++ b/sei-db/config/toml_test.go @@ -49,15 +49,8 @@ func TestStateCommitConfigTemplate(t *testing.T) { require.Contains(t, output, "sc-historical-proof-max-inflight = 1", "Missing or incorrect sc-historical-proof-max-inflight") require.Contains(t, output, "sc-historical-proof-rate-limit = 1", "Missing or incorrect sc-historical-proof-rate-limit") require.Contains(t, output, "sc-historical-proof-burst = 1", "Missing or incorrect sc-historical-proof-burst") - - // The FlatKV section header is kept, but no FlatKV configs are exposed yet. require.Contains(t, output, "[state-commit.flatkv]", "Missing FlatKV section") - flatKVSection := output[strings.Index(output, "[state-commit.flatkv]"):] - require.NotContains(t, flatKVSection, "fsync", "FlatKV fsync should not be exposed in app.toml") - require.NotContains(t, flatKVSection, "async-write-buffer", "FlatKV async-write-buffer should not be exposed in app.toml") - require.NotContains(t, flatKVSection, "snapshot-interval", "FlatKV snapshot-interval should not be exposed in app.toml") - require.NotContains(t, flatKVSection, "snapshot-keep-recent", "FlatKV snapshot-keep-recent should not be exposed in app.toml") - require.NotContains(t, flatKVSection, "enable-read-write-metrics", "FlatKV read/write metrics flag should not be exposed in app.toml") + require.Contains(t, output, "enable-read-write-metrics = false", "Missing FlatKV read/write metrics flag") // sc-snapshot-writer-limit is intentionally removed from template (hardcoded to 4) // but old configs with this field still parse fine via mapstructure diff --git a/sei-db/db_engine/litt/README.md b/sei-db/db_engine/litt/README.md index 6337f06ac7..ea0b99efaf 100644 --- a/sei-db/db_engine/litt/README.md +++ b/sei-db/db_engine/litt/README.md @@ -44,7 +44,7 @@ The following features are currently supported by LittDB: - [tables](#table) with non-overlapping namespaces - multi-drive support (data can be spread across multiple physical volumes) - incremental backups (both local and remote) -- keys up to 64 KiB (2^16 - 1 bytes) and values up to 2^32 - 1 bytes (~4 GiB) in size +- keys and values up to 2^32 bytes in size - incremental snapshots - incremental remote backups @@ -133,8 +133,7 @@ type Table interface { } ``` -Both primary keys and secondary keys must not exceed 64 KiB (2^16 - 1 bytes). Values may be up to 2^32 - 1 bytes -(`math.MaxUint32`, ~4 GiB); larger values are rejected. +Both primary keys and secondary keys must not exceed 64 KiB (2^16 - 1 bytes). Values may be up to 2^32 bytes. Source: [put_request.go](types/put_request.go) @@ -380,14 +379,14 @@ time. Segments are only deleted when all data contained within them has [expired Segments have a target data size. When a segment is full, that segment is made immutable, and a new segment is created and added to the end of the list. -An [address](#address) stores the offset of a value's first byte within its segment value file using 32 bits, so every -value (and every secondary key, which points at a sub-range of a value) must begin below the 2^32 byte mark. A value is -always written whole within a single value file: before writing a value whose bytes would cross 2^32, the segment is -rolled and a fresh one is started, so the value lands entirely within the new file. Consequently a value file never -exceeds 2^32 bytes and no value is ever split across the boundary. +Note that the maximum size of a segment file is not a hard limit. As long as the first byte of a [value](#value) is +written to a segment file before the segment is full, the segment is permitted to hold it. An [address](#address) +points to that first byte of a value. Since there are 32 bits in an [address](#address) used to store the offset +within the file, the maximum offset for the first byte of a value is 2^32 bytes (4GB). -This bounds the maximum size of a single [value](#value) to 2^32 - 1 bytes (`math.MaxUint32`, ~4 GiB); larger values are -rejected. (Before secondary keys were introduced, values could span past this boundary; that is no longer supported.) +A natural side effect of only requiring the first byte of a [value](#value) to be written before the segment is full is +that LittDB can support arbitrarily large [values](#value). Doing so may result in a large amount of data in a single +segment, but this does not violate any correctness invariants. Each segment may split its data into multiple [shards](#shard). The number of shards in a segment is called the [sharding factor](#sharding-factor). The [sharding factor](#sharding-factor) is configurable, and different segments diff --git a/sei-db/db_engine/litt/benchmark/config/benchmark_config.go b/sei-db/db_engine/litt/benchmark/config/benchmark_config.go index 056a9232c5..df2e6bf91f 100644 --- a/sei-db/db_engine/litt/benchmark/config/benchmark_config.go +++ b/sei-db/db_engine/litt/benchmark/config/benchmark_config.go @@ -92,9 +92,6 @@ func DefaultBenchmarkConfig() *BenchmarkConfig { littConfig := litt.DefaultConfigNoPaths() littConfig.MetricsEnabled = true - // The standalone benchmark has no embedding application to configure and serve the global OTel - // MeterProvider, so LittDB must stand up its own Prometheus exporter and /metrics server. - littConfig.MetricsServeEndpoint = true return &BenchmarkConfig{ LittConfig: littConfig, diff --git a/sei-db/db_engine/litt/disktable/control_loop.go b/sei-db/db_engine/litt/disktable/control_loop.go index 506b6cfe91..754304e557 100644 --- a/sei-db/db_engine/litt/disktable/control_loop.go +++ b/sei-db/db_engine/litt/disktable/control_loop.go @@ -3,7 +3,6 @@ package disktable import ( "fmt" "log/slog" - "math" "sync" "sync/atomic" "time" @@ -64,19 +63,6 @@ type controlLoop struct { // The target size for key files. targetKeyFileSize uint64 - // shardControlChannelSize is the capacity of each new segment's per-shard write channels (and, scaled by - // the sharding factor, its key-file channel). Passed to segment.CreateSegment when a segment rolls over. - shardControlChannelSize int - - // autoFlushByteThreshold is the number of value bytes written through the control loop (without an - // intervening flush) that triggers an automatic fire-and-forget flush, bounding the in-memory - // unflushed-data cache. Set once at construction from Config.AutoFlushByteThreshold; never mutated. - autoFlushByteThreshold uint64 - - // bytesSinceLastFlush accumulates value bytes written since the last flush (explicit or automatic). - // Only the control loop goroutine reads or writes it, so it needs no synchronization. - bytesSinceLastFlush uint64 - // The size of the disk table is stored here. size *atomic.Uint64 @@ -472,17 +458,6 @@ func (c *controlLoop) handleWriteRequest(req *controlLoopWriteRequest) { // Do the write. seg := c.segments[c.highestSegmentIndex] - // Roll to a fresh segment before writing if this value's bytes would cross the 2^32 addressable - // limit of the segment's value files (offsets are stored as uint32, so a value's first byte must - // sit below 2^32). - if seg.GetMaxShardSize()+uint64(len(kv.Value)) > math.MaxUint32 { - if err := c.expandSegments(); err != nil { - c.errorMonitor.Panic(fmt.Errorf("failed to expand segments: %w", err)) - return - } - seg = c.segments[c.highestSegmentIndex] - } - // Track boundary keys. The newest primary key is simply the most recently written key. The // mutable segment's first primary key is recorded the first time a key is written to a fresh // mutable segment (it is reset to nil whenever a new mutable segment is created). @@ -508,13 +483,6 @@ func (c *controlLoop) handleWriteRequest(req *controlLoopWriteRequest) { return } } - - // Bound the in-memory unflushed-data cache: once enough bytes have been written without an - // intervening flush, schedule a fire-and-forget flush so the cache drains as keys become durable. - c.bytesSinceLastFlush += uint64(len(kv.Value)) - if c.bytesSinceLastFlush >= c.autoFlushByteThreshold { - c.scheduleAutoFlush() - } } c.updateCurrentSize() @@ -564,8 +532,7 @@ func (c *controlLoop) expandSegments() error { c.segmentPaths, c.snapshottingEnabled, c.diskTable.getShardingFactor(), - c.fsync, - c.shardControlChannelSize) + c.fsync) if err != nil { return err } @@ -607,34 +574,6 @@ func (c *controlLoop) handleFlushRequest(req *controlLoopFlushRequest) { if err != nil { c.logger.Error("failed to send flush request to flush loop", "error", err) } - - // An explicit flush drains the unflushed-data cache, so restart the auto-flush accounting. - c.bytesSinceLastFlush = 0 -} - -// scheduleAutoFlush schedules a fire-and-forget flush of the mutable segment to bound the in-memory -// unflushed-data cache. It is triggered from the write path once autoFlushByteThreshold bytes have been -// written without an intervening flush. Unlike handleFlushRequest there is no waiting caller, so the -// request carries a nil responseChan (the flush loop skips the completion signal but still schedules the -// keymap write that drains the cache). Called only on the control loop goroutine. -func (c *controlLoop) scheduleAutoFlush() { - flushWaitFunction, err := c.segments[c.highestSegmentIndex].Flush() - if err != nil { - c.errorMonitor.Panic(fmt.Errorf("failed to flush segment %d: %w", c.highestSegmentIndex, err)) - return - } - - request := &flushLoopFlushRequest{ - flushWaitFunction: flushWaitFunction, - responseChan: nil, - } - err = c.flushLoop.enqueue(request) - if err != nil { - c.logger.Error("failed to send auto-flush request to flush loop", "error", err) - return - } - - c.bytesSinceLastFlush = 0 } // handleControlLoopSetShardingFactorRequest updates the sharding factor of the disk table. If the requested diff --git a/sei-db/db_engine/litt/disktable/disk_table.go b/sei-db/db_engine/litt/disktable/disk_table.go index 0f7432625f..b9ea340ab9 100644 --- a/sei-db/db_engine/litt/disktable/disk_table.go +++ b/sei-db/db_engine/litt/disktable/disk_table.go @@ -24,6 +24,8 @@ var _ litt.ManagedTable = (*DiskTable)(nil) // keymapReloadBatchSize is the size of the batch used for reloading keys from segments into the keymap. const keymapReloadBatchSize = 1024 +const tableFlushChannelCapacity = 8 + // DiskTable manages a table's Segments. type DiskTable struct { // The logger for the disk table. @@ -206,8 +208,7 @@ func NewDiskTable( segmentPaths, snapshottingEnabled, table.getShardingFactor(), - config.Fsync, - config.ShardControlChannelSize) + config.Fsync) if err != nil { return nil, fmt.Errorf("failed to create mutable segment: %w", err) } @@ -331,7 +332,6 @@ func NewDiskTable( name, config.KeymapManagerChannelSize, config.KeymapManagerMaxBatchSize, - config.KeymapManagerMaxBatchBytes, config.GCBatchSize, config.KeymapManagerMaxInterval, config.KeymapManagerMaxBufferedDeletes, @@ -342,7 +342,7 @@ func NewDiskTable( logger: runtimeConfig.Logger, keymapManager: kManager, errorMonitor: errorMonitor, - flushChannel: make(chan any, config.FlushChannelSize), + flushChannel: make(chan any, tableFlushChannelCapacity), metrics: metrics, clock: runtimeConfig.Clock, name: name, @@ -362,8 +362,6 @@ func NewDiskTable( targetFileSize: config.TargetSegmentFileSize, targetKeyFileSize: config.TargetSegmentKeyFileSize, maxKeyCount: config.MaxSegmentKeyCount, - autoFlushByteThreshold: config.AutoFlushByteThreshold, - shardControlChannelSize: config.ShardControlChannelSize, clock: runtimeConfig.Clock, segmentPaths: segmentPaths, snapshottingEnabled: snapshottingEnabled, @@ -974,7 +972,7 @@ func (d *DiskTable) PutBatch(batch []*types.PutRequest) error { return fmt.Errorf("key is too large, length must not exceed 2^16 bytes: %d bytes", len(kv.Key)) } if len(kv.Value) > math.MaxUint32 { - return fmt.Errorf("value is too large, length must not exceed 2^32 - 1 bytes: %d bytes", len(kv.Value)) + return fmt.Errorf("value is too large, length must not exceed 2^32 bytes: %d bytes", len(kv.Value)) } // Validate every secondary key in this request, and detect duplicate keys (primary vs diff --git a/sei-db/db_engine/litt/disktable/flush_loop.go b/sei-db/db_engine/litt/disktable/flush_loop.go index 0dd8a8099d..4ceb48afda 100644 --- a/sei-db/db_engine/litt/disktable/flush_loop.go +++ b/sei-db/db_engine/litt/disktable/flush_loop.go @@ -140,9 +140,5 @@ func (f *flushLoop) handleFlushRequest(req *flushLoopFlushRequest) { return } - // An auto-flush scheduled by the control loop is fire-and-forget and has no waiting caller, so its - // responseChan is nil. The cache-draining work above still runs; only the completion signal is skipped. - if req.responseChan != nil { - req.responseChan <- struct{}{} - } + req.responseChan <- struct{}{} } diff --git a/sei-db/db_engine/litt/disktable/keymap_manager.go b/sei-db/db_engine/litt/disktable/keymap_manager.go index 9f80a99db9..8efdfecaf1 100644 --- a/sei-db/db_engine/litt/disktable/keymap_manager.go +++ b/sei-db/db_engine/litt/disktable/keymap_manager.go @@ -93,10 +93,6 @@ type keymapManager struct { // the maximum number of keys coalesced into a single keymap Put or Delete maxBatchSize int - // the maximum number of value bytes a coalescing put batch may represent before it is applied, - // independent of key count. Bounds the unflushed-data cache when values are large (few keys, many bytes). - maxBatchBytes uint64 - // the maximum number of keys deleted from the keymap in a single keymap.Delete call deleteBatchSize uint64 @@ -113,11 +109,6 @@ type keymapManager struct { // sub-batch, so a put and a same-key delete in flight resolve to deleted (the delete wins). puts []*types.ScopedKey - // pendingPutBytes is the total value bytes represented by the primary keys in puts. Secondary keys are - // zero-copy sub-ranges of their primary's value in the unflushed-data cache, so counting primaries alone - // tracks the cache memory this batch will free when applied. Reset to zero whenever puts is applied. - pendingPutBytes uint64 - // deleteBacklog holds garbage-collected segments' keys awaiting deletion, in arrival (FIFO) order — // oldest segment first. It is drained one sub-batch at a time, interleaved with puts, so a large delete // burst cannot block writes. @@ -153,7 +144,6 @@ func newKeymapManager( name string, channelSize int, maxBatchSize int, - maxBatchBytes uint64, deleteBatchSize uint64, maxFlushInterval time.Duration, maxBufferedDeletes uint64, @@ -168,7 +158,6 @@ func newKeymapManager( name: name, requestChan: make(chan any, channelSize), maxBatchSize: maxBatchSize, - maxBatchBytes: maxBatchBytes, deleteBatchSize: deleteBatchSize, maxFlushInterval: maxFlushInterval, maxBufferedDeletes: maxBufferedDeletes, @@ -226,16 +215,16 @@ func (m *keymapManager) sync() error { // draining all queued work) or when the error monitor signals an immediate shutdown (panic). // // Each cycle coalesces immediately-available work, then either applies a batch or blocks for more. A batch is -// applied once the put batch is full (by key count or by the value bytes it represents) or a delete backlog -// exists: puts are applied first (so a put followed by a delete of the same key resolves to deleted), then a -// single delete sub-batch, which keeps puts flowing while still making steady progress on a delete backlog. +// applied once the put batch is full or a delete backlog exists: puts are applied first (so a put followed by +// a delete of the same key resolves to deleted), then a single delete sub-batch, which keeps puts flowing +// while still making steady progress on a delete backlog. func (m *keymapManager) run() { for { if m.coalesce() { return } - if len(m.puts) >= m.maxBatchSize || m.pendingPutBytes >= m.maxBatchBytes || len(m.deleteBacklog) > 0 { + if len(m.puts) >= m.maxBatchSize || len(m.deleteBacklog) > 0 { // Enough work to apply: puts first (delete wins on a same-key collision), then one delete // sub-batch so the backlog drains without starving puts. if !m.flushPuts() { @@ -269,10 +258,10 @@ func (m *keymapManager) refreshBackpressure() { } // coalesce drains immediately-available requests into the put batch and delete backlog without blocking, -// stopping once the put batch is full (by key count or represented value bytes), the delete backlog reaches -// its high-water mark (backpressure engages), or no request is ready. Returns true if the manager must stop. +// stopping once the put batch is full, the delete backlog reaches its high-water mark (backpressure engages), +// or no request is ready. Returns true if the manager must stop. func (m *keymapManager) coalesce() bool { - for len(m.puts) < m.maxBatchSize && m.pendingPutBytes < m.maxBatchBytes && !m.backpressure { + for len(m.puts) < m.maxBatchSize && !m.backpressure { select { case <-m.errorMonitor.ImmediateShutdownRequired(): return true @@ -308,13 +297,6 @@ func (m *keymapManager) routeRequest(msg any) bool { switch req := msg.(type) { case *keymapWriteRequest: m.puts = append(m.puts, req.keys...) - for _, k := range req.keys { - // Only primaries own value bytes in the unflushed-data cache; secondaries alias a sub-range of - // their primary's value, so counting them too would double-count the cache memory. - if k.Kind.IsPrimary() { - m.pendingPutBytes += uint64(k.Address.ValueSize()) - } - } case *keymapDeleteRequest: m.enqueueDelete(req) case *keymapManagerSyncRequest: @@ -361,7 +343,6 @@ func (m *keymapManager) flushPuts() bool { return false } m.puts = nil - m.pendingPutBytes = 0 } m.stopFlushTimer() return true diff --git a/sei-db/db_engine/litt/disktable/keymap_manager_test.go b/sei-db/db_engine/litt/disktable/keymap_manager_test.go index 0e6d6919fe..d6d8ef51d6 100644 --- a/sei-db/db_engine/litt/disktable/keymap_manager_test.go +++ b/sei-db/db_engine/litt/disktable/keymap_manager_test.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "log/slog" - "math" "sync" "testing" "time" @@ -40,7 +39,6 @@ func buildTestKeymapManager( "test", channelSize, maxBatchSize, - math.MaxUint64, // disable the byte-based batch trigger; these tests exercise key-count/time batching deleteBatchSize, time.Second, maxBufferedDeletes, diff --git a/sei-db/db_engine/litt/disktable/segment/rollback.go b/sei-db/db_engine/litt/disktable/segment/rollback.go deleted file mode 100644 index 1ca98e3e22..0000000000 --- a/sei-db/db_engine/litt/disktable/segment/rollback.go +++ /dev/null @@ -1,101 +0,0 @@ -package segment - -import ( - "fmt" - "os" -) - -// RollbackToKeyCount truncates a sealed segment so that it retains only its first survivingKeyCount key-file -// records (in the order they were written), discarding every key and value written afterwards. -// -// This is a destructive, offline operation. The caller must guarantee that the database is not running and -// that nothing else is touching the segment's files. -// -// survivingKeyCount counts individual key-file records; a primary key and each of its secondary keys count -// separately. It must not exceed the number of records currently in the segment. To keep the segment -// internally consistent, callers should pass a count that lands on a group boundary (a primary plus all of -// its secondaries are either all kept or all discarded); RollbackToKeyCount itself does not enforce this. -// -// The steps are ordered so that an interruption never leaves a torn record: -// 1. the key file is rewritten via an atomic swap (the commit point), skipped when it already holds exactly -// the surviving records, -// 2. each shard's value file is truncated, and -// 3. the segment's key count is recorded in the metadata file. -// -// It is idempotent: re-invoking with the same survivingKeyCount is a no-op on an already-rolled-back segment -// and repairs one whose earlier run was interrupted after step 1 (finishing steps 2 and 3). -// -// The surviving records always occupy a contiguous prefix of the key file and of each shard's value file, -// so the addresses of the kept records are never disturbed. -func (s *Segment) RollbackToKeyCount(survivingKeyCount uint32) error { - if !s.IsSealed() { - return fmt.Errorf("segment %d is not sealed, cannot roll back", s.index) - } - - keys, err := s.keys.readKeys() - if err != nil { - return fmt.Errorf("failed to read keys for segment %d: %w", s.index, err) - } - - if int(survivingKeyCount) > len(keys) { - return fmt.Errorf("surviving key count %d exceeds the %d records in segment %d", - survivingKeyCount, len(keys), s.index) - } - - survivingKeys := keys[:survivingKeyCount] - - // 1. Rewrite the key file to contain only the surviving records. This is skipped when the key file - // already holds exactly those records — either nothing was written after the rollback boundary, or a - // prior run was interrupted right after this step. The atomic rename of the swap file over the original - // key file is the commit point for dropping records; steps 2 and 3 below always run, so a rollback - // interrupted after this swap is still repaired (over-long value files truncated, stale metadata key - // count corrected) when it is re-invoked. - if int(survivingKeyCount) < len(keys) { - var swapFile *keyFile - swapFile, err = createKeyFile(s.logger, s.index, s.keys.segmentPath, true) - if err != nil { - return fmt.Errorf("failed to create swap key file for segment %d: %w", s.index, err) - } - for _, key := range survivingKeys { - if err = swapFile.write(key); err != nil { - return fmt.Errorf("failed to write key to swap file for segment %d: %w", s.index, err) - } - } - if err = swapFile.seal(); err != nil { - return fmt.Errorf("failed to seal swap key file for segment %d: %w", s.index, err) - } - if err = swapFile.atomicSwap(s.fsync); err != nil { - return fmt.Errorf("failed to swap key file for segment %d: %w", s.index, err) - } - s.keys = swapFile - } - - // 2. Truncate each shard's value file to the end of its last surviving value. Values carry no length - // prefix on disk, so a value occupies exactly [offset, offset+valueSize), and the surviving values form - // a prefix of each shard because values are appended in write order. - shardEnds := make([]uint64, len(s.shards)) - for _, key := range survivingKeys { - shardID := key.Address.ShardID() - if int(shardID) >= len(s.shards) { - return fmt.Errorf("segment %d has a key with shard ID %d outside its sharding factor %d", - s.index, shardID, len(s.shards)) - } - end := uint64(key.Address.Offset()) + uint64(key.Address.ValueSize()) - if end > shardEnds[shardID] { - shardEnds[shardID] = end - } - } - for shardID, valueFile := range s.shards { - if err = os.Truncate(valueFile.path(), int64(shardEnds[shardID])); err != nil { //nolint:gosec // value offsets are bounded to 2^32 - return fmt.Errorf("failed to truncate value file for segment %d shard %d: %w", s.index, shardID, err) - } - } - - // 3. Record the new key count. The seal time is preserved so the segment's TTL/expiry is unaffected. - s.metadata.keyCount = survivingKeyCount - if err = s.metadata.write(); err != nil { - return fmt.Errorf("failed to update metadata for segment %d: %w", s.index, err) - } - - return nil -} diff --git a/sei-db/db_engine/litt/disktable/segment/segment.go b/sei-db/db_engine/litt/disktable/segment/segment.go index 699fce41d0..841a184869 100644 --- a/sei-db/db_engine/litt/disktable/segment/segment.go +++ b/sei-db/db_engine/litt/disktable/segment/segment.go @@ -18,6 +18,9 @@ import ( // that have been written to the segment but have not yet been flushed to disk. const unflushedKeysInitialCapacity = 128 +// shardControlChannelCapacity is the capacity of the channel used to send messages to the shard control loop. +const shardControlChannelCapacity = 32 + // Segment is a chunk of data stored on disk. All data in a particular data segment is expired at the same time. // // This struct is not safe for operations that mutate the segment, access control must be handled by the caller. @@ -109,9 +112,7 @@ func CreateSegment( segmentPaths []*SegmentPath, snapshottingEnabled bool, shardingFactor uint8, - fsync bool, - shardChannelCapacity int, -) (*Segment, error) { + fsync bool) (*Segment, error) { if len(segmentPaths) == 0 { return nil, errors.New("no segment paths provided") @@ -148,14 +149,14 @@ func CreateSegment( shardChannels := make([]chan any, metadata.shardingFactor) for shard := uint8(0); shard < metadata.shardingFactor; shard++ { - shardChannels[shard] = make(chan any, shardChannelCapacity) + shardChannels[shard] = make(chan any, shardControlChannelCapacity) } // If at all possible, we want to size this channel so that the goroutines writing data to the sharded value files // do not block on insertion into this channel. Scale the size of this channel by the number of shards, as more // shards mean there may be a higher rate of writes to this channel. Widen to int before multiplying so that the // product does not wrap at 256 (metadata.shardingFactor is a uint8). - keyFileChannel := make(chan any, shardChannelCapacity*int(metadata.shardingFactor)) + keyFileChannel := make(chan any, int(shardControlChannelCapacity)*int(metadata.shardingFactor)) segment := &Segment{ logger: logger, @@ -488,17 +489,31 @@ func (s *Segment) Write(data *types.PutRequest) (keyCount uint32, keyFileSize ui } currentSize := s.shardSizes[shard] - // Defensive invariant: the control loop rolls to a fresh segment before writing any value whose bytes - // would cross the 2^32 addressable limit (see disktable control_loop handleWriteRequest), so a mutable - // segment never grows to where the next value's first byte would be unaddressable. A whole value is - // always written contiguously below 2^32, which also keeps every secondary key's start - // (firstByteIndex + Offset, a sub-range of the value) addressable. if currentSize > math.MaxUint32 { + // No matter the configuration, we absolutely cannot permit a value to be written if the first byte of the + // value would be beyond position 2^32. This is because we only have 32 bits in an address to store the + // position of a value's first byte. return 0, 0, fmt.Errorf("value file already contains %d bytes, cannot add a new value", currentSize) } firstByteIndex := uint32(currentSize) valueLen := uint64(len(data.Value)) + if uint64(firstByteIndex)+valueLen > math.MaxUint32 { + return 0, 0, + fmt.Errorf("value of length %d would push value file past 2^32 bytes (current size %d)", + valueLen, currentSize) + } + + // Validate every secondary key's address fits in uint32 *before* sending anything, so we never + // produce a partial write. + for _, sk := range data.SecondaryKeys { + end := uint64(firstByteIndex) + uint64(sk.Offset) + uint64(sk.Length) + if end > math.MaxUint32 { + return 0, 0, + fmt.Errorf("secondary key range [%d, %d) would exceed 2^32 byte addressable range", + uint64(firstByteIndex)+uint64(sk.Offset), end) + } + } n := len(data.SecondaryKeys) totalKeys := uint32(1 + n) //nolint:gosec // n bounded by caller validation diff --git a/sei-db/db_engine/litt/disktable/segment/segment_test.go b/sei-db/db_engine/litt/disktable/segment/segment_test.go index c25847e536..580dde06ce 100644 --- a/sei-db/db_engine/litt/disktable/segment/segment_test.go +++ b/sei-db/db_engine/litt/disktable/segment/segment_test.go @@ -59,8 +59,7 @@ func TestWriteAndReadSegmentSingleShard(t *testing.T) { []*SegmentPath{segmentPath}, false, 1, - false, - 32) + false) require.NoError(t, err) @@ -209,8 +208,7 @@ func TestWriteAndReadSegmentMultiShard(t *testing.T) { []*SegmentPath{segmentPath}, false, shardCount, - false, - 32) + false) require.NoError(t, err) @@ -368,8 +366,7 @@ func TestWriteAndReadColdShard(t *testing.T) { []*SegmentPath{segmentPath}, false, shardCount, - false, - 32) + false) require.NoError(t, err) @@ -478,8 +475,7 @@ func TestGetFilePaths(t *testing.T) { []*SegmentPath{segmentPath}, false, shardingFactor, - false, - 32) + false) require.NoError(t, err) files := segment.GetFilePaths() @@ -546,8 +542,7 @@ func TestRoundRobinShardAssignment(t *testing.T) { []*SegmentPath{segmentPath}, false, shardingFactor, - false, - 32) + false) require.NoError(t, err) // Capture the address that the segment assigns to each write, in insertion order. @@ -618,7 +613,6 @@ func newSingleShardSegment(t *testing.T) (*Segment, *SegmentPath, uint32) { false, 1, false, - 32, ) require.NoError(t, err) return seg, segmentPath, index diff --git a/sei-db/db_engine/litt/disktable/segment/shard_id_validation_test.go b/sei-db/db_engine/litt/disktable/segment/shard_id_validation_test.go index b0a29740f5..d948acc994 100644 --- a/sei-db/db_engine/litt/disktable/segment/shard_id_validation_test.go +++ b/sei-db/db_engine/litt/disktable/segment/shard_id_validation_test.go @@ -34,8 +34,7 @@ func TestSegmentReadRejectsOutOfRangeShardID(t *testing.T) { []*SegmentPath{segmentPath}, false, shardingFactor, - false, - 32) + false) require.NoError(t, err) badAddr := types.NewAddress(seg.SegmentIndex(), 0, shardingFactor, 0) diff --git a/sei-db/db_engine/litt/disktable/segment_rollover_test.go b/sei-db/db_engine/litt/disktable/segment_rollover_test.go deleted file mode 100644 index 662c73da7c..0000000000 --- a/sei-db/db_engine/litt/disktable/segment_rollover_test.go +++ /dev/null @@ -1,177 +0,0 @@ -package disktable - -import ( - "bytes" - "fmt" - "log/slog" - "math" - "os" - "path/filepath" - "testing" - - "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt" - "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt/disktable/keymap" - "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt/disktable/segment" - "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt/types" - "github.com/stretchr/testify/require" -) - -// rolloverValueSize is the size of each value written by TestSegmentRollsOverAt2GiBBoundary. It is kept -// small so only a handful are ever resident at once (see the channel sizing in the table builder), while -// 2^32 remains an exact multiple of it, so the addressability boundary still lands cleanly between values. -const rolloverValueSize = 64 * 1024 * 1024 // 64 MiB - -// rolloverValueCount is chosen so the total written (count * 64 MiB = 5 GiB) comfortably exceeds the -// 2^32-byte (4 GiB) single-value-file addressable limit, forcing at least one segment rollover. -const rolloverValueCount = 80 - -// makeRolloverValue deterministically generates a value of rolloverValueSize bytes whose contents depend -// on index, so a mis-read (wrong segment/offset) is detectable without holding every value in memory. -func makeRolloverValue(index int) []byte { - v := make([]byte, rolloverValueSize) - seed := make([]byte, 4096) - for i := range seed { - seed[i] = byte(index*7 + i) - } - for off := 0; off < len(v); off += len(seed) { - copy(v[off:], seed) - } - return v -} - -// buildSingleShardDiskTableDefaultSegmentSize builds a single-shard, mem-keymap disk table using the -// DEFAULT (math.MaxUint32) target segment size, so segments only roll when the addressability limit is -// reached — the behavior under test. fsync is disabled to keep the multi-GiB write fast. -func buildSingleShardDiskTableDefaultSegmentSize(t *testing.T, root string) litt.ManagedTable { - t.Helper() - logger := slog.Default() - - keymapPath := filepath.Join(root, keymap.KeymapDirectoryName) - keymapTypeFile, err := setupKeymapTypeFile(keymapPath, keymap.MemKeymapType) - require.NoError(t, err) - - keys, _, err := keymap.NewMemKeymap(logger, "", true) - require.NoError(t, err) - - config, err := litt.DefaultConfig(root) - require.NoError(t, err) - config.Fsync = false // default TargetSegmentFileSize (math.MaxUint32) is intentionally kept - - // This test writes ~5 GiB but must stay well under 1 GiB resident. Two levers keep it there, and both - // are needed: - // - // 1. Shrink every buffer that can hold in-flight values. A value stays in the unflushed-data cache from - // Put until its key is durable in the keymap, so the peak cache is bounded by how many values can be - // in flight across the write pipeline at once. That pipeline is a chain of bounded channels - // (control loop -> per-shard writer -> flush loop -> keymap manager); sizing them all to 1 means at - // most a handful of 256 MiB values are resident before Put backpressures. The production defaults - // (dozens deep) are sized for 256 GiB machines and would allow many GiB of large values in flight. - // 2. Flush aggressively so the cache actually drains (rather than just capping the in-flight count): - // AutoFlushByteThreshold smaller than one value schedules a flush after every Put, and a small keymap - // batch-byte limit makes the keymap apply (and thus drop cache entries) promptly instead of waiting - // for its 1s timer. - config.ControlChannelSize = 1 - config.FlushChannelSize = 1 - config.ShardControlChannelSize = 1 - config.KeymapManagerChannelSize = 1 - config.AutoFlushByteThreshold = 32 * 1024 * 1024 // < one rolloverValueSize (64 MiB): flush after every Put - config.KeymapManagerMaxBatchBytes = 32 * 1024 * 1024 // apply keymap puts (draining the cache) promptly - - tableConfig := litt.DefaultTableConfig("rollover") - tableConfig.ShardingFactor = 1 // one value file, so 4 GiB of writes crosses the 2^32 boundary - - runtimeConfig := litt.DefaultRuntimeConfig() - runtimeConfig.Logger = logger - - table, err := NewDiskTable( - config, - runtimeConfig, - "rollover", - tableConfig, - keys, - keymapPath, - keymapTypeFile, - []string{root}, - true, - nil) - require.NoError(t, err) - return table -} - -// TestSegmentRollsOverAt2GiBBoundary writes more than 2^32 bytes of values into a single-shard table and -// verifies that the value file never exceeds the 2^32-byte addressable limit: the control loop must roll -// to a new segment before a value would cross it (rather than panicking, the previous behavior). Every -// primary and secondary key must read back correctly across the boundary. -func TestSegmentRollsOverAt2GiBBoundary(t *testing.T) { - root := t.TempDir() - table := buildSingleShardDiskTableDefaultSegmentSize(t, root) - defer func() { require.NoError(t, table.Close()) }() - - const secondaryOffset = uint32(1 * 1024 * 1024) // 1 MiB into the value - const secondaryLength = uint32(64 * 1024) // 64 KiB alias - primaryKey := func(i int) []byte { return []byte(fmt.Sprintf("primary-%03d", i)) } - secondaryKey := func(i int) []byte { return []byte(fmt.Sprintf("secondary-%03d", i)) } - hasSecondary := func(i int) bool { return i%5 == 0 } // a subset carry a secondary sub-range alias - - for i := 0; i < rolloverValueCount; i++ { - value := makeRolloverValue(i) - if hasSecondary(i) { - sk := &types.SecondaryKey{Key: secondaryKey(i), Offset: secondaryOffset, Length: secondaryLength} - require.NoError(t, table.Put(primaryKey(i), value, sk)) - } else { - require.NoError(t, table.Put(primaryKey(i), value)) - } - } - require.NoError(t, table.Flush()) - - // The single shard's value files must have rolled: at least two segments exist, and none exceeds the - // 2^32-byte addressable limit. - valueFileSizes := collectValueFileSizes(t, root) - require.GreaterOrEqual(t, len(valueFileSizes), 2, - "expected the segment to roll over (>=2 value files) after writing >2^32 bytes") - for path, size := range valueFileSizes { - require.LessOrEqualf(t, size, int64(math.MaxUint32), - "value file %s is %d bytes, exceeding the 2^32 addressable limit", path, size) - } - - // Every primary (and secondary) key reads back correctly across the boundary. - for i := 0; i < rolloverValueCount; i++ { - expected := makeRolloverValue(i) - - got, exists, err := table.Get(primaryKey(i)) - require.NoError(t, err) - require.Truef(t, exists, "primary key %d missing after rollover", i) - require.Truef(t, bytes.Equal(expected, got), "primary value %d mismatch after rollover", i) - - if hasSecondary(i) { - gotSecondary, exists, err := table.Get(secondaryKey(i)) - require.NoError(t, err) - require.Truef(t, exists, "secondary key %d missing after rollover", i) - wantSecondary := expected[secondaryOffset : secondaryOffset+secondaryLength] - require.Truef(t, bytes.Equal(wantSecondary, gotSecondary), - "secondary value %d mismatch after rollover", i) - } - } -} - -// collectValueFileSizes walks root for segment value files (*.values) and returns their sizes by path. -func collectValueFileSizes(t *testing.T, root string) map[string]int64 { - t.Helper() - sizes := make(map[string]int64) - err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() || filepath.Ext(path) != segment.ValuesFileExtension { - return nil - } - info, err := d.Info() - if err != nil { - return err - } - sizes[path] = info.Size() - return nil - }) - require.NoError(t, err) - return sizes -} diff --git a/sei-db/db_engine/litt/littbuilder/build_utils.go b/sei-db/db_engine/litt/littbuilder/build_utils.go index 0b8be612c4..4b6bfc872b 100644 --- a/sei-db/db_engine/litt/littbuilder/build_utils.go +++ b/sei-db/db_engine/litt/littbuilder/build_utils.go @@ -237,15 +237,11 @@ func buildTable( } // buildMetrics creates a new metrics object backed by the global OTel -// MeterProvider. It records into whatever MeterProvider is globally configured. -// -// When MetricsServeEndpoint is true, this also configures the global provider -// with a Prometheus exporter and starts an HTTP server on MetricsPort that -// serves /metrics; the returned shutdown function flushes that provider and is -// the responsibility of the caller to invoke during teardown. When -// MetricsServeEndpoint is false (the default), the embedding application is -// assumed to have already configured and served the global provider, so no -// exporter or server is created and the returned shutdown function is nil. +// MeterProvider. When MetricsEnabled is true, this configures the global +// provider with a Prometheus exporter and starts an HTTP server on +// MetricsPort that serves /metrics. The returned shutdown function flushes +// the provider; it is the responsibility of the caller to invoke it during +// teardown. func buildMetrics( config *litt.Config, runtimeConfig *litt.RuntimeConfig, @@ -254,10 +250,6 @@ func buildMetrics( return nil, nil } - if !config.MetricsServeEndpoint { - return metrics.NewLittDBMetrics(), nil - } - reg, shutdown, err := commonmetrics.SetupOtelPrometheus() if err != nil { runtimeConfig.Logger.Error("failed to set up OTel Prometheus exporter", "error", err) diff --git a/sei-db/db_engine/litt/littdb_config.go b/sei-db/db_engine/litt/littdb_config.go index 1168d692cd..69be206ded 100644 --- a/sei-db/db_engine/litt/littdb_config.go +++ b/sei-db/db_engine/litt/littdb_config.go @@ -30,52 +30,43 @@ type Config struct { Paths []string // The type of the keymap. Choices are keymap.MemKeymapType and keymap.PebbleDBKeymapType. + // Default is keymap.PebbleDBKeymapType. KeymapType keymap.KeymapType - // The size of the control channel for the segment manager. + // The size of the control channel for the segment manager. The default is 64. ControlChannelSize int - // The capacity of a table's flush loop channel. The control loop hands each segment flush and seal to - // the flush loop over this channel; when it fills, the control loop blocks, which backpressures writes. - // Each queued flush corresponds to values still resident in the unflushed-data cache, so this depth is - // one component of peak in-flight memory. - FlushChannelSize int - - // The capacity of each segment shard's write channel. seg.Write hands a value to the shard's writer - // goroutine over this channel, and the segment's key-file channel is sized at this times the sharding - // factor. These channels carry the actual value bytes in flight, so their depth bounds how many - // written-but-not-yet-durable values may be resident per shard before a write blocks. - ShardControlChannelSize int - - // The target size for segments. + // The target size for segments. The default is math.MaxUint32. TargetSegmentFileSize uint32 - // The maximum number of keys in a segment. For workloads with moderately large values + // The maximum number of keys in a segment. The default is 50,000. For workloads with moderately large values // (i.e. in the kb+ range), this threshold is unlikely to be relevant. For workloads with very small values, // this constant prevents a segment from accumulating too many keys. A segment with too many keys may have // undesirable properties such as a very large key file and very slow garbage collection (since no kv-pair in // a segment can be deleted until the entire segment is deleted). MaxSegmentKeyCount uint32 - // The desired maximum size for a key file. When a key file exceeds this size, the segment + // The desired maximum size for a key file. The default is 2 MB. When a key file exceeds this size, the segment // will close the current segment and begin writing to a new one. For workloads with moderately large values, // this threshold is unlikely to be relevant. For workloads with very small values, this constant prevents a key // file from growing too large. A key file with too many keys may have undesirable properties such as very slow // garbage collection (since no kv-pair in a segment can be deleted until the entire segment is deleted). TargetSegmentKeyFileSize uint64 - // The period between garbage collection runs. GC is cheap on the control loop + // The period between garbage collection runs. The default is 10 seconds. GC is cheap on the control loop // (keymap deletes happen asynchronously on the keymap manager), so it runs frequently to avoid letting a // backlog of collectable segments build up. GCPeriod time.Duration - // The size of the keymap deletion batch for garbage collection. + // The size of the keymap deletion batch for garbage collection. The default is 10,000. GCBatchSize uint64 // If true, then flush operations will call fsync on the underlying file to ensure data is flushed out of the // operating system's buffer and onto disk. Setting this to false means that even after flushing data, // there may be data loss in the advent of an OS/hardware crash. // + // The default is true. + // // Enabling fsync may have performance implications, although this strongly depends on the workload. For large // batches that are flushed infrequently, benchmark data suggests that the impact is minimal. For small batches // that are flushed frequently, the difference can be severe. For example, when enabled in unit tests that do @@ -85,24 +76,18 @@ type Config struct { // If enabled, the database will return an error if a key is written but that key is already present in // the database. Updating existing keys is illegal and may result in unexpected behavior, and so this check // acts as a safety mechanism against this sort of illegal operation. Unfortunately, if using a keymap other - // than keymap.MemKeymapType, performing this check may be very expensive. + // than keymap.MemKeymapType, performing this check may be very expensive. By default, this is false. DoubleWriteProtection bool - // If enabled, collect DB metrics and record them via the global OTel MeterProvider. - // How the metrics are exported depends on MetricsServeEndpoint. + // If enabled, collect DB metrics and export them via the global OTel MeterProvider. By default, this is false. + // When enabled, the database configures a Prometheus exporter on the global provider and serves /metrics on + // MetricsPort. MetricsEnabled bool - // If true, the database sets up its own Prometheus exporter on the global OTel MeterProvider and serves - // /metrics on MetricsPort. If false, the database records into the already-configured global - // MeterProvider and leaves exporting to the embedding application (which is responsible for calling - // SetupOtelPrometheus and serving the registry). Ignored if MetricsEnabled is false. - MetricsServeEndpoint bool - - // The port to use for the metrics server. Ignored unless both MetricsEnabled and MetricsServeEndpoint are - // true. + // The port to use for the metrics server. Ignored if MetricsEnabled is false. The default is 9101. MetricsPort int - // The interval at which various DB metrics are updated. + // The interval at which various DB metrics are updated. The default is 1 second. MetricsUpdateInterval time.Duration // If empty, snapshotting is disabled. If not empty, then this directory is used by the database to publish a @@ -130,40 +115,28 @@ type Config struct { // performance. If this is set to zero, then no batching is performed and all flushes are executed immediately. MinimumFlushInterval time.Duration - // When this many value bytes are written through a table's control loop without an intervening flush, a - // flush is scheduled automatically. This bounds the in-memory unflushed-data cache (which holds values - // until their keys become durable in the keymap) for callers that write large volumes without flushing. - // The flush is non-blocking (fire-and-forget), so peak resident bytes are approximately this threshold - // plus whatever is written during the asynchronous drain. - AutoFlushByteThreshold uint64 - // The capacity of the buffered channel feeding the asynchronous keymap manager. Keymap puts and deletes are // scheduled (not executed) on the Flush() and GC paths; this bounds how many operations may be queued for the // keymap before backpressure is applied, which in turn bounds how far the keymap may lag behind the segments. + // The default is 1024. KeymapManagerChannelSize int // The maximum number of keys the asynchronous keymap manager coalesces into a single keymap Put or Delete. // Larger values amortize the keymap's per-write fsync across more keys under load; the cap bounds the size - // and latency of any single operation. + // and latency of any single operation. The default is 10000. KeymapManagerMaxBatchSize int - // The maximum number of value bytes a coalescing keymap put batch may represent before it is applied, - // independent of the key count. A key stays in the unflushed-data cache until its keymap put is applied, - // so with large values a put batch can represent gigabytes while holding far fewer than - // KeymapManagerMaxBatchSize keys; this byte bound forces the batch out (draining the cache) well before - // that. Complements KeymapManagerMaxBatchSize (whichever limit is reached first triggers the batch). - KeymapManagerMaxBatchBytes uint64 - // The maximum time the asynchronous keymap manager accumulates scheduled work before applying a partial batch. // The manager prefers to coalesce work into full batches (see KeymapManagerMaxBatchSize), but if a full batch // does not accumulate within this interval it applies whatever it has, bounding how long a key may wait before - // it is written into the keymap. + // it is written into the keymap. The default is 1 second. KeymapManagerMaxInterval time.Duration // The maximum number of garbage-collected keys the keymap manager will buffer awaiting deletion. Deletes are // drained incrementally and always yield to latency-critical puts, so a large garbage-collection burst does // not stall writes; this is the high-water mark at which the manager stops accepting new work (backpressuring - // producers via a full channel) until the backlog drains to half. Bounds the manager's peak memory. + // producers via a full channel) until the backlog drains to half. Bounds the manager's peak memory. The + // default is 1000000. KeymapManagerMaxBufferedDeletes uint64 // The capacity of the channel on which the keymap manager publishes its deletion watermark to the control @@ -172,14 +145,14 @@ type Config struct { // file deletion), but a dropped value is only superseded by a subsequent, higher publish — so a single // pass that collects more than this many segments before the control loop drains may defer reclaiming some // files until a later collection. Sizing this at or above the largest expected single-pass collection keeps - // reclamation complete in one pass (relevant to explicit RunGC). + // reclamation complete in one pass (relevant to explicit RunGC). The default is 1024. KeymapManagerWatermarkChannelSize int // The capacity of the channel over which the control loop hands sealed segments to the GC manager (the GC // manager keeps its own local view of sealed segments rather than reading the control loop's segment map). // A segment is sent the moment it is sealed; the GC manager drains the channel between collection passes, so // this only needs to absorb the seals that occur during a single pass. If it fills, the control loop applies - // brief backpressure to writes until the GC manager drains it. + // brief backpressure to writes until the GC manager drains it. The default is 1024. GCSegmentChannelSize int } @@ -203,26 +176,21 @@ func DefaultConfigNoPaths() *Config { GCBatchSize: 10_000, KeymapType: keymap.PebbleDBKeymapType, ControlChannelSize: 64, - FlushChannelSize: 8, - ShardControlChannelSize: 32, TargetSegmentFileSize: math.MaxUint32, MaxSegmentKeyCount: 50_000, TargetSegmentKeyFileSize: 2 * unit.MB, Fsync: true, DoubleWriteProtection: false, MetricsEnabled: false, - MetricsServeEndpoint: false, MetricsPort: 9101, MetricsUpdateInterval: time.Second, PurgeLocks: false, KeymapManagerChannelSize: 1024, KeymapManagerMaxBatchSize: 10_000, - KeymapManagerMaxBatchBytes: 64 * unit.MB, KeymapManagerMaxInterval: time.Second, KeymapManagerMaxBufferedDeletes: 1_000_000, KeymapManagerWatermarkChannelSize: 1024, GCSegmentChannelSize: 1024, - AutoFlushByteThreshold: 256 * unit.MB, } } @@ -259,12 +227,6 @@ func (c *Config) Validate() error { if c.ControlChannelSize == 0 { return fmt.Errorf("control channel size must be at least 1") } - if c.FlushChannelSize < 1 { - return fmt.Errorf("flush channel size must be at least 1") - } - if c.ShardControlChannelSize < 1 { - return fmt.Errorf("shard control channel size must be at least 1") - } if c.TargetSegmentFileSize == 0 { return fmt.Errorf("target segment file size must be at least 1") } @@ -286,9 +248,6 @@ func (c *Config) Validate() error { if c.KeymapManagerMaxBatchSize < 1 { return fmt.Errorf("keymap write max batch size must be at least 1") } - if c.KeymapManagerMaxBatchBytes < 1 { - return fmt.Errorf("keymap write max batch bytes must be at least 1") - } if c.KeymapManagerMaxInterval <= 0 { return fmt.Errorf("keymap write max interval must be greater than zero") } @@ -301,9 +260,6 @@ func (c *Config) Validate() error { if c.GCSegmentChannelSize < 1 { return fmt.Errorf("gc segment channel size must be at least 1") } - if c.AutoFlushByteThreshold == 0 { - return fmt.Errorf("auto flush byte threshold must be at least 1") - } return nil } diff --git a/sei-db/db_engine/litt/rollback/rollback.go b/sei-db/db_engine/litt/rollback/rollback.go deleted file mode 100644 index 44101bd8c0..0000000000 --- a/sei-db/db_engine/litt/rollback/rollback.go +++ /dev/null @@ -1,320 +0,0 @@ -// Package rollback implements an offline rollback utility for LittDB. It rewinds a database to a chosen -// point by discarding the most recently written keys, and is intended for operational use (for example, -// rolling a node's state back to a specific block height) while the database is not running. -package rollback - -import ( - "context" - "fmt" - "log/slog" - "os" - "path/filepath" - "sort" - "time" - - "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt/disktable" - "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt/disktable/keymap" - "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt/disktable/segment" - "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt/types" - "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt/util" -) - -// RollbackFilter decides where a rollback stops. It is invoked once per key-file record, walking each table -// from the most recently written key to the oldest. tableName identifies the table the record belongs to: -// key schemas differ across tables, so the filter must decode the key in a table-aware way. isPrimary is -// true for primary keys (standalone primaries and primaries that own secondary keys) and false for -// secondary keys. The first record for which the filter returns true is the rollback point: that record's -// group is kept along with everything written before it, and everything written after the group is discarded. -type RollbackFilter func(tableName string, key []byte, isPrimary bool) (bool, error) - -// RollbackLittDB performs an offline rollback of the LittDB instance stored across the given data -// directories (the same paths passed to the database as its storage roots). -// -// For every table found under dataDirs, RollbackLittDB walks that table's key files from newest to oldest -// and invokes rollbackFilter(tableName, key, isPrimary) for each record. The first key for which the filter -// returns true marks the rollback point: that key's group (a primary plus any secondary keys written with -// it) and everything written before it are retained; everything written after the group is permanently -// deleted from the segment files. A table for which the filter never returns true is left unchanged. -// -// The keymap and any snapshot are discarded rather than edited: the database rebuilds both from the -// truncated segment files the next time it starts, which keeps them exactly consistent with the -// rolled-back data (the same approach cli/prune.go uses after an offline mutation). The durable gc-watermark -// is deliberately left in place; rolling a table back below its gc-watermark — into data that garbage -// collection has already reclaimed — is refused, because that state cannot be faithfully reconstructed. -// -// The database must NOT be running while this is called. RollbackLittDB takes the same directory locks the -// database uses, so it will fail rather than corrupt a live database, and it assumes nothing else mutates -// the files while it works. -// -// The operation is idempotent and safe to re-run: re-running with the same filter completes (and repairs) -// a rollback that was interrupted partway through. -func RollbackLittDB(dataDirs []string, rollbackFilter RollbackFilter) error { - logger := slog.Default() - - if len(dataDirs) == 0 { - return fmt.Errorf("no data directories provided") - } - if rollbackFilter == nil { - return fmt.Errorf("rollback filter must not be nil") - } - - roots := make([]string, len(dataDirs)) - for i, dir := range dataDirs { - sanitized, err := util.SanitizePath(dir) - if err != nil { - return fmt.Errorf("invalid data directory %q: %w", dir, err) - } - roots[i] = sanitized - } - - // Refuse to operate on a database that is in active use. The DB holds these same locks while running. - releaseLocks, err := util.LockDirectories(logger, roots, util.LockfileName, true) - if err != nil { - return fmt.Errorf("failed to lock data directories %v: %w", roots, err) - } - defer releaseLocks() - - tables, err := findTables(roots) - if err != nil { - return fmt.Errorf("failed to enumerate tables under %v: %w", roots, err) - } - - for _, table := range tables { - if err := rollbackTable(logger, roots, table, rollbackFilter); err != nil { - return fmt.Errorf("failed to roll back table %q: %w", table, err) - } - } - - return nil -} - -// rollbackPoint identifies where a table's rollback boundary falls: the segment that contains the matched -// key and the number of key-file records to retain in that segment. Everything after that prefix in the -// rollback segment, and every newer segment, is discarded. -type rollbackPoint struct { - segmentIndex uint32 - survivingKeyCount uint32 -} - -// rollbackTable rolls back a single table. -func rollbackTable( - logger *slog.Logger, - roots []string, - tableName string, - rollbackFilter RollbackFilter, -) error { - errorMonitor := util.NewErrorMonitor(context.Background(), logger, nil) - - segmentPaths, err := segment.BuildSegmentPaths(roots, "", tableName) - if err != nil { - return fmt.Errorf("failed to build segment paths: %w", err) - } - - lowestSegmentIndex, highestSegmentIndex, segments, err := segment.GatherSegmentFiles( - logger, errorMonitor, segmentPaths, false /* snapshottingEnabled */, time.Now(), - true /* cleanOrphans */, true /* fsync */) - if err != nil { - return fmt.Errorf("failed to gather segment files: %w", err) - } - if len(segments) == 0 { - logger.Info("table has no segments, nothing to roll back", "table", tableName) - return nil - } - - // Refuse to operate on a symlinked snapshot directory: truncating symlinked value files would corrupt - // the real segment data they point at. Rollback must run against the database's storage roots, not a - // snapshot. (cli/prune.go makes the same check.) - isSnapshot, err := segments[lowestSegmentIndex].IsSnapshot() - if err != nil { - return fmt.Errorf("failed to determine whether table %q is a snapshot: %w", tableName, err) - } - if isSnapshot { - return fmt.Errorf("table %q is a symlinked snapshot; refusing to roll back "+ - "(point the tool at the database's storage roots, not a snapshot)", tableName) - } - - pivot, err := findRollbackPoint(segments, tableName, lowestSegmentIndex, highestSegmentIndex, rollbackFilter) - if err != nil { - return err - } - if pivot == nil { - logger.Warn("no rollback point found, leaving table unchanged", "table", tableName) - return nil - } - - // Refuse to roll back below the durable gc-watermark. Segments below it are logically garbage collected - // (their keys were durably removed from the keymap), so a rollback target there cannot be faithfully - // reconstructed — and it would leave lowestReadableSegment above the highest surviving segment, which the - // database rejects at startup. We must not simply delete the watermark either: keymap reload honors it so - // a rebuild does not resurrect collected keys. - watermark, defined, err := highestGCWatermark(roots, tableName) - if err != nil { - return err - } - if defined && watermark > pivot.segmentIndex { - return fmt.Errorf("cannot roll back table %q to segment %d: it is below the gc-watermark "+ - "(lowest readable segment %d); that data has already been garbage collected", - tableName, pivot.segmentIndex, watermark) - } - - logger.Info("rolling back table", - "table", tableName, - "rollbackSegment", pivot.segmentIndex, - "survivingRecordsInRollbackSegment", pivot.survivingKeyCount, - "deletedSegments", highestSegmentIndex-pivot.segmentIndex, - ) - - // 1. Discard the derived keymap and snapshot first. They are rebuilt from the segment files on the next - // start, so doing this before touching the segments guarantees that however the steps below are - // interrupted, the database rebuilds the keymap from whatever segment state exists rather than trusting - // a keymap that points into truncated or deleted data. - if err = discardDerivedState(roots, tableName); err != nil { - return fmt.Errorf("failed to discard derived state: %w", err) - } - - // 2. Delete whole segments newer than the rollback segment, highest index first so that an interruption - // never leaves a gap in the middle of the segment sequence. - for segmentIndex := highestSegmentIndex; segmentIndex > pivot.segmentIndex; segmentIndex-- { - for _, filePath := range segments[segmentIndex].GetFilePaths() { - if err = util.DeepDelete(filePath); err != nil { - return fmt.Errorf("failed to delete %s: %w", filePath, err) - } - } - } - - // 3. Truncate the rollback segment down to the surviving records. - if err = segments[pivot.segmentIndex].RollbackToKeyCount(pivot.survivingKeyCount); err != nil { - return fmt.Errorf("failed to truncate segment %d: %w", pivot.segmentIndex, err) - } - - return nil -} - -// findRollbackPoint walks the table's key files from the newest record to the oldest, invoking the filter -// on each. The first record for which the filter returns true is the rollback point. The whole group that -// contains that record (a standalone primary, or a primary together with the secondaries that follow it) -// is retained, so the surviving boundary is set to the end of that group. Returns nil if no record matches. -func findRollbackPoint( - segments map[uint32]*segment.Segment, - tableName string, - lowestSegmentIndex uint32, - highestSegmentIndex uint32, - rollbackFilter RollbackFilter, -) (*rollbackPoint, error) { - - for segmentIndex := highestSegmentIndex; ; segmentIndex-- { - keys, err := segments[segmentIndex].GetKeys() - if err != nil { - return nil, fmt.Errorf("failed to read keys from segment %d: %w", segmentIndex, err) - } - - for i := len(keys) - 1; i >= 0; i-- { - match, err := rollbackFilter(tableName, keys[i].Key, keys[i].Kind.IsPrimary()) - if err != nil { - return nil, fmt.Errorf("rollback filter returned an error in segment %d: %w", segmentIndex, err) - } - if match { - groupEnd, err := groupEndIndex(keys, i) - if err != nil { - return nil, fmt.Errorf("segment %d: %w", segmentIndex, err) - } - return &rollbackPoint{ - segmentIndex: segmentIndex, - survivingKeyCount: uint32(groupEnd + 1), //nolint:gosec // bounded by the segment's key count - }, nil - } - } - - if segmentIndex == lowestSegmentIndex { - break - } - } - - return nil, nil -} - -// groupEndIndex returns the index of the last record in the group that contains record i. A group is -// either a single standalone primary, or a primary followed by one or more secondaries terminated by a -// KeyKindFinalSecondary record. -func groupEndIndex(keys []*types.ScopedKey, i int) (int, error) { - if keys[i].Kind == types.KeyKindStandalone { - return i, nil - } - for j := i; j < len(keys); j++ { - if keys[j].Kind == types.KeyKindFinalSecondary { - return j, nil - } - } - return 0, fmt.Errorf("key group starting at record %d has no terminating final-secondary record", i) -} - -// discardDerivedState removes the keymap and snapshot directories for a table from every root. Both are -// derived entirely from the segment files: the database rebuilds the keymap (via reloadKeymap) and the -// snapshot on its next start, so deleting them forces both back into sync with the truncated segments. -// Leaving them would let a stale keymap reference discarded keys, or let a snapshot's hard links pin the -// rolled-back data on disk. Removing a directory that does not exist is a no-op. -func discardDerivedState(roots []string, tableName string) error { - for _, root := range roots { - dirs := []string{ - filepath.Join(root, tableName, keymap.KeymapDirectoryName), - filepath.Join(root, tableName, segment.HardLinkDirectory), - } - for _, dir := range dirs { - if err := os.RemoveAll(dir); err != nil { - return fmt.Errorf("failed to remove %s: %w", dir, err) - } - } - } - return nil -} - -// highestGCWatermark returns the highest gc-watermark (lowest readable segment index) recorded for a table -// across all roots, and whether any root defined one. Segments below this index have been logically garbage -// collected. The watermark lives at the table root and survives a keymap rebuild, matching how the database -// loads it at startup. -func highestGCWatermark(roots []string, tableName string) (watermark uint32, defined bool, err error) { - for _, root := range roots { - f, err := disktable.LoadGCWatermarkFile(filepath.Join(root, tableName)) - if err != nil { - return 0, false, fmt.Errorf("failed to load gc-watermark for table %q under %s: %w", - tableName, root, err) - } - if f.IsDefined() && (!defined || f.LowestReadableSegment() > watermark) { - watermark = f.LowestReadableSegment() - defined = true - } - } - return watermark, defined, nil -} - -// findTables returns the names of all LittDB tables found under the given roots. A table is any directory -// that contains a "segments" sub-directory. -func findTables(roots []string) ([]string, error) { - tableSet := make(map[string]struct{}) - for _, root := range roots { - entries, err := os.ReadDir(root) - if err != nil { - return nil, fmt.Errorf("failed to read directory %s: %w", root, err) - } - for _, entry := range entries { - if !entry.IsDir() { - continue - } - segmentsDir := filepath.Join(root, entry.Name(), segment.SegmentDirectory) - isDir, err := util.IsDirectory(segmentsDir) - if err != nil { - return nil, fmt.Errorf("failed to check directory %s: %w", segmentsDir, err) - } - if isDir { - tableSet[entry.Name()] = struct{}{} - } - } - } - - tables := make([]string, 0, len(tableSet)) - for table := range tableSet { - tables = append(tables, table) - } - sort.Strings(tables) - return tables, nil -} diff --git a/sei-db/db_engine/litt/rollback/rollback_test.go b/sei-db/db_engine/litt/rollback/rollback_test.go deleted file mode 100644 index 929593c2a8..0000000000 --- a/sei-db/db_engine/litt/rollback/rollback_test.go +++ /dev/null @@ -1,312 +0,0 @@ -package rollback - -import ( - "fmt" - "path/filepath" - "strconv" - "strings" - "testing" - - "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt" - "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt/disktable" - "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt/littbuilder" - "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt/types" - "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt/util" - "github.com/stretchr/testify/require" -) - -const rollbackTestTable = "rollback-test" - -// newRollbackTestDB returns a littDB config (with a handful of storage roots) and an open database, sized -// so that even a modest number of writes produces many sealed segments. The caller must close the DB. -func newRollbackTestDB(t *testing.T) (*litt.Config, []string) { - t.Helper() - rand := util.NewTestRandom() - testDirectory := t.TempDir() - - rootPathCount := rand.Uint64Range(1, 4) - rootPaths := make([]string, rootPathCount) - for i := uint64(0); i < rootPathCount; i++ { - rootPaths[i] = filepath.Join(testDirectory, fmt.Sprintf("root-%d", i)) - } - - config, err := litt.DefaultConfig(rootPaths...) - require.NoError(t, err) - config.Fsync = false - config.DoubleWriteProtection = true - // A tiny target file size forces the data to be spread over many sealed segments, exercising both - // whole-segment deletion and partial (truncating) rollback of a single segment. - config.TargetSegmentFileSize = 100 - - return config, rootPaths -} - -// writeSequentialKeys writes count primary keys named "key-NNNNN" in index order, each with value -// "value-NNNNN", returning the value for every key index. The DB is flushed and closed before returning so -// the data is sealed on disk and ready for an offline rollback. -func writeSequentialKeys(t *testing.T, config *litt.Config, count int) map[int][]byte { - t.Helper() - - db, err := littbuilder.NewDB(config) - require.NoError(t, err) - - tableConfig := litt.DefaultTableConfig(rollbackTestTable) - tableConfig.ShardingFactor = 3 - table, err := db.BuildTable(tableConfig) - require.NoError(t, err) - - values := make(map[int][]byte, count) - for i := 0; i < count; i++ { - value := []byte(fmt.Sprintf("value-%05d", i)) - require.NoError(t, table.Put(keyForIndex(i), value)) - values[i] = value - } - - require.NoError(t, table.Flush()) - require.NoError(t, db.Close()) - return values -} - -func keyForIndex(i int) []byte { - return []byte(fmt.Sprintf("key-%05d", i)) -} - -// indexFromKey parses the integer index encoded in a "key-NNNNN" key. -func indexFromKey(t *testing.T, key []byte) int { - t.Helper() - idx, err := strconv.Atoi(strings.TrimPrefix(string(key), "key-")) - require.NoError(t, err) - return idx -} - -// openTable reopens the test table. -func openTable(t *testing.T, config *litt.Config) (litt.DB, litt.Table) { - t.Helper() - db, err := littbuilder.NewDB(config) - require.NoError(t, err) - tableConfig := litt.DefaultTableConfig(rollbackTestTable) - tableConfig.ShardingFactor = 3 - table, err := db.BuildTable(tableConfig) - require.NoError(t, err) - return db, table -} - -// assertSequentialState verifies that exactly indices [0, keepThrough] are present (with their original -// values) and all higher indices are absent. -func assertSequentialState(t *testing.T, table litt.Table, count int, keepThrough int, values map[int][]byte) { - t.Helper() - for i := 0; i < count; i++ { - got, ok, err := table.Get(keyForIndex(i)) - require.NoError(t, err) - if i <= keepThrough { - require.Truef(t, ok, "key %d should survive rollback", i) - require.Equalf(t, values[i], got, "value mismatch for surviving key %d", i) - } else { - require.Falsef(t, ok, "key %d should have been rolled back", i) - } - } - require.Equal(t, uint64(keepThrough+1), table.KeyCount()) -} - -// TestRollbackLittDB rolls back to a key in the middle of the write history and verifies that the surviving -// keys keep their values and the newer keys are gone. -func TestRollbackLittDB(t *testing.T) { - t.Parallel() - - const count = 200 - const keepThrough = 137 - - config, roots := newRollbackTestDB(t) - values := writeSequentialKeys(t, config, count) - - err := RollbackLittDB(roots, func(tableName string, key []byte, isPrimary bool) (bool, error) { - require.Equal(t, rollbackTestTable, tableName) // filter is invoked per table - require.True(t, isPrimary) // these are all standalone primary keys - return indexFromKey(t, key) <= keepThrough, nil - }) - require.NoError(t, err) - - // The rollback discards the keymap, so reopening rebuilds it from the truncated segment files. A correct - // read of every surviving key therefore also confirms the segments were truncated consistently. - db, table := openTable(t, config) - assertSequentialState(t, table, count, keepThrough, values) - require.NoError(t, db.Close()) -} - -// TestRollbackNoMatch verifies that a table for which the filter never returns true is left untouched. -func TestRollbackNoMatch(t *testing.T) { - t.Parallel() - - const count = 50 - - config, roots := newRollbackTestDB(t) - values := writeSequentialKeys(t, config, count) - - err := RollbackLittDB(roots, func(tableName string, key []byte, isPrimary bool) (bool, error) { - return false, nil - }) - require.NoError(t, err) - - db, table := openTable(t, config) - assertSequentialState(t, table, count, count-1, values) // everything survives - require.NoError(t, db.Close()) -} - -// TestRollbackKeepsEverything verifies that when the newest key matches, nothing is deleted. -func TestRollbackKeepsEverything(t *testing.T) { - t.Parallel() - - const count = 50 - - config, roots := newRollbackTestDB(t) - values := writeSequentialKeys(t, config, count) - - err := RollbackLittDB(roots, func(tableName string, key []byte, isPrimary bool) (bool, error) { - return true, nil // the very first key visited (the newest) matches - }) - require.NoError(t, err) - - db, table := openTable(t, config) - assertSequentialState(t, table, count, count-1, values) - require.NoError(t, db.Close()) -} - -// TestRollbackPropagatesFilterError verifies that an error from the filter aborts the rollback. -func TestRollbackPropagatesFilterError(t *testing.T) { - t.Parallel() - - config, roots := newRollbackTestDB(t) - writeSequentialKeys(t, config, 20) - - wantErr := fmt.Errorf("boom") - err := RollbackLittDB(roots, func(tableName string, key []byte, isPrimary bool) (bool, error) { - return false, wantErr - }) - require.ErrorIs(t, err, wantErr) -} - -// TestRollbackWithSecondaryKeys verifies that secondary keys are handled correctly: the rollback point's -// whole group (its primary plus the secondaries written after it) is retained, isPrimary is reported -// correctly to the filter, and discarded groups lose both their primary and secondary keys. -func TestRollbackWithSecondaryKeys(t *testing.T) { - t.Parallel() - - const count = 60 - const keepThrough = 28 - - config, roots := newRollbackTestDB(t) - - db, err := littbuilder.NewDB(config) - require.NoError(t, err) - tableConfig := litt.DefaultTableConfig(rollbackTestTable) - tableConfig.ShardingFactor = 2 - table, err := db.BuildTable(tableConfig) - require.NoError(t, err) - - primaryKey := func(i int) []byte { return []byte(fmt.Sprintf("pk-%05d", i)) } - secondaryKey := func(i int) []byte { return []byte(fmt.Sprintf("sk-%05d", i)) } - - values := make(map[int][]byte, count) - for i := 0; i < count; i++ { - value := []byte(fmt.Sprintf("value-%05d", i)) - // One secondary key aliasing the entire value. - secondary := &types.SecondaryKey{Key: secondaryKey(i), Offset: 0, Length: uint32(len(value))} - require.NoError(t, table.Put(primaryKey(i), value, secondary)) - values[i] = value - } - require.NoError(t, table.Flush()) - require.NoError(t, db.Close()) - - err = RollbackLittDB(roots, func(tableName string, key []byte, isPrimary bool) (bool, error) { - // Only primary keys carry an index we want to stop on; secondaries are reported with isPrimary=false. - if !isPrimary { - require.True(t, strings.HasPrefix(string(key), "sk-")) - return false, nil - } - require.True(t, strings.HasPrefix(string(key), "pk-")) - idx, err := strconv.Atoi(strings.TrimPrefix(string(key), "pk-")) - require.NoError(t, err) - return idx <= keepThrough, nil - }) - require.NoError(t, err) - - db, err = littbuilder.NewDB(config) - require.NoError(t, err) - table, err = db.BuildTable(tableConfig) - require.NoError(t, err) - - for i := 0; i < count; i++ { - gotPrimary, okPrimary, err := table.Get(primaryKey(i)) - require.NoError(t, err) - gotSecondary, okSecondary, err := table.Get(secondaryKey(i)) - require.NoError(t, err) - - if i <= keepThrough { - require.Truef(t, okPrimary, "primary %d should survive", i) - require.Equal(t, values[i], gotPrimary) - require.Truef(t, okSecondary, "secondary %d should survive (same group as its primary)", i) - require.Equal(t, values[i], gotSecondary) - } else { - require.Falsef(t, okPrimary, "primary %d should be rolled back", i) - require.Falsef(t, okSecondary, "secondary %d should be rolled back", i) - } - } - require.NoError(t, db.Close()) -} - -// TestRollbackRefusesBelowGCWatermark verifies that rolling a table back to a point below its durable -// gc-watermark (data garbage collection has already reclaimed) is refused before any files are mutated. -func TestRollbackRefusesBelowGCWatermark(t *testing.T) { - t.Parallel() - - const count = 50 - - config, roots := newRollbackTestDB(t) - writeSequentialKeys(t, config, count) - - // Record a gc-watermark far above any surviving segment so the rollback point is guaranteed to fall - // below it. The watermark lives at the table root on one of the storage roots. - tableDir := filepath.Join(roots[0], rollbackTestTable) - watermark, err := disktable.LoadGCWatermarkFile(tableDir) - require.NoError(t, err) - require.NoError(t, watermark.Update(1_000_000)) - - keymapDir := filepath.Join(tableDir, "keymap") - existsBefore, err := util.Exists(keymapDir) - require.NoError(t, err) - require.True(t, existsBefore) - - err = RollbackLittDB(roots, func(tableName string, key []byte, isPrimary bool) (bool, error) { - return indexFromKey(t, key) <= 10, nil - }) - require.Error(t, err) - require.Contains(t, err.Error(), "gc-watermark") - - // The guard fires before any destructive step, so the derived state is left untouched. - existsAfter, err := util.Exists(keymapDir) - require.NoError(t, err) - require.True(t, existsAfter, "rollback must not mutate anything when it refuses") -} - -// TestRollbackIsIdempotent verifies that re-running the same rollback is a safe no-op that leaves the table -// in the same correct state (exercising RollbackToKeyCount's survivingKeyCount == len(keys) path). -func TestRollbackIsIdempotent(t *testing.T) { - t.Parallel() - - const count = 120 - const keepThrough = 71 - - config, roots := newRollbackTestDB(t) - values := writeSequentialKeys(t, config, count) - - filter := func(tableName string, key []byte, isPrimary bool) (bool, error) { - return indexFromKey(t, key) <= keepThrough, nil - } - - require.NoError(t, RollbackLittDB(roots, filter)) - require.NoError(t, RollbackLittDB(roots, filter)) // second run must be a safe no-op - - db, table := openTable(t, config) - assertSequentialState(t, table, count, keepThrough, values) - require.NoError(t, db.Close()) -} diff --git a/sei-db/db_engine/litt/table.go b/sei-db/db_engine/litt/table.go index d2d5d8d832..dff21e757c 100644 --- a/sei-db/db_engine/litt/table.go +++ b/sei-db/db_engine/litt/table.go @@ -29,12 +29,9 @@ type Table interface { // each and do not duplicate value bytes. Secondary keys must be globally unique just like // primary keys, and must not collide with the primary key or other secondaries. // - // The maximum size of a key (primary or secondary) is 64 KiB (2^16 - 1 bytes). The maximum size of a - // value is 2^32 - 1 bytes (math.MaxUint32, ~4 GiB); a larger value is rejected with an error. (This - // limit is stricter than it was before secondary keys existed: a value's byte offsets are stored as - // uint32, and each value is written whole within a single segment file below the 2^32 boundary.) This - // database has been optimized under the assumption that values are generally much larger than keys. - // This affects performance, but not correctness. + // The maximum size of a key (primary or secondary) is 64 KiB (2^16 - 1 bytes). The maximum size + // of the value is 2^32 bytes. This database has been optimized under the assumption that values + // are generally much larger than keys. This affects performance, but not correctness. // // Although writes are individually atomic, the DB makes no guarantees about atomicity of multiple writes in // aggregate. That is to say, if a caller writes A and then B and the DB crashes before flushing, it may be the @@ -50,10 +47,9 @@ type Table interface { // // Each PutRequest may include zero or more secondary keys (see Put for semantics). // - // The maximum size of a key (primary or secondary) is 64 KiB (2^16 - 1 bytes). The maximum size of a - // value is 2^32 - 1 bytes (math.MaxUint32, ~4 GiB); a larger value is rejected with an error. This - // database has been optimized under the assumption that values are generally much larger than keys. - // This affects performance, but not correctness. + // The maximum size of a key (primary or secondary) is 64 KiB (2^16 - 1 bytes). The maximum size + // of a value is 2^32 bytes. This database has been optimized under the assumption that values + // are generally much larger than keys. This affects performance, but not correctness. // // Although writes in a batch are individually atomic, the DB makes no guarantees about atomicity of multiple // writes in aggregate. That is to say, if a caller writes A and then B in a batch and the DB crashes before @@ -69,9 +65,9 @@ type Table interface { // (returns false if the key does not exist). If an error is returned, the value of the other returned values are // undefined. // - // The maximum size of a key is 64 KiB (2^16 - 1 bytes). The maximum size of a value is 2^32 - 1 bytes - // (math.MaxUint32, ~4 GiB). This database has been optimized under the assumption that values are - // generally much larger than keys. This affects performance, but not correctness. + // The maximum size of a key is 2^32 bytes. The maximum size of a value is 2^32 bytes. + // This database has been optimized under the assumption that values are generally much larger than keys. + // This affects performance, but not correctness. // // For the sake of performance, the returned data is NOT safe to mutate. If you need to modify the data, // make a copy of it first. It is also not safe to modify the key byte slice after it is passed to this diff --git a/sei-db/ledger_db/block/block_db_test.go b/sei-db/ledger_db/block/block_db_test.go index f211069b52..3b905cd4f5 100644 --- a/sei-db/ledger_db/block/block_db_test.go +++ b/sei-db/ledger_db/block/block_db_test.go @@ -1,7 +1,6 @@ package block import ( - "fmt" "testing" "time" @@ -58,17 +57,7 @@ func TestBlockDB(t *testing.T) { t.Run("RestartPersistsData", func(t *testing.T) { testRestartPersistsData(t, impl.build) }) t.Run("PruneRetainsAtOrAbove", func(t *testing.T) { testPruneRetainsAtOrAbove(t, impl.build) }) t.Run("PruneStraddleRetainsQC", func(t *testing.T) { testPruneStraddleRetainsQC(t, impl.build) }) - t.Run("PruneRefusesBelowWatermark", func(t *testing.T) { testPruneRefusesBelowWatermark(t, impl.build) }) - t.Run("PrunedDistinctFromNotFound", func(t *testing.T) { testPrunedDistinctFromNotFound(t, impl.build) }) t.Run("PruneIdempotentMonotonic", func(t *testing.T) { testPruneIdempotentMonotonic(t, impl.build) }) - t.Run("PruneNeverEmpties", func(t *testing.T) { testPruneNeverEmpties(t, impl.build) }) - t.Run("PruneEmptyStoreThenWriteBelow", func(t *testing.T) { - testPruneEmptyStoreThenWriteBelow(t, impl.build) - }) - t.Run("PruneQCAheadOfBlocks", func(t *testing.T) { testPruneQCAheadOfBlocks(t, impl.build) }) - t.Run("PruneQCOnlyThenWriteBlock", func(t *testing.T) { - testPruneQCOnlyThenWriteBlock(t, impl.build) - }) t.Run("WriteOrderRejected", func(t *testing.T) { testWriteOrderRejected(t, impl.build) }) t.Run("WriteOrderRejectedAfterRestart", func(t *testing.T) { testWriteOrderRejectedAfterRestart(t, impl.build) @@ -264,109 +253,7 @@ func testPruneStraddleRetainsQC(t *testing.T, build builder) { require.NoError(t, err) got, ok := opt.Get() require.True(t, ok, "straddling QC must be retained") - require.Equal(t, straddled.first, got.QC().GlobalRange().First) -} - -// testPruneRefusesBelowWatermark asserts the refuse direction of PruneBefore: -// once the watermark advances past a block, that block is no longer served by -// ReadBlockByNumber, ReadBlockByHash, or the Blocks iterator — so a caller can -// never observe a block whose covering QC may have been pruned out from under it. -func testPruneRefusesBelowWatermark(t *testing.T, build builder) { - committee, keys := buildCommittee() - batches := generateBatches(committee, keys) - db, _ := openFresh(t, build) - defer func() { _ = db.Close() }() - writeAll(t, db, batches) - - // Prune at the start of the second batch: all of the first batch is below it. - watermark := batches[1].first - require.NoError(t, db.PruneBefore(watermark)) - - below := batches[0] - for i, blk := range below.blocks { - n := below.first + gbn(i) - require.Less(t, n, watermark) - - byNum, err := db.ReadBlockByNumber(n) - require.ErrorIs(t, err, types.ErrPruned, "block %d below watermark %d must be reported pruned", n, watermark) - require.False(t, byNum.IsPresent(), "block %d below watermark %d must not be served", n, watermark) - - byHash, err := db.ReadBlockByHash(blk.Header().Hash()) - require.NoError(t, err) - require.False(t, byHash.IsPresent(), "block %d below watermark %d must not be served by hash", n, watermark) - } - - blockIt, err := db.Blocks(false) - require.NoError(t, err) - defer func() { _ = blockIt.Close() }() - for { - ok, err := blockIt.Next() - require.NoError(t, err) - if !ok { - break - } - require.GreaterOrEqual(t, blockIt.Number(), watermark, - "iterator must not yield block %d below watermark %d", blockIt.Number(), watermark) - } -} - -// testPrunedDistinctFromNotFound is the crux of the ErrPruned contract: after a -// prune, a below-watermark by-number read reports ErrPruned (not served while -// below the watermark), while a never-written height at or above the watermark -// reports a plain utils.None with a nil error (absent, but a future write may -// fill it). Both implementations must agree. -// -// The prune point is placed strictly *inside* a QC's range. A QC's cohort of -// blocks must change readability atomically — the watermark may never split a -// cohort — so the effective watermark rounds down to the cohort boundary: the -// whole straddled cohort stays readable, and only the fully-below first cohort -// becomes pruned. -func testPrunedDistinctFromNotFound(t *testing.T, build builder) { - committee, keys := buildCommittee() - batches := generateBatches(committee, keys) - db, _ := openFresh(t, build) - defer func() { _ = db.Close() }() - writeAll(t, db, batches) - - straddled := batches[1] - pruneAt := straddled.first + 2 - require.Greater(t, straddled.next, pruneAt, "prune point must fall strictly inside the cohort") - require.LessOrEqual(t, batches[0].next, straddled.first, "first cohort must sit below the straddled one") - require.NoError(t, db.PruneBefore(pruneAt)) - - // Cohort atomicity: every block in the straddled cohort is still served, - // including those numerically below the prune point — the cohort does not - // split. - for i := range straddled.blocks { - n := straddled.first + gbn(i) - opt, err := db.ReadBlockByNumber(n) - require.NoError(t, err, "block %d in the straddled cohort must not report ErrPruned", n) - require.True(t, opt.IsPresent(), "block %d in the straddled cohort must remain served", n) - } - qcOpt, err := db.ReadQCByBlockNumber(straddled.first) - require.NoError(t, err, "straddled cohort's QC must not report ErrPruned") - got, ok := qcOpt.Get() - require.True(t, ok, "straddled cohort's QC must remain served") - require.Equal(t, straddled.first, got.QC().GlobalRange().First) - - // The fully-below first cohort is pruned: ErrPruned for both the block and - // its covering QC. - belowNum := batches[0].first - blk, err := db.ReadBlockByNumber(belowNum) - require.ErrorIs(t, err, types.ErrPruned, "below-watermark block must report ErrPruned") - require.False(t, blk.IsPresent()) - qc, err := db.ReadQCByBlockNumber(belowNum) - require.ErrorIs(t, err, types.ErrPruned, "below-watermark QC must report ErrPruned") - require.False(t, qc.IsPresent()) - - // Above the watermark but never written: not pruned, just absent. - unwritten := batches[len(batches)-1].next + 1000 - missBlk, err := db.ReadBlockByNumber(unwritten) - require.NoError(t, err, "never-written height must not report ErrPruned") - require.False(t, missBlk.IsPresent()) - missQC, err := db.ReadQCByBlockNumber(unwritten) - require.NoError(t, err, "never-written height must not report ErrPruned") - require.False(t, missQC.IsPresent()) + require.Equal(t, straddled.first, got.QC().GlobalRange(committee).First) } // testPruneIdempotentMonotonic asserts PruneBefore is idempotent and the @@ -401,196 +288,6 @@ func testPruneIdempotentMonotonic(t *testing.T, build builder) { } } -// testPruneEmptyStoreThenWriteBelow asserts a prune on an empty store neither -// refuses nor reclaims data written afterward, even below the requested point. -// Regression for the empty-store watermark bug, and a memblock/littblock parity -// check: an empty-store prune must not advance a read/GC watermark past data that -// does not exist yet. -func testPruneEmptyStoreThenWriteBelow(t *testing.T, build builder) { - committee, keys := buildCommittee() - batches := generateBatches(committee, keys) - db, _ := openFresh(t, build) - defer func() { _ = db.Close() }() - - // Prune above where we are about to write, while the store is still empty. - require.NoError(t, db.PruneBefore(batches[1].first)) - - // Blocks start at 0, below the prune point; all must remain readable. - writeAll(t, db, batches) - assertBlocksReadable(t, db, batches) - assertQCsReadable(t, db, committee, batches) -} - -// testPruneNeverEmpties asserts the store is never emptied by pruning and that -// pruning is monotonic around the newest cohort. Any request whose watermark -// would enter the newest block's cohort — from just past the cohort's first, -// through the newest block, to well beyond every block — is clamped to the -// cohort's first, so the whole newest cohort (and its shared QC) stays readable -// while everything below is gone. The clamp lands on the cohort's first, not -// merely the newest block: the covering QC is retained regardless and covers the -// entire cohort, so a larger n must never retain more. Holds across both -// implementations. -func testPruneNeverEmpties(t *testing.T, build builder) { - committee, keys := buildCommittee() - batches := generateBatches(committee, keys) - require.GreaterOrEqual(t, len(batches), 2, "need a below-cohort batch plus the newest cohort") - last := batches[len(batches)-1] // the newest block's cohort - newest := last.next - 1 - require.Greater(t, len(last.blocks), 1, "need a multi-block cohort to exercise a within-cohort prune") - - // Every request lands the watermark inside (or past) the newest cohort: - // within the cohort, exactly at the newest block, and well past every block. - // All must clamp identically to the cohort's first. - for _, prune := range []types.GlobalBlockNumber{last.first + 1, newest, last.next + 1000} { - t.Run(fmt.Sprintf("prune=%d", prune), func(t *testing.T) { - db, _ := openFresh(t, build) - defer func() { _ = db.Close() }() - writeAll(t, db, batches) - - require.NoError(t, db.PruneBefore(prune)) - - // Every block in the newest cohort is still served on every read path. - for i, blk := range last.blocks { - n := last.first + gbn(i) - - byNum, err := db.ReadBlockByNumber(n) - require.NoError(t, err) - got, ok := byNum.Get() - require.True(t, ok, "block %d in the newest cohort must survive PruneBefore(%d)", n, prune) - require.Equal(t, blk.Header().Hash(), got.Header().Hash()) - - byHash, err := db.ReadBlockByHash(blk.Header().Hash()) - require.NoError(t, err) - bwn, ok := byHash.Get() - require.True(t, ok, "block %d must survive lookup by hash", n) - require.Equal(t, n, bwn.Number) - - qc, err := db.ReadQCByBlockNumber(n) - require.NoError(t, err) - require.True(t, qc.IsPresent(), "the QC covering the newest cohort must survive") - } - - // A block below the newest cohort is gone (clamped watermark refuses/removes it). - belowBatch := batches[len(batches)-2] - require.Less(t, belowBatch.first, last.first) - below, err := db.ReadBlockByNumber(belowBatch.first) - require.ErrorIs(t, err, types.ErrPruned, "blocks below the newest cohort must be reported pruned") - require.False(t, below.IsPresent(), "blocks below the newest cohort must not be served") - - // The block iterator yields exactly the newest cohort, and the QC - // iterator exactly its covering QC. - var expected []types.GlobalBlockNumber - for i := range last.blocks { - expected = append(expected, last.first+gbn(i)) - } - blockIt, err := db.Blocks(false) - require.NoError(t, err) - defer func() { _ = blockIt.Close() }() - var blockNums []types.GlobalBlockNumber - for { - ok, err := blockIt.Next() - require.NoError(t, err) - if !ok { - break - } - blockNums = append(blockNums, blockIt.Number()) - } - require.Equal(t, expected, blockNums, - "exactly the newest cohort must remain after PruneBefore(%d)", prune) - - qcIt, err := db.QCs(false) - require.NoError(t, err) - defer func() { _ = qcIt.Close() }() - qcCount := 0 - for { - ok, err := qcIt.Next() - require.NoError(t, err) - if !ok { - break - } - fqc, err := qcIt.QC() - require.NoError(t, err) - require.Equal(t, last.first, fqc.QC().GlobalRange().First, - "only the QC covering the newest cohort must remain") - qcCount++ - } - require.Equal(t, 1, qcCount, "exactly one QC (covering the newest cohort) must remain") - }) - } -} - -// testPruneQCAheadOfBlocks pins the min() guard in the prune clamp. QCs are -// written before the blocks they cover, so between writing a QC and its first -// block — and after a crash that persisted a QC but not its blocks — the newest -// QC starts above the newest block (latestQCStartBlock > lastBlockNumber). A -// prune-to-empty request must clamp to the newest actual block, not the newest -// QC's first: clamping to the latter would push the watermark past every written -// block and empty the store. This holds across both implementations. -func testPruneQCAheadOfBlocks(t *testing.T, build builder) { - committee, keys := buildCommittee() - batches := generateBatches(committee, keys) - require.GreaterOrEqual(t, len(batches), 2, "need a filled cohort plus an unfilled newest QC") - db, _ := openFresh(t, build) - defer func() { _ = db.Close() }() - - // Fill the first cohort, then write only the QC of the second — no blocks in - // its range. Now latestQCStartBlock (b1.first) exceeds lastBlockNumber (the - // last block of b0), since QCs are contiguous (b1.first == b0.next). - b0 := batches[0] - require.NoError(t, db.WriteQC(b0.first, b0.next, b0.qc)) - for i, blk := range b0.blocks { - require.NoError(t, db.WriteBlock(b0.first+gbn(i), blk)) - } - b1 := batches[1] - require.NoError(t, db.WriteQC(b1.first, b1.next, b1.qc)) - require.Equal(t, b0.next, b1.first, "QCs must be contiguous for this setup") - - newest := b0.next - 1 // newest actual block; b1.first == b0.next > newest - - require.NoError(t, db.PruneBefore(b1.next+1000)) - - // The newest actual block and its covering QC are still served: the clamp - // used min(latestQCStartBlock, lastBlockNumber), not latestQCStartBlock — - // otherwise the watermark would sit above every written block. - blk, err := db.ReadBlockByNumber(newest) - require.NoError(t, err) - require.True(t, blk.IsPresent(), "newest block %d must survive; the clamp must not pass it", newest) - qc, err := db.ReadQCByBlockNumber(newest) - require.NoError(t, err) - require.True(t, qc.IsPresent(), "covering QC of the newest block must survive") -} - -// testPruneQCOnlyThenWriteBlock asserts that pruning while QCs exist but no -// blocks have been written yet does not delete the covering QC. A subsequent -// WriteBlock still passes its coverage check, so deleting the QC here would -// strand a readable block with no readable covering QC. Regression for the -// memblock PruneBefore fall-through (the clamp was guarded by hasBlocks but the -// deletion loops ran regardless); littblock returns early on !hasBlocks. -func testPruneQCOnlyThenWriteBlock(t *testing.T, build builder) { - committee, keys := buildCommittee() - batches := generateBatches(committee, keys) - db, _ := openFresh(t, build) - defer func() { _ = db.Close() }() - - // Write only the QC of the first cohort — no blocks yet (hasQC, !hasBlocks). - b0 := batches[0] - require.NoError(t, db.WriteQC(b0.first, b0.next, b0.qc)) - - // Prune far past the QC. With no blocks, this must be a no-op; the QC cannot - // be deleted or a later covered WriteBlock would be orphaned. - require.NoError(t, db.PruneBefore(b0.next+1000)) - - // The block is still within [b0.first, b0.next), so its coverage check passes. - require.NoError(t, db.WriteBlock(b0.first, b0.blocks[0])) - - blk, err := db.ReadBlockByNumber(b0.first) - require.NoError(t, err) - require.True(t, blk.IsPresent(), "block %d must be readable after write", b0.first) - qc, err := db.ReadQCByBlockNumber(b0.first) - require.NoError(t, err) - require.True(t, qc.IsPresent(), "covering QC of block %d must survive the earlier prune", b0.first) -} - // testIteratorSnapshot asserts that an iterator observes only the records present // when it was created — writes made afterward are invisible to it. func testIteratorSnapshot(t *testing.T, build builder) { @@ -788,7 +485,7 @@ func testReverseIteratorOrdering(t *testing.T, build builder) { } qc, err := qcIt.QC() require.NoError(t, err) - first := qc.QC().GlobalRange().First + first := qc.QC().GlobalRange(committee).First if qcCount == 0 { require.Equal(t, lastFirst, first, "reverse QCs must surface the last QC first") } @@ -828,8 +525,8 @@ func testResumeAfterRestart(t *testing.T, build builder) { prevQC, ok := recoverLastQC(t, db) require.True(t, ok) - require.Equal(t, last.first, prevQC.GlobalRange().First, "recovered QC must be the last persisted QC") - require.Equal(t, last.next, prevQC.GlobalRange().Next) + require.Equal(t, last.first, prevQC.GlobalRange(committee).First, "recovered QC must be the last persisted QC") + require.Equal(t, last.next, prevQC.GlobalRange(committee).Next) // The recovered QC's upper bound is exactly where the continuation begins; // writing the next contiguous batch must be accepted. @@ -935,10 +632,9 @@ func testWriteBlockGaps(t *testing.T, build builder) { byHash, err := db.ReadBlockByHash(blocks[n].Header().Hash()) require.NoError(t, err) - bwn, ok := byHash.Get() + got, ok = byHash.Get() require.True(t, ok, "block %d should be found by hash", n) - require.Equal(t, blocks[n].Header().Hash(), bwn.Block.Header().Hash()) - require.Equal(t, n, bwn.Number, "block %d hash lookup should return its number", n) + require.Equal(t, blocks[n].Header().Hash(), got.Header().Hash()) } // Numbers in the gaps were never written and must miss. @@ -981,11 +677,11 @@ func TestMemblockPruneRemovesBelowWatermark(t *testing.T) { for i := range batches[0].blocks { n := batches[0].first + gbn(i) opt, err := db.ReadBlockByNumber(n) - require.ErrorIs(t, err, types.ErrPruned, "block %d should be pruned", n) + require.NoError(t, err) require.False(t, opt.IsPresent(), "block %d should be pruned", n) } qc, err := db.ReadQCByBlockNumber(batches[0].first) - require.ErrorIs(t, err, types.ErrPruned, "QC below watermark should be pruned") + require.NoError(t, err) require.False(t, qc.IsPresent(), "QC below watermark should be pruned") // Watermark block is retained. @@ -1016,59 +712,102 @@ func TestMemblockPruneRemovesBelowWatermark(t *testing.T) { } fqc, err := qcIt.QC() require.NoError(t, err) - require.GreaterOrEqual(t, fqc.QC().GlobalRange().First, watermark, + require.GreaterOrEqual(t, fqc.QC().GlobalRange(committee).First, watermark, "QC iterator must not surface pruned QCs") } require.NoError(t, qcIt.Close()) } -// TestMemblockPruneIntoCohortRoundsDown verifies memblock's in-memory behavior -// when a prune point lands strictly inside a QC's range: the watermark rounds -// down to that cohort's start, so the cohort's blocks change readability -// atomically — the whole straddled cohort stays served (never split), the -// straddling QC is retained, and the fully-below cohort is pruned. Matches -// littblock. -func TestMemblockPruneIntoCohortRoundsDown(t *testing.T) { +// TestMemblockPruneStraddlingQC verifies the exact in-memory behavior when the +// watermark falls inside a QC's range: blocks below it are removed, blocks at or +// above it stay, and the straddling QC survives and resolves for every in-range +// lookup. memblock keeps no watermark, so even sub-watermark lookups hit the +// retained QC — which the contract permits (below-watermark lookups MAY miss, +// but are not required to). +func TestMemblockPruneStraddlingQC(t *testing.T) { committee, keys := buildCommittee() batches := generateBatches(committee, keys) db := memblock.NewBlockDB() writeAll(t, db, batches) straddled := batches[1] - pruneAt := straddled.first + 2 - require.Greater(t, straddled.next, pruneAt, "prune point must fall strictly inside the cohort") - require.NoError(t, db.PruneBefore(pruneAt)) + watermark := straddled.first + 2 + require.Greater(t, straddled.next, watermark, "watermark must fall strictly inside the batch range") + require.NoError(t, db.PruneBefore(watermark)) - // The whole straddled cohort is still served — the watermark rounded down to - // its start, so none of its blocks are split off below the gate. - for i := range straddled.blocks { - n := straddled.first + gbn(i) - opt, err := db.ReadBlockByNumber(n) - require.NoError(t, err, "block %d in the straddled cohort must be served", n) - require.True(t, opt.IsPresent(), "block %d in the straddled cohort must be served", n) + // Blocks below the watermark within the straddled batch are gone... + for i := 0; gbn(i) < watermark-straddled.first; i++ { + opt, err := db.ReadBlockByNumber(straddled.first + gbn(i)) + require.NoError(t, err) + require.False(t, opt.IsPresent(), "block %d below watermark must be pruned", straddled.first+gbn(i)) } - - // The straddling QC is retained and resolves across its whole range. - for i := range straddled.blocks { - n := straddled.first + gbn(i) - qc, err := db.ReadQCByBlockNumber(n) - require.NoError(t, err, "cohort QC must resolve at %d", n) - require.True(t, qc.IsPresent(), "cohort QC must resolve at %d", n) + // ...while those at or above it remain. + for i := int(watermark - straddled.first); i < len(straddled.blocks); i++ { + opt, err := db.ReadBlockByNumber(straddled.first + gbn(i)) + require.NoError(t, err) + require.True(t, opt.IsPresent(), "block %d at/above watermark must be retained", straddled.first+gbn(i)) } - // The fully-below first cohort is pruned. - for i := range batches[0].blocks { - n := batches[0].first + gbn(i) - opt, err := db.ReadBlockByNumber(n) - require.ErrorIs(t, err, types.ErrPruned, "block %d in the fully-below cohort must be pruned", n) - require.False(t, opt.IsPresent(), "block %d in the fully-below cohort must not be served", n) - } + // The straddling QC stays (its Next > watermark). memblock tracks no + // watermark, so it resolves the retained QC for every n in its range, + // including sub-watermark lookups — which the contract permits. + above, err := db.ReadQCByBlockNumber(watermark) + require.NoError(t, err) + require.True(t, above.IsPresent(), "straddling QC must be retained for lookups at/above watermark") + + below, err := db.ReadQCByBlockNumber(straddled.first) + require.NoError(t, err) + require.True(t, below.IsPresent(), "memblock retains the straddling QC for sub-watermark in-range lookups") } -// The durable reclamation path (data pruned past after a restart is physically -// collected by GC) is covered by TestLittblockReclaimsAcrossRestart in package -// littblock, which inspects the raw table directly — public reads can no longer -// distinguish "reclaimed" from "refused by the read watermark". +// TestLittblockReclaimsAcrossRestart verifies the durable reclamation path: data +// written, then pruned past after a restart (which seals the segments it landed +// in), is collected by GC. The active segment of a running DB only holds the +// newest data, which is never below the watermark — hence the restart. +func TestLittblockReclaimsAcrossRestart(t *testing.T) { + dir := t.TempDir() + committee, keys := buildCommittee() + batches := generateBatches(committee, keys) + + db, err := littblock.NewBlockDB(littConfig(t, dir)) + require.NoError(t, err) + writeAll(t, db, batches) + require.NoError(t, db.Flush()) + require.NoError(t, db.Close()) + + // Reopen: the segments written above are now sealed and collectable. + db2, err := littblock.NewBlockDB(littConfig(t, dir)) + require.NoError(t, err) + defer func() { _ = db2.Close() }() + + beyond := batches[len(batches)-1].next + require.NoError(t, db2.PruneBefore(beyond)) + require.NoError(t, littblock.ForceGC(db2)) + + for _, b := range batches { + opt, err := db2.ReadBlockByNumber(b.first) + require.NoError(t, err) + require.False(t, opt.IsPresent(), "block %d should be reclaimed after restart", b.first) + qc, err := db2.ReadQCByBlockNumber(b.first) + require.NoError(t, err) + require.False(t, qc.IsPresent(), "QC at %d should be reclaimed after restart", b.first) + } + + // After reclamation the iterators must surface nothing. + blockIt, err := db2.Blocks(false) + require.NoError(t, err) + ok, err := blockIt.Next() + require.NoError(t, err) + require.False(t, ok, "all blocks reclaimed: block iterator must be empty") + require.NoError(t, blockIt.Close()) + + qcIt, err := db2.QCs(false) + require.NoError(t, err) + ok, err = qcIt.Next() + require.NoError(t, err) + require.False(t, ok, "all QCs reclaimed: QC iterator must be empty") + require.NoError(t, qcIt.Close()) +} // littConfig builds a littblock config rooted at dir with a tiny retention so // the prune watermark is the sole observable reclamation gate in tests. @@ -1094,23 +833,22 @@ func assertBlocksReadable(t *testing.T, db types.BlockDB, batches []batch) { byHash, err := db.ReadBlockByHash(blk.Header().Hash()) require.NoError(t, err) - bwn, ok := byHash.Get() + got, ok = byHash.Get() require.True(t, ok, "block by hash should exist") - require.Equal(t, blk.Header().Hash(), bwn.Block.Header().Hash()) - require.Equal(t, n, bwn.Number, "block %d hash lookup should return its number", n) + require.Equal(t, blk.Header().Hash(), got.Header().Hash()) } } } func assertQCsReadable(t *testing.T, db types.BlockDB, committee *types.Committee, batches []batch) { for _, b := range batches { - r := b.qc.QC().GlobalRange() + r := b.qc.QC().GlobalRange(committee) for n := r.First; n < r.Next; n++ { opt, err := db.ReadQCByBlockNumber(n) require.NoError(t, err) got, ok := opt.Get() require.True(t, ok, "QC covering %d should exist", n) - gr := got.QC().GlobalRange() + gr := got.QC().GlobalRange(committee) require.Equal(t, r.First, gr.First) require.Equal(t, r.Next, gr.Next) require.Len(t, got.Headers(), len(b.qc.Headers()), "QC must round-trip its full header set") @@ -1164,7 +902,7 @@ func assertIterators(t *testing.T, db types.BlockDB, committee *types.Committee, } qc, err := qcIt.QC() require.NoError(t, err) - first := qc.QC().GlobalRange().First + first := qc.QC().GlobalRange(committee).First if haveQC { require.Greater(t, first, prevFirst, "QCs must iterate ascending by First") } @@ -1222,7 +960,7 @@ func buildCommittee() (*types.Committee, []types.SecretKey) { keys[i] = types.GenSecretKey(rng) replicas[i] = keys[i].Public() } - committee := utils.OrPanic1(types.NewRoundRobinElection(replicas)) + committee := utils.OrPanic1(types.NewRoundRobinElection(replicas, 0, genesisTime)) return committee, keys } @@ -1234,7 +972,7 @@ func generateBatches(committee *types.Committee, keys []types.SecretKey) []batch batches := make([]batch, 0, numBatches) for range numBatches { fqc, blocks := buildFullCommitQC(rng, committee, keys, prev) - r := fqc.QC().GlobalRange() + r := fqc.QC().GlobalRange(committee) batches = append(batches, batch{first: r.First, next: r.Next, blocks: blocks, qc: fqc}) prev = utils.Some(fqc.QC()) } @@ -1253,12 +991,18 @@ func buildFullCommitQC( parent := bs[len(bs)-1] return types.NewBlock(producer, parent.Header().Next(), parent.Header().Hash(), types.GenPayload(rng)) } - return types.NewBlock(producer, types.LaneRangeOpt(prev, producer).Next(), types.GenBlockHeaderHash(rng), types.GenPayload(rng)) + return types.NewBlock( + producer, + types.LaneRangeOpt(prev, producer).Next(), + types.GenBlockHeaderHash(rng), + types.GenPayload(rng), + ) } for range blocksPerQC { producer := committee.Lanes().At(rng.Intn(committee.Lanes().Len())) blocks[producer] = append(blocks[producer], makeBlock(producer)) } + laneQCs := map[types.LaneID]*types.LaneQC{} var headers []*types.BlockHeader var blockList []*types.Block @@ -1271,16 +1015,35 @@ func buildFullCommitQC( } } } - var appQC utils.Option[*types.AppQC] - if cqc, ok := prev.Get(); ok { - p := types.NewAppProposal(cqc.GlobalRange().Next-1, types.NextIndexOpt(prev), types.GenAppHash(rng), cqc.Proposal().EpochIndex()) - appQC = utils.Some(testAppQC(keys, p)) - } else { - appQC = utils.None[*types.AppQC]() + + viewSpec := types.ViewSpec{CommitQC: prev} + leader := committee.Leader(viewSpec.View()) + var leaderKey types.SecretKey + for _, k := range keys { + if k.Public() == leader { + leaderKey = k + break + } + } + proposal := utils.OrPanic1(types.NewProposal( + leaderKey, + committee, + viewSpec, + genesisTime, + laneQCs, + func() utils.Option[*types.AppQC] { + if n := types.GlobalRangeOpt(prev, committee).Next; n > 0 { + p := types.NewAppProposal(n-1, viewSpec.View().Index, types.GenAppHash(rng)) + return utils.Some(testAppQC(keys, p)) + } + return utils.None[*types.AppQC]() + }(), + )) + votes := make([]*types.Signed[*types.CommitVote], 0, len(keys)) + for _, k := range keys { + votes = append(votes, types.Sign(k, types.NewCommitVote(proposal.Proposal().Msg()))) } - ep := types.NewEpoch(0, types.OpenRoadRange(), genesisTime, committee, 0) - cqc := types.BuildCommitQC(ep, keys, prev, laneQCs, appQC) - return types.NewFullCommitQC(cqc, headers), blockList + return types.NewFullCommitQC(types.NewCommitQC(votes), headers), blockList } func testLaneQC(keys []types.SecretKey, header *types.BlockHeader) *types.LaneQC { diff --git a/sei-db/ledger_db/block/blocksim/block_generator.go b/sei-db/ledger_db/block/blocksim/block_generator.go index 0b13f9ad0e..6d0b1d048f 100644 --- a/sei-db/ledger_db/block/blocksim/block_generator.go +++ b/sei-db/ledger_db/block/blocksim/block_generator.go @@ -2,21 +2,12 @@ package blocksim import ( "context" - "fmt" "time" - crand "github.com/sei-protocol/sei-chain/sei-db/common/rand" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) -// Ed25519 signatures are 64 bytes. Fake signatures are filled with this many canned -// random bytes; types.SignatureForTesting validates the length. -const signatureSizeBytes = 64 - -// SHA-256 digests (parent hash, payload hash, app hash) are 32 bytes. -const hashSizeBytes = 32 - // generatedBatch is a contiguous run of finalized blocks together with the // FullCommitQC that finalizes them. The blocks occupy global numbers // [first, next). @@ -30,20 +21,12 @@ type generatedBatch struct { // BlockGenerator asynchronously produces finalized batches (blocks + their QC) // and feeds them into a channel. The generator stops when the context is // cancelled. -// -// This is a DB stress benchmark: the block store persists blocks/QCs verbatim and never -// verifies signatures or content. The generator therefore avoids the two dominant costs -// of realistic block production — real Ed25519 signing and per-transaction random-byte -// generation — so that the benchmark measures the database rather than block production. -// All randomness comes from a CannedRandom (a pre-generated, immutable buffer served as -// zero-copy sub-slices), and every signature is a fake (never-signed) blob. See -// types.SignatureForTesting / SignedForTesting / NewBlockForTesting / NewProposalForTesting. type BlockGenerator struct { ctx context.Context config *BlocksimConfig - rand *crand.CannedRandom + rng utils.Rng committee *types.Committee - pubKeys []types.PublicKey + keys []types.SecretKey // The QC finalized in the previous batch; chains successive batches into // contiguous global ranges. @@ -56,26 +39,21 @@ type BlockGenerator struct { // background goroutine. prev seeds the chain: pass the last persisted QC to // resume after existing on-disk history, or utils.None to start from genesis. // prev is set on the struct before the goroutine is launched, so the goroutine -// observes it without a data race. rand must not be shared with any other -// goroutine (a single generator goroutine owns it). +// observes it without a data race. func NewBlockGenerator( ctx context.Context, config *BlocksimConfig, - rand *crand.CannedRandom, + rng utils.Rng, committee *types.Committee, keys []types.SecretKey, prev utils.Option[*types.CommitQC], ) *BlockGenerator { - pubKeys := make([]types.PublicKey, len(keys)) - for i, k := range keys { - pubKeys[i] = k.Public() - } g := &BlockGenerator{ ctx: ctx, config: config, - rand: rand, + rng: rng, committee: committee, - pubKeys: pubKeys, + keys: keys, prev: prev, batchChan: make(chan *generatedBatch, config.StagedBlockQueueSize), } @@ -96,18 +74,17 @@ func (g *BlockGenerator) mainLoop() { func (g *BlockGenerator) buildBatch() *generatedBatch { fqc, blocks := g.buildFullCommitQC() - r := fqc.QC().GlobalRange() + r := fqc.QC().GlobalRange(g.committee) g.prev = utils.Some(fqc.QC()) return &generatedBatch{first: r.First, next: r.Next, blocks: blocks, qc: fqc} } -// makePayload builds a Payload of the configured size: TransactionsPerBlock transactions -// of BytesPerTransaction bytes each. The transaction bytes are zero-copy sub-slices of -// the CannedRandom buffer, so no random generation or allocation happens per transaction. +// makePayload builds a Payload of the configured size: +// TransactionsPerBlock transactions of BytesPerTransaction random bytes each. func (g *BlockGenerator) makePayload() *types.Payload { txs := make([][]byte, g.config.TransactionsPerBlock) for i := range txs { - txs[i] = g.rand.Bytes(int(g.config.BytesPerTransaction)) //nolint:gosec // payload sizes are bounded by config validation + txs[i] = utils.GenBytes(g.rng, int(g.config.BytesPerTransaction)) //nolint:gosec // payload sizes are bounded by config validation } return utils.OrPanic1(types.PayloadBuilder{ CreatedAt: time.Now(), @@ -115,43 +92,30 @@ func (g *BlockGenerator) makePayload() *types.Payload { }.Build()) } -// fakeSig builds a signature carrying the given public key but random (never-signed) -// bytes. The block store never verifies signatures, so this avoids real Ed25519 signing. -func (g *BlockGenerator) fakeSig(key types.PublicKey) *types.Signature { - sig, err := types.SignatureForTesting(key, g.rand.Bytes(signatureSizeBytes)) - if err != nil { - panic(fmt.Sprintf("failed to build fake signature: %v", err)) - } - return sig -} - // buildFullCommitQC mirrors the construction in data.TestCommitQC, but with a -// configurable block count and configurable payload size, and without real crypto or -// math/rand. Blocks are chained off the previous QC so successive QCs cover contiguous -// global ranges. +// configurable block count and configurable payload size. Blocks are chained +// off the previous QC so successive QCs cover contiguous global ranges. func (g *BlockGenerator) buildFullCommitQC() (*types.FullCommitQC, []*types.Block) { + rng := g.rng committee := g.committee + keys := g.keys prev := g.prev blocks := map[types.LaneID][]*types.Block{} makeBlock := func(producer types.LaneID) *types.Block { - payload := g.makePayload() - payloadHash := utils.OrPanic1(types.ParsePayloadHash(g.rand.Bytes(hashSizeBytes))) if bs := blocks[producer]; len(bs) > 0 { parent := bs[len(bs)-1] - return types.NewBlockForTesting(producer, parent.Header().Next(), parent.Header().Hash(), payload, payloadHash) + return types.NewBlock(producer, parent.Header().Next(), parent.Header().Hash(), g.makePayload()) } - genesisParent := utils.OrPanic1(types.ParseBlockHeaderHash(g.rand.Bytes(hashSizeBytes))) - return types.NewBlockForTesting( + return types.NewBlock( producer, types.LaneRangeOpt(prev, producer).Next(), - genesisParent, - payload, - payloadHash, + types.GenBlockHeaderHash(rng), + g.makePayload(), ) } for range int(g.config.BlocksPerQc) { //nolint:gosec // BlocksPerQc is a small config value - producer := committee.Lanes().At(int(g.rand.Int64Range(0, int64(committee.Lanes().Len())))) + producer := committee.Lanes().At(rng.Intn(committee.Lanes().Len())) blocks[producer] = append(blocks[producer], makeBlock(producer)) } @@ -160,7 +124,7 @@ func (g *BlockGenerator) buildFullCommitQC() (*types.FullCommitQC, []*types.Bloc var blockList []*types.Block for lane := range committee.Lanes().All() { if bs := blocks[lane]; len(bs) > 0 { - laneQCs[lane] = g.fakeLaneQC(bs[len(bs)-1].Header()) + laneQCs[lane] = testLaneQC(keys, bs[len(bs)-1].Header()) for _, b := range bs { headers = append(headers, b.Header()) blockList = append(blockList, b) @@ -168,45 +132,50 @@ func (g *BlockGenerator) buildFullCommitQC() (*types.FullCommitQC, []*types.Bloc } } - viewSpec := types.ViewSpec{CommitQC: prev, Epoch: types.NewEpoch(0, types.OpenRoadRange(), genesisTime, committee, 0)} + viewSpec := types.ViewSpec{CommitQC: prev} leader := committee.Leader(viewSpec.View()) - appQC := func() utils.Option[*types.AppQC] { - if n := viewSpec.NextGlobalBlock(); n > 0 { - p := types.NewAppProposal(n-1, viewSpec.View().Index, types.AppHash(g.rand.Bytes(hashSizeBytes)), viewSpec.Epoch.EpochIndex()) - return utils.Some(g.fakeAppQC(p)) + var leaderKey types.SecretKey + for _, k := range keys { + if k.Public() == leader { + leaderKey = k + break } - return utils.None[*types.AppQC]() - }() - proposal := utils.OrPanic1(types.NewProposalForTesting( + } + proposal := utils.OrPanic1(types.NewProposal( + leaderKey, committee, viewSpec, time.Now(), laneQCs, - appQC, - g.fakeSig(leader), + func() utils.Option[*types.AppQC] { + if n := types.GlobalRangeOpt(prev, committee).Next; n > 0 { + p := types.NewAppProposal(n-1, viewSpec.View().Index, types.GenAppHash(rng)) + return utils.Some(testAppQC(keys, p)) + } + return utils.None[*types.AppQC]() + }(), )) - commitVote := types.NewCommitVote(proposal.Proposal().Msg()) - votes := make([]*types.Signed[*types.CommitVote], 0, len(g.pubKeys)) - for _, pk := range g.pubKeys { - votes = append(votes, types.SignedForTesting(commitVote, g.fakeSig(pk))) + votes := make([]*types.Signed[*types.CommitVote], 0, len(keys)) + for _, k := range keys { + votes = append(votes, types.Sign(k, types.NewCommitVote(proposal.Proposal().Msg()))) } return types.NewFullCommitQC(types.NewCommitQC(votes), headers), blockList } -func (g *BlockGenerator) fakeLaneQC(header *types.BlockHeader) *types.LaneQC { +func testLaneQC(keys []types.SecretKey, header *types.BlockHeader) *types.LaneQC { vote := types.NewLaneVote(header) - votes := make([]*types.Signed[*types.LaneVote], 0, len(g.pubKeys)) - for _, pk := range g.pubKeys { - votes = append(votes, types.SignedForTesting(vote, g.fakeSig(pk))) + votes := make([]*types.Signed[*types.LaneVote], 0, len(keys)) + for _, k := range keys { + votes = append(votes, types.Sign(k, vote)) } return types.NewLaneQC(votes) } -func (g *BlockGenerator) fakeAppQC(proposal *types.AppProposal) *types.AppQC { +func testAppQC(keys []types.SecretKey, proposal *types.AppProposal) *types.AppQC { vote := types.NewAppVote(proposal) - votes := make([]*types.Signed[*types.AppVote], 0, len(g.pubKeys)) - for _, pk := range g.pubKeys { - votes = append(votes, types.SignedForTesting(vote, g.fakeSig(pk))) + votes := make([]*types.Signed[*types.AppVote], 0, len(keys)) + for _, k := range keys { + votes = append(votes, types.Sign(k, vote)) } return types.NewAppQC(votes) } diff --git a/sei-db/ledger_db/block/blocksim/blocksim.go b/sei-db/ledger_db/block/blocksim/blocksim.go index b13dc0c11d..38eebfca5f 100644 --- a/sei-db/ledger_db/block/blocksim/blocksim.go +++ b/sei-db/ledger_db/block/blocksim/blocksim.go @@ -6,7 +6,6 @@ import ( "os" "time" - crand "github.com/sei-protocol/sei-chain/sei-db/common/rand" "github.com/sei-protocol/sei-chain/sei-db/common/utils" "github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/littblock" "github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/memblock" @@ -92,10 +91,6 @@ func NewBlockSim( return nil, err } - // Pre-generate a random buffer once; all block/QC data generation slices into it - // (zero-copy) so the generator never runs math/rand on the hot path. - cannedRand := crand.NewCannedRandom(int(config.RandomDataBufferSizeBytes), config.Seed) //nolint:gosec // buffer size is bounded by config - db, err := openBlockDB(config) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) @@ -126,14 +121,14 @@ func NewBlockSim( // last QC's range — the next batch then appends contiguously. Block bytes // are irrelevant here (this is a DB stress test), so the backfill writes // freshly generated blocks under the already-persisted QC. - qcRange := prevQC.GlobalRange() + qcRange := prevQC.GlobalRange(committee) lastQCNext := uint64(qcRange.Next) firstMissing := uint64(qcRange.First) if h, ok := highestOpt.Get(); ok { firstMissing = h + 1 } for n := firstMissing; n < lastQCNext; n++ { - blk := backfillBlock(cannedRand, committee, config) + blk := types.GenBlock(rng) if err := db.WriteBlock(types.GlobalBlockNumber(n), blk); err != nil { //nolint:gosec // n < lastQCNext cancel() return nil, fmt.Errorf("failed to backfill block %d: %w", n, err) @@ -143,7 +138,7 @@ func NewBlockSim( fmt.Printf("Resuming from block %d.\n", highest) } - generator := NewBlockGenerator(ctx, config, cannedRand, committee, keys, prev) + generator := NewBlockGenerator(ctx, config, rng, committee, keys, prev) consoleUpdatePeriod := time.Duration(config.ConsoleUpdateIntervalSeconds * float64(time.Second)) @@ -268,27 +263,13 @@ func buildCommittee(rng tmutils.Rng, size int) (*types.Committee, []types.Secret keys[i] = types.GenSecretKey(rng) replicas[i] = keys[i].Public() } - committee, err := types.NewRoundRobinElection(replicas) + committee, err := types.NewRoundRobinElection(replicas, 0, genesisTime) if err != nil { return nil, nil, fmt.Errorf("failed to build committee: %w", err) } return committee, keys, nil } -// backfillBlock builds a throwaway block for crash-recovery backfill using canned random -// data. The block's content and internal header are irrelevant — the store keys blocks by -// the global number passed to WriteBlock — so this avoids real crypto and math/rand. -func backfillBlock(rand *crand.CannedRandom, committee *types.Committee, config *BlocksimConfig) *types.Block { - txs := make([][]byte, config.TransactionsPerBlock) - for i := range txs { - txs[i] = rand.Bytes(int(config.BytesPerTransaction)) //nolint:gosec // payload sizes are bounded by config validation - } - payload := tmutils.OrPanic1(types.PayloadBuilder{CreatedAt: time.Now(), Txs: txs}.Build()) - payloadHash := tmutils.OrPanic1(types.ParsePayloadHash(rand.Bytes(hashSizeBytes))) - parentHash := tmutils.OrPanic1(types.ParseBlockHeaderHash(rand.Bytes(hashSizeBytes))) - return types.NewBlockForTesting(committee.Lanes().At(0), 0, parentHash, payload, payloadHash) -} - // The main loop of the benchmark. func (b *BlockSim) run() { defer b.teardown() @@ -369,7 +350,7 @@ func (b *BlockSim) handleNextBatch(batch *generatedBatch) { b.totalBlocksWritten++ b.totalBytesWritten += blockBytes b.highestBlockHeight = uint64(n) - b.metrics.ReportBlockWritten(blockBytes, int64(len(blk.Payload().Txs()))) + b.metrics.ReportBlockWritten(blockBytes) } b.metrics.RecordHighestHeight(b.highestBlockHeight) @@ -528,10 +509,6 @@ func openBlockDB(config *BlocksimConfig) (types.BlockDB, error) { return nil, fmt.Errorf("failed to build litt block db config: %w", err) } littConfig.Retention = time.Duration(config.LittRetentionSeconds) * time.Second - // Record litt_* metrics into blocksim's already-configured global OTel MeterProvider (set up in - // main before the DB is opened). MetricsServeEndpoint stays false so LittDB does not stand up its - // own registry/server; the metrics surface on blocksim's single /metrics endpoint. - littConfig.Litt.MetricsEnabled = config.LittMetricsEnabled return littblock.NewBlockDB(littConfig) default: return nil, fmt.Errorf("unknown block store backend: %q", config.Backend) diff --git a/sei-db/ledger_db/block/blocksim/blocksim_config.go b/sei-db/ledger_db/block/blocksim/blocksim_config.go index 611cb9f130..ee0da4b251 100644 --- a/sei-db/ledger_db/block/blocksim/blocksim_config.go +++ b/sei-db/ledger_db/block/blocksim/blocksim_config.go @@ -30,13 +30,6 @@ type BlocksimConfig struct { // benchmark. A larger queue allows the block generator to run further ahead of the consumer. StagedBlockQueueSize uint64 - // Size in bytes of the pre-generated random buffer used to synthesize block payloads and fake - // signatures. The buffer is filled once at startup and sliced (zero-copy) thereafter, so the - // generator never runs math/rand or allocates payload bytes on the hot path. Must be at least - // BytesPerTransaction (the largest single request). Larger values give a longer runway before - // the byte sequence repeats. - RandomDataBufferSizeBytes uint64 - // The number of blocks to keep in the database after pruning. UnprunedBlocks uint64 @@ -59,11 +52,6 @@ type BlocksimConfig struct { // reclamation on top of this). Must be positive. LittRetentionSeconds int - // If true, the "litt" backend records its litt_* metrics into blocksim's global OTel MeterProvider, - // so they are exported on the same /metrics endpoint as the blocksim_* metrics (see MetricsAddr). - // Only meaningful when Backend is "litt" and MetricsAddr is set. - LittMetricsEnabled bool - // If this many seconds go by without a console update, the benchmark will print a report. ConsoleUpdateIntervalSeconds float64 @@ -117,23 +105,21 @@ type BlocksimConfig struct { func DefaultBlocksimConfig() *BlocksimConfig { return &BlocksimConfig{ BytesPerTransaction: 1024, - TransactionsPerBlock: 2000, + TransactionsPerBlock: 512, CommitteeSize: 4, - BlocksPerQc: 1, + BlocksPerQc: 10, StagedBlockQueueSize: 8, - RandomDataBufferSizeBytes: 64 * 1024 * 1024, // 64 MiB - LittRetentionSeconds: 2 * 60 * 60, // 2 hours - LittMetricsEnabled: true, + LittRetentionSeconds: 86_400, UnprunedBlocks: 100_000, Seed: 1337, DataDir: "data", - Backend: "litt", + Backend: "mem", ConsoleUpdateIntervalSeconds: 1, ConsoleUpdateIntervalBlocks: 10_000, MaxRuntimeSeconds: 0, MetricsAddr: ":9090", EnableSuspension: true, - FlushIntervalBlocks: 100, + FlushIntervalBlocks: 1024, BackgroundMetricsScrapeInterval: 60, LogDir: "logs", LogLevel: "info", @@ -168,15 +154,6 @@ func (c *BlocksimConfig) Validate() error { if c.StagedBlockQueueSize < 1 { return fmt.Errorf("StagedBlockQueueSize must be at least 1 (got %d)", c.StagedBlockQueueSize) } - minBuffer := c.BytesPerTransaction - if signatureSizeBytes > minBuffer { - minBuffer = signatureSizeBytes - } - if c.RandomDataBufferSizeBytes < minBuffer { - return fmt.Errorf("RandomDataBufferSizeBytes must be at least %d "+ - "(max of BytesPerTransaction and the fixed signature/hash draws) (got %d)", - minBuffer, c.RandomDataBufferSizeBytes) - } if c.LittRetentionSeconds < 1 { return fmt.Errorf("LittRetentionSeconds must be positive (got %d)", c.LittRetentionSeconds) } diff --git a/sei-db/ledger_db/block/blocksim/blocksim_metrics.go b/sei-db/ledger_db/block/blocksim/blocksim_metrics.go index 9311b3b65f..7a44b1f497 100644 --- a/sei-db/ledger_db/block/blocksim/blocksim_metrics.go +++ b/sei-db/ledger_db/block/blocksim/blocksim_metrics.go @@ -14,12 +14,11 @@ const blocksimMeterName = "blocksim" type BlocksimMetrics struct { ctx context.Context - blocksWrittenTotal metric.Int64Counter - transactionsWrittenTotal metric.Int64Counter - qcsWrittenTotal metric.Int64Counter - bytesWrittenTotal metric.Int64Counter - pruneCallsTotal metric.Int64Counter - flushCallsTotal metric.Int64Counter + blocksWrittenTotal metric.Int64Counter + qcsWrittenTotal metric.Int64Counter + bytesWrittenTotal metric.Int64Counter + pruneCallsTotal metric.Int64Counter + flushCallsTotal metric.Int64Counter lowestBlockHeight metric.Int64Gauge highestBlockHeight metric.Int64Gauge @@ -39,11 +38,6 @@ func NewBlocksimMetrics(ctx context.Context, config *BlocksimConfig) *BlocksimMe metric.WithDescription("Total number of blocks written to the database"), metric.WithUnit("{count}"), ) - transactionsWrittenTotal, _ := meter.Int64Counter( - "blocksim_transactions_written_total", - metric.WithDescription("Total number of transactions written to the database (summed across all block payloads)"), - metric.WithUnit("{count}"), - ) qcsWrittenTotal, _ := meter.Int64Counter( "blocksim_qcs_written_total", metric.WithDescription("Total number of commit QCs written to the database"), @@ -83,17 +77,16 @@ func NewBlocksimMetrics(ctx context.Context, config *BlocksimConfig) *BlocksimMe mainThreadPhase := metrics.NewPhaseTimer(meter, "blocksim_main_thread") m := &BlocksimMetrics{ - ctx: ctx, - blocksWrittenTotal: blocksWrittenTotal, - transactionsWrittenTotal: transactionsWrittenTotal, - qcsWrittenTotal: qcsWrittenTotal, - bytesWrittenTotal: bytesWrittenTotal, - pruneCallsTotal: pruneCallsTotal, - flushCallsTotal: flushCallsTotal, - lowestBlockHeight: lowestBlockHeight, - highestBlockHeight: highestBlockHeight, - blockSizeBytes: blockSizeBytes, - mainThreadPhase: mainThreadPhase, + ctx: ctx, + blocksWrittenTotal: blocksWrittenTotal, + qcsWrittenTotal: qcsWrittenTotal, + bytesWrittenTotal: bytesWrittenTotal, + pruneCallsTotal: pruneCallsTotal, + flushCallsTotal: flushCallsTotal, + lowestBlockHeight: lowestBlockHeight, + highestBlockHeight: highestBlockHeight, + blockSizeBytes: blockSizeBytes, + mainThreadPhase: mainThreadPhase, } m.recordBlockSize(config) @@ -124,7 +117,7 @@ func (m *BlocksimMetrics) RecordHighestHeight(height uint64) { m.highestBlockHeight.Record(context.Background(), int64(height)) //nolint:gosec } -func (m *BlocksimMetrics) ReportBlockWritten(byteCount int64, txCount int64) { +func (m *BlocksimMetrics) ReportBlockWritten(byteCount int64) { if m == nil { return } @@ -132,9 +125,6 @@ func (m *BlocksimMetrics) ReportBlockWritten(byteCount int64, txCount int64) { if m.blocksWrittenTotal != nil { m.blocksWrittenTotal.Add(ctx, 1) } - if m.transactionsWrittenTotal != nil { - m.transactionsWrittenTotal.Add(ctx, txCount) - } if m.bytesWrittenTotal != nil { m.bytesWrittenTotal.Add(ctx, byteCount) } diff --git a/sei-db/ledger_db/block/blocksim/config/debug.json b/sei-db/ledger_db/block/blocksim/config/debug.json index e9d22876f2..7a2dbefb3c 100644 --- a/sei-db/ledger_db/block/blocksim/config/debug.json +++ b/sei-db/ledger_db/block/blocksim/config/debug.json @@ -1,9 +1,9 @@ { "Comment": "Configuration for local smoke testing and debugging.", "Seed": 1337, - "DataDir": "~/blocksim/data", - "LogDir": "~/blocksim/logs", - "Backend": "litt", + "DataDir": "data", + "Backend": "mem", + "LogDir": "logs", "CleanDataOnStart": true, "CleanLogsOnStart": true, "CleanDataOnExit": true, diff --git a/sei-db/ledger_db/block/blocksim/config/standard.json b/sei-db/ledger_db/block/blocksim/config/standard.json deleted file mode 100644 index 622142e41c..0000000000 --- a/sei-db/ledger_db/block/blocksim/config/standard.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "Comment": "Standard blocksim configuration.", - "Seed": 1337, - "DataDir": "~/blocksim/data", - "LogDir": "~/blocksim/logs", - "Backend": "litt" -} diff --git a/sei-db/ledger_db/block/blocksim/resume_test.go b/sei-db/ledger_db/block/blocksim/resume_test.go index 8c2c764981..da982d6a9b 100644 --- a/sei-db/ledger_db/block/blocksim/resume_test.go +++ b/sei-db/ledger_db/block/blocksim/resume_test.go @@ -6,7 +6,6 @@ import ( "github.com/stretchr/testify/require" - crand "github.com/sei-protocol/sei-chain/sei-db/common/rand" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" tmutils "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) @@ -24,17 +23,11 @@ func TestRecoverResumeState(t *testing.T) { cfg.TransactionsPerBlock = 1 cfg.BytesPerTransaction = 16 - // Mirror NewBlockSim: build the keygen RNG and the committee, then a CannedRandom - // for the generator's data. + // Mirror NewBlockSim: build the RNG, then the committee (which consumes it), + // then hand the same RNG to the generator. rng := tmutils.TestRngFromSeed(cfg.Seed) committee, keys, err := buildCommittee(rng, int(cfg.CommitteeSize)) //nolint:gosec // small config value require.NoError(t, err) - cannedRand := crand.NewCannedRandom(int(cfg.RandomDataBufferSizeBytes), cfg.Seed) //nolint:gosec // bounded by config - - pubKeys := make([]types.PublicKey, len(keys)) - for i, k := range keys { - pubKeys[i] = k.Public() - } db, err := openBlockDB(cfg) require.NoError(t, err) @@ -44,9 +37,9 @@ func TestRecoverResumeState(t *testing.T) { gen := &BlockGenerator{ ctx: context.Background(), config: cfg, - rand: cannedRand, + rng: rng, committee: committee, - pubKeys: pubKeys, + keys: keys, prev: tmutils.None[*types.CommitQC](), } var last *generatedBatch @@ -74,8 +67,8 @@ func TestRecoverResumeState(t *testing.T) { prevQC, ok := prev.Get() require.True(t, ok, "recovered prev QC must be present") - require.Equal(t, last.first, prevQC.GlobalRange().First, "recovered QC must be the last persisted QC") - require.Equal(t, last.next, prevQC.GlobalRange().Next) + require.Equal(t, last.first, prevQC.GlobalRange(committee).First, "recovered QC must be the last persisted QC") + require.Equal(t, last.next, prevQC.GlobalRange(committee).Next) // Empty-store sanity: a fresh dir recovers nothing. empty, err := openBlockDB(&BlocksimConfig{Backend: "litt", DataDir: t.TempDir(), LittRetentionSeconds: 1}) diff --git a/sei-db/ledger_db/block/littblock/codec.go b/sei-db/ledger_db/block/littblock/codec.go index c39bf69276..7a75715f04 100644 --- a/sei-db/ledger_db/block/littblock/codec.go +++ b/sei-db/ledger_db/block/littblock/codec.go @@ -2,7 +2,6 @@ package littblock import ( "encoding/binary" - "fmt" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" ) @@ -64,67 +63,22 @@ func decodeNumberKey(key []byte) types.GlobalBlockNumber { return decodeKey(key[1:]) } -// Serialization version for blocks. -const blockSerializationVersion byte = 1 - -// Serialization version for QCs. -const qcSerializationVersion byte = 1 - -// blockValuePrefixLen is the fixed header preceding a block's proto bytes: one -// version byte followed by the 8-byte big-endian GlobalBlockNumber. -const blockValuePrefixLen = 1 + 8 - -// encodeBlock marshals a block to the bytes stored as its table value. The value -// is framed as [version:1][GlobalBlockNumber:8 big-endian][proto(Block)]. The -// number is embedded so a by-hash lookup — which reaches this same shared value -// through a secondary key that carries only the hash — can still recover it. -func encodeBlock(n types.GlobalBlockNumber, blk *types.Block) []byte { - proto := types.BlockConv.Marshal(blk) - value := make([]byte, 0, blockValuePrefixLen+len(proto)) - value = append(value, blockSerializationVersion) - value = binary.BigEndian.AppendUint64(value, uint64(n)) - value = append(value, proto...) - return value +// encodeBlock marshals a block to the bytes stored as its table value. +func encodeBlock(blk *types.Block) []byte { + return types.BlockConv.Marshal(blk) } -// decodeBlock unmarshals a block and its embedded GlobalBlockNumber from the -// value produced by encodeBlock. -func decodeBlock(value []byte) (types.GlobalBlockNumber, *types.Block, error) { - if len(value) < blockValuePrefixLen { - return 0, nil, fmt.Errorf("block value too short: %d bytes", len(value)) - } - if value[0] != blockSerializationVersion { - return 0, nil, fmt.Errorf("unsupported block serialization version %d", value[0]) - } - n := types.GlobalBlockNumber(binary.BigEndian.Uint64(value[1:blockValuePrefixLen])) - blk, err := types.BlockConv.Unmarshal(value[blockValuePrefixLen:]) - if err != nil { - return 0, nil, fmt.Errorf("failed to unmarshal block: %w", err) - } - return n, blk, nil +// decodeBlock unmarshals a block from its stored table value. +func decodeBlock(value []byte) (*types.Block, error) { + return types.BlockConv.Unmarshal(value) } -// encodeQC marshals a FullCommitQC to the bytes stored as its table value, -// framed as [version:1][proto(FullCommitQC)]. +// encodeQC marshals a FullCommitQC to the bytes stored as its table value. func encodeQC(qc *types.FullCommitQC) []byte { - proto := types.FullCommitQCConv.Marshal(qc) - value := make([]byte, 0, 1+len(proto)) - value = append(value, qcSerializationVersion) - value = append(value, proto...) - return value + return types.FullCommitQCConv.Marshal(qc) } -// decodeQC unmarshals a FullCommitQC from the value produced by encodeQC. +// decodeQC unmarshals a FullCommitQC from its stored table value. func decodeQC(value []byte) (*types.FullCommitQC, error) { - if len(value) < 1 { - return nil, fmt.Errorf("qc value too short: %d bytes", len(value)) - } - if value[0] != qcSerializationVersion { - return nil, fmt.Errorf("unsupported qc serialization version %d", value[0]) - } - qc, err := types.FullCommitQCConv.Unmarshal(value[1:]) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal qc: %w", err) - } - return qc, nil + return types.FullCommitQCConv.Unmarshal(value) } diff --git a/sei-db/ledger_db/block/littblock/codec_test.go b/sei-db/ledger_db/block/littblock/codec_test.go index 3c5aff9eca..4213425283 100644 --- a/sei-db/ledger_db/block/littblock/codec_test.go +++ b/sei-db/ledger_db/block/littblock/codec_test.go @@ -66,19 +66,15 @@ func TestPrefixedKeys(t *testing.T) { func TestBlockRoundTrip(t *testing.T) { rng := utils.TestRngFromSeed(1) - for i := range 16 { - n := types.GlobalBlockNumber(i) + for range 16 { blk := types.GenBlock(rng) - value := encodeBlock(n, blk) - require.Equal(t, blockSerializationVersion, value[0], "value must be version-prefixed") - gotN, decoded, err := decodeBlock(value) + value := encodeBlock(blk) + decoded, err := decodeBlock(value) require.NoError(t, err) - // The embedded block number must round-trip. - require.Equal(t, n, gotN) // Header hash uniquely identifies a block; equal hash => same block. require.Equal(t, blk.Header().Hash(), decoded.Header().Hash()) - // Re-encoding the decoded block (with the same number) must reproduce the same bytes. - require.Equal(t, value, encodeBlock(gotN, decoded)) + // Re-encoding the decoded block must reproduce the same bytes. + require.Equal(t, value, encodeBlock(decoded)) } } @@ -87,7 +83,6 @@ func TestQCRoundTrip(t *testing.T) { for range 16 { qc := types.GenFullCommitQC(rng) value := encodeQC(qc) - require.Equal(t, qcSerializationVersion, value[0], "value must be version-prefixed") decoded, err := decodeQC(value) require.NoError(t, err) // Re-encoding the decoded QC must reproduce the same bytes. @@ -96,26 +91,10 @@ func TestQCRoundTrip(t *testing.T) { } func TestDecodeRejectsGarbage(t *testing.T) { - // Invalid bytes must surface an error rather than a partial value. + // Invalid protobuf wire bytes must surface an error rather than a partial value. garbage := []byte{0xff, 0xff, 0xff, 0xff} - _, _, blockErr := decodeBlock(garbage) + _, blockErr := decodeBlock(garbage) require.Error(t, blockErr) _, qcErr := decodeQC(garbage) require.Error(t, qcErr) } - -func TestDecodeRejectsUnknownVersion(t *testing.T) { - rng := utils.TestRngFromSeed(3) - - // A block value whose version byte is not the current one must be rejected, - // even though the rest of the value is well-formed. - blockValue := encodeBlock(1, types.GenBlock(rng)) - blockValue[0] = blockSerializationVersion + 1 - _, _, blockErr := decodeBlock(blockValue) - require.Error(t, blockErr) - - qcValue := encodeQC(types.GenFullCommitQC(rng)) - qcValue[0] = qcSerializationVersion + 1 - _, qcErr := decodeQC(qcValue) - require.Error(t, qcErr) -} diff --git a/sei-db/ledger_db/block/littblock/litt_block_db.go b/sei-db/ledger_db/block/littblock/litt_block_db.go index 2882e7c219..97e0585e09 100644 --- a/sei-db/ledger_db/block/littblock/litt_block_db.go +++ b/sei-db/ledger_db/block/littblock/litt_block_db.go @@ -25,19 +25,8 @@ type blockDB struct { db littdb.DB table littdb.Table - // watermark is a retention floor, always a QC boundary (a GlobalRange().First): - // PruneBefore rounds a requested prune point down to the start of the cohort - // containing it, and startup re-derives it as the lowest surviving QC's First - // (see cohortStart and recoverReadWatermark). Keeping it on a cohort boundary - // is what makes a QC's blocks change readability atomically — the gate never - // splits a cohort. - // - // It serves two purposes: the GC filters treat keys strictly below it as - // eligible for reclamation, and reads and iterators refuse to serve any block - // strictly below it. The read gate is what upholds the "a served block is - // always covered by a served QC" invariant: asynchronous GC can strand a - // below-watermark block on disk (its covering QC reclaimed while the block's - // own segment lingers), but such a block is never served. Read from the GC + // watermark is the highest n passed to PruneBefore; the GC filters treat + // keys strictly below it as eligible for reclamation. Read from the GC // goroutine, so accessed atomically. watermark atomic.Uint64 @@ -47,9 +36,6 @@ type blockDB struct { lastBlockNumber types.GlobalBlockNumber hasQC bool lastQCNext types.GlobalBlockNumber - - // latestQCStartBlock is the most recently written QC's starting block number. - latestQCStartBlock types.GlobalBlockNumber } // NewBlockDB opens (or creates) a LittDB-backed types.BlockDB from config. The @@ -91,10 +77,6 @@ func NewBlockDB(config *LittBlockConfig) (types.BlockDB, error) { _ = db.Close() return nil, fmt.Errorf("failed to recover write cursors: %w", err) } - if err := s.recoverReadWatermark(); err != nil { - _ = db.Close() - return nil, fmt.Errorf("failed to recover read watermark: %w", err) - } return s, nil } @@ -138,7 +120,6 @@ func (s *blockDB) recoverCursors() error { if err != nil { return fmt.Errorf("failed to unmarshal newest qc: %w", err) } - s.latestQCStartBlock = lowerBound s.lastQCNext = lowerBound + types.GlobalBlockNumber(len(qc.Headers())) s.hasQC = true } @@ -147,44 +128,6 @@ func (s *blockDB) recoverCursors() error { return nil } -// recoverReadWatermark re-derives a safe read watermark on open. The watermark -// is in-memory only, so a restart forgets every PruneBefore. That is fine for -// reclamation (nothing new is deleted), but we must protect against showing un-pruned -// blocks with pruned QCs. -func (s *blockDB) recoverReadWatermark() error { - it, err := s.table.Iterator(false) - if err != nil { - return fmt.Errorf("failed to open watermark recovery iterator: %w", err) - } - defer func() { _ = it.Close() }() - - for { - ok, err := it.Next() - if err != nil { - return fmt.Errorf("failed to advance watermark recovery iterator: %w", err) - } - if !ok { - break - } - key, isPrimary := it.GetKey() - if !isPrimary || keyKind(key) != kindQC { - continue - } - s.watermark.Store(uint64(decodeNumberKey(key))) - return nil - } - - if s.hasBlocks { - // No QC survives. The never-empty prune invariant guarantees at least one - // (block, QC) pair is always retained, so blocks-without-QC is unreachable - // through normal operation — it means the store is corrupt (e.g. a QC WAL - // file was removed out of band). Refuse to open rather than serve blocks we - // can no longer trust. - return fmt.Errorf("corrupt store: newest block %d has no surviving QC covering it", s.lastBlockNumber) - } - return nil -} - func (s *blockDB) WriteBlock(n types.GlobalBlockNumber, blk *types.Block) error { s.mu.Lock() defer s.mu.Unlock() @@ -201,7 +144,7 @@ func (s *blockDB) WriteBlock(n types.GlobalBlockNumber, blk *types.Block) error n, s.lastQCNext, types.ErrBlockMissingQC) } - value := encodeBlock(n, blk) + value := encodeBlock(blk) hash := blk.Header().Hash() hashAlias := &litttypes.SecondaryKey{ Key: blockHashKey(hash), @@ -246,56 +189,21 @@ func (s *blockDB) WriteQC( return fmt.Errorf("failed to put QC [%d,%d): %w", lowerBound, upperBound, err) } - s.latestQCStartBlock = lowerBound s.lastQCNext = upperBound s.hasQC = true return nil } -func (s *blockDB) PruneBefore(blockHeight types.GlobalBlockNumber) error { - s.mu.Lock() - defer s.mu.Unlock() - - if !s.hasBlocks { - // Ignore prune requests if we've not got any data yet. Simplifies several edge cases - // and is technically a legal implementation of the contract in the godocs. - return nil - } - - if ceiling := min(s.latestQCStartBlock, s.lastBlockNumber); blockHeight > ceiling { - blockHeight = ceiling - } - - // Round the watermark down to the start of a QC's range, to avoid pruning a QC before its blocks. - blockHeight, err := s.clampPruneBoundary(blockHeight) - if err != nil { - return err - } - - // Advance the watermark monotonically. mu serializes writers and PruneBefore - // is the only one, so a plain load/store suffices; the field stays atomic - // only because the GC filter and readers load it without holding mu. - if uint64(blockHeight) > s.watermark.Load() { - s.watermark.Store(uint64(blockHeight)) - } - return nil -} - -// clampPruneBoundary returns the start of the QC that covers n, or n if there is no QC covering N -// (which can happen if you prune the same n twice). -func (s *blockDB) clampPruneBoundary(blockHeight types.GlobalBlockNumber) (types.GlobalBlockNumber, error) { - value, exists, err := s.table.Get(qcKey(blockHeight)) - if err != nil { - return 0, fmt.Errorf("failed to read covering QC for %d: %w", blockHeight, err) - } - if !exists { - return blockHeight, nil - } - qc, err := decodeQC(value) - if err != nil { - return 0, fmt.Errorf("failed to decode covering QC for %d: %w", blockHeight, err) +func (s *blockDB) PruneBefore(n types.GlobalBlockNumber) error { + for { + cur := s.watermark.Load() + if uint64(n) <= cur { + return nil + } + if s.watermark.CompareAndSwap(cur, uint64(n)) { + return nil + } } - return qc.QC().GlobalRange().First, nil } // gcFilter marks a key in the shared ledger table as reclaimable, dispatching on @@ -333,7 +241,7 @@ func (s *blockDB) Blocks(reverse bool) (types.BlockIterator, error) { if err != nil { return nil, fmt.Errorf("failed to open blocks iterator: %w", err) } - return &blockIterator{it: it, watermark: s.watermark.Load()}, nil + return &blockIterator{it: it}, nil } func (s *blockDB) QCs(reverse bool) (types.QCIterator, error) { @@ -341,59 +249,39 @@ func (s *blockDB) QCs(reverse bool) (types.QCIterator, error) { if err != nil { return nil, fmt.Errorf("failed to open qcs iterator: %w", err) } - return &qcIterator{it: it, watermark: s.watermark.Load()}, nil + return &qcIterator{it: it}, nil } -func (s *blockDB) ReadBlockByNumber(n types.GlobalBlockNumber) (utils.Option[*types.Block], error) { - // Refuse below-watermark blocks: they may be stranded (covering QC reclaimed). - if uint64(n) < s.watermark.Load() { - return utils.None[*types.Block](), types.ErrPruned - } - result, err := getBlock(s.table, blockKey(n)) - if err != nil { - return utils.None[*types.Block](), err - } - if bwn, ok := result.Get(); ok { - return utils.Some(bwn.Block), nil - } - return utils.None[*types.Block](), nil +func (s *blockDB) ReadBlockByNumber( + n types.GlobalBlockNumber, +) (utils.Option[*types.Block], error) { + return getBlock(s.table, blockKey(n)) } -func (s *blockDB) ReadBlockByHash(hash types.BlockHeaderHash) (utils.Option[types.BlockWithNumber], error) { - result, err := getBlock(s.table, blockHashKey(hash)) - if err != nil { - return utils.None[types.BlockWithNumber](), err - } - // The number is not known until the block is resolved; refuse it if it turns - // out to be below the watermark (potentially stranded from its covering QC). - if bwn, ok := result.Get(); ok && uint64(bwn.Number) < s.watermark.Load() { - return utils.None[types.BlockWithNumber](), nil - } - return result, nil +func (s *blockDB) ReadBlockByHash( + hash types.BlockHeaderHash, +) (utils.Option[*types.Block], error) { + return getBlock(s.table, blockHashKey(hash)) } -func getBlock(table littdb.Table, key []byte) (utils.Option[types.BlockWithNumber], error) { +func getBlock(table littdb.Table, key []byte) (utils.Option[*types.Block], error) { value, exists, err := table.Get(key) if err != nil { - return utils.None[types.BlockWithNumber](), fmt.Errorf("failed to read block: %w", err) + return utils.None[*types.Block](), fmt.Errorf("failed to read block: %w", err) } if !exists { - return utils.None[types.BlockWithNumber](), nil + return utils.None[*types.Block](), nil } - n, blk, err := decodeBlock(value) + blk, err := decodeBlock(value) if err != nil { - return utils.None[types.BlockWithNumber](), fmt.Errorf("failed to unmarshal block: %w", err) + return utils.None[*types.Block](), fmt.Errorf("failed to unmarshal block: %w", err) } - return utils.Some(types.BlockWithNumber{Block: blk, Number: n}), nil + return utils.Some(blk), nil } func (s *blockDB) ReadQCByBlockNumber( n types.GlobalBlockNumber, ) (utils.Option[*types.FullCommitQC], error) { - // Below-watermark blocks are not served, so neither is their covering QC. - if uint64(n) < s.watermark.Load() { - return utils.None[*types.FullCommitQC](), types.ErrPruned - } value, exists, err := s.table.Get(qcKey(n)) if err != nil { return utils.None[*types.FullCommitQC](), fmt.Errorf("failed to read QC: %w", err) diff --git a/sei-db/ledger_db/block/littblock/litt_block_iterator.go b/sei-db/ledger_db/block/littblock/litt_block_iterator.go index 28ba7f2df8..72460ee8b3 100644 --- a/sei-db/ledger_db/block/littblock/litt_block_iterator.go +++ b/sei-db/ledger_db/block/littblock/litt_block_iterator.go @@ -14,12 +14,9 @@ var ( // blockIterator wraps a litt iterator over the shared ledger table, yielding one // entry per block: it skips QC keys and the secondary (hash-alias) keys, keeping -// only primary block-number keys. It also skips blocks strictly below watermark, -// which may be stranded from their covering QC (see blockDB.watermark); the -// watermark is captured when the iterator is created. +// only primary block-number keys. type blockIterator struct { - it littdb.Iterator - watermark uint64 + it littdb.Iterator } func (b *blockIterator) Next() (bool, error) { @@ -31,14 +28,9 @@ func (b *blockIterator) Next() (bool, error) { if !ok { return false, nil } - key, isPrimary := b.it.GetKey() - if !isPrimary || keyKind(key) != kindBlock { - continue - } - if uint64(decodeNumberKey(key)) < b.watermark { - continue + if key, isPrimary := b.it.GetKey(); isPrimary && keyKind(key) == kindBlock { + return true, nil } - return true, nil } } @@ -52,7 +44,7 @@ func (b *blockIterator) Block() (*types.Block, error) { if err != nil { return nil, fmt.Errorf("failed to read block value: %w", err) } - _, blk, err := decodeBlock(value) + blk, err := decodeBlock(value) if err != nil { return nil, fmt.Errorf("failed to unmarshal block: %w", err) } @@ -68,13 +60,9 @@ func (b *blockIterator) Close() error { // qcIterator wraps a litt iterator over the shared ledger table, yielding one // entry per QC: it skips block keys and the secondary (covered-number) keys, -// keeping only primary QC keys. It also skips any QC whose entire covered range -// is strictly below watermark (Next <= watermark), since none of its blocks are -// served; a QC straddling the watermark still covers served blocks and is kept. -// The watermark is captured when the iterator is created. +// keeping only primary QC keys. type qcIterator struct { - it littdb.Iterator - watermark uint64 + it littdb.Iterator } func (q *qcIterator) Next() (bool, error) { @@ -86,22 +74,7 @@ func (q *qcIterator) Next() (bool, error) { if !ok { return false, nil } - key, isPrimary := q.it.GetKey() - if !isPrimary || keyKind(key) != kindQC { - continue - } - // The First of this QC is its primary-key number. If First >= watermark - // the whole range is served; otherwise decode the value to learn Next and - // keep it only if it straddles the watermark (covers a served block). - if uint64(decodeNumberKey(key)) >= q.watermark { - return true, nil - } - qc, err := q.QC() - if err != nil { - return false, err - } - next := uint64(decodeNumberKey(key)) + uint64(len(qc.Headers())) - if next > q.watermark { + if key, isPrimary := q.it.GetKey(); isPrimary && keyKind(key) == kindQC { return true, nil } } diff --git a/sei-db/ledger_db/block/littblock/litt_block_stranding_test.go b/sei-db/ledger_db/block/littblock/litt_block_stranding_test.go deleted file mode 100644 index 423018b342..0000000000 --- a/sei-db/ledger_db/block/littblock/litt_block_stranding_test.go +++ /dev/null @@ -1,346 +0,0 @@ -package littblock - -import ( - "math" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" - "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" -) - -// strandingConfig builds a config whose only segment-rollover trigger is a small -// MaxSegmentKeyCount, so a test can place a segment boundary at a precise key. -// Retention is tiny (the prune watermark is the sole reclamation gate) and GC is -// effectively background-disabled so ForceGC is the only thing that reclaims. -func strandingConfig(t *testing.T, dir string, maxSegmentKeyCount uint32) *LittBlockConfig { - cfg, err := DefaultConfig(dir) - require.NoError(t, err) - cfg.Retention = time.Nanosecond - cfg.Litt.TargetSegmentFileSize = math.MaxUint32 - cfg.Litt.MaxSegmentKeyCount = maxSegmentKeyCount - cfg.Litt.GCPeriod = time.Hour - cfg.Litt.Fsync = false - return cfg -} - -// writeSyntheticBatches writes numBatches contiguous batches of perQC blocks each -// (global numbers 0.., QC ranges [0,perQC), [perQC,2*perQC), ...). Each QC's own -// GlobalRange matches the range it is written at, so littblock's clamp can trust -// it; littblock does not verify signatures, so no committee is needed. -func writeSyntheticBatches(t *testing.T, db types.BlockDB, rng utils.Rng, numBatches int, perQC int) { - for i := 0; i < numBatches; i++ { - first := types.GlobalBlockNumber(i * perQC) //nolint:gosec // small test indices - next := first + types.GlobalBlockNumber(perQC) - qc := types.GenFullCommitQCRange(rng, first, next) - require.NoError(t, db.WriteQC(first, next, qc)) - for j := 0; j < perQC; j++ { - require.NoError(t, db.WriteBlock(first+types.GlobalBlockNumber(j), types.GenBlock(rng))) //nolint:gosec - } - } -} - -// physicallyPresent reports whether a key exists in the raw table, bypassing the -// read-watermark gate. Used to distinguish a record that has been physically -// reclaimed from one that is present on disk but refused by the watermark. -func physicallyPresent(t *testing.T, impl *blockDB, key []byte) bool { - _, exists, err := impl.table.Get(key) - require.NoError(t, err) - return exists -} - -// TestLittblockStrandedBlockNotServedAfterRestart is the cross-segment stranding -// regression. GC reclaims a contiguous prefix of segments in write order, and a -// QC is always written before the blocks it covers, so a QC's covered range can -// straddle a segment boundary and its segment can be reclaimed while a later -// segment still holds some covered blocks — leaving those blocks on disk with no -// covering QC. Because the prune watermark is in-memory only, a restart forgets -// it and a naive store would re-serve the stranded blocks. -// -// With MaxSegmentKeyCount = 8 the QC Put (5 keys) plus the first two block Puts -// (2 keys each) fill segment 0 = {QC[0,5), b0, b1}; the remaining covered blocks -// spill into segment 1 = {b2, b3, b4, QC[5,10)}. Pruning to 5 makes segment 0 -// collectable (every key < 5) while segment 1 is pinned by QC[5,10) (key 5), so -// QC[0,5) is reclaimed but blocks 2..4 survive, stranded. -func TestLittblockStrandedBlockNotServedAfterRestart(t *testing.T) { - dir := t.TempDir() - rng := utils.TestRngFromSeed(1) - - db, err := NewBlockDB(strandingConfig(t, dir, 8)) - require.NoError(t, err) - writeSyntheticBatches(t, db, rng, 4, 5) // blocks 0..19; QCs [0,5),[5,10),[10,15),[15,20) - require.NoError(t, db.Flush()) - require.NoError(t, db.Close()) - - // Reopen so the segments seal, then prune past the first QC and collect. - db2, err := NewBlockDB(strandingConfig(t, dir, 8)) - require.NoError(t, err) - require.NoError(t, db2.PruneBefore(5)) - require.NoError(t, ForceGC(db2)) - require.NoError(t, db2.Flush()) - require.NoError(t, db2.Close()) - - // Reopen with the in-memory watermark forgotten: this is where a store that - // did not re-derive the watermark would re-serve the stranded blocks 2..4. - db3, err := NewBlockDB(strandingConfig(t, dir, 8)) - require.NoError(t, err) - defer func() { _ = db3.Close() }() - impl := db3.(*blockDB) - - // The stranding really materialized on disk: block 2 is physically present - // but its covering QC's key is gone (reclaimed with segment 0). Blocks 0 and - // 1 were in the reclaimed segment and are physically gone. - require.True(t, physicallyPresent(t, impl, blockKey(2)), "block 2 must be physically stranded on disk") - require.False(t, physicallyPresent(t, impl, qcKey(2)), "covering QC key for block 2 must be reclaimed") - require.False(t, physicallyPresent(t, impl, blockKey(0)), "block 0 must be physically reclaimed") - require.False(t, physicallyPresent(t, impl, blockKey(1)), "block 1 must be physically reclaimed") - - // The watermark is re-derived as the lowest surviving QC's First. - require.Equal(t, uint64(5), impl.watermark.Load(), "recovered watermark must be the lowest surviving QC's First") - - // The gate refuses the stranded blocks and their (absent) QCs. - for n := types.GlobalBlockNumber(0); n < 5; n++ { - blk, err := db3.ReadBlockByNumber(n) - require.ErrorIs(t, err, types.ErrPruned, "stranded/pruned block %d must be reported pruned", n) - require.False(t, blk.IsPresent(), "stranded/pruned block %d must not be served", n) - qc, err := db3.ReadQCByBlockNumber(n) - require.ErrorIs(t, err, types.ErrPruned, "QC for pruned block %d must be reported pruned", n) - require.False(t, qc.IsPresent(), "QC for pruned block %d must not be served", n) - } - - // Every served block has a readable covering QC (the invariant). - for n := types.GlobalBlockNumber(5); n < 20; n++ { - blk, err := db3.ReadBlockByNumber(n) - require.NoError(t, err) - require.True(t, blk.IsPresent(), "block %d must be served", n) - qc, err := db3.ReadQCByBlockNumber(n) - require.NoError(t, err) - require.True(t, qc.IsPresent(), "covering QC for served block %d must be readable", n) - } - - // The block iterator never yields a stranded block, and each yielded block - // has a covering QC. - it, err := db3.Blocks(false) - require.NoError(t, err) - defer func() { _ = it.Close() }() - for { - ok, err := it.Next() - require.NoError(t, err) - if !ok { - break - } - n := it.Number() - require.GreaterOrEqual(t, uint64(n), uint64(5), "iterator must not yield stranded block %d", n) - qc, err := db3.ReadQCByBlockNumber(n) - require.NoError(t, err) - require.True(t, qc.IsPresent(), "iterated block %d must have a covering QC", n) - } -} - -// TestLittblockReclaimsAcrossRestart verifies the durable reclamation path with a -// white-box check on the raw table: data written, then pruned after a restart -// (which seals the segments it landed in), is physically collected by GC. A -// raw-table check is required because the read watermark now refuses -// below-watermark reads regardless of physical reclamation, so public reads alone -// can no longer distinguish "reclaimed" from "gated". -// -// PruneBefore(20) requests pruning past every block, but the never-empty -// invariant clamps it to the newest block's cohort — the QC[15,20) range, whose -// first is 15. Blocks 15..19 and QC[15,20) stay readable, pinning their segments -// (seg5={QC[15,20),b15,b16}, seg6={b17,b18,b19}) against GC. Every fully-below -// segment — blocks 0..14 and QCs [0,5),[5,10),[10,15) — is reclaimed. -func TestLittblockReclaimsAcrossRestart(t *testing.T) { - dir := t.TempDir() - rng := utils.TestRngFromSeed(2) - - db, err := NewBlockDB(strandingConfig(t, dir, 8)) - require.NoError(t, err) - writeSyntheticBatches(t, db, rng, 4, 5) // blocks 0..19 - require.NoError(t, db.Flush()) - require.NoError(t, db.Close()) - - // Reopen: the segments written above are now sealed and collectable. - db2, err := NewBlockDB(strandingConfig(t, dir, 8)) - require.NoError(t, err) - defer func() { _ = db2.Close() }() - impl := db2.(*blockDB) - - require.NoError(t, db2.PruneBefore(20)) // past every block; capped to 19 (never-empty) - require.NoError(t, ForceGC(db2)) - - // Blocks 0..14 and their QC keys are physically reclaimed. - for n := types.GlobalBlockNumber(0); n < 15; n++ { - require.False(t, physicallyPresent(t, impl, blockKey(n)), "block %d must be reclaimed", n) - require.False(t, physicallyPresent(t, impl, qcKey(n)), "QC key %d must be reclaimed", n) - } - - // The newest block and its covering QC survive the capped prune: the cap - // pins block 19's segment and the straddling QC[15,20) against GC. - for n := types.GlobalBlockNumber(15); n < 20; n++ { - require.True(t, physicallyPresent(t, impl, blockKey(n)), "block %d must survive the capped prune", n) - } - require.True(t, physicallyPresent(t, impl, qcKey(15)), "covering QC[15,20) must survive the capped prune") - - // The whole newest cohort is served through the public API: the clamp lands - // on the cohort's first (15), so blocks 15..19 and their covering QC are - // readable, while block 14 (just below the cohort) is refused. - for n := types.GlobalBlockNumber(15); n < 20; n++ { - blk, err := db2.ReadBlockByNumber(n) - require.NoError(t, err) - require.True(t, blk.IsPresent(), "cohort block %d must remain readable after a prune-to-empty request", n) - qc, err := db2.ReadQCByBlockNumber(n) - require.NoError(t, err) - require.True(t, qc.IsPresent(), "QC covering cohort block %d must remain readable", n) - } - below, err := db2.ReadBlockByNumber(14) - require.ErrorIs(t, err, types.ErrPruned, "block 14 below the newest cohort must be reported pruned") - require.False(t, below.IsPresent(), "block 14 below the newest cohort must not be served") -} - -// TestLittblockPruneIntoCohortRoundsDown pins the cohort-atomicity rule: a prune -// point that lands strictly inside a QC's range rounds the watermark DOWN to the -// cohort's start, so the whole cohort — every block the QC covers — stays both -// physically retained and readable. A QC's blocks change readability atomically; -// the watermark never splits a cohort. -// -// Because a QC is always written before the blocks it covers, its segment sits -// at or before every block in its range in write order, and GC reclaims only a -// contiguous write-order prefix of segments — so pinning the QC's segment pins -// every later segment too, retaining the whole range on disk. -// -// The test is written so it would FAIL if the rule were broken: -// - It first asserts the fully-below segment (seg0) IS reclaimed. LittDB never -// GCs the live mutable file, so this is what proves GC actually ran with -// teeth on this data — without it the retention assertions below would hold -// vacuously (nothing ever collected). The write→flush→close→reopen dance -// seals the mutable file the data landed in so GC can reach it. -// - If the watermark did NOT round down (landing at 7 instead of the cohort -// start 5), reads of blocks 5 and 6 would be refused — splitting the cohort — -// and the read-semantics assertions would fail. -// -// Layout (MaxSegmentKeyCount = 8, as in TestLittblockStrandedBlockNotServedAfterRestart): -// seg0 = {QC[0,5), b0, b1}, seg1 = {b2, b3, b4, QC[5,10)}, seg2 = {b5, b6, b7, b8}, -// seg3 = {b9, ...}. Pruning to 7 rounds down to the QC[5,10) cohort start (5): -// seg0 is fully below the watermark and reclaimed, while QC[5,10)'s key 5 pins -// seg1 and every segment after it — blocks 5..9 all survive. -func TestLittblockPruneIntoCohortRoundsDown(t *testing.T) { - dir := t.TempDir() - rng := utils.TestRngFromSeed(3) - - db, err := NewBlockDB(strandingConfig(t, dir, 8)) - require.NoError(t, err) - writeSyntheticBatches(t, db, rng, 4, 5) // blocks 0..19; QC[5,10) covers blocks 5..9 - require.NoError(t, db.Flush()) - require.NoError(t, db.Close()) - - // Reopen to seal the mutable file the data landed in, then prune to 7 — - // strictly inside QC[5,10), which must round the watermark down to 5. - db2, err := NewBlockDB(strandingConfig(t, dir, 8)) - require.NoError(t, err) - defer func() { _ = db2.Close() }() - impl := db2.(*blockDB) - - require.NoError(t, db2.PruneBefore(7)) - require.Equal(t, uint64(5), impl.watermark.Load(), "prune into QC[5,10) must round the watermark down to 5") - require.NoError(t, ForceGC(db2)) - - // GC actually had teeth: the fully-below segment (seg0 = {QC[0,5), b0, b1}) is - // reclaimed. If this fails, the data never made it out of the mutable file and - // the retention assertions below would be vacuous. - require.False(t, physicallyPresent(t, impl, blockKey(0)), "block 0 (fully below watermark) must be reclaimed") - require.False(t, physicallyPresent(t, impl, blockKey(1)), "block 1 (fully below watermark) must be reclaimed") - require.False(t, physicallyPresent(t, impl, qcKey(0)), "QC[0,5) (entirely below watermark) must be reclaimed") - - // The cohort's QC[5,10) is not reclaimed: its key 5 is at the watermark, so - // its segment is pinned. - require.True(t, physicallyPresent(t, impl, qcKey(5)), "cohort QC[5,10) primary key must survive") - require.True(t, physicallyPresent(t, impl, qcKey(9)), "cohort QC[5,10) secondary key must survive") - - // The whole covered range survives on disk. Pinning the QC's segment pins the - // whole range. - for n := types.GlobalBlockNumber(5); n < 10; n++ { - require.True(t, physicallyPresent(t, impl, blockKey(n)), - "block %d in the cohort must be retained on disk", n) - } - - // Read semantics: the watermark rounded down to the cohort start (5), so the - // whole cohort [5,10) is served — never split at 7 — and every served block - // has a readable covering QC. - for n := types.GlobalBlockNumber(5); n < 10; n++ { - blk, err := db2.ReadBlockByNumber(n) - require.NoError(t, err, "block %d in the cohort must be served", n) - require.True(t, blk.IsPresent(), "block %d in the cohort must be served", n) - qc, err := db2.ReadQCByBlockNumber(n) - require.NoError(t, err) - require.True(t, qc.IsPresent(), "covering QC for served block %d must be readable", n) - } - // The fully-below cohort [0,5) is pruned. - for n := types.GlobalBlockNumber(0); n < 5; n++ { - blk, err := db2.ReadBlockByNumber(n) - require.ErrorIs(t, err, types.ErrPruned, "block %d below the cohort must be reported pruned", n) - require.False(t, blk.IsPresent(), "block %d below the cohort must not be served", n) - } -} - -// TestLittblockRefusesToOpenWithStrandedBlocks verifies the corruption guard in -// recoverReadWatermark. The never-empty prune invariant guarantees at least one -// (block, QC) pair is always retained, so a store holding a block with no -// surviving QC is corrupt (e.g. a QC WAL file removed out of band). Rather than -// serve blocks it can no longer trust, the store refuses to open. -// -// The state is unreachable through the public API — WriteBlock rejects an -// uncovered block, and pruning never reclaims the newest cohort's QC — so the -// test injects it directly: a block primary key written straight to the raw -// table with no covering QC, exactly the on-disk shape corruption leaves behind. -func TestLittblockRefusesToOpenWithStrandedBlocks(t *testing.T) { - dir := t.TempDir() - rng := utils.TestRngFromSeed(3) - - db, err := NewBlockDB(strandingConfig(t, dir, 8)) - require.NoError(t, err) - impl := db.(*blockDB) - - // Bypass WriteBlock's QC guard and write a lone block to the raw table. - require.NoError(t, impl.table.Put(blockKey(5), encodeBlock(5, types.GenBlock(rng)))) - require.NoError(t, db.Flush()) - require.NoError(t, db.Close()) - - // Reopen: recovery finds the block but no QC, so it refuses to open. - _, err = NewBlockDB(strandingConfig(t, dir, 8)) - require.Error(t, err) - require.ErrorContains(t, err, "no surviving QC") -} - -// TestLittblockEmptyStorePruneDoesNotReclaimLaterWrites is the regression for the -// empty-store prune bug: a PruneBefore on a store with no blocks must not advance -// the watermark, or blocks later written below the requested point would be -// refused by the read gate and physically reclaimed by GC. The watermark must -// never outrun the data it protects. -func TestLittblockEmptyStorePruneDoesNotReclaimLaterWrites(t *testing.T) { - dir := t.TempDir() - rng := utils.TestRngFromSeed(4) - - db, err := NewBlockDB(strandingConfig(t, dir, 8)) - require.NoError(t, err) - defer func() { _ = db.Close() }() - impl := db.(*blockDB) - - // Prune well past where we are about to write, while the store is still empty. - require.NoError(t, db.PruneBefore(1000)) - require.Equal(t, uint64(0), impl.watermark.Load(), "empty-store prune must not advance the watermark") - - // Write blocks 0..9 (below the pruned point) and force a GC pass. - writeSyntheticBatches(t, db, rng, 2, 5) // blocks 0..9; QCs [0,5),[5,10) - require.NoError(t, ForceGC(db)) - require.NoError(t, db.Flush()) - - // Every written block survives GC on disk and is served by the read gate. - for n := types.GlobalBlockNumber(0); n < 10; n++ { - require.True(t, physicallyPresent(t, impl, blockKey(n)), "block %d must survive GC", n) - blk, err := db.ReadBlockByNumber(n) - require.NoError(t, err) - require.True(t, blk.IsPresent(), "block %d must be served", n) - } -} diff --git a/sei-db/ledger_db/block/memblock/mem_block_db.go b/sei-db/ledger_db/block/memblock/mem_block_db.go index 8fb1e97243..ff631ef7a7 100644 --- a/sei-db/ledger_db/block/memblock/mem_block_db.go +++ b/sei-db/ledger_db/block/memblock/mem_block_db.go @@ -19,21 +19,13 @@ type qcEntry struct { upper types.GlobalBlockNumber } -// hashEntry pairs a block with its GlobalBlockNumber so ReadBlockByHash can -// return the number, mirroring the littblock implementation which embeds it in -// the stored value. -type hashEntry struct { - blk *types.Block - n types.GlobalBlockNumber -} - // blockDB is an in-memory types.BlockDB. It holds blocks and QCs by pointer (no // marshaling) and is intended as a test/benchmark fixture, not a durable // implementation. type blockDB struct { mu sync.RWMutex byNumber map[types.GlobalBlockNumber]*types.Block - byHash map[types.BlockHeaderHash]hashEntry + byHash map[types.BlockHeaderHash]*types.Block qcsByLower map[types.GlobalBlockNumber]qcEntry // Write-order cursors (see types.BlockDB contract). @@ -41,24 +33,13 @@ type blockDB struct { lastBlockNumber types.GlobalBlockNumber hasQC bool lastQCNext types.GlobalBlockNumber - - // latestQCStartBlock is the most recently written QC's starting block number — - // the lowest block number in the newest cohort. PruneBefore clamps to it (see - // littblock). - latestQCStartBlock types.GlobalBlockNumber - - // watermark is the (clamped) retention floor set by PruneBefore. Reads - // strictly below it are refused with types.ErrPruned; because pruned entries - // are deleted eagerly, this is the only record of where the floor sits and - // so the only way to tell a pruned block from one never written. - watermark types.GlobalBlockNumber } // NewBlockDB returns an in-memory types.BlockDB. func NewBlockDB() types.BlockDB { return &blockDB{ byNumber: make(map[types.GlobalBlockNumber]*types.Block), - byHash: make(map[types.BlockHeaderHash]hashEntry), + byHash: make(map[types.BlockHeaderHash]*types.Block), qcsByLower: make(map[types.GlobalBlockNumber]qcEntry), } } @@ -77,7 +58,7 @@ func (s *blockDB) WriteBlock(n types.GlobalBlockNumber, blk *types.Block) error n, s.lastQCNext, types.ErrBlockMissingQC) } s.byNumber[n] = blk - s.byHash[blk.Header().Hash()] = hashEntry{blk: blk, n: n} + s.byHash[blk.Header().Hash()] = blk s.lastBlockNumber = n s.hasBlocks = true return nil @@ -98,7 +79,6 @@ func (s *blockDB) WriteQC( lowerBound, s.lastQCNext, types.ErrQCNonContiguous) } s.qcsByLower[lowerBound] = qcEntry{qc: qc, lower: lowerBound, upper: upperBound} - s.latestQCStartBlock = lowerBound s.lastQCNext = upperBound s.hasQC = true return nil @@ -107,38 +87,14 @@ func (s *blockDB) WriteQC( func (s *blockDB) PruneBefore(n types.GlobalBlockNumber) error { s.mu.Lock() defer s.mu.Unlock() - if !s.hasBlocks { - // No blocks yet: nothing to prune, and deleting QCs here would strand a - // future block whose coverage check still passes. Mirrors littblock. - return nil - } - // Never let the watermark enter the newest block's cohort: clamp its ceiling - // at the cohort's first block (latestQCStartBlock), guarded by lastBlockNumber - // for a QC written ahead of its blocks. Keeps the newest cohort whole and - // pruning monotonic. See littblock and the BlockDB PruneBefore contract. - if ceiling := min(s.latestQCStartBlock, s.lastBlockNumber); n > ceiling { - n = ceiling - } - // Round the watermark down to the covering QC's First. A QC's cohort of - // blocks changes readability atomically, so the watermark must never fall - // strictly inside a QC's range (see littblock): otherwise a read would - // refuse the cohort's low blocks while still serving its high blocks (which - // pruning must retain). - for _, e := range s.qcsByLower { - if e.lower <= n && n < e.upper { - n = e.lower - break - } - } - s.watermark = max(s.watermark, n) for num, blk := range s.byNumber { - if num < s.watermark { + if num < n { delete(s.byNumber, num) delete(s.byHash, blk.Header().Hash()) } } for lower, e := range s.qcsByLower { - if e.upper <= s.watermark { + if e.upper <= n { delete(s.qcsByLower, lower) } } @@ -229,9 +185,6 @@ func (s *blockDB) ReadBlockByNumber( ) (utils.Option[*types.Block], error) { s.mu.RLock() defer s.mu.RUnlock() - if n < s.watermark { - return utils.None[*types.Block](), types.ErrPruned - } if blk, ok := s.byNumber[n]; ok { return utils.Some(blk), nil } @@ -240,13 +193,13 @@ func (s *blockDB) ReadBlockByNumber( func (s *blockDB) ReadBlockByHash( hash types.BlockHeaderHash, -) (utils.Option[types.BlockWithNumber], error) { +) (utils.Option[*types.Block], error) { s.mu.RLock() defer s.mu.RUnlock() - if e, ok := s.byHash[hash]; ok { - return utils.Some(types.BlockWithNumber{Block: e.blk, Number: e.n}), nil + if blk, ok := s.byHash[hash]; ok { + return utils.Some(blk), nil } - return utils.None[types.BlockWithNumber](), nil + return utils.None[*types.Block](), nil } func (s *blockDB) ReadQCByBlockNumber( @@ -254,9 +207,6 @@ func (s *blockDB) ReadQCByBlockNumber( ) (utils.Option[*types.FullCommitQC], error) { s.mu.RLock() defer s.mu.RUnlock() - if n < s.watermark { - return utils.None[*types.FullCommitQC](), types.ErrPruned - } for _, e := range s.qcsByLower { if e.lower <= n && n < e.upper { return utils.Some(e.qc), nil diff --git a/sei-db/seiwal/metrics.go b/sei-db/seiwal/metrics.go deleted file mode 100644 index 30ebef81b3..0000000000 --- a/sei-db/seiwal/metrics.go +++ /dev/null @@ -1,96 +0,0 @@ -package seiwal - -import ( - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/metric" - - commonmetrics "github.com/sei-protocol/sei-chain/sei-db/common/metrics" -) - -// The name of the OpenTelemetry meter for WAL metrics. -const walMeterName = "seidb_seiwal" - -// Instruments are shared process-wide (created once); individual WAL instances are distinguished by the -// "wal" attribute attached at each recording (see walNameAttr), mirroring LittDB's per-table labeling. This -// keeps metrics from multiple instances in one process from clobbering each other. -var ( - walMeter = otel.Meter(walMeterName) - - // The number of records appended to the WAL. - walRecordsWritten = must(walMeter.Int64Counter( - "seiwal_records_written", - metric.WithDescription("Number of records appended to the WAL"), - metric.WithUnit("{count}"), - )) - - // The number of record bytes appended to the WAL (including framing). - walBytesWritten = must(walMeter.Int64Counter( - "seiwal_bytes_written", - metric.WithDescription("Number of bytes written to the WAL"), - metric.WithUnit("By"), - )) - - // The number of WAL files sealed (rotated) after reaching the target size. - walFilesSealed = must(walMeter.Int64Counter( - "seiwal_files_sealed", - metric.WithDescription("Number of WAL files sealed on rotation"), - metric.WithUnit("{count}"), - )) - - // The number of sealed WAL files deleted by pruning. - walFilesPruned = must(walMeter.Int64Counter( - "seiwal_files_pruned", - metric.WithDescription("Number of WAL files removed by pruning"), - metric.WithUnit("{count}"), - )) - - // The time spent serializing a payload in the generic serializing WAL. - walSerializeDuration = must(walMeter.Float64Histogram( - "seiwal_serialize_duration_seconds", - metric.WithDescription("Time spent serializing a payload in the generic WAL"), - metric.WithUnit("s"), - metric.WithExplicitBucketBoundaries(commonmetrics.LatencyBuckets...), - )) - - // The number of payload bytes produced by serialization in the generic serializing WAL. - walSerializedBytes = must(walMeter.Int64Counter( - "seiwal_serialized_bytes", - metric.WithDescription("Number of payload bytes produced by serialization in the generic WAL"), - metric.WithUnit("By"), - )) - - // The number of serialization failures in the generic serializing WAL. - walSerializeErrors = must(walMeter.Int64Counter( - "seiwal_serialize_errors", - metric.WithDescription("Number of serialization failures in the generic WAL"), - metric.WithUnit("{count}"), - )) - - // The buffered depth of a WAL's internal channel, sampled periodically. - walQueueDepth = must(walMeter.Int64Gauge( - "seiwal_queue_depth", - metric.WithDescription("Buffered depth of a WAL internal channel, sampled periodically"), - metric.WithUnit("{count}"), - )) -) - -// walNameAttr returns the measurement option that tags an observation with a WAL instance's name, so metrics -// from distinct instances in the same process remain distinguishable. -func walNameAttr(name string) metric.MeasurementOption { - return metric.WithAttributeSet(attribute.NewSet(attribute.String("wal", name))) -} - -// queueDepthAttrs tags a queue-depth observation with the WAL instance name and which internal channel -// ("writer" or "serializer") is being measured. -func queueDepthAttrs(name string, queue string) metric.MeasurementOption { - return metric.WithAttributeSet(attribute.NewSet( - attribute.String("wal", name), attribute.String("queue", queue))) -} - -func must[V any](v V, err error) V { - if err != nil { - panic(err) - } - return v -} diff --git a/sei-db/seiwal/seiwal.go b/sei-db/seiwal/seiwal.go deleted file mode 100644 index e0ce5f161a..0000000000 --- a/sei-db/seiwal/seiwal.go +++ /dev/null @@ -1,130 +0,0 @@ -package seiwal - -import ( - "errors" - "fmt" -) - -// ErrIteratorRange is returned by Iterator when the requested [startIndex, endIndex] range is invalid: -// endIndex below startIndex, or endIndex above the highest index currently stored in the WAL (including when -// the WAL is empty). It signals a caller error, not a failure of the WAL, so the WAL remains usable. -var ErrIteratorRange = errors.New("invalid iterator range") - -// WAL is a generic, index-keyed, append-only write-ahead log over payloads of type T. -// -// Each record is tagged with a caller-provided monotonic index. The index is what makes garbage -// collection ("drop everything below N"), iteration ("start at N"), and rollback ("drop everything -// above N") expressible without the WAL ever interpreting a payload. -// -// A WAL instance is not safe for concurrent use: its methods must not be called from multiple -// goroutines simultaneously. Callers that share a WAL across goroutines must serialize access -// themselves. -// -// Slices are not copied at the call boundary. Any slice passed into a WAL method — the payload and every -// slice reachable through it — must not be modified after the call: the WAL may retain it and read it -// asynchronously, so mutating it races the WAL and can corrupt what is persisted. Likewise every slice -// returned from a WAL or its iterator is owned by the WAL and must be treated as read-only. Callers that -// need to mutate such data must copy it first. -type WAL[T any] interface { - - // Append a record with the given index and payload. - // - // The required relationship between successive indices depends on Config.PermitGaps. When PermitGaps is - // false (the default), each index must be exactly one greater than the previous (strictly contiguous). - // When PermitGaps is true, each index need only be strictly greater than the previous, so gaps are - // allowed. In either case the first append on a fresh WAL may use any index, which sets the baseline. - // - // This method only schedules the append; it does not block until the record is durable. Durability is - // achieved by a subsequent Flush. - // - // data, and every slice reachable through it, must not be modified after this call: the payload may be - // retained and serialized asynchronously, so mutating it races the WAL and can corrupt what is - // persisted. Callers that need to reuse or mutate the buffer must copy it first. - Append(index uint64, data T) error - - // Flush blocks until all previously scheduled appends are durable. - Flush() error - - // Bounds reports the range of record indices currently stored in the WAL. - Bounds() ( - // If true, there is at least one record in the WAL and first/last are valid. If false, the WAL is - // empty and first/last are undefined. - ok bool, - // The lowest stored record index, inclusive. Only valid if ok is true. - first uint64, - // The highest stored record index, inclusive. Only valid if ok is true. - last uint64, - // Any error encountered while retrieving the range. - err error, - ) - - // PruneBefore removes all records with an index less than lowestIndexToKeep. - // - // This method merely schedules the prune; it does not block until the prune is complete. Pruning is - // async and lazy, and implementations are free to delay it arbitrarily long. Pruning removes whole - // sealed files only, so records may survive above the requested threshold until their containing file - // is fully below it. - PruneBefore(lowestIndexToKeep uint64) error - - // Iterator returns an iterator over the WAL across the inclusive index range [startIndex, endIndex]. - // - // The iterator yields no record with an index below startIndex or above endIndex. It is an error for - // endIndex to be below startIndex, or for endIndex to be above the highest index currently stored in the - // WAL (including when the WAL is empty); both are reported as ErrIteratorRange and leave the WAL usable. - // - // Construction may also fail with an I/O error (for example, the point-in-time snapshot the iterator reads - // from could not be created). Such a failure is returned to the caller and leaves the WAL usable. - // - // The iterator reads a consistent, point-in-time snapshot of the WAL taken at some instant between the - // start and the return of this call. Records appended before that instant are included; records - // appended after it are not. For records appended concurrently with this call, whether they are - // included is unspecified. - Iterator(startIndex uint64, endIndex uint64) (Iterator[T], error) - - // Close flushes pending appends, seals the current file, and releases resources. - Close() error -} - -// Iterator iterates over the records of a WAL in ascending index order. -// -// An Iterator is single-consumer and not safe for concurrent use: all of its methods, including Close, must -// be called from a single goroutine (or with external serialization). In particular, Close must not be -// called concurrently with Next from another goroutine. -// -// Every payload returned by an Iterator — and every slice reachable through it — is owned by the WAL and -// must be treated as read-only. Callers that need to retain or mutate the data must copy it first. -type Iterator[T any] interface { - // Next advances the iterator to the next record. It returns false when iteration is complete (no more - // records), and returns an error if advancing failed. After Next returns (false, nil), iteration is - // complete; after it returns an error, the iterator must not be used further (other than Close). - Next() (bool, error) - - // Entry returns the index and payload of the record at the iterator's current position. It is only - // valid to call Entry after Next has returned (true, nil). - // - // The returned payload must be treated as read-only and must not be modified. Callers that need to - // retain or mutate the data must copy it first. - Entry() (index uint64, data T) - - // Close releases the resources held by the iterator. - Close() error -} - -// NewWAL opens (or creates) a byte-oriented WAL in the configured directory, recovering any files left -// behind by a previous session. Operates on []byte payloads. -func NewWAL(config *Config) (WAL[[]byte], error) { - return newWAL(config) -} - -// NewGenericWAL opens a WAL over payloads of type T that does serialization on a background goroutine. -func NewGenericWAL[T any]( - config *Config, - serialize func(T) ([]byte, error), - deserialize func([]byte) (T, error), -) (WAL[T], error) { - inner, err := NewWAL(config) - if err != nil { - return nil, fmt.Errorf("failed to open inner WAL: %w", err) - } - return newSerializingWAL(config, inner, serialize, deserialize), nil -} diff --git a/sei-db/seiwal/seiwal_config.go b/sei-db/seiwal/seiwal_config.go deleted file mode 100644 index 6568e7c331..0000000000 --- a/sei-db/seiwal/seiwal_config.go +++ /dev/null @@ -1,88 +0,0 @@ -package seiwal - -import ( - "fmt" - "regexp" - "time" - - "github.com/sei-protocol/sei-chain/sei-db/common/unit" -) - -// The permitted shape of a WAL instance name: it becomes a metric attribute value, so it is restricted to -// characters safe for label values. -var nameRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) - -// Config configures a WAL. -type Config struct { - // The directory where the WAL writes its files. - Path string - - // A short identifier for this WAL instance, used to distinguish its metrics from those of other - // instances in the same process. Required; must match [a-zA-Z0-9_-]+. - Name string - - // The size of the channel used to send framed records and control messages to the writer goroutine. - WriteBufferSize uint - - // The depth of the serialization request queue. Used only by the generic serializing WAL - // (NewGenericWAL); the byte-oriented engine ignores it. - SerializerBufferSize uint - - // The size a WAL file may reach before it is sealed and a fresh one is opened. Rotation happens after a - // record is appended, so a file may exceed this by the size of a single record — and because a record - // is written atomically to a single file, a record larger than this threshold produces a file that - // overshoots it by that record's size. Must be greater than 0. - TargetFileSize uint - - // When true, Flush calls fsync on the underlying file so that flushed data survives a power loss, not - // merely a process crash. When false, Flush only flushes the in-process buffer to the OS. - FsyncOnFlush bool - - // When false, appended indices must be strictly contiguous: each index must equal the - // previous index plus one. The first append on a fresh WAL may start at any index and establishes the - // baseline; after recovery the baseline is the highest stored index. When true, indices need only be - // strictly increasing, so gaps between consecutive indices are permitted. - PermitGaps bool - - // The number of records an iterator's reader thread may prefetch ahead of the consumer. A larger value - // keeps the reader busy while the consumer processes records, which matters for startup replay speed. - // Must be greater than 0. - IteratorPrefetchSize uint - - // The interval at which the WAL samples the buffered depth of its internal channel into the - // seiwal_queue_depth gauge. Zero or negative disables sampling. - MetricsSampleInterval time.Duration -} - -// DefaultConfig returns a default WAL configuration for the WAL at path, identified by name. -func DefaultConfig(path string, name string) *Config { - return &Config{ - Path: path, - Name: name, - WriteBufferSize: 16, - SerializerBufferSize: 16, - TargetFileSize: 64 * unit.MB, - FsyncOnFlush: false, - PermitGaps: false, - IteratorPrefetchSize: 32, - MetricsSampleInterval: 15 * time.Second, - } -} - -// Validate the configuration, returning nil if valid, or an error describing the problem if invalid. -func (c *Config) Validate() error { - if c.Path == "" { - return fmt.Errorf("path is required") - } - if !nameRegex.MatchString(c.Name) { - return fmt.Errorf("name %q is required and must match %s", c.Name, nameRegex.String()) - } - if c.TargetFileSize == 0 { - // A zero target would seal and rotate a fresh file after every single record. - return fmt.Errorf("target file size must be greater than 0") - } - if c.IteratorPrefetchSize == 0 { - return fmt.Errorf("iterator prefetch size must be greater than 0") - } - return nil -} diff --git a/sei-db/seiwal/seiwal_config_test.go b/sei-db/seiwal/seiwal_config_test.go deleted file mode 100644 index 281f5ffbac..0000000000 --- a/sei-db/seiwal/seiwal_config_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package seiwal - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestConfigValidate(t *testing.T) { - t.Run("default config is valid", func(t *testing.T) { - require.NoError(t, DefaultConfig("/tmp/wal", "test").Validate()) - }) - - t.Run("empty path is rejected", func(t *testing.T) { - cfg := DefaultConfig("", "test") - require.Error(t, cfg.Validate()) - }) - - t.Run("empty name is rejected", func(t *testing.T) { - cfg := DefaultConfig("/tmp/wal", "") - require.Error(t, cfg.Validate()) - }) - - t.Run("malformed name is rejected", func(t *testing.T) { - cfg := DefaultConfig("/tmp/wal", "bad name!") - require.Error(t, cfg.Validate()) - }) - - t.Run("zero target file size is rejected", func(t *testing.T) { - cfg := DefaultConfig("/tmp/wal", "test") - cfg.TargetFileSize = 0 - require.Error(t, cfg.Validate()) - }) - - t.Run("zero iterator prefetch size is rejected", func(t *testing.T) { - cfg := DefaultConfig("/tmp/wal", "test") - cfg.IteratorPrefetchSize = 0 - require.Error(t, cfg.Validate()) - }) -} diff --git a/sei-db/seiwal/seiwal_file.go b/sei-db/seiwal/seiwal_file.go deleted file mode 100644 index 6a12aba9b4..0000000000 --- a/sei-db/seiwal/seiwal_file.go +++ /dev/null @@ -1,583 +0,0 @@ -package seiwal - -import ( - "bufio" - "bytes" - "encoding/binary" - "fmt" - "hash/crc32" - "io" - "os" - "path/filepath" - "regexp" - "strconv" - - "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt/util" -) - -// WAL files use the following naming schema: -// -// For mutable files: {file sequence}.wal.u -// For sealed files: {file sequence}-{first index}-{last index}.wal -// -// The file sequence is a unique, monotonically increasing counter identifying the file; the first and last -// index are the record indices the sealed file spans. The on-disk serialization version is recorded in each -// file's header rather than its name. - -const ( - // The file extension for unsealed (mutable) WAL files. - walUnsealedExtension = ".wal.u" - // The file extension for sealed (immutable) WAL files. - walSealedExtension = ".wal" - // The serialization version written into each file's header. Bumped if the on-disk format changes. - walFormatVersion = byte(1) - // The subdirectory of the WAL directory holding per-iterator hard-link snapshots. Each live iterator owns - // iterator//; the whole tree is blasted at startup (see recoverDirectory) and per-iterator on Close. - walIteratorDirName = "iterator" -) - -// The magic prefix written at the start of every WAL file, followed by a single format-version byte. -var walFileMagic = []byte("SEIWL") - -// The length of a WAL file header: magic prefix plus the format-version byte. -const walHeaderSize = 6 // len("SEIWL") + 1 - -var ( - unsealedFileRegex = regexp.MustCompile(`^(\d+)\.wal\.u$`) - sealedFileRegex = regexp.MustCompile(`^(\d+)-(\d+)-(\d+)\.wal$`) -) - -// iteratorRoot is the directory under a WAL directory that holds every live iterator's hard-link snapshot. It -// is skipped by every WAL scanner because they ignore directory entries, so its contents never look like WAL -// files. -func iteratorRoot(directory string) string { - return filepath.Join(directory, walIteratorDirName) -} - -// iteratorLinkDir is the directory holding one iterator's hard-link snapshot, identified by its serial number. -func iteratorLinkDir(directory string, serial uint64) string { - return filepath.Join(iteratorRoot(directory), strconv.FormatUint(serial, 10)) -} - -// deleteIteratorLinks blasts the entire iterator hard-link tree. Called at startup to clear links left by a -// prior session: the links are ephemeral (never fsynced), so any survivor of a crash is safe to remove, and -// dropping a link to an already-pruned file simply reclaims its inode. -func deleteIteratorLinks(directory string) error { - if err := os.RemoveAll(iteratorRoot(directory)); err != nil { - return fmt.Errorf("failed to delete iterator link tree: %w", err) - } - return nil -} - -// The result of parsing a WAL file name. -type parsedFileName struct { - fileSeq uint64 - firstIndex uint64 - lastIndex uint64 - sealed bool -} - -// Parse a WAL file name into its components. Returns false if the name is not a WAL file name. -func parseFileName(fileName string) (parsedFileName, bool) { - if m := sealedFileRegex.FindStringSubmatch(fileName); m != nil { - seq, err1 := strconv.ParseUint(m[1], 10, 64) - first, err2 := strconv.ParseUint(m[2], 10, 64) - last, err3 := strconv.ParseUint(m[3], 10, 64) - if err1 != nil || err2 != nil || err3 != nil { - return parsedFileName{}, false - } - return parsedFileName{fileSeq: seq, firstIndex: first, lastIndex: last, sealed: true}, true - } - if m := unsealedFileRegex.FindStringSubmatch(fileName); m != nil { - seq, err := strconv.ParseUint(m[1], 10, 64) - if err != nil { - return parsedFileName{}, false - } - return parsedFileName{fileSeq: seq, sealed: false}, true - } - return parsedFileName{}, false -} - -// Build the name of an unsealed (mutable) WAL file. -func unsealedFileName(fileSeq uint64) string { - return fmt.Sprintf("%d%s", fileSeq, walUnsealedExtension) -} - -// Build the name of a sealed WAL file. -func sealedFileName(fileSeq uint64, firstIndex uint64, lastIndex uint64) string { - return fmt.Sprintf("%d-%d-%d%s", fileSeq, firstIndex, lastIndex, walSealedExtension) -} - -// A single WAL file on disk, either the current mutable file being appended to or a sealed file. -type walFile struct { - // The directory this file lives in. - directory string - - // The open file handle and buffered writer. Only set for the mutable file being written this session. - file *os.File - writer *bufio.Writer - - // The unique, monotonically increasing sequence number of this file. - fileSeq uint64 - - // If true, this file is sealed and rejects writes. - sealed bool - - // The first and last record indices that appear in this file, valid only when hasRecords is true. - firstIndex uint64 - lastIndex uint64 - hasRecords bool - - // The number of bytes written to this file so far, including the header. - size uint64 -} - -// Create a new mutable WAL file on disk, writing its header, ready to accept records. -func newWalFile(directory string, fileSeq uint64) (*walFile, error) { - path := filepath.Join(directory, unsealedFileName(fileSeq)) - file, err := os.Create(path) //nolint:gosec // path derived from a validated directory - if err != nil { - return nil, fmt.Errorf("failed to create WAL file %s: %w", path, err) - } - - // Persist the new directory entry so a later fsync of the file's contents (via flush) cannot be undone by a - // power loss that drops the unsynced create. Without this, flushed data could be lost if the file is never - // sealed (a seal fsyncs the directory via the atomic rename) before a crash. - if err := util.SyncParentPath(path); err != nil { - _ = file.Close() - return nil, fmt.Errorf("failed to fsync WAL directory after creating %s: %w", path, err) - } - - writer := bufio.NewWriter(file) - header := append(append([]byte(nil), walFileMagic...), walFormatVersion) - if _, err := writer.Write(header); err != nil { - _ = file.Close() - return nil, fmt.Errorf("failed to write WAL header to %s: %w", path, err) - } - - return &walFile{ - directory: directory, - file: file, - writer: writer, - fileSeq: fileSeq, - size: walHeaderSize, - }, nil -} - -// frameRecord wraps a payload in its on-disk framing: -// [uvarint index][uvarint payload length][payload][uint32 CRC32(index+length+payload)]. -// The checksum covers the index and length prefixes as well as the payload, so a torn or corrupt index is -// detected on recovery exactly as a torn payload is. -func frameRecord(index uint64, payload []byte) []byte { - var idxBuf [binary.MaxVarintLen64]byte - idxN := binary.PutUvarint(idxBuf[:], index) - var lenBuf [binary.MaxVarintLen64]byte - lenN := binary.PutUvarint(lenBuf[:], uint64(len(payload))) - - record := make([]byte, 0, idxN+lenN+len(payload)+4) - record = append(record, idxBuf[:idxN]...) - record = append(record, lenBuf[:lenN]...) - record = append(record, payload...) - var crcBuf [4]byte - binary.BigEndian.PutUint32(crcBuf[:], crc32.ChecksumIEEE(record)) - record = append(record, crcBuf[:]...) - return record -} - -// Append a pre-framed record (see frameRecord) with the given index to this file. -func (f *walFile) writeRecord(record []byte, index uint64) error { - if f.sealed { - return fmt.Errorf("cannot write to a sealed WAL file") - } - if _, err := f.writer.Write(record); err != nil { - return fmt.Errorf("failed to write WAL record: %w", err) - } - f.size += uint64(len(record)) - - if !f.hasRecords { - f.firstIndex = index - f.hasRecords = true - } - f.lastIndex = index - return nil -} - -// close releases the file handle without sealing. Used on the fatal-error path, where the file is left -// unsealed for recoverOrphans to seal (truncating any torn tail) on the next open. Idempotent. -func (f *walFile) close() error { - if f.file == nil { - return nil - } - err := f.file.Close() - f.file = nil - return err -} - -// Flush buffered data to the OS. When fsync is true, also fsync the file so the data survives power loss. -func (f *walFile) flush(fsync bool) error { - if f.writer != nil { - if err := f.writer.Flush(); err != nil { - return fmt.Errorf("failed to flush WAL file: %w", err) - } - } - if fsync && f.file != nil { - if err := f.file.Sync(); err != nil { - return fmt.Errorf("failed to fsync WAL file: %w", err) - } - } - return nil -} - -// Seal this file: flush it, then atomically rename it to its sealed name. Every record written in-process is -// whole (records are framed and written atomically), so there is nothing to truncate. A file with no records -// (including one that never received a record) is removed rather than sealed. Idempotent. Returns the sealed -// file name, or "" if the file was removed. -func (f *walFile) seal() (string, error) { - if f.sealed { - return "", nil - } - if err := f.flush(true); err != nil { - _ = f.close() - return "", fmt.Errorf("failed to flush before sealing: %w", err) - } - - unsealedPath := filepath.Join(f.directory, unsealedFileName(f.fileSeq)) - if !f.hasRecords { - if err := f.close(); err != nil { - return "", fmt.Errorf("failed to close WAL file: %w", err) - } - if err := removeAndSyncDir(f.directory, unsealedFileName(f.fileSeq)); err != nil { - return "", fmt.Errorf("failed to remove empty WAL file: %w", err) - } - f.sealed = true - return "", nil - } - - if err := f.close(); err != nil { - return "", fmt.Errorf("failed to close WAL file: %w", err) - } - - sealedName := sealedFileName(f.fileSeq, f.firstIndex, f.lastIndex) - sealedPath := filepath.Join(f.directory, sealedName) - if err := util.AtomicRename(unsealedPath, sealedPath, true); err != nil { - return "", fmt.Errorf("failed to seal WAL file: %w", err) - } - f.sealed = true - return sealedName, nil -} - -// A single record read from a WAL file: its index, its payload (a sub-slice of the file's bytes), and the -// byte offset just past its framing. -type walRecord struct { - index uint64 - payload []byte - end int64 -} - -// The result of reading a WAL file from disk. -type walFileContents struct { - // The parsed file name components. - parsed parsedFileName - - // The intact records read from the file, in order. Excludes any torn trailing record. - records []walRecord - - // The first and last record indices across the intact records, valid only when hasRecords is true. - firstIndex uint64 - lastIndex uint64 - hasRecords bool - - // The byte offset just past the last intact record. Data beyond this offset is a torn trailing record and - // is discarded on recovery. - validEnd int64 -} - -// Read a WAL file from disk, tolerating a torn trailing record (a crash mid-write can leave a final record -// whose index prefix, length prefix, payload, or checksum is incomplete). Any bytes past the last intact -// record are discarded; the last intact record's boundary is reported so callers can truncate the torn tail. -func readWalFile(path string) (*walFileContents, error) { - name := filepath.Base(path) - parsed, ok := parseFileName(name) - if !ok { - return nil, fmt.Errorf("not a WAL file: %s", name) - } - - data, err := os.ReadFile(path) //nolint:gosec // caller-supplied path - if err != nil { - return nil, fmt.Errorf("failed to read WAL file %s: %w", path, err) - } - - return parseWalFileData(data, parsed, path) -} - -// readWalFileFromHandle reads and parses a WAL file from an already-open handle, then closes the handle. -func readWalFileFromHandle(file *os.File, parsed parsedFileName) (*walFileContents, error) { - defer func() { _ = file.Close() }() - data, err := io.ReadAll(file) - if err != nil { - return nil, fmt.Errorf("failed to read WAL file %s: %w", file.Name(), err) - } - return parseWalFileData(data, parsed, file.Name()) -} - -// parseWalFileData parses the raw bytes of a WAL file (already read into memory) into its intact records, -// tolerating a torn trailing record. name is used only for error messages. It is shared by readWalFile (which -// reads by path) and the iterator (which reads through a file handle opened on the writer goroutine). -func parseWalFileData(data []byte, parsed parsedFileName, name string) (*walFileContents, error) { - contents := &walFileContents{parsed: parsed} - - if len(data) < walHeaderSize { - // A file too short to even contain a header carries no committed data. - return contents, nil - } - if !bytes.Equal(data[:len(walFileMagic)], walFileMagic) { - return nil, fmt.Errorf("WAL file %s has an invalid magic prefix", name) - } - if version := data[len(walFileMagic)]; version != walFormatVersion { - return nil, fmt.Errorf("WAL file %s has unsupported format version %d", name, version) - } - contents.validEnd = walHeaderSize - - // A torn trailing record is expected only in the mutable file after a crash, where discarding it is correct. - // A sealed file is durable, fsync'd, and truncated to a record boundary on seal, so any framing or checksum - // failure in it is corruption (e.g. bit-rot) that must be surfaced rather than silently discarded — otherwise - // records past the fault vanish while the file's name keeps promising them. torn() turns each tolerated break - // into a hard error for a sealed file. - torn := func(offset int, reason string) error { - if !parsed.sealed { - return nil - } - return fmt.Errorf("sealed WAL file %s is corrupt: %s at offset %d", name, reason, offset) - } - - offset := walHeaderSize - for offset < len(data) { - index, idxN := binary.Uvarint(data[offset:]) - if idxN <= 0 { - if err := torn(offset, "torn record index prefix"); err != nil { - return nil, err - } - break // torn or incomplete index prefix - } - lenStart := offset + idxN - length, lenN := binary.Uvarint(data[lenStart:]) - if lenN <= 0 { - if err := torn(offset, "torn record length prefix"); err != nil { - return nil, err - } - break // torn or incomplete length prefix - } - payloadStart := lenStart + lenN - // payloadStart <= len(data), so the difference is non-negative. - remaining := uint64(len(data) - payloadStart) //nolint:gosec - if remaining < 4 || length > remaining-4 { - if err := torn(offset, "truncated record payload or checksum"); err != nil { - return nil, err - } - break // torn record: payload or checksum truncated (4 trailing bytes are the CRC32) - } - payloadLen := int(length) //nolint:gosec // bounded above by remaining-4, which is <= len(data) - payloadEnd := payloadStart + payloadLen - recordEnd := payloadEnd + 4 - gotCRC := binary.BigEndian.Uint32(data[payloadEnd:recordEnd]) - if gotCRC != crc32.ChecksumIEEE(data[offset:payloadEnd]) { - if err := torn(offset, "record checksum mismatch"); err != nil { - return nil, err - } - break // torn or corrupt record - } - - contents.records = append(contents.records, - walRecord{index: index, payload: data[payloadStart:payloadEnd], end: int64(recordEnd)}) - if !contents.hasRecords { - contents.firstIndex = index - contents.hasRecords = true - } - contents.lastIndex = index - contents.validEnd = int64(recordEnd) - - offset = recordEnd - } - - return contents, nil -} - -// verifySealedContents confirms that a sealed file's intact content exactly covers the [first, last] index -// range promised by its name. A sealed file is durable and complete, so a shortfall means interior corruption -// (e.g. a record truncated to a clean boundary) that leaves parseWalFileData reading fewer records than the -// name promises, while Bounds/GetStoredRange keep reporting the full range. fileSeq is used only for error -// messages. -func verifySealedContents(contents *walFileContents, fileSeq uint64, first uint64, last uint64) error { - if !contents.hasRecords { - return fmt.Errorf( - "WAL file (sequence %d) is corrupt: name promises indices [%d, %d] "+ - "but no intact records were read", - fileSeq, first, last) - } - if contents.firstIndex != first || contents.lastIndex != last { - return fmt.Errorf( - "WAL file (sequence %d) is corrupt: name promises indices [%d, %d] but content holds [%d, %d]", - fileSeq, first, last, contents.firstIndex, contents.lastIndex) - } - return nil -} - -// truncateAndSync truncates the file at path to size and fsyncs it, so the shorter length is durable on its -// own — before any subsequent rename. Without the fsync, a crash could persist a rename while losing the -// truncation, leaving a file whose name promises more records than its content actually holds. -func truncateAndSync(path string, size int64) error { - file, err := os.OpenFile(path, os.O_RDWR, 0) //nolint:gosec // caller-supplied path - if err != nil { - return fmt.Errorf("failed to open %s for truncation: %w", path, err) - } - if err := file.Truncate(size); err != nil { - _ = file.Close() - return fmt.Errorf("failed to truncate %s: %w", path, err) - } - if err := file.Sync(); err != nil { - _ = file.Close() - return fmt.Errorf("failed to fsync %s after truncation: %w", path, err) - } - if err := file.Close(); err != nil { - return fmt.Errorf("failed to close %s after truncation: %w", path, err) - } - return nil -} - -// removeAndSyncDir removes the named file and fsyncs its parent directory, so the removal is durable before the -// caller proceeds. Callers rely on this to keep the sealed-file sequence gap-free across a crash. -func removeAndSyncDir(directory string, name string) error { - path := filepath.Join(directory, name) - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to remove WAL file %s: %w", path, err) - } - if err := util.SyncParentPath(path); err != nil { - return fmt.Errorf("failed to fsync directory after removing %s: %w", path, err) - } - return nil -} - -// Seal an orphaned mutable file discovered on disk at startup (left behind by a crash before it could be -// sealed). Any torn trailing record is truncated away first, so the sealed file ends cleanly on a record -// boundary. A file left with no records is removed. -func sealOrphanFile(directory string, name string) error { - path := filepath.Join(directory, name) - contents, err := readWalFile(path) - if err != nil { - return fmt.Errorf("failed to read orphaned WAL file %s: %w", path, err) - } - - if !contents.hasRecords { - return removeAndSyncDir(directory, name) - } - - if err := truncateAndSync(path, contents.validEnd); err != nil { - return fmt.Errorf("failed to truncate orphaned WAL file %s: %w", path, err) - } - sealedPath := filepath.Join(directory, - sealedFileName(contents.parsed.fileSeq, contents.firstIndex, contents.lastIndex)) - if err := util.AtomicRename(path, sealedPath, true); err != nil { - return fmt.Errorf("failed to seal orphaned WAL file %s: %w", path, err) - } - return nil -} - -// rollbackStraddlingFile handles the single sealed file that spans rollbackThrough: it drops every record -// beyond the rollback point and reduces the file's range accordingly. The name of a sealed file encodes its -// index range, so the reduced content and the reduced name must become durable together — a crash must never -// leave a file whose name promises more records than its content holds (the iterator bounds sealed reads by -// content and would otherwise under-yield, while Bounds trusts the name and would over-report). Because the -// reduced range means a different file name, this cannot be a single in-place rename: the kept prefix is -// written to a fresh correctly-named file via AtomicWrite (durable on its own), and only then is the old, -// larger-named file removed. A crash after the write but before the removal leaves both files under the same -// file sequence; recovery's reconcileRollbackRemnants resolves that deterministically. Files entirely beyond -// the rollback point are removed by the caller; this handles only the boundary file. -func rollbackStraddlingFile(directory string, name string, rollbackThrough uint64) error { - path := filepath.Join(directory, name) - parsed, ok := parseFileName(name) - if !ok { - return fmt.Errorf("not a WAL file: %s", name) - } - data, err := os.ReadFile(path) //nolint:gosec // path derived from a scanned WAL directory entry - if err != nil { - return fmt.Errorf("failed to read WAL file %s during rollback: %w", path, err) - } - contents, err := parseWalFileData(data, parsed, path) - if err != nil { - return fmt.Errorf("failed to parse WAL file %s during rollback: %w", path, err) - } - - truncateTo := int64(walHeaderSize) - var lastKept uint64 - kept := false - for _, record := range contents.records { - if record.index > rollbackThrough { - break - } - truncateTo = record.end - lastKept = record.index - kept = true - } - - if !kept { - // The content holds no record at or below the rollback point after all; drop the whole file. - return removeAndSyncDir(directory, name) - } - if lastKept == contents.lastIndex { - return nil // nothing beyond the rollback point; leave the file untouched - } - - // Write the kept prefix to a fresh file under its reduced name, made durable before the old file is removed. - newName := sealedFileName(parsed.fileSeq, contents.firstIndex, lastKept) - newPath := filepath.Join(directory, newName) - if err := util.AtomicWrite(newPath, data[:truncateTo], true); err != nil { - return fmt.Errorf("failed to write rolled-back WAL file %s: %w", newPath, err) - } - if err := removeAndSyncDir(directory, name); err != nil { - return fmt.Errorf("failed to remove old WAL file %s during rollback: %w", path, err) - } - return nil -} - -// reconcileRollbackRemnants resolves the one crash window left by rollbackStraddlingFile: a crash after the -// reduced file was written but before the old, larger-named file was removed leaves two sealed files sharing a -// file sequence. This can happen only from an interrupted rollback swap (healthy operation never assigns a -// sequence to two files), so the reduced file (the one with the smaller last index) is the intended survivor; -// the larger one is removed. Both files are internally name/content consistent, so the choice is made from -// names alone without reading contents. A no-op in the common case where every sealed sequence is unique. -func reconcileRollbackRemnants(directory string) error { - entries, err := os.ReadDir(directory) - if err != nil { - return fmt.Errorf("failed to read WAL directory %s: %w", directory, err) - } - - // The name kept for each sealed sequence so far. A duplicate sequence means an interrupted rollback swap. - kept := make(map[uint64]parsedFileName) - for _, entry := range entries { - if entry.IsDir() { - continue - } - parsed, ok := parseFileName(entry.Name()) - if !ok || !parsed.sealed { - continue - } - prev, seen := kept[parsed.fileSeq] - if !seen { - kept[parsed.fileSeq] = parsed - continue - } - // Two sealed files share this sequence. Keep the one with the smaller last index (the rolled-back - // result) and remove the other. A sealed name is a deterministic function of its parsed fields, so - // each file's name is recoverable without tracking the raw directory entry. - keep, drop := parsed, prev - if prev.lastIndex <= parsed.lastIndex { - keep, drop = prev, parsed - } - dropName := sealedFileName(drop.fileSeq, drop.firstIndex, drop.lastIndex) - if err := removeAndSyncDir(directory, dropName); err != nil { - return fmt.Errorf("failed to remove rollback remnant %s: %w", dropName, err) - } - kept[parsed.fileSeq] = keep - } - return nil -} diff --git a/sei-db/seiwal/seiwal_file_test.go b/sei-db/seiwal/seiwal_file_test.go deleted file mode 100644 index 1a0bfbe9d8..0000000000 --- a/sei-db/seiwal/seiwal_file_test.go +++ /dev/null @@ -1,185 +0,0 @@ -package seiwal - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestFileNaming(t *testing.T) { - require.Equal(t, "3.wal.u", unsealedFileName(3)) - require.Equal(t, "3-10-20.wal", sealedFileName(3, 10, 20)) - - parsed, ok := parseFileName("3.wal.u") - require.True(t, ok) - require.Equal(t, parsedFileName{fileSeq: 3, sealed: false}, parsed) - - parsed, ok = parseFileName("3-10-20.wal") - require.True(t, ok) - require.Equal(t, parsedFileName{fileSeq: 3, firstIndex: 10, lastIndex: 20, sealed: true}, parsed) - - _, ok = parseFileName("not-a-wal-file.txt") - require.False(t, ok) -} - -// writeMutableFile creates a mutable file at sequence 0, applies fn to it, then flushes and closes the -// underlying handle without sealing, leaving an unsealed file on disk. It returns the file path. -func writeMutableFile(t *testing.T, dir string, fn func(f *walFile)) string { - t.Helper() - f, err := newWalFile(dir, 0) - require.NoError(t, err) - fn(f) - require.NoError(t, f.flush(true)) - require.NoError(t, f.file.Close()) - return filepath.Join(dir, unsealedFileName(0)) -} - -// writeRecordTo frames and appends a record for the given index to f. -func writeRecordTo(t *testing.T, f *walFile, index uint64, payload []byte) { - t.Helper() - require.NoError(t, f.writeRecord(frameRecord(index, payload), index)) -} - -func TestReadWalFileCleanTail(t *testing.T) { - dir := t.TempDir() - path := writeMutableFile(t, dir, func(f *walFile) { - writeRecordTo(t, f, 1, []byte("one")) - writeRecordTo(t, f, 2, []byte("two")) - }) - - contents, err := readWalFile(path) - require.NoError(t, err) - require.True(t, contents.hasRecords) - require.Equal(t, uint64(1), contents.firstIndex) - require.Equal(t, uint64(2), contents.lastIndex) - require.Len(t, contents.records, 2) -} - -func TestReadWalFileTornTrailingRecord(t *testing.T) { - dir := t.TempDir() - path := writeMutableFile(t, dir, func(f *walFile) { - writeRecordTo(t, f, 1, []byte("one")) - writeRecordTo(t, f, 2, []byte("two")) - // A third record whose framing is truncated mid-payload, as a torn write would leave. - frame := frameRecord(3, []byte("three")) - require.NoError(t, f.flush(false)) - _, err := f.writer.Write(frame[:len(frame)-3]) - require.NoError(t, err) - }) - - contents, err := readWalFile(path) - require.NoError(t, err) - // The torn record 3 is dropped; the last intact record is 2. - require.True(t, contents.hasRecords) - require.Equal(t, uint64(2), contents.lastIndex) - require.Len(t, contents.records, 2) -} - -func TestReadWalFilePartialLengthPrefix(t *testing.T) { - dir := t.TempDir() - path := writeMutableFile(t, dir, func(f *walFile) { - writeRecordTo(t, f, 1, []byte("one")) - }) - - // Append a lone 0x80 byte: an incomplete uvarint prefix, as a torn write would leave. - f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0) - require.NoError(t, err) - _, err = f.Write([]byte{0x80}) - require.NoError(t, err) - require.NoError(t, f.Close()) - - contents, err := readWalFile(path) - require.NoError(t, err) - require.True(t, contents.hasRecords) - require.Equal(t, uint64(1), contents.lastIndex) - require.Len(t, contents.records, 1) -} - -func TestReadWalFileMidRecordTruncation(t *testing.T) { - dir := t.TempDir() - path := writeMutableFile(t, dir, func(f *walFile) { - writeRecordTo(t, f, 1, []byte("one")) - writeRecordTo(t, f, 2, []byte("two")) - }) - - info, err := os.Stat(path) - require.NoError(t, err) - // Lop a few bytes off the end, tearing record 2. - require.NoError(t, os.Truncate(path, info.Size()-3)) - - contents, err := readWalFile(path) - require.NoError(t, err) - require.True(t, contents.hasRecords) - require.Equal(t, uint64(1), contents.lastIndex) - require.Len(t, contents.records, 1) -} - -func TestReadWalFileChecksumMismatch(t *testing.T) { - dir := t.TempDir() - path := writeMutableFile(t, dir, func(f *walFile) { - writeRecordTo(t, f, 1, []byte("one")) - writeRecordTo(t, f, 2, []byte("two")) - }) - - // Flip the final byte (part of record 2's CRC), so that record fails its checksum. - data, err := os.ReadFile(path) - require.NoError(t, err) - data[len(data)-1] ^= 0xFF - require.NoError(t, os.WriteFile(path, data, 0o600)) - - contents, err := readWalFile(path) - require.NoError(t, err) - // Record 1 survives; the corrupt record 2 is dropped. - require.True(t, contents.hasRecords) - require.Equal(t, uint64(1), contents.lastIndex) - require.Len(t, contents.records, 1) -} - -func TestReadWalFileBadMagic(t *testing.T) { - dir := t.TempDir() - path := writeMutableFile(t, dir, func(f *walFile) { - writeRecordTo(t, f, 1, []byte("one")) - }) - - data, err := os.ReadFile(path) - require.NoError(t, err) - data[0] ^= 0xFF - require.NoError(t, os.WriteFile(path, data, 0o600)) - - _, err = readWalFile(path) - require.Error(t, err) -} - -// TestSealClearsFileHandle verifies that a successful seal() closes and clears f.file, so a later close() is a -// true no-op rather than a double-close on an already-closed handle — keeping close() idempotent as documented. -func TestSealClearsFileHandle(t *testing.T) { - dir := t.TempDir() - f, err := newWalFile(dir, 0) - require.NoError(t, err) - writeRecordTo(t, f, 1, []byte("one")) - - name, err := f.seal() - require.NoError(t, err) - require.NotEmpty(t, name) - require.Nil(t, f.file) // seal cleared the handle - require.NoError(t, f.close()) // idempotent: no second Close on a closed handle -} - -// TestSealReleasesHandleOnFlushFailure verifies that when the pre-seal flush fails, seal() still releases the -// file descriptor (clears f.file) rather than leaking it until GC. -func TestSealReleasesHandleOnFlushFailure(t *testing.T) { - dir := t.TempDir() - f, err := newWalFile(dir, 0) - require.NoError(t, err) - writeRecordTo(t, f, 1, []byte("one")) - - // Close the descriptor out from under seal so its flush(true) fails on the still-buffered bytes. - require.NoError(t, f.file.Close()) - - _, err = f.seal() - require.Error(t, err) - require.Nil(t, f.file) // fd released despite the failure - require.NoError(t, f.close()) // idempotent -} diff --git a/sei-db/seiwal/seiwal_impl.go b/sei-db/seiwal/seiwal_impl.go deleted file mode 100644 index 2e6621d601..0000000000 --- a/sei-db/seiwal/seiwal_impl.go +++ /dev/null @@ -1,809 +0,0 @@ -package seiwal - -import ( - "context" - "fmt" - "os" - "path/filepath" - "sort" - "sync" - "time" - - "github.com/zbiljic/go-filelock" - "go.opentelemetry.io/otel/metric" - - "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt/util" - "github.com/sei-protocol/seilog" -) - -var _ WAL[[]byte] = (*walImpl)(nil) - -var logger = seilog.NewLogger("db", "seiwal") - -// dataToBeWritten carries a framed record from a caller to the writer to be appended. -type dataToBeWritten struct { - record []byte - index uint64 -} - -// flushRequest asks the writer to flush (and optionally fsync) the mutable file, signaling done when durable. -type flushRequest struct { - done chan error -} - -// rangeQuery asks the writer to report the stored index range. -type rangeQuery struct { - reply chan storedRange -} - -// pruneRequest asks the writer to drop whole sealed files below `through`. -type pruneRequest struct { - through uint64 -} - -// closeRequest asks the writer to seal the mutable file and shut down, signaling done when sealed. -type closeRequest struct { - done chan error -} - -// iteratorStartRequest asks the writer to construct an iterator. The writer hard-links a point-in-time -// snapshot of the files to read and builds the iterator, all on its own goroutine so the snapshot is -// serialized with rotation/seal/prune. -type iteratorStartRequest struct { - startIndex uint64 - endIndex uint64 - reply chan iteratorStartResponse -} - -// The iterator (or an error) produced by the writer in response to an iteratorStartRequest. -type iteratorStartResponse struct { - iterator *walIterator - err error - - // fatal reports whether err left the WAL in a state that requires bricking the pipeline. A non-fatal err (a - // caller range error, or a non-corrupting filesystem failure while building the snapshot) leaves the WAL - // usable, so the writer surfaces it to the caller and keeps running. - fatal bool -} - -// The index range reported by Bounds. -type storedRange struct { - ok bool - first uint64 - last uint64 -} - -// Bookkeeping for a sealed WAL file, owned by the writer goroutine. -type sealedFileInfo struct { - fileSeq uint64 - name string - firstIndex uint64 - lastIndex uint64 -} - -// A generic write-ahead log implementation. -type walImpl struct { - // The configuration this WAL was opened with. Read-only after construction. - config *Config - - // The measurement option tagging this instance's metrics with its name. Read-only after construction. - metricAttrs metric.MeasurementOption - - // Callers funnel framed records and control messages through writerChan as a single ordered stream to - // the writer goroutine. - writerChan chan any - - // The hard-stop context the writer watches. Cancelled by fail() with the fatal error as its cause, and - // by Close() (with a nil cause) once everything has drained. The cause carries the fatal error to - // callers, so no separate error field is needed. - ctx context.Context - // Cancels ctx, tearing down the writer goroutine, recording the fatal error (or nil) as the cause. - cancel context.CancelCauseFunc - - // A child of ctx that the writerChan producers watch, cancelled once the writer stops reading so an - // in-flight or future push aborts rather than deadlocking. - senderCtx context.Context - // Cancels senderCtx. - senderCancel context.CancelCauseFunc - - // Tracks the writer and queue-depth sampler goroutines so Close() can wait for them to exit. - wg sync.WaitGroup - - // Closed by Close() to stop the queue-depth sampler goroutine. - samplerStop chan struct{} - - // The exclusive advisory lock on the WAL directory, held for this instance's entire lifetime and - // released by Close(). It prevents a second WAL instance or an offline utility from mutating the same - // directory concurrently. - fileLock filelock.TryLockerSafe - - // Guarantees the Close() shutdown sequence runs at most once. - closeOnce sync.Once - - // Set by Close() so subsequent scheduling calls fail fast. Plain: calling any method after Close is a - // contract violation, so this need not be atomic. - closed bool - - // The index of the most recently appended record. - lastAppendIndex uint64 - - // Whether any record has been appended (this session or recovered from disk). - hasAppended bool - - // The following fields are owned exclusively by the writer goroutine. - - // The index of the most recently written record. A writer-owned backstop that rejects out-of-order - // records that slip past the caller-side gate (e.g. under concurrent misuse), turning silent - // corruption into a fatal error. - lastWrittenIndex uint64 - - // Whether any record has been written (this session or recovered from disk). - hasWritten bool - - // The current mutable file accepting records. - mutableFile *walFile - - // The sequence number to assign the next mutable file. - nextFileSeq uint64 - - // Sealed files in ascending index order. Rotation appends to the back; pruning removes from the front. - sealedFiles *util.RandomAccessDeque[*sealedFileInfo] - - // The serial number to assign the next iterator, naming its hard-link snapshot directory - // (iterator//). Mutated only by the writer goroutine. - nextIteratorSeq uint64 -} - -// recoverDirectory brings a WAL directory into a clean, consistent on-disk state: it removes crash remnants -// from an interrupted rollback and seals any unsealed file left behind by a prior session. After it returns, -// every record lives in a sealed file whose name matches its content, with no orphans remaining. Shared by -// the WAL constructor and the offline GetRange/PruneAfter utilities, so all three run the same sanity pass. -func recoverDirectory(path string) error { - if err := util.EnsureDirectoryExists(path, true); err != nil { - return fmt.Errorf("failed to ensure WAL directory %s: %w", path, err) - } - // Blast any hard-link snapshots left by iterators of a prior session; they are ephemeral read-side leases, - // never part of the durable WAL, so a crash survivor is always safe to remove. - if err := deleteIteratorLinks(path); err != nil { - return err - } - // Clean up remnants of a rollback swap interrupted by a crash before scanning (see rollbackStraddlingFile): - // a leftover swap file from an unfinished AtomicWrite, or two sealed files sharing a sequence because the old - // file was not yet removed. This leaves a set where every sealed sequence is unique and name matches content. - if err := util.DeleteOrphanedSwapFiles(path); err != nil { - return fmt.Errorf("failed to delete orphaned swap files: %w", err) - } - if err := reconcileRollbackRemnants(path); err != nil { - return fmt.Errorf("failed to reconcile rollback remnants: %w", err) - } - if err := recoverOrphans(path); err != nil { - return fmt.Errorf("failed to recover orphaned WAL files: %w", err) - } - return nil -} - -func newWAL(config *Config) (WAL[[]byte], error) { - if err := config.Validate(); err != nil { - return nil, fmt.Errorf("invalid WAL config: %w", err) - } - - // Take the directory lock before touching any files: recoverDirectory (below) mutates the directory, so - // we must own it exclusively first. The lock is released by Close(), or here on any open failure. - if err := util.EnsureDirectoryExists(config.Path, true); err != nil { - return nil, fmt.Errorf("failed to ensure WAL directory %s: %w", config.Path, err) - } - fileLock, err := acquireDirLock(config.Path) - if err != nil { - return nil, fmt.Errorf("failed to lock WAL directory %s: %w", config.Path, err) - } - defer func() { - if fileLock != nil { - releaseDirLock(fileLock, config.Path) - } - }() - - if err := recoverDirectory(config.Path); err != nil { - return nil, err - } - // Only the cheap directory-listing scan runs at open: it reads file names, not their contents, and still - // rejects structural corruption (a gap in the sealed sequence). - sealedFiles, nextFileSeq, err := scanSealedFiles(config.Path) - if err != nil { - return nil, err - } - - mutable, err := newWalFile(config.Path, nextFileSeq) - if err != nil { - return nil, fmt.Errorf("failed to open mutable WAL file: %w", err) - } - - ctx, cancel := context.WithCancelCause(context.Background()) - senderCtx, senderCancel := context.WithCancelCause(ctx) - - w := &walImpl{ - config: config, - fileLock: fileLock, - metricAttrs: walNameAttr(config.Name), - writerChan: make(chan any, config.WriteBufferSize), - ctx: ctx, - cancel: cancel, - senderCtx: senderCtx, - senderCancel: senderCancel, - mutableFile: mutable, - nextFileSeq: nextFileSeq + 1, - sealedFiles: sealedFiles, - samplerStop: make(chan struct{}), - } - // Recover the append-ordering position from the highest index already on disk. - if r := w.bounds(); r.ok { - w.lastAppendIndex = r.last - w.hasAppended = true - w.lastWrittenIndex = r.last - w.hasWritten = true - } - - w.wg.Add(1) - go w.writerLoop() - - if config.MetricsSampleInterval > 0 { - w.wg.Add(1) - go w.sampleQueueDepth(config.MetricsSampleInterval) - } - - // Ownership of the lock has passed to w (released by Close); disarm the open-failure cleanup above. - fileLock = nil - return w, nil -} - -// sampleQueueDepth periodically records the writer channel's buffered depth until Close stops it (samplerStop) -// or a fatal shutdown cancels ctx. -func (w *walImpl) sampleQueueDepth(interval time.Duration) { - defer w.wg.Done() - attrs := queueDepthAttrs(w.config.Name, "writer") - ticker := time.NewTicker(interval) - defer ticker.Stop() - for { - select { - case <-w.ctx.Done(): - return - case <-w.samplerStop: - return - case <-ticker.C: - walQueueDepth.Record(w.ctx, int64(len(w.writerChan)), attrs) - } - } -} - -// Append frames a record and schedules it for the writer, after enforcing that indices strictly increase. -func (w *walImpl) Append(index uint64, data []byte) error { - if w.closed { - return fmt.Errorf("WAL is closed") - } - - if w.hasAppended { - if w.config.PermitGaps { - if index <= w.lastAppendIndex { - return fmt.Errorf( - "append rejected: index %d is not greater than last appended index %d", - index, w.lastAppendIndex) - } - } else if index != w.lastAppendIndex+1 { - return fmt.Errorf( - "append rejected: index %d is not contiguous with last appended index %d (expected %d)", - index, w.lastAppendIndex, w.lastAppendIndex+1) - } - } - w.lastAppendIndex = index - w.hasAppended = true - - record := frameRecord(index, data) - if err := w.sendToWriter(dataToBeWritten{record: record, index: index}); err != nil { - return fmt.Errorf("failed to schedule append for index %d: %w", index, err) - } - return nil -} - -// Flush blocks until all previously scheduled appends are durable. -func (w *walImpl) Flush() error { - done := make(chan error, 1) - if err := w.sendToWriter(flushRequest{done: done}); err != nil { - return fmt.Errorf("failed to schedule flush: %w", err) - } - select { - case err := <-done: - return err // already wrapped by the writer, or nil on success - case <-w.ctx.Done(): - if err := w.asyncError(); err != nil { - return fmt.Errorf("flush aborted: %w", err) - } - return fmt.Errorf("flush aborted: %w", w.ctx.Err()) - } -} - -// Bounds reports the range of record indices stored in the WAL. -func (w *walImpl) Bounds() (bool, uint64, uint64, error) { - reply := make(chan storedRange, 1) - if err := w.sendToWriter(rangeQuery{reply: reply}); err != nil { - return false, 0, 0, fmt.Errorf("failed to schedule bounds query: %w", err) - } - select { - case r := <-reply: - return r.ok, r.first, r.last, nil - case <-w.ctx.Done(): - if err := w.asyncError(); err != nil { - return false, 0, 0, fmt.Errorf("bounds query aborted: %w", err) - } - return false, 0, 0, fmt.Errorf("bounds query aborted: %w", w.ctx.Err()) - } -} - -// PruneBefore schedules removal of whole sealed files below lowestIndexToKeep. It does not block on completion. -func (w *walImpl) PruneBefore(lowestIndexToKeep uint64) error { - if err := w.sendToWriter(pruneRequest{through: lowestIndexToKeep}); err != nil { - return fmt.Errorf("failed to schedule prune below index %d: %w", lowestIndexToKeep, err) - } - return nil -} - -// Iterator returns an iterator over the inclusive index range [startIndex, endIndex]. Construction runs on -// the writer goroutine (see iteratorStartRequest): the writer captures a hard-link snapshot of the files to -// read so later rotation and pruning cannot pull them out from under the iterator, and builds the iterator. -// The snapshot is removed by the iterator's Close. -func (w *walImpl) Iterator(startIndex uint64, endIndex uint64) (Iterator[[]byte], error) { - reply := make(chan iteratorStartResponse, 1) - req := iteratorStartRequest{startIndex: startIndex, endIndex: endIndex, reply: reply} - if err := w.sendToWriter(req); err != nil { - return nil, fmt.Errorf("failed to schedule iterator creation: %w", err) - } - select { - case resp := <-reply: - if resp.err != nil { - return nil, fmt.Errorf("failed to create iterator: %w", resp.err) - } - return resp.iterator, nil - case <-w.ctx.Done(): - if err := w.asyncError(); err != nil { - return nil, fmt.Errorf("iterator creation aborted: %w", err) - } - return nil, fmt.Errorf("iterator creation aborted: %w", w.ctx.Err()) - } -} - -// Close flushes pending appends, seals the mutable file, and releases resources. -func (w *walImpl) Close() error { - var closeErr error - w.closeOnce.Do(func() { - w.closed = true - close(w.samplerStop) // stop the queue-depth sampler before waiting for goroutines - done := make(chan error, 1) - if err := w.sendToWriter(closeRequest{done: done}); err == nil { - select { - case closeErr = <-done: - case <-w.ctx.Done(): - } - } - w.wg.Wait() - w.cancel(nil) // a clean close carries no fatal cause; a prior fail() already recorded one - // Release the directory lock only after every goroutine has stopped, so nothing touches the files - // after another owner could acquire the directory. - releaseDirLock(w.fileLock, w.config.Path) - }) - if err := w.asyncError(); err != nil { - return fmt.Errorf("WAL closed with error: %w", err) - } - return closeErr // already wrapped by the writer, or nil on a clean seal -} - -// sendToWriter enqueues a message onto the writer's input channel, aborting if the WAL is shutting down or has -// failed. -func (w *walImpl) sendToWriter(msg any) error { - // Prioritize shutdown: if the sender context is already done, never race the send case of the select - // below, which could otherwise enqueue onto a stopped writer's buffer and silently drop the record. - select { - case <-w.senderCtx.Done(): - return w.senderErr() - default: - } - select { - case w.writerChan <- msg: - return nil - case <-w.senderCtx.Done(): - return w.senderErr() - } -} - -// senderErr reports why a send was aborted: the fatal cause if the WAL bricked, or a plain closed error if it -// was shut down normally. -func (w *walImpl) senderErr() error { - if cause := context.Cause(w.senderCtx); cause != nil && cause != context.Canceled { - return fmt.Errorf("WAL failed: %w", cause) - } - return fmt.Errorf("WAL is closed") -} - -// writerLoop consumes messages, appending records to the mutable file and handling control messages. It owns -// all file bookkeeping and runs on its own goroutine until close or a fatal error. -func (w *walImpl) writerLoop() { - defer w.wg.Done() - for { - var msg any - select { - case <-w.ctx.Done(): - return - case msg = <-w.writerChan: - } - - switch m := msg.(type) { - case dataToBeWritten: - if err := w.appendRecord(m); err != nil { - w.fail(err) - return - } - case flushRequest: - err := w.mutableFile.flush(w.config.FsyncOnFlush) - m.done <- err - if err != nil { - w.fail(err) - return - } - case rangeQuery: - m.reply <- w.bounds() - case pruneRequest: - if err := w.pruneSealedFiles(m.through); err != nil { - w.fail(err) - return - } - case iteratorStartRequest: - resp := w.startIterator(m.startIndex, m.endIndex) - m.reply <- resp - // A rejected range and a non-corrupting snapshot I/O failure both leave the WAL healthy; only a - // failed flush of buffered records desyncs in-memory state from disk and bricks the pipeline. - if resp.fatal { - w.fail(resp.err) - return - } - case closeRequest: - _, err := w.mutableFile.seal() - m.done <- err - // FIFO guarantees every prior append has been processed. Forbid further pushes so any - // racing/future schedule aborts instead of deadlocking against the now-exiting writer. - w.senderCancel(nil) // normal shutdown, not a failure - return - } - } -} - -// appendRecord appends a record to the mutable file, updates bookkeeping, and rotates once the file exceeds -// the target size. Every record is complete, so any record is a valid rotation boundary. -func (w *walImpl) appendRecord(m dataToBeWritten) error { - // Authoritative ordering check: the caller-side gate in Append can be bypassed by concurrent callers - // (the WAL is documented single-threaded), so re-assert strict increase here on the one writer - // goroutine to reject a reordered record rather than write a file with inverted index bounds. - if w.hasWritten { - if w.config.PermitGaps { - if m.index <= w.lastWrittenIndex { - return fmt.Errorf( - "append out of order: index %d is not greater than last written index %d", - m.index, w.lastWrittenIndex) - } - } else if m.index != w.lastWrittenIndex+1 { - return fmt.Errorf( - "append out of order: index %d is not contiguous with last written index "+ - "%d (expected %d)", - m.index, w.lastWrittenIndex, w.lastWrittenIndex+1) - } - } - if err := w.mutableFile.writeRecord(m.record, m.index); err != nil { - return fmt.Errorf("failed to append record for index %d: %w", m.index, err) - } - w.lastWrittenIndex = m.index - w.hasWritten = true - walBytesWritten.Add(w.ctx, int64(len(m.record)), w.metricAttrs) - walRecordsWritten.Add(w.ctx, 1, w.metricAttrs) - - if w.mutableFile.size >= uint64(w.config.TargetFileSize) { - if err := w.rotate(); err != nil { - return fmt.Errorf("failed to rotate after index %d: %w", m.index, err) - } - } - return nil -} - -// rotate seals the current mutable file, records its bookkeeping, and opens a fresh mutable file. It is only -// called when the mutable file holds at least one record (immediately after a size-triggering append), so the -// seal always produces a sealed file rather than removing an empty one. -func (w *walImpl) rotate() error { - fileSeq := w.mutableFile.fileSeq - first := w.mutableFile.firstIndex - last := w.mutableFile.lastIndex - sealedName, err := w.mutableFile.seal() - if err != nil { - return fmt.Errorf("failed to seal WAL file during rotation: %w", err) - } - w.sealedFiles.PushBack(&sealedFileInfo{fileSeq: fileSeq, name: sealedName, firstIndex: first, lastIndex: last}) - walFilesSealed.Add(w.ctx, 1, w.metricAttrs) - - mutable, err := newWalFile(w.config.Path, w.nextFileSeq) - if err != nil { - return fmt.Errorf("failed to open new mutable WAL file during rotation: %w", err) - } - w.mutableFile = mutable - w.nextFileSeq++ - return nil -} - -// pruneSealedFiles deletes sealed files whose highest index is below pruneThrough. Files are removed -// oldest-first (from the front of the deque) with a directory fsync after each removal, so a crash mid-prune -// leaves a contiguous suffix of files rather than a gap in the sequence. The mutable file is never pruned. -// -// Pruning is unconditional with respect to live iterators: an iterator holds its own hard links to every file -// it reads (see startIterator), so removing a file's canonical name here only drops one link — the inode -// survives until the iterator's link is removed on Close. Pruning therefore need not know iterators exist. -// -// Iteration stops at the first retained file: index ranges grow toward the back, so once a file reaches -// pruneThrough every later file is kept too. -func (w *walImpl) pruneSealedFiles(pruneThrough uint64) error { - for { - front, ok := w.sealedFiles.TryPeekFront() - if !ok || front.lastIndex >= pruneThrough { - break - } - path := filepath.Join(w.config.Path, front.name) - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to prune WAL file %s: %w", path, err) - } - if err := util.SyncParentPath(path); err != nil { - return fmt.Errorf("failed to fsync directory after pruning %s: %w", path, err) - } - w.sealedFiles.PopFront() - walFilesPruned.Add(w.ctx, 1, w.metricAttrs) - } - return nil -} - -// startIterator builds an iterator on the writer goroutine. It captures a point-in-time snapshot by -// hard-linking every file the iterator will read into a private directory (iterator//) and bounding it -// at endIndex, which must not exceed the highest index stored now. Running here serializes the snapshot with -// rotation, seal, and prune. The live WAL may then rotate, seal, and prune freely: the iterator reads only its -// own hard links, which keep the underlying inodes alive until Close removes them. The mutable file is flushed -// (not fsynced) so its records are readable through the reader's separate handle — no crash can intervene -// between creation and use, so durability is irrelevant here. -// -// Failures split by whether they leave the WAL usable. A range rejection (ErrIteratorRange) is a caller error, -// and a snapshot failure (MkdirAll/os.Link) touches only the ephemeral iterator// lease directory, so -// both are returned non-fatally and the WAL keeps running. Only a failed mutable-file flush is fatal: it -// poisons the file's buffered writer and leaves in-memory bookkeeping ahead of what is durable, so the caller -// marks the response fatal to brick the pipeline. -func (w *walImpl) startIterator(startIndex uint64, endIndex uint64) iteratorStartResponse { - if endIndex < startIndex { - return iteratorStartResponse{ - err: fmt.Errorf( - "%w: end index %d is below start index %d", ErrIteratorRange, endIndex, startIndex), - } - } - r := w.bounds() - if !r.ok { - return iteratorStartResponse{ - err: fmt.Errorf("%w: end index %d requested but the WAL is empty", ErrIteratorRange, endIndex), - } - } - if endIndex > r.last { - return iteratorStartResponse{ - err: fmt.Errorf( - "%w: end index %d is beyond the latest stored index %d", - ErrIteratorRange, endIndex, r.last), - } - } - maxIndex := endIndex - - // Gather the files to read: those whose range overlaps [startIndex, endIndex], plus the mutable file if it - // holds records in range. Files entirely below startIndex or entirely above endIndex are never opened, so - // they are not linked. The mutable snapshot's range is capped at maxIndex; the reader drops anything the - // writer appends past it. - var sources []iteratorFile - for _, info := range w.sealedFiles.Iterator() { - if info.lastIndex < startIndex || info.firstIndex > endIndex { - continue - } - sources = append(sources, iteratorFile{ - fileSeq: info.fileSeq, name: info.name, - firstIndex: info.firstIndex, lastIndex: info.lastIndex, sealed: true, - }) - } - // Flush the mutable file only when it is actually needed to cover the range. When endIndex is fully served - // by sealed files, the mutable file is left untouched. - if w.mutableFile.hasRecords && w.mutableFile.lastIndex >= startIndex && w.mutableFile.firstIndex <= endIndex { - if err := w.mutableFile.flush(false); err != nil { - return iteratorStartResponse{ - err: fmt.Errorf("failed to flush mutable file for iterator: %w", err), - fatal: true, - } - } - sources = append(sources, iteratorFile{ - fileSeq: w.mutableFile.fileSeq, name: unsealedFileName(w.mutableFile.fileSeq), - firstIndex: w.mutableFile.firstIndex, lastIndex: maxIndex, sealed: false, - }) - } - - if len(sources) == 0 { - // Nothing at or above startIndex to read; no snapshot directory needed. - it := newWalIterator(w, startIndex, maxIndex, "", nil, w.config.IteratorPrefetchSize) - return iteratorStartResponse{iterator: it} - } - - dir := iteratorLinkDir(w.config.Path, w.nextIteratorSeq) - if err := w.linkSnapshot(dir, sources); err != nil { - _ = os.RemoveAll(dir) - return iteratorStartResponse{err: err} - } - w.nextIteratorSeq++ - it := newWalIterator(w, startIndex, maxIndex, dir, sources, w.config.IteratorPrefetchSize) - return iteratorStartResponse{iterator: it} -} - -// linkSnapshot creates dir and hard-links each source file into it under the source's basename. The links keep -// the underlying inodes alive for the iterator even after the WAL rotates or prunes the originals. No fsync: -// the links are ephemeral read-side leases, reclaimed on Close or, after a crash, at the next open. -func (w *walImpl) linkSnapshot(dir string, sources []iteratorFile) error { - if err := os.MkdirAll(dir, 0o750); err != nil { - return fmt.Errorf("failed to create iterator snapshot directory %s: %w", dir, err) - } - for _, f := range sources { - src := filepath.Join(w.config.Path, f.name) - if err := os.Link(src, filepath.Join(dir, f.name)); err != nil { - return fmt.Errorf("failed to hard-link %s for iterator: %w", f.name, err) - } - } - return nil -} - -// bounds reports the range of record indices across all files. Owned by the writer goroutine. -func (w *walImpl) bounds() storedRange { - var r storedRange - - // The highest index is in the mutable file if it has records, otherwise in the newest sealed file. - if w.mutableFile.hasRecords { - r = storedRange{ok: true, last: w.mutableFile.lastIndex} - } else if back, ok := w.sealedFiles.TryPeekBack(); ok { - r = storedRange{ok: true, last: back.lastIndex} - } else { - return storedRange{} // nothing stored yet - } - - // The lowest index is in the oldest sealed file if any, otherwise in the mutable file. - if front, ok := w.sealedFiles.TryPeekFront(); ok { - r.first = front.firstIndex - } else { - r.first = w.mutableFile.firstIndex - } - return r -} - -// fail records the first fatal background error and triggers shutdown of the pipeline. The error is recorded -// as the cancellation cause of ctx, so callers observe it via asyncError / context.Cause. -func (w *walImpl) fail(err error) { - w.senderCancel(err) - w.cancel(err) // the first cancel wins, so the first fatal error is the one retained - if cerr := w.mutableFile.close(); cerr != nil { - logger.Error("failed to close mutable WAL file after fatal error", "err", cerr) - } - logger.Error("WAL encountered a fatal error", "err", err) -} - -// asyncError returns the first fatal background error, or nil if the WAL is healthy or was closed normally. -func (w *walImpl) asyncError() error { - if cause := context.Cause(w.ctx); cause != nil && cause != context.Canceled { - return cause - } - return nil -} - -// recoverOrphans seals any unsealed WAL files left behind by a crash. -func recoverOrphans(directory string) error { - entries, err := os.ReadDir(directory) - if err != nil { - return fmt.Errorf("failed to read WAL directory %s: %w", directory, err) - } - for _, entry := range entries { - if entry.IsDir() { - continue - } - parsed, ok := parseFileName(entry.Name()) - if !ok || parsed.sealed { - continue - } - if err := sealOrphanFile(directory, entry.Name()); err != nil { - return fmt.Errorf("failed to seal orphan %s: %w", entry.Name(), err) - } - } - return nil -} - -// rollbackDirectory drops all records beyond rollbackThrough from the sealed files. Assumes orphans are already -// sealed. Files are processed highest-sequence-first: the files entirely beyond the rollback point (a suffix of -// the sequence) are removed one at a time, each removal made durable before the next, and finally the single -// file straddling the rollback point is truncated. This ordering guarantees that a crash mid-rollback always -// leaves a contiguous prefix of files — never a gap that scanSealedFiles would reject — mirroring the -// contiguous-suffix guarantee that pruning provides from the other end. -func rollbackDirectory(directory string, rollbackThrough uint64) error { - entries, err := os.ReadDir(directory) - if err != nil { - return fmt.Errorf("failed to read WAL directory %s: %w", directory, err) - } - - sealed := make([]parsedFileName, 0, len(entries)) - names := make(map[uint64]string, len(entries)) - for _, entry := range entries { - if entry.IsDir() { - continue - } - parsed, ok := parseFileName(entry.Name()) - if !ok || !parsed.sealed { - continue - } - sealed = append(sealed, parsed) - names[parsed.fileSeq] = entry.Name() - } - sort.Slice(sealed, func(i int, j int) bool { return sealed[i].fileSeq > sealed[j].fileSeq }) - - for _, parsed := range sealed { - if parsed.lastIndex <= rollbackThrough { - // This file lies entirely at or below the rollback point; so does every lower-sequence - // file. Done. - break - } - if parsed.firstIndex > rollbackThrough { - // Entirely beyond the rollback point: remove the whole file, durably, before the - // next-lower one. - if err := removeAndSyncDir(directory, names[parsed.fileSeq]); err != nil { - return fmt.Errorf("failed to roll back %s: %w", names[parsed.fileSeq], err) - } - continue - } - // Straddles the rollback point: truncate away the records beyond it. This is the last file to process. - if err := rollbackStraddlingFile(directory, names[parsed.fileSeq], rollbackThrough); err != nil { - return fmt.Errorf("failed to roll back %s: %w", names[parsed.fileSeq], err) - } - } - return nil -} - -// scanSealedFiles loads the sealed files in a directory into an ascending-order deque and returns the sequence -// to assign the next mutable file (one past the highest sealed sequence, or 0 when there are none). File -// sequences must be contiguous: a gap means a sealed file went missing, which is unrecoverable corruption, so -// this fails with an informative error rather than silently leaving a hole in the index sequence. -func scanSealedFiles(directory string) (*util.RandomAccessDeque[*sealedFileInfo], uint64, error) { - entries, err := os.ReadDir(directory) - if err != nil { - return nil, 0, fmt.Errorf("failed to read WAL directory %s: %w", directory, err) - } - - parsed := make([]parsedFileName, 0, len(entries)) - names := make(map[uint64]string, len(entries)) - for _, entry := range entries { - if entry.IsDir() { - continue - } - p, ok := parseFileName(entry.Name()) - if !ok || !p.sealed { - continue - } - parsed = append(parsed, p) - names[p.fileSeq] = entry.Name() - } - sort.Slice(parsed, func(i int, j int) bool { return parsed[i].fileSeq < parsed[j].fileSeq }) - - sealedFiles := util.NewRandomAccessDeque[*sealedFileInfo](uint64(len(parsed))) - var nextFileSeq uint64 - for i, p := range parsed { - if i > 0 && p.fileSeq != parsed[i-1].fileSeq+1 { - return nil, 0, fmt.Errorf( - "WAL is corrupt: sealed file sequences are not contiguous (gap between %d and %d)", - parsed[i-1].fileSeq, p.fileSeq) - } - sealedFiles.PushBack(&sealedFileInfo{ - fileSeq: p.fileSeq, name: names[p.fileSeq], firstIndex: p.firstIndex, lastIndex: p.lastIndex}) - nextFileSeq = p.fileSeq + 1 - } - return sealedFiles, nextFileSeq, nil -} diff --git a/sei-db/seiwal/seiwal_impl_test.go b/sei-db/seiwal/seiwal_impl_test.go deleted file mode 100644 index 4bcd13787a..0000000000 --- a/sei-db/seiwal/seiwal_impl_test.go +++ /dev/null @@ -1,1344 +0,0 @@ -package seiwal - -import ( - "context" - "fmt" - "os" - "path/filepath" - "sort" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -// TestQueueDepthSamplerRunsAndStops exercises the queue-depth sampler goroutine on a tiny interval: it must -// sample the writer channel concurrently with appends (validated by the race detector) and shut down cleanly -// on Close. -func TestQueueDepthSamplerRunsAndStops(t *testing.T) { - cfg := testConfig(t.TempDir()) - cfg.MetricsSampleInterval = time.Millisecond - w := openWAL(t, cfg) - for index := uint64(1); index <= 300; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Flush()) - require.NoError(t, w.Close()) -} - -func testConfig(dir string) *Config { - return DefaultConfig(dir, "test") -} - -func openWAL(t *testing.T, cfg *Config) WAL[[]byte] { - t.Helper() - w, err := NewWAL(cfg) - require.NoError(t, err) - return w -} - -// recordPayload returns a deterministic payload for a record index. -func recordPayload(index uint64) []byte { - return []byte(fmt.Sprintf("payload-%d", index)) -} - -// appendRecord appends a record with recordPayload(index) at the given index. -func appendRecord(t *testing.T, w WAL[[]byte], index uint64) { - t.Helper() - require.NoError(t, w.Append(index, recordPayload(index))) -} - -// collectIndices iterates the inclusive range [start, end] and returns the index of each record, verifying -// that indices are strictly increasing and never below start or above end. -func collectIndices(t *testing.T, w WAL[[]byte], start uint64, end uint64) []uint64 { - t.Helper() - it, err := w.Iterator(start, end) - require.NoError(t, err) - defer func() { require.NoError(t, it.Close()) }() - - var indices []uint64 - for { - ok, err := it.Next() - require.NoError(t, err) - if !ok { - break - } - index, _ := it.Entry() - require.GreaterOrEqual(t, index, start) - require.LessOrEqual(t, index, end) - if len(indices) > 0 { - require.Greater(t, index, indices[len(indices)-1]) - } - indices = append(indices, index) - } - return indices -} - -func countSealedFiles(t *testing.T, dir string) int { - t.Helper() - entries, err := os.ReadDir(dir) - require.NoError(t, err) - count := 0 - for _, entry := range entries { - if parsed, ok := parseFileName(entry.Name()); ok && parsed.sealed { - count++ - } - } - return count -} - -// sealedFileNames returns the names of all sealed WAL files in dir, sorted for stable assertions. -func sealedFileNames(t *testing.T, dir string) []string { - t.Helper() - entries, err := os.ReadDir(dir) - require.NoError(t, err) - var names []string - for _, entry := range entries { - if parsed, ok := parseFileName(entry.Name()); ok && parsed.sealed { - names = append(names, entry.Name()) - } - } - sort.Strings(names) - return names -} - -func TestAppendFlushReopenBounds(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - - w := openWAL(t, cfg) - for index := uint64(1); index <= 5; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Flush()) - - ok, first, last, err := w.Bounds() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), first) - require.Equal(t, uint64(5), last) - require.NoError(t, w.Close()) - - w2 := openWAL(t, cfg) - defer func() { require.NoError(t, w2.Close()) }() - - ok, first, last, err = w2.Bounds() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), first) - require.Equal(t, uint64(5), last) - - require.Equal(t, []uint64{1, 2, 3, 4, 5}, collectIndices(t, w2, 1, 5)) -} - -func TestAppendOrdering(t *testing.T) { - t.Run("contiguous indices are required by default", func(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) - defer func() { require.NoError(t, w.Close()) }() - require.NoError(t, w.Append(5, recordPayload(5))) // first append sets the baseline - require.Error(t, w.Append(4, recordPayload(4))) // lower than the last index - require.Error(t, w.Append(5, recordPayload(5))) // equal to the last index - require.Error(t, w.Append(7, recordPayload(7))) // a gap: not exactly last+1 - require.NoError(t, w.Append(6, recordPayload(6))) // contiguous - }) - - t.Run("first append may start at any index", func(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) - defer func() { require.NoError(t, w.Close()) }() - require.NoError(t, w.Append(12345, recordPayload(12345))) - require.NoError(t, w.Append(12346, recordPayload(12346))) - require.Error(t, w.Append(12348, recordPayload(12348))) - }) - - t.Run("contiguity resumes after reopen", func(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - w := openWAL(t, cfg) - for index := uint64(1); index <= 3; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Flush()) - require.NoError(t, w.Close()) - - w2 := openWAL(t, cfg) - defer func() { require.NoError(t, w2.Close()) }() - require.Error(t, w2.Append(5, recordPayload(5))) // a gap after the recovered baseline of 3 - require.NoError(t, w2.Append(4, recordPayload(4))) // contiguous with the recovered baseline - }) - - t.Run("non-contiguous indices are allowed when gaps permitted", func(t *testing.T) { - cfg := testConfig(t.TempDir()) - cfg.PermitGaps = true - w := openWAL(t, cfg) - defer func() { require.NoError(t, w.Close()) }() - require.NoError(t, w.Append(1, recordPayload(1))) - require.NoError(t, w.Append(3, recordPayload(3))) - require.NoError(t, w.Append(100, recordPayload(100))) - require.NoError(t, w.Flush()) - require.Equal(t, []uint64{1, 3, 100}, collectIndices(t, w, 0, 100)) - }) - - t.Run("empty payload is allowed", func(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) - defer func() { require.NoError(t, w.Close()) }() - require.NoError(t, w.Append(1, nil)) - require.NoError(t, w.Append(2, []byte{})) - require.NoError(t, w.Flush()) - - it, err := w.Iterator(1, 2) - require.NoError(t, err) - defer func() { require.NoError(t, it.Close()) }() - ok, err := it.Next() - require.NoError(t, err) - require.True(t, ok) - index, data := it.Entry() - require.Equal(t, uint64(1), index) - require.Empty(t, data) - }) -} - -// TestWriterRejectsOutOfOrderRecord exercises the writer-goroutine backstop directly. The caller-side gate -// in Append can be bypassed by concurrent misuse (the reordering race is non-deterministic), so appendRecord -// re-asserts strict index increase itself. Driving appendRecord on a standalone walImpl (no running writer -// loop) verifies that a non-increasing index is rejected rather than written with inverted bounds. -func TestWriterRejectsOutOfOrderRecord(t *testing.T) { - dir := t.TempDir() - mf, err := newWalFile(dir, 0) - require.NoError(t, err) - w := &walImpl{ - config: testConfig(dir), - metricAttrs: walNameAttr("test"), - ctx: context.Background(), - mutableFile: mf, - } - defer func() { _, _ = w.mutableFile.seal() }() - - write := func(index uint64) error { - return w.appendRecord(dataToBeWritten{record: frameRecord(index, recordPayload(index)), index: index}) - } - - require.NoError(t, write(5)) - require.Error(t, write(4)) // lower than last written - require.Error(t, write(5)) // equal to last written - require.NoError(t, write(6)) - require.Error(t, write(8)) // a gap: not exactly last+1 (the default forbids gaps) - require.Equal(t, uint64(6), w.lastWrittenIndex) -} - -// TestWriterBackstopPermitsGapsWhenConfigured verifies that with PermitGaps enabled the writer-goroutine -// backstop only rejects non-increasing indices, allowing gaps. -func TestWriterBackstopPermitsGapsWhenConfigured(t *testing.T) { - dir := t.TempDir() - mf, err := newWalFile(dir, 0) - require.NoError(t, err) - cfg := testConfig(dir) - cfg.PermitGaps = true - w := &walImpl{ - config: cfg, - metricAttrs: walNameAttr("test"), - ctx: context.Background(), - mutableFile: mf, - } - defer func() { _, _ = w.mutableFile.seal() }() - - write := func(index uint64) error { - return w.appendRecord(dataToBeWritten{record: frameRecord(index, recordPayload(index)), index: index}) - } - - require.NoError(t, write(5)) - require.NoError(t, write(9)) // a gap is allowed when gaps are permitted - require.Error(t, write(9)) // equal to last written - require.Error(t, write(3)) // lower than last written - require.Equal(t, uint64(9), w.lastWrittenIndex) -} - -// TestFailReleasesMutableFile verifies that a fatal error releases the mutable file's handle (rather than -// leaking the fd until process exit) and that the release is idempotent. -func TestFailReleasesMutableFile(t *testing.T) { - dir := t.TempDir() - mf, err := newWalFile(dir, 0) - require.NoError(t, err) - ctx, cancel := context.WithCancelCause(context.Background()) - senderCtx, senderCancel := context.WithCancelCause(ctx) - w := &walImpl{ - config: testConfig(dir), - metricAttrs: walNameAttr("test"), - ctx: ctx, - cancel: cancel, - senderCtx: senderCtx, - senderCancel: senderCancel, - mutableFile: mf, - } - require.NoError(t, w.appendRecord(dataToBeWritten{record: frameRecord(1, recordPayload(1)), index: 1})) - - w.fail(fmt.Errorf("boom")) - - require.Nil(t, w.mutableFile.file) // fd released - require.Error(t, w.asyncError()) // failure recorded - require.NoError(t, w.mutableFile.close()) // idempotent -} - -// TestFlushIOFailureBricksWAL verifies that an IO error during Flush is fatal: the failure is surfaced to the -// flushing caller, the WAL then refuses all further work, and Close reports the original error — matching how -// every other writer IO error is handled, so a broken durability guarantee is never silently tolerated. -func TestFlushIOFailureBricksWAL(t *testing.T) { - dir := t.TempDir() - w := openWAL(t, testConfig(dir)) - - impl, ok := w.(*walImpl) - require.True(t, ok) - - // Force the next flush to fail by closing the mutable file's descriptor out from under the writer. The - // writer is idle (blocked awaiting a message) and never reassigns the handle, and appending only buffers - // bytes, so this affects nothing until the flush attempts to write/fsync the closed descriptor. - require.NoError(t, impl.mutableFile.file.Close()) - - require.NoError(t, w.Append(1, recordPayload(1))) - require.Error(t, w.Flush(), "flush must surface the IO failure") - - // Bricking cancels the context; wait for it so the "refuses further work" assertions are deterministic - // (Flush may return the moment the error is sent, a hair before fail() finishes tearing down). - select { - case <-impl.ctx.Done(): - case <-time.After(5 * time.Second): - t.Fatal("WAL did not brick after flush failure") - } - - require.Error(t, w.Append(2, recordPayload(2)), "appends must fail on a bricked WAL") - require.Error(t, w.Flush(), "flush must fail on a bricked WAL") - _, _, _, err := w.Bounds() - require.Error(t, err, "bounds must fail on a bricked WAL") - - require.Error(t, w.Close(), "Close must surface the fatal flush error") - require.Error(t, impl.asyncError()) -} - -func TestOrphanFileRecovery(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - - // Fabricate an orphaned unsealed file: records 1 and 2 intact, a torn record 3, left unsealed as if the - // process crashed before it could seal. - f, err := newWalFile(dir, 0) - require.NoError(t, err) - writeRecordTo(t, f, 1, recordPayload(1)) - writeRecordTo(t, f, 2, recordPayload(2)) - frame := frameRecord(3, recordPayload(3)) - require.NoError(t, f.flush(false)) - _, err = f.writer.Write(frame[:len(frame)-3]) // torn record 3 - require.NoError(t, err) - require.NoError(t, f.flush(true)) - require.NoError(t, f.file.Close()) - - w := openWAL(t, cfg) - defer func() { require.NoError(t, w.Close()) }() - - ok, first, last, err := w.Bounds() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), first) - require.Equal(t, uint64(2), last) - require.Equal(t, []uint64{1, 2}, collectIndices(t, w, 1, 2)) -} - -func TestRotationProducesContiguousSealedFiles(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = 1 // rotate after every record - - w := openWAL(t, cfg) - for index := uint64(1); index <= 6; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Flush()) - - ok, first, last, err := w.Bounds() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), first) - require.Equal(t, uint64(6), last) - require.Equal(t, []uint64{1, 2, 3, 4, 5, 6}, collectIndices(t, w, 1, 6)) - require.NoError(t, w.Close()) - - // Every record should have produced its own sealed file with a clean [k,k] range. - var sealed []parsedFileName - entries, err := os.ReadDir(dir) - require.NoError(t, err) - for _, entry := range entries { - if parsed, okName := parseFileName(entry.Name()); okName && parsed.sealed { - sealed = append(sealed, parsed) - require.Equal(t, parsed.firstIndex, parsed.lastIndex) - } - } - require.Len(t, sealed, 6) -} - -func TestRecordNeverSplitAcrossFiles(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = 128 // tiny, so a single record dwarfs the rotation threshold - - w := openWAL(t, cfg) - defer func() { require.NoError(t, w.Close()) }() - - // Two records, each far larger than TargetFileSize. - big1 := make([]byte, 4096) - big2 := make([]byte, 4096) - for i := range big1 { - big1[i] = byte(i) - big2[i] = byte(i + 1) - } - require.NoError(t, w.Append(1, big1)) - require.NoError(t, w.Append(2, big2)) - require.NoError(t, w.Flush()) - - // Each oversized record rotated into its own file, intact — never split across files. - require.Equal(t, 2, countSealedFiles(t, dir)) - - it, err := w.Iterator(1, 2) - require.NoError(t, err) - defer func() { require.NoError(t, it.Close()) }() - - ok, err := it.Next() - require.NoError(t, err) - require.True(t, ok) - index, data := it.Entry() - require.Equal(t, uint64(1), index) - require.Equal(t, big1, data) - - ok, err = it.Next() - require.NoError(t, err) - require.True(t, ok) - index, data = it.Entry() - require.Equal(t, uint64(2), index) - require.Equal(t, big2, data) - - ok, err = it.Next() - require.NoError(t, err) - require.False(t, ok) -} - -func TestPruneDropsWholeFiles(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = 1 // one record per file, so pruning can drop whole files - - w := openWAL(t, cfg) - defer func() { require.NoError(t, w.Close()) }() - for index := uint64(1); index <= 10; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Flush()) - - require.NoError(t, w.PruneBefore(5)) - - ok, first, last, err := w.Bounds() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(5), first) - require.Equal(t, uint64(10), last) - require.Equal(t, []uint64{5, 6, 7, 8, 9, 10}, collectIndices(t, w, 0, 10)) -} - -func TestPrunePastAllRecordsEmptiesRange(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = 1 // one record per file so every record sits in a prunable sealed file - - w := openWAL(t, cfg) - defer func() { require.NoError(t, w.Close()) }() - for index := uint64(1); index <= 5; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Flush()) - - require.NoError(t, w.PruneBefore(100)) - - ok, _, _, err := w.Bounds() - require.NoError(t, err) - require.False(t, ok) -} - -// TestIteratorWithEmptySnapshotDoesNotBlockPruning covers an iterator whose requested range falls entirely in -// an index gap, so its file snapshot is empty (no hard links, no private directory) and it holds nothing on -// disk. Held open across a prune, it neither yields anything nor impedes pruning, which proceeds -// unconditionally. -func TestIteratorWithEmptySnapshotDoesNotBlockPruning(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = 1 // one record per sealed file, so pruning works file-by-file - cfg.PermitGaps = true // the requested range lands in a gap - - w := openWAL(t, cfg) - defer func() { require.NoError(t, w.Close()) }() - - // Indices 1,2,3 then a legal jump to 10,11,12. No file covers the range [5, 8], so the snapshot is empty. - for _, index := range []uint64{1, 2, 3, 10, 11, 12} { - appendRecord(t, w, index) - } - require.NoError(t, w.Flush()) - - // Create the empty-snapshot iterator and hold it open (never closed until the end). - it, err := w.Iterator(5, 8) - require.NoError(t, err) - ok, err := it.Next() - require.NoError(t, err) - require.False(t, ok, "an iterator whose range falls in a gap yields no records") - - require.NoError(t, w.PruneBefore(11)) - stored, first, last, err := w.Bounds() - require.NoError(t, err) - require.True(t, stored) - require.Equal(t, uint64(11), first, "the empty-snapshot iterator must not block pruning") - require.Equal(t, uint64(12), last) - - require.NoError(t, it.Close()) -} - -// drainIndices reads an already-open iterator to exhaustion and returns the indices it yields. -func drainIndices(t *testing.T, it Iterator[[]byte]) []uint64 { - t.Helper() - var indices []uint64 - for { - ok, err := it.Next() - require.NoError(t, err) - if !ok { - break - } - index, _ := it.Entry() - indices = append(indices, index) - } - return indices -} - -// TestActiveIteratorReadsThroughUnconditionalPruning verifies the hard-link snapshot model: pruning is -// unconditional (it advances Bounds and removes canonical files immediately, without regard for live -// iterators), yet an iterator opened before the prune still yields its full snapshot, because it reads through -// its own hard links whose inodes survive the prune. -func TestActiveIteratorReadsThroughUnconditionalPruning(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = 1 // one record per sealed file, so pruning works file-by-file - - w := openWAL(t, cfg) - defer func() { require.NoError(t, w.Close()) }() - for index := uint64(1); index <= 10; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Flush()) - - // Snapshot indices 1..10 (hard-linked) at creation. - it, err := w.Iterator(1, 10) - require.NoError(t, err) - - // Pruning proceeds unconditionally: Bounds advances even though the iterator is live. - require.NoError(t, w.PruneBefore(5)) - ok, first, last, err := w.Bounds() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(5), first, "pruning advances the range immediately; it no longer waits for iterators") - require.Equal(t, uint64(10), last) - - // The live iterator still yields the full intact sequence from its hard-link snapshot. - require.Equal(t, []uint64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, drainIndices(t, it)) - require.NoError(t, it.Close()) - - // A fresh iterator sees only the post-prune range. - require.Equal(t, []uint64{5, 6, 7, 8, 9, 10}, collectIndices(t, w, 0, 10)) -} - -func TestIteratorAnchoredAboveKeepPointDoesNotBlockPruning(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = 1 - - w := openWAL(t, cfg) - defer func() { require.NoError(t, w.Close()) }() - for index := uint64(1); index <= 10; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Flush()) - - // An iterator anchored at index 8 does not need records below 5, so pruning to 5 proceeds. - it, err := w.Iterator(8, 10) - require.NoError(t, err) - defer func() { require.NoError(t, it.Close()) }() - - require.NoError(t, w.PruneBefore(5)) - ok, first, _, err := w.Bounds() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(5), first) -} - -// TestIteratorAcrossGapReadsThroughPruning covers the index-gap case: indices may jump, so an iterator's -// snapshot spans a gap between files. Unconditional pruning removes the canonical files, but the iterator -// reads its gap-spanning snapshot through its hard links. -func TestIteratorAcrossGapReadsThroughPruning(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = 1 // one record per sealed file - cfg.PermitGaps = true // this test exercises index gaps - - w := openWAL(t, cfg) - defer func() { require.NoError(t, w.Close()) }() - // Indices 1,2,3 then a legal jump to 10,11,12. The start index 5 falls in the gap (3, 10). - for _, index := range []uint64{1, 2, 3, 10, 11, 12} { - appendRecord(t, w, index) - } - require.NoError(t, w.Flush()) - - // Snapshot links the files reaching index 5 or higher: those for 10, 11, 12. Files 1,2,3 are below the - // start and are not linked. - it, err := w.Iterator(5, 12) - require.NoError(t, err) - defer func() { require.NoError(t, it.Close()) }() - - // Prune(12) removes every canonical file with last index < 12 (indices 1,2,3,10,11); only 12 remains named. - require.NoError(t, w.PruneBefore(12)) - ok, first, last, err := w.Bounds() // synchronous round-trip forces the async prune to complete - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(12), first) - require.Equal(t, uint64(12), last) - names := sealedFileNames(t, dir) - require.NotContains(t, names, sealedFileName(3, 10, 10), "canonical file for index 10 is pruned") - - // The live iterator still yields the gap-spanning snapshot through its hard links. - require.Equal(t, []uint64{10, 11, 12}, drainIndices(t, it)) -} - -// TestIteratorReadsThroughPruningPastAnchor checks the boundary where the start index sits within the kept -// window: an iterator anchored at 5 keeps yielding 5..10 through its hard links even as pruning removes the -// canonical files up through a higher point. -func TestIteratorReadsThroughPruningPastAnchor(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = 1 // one record per sealed file - - w := openWAL(t, cfg) - defer func() { require.NoError(t, w.Close()) }() - for index := uint64(1); index <= 10; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Flush()) - - it, err := w.Iterator(5, 10) - require.NoError(t, err) - defer func() { require.NoError(t, it.Close()) }() - - require.NoError(t, w.PruneBefore(8)) - ok, first, last, err := w.Bounds() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(8), first, "pruning advances past the iterator's anchor unconditionally") - require.Equal(t, uint64(10), last) - require.Equal(t, []uint64{5, 6, 7, 8, 9, 10}, drainIndices(t, it)) -} - -// TestIteratorDoesNotSealMutableFile verifies the core of the change: opening an iterator over records still -// in the mutable file reads them via a hard-link snapshot without sealing or rotating, so frequent iteration -// creates no sealed files and the mutable file keeps accepting contiguous appends. -func TestIteratorDoesNotSealMutableFile(t *testing.T) { - dir := t.TempDir() - w := openWAL(t, testConfig(dir)) // large default target: no size-based rotation - defer func() { require.NoError(t, w.Close()) }() - for index := uint64(1); index <= 3; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Flush()) - require.Equal(t, 0, countSealedFiles(t, dir), "records stay in the mutable file; nothing sealed yet") - - it, err := w.Iterator(1, 3) - require.NoError(t, err) - require.Equal(t, 0, countSealedFiles(t, dir), "opening an iterator must not seal the mutable file") - require.Equal(t, []uint64{1, 2, 3}, drainIndices(t, it)) - require.NoError(t, it.Close()) - - // The mutable file is untouched, so appends continue contiguously. - appendRecord(t, w, 4) - require.NoError(t, w.Flush()) - require.Equal(t, []uint64{1, 2, 3, 4}, collectIndices(t, w, 1, 4)) -} - -// unsealedFilePath returns the path of the single mutable (unsealed) WAL file in dir. -func unsealedFilePath(t *testing.T, dir string) string { - t.Helper() - entries, err := os.ReadDir(dir) - require.NoError(t, err) - var name string - for _, entry := range entries { - if parsed, ok := parseFileName(entry.Name()); ok && !parsed.sealed { - require.Empty(t, name, "expected exactly one mutable file") - name = entry.Name() - } - } - require.NotEmpty(t, name, "no mutable file found") - return filepath.Join(dir, name) -} - -// onDiskSize returns the number of bytes currently written to disk for the file at path. Records buffered in -// the mutable file's writer but not yet flushed do not count. -func onDiskSize(t *testing.T, path string) int64 { - t.Helper() - info, err := os.Stat(path) - require.NoError(t, err) - return info.Size() -} - -// TestIteratorDoesNotFlushMutableFileOutsideRange verifies that when the requested range is fully covered by -// sealed files, the mutable file (which holds only higher indices) is left unflushed — its buffered records -// stay off disk. As a positive control, a range that reaches into the mutable file does flush it. -func TestIteratorDoesNotFlushMutableFileOutsideRange(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = 250 // seals after three ~107-byte records, leaving the rest in the mutable file - w := openWAL(t, cfg) - defer func() { require.NoError(t, w.Close()) }() - - payload := make([]byte, 100) - for index := uint64(1); index <= 5; index++ { - require.NoError(t, w.Append(index, payload)) - } - // Bounds is a writer-goroutine round-trip that drains the appends without flushing the mutable buffer. - ok, _, last, err := w.Bounds() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(5), last) - require.Equal(t, 1, countSealedFiles(t, dir), - "records 1..3 sealed into one file; 4,5 buffered in the mutable file") - - mutablePath := unsealedFilePath(t, dir) - before := onDiskSize(t, mutablePath) - - // Range [1,3] is fully covered by the sealed file; the mutable file (first index 4) must not be flushed. - it, err := w.Iterator(1, 3) - require.NoError(t, err) - require.Equal(t, before, onDiskSize(t, mutablePath), - "the mutable file must not be flushed when outside the range") - require.Equal(t, []uint64{1, 2, 3}, drainIndices(t, it)) - require.NoError(t, it.Close()) - - // Positive control: a range reaching into the mutable file flushes it, so its bytes reach disk. - it2, err := w.Iterator(1, 5) - require.NoError(t, err) - require.Greater(t, onDiskSize(t, mutablePath), before, "the mutable file is flushed when the range needs it") - require.Equal(t, []uint64{1, 2, 3, 4, 5}, drainIndices(t, it2)) - require.NoError(t, it2.Close()) -} - -// TestIteratorDoesNotLinkFilesOutsideRange verifies that only the sealed files overlapping the requested range -// are hard-linked into the iterator's snapshot directory; files entirely below the start or above the end are -// not linked. -func TestIteratorDoesNotLinkFilesOutsideRange(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = 1 // one record per sealed file - w := openWAL(t, cfg) - defer func() { require.NoError(t, w.Close()) }() - for index := uint64(1); index <= 6; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Flush()) - - // Range [2,4] overlaps only the files for indices 2, 3, and 4. - it, err := w.Iterator(2, 4) - require.NoError(t, err) - - linkDir := iteratorLinkDir(dir, 0) // first iterator gets serial 0 - linked := sealedFileNames(t, linkDir) - require.Equal(t, []string{ - sealedFileName(1, 2, 2), - sealedFileName(2, 3, 3), - sealedFileName(3, 4, 4), - }, linked, "only files overlapping [2,4] are linked; those below 2 or above 4 are not") - - require.Equal(t, []uint64{2, 3, 4}, drainIndices(t, it)) - require.NoError(t, it.Close()) -} - -// TestIteratorExcludesRecordsAppendedAfterCreation verifies the point-in-time cap: records appended (and -// durably flushed) into the mutable file after the iterator was created are excluded, even though the reader's -// hard link points at the same, now-larger inode. -func TestIteratorExcludesRecordsAppendedAfterCreation(t *testing.T) { - dir := t.TempDir() - w := openWAL(t, testConfig(dir)) - defer func() { require.NoError(t, w.Close()) }() - for index := uint64(1); index <= 3; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Flush()) - - it, err := w.Iterator(1, 3) // snapshot caps at index 3 - require.NoError(t, err) - defer func() { require.NoError(t, it.Close()) }() - - appendRecord(t, w, 4) - appendRecord(t, w, 5) - require.NoError(t, w.Flush()) - - require.Equal(t, []uint64{1, 2, 3}, drainIndices(t, it)) -} - -// TestIteratorSnapshotDirLifecycle verifies that an iterator's private hard-link directory exists while it is -// open and is removed on Close. -func TestIteratorSnapshotDirLifecycle(t *testing.T) { - dir := t.TempDir() - w := openWAL(t, testConfig(dir)) - defer func() { require.NoError(t, w.Close()) }() - for index := uint64(1); index <= 3; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Flush()) - - it, err := w.Iterator(1, 3) - require.NoError(t, err) - - linkDir := iteratorLinkDir(dir, 0) // first iterator gets serial 0 - info, err := os.Stat(linkDir) - require.NoError(t, err, "the iterator's snapshot directory must exist while it is open") - require.True(t, info.IsDir()) - - require.NoError(t, it.Close()) - _, err = os.Stat(linkDir) - require.True(t, os.IsNotExist(err), "Close must remove the iterator's snapshot directory") -} - -// TestStartupBlastsIteratorLinks verifies that hard-link snapshots left by a crashed prior session are blasted -// at the next open. -func TestStartupBlastsIteratorLinks(t *testing.T) { - dir := t.TempDir() - w := openWAL(t, testConfig(dir)) - for index := uint64(1); index <= 3; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Close()) - - // Simulate iterator hard links left behind by a crash. - stray := iteratorLinkDir(dir, 7) - require.NoError(t, os.MkdirAll(stray, 0o750)) - require.NoError(t, os.WriteFile(filepath.Join(stray, "leftover"), []byte("x"), 0o600)) - - w2 := openWAL(t, testConfig(dir)) - defer func() { require.NoError(t, w2.Close()) }() - - _, err := os.Stat(iteratorRoot(dir)) - require.True(t, os.IsNotExist(err), "startup must blast the entire iterator link tree") -} - -func TestScanRejectsGapInSealedFiles(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = 1 // one record per sealed file - - w := openWAL(t, cfg) - for index := uint64(1); index <= 4; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Close()) - - // Delete a middle sealed file to punch a gap in the sequence, simulating corruption. - var sealed []parsedFileName - entries, err := os.ReadDir(dir) - require.NoError(t, err) - for _, entry := range entries { - if p, ok := parseFileName(entry.Name()); ok && p.sealed { - sealed = append(sealed, p) - } - } - require.GreaterOrEqual(t, len(sealed), 3) - sort.Slice(sealed, func(i int, j int) bool { return sealed[i].fileSeq < sealed[j].fileSeq }) - victim := sealed[len(sealed)/2] - victimName := sealedFileName(victim.fileSeq, victim.firstIndex, victim.lastIndex) - require.NoError(t, os.Remove(filepath.Join(dir, victimName))) - - _, err = NewWAL(cfg) - require.Error(t, err) - require.Contains(t, err.Error(), "not contiguous") -} - -// TestOpenIgnoresMidStreamCorruptSealedFile verifies that a checksum mismatch in a non-final record of a -// sealed file does NOT block open: open reads file names only, never sealed contents, so it must not scale -// with (or fault on) stored bytes. The fault is instead surfaced on demand by VerifyIntegrity. -func TestOpenIgnoresMidStreamCorruptSealedFile(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - - w := openWAL(t, cfg) - for index := uint64(1); index <= 5; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Close()) // seals records 1..5 into a single file - - names := sealedFileNames(t, dir) - require.Len(t, names, 1) - path := filepath.Join(dir, names[0]) - data, err := os.ReadFile(path) - require.NoError(t, err) - // Flip a byte in the first record's payload so the fault is mid-stream, not a torn trailing record. The - // first record's payload begins just past the header and its two single-byte uvarint prefixes (index 1, - // length 9), so walHeaderSize+2 lands inside the payload. - data[walHeaderSize+2] ^= 0xFF - require.NoError(t, os.WriteFile(path, data, 0o600)) - - // Open succeeds despite the corruption — it never touched the sealed contents. - w2, err := NewWAL(cfg) - require.NoError(t, err) - require.NoError(t, w2.Close()) - - // The on-demand scan catches the mid-stream fault. - require.Error(t, VerifyIntegrity(dir)) -} - -// TestOpenIgnoresTruncatedSealedFile verifies that a sealed file truncated at a clean record boundary — all -// remaining records checksum correctly, but the content stops short of the last index its name promises — -// does not block open (open trusts the name), and is caught by VerifyIntegrity's name-versus-content range -// check. This is the case parse-strictness alone cannot catch (no torn record remains). -func TestOpenIgnoresTruncatedSealedFile(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - - w := openWAL(t, cfg) - for index := uint64(1); index <= 5; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Close()) // seals records 1..5 into a single file named 0-1-5 - - names := sealedFileNames(t, dir) - require.Len(t, names, 1) - path := filepath.Join(dir, names[0]) - - // Truncate at the boundary just past the 4th record, leaving indices 1..4 intact while the name still - // promises [1, 5]. - contents, err := readWalFile(path) - require.NoError(t, err) - require.Len(t, contents.records, 5) - require.NoError(t, os.Truncate(path, contents.records[3].end)) - - w2, err := NewWAL(cfg) - require.NoError(t, err) - require.NoError(t, w2.Close()) - - require.Error(t, VerifyIntegrity(dir)) -} - -// TestVerifyIntegrityCleanLog verifies that VerifyIntegrity returns nil for an intact multi-file sealed log. -func TestVerifyIntegrityCleanLog(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = 1 // rotate after every record, producing one sealed file per index - - w := openWAL(t, cfg) - for index := uint64(1); index <= 5; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Close()) - require.Greater(t, countSealedFiles(t, dir), 1) - - require.NoError(t, VerifyIntegrity(dir)) -} - -// TestVerifyIntegrityDetectsSequenceGap verifies that a missing sealed file (a hole in the sequence) is -// reported by VerifyIntegrity even though every surviving file is itself intact. -func TestVerifyIntegrityDetectsSequenceGap(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = 1 // one sealed file per record - - w := openWAL(t, cfg) - for index := uint64(1); index <= 5; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Close()) - - names := sealedFileNames(t, dir) - require.Greater(t, len(names), 2) - // Remove a middle file to punch a hole in the sequence. - require.NoError(t, os.Remove(filepath.Join(dir, names[1]))) - - err := VerifyIntegrity(dir) - require.Error(t, err) - require.Contains(t, err.Error(), "gap") -} - -// TestVerifyIntegrityReportsDuplicateSequence verifies that when an interrupted rollback swap leaves two sealed -// files sharing a fileSeq (the reduced [1,3] file beside the untouched [1,6] original), VerifyIntegrity -// checksums each physical file under its own name and reports the duplicate as such. It must not collapse both -// parsed entries onto one file — which would skip the survivor's CRC entirely, raise a misattributed range -// mismatch, and report the duplicate as a nonsensical "gap between N and N". -func TestVerifyIntegrityReportsDuplicateSequence(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) // large target: all six records land in one file, sequence 0 - - w := openWAL(t, cfg) - for index := uint64(1); index <= 6; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Close()) - - oldName := sealedFileName(0, 1, 6) - require.Equal(t, []string{oldName}, sealedFileNames(t, dir)) - - // Reproduce the crash state: the reduced file [1,3] beside the untouched original [1,6], both internally - // name/content consistent. VerifyIntegrity does not run recovery, so it observes the unreconciled duplicate. - reducedName := sealedFileName(0, 1, 3) - prefix := recordPrefixBytes(t, filepath.Join(dir, oldName), 3) - require.NoError(t, os.WriteFile(filepath.Join(dir, reducedName), prefix, 0o600)) - require.Equal(t, []string{reducedName, oldName}, sealedFileNames(t, dir)) - - err := VerifyIntegrity(dir) - require.Error(t, err) - require.Contains(t, err.Error(), "duplicate") - // Each parsed entry resolves to its own file, so neither yields a misattributed range mismatch, and the - // duplicate is reported as a duplicate rather than a sequence gap. - require.NotContains(t, err.Error(), "content holds") - require.NotContains(t, err.Error(), "gap") -} - -// TestVerifyIntegrityReportsAllFaults verifies that a single VerifyIntegrity pass aggregates every problem it -// finds rather than stopping at the first one. -func TestVerifyIntegrityReportsAllFaults(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = 1 // one sealed file per record - - w := openWAL(t, cfg) - for index := uint64(1); index <= 5; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Close()) - - names := sealedFileNames(t, dir) - require.GreaterOrEqual(t, len(names), 4) - - // Corrupt two different sealed files' payloads. Each file holds a single record, so the payload begins at - // walHeaderSize plus the two single-byte uvarint prefixes. - for _, name := range []string{names[0], names[2]} { - path := filepath.Join(dir, name) - data, err := os.ReadFile(path) - require.NoError(t, err) - data[walHeaderSize+2] ^= 0xFF - require.NoError(t, os.WriteFile(path, data, 0o600)) - } - - err := VerifyIntegrity(dir) - require.Error(t, err) - // errors.Join renders one line per wrapped error; both corrupt files must appear. - require.Contains(t, err.Error(), names[0]) - require.Contains(t, err.Error(), names[2]) -} - -// TestVerifyIntegrityIsReadOnly verifies that VerifyIntegrity does not mutate the directory (no orphan -// sealing, no removals): the exact set of files before and after a scan is identical, even when a corrupt -// sealed file is present. -func TestVerifyIntegrityIsReadOnly(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - - w := openWAL(t, cfg) - for index := uint64(1); index <= 5; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Close()) - - names := sealedFileNames(t, dir) - require.Len(t, names, 1) - path := filepath.Join(dir, names[0]) - data, err := os.ReadFile(path) - require.NoError(t, err) - data[walHeaderSize+2] ^= 0xFF - require.NoError(t, os.WriteFile(path, data, 0o600)) - - before := sealedFileNames(t, dir) - require.Error(t, VerifyIntegrity(dir)) - require.Equal(t, before, sealedFileNames(t, dir), "VerifyIntegrity must not mutate the directory") -} - -// TestVerifyIntegrityDetectsSealedFileBadMagic verifies that a sealed file with a clobbered header (invalid -// magic prefix) does not block open (open reads names only) but is surfaced by VerifyIntegrity. -func TestVerifyIntegrityDetectsSealedFileBadMagic(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - - w := openWAL(t, cfg) - for index := uint64(1); index <= 3; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Close()) - - names := sealedFileNames(t, dir) - require.Len(t, names, 1) - path := filepath.Join(dir, names[0]) - data, err := os.ReadFile(path) - require.NoError(t, err) - data[0] ^= 0xFF // clobber the magic prefix - require.NoError(t, os.WriteFile(path, data, 0o600)) - - w2, err := NewWAL(cfg) - require.NoError(t, err) - require.NoError(t, w2.Close()) - - err = VerifyIntegrity(dir) - require.Error(t, err) - require.Contains(t, err.Error(), "magic") -} - -func TestBoundsEmpty(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) - defer func() { require.NoError(t, w.Close()) }() - - ok, _, _, err := w.Bounds() - require.NoError(t, err) - require.False(t, ok) -} - -func TestGetRange(t *testing.T) { - t.Run("empty directory reports no records", func(t *testing.T) { - ok, _, _, err := GetRange(t.TempDir()) - require.NoError(t, err) - require.False(t, ok) - }) - - t.Run("reports the range of a cleanly closed WAL", func(t *testing.T) { - dir := t.TempDir() - w := openWAL(t, testConfig(dir)) - for index := uint64(1); index <= 5; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Close()) - - ok, first, last, err := GetRange(dir) - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), first) - require.Equal(t, uint64(5), last) - }) - - t.Run("seals an unsealed orphan then reports the range", func(t *testing.T) { - dir := t.TempDir() - // Fabricate an orphaned unsealed file (a crash before sealing), records 1..3 intact. - f, err := newWalFile(dir, 0) - require.NoError(t, err) - writeRecordTo(t, f, 1, recordPayload(1)) - writeRecordTo(t, f, 2, recordPayload(2)) - writeRecordTo(t, f, 3, recordPayload(3)) - require.NoError(t, f.flush(true)) - require.NoError(t, f.file.Close()) - - ok, first, last, err := GetRange(dir) - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), first) - require.Equal(t, uint64(3), last) - // GetRange sealed the orphan, so the directory now holds only the sealed file. - require.Equal(t, []string{sealedFileName(0, 1, 3)}, sealedFileNames(t, dir)) - - // A subsequent normal open round-trips cleanly against the sealed range. - w := openWAL(t, testConfig(dir)) - defer func() { require.NoError(t, w.Close()) }() - require.Equal(t, []uint64{1, 2, 3}, collectIndices(t, w, 1, 3)) - }) -} - -func TestPruneAfter(t *testing.T) { - t.Run("drops whole files beyond the rollback point", func(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = 1 // one record per file - - w := openWAL(t, cfg) - for index := uint64(1); index <= 6; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Close()) - - require.NoError(t, PruneAfter(cfg.Path, 3)) - w2 := openWAL(t, cfg) - defer func() { require.NoError(t, w2.Close()) }() - - ok, first, last, err := w2.Bounds() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), first) - require.Equal(t, uint64(3), last) - require.Equal(t, []uint64{1, 2, 3}, collectIndices(t, w2, 1, 3)) - }) - - t.Run("truncates within a file at the rollback point", func(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) // large target: all records land in one file - - w := openWAL(t, cfg) - for index := uint64(1); index <= 6; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Close()) - - require.NoError(t, PruneAfter(cfg.Path, 3)) - w2 := openWAL(t, cfg) - defer func() { require.NoError(t, w2.Close()) }() - - ok, first, last, err := w2.Bounds() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), first) - require.Equal(t, uint64(3), last) - require.Equal(t, []uint64{1, 2, 3}, collectIndices(t, w2, 1, 3)) - - // Appending continues cleanly after the rollback point. - appendRecord(t, w2, 4) - require.NoError(t, w2.Flush()) - _, _, last, err = w2.Bounds() - require.NoError(t, err) - require.Equal(t, uint64(4), last) - }) - - // After a rollback, a subsequent *normal* open (not another rollback) must observe exactly the rolled-back - // range. This is the path that would expose a name/content mismatch left by a non-crash-safe rollback: - // Bounds is name-derived while iteration is content-bound, so the two agree only if the truncation and - // rename were applied consistently. Exercises both rollback shapes: whole-file removal and in-file - // truncation of the straddling file. - t.Run("rolled-back state is consistent under a normal reopen", func(t *testing.T) { - for _, tc := range []struct { - name string - targetSize uint - }{ - // one record per file: rollback removes whole trailing files - {"whole-file removal", 1}, - // all records in one file: rollback truncates it in place - {"in-file truncation", 64 * 1024 * 1024}, - } { - t.Run(tc.name, func(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = tc.targetSize - - w := openWAL(t, cfg) - for index := uint64(1); index <= 6; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Close()) - - require.NoError(t, PruneAfter(cfg.Path, 3)) - - // Reopen normally; the rollback must have durably and consistently reduced the range - // to [1,3]. - w3 := openWAL(t, cfg) - defer func() { require.NoError(t, w3.Close()) }() - - ok, first, last, err := w3.Bounds() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), first) - require.Equal(t, uint64(3), last) - require.Equal(t, []uint64{1, 2, 3}, collectIndices(t, w3, 1, 3)) - }) - } - }) -} - -// recordPrefixBytes reads the sealed file at path and returns the raw bytes of the prefix ending just past the -// record for lastKeep — i.e. the exact content rollbackStraddlingFile's AtomicWrite would install for a -// rollback to lastKeep. It is the test's stand-in for "the truncated copy the rollback would produce". -func recordPrefixBytes(t *testing.T, path string, lastKeep uint64) []byte { - t.Helper() - data, err := os.ReadFile(path) - require.NoError(t, err) - contents, err := readWalFile(path) - require.NoError(t, err) - var truncateTo int64 - found := false - for _, r := range contents.records { - if r.index == lastKeep { - truncateTo = r.end - found = true - break - } - } - require.True(t, found, "index %d has no record boundary in %s", lastKeep, path) - return data[:truncateTo] -} - -// TestRollbackCrashAfterSwapReconciledOnReopen simulates a crash in rollbackStraddlingFile after the reduced -// file was durably written (AtomicWrite) but before the old, larger-named file was removed. That leaves two -// sealed files sharing a sequence. A subsequent open must reconcile them — keeping the reduced file — so the -// name-derived Bounds and the content-derived iterator agree on the rolled-back range. -func TestRollbackCrashAfterSwapReconciledOnReopen(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) // large target: all six records land in one file, sequence 0 - - w := openWAL(t, cfg) - for index := uint64(1); index <= 6; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Close()) - - oldName := sealedFileName(0, 1, 6) - require.Equal(t, []string{oldName}, sealedFileNames(t, dir)) - - // Reproduce the crash state: the reduced file [1,3] exists next to the untouched original [1,6]. - reducedName := sealedFileName(0, 1, 3) - prefix := recordPrefixBytes(t, filepath.Join(dir, oldName), 3) - require.NoError(t, os.WriteFile(filepath.Join(dir, reducedName), prefix, 0o600)) - require.Equal(t, []string{reducedName, oldName}, sealedFileNames(t, dir)) - - // A plain reopen must reconcile the duplicate sequence down to the rolled-back file. - w2 := openWAL(t, cfg) - defer func() { require.NoError(t, w2.Close()) }() - - require.Equal(t, []string{reducedName}, sealedFileNames(t, dir)) - ok, first, last, err := w2.Bounds() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), first) - require.Equal(t, uint64(3), last) - require.Equal(t, []uint64{1, 2, 3}, collectIndices(t, w2, 1, 3)) -} - -// TestRollbackCrashDuringSwapWindowRecovers simulates a crash mid-rollback in the earlier window: the -// AtomicWrite's swap file was created but not yet renamed into place, so only a leftover ".swap" exists beside -// the still-intact original. A reopen must drop the swap and leave the original range intact (the rollback -// simply did not take effect), and a subsequent rollback must then complete cleanly and durably. -func TestRollbackCrashDuringSwapWindowRecovers(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) // large target: all six records in one file, sequence 0 - - w := openWAL(t, cfg) - for index := uint64(1); index <= 6; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Close()) - - oldName := sealedFileName(0, 1, 6) - - // Reproduce the crash state: an unfinished AtomicWrite left a swap file for the reduced name, alongside - // the untouched original. util.AtomicWrite names its temp ".swap". - prefix := recordPrefixBytes(t, filepath.Join(dir, oldName), 3) - swapName := sealedFileName(0, 1, 3) + ".swap" - require.NoError(t, os.WriteFile(filepath.Join(dir, swapName), prefix, 0o600)) - - // A plain reopen drops the swap; the original range survives (rollback did not take effect). - w2 := openWAL(t, cfg) - require.Equal(t, []string{oldName}, sealedFileNames(t, dir)) - _, err := os.Stat(filepath.Join(dir, swapName)) - require.True(t, os.IsNotExist(err), "leftover swap file should have been removed") - ok, first, last, err := w2.Bounds() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), first) - require.Equal(t, uint64(6), last) - require.NoError(t, w2.Close()) - - // The subsequent rollback completes cleanly, and a normal reopen sees the consistent rolled-back range. - require.NoError(t, PruneAfter(cfg.Path, 3)) - - w4 := openWAL(t, cfg) - defer func() { require.NoError(t, w4.Close()) }() - require.Equal(t, []string{sealedFileName(0, 1, 3)}, sealedFileNames(t, dir)) - ok, first, last, err = w4.Bounds() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), first) - require.Equal(t, uint64(3), last) - require.Equal(t, []uint64{1, 2, 3}, collectIndices(t, w4, 1, 3)) -} diff --git a/sei-db/seiwal/seiwal_iterator.go b/sei-db/seiwal/seiwal_iterator.go deleted file mode 100644 index 133460b134..0000000000 --- a/sei-db/seiwal/seiwal_iterator.go +++ /dev/null @@ -1,316 +0,0 @@ -package seiwal - -import ( - "fmt" - "os" - "path/filepath" - "sync" -) - -var _ Iterator[[]byte] = (*walIterator)(nil) - -// A record produced by the reader goroutine, or a terminal error. -type iteratorResult struct { - index uint64 - payload []byte - err error -} - -// iteratorFile is one entry in an iterator's file snapshot, captured on the writer goroutine when the iterator -// is created (see startIterator). name is the file's basename inside the iterator's private hard-link -// directory. A sealed entry is immutable and verified against its [firstIndex, lastIndex] range; a non-sealed -// entry is the hard-linked mutable file, which may still be growing, so it is parsed torn-tolerantly and -// bounded by the iterator's maxIndex. -type iteratorFile struct { - fileSeq uint64 - name string - firstIndex uint64 - lastIndex uint64 - sealed bool -} - -// walIterator iterates the WAL a record at a time, in ascending index order. A dedicated reader goroutine -// reads WAL files from disk and pushes each record onto a buffered channel; Next simply dequeues. Decoupling -// disk reads from the consumer keeps the reader busy while the consumer works, which matters for startup -// replay speed. The reader loads one file at a time, so its memory use is bounded by a single WAL file plus -// the prefetch buffer. -// -// The set of files to read is snapshotted once at creation as hard links in a private directory (dir); the -// reader walks that list in O(n) without re-scanning. The links keep their inodes alive against concurrent -// rotation and pruning, so the reader always finds its files; Close removes the directory. Records above -// maxIndex — the highest index stored at creation — are refused, giving a consistent point-in-time view even -// though the hard-linked mutable file may keep growing under the reader. -type walIterator struct { - // The WAL this iterator reads from. - wal *walImpl - - // The lowest index the consumer asked for; records below it are skipped. - start uint64 - - // The highest index this iterator yields; records above it (appended after creation, or written to the - // hard-linked mutable file after the snapshot) are refused, fixing the point-in-time view. - maxIndex uint64 - - // The iterator's private directory of hard-link snapshots (iterator//), removed by Close. Empty - // when the iterator has no files to read, in which case Close removes nothing. - dir string - - // Records produced by the reader goroutine. Closed by the reader on clean EOF. - results chan iteratorResult - - // Closed by Close to tell the reader goroutine to stop early. - stop chan struct{} - - // Closed by the reader goroutine when it exits, so Close can wait for it. - readerExited chan struct{} - - // Ensures the shutdown sequence runs at most once. - closeOnce sync.Once - - // The index and payload returned by Entry, set by the most recent successful Next. Owned by the single - // consumer goroutine (see the Iterator concurrency contract); never touched by Close or the reader. - resultIndex uint64 - resultPayload []byte - - // Set once iteration is complete. Owned by the single consumer goroutine (see the Iterator concurrency - // contract): Next and Close both set it, which is why they must not run concurrently. - done bool - - // The following fields are owned exclusively by the reader goroutine. - - // The point-in-time snapshot of files to read, in ascending index order. Set once at construction. - files []iteratorFile - // The position into files of the next file to load. - filePos int - // The records loaded from the current file, filtered to indices at or beyond start. - buffer []walRecord - // The position within buffer; -1 before the first record is read. - pos int -} - -// newWalIterator creates an iterator over wal starting at startIndex and launches its reader goroutine. -// maxIndex is the highest index it will yield. files is the snapshot of hard-linked files to read (captured on -// the writer goroutine), living under dir; Close removes dir (empty dir means nothing to read and nothing to -// remove). prefetch is the number of records the reader may buffer ahead of the consumer. -func newWalIterator( - wal *walImpl, - startIndex uint64, - maxIndex uint64, - dir string, - files []iteratorFile, - prefetch uint, -) *walIterator { - it := &walIterator{ - wal: wal, - start: startIndex, - maxIndex: maxIndex, - dir: dir, - results: make(chan iteratorResult, prefetch), - stop: make(chan struct{}), - readerExited: make(chan struct{}), - files: files, - pos: -1, - } - go it.read() - return it -} - -func (it *walIterator) Next() (bool, error) { - if it.done { - return false, nil - } - // Drain an already-available result first (non-blocking), so a clean EOF — the reader closing results - // with records still buffered — is never lost to a concurrent WAL shutdown in the select below. - select { - case result, ok := <-it.results: - return it.deliver(result, ok) - default: - } - // Otherwise wait for the next result, but don't block forever if the WAL is torn down. The reader - // goroutine watches the same context (see send) and stops producing when it fires, so the results - // channel would never advance again; surface the shutdown as an error rather than hang. - select { - case result, ok := <-it.results: - return it.deliver(result, ok) - case <-it.wal.ctx.Done(): - it.done = true - if err := it.wal.asyncError(); err != nil { - return false, fmt.Errorf("WAL shut down during iteration: %w", err) - } - return false, fmt.Errorf("WAL shut down during iteration: %w", it.wal.ctx.Err()) - } -} - -// deliver turns a dequeued reader result (or a closed-channel signal) into a Next return value. -func (it *walIterator) deliver(result iteratorResult, ok bool) (bool, error) { - if !ok { - it.done = true - return false, nil - } - if result.err != nil { - it.done = true - return false, result.err - } - it.resultIndex = result.index - it.resultPayload = result.payload - return true, nil -} - -func (it *walIterator) Entry() (uint64, []byte) { - return it.resultIndex, it.resultPayload -} - -func (it *walIterator) Close() error { - it.closeOnce.Do(func() { - close(it.stop) // tell the reader to stop if it is mid-read - <-it.readerExited // wait for it to actually exit before releasing its file handles - if it.dir != "" { - // Remove this iterator's hard-link snapshot, freeing any inode it was the last link to. - // Best-effort: a leftover is reclaimed by the startup blast. The reader has exited, so no - // handle is open here. - _ = os.RemoveAll(it.dir) - } - }) - it.done = true - return nil -} - -// read is the reader goroutine: it produces records onto the results channel until the WAL is exhausted (then -// closes the channel), a read fails (then sends the error), Close signals a stop, or the WAL context is -// cancelled (see send). It never blocks indefinitely, so it cannot outlive the WAL as a zombie. -func (it *walIterator) read() { - defer close(it.readerExited) - for { - record, ok, err := it.nextRecord() - if err != nil { - it.send(iteratorResult{err: err}) - return - } - if !ok { - close(it.results) - return - } - if !it.send(iteratorResult{index: record.index, payload: record.payload}) { - return // Close signalled a stop - } - } -} - -// send pushes a result onto the channel, returning false if Close signalled a stop or the WAL was torn down -// first. Watching the WAL context here is what keeps the reader from becoming a zombie: if an iterator is -// orphaned (Iterator aborted via ctx.Done before the caller ever received it, so Close is never called) the -// prefetch buffer eventually fills and this send would otherwise block forever with no one to close stop. -func (it *walIterator) send(result iteratorResult) bool { - select { - case it.results <- result: - return true - case <-it.stop: - return false - case <-it.wal.ctx.Done(): - return false - } -} - -// nextRecord returns the next record in ascending order, advancing across files as needed. It returns -// ok=false once no further records remain. -func (it *walIterator) nextRecord() (walRecord, bool, error) { - for { - it.pos++ - if it.pos < len(it.buffer) { - return it.buffer[it.pos], true, nil - } - loaded, err := it.loadNextFile() - if err != nil { - return walRecord{}, false, err - } - if !loaded { - return walRecord{}, false, nil - } - it.pos = -1 - } -} - -// loadNextFile walks the file snapshot from filePos, loading the next file's records (filtered to indices at -// or beyond start) into buffer and advancing filePos. It returns false when the snapshot is exhausted. Sealed -// files entirely below start are skipped without being opened; a file that yields no matching records leaves -// buffer empty (still reported as loaded). -func (it *walIterator) loadNextFile() (bool, error) { - for { - if it.filePos >= len(it.files) { - return false, nil - } - f := &it.files[it.filePos] - it.filePos++ - it.buffer = nil - - if f.lastIndex < it.start { - continue // entirely below the start index; skip without opening - } - - handle, err := it.openFile(f) - if err != nil { - return false, err - } - - parsed := parsedFileName{ - fileSeq: f.fileSeq, - firstIndex: f.firstIndex, - lastIndex: f.lastIndex, - sealed: f.sealed, - } - contents, err := readWalFileFromHandle(handle, parsed) - if err != nil { - return false, fmt.Errorf( - "failed to read WAL file (sequence %d) during iteration: %w", f.fileSeq, err) - } - - if f.sealed { - if err := verifySealedContents(contents, f.fileSeq, f.firstIndex, f.lastIndex); err != nil { - return false, err - } - } else { - // The unsealed mutable snapshot's records through f.lastIndex (the snapshot bound) were complete - // when the snapshot was taken, so a short read is corruption in known-complete data — not a - // live-write torn tail, which can only appear past the bound and is dropped by the maxIndex filter. - if !contents.hasRecords { - return false, fmt.Errorf( - "WAL file (sequence %d) is corrupt: no intact records were read, "+ - "but the snapshot promises indices through %d", - f.fileSeq, f.lastIndex) - } - if contents.lastIndex < f.lastIndex { - return false, fmt.Errorf( - "WAL file (sequence %d) is corrupt: content covers indices through %d, "+ - "short of the snapshot bound %d", - f.fileSeq, contents.lastIndex, f.lastIndex) - } - } - - for _, record := range contents.records { - if record.index < it.start { - // Locating the start index is a linear scan over this file's records (and the whole - // file was just read into memory above). It's wasteful when start lands deep in a - // large file. If this becomes a hotspot, build a small per-file index (offset by - // index, like LittDB key files) and seek instead. - continue - } - if record.index > it.maxIndex { - break // beyond the point-in-time snapshot; records ascend, so nothing further qualifies - } - it.buffer = append(it.buffer, record) - } - return true, nil - } -} - -// openFile opens a snapshot file from the iterator's private hard-link directory. The hard link keeps the -// underlying inode alive against rotation and pruning, so the open cannot miss it even after the WAL removed -// the file's canonical name. readWalFileFromHandle closes the returned handle after reading. -func (it *walIterator) openFile(f *iteratorFile) (*os.File, error) { - path := filepath.Join(it.dir, f.name) - handle, err := os.Open(path) //nolint:gosec // path derived from the writer's file snapshot - if err != nil { - return nil, fmt.Errorf("failed to open WAL file %s during iteration: %w", f.name, err) - } - return handle, nil -} diff --git a/sei-db/seiwal/seiwal_iterator_test.go b/sei-db/seiwal/seiwal_iterator_test.go deleted file mode 100644 index f52777ec12..0000000000 --- a/sei-db/seiwal/seiwal_iterator_test.go +++ /dev/null @@ -1,485 +0,0 @@ -package seiwal - -import ( - "context" - "fmt" - "os" - "path/filepath" - "sync" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -// TestIteratorRejectsCorruptSealedFile verifies that interior corruption in a sealed file is surfaced as an -// error rather than silently truncating iteration short of the file's name-promised last index. Open no longer -// reads sealed contents, so the iterator's per-record CRC re-read is where such corruption is caught at -// point-of-use (the offline VerifyIntegrity also finds it on demand). -func TestIteratorRejectsCorruptSealedFile(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - - w := openWAL(t, cfg) - for index := uint64(1); index <= 5; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Close()) // seals records 1..5 into a single file - - names := sealedFileNames(t, dir) - require.Len(t, names, 1) - path := filepath.Join(dir, names[0]) - - w2 := openWAL(t, cfg) // healthy at open; validation passes - defer func() { require.NoError(t, w2.Close()) }() - - // Corrupt the last record's CRC only now, after open, so the parser stops short of index 5 on the - // iterator's re-read of the file from disk. - data, err := os.ReadFile(path) - require.NoError(t, err) - data[len(data)-1] ^= 0xFF - require.NoError(t, os.WriteFile(path, data, 0o600)) - - it, err := w2.Iterator(1, 5) - require.NoError(t, err) - defer func() { require.NoError(t, it.Close()) }() - - var iterErr error - for { - ok, err := it.Next() - if err != nil { - iterErr = err - break - } - if !ok { - break - } - } - require.Error(t, iterErr) - require.Contains(t, iterErr.Error(), "corrupt") -} - -// TestIteratorRejectsCorruptMutableSnapshot verifies that interior corruption in the still-unsealed mutable -// file, within the range the iterator's point-in-time snapshot promises, is surfaced as an error rather than -// silently truncating. Those records were complete when the snapshot was taken, so a short read is corruption, -// not a live-write torn tail — and iteration must not depend on whether the file later gets sealed for real. -func TestIteratorRejectsCorruptMutableSnapshot(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) // large target: records 1..5 stay in the unsealed mutable file, sequence 0 - - w := openWAL(t, cfg) - for index := uint64(1); index <= 5; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Flush()) // records readable on disk; the file is still unsealed - defer func() { require.NoError(t, w.Close()) }() - require.Empty(t, sealedFileNames(t, dir)) - - // Corrupt record 5's CRC in the mutable file, within the [1,5] range the snapshot will promise. - path := filepath.Join(dir, unsealedFileName(0)) - data, err := os.ReadFile(path) - require.NoError(t, err) - data[len(data)-1] ^= 0xFF - require.NoError(t, os.WriteFile(path, data, 0o600)) - - it, err := w.Iterator(1, 5) - require.NoError(t, err) - defer func() { require.NoError(t, it.Close()) }() - - var iterErr error - for { - ok, err := it.Next() - if err != nil { - iterErr = err - break - } - if !ok { - break - } - } - require.Error(t, iterErr) - require.Contains(t, iterErr.Error(), "corrupt") -} - -// TestIteratorEmptyWALErrors verifies that an empty WAL has no latest index, so any requested end index is -// beyond it: iterator creation fails with ErrIteratorRange rather than returning an empty iterator, and the -// rejection leaves the WAL usable. -func TestIteratorEmptyWALErrors(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) - defer func() { require.NoError(t, w.Close()) }() - - _, err := w.Iterator(0, 0) - require.ErrorIs(t, err, ErrIteratorRange) - - // The WAL is unharmed by the rejected request: it still accepts appends and iterates. - appendRecord(t, w, 1) - require.NoError(t, w.Flush()) - require.Equal(t, []uint64{1}, collectIndices(t, w, 1, 1)) -} - -// TestIteratorRangeErrors verifies the two invalid-range rejections on a non-empty WAL: an end index below the -// start index, and an end index beyond the latest stored index. Both report ErrIteratorRange and leave the WAL -// usable. -func TestIteratorRangeErrors(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) - defer func() { require.NoError(t, w.Close()) }() - for index := uint64(1); index <= 5; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Flush()) - - _, err := w.Iterator(3, 2) - require.ErrorIs(t, err, ErrIteratorRange, "end index below start index must be rejected") - - _, err = w.Iterator(1, 6) - require.ErrorIs(t, err, ErrIteratorRange, "end index beyond the latest stored index must be rejected") - - // Neither rejection bricked the WAL: a valid request still works. - require.Equal(t, []uint64{1, 2, 3, 4, 5}, collectIndices(t, w, 1, 5)) -} - -// TestIteratorSnapshotIOFailureDoesNotBrick verifies that a filesystem failure while building the iterator's -// hard-link snapshot is surfaced to the caller without bricking the WAL. Such a failure touches only the -// ephemeral iterator// lease directory, never the WAL data files or the in-memory write state, so the -// WAL must keep accepting appends and, once the fault clears, serving iterators. -func TestIteratorSnapshotIOFailureDoesNotBrick(t *testing.T) { - dir := t.TempDir() - w := openWAL(t, testConfig(dir)).(*walImpl) - defer func() { require.NoError(t, w.Close()) }() - - for index := uint64(1); index <= 5; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Flush()) - - // Occupy the iterator root with a regular file so MkdirAll of iterator// fails with ENOTDIR. The - // mutable-file flush inside startIterator still succeeds, so this exercises only the non-corrupting snapshot - // failure, not the fatal flush path. - require.NoError(t, os.WriteFile(iteratorRoot(dir), []byte("x"), 0o600)) - - _, err := w.Iterator(1, 5) - require.Error(t, err) - require.NotErrorIs(t, err, ErrIteratorRange, "a snapshot I/O failure is not a caller range error") - - // The failed request left the WAL healthy: no fatal cause recorded, and appends still succeed. - require.NoError(t, w.asyncError()) - appendRecord(t, w, 6) - require.NoError(t, w.Flush()) - - // Once the fault clears, iteration works again and sees every record, including the one appended after the - // failed request. - require.NoError(t, os.Remove(iteratorRoot(dir))) - require.Equal(t, []uint64{1, 2, 3, 4, 5, 6}, collectIndices(t, w, 1, 6)) -} - -// TestIteratorStopsAtEndIndex verifies the core new behavior: the iterator yields no record beyond the -// requested end index, even though later records exist in the WAL. -func TestIteratorStopsAtEndIndex(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) - defer func() { require.NoError(t, w.Close()) }() - for index := uint64(1); index <= 10; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Flush()) - - require.Equal(t, []uint64{3, 4, 5, 6, 7}, collectIndices(t, w, 3, 7)) -} - -func TestIteratorFromMiddle(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) - defer func() { require.NoError(t, w.Close()) }() - for index := uint64(1); index <= 5; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Flush()) - - require.Equal(t, []uint64{3, 4, 5}, collectIndices(t, w, 3, 5)) -} - -func TestIteratorAcrossFiles(t *testing.T) { - cfg := testConfig(t.TempDir()) - cfg.TargetFileSize = 1 // one record per file - w := openWAL(t, cfg) - defer func() { require.NoError(t, w.Close()) }() - for index := uint64(1); index <= 5; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Flush()) - - require.Equal(t, []uint64{2, 3, 4, 5}, collectIndices(t, w, 2, 5)) -} - -func TestIteratorWithTinyPrefetchBuffer(t *testing.T) { - // A prefetch buffer smaller than the number of records exercises reader backpressure: the reader must - // block on a full channel and resume as the consumer drains, without deadlocking or dropping records. - cfg := testConfig(t.TempDir()) - cfg.IteratorPrefetchSize = 1 - w := openWAL(t, cfg) - defer func() { require.NoError(t, w.Close()) }() - for index := uint64(1); index <= 20; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Flush()) - - require.Equal(t, []uint64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20}, - collectIndices(t, w, 1, 20)) -} - -func TestIteratorCloseBeforeDrainDoesNotLeak(t *testing.T) { - // Closing an iterator before consuming it must unblock and shut down the reader goroutine cleanly. - cfg := testConfig(t.TempDir()) - cfg.IteratorPrefetchSize = 1 - w := openWAL(t, cfg) - defer func() { require.NoError(t, w.Close()) }() - for index := uint64(1); index <= 20; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Flush()) - - it, err := w.Iterator(1, 20) - require.NoError(t, err) - // Consume just one record, then close while the reader is still mid-stream (blocked on the full buffer). - ok, err := it.Next() - require.NoError(t, err) - require.True(t, ok) - require.NoError(t, it.Close()) - require.NoError(t, it.Close()) // idempotent -} - -func TestIteratorReaderExitsWhenWALTornDownWhileOrphaned(t *testing.T) { - // Regression: an iterator whose reader fills the prefetch buffer and is never consumed or Closed — as - // happens when Iterator() is aborted via ctx.Done() and the constructed iterator is returned to no one — - // must not leave its reader goroutine blocked on send() forever. Watching the WAL context lets the reader - // exit when the WAL is torn down, so it cannot become a zombie. - cfg := testConfig(t.TempDir()) - cfg.IteratorPrefetchSize = 1 - w := openWAL(t, cfg) - for index := uint64(1); index <= 20; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Flush()) - - it, err := w.Iterator(1, 20) - require.NoError(t, err) - iter := it.(*walIterator) - - // Do not consume the iterator: the reader fills the prefetch buffer (size 1) and blocks on send. Tear - // down the WAL context out from under it, as fail() or Close() would. - w.(*walImpl).cancel(nil) - - select { - case <-iter.readerExited: - case <-time.After(5 * time.Second): - t.Fatal("reader goroutine did not exit after the WAL context was cancelled") - } - - // A consumer that races the teardown drains whatever the reader had already buffered, then observes an - // error rather than a clean EOF: a truncated iteration must never masquerade as fully consumed. - var termErr error - for i := 0; i < 25; i++ { - ok, err := it.Next() - if err != nil { - termErr = err - break - } - if !ok { - break - } - } - require.Error(t, termErr, "truncated iteration must surface an error, not a clean EOF") -} - -func TestIteratorYieldsRecordContents(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) - defer func() { require.NoError(t, w.Close()) }() - - require.NoError(t, w.Append(1, []byte("hello world"))) - require.NoError(t, w.Flush()) - - it, err := w.Iterator(1, 1) - require.NoError(t, err) - defer func() { require.NoError(t, it.Close()) }() - - ok, err := it.Next() - require.NoError(t, err) - require.True(t, ok) - index, data := it.Entry() - require.Equal(t, uint64(1), index) - require.Equal(t, []byte("hello world"), data) - - ok, err = it.Next() - require.NoError(t, err) - require.False(t, ok) -} - -// TestConcurrentIterationDuringRotation hammers the writer with rotate-on-every-record churn while several -// iterators read concurrently. Each iterator seals the mutable file and snapshots its file set at creation on -// the writer goroutine, so every file it reads is sealed and immutable and can never be renamed or rewritten -// out from under an in-flight read; every iteration must be error-free and gap-free. -func TestConcurrentIterationDuringRotation(t *testing.T) { - cfg := testConfig(t.TempDir()) - cfg.TargetFileSize = 1 // rotate (rename) after every record, maximizing the seal/rename churn - w := openWAL(t, cfg) - defer func() { require.NoError(t, w.Close()) }() - - const totalRecords = 300 - const readers = 4 - const iterationsPerReader = 40 - - var wg sync.WaitGroup - - writeErr := make(chan error, 1) - wg.Add(1) - go func() { - defer wg.Done() - for index := uint64(1); index <= totalRecords; index++ { - if err := w.Append(index, recordPayload(index)); err != nil { - writeErr <- err - return - } - } - writeErr <- nil - }() - - readerErr := make(chan error, readers) - for r := 0; r < readers; r++ { - wg.Add(1) - go func() { - defer wg.Done() - for i := 0; i < iterationsPerReader; i++ { - if err := drainContiguousFrom(w, 1); err != nil { - readerErr <- err - return - } - } - readerErr <- nil - }() - } - - wg.Wait() - require.NoError(t, <-writeErr) - for r := 0; r < readers; r++ { - require.NoError(t, <-readerErr) - } -} - -// drainContiguousFrom fully consumes an iterator over [start, currentLast], verifying the yielded indices -// form a gap-free, strictly-increasing run beginning at start (an empty run is allowed: the writer may not -// have produced start yet). The end is read from the WAL's current bounds so a concurrent writer's latest -// index does not need to be known in advance. Returns the first error encountered. -func drainContiguousFrom(w WAL[[]byte], start uint64) error { - ok, _, last, err := w.Bounds() - if err != nil { - return fmt.Errorf("bounds: %w", err) - } - if !ok || last < start { - return nil // nothing at or above start yet - } - it, err := w.Iterator(start, last) - if err != nil { - return fmt.Errorf("create iterator: %w", err) - } - prev := start - 1 - for { - ok, err := it.Next() - if err != nil { - _ = it.Close() - return fmt.Errorf("next: %w", err) - } - if !ok { - break - } - index, _ := it.Entry() - if index != prev+1 { - _ = it.Close() - return fmt.Errorf( - "non-contiguous iteration: got index %d after %d (start %d)", index, prev, start) - } - prev = index - } - return it.Close() -} - -// TestIteratorDoesNotSeePostConstructionRecords pins down the snapshot contract: an iterator yields only -// records that existed when it was created, never records appended afterward. Because Iterator() seals the -// mutable file at creation, a record appended after lands in a fresh file outside the snapshot and must not -// appear. -func TestIteratorDoesNotSeePostConstructionRecords(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) // large target: records begin in one mutable file - defer func() { require.NoError(t, w.Close()) }() - - for index := uint64(1); index <= 3; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Flush()) - - it, err := w.Iterator(1, 3) - require.NoError(t, err) - defer func() { require.NoError(t, it.Close()) }() - - // Appended after the iterator exists, before draining: must not be observed. - require.NoError(t, w.Append(4, recordPayload(4))) - require.NoError(t, w.Flush()) - - var got []uint64 - for { - ok, err := it.Next() - require.NoError(t, err) - if !ok { - break - } - index, _ := it.Entry() - got = append(got, index) - } - require.Equal(t, []uint64{1, 2, 3}, got, "post-construction record 4 must not be iterated") -} - -// TestIteratorSurfacesFatalCause verifies that when the WAL bricks while an iterator is live, Next surfaces the -// recorded fatal cause rather than a bare context.Canceled — so a disk failure during replay is not buried -// under a generic "context canceled" message, matching the asyncError-first pattern used elsewhere in walImpl. -func TestIteratorSurfacesFatalCause(t *testing.T) { - cfg := testConfig(t.TempDir()) - w := openWAL(t, cfg).(*walImpl) - defer func() { _ = w.Close() }() - - // More records than the reader can prefetch, so it is still producing (not at a clean EOF) when the WAL - // bricks, guaranteeing Next reaches the shutdown branch rather than a normal end-of-iteration. - const n = 100 - for i := uint64(1); i <= n; i++ { - appendRecord(t, w, i) - } - require.NoError(t, w.Flush()) - - it, err := w.Iterator(1, n) - require.NoError(t, err) - defer func() { _ = it.Close() }() - - // Brick from the writer goroutine: close the mutable fd out from under it, then a flush fails and calls fail(). - require.NoError(t, w.mutableFile.file.Close()) - require.NoError(t, w.Append(uint64(n+1), recordPayload(uint64(n+1)))) - require.Error(t, w.Flush()) - - select { - case <-w.ctx.Done(): - case <-time.After(5 * time.Second): - t.Fatal("WAL did not brick after flush failure") - } - cause := w.asyncError() - require.Error(t, cause) - - // Drain whatever the reader had buffered; the first error must carry the fatal cause, not context.Canceled. - var got error - for { - ok, nextErr := it.Next() - if nextErr != nil { - got = nextErr - break - } - if !ok { - break - } - } - require.Error(t, got) - require.ErrorIs(t, got, cause) - require.NotErrorIs(t, got, context.Canceled) -} diff --git a/sei-db/seiwal/seiwal_lock.go b/sei-db/seiwal/seiwal_lock.go deleted file mode 100644 index af517dcb62..0000000000 --- a/sei-db/seiwal/seiwal_lock.go +++ /dev/null @@ -1,48 +0,0 @@ -package seiwal - -import ( - "errors" - "fmt" - "path/filepath" - - "github.com/zbiljic/go-filelock" - - commonerrors "github.com/sei-protocol/sei-chain/sei-db/common/errors" -) - -// The name of the WAL directory lock file. Holding this lock grants exclusive access to the WAL directory, -// so a second WAL instance or an offline utility cannot mutate the directory while another owner is live. -const lockFileName = "wal.lock" - -// acquireDirLock takes an exclusive advisory lock on dir/wal.lock. It fails with -// commonerrors.ErrFileLockUnavailable if another WAL instance or offline utility already holds the lock. The -// caller owns the returned handle and must Unlock it to release the directory. -func acquireDirLock(dir string) (filelock.TryLockerSafe, error) { - lockPath, err := filepath.Abs(filepath.Join(dir, lockFileName)) - if err != nil { - return nil, fmt.Errorf("resolve lock path: %w", err) - } - fl, err := filelock.New(lockPath) - if err != nil { - return nil, fmt.Errorf("create file lock %s: %w", lockPath, err) - } - locked, err := fl.TryLock() - if err != nil { - if errors.Is(err, filelock.ErrLocked) { - return nil, fmt.Errorf("%w: %s", commonerrors.ErrFileLockUnavailable, lockPath) - } - return nil, fmt.Errorf("acquire file lock %s: %w", lockPath, err) - } - if !locked { - return nil, fmt.Errorf("%w: held by another owner (%s)", commonerrors.ErrFileLockUnavailable, lockPath) - } - return fl, nil -} - -// releaseDirLock releases a lock acquired by acquireDirLock, logging any failure. A release failure is not -// fatal: the lock is advisory and the kernel drops it when the process exits. -func releaseDirLock(lock filelock.TryLockerSafe, dir string) { - if err := lock.Unlock(); err != nil { - logger.Error("failed to release WAL directory lock", "path", dir, "error", err) - } -} diff --git a/sei-db/seiwal/seiwal_lock_test.go b/sei-db/seiwal/seiwal_lock_test.go deleted file mode 100644 index 005d0a3b7f..0000000000 --- a/sei-db/seiwal/seiwal_lock_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package seiwal - -import ( - "path/filepath" - "testing" - - "github.com/stretchr/testify/require" - - commonerrors "github.com/sei-protocol/sei-chain/sei-db/common/errors" -) - -// TestFileLockPreventsSecondWAL verifies that a second WAL cannot open a directory a live WAL already owns. -func TestFileLockPreventsSecondWAL(t *testing.T) { - dir := t.TempDir() - w := openWAL(t, testConfig(dir)) - defer func() { require.NoError(t, w.Close()) }() - - _, err := NewWAL(testConfig(dir)) - require.ErrorIs(t, err, commonerrors.ErrFileLockUnavailable) -} - -// TestFileLockPreventsOfflineWhileLive verifies that the offline utilities fail fast while a WAL is live on -// the same directory, rather than mutating files the running WAL owns. -func TestFileLockPreventsOfflineWhileLive(t *testing.T) { - dir := t.TempDir() - w := openWAL(t, testConfig(dir)) - defer func() { require.NoError(t, w.Close()) }() - - _, _, _, err := GetRange(dir) - require.ErrorIs(t, err, commonerrors.ErrFileLockUnavailable) - - err = PruneAfter(dir, 0) - require.ErrorIs(t, err, commonerrors.ErrFileLockUnavailable) - - err = VerifyIntegrity(dir) - require.ErrorIs(t, err, commonerrors.ErrFileLockUnavailable) -} - -// TestFileLockReleasedOnClose verifies that Close releases the lock so a later WAL and the offline utilities -// can acquire the same directory. -func TestFileLockReleasedOnClose(t *testing.T) { - dir := t.TempDir() - - w := openWAL(t, testConfig(dir)) - for index := uint64(1); index <= 5; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Flush()) - require.NoError(t, w.Close()) - - // Every operation that takes the lock now succeeds because the lock was released by Close. - ok, first, last, err := GetRange(dir) - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), first) - require.Equal(t, uint64(5), last) - - require.NoError(t, VerifyIntegrity(dir)) - require.NoError(t, PruneAfter(dir, 3)) - - w2 := openWAL(t, testConfig(dir)) - require.NoError(t, w2.Close()) -} - -// TestFileLockSequentialOpenClose verifies that repeated open/close cycles succeed: the lock leaves no stale -// state that blocks a subsequent open. -func TestFileLockSequentialOpenClose(t *testing.T) { - dir := t.TempDir() - for i := 0; i < 3; i++ { - w := openWAL(t, testConfig(dir)) - require.NoError(t, w.Close()) - } -} - -// TestFileLockFileIgnoredByScans verifies that the lock file does not interfere with directory recovery: a -// WAL that appended, closed, and reopened still reports the correct bounds despite wal.lock in the directory. -func TestFileLockFileIgnoredByScans(t *testing.T) { - dir := t.TempDir() - - w := openWAL(t, testConfig(dir)) - for index := uint64(1); index <= 5; index++ { - appendRecord(t, w, index) - } - require.NoError(t, w.Flush()) - require.NoError(t, w.Close()) - - require.FileExists(t, filepath.Join(dir, lockFileName)) - - w2 := openWAL(t, testConfig(dir)) - defer func() { require.NoError(t, w2.Close()) }() - ok, first, last, err := w2.Bounds() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), first) - require.Equal(t, uint64(5), last) -} diff --git a/sei-db/seiwal/seiwal_offline.go b/sei-db/seiwal/seiwal_offline.go deleted file mode 100644 index c001d2fa1c..0000000000 --- a/sei-db/seiwal/seiwal_offline.go +++ /dev/null @@ -1,136 +0,0 @@ -package seiwal - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "sort" - - "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt/util" -) - -// GetRange reports the range of record indices stored in the WAL directory at path, without constructing a -// live WAL instance. It first runs the standard recovery/sanity pass (the same one the constructor uses), -// which SEALS any unsealed file left behind by a prior session — so this function mutates the directory on -// disk even though its name suggests a read. -// -// It takes the same exclusive directory lock a live WAL holds, so it fails with -// commonerrors.ErrFileLockUnavailable if a WAL is open on the same directory, and serializes against any -// other GetRange/PruneAfter/VerifyIntegrity on that directory. The lock is released before it returns. -func GetRange(path string) (ok bool, first uint64, last uint64, err error) { - if err := util.EnsureDirectoryExists(path, true); err != nil { - return false, 0, 0, fmt.Errorf("failed to ensure WAL directory %s: %w", path, err) - } - lock, err := acquireDirLock(path) - if err != nil { - return false, 0, 0, fmt.Errorf("failed to lock WAL directory %s: %w", path, err) - } - defer releaseDirLock(lock, path) - - if err := recoverDirectory(path); err != nil { - return false, 0, 0, fmt.Errorf("failed to recover WAL directory: %w", err) - } - sealedFiles, _, err := scanSealedFiles(path) - if err != nil { - return false, 0, 0, err - } - front, ok := sealedFiles.TryPeekFront() - if !ok { - return false, 0, 0, nil - } - back, _ := sealedFiles.TryPeekBack() - return true, front.firstIndex, back.lastIndex, nil -} - -// PruneAfter deletes every record with an index greater than highestIndexToKeep from the WAL directory at -// path — the offline rollback operation — without constructing a live WAL instance. It runs the standard -// recovery/sanity pass first (sealing any orphaned file), applies the rollback, then re-scans the result. -// -// It takes the same exclusive directory lock a live WAL holds, so it fails with -// commonerrors.ErrFileLockUnavailable if a WAL is open on the same directory, and serializes against any -// other GetRange/PruneAfter/VerifyIntegrity on that directory. The lock is released before it returns. -func PruneAfter(path string, highestIndexToKeep uint64) error { - if err := util.EnsureDirectoryExists(path, true); err != nil { - return fmt.Errorf("failed to ensure WAL directory %s: %w", path, err) - } - lock, err := acquireDirLock(path) - if err != nil { - return fmt.Errorf("failed to lock WAL directory %s: %w", path, err) - } - defer releaseDirLock(lock, path) - - if err := recoverDirectory(path); err != nil { - return fmt.Errorf("failed to recover WAL directory: %w", err) - } - if err := rollbackDirectory(path, highestIndexToKeep); err != nil { - return fmt.Errorf("failed to prune WAL entries after index %d: %w", highestIndexToKeep, err) - } - if _, _, err := scanSealedFiles(path); err != nil { - return fmt.Errorf("WAL is corrupt after pruning: %w", err) - } - return nil -} - -// VerifyIntegrity checks every sealed file in the WAL directory at path: each record's CRC is intact, each -// file's content covers the index range its name promises, and the sealed sequence has no gaps or duplicates. -// It does not modify the directory. It requires the exclusive directory lock and returns -// commonerrors.ErrFileLockUnavailable if a WAL is open on the same directory. -func VerifyIntegrity(path string) error { - if err := util.EnsureDirectoryExists(path, true); err != nil { - return fmt.Errorf("failed to ensure WAL directory %s: %w", path, err) - } - lock, err := acquireDirLock(path) - if err != nil { - return fmt.Errorf("failed to lock WAL directory %s: %w", path, err) - } - defer releaseDirLock(lock, path) - - entries, err := os.ReadDir(path) - if err != nil { - return fmt.Errorf("failed to read WAL directory %s: %w", path, err) - } - - sealed := make([]parsedFileName, 0, len(entries)) - for _, entry := range entries { - if entry.IsDir() { - continue - } - parsed, ok := parseFileName(entry.Name()) - if !ok || !parsed.sealed { - continue - } - sealed = append(sealed, parsed) - } - sort.Slice(sealed, func(i int, j int) bool { return sealed[i].fileSeq < sealed[j].fileSeq }) - - var problems []error - for i, parsed := range sealed { - if i > 0 { - switch { - case parsed.fileSeq == sealed[i-1].fileSeq: - problems = append(problems, fmt.Errorf( - "duplicate sealed file sequence %d (an interrupted rollback swap left two files)", - parsed.fileSeq)) - case parsed.fileSeq != sealed[i-1].fileSeq+1: - problems = append(problems, fmt.Errorf( - "gap in sealed file sequence between %d and %d (a sealed file is missing)", - sealed[i-1].fileSeq, parsed.fileSeq)) - } - } - // A sealed file's name is a deterministic function of its parsed fields, so reconstruct it here rather - // than tracking the raw directory entry. Two sealed files that share a fileSeq (a crash remnant from an - // interrupted rollback swap) then resolve to their own distinct files instead of collapsing to one. - name := sealedFileName(parsed.fileSeq, parsed.firstIndex, parsed.lastIndex) - contents, err := readWalFile(filepath.Join(path, name)) - if err != nil { - problems = append(problems, fmt.Errorf("failed to read sealed WAL file %s: %w", name, err)) - continue - } - err = verifySealedContents(contents, parsed.fileSeq, parsed.firstIndex, parsed.lastIndex) - if err != nil { - problems = append(problems, err) - } - } - return errors.Join(problems...) -} diff --git a/sei-db/seiwal/seiwal_serializing.go b/sei-db/seiwal/seiwal_serializing.go deleted file mode 100644 index 1874131cad..0000000000 --- a/sei-db/seiwal/seiwal_serializing.go +++ /dev/null @@ -1,412 +0,0 @@ -package seiwal - -import ( - "context" - "errors" - "fmt" - "sync" - "time" - - "go.opentelemetry.io/otel/metric" -) - -var _ WAL[[]byte] = (*serializingWAL[[]byte])(nil) - -// serAppend carries a framed-payload producer to the serializer goroutine. The closure captures the typed -// item so this message type stays non-generic — T never enters the channel's dynamic type, which keeps the -// serializer loop's type switch free of type parameters. -type serAppend struct { - index uint64 - serialize func() ([]byte, error) -} - -// serFlush asks the serializer goroutine to flush the inner WAL, signaling done when durable. -type serFlush struct { - done chan error -} - -// serBounds asks the serializer goroutine to report the inner WAL's stored index range. -type serBounds struct { - reply chan serBoundsResult -} - -// The index range (and any error) reported by the inner WAL's Bounds. -type serBoundsResult struct { - ok bool - first uint64 - last uint64 - err error -} - -// serPrune asks the serializer goroutine to prune the inner WAL below `through`. -type serPrune struct { - through uint64 -} - -// serIterator asks the serializer goroutine to create an inner iterator, ordered after every prior append. -type serIterator struct { - startIndex uint64 - endIndex uint64 - reply chan serIteratorResult -} - -// The inner iterator (or an error) produced in response to a serIterator request. -type serIteratorResult struct { - it Iterator[[]byte] - err error -} - -// serClose asks the serializer goroutine to close the inner WAL and shut down, signaling done when closed. -type serClose struct { - done chan error -} - -// serializingWAL is a WAL[T] that serializes each payload to []byte on a background goroutine. -type serializingWAL[T any] struct { - // The inner byte-oriented WAL that framed records are delegated to. - inner WAL[[]byte] - - // Serializes a payload to bytes; runs on the serializer goroutine. - serialize func(T) ([]byte, error) - // Deserializes stored bytes back to a payload; runs inline in the iterator. - deserialize func([]byte) (T, error) - - // The measurement option tagging this instance's metrics with its name. Read-only after construction. - metricAttrs metric.MeasurementOption - - // Caller entry points funnel through serializerChan as a single ordered stream to the serializer. - serializerChan chan any - - // The hard-stop context the serializer watches. Cancelled by fail() with the fatal error as its cause, - // and by Close() (with a nil cause) once everything has drained. The cause carries the fatal error to - // callers, so no separate error field is needed. - ctx context.Context - // Cancels ctx, tearing down the serializer goroutine, recording the fatal error (or nil) as the cause. - cancel context.CancelCauseFunc - - // A child of ctx that the serializerChan producers watch, cancelled once the serializer stops reading so - // an in-flight or future push aborts rather than deadlocking. - senderCtx context.Context - // Cancels senderCtx. - senderCancel context.CancelCauseFunc - - // Tracks the serializer and queue-depth sampler goroutines so Close() can wait for them to exit. - wg sync.WaitGroup - - // Closed by Close() to stop the queue-depth sampler goroutine. - samplerStop chan struct{} - - // Guarantees the Close() shutdown sequence runs at most once. - closeOnce sync.Once - - // Set by Close() so subsequent scheduling calls fail fast. Plain: calling any method after Close is a - // contract violation, so this need not be atomic. - closed bool -} - -func newSerializingWAL[T any]( - config *Config, - inner WAL[[]byte], - serialize func(T) ([]byte, error), - deserialize func([]byte) (T, error), -) *serializingWAL[T] { - ctx, cancel := context.WithCancelCause(context.Background()) - senderCtx, senderCancel := context.WithCancelCause(ctx) - - s := &serializingWAL[T]{ - inner: inner, - serialize: serialize, - deserialize: deserialize, - metricAttrs: walNameAttr(config.Name), - serializerChan: make(chan any, config.SerializerBufferSize), - ctx: ctx, - cancel: cancel, - senderCtx: senderCtx, - senderCancel: senderCancel, - samplerStop: make(chan struct{}), - } - - s.wg.Add(1) - go s.serializerLoop() - - if config.MetricsSampleInterval > 0 { - s.wg.Add(1) - go s.sampleQueueDepth(config.Name, config.MetricsSampleInterval) - } - - return s -} - -// sampleQueueDepth periodically records the serializer channel's buffered depth until Close stops it -// (samplerStop) or a fatal shutdown cancels ctx. -func (s *serializingWAL[T]) sampleQueueDepth(name string, interval time.Duration) { - defer s.wg.Done() - attrs := queueDepthAttrs(name, "serializer") - ticker := time.NewTicker(interval) - defer ticker.Stop() - for { - select { - case <-s.ctx.Done(): - return - case <-s.samplerStop: - return - case <-ticker.C: - walQueueDepth.Record(s.ctx, int64(len(s.serializerChan)), attrs) - } - } -} - -// Append schedules a payload to be serialized and appended at the given index. -func (s *serializingWAL[T]) Append(index uint64, data T) error { - if s.closed { - return fmt.Errorf("WAL is closed") - } - req := serAppend{ - index: index, - serialize: func() ([]byte, error) { return s.serialize(data) }, - } - if err := s.submit(req); err != nil { - return fmt.Errorf("failed to schedule append for index %d: %w", index, err) - } - return nil -} - -// Flush blocks until all previously scheduled appends are durable. -func (s *serializingWAL[T]) Flush() error { - done := make(chan error, 1) - if err := s.submit(serFlush{done: done}); err != nil { - return fmt.Errorf("failed to schedule flush: %w", err) - } - select { - case err := <-done: - return err // already wrapped by the inner WAL, or nil on success - case <-s.ctx.Done(): - if err := s.asyncError(); err != nil { - return fmt.Errorf("flush aborted: %w", err) - } - return fmt.Errorf("flush aborted: %w", s.ctx.Err()) - } -} - -// Bounds reports the range of record indices stored in the WAL. -func (s *serializingWAL[T]) Bounds() (bool, uint64, uint64, error) { - reply := make(chan serBoundsResult, 1) - if err := s.submit(serBounds{reply: reply}); err != nil { - return false, 0, 0, fmt.Errorf("failed to schedule bounds query: %w", err) - } - select { - case r := <-reply: - if r.err != nil { - return false, 0, 0, fmt.Errorf("bounds query failed: %w", r.err) - } - return r.ok, r.first, r.last, nil - case <-s.ctx.Done(): - if err := s.asyncError(); err != nil { - return false, 0, 0, fmt.Errorf("bounds query aborted: %w", err) - } - return false, 0, 0, fmt.Errorf("bounds query aborted: %w", s.ctx.Err()) - } -} - -// PruneBefore schedules removal of whole inner files below lowestIndexToKeep. It does not block on completion. -func (s *serializingWAL[T]) PruneBefore(lowestIndexToKeep uint64) error { - if err := s.submit(serPrune{through: lowestIndexToKeep}); err != nil { - return fmt.Errorf("failed to schedule prune below index %d: %w", lowestIndexToKeep, err) - } - return nil -} - -// Iterator returns an iterator over the inclusive index range [startIndex, endIndex]. Construction is ordered -// on the serializer goroutine after every prior append, so the iterator observes all previously scheduled -// appends. -func (s *serializingWAL[T]) Iterator(startIndex uint64, endIndex uint64) (Iterator[T], error) { - reply := make(chan serIteratorResult, 1) - if err := s.submit(serIterator{startIndex: startIndex, endIndex: endIndex, reply: reply}); err != nil { - return nil, fmt.Errorf("failed to schedule iterator creation: %w", err) - } - select { - case r := <-reply: - if r.err != nil { - return nil, fmt.Errorf("failed to create iterator: %w", r.err) - } - return &serializingIterator[T]{inner: r.it, deserialize: s.deserialize}, nil - case <-s.ctx.Done(): - if err := s.asyncError(); err != nil { - return nil, fmt.Errorf("iterator creation aborted: %w", err) - } - return nil, fmt.Errorf("iterator creation aborted: %w", s.ctx.Err()) - } -} - -// Close flushes pending appends, closes the inner WAL, and releases resources. -func (s *serializingWAL[T]) Close() error { - var closeErr error - s.closeOnce.Do(func() { - s.closed = true - close(s.samplerStop) // stop the queue-depth sampler before waiting for goroutines - done := make(chan error, 1) - if err := s.submit(serClose{done: done}); err == nil { - select { - case closeErr = <-done: - case <-s.ctx.Done(): - } - } - s.wg.Wait() - s.cancel(nil) // a clean close carries no fatal cause; a prior fail() already recorded one - }) - if err := s.asyncError(); err != nil { - return fmt.Errorf("WAL closed with error: %w", err) - } - return closeErr // already wrapped by the inner WAL, or nil on a clean close -} - -// submit enqueues a message onto the serializer's input channel, aborting if the WAL is shutting down or has -// failed. -func (s *serializingWAL[T]) submit(msg any) error { - // Prioritize shutdown: if the sender context is already done, never race the send case of the select - // below, which could otherwise enqueue onto a stopped serializer's buffer and silently drop the record. - select { - case <-s.senderCtx.Done(): - return s.senderErr() - default: - } - select { - case s.serializerChan <- msg: - return nil - case <-s.senderCtx.Done(): - return s.senderErr() - } -} - -// senderErr reports why a submit was aborted: the fatal cause if the WAL bricked, or a plain closed error if -// it was shut down normally. -func (s *serializingWAL[T]) senderErr() error { - if cause := context.Cause(s.senderCtx); cause != nil && cause != context.Canceled { - return fmt.Errorf("WAL failed: %w", cause) - } - return fmt.Errorf("WAL is closed") -} - -// serializerLoop serializes each append's payload and delegates it to the inner WAL, handling control -// messages (flush, bounds, prune, iterator, close) in FIFO order relative to appends so they observe a -// consistent view. Runs on its own goroutine until close or a fatal error. -func (s *serializingWAL[T]) serializerLoop() { - defer s.wg.Done() - for { - var msg any - select { - case <-s.ctx.Done(): - return - case msg = <-s.serializerChan: - } - - switch m := msg.(type) { - case serAppend: - start := time.Now() - data, err := m.serialize() - if err != nil { - walSerializeErrors.Add(s.ctx, 1, s.metricAttrs) - s.fail(fmt.Errorf("failed to serialize record for index %d: %w", m.index, err)) - return - } - walSerializeDuration.Record(s.ctx, time.Since(start).Seconds(), s.metricAttrs) - walSerializedBytes.Add(s.ctx, int64(len(data)), s.metricAttrs) - if err := s.inner.Append(m.index, data); err != nil { - s.fail(fmt.Errorf("failed to append record for index %d: %w", m.index, err)) - return - } - case serFlush: - err := s.inner.Flush() - m.done <- err - if err != nil { - s.fail(fmt.Errorf("failed to flush: %w", err)) - return - } - case serBounds: - ok, first, last, err := s.inner.Bounds() - m.reply <- serBoundsResult{ok: ok, first: first, last: last, err: err} - if err != nil { - s.fail(fmt.Errorf("bounds query failed: %w", err)) - return - } - case serPrune: - if err := s.inner.PruneBefore(m.through); err != nil { - s.fail(fmt.Errorf("failed to prune below index %d: %w", m.through, err)) - return - } - case serIterator: - it, err := s.inner.Iterator(m.startIndex, m.endIndex) - m.reply <- serIteratorResult{it: it, err: err} - // A rejected range leaves the inner WAL healthy, so mirror that here; only a genuine inner - // failure bricks the serializing layer. - if err != nil && !errors.Is(err, ErrIteratorRange) { - s.fail(fmt.Errorf("failed to create iterator: %w", err)) - return - } - case serClose: - m.done <- s.inner.Close() - // FIFO guarantees every prior append has been delegated. Forbid further pushes so any - // racing/future schedule aborts instead of deadlocking against the now-exiting serializer. - s.senderCancel(nil) // normal shutdown, not a failure - return - } - } -} - -// fail records the first fatal background error and triggers shutdown of the pipeline. The error is recorded -// as the cancellation cause of ctx, so callers observe it via asyncError / context.Cause. -func (s *serializingWAL[T]) fail(err error) { - s.senderCancel(err) - s.cancel(err) // the first cancel wins, so the first fatal error is the one retained - if cerr := s.inner.Close(); cerr != nil { - logger.Error("failed to close inner WAL after fatal error", "err", cerr) - } - logger.Error("serializing WAL encountered a fatal error", "err", err) -} - -// asyncError returns the first fatal background error, or nil if the WAL is healthy or was closed normally. -func (s *serializingWAL[T]) asyncError() error { - if cause := context.Cause(s.ctx); cause != nil && cause != context.Canceled { - return cause - } - return nil -} - -var _ Iterator[[]byte] = (*serializingIterator[[]byte])(nil) - -// serializingIterator adapts an inner byte iterator to a typed iterator by running deserialize inline in Next. -// Like the inner iterator, it is single-consumer and not safe for concurrent use (see the Iterator -// concurrency contract). -type serializingIterator[T any] struct { - inner Iterator[[]byte] - deserialize func([]byte) (T, error) - index uint64 - entry T -} - -func (it *serializingIterator[T]) Next() (bool, error) { - ok, err := it.inner.Next() - if err != nil || !ok { - var zero T - it.entry = zero - return false, err - } - index, data := it.inner.Entry() - value, err := it.deserialize(data) - if err != nil { - var zero T - it.entry = zero - return false, fmt.Errorf("failed to deserialize record at index %d: %w", index, err) - } - it.index = index - it.entry = value - return true, nil -} - -func (it *serializingIterator[T]) Entry() (uint64, T) { - return it.index, it.entry -} - -func (it *serializingIterator[T]) Close() error { - return it.inner.Close() -} diff --git a/sei-db/seiwal/seiwal_serializing_test.go b/sei-db/seiwal/seiwal_serializing_test.go deleted file mode 100644 index 19e03ed266..0000000000 --- a/sei-db/seiwal/seiwal_serializing_test.go +++ /dev/null @@ -1,322 +0,0 @@ -package seiwal - -import ( - "errors" - "fmt" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -// TestGenericWALQueueDepthSampler exercises the queue-depth samplers (both the serializing layer's and the -// inner byte engine's) on a tiny interval, validating concurrent sampling under the race detector and a clean -// shutdown on Close. -func TestGenericWALQueueDepthSampler(t *testing.T) { - cfg := testConfig(t.TempDir()) - cfg.MetricsSampleInterval = time.Millisecond - w := openStringWAL(t, cfg) - for i := uint64(1); i <= 300; i++ { - require.NoError(t, w.Append(i, fmt.Sprintf("v%d", i))) - } - require.NoError(t, w.Flush()) - require.NoError(t, w.Close()) -} - -func stringSerialize(s string) ([]byte, error) { return []byte(s), nil } -func stringDeserialize(b []byte) (string, error) { return string(b), nil } - -func openStringWAL(t *testing.T, cfg *Config) WAL[string] { - t.Helper() - w, err := NewGenericWAL[string](cfg, stringSerialize, stringDeserialize) - require.NoError(t, err) - return w -} - -type indexedString struct { - index uint64 - value string -} - -func collectStrings(t *testing.T, w WAL[string], start uint64, end uint64) []indexedString { - t.Helper() - it, err := w.Iterator(start, end) - require.NoError(t, err) - defer func() { require.NoError(t, it.Close()) }() - - var out []indexedString - for { - ok, err := it.Next() - require.NoError(t, err) - if !ok { - break - } - index, value := it.Entry() - out = append(out, indexedString{index: index, value: value}) - } - return out -} - -// TestSerializeFailureClosesInnerWAL verifies that a serialize error tears the inner byte WAL down instead of -// orphaning its writer goroutine and mutable-file handle. The inner WAL is healthy (only serialization failed), -// so fail() closes it gracefully — observable as the inner mutable file being sealed. -func TestSerializeFailureClosesInnerWAL(t *testing.T) { - cfg := testConfig(t.TempDir()) - boom := errors.New("serialize boom") - serialize := func(s string) ([]byte, error) { return nil, boom } - w, err := NewGenericWAL[string](cfg, serialize, stringDeserialize) - require.NoError(t, err) - - require.NoError(t, w.Append(1, "one")) // scheduling succeeds; serialize fails on the serializer goroutine - - // Close drains the serializer goroutine, which by now has run fail() -> inner.Close(). - err = w.Close() - require.Error(t, err) - require.ErrorIs(t, err, boom) - - sw := w.(*serializingWAL[string]) - inner := sw.inner.(*walImpl) - require.True(t, inner.mutableFile.sealed) // inner cleanly closed by fail(), not orphaned -} - -// TestGenericWALFlushIOFailureBricksWAL verifies that an inner flush IO failure bricks the serializing WAL too: -// the inner byte engine tears itself down, and the serializing layer mirrors that rather than delegating -// subsequent appends to a dead inner WAL. -func TestGenericWALFlushIOFailureBricksWAL(t *testing.T) { - cfg := testConfig(t.TempDir()) - w := openStringWAL(t, cfg) - - ser, ok := w.(*serializingWAL[string]) - require.True(t, ok) - inner, ok := ser.inner.(*walImpl) - require.True(t, ok) - - // Close the inner mutable file's descriptor so the flush the inner engine performs fails. - require.NoError(t, inner.mutableFile.file.Close()) - - require.NoError(t, w.Append(1, "one")) - require.Error(t, w.Flush(), "flush must surface the inner IO failure") - - // Bricking cancels the serializing layer's context; wait for it so the assertions below are deterministic. - select { - case <-ser.ctx.Done(): - case <-time.After(5 * time.Second): - t.Fatal("serializing WAL did not brick after flush failure") - } - - require.Error(t, w.Append(2, "two"), "appends must fail on a bricked WAL") - require.Error(t, w.Flush(), "flush must fail on a bricked WAL") - require.Error(t, w.Close(), "Close must surface the fatal flush error") -} - -func TestGenericWALRoundTrip(t *testing.T) { - cfg := testConfig(t.TempDir()) - cfg.PermitGaps = true - w := openStringWAL(t, cfg) - defer func() { require.NoError(t, w.Close()) }() - - require.NoError(t, w.Append(1, "one")) - require.NoError(t, w.Append(2, "two")) - require.NoError(t, w.Append(5, "five")) // non-contiguous index is allowed when gaps are permitted - require.NoError(t, w.Flush()) - - ok, first, last, err := w.Bounds() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), first) - require.Equal(t, uint64(5), last) - - require.Equal(t, []indexedString{{1, "one"}, {2, "two"}, {5, "five"}}, collectStrings(t, w, 0, 5)) - require.Equal(t, []indexedString{{2, "two"}, {5, "five"}}, collectStrings(t, w, 2, 5)) -} - -func TestGenericWALReopen(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - - w := openStringWAL(t, cfg) - for i := uint64(1); i <= 3; i++ { - require.NoError(t, w.Append(i, fmt.Sprintf("v%d", i))) - } - require.NoError(t, w.Flush()) - require.NoError(t, w.Close()) - - w2 := openStringWAL(t, cfg) - defer func() { require.NoError(t, w2.Close()) }() - - ok, first, last, err := w2.Bounds() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), first) - require.Equal(t, uint64(3), last) - require.Equal(t, []indexedString{{1, "v1"}, {2, "v2"}, {3, "v3"}}, collectStrings(t, w2, 0, 3)) -} - -func TestGenericWALPrune(t *testing.T) { - cfg := testConfig(t.TempDir()) - cfg.TargetFileSize = 1 // one record per file so pruning drops whole files - - w := openStringWAL(t, cfg) - defer func() { require.NoError(t, w.Close()) }() - for i := uint64(1); i <= 10; i++ { - require.NoError(t, w.Append(i, fmt.Sprintf("v%d", i))) - } - require.NoError(t, w.Flush()) - - require.NoError(t, w.PruneBefore(5)) - - ok, first, last, err := w.Bounds() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(5), first) - require.Equal(t, uint64(10), last) -} - -func TestGenericWALRollback(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = 1 - - w := openStringWAL(t, cfg) - for i := uint64(1); i <= 6; i++ { - require.NoError(t, w.Append(i, fmt.Sprintf("v%d", i))) - } - require.NoError(t, w.Close()) - - require.NoError(t, PruneAfter(cfg.Path, 3)) - w2 := openStringWAL(t, cfg) - defer func() { require.NoError(t, w2.Close()) }() - - ok, first, last, err := w2.Bounds() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), first) - require.Equal(t, uint64(3), last) - require.Equal(t, []indexedString{{1, "v1"}, {2, "v2"}, {3, "v3"}}, collectStrings(t, w2, 0, 3)) -} - -func TestGenericWALSerializeErrorSurfaces(t *testing.T) { - serErr := errors.New("serialize boom") - serialize := func(s string) ([]byte, error) { - if s == "bad" { - return nil, serErr - } - return []byte(s), nil - } - w, err := NewGenericWAL[string](testConfig(t.TempDir()), serialize, stringDeserialize) - require.NoError(t, err) - - require.NoError(t, w.Append(1, "good")) - require.NoError(t, w.Append(2, "bad")) // async; the serialize failure tears the pipeline down - - // The fatal serialization error surfaces on the next synchronous operation. - require.Error(t, w.Flush()) - require.Error(t, w.Close()) -} - -func TestGenericWALDeserializeErrorSurfaces(t *testing.T) { - deErr := errors.New("deserialize boom") - deserialize := func(b []byte) (string, error) { - if string(b) == "poison" { - return "", deErr - } - return string(b), nil - } - w, err := NewGenericWAL[string](testConfig(t.TempDir()), stringSerialize, deserialize) - require.NoError(t, err) - defer func() { require.NoError(t, w.Close()) }() - - require.NoError(t, w.Append(1, "ok")) - require.NoError(t, w.Append(2, "poison")) - require.NoError(t, w.Flush()) - - it, err := w.Iterator(0, 2) - require.NoError(t, err) - defer func() { require.NoError(t, it.Close()) }() - - ok, err := it.Next() - require.NoError(t, err) - require.True(t, ok) - index, value := it.Entry() - require.Equal(t, uint64(1), index) - require.Equal(t, "ok", value) - - // The poison record fails to deserialize; the error surfaces from Next, not a clean EOF. - ok, err = it.Next() - require.Error(t, err) - require.False(t, ok) -} - -func TestGenericWALAppendOrdering(t *testing.T) { - w := openStringWAL(t, testConfig(t.TempDir())) - - require.NoError(t, w.Append(5, "five")) - require.NoError(t, w.Flush()) - // The inner byte engine enforces strictly-increasing indices; a stale index tears the pipeline down. - require.NoError(t, w.Append(4, "four")) // async, will fail on the goroutine - require.Error(t, w.Flush()) - // Close surfaces the same fatal error rather than succeeding. - require.Error(t, w.Close()) -} - -// faultyInner is a WAL[[]byte] whose Bounds/Iterator return an injected error. An inner WAL only errors on -// those read-path calls once it is already dead, so it models a failed inner engine for the serializing layer. -type faultyInner struct { - boundsErr error - iterErr error -} - -var _ WAL[[]byte] = (*faultyInner)(nil) - -func (f *faultyInner) Append(uint64, []byte) error { return nil } -func (f *faultyInner) Flush() error { return nil } -func (f *faultyInner) Bounds() (bool, uint64, uint64, error) { return false, 0, 0, f.boundsErr } -func (f *faultyInner) PruneBefore(uint64) error { return nil } -func (f *faultyInner) Iterator(uint64, uint64) (Iterator[[]byte], error) { - return nil, f.iterErr -} -func (f *faultyInner) Close() error { return nil } - -// TestGenericWALBricksOnInnerBoundsError verifies that an error from the inner WAL's Bounds — which only -// happens once the inner engine is already dead — bricks the serializing layer instead of leaving it running -// against a dead inner until a later mutating call fails. -func TestGenericWALBricksOnInnerBoundsError(t *testing.T) { - boom := errors.New("inner bounds boom") - cfg := testConfig(t.TempDir()) - cfg.MetricsSampleInterval = 0 - s := newSerializingWAL[string](cfg, &faultyInner{boundsErr: boom}, stringSerialize, stringDeserialize) - defer func() { _ = s.Close() }() - - _, _, _, err := s.Bounds() - require.Error(t, err) - require.ErrorIs(t, err, boom) - - select { - case <-s.ctx.Done(): - case <-time.After(5 * time.Second): - t.Fatal("serializing WAL did not brick after inner Bounds error") - } - require.ErrorIs(t, s.asyncError(), boom) - require.Error(t, s.Append(1, "x"), "appends must fail on a bricked WAL") -} - -// TestGenericWALBricksOnInnerIteratorError is the Iterator analogue of TestGenericWALBricksOnInnerBoundsError. -func TestGenericWALBricksOnInnerIteratorError(t *testing.T) { - boom := errors.New("inner iterator boom") - cfg := testConfig(t.TempDir()) - cfg.MetricsSampleInterval = 0 - s := newSerializingWAL[string](cfg, &faultyInner{iterErr: boom}, stringSerialize, stringDeserialize) - defer func() { _ = s.Close() }() - - _, err := s.Iterator(0, 0) - require.Error(t, err) - require.ErrorIs(t, err, boom) - - select { - case <-s.ctx.Done(): - case <-time.After(5 * time.Second): - t.Fatal("serializing WAL did not brick after inner Iterator error") - } - require.ErrorIs(t, s.asyncError(), boom) - require.Error(t, s.Append(1, "x"), "appends must fail on a bricked WAL") -} diff --git a/sei-db/state_db/bench/cryptosim/util.go b/sei-db/state_db/bench/cryptosim/util.go index 9eab5713ba..b86ff01c58 100644 --- a/sei-db/state_db/bench/cryptosim/util.go +++ b/sei-db/state_db/bench/cryptosim/util.go @@ -18,7 +18,7 @@ func BytesToHex(b []byte) string { // Get the key for the account ID counter in the database. // Uses EVMKeyCode with padded keyBytes; EVMKeyNonce requires 20-byte addresses and -// non-standard lengths are routed to EVMKeyMisc which FlatKV ignores. +// non-standard lengths are routed to EVMKeyLegacy which FlatKV ignores. func AccountIDCounterKey() []byte { return keys.BuildEVMKey(keys.EVMKeyCode, paddedCounterKey(accountIdCounterKey)) } diff --git a/sei-db/state_db/bench/wrappers/db_implementations.go b/sei-db/state_db/bench/wrappers/db_implementations.go index 432cb4ef53..3027f027b9 100644 --- a/sei-db/state_db/bench/wrappers/db_implementations.go +++ b/sei-db/state_db/bench/wrappers/db_implementations.go @@ -150,11 +150,7 @@ func NewDBImpl(ctx context.Context, dbType DBType, dataDir string, dbConfig any) case MemIAVL: return newMemIAVLCommitStore(dataDir) case FlatKV: - flatKVConfig, ok := dbConfig.(*flatkvConfig.Config) - if dbConfig != nil && !ok { - return nil, fmt.Errorf("invalid FlatKV config type %T", dbConfig) - } - return newFlatKVCommitStore(ctx, dataDir, flatKVConfig) + return newFlatKVCommitStore(ctx, dataDir, dbConfig.(*flatkvConfig.Config)) case CompositeDual: return newCompositeCommitStore(ctx, dataDir, sctypes.TestOnlyDualWrite) case CompositeSplit: diff --git a/sei-db/state_db/bench/wrappers/wrappers_test.go b/sei-db/state_db/bench/wrappers/wrappers_test.go index 3d556fbc1c..c1b7d88303 100644 --- a/sei-db/state_db/bench/wrappers/wrappers_test.go +++ b/sei-db/state_db/bench/wrappers/wrappers_test.go @@ -191,16 +191,3 @@ func TestNoOpWrapperTracksVersionWithoutReadsOrWrites(t *testing.T) { require.NoError(t, err) require.Equal(t, int64(9), version) } - -func TestNewDBImplFlatKVUsesDefaultConfigWhenNil(t *testing.T) { - wrapper, err := NewDBImpl(t.Context(), FlatKV, t.TempDir(), nil) - require.NoError(t, err) - require.NoError(t, wrapper.Close()) -} - -func TestNewDBImplFlatKVRejectsInvalidConfigType(t *testing.T) { - wrapper, err := NewDBImpl(t.Context(), FlatKV, t.TempDir(), "invalid") - require.Error(t, err) - require.Nil(t, wrapper) - require.ErrorContains(t, err, "invalid FlatKV config type string") -} diff --git a/sei-db/state_db/sc/composite/random_test_framework_test.go b/sei-db/state_db/sc/composite/random_test_framework_test.go index 9af946a17e..302d3495b9 100644 --- a/sei-db/state_db/sc/composite/random_test_framework_test.go +++ b/sei-db/state_db/sc/composite/random_test_framework_test.go @@ -333,7 +333,7 @@ func freshEVMValue(rng *testutil.TestRandom, key []byte) []byte { return randomCodeValue(rng) case keys.EVMKeyStorage: return randomStorageValue(rng) - default: // EVMKeyMisc + default: // EVMKeyLegacy return randomLegacyValue(rng) } } @@ -1057,7 +1057,7 @@ func oracleToFlatKVRows( getAcct(string(stripped)).nonce = binary.BigEndian.Uint64(v) case keys.EVMKeyCodeHash: copy(getAcct(string(stripped)).codeHash[:], v) - default: // EVMKeyMisc: identity-mapped under the "evm/" prefix + default: // EVMKeyLegacy: identity-mapped under the "evm/" prefix rows[string(ktype.ModulePhysicalKey(keys.EVMStoreKey, []byte(k)))] = flatKVExpectedRow{kind: rowLegacy, legacyValue: append([]byte(nil), v...)} } @@ -1138,7 +1138,7 @@ func assertFlatKVRowMatches(t *testing.T, physKey, rawVal []byte, exp flatKVExpe require.Equal(t, zeroBalance[:], ad.GetBalance()[:], "account balance must be zero (balances are not stored in flatkv yet) for %x", physKey) case rowLegacy: - ld, err := vtype.DeserializeMiscData(rawVal) + ld, err := vtype.DeserializeLegacyData(rawVal) require.NoError(t, err, "decode legacy row %x", physKey) require.True(t, valuesEqual(exp.legacyValue, ld.GetValue()), "legacy value mismatch for %x", physKey) } diff --git a/sei-db/state_db/sc/composite/store.go b/sei-db/state_db/sc/composite/store.go index bbe2b9dbd5..fdd5170e50 100644 --- a/sei-db/state_db/sc/composite/store.go +++ b/sei-db/state_db/sc/composite/store.go @@ -138,8 +138,6 @@ func NewCompositeCommitStore( return nil, fmt.Errorf("invalid state commit config: %w", err) } - alignFlatKVSnapshotWithMemIAVL(&cfg) - var memIAVL *memiavl.CommitStore if cfg.WriteMode != types.FlatKVOnly { memIAVL = memiavl.NewCommitStore(homeDir, cfg.MemIAVLConfig) @@ -178,47 +176,6 @@ func NewCompositeCommitStore( }, nil } -// alignFlatKVSnapshotWithMemIAVL keeps the two backends' snapshot cadence in -// sync. FlatKV has no independently-exposed snapshot knobs in app.toml, so it -// derives its snapshot-interval / keep-recent from memIAVL's sc-* keys. This is -// the single place both backends are constructed from the same config, so it is -// where the alignment is enforced. -// -// This derivation is intentionally unconditional across write modes, including -// FlatKVOnly — where NewCompositeCommitStore never constructs a memIAVL store. -// The sc-* keys are the only operator-visible snapshot-cadence knobs now that -// the flatkv.* keys are hidden from the app.toml template, so they must govern -// FlatKV's cadence in every mode; otherwise FlatKVOnly would have no -// template-visible way to tune it. It is harmless when memIAVL is absent: the -// sc-* defaults match FlatKV's own in-code defaults, and only cfg.FlatKVConfig -// is read when building the FlatKVOnly store. -// -// FlatKV mirrors memIAVL's *effective* cadence: a zero memIAVL value is first -// resolved to the same default Options.FillDefaults would apply at OpenDB -// (interval 0 -> DefaultSnapshotInterval, keep-recent 0 -> DefaultSnapshotKeepRecent), -// then assigned to FlatKV unconditionally. Resolving-then-assigning (rather than -// skipping on a zero and letting FlatKV keep its own in-code default) keeps the -// two backends in true lockstep without relying on FlatKV's default happening to -// equal memIAVL's healed default. That reliance is fragile — the defaults are -// only kept equal by hand — and it breaks for an upgrading node whose old -// app.toml still carries an explicit state-commit.flatkv.snapshot-keep-recent -// (rendered by the old template) alongside sc-keep-recent = 0: skipping would -// leave FlatKV pinned to the stale explicit value while memIAVL healed to a -// different default. Note that mirroring a raw 0 is never correct here (0 means -// "disable auto-snapshots" for FlatKV), which is why the zero is resolved first. -func alignFlatKVSnapshotWithMemIAVL(cfg *config.StateCommitConfig) { - interval := cfg.MemIAVLConfig.SnapshotInterval - if interval == 0 { - interval = memiavl.DefaultSnapshotInterval - } - keepRecent := cfg.MemIAVLConfig.SnapshotKeepRecent - if keepRecent == 0 { - keepRecent = memiavl.DefaultSnapshotKeepRecent - } - cfg.FlatKVConfig.SnapshotInterval = interval - cfg.FlatKVConfig.SnapshotKeepRecent = keepRecent -} - // Initialize records the set of child store names that should exist on // the memiavl backend the first time it is opened. In mixed-DB modes // names must be members of keys.MemIAVLStoreKeys. @@ -488,7 +445,7 @@ func (cs *CompositeCommitStore) resolveCurrentWriteMode(closeIdleFlatKV bool) er } cs.flatKV = nil } - logger.Debug("derived effective write mode from migration metadata", "mode", derived) + logger.Info("derived effective write mode from migration metadata", "mode", derived) cs.currentWriteMode = derived return nil } diff --git a/sei-db/state_db/sc/composite/store_test.go b/sei-db/state_db/sc/composite/store_test.go index e99e005a0a..d74fc7d34b 100644 --- a/sei-db/state_db/sc/composite/store_test.go +++ b/sei-db/state_db/sc/composite/store_test.go @@ -18,7 +18,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/ktype" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/hashlog" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/memiavl" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/migration" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" ) @@ -1043,13 +1042,6 @@ func evmMigratedConfig() config.StateCommitConfig { cfg.MemIAVLConfig.SnapshotInterval = 1 cfg.MemIAVLConfig.SnapshotMinTimeInterval = 0 cfg.MemIAVLConfig.AsyncCommitBuffer = 0 - // With SnapshotInterval=1 every commit produces a snapshot, and FlatKV - // mirrors this cadence via alignFlatKVSnapshotWithMemIAVL. The default - // keep-recent of 1 would prune all but the two newest snapshots, so a - // rollback/reconcile to an older version (e.g. v3 after committing v5) - // could no longer find a base snapshot at-or-below the target. Retain all - // snapshots for the short duration of a test so those paths stay valid. - cfg.MemIAVLConfig.SnapshotKeepRecent = 100 return cfg } @@ -2620,61 +2612,3 @@ func TestLoadVersionReadOnlyDuringMigrateEVMTransition(t *testing.T) { require.True(t, found, "pre-migration evm/ value must be visible to the read-only handle") require.Equal(t, []byte(evmVal), got) } - -func TestAlignFlatKVSnapshotWithMemIAVL(t *testing.T) { - t.Run("FlatKV derives interval and keep-recent from a non-zero memIAVL", func(t *testing.T) { - cfg := config.DefaultStateCommitConfig() - cfg.MemIAVLConfig.SnapshotInterval = 5000 - cfg.MemIAVLConfig.SnapshotKeepRecent = 3 - // Start FlatKV from divergent values to prove they get overwritten. - cfg.FlatKVConfig.SnapshotInterval = 111 - cfg.FlatKVConfig.SnapshotKeepRecent = 222 - - alignFlatKVSnapshotWithMemIAVL(&cfg) - - require.Equal(t, uint32(5000), cfg.FlatKVConfig.SnapshotInterval) - require.Equal(t, uint32(3), cfg.FlatKVConfig.SnapshotKeepRecent) - }) - - t.Run("a zero memIAVL keep-recent resolves to the healed default", func(t *testing.T) { - cfg := config.DefaultStateCommitConfig() - cfg.MemIAVLConfig.SnapshotKeepRecent = 0 - // FlatKV must not mirror the raw 0 (which would prune everything but the - // latest). Instead it mirrors the value FillDefaults will heal memIAVL to, - // keeping the two in lockstep. memIAVL's own 0 is left for FillDefaults. - alignFlatKVSnapshotWithMemIAVL(&cfg) - - require.Equal(t, uint32(0), cfg.MemIAVLConfig.SnapshotKeepRecent) - require.Equal(t, uint32(memiavl.DefaultSnapshotKeepRecent), cfg.FlatKVConfig.SnapshotKeepRecent) - }) - - t.Run("a zero memIAVL interval resolves to the healed default", func(t *testing.T) { - cfg := config.DefaultStateCommitConfig() - cfg.MemIAVLConfig.SnapshotInterval = 0 - // A raw 0 would disable FlatKV auto-snapshots; instead FlatKV mirrors the - // value FillDefaults will heal memIAVL's interval to. - alignFlatKVSnapshotWithMemIAVL(&cfg) - - require.Equal(t, uint32(memiavl.DefaultSnapshotInterval), cfg.FlatKVConfig.SnapshotInterval) - require.NotZero(t, cfg.FlatKVConfig.SnapshotInterval) - }) - - t.Run("an explicit FlatKV override loses to memIAVL's healed default", func(t *testing.T) { - // Upgrade scenario: an old app.toml still pins an explicit FlatKV - // keep-recent/interval (the previous template rendered flatkv.* keys) - // while sc-* is 0. FlatKV must follow memIAVL's effective (healed) cadence - // rather than staying pinned to the stale explicit value, otherwise the - // two backends diverge (memIAVL heals 0 -> default, FlatKV keeps the old - // explicit value). - cfg := config.DefaultStateCommitConfig() - cfg.MemIAVLConfig.SnapshotKeepRecent = 0 - cfg.MemIAVLConfig.SnapshotInterval = 0 - cfg.FlatKVConfig.SnapshotKeepRecent = 2 - cfg.FlatKVConfig.SnapshotInterval = 7777 - - alignFlatKVSnapshotWithMemIAVL(&cfg) - - require.Equal(t, uint32(memiavl.DefaultSnapshotKeepRecent), cfg.FlatKVConfig.SnapshotKeepRecent) - require.Equal(t, uint32(memiavl.DefaultSnapshotInterval), cfg.FlatKVConfig.SnapshotInterval) - }) -} diff --git a/sei-db/state_db/sc/flatkv/api.go b/sei-db/state_db/sc/flatkv/api.go index 3dd3b1fe04..070ba27dce 100644 --- a/sei-db/state_db/sc/flatkv/api.go +++ b/sei-db/state_db/sc/flatkv/api.go @@ -30,7 +30,7 @@ type Store interface { LoadVersion(targetVersion int64, readOnly bool) (Store, error) // ApplyChangeSets buffers EVM changesets (x/evm memiavl keys) and updates LtHash. - // Non-EVM modules are routed into misc storage under their module prefix. + // Non-EVM modules are routed into legacy storage under their module prefix. // Call Commit to persist. ApplyChangeSets(cs []*proto.NamedChangeSet) error @@ -53,13 +53,13 @@ type Store interface { // Get returns the value for a key within the given module. // For EVM keys (moduleName == "evm"), the key is a memiavl EVM key - // routed to account/storage/code/misc DBs internally. - // For non-EVM modules, the key is read from misc storage with the module prefix. + // routed to account/storage/code/legacy DBs internally. + // For non-EVM modules, the key is read from legacy storage with the module prefix. // If not found, returns (nil, false). Get(moduleName string, key []byte) (value []byte, found bool) // GetBlockHeightModified returns the block height at which the key was last modified. - // Only supported for EVM keys; non-EVM misc data does not track block height. + // Only supported for EVM keys; non-EVM legacy data does not track block height. // If not found, returns (-1, false, nil). GetBlockHeightModified(moduleName string, key []byte) (int64, bool, error) diff --git a/sei-db/state_db/sc/flatkv/config/config.go b/sei-db/state_db/sc/flatkv/config/config.go index 764e3fba39..dc349309a7 100644 --- a/sei-db/state_db/sc/flatkv/config/config.go +++ b/sei-db/state_db/sc/flatkv/config/config.go @@ -10,7 +10,7 @@ import ( const ( DefaultSnapshotInterval uint32 = 10000 - DefaultSnapshotKeepRecent uint32 = 1 + DefaultSnapshotKeepRecent uint32 = 2 ) // Config defines configuration for the FlatKV (EVM) commit store. @@ -39,7 +39,7 @@ type Config struct { // SnapshotKeepRecent defines how many old snapshots to keep besides the // latest one. 0 means keep only the current snapshot (no old snapshots). - // Default: 1 + // Default: 2 SnapshotKeepRecent uint32 `mapstructure:"snapshot-keep-recent"` // EnablePebbleMetrics defines if the Pebble metrics should be enabled. @@ -68,11 +68,11 @@ type Config struct { // StorageCacheConfig defines the cache configuration for the storage database. StorageCacheConfig dbcache.CacheConfig - // MiscDBConfig defines the PebbleDB configuration for the misc database. - MiscDBConfig pebbledb.PebbleDBConfig + // LegacyDBConfig defines the PebbleDB configuration for the legacy database. + LegacyDBConfig pebbledb.PebbleDBConfig - // MiscCacheConfig defines the cache configuration for the misc database. - MiscCacheConfig dbcache.CacheConfig + // LegacyCacheConfig defines the cache configuration for the legacy database. + LegacyCacheConfig dbcache.CacheConfig // MetadataDBConfig defines the PebbleDB configuration for the metadata database. MetadataDBConfig pebbledb.PebbleDBConfig @@ -98,12 +98,6 @@ type Config struct { // Controls the number of goroutines pre-allocated in the thread pool for miscellaneous operations. // The number of threads in this pool is equal to MiscThreadsPerCore * runtime.NumCPU() + MiscConstantThreadCount. MiscConstantThreadCount int - - // Controls the number of workers in the dedicated lattice-hash pool used to - // compute per-module LtHashes during ApplyChangeSets. The worker count is - // LtHashThreadsPerCore * runtime.NumCPU() (clamped to at least 1). LtHash - // computation is CPU-bound, so ~1 worker per core is a sensible default. - LtHashThreadsPerCore float64 } // DefaultConfig returns Config with safe default values. @@ -120,8 +114,8 @@ func DefaultConfig() *Config { CodeCacheConfig: dbcache.DefaultCacheConfig(), StorageDBConfig: pebbledb.DefaultConfig(), StorageCacheConfig: dbcache.DefaultCacheConfig(), - MiscDBConfig: pebbledb.DefaultConfig(), - MiscCacheConfig: dbcache.DefaultCacheConfig(), + LegacyDBConfig: pebbledb.DefaultConfig(), + LegacyCacheConfig: dbcache.DefaultCacheConfig(), MetadataDBConfig: pebbledb.DefaultConfig(), MetadataCacheConfig: dbcache.DefaultCacheConfig(), ReaderThreadsPerCore: 2.0, @@ -129,7 +123,6 @@ func DefaultConfig() *Config { ReaderPoolQueueSize: 1024, MiscPoolThreadsPerCore: 4.0, MiscConstantThreadCount: 0, - LtHashThreadsPerCore: 1.0, } cfg.AccountCacheConfig.MaxSize = unit.GB @@ -156,8 +149,8 @@ func (c *Config) Validate() error { if err := c.StorageCacheConfig.Validate(); err != nil { return fmt.Errorf("storage cache config is invalid: %w", err) } - if err := c.MiscCacheConfig.Validate(); err != nil { - return fmt.Errorf("misc cache config is invalid: %w", err) + if err := c.LegacyCacheConfig.Validate(); err != nil { + return fmt.Errorf("legacy cache config is invalid: %w", err) } if err := c.MetadataCacheConfig.Validate(); err != nil { return fmt.Errorf("metadata cache config is invalid: %w", err) @@ -174,8 +167,8 @@ func (c *Config) Validate() error { if err := c.StorageDBConfig.Validate(); err != nil { return fmt.Errorf("storage db config is invalid: %w", err) } - if err := c.MiscDBConfig.Validate(); err != nil { - return fmt.Errorf("misc db config is invalid: %w", err) + if err := c.LegacyDBConfig.Validate(); err != nil { + return fmt.Errorf("legacy db config is invalid: %w", err) } if err := c.MetadataDBConfig.Validate(); err != nil { return fmt.Errorf("metadata db config is invalid: %w", err) @@ -196,9 +189,6 @@ func (c *Config) Validate() error { if c.MiscConstantThreadCount < 0 { return fmt.Errorf("misc constant thread count must not be negative") } - if c.LtHashThreadsPerCore < 0 { - return fmt.Errorf("lthash threads per core must not be negative") - } return nil } diff --git a/sei-db/state_db/sc/flatkv/config/config_test.go b/sei-db/state_db/sc/flatkv/config/config_test.go index 4c5cf3ac9d..5664ef6e13 100644 --- a/sei-db/state_db/sc/flatkv/config/config_test.go +++ b/sei-db/state_db/sc/flatkv/config/config_test.go @@ -14,7 +14,7 @@ func validBaseConfig() *Config { cfg.AccountDBConfig.DataDir = "/tmp/test/account" cfg.CodeDBConfig.DataDir = "/tmp/test/code" cfg.StorageDBConfig.DataDir = "/tmp/test/storage" - cfg.MiscDBConfig.DataDir = "/tmp/test/misc" + cfg.LegacyDBConfig.DataDir = "/tmp/test/legacy" cfg.MetadataDBConfig.DataDir = "/tmp/test/metadata" return cfg } @@ -91,7 +91,7 @@ func TestDefaultConfigValidExceptDataDir(t *testing.T) { cfg.AccountDBConfig.DataDir = "/tmp/test/account" cfg.CodeDBConfig.DataDir = "/tmp/test/code" cfg.StorageDBConfig.DataDir = "/tmp/test/storage" - cfg.MiscDBConfig.DataDir = "/tmp/test/misc" + cfg.LegacyDBConfig.DataDir = "/tmp/test/legacy" cfg.MetadataDBConfig.DataDir = "/tmp/test/metadata" require.NoError(t, cfg.Validate()) } diff --git a/sei-db/state_db/sc/flatkv/config/flatkv_test_config.go b/sei-db/state_db/sc/flatkv/config/flatkv_test_config.go index 0b04fbe604..b2ff541d46 100644 --- a/sei-db/state_db/sc/flatkv/config/flatkv_test_config.go +++ b/sei-db/state_db/sc/flatkv/config/flatkv_test_config.go @@ -36,13 +36,12 @@ func DefaultTestConfig(t *testing.T) *Config { CodeCacheConfig: smallTestCacheConfig(), StorageDBConfig: smallTestPebbleConfig(), StorageCacheConfig: smallTestCacheConfig(), - MiscDBConfig: smallTestPebbleConfig(), - MiscCacheConfig: smallTestCacheConfig(), + LegacyDBConfig: smallTestPebbleConfig(), + LegacyCacheConfig: smallTestCacheConfig(), MetadataDBConfig: smallTestPebbleConfig(), MetadataCacheConfig: smallTestCacheConfig(), ReaderThreadsPerCore: 2.0, ReaderPoolQueueSize: 1024, MiscPoolThreadsPerCore: 4.0, - LtHashThreadsPerCore: 1.0, } } diff --git a/sei-db/state_db/sc/flatkv/hashlog_test.go b/sei-db/state_db/sc/flatkv/hashlog_test.go index 4d12f78f24..bc58103136 100644 --- a/sei-db/state_db/sc/flatkv/hashlog_test.go +++ b/sei-db/state_db/sc/flatkv/hashlog_test.go @@ -56,7 +56,7 @@ func TestFlatKVHashReporting(t *testing.T) { "flatKV/db/account", "flatKV/db/code", "flatKV/db/storage", - "flatKV/db/misc", + "flatKV/db/legacy", }, s.HashCategories()) logger := newCaptureLogger() diff --git a/sei-db/state_db/sc/flatkv/import_export_test.go b/sei-db/state_db/sc/flatkv/import_export_test.go index 5413cef941..8fb1543e64 100644 --- a/sei-db/state_db/sc/flatkv/import_export_test.go +++ b/sei-db/state_db/sc/flatkv/import_export_test.go @@ -708,10 +708,6 @@ func TestImporterDoubleImport(t *testing.T) { func TestExporterAtHistoricalVersion(t *testing.T) { cfg := config.DefaultTestConfig(t) cfg.SnapshotInterval = 1 - // Keep enough historical snapshots that v1 survives after committing v3 - // (latest v3 + the two older snapshots v2 and v1); the default keep-recent - // of 1 would prune v1 and make the historical export below fail. - cfg.SnapshotKeepRecent = 2 s := setupTestStoreWithConfig(t, cfg) defer s.Close() @@ -869,9 +865,9 @@ func TestExporterCorruptAccountValueInDB(t *testing.T) { } } -// TestExporterImporterNonEVMMiscRoundTrip drives non-EVM module data +// TestExporterImporterNonEVMLegacyRoundTrip drives non-EVM module data // (e.g. "bank", "staking") through the full Export → Import pipeline to -// cover the module-prefixed miscDB path introduced in PR #3229. +// cover the module-prefixed legacyDB path introduced in PR #3229. // // The path exercised here is NOT reachable from a rootmulti-level // integration test because CompositeCommitStore.ApplyChangeSets filters @@ -879,11 +875,11 @@ func TestExporterCorruptAccountValueInDB(t *testing.T) { // exporting, and re-importing non-EVM data has to be tested at this layer. // // Invariants verified: -// 1. ApplyChangeSets routes non-EVM modules to miscDB under a +// 1. ApplyChangeSets routes non-EVM modules to legacyDB under a // "/" physical-key prefix via classifyAndPrefix. // 2. The Exporter emits raw physical keys carrying that prefix. // 3. The Importer re-routes via routePhysicalKey, sending non-EVM keys -// back to miscDB with the prefix intact. +// back to legacyDB with the prefix intact. // 4. Get(moduleName, key) reads them back correctly, and cross-module // namespaces stay isolated (same inner bytes under a different module // must miss). @@ -891,7 +887,7 @@ func TestExporterCorruptAccountValueInDB(t *testing.T) { // resurface in the Exporter output. // 6. The imported store's LtHash matches the source bit-for-bit, and // VerifyLtHash passes on the imported store (full-scan ≡ committed). -func TestExporterImporterNonEVMMiscRoundTrip(t *testing.T) { +func TestExporterImporterNonEVMLegacyRoundTrip(t *testing.T) { src := setupTestStore(t) defer func() { require.NoError(t, src.Close()) }() @@ -1014,11 +1010,11 @@ func TestExporterImporterNonEVMMiscRoundTrip(t *testing.T) { require.Equalf(t, srcHash, dst.RootHash(), "RootHash after non-EVM round-trip mismatch") - // Full-scan verification catches any silent drift between miscDB's + // Full-scan verification catches any silent drift between legacyDB's // physical layout and the per-DB LtHash accumulator on the imported // store. require.NoError(t, VerifyLtHash(dst), - "VerifyLtHash should pass on imported store with misc data") + "VerifyLtHash should pass on imported store with legacy data") require.NoError(t, dst.Close()) } diff --git a/sei-db/state_db/sc/flatkv/import_translator.go b/sei-db/state_db/sc/flatkv/import_translator.go index 6c7722b384..3c67af7527 100644 --- a/sei-db/state_db/sc/flatkv/import_translator.go +++ b/sei-db/state_db/sc/flatkv/import_translator.go @@ -29,10 +29,10 @@ type PhysicalKVPair struct { // // It applies the same translation logic that CommitStore.ApplyChangeSets uses // (classifyAndPrefix + processStorageChanges + processCodeChanges + -// processMiscChanges + mergeAccountUpdates), but assumes the import target +// processLegacyChanges + mergeAccountUpdates), but assumes the import target // is empty so it does not merge with prior DB values. // -// Storage / code / misc / non-EVM pairs are emitted directly from each +// Storage / code / legacy / non-EVM pairs are emitted directly from each // Translate call. Account-related entries (nonce, codehash) are buffered // across all Translate calls so that each address is written exactly once // with its fully-merged AccountData; flush them by calling Finalize. @@ -56,7 +56,7 @@ func NewImportTranslator(blockHeight int64) *ImportTranslator { } } -// Translate returns the storage / code / misc / non-EVM physical pairs +// Translate returns the storage / code / legacy / non-EVM physical pairs // encoded from cs. Account fragments (nonce, codehash) are buffered // internally; flush them via Finalize after all changesets have been fed in. // @@ -106,11 +106,11 @@ func (t *ImportTranslator) Translate(cs *proto.NamedChangeSet) ([]PhysicalKVPair } out = appendNonDeletes(out, codeChanges) - miscChanges, err := processMiscChanges(changesByType[keys.EVMKeyMisc], t.blockHeight) + legacyChanges, err := processLegacyChanges(changesByType[keys.EVMKeyLegacy], t.blockHeight) if err != nil { - return nil, fmt.Errorf("failed to process misc changes: %w", err) + return nil, fmt.Errorf("failed to process legacy changes: %w", err) } - out = appendNonDeletes(out, miscChanges) + out = appendNonDeletes(out, legacyChanges) // Accumulate nonce + codeHash entries from this batch into the // translator-level pending account map. Multiple Translate calls @@ -162,9 +162,9 @@ func (t *ImportTranslator) Finalize() []PhysicalKVPair { // appendNonDeletes serializes every non-delete entry in m and appends the // resulting (physical_key, serialized_value) pair to out. Hoisted out of -// the three processStorage/Code/Misc branches in Translate (and reused +// the three processStorage/Code/Legacy branches in Translate (and reused // by Finalize) so that the "drop tombstones, serialize to PhysicalKVPair" -// contract lives in one place; mirrors gatherPairs's generic use +// contract lives in one place; mirrors gatherLTHashPairs's generic use // of vtype.VType in store_apply.go. func appendNonDeletes[T vtype.VType](out []PhysicalKVPair, m map[string]T) []PhysicalKVPair { for k, v := range m { diff --git a/sei-db/state_db/sc/flatkv/import_translator_test.go b/sei-db/state_db/sc/flatkv/import_translator_test.go index 072f4ffe44..bc3c08f84a 100644 --- a/sei-db/state_db/sc/flatkv/import_translator_test.go +++ b/sei-db/state_db/sc/flatkv/import_translator_test.go @@ -80,7 +80,7 @@ func TestImportTranslator_CodeEntry(t *testing.T) { require.Empty(t, tr.Finalize()) } -func TestImportTranslator_MiscEntryWithinEVMModule(t *testing.T) { +func TestImportTranslator_LegacyEntryWithinEVMModule(t *testing.T) { addr := addrN(0x42) rawKey := append([]byte{0x09}, addr[:]...) rawValue := []byte{0xAA, 0xBB} @@ -99,7 +99,7 @@ func TestImportTranslator_MiscEntryWithinEVMModule(t *testing.T) { expectedKey := ktype.ModulePhysicalKey(keys.EVMStoreKey, rawKey) require.Equal(t, expectedKey, pairs[0].Key) - got, err := vtype.DeserializeMiscData(pairs[0].Value) + got, err := vtype.DeserializeLegacyData(pairs[0].Value) require.NoError(t, err) require.Equal(t, importBlockHeight, got.GetBlockHeight()) require.Equal(t, rawValue, got.GetValue()) @@ -108,7 +108,7 @@ func TestImportTranslator_MiscEntryWithinEVMModule(t *testing.T) { require.Empty(t, tr.Finalize()) } -func TestImportTranslator_NonEVMModuleRoutesToMisc(t *testing.T) { +func TestImportTranslator_NonEVMModuleRoutesToLegacy(t *testing.T) { rawKey := []byte("custom-key") rawValue := []byte("custom-value") @@ -126,7 +126,7 @@ func TestImportTranslator_NonEVMModuleRoutesToMisc(t *testing.T) { expectedKey := ktype.ModulePhysicalKey("bank", rawKey) require.Equal(t, expectedKey, pairs[0].Key) - got, err := vtype.DeserializeMiscData(pairs[0].Value) + got, err := vtype.DeserializeLegacyData(pairs[0].Value) require.NoError(t, err) require.Equal(t, rawValue, got.GetValue()) diff --git a/sei-db/state_db/sc/flatkv/importer.go b/sei-db/state_db/sc/flatkv/importer.go index b3a2c79311..fa652e3ba8 100644 --- a/sei-db/state_db/sc/flatkv/importer.go +++ b/sei-db/state_db/sc/flatkv/importer.go @@ -46,33 +46,19 @@ type dbWorker struct { batch seidbtypes.Batch ltPairs []lthash.KVPairWithLastValue ltHash *lthash.LtHash - // moduleLtHash tracks the per-module decomposition of ltHash, keyed by the - // "/" physical-key prefix. Its homomorphic sum equals ltHash. - moduleLtHash map[string]*lthash.LtHash - // moduleStats tracks the per-module key-count / byte totals accumulated - // alongside moduleLtHash, keyed the same way. Mirrors the live commit path - // so an imported store carries identical per-module stats metadata. - moduleStats map[string]lthash.ModuleStats - // calc is the shared lattice-hash calculator. Its worker pool is used to - // distribute this worker's flushed pairs and compute per-module deltas — - // the same path the live commit uses (see HashCalculator.ComputeModuleHashInfos). - calc *lthash.HashCalculator flushes int64 pairs int64 } -func newDBWorker(ctx context.Context, dir string, db seidbtypes.KeyValueDB, calc *lthash.HashCalculator, ltHash *lthash.LtHash, moduleLtHash map[string]*lthash.LtHash, moduleStats map[string]lthash.ModuleStats) *dbWorker { +func newDBWorker(ctx context.Context, dir string, db seidbtypes.KeyValueDB, ltHash *lthash.LtHash) *dbWorker { return &dbWorker{ - ctx: ctx, - dir: dir, - db: db, - ch: make(chan rawKVPair, workerChanSize), - batch: db.NewBatch(), - ltPairs: make([]lthash.KVPairWithLastValue, 0, importBatchSize), - ltHash: ltHash, - moduleLtHash: moduleLtHash, - moduleStats: moduleStats, - calc: calc, + ctx: ctx, + dir: dir, + db: db, + ch: make(chan rawKVPair, workerChanSize), + batch: db.NewBatch(), + ltPairs: make([]lthash.KVPairWithLastValue, 0, importBatchSize), + ltHash: ltHash, } } @@ -124,29 +110,9 @@ func (w *dbWorker) flush() (err error) { metric.WithAttributes(dbAttr(w.dir), successAttr(err))) }() - // Per-module hashes are the primitive: distribute this batch's pairs across - // the shared lattice-hash pool to compute each touched module's delta (the - // same path the live commit uses), fold each delta into the running - // per-module hash, then derive the per-DB root as their homomorphic sum. - // This mirrors the live commit path so an imported store carries the same - // per-module metadata and identical per-DB root a natively-committed store - // would — and it lets a single large DB's batch fan out across every core - // instead of being pinned to one import worker goroutine. - deltas, err := w.calc.ComputeModuleHashInfos([]lthash.DBPairs{{Dir: w.dir, Pairs: w.ltPairs}}) - if err != nil { - return fmt.Errorf("%s compute module deltas: %w", w.dir, err) - } - for key, delta := range deltas { - acc := w.moduleLtHash[key.Module] - if acc == nil { - acc = lthash.New() - w.moduleLtHash[key.Module] = acc - } - acc.MixIn(delta.Hash) - w.moduleStats[key.Module] = w.moduleStats[key.Module].Add( - lthash.ModuleStats{KeyCount: delta.KeyCount, Bytes: delta.Bytes}) - } - w.ltHash = lthash.SumModuleHashes(w.moduleLtHash) + // TODO:In theory, we could offload lattice hash calculation to a work pool and get parallelism between DB operations and hash calculations. Cryptosim performance makes me think we could probably get a 2-3x speedup from this, assuming receiving data from the network isn't the bottleneck. + newHash, _ := lthash.ComputeLtHash(w.ltHash, w.ltPairs) + w.ltHash = newHash syncOpt := seidbtypes.WriteOptions{Sync: false} if err := w.batch.Commit(syncOpt); err != nil { @@ -196,10 +162,7 @@ func NewKVImporter(store *CommitStore, version int64) types.Importer { store.ctx, ndb.dir, ndb.db, - store.ltCalc, store.perDBWorkingLtHash[ndb.dir], - cloneModuleHashes(store.perDBModuleWorkingLtHash[ndb.dir]), - cloneModuleStats(store.perDBModuleWorkingStats[ndb.dir]), ) imp.workers[ndb.db] = w } @@ -350,8 +313,6 @@ func (imp *KVImporter) Close() error { for _, w := range imp.workers { imp.store.perDBWorkingLtHash[w.dir] = w.ltHash - imp.store.perDBModuleWorkingLtHash[w.dir] = w.moduleLtHash - imp.store.perDBModuleWorkingStats[w.dir] = w.moduleStats } if err = imp.store.FinalizeImport(imp.version); err != nil { diff --git a/sei-db/state_db/sc/flatkv/importer_test.go b/sei-db/state_db/sc/flatkv/importer_test.go index e45e86c7db..4a8a0d83a0 100644 --- a/sei-db/state_db/sc/flatkv/importer_test.go +++ b/sei-db/state_db/sc/flatkv/importer_test.go @@ -78,29 +78,6 @@ func TestKVImporter_CloseIdempotent_AfterError(t *testing.T) { require.Equal(t, first, third) } -// TestKVImporter_EmptyModuleNameRejected guards the state-sync import -// boundary the same way TestStoreApplyRejectsEmptyModuleName guards the -// live-commit path: a physical key with an empty module segment (e.g. a -// peer-supplied snapshot key of the form "/x") must fail routing instead of -// silently folding into moduleLtHash[""], which would later persist as the -// per-module meta key "_meta/x:/hash" and permanently brick the store on the -// very next reopen (ParseModuleLtHashKey rejects that key, so the sum-to-root -// check fails forever). -func TestKVImporter_EmptyModuleNameRejected(t *testing.T) { - s, imp := newKVImporterForTest(t, 1) - defer func() { require.NoError(t, s.Close()) }() - - imp.AddNode(&types.SnapshotNode{ - Key: []byte("/x"), - Value: []byte("v"), - Version: 1, - }) - - err := imp.Close() - require.Error(t, err) - require.Contains(t, err.Error(), "empty module name") -} - // TestKVImporter_ErrLifecycle locks in the contract that Err() returns the // first pipeline error as soon as it propagates, before Close is invoked. // This is the path the seidb tool relies on to short-circuit a failing import diff --git a/sei-db/state_db/sc/flatkv/ktype/ktype.go b/sei-db/state_db/sc/flatkv/ktype/ktype.go index 3d5e4553e9..9904cf1992 100644 --- a/sei-db/state_db/sc/flatkv/ktype/ktype.go +++ b/sei-db/state_db/sc/flatkv/ktype/ktype.go @@ -1,6 +1,6 @@ // Package ktype defines the physical key encoding used by FlatKV's data DBs. // -// Every key stored in accountDB, codeDB, storageDB, and miscDB is prefixed +// Every key stored in accountDB, codeDB, storageDB, and legacyDB is prefixed // with "moduleName/" so that keys remain unique and LtHash-stable when DBs are // merged in the future. package ktype @@ -52,11 +52,11 @@ func StorageKey(addr Address, slot Slot) []byte { // EVMKeyStorage 0x03 storageDB "evm/" + 0x03 + addr||slot // EVMKeyAccount 0x0a accountDB "evm/" + 0x0a + addr (merges nonce, codehash, balance) // EVMKeyCode 0x07 codeDB "evm/" + 0x07 + addr -// EVMKeyMisc (orig) miscDB "evm/" + original_key OR "module/" + cosmos_key +// EVMKeyLegacy (orig) legacyDB "evm/" + original_key OR "module/" + cosmos_key const EVMKeyAccount = keys.EVMKeyNonce // ModulePhysicalKey returns "moduleName/" + key. -// All four data DBs (account, code, storage, misc) use this format so keys +// All four data DBs (account, code, storage, legacy) use this format so keys // remain unique and LtHash-stable when DBs are merged in the future. func ModulePhysicalKey(moduleName string, key []byte) []byte { n := len(moduleName) diff --git a/sei-db/state_db/sc/flatkv/ktype/meta.go b/sei-db/state_db/sc/flatkv/ktype/meta.go index 2490e144d7..6ace6b7420 100644 --- a/sei-db/state_db/sc/flatkv/ktype/meta.go +++ b/sei-db/state_db/sc/flatkv/ktype/meta.go @@ -12,15 +12,6 @@ const ( metaVersion = metaKeyPrefix + "version" metaLtHash = metaKeyPrefix + "hash" metaEarliest = metaKeyPrefix + "earliest" - - // moduleLtHashPrefix brackets the per-module metadata keys stored in each - // data DB, e.g. "_meta/x:evm/hash", "_meta/x:gov/stats". The "x:" segment - // namespaces module names so they never collide with the fixed per-DB keys - // (version / hash / earliest). Each module has a "/hash" key (its per-module - // LtHash) and a "/stats" key (its per-module key-count / byte totals). - moduleLtHashPrefix = metaKeyPrefix + "x:" - moduleLtHashSuffix = "/hash" - moduleStatsSuffix = "/stats" ) var ( @@ -31,87 +22,22 @@ var ( // begins at (written once by SetInitialVersion, global metadata DB // only). Absent on genesis stores and stores predating the record. MetaEarliestVersionKey = []byte(metaEarliest) - - // ModuleLtHashPrefixBytes is the inclusive lower bound for iterating the - // per-module LtHash keys ("_meta/x:") within a data DB. - ModuleLtHashPrefixBytes = []byte(moduleLtHashPrefix) ) -// ModuleLtHashKey returns the per-DB metadata key that stores the LtHash for a -// single module within a data DB, e.g. ModuleLtHashKey("evm") == -// "_meta/x:evm/hash". account/code/storage DBs only ever hold the "evm" -// module; miscDB may hold several (evm plus cosmos modules). -func ModuleLtHashKey(module string) []byte { - return []byte(moduleLtHashPrefix + module + moduleLtHashSuffix) -} - -// ParseModuleLtHashKey extracts the module name from a per-module LtHash meta -// key. Returns ("", false) if key is not of the form "_meta/x:/hash". -// Module names never contain '/', so trimming the fixed prefix/suffix is -// unambiguous. Per-module stats keys ("_meta/x:/stats") share the -// prefix but not the suffix, so they are correctly rejected here. -func ParseModuleLtHashKey(key []byte) (string, bool) { - return parseModuleKey(key, moduleLtHashSuffix) -} - -// ModuleStatsKey returns the per-DB metadata key that stores the auxiliary -// stats (key count / byte totals) for a single module within a data DB, e.g. -// ModuleStatsKey("evm") == "_meta/x:evm/stats". -func ModuleStatsKey(module string) []byte { - return []byte(moduleLtHashPrefix + module + moduleStatsSuffix) -} - -// ParseModuleStatsKey extracts the module name from a per-module stats meta -// key. Returns ("", false) if key is not of the form "_meta/x:/stats". -func ParseModuleStatsKey(key []byte) (string, bool) { - return parseModuleKey(key, moduleStatsSuffix) -} - -// parseModuleKey trims the shared "_meta/x:" prefix and the given suffix from a -// per-module meta key, returning the module name in between. Module names never -// contain '/', so the trim is unambiguous. -func parseModuleKey(key []byte, suffix string) (string, bool) { - if !bytes.HasPrefix(key, ModuleLtHashPrefixBytes) { - return "", false - } - rest := key[len(ModuleLtHashPrefixBytes):] - if !bytes.HasSuffix(rest, []byte(suffix)) { - return "", false - } - module := rest[:len(rest)-len(suffix)] - if len(module) == 0 { - return "", false - } - return string(module), true -} - // IsMetaKey reports whether key is a per-DB internal metadata key (not user data). // // Safety: _meta/ keys are 10–13 bytes; the shortest user key is 20 bytes // (an EVM address). Prefix collision would require an address starting with // 0x5F6D657461 ("_meta") — probability ~2^-48 for random addresses and -// negligible even under CREATE2 brute-force. Misc DB keys must not use +// negligible even under CREATE2 brute-force. Legacy DB keys must not use // the _meta/ prefix. func IsMetaKey(key []byte) bool { return bytes.HasPrefix(key, MetaKeyPrefixBytes) } // LocalMeta stores per-DB version tracking metadata. -// Version is stored at _meta/version, the per-DB root LtHash at _meta/hash, -// and per-module LtHashes at _meta/x:/hash. +// Version is stored at _meta/version, LtHash at _meta/hash. type LocalMeta struct { CommittedVersion int64 // Current committed version in this DB - LtHash *lthash.LtHash // per-DB root; nil for old format (version-only) - - // ModuleLtHashes holds the LtHash of each module's keys within this DB, - // keyed by module name (e.g. "evm", "gov"). The per-DB root (LtHash) - // equals the homomorphic sum of these module hashes. nil/empty when the - // DB has never been written (fresh store). - ModuleLtHashes map[string]*lthash.LtHash - - // ModuleStats holds the auxiliary key-count / byte totals of each module's - // keys within this DB, keyed by module name and mirroring ModuleLtHashes. - // Consensus-irrelevant; per-DB / global totals are derived on demand. - // nil/empty when the DB has never been written (fresh store). - ModuleStats map[string]lthash.ModuleStats + LtHash *lthash.LtHash // nil for old format (version-only) } diff --git a/sei-db/state_db/sc/flatkv/ktype/metakey_test.go b/sei-db/state_db/sc/flatkv/ktype/metakey_test.go index 61a648c6f8..dbba88b32f 100644 --- a/sei-db/state_db/sc/flatkv/ktype/metakey_test.go +++ b/sei-db/state_db/sc/flatkv/ktype/metakey_test.go @@ -15,45 +15,3 @@ func TestIsMetaKey(t *testing.T) { require.False(t, IsMetaKey(addr[:])) require.False(t, IsMetaKey(StorageKey(Address{0x01}, Slot{0x02}))) } - -func TestModuleMetaKeyRoundTrip(t *testing.T) { - for _, module := range []string{"evm", "gov", "bank", "x"} { - hashKey := ModuleLtHashKey(module) - statsKey := ModuleStatsKey(module) - require.True(t, IsMetaKey(hashKey)) - require.True(t, IsMetaKey(statsKey)) - - gotHash, ok := ParseModuleLtHashKey(hashKey) - require.True(t, ok) - require.Equal(t, module, gotHash) - - gotStats, ok := ParseModuleStatsKey(statsKey) - require.True(t, ok) - require.Equal(t, module, gotStats) - - // The two key spaces must not alias: a hash key is never parsed as a - // stats key and vice versa, even though they share the "_meta/x:" prefix. - _, ok = ParseModuleStatsKey(hashKey) - require.False(t, ok, "hash key must not parse as stats key") - _, ok = ParseModuleLtHashKey(statsKey) - require.False(t, ok, "stats key must not parse as hash key") - } -} - -func TestParseModuleMetaKeyRejectsMalformed(t *testing.T) { - for _, key := range [][]byte{ - []byte("_meta/version"), // fixed per-DB key - []byte("_meta/x:/hash"), // empty module name - []byte("_meta/x:evm"), // missing suffix - []byte("_meta/x:evm/other"), // wrong suffix - StorageKey(Address{0x01}, Slot{}), // user data - } { - _, ok := ParseModuleLtHashKey(key) - require.False(t, ok, "hash parse should reject %q", key) - _, ok = ParseModuleStatsKey(key) - require.False(t, ok, "stats parse should reject %q", key) - } - // Empty-module stats key is also rejected. - _, ok := ParseModuleStatsKey([]byte("_meta/x:/stats")) - require.False(t, ok) -} diff --git a/sei-db/state_db/sc/flatkv/lthash/hash_calculator.go b/sei-db/state_db/sc/flatkv/lthash/hash_calculator.go deleted file mode 100644 index 667139ae8a..0000000000 --- a/sei-db/state_db/sc/flatkv/lthash/hash_calculator.go +++ /dev/null @@ -1,444 +0,0 @@ -package lthash - -import ( - "fmt" - "sync" - - "github.com/sei-protocol/sei-chain/sei-db/common/threading" -) - -const ( - // computeChunkSize is the number of KV pairs one task carries. Splitting a - // module's pairs into fixed-size chunks lets a single large module (e.g. the - // EVM storage DB in a big block) fan out across many workers instead of - // pinning one. Small enough to balance load, large enough to amortize the - // per-task scheduling overhead and result bookkeeping. - computeChunkSize = 100 - - // parallelThreshold is the minimum total pair count before the worker pool - // is engaged. Below it, the pool hand-off + merge overhead outweighs the - // parallelism, so the delta is computed inline on the caller goroutine. Kept - // a multiple of computeChunkSize (>= 2x) so that any batch which does go - // parallel splits into several chunks rather than paying the pool tax to run - // a single chunk on one worker. - parallelThreshold = 1000 -) - -// OldValueReader reads the previously-committed serialized value for a set of -// physical keys within a single logical DB (identified by dir). It is -// implemented by the caller (the FlatKV store) over its underlying DBs plus any -// pending-write overlay. Only keys that have a prior value appear in the result; -// a key mapped to a nil value means "resolved, but no bytes to unmix" (e.g. the -// key is pending a deletion earlier in the same block). -type OldValueReader interface { - ReadOldValues(dir string, physKeys map[string]struct{}) (map[string][]byte, error) -} - -// ModuleFunc extracts the owning module name from a physical key. Injected by -// the caller so the HashCalculator stays decoupled from the key-encoding package. -type ModuleFunc func(physicalKey []byte) (module string, err error) - -// DBPairs couples a data DB dir with the LtHash pairs to fold into it this -// block. -type DBPairs struct { - Dir string - Pairs []KVPairWithLastValue -} - -// Result holds the recomputed hash state after folding a block's pairs. PerDB -// and PerModule contain an entry for every DB dir the HashCalculator was -// configured with (so callers can swap them in wholesale). Global is the -// homomorphic sum of the per-DB roots. PerModuleStats holds the per-(dir, -// module) key-count / byte totals accumulated alongside the hash. -type Result struct { - PerDB map[string]*LtHash - PerModule map[string]map[string]*LtHash - PerModuleStats map[string]map[string]ModuleStats - Global *LtHash -} - -// HashCalculator encapsulates the per-block lattice-hash pipeline over an -// injected CPU-bound worker pool: -// -// 1. ReadOldValues — grab the prior value for each changed key (in parallel). -// 2. Compute — hash individual keys and combine the per-worker results into -// the final per-module hashes, then derive each per-DB root and the global -// hash from those. -// -// The pool is supplied by the caller (the FlatKV store) rather than created -// here. The HashCalculator does not own the pool and never closes it; pool -// lifecycle is the caller's responsibility. -// -// The pool distributes independent per-chunk tasks, so ComputeModuleHashInfos is -// safe to call concurrently from multiple goroutines that share one -// HashCalculator (the state-sync importer runs a goroutine per DB). The live -// commit path is additionally serialized by FlatKV's write lock. -type HashCalculator struct { - pool threading.Pool - dbDirs []string - moduleOf ModuleFunc -} - -// NewHashCalculator creates a HashCalculator that runs on the provided pool. -// dbDirs is the canonical, ordered set of data DB directories; moduleOf extracts -// a physical key's owning module. The pool is owned by the caller — closing it -// is the caller's responsibility, not the HashCalculator's. -func NewHashCalculator(pool threading.Pool, dbDirs []string, moduleOf ModuleFunc) *HashCalculator { - return &HashCalculator{ - pool: pool, - dbDirs: append([]string(nil), dbDirs...), - moduleOf: moduleOf, - } -} - -// ReadOldValues fetches prior serialized values for keysByDB (dir -> physical -// keyset), reading each DB in parallel over the pool. This is step (1) of the -// pipeline: "take the changed keys and grab the old value for each". -func (c *HashCalculator) ReadOldValues( - reader OldValueReader, - keysByDB map[string]map[string]struct{}, -) (map[string]map[string][]byte, error) { - type job struct { - dir string - keys map[string]struct{} - } - jobs := make([]job, 0, len(keysByDB)) - for dir, keySet := range keysByDB { - if len(keySet) == 0 { - continue - } - jobs = append(jobs, job{dir: dir, keys: keySet}) - } - - out := make(map[string]map[string][]byte, len(jobs)) - if len(jobs) == 0 { - return out, nil - } - - results := make([]map[string][]byte, len(jobs)) - errs := make([]error, len(jobs)) - var wg sync.WaitGroup - wg.Add(len(jobs)) - for i := range jobs { - idx := i - c.pool.Submit(func() { - defer wg.Done() - results[idx], errs[idx] = reader.ReadOldValues(jobs[idx].dir, jobs[idx].keys) - }) - } - wg.Wait() - - for i, err := range errs { - if err != nil { - return nil, fmt.Errorf("read old values for %s: %w", jobs[i].dir, err) - } - out[jobs[i].dir] = results[i] - } - return out, nil -} - -// ModuleKey identifies a single (data DB dir, module) accumulator. -type ModuleKey struct { - Dir string - Module string -} - -// Compute folds pairSets into the previous hashes and derives the full result: -// per-module hashes (via ComputeModuleHashInfos), each touched per-DB root as the -// homomorphic sum of its module hashes, and the global hash as the sum of the -// per-DB roots. -// -// The returned maps are freshly allocated (cloned from prev), so the caller can -// swap them in without aliasing. Because MixIn/MixOut are commutative and -// associative, the result is identical to a single serial fold — the global -// store hash (and consensus AppHash) is independent of worker count or chunking. -// -// Used by the live commit path, which maintains a running per-DB/per-module -// hash (and per-module stats) across blocks. -func (c *HashCalculator) Compute( - pairSets []DBPairs, - prevPerDB map[string]*LtHash, - prevPerModule map[string]map[string]*LtHash, - prevPerModuleStats map[string]map[string]ModuleStats, -) (*Result, error) { - newPerDB := make(map[string]*LtHash, len(c.dbDirs)) - newPerModule := make(map[string]map[string]*LtHash, len(c.dbDirs)) - newPerModuleStats := make(map[string]map[string]ModuleStats, len(c.dbDirs)) - for _, dir := range c.dbDirs { - if h := prevPerDB[dir]; h != nil { - newPerDB[dir] = h.Clone() - } else { - newPerDB[dir] = New() - } - newPerModule[dir] = cloneModuleMap(prevPerModule[dir]) - newPerModuleStats[dir] = cloneModuleStatsMap(prevPerModuleStats[dir]) - } - - deltas, err := c.ComputeModuleHashInfos(pairSets) - if err != nil { - return nil, err - } - - touched := make(map[string]struct{}, len(c.dbDirs)) - for key, delta := range deltas { - modBucket := newPerModule[key.Dir] - statBucket := newPerModuleStats[key.Dir] - if modBucket == nil { - // Defensive: a DB dir not in c.dbDirs still gets buckets so the - // delta is not silently dropped. - modBucket = make(map[string]*LtHash) - newPerModule[key.Dir] = modBucket - statBucket = make(map[string]ModuleStats) - newPerModuleStats[key.Dir] = statBucket - } - cur := modBucket[key.Module] - if cur == nil { - cur = New() - modBucket[key.Module] = cur - } - cur.MixIn(delta.Hash) - statBucket[key.Module] = statBucket[key.Module].Add(ModuleStats{KeyCount: delta.KeyCount, Bytes: delta.Bytes}) - touched[key.Dir] = struct{}{} - } - for dir := range touched { - newPerDB[dir] = SumModuleHashes(newPerModule[dir]) - } - - global := New() - for _, dir := range c.dbDirs { - global.MixIn(newPerDB[dir]) - } - - return &Result{ - PerDB: newPerDB, - PerModule: newPerModule, - PerModuleStats: newPerModuleStats, - Global: global, - }, nil -} - -// ModuleHashInfo is the per-(dir, module) change computed for one block/batch: -// the homomorphic hash delta plus the net key-count and byte deltas implied by -// the same MixIn/MixOut transitions. -type ModuleHashInfo struct { - Hash *LtHash - KeyCount int64 - Bytes int64 -} - -// ComputeModuleHashInfos is the shared per-module hashing primitive used by both -// the live commit path (via Compute) and the state-sync importer. It processes -// the changeset pairs identically for both: bucket each DB's pairs by module, -// split every bucket into fixed-size chunks, and distribute those chunks across -// the shared worker pool to compute the per-(dir, module) homomorphic hash delta -// and the accompanying key-count / byte deltas. -// -// Each chunk is an independent, self-terminating task, so ComputeModuleHashInfos is -// safe to call concurrently from multiple goroutines sharing one pool (the -// importer runs a goroutine per DB). It never holds a worker while waiting on -// another task, so no oversubscription or deadlock can arise from the nesting. -// -// The caller decides how to apply the deltas: Compute mixes them onto a running -// per-block hash; the importer folds them into its per-DB accumulators. -func (c *HashCalculator) ComputeModuleHashInfos(pairSets []DBPairs) (map[ModuleKey]*ModuleHashInfo, error) { - tasks, total, err := c.buildTasks(pairSets) - if err != nil { - return nil, err - } - if len(tasks) == 0 { - return nil, nil - } - if total < parallelThreshold { - return computeDeltasSerial(tasks), nil - } - return c.computeDeltasParallel(tasks), nil -} - -// lthashTask is one unit of parallel work: a chunk of pairs that all belong to -// a single (db, module) bucket. -type lthashTask struct { - key ModuleKey - pairs []KVPairWithLastValue -} - -// buildTasks buckets each DB's pairs by module and splits every bucket into -// fixed-size tasks. It also returns the total pair count so callers can pick the -// serial vs parallel path. -func (c *HashCalculator) buildTasks(pairSets []DBPairs) (tasks []lthashTask, total int, err error) { - for _, ps := range pairSets { - if len(ps.Pairs) == 0 { - continue - } - total += len(ps.Pairs) - byModule, err := BucketByModule(ps.Pairs, c.moduleOf) - if err != nil { - return nil, 0, fmt.Errorf("failed to bucket %s pairs by module: %w", ps.Dir, err) - } - for module, mpairs := range byModule { - for start := 0; start < len(mpairs); start += computeChunkSize { - end := start + computeChunkSize - if end > len(mpairs) { - end = len(mpairs) - } - tasks = append(tasks, lthashTask{ - key: ModuleKey{Dir: ps.Dir, Module: module}, - pairs: mpairs[start:end], - }) - } - } - } - return tasks, total, nil -} - -// foldChunk computes the homomorphic hash delta and the net key-count / byte -// deltas for one chunk of pairs. Key presence is defined exactly as the hash -// defines it: a prior value exists iff LastValue is non-empty (an unmix), and a -// new value exists iff the entry is not a delete and Value is non-empty (a mix). -// - add (!old, new): +1 key, + (len(key)+len(newVal)) bytes -// - update ( old, new): 0 keys, + (len(newVal)-len(oldVal)) bytes -// - delete ( old, !new): -1 key, - (len(key)+len(oldVal)) bytes -// - no-op (!old, !new): unchanged (delete of an absent key) -func foldChunk(pairs []KVPairWithLastValue) *ModuleHashInfo { - d := &ModuleHashInfo{Hash: New()} - for _, kv := range pairs { - // A member exists iff serializeKV would produce a non-nil buffer, i.e. - // key and value are both non-empty. Keeping these predicates identical - // to the mix conditions guarantees the stats track exactly the set the - // hash represents. - hadOld := len(kv.Key) > 0 && len(kv.LastValue) > 0 - hasNew := len(kv.Key) > 0 && !kv.Delete && len(kv.Value) > 0 - if hadOld { - h := hash(serializeKV(kv.Key, kv.LastValue)) - d.Hash.MixOut(h) - putLtHashToPool(h) - } - if hasNew { - h := hash(serializeKV(kv.Key, kv.Value)) - d.Hash.MixIn(h) - putLtHashToPool(h) - } - switch { - case !hadOld && hasNew: - d.KeyCount++ - d.Bytes += int64(len(kv.Key)) + int64(len(kv.Value)) - case hadOld && hasNew: - d.Bytes += int64(len(kv.Value)) - int64(len(kv.LastValue)) - case hadOld && !hasNew: - d.KeyCount-- - d.Bytes -= int64(len(kv.Key)) + int64(len(kv.LastValue)) - } - } - return d -} - -// mergeDelta folds src into dst (hash + counts). dst must be non-nil. -func mergeDelta(dst, src *ModuleHashInfo) { - dst.Hash.MixIn(src.Hash) - dst.KeyCount += src.KeyCount - dst.Bytes += src.Bytes -} - -// computeDeltasSerial folds all tasks into per-(db,module) deltas on the caller -// goroutine. Used for small blocks where pool overhead does not pay off. -func computeDeltasSerial(tasks []lthashTask) map[ModuleKey]*ModuleHashInfo { - deltas := make(map[ModuleKey]*ModuleHashInfo) - for _, task := range tasks { - d := foldChunk(task.pairs) - if acc := deltas[task.key]; acc != nil { - mergeDelta(acc, d) - } else { - deltas[task.key] = d - } - } - return deltas -} - -// computeDeltasParallel distributes tasks across the fixed pool as independent, -// self-terminating units — one fold per chunk — then merges results as they -// arrive. A buffered result channel (capacity = task count) ensures workers -// never block on send, so a full pool queue only backpressures the submitter -// while already-running chunks drain. This is safe when several goroutines -// share one pool (the importer's per-DB workers all call through here). -// MixIn/addition are commutative, so merge order does not matter. -func (c *HashCalculator) computeDeltasParallel(tasks []lthashTask) map[ModuleKey]*ModuleHashInfo { - type result struct { - key ModuleKey - info *ModuleHashInfo - } - // Buffer must be large enough for every task: we submit all work before - // draining results, and Submit can block when the pool queue is full. If a - // finished worker then blocked on an unbuffered send here, nothing would - // free a queue slot and we'd deadlock. MixIn/addition are commutative, so - // merge order does not matter. - results := make(chan result, len(tasks)) - for i := range tasks { - task := tasks[i] - c.pool.Submit(func() { - results <- result{key: task.key, info: foldChunk(task.pairs)} - }) - } - - merged := make(map[ModuleKey]*ModuleHashInfo) - for range tasks { - r := <-results - if acc := merged[r.key]; acc != nil { - mergeDelta(acc, r.info) - } else { - merged[r.key] = r.info - } - } - return merged -} - -// BucketByModule groups LtHash pairs by their owning module, derived from each -// physical key via moduleOf. Used to decompose a per-DB root into additive -// per-module hashes without changing the root. -func BucketByModule( - pairs []KVPairWithLastValue, - moduleOf ModuleFunc, -) (map[string][]KVPairWithLastValue, error) { - byModule := make(map[string][]KVPairWithLastValue) - for _, pair := range pairs { - module, err := moduleOf(pair.Key) - if err != nil { - return nil, err - } - byModule[module] = append(byModule[module], pair) - } - return byModule, nil -} - -// SumModuleHashes returns the homomorphic sum of a DB's per-module hashes, i.e. -// its derived per-DB root. A nil/empty map yields the identity hash. -func SumModuleHashes(moduleHashes map[string]*LtHash) *LtHash { - root := New() - for _, h := range moduleHashes { - if h != nil { - root.MixIn(h) - } - } - return root -} - -// cloneModuleMap deep-copies a per-module hash map (cloning each LtHash). A -// nil/empty source yields a fresh empty map. -func cloneModuleMap(src map[string]*LtHash) map[string]*LtHash { - dst := make(map[string]*LtHash, len(src)) - for module, h := range src { - if h != nil { - dst[module] = h.Clone() - } - } - return dst -} - -// cloneModuleStatsMap copies a per-module stats map. ModuleStats is a value -// type, so a shallow per-entry copy is a full copy. A nil/empty source yields a -// fresh empty map. -func cloneModuleStatsMap(src map[string]ModuleStats) map[string]ModuleStats { - dst := make(map[string]ModuleStats, len(src)) - for module, s := range src { - dst[module] = s - } - return dst -} diff --git a/sei-db/state_db/sc/flatkv/lthash/stats.go b/sei-db/state_db/sc/flatkv/lthash/stats.go deleted file mode 100644 index 0aef2956b6..0000000000 --- a/sei-db/state_db/sc/flatkv/lthash/stats.go +++ /dev/null @@ -1,50 +0,0 @@ -package lthash - -import ( - "encoding/binary" - "fmt" -) - -// moduleStatsEncodedLen is the fixed on-disk size of a marshaled ModuleStats: -// two big-endian int64s (KeyCount || Bytes). -const moduleStatsEncodedLen = 16 - -// ModuleStats is auxiliary per-(DB, module) metadata accumulated alongside the -// lattice hash: the number of live keys and their total serialized footprint -// (physical key bytes + serialized value bytes) for that module within a DB. -// -// Both are net running totals maintained with the same key-membership rule the -// lattice hash uses (see foldChunk): an add increments KeyCount and adds -// key+value bytes; an update leaves KeyCount unchanged and adjusts Bytes by the -// value-size delta; a delete decrements KeyCount and subtracts the old -// key+value bytes. They are consensus-irrelevant (not folded into the AppHash) -// but are persisted and crash-recovered the same way as the per-module hash. -type ModuleStats struct { - KeyCount int64 - Bytes int64 -} - -// Add returns the sum of two stats. Used to fold a per-block delta into a -// running total (or to aggregate per-module stats into a per-DB total). -func (s ModuleStats) Add(d ModuleStats) ModuleStats { - return ModuleStats{KeyCount: s.KeyCount + d.KeyCount, Bytes: s.Bytes + d.Bytes} -} - -// Marshal encodes stats as 16 big-endian bytes (KeyCount || Bytes). -func (s ModuleStats) Marshal() []byte { - b := make([]byte, moduleStatsEncodedLen) - binary.BigEndian.PutUint64(b[0:8], uint64(s.KeyCount)) //nolint:gosec // two's-complement round-trip; decoded back to int64 by UnmarshalModuleStats - binary.BigEndian.PutUint64(b[8:16], uint64(s.Bytes)) //nolint:gosec // two's-complement round-trip; decoded back to int64 by UnmarshalModuleStats - return b -} - -// UnmarshalModuleStats decodes bytes produced by ModuleStats.Marshal. -func UnmarshalModuleStats(b []byte) (ModuleStats, error) { - if len(b) != moduleStatsEncodedLen { - return ModuleStats{}, fmt.Errorf("lthash: invalid module stats length: got %d, want %d", len(b), moduleStatsEncodedLen) - } - return ModuleStats{ - KeyCount: int64(binary.BigEndian.Uint64(b[0:8])), //nolint:gosec // round-trips the value written by Marshal - Bytes: int64(binary.BigEndian.Uint64(b[8:16])), //nolint:gosec // round-trips the value written by Marshal - }, nil -} diff --git a/sei-db/state_db/sc/flatkv/lthash/stats_test.go b/sei-db/state_db/sc/flatkv/lthash/stats_test.go deleted file mode 100644 index 42c9569aa8..0000000000 --- a/sei-db/state_db/sc/flatkv/lthash/stats_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package lthash - -import ( - "fmt" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/sei-protocol/sei-chain/sei-db/common/threading" -) - -func TestModuleStatsMarshalRoundTrip(t *testing.T) { - cases := []ModuleStats{ - {}, - {KeyCount: 1, Bytes: 42}, - {KeyCount: 1 << 40, Bytes: 1 << 50}, - } - for _, want := range cases { - b := want.Marshal() - require.Len(t, b, moduleStatsEncodedLen) - got, err := UnmarshalModuleStats(b) - require.NoError(t, err) - require.Equal(t, want, got) - } -} - -func TestUnmarshalModuleStatsBadLength(t *testing.T) { - for _, n := range []int{0, 8, 15, 17, 32} { - _, err := UnmarshalModuleStats(make([]byte, n)) - require.Error(t, err, "length %d should be rejected", n) - } -} - -func TestModuleStatsAdd(t *testing.T) { - a := ModuleStats{KeyCount: 3, Bytes: 100} - b := ModuleStats{KeyCount: -1, Bytes: -30} - require.Equal(t, ModuleStats{KeyCount: 2, Bytes: 70}, a.Add(b)) - // Add must not mutate the receiver. - require.Equal(t, ModuleStats{KeyCount: 3, Bytes: 100}, a) -} - -// TestFoldChunkStats locks down the per-key accounting rule: add increments the -// count and adds key+value bytes, update keeps the count and adjusts by the -// value-size delta, and delete decrements the count and subtracts key+old-value -// bytes. Delete-of-absent is a no-op. -func TestFoldChunkStats(t *testing.T) { - key := []byte("some/physical/key") - - tests := []struct { - name string - pair KVPairWithLastValue - wantKeys int64 - wantByte int64 - }{ - { - name: "add", - pair: KVPairWithLastValue{Key: key, Value: []byte("newvalue")}, - wantKeys: 1, - wantByte: int64(len(key)) + int64(len("newvalue")), - }, - { - name: "update grows", - pair: KVPairWithLastValue{Key: key, Value: []byte("longer-value"), LastValue: []byte("short")}, - wantKeys: 0, - wantByte: int64(len("longer-value")) - int64(len("short")), - }, - { - name: "update shrinks", - pair: KVPairWithLastValue{Key: key, Value: []byte("v"), LastValue: []byte("wasbigger")}, - wantKeys: 0, - wantByte: int64(len("v")) - int64(len("wasbigger")), - }, - { - name: "delete", - pair: KVPairWithLastValue{Key: key, LastValue: []byte("oldvalue"), Delete: true}, - wantKeys: -1, - wantByte: -(int64(len(key)) + int64(len("oldvalue"))), - }, - { - name: "delete absent is no-op", - pair: KVPairWithLastValue{Key: key, Delete: true}, - wantKeys: 0, - wantByte: 0, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - d := foldChunk([]KVPairWithLastValue{tc.pair}) - require.Equal(t, tc.wantKeys, d.KeyCount) - require.Equal(t, tc.wantByte, d.Bytes) - }) - } -} - -// TestComputeModuleHashInfosStatsParallel exercises the pooled path (> chunk size) -// and checks the aggregated per-module stats equal a straightforward serial -// tally, proving the chunk-and-merge does not lose or double-count. -func TestComputeModuleHashInfosStatsParallel(t *testing.T) { - const dir = "d" - moduleOf := func([]byte) (string, error) { return "m", nil } - pool := threading.NewFixedPool("test", 4, 4) - defer pool.Close() - c := NewHashCalculator(pool, []string{dir}, moduleOf) - - const n = computeChunkSize*3 + 7 // spans several chunks, not a chunk multiple - pairs := make([]KVPairWithLastValue, n) - var wantKeys, wantBytes int64 - for i := range pairs { - key := []byte(fmt.Sprintf("m/key-%05d", i)) - val := []byte(fmt.Sprintf("value-%d", i)) - pairs[i] = KVPairWithLastValue{Key: key, Value: val} - wantKeys++ - wantBytes += int64(len(key)) + int64(len(val)) - } - - deltas, err := c.ComputeModuleHashInfos([]DBPairs{{Dir: dir, Pairs: pairs}}) - require.NoError(t, err) - require.Len(t, deltas, 1) - - d := deltas[ModuleKey{Dir: dir, Module: "m"}] - require.NotNil(t, d) - require.Equal(t, wantKeys, d.KeyCount) - require.Equal(t, wantBytes, d.Bytes) -} diff --git a/sei-db/state_db/sc/flatkv/lthash_correctness_test.go b/sei-db/state_db/sc/flatkv/lthash_correctness_test.go index 63144c35d8..edc045026f 100644 --- a/sei-db/state_db/sc/flatkv/lthash_correctness_test.go +++ b/sei-db/state_db/sc/flatkv/lthash_correctness_test.go @@ -6,9 +6,6 @@ import ( "errors" "fmt" - "path/filepath" - "testing" - errorutils "github.com/sei-protocol/sei-chain/sei-db/common/errors" "github.com/sei-protocol/sei-chain/sei-db/common/keys" "github.com/sei-protocol/sei-chain/sei-db/db_engine/types" @@ -19,6 +16,8 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/vtype" scTypes "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" "github.com/stretchr/testify/require" + "path/filepath" + "testing" ) // fullScanLtHash computes an LtHash from scratch by iterating every KV pair @@ -594,23 +593,23 @@ func TestLtHashPersistenceAfterReopen(t *testing.T) { } // ============================================================================= -// fullScanLtHash Includes miscDB (W-P0-11) +// fullScanLtHash Includes legacyDB (W-P0-11) // ============================================================================= -func TestFullScanLtHashIncludesMisc(t *testing.T) { +func TestFullScanLtHashIncludesLegacy(t *testing.T) { s := setupTestStore(t) defer s.Close() addr := ktype.Address{0xAA} - miscKey := append([]byte{0x09}, addr[:]...) + legacyKey := append([]byte{0x09}, addr[:]...) - cs := makeChangeSet(miscKey, []byte{0x42}, false) + cs := makeChangeSet(legacyKey, []byte{0x42}, false) require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{cs})) commitAndCheck(t, s) groundTruth := fullScanLtHash(t, s) require.Equal(t, s.workingLtHash.Checksum(), groundTruth.Checksum(), - "full scan including miscDB should match incremental LtHash") + "full scan including legacyDB should match incremental LtHash") } // ============================================================================= @@ -722,34 +721,34 @@ func TestLtHashCrossApplyCodeOverwrite(t *testing.T) { require.Equal(t, []byte{0x60, 0x40, 0x02, 0x03}, val) } -// TestLtHashCrossApplyMiscOverwrite verifies that overwriting the same -// misc key across two ApplyChangeSets calls in the same block produces +// TestLtHashCrossApplyLegacyOverwrite verifies that overwriting the same +// legacy key across two ApplyChangeSets calls in the same block produces // a correct LtHash (full-scan verified). -func TestLtHashCrossApplyMiscOverwrite(t *testing.T) { +func TestLtHashCrossApplyLegacyOverwrite(t *testing.T) { s := setupTestStore(t) defer s.Close() addr := addrN(0x53) - miscKey := append([]byte{0x09}, addr[:]...) + legacyKey := append([]byte{0x09}, addr[:]...) - // Block 1: create misc entry - cs0 := makeChangeSet(miscKey, []byte{0x00, 0x10}, false) + // Block 1: create legacy entry + cs0 := makeChangeSet(legacyKey, []byte{0x00, 0x10}, false) require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{cs0})) commitAndCheck(t, s) verifyLtHashAtHeight(t, s, 1) - // Block 2: overwrite same misc key in two separate ApplyChangeSets calls - cs1 := makeChangeSet(miscKey, []byte{0x00, 0x20}, false) + // Block 2: overwrite same legacy key in two separate ApplyChangeSets calls + cs1 := makeChangeSet(legacyKey, []byte{0x00, 0x20}, false) require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{cs1})) - cs2 := makeChangeSet(miscKey, []byte{0x00, 0x30}, false) + cs2 := makeChangeSet(legacyKey, []byte{0x00, 0x30}, false) require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{cs2})) commitAndCheck(t, s) verifyLtHashAtHeight(t, s, 2) // Verify final value - val, found := s.Get(keys.EVMStoreKey, miscKey) + val, found := s.Get(keys.EVMStoreKey, legacyKey) require.True(t, found) require.Equal(t, []byte{0x00, 0x30}, val) } @@ -763,7 +762,7 @@ func TestLtHashCrossApplyMixedOverwrite(t *testing.T) { addr := addrN(0x54) slot := slotN(0x01) - miscKey := append([]byte{0x09}, addr[:]...) + legacyKey := append([]byte{0x09}, addr[:]...) // Block 1: create initial state for all key types require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{ @@ -775,7 +774,7 @@ func TestLtHashCrossApplyMixedOverwrite(t *testing.T) { ), })) require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{ - makeChangeSet(miscKey, []byte{0x00, 0x01}, false), + makeChangeSet(legacyKey, []byte{0x00, 0x01}, false), })) commitAndCheck(t, s) verifyLtHashAtHeight(t, s, 1) @@ -789,7 +788,7 @@ func TestLtHashCrossApplyMixedOverwrite(t *testing.T) { ) require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{cs1a})) require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{ - makeChangeSet(miscKey, []byte{0x00, 0x02}, false), + makeChangeSet(legacyKey, []byte{0x00, 0x02}, false), })) // Block 2: second Apply — overwrite all key types again @@ -801,7 +800,7 @@ func TestLtHashCrossApplyMixedOverwrite(t *testing.T) { ) require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{cs2a})) require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{ - makeChangeSet(miscKey, []byte{0x00, 0x03}, false), + makeChangeSet(legacyKey, []byte{0x00, 0x03}, false), })) commitAndCheck(t, s) @@ -829,9 +828,9 @@ func TestLtHashCrossApplyMixedOverwrite(t *testing.T) { require.True(t, found) require.Equal(t, padLeft32(0x33), storageVal) - miscVal, found := s.Get(keys.EVMStoreKey, miscKey) + legacyVal, found := s.Get(keys.EVMStoreKey, legacyKey) require.True(t, found) - require.Equal(t, []byte{0x00, 0x03}, miscVal) + require.Equal(t, []byte{0x00, 0x03}, legacyVal) } // ---------- Account Row GC LtHash tests ---------- @@ -1203,7 +1202,7 @@ func TestLtHashExportImportRoundTrip(t *testing.T) { // height. The importer commits everything at a single version, so block // heights must match for the LtHash round-trip to be identical. var evmPairs []*proto.KVPair - var miscCS []*proto.NamedChangeSet + var legacyCS []*proto.NamedChangeSet for i := byte(1); i <= 5; i++ { addr := addrN(i) evmPairs = append(evmPairs, @@ -1212,10 +1211,10 @@ func TestLtHashExportImportRoundTrip(t *testing.T) { codePair(addr, []byte{0x60, 0x80, i}), storagePair(addr, slotN(i), []byte{i, 0xBB}), ) - miscKey := append([]byte{0x09}, addr[:]...) - miscCS = append(miscCS, makeChangeSet(miscKey, []byte{i, 0xCC}, false)) + legacyKey := append([]byte{0x09}, addr[:]...) + legacyCS = append(legacyCS, makeChangeSet(legacyKey, []byte{i, 0xCC}, false)) } - allCS := append([]*proto.NamedChangeSet{namedCS(evmPairs...)}, miscCS...) + allCS := append([]*proto.NamedChangeSet{namedCS(evmPairs...)}, legacyCS...) require.NoError(t, s.ApplyChangeSets(allCS)) commitAndCheck(t, s) @@ -1356,7 +1355,7 @@ func TestLtHashDeterministicFreshStores(t *testing.T) { applyWorkload := func(s *CommitStore) { for i := byte(1); i <= 10; i++ { addr := addrN(i) - miscKey := append([]byte{0x09}, addr[:]...) + legacyKey := append([]byte{0x09}, addr[:]...) require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{ namedCS( noncePair(addr, uint64(i)*7), @@ -1364,7 +1363,7 @@ func TestLtHashDeterministicFreshStores(t *testing.T) { codePair(addr, []byte{0x60, i}), storagePair(addr, slotN(i), []byte{i, 0xDD}), ), - makeChangeSet(miscKey, []byte{i}, false), + makeChangeSet(legacyKey, []byte{i}, false), })) commitAndCheck(t, s) } diff --git a/sei-db/state_db/sc/flatkv/perdb_lthash_test.go b/sei-db/state_db/sc/flatkv/perdb_lthash_test.go index 81de759bea..6abc768019 100644 --- a/sei-db/state_db/sc/flatkv/perdb_lthash_test.go +++ b/sei-db/state_db/sc/flatkv/perdb_lthash_test.go @@ -72,7 +72,7 @@ func commitMixedState(t *testing.T, s *CommitStore, round byte) { t.Helper() addr := addrN(round) slot := slotN(round) - miscKey := append([]byte{0x09}, addr[:]...) + legacyKey := append([]byte{0x09}, addr[:]...) cs1 := namedCS( noncePair(addr, uint64(round)), @@ -80,7 +80,7 @@ func commitMixedState(t *testing.T, s *CommitStore, round byte) { codePair(addr, []byte{0x60, 0x80, round}), storagePair(addr, slot, []byte{round, 0xAA}), ) - cs2 := makeChangeSet(miscKey, []byte{round, 0xBB}, false) + cs2 := makeChangeSet(legacyKey, []byte{round, 0xBB}, false) require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{cs1, cs2})) _, err := s.Commit() require.NoError(t, err) @@ -187,13 +187,13 @@ func TestPerDBLtHashIncrementalEqualsFullScan(t *testing.T) { for i := 1; i <= 10; i++ { addr := addrN(byte(i)) slot := slotN(byte(i)) - miscKey := append([]byte{0x09}, addr[:]...) + legacyKey := append([]byte{0x09}, addr[:]...) cs1 := namedCS( noncePair(addr, uint64(i)), storagePair(addr, slot, []byte{byte(i), 0xAA}), ) - cs2 := makeChangeSet(miscKey, []byte{byte(i)}, false) + cs2 := makeChangeSet(legacyKey, []byte{byte(i)}, false) require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{cs1, cs2})) commitAndCheck(t, s) } @@ -240,7 +240,7 @@ func TestPerDBLtHashSumEqualsGlobal(t *testing.T) { } sumHash := lthash.New() - for _, dbDir := range []string{accountDBDir, codeDBDir, storageDBDir, miscDBDir} { + for _, dbDir := range []string{accountDBDir, codeDBDir, storageDBDir, legacyDBDir} { sumHash.MixIn(s.perDBWorkingLtHash[dbDir]) } @@ -407,7 +407,7 @@ func TestPerDBLtHashPersistedInLocalMeta(t *testing.T) { accountDBDir: s.accountDB, codeDBDir: s.codeDB, storageDBDir: s.storageDB, - miscDBDir: s.miscDB, + legacyDBDir: s.legacyDB, } for _, dbDirName := range dataDBDirs { db := dbInstances[dbDirName] @@ -475,8 +475,8 @@ func TestPerDBLtHashPartialKeyTypeOperations(t *testing.T) { "accountDB hash should remain zero") require.Equal(t, zeroChecksum, s.perDBWorkingLtHash[codeDBDir].Checksum(), "codeDB hash should remain zero") - require.Equal(t, zeroChecksum, s.perDBWorkingLtHash[miscDBDir].Checksum(), - "miscDB hash should remain zero") + require.Equal(t, zeroChecksum, s.perDBWorkingLtHash[legacyDBDir].Checksum(), + "legacyDB hash should remain zero") } func TestPerDBLtHashDeleteLastKeyZerosHash(t *testing.T) { diff --git a/sei-db/state_db/sc/flatkv/permodule_lthash_test.go b/sei-db/state_db/sc/flatkv/permodule_lthash_test.go deleted file mode 100644 index a57e23ac2f..0000000000 --- a/sei-db/state_db/sc/flatkv/permodule_lthash_test.go +++ /dev/null @@ -1,354 +0,0 @@ -package flatkv - -import ( - "bytes" - "path/filepath" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/sei-protocol/sei-chain/sei-db/common/keys" - "github.com/sei-protocol/sei-chain/sei-db/db_engine/types" - "github.com/sei-protocol/sei-chain/sei-db/proto" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/config" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/ktype" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/vtype" - scTypes "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" -) - -// moduleCS builds a changeset for an arbitrary (non-EVM) cosmos module. Its -// keys are routed to miscDB under a "/" physical prefix. -func moduleCS(module string, pairs ...*proto.KVPair) *proto.NamedChangeSet { - return &proto.NamedChangeSet{ - Name: module, - Changeset: proto.ChangeSet{Pairs: pairs}, - } -} - -// fullScanModuleLtHash computes, per module, the LtHash of every non-meta key -// in db by bucketing keys on their "/" physical prefix. Test-only. -func fullScanModuleLtHash(t *testing.T, db types.KeyValueDB) map[string]*lthash.LtHash { - t.Helper() - iter, err := db.NewIter(&types.IterOptions{}) - require.NoError(t, err) - defer iter.Close() - - byModule := make(map[string][]lthash.KVPairWithLastValue) - for ; iter.Valid(); iter.Next() { - if ktype.IsMetaKey(iter.Key()) { - continue - } - module, _, err := ktype.StripModulePrefix(iter.Key()) - require.NoError(t, err) - byModule[module] = append(byModule[module], lthash.KVPairWithLastValue{ - Key: bytes.Clone(iter.Key()), - Value: bytes.Clone(iter.Value()), - }) - } - require.NoError(t, iter.Error()) - - out := make(map[string]*lthash.LtHash, len(byModule)) - for module, pairs := range byModule { - h, _ := lthash.ComputeLtHash(nil, pairs) - if h == nil { - h = lthash.New() - } - out[module] = h - } - return out -} - -// verifyModuleLtHash checks that the in-memory per-module working hashes match -// a full scan of each data DB, and that the per-module hashes homomorphically -// sum to the per-DB root. -func verifyModuleLtHash(t *testing.T, s *CommitStore) { - t.Helper() - for _, ndb := range s.namedDataDBs() { - scanned := fullScanModuleLtHash(t, ndb.db) - working := s.perDBModuleWorkingLtHash[ndb.dir] - - // Every scanned module must have a matching working hash. - for module, scanHash := range scanned { - wh := working[module] - require.NotNil(t, wh, "missing working per-module hash for %s/%s", ndb.dir, module) - require.True(t, wh.Equal(scanHash), - "per-module LtHash mismatch for %s/%s:\n working: %x\n fullscan: %x", - ndb.dir, module, wh.Checksum(), scanHash.Checksum()) - } - - // The sum of the (non-zero) working per-module hashes must equal the - // per-DB root hash. - sum := lthash.New() - for _, wh := range working { - sum.MixIn(wh) - } - require.True(t, s.perDBWorkingLtHash[ndb.dir].Equal(sum), - "sum of per-module hashes should equal per-DB root for %s:\n root: %x\n sum: %x", - ndb.dir, s.perDBWorkingLtHash[ndb.dir].Checksum(), sum.Checksum()) - } -} - -// commitMultiModuleState commits a block touching EVM (account/code/storage + -// an EVM misc key) plus two non-EVM cosmos modules ("gov", "bank"), all in one -// block, so miscDB ends up holding several modules. -func commitMultiModuleState(t *testing.T, s *CommitStore, round byte) { - t.Helper() - addr := addrN(round) - slot := slotN(round) - evmMiscKey := append([]byte{0x09}, addr[:]...) - - evmCS := namedCS( - noncePair(addr, uint64(round)), - codeHashPair(addr, codeHashN(round)), - codePair(addr, []byte{0x60, 0x80, round}), - storagePair(addr, slot, []byte{round, 0xAA}), - ) - evmMiscCS := makeChangeSet(evmMiscKey, []byte{round, 0xBB}, false) - govCS := moduleCS("gov", - &proto.KVPair{Key: []byte{0x01, round}, Value: []byte{round, 0xC1}}, - &proto.KVPair{Key: []byte{0x02, round}, Value: []byte{round, 0xC2}}, - ) - bankCS := moduleCS("bank", - &proto.KVPair{Key: []byte{0x01, round}, Value: []byte{round, 0xD1}}, - ) - require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{evmCS, evmMiscCS, govCS, bankCS})) - _, err := s.Commit() - require.NoError(t, err) -} - -// Test: per-module hashes are computed incrementally and match a full scan, -// and homomorphically sum to each per-DB root. -func TestPerModuleLtHashIncrementalEqualsFullScan(t *testing.T) { - s := setupTestStore(t) - defer s.Close() - - for i := byte(1); i <= 5; i++ { - commitMultiModuleState(t, s, i) - } - verifyModuleLtHash(t, s) - - // miscDB should now carry three modules: evm, gov, bank. - misc := s.perDBModuleWorkingLtHash[miscDBDir] - require.Contains(t, misc, keys.EVMStoreKey) - require.Contains(t, misc, "gov") - require.Contains(t, misc, "bank") - - // account/code/storage only ever carry the evm module, and that module's - // hash equals the per-DB root. - for _, dir := range []string{accountDBDir, codeDBDir, storageDBDir} { - mod := s.perDBModuleWorkingLtHash[dir] - require.Len(t, mod, 1, "%s should only track the evm module", dir) - require.Contains(t, mod, keys.EVMStoreKey) - require.True(t, mod[keys.EVMStoreKey].Equal(s.perDBWorkingLtHash[dir]), - "%s evm module hash should equal per-DB root", dir) - } -} - -// Test: per-module hashes are persisted in each DB's LocalMeta and reload -// correctly after reopen. -func TestPerModuleLtHashPersistenceAfterReopen(t *testing.T) { - dir := t.TempDir() - dbDir := filepath.Join(dir, flatkvRootDir) - - cfg := config.DefaultTestConfig(t) - cfg.DataDir = dbDir - - s1, err := NewCommitStore(t.Context(), cfg) - require.NoError(t, err) - _, err = s1.LoadVersion(0, false) - require.NoError(t, err) - - for i := byte(1); i <= 5; i++ { - commitMultiModuleState(t, s1, i) - } - verifyModuleLtHash(t, s1) - - expected := make(map[string]map[string][32]byte) - for dir, mods := range s1.perDBModuleWorkingLtHash { - expected[dir] = make(map[string][32]byte) - for module, h := range mods { - expected[dir][module] = h.Checksum() - } - } - require.NoError(t, s1.Close()) - - cfg2 := config.DefaultTestConfig(t) - cfg2.DataDir = dbDir - s2, err := NewCommitStore(t.Context(), cfg2) - require.NoError(t, err) - _, err = s2.LoadVersion(0, false) - require.NoError(t, err) - defer s2.Close() - - require.Equal(t, int64(5), s2.Version()) - verifyModuleLtHash(t, s2) - - // Working per-module hashes rehydrated from disk must match pre-close. - for dir, mods := range expected { - for module, cs := range mods { - got := s2.perDBModuleWorkingLtHash[dir][module] - require.NotNil(t, got, "module %s/%s missing after reopen", dir, module) - require.Equal(t, cs, got.Checksum(), - "per-module hash mismatch after reopen for %s/%s", dir, module) - } - } - - // LocalMeta persisted the same set on disk. - dbInstances := map[string]types.KeyValueDB{ - accountDBDir: s2.accountDB, - codeDBDir: s2.codeDB, - storageDBDir: s2.storageDB, - miscDBDir: s2.miscDB, - } - for dir, db := range dbInstances { - meta, err := loadLocalMeta(db) - require.NoError(t, err) - require.Equal(t, len(expected[dir]), len(meta.ModuleLtHashes), - "module hash count mismatch on disk for %s", dir) - for module, cs := range expected[dir] { - h := meta.ModuleLtHashes[module] - require.NotNil(t, h, "module %s/%s not persisted", dir, module) - require.Equal(t, cs, h.Checksum(), "persisted module hash mismatch for %s/%s", dir, module) - } - } -} - -// Test: deleting every key of a module returns that module's hash to zero -// (and drops out of the full scan while the working map keeps a zero hash). -func TestPerModuleLtHashDeleteModuleZerosHash(t *testing.T) { - s := setupTestStore(t) - defer s.Close() - - govKey := []byte{0x01, 0x01} - set := moduleCS("gov", &proto.KVPair{Key: govKey, Value: []byte{0xAB}}) - require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{set})) - commitAndCheck(t, s) - - zero := lthash.New().Checksum() - require.NotEqual(t, zero, s.perDBModuleWorkingLtHash[miscDBDir]["gov"].Checksum(), - "gov module hash should be non-zero after write") - - del := moduleCS("gov", &proto.KVPair{Key: govKey, Delete: true}) - require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{del})) - commitAndCheck(t, s) - - require.Equal(t, zero, s.perDBModuleWorkingLtHash[miscDBDir]["gov"].Checksum(), - "gov module hash should be zero after deleting all its keys") - verifyModuleLtHash(t, s) -} - -// Test: per-module hashes are populated by the Importer path for a fresh store. -func TestPerModuleLtHashAfterImport(t *testing.T) { - dir := t.TempDir() - dbDir := filepath.Join(dir, flatkvRootDir) - - cfg := config.DefaultTestConfig(t) - cfg.DataDir = dbDir - - s, err := NewCommitStore(t.Context(), cfg) - require.NoError(t, err) - _, err = s.LoadVersion(0, false) - require.NoError(t, err) - - imp, err := s.Importer(1) - require.NoError(t, err) - - for i := byte(1); i <= 5; i++ { - addr := addrN(i) - slot := slotN(i) - storVal := vtype.NewStorageData().SetBlockHeight(1).SetValue(&[32]byte{i, 0xAA}).Serialize() - acctVal := vtype.NewAccountData().SetBlockHeight(1).SetNonce(uint64(i)).Serialize() - imp.AddNode(&scTypes.SnapshotNode{Key: storagePhysKey(addr, slot), Value: storVal, Version: 1}) - imp.AddNode(&scTypes.SnapshotNode{Key: accountPhysKey(addr), Value: acctVal, Version: 1}) - - // A non-EVM module row goes to miscDB under "gov/". - govVal := vtype.NewMiscData().SetBlockHeight(1).SetValue([]byte{i, 0xC0}).Serialize() - imp.AddNode(&scTypes.SnapshotNode{Key: ktype.ModulePhysicalKey("gov", []byte{i}), Value: govVal, Version: 1}) - } - require.NoError(t, imp.Close()) - - verifyModuleLtHash(t, s) - - require.Contains(t, s.perDBModuleWorkingLtHash[miscDBDir], "gov") - require.Contains(t, s.perDBModuleWorkingLtHash[accountDBDir], keys.EVMStoreKey) - require.Contains(t, s.perDBModuleWorkingLtHash[storageDBDir], keys.EVMStoreKey) - require.NoError(t, s.Close()) -} - -// Test: per-module hashes written by a state-sync import survive a process -// restart. This is the durability guarantee for the state-sync path: the -// importer's FinalizeImport persists per-module hashes into each DB's -// LocalMeta and the post-import checkpoint snapshot carries them across -// reopen, so a restarted node rehydrates the same per-module metadata. -func TestPerModuleLtHashStateSyncImportSurvivesRestart(t *testing.T) { - dir := t.TempDir() - dbDir := filepath.Join(dir, flatkvRootDir) - - cfg := config.DefaultTestConfig(t) - cfg.DataDir = dbDir - - s1, err := NewCommitStore(t.Context(), cfg) - require.NoError(t, err) - _, err = s1.LoadVersion(0, false) - require.NoError(t, err) - - imp, err := s1.Importer(7) - require.NoError(t, err) - - for i := byte(1); i <= 5; i++ { - addr := addrN(i) - slot := slotN(i) - storVal := vtype.NewStorageData().SetBlockHeight(7).SetValue(&[32]byte{i, 0xAA}).Serialize() - acctVal := vtype.NewAccountData().SetBlockHeight(7).SetNonce(uint64(i)).Serialize() - imp.AddNode(&scTypes.SnapshotNode{Key: storagePhysKey(addr, slot), Value: storVal, Version: 7}) - imp.AddNode(&scTypes.SnapshotNode{Key: accountPhysKey(addr), Value: acctVal, Version: 7}) - - govVal := vtype.NewMiscData().SetBlockHeight(7).SetValue([]byte{i, 0xC0}).Serialize() - bankVal := vtype.NewMiscData().SetBlockHeight(7).SetValue([]byte{i, 0xD0}).Serialize() - imp.AddNode(&scTypes.SnapshotNode{Key: ktype.ModulePhysicalKey("gov", []byte{i}), Value: govVal, Version: 7}) - imp.AddNode(&scTypes.SnapshotNode{Key: ktype.ModulePhysicalKey("bank", []byte{i}), Value: bankVal, Version: 7}) - } - require.NoError(t, imp.Close()) - verifyModuleLtHash(t, s1) - - expected := make(map[string]map[string][32]byte) - for dir, mods := range s1.perDBModuleWorkingLtHash { - expected[dir] = make(map[string][32]byte) - for module, h := range mods { - expected[dir][module] = h.Checksum() - } - } - require.NoError(t, s1.Close()) - - // Reopen as a fresh store: state must rehydrate purely from the on-disk - // snapshot written by the import. - cfg2 := config.DefaultTestConfig(t) - cfg2.DataDir = dbDir - s2, err := NewCommitStore(t.Context(), cfg2) - require.NoError(t, err) - _, err = s2.LoadVersion(0, false) - require.NoError(t, err) - defer s2.Close() - - require.Equal(t, int64(7), s2.Version()) - verifyModuleLtHash(t, s2) - - for dir, mods := range expected { - require.Equal(t, len(mods), len(s2.perDBModuleWorkingLtHash[dir]), - "module count mismatch after restart for %s", dir) - for module, cs := range mods { - got := s2.perDBModuleWorkingLtHash[dir][module] - require.NotNil(t, got, "module %s/%s missing after restart", dir, module) - require.Equal(t, cs, got.Checksum(), - "per-module hash mismatch after restart for %s/%s", dir, module) - } - } - - // miscDB must have persisted both cosmos modules across the restart. - require.Contains(t, s2.perDBModuleWorkingLtHash[miscDBDir], "gov") - require.Contains(t, s2.perDBModuleWorkingLtHash[miscDBDir], "bank") - // account/storage only ever carry the evm module. - require.Contains(t, s2.perDBModuleWorkingLtHash[accountDBDir], keys.EVMStoreKey) - require.Contains(t, s2.perDBModuleWorkingLtHash[storageDBDir], keys.EVMStoreKey) -} diff --git a/sei-db/state_db/sc/flatkv/permodule_stats_test.go b/sei-db/state_db/sc/flatkv/permodule_stats_test.go deleted file mode 100644 index 57cfc8ac04..0000000000 --- a/sei-db/state_db/sc/flatkv/permodule_stats_test.go +++ /dev/null @@ -1,255 +0,0 @@ -package flatkv - -import ( - "path/filepath" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/sei-protocol/sei-chain/sei-db/common/keys" - "github.com/sei-protocol/sei-chain/sei-db/db_engine/types" - "github.com/sei-protocol/sei-chain/sei-db/proto" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/config" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/ktype" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/vtype" - scTypes "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" -) - -// fullScanModuleStats tallies, per module, the live key count and total -// footprint (physical key bytes + stored value bytes) of every non-meta key in -// db whose key and value are both non-empty — matching foldChunk / serializeKV -// membership. This is the ground truth the incrementally-maintained working -// stats must always equal. Test-only. -func fullScanModuleStats(t *testing.T, db types.KeyValueDB) map[string]lthash.ModuleStats { - t.Helper() - iter, err := db.NewIter(&types.IterOptions{}) - require.NoError(t, err) - defer iter.Close() - - out := make(map[string]lthash.ModuleStats) - for ; iter.Valid(); iter.Next() { - if ktype.IsMetaKey(iter.Key()) { - continue - } - if len(iter.Key()) == 0 || len(iter.Value()) == 0 { - continue - } - module, _, err := ktype.StripModulePrefix(iter.Key()) - require.NoError(t, err) - st := out[module] - st.KeyCount++ - st.Bytes += int64(len(iter.Key())) + int64(len(iter.Value())) - out[module] = st - } - require.NoError(t, iter.Error()) - return out -} - -// verifyModuleStats asserts the in-memory per-module working stats exactly -// match a full scan of each data DB. Modules whose keys were all deleted keep a -// zeroed working entry (mirroring the per-module hash) and must not appear with -// non-zero counts. -func verifyModuleStats(t *testing.T, s *CommitStore) { - t.Helper() - for _, ndb := range s.namedDataDBs() { - scanned := fullScanModuleStats(t, ndb.db) - working := s.perDBModuleWorkingStats[ndb.dir] - - for module, want := range scanned { - require.Equal(t, want, working[module], - "per-module stats mismatch for %s/%s", ndb.dir, module) - } - for module, got := range working { - if _, ok := scanned[module]; !ok { - require.Equal(t, lthash.ModuleStats{}, got, - "stale non-zero working stats for emptied module %s/%s", ndb.dir, module) - } - } - } -} - -// Test: per-module stats are maintained incrementally and always equal a full -// scan across a sequence of multi-module blocks. -func TestPerModuleStatsIncrementalEqualsFullScan(t *testing.T) { - s := setupTestStore(t) - defer s.Close() - - for i := byte(1); i <= 5; i++ { - commitMultiModuleState(t, s, i) - verifyModuleStats(t, s) - } - - // Sanity: miscDB tracks evm + gov + bank, each with the expected key count. - misc := s.perDBModuleWorkingStats[miscDBDir] - require.Equal(t, int64(5), misc[keys.EVMStoreKey].KeyCount, "one evm-misc key per round") - require.Equal(t, int64(10), misc["gov"].KeyCount, "two gov keys per round") - require.Equal(t, int64(5), misc["bank"].KeyCount, "one bank key per round") -} - -// Test: add -> update -> delete transitions move KeyCount and Bytes exactly as -// specified (add: +1 key + full footprint; update: same count, byte delta only; -// delete: -1 key back to zero). -func TestPerModuleStatsAddUpdateDeleteTransitions(t *testing.T) { - s := setupTestStore(t) - defer s.Close() - - govKey := []byte{0x01, 0x2A} - physKeyLen := int64(len(ktype.ModulePhysicalKey("gov", govKey))) - stats := func() lthash.ModuleStats { return s.perDBModuleWorkingStats[miscDBDir]["gov"] } - - // Add: one key with a short value. Footprint must exceed the physical key - // length (key bytes are always counted, plus a non-empty serialized value). - shortVal := []byte{0xAA} - require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{ - moduleCS("gov", &proto.KVPair{Key: govKey, Value: shortVal}), - })) - commitAndCheck(t, s) - verifyModuleStats(t, s) - afterAdd := stats() - require.Equal(t, int64(1), afterAdd.KeyCount) - storedShort := afterAdd.Bytes - require.Greater(t, storedShort, physKeyLen, "footprint must include key + non-empty value bytes") - - // Update to a longer value: count unchanged, bytes grow by the value delta. - longVal := []byte{0xBB, 0xCC, 0xDD, 0xEE} - require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{ - moduleCS("gov", &proto.KVPair{Key: govKey, Value: longVal}), - })) - commitAndCheck(t, s) - verifyModuleStats(t, s) - afterUpdate := stats() - require.Equal(t, int64(1), afterUpdate.KeyCount, "update must not change key count") - require.Greater(t, afterUpdate.Bytes, storedShort, "longer value should grow the footprint") - - // Delete: back to an empty module (zeroed working entry). - require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{ - moduleCS("gov", &proto.KVPair{Key: govKey, Delete: true}), - })) - commitAndCheck(t, s) - verifyModuleStats(t, s) - require.Equal(t, lthash.ModuleStats{}, stats(), "delete of the last key returns stats to zero") -} - -// Test: per-module stats persist in each DB's LocalMeta and reload after reopen. -func TestPerModuleStatsPersistenceAfterReopen(t *testing.T) { - dir := t.TempDir() - dbDir := filepath.Join(dir, flatkvRootDir) - - cfg := config.DefaultTestConfig(t) - cfg.DataDir = dbDir - - s1, err := NewCommitStore(t.Context(), cfg) - require.NoError(t, err) - _, err = s1.LoadVersion(0, false) - require.NoError(t, err) - - for i := byte(1); i <= 5; i++ { - commitMultiModuleState(t, s1, i) - } - verifyModuleStats(t, s1) - - expected := make(map[string]map[string]lthash.ModuleStats) - for dir, mods := range s1.perDBModuleWorkingStats { - expected[dir] = make(map[string]lthash.ModuleStats) - for module, st := range mods { - expected[dir][module] = st - } - } - require.NoError(t, s1.Close()) - - cfg2 := config.DefaultTestConfig(t) - cfg2.DataDir = dbDir - s2, err := NewCommitStore(t.Context(), cfg2) - require.NoError(t, err) - _, err = s2.LoadVersion(0, false) - require.NoError(t, err) - defer s2.Close() - - require.Equal(t, int64(5), s2.Version()) - verifyModuleStats(t, s2) - - for dir, mods := range expected { - for module, want := range mods { - require.Equal(t, want, s2.perDBModuleWorkingStats[dir][module], - "working stats mismatch after reopen for %s/%s", dir, module) - } - } - - // On-disk LocalMeta carries the same stats. - dbInstances := map[string]types.KeyValueDB{ - accountDBDir: s2.accountDB, - codeDBDir: s2.codeDB, - storageDBDir: s2.storageDB, - miscDBDir: s2.miscDB, - } - for dir, db := range dbInstances { - meta, err := loadLocalMeta(db) - require.NoError(t, err) - for module, want := range expected[dir] { - require.Equal(t, want, meta.ModuleStats[module], - "persisted stats mismatch for %s/%s", dir, module) - } - } -} - -// Test: the state-sync importer produces the same per-module stats the live -// commit path would, and they survive a restart. -func TestPerModuleStatsAfterImportSurvivesRestart(t *testing.T) { - dir := t.TempDir() - dbDir := filepath.Join(dir, flatkvRootDir) - - cfg := config.DefaultTestConfig(t) - cfg.DataDir = dbDir - - s1, err := NewCommitStore(t.Context(), cfg) - require.NoError(t, err) - _, err = s1.LoadVersion(0, false) - require.NoError(t, err) - - imp, err := s1.Importer(7) - require.NoError(t, err) - for i := byte(1); i <= 5; i++ { - addr := addrN(i) - slot := slotN(i) - storVal := vtype.NewStorageData().SetBlockHeight(7).SetValue(&[32]byte{i, 0xAA}).Serialize() - acctVal := vtype.NewAccountData().SetBlockHeight(7).SetNonce(uint64(i)).Serialize() - imp.AddNode(&scTypes.SnapshotNode{Key: storagePhysKey(addr, slot), Value: storVal, Version: 7}) - imp.AddNode(&scTypes.SnapshotNode{Key: accountPhysKey(addr), Value: acctVal, Version: 7}) - - govVal := vtype.NewMiscData().SetBlockHeight(7).SetValue([]byte{i, 0xC0}).Serialize() - imp.AddNode(&scTypes.SnapshotNode{Key: ktype.ModulePhysicalKey("gov", []byte{i}), Value: govVal, Version: 7}) - } - require.NoError(t, imp.Close()) - verifyModuleStats(t, s1) - - require.Equal(t, int64(5), s1.perDBModuleWorkingStats[storageDBDir][keys.EVMStoreKey].KeyCount) - require.Equal(t, int64(5), s1.perDBModuleWorkingStats[accountDBDir][keys.EVMStoreKey].KeyCount) - require.Equal(t, int64(5), s1.perDBModuleWorkingStats[miscDBDir]["gov"].KeyCount) - - expected := make(map[string]map[string]lthash.ModuleStats) - for dir, mods := range s1.perDBModuleWorkingStats { - expected[dir] = make(map[string]lthash.ModuleStats) - for module, st := range mods { - expected[dir][module] = st - } - } - require.NoError(t, s1.Close()) - - cfg2 := config.DefaultTestConfig(t) - cfg2.DataDir = dbDir - s2, err := NewCommitStore(t.Context(), cfg2) - require.NoError(t, err) - _, err = s2.LoadVersion(0, false) - require.NoError(t, err) - defer s2.Close() - - require.Equal(t, int64(7), s2.Version()) - verifyModuleStats(t, s2) - for dir, mods := range expected { - for module, want := range mods { - require.Equal(t, want, s2.perDBModuleWorkingStats[dir][module], - "stats mismatch after restart for %s/%s", dir, module) - } - } -} diff --git a/sei-db/state_db/sc/flatkv/snapshot.go b/sei-db/state_db/sc/flatkv/snapshot.go index 1905746d7d..1c2149c100 100644 --- a/sei-db/state_db/sc/flatkv/snapshot.go +++ b/sei-db/state_db/sc/flatkv/snapshot.go @@ -27,10 +27,10 @@ import ( // account/ (PebbleDB: addr → AccountValue) // code/ (PebbleDB: addr → bytecode) // storage/ (PebbleDB: addr||slot → value) -// misc/ (PebbleDB: full key → value) +// legacy/ (PebbleDB: full key → value) // metadata/ (PebbleDB: version + LtHash) // working/ (mutable clone of active snapshot) -// account/, code/, storage/, misc/, metadata/ +// account/, code/, storage/, legacy/, metadata/ // SNAPSHOT_BASE (records source snapshot name) // changelog/ (WAL, shared across snapshots) const ( @@ -179,7 +179,7 @@ func updateCurrentSymlink(root, snapshotDir string) error { } // snapshotDBDirs lists the DB subdirectory names included in a snapshot. -var snapshotDBDirs = []string{accountDBDir, codeDBDir, storageDBDir, miscDBDir, metadataDir} +var snapshotDBDirs = []string{accountDBDir, codeDBDir, storageDBDir, legacyDBDir, metadataDir} // removeTmpDirs removes any directories ending in "-tmp" or "-removing" // left over from interrupted snapshot writes or deletes. @@ -481,7 +481,7 @@ func (s *CommitStore) WriteSnapshot(_ string) (err error) { {accountDBDir, s.accountDB}, {codeDBDir, s.codeDB}, {storageDBDir, s.storageDB}, - {miscDBDir, s.miscDB}, + {legacyDBDir, s.legacyDB}, {metadataDir, s.metadataDB}, } for _, ndb := range dbs { diff --git a/sei-db/state_db/sc/flatkv/snapshot_test.go b/sei-db/state_db/sc/flatkv/snapshot_test.go index 5438194f9a..d1fb309b61 100644 --- a/sei-db/state_db/sc/flatkv/snapshot_test.go +++ b/sei-db/state_db/sc/flatkv/snapshot_test.go @@ -277,7 +277,7 @@ func TestMigrationFromFlatLayout(t *testing.T) { flatkvDir := filepath.Join(dir, flatkvRootDir) // Simulate the old flat layout by creating DB dirs directly - for _, sub := range []string{accountDBDir, codeDBDir, storageDBDir, metadataDir, miscDBDir} { + for _, sub := range []string{accountDBDir, codeDBDir, storageDBDir, metadataDir, legacyDBDir} { dbPath := filepath.Join(flatkvDir, sub) require.NoError(t, os.MkdirAll(dbPath, 0750)) // Create an actual PebbleDB so Open works diff --git a/sei-db/state_db/sc/flatkv/store.go b/sei-db/state_db/sc/flatkv/store.go index cc20010ecb..7b34210b14 100644 --- a/sei-db/state_db/sc/flatkv/store.go +++ b/sei-db/state_db/sc/flatkv/store.go @@ -43,7 +43,7 @@ const ( accountDBDir = "account" codeDBDir = "code" storageDBDir = "storage" - miscDBDir = "misc" + legacyDBDir = "legacy" metadataDir = "metadata" // Suffixes for atomic directory operations @@ -56,7 +56,7 @@ const ( ) // dataDBDirs lists all data DB directory names (used for per-DB LtHash iteration). -var dataDBDirs = []string{accountDBDir, codeDBDir, storageDBDir, miscDBDir} +var dataDBDirs = []string{accountDBDir, codeDBDir, storageDBDir, legacyDBDir} // InitializeDataDirectories sets the DataDir for each nested PebbleDB config // that does not already have one, using DataDir as the base path. The DBs live @@ -72,8 +72,8 @@ func InitializeDataDirectories(c *config.Config) { if c.StorageDBConfig.DataDir == "" { c.StorageDBConfig.DataDir = filepath.Join(workDir, storageDBDir) } - if c.MiscDBConfig.DataDir == "" { - c.MiscDBConfig.DataDir = filepath.Join(workDir, miscDBDir) + if c.LegacyDBConfig.DataDir == "" { + c.LegacyDBConfig.DataDir = filepath.Join(workDir, legacyDBDir) } if c.MetadataDBConfig.DataDir == "" { c.MetadataDBConfig.DataDir = filepath.Join(workDir, metadataDir) @@ -87,13 +87,13 @@ func applyPebbleMetricsConfig(c *config.Config) { c.AccountDBConfig.EnableMetrics = c.EnablePebbleMetrics c.CodeDBConfig.EnableMetrics = c.EnablePebbleMetrics c.StorageDBConfig.EnableMetrics = c.EnablePebbleMetrics - c.MiscDBConfig.EnableMetrics = c.EnablePebbleMetrics + c.LegacyDBConfig.EnableMetrics = c.EnablePebbleMetrics c.MetadataDBConfig.EnableMetrics = c.EnablePebbleMetrics c.AccountDBConfig.EnableReadWriteMetrics = c.EnableReadWriteMetrics c.CodeDBConfig.EnableReadWriteMetrics = c.EnableReadWriteMetrics c.StorageDBConfig.EnableReadWriteMetrics = c.EnableReadWriteMetrics - c.MiscDBConfig.EnableReadWriteMetrics = c.EnableReadWriteMetrics + c.LegacyDBConfig.EnableReadWriteMetrics = c.EnableReadWriteMetrics c.MetadataDBConfig.EnableReadWriteMetrics = c.EnableReadWriteMetrics } @@ -129,7 +129,7 @@ type CommitStore struct { accountDB seidbtypes.KeyValueDB // "evm/"+0x0a+addr(20) → vtype.AccountData codeDB seidbtypes.KeyValueDB // "evm/"+0x07+addr(20) → vtype.CodeData storageDB seidbtypes.KeyValueDB // "evm/"+0x03+addr(20)||slot(32) → vtype.StorageData - miscDB seidbtypes.KeyValueDB // "module/"+key → vtype.MiscData + legacyDB seidbtypes.KeyValueDB // "module/"+key → vtype.LegacyData // Per-DB committed version, keyed by DB dir name (e.g. accountDBDir). localMeta map[string]*ktype.LocalMeta @@ -150,27 +150,11 @@ type CommitStore struct { // working hashes are loaded from LocalMeta. perDBWorkingLtHash map[string]*lthash.LtHash - // Per-DB, per-module working LtHash: dbDir -> module name -> hash. - // The per-DB root (perDBWorkingLtHash[dir]) is the homomorphic sum of - // the module hashes here. account/code/storage DBs only ever carry the - // "evm" module; miscDB may carry several (evm plus cosmos modules). - // Persisted alongside the per-DB root in each DB's LocalMeta and reloaded - // on startup. This is bookkeeping metadata only: it does not feed the - // global evm_lattice/AppHash. - perDBModuleWorkingLtHash map[string]map[string]*lthash.LtHash - - // Per-DB, per-module working stats: dbDir -> module name -> key-count / - // byte totals. Accumulated alongside perDBModuleWorkingLtHash using the - // same key-membership rule, persisted in each DB's LocalMeta, and reloaded - // on startup. Consensus-irrelevant bookkeeping; per-DB / global totals are - // derived on demand. - perDBModuleWorkingStats map[string]map[string]lthash.ModuleStats - // Pending writes buffer accountWrites map[string]*vtype.AccountData codeWrites map[string]*vtype.CodeData storageWrites map[string]*vtype.StorageData - miscWrites map[string]*vtype.MiscData + legacyWrites map[string]*vtype.LegacyData changelog wal.ChangelogWAL @@ -198,25 +182,14 @@ type CommitStore struct { // // Uses an elasticly-sized pool, so it is safe to submit tasks that have dependencies on other tasks in the pool. miscPool threading.Pool - - // A work pool for lattice-hash computation (CPU-bound). - // - // Uses a fixed-size pool, same lifecycle as readPool / miscPool. - ltHashPool threading.Pool - - // ltCalc encapsulates the lattice-hash pipeline (old-value reads, per-key - // hashing, and worker-combine into final per-DB / per-module hashes) over - // ltHashPool. The commit path is serialized by s.mu, so the calculator has - // a single caller at a time. - ltCalc *lthash.HashCalculator } var _ Store = (*CommitStore)(nil) // dataDBs returns the four data PebbleDB instances in fixed iteration order: -// accountDB, codeDB, storageDB, miscDB. metadataDB is excluded. +// accountDB, codeDB, storageDB, legacyDB. metadataDB is excluded. func (s *CommitStore) dataDBs() []seidbtypes.KeyValueDB { - return []seidbtypes.KeyValueDB{s.accountDB, s.codeDB, s.storageDB, s.miscDB} + return []seidbtypes.KeyValueDB{s.accountDB, s.codeDB, s.storageDB, s.legacyDB} } type namedDB struct { @@ -230,28 +203,19 @@ func (s *CommitStore) namedDataDBs() []namedDB { {accountDBDir, s.accountDB}, {codeDBDir, s.codeDB}, {storageDBDir, s.storageDB}, - {miscDBDir, s.miscDB}, + {legacyDBDir, s.legacyDB}, } } // routePhysicalKey maps a physical DB key to its target database. -// Non-EVM modules are routed to miscDB; EVM keys are routed by kind. +// Non-EVM modules are routed to legacyDB; EVM keys are routed by kind. func (s *CommitStore) routePhysicalKey(physicalKey []byte) (seidbtypes.KeyValueDB, error) { moduleName, innerKey, err := ktype.StripModulePrefix(physicalKey) if err != nil { return nil, err } - if moduleName == "" { - // An empty module name would fold into moduleLtHash[""]/moduleStats[""] - // and later persist as the per-module meta key "_meta/x:/hash", which - // ParseModuleLtHashKey rejects on the very next reopen — permanently - // bricking the store (root != sum(modules) forever). Reject it here, - // at the state-sync import dispatch boundary, mirroring the - // classifyAndPrefix guard on the live-commit path (store_apply.go). - return nil, fmt.Errorf("flatkv: empty module name in physical key %q", physicalKey) - } if moduleName != keys.EVMStoreKey { - return s.miscDB, nil + return s.legacyDB, nil } kind, _ := keys.ParseEVMKey(innerKey) switch kind { @@ -262,7 +226,7 @@ func (s *CommitStore) routePhysicalKey(physicalKey []byte) (seidbtypes.KeyValueD case keys.EVMKeyStorage: return s.storageDB, nil default: - return s.miscDB, nil + return s.legacyDB, nil } } @@ -289,43 +253,25 @@ func NewCommitStore( miscPoolSize := int(cfg.MiscPoolThreadsPerCore*float64(coreCount) + float64(cfg.MiscConstantThreadCount)) miscPool := threading.NewElasticPool("flatkv-misc", miscPoolSize) - ltHashPoolSize := lthashWorkerCount(cfg, coreCount) - ltHashPool := threading.NewFixedPool("flatkv-lthash", ltHashPoolSize, ltHashPoolSize) - ltCalc := lthash.NewHashCalculator(ltHashPool, dataDBDirs, moduleOfKey) - return &CommitStore{ - ctx: ctx, - cancel: cancel, - config: *cfg, - localMeta: make(map[string]*ktype.LocalMeta), - accountWrites: make(map[string]*vtype.AccountData), - codeWrites: make(map[string]*vtype.CodeData), - storageWrites: make(map[string]*vtype.StorageData), - miscWrites: make(map[string]*vtype.MiscData), - pendingChangeSets: make([]*proto.NamedChangeSet, 0), - committedLtHash: lthash.New(), - workingLtHash: lthash.New(), - perDBWorkingLtHash: make(map[string]*lthash.LtHash), - perDBModuleWorkingLtHash: newPerDBModuleLtHashMap(), - perDBModuleWorkingStats: newPerDBModuleStatsMap(), - phaseTimer: metrics.NewPhaseTimer(flatkvMeter, "seidb_main_thread"), - readPool: readPool, - miscPool: miscPool, - ltHashPool: ltHashPool, - ltCalc: ltCalc, + ctx: ctx, + cancel: cancel, + config: *cfg, + localMeta: make(map[string]*ktype.LocalMeta), + accountWrites: make(map[string]*vtype.AccountData), + codeWrites: make(map[string]*vtype.CodeData), + storageWrites: make(map[string]*vtype.StorageData), + legacyWrites: make(map[string]*vtype.LegacyData), + pendingChangeSets: make([]*proto.NamedChangeSet, 0), + committedLtHash: lthash.New(), + workingLtHash: lthash.New(), + perDBWorkingLtHash: make(map[string]*lthash.LtHash), + phaseTimer: metrics.NewPhaseTimer(flatkvMeter, "seidb_main_thread"), + readPool: readPool, + miscPool: miscPool, }, nil } -// lthashWorkerCount computes the fixed lattice-hash pool worker count from -// config, clamped to at least 1 (LtHash computation always needs a worker). -func lthashWorkerCount(cfg *config.Config, coreCount int) int { - n := int(cfg.LtHashThreadsPerCore * float64(coreCount)) - if n < 1 { - n = 1 - } - return n -} - // resetPools recreates the context and thread pools after a full Close(). func (s *CommitStore) resetPools() { coreCount := runtime.NumCPU() @@ -337,10 +283,6 @@ func (s *CommitStore) resetPools() { miscPoolSize := int(s.config.MiscPoolThreadsPerCore*float64(coreCount) + float64(s.config.MiscConstantThreadCount)) s.miscPool = threading.NewElasticPool("flatkv-misc", miscPoolSize) - - ltHashPoolSize := lthashWorkerCount(&s.config, coreCount) - s.ltHashPool = threading.NewFixedPool("flatkv-lthash", ltHashPoolSize, ltHashPoolSize) - s.ltCalc = lthash.NewHashCalculator(s.ltHashPool, dataDBDirs, moduleOfKey) } func (s *CommitStore) flatkvDir() string { @@ -443,15 +385,7 @@ func (s *CommitStore) loadVersionReadOnly(targetVersion int64) (_ Store, retErr return nil, fmt.Errorf("loadVersionReadOnly: pre-init cleanup: %w", err) } } - // Give the clone an independent context rather than deriving it from the - // parent (s.ctx). The read-only store owns its own resources (thread pools, - // pebble handles) and is torn down by its own Close, which cancels the - // context NewCommitStore derives here. Rooting it at s.ctx instead would - // cancel the clone's context — and abort in-flight reads with "context - // canceled" — the moment the parent is closed, even though the caller may - // still be using the returned view. The clone's lifecycle is therefore - // decoupled from the parent's. - ro, err := NewCommitStore(context.Background(), &s.config) + ro, err := NewCommitStore(s.ctx, &s.config) if err != nil { return nil, fmt.Errorf("failed to create readonly store: %w", err) } @@ -465,7 +399,7 @@ func (s *CommitStore) loadVersionReadOnly(targetVersion int64) (_ Store, retErr ro.config.AccountDBConfig.DataDir = filepath.Join(workDir, accountDBDir) ro.config.CodeDBConfig.DataDir = filepath.Join(workDir, codeDBDir) ro.config.StorageDBConfig.DataDir = filepath.Join(workDir, storageDBDir) - ro.config.MiscDBConfig.DataDir = filepath.Join(workDir, miscDBDir) + ro.config.LegacyDBConfig.DataDir = filepath.Join(workDir, legacyDBDir) ro.config.MetadataDBConfig.DataDir = filepath.Join(workDir, metadataDir) // Transfer the lazily-acquired lock to the clone so that ro.Close() @@ -675,7 +609,7 @@ func (s *CommitStore) openDBs(dbDir, changelogRoot string) (retErr error) { s.accountDB = nil s.codeDB = nil s.storageDB = nil - s.miscDB = nil + s.legacyDB = nil s.changelog = nil s.localMeta = make(map[string]*ktype.LocalMeta) } @@ -700,11 +634,11 @@ func (s *CommitStore) openDBs(dbDir, changelogRoot string) (retErr error) { } toClose = append(toClose, s.storageDB) - s.miscDB, err = s.openPebbleDB(&s.config.MiscDBConfig, &s.config.MiscCacheConfig) + s.legacyDB, err = s.openPebbleDB(&s.config.LegacyDBConfig, &s.config.LegacyCacheConfig) if err != nil { - return fmt.Errorf("failed to open misc DB: %w", err) + return fmt.Errorf("failed to open legacy DB: %w", err) } - toClose = append(toClose, s.miscDB) + toClose = append(toClose, s.legacyDB) s.metadataDB, err = s.openPebbleDB(&s.config.MetadataDBConfig, &s.config.MetadataCacheConfig) if err != nil { @@ -766,21 +700,11 @@ func (s *CommitStore) loadGlobalMetadata() error { // corruption), lower committedVersion so catchup replays from there. for _, dbDir := range dataDBDirs { meta := s.localMeta[dbDir] - if err := validatePerModuleMetadata(dbDir, meta); err != nil { - return err - } if meta != nil && meta.LtHash != nil { s.perDBWorkingLtHash[dbDir] = meta.LtHash.Clone() } else { s.perDBWorkingLtHash[dbDir] = lthash.New() } - if meta != nil { - s.perDBModuleWorkingLtHash[dbDir] = cloneModuleHashes(meta.ModuleLtHashes) - s.perDBModuleWorkingStats[dbDir] = cloneModuleStats(meta.ModuleStats) - } else { - s.perDBModuleWorkingLtHash[dbDir] = make(map[string]*lthash.LtHash) - s.perDBModuleWorkingStats[dbDir] = make(map[string]lthash.ModuleStats) - } if meta != nil && meta.CommittedVersion < s.committedVersion { logger.Warn("DB LocalMeta version behind global version, will catchup", "db", dbDir, @@ -913,8 +837,6 @@ func (s *CommitStore) resetForImport() error { s.committedLtHash = lthash.New() s.workingLtHash = lthash.New() s.perDBWorkingLtHash = newPerDBLtHashMap() - s.perDBModuleWorkingLtHash = newPerDBModuleLtHashMap() - s.perDBModuleWorkingStats = newPerDBModuleStatsMap() return nil } diff --git a/sei-db/state_db/sc/flatkv/store_apply.go b/sei-db/state_db/sc/flatkv/store_apply.go index 4e56d20f1e..0534899001 100644 --- a/sei-db/state_db/sc/flatkv/store_apply.go +++ b/sei-db/state_db/sc/flatkv/store_apply.go @@ -3,25 +3,17 @@ package flatkv import ( "fmt" "maps" - "time" "github.com/sei-protocol/sei-chain/sei-db/common/keys" "github.com/sei-protocol/sei-chain/sei-db/proto" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/ktype" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/vtype" - "go.opentelemetry.io/otel/metric" ) -// ApplyChangeSets classifies changesets, buffers pending writes, and folds -// them into the working LtHash. Non-EVM modules go to miscDB under "/". +// ApplyChangeSets buffers EVM changesets and updates LtHash. +// Non-EVM modules are routed to legacyDB with a "/" key prefix. func (s *CommitStore) ApplyChangeSets(changeSets []*proto.NamedChangeSet) (err error) { - // Hold the write lock for the whole body: it both reads (old values) and - // mutates (maps.Copy) the pending-writes maps, which iterator construction - // and Get read under a read lock. - s.mu.Lock() - defer s.mu.Unlock() - obs := s.observeOp("ApplyChangeSets", otelMetrics.ApplyChangesetsLatency, "changesets", len(changeSets)) defer obs.done(&err, nil) @@ -30,175 +22,145 @@ func (s *CommitStore) ApplyChangeSets(changeSets []*proto.NamedChangeSet) (err e return errReadOnly } + // Hold the write lock for the whole body: it both reads + // (batchReadOldValues) and mutates (maps.Copy) the pending-writes maps, + // which iterator construction and Get read under a read lock. + s.mu.Lock() + defer s.mu.Unlock() + + /////////// + // Setup // + /////////// s.phaseTimer.SetPhase("apply_change_sets_prepare") - changesByType, err := classifyAndPrefix(changeSets) - if err != nil { - return err - } - pairSets, counts, err := s.prepareWrites(changesByType, s.committedVersion+1) - if err != nil { - return err - } - s.phaseTimer.SetPhase("apply_change_compute_lt_hash") - res, err := s.ltCalc.Compute(pairSets, s.perDBWorkingLtHash, s.perDBModuleWorkingLtHash, s.perDBModuleWorkingStats) + changesByType, err := classifyAndPrefix(changeSets) if err != nil { return err } + storageChanges := len(changesByType[keys.EVMKeyStorage]) + accountChanges := len(changesByType[keys.EVMKeyNonce]) + len(changesByType[keys.EVMKeyCodeHash]) + codeChanges := len(changesByType[keys.EVMKeyCode]) + legacyChanges := len(changesByType[keys.EVMKeyLegacy]) - s.perDBWorkingLtHash = res.PerDB - s.perDBModuleWorkingLtHash = res.PerModule - s.perDBModuleWorkingStats = res.PerModuleStats - s.workingLtHash = res.Global - s.pendingChangeSets = append(s.pendingChangeSets, changeSets...) - - s.phaseTimer.SetPhase("apply_change_done") - logger.Debug("FlatKV ApplyChangeSets complete", - "changesets", len(changeSets), - "writes", counts.accountWrites+counts.storageWrites+counts.codeWrites+counts.miscWrites, - "elapsed", obs.elapsed()) - return nil -} - -// applyCounts records per-DB write tallies for logging and metrics. -type applyCounts struct { - accountWrites, storageWrites, codeWrites, miscWrites int -} - -// prepareWrites reads prior values, applies EVM value semantics, buffers the -// resulting rows into the pending-write maps, and returns per-DB LtHash pairs -// for Compute. Only accounts need old values in structured form (to merge -// partial nonce/codehash updates); other DBs pass raw old bytes through. -func (s *CommitStore) prepareWrites( - changesByType map[keys.EVMKeyKind]map[string][]byte, - blockHeight int64, -) ([]lthash.DBPairs, applyCounts, error) { - var counts applyCounts + blockHeight := s.committedVersion + 1 + //////////////////// + // Batch Read Old // + //////////////////// s.phaseTimer.SetPhase("apply_change_sets_batch_read") - readStart := time.Now() - oldByDB, err := s.ltCalc.ReadOldValues(s, keysByDBFromClassified(changesByType)) - otelMetrics.BatchReadOldValuesLatency.Record(s.ctx, secondsSince(readStart), - metric.WithAttributes(successAttr(err))) + + storageOld, accountOld, codeOld, legacyOld, err := s.batchReadOldValues(changesByType) if err != nil { - return nil, counts, fmt.Errorf("failed to batch read old values: %w", err) + return fmt.Errorf("failed to batch read old values: %w", err) } + ////////////////// + // Gather Pairs // + ////////////////// s.phaseTimer.SetPhase("apply_change_sets_gather_pairs") - // Account: merge partial nonce/codehash updates onto the old account. - accountOld, err := deserializeAccountOld(oldByDB[accountDBDir]) - if err != nil { - return nil, counts, err - } - accountUpdates, err := mergeAccountUpdates( + // Gather account pairs + accountWrites, err := mergeAccountUpdates( changesByType[keys.EVMKeyNonce], changesByType[keys.EVMKeyCodeHash], nil, // TODO: update this when we add a balance key! ) if err != nil { - return nil, counts, fmt.Errorf("failed to gather account updates: %w", err) + return fmt.Errorf("failed to gather account updates: %w", err) } - newAccounts := deriveNewAccountValues(accountUpdates, accountOld, blockHeight) - accountPairs := gatherPairs(newAccounts, oldByDB[accountDBDir]) - maps.Copy(s.accountWrites, newAccounts) - counts.accountWrites = len(newAccounts) + newAccountValues := deriveNewAccountValues(accountWrites, accountOld, blockHeight) + accountPairs := gatherLTHashPairs(newAccountValues, accountOld) + maps.Copy(s.accountWrites, newAccountValues) storageWrites, err := processStorageChanges(changesByType[keys.EVMKeyStorage], blockHeight) if err != nil { - return nil, counts, fmt.Errorf("failed to parse storage changes: %w", err) + return fmt.Errorf("failed to parse storage changes: %w", err) } - storagePairs := gatherPairs(storageWrites, oldByDB[storageDBDir]) + storagePairs := gatherLTHashPairs(storageWrites, storageOld) maps.Copy(s.storageWrites, storageWrites) - counts.storageWrites = len(storageWrites) codeWrites, err := processCodeChanges(changesByType[keys.EVMKeyCode], blockHeight) if err != nil { - return nil, counts, fmt.Errorf("failed to parse code changes: %w", err) + return fmt.Errorf("failed to parse code changes: %w", err) } - codePairs := gatherPairs(codeWrites, oldByDB[codeDBDir]) + codePairs := gatherLTHashPairs(codeWrites, codeOld) maps.Copy(s.codeWrites, codeWrites) - counts.codeWrites = len(codeWrites) - miscWrites, err := processMiscChanges(changesByType[keys.EVMKeyMisc], blockHeight) + legacyWrites, err := processLegacyChanges(changesByType[keys.EVMKeyLegacy], blockHeight) if err != nil { - return nil, counts, fmt.Errorf("failed to parse misc changes: %w", err) + return fmt.Errorf("failed to parse legacy changes: %w", err) } - miscPairs := gatherPairs(miscWrites, oldByDB[miscDBDir]) - maps.Copy(s.miscWrites, miscWrites) - counts.miscWrites = len(miscWrites) - - addKVPairs(s.ctx, accountDBDir, counts.accountWrites) - addKVPairs(s.ctx, storageDBDir, counts.storageWrites) - addKVPairs(s.ctx, codeDBDir, counts.codeWrites) - addKVPairs(s.ctx, miscDBDir, counts.miscWrites) + legacyPairs := gatherLTHashPairs(legacyWrites, legacyOld) + maps.Copy(s.legacyWrites, legacyWrites) + + addKVPairs(s.ctx, accountDBDir, len(newAccountValues)) + addKVPairs(s.ctx, storageDBDir, len(storageWrites)) + addKVPairs(s.ctx, codeDBDir, len(codeWrites)) + addKVPairs(s.ctx, legacyDBDir, len(legacyWrites)) recordPendingWrites(s.ctx, accountDBDir, len(s.accountWrites)) recordPendingWrites(s.ctx, storageDBDir, len(s.storageWrites)) recordPendingWrites(s.ctx, codeDBDir, len(s.codeWrites)) - recordPendingWrites(s.ctx, miscDBDir, len(s.miscWrites)) - - return []lthash.DBPairs{ - {Dir: storageDBDir, Pairs: storagePairs}, - {Dir: accountDBDir, Pairs: accountPairs}, - {Dir: codeDBDir, Pairs: codePairs}, - {Dir: miscDBDir, Pairs: miscPairs}, - }, counts, nil -} + recordPendingWrites(s.ctx, legacyDBDir, len(s.legacyWrites)) -// keysByDBFromClassified maps the per-kind classified changes to the set of -// physical keys per data DB dir, so the calculator can read old values grouped -// by DB. Account keys come from both the nonce and codehash kinds. -func keysByDBFromClassified(changesByType map[keys.EVMKeyKind]map[string][]byte) map[string]map[string]struct{} { - out := make(map[string]map[string]struct{}, len(dataDBDirs)) - add := func(dir string, changes map[string][]byte) { - if len(changes) == 0 { - return - } - set := out[dir] - if set == nil { - set = make(map[string]struct{}, len(changes)) - out[dir] = set - } - for key := range changes { - set[key] = struct{}{} + //////////////////// + // Compute LTHash // + //////////////////// + s.phaseTimer.SetPhase("apply_change_compute_lt_hash") + + type dbPairs struct { + dir string + pairs []lthash.KVPairWithLastValue + } + for _, dp := range [4]dbPairs{ + {storageDBDir, storagePairs}, + {accountDBDir, accountPairs}, + {codeDBDir, codePairs}, + {legacyDBDir, legacyPairs}, + } { + if len(dp.pairs) > 0 { + newHash, _ := lthash.ComputeLtHash(s.perDBWorkingLtHash[dp.dir], dp.pairs) + s.perDBWorkingLtHash[dp.dir] = newHash } } - add(storageDBDir, changesByType[keys.EVMKeyStorage]) - add(accountDBDir, changesByType[keys.EVMKeyNonce]) - add(accountDBDir, changesByType[keys.EVMKeyCodeHash]) - add(codeDBDir, changesByType[keys.EVMKeyCode]) - add(miscDBDir, changesByType[keys.EVMKeyMisc]) - return out -} -// deserializeAccountOld parses the raw old account bytes read by the calculator -// into structured AccountData, needed to merge partial account-field updates. -func deserializeAccountOld(raw map[string][]byte) (map[string]*vtype.AccountData, error) { - old := make(map[string]*vtype.AccountData, len(raw)) - for key, b := range raw { - if b == nil { - continue - } - v, err := vtype.DeserializeAccountData(b) - if err != nil { - return nil, fmt.Errorf("failed to deserialize accountDB old value: %w", err) - } - old[key] = v + // Global LTHash = sum of per-DB hashes (homomorphic property). + // Compute into a fresh hash and swap to avoid a transient empty state + // on workingLtHash (safe for future pipelining / async callers). + globalHash := lthash.New() + for _, dir := range dataDBDirs { + globalHash.MixIn(s.perDBWorkingLtHash[dir]) } - return old, nil -} + s.workingLtHash = globalHash -// moduleOfKey extracts the owning module from a physical key. Injected into the -// lthash HashCalculator so it can bucket pairs by module without importing ktype -// (ktype already imports lthash). -func moduleOfKey(physicalKey []byte) (string, error) { - module, _, err := ktype.StripModulePrefix(physicalKey) - return module, err + ////////////// + // Finalize // + ////////////// + + // Now that we've made it through the batch without errors, we can add the change sets to the pending change sets. + s.pendingChangeSets = append(s.pendingChangeSets, changeSets...) + + s.phaseTimer.SetPhase("apply_change_done") + logger.Debug("FlatKV ApplyChangeSets complete", + "changesets", len(changeSets), + "accountChanges", accountChanges, + "accountWrites", len(newAccountValues), + "storageChanges", storageChanges, + "storageWrites", len(storageWrites), + "codeChanges", codeChanges, + "codeWrites", len(codeWrites), + "legacyChanges", legacyChanges, + "legacyWrites", len(legacyWrites), + "pendingAccount", len(s.accountWrites), + "pendingStorage", len(s.storageWrites), + "pendingCode", len(s.codeWrites), + "pendingLegacy", len(s.legacyWrites), + "elapsed", obs.elapsed()) + return nil } // classifyAndPrefix splits changeSets into per-EVMKeyKind maps whose keys are // already in physical format ("module/" + prefix_encoded_key). Non-EVM modules are -// merged into the EVMKeyMisc bucket with a "/" prefix. +// merged into the EVMKeyLegacy bucket with a "/" prefix. // // This replaces the former sortChangeSets + prefixModuleKeys two-pass approach, // avoiding an extra map allocation and repeated string concatenation per key. @@ -215,7 +177,7 @@ func classifyAndPrefix(changeSets []*proto.NamedChangeSet) (map[keys.EVMKeyKind] } for _, cs := range changeSets { - if cs == nil || len(cs.Changeset.Pairs) == 0 { + if len(cs.Changeset.Pairs) == 0 { continue } @@ -227,7 +189,7 @@ func classifyAndPrefix(changeSets []*proto.NamedChangeSet) (map[keys.EVMKeyKind] } var physKey string - if kind == keys.EVMKeyMisc { + if kind == keys.EVMKeyLegacy { physKey = string(ktype.ModulePhysicalKey(keys.EVMStoreKey, pair.Key)) } else { physKey = string(ktype.EVMPhysicalKey(kind, keyBytes)) @@ -241,23 +203,13 @@ func classifyAndPrefix(changeSets []*proto.NamedChangeSet) (map[keys.EVMKeyKind] } } } else { - // An empty module name would fold into "/"+key here and later - // persist as the per-module meta key "_meta/x:/hash", which - // ParseModuleLtHashKey rejects on reload — a store that ever - // commits one becomes permanently unopenable (sum-to-root check - // fails forever). Reject it up front instead; module names are - // never empty in normal operation (Cosmos SDK's NewKVStoreKey - // panics on an empty name), so this only guards malformed input. - if cs.Name == "" { - return nil, fmt.Errorf("flatkv: empty module name in changeset") - } - miscMap := getOrCreate(keys.EVMKeyMisc, len(cs.Changeset.Pairs)) + legacyMap := getOrCreate(keys.EVMKeyLegacy, len(cs.Changeset.Pairs)) for _, pair := range cs.Changeset.Pairs { physKey := string(ktype.ModulePhysicalKey(cs.Name, pair.Key)) if pair.Delete { - miscMap[physKey] = nil + legacyMap[physKey] = nil } else { - miscMap[physKey] = nonNilValue(pair.Value) + legacyMap[physKey] = nonNilValue(pair.Value) } } } @@ -327,36 +279,32 @@ func processCodeChanges( return result, nil } -// Process incoming misc changes into a form appropriate for hashing and insertion into the DB. -func processMiscChanges( +// Process incoming legacy changes into a form appropriate for hashing and insertion into the DB. +func processLegacyChanges( rawChanges map[string][]byte, blockHeight int64, -) (map[string]*vtype.MiscData, error) { - result := make(map[string]*vtype.MiscData, len(rawChanges)) +) (map[string]*vtype.LegacyData, error) { + result := make(map[string]*vtype.LegacyData, len(rawChanges)) for keyStr, rawChange := range rawChanges { if rawChange == nil { - result[keyStr] = vtype.NewMiscData().SetBlockHeight(blockHeight).MarkDeleted() + result[keyStr] = vtype.NewLegacyData().SetBlockHeight(blockHeight).MarkDeleted() } else { - result[keyStr] = vtype.NewMiscData().SetBlockHeight(blockHeight).SetValue(rawChange) + result[keyStr] = vtype.NewLegacyData().SetBlockHeight(blockHeight).SetValue(rawChange) } } return result, nil } -// gatherPairs builds the LtHash pairs for one DB from its new typed values and -// the raw old serialized bytes read by the calculator. The old bytes are used -// verbatim as LastValue: by the round-trip identity of the value serializers -// they equal the exact bytes previously folded into the hash, so unmixing them -// cancels that contribution precisely. A key with no prior value (or a pending -// deletion) has a nil entry in rawOld and thus a nil LastValue (nothing to -// unmix). -func gatherPairs[T vtype.VType]( +func gatherLTHashPairs[T vtype.VType]( newValues map[string]T, - rawOld map[string][]byte, + oldValues map[string]T, ) []lthash.KVPairWithLastValue { + pairs := make([]lthash.KVPairWithLastValue, 0, len(newValues)) + for keyStr, newValue := range newValues { + oldValue := oldValues[keyStr] isDelete := newValue.IsDelete() var newBytes []byte @@ -364,13 +312,19 @@ func gatherPairs[T vtype.VType]( newBytes = newValue.Serialize() } + var oldBytes []byte + if !oldValue.IsDelete() { + oldBytes = oldValue.Serialize() + } + pairs = append(pairs, lthash.KVPairWithLastValue{ Key: []byte(keyStr), Value: newBytes, - LastValue: rawOld[keyStr], + LastValue: oldBytes, Delete: isDelete, }) } + return pairs } diff --git a/sei-db/state_db/sc/flatkv/store_catchup.go b/sei-db/state_db/sc/flatkv/store_catchup.go index 8b873adbd2..dc3cdec058 100644 --- a/sei-db/state_db/sc/flatkv/store_catchup.go +++ b/sei-db/state_db/sc/flatkv/store_catchup.go @@ -211,7 +211,7 @@ func (s *CommitStore) catchup(targetVersion int64) (err error) { recordPendingWrites(s.ctx, accountDBDir, 0) recordPendingWrites(s.ctx, codeDBDir, 0) recordPendingWrites(s.ctx, storageDBDir, 0) - recordPendingWrites(s.ctx, miscDBDir, 0) + recordPendingWrites(s.ctx, legacyDBDir, 0) expectedNext = entry.Version + 1 replayed++ diff --git a/sei-db/state_db/sc/flatkv/store_iteration.go b/sei-db/state_db/sc/flatkv/store_iteration.go index 937c3e91ed..8e80d0c7de 100644 --- a/sei-db/state_db/sc/flatkv/store_iteration.go +++ b/sei-db/state_db/sc/flatkv/store_iteration.go @@ -14,7 +14,7 @@ import ( ) // RawGlobalIterator returns an iterator over all committed keys across the -// data DBs (account, code, storage, misc), merged in global lexicographic +// data DBs (account, code, storage, legacy), merged in global lexicographic // order. Within each DB, keys are in Pebble order. Per-DB _meta/* keys are // skipped. Pending writes are not visible. metadataDB is not included. func (s *CommitStore) RawGlobalIterator() (dbm.Iterator, error) { @@ -57,7 +57,7 @@ func (s *CommitStore) Iterator(store string, start []byte, end []byte, ascending return nil, fmt.Errorf("store name cannot be empty") } - // Read lock for the construction span: buildEvmIterator/buildMiscDBLane + // Read lock for the construction span: buildEvmIterator/buildLegacyDBLane // snapshot the pending-writes maps and pin the Pebble view here, so the // returned iterator may safely outlive a concurrent ApplyChangeSets/Commit. s.mu.RLock() @@ -69,7 +69,7 @@ func (s *CommitStore) Iterator(store string, start []byte, end []byte, ascending iter, err = s.buildEvmIterator(start, end, ascending) } else { lowerBound, upperBound := moduleIteratorBounds(store, start, end) - iter, err = s.buildMiscDBLane(store, lowerBound, upperBound, ascending) + iter, err = s.buildLegacyDBLane(store, lowerBound, upperBound, ascending) } if err != nil { return nil, err @@ -83,7 +83,7 @@ func (s *CommitStore) Iterator(store string, start []byte, end []byte, ascending buildCodeLane ──────────────┐ buildStorageLane ───────────┤ -buildMiscDBLane (evm/) ───--┼──► merge iterator ──► memiavl keys + values +buildLegacyDBLane (evm/) ───┼──► merge iterator ──► memiavl keys + values buildAccountNonceLane ──────┤ buildAccountCodehashLane ───┘ @@ -118,15 +118,15 @@ func (s *CommitStore) buildEvmIterator( lanes = append(lanes, lane) } - // Misc is the identity-mapped catch-all (no single type prefix), so it uses + // Legacy is the identity-mapped catch-all (no single type prefix), so it uses // the whole-range translation and is always built. - miscLower, miscUpper := moduleIteratorBounds(keys.EVMStoreKey, start, end) - miscLane, err := s.buildMiscDBLane(keys.EVMStoreKey, miscLower, miscUpper, ascending) + legacyLower, legacyUpper := moduleIteratorBounds(keys.EVMStoreKey, start, end) + legacyLane, err := s.buildLegacyDBLane(keys.EVMStoreKey, legacyLower, legacyUpper, ascending) if err != nil { closeIterators(lanes) return nil, err } - lanes = append(lanes, miscLane) + lanes = append(lanes, legacyLane) // TODO: once we move account balances to FlatKV, we need to add a lane for them here. @@ -173,7 +173,7 @@ func (sp evmLaneSpec) bounds(start []byte, end []byte) (lower []byte, upper []by } // evmLaneSpecs returns the optimized lanes, in no particular order (the merging -// iterator orders the combined output). The misc catch-all lane is handled +// iterator orders the combined output). The legacy catch-all lane is handled // separately because it has no single type prefix. func (s *CommitStore) evmLaneSpecs() []evmLaneSpec { return []evmLaneSpec{ @@ -307,10 +307,10 @@ func buildLane[T vtype.VType]( return transformedIterator, nil } -/* Data flow: buildMiscDBLane +/* Data flow: buildLegacyDBLane ┌────────────────────────┐ ┌───────────────────┐ - │ miscWrites (pending) │ │ miscDB (pebble) │ + │ legacyWrites (pending) │ │ legacyDB (pebble) │ └────────────────────────┘ └───────────────────┘ │ │ ▼ ▼ @@ -325,7 +325,7 @@ func buildLane[T vtype.VType]( │ merge iterator │ pending writes "win" └────────────────┘ │ - physical key + serialized MiscData + physical key + serialized LegacyData includes deleted values │ ▼ @@ -339,7 +339,7 @@ func buildLane[T vtype.VType]( ▼ */ -func (s *CommitStore) buildMiscDBLane( +func (s *CommitStore) buildLegacyDBLane( store string, lowerBound, upperBound []byte, ascending bool, @@ -354,17 +354,17 @@ func (s *CommitStore) buildMiscDBLane( } if moduleName != store { return nil, nil, false, fmt.Errorf( - "misc iterator key %q has module %q, expected %q", + "legacy iterator key %q has module %q, expected %q", key, moduleName, store, ) } - ld, err := vtype.DeserializeMiscData(value) + ld, err := vtype.DeserializeLegacyData(value) if err != nil { return nil, nil, false, err } return logicalKey, ld.GetValue(), false, nil } - return buildLane(s.miscWrites, s.miscDB, lowerBound, upperBound, ascending, serializeForIter, transform) + return buildLane(s.legacyWrites, s.legacyDB, lowerBound, upperBound, ascending, serializeForIter, transform) } /* Data flow: buildCodeLane diff --git a/sei-db/state_db/sc/flatkv/store_iteration_test.go b/sei-db/state_db/sc/flatkv/store_iteration_test.go index e5d99aa8d5..286cda6ac9 100644 --- a/sei-db/state_db/sc/flatkv/store_iteration_test.go +++ b/sei-db/state_db/sc/flatkv/store_iteration_test.go @@ -69,8 +69,8 @@ func TestEvmIterator(t *testing.T) { codeEnd := ktype.PrefixEnd(codeStart) codeHashStart := []byte{0x08} codeHashEnd := ktype.PrefixEnd(codeHashStart) - miscStart := []byte{0x09} - miscEnd := ktype.PrefixEnd(miscStart) + legacyStart := []byte{0x09} + legacyEnd := ktype.PrefixEnd(legacyStart) nonceStart := []byte{0x0a} nonceEnd := ktype.PrefixEnd(nonceStart) midAddr := addrN(0x80) @@ -87,7 +87,7 @@ func TestEvmIterator(t *testing.T) { {name: "full module ascending", ascending: true}, {name: "full module descending", ascending: false}, {name: "storage prefix range", start: storageStart, end: storageEnd, ascending: true}, - {name: "misc sub-range", start: miscStart, end: miscEnd, ascending: true}, + {name: "legacy sub-range", start: legacyStart, end: legacyEnd, ascending: true}, {name: "code prefix range", start: codeStart, end: codeEnd, ascending: true}, {name: "codehash prefix range ascending", start: codeHashStart, end: codeHashEnd, ascending: true}, {name: "codehash prefix range descending", start: codeHashStart, end: codeHashEnd, ascending: false}, @@ -145,7 +145,7 @@ func TestEvmIterator(t *testing.T) { }) } -func TestMiscIteratorNonEVM(t *testing.T) { +func TestLegacyIteratorNonEVM(t *testing.T) { s := setupTestStore(t) defer s.Close() @@ -595,26 +595,26 @@ func buildEvmIteratorFixture(t *testing.T, seed int64) *evmIteratorFixture { } { gen.addStorage(disp) gen.addCode(disp) - gen.addMisc(disp) + gen.addLegacy(disp) gen.addAccount(disp) } // Nonce-only account (no codehash key in iterator output). gen.addNonceOnlyAccount() - // Malformed account-prefixed misc key: lands in the account physical - // region (evm/0x0a...) but is routed to miscDB, exercising the overlap - // between the misc lane and the account-derived lanes. - gen.addMalformedAccountMiscKey() + // Malformed account-prefixed legacy key: lands in the account physical + // region (evm/0x0a...) but is routed to legacyDB, exercising the overlap + // between the legacy lane and the account-derived lanes. + gen.addMalformedAccountLegacyKey() - // Malformed storage/code-prefixed misc keys: correct type byte but wrong - // length, so they route to miscDB and physically live in the storage - // (evm/0x03...) and code (evm/0x07...) keyspaces. Confirms the misc lane + // Malformed storage/code-prefixed legacy keys: correct type byte but wrong + // length, so they route to legacyDB and physically live in the storage + // (evm/0x03...) and code (evm/0x07...) keyspaces. Confirms the legacy lane // interleaves with the storage and code lanes and that the merge does not - // falsely dedup a misc key against an optimized-lane key of a different + // falsely dedup a legacy key against an optimized-lane key of a different // length. - gen.addMalformedStoragePrefixMiscKey() - gen.addMalformedCodePrefixMiscKey() + gen.addMalformedStoragePrefixLegacyKey() + gen.addMalformedCodePrefixLegacyKey() require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{namedCS(batch1...)})) commitAndCheck(t, s) @@ -652,32 +652,32 @@ func (g *evmIteratorGenerator) uniqueAddr() ktype.Address { panic("failed to allocate unique test address") } -// addMalformedAccountMiscKey writes a 0x0a-prefixed key whose length does not -// match a well-formed nonce key, so it routes to miscDB while physically +// addMalformedAccountLegacyKey writes a 0x0a-prefixed key whose length does not +// match a well-formed nonce key, so it routes to legacyDB while physically // living in the account keyspace (evm/0x0a...). -func (g *evmIteratorGenerator) addMalformedAccountMiscKey() { +func (g *evmIteratorGenerator) addMalformedAccountLegacyKey() { key := append([]byte{0x0a}, bytes.Repeat([]byte{0x7f}, 19)...) val := []byte{0xab, 0xcd} *g.batch1 = append(*g.batch1, &proto.KVPair{Key: bytes.Clone(key), Value: bytes.Clone(val)}) setEvmLatest(g.latest, key, val) } -// addMalformedStoragePrefixMiscKey writes a 0x03-prefixed key whose length +// addMalformedStoragePrefixLegacyKey writes a 0x03-prefixed key whose length // does not match a well-formed storage key (1 + 20 + 32), so it routes to -// miscDB while physically living in the storage keyspace (evm/0x03...). It is -// committed (batch1) so the misc and storage lanes interleave over pebble. -func (g *evmIteratorGenerator) addMalformedStoragePrefixMiscKey() { +// legacyDB while physically living in the storage keyspace (evm/0x03...). It is +// committed (batch1) so the legacy and storage lanes interleave over pebble. +func (g *evmIteratorGenerator) addMalformedStoragePrefixLegacyKey() { key := append([]byte{0x03}, bytes.Repeat([]byte{0x7f}, ktype.AddressLen)...) val := []byte{0x12, 0x34} *g.batch1 = append(*g.batch1, &proto.KVPair{Key: bytes.Clone(key), Value: bytes.Clone(val)}) setEvmLatest(g.latest, key, val) } -// addMalformedCodePrefixMiscKey writes a 0x07-prefixed key whose length does -// not match a well-formed code key (1 + 20), so it routes to miscDB while +// addMalformedCodePrefixLegacyKey writes a 0x07-prefixed key whose length does +// not match a well-formed code key (1 + 20), so it routes to legacyDB while // physically living in the code keyspace (evm/0x07...). It is pending-only -// (batch2) so the misc and code lanes interleave over pending writes too. -func (g *evmIteratorGenerator) addMalformedCodePrefixMiscKey() { +// (batch2) so the legacy and code lanes interleave over pending writes too. +func (g *evmIteratorGenerator) addMalformedCodePrefixLegacyKey() { key := append([]byte{0x07}, bytes.Repeat([]byte{0x5a}, ktype.AddressLen-1)...) val := []byte{0x56, 0x78} *g.batch2 = append(*g.batch2, &proto.KVPair{Key: bytes.Clone(key), Value: bytes.Clone(val)}) @@ -704,7 +704,7 @@ func (g *evmIteratorGenerator) codeVal() []byte { return out } -func (g *evmIteratorGenerator) miscVal() []byte { +func (g *evmIteratorGenerator) legacyVal() []byte { n := 1 + g.rng.Intn(32) out := make([]byte, n) g.rng.Read(out) @@ -798,31 +798,31 @@ func (g *evmIteratorGenerator) addCode(disp evmIteratorDisposition) { } } -func (g *evmIteratorGenerator) addMisc(disp evmIteratorDisposition) { +func (g *evmIteratorGenerator) addLegacy(disp evmIteratorDisposition) { addr := g.uniqueAddr() - v1 := g.miscVal() - v2 := g.miscVal() + v1 := g.legacyVal() + v2 := g.legacyVal() for bytes.Equal(v1, v2) { - v2 = g.miscVal() + v2 = g.legacyVal() } key := append([]byte{0x09}, addr[:]...) switch disp { case dispositionPebbleOnly: - *g.batch1 = append(*g.batch1, miscPair(addr, v1)) - recordMiscLatest(g.latest, addr, v1) + *g.batch1 = append(*g.batch1, legacyPair(addr, v1)) + recordLegacyLatest(g.latest, addr, v1) case dispositionPendingOnly: - *g.batch2 = append(*g.batch2, miscPair(addr, v2)) - recordMiscLatest(g.latest, addr, v2) + *g.batch2 = append(*g.batch2, legacyPair(addr, v2)) + recordLegacyLatest(g.latest, addr, v2) case dispositionOverlap: - *g.batch1 = append(*g.batch1, miscPair(addr, v1)) - *g.batch2 = append(*g.batch2, miscPair(addr, v2)) - recordMiscLatest(g.latest, addr, v2) + *g.batch1 = append(*g.batch1, legacyPair(addr, v1)) + *g.batch2 = append(*g.batch2, legacyPair(addr, v2)) + recordLegacyLatest(g.latest, addr, v2) g.recordOverlap(key, bytes.Clone(v2)) case dispositionTombstone: - *g.batch1 = append(*g.batch1, miscPair(addr, v1)) - *g.batch2 = append(*g.batch2, miscDeletePair(addr)) - removeMiscLatest(g.latest, addr) + *g.batch1 = append(*g.batch1, legacyPair(addr, v1)) + *g.batch2 = append(*g.batch2, legacyDeletePair(addr)) + removeLegacyLatest(g.latest, addr) g.recordTombstone(key) } } @@ -882,14 +882,14 @@ func bankNamedCS(pairs ...*proto.KVPair) *proto.NamedChangeSet { } } -func miscPair(addr ktype.Address, val []byte) *proto.KVPair { +func legacyPair(addr ktype.Address, val []byte) *proto.KVPair { return &proto.KVPair{ Key: append([]byte{0x09}, addr[:]...), Value: val, } } -func miscDeletePair(addr ktype.Address) *proto.KVPair { +func legacyDeletePair(addr ktype.Address) *proto.KVPair { return &proto.KVPair{ Key: append([]byte{0x09}, addr[:]...), Delete: true, @@ -924,12 +924,12 @@ func removeCodeLatest(latest map[string]evmIteratorEntry, addr ktype.Address) { removeEvmLatest(latest, keys.BuildEVMKey(keys.EVMKeyCode, addr[:])) } -func recordMiscLatest(latest map[string]evmIteratorEntry, addr ktype.Address, val []byte) { +func recordLegacyLatest(latest map[string]evmIteratorEntry, addr ktype.Address, val []byte) { key := append([]byte{0x09}, addr[:]...) setEvmLatest(latest, key, bytes.Clone(val)) } -func removeMiscLatest(latest map[string]evmIteratorEntry, addr ktype.Address) { +func removeLegacyLatest(latest map[string]evmIteratorEntry, addr ktype.Address) { removeEvmLatest(latest, append([]byte{0x09}, addr[:]...)) } diff --git a/sei-db/state_db/sc/flatkv/store_lifecycle.go b/sei-db/state_db/sc/flatkv/store_lifecycle.go index 9631e0fb02..e08e48d2d1 100644 --- a/sei-db/state_db/sc/flatkv/store_lifecycle.go +++ b/sei-db/state_db/sc/flatkv/store_lifecycle.go @@ -14,7 +14,7 @@ import ( // isClosed reports whether the store's DB handles have been released. func (s *CommitStore) isClosed() bool { return s.metadataDB == nil && s.accountDB == nil && - s.codeDB == nil && s.storageDB == nil && s.miscDB == nil + s.codeDB == nil && s.storageDB == nil && s.legacyDB == nil } // closeDBsOnly closes all database handles and the WAL but retains the @@ -55,11 +55,11 @@ func (s *CommitStore) closeDBsOnly() error { s.accountDB = nil } - if s.miscDB != nil { - if err := s.miscDB.Close(); err != nil { - errs = append(errs, fmt.Errorf("miscDB close: %w", err)) + if s.legacyDB != nil { + if err := s.legacyDB.Close(); err != nil { + errs = append(errs, fmt.Errorf("legacyDB close: %w", err)) } - s.miscDB = nil + s.legacyDB = nil } s.localMeta = make(map[string]*ktype.LocalMeta) @@ -76,20 +76,10 @@ func (s *CommitStore) closeDBsOnly() error { func (s *CommitStore) Close() error { if s.readPool != nil { s.readPool.Close() - s.readPool = nil } if s.miscPool != nil { s.miscPool.Close() - s.miscPool = nil } - if s.ltHashPool != nil { - s.ltHashPool.Close() - s.ltHashPool = nil - } - // Calculator is bound to ltHashPool; drop it so a post-Close use cannot - // submit to a closed pool. resetPools recreates both together. - s.ltCalc = nil - err := s.closeDBsOnly() s.cancel() diff --git a/sei-db/state_db/sc/flatkv/store_meta.go b/sei-db/state_db/sc/flatkv/store_meta.go index f3c504ba26..bf838f2a14 100644 --- a/sei-db/state_db/sc/flatkv/store_meta.go +++ b/sei-db/state_db/sc/flatkv/store_meta.go @@ -55,93 +55,11 @@ func loadLocalMeta(db types.KeyValueDB) (*ktype.LocalMeta, error) { meta.LtHash = h } - moduleHashes, err := loadModuleLtHashes(db) - if err != nil { - return nil, err - } - meta.ModuleLtHashes = moduleHashes - - moduleStats, err := loadModuleStats(db) - if err != nil { - return nil, err - } - meta.ModuleStats = moduleStats - return meta, nil } -// loadModuleLtHashes reads every per-module LtHash key ("_meta/x:/hash") -// from db and returns them keyed by module name. Returns an empty map when the -// DB carries none (fresh store or a store written before per-module tracking). -func loadModuleLtHashes(db types.KeyValueDB) (map[string]*lthash.LtHash, error) { - iter, err := db.NewIter(&types.IterOptions{ - LowerBound: ktype.ModuleLtHashPrefixBytes, - UpperBound: ktype.PrefixEnd(ktype.ModuleLtHashPrefixBytes), - }) - if err != nil { - return nil, fmt.Errorf("open module lthash iterator: %w", err) - } - defer func() { _ = iter.Close() }() - - out := make(map[string]*lthash.LtHash) - for ; iter.Valid(); iter.Next() { - module, ok := ktype.ParseModuleLtHashKey(iter.Key()) - if !ok { - continue - } - h, err := lthash.Unmarshal(iter.Value()) - if err != nil { - return nil, fmt.Errorf("unmarshal module %q meta hash: %w", module, err) - } - out[module] = h - } - if err := iter.Error(); err != nil { - return nil, fmt.Errorf("iterate module lthash keys: %w", err) - } - return out, nil -} - -// loadModuleStats reads every per-module stats key ("_meta/x:/stats") -// from db and returns them keyed by module name. Returns an empty map when the -// DB carries none (fresh store or a store written before per-module stats -// tracking). -func loadModuleStats(db types.KeyValueDB) (map[string]lthash.ModuleStats, error) { - iter, err := db.NewIter(&types.IterOptions{ - LowerBound: ktype.ModuleLtHashPrefixBytes, - UpperBound: ktype.PrefixEnd(ktype.ModuleLtHashPrefixBytes), - }) - if err != nil { - return nil, fmt.Errorf("open module stats iterator: %w", err) - } - defer func() { _ = iter.Close() }() - - out := make(map[string]lthash.ModuleStats) - for ; iter.Valid(); iter.Next() { - module, ok := ktype.ParseModuleStatsKey(iter.Key()) - if !ok { - continue - } - st, err := lthash.UnmarshalModuleStats(iter.Value()) - if err != nil { - return nil, fmt.Errorf("unmarshal module %q stats: %w", module, err) - } - out[module] = st - } - if err := iter.Error(); err != nil { - return nil, fmt.Errorf("iterate module stats keys: %w", err) - } - return out, nil -} - -// writeLocalMetaToBatch writes per-DB metadata (version + per-DB root LtHash + -// per-module LtHashes + per-module stats) as separate keys. -func writeLocalMetaToBatch( - batch types.Batch, - version int64, - ltHash *lthash.LtHash, - moduleHashes map[string]*lthash.LtHash, - moduleStats map[string]lthash.ModuleStats, -) error { +// writeLocalMetaToBatch writes per-DB metadata (version + LtHash) as separate keys. +func writeLocalMetaToBatch(batch types.Batch, version int64, ltHash *lthash.LtHash) error { if err := batch.Set(ktype.MetaVersionKey, versionToBytes(version)); err != nil { return fmt.Errorf("set meta version: %w", err) } @@ -150,88 +68,9 @@ func writeLocalMetaToBatch( return fmt.Errorf("set meta hash: %w", err) } } - for module, h := range moduleHashes { - if h == nil { - continue - } - if err := batch.Set(ktype.ModuleLtHashKey(module), h.Marshal()); err != nil { - return fmt.Errorf("set module %q meta hash: %w", module, err) - } - } - for module, st := range moduleStats { - if err := batch.Set(ktype.ModuleStatsKey(module), st.Marshal()); err != nil { - return fmt.Errorf("set module %q stats: %w", module, err) - } - } - return nil -} - -// validatePerModuleMetadata enforces the load-time invariant that a persisted -// per-DB root is consistent with its per-module decomposition. FlatKV writes -// the per-DB root and its per-module hashes in the same atomic batch (see -// writeLocalMetaToBatch / commitBatches), so a correctly written store always -// satisfies SumModuleHashes(ModuleLtHashes) == LtHash. -// -// Two failure modes are rejected here: -// 1. Non-identity root with an empty module map — the store predates -// per-module hashing; migration is intentionally unsupported. -// 2. Module map present but its homomorphic sum does not equal the root — -// incomplete / drifted bookkeeping (e.g. a subset of module /hash keys -// lost while the root survived). On the next write, -// HashCalculator.Compute rebuilds the root as SumModuleHashes over that -// incomplete map, silently dropping a module's contribution to the -// per-DB root and thus the global store hash / AppHash. -// -// Fail loudly at load instead of corrupting consensus-critical state. -func validatePerModuleMetadata(dbDir string, meta *ktype.LocalMeta) error { - if meta == nil || meta.LtHash == nil { - return nil - } - if len(meta.ModuleLtHashes) == 0 { - if meta.LtHash.IsZero() { - return nil - } - return fmt.Errorf( - "flatkv: %s carries a non-identity per-DB LtHash root but no "+ - "per-module metadata; this store predates per-module hashing "+ - "and cannot be opened (migration is intentionally unsupported — "+ - "recreate the store from a snapshot or genesis)", - dbDir, - ) - } - sum := lthash.SumModuleHashes(meta.ModuleLtHashes) - if !meta.LtHash.Equal(sum) { - return fmt.Errorf( - "flatkv: %s per-module hashes do not sum to per-DB root (root=%x sum=%x)", - dbDir, meta.LtHash.Checksum(), sum.Checksum(), - ) - } return nil } -// cloneModuleHashes returns a deep copy of a per-module hash map (cloning each -// LtHash). A nil or empty source yields a fresh empty map. -func cloneModuleHashes(src map[string]*lthash.LtHash) map[string]*lthash.LtHash { - dst := make(map[string]*lthash.LtHash, len(src)) - for module, h := range src { - if h != nil { - dst[module] = h.Clone() - } - } - return dst -} - -// cloneModuleStats returns a copy of a per-module stats map. ModuleStats is a -// value type, so a per-entry copy is a full copy. A nil or empty source yields -// a fresh empty map. -func cloneModuleStats(src map[string]lthash.ModuleStats) map[string]lthash.ModuleStats { - dst := make(map[string]lthash.ModuleStats, len(src)) - for module, st := range src { - dst[module] = st - } - return dst -} - // loadGlobalVersion reads the global committed version from metadata DB. // Returns 0 if not found (fresh start). func (s *CommitStore) loadGlobalVersion() (int64, error) { @@ -312,26 +151,6 @@ func newPerDBLtHashMap() map[string]*lthash.LtHash { return m } -// newPerDBModuleLtHashMap returns a map with a fresh empty per-module hash map -// for each data DB. Modules are added lazily as their keys are first written. -func newPerDBModuleLtHashMap() map[string]map[string]*lthash.LtHash { - m := make(map[string]map[string]*lthash.LtHash, len(dataDBDirs)) - for _, dbDir := range dataDBDirs { - m[dbDir] = make(map[string]*lthash.LtHash) - } - return m -} - -// newPerDBModuleStatsMap returns a map with a fresh empty per-module stats map -// for each data DB. Modules are added lazily as their keys are first written. -func newPerDBModuleStatsMap() map[string]map[string]lthash.ModuleStats { - m := make(map[string]map[string]lthash.ModuleStats, len(dataDBDirs)) - for _, dbDir := range dataDBDirs { - m[dbDir] = make(map[string]lthash.ModuleStats) - } - return m -} - // SetInitialVersion seeds the store so that the next Commit produces // initialVersion. Mirrors memiavl.DB.SetInitialVersion: only valid on a // truly fresh store (committedVersion == 0 and no prior commits), rejected @@ -393,10 +212,8 @@ func (s *CommitStore) SetInitialVersion(initialVersion int64) error { ltHash = lthash.New() s.perDBWorkingLtHash[ndb.dir] = ltHash } - moduleHashes := s.perDBModuleWorkingLtHash[ndb.dir] - moduleStats := s.perDBModuleWorkingStats[ndb.dir] batch := ndb.db.NewBatch() - if err := writeLocalMetaToBatch(batch, seededVersion, ltHash, moduleHashes, moduleStats); err != nil { + if err := writeLocalMetaToBatch(batch, seededVersion, ltHash); err != nil { _ = batch.Close() return fmt.Errorf("flatkv: SetInitialVersion: prepare %s local meta: %w", ndb.dir, err) } @@ -408,8 +225,6 @@ func (s *CommitStore) SetInitialVersion(initialVersion int64) error { s.localMeta[ndb.dir] = &ktype.LocalMeta{ CommittedVersion: seededVersion, LtHash: ltHash.Clone(), - ModuleLtHashes: cloneModuleHashes(moduleHashes), - ModuleStats: cloneModuleStats(moduleStats), } } diff --git a/sei-db/state_db/sc/flatkv/store_meta_test.go b/sei-db/state_db/sc/flatkv/store_meta_test.go index 781f2fa5de..3a94ce3243 100644 --- a/sei-db/state_db/sc/flatkv/store_meta_test.go +++ b/sei-db/state_db/sc/flatkv/store_meta_test.go @@ -56,125 +56,6 @@ func TestLoadLocalMeta(t *testing.T) { }) } -// TestValidatePerModuleMetadata pins the load-time guard's decision matrix: -// empty module map with a non-identity root (pre-per-module store) and a -// non-empty module map that does not sum to the root (incomplete / drifted -// bookkeeping) are rejected; both would otherwise silently corrupt the -// per-DB root — and thus the global store hash / AppHash — on the first write. -func TestValidatePerModuleMetadata(t *testing.T) { - nonZero, _ := lthash.ComputeLtHash(nil, []lthash.KVPairWithLastValue{ - {Key: []byte("k"), Value: []byte("v")}, - }) - require.False(t, nonZero.IsZero(), "precondition: crafted root must be non-identity") - - other, _ := lthash.ComputeLtHash(nil, []lthash.KVPairWithLastValue{ - {Key: []byte("other"), Value: []byte("w")}, - }) - require.False(t, other.IsZero()) - require.False(t, nonZero.Equal(other), "precondition: distinct hashes") - - // Two-module root: sum equals root only when both modules are present. - combined := nonZero.Clone() - combined.MixIn(other) - - cases := []struct { - name string - meta *ktype.LocalMeta - wantErrSub string // empty => expect success - }{ - {"nil meta", nil, ""}, - {"nil root", &ktype.LocalMeta{}, ""}, - {"identity root, no modules", &ktype.LocalMeta{LtHash: lthash.New()}, ""}, - { - "non-identity root with matching modules", - &ktype.LocalMeta{LtHash: nonZero.Clone(), ModuleLtHashes: map[string]*lthash.LtHash{"EVM": nonZero.Clone()}}, - "", - }, - { - "multi-module root with matching modules", - &ktype.LocalMeta{ - LtHash: combined.Clone(), - ModuleLtHashes: map[string]*lthash.LtHash{ - "EVM": nonZero.Clone(), - "bank": other.Clone(), - }, - }, - "", - }, - {"non-identity root without modules", &ktype.LocalMeta{LtHash: nonZero.Clone()}, "predates per-module hashing"}, - { - "modules do not sum to root", - &ktype.LocalMeta{LtHash: combined.Clone(), ModuleLtHashes: map[string]*lthash.LtHash{"EVM": nonZero.Clone()}}, - "do not sum to per-DB root", - }, - { - "identity root with non-zero modules", - &ktype.LocalMeta{LtHash: lthash.New(), ModuleLtHashes: map[string]*lthash.LtHash{"EVM": nonZero.Clone()}}, - "do not sum to per-DB root", - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - err := validatePerModuleMetadata(storageDBDir, tc.meta) - if tc.wantErrSub != "" { - require.Error(t, err) - require.Contains(t, err.Error(), tc.wantErrSub) - } else { - require.NoError(t, err) - } - }) - } -} - -// TestLoadRejectsStoreMissingPerModuleMetadata drives the guard through the real -// load path: it commits data, strips the per-module hash keys off disk to -// imitate a pre-per-module store, and confirms reopening fails loudly instead of -// discarding the pre-existing root on the next write. -func TestLoadRejectsStoreMissingPerModuleMetadata(t *testing.T) { - dir := t.TempDir() - dbDir := filepath.Join(dir, flatkvRootDir) - - cfg := config.DefaultConfig() - cfg.DataDir = dbDir - s, err := NewCommitStore(t.Context(), cfg) - require.NoError(t, err) - _, err = s.LoadVersion(0, false) - require.NoError(t, err) - - // A committed EVM storage entry gives storageDB a non-identity per-DB root - // plus an "EVM" per-module hash. - commitStorageEntry(t, s, ktype.Address{0x01}, ktype.Slot{0x01}, []byte{0xAA}) - - // Simulate a store written before per-module hashing: strip every - // per-module meta key (hashes + stats) while keeping the per-DB root. - iter, err := s.storageDB.NewIter(&types.IterOptions{ - LowerBound: ktype.ModuleLtHashPrefixBytes, - UpperBound: ktype.PrefixEnd(ktype.ModuleLtHashPrefixBytes), - }) - require.NoError(t, err) - var keys [][]byte - for ; iter.Valid(); iter.Next() { - keys = append(keys, append([]byte(nil), iter.Key()...)) - } - require.NoError(t, iter.Error()) - require.NoError(t, iter.Close()) - require.NotEmpty(t, keys, "precondition: storageDB must carry per-module meta keys") - for _, k := range keys { - require.NoError(t, s.storageDB.Delete(k, types.WriteOptions{})) - } - require.NoError(t, s.Close()) - - // Reopening must reject the tampered store loudly rather than corrupt it. - cfg2 := config.DefaultConfig() - cfg2.DataDir = dbDir - s2, err := NewCommitStore(context.Background(), cfg2) - require.NoError(t, err) - defer s2.Close() - _, err = s2.LoadVersion(0, false) - require.Error(t, err) - require.Contains(t, err.Error(), "predates per-module hashing") -} - func TestStoreCommitBatchesUpdatesLocalMeta(t *testing.T) { s := setupTestStore(t) defer s.Close() diff --git a/sei-db/state_db/sc/flatkv/store_read.go b/sei-db/state_db/sc/flatkv/store_read.go index c999ee2c12..42e980bba1 100644 --- a/sei-db/state_db/sc/flatkv/store_read.go +++ b/sei-db/state_db/sc/flatkv/store_read.go @@ -13,20 +13,20 @@ import ( // Get returns the value for the given key within the specified module. // For EVM keys (moduleName == keys.EVMStoreKey), the key is a prefix-encoded -// EVM key routed internally to account/storage/code/misc DBs. -// For non-EVM modules, the key is read from misc storage with the module prefix. +// EVM key routed internally to account/storage/code/legacy DBs. +// For non-EVM modules, the key is read from legacy storage with the module prefix. // Returns (value, true) if found, (nil, false) if not found. // Panics on I/O errors or unsupported key types. func (s *CommitStore) Get(moduleName string, key []byte) ([]byte, bool) { // Read lock: the internal getters (getAccountData, getStorageData, - // getCodeData, getMiscData) read the pending-writes maps, which + // getCodeData, getLegacyData) read the pending-writes maps, which // ApplyChangeSets/Commit mutate under the write lock. Has delegates to Get // and must not take its own lock (RWMutex read locks are not reentrant). s.mu.RLock() defer s.mu.RUnlock() if moduleName != keys.EVMStoreKey { - value, err := s.getMiscValue(moduleName, key) + value, err := s.getLegacyValue(moduleName, key) if err != nil { panic(fmt.Sprintf("flatkv: Get module=%s key %x: %v", moduleName, key, err)) } @@ -74,10 +74,10 @@ func (s *CommitStore) Get(moduleName string, key []byte) ([]byte, bool) { } return value, value != nil - case keys.EVMKeyMisc: - value, err := s.getMiscValue(keys.EVMStoreKey, keyBytes) + case keys.EVMKeyLegacy: + value, err := s.getLegacyValue(keys.EVMStoreKey, keyBytes) if err != nil { - panic(fmt.Sprintf("flatkv: Get misc key %x: %v", key, err)) + panic(fmt.Sprintf("flatkv: Get legacy key %x: %v", key, err)) } return value, value != nil @@ -87,7 +87,7 @@ func (s *CommitStore) Get(moduleName string, key []byte) ([]byte, bool) { } // GetBlockHeightModified returns the block height at which the key was last modified. -// Only supported for EVM keys; non-EVM misc data does not track block height. +// Only supported for EVM keys; non-EVM legacy data does not track block height. // If not found, returns (-1, false, nil). func (s *CommitStore) GetBlockHeightModified(moduleName string, key []byte) (int64, bool, error) { // Read lock: the internal getters (getStorageData, getAccountData, @@ -213,12 +213,12 @@ func (s *CommitStore) getCodeValue(key []byte) ([]byte, error) { return cd.GetBytecode(), nil } -func (s *CommitStore) getMiscData(moduleName string, keyBytes []byte) (*vtype.MiscData, error) { - return readFromDB(ktype.ModulePhysicalKey(moduleName, keyBytes), s.miscWrites, s.miscDB, vtype.DeserializeMiscData, "miscDB") +func (s *CommitStore) getLegacyData(moduleName string, keyBytes []byte) (*vtype.LegacyData, error) { + return readFromDB(ktype.ModulePhysicalKey(moduleName, keyBytes), s.legacyWrites, s.legacyDB, vtype.DeserializeLegacyData, "legacyDB") } -func (s *CommitStore) getMiscValue(moduleName string, key []byte) ([]byte, error) { - ld, err := s.getMiscData(moduleName, key) +func (s *CommitStore) getLegacyValue(moduleName string, key []byte) ([]byte, error) { + ld, err := s.getLegacyData(moduleName, key) if err != nil { return nil, err } diff --git a/sei-db/state_db/sc/flatkv/store_read_test.go b/sei-db/state_db/sc/flatkv/store_read_test.go index 4308a1554f..f110278fd9 100644 --- a/sei-db/state_db/sc/flatkv/store_read_test.go +++ b/sei-db/state_db/sc/flatkv/store_read_test.go @@ -126,62 +126,62 @@ func TestStoreHas(t *testing.T) { } // ============================================================================= -// Misc Key Get Tests +// Legacy Key Get Tests // ============================================================================= -func TestStoreGetMiscPendingWrites(t *testing.T) { +func TestStoreGetLegacyPendingWrites(t *testing.T) { s := setupTestStore(t) defer s.Close() addr := ktype.Address{0xEE} - miscKey := append([]byte{0x09}, addr[:]...) + legacyKey := append([]byte{0x09}, addr[:]...) // Not found initially - _, found := s.Get(keys.EVMStoreKey, miscKey) + _, found := s.Get(keys.EVMStoreKey, legacyKey) require.False(t, found) // Apply changeset - cs := makeChangeSet(miscKey, []byte{0x00, 0x40}, false) + cs := makeChangeSet(legacyKey, []byte{0x00, 0x40}, false) require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{cs})) // Should be readable from pending writes - got, found := s.Get(keys.EVMStoreKey, miscKey) + got, found := s.Get(keys.EVMStoreKey, legacyKey) require.True(t, found) require.Equal(t, []byte{0x00, 0x40}, got) // Commit and still readable commitAndCheck(t, s) - got, found = s.Get(keys.EVMStoreKey, miscKey) + got, found = s.Get(keys.EVMStoreKey, legacyKey) require.True(t, found) require.Equal(t, []byte{0x00, 0x40}, got) } -func TestStoreGetMiscPendingDelete(t *testing.T) { +func TestStoreGetLegacyPendingDelete(t *testing.T) { s := setupTestStore(t) defer s.Close() addr := ktype.Address{0xFF} - miscKey := append([]byte{0x09}, addr[:]...) + legacyKey := append([]byte{0x09}, addr[:]...) // Write and commit - cs1 := makeChangeSet(miscKey, []byte{0x00, 0x80}, false) + cs1 := makeChangeSet(legacyKey, []byte{0x00, 0x80}, false) require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{cs1})) commitAndCheck(t, s) - _, found := s.Get(keys.EVMStoreKey, miscKey) + _, found := s.Get(keys.EVMStoreKey, legacyKey) require.True(t, found) // Apply delete (pending) - cs2 := makeChangeSet(miscKey, nil, true) + cs2 := makeChangeSet(legacyKey, nil, true) require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{cs2})) // Should not be found (pending delete) - _, found = s.Get(keys.EVMStoreKey, miscKey) + _, found = s.Get(keys.EVMStoreKey, legacyKey) require.False(t, found) // Commit delete commitAndCheck(t, s) - _, found = s.Get(keys.EVMStoreKey, miscKey) + _, found = s.Get(keys.EVMStoreKey, legacyKey) require.False(t, found) } @@ -230,8 +230,8 @@ func TestGetAllKeyTypesFromCommittedDB(t *testing.T) { ch := codeHashN(0xBB) bytecode := []byte{0x60, 0x80, 0x60, 0x40} storageVal := []byte{0x42} - miscKey := append([]byte{0x09}, addr[:]...) - miscVal := []byte{0x99, 0x88} + legacyKey := append([]byte{0x09}, addr[:]...) + legacyVal := []byte{0x99, 0x88} require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{ namedCS( @@ -240,7 +240,7 @@ func TestGetAllKeyTypesFromCommittedDB(t *testing.T) { codeHashPair(addr, ch), codePair(addr, bytecode), ), - makeChangeSet(miscKey, miscVal, false), + makeChangeSet(legacyKey, legacyVal, false), })) commitAndCheck(t, s) @@ -264,10 +264,10 @@ func TestGetAllKeyTypesFromCommittedDB(t *testing.T) { require.True(t, found, "code should be found") require.Equal(t, bytecode, got) - // Misc - got, found = s.Get(keys.EVMStoreKey, miscKey) - require.True(t, found, "misc should be found") - require.Equal(t, miscVal, got) + // Legacy + got, found = s.Get(keys.EVMStoreKey, legacyKey) + require.True(t, found, "legacy should be found") + require.Equal(t, legacyVal, got) // Has should match found = s.Has(keys.EVMStoreKey, keys.BuildEVMKey(keys.EVMKeyStorage, ktype.StorageKey(addr, slot))) @@ -278,7 +278,7 @@ func TestGetAllKeyTypesFromCommittedDB(t *testing.T) { require.True(t, found) found = s.Has(keys.EVMStoreKey, keys.BuildEVMKey(keys.EVMKeyCode, addr[:])) require.True(t, found) - found = s.Has(keys.EVMStoreKey, miscKey) + found = s.Has(keys.EVMStoreKey, legacyKey) require.True(t, found) } @@ -394,7 +394,7 @@ func TestGetUnknownKeyTypes(t *testing.T) { } // Non-empty keys that don't match a known prefix are classified as - // EVMKeyMisc, which is a supported type — Get/Has should not panic. + // EVMKeyLegacy, which is a supported type — Get/Has should not panic. for _, tc := range []struct { name string key []byte @@ -586,7 +586,7 @@ func TestGetAfterReopenAllKeyTypes(t *testing.T) { slot := slotN(0x01) ch := codeHashN(0xAA) bytecode := []byte{0x60, 0x80} - miscKey := append([]byte{0x09}, addr[:]...) + legacyKey := append([]byte{0x09}, addr[:]...) // Phase 1: write everything and close cfg := config.DefaultTestConfig(t) @@ -604,7 +604,7 @@ func TestGetAfterReopenAllKeyTypes(t *testing.T) { codePair(addr, bytecode), storagePair(addr, slot, []byte{0x42}), ), - makeChangeSet(miscKey, []byte{0x77}, false), + makeChangeSet(legacyKey, []byte{0x77}, false), })) _, err = s1.Commit() require.NoError(t, err) @@ -635,8 +635,8 @@ func TestGetAfterReopenAllKeyTypes(t *testing.T) { require.True(t, found, "code should survive reopen") require.Equal(t, bytecode, got) - got, found = s2.Get(keys.EVMStoreKey, miscKey) - require.True(t, found, "misc should survive reopen") + got, found = s2.Get(keys.EVMStoreKey, legacyKey) + require.True(t, found, "legacy should survive reopen") require.Equal(t, []byte{0x77}, got) } diff --git a/sei-db/state_db/sc/flatkv/store_test.go b/sei-db/state_db/sc/flatkv/store_test.go index ed5a897617..7ff3b1191e 100644 --- a/sei-db/state_db/sc/flatkv/store_test.go +++ b/sei-db/state_db/sc/flatkv/store_test.go @@ -54,7 +54,7 @@ func TestInitializeDataDirectoriesPropagatesPebbleMetrics(t *testing.T) { cfg.AccountDBConfig.EnableMetrics = true cfg.CodeDBConfig.EnableMetrics = true cfg.StorageDBConfig.EnableMetrics = true - cfg.MiscDBConfig.EnableMetrics = true + cfg.LegacyDBConfig.EnableMetrics = true cfg.MetadataDBConfig.EnableMetrics = true InitializeDataDirectories(cfg) @@ -62,7 +62,7 @@ func TestInitializeDataDirectoriesPropagatesPebbleMetrics(t *testing.T) { require.False(t, cfg.AccountDBConfig.EnableMetrics) require.False(t, cfg.CodeDBConfig.EnableMetrics) require.False(t, cfg.StorageDBConfig.EnableMetrics) - require.False(t, cfg.MiscDBConfig.EnableMetrics) + require.False(t, cfg.LegacyDBConfig.EnableMetrics) require.False(t, cfg.MetadataDBConfig.EnableMetrics) } @@ -201,24 +201,6 @@ func TestStoreEmptyChangesets(t *testing.T) { require.Equal(t, int64(1), s.Version()) } -// TestStoreApplyRejectsEmptyModuleName guards against a non-EVM changeset with -// an empty Name. An empty module folds into the physical key as "/"+key, -// whose per-module meta key ("_meta/x:/hash") ParseModuleLtHashKey rejects on -// reload — silently accepting it here would make the store permanently -// unopenable rather than failing fast at Apply time. -func TestStoreApplyRejectsEmptyModuleName(t *testing.T) { - s := setupTestStore(t) - defer s.Close() - - cs := &proto.NamedChangeSet{ - Name: "", - Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{{Key: []byte("k"), Value: []byte("v")}}}, - } - - err := s.ApplyChangeSets([]*proto.NamedChangeSet{cs}) - require.ErrorContains(t, err, "empty module name") -} - func TestStoreClearsPendingAfterCommit(t *testing.T) { s := setupTestStore(t) defer s.Close() @@ -1325,7 +1307,7 @@ func TestCrashRecoverySkewedPerDBVersions(t *testing.T) { // LtHash. This simulates a crash where the version watermark wasn't // persisted but the actual data and hash are intact. batch := s.accountDB.NewBatch() - require.NoError(t, writeLocalMetaToBatch(batch, 4, savedAccountLtHash, s.perDBModuleWorkingLtHash[accountDBDir], s.perDBModuleWorkingStats[accountDBDir])) + require.NoError(t, writeLocalMetaToBatch(batch, 4, savedAccountLtHash)) require.NoError(t, batch.Commit(types.WriteOptions{Sync: true})) _ = batch.Close() @@ -1379,7 +1361,7 @@ func TestCrashRecoveryGlobalMetadataAheadOfDataDBs(t *testing.T) { // Simulate crash: storageDB only flushed v3 (version watermark behind). batch := s.storageDB.NewBatch() - require.NoError(t, writeLocalMetaToBatch(batch, 3, savedStorageLtHash, s.perDBModuleWorkingLtHash[storageDBDir], s.perDBModuleWorkingStats[storageDBDir])) + require.NoError(t, writeLocalMetaToBatch(batch, 3, savedStorageLtHash)) require.NoError(t, batch.Commit(types.WriteOptions{Sync: true})) _ = batch.Close() @@ -1513,7 +1495,7 @@ func TestCrashRecoveryCorruptedAccountValueInDB(t *testing.T) { _ = batch.Close() // Next ApplyChangeSets touching this account should detect the corruption - // when deserializing the old account value (deserializeAccountOld). + // during batchReadOldValues. cs2 := &proto.NamedChangeSet{ Name: "evm", Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{noncePair(addr, 99)}}, @@ -1754,7 +1736,7 @@ func TestInitializeDataDirectories(t *testing.T) { cfg.AccountDBConfig.DataDir = "" cfg.CodeDBConfig.DataDir = "" cfg.StorageDBConfig.DataDir = "" - cfg.MiscDBConfig.DataDir = "" + cfg.LegacyDBConfig.DataDir = "" cfg.MetadataDBConfig.DataDir = "" InitializeDataDirectories(cfg) @@ -1762,7 +1744,7 @@ func TestInitializeDataDirectories(t *testing.T) { require.Equal(t, "/base/flatkv/working/account", cfg.AccountDBConfig.DataDir) require.Equal(t, "/base/flatkv/working/code", cfg.CodeDBConfig.DataDir) require.Equal(t, "/base/flatkv/working/storage", cfg.StorageDBConfig.DataDir) - require.Equal(t, "/base/flatkv/working/misc", cfg.MiscDBConfig.DataDir) + require.Equal(t, "/base/flatkv/working/legacy", cfg.LegacyDBConfig.DataDir) require.Equal(t, "/base/flatkv/working/metadata", cfg.MetadataDBConfig.DataDir) } diff --git a/sei-db/state_db/sc/flatkv/store_write.go b/sei-db/state_db/sc/flatkv/store_write.go index eaee86f44c..4b8c807362 100644 --- a/sei-db/state_db/sc/flatkv/store_write.go +++ b/sei-db/state_db/sc/flatkv/store_write.go @@ -1,12 +1,12 @@ package flatkv import ( - "bytes" "errors" "fmt" "sync" "time" + "github.com/sei-protocol/sei-chain/sei-db/common/keys" "github.com/sei-protocol/sei-chain/sei-db/db_engine/types" "github.com/sei-protocol/sei-chain/sei-db/proto" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/ktype" @@ -33,7 +33,7 @@ func (s *CommitStore) Commit() (version int64, err error) { pendingAccount := len(s.accountWrites) pendingCode := len(s.codeWrites) pendingStorage := len(s.storageWrites) - pendingMisc := len(s.miscWrites) + pendingLegacy := len(s.legacyWrites) defer func() { otelMetrics.CommitLatency.Record(s.ctx, secondsSince(start), metric.WithAttributes(successAttr(err))) @@ -43,7 +43,7 @@ func (s *CommitStore) Commit() (version int64, err error) { "pendingAccount", pendingAccount, "pendingCode", pendingCode, "pendingStorage", pendingStorage, - "pendingMisc", pendingMisc, + "pendingLegacy", pendingLegacy, "elapsed", time.Since(start), "err", err) } @@ -94,7 +94,7 @@ func (s *CommitStore) Commit() (version int64, err error) { recordPendingWrites(s.ctx, accountDBDir, 0) recordPendingWrites(s.ctx, codeDBDir, 0) recordPendingWrites(s.ctx, storageDBDir, 0) - recordPendingWrites(s.ctx, miscDBDir, 0) + recordPendingWrites(s.ctx, legacyDBDir, 0) // Periodic snapshot so WAL stays bounded and restarts are fast. if s.config.SnapshotInterval > 0 && version%int64(s.config.SnapshotInterval) == 0 { @@ -113,7 +113,10 @@ func (s *CommitStore) Commit() (version int64, err error) { otelMetrics.CurrentVersion.Record(s.ctx, version) logger.Info("FlatKV Commit complete", "version", version, - "totalWriteCount", pendingAccount+pendingCode+pendingStorage+pendingMisc, + "pendingAccount", pendingAccount, + "pendingCode", pendingCode, + "pendingStorage", pendingStorage, + "pendingLegacy", pendingLegacy, "elapsed", time.Since(start)) return version, nil } @@ -123,7 +126,7 @@ func (s *CommitStore) flushAllDBs() error { errs := make([]error, 4) var wg sync.WaitGroup wg.Add(4) - names := [4]string{accountDBDir, codeDBDir, storageDBDir, miscDBDir} + names := [4]string{accountDBDir, codeDBDir, storageDBDir, legacyDBDir} for i, db := range s.dataDBs() { s.miscPool.Submit(func() { defer wg.Done() @@ -146,7 +149,7 @@ func (s *CommitStore) clearPendingWrites() { s.accountWrites = make(map[string]*vtype.AccountData, len(s.accountWrites)) s.codeWrites = make(map[string]*vtype.CodeData, len(s.codeWrites)) s.storageWrites = make(map[string]*vtype.StorageData, len(s.storageWrites)) - s.miscWrites = make(map[string]*vtype.MiscData, len(s.miscWrites)) + s.legacyWrites = make(map[string]*vtype.LegacyData, len(s.legacyWrites)) s.pendingChangeSets = make([]*proto.NamedChangeSet, 0, len(s.pendingChangeSets)) } @@ -175,16 +178,16 @@ func (s *CommitStore) commitBatches(version int64) error { prep func() (types.Batch, error) }{ {accountDBDir, "commit_account_db_prepare", func() (types.Batch, error) { - return prepareBatch(s.accountDB, s.accountWrites, version, s.localMeta[accountDBDir], s.perDBWorkingLtHash[accountDBDir], s.perDBModuleWorkingLtHash[accountDBDir], s.perDBModuleWorkingStats[accountDBDir], "accountDB") + return prepareBatch(s.accountDB, s.accountWrites, version, s.localMeta[accountDBDir], s.perDBWorkingLtHash[accountDBDir], "accountDB") }}, {codeDBDir, "commit_code_db_prepare", func() (types.Batch, error) { - return prepareBatch(s.codeDB, s.codeWrites, version, s.localMeta[codeDBDir], s.perDBWorkingLtHash[codeDBDir], s.perDBModuleWorkingLtHash[codeDBDir], s.perDBModuleWorkingStats[codeDBDir], "codeDB") + return prepareBatch(s.codeDB, s.codeWrites, version, s.localMeta[codeDBDir], s.perDBWorkingLtHash[codeDBDir], "codeDB") }}, {storageDBDir, "commit_storage_db_prepare", func() (types.Batch, error) { - return prepareBatch(s.storageDB, s.storageWrites, version, s.localMeta[storageDBDir], s.perDBWorkingLtHash[storageDBDir], s.perDBModuleWorkingLtHash[storageDBDir], s.perDBModuleWorkingStats[storageDBDir], "storageDB") + return prepareBatch(s.storageDB, s.storageWrites, version, s.localMeta[storageDBDir], s.perDBWorkingLtHash[storageDBDir], "storageDB") }}, - {miscDBDir, "commit_misc_db_prepare", func() (types.Batch, error) { - return prepareBatch(s.miscDB, s.miscWrites, version, s.localMeta[miscDBDir], s.perDBWorkingLtHash[miscDBDir], s.perDBModuleWorkingLtHash[miscDBDir], s.perDBModuleWorkingStats[miscDBDir], "miscDB") + {legacyDBDir, "commit_legacy_db_prepare", func() (types.Batch, error) { + return prepareBatch(s.legacyDB, s.legacyWrites, version, s.localMeta[legacyDBDir], s.perDBWorkingLtHash[legacyDBDir], "legacyDB") }}, } @@ -230,8 +233,6 @@ func (s *CommitStore) commitBatches(version int64) error { s.localMeta[p.dbDir] = &ktype.LocalMeta{ CommittedVersion: version, LtHash: s.perDBWorkingLtHash[p.dbDir].Clone(), - ModuleLtHashes: cloneModuleHashes(s.perDBModuleWorkingLtHash[p.dbDir]), - ModuleStats: cloneModuleStats(s.perDBModuleWorkingStats[p.dbDir]), } } return nil @@ -243,8 +244,6 @@ func prepareBatch[T vtype.VType]( version int64, localMeta *ktype.LocalMeta, ltHash *lthash.LtHash, - moduleHashes map[string]*lthash.LtHash, - moduleStats map[string]lthash.ModuleStats, dbName string, ) (types.Batch, error) { if len(writes) == 0 && version <= localMeta.CommittedVersion { @@ -267,110 +266,58 @@ func prepareBatch[T vtype.VType]( } } - if err := writeLocalMetaToBatch(batch, version, ltHash, moduleHashes, moduleStats); err != nil { + if err := writeLocalMetaToBatch(batch, version, ltHash); err != nil { _ = batch.Close() return nil, fmt.Errorf("%s local meta: %w", dbName, err) } return batch, nil } -// ReadOldValues implements lthash.OldValueReader. It returns the prior -// serialized value for each requested physical key of one data DB (dir), -// resolving pending same-block writes from memory and batch-reading the rest -// from disk. -// -// The returned bytes are the on-disk serialized form (or the serialized pending -// write). By the round-trip identity of the value serializers, these are the -// exact bytes that were folded into the LtHash when the key was last written, -// so unmixing them cancels that contribution precisely. A key that resolves to -// a pending deletion (or is absent) is mapped to a nil value: "resolved, but no -// bytes to unmix". -// -// Callers must hold s.mu (the commit path does): this reads the pending-write -// overlay maps concurrently across dirs, but each dir touches a distinct map. -func (s *CommitStore) ReadOldValues(dir string, physKeys map[string]struct{}) (map[string][]byte, error) { - db, err := s.dataDBByDir(dir) - if err != nil { - return nil, err - } - - old := make(map[string][]byte, len(physKeys)) - batch := make(map[string]types.BatchGetResult, len(physKeys)) - for key := range physKeys { - if v, resolved := s.pendingOldSerialized(dir, key); resolved { - old[key] = v - } else { - batch[key] = types.BatchGetResult{} - } +// collectPendingReads partitions keys from changeMaps into those already +// buffered in pendingWrites (copied to old) and those needing a DB read +// (returned as a BatchGetResult map). +func collectPendingReads[T vtype.VType]( + pendingWrites map[string]T, + old map[string]T, + changeMaps ...map[string][]byte, +) map[string]types.BatchGetResult { + totalKeys := 0 + for _, changes := range changeMaps { + totalKeys += len(changes) } - - if len(batch) > 0 { - if err := db.BatchGet(batch); err != nil { - return nil, fmt.Errorf("%s batch get: %w", dir, err) - } - for k, v := range batch { - if v.Error != nil { - return nil, fmt.Errorf("%s batch read error for key %x: %w", dir, k, v.Error) - } - if v.IsFound() { - // v.Value may alias a pebble buffer reused after this call. - old[k] = bytes.Clone(v.Value) + batch := make(map[string]types.BatchGetResult, totalKeys) + for _, changes := range changeMaps { + for key := range changes { + if v, ok := pendingWrites[key]; ok { + old[key] = v + } else { + batch[key] = types.BatchGetResult{} } } } - return old, nil + return batch } -// pendingOldSerialized returns the serialized value of a key still buffered in -// this block's pending writes, to be used as its "old" value for the current -// apply. The bool reports whether the key was resolved from the pending overlay -// at all; a pending deletion resolves to (nil, true) since there is nothing to -// unmix (the prior committed value was already unmixed when the delete was -// applied earlier in the block). -func (s *CommitStore) pendingOldSerialized(dir, key string) ([]byte, bool) { - switch dir { - case accountDBDir: - if v, ok := s.accountWrites[key]; ok { - return serializedUnlessDelete(v), true - } - case codeDBDir: - if v, ok := s.codeWrites[key]; ok { - return serializedUnlessDelete(v), true - } - case storageDBDir: - if v, ok := s.storageWrites[key]; ok { - return serializedUnlessDelete(v), true +// deserializeBatchResults converts raw BatchGetResults into typed values. +func deserializeBatchResults[T vtype.VType]( + batch map[string]types.BatchGetResult, + old map[string]T, + deserialize func([]byte) (T, error), + dbName string, +) error { + for k, v := range batch { + if v.Error != nil { + return fmt.Errorf("%s batch read error for key %x: %w", dbName, k, v.Error) } - case miscDBDir: - if v, ok := s.miscWrites[key]; ok { - return serializedUnlessDelete(v), true + if v.IsFound() { + val, err := deserialize(v.Value) + if err != nil { + return fmt.Errorf("failed to deserialize %s data: %w", dbName, err) + } + old[k] = val } } - return nil, false -} - -// serializedUnlessDelete serializes v, or returns nil if v represents a -// deletion (nothing to unmix). -func serializedUnlessDelete[T vtype.VType](v T) []byte { - if v.IsDelete() { - return nil - } - return v.Serialize() -} - -// dataDBByDir returns the underlying KeyValueDB for a data DB dir. -func (s *CommitStore) dataDBByDir(dir string) (types.KeyValueDB, error) { - switch dir { - case accountDBDir: - return s.accountDB, nil - case codeDBDir: - return s.codeDB, nil - case storageDBDir: - return s.storageDB, nil - case miscDBDir: - return s.miscDB, nil - } - return nil, fmt.Errorf("unknown data DB dir %q", dir) + return nil } // rawKVPair is a raw physical key/value pair as stored on disk. @@ -385,10 +332,8 @@ type rawKVPair struct { func (s *CommitStore) FinalizeImport(version int64) error { syncOpt := types.WriteOptions{Sync: true} for _, ndb := range s.namedDataDBs() { - moduleHashes := s.perDBModuleWorkingLtHash[ndb.dir] - moduleStats := s.perDBModuleWorkingStats[ndb.dir] batch := ndb.db.NewBatch() - if err := writeLocalMetaToBatch(batch, version, s.perDBWorkingLtHash[ndb.dir], moduleHashes, moduleStats); err != nil { + if err := writeLocalMetaToBatch(batch, version, s.perDBWorkingLtHash[ndb.dir]); err != nil { _ = batch.Close() return fmt.Errorf("%s local meta: %w", ndb.dir, err) } @@ -400,8 +345,6 @@ func (s *CommitStore) FinalizeImport(version int64) error { s.localMeta[ndb.dir] = &ktype.LocalMeta{ CommittedVersion: version, LtHash: s.perDBWorkingLtHash[ndb.dir].Clone(), - ModuleLtHashes: cloneModuleHashes(moduleHashes), - ModuleStats: cloneModuleStats(moduleStats), } } @@ -417,3 +360,75 @@ func (s *CommitStore) FinalizeImport(version int64) error { } return nil } + +// batchReadOldValues returns the prior value for every key in changesByType. +// Pending writes are resolved from memory; the rest are batch-read from disk +// in parallel. +func (s *CommitStore) batchReadOldValues(changesByType map[keys.EVMKeyKind]map[string][]byte) ( + storageOld map[string]*vtype.StorageData, + accountOld map[string]*vtype.AccountData, + codeOld map[string]*vtype.CodeData, + legacyOld map[string]*vtype.LegacyData, + err error, +) { + start := time.Now() + defer func() { + otelMetrics.BatchReadOldValuesLatency.Record(s.ctx, secondsSince(start), + metric.WithAttributes(successAttr(err))) + }() + + storageOld = make(map[string]*vtype.StorageData, len(changesByType[keys.EVMKeyStorage])) + accountOld = make(map[string]*vtype.AccountData, len(changesByType[keys.EVMKeyNonce])+len(changesByType[keys.EVMKeyCodeHash])) + codeOld = make(map[string]*vtype.CodeData, len(changesByType[keys.EVMKeyCode])) + legacyOld = make(map[string]*vtype.LegacyData, len(changesByType[keys.EVMKeyLegacy])) + + storageBatch := collectPendingReads(s.storageWrites, storageOld, changesByType[keys.EVMKeyStorage]) + // TODO: add balance changeMap when balance key is supported. + accountBatch := collectPendingReads(s.accountWrites, accountOld, changesByType[keys.EVMKeyNonce], changesByType[keys.EVMKeyCodeHash]) + codeBatch := collectPendingReads(s.codeWrites, codeOld, changesByType[keys.EVMKeyCode]) + legacyBatch := collectPendingReads(s.legacyWrites, legacyOld, changesByType[keys.EVMKeyLegacy]) + + type readJob struct { + batch map[string]types.BatchGetResult + db types.KeyValueDB + } + jobs := [4]readJob{ + {storageBatch, s.storageDB}, + {accountBatch, s.accountDB}, + {codeBatch, s.codeDB}, + {legacyBatch, s.legacyDB}, + } + readErrs := make([]error, 4) + var wg sync.WaitGroup + for i := range jobs { + idx := i + job := jobs[i] + if len(job.batch) > 0 { + wg.Add(1) + s.miscPool.Submit(func() { + defer wg.Done() + readErrs[idx] = job.db.BatchGet(job.batch) + }) + } + } + wg.Wait() + + if err = errors.Join(readErrs...); err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to batch read old values: %w", err) + } + + if err = deserializeBatchResults(storageBatch, storageOld, vtype.DeserializeStorageData, "storageDB"); err != nil { + return nil, nil, nil, nil, fmt.Errorf("deserialize storageDB old values: %w", err) + } + if err = deserializeBatchResults(accountBatch, accountOld, vtype.DeserializeAccountData, "accountDB"); err != nil { + return nil, nil, nil, nil, fmt.Errorf("deserialize accountDB old values: %w", err) + } + if err = deserializeBatchResults(codeBatch, codeOld, vtype.DeserializeCodeData, "codeDB"); err != nil { + return nil, nil, nil, nil, fmt.Errorf("deserialize codeDB old values: %w", err) + } + if err = deserializeBatchResults(legacyBatch, legacyOld, vtype.DeserializeLegacyData, "legacyDB"); err != nil { + return nil, nil, nil, nil, fmt.Errorf("deserialize legacyDB old values: %w", err) + } + + return storageOld, accountOld, codeOld, legacyOld, nil +} diff --git a/sei-db/state_db/sc/flatkv/store_write_test.go b/sei-db/state_db/sc/flatkv/store_write_test.go index 7ed8edb1e7..78b8719ef4 100644 --- a/sei-db/state_db/sc/flatkv/store_write_test.go +++ b/sei-db/state_db/sc/flatkv/store_write_test.go @@ -61,7 +61,7 @@ func TestStoreWriteAllDBs(t *testing.T) { addr := ktype.Address{0x12, 0x34} slot := ktype.Slot{0x56, 0x78} - miscKey := append([]byte{0x09}, addr[:]...) + legacyKey := append([]byte{0x09}, addr[:]...) pairs := []*proto.KVPair{ // Storage key @@ -79,9 +79,9 @@ func TestStoreWriteAllDBs(t *testing.T) { Key: keys.BuildEVMKey(keys.EVMKeyCode, addr[:]), Value: []byte{0x60, 0x60, 0x60}, // some bytecode }, - // Misc key (codeSize: 0x09 || addr) + // Legacy key (codeSize: 0x09 || addr) { - Key: miscKey, + Key: legacyKey, Value: []byte{0x00, 0x03}, }, } @@ -120,10 +120,10 @@ func TestStoreWriteAllDBs(t *testing.T) { require.True(t, found, "Code should be found") require.Equal(t, []byte{0x60, 0x60, 0x60}, codeValue) - // Verify misc data persisted (via Store.Get which deserializes) - miscVal, found := s.Get(keys.EVMStoreKey, miscKey) - require.True(t, found, "Misc should be found") - require.Equal(t, []byte{0x00, 0x03}, miscVal) + // Verify legacy data persisted (via Store.Get which deserializes) + legacyVal, found := s.Get(keys.EVMStoreKey, legacyKey) + require.True(t, found, "Legacy should be found") + require.Equal(t, []byte{0x00, 0x03}, legacyVal) } func TestStoreWriteEmptyCommit(t *testing.T) { @@ -352,29 +352,29 @@ func TestAccountValueStorage(t *testing.T) { } // ============================================================================= -// Misc DB Write Tests +// Legacy DB Write Tests // ============================================================================= -func TestStoreWriteMiscKeys(t *testing.T) { +func TestStoreWriteLegacyKeys(t *testing.T) { s := setupTestStore(t) defer s.Close() addr := ktype.Address{0xAA} - // CodeSize key (0x09 || addr) goes to misc + // CodeSize key (0x09 || addr) goes to legacy codeSizeKey := append([]byte{0x09}, addr[:]...) codeSizeValue := []byte{0x00, 0x10} cs := makeChangeSet(codeSizeKey, codeSizeValue, false) require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{cs})) - // Should be in miscWrites pending buffer - require.Len(t, s.miscWrites, 1) + // Should be in legacyWrites pending buffer + require.Len(t, s.legacyWrites, 1) commitAndCheck(t, s) - // Verify miscDB LocalMeta is updated - require.Equal(t, int64(1), s.localMeta[miscDBDir].CommittedVersion) + // Verify legacyDB LocalMeta is updated + require.Equal(t, int64(1), s.localMeta[legacyDBDir].CommittedVersion) // Verify data persisted (via Store.Get which deserializes) got, found := s.Get(keys.EVMStoreKey, codeSizeKey) @@ -382,7 +382,7 @@ func TestStoreWriteMiscKeys(t *testing.T) { require.Equal(t, codeSizeValue, got) } -func TestStoreWriteMiscAndOptimizedKeys(t *testing.T) { +func TestStoreWriteLegacyAndOptimizedKeys(t *testing.T) { s := setupTestStore(t) defer s.Close() @@ -405,7 +405,7 @@ func TestStoreWriteMiscAndOptimizedKeys(t *testing.T) { Key: keys.BuildEVMKey(keys.EVMKeyCode, addr[:]), Value: []byte{0x60, 0x60, 0x60}, }, - // CodeSize → misc (0x09 || addr) + // CodeSize → legacy (0x09 || addr) { Key: append([]byte{0x09}, addr[:]...), Value: []byte{0x00, 0x03}, @@ -422,56 +422,56 @@ func TestStoreWriteMiscAndOptimizedKeys(t *testing.T) { requireAllLocalMetaAt(t, s, 1) - // Verify misc data persisted (via Store.Get which deserializes) + // Verify legacy data persisted (via Store.Get which deserializes) codeSizeKey := append([]byte{0x09}, addr[:]...) got, found := s.Get(keys.EVMStoreKey, codeSizeKey) require.True(t, found) require.Equal(t, []byte{0x00, 0x03}, got) } -func TestStoreWriteDeleteMiscKey(t *testing.T) { +func TestStoreWriteDeleteLegacyKey(t *testing.T) { s := setupTestStore(t) defer s.Close() addr := ktype.Address{0xCC} - miscKey := append([]byte{0x09}, addr[:]...) + legacyKey := append([]byte{0x09}, addr[:]...) // Write - cs1 := makeChangeSet(miscKey, []byte{0x00, 0x10}, false) + cs1 := makeChangeSet(legacyKey, []byte{0x00, 0x10}, false) require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{cs1})) commitAndCheck(t, s) // Verify exists - got, found := s.Get(keys.EVMStoreKey, miscKey) + got, found := s.Get(keys.EVMStoreKey, legacyKey) require.True(t, found) require.Equal(t, []byte{0x00, 0x10}, got) // Delete - cs2 := makeChangeSet(miscKey, nil, true) + cs2 := makeChangeSet(legacyKey, nil, true) require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{cs2})) commitAndCheck(t, s) // Should not be found - _, found = s.Get(keys.EVMStoreKey, miscKey) + _, found = s.Get(keys.EVMStoreKey, legacyKey) require.False(t, found) } -func TestStoreMiscKeyIncludedInLtHash(t *testing.T) { +func TestStoreLegacyKeyIncludedInLtHash(t *testing.T) { s := setupTestStore(t) defer s.Close() // Get initial hash hash1 := s.RootHash() - // Write a misc key + // Write a legacy key addr := ktype.Address{0xDD} - miscKey := append([]byte{0x09}, addr[:]...) - cs := makeChangeSet(miscKey, []byte{0x00, 0x20}, false) + legacyKey := append([]byte{0x09}, addr[:]...) + cs := makeChangeSet(legacyKey, []byte{0x00, 0x20}, false) require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{cs})) - // LtHash should change after applying misc key changeset + // LtHash should change after applying legacy key changeset hash2 := s.RootHash() - require.NotEqual(t, hash1, hash2, "LtHash should change when misc key is written") + require.NotEqual(t, hash1, hash2, "LtHash should change when legacy key is written") commitAndCheck(t, s) @@ -480,11 +480,11 @@ func TestStoreMiscKeyIncludedInLtHash(t *testing.T) { require.Equal(t, hash2, hash3) } -func TestStoreMiscEmptyCommitLocalMeta(t *testing.T) { +func TestStoreLegacyEmptyCommitLocalMeta(t *testing.T) { s := setupTestStore(t) defer s.Close() - // Commit with no writes — all DBs including misc should advance LocalMeta + // Commit with no writes — all DBs including legacy should advance LocalMeta emptyCS := &proto.NamedChangeSet{ Name: "evm", Changeset: proto.ChangeSet{Pairs: nil}, @@ -1489,7 +1489,7 @@ func requireAllLocalMetaAt(t *testing.T, s *CommitStore, ver int64) { require.Equal(t, ver, s.localMeta[storageDBDir].CommittedVersion) require.Equal(t, ver, s.localMeta[accountDBDir].CommittedVersion) require.Equal(t, ver, s.localMeta[codeDBDir].CommittedVersion) - require.Equal(t, ver, s.localMeta[miscDBDir].CommittedVersion) + require.Equal(t, ver, s.localMeta[legacyDBDir].CommittedVersion) } func TestApplyChangeSetsNilInput(t *testing.T) { @@ -1510,7 +1510,7 @@ func TestApplyChangeSetsEmptySlice(t *testing.T) { require.Equal(t, hashBefore, s.RootHash(), "empty slice should not change hash") } -func TestApplyChangeSetsNonEVMModuleRoutesToMisc(t *testing.T) { +func TestApplyChangeSetsNonEVMModuleRoutesToLegacy(t *testing.T) { s := setupTestStore(t) defer s.Close() @@ -1523,21 +1523,21 @@ func TestApplyChangeSetsNonEVMModuleRoutesToMisc(t *testing.T) { }}, } require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{cs})) - require.NotEqual(t, hashBefore, s.RootHash(), "misc-routed key changes hash") - require.Len(t, s.miscWrites, 1) + require.NotEqual(t, hashBefore, s.RootHash(), "legacy-routed key changes hash") + require.Len(t, s.legacyWrites, 1) require.Len(t, s.storageWrites, 0) require.Len(t, s.pendingChangeSets, 1) - // Physical key in miscWrites should be module-prefixed: "bank/some-bank-key" + // Physical key in legacyWrites should be module-prefixed: "bank/some-bank-key" physKey := string(ktype.ModulePhysicalKey("bank", []byte("some-bank-key"))) - _, found := s.miscWrites[physKey] - require.True(t, found, "miscWrites should contain module-prefixed key %q", physKey) + _, found := s.legacyWrites[physKey] + require.True(t, found, "legacyWrites should contain module-prefixed key %q", physKey) - // Persist and verify round-trip via raw miscDB lookup + // Persist and verify round-trip via raw legacyDB lookup commitAndCheck(t, s) - raw, err := s.miscDB.Get([]byte(physKey)) + raw, err := s.legacyDB.Get([]byte(physKey)) require.NoError(t, err) - require.NotNil(t, raw, "miscDB should persist module-prefixed key") + require.NotNil(t, raw, "legacyDB should persist module-prefixed key") } func TestApplyChangeSetsMixedEVMAndNonEVM(t *testing.T) { @@ -1571,11 +1571,11 @@ func TestApplyChangeSetsMixedEVMAndNonEVM(t *testing.T) { require.True(t, found) require.Equal(t, padLeft32(0x42), val) - // Bank key should be in miscWrites with module prefix. + // Bank key should be in legacyWrites with module prefix. bankPhysKey := string(ktype.ModulePhysicalKey("bank", []byte("bank-key"))) - _, found = s.miscWrites[bankPhysKey] - require.True(t, found, "bank key should be in miscWrites with module prefix") - require.Len(t, s.miscWrites, 1) + _, found = s.legacyWrites[bankPhysKey] + require.True(t, found, "bank key should be in legacyWrites with module prefix") + require.Len(t, s.legacyWrites, 1) } func TestApplyChangeSetsEmptyPairsVsNilPairs(t *testing.T) { @@ -1624,7 +1624,7 @@ func TestApplyChangeSetsInvalidAddressLength(t *testing.T) { // A well-formed nonce key: prefix(1) + addr(20) = 21 bytes. // Build one manually with correct prefix but wrong addr length. - // ParseEVMKey checks len(key) != len(noncePrefix)+20 and falls back to misc. + // ParseEVMKey checks len(key) != len(noncePrefix)+20 and falls back to legacy. // To actually trigger "invalid address length" in ApplyChangeSets, we need // ParseEVMKey to return EVMKeyNonce with wrong-length keyBytes. // This only happens for the correct total length. So instead, test via @@ -1634,7 +1634,7 @@ func TestApplyChangeSetsInvalidAddressLength(t *testing.T) { // Actually, ParseEVMKey always strips the prefix correctly for 21-byte keys. // The address will always be 20 bytes. So this error path is unreachable // through normal key construction. Instead, verify that malformed nonce keys - // (wrong total length) are routed to misc. + // (wrong total length) are routed to legacy. truncatedNonceKey := append([]byte{0x0a}, make([]byte, 15)...) // 16 bytes total cs := &proto.NamedChangeSet{ Name: "evm", @@ -1642,9 +1642,9 @@ func TestApplyChangeSetsInvalidAddressLength(t *testing.T) { {Key: truncatedNonceKey, Value: nonceBytes(1)}, }}, } - // Routed to EVMKeyMisc (not Nonce), so no address validation error. + // Routed to EVMKeyLegacy (not Nonce), so no address validation error. require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{cs})) - require.Len(t, s.miscWrites, 1, "malformed nonce key should be treated as misc") + require.Len(t, s.legacyWrites, 1, "malformed nonce key should be treated as legacy") require.Len(t, s.accountWrites, 0, "should not reach account path") } @@ -1689,13 +1689,13 @@ func TestApplyChangeSetsEVMKeyEmptySkipped(t *testing.T) { require.Error(t, s.ApplyChangeSets([]*proto.NamedChangeSet{cs})) } -func TestApplyChangeSetsNonPrefixedKeyGoesToMisc(t *testing.T) { +func TestApplyChangeSetsNonPrefixedKeyGoesToLegacy(t *testing.T) { s := setupTestStore(t) defer s.Close() hashBefore := s.RootHash() - // A key with an unrecognized prefix goes to EVMKeyMisc, not skipped. + // A key with an unrecognized prefix goes to EVMKeyLegacy, not skipped. cs := &proto.NamedChangeSet{ Name: "evm", Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{ @@ -1703,8 +1703,8 @@ func TestApplyChangeSetsNonPrefixedKeyGoesToMisc(t *testing.T) { }}, } require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{cs})) - require.NotEqual(t, hashBefore, s.RootHash(), "misc key changes hash") - require.Len(t, s.miscWrites, 1) + require.NotEqual(t, hashBefore, s.RootHash(), "legacy key changes hash") + require.Len(t, s.legacyWrites, 1) } func TestCommitWithoutPriorApply(t *testing.T) { diff --git a/sei-db/state_db/sc/flatkv/verify.go b/sei-db/state_db/sc/flatkv/verify.go index 49dbc98af2..164d4b3d82 100644 --- a/sei-db/state_db/sc/flatkv/verify.go +++ b/sei-db/state_db/sc/flatkv/verify.go @@ -9,17 +9,13 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" ) -// VerifyLtHash full-scans all four data DBs and checks the recomputed state -// against the store's maintained metadata. In addition to the global -// committedLtHash, it validates the per-DB, per-module decomposition that FlatKV -// now persists (per-module LtHashes and per-module key/byte stats), catching -// drift in that bookkeeping even when the global root still matches. Read-write -// stores with uncommitted ApplyChangeSets writes are rejected (the on-disk scan -// cannot see them). +// VerifyLtHash full-scans all four data DBs and checks the recomputed LtHash +// against the store's committedLtHash. Read-write stores with uncommitted +// ApplyChangeSets writes are rejected (the on-disk scan cannot see them). // -// Buffers one DB's worth of KVs in memory at a time and is not cancellable. -// Intended for tests and offline maintenance / migration checks; not suitable -// for online verification of production-sized state. +// Buffers every KV in memory (peak RSS ~2-3x on-disk size) and is not +// cancellable. Intended for tests and offline maintenance / migration checks; +// not suitable for online verification of production-sized state. func VerifyLtHash(s Store) error { cs, ok := s.(*CommitStore) if !ok { @@ -42,174 +38,45 @@ func verifyLtHashInternal(cs *CommitStore) error { ) } - // Recompute each DB's per-module hashes and stats from disk, validate the - // maintained per-module metadata against them, and accumulate the global - // root as the homomorphic sum of the derived per-DB roots. - global := lthash.New() - for _, ndb := range cs.namedDataDBs() { - if ndb.db == nil { - continue - } - scanHash, scanStats, err := scanDBByModule(ndb.db) - if err != nil { - return fmt.Errorf("VerifyLtHash: scan %s: %w", ndb.dir, err) - } - dbRoot, err := cs.verifyDBModuleMetadata(ndb.dir, scanHash, scanStats) - if err != nil { - return err - } - global.MixIn(dbRoot) - } - - // The full scan reflects on-disk (committed) state, so the only correct - // reference is committedLtHash. workingLtHash may include uncommitted - // ApplyChangeSets updates that have not yet been persisted. - if gc, cc := global.Checksum(), cs.committedLtHash.Checksum(); gc != cc { - return fmt.Errorf( - "VerifyLtHash: global mismatch at version %d\n committed: %x\n full-scan: %x", - cs.committedVersion, cc, gc, - ) - } - return nil -} - -// scanDBByModule full-scans one data DB and returns, per module, the LtHash of -// its keys and their key-count / byte footprint. Meta keys are skipped. Only -// rows with a non-empty key and non-empty value are counted — the same -// membership predicate foldChunk / serializeKV use for LtHash MixIn — so the -// scan is directly comparable to the maintained per-module metadata. Module -// membership uses the same physical-key routing the write path uses. -func scanDBByModule(db seidbtypes.KeyValueDB) (map[string]*lthash.LtHash, map[string]lthash.ModuleStats, error) { - iter, err := db.NewIter(&seidbtypes.IterOptions{}) - if err != nil { - return nil, nil, fmt.Errorf("open iterator: %w", err) - } - defer func() { _ = iter.Close() }() + var pairs []lthash.KVPairWithLastValue - byModule := make(map[string][]lthash.KVPairWithLastValue) - stats := make(map[string]lthash.ModuleStats) - for ; iter.Valid(); iter.Next() { - if ktype.IsMetaKey(iter.Key()) { + for _, db := range []seidbtypes.KeyValueDB{cs.accountDB, cs.codeDB, cs.storageDB, cs.legacyDB} { + if db == nil { continue } - // Match foldChunk / serializeKV: empty key or empty value is not a - // hash-set member and must not appear in stats. - if len(iter.Key()) == 0 || len(iter.Value()) == 0 { - continue - } - module, err := moduleOfKey(iter.Key()) + iter, err := db.NewIter(&seidbtypes.IterOptions{}) if err != nil { - return nil, nil, fmt.Errorf("route key %x: %w", iter.Key(), err) - } - byModule[module] = append(byModule[module], lthash.KVPairWithLastValue{ - Key: bytes.Clone(iter.Key()), - Value: bytes.Clone(iter.Value()), - }) - st := stats[module] - st.KeyCount++ - st.Bytes += int64(len(iter.Key())) + int64(len(iter.Value())) - stats[module] = st - } - if err := iter.Error(); err != nil { - return nil, nil, fmt.Errorf("iterator error: %w", err) - } - - hashes := make(map[string]*lthash.LtHash, len(byModule)) - for module, pairs := range byModule { - h, _ := lthash.ComputeLtHash(nil, pairs) - if h == nil { - h = lthash.New() + return fmt.Errorf("VerifyLtHash: open iterator: %w", err) } - hashes[module] = h - } - return hashes, stats, nil -} - -// verifyDBModuleMetadata checks the maintained per-module hashes and stats for -// one DB against a fresh scan, verifies they homomorphically sum to the -// maintained per-DB root, and returns that (scan-derived) per-DB root for the -// global accumulation. It fails on: a scanned module missing/mismatched in the -// maintained maps, a maintained hash/stats entry for a module absent from disk -// that is not zeroed, or the per-module sum not equaling the per-DB root. -func (cs *CommitStore) verifyDBModuleMetadata( - dir string, - scanHash map[string]*lthash.LtHash, - scanStats map[string]lthash.ModuleStats, -) (*lthash.LtHash, error) { - workingHash := cs.perDBModuleWorkingLtHash[dir] - workingStats := cs.perDBModuleWorkingStats[dir] - - // Every module on disk must match the maintained hash and stats. - for module, h := range scanHash { - wh := workingHash[module] - if wh == nil || !wh.Equal(h) { - return nil, fmt.Errorf( - "VerifyLtHash: per-module hash mismatch for %s/%s at version %d\n maintained: %s\n full-scan: %x", - dir, module, cs.committedVersion, checksumOrNil(wh), h.Checksum(), - ) + for ; iter.Valid(); iter.Next() { + if ktype.IsMetaKey(iter.Key()) { + continue + } + pairs = append(pairs, lthash.KVPairWithLastValue{ + Key: bytes.Clone(iter.Key()), + Value: bytes.Clone(iter.Value()), + }) } - if ws := workingStats[module]; ws != scanStats[module] { - return nil, fmt.Errorf( - "VerifyLtHash: per-module stats mismatch for %s/%s at version %d\n maintained: %+v\n full-scan: %+v", - dir, module, cs.committedVersion, ws, scanStats[module], - ) + if err := iter.Error(); err != nil { + _ = iter.Close() + return fmt.Errorf("VerifyLtHash: iterator error: %w", err) } + _ = iter.Close() } - // A maintained hash for a module with no keys on disk is allowed only if - // it has been zeroed (identity) — the residue of deleting every key of a - // module. - for module, wh := range workingHash { - if _, ok := scanHash[module]; ok { - continue - } - if wh != nil && !wh.Equal(lthash.New()) { - return nil, fmt.Errorf( - "VerifyLtHash: maintained per-module hash for %s/%s is non-zero but the module has no keys on disk (version %d)", - dir, module, cs.committedVersion, - ) - } - } + fullScan, _ := lthash.ComputeLtHash(nil, pairs) + fullChecksum := fullScan.Checksum() - // Same residue rule for stats. Iterate workingStats on its own so an - // orphan entry (stats present, no hash entry, no on-disk keys) cannot - // slip past the hash-keyed loop above. - for module, ws := range workingStats { - if _, ok := scanHash[module]; ok { - continue - } - if ws != (lthash.ModuleStats{}) { - return nil, fmt.Errorf( - "VerifyLtHash: maintained per-module stats for %s/%s are non-zero but the module has no keys on disk (version %d): %+v", - dir, module, cs.committedVersion, ws, - ) - } - } + // Full scan reflects on-disk (committed) state, so the only correct + // reference is committedLtHash. workingLtHash may include uncommitted + // ApplyChangeSets updates that have not yet been persisted. + committed := cs.committedLtHash.Checksum() - // The maintained per-module hashes must homomorphically sum to the - // maintained per-DB root, and that root must equal the scan. - root := cs.perDBWorkingLtHash[dir] - sum := lthash.SumModuleHashes(workingHash) - if root == nil || !root.Equal(sum) { - return nil, fmt.Errorf( - "VerifyLtHash: per-module hashes do not sum to the per-DB root for %s at version %d\n root: %s\n sum: %x", - dir, cs.committedVersion, checksumOrNil(root), sum.Checksum(), - ) - } - scanRoot := lthash.SumModuleHashes(scanHash) - if !root.Equal(scanRoot) { - return nil, fmt.Errorf( - "VerifyLtHash: per-DB root mismatch for %s at version %d\n maintained: %x\n full-scan: %x", - dir, cs.committedVersion, root.Checksum(), scanRoot.Checksum(), + if fullChecksum != committed { + return fmt.Errorf( + "VerifyLtHash: mismatch at version %d\n committed: %x\n full-scan: %x", + cs.committedVersion, committed, fullChecksum, ) } - return scanRoot, nil -} - -// checksumOrNil renders an LtHash checksum for error messages, tolerating nil. -func checksumOrNil(h *lthash.LtHash) string { - if h == nil { - return "" - } - return fmt.Sprintf("%x", h.Checksum()) + return nil } diff --git a/sei-db/state_db/sc/flatkv/verify_test.go b/sei-db/state_db/sc/flatkv/verify_test.go deleted file mode 100644 index 20f034e89d..0000000000 --- a/sei-db/state_db/sc/flatkv/verify_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package flatkv - -import ( - "testing" - - "github.com/stretchr/testify/require" - - "github.com/sei-protocol/sei-chain/sei-db/db_engine/types" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" -) - -// TestVerifyDBModuleMetadataOrphanStats ensures a stats entry for a module -// with no on-disk keys and no maintained hash cannot slip past verification. -// The hash-keyed residue loop alone would miss it. -func TestVerifyDBModuleMetadataOrphanStats(t *testing.T) { - cs := &CommitStore{ - committedVersion: 1, - perDBWorkingLtHash: map[string]*lthash.LtHash{storageDBDir: lthash.New()}, - perDBModuleWorkingLtHash: map[string]map[string]*lthash.LtHash{storageDBDir: {}}, - perDBModuleWorkingStats: map[string]map[string]lthash.ModuleStats{ - storageDBDir: { - "orphan": {KeyCount: 3, Bytes: 99}, - }, - }, - } - - _, err := cs.verifyDBModuleMetadata(storageDBDir, nil, nil) - require.Error(t, err) - require.Contains(t, err.Error(), "per-module stats") - require.Contains(t, err.Error(), "orphan") -} - -func TestVerifyDBModuleMetadataZeroResidueOK(t *testing.T) { - cs := &CommitStore{ - committedVersion: 1, - perDBWorkingLtHash: map[string]*lthash.LtHash{ - storageDBDir: lthash.New(), - }, - perDBModuleWorkingLtHash: map[string]map[string]*lthash.LtHash{ - storageDBDir: {"gone": lthash.New()}, - }, - perDBModuleWorkingStats: map[string]map[string]lthash.ModuleStats{ - storageDBDir: {"gone": {}}, - }, - } - - root, err := cs.verifyDBModuleMetadata(storageDBDir, nil, nil) - require.NoError(t, err) - require.True(t, root.IsZero()) -} - -// TestVerifyLtHashIgnoresEmptyValueRows pins the membership predicate shared -// with foldChunk / serializeKV: a live Pebble row with an empty value is not -// part of the LtHash set and must not inflate the verification scan's stats -// (or hash) relative to incrementally maintained bookkeeping. -func TestVerifyLtHashIgnoresEmptyValueRows(t *testing.T) { - s := setupTestStore(t) - defer s.Close() - - commitStorageEntry(t, s, addrN(0x01), slotN(0x01), []byte{0xAA}) - - // Plant an empty-value row that foldChunk would never count. - emptyKey := storagePhysKey(addrN(0x02), slotN(0x02)) - require.NoError(t, s.storageDB.Set(emptyKey, nil, types.WriteOptions{})) - - require.NoError(t, VerifyLtHash(s)) -} diff --git a/sei-db/state_db/sc/flatkv/vtype/legacy_data.go b/sei-db/state_db/sc/flatkv/vtype/legacy_data.go new file mode 100644 index 0000000000..cc0bc995ea --- /dev/null +++ b/sei-db/state_db/sc/flatkv/vtype/legacy_data.go @@ -0,0 +1,153 @@ +package vtype + +import ( + "encoding/binary" + "errors" + "fmt" +) + +type LegacyDataVersion uint8 + +// DO NOT CHANGE VERSION VALUES!!! Adding new versions is ok, but historical versions should never be removed/changed. +const ( + LegacyDataVersion0 LegacyDataVersion = 0 +) + +/* +Serialization schema for LegacyData version 0: + +| Version | Block Height | Value | +|---------|--------------|----------| +| 1 byte | 8 bytes | variable | + +Data is stored in big-endian order. Value is variable length. +*/ + +const ( + legacyVersionStart = 0 + legacyBlockHeightStart = legacyVersionStart + VersionLength + legacyValueStart = legacyBlockHeightStart + BlockHeightLength + legacyHeaderLength = VersionLength + BlockHeightLength +) + +var _ VType = (*LegacyData)(nil) + +// Used for encapsulating and serializing legacy data in the FlatKV legacy database. +// +// This data structure is not threadsafe. Values passed into and values received from this data structure +// are not safe to modify without first copying them. +type LegacyData struct { + version LegacyDataVersion + blockHeight int64 + value []byte + isDelete bool +} + +// Create a new LegacyData with the given value. +func NewLegacyData() *LegacyData { + return &LegacyData{version: LegacyDataVersion0} +} + +// Serialize the legacy data to a byte slice. +func (l *LegacyData) Serialize() []byte { + if l == nil { + return make([]byte, legacyHeaderLength) + } + data := make([]byte, legacyHeaderLength+len(l.value)) + data[legacyVersionStart] = byte(l.version) + binary.BigEndian.PutUint64(data[legacyBlockHeightStart:legacyValueStart], uint64(l.blockHeight)) //nolint:gosec + copy(data[legacyValueStart:], l.value) + return data +} + +// Deserialize the legacy data from the given byte slice. +func DeserializeLegacyData(data []byte) (*LegacyData, error) { + if len(data) == 0 { + return nil, errors.New("data is empty") + } + + version := LegacyDataVersion(data[legacyVersionStart]) + if version != LegacyDataVersion0 { + return nil, fmt.Errorf("unsupported serialization version: %d", version) + } + + if len(data) < legacyHeaderLength { + return nil, fmt.Errorf("data length at version %d should be at least %d, got %d", + version, legacyHeaderLength, len(data)) + } + + value := make([]byte, len(data)-legacyHeaderLength) + copy(value, data[legacyValueStart:]) + + return &LegacyData{ + version: version, + blockHeight: int64(binary.BigEndian.Uint64(data[legacyBlockHeightStart:legacyValueStart])), //nolint:gosec + value: value, + }, nil +} + +// Get the serialization version for this LegacyData instance. +func (l *LegacyData) GetSerializationVersion() LegacyDataVersion { + if l == nil { + return LegacyDataVersion0 + } + return l.version +} + +// Get the block height when this legacy entry was last modified. +func (l *LegacyData) GetBlockHeight() int64 { + if l == nil { + return 0 + } + return l.blockHeight +} + +// Get the legacy value. +func (l *LegacyData) GetValue() []byte { + if l == nil { + return []byte{} + } + return l.value +} + +// Set the block height when this legacy entry was last modified/touched. Returns self (or a new LegacyData if nil). +func (l *LegacyData) SetBlockHeight(blockHeight int64) *LegacyData { + if l == nil { + l = NewLegacyData() + } + l.blockHeight = blockHeight + return l +} + +// Set the legacy value. Returns self (or a new LegacyData if nil). +// Clears the delete flag — an explicit SetValue is a write, not a deletion, +// even when value is empty ([]byte{} is a valid Cosmos module value). +func (l *LegacyData) SetValue(value []byte) *LegacyData { + if l == nil { + l = NewLegacyData() + } + l.value = append([]byte(nil), value...) + l.isDelete = false + return l +} + +// MarkDeleted flags this entry for physical key removal at commit time. +// The stored value is irrelevant once marked; IsDelete() will return true. +func (l *LegacyData) MarkDeleted() *LegacyData { + if l == nil { + l = NewLegacyData() + } + l.isDelete = true + return l +} + +// IsDelete reports whether this entry represents a deletion. +// Uses an explicit flag rather than value-length inference so that empty +// values ([]byte{}) written by Cosmos modules are not misinterpreted as +// deletions. +func (l *LegacyData) IsDelete() bool { + if l == nil { + return true + } + return l.isDelete +} diff --git a/sei-db/state_db/sc/flatkv/vtype/legacy_data_test.go b/sei-db/state_db/sc/flatkv/vtype/legacy_data_test.go new file mode 100644 index 0000000000..cea7ddd183 --- /dev/null +++ b/sei-db/state_db/sc/flatkv/vtype/legacy_data_test.go @@ -0,0 +1,233 @@ +package vtype + +import ( + "bytes" + "encoding/hex" + "math" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLegacySerializationGoldenFile_V0(t *testing.T) { + value := []byte{0xca, 0xfe, 0xba, 0xbe} + ld := NewLegacyData().SetBlockHeight(100).SetValue(value) + + serialized := ld.Serialize() + + golden := filepath.Join(testdataDir, "legacy_data_v0.hex") + if _, err := os.Stat(golden); os.IsNotExist(err) { + require.NoError(t, os.MkdirAll(testdataDir, 0o755)) + require.NoError(t, os.WriteFile(golden, []byte(hex.EncodeToString(serialized)), 0o644)) + t.Logf("created golden file %s — re-run to verify", golden) + return + } + + want, err := os.ReadFile(golden) + require.NoError(t, err) + wantBytes, err := hex.DecodeString(strings.TrimSpace(string(want))) + require.NoError(t, err) + require.Equal(t, wantBytes, serialized, "serialization differs from golden file") + + rt, err := DeserializeLegacyData(wantBytes) + require.NoError(t, err) + require.Equal(t, int64(100), rt.GetBlockHeight()) + require.Equal(t, value, rt.GetValue()) +} + +func TestLegacyNewWithValue(t *testing.T) { + value := []byte{0x01, 0x02, 0x03} + ld := NewLegacyData().SetValue(value) + require.Equal(t, LegacyDataVersion0, ld.GetSerializationVersion()) + require.Equal(t, int64(0), ld.GetBlockHeight()) + require.Equal(t, value, ld.GetValue()) +} + +func TestLegacyNewEmpty(t *testing.T) { + ld := NewLegacyData() + require.Equal(t, LegacyDataVersion0, ld.GetSerializationVersion()) + require.Equal(t, int64(0), ld.GetBlockHeight()) + require.Empty(t, ld.GetValue()) +} + +func TestLegacySerializeLength(t *testing.T) { + value := []byte{0x01, 0x02, 0x03} + ld := NewLegacyData().SetValue(value) + require.Len(t, ld.Serialize(), legacyHeaderLength+len(value)) +} + +func TestLegacySerializeLength_Empty(t *testing.T) { + ld := NewLegacyData() + require.Len(t, ld.Serialize(), legacyHeaderLength) +} + +func TestLegacyRoundTrip_WithValue(t *testing.T) { + value := bytes.Repeat([]byte{0xab}, 1000) + ld := NewLegacyData().SetBlockHeight(999).SetValue(value) + + rt, err := DeserializeLegacyData(ld.Serialize()) + require.NoError(t, err) + require.Equal(t, int64(999), rt.GetBlockHeight()) + require.Equal(t, value, rt.GetValue()) +} + +func TestLegacyRoundTrip_EmptyValue(t *testing.T) { + ld := NewLegacyData().SetBlockHeight(42) + + rt, err := DeserializeLegacyData(ld.Serialize()) + require.NoError(t, err) + require.Equal(t, int64(42), rt.GetBlockHeight()) + require.Empty(t, rt.GetValue()) +} + +func TestLegacyRoundTrip_MaxBlockHeight(t *testing.T) { + ld := NewLegacyData().SetBlockHeight(math.MaxInt64).SetValue([]byte{0xff}) + + rt, err := DeserializeLegacyData(ld.Serialize()) + require.NoError(t, err) + require.Equal(t, int64(math.MaxInt64), rt.GetBlockHeight()) + require.Equal(t, []byte{0xff}, rt.GetValue()) +} + +func TestLegacyIsDelete_Default(t *testing.T) { + ld := NewLegacyData() + require.False(t, ld.IsDelete(), "newly created LegacyData is not a deletion") +} + +func TestLegacyIsDelete_EmptySliceIsNotDelete(t *testing.T) { + ld := NewLegacyData().SetValue([]byte{}) + require.False(t, ld.IsDelete(), "empty value is a valid write, not a deletion") +} + +func TestLegacyIsDelete_MarkDeleted(t *testing.T) { + ld := NewLegacyData().MarkDeleted() + require.True(t, ld.IsDelete()) +} + +func TestLegacyIsDelete_MarkDeletedThenSetValue(t *testing.T) { + ld := NewLegacyData().MarkDeleted().SetValue([]byte{0x01}) + require.False(t, ld.IsDelete(), "SetValue clears the delete flag") +} + +func TestLegacyIsDelete_SetValueThenMarkDeleted(t *testing.T) { + ld := NewLegacyData().SetValue([]byte{0x01}).MarkDeleted() + require.True(t, ld.IsDelete()) +} + +func TestLegacyIsDelete_NonEmptyValue(t *testing.T) { + ld := NewLegacyData().SetValue([]byte{0x01}) + require.False(t, ld.IsDelete()) +} + +func TestLegacyIsDelete_SetBlockHeightDoesNotAffectDelete(t *testing.T) { + ld := NewLegacyData().MarkDeleted().SetBlockHeight(42) + require.True(t, ld.IsDelete(), "SetBlockHeight does not clear the delete flag") +} + +func TestLegacyDeserialize_EmptyData(t *testing.T) { + _, err := DeserializeLegacyData([]byte{}) + require.Error(t, err) +} + +func TestLegacyDeserialize_NilData(t *testing.T) { + _, err := DeserializeLegacyData(nil) + require.Error(t, err) +} + +func TestLegacyDeserialize_TooShort(t *testing.T) { + _, err := DeserializeLegacyData([]byte{0x00, 0x01, 0x02}) + require.Error(t, err) +} + +func TestLegacyDeserialize_HeaderOnly(t *testing.T) { + ld := NewLegacyData() + rt, err := DeserializeLegacyData(ld.Serialize()) + require.NoError(t, err) + require.Empty(t, rt.GetValue()) +} + +func TestLegacyDeserialize_UnsupportedVersion(t *testing.T) { + data := make([]byte, legacyHeaderLength+1) + data[0] = 0xff + _, err := DeserializeLegacyData(data) + require.Error(t, err) +} + +func TestLegacySetterChaining(t *testing.T) { + ld := NewLegacyData().SetValue([]byte{0x01}).SetBlockHeight(42) + require.Equal(t, int64(42), ld.GetBlockHeight()) + require.Equal(t, []byte{0x01}, ld.GetValue()) +} + +func TestLegacyConstantLayout_V0(t *testing.T) { + require.Equal(t, 9, legacyHeaderLength) +} + +func TestLegacyNewCopiesValue(t *testing.T) { + value := []byte{0x01, 0x02, 0x03} + ld := NewLegacyData().SetValue(value) + value[0] = 0xff + require.Equal(t, byte(0x01), ld.GetValue()[0]) +} + +func TestNilLegacyData_Getters(t *testing.T) { + var ld *LegacyData + + require.Equal(t, LegacyDataVersion0, ld.GetSerializationVersion()) + require.Equal(t, int64(0), ld.GetBlockHeight()) + require.Empty(t, ld.GetValue()) +} + +func TestNilLegacyData_IsDelete(t *testing.T) { + var ld *LegacyData + require.True(t, ld.IsDelete()) +} + +func TestNilLegacyData_Serialize(t *testing.T) { + var ld *LegacyData + s := ld.Serialize() + require.Len(t, s, legacyHeaderLength) +} + +func TestNilLegacyData_SerializeRoundTrips(t *testing.T) { + var ld *LegacyData + rt, err := DeserializeLegacyData(ld.Serialize()) + require.NoError(t, err) + require.False(t, rt.IsDelete(), "deserialized data from disk is not a deletion") + require.Empty(t, rt.GetValue()) +} + +func TestNilLegacyData_SettersAutoCreate(t *testing.T) { + var l1 *LegacyData + l1 = l1.SetValue([]byte{0xAB}) + require.NotNil(t, l1) + require.Equal(t, []byte{0xAB}, l1.GetValue()) + + var l2 *LegacyData + l2 = l2.SetBlockHeight(42) + require.NotNil(t, l2) + require.Equal(t, int64(42), l2.GetBlockHeight()) +} + +func TestLegacyData_SetValueOverwrite(t *testing.T) { + ld := NewLegacyData().SetValue([]byte{0x01, 0x02, 0x03}) + ld = ld.SetValue([]byte{0xAA}) + require.Equal(t, []byte{0xAA}, ld.GetValue()) +} + +func TestLegacyData_SetValueNil(t *testing.T) { + ld := NewLegacyData().SetValue([]byte{0x01}) + ld = ld.SetValue(nil) + require.Empty(t, ld.GetValue()) + require.False(t, ld.IsDelete(), "SetValue(nil) is a write of empty data, not a deletion") +} + +func TestLegacyData_MarkDeletedNilReceiver(t *testing.T) { + var ld *LegacyData + ld = ld.MarkDeleted() + require.NotNil(t, ld) + require.True(t, ld.IsDelete()) +} diff --git a/sei-db/state_db/sc/flatkv/vtype/misc_data.go b/sei-db/state_db/sc/flatkv/vtype/misc_data.go deleted file mode 100644 index 7757d0c551..0000000000 --- a/sei-db/state_db/sc/flatkv/vtype/misc_data.go +++ /dev/null @@ -1,153 +0,0 @@ -package vtype - -import ( - "encoding/binary" - "errors" - "fmt" -) - -type MiscDataVersion uint8 - -// DO NOT CHANGE VERSION VALUES!!! Adding new versions is ok, but historical versions should never be removed/changed. -const ( - MiscDataVersion0 MiscDataVersion = 0 -) - -/* -Serialization schema for MiscData version 0: - -| Version | Block Height | Value | -|---------|--------------|----------| -| 1 byte | 8 bytes | variable | - -Data is stored in big-endian order. Value is variable length. -*/ - -const ( - miscVersionStart = 0 - miscBlockHeightStart = miscVersionStart + VersionLength - miscValueStart = miscBlockHeightStart + BlockHeightLength - miscHeaderLength = VersionLength + BlockHeightLength -) - -var _ VType = (*MiscData)(nil) - -// Used for encapsulating and serializing misc data in the FlatKV misc database. -// -// This data structure is not threadsafe. Values passed into and values received from this data structure -// are not safe to modify without first copying them. -type MiscData struct { - version MiscDataVersion - blockHeight int64 - value []byte - isDelete bool -} - -// Create a new MiscData with the given value. -func NewMiscData() *MiscData { - return &MiscData{version: MiscDataVersion0} -} - -// Serialize the misc data to a byte slice. -func (l *MiscData) Serialize() []byte { - if l == nil { - return make([]byte, miscHeaderLength) - } - data := make([]byte, miscHeaderLength+len(l.value)) - data[miscVersionStart] = byte(l.version) - binary.BigEndian.PutUint64(data[miscBlockHeightStart:miscValueStart], uint64(l.blockHeight)) //nolint:gosec - copy(data[miscValueStart:], l.value) - return data -} - -// Deserialize the misc data from the given byte slice. -func DeserializeMiscData(data []byte) (*MiscData, error) { - if len(data) == 0 { - return nil, errors.New("data is empty") - } - - version := MiscDataVersion(data[miscVersionStart]) - if version != MiscDataVersion0 { - return nil, fmt.Errorf("unsupported serialization version: %d", version) - } - - if len(data) < miscHeaderLength { - return nil, fmt.Errorf("data length at version %d should be at least %d, got %d", - version, miscHeaderLength, len(data)) - } - - value := make([]byte, len(data)-miscHeaderLength) - copy(value, data[miscValueStart:]) - - return &MiscData{ - version: version, - blockHeight: int64(binary.BigEndian.Uint64(data[miscBlockHeightStart:miscValueStart])), //nolint:gosec - value: value, - }, nil -} - -// Get the serialization version for this MiscData instance. -func (l *MiscData) GetSerializationVersion() MiscDataVersion { - if l == nil { - return MiscDataVersion0 - } - return l.version -} - -// Get the block height when this misc entry was last modified. -func (l *MiscData) GetBlockHeight() int64 { - if l == nil { - return 0 - } - return l.blockHeight -} - -// Get the misc value. -func (l *MiscData) GetValue() []byte { - if l == nil { - return []byte{} - } - return l.value -} - -// Set the block height when this misc entry was last modified/touched. Returns self (or a new MiscData if nil). -func (l *MiscData) SetBlockHeight(blockHeight int64) *MiscData { - if l == nil { - l = NewMiscData() - } - l.blockHeight = blockHeight - return l -} - -// Set the misc value. Returns self (or a new MiscData if nil). -// Clears the delete flag — an explicit SetValue is a write, not a deletion, -// even when value is empty ([]byte{} is a valid Cosmos module value). -func (l *MiscData) SetValue(value []byte) *MiscData { - if l == nil { - l = NewMiscData() - } - l.value = append([]byte(nil), value...) - l.isDelete = false - return l -} - -// MarkDeleted flags this entry for physical key removal at commit time. -// The stored value is irrelevant once marked; IsDelete() will return true. -func (l *MiscData) MarkDeleted() *MiscData { - if l == nil { - l = NewMiscData() - } - l.isDelete = true - return l -} - -// IsDelete reports whether this entry represents a deletion. -// Uses an explicit flag rather than value-length inference so that empty -// values ([]byte{}) written by Cosmos modules are not misinterpreted as -// deletions. -func (l *MiscData) IsDelete() bool { - if l == nil { - return true - } - return l.isDelete -} diff --git a/sei-db/state_db/sc/flatkv/vtype/misc_data_test.go b/sei-db/state_db/sc/flatkv/vtype/misc_data_test.go deleted file mode 100644 index c1040e9ae1..0000000000 --- a/sei-db/state_db/sc/flatkv/vtype/misc_data_test.go +++ /dev/null @@ -1,233 +0,0 @@ -package vtype - -import ( - "bytes" - "encoding/hex" - "math" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestMiscSerializationGoldenFile_V0(t *testing.T) { - value := []byte{0xca, 0xfe, 0xba, 0xbe} - ld := NewMiscData().SetBlockHeight(100).SetValue(value) - - serialized := ld.Serialize() - - golden := filepath.Join(testdataDir, "misc_data_v0.hex") - if _, err := os.Stat(golden); os.IsNotExist(err) { - require.NoError(t, os.MkdirAll(testdataDir, 0o755)) - require.NoError(t, os.WriteFile(golden, []byte(hex.EncodeToString(serialized)), 0o644)) - t.Logf("created golden file %s — re-run to verify", golden) - return - } - - want, err := os.ReadFile(golden) - require.NoError(t, err) - wantBytes, err := hex.DecodeString(strings.TrimSpace(string(want))) - require.NoError(t, err) - require.Equal(t, wantBytes, serialized, "serialization differs from golden file") - - rt, err := DeserializeMiscData(wantBytes) - require.NoError(t, err) - require.Equal(t, int64(100), rt.GetBlockHeight()) - require.Equal(t, value, rt.GetValue()) -} - -func TestMiscNewWithValue(t *testing.T) { - value := []byte{0x01, 0x02, 0x03} - ld := NewMiscData().SetValue(value) - require.Equal(t, MiscDataVersion0, ld.GetSerializationVersion()) - require.Equal(t, int64(0), ld.GetBlockHeight()) - require.Equal(t, value, ld.GetValue()) -} - -func TestMiscNewEmpty(t *testing.T) { - ld := NewMiscData() - require.Equal(t, MiscDataVersion0, ld.GetSerializationVersion()) - require.Equal(t, int64(0), ld.GetBlockHeight()) - require.Empty(t, ld.GetValue()) -} - -func TestMiscSerializeLength(t *testing.T) { - value := []byte{0x01, 0x02, 0x03} - ld := NewMiscData().SetValue(value) - require.Len(t, ld.Serialize(), miscHeaderLength+len(value)) -} - -func TestMiscSerializeLength_Empty(t *testing.T) { - ld := NewMiscData() - require.Len(t, ld.Serialize(), miscHeaderLength) -} - -func TestMiscRoundTrip_WithValue(t *testing.T) { - value := bytes.Repeat([]byte{0xab}, 1000) - ld := NewMiscData().SetBlockHeight(999).SetValue(value) - - rt, err := DeserializeMiscData(ld.Serialize()) - require.NoError(t, err) - require.Equal(t, int64(999), rt.GetBlockHeight()) - require.Equal(t, value, rt.GetValue()) -} - -func TestMiscRoundTrip_EmptyValue(t *testing.T) { - ld := NewMiscData().SetBlockHeight(42) - - rt, err := DeserializeMiscData(ld.Serialize()) - require.NoError(t, err) - require.Equal(t, int64(42), rt.GetBlockHeight()) - require.Empty(t, rt.GetValue()) -} - -func TestMiscRoundTrip_MaxBlockHeight(t *testing.T) { - ld := NewMiscData().SetBlockHeight(math.MaxInt64).SetValue([]byte{0xff}) - - rt, err := DeserializeMiscData(ld.Serialize()) - require.NoError(t, err) - require.Equal(t, int64(math.MaxInt64), rt.GetBlockHeight()) - require.Equal(t, []byte{0xff}, rt.GetValue()) -} - -func TestMiscIsDelete_Default(t *testing.T) { - ld := NewMiscData() - require.False(t, ld.IsDelete(), "newly created MiscData is not a deletion") -} - -func TestMiscIsDelete_EmptySliceIsNotDelete(t *testing.T) { - ld := NewMiscData().SetValue([]byte{}) - require.False(t, ld.IsDelete(), "empty value is a valid write, not a deletion") -} - -func TestMiscIsDelete_MarkDeleted(t *testing.T) { - ld := NewMiscData().MarkDeleted() - require.True(t, ld.IsDelete()) -} - -func TestMiscIsDelete_MarkDeletedThenSetValue(t *testing.T) { - ld := NewMiscData().MarkDeleted().SetValue([]byte{0x01}) - require.False(t, ld.IsDelete(), "SetValue clears the delete flag") -} - -func TestMiscIsDelete_SetValueThenMarkDeleted(t *testing.T) { - ld := NewMiscData().SetValue([]byte{0x01}).MarkDeleted() - require.True(t, ld.IsDelete()) -} - -func TestMiscIsDelete_NonEmptyValue(t *testing.T) { - ld := NewMiscData().SetValue([]byte{0x01}) - require.False(t, ld.IsDelete()) -} - -func TestMiscIsDelete_SetBlockHeightDoesNotAffectDelete(t *testing.T) { - ld := NewMiscData().MarkDeleted().SetBlockHeight(42) - require.True(t, ld.IsDelete(), "SetBlockHeight does not clear the delete flag") -} - -func TestMiscDeserialize_EmptyData(t *testing.T) { - _, err := DeserializeMiscData([]byte{}) - require.Error(t, err) -} - -func TestMiscDeserialize_NilData(t *testing.T) { - _, err := DeserializeMiscData(nil) - require.Error(t, err) -} - -func TestMiscDeserialize_TooShort(t *testing.T) { - _, err := DeserializeMiscData([]byte{0x00, 0x01, 0x02}) - require.Error(t, err) -} - -func TestMiscDeserialize_HeaderOnly(t *testing.T) { - ld := NewMiscData() - rt, err := DeserializeMiscData(ld.Serialize()) - require.NoError(t, err) - require.Empty(t, rt.GetValue()) -} - -func TestMiscDeserialize_UnsupportedVersion(t *testing.T) { - data := make([]byte, miscHeaderLength+1) - data[0] = 0xff - _, err := DeserializeMiscData(data) - require.Error(t, err) -} - -func TestMiscSetterChaining(t *testing.T) { - ld := NewMiscData().SetValue([]byte{0x01}).SetBlockHeight(42) - require.Equal(t, int64(42), ld.GetBlockHeight()) - require.Equal(t, []byte{0x01}, ld.GetValue()) -} - -func TestMiscConstantLayout_V0(t *testing.T) { - require.Equal(t, 9, miscHeaderLength) -} - -func TestMiscNewCopiesValue(t *testing.T) { - value := []byte{0x01, 0x02, 0x03} - ld := NewMiscData().SetValue(value) - value[0] = 0xff - require.Equal(t, byte(0x01), ld.GetValue()[0]) -} - -func TestNilMiscData_Getters(t *testing.T) { - var ld *MiscData - - require.Equal(t, MiscDataVersion0, ld.GetSerializationVersion()) - require.Equal(t, int64(0), ld.GetBlockHeight()) - require.Empty(t, ld.GetValue()) -} - -func TestNilMiscData_IsDelete(t *testing.T) { - var ld *MiscData - require.True(t, ld.IsDelete()) -} - -func TestNilMiscData_Serialize(t *testing.T) { - var ld *MiscData - s := ld.Serialize() - require.Len(t, s, miscHeaderLength) -} - -func TestNilMiscData_SerializeRoundTrips(t *testing.T) { - var ld *MiscData - rt, err := DeserializeMiscData(ld.Serialize()) - require.NoError(t, err) - require.False(t, rt.IsDelete(), "deserialized data from disk is not a deletion") - require.Empty(t, rt.GetValue()) -} - -func TestNilMiscData_SettersAutoCreate(t *testing.T) { - var l1 *MiscData - l1 = l1.SetValue([]byte{0xAB}) - require.NotNil(t, l1) - require.Equal(t, []byte{0xAB}, l1.GetValue()) - - var l2 *MiscData - l2 = l2.SetBlockHeight(42) - require.NotNil(t, l2) - require.Equal(t, int64(42), l2.GetBlockHeight()) -} - -func TestMiscData_SetValueOverwrite(t *testing.T) { - ld := NewMiscData().SetValue([]byte{0x01, 0x02, 0x03}) - ld = ld.SetValue([]byte{0xAA}) - require.Equal(t, []byte{0xAA}, ld.GetValue()) -} - -func TestMiscData_SetValueNil(t *testing.T) { - ld := NewMiscData().SetValue([]byte{0x01}) - ld = ld.SetValue(nil) - require.Empty(t, ld.GetValue()) - require.False(t, ld.IsDelete(), "SetValue(nil) is a write of empty data, not a deletion") -} - -func TestMiscData_MarkDeletedNilReceiver(t *testing.T) { - var ld *MiscData - ld = ld.MarkDeleted() - require.NotNil(t, ld) - require.True(t, ld.IsDelete()) -} diff --git a/sei-db/state_db/sc/flatkv/vtype/testdata/misc_data_v0.hex b/sei-db/state_db/sc/flatkv/vtype/testdata/legacy_data_v0.hex similarity index 100% rename from sei-db/state_db/sc/flatkv/vtype/testdata/misc_data_v0.hex rename to sei-db/state_db/sc/flatkv/vtype/testdata/legacy_data_v0.hex diff --git a/sei-db/state_db/sc/memiavl/config.go b/sei-db/state_db/sc/memiavl/config.go index 0fe29b9641..9aae0dc1a5 100644 --- a/sei-db/state_db/sc/memiavl/config.go +++ b/sei-db/state_db/sc/memiavl/config.go @@ -1,11 +1,8 @@ package memiavl const ( - DefaultSnapshotInterval = 10000 - // DefaultSnapshotKeepRecent is how many old snapshots (besides the latest) to - // keep by default. A configured value of 0 is treated as "unset" and healed - // to this default by Options.FillDefaults. - DefaultSnapshotKeepRecent = 1 + DefaultSnapshotInterval = 10000 + DefaultSnapshotKeepRecent = 0 // set to 0 to only keep one current snapshot DefaultSnapshotMinTimeInterval = 60 * 60 // 1 hour in seconds DefaultAsyncCommitBuffer = 100 DefaultSnapshotPrefetchThreshold = 0.8 // prefetch if <80% pages in cache @@ -19,8 +16,8 @@ type Config struct { // defaults to 100 AsyncCommitBuffer int `mapstructure:"async-commit-buffer"` - // SnapshotKeepRecent defines how many old snapshots (excluding the latest one) to keep. - // Defaults to 1; a configured value of 0 is overridden to the default by FillDefaults. + // SnapshotKeepRecent defines what many old snapshots (excluding the latest one) to keep + // defaults to 0 to only keep one current snapshot SnapshotKeepRecent uint32 `mapstructure:"snapshot-keep-recent"` // SnapshotInterval defines the block interval the memiavl snapshot is taken, default to 10000. diff --git a/sei-db/state_db/sc/memiavl/config_test.go b/sei-db/state_db/sc/memiavl/config_test.go deleted file mode 100644 index e9bce06659..0000000000 --- a/sei-db/state_db/sc/memiavl/config_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package memiavl - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestDefaultConfigSnapshotKeepRecent(t *testing.T) { - require.Equal(t, uint32(1), DefaultConfig().SnapshotKeepRecent) -} - -func TestFillDefaultsSnapshotKeepRecent(t *testing.T) { - tests := []struct { - name string - in uint32 - want uint32 - }{ - {"zero uses default", 0, DefaultSnapshotKeepRecent}, - {"one stays", 1, 1}, - {"two stays", 2, 2}, - {"large stays", 100, 100}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - opts := Options{Config: Config{SnapshotKeepRecent: tc.in}} - opts.FillDefaults() - require.Equal(t, tc.want, opts.SnapshotKeepRecent) - }) - } -} diff --git a/sei-db/state_db/sc/memiavl/db_test.go b/sei-db/state_db/sc/memiavl/db_test.go index 3cddd00f77..b99f63f7f7 100644 --- a/sei-db/state_db/sc/memiavl/db_test.go +++ b/sei-db/state_db/sc/memiavl/db_test.go @@ -92,9 +92,7 @@ func TestRemoveSnapshotDir(t *testing.T) { func TestRewriteSnapshotBackground(t *testing.T) { db, err := OpenDB(0, Options{ - // SnapshotKeepRecent 0 is healed to DefaultSnapshotKeepRecent (1) by - // FillDefaults, so the latest snapshot plus one older snapshot are kept. - Config: Config{SnapshotKeepRecent: 0}, + Config: Config{SnapshotKeepRecent: 0}, // only a single snapshot is kept Dir: t.TempDir(), CreateIfMissing: true, InitialStores: []string{"test"}, @@ -154,12 +152,12 @@ func TestRewriteSnapshotBackground(t *testing.T) { wg.Wait() // Wait for async prune to finish by checking the actual directory state. - // keep-recent heals to 1, so after prune completes 5 entries should remain: - // latest snapshot, one older snapshot, current link, LOCK, changelog WAL dir + // After prune completes, only 4 entries should remain: + // snapshot, current link, LOCK, changelog WAL dir require.Eventually(t, func() bool { entries, err := os.ReadDir(db.dir) - return err == nil && len(entries) == 5 - }, 3*time.Second, 50*time.Millisecond, "prune should complete and leave exactly 5 entries") + return err == nil && len(entries) == 4 + }, 3*time.Second, 50*time.Millisecond, "prune should complete and leave exactly 4 entries") // stopCh is closed by defer above } diff --git a/sei-db/state_db/sc/memiavl/opts.go b/sei-db/state_db/sc/memiavl/opts.go index 9c4a6f5d31..a78e57f58a 100644 --- a/sei-db/state_db/sc/memiavl/opts.go +++ b/sei-db/state_db/sc/memiavl/opts.go @@ -54,9 +54,6 @@ func (opts *Options) FillDefaults() { if opts.SnapshotInterval <= 0 { opts.SnapshotInterval = DefaultSnapshotInterval } - if opts.SnapshotKeepRecent <= 0 { - opts.SnapshotKeepRecent = DefaultSnapshotKeepRecent - } // SnapshotWriterLimit controls tree concurrency but not I/O rate (use SnapshotWriteRateMBps for that) if opts.SnapshotWriterLimit <= 0 { diff --git a/sei-db/state_db/sc/migration/migration_iterator_paired_test.go b/sei-db/state_db/sc/migration/migration_iterator_paired_test.go index 2c0f13b3e1..1d06757127 100644 --- a/sei-db/state_db/sc/migration/migration_iterator_paired_test.go +++ b/sei-db/state_db/sc/migration/migration_iterator_paired_test.go @@ -1,11 +1,11 @@ package migration import ( - "context" "fmt" "math/rand" "sort" "testing" + "time" "github.com/sei-protocol/sei-chain/sei-db/proto" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/memiavl" @@ -297,7 +297,7 @@ func TestMemiavlIteratorSurvivesSnapshotRewrite(t *testing.T) { db, err := memiavl.OpenDB(0, memiavl.Options{ Config: memiavl.Config{ - SnapshotInterval: 0, + SnapshotInterval: 1, SnapshotMinTimeInterval: 0, }, Dir: t.TempDir(), @@ -335,10 +335,13 @@ func TestMemiavlIteratorSurvivesSnapshotRewrite(t *testing.T) { require.True(t, mockBound.Equals(memiavlBound)) } - // Force a snapshot rewrite and reload between batches so the DB swaps in - // a new MultiTree (which ReplaceWith's each tree) deterministically. - require.NoError(t, db.RewriteSnapshot(context.Background())) - require.NoError(t, db.Reload()) + // Force a background snapshot rewrite and wait for it to complete. + // The next Commit call will pick up the result via checkAsyncTasks + // and swap in the new MultiTree (which ReplaceWith's each tree). + require.NoError(t, db.RewriteSnapshotBackground()) + time.Sleep(500 * time.Millisecond) + _, err = db.Commit() + require.NoError(t, err) // Drain to completion in lockstep and assert every batch still matches. for { diff --git a/sei-db/state_db/sc/migration/migration_manager.go b/sei-db/state_db/sc/migration/migration_manager.go index 627e9dd68e..daa51286f8 100644 --- a/sei-db/state_db/sc/migration/migration_manager.go +++ b/sei-db/state_db/sc/migration/migration_manager.go @@ -144,7 +144,7 @@ func NewMigrationManager( } iterator.SetBoundary(boundary) - logger.Debug("initialized migration manager", + logger.Info("initialized migration manager", "startVersion", startVersion, "targetVersion", targetVersion, "boundary", boundary.String()) @@ -386,17 +386,8 @@ func (m *MigrationManager) ApplyChangeSets(changesets []*proto.NamedChangeSet, f // silently drop those stats. keysMigrated and the metadata pair count stay zero on // non-first calls because the migration-advance branch above is skipped. m.metrics.RecordBatch(batchStats) - - if advanceMigration { - if batchStats.keysMigrated > 0 { - logger.Info("migration progress advanced", - "keysMigrated", batchStats.keysMigrated, - "newBoundary", m.boundary.String()) - } - if migrationComplete { - m.logMigrationCompleteSummary() - } - + if advanceMigration && migrationComplete { + m.logMigrationCompleteSummary() } return nil diff --git a/sei-db/state_db/sc/migration/migration_test_framework_test.go b/sei-db/state_db/sc/migration/migration_test_framework_test.go index 8a3ed498b8..34a938ff56 100644 --- a/sei-db/state_db/sc/migration/migration_test_framework_test.go +++ b/sei-db/state_db/sc/migration/migration_test_framework_test.go @@ -517,7 +517,7 @@ func randomEVMValue(rng *testutil.TestRandom, key []byte) []byte { return randomTestBytes(rng, 8) case keys.EVMKeyCodeHash, keys.EVMKeyCode, keys.EVMKeyStorage: return randomTestBytes(rng, 32) - default: // EVMKeyMisc or unknown — no fixed constraint + default: // EVMKeyLegacy or unknown — no fixed constraint return randomTestBytes(rng, 8) } } diff --git a/sei-db/state_db/sc/migration/router_builder.go b/sei-db/state_db/sc/migration/router_builder.go index 4976e13e7f..3a15ee6e7a 100644 --- a/sei-db/state_db/sc/migration/router_builder.go +++ b/sei-db/state_db/sc/migration/router_builder.go @@ -173,13 +173,6 @@ func buildMigrateEVMRouter( if err != nil { return nil, fmt.Errorf("NewMigrationManager: %w", err) } - readonly := memIAVL.GetDB().ReadOnly() - if !readonly { - logger.Info("created new EVM migration manager", - "startVersion", Version0_MemiavlOnly, - "targetVersion", Version1_MigrateEVM, - "boundary", migrationManager.boundary.String()) - } nonEVMModules, err := keys.AllModulesExcept(keys.EVMStoreKey) if err != nil { diff --git a/sei-db/state_db/ss/composite/store.go b/sei-db/state_db/ss/composite/store.go index a4077cca09..2d4102a486 100644 --- a/sei-db/state_db/ss/composite/store.go +++ b/sei-db/state_db/ss/composite/store.go @@ -385,10 +385,10 @@ func convertFlatKVNodes(node types.SnapshotNode) ([]types.SnapshotNode, error) { {StoreKey: evm.EVMStoreKey, Key: innerKey, Value: cd.GetBytecode()}, }, nil - case keys.EVMKeyMisc: - ld, err := vtype.DeserializeMiscData(node.Value) + case keys.EVMKeyLegacy: + ld, err := vtype.DeserializeLegacyData(node.Value) if err != nil { - return nil, fmt.Errorf("failed to DeserializeMiscData misc: %w", err) + return nil, fmt.Errorf("failed to DeserializeLegacyData legacy: %w", err) } return []types.SnapshotNode{ {StoreKey: moduleName, Key: innerKey, Value: ld.GetValue()}, diff --git a/sei-db/state_db/ss/composite/store_test.go b/sei-db/state_db/ss/composite/store_test.go index d2f2c28bd2..ad83c860f7 100644 --- a/sei-db/state_db/ss/composite/store_test.go +++ b/sei-db/state_db/ss/composite/store_test.go @@ -1170,11 +1170,11 @@ func TestImport_FlatKVLegacyKeysPreserveModule(t *testing.T) { evmLegacyInnerKey := append([]byte{0x01}, addr...) evmLegacyPhysKey := ktype.ModulePhysicalKey("evm", evmLegacyInnerKey) - evmLegacyVal := vtype.NewMiscData().SetValue([]byte("sei1abc")).Serialize() + evmLegacyVal := vtype.NewLegacyData().SetValue([]byte("sei1abc")).Serialize() bankInnerKey := []byte("balances/addr1") bankPhysKey := ktype.ModulePhysicalKey("bank", bankInnerKey) - bankLegacyVal := vtype.NewMiscData().SetValue([]byte("1000usei")).Serialize() + bankLegacyVal := vtype.NewLegacyData().SetValue([]byte("1000usei")).Serialize() for _, mode := range []bool{true, false} { t.Run(fmt.Sprintf("EVMSplit=%v", mode), func(t *testing.T) { diff --git a/sei-db/state_db/ss/evm/config_test.go b/sei-db/state_db/ss/evm/config_test.go index c214dd68f3..5cc185074a 100644 --- a/sei-db/state_db/ss/evm/config_test.go +++ b/sei-db/state_db/ss/evm/config_test.go @@ -21,7 +21,7 @@ func TestAllEVMStoreTypes(t *testing.T) { require.True(t, typeSet[StoreCodeHash], "StoreCodeHash should be in AllEVMStoreTypes") require.True(t, typeSet[StoreCode], "StoreCode should be in AllEVMStoreTypes") require.True(t, typeSet[StoreStorage], "StoreStorage should be in AllEVMStoreTypes") - require.True(t, typeSet[StoreMisc], "StoreMisc should be in AllEVMStoreTypes") + require.True(t, typeSet[StoreLegacy], "StoreLegacy should be in AllEVMStoreTypes") // Balance should NOT be present (reserved for future) require.False(t, typeSet[StoreBalance], "StoreBalance should not be in AllEVMStoreTypes yet") @@ -36,7 +36,7 @@ func TestStoreTypeName(t *testing.T) { {StoreCodeHash, "codehash"}, {StoreCode, "code"}, {StoreStorage, "storage"}, - {StoreMisc, "legacy"}, + {StoreLegacy, "legacy"}, {StoreBalance, "balance"}, {StoreEmpty, "unknown"}, } diff --git a/sei-db/state_db/ss/evm/db_test.go b/sei-db/state_db/ss/evm/db_test.go index 4b7682f302..4f441fa9ff 100644 --- a/sei-db/state_db/ss/evm/db_test.go +++ b/sei-db/state_db/ss/evm/db_test.go @@ -382,7 +382,7 @@ func TestParseKey(t *testing.T) { key := []byte{0xff, 0x01, 0x02} storeType, keyBytes := commonevm.ParseEVMKey(key) - require.Equal(t, StoreMisc, storeType) + require.Equal(t, StoreLegacy, storeType) require.Equal(t, key, keyBytes) }) @@ -390,7 +390,7 @@ func TestParseKey(t *testing.T) { key := []byte{0x03, 0x01, 0x02} storeType, keyBytes := commonevm.ParseEVMKey(key) - require.Equal(t, StoreMisc, storeType) + require.Equal(t, StoreLegacy, storeType) require.Equal(t, key, keyBytes) }) @@ -400,7 +400,7 @@ func TestParseKey(t *testing.T) { key := append([]byte{0x09}, addr...) storeType, keyBytes := commonevm.ParseEVMKey(key) - require.Equal(t, StoreMisc, storeType) + require.Equal(t, StoreLegacy, storeType) require.Equal(t, key, keyBytes) }) } diff --git a/sei-db/state_db/ss/evm/types.go b/sei-db/state_db/ss/evm/types.go index 5f0d9d77a0..bac3552a0d 100644 --- a/sei-db/state_db/ss/evm/types.go +++ b/sei-db/state_db/ss/evm/types.go @@ -22,7 +22,7 @@ const ( StoreCodeHash = commonevm.EVMKeyCodeHash StoreCode = commonevm.EVMKeyCode StoreStorage = commonevm.EVMKeyStorage - StoreMisc = commonevm.EVMKeyMisc // Catch-all: codesize, address mappings, receipts, etc. + StoreLegacy = commonevm.EVMKeyLegacy // Catch-all: codesize, address mappings, receipts, etc. // StoreBalance is reserved for future migration; balances currently use tendermint store StoreBalance EVMStoreType = 100 ) @@ -35,7 +35,7 @@ func AllEVMStoreTypes() []EVMStoreType { StoreCodeHash, StoreCode, StoreStorage, - StoreMisc, + StoreLegacy, } } @@ -50,7 +50,7 @@ func StoreTypeName(st EVMStoreType) string { return "code" case StoreStorage: return "storage" - case StoreMisc: + case StoreLegacy: return "legacy" case StoreBalance: return "balance" diff --git a/sei-db/state_db/statewal/state_wal.go b/sei-db/state_db/statewal/state_wal.go deleted file mode 100644 index cb7fa7dc74..0000000000 --- a/sei-db/state_db/statewal/state_wal.go +++ /dev/null @@ -1,95 +0,0 @@ -package statewal - -import ( - "github.com/sei-protocol/sei-chain/sei-db/proto" - "github.com/sei-protocol/sei-chain/sei-db/seiwal" -) - -// A WAL for state. -// -// A StateWAL is not safe for concurrent use. Callers must serialize their calls to a single instance; -// in particular Write and SignalEndOfBlock share write-ordering state that is not internally locked. -// -// Slices are not copied at the call boundary. Changesets passed to Write — and every byte slice reachable -// through them — must not be modified after the call: the WAL retains them and serializes them -// asynchronously, so mutating them races the WAL and can corrupt what is persisted. Likewise the changesets -// returned by the iterator are owned by the WAL and must be treated as read-only. Callers that need to -// mutate such data must copy it first. -type StateWAL interface { - - // Write a set of changes to the WAL. - // - // This method only schedules the write, it does not block until the write is complete. - // - // cs, and every byte slice reachable through it (changeset keys and values), must not be modified after - // this call. Callers that need to modify those buffers must copy them first. - // - // A nil entry in cs is rejected synchronously with an error and leaves the WAL usable; cs itself may be - // nil or empty. - // - // The StateWAL rejects writes for blocks if provided out of order. To avoid errors, observe - // the following rules: - // - // - The block numbers passed to Write() may never decrease. - // - If data has been written for block N, you cannot write data for block N+1 until you have called - // SignalEndOfBlock(). - Write( - // The block number associated with the changeset. - blockNumber uint64, - // The changeset to write. - cs []*proto.NamedChangeSet, - ) error - - // Signal that there will be no more writes for the current block number. Attempting to write additional - // changes for the same block number after calling this method may result in an error. - // - // Similar to Write(), this method is asynchronous. Calling this method does not, by itself, make - // data immediately crash durable. - SignalEndOfBlock() error - - // Flush the WAL to disk. Only completed blocks — those for which SignalEndOfBlock has been called — are - // made crash durable; changes for a block that has not yet been ended remain buffered and are not flushed. - Flush() error - - // Get the range of block numbers stored in the WAL. - GetStoredRange() ( - // If true, there is data in the WAL. If false, the WAL is empty and startBlockNumber and - // endBlockNumber are undefined. - ok bool, - // The lowest block number stored in the WAL, inclusive. Only valid if ok is true. - startBlockNumber uint64, - // The highest block number stored in the WAL, inclusive. Only valid if ok is true. - endBlockNumber uint64, - // Any error encountered while retrieving the range. - err error, - ) - - // Prune the WAL, removing all entries with block numbers less than lowestBlockNumberToKeep. - // - // This method merely schedules the prune operation, it does not block until the prune is complete. Pruning - // is async and lazy, and implementations are free to delay pruning arbitrarily long. If crashed or closed - // before the prune is complete, the WAL may not attempt to prune again on the next open unless Prune() is - // called again or for a higher block number. - Prune(lowestBlockNumberToKeep uint64) error - - // Create an iterator over the WAL across the inclusive block range [startingBlockNumber, endingBlockNumber]. - // - // The iterator yields no block below startingBlockNumber or above endingBlockNumber. It is an error for - // endingBlockNumber to be below startingBlockNumber, or for endingBlockNumber to be above the highest block - // number currently stored in the WAL (including when the WAL is empty); both are reported as - // seiwal.ErrIteratorRange and leave the WAL usable. - // - // The iterator reads a consistent, point-in-time snapshot of the WAL taken at some instant between the - // start and the return of this call. Data written before that instant is included; data written after it - // is not. For data written concurrently with this call, whether it is included is unspecified. - // - // The iterator yields one entry per block in ascending block order. Its Entry() returns (blockNumber, - // changesets), where changesets are all the changes written for that block (across one or more Write - // calls) combined in write order. Blocks that were never ended with SignalEndOfBlock are not yielded. - // The returned changesets, and every byte slice reachable through them, must be treated as read-only. - Iterator(startingBlockNumber uint64, endingBlockNumber uint64) (seiwal.Iterator[[]*proto.NamedChangeSet], error) - - // Close the WAL, flushing complete blocks (those ended with SignalEndOfBlock) to disk and releasing - // resources. Changes for a block that was not ended with SignalEndOfBlock are discarded. - Close() error -} diff --git a/sei-db/state_db/statewal/state_wal_brick_test.go b/sei-db/state_db/statewal/state_wal_brick_test.go deleted file mode 100644 index a047a2d78f..0000000000 --- a/sei-db/state_db/statewal/state_wal_brick_test.go +++ /dev/null @@ -1,163 +0,0 @@ -package statewal - -import ( - "errors" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/sei-protocol/sei-chain/sei-db/proto" - "github.com/sei-protocol/sei-chain/sei-db/seiwal" -) - -// errInjected is the sentinel failure returned by fakeWAL to simulate a fatal underlying-WAL error. -var errInjected = errors.New("injected failure") - -// fakeWAL is a seiwal.WAL whose methods return an injected error when the corresponding field is set, -// letting tests drive each of the wrapper's fatal error paths. Errors are set after construction so the -// initial Bounds call in newStateWAL succeeds. -type fakeWAL struct { - appendErr error - flushErr error - boundsErr error - pruneErr error - iteratorErr error - - // The indices successfully appended, so tests can assert that a failed append persisted nothing. - appended []uint64 -} - -var _ seiwal.WAL[[]*proto.NamedChangeSet] = (*fakeWAL)(nil) - -func (f *fakeWAL) Append(index uint64, data []*proto.NamedChangeSet) error { - if f.appendErr != nil { - return f.appendErr - } - f.appended = append(f.appended, index) - return nil -} - -func (f *fakeWAL) Flush() error { - return f.flushErr -} - -func (f *fakeWAL) Bounds() (bool, uint64, uint64, error) { - if f.boundsErr != nil { - return false, 0, 0, f.boundsErr - } - return false, 0, 0, nil -} - -func (f *fakeWAL) PruneBefore(lowestIndexToKeep uint64) error { - return f.pruneErr -} - -func (f *fakeWAL) Iterator(startIndex uint64, endIndex uint64) (seiwal.Iterator[[]*proto.NamedChangeSet], error) { - if f.iteratorErr != nil { - return nil, f.iteratorErr - } - return nil, nil -} - -func (f *fakeWAL) Close() error { - return nil -} - -func newFakeStateWAL(t *testing.T, f *fakeWAL) StateWAL { - t.Helper() - w, err := newStateWAL(f) - require.NoError(t, err) - return w -} - -// requireBricked asserts that every operation fails with the fatal error the WAL was bricked by. -func requireBricked(t *testing.T, w StateWAL) { - t.Helper() - require.ErrorIs(t, w.Write(999, nil), errInjected) - require.ErrorIs(t, w.SignalEndOfBlock(), errInjected) - require.ErrorIs(t, w.Flush(), errInjected) - _, _, _, err := w.GetStoredRange() - require.ErrorIs(t, err, errInjected) - require.ErrorIs(t, w.Prune(1), errInjected) - _, err = w.Iterator(0, 0) - require.ErrorIs(t, err, errInjected) -} - -// TestFatalErrorsBrickWAL verifies that a fatal error from any underlying-WAL operation permanently -// bricks the wrapper, so every subsequent operation fails fast rather than limping onward. -func TestFatalErrorsBrickWAL(t *testing.T) { - t.Run("append", func(t *testing.T) { - f := &fakeWAL{} - w := newFakeStateWAL(t, f) - f.appendErr = errInjected - require.NoError(t, w.Write(1, nil)) - require.ErrorIs(t, w.SignalEndOfBlock(), errInjected) - requireBricked(t, w) - }) - - t.Run("flush", func(t *testing.T) { - f := &fakeWAL{} - w := newFakeStateWAL(t, f) - f.flushErr = errInjected - require.ErrorIs(t, w.Flush(), errInjected) - requireBricked(t, w) - }) - - t.Run("bounds", func(t *testing.T) { - f := &fakeWAL{} - w := newFakeStateWAL(t, f) - f.boundsErr = errInjected - _, _, _, err := w.GetStoredRange() - require.ErrorIs(t, err, errInjected) - requireBricked(t, w) - }) - - t.Run("prune", func(t *testing.T) { - f := &fakeWAL{} - w := newFakeStateWAL(t, f) - f.pruneErr = errInjected - require.ErrorIs(t, w.Prune(1), errInjected) - requireBricked(t, w) - }) - - t.Run("iterator", func(t *testing.T) { - f := &fakeWAL{} - w := newFakeStateWAL(t, f) - f.iteratorErr = errInjected - _, err := w.Iterator(0, 0) - require.ErrorIs(t, err, errInjected) - requireBricked(t, w) - }) -} - -// TestAppendFailureDoesNotSilentlyAdvance verifies the Bugbot scenario: when the append for a block fails, -// the block is not silently finalized. The WAL is bricked, so a write to the next block is rejected rather -// than skipping the lost block. -func TestAppendFailureDoesNotSilentlyAdvance(t *testing.T) { - f := &fakeWAL{appendErr: errInjected} - w := newFakeStateWAL(t, f) - - require.NoError(t, w.Write(1, nil)) - require.ErrorIs(t, w.SignalEndOfBlock(), errInjected) - - require.ErrorIs(t, w.Write(2, nil), errInjected) - require.Empty(t, f.appended) -} - -// TestCallerViolationsDoNotBrick verifies that a caller-contract rejection (an out-of-order write) leaves -// the WAL fully usable, since it corrupts no state. -func TestCallerViolationsDoNotBrick(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) - defer func() { require.NoError(t, w.Close()) }() - - writeBlock(t, w, 5) - require.Error(t, w.Write(4, nil)) // block numbers may not decrease - - // The WAL is not bricked: a subsequent valid write still succeeds and is durable. - writeBlock(t, w, 6) - require.NoError(t, w.Flush()) - ok, _, end, err := w.GetStoredRange() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(6), end) -} diff --git a/sei-db/state_db/statewal/state_wal_config.go b/sei-db/state_db/statewal/state_wal_config.go deleted file mode 100644 index 6aa7eec9bc..0000000000 --- a/sei-db/state_db/statewal/state_wal_config.go +++ /dev/null @@ -1,78 +0,0 @@ -package statewal - -import ( - "time" - - "github.com/sei-protocol/sei-chain/sei-db/seiwal" -) - -// Configuration for a state WAL. -type Config struct { - // The directory where the WAL writes its files. - Path string - - // A short identifier for this WAL instance, used to distinguish its metrics from those of other - // instances in the same process. Required; must match [a-zA-Z0-9_-]+. - Name string - - // The size of the channel used to send work from the caller to the serialization goroutine. - RequestBufferSize uint - - // The size of the channel used to send framed records from the underlying WAL's serialization to its - // writer goroutine. - WriteBufferSize uint - - // The size a WAL file may reach before it is sealed and a fresh one is opened. Because each block is - // written as a single record, a file may exceed this by the size of one block's serialized changesets. - // Must be greater than 0. - TargetFileSize uint - - // When true, Flush calls fsync on the underlying file so that flushed data survives a power loss, not - // merely a process crash. When false, Flush only flushes the in-process buffer to the OS. - FsyncOnFlush bool - - // The number of blocks an iterator's reader thread may prefetch ahead of the consumer. A larger value - // keeps the reader busy while the consumer processes blocks, which matters for startup replay speed. - // Must be greater than 0. - IteratorPrefetchSize uint - - // The interval at which the underlying WAL samples the buffered depth of its internal channels into the - // seiwal_queue_depth gauge. Zero or negative disables sampling. - MetricsSampleInterval time.Duration -} - -// Constructor for a default state WAL configuration for the WAL at path, identified by name. -func DefaultConfig(path string, name string) *Config { - s := seiwal.DefaultConfig(path, name) - return &Config{ - Path: path, - Name: name, - RequestBufferSize: 16, - WriteBufferSize: s.WriteBufferSize, - TargetFileSize: s.TargetFileSize, - FsyncOnFlush: s.FsyncOnFlush, - IteratorPrefetchSize: s.IteratorPrefetchSize, - MetricsSampleInterval: s.MetricsSampleInterval, - } -} - -// Validate the configuration, returning nil if valid, or an error describing the problem if invalid. -func (c *Config) Validate() error { - return c.toSeiwalConfig().Validate() -} - -// toSeiwalConfig maps this configuration onto the underlying generic WAL's configuration. -func (c *Config) toSeiwalConfig() *seiwal.Config { - return &seiwal.Config{ - Path: c.Path, - Name: c.Name, - WriteBufferSize: c.WriteBufferSize, - SerializerBufferSize: c.RequestBufferSize, - TargetFileSize: c.TargetFileSize, - FsyncOnFlush: c.FsyncOnFlush, - // State blocks must be contiguous — no skipped blocks — so the underlying WAL rejects gaps. - PermitGaps: false, - IteratorPrefetchSize: c.IteratorPrefetchSize, - MetricsSampleInterval: c.MetricsSampleInterval, - } -} diff --git a/sei-db/state_db/statewal/state_wal_config_test.go b/sei-db/state_db/statewal/state_wal_config_test.go deleted file mode 100644 index 4d28024e7b..0000000000 --- a/sei-db/state_db/statewal/state_wal_config_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package statewal - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestConfigValidate(t *testing.T) { - t.Run("default config is valid", func(t *testing.T) { - require.NoError(t, DefaultConfig("/tmp/wal", "test").Validate()) - }) - - t.Run("empty path is rejected", func(t *testing.T) { - cfg := DefaultConfig("", "test") - require.Error(t, cfg.Validate()) - }) - - t.Run("empty name is rejected", func(t *testing.T) { - cfg := DefaultConfig("/tmp/wal", "") - require.Error(t, cfg.Validate()) - }) - - t.Run("malformed name is rejected", func(t *testing.T) { - cfg := DefaultConfig("/tmp/wal", "bad name!") - require.Error(t, cfg.Validate()) - }) - - t.Run("zero target file size is rejected", func(t *testing.T) { - cfg := DefaultConfig("/tmp/wal", "test") - cfg.TargetFileSize = 0 - require.Error(t, cfg.Validate()) - }) - - t.Run("zero iterator prefetch size is rejected", func(t *testing.T) { - cfg := DefaultConfig("/tmp/wal", "test") - cfg.IteratorPrefetchSize = 0 - require.Error(t, cfg.Validate()) - }) -} diff --git a/sei-db/state_db/statewal/state_wal_impl.go b/sei-db/state_db/statewal/state_wal_impl.go deleted file mode 100644 index 5dbc9e7de1..0000000000 --- a/sei-db/state_db/statewal/state_wal_impl.go +++ /dev/null @@ -1,281 +0,0 @@ -package statewal - -import ( - "errors" - "fmt" - - "github.com/sei-protocol/sei-chain/sei-db/proto" - "github.com/sei-protocol/sei-chain/sei-db/seiwal" -) - -var _ StateWAL = (*stateWALImpl)(nil) - -// A WAL for storing state changesets by block number. -// -// Not safe for concurrent use; see the StateWAL interface doc. -type stateWALImpl struct { - // The underlying generic WAL, keyed by block number, whose payload is a block's changesets. - wal seiwal.WAL[[]*proto.NamedChangeSet] - - // Set by Close() so subsequent calls fail fast. A plain field: like the write-ordering state below, it - // is only ever touched by the single caller, which must not invoke methods concurrently. - closed bool - - // The first fatal error from the underlying WAL that bricked this one, surfaced to the caller by every - // subsequent operation. Once set, no operation touches the underlying WAL, so a corrupt WAL never - // limps onward. Caller-serialized like closed. - fatalErr error - - // The write-ordering contract state and the accumulation buffer below are mutated by Write and - // SignalEndOfBlock, which callers must not invoke concurrently. - - // The block number of the most recent Write or SignalEndOfBlock. - currentBlock uint64 - // Whether currentBlock has been finalized by SignalEndOfBlock. - currentBlockEnded bool - // Whether any block has been observed (this session or recovered from disk). - hasCurrentBlock bool - // The changesets accumulated for the current block across its Write calls, appended as one record at - // end-of-block. Ownership is handed to the WAL at end-of-block and a fresh buffer starts for the next - // block, so the serialization goroutine never races the wrapper over the backing array. - buf []*proto.NamedChangeSet -} - -// New opens (or creates) a state WAL in the configured directory, recovering any files left behind by a -// previous session. -func New(config *Config) (StateWAL, error) { - wal, err := seiwal.NewGenericWAL[[]*proto.NamedChangeSet]( - config.toSeiwalConfig(), serializeChangesets, deserializeChangesets) - if err != nil { - return nil, fmt.Errorf("failed to open state WAL: %w", err) - } - return newStateWAL(wal) -} - -// GetRange reports the range of block numbers stored in the state WAL directory configured by config, -// without constructing a live StateWAL. Like the seiwal function it wraps, it runs the recovery/sanity pass -// (which seals any unsealed file left by a prior session) before reading, so it mutates the directory. -// -// The range is read from sealed file names only, so its cost is a directory listing regardless of how much -// data the WAL holds; content is not checked. Use VerifyIntegrity to check for corruption. -// -// NOT SAFE FOR CONCURRENT USE with a live StateWAL, or with another GetRange/PruneAfter, on the same -// directory: it seals files a running WAL owns. Call it only while no StateWAL is open there (e.g. offline, -// at startup before New). For a range query against a live WAL use the instance method GetStoredRange -// instead. -func GetRange(config *Config) (bool, uint64, uint64, error) { - ok, first, last, err := seiwal.GetRange(config.Path) - if err != nil { - return false, 0, 0, fmt.Errorf("failed to read state WAL range: %w", err) - } - return ok, first, last, nil -} - -// PruneAfter deletes all data for blocks after highestBlockToKeep from the state WAL directory configured by -// config, without constructing a live StateWAL. It runs the recovery/sanity pass, applies the rollback, and -// re-scans the result structurally (file names / sequence contiguity, not contents); blocks with a number -// <= highestBlockToKeep are kept. -// -// NOT SAFE FOR CONCURRENT USE with a live StateWAL, or with another GetRange/PruneAfter, on the same -// directory: it seals, rewrites, and removes files a running WAL owns. Call it only while no StateWAL is open -// there (e.g. offline, at startup before New). -func PruneAfter(config *Config, highestBlockToKeep uint64) error { - if err := seiwal.PruneAfter(config.Path, highestBlockToKeep); err != nil { - return fmt.Errorf("failed to prune state WAL: %w", err) - } - return nil -} - -// VerifyIntegrity reads every sealed file in the state WAL directory configured by config and confirms each -// record's CRC and each file's name-versus-content range. This is the expensive O(total stored bytes) check -// that New/GetRange/PruneAfter deliberately skip; call it only when corruption is suspected. It is read-only -// and reports every problem it finds in a single pass, returning nil when the durable log is clean. -// -// NOT SAFE FOR CONCURRENT USE with a live StateWAL, or with GetRange/PruneAfter, on the same directory. Call -// it only while no StateWAL is open there (e.g. offline, at startup before New). -func VerifyIntegrity(config *Config) error { - if err := seiwal.VerifyIntegrity(config.Path); err != nil { - return fmt.Errorf("state WAL integrity check failed: %w", err) - } - return nil -} - -func newStateWAL(wal seiwal.WAL[[]*proto.NamedChangeSet]) (StateWAL, error) { - w := &stateWALImpl{wal: wal} - - // Recover the write-ordering position from the highest block already on disk. - ok, _, last, err := wal.Bounds() - if err != nil { - _ = wal.Close() - return nil, fmt.Errorf("failed to read WAL bounds: %w", err) - } - if ok { - w.currentBlock = last - w.currentBlockEnded = true - w.hasCurrentBlock = true - } - return w, nil -} - -// Write accumulates a set of changes for the given block number in memory. -func (w *stateWALImpl) Write(blockNumber uint64, cs []*proto.NamedChangeSet) error { - if w.closed { - return fmt.Errorf("state WAL is closed") - } - if w.fatalErr != nil { - return fmt.Errorf("state WAL failed: %w", w.fatalErr) - } - for i, ncs := range cs { - if ncs == nil { - return fmt.Errorf("write rejected: changeset at index %d is nil", i) - } - } - if err := w.enforceWriteOrdering(blockNumber); err != nil { - return fmt.Errorf("write rejected: %w", err) - } - w.buf = append(w.buf, cs...) - return nil -} - -// SignalEndOfBlock appends the current block's accumulated changesets to the WAL as a single record. -func (w *stateWALImpl) SignalEndOfBlock() error { - if w.closed { - return fmt.Errorf("state WAL is closed") - } - if w.fatalErr != nil { - return fmt.Errorf("state WAL failed: %w", w.fatalErr) - } - - if !w.hasCurrentBlock || w.currentBlockEnded { - return fmt.Errorf("no block in progress to end") - } - - // Commit the finalization state only after the append succeeds; a failed append bricks the WAL and - // leaves the block in progress rather than silently finalizing a block whose changesets were lost. - if err := w.wal.Append(w.currentBlock, w.buf); err != nil { - return w.fail(fmt.Errorf("failed to append block %d: %w", w.currentBlock, err)) - } - w.currentBlockEnded = true - w.buf = nil // hand ownership to the WAL; the next block starts a fresh buffer - return nil -} - -// enforceWriteOrdering rejects a Write that violates the block-ordering rules (no decreasing block numbers; no -// advancing to a new block before the current one is ended) and records the new position when it is allowed. -func (w *stateWALImpl) enforceWriteOrdering(blockNumber uint64) error { - if !w.hasCurrentBlock { - w.currentBlock = blockNumber - w.currentBlockEnded = false - w.hasCurrentBlock = true - return nil - } - if blockNumber < w.currentBlock { - return fmt.Errorf( - "block number %d is less than the current block number %d", blockNumber, w.currentBlock) - } - if blockNumber == w.currentBlock { - if w.currentBlockEnded { - return fmt.Errorf( - "block number %d has already ended; cannot write more changes to it", blockNumber) - } - return nil - } - // blockNumber > currentBlock - if !w.currentBlockEnded { - return fmt.Errorf( - "cannot write block %d before calling SignalEndOfBlock for block %d", - blockNumber, w.currentBlock) - } - if blockNumber != w.currentBlock+1 { - return fmt.Errorf("block number %d is not contiguous with the current block number %d (expected %d)", - blockNumber, w.currentBlock, w.currentBlock+1) - } - w.currentBlock = blockNumber - w.currentBlockEnded = false - return nil -} - -// Flush blocks until all previously scheduled writes are durable. -func (w *stateWALImpl) Flush() error { - if w.closed { - return fmt.Errorf("state WAL is closed") - } - if w.fatalErr != nil { - return fmt.Errorf("state WAL failed: %w", w.fatalErr) - } - if err := w.wal.Flush(); err != nil { - return w.fail(fmt.Errorf("failed to flush state WAL: %w", err)) - } - return nil -} - -// GetStoredRange reports the range of complete blocks stored in the WAL. -func (w *stateWALImpl) GetStoredRange() (bool, uint64, uint64, error) { - if w.closed { - return false, 0, 0, fmt.Errorf("state WAL is closed") - } - if w.fatalErr != nil { - return false, 0, 0, fmt.Errorf("state WAL failed: %w", w.fatalErr) - } - ok, first, last, err := w.wal.Bounds() - if err != nil { - return false, 0, 0, w.fail(fmt.Errorf("failed to read WAL bounds: %w", err)) - } - return ok, first, last, nil -} - -// Prune schedules removal of whole underlying files below lowestBlockNumberToKeep. It does not block on -// completion. -func (w *stateWALImpl) Prune(lowestBlockNumberToKeep uint64) error { - if w.closed { - return fmt.Errorf("state WAL is closed") - } - if w.fatalErr != nil { - return fmt.Errorf("state WAL failed: %w", w.fatalErr) - } - if err := w.wal.PruneBefore(lowestBlockNumberToKeep); err != nil { - return w.fail(fmt.Errorf("failed to prune state WAL: %w", err)) - } - return nil -} - -// Iterator returns an iterator over the inclusive block range [startingBlockNumber, endingBlockNumber]. It -// yields (blockNumber, changesets) directly from the underlying generic WAL. -func (w *stateWALImpl) Iterator( - startingBlockNumber uint64, - endingBlockNumber uint64, -) (seiwal.Iterator[[]*proto.NamedChangeSet], error) { - if w.closed { - return nil, fmt.Errorf("state WAL is closed") - } - if w.fatalErr != nil { - return nil, fmt.Errorf("state WAL failed: %w", w.fatalErr) - } - it, err := w.wal.Iterator(startingBlockNumber, endingBlockNumber) - if err != nil { - // A rejected range is the caller's error and leaves the WAL usable; only a genuine WAL failure bricks. - if errors.Is(err, seiwal.ErrIteratorRange) { - return nil, fmt.Errorf("failed to create WAL iterator: %w", err) - } - return nil, w.fail(fmt.Errorf("failed to create WAL iterator: %w", err)) - } - return it, nil -} - -// Close flushes pending writes, closes the underlying WAL, and releases resources. -func (w *stateWALImpl) Close() error { - w.closed = true - if err := w.wal.Close(); err != nil { - return fmt.Errorf("failed to close state WAL: %w", err) - } - return nil -} - -// fail records err as the first fatal error that bricks the WAL and returns it. Once set, every -// subsequent operation fails fast rather than touching the underlying WAL. -func (w *stateWALImpl) fail(err error) error { - if w.fatalErr == nil { - w.fatalErr = err - } - return err -} diff --git a/sei-db/state_db/statewal/state_wal_impl_test.go b/sei-db/state_db/statewal/state_wal_impl_test.go deleted file mode 100644 index 749b80fa8d..0000000000 --- a/sei-db/state_db/statewal/state_wal_impl_test.go +++ /dev/null @@ -1,349 +0,0 @@ -package statewal - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/sei-protocol/sei-chain/sei-db/proto" - "github.com/stretchr/testify/require" -) - -func testConfig(dir string) *Config { - return DefaultConfig(dir, "test") -} - -func openWAL(t *testing.T, cfg *Config) StateWAL { - t.Helper() - w, err := New(cfg) - require.NoError(t, err) - return w -} - -// writeBlock writes a single changeset for the block and signals end of block. -func writeBlock(t *testing.T, w StateWAL, block uint64) { - t.Helper() - cs := []*proto.NamedChangeSet{makeChangeSet("evm", []byte{byte(block)}, []byte{byte(block)})} - require.NoError(t, w.Write(block, cs)) - require.NoError(t, w.SignalEndOfBlock()) -} - -// collectBlocks iterates the inclusive range [start, end] and returns the block number of each entry, -// verifying that entries are strictly increasing and never below start or above end. -func collectBlocks(t *testing.T, w StateWAL, start uint64, end uint64) []uint64 { - t.Helper() - it, err := w.Iterator(start, end) - require.NoError(t, err) - defer func() { require.NoError(t, it.Close()) }() - - var blocks []uint64 - for { - ok, err := it.Next() - require.NoError(t, err) - if !ok { - break - } - blockNumber, _ := it.Entry() - require.GreaterOrEqual(t, blockNumber, start) - require.LessOrEqual(t, blockNumber, end) - if len(blocks) > 0 { - require.Greater(t, blockNumber, blocks[len(blocks)-1]) - } - blocks = append(blocks, blockNumber) - } - return blocks -} - -func TestWriteFlushReopenGetRange(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - - w := openWAL(t, cfg) - for block := uint64(1); block <= 5; block++ { - writeBlock(t, w, block) - } - require.NoError(t, w.Flush()) - - ok, start, end, err := w.GetStoredRange() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), start) - require.Equal(t, uint64(5), end) - require.NoError(t, w.Close()) - - w2 := openWAL(t, cfg) - defer func() { require.NoError(t, w2.Close()) }() - - ok, start, end, err = w2.GetStoredRange() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), start) - require.Equal(t, uint64(5), end) - - require.Equal(t, []uint64{1, 2, 3, 4, 5}, collectBlocks(t, w2, 1, 5)) -} - -// TestWriteRejectsNilChangeset verifies that a nil changeset entry is rejected synchronously at the Write call -// site rather than surfacing later from the serialization goroutine, and that the rejection neither bricks the -// WAL nor advances the block-ordering state. -func TestWriteRejectsNilChangeset(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) - defer func() { require.NoError(t, w.Close()) }() - - valid := makeChangeSet("evm", []byte("k"), []byte("v")) - - // A nil entry is rejected synchronously at the call site, before SignalEndOfBlock/Flush is ever reached. - err := w.Write(5, []*proto.NamedChangeSet{valid, nil}) - require.Error(t, err) - require.Contains(t, err.Error(), "nil") - - // The rejected write is a no-op: it neither bricked the WAL nor advanced the block-ordering state. Were the - // state advanced to block 5, this write to the lower block 3 would be rejected as decreasing. - require.NoError(t, w.Write(3, []*proto.NamedChangeSet{valid})) - require.NoError(t, w.SignalEndOfBlock()) - require.NoError(t, w.Flush()) - - ok, start, end, err := w.GetStoredRange() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(3), start) - require.Equal(t, uint64(3), end) -} - -func TestContractViolations(t *testing.T) { - t.Run("block numbers may not decrease", func(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) - defer func() { require.NoError(t, w.Close()) }() - writeBlock(t, w, 5) - require.Error(t, w.Write(4, nil)) - }) - - t.Run("cannot advance block without ending the previous one", func(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) - defer func() { require.NoError(t, w.Close()) }() - require.NoError(t, w.Write(1, nil)) - require.Error(t, w.Write(2, nil)) - }) - - t.Run("cannot skip a block", func(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) - defer func() { require.NoError(t, w.Close()) }() - writeBlock(t, w, 1) - require.Error(t, w.Write(3, nil)) // block 2 was skipped - require.NoError(t, w.Write(2, nil)) - }) - - t.Run("cannot write to an ended block", func(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) - defer func() { require.NoError(t, w.Close()) }() - require.NoError(t, w.Write(1, nil)) - require.NoError(t, w.SignalEndOfBlock()) - require.Error(t, w.Write(1, nil)) - }) - - t.Run("end of block with no block in progress is an error", func(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) - defer func() { require.NoError(t, w.Close()) }() - require.Error(t, w.SignalEndOfBlock()) - }) - - t.Run("multiple writes to the same block are allowed before end of block", func(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) - defer func() { require.NoError(t, w.Close()) }() - require.NoError(t, w.Write(1, []*proto.NamedChangeSet{makeChangeSet("a", []byte("k1"), []byte("v1"))})) - require.NoError(t, w.Write(1, []*proto.NamedChangeSet{makeChangeSet("b", []byte("k2"), []byte("v2"))})) - require.NoError(t, w.SignalEndOfBlock()) - }) -} - -func TestIncompleteBlockDiscardedOnReopen(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - - w := openWAL(t, cfg) - for block := uint64(1); block <= 3; block++ { - writeBlock(t, w, block) - } - // Block 4 is written but never ended (a crash mid-block): it was never appended as a record. - require.NoError(t, w.Write(4, []*proto.NamedChangeSet{makeChangeSet("evm", []byte{0x04}, []byte{0x04})})) - require.NoError(t, w.Flush()) - require.NoError(t, w.Close()) - - w2 := openWAL(t, cfg) - defer func() { require.NoError(t, w2.Close()) }() - - ok, start, end, err := w2.GetStoredRange() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), start) - require.Equal(t, uint64(3), end) - require.Equal(t, []uint64{1, 2, 3}, collectBlocks(t, w2, 1, 3)) - - // Block 4 may now be re-executed cleanly. - writeBlock(t, w2, 4) - require.NoError(t, w2.Flush()) - ok, _, end, err = w2.GetStoredRange() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(4), end) -} - -func TestGetStoredRangeEmpty(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) - defer func() { require.NoError(t, w.Close()) }() - - ok, _, _, err := w.GetStoredRange() - require.NoError(t, err) - require.False(t, ok) -} - -func TestEmptyChangesetBlockIsStored(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) - defer func() { require.NoError(t, w.Close()) }() - - // A block with an empty changeset that is properly ended is a real, stored block. - require.NoError(t, w.Write(1, []*proto.NamedChangeSet{})) - require.NoError(t, w.SignalEndOfBlock()) - require.NoError(t, w.Flush()) - - ok, start, end, err := w.GetStoredRange() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), start) - require.Equal(t, uint64(1), end) - - it, err := w.Iterator(1, 1) - require.NoError(t, err) - defer func() { require.NoError(t, it.Close()) }() - ok, err = it.Next() - require.NoError(t, err) - require.True(t, ok) - blockNumber, changeset := it.Entry() - require.Equal(t, uint64(1), blockNumber) - require.Empty(t, changeset) -} - -func TestPruneDropsOldBlocks(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = 1 // one block per file, so pruning can drop whole files - - w := openWAL(t, cfg) - defer func() { require.NoError(t, w.Close()) }() - for block := uint64(1); block <= 10; block++ { - writeBlock(t, w, block) - } - require.NoError(t, w.Flush()) - - require.NoError(t, w.Prune(5)) - - ok, start, end, err := w.GetStoredRange() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(5), start) - require.Equal(t, uint64(10), end) - require.Equal(t, []uint64{5, 6, 7, 8, 9, 10}, collectBlocks(t, w, 0, 10)) -} - -// TestGetRange is a wrapper-level smoke test that the standalone GetRange reports the stored block range of a -// directory without a live StateWAL (the seal/recovery details are exercised in the seiwal package). -func TestGetRange(t *testing.T) { - t.Run("empty directory reports no blocks", func(t *testing.T) { - ok, _, _, err := GetRange(testConfig(t.TempDir())) - require.NoError(t, err) - require.False(t, ok) - }) - - t.Run("reports the range of a cleanly closed WAL", func(t *testing.T) { - cfg := testConfig(t.TempDir()) - w := openWAL(t, cfg) - for block := uint64(1); block <= 4; block++ { - writeBlock(t, w, block) - } - require.NoError(t, w.Close()) - - ok, start, end, err := GetRange(cfg) - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), start) - require.Equal(t, uint64(4), end) - }) -} - -// TestPruneAfter is a wrapper-level smoke test that PruneAfter drops blocks beyond the rollback point end to -// end (the crash-safety details are exercised in the seiwal package). -func TestPruneAfter(t *testing.T) { - for _, tc := range []struct { - name string - targetSize uint - }{ - {"whole-file removal", 1}, // one block per file: rollback removes whole trailing files - {"in-file truncation", 64 * 1024 * 1024}, // all blocks in one file: rollback truncates it in place - } { - t.Run(tc.name, func(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = tc.targetSize - - w := openWAL(t, cfg) - for block := uint64(1); block <= 6; block++ { - writeBlock(t, w, block) - } - require.NoError(t, w.Close()) - - require.NoError(t, PruneAfter(cfg, 3)) - w2 := openWAL(t, cfg) - defer func() { require.NoError(t, w2.Close()) }() - - ok, start, end, err := w2.GetStoredRange() - require.NoError(t, err) - require.True(t, ok) - require.Equal(t, uint64(1), start) - require.Equal(t, uint64(3), end) - require.Equal(t, []uint64{1, 2, 3}, collectBlocks(t, w2, 1, 3)) - - // Writing continues cleanly after the rollback point. - writeBlock(t, w2, 4) - require.NoError(t, w2.Flush()) - _, _, end, err = w2.GetStoredRange() - require.NoError(t, err) - require.Equal(t, uint64(4), end) - }) - } -} - -// TestVerifyIntegrity is a wrapper-level smoke test that VerifyIntegrity passes on a clean log and reports a -// fault when a sealed file is corrupted (the detailed cases are exercised in the seiwal package). -func TestVerifyIntegrity(t *testing.T) { - dir := t.TempDir() - cfg := testConfig(dir) - cfg.TargetFileSize = 1 // one sealed file per block - - w := openWAL(t, cfg) - for block := uint64(1); block <= 5; block++ { - writeBlock(t, w, block) - } - require.NoError(t, w.Close()) - - require.NoError(t, VerifyIntegrity(cfg)) - - // Corrupt a byte in one sealed file's body; the on-demand scan must surface it. - entries, err := os.ReadDir(dir) - require.NoError(t, err) - var sealed string - for _, entry := range entries { - if strings.HasSuffix(entry.Name(), ".wal") { - sealed = entry.Name() - break - } - } - require.NotEmpty(t, sealed) - path := filepath.Join(dir, sealed) - data, err := os.ReadFile(path) - require.NoError(t, err) - data[len(data)-5] ^= 0xFF // flip a byte inside the record, before the trailing CRC - require.NoError(t, os.WriteFile(path, data, 0o600)) - - require.Error(t, VerifyIntegrity(cfg)) -} diff --git a/sei-db/state_db/statewal/state_wal_iterator_test.go b/sei-db/state_db/statewal/state_wal_iterator_test.go deleted file mode 100644 index fdf3a8d9f0..0000000000 --- a/sei-db/state_db/statewal/state_wal_iterator_test.go +++ /dev/null @@ -1,159 +0,0 @@ -package statewal - -import ( - "testing" - - "github.com/sei-protocol/sei-chain/sei-db/proto" - "github.com/sei-protocol/sei-chain/sei-db/seiwal" - "github.com/stretchr/testify/require" -) - -// TestIteratorEmptyWALErrors verifies that an empty WAL has no latest block, so any requested end block is -// beyond it: iterator creation fails with ErrIteratorRange rather than bricking the WAL. -func TestIteratorEmptyWALErrors(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) - defer func() { require.NoError(t, w.Close()) }() - - _, err := w.Iterator(0, 0) - require.ErrorIs(t, err, seiwal.ErrIteratorRange) - - // The rejection must not brick the WAL: it still accepts a block and iterates. - writeBlock(t, w, 1) - require.NoError(t, w.Flush()) - require.Equal(t, []uint64{1}, collectBlocks(t, w, 1, 1)) -} - -// TestIteratorRangeErrorDoesNotBrick verifies that an out-of-range request on a non-empty WAL is reported as -// ErrIteratorRange and leaves the WAL usable, rather than bricking the wrapper. -func TestIteratorRangeErrorDoesNotBrick(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) - defer func() { require.NoError(t, w.Close()) }() - for block := uint64(1); block <= 3; block++ { - writeBlock(t, w, block) - } - require.NoError(t, w.Flush()) - - _, err := w.Iterator(1, 4) // end block beyond the latest stored block - require.ErrorIs(t, err, seiwal.ErrIteratorRange) - - require.Equal(t, []uint64{1, 2, 3}, collectBlocks(t, w, 1, 3), "the WAL remains usable after a rejected range") -} - -func TestIteratorFromMiddle(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) - defer func() { require.NoError(t, w.Close()) }() - for block := uint64(1); block <= 5; block++ { - writeBlock(t, w, block) - } - require.NoError(t, w.Flush()) - - require.Equal(t, []uint64{3, 4, 5}, collectBlocks(t, w, 3, 5)) -} - -func TestIteratorYieldsChangesetContents(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) - defer func() { require.NoError(t, w.Close()) }() - - cs := []*proto.NamedChangeSet{makeChangeSet("evm", []byte("key"), []byte("value"))} - require.NoError(t, w.Write(1, cs)) - require.NoError(t, w.SignalEndOfBlock()) - require.NoError(t, w.Flush()) - - it, err := w.Iterator(1, 1) - require.NoError(t, err) - defer func() { require.NoError(t, it.Close()) }() - - ok, err := it.Next() - require.NoError(t, err) - require.True(t, ok) - blockNumber, changeset := it.Entry() - require.Equal(t, uint64(1), blockNumber) - require.Len(t, changeset, 1) - require.Equal(t, "evm", changeset[0].Name) - require.Equal(t, []byte("key"), changeset[0].Changeset.Pairs[0].Key) - require.Equal(t, []byte("value"), changeset[0].Changeset.Pairs[0].Value) - - ok, err = it.Next() - require.NoError(t, err) - require.False(t, ok) -} - -// TestIteratorCombinesMultipleWritesInOrder verifies that all changesets written for one block across several -// Write calls appear, in write order, in that block's single entry. -func TestIteratorCombinesMultipleWritesInOrder(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) - defer func() { require.NoError(t, w.Close()) }() - - require.NoError(t, w.Write(1, []*proto.NamedChangeSet{makeChangeSet("a", []byte("k1"), []byte("v1"))})) - require.NoError(t, w.Write(1, []*proto.NamedChangeSet{ - makeChangeSet("b", []byte("k2"), []byte("v2")), - makeChangeSet("c", []byte("k3"), []byte("v3")), - })) - require.NoError(t, w.SignalEndOfBlock()) - require.NoError(t, w.Flush()) - - it, err := w.Iterator(1, 1) - require.NoError(t, err) - defer func() { require.NoError(t, it.Close()) }() - - ok, err := it.Next() - require.NoError(t, err) - require.True(t, ok) - - blockNumber, changeset := it.Entry() - require.Equal(t, uint64(1), blockNumber) - // Three changesets total (1 from the first Write, 2 from the second), in write order. - require.Len(t, changeset, 3) - require.Equal(t, "a", changeset[0].Name) - require.Equal(t, "b", changeset[1].Name) - require.Equal(t, "c", changeset[2].Name) - - ok, err = it.Next() - require.NoError(t, err) - require.False(t, ok) -} - -func TestIteratorStopsBeforeIncompleteBlock(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) - defer func() { require.NoError(t, w.Close()) }() - for block := uint64(1); block <= 3; block++ { - writeBlock(t, w, block) - } - // Block 4 written but not ended: it was never appended, so it must not be yielded. - require.NoError(t, w.Write(4, []*proto.NamedChangeSet{makeChangeSet("evm", []byte{4}, []byte{4})})) - require.NoError(t, w.Flush()) - - require.Equal(t, []uint64{1, 2, 3}, collectBlocks(t, w, 1, 3)) -} - -// TestIteratorDoesNotSeePostConstructionBlocks confirms the snapshot contract at the wrapper level: an -// iterator yields only blocks that were complete when it was created. -func TestIteratorDoesNotSeePostConstructionBlocks(t *testing.T) { - w := openWAL(t, testConfig(t.TempDir())) - defer func() { require.NoError(t, w.Close()) }() - - for block := uint64(1); block <= 3; block++ { - writeBlock(t, w, block) - } - require.NoError(t, w.Flush()) - - it, err := w.Iterator(1, 3) - require.NoError(t, err) - defer func() { require.NoError(t, it.Close()) }() - - // Written after the iterator exists, before draining: must not be observed. - writeBlock(t, w, 4) - require.NoError(t, w.Flush()) - - var got []uint64 - for { - ok, err := it.Next() - require.NoError(t, err) - if !ok { - break - } - blockNumber, _ := it.Entry() - got = append(got, blockNumber) - } - require.Equal(t, []uint64{1, 2, 3}, got, "post-construction block 4 must not be iterated") -} diff --git a/sei-db/state_db/statewal/state_wal_serialization.go b/sei-db/state_db/statewal/state_wal_serialization.go deleted file mode 100644 index 6fd667ed8d..0000000000 --- a/sei-db/state_db/statewal/state_wal_serialization.go +++ /dev/null @@ -1,77 +0,0 @@ -package statewal - -import ( - "encoding/binary" - "fmt" - - "github.com/sei-protocol/sei-chain/sei-db/proto" -) - -// The version byte prefixed to every serialized changeset payload. Bumped if the changeset encoding changes, -// so deserialization can detect and reject an unknown format rather than misparsing it. -const changesetFormatVersion = byte(1) - -// appendChangeset appends the framing [uvarint marshaled length][marshaled NamedChangeSet] for ncs to buf and -// returns the extended buffer. It frames a single changeset; serializeChangesets calls it once per changeset -// to build a block's WAL record payload. -func appendChangeset(buf []byte, ncs *proto.NamedChangeSet) ([]byte, error) { - if ncs == nil { - return nil, fmt.Errorf("changeset is nil") - } - marshaled, err := ncs.Marshal() - if err != nil { - return nil, fmt.Errorf("failed to marshal changeset: %w", err) - } - var scratch [binary.MaxVarintLen64]byte - n := binary.PutUvarint(scratch[:], uint64(len(marshaled))) - buf = append(buf, scratch[:n]...) - buf = append(buf, marshaled...) - return buf, nil -} - -// serializeChangesets encodes a changeset list as a version byte followed by the concatenation -// [version]([uvarint length][marshaled])* — the payload of a single block's WAL record. The block number is -// not encoded: it is the WAL record's index. -func serializeChangesets(cs []*proto.NamedChangeSet) ([]byte, error) { - buf := []byte{changesetFormatVersion} - var err error - for _, ncs := range cs { - buf, err = appendChangeset(buf, ncs) - if err != nil { - return nil, err - } - } - return buf, nil -} - -// deserializeChangesets decodes the payload produced by serializeChangesets, after checking its leading -// version byte. Because the enclosing WAL record is length-delimited and CRC-verified by the underlying WAL, -// any truncation encountered here indicates corruption and is reported as an error rather than tolerated. -func deserializeChangesets(data []byte) ([]*proto.NamedChangeSet, error) { - if len(data) == 0 { - return nil, fmt.Errorf("empty changeset payload: missing version byte") - } - if version := data[0]; version != changesetFormatVersion { - return nil, fmt.Errorf("unsupported changeset format version %d", version) - } - - var result []*proto.NamedChangeSet - rest := data[1:] - for len(rest) > 0 { - length, n := binary.Uvarint(rest) - if n <= 0 { - return nil, fmt.Errorf("corrupt changeset length prefix") - } - rest = rest[n:] - if uint64(len(rest)) < length { - return nil, fmt.Errorf("changeset payload truncated: need %d bytes, have %d", length, len(rest)) - } - ncs := &proto.NamedChangeSet{} - if err := ncs.Unmarshal(rest[:length]); err != nil { - return nil, fmt.Errorf("failed to unmarshal changeset: %w", err) - } - rest = rest[length:] - result = append(result, ncs) - } - return result, nil -} diff --git a/sei-db/state_db/statewal/state_wal_serialization_test.go b/sei-db/state_db/statewal/state_wal_serialization_test.go deleted file mode 100644 index 30c3c995df..0000000000 --- a/sei-db/state_db/statewal/state_wal_serialization_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package statewal - -import ( - "testing" - - "github.com/sei-protocol/sei-chain/sei-db/proto" - "github.com/stretchr/testify/require" -) - -func makeChangeSet(name string, key []byte, value []byte) *proto.NamedChangeSet { - return &proto.NamedChangeSet{ - Name: name, - Changeset: proto.ChangeSet{ - Pairs: []*proto.KVPair{{Key: key, Value: value}}, - }, - } -} - -func TestChangesetsRoundTrip(t *testing.T) { - t.Run("multiple named change sets", func(t *testing.T) { - cs := []*proto.NamedChangeSet{ - makeChangeSet("bank", []byte("a"), []byte("1")), - makeChangeSet("evm", []byte("b"), []byte("2")), - } - - data, err := serializeChangesets(cs) - require.NoError(t, err) - require.Equal(t, changesetFormatVersion, data[0], "payload must begin with the format version") - - got, err := deserializeChangesets(data) - require.NoError(t, err) - require.Equal(t, cs, got) - }) - - t.Run("empty changeset list", func(t *testing.T) { - data, err := serializeChangesets([]*proto.NamedChangeSet{}) - require.NoError(t, err) - require.Equal(t, []byte{changesetFormatVersion}, data) // just the version byte - - got, err := deserializeChangesets(data) - require.NoError(t, err) - require.Empty(t, got) - }) -} - -func TestDeserializeUnknownVersion(t *testing.T) { - // A payload whose leading version byte is not recognized must be rejected before any decoding. - _, err := deserializeChangesets([]byte{changesetFormatVersion + 1}) - require.Error(t, err) - - // An empty payload is missing the version byte entirely. - _, err = deserializeChangesets(nil) - require.Error(t, err) -} - -func TestDeserializeChangesetsTruncated(t *testing.T) { - cs := []*proto.NamedChangeSet{ - makeChangeSet("bank", []byte("hello"), []byte("world")), - } - data, err := serializeChangesets(cs) - require.NoError(t, err) - - // Every strict prefix that reaches past the version byte is truncated. Because the enclosing record is - // length-delimited by the underlying WAL, a truncated payload here is corruption and must surface an - // error, never a silent partial decode. (Length 1 is the bare version byte, a valid empty payload.) - for length := 2; length < len(data); length++ { - _, err := deserializeChangesets(data[:length]) - require.Error(t, err) - } -} - -func TestDeserializeCorruptChangeset(t *testing.T) { - // A length prefix pointing at bytes that are not a valid NamedChangeSet protobuf must surface an error. - // Layout: [version][len=2][0x08 0xFF] where 0x08 is a varint field tag (field 1, wire type 0) followed by - // a truncated varint, which the protobuf decoder rejects. - payload := []byte{changesetFormatVersion, 0x02, 0x08, 0xFF} - _, err := deserializeChangesets(payload) - require.Error(t, err) -} diff --git a/sei-db/tools/cmd/seidb/operations/dump_flatkv.go b/sei-db/tools/cmd/seidb/operations/dump_flatkv.go index e8a70a35f7..b141e8c9f5 100644 --- a/sei-db/tools/cmd/seidb/operations/dump_flatkv.go +++ b/sei-db/tools/cmd/seidb/operations/dump_flatkv.go @@ -45,13 +45,13 @@ const ( flatkvBucketAccount = "account" flatkvBucketCode = "code" flatkvBucketStorage = "storage" - flatkvBucketMisc = "misc" + flatkvBucketLegacy = "legacy" ) // flatkvBucketOrder lists the logical bucket names for dump output files. // RawGlobalIterator emits keys in global lex order; this order is used only // for CLI validation and per-bucket file allocation. -var flatkvBucketOrder = []string{flatkvBucketAccount, flatkvBucketCode, flatkvBucketStorage, flatkvBucketMisc} +var flatkvBucketOrder = []string{flatkvBucketAccount, flatkvBucketCode, flatkvBucketStorage, flatkvBucketLegacy} // DumpFlatKVCmd dumps every (physical key, value) pair of a FlatKV store into // per-bucket files, formatted to match dump-iavl so the same diff tooling works @@ -73,7 +73,7 @@ var flatkvBucketOrder = []string{flatkvBucketAccount, flatkvBucketCode, flatkvBu // --height Target version. 0 (default) selects the latest available // version (replays the WAL to the tip). // -b, --bucket Restrict the on-disk dump to a single bucket -// (account|code|storage|misc). Default: all buckets. +// (account|code|storage|legacy). Default: all buckets. // NOTE: this only filters which hex files are WRITTEN; the // full keyspace is always scanned, and --lthash always // covers all four buckets, so the LtHash total stays valid. @@ -128,7 +128,7 @@ func DumpFlatKVCmd() *cobra.Command { cmd.PersistentFlags().StringP("db-dir", "d", "", "FlatKV database directory") cmd.PersistentFlags().StringP("output-dir", "o", "", "Output directory (one file per bucket)") cmd.PersistentFlags().Int64("height", 0, "FlatKV target version; 0 selects the latest available version") - cmd.PersistentFlags().StringP("bucket", "b", "", "Restrict dump to a single bucket (account|code|storage|misc). Default: all buckets") + cmd.PersistentFlags().StringP("bucket", "b", "", "Restrict dump to a single bucket (account|code|storage|legacy). Default: all buckets") cmd.PersistentFlags().Bool("lthash", true, "Also compute per-bucket and total LtHash (lattice hash) over the scanned state. Computed for all buckets regardless of --bucket so the total matches the node's committed LtHash") cmd.PersistentFlags().Bool("lthash-only", false, "Only compute and verify LtHash; do not write bucket dump files. Requires --lthash=true and does not require --output-dir") cmd.PersistentFlags().Float64("read-limit-mb", defaultReadLimitMiBps, "Throttle the scan to at most this many MiB/s of (key+value) bytes read, so a dump against a running node does not starve the chain of disk bandwidth. 0 = unlimited") @@ -151,7 +151,7 @@ func executeDumpFlatKV(cmd *cobra.Command, _ []string) { panic("Must provide --output-dir") } if bucket != "" && !isFlatKVBucket(bucket) { - panic(fmt.Sprintf("Unknown --bucket %q. Valid: account, code, storage, misc", bucket)) + panic(fmt.Sprintf("Unknown --bucket %q. Valid: account, code, storage, legacy", bucket)) } if lthashOnly && !withLtHash { panic("--lthash-only requires --lthash=true") diff --git a/sei-db/tools/cmd/seidb/operations/dump_flatkv_test.go b/sei-db/tools/cmd/seidb/operations/dump_flatkv_test.go index 9a27b4a5d6..aa4c08fbdc 100644 --- a/sei-db/tools/cmd/seidb/operations/dump_flatkv_test.go +++ b/sei-db/tools/cmd/seidb/operations/dump_flatkv_test.go @@ -18,7 +18,7 @@ import ( ) // TestDumpFlatKVFromStoreAllBuckets seeds a mix of account, code, storage and -// misc rows, runs dumpFlatKVFromStore across all four buckets, and checks +// legacy rows, runs dumpFlatKVFromStore across all four buckets, and checks // that every file gets the right header, the right number of data lines, and // the right format. Physical keys are emitted verbatim (no logical // stripping), which is the contract dump-flatkv promises. @@ -61,7 +61,7 @@ func TestDumpFlatKVFromStoreAllBuckets(t *testing.T) { "account": {lines: 2}, // 2 nonces -> 2 account rows "code": {lines: 1}, // 1 code "storage": {lines: 3}, // 3 storage slots - "misc": {lines: 1}, // 1 bank row + "legacy": {lines: 1}, // 1 bank row } for name, w := range want { diff --git a/sei-db/tools/cmd/seidb/operations/evm_logical_digest.go b/sei-db/tools/cmd/seidb/operations/evm_logical_digest.go index 79ecd140c6..d700b76c98 100644 --- a/sei-db/tools/cmd/seidb/operations/evm_logical_digest.go +++ b/sei-db/tools/cmd/seidb/operations/evm_logical_digest.go @@ -18,7 +18,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/ktype" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/vtype" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/memiavl" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/migration" "github.com/spf13/cobra" ) @@ -26,30 +25,11 @@ import ( // migrationVersionPhysKey is the FlatKV physical key of the migration-version // marker. FlatKV stores non-EVM module rows as "/"; the // MigrationManager writes this marker only to the new database (flatkv) and -// never to memiavl. It therefore shows up in the FlatKV misc bucket but is +// never to memiavl. It therefore shows up in the FlatKV legacy bucket but is // absent from a memiavl-only node, so excluding it lets the FlatKV digest be // compared apples-to-apples against memiavl-only output. var migrationVersionPhysKey = []byte(migration.MigrationStore + "/" + migration.MigrationVersionKey) -// migrationBoundaryPhysKey is the FlatKV physical key of the in-progress -// migration cursor. Like migrationVersionPhysKey it is a FlatKV-only -// MigrationStore row that a memiavl-only node never owns, but it is present only -// while a migration is in flight (the MigrationManager deletes it on completion, -// atomically writing MigrationVersionKey instead). Excluding it lets an -// in-progress node's FlatKV/composite digest compare apples-to-apples against a -// completed or memiavl-only node, which carry no boundary row. -var migrationBoundaryPhysKey = []byte(migration.MigrationStore + "/" + migration.MigrationBoundaryKey) - -// memiavl-open-mode and memiavl-normalization flag values, named so they are not -// repeated as bare string literals (goconst). -const ( - memiavlOpenModeSnapshot = "snapshot" - memiavlOpenModeReplay = "replay" - memiavlNormSemantic = "semantic" - memiavlNormIndependent = "independent" - memiavlNormTranslator = "translator" -) - // EvmLogicalDigestCmd computes a backend-independent digest of the EVM logical // state (account / code / storage canonical buckets) so a memIAVL node and a // FlatKV node can be compared at the same chain height. @@ -65,7 +45,7 @@ const ( // Both sides are normalized to FlatKV physical keys: // - FlatKV: keys come straight from RawGlobalIterator. // - memIAVL semantic mode (default): raw EVM leaves are independently decoded -// into the same logical account / code / storage / misc buckets. +// into the same logical account / code / storage / legacy buckets. // - memIAVL translator mode: each EVM leaf is fed through // flatkv.ImportTranslator, which applies the same classifyAndPrefix + // account-merge logic FlatKV uses. @@ -75,31 +55,9 @@ const ( // global order while memIAVL is scanned by leaf index, nor that merged accounts // are flushed out of order at Finalize. // -// Performance / mode selection (memiavl side only; --memiavl-open-mode): -// - snapshot (default, FAST): sequentially scans the completed snapshot kvs -// file at snapshot-/evm. Requires an on-disk memiavl snapshot AT -// that exact height (or --height 0 for the current symlink). This is the -// preferred mode whenever the target height lines up with an existing -// snapshot boundary. -// - replay (SLOW): opens a read-only DB, replays the changelog up to -// --height, then walks the in-memory/mmap tree. Roughly an order of -// magnitude slower than snapshot (changelog replay + per-leaf tree walk -// instead of a sequential file read). Use it only when no snapshot exists -// at the target height — e.g. nodes whose snapshot rewrite lags the tip, so -// an arbitrary comparison height has no snapshot- on disk. -// -// The flatkv side is always a pebble WAL-replay-to-height and is fast -// regardless. So when comparing across nodes, pick a height that is an existing -// memiavl snapshot on every node and use snapshot mode; fall back to replay only -// when no such common height is reachable within each backend's retained window. -// -// The primary comparison is account+code+storage. The misc bucket is printed -// separately, plus marker-adjusted comparison lines, because FlatKV can contain -// FlatKV-only MigrationStore rows that a memiavl-only truth node never owns: the -// migration-version marker (present once a migration completes) and the -// migration-boundary cursor (present only while a migration is in flight). Both -// are XORed out of the misc bucket for the final comparison so that memiavl, -// mid-migration, and completed nodes all agree. +// The primary comparison is account+code+storage. The legacy bucket is printed +// separately, plus a marker-adjusted comparison line, because FlatKV can contain +// a FlatKV-only migration-version row that a memiavl-only truth node never owns. // // Usage: // @@ -110,29 +68,12 @@ const ( // --db-dir /.sei/data/state_commit/flatkv --height 213200000 // // # memIAVL digest at the same height (0 = current symlink), using the -// # default semantic + snapshot mode: independently decodes raw EVM keys -// # without flatkv.ImportTranslator, reading the completed snapshot kvs file -// # at snapshot-/evm (or current/evm). This is the fast path and -// # requires a snapshot at that exact height. +// # default semantic mode. This independently decodes raw EVM keys without +// # flatkv.ImportTranslator. memiavl does not replay WAL in this tool; it +// # opens snapshot-/evm or current/evm. // seidb evm-logical-digest --backend memiavl \ // --db-dir /.sei/data/state_commit/memiavl --height 213200000 // -// # Same, but for a height with no on-disk snapshot (e.g. snapshot rewrite -// # lags the tip): replay the changelog to the height first. Slower — prefer -// # snapshot mode whenever the height matches an existing snapshot boundary. -// seidb evm-logical-digest --backend memiavl --memiavl-open-mode replay \ -// --db-dir /.sei/data/state_commit/memiavl --height 213205000 -// -// # Mid-migration node: digest the full EVM logical view as the union of -// # flatkv (migrated rows) and memiavl (rows not yet past the boundary), to -// # compare a migrating node against a memiavl-only node at the same height. -// # Use --memiavl-open-mode replay when memiavl's retained snapshot height is -// # outside flatkv's retained snapshot window (the common case on a live -// # migrating node, where the two backends keep snapshots at different heights). -// seidb evm-logical-digest --backend composite --memiavl-open-mode replay \ -// --flatkv-dir /.sei/data/state_commit/flatkv \ -// --memiavl-dir /.sei/data/state_commit/memiavl --height 213200000 -// // # Translator-based memIAVL digest. This proves FlatKV state matches the // # current migration mapping and is useful when debugging ImportTranslator. // seidb evm-logical-digest --backend memiavl \ @@ -140,7 +81,7 @@ const ( // --memiavl-normalization translator // // # Compare a migrated FlatKV node against a memiavl-only node at height H: -// # FlatKV FINAL_DIGEST account+code+storage+misc digest=... == memiavl FINAL_DIGEST account+code+storage+misc digest=... +// # FlatKV FINAL_DIGEST account+code+storage+legacy digest=... == memiavl FINAL_DIGEST account+code+storage+legacy digest=... // // # Inspect one bucket instead of the global digest (e.g. list storage rows // # under a key prefix, sharded by the next 2 bytes): @@ -160,14 +101,11 @@ func EvmLogicalDigestCmd() *cobra.Command { Short: "Backend-independent digest of EVM logical state (account/code/storage) for memiavl vs flatkv comparison", RunE: runEvmLogicalDigest, } - cmd.Flags().String("backend", "", "Backend to read: flatkv | memiavl | composite") + cmd.Flags().String("backend", "", "Backend to read: flatkv | memiavl") cmd.Flags().StringP("db-dir", "d", "", "For flatkv: the flatkv data dir. For memiavl: the memiavl root dir (contains current/ and snapshot-* )") - cmd.Flags().String("flatkv-dir", "", "Composite mode: flatkv data dir") - cmd.Flags().String("memiavl-dir", "", "Composite mode: memiavl root dir (contains current/ and snapshot-* )") cmd.Flags().Int64("height", 0, "Target version. flatkv WAL-replays to it; memiavl resolves snapshot-/evm (0 = current symlink)") - cmd.Flags().String("memiavl-open-mode", memiavlOpenModeSnapshot, "memiavl read mode: snapshot (FAST: sequential scan of the completed snapshot kvs file; requires an on-disk snapshot at --height, or --height 0 for current) | replay (SLOW, ~10x: replays changelog to --height then walks the mmap tree; use only when no snapshot exists at the target height). Prefer snapshot when --height matches an existing snapshot boundary") - cmd.Flags().String("memiavl-normalization", memiavlNormSemantic, "memiavl digest/inspect normalization: semantic/independent (raw EVM key/value decoder) | translator (current migration mapping)") - cmd.Flags().String("inspect-bucket", "", "Inspect one normalized bucket (account|code|storage|misc) instead of printing the global digest") + cmd.Flags().String("memiavl-normalization", "semantic", "memiavl digest/inspect normalization: semantic/independent (raw EVM key/value decoder) | translator (current migration mapping)") + cmd.Flags().String("inspect-bucket", "", "Inspect one normalized bucket (account|code|storage|legacy) instead of printing the global digest") cmd.Flags().Int("key-offset", 0, "Inspect mode: byte offset into physical key before applying --key-prefix / sharding") cmd.Flags().String("key-prefix", "", "Inspect mode: hex prefix, relative to --key-offset, used to filter physical keys") cmd.Flags().Int("shard-next-bytes", 0, "Inspect mode: group matching keys by this many bytes after --key-prefix") @@ -215,29 +153,20 @@ type evmDigest struct { account digestBucket code digestBucket storage digestBucket - misc digestBucket + legacy digestBucket // findTarget, when non-nil, is a per-entry hash to hunt for; every // matching entry is printed with its bucket, physical key, and values. findTarget []byte // migrationVersionFound/Hash capture the FlatKV-only - // "migration/migration-version" marker. It is folded into the misc + // "migration/migration-version" marker. It is folded into the legacy // bucket like any other row, but tracked separately so print can also // report a variant with it XORed back out. A memiavl-only node never // owns this key, so that "excl migration-version" variant is what // should match memiavl-only output exactly. migrationVersionFound bool migrationVersionHash [sha256.Size]byte - - // migrationBoundaryFound/Hash capture the FlatKV-only - // "migration/migration-boundary" cursor, present only while a migration is - // in flight. It is folded into the misc bucket like any other row but - // tracked separately so print can XOR it back out — mirroring the - // migration-version handling — so an in-progress node matches a completed - // or memiavl-only node. - migrationBoundaryFound bool - migrationBoundaryHash [sha256.Size]byte } type digestPrintContext struct { @@ -252,8 +181,8 @@ type digestPrintContext struct { // consume routes a physical (key, serialized-value) pair into its canonical // bucket, strips the vtype header, and folds the logical payload into the -// accumulator. The misc bucket (EVM keys with no canonical prefix: address -// mappings, codesize, etc.) is digested too — its MiscData value is just as +// accumulator. The legacy bucket (EVM keys with no canonical prefix: address +// mappings, codesize, etc.) is digested too — its LegacyData value is just as // height-independent (version+blockHeight header stripped) as the other three. func (d *evmDigest) consume(physKey, val []byte) error { bucket, logical, err := normalizeEVMFlatKVPair(physKey, val) @@ -276,16 +205,12 @@ func (d *evmDigest) addLogical(bucket string, physKey, logical, rawVal []byte) { d.code.addSum(sum) case flatkvBucketStorage: d.storage.addSum(sum) - default: // flatkvBucketMisc - d.misc.addSum(sum) + default: // flatkvBucketLegacy + d.legacy.addSum(sum) if bytes.Equal(physKey, migrationVersionPhysKey) { d.migrationVersionFound = true d.migrationVersionHash = sum } - if bytes.Equal(physKey, migrationBoundaryPhysKey) { - d.migrationBoundaryFound = true - d.migrationBoundaryHash = sum - } } } @@ -317,51 +242,28 @@ func normalizeEVMFlatKVPair(physKey, val []byte) (string, []byte, error) { } value := sd.GetValue() return bucket, value[:], nil - default: // flatkvBucketMisc - ld, err := vtype.DeserializeMiscData(val) + default: // flatkvBucketLegacy + ld, err := vtype.DeserializeLegacyData(val) if err != nil { - return "", nil, fmt.Errorf("deserialize misc %X: %w", physKey, err) + return "", nil, fmt.Errorf("deserialize legacy %X: %w", physKey, err) } return bucket, ld.GetValue(), nil } } -// miscForCompare returns the misc bucket accumulator and count with the -// FlatKV-only MigrationStore marker rows XORed back out: the migration-version -// marker (present on a completed node) and the migration-boundary cursor -// (present on an in-progress node). A memiavl-only node owns neither, so after -// this adjustment memiavl, mid-migration, and completed nodes all produce the -// same misc digest for identical EVM state. -func (d *evmDigest) miscForCompare() (acc [sha256.Size]byte, count uint64) { - acc = d.misc.acc - count = d.misc.count - if d.migrationVersionFound { - for i := 0; i < sha256.Size; i++ { - acc[i] ^= d.migrationVersionHash[i] - } - count-- - } - if d.migrationBoundaryFound { - for i := 0; i < sha256.Size; i++ { - acc[i] ^= d.migrationBoundaryHash[i] - } - count-- - } - return acc, count -} - func (d *evmDigest) print(ctx digestPrintContext) { fmt.Println("EVM logical digest report") printDigestContext(ctx) fmt.Println() - miscForCompare, miscCountForCompare := d.miscForCompare() + legacyForCompare := d.legacy.acc + legacyCountForCompare := d.legacy.count if d.migrationVersionFound { - fmt.Println("flatkv_marker_adjustment: omitted migration/migration-version from misc bucket in final result") - fmt.Println() - } - if d.migrationBoundaryFound { - fmt.Println("flatkv_marker_adjustment: omitted migration/migration-boundary from misc bucket in final result") + for i := 0; i < sha256.Size; i++ { + legacyForCompare[i] ^= d.migrationVersionHash[i] + } + legacyCountForCompare-- + fmt.Println("flatkv_marker_adjustment: omitted migration/migration-version from legacy bucket in final result") fmt.Println() } @@ -369,16 +271,16 @@ func (d *evmDigest) print(ctx digestPrintContext) { fmt.Printf("account count=%d bucket_digest=%X\n", d.account.count, d.account.acc) fmt.Printf("code count=%d bucket_digest=%X\n", d.code.count, d.code.acc) fmt.Printf("storage count=%d bucket_digest=%X\n", d.storage.count, d.storage.acc) - fmt.Printf("misc count=%d bucket_digest=%X\n", miscCountForCompare, miscForCompare) + fmt.Printf("legacy count=%d bucket_digest=%X\n", legacyCountForCompare, legacyForCompare) combined := sha256.New() _, _ = combined.Write(d.account.acc[:]) _, _ = combined.Write(d.code.acc[:]) _, _ = combined.Write(d.storage.acc[:]) - _, _ = combined.Write(miscForCompare[:]) + _, _ = combined.Write(legacyForCompare[:]) fmt.Println() - fmt.Printf("FINAL_DIGEST account+code+storage+misc count=%d digest=%X\n", - d.account.count+d.code.count+d.storage.count+miscCountForCompare, combined.Sum(nil)) + fmt.Printf("FINAL_DIGEST account+code+storage+legacy count=%d digest=%X\n", + d.account.count+d.code.count+d.storage.count+legacyCountForCompare, combined.Sum(nil)) } func printDigestStart(ctx digestPrintContext) { @@ -402,19 +304,13 @@ func printDigestContext(ctx digestPrintContext) { func runEvmLogicalDigest(cmd *cobra.Command, _ []string) error { backend, _ := cmd.Flags().GetString("backend") dbDir, _ := cmd.Flags().GetString("db-dir") - flatKVDir, _ := cmd.Flags().GetString("flatkv-dir") - memIAVLDir, _ := cmd.Flags().GetString("memiavl-dir") height, _ := cmd.Flags().GetInt64("height") - if dbDir == "" && backend != "composite" { + if dbDir == "" { return errors.New("must provide --db-dir") } inspectBucket, _ := cmd.Flags().GetString("inspect-bucket") memiavlNormalization, _ := cmd.Flags().GetString("memiavl-normalization") - memiavlOpenMode, _ := cmd.Flags().GetString("memiavl-open-mode") if inspectBucket != "" { - if memiavlOpenMode != memiavlOpenModeSnapshot { - return fmt.Errorf("--inspect-bucket does not support --memiavl-open-mode=%q yet", memiavlOpenMode) - } return runEvmLogicalInspect(cmd, backend, dbDir, height, inspectBucket, memiavlNormalization) } @@ -435,196 +331,10 @@ func runEvmLogicalDigest(cmd *cobra.Command, _ []string) error { case "flatkv": return digestFlatKV(dbDir, height, findTarget) case "memiavl": - return digestMemIAVL(dbDir, height, findTarget, memiavlNormalization, memiavlOpenMode) - case "composite": - if flatKVDir == "" || memIAVLDir == "" { - return errors.New("--backend composite requires --flatkv-dir and --memiavl-dir") - } - return digestCompositeMigrateEVM(flatKVDir, memIAVLDir, height, findTarget, memiavlOpenMode) - default: - return fmt.Errorf("unknown --backend %q (want flatkv|memiavl|composite)", backend) - } -} - -func digestCompositeMigrateEVM(flatKVDir, memIAVLDir string, height int64, findTarget []byte, memiavlOpenMode string) error { - opened, err := openFlatKVReadOnly(flatKVDir, height) - if err != nil { - return fmt.Errorf("open flatkv read-only: %w", err) - } - defer func() { _ = opened.Close() }() - - boundary, versionKnown, migrationVersion, err := readFlatKVMigrationState(opened) - if err != nil { - return err - } - - ctx := digestPrintContext{ - backend: "composite", - mode: "migrate_evm", - dbDir: fmt.Sprintf("flatkv=%s memiavl=%s", flatKVDir, memIAVLDir), - requestedHeight: height, - version: opened.Version(), - } - var memReplayDB *memiavl.DB - var memEvmSnapshotDir string - var memVersion int64 - switch memiavlOpenMode { - case "", memiavlOpenModeSnapshot: - memEvmSnapshotDir, err = resolveMemIAVLEvmSnapshotDir(memIAVLDir, height) - if err != nil { - return err - } - memVersion, err = readMemIAVLSnapshotVersion(memEvmSnapshotDir) - if err != nil { - return err - } - ctx.source = fmt.Sprintf("flatkv clone version=%d + memiavl snapshot=%s", opened.Version(), memEvmSnapshotDir) - ctx.normalization = fmt.Sprintf("flatkv rows plus memiavl rows not migrated by boundary=%s version_known=%t migration_version=%d memiavl_version=%d", boundary.String(), versionKnown, migrationVersion, memVersion) - case memiavlOpenModeReplay: - memReplayDB, err = openMemiAVLReplayReadOnly(memIAVLDir, height) - if err != nil { - return err - } - defer func() { _ = memReplayDB.Close() }() - memVersion = memReplayDB.Version() - ctx.source = fmt.Sprintf("flatkv clone version=%d + memiavl read-only replay dir=%s", opened.Version(), memIAVLDir) - ctx.normalization = fmt.Sprintf("flatkv rows plus replayed memiavl rows not migrated by boundary=%s version_known=%t migration_version=%d memiavl_version=%d", boundary.String(), versionKnown, migrationVersion, memVersion) + return digestMemIAVL(dbDir, height, findTarget, memiavlNormalization) default: - return fmt.Errorf("unknown --memiavl-open-mode %q (want snapshot|replay)", memiavlOpenMode) - } - printDigestStart(ctx) - fmt.Println("Scan progress: composite migrate_evm logical view -> flatkv rows plus memiavl rows to the right of boundary") - - d := evmDigest{findTarget: findTarget} - accounts := make(map[string]*semanticAccountDigestState) - - if err := consumeCompositeFlatKV(opened, &d, accounts); err != nil { - return err - } - if boundary.Status() != migration.MigrationComplete { - if memReplayDB != nil { - if err := consumeCompositeMemiavl(func(fn func(rawKey, rawVal []byte) error) error { - return scanMemiavlReplayEVMLeaves(memReplayDB, fn) - }, "memiavl-replay", boundary, &d, accounts); err != nil { - return err - } - } else { - if err := consumeCompositeMemiavl(func(fn func(rawKey, rawVal []byte) error) error { - return scanMemiavlSnapshotEVMLeaves(memEvmSnapshotDir, fn) - }, "memiavl", boundary, &d, accounts); err != nil { - return err - } - } - } - finalizeSemanticAccounts(accounts, d.addLogical) - d.print(ctx) - return nil -} - -func readFlatKVMigrationState(store *openedFlatKV) (migration.MigrationBoundary, bool, uint64, error) { - if data, ok := store.Get(migration.MigrationStore, []byte(migration.MigrationVersionKey)); ok { - if len(data) != 8 { - return migration.MigrationBoundary{}, false, 0, fmt.Errorf("flatkv migration version length=%d, want 8", len(data)) - } - return migration.MigrationBoundaryComplete, true, binary.BigEndian.Uint64(data), nil - } - if data, ok := store.Get(migration.MigrationStore, []byte(migration.MigrationBoundaryKey)); ok { - b, err := migration.DeserializeMigrationBoundary(data) - return b, false, 0, err - } - return migration.MigrationBoundaryNotStarted, false, 0, nil -} - -// shouldIncludeFlatKVEVMLogicalDigestKey reports whether a raw FlatKV physical -// row belongs in the EVM logical digest. FlatKV's RawGlobalIterator yields every -// module's rows, but only the EVM module participates in the comparison against a -// memiavl node (which walks the EVM tree alone). The FlatKV-only migration -// markers are let through here and then XORed back out in legacyForCompare; all -// other non-EVM module rows (e.g. Cosmos state migrated into FlatKV) are excluded -// so the legacy bucket and FINAL_DIGEST stay comparable across backends. -func shouldIncludeFlatKVEVMLogicalDigestKey(physKey []byte) bool { - if bytes.Equal(physKey, migrationVersionPhysKey) || bytes.Equal(physKey, migrationBoundaryPhysKey) { - return true - } - moduleName, _, err := ktype.StripModulePrefix(physKey) - return err == nil && moduleName == keys.EVMStoreKey -} - -func consumeCompositeFlatKV(opened *openedFlatKV, d *evmDigest, accounts map[string]*semanticAccountDigestState) error { - iter, err := opened.RawGlobalIterator() - if err != nil { - return fmt.Errorf("raw global iterator: %w", err) - } - defer func() { _ = iter.Close() }() - var seen uint64 - for ; iter.Valid(); iter.Next() { - seen++ - k := iter.Key() - if !shouldIncludeFlatKVEVMLogicalDigestKey(k) { - continue - } - if classifyFlatKVPhysicalKey(k) == flatkvBucketAccount { - if err := mergeCompositeFlatKVAccount(accounts, k, iter.Value()); err != nil { - return err - } - } else if err := d.consume(k, iter.Value()); err != nil { - return err - } - if seen%20000000 == 0 { - fmt.Printf(" progress backend=composite source=flatkv input_physical_rows=%d account_buffered=%d code=%d storage=%d misc=%d\n", seen, len(accounts), d.code.count, d.storage.count, d.misc.count) - } - } - if err := iter.Error(); err != nil { - return fmt.Errorf("iterate flatkv: %w", err) - } - fmt.Printf(" composite flatkv rows=%d\n", seen) - return nil -} - -func mergeCompositeFlatKVAccount(accounts map[string]*semanticAccountDigestState, physKey, val []byte) error { - kind, addr, err := ktype.StripEVMPhysicalKey(physKey) - if err != nil { - return err - } - if kind != ktype.EVMKeyAccount { - return fmt.Errorf("flatkv account key %X kind=%d, want account", physKey, kind) - } - ad, err := vtype.DeserializeAccountData(val) - if err != nil { - return err - } - acct := getSemanticAccount(accounts, addr) - bal := ad.GetBalance() - copy(acct.balance[:], bal[:]) - acct.nonce = ad.GetNonce() - copy(acct.codeHash[:], ad.GetCodeHash()[:]) - return nil -} - -// consumeCompositeMemiavl folds the memiavl EVM leaves NOT yet migrated past the -// boundary into d (the unmigrated tail of the composite mid-migration view). -// srcLabel selects the snapshot vs replay wording so both modes emit the same -// progress output as before. -func consumeCompositeMemiavl(scan evmLeafSource, srcLabel string, boundary migration.MigrationBoundary, d *evmDigest, accounts map[string]*semanticAccountDigestState) error { - var leaves, consumed uint64 - if err := scan(func(k, v []byte) error { - leaves++ - if !boundary.IsMigrated(keys.EVMStoreKey, k) { - if err := d.consumeSemanticMemiavlLeaf(accounts, k, v); err != nil { - return err - } - consumed++ - } - if leaves%20000000 == 0 { - fmt.Printf(" progress backend=composite source=%s input_leaves=%d consumed_unmigrated=%d account_buffered=%d code=%d storage=%d misc=%d\n", - srcLabel, leaves, consumed, len(accounts), d.code.count, d.storage.count, d.misc.count) - } - return nil - }); err != nil { - return err + return fmt.Errorf("unknown --backend %q (want flatkv|memiavl)", backend) } - fmt.Printf(" composite %s leaves=%d consumed_unmigrated=%d\n", srcLabel, leaves, consumed) - return nil } func digestFlatKV(dbDir string, height int64, findTarget []byte) error { @@ -664,8 +374,8 @@ func digestFlatKV(dbDir string, height int64, findTarget []byte) error { return err } if seen%20000000 == 0 { - fmt.Printf(" progress backend=flatkv input_physical_rows=%d digested account=%d code=%d storage=%d misc=%d\n", - seen, d.account.count, d.code.count, d.storage.count, d.misc.count) + fmt.Printf(" progress backend=flatkv input_physical_rows=%d digested account=%d code=%d storage=%d legacy=%d\n", + seen, d.account.count, d.code.count, d.storage.count, d.legacy.count) } } if err := iter.Error(); err != nil { @@ -675,6 +385,14 @@ func digestFlatKV(dbDir string, height int64, findTarget []byte) error { return nil } +func shouldIncludeFlatKVEVMLogicalDigestKey(physKey []byte) bool { + if bytes.Equal(physKey, migrationVersionPhysKey) { + return true + } + moduleName, _, err := ktype.StripModulePrefix(physKey) + return err == nil && moduleName == keys.EVMStoreKey +} + type inspectAccumulator struct { inspectBucket string keyOffset int @@ -840,9 +558,9 @@ func inspectFlatKV(dbDir string, height int64, acc *inspectAccumulator) error { func inspectMemIAVL(dbDir string, height int64, acc *inspectAccumulator, normalization string) error { switch normalization { - case "", memiavlNormSemantic, memiavlNormIndependent: + case "", "semantic", "independent": return inspectMemIAVLSemantic(dbDir, height, acc) - case memiavlNormTranslator: + case "translator": return inspectMemIAVLTranslator(dbDir, height, acc) default: return fmt.Errorf("unknown --memiavl-normalization %q (want semantic|independent|translator)", normalization) @@ -862,6 +580,13 @@ func inspectMemIAVLTranslator(dbDir string, height int64, acc *inspectAccumulato if err != nil { return err } + kvsPath := filepath.Join(evmSnapshotDir, "kvs") + f, err := os.Open(filepath.Clean(kvsPath)) + if err != nil { + return fmt.Errorf("open kvs %s: %w", kvsPath, err) + } + defer func() { _ = f.Close() }() + r := bufio.NewReaderSize(f, 16*1024*1024) translator := flatkv.NewImportTranslator(0) var leaves uint64 @@ -885,18 +610,37 @@ func inspectMemIAVLTranslator(dbDir string, height int64, acc *inspectAccumulato return nil } - if err := scanMemiavlSnapshotEVMLeaves(evmSnapshotDir, func(k, v []byte) error { + var lenbuf [4]byte + for { + if _, err := io.ReadFull(r, lenbuf[:]); err != nil { + if errors.Is(err, io.EOF) { + break + } + return fmt.Errorf("read key len: %w", err) + } + keyLen := binary.LittleEndian.Uint32(lenbuf[:]) + k := make([]byte, keyLen) + if _, err := io.ReadFull(r, k); err != nil { + return fmt.Errorf("read key: %w", err) + } + if _, err := io.ReadFull(r, lenbuf[:]); err != nil { + return fmt.Errorf("read val len: %w", err) + } + valLen := binary.LittleEndian.Uint32(lenbuf[:]) + v := make([]byte, valLen) + if _, err := io.ReadFull(r, v); err != nil { + return fmt.Errorf("read val: %w", err) + } leaves++ if leaves%20000000 == 0 { fmt.Printf(" ...memiavl inspect leaves=%d matched=%d\n", leaves, acc.matched) } batch = append(batch, &proto.KVPair{Key: k, Value: v}) if len(batch) >= batchCap { - return flush() + if ferr := flush(); ferr != nil { + return ferr + } } - return nil - }); err != nil { - return err } if err := flush(); err != nil { return err @@ -924,6 +668,13 @@ func inspectMemIAVLSemantic(dbDir string, height int64, acc *inspectAccumulator) if err != nil { return err } + kvsPath := filepath.Join(evmSnapshotDir, "kvs") + f, err := os.Open(filepath.Clean(kvsPath)) + if err != nil { + return fmt.Errorf("open kvs %s: %w", kvsPath, err) + } + defer func() { _ = f.Close() }() + r := bufio.NewReaderSize(f, 16*1024*1024) var accounts map[string]*semanticAccountDigestState if acc.inspectBucket == flatkvBucketAccount { @@ -932,15 +683,35 @@ func inspectMemIAVLSemantic(dbDir string, height int64, acc *inspectAccumulator) consume := func(bucket string, physKey, logical, _ []byte) { acc.consumeLogical(bucket, physKey, logical, "") } + var lenbuf [4]byte var leaves uint64 - if err := scanMemiavlSnapshotEVMLeaves(evmSnapshotDir, func(k, v []byte) error { + for { + if _, err := io.ReadFull(r, lenbuf[:]); err != nil { + if errors.Is(err, io.EOF) { + break + } + return fmt.Errorf("read key len: %w", err) + } + keyLen := binary.LittleEndian.Uint32(lenbuf[:]) + k := make([]byte, keyLen) + if _, err := io.ReadFull(r, k); err != nil { + return fmt.Errorf("read key: %w", err) + } + if _, err := io.ReadFull(r, lenbuf[:]); err != nil { + return fmt.Errorf("read val len: %w", err) + } + valLen := binary.LittleEndian.Uint32(lenbuf[:]) + v := make([]byte, valLen) + if _, err := io.ReadFull(r, v); err != nil { + return fmt.Errorf("read val: %w", err) + } leaves++ if leaves%20000000 == 0 { fmt.Printf(" ...memiavl inspect mode=semantic leaves=%d matched=%d\n", leaves, acc.matched) } - return consumeSemanticMemiavlLeaf(accounts, k, v, consume, "inspect") - }); err != nil { - return err + if err := consumeSemanticMemiavlLeaf(accounts, k, v, consume, "inspect"); err != nil { + return err + } } finalizeSemanticAccounts(accounts, consume) fmt.Printf(" memiavl inspect total leaves=%d\n", leaves) @@ -1058,9 +829,9 @@ func flatKVValueMeta(physKey, val []byte) (string, error) { } return fmt.Sprintf("block_height=%d", sd.GetBlockHeight()), nil default: - ld, err := vtype.DeserializeMiscData(val) + ld, err := vtype.DeserializeLegacyData(val) if err != nil { - return "", fmt.Errorf("deserialize misc %X: %w", physKey, err) + return "", fmt.Errorf("deserialize legacy %X: %w", physKey, err) } return fmt.Sprintf("block_height=%d", ld.GetBlockHeight()), nil } @@ -1087,61 +858,28 @@ func flatKVValueMeta(physKey, val []byte) (string, error) { // kvs record layout (little-endian), repeated leafCount times until EOF: // // keyLen uint32 | key [keyLen] | valLen uint32 | value [valLen] -func digestMemIAVL(dbDir string, height int64, findTarget []byte, normalization string, openMode string) error { - if openMode == memiavlOpenModeReplay { - return digestMemIAVLReplay(dbDir, height, findTarget, normalization) - } - if openMode != "" && openMode != memiavlOpenModeSnapshot { - return fmt.Errorf("unknown --memiavl-open-mode %q (want snapshot|replay)", openMode) - } +func digestMemIAVL(dbDir string, height int64, findTarget []byte, normalization string) error { switch normalization { - case "", memiavlNormSemantic, memiavlNormIndependent: + case "", "semantic", "independent": return digestMemIAVLSemantic(dbDir, height, findTarget) - case memiavlNormTranslator: + case "translator": return digestMemIAVLTranslator(dbDir, height, findTarget) default: return fmt.Errorf("unknown --memiavl-normalization %q (want semantic|independent|translator)", normalization) } } -func openMemiAVLReplayReadOnly(dbDir string, height int64) (*memiavl.DB, error) { - db, err := memiavl.OpenDB(height, memiavl.Options{ - Dir: dbDir, - ReadOnly: true, - ZeroCopy: true, - }) +func digestMemIAVLTranslator(dbDir string, height int64, findTarget []byte) error { + evmSnapshotDir, err := resolveMemIAVLEvmSnapshotDir(dbDir, height) if err != nil { - return nil, fmt.Errorf("open memiavl read-only replay: %w", err) + return err } - return db, nil -} -func digestMemIAVLReplay(dbDir string, height int64, findTarget []byte, normalization string) error { - db, err := openMemiAVLReplayReadOnly(dbDir, height) + version, err := readMemIAVLSnapshotVersion(evmSnapshotDir) if err != nil { return err } - defer func() { _ = db.Close() }() - - switch normalization { - case "", memiavlNormSemantic, memiavlNormIndependent: - return digestMemIAVLReplaySemantic(dbDir, height, db, findTarget) - case memiavlNormTranslator: - return digestMemIAVLReplayTranslator(dbDir, height, db, findTarget) - default: - return fmt.Errorf("unknown --memiavl-normalization %q (want semantic|independent|translator)", normalization) - } -} - -// evmLeafSource streams raw memiavl EVM (key,val) leaves to fn, stopping on the -// first error. It abstracts the two ways the tool reads memiavl EVM leaves — a -// snapshot kvs file scan and a replayed read-only tree walk — so the semantic -// and translator digest cores below are shared between snapshot and replay modes. -type evmLeafSource func(fn func(rawKey, rawVal []byte) error) error -// scanMemiavlSnapshotEVMLeaves streams every leaf from a memiavl EVM snapshot -// kvs file (length-prefixed keyLen|key|valLen|val, little-endian). -func scanMemiavlSnapshotEVMLeaves(evmSnapshotDir string, fn func(rawKey, rawVal []byte) error) error { kvsPath := filepath.Join(evmSnapshotDir, "kvs") f, err := os.Open(filepath.Clean(kvsPath)) if err != nil { @@ -1149,89 +887,46 @@ func scanMemiavlSnapshotEVMLeaves(evmSnapshotDir string, fn func(rawKey, rawVal } defer func() { _ = f.Close() }() r := bufio.NewReaderSize(f, 16*1024*1024) - var lenbuf [4]byte - for { - if _, err := io.ReadFull(r, lenbuf[:]); err != nil { - if errors.Is(err, io.EOF) { - return nil - } - return fmt.Errorf("read key len: %w", err) - } - k := make([]byte, binary.LittleEndian.Uint32(lenbuf[:])) - if _, err := io.ReadFull(r, k); err != nil { - return fmt.Errorf("read key: %w", err) - } - if _, err := io.ReadFull(r, lenbuf[:]); err != nil { - return fmt.Errorf("read val len: %w", err) - } - v := make([]byte, binary.LittleEndian.Uint32(lenbuf[:])) - if _, err := io.ReadFull(r, v); err != nil { - return fmt.Errorf("read val: %w", err) - } - if err := fn(k, v); err != nil { - return err - } - } -} - -// scanMemiavlReplayEVMLeaves streams every leaf from the EVM tree of a read-only -// replayed memiavl DB. -func scanMemiavlReplayEVMLeaves(db *memiavl.DB, fn func(rawKey, rawVal []byte) error) error { - tree := db.TreeByName(keys.EVMStoreKey) - if tree == nil { - return fmt.Errorf("memiavl tree %q not found", keys.EVMStoreKey) - } - iter := tree.Iterator(nil, nil, true) - defer func() { _ = iter.Close() }() - for ; iter.Valid(); iter.Next() { - if err := fn(iter.Key(), iter.Value()); err != nil { - return err - } - } - if err := iter.Error(); err != nil { - return fmt.Errorf("iterate replayed memiavl: %w", err) - } - return nil -} -// runMemiavlSemanticDigest digests memiavl EVM leaves with the independent -// semantic decoder (no flatkv.ImportTranslator), buffering account fragments and -// merging them at finalize. modeLabel/totalLabel select the snapshot vs replay -// wording so each mode emits the same progress output as before. -func runMemiavlSemanticDigest(ctx digestPrintContext, modeLabel, totalLabel string, findTarget []byte, scan evmLeafSource) error { + // Route EVERY leaf through ImportTranslator and then through the same + // `consume` used for the FlatKV side. This makes the two backends + // byte-identical by construction: + // + // - Translate runs the exact classifyAndPrefix + processStorageChanges / + // processCodeChanges / processLegacyChanges / mergeAccountUpdates that + // CommitStore.ApplyChangeSets uses, so the physical keys AND the + // FlatKV-serialized values it emits match what is physically stored in + // pebble on the FlatKV node. + // - consume then deserializes those values (DeserializeStorageData / + // DeserializeCodeData / DeserializeAccountData) and digests only the + // height-independent logical payload — the identical code path both + // backends take. + // + // We deliberately do NOT take the old hybrid shortcut (emitting node.Value() + // raw with a hand-built key for storage/code): a raw memIAVL leaf value is + // not guaranteed to be byte-identical to FlatKV's normalized StorageData / + // CodeData payload, which would make the digest diverge spuriously even when + // the underlying state is identical. + // + // Memory: Translate streams storage/code/legacy pairs out immediately and + // only buffers account fragments (nonce/codehash) until Finalize, so feeding + // all leaves through it does not blow up RSS beyond the account buffer. + translator := flatkv.NewImportTranslator(0) d := evmDigest{findTarget: findTarget} - accounts := make(map[string]*semanticAccountDigestState) - var leaves uint64 - if err := scan(func(k, v []byte) error { - leaves++ - if err := d.consumeSemanticMemiavlLeaf(accounts, k, v); err != nil { - return err - } - if leaves%20000000 == 0 { - fmt.Printf(" progress backend=memiavl mode=%s input_leaves=%d digested account=deferred_until_finalize account_buffered=%d code=%d storage=%d misc=%d\n", - modeLabel, leaves, len(accounts), d.code.count, d.storage.count, d.misc.count) - } - return nil - }); err != nil { - return err + ctx := digestPrintContext{ + backend: "memiavl", + mode: "translator", + dbDir: dbDir, + source: evmSnapshotDir + " (snapshot/current only; no memiavl WAL replay)", + normalization: "memiavl leaves translated with flatkv.ImportTranslator, then reduced to logical payload", + requestedHeight: height, + version: version, } - d.finalizeSemanticAccounts(accounts) - fmt.Printf(" finalize backend=memiavl mode=%s account=%d code=%d storage=%d misc=%d\n", - modeLabel, d.account.count, d.code.count, d.storage.count, d.misc.count) - fmt.Printf(" %s=%d\n", totalLabel, leaves) - d.print(ctx) - return nil -} + printDigestStart(ctx) + fmt.Println("Scan progress: memiavl input_leaves -> translated flatkv logical bucket counts") + fmt.Println("Note: account rows are merged by the translator at finalize, so progress shows account=deferred_until_finalize.") + var leaves uint64 -// runMemiavlTranslatorDigest digests memiavl EVM leaves by routing every leaf -// through flatkv.ImportTranslator (the exact classifyAndPrefix + merge path -// CommitStore.ApplyChangeSets uses) and then through the shared consume, making -// the memiavl and flatkv digests byte-identical by construction. Translate -// streams storage/code/misc out immediately and only buffers account fragments -// until Finalize, so RSS stays bounded. -func runMemiavlTranslatorDigest(ctx digestPrintContext, modeLabel, totalLabel string, findTarget []byte, scan evmLeafSource) error { - translator := flatkv.NewImportTranslator(0) - d := evmDigest{findTarget: findTarget} const batchCap = 8192 batch := make([]*proto.KVPair, 0, batchCap) flush := func() error { @@ -1251,104 +946,66 @@ func runMemiavlTranslatorDigest(ctx digestPrintContext, modeLabel, totalLabel st batch = batch[:0] return nil } - var leaves uint64 - if err := scan(func(k, v []byte) error { + + var lenbuf [4]byte + for { + // key length + if _, err := io.ReadFull(r, lenbuf[:]); err != nil { + if errors.Is(err, io.EOF) { + break + } + return fmt.Errorf("read key len: %w", err) + } + keyLen := binary.LittleEndian.Uint32(lenbuf[:]) + k := make([]byte, keyLen) + if _, err := io.ReadFull(r, k); err != nil { + return fmt.Errorf("read key: %w", err) + } + // value length + if _, err := io.ReadFull(r, lenbuf[:]); err != nil { + return fmt.Errorf("read val len: %w", err) + } + valLen := binary.LittleEndian.Uint32(lenbuf[:]) + v := make([]byte, valLen) + if _, err := io.ReadFull(r, v); err != nil { + return fmt.Errorf("read val: %w", err) + } + leaves++ batch = append(batch, &proto.KVPair{Key: k, Value: v}) if len(batch) >= batchCap { - if err := flush(); err != nil { - return err + if ferr := flush(); ferr != nil { + return ferr } } if leaves%20000000 == 0 { - if err := flush(); err != nil { - return err + // Flush before reporting so progress counts include all leaves seen so far. + if ferr := flush(); ferr != nil { + return ferr } - fmt.Printf(" progress backend=memiavl mode=%s input_leaves=%d digested account=deferred_until_finalize code=%d storage=%d misc=%d\n", - modeLabel, leaves, d.code.count, d.storage.count, d.misc.count) + fmt.Printf(" progress backend=memiavl input_leaves=%d digested account=deferred_until_finalize code=%d storage=%d legacy=%d\n", + leaves, d.code.count, d.storage.count, d.legacy.count) } - return nil - }); err != nil { - return err } + if err := flush(); err != nil { return err } + // Flush merged accounts buffered across all batches. for _, p := range translator.Finalize() { - if err := d.consume(p.Key, p.Value); err != nil { - return err + if cerr := d.consume(p.Key, p.Value); cerr != nil { + return cerr } } - fmt.Printf(" finalize backend=memiavl mode=%s account=%d code=%d storage=%d misc=%d\n", - modeLabel, d.account.count, d.code.count, d.storage.count, d.misc.count) - fmt.Printf(" %s=%d\n", totalLabel, leaves) + fmt.Printf(" finalize backend=memiavl account=%d code=%d storage=%d legacy=%d\n", + d.account.count, d.code.count, d.storage.count, d.legacy.count) + + fmt.Printf(" memiavl total leaves=%d\n", leaves) d.print(ctx) return nil } -func digestMemIAVLReplaySemantic(dbDir string, height int64, db *memiavl.DB, findTarget []byte) error { - ctx := digestPrintContext{ - backend: "memiavl", - mode: "semantic-replay", - dbDir: dbDir, - source: "read-only memiavl DB opened from snapshot + changelog replay", - normalization: "independent semantic decoder for replayed memiavl EVM keys; does not call flatkv.ImportTranslator", - requestedHeight: height, - version: db.Version(), - } - printDigestStart(ctx) - fmt.Println("Scan progress: replayed memiavl iterator -> independently decoded EVM logical bucket counts") - fmt.Println("Note: semantic replay mode walks the in-memory/mmap tree, not the snapshot kvs file.") - return runMemiavlSemanticDigest(ctx, "semantic-replay", "memiavl-replay total leaves", findTarget, - func(fn func(rawKey, rawVal []byte) error) error { return scanMemiavlReplayEVMLeaves(db, fn) }) -} - -func digestMemIAVLReplayTranslator(dbDir string, height int64, db *memiavl.DB, findTarget []byte) error { - ctx := digestPrintContext{ - backend: "memiavl", - mode: "translator-replay", - dbDir: dbDir, - source: "read-only memiavl DB opened from snapshot + changelog replay", - normalization: "replayed memiavl leaves translated with flatkv.ImportTranslator, then reduced to logical payload", - requestedHeight: height, - version: db.Version(), - } - printDigestStart(ctx) - fmt.Println("Scan progress: replayed memiavl iterator -> translated flatkv logical bucket counts") - fmt.Println("Note: account rows are merged by the translator at finalize, so progress shows account=deferred_until_finalize.") - return runMemiavlTranslatorDigest(ctx, "translator-replay", "memiavl-replay total leaves", findTarget, - func(fn func(rawKey, rawVal []byte) error) error { return scanMemiavlReplayEVMLeaves(db, fn) }) -} - -func digestMemIAVLTranslator(dbDir string, height int64, findTarget []byte) error { - evmSnapshotDir, err := resolveMemIAVLEvmSnapshotDir(dbDir, height) - if err != nil { - return err - } - version, err := readMemIAVLSnapshotVersion(evmSnapshotDir) - if err != nil { - return err - } - ctx := digestPrintContext{ - backend: "memiavl", - mode: memiavlNormTranslator, - dbDir: dbDir, - source: evmSnapshotDir + " (snapshot/current only; no memiavl WAL replay)", - normalization: "memiavl leaves translated with flatkv.ImportTranslator, then reduced to logical payload", - requestedHeight: height, - version: version, - } - printDigestStart(ctx) - fmt.Println("Scan progress: memiavl input_leaves -> translated flatkv logical bucket counts") - fmt.Println("Note: account rows are merged by the translator at finalize, so progress shows account=deferred_until_finalize.") - return runMemiavlTranslatorDigest(ctx, memiavlNormTranslator, "memiavl total leaves", findTarget, - func(fn func(rawKey, rawVal []byte) error) error { - return scanMemiavlSnapshotEVMLeaves(evmSnapshotDir, fn) - }) -} - type semanticAccountDigestState struct { - balance [32]byte nonce uint64 codeHash [32]byte } @@ -1360,11 +1017,6 @@ func (s *semanticAccountDigestState) isZeroAccount() bool { if s.nonce != 0 { return false } - for _, b := range s.balance { - if b != 0 { - return false - } - } for _, b := range s.codeHash { if b != 0 { return false @@ -1375,7 +1027,8 @@ func (s *semanticAccountDigestState) isZeroAccount() bool { func (s *semanticAccountDigestState) logicalPayload() []byte { logical := make([]byte, 72) - copy(logical[:32], s.balance[:]) + // Balance is not represented in the current memiavl EVM keyspace, so the + // first 32 bytes intentionally remain zero. binary.BigEndian.PutUint64(logical[32:40], s.nonce) copy(logical[40:], s.codeHash[:]) return logical @@ -1386,13 +1039,25 @@ func digestMemIAVLSemantic(dbDir string, height int64, findTarget []byte) error if err != nil { return err } + version, err := readMemIAVLSnapshotVersion(evmSnapshotDir) if err != nil { return err } + + kvsPath := filepath.Join(evmSnapshotDir, "kvs") + f, err := os.Open(filepath.Clean(kvsPath)) + if err != nil { + return fmt.Errorf("open kvs %s: %w", kvsPath, err) + } + defer func() { _ = f.Close() }() + r := bufio.NewReaderSize(f, 16*1024*1024) + + d := evmDigest{findTarget: findTarget} + accounts := make(map[string]*semanticAccountDigestState) ctx := digestPrintContext{ backend: "memiavl", - mode: memiavlNormSemantic, + mode: "semantic", dbDir: dbDir, source: evmSnapshotDir + " (snapshot/current only; no memiavl WAL replay)", normalization: "independent semantic decoder for raw memiavl EVM keys; does not call flatkv.ImportTranslator", @@ -1402,10 +1067,47 @@ func digestMemIAVLSemantic(dbDir string, height int64, findTarget []byte) error printDigestStart(ctx) fmt.Println("Scan progress: memiavl input_leaves -> independently decoded EVM logical bucket counts") fmt.Println("Note: semantic mode does not call flatkv.ImportTranslator; account rows are merged locally at finalize.") - return runMemiavlSemanticDigest(ctx, memiavlNormSemantic, "memiavl total leaves", findTarget, - func(fn func(rawKey, rawVal []byte) error) error { - return scanMemiavlSnapshotEVMLeaves(evmSnapshotDir, fn) - }) + + var lenbuf [4]byte + var leaves uint64 + for { + if _, err := io.ReadFull(r, lenbuf[:]); err != nil { + if errors.Is(err, io.EOF) { + break + } + return fmt.Errorf("read key len: %w", err) + } + keyLen := binary.LittleEndian.Uint32(lenbuf[:]) + k := make([]byte, keyLen) + if _, err := io.ReadFull(r, k); err != nil { + return fmt.Errorf("read key: %w", err) + } + if _, err := io.ReadFull(r, lenbuf[:]); err != nil { + return fmt.Errorf("read val len: %w", err) + } + valLen := binary.LittleEndian.Uint32(lenbuf[:]) + v := make([]byte, valLen) + if _, err := io.ReadFull(r, v); err != nil { + return fmt.Errorf("read val: %w", err) + } + + leaves++ + if err := d.consumeSemanticMemiavlLeaf(accounts, k, v); err != nil { + return err + } + if leaves%20000000 == 0 { + fmt.Printf(" progress backend=memiavl mode=semantic input_leaves=%d digested account=deferred_until_finalize account_buffered=%d code=%d storage=%d legacy=%d\n", + leaves, len(accounts), d.code.count, d.storage.count, d.legacy.count) + } + } + + d.finalizeSemanticAccounts(accounts) + fmt.Printf(" finalize backend=memiavl mode=semantic account=%d code=%d storage=%d legacy=%d\n", + d.account.count, d.code.count, d.storage.count, d.legacy.count) + + fmt.Printf(" memiavl total leaves=%d\n", leaves) + d.print(ctx) + return nil } func (d *evmDigest) finalizeSemanticAccounts(accounts map[string]*semanticAccountDigestState) { @@ -1466,9 +1168,9 @@ func consumeSemanticMemiavlLeaf(accounts map[string]*semanticAccountDigestState, } physKey := ktype.EVMPhysicalKey(keys.EVMKeyStorage, keyBytes) consume(flatkvBucketStorage, physKey, rawVal, rawVal) - case keys.EVMKeyMisc: + case keys.EVMKeyLegacy: physKey := ktype.ModulePhysicalKey(keys.EVMStoreKey, rawKey) - consume(flatkvBucketMisc, physKey, rawVal, rawVal) + consume(flatkvBucketLegacy, physKey, rawVal, rawVal) default: return fmt.Errorf("semantic memiavl %s: unsupported EVM key kind %d for key %X", caller, kind, rawKey) } diff --git a/sei-db/tools/cmd/seidb/operations/evm_logical_digest_test.go b/sei-db/tools/cmd/seidb/operations/evm_logical_digest_test.go index 477e598b18..2777891043 100644 --- a/sei-db/tools/cmd/seidb/operations/evm_logical_digest_test.go +++ b/sei-db/tools/cmd/seidb/operations/evm_logical_digest_test.go @@ -8,7 +8,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/proto" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/ktype" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/vtype" "github.com/stretchr/testify/require" ) @@ -39,7 +38,7 @@ func TestSemanticMemiavlDigestMatchesTranslatorForCoreEVMKeys(t *testing.T) { require.Equal(t, translatorDigest.account, semanticDigest.account) require.Equal(t, translatorDigest.code, semanticDigest.code) require.Equal(t, translatorDigest.storage, semanticDigest.storage) - require.Equal(t, translatorDigest.misc, semanticDigest.misc) + require.Equal(t, translatorDigest.legacy, semanticDigest.legacy) } func TestSemanticMemiavlInspectMatchesTranslatorForCoreEVMKeys(t *testing.T) { @@ -88,6 +87,19 @@ func TestInspectMemiavlRejectsUnknownNormalizationBeforeOpeningSnapshot(t *testi require.ErrorContains(t, err, `unknown --memiavl-normalization "bogus"`) } +func TestShouldIncludeFlatKVEVMLogicalDigestKey(t *testing.T) { + addr := bytesOfLen(keys.AddressLen, 0x42) + slot := bytesOfLen(32, 0x07) + storageKeyBytes := append(append([]byte{}, addr...), slot...) + + require.True(t, shouldIncludeFlatKVEVMLogicalDigestKey(ktype.EVMPhysicalKey(keys.EVMKeyStorage, storageKeyBytes))) + require.True(t, shouldIncludeFlatKVEVMLogicalDigestKey(ktype.ModulePhysicalKey(keys.EVMStoreKey, []byte{0xFF, 0xAA}))) + require.True(t, shouldIncludeFlatKVEVMLogicalDigestKey(migrationVersionPhysKey)) + + require.False(t, shouldIncludeFlatKVEVMLogicalDigestKey(ktype.ModulePhysicalKey("bank", []byte("balance")))) + require.False(t, shouldIncludeFlatKVEVMLogicalDigestKey([]byte("malformed-key-without-module-prefix"))) +} + func coreEVMRawPairs() []*proto.KVPair { addr := bytesOfLen(keys.AddressLen, 0x42) slot := bytesOfLen(32, 0x07) @@ -95,15 +107,15 @@ func coreEVMRawPairs() []*proto.KVPair { codeHash := bytesOfLen(32, 0xAB) storageValue := bytesOfLen(32, 0x2A) code := []byte{0x60, 0x2A, 0x60, 0x00} - miscKey := append([]byte{0x09}, addr...) - miscValue := []byte{0xAA, 0xBB} + legacyKey := append([]byte{0x09}, addr...) + legacyValue := []byte{0xAA, 0xBB} return []*proto.KVPair{ {Key: keys.BuildEVMKey(keys.EVMKeyNonce, addr), Value: nonceBytes(7)}, {Key: keys.BuildEVMKey(keys.EVMKeyCodeHash, addr), Value: codeHash}, {Key: keys.BuildEVMKey(keys.EVMKeyStorage, storageKeyBytes), Value: storageValue}, {Key: keys.BuildEVMKey(keys.EVMKeyCode, addr), Value: code}, - {Key: miscKey, Value: miscValue}, + {Key: legacyKey, Value: legacyValue}, // Both paths should treat these as delete-equivalent and omit them. {Key: keys.BuildEVMKey(keys.EVMKeyStorage, append(append([]byte{}, addr...), bytesOfLen(32, 0x08)...)), Value: make([]byte, 32)}, {Key: keys.BuildEVMKey(keys.EVMKeyCode, bytesOfLen(keys.AddressLen, 0x99)), Value: nil}, @@ -130,69 +142,3 @@ func nonceBytes(n uint64) []byte { binary.BigEndian.PutUint64(bz, n) return bz } - -// TestMiscForCompareOmitsMigrationMarkerRows pins the marker adjustment that -// lets a memiavl-only node, a completed node (carrying the migration-version -// marker), and an in-progress node (carrying the migration-boundary cursor) all -// produce the same misc digest for identical EVM state. Both FlatKV-only -// MigrationStore rows are folded into the misc bucket during the scan but must -// be XORed back out for the final cross-backend comparison. -func TestMiscForCompareOmitsMigrationMarkerRows(t *testing.T) { - miscKey := append([]byte{0x09}, bytesOfLen(keys.AddressLen, 0x33)...) - miscVal := vtype.NewMiscData().SetBlockHeight(10).SetValue([]byte{0xDE, 0xAD}).Serialize() - versionVal := vtype.NewMiscData().SetBlockHeight(20).SetValue([]byte{0x01}).Serialize() - boundaryVal := vtype.NewMiscData().SetBlockHeight(30).SetValue([]byte{0x02, 0x03}).Serialize() - - // memiavl-only / clean node: only the plain misc row. - clean := evmDigest{} - require.NoError(t, clean.consume(miscKey, miscVal)) - - // completed node: plain row + migration-version marker. - completed := evmDigest{} - require.NoError(t, completed.consume(miscKey, miscVal)) - require.NoError(t, completed.consume(migrationVersionPhysKey, versionVal)) - require.True(t, completed.migrationVersionFound) - - // in-progress node: plain row + migration-boundary cursor. - inProgress := evmDigest{} - require.NoError(t, inProgress.consume(miscKey, miscVal)) - require.NoError(t, inProgress.consume(migrationBoundaryPhysKey, boundaryVal)) - require.True(t, inProgress.migrationBoundaryFound) - - // Raw misc buckets differ because each folds in its marker row. - require.NotEqual(t, clean.misc, completed.misc) - require.NotEqual(t, clean.misc, inProgress.misc) - - // After the marker adjustment all three agree (digest and count). - cleanAcc, cleanCount := clean.miscForCompare() - compAcc, compCount := completed.miscForCompare() - progAcc, progCount := inProgress.miscForCompare() - require.Equal(t, cleanAcc, compAcc, "completed node must match clean after omitting migration-version") - require.Equal(t, cleanCount, compCount) - require.Equal(t, cleanAcc, progAcc, "in-progress node must match clean after omitting migration-boundary") - require.Equal(t, cleanCount, progCount) -} - -func TestCompositeAccountMergeCombinesFlatKVAndMemiavlFragments(t *testing.T) { - addr := bytesOfLen(keys.AddressLen, 0x55) - codeHash := bytesOfLen(32, 0xCC) - balance := bytesOfLen(32, 0x11) - bal, err := vtype.ParseBalance(balance) - require.NoError(t, err) - - flatKVAccount := vtype.NewAccountData().SetBlockHeight(123).SetBalance(bal).SetNonce(9) - accounts := make(map[string]*semanticAccountDigestState) - require.NoError(t, mergeCompositeFlatKVAccount(accounts, ktype.EVMPhysicalKey(keys.EVMKeyNonce, addr), flatKVAccount.Serialize())) - - composite := evmDigest{} - require.NoError(t, composite.consumeSemanticMemiavlLeaf(accounts, keys.BuildEVMKey(keys.EVMKeyCodeHash, addr), codeHash)) - composite.finalizeSemanticAccounts(accounts) - - expected := evmDigest{} - codeHashParsed, err := vtype.ParseCodeHash(codeHash) - require.NoError(t, err) - fullAccount := vtype.NewAccountData().SetBlockHeight(456).SetBalance(bal).SetNonce(9).SetCodeHash(codeHashParsed) - require.NoError(t, expected.consume(ktype.EVMPhysicalKey(keys.EVMKeyNonce, addr), fullAccount.Serialize())) - - require.Equal(t, expected.account, composite.account) -} diff --git a/sei-db/tools/cmd/seidb/operations/flatkv_state_size.go b/sei-db/tools/cmd/seidb/operations/flatkv_state_size.go index 73dd808f93..873624c47a 100644 --- a/sei-db/tools/cmd/seidb/operations/flatkv_state_size.go +++ b/sei-db/tools/cmd/seidb/operations/flatkv_state_size.go @@ -22,7 +22,7 @@ type FlatKVStateSizeResult struct { // Total holds the aggregate size stats across every physical row. Total FlatKVDBSize - // Per-DB breakdown (account, code, storage, misc). + // Per-DB breakdown (account, code, storage, legacy). DBSizes map[string]*FlatKVDBSize // Top EVM contracts by storage size. @@ -100,12 +100,12 @@ func collectFlatKVStateSize(store *flatkv.CommitStore) (*FlatKVStateSizeResult, // classifyFlatKVPhysicalKey determines which logical DB a physical key // belongs to. Physical format: "/" + type_prefix_byte + stripped_key. // Non-evm modules and evm keys with an unrecognised type prefix are bucketed -// into "misc". The kind switch mirrors CommitStore.routePhysicalKey so the +// into "legacy". The kind switch mirrors CommitStore.routePhysicalKey so the // classification stays in sync with FlatKV's actual write routing. func classifyFlatKVPhysicalKey(key []byte) string { moduleName, innerKey, err := ktype.StripModulePrefix(key) if err != nil || moduleName != keys.EVMStoreKey { - return flatkvBucketMisc + return flatkvBucketLegacy } kind, _ := keys.ParseEVMKey(innerKey) switch kind { @@ -116,7 +116,7 @@ func classifyFlatKVPhysicalKey(key []byte) string { case keys.EVMKeyStorage: return flatkvBucketStorage default: - return flatkvBucketMisc + return flatkvBucketLegacy } } @@ -161,7 +161,7 @@ func printFlatKVResults(r *FlatKVStateSizeResult, height int64) { fmt.Printf("%-12s %15s %15s %15s %15s\n", "DB", "Keys", "Key Size", "Value Size", "Total Size") fmt.Printf("%s\n", strings.Repeat("-", 75)) - dbOrder := []string{flatkvBucketAccount, flatkvBucketCode, flatkvBucketStorage, flatkvBucketMisc} + dbOrder := []string{flatkvBucketAccount, flatkvBucketCode, flatkvBucketStorage, flatkvBucketLegacy} for _, name := range dbOrder { db, ok := r.DBSizes[name] if !ok { diff --git a/sei-db/tools/cmd/seidb/operations/flatkv_state_size_test.go b/sei-db/tools/cmd/seidb/operations/flatkv_state_size_test.go index 244b44ba36..ed2992d4cb 100644 --- a/sei-db/tools/cmd/seidb/operations/flatkv_state_size_test.go +++ b/sei-db/tools/cmd/seidb/operations/flatkv_state_size_test.go @@ -48,24 +48,24 @@ func TestClassifyFlatKVPhysicalKey(t *testing.T) { expected: "storage", }, { - name: "non-evm module -> misc", + name: "non-evm module -> legacy", key: ktype.ModulePhysicalKey("bank", []byte("supply")), - expected: "misc", + expected: "legacy", }, { - name: "evm with unknown prefix byte -> misc", + name: "evm with unknown prefix byte -> legacy", key: append([]byte("evm/"), 0xFF, 0xAA), - expected: "misc", + expected: "legacy", }, { - name: "empty evm inner key -> misc", + name: "empty evm inner key -> legacy", key: []byte("evm/"), - expected: "misc", + expected: "legacy", }, { - name: "missing module prefix -> misc", + name: "missing module prefix -> legacy", key: []byte{0x03, 0x04, 0x05}, - expected: "misc", + expected: "legacy", }, } @@ -98,7 +98,7 @@ func TestExtractFlatKVContractAddress(t *testing.T) { // TestCollectFlatKVStateSize exercises the full scan path against a real // in-memory FlatKV store. We seed a mix of account, code, storage and -// misc rows, then verify per-DB counts and the top-contracts ranking by +// legacy rows, then verify per-DB counts and the top-contracts ranking by // storage size. func TestCollectFlatKVStateSize(t *testing.T) { store := newTestFlatKVStore(t) @@ -138,7 +138,7 @@ func TestCollectFlatKVStateSize(t *testing.T) { Changeset: proto.ChangeSet{Pairs: pairs}, } - // One non-evm write goes to the misc DB. + // One non-evm write goes to the legacy DB. bankCS := &proto.NamedChangeSet{ Name: "bank", Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{ @@ -166,9 +166,9 @@ func TestCollectFlatKVStateSize(t *testing.T) { require.EqualValues(t, 8, result.DBSizes["storage"].NumKeys, "3 slots for addrA + 5 slots for addrB = 8 physical storage rows") - require.NotNil(t, result.DBSizes["misc"], "expected misc DB breakdown") - require.EqualValues(t, 1, result.DBSizes["misc"].NumKeys, - "one non-evm bank row should land in misc") + require.NotNil(t, result.DBSizes["legacy"], "expected legacy DB breakdown") + require.EqualValues(t, 1, result.DBSizes["legacy"].NumKeys, + "one non-evm bank row should land in legacy") // Total key count is the sum of per-DB counts. require.EqualValues(t, 3+2+8+1, result.Total.NumKeys) diff --git a/sei-db/tools/cmd/seidb/operations/import_flatkv_from_memiavl.go b/sei-db/tools/cmd/seidb/operations/import_flatkv_from_memiavl.go index 6ce0446aa2..cfe45f6edb 100644 --- a/sei-db/tools/cmd/seidb/operations/import_flatkv_from_memiavl.go +++ b/sei-db/tools/cmd/seidb/operations/import_flatkv_from_memiavl.go @@ -312,7 +312,7 @@ func importMemiavlModulesToFlatKV(ctx context.Context, homeDir string, modules [ // module to the allow-list, this `continue` is what keeps that // module's pairs out of the importer -- the flatkv store does // not have a routing path for non-EVM physical keys yet, and - // silently accepting them would land them in the miscDB + // silently accepting them would land them in the legacyDB // bucket. Any allow-list change MUST be paired with a flatkv // routePhysicalKey extension; otherwise leave this skip alone. if !acceptCurrent { diff --git a/sei-db/tools/cmd/seidb/operations/import_flatkv_from_memiavl_test.go b/sei-db/tools/cmd/seidb/operations/import_flatkv_from_memiavl_test.go index d54e255b34..8972f9483a 100644 --- a/sei-db/tools/cmd/seidb/operations/import_flatkv_from_memiavl_test.go +++ b/sei-db/tools/cmd/seidb/operations/import_flatkv_from_memiavl_test.go @@ -27,8 +27,8 @@ func TestImportMemiavlModulesToFlatKVEncodesEVMValues(t *testing.T) { storageValue := padLeft32(0x2A) nonceValue := uint64(7) eoaNonceValue := uint64(1) - miscKey := append([]byte{0x09}, addr[:]...) - miscValue := []byte{0x00, 0x03} + legacyKey := append([]byte{0x09}, addr[:]...) + legacyValue := []byte{0x00, 0x03} memStore := newTestMemiavlStore(t, homeDir) require.NoError(t, memStore.ApplyChangeSets([]*proto.NamedChangeSet{{ @@ -40,7 +40,7 @@ func TestImportMemiavlModulesToFlatKVEncodesEVMValues(t *testing.T) { codeHashPair(addr, codeHash), noncePair(eoaAddr, eoaNonceValue), codeHashPair(contractOnlyAddr, contractOnlyCodeHash), - {Key: miscKey, Value: miscValue}, + {Key: legacyKey, Value: legacyValue}, }}, }})) version, err := memStore.Commit() @@ -77,9 +77,9 @@ func TestImportMemiavlModulesToFlatKVEncodesEVMValues(t *testing.T) { require.True(t, found) require.Equal(t, contractOnlyCodeHash[:], gotContractOnlyCodeHash) - gotMisc, found := flatStore.Get(keys.EVMStoreKey, miscKey) + gotLegacy, found := flatStore.Get(keys.EVMStoreKey, legacyKey) require.True(t, found) - require.Equal(t, miscValue, gotMisc) + require.Equal(t, legacyValue, gotLegacy) } func TestImportMemiavlModulesToFlatKVRefusesExistingFlatKVWithoutForce(t *testing.T) { diff --git a/sei-db/tools/cmd/seidb/operations/state_size.go b/sei-db/tools/cmd/seidb/operations/state_size.go index 05021c20d3..815bf7dbc0 100644 --- a/sei-db/tools/cmd/seidb/operations/state_size.go +++ b/sei-db/tools/cmd/seidb/operations/state_size.go @@ -70,7 +70,7 @@ func executeStateSize(cmd *cobra.Command, _ []string) { // Optionally scan FlatKV at the same requested height. We only bother // when --module is empty or "evm" because FlatKV in production holds - // only evm keys (anything else is bucketed into the "misc" DB). + // only evm keys (anything else is bucketed into the "legacy" DB). flatkvResult, flatkvActualHeight := maybeCollectFlatKV(flatkvDir, dbDir, module, height) if exportDynamoDB { diff --git a/sei-ibc-go/modules/light-clients/07-tendermint/types/header.go b/sei-ibc-go/modules/light-clients/07-tendermint/types/header.go index cb38eec3d1..ac7242e86d 100644 --- a/sei-ibc-go/modules/light-clients/07-tendermint/types/header.go +++ b/sei-ibc-go/modules/light-clients/07-tendermint/types/header.go @@ -5,7 +5,6 @@ import ( "time" sdkerrors "github.com/sei-protocol/sei-chain/sei-cosmos/types/errors" - tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types" tmtypes "github.com/sei-protocol/sei-chain/sei-tendermint/types" clienttypes "github.com/sei-protocol/sei-chain/sei-ibc-go/modules/core/02-client/types" @@ -13,22 +12,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-ibc-go/modules/core/exported" ) -// validatorSetFromProto converts a proto validator set, rejecting an empty -// one: every set a client message carries must prove a commit or a hash -// link, which an empty set cannot. tmtypes.ValidatorSetFromProto -// canonicalizes an empty proto (for the state store's genesis state), so -// the untrusted decode paths re-tighten here. -func validatorSetFromProto(vp *tmproto.ValidatorSet) (*tmtypes.ValidatorSet, error) { - vals, err := tmtypes.ValidatorSetFromProto(vp) - if err != nil { - return nil, err - } - if vals.IsNilOrEmpty() { - return nil, tmtypes.ErrValidatorSetEmpty - } - return vals, nil -} - var _ exported.Header = &Header{} // ConsensusState returns the updated consensus state associated with the header @@ -88,7 +71,7 @@ func (h Header) ValidateBasic() error { if h.ValidatorSet == nil { return sdkerrors.Wrap(clienttypes.ErrInvalidHeader, "validator set is nil") } - tmValset, err := validatorSetFromProto(h.ValidatorSet) + tmValset, err := tmtypes.ValidatorSetFromProto(h.ValidatorSet) if err != nil { return sdkerrors.Wrap(err, "validator set is not tendermint validator set") } diff --git a/sei-ibc-go/modules/light-clients/07-tendermint/types/misbehaviour.go b/sei-ibc-go/modules/light-clients/07-tendermint/types/misbehaviour.go index fa0091cb89..da972ff07d 100644 --- a/sei-ibc-go/modules/light-clients/07-tendermint/types/misbehaviour.go +++ b/sei-ibc-go/modules/light-clients/07-tendermint/types/misbehaviour.go @@ -119,7 +119,7 @@ func validCommit(chainID string, blockID tmtypes.BlockID, commit *tmproto.Commit if err != nil { return sdkerrors.Wrap(err, "commit is not tendermint commit type") } - tmValset, err := validatorSetFromProto(valSet) + tmValset, err := tmtypes.ValidatorSetFromProto(valSet) if err != nil { return sdkerrors.Wrap(err, "validator set is not tendermint validator set type") } diff --git a/sei-ibc-go/modules/light-clients/07-tendermint/types/misbehaviour_handle.go b/sei-ibc-go/modules/light-clients/07-tendermint/types/misbehaviour_handle.go index 2808691e43..d2675ffeca 100644 --- a/sei-ibc-go/modules/light-clients/07-tendermint/types/misbehaviour_handle.go +++ b/sei-ibc-go/modules/light-clients/07-tendermint/types/misbehaviour_handle.go @@ -101,7 +101,7 @@ func (cs ClientState) CheckMisbehaviourAndUpdateState( func checkMisbehaviourHeader( clientState *ClientState, consState *ConsensusState, header *Header, currentTimestamp time.Time, ) error { - tmTrustedValset, err := validatorSetFromProto(header.TrustedValidators) + tmTrustedValset, err := tmtypes.ValidatorSetFromProto(header.TrustedValidators) if err != nil { return sdkerrors.Wrap(err, "trusted validator set is not tendermint validator set type") } diff --git a/sei-ibc-go/modules/light-clients/07-tendermint/types/update.go b/sei-ibc-go/modules/light-clients/07-tendermint/types/update.go index 1d1fd573d9..ef3cde3192 100644 --- a/sei-ibc-go/modules/light-clients/07-tendermint/types/update.go +++ b/sei-ibc-go/modules/light-clients/07-tendermint/types/update.go @@ -148,7 +148,7 @@ func (cs ClientState) CheckHeaderAndUpdateState( // checkTrustedHeader checks that consensus state matches trusted fields of Header func checkTrustedHeader(header *Header, consState *ConsensusState) error { - tmTrustedValidators, err := validatorSetFromProto(header.TrustedValidators) + tmTrustedValidators, err := tmtypes.ValidatorSetFromProto(header.TrustedValidators) if err != nil { return sdkerrors.Wrap(err, "trusted validator set in not tendermint validator set type") } @@ -186,7 +186,7 @@ func checkValidity( ) } - tmTrustedValidators, err := validatorSetFromProto(header.TrustedValidators) + tmTrustedValidators, err := tmtypes.ValidatorSetFromProto(header.TrustedValidators) if err != nil { return sdkerrors.Wrap(err, "trusted validator set in not tendermint validator set type") } @@ -196,7 +196,7 @@ func checkValidity( return sdkerrors.Wrap(err, "signed header in not tendermint signed header type") } - tmValidatorSet, err := validatorSetFromProto(header.ValidatorSet) + tmValidatorSet, err := tmtypes.ValidatorSetFromProto(header.ValidatorSet) if err != nil { return sdkerrors.Wrap(err, "validator set in not tendermint validator set type") } diff --git a/sei-ibc-go/testing/simapp/app.go b/sei-ibc-go/testing/simapp/app.go index ee98f1b8e0..b0e881ecc9 100644 --- a/sei-ibc-go/testing/simapp/app.go +++ b/sei-ibc-go/testing/simapp/app.go @@ -380,7 +380,7 @@ func NewSimApp( encodingConfig.TxConfig, ), auth.NewAppModule(appCodec, app.AccountKeeper, nil), - vesting.NewAppModule(app.AccountKeeper, app.BankKeeper, app.UpgradeKeeper), + vesting.NewAppModule(app.AccountKeeper, app.BankKeeper), bank.NewAppModule(appCodec, app.BankKeeper, app.AccountKeeper), capability.NewAppModule(appCodec, *app.CapabilityKeeper), feegrantmodule.NewAppModule(appCodec, app.AccountKeeper, app.BankKeeper, app.FeeGrantKeeper, app.interfaceRegistry), diff --git a/sei-tendermint/AGENTS.md b/sei-tendermint/AGENTS.md index 855348511e..ae7ff7b8ea 100644 --- a/sei-tendermint/AGENTS.md +++ b/sei-tendermint/AGENTS.md @@ -8,9 +8,6 @@ Within sei-tendermint subdirectory * avoid capturing loop iterators ("tc := tc" is not needed) * use slices, maps libraries whenever they do not harm readability * use t.Context() in tests instead of context.Background(). -* use sei-tendermint/libs/utils synchronization primitives: utils.Mutex instead of sync.Mutex, utils.RWMutex instead of sync.RWMutex. - utils.Mutex/RWMutex are supposed to be parametrized by the data type they protect. Do NOT use utils.Mutex\[struct{}\] as a drop-in replacement - of sync.Mutex. * use public api of types in tests, never malform internal state of types. For read-only access, you can use private fields directly in case it is not accessible via public api. * when writing tests, focus on asserting the publically visible properties, especially the API contract. Avoid asserting implementation details. @@ -18,13 +15,6 @@ Within sei-tendermint subdirectory * Avoid checking human readable error messages in tests. If programatic error type check is requested, use errors.Is/As/AsType instead. * After introducing changes, you may Use `go test --count=0` to quickly check if tests compile. You may run `go test` to actually run the tests afterwards. Prefer running modified tests selectively and run the whole test suite only if requested or looking for failures. -* Proto fields: use `optional` for all scalar and message fields (required for protobuf3 presence - detection and wireguard bounds checking). Annotate semantically required fields with `// required` - and truly optional fields with `// optional`. Example: - optional uint64 index = 1; // required - optional AppProposal app = 4; // optional - Required fields must be nil-checked at the boundary (constructor and proto Decode) and trusted - everywhere else — do not add defensive nil-checks in internal logic. * Avoid removing comments and logs which are not obviously obsolete. Keep the original wording, only fixing mistakes or obsolete parts. * TestRng instance should be one per test, constructed directly in the test function. In case of nested/table tests, each nested test should create its own instance. Use TestRng.Split() (before spawning) if you need to pass entropy source to a spawned goroutine diff --git a/sei-tendermint/abci/example/kvstore/kvstore.go b/sei-tendermint/abci/example/kvstore/kvstore.go index c9b142d55e..60f3f20d8b 100644 --- a/sei-tendermint/abci/example/kvstore/kvstore.go +++ b/sei-tendermint/abci/example/kvstore/kvstore.go @@ -98,7 +98,7 @@ func NewApplication() *Application { } func NewProxy() *proxy.Proxy { - return proxy.New(NewApplication()) + return proxy.New(NewApplication(), proxy.NopMetrics()) } func (app *Application) InitChain(_ context.Context, req *types.RequestInitChain) (*types.ResponseInitChain, error) { diff --git a/sei-tendermint/autobahn/types/app_proposal.go b/sei-tendermint/autobahn/types/app_proposal.go index e383cc3591..5566acc7fe 100644 --- a/sei-tendermint/autobahn/types/app_proposal.go +++ b/sei-tendermint/autobahn/types/app_proposal.go @@ -18,12 +18,11 @@ type AppProposal struct { globalNumber GlobalBlockNumber roadIndex RoadIndex appHash AppHash - epochIndex EpochIndex } // NewAppProposal creates a new AppProposal. -func NewAppProposal(globalNumber GlobalBlockNumber, roadIndex RoadIndex, appHash AppHash, epochIndex EpochIndex) *AppProposal { - return &AppProposal{globalNumber: globalNumber, roadIndex: roadIndex, appHash: appHash, epochIndex: epochIndex} +func NewAppProposal(globalNumber GlobalBlockNumber, roadIndex RoadIndex, appHash AppHash) *AppProposal { + return &AppProposal{globalNumber: globalNumber, roadIndex: roadIndex, appHash: appHash} } // GlobalNumber . @@ -35,25 +34,19 @@ func (m *AppProposal) RoadIndex() RoadIndex { return m.roadIndex } // AppHash . func (m *AppProposal) AppHash() AppHash { return m.appHash } -// EpochIndex returns the epoch this proposal belongs to. -func (m *AppProposal) EpochIndex() EpochIndex { return m.epochIndex } - // Next is the next global block number to compute AppHash for. func (m *AppProposal) Next() RoadIndex { return m.RoadIndex() + 1 } // Verify verifies that the AppProposal is consistent with the CommitQC. -func (m *AppProposal) Verify(qc *CommitQC) error { +func (m *AppProposal) Verify(c *Committee, qc *CommitQC) error { if got, want := m.RoadIndex(), qc.Proposal().Index(); got != want { return fmt.Errorf("roadIndex() = %v, want %v", got, want) } - if got, want := m.GlobalNumber(), qc.GlobalRange(); got < want.First || got >= want.Next { + if got, want := m.GlobalNumber(), qc.GlobalRange(c); got < want.First || got >= want.Next { return fmt.Errorf("globalNumber() = %v, want in range [%v,%v)", got, want.First, want.Next) } - if got, want := m.EpochIndex(), qc.Proposal().EpochIndex(); got != want { - return fmt.Errorf("epoch_index = %d, want %d", got, want) - } return nil } @@ -64,24 +57,19 @@ var AppProposalConv = protoutils.Conv[*AppProposal, *pb.AppProposal]{ GlobalNumber: utils.Alloc(uint64(m.globalNumber)), RoadIndex: utils.Alloc(uint64(m.roadIndex)), AppHash: m.appHash, - EpochIndex: utils.Alloc(uint64(m.epochIndex)), } }, Decode: func(m *pb.AppProposal) (*AppProposal, error) { if m.GlobalNumber == nil { - return nil, fmt.Errorf("global_number: missing") + return nil, fmt.Errorf("GlobalNumber: missing") } if m.RoadIndex == nil { - return nil, fmt.Errorf("road_index: missing") - } - if m.EpochIndex == nil { - return nil, fmt.Errorf("epoch_index: missing") + return nil, fmt.Errorf("RoadIndex: missing") } return &AppProposal{ globalNumber: GlobalBlockNumber(*m.GlobalNumber), roadIndex: RoadIndex(*m.RoadIndex), appHash: AppHash(m.AppHash), - epochIndex: EpochIndex(*m.EpochIndex), }, nil }, } diff --git a/sei-tendermint/autobahn/types/block.go b/sei-tendermint/autobahn/types/block.go index d3e283bd23..7b3e220e30 100644 --- a/sei-tendermint/autobahn/types/block.go +++ b/sei-tendermint/autobahn/types/block.go @@ -28,14 +28,6 @@ type BlockNumber uint64 // GlobalBlockNumber is the number of a block in the global chain. type GlobalBlockNumber uint64 -// BlockWithNumber pairs a block with its GlobalBlockNumber. It is used as the -// payload of the utils.Option returned by ReadBlockByHash so that the block -// number is only present when the block itself is present. -type BlockWithNumber struct { - Block *Block - Number GlobalBlockNumber -} - // BlockHeaderHash is the hash of a BlockHeader. type BlockHeaderHash hashable.Hash[*pb.BlockHeader] @@ -164,12 +156,6 @@ func (h *BlockHeader) Hash() BlockHeaderHash { // PayloadHash is the hash of a Payload. type PayloadHash hashable.Hash[*pb.Payload] -// ParsePayloadHash constructs a PayloadHash from its raw bytes. -func ParsePayloadHash(bytes []byte) (PayloadHash, error) { - h, err := hashable.ParseHash[*pb.Payload](bytes) - return PayloadHash(h), err -} - // PayloadBuilder builds a Payload. type PayloadBuilder struct { CreatedAt time.Time diff --git a/sei-tendermint/autobahn/types/block_db.go b/sei-tendermint/autobahn/types/block_db.go index dc8cbf95d4..c5baafcd3d 100644 --- a/sei-tendermint/autobahn/types/block_db.go +++ b/sei-tendermint/autobahn/types/block_db.go @@ -21,22 +21,6 @@ var ErrQCNonContiguous = errors.New("block: WriteQC non-contiguous") // before that block (see the BlockDB ordering contract). var ErrBlockMissingQC = errors.New("block: WriteBlock without covering QC") -// ErrPruned is returned by the by-number read methods (ReadBlockByNumber and -// ReadQCByBlockNumber) when the requested GlobalBlockNumber is strictly below -// the current retention watermark: the record is treated as pruned and is not -// served while below the watermark. It is distinct from a utils.None result, -// which means "not present at or above the watermark" and may still be filled -// by a future write. -// -// ErrPruned reflects the watermark's current position, not a permanent verdict. -// The watermark only advances while a store stays open, so within a single -// session ErrPruned is terminal — retrying the same n keeps returning it. It is -// not durable across restarts: the watermark is re-derived on open and -// reclamation is asynchronous, so an n that returned ErrPruned before a restart -// may afterward read as present (or as utils.None). Callers should treat -// ErrPruned as "not currently served," not as a guarantee the record is gone. -var ErrPruned = errors.New("block: below retention watermark") - // BlockDB is the durable backing store for data.State. It persists the two // kinds of finalized records the consensus state machine produces — // finalized blocks (indexed by GlobalBlockNumber and by header hash) and @@ -86,11 +70,6 @@ var ErrPruned = errors.New("block: below retention watermark") // is written, then after a crash if B is persisted then A is also persisted. // - Since QCs must always be written before the blocks they cover, a persisted block is always covered // by a persisted QC, but a persisted QC may or may not have its covered blocks persisted. -// -// # A readable block always has a readable covering QC -// -// Pruning never leaves a block readable without its covering QC also being readable. And if a block becomes -// crash recoverable, its QC is guaranteed to also be crash recoverable. type BlockDB interface { // WriteBlock persists a finalized block at GlobalBlockNumber n. A // block for height n may only be written after a QC covering n has @@ -134,28 +113,15 @@ type BlockDB interface { qc *FullCommitQC, ) error - // PruneBefore advances the retention watermark toward n and removes - // everything below it: - // - every block with GlobalBlockNumber < watermark - // - every QC whose GlobalRange().Next ≤ watermark (its entire - // covered range is below the watermark; a QC straddling the - // watermark stays) - // - // A QC's cohort of blocks changes readability atomically: the watermark - // never falls strictly inside a QC's covered range. A requested n that - // lands inside a QC's range is rounded DOWN to that QC's GlobalRange().First, - // so the whole cohort stays readable until a later prune reaches the QC's - // Next. (Rounding down, not up, because blocks at or above n must be - // retained.) The watermark is therefore always a QC boundary. + // PruneBefore removes: + // - every block with GlobalBlockNumber < n + // - every QC whose GlobalRange().Next ≤ n (the QC's entire + // covered range is strictly below the retention watermark; a + // QC straddling n stays) // // Idempotent: calling with n ≤ the existing retention watermark is // a no-op; the watermark only advances. // - // Pruning never empties the store. Once a block has been written, at - // least one block (and a QC covering it) always remains readable — a - // request that would remove every block is capped to retain the most - // recently written block (and the QC covering it). - // // Pruning is asynchronous and MAY BE DELAYED. PruneBefore records the // watermark and returns; reclamation happens later, on the // implementation's own schedule and potentially at a coarse @@ -164,6 +130,10 @@ type BlockDB interface { // watermark guarantees nothing below n is removed before n is // reached, but does NOT bound when eligible data is actually // reclaimed — pruned entries may remain readable for a while. + // + // Callers must ensure no in-flight reader is holding a pointer + // returned from a Read* call for a record being pruned. Pruning a + // record still being processed is undefined. PruneBefore(n GlobalBlockNumber) error // Flush blocks until every Write that has returned before Flush is @@ -221,29 +191,25 @@ type BlockDB interface { // ReadBlockByNumber returns the block at GlobalBlockNumber n. // - // The result is one of: - // - utils.Some with a nil error: the block is present at n. - // - ErrPruned: n is strictly below the current retention watermark. The - // block is treated as pruned and is not served while below the - // watermark (see ErrPruned for its within-session and cross-restart - // semantics). - // - utils.None with a nil error: n is at or above the watermark but no - // block is present. It was either never written or not yet written — - // the two are indistinguishable — and a future write may fill it. - // - // Never blocks waiting for a future write; blocking semantics (wait for a - // write at n) live above this interface, in data.State. + // Returns utils.None if no block has been written at n. A pruned + // block MAY also return None, but this is not guaranteed: + // reclamation is asynchronous, so a below-watermark block may remain + // readable until it is actually reclaimed. Implementations must not + // block waiting for a future write — "not yet written" is reported as + // utils.None identical to "never written". Blocking semantics + // (wait for a write at n) live above this interface, in + // data.State. ReadBlockByNumber(n GlobalBlockNumber) (utils.Option[*Block], error) // ReadBlockByHash returns the block whose header hashes to the - // given value, paired with its GlobalBlockNumber. The hash is the - // same value as block.Header().Hash() for the block that was passed - // to WriteBlock. + // given value. The hash is the same value as block.Header().Hash() + // for the block that was passed to WriteBlock. // - // Returns utils.None if no such block is readable — either because - // none was written or because it has been pruned (see - // ReadBlockByNumber). Non-blocking. - ReadBlockByHash(hash BlockHeaderHash) (utils.Option[BlockWithNumber], error) + // Returns utils.None if no such block has been written. A pruned + // block MAY also return None, but — as with ReadBlockByNumber — + // reclamation is asynchronous, so a pruned block may remain readable + // until it is actually reclaimed. Non-blocking. + ReadBlockByHash(hash BlockHeaderHash) (utils.Option[*Block], error) // ReadQCByBlockNumber returns the FullCommitQC whose // GlobalRange().First ≤ n < GlobalRange().Next — i.e. the QC that @@ -251,16 +217,12 @@ type BlockDB interface { // blocks, the same *FullCommitQC is returned for every n in its // range. // - // The result is one of: - // - utils.Some with a nil error: a QC covering n is present. - // - ErrPruned: n is strictly below the current retention watermark. The - // covering QC is treated as pruned and is not served while below the - // watermark (see ErrPruned for its within-session and cross-restart - // semantics). - // - utils.None with a nil error: n is at or above the watermark but no QC - // covers it. Either no covering QC was written or it is not yet written. - // - // Non-blocking. + // Returns utils.None if no QC has been written that covers n. A QC + // whose range is below the retention watermark MAY also return None, + // but this is not guaranteed: reclamation is asynchronous and coarse, + // and a QC straddling the watermark is retained outright, so a + // below-watermark lookup may still resolve to a retained QC. Callers + // must not rely on below-watermark lookups missing. Non-blocking. ReadQCByBlockNumber(n GlobalBlockNumber) (utils.Option[*FullCommitQC], error) // Close releases resources held by the store. After Close returns, diff --git a/sei-tendermint/autobahn/types/commit_qc.go b/sei-tendermint/autobahn/types/commit_qc.go index 24f93b58b4..3f2562cc78 100644 --- a/sei-tendermint/autobahn/types/commit_qc.go +++ b/sei-tendermint/autobahn/types/commit_qc.go @@ -41,16 +41,13 @@ func (m *CommitQC) LaneRange(lane LaneID) *LaneRange { } // GlobalRange returns the finalized global block range. -func (m *CommitQC) GlobalRange() GlobalRange { - return m.Proposal().GlobalRange() +func (m *CommitQC) GlobalRange(c *Committee) GlobalRange { + return m.Proposal().GlobalRange(c) } -// Verify verifies the CommitQC against the epoch. -func (m *CommitQC) Verify(ep *Epoch) error { - if err := m.Proposal().Verify(ep); err != nil { - return err - } - c := ep.Committee() +// Verify verifies the CommitQC against the committee. +// Currently it doesn't require the previous CommitQC. +func (m *CommitQC) Verify(c *Committee) error { return m.vote.verifyQC(c, c.CommitQuorum(), m.sigs) } @@ -63,7 +60,7 @@ type FullCommitQC struct { // NewFullCommitQC constructs a new FullCommitQC. func NewFullCommitQC(qc *CommitQC, headers []*BlockHeader) *FullCommitQC { - if got, want := len(headers), int(qc.GlobalRange().Len()); got != want { //nolint:gosec // total lane range len is a small bounded value representing block count in a QC + if got, want := len(headers), int(qc.Proposal().globalRangeWithoutOffset.Len()); got != want { //nolint:gosec // total lane range len is a small bounded value representing block count in a QC panic(fmt.Sprintf("headers length %d != finalized blocks %d", got, want)) } return &FullCommitQC{qc: qc, headers: headers} @@ -80,16 +77,16 @@ func (m *FullCommitQC) Index() RoadIndex { return m.qc.Index() } -// Verify verifies the FullCommitQC against the epoch. -func (m *FullCommitQC) Verify(ep *Epoch) error { - if err := m.qc.Verify(ep); err != nil { +// Verify verifies the FullCommitQC against the committee. +func (m *FullCommitQC) Verify(c *Committee) error { + if err := m.qc.Verify(c); err != nil { return fmt.Errorf("qC: %w", err) } n := uint64(0) - if want, got := int(m.qc.GlobalRange().Len()), len(m.headers); want != got { //nolint:gosec // global range len is a small bounded value representing block count in a QC + if want, got := int(m.qc.GlobalRange(c).Len()), len(m.headers); want != got { //nolint:gosec // global range len is a small bounded value representing block count in a QC return fmt.Errorf("len(headers) = %d, want %d", got, want) } - for lane := range ep.Committee().Lanes().All() { + for lane := range c.Lanes().All() { lr := m.qc.LaneRange(lane) if lr.Len() == 0 { continue diff --git a/sei-tendermint/autobahn/types/committee.go b/sei-tendermint/autobahn/types/committee.go index 34da1345b3..5984febda9 100644 --- a/sei-tendermint/autobahn/types/committee.go +++ b/sei-tendermint/autobahn/types/committee.go @@ -8,6 +8,7 @@ import ( "iter" "maps" "slices" + "time" "github.com/ethereum/go-ethereum/common" "github.com/holiman/uint256" @@ -26,6 +27,14 @@ type Committee struct { replicas ImSlice[PublicKey] weights map[PublicKey]uint64 totalWeight uint64 + // Number of the first block of the chain. + // TODO: firstBlock is not really a part of the committee, + // but it does belong to a chain spec (or epoch spec/genesis/etc.), + // which should be passed around to verify autobahn messages. + // Once we introduce the chain spec it should wrap Committee and firstBlock. + firstBlock GlobalBlockNumber + // timestamp at genesis. All blocks need to have a timestamp later than genesis. + genesisTimestamp time.Time } const MaxValidators = 100 @@ -46,6 +55,12 @@ func (c *Committee) Lanes() ImSlice[LaneID] { return c.replicas } // Replicas is the list of nodes which are eligible to participate in the consensus. func (c *Committee) Replicas() ImSlice[PublicKey] { return c.replicas } +// FirstBlock is the index of the first global block finalized by this committee. +func (c *Committee) FirstBlock() GlobalBlockNumber { return c.firstBlock } + +// GenesisTimestamp is the timestamp at genesis. +func (c *Committee) GenesisTimestamp() time.Time { return c.genesisTimestamp } + // Deterministic random oracle selecting a replica with probability proportional to the weight. func (c *Committee) randomReplica(seed []byte) PublicKey { h := sha256.Sum256(seed[:]) @@ -116,7 +131,7 @@ func (c *Committee) LaneQuorum() uint64 { return c.Faulty() + 1 } -func NewCommittee(weights map[PublicKey]uint64) (*Committee, error) { +func NewCommittee(weights map[PublicKey]uint64, firstBlock GlobalBlockNumber, genesisTimestamp time.Time) (*Committee, error) { weights = maps.Clone(weights) totalWeight := uint64(0) for k, w := range weights { @@ -136,17 +151,19 @@ func NewCommittee(weights map[PublicKey]uint64) (*Committee, error) { } replicas := slices.SortedFunc(maps.Keys(weights), func(a, b PublicKey) int { return a.Compare(b) }) return &Committee{ - replicas: ImSlice[PublicKey]{replicas}, - weights: weights, - totalWeight: totalWeight, + replicas: ImSlice[PublicKey]{replicas}, + weights: weights, + totalWeight: totalWeight, + firstBlock: firstBlock, + genesisTimestamp: genesisTimestamp, }, nil } -// NewRoundRobinElection creates a Committee with equal weights for each replica. -func NewRoundRobinElection(replicas []PublicKey) (*Committee, error) { +// NewRoundRobinElection creates a Committee with round robin election starting at firstBlock. +func NewRoundRobinElection(replicas []PublicKey, firstBlock GlobalBlockNumber, genesisTimestamp time.Time) (*Committee, error) { weights := map[PublicKey]uint64{} for _, k := range replicas { weights[k] = 1 } - return NewCommittee(weights) + return NewCommittee(weights, firstBlock, genesisTimestamp) } diff --git a/sei-tendermint/autobahn/types/committee_test.go b/sei-tendermint/autobahn/types/committee_test.go index 996e2a812a..c893c064d3 100644 --- a/sei-tendermint/autobahn/types/committee_test.go +++ b/sei-tendermint/autobahn/types/committee_test.go @@ -11,13 +11,15 @@ import ( func TestNewCommittee_FiltersOutZeroWeightValidators(t *testing.T) { rng := utils.TestRng() + firstBlock := GenGlobalBlockNumber(rng) + genesisTimestamp := time.Now() zeroWeightKey := GenPublicKey(rng) nonZeroWeightKey := GenPublicKey(rng) committee, err := NewCommittee(map[PublicKey]uint64{ zeroWeightKey: 0, nonZeroWeightKey: 7, - }) + }, firstBlock, genesisTimestamp) if err != nil { t.Fatalf("NewCommittee(): %v", err) } @@ -38,11 +40,13 @@ func TestNewCommittee_FiltersOutZeroWeightValidators(t *testing.T) { func TestNewCommittee_RejectsZeroTotalWeight(t *testing.T) { rng := utils.TestRng() + firstBlock := GenGlobalBlockNumber(rng) + genesisTimestamp := time.Now() _, err := NewCommittee(map[PublicKey]uint64{ GenPublicKey(rng): 0, GenPublicKey(rng): 0, - }) + }, firstBlock, genesisTimestamp) if err == nil { t.Fatal("NewCommittee() succeeded, want error") } @@ -50,11 +54,13 @@ func TestNewCommittee_RejectsZeroTotalWeight(t *testing.T) { func TestNewCommittee_RejectsWeightOverflow(t *testing.T) { rng := utils.TestRng() + firstBlock := GenGlobalBlockNumber(rng) + genesisTimestamp := time.Now() _, err := NewCommittee(map[PublicKey]uint64{ GenPublicKey(rng): math.MaxUint64, GenPublicKey(rng): 1, - }) + }, firstBlock, genesisTimestamp) if err == nil { t.Fatal("NewCommittee() succeeded, want error") } @@ -62,156 +68,106 @@ func TestNewCommittee_RejectsWeightOverflow(t *testing.T) { func TestNewCommittee_RejectsTooManyValidators(t *testing.T) { rng := utils.TestRng() + firstBlock := GenGlobalBlockNumber(rng) + genesisTimestamp := time.Now() weights := map[PublicKey]uint64{} for range MaxValidators + 1 { weights[GenPublicKey(rng)] = 1 } - _, err := NewCommittee(weights) + _, err := NewCommittee(weights, firstBlock, genesisTimestamp) if err == nil { t.Fatal("NewCommittee() succeeded, want error") } } -func makeEpoch(rng utils.Rng) (*Epoch, []SecretKey) { +func makeCommittee() (*Committee, []SecretKey) { keys := []SecretKey{ TestSecretKey("heavy"), TestSecretKey("light1"), TestSecretKey("light2"), } - committee := utils.OrPanic1(NewCommittee(map[PublicKey]uint64{ + return utils.OrPanic1(NewCommittee(map[PublicKey]uint64{ keys[0].Public(): 5, keys[1].Public(): 1, keys[2].Public(): 1, - })) - return GenEpochWithCommittee(rng, committee), keys + }, 0, time.Now())), keys } func TestLaneQCVerifyChecksWeight(t *testing.T) { rng := utils.TestRng() - ep, keys := makeEpoch(rng) + committee, keys := makeCommittee() vote := NewLaneVote(NewBlock(keys[0].Public(), 0, GenBlockHeaderHash(rng), GenPayload(rng)).Header()) heavyOnly := NewLaneQC([]*Signed[*LaneVote]{ Sign(keys[0], vote), }) - require.NoError(t, heavyOnly.Verify(ep.Committee())) + require.NoError(t, heavyOnly.Verify(committee)) lightMajority := NewLaneQC([]*Signed[*LaneVote]{ Sign(keys[1], vote), Sign(keys[2], vote), }) - require.Error(t, lightMajority.Verify(ep.Committee())) + require.Error(t, lightMajority.Verify(committee)) } func TestPrepareQCVerifyChecksWeight(t *testing.T) { rng := utils.TestRng() - ep, keys := makeEpoch(rng) - vote := NewPrepareVote(ProposalAt(ep, View{EpochIndex: ep.EpochIndex(), Index: ep.RoadRange().First})) + committee, keys := makeCommittee() + vote := NewPrepareVote(GenProposalAt(rng, View{})) heavyOnly := NewPrepareQC([]*Signed[*PrepareVote]{ Sign(keys[0], vote), }) - require.NoError(t, heavyOnly.Verify(ep)) + require.NoError(t, heavyOnly.Verify(committee)) lightMajority := NewPrepareQC([]*Signed[*PrepareVote]{ Sign(keys[1], vote), Sign(keys[2], vote), }) - require.Error(t, lightMajority.Verify(ep)) -} - -func TestPrepareQCVerifyChecksEpochBinding(t *testing.T) { - rng := utils.TestRng() - ep, keys := makeEpoch(rng) - sign := func(p *Proposal) *PrepareQC { - return NewPrepareQC([]*Signed[*PrepareVote]{Sign(keys[0], NewPrepareVote(p))}) - } - - require.NoError(t, sign(ProposalAt(ep, View{Index: ep.RoadRange().First})).Verify(ep)) - - wrongEpoch := newProposal(View{Index: ep.RoadRange().First, EpochIndex: ep.EpochIndex() + 1}, time.Time{}, nil, utils.None[*AppProposal](), ep.FirstBlock()) - require.Error(t, sign(wrongEpoch).Verify(ep)) - - outOfRoads := newProposal(View{Index: ep.RoadRange().Last + 1, EpochIndex: ep.EpochIndex()}, time.Time{}, nil, utils.None[*AppProposal](), ep.FirstBlock()) - require.Error(t, sign(outOfRoads).Verify(ep)) -} - -func TestCommitQCVerifyChecksEpochBinding(t *testing.T) { - rng := utils.TestRng() - ep, keys := makeEpoch(rng) - sign := func(p *Proposal) *CommitQC { - return NewCommitQC([]*Signed[*CommitVote]{Sign(keys[0], NewCommitVote(p))}) - } - - require.NoError(t, sign(ProposalAt(ep, View{Index: ep.RoadRange().First})).Verify(ep)) - - wrongEpoch := newProposal(View{Index: ep.RoadRange().First, EpochIndex: ep.EpochIndex() + 1}, time.Time{}, nil, utils.None[*AppProposal](), ep.FirstBlock()) - require.Error(t, sign(wrongEpoch).Verify(ep)) - - outOfRoads := newProposal(View{Index: ep.RoadRange().Last + 1, EpochIndex: ep.EpochIndex()}, time.Time{}, nil, utils.None[*AppProposal](), ep.FirstBlock()) - require.Error(t, sign(outOfRoads).Verify(ep)) + require.Error(t, lightMajority.Verify(committee)) } func TestCommitQCVerifyChecksWeight(t *testing.T) { rng := utils.TestRng() - ep, keys := makeEpoch(rng) - vote := NewCommitVote(ProposalAt(ep, View{EpochIndex: ep.EpochIndex(), Index: ep.RoadRange().First})) + committee, keys := makeCommittee() + vote := NewCommitVote(GenProposalAt(rng, View{})) heavyOnly := NewCommitQC([]*Signed[*CommitVote]{ Sign(keys[0], vote), }) - require.NoError(t, heavyOnly.Verify(ep)) + require.NoError(t, heavyOnly.Verify(committee)) lightMajority := NewCommitQC([]*Signed[*CommitVote]{ Sign(keys[1], vote), Sign(keys[2], vote), }) - require.Error(t, lightMajority.Verify(ep)) + require.Error(t, lightMajority.Verify(committee)) } func TestAppQCVerifyChecksWeight(t *testing.T) { rng := utils.TestRng() - ep, keys := makeEpoch(rng) - vote := NewAppVote(NewAppProposal(0, 0, GenAppHash(rng), ep.EpochIndex())) + committee, keys := makeCommittee() + vote := NewAppVote(NewAppProposal(0, 0, GenAppHash(rng))) heavyOnly := NewAppQC([]*Signed[*AppVote]{ Sign(keys[0], vote), }) - require.NoError(t, heavyOnly.Verify(ep.Committee())) + require.NoError(t, heavyOnly.Verify(committee)) lightMajority := NewAppQC([]*Signed[*AppVote]{ Sign(keys[1], vote), Sign(keys[2], vote), }) - require.Error(t, lightMajority.Verify(ep.Committee())) -} - -func TestTimeoutQCVerifyChecksEpochBinding(t *testing.T) { - rng := utils.TestRng() - ep, keys := makeEpoch(rng) - prev := utils.Some(CommitQCAt(ep, keys)) - view := View{EpochIndex: ep.EpochIndex(), Index: ep.RoadRange().First + 1} - - correct := NewTimeoutQC([]*FullTimeoutVote{ - NewFullTimeoutVote(keys[0], view, utils.None[*PrepareQC]()), - }) - require.NoError(t, correct.Verify(ep, prev)) - - wrongEpoch := NewTimeoutQC([]*FullTimeoutVote{ - NewFullTimeoutVote(keys[0], View{EpochIndex: ep.EpochIndex() + 1, Index: view.Index}, utils.None[*PrepareQC]()), - }) - require.Error(t, wrongEpoch.Verify(ep, prev)) + require.Error(t, lightMajority.Verify(committee)) } func TestTimeoutQCVerifyChecksWeight(t *testing.T) { - rng := utils.TestRng() - ep, keys := makeEpoch(rng) - prev := utils.Some(CommitQCAt(ep, keys)) - view := View{EpochIndex: ep.EpochIndex(), Index: ep.RoadRange().First + 1} + committee, keys := makeCommittee() + view := View{} heavyOnly := NewTimeoutQC([]*FullTimeoutVote{ NewFullTimeoutVote(keys[0], view, utils.None[*PrepareQC]()), }) - if err := heavyOnly.Verify(ep, prev); err != nil { + if err := heavyOnly.Verify(committee, utils.None[*CommitQC]()); err != nil { t.Fatalf("heavyOnly.Verify(): %v", err) } @@ -219,14 +175,7 @@ func TestTimeoutQCVerifyChecksWeight(t *testing.T) { NewFullTimeoutVote(keys[1], view, utils.None[*PrepareQC]()), NewFullTimeoutVote(keys[2], view, utils.None[*PrepareQC]()), }) - if err := lightMajority.Verify(ep, prev); err == nil { + if err := lightMajority.Verify(committee, utils.None[*CommitQC]()); err == nil { t.Fatal("lightMajority.Verify() succeeded, want error") } } - -func TestNewCommittee_RejectsEmptyWeights(t *testing.T) { - _, err := NewCommittee(map[PublicKey]uint64{}) - if err == nil { - t.Fatal("NewCommittee() succeeded with empty weights, want error") - } -} diff --git a/sei-tendermint/autobahn/types/epoch.go b/sei-tendermint/autobahn/types/epoch.go deleted file mode 100644 index 3ab13df54a..0000000000 --- a/sei-tendermint/autobahn/types/epoch.go +++ /dev/null @@ -1,51 +0,0 @@ -package types - -import ( - "time" - - "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" -) - -// EpochIndex is the epoch number. -type EpochIndex uint64 - -// RoadRange is an inclusive range of RoadIndex values [First, Last]. -type RoadRange struct { - First RoadIndex - Last RoadIndex -} - -// OpenRoadRange returns a RoadRange covering all road indices from 0. -// Use in tests and genesis epochs where no upper bound is known yet. -func OpenRoadRange() RoadRange { return RoadRange{First: 0, Last: utils.Max[RoadIndex]()} } - -// Has reports whether idx falls within this range (inclusive on both ends). -func (r RoadRange) Has(idx RoadIndex) bool { return idx >= r.First && idx <= r.Last } - -// Epoch holds the complete context for a single epoch. -// Retrieved from the local Registry; never transmitted on the wire. -type Epoch struct { - utils.ReadOnly - epochIndex EpochIndex - roads RoadRange - firstTimestamp time.Time - committee *Committee - firstBlock GlobalBlockNumber -} - -// NewEpoch constructs an Epoch. -func NewEpoch(index EpochIndex, roads RoadRange, firstTimestamp time.Time, committee *Committee, firstBlock GlobalBlockNumber) *Epoch { - return &Epoch{ - epochIndex: index, - roads: roads, - firstTimestamp: firstTimestamp, - committee: committee, - firstBlock: firstBlock, - } -} - -func (e *Epoch) EpochIndex() EpochIndex { return e.epochIndex } -func (e *Epoch) RoadRange() RoadRange { return e.roads } -func (e *Epoch) FirstTimestamp() time.Time { return e.firstTimestamp } -func (e *Epoch) Committee() *Committee { return e.committee } -func (e *Epoch) FirstBlock() GlobalBlockNumber { return e.firstBlock } diff --git a/sei-tendermint/autobahn/types/opt.go b/sei-tendermint/autobahn/types/opt.go index 7dea90a3f4..b6da4556ea 100644 --- a/sei-tendermint/autobahn/types/opt.go +++ b/sei-tendermint/autobahn/types/opt.go @@ -46,6 +46,16 @@ func LaneRangeOpt[T interface { return NewLaneRange(lane, 0, utils.None[*BlockHeader]()) } +// GlobalRangeOpt defaults to an empty initial range. +func GlobalRangeOpt[T interface { + GlobalRange(c *Committee) GlobalRange +}](mv utils.Option[T], c *Committee) GlobalRange { + if v, ok := mv.Get(); ok { + return v.GlobalRange(c) + } + return GlobalRange{First: c.FirstBlock(), Next: c.FirstBlock()} +} + // AppOpt defaults to None. func AppOpt[T interface { App() utils.Option[*AppProposal] diff --git a/sei-tendermint/autobahn/types/prepare_qc.go b/sei-tendermint/autobahn/types/prepare_qc.go index 92c3f07d79..7df064988b 100644 --- a/sei-tendermint/autobahn/types/prepare_qc.go +++ b/sei-tendermint/autobahn/types/prepare_qc.go @@ -36,12 +36,9 @@ func (m *PrepareQC) View() View { return m.vote.Msg().Proposal().View() } -// Verify verifies the PrepareQC against the epoch. -func (m *PrepareQC) Verify(ep *Epoch) error { - if err := m.Proposal().Verify(ep); err != nil { - return err - } - c := ep.Committee() +// Verify verifies the PrepareQC against the committee. +// Currently it doesn't require the previous CommitQC. +func (m *PrepareQC) Verify(c *Committee) error { return m.vote.verifyQC(c, c.PrepareQuorum(), m.sigs) } diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index 2206e65e8b..5630839a21 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -88,76 +88,47 @@ func (n ViewNumber) Next() ViewNumber { return n + 1 } // View represents a consensus view. type View struct { - Index RoadIndex - Number ViewNumber - EpochIndex EpochIndex + Index RoadIndex + Number ViewNumber } // Less checks if v is earlier than b. func (v View) Less(b View) bool { - if v.EpochIndex != b.EpochIndex { - return v.EpochIndex < b.EpochIndex - } if v.Index != b.Index { return v.Index < b.Index } return v.Number < b.Number } -// Verify checks that the view's epoch index and road index are consistent with ep. -func (v View) Verify(ep *Epoch) error { - if got, want := v.EpochIndex, ep.EpochIndex(); got != want { - return fmt.Errorf("epoch_index = %d, want %d", got, want) - } - if rr := ep.RoadRange(); !rr.Has(v.Index) { - return fmt.Errorf("road_index %v not in epoch roads [%v, %v]", v.Index, rr.First, rr.Last) - } - return nil -} - // Next returns the next view. func (v View) Next() View { v.Number = v.Number.Next() return v } -// ViewSpec is the full local context for starting a view: justification QCs plus -// the epoch active at that view. Epoch is required; View(), NextGlobalBlock(), and -// NextTimestamp() panic if it is nil. +// ViewSpec is a justification to start a given view. type ViewSpec struct { // WARNING: currently we have implicit assumption that // TimeoutQC.View().Index == CommitQC.Index.Next(), // I.e. that TimeoutQC comes from the expected consensus instance. CommitQC utils.Option[*CommitQC] TimeoutQC utils.Option[*TimeoutQC] - Epoch *Epoch -} - -// NextGlobalBlock returns the first global block number expected in the next proposal. -// CommitQC is None only at global block 0 (genesis), in which case it returns Epoch[0].FirstBlock. -// For all other views, including the first view of a non-genesis epoch, CommitQC is present and it returns CommitQC.GlobalRange().Next. -func (vs *ViewSpec) NextGlobalBlock() GlobalBlockNumber { - if cQC, ok := vs.CommitQC.Get(); ok { - return cQC.GlobalRange().Next - } - return vs.Epoch.FirstBlock() } // View is the view justified by vs. func (vs *ViewSpec) View() View { idx := NextIndexOpt(vs.CommitQC) if view := NextViewOpt(vs.TimeoutQC); view.Index == idx { - view.EpochIndex = vs.Epoch.EpochIndex() return view } - return View{Index: idx, Number: 0, EpochIndex: vs.Epoch.EpochIndex()} + return View{Index: idx, Number: 0} } -func (vs *ViewSpec) NextTimestamp() time.Time { +func (vs *ViewSpec) NextTimestamp(c *Committee) time.Time { if cQC, ok := vs.CommitQC.Get(); ok { return cQC.Proposal().NextTimestamp() } - return vs.Epoch.FirstTimestamp() + return c.GenesisTimestamp() } // Proposal is the road tipcut proposal. @@ -165,28 +136,33 @@ func (vs *ViewSpec) NextTimestamp() time.Time { // AppQC could be nil if we haven't reached any quorum state hash. type Proposal struct { utils.ReadOnly - view View - timestamp time.Time - laneRanges map[LaneID]*LaneRange - app utils.Option[*AppProposal] - globalRange GlobalRange -} - -func newProposal(view View, timestamp time.Time, laneRanges []*LaneRange, app utils.Option[*AppProposal], globalFirst GlobalBlockNumber) *Proposal { + view View + timestamp time.Time + laneRanges map[LaneID]*LaneRange + app utils.Option[*AppProposal] + // derived + // WARNING: this is not a valid global range, because + // it does not take into consideration committee.FirstBlock(). + // We keep it precomputed just to optimize the GlobalRange call. + globalRangeWithoutOffset GlobalRange +} + +func newProposal(view View, timestamp time.Time, laneRanges []*LaneRange, app utils.Option[*AppProposal]) *Proposal { laneRangesM := map[LaneID]*LaneRange{} - gr := GlobalRange{First: globalFirst, Next: globalFirst} + globalRangeWithoutOffset := GlobalRange{} for _, r := range laneRanges { laneRangesM[r.Lane()] = r } for _, r := range laneRangesM { - gr.Next += GlobalBlockNumber(r.Next() - r.First()) + globalRangeWithoutOffset.First += GlobalBlockNumber(r.First()) + globalRangeWithoutOffset.Next += GlobalBlockNumber(r.Next()) } return &Proposal{ - view: view, - timestamp: timestamp, - laneRanges: laneRangesM, - globalRange: gr, - app: app, + view: view, + timestamp: timestamp, + laneRanges: laneRangesM, + globalRangeWithoutOffset: globalRangeWithoutOffset, + app: app, } } @@ -202,21 +178,24 @@ func (m *Proposal) Timestamp() time.Time { return m.timestamp } // App . func (m *Proposal) App() utils.Option[*AppProposal] { return m.app } -// EpochIndex returns the epoch index encoded in the proposal. -func (m *Proposal) EpochIndex() EpochIndex { return m.view.EpochIndex } - // GlobalRange returns the proposed global block range. -func (m *Proposal) GlobalRange() GlobalRange { - return m.globalRange +// To compute GlobalRange from lane ranges in proposal, +// we need to know the global number of the first block +// of the chain (c.FirstBlock()). +func (m *Proposal) GlobalRange(c *Committee) GlobalRange { + gr := m.globalRangeWithoutOffset + gr.First += c.FirstBlock() + gr.Next += c.FirstBlock() + return gr } // Arbitrary deterministic minimal diff between consecutive blocks. const minTimestampDiff = time.Microsecond // Monotone timestamp assigned to each block of the proposal. -// Returns None if n does not belong to the proposal's global range. -func (m *Proposal) BlockTimestamp(n GlobalBlockNumber) utils.Option[time.Time] { - gr := m.GlobalRange() +// Returns None, if n doed not belong to the proposal's global range. +func (m *Proposal) BlockTimestamp(c *Committee, n GlobalBlockNumber) utils.Option[time.Time] { + gr := m.GlobalRange(c) if !gr.Has(n) { return utils.None[time.Time]() } @@ -227,24 +206,20 @@ func (m *Proposal) BlockTimestamp(n GlobalBlockNumber) utils.Option[time.Time] { // Lowest allowed timestamp for the next index proposal. func (m *Proposal) NextTimestamp() time.Time { //nolint:gosec // TODO: do stricter timestamp validation before running in prod. - return m.Timestamp().Add(time.Duration(m.globalRange.Len()) * minTimestampDiff) + return m.Timestamp().Add(time.Duration(m.globalRangeWithoutOffset.Len()) * minTimestampDiff) } -// Verify checks epoch binding, lane-range structural validity (bounds, max-length, -// and lane committee membership), and proposal-hash integrity. QC-chain continuity -// (matching starts against the previous QC) is only enforced by FullProposal.Verify. -func (m *Proposal) Verify(ep *Epoch) error { - if err := m.view.Verify(ep); err != nil { - return fmt.Errorf("view: %w", err) - } - c := ep.Committee() +// Verify checks that every present lane range belongs to the committee +// and is internally valid. Lanes may be omitted — omitted lanes are +// treated as implicit empty ranges by FullProposal.Verify. +func (m *Proposal) Verify(c *Committee) error { for _, r := range m.laneRanges { - if got := r.Len(); got > MaxLaneRangeInProposal { - return fmt.Errorf("laneRange[%v].Len() = %d, want <= %d", r.Lane(), got, MaxLaneRangeInProposal) - } if err := r.Verify(c); err != nil { return fmt.Errorf("laneRange[%v]: %w", r.Lane(), err) } + if got, wantMax := r.Len(), uint64(MaxLaneRangeInProposal); got > wantMax { + return fmt.Errorf("laneRanges[%v]: len = %d, want <= %d", r.Lane(), got, wantMax) + } } return nil } @@ -290,50 +265,28 @@ func NewReproposal( // timestamp might get replaced to ensure that timestamps are monotone. func NewProposal( key SecretKey, + committee *Committee, viewSpec ViewSpec, timestamp time.Time, laneQCs map[LaneID]*LaneQC, appQC utils.Option[*AppQC], ) (*FullProposal, error) { - committee := viewSpec.Epoch.Committee() if got, want := key.Public(), committee.Leader(viewSpec.View()); got != want { return nil, fmt.Errorf("key %q is not the leader %q for view %v", got, want, viewSpec.View()) } if p, ok := NewReproposal(key, viewSpec); ok { return p, nil } - proposal, appQC, err := buildProposal(committee, viewSpec, timestamp, laneQCs, appQC) - if err != nil { - return nil, err - } - return &FullProposal{ - proposal: Sign(key, proposal), - laneQCs: laneQCs, - appQC: appQC, - timeoutQC: viewSpec.TimeoutQC, - }, nil -} - -// buildProposal constructs the unsigned Proposal message and returns it along with the -// (possibly cleared) appQC. It contains the non-signing body shared by NewProposal and -// NewProposalForTesting. -func buildProposal( - committee *Committee, - viewSpec ViewSpec, - timestamp time.Time, - laneQCs map[LaneID]*LaneQC, - appQC utils.Option[*AppQC], -) (*Proposal, utils.Option[*AppQC], error) { var laneRanges []*LaneRange for lane := range committee.Lanes().All() { first := LaneRangeOpt(viewSpec.CommitQC, lane).Next() if lQC, ok := laneQCs[lane]; ok { if lQC.Header().Lane() != lane { - return nil, appQC, fmt.Errorf("laneQC %v for lane %v", lQC.Header().Lane(), lane) + return nil, fmt.Errorf("laneQC %v for lane %v", lQC.Header().Lane(), lane) } laneRange := NewLaneRange(lane, first, utils.Some(lQC.Header())) if got := laneRange.Len(); got > MaxLaneRangeInProposal { - return nil, appQC, fmt.Errorf("laneRange[%v].Len() = %d, want <= %d", lane, got, MaxLaneRangeInProposal) + return nil, fmt.Errorf("laneRange[%v].Len() = %d, want <= %d", lane, got, MaxLaneRangeInProposal) } laneRanges = append(laneRanges, laneRange) } else { @@ -348,35 +301,18 @@ func buildProposal( } // If the new appProposal is from the future (which may happen if this node is behind), then clear appQC. // The proposal will be useless in this case, but at least it will be valid. - if a, ok := app.Get(); ok && a.GlobalNumber() >= viewSpec.NextGlobalBlock() { + if a, ok := app.Get(); ok && a.GlobalNumber() >= GlobalRangeOpt(viewSpec.CommitQC, committee).Next { app = utils.None[*AppProposal]() appQC = utils.None[*AppQC]() } // Normalize the creation timestamp. - if wantMin := viewSpec.NextTimestamp(); timestamp.Before(wantMin) { + if wantMin := viewSpec.NextTimestamp(committee); timestamp.Before(wantMin) { timestamp = wantMin } - return newProposal(viewSpec.View(), timestamp, laneRanges, app, viewSpec.NextGlobalBlock()), appQC, nil -} + proposal := newProposal(viewSpec.View(), timestamp, laneRanges, app) -// NewProposalForTesting builds a FullProposal exactly like NewProposal but attaches the -// provided (typically fake) signature instead of signing with a secret key. FOR -// TESTS/BENCHMARKS ONLY: the resulting proposal will NOT verify. Unlike NewProposal it -// does not support the reproposal path, so viewSpec.TimeoutQC must be None. -func NewProposalForTesting( - committee *Committee, - viewSpec ViewSpec, - timestamp time.Time, - laneQCs map[LaneID]*LaneQC, - appQC utils.Option[*AppQC], - sig *Signature, -) (*FullProposal, error) { - proposal, appQC, err := buildProposal(committee, viewSpec, timestamp, laneQCs, appQC) - if err != nil { - return nil, err - } return &FullProposal{ - proposal: newSigned(proposal, sig), + proposal: Sign(key, proposal), laneQCs: laneQCs, appQC: appQC, timeoutQC: viewSpec.TimeoutQC, @@ -403,18 +339,17 @@ func (m *FullProposal) TimeoutQC() utils.Option[*TimeoutQC] { } // Verify verifies the FullProposal against the current view. -func (m *FullProposal) Verify(vs ViewSpec) error { - c := vs.Epoch.Committee() +func (m *FullProposal) Verify(c *Committee, vs ViewSpec) error { return scope.Parallel(func(s scope.ParallelScope) error { // Does the view match? if got, want := m.proposal.Msg().View(), vs.View(); got != want { return fmt.Errorf("view = %v, want %v", m.View(), vs.View()) } - if got, want := m.proposal.Msg().GlobalRange().First, vs.NextGlobalBlock(); got != want { + if got, want := m.proposal.Msg().GlobalRange(c).First, GlobalRangeOpt(vs.CommitQC, c).Next; got != want { return fmt.Errorf("proposal.GlobalRange().First = %v, want %v", got, want) } // Is the timestamp monotone? - if got, wantMin := m.proposal.Msg().Timestamp(), vs.NextTimestamp(); got.Before(wantMin) { + if got, wantMin := m.proposal.Msg().Timestamp(), vs.NextTimestamp(c); got.Before(wantMin) { return fmt.Errorf("proposal.Timestamp() = %v, want >= %v", got, wantMin) } // Is proposer valid? @@ -432,7 +367,7 @@ func (m *FullProposal) Verify(vs ViewSpec) error { // Verify timeoutQC. if tQC, ok := m.timeoutQC.Get(); ok { s.Spawn(func() error { - if err := tQC.Verify(vs.Epoch, vs.CommitQC); err != nil { + if err := tQC.Verify(c, vs.CommitQC); err != nil { return fmt.Errorf("timeoutQC: %w", err) } return nil @@ -449,9 +384,9 @@ func (m *FullProposal) Verify(vs ViewSpec) error { return nil } } - // Verify the proposal's epoch binding, road range, lane structure, and membership. + // Verify the proposal's lane structure against the committee. proposal := m.proposal.Msg() - if err := proposal.Verify(vs.Epoch); err != nil { + if err := proposal.Verify(c); err != nil { return fmt.Errorf("proposal: %w", err) } // Verify each lane range against the previous commitQC and its laneQC justification. @@ -490,10 +425,6 @@ func (m *FullProposal) Verify(vs ViewSpec) error { } } else { app, _ := m.proposal.Msg().App().Get() - // TODO: relax to allow current_epoch-1 once epoch transitions are wired up. - if got, want := app.EpochIndex(), m.proposal.Msg().EpochIndex(); got != want { - return fmt.Errorf("app epoch_index %d != proposal epoch_index %d", got, want) - } appQC, ok := m.appQC.Get() if !ok { return errors.New("appQC missing") @@ -507,7 +438,7 @@ func (m *FullProposal) Verify(vs ViewSpec) error { } return nil }) - if got, want := appQC.Proposal().GlobalNumber(), vs.NextGlobalBlock(); got >= want { + if got, want := appQC.Proposal().GlobalNumber(), GlobalRangeOpt(vs.CommitQC, c).Next; got >= want { return fmt.Errorf("appQC for block %v, while only %v blocks were finalized", got, want) } } @@ -553,9 +484,8 @@ var LaneRangeConv = protoutils.Conv[*LaneRange, *pb.LaneRange]{ var ViewConv = protoutils.Conv[View, *pb.View]{ Encode: func(m View) *pb.View { return &pb.View{ - Index: utils.Alloc(uint64(m.Index)), - Number: utils.Alloc(uint64(m.Number)), - EpochIndex: utils.Alloc(uint64(m.EpochIndex)), + Index: utils.Alloc(uint64(m.Index)), + Number: utils.Alloc(uint64(m.Number)), } }, Decode: func(m *pb.View) (View, error) { @@ -565,13 +495,9 @@ var ViewConv = protoutils.Conv[View, *pb.View]{ if m.Number == nil { return View{}, fmt.Errorf("number: missing") } - if m.EpochIndex == nil { - return View{}, fmt.Errorf("epoch_index: missing") - } return View{ - Index: RoadIndex(*m.Index), - Number: ViewNumber(*m.Number), - EpochIndex: EpochIndex(*m.EpochIndex), + Index: RoadIndex(*m.Index), + Number: ViewNumber(*m.Number), }, nil }, } @@ -585,11 +511,10 @@ var ProposalConv = protoutils.Conv[*Proposal, *pb.Proposal]{ } sort.Slice(laneRanges, func(i, j int) bool { return laneRanges[i].Lane().Compare(laneRanges[j].Lane()) < 0 }) return &pb.Proposal{ - View: ViewConv.Encode(m.view), - Timestamp: TimeConv.Encode(m.timestamp), - LaneRanges: LaneRangeConv.EncodeSlice(laneRanges), - App: AppProposalConv.EncodeOpt(m.app), - GlobalFirst: utils.Alloc(uint64(m.globalRange.First)), + View: ViewConv.Encode(m.view), + Timestamp: TimeConv.Encode(m.timestamp), + LaneRanges: LaneRangeConv.EncodeSlice(laneRanges), + App: AppProposalConv.EncodeOpt(m.app), } }, Decode: func(m *pb.Proposal) (*Proposal, error) { @@ -609,13 +534,12 @@ var ProposalConv = protoutils.Conv[*Proposal, *pb.Proposal]{ if err != nil { return nil, fmt.Errorf("appQC: %w", err) } - // Hard-reject messages with absent global_first/epoch_index. - // Autobahn is pre-production; there is no rolling-upgrade path from - // messages encoded before these fields were added. - if m.GlobalFirst == nil { - return nil, fmt.Errorf("global_first: missing") - } - proposal := newProposal(view, timestamp, laneRanges, app, GlobalBlockNumber(*m.GlobalFirst)) + proposal := newProposal( + view, + timestamp, + laneRanges, + app, + ) if len(proposal.laneRanges) != len(laneRanges) { return nil, fmt.Errorf("laneRanges: duplicate ranges") } diff --git a/sei-tendermint/autobahn/types/proposal_test.go b/sei-tendermint/autobahn/types/proposal_test.go index 408ed5f9d6..c6aab48ecc 100644 --- a/sei-tendermint/autobahn/types/proposal_test.go +++ b/sei-tendermint/autobahn/types/proposal_test.go @@ -1,6 +1,7 @@ package types import ( + "slices" "testing" "time" @@ -8,19 +9,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" ) -// genFreshEpoch returns an epoch whose road range is OpenRoadRange (so road index 0 -// is always valid for a ViewSpec with no CommitQC) but whose epoch index and first -// block are randomised to prevent tests from silently passing on zero-value defaults. -func genFreshEpoch(rng utils.Rng, committee *Committee) *Epoch { - return NewEpoch( - GenEpochIndex(rng), - OpenRoadRange(), - time.Time{}, - committee, - GlobalBlockNumber(rng.Uint64()%1000000)+1, - ) -} - // leaderKey returns the secret key for the leader of the given view. func leaderKey(committee *Committee, keys []SecretKey, view View) SecretKey { leader := committee.Leader(view) @@ -61,8 +49,8 @@ func makeCommitQCFromProposal(keys []SecretKey, fp *FullProposal) *CommitQC { } // makeAppQCFor creates an AppQC for the given parameters, signed by all keys. -func makeAppQCFor(keys []SecretKey, globalNum GlobalBlockNumber, roadIdx RoadIndex, appHash AppHash, epochIdx EpochIndex) *AppQC { - appProposal := NewAppProposal(globalNum, roadIdx, appHash, epochIdx) +func makeAppQCFor(keys []SecretKey, globalNum GlobalBlockNumber, roadIdx RoadIndex, appHash AppHash) *AppQC { + appProposal := NewAppProposal(globalNum, roadIdx, appHash) vote := NewAppVote(appProposal) var votes []*Signed[*AppVote] for _, k := range keys { @@ -74,41 +62,39 @@ func makeAppQCFor(keys []SecretKey, globalNum GlobalBlockNumber, roadIdx RoadInd func TestProposalVerifyFreshEmptyRanges(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{} proposerKey := leaderKey(committee, keys, vs.View()) - fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), nil, utils.None[*AppQC]())) - require.NoError(t, fp.Verify(vs)) + fp := utils.OrPanic1(NewProposal(proposerKey, committee, vs, time.Now(), nil, utils.None[*AppQC]())) + require.NoError(t, fp.Verify(committee, vs)) } func TestProposalVerifyFreshWithBlocks(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{} proposerKey := leaderKey(committee, keys, vs.View()) // Produce a LaneQC for the proposer's lane. lane := proposerKey.Public() laneQC := makeLaneQC(rng, committee, keys, lane, 0, GenBlockHeaderHash(rng)) - fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), + fp := utils.OrPanic1(NewProposal(proposerKey, committee, vs, time.Now(), map[LaneID]*LaneQC{lane: laneQC}, utils.None[*AppQC]())) - require.NoError(t, fp.Verify(vs)) + require.NoError(t, fp.Verify(committee, vs)) } func TestNewProposalRejectsLaneRangeLongerThanMaxLaneRangeInProposal(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{} proposerKey := leaderKey(committee, keys, vs.View()) lane := proposerKey.Public() laneQC := makeLaneQC(rng, committee, keys, lane, MaxLaneRangeInProposal, GenBlockHeaderHash(rng)) _, err := NewProposal( proposerKey, + committee, vs, time.Now(), map[LaneID]*LaneQC{lane: laneQC}, @@ -120,47 +106,49 @@ func TestNewProposalRejectsLaneRangeLongerThanMaxLaneRangeInProposal(t *testing. func TestProposalBlockTimestampStrictlyMonotone(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) - firstBlock := ep.FirstBlock() - vs0 := ViewSpec{Epoch: ep} + vs0 := ViewSpec{} proposer0 := leaderKey(committee, keys, vs0.View()) lane := proposer0.Public() firstProposal := utils.OrPanic1(NewProposal( proposer0, - vs0, time.Now(), + committee, + vs0, + time.Now(), map[LaneID]*LaneQC{ lane: makeLaneQC(rng, committee, keys, lane, 2, GenBlockHeaderHash(rng)), }, utils.None[*AppQC](), )) p0 := firstProposal.Proposal().Msg() - gr0 := p0.GlobalRange() - require.Equal(t, firstBlock, gr0.First) - require.Equal(t, firstBlock+3, gr0.Next) - first0 := p0.BlockTimestamp(gr0.First).OrPanic("missing first block timestamp") - second0 := p0.BlockTimestamp(gr0.First + 1).OrPanic("missing second block timestamp") - third0 := p0.BlockTimestamp(gr0.First + 2).OrPanic("missing third block timestamp") + gr0 := p0.GlobalRange(committee) + require.Equal(t, committee.FirstBlock(), gr0.First) + require.Equal(t, committee.FirstBlock()+3, gr0.Next) + first0 := p0.BlockTimestamp(committee, gr0.First).OrPanic("missing first block timestamp") + second0 := p0.BlockTimestamp(committee, gr0.First+1).OrPanic("missing second block timestamp") + third0 := p0.BlockTimestamp(committee, gr0.First+2).OrPanic("missing third block timestamp") require.True(t, first0.Before(second0), "block timestamps within one proposal must be strictly increasing") require.True(t, second0.Before(third0), "block timestamps within one proposal must be strictly increasing") commitQC0 := makeCommitQCFromProposal(keys, firstProposal) - vs1 := ViewSpec{CommitQC: utils.Some(commitQC0), Epoch: ep} + vs1 := ViewSpec{CommitQC: utils.Some(commitQC0)} proposer1 := leaderKey(committee, keys, vs1.View()) secondProposal := utils.OrPanic1(NewProposal( proposer1, - vs1, time.Now(), + committee, + vs1, + time.Now(), map[LaneID]*LaneQC{ lane: makeLaneQC(rng, committee, keys, lane, 3, GenBlockHeaderHash(rng)), }, utils.None[*AppQC](), )) p1 := secondProposal.Proposal().Msg() - gr1 := p1.GlobalRange() + gr1 := p1.GlobalRange(committee) require.Equal(t, gr0.Next, gr1.First) - last0 := p0.BlockTimestamp(gr0.Next - 1).OrPanic("missing last block timestamp") - first1 := p1.BlockTimestamp(gr1.First).OrPanic("missing first timestamp of next proposal") + last0 := p0.BlockTimestamp(committee, gr0.Next-1).OrPanic("missing last block timestamp") + first1 := p1.BlockTimestamp(committee, gr1.First).OrPanic("missing first timestamp of next proposal") require.True(t, last0.Before(first1), "block timestamps across consecutive proposals must be strictly increasing") } @@ -168,98 +156,101 @@ func TestProposalVerifyRejectsNonMonotoneTimestamp(t *testing.T) { t.Run("wrt genesis timestamp", func(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - genesisTimestamp := time.Now() - ep := NewEpoch(GenEpochIndex(rng), OpenRoadRange(), genesisTimestamp, committee, GlobalBlockNumber(rng.Uint64()%1000000)+1) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{} k := leaderKey(committee, keys, vs.View()) - fp := utils.OrPanic1(NewProposal(k, vs, genesisTimestamp, nil, utils.None[*AppQC]())) - require.NoError(t, fp.Verify(vs)) - - vsLater := vs - vsLater.Epoch = NewEpoch(ep.EpochIndex(), ep.RoadRange(), fp.Proposal().Msg().Timestamp().Add(time.Nanosecond), committee, ep.FirstBlock()) - require.Error(t, fp.Verify(vsLater)) + fp := utils.OrPanic1(NewProposal(k, committee, vs, committee.GenesisTimestamp(), nil, utils.None[*AppQC]())) + require.NoError(t, fp.Verify(committee, vs)) + + committee = utils.OrPanic1(NewRoundRobinElection( + slices.Collect(committee.Replicas().All()), + committee.FirstBlock(), + fp.Proposal().Msg().Timestamp().Add(time.Nanosecond)), + ) + require.Error(t, fp.Verify(committee, vs)) }) t.Run("wrt previous proposal", func(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) - vs0 := ViewSpec{Epoch: ep} + vs0 := ViewSpec{} proposer0 := leaderKey(committee, keys, vs0.View()) lane := proposer0.Public() lQC := makeLaneQC(rng, committee, keys, lane, 0, GenBlockHeaderHash(rng)) fp0a := utils.OrPanic1(NewProposal( proposer0, - vs0, time.Now(), + committee, + vs0, + time.Now(), map[LaneID]*LaneQC{lane: lQC}, utils.None[*AppQC](), )) fp0b := utils.OrPanic1(NewProposal( proposer0, - vs0, fp0a.Proposal().Msg().NextTimestamp().Add(time.Hour), + committee, + vs0, + fp0a.Proposal().Msg().NextTimestamp().Add(time.Hour), map[LaneID]*LaneQC{lane: lQC}, utils.None[*AppQC](), )) - vs1a := ViewSpec{CommitQC: utils.Some(makeCommitQCFromProposal(keys, fp0a)), Epoch: ep} - vs1b := ViewSpec{CommitQC: utils.Some(makeCommitQCFromProposal(keys, fp0b)), Epoch: ep} + vs1a := ViewSpec{CommitQC: utils.Some(makeCommitQCFromProposal(keys, fp0a))} + vs1b := ViewSpec{CommitQC: utils.Some(makeCommitQCFromProposal(keys, fp0b))} proposer1 := leaderKey(committee, keys, vs1a.View()) fp1a := utils.OrPanic1(NewProposal( proposer1, - vs1a, fp0a.Proposal().Msg().NextTimestamp(), + committee, + vs1a, + fp0a.Proposal().Msg().NextTimestamp(), nil, utils.None[*AppQC](), )) - require.NoError(t, fp1a.Verify(vs1a)) - require.Error(t, fp1a.Verify(vs1b)) + require.NoError(t, fp1a.Verify(committee, vs1a)) + require.Error(t, fp1a.Verify(committee, vs1b)) }) } func TestProposalVerifyRejectsViewMismatch(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) // Build a valid proposal at genesis view (0, 0). - vs0 := ViewSpec{Epoch: ep} + vs0 := ViewSpec{} leader0 := leaderKey(committee, keys, vs0.View()) - fp := utils.OrPanic1(NewProposal(leader0, vs0, time.Now(), nil, utils.None[*AppQC]())) + fp := utils.OrPanic1(NewProposal(leader0, committee, vs0, time.Now(), nil, utils.None[*AppQC]())) // Verify it against a different ViewSpec (view 1, 0). commitQC := makeCommitQCFromProposal(keys, fp) - vs1 := ViewSpec{CommitQC: utils.Some(commitQC), Epoch: ep} - err := fp.Verify(vs1) + vs1 := ViewSpec{CommitQC: utils.Some(commitQC)} + err := fp.Verify(committee, vs1) require.Error(t, err) } func TestProposalVerifyRejectsForgedSignature(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{} proposerKey := leaderKey(committee, keys, vs.View()) // Build two valid proposals with different timestamps. - fp1 := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), nil, utils.None[*AppQC]())) - fp2 := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now().Add(time.Hour), nil, utils.None[*AppQC]())) + fp1 := utils.OrPanic1(NewProposal(proposerKey, committee, vs, time.Now(), nil, utils.None[*AppQC]())) + fp2 := utils.OrPanic1(NewProposal(proposerKey, committee, vs, time.Now().Add(time.Hour), nil, utils.None[*AppQC]())) // Graft fp1's signature onto fp2 (different content). fp2.proposal.sig = fp1.proposal.sig - err := fp2.Verify(vs) + err := fp2.Verify(committee, vs) require.Error(t, err) } func TestProposalVerifyRejectsWrongProposer(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{} correctLeader := leaderKey(committee, keys, vs.View()) - fp := utils.OrPanic1(NewProposal(correctLeader, vs, time.Now(), nil, utils.None[*AppQC]())) + fp := utils.OrPanic1(NewProposal(correctLeader, committee, vs, time.Now(), nil, utils.None[*AppQC]())) // Re-sign the same proposal with a different (non-leader) key. var wrongKey SecretKey @@ -275,18 +266,17 @@ func TestProposalVerifyRejectsWrongProposer(t *testing.T) { appQC: fp.appQC, timeoutQC: fp.timeoutQC, } - err := tamperedFP.Verify(vs) + err := tamperedFP.Verify(committee, vs) require.Error(t, err) } func TestProposalVerifyRejectsInconsistentTimeoutQC(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} // no timeoutQC + vs := ViewSpec{} // no timeoutQC proposerKey := leaderKey(committee, keys, vs.View()) - fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), nil, utils.None[*AppQC]())) + fp := utils.OrPanic1(NewProposal(proposerKey, committee, vs, time.Now(), nil, utils.None[*AppQC]())) // Attach a timeoutQC that the ViewSpec doesn't expect. var timeoutVotes []*FullTimeoutVote @@ -301,18 +291,17 @@ func TestProposalVerifyRejectsInconsistentTimeoutQC(t *testing.T) { appQC: fp.appQC, timeoutQC: utils.Some(tQC), } - err := tamperedFP.Verify(vs) + err := tamperedFP.Verify(committee, vs) require.Error(t, err) } func TestProposalVerifyRejectsNonCommitteeLane(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{} proposerKey := leaderKey(committee, keys, vs.View()) - fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), nil, utils.None[*AppQC]())) + fp := utils.OrPanic1(NewProposal(proposerKey, committee, vs, time.Now(), nil, utils.None[*AppQC]())) // Replace one committee lane with a non-committee lane. // E.g. committee = {A, B, C, D}, proposal = {A, B, C, X}. @@ -335,25 +324,24 @@ func TestProposalVerifyRejectsNonCommitteeLane(t *testing.T) { } } - tamperedProposal := newProposal(origProposal.view, origProposal.timestamp, tamperedRanges, origProposal.app, origProposal.GlobalRange().First) + tamperedProposal := newProposal(origProposal.view, origProposal.timestamp, tamperedRanges, origProposal.app) maliciousFP := &FullProposal{ proposal: Sign(proposerKey, tamperedProposal), laneQCs: fp.laneQCs, appQC: fp.appQC, timeoutQC: fp.timeoutQC, } - err := maliciousFP.Verify(vs) + err := maliciousFP.Verify(committee, vs) require.Error(t, err) } func TestProposalVerifyAcceptsImplicitLaneRange(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{} proposerKey := leaderKey(committee, keys, vs.View()) - fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), nil, utils.None[*AppQC]())) + fp := utils.OrPanic1(NewProposal(proposerKey, committee, vs, time.Now(), nil, utils.None[*AppQC]())) // Drop one lane — the omitted lane gets an implicit [0, 0) range, // which matches the expected first=0 at genesis. @@ -368,21 +356,20 @@ func TestProposalVerifyAcceptsImplicitLaneRange(t *testing.T) { keptRanges = append(keptRanges, r) } - shortProposal := newProposal(origP.view, origP.timestamp, keptRanges, origP.app, origP.GlobalRange().First) + shortProposal := newProposal(origP.view, origP.timestamp, keptRanges, origP.app) shortFP := &FullProposal{ proposal: Sign(proposerKey, shortProposal), } - require.NoError(t, shortFP.Verify(vs)) + require.NoError(t, shortFP.Verify(committee, vs)) } func TestProposalVerifyAcceptsNonContiguousImplicitRanges(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{} proposerKey := leaderKey(committee, keys, vs.View()) - fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), nil, utils.None[*AppQC]())) + fp := utils.OrPanic1(NewProposal(proposerKey, committee, vs, time.Now(), nil, utils.None[*AppQC]())) // Keep only every other lane (e.g. {A, C} out of {A, B, C, D}). origP := fp.Proposal().Msg() @@ -395,21 +382,20 @@ func TestProposalVerifyAcceptsNonContiguousImplicitRanges(t *testing.T) { i++ } - shortProposal := newProposal(origP.view, origP.timestamp, keptRanges, origP.app, origP.GlobalRange().First) + shortProposal := newProposal(origP.view, origP.timestamp, keptRanges, origP.app) shortFP := &FullProposal{ proposal: Sign(proposerKey, shortProposal), } - require.NoError(t, shortFP.Verify(vs)) + require.NoError(t, shortFP.Verify(committee, vs)) } func TestProposalVerifyRejectsLaneRangeFirstMismatch(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{} proposerKey := leaderKey(committee, keys, vs.View()) - fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), nil, utils.None[*AppQC]())) + fp := utils.OrPanic1(NewProposal(proposerKey, committee, vs, time.Now(), nil, utils.None[*AppQC]())) // Tamper: change one lane's first to 5 (genesis expects 0). origP := fp.Proposal().Msg() @@ -422,48 +408,46 @@ func TestProposalVerifyRejectsLaneRangeFirstMismatch(t *testing.T) { tamperedRanges = append(tamperedRanges, r) } } - tamperedProposal := newProposal(origP.view, origP.timestamp, tamperedRanges, origP.app, origP.GlobalRange().First) + tamperedProposal := newProposal(origP.view, origP.timestamp, tamperedRanges, origP.app) tamperedFP := &FullProposal{ proposal: Sign(proposerKey, tamperedProposal), } - err := tamperedFP.Verify(vs) + err := tamperedFP.Verify(committee, vs) require.Error(t, err) } func TestProposalVerifyRejectsMissingLaneQC(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{} proposerKey := leaderKey(committee, keys, vs.View()) lane := keys[0].Public() laneQC := makeLaneQC(rng, committee, keys, lane, 0, GenBlockHeaderHash(rng)) // Build a valid proposal with a block, then strip the laneQC. - fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), + fp := utils.OrPanic1(NewProposal(proposerKey, committee, vs, time.Now(), map[LaneID]*LaneQC{lane: laneQC}, utils.None[*AppQC]())) tamperedFP := &FullProposal{ proposal: fp.proposal, laneQCs: map[LaneID]*LaneQC{}, } - err := tamperedFP.Verify(vs) + err := tamperedFP.Verify(committee, vs) require.Error(t, err) } func TestProposalVerifyRejectsLaneQCBlockNumberMismatch(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{} proposerKey := leaderKey(committee, keys, vs.View()) lane := keys[0].Public() // Build a valid proposal with a QC certifying block 1 (range [0, 2)). goodQC := makeLaneQC(rng, committee, keys, lane, 1, GenBlockHeaderHash(rng)) - fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), + fp := utils.OrPanic1(NewProposal(proposerKey, committee, vs, time.Now(), map[LaneID]*LaneQC{lane: goodQC}, utils.None[*AppQC]())) // Swap in a QC certifying block 0 — range expects block 1. @@ -472,15 +456,14 @@ func TestProposalVerifyRejectsLaneQCBlockNumberMismatch(t *testing.T) { proposal: fp.proposal, laneQCs: map[LaneID]*LaneQC{lane: wrongQC}, } - err := tamperedFP.Verify(vs) + err := tamperedFP.Verify(committee, vs) require.Error(t, err) } func TestProposalVerifyRejectsInvalidLaneQCSignature(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{} proposerKey := leaderKey(committee, keys, vs.View()) lane := keys[0].Public() @@ -498,10 +481,10 @@ func TestProposalVerifyRejectsInvalidLaneQCSignature(t *testing.T) { } badLaneQC := NewLaneQC(badVotes) - fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), + fp := utils.OrPanic1(NewProposal(proposerKey, committee, vs, time.Now(), map[LaneID]*LaneQC{lane: badLaneQC}, utils.None[*AppQC]())) - err := fp.Verify(vs) + err := fp.Verify(committee, vs) require.Error(t, err) } @@ -519,32 +502,37 @@ func TestProposalConvDecode_RejectsDuplicateLaneRanges(t *testing.T) { func TestProposalVerifyRejectsLaneRangeLongerThanMaxLaneRangeInProposal(t *testing.T) { rng := utils.TestRng() - committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) - lane := leaderKey(committee, keys, View{}).Public() - // Bypass NewProposal's check by constructing the proposal directly. - oversized := newProposal( + committee, _ := GenCommittee(rng, 4) + lane := slices.Collect(committee.Lanes().All())[0] + parentHash := GenBlockHeaderHash(rng) + var lastHeader *BlockHeader + for blockNumber := range BlockNumber(MaxLaneRangeInProposal + 1) { + lastHeader = NewBlock(lane, blockNumber, parentHash, GenPayload(rng)).Header() + parentHash = lastHeader.Hash() + } + + proposal := newProposal( View{}, - time.Now(), - []*LaneRange{NewLaneRange(lane, 0, utils.Some(NewBlock(lane, MaxLaneRangeInProposal, GenBlockHeaderHash(rng), GenPayload(rng)).Header()))}, + time.Unix(1, 2), + []*LaneRange{NewLaneRange(lane, 0, utils.Some(lastHeader))}, utils.None[*AppProposal](), - ep.FirstBlock(), ) - require.Error(t, oversized.Verify(ep)) + require.Error(t, proposal.Verify(committee)) } func makeFullProposal( - ep *Epoch, + committee *Committee, keys []SecretKey, prev utils.Option[*CommitQC], laneQCs map[LaneID]*LaneQC, appQC utils.Option[*AppQC], ) *FullProposal { - committee := ep.Committee() - vs := ViewSpec{CommitQC: prev, Epoch: ep} + vs := ViewSpec{CommitQC: prev} return utils.OrPanic1(NewProposal( leaderKey(committee, keys, vs.View()), - vs, time.Now(), + committee, + vs, + time.Now(), laneQCs, appQC, )) @@ -562,149 +550,120 @@ func makeCommitQC(keys []SecretKey, fullProposal *FullProposal) *CommitQC { func TestProposalVerifyRejectsAppProposalLowerThanPrevious(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) // Construct commitQC for index 1 with AppProposal // and Proposal for index 2 without any app proposal. // Such a proposal should fail validation, because app proposals need to be monotone. l := keys[0].Public() lQCs := map[LaneID]*LaneQC{l: makeLaneQC(rng, committee, keys, l, 0, GenBlockHeaderHash(rng))} - commitQC0 := makeCommitQC(keys, makeFullProposal(ep, keys, utils.None[*CommitQC](), lQCs, utils.None[*AppQC]())) - appQC0 := makeAppQCFor(keys, commitQC0.GlobalRange().First, 0, GenAppHash(rng), ep.EpochIndex()) - commitQC1a := makeCommitQC(keys, makeFullProposal(ep, keys, utils.Some(commitQC0), nil, utils.Some(appQC0))) - commitQC1b := makeCommitQC(keys, makeFullProposal(ep, keys, utils.Some(commitQC0), nil, utils.None[*AppQC]())) - fp2a := makeFullProposal(ep, keys, utils.Some(commitQC1a), nil, utils.None[*AppQC]()) - fp2b := makeFullProposal(ep, keys, utils.Some(commitQC1b), nil, utils.None[*AppQC]()) + commitQC0 := makeCommitQC(keys, makeFullProposal(committee, keys, utils.None[*CommitQC](), lQCs, utils.None[*AppQC]())) + appQC0 := makeAppQCFor(keys, commitQC0.GlobalRange(committee).First, 0, GenAppHash(rng)) + commitQC1a := makeCommitQC(keys, makeFullProposal(committee, keys, utils.Some(commitQC0), nil, utils.Some(appQC0))) + commitQC1b := makeCommitQC(keys, makeFullProposal(committee, keys, utils.Some(commitQC0), nil, utils.None[*AppQC]())) + fp2a := makeFullProposal(committee, keys, utils.Some(commitQC1a), nil, utils.None[*AppQC]()) + fp2b := makeFullProposal(committee, keys, utils.Some(commitQC1b), nil, utils.None[*AppQC]()) // We construct the invalid proposal by constructing 2 alternative futures: one with appQC, one without. - vs := ViewSpec{CommitQC: utils.Some(commitQC1a), Epoch: ep} - require.NoError(t, fp2a.Verify(vs)) - require.Error(t, fp2b.Verify(vs)) + vs := ViewSpec{CommitQC: utils.Some(commitQC1a)} + require.NoError(t, fp2a.Verify(committee, vs)) + require.Error(t, fp2b.Verify(committee, vs)) } func TestProposalVerifyRejectsUnnecessaryAppQC(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} // no previous commitQC, so app starts at None + vs := ViewSpec{} // no previous commitQC, so app starts at None + initialBlock := committee.FirstBlock() leader := leaderKey(committee, keys, vs.View()) - fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.None[*AppQC]())) + fp := utils.OrPanic1(NewProposal(leader, committee, vs, time.Now(), nil, utils.None[*AppQC]())) // Attach an unrequested AppQC. - appQC := makeAppQCFor(keys, ep.FirstBlock(), 0, GenAppHash(rng), ep.EpochIndex()) + appQC := makeAppQCFor(keys, initialBlock, 0, GenAppHash(rng)) tamperedFP := &FullProposal{ proposal: fp.proposal, laneQCs: fp.laneQCs, appQC: utils.Some(appQC), timeoutQC: fp.timeoutQC, } - err := tamperedFP.Verify(vs) + err := tamperedFP.Verify(committee, vs) require.Error(t, err) } func TestProposalVerifyRejectsMissingAppQC(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) // firstBlock >= 1, so firstBlock-1 is valid - vs := ViewSpec{Epoch: ep} // no previous commitQC + vs := ViewSpec{} // no previous commitQC leader := leaderKey(committee, keys, vs.View()) + initialBlock := committee.FirstBlock() // Build a valid proposal with an AppQC, then strip it. - goodAppQC := makeAppQCFor(keys, ep.FirstBlock()-1, 0, GenAppHash(rng), ep.EpochIndex()) - fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.Some(goodAppQC))) + goodAppQC := makeAppQCFor(keys, initialBlock-1, 0, GenAppHash(rng)) + fp := utils.OrPanic1(NewProposal(leader, committee, vs, time.Now(), nil, utils.Some(goodAppQC))) tamperedFP := &FullProposal{ proposal: fp.proposal, } - err := tamperedFP.Verify(vs) + err := tamperedFP.Verify(committee, vs) require.Error(t, err) } func TestProposalVerifyRejectsAppQCMismatch(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{} leader := leaderKey(committee, keys, vs.View()) + initialBlock := committee.FirstBlock() // Build a valid proposal with an AppQC, then swap in a different one. - goodAppQC := makeAppQCFor(keys, ep.FirstBlock(), 0, GenAppHash(rng), ep.EpochIndex()) - fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.Some(goodAppQC))) + goodAppQC := makeAppQCFor(keys, initialBlock, 0, GenAppHash(rng)) + fp := utils.OrPanic1(NewProposal(leader, committee, vs, time.Now(), nil, utils.Some(goodAppQC))) - differentAppQC := makeAppQCFor(keys, ep.FirstBlock(), 0, GenAppHash(rng), ep.EpochIndex()) + differentAppQC := makeAppQCFor(keys, initialBlock, 0, GenAppHash(rng)) tamperedFP := &FullProposal{ proposal: fp.proposal, appQC: utils.Some(differentAppQC), } - err := tamperedFP.Verify(vs) + err := tamperedFP.Verify(committee, vs) require.Error(t, err) } -func TestProposalVerifyRejectsAppProposalWrongEpoch(t *testing.T) { - rng := utils.TestRng() - committee, keys := GenCommittee(rng, 4) - - // firstBlock=1 so NextGlobalBlock()=1 and globalNumber=0 is a valid app target. - vs := ViewSpec{Epoch: NewEpoch(0, OpenRoadRange(), time.Time{}, committee, 1)} - leader := leaderKey(committee, keys, vs.View()) - - makeAppQCWithEpoch := func(epochIdx EpochIndex) *AppQC { - p := NewAppProposal(0, 0, GenAppHash(rng), epochIdx) - v := NewAppVote(p) - var votes []*Signed[*AppVote] - for _, k := range keys { - votes = append(votes, Sign(k, v)) - } - return NewAppQC(votes) - } - - // app epoch matches proposal epoch — accepted. - fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.Some(makeAppQCWithEpoch(0)))) - require.NoError(t, fp.Verify(vs)) - - // app epoch differs from proposal epoch — rejected. - fpWrong := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.Some(makeAppQCWithEpoch(1)))) - require.Error(t, fpWrong.Verify(vs)) -} - func TestProposalVerifyRejectsInvalidAppQCSignature(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{} leader := leaderKey(committee, keys, vs.View()) + initialBlock := committee.FirstBlock() appHash := GenAppHash(rng) - goodAppQC := makeAppQCFor(keys, ep.FirstBlock(), 0, appHash, ep.EpochIndex()) - fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.Some(goodAppQC))) + goodAppQC := makeAppQCFor(keys, initialBlock, 0, appHash) + fp := utils.OrPanic1(NewProposal(leader, committee, vs, time.Now(), nil, utils.Some(goodAppQC))) // Swap in an AppQC signed by NON-committee keys (same hash). otherKeys := make([]SecretKey, len(keys)) for i := range otherKeys { otherKeys[i] = GenSecretKey(rng) } - badAppQC := makeAppQCFor(otherKeys, ep.FirstBlock(), 0, appHash, ep.EpochIndex()) + badAppQC := makeAppQCFor(otherKeys, initialBlock, 0, appHash) tamperedFP := &FullProposal{ proposal: fp.proposal, appQC: utils.Some(badAppQC), } - err := tamperedFP.Verify(vs) + err := tamperedFP.Verify(committee, vs) require.Error(t, err) } func TestProposalVerifyRejectsLaneQCHeaderHashMismatch(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{} proposerKey := leaderKey(committee, keys, vs.View()) lane := proposerKey.Public() // Build a valid proposal with a QC for block 0. realQC := makeLaneQC(rng, committee, keys, lane, 0, GenBlockHeaderHash(rng)) - fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), + fp := utils.OrPanic1(NewProposal(proposerKey, committee, vs, time.Now(), map[LaneID]*LaneQC{lane: realQC}, utils.None[*AppQC]())) // Swap in a different QC for block 0 (different payload → different hash). @@ -715,23 +674,18 @@ func TestProposalVerifyRejectsLaneQCHeaderHashMismatch(t *testing.T) { proposal: fp.proposal, laneQCs: map[LaneID]*LaneQC{lane: differentQC}, } - err := tamperedFP.Verify(vs) + err := tamperedFP.Verify(committee, vs) require.Error(t, err) } func TestProposalVerifyValidReproposal(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - // Build a proposal at view (0, 0) with one lane block so sum(lane.First) > 0. - // firstBlock > 0 ensures a reproposal bug that passes GlobalRange().First - // (= sum(lane.First)+firstBlock) instead of firstBlock would be caught. - ep := genFreshEpoch(rng, committee) - vs0 := ViewSpec{Epoch: ep} + + // First, create a valid proposal at view (0, 0) with a PrepareQC. + vs0 := ViewSpec{} leader0 := leaderKey(committee, keys, vs0.View()) - lane := committee.Leader(vs0.View()) - laneQC0 := makeLaneQC(rng, committee, keys, lane, 0, GenBlockHeaderHash(rng)) - fp0 := utils.OrPanic1(NewProposal(leader0, vs0, time.Now(), - map[LaneID]*LaneQC{lane: laneQC0}, utils.None[*AppQC]())) + fp0 := utils.OrPanic1(NewProposal(leader0, committee, vs0, time.Now(), nil, utils.None[*AppQC]())) // Build a PrepareQC for the proposal at (0, 0). var prepareVotes []*Signed[*PrepareVote] @@ -743,30 +697,27 @@ func TestProposalVerifyValidReproposal(t *testing.T) { // Timeout at view (0, 0) with the PrepareQC → forces reproposal at (0, 1). var timeoutVotes []*FullTimeoutVote for _, k := range keys { - timeoutVotes = append(timeoutVotes, NewFullTimeoutVote(k, View{Index: 0, Number: 0, EpochIndex: ep.EpochIndex()}, utils.Some(prepareQC))) + timeoutVotes = append(timeoutVotes, NewFullTimeoutVote(k, View{Index: 0, Number: 0}, utils.Some(prepareQC))) } timeoutQC := NewTimeoutQC(timeoutVotes) - vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC), Epoch: ep} - require.Equal(t, View{Index: 0, Number: 1, EpochIndex: ep.EpochIndex()}, vs1.View()) + vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC)} + require.Equal(t, View{Index: 0, Number: 1}, vs1.View()) leader1 := leaderKey(committee, keys, vs1.View()) - reproposal := utils.OrPanic1(NewProposal(leader1, vs1, time.Now(), nil, utils.None[*AppQC]())) + reproposal := utils.OrPanic1(NewProposal(leader1, committee, vs1, time.Now(), nil, utils.None[*AppQC]())) - // Reproposal must carry the same GlobalRange as the original. - require.Equal(t, fp0.Proposal().Msg().GlobalRange(), reproposal.Proposal().Msg().GlobalRange()) - require.NoError(t, reproposal.Verify(vs1)) + require.NoError(t, reproposal.Verify(committee, vs1)) } func TestProposalVerifyRejectsReproposalWithUnnecessaryData(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) // Build a PrepareQC at (0, 0). - vs0 := ViewSpec{Epoch: ep} + vs0 := ViewSpec{} leader0 := leaderKey(committee, keys, vs0.View()) - fp0 := utils.OrPanic1(NewProposal(leader0, vs0, time.Now(), nil, utils.None[*AppQC]())) + fp0 := utils.OrPanic1(NewProposal(leader0, committee, vs0, time.Now(), nil, utils.None[*AppQC]())) var prepareVotes []*Signed[*PrepareVote] for _, k := range keys { @@ -776,15 +727,15 @@ func TestProposalVerifyRejectsReproposalWithUnnecessaryData(t *testing.T) { var timeoutVotes []*FullTimeoutVote for _, k := range keys { - timeoutVotes = append(timeoutVotes, NewFullTimeoutVote(k, View{Index: 0, Number: 0, EpochIndex: ep.EpochIndex()}, utils.Some(prepareQC))) + timeoutVotes = append(timeoutVotes, NewFullTimeoutVote(k, View{Index: 0, Number: 0}, utils.Some(prepareQC))) } timeoutQC := NewTimeoutQC(timeoutVotes) - vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC), Epoch: ep} + vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC)} leader1 := leaderKey(committee, keys, vs1.View()) // Create a valid reproposal, then tamper it with unnecessary laneQCs. - reproposal := utils.OrPanic1(NewProposal(leader1, vs1, time.Now(), nil, utils.None[*AppQC]())) + reproposal := utils.OrPanic1(NewProposal(leader1, committee, vs1, time.Now(), nil, utils.None[*AppQC]())) lane := keys[0].Public() laneQC := makeLaneQC(rng, committee, keys, lane, 0, GenBlockHeaderHash(rng)) @@ -793,19 +744,18 @@ func TestProposalVerifyRejectsReproposalWithUnnecessaryData(t *testing.T) { laneQCs: map[LaneID]*LaneQC{lane: laneQC}, timeoutQC: reproposal.timeoutQC, } - err := tamperedFP.Verify(vs1) + err := tamperedFP.Verify(committee, vs1) require.Error(t, err) } func TestProposalVerifyRejectsReproposalHashMismatch(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) // Build a PrepareQC at (0, 0). - vs0 := ViewSpec{Epoch: ep} + vs0 := ViewSpec{} leader0 := leaderKey(committee, keys, vs0.View()) - fp0 := utils.OrPanic1(NewProposal(leader0, vs0, time.Now(), nil, utils.None[*AppQC]())) + fp0 := utils.OrPanic1(NewProposal(leader0, committee, vs0, time.Now(), nil, utils.None[*AppQC]())) var prepareVotes []*Signed[*PrepareVote] for _, k := range keys { @@ -815,34 +765,33 @@ func TestProposalVerifyRejectsReproposalHashMismatch(t *testing.T) { var timeoutVotes []*FullTimeoutVote for _, k := range keys { - timeoutVotes = append(timeoutVotes, NewFullTimeoutVote(k, View{Index: 0, Number: 0, EpochIndex: ep.EpochIndex()}, utils.Some(prepareQC))) + timeoutVotes = append(timeoutVotes, NewFullTimeoutVote(k, View{Index: 0, Number: 0}, utils.Some(prepareQC))) } timeoutQC := NewTimeoutQC(timeoutVotes) - vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC), Epoch: ep} + vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC)} leader1 := leaderKey(committee, keys, vs1.View()) // Build the valid reproposal, then tamper its timestamp to get a different hash. - reproposal := utils.OrPanic1(NewProposal(leader1, vs1, time.Now(), nil, utils.None[*AppQC]())) + reproposal := utils.OrPanic1(NewProposal(leader1, committee, vs1, time.Now(), nil, utils.None[*AppQC]())) origP := reproposal.Proposal().Msg() var ranges []*LaneRange for _, r := range origP.laneRanges { ranges = append(ranges, r) } - wrongP := newProposal(origP.view, time.Now().Add(time.Hour), ranges, origP.app, origP.GlobalRange().First) + wrongP := newProposal(origP.view, time.Now().Add(time.Hour), ranges, origP.app) wrongFP := &FullProposal{ proposal: Sign(leader1, wrongP), timeoutQC: reproposal.timeoutQC, } - err := wrongFP.Verify(vs1) + err := wrongFP.Verify(committee, vs1) require.Error(t, err) } func TestProposalVerifyRejectsInvalidTimeoutQCSignature(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) // Build a TimeoutQC signed by NON-committee keys. otherKeys := make([]SecretKey, len(keys)) @@ -855,53 +804,11 @@ func TestProposalVerifyRejectsInvalidTimeoutQCSignature(t *testing.T) { } badTimeoutQC := NewTimeoutQC(timeoutVotes) - vs := ViewSpec{TimeoutQC: utils.Some(badTimeoutQC), Epoch: ep} + vs := ViewSpec{TimeoutQC: utils.Some(badTimeoutQC)} leader := leaderKey(committee, keys, vs.View()) - fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.None[*AppQC]())) + fp := utils.OrPanic1(NewProposal(leader, committee, vs, time.Now(), nil, utils.None[*AppQC]())) - err := fp.Verify(vs) + err := fp.Verify(committee, vs) require.Error(t, err) } - -func TestViewSpecViewStampsEpochIndex(t *testing.T) { - rng := utils.TestRng() - committee, keys := GenCommittee(rng, 4) - epochIdx := EpochIndex(7) - ep := NewEpoch(epochIdx, OpenRoadRange(), time.Time{}, committee, 0) - - // Without TimeoutQC: epoch index must come from vs.Epoch. - vs0 := ViewSpec{Epoch: ep} - if got := vs0.View().EpochIndex; got != epochIdx { - t.Fatalf("no-TimeoutQC path: EpochIndex = %d, want %d", got, epochIdx) - } - - // With TimeoutQC: epoch index must still come from vs.Epoch, not the QC's stored value. - tqc := NewTimeoutQC([]*FullTimeoutVote{ - NewFullTimeoutVote(keys[0], View{EpochIndex: 0}, utils.None[*PrepareQC]()), - }) - vs1 := ViewSpec{TimeoutQC: utils.Some(tqc), Epoch: ep} - if got := vs1.View().EpochIndex; got != epochIdx { - t.Fatalf("TimeoutQC path: EpochIndex = %d, want %d", got, epochIdx) - } -} - -func TestViewLess(t *testing.T) { - cases := []struct { - a, b View - want bool - }{ - {View{EpochIndex: 0, Index: 0, Number: 0}, View{EpochIndex: 1, Index: 0, Number: 0}, true}, - {View{EpochIndex: 1, Index: 0, Number: 0}, View{EpochIndex: 0, Index: 0, Number: 0}, false}, - {View{EpochIndex: 0, Index: 0, Number: 0}, View{EpochIndex: 0, Index: 1, Number: 0}, true}, - {View{EpochIndex: 0, Index: 1, Number: 0}, View{EpochIndex: 0, Index: 0, Number: 0}, false}, - {View{EpochIndex: 0, Index: 0, Number: 0}, View{EpochIndex: 0, Index: 0, Number: 1}, true}, - {View{EpochIndex: 0, Index: 0, Number: 1}, View{EpochIndex: 0, Index: 0, Number: 0}, false}, - {View{EpochIndex: 0, Index: 0, Number: 0}, View{EpochIndex: 0, Index: 0, Number: 0}, false}, - } - for _, c := range cases { - if got := c.a.Less(c.b); got != c.want { - t.Errorf("%v.Less(%v) = %v, want %v", c.a, c.b, got, c.want) - } - } -} diff --git a/sei-tendermint/autobahn/types/testonly.go b/sei-tendermint/autobahn/types/testonly.go index 46ac55e314..3dd9698d3d 100644 --- a/sei-tendermint/autobahn/types/testonly.go +++ b/sei-tendermint/autobahn/types/testonly.go @@ -2,7 +2,6 @@ package types import ( "cmp" - "fmt" "slices" "time" @@ -12,32 +11,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) -// BuildCommitQC builds a valid CommitQC from explicit lane QCs and an optional app QC. -// Use BuildFullCommitQC when you want random blocks generated automatically. -func BuildCommitQC( - epoch *Epoch, - keys []SecretKey, - prev utils.Option[*CommitQC], - laneQCs map[LaneID]*LaneQC, - appQC utils.Option[*AppQC], -) *CommitQC { - vs := ViewSpec{CommitQC: prev, Epoch: epoch} - leader := epoch.Committee().Leader(vs.View()) - var leaderKey SecretKey - for _, k := range keys { - if k.Public() == leader { - leaderKey = k - break - } - } - proposal := utils.OrPanic1(NewProposal(leaderKey, vs, time.Now(), laneQCs, appQC)) - votes := make([]*Signed[*CommitVote], 0, len(keys)) - for _, k := range keys { - votes = append(votes, Sign(k, NewCommitVote(proposal.Proposal().Msg()))) - } - return NewCommitQC(votes) -} - // GenNodeID generates a random NodeID. func GenNodeID(rng utils.Rng) NodeID { return NodeID(utils.GenString(rng, 10)) @@ -64,7 +37,7 @@ func GenCommittee(rng utils.Rng, size int) (*Committee, []SecretKey) { slices.SortStableFunc(sks, func(a, b SecretKey) int { return -cmp.Compare(pks[a.Public()], pks[b.Public()]) }) - return utils.OrPanic1(NewCommittee(pks)), sks + return utils.OrPanic1(NewCommittee(pks, GenGlobalBlockNumber(rng)%1000000, time.Now())), sks } // TestKeysWithWeight returns a deterministic subset of keys whose committee weight reaches the requested threshold. @@ -102,69 +75,11 @@ func GenSignature(rng utils.Rng) *Signature { } } -// SignatureForTesting builds a Signature from a public key and raw signature bytes -// WITHOUT performing any real signing. FOR TESTS/BENCHMARKS ONLY: the resulting -// signature is arbitrary bytes and will NOT verify. sigBytes must be exactly -// ed25519.SignatureSize (64) bytes. -func SignatureForTesting(key PublicKey, sigBytes []byte) (*Signature, error) { - sig, err := ed25519.SignatureFromBytes(sigBytes) - if err != nil { - return nil, fmt.Errorf("sig: %w", err) - } - return &Signature{key: key, sig: sig}, nil -} - -// SignedForTesting attaches a precomputed (typically fake) signature to a message -// WITHOUT signing. FOR TESTS/BENCHMARKS ONLY: the result will NOT verify. The message is -// still hashed (cheap), only the expensive signing operation is skipped. -func SignedForTesting[T Msg](msg T, sig *Signature) *Signed[T] { - return newSigned(msg, sig) -} - -// NewBlockForTesting builds a Block with an injected payload hash instead of computing -// payload.Hash(). FOR TESTS/BENCHMARKS ONLY: the header's payloadHash need not match the -// payload, so Block.Verify will fail. This skips a full marshal + SHA-256 of the payload. -func NewBlockForTesting( - lane LaneID, - blockNumber BlockNumber, - parentHash BlockHeaderHash, - payload *Payload, - payloadHash PayloadHash, -) *Block { - return &Block{ - header: &BlockHeader{ - lane: lane, - blockNumber: blockNumber, - parentHash: parentHash, - payloadHash: payloadHash, - }, - payload: payload, - } -} - // GenBlockNumber generates a random BlockNumber. func GenBlockNumber(rng utils.Rng) BlockNumber { return BlockNumber(rng.Uint64()) } -// GenLaneRangeFor generates a random LaneRange whose lane ID is drawn from c. -func GenLaneRangeFor(rng utils.Rng, c *Committee) *LaneRange { - lanes := c.Lanes() - lane := lanes.At(rng.Intn(lanes.Len())) - first := GenBlockNumber(rng) - length := rng.Uint64() % (MaxLaneRangeInProposal + 1) - if length == 0 { - return NewLaneRange(lane, first, utils.None[*BlockHeader]()) - } - header := NewBlock( - lane, - first+BlockNumber(length-1), - GenBlockHeaderHash(rng), - GenPayload(rng), - ).Header() - return NewLaneRange(lane, first, utils.Some(header)) -} - // GenLaneRange generates a random LaneRange. func GenLaneRange(rng utils.Rng) *LaneRange { lane := GenLaneID(rng) @@ -258,67 +173,19 @@ func GenViewNumber(rng utils.Rng) ViewNumber { // GenView generates a random View. func GenView(rng utils.Rng) View { return View{ - Index: GenRoadIndex(rng), - Number: GenViewNumber(rng), - EpochIndex: GenEpochIndex(rng), + Index: GenRoadIndex(rng), + Number: GenViewNumber(rng), } } -// GenEpochIndex returns a random small EpochIndex for test use. -func GenEpochIndex(rng utils.Rng) EpochIndex { - return EpochIndex(rng.Uint64() % 100) -} - -// GenEpochWithCommittee returns a random Epoch wrapping committee. -// epochIndex, firstBlock, timestamp, and Roads.First are randomized so that tests -// exercise epoch-binding checks rather than silently passing on zero values. -func GenEpochWithCommittee(rng utils.Rng, committee *Committee) *Epoch { - first := RoadIndex(rng.Uint64() % 1000) - return NewEpoch( - GenEpochIndex(rng), - RoadRange{First: first, Last: first + RoadIndex(rng.Uint64()%10000) + 10}, - utils.GenTimestamp(rng), - committee, - GlobalBlockNumber(rng.Uint64()%1000000)+1, - ) -} - -// CommitQCAt creates a CommitQC at ep.RoadRange().First, signed by all keys. -func CommitQCAt(ep *Epoch, keys []SecretKey) *CommitQC { - vote := NewCommitVote(ProposalAt(ep, View{EpochIndex: ep.EpochIndex(), Index: ep.RoadRange().First})) - votes := make([]*Signed[*CommitVote], len(keys)) - for i, k := range keys { - votes[i] = Sign(k, vote) - } - return NewCommitQC(votes) -} - // GenProposal generates a random Proposal. func GenProposal(rng utils.Rng) *Proposal { - return newProposal(GenView(rng), utils.GenTimestamp(rng), utils.GenSlice(rng, GenLaneRange), utils.Some(GenAppProposal(rng)), GlobalBlockNumber(rng.Uint64())) + return newProposal(GenView(rng), time.Now(), utils.GenSlice(rng, GenLaneRange), utils.Some(GenAppProposal(rng))) } // GenProposalAt generates a Proposal at a specific view. func GenProposalAt(rng utils.Rng, view View) *Proposal { - return newProposal(view, utils.GenTimestamp(rng), utils.GenSlice(rng, GenLaneRange), utils.Some(GenAppProposal(rng)), GlobalBlockNumber(rng.Uint64())) -} - -// ProposalAt returns a minimal Proposal at view, consistent with ep. -// No lane ranges and no app proposal — only for tests that care about -// signature weight or epoch binding, not lane/app data. -func ProposalAt(ep *Epoch, view View) *Proposal { - view.EpochIndex = ep.EpochIndex() - return newProposal(view, time.Time{}, nil, utils.None[*AppProposal](), ep.FirstBlock()) -} - -// GenProposalForEpoch generates a Proposal at a specific view whose epochIndex, -// firstBlock, and lane IDs are all consistent with ep. Use in tests that verify -// QCs against a known Epoch. -func GenProposalForEpoch(rng utils.Rng, ep *Epoch, view View) *Proposal { - view.EpochIndex = ep.EpochIndex() - c := ep.Committee() - laneRanges := utils.GenSlice(rng, func(rng utils.Rng) *LaneRange { return GenLaneRangeFor(rng, c) }) - return newProposal(view, utils.GenTimestamp(rng), laneRanges, utils.Some(GenAppProposal(rng)), ep.FirstBlock()) + return newProposal(view, time.Now(), utils.GenSlice(rng, GenLaneRange), utils.Some(GenAppProposal(rng))) } // GenAppHash generates a random AppHash. @@ -328,7 +195,7 @@ func GenAppHash(rng utils.Rng) AppHash { // GenAppProposal generates a random AppProposal. func GenAppProposal(rng utils.Rng) *AppProposal { - return NewAppProposal(GenGlobalBlockNumber(rng), GenRoadIndex(rng), GenAppHash(rng), GenEpochIndex(rng)) + return NewAppProposal(GenGlobalBlockNumber(rng), GenRoadIndex(rng), GenAppHash(rng)) } // GenAppVote generates a random AppVote. @@ -394,8 +261,11 @@ func GenCommitVote(rng utils.Rng) *CommitVote { // GenCommitQC generates a random CommitQC. func GenCommitQC(rng utils.Rng) *CommitQC { - committee, keys := GenCommittee(rng, int(rng.Uint64()%5)+1) //nolint:gosec - return CommitQCAt(GenEpochWithCommittee(rng, committee), keys) + vote := GenCommitVote(rng) + return NewCommitQC(utils.GenSlice( + rng, + func(rng utils.Rng) *Signed[*CommitVote] { return GenSigned(rng, vote) }, + )) } // GenFullCommitQC generates a random FullCommitQC. @@ -406,17 +276,6 @@ func GenFullCommitQC(rng utils.Rng) *FullCommitQC { } } -// GenFullCommitQCRange generates a FullCommitQC whose GlobalRange is -// [first, next) and which is internally consistent (First + len(headers) == -// Next), for tests that index a QC by its own range. littblock does not verify -// signatures, so the QC need not be otherwise well-formed. -func GenFullCommitQCRange(rng utils.Rng, first GlobalBlockNumber, next GlobalBlockNumber) *FullCommitQC { - qc := GenCommitQC(rng) - qc.vote.Msg().Proposal().globalRange = GlobalRange{First: first, Next: next} - headers := utils.GenSliceN(rng, int(next-first), GenBlockHeader) //nolint:gosec // small test range - return NewFullCommitQC(qc, headers) -} - // GenTimeoutVote generates a random TimeoutVote. func GenTimeoutVote(rng utils.Rng) *TimeoutVote { return NewTimeoutVote(GenView(rng), utils.Some(GenViewNumber(rng))) diff --git a/sei-tendermint/autobahn/types/timeout.go b/sei-tendermint/autobahn/types/timeout.go index d870891a13..003ff64b26 100644 --- a/sei-tendermint/autobahn/types/timeout.go +++ b/sei-tendermint/autobahn/types/timeout.go @@ -29,18 +29,10 @@ func (m *TimeoutVote) View() View { return m.view } -// EpochIndex returns the epoch this vote belongs to. -func (m *TimeoutVote) EpochIndex() EpochIndex { return m.view.EpochIndex } - -// Verify checks epoch binding and road range validity of the vote. -func (m *TimeoutVote) Verify(ep *Epoch) error { - return m.view.Verify(ep) -} - // latestPrepareQCView is the highest view number for which a PrepareQC was observed by the node. func (m *TimeoutVote) latestPrepareQCView() utils.Option[View] { return utils.MapOpt(m.latestPrepareQC, func(n ViewNumber) View { - return View{Index: m.view.Index, Number: n, EpochIndex: m.view.EpochIndex} + return View{Index: m.view.Index, Number: n} }) } @@ -73,12 +65,8 @@ func (m *FullTimeoutVote) View() View { return m.vote.Msg().View() } -// Verify verifies the FullTimeoutVote against the epoch. -func (m *FullTimeoutVote) Verify(ep *Epoch) error { - if err := m.vote.Msg().Verify(ep); err != nil { - return err - } - c := ep.Committee() +// Verify verifies the FullTimeoutVote against the committee. +func (m *FullTimeoutVote) Verify(c *Committee) error { if err := m.vote.VerifySig(c); err != nil { return err } @@ -89,7 +77,7 @@ func (m *FullTimeoutVote) Verify(ep *Epoch) error { } // TODO: verifying PrepareQC in all Timeout votes might be too inefficient. // If it is, we can skip duplicated verification. - if err := pQC.Verify(ep); err != nil { + if err := pQC.Verify(c); err != nil { return fmt.Errorf("latestPrepareQC: %w", err) } if got := pQC.Proposal().View(); got != want { @@ -144,19 +132,10 @@ func (m *TimeoutQC) LatestPrepareQC() utils.Option[*PrepareQC] { return m.latestPrepareQC } -// Verify verifies the TimeoutQC against the epoch and the previous CommitQC. +// Verify verifies the TimeoutQC against the committee and the previous CommitQC. // Verifying TimeoutQC should NOT require previous TimeoutQC, // since observing prior TimeoutQCs is not required in the pb. -func (m *TimeoutQC) Verify(ep *Epoch, prev utils.Option[*CommitQC]) error { - view := m.View() - if err := m.votes[0].Msg().Verify(ep); err != nil { - return err - } - c := ep.Committee() - // Check that the TimeoutQC is from the correct consensus instance. - if got, want := view.Index, NextIndexOpt(prev); got != want { - return fmt.Errorf("timeoutQC.View().Index = %v, want %v", got, want) - } +func (m *TimeoutQC) Verify(c *Committee, prev utils.Option[*CommitQC]) error { // Verify the signatures. weight := uint64(0) done := map[PublicKey]struct{}{} @@ -174,7 +153,12 @@ func (m *TimeoutQC) Verify(ep *Epoch, prev utils.Option[*CommitQC]) error { if got, want := weight, c.TimeoutQuorum(); got < want { return fmt.Errorf("got %v votes weight, want >= %v", got, want) } + // Check that the TimeoutQC is from the correct consensus instance. h := utils.None[ViewNumber]() + view := m.View() + if got, want := view.Index, NextIndexOpt(prev); got != want { + return fmt.Errorf("timeoutQC.View().Index = %v, want %v", got, want) + } // Check that the votes come from the same view. for _, v := range m.Votes() { if got := v.Msg().View(); got != view { @@ -190,10 +174,10 @@ func (m *TimeoutQC) Verify(ep *Epoch, prev utils.Option[*CommitQC]) error { if !ok { return errors.New("missing latestPrepareQC") } - if got, want := pQC.Proposal().View(), (View{Index: view.Index, Number: vn, EpochIndex: view.EpochIndex}); got != want { + if got, want := pQC.Proposal().View(), (View{Index: view.Index, Number: vn}); got != want { return fmt.Errorf("latestPrepareQC view number mismatch, got %v, want %v", got, want) } - if err := pQC.Verify(ep); err != nil { + if err := pQC.Verify(c); err != nil { return fmt.Errorf("higPrepareQC: %w", err) } } else { @@ -215,7 +199,12 @@ func (m *TimeoutQC) reproposal() (*Proposal, bool) { for _, l := range p.laneRanges { laneRanges = append(laneRanges, l) } - return newProposal(m.View().Next(), p.Timestamp(), laneRanges, p.App(), p.GlobalRange().First), true + return newProposal( + m.View().Next(), + p.Timestamp(), + laneRanges, + p.App(), + ), true } // TimeoutVoteConv is the protobuf converter for TimeoutVote. diff --git a/sei-tendermint/autobahn/types/types_test.go b/sei-tendermint/autobahn/types/types_test.go index b82de8462c..79f48a7773 100644 --- a/sei-tendermint/autobahn/types/types_test.go +++ b/sei-tendermint/autobahn/types/types_test.go @@ -96,6 +96,25 @@ func TestMarshal(t *testing.T) { } } +func TestNewRoundRobinElection_GenesisTimestamp(t *testing.T) { + rng := utils.TestRng() + replicas := []PublicKey{GenPublicKey(rng), GenPublicKey(rng)} + firstBlock := GenGlobalBlockNumber(rng) + genesisTimestamp := time.Now() + + committee, err := NewRoundRobinElection(replicas, firstBlock, genesisTimestamp) + if err != nil { + t.Fatalf("NewRoundRobinElection(): %v", err) + } + + if got := committee.FirstBlock(); got != firstBlock { + t.Fatalf("FirstBlock() = %v, want %v", got, firstBlock) + } + if got := committee.GenesisTimestamp(); !got.Equal(genesisTimestamp) { + t.Fatalf("GenesisTimestamp() = %v, want %v", got, genesisTimestamp) + } +} + func makePrepareQC(keys []SecretKey, vote *PrepareVote) *PrepareQC { var votes []*Signed[*PrepareVote] for _, k := range keys { @@ -109,14 +128,13 @@ func TestNewTimeoutQC(t *testing.T) { _, keys := GenCommittee(rng, 10) view := GenView(rng) var votes []*FullTimeoutVote - wantView := View{EpochIndex: view.EpochIndex} + wantView := View{} for _, k := range keys { pView := View{ - Index: view.Index, - Number: GenViewNumber(rng) % view.Number, - EpochIndex: view.EpochIndex, + Index: view.Index, + Number: GenViewNumber(rng) % view.Number, } - p := newProposal(pView, utils.GenTimestamp(rng), utils.GenSlice(rng, GenLaneRange), utils.Some(GenAppProposal(rng)), GlobalBlockNumber(rng.Uint64())) + p := newProposal(pView, time.Now(), utils.GenSlice(rng, GenLaneRange), utils.Some(GenAppProposal(rng))) if wantView.Less(pView) { wantView = pView } @@ -144,10 +162,11 @@ func TestTimeoutQCConvDecode_EmptyVotesReturnsError(t *testing.T) { func TestNewTimeoutQC_MixedPrepareQCs(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := NewEpoch(GenEpochIndex(rng), OpenRoadRange(), utils.GenTimestamp(rng), committee, GlobalBlockNumber(rng.Uint64()%1000000)+1) - view := View{Index: 0, Number: 0, EpochIndex: ep.EpochIndex()} + view := View{Index: 0, Number: 0} - pqc := makePrepareQC(keys, NewPrepareVote(ProposalAt(ep, view))) + pqc := makePrepareQC(keys, NewPrepareVote( + newProposal(view, time.Now(), utils.GenSlice(rng, GenLaneRange), utils.Some(GenAppProposal(rng))), + )) // Only keys[0] carries the PrepareQC; the rest carry None. votes := make([]*FullTimeoutVote, len(keys)) @@ -164,7 +183,7 @@ func TestNewTimeoutQC_MixedPrepareQCs(t *testing.T) { if got.View() != view { t.Fatalf("LatestPrepareQC.View() = %v, want %v", got.View(), view) } - if err := tqc.Verify(ep, utils.None[*CommitQC]()); err != nil { + if err := tqc.Verify(committee, utils.None[*CommitQC]()); err != nil { t.Fatalf("Verify: %v", err) } } @@ -174,8 +193,7 @@ func TestNewTimeoutQC_MixedPrepareQCs(t *testing.T) { func TestNewTimeoutQC_AllNone(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := NewEpoch(GenEpochIndex(rng), OpenRoadRange(), utils.GenTimestamp(rng), committee, GlobalBlockNumber(rng.Uint64()%1000000)+1) - view := View{Index: 0, Number: 0, EpochIndex: ep.EpochIndex()} + view := View{Index: 0, Number: 0} votes := make([]*FullTimeoutVote, len(keys)) for i, k := range keys { @@ -186,7 +204,7 @@ func TestNewTimeoutQC_AllNone(t *testing.T) { if tqc.LatestPrepareQC().IsPresent() { t.Fatal("LatestPrepareQC should be None when no vote carries one") } - if err := tqc.Verify(ep, utils.None[*CommitQC]()); err != nil { + if err := tqc.Verify(committee, utils.None[*CommitQC]()); err != nil { t.Fatalf("Verify: %v", err) } } @@ -196,12 +214,13 @@ func TestNewTimeoutQC_AllNone(t *testing.T) { func TestTimeoutQCVerify_HighestPrepareQCSelected(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := NewEpoch(GenEpochIndex(rng), OpenRoadRange(), utils.GenTimestamp(rng), committee, GlobalBlockNumber(rng.Uint64()%1000000)+1) - view := View{Index: 0, Number: 5, EpochIndex: ep.EpochIndex()} + view := View{Index: 0, Number: 5} makePQCAt := func(vn ViewNumber) *PrepareQC { - pView := View{Index: 0, Number: vn, EpochIndex: ep.EpochIndex()} - return makePrepareQC(keys, NewPrepareVote(ProposalAt(ep, pView))) + pView := View{Index: view.Index, Number: vn} + return makePrepareQC(keys, NewPrepareVote( + newProposal(pView, time.Now(), utils.GenSlice(rng, GenLaneRange), utils.Some(GenAppProposal(rng))), + )) } // keys[0] has PrepareQC at view number 2, keys[1] at 4, rest None. @@ -216,11 +235,11 @@ func TestTimeoutQCVerify_HighestPrepareQCSelected(t *testing.T) { if !ok { t.Fatal("LatestPrepareQC must be present") } - wantView := View{Index: 0, Number: 4, EpochIndex: ep.EpochIndex()} + wantView := View{Index: 0, Number: 4} if got.View() != wantView { t.Fatalf("LatestPrepareQC.View() = %v, want %v", got.View(), wantView) } - if err := tqc.Verify(ep, utils.None[*CommitQC]()); err != nil { + if err := tqc.Verify(committee, utils.None[*CommitQC]()); err != nil { t.Fatalf("Verify: %v", err) } } diff --git a/sei-tendermint/autobahn/types/wireguard_test.go b/sei-tendermint/autobahn/types/wireguard_test.go index cbebd32535..cfe57647c3 100644 --- a/sei-tendermint/autobahn/types/wireguard_test.go +++ b/sei-tendermint/autobahn/types/wireguard_test.go @@ -143,7 +143,7 @@ func TestFullCommitQCWireguardAcceptsMaxValidatorsAndHeaders(t *testing.T) { } laneRanges = append(laneRanges, NewLaneRange(lane, 0, utils.Some(lastHeader))) } - proposal := newProposal(View{}, time.Unix(1, 2), laneRanges, utils.None[*AppProposal](), 0) + proposal := newProposal(View{}, time.Unix(1, 2), laneRanges, utils.None[*AppProposal]()) vote := NewCommitVote(proposal) votes := make([]*Signed[*CommitVote], len(keys)) for i, key := range keys { @@ -197,7 +197,8 @@ func TestFullProposalWireguardAcceptsMaxValidators(t *testing.T) { } proposal, err := NewProposal( secretKeyFor(keys, committee.Leader(View{})), - ViewSpec{Epoch: NewEpoch(0, OpenRoadRange(), time.Time{}, committee, 0)}, + committee, + ViewSpec{}, time.Unix(1, 2), laneQCs, utils.None[*AppQC](), diff --git a/sei-tendermint/cmd/tendermint/commands/reindex_event.go b/sei-tendermint/cmd/tendermint/commands/reindex_event.go index d0eea56b2a..72dc2817ff 100644 --- a/sei-tendermint/cmd/tendermint/commands/reindex_event.go +++ b/sei-tendermint/cmd/tendermint/commands/reindex_event.go @@ -124,11 +124,7 @@ func loadEventSinks(cfg *tmcfg.Config) ([]indexer.EventSink, error) { if conn == "" { return nil, errors.New("the psql connection settings cannot be empty") } - genDoc, err := types.GenesisDocFromFile(cfg.GenesisFile()) - if err != nil { - return nil, fmt.Errorf("failed to load genesis file: %w", err) - } - es, err := psql.NewEventSink(conn, genDoc.ChainID) + es, err := psql.NewEventSink(conn, cfg.ChainID()) if err != nil { return nil, err } diff --git a/sei-tendermint/config/config.go b/sei-tendermint/config/config.go index 57d6c158df..633c626ce0 100644 --- a/sei-tendermint/config/config.go +++ b/sei-tendermint/config/config.go @@ -177,6 +177,9 @@ func (cfg *Config) DeprecatedFieldWarning() error { // BaseConfig defines the base configuration for a Tendermint node type BaseConfig struct { + // chainID is unexposed and immutable but here for convenience + chainID string + // The root directory for all data. // This should be set in viper so it can unmarshal into this struct RootDir string `mapstructure:"home"` @@ -265,12 +268,17 @@ func DefaultBaseConfig() BaseConfig { // TestBaseConfig returns a base configuration for testing a Tendermint node func TestBaseConfig() BaseConfig { cfg := DefaultBaseConfig() + cfg.chainID = "tendermint_test" cfg.Mode = ModeValidator cfg.ProxyApp = "kvstore" cfg.DBBackend = "memdb" return cfg } +func (cfg BaseConfig) ChainID() string { + return cfg.chainID +} + // GenesisFile returns the full path to the genesis.json file func (cfg BaseConfig) GenesisFile() string { return rootify(cfg.Genesis, cfg.RootDir) @@ -1439,8 +1447,7 @@ type InstrumentationConfig struct { // 0 - unlimited. MaxOpenConnections int `mapstructure:"max-open-connections"` - // Deprecated: Instrumentation namespace is ignored. Tendermint Prometheus - // metrics always use the fixed "tendermint" namespace. + // Instrumentation namespace. Namespace string `mapstructure:"namespace"` } diff --git a/sei-tendermint/config/testonly.go b/sei-tendermint/config/testonly.go deleted file mode 100644 index aed79c3fd0..0000000000 --- a/sei-tendermint/config/testonly.go +++ /dev/null @@ -1,10 +0,0 @@ -package config - -import ( - "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" - "github.com/sei-protocol/sei-chain/sei-tendermint/types" -) - -func TestLoadGenesis(cfg *Config) *types.GenesisDoc { - return utils.OrPanic1(types.GenesisDocFromFile(cfg.GenesisFile())) -} diff --git a/sei-tendermint/config/toml.go b/sei-tendermint/config/toml.go index 3e4c30ee21..7802db8897 100644 --- a/sei-tendermint/config/toml.go +++ b/sei-tendermint/config/toml.go @@ -11,6 +11,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/tcp" tmos "github.com/sei-protocol/sei-chain/sei-tendermint/libs/os" + tmrand "github.com/sei-protocol/sei-chain/sei-tendermint/libs/rand" ) // defaultDirPerm is the default permissions used when creating directories. @@ -617,6 +618,9 @@ prometheus-listen-addr = "{{ .Instrumentation.PrometheusListenAddr }}" # 0 - unlimited. max-open-connections = {{ .Instrumentation.MaxOpenConnections }} +# Instrumentation namespace +namespace = "{{ .Instrumentation.Namespace }}" + ####################################################################### ### Priv Validator Configuration (Auto-managed) ### ####################################################################### @@ -719,6 +723,7 @@ func ResetTestRootWithChainID(dir, testName string, chainID string) (*Config, er config := TestConfig().SetRoot(rootDir) config.P2P.ListenAddress = tcp.TestReserveAddr().String() + config.Instrumentation.Namespace = fmt.Sprintf("%s_%s_%s", testName, chainID, tmrand.Str(16)) return config, nil } diff --git a/sei-tendermint/internal/autobahn/autobahn.proto b/sei-tendermint/internal/autobahn/autobahn.proto index 3b04dd242f..6dc7f9a0e8 100644 --- a/sei-tendermint/internal/autobahn/autobahn.proto +++ b/sei-tendermint/internal/autobahn/autobahn.proto @@ -119,7 +119,6 @@ message View { option (wireguard.sized) = true; optional uint64 index = 1; // required optional uint64 number = 2; // required - optional uint64 epoch_index = 3; // epoch this view belongs to; required } message Proposal { @@ -131,7 +130,6 @@ message Proposal { optional Timestamp timestamp = 5; // required repeated LaneRange lane_ranges = 3 [(wireguard.max_count) = 100]; // Sorted by lane. optional AppProposal app = 4; // optional - optional uint64 global_first = 6; // required; first global block number of this proposal's global range } message FullProposal { @@ -223,8 +221,6 @@ message AppProposal { optional uint64 road_index = 2; // required // App hash at that block. optional bytes app_hash = 3 [(wireguard.max_size) = 32]; // required - // Epoch this proposal belongs to. - optional uint64 epoch_index = 4; // required } // This is the signable message. diff --git a/sei-tendermint/internal/autobahn/avail/block_votes.go b/sei-tendermint/internal/autobahn/avail/block_votes.go index ba84945e1e..ae64772803 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes.go @@ -17,9 +17,7 @@ func newBlockVotes() blockVotes { } // Returns true iff a new QC has been constructed. -// TODO: handle epoch transitions — weight must be counted per-epoch committee once multi-epoch is wired up. -func (bv blockVotes) pushVote(ep *types.Epoch, vote *types.Signed[*types.LaneVote]) (*types.LaneQC, bool) { - c := ep.Committee() +func (bv blockVotes) pushVote(c *types.Committee, vote *types.Signed[*types.LaneVote]) (*types.LaneQC, bool) { k := vote.Key() h := vote.Msg().Header().Hash() if _, ok := bv.byKey[k]; ok { diff --git a/sei-tendermint/internal/autobahn/avail/conv_test.go b/sei-tendermint/internal/autobahn/avail/conv_test.go index ca753e5ee5..2bec6a4042 100644 --- a/sei-tendermint/internal/autobahn/avail/conv_test.go +++ b/sei-tendermint/internal/autobahn/avail/conv_test.go @@ -4,28 +4,25 @@ import ( "testing" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/stretchr/testify/require" - "google.golang.org/protobuf/proto" ) func TestPruneAnchorConv(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + committee, keys := types.GenCommittee(rng, 4) lane := keys[0].Public() block := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) laneQCs := map[types.LaneID]*types.LaneQC{ lane: types.NewLaneQC(makeLaneVotes(keys, block.Header())), } - commitQC := makeCommitQC(registry.LatestEpoch(), keys, utils.None[*types.CommitQC](), laneQCs, utils.None[*types.AppQC]()) - appProposal := types.NewAppProposal(commitQC.GlobalRange().First, commitQC.Proposal().Index(), types.GenAppHash(rng), commitQC.Proposal().EpochIndex()) + commitQC := makeCommitQC(committee, keys, utils.None[*types.CommitQC](), laneQCs, utils.None[*types.AppQC]()) + appProposal := types.NewAppProposal(commitQC.GlobalRange(committee).First, commitQC.Proposal().Index(), types.GenAppHash(rng)) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) - anchor := &PruneAnchor{AppQC: appQC, CommitQC: commitQC} - pb1 := PruneAnchorConv.Encode(anchor) - decoded, err := PruneAnchorConv.Decode(pb1) - require.NoError(t, err) - require.True(t, proto.Equal(pb1, PruneAnchorConv.Encode(decoded))) + require.NoError(t, PruneAnchorConv.Test(&PruneAnchor{ + AppQC: appQC, + CommitQC: commitQC, + })) } diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index f25d8bf461..601ca4611c 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -5,7 +5,6 @@ import ( "log/slog" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/avail/metrics" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus/persist" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) @@ -16,7 +15,6 @@ import ( // BlockPersister creates lane WALs lazily inside MaybePruneAndPersistLane, but the new // member must also appear in inner.blocks before the next persist cycle. type inner struct { - epoch *types.Epoch latestAppQC utils.Option[*types.AppQC] latestCommitQC utils.AtomicSend[utils.Option[*types.CommitQC]] appVotes *queue[types.GlobalBlockNumber, appVotes] @@ -57,26 +55,25 @@ type loadedAvailState struct { blocks map[types.LaneID][]persist.LoadedBlock } -func newInner(epoch *types.Epoch, loaded utils.Option[*loadedAvailState]) (*inner, error) { +func newInner(c *types.Committee, loaded utils.Option[*loadedAvailState]) (*inner, error) { votes := map[types.LaneID]*queue[types.BlockNumber, blockVotes]{} blocks := map[types.LaneID]*queue[types.BlockNumber, *types.Signed[*types.LaneProposal]]{} - for lane := range epoch.Committee().Lanes().All() { + for lane := range c.Lanes().All() { votes[lane] = newQueue[types.BlockNumber, blockVotes]() blocks[lane] = newQueue[types.BlockNumber, *types.Signed[*types.LaneProposal]]() } i := &inner{ - epoch: epoch, latestAppQC: utils.None[*types.AppQC](), latestCommitQC: utils.NewAtomicSend(utils.None[*types.CommitQC]()), appVotes: newQueue[types.GlobalBlockNumber, appVotes](), commitQCs: newQueue[types.RoadIndex, *types.CommitQC](), blocks: blocks, votes: votes, - nextBlockToPersist: make(map[types.LaneID]types.BlockNumber, len(votes)), - persistedBlockStart: make(map[types.LaneID]types.BlockNumber, len(votes)), + nextBlockToPersist: make(map[types.LaneID]types.BlockNumber, c.Lanes().Len()), + persistedBlockStart: make(map[types.LaneID]types.BlockNumber, c.Lanes().Len()), } - i.appVotes.prune(epoch.FirstBlock()) + i.appVotes.prune(c.FirstBlock()) l, ok := loaded.Get() if !ok { @@ -91,8 +88,7 @@ func newInner(epoch *types.Epoch, loaded utils.Option[*loadedAvailState]) (*inne slog.Uint64("roadIndex", uint64(anchor.AppQC.Proposal().RoadIndex())), slog.Uint64("globalNumber", uint64(anchor.AppQC.Proposal().GlobalNumber())), ) - // TODO: use the committee of the anchor's epoch once epoch transitions are wired up. - if _, err := i.prune(epoch.Committee(), anchor.AppQC, anchor.CommitQC); err != nil { + if _, err := i.prune(c, anchor.AppQC, anchor.CommitQC); err != nil { return nil, fmt.Errorf("prune: %w", err) } for lane := range i.blocks { @@ -147,9 +143,7 @@ func newInner(epoch *types.Epoch, loaded utils.Option[*loadedAvailState]) (*inne return i, nil } -// TODO: filter votes per-epoch committee once epoch transitions are wired up. -func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber) (*types.LaneQC, bool) { - c := i.epoch.Committee() +func (i *inner) laneQC(c *types.Committee, lane types.LaneID, n types.BlockNumber) (*types.LaneQC, bool) { for _, byHash := range i.votes[lane].q[n].byHash { if byHash.weight >= c.LaneQuorum() { return types.NewLaneQC(byHash.votes[:]), true @@ -169,13 +163,11 @@ func (i *inner) prune(c *types.Committee, appQC *types.AppQC, commitQC *types.Co return false, nil } i.latestAppQC = utils.Some(appQC) - metrics.ObserveAppQC(appQC) i.commitQCs.prune(idx) if i.commitQCs.next == idx { i.commitQCs.pushBack(commitQC) - metrics.ObserveCommitQC(commitQC) } - i.appVotes.prune(commitQC.GlobalRange().First) + i.appVotes.prune(commitQC.GlobalRange(c).First) for lane := range i.votes { lr := commitQC.LaneRange(lane) i.votes[lr.Lane()].prune(lr.First()) diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index afdee71d9a..257641de63 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -1,20 +1,19 @@ package avail import ( + pb "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/pb" "testing" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus/persist" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" - pb "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/pb" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/stretchr/testify/require" ) func TestPruneMismatchedIndices(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + committee, keys := types.GenCommittee(rng, 4) makeCommitQC := func(prev utils.Option[*types.CommitQC]) *types.CommitQC { l := keys[0].Public() @@ -23,12 +22,12 @@ func TestPruneMismatchedIndices(t *testing.T) { lqcs := map[types.LaneID]*types.LaneQC{ l: types.NewLaneQC(makeLaneVotes(keys, b.Header())), } - return makeCommitQC(registry.LatestEpoch(), keys, prev, lqcs, utils.None[*types.AppQC]()) + return makeCommitQC(committee, keys, prev, lqcs, utils.None[*types.AppQC]()) } makeAppQC := func(qcForRange *types.CommitQC, qcForIndex *types.CommitQC) *types.AppQC { - gr := qcForRange.GlobalRange() + gr := qcForRange.GlobalRange(committee) require.True(t, gr.Len() > 0) - ap := types.NewAppProposal(gr.First, qcForIndex.Index(), types.GenAppHash(rng), 0) + ap := types.NewAppProposal(gr.First, qcForIndex.Index(), types.GenAppHash(rng)) return types.NewAppQC(makeAppVotes(keys, ap)) } @@ -36,7 +35,7 @@ func TestPruneMismatchedIndices(t *testing.T) { qc1 := makeCommitQC(utils.Some(qc0)) t.Logf("test State.PushAppQC") - ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) state, err := NewState(keys[0], ds, utils.None[string]()) require.NoError(t, err) require.Error(t, state.PushAppQC(makeAppQC(qc0, qc0), qc1), "bad range, bad index should fail") @@ -45,14 +44,14 @@ func TestPruneMismatchedIndices(t *testing.T) { require.NoError(t, state.PushAppQC(makeAppQC(qc1, qc1), qc1), "good range, good index should succeed") t.Logf("test inner.prune") - ds = utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + ds = utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) state, err = NewState(keys[0], ds, utils.None[string]()) require.NoError(t, err) for inner := range state.inner.Lock() { - _, err := inner.prune(registry.LatestEpoch().Committee(), makeAppQC(qc1, qc0), qc1) + _, err := inner.prune(committee, makeAppQC(qc1, qc0), qc1) require.Error(t, err, "good range, bad index should fail") require.False(t, inner.latestAppQC.IsPresent(), "latestAppQC should not have been updated") - _, err = inner.prune(registry.LatestEpoch().Committee(), makeAppQC(qc1, qc1), qc1) + _, err = inner.prune(committee, makeAppQC(qc1, qc1), qc1) require.NoError(t, err, "good range, good index should succeed") } } @@ -65,18 +64,18 @@ func testSignedBlock(key types.SecretKey, lane types.LaneID, n types.BlockNumber func TestNewInnerFreshStart(t *testing.T) { rng := utils.TestRng() - registry, _ := epoch.GenRegistry(rng, 4) + committee, _ := types.GenCommittee(rng, 4) - i, err := newInner(registry.LatestEpoch(), utils.None[*loadedAvailState]()) + i, err := newInner(committee, utils.None[*loadedAvailState]()) require.NoError(t, err) require.False(t, i.latestAppQC.IsPresent()) require.NotNil(t, i.nextBlockToPersist) require.Equal(t, types.RoadIndex(0), i.commitQCs.first) require.Equal(t, types.RoadIndex(0), i.commitQCs.next) - require.Equal(t, registry.FirstBlock(), i.appVotes.first) - require.Equal(t, registry.FirstBlock(), i.appVotes.next) - for lane := range registry.LatestEpoch().Committee().Lanes().All() { + require.Equal(t, committee.FirstBlock(), i.appVotes.first) + require.Equal(t, committee.FirstBlock(), i.appVotes.next) + for lane := range committee.Lanes().All() { require.Equal(t, types.BlockNumber(0), i.blocks[lane].first) require.Equal(t, types.BlockNumber(0), i.blocks[lane].next) require.Equal(t, types.BlockNumber(0), i.votes[lane].first) @@ -86,9 +85,9 @@ func TestNewInnerFreshStart(t *testing.T) { func TestDecodePruneAnchorIncomplete(t *testing.T) { rng := utils.TestRng() - _, keys := epoch.GenRegistry(rng, 4) + _, keys := types.GenCommittee(rng, 4) - appProposal := types.NewAppProposal(42, 5, types.GenAppHash(rng), 0) + appProposal := types.NewAppProposal(42, 5, types.GenAppHash(rng)) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) _, err := PruneAnchorConv.Decode(&pb.PersistedAvailPruneAnchor{ @@ -100,22 +99,22 @@ func TestDecodePruneAnchorIncomplete(t *testing.T) { func TestNewInnerLoadedNoAnchor(t *testing.T) { rng := utils.TestRng() - registry, _ := epoch.GenRegistry(rng, 4) + committee, _ := types.GenCommittee(rng, 4) loaded := &loadedAvailState{} - i, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + i, err := newInner(committee, utils.Some(loaded)) require.NoError(t, err) - // No anchor loaded, app votes should start at the registry's first block. + // No anchor loaded, app votes should start at the committee's first block. require.False(t, i.latestAppQC.IsPresent()) require.Equal(t, types.RoadIndex(0), i.commitQCs.first) - require.Equal(t, registry.FirstBlock(), i.appVotes.first) + require.Equal(t, committee.FirstBlock(), i.appVotes.first) } func TestNewInnerLoadedBlocksContiguous(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + committee, keys := types.GenCommittee(rng, 4) lane := keys[0].Public() // Build 3 contiguous blocks: 0, 1, 2. @@ -131,7 +130,7 @@ func TestNewInnerLoadedBlocksContiguous(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - i, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + i, err := newInner(committee, utils.Some(loaded)) require.NoError(t, err) q := i.blocks[lane] @@ -144,7 +143,7 @@ func TestNewInnerLoadedBlocksContiguous(t *testing.T) { // nextBlockToPersist: loaded lane at q.next, other lanes at 0 (map zero-value). require.NotNil(t, i.nextBlockToPersist) require.Equal(t, types.BlockNumber(3), i.nextBlockToPersist[lane]) - for other := range registry.LatestEpoch().Committee().Lanes().All() { + for other := range committee.Lanes().All() { if other != lane { require.Equal(t, types.BlockNumber(0), i.nextBlockToPersist[other]) } @@ -153,14 +152,14 @@ func TestNewInnerLoadedBlocksContiguous(t *testing.T) { func TestNewInnerLoadedBlocksEmptySlice(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + committee, keys := types.GenCommittee(rng, 4) lane := keys[0].Public() loaded := &loadedAvailState{ blocks: map[types.LaneID][]persist.LoadedBlock{lane: {}}, } - i, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + i, err := newInner(committee, utils.Some(loaded)) require.NoError(t, err) q := i.blocks[lane] @@ -170,7 +169,7 @@ func TestNewInnerLoadedBlocksEmptySlice(t *testing.T) { func TestNewInnerLoadedBlocksUnknownLane(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + committee, keys := types.GenCommittee(rng, 4) unknownKey := types.GenSecretKey(rng) unknownLane := unknownKey.Public() @@ -180,10 +179,10 @@ func TestNewInnerLoadedBlocksUnknownLane(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{unknownLane: {{Number: 0, Proposal: b}}}, } - i, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + i, err := newInner(committee, utils.Some(loaded)) require.NoError(t, err) - for lane := range registry.LatestEpoch().Committee().Lanes().All() { + for lane := range committee.Lanes().All() { q := i.blocks[lane] require.Equal(t, types.BlockNumber(0), q.first) require.Equal(t, types.BlockNumber(0), q.next) @@ -193,7 +192,7 @@ func TestNewInnerLoadedBlocksUnknownLane(t *testing.T) { func TestNewInnerLoadedBlocksMultipleLanes(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + committee, keys := types.GenCommittee(rng, 4) lane0 := keys[0].Public() lane1 := keys[1].Public() @@ -217,7 +216,7 @@ func TestNewInnerLoadedBlocksMultipleLanes(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane0: bs0, lane1: bs1}, } - i, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + i, err := newInner(committee, utils.Some(loaded)) require.NoError(t, err) q0 := i.blocks[lane0] @@ -235,13 +234,13 @@ func TestNewInnerLoadedBlocksMultipleLanes(t *testing.T) { func TestNewInnerLoadedCommitQCsNoAppQC(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + committee, keys := types.GenCommittee(rng, 4) // Create 3 sequential CommitQCs. qcs := make([]*types.CommitQC, 3) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(committee, keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } @@ -254,7 +253,7 @@ func TestNewInnerLoadedCommitQCsNoAppQC(t *testing.T) { commitQCs: loadedQCs, } - inner, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + inner, err := newInner(committee, utils.Some(loaded)) require.NoError(t, err) // Without anchor, commitQCs.first = 0. All 3 should be restored. @@ -272,19 +271,19 @@ func TestNewInnerLoadedCommitQCsNoAppQC(t *testing.T) { func TestNewInnerLoadedCommitQCsWithAppQC(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + committee, keys := types.GenCommittee(rng, 4) // AppQC at road index 2. roadIdx := types.RoadIndex(2) globalNum := types.GlobalBlockNumber(10) - appProposal := types.NewAppProposal(globalNum, roadIdx, types.GenAppHash(rng), 0) + appProposal := types.NewAppProposal(globalNum, roadIdx, types.GenAppHash(rng)) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) // Create 5 sequential CommitQCs (indices 0-4). qcs := make([]*types.CommitQC, 5) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(committee, keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } @@ -300,7 +299,7 @@ func TestNewInnerLoadedCommitQCsWithAppQC(t *testing.T) { commitQCs: loadedQCs, } - inner, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + inner, err := newInner(committee, utils.Some(loaded)) require.NoError(t, err) // latestAppQC should be set by prune. @@ -324,19 +323,19 @@ func TestNewInnerLoadedCommitQCsWithAppQC(t *testing.T) { func TestNewInnerLoadedAllThree(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + committee, keys := types.GenCommittee(rng, 4) lane := keys[0].Public() // AppQC at road index 2. roadIdx := types.RoadIndex(2) - appProposal := types.NewAppProposal(10, roadIdx, types.GenAppHash(rng), 0) + appProposal := types.NewAppProposal(10, roadIdx, types.GenAppHash(rng)) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) // CommitQCs 0-4. qcs := make([]*types.CommitQC, 5) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(committee, keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } // Pre-filtered: only commitQCs >= anchor road index (2). @@ -361,7 +360,7 @@ func TestNewInnerLoadedAllThree(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - inner, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + inner, err := newInner(committee, utils.Some(loaded)) require.NoError(t, err) // AppQC restored. @@ -387,10 +386,10 @@ func TestNewInnerLoadedAllThree(t *testing.T) { func TestPruneAdvancesNextBlockToPersist(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + committee, keys := types.GenCommittee(rng, 4) lane := keys[0].Public() - i, err := newInner(registry.LatestEpoch(), utils.None[*loadedAvailState]()) + i, err := newInner(committee, utils.None[*loadedAvailState]()) require.NoError(t, err) // Push blocks 0-4 on one lane. @@ -412,11 +411,11 @@ func TestPruneAdvancesNextBlockToPersist(t *testing.T) { h := i.blocks[lane].q[bn].Msg().Block().Header() laneQCs := map[types.LaneID]*types.LaneQC{ lane: types.NewLaneQC(makeLaneVotes( - types.TestKeysWithWeight(registry.LatestEpoch().Committee(), keys, registry.LatestEpoch().Committee().LaneQuorum()), + types.TestKeysWithWeight(committee, keys, committee.LaneQuorum()), h, )), } - qcs[j] = makeCommitQC(registry.LatestEpoch(), keys, prev, laneQCs, utils.None[*types.AppQC]()) + qcs[j] = makeCommitQC(committee, keys, prev, laneQCs, utils.None[*types.AppQC]()) prev = utils.Some(qcs[j]) i.commitQCs.pushBack(qcs[j]) } @@ -427,10 +426,10 @@ func TestPruneAdvancesNextBlockToPersist(t *testing.T) { "CommitQC lane range should reference blocks for this test to be meaningful") // AppQC at index 2 → prune will fast-forward blocks past the cursor. - appProposal := types.NewAppProposal(10, 2, types.GenAppHash(rng), 0) + appProposal := types.NewAppProposal(10, 2, types.GenAppHash(rng)) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) - updated, err := i.prune(registry.LatestEpoch().Committee(), appQC, qcs[2]) + updated, err := i.prune(committee, appQC, qcs[2]) require.NoError(t, err) require.True(t, updated) @@ -446,7 +445,7 @@ func TestPruneAdvancesNextBlockToPersist(t *testing.T) { func TestNewInnerLoadedCommitQCsAllBeforeAppQCArePruned(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + committee, keys := types.GenCommittee(rng, 4) // Build 6 CommitQCs (indices 0-5). Anchor at index 5. // All stale commitQCs (0-4) were already filtered by loadPersistedState, @@ -454,18 +453,18 @@ func TestNewInnerLoadedCommitQCsAllBeforeAppQCArePruned(t *testing.T) { qcs := make([]*types.CommitQC, 6) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(committee, keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } - appProposal := types.NewAppProposal(20, 5, types.GenAppHash(rng), 0) + appProposal := types.NewAppProposal(20, 5, types.GenAppHash(rng)) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) loaded := &loadedAvailState{ pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC, CommitQC: qcs[5]}), } - inner, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + inner, err := newInner(committee, utils.Some(loaded)) require.NoError(t, err) // prune() pushes the anchor's CommitQC into the queue. @@ -476,25 +475,25 @@ func TestNewInnerLoadedCommitQCsAllBeforeAppQCArePruned(t *testing.T) { func TestNewInnerAnchorWithNoCommitQCFiles(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + committee, keys := types.GenCommittee(rng, 4) // Simulate crash between anchor write and CommitQC file write: // anchor has AppQC@3 + CommitQC@3, but no CommitQC files on disk. qcs := make([]*types.CommitQC, 4) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(committee, keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } - appProposal := types.NewAppProposal(20, 3, types.GenAppHash(rng), 0) + appProposal := types.NewAppProposal(20, 3, types.GenAppHash(rng)) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) loaded := &loadedAvailState{ pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC, CommitQC: qcs[3]}), } - inner, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + inner, err := newInner(committee, utils.Some(loaded)) require.NoError(t, err) // prune() should push the anchor's CommitQC into the queue. @@ -508,7 +507,7 @@ func TestNewInnerAnchorWithNoCommitQCFiles(t *testing.T) { require.Equal(t, types.RoadIndex(3), aq.Proposal().RoadIndex()) // persistedBlockStart should be initialized from the anchor's CommitQC. - for lane := range registry.LatestEpoch().Committee().Lanes().All() { + for lane := range committee.Lanes().All() { expected := qcs[3].LaneRange(lane).First() require.Equal(t, expected, inner.persistedBlockStart[lane]) } @@ -516,12 +515,12 @@ func TestNewInnerAnchorWithNoCommitQCFiles(t *testing.T) { func TestNewInnerLoadedCommitQCsGapReturnsError(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + committee, keys := types.GenCommittee(rng, 4) qcs := make([]*types.CommitQC, 3) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(committee, keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } @@ -537,20 +536,20 @@ func TestNewInnerLoadedCommitQCsGapReturnsError(t *testing.T) { commitQCs: loadedQCs, } - _, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + _, err := newInner(committee, utils.Some(loaded)) require.Error(t, err) require.Contains(t, err.Error(), "non-contiguous") } func TestNewInnerLoadedCommitQCsEmpty(t *testing.T) { rng := utils.TestRng() - registry, _ := epoch.GenRegistry(rng, 4) + committee, _ := types.GenCommittee(rng, 4) loaded := &loadedAvailState{ commitQCs: nil, } - inner, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + inner, err := newInner(committee, utils.Some(loaded)) require.NoError(t, err) require.Equal(t, types.RoadIndex(0), inner.commitQCs.first) @@ -561,7 +560,7 @@ func TestNewInnerLoadedCommitQCsEmpty(t *testing.T) { func TestNewInnerLoadedCommitQCsGapWithAppQCAnchor(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + committee, keys := types.GenCommittee(rng, 4) // Simulate crash scenario: disk had stale QCs [0,1,2] and a new QC at // index 10. loadPersistedState pre-filters stale entries, so newInner @@ -569,11 +568,11 @@ func TestNewInnerLoadedCommitQCsGapWithAppQCAnchor(t *testing.T) { qcs := make([]*types.CommitQC, 11) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(committee, keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } - appProposal := types.NewAppProposal(50, 10, types.GenAppHash(rng), 0) + appProposal := types.NewAppProposal(50, 10, types.GenAppHash(rng)) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) loadedQCs := []persist.LoadedCommitQC{ @@ -585,7 +584,7 @@ func TestNewInnerLoadedCommitQCsGapWithAppQCAnchor(t *testing.T) { commitQCs: loadedQCs, } - inner, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + inner, err := newInner(committee, utils.Some(loaded)) require.NoError(t, err) // Only QC@10 loaded. @@ -605,7 +604,7 @@ func TestNewInnerLoadedCommitQCsGapWithAppQCAnchor(t *testing.T) { func TestNewInnerLoadedCommitQCsBelowAnchorSkipped(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + committee, keys := types.GenCommittee(rng, 4) // Build 6 CommitQCs (0-5). Anchor at index 3. // Loaded list includes stale entries [1, 2] below the anchor plus [3, 4, 5]. @@ -614,11 +613,11 @@ func TestNewInnerLoadedCommitQCsBelowAnchorSkipped(t *testing.T) { qcs := make([]*types.CommitQC, 6) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(committee, keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } - appProposal := types.NewAppProposal(20, 3, types.GenAppHash(rng), 0) + appProposal := types.NewAppProposal(20, 3, types.GenAppHash(rng)) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) loadedQCs := []persist.LoadedCommitQC{ @@ -634,7 +633,7 @@ func TestNewInnerLoadedCommitQCsBelowAnchorSkipped(t *testing.T) { commitQCs: loadedQCs, } - inner, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + inner, err := newInner(committee, utils.Some(loaded)) require.NoError(t, err) // prune(3) pushes QC@3 (next=4). Indices 1,2,3 are skipped. 4,5 pushed. @@ -647,7 +646,7 @@ func TestNewInnerLoadedCommitQCsBelowAnchorSkipped(t *testing.T) { func TestNewInnerLoadedCommitQCsGapAfterAnchorReturnsError(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + committee, keys := types.GenCommittee(rng, 4) // Anchor at index 2. Loaded commitQCs are [2, 3, 5] — gap at 4. // After prune(2), next=3. Index 2 is skipped, 3 pushed (next=4), @@ -655,11 +654,11 @@ func TestNewInnerLoadedCommitQCsGapAfterAnchorReturnsError(t *testing.T) { qcs := make([]*types.CommitQC, 6) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(committee, keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } - appProposal := types.NewAppProposal(10, 2, types.GenAppHash(rng), 0) + appProposal := types.NewAppProposal(10, 2, types.GenAppHash(rng)) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) loadedQCs := []persist.LoadedCommitQC{ @@ -673,14 +672,14 @@ func TestNewInnerLoadedCommitQCsGapAfterAnchorReturnsError(t *testing.T) { commitQCs: loadedQCs, } - _, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + _, err := newInner(committee, utils.Some(loaded)) require.Error(t, err) require.Contains(t, err.Error(), "non-contiguous") } func TestNewInnerLoadedBlocksGapReturnsError(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + committee, keys := types.GenCommittee(rng, 4) lane := keys[0].Public() // Blocks 3, 4, 6, 7 with no anchor — queue starts at 0, so block 3 @@ -697,14 +696,14 @@ func TestNewInnerLoadedBlocksGapReturnsError(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - _, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + _, err := newInner(committee, utils.Some(loaded)) require.Error(t, err) require.Contains(t, err.Error(), "non-contiguous") } func TestNewInnerLoadedBlocksParentHashMismatchReturnsError(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + committee, keys := types.GenCommittee(rng, 4) lane := keys[0].Public() // Build blocks 0, 1 with correct chaining, then block 2 with wrong parent. @@ -725,14 +724,14 @@ func TestNewInnerLoadedBlocksParentHashMismatchReturnsError(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - _, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + _, err := newInner(committee, utils.Some(loaded)) require.Error(t, err) require.Contains(t, err.Error(), "parent hash mismatch") } func TestNewInnerLoadedBlocksOverCapacityReturnsError(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + committee, keys := types.GenCommittee(rng, 4) lane := keys[0].Public() // Build BlocksPerLane + 5 contiguous blocks — more than the lane capacity. @@ -751,26 +750,26 @@ func TestNewInnerLoadedBlocksOverCapacityReturnsError(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - _, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + _, err := newInner(committee, utils.Some(loaded)) require.Error(t, err) require.Contains(t, err.Error(), "exceeds capacity") } func TestNewInnerPruneAnchorPrunesBlockQueues(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) - initialBlock := types.GlobalBlockNumber(0) + committee, keys := types.GenCommittee(rng, 4) + initialBlock := committee.FirstBlock() // Build CommitQCs 0-2. qcs := make([]*types.CommitQC, 3) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(committee, keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } // AppQC at road index 2, prune anchor is CommitQC[2]. - appProposal := types.NewAppProposal(initialBlock, 2, types.GenAppHash(rng), 0) + appProposal := types.NewAppProposal(initialBlock, 2, types.GenAppHash(rng)) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) pruneQC := qcs[2] @@ -794,11 +793,11 @@ func TestNewInnerPruneAnchorPrunesBlockQueues(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - i, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + i, err := newInner(committee, utils.Some(loaded)) require.NoError(t, err) // prune() should advance block queue first to the prune anchor's lane range. - for l := range registry.LatestEpoch().Committee().Lanes().All() { + for l := range committee.Lanes().All() { expected := pruneQC.LaneRange(l).First() require.Equal(t, expected, i.blocks[l].first, "blocks[%v].first should be advanced by prune to prune anchor lane range", l) @@ -807,19 +806,19 @@ func TestNewInnerPruneAnchorPrunesBlockQueues(t *testing.T) { func TestNewInnerPruneAnchorCommitQCUsedForPrune(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) - initialBlock := types.GlobalBlockNumber(0) + committee, keys := types.GenCommittee(rng, 4) + initialBlock := committee.FirstBlock() // Build CommitQCs 0-2. qcs := make([]*types.CommitQC, 3) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(committee, keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } // AppQC at road index 1, prune anchor is CommitQC[1]. - appProposal := types.NewAppProposal(initialBlock, 1, types.GenAppHash(rng), 0) + appProposal := types.NewAppProposal(initialBlock, 1, types.GenAppHash(rng)) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) loaded := &loadedAvailState{ @@ -830,7 +829,7 @@ func TestNewInnerPruneAnchorCommitQCUsedForPrune(t *testing.T) { }, } - i, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + i, err := newInner(committee, utils.Some(loaded)) require.NoError(t, err) // prune(appQC@1, pruneQC@1) should advance commitQCs.first to 1. diff --git a/sei-tendermint/internal/autobahn/avail/metrics/metrics.gen.go b/sei-tendermint/internal/autobahn/avail/metrics/metrics.gen.go deleted file mode 100644 index 4aa8151859..0000000000 --- a/sei-tendermint/internal/autobahn/avail/metrics/metrics.gen.go +++ /dev/null @@ -1,87 +0,0 @@ -// Code generated by metricsgen. DO NOT EDIT. - -package metrics - -import ( - "github.com/prometheus/client_golang/prometheus" - tmprometheus "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/prometheus" -) - -var Global = newMetrics() - -func init() { - prometheus.MustRegister( - Global.commitRoadIndex, - Global.appRoadIndex, - Global.commitGlobalBlockNumber, - Global.appGlobalBlockNumber, - Global.proposalToCommitLatency, - Global.commitToCommitLatency, - ) -} - -func newMetrics() *metrics { - return &metrics{ - commitRoadIndex: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, - Subsystem: MetricsSubsystem, - Name: "commit_road_index", - Help: "Road index of the highest observed commitQC.", - }, nil), - appRoadIndex: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, - Subsystem: MetricsSubsystem, - Name: "app_road_index", - Help: "Road index of the highest observed appQC.", - }, nil), - commitGlobalBlockNumber: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, - Subsystem: MetricsSubsystem, - Name: "commit_global_block_number", - Help: "Global block number of the highest observed commitQC.", - }, nil), - appGlobalBlockNumber: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, - Subsystem: MetricsSubsystem, - Name: "app_global_block_number", - Help: "Global block number of the highest observed appQC.", - }, nil), - proposalToCommitLatency: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, - Subsystem: MetricsSubsystem, - Name: "proposal_to_commit_latency", - Help: "Latency from proposal being constructed to commit being observed.", - Buckets: prometheus.ExponentialBuckets(0.01, 1.2, 35), - }, nil), - commitToCommitLatency: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, - Subsystem: MetricsSubsystem, - Name: "commit_to_commit_latency", - Help: "Latency between consecutive commits being observed.", - }, []string{"timeouts"}), - } -} - -func (m *metrics) commitRoadIndexAt() *tmprometheus.GaugeInt { - return m.commitRoadIndex.WithLabelValues() -} - -func (m *metrics) appRoadIndexAt() *tmprometheus.GaugeInt { - return m.appRoadIndex.WithLabelValues() -} - -func (m *metrics) commitGlobalBlockNumberAt() *tmprometheus.GaugeInt { - return m.commitGlobalBlockNumber.WithLabelValues() -} - -func (m *metrics) appGlobalBlockNumberAt() *tmprometheus.GaugeInt { - return m.appGlobalBlockNumber.WithLabelValues() -} - -func (m *metrics) proposalToCommitLatencyAt() *tmprometheus.Histogram { - return m.proposalToCommitLatency.WithLabelValues() -} - -func (m *metrics) commitToCommitLatencyAt(timeouts string) *tmprometheus.Histogram { - return m.commitToCommitLatency.WithLabelValues(timeouts) -} diff --git a/sei-tendermint/internal/autobahn/avail/metrics/metrics.go b/sei-tendermint/internal/autobahn/avail/metrics/metrics.go deleted file mode 100644 index acfb7a6c6f..0000000000 --- a/sei-tendermint/internal/autobahn/avail/metrics/metrics.go +++ /dev/null @@ -1,78 +0,0 @@ -package metrics - -import ( - "strconv" - "time" - - "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" - "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" - "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/prometheus" -) - -const MetricsNamespace = "tendermint" -const MetricsSubsystem = "internal_autobahn_avail" - -//go:generate go run github.com/sei-protocol/sei-chain/sei-tendermint/scripts/metricsgen -struct=metrics -type metrics struct { - // Road index of the highest observed commitQC. - commitRoadIndex prometheus.GaugeIntVec - // Road index of the highest observed appQC. - appRoadIndex prometheus.GaugeIntVec - - // Global block number of the highest observed commitQC. - commitGlobalBlockNumber prometheus.GaugeIntVec - // Global block number of the highest observed appQC. - appGlobalBlockNumber prometheus.GaugeIntVec - - // Latency from proposal being constructed to commit being observed. - proposalToCommitLatency prometheus.HistogramVec `metrics_buckets:"exp(0.01, 1.2, 35)"` - // Latency between consecutive commits being observed. - commitToCommitLatency prometheus.HistogramVec `metrics_labels:"timeouts" metrics_buckets:"none"` -} - -type observed[T any] struct { - time time.Time - val T -} - -func newObserved[T any]() utils.Mutex[*utils.Option[observed[T]]] { - return utils.NewMutex(utils.Alloc(utils.None[observed[T]]())) -} - -var observedCommitQC = newObserved[*types.CommitQC]() -var observedAppQC = newObserved[*types.AppQC]() - -// ObserveCommitQC observes the CommitQC latency. -func ObserveCommitQC(qc *types.CommitQC) { - now := time.Now() - for mLast := range observedCommitQC.Lock() { - if last, ok := mLast.Get(); ok { - if last.val.Index() >= qc.Index() { - return - } - // "timeouts" label is capped - timeouts := "inf" - if n := qc.Proposal().View().Number; n < 20 { - timeouts = strconv.FormatUint(uint64(n), 10) - } - Global.commitToCommitLatencyAt(timeouts).Observe(now.Sub(last.time).Seconds()) - } - Global.proposalToCommitLatencyAt().Observe(now.Sub(qc.Proposal().Timestamp()).Seconds()) - Global.commitRoadIndexAt().Set(int64(qc.Index())) // nolint: gosec - Global.commitGlobalBlockNumberAt().Set(int64(qc.GlobalRange().Next)) // nolint: gosec - *mLast = utils.Some(observed[*types.CommitQC]{now, qc}) - } -} - -func ObserveAppQC(qc *types.AppQC) { - now := time.Now() - for mLast := range observedAppQC.Lock() { - if last, ok := mLast.Get(); ok && last.val.Proposal().GlobalNumber() >= qc.Proposal().GlobalNumber() { - return - } - Global.appRoadIndexAt().Set(int64(qc.Proposal().RoadIndex())) // nolint: gosec - // +1 is for consistency with commitGlobalBlockNumber - Global.appGlobalBlockNumberAt().Set(int64(qc.Proposal().GlobalNumber() + 1)) // nolint: gosec - *mLast = utils.Some(observed[*types.AppQC]{now, qc}) - } -} diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 913d6fbfb4..cf8e1948bd 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -7,7 +7,6 @@ import ( "log/slog" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/avail/metrics" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus/persist" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data" pb "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/pb" @@ -156,8 +155,7 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin return nil, err } - ep := data.Registry().LatestEpoch() - inner, err := newInner(ep, loaded) + inner, err := newInner(data.Committee(), loaded) if err != nil { return nil, err } @@ -166,7 +164,7 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin // loadPersistedState. if ls, ok := loaded.Get(); ok { if anchor, ok := ls.pruneAnchor.Get(); ok { - for lane := range ep.Committee().Lanes().All() { + for lane := range data.Committee().Lanes().All() { if err := pers.blocks.MaybePruneAndPersistLane(lane, utils.Some(anchor.CommitQC), nil, utils.None[func(*types.Signed[*types.LaneProposal])]()); err != nil { return nil, fmt.Errorf("prune stale block WAL entries: %w", err) } @@ -262,22 +260,14 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { return err } } - ep, ok := s.data.Registry().EpochByIndex(qc.Proposal().EpochIndex()) - if !ok { - return fmt.Errorf("unknown epoch_index %d", qc.Proposal().EpochIndex()) - } - if err := qc.Verify(ep); err != nil { + if err := qc.Verify(s.data.Committee()); err != nil { return fmt.Errorf("qc.Verify(): %w", err) } for inner, ctrl := range s.inner.Lock() { if idx != inner.commitQCs.next { return nil } - if qc.Proposal().EpochIndex() != inner.epoch.EpochIndex() { - return fmt.Errorf("commitQC epoch_index %d != current epoch %d", qc.Proposal().EpochIndex(), inner.epoch.EpochIndex()) - } inner.commitQCs.pushBack(qc) - metrics.ObserveCommitQC(qc) // The persist goroutine publishes latestCommitQC after writing to disk // (or immediately for no-op persisters), so consensus won't advance // until the CommitQC is durable. @@ -289,15 +279,10 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { // PushAppVote pushes an AppVote to the state. func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote]) error { - ep, ok := s.data.Registry().EpochByIndex(v.Msg().Proposal().EpochIndex()) - if !ok { - return fmt.Errorf("unknown epoch_index %d", v.Msg().Proposal().EpochIndex()) - } - committee := ep.Committee() - idx := v.Msg().Proposal().RoadIndex() - if err := v.VerifySig(committee); err != nil { + if err := v.VerifySig(s.data.Committee()); err != nil { return fmt.Errorf("v.VerifySig(): %w", err) } + idx := v.Msg().Proposal().RoadIndex() // Wait for the corresponding commitQC. if err := s.waitForCommitQC(ctx, idx); err != nil { return err @@ -309,7 +294,7 @@ func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote] } // Verify the vote against the CommitQC. qc := inner.commitQCs.q[idx] - if err := v.Msg().Proposal().Verify(qc); err != nil { + if err := v.Msg().Proposal().Verify(s.data.Committee(), qc); err != nil { return fmt.Errorf("invalid vote: %w", err) } // Push the vote. @@ -318,11 +303,11 @@ func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote] for q.next <= n { q.pushBack(newAppVotes()) } - appQC, ok := q.q[n].pushVote(committee, v) + appQC, ok := q.q[n].pushVote(s.data.Committee(), v) if !ok { return nil } - updated, err := inner.prune(committee, appQC, qc) + updated, err := inner.prune(s.data.Committee(), appQC, qc) if err != nil { return err } @@ -342,29 +327,23 @@ func (s *State) PushAppQC(appQC *types.AppQC, commitQC *types.CommitQC) error { return nil } } - ep, ok := s.data.Registry().EpochByIndex(commitQC.Proposal().EpochIndex()) - if !ok { - return fmt.Errorf("unknown epoch_index %d", commitQC.Proposal().EpochIndex()) - } - if err := appQC.Verify(ep.Committee()); err != nil { + c := s.data.Committee() + if err := appQC.Verify(c); err != nil { return fmt.Errorf("appQC.Verify(): %w", err) } - if err := commitQC.Verify(ep); err != nil { + if err := commitQC.Verify(c); err != nil { return fmt.Errorf("commitQC.Verify(): %w", err) } if appQC.Proposal().RoadIndex() != commitQC.Proposal().Index() { return fmt.Errorf("mismatched QCs: appQC index %v, commitQC index %v", appQC.Proposal().RoadIndex(), commitQC.Proposal().Index()) } - if got, want := appQC.Proposal().EpochIndex(), commitQC.Proposal().EpochIndex(); got != want { - return fmt.Errorf("appQC epoch_index %d != commitQC epoch_index %d", got, want) - } // Defense-in-depth check, it should never happen that >f validators sign // a proposal which does not match the commitQC's global range. - if !commitQC.GlobalRange().Has(appQC.Proposal().GlobalNumber()) { + if !commitQC.GlobalRange(c).Has(appQC.Proposal().GlobalNumber()) { return fmt.Errorf("appQC GlobalNumber not in commitQC range") } for inner, ctrl := range s.inner.Lock() { - updated, err := inner.prune(ep.Committee(), appQC, commitQC) + updated, err := inner.prune(s.data.Committee(), appQC, commitQC) if err != nil { return err } @@ -412,14 +391,12 @@ func (s *State) PushBlock(ctx context.Context, p *types.Signed[*types.LanePropos if p.Key() != h.Lane() { return fmt.Errorf("signer %v does not match lane %v", p.Key(), h.Lane()) } - if _, err := s.data.Registry().VerifyInWindow(func(c *types.Committee) error { - if err := p.Msg().Verify(c); err != nil { - return err - } - return p.VerifySig(c) - }); err != nil { + if err := p.Msg().Verify(s.data.Committee()); err != nil { return fmt.Errorf("block.Verify(): %w", err) } + if err := p.VerifySig(s.data.Committee()); err != nil { + return fmt.Errorf("p.VerifySig(): %w", err) + } for inner, ctrl := range s.inner.Lock() { q, ok := inner.blocks[h.Lane()] if !ok { @@ -464,13 +441,11 @@ func (s *State) PushBlock(ctx context.Context, p *types.Signed[*types.LanePropos // Waits until the lane has enough capacity for the new vote. // It does NOT wait for the previous votes. func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote]) error { - if _, err := s.data.Registry().VerifyInWindow(func(c *types.Committee) error { - if err := vote.Msg().Verify(c); err != nil { - return err - } - return vote.VerifySig(c) - }); err != nil { - return fmt.Errorf("vote.Verify(): %w", err) + if err := vote.Msg().Verify(s.data.Committee()); err != nil { + return fmt.Errorf("vote.Msg().Verify(): %w", err) + } + if err := vote.VerifySig(s.data.Committee()); err != nil { + return fmt.Errorf("vote.VerifySig(): %w", err) } h := vote.Msg().Header() for inner, ctrl := range s.inner.Lock() { @@ -489,7 +464,7 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote for q.next <= h.BlockNumber() { q.pushBack(newBlockVotes()) } - if _, ok := q.q[h.BlockNumber()].pushVote(inner.epoch, vote); ok { + if _, ok := q.q[h.BlockNumber()].pushVote(s.data.Committee(), vote); ok { ctrl.Updated() } } @@ -514,8 +489,8 @@ func (s *State) headers(ctx context.Context, lr *types.LaneRange) ([]*types.Bloc return nil, data.ErrPruned } // Check if we have the header. - if entry, ok := q.q[n].byHash[want]; ok { - h := entry.votes[0].Msg().Header() + if byHash, ok := q.q[n].byHash[want]; ok { + h := byHash.votes[0].Msg().Header() want = h.ParentHash() headers[len(headers)-i-1] = h break @@ -538,11 +513,7 @@ func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.Ful } // Collect the headers from the votes. var commitHeaders []*types.BlockHeader - ep, ok := s.data.Registry().EpochByIndex(qc.Proposal().EpochIndex()) - if !ok { - return nil, fmt.Errorf("unknown epoch_index %d", qc.Proposal().EpochIndex()) - } - for lane := range ep.Committee().Lanes().All() { + for lane := range s.data.Committee().Lanes().All() { headers, err := s.headers(ctx, qc.LaneRange(lane)) if err != nil { return nil, err @@ -565,18 +536,18 @@ func (s *State) WaitForLocalCapacity(ctx context.Context, toProduce types.BlockN return nil } -// WaitForLaneQCs waits until there is at least 1 LaneQC (for the given epoch) -// with a block not finalized by prev. +// WaitForLaneQCs waits until there is at least 1 LaneQC with a block not finalized by prev. func (s *State) WaitForLaneQCs( - ctx context.Context, ep *types.Epoch, prev utils.Option[*types.CommitQC], + ctx context.Context, prev utils.Option[*types.CommitQC], ) (map[types.LaneID]*types.LaneQC, error) { + c := s.data.Committee() for inner, ctrl := range s.inner.Lock() { laneQCs := map[types.LaneID]*types.LaneQC{} for { - for lane := range ep.Committee().Lanes().All() { + for lane := range c.Lanes().All() { first := types.LaneRangeOpt(prev, lane).Next() for i := range types.BlockNumber(types.MaxLaneRangeInProposal) { - if qc, ok := inner.laneQC(lane, first+i); ok { + if qc, ok := inner.laneQC(c, lane, first+i); ok { laneQCs[lane] = qc } else { break @@ -640,6 +611,7 @@ func (s *State) Run(ctx context.Context) error { }) // Task inserting FullCommitQCs and local blocks to data state. scope.SpawnNamed("s.data.PushQC", func() error { + c := s.data.Committee() for n := types.RoadIndex(0); ; n = max(n+1, s.FirstCommitQC()) { qc, err := s.fullCommitQC(ctx, n) if err != nil { @@ -650,11 +622,6 @@ func (s *State) Run(ctx context.Context) error { } // Collect the blocks we have locally. - ep, ok := s.data.Registry().EpochByIndex(qc.QC().Proposal().EpochIndex()) - if !ok { - return fmt.Errorf("unknown epoch_index %d", qc.QC().Proposal().EpochIndex()) - } - c := ep.Committee() var blocks []*types.Block for inner := range s.inner.Lock() { for lane := range c.Lanes().All() { @@ -717,7 +684,7 @@ func (s *State) runPersist(ctx context.Context, pers persisters) error { s.markBlockPersisted(header.Lane(), header.BlockNumber()+1) } - blocksByLane := make(map[types.LaneID][]*types.Signed[*types.LaneProposal]) + blocksByLane := make(map[types.LaneID][]*types.Signed[*types.LaneProposal], s.data.Committee().Lanes().Len()) for _, proposal := range batch.blocks { lane := proposal.Msg().Block().Header().Lane() blocksByLane[lane] = append(blocksByLane[lane], proposal) @@ -731,25 +698,7 @@ func (s *State) runPersist(ctx context.Context, pers persisters) error { s.markCommitQCsPersisted(qc) })) }) - // Collect lanes: any lane with blocks in this batch, plus all lanes - // in the anchor epoch (for WAL pruning). - // TODO: when epoch transitions land, also union in lanes from all - // epochs that appear in batch.commitQCs so new-epoch lanes are - // never skipped in a cross-epoch batch. - batchLanes := map[types.LaneID]struct{}{} - for lane := range blocksByLane { - batchLanes[lane] = struct{}{} - } - if anchor, ok := anchorQC.Get(); ok { - ep, epOK := s.data.Registry().EpochByIndex(anchor.Proposal().EpochIndex()) - if !epOK { - return fmt.Errorf("unknown epoch_index %d", anchor.Proposal().EpochIndex()) - } - for lane := range ep.Committee().Lanes().All() { - batchLanes[lane] = struct{}{} - } - } - for lane := range batchLanes { + for lane := range s.data.Committee().Lanes().All() { proposals := blocksByLane[lane] ps.Spawn(func() error { return pers.blocks.MaybePruneAndPersistLane(lane, anchorQC, proposals, utils.Some(markBlock)) diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 3558c4a5ad..4bb2d3e2fa 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -12,7 +12,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus/persist" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" pb "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/pb" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" @@ -54,13 +53,27 @@ func leaderKey(committee *types.Committee, keys []types.SecretKey, view types.Vi } func makeCommitQC( - ep *types.Epoch, + committee *types.Committee, keys []types.SecretKey, prev utils.Option[*types.CommitQC], laneQCs map[types.LaneID]*types.LaneQC, appQC utils.Option[*types.AppQC], ) *types.CommitQC { - return types.BuildCommitQC(ep, keys, prev, laneQCs, appQC) + vs := types.ViewSpec{CommitQC: prev} + fullProposal := utils.OrPanic1(types.NewProposal( + leaderKey(committee, keys, vs.View()), + committee, + vs, + time.Now(), + laneQCs, + appQC, + )) + vote := types.NewCommitVote(fullProposal.Proposal().Msg()) + var votes []*types.Signed[*types.CommitVote] + for _, k := range keys { + votes = append(votes, types.Sign(k, vote)) + } + return types.NewCommitQC(votes) } func qcPayloadHashes(qc *types.FullCommitQC) byLane[types.PayloadHash] { @@ -89,13 +102,12 @@ func testState(t *testing.T, stateDir utils.Option[string]) { t.Helper() ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) - committee := registry.LatestEpoch().Committee() + committee, keys := types.GenCommittee(rng, 3) if err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { ds := utils.OrPanic1(data.NewState(&data.Config{ - Registry: registry, - }, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + Committee: committee, + }, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) s.SpawnBgNamed("data.State.Run()", func() error { return utils.IgnoreCancel(ds.Run(ctx)) }) @@ -142,17 +154,17 @@ func testState(t *testing.T, stateDir utils.Option[string]) { } t.Logf("Push a commit QC.") - laneQCs, err := state.WaitForLaneQCs(ctx, registry.LatestEpoch(), prev) + laneQCs, err := state.WaitForLaneQCs(ctx, prev) if err != nil { return fmt.Errorf("state.WaitForNewLaneQCs(): %w", err) } - qc := makeCommitQC(registry.LatestEpoch(), keys, prev, laneQCs, state.LastAppQC()) + qc := makeCommitQC(committee, keys, prev, laneQCs, state.LastAppQC()) if err := state.PushCommitQC(ctx, qc); err != nil { return fmt.Errorf("state.PushCommitQC(): %w", err) } t.Logf("Push app votes.") - appProposal := types.NewAppProposal(qc.GlobalRange().Next-1, qc.Proposal().Index(), types.GenAppHash(rng), 0) + appProposal := types.NewAppProposal(qc.GlobalRange(committee).Next-1, qc.Proposal().Index(), types.GenAppHash(rng)) for _, vote := range makeAppVotes(keys, appProposal) { if err := state.PushAppVote(ctx, vote); err != nil { return fmt.Errorf("state.PushAppVote(): %w", err) @@ -188,7 +200,7 @@ func testState(t *testing.T, stateDir utils.Option[string]) { } t.Logf("Check that the blocks were successfully pushed to data state.") - gr := got.QC().GlobalRange() + gr := got.QC().GlobalRange(committee) for i := gr.First; i < gr.Next; i++ { b, err := ds.Block(ctx, i) if err != nil { @@ -216,8 +228,7 @@ func testState(t *testing.T, stateDir utils.Option[string]) { // loadPersistedState (stale entries below the prune anchor are discarded). func TestStateRestartFromPersisted(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) - committee := registry.LatestEpoch().Committee() + committee, keys := types.GenCommittee(rng, 3) dir := t.TempDir() // Phase 1: Run state with persistence through 2 iterations. @@ -225,7 +236,7 @@ func TestStateRestartFromPersisted(t *testing.T) { var wantNextBlocks map[types.LaneID]types.BlockNumber require.NoError(t, scope.Run(t.Context(), func(ctx context.Context, s scope.Scope) error { - ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) s.SpawnBgNamed("data.Run", func() error { return utils.IgnoreCancel(ds.Run(ctx)) }) @@ -263,16 +274,16 @@ func TestStateRestartFromPersisted(t *testing.T) { } } - laneQCs, err := state.WaitForLaneQCs(ctx, registry.LatestEpoch(), prev) + laneQCs, err := state.WaitForLaneQCs(ctx, prev) if err != nil { return fmt.Errorf("WaitForLaneQCs: %w", err) } - qc := makeCommitQC(registry.LatestEpoch(), keys, prev, laneQCs, state.LastAppQC()) + qc := makeCommitQC(committee, keys, prev, laneQCs, state.LastAppQC()) if err := state.PushCommitQC(ctx, qc); err != nil { return fmt.Errorf("PushCommitQC: %w", err) } - appProposal := types.NewAppProposal(qc.GlobalRange().Next-1, qc.Proposal().Index(), types.GenAppHash(rng), 0) + appProposal := types.NewAppProposal(qc.GlobalRange(committee).Next-1, qc.Proposal().Index(), types.GenAppHash(rng)) for _, vote := range makeAppVotes(keys, appProposal) { if err := state.PushAppVote(ctx, vote); err != nil { return fmt.Errorf("PushAppVote: %w", err) @@ -300,7 +311,7 @@ func TestStateRestartFromPersisted(t *testing.T) { })) // Phase 2: Restart from the same directory. - ds2 := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + ds2 := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) state2, err := NewState(keys[0], ds2, utils.Some(dir)) require.NoError(t, err) @@ -321,21 +332,21 @@ func TestStateRestartFromPersisted(t *testing.T) { func TestStateMismatchedQCs(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) - committee := registry.LatestEpoch().Committee() - initialBlock := registry.FirstBlock() + committee, keys := types.GenCommittee(rng, 4) + initialBlock := committee.FirstBlock() ds := utils.OrPanic1(data.NewState(&data.Config{ - Registry: registry, - }, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + Committee: committee, + }, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) state, err := NewState(keys[0], ds, utils.None[string]()) require.NoError(t, err) // Helper to create a CommitQC for a specific index makeQC := func(prev utils.Option[*types.CommitQC], laneQCs map[types.LaneID]*types.LaneQC) *types.CommitQC { - vs := types.ViewSpec{CommitQC: prev, Epoch: types.NewEpoch(0, types.OpenRoadRange(), time.Time{}, committee, initialBlock)} + vs := types.ViewSpec{CommitQC: prev} fullProposal := utils.OrPanic1(types.NewProposal( leaderKey(committee, keys, vs.View()), + committee, vs, time.Now(), laneQCs, @@ -363,13 +374,13 @@ func TestStateMismatchedQCs(t *testing.T) { // 3. Create CommitQC for index 0 (finalizes block 0) qc0 := makeQC(utils.None[*types.CommitQC](), map[types.LaneID]*types.LaneQC{lane: laneQC}) - require.Equal(t, initialBlock, qc0.GlobalRange().First) - require.Equal(t, initialBlock+1, qc0.GlobalRange().Next) + require.Equal(t, initialBlock, qc0.GlobalRange(committee).First) + require.Equal(t, initialBlock+1, qc0.GlobalRange(committee).Next) t.Run("PushAppQC mismatch", func(t *testing.T) { require := require.New(t) // AppQC for index 1, but paired with CommitQC for index 0 - appProposal1 := types.NewAppProposal(initialBlock, 1, types.GenAppHash(rng), 0) + appProposal1 := types.NewAppProposal(initialBlock, 1, types.GenAppHash(rng)) appQC1 := types.NewAppQC(makeAppVotes(keys, appProposal1)) err := state.PushAppQC(appQC1, qc0) @@ -380,11 +391,11 @@ func TestStateMismatchedQCs(t *testing.T) { func TestPushBlockRejectsBadParentHash(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) ds := utils.OrPanic1(data.NewState(&data.Config{ - Registry: registry, - }, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + Committee: committee, + }, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) state := utils.OrPanic1(NewState(keys[0], ds, utils.None[string]())) // Produce a valid first block on our lane. @@ -405,11 +416,11 @@ func TestPushBlockRejectsBadParentHash(t *testing.T) { func TestPushBlockRejectsWrongSigner(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) ds := utils.OrPanic1(data.NewState(&data.Config{ - Registry: registry, - }, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + Committee: committee, + }, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) state := utils.OrPanic1(NewState(keys[0], ds, utils.None[string]())) // Create a block on keys[0]'s lane but sign it with keys[1]. @@ -423,12 +434,12 @@ func TestPushBlockRejectsWrongSigner(t *testing.T) { func TestNewStateWithPersistence(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) - initialBlock := types.GlobalBlockNumber(0) + committee, keys := types.GenCommittee(rng, 4) + initialBlock := committee.FirstBlock() t.Run("empty dir loads fresh state", func(t *testing.T) { dir := t.TempDir() - ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) state, err := NewState(keys[0], ds, utils.Some(dir)) require.NoError(t, err) @@ -441,11 +452,11 @@ func TestNewStateWithPersistence(t *testing.T) { t.Run("loads persisted AppQC", func(t *testing.T) { dir := t.TempDir() - ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) roadIdx := types.RoadIndex(7) globalNum := types.GlobalBlockNumber(50) - appProposal := types.NewAppProposal(globalNum, roadIdx, types.GenAppHash(rng), 0) + appProposal := types.NewAppProposal(globalNum, roadIdx, types.GenAppHash(rng)) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) // Persist commitQCs 0-7 so the matching one at roadIdx exists. @@ -454,7 +465,7 @@ func TestNewStateWithPersistence(t *testing.T) { prev := utils.None[*types.CommitQC]() var pruneQC *types.CommitQC for i := types.RoadIndex(0); i <= roadIdx; i++ { - qc := makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qc := makeCommitQC(committee, keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qc) require.NoError(t, cp.MaybePruneAndPersist(utils.None[*types.CommitQC](), []*types.CommitQC{qc}, noCommitQCCB)) pruneQC = qc @@ -482,7 +493,7 @@ func TestNewStateWithPersistence(t *testing.T) { t.Run("loads persisted blocks", func(t *testing.T) { dir := t.TempDir() - ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) lane := keys[0].Public() // Persist blocks using BlockPersister. @@ -506,12 +517,12 @@ func TestNewStateWithPersistence(t *testing.T) { t.Run("loads persisted AppQC and blocks together", func(t *testing.T) { dir := t.TempDir() - ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) lane := keys[0].Public() roadIdx := types.RoadIndex(2) globalNum := types.GlobalBlockNumber(5) - appProposal := types.NewAppProposal(globalNum, roadIdx, types.GenAppHash(rng), 0) + appProposal := types.NewAppProposal(globalNum, roadIdx, types.GenAppHash(rng)) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) // Persist commitQCs 0-2 so the matching one at roadIdx exists. @@ -520,7 +531,7 @@ func TestNewStateWithPersistence(t *testing.T) { prev := utils.None[*types.CommitQC]() var pruneQC *types.CommitQC for range roadIdx + 1 { - qc := makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qc := makeCommitQC(committee, keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qc) require.NoError(t, cp.MaybePruneAndPersist(utils.None[*types.CommitQC](), []*types.CommitQC{qc}, noCommitQCCB)) pruneQC = qc @@ -559,7 +570,7 @@ func TestNewStateWithPersistence(t *testing.T) { t.Run("loads persisted commitQCs", func(t *testing.T) { dir := t.TempDir() - ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) // Persist CommitQCs to disk. cp, _, err := persist.NewCommitQCPersister(utils.Some(dir)) @@ -568,7 +579,7 @@ func TestNewStateWithPersistence(t *testing.T) { qcs := make([]*types.CommitQC, 3) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(committee, keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) require.NoError(t, cp.MaybePruneAndPersist(utils.None[*types.CommitQC](), []*types.CommitQC{qcs[i]}, noCommitQCCB)) } @@ -579,19 +590,17 @@ func TestNewStateWithPersistence(t *testing.T) { // All 3 commitQCs should be loaded (no AppQC to skip past). require.Equal(t, types.RoadIndex(0), state.FirstCommitQC()) // LastCommitQC should be set to the last loaded one. - got2, ok2 := state.LastCommitQC().Load().Get() - require.True(t, ok2) - require.NoError(t, utils.TestDiff(qcs[2], got2)) + require.NoError(t, utils.TestDiff(utils.Some(qcs[2]), state.LastCommitQC().Load())) }) t.Run("loads persisted commitQCs with AppQC", func(t *testing.T) { dir := t.TempDir() - ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) // Persist AppQC at road index 1. roadIdx := types.RoadIndex(1) globalNum := types.GlobalBlockNumber(5) - appProposal := types.NewAppProposal(globalNum, roadIdx, types.GenAppHash(rng), 0) + appProposal := types.NewAppProposal(globalNum, roadIdx, types.GenAppHash(rng)) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) // Persist CommitQCs 0-4. @@ -601,7 +610,7 @@ func TestNewStateWithPersistence(t *testing.T) { qcs := make([]*types.CommitQC, 5) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(committee, keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) require.NoError(t, cp.MaybePruneAndPersist(utils.None[*types.CommitQC](), []*types.CommitQC{qcs[i]}, noCommitQCCB)) } @@ -619,9 +628,7 @@ func TestNewStateWithPersistence(t *testing.T) { // inner.prune(appQC@1, commitQC@1) sets commitQCs.first = 1. require.Equal(t, types.RoadIndex(1), state.FirstCommitQC()) - got4, ok4 := state.LastCommitQC().Load().Get() - require.True(t, ok4) - require.NoError(t, utils.TestDiff(qcs[4], got4)) + require.NoError(t, utils.TestDiff(utils.Some(qcs[4]), state.LastCommitQC().Load())) }) t.Run("non-contiguous commitQC files return error", func(t *testing.T) { @@ -631,12 +638,12 @@ func TestNewStateWithPersistence(t *testing.T) { allQCs := make([]*types.CommitQC, 6) prev := utils.None[*types.CommitQC]() for i := range allQCs { - allQCs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + allQCs[i] = makeCommitQC(committee, keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(allQCs[i]) } // Persist prune anchor (AppQC + CommitQC pair at road index 0). - appProposal := types.NewAppProposal(initialBlock, 0, types.GenAppHash(rng), 0) + appProposal := types.NewAppProposal(initialBlock, 0, types.GenAppHash(rng)) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) prunePers, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](utils.Some(dir), innerFile) require.NoError(t, err) @@ -661,13 +668,13 @@ func TestNewStateWithPersistence(t *testing.T) { t.Run("anchor past all persisted commitQCs truncates WAL", func(t *testing.T) { dir := t.TempDir() - ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) // Build a chain of 10 CommitQCs (indices 0-9). qcs := make([]*types.CommitQC, 10) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(committee, keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } @@ -680,7 +687,7 @@ func TestNewStateWithPersistence(t *testing.T) { require.NoError(t, cp.Close()) // Persist a prune anchor at index 9 — well past the persisted range. - appProposal := types.NewAppProposal(50, 9, types.GenAppHash(rng), 0) + appProposal := types.NewAppProposal(50, 9, types.GenAppHash(rng)) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) prunePers, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](utils.Some(dir), innerFile) require.NoError(t, err) @@ -695,9 +702,7 @@ func TestNewStateWithPersistence(t *testing.T) { require.NoError(t, err) require.Equal(t, types.RoadIndex(9), state.FirstCommitQC()) - got9, ok9 := state.LastCommitQC().Load().Get() - require.True(t, ok9) - require.NoError(t, utils.TestDiff(qcs[9], got9)) + require.NoError(t, utils.TestDiff(utils.Some(qcs[9]), state.LastCommitQC().Load())) got, ok := state.LastAppQC().Get() require.True(t, ok) @@ -706,7 +711,7 @@ func TestNewStateWithPersistence(t *testing.T) { t.Run("anchor past all persisted blocks truncates lane WAL", func(t *testing.T) { dir := t.TempDir() - ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) lane := keys[0].Public() // Persist commitQCs 0-9 and blocks 0-2 for one lane. @@ -715,7 +720,7 @@ func TestNewStateWithPersistence(t *testing.T) { cp, _, err := persist.NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(committee, keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) require.NoError(t, cp.MaybePruneAndPersist(utils.None[*types.CommitQC](), []*types.CommitQC{qcs[i]}, noCommitQCCB)) } @@ -733,7 +738,7 @@ func TestNewStateWithPersistence(t *testing.T) { // Persist a prune anchor at index 9 with a laneRange that starts past // all persisted blocks — MaybePruneAndPersistLane will TruncateAll the block WAL. - appProposal := types.NewAppProposal(50, 9, types.GenAppHash(rng), 0) + appProposal := types.NewAppProposal(50, 9, types.GenAppHash(rng)) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) prunePers, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](utils.Some(dir), innerFile) require.NoError(t, err) @@ -754,7 +759,7 @@ func TestNewStateWithPersistence(t *testing.T) { t.Run("corrupt AppQC data returns error", func(t *testing.T) { dir := t.TempDir() - ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) // Create a throwaway persister to discover the A/B filenames, // then corrupt them so NewState fails on load. @@ -770,49 +775,3 @@ func TestNewStateWithPersistence(t *testing.T) { require.Error(t, err) }) } - -func TestWaitForLaneQCs_OnlyReturnsCommitteeLanes(t *testing.T) { - ctx := t.Context() - rng := utils.TestRng() - - committeeKey := types.GenSecretKey(rng) - committee := utils.OrPanic1(types.NewCommittee(map[types.PublicKey]uint64{ - committeeKey.Public(): 1, - })) - registry := utils.OrPanic1(epoch.NewRegistry(committee, 0, time.Time{})) - ep := registry.LatestEpoch() - - ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) - state, err := NewState(committeeKey, ds, utils.None[string]()) - require.NoError(t, err) - - require.NoError(t, scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { - s.SpawnBgNamed("data.Run", func() error { return utils.IgnoreCancel(ds.Run(ctx)) }) - s.SpawnBgNamed("avail.Run", func() error { return utils.IgnoreCancel(state.Run(ctx)) }) - - // Produce and vote on a block for the committee lane. - b, err := state.produceLocalBlock(0, committeeKey, types.GenPayload(rng)) - if err != nil { - return err - } - for _, vote := range makeLaneVotes([]types.SecretKey{committeeKey}, b.Msg().Block().Header()) { - if err := state.PushVote(ctx, vote); err != nil { - return err - } - } - - laneQCs, err := state.WaitForLaneQCs(ctx, ep, utils.None[*types.CommitQC]()) - if err != nil { - return err - } - for lane := range laneQCs { - if !ep.Committee().HasLane(lane) { - return fmt.Errorf("WaitForLaneQCs returned lane %v outside committee", lane) - } - } - if _, ok := laneQCs[committeeKey.Public()]; !ok { - return fmt.Errorf("WaitForLaneQCs missing committee lane") - } - return nil - })) -} diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index e551613386..6cc197cbbd 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -81,7 +81,6 @@ import ( "fmt" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/pb" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/seilog" @@ -94,19 +93,12 @@ const innerFile = "inner" type inner struct { persistedInner - epoch *types.Epoch -} - -// View returns the current view, embedding the epoch's index. -func (i inner) View() types.View { - vs := types.ViewSpec{CommitQC: i.CommitQC, TimeoutQC: i.TimeoutQC, Epoch: i.epoch} - return vs.View() } // newInner creates the inner state from persisted data loaded by NewPersister. // data is None on fresh start (persistence disabled or no prior state). // Returns error if persisted state is corrupt (see persistedInner.validate). -func newInner(data utils.Option[*pb.PersistedInner], registry *epoch.Registry) (inner, error) { +func newInner(data utils.Option[*pb.PersistedInner], committee *types.Committee) (inner, error) { var persisted persistedInner if p, ok := data.Get(); ok { @@ -117,25 +109,20 @@ func newInner(data utils.Option[*pb.PersistedInner], registry *epoch.Registry) ( persisted = *decoded } - // TODO: when AddEpoch is wired, resolve the epoch from the persisted QC/proposal - // rather than assuming LatestEpoch — otherwise a restart after an epoch transition - // fails validation with an epoch/road mismatch. - ep := registry.LatestEpoch() - if err := persisted.validate(ep); err != nil { + if err := persisted.validate(committee); err != nil { return inner{}, err } logger.Info("restored consensus state", "state", innerProtoConv.Encode(&persisted)) - return inner{persistedInner: persisted, epoch: ep}, nil + return inner{persistedInner: persisted}, nil } func (s *State) pushCommitQC(qc *types.CommitQC) error { - i := s.innerRecv.Load() - if qc.Proposal().Index() < i.View().Index { + if i := s.innerRecv.Load(); qc.Proposal().Index() < i.View().Index { return nil } - if err := qc.Verify(i.epoch); err != nil { + if err := qc.Verify(s.Data().Committee()); err != nil { return fmt.Errorf("qc.Verify(): %w", err) } for iSend := range s.inner.Lock() { @@ -143,9 +130,10 @@ func (s *State) pushCommitQC(qc *types.CommitQC) error { if qc.Proposal().Index() < i.View().Index { return nil } - // CommitQC advances to new index; clear all state for new view. - // TODO: rotate ep when epoch transitions are wired up. - iSend.Store(inner{persistedInner: persistedInner{CommitQC: utils.Some(qc)}, epoch: i.epoch}) + // CommitQC advances to new index; clear all state for new view + iSend.Store(inner{persistedInner{ + CommitQC: utils.Some(qc), + }}) } return nil } @@ -163,7 +151,7 @@ func (s *State) pushTimeoutQC(ctx context.Context, qc *types.TimeoutQC) error { return nil } // Verify checks the invariant: TimeoutQC.View().Index == CommitQC.Index + 1 - if err := qc.Verify(i.epoch, i.CommitQC); err != nil { + if err := qc.Verify(s.Data().Committee(), i.CommitQC); err != nil { return fmt.Errorf("qc.Verify(): %w", err) } for isend := range s.inner.Lock() { @@ -172,7 +160,10 @@ func (s *State) pushTimeoutQC(ctx context.Context, qc *types.TimeoutQC) error { return nil } // TimeoutQC advances view number; clear votes and prepareQC (stale view). - isend.Store(inner{persistedInner: persistedInner{CommitQC: i.CommitQC, TimeoutQC: utils.Some(qc)}, epoch: i.epoch}) + isend.Store(inner{persistedInner{ + CommitQC: i.CommitQC, + TimeoutQC: utils.Some(qc), + }}) } return nil } @@ -188,7 +179,7 @@ func (s *State) pushProposal(ctx context.Context, proposal *types.FullProposal) if vs.View() != proposal.View() { return nil } - if err := proposal.Verify(vs); err != nil { + if err := proposal.Verify(s.Data().Committee(), vs); err != nil { return fmt.Errorf("proposal.Verify(): %w", err) } // Update. @@ -214,7 +205,7 @@ func (s *State) pushPrepareQC(ctx context.Context, qc *types.PrepareQC) error { if vs.View() != qc.Proposal().View() { return nil } - if err := qc.Verify(vs.Epoch); err != nil { + if err := qc.Verify(s.Data().Committee()); err != nil { return fmt.Errorf("qc.Verify(): %w", err) } // Update. diff --git a/sei-tendermint/internal/autobahn/consensus/inner_test.go b/sei-tendermint/internal/autobahn/consensus/inner_test.go index 6625771c79..761e528d36 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner_test.go +++ b/sei-tendermint/internal/autobahn/consensus/inner_test.go @@ -9,7 +9,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus/persist" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/pb" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/protoutils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" @@ -29,12 +28,12 @@ func seedPersistedInner(dir string, state *persistedInner) { // loadInner is a test helper that loads persisted data and creates inner. // Mirrors what NewState does: NewPersister → newInner. -func loadInner(dir string, registry *epoch.Registry) (inner, error) { +func loadInner(dir string, committee *types.Committee) (inner, error) { _, data, err := persist.NewPersister[*pb.PersistedInner](utils.Some(dir), innerFile) if err != nil { return inner{}, err } - return newInner(data, registry) + return newInner(data, committee) } // makePrepareQC creates a PrepareQC with valid signatures from the given keys. @@ -48,9 +47,9 @@ func makePrepareQC(keys []types.SecretKey, proposal *types.Proposal) *types.Prep func TestNewInnerEmpty(t *testing.T) { rng := utils.TestRng() - registry, _ := epoch.GenRegistry(rng, 1) + committee, _ := types.GenCommittee(rng, 1) // No data should return empty inner (persistence disabled / fresh start) - i, err := newInner(utils.None[*pb.PersistedInner](), registry) + i, err := newInner(utils.None[*pb.PersistedInner](), committee) require.NoError(t, err) require.False(t, i.PrepareVote.IsPresent(), "prepareVote should be None") require.False(t, i.CommitVote.IsPresent(), "commitVote should be None") @@ -62,9 +61,9 @@ func TestNewInnerPrepareVote(t *testing.T) { dir := t.TempDir() // Create and persist a prepare vote at genesis view (0, 0) - registry, keys := epoch.GenRegistry(rng, 1) + committee, keys := types.GenCommittee(rng, 1) key := keys[0] - genesisProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0}) + genesisProposal := types.GenProposalAt(rng, types.View{Index: 0, Number: 0}) vote := types.Sign(key, types.NewPrepareVote(genesisProposal)) seedPersistedInner(dir, &persistedInner{ @@ -72,7 +71,7 @@ func TestNewInnerPrepareVote(t *testing.T) { }) // Load and verify - i, err := loadInner(dir, registry) + i, err := loadInner(dir, committee) require.NoError(t, err) loaded, ok := i.PrepareVote.Get() require.True(t, ok, "prepareVote should be Some") @@ -84,9 +83,9 @@ func TestNewInnerCommitVote(t *testing.T) { dir := t.TempDir() // Create and persist a commit vote at genesis view (0, 0) - registry, keys := epoch.GenRegistry(rng, 1) + committee, keys := types.GenCommittee(rng, 1) key := keys[0] - genesisProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0}) + genesisProposal := types.GenProposalAt(rng, types.View{Index: 0, Number: 0}) prepareQC := makePrepareQC([]types.SecretKey{key}, genesisProposal) vote := types.Sign(key, types.NewCommitVote(genesisProposal)) @@ -96,7 +95,7 @@ func TestNewInnerCommitVote(t *testing.T) { }) // Load and verify - i, err := loadInner(dir, registry) + i, err := loadInner(dir, committee) require.NoError(t, err) loaded, ok := i.CommitVote.Get() require.True(t, ok, "commitVote should be Some") @@ -108,7 +107,7 @@ func TestNewInnerTimeoutVote(t *testing.T) { dir := t.TempDir() // Create and persist a timeout vote at genesis view (0, 0) - registry, keys := epoch.GenRegistry(rng, 1) + committee, keys := types.GenCommittee(rng, 1) key := keys[0] vote := types.NewFullTimeoutVote(key, types.View{Index: 0, Number: 0}, utils.None[*types.PrepareQC]()) @@ -117,7 +116,7 @@ func TestNewInnerTimeoutVote(t *testing.T) { }) // Load and verify - i, err := loadInner(dir, registry) + i, err := loadInner(dir, committee) require.NoError(t, err) loaded, ok := i.TimeoutVote.Get() require.True(t, ok, "timeoutVote should be Some") @@ -129,9 +128,9 @@ func TestNewInnerAllVotes(t *testing.T) { dir := t.TempDir() // Create all vote types at genesis view (0, 0) - registry, keys := epoch.GenRegistry(rng, 1) + committee, keys := types.GenCommittee(rng, 1) key := keys[0] - genesisProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0}) + genesisProposal := types.GenProposalAt(rng, types.View{Index: 0, Number: 0}) prepareQC := makePrepareQC([]types.SecretKey{key}, genesisProposal) prepareVote := types.Sign(key, types.NewPrepareVote(genesisProposal)) commitVote := types.Sign(key, types.NewCommitVote(genesisProposal)) @@ -145,7 +144,7 @@ func TestNewInnerAllVotes(t *testing.T) { }) // Load and verify all - i, err := loadInner(dir, registry) + i, err := loadInner(dir, committee) require.NoError(t, err) require.True(t, i.PrepareVote.IsPresent(), "prepareVote should be Some") require.True(t, i.CommitVote.IsPresent(), "commitVote should be Some") @@ -157,9 +156,9 @@ func TestNewInnerPartialState(t *testing.T) { dir := t.TempDir() // Only persist prepareVote - registry, keys := epoch.GenRegistry(rng, 1) + committee, keys := types.GenCommittee(rng, 1) key := keys[0] - genesisProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0}) + genesisProposal := types.GenProposalAt(rng, types.View{Index: 0, Number: 0}) prepareVote := types.Sign(key, types.NewPrepareVote(genesisProposal)) seedPersistedInner(dir, &persistedInner{ @@ -167,7 +166,7 @@ func TestNewInnerPartialState(t *testing.T) { }) // Load - only prepareVote should be present - i, err := loadInner(dir, registry) + i, err := loadInner(dir, committee) require.NoError(t, err) require.True(t, i.PrepareVote.IsPresent(), "prepareVote should be Some") require.False(t, i.CommitVote.IsPresent(), "commitVote should be None") @@ -177,10 +176,10 @@ func TestNewInnerPartialState(t *testing.T) { func TestNewInnerCommitQC(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) // Create a CommitQC at index 5 - proposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + proposal := types.GenProposalAt(rng, types.View{Index: 5, Number: 0}) vote := types.NewCommitVote(proposal) var votes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -193,7 +192,7 @@ func TestNewInnerCommitQC(t *testing.T) { }) // Load and verify - i, err := loadInner(dir, registry) + i, err := loadInner(dir, committee) require.NoError(t, err) require.True(t, i.CommitQC.IsPresent(), "CommitQC should be loaded") loadedQC, ok := i.CommitQC.Get() @@ -206,10 +205,10 @@ func TestNewInnerCommitQC(t *testing.T) { func TestNewInnerTimeoutQC(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) // Create a CommitQC at index 5 (required for TimeoutQC at index 6) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalAt(rng, types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -230,7 +229,7 @@ func TestNewInnerTimeoutQC(t *testing.T) { }) // Load and verify - i, err := loadInner(dir, registry) + i, err := loadInner(dir, committee) require.NoError(t, err) require.True(t, i.TimeoutQC.IsPresent(), "TimeoutQC should be loaded") // View should be (6, 3) since TimeoutQC at (6, 2) advances to (6, 3) @@ -240,7 +239,7 @@ func TestNewInnerTimeoutQC(t *testing.T) { func TestNewInnerTimeoutQCOnlyGenesis(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) // Create TimeoutQC at (0, 2) - no CommitQC needed for index 0 var timeoutVotes []*types.FullTimeoutVote @@ -254,7 +253,7 @@ func TestNewInnerTimeoutQCOnlyGenesis(t *testing.T) { }) // Load and verify - should work without CommitQC since index is 0 - i, err := loadInner(dir, registry) + i, err := loadInner(dir, committee) require.NoError(t, err) require.True(t, i.TimeoutQC.IsPresent(), "TimeoutQC should be loaded") require.Equal(t, types.View{Index: 0, Number: 3}, i.View()) @@ -263,7 +262,7 @@ func TestNewInnerTimeoutQCOnlyGenesis(t *testing.T) { func TestNewInnerTimeoutQCWithoutCommitQCError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) // Create TimeoutQC at index 6 WITHOUT CommitQC at index 5 var timeoutVotes []*types.FullTimeoutVote @@ -277,7 +276,7 @@ func TestNewInnerTimeoutQCWithoutCommitQCError(t *testing.T) { }) // Should return error - TimeoutQC at index 6 requires CommitQC at index 5 - _, err := loadInner(dir, registry) + _, err := loadInner(dir, committee) require.Error(t, err) require.Contains(t, err.Error(), "corrupt persisted state") } @@ -285,10 +284,10 @@ func TestNewInnerTimeoutQCWithoutCommitQCError(t *testing.T) { func TestNewInnerTimeoutQCAheadOfCommitQCError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) // Create CommitQC at index 5 - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalAt(rng, types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -309,7 +308,7 @@ func TestNewInnerTimeoutQCAheadOfCommitQCError(t *testing.T) { }) // Should return error - TimeoutQC index must equal CommitQC.Index + 1 - _, err := loadInner(dir, registry) + _, err := loadInner(dir, committee) require.Error(t, err) require.Contains(t, err.Error(), "corrupt persisted state") } @@ -317,10 +316,10 @@ func TestNewInnerTimeoutQCAheadOfCommitQCError(t *testing.T) { func TestNewInnerViewSpecStaleTimeoutQC(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) // Create CommitQC at index 10 - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 10, Number: 0}) + qcProposal := types.GenProposalAt(rng, types.View{Index: 10, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -342,7 +341,7 @@ func TestNewInnerViewSpecStaleTimeoutQC(t *testing.T) { }) // Load - stale TimeoutQC should be treated as corrupt state - _, err := loadInner(dir, registry) + _, err := loadInner(dir, committee) require.Error(t, err) require.Contains(t, err.Error(), "corrupt persisted state") } @@ -350,10 +349,10 @@ func TestNewInnerViewSpecStaleTimeoutQC(t *testing.T) { func TestNewInnerViewSpecValidBothQCs(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) // Create CommitQC at index 5 - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalAt(rng, types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -374,7 +373,7 @@ func TestNewInnerViewSpecValidBothQCs(t *testing.T) { }) // Load - both should be present - i, err := loadInner(dir, registry) + i, err := loadInner(dir, committee) require.NoError(t, err) require.True(t, i.CommitQC.IsPresent(), "CommitQC should be loaded") require.True(t, i.TimeoutQC.IsPresent(), "TimeoutQC should be loaded") @@ -385,10 +384,10 @@ func TestNewInnerViewSpecValidBothQCs(t *testing.T) { func TestNewInnerStaleVoteError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalAt(rng, types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -398,7 +397,7 @@ func TestNewInnerStaleVoteError(t *testing.T) { // Create stale vote at view (3, 0) - before current view (6, 0). // Since inner is persisted atomically, a mismatched view is corrupt. - staleProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 3, Number: 0}) + staleProposal := types.GenProposalAt(rng, types.View{Index: 3, Number: 0}) staleVote := types.Sign(keys[0], types.NewPrepareVote(staleProposal)) seedPersistedInner(dir, &persistedInner{ @@ -406,7 +405,7 @@ func TestNewInnerStaleVoteError(t *testing.T) { PrepareVote: utils.Some(staleVote), }) - _, err := loadInner(dir, registry) + _, err := loadInner(dir, committee) require.Error(t, err) require.Contains(t, err.Error(), "corrupt persisted state") } @@ -414,10 +413,10 @@ func TestNewInnerStaleVoteError(t *testing.T) { func TestNewInnerFuturePrepareVoteError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalAt(rng, types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -426,7 +425,7 @@ func TestNewInnerFuturePrepareVoteError(t *testing.T) { commitQC := types.NewCommitQC(qcVotes) // Create future vote at view (10, 0) - ahead of current view (6, 0) - futureProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 10, Number: 0}) + futureProposal := types.GenProposalAt(rng, types.View{Index: 10, Number: 0}) futureVote := types.Sign(keys[0], types.NewPrepareVote(futureProposal)) seedPersistedInner(dir, &persistedInner{ @@ -435,7 +434,7 @@ func TestNewInnerFuturePrepareVoteError(t *testing.T) { }) // Should return error - future votes indicate corrupt state - _, err := loadInner(dir, registry) + _, err := loadInner(dir, committee) require.Error(t, err) require.Contains(t, err.Error(), "corrupt persisted state") } @@ -443,10 +442,10 @@ func TestNewInnerFuturePrepareVoteError(t *testing.T) { func TestNewInnerFutureCommitVoteError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalAt(rng, types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -455,7 +454,7 @@ func TestNewInnerFutureCommitVoteError(t *testing.T) { commitQC := types.NewCommitQC(qcVotes) // Create future commit vote at view (10, 0) - futureProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 10, Number: 0}) + futureProposal := types.GenProposalAt(rng, types.View{Index: 10, Number: 0}) futureVote := types.Sign(keys[0], types.NewCommitVote(futureProposal)) seedPersistedInner(dir, &persistedInner{ @@ -464,7 +463,7 @@ func TestNewInnerFutureCommitVoteError(t *testing.T) { }) // Should return error - _, err := loadInner(dir, registry) + _, err := loadInner(dir, committee) require.Error(t, err) require.Contains(t, err.Error(), "corrupt persisted state") } @@ -472,10 +471,10 @@ func TestNewInnerFutureCommitVoteError(t *testing.T) { func TestNewInnerFutureTimeoutVoteError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalAt(rng, types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -492,7 +491,7 @@ func TestNewInnerFutureTimeoutVoteError(t *testing.T) { }) // Should return error - _, err := loadInner(dir, registry) + _, err := loadInner(dir, committee) require.Error(t, err) require.Contains(t, err.Error(), "corrupt persisted state") } @@ -500,10 +499,10 @@ func TestNewInnerFutureTimeoutVoteError(t *testing.T) { func TestNewInnerCurrentViewVoteOk(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalAt(rng, types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -512,7 +511,7 @@ func TestNewInnerCurrentViewVoteOk(t *testing.T) { commitQC := types.NewCommitQC(qcVotes) // Create vote at exactly current view (6, 0) - currentProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 6, Number: 0}) + currentProposal := types.GenProposalAt(rng, types.View{Index: 6, Number: 0}) currentVote := types.Sign(keys[0], types.NewPrepareVote(currentProposal)) seedPersistedInner(dir, &persistedInner{ @@ -521,7 +520,7 @@ func TestNewInnerCurrentViewVoteOk(t *testing.T) { }) // Should succeed - current view votes are valid - i, err := loadInner(dir, registry) + i, err := loadInner(dir, committee) require.NoError(t, err) require.True(t, i.PrepareVote.IsPresent(), "current view vote should be loaded") } @@ -529,14 +528,14 @@ func TestNewInnerCurrentViewVoteOk(t *testing.T) { func TestNewInnerCommitQCInvalidSignatureError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, _ := epoch.GenRegistry(rng, 3) + committee, _ := types.GenCommittee(rng, 3) // Create CommitQC signed by keys NOT in committee otherKeys := make([]types.SecretKey, 3) for i := range otherKeys { otherKeys[i] = types.GenSecretKey(rng) } - proposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + proposal := types.GenProposalAt(rng, types.View{Index: 5, Number: 0}) vote := types.NewCommitVote(proposal) var votes []*types.Signed[*types.CommitVote] for _, k := range otherKeys { @@ -549,7 +548,7 @@ func TestNewInnerCommitQCInvalidSignatureError(t *testing.T) { }) // Should return error - invalid signatures - _, err := loadInner(dir, registry) + _, err := loadInner(dir, committee) require.Error(t, err) require.Contains(t, err.Error(), "corrupt persisted state") } @@ -557,10 +556,10 @@ func TestNewInnerCommitQCInvalidSignatureError(t *testing.T) { func TestNewInnerTimeoutQCInvalidSignatureError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) // Create valid CommitQC at index 5 - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalAt(rng, types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -585,7 +584,7 @@ func TestNewInnerTimeoutQCInvalidSignatureError(t *testing.T) { }) // Should return error - invalid signatures on TimeoutQC - _, err := loadInner(dir, registry) + _, err := loadInner(dir, committee) require.Error(t, err) require.Contains(t, err.Error(), "corrupt persisted state") } @@ -593,10 +592,10 @@ func TestNewInnerTimeoutQCInvalidSignatureError(t *testing.T) { func TestNewInnerCurrentViewVoteInvalidSignatureError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) // Create valid CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalAt(rng, types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -606,7 +605,7 @@ func TestNewInnerCurrentViewVoteInvalidSignatureError(t *testing.T) { // Create vote at current view (6, 0) but signed by key NOT in committee otherKey := types.GenSecretKey(rng) - currentProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 6, Number: 0}) + currentProposal := types.GenProposalAt(rng, types.View{Index: 6, Number: 0}) badVote := types.Sign(otherKey, types.NewPrepareVote(currentProposal)) seedPersistedInner(dir, &persistedInner{ @@ -615,7 +614,7 @@ func TestNewInnerCurrentViewVoteInvalidSignatureError(t *testing.T) { }) // Should return error - current view votes must have valid signatures - _, err := loadInner(dir, registry) + _, err := loadInner(dir, committee) require.Error(t, err) require.Contains(t, err.Error(), "corrupt persisted state") } @@ -623,10 +622,10 @@ func TestNewInnerCurrentViewVoteInvalidSignatureError(t *testing.T) { func TestNewInnerStaleVoteInvalidSignatureError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) // Create valid CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalAt(rng, types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -637,7 +636,7 @@ func TestNewInnerStaleVoteInvalidSignatureError(t *testing.T) { // Create stale vote at (3, 0) signed by key NOT in committee. // Since inner is persisted atomically, a mismatched view is corrupt. otherKey := types.GenSecretKey(rng) - staleProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 3, Number: 0}) + staleProposal := types.GenProposalAt(rng, types.View{Index: 3, Number: 0}) badVote := types.Sign(otherKey, types.NewPrepareVote(staleProposal)) seedPersistedInner(dir, &persistedInner{ @@ -645,7 +644,7 @@ func TestNewInnerStaleVoteInvalidSignatureError(t *testing.T) { PrepareVote: utils.Some(badVote), }) - _, err := loadInner(dir, registry) + _, err := loadInner(dir, committee) require.Error(t, err) require.Contains(t, err.Error(), "corrupt persisted state") } @@ -653,10 +652,10 @@ func TestNewInnerStaleVoteInvalidSignatureError(t *testing.T) { func TestNewInnerPrepareQC(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) // Create prepareQC at genesis view (0, 0) - proposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0}) + proposal := types.GenProposalAt(rng, types.View{Index: 0, Number: 0}) prepareQC := makePrepareQC(keys, proposal) seedPersistedInner(dir, &persistedInner{ @@ -664,7 +663,7 @@ func TestNewInnerPrepareQC(t *testing.T) { }) // Load and verify - i, err := loadInner(dir, registry) + i, err := loadInner(dir, committee) require.NoError(t, err) require.True(t, i.PrepareQC.IsPresent(), "prepareQC should be loaded") } @@ -672,10 +671,10 @@ func TestNewInnerPrepareQC(t *testing.T) { func TestNewInnerStalePrepareQCError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalAt(rng, types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -685,7 +684,7 @@ func TestNewInnerStalePrepareQCError(t *testing.T) { // Create stale prepareQC at view (3, 0) - before current view (6, 0). // Since inner is persisted atomically, a mismatched view is corrupt. - staleProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 3, Number: 0}) + staleProposal := types.GenProposalAt(rng, types.View{Index: 3, Number: 0}) stalePrepareQC := makePrepareQC(keys, staleProposal) seedPersistedInner(dir, &persistedInner{ @@ -693,7 +692,7 @@ func TestNewInnerStalePrepareQCError(t *testing.T) { PrepareQC: utils.Some(stalePrepareQC), }) - _, err := loadInner(dir, registry) + _, err := loadInner(dir, committee) require.Error(t, err) require.Contains(t, err.Error(), "corrupt persisted state") } @@ -701,18 +700,18 @@ func TestNewInnerStalePrepareQCError(t *testing.T) { func TestNewInnerCommitVoteWithoutPrepareQCError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) // Current view is (0, 0) (no CommitQC or TimeoutQC). // CommitVote requires PrepareQC justification. - proposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0}) + proposal := types.GenProposalAt(rng, types.View{Index: 0, Number: 0}) commitVote := types.Sign(keys[0], types.NewCommitVote(proposal)) seedPersistedInner(dir, &persistedInner{ CommitVote: utils.Some(commitVote), }) - _, err := loadInner(dir, registry) + _, err := loadInner(dir, committee) require.Error(t, err) require.Contains(t, err.Error(), "CommitVote present without PrepareQC") } @@ -720,10 +719,10 @@ func TestNewInnerCommitVoteWithoutPrepareQCError(t *testing.T) { func TestNewInnerFuturePrepareQCError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalAt(rng, types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -732,7 +731,7 @@ func TestNewInnerFuturePrepareQCError(t *testing.T) { commitQC := types.NewCommitQC(qcVotes) // Create future prepareQC at index 10 (> current 6) - futureProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 10, Number: 0}) + futureProposal := types.GenProposalAt(rng, types.View{Index: 10, Number: 0}) prepareQC := makePrepareQC(keys, futureProposal) seedPersistedInner(dir, &persistedInner{ @@ -741,7 +740,7 @@ func TestNewInnerFuturePrepareQCError(t *testing.T) { }) // Should return error - future prepareQC indicates corrupt state - _, err := loadInner(dir, registry) + _, err := loadInner(dir, committee) require.Error(t, err) require.Contains(t, err.Error(), "corrupt persisted state") } @@ -749,10 +748,10 @@ func TestNewInnerFuturePrepareQCError(t *testing.T) { func TestNewInnerCurrentViewPrepareQCOk(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalAt(rng, types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -761,7 +760,7 @@ func TestNewInnerCurrentViewPrepareQCOk(t *testing.T) { commitQC := types.NewCommitQC(qcVotes) // Create prepareQC at current view (6, 0) - currentProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 6, Number: 0}) + currentProposal := types.GenProposalAt(rng, types.View{Index: 6, Number: 0}) prepareQC := makePrepareQC(keys, currentProposal) seedPersistedInner(dir, &persistedInner{ @@ -770,7 +769,7 @@ func TestNewInnerCurrentViewPrepareQCOk(t *testing.T) { }) // Should succeed - current view prepareQC is valid - i, err := loadInner(dir, registry) + i, err := loadInner(dir, committee) require.NoError(t, err) require.True(t, i.PrepareQC.IsPresent(), "current view prepareQC should be loaded") } @@ -778,10 +777,10 @@ func TestNewInnerCurrentViewPrepareQCOk(t *testing.T) { func TestNewInnerCurrentViewPrepareQCInvalidSignatureError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalAt(rng, types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -794,7 +793,7 @@ func TestNewInnerCurrentViewPrepareQCInvalidSignatureError(t *testing.T) { for i := range otherKeys { otherKeys[i] = types.GenSecretKey(rng) } - currentProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 6, Number: 0}) + currentProposal := types.GenProposalAt(rng, types.View{Index: 6, Number: 0}) prepareQC := makePrepareQC(otherKeys, currentProposal) seedPersistedInner(dir, &persistedInner{ @@ -803,7 +802,7 @@ func TestNewInnerCurrentViewPrepareQCInvalidSignatureError(t *testing.T) { }) // Should return error - current view prepareQC has invalid signatures - _, err := loadInner(dir, registry) + _, err := loadInner(dir, committee) require.Error(t, err) require.Contains(t, err.Error(), "corrupt persisted state") } @@ -811,11 +810,11 @@ func TestNewInnerCurrentViewPrepareQCInvalidSignatureError(t *testing.T) { func TestNewInnerPrepareQCIncludedInTimeoutVote(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) voteKey := keys[0] // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalAt(rng, types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -824,7 +823,7 @@ func TestNewInnerPrepareQCIncludedInTimeoutVote(t *testing.T) { commitQC := types.NewCommitQC(qcVotes) // Create prepareQC at current view (6, 0) - currentProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 6, Number: 0}) + currentProposal := types.GenProposalAt(rng, types.View{Index: 6, Number: 0}) prepareQC := makePrepareQC(keys, currentProposal) seedPersistedInner(dir, &persistedInner{ @@ -833,7 +832,7 @@ func TestNewInnerPrepareQCIncludedInTimeoutVote(t *testing.T) { }) // Load state - i, err := loadInner(dir, registry) + i, err := loadInner(dir, committee) require.NoError(t, err) require.True(t, i.PrepareQC.IsPresent(), "prepareQC should be loaded") @@ -842,7 +841,7 @@ func TestNewInnerPrepareQCIncludedInTimeoutVote(t *testing.T) { timeoutVote := types.NewFullTimeoutVote(voteKey, currentView, i.PrepareQC) // The timeoutVote should pass verification (which checks prepareQC is correctly included) - err = timeoutVote.Verify(registry.LatestEpoch()) + err = timeoutVote.Verify(committee) require.NoError(t, err, "timeoutVote with loaded prepareQC should verify") // Verify the loaded prepareQC matches what we persisted @@ -856,10 +855,10 @@ func TestNewInnerPrepareQCIncludedInTimeoutVote(t *testing.T) { func TestPushTimeoutQCClearsStaleState(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) // Setup: Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalAt(rng, types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -868,7 +867,7 @@ func TestPushTimeoutQCClearsStaleState(t *testing.T) { commitQC := types.NewCommitQC(qcVotes) // Setup: Create prepareQC at current view (6, 0) - currentProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 6, Number: 0}) + currentProposal := types.GenProposalAt(rng, types.View{Index: 6, Number: 0}) prepareQC := makePrepareQC(keys, currentProposal) // Setup: Create votes at current view (6, 0) @@ -885,7 +884,7 @@ func TestPushTimeoutQCClearsStaleState(t *testing.T) { }) // Load initial state and verify everything is present - i, err := loadInner(dir, registry) + i, err := loadInner(dir, committee) require.NoError(t, err) require.True(t, i.PrepareQC.IsPresent(), "prepareQC should be loaded") require.True(t, i.PrepareVote.IsPresent(), "prepareVote should be loaded") @@ -901,7 +900,10 @@ func TestPushTimeoutQCClearsStaleState(t *testing.T) { timeoutQC := types.NewTimeoutQC(timeoutVotes) // Simulate pushTimeoutQC's Update callback - newInner := inner{persistedInner: persistedInner{CommitQC: i.CommitQC, TimeoutQC: utils.Some(timeoutQC)}, epoch: i.epoch} + newInner := inner{persistedInner{ + CommitQC: i.CommitQC, + TimeoutQC: utils.Some(timeoutQC), + }} // Verify: view advanced to (6, 1) require.Equal(t, types.View{Index: 6, Number: 1}, newInner.View(), "view should advance to (6, 1)") @@ -922,8 +924,8 @@ func TestRunOutputsPersistErrorPropagates(t *testing.T) { // Verify that a persist error in runOutputs propagates // and terminates the consensus component (instead of panicking). rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) - ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + committee, keys := types.GenCommittee(rng, 4) + ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) wantErr := errors.New("disk on fire") pers := utils.Some[persist.Persister[*pb.PersistedInner]](failPersister[*pb.PersistedInner]{err: wantErr}) diff --git a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go index a1a0ae5035..1bbcadeb4a 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go @@ -7,7 +7,6 @@ import ( "time" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" ) @@ -22,7 +21,7 @@ func testCommitQC( laneQCs map[types.LaneID]*types.LaneQC, appQC utils.Option[*types.AppQC], ) *types.CommitQC { - vs := types.ViewSpec{CommitQC: prev, Epoch: types.NewEpoch(0, types.OpenRoadRange(), time.Time{}, committee, 0)} + vs := types.ViewSpec{CommitQC: prev} leader := committee.Leader(vs.View()) var leaderKey types.SecretKey for _, k := range keys { @@ -33,6 +32,7 @@ func testCommitQC( } fullProposal := utils.OrPanic1(types.NewProposal( leaderKey, + committee, vs, time.Now(), laneQCs, @@ -106,8 +106,7 @@ func TestNewCommitQCPersisterEmptyDir(t *testing.T) { func TestPersistCommitQCAndLoad(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) - committee := registry.LatestEpoch().Committee() + committee, keys := types.GenCommittee(rng, 4) dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 3) @@ -135,8 +134,7 @@ func TestPersistCommitQCAndLoad(t *testing.T) { func TestCommitQCDeleteBeforeRemovesOldKeepsNew(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) - committee := registry.LatestEpoch().Committee() + committee, keys := types.GenCommittee(rng, 4) dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 5) @@ -158,8 +156,7 @@ func TestCommitQCDeleteBeforeRemovesOldKeepsNew(t *testing.T) { func TestCommitQCDeleteBeforeZero(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) - committee := registry.LatestEpoch().Committee() + committee, keys := types.GenCommittee(rng, 4) dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 3) @@ -186,8 +183,7 @@ func TestCommitQCDeleteBeforeZero(t *testing.T) { func TestCommitQCPersistDuplicateIsNoOp(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) - committee := registry.LatestEpoch().Committee() + committee, keys := types.GenCommittee(rng, 4) dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 3) @@ -204,8 +200,7 @@ func TestCommitQCPersistDuplicateIsNoOp(t *testing.T) { func TestCommitQCPersistGapRejected(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) - committee := registry.LatestEpoch().Committee() + committee, keys := types.GenCommittee(rng, 4) dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 5) @@ -223,8 +218,7 @@ func TestCommitQCPersistGapRejected(t *testing.T) { func TestLoadAllDetectsCommitQCGap(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) - committee := registry.LatestEpoch().Committee() + committee, keys := types.GenCommittee(rng, 4) dir := t.TempDir() // Build 3 sequential CommitQCs (indices 0, 1, 2). @@ -247,8 +241,7 @@ func TestLoadAllDetectsCommitQCGap(t *testing.T) { func TestNoOpCommitQCPersister(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) - committee := registry.LatestEpoch().Committee() + committee, keys := types.GenCommittee(rng, 4) qcs := makeSequentialCommitQCs(committee, keys, 11) // Fresh no-op persister: prune with anchor at index 0 (idx==0, @@ -281,8 +274,7 @@ func TestNoOpCommitQCPersister(t *testing.T) { func TestCommitQCDeleteBeforePastAll(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) - committee := registry.LatestEpoch().Committee() + committee, keys := types.GenCommittee(rng, 4) dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 12) @@ -313,8 +305,7 @@ func TestCommitQCDeleteBeforePastAll(t *testing.T) { // must re-establish the cursor so subsequent persists succeed. func TestCommitQCDeleteBeforePastAllCrashRecovery(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) - committee := registry.LatestEpoch().Committee() + committee, keys := types.GenCommittee(rng, 4) dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 12) @@ -357,8 +348,7 @@ func TestCommitQCDeleteBeforePastAllCrashRecovery(t *testing.T) { // re-establishes the cursor for subsequent writes. func TestCommitQCDeleteBeforeWithAnchorRecovers(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) - committee := registry.LatestEpoch().Committee() + committee, keys := types.GenCommittee(rng, 4) dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 5) @@ -396,8 +386,7 @@ func TestCommitQCDeleteBeforeWithAnchorRecovers(t *testing.T) { func TestCommitQCDeleteBeforeThenPersistMore(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) - committee := registry.LatestEpoch().Committee() + committee, keys := types.GenCommittee(rng, 4) dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 6) @@ -422,8 +411,7 @@ func TestCommitQCDeleteBeforeThenPersistMore(t *testing.T) { func TestCommitQCDeleteBeforeAlreadyPruned(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) - committee := registry.LatestEpoch().Committee() + committee, keys := types.GenCommittee(rng, 4) dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 5) @@ -451,8 +439,7 @@ func TestCommitQCDeleteBeforeAlreadyPruned(t *testing.T) { func TestCommitQCProgressiveDeleteBefore(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) - committee := registry.LatestEpoch().Committee() + committee, keys := types.GenCommittee(rng, 4) dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 8) diff --git a/sei-tendermint/internal/autobahn/consensus/persist/fullcommitqcs.go b/sei-tendermint/internal/autobahn/consensus/persist/fullcommitqcs.go index 13b44b0fd2..f4ff1df34e 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/fullcommitqcs.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/fullcommitqcs.go @@ -12,14 +12,14 @@ const fullCommitQCsDir = "fullcommitqcs" // fullCommitQCState is the mutable state protected by FullCommitQCPersister's mutex. type fullCommitQCState struct { - iw utils.Option[*indexedWAL[*types.FullCommitQC]] - firstBlock types.GlobalBlockNumber - next types.GlobalBlockNumber // next expected GlobalRange().First == last QC's GlobalRange().Next - loaded []*types.FullCommitQC + iw utils.Option[*indexedWAL[*types.FullCommitQC]] + committee *types.Committee + next types.GlobalBlockNumber // next expected GlobalRange().First == last QC's GlobalRange().Next + loaded []*types.FullCommitQC } func (s *fullCommitQCState) persistQC(qc *types.FullCommitQC) error { - gr := qc.QC().GlobalRange() + gr := qc.QC().GlobalRange(s.committee) if gr.First < s.next { return nil } @@ -51,7 +51,7 @@ func (s *fullCommitQCState) truncateBefore(n types.GlobalBlockNumber) error { // per prune call because pruning advances one block at a time while // each QC covers many blocks. if err := iw.TruncateWhile(func(entry *types.FullCommitQC) bool { - return entry.QC().GlobalRange().Next <= n + return entry.QC().GlobalRange(s.committee).Next <= n }); err != nil { return fmt.Errorf("truncate full commitqc WAL: %w", err) } @@ -69,10 +69,10 @@ type FullCommitQCPersister struct { // NewFullCommitQCPersister opens (or creates) a WAL in the fullcommitqcs/ // subdir and replays all persisted entries. Loaded QCs are available via // ConsumeLoaded. When stateDir is None, returns a no-op persister. -func NewFullCommitQCPersister(stateDir utils.Option[string], firstBlock types.GlobalBlockNumber) (*FullCommitQCPersister, error) { +func NewFullCommitQCPersister(stateDir utils.Option[string], committee *types.Committee) (*FullCommitQCPersister, error) { sd, ok := stateDir.Get() if !ok { - return &FullCommitQCPersister{state: utils.NewMutex(&fullCommitQCState{firstBlock: firstBlock, next: firstBlock})}, nil + return &FullCommitQCPersister{state: utils.NewMutex(&fullCommitQCState{committee: committee, next: committee.FirstBlock()})}, nil } dir := filepath.Join(sd, fullCommitQCsDir) iw, err := openIndexedWAL(dir, types.FullCommitQCConv) @@ -80,15 +80,14 @@ func NewFullCommitQCPersister(stateDir utils.Option[string], firstBlock types.Gl return nil, fmt.Errorf("open full commitqc WAL in %s: %w", dir, err) } - s := &fullCommitQCState{iw: utils.Some(iw), firstBlock: firstBlock, next: firstBlock} + s := &fullCommitQCState{iw: utils.Some(iw), committee: committee, next: committee.FirstBlock()} loaded, err := s.loadAll() if err != nil { _ = iw.Close() return nil, err } if len(loaded) > 0 { - s.firstBlock = loaded[0].QC().GlobalRange().First - s.next = loaded[len(loaded)-1].QC().GlobalRange().Next + s.next = loaded[len(loaded)-1].QC().GlobalRange(s.committee).Next } s.loaded = loaded return &FullCommitQCPersister{ @@ -106,13 +105,13 @@ func (gp *FullCommitQCPersister) Next() types.GlobalBlockNumber { } // LoadedFirst returns the first global block number of the first loaded QC, -// or firstBlock if empty. +// or committee.FirstBlock() if empty. func (gp *FullCommitQCPersister) LoadedFirst() types.GlobalBlockNumber { for s := range gp.state.Lock() { if len(s.loaded) > 0 { - return s.loaded[0].QC().GlobalRange().First + return s.loaded[0].QC().GlobalRange(s.committee).First } - return s.firstBlock + return s.committee.FirstBlock() } panic("unreachable") } diff --git a/sei-tendermint/internal/autobahn/consensus/persist/fullcommitqcs_test.go b/sei-tendermint/internal/autobahn/consensus/persist/fullcommitqcs_test.go index 5d21fa5046..8932bcab76 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/fullcommitqcs_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/fullcommitqcs_test.go @@ -2,55 +2,99 @@ package persist import ( "testing" + "time" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" ) func makeSequentialFullCommitQCs( rng utils.Rng, - registry *epoch.Registry, + committee *types.Committee, keys []types.SecretKey, n int, ) []*types.FullCommitQC { qcs := make([]*types.FullCommitQC, n) prev := utils.None[*types.CommitQC]() for i := range n { - vs := types.ViewSpec{CommitQC: prev, Epoch: registry.LatestEpoch()} - committee := vs.Epoch.Committee() - lane := committee.Lanes().At(rng.Intn(committee.Lanes().Len())) - b := types.NewBlock(lane, types.LaneRangeOpt(prev, lane).Next(), types.GenBlockHeaderHash(rng), types.GenPayload(rng)) - lv := types.NewLaneVote(b.Header()) - lvotes := make([]*types.Signed[*types.LaneVote], 0, len(keys)) + blocks := map[types.LaneID][]*types.Block{} + for range 3 { + lane := committee.Lanes().At(rng.Intn(committee.Lanes().Len())) + var b *types.Block + if bs := blocks[lane]; len(bs) > 0 { + parent := bs[len(bs)-1] + b = types.NewBlock(lane, parent.Header().Next(), parent.Header().Hash(), types.GenPayload(rng)) + } else { + b = types.NewBlock( + lane, + types.LaneRangeOpt(prev, lane).Next(), + types.GenBlockHeaderHash(rng), + types.GenPayload(rng), + ) + } + blocks[lane] = append(blocks[lane], b) + } + laneQCs := map[types.LaneID]*types.LaneQC{} + var headers []*types.BlockHeader + for lane := range committee.Lanes().All() { + if bs := blocks[lane]; len(bs) > 0 { + votes := make([]*types.Signed[*types.LaneVote], 0, len(keys)) + for _, k := range keys { + votes = append(votes, types.Sign(k, types.NewLaneVote(bs[len(bs)-1].Header()))) + } + laneQCs[lane] = types.NewLaneQC(votes) + for _, b := range bs { + headers = append(headers, b.Header()) + } + } + } + viewSpec := types.ViewSpec{CommitQC: prev} + leader := committee.Leader(viewSpec.View()) + var leaderKey types.SecretKey for _, k := range keys { - lvotes = append(lvotes, types.Sign(k, lv)) + if k.Public() == leader { + leaderKey = k + break + } } - laneQCs := map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(lvotes)} - cqc := types.BuildCommitQC(vs.Epoch, keys, prev, laneQCs, utils.None[*types.AppQC]()) - qcs[i] = types.NewFullCommitQC(cqc, []*types.BlockHeader{b.Header()}) - prev = utils.Some(qcs[i].QC()) + proposal := utils.OrPanic1(types.NewProposal( + leaderKey, + committee, + viewSpec, + time.Now(), + laneQCs, + utils.None[*types.AppQC](), + )) + votes := make([]*types.Signed[*types.CommitVote], 0, len(keys)) + for _, k := range keys { + votes = append(votes, types.Sign(k, types.NewCommitVote(proposal.Proposal().Msg()))) + } + cqc := types.NewCommitQC(votes) + qcs[i] = types.NewFullCommitQC(cqc, headers) + prev = utils.Some(cqc) } return qcs } func TestNewFullCommitQCPersisterEmptyDir(t *testing.T) { + rng := utils.TestRng() + committee, _ := types.GenCommittee(rng, 3) dir := t.TempDir() - gp, err := NewFullCommitQCPersister(utils.Some(dir), 0) + gp, err := NewFullCommitQCPersister(utils.Some(dir), committee) require.NoError(t, err) require.NotNil(t, gp) require.Equal(t, 0, len(gp.ConsumeLoaded())) - require.Equal(t, types.GlobalBlockNumber(0), gp.Next()) + require.Equal(t, committee.FirstBlock(), gp.Next()) require.NoError(t, gp.Close()) } func TestNewFullCommitQCPersisterNoop(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) - qcs := makeSequentialFullCommitQCs(rng, registry, keys, 5) + committee, keys := types.GenCommittee(rng, 3) + qcs := makeSequentialFullCommitQCs(rng, committee, keys, 5) - gp, err := NewFullCommitQCPersister(utils.None[string](), registry.FirstBlock()) + gp, err := NewFullCommitQCPersister(utils.None[string](), committee) require.NoError(t, err) require.NotNil(t, gp) require.Equal(t, 0, len(gp.ConsumeLoaded())) @@ -58,7 +102,7 @@ func TestNewFullCommitQCPersisterNoop(t *testing.T) { for _, qc := range qcs { require.NoError(t, gp.PersistQC(qc)) } - lastNext := qcs[len(qcs)-1].QC().GlobalRange().Next + lastNext := qcs[len(qcs)-1].QC().GlobalRange(committee).Next require.Equal(t, lastNext, gp.Next()) // Truncate past everything in no-op mode advances cursor. @@ -68,48 +112,27 @@ func TestNewFullCommitQCPersisterNoop(t *testing.T) { require.NoError(t, gp.Close()) } -func TestFullCommitQCPersisterFirstBlockFromWAL(t *testing.T) { - dir := t.TempDir() - rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) - qcs := makeSequentialFullCommitQCs(rng, registry, keys, 3) - wantFirst := qcs[0].QC().GlobalRange().First - - gp, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) - require.NoError(t, err) - for _, qc := range qcs { - require.NoError(t, gp.PersistQC(qc)) - } - require.NoError(t, gp.Close()) - - // Reopen with a wrong firstBlock — WAL should take precedence. - gp2, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()+999) - require.NoError(t, err) - require.Equal(t, wantFirst, gp2.LoadedFirst()) - require.NoError(t, gp2.Close()) -} - func TestFullCommitQCPersistAndReload(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) - qcs := makeSequentialFullCommitQCs(rng, registry, keys, 5) + committee, keys := types.GenCommittee(rng, 3) + qcs := makeSequentialFullCommitQCs(rng, committee, keys, 5) - gp, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) + gp, err := NewFullCommitQCPersister(utils.Some(dir), committee) require.NoError(t, err) for _, qc := range qcs { require.NoError(t, gp.PersistQC(qc)) } - lastNext := qcs[len(qcs)-1].QC().GlobalRange().Next + lastNext := qcs[len(qcs)-1].QC().GlobalRange(committee).Next require.Equal(t, lastNext, gp.Next()) require.NoError(t, gp.Close()) - gp2, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) + gp2, err := NewFullCommitQCPersister(utils.Some(dir), committee) require.NoError(t, err) loaded := gp2.ConsumeLoaded() require.Equal(t, len(qcs), len(loaded)) for i, lqc := range loaded { - require.Equal(t, qcs[i].QC().GlobalRange().First, lqc.QC().GlobalRange().First) + require.Equal(t, qcs[i].QC().GlobalRange(committee).First, lqc.QC().GlobalRange(committee).First) } require.Equal(t, lastNext, gp2.Next()) require.NoError(t, gp2.Close()) @@ -118,46 +141,46 @@ func TestFullCommitQCPersistAndReload(t *testing.T) { func TestFullCommitQCTruncateAndReload(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) - qcs := makeSequentialFullCommitQCs(rng, registry, keys, 5) + committee, keys := types.GenCommittee(rng, 3) + qcs := makeSequentialFullCommitQCs(rng, committee, keys, 5) - gp, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) + gp, err := NewFullCommitQCPersister(utils.Some(dir), committee) require.NoError(t, err) for _, qc := range qcs { require.NoError(t, gp.PersistQC(qc)) } // Truncate before the third QC's range start, which should remove // all QCs whose range is fully below that point. - truncPoint := qcs[2].QC().GlobalRange().First + truncPoint := qcs[2].QC().GlobalRange(committee).First require.NoError(t, gp.TruncateBefore(truncPoint)) require.NoError(t, gp.Close()) - gp2, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) + gp2, err := NewFullCommitQCPersister(utils.Some(dir), committee) require.NoError(t, err) loaded := gp2.ConsumeLoaded() // QCs 0 and 1 should be gone (their ranges are fully before truncPoint). // QC 2 should be the first one remaining. require.GreaterOrEqual(t, len(loaded), 1) - require.Equal(t, qcs[2].QC().GlobalRange().First, loaded[0].QC().GlobalRange().First) + require.Equal(t, qcs[2].QC().GlobalRange(committee).First, loaded[0].QC().GlobalRange(committee).First) require.NoError(t, gp2.Close()) } func TestFullCommitQCTruncateAll(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) - qcs := makeSequentialFullCommitQCs(rng, registry, keys, 3) + committee, keys := types.GenCommittee(rng, 3) + qcs := makeSequentialFullCommitQCs(rng, committee, keys, 3) - gp, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) + gp, err := NewFullCommitQCPersister(utils.Some(dir), committee) require.NoError(t, err) for _, qc := range qcs { require.NoError(t, gp.PersistQC(qc)) } - lastNext := qcs[len(qcs)-1].QC().GlobalRange().Next + lastNext := qcs[len(qcs)-1].QC().GlobalRange(committee).Next require.NoError(t, gp.TruncateBefore(lastNext+100)) require.NoError(t, gp.Close()) - gp2, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) + gp2, err := NewFullCommitQCPersister(utils.Some(dir), committee) require.NoError(t, err) require.Equal(t, 0, len(gp2.ConsumeLoaded())) require.NoError(t, gp2.Close()) @@ -166,24 +189,24 @@ func TestFullCommitQCTruncateAll(t *testing.T) { func TestFullCommitQCDuplicateIgnored(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) - qcs := makeSequentialFullCommitQCs(rng, registry, keys, 2) + committee, keys := types.GenCommittee(rng, 3) + qcs := makeSequentialFullCommitQCs(rng, committee, keys, 2) - gp, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) + gp, err := NewFullCommitQCPersister(utils.Some(dir), committee) require.NoError(t, err) require.NoError(t, gp.PersistQC(qcs[0])) require.NoError(t, gp.PersistQC(qcs[0])) // duplicate - require.Equal(t, qcs[0].QC().GlobalRange().Next, gp.Next()) + require.Equal(t, qcs[0].QC().GlobalRange(committee).Next, gp.Next()) require.NoError(t, gp.Close()) } func TestFullCommitQCGapError(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) - qcs := makeSequentialFullCommitQCs(rng, registry, keys, 3) + committee, keys := types.GenCommittee(rng, 3) + qcs := makeSequentialFullCommitQCs(rng, committee, keys, 3) - gp, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) + gp, err := NewFullCommitQCPersister(utils.Some(dir), committee) require.NoError(t, err) // Skip qcs[0] and try to persist qcs[1] directly. err = gp.PersistQC(qcs[1]) @@ -195,10 +218,10 @@ func TestFullCommitQCGapError(t *testing.T) { func TestFullCommitQCTruncateBeforeNoop(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) - qcs := makeSequentialFullCommitQCs(rng, registry, keys, 3) + committee, keys := types.GenCommittee(rng, 3) + qcs := makeSequentialFullCommitQCs(rng, committee, keys, 3) - gp, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) + gp, err := NewFullCommitQCPersister(utils.Some(dir), committee) require.NoError(t, err) for _, qc := range qcs { require.NoError(t, gp.PersistQC(qc)) @@ -207,7 +230,7 @@ func TestFullCommitQCTruncateBeforeNoop(t *testing.T) { require.NoError(t, gp.TruncateBefore(0)) require.NoError(t, gp.Close()) - gp2, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) + gp2, err := NewFullCommitQCPersister(utils.Some(dir), committee) require.NoError(t, err) require.Equal(t, 3, len(gp2.ConsumeLoaded())) require.NoError(t, gp2.Close()) @@ -216,26 +239,26 @@ func TestFullCommitQCTruncateBeforeNoop(t *testing.T) { func TestFullCommitQCContinueAfterReload(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) - qcs := makeSequentialFullCommitQCs(rng, registry, keys, 6) + committee, keys := types.GenCommittee(rng, 3) + qcs := makeSequentialFullCommitQCs(rng, committee, keys, 6) - gp, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) + gp, err := NewFullCommitQCPersister(utils.Some(dir), committee) require.NoError(t, err) for _, qc := range qcs[:3] { require.NoError(t, gp.PersistQC(qc)) } require.NoError(t, gp.Close()) - gp2, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) + gp2, err := NewFullCommitQCPersister(utils.Some(dir), committee) require.NoError(t, err) require.Equal(t, 3, len(gp2.ConsumeLoaded())) for _, qc := range qcs[3:] { require.NoError(t, gp2.PersistQC(qc)) } - require.Equal(t, qcs[5].QC().GlobalRange().Next, gp2.Next()) + require.Equal(t, qcs[5].QC().GlobalRange(committee).Next, gp2.Next()) require.NoError(t, gp2.Close()) - gp3, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) + gp3, err := NewFullCommitQCPersister(utils.Some(dir), committee) require.NoError(t, err) require.Equal(t, 6, len(gp3.ConsumeLoaded())) require.NoError(t, gp3.Close()) @@ -244,23 +267,23 @@ func TestFullCommitQCContinueAfterReload(t *testing.T) { func TestFullCommitQCTruncateMidRange(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) - qcs := makeSequentialFullCommitQCs(rng, registry, keys, 5) + committee, keys := types.GenCommittee(rng, 3) + qcs := makeSequentialFullCommitQCs(rng, committee, keys, 5) - gp, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) + gp, err := NewFullCommitQCPersister(utils.Some(dir), committee) require.NoError(t, err) for _, qc := range qcs { require.NoError(t, gp.PersistQC(qc)) } // Truncate at a point inside the first QC's range. // The first QC should be kept because its range extends past truncPoint. - gr0 := qcs[0].QC().GlobalRange() + gr0 := qcs[0].QC().GlobalRange(committee) if gr0.Len() > 1 { midPoint := gr0.First + types.GlobalBlockNumber(gr0.Len()/2) require.NoError(t, gp.TruncateBefore(midPoint)) require.NoError(t, gp.Close()) - gp2, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) + gp2, err := NewFullCommitQCPersister(utils.Some(dir), committee) require.NoError(t, err) require.Equal(t, 5, len(gp2.ConsumeLoaded())) require.NoError(t, gp2.Close()) diff --git a/sei-tendermint/internal/autobahn/consensus/persist/globalblocks.go b/sei-tendermint/internal/autobahn/consensus/persist/globalblocks.go index d366a13f28..9471994e3c 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/globalblocks.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/globalblocks.go @@ -45,10 +45,10 @@ func (loadedGlobalBlockCodec) Unmarshal(raw []byte) (LoadedGlobalBlock, error) { // globalBlockState is the mutable state protected by GlobalBlockPersister's mutex. type globalBlockState struct { - iw utils.Option[*indexedWAL[LoadedGlobalBlock]] - firstBlock types.GlobalBlockNumber - next types.GlobalBlockNumber - loaded []LoadedGlobalBlock + iw utils.Option[*indexedWAL[LoadedGlobalBlock]] + committee *types.Committee + next types.GlobalBlockNumber + loaded []LoadedGlobalBlock } func (s *globalBlockState) persistBlock(n types.GlobalBlockNumber, block *types.Block) error { @@ -105,10 +105,10 @@ type GlobalBlockPersister struct { // NewGlobalBlockPersister opens (or creates) a WAL in the globalblocks/ subdir // and replays all persisted entries. Loaded blocks are available via // ConsumeLoaded. When stateDir is None, returns a no-op persister. -func NewGlobalBlockPersister(stateDir utils.Option[string], firstBlock types.GlobalBlockNumber) (*GlobalBlockPersister, error) { +func NewGlobalBlockPersister(stateDir utils.Option[string], committee *types.Committee) (*GlobalBlockPersister, error) { sd, ok := stateDir.Get() if !ok { - return &GlobalBlockPersister{state: utils.NewMutex(&globalBlockState{firstBlock: firstBlock, next: firstBlock})}, nil + return &GlobalBlockPersister{state: utils.NewMutex(&globalBlockState{committee: committee, next: committee.FirstBlock()})}, nil } dir := filepath.Join(sd, globalBlocksDir) iw, err := openIndexedWAL(dir, loadedGlobalBlockCodec{}) @@ -116,7 +116,7 @@ func NewGlobalBlockPersister(stateDir utils.Option[string], firstBlock types.Glo return nil, fmt.Errorf("open global block WAL in %s: %w", dir, err) } - s := &globalBlockState{iw: utils.Some(iw), firstBlock: firstBlock, next: firstBlock} + s := &globalBlockState{iw: utils.Some(iw), committee: committee, next: committee.FirstBlock()} // TODO: avoid loading all blocks on startup; cache only the last N blocks // (e.g. 1000) in memory instead. loaded, err := s.loadAll() @@ -125,7 +125,6 @@ func NewGlobalBlockPersister(stateDir utils.Option[string], firstBlock types.Glo return nil, err } if len(loaded) > 0 { - s.firstBlock = loaded[0].Number s.next = loaded[len(loaded)-1].Number + 1 } s.loaded = loaded @@ -142,13 +141,13 @@ func (gp *GlobalBlockPersister) Next() types.GlobalBlockNumber { panic("unreachable") } -// LoadedFirst returns the first loaded block number, or firstBlock if empty. +// LoadedFirst returns the first loaded block number, or committee.FirstBlock() if empty. func (gp *GlobalBlockPersister) LoadedFirst() types.GlobalBlockNumber { for s := range gp.state.Lock() { if len(s.loaded) > 0 { return s.loaded[0].Number } - return s.firstBlock + return s.committee.FirstBlock() } panic("unreachable") } diff --git a/sei-tendermint/internal/autobahn/consensus/persist/globalblocks_test.go b/sei-tendermint/internal/autobahn/consensus/persist/globalblocks_test.go index 7a16453364..85e50feb7c 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/globalblocks_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/globalblocks_test.go @@ -4,7 +4,6 @@ import ( "testing" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" ) @@ -19,21 +18,25 @@ func makeGlobalBlocks(rng utils.Rng, n int) []*types.Block { func TestNewGlobalBlockPersisterEmptyDir(t *testing.T) { dir := t.TempDir() - gp, err := NewGlobalBlockPersister(utils.Some(dir), 0) + rng := utils.TestRng() + committee, _ := types.GenCommittee(rng, 3) + fb := committee.FirstBlock() + + gp, err := NewGlobalBlockPersister(utils.Some(dir), committee) require.NoError(t, err) require.NotNil(t, gp) require.Equal(t, 0, len(gp.ConsumeLoaded())) - require.Equal(t, types.GlobalBlockNumber(0), gp.Next()) + require.Equal(t, fb, gp.Next()) require.NoError(t, gp.Close()) } func TestNewGlobalBlockPersisterNoop(t *testing.T) { rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) - fb := types.GlobalBlockNumber(0) + committee, _ := types.GenCommittee(rng, 3) + fb := committee.FirstBlock() blocks := makeGlobalBlocks(rng, 5) - gp, err := NewGlobalBlockPersister(utils.None[string](), 0) + gp, err := NewGlobalBlockPersister(utils.None[string](), committee) require.NoError(t, err) require.NotNil(t, gp) require.Equal(t, 0, len(gp.ConsumeLoaded())) @@ -55,34 +58,14 @@ func TestNewGlobalBlockPersisterNoop(t *testing.T) { require.NoError(t, gp.Close()) } -func TestGlobalBlockPersisterFirstBlockFromWAL(t *testing.T) { - dir := t.TempDir() - rng := utils.TestRng() - const wantFirst = types.GlobalBlockNumber(10) - blocks := makeGlobalBlocks(rng, 3) - - gp, err := NewGlobalBlockPersister(utils.Some(dir), wantFirst) - require.NoError(t, err) - for i, b := range blocks { - require.NoError(t, gp.PersistBlock(wantFirst+types.GlobalBlockNumber(i), b)) - } - require.NoError(t, gp.Close()) - - // Reopen with a wrong firstBlock — WAL should take precedence. - gp2, err := NewGlobalBlockPersister(utils.Some(dir), wantFirst+999) - require.NoError(t, err) - require.Equal(t, wantFirst, gp2.LoadedFirst()) - require.NoError(t, gp2.Close()) -} - func TestGlobalBlockPersistAndReload(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) - fb := types.GlobalBlockNumber(0) + committee, _ := types.GenCommittee(rng, 3) + fb := committee.FirstBlock() blocks := makeGlobalBlocks(rng, 5) - gp, err := NewGlobalBlockPersister(utils.Some(dir), 0) + gp, err := NewGlobalBlockPersister(utils.Some(dir), committee) require.NoError(t, err) for i, b := range blocks { require.NoError(t, gp.PersistBlock(fb+types.GlobalBlockNumber(i), b)) @@ -90,7 +73,7 @@ func TestGlobalBlockPersistAndReload(t *testing.T) { require.Equal(t, fb+5, gp.Next()) require.NoError(t, gp.Close()) - gp2, err := NewGlobalBlockPersister(utils.Some(dir), 0) + gp2, err := NewGlobalBlockPersister(utils.Some(dir), committee) require.NoError(t, err) loaded := gp2.ConsumeLoaded() require.Equal(t, 5, len(loaded)) @@ -104,11 +87,11 @@ func TestGlobalBlockPersistAndReload(t *testing.T) { func TestGlobalBlockTruncateAndReload(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) - fb := types.GlobalBlockNumber(0) + committee, _ := types.GenCommittee(rng, 3) + fb := committee.FirstBlock() blocks := makeGlobalBlocks(rng, 10) - gp, err := NewGlobalBlockPersister(utils.Some(dir), 0) + gp, err := NewGlobalBlockPersister(utils.Some(dir), committee) require.NoError(t, err) for i, b := range blocks { require.NoError(t, gp.PersistBlock(fb+types.GlobalBlockNumber(i), b)) @@ -116,7 +99,7 @@ func TestGlobalBlockTruncateAndReload(t *testing.T) { require.NoError(t, gp.TruncateBefore(fb+5)) require.NoError(t, gp.Close()) - gp2, err := NewGlobalBlockPersister(utils.Some(dir), 0) + gp2, err := NewGlobalBlockPersister(utils.Some(dir), committee) require.NoError(t, err) loaded := gp2.ConsumeLoaded() require.Equal(t, 5, len(loaded)) @@ -129,11 +112,11 @@ func TestGlobalBlockTruncateAndReload(t *testing.T) { func TestGlobalBlockTruncateAll(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) - fb := types.GlobalBlockNumber(0) + committee, _ := types.GenCommittee(rng, 3) + fb := committee.FirstBlock() blocks := makeGlobalBlocks(rng, 5) - gp, err := NewGlobalBlockPersister(utils.Some(dir), 0) + gp, err := NewGlobalBlockPersister(utils.Some(dir), committee) require.NoError(t, err) for i, b := range blocks { require.NoError(t, gp.PersistBlock(fb+types.GlobalBlockNumber(i), b)) @@ -142,7 +125,7 @@ func TestGlobalBlockTruncateAll(t *testing.T) { require.Equal(t, fb+10, gp.Next()) require.NoError(t, gp.Close()) - gp2, err := NewGlobalBlockPersister(utils.Some(dir), 0) + gp2, err := NewGlobalBlockPersister(utils.Some(dir), committee) require.NoError(t, err) require.Equal(t, 0, len(gp2.ConsumeLoaded())) require.Equal(t, fb, gp2.Next()) @@ -152,11 +135,11 @@ func TestGlobalBlockTruncateAll(t *testing.T) { func TestGlobalBlockDuplicateIgnored(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) - fb := types.GlobalBlockNumber(0) + committee, _ := types.GenCommittee(rng, 3) + fb := committee.FirstBlock() block := types.GenBlock(rng) - gp, err := NewGlobalBlockPersister(utils.Some(dir), 0) + gp, err := NewGlobalBlockPersister(utils.Some(dir), committee) require.NoError(t, err) require.NoError(t, gp.PersistBlock(fb, block)) require.NoError(t, gp.PersistBlock(fb, block)) @@ -167,11 +150,11 @@ func TestGlobalBlockDuplicateIgnored(t *testing.T) { func TestGlobalBlockGapError(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) - fb := types.GlobalBlockNumber(0) + committee, _ := types.GenCommittee(rng, 3) + fb := committee.FirstBlock() block := types.GenBlock(rng) - gp, err := NewGlobalBlockPersister(utils.Some(dir), 0) + gp, err := NewGlobalBlockPersister(utils.Some(dir), committee) require.NoError(t, err) err = gp.PersistBlock(fb+2, block) require.Error(t, err) @@ -182,11 +165,11 @@ func TestGlobalBlockGapError(t *testing.T) { func TestGlobalBlockTruncateBeforeNoop(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) - fb := types.GlobalBlockNumber(0) + committee, _ := types.GenCommittee(rng, 3) + fb := committee.FirstBlock() blocks := makeGlobalBlocks(rng, 5) - gp, err := NewGlobalBlockPersister(utils.Some(dir), 0) + gp, err := NewGlobalBlockPersister(utils.Some(dir), committee) require.NoError(t, err) for i, b := range blocks { require.NoError(t, gp.PersistBlock(fb+types.GlobalBlockNumber(i), b)) @@ -195,7 +178,7 @@ func TestGlobalBlockTruncateBeforeNoop(t *testing.T) { require.Equal(t, fb+5, gp.Next()) require.NoError(t, gp.Close()) - gp2, err := NewGlobalBlockPersister(utils.Some(dir), 0) + gp2, err := NewGlobalBlockPersister(utils.Some(dir), committee) require.NoError(t, err) require.Equal(t, 5, len(gp2.ConsumeLoaded())) require.NoError(t, gp2.Close()) @@ -204,18 +187,18 @@ func TestGlobalBlockTruncateBeforeNoop(t *testing.T) { func TestGlobalBlockContinueAfterReload(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) - fb := types.GlobalBlockNumber(0) + committee, _ := types.GenCommittee(rng, 3) + fb := committee.FirstBlock() blocks := makeGlobalBlocks(rng, 10) - gp, err := NewGlobalBlockPersister(utils.Some(dir), 0) + gp, err := NewGlobalBlockPersister(utils.Some(dir), committee) require.NoError(t, err) for i := range 5 { require.NoError(t, gp.PersistBlock(fb+types.GlobalBlockNumber(i), blocks[i])) } require.NoError(t, gp.Close()) - gp2, err := NewGlobalBlockPersister(utils.Some(dir), 0) + gp2, err := NewGlobalBlockPersister(utils.Some(dir), committee) require.NoError(t, err) require.Equal(t, 5, len(gp2.ConsumeLoaded())) for i := 5; i < 10; i++ { @@ -224,7 +207,7 @@ func TestGlobalBlockContinueAfterReload(t *testing.T) { require.Equal(t, fb+10, gp2.Next()) require.NoError(t, gp2.Close()) - gp3, err := NewGlobalBlockPersister(utils.Some(dir), 0) + gp3, err := NewGlobalBlockPersister(utils.Some(dir), committee) require.NoError(t, err) require.Equal(t, 10, len(gp3.ConsumeLoaded())) require.NoError(t, gp3.Close()) @@ -233,12 +216,12 @@ func TestGlobalBlockContinueAfterReload(t *testing.T) { func TestGlobalBlockTruncateAfterMiddle(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) - fb := types.GlobalBlockNumber(0) + committee, _ := types.GenCommittee(rng, 3) + fb := committee.FirstBlock() blocks := makeGlobalBlocks(rng, 5) // First session: persist 5 blocks. - gp, err := NewGlobalBlockPersister(utils.Some(dir), 0) + gp, err := NewGlobalBlockPersister(utils.Some(dir), committee) require.NoError(t, err) for i, b := range blocks { require.NoError(t, gp.PersistBlock(fb+types.GlobalBlockNumber(i), b)) @@ -246,7 +229,7 @@ func TestGlobalBlockTruncateAfterMiddle(t *testing.T) { require.NoError(t, gp.Close()) // Second session: reload, then TruncateAfter trims WAL and loaded. - gp2, err := NewGlobalBlockPersister(utils.Some(dir), 0) + gp2, err := NewGlobalBlockPersister(utils.Some(dir), committee) require.NoError(t, err) require.NoError(t, gp2.TruncateAfter(fb+3)) require.Equal(t, fb+3, gp2.Next()) @@ -257,7 +240,7 @@ func TestGlobalBlockTruncateAfterMiddle(t *testing.T) { require.NoError(t, gp2.Close()) // Third session: verify WAL persistence. - gp3, err := NewGlobalBlockPersister(utils.Some(dir), 0) + gp3, err := NewGlobalBlockPersister(utils.Some(dir), committee) require.NoError(t, err) loaded = gp3.ConsumeLoaded() require.Equal(t, 3, len(loaded)) @@ -269,11 +252,11 @@ func TestGlobalBlockTruncateAfterMiddle(t *testing.T) { func TestGlobalBlockTruncateAfterNoop(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) - fb := types.GlobalBlockNumber(0) + committee, _ := types.GenCommittee(rng, 3) + fb := committee.FirstBlock() blocks := makeGlobalBlocks(rng, 3) - gp, err := NewGlobalBlockPersister(utils.Some(dir), 0) + gp, err := NewGlobalBlockPersister(utils.Some(dir), committee) require.NoError(t, err) for i, b := range blocks { require.NoError(t, gp.PersistBlock(fb+types.GlobalBlockNumber(i), b)) @@ -287,11 +270,11 @@ func TestGlobalBlockTruncateAfterNoop(t *testing.T) { func TestGlobalBlockTruncateAfterBeforeFirst(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) - fb := types.GlobalBlockNumber(0) + committee, _ := types.GenCommittee(rng, 3) + fb := committee.FirstBlock() blocks := makeGlobalBlocks(rng, 5) - gp, err := NewGlobalBlockPersister(utils.Some(dir), 0) + gp, err := NewGlobalBlockPersister(utils.Some(dir), committee) require.NoError(t, err) for i, b := range blocks { require.NoError(t, gp.PersistBlock(fb+types.GlobalBlockNumber(i), b)) @@ -306,7 +289,7 @@ func TestGlobalBlockTruncateAfterBeforeFirst(t *testing.T) { require.NoError(t, gp.Close()) // Reload — should be empty. - gp2, err := NewGlobalBlockPersister(utils.Some(dir), 0) + gp2, err := NewGlobalBlockPersister(utils.Some(dir), committee) require.NoError(t, err) require.Equal(t, 0, len(gp2.ConsumeLoaded())) require.NoError(t, gp2.Close()) diff --git a/sei-tendermint/internal/autobahn/consensus/persisted_inner.go b/sei-tendermint/internal/autobahn/consensus/persisted_inner.go index 46e947a672..8a05a04714 100644 --- a/sei-tendermint/internal/autobahn/consensus/persisted_inner.go +++ b/sei-tendermint/internal/autobahn/consensus/persisted_inner.go @@ -69,11 +69,21 @@ type persistedInner struct { TimeoutVote utils.Option[*types.FullTimeoutVote] } +// View returns the current view based on CommitQC and TimeoutQC. +// Delegates to types.ViewSpec.View() for a single source of truth. +func (p *persistedInner) View() types.View { + vs := types.ViewSpec{ + CommitQC: p.CommitQC, + TimeoutQC: p.TimeoutQC, + } + return vs.View() +} + // validate checks internal consistency and cryptographic signatures of persisted state. // Returns error on corrupt state. -func (p *persistedInner) validate(ep *types.Epoch) error { +func (p *persistedInner) validate(committee *types.Committee) error { if cqc, ok := p.CommitQC.Get(); ok { - if err := cqc.Verify(ep); err != nil { + if err := cqc.Verify(committee); err != nil { return fmt.Errorf("corrupt persisted state: CommitQC failed verification: %w", err) } } @@ -86,14 +96,12 @@ func (p *persistedInner) validate(ep *types.Epoch) error { if tqcIndex != expectedIndex { return fmt.Errorf("corrupt persisted state: TimeoutQC has index %d but expected %d", tqcIndex, expectedIndex) } - if err := tqc.Verify(ep, p.CommitQC); err != nil { + if err := tqc.Verify(committee, p.CommitQC); err != nil { return fmt.Errorf("corrupt persisted state: TimeoutQC failed verification: %w", err) } } - vs := types.ViewSpec{CommitQC: p.CommitQC, TimeoutQC: p.TimeoutQC, Epoch: ep} - currentView := vs.View() - committee := ep.Committee() + currentView := p.View() // checkViewAndSig validates that a persisted field has the current view and a valid signature. // Since inner is persisted atomically, any view mismatch indicates corrupt state. @@ -109,7 +117,7 @@ func (p *persistedInner) validate(ep *types.Epoch) error { // PrepareQC is required when CommitVote is present (CommitVote requires PrepareQC justification). if pqc, ok := p.PrepareQC.Get(); ok { - if err := checkViewAndSig("PrepareQC", pqc.Proposal().View(), pqc.Verify(ep)); err != nil { + if err := checkViewAndSig("PrepareQC", pqc.Proposal().View(), pqc.Verify(committee)); err != nil { return err } } else if p.CommitVote.IsPresent() { @@ -126,7 +134,7 @@ func (p *persistedInner) validate(ep *types.Epoch) error { } } if v, ok := p.TimeoutVote.Get(); ok { - if err := checkViewAndSig("TimeoutVote", v.View(), v.Verify(ep)); err != nil { + if err := checkViewAndSig("TimeoutVote", v.View(), v.Verify(committee)); err != nil { return err } } diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index 583ec1f270..75374a5ba4 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -100,7 +100,7 @@ func newState( pers utils.Option[persist.Persister[*pb.PersistedInner]], persistedData utils.Option[*pb.PersistedInner], ) (*State, error) { - initialInner, err := newInner(persistedData, data.Registry()) + initialInner, err := newInner(persistedData, data.Committee()) if err != nil { return nil, fmt.Errorf("newInner: %w", err) } @@ -123,7 +123,7 @@ func newState( prepareVotes: utils.NewMutex(newPrepareVotes()), commitVotes: utils.NewMutex(newCommitVotes()), - myView: utils.NewAtomicSend(types.ViewSpec{Epoch: initialInner.epoch}), + myView: utils.NewAtomicSend(types.ViewSpec{}), myProposal: utils.NewAtomicSend(utils.None[*types.FullProposal]()), myPrepareVote: utils.NewAtomicSend(utils.None[*types.ConsensusReqPrepareVote]()), myCommitVote: utils.NewAtomicSend(utils.None[*types.ConsensusReqCommitVote]()), @@ -164,41 +164,35 @@ func (s *State) PushTimeoutQC(ctx context.Context, qc *types.TimeoutQC) error { return s.pushTimeoutQC(ctx, qc) } -// TODO: scope prepareVotes, commitVotes, and timeoutVotes to a single epoch -// so stale votes from a previous epoch are automatically dropped on transition. - // PushPrepareVote processes an unverified Prepare vote message. func (s *State) PushPrepareVote(vote *types.Signed[*types.PrepareVote]) error { - committee := s.myView.Load().Epoch.Committee() - if err := vote.VerifySig(committee); err != nil { + if err := vote.VerifySig(s.Data().Committee()); err != nil { return fmt.Errorf("vote.VerifySig(): %w", err) } for pv := range s.prepareVotes.Lock() { - pv.pushVote(committee, vote) + pv.pushVote(s.Data().Committee(), vote) } return nil } // PushCommitVote processes an unverified CommitVote message. func (s *State) PushCommitVote(vote *types.Signed[*types.CommitVote]) error { - committee := s.myView.Load().Epoch.Committee() - if err := vote.VerifySig(committee); err != nil { + if err := vote.VerifySig(s.Data().Committee()); err != nil { return fmt.Errorf("vote.VerifySig(): %w", err) } for cv := range s.commitVotes.Lock() { - cv.pushVote(committee, vote) + cv.pushVote(s.Data().Committee(), vote) } return nil } // PushTimeoutVote processes an unverified FullTimeoutVote message. func (s *State) PushTimeoutVote(vote *types.FullTimeoutVote) error { - ep := s.myView.Load().Epoch - if err := vote.Verify(ep); err != nil { + if err := vote.Verify(s.Data().Committee()); err != nil { return fmt.Errorf("vote.Verify(): %w", err) } for tv := range s.timeoutVotes.Lock() { - tv.pushVote(ep.Committee(), vote) + tv.pushVote(s.Data().Committee(), vote) } return nil } @@ -209,8 +203,9 @@ func (s *State) Avail() *avail.State { return s.avail } // Constructs new proposals. func (s *State) runPropose(ctx context.Context) error { + committee := s.Data().Committee() return s.myView.Iter(ctx, func(ctx context.Context, vs types.ViewSpec) error { - if vs.Epoch.Committee().Leader(vs.View()) != s.cfg.Key.Public() { + if committee.Leader(vs.View()) != s.cfg.Key.Public() { return nil // not the leader. } // Try repropose. @@ -219,13 +214,14 @@ func (s *State) runPropose(ctx context.Context) error { return nil } // Wait for laneQCs. - laneQCsMap, err := s.avail.WaitForLaneQCs(ctx, vs.Epoch, vs.CommitQC) + laneQCsMap, err := s.avail.WaitForLaneQCs(ctx, vs.CommitQC) if err != nil { return fmt.Errorf("s.avail.WaitForLaneQCs(): %w", err) } // Construct a full proposal. fullProposal, err := types.NewProposal( s.cfg.Key, + committee, vs, time.Now(), laneQCsMap, @@ -253,7 +249,7 @@ func updateOutput[T types.ConsensusReq](w *utils.AtomicSend[utils.Option[T]], v // timers, neither of which constitutes a vote. func (s *State) runOutputs(ctx context.Context) error { return s.innerRecv.Iter(ctx, func(ctx context.Context, i inner) error { - vs := types.ViewSpec{CommitQC: i.CommitQC, TimeoutQC: i.TimeoutQC, Epoch: i.epoch} + vs := types.ViewSpec{CommitQC: i.CommitQC, TimeoutQC: i.TimeoutQC} old := s.myView.Load() if old.View().Less(vs.View()) { s.myView.Store(vs) diff --git a/sei-tendermint/internal/autobahn/consensus/state_test.go b/sei-tendermint/internal/autobahn/consensus/state_test.go index a4ede7d420..43cdb6ef61 100644 --- a/sei-tendermint/internal/autobahn/consensus/state_test.go +++ b/sei-tendermint/internal/autobahn/consensus/state_test.go @@ -8,7 +8,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" @@ -17,18 +16,18 @@ import ( // newTestState creates a State for testing with no persistence and a long // view timeout (so voteTimeout is only triggered explicitly). // keys[0] is used as the node's signing key. -func newTestState(rng utils.Rng) (*State, []types.SecretKey, *epoch.Registry) { - registry, keys := epoch.GenRegistry(rng, 3) +func newTestState(rng utils.Rng) (*State, []types.SecretKey) { + committee, keys := types.GenCommittee(rng, 3) dataState := utils.OrPanic1(data.NewState( - &data.Config{Registry: registry}, - utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())), + &data.Config{Committee: committee}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)), )) s := utils.OrPanic1(NewState(&Config{ Key: keys[0], ViewTimeout: func(types.View) time.Duration { return time.Hour }, PersistentStateDir: utils.None[string](), }, dataState)) - return s, keys, registry + return s, keys } // makeTimeoutQC creates a TimeoutQC at the given view where all keys @@ -59,7 +58,7 @@ func testTimeoutVotePrepareQC(tv *types.FullTimeoutVote) utils.Option[*types.Pre func TestVoteTimeoutPrepareQC_BothNone(t *testing.T) { rng := utils.TestRng() - s, _, _ := newTestState(rng) + s, _ := newTestState(rng) err := scope.Run(t.Context(), func(ctx context.Context, sc scope.Scope) error { sc.SpawnBg(func() error { return utils.IgnoreCancel(s.Run(ctx)) }) @@ -81,12 +80,12 @@ func TestVoteTimeoutPrepareQC_BothNone(t *testing.T) { func TestVoteTimeoutPrepareQC_OnlyCurrentView(t *testing.T) { rng := utils.TestRng() - s, keys, registry := newTestState(rng) + s, keys := newTestState(rng) err := scope.Run(t.Context(), func(ctx context.Context, sc scope.Scope) error { sc.SpawnBg(func() error { return utils.IgnoreCancel(s.Run(ctx)) }) - pqc := makePrepareQC(keys, types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0})) + pqc := makePrepareQC(keys, types.GenProposalAt(rng, types.View{Index: 0, Number: 0})) if err := s.pushPrepareQC(ctx, pqc); err != nil { return fmt.Errorf("pushPrepareQC: %w", err) } @@ -109,14 +108,14 @@ func TestVoteTimeoutPrepareQC_OnlyCurrentView(t *testing.T) { // consecutive timeouts with an offline leader must not lose the PrepareQC. func TestVoteTimeoutPrepareQC_InheritedFromTimeoutQC(t *testing.T) { rng := utils.TestRng() - s, keys, registry := newTestState(rng) + s, keys := newTestState(rng) err := scope.Run(t.Context(), func(ctx context.Context, sc scope.Scope) error { sc.SpawnBg(func() error { return utils.IgnoreCancel(s.Run(ctx)) }) // View (0, 0): push PrepareQC for proposal P. view0 := types.View{Index: 0, Number: 0} - pqc0 := makePrepareQC(keys, types.GenProposalForEpoch(rng, registry.LatestEpoch(), view0)) + pqc0 := makePrepareQC(keys, types.GenProposalAt(rng, view0)) if err := s.pushPrepareQC(ctx, pqc0); err != nil { return fmt.Errorf("pushPrepareQC: %w", err) } @@ -166,14 +165,14 @@ func TestVoteTimeoutPrepareQC_InheritedFromTimeoutQC(t *testing.T) { // view's PrepareQC is preferred over the older inherited one. func TestVoteTimeoutPrepareQC_CurrentViewHigherThanInherited(t *testing.T) { rng := utils.TestRng() - s, keys, registry := newTestState(rng) + s, keys := newTestState(rng) err := scope.Run(t.Context(), func(ctx context.Context, sc scope.Scope) error { sc.SpawnBg(func() error { return utils.IgnoreCancel(s.Run(ctx)) }) // View (0, 0): PrepareQC for P. view0 := types.View{Index: 0, Number: 0} - pqc0 := makePrepareQC(keys, types.GenProposalForEpoch(rng, registry.LatestEpoch(), view0)) + pqc0 := makePrepareQC(keys, types.GenProposalAt(rng, view0)) if err := s.pushPrepareQC(ctx, pqc0); err != nil { return fmt.Errorf("pushPrepareQC(pqc0): %w", err) } @@ -186,7 +185,7 @@ func TestVoteTimeoutPrepareQC_CurrentViewHigherThanInherited(t *testing.T) { // Reproposal at (0, 1) succeeds — new PrepareQC at view (0, 1). view1 := types.View{Index: 0, Number: 1} - pqc1 := makePrepareQC(keys, types.GenProposalForEpoch(rng, registry.LatestEpoch(), view1)) + pqc1 := makePrepareQC(keys, types.GenProposalAt(rng, view1)) if err := s.pushPrepareQC(ctx, pqc1); err != nil { return fmt.Errorf("pushPrepareQC(pqc1): %w", err) } @@ -216,7 +215,7 @@ func TestVoteTimeoutPrepareQC_CurrentViewHigherThanInherited(t *testing.T) { // the current view's PrepareQC is used. func TestVoteTimeoutPrepareQC_CurrentViewPresentInheritedNone(t *testing.T) { rng := utils.TestRng() - s, keys, registry := newTestState(rng) + s, keys := newTestState(rng) err := scope.Run(t.Context(), func(ctx context.Context, sc scope.Scope) error { sc.SpawnBg(func() error { return utils.IgnoreCancel(s.Run(ctx)) }) @@ -230,7 +229,7 @@ func TestVoteTimeoutPrepareQC_CurrentViewPresentInheritedNone(t *testing.T) { // Fresh PrepareQC at (0, 1). view1 := types.View{Index: 0, Number: 1} - pqc1 := makePrepareQC(keys, types.GenProposalForEpoch(rng, registry.LatestEpoch(), view1)) + pqc1 := makePrepareQC(keys, types.GenProposalAt(rng, view1)) if err := s.pushPrepareQC(ctx, pqc1); err != nil { return fmt.Errorf("pushPrepareQC: %w", err) } @@ -259,7 +258,7 @@ func TestVoteTimeoutPrepareQC_CurrentViewPresentInheritedNone(t *testing.T) { // voteTimeout still inherits the PrepareQC from the persisted TimeoutQC. func TestVoteTimeoutPrepareQC_PersistedRestart(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) dir := t.TempDir() makeCfg := func() *Config { @@ -271,13 +270,13 @@ func TestVoteTimeoutPrepareQC_PersistedRestart(t *testing.T) { } makeDataState := func() *data.State { return utils.OrPanic1(data.NewState( - &data.Config{Registry: registry}, - utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())), + &data.Config{Committee: committee}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)), )) } view0 := types.View{Index: 0, Number: 0} - pqc0 := makePrepareQC(keys, types.GenProposalForEpoch(rng, registry.LatestEpoch(), view0)) + pqc0 := makePrepareQC(keys, types.GenProposalAt(rng, view0)) // Session 1: push PrepareQC + TimeoutQC, let runOutputs persist. err := scope.Run(t.Context(), func(ctx context.Context, sc scope.Scope) error { diff --git a/sei-tendermint/internal/autobahn/data/metrics.go b/sei-tendermint/internal/autobahn/data/metrics.go new file mode 100644 index 0000000000..3c90977fef --- /dev/null +++ b/sei-tendermint/internal/autobahn/data/metrics.go @@ -0,0 +1,59 @@ +package data + +import ( + "github.com/prometheus/client_golang/prometheus" + "k8s.io/component-base/metrics/prometheusextension" +) + +var _ prometheus.Collector = (*State)(nil) + +type latencyMetric struct { + *prometheusextension.WeightedHistogramVec +} + +type resourceLatencyMetric struct { + Receive prometheusextension.WeightedObserver + Execute prometheusextension.WeightedObserver + Prune prometheusextension.WeightedObserver +} + +func newLatencyMetric() latencyMetric { + return latencyMetric{ + prometheusextension.NewWeightedHistogramVec(prometheus.HistogramOpts{ + Name: "sei_data__latency", + Help: "latency of resource processing up from production to the given stage", + Buckets: prometheus.ExponentialBuckets(0.001, 1.5, 30), + }, "resource", "stage"), + } +} + +func (m latencyMetric) resource(resource string) resourceLatencyMetric { + return resourceLatencyMetric{ + Receive: m.WithLabelValues(resource, "receive"), + Execute: m.WithLabelValues(resource, "execute"), + Prune: m.WithLabelValues(resource, "prune"), + } +} + +type dataMetrics struct { + Base latencyMetric + Blocks resourceLatencyMetric + Txs resourceLatencyMetric +} + +func newDataMetrics() *dataMetrics { + base := newLatencyMetric() + return &dataMetrics{ + Base: base, + Blocks: base.resource("blocks"), + Txs: base.resource("txs"), + } +} + +// Describe from prometheus.Collector. +func (s *State) Describe(chan<- *prometheus.Desc) {} + +// Collect from prometheus.Collector. +func (s *State) Collect(m chan<- prometheus.Metric) { + s.metrics.Base.Collect(m) +} diff --git a/sei-tendermint/internal/autobahn/data/metrics/metrics.gen.go b/sei-tendermint/internal/autobahn/data/metrics/metrics.gen.go deleted file mode 100644 index 4e219ee06e..0000000000 --- a/sei-tendermint/internal/autobahn/data/metrics/metrics.gen.go +++ /dev/null @@ -1,32 +0,0 @@ -// Code generated by metricsgen. DO NOT EDIT. - -package metrics - -import ( - "github.com/prometheus/client_golang/prometheus" - tmprometheus "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/prometheus" -) - -var Global = newMetrics() - -func init() { - prometheus.MustRegister( - Global.latency, - ) -} - -func newMetrics() *metrics { - return &metrics{ - latency: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, - Subsystem: MetricsSubsystem, - Name: "latency", - Help: "latency of resource processing up from production to the given stage", - Buckets: prometheus.ExponentialBuckets(0.001, 1.5, 30), - }, []string{"resource", "stage"}), - } -} - -func (m *metrics) latencyAt(resource string, stage string) *tmprometheus.Histogram { - return m.latency.WithLabelValues(resource, stage) -} diff --git a/sei-tendermint/internal/autobahn/data/metrics/metrics.go b/sei-tendermint/internal/autobahn/data/metrics/metrics.go deleted file mode 100644 index b711cb07b9..0000000000 --- a/sei-tendermint/internal/autobahn/data/metrics/metrics.go +++ /dev/null @@ -1,39 +0,0 @@ -package metrics - -import ( - "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/prometheus" -) - -const MetricsNamespace = "tendermint" -const MetricsSubsystem = "internal_autobahn_data" - -//go:generate go run github.com/sei-protocol/sei-chain/sei-tendermint/scripts/metricsgen -struct=metrics -type metrics struct { - // latency of resource processing up from production to the given stage - latency prometheus.HistogramVec `metrics_labels:"resource,stage" metrics_buckets:"exp(0.001, 1.5, 30)"` -} - -type resourceMetrics struct { - Receive *prometheus.Histogram - Execute *prometheus.Histogram - Prune *prometheus.Histogram -} - -type Metrics struct { - Blocks resourceMetrics - Txs resourceMetrics -} - -func Get() *Metrics { - get := func(resource string) resourceMetrics { - return resourceMetrics{ - Receive: Global.latencyAt(resource, "receive"), - Execute: Global.latencyAt(resource, "execute"), - Prune: Global.latencyAt(resource, "prune"), - } - } - return &Metrics{ - Blocks: get("blocks"), - Txs: get("txs"), - } -} diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 6311c20c05..5aba4e17c1 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -6,10 +6,10 @@ import ( "fmt" "time" + "github.com/prometheus/client_golang/prometheus" + "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus/persist" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data/metrics" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" ) @@ -24,8 +24,8 @@ var ErrPruned = errors.New("pruned") // Config is the config for the data State. type Config struct { - // Registry is the authoritative source of committee and stake information. - Registry *epoch.Registry + // Committee. + Committee *types.Committee // PruneAfter is the duration after which the state prunes executed blocks. PruneAfter utils.Option[time.Duration] } @@ -33,6 +33,7 @@ type Config struct { // StateAPI is the interface of the State for consuming global blocks // and reporting AppHashes. type StateAPI interface { + prometheus.Collector GlobalBlock(ctx context.Context, n types.GlobalBlockNumber) (*types.GlobalBlock, error) // PushAppHash blocks until block n and its QC are durably persisted, // ensuring AppVotes are only issued for data that survives a crash. @@ -89,8 +90,8 @@ func (dw *DataWAL) TruncateBefore(n types.GlobalBlockNumber) error { // 6 [a,b] [X,Y) Prune crash: QCs ahead (a=Y) Tail: truncate blocks to Y // 8 [a,b] [X,Y) QCs ahead (normal, b fb { @@ -114,12 +115,12 @@ func (dw *DataWAL) reconcile(firstBlock types.GlobalBlockNumber) error { // NewDataWAL constructs both global-block and global-commitqc WALs. // When stateDir is None, the returned persisters are no-ops. -func NewDataWAL(stateDir utils.Option[string], firstBlock types.GlobalBlockNumber) (*DataWAL, error) { - blocks, err := persist.NewGlobalBlockPersister(stateDir, firstBlock) +func NewDataWAL(stateDir utils.Option[string], committee *types.Committee) (*DataWAL, error) { + blocks, err := persist.NewGlobalBlockPersister(stateDir, committee) if err != nil { return nil, fmt.Errorf("global block WAL: %w", err) } - commitQCs, err := persist.NewFullCommitQCPersister(stateDir, firstBlock) + commitQCs, err := persist.NewFullCommitQCPersister(stateDir, committee) if err != nil { _ = blocks.Close() return nil, fmt.Errorf("full commitqc WAL: %w", err) @@ -131,7 +132,7 @@ func NewDataWAL(stateDir utils.Option[string], firstBlock types.GlobalBlockNumbe // Reconcile cursor inconsistency: a crash between the two parallel // TruncateBefore calls can leave one WAL truncated while the other // still has stale entries. Advance both to the max starting point. - if err := dw.reconcile(firstBlock); err != nil { + if err := dw.reconcile(committee); err != nil { _ = dw.Close() return nil, fmt.Errorf("reconcile WALs: %w", err) } @@ -173,8 +174,8 @@ type inner struct { nextQC types.GlobalBlockNumber } -func newInner(firstBlock types.GlobalBlockNumber) *inner { - first := firstBlock +func newInner(committee *types.Committee) *inner { + first := committee.FirstBlock() return &inner{ qcs: map[types.GlobalBlockNumber]*types.FullCommitQC{}, blocks: map[types.GlobalBlockNumber]*types.Block{}, @@ -202,15 +203,11 @@ func (i *inner) skipTo(n types.GlobalBlockNumber) { // insertQC verifies and inserts a FullCommitQC into the inner state. // Accepts QCs whose range starts at or before nextQC (partially pruned // prefix is silently skipped). Rejects gaps where gr.First > nextQC. -func (i *inner) insertQC(registry *epoch.Registry, qc *types.FullCommitQC) error { - e, ok := registry.EpochByIndex(qc.QC().Proposal().EpochIndex()) - if !ok { - return fmt.Errorf("unknown epoch_index %d", qc.QC().Proposal().EpochIndex()) - } - if err := qc.Verify(e); err != nil { +func (i *inner) insertQC(committee *types.Committee, qc *types.FullCommitQC) error { + if err := qc.Verify(committee); err != nil { return fmt.Errorf("qc.Verify(): %w", err) } - gr := qc.QC().GlobalRange() + gr := qc.QC().GlobalRange(committee) if gr.Next <= i.nextQC { return nil // fully behind, skip } @@ -240,7 +237,7 @@ func (i *inner) insertBlock(committee *types.Committee, n types.GlobalBlockNumbe return nil // already have it } qc := i.qcs[n] - storedGR := qc.QC().GlobalRange() + storedGR := qc.QC().GlobalRange(committee) want := qc.Headers()[n-storedGR.First].Hash() got := block.Header().Hash() if want != got { @@ -251,7 +248,7 @@ func (i *inner) insertBlock(committee *types.Committee, n types.GlobalBlockNumbe return nil } -func (i *inner) updateNextBlock(m *metrics.Metrics) { +func (i *inner) updateNextBlock(m *dataMetrics) { t := time.Now() for { b, ok := i.blocks[i.nextBlock] @@ -260,15 +257,15 @@ func (i *inner) updateNextBlock(m *metrics.Metrics) { } i.nextBlock += 1 latency := t.Sub(b.Payload().CreatedAt()).Seconds() - m.Blocks.Receive.Observe(latency) + m.Blocks.Receive.ObserveWithWeight(latency, 1) m.Txs.Receive.ObserveWithWeight(latency, uint64(len(b.Payload().Txs()))) } } -func (i *inner) pruneFirst(now time.Time, m *metrics.Metrics) { +func (i *inner) pruneFirst(now time.Time, m *dataMetrics) { b := i.blocks[i.first] latency := now.Sub(b.Payload().CreatedAt()).Seconds() - m.Blocks.Prune.Observe(latency) + m.Blocks.Prune.ObserveWithWeight(latency, 1) m.Txs.Prune.ObserveWithWeight(latency, uint64(len(b.Payload().Txs()))) delete(i.appProposals, i.first) delete(i.blocks, i.first) @@ -281,7 +278,7 @@ func (i *inner) pruneFirst(now time.Time, m *metrics.Metrics) { // Contains blocks in global order and proofs of their finality. type State struct { cfg *Config - metrics *metrics.Metrics + metrics *dataMetrics inner utils.Watch[*inner] dataWAL *DataWAL } @@ -290,7 +287,7 @@ type State struct { // dataWAL persists blocks and QCs to WALs for crash recovery and provides // preloaded data from the previous run. Use NewDataWAL to construct it. func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { - inner := newInner(cfg.Registry.FirstBlock()) + inner := newInner(cfg.Committee) // Fast-forward cursors to where data starts. Use blocks as golden: // per-block pruning may split a QC range, so blocks determine where // useful data starts. QCs before that are kept for verification but @@ -298,13 +295,13 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { blocksFirst := dataWAL.Blocks.LoadedFirst() qcFirst := dataWAL.CommitQCs.LoadedFirst() dataFirst := max(blocksFirst, qcFirst) - if dataFirst > cfg.Registry.FirstBlock() { + if dataFirst > cfg.Committee.FirstBlock() { inner.skipTo(dataFirst) } // Restore QCs. insertQC handles partially pruned QCs (range starts // before inner.first) by skipping the pruned prefix. for _, qc := range dataWAL.CommitQCs.ConsumeLoaded() { - if err := inner.insertQC(cfg.Registry, qc); err != nil { + if err := inner.insertQC(cfg.Committee, qc); err != nil { return nil, fmt.Errorf("load QC from WAL: %w", err) } } @@ -318,16 +315,10 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { return nil, fmt.Errorf("block gap in WAL: expected %d, got %d", expectedBlock, lb.Number) } expectedBlock = lb.Number + 1 - qc := inner.qcs[lb.Number] - e, ok := cfg.Registry.EpochByIndex(qc.QC().Proposal().EpochIndex()) - if !ok { - return nil, fmt.Errorf("unknown epoch_index %d", qc.QC().Proposal().EpochIndex()) - } - committee := e.Committee() - if err := lb.Block.Verify(committee); err != nil { + if err := lb.Block.Verify(cfg.Committee); err != nil { return nil, fmt.Errorf("load block %d from WAL: %w", lb.Number, err) } - if err := inner.insertBlock(committee, lb.Number, lb.Block); err != nil { + if err := inner.insertBlock(cfg.Committee, lb.Number, lb.Block); err != nil { return nil, fmt.Errorf("load block %d from WAL: %w", lb.Number, err) } } @@ -345,25 +336,21 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { } return &State{ cfg: cfg, - metrics: metrics.Get(), + metrics: newDataMetrics(), inner: utils.NewWatch(inner), dataWAL: dataWAL, }, nil } -// Registry returns the epoch registry. -func (s *State) Registry() *epoch.Registry { return s.cfg.Registry } +// Committee returns the committee. +func (s *State) Committee() *types.Committee { return s.cfg.Committee } // PushQC pushes FullCommitQC and a subset of blocks that were finalized by it. // Pushing the qc and blocks is atomic, so that no unnecessary GetBlock RPCs are issued. // Even if the qc was already pushed earlier, the blocks are pushed anyway. func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*types.Block) error { // Wait until QC is needed. - ep, ok := s.cfg.Registry.EpochByIndex(qc.QC().Proposal().EpochIndex()) - if !ok { - return fmt.Errorf("unknown epoch_index %d", qc.QC().Proposal().EpochIndex()) - } - gr := qc.QC().GlobalRange() + gr := qc.QC().GlobalRange(s.cfg.Committee) needQC, err := func() (bool, error) { for inner, ctrl := range s.inner.Lock() { if err := ctrl.WaitUntil(ctx, func() bool { @@ -380,15 +367,14 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty } // Verify data. if needQC { - if err := qc.Verify(ep); err != nil { + if err := qc.Verify(s.cfg.Committee); err != nil { return fmt.Errorf("qc.Verify(): %w", err) } } byHash := map[types.BlockHeaderHash]*types.Block{} - committee := ep.Committee() for _, b := range blocks { byHash[b.Header().Hash()] = b - if err := b.Verify(committee); err != nil { + if err := b.Verify(s.cfg.Committee); err != nil { return fmt.Errorf("b.Verify(): %w", err) } } @@ -407,14 +393,9 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty // Match blocks against stored (already verified) QC headers. for n := max(inner.nextBlock, gr.First); n < min(gr.Next, inner.nextQC); n += 1 { storedQC := inner.qcs[n] - storedGR := storedQC.QC().GlobalRange() - storedEp, ok := s.cfg.Registry.EpochByIndex(storedQC.QC().Proposal().EpochIndex()) - if !ok { - return fmt.Errorf("unknown epoch_index %d", storedQC.QC().Proposal().EpochIndex()) - } - storedCommittee := storedEp.Committee() + storedGR := storedQC.QC().GlobalRange(s.cfg.Committee) if b, ok := byHash[storedQC.Headers()[n-storedGR.First].Hash()]; ok { - if err := inner.insertBlock(storedCommittee, n, b); err != nil { + if err := inner.insertBlock(s.cfg.Committee, n, b); err != nil { return err } } @@ -442,32 +423,17 @@ func (s *State) QC(ctx context.Context, n types.GlobalBlockNumber) (*types.FullC } // PushBlock pushes block to the state. -// The QC for n must already be present (guaranteed by PushQC ordering). +// Waits until the block header is available. func (s *State) PushBlock(ctx context.Context, n types.GlobalBlockNumber, block *types.Block) error { - var epochIdx types.EpochIndex - for inner, ctrl := range s.inner.Lock() { - if err := ctrl.WaitUntil(ctx, func() bool { return n < inner.nextQC }); err != nil { - return err - } - if n < inner.first { - // Block arrived after pruning; drop silently so the sender keeps delivering future blocks. - return nil - } - epochIdx = inner.qcs[n].QC().Proposal().EpochIndex() - } - ep, ok := s.cfg.Registry.EpochByIndex(epochIdx) - if !ok { - return fmt.Errorf("unknown epoch_index %d", epochIdx) - } - // Verify outside the lock against the known epoch. - if err := block.Verify(ep.Committee()); err != nil { + // Verify outside the lock to avoid holding it during expensive crypto. + if err := block.Verify(s.cfg.Committee); err != nil { return fmt.Errorf("block.Verify(): %w", err) } for inner, ctrl := range s.inner.Lock() { - if n < inner.first { - return nil + if err := ctrl.WaitUntil(ctx, func() bool { return n < inner.nextQC }); err != nil { + return err } - if err := inner.insertBlock(ep.Committee(), n, block); err != nil { + if err := inner.insertBlock(s.cfg.Committee, n, block); err != nil { return err } inner.updateNextBlock(s.metrics) @@ -506,7 +472,7 @@ func (s *State) GlobalBlockByHash(hash types.BlockHeaderHash) (utils.Option[*typ if !ok { return utils.None[*types.GlobalBlock](), nil } - return utils.Some(inner.globalBlockAt(n)), nil + return utils.Some(inner.globalBlockAt(s.Committee(), n)), nil } panic("unreachable") } @@ -549,12 +515,12 @@ func (s *State) TryBlock(n types.GlobalBlockNumber) (*types.Block, error) { // globalBlockAt assembles the GlobalBlock at height n from inner state. // Caller must have verified n is in [inner.first, inner.nextBlock); n // outside that range nil-derefs on inner.blocks[n] / inner.qcs[n]. -func (i *inner) globalBlockAt(n types.GlobalBlockNumber) *types.GlobalBlock { +func (i *inner) globalBlockAt(c *types.Committee, n types.GlobalBlockNumber) *types.GlobalBlock { b := i.blocks[n] qc := i.qcs[n].QC() return &types.GlobalBlock{ GlobalNumber: n, - Timestamp: qc.Proposal().BlockTimestamp(n).OrPanic("global block not in QC"), + Timestamp: qc.Proposal().BlockTimestamp(c, n).OrPanic("global block not in QC"), Header: b.Header(), Payload: b.Payload(), FinalAppState: qc.Proposal().App(), @@ -573,7 +539,7 @@ func (s *State) GlobalBlock(ctx context.Context, n types.GlobalBlockNumber) (*ty if n < inner.first { return nil, ErrPruned } - return inner.globalBlockAt(n), nil + return inner.globalBlockAt(s.Committee(), n), nil } panic("unreachable") } @@ -594,7 +560,6 @@ func (s *State) PushAppHash(ctx context.Context, n types.GlobalBlockNumber, hash n, inner.qcs[n].QC().Proposal().Index(), hash, - inner.qcs[n].QC().Proposal().EpochIndex(), ) t := time.Now() apt := appProposalWithTimestamp{ @@ -607,7 +572,7 @@ func (s *State) PushAppHash(ctx context.Context, n types.GlobalBlockNumber, hash for inner.nextAppProposal <= n { b := inner.blocks[inner.nextAppProposal] latency := t.Sub(b.Payload().CreatedAt()).Seconds() - s.metrics.Blocks.Execute.Observe(latency) + s.metrics.Blocks.Execute.ObserveWithWeight(latency, 1) s.metrics.Txs.Execute.ObserveWithWeight(latency, uint64(len(b.Payload().Txs()))) inner.appProposals[inner.nextAppProposal] = apt inner.nextAppProposal += 1 @@ -733,7 +698,7 @@ func (s *State) runPersist(ctx context.Context) error { seen := map[types.GlobalBlockNumber]bool{} for n := persistedQC; n < inner.nextQC; n++ { qc := inner.qcs[n] - first := qc.QC().GlobalRange().First + first := qc.QC().GlobalRange(s.cfg.Committee).First if !seen[first] { seen[first] = true b.qcs = append(b.qcs, qc) diff --git a/sei-tendermint/internal/autobahn/data/state_test.go b/sei-tendermint/internal/autobahn/data/state_test.go index a25e4d61aa..7f5c57b477 100644 --- a/sei-tendermint/internal/autobahn/data/state_test.go +++ b/sei-tendermint/internal/autobahn/data/state_test.go @@ -13,7 +13,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus/persist" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" @@ -51,11 +50,11 @@ func snapshot(s *State) Snapshot { func TestState(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) if err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { state := utils.OrPanic1(NewState(&Config{ - Registry: registry, - }, utils.OrPanic1(NewDataWAL(utils.None[string](), registry.FirstBlock())))) + Committee: committee, + }, utils.OrPanic1(NewDataWAL(utils.None[string](), committee)))) s.SpawnBgNamed("state.Run()", func() error { return utils.IgnoreCancel(state.Run(ctx)) }) @@ -64,12 +63,12 @@ func TestState(t *testing.T) { prev := utils.None[*types.CommitQC]() for i := range 3 { t.Logf("iteration %v", i) - qc, blocks := TestCommitQC(rng, registry.LatestEpoch(), keys, prev) + qc, blocks := TestCommitQC(rng, committee, keys, prev) prev = utils.Some(qc.QC()) if err := state.PushQC(ctx, qc, blocks); err != nil { return fmt.Errorf("state.PushQC(): %w", err) } - gr := qc.QC().GlobalRange() + gr := qc.QC().GlobalRange(committee) for n := gr.First; n < gr.Next; n += 1 { want.QCs[n] = qc want.Blocks[n] = blocks[n-gr.First] @@ -97,7 +96,7 @@ func TestState(t *testing.T) { wantG := &types.GlobalBlock{ GlobalNumber: n, - Timestamp: want.QCs[n].QC().Proposal().BlockTimestamp(n).OrPanic("global block not in QC"), + Timestamp: want.QCs[n].QC().Proposal().BlockTimestamp(committee, n).OrPanic("global block not in QC"), Header: wantB.Header(), Payload: wantB.Payload(), FinalAppState: want.QCs[n].QC().Proposal().App(), @@ -125,16 +124,15 @@ func TestState(t *testing.T) { func TestPushConflictingBadCommitQC(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) - committee := registry.LatestEpoch().Committee() + committee, keys := types.GenCommittee(rng, 3) state := utils.OrPanic1(NewState(&Config{ - Registry: registry, - }, utils.OrPanic1(NewDataWAL(utils.None[string](), registry.FirstBlock())))) + Committee: committee, + }, utils.OrPanic1(NewDataWAL(utils.None[string](), committee)))) // Push a valid QC to advance inner.nextQC. - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) require.NoError(t, state.PushQC(ctx, qc1, blocks1)) - nextQC := qc1.QC().GlobalRange().Next + gr1 := qc1.QC().GlobalRange(committee) // Construct a malicious QC signed by non-committee keys. // It starts from block 0 (stale) but extends beyond nextQC. @@ -145,7 +143,7 @@ func TestPushConflictingBadCommitQC(t *testing.T) { badKeys[i] = types.GenSecretKey(rng) } laneBlocks := map[types.LaneID][]*types.Block{} - maliciousBlocksTotal := int(nextQC-registry.FirstBlock()) + 1 + maliciousBlocksTotal := int(gr1.Len()) + 1 require.LessOrEqual(t, maliciousBlocksTotal, committee.Lanes().Len()*types.MaxLaneRangeInProposal) for i := range maliciousBlocksTotal { lane := committee.Lanes().At(i % committee.Lanes().Len()) @@ -172,7 +170,7 @@ func TestPushConflictingBadCommitQC(t *testing.T) { malBlocks = append(malBlocks, b) } } - viewSpec := types.ViewSpec{CommitQC: utils.None[*types.CommitQC](), Epoch: registry.LatestEpoch()} + viewSpec := types.ViewSpec{CommitQC: utils.None[*types.CommitQC]()} leader := committee.Leader(viewSpec.View()) var leaderKey types.SecretKey for _, k := range keys { @@ -183,14 +181,15 @@ func TestPushConflictingBadCommitQC(t *testing.T) { } proposal := utils.OrPanic1(types.NewProposal( leaderKey, + committee, viewSpec, time.Now(), laneQCs, utils.None[*types.AppQC](), )) - malGR := proposal.Proposal().Msg().GlobalRange() - require.Less(t, malGR.First, nextQC, "test setup: malicious gr.First must be < nextQC") - require.Greater(t, malGR.Next, nextQC, "test setup: malicious gr.Next must be > nextQC") + malGR := proposal.Proposal().Msg().GlobalRange(committee) + require.Less(t, malGR.First, gr1.Next, "test setup: malicious gr.First must be < nextQC") + require.Greater(t, malGR.Next, gr1.Next, "test setup: malicious gr.Next must be > nextQC") votes := make([]*types.Signed[*types.CommitVote], 0, len(badKeys)) for _, k := range badKeys { @@ -205,7 +204,6 @@ func TestPushConflictingBadCommitQC(t *testing.T) { _ = state.PushQC(ctx, maliciousQC, malBlocks) // Verify state was not corrupted: all previously pushed QCs and blocks are intact. - gr1 := qc1.QC().GlobalRange() for n := gr1.First; n < gr1.Next; n++ { got, err := state.QC(ctx, n) require.NoError(t, err) @@ -223,9 +221,9 @@ func TestPushConflictingBadCommitQC(t *testing.T) { } // Verify state is still functional: the next valid QC is accepted and visible. - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + qc2, blocks2 := TestCommitQC(rng, committee, keys, utils.Some(qc1.QC())) require.NoError(t, state.PushQC(ctx, qc2, blocks2)) - gr2 := qc2.QC().GlobalRange() + gr2 := qc2.QC().GlobalRange(committee) for n := gr2.First; n < gr2.Next; n++ { got, err := state.QC(ctx, n) require.NoError(t, err) @@ -236,15 +234,15 @@ func TestPushConflictingBadCommitQC(t *testing.T) { func TestPushQCIgnoresBlocksMatchingUnverifiedHeaders(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) state := utils.OrPanic1(NewState(&Config{ - Registry: registry, - }, utils.OrPanic1(NewDataWAL(utils.None[string](), registry.FirstBlock())))) + Committee: committee, + }, utils.OrPanic1(NewDataWAL(utils.None[string](), committee)))) // Push qc1 with NO blocks — only the QC is stored. - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) require.NoError(t, state.PushQC(ctx, qc1, nil)) - gr := qc1.QC().GlobalRange() + gr := qc1.QC().GlobalRange(committee) // Build a tampered FullCommitQC: same CommitQC (same range) but with // different block headers (different payloads → different hashes). @@ -281,11 +279,11 @@ func TestPushQCIgnoresBlocksMatchingUnverifiedHeaders(t *testing.T) { func TestExecution(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) if err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { state := utils.OrPanic1(NewState(&Config{ - Registry: registry, - }, utils.OrPanic1(NewDataWAL(utils.None[string](), registry.FirstBlock())))) + Committee: committee, + }, utils.OrPanic1(NewDataWAL(utils.None[string](), committee)))) s.SpawnBgNamed("state.Run()", func() error { return utils.IgnoreCancel(state.Run(ctx)) }) @@ -293,12 +291,12 @@ func TestExecution(t *testing.T) { prev := utils.None[*types.CommitQC]() for i := range 3 { t.Logf("iteration %v", i) - qc, blocks := TestCommitQC(rng, registry.LatestEpoch(), keys, prev) + qc, blocks := TestCommitQC(rng, committee, keys, prev) if err := state.PushQC(ctx, qc, blocks); err != nil { return fmt.Errorf("state.PushQC(): %w", err) } prev = utils.Some(qc.QC()) - gr := qc.QC().GlobalRange() + gr := qc.QC().GlobalRange(committee) // PushAppHash for a block beyond nextBlock should not succeed: // it waits for persistence which never happens for unfinalised blocks. shortCtx, cancel := context.WithTimeout(ctx, 10*time.Millisecond) @@ -325,16 +323,16 @@ func TestExecution(t *testing.T) { func TestPushBlockAcceptsBlockWithQC(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) state := utils.OrPanic1(NewState(&Config{ - Registry: registry, - }, utils.OrPanic1(NewDataWAL(utils.None[string](), registry.FirstBlock())))) + Committee: committee, + }, utils.OrPanic1(NewDataWAL(utils.None[string](), committee)))) // Push QC without blocks. - qc, blocks := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc, blocks := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) require.NoError(t, state.PushQC(ctx, qc, nil)) - gr := qc.QC().GlobalRange() + gr := qc.QC().GlobalRange(committee) // PushBlock for a block whose QC is already present succeeds immediately. require.NoError(t, state.PushBlock(ctx, gr.First, blocks[0])) @@ -358,15 +356,15 @@ func TestPushBlockAcceptsBlockWithQC(t *testing.T) { func TestGlobalBlockByHash(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) state := utils.OrPanic1(NewState(&Config{ - Registry: registry, - }, utils.OrPanic1(NewDataWAL(utils.None[string](), registry.FirstBlock())))) + Committee: committee, + }, utils.OrPanic1(NewDataWAL(utils.None[string](), committee)))) - qc, blocks := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc, blocks := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) require.NoError(t, state.PushQC(ctx, qc, blocks)) - gr := qc.QC().GlobalRange() + gr := qc.QC().GlobalRange(committee) n := gr.First wantBlock := blocks[0] wantHash := wantBlock.Header().Hash() @@ -403,12 +401,12 @@ func TestGlobalBlockByHash(t *testing.T) { func TestReconcileCase1Empty(t *testing.T) { t.Log("Reconcile case 1: Fresh start (empty/empty)") rng := utils.TestRng() - registry, _ := epoch.GenRegistry(rng, 3) + committee, _ := types.GenCommittee(rng, 3) dir := t.TempDir() - fb := registry.FirstBlock() + fb := committee.FirstBlock() - dw := utils.OrPanic1(NewDataWAL(utils.Some(dir), registry.FirstBlock())) - state := utils.OrPanic1(NewState(&Config{Registry: registry}, dw)) + dw := utils.OrPanic1(NewDataWAL(utils.Some(dir), committee)) + state := utils.OrPanic1(NewState(&Config{Committee: committee}, dw)) for inner := range state.inner.Lock() { require.Equal(t, fb, inner.first) @@ -423,14 +421,14 @@ func TestReconcileCase1Empty(t *testing.T) { func TestReconcileCase2Corrupted(t *testing.T) { t.Log("Reconcile case 2: QCs lost (corruption), returns error") rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) dir := t.TempDir() // Persist blocks and QCs normally. - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - gr1 := qc1.QC().GlobalRange() + qc1, blocks1 := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) + gr1 := qc1.QC().GlobalRange(committee) - dw1 := utils.OrPanic1(NewDataWAL(utils.Some(dir), registry.FirstBlock())) + dw1 := utils.OrPanic1(NewDataWAL(utils.Some(dir), committee)) require.NoError(t, dw1.CommitQCs.PersistQC(qc1)) for i, n := 0, gr1.First; n < gr1.Next; n++ { require.NoError(t, dw1.Blocks.PersistBlock(n, blocks1[i])) @@ -442,7 +440,7 @@ func TestReconcileCase2Corrupted(t *testing.T) { require.NoError(t, os.RemoveAll(filepath.Join(dir, "fullcommitqcs"))) // Reopen should fail — blocks exist but QCs are gone. - _, err := NewDataWAL(utils.Some(dir), registry.FirstBlock()) + _, err := NewDataWAL(utils.Some(dir), committee) require.Error(t, err) require.Contains(t, err.Error(), "corrupted") } @@ -456,15 +454,15 @@ func TestReconcileCase3BlocksLost(t *testing.T) { t.Log("Reconcile case 3: Blocks lost (crash), QCs survive") ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) dir := t.TempDir() // First run: populate both WALs. - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - gr1 := qc1.QC().GlobalRange() + qc1, blocks1 := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) + gr1 := qc1.QC().GlobalRange(committee) - dw1 := utils.OrPanic1(NewDataWAL(utils.Some(dir), registry.FirstBlock())) - state1 := utils.OrPanic1(NewState(&Config{Registry: registry}, dw1)) + dw1 := utils.OrPanic1(NewDataWAL(utils.Some(dir), committee)) + state1 := utils.OrPanic1(NewState(&Config{Committee: committee}, dw1)) require.NoError(t, state1.PushQC(ctx, qc1, blocks1)) require.NoError(t, dw1.CommitQCs.PersistQC(qc1)) for i, n := 0, gr1.First; n < gr1.Next; n++ { @@ -477,8 +475,8 @@ func TestReconcileCase3BlocksLost(t *testing.T) { require.NoError(t, os.RemoveAll(filepath.Join(dir, "globalblocks"))) // Second run: only QCs WAL survives. - dw2 := utils.OrPanic1(NewDataWAL(utils.Some(dir), registry.FirstBlock())) - state2 := utils.OrPanic1(NewState(&Config{Registry: registry}, dw2)) + dw2 := utils.OrPanic1(NewDataWAL(utils.Some(dir), committee)) + state2 := utils.OrPanic1(NewState(&Config{Committee: committee}, dw2)) // QCs loaded, blocks empty. The state needs blocks re-pushed. // Without the cursor sync fix, PushBlock here would fail with @@ -496,9 +494,9 @@ func TestReconcileCase3BlocksLost(t *testing.T) { } // State should accept the next QC normally. - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + qc2, blocks2 := TestCommitQC(rng, committee, keys, utils.Some(qc1.QC())) require.NoError(t, state2.PushQC(ctx, qc2, blocks2)) - require.Equal(t, qc2.QC().GlobalRange().Next, state2.NextBlock()) + require.Equal(t, qc2.QC().GlobalRange(committee).Next, state2.NextBlock()) require.NoError(t, dw2.Close()) } @@ -506,18 +504,18 @@ func TestReconcileCase4Normal(t *testing.T) { t.Log("Reconcile case 4: Normal (a=X, bX)") rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) dir := t.TempDir() - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) - gr1 := qc1.QC().GlobalRange() - gr2 := qc2.QC().GlobalRange() + qc1, blocks1 := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, committee, keys, utils.Some(qc1.QC())) + gr1 := qc1.QC().GlobalRange(committee) + gr2 := qc2.QC().GlobalRange(committee) // Persist both QCs and all blocks. - dw := utils.OrPanic1(NewDataWAL(utils.Some(dir), registry.FirstBlock())) + dw := utils.OrPanic1(NewDataWAL(utils.Some(dir), committee)) require.NoError(t, dw.CommitQCs.PersistQC(qc1)) require.NoError(t, dw.CommitQCs.PersistQC(qc2)) allBlocks := append(blocks1, blocks2...) @@ -654,8 +652,8 @@ func TestReconcileCase5BlocksAhead(t *testing.T) { // Reopen: blocks start at gr2.First, QCs start at gr1.First. // Reconcile should truncate QCs to match blocks. - dw2 := utils.OrPanic1(NewDataWAL(utils.Some(dir), registry.FirstBlock())) - state := utils.OrPanic1(NewState(&Config{Registry: registry}, dw2)) + dw2 := utils.OrPanic1(NewDataWAL(utils.Some(dir), committee)) + state := utils.OrPanic1(NewState(&Config{Committee: committee}, dw2)) for inner := range state.inner.Lock() { require.Equal(t, gr2.First, inner.first) @@ -677,18 +675,18 @@ func TestReconcileCase6QCsAhead(t *testing.T) { t.Log("Reconcile case 6: Prune crash, QCs ahead (a=Y)") rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) dir := t.TempDir() // Build 2 sequential QCs. - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) - gr1 := qc1.QC().GlobalRange() - gr2 := qc2.QC().GlobalRange() + qc1, blocks1 := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, committee, keys, utils.Some(qc1.QC())) + gr1 := qc1.QC().GlobalRange(committee) + gr2 := qc2.QC().GlobalRange(committee) // Persist only qc1 to QCs WAL but persist ALL blocks (qc1 + qc2) to blocks WAL. // This simulates blocks being persisted ahead of QCs. - dw1 := utils.OrPanic1(NewDataWAL(utils.Some(dir), registry.FirstBlock())) + dw1 := utils.OrPanic1(NewDataWAL(utils.Some(dir), committee)) require.NoError(t, dw1.CommitQCs.PersistQC(qc1)) allBlocks := append(blocks1, blocks2...) for i, n := 0, gr1.First; n < gr2.Next; n++ { @@ -751,8 +749,8 @@ func TestReconcileCase7BlocksPastQCs(t *testing.T) { // On recovery, only blocks within qc1's range should be loaded. // Blocks in qc2's range have no QC and should be ignored. - dw2 := utils.OrPanic1(NewDataWAL(utils.Some(dir), registry.FirstBlock())) - state2 := utils.OrPanic1(NewState(&Config{Registry: registry}, dw2)) + dw2 := utils.OrPanic1(NewDataWAL(utils.Some(dir), committee)) + state2 := utils.OrPanic1(NewState(&Config{Committee: committee}, dw2)) // Blocks in qc1's range should be available. for n := gr1.First; n < gr1.Next; n++ { @@ -780,18 +778,18 @@ func TestReconcileCase7BlocksTail(t *testing.T) { t.Log("Reconcile case 7: Persist crash, tail truncation with re-push") ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + committee, keys := types.GenCommittee(rng, 3) dir := t.TempDir() // Build 2 sequential QCs. - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) - gr1 := qc1.QC().GlobalRange() - gr2 := qc2.QC().GlobalRange() + qc1, blocks1 := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, committee, keys, utils.Some(qc1.QC())) + gr1 := qc1.QC().GlobalRange(committee) + gr2 := qc2.QC().GlobalRange(committee) // Persist qc1 to both WALs, but only blocks (not QC) for qc2. // This simulates a crash during parallel persistence in runPersist. - dw1 := utils.OrPanic1(NewDataWAL(utils.Some(dir), registry.FirstBlock())) + dw1 := utils.OrPanic1(NewDataWAL(utils.Some(dir), committee)) require.NoError(t, dw1.CommitQCs.PersistQC(qc1)) allBlocks := append(blocks1, blocks2...) for i, n := 0, gr1.First; n < gr2.Next; n++ { @@ -801,12 +799,12 @@ func TestReconcileCase7BlocksTail(t *testing.T) { require.NoError(t, dw1.Close()) // Reopen: reconcile should truncate the blocks tail (qc2's blocks). - dw2 := utils.OrPanic1(NewDataWAL(utils.Some(dir), registry.FirstBlock())) + dw2 := utils.OrPanic1(NewDataWAL(utils.Some(dir), committee)) // Blocks persister cursor should now match QCs range. require.Equal(t, dw2.CommitQCs.Next(), dw2.Blocks.Next()) - state := utils.OrPanic1(NewState(&Config{Registry: registry}, dw2)) + state := utils.OrPanic1(NewState(&Config{Committee: committee}, dw2)) // qc1's blocks should be available. for n := gr1.First; n < gr1.Next; n++ { @@ -829,17 +827,17 @@ func TestReconcileCase8BlocksBehind(t *testing.T) { t.Log("Reconcile case 8: QCs ahead normal (b 0 { parent := bs[len(bs)-1] - return types.NewBlock(producer, parent.Header().Next(), parent.Header().Hash(), types.GenPayload(rng)) + return types.NewBlock( + producer, + parent.Header().Next(), + parent.Header().Hash(), + types.GenPayload(rng), + ) } - return types.NewBlock(producer, types.LaneRangeOpt(prev, producer).Next(), types.GenBlockHeaderHash(rng), types.GenPayload(rng)) + return types.NewBlock( + producer, + types.LaneRangeOpt(prev, producer).Next(), + types.GenBlockHeaderHash(rng), + types.GenPayload(rng), + ) } + // Make some blocks for range 10 { producer := committee.Lanes().At(rng.Intn(committee.Lanes().Len())) blocks[producer] = append(blocks[producer], makeBlock(producer)) } + // Construct a proposal. laneQCs := map[types.LaneID]*types.LaneQC{} var headers []*types.BlockHeader var blockList []*types.Block @@ -58,14 +72,37 @@ func TestCommitQC( } } } - var appQC utils.Option[*types.AppQC] - if cqc, ok := prev.Get(); ok { - vs := types.ViewSpec{CommitQC: prev, Epoch: ep} - p := types.NewAppProposal(cqc.GlobalRange().Next-1, vs.View().Index, types.GenAppHash(rng), ep.EpochIndex()) - appQC = utils.Some(TestAppQC(keys, p)) + viewSpec := types.ViewSpec{CommitQC: prev} + leader := committee.Leader(viewSpec.View()) + var leaderKey types.SecretKey + for _, k := range keys { + if k.Public() == leader { + leaderKey = k + break + } + } + proposal := utils.OrPanic1(types.NewProposal( + leaderKey, + committee, + viewSpec, + time.Now(), + laneQCs, + func() utils.Option[*types.AppQC] { + if n := types.GlobalRangeOpt(prev, committee).Next; n > 0 { + p := types.NewAppProposal(n-1, viewSpec.View().Index, types.GenAppHash(rng)) + return utils.Some(TestAppQC(keys, p)) + } + return utils.None[*types.AppQC]() + }(), + )) + votes := make([]*types.Signed[*types.CommitVote], 0, len(keys)) + for _, k := range keys { + votes = append(votes, types.Sign(k, types.NewCommitVote(proposal.Proposal().Msg()))) } - cqc := types.BuildCommitQC(ep, keys, prev, laneQCs, appQC) - return types.NewFullCommitQC(cqc, headers), blockList + return types.NewFullCommitQC( + types.NewCommitQC(votes), + headers, + ), blockList } var _ StateAPI = (*MockState)(nil) @@ -143,3 +180,9 @@ func (s *MockState) PushAppHash(_ context.Context, n types.GlobalBlockNumber, ap } return nil } + +// Describe from prometheus.Collector. +func (s *MockState) Describe(chan<- *prometheus.Desc) {} + +// Collect from prometheus.Collector. +func (s *MockState) Collect(chan<- prometheus.Metric) {} diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go deleted file mode 100644 index 75191764d1..0000000000 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ /dev/null @@ -1,83 +0,0 @@ -package epoch - -import ( - "time" - - "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" - "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" -) - -type registryState struct { - m map[types.EpochIndex]*types.Epoch - latest types.EpochIndex -} - -// Registry is the authoritative source of epoch and committee information. -// All layers (consensus, data, avail) read from it. -type Registry struct { - state utils.RWMutex[registryState] -} - -// NewRegistry creates a Registry with the genesis committee. -func NewRegistry( - committee *types.Committee, - firstBlock types.GlobalBlockNumber, - genesisTimestamp time.Time, -) (*Registry, error) { - ep := types.NewEpoch(0, types.OpenRoadRange(), genesisTimestamp, committee, firstBlock) - return &Registry{ - state: utils.NewRWMutex(registryState{ - m: map[types.EpochIndex]*types.Epoch{0: ep}, - latest: 0, - }), - }, nil -} - -// FirstBlock returns the first global block number of the genesis epoch. -// Used as the cold-start default (no WAL, no snapshot); WAL overrides this on restart. -func (r *Registry) FirstBlock() types.GlobalBlockNumber { - for s := range r.state.RLock() { - return s.m[0].FirstBlock() - } - panic("unreachable") -} - -// GenesisTimestamp returns the timestamp of the genesis epoch. -func (r *Registry) GenesisTimestamp() time.Time { - for s := range r.state.RLock() { - return s.m[0].FirstTimestamp() - } - panic("unreachable") -} - -// EpochByIndex returns the epoch with the given index, if it exists. -func (r *Registry) EpochByIndex(idx types.EpochIndex) (*types.Epoch, bool) { - for s := range r.state.RLock() { - ep, ok := s.m[idx] - return ep, ok - } - panic("unreachable") -} - -// LatestEpoch returns the most recently activated epoch. -func (r *Registry) LatestEpoch() *types.Epoch { - for s := range r.state.RLock() { - return s.m[s.latest] - } - panic("unreachable") -} - -// VerifyInWindow calls fn against the latest epoch's committee and returns it if accepted. -// Returns a slice of all matching epochs so callers can skip re-verification for any -// epoch already checked here. -// TODO: expand to neighbor epochs (previous and next) once multi-epoch transitions are wired up. -func (r *Registry) VerifyInWindow(fn func(*types.Committee) error) ([]*types.Epoch, error) { - for s := range r.state.RLock() { - ep := s.m[s.latest] - if err := fn(ep.Committee()); err != nil { - return nil, err - } - return []*types.Epoch{ep}, nil - } - panic("unreachable") -} diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go deleted file mode 100644 index f5249bcd44..0000000000 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package epoch - -import ( - "testing" - "time" - - "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" - "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" -) - -func makeRegistry(t *testing.T) (*Registry, *types.Committee) { - t.Helper() - rng := utils.TestRng() - committee := utils.OrPanic1(types.NewCommittee(map[types.PublicKey]uint64{ - types.GenSecretKey(rng).Public(): 1, - types.GenSecretKey(rng).Public(): 1, - types.GenSecretKey(rng).Public(): 1, - })) - r := utils.OrPanic1(NewRegistry(committee, 0, time.Time{})) - return r, committee -} - -func TestRegistry_EpochByIndex_UnknownReturnsNotFound(t *testing.T) { - r, _ := makeRegistry(t) - if _, ok := r.EpochByIndex(99); ok { - t.Fatal("EpochByIndex(99) returned ok, want not found") - } -} - -func TestRegistry_EpochByIndex_GenesisFound(t *testing.T) { - r, _ := makeRegistry(t) - ep, ok := r.EpochByIndex(0) - if !ok { - t.Fatal("EpochByIndex(0) not found") - } - if ep.EpochIndex() != 0 { - t.Fatalf("EpochIndex() = %d, want 0", ep.EpochIndex()) - } -} diff --git a/sei-tendermint/internal/autobahn/epoch/testonly.go b/sei-tendermint/internal/autobahn/epoch/testonly.go deleted file mode 100644 index c02099160f..0000000000 --- a/sei-tendermint/internal/autobahn/epoch/testonly.go +++ /dev/null @@ -1,23 +0,0 @@ -package epoch - -import ( - "time" - - "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" - "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" -) - -// GenRegistry generates a random Registry of the given committee size. -// Returns the generated secret keys as well. -// Intended for use in tests only. -func GenRegistry(rng utils.Rng, size int) (*Registry, []types.SecretKey) { - sks := utils.GenSliceN(rng, size, types.GenSecretKey) - weights := map[types.PublicKey]uint64{} - for _, sk := range sks { - weights[sk.Public()] = 1000 + uint64(rng.Intn(1000)) //nolint:gosec - } - committee := utils.OrPanic1(types.NewCommittee(weights)) - firstBlock := types.GenGlobalBlockNumber(rng) % 1000000 - registry := utils.OrPanic1(NewRegistry(committee, firstBlock, time.Now())) - return registry, sks -} diff --git a/sei-tendermint/internal/autobahn/pb/autobahn.pb.go b/sei-tendermint/internal/autobahn/pb/autobahn.pb.go index 1348b18d6b..0a1e281483 100644 --- a/sei-tendermint/internal/autobahn/pb/autobahn.pb.go +++ b/sei-tendermint/internal/autobahn/pb/autobahn.pb.go @@ -783,9 +783,8 @@ func (x *LaneRange) GetLastHash() []byte { type View struct { state protoimpl.MessageState `protogen:"open.v1"` - Index *uint64 `protobuf:"varint,1,opt,name=index,proto3,oneof" json:"index,omitempty"` // required - Number *uint64 `protobuf:"varint,2,opt,name=number,proto3,oneof" json:"number,omitempty"` // required - EpochIndex *uint64 `protobuf:"varint,3,opt,name=epoch_index,json=epochIndex,proto3,oneof" json:"epoch_index,omitempty"` // epoch this view belongs to; required + Index *uint64 `protobuf:"varint,1,opt,name=index,proto3,oneof" json:"index,omitempty"` // required + Number *uint64 `protobuf:"varint,2,opt,name=number,proto3,oneof" json:"number,omitempty"` // required unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -834,20 +833,12 @@ func (x *View) GetNumber() uint64 { return 0 } -func (x *View) GetEpochIndex() uint64 { - if x != nil && x.EpochIndex != nil { - return *x.EpochIndex - } - return 0 -} - type Proposal struct { state protoimpl.MessageState `protogen:"open.v1"` - View *View `protobuf:"bytes,1,opt,name=view,proto3,oneof" json:"view,omitempty"` // required. - Timestamp *Timestamp `protobuf:"bytes,5,opt,name=timestamp,proto3,oneof" json:"timestamp,omitempty"` // required - LaneRanges []*LaneRange `protobuf:"bytes,3,rep,name=lane_ranges,json=laneRanges,proto3" json:"lane_ranges,omitempty"` // Sorted by lane. - App *AppProposal `protobuf:"bytes,4,opt,name=app,proto3,oneof" json:"app,omitempty"` // optional - GlobalFirst *uint64 `protobuf:"varint,6,opt,name=global_first,json=globalFirst,proto3,oneof" json:"global_first,omitempty"` // required; first global block number of this proposal's global range + View *View `protobuf:"bytes,1,opt,name=view,proto3,oneof" json:"view,omitempty"` // required. + Timestamp *Timestamp `protobuf:"bytes,5,opt,name=timestamp,proto3,oneof" json:"timestamp,omitempty"` // required + LaneRanges []*LaneRange `protobuf:"bytes,3,rep,name=lane_ranges,json=laneRanges,proto3" json:"lane_ranges,omitempty"` // Sorted by lane. + App *AppProposal `protobuf:"bytes,4,opt,name=app,proto3,oneof" json:"app,omitempty"` // optional unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -910,13 +901,6 @@ func (x *Proposal) GetApp() *AppProposal { return nil } -func (x *Proposal) GetGlobalFirst() uint64 { - if x != nil && x.GlobalFirst != nil { - return *x.GlobalFirst - } - return 0 -} - type FullProposal struct { state protoimpl.MessageState `protogen:"open.v1"` ProposalV2 *SignedProposal `protobuf:"bytes,5,opt,name=proposal_v2,json=proposalV2,proto3" json:"proposal_v2,omitempty"` @@ -1498,9 +1482,7 @@ type AppProposal struct { // Index of the commit qc finalizing the block. RoadIndex *uint64 `protobuf:"varint,2,opt,name=road_index,json=roadIndex,proto3,oneof" json:"road_index,omitempty"` // required // App hash at that block. - AppHash []byte `protobuf:"bytes,3,opt,name=app_hash,json=appHash,proto3,oneof" json:"app_hash,omitempty"` // required - // Epoch this proposal belongs to. - EpochIndex *uint64 `protobuf:"varint,4,opt,name=epoch_index,json=epochIndex,proto3,oneof" json:"epoch_index,omitempty"` // required + AppHash []byte `protobuf:"bytes,3,opt,name=app_hash,json=appHash,proto3,oneof" json:"app_hash,omitempty"` // required unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1556,13 +1538,6 @@ func (x *AppProposal) GetAppHash() []byte { return nil } -func (x *AppProposal) GetEpochIndex() uint64 { - if x != nil && x.EpochIndex != nil { - return *x.EpochIndex - } - return 0 -} - // This is the signable message. // To sign ConsensusMsg/BlockMsg, you need to embed it in Msg first. type Msg struct { @@ -2262,27 +2237,22 @@ const file_autobahn_autobahn_proto_rawDesc = "" + "\x06_firstB\a\n" + "\x05_nextB\f\n" + "\n" + - "_last_hash\"\x97\x01\n" + + "_last_hash\"a\n" + "\x04View\x12\x19\n" + "\x05index\x18\x01 \x01(\x04H\x00R\x05index\x88\x01\x01\x12\x1b\n" + - "\x06number\x18\x02 \x01(\x04H\x01R\x06number\x88\x01\x01\x12$\n" + - "\vepoch_index\x18\x03 \x01(\x04H\x02R\n" + - "epochIndex\x88\x01\x01:\fȈ\xe2\xab\f\x01\xe8\x88\xe2\xab\f\x01B\b\n" + + "\x06number\x18\x02 \x01(\x04H\x01R\x06number\x88\x01\x01:\fȈ\xe2\xab\f\x01\xe8\x88\xe2\xab\f\x01B\b\n" + "\x06_indexB\t\n" + - "\a_numberB\x0e\n" + - "\f_epoch_index\"\xcf\x02\n" + + "\a_number\"\x96\x02\n" + "\bProposal\x12'\n" + "\x04view\x18\x01 \x01(\v2\x0e.autobahn.ViewH\x00R\x04view\x88\x01\x01\x126\n" + "\ttimestamp\x18\x05 \x01(\v2\x13.autobahn.TimestampH\x01R\ttimestamp\x88\x01\x01\x12<\n" + "\vlane_ranges\x18\x03 \x03(\v2\x13.autobahn.LaneRangeB\x06Ј\xe2\xab\fdR\n" + "laneRanges\x12,\n" + - "\x03app\x18\x04 \x01(\v2\x15.autobahn.AppProposalH\x02R\x03app\x88\x01\x01\x12&\n" + - "\fglobal_first\x18\x06 \x01(\x04H\x03R\vglobalFirst\x88\x01\x01:\fȈ\xe2\xab\f\x01\xe8\x88\xe2\xab\f\x01B\a\n" + + "\x03app\x18\x04 \x01(\v2\x15.autobahn.AppProposalH\x02R\x03app\x88\x01\x01:\fȈ\xe2\xab\f\x01\xe8\x88\xe2\xab\f\x01B\a\n" + "\x05_viewB\f\n" + "\n" + "_timestampB\x06\n" + - "\x04_appB\x0f\n" + - "\r_global_firstJ\x04\b\x02\x10\x03R\n" + + "\x04_appJ\x04\b\x02\x10\x03R\n" + "created_at\"\x96\x02\n" + "\fFullProposal\x129\n" + "\vproposal_v2\x18\x05 \x01(\v2\x18.autobahn.SignedProposalR\n" + @@ -2339,18 +2309,15 @@ const file_autobahn_autobahn_proto_rawDesc = "" + "_commit_qc\"k\n" + "\x05AppQC\x12)\n" + "\x04vote\x18\x01 \x01(\v2\x15.autobahn.AppProposalR\x04vote\x12/\n" + - "\x04sigs\x18\x02 \x03(\v2\x13.autobahn.SignatureB\x06Ј\xe2\xab\fdR\x04sigs:\x06\xe8\x88\xe2\xab\f\x01\"\xf5\x01\n" + + "\x04sigs\x18\x02 \x03(\v2\x13.autobahn.SignatureB\x06Ј\xe2\xab\fdR\x04sigs:\x06\xe8\x88\xe2\xab\f\x01\"\xbf\x01\n" + "\vAppProposal\x12(\n" + "\rglobal_number\x18\x01 \x01(\x04H\x00R\fglobalNumber\x88\x01\x01\x12\"\n" + "\n" + "road_index\x18\x02 \x01(\x04H\x01R\troadIndex\x88\x01\x01\x12&\n" + - "\bapp_hash\x18\x03 \x01(\fB\x06؈\xe2\xab\f H\x02R\aappHash\x88\x01\x01\x12$\n" + - "\vepoch_index\x18\x04 \x01(\x04H\x03R\n" + - "epochIndex\x88\x01\x01:\fȈ\xe2\xab\f\x01\xe8\x88\xe2\xab\f\x01B\x10\n" + + "\bapp_hash\x18\x03 \x01(\fB\x06؈\xe2\xab\f H\x02R\aappHash\x88\x01\x01:\fȈ\xe2\xab\f\x01\xe8\x88\xe2\xab\f\x01B\x10\n" + "\x0e_global_numberB\r\n" + "\v_road_indexB\v\n" + - "\t_app_hashB\x0e\n" + - "\f_epoch_index\"\x98\x03\n" + + "\t_app_hash\"\x98\x03\n" + "\x03Msg\x126\n" + "\rlane_proposal\x18\x01 \x01(\v2\x0f.autobahn.BlockH\x00R\flaneProposal\x124\n" + "\tlane_vote\x18\x02 \x01(\v2\x15.autobahn.BlockHeaderH\x00R\blaneVote\x120\n" + diff --git a/sei-tendermint/internal/autobahn/pb/autobahn.wireguard.go b/sei-tendermint/internal/autobahn/pb/autobahn.wireguard.go index 99b476ba5d..f27ae2e428 100644 --- a/sei-tendermint/internal/autobahn/pb/autobahn.wireguard.go +++ b/sei-tendermint/internal/autobahn/pb/autobahn.wireguard.go @@ -40,47 +40,47 @@ func (*LaneRange) MaxSize() int { } func (*View) MaxSize() int { - return 33 + return 22 } func (*Proposal) MaxSize() int { - return 9539 + return 9506 } func (*FullProposal) MaxSize() int { - return 1107571 + return 1106394 } func (*PrepareQC) MaxSize() int { - return 19942 + return 19909 } func (*CommitQC) MaxSize() int { - return 19942 + return 19909 } func (*FullCommitQC) MaxSize() int { - return 136946 + return 136913 } func (*TimeoutVote) MaxSize() int { - return 46 + return 35 } func (*TimeoutQC) MaxSize() int { - return 35446 + return 34313 } func (*FullTimeoutVote) MaxSize() int { - return 20101 + return 20057 } func (*AppQC) MaxSize() int { - return 10469 + return 10458 } func (*AppProposal) MaxSize() int { - return 67 + return 56 } func (*Msg) MaxSize() int { @@ -88,15 +88,15 @@ func (*Msg) MaxSize() int { } func (*SignedProposal) MaxSize() int { - return 9646 + return 9613 } func (*SignedTimeoutVote) MaxSize() int { - return 152 + return 141 } func (*SignedAppVote) MaxSize() int { - return 173 + return 162 } func (*SignedBlock) MaxSize() int { @@ -108,11 +108,11 @@ func (*SignedBlockHeader) MaxSize() int { } func (*SignedAppProposal) MaxSize() int { - return 173 + return 162 } func (*ConsensusReq) MaxSize() int { - return 1107575 + return 1106398 } func init() { @@ -207,7 +207,6 @@ func init() { runtime.MustRegister[*View](runtime.Schema{ 1: {MaxCount: 1}, 2: {MaxCount: 1}, - 3: {MaxCount: 1}, }) // Register the wireguard.Schema generated for autobahn.Proposal. @@ -216,7 +215,6 @@ func init() { 5: {MaxCount: 1, Nested: utils.Some(reflect.TypeFor[*Timestamp]())}, 3: {MaxCount: 100, Nested: utils.Some(reflect.TypeFor[*LaneRange]())}, 4: {MaxCount: 1, Nested: utils.Some(reflect.TypeFor[*AppProposal]())}, - 6: {MaxCount: 1}, }) // Register the wireguard.Schema generated for autobahn.FullProposal. @@ -290,7 +288,6 @@ func init() { 1: {MaxCount: 1}, 2: {MaxCount: 1}, 3: {MaxCount: 1, MaxSize: 32}, - 4: {MaxCount: 1}, }) // Register the wireguard.Schema generated for autobahn.Msg. diff --git a/sei-tendermint/internal/autobahn/producer/mempool_test.go b/sei-tendermint/internal/autobahn/producer/mempool_test.go index 8961595683..3b83fc77f7 100644 --- a/sei-tendermint/internal/autobahn/producer/mempool_test.go +++ b/sei-tendermint/internal/autobahn/producer/mempool_test.go @@ -16,7 +16,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/proxy" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" @@ -118,7 +117,7 @@ func (a *testApp) Cfg() *Config { } func (a *testApp) Proxy() *proxy.Proxy { - return proxy.New(a) + return proxy.New(a, proxy.NopMetrics()) } func (a *testApp) EvmNonce(addr common.Address) uint64 { @@ -182,8 +181,7 @@ func (env *testEnv) Run(ctx context.Context) error { s.Spawn(func() error { return env.state.Run(ctx) }) // Process blocks. stats := blockStats{} - firstBlock := env.data.Registry().FirstBlock() - for i := firstBlock; ; i += 1 { + for i := env.data.Committee().FirstBlock(); ; i += 1 { // Wait for the next block to be finalized. b, err := env.data.GlobalBlock(ctx, i) if err != nil { @@ -191,7 +189,7 @@ func (env *testEnv) Run(ctx context.Context) error { } // Check that adding first transaction to the previous block would exceed the limit. - if i > firstBlock { + if i > env.data.Committee().FirstBlock() { tx, err := decodeTxSpec(b.Payload.Txs()[0]) if err != nil { return fmt.Errorf("decodeTxSpec(): %w", err) @@ -230,10 +228,10 @@ func (env *testEnv) Run(ctx context.Context) error { } func newTestEnv(rng utils.Rng, cfg *Config, app *proxy.Proxy) *testEnv { - registry, keys := epoch.GenRegistry(rng, 1) + committee, keys := types.GenCommittee(rng, 1) dataState := utils.OrPanic1(data.NewState( - &data.Config{Registry: registry}, - utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())), + &data.Config{Committee: committee}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)), )) consensusState := utils.OrPanic1(consensus.NewState(&consensus.Config{ Key: keys[0], diff --git a/sei-tendermint/internal/blocksync/reactor.go b/sei-tendermint/internal/blocksync/reactor.go index 3d8d84be55..a80332853b 100644 --- a/sei-tendermint/internal/blocksync/reactor.go +++ b/sei-tendermint/internal/blocksync/reactor.go @@ -105,6 +105,7 @@ type SyncerConfig struct { BlockExec *sm.BlockExecutor ConsReactor utils.Option[ConsensusReactor] BlockSync bool + Metrics *consensus.Metrics EventBus *eventbus.EventBus RestartEvent func() SelfRemediationConfig *config.SelfRemediationConfig @@ -139,6 +140,7 @@ type syncController struct { router *p2p.Router channel *p2p.Channel[*pb.Message] consReactor utils.Option[ConsensusReactor] + metrics *consensus.Metrics eventBus *eventbus.EventBus // Mutable sync state initialized on start and updated as blocksync runs. @@ -181,6 +183,7 @@ func NewReactor( router: router, channel: channel, consReactor: cfg.ConsReactor, + metrics: cfg.Metrics, eventBus: cfg.EventBus, restartEvent: cfg.RestartEvent, blocksBehindThreshold: cfg.SelfRemediationConfig.BlocksBehindThreshold, @@ -579,7 +582,7 @@ func (s *syncController) poolRoutine(ctx context.Context, pool *BlockPool, initi panic(fmt.Sprintf("failed to process committed block (%d:%X): %v", first.Height, first.Hash(), err)) } - consensus.Global.RecordConsMetrics(first) + s.metrics.RecordConsMetrics(first) blocksSynced++ if blocksSynced%100 == 0 { diff --git a/sei-tendermint/internal/blocksync/reactor_test.go b/sei-tendermint/internal/blocksync/reactor_test.go index a354e03f3c..cf0eca7ae0 100644 --- a/sei-tendermint/internal/blocksync/reactor_test.go +++ b/sei-tendermint/internal/blocksync/reactor_test.go @@ -17,6 +17,7 @@ import ( abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" "github.com/sei-protocol/sei-chain/sei-tendermint/config" + "github.com/sei-protocol/sei-chain/sei-tendermint/internal/consensus" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/eventbus" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/proxy" @@ -93,12 +94,12 @@ func makeReactor( stateDB := dbm.NewMemDB() stateStore := sm.NewStore(stateDB) blockStore := store.NewBlockStore(blockDB) - proxyApp := proxy.New(app) + proxyApp := proxy.New(app, proxy.NopMetrics()) state, err := sm.MakeGenesisState(genDoc) require.NoError(t, err) require.NoError(t, stateStore.Save(state)) - mp := mempool.NewTxMempool(mempool.TestConfig(), proxyApp, mempool.NopTxConstraintsFetcher) + mp := mempool.NewTxMempool(mempool.TestConfig(), proxyApp, mempool.NopMetrics(), mempool.NopTxConstraintsFetcher) bus := eventbus.NewDefault() require.NoError(t, bus.Start(ctx)) @@ -109,6 +110,7 @@ func makeReactor( sm.EmptyEvidencePool{}, blockStore, bus, + sm.NopMetrics(), types.DefaultConsensusPolicy(), ) @@ -120,6 +122,7 @@ func makeReactor( BlockExec: blockExec, ConsReactor: utils.None[ConsensusReactor](), BlockSync: blockSync, + Metrics: consensus.NopMetrics(), EventBus: nil, // eventbus can be nil RestartEvent: restartEvent, SelfRemediationConfig: selfRemediationConfig, diff --git a/sei-tendermint/internal/blocksync/reactor_validation_failure_test.go b/sei-tendermint/internal/blocksync/reactor_validation_failure_test.go index 291c224df6..453a797426 100644 --- a/sei-tendermint/internal/blocksync/reactor_validation_failure_test.go +++ b/sei-tendermint/internal/blocksync/reactor_validation_failure_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/require" "github.com/sei-protocol/sei-chain/sei-tendermint/config" + "github.com/sei-protocol/sei-chain/sei-tendermint/internal/consensus" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p" sm "github.com/sei-protocol/sei-chain/sei-tendermint/internal/state" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/test/factory" @@ -76,7 +77,8 @@ func TestPoolRoutine_DoesNotReturnOnValidationFailure(t *testing.T) { evictNetwork := p2p.MakeTestNetwork(t, p2p.TestNetworkOptions{NumNodes: 1}) syncer := &syncController{ - router: evictNetwork.Node(evictNetwork.NodeIDs()[0]).Router, + router: evictNetwork.Node(evictNetwork.NodeIDs()[0]).Router, + metrics: consensus.NopMetrics(), } results := make(chan error, 1) @@ -132,7 +134,8 @@ func TestPoolRoutine_RetriesAfterValidationFailure(t *testing.T) { pool.SetPeerRange(badPeer, 1, 2) syncer := &syncController{ - router: network.Node(network.NodeIDs()[0]).Router, + router: network.Node(network.NodeIDs()[0]).Router, + metrics: consensus.NopMetrics(), } results := make(chan error, 1) diff --git a/sei-tendermint/internal/consensus/byzantine_test.go b/sei-tendermint/internal/consensus/byzantine_test.go index fa0bd68866..3084212535 100644 --- a/sei-tendermint/internal/consensus/byzantine_test.go +++ b/sei-tendermint/internal/consensus/byzantine_test.go @@ -95,10 +95,10 @@ package consensus // // // Make a full instance of the evidence pool // evidenceDB := dbm.NewMemDB() -// evpool := evidence.NewPool(evidenceDB, stateStore, blockStore, eventBus) +// evpool := evidence.NewPool(evidenceDB, stateStore, blockStore, evidence.NopMetrics(), eventBus) // // // Make State -// blockExec := sm.NewBlockExecutor(stateStore, proxyAppConnCon, mempool, evpool, blockStore, eventBus) +// blockExec := sm.NewBlockExecutor(stateStore, proxyAppConnCon, mempool, evpool, blockStore, eventBus, sm.NopMetrics()) // cs, err := NewState( thisConfig.Consensus, stateStore, blockExec, blockStore, mempool, evpool, eventBus, []trace.TracerProviderOption{}) // require.NoError(t, err) // // set private validator diff --git a/sei-tendermint/internal/consensus/common_test.go b/sei-tendermint/internal/consensus/common_test.go index 39379d3932..ffbfb25bce 100644 --- a/sei-tendermint/internal/consensus/common_test.go +++ b/sei-tendermint/internal/consensus/common_test.go @@ -501,6 +501,7 @@ func newStateWithConfigAndBlockStore( mempool := mempool.NewTxMempool( thisConfig.Mempool.ToMempoolConfig(), app, + mempool.NopMetrics(), mempool.NopTxConstraintsFetcher, ) @@ -518,7 +519,7 @@ func newStateWithConfigAndBlockStore( panic(fmt.Errorf("eventBus.Start(): %w", err)) } - blockExec := sm.NewBlockExecutor(stateStore, app, mempool, evpool, blockStore, eventBus, types.DefaultConsensusPolicy()) + blockExec := sm.NewBlockExecutor(stateStore, app, mempool, evpool, blockStore, eventBus, sm.NopMetrics(), types.DefaultConsensusPolicy()) wal, err := OpenWAL(thisConfig.Consensus.WalFile()) if err != nil { panic(err) @@ -533,6 +534,7 @@ func newStateWithConfigAndBlockStore( evpool, eventBus, []trace.TracerProviderOption{}, + NopMetrics(), )} if err := stateHandle.updateStateFromStore(); err != nil { panic(err) @@ -992,7 +994,7 @@ func makeConsensusState( require.NoError(t, err) app.SetValidators(vals) - proxyApp := proxy.New(app) + proxyApp := proxy.New(app, proxy.NopMetrics()) css[i] = newStateWithConfigAndBlockStore(t, thisConfig, state, privVals[i], proxyApp, blockStore) css[i].SetTimeoutTicker(tickerFunc()) } @@ -1057,7 +1059,7 @@ func randConsensusNetWithPeers( app.SetValidators(vals) // sm.SaveState(stateDB,state) //height 1's validatorsInfo already saved in LoadStateFromDBOrGenesisDoc above - proxyApp := proxy.New(app) + proxyApp := proxy.New(app, proxy.NopMetrics()) css[i] = newStateWithConfig(t, thisConfig, state, privVal, proxyApp) css[i].SetTimeoutTicker(tickerFunc()) } diff --git a/sei-tendermint/internal/consensus/mempool_test.go b/sei-tendermint/internal/consensus/mempool_test.go index 3487c01a1a..e08ebef9c5 100644 --- a/sei-tendermint/internal/consensus/mempool_test.go +++ b/sei-tendermint/internal/consensus/mempool_test.go @@ -63,7 +63,7 @@ func TestMempoolNoProgressUntilTxsAvailable(t *testing.T) { Validators: 1, Power: 10, Params: factory.ConsensusParams()}) - cs := newStateWithConfig(t, config, state, privVals[0], proxy.New(NewCounterApplication())) + cs := newStateWithConfig(t, config, state, privVals[0], proxy.New(NewCounterApplication(), proxy.NopMetrics())) height, round := cs.roundState.Height(), cs.roundState.Round() newBlockCh := subscribe(ctx, t, cs.eventBus, types.EventQueryNewBlock) cs.startTestRound(ctx, height, round) @@ -89,7 +89,7 @@ func TestMempoolProgressAfterCreateEmptyBlocksInterval(t *testing.T) { Validators: 1, Power: 10, Params: factory.ConsensusParams()}) - cs := newStateWithConfig(t, config, state, privVals[0], proxy.New(NewCounterApplication())) + cs := newStateWithConfig(t, config, state, privVals[0], proxy.New(NewCounterApplication(), proxy.NopMetrics())) height, round := cs.roundState.Height(), cs.roundState.Round() newBlockCh := subscribe(ctx, t, cs.eventBus, types.EventQueryNewBlock) @@ -113,7 +113,7 @@ func TestMempoolProgressInHigherRound(t *testing.T) { Validators: 1, Power: 10, Params: factory.ConsensusParams()}) - cs := newStateWithConfig(t, config, state, privVals[0], proxy.New(NewCounterApplication())) + cs := newStateWithConfig(t, config, state, privVals[0], proxy.New(NewCounterApplication(), proxy.NopMetrics())) height, round := cs.roundState.Height(), cs.roundState.Round() newBlockCh := subscribe(ctx, t, cs.eventBus, types.EventQueryNewBlock) newRoundCh := subscribe(ctx, t, cs.eventBus, types.EventQueryNewRound) @@ -168,7 +168,7 @@ func TestMempoolTxConcurrentWithCommit(t *testing.T) { blockStore := store.NewBlockStore(dbm.NewMemDB()) cs := newStateWithConfigAndBlockStore( - t, config, state, privVals[0], proxy.New(NewCounterApplication()), blockStore) + t, config, state, privVals[0], proxy.New(NewCounterApplication(), proxy.NopMetrics()), blockStore) err := stateStore.Save(state) require.NoError(t, err) @@ -211,7 +211,7 @@ func TestMempoolRmBadTx(t *testing.T) { app := NewCounterApplication() stateStore := sm.NewStore(dbm.NewMemDB()) blockStore := store.NewBlockStore(dbm.NewMemDB()) - proxyApp := proxy.New(app) + proxyApp := proxy.New(app, proxy.NopMetrics()) cs := newStateWithConfigAndBlockStore(t, config, state, privVals[0], proxyApp, blockStore) err := stateStore.Save(state) require.NoError(t, err) diff --git a/sei-tendermint/internal/consensus/metrics.gen.go b/sei-tendermint/internal/consensus/metrics.gen.go index 69f41c6d5b..2b0dc95e3e 100644 --- a/sei-tendermint/internal/consensus/metrics.gen.go +++ b/sei-tendermint/internal/consensus/metrics.gen.go @@ -3,492 +3,339 @@ package consensus import ( - "github.com/prometheus/client_golang/prometheus" - tmprometheus "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/prometheus" + "github.com/go-kit/kit/metrics/discard" + prometheus "github.com/go-kit/kit/metrics/prometheus" + stdprometheus "github.com/prometheus/client_golang/prometheus" ) -var Global = NewMetrics() - -func init() { - prometheus.MustRegister( - Global.Height, - Global.ValidatorLastSignedHeight, - Global.Rounds, - Global.RoundDuration, - Global.Validators, - Global.ValidatorsPower, - Global.ValidatorPower, - Global.ValidatorMissedBlocks, - Global.MissingValidators, - Global.MissingValidatorsPower, - Global.ByzantineValidators, - Global.ByzantineValidatorsPower, - Global.BlockIntervalSeconds, - Global.NumTxs, - Global.BlockSizeBytes, - Global.TotalTxs, - Global.CommittedHeight, - Global.BlockSyncing, - Global.StateSyncing, - Global.BlockParts, - Global.StepDuration, - Global.BlockGossipReceiveLatency, - Global.BlockGossipPartsReceived, - Global.ProposalBlockCreatedOnPropose, - Global.ProposalTxs, - Global.ProposalMissingTxs, - Global.MissingTxs, - Global.QuorumPrevoteDelay, - Global.FullPrevoteDelay, - Global.ProposalTimestampDifference, - Global.ProposalReceiveCount, - Global.ProposalCreateCount, - Global.RoundVotingPowerPercent, - Global.LateVotes, - Global.FinalRound, - Global.ProposeLatency, - Global.PrevoteLatency, - Global.ConsensusTime, - Global.CompleteProposalTime, - Global.ApplyBlockLatency, - Global.StepLatency, - Global.StepCount, - ) -} - -func NewMetrics() *Metrics { +func PrometheusMetrics(namespace string, labelsAndValues ...string) *Metrics { + labels := []string{} + for i := 0; i < len(labelsAndValues); i += 2 { + labels = append(labels, labelsAndValues[i]) + } return &Metrics{ - Height: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + Height: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "height", Help: "Height of the chain.", - }, nil), - ValidatorLastSignedHeight: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + ValidatorLastSignedHeight: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "validator_last_signed_height", Help: "Last height signed by this validator if the node is a validator.", - }, []string{"validator_address"}), - Rounds: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, append(labels, "validator_address")).With(labelsAndValues...), + Rounds: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "rounds", Help: "Number of rounds.", - }, nil), - RoundDuration: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + RoundDuration: prometheus.NewHistogramFrom(stdprometheus.HistogramOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "round_duration", Help: "Histogram of round duration.", - Buckets: prometheus.ExponentialBucketsRange(0.1, 100, 8), - }, nil), - Validators: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + + Buckets: stdprometheus.ExponentialBucketsRange(0.1, 100, 8), + }, labels).With(labelsAndValues...), + Validators: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "validators", Help: "Number of validators.", - }, nil), - ValidatorsPower: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + ValidatorsPower: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "validators_power", Help: "Total power of all validators.", - }, nil), - ValidatorPower: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + ValidatorPower: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "validator_power", Help: "Power of a validator.", - }, []string{"validator_address"}), - ValidatorMissedBlocks: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, append(labels, "validator_address")).With(labelsAndValues...), + ValidatorMissedBlocks: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "validator_missed_blocks", Help: "Amount of blocks missed per validator.", - }, []string{"validator_address"}), - MissingValidators: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, append(labels, "validator_address")).With(labelsAndValues...), + MissingValidators: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "missing_validators", Help: "Number of validators who did not sign.", - }, nil), - MissingValidatorsPower: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + MissingValidatorsPower: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "missing_validators_power", Help: "Total power of the missing validators.", - }, []string{"validator_address"}), - ByzantineValidators: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, append(labels, "validator_address")).With(labelsAndValues...), + ByzantineValidators: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "byzantine_validators", Help: "Number of validators who tried to double sign.", - }, nil), - ByzantineValidatorsPower: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + ByzantineValidatorsPower: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "byzantine_validators_power", Help: "Total power of the byzantine validators.", - }, nil), - BlockIntervalSeconds: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + BlockIntervalSeconds: prometheus.NewHistogramFrom(stdprometheus.HistogramOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "block_interval_seconds", Help: "Time in seconds between this and the last block.", - Buckets: prometheus.ExponentialBuckets(0.1, 1.3, 20), - }, nil), - NumTxs: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + + Buckets: stdprometheus.ExponentialBuckets(0.1, 1.3, 20), + }, labels).With(labelsAndValues...), + NumTxs: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "num_txs", Help: "Number of transactions.", - }, nil), - BlockSizeBytes: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + BlockSizeBytes: prometheus.NewHistogramFrom(stdprometheus.HistogramOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "block_size_bytes", Help: "Size of the block.", - Buckets: prometheus.ExponentialBuckets(1000, 1.5, 25), - }, nil), - TotalTxs: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + + Buckets: stdprometheus.ExponentialBuckets(1000, 1.5, 25), + }, labels).With(labelsAndValues...), + TotalTxs: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "total_txs", Help: "Total number of transactions.", - }, nil), - CommittedHeight: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + CommittedHeight: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "latest_block_height", Help: "The latest block height.", - }, nil), - BlockSyncing: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + BlockSyncing: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "block_syncing", Help: "Whether or not a node is block syncing. 1 if yes, 0 if no.", - }, nil), - StateSyncing: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + StateSyncing: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "state_syncing", Help: "Whether or not a node is state syncing. 1 if yes, 0 if no.", - }, nil), - BlockParts: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + BlockParts: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "block_parts", Help: "Number of block parts transmitted by each peer.", - }, []string{"peer_id"}), - StepDuration: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, + }, append(labels, "peer_id")).With(labelsAndValues...), + StepDuration: prometheus.NewHistogramFrom(stdprometheus.HistogramOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "step_duration", Help: "Histogram of durations for each step in the consensus protocol.", - Buckets: prometheus.ExponentialBucketsRange(0.1, 100, 8), - }, []string{"step"}), - BlockGossipReceiveLatency: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, + + Buckets: stdprometheus.ExponentialBucketsRange(0.1, 100, 8), + }, append(labels, "step")).With(labelsAndValues...), + BlockGossipReceiveLatency: prometheus.NewHistogramFrom(stdprometheus.HistogramOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "block_gossip_receive_latency", Help: "Histogram of time taken to receive a block in seconds, measured between when a new block is first discovered to when the block is completed.", - Buckets: prometheus.ExponentialBucketsRange(0.1, 100, 8), - }, nil), - BlockGossipPartsReceived: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + + Buckets: stdprometheus.ExponentialBucketsRange(0.1, 100, 8), + }, labels).With(labelsAndValues...), + BlockGossipPartsReceived: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "block_gossip_parts_received", Help: "Number of block parts received by the node, separated by whether the part was relevant to the block the node is trying to gather or not.", - }, []string{"matches_current"}), - ProposalBlockCreatedOnPropose: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + }, append(labels, "matches_current")).With(labelsAndValues...), + ProposalBlockCreatedOnPropose: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "proposal_block_created_on_propose", Help: "Number of proposal blocks created on propose received.", - }, []string{"success"}), - ProposalTxs: prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, append(labels, "success")).With(labelsAndValues...), + ProposalTxs: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "proposal_txs", Help: "Number of txs in a proposal.", - }, nil), - ProposalMissingTxs: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + ProposalMissingTxs: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "proposal_missing_txs", Help: "Number of missing txs when trying to create proposal.", - }, nil), - MissingTxs: prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + MissingTxs: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "missing_txs", Help: "Number of missing txs when a proposal is received", - }, []string{"proposer_address"}), - QuorumPrevoteDelay: prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, append(labels, "proposer_address")).With(labelsAndValues...), + QuorumPrevoteDelay: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "quorum_prevote_delay", Help: "Interval in seconds between the proposal timestamp and the timestamp of the earliest prevote that achieved a quorum.", - }, []string{"proposer_address"}), - FullPrevoteDelay: prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, append(labels, "proposer_address")).With(labelsAndValues...), + FullPrevoteDelay: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "full_prevote_delay", Help: "Interval in seconds between the proposal timestamp and the timestamp of the latest prevote in a round where all validators voted.", - }, []string{"proposer_address"}), - ProposalTimestampDifference: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, + }, append(labels, "proposer_address")).With(labelsAndValues...), + ProposalTimestampDifference: prometheus.NewHistogramFrom(stdprometheus.HistogramOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "proposal_timestamp_difference", Help: "Difference between the timestamp in the proposal message and the local time of the validator at the time it received the message.", - Buckets: []float64{-10, -.5, -.025, 0, .1, .5, 1, 1.5, 2, 10}, - }, []string{"is_timely"}), - ProposalReceiveCount: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + + Buckets: []float64{-10, -.5, -.025, 0, .1, .5, 1, 1.5, 2, 10}, + }, append(labels, "is_timely")).With(labelsAndValues...), + ProposalReceiveCount: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "proposal_receive_count", Help: "Total number of proposals received by the node since process start labeled by application response status.", - }, []string{"status"}), - ProposalCreateCount: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + }, append(labels, "status")).With(labelsAndValues...), + ProposalCreateCount: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "proposal_create_count", Help: "Total number of proposals created by the node since process start.", - }, nil), - RoundVotingPowerPercent: prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + RoundVotingPowerPercent: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "round_voting_power_percent", Help: "A value between 0 and 1.0 representing the percentage of the total voting power per vote type received within a round.", - }, []string{"vote_type"}), - LateVotes: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + }, append(labels, "vote_type")).With(labelsAndValues...), + LateVotes: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "late_votes", Help: "Number of votes received by the node since process start that correspond to earlier heights and rounds than this node is currently in.", - }, []string{"validator_address"}), - FinalRound: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, + }, append(labels, "validator_address")).With(labelsAndValues...), + FinalRound: prometheus.NewHistogramFrom(stdprometheus.HistogramOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "final_round", Help: "The final round number for where the proposal block reach consensus in, starting at 0.", - Buckets: []float64{0, 1, 2, 3, 5, 10}, - }, []string{"proposer_address"}), - ProposeLatency: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, + + Buckets: []float64{0, 1, 2, 3, 5, 10}, + }, append(labels, "proposer_address")).With(labelsAndValues...), + ProposeLatency: prometheus.NewHistogramFrom(stdprometheus.HistogramOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "propose_latency", Help: "Number of seconds from when the consensus round started till the proposal receive time", - Buckets: prometheus.ExponentialBucketsRange(0.01, 10, 10), - }, []string{"proposer_address"}), - PrevoteLatency: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, + + Buckets: stdprometheus.ExponentialBucketsRange(0.01, 10, 10), + }, append(labels, "proposer_address")).With(labelsAndValues...), + PrevoteLatency: prometheus.NewHistogramFrom(stdprometheus.HistogramOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "prevote_latency", Help: "Number of seconds from when first prevote arrive till other remaining prevote arrives for each validator", - Buckets: prometheus.ExponentialBucketsRange(0.01, 10, 10), - }, []string{"validator_address"}), - ConsensusTime: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, + + Buckets: stdprometheus.ExponentialBucketsRange(0.01, 10, 10), + }, append(labels, "validator_address")).With(labelsAndValues...), + ConsensusTime: prometheus.NewHistogramFrom(stdprometheus.HistogramOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "consensus_time", Help: "Number of seconds spent on consensus", - Buckets: prometheus.ExponentialBuckets(0.01, 1.3, 25), - }, nil), - CompleteProposalTime: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, + + Buckets: stdprometheus.ExponentialBuckets(0.01, 1.3, 25), + }, labels).With(labelsAndValues...), + CompleteProposalTime: prometheus.NewHistogramFrom(stdprometheus.HistogramOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "complete_proposal_time", Help: "CompleteProposalTime measures how long it takes between receiving a proposal and finishing processing all of its parts. Note that this means it also includes network latency from block parts gossip", - Buckets: prometheus.ExponentialBuckets(0.01, 1.3, 25), - }, nil), - ApplyBlockLatency: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, + + Buckets: stdprometheus.ExponentialBuckets(0.01, 1.3, 25), + }, labels).With(labelsAndValues...), + ApplyBlockLatency: prometheus.NewHistogramFrom(stdprometheus.HistogramOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "apply_block_latency", Help: "ApplyBlockLatency measures how long it takes to execute ApplyBlock in finalize commit step", - Buckets: prometheus.ExponentialBuckets(0.01, 1.3, 25), - }, nil), - StepLatency: prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + + Buckets: stdprometheus.ExponentialBuckets(0.01, 1.3, 25), + }, labels).With(labelsAndValues...), + StepLatency: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "step_latency", Help: "", - }, []string{"step"}), - StepCount: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, append(labels, "step")).With(labelsAndValues...), + StepCount: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "step_count", Help: "", - }, []string{"step"}), + }, append(labels, "step")).With(labelsAndValues...), } } -func (m *Metrics) HeightAt() *tmprometheus.GaugeInt { - return m.Height.WithLabelValues() -} - -func (m *Metrics) ValidatorLastSignedHeightAt(validator_address string) *tmprometheus.GaugeInt { - return m.ValidatorLastSignedHeight.WithLabelValues(validator_address) -} - -func (m *Metrics) RoundsAt() *tmprometheus.GaugeInt { - return m.Rounds.WithLabelValues() -} - -func (m *Metrics) RoundDurationAt() *tmprometheus.Histogram { - return m.RoundDuration.WithLabelValues() -} - -func (m *Metrics) ValidatorsAt() *tmprometheus.GaugeInt { - return m.Validators.WithLabelValues() -} - -func (m *Metrics) ValidatorsPowerAt() *tmprometheus.GaugeInt { - return m.ValidatorsPower.WithLabelValues() -} - -func (m *Metrics) ValidatorPowerAt(validator_address string) *tmprometheus.GaugeInt { - return m.ValidatorPower.WithLabelValues(validator_address) -} - -func (m *Metrics) ValidatorMissedBlocksAt(validator_address string) *tmprometheus.GaugeInt { - return m.ValidatorMissedBlocks.WithLabelValues(validator_address) -} - -func (m *Metrics) MissingValidatorsAt() *tmprometheus.GaugeInt { - return m.MissingValidators.WithLabelValues() -} - -func (m *Metrics) MissingValidatorsPowerAt(validator_address string) *tmprometheus.GaugeInt { - return m.MissingValidatorsPower.WithLabelValues(validator_address) -} - -func (m *Metrics) ByzantineValidatorsAt() *tmprometheus.GaugeInt { - return m.ByzantineValidators.WithLabelValues() -} - -func (m *Metrics) ByzantineValidatorsPowerAt() *tmprometheus.GaugeInt { - return m.ByzantineValidatorsPower.WithLabelValues() -} - -func (m *Metrics) BlockIntervalSecondsAt() *tmprometheus.Histogram { - return m.BlockIntervalSeconds.WithLabelValues() -} - -func (m *Metrics) NumTxsAt() *tmprometheus.GaugeInt { - return m.NumTxs.WithLabelValues() -} - -func (m *Metrics) BlockSizeBytesAt() *tmprometheus.Histogram { - return m.BlockSizeBytes.WithLabelValues() -} - -func (m *Metrics) TotalTxsAt() *tmprometheus.GaugeInt { - return m.TotalTxs.WithLabelValues() -} - -func (m *Metrics) CommittedHeightAt() *tmprometheus.GaugeInt { - return m.CommittedHeight.WithLabelValues() -} - -func (m *Metrics) BlockSyncingAt() *tmprometheus.GaugeInt { - return m.BlockSyncing.WithLabelValues() -} - -func (m *Metrics) StateSyncingAt() *tmprometheus.GaugeInt { - return m.StateSyncing.WithLabelValues() -} - -func (m *Metrics) BlockPartsAt(peer_id string) *tmprometheus.CounterInt { - return m.BlockParts.WithLabelValues(peer_id) -} - -func (m *Metrics) StepDurationAt(step string) *tmprometheus.Histogram { - return m.StepDuration.WithLabelValues(step) -} - -func (m *Metrics) BlockGossipReceiveLatencyAt() *tmprometheus.Histogram { - return m.BlockGossipReceiveLatency.WithLabelValues() -} - -func (m *Metrics) BlockGossipPartsReceivedAt(matches_current string) *tmprometheus.CounterInt { - return m.BlockGossipPartsReceived.WithLabelValues(matches_current) -} - -func (m *Metrics) ProposalBlockCreatedOnProposeAt(success string) *tmprometheus.CounterInt { - return m.ProposalBlockCreatedOnPropose.WithLabelValues(success) -} - -func (m *Metrics) ProposalTxsAt() prometheus.Gauge { - return m.ProposalTxs.WithLabelValues() -} - -func (m *Metrics) ProposalMissingTxsAt() *tmprometheus.GaugeInt { - return m.ProposalMissingTxs.WithLabelValues() -} - -func (m *Metrics) MissingTxsAt(proposer_address string) prometheus.Gauge { - return m.MissingTxs.WithLabelValues(proposer_address) -} - -func (m *Metrics) QuorumPrevoteDelayAt(proposer_address string) prometheus.Gauge { - return m.QuorumPrevoteDelay.WithLabelValues(proposer_address) -} - -func (m *Metrics) FullPrevoteDelayAt(proposer_address string) prometheus.Gauge { - return m.FullPrevoteDelay.WithLabelValues(proposer_address) -} - -func (m *Metrics) ProposalTimestampDifferenceAt(is_timely string) *tmprometheus.Histogram { - return m.ProposalTimestampDifference.WithLabelValues(is_timely) -} - -func (m *Metrics) ProposalReceiveCountAt(status string) *tmprometheus.CounterInt { - return m.ProposalReceiveCount.WithLabelValues(status) -} - -func (m *Metrics) ProposalCreateCountAt() *tmprometheus.CounterInt { - return m.ProposalCreateCount.WithLabelValues() -} - -func (m *Metrics) RoundVotingPowerPercentAt(vote_type string) prometheus.Gauge { - return m.RoundVotingPowerPercent.WithLabelValues(vote_type) -} - -func (m *Metrics) LateVotesAt(validator_address string) *tmprometheus.CounterInt { - return m.LateVotes.WithLabelValues(validator_address) -} - -func (m *Metrics) FinalRoundAt(proposer_address string) *tmprometheus.Histogram { - return m.FinalRound.WithLabelValues(proposer_address) -} - -func (m *Metrics) ProposeLatencyAt(proposer_address string) *tmprometheus.Histogram { - return m.ProposeLatency.WithLabelValues(proposer_address) -} - -func (m *Metrics) PrevoteLatencyAt(validator_address string) *tmprometheus.Histogram { - return m.PrevoteLatency.WithLabelValues(validator_address) -} - -func (m *Metrics) ConsensusTimeAt() *tmprometheus.Histogram { - return m.ConsensusTime.WithLabelValues() -} - -func (m *Metrics) CompleteProposalTimeAt() *tmprometheus.Histogram { - return m.CompleteProposalTime.WithLabelValues() -} - -func (m *Metrics) ApplyBlockLatencyAt() *tmprometheus.Histogram { - return m.ApplyBlockLatency.WithLabelValues() -} - -func (m *Metrics) StepLatencyAt(step string) prometheus.Gauge { - return m.StepLatency.WithLabelValues(step) -} - -func (m *Metrics) StepCountAt(step string) *tmprometheus.GaugeInt { - return m.StepCount.WithLabelValues(step) +func NopMetrics() *Metrics { + return &Metrics{ + Height: discard.NewGauge(), + ValidatorLastSignedHeight: discard.NewGauge(), + Rounds: discard.NewGauge(), + RoundDuration: discard.NewHistogram(), + Validators: discard.NewGauge(), + ValidatorsPower: discard.NewGauge(), + ValidatorPower: discard.NewGauge(), + ValidatorMissedBlocks: discard.NewGauge(), + MissingValidators: discard.NewGauge(), + MissingValidatorsPower: discard.NewGauge(), + ByzantineValidators: discard.NewGauge(), + ByzantineValidatorsPower: discard.NewGauge(), + BlockIntervalSeconds: discard.NewHistogram(), + NumTxs: discard.NewGauge(), + BlockSizeBytes: discard.NewHistogram(), + TotalTxs: discard.NewGauge(), + CommittedHeight: discard.NewGauge(), + BlockSyncing: discard.NewGauge(), + StateSyncing: discard.NewGauge(), + BlockParts: discard.NewCounter(), + StepDuration: discard.NewHistogram(), + BlockGossipReceiveLatency: discard.NewHistogram(), + BlockGossipPartsReceived: discard.NewCounter(), + ProposalBlockCreatedOnPropose: discard.NewCounter(), + ProposalTxs: discard.NewGauge(), + ProposalMissingTxs: discard.NewGauge(), + MissingTxs: discard.NewGauge(), + QuorumPrevoteDelay: discard.NewGauge(), + FullPrevoteDelay: discard.NewGauge(), + ProposalTimestampDifference: discard.NewHistogram(), + ProposalReceiveCount: discard.NewCounter(), + ProposalCreateCount: discard.NewCounter(), + RoundVotingPowerPercent: discard.NewGauge(), + LateVotes: discard.NewCounter(), + FinalRound: discard.NewHistogram(), + ProposeLatency: discard.NewHistogram(), + PrevoteLatency: discard.NewHistogram(), + ConsensusTime: discard.NewHistogram(), + CompleteProposalTime: discard.NewHistogram(), + ApplyBlockLatency: discard.NewHistogram(), + StepLatency: discard.NewGauge(), + StepCount: discard.NewGauge(), + } } diff --git a/sei-tendermint/internal/consensus/metrics.go b/sei-tendermint/internal/consensus/metrics.go index bfce5cc9c8..da2acdcc24 100644 --- a/sei-tendermint/internal/consensus/metrics.go +++ b/sei-tendermint/internal/consensus/metrics.go @@ -4,8 +4,7 @@ import ( "strings" "time" - "github.com/prometheus/client_golang/prometheus" - tmprometheus "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/prometheus" + "github.com/go-kit/kit/metrics" cstypes "github.com/sei-protocol/sei-chain/sei-tendermint/internal/consensus/types" tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types" @@ -13,8 +12,6 @@ import ( ) const ( - // MetricsNamespace is the namespace shared by all Tendermint Prometheus metrics. - MetricsNamespace = "tendermint" // MetricsSubsystem is a subsystem shared by all metrics exposed by this // package. MetricsSubsystem = "consensus" @@ -22,84 +19,80 @@ const ( //go:generate go run ../../scripts/metricsgen -struct=Metrics -type latencyMetrics struct { - stepStart time.Time - blockGossipStart time.Time - lastRecordedStepLatencyNano int64 -} - // Metrics contains metrics exposed by this package. type Metrics struct { // Height of the chain. - Height tmprometheus.GaugeIntVec + Height metrics.Gauge // Last height signed by this validator if the node is a validator. - ValidatorLastSignedHeight tmprometheus.GaugeIntVec `metrics_labels:"validator_address"` + ValidatorLastSignedHeight metrics.Gauge `metrics_labels:"validator_address"` // Number of rounds. - Rounds tmprometheus.GaugeIntVec + Rounds metrics.Gauge // Histogram of round duration. - RoundDuration tmprometheus.HistogramVec `metrics_buckets:"exprange(0.1, 100, 8)"` + RoundDuration metrics.Histogram `metrics_buckettype:"exprange" metrics_bucketsizes:"0.1, 100, 8"` // Number of validators. - Validators tmprometheus.GaugeIntVec + Validators metrics.Gauge // Total power of all validators. - ValidatorsPower tmprometheus.GaugeIntVec + ValidatorsPower metrics.Gauge // Power of a validator. - ValidatorPower tmprometheus.GaugeIntVec `metrics_labels:"validator_address"` + ValidatorPower metrics.Gauge `metrics_labels:"validator_address"` // Amount of blocks missed per validator. - ValidatorMissedBlocks tmprometheus.GaugeIntVec `metrics_labels:"validator_address"` + ValidatorMissedBlocks metrics.Gauge `metrics_labels:"validator_address"` // Number of validators who did not sign. - MissingValidators tmprometheus.GaugeIntVec + MissingValidators metrics.Gauge // Total power of the missing validators. - MissingValidatorsPower tmprometheus.GaugeIntVec `metrics_labels:"validator_address"` + MissingValidatorsPower metrics.Gauge `metrics_labels:"validator_address"` // Number of validators who tried to double sign. - ByzantineValidators tmprometheus.GaugeIntVec + ByzantineValidators metrics.Gauge // Total power of the byzantine validators. - ByzantineValidatorsPower tmprometheus.GaugeIntVec + ByzantineValidatorsPower metrics.Gauge // Time in seconds between this and the last block. - BlockIntervalSeconds tmprometheus.HistogramVec `metrics_buckets:"exp(0.1, 1.3, 20)"` + BlockIntervalSeconds metrics.Histogram `metrics_buckettype:"exp" metrics_bucketsizes:"0.1, 1.3, 20"` // Number of transactions. - NumTxs tmprometheus.GaugeIntVec + NumTxs metrics.Gauge // Size of the block. - BlockSizeBytes tmprometheus.HistogramVec `metrics_buckets:"exp(1000, 1.5, 25)"` + BlockSizeBytes metrics.Histogram `metrics_buckettype:"exp" metrics_bucketsizes:"1000, 1.5, 25"` // Total number of transactions. - TotalTxs tmprometheus.GaugeIntVec + TotalTxs metrics.Gauge // The latest block height. - CommittedHeight tmprometheus.GaugeIntVec `metrics_name:"latest_block_height"` + CommittedHeight metrics.Gauge `metrics_name:"latest_block_height"` // Whether or not a node is block syncing. 1 if yes, 0 if no. - BlockSyncing tmprometheus.GaugeIntVec + BlockSyncing metrics.Gauge // Whether or not a node is state syncing. 1 if yes, 0 if no. - StateSyncing tmprometheus.GaugeIntVec + StateSyncing metrics.Gauge // Number of block parts transmitted by each peer. - BlockParts tmprometheus.CounterIntVec `metrics_labels:"peer_id"` + BlockParts metrics.Counter `metrics_labels:"peer_id"` // Histogram of durations for each step in the consensus protocol. - StepDuration tmprometheus.HistogramVec `metrics_labels:"step" metrics_buckets:"exprange(0.1, 100, 8)"` + StepDuration metrics.Histogram `metrics_labels:"step" metrics_buckettype:"exprange" metrics_bucketsizes:"0.1, 100, 8"` + stepStart time.Time // Histogram of time taken to receive a block in seconds, measured between when a new block is first // discovered to when the block is completed. - BlockGossipReceiveLatency tmprometheus.HistogramVec `metrics_buckets:"exprange(0.1, 100, 8)"` + BlockGossipReceiveLatency metrics.Histogram `metrics_buckettype:"exprange" metrics_bucketsizes:"0.1, 100, 8"` + blockGossipStart time.Time // Number of block parts received by the node, separated by whether the part // was relevant to the block the node is trying to gather or not. - BlockGossipPartsReceived tmprometheus.CounterIntVec `metrics_labels:"matches_current"` + BlockGossipPartsReceived metrics.Counter `metrics_labels:"matches_current"` // Number of proposal blocks created on propose received. - ProposalBlockCreatedOnPropose tmprometheus.CounterIntVec `metrics_labels:"success"` + ProposalBlockCreatedOnPropose metrics.Counter `metrics_labels:"success"` // Number of txs in a proposal. - ProposalTxs *prometheus.GaugeVec + ProposalTxs metrics.Gauge // Number of missing txs when trying to create proposal. - ProposalMissingTxs tmprometheus.GaugeIntVec + ProposalMissingTxs metrics.Gauge //Number of missing txs when a proposal is received - MissingTxs *prometheus.GaugeVec `metrics_labels:"proposer_address"` + MissingTxs metrics.Gauge `metrics_labels:"proposer_address"` // QuroumPrevoteMessageDelay is the interval in seconds between the proposal // timestamp and the timestamp of the earliest prevote that achieved a quorum @@ -111,92 +104,89 @@ type Metrics struct { // the endpoint of the interval. Subtract the proposal timestamp from this endpoint // to obtain the quorum delay. //metrics:Interval in seconds between the proposal timestamp and the timestamp of the earliest prevote that achieved a quorum. - QuorumPrevoteDelay *prometheus.GaugeVec `metrics_labels:"proposer_address"` + QuorumPrevoteDelay metrics.Gauge `metrics_labels:"proposer_address"` // FullPrevoteDelay is the interval in seconds between the proposal // timestamp and the timestamp of the latest prevote in a round where 100% // of the voting power on the network issued prevotes. //metrics:Interval in seconds between the proposal timestamp and the timestamp of the latest prevote in a round where all validators voted. - FullPrevoteDelay *prometheus.GaugeVec `metrics_labels:"proposer_address"` + FullPrevoteDelay metrics.Gauge `metrics_labels:"proposer_address"` // ProposalTimestampDifference is the difference between the timestamp in // the proposal message and the local time of the validator at the time // that the validator received the message. //metrics:Difference between the timestamp in the proposal message and the local time of the validator at the time it received the message. - ProposalTimestampDifference tmprometheus.HistogramVec `metrics_labels:"is_timely" metrics_buckets:"-10, -.5, -.025, 0, .1, .5, 1, 1.5, 2, 10"` + ProposalTimestampDifference metrics.Histogram `metrics_labels:"is_timely" metrics_bucketsizes:"-10, -.5, -.025, 0, .1, .5, 1, 1.5, 2, 10"` // ProposalReceiveCount is the total number of proposals received by this node // since process start. // The metric is annotated by the status of the proposal from the application, // either 'accepted' or 'rejected'. //metrics:Total number of proposals received by the node since process start labeled by application response status. - ProposalReceiveCount tmprometheus.CounterIntVec `metrics_labels:"status"` + ProposalReceiveCount metrics.Counter `metrics_labels:"status"` // ProposalCreationCount is the total number of proposals created by this node // since process start. //metrics:Total number of proposals created by the node since process start. - ProposalCreateCount tmprometheus.CounterIntVec + ProposalCreateCount metrics.Counter // RoundVotingPowerPercent is the percentage of the total voting power received // with a round. The value begins at 0 for each round and approaches 1.0 as // additional voting power is observed. The metric is labeled by vote type. //metrics:A value between 0 and 1.0 representing the percentage of the total voting power per vote type received within a round. - RoundVotingPowerPercent *prometheus.GaugeVec `metrics_labels:"vote_type"` + RoundVotingPowerPercent metrics.Gauge `metrics_labels:"vote_type"` // LateVotes stores the number of votes that were received by this node that // correspond to earlier heights and rounds than this node is currently // in. //metrics:Number of votes received by the node since process start that correspond to earlier heights and rounds than this node is currently in. - LateVotes tmprometheus.CounterIntVec `metrics_labels:"validator_address"` + LateVotes metrics.Counter `metrics_labels:"validator_address"` // FinalRound stores the final round id the proposal block reach consensus in. //metrics:The final round number for where the proposal block reach consensus in, starting at 0. - FinalRound tmprometheus.HistogramVec `metrics_labels:"proposer_address" metrics_buckets:"0,1,2,3,5,10"` + FinalRound metrics.Histogram `metrics_labels:"proposer_address" metrics_bucketsizes:"0,1,2,3,5,10"` // ProposeLatency stores the latency in seconds from when the initial round // starts till the proposal is created and received //metrics:Number of seconds from when the consensus round started till the proposal receive time - ProposeLatency tmprometheus.HistogramVec `metrics_labels:"proposer_address" metrics_buckets:"exprange(0.01, 10, 10)"` + ProposeLatency metrics.Histogram `metrics_labels:"proposer_address" metrics_buckettype:"exprange" metrics_bucketsizes:"0.01, 10, 10"` // PrevoteLatency is measuring the relative delay in seconds from when the first vote arrive in each round // till all remaining following prevote arrives from different validators to reach consensus. //metrics:Number of seconds from when first prevote arrive till other remaining prevote arrives for each validator - PrevoteLatency tmprometheus.HistogramVec `metrics_labels:"validator_address" metrics_buckets:"exprange(0.01, 10, 10)"` + PrevoteLatency metrics.Histogram `metrics_labels:"validator_address" metrics_buckettype:"exprange" metrics_bucketsizes:"0.01, 10, 10"` // ConsensusTime the metric to track how long the consensus takes in each block //metrics: Number of seconds spent on consensus - ConsensusTime tmprometheus.HistogramVec `metrics_buckets:"exp(0.01, 1.3, 25)"` + ConsensusTime metrics.Histogram `metrics_buckettype:"exp" metrics_bucketsizes:"0.01, 1.3, 25"` // CompleteProposalTime measures how long it takes between receiving a proposal and finishing // processing all of its parts. Note that this means it also includes network latency from // block parts gossip - CompleteProposalTime tmprometheus.HistogramVec `metrics_buckets:"exp(0.01, 1.3, 25)"` + CompleteProposalTime metrics.Histogram `metrics_buckettype:"exp" metrics_bucketsizes:"0.01, 1.3, 25"` // ApplyBlockLatency measures how long it takes to execute ApplyBlock in finalize commit step - ApplyBlockLatency tmprometheus.HistogramVec `metrics_buckets:"exp(0.01, 1.3, 25)"` + ApplyBlockLatency metrics.Histogram `metrics_buckettype:"exp" metrics_bucketsizes:"0.01, 1.3, 25"` - StepLatency *prometheus.GaugeVec `metrics_labels:"step"` - StepCount tmprometheus.GaugeIntVec `metrics_labels:"step"` + StepLatency metrics.Gauge `metrics_labels:"step"` + lastRecordedStepLatencyNano int64 + StepCount metrics.Gauge `metrics_labels:"step"` } // RecordConsMetrics uses for recording the block related metrics during fast-sync. func (m *Metrics) RecordConsMetrics(block *types.Block) { - m.NumTxsAt().Set(int64(len(block.Txs))) - m.TotalTxsAt().Add(int64(len(block.Txs))) - m.BlockSizeBytesAt().Observe(float64(block.Size())) - m.CommittedHeightAt().Set(block.Height) + m.NumTxs.Set(float64(len(block.Txs))) + m.TotalTxs.Add(float64(len(block.Txs))) + m.BlockSizeBytes.Observe(float64(block.Size())) + m.CommittedHeight.Set(float64(block.Height)) } -func (m *latencyMetrics) MarkBlockGossipStarted() { +func (m *Metrics) MarkBlockGossipStarted() { m.blockGossipStart = time.Now() } -func (m *latencyMetrics) MarkBlockGossipComplete() { - start := m.blockGossipStart - if start.IsZero() { - return - } - Global.BlockGossipReceiveLatencyAt().Observe(time.Since(start).Seconds()) +func (m *Metrics) MarkBlockGossipComplete() { + m.BlockGossipReceiveLatency.Observe(time.Since(m.blockGossipStart).Seconds()) } func (m *Metrics) MarkProposalProcessed(accepted bool) { @@ -204,71 +194,71 @@ func (m *Metrics) MarkProposalProcessed(accepted bool) { if !accepted { status = "rejected" } - m.ProposalReceiveCountAt(status).Add(1) + m.ProposalReceiveCount.With("status", status).Add(1) } func (m *Metrics) MarkVoteReceived(vt tmproto.SignedMsgType, power, totalPower int64) { p := float64(power) / float64(totalPower) n := strings.ToLower(strings.TrimPrefix(vt.String(), "SIGNED_MSG_TYPE_")) - m.RoundVotingPowerPercentAt(n).Add(p) + m.RoundVotingPowerPercent.With("vote_type", n).Add(p) } func (m *Metrics) MarkRound(r int32, st time.Time) { - m.RoundsAt().Set(int64(r)) + m.Rounds.Set(float64(r)) roundTime := time.Since(st).Seconds() - m.RoundDurationAt().Observe(roundTime) + m.RoundDuration.Observe(roundTime) pvt := tmproto.PrevoteType pvn := strings.ToLower(strings.TrimPrefix(pvt.String(), "SIGNED_MSG_TYPE_")) - m.RoundVotingPowerPercentAt(pvn).Set(0) + m.RoundVotingPowerPercent.With("vote_type", pvn).Set(0) pct := tmproto.PrecommitType pcn := strings.ToLower(strings.TrimPrefix(pct.String(), "SIGNED_MSG_TYPE_")) - m.RoundVotingPowerPercentAt(pcn).Set(0) + m.RoundVotingPowerPercent.With("vote_type", pcn).Set(0) } func (m *Metrics) MarkLateVote(vote *types.Vote) { validator := vote.ValidatorAddress.String() - m.LateVotesAt(validator).Add(1) + m.LateVotes.With("validator_address", validator).Add(1) } func (m *Metrics) MarkFinalRound(round int32, proposer string) { - m.FinalRoundAt(proposer).Observe(float64(round)) + m.FinalRound.With("proposer_address", proposer).Observe(float64(round)) } func (m *Metrics) MarkProposeLatency(proposer string, latency time.Duration) { - m.ProposeLatencyAt(proposer).Observe(latency.Seconds()) + m.ProposeLatency.With("proposer_address", proposer).Observe(latency.Seconds()) } func (m *Metrics) MarkPrevoteLatency(validator string, latency time.Duration) { - m.PrevoteLatencyAt(validator).Observe(latency.Seconds()) + m.PrevoteLatency.With("validator_address", validator).Observe(latency.Seconds()) } func (m *Metrics) MarkCompleteProposalTime(latency time.Duration) { - m.CompleteProposalTimeAt().Observe(latency.Seconds()) + m.CompleteProposalTime.Observe(latency.Seconds()) } func (m *Metrics) MarkConsensusTime(latency time.Duration) { - m.ConsensusTimeAt().Observe(latency.Seconds()) + m.ConsensusTime.Observe(latency.Seconds()) } func (m *Metrics) MarkApplyBlockLatency(latency time.Duration) { - m.ApplyBlockLatencyAt().Observe(latency.Seconds()) + m.ApplyBlockLatency.Observe(latency.Seconds()) } -func (m *latencyMetrics) MarkStep(s cstypes.RoundStepType) { +func (m *Metrics) MarkStep(s cstypes.RoundStepType) { if !m.stepStart.IsZero() { stepTime := time.Since(m.stepStart).Seconds() stepName := strings.TrimPrefix(s.String(), "RoundStep") - Global.StepDurationAt(stepName).Observe(stepTime) - Global.StepCountAt(s.String()).Add(1) + m.StepDuration.With("step", stepName).Observe(stepTime) + m.StepCount.With("step", s.String()).Add(1) } m.stepStart = time.Now() } -func (m *latencyMetrics) MarkStepLatency(s cstypes.RoundStepType) { +func (m *Metrics) MarkStepLatency(s cstypes.RoundStepType) { now := time.Now().UnixNano() - Global.StepLatencyAt(s.String()).Add(float64(now - m.lastRecordedStepLatencyNano)) + m.StepLatency.With("step", s.String()).Add(float64(now - m.lastRecordedStepLatencyNano)) m.lastRecordedStepLatencyNano = now } @@ -283,7 +273,7 @@ func (m *Metrics) ClearStepMetrics() { cstypes.RoundStepPrecommitWait, cstypes.RoundStepCommit, } { - m.StepCountAt(st.String()).Set(0) - m.StepLatencyAt(st.String()).Set(0) + m.StepCount.With("step", st.String()).Set(0) + m.StepLatency.With("step", st.String()).Set(0) } } diff --git a/sei-tendermint/internal/consensus/pbts_test.go b/sei-tendermint/internal/consensus/pbts_test.go index 46c68014de..751d044db3 100644 --- a/sei-tendermint/internal/consensus/pbts_test.go +++ b/sei-tendermint/internal/consensus/pbts_test.go @@ -163,7 +163,7 @@ func newPBTSTestHarness(ctx context.Context, t *testing.T, tc pbtsTestConfigurat patternStartHeight: patternStartHeight, validatorClock: clock, currentHeight: 1, - chainID: config.TestLoadGenesis(cfg).ChainID, + chainID: cfg.ChainID(), roundCh: subscribe(ctx, t, cs.eventBus, types.EventQueryNewRound), ensureProposalCh: subscribe(ctx, t, cs.eventBus, types.EventQueryCompleteProposal), blockCh: subscribe(ctx, t, cs.eventBus, types.EventQueryNewBlock), diff --git a/sei-tendermint/internal/consensus/reactor.go b/sei-tendermint/internal/consensus/reactor.go index dd410d9d29..095203cfa8 100644 --- a/sei-tendermint/internal/consensus/reactor.go +++ b/sei-tendermint/internal/consensus/reactor.go @@ -98,6 +98,7 @@ type Reactor struct { router *p2p.Router channels channelBundle eventBus *eventbus.EventBus + Metrics *Metrics peers utils.RWMutex[map[types.NodeID]*PeerState] roundState atomic.Pointer[cstypes.RoundState] @@ -113,6 +114,7 @@ func NewReactor( router *p2p.Router, eventBus *eventbus.EventBus, waitSync bool, + metrics *Metrics, cfg *config.Config, ) (*Reactor, error) { stateCh, err := p2p.OpenChannel(router, GetStateChannelDescriptor()) @@ -135,6 +137,7 @@ func NewReactor( state: cs, peers: utils.NewRWMutex(map[types.NodeID]*PeerState{}), eventBus: eventBus, + Metrics: metrics, router: router, readySignal: utils.NewAtomicSend(!waitSync), channels: channelBundle{ @@ -208,8 +211,8 @@ func (r *Reactor) SwitchToConsensus(state sm.State, skipWAL bool) { if err := r.eventBus.PublishEventBlockSyncStatus(d); err != nil { logger.Error("failed to emit the blocksync complete event", "err", err) } - Global.BlockSyncingAt().Set(0) - Global.StateSyncingAt().Set(0) + r.Metrics.BlockSyncing.Set(0) + r.Metrics.StateSyncing.Set(0) // we have no votes, so reconstruct LastCommit from SeenCommit r.state.mtx.Lock() @@ -761,7 +764,7 @@ func (r *Reactor) handleDataMessage(ctx context.Context, m p2p.RecvMsg[*tmcons.M return nil case *BlockPartMessage: ps.SetHasProposalBlockPart(msg.Height, msg.Round, int(msg.Part.Index)) - Global.BlockPartsAt(string(m.From)).Add(1) + r.Metrics.BlockParts.With("peer_id", string(m.From)).Add(1) return utils.Send(ctx, r.state.peerMsgQueue, msgInfo{msg, m.From, tmtime.Now()}) default: return fmt.Errorf("received unknown message on DataChannel: %T", msg) @@ -1010,10 +1013,10 @@ func (r *Reactor) recordPeerMsg(msg msgInfo) { } } -func (r *Reactor) SetStateSyncingMetrics(v int64) { - Global.StateSyncingAt().Set(v) +func (r *Reactor) SetStateSyncingMetrics(v float64) { + r.Metrics.StateSyncing.Set(v) } -func (r *Reactor) SetBlockSyncingMetrics(v int64) { - Global.BlockSyncingAt().Set(v) +func (r *Reactor) SetBlockSyncingMetrics(v float64) { + r.Metrics.BlockSyncing.Set(v) } diff --git a/sei-tendermint/internal/consensus/reactor_test.go b/sei-tendermint/internal/consensus/reactor_test.go index ced7f627a2..21c9306700 100644 --- a/sei-tendermint/internal/consensus/reactor_test.go +++ b/sei-tendermint/internal/consensus/reactor_test.go @@ -77,6 +77,7 @@ func setup( node.Router, state.eventBus, true, + NopMetrics(), config.DefaultConfig(), ) require.NoError(t, err) @@ -268,11 +269,12 @@ func TestReactorWithEvidence(t *testing.T) { blockDB := dbm.NewMemDB() blockStore := store.NewBlockStore(blockDB) - proxyApp := proxy.New(app) + proxyApp := proxy.New(app, proxy.NopMetrics()) mempool := mempool.NewTxMempool( thisConfig.Mempool.ToMempoolConfig(), proxyApp, + mempool.NopMetrics(), mempool.NopTxConstraintsFetcher, ) @@ -280,7 +282,7 @@ func TestReactorWithEvidence(t *testing.T) { // everyone includes evidence of another double signing vIdx := (i + 1) % n - ev, err := types.NewMockDuplicateVoteEvidenceWithValidator(ctx, 1, defaultTestTime, privVals[vIdx], config.TestLoadGenesis(cfg).ChainID) + ev, err := types.NewMockDuplicateVoteEvidenceWithValidator(ctx, 1, defaultTestTime, privVals[vIdx], cfg.ChainID()) require.NoError(t, err) evpool := &statemocks.EvidencePool{} evpool.On("CheckEvidence", mock.Anything, mock.AnythingOfType("types.EvidenceList")).Return(nil) @@ -292,12 +294,12 @@ func TestReactorWithEvidence(t *testing.T) { eventBus := eventbus.NewDefault() require.NoError(t, eventBus.Start(ctx)) - blockExec := sm.NewBlockExecutor(stateStore, proxyApp, mempool, evpool, blockStore, eventBus, types.DefaultConsensusPolicy()) + blockExec := sm.NewBlockExecutor(stateStore, proxyApp, mempool, evpool, blockStore, eventBus, sm.NopMetrics(), types.DefaultConsensusPolicy()) wal, err := OpenWAL(thisConfig.Consensus.WalFile()) require.NoError(t, err) cs := NewState( - thisConfig.Consensus, wal, stateStore, blockExec, blockStore, mempool, evpool2, eventBus, []trace.TracerProviderOption{}) + thisConfig.Consensus, wal, stateStore, blockExec, blockStore, mempool, evpool2, eventBus, []trace.TracerProviderOption{}, NopMetrics()) require.NoError(t, cs.updateStateFromStore()) cs.SetPrivValidator(ctx, utils.Some(pv)) diff --git a/sei-tendermint/internal/consensus/replay.go b/sei-tendermint/internal/consensus/replay.go index 175be9c573..26f3ee901e 100644 --- a/sei-tendermint/internal/consensus/replay.go +++ b/sei-tendermint/internal/consensus/replay.go @@ -139,7 +139,7 @@ func NewHandshaker( } func newReplayTxMempool(app *proxy.Proxy) *mempool.TxMempool { - return mempool.NewTxMempool(config.DefaultMempoolConfig().ToMempoolConfig(), app, mempool.NopTxConstraintsFetcher) + return mempool.NewTxMempool(config.DefaultMempoolConfig().ToMempoolConfig(), app, mempool.NopMetrics(), mempool.NopTxConstraintsFetcher) } // NBlocks returns the number of blocks applied to the state. @@ -401,7 +401,7 @@ func (h *Handshaker) replayBlocks( if i == finalBlock && !mutateState { // We emit events for the index services at the final block due to the sync issue when // the node shutdown during the block committing status. - blockExec := sm.NewBlockExecutor(h.stateStore, app, newReplayTxMempool(app), sm.EmptyEvidencePool{}, h.store, h.eventBus, h.consensusPolicy) + blockExec := sm.NewBlockExecutor(h.stateStore, app, newReplayTxMempool(app), sm.EmptyEvidencePool{}, h.store, h.eventBus, sm.NopMetrics(), h.consensusPolicy) appHash, err = sm.ExecCommitBlock(ctx, blockExec, app, block, h.stateStore, h.genDoc.InitialHeight, state) if err != nil { @@ -446,7 +446,7 @@ func (h *Handshaker) replayBlock( // Use stubs for both mempool and evidence pool since no transactions nor // evidence are needed here - block already exists. - blockExec := sm.NewBlockExecutor(h.stateStore, app, newReplayTxMempool(app), sm.EmptyEvidencePool{}, h.store, h.eventBus, h.consensusPolicy) + blockExec := sm.NewBlockExecutor(h.stateStore, app, newReplayTxMempool(app), sm.EmptyEvidencePool{}, h.store, h.eventBus, sm.NopMetrics(), h.consensusPolicy) var err error state, err = blockExec.ApplyBlock(ctx, state, meta.BlockID, block, nil) diff --git a/sei-tendermint/internal/consensus/replay_stubs.go b/sei-tendermint/internal/consensus/replay_stubs.go index 59df2465dc..a1fa1c6669 100644 --- a/sei-tendermint/internal/consensus/replay_stubs.go +++ b/sei-tendermint/internal/consensus/replay_stubs.go @@ -20,7 +20,7 @@ func newMockProxyApp( return proxy.New(&mockProxyApp{ appHash: appHash, finalizeBlockResponses: finalizeBlockResponses, - }) + }, proxy.NopMetrics()) } type mockProxyApp struct { diff --git a/sei-tendermint/internal/consensus/replay_test.go b/sei-tendermint/internal/consensus/replay_test.go index e114a73d1a..c4ff2f4804 100644 --- a/sei-tendermint/internal/consensus/replay_test.go +++ b/sei-tendermint/internal/consensus/replay_test.go @@ -107,7 +107,7 @@ func runStateUntilBlock(t *testing.T, cfg *config.Config, lastBlock int64) { require.NoError(t, err) genDoc := utils.OrPanic1(types.GenesisDocFromFile(cfg.GenesisFile())) state.Version.Consensus.App = kvstore.ProtocolVersion // simulate handshake, receive app version - proxyApp := proxy.New(newApp(genDoc.ValidatorUpdates())) + proxyApp := proxy.New(newApp(genDoc.ValidatorUpdates()), proxy.NopMetrics()) cs := newStateWithConfigAndBlockStore( t, cfg, @@ -233,7 +233,7 @@ func crashWALandCheckLiveness( require.NoError(t, err) genDoc := utils.OrPanic1(types.GenesisDocFromFile(cfg.GenesisFile())) state.Version.Consensus.App = kvstore.ProtocolVersion // simulate handshake, receive app version - proxyApp := proxy.New(newApp(genDoc.ValidatorUpdates())) + proxyApp := proxy.New(newApp(genDoc.ValidatorUpdates()), proxy.NopMetrics()) cs := newStateWithConfigAndBlockStore( t, cfg, @@ -291,7 +291,6 @@ var modes = []uint{0, 1, 2, 3} func setupSimulator(ctx context.Context, t *testing.T) *simulatorTestSuite { t.Helper() cfg := configSetup(t) - chainID := config.TestLoadGenesis(cfg).ChainID sim := &simulatorTestSuite{ Evpool: sm.EmptyEvidencePool{}, } @@ -361,7 +360,7 @@ func setupSimulator(ctx context.Context, t *testing.T) *simulatorTestSuite { blockID := types.BlockID{Hash: propBlock.Hash(), PartSetHeader: propBlockParts.Header()} proposal := types.NewProposal(proposerVS.Height, round, -1, blockID, propBlock.Header.Time, propBlock.GetTxHashes(), propBlock.Header, propBlock.LastCommit, propBlock.Evidence, leaderPubKey.Address()) p := proposal.ToProto() - if err := proposerVS.SignProposal(ctx, chainID, p); err != nil { + if err := proposerVS.SignProposal(ctx, cfg.ChainID(), p); err != nil { t.Fatal("failed to sign proposal", err) } proposal.Signature = utils.OrPanic1(crypto.SigFromBytes(p.Signature)) @@ -389,7 +388,7 @@ func setupSimulator(ctx context.Context, t *testing.T) *simulatorTestSuite { _, err = css[0].txMempool.CheckTx(ctx, newValidatorTx1) assert.NoError(t, err) - css[0].signAddVotes(ctx, t, tmproto.PrecommitType, chainID, + css[0].signAddVotes(ctx, t, tmproto.PrecommitType, sim.Config.ChainID(), types.BlockID{Hash: rs.ProposalBlock.Hash(), PartSetHeader: rs.ProposalBlockParts.Header()}, vss[1:nVals]...) @@ -407,7 +406,7 @@ func setupSimulator(ctx context.Context, t *testing.T) *simulatorTestSuite { updateValidatorTx1 := kvstore.MakeValSetChangeTx(updatePubKey1ABCI, 25) _, err = css[0].txMempool.CheckTx(ctx, updateValidatorTx1) assert.NoError(t, err) - css[0].signAddVotes(ctx, t, tmproto.PrecommitType, chainID, + css[0].signAddVotes(ctx, t, tmproto.PrecommitType, sim.Config.ChainID(), types.BlockID{Hash: rs.ProposalBlock.Hash(), PartSetHeader: rs.ProposalBlockParts.Header()}, vss[1:nVals]...) ensureNewRound(t, newRoundCh, height+1, 0) @@ -432,7 +431,7 @@ func setupSimulator(ctx context.Context, t *testing.T) *simulatorTestSuite { newValidatorTx3 := kvstore.MakeValSetChangeTx(newVal3ABCI, testMinPower) _, err = css[0].txMempool.CheckTx(ctx, newValidatorTx3) assert.NoError(t, err) - css[0].signAddVotes(ctx, t, tmproto.PrecommitType, chainID, + css[0].signAddVotes(ctx, t, tmproto.PrecommitType, sim.Config.ChainID(), types.BlockID{Hash: rs.ProposalBlock.Hash(), PartSetHeader: rs.ProposalBlockParts.Header()}, vss[1:nVals]...) ensureNewRound(t, newRoundCh, height+1, 0) @@ -473,7 +472,7 @@ func setupSimulator(ctx context.Context, t *testing.T) *simulatorTestSuite { if i == selfIndex { continue } - css[0].signAddVotes(ctx, t, tmproto.PrecommitType, chainID, + css[0].signAddVotes(ctx, t, tmproto.PrecommitType, sim.Config.ChainID(), types.BlockID{Hash: rs.ProposalBlock.Hash(), PartSetHeader: rs.ProposalBlockParts.Header()}, newVss[i]) } @@ -501,7 +500,7 @@ func setupSimulator(ctx context.Context, t *testing.T) *simulatorTestSuite { if i == selfIndex { continue } - css[0].signAddVotes(ctx, t, tmproto.PrecommitType, chainID, + css[0].signAddVotes(ctx, t, tmproto.PrecommitType, sim.Config.ChainID(), types.BlockID{Hash: rs.ProposalBlock.Hash(), PartSetHeader: rs.ProposalBlockParts.Header()}, newVss[i]) } @@ -522,7 +521,7 @@ func setupSimulator(ctx context.Context, t *testing.T) *simulatorTestSuite { if i == selfIndex { continue } - css[0].signAddVotes(ctx, t, tmproto.PrecommitType, chainID, + css[0].signAddVotes(ctx, t, tmproto.PrecommitType, sim.Config.ChainID(), types.BlockID{Hash: rs.ProposalBlock.Hash(), PartSetHeader: rs.ProposalBlockParts.Header()}, newVss[i]) } @@ -695,7 +694,7 @@ func testHandshakeReplay( genDoc, err := sm.MakeGenesisDocFromFile(cfg.GenesisFile()) require.NoError(t, err) handshaker := NewHandshaker(stateStore, state, store, eventBus, genDoc, types.DefaultConsensusPolicy()) - proxyApp := proxy.New(app) + proxyApp := proxy.New(app, proxy.NopMetrics()) err = handshaker.Handshake(ctx, proxyApp) if expectError { require.Error(t, err) @@ -742,7 +741,7 @@ func applyBlock( eventBus *eventbus.EventBus, ) sm.State { testPartSize := types.BlockPartSizeBytes - blockExec := sm.NewBlockExecutor(stateStore, appClient, mempool, evpool, blockStore, eventBus, types.DefaultConsensusPolicy()) + blockExec := sm.NewBlockExecutor(stateStore, appClient, mempool, evpool, blockStore, eventBus, sm.NopMetrics(), types.DefaultConsensusPolicy()) bps, err := blk.MakePartSet(testPartSize) require.NoError(t, err) @@ -767,7 +766,7 @@ func buildAppStateFromChain( ) { t.Helper() // start a new app without handshake, play nBlocks blocks - proxyApp := proxy.New(appClient) + proxyApp := proxy.New(appClient, proxy.NopMetrics()) mempool := newReplayTxMempool(proxyApp) state.Version.Consensus.App = kvstore.ProtocolVersion // simulate handshake, receive app version _, err := appClient.InitChain(ctx, &abci.RequestInitChain{}) @@ -812,7 +811,7 @@ func buildTMStateFromChain( // run the whole chain against this client to build up the tendermint state app := newApp(types.TM2PB.ValidatorUpdates(state.Validators)) - proxyApp := proxy.New(app) + proxyApp := proxy.New(app, proxy.NopMetrics()) state.Version.Consensus.App = kvstore.ProtocolVersion // simulate handshake, receive app version _, err := app.InitChain(ctx, &abci.RequestInitChain{}) require.NoError(t, err) @@ -881,7 +880,7 @@ func TestHandshakeErrorsIfAppReturnsWrongAppHash(t *testing.T) { { app := &badApp{numBlocks: 3, allHashesAreWrong: true} h := NewHandshaker(stateStore, state, store, eventBus, genDoc, types.DefaultConsensusPolicy()) - proxyApp := proxy.New(app) + proxyApp := proxy.New(app, proxy.NopMetrics()) assert.Error(t, h.Handshake(ctx, proxyApp)) } @@ -892,7 +891,7 @@ func TestHandshakeErrorsIfAppReturnsWrongAppHash(t *testing.T) { { app := &badApp{numBlocks: 3, onlyLastHashIsWrong: true} h := NewHandshaker(stateStore, state, store, eventBus, genDoc, types.DefaultConsensusPolicy()) - proxyApp := proxy.New(app) + proxyApp := proxy.New(app, proxy.NopMetrics()) require.Error(t, h.Handshake(ctx, proxyApp)) } } @@ -1121,7 +1120,7 @@ func TestHandshakeUpdatesValidators(t *testing.T) { genDoc, err = sm.MakeGenesisDocFromFile(cfg.GenesisFile()) require.NoError(t, err) handshaker := NewHandshaker(stateStore, state, store, eventBus, genDoc, types.DefaultConsensusPolicy()) - proxyApp := proxy.New(app) + proxyApp := proxy.New(app, proxy.NopMetrics()) require.NoError(t, handshaker.Handshake(ctx, proxyApp), "error on abci handshake") // reload the state, check the validator set was updated diff --git a/sei-tendermint/internal/consensus/state.go b/sei-tendermint/internal/consensus/state.go index f385b6ceb2..37ded169d8 100644 --- a/sei-tendermint/internal/consensus/state.go +++ b/sei-tendermint/internal/consensus/state.go @@ -94,7 +94,6 @@ type evidencePool interface { type State struct { // config details config *config.ConsensusConfig - metrics utils.Mutex[*latencyMetrics] privValidator utils.Option[types.PrivValidator] // for signing votes // privValidator pubkey, memoized for the duration of one block // to avoid extra requests to HSM @@ -151,6 +150,9 @@ type State struct { eventVote func(vote *types.Vote) eventMsg func(msgInfo) + // for reporting metrics + metrics *Metrics + tracer otrace.Tracer heightSpan otrace.Span heightBeingTraced int64 @@ -168,11 +170,11 @@ func NewState( evpool evidencePool, eventBus *eventbus.EventBus, traceProviderOps []trace.TracerProviderOption, + metrics *Metrics, ) *State { cs := &State{ eventBus: eventBus, config: cfg, - metrics: utils.NewMutex(&latencyMetrics{}), roundState: cstypes.NewSafeRoundState(), blockExec: blockExec, blockStore: blockStore, @@ -183,6 +185,7 @@ func NewState( timeoutTicker: NewTimeoutTicker(), doWALCatchup: true, evpool: evpool, + metrics: metrics, wal: wal, eventValidBlock: utils.NewAtomicSend(utils.None[*cstypes.RoundState]()), eventNewRoundStep: func(*cstypes.RoundState) {}, @@ -439,20 +442,18 @@ func (cs *State) SetProposalAndBlock( // internal functions for managing the state func (cs *State) updateHeight(height int64) { - Global.HeightAt().Set(height) - Global.ClearStepMetrics() + cs.metrics.Height.Set(float64(height)) + cs.metrics.ClearStepMetrics() cs.roundState.SetHeight(height) } func (cs *State) updateRoundStep(round int32, step cstypes.RoundStepType) { if !cs.replayMode { if round != cs.roundState.Round() || round == 0 && step == cstypes.RoundStepNewRound { - Global.MarkRound(cs.roundState.Round(), cs.roundState.StartTime()) + cs.metrics.MarkRound(cs.roundState.Round(), cs.roundState.StartTime()) } if cs.roundState.Step() != step { - for m := range cs.metrics.Lock() { - m.MarkStep(cs.roundState.Step()) - } + cs.metrics.MarkStep(cs.roundState.Step()) } } cs.roundState.SetRound(round) @@ -746,13 +747,13 @@ func (cs *State) receiveRoutine(ctx context.Context, maxSteps int) error { } } func (cs *State) fsyncAndCompleteProposal(ctx context.Context, fsyncUponCompletion bool, height int64, span otrace.Span, onPropose bool) { - Global.ProposalBlockCreatedOnProposeAt(strconv.FormatBool(onPropose)).Add(1) + cs.metrics.ProposalBlockCreatedOnPropose.With("success", strconv.FormatBool(onPropose)).Add(1) if fsyncUponCompletion { if err := cs.wal.Sync(); err != nil { // fsync logger.Error("Error flushing wal after receiving all block parts", "error", err) } } - Global.MarkCompleteProposalTime(time.Since(cs.roundState.ProposalReceiveTime())) + cs.metrics.MarkCompleteProposalTime(time.Since(cs.roundState.ProposalReceiveTime())) cs.handleCompleteProposal(ctx, height, span) } @@ -765,9 +766,7 @@ func (cs *State) handleMsg(ctx context.Context, mi msgInfo, fsyncUponCompletion err error ) - for m := range cs.metrics.Lock() { - m.MarkStepLatency(cs.roundState.Step()) - } + cs.metrics.MarkStepLatency(cs.roundState.Step()) msg, peerID := mi.Msg, mi.PeerID @@ -892,9 +891,7 @@ func (cs *State) handleTimeout( // the timeout will now cause a state transition cs.mtx.Lock() defer cs.mtx.Unlock() - for m := range cs.metrics.Lock() { - m.MarkStepLatency(rs.Step) - } + cs.metrics.MarkStepLatency(rs.Step) switch ti.Step { case cstypes.RoundStepNewHeight: @@ -1193,7 +1190,7 @@ func (cs *State) decideProposal(ctx context.Context, height int64, round int32, } else if block == nil { return } - Global.ProposalCreateCountAt().Add(1) + cs.metrics.ProposalCreateCount.Add(1) blockParts, err = block.MakePartSet(types.BlockPartSizeBytes) if err != nil { logger.Error("unable to create proposal block part set", "error", err) @@ -1422,7 +1419,7 @@ func (cs *State) defaultDoPrevote(ctx context.Context, height int64, round int32 if err != nil { panic(fmt.Sprintf("ProcessProposal: %v", err)) } - Global.MarkProposalProcessed(isAppValid) + cs.metrics.MarkProposalProcessed(isAppValid) // Vote nil if the Application rejected the block if !isAppValid { @@ -1663,9 +1660,7 @@ func (cs *State) enterPrecommit(ctx context.Context, height int64, round int32, if !cs.roundState.ProposalBlockParts().HasHeader(blockID.PartSetHeader) { cs.roundState.SetProposalBlock(nil) - for m := range cs.metrics.Lock() { - m.MarkBlockGossipStarted() - } + cs.metrics.MarkBlockGossipStarted() cs.roundState.SetProposalBlockParts(types.NewPartSetFromHeader(blockID.PartSetHeader)) } @@ -1761,9 +1756,7 @@ func (cs *State) enterCommit(ctx context.Context, height int64, commitRound int3 // We're getting the wrong block. // Set up ProposalBlockParts, clear ProposalBlock and keep waiting for the parts. - for m := range cs.metrics.Lock() { - m.MarkBlockGossipStarted() - } + cs.metrics.MarkBlockGossipStarted() cs.roundState.SetProposalBlockParts(types.NewPartSetFromHeader(blockID.PartSetHeader)) cs.roundState.SetProposalBlock(nil) @@ -1856,7 +1849,7 @@ func (cs *State) finalizeCommit(ctx context.Context, height int64) { seenCommit := cs.roundState.Votes().Precommits(cs.roundState.CommitRound()).MakeCommit() cs.blockStore.SaveBlock(block, blockParts, seenCommit) // Calculate consensus time - Global.MarkConsensusTime(time.Since(cs.roundState.StartTime())) + cs.metrics.MarkConsensusTime(time.Since(cs.roundState.StartTime())) } else { // Happens during replay if we already saved the block but didn't commit logger.Debug("calling finalizeCommit on already stored block", "height", block.Height) @@ -1904,7 +1897,7 @@ func (cs *State) finalizeCommit(ctx context.Context, height int64) { block, cs.tracer, ) - Global.MarkApplyBlockLatency(time.Since(startTime)) + cs.metrics.MarkApplyBlockLatency(time.Since(startTime)) if err != nil { logger.Error("failed to apply block", "err", err) return @@ -1932,8 +1925,8 @@ func (cs *State) finalizeCommit(ctx context.Context, height int64) { } func (cs *State) RecordMetrics(height int64, block *types.Block) { - Global.ValidatorsAt().Set(int64(cs.roundState.Validators().Size())) - Global.ValidatorsPowerAt().Set(cs.roundState.Validators().TotalVotingPower()) + cs.metrics.Validators.Set(float64(cs.roundState.Validators().Size())) + cs.metrics.ValidatorsPower.Set(float64(cs.roundState.Validators().TotalVotingPower())) var ( missingValidators int @@ -1971,23 +1964,25 @@ func (cs *State) RecordMetrics(height int64, block *types.Block) { if commitSig.BlockIDFlag == types.BlockIDFlagAbsent { missingValidators++ missingValidatorsPower += val.VotingPower - Global.MissingValidatorsPowerAt(val.Address.String()).Set(val.VotingPower) + cs.metrics.MissingValidatorsPower.With("validator_address", val.Address.String()).Set(float64(val.VotingPower)) } else { - Global.MissingValidatorsPowerAt(val.Address.String()).Set(0) + cs.metrics.MissingValidatorsPower.With("validator_address", val.Address.String()).Set(0) } if bytes.Equal(val.Address, address) { - validatorAddress := val.Address.String() - Global.ValidatorPowerAt(validatorAddress).Set(val.VotingPower) + label := []string{ + "validator_address", val.Address.String(), + } + cs.metrics.ValidatorPower.With(label...).Set(float64(val.VotingPower)) if commitSig.BlockIDFlag == types.BlockIDFlagCommit { - Global.ValidatorLastSignedHeightAt(validatorAddress).Set(height) + cs.metrics.ValidatorLastSignedHeight.With(label...).Set(float64(height)) } else { - Global.ValidatorMissedBlocksAt(validatorAddress).Add(1) + cs.metrics.ValidatorMissedBlocks.With(label...).Add(float64(1)) } } } } - Global.MissingValidatorsAt().Set(int64(missingValidators)) + cs.metrics.MissingValidators.Set(float64(missingValidators)) // NOTE: byzantine validators power and count is only for consensus evidence i.e. duplicate vote var ( @@ -2003,14 +1998,14 @@ func (cs *State) RecordMetrics(height int64, block *types.Block) { } } } - Global.ByzantineValidatorsAt().Set(byzantineValidatorsCount) - Global.ByzantineValidatorsPowerAt().Set(byzantineValidatorsPower) + cs.metrics.ByzantineValidators.Set(float64(byzantineValidatorsCount)) + cs.metrics.ByzantineValidatorsPower.Set(float64(byzantineValidatorsPower)) // Block Interval metric if height > 1 { lastBlockMeta := cs.blockStore.LoadBlockMeta(height - 1) if lastBlockMeta != nil { - Global.BlockIntervalSecondsAt().Observe( + cs.metrics.BlockIntervalSeconds.Observe( block.Time.Sub(lastBlockMeta.Header.Time).Seconds(), ) } @@ -2021,8 +2016,8 @@ func (cs *State) RecordMetrics(height int64, block *types.Block) { // Latency metric for prevote delay if proposal != nil { - Global.MarkFinalRound(roundState.Round, proposal.ProposerAddress.String()) - Global.MarkProposeLatency(proposal.ProposerAddress.String(), proposal.Timestamp.Sub(roundState.StartTime)) + cs.metrics.MarkFinalRound(roundState.Round, proposal.ProposerAddress.String()) + cs.metrics.MarkProposeLatency(proposal.ProposerAddress.String(), proposal.Timestamp.Sub(roundState.StartTime)) for roundID := int32(0); roundID <= roundState.ValidRound; roundID++ { //nolint:gosec // ValidRound is a small consensus round number preVotes := roundState.Votes.Prevotes(roundID) pl := preVotes.List() @@ -2037,14 +2032,14 @@ func (cs *State) RecordMetrics(height int64, block *types.Block) { for _, vote := range pl { currVoteDelay := vote.Timestamp.Sub(roundState.StartTime) relativeVoteDelay := currVoteDelay - firstVoteDelay - Global.MarkPrevoteLatency(vote.ValidatorAddress.String(), relativeVoteDelay) + cs.metrics.MarkPrevoteLatency(vote.ValidatorAddress.String(), relativeVoteDelay) } } } - Global.NumTxsAt().Set(int64(len(block.Txs))) - Global.TotalTxsAt().Add(int64(len(block.Txs))) - Global.BlockSizeBytesAt().Observe(float64(block.Size())) - Global.CommittedHeightAt().Set(block.Height) + cs.metrics.NumTxs.Set(float64(len(block.Txs))) + cs.metrics.TotalTxs.Add(float64(len(block.Txs))) + cs.metrics.BlockSizeBytes.Observe(float64(block.Size())) + cs.metrics.CommittedHeight.Set(float64(block.Height)) } //----------------------------------------------------------------------------- @@ -2104,9 +2099,7 @@ func (cs *State) defaultSetProposal(proposal *types.Proposal, recvTime time.Time logger.Debug("rejecting proposal with too many parts", "total", proposal.BlockID.PartSetHeader.Total, "max", types.MaxBlockPartsCount) return ErrInvalidProposalPartSetHeader } - for m := range cs.metrics.Lock() { - m.MarkBlockGossipStarted() - } + cs.metrics.MarkBlockGossipStarted() cs.roundState.SetProposalBlockParts(types.NewPartSetFromHeader(proposal.BlockID.PartSetHeader)) cs.roundState.SetProposalBlock(nil) } @@ -2127,13 +2120,13 @@ func (cs *State) addProposalBlockPart( // Blocks might be reused, so round mismatch is OK if cs.roundState.Height() != height { logger.Debug("received block part from wrong height", "height", height, "round", round) - Global.BlockGossipPartsReceivedAt("false").Add(1) + cs.metrics.BlockGossipPartsReceived.With("matches_current", "false").Add(1) return false, nil } // We're not expecting a block part. if cs.roundState.ProposalBlockParts() == nil { - Global.BlockGossipPartsReceivedAt("false").Add(1) + cs.metrics.BlockGossipPartsReceived.With("matches_current", "false").Add(1) // NOTE: this can happen when we've gone to a higher round and // then receive parts from the previous round - not necessarily a bad peer. logger.Debug( @@ -2149,12 +2142,12 @@ func (cs *State) addProposalBlockPart( added, err = cs.roundState.ProposalBlockParts().AddPart(part) if err != nil { if errors.Is(err, types.ErrPartSetInvalidProof) || errors.Is(err, types.ErrPartSetUnexpectedIndex) { - Global.BlockGossipPartsReceivedAt("false").Add(1) + cs.metrics.BlockGossipPartsReceived.With("matches_current", "false").Add(1) } return added, err } - Global.BlockGossipPartsReceivedAt("true").Add(1) + cs.metrics.BlockGossipPartsReceived.With("matches_current", "true").Add(1) if cs.roundState.ProposalBlockParts().ByteSize() > cs.state.ConsensusParams.Block.MaxBytes { return added, fmt.Errorf("total size of proposal block parts exceeds maximum block bytes (%d > %d)", @@ -2162,9 +2155,7 @@ func (cs *State) addProposalBlockPart( ) } if added && cs.roundState.ProposalBlockParts().IsComplete() { - for m := range cs.metrics.Lock() { - m.MarkBlockGossipComplete() - } + cs.metrics.MarkBlockGossipComplete() block, err := cs.getBlockFromBlockParts() if err != nil { logger.Error("Encountered error building block from parts", "block parts", cs.roundState.ProposalBlockParts()) @@ -2214,9 +2205,7 @@ func (cs *State) tryCreateProposalBlock(ctx context.Context) bool { defer func() { if cs.roundState.ProposalBlock() != nil { // NOTE: it's possible to receive complete proposal blocks for future rounds without having the proposal - for m := range cs.metrics.Lock() { - m.MarkBlockGossipComplete() - } + cs.metrics.MarkBlockGossipComplete() } }() @@ -2290,7 +2279,7 @@ func (cs *State) tryCreateProposalBlock(ctx context.Context) bool { func (cs *State) buildProposalBlock(proposal *types.Proposal) *types.Block { txs, missingTxs := cs.blockExec.SafeGetTxsByHashes(proposal.TxHashes) if len(missingTxs) > 0 { - Global.ProposalMissingTxsAt().Set(int64(len(missingTxs))) + cs.metrics.ProposalMissingTxs.Set(float64(len(missingTxs))) logger.Debug("Missing txs when trying to build block", "missing_txs", missingTxs) return nil } @@ -2405,7 +2394,7 @@ func (cs *State) addVote( "cs_height", cs.roundState.Height(), ) if vote.Height < cs.roundState.Height() || (vote.Height == cs.roundState.Height() && vote.Round < cs.roundState.Round()) { - Global.MarkLateVote(vote) + cs.metrics.MarkLateVote(vote) } // A precommit for the previous height? @@ -2459,7 +2448,7 @@ func (cs *State) addVote( if !ok { panic(fmt.Errorf("validator index %v out of range", vote.ValidatorIndex)) } - Global.MarkVoteReceived(vote.Type, val.VotingPower, vals.TotalVotingPower()) + cs.metrics.MarkVoteReceived(vote.Type, val.VotingPower, vals.TotalVotingPower()) } if err := cs.eventBus.PublishEventVote(types.EventDataVote{Vote: vote}); err != nil { @@ -2496,9 +2485,7 @@ func (cs *State) addVote( } if !cs.roundState.ProposalBlockParts().HasHeader(blockID.PartSetHeader) { - for m := range cs.metrics.Lock() { - m.MarkBlockGossipStarted() - } + cs.metrics.MarkBlockGossipStarted() cs.roundState.SetProposalBlockParts(types.NewPartSetFromHeader(blockID.PartSetHeader)) } @@ -2726,12 +2713,12 @@ func (cs *State) calculatePrevoteMessageDelayMetrics() { } votingPowerSeen += val.VotingPower if votingPowerSeen >= cs.roundState.Validators().TotalVotingPower()*2/3+1 { - Global.QuorumPrevoteDelayAt(leaderAddr.String()).Set(v.Timestamp.Sub(cs.roundState.Proposal().Timestamp).Seconds()) + cs.metrics.QuorumPrevoteDelay.With("proposer_address", leaderAddr.String()).Set(v.Timestamp.Sub(cs.roundState.Proposal().Timestamp).Seconds()) break } } if ps.HasAll() { - Global.FullPrevoteDelayAt(leaderAddr.String()).Set(pl[len(pl)-1].Timestamp.Sub(cs.roundState.Proposal().Timestamp).Seconds()) + cs.metrics.FullPrevoteDelay.With("proposer_address", leaderAddr.String()).Set(pl[len(pl)-1].Timestamp.Sub(cs.roundState.Proposal().Timestamp).Seconds()) } } @@ -2761,7 +2748,7 @@ func (cs *State) calculateProposalTimestampDifferenceMetric() { if cs.roundState.Proposal() != nil && cs.roundState.Proposal().POLRound == -1 { sp := cs.state.ConsensusParams.Synchrony.SynchronyParamsOrDefaults() isTimely := cs.roundState.Proposal().IsTimely(cs.roundState.ProposalReceiveTime(), sp, cs.roundState.Round()) - Global.ProposalTimestampDifferenceAt(fmt.Sprintf("%t", isTimely)). + cs.metrics.ProposalTimestampDifference.With("is_timely", fmt.Sprintf("%t", isTimely)). Observe(cs.roundState.ProposalReceiveTime().Sub(cs.roundState.Proposal().Timestamp).Seconds()) } } diff --git a/sei-tendermint/internal/consensus/state_badproposal_default_test.go b/sei-tendermint/internal/consensus/state_badproposal_default_test.go index 06ef4bf93f..ab4cf29891 100644 --- a/sei-tendermint/internal/consensus/state_badproposal_default_test.go +++ b/sei-tendermint/internal/consensus/state_badproposal_default_test.go @@ -8,7 +8,6 @@ package consensus import ( "testing" - tmconfig "github.com/sei-protocol/sei-chain/sei-tendermint/config" "github.com/sei-protocol/sei-chain/sei-tendermint/crypto" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" @@ -18,7 +17,6 @@ import ( func TestStateBadProposal(t *testing.T) { config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID ctx := t.Context() cs1, vss := makeState(ctx, t, makeStateArgs{config: config, validators: 2}) @@ -48,7 +46,7 @@ func TestStateBadProposal(t *testing.T) { require.NoError(t, err) proposal := types.NewProposal(vs2.Height, round, -1, blockID, propBlock.Header.Time, propBlock.GetTxHashes(), propBlock.Header, propBlock.LastCommit, propBlock.Evidence, pubKey.Address()) p := proposal.ToProto() - require.NoError(t, vs2.SignProposal(ctx, chainID, p)) + require.NoError(t, vs2.SignProposal(ctx, config.ChainID(), p)) proposal.Signature = utils.OrPanic1(crypto.SigFromBytes(p.Signature)) err = cs1.SetProposalAndBlock(ctx, proposal, propBlock, propBlockParts, "some peer") @@ -57,9 +55,9 @@ func TestStateBadProposal(t *testing.T) { cs1.startTestRound(ctx, height, round) ensureProposal(t, proposalCh, height, round, blockID) ensurePrevoteMatch(t, voteCh, height, round, nil) - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, blockID, vs2) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), blockID, vs2) ensurePrevote(t, voteCh, height, round) ensurePrecommit(t, voteCh, height, round) cs1.validatePrecommit(ctx, t, round, -1, vss[0], nil, nil) - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, blockID, vs2) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), blockID, vs2) } diff --git a/sei-tendermint/internal/consensus/state_empty_valset_test.go b/sei-tendermint/internal/consensus/state_empty_valset_test.go deleted file mode 100644 index f13503e277..0000000000 --- a/sei-tendermint/internal/consensus/state_empty_valset_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package consensus - -import ( - "encoding/json" - "testing" - "time" - - "github.com/sei-protocol/sei-chain/sei-tendermint/abci/example/kvstore" - abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/proxy" - sm "github.com/sei-protocol/sei-chain/sei-tendermint/internal/state" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/store" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/test/factory" - - dbm "github.com/tendermint/tm-db" - - "github.com/stretchr/testify/require" -) - -// newStateFromEmptyGenesisValidators builds a consensus State whose genesis -// carries no validators — the shape a gentx-launched chain has before its set -// is installed — and drives the reactor's OnStart -> updateStateFromStore path, -// leaving the round state holding an empty validator set. -func newStateFromEmptyGenesisValidators(t *testing.T) *State { - t.Helper() - ctx := t.Context() - - cfg := configSetup(t) - genDoc := factory.GenesisDoc(cfg, time.Now(), nil, factory.ConsensusParams()) - state, err := sm.MakeGenesisState(genDoc) - require.NoError(t, err) - require.Equal(t, 0, state.Validators.Size(), "genesis validator set must be empty") - require.Zero(t, state.LastBlockHeight) - - thisConfig, err := ResetConfig(t.TempDir(), "plt794") - require.NoError(t, err) - - app := kvstore.NewApplication() - t.Cleanup(func() { _ = app.Close() }) - _, err = app.InitChain(ctx, &abci.RequestInitChain{}) - require.NoError(t, err) - - pv := loadPrivValidator(thisConfig) - blockStore := store.NewBlockStore(dbm.NewMemDB()) - proxyApp := proxy.New(app) - - return newStateWithConfigAndBlockStore(t, thisConfig, state, pv, proxyApp, blockStore).State -} - -// TestConsensusStateRPCEmptyValidatorSet guards the /consensus_state RPC path -// (GetRoundStateSimpleJSON -> RoundStateSimple -> leader resolution) against an -// empty validator set: with zero total voting power, leader election divides by -// zero and takes the process down. The RPC must serialize an empty proposer -// instead. -func TestConsensusStateRPCEmptyValidatorSet(t *testing.T) { - cs := newStateFromEmptyGenesisValidators(t) - require.Equal(t, 0, cs.roundState.Validators().Size()) - - var bz []byte - require.NotPanics(t, func() { - var err error - bz, err = cs.GetRoundStateSimpleJSON() - require.NoError(t, err) - }, "the /consensus_state RPC path must not divide by zero on an empty validator set") - - // The serialized round state carries an empty proposer rather than crashing. - var simple struct { - Proposer struct { - Address []byte `json:"address"` - Index int32 `json:"index"` - } `json:"proposer"` - } - require.NoError(t, json.Unmarshal(bz, &simple)) - require.Empty(t, simple.Proposer.Address) - require.Zero(t, simple.Proposer.Index) -} diff --git a/sei-tendermint/internal/consensus/state_test.go b/sei-tendermint/internal/consensus/state_test.go index d1c8bf402e..c38a9c4a5c 100644 --- a/sei-tendermint/internal/consensus/state_test.go +++ b/sei-tendermint/internal/consensus/state_test.go @@ -14,7 +14,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/abci/example/kvstore" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" abcimocks "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types/mocks" - tmconfig "github.com/sei-protocol/sei-chain/sei-tendermint/config" + "github.com/sei-protocol/sei-chain/sei-tendermint/config" "github.com/sei-protocol/sei-chain/sei-tendermint/crypto" "github.com/sei-protocol/sei-chain/sei-tendermint/crypto/ed25519" cstypes "github.com/sei-protocol/sei-chain/sei-tendermint/internal/consensus/types" @@ -76,7 +76,6 @@ x * TestHalt1 - if we see +2/3 precommits after timing out into new round, we sh func TestStateProposerSelection0(t *testing.T) { ctx := t.Context() config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID cs1, vss := makeState(ctx, t, makeStateArgs{config: config}) height, round := cs1.roundState.Height(), cs1.roundState.Round() @@ -101,7 +100,7 @@ func TestStateProposerSelection0(t *testing.T) { ensureNewProposal(t, proposalCh, height, round) rs := cs1.GetRoundState() - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, types.BlockID{ + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), types.BlockID{ Hash: rs.ProposalBlock.Hash(), PartSetHeader: rs.ProposalBlockParts.Header(), }, vss[1:]...) @@ -119,7 +118,6 @@ func TestStateProposerSelection0(t *testing.T) { // Now let's do it all again, but starting from round 2 instead of 0 func TestStateProposerSelection2(t *testing.T) { config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID ctx := t.Context() cs1, vss := makeState(ctx, t, makeStateArgs{config: config}) // test needs more work for more than 3 validators @@ -147,7 +145,7 @@ func TestStateProposerSelection2(t *testing.T) { int(i+2)%len(vss), prop.Address) - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, types.BlockID{}, vss[1:]...) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), types.BlockID{}, vss[1:]...) ensureNewRound(t, newRoundCh, height, i+round+1) // wait for the new round event each round incrementRound(vss[1:]...) } @@ -205,7 +203,6 @@ func TestStateEnterProposeYesPrivValidator(t *testing.T) { func TestStateOversizedBlock(t *testing.T) { config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID ctx := t.Context() cs1, vss := makeState(ctx, t, makeStateArgs{config: config, validators: 2}) @@ -232,7 +229,7 @@ func TestStateOversizedBlock(t *testing.T) { require.NoError(t, err) proposal := types.NewProposal(height, round, -1, blockID, propBlock.Header.Time, propBlock.GetTxHashes(), propBlock.Header, propBlock.LastCommit, propBlock.Evidence, pubKey.Address()) p := proposal.ToProto() - require.NoError(t, vs2.SignProposal(ctx, chainID, p)) + require.NoError(t, vs2.SignProposal(ctx, config.ChainID(), p)) proposal.Signature = utils.OrPanic1(crypto.SigFromBytes(p.Signature)) err = cs1.SetProposalAndBlock(ctx, proposal, propBlock, propBlockParts, "some peer") @@ -241,11 +238,11 @@ func TestStateOversizedBlock(t *testing.T) { cs1.startTestRound(ctx, height, round) ensureNewTimeout(t, timeoutProposeCh, height, round) ensurePrevoteMatch(t, voteCh, height, round, nil) - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, blockID, vs2) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), blockID, vs2) ensurePrevote(t, voteCh, height, round) ensurePrecommit(t, voteCh, height, round) cs1.validatePrecommit(ctx, t, round, -1, vss[0], nil, nil) - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, blockID, vs2) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), blockID, vs2) } //---------------------------------------------------------------------------------------------------- @@ -292,7 +289,6 @@ func TestStateFullRoundNil(t *testing.T) { // where the first validator has to wait for votes from the second func TestStateFullRound2(t *testing.T) { config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID ctx := t.Context() cs1, vss := makeState(ctx, t, makeStateArgs{config: config, validators: 2}) @@ -308,11 +304,11 @@ func TestStateFullRound2(t *testing.T) { rs := cs1.GetRoundState() blockID := types.BlockID{Hash: rs.ProposalBlock.Hash(), PartSetHeader: rs.ProposalBlockParts.Header()} - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, blockID, vs2) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), blockID, vs2) ensurePrevote(t, voteCh, height, round) ensurePrecommit(t, voteCh, height, round) cs1.validatePrecommit(ctx, t, 0, 0, vss[0], blockID.Hash, blockID.Hash) - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, blockID, vs2) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), blockID, vs2) ensurePrecommit(t, voteCh, height, round) ensureNewBlock(t, newBlockCh, height) } @@ -330,7 +326,6 @@ func TestStateLock_NoPOL(t *testing.T) { func testStateLockNoPOL(t *testing.T) { config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID // Deflake: when cs1 is proposer in round 3, proposal construction can race // timeoutPropose on loaded CI runners and force an early prevote nil. config.Consensus.UnsafeProposeTimeoutOverride = time.Second @@ -373,7 +368,7 @@ func testStateLockNoPOL(t *testing.T) { // we should now be stuck in limbo forever, waiting for more prevotes // prevote arrives from vs2: - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, initialBlockID, vs2) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), initialBlockID, vs2) ensurePrevote(t, voteCh, height, round) // prevote cs1.validatePrevote(ctx, t, round, vss[0], initialBlockID.Hash) @@ -386,7 +381,7 @@ func testStateLockNoPOL(t *testing.T) { hash := make([]byte, len(initialBlockID.Hash)) copy(hash, initialBlockID.Hash) hash[0] = (hash[0] + 1) % 255 - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, types.BlockID{ + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), types.BlockID{ Hash: hash, PartSetHeader: initialBlockID.PartSetHeader, }, vs2) @@ -421,7 +416,7 @@ func testStateLockNoPOL(t *testing.T) { partSet, err := rs.LockedBlock.MakePartSet(partSize) require.NoError(t, err) conflictingBlockID := types.BlockID{Hash: hash, PartSetHeader: partSet.Header()} - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, conflictingBlockID, vs2) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), conflictingBlockID, vs2) ensurePrevote(t, voteCh, height, round) // now we're going to enter prevote again, but with invalid args @@ -433,7 +428,7 @@ func testStateLockNoPOL(t *testing.T) { cs1.validatePrecommit(ctx, t, round, initialLockRound, vss[0], nil, initialBlockID.Hash) // add conflicting precommit from vs2 - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, conflictingBlockID, vs2) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), conflictingBlockID, vs2) ensurePrecommit(t, voteCh, height, round) // (note we're entering precommit for a second time this round, but with invalid args @@ -462,7 +457,7 @@ func testStateLockNoPOL(t *testing.T) { partSet, err = rs.ProposalBlock.MakePartSet(partSize) require.NoError(t, err) newBlockID := types.BlockID{Hash: hash, PartSetHeader: partSet.Header()} - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, newBlockID, vs2) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), newBlockID, vs2) ensurePrevote(t, voteCh, height, round) ensureNewTimeout(t, timeoutWaitCh, height, round) @@ -474,7 +469,7 @@ func testStateLockNoPOL(t *testing.T) { ctx, t, tmproto.PrecommitType, - chainID, + config.ChainID(), newBlockID, vs2) // NOTE: conflicting precommits at same height ensurePrecommit(t, voteCh, height, round) @@ -515,7 +510,7 @@ func testStateLockNoPOL(t *testing.T) { cs1.validatePrevote(ctx, t, round, vss[0], nil) // prevote for proposed block - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, propBlockID, vs2) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), propBlockID, vs2) ensurePrevote(t, voteCh, height, round) ensureNewTimeout(t, timeoutWaitCh, height, round) @@ -526,7 +521,7 @@ func testStateLockNoPOL(t *testing.T) { ctx, t, tmproto.PrecommitType, - chainID, + config.ChainID(), propBlockID, vs2) // NOTE: conflicting precommits at same height ensurePrecommit(t, voteCh, height, round) @@ -539,7 +534,6 @@ func testStateLockNoPOL(t *testing.T) { // power on the network for the block. func TestStateLock_POLUpdateLock(t *testing.T) { config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID ctx := t.Context() @@ -580,7 +574,7 @@ func TestStateLock_POLUpdateLock(t *testing.T) { ensurePrevote(t, voteCh, height, round) - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, initialBlockID, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), initialBlockID, vs2, vs3, vs4) // check that the validator generates a Lock event. ensureLock(t, lockCh, height, round) @@ -590,7 +584,7 @@ func TestStateLock_POLUpdateLock(t *testing.T) { cs1.validatePrecommit(ctx, t, round, round, vss[0], initialBlockID.Hash, initialBlockID.Hash) // add precommits from the rest of the validators. - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, types.BlockID{}, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), types.BlockID{}, vs2, vs3, vs4) // timeout to new round. ensureNewTimeout(t, timeoutWaitCh, height, round) @@ -630,7 +624,7 @@ func TestStateLock_POLUpdateLock(t *testing.T) { ensurePrevoteMatch(t, voteCh, height, round, nil) // Add prevotes from the remainder of the validators for the new locked block. - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, r1BlockID, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), r1BlockID, vs2, vs3, vs4) // Check that we lock on a new block. ensureLock(t, lockCh, height, round) @@ -648,7 +642,6 @@ func TestStateLock_POLUpdateLock(t *testing.T) { func TestStateLock_POLRelock(t *testing.T) { ctx := t.Context() config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID cs1, vss := makeState(ctx, t, makeStateArgs{config: config}) vs2, vs3, vs4 := vss[1], vss[2], vss[3] @@ -686,7 +679,7 @@ func TestStateLock_POLRelock(t *testing.T) { ensurePrevote(t, voteCh, height, round) - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, blockID, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), blockID, vs2, vs3, vs4) // check that the validator generates a Lock event. ensureLock(t, lockCh, height, round) @@ -696,7 +689,7 @@ func TestStateLock_POLRelock(t *testing.T) { cs1.validatePrecommit(ctx, t, round, round, vss[0], blockID.Hash, blockID.Hash) // add precommits from the rest of the validators. - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, types.BlockID{}, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), types.BlockID{}, vs2, vs3, vs4) // timeout to new round. ensureNewTimeout(t, timeoutWaitCh, height, round) @@ -732,7 +725,7 @@ func TestStateLock_POLRelock(t *testing.T) { cs1.validatePrevote(ctx, t, round, vss[0], blockID.Hash) // Add prevotes from the remainder of the validators for the locked block. - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, blockID, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), blockID, vs2, vs3, vs4) // Check that we relock. ensureRelock(t, relockCh, height, round) @@ -748,7 +741,6 @@ func TestStateLock_POLRelock(t *testing.T) { func TestStateLock_PrevoteNilWhenLockedAndMissProposal(t *testing.T) { ctx := t.Context() config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID cs1, vss := makeState(ctx, t, makeStateArgs{config: config}) vs2, vs3, vs4 := vss[1], vss[2], vss[3] @@ -784,7 +776,7 @@ func TestStateLock_PrevoteNilWhenLockedAndMissProposal(t *testing.T) { ensurePrevote(t, voteCh, height, round) - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, blockID, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), blockID, vs2, vs3, vs4) // check that the validator generates a Lock event. ensureLock(t, lockCh, height, round) @@ -794,7 +786,7 @@ func TestStateLock_PrevoteNilWhenLockedAndMissProposal(t *testing.T) { cs1.validatePrecommit(ctx, t, round, round, vss[0], blockID.Hash, blockID.Hash) // add precommits from the rest of the validators. - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, types.BlockID{}, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), types.BlockID{}, vs2, vs3, vs4) // timeout to new round. ensureNewTimeout(t, timeoutWaitCh, height, round) @@ -818,7 +810,7 @@ func TestStateLock_PrevoteNilWhenLockedAndMissProposal(t *testing.T) { cs1.validatePrevote(ctx, t, round, vss[0], nil) // Add prevotes from the remainder of the validators nil. - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, types.BlockID{}, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), types.BlockID{}, vs2, vs3, vs4) ensurePrecommit(t, voteCh, height, round) // We should now be locked on the same block but with an updated locked round. cs1.validatePrecommit(ctx, t, round, lockRound, vss[0], nil, blockID.Hash) @@ -830,7 +822,6 @@ func TestStateLock_PrevoteNilWhenLockedAndDifferentProposal(t *testing.T) { ctx := t.Context() config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID /* All of the assertions in this test occur on the `cs1` validator. The test sends signed votes from the other validators to cs1 and @@ -871,7 +862,7 @@ func TestStateLock_PrevoteNilWhenLockedAndDifferentProposal(t *testing.T) { ensurePrevote(t, voteCh, height, round) - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, blockID, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), blockID, vs2, vs3, vs4) // check that the validator generates a Lock event. ensureLock(t, lockCh, height, round) @@ -881,7 +872,7 @@ func TestStateLock_PrevoteNilWhenLockedAndDifferentProposal(t *testing.T) { cs1.validatePrecommit(ctx, t, round, round, vss[0], blockID.Hash, blockID.Hash) // add precommits from the rest of the validators. - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, types.BlockID{}, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), types.BlockID{}, vs2, vs3, vs4) // timeout to new round. ensureNewTimeout(t, timeoutWaitCh, height, round) @@ -916,7 +907,7 @@ func TestStateLock_PrevoteNilWhenLockedAndDifferentProposal(t *testing.T) { cs1.validatePrevote(ctx, t, round, vss[0], nil) // Add prevotes from the remainder of the validators for nil. - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, types.BlockID{}, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), types.BlockID{}, vs2, vs3, vs4) // We should now be locked on the same block but prevote nil. ensurePrecommit(t, voteCh, height, round) @@ -930,7 +921,6 @@ func TestStateLock_PrevoteNilWhenLockedAndDifferentProposal(t *testing.T) { // that it has been completely removed. func TestStateLock_POLDoesNotUnlock(t *testing.T) { config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID ctx := t.Context() /* @@ -975,7 +965,7 @@ func TestStateLock_POLDoesNotUnlock(t *testing.T) { ensurePrevoteMatch(t, voteCh, height, round, blockID.Hash) - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, blockID, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), blockID, vs2, vs3, vs4) // the validator should have locked a block in this round. ensureLock(t, lockCh, height, round) @@ -990,8 +980,8 @@ func TestStateLock_POLDoesNotUnlock(t *testing.T) { // This ensures that the validator being tested does not commit the block. // We do not want the validator to commit the block because we want the test // test to proceeds to the next consensus round. - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, types.BlockID{}, vs2, vs4) - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, blockID, vs3) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), types.BlockID{}, vs2, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), blockID, vs3) // timeout to new round ensureNewTimeout(t, timeoutWaitCh, height, round) @@ -1022,14 +1012,14 @@ func TestStateLock_POLDoesNotUnlock(t *testing.T) { ensurePrevoteMatch(t, voteCh, height, round, nil) // add >2/3 prevotes for nil from all other validators - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, types.BlockID{}, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), types.BlockID{}, vs2, vs3, vs4) ensurePrecommit(t, voteCh, height, round) // verify that we haven't update our locked block since the first round cs1.validatePrecommit(ctx, t, round, lockRound, vss[0], nil, blockID.Hash) - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, types.BlockID{}, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), types.BlockID{}, vs2, vs3, vs4) ensureNewTimeout(t, timeoutWaitCh, height, round) /* @@ -1056,7 +1046,7 @@ func TestStateLock_POLDoesNotUnlock(t *testing.T) { ensurePrevote(t, voteCh, height, round) cs1.validatePrevote(ctx, t, round, vss[0], nil) - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, types.BlockID{}, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), types.BlockID{}, vs2, vs3, vs4) ensurePrecommit(t, voteCh, height, round) @@ -1069,7 +1059,6 @@ func TestStateLock_POLDoesNotUnlock(t *testing.T) { // new block if a proposal was not seen for that block. func TestStateLock_MissingProposalWhenPOLSeenDoesNotUpdateLock(t *testing.T) { config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID ctx := t.Context() @@ -1107,14 +1096,14 @@ func TestStateLock_MissingProposalWhenPOLSeenDoesNotUpdateLock(t *testing.T) { ensurePrevote(t, voteCh, height, round) // prevote - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, firstBlockID, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), firstBlockID, vs2, vs3, vs4) ensurePrecommit(t, voteCh, height, round) // our precommit // the proposed block should now be locked and our precommit added cs1.validatePrecommit(ctx, t, round, round, vss[0], firstBlockID.Hash, firstBlockID.Hash) // add precommits from the rest - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, types.BlockID{}, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), types.BlockID{}, vs2, vs3, vs4) // timeout to new round ensureNewTimeout(t, timeoutWaitCh, height, round) @@ -1148,7 +1137,7 @@ func TestStateLock_MissingProposalWhenPOLSeenDoesNotUpdateLock(t *testing.T) { ensurePrevoteMatch(t, voteCh, height, round, nil) // now lets add prevotes from everyone else for the new block - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, secondBlockID, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), secondBlockID, vs2, vs3, vs4) ensurePrecommit(t, voteCh, height, round) cs1.validatePrecommit(ctx, t, round, lockRound, vss[0], nil, firstBlockID.Hash) @@ -1161,7 +1150,6 @@ func TestStateLock_MissingProposalWhenPOLSeenDoesNotUpdateLock(t *testing.T) { func TestStateLock_DoesNotLockOnOldProposal(t *testing.T) { ctx := t.Context() config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID cs1, vss := makeState(ctx, t, makeStateArgs{config: config}) vs2, vs3, vs4 := vss[1], vss[2], vss[3] @@ -1194,13 +1182,13 @@ func TestStateLock_DoesNotLockOnOldProposal(t *testing.T) { ensurePrevote(t, voteCh, height, round) - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, types.BlockID{}, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), types.BlockID{}, vs2, vs3, vs4) // The proposed block should not have been locked. ensurePrecommit(t, voteCh, height, round) cs1.validatePrecommit(ctx, t, round, -1, vss[0], nil, nil) - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, types.BlockID{}, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), types.BlockID{}, vs2, vs3, vs4) incrementRound(vs2, vs3, vs4) @@ -1222,7 +1210,7 @@ func TestStateLock_DoesNotLockOnOldProposal(t *testing.T) { cs1.validatePrevote(ctx, t, round, vss[0], nil) // All validators prevote for the old block. // All validators prevote for the old block. - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, firstBlockID, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), firstBlockID, vs2, vs3, vs4) // Make sure that cs1 did not lock on the block since it did not receive a proposal for it. ensurePrecommit(t, voteCh, height, round) @@ -1235,7 +1223,6 @@ func TestStateLock_DoesNotLockOnOldProposal(t *testing.T) { // then we see the polka from round 1 but shouldn't unlock func TestStateLock_POLSafety1(t *testing.T) { config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID // Deflake: SetProposalAndBlock in round 2 can race timeoutPropose under CI load. config.Consensus.UnsafeProposeTimeoutOverride = time.Second config.Consensus.UnsafeProposeTimeoutDeltaOverride = 0 @@ -1282,12 +1269,12 @@ func TestStateLock_POLSafety1(t *testing.T) { PartSetHeader: propBlockParts.Header(), } // the others sign a polka but we don't see it - prevotes := signVotes(ctx, t, tmproto.PrevoteType, chainID, + prevotes := signVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), blockID, vs2, vs3, vs4) // we do see them precommit nil - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, types.BlockID{}, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), types.BlockID{}, vs2, vs3, vs4) // cs1 precommit nil ensurePrecommit(t, voteCh, height, round) @@ -1317,13 +1304,13 @@ func TestStateLock_POLSafety1(t *testing.T) { ensurePrevoteMatch(t, voteCh, height, round, r2BlockID.Hash) // now we see the others prevote for it, so we should lock on it - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, r2BlockID, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), r2BlockID, vs2, vs3, vs4) ensurePrecommit(t, voteCh, height, round) // we should have precommitted cs1.validatePrecommit(ctx, t, round, round, vss[0], r2BlockID.Hash, r2BlockID.Hash) - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, types.BlockID{}, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), types.BlockID{}, vs2, vs3, vs4) ensureNewTimeout(t, timeoutWaitCh, height, round) @@ -1360,7 +1347,6 @@ func TestStateLock_POLSafety1(t *testing.T) { // dont see P0, lock on P1 at R1, dont unlock using P0 at R2 func TestStateLock_POLSafety2(t *testing.T) { config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID ctx := t.Context() cs1, vss := makeState(ctx, t, makeStateArgs{config: config}) @@ -1388,7 +1374,7 @@ func TestStateLock_POLSafety2(t *testing.T) { propBlockID0 := types.BlockID{Hash: propBlockHash0, PartSetHeader: propBlockParts0.Header()} // the others sign a polka but we don't see it - prevotes := signVotes(ctx, t, tmproto.PrevoteType, chainID, propBlockID0, vs2, vs3, vs4) + prevotes := signVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), propBlockID0, vs2, vs3, vs4) // the block for round 1 nextRound := round + 1 @@ -1413,15 +1399,15 @@ func TestStateLock_POLSafety2(t *testing.T) { ensurePrevoteMatch(t, voteCh, height, round, propBlockID1.Hash) - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, propBlockID1, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), propBlockID1, vs2, vs3, vs4) ensurePrecommit(t, voteCh, height, round) // the proposed block should now be locked and our precommit added cs1.validatePrecommit(ctx, t, round, round, vss[0], propBlockID1.Hash, propBlockID1.Hash) // add precommits from the rest - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, types.BlockID{}, vs2, vs4) - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, propBlockID1, vs3) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), types.BlockID{}, vs2, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), propBlockID1, vs3) incrementRound(vs2, vs3, vs4) @@ -1442,7 +1428,7 @@ func TestStateLock_POLSafety2(t *testing.T) { require.NoError(t, err) newProp := types.NewProposal(height, round, baseRound, propBlockID0, propBlock0.Header.Time, propBlock0.GetTxHashes(), propBlock0.Header, propBlock0.LastCommit, propBlock0.Evidence, pubKey.Address()) p := newProp.ToProto() - err = leaderR2.SignProposal(ctx, chainID, p) + err = leaderR2.SignProposal(ctx, config.ChainID(), p) require.NoError(t, err) newProp.Signature = utils.OrPanic1(crypto.SigFromBytes(p.Signature)) @@ -1465,7 +1451,6 @@ func TestStateLock_POLSafety2(t *testing.T) { func TestState_PrevotePOLFromPreviousRound(t *testing.T) { ctx := t.Context() config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID cs1, vss := makeState(ctx, t, makeStateArgs{config: config}) vs2, vs3, vs4 := vss[1], vss[2], vss[3] @@ -1504,7 +1489,7 @@ func TestState_PrevotePOLFromPreviousRound(t *testing.T) { ensurePrevote(t, voteCh, height, round) - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, r0BlockID, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), r0BlockID, vs2, vs3, vs4) // check that the validator generates a Lock event. ensureLock(t, lockCh, height, round) @@ -1514,7 +1499,7 @@ func TestState_PrevotePOLFromPreviousRound(t *testing.T) { cs1.validatePrecommit(ctx, t, round, round, vss[0], r0BlockID.Hash, r0BlockID.Hash) // add precommits from the rest of the validators. - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, types.BlockID{}, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), types.BlockID{}, vs2, vs3, vs4) // timeout to new round. ensureNewTimeout(t, timeoutWaitCh, height, round) @@ -1549,12 +1534,12 @@ func TestState_PrevotePOLFromPreviousRound(t *testing.T) { ensureNewRound(t, newRoundCh, height, round) - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, r1BlockID, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), r1BlockID, vs2, vs3, vs4) ensurePrevote(t, voteCh, height, round) cs1.validatePrevote(ctx, t, round, vss[0], nil) - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, types.BlockID{}, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), types.BlockID{}, vs2, vs3, vs4) ensurePrecommit(t, voteCh, height, round) @@ -1596,7 +1581,7 @@ func TestState_PrevotePOLFromPreviousRound(t *testing.T) { ensurePrevote(t, voteCh, height, round) cs1.validatePrevote(ctx, t, round, vss[0], r1BlockID.Hash) - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, types.BlockID{}, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), types.BlockID{}, vs2, vs3, vs4) // cs1 did not receive a POL within this round, so it should remain locked // on the block from round 0. @@ -1611,7 +1596,6 @@ func TestState_PrevotePOLFromPreviousRound(t *testing.T) { // P0 proposes B0 at R3. func TestProposeValidBlock(t *testing.T) { config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID ctx := t.Context() cs1, vss := makeState(ctx, t, makeStateArgs{config: config}) @@ -1647,14 +1631,14 @@ func TestProposeValidBlock(t *testing.T) { ensurePrevoteMatch(t, voteCh, height, round, blockID.Hash) // the others sign a polka - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, blockID, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), blockID, vs2, vs3, vs4) ensurePrecommit(t, voteCh, height, round) // we should have precommitted the proposed block in this round. cs1.validatePrecommit(ctx, t, round, round, vss[0], blockID.Hash, blockID.Hash) - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, types.BlockID{}, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), types.BlockID{}, vs2, vs3, vs4) ensureNewTimeout(t, timeoutWaitCh, height, round) @@ -1669,7 +1653,7 @@ func TestProposeValidBlock(t *testing.T) { // We did not see a valid proposal within this round, so prevote nil. ensurePrevoteMatch(t, voteCh, height, round, nil) - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, types.BlockID{}, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), types.BlockID{}, vs2, vs3, vs4) ensurePrecommit(t, voteCh, height, round) // we should have precommitted nil during this round because we received @@ -1679,7 +1663,7 @@ func TestProposeValidBlock(t *testing.T) { incrementRound(vs2, vs3, vs4) incrementRound(vs2, vs3, vs4) - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, types.BlockID{}, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), types.BlockID{}, vs2, vs3, vs4) round += 2 // increment by multiple rounds @@ -1704,7 +1688,6 @@ func TestProposeValidBlock(t *testing.T) { // P0 miss to lock B but set valid block to B after receiving delayed prevote. func TestSetValidBlockOnDelayedPrevote(t *testing.T) { config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID ctx := t.Context() cs1, vss := makeState(ctx, t, makeStateArgs{config: config}) @@ -1737,8 +1720,8 @@ func TestSetValidBlockOnDelayedPrevote(t *testing.T) { ensurePrevoteMatch(t, voteCh, height, round, blockID.Hash) - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, blockID, vs2) - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, types.BlockID{}, vs3) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), blockID, vs2) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), types.BlockID{}, vs3) ensureNewTimeout(t, timeoutWaitCh, height, round) ensurePrecommit(t, voteCh, height, round) @@ -1749,7 +1732,7 @@ func TestSetValidBlockOnDelayedPrevote(t *testing.T) { assert.True(t, rs.ValidBlockParts == nil) assert.True(t, rs.ValidRound == -1) - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, blockID, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), blockID, vs4) ensureNewValidBlock(t, validBlockCh, height, round) rs = cs1.GetRoundState() @@ -1763,7 +1746,6 @@ func TestSetValidBlockOnDelayedPrevote(t *testing.T) { // receiving delayed Block Proposal. func TestSetValidBlockOnDelayedProposal(t *testing.T) { config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID ctx := t.Context() cs1, vss := makeState(ctx, t, makeStateArgs{config: config}) @@ -1795,7 +1777,7 @@ func TestSetValidBlockOnDelayedProposal(t *testing.T) { PartSetHeader: partSet.Header(), } - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, blockID, vss[1:]...) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), blockID, vss[1:]...) ensureNewValidBlock(t, validBlockCh, height, round) ensureNewTimeout(t, timeoutWaitCh, height, round) @@ -1845,7 +1827,7 @@ func TestProcessProposalAccept(t *testing.T) { m.On("ProcessProposal", mock.Anything, mock.Anything).Return(&abci.ResponseProcessProposal{Status: status}, nil) cs1, vss := makeState(ctx, t, makeStateArgs{ config: config, - application: proxy.New(m), + application: proxy.New(m, proxy.NopMetrics()), }) height, round := cs1.roundState.Height(), cs1.roundState.Round() round = cs1.nextRoundForLocalLeader(ctx, t, height, round, len(vss)*4) @@ -1887,7 +1869,6 @@ func TestFinalizeBlockCalled(t *testing.T) { } { t.Run(testCase.name, func(t *testing.T) { config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID ctx := t.Context() m := abcimocks.NewApplication(t) @@ -1900,7 +1881,7 @@ func TestFinalizeBlockCalled(t *testing.T) { cs1, vss := makeState(ctx, t, makeStateArgs{ config: config, - application: proxy.New(m), + application: proxy.New(m, proxy.NopMetrics()), }) height, round := cs1.roundState.Height(), cs1.roundState.Round() round = cs1.nextRoundForLocalLeader(ctx, t, height, round, len(vss)*4) @@ -1928,10 +1909,10 @@ func TestFinalizeBlockCalled(t *testing.T) { } } - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, blockID, vss[1:]...) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), blockID, vss[1:]...) ensurePrevoteMatch(t, voteCh, height, round, rs.ProposalBlock.Hash()) - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, blockID, vss[1:]...) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), blockID, vss[1:]...) ensurePrecommit(t, voteCh, height, round) ensureNewRound(t, newRoundCh, nextHeight, nextRound) @@ -1951,7 +1932,6 @@ func TestFinalizeBlockCalled(t *testing.T) { // P0 waits for timeoutPropose in the next round before entering prevote func TestWaitingTimeoutProposeOnNewRound(t *testing.T) { config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID ctx := t.Context() cs1, vss := makeState(ctx, t, makeStateArgs{config: config}) @@ -1973,7 +1953,7 @@ func TestWaitingTimeoutProposeOnNewRound(t *testing.T) { ensureNewTimeout(t, timeoutWaitCh, height, round) incrementRound(vss[1:]...) - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, types.BlockID{}, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), types.BlockID{}, vs2, vs3, vs4) round++ // moving to the next round ensureNewRound(t, newRoundCh, height, round) @@ -1990,7 +1970,6 @@ func TestWaitingTimeoutProposeOnNewRound(t *testing.T) { // P0 jump to higher round, precommit and start precommit wait func TestRoundSkipOnNilPolkaFromHigherRound(t *testing.T) { config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID ctx := t.Context() cs1, vss := makeState(ctx, t, makeStateArgs{config: config}) @@ -2009,7 +1988,7 @@ func TestRoundSkipOnNilPolkaFromHigherRound(t *testing.T) { ensurePrevote(t, voteCh, height, round) incrementRound(vss[1:]...) - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, types.BlockID{}, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), types.BlockID{}, vs2, vs3, vs4) round++ // moving to the next round ensureNewRound(t, newRoundCh, height, round) @@ -2028,7 +2007,6 @@ func TestRoundSkipOnNilPolkaFromHigherRound(t *testing.T) { // P0 wait for timeoutPropose to expire before sending prevote. func TestWaitTimeoutProposeOnNilPolkaForTheCurrentRound(t *testing.T) { config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID ctx := t.Context() cs1, vss := makeState(ctx, t, makeStateArgs{config: config}) @@ -2045,7 +2023,7 @@ func TestWaitTimeoutProposeOnNilPolkaForTheCurrentRound(t *testing.T) { ensureNewRound(t, newRoundCh, height, round) incrementRound(vss[1:]...) - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, types.BlockID{}, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), types.BlockID{}, vs2, vs3, vs4) ensureNewTimeout(t, timeoutProposeCh, height, round) @@ -2056,7 +2034,6 @@ func TestWaitTimeoutProposeOnNilPolkaForTheCurrentRound(t *testing.T) { // P0 emit NewValidBlock event upon receiving 2/3+ Precommit for B but hasn't received block B yet func TestEmitNewValidBlockEventOnCommitWithoutBlock(t *testing.T) { config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID ctx := t.Context() cs1, vss := makeState(ctx, t, makeStateArgs{config: config}) @@ -2083,7 +2060,7 @@ func TestEmitNewValidBlockEventOnCommitWithoutBlock(t *testing.T) { ensureNewRound(t, newRoundCh, height, round) // vs2, vs3 and vs4 send precommit for propBlock - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, blockID, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), blockID, vs2, vs3, vs4) ensureNewValidBlock(t, validBlockCh, height, round) } @@ -2092,7 +2069,6 @@ func TestEmitNewValidBlockEventOnCommitWithoutBlock(t *testing.T) { // After receiving block, it executes block and moves to the next height. func TestCommitFromPreviousRound(t *testing.T) { config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID ctx := t.Context() cs1, vss := makeState(ctx, t, makeStateArgs{config: config}) @@ -2119,7 +2095,7 @@ func TestCommitFromPreviousRound(t *testing.T) { ensureNewRound(t, newRoundCh, height, round) // vs2, vs3 and vs4 send precommit for propBlock for the previous round - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, blockID, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), blockID, vs2, vs3, vs4) ensureNewValidBlock(t, validBlockCh, height, round) partSet, err = propBlock.MakePartSet(partSize) @@ -2134,7 +2110,6 @@ func TestCommitFromPreviousRound(t *testing.T) { // start of the next round func TestStartNextHeightCorrectlyAfterTimeout(t *testing.T) { config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID ctx := t.Context() cs1, vss := makeState(ctx, t, makeStateArgs{config: config}) @@ -2167,15 +2142,15 @@ func TestStartNextHeightCorrectlyAfterTimeout(t *testing.T) { ensurePrevoteMatch(t, voteCh, height, round, blockID.Hash) - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, blockID, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), blockID, vs2, vs3, vs4) ensurePrecommit(t, voteCh, height, round) // the proposed block should now be locked and our precommit added cs1.validatePrecommit(ctx, t, round, round, vss[0], blockID.Hash, blockID.Hash) // add precommits - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, types.BlockID{}, vs2) - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, blockID, vs3) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), types.BlockID{}, vs2) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), blockID, vs3) // wait till timeout occurs ensureNewTimeout(t, precommitTimeoutCh, height, round) @@ -2183,7 +2158,7 @@ func TestStartNextHeightCorrectlyAfterTimeout(t *testing.T) { ensureNewRound(t, newRoundCh, height, round+1) // majority is now reached - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, blockID, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), blockID, vs4) ensureNewBlockHeader(t, newBlockHeader, height, blockID.Hash) @@ -2202,7 +2177,6 @@ func TestStartNextHeightCorrectlyAfterTimeout(t *testing.T) { func TestResetTimeoutPrecommitUponNewHeight(t *testing.T) { config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID ctx := t.Context() cs1, vss := makeState(ctx, t, makeStateArgs{config: config}) @@ -2233,15 +2207,15 @@ func TestResetTimeoutPrecommitUponNewHeight(t *testing.T) { ensurePrevoteMatch(t, voteCh, height, round, blockID.Hash) - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, blockID, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), blockID, vs2, vs3, vs4) ensurePrecommit(t, voteCh, height, round) cs1.validatePrecommit(ctx, t, round, round, vss[0], blockID.Hash, blockID.Hash) // add precommits - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, types.BlockID{}, vs2) - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, blockID, vs3) - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, blockID, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), types.BlockID{}, vs2) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), blockID, vs3) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), blockID, vs4) ensureNewBlockHeader(t, newBlockHeader, height, blockID.Hash) ensureNewRound(t, newRoundCh, height+1, 0) @@ -2270,7 +2244,6 @@ func TestResetTimeoutPrecommitUponNewHeight(t *testing.T) { // we receive a final precommit after going into next round, but others might have gone to commit already! func TestStateHalt1(t *testing.T) { config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID ctx := t.Context() cs1, vss := makeState(ctx, t, makeStateArgs{config: config}) @@ -2303,17 +2276,17 @@ func TestStateHalt1(t *testing.T) { ensurePrevote(t, voteCh, height, round) - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, blockID, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), blockID, vs2, vs3, vs4) ensurePrecommit(t, voteCh, height, round) // the proposed block should now be locked and our precommit added cs1.validatePrecommit(ctx, t, round, round, vss[0], propBlock.Hash(), propBlock.Hash()) // add precommits from the rest - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, types.BlockID{}, vs2) // didnt receive proposal - cs1.signAddVotes(ctx, t, tmproto.PrecommitType, chainID, blockID, vs3) + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), types.BlockID{}, vs2) // didnt receive proposal + cs1.signAddVotes(ctx, t, tmproto.PrecommitType, config.ChainID(), blockID, vs3) // we receive this later, but vs3 might receive it earlier and with ours will go to commit! - precommit4 := signVote(ctx, t, vs4, tmproto.PrecommitType, chainID, blockID) + precommit4 := signVote(ctx, t, vs4, tmproto.PrecommitType, config.ChainID(), blockID) incrementRound(vs2, vs3, vs4) @@ -2379,7 +2352,6 @@ func TestStateOutputsBlockPartsStats(t *testing.T) { func TestGossipTransactionKeyOnlyConfig(t *testing.T) { config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID ctx := t.Context() cs1, vss := makeState(ctx, t, makeStateArgs{config: config, validators: 2}) @@ -2399,7 +2371,7 @@ func TestGossipTransactionKeyOnlyConfig(t *testing.T) { require.NoError(t, err) proposal := *types.NewProposal(height, round, -1, blockID, propBlock.Time, propBlock.GetTxHashes(), propBlock.Header, propBlock.LastCommit, propBlock.Evidence, pubKey.Address()) p := proposal.ToProto() - err = vs2.SignProposal(ctx, chainID, p) + err = vs2.SignProposal(ctx, config.ChainID(), p) require.NoError(t, err) proposal.Signature = utils.OrPanic1(crypto.SigFromBytes(p.Signature)) @@ -2423,7 +2395,6 @@ func TestGossipTransactionKeyOnlyConfig(t *testing.T) { func TestProposalBlockIsNotRecreatedAfterCommitMismatch(t *testing.T) { config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID ctx := t.Context() cs, vss := makeState(ctx, t, makeStateArgs{config: config}) @@ -2452,7 +2423,7 @@ func TestProposalBlockIsNotRecreatedAfterCommitMismatch(t *testing.T) { for _, vs := range vss[1:] { vs.Height = height vs.Round = round - precommit := signVote(ctx, t, vs, tmproto.PrecommitType, chainID, wrongBlockID) + precommit := signVote(ctx, t, vs, tmproto.PrecommitType, config.ChainID(), wrongBlockID) cs.handleMsg(ctx, msgInfo{&VoteMessage{precommit}, peerID, tmtime.Now()}, false) } @@ -2471,7 +2442,6 @@ func TestProposalBlockIsNotRecreatedAfterCommitMismatch(t *testing.T) { func TestSetProposal_InvalidProposer(t *testing.T) { ctx := t.Context() config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID cs, vss := makeState(ctx, t, makeStateArgs{config: config, nonLeaderLocal: true}) height, round := cs.roundState.Height(), cs.roundState.Round() @@ -2500,7 +2470,7 @@ func TestSetProposal_InvalidProposer(t *testing.T) { proposal.ProposerAddress = badPubKey.Address() p := proposal.ToProto() - require.NoError(t, proposer.SignProposal(ctx, chainID, p)) + require.NoError(t, proposer.SignProposal(ctx, config.ChainID(), p)) proposal.Signature = utils.OrPanic1(crypto.SigFromBytes(p.Signature)) require.ErrorIs(t, cs.setProposal(proposal, tmtime.Now()), ErrInvalidProposer) require.Nil(t, cs.roundState.Proposal()) @@ -2509,7 +2479,6 @@ func TestSetProposal_InvalidProposer(t *testing.T) { func TestSetProposal_InvalidHeaderProposer(t *testing.T) { ctx := t.Context() config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID cs, vss := makeState(ctx, t, makeStateArgs{config: config, nonLeaderLocal: true}) height, round := cs.roundState.Height(), cs.roundState.Round() @@ -2531,7 +2500,7 @@ func TestSetProposal_InvalidHeaderProposer(t *testing.T) { proposal.Header.ProposerAddress = ed25519.GenerateSecretKey().Public().Address() p := proposal.ToProto() - require.NoError(t, proposer.SignProposal(ctx, chainID, p)) + require.NoError(t, proposer.SignProposal(ctx, config.ChainID(), p)) proposal.Signature = utils.OrPanic1(crypto.SigFromBytes(p.Signature)) require.ErrorIs(t, cs.setProposal(proposal, tmtime.Now()), ErrInvalidHeaderProposer) require.Nil(t, cs.roundState.Proposal()) @@ -2599,7 +2568,6 @@ func TestTryCreateProposalBlock_PartsMismatch(t *testing.T) { func TestStateOutputVoteStats(t *testing.T) { config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID ctx := t.Context() cs, vss := makeState(ctx, t, makeStateArgs{config: config, validators: 2}) @@ -2612,7 +2580,7 @@ func TestStateOutputVoteStats(t *testing.T) { Hash: randBytes, } - vote := signVote(ctx, t, vss[1], tmproto.PrecommitType, chainID, blockID) + vote := signVote(ctx, t, vss[1], tmproto.PrecommitType, config.ChainID(), blockID) voteMessage := &VoteMessage{vote} cs.handleMsg(ctx, msgInfo{voteMessage, peerID, tmtime.Now()}, false) @@ -2622,14 +2590,13 @@ func TestStateOutputVoteStats(t *testing.T) { // sending the vote for the bigger height incrementHeight(vss[1]) - vote = signVote(ctx, t, vss[1], tmproto.PrecommitType, chainID, blockID) + vote = signVote(ctx, t, vss[1], tmproto.PrecommitType, config.ChainID(), blockID) cs.handleMsg(ctx, msgInfo{&VoteMessage{vote}, peerID, tmtime.Now()}, false) } func TestSignSameVoteTwice(t *testing.T) { config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID ctx := t.Context() _, vss := makeState(ctx, t, makeStateArgs{config: config, validators: 2}) @@ -2641,7 +2608,7 @@ func TestSignSameVoteTwice(t *testing.T) { t, vss[1], tmproto.PrecommitType, - chainID, + config.ChainID(), types.BlockID{ Hash: randBytes, @@ -2653,7 +2620,7 @@ func TestSignSameVoteTwice(t *testing.T) { t, vss[1], tmproto.PrecommitType, - chainID, + config.ChainID(), types.BlockID{ Hash: randBytes, @@ -2669,7 +2636,6 @@ func TestSignSameVoteTwice(t *testing.T) { // corresponding proposal message. func TestStateTimestamp_ProposalNotMatch(t *testing.T) { config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID ctx := t.Context() cs1, vss := makeState(ctx, t, makeStateArgs{config: config}) @@ -2694,7 +2660,7 @@ func TestStateTimestamp_ProposalNotMatch(t *testing.T) { require.NoError(t, err) proposal := types.NewProposal(vs2.Height, round, -1, blockID, propBlock.Header.Time.Add(time.Millisecond), propBlock.GetTxHashes(), propBlock.Header, propBlock.LastCommit, propBlock.Evidence, pubKey.Address()) p := proposal.ToProto() - err = vs2.SignProposal(ctx, chainID, p) + err = vs2.SignProposal(ctx, config.ChainID(), p) require.NoError(t, err) proposal.Signature = utils.OrPanic1(crypto.SigFromBytes(p.Signature)) require.NoError(t, cs1.SetProposalAndBlock(ctx, proposal, propBlock, propBlockParts, "some peer")) @@ -2702,7 +2668,7 @@ func TestStateTimestamp_ProposalNotMatch(t *testing.T) { cs1.startTestRound(ctx, height, round) ensureProposal(t, proposalCh, height, round, blockID) - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, blockID, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), blockID, vs2, vs3, vs4) // ensure that the validator prevotes nil. ensurePrevote(t, voteCh, height, round) @@ -2717,7 +2683,6 @@ func TestStateTimestamp_ProposalNotMatch(t *testing.T) { // corresponding proposal message. func TestStateTimestamp_ProposalMatch(t *testing.T) { config := configSetup(t) - chainID := tmconfig.TestLoadGenesis(config).ChainID ctx := t.Context() cs1, vss := makeState(ctx, t, makeStateArgs{config: config}) @@ -2742,7 +2707,7 @@ func TestStateTimestamp_ProposalMatch(t *testing.T) { require.NoError(t, err) proposal := types.NewProposal(vs2.Height, round, -1, blockID, propBlock.Header.Time, propBlock.GetTxHashes(), propBlock.Header, propBlock.LastCommit, propBlock.Evidence, pubKey.Address()) p := proposal.ToProto() - err = vs2.SignProposal(ctx, chainID, p) + err = vs2.SignProposal(ctx, config.ChainID(), p) require.NoError(t, err) proposal.Signature = utils.OrPanic1(crypto.SigFromBytes(p.Signature)) require.NoError(t, cs1.SetProposalAndBlock(ctx, proposal, propBlock, propBlockParts, "some peer")) @@ -2750,7 +2715,7 @@ func TestStateTimestamp_ProposalMatch(t *testing.T) { cs1.startTestRound(ctx, height, round) ensureProposal(t, proposalCh, height, round, blockID) - cs1.signAddVotes(ctx, t, tmproto.PrevoteType, chainID, blockID, vs2, vs3, vs4) + cs1.signAddVotes(ctx, t, tmproto.PrevoteType, config.ChainID(), blockID, vs2, vs3, vs4) // ensure that the validator prevotes the block. ensurePrevote(t, voteCh, height, round) @@ -2907,10 +2872,9 @@ func TestAddProposalBlockPartNilProposalBlockParts(t *testing.T) { func TestStateTimeoutResolution(t *testing.T) { baseTime := time.Unix(100, 0) - newState := func(cfg *tmconfig.ConsensusConfig, params types.TimeoutParams) *State { + newState := func(cfg *config.ConsensusConfig, params types.TimeoutParams) *State { return &State{ - config: cfg, - metrics: utils.NewMutex(&latencyMetrics{}), + config: cfg, state: sm.State{ ConsensusParams: types.ConsensusParams{ Timeout: params, @@ -2919,8 +2883,8 @@ func TestStateTimeoutResolution(t *testing.T) { } } - cfgWithOverrides := func(enabled bool, bypass bool) *tmconfig.ConsensusConfig { - cfg := tmconfig.DefaultConsensusConfig() + cfgWithOverrides := func(enabled bool, bypass bool) *config.ConsensusConfig { + cfg := config.DefaultConsensusConfig() cfg.UnsafeOverridesEnabled = enabled cfg.UnsafeProposeTimeoutOverride = 9 * time.Second cfg.UnsafeProposeTimeoutDeltaOverride = 8 * time.Second @@ -2952,7 +2916,7 @@ func TestStateTimeoutResolution(t *testing.T) { testCases := []struct { name string - cfg *tmconfig.ConsensusConfig + cfg *config.ConsensusConfig params types.TimeoutParams expected types.TimeoutParams }{ @@ -2976,11 +2940,11 @@ func TestStateTimeoutResolution(t *testing.T) { params := onchain params.BypassCommitTimeout = true - cfgNil := tmconfig.DefaultConsensusConfig() + cfgNil := config.DefaultConsensusConfig() cfgNil.UnsafeOverridesEnabled = true cfgNil.UnsafeBypassCommitTimeoutOverride = nil - cfgFalse := tmconfig.DefaultConsensusConfig() + cfgFalse := config.DefaultConsensusConfig() cfgFalse.UnsafeOverridesEnabled = true cfgFalse.UnsafeBypassCommitTimeoutOverride = utils.Alloc(false) diff --git a/sei-tendermint/internal/consensus/types/height_vote_set_test.go b/sei-tendermint/internal/consensus/types/height_vote_set_test.go index 898135a428..b203c66b36 100644 --- a/sei-tendermint/internal/consensus/types/height_vote_set_test.go +++ b/sei-tendermint/internal/consensus/types/height_vote_set_test.go @@ -26,7 +26,7 @@ func TestPeerCatchupRounds(t *testing.T) { valSet, privVals := factory.ValidatorSet(ctx, 10, 1) - chainID := config.TestLoadGenesis(cfg).ChainID + chainID := cfg.ChainID() hvs := NewHeightVoteSet(chainID, 1, valSet) vote999_0 := makeVoteHR(ctx, t, 1, 0, 999, privVals, chainID) diff --git a/sei-tendermint/internal/consensus/types/round_state.go b/sei-tendermint/internal/consensus/types/round_state.go index db71ccab30..d9dde30bb9 100644 --- a/sei-tendermint/internal/consensus/types/round_state.go +++ b/sei-tendermint/internal/consensus/types/round_state.go @@ -419,9 +419,6 @@ var leaderElectionSeed = [32]byte(utils.OrPanic1(hex.DecodeString( // Validators are assigned subintervals of [0,TotalVotingPower) of length // equal to their voting poser. // Validator i is the leader of (height,round) <=> pos(height,round)%TotalVotingPower \in validator_interval[i] -// -// Leader must not be called with an empty validator set: it divides by the -// set's total voting power. func (rs *RoundState) Leader() crypto.PubKey { // sha256 does not support seed natively, so we add it by hand. d := slices.Clone(leaderElectionSeed[:]) @@ -458,6 +455,12 @@ func (rs *RoundState) RoundStateSimple() RoundStateSimple { panic(err) } + addr := rs.Leader().Address() + idx, _, ok := rs.Validators.GetByAddress(addr) + if !ok { + panic(fmt.Errorf("validator %v not in committee", addr)) + } + return RoundStateSimple{ HeightRoundStep: fmt.Sprintf("%d/%d/%d", rs.Height, rs.Round, rs.Step), StartTime: rs.StartTime, @@ -465,34 +468,28 @@ func (rs *RoundState) RoundStateSimple() RoundStateSimple { LockedBlockHash: rs.LockedBlock.Hash(), ValidBlockHash: rs.ValidBlock.Hash(), Votes: votesJSON, - Proposer: rs.leaderInfo(), + Proposer: types.ValidatorInfo{ + Address: addr, + Index: idx, + }, } } -// leaderInfo resolves the current leader into a ValidatorInfo for event/RPC -// serialization, guarding the emptiness precondition that Leader itself cannot: -// an empty validator set has no leader and yields a zero ValidatorInfo. This is -// the reachable surface — /consensus_state (RoundStateSimple) can be scraped -// while the validator set is still empty. -func (rs *RoundState) leaderInfo() types.ValidatorInfo { - if rs.Validators.IsNilOrEmpty() { - return types.ValidatorInfo{} - } +// NewRoundEvent returns the RoundState with proposer information as an event. +func (rs *RoundState) NewRoundEvent() types.EventDataNewRound { addr := rs.Leader().Address() idx, _, ok := rs.Validators.GetByAddress(addr) if !ok { panic(fmt.Errorf("validator %v not in committee", addr)) } - return types.ValidatorInfo{Address: addr, Index: idx} -} - -// NewRoundEvent returns the RoundState with proposer information as an event. -func (rs *RoundState) NewRoundEvent() types.EventDataNewRound { return types.EventDataNewRound{ - Height: rs.Height, - Round: rs.Round, - Step: rs.Step.String(), - Proposer: rs.leaderInfo(), + Height: rs.Height, + Round: rs.Round, + Step: rs.Step.String(), + Proposer: types.ValidatorInfo{ + Address: addr, + Index: idx, + }, } } diff --git a/sei-tendermint/internal/consensus/types/round_state_test.go b/sei-tendermint/internal/consensus/types/round_state_test.go index d961fb8dea..0c2c171596 100644 --- a/sei-tendermint/internal/consensus/types/round_state_test.go +++ b/sei-tendermint/internal/consensus/types/round_state_test.go @@ -59,25 +59,6 @@ func TestRoundStateLeaderChangeDetector(t *testing.T) { } } -// TestRoundStateEmptyValidatorSet checks that the event/RPC serializers yield -// an empty proposer on an empty validator set rather than dividing by the set's -// zero total voting power in leader election. -func TestRoundStateEmptyValidatorSet(t *testing.T) { - rs := RoundState{ - Validators: tmtypes.NewValidatorSet(nil), - } - rs.Height, rs.Round = 1, 0 - require.Zero(t, rs.Validators.TotalVotingPower()) - - // The event/RPC serializers must yield an empty proposer rather than - // dividing by zero in leader election. - require.NotPanics(t, func() { - ev := rs.NewRoundEvent() - require.Zero(t, ev.Proposer.Index) - require.Empty(t, ev.Proposer.Address) - }, "NewRoundEvent must not divide by zero on an empty validator set") -} - func TestRoundStateLeaderDistribution(t *testing.T) { const samples = 100000 diff --git a/sei-tendermint/internal/eventlog/eventlog.go b/sei-tendermint/internal/eventlog/eventlog.go index e2f3ec8026..dc09701e71 100644 --- a/sei-tendermint/internal/eventlog/eventlog.go +++ b/sei-tendermint/internal/eventlog/eventlog.go @@ -26,6 +26,7 @@ type Log struct { // These values do not change after construction. windowSize time.Duration maxItems int + metrics *Metrics // Protects access to the fields below. Lock to modify the values of these // fields, or to read or snapshot the values. @@ -46,9 +47,13 @@ func New(opts LogSettings) (*Log, error) { lg := &Log{ windowSize: opts.WindowSize, maxItems: opts.MaxItems, + metrics: NopMetrics(), ready: make(chan struct{}), source: opts.Source, } + if opts.Metrics != nil { + lg.metrics = opts.Metrics + } return lg, nil } @@ -125,6 +130,9 @@ type LogSettings struct { // The cursor source to use for log entries. If not set, use wallclock time. Source cursor.Source + + // If non-nil, exported metrics to update. If nil, metrics are discarded. + Metrics *Metrics } // Info records the current state of the log at the time of a scan operation. diff --git a/sei-tendermint/internal/eventlog/metrics.gen.go b/sei-tendermint/internal/eventlog/metrics.gen.go index b99d75331c..d9d86b2b9e 100644 --- a/sei-tendermint/internal/eventlog/metrics.gen.go +++ b/sei-tendermint/internal/eventlog/metrics.gen.go @@ -3,29 +3,28 @@ package eventlog import ( - "github.com/prometheus/client_golang/prometheus" - tmprometheus "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/prometheus" + "github.com/go-kit/kit/metrics/discard" + prometheus "github.com/go-kit/kit/metrics/prometheus" + stdprometheus "github.com/prometheus/client_golang/prometheus" ) -var Global = NewMetrics() - -func init() { - prometheus.MustRegister( - Global.numItems, - ) -} - -func NewMetrics() *Metrics { +func PrometheusMetrics(namespace string, labelsAndValues ...string) *Metrics { + labels := []string{} + for i := 0; i < len(labelsAndValues); i += 2 { + labels = append(labels, labelsAndValues[i]) + } return &Metrics{ - numItems: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + numItems: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "num_items", Help: "Number of items currently resident in the event log.", - }, nil), + }, labels).With(labelsAndValues...), } } -func (m *Metrics) numItemsAt() *tmprometheus.GaugeInt { - return m.numItems.WithLabelValues() +func NopMetrics() *Metrics { + return &Metrics{ + numItems: discard.NewGauge(), + } } diff --git a/sei-tendermint/internal/eventlog/metrics.go b/sei-tendermint/internal/eventlog/metrics.go index 3a62403202..fb7ccf694e 100644 --- a/sei-tendermint/internal/eventlog/metrics.go +++ b/sei-tendermint/internal/eventlog/metrics.go @@ -1,12 +1,8 @@ package eventlog -import tmprometheus "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/prometheus" +import "github.com/go-kit/kit/metrics" -const ( - // MetricsNamespace is the namespace shared by all Tendermint Prometheus metrics. - MetricsNamespace = "tendermint" - MetricsSubsystem = "eventlog" -) +const MetricsSubsystem = "eventlog" //go:generate go run ../../scripts/metricsgen -struct=Metrics @@ -14,5 +10,5 @@ const ( type Metrics struct { // Number of items currently resident in the event log. - numItems tmprometheus.GaugeIntVec + numItems metrics.Gauge } diff --git a/sei-tendermint/internal/eventlog/prune.go b/sei-tendermint/internal/eventlog/prune.go index 2eb35e488d..062e91bd2b 100644 --- a/sei-tendermint/internal/eventlog/prune.go +++ b/sei-tendermint/internal/eventlog/prune.go @@ -12,7 +12,7 @@ func (lg *Log) checkPrune(head *logEntry, size int, age time.Duration) error { const windowSlop = 30 * time.Second if age < (lg.windowSize+windowSlop) && (lg.maxItems <= 0 || size <= lg.maxItems) { - Global.numItemsAt().Set(int64(lg.numItems)) + lg.metrics.numItems.Set(float64(lg.numItems)) return nil // no pruning is needed } @@ -46,7 +46,7 @@ func (lg *Log) checkPrune(head *logEntry, size int, age time.Duration) error { lg.mu.Lock() defer lg.mu.Unlock() lg.numItems = newState.size - Global.numItemsAt().Set(int64(newState.size)) + lg.metrics.numItems.Set(float64(newState.size)) lg.oldestCursor = newState.oldest lg.head = newState.head return err diff --git a/sei-tendermint/internal/evidence/metrics.gen.go b/sei-tendermint/internal/evidence/metrics.gen.go index c3e4c4f422..f2eb7dfa8f 100644 --- a/sei-tendermint/internal/evidence/metrics.gen.go +++ b/sei-tendermint/internal/evidence/metrics.gen.go @@ -3,29 +3,28 @@ package evidence import ( - "github.com/prometheus/client_golang/prometheus" - tmprometheus "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/prometheus" + "github.com/go-kit/kit/metrics/discard" + prometheus "github.com/go-kit/kit/metrics/prometheus" + stdprometheus "github.com/prometheus/client_golang/prometheus" ) -var Global = NewMetrics() - -func init() { - prometheus.MustRegister( - Global.NumEvidence, - ) -} - -func NewMetrics() *Metrics { +func PrometheusMetrics(namespace string, labelsAndValues ...string) *Metrics { + labels := []string{} + for i := 0; i < len(labelsAndValues); i += 2 { + labels = append(labels, labelsAndValues[i]) + } return &Metrics{ - NumEvidence: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + NumEvidence: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "num_evidence", Help: "Number of pending evidence in the evidence pool.", - }, nil), + }, labels).With(labelsAndValues...), } } -func (m *Metrics) NumEvidenceAt() *tmprometheus.GaugeInt { - return m.NumEvidence.WithLabelValues() +func NopMetrics() *Metrics { + return &Metrics{ + NumEvidence: discard.NewGauge(), + } } diff --git a/sei-tendermint/internal/evidence/metrics.go b/sei-tendermint/internal/evidence/metrics.go index 51281ffc55..adb0260f2d 100644 --- a/sei-tendermint/internal/evidence/metrics.go +++ b/sei-tendermint/internal/evidence/metrics.go @@ -1,10 +1,10 @@ package evidence -import tmprometheus "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/prometheus" +import ( + "github.com/go-kit/kit/metrics" +) const ( - // MetricsNamespace is the namespace shared by all Tendermint Prometheus metrics. - MetricsNamespace = "tendermint" // MetricsSubsystem is a subsystem shared by all metrics exposed by this // package. MetricsSubsystem = "evidence_pool" @@ -16,5 +16,5 @@ const ( // see MetricsProvider for descriptions. type Metrics struct { // Number of pending evidence in the evidence pool. - NumEvidence tmprometheus.GaugeIntVec + NumEvidence metrics.Gauge } diff --git a/sei-tendermint/internal/evidence/pool.go b/sei-tendermint/internal/evidence/pool.go index 8a2694da05..fcf98d185d 100644 --- a/sei-tendermint/internal/evidence/pool.go +++ b/sei-tendermint/internal/evidence/pool.go @@ -67,17 +67,20 @@ type Pool struct { // Not part of the constructor, use SetEventBus to set it // The eventBus must be started in order for event publishing not to block eventBus *eventbus.EventBus + + Metrics *Metrics } // NewPool creates an evidence pool. If using an existing evidence store, // it will add all pending evidence to the concurrent list. -func NewPool(evidenceDB dbm.DB, stateStore sm.Store, blockStore BlockStore, eventBus *eventbus.EventBus) *Pool { +func NewPool(evidenceDB dbm.DB, stateStore sm.Store, blockStore BlockStore, metrics *Metrics, eventBus *eventbus.EventBus) *Pool { return &Pool{ blockStore: blockStore, stateDB: stateStore, evidenceStore: evidenceDB, evidenceList: clist.New[types.Evidence](), consensusBuffer: make([]duplicateVoteSet, 0), + Metrics: metrics, eventBus: eventBus, } } @@ -273,7 +276,7 @@ func (evpool *Pool) Start(state sm.State) error { atomic.StoreUint32(&evpool.evidenceSize, uint32(len(evList))) //nolint:gosec // evidence list is bounded by block limits; no overflow risk - Global.NumEvidenceAt().Set(int64(evpool.evidenceSize)) + evpool.Metrics.NumEvidence.Set(float64(evpool.evidenceSize)) for _, ev := range evList { evpool.evidenceList.PushBack(ev) @@ -337,7 +340,7 @@ func (evpool *Pool) addPendingEvidence(ctx context.Context, ev types.Evidence) e } atomic.AddUint32(&evpool.evidenceSize, 1) - Global.NumEvidenceAt().Set(int64(evpool.evidenceSize)) + evpool.Metrics.NumEvidence.Set(float64(evpool.evidenceSize)) // This should normally never be true if evpool.eventBus == nil { @@ -400,7 +403,7 @@ func (evpool *Pool) markEvidenceAsCommitted(evidence types.EvidenceList, height // update the evidence size atomic.AddUint32(&evpool.evidenceSize, ^uint32(len(blockEvidenceMap)-1)) //nolint:gosec // len(blockEvidenceMap) is guaranteed > 0 by early return above; atomic subtract idiom - Global.NumEvidenceAt().Set(int64(evpool.evidenceSize)) + evpool.Metrics.NumEvidence.Set(float64(evpool.evidenceSize)) } // listEvidence retrieves lists evidence from oldest to newest within maxBytes. diff --git a/sei-tendermint/internal/evidence/pool_test.go b/sei-tendermint/internal/evidence/pool_test.go index 12f07aca02..86c22bd0c9 100644 --- a/sei-tendermint/internal/evidence/pool_test.go +++ b/sei-tendermint/internal/evidence/pool_test.go @@ -73,7 +73,7 @@ func TestEvidencePoolBasic(t *testing.T) { eventBus := eventbus.NewDefault() require.NoError(t, eventBus.Start(ctx)) - pool := evidence.NewPool(evidenceDB, stateStore, blockStore, eventBus) + pool := evidence.NewPool(evidenceDB, stateStore, blockStore, evidence.NopMetrics(), eventBus) startPool(t, pool, stateStore) // evidence not seen yet: @@ -140,7 +140,7 @@ func TestAddExpiredEvidence(t *testing.T) { eventBus := eventbus.NewDefault() require.NoError(t, eventBus.Start(ctx)) - pool := evidence.NewPool(evidenceDB, stateStore, blockStore, eventBus) + pool := evidence.NewPool(evidenceDB, stateStore, blockStore, evidence.NopMetrics(), eventBus) startPool(t, pool, stateStore) testCases := []struct { @@ -402,7 +402,7 @@ func TestLightClientAttackEvidenceLifecycle(t *testing.T) { eventBus := eventbus.NewDefault() require.NoError(t, eventBus.Start(ctx)) - pool := evidence.NewPool(dbm.NewMemDB(), stateStore, blockStore, eventBus) + pool := evidence.NewPool(dbm.NewMemDB(), stateStore, blockStore, evidence.NopMetrics(), eventBus) hash := ev.Hash() @@ -457,7 +457,7 @@ func TestRecoverPendingEvidence(t *testing.T) { require.NoError(t, eventBus.Start(ctx)) // create previous pool and populate it - pool := evidence.NewPool(evidenceDB, stateStore, blockStore, eventBus) + pool := evidence.NewPool(evidenceDB, stateStore, blockStore, evidence.NopMetrics(), eventBus) startPool(t, pool, stateStore) goodEvidence, err := types.NewMockDuplicateVoteEvidenceWithValidator( @@ -500,7 +500,7 @@ func TestRecoverPendingEvidence(t *testing.T) { }, }, nil) - newPool := evidence.NewPool(evidenceDB, newStateStore, blockStore, nil) + newPool := evidence.NewPool(evidenceDB, newStateStore, blockStore, evidence.NopMetrics(), nil) startPool(t, newPool, newStateStore) evList, _ := newPool.PendingEvidence(defaultEvidenceMaxBytes) require.Equal(t, 1, len(evList)) @@ -606,7 +606,7 @@ func defaultTestPool(ctx context.Context, t *testing.T, height int64) (*evidence eventBus := eventbus.NewDefault() require.NoError(t, eventBus.Start(ctx)) - pool := evidence.NewPool(evidenceDB, stateStore, blockStore, eventBus) + pool := evidence.NewPool(evidenceDB, stateStore, blockStore, evidence.NopMetrics(), eventBus) startPool(t, pool, stateStore) return pool, val, eventBus } diff --git a/sei-tendermint/internal/evidence/reactor_test.go b/sei-tendermint/internal/evidence/reactor_test.go index b589ebc836..b3d90b8f65 100644 --- a/sei-tendermint/internal/evidence/reactor_test.go +++ b/sei-tendermint/internal/evidence/reactor_test.go @@ -62,7 +62,7 @@ func setup(ctx context.Context, t *testing.T, stateStores []sm.Store) *reactorTe err := eventBus.Start(ctx) require.NoError(t, err) - rts.pools[nodeID] = evidence.NewPool(evidenceDB, stateStores[idx], blockStore, eventBus) + rts.pools[nodeID] = evidence.NewPool(evidenceDB, stateStores[idx], blockStore, evidence.NopMetrics(), eventBus) startPool(t, rts.pools[nodeID], stateStores[idx]) rts.nodes = append(rts.nodes, node) diff --git a/sei-tendermint/internal/evidence/verify_test.go b/sei-tendermint/internal/evidence/verify_test.go index 407601f500..d818449d65 100644 --- a/sei-tendermint/internal/evidence/verify_test.go +++ b/sei-tendermint/internal/evidence/verify_test.go @@ -94,7 +94,7 @@ func TestVerify_LunaticAttackAgainstState(t *testing.T) { blockStore.On("LoadBlockMeta", height).Return(&types.BlockMeta{Header: *trusted.Header}) blockStore.On("LoadBlockCommit", commonHeight).Return(common.Commit) blockStore.On("LoadBlockCommit", height).Return(trusted.Commit) - pool := evidence.NewPool(dbm.NewMemDB(), stateStore, blockStore, nil) + pool := evidence.NewPool(dbm.NewMemDB(), stateStore, blockStore, evidence.NopMetrics(), nil) evList := types.EvidenceList{ev} // check that the evidence pool correctly verifies the evidence @@ -114,7 +114,7 @@ func TestVerify_LunaticAttackAgainstState(t *testing.T) { // duplicate evidence should be rejected evList = types.EvidenceList{ev, ev} - pool = evidence.NewPool(dbm.NewMemDB(), stateStore, blockStore, nil) + pool = evidence.NewPool(dbm.NewMemDB(), stateStore, blockStore, evidence.NopMetrics(), nil) assert.Error(t, pool.CheckEvidence(ctx, evList)) // If evidence is submitted with an altered timestamp it should return an error @@ -122,7 +122,7 @@ func TestVerify_LunaticAttackAgainstState(t *testing.T) { require.NoError(t, eventBus.Start(ctx)) ev.Timestamp = defaultEvidenceTime.Add(1 * time.Minute) - pool = evidence.NewPool(dbm.NewMemDB(), stateStore, blockStore, eventBus) + pool = evidence.NewPool(dbm.NewMemDB(), stateStore, blockStore, evidence.NopMetrics(), eventBus) err := pool.AddEvidence(ctx, ev) assert.Error(t, err) @@ -130,7 +130,7 @@ func TestVerify_LunaticAttackAgainstState(t *testing.T) { // Evidence submitted with a different validator power should fail ev.TotalVotingPower = 1 - pool = evidence.NewPool(dbm.NewMemDB(), stateStore, blockStore, nil) + pool = evidence.NewPool(dbm.NewMemDB(), stateStore, blockStore, evidence.NopMetrics(), nil) err = pool.AddEvidence(ctx, ev) assert.Error(t, err) ev.TotalVotingPower = common.ValidatorSet.TotalVotingPower() @@ -177,7 +177,7 @@ func TestVerify_ForwardLunaticAttack(t *testing.T) { eventBus := eventbus.NewDefault() require.NoError(t, eventBus.Start(ctx)) - pool := evidence.NewPool(dbm.NewMemDB(), stateStore, blockStore, eventBus) + pool := evidence.NewPool(dbm.NewMemDB(), stateStore, blockStore, evidence.NopMetrics(), eventBus) // check that the evidence pool correctly verifies the evidence assert.NoError(t, pool.CheckEvidence(ctx, types.EvidenceList{ev})) @@ -194,7 +194,7 @@ func TestVerify_ForwardLunaticAttack(t *testing.T) { oldBlockStore.On("Height").Return(nodeHeight) require.Equal(t, defaultEvidenceTime, oldBlockStore.LoadBlockMeta(nodeHeight).Header.Time) - pool = evidence.NewPool(dbm.NewMemDB(), stateStore, oldBlockStore, nil) + pool = evidence.NewPool(dbm.NewMemDB(), stateStore, oldBlockStore, evidence.NopMetrics(), nil) assert.Error(t, pool.CheckEvidence(ctx, types.EvidenceList{ev})) } @@ -285,7 +285,7 @@ func TestVerifyLightClientAttack_Equivocation(t *testing.T) { eventBus := eventbus.NewDefault() require.NoError(t, eventBus.Start(ctx)) - pool := evidence.NewPool(dbm.NewMemDB(), stateStore, blockStore, eventBus) + pool := evidence.NewPool(dbm.NewMemDB(), stateStore, blockStore, evidence.NopMetrics(), eventBus) evList := types.EvidenceList{ev} err = pool.CheckEvidence(ctx, evList) @@ -374,7 +374,7 @@ func TestVerifyLightClientAttack_Amnesia(t *testing.T) { eventBus := eventbus.NewDefault() require.NoError(t, eventBus.Start(ctx)) - pool := evidence.NewPool(dbm.NewMemDB(), stateStore, blockStore, eventBus) + pool := evidence.NewPool(dbm.NewMemDB(), stateStore, blockStore, evidence.NopMetrics(), eventBus) evList := types.EvidenceList{ev} err = pool.CheckEvidence(ctx, evList) @@ -473,7 +473,7 @@ func TestVerifyDuplicateVoteEvidence(t *testing.T) { eventBus := eventbus.NewDefault() require.NoError(t, eventBus.Start(ctx)) - pool := evidence.NewPool(dbm.NewMemDB(), stateStore, blockStore, eventBus) + pool := evidence.NewPool(dbm.NewMemDB(), stateStore, blockStore, evidence.NopMetrics(), eventBus) startPool(t, pool, stateStore) evList := types.EvidenceList{goodEv} diff --git a/sei-tendermint/internal/mempool/mempool.go b/sei-tendermint/internal/mempool/mempool.go index 5242f3060c..5bc2bdbd76 100644 --- a/sei-tendermint/internal/mempool/mempool.go +++ b/sei-tendermint/internal/mempool/mempool.go @@ -169,6 +169,7 @@ func (m *lockMap[K]) Unlock(k K) { // when a block proposer constructs a block and a thread-safe linked-list that // is used to gossip transactions to peers in a FIFO manner. type TxMempool struct { + metrics *Metrics config *Config app *proxy.Proxy txLocks *lockMap[types.TxHash] @@ -207,6 +208,7 @@ func (txmp *TxMempool) PendingSizeBytes() uint64 { return txmp.txStore.State(). func NewTxMempool( cfg *Config, app *proxy.Proxy, + metrics *Metrics, txConstraintsFetcher TxConstraintsFetcher, ) *TxMempool { txmp := &TxMempool{ @@ -215,7 +217,8 @@ func NewTxMempool( txsAvailable: make(chan struct{}, 1), txLocks: newLockMap[types.TxHash](), height: -1, - txStore: NewTxStore(cfg, app), + metrics: metrics, + txStore: NewTxStore(cfg, app, metrics), txConstraintsFetcher: txConstraintsFetcher, } @@ -314,19 +317,19 @@ func (txmp *TxMempool) CheckTx(ctx context.Context, tx types.Tx) (*abci.Response // Reject low priority transactions when the mempool is more than // DropUtilisationThreshold full. if txmp.config.DropUtilisationThreshold > 0 && txmp.utilisation() >= txmp.config.DropUtilisationThreshold { - Global.CheckTxMetDropUtilisationThresholdAt().Add(1) + txmp.metrics.CheckTxMetDropUtilisationThreshold.Add(1) hint, err := txmp.app.GetTxPriorityHint(ctx, &abci.RequestGetTxPriorityHintV2{Tx: tx}) if err != nil { - Global.observeCheckTxPriorityDistribution(0, true, "", true) + txmp.metrics.observeCheckTxPriorityDistribution(0, true, "", true) logger.Error("failed to get tx priority hint", "err", err) return nil, err } - Global.observeCheckTxPriorityDistribution(hint.Priority, true, "", false) + txmp.metrics.observeCheckTxPriorityDistribution(hint.Priority, true, "", false) cutoff, found := txmp.txStore.priorityReservoir.Percentile() if found && hint.Priority <= cutoff { - Global.CheckTxDroppedByPriorityHintAt().Add(1) + txmp.metrics.CheckTxDroppedByPriorityHint.Add(1) return nil, errors.New("priority not high enough for mempool") } } @@ -341,8 +344,8 @@ func (txmp *TxMempool) CheckTx(ctx context.Context, tx types.Tx) (*abci.Response } res, err := txmp.app.CheckTxSafe(ctx, &abci.RequestCheckTxV2{Tx: tx}) if err != nil || !res.IsOK() { - Global.NumberOfFailedCheckTxsAt().Add(1) - Global.observeCheckTxPriorityDistribution(0, false, "", true) + txmp.metrics.NumberOfFailedCheckTxs.Add(1) + txmp.metrics.observeCheckTxPriorityDistribution(0, false, "", true) } if err != nil { return nil, err @@ -350,8 +353,8 @@ func (txmp *TxMempool) CheckTx(ctx context.Context, tx types.Tx) (*abci.Response if !res.IsOK() { return res.ResponseCheckTx, nil } - Global.NumberOfSuccessfulCheckTxsAt().Add(1) - Global.observeCheckTxPriorityDistribution(res.Priority, false, "", false) + txmp.metrics.NumberOfSuccessfulCheckTxs.Add(1) + txmp.metrics.observeCheckTxPriorityDistribution(res.Priority, false, "", false) // Normalize the estimate. estimatedGas := res.GasEstimated @@ -380,20 +383,20 @@ func (txmp *TxMempool) CheckTx(ctx context.Context, tx types.Tx) (*abci.Response // ignore bad transactions logger.Info("rejected bad transaction", "priority", wtx.priority, "tx", wtx.Hash(), "post_check_err", err) txmp.txStore.MarkInvalid(hTx.Hash()) - Global.FailedTxsAt().Add(1) + txmp.metrics.FailedTxs.Add(1) return nil, err } if err := txmp.txStore.Insert(wtx); err != nil { - Global.RejectedTxsAt().Add(1) + txmp.metrics.RejectedTxs.Add(1) return nil, err } - Global.InsertedTxsAt().Add(1) - Global.TxSizeBytesAt().Add(int64(wtx.Size())) //nolint:gosec // metric precision is not security-sensitive; overflow is acceptable here - Global.SizeAt().Set(int64(txmp.NumTxsNotPending())) - Global.PendingSizeAt().Set(int64(txmp.PendingSize())) - Global.TotalTxsSizeBytesAt().Set(int64(txmp.TotalTxsBytesSize())) //nolint:gosec // metric precision is not security-sensitive; overflow is acceptable here + txmp.metrics.InsertedTxs.Add(1) + txmp.metrics.TxSizeBytes.Add(float64(wtx.Size())) + txmp.metrics.Size.Set(float64(txmp.NumTxsNotPending())) + txmp.metrics.PendingSize.Set(float64(txmp.PendingSize())) + txmp.metrics.TotalTxsSizeBytes.Set(float64(txmp.TotalTxsBytesSize())) txmp.notifyTxsAvailable() return res.ResponseCheckTx, nil @@ -406,9 +409,9 @@ func (txmp *TxMempool) SafeGetTxsForHashes(txHashes []types.TxHash) (types.Txs, // Flush empties the mempool. func (txmp *TxMempool) Flush() { txmp.txStore.Clear() - Global.SizeAt().Set(0) - Global.PendingSizeAt().Set(0) - Global.TotalTxsSizeBytesAt().Set(0) + txmp.metrics.Size.Set(0) + txmp.metrics.PendingSize.Set(0) + txmp.metrics.TotalTxsSizeBytes.Set(0) } // ReapTxs returns a list of transactions within the provided constraints and their total gas estimate. @@ -426,9 +429,9 @@ func (txmp *TxMempool) Flush() { func (txmp *TxMempool) ReapTxs(limits ReapLimits, remove bool) (types.Txs, int64) { txs, gasEstimate := txmp.txStore.Reap(limits, remove) if remove { - Global.SizeAt().Set(int64(txmp.NumTxsNotPending())) - Global.PendingSizeAt().Set(int64(txmp.PendingSize())) - Global.TotalTxsSizeBytesAt().Set(int64(txmp.TotalTxsBytesSize())) //nolint:gosec // metric precision is not security-sensitive; overflow is acceptable here + txmp.metrics.Size.Set(float64(txmp.NumTxsNotPending())) + txmp.metrics.PendingSize.Set(float64(txmp.PendingSize())) + txmp.metrics.TotalTxsSizeBytes.Set(float64(txmp.TotalTxsBytesSize())) } return txs, gasEstimate } @@ -468,7 +471,7 @@ func (txmp *TxMempool) Update( if _, ok := txResults[wtx.Hash()]; ok { continue } - Global.RecheckTimesAt().Add(1) + txmp.metrics.RecheckTimes.Add(1) res, err := txmp.app.CheckTxSafe(ctx, &abci.RequestCheckTxV2{ Tx: wtx.Tx(), Type: abci.CheckTxTypeV2Recheck, @@ -490,9 +493,9 @@ func (txmp *TxMempool) Update( Constraints: txConstraints, }) txmp.notifyTxsAvailable() - Global.SizeAt().Set(int64(txmp.NumTxsNotPending())) - Global.TotalTxsSizeBytesAt().Set(int64(txmp.TotalTxsBytesSize())) //nolint:gosec // metric precision is not security-sensitive; overflow is acceptable here - Global.PendingSizeAt().Set(int64(txmp.PendingSize())) + txmp.metrics.Size.Set(float64(txmp.NumTxsNotPending())) + txmp.metrics.TotalTxsSizeBytes.Set(float64(txmp.TotalTxsBytesSize())) + txmp.metrics.PendingSize.Set(float64(txmp.PendingSize())) return nil } @@ -522,10 +525,10 @@ func (txmp *TxMempool) Run(ctx context.Context) error { // TODO(gprusak): instead of actively updating stats, // TxMempool should implement prometheus.Collector. maxOccurrence, totalOccurrence, duplicateCount, nonDuplicateCount := c.GetForMetrics() - Global.DuplicateTxMaxOccurrencesAt().Set(int64(maxOccurrence)) - Global.DuplicateTxTotalOccurrencesAt().Set(int64(totalOccurrence)) - Global.NumberOfDuplicateTxsAt().Set(int64(duplicateCount)) - Global.NumberOfNonDuplicateTxsAt().Set(int64(nonDuplicateCount)) + txmp.metrics.DuplicateTxMaxOccurrences.Set(float64(maxOccurrence)) + txmp.metrics.DuplicateTxTotalOccurrences.Set(float64(totalOccurrence)) + txmp.metrics.NumberOfDuplicateTxs.Set(float64(duplicateCount)) + txmp.metrics.NumberOfNonDuplicateTxs.Set(float64(nonDuplicateCount)) } }) } diff --git a/sei-tendermint/internal/mempool/mempool_bench_test.go b/sei-tendermint/internal/mempool/mempool_bench_test.go index dc6f8c9678..b0f1f302c0 100644 --- a/sei-tendermint/internal/mempool/mempool_bench_test.go +++ b/sei-tendermint/internal/mempool/mempool_bench_test.go @@ -15,7 +15,7 @@ func BenchmarkTxMempool_CheckTx(b *testing.B) { ctx := b.Context() client := kvstore.NewApplication() - proxyClient := proxy.New(client) + proxyClient := proxy.New(client, proxy.NopMetrics()) // setup the cache and the mempool number for hitting GetEvictableTxs during the // benchmark. 5000 is the current default mempool size in the TM config. diff --git a/sei-tendermint/internal/mempool/mempool_test.go b/sei-tendermint/internal/mempool/mempool_test.go index d5af52969f..7b8b69a954 100644 --- a/sei-tendermint/internal/mempool/mempool_test.go +++ b/sei-tendermint/internal/mempool/mempool_test.go @@ -145,7 +145,7 @@ func (app *application) GetTxPriorityHint(context.Context, *abci.RequestGetTxPri } func setup(cfg *Config, app *proxy.Proxy, txConstraintsFetcher TxConstraintsFetcher) *TxMempool { - return NewTxMempool(cfg, app, txConstraintsFetcher) + return NewTxMempool(cfg, app, NopMetrics(), txConstraintsFetcher) } func checkTxs(ctx context.Context, t *testing.T, txmp *TxMempool, numTxs int) []testTx { @@ -209,7 +209,7 @@ func TestTxMempool_TxsAvailable(t *testing.T) { client := &application{Application: kvstore.NewApplication()} cfg := TestConfig() cfg.CacheSize = 0 - txmp := setup(cfg, proxy.New(client), NopTxConstraintsFetcher) + txmp := setup(cfg, proxy.New(client, proxy.NopMetrics()), NopTxConstraintsFetcher) ensureNoTxFire := func() { timer := time.NewTimer(500 * time.Millisecond) @@ -267,7 +267,7 @@ func TestTxMempool_Size(t *testing.T) { client := &application{Application: kvstore.NewApplication()} cfg := TestConfig() cfg.CacheSize = 0 - txmp := setup(cfg, proxy.New(client), NopTxConstraintsFetcher) + txmp := setup(cfg, proxy.New(client, proxy.NopMetrics()), NopTxConstraintsFetcher) txs := checkTxs(ctx, t, txmp, 100) require.Equal(t, len(txs), txmp.Size()) require.Equal(t, 0, txmp.PendingSize()) @@ -297,7 +297,7 @@ func TestTxMempool_Flush(t *testing.T) { client := &application{Application: kvstore.NewApplication()} cfg := TestConfig() cfg.CacheSize = 0 - txmp := setup(cfg, proxy.New(client), NopTxConstraintsFetcher) + txmp := setup(cfg, proxy.New(client, proxy.NopMetrics()), NopTxConstraintsFetcher) txs := checkTxs(ctx, t, txmp, 100) require.Equal(t, len(txs), txmp.Size()) require.Equal(t, totalTxSizeBytes(txs), txmp.SizeBytes()) @@ -328,7 +328,7 @@ func TestTxMempool_ReapMaxBytesMaxGas(t *testing.T) { client := &application{Application: kvstore.NewApplication(), gasEstimated: &gasEstimated} cfg := TestConfig() cfg.CacheSize = 0 - txmp := setup(cfg, proxy.New(client), NopTxConstraintsFetcher) + txmp := setup(cfg, proxy.New(client, proxy.NopMetrics()), NopTxConstraintsFetcher) tTxs := checkTxs(ctx, t, txmp, 100) // all txs request 1 gas unit require.Equal(t, len(tTxs), txmp.Size()) require.Equal(t, totalTxSizeBytes(tTxs), txmp.SizeBytes()) @@ -418,7 +418,7 @@ func TestTxMempool_ReapMaxBytesMaxGas_FallbackToGasWanted(t *testing.T) { client := &application{Application: kvstore.NewApplication(), gasEstimated: &gasEstimated} cfg := TestConfig() cfg.CacheSize = 0 - txmp := setup(cfg, proxy.New(client), NopTxConstraintsFetcher) + txmp := setup(cfg, proxy.New(client, proxy.NopMetrics()), NopTxConstraintsFetcher) tTxs := checkTxs(ctx, t, txmp, 100) txMap := make(map[types.TxHash]testTx) @@ -460,7 +460,7 @@ func TestTxMempool_ReapMaxTxs(t *testing.T) { client := &application{Application: kvstore.NewApplication()} cfg := TestConfig() cfg.CacheSize = 0 - txmp := setup(cfg, proxy.New(client), NopTxConstraintsFetcher) + txmp := setup(cfg, proxy.New(client, proxy.NopMetrics()), NopTxConstraintsFetcher) tTxs := checkTxs(ctx, t, txmp, 100) require.Equal(t, len(tTxs), txmp.Size()) require.Equal(t, totalTxSizeBytes(tTxs), txmp.SizeBytes()) @@ -527,7 +527,7 @@ func TestTxMempool_ReapMaxBytesMaxGas_MinGasEVMTxThreshold(t *testing.T) { client := &application{Application: kvstore.NewApplication(), gasEstimated: &gasEstimated, gasWanted: &gasWanted} cfg := TestConfig() cfg.CacheSize = 0 - txmp := setup(cfg, proxy.New(client), NopTxConstraintsFetcher) + txmp := setup(cfg, proxy.New(client, proxy.NopMetrics()), NopTxConstraintsFetcher) address := "0xeD23B3A9DE15e92B9ef9540E587B3661E15A12fA" // Insert a single EVM tx (format: evm-sender=account=priority=nonce) @@ -550,7 +550,7 @@ func TestTxMempool_CheckTxExceedsMaxSize(t *testing.T) { client := &application{Application: kvstore.NewApplication()} cfg := TestConfig() cfg.CacheSize = 0 - txmp := setup(cfg, proxy.New(client), NopTxConstraintsFetcher) + txmp := setup(cfg, proxy.New(client, proxy.NopMetrics()), NopTxConstraintsFetcher) rng := rand.New(rand.NewSource(time.Now().UnixNano())) tx := make([]byte, txmp.config.MaxTxBytes+1) @@ -574,7 +574,7 @@ func TestTxMempool_Prioritization(t *testing.T) { client := &application{Application: kvstore.NewApplication()} cfg := TestConfig() cfg.CacheSize = 100 - txmp := setup(cfg, proxy.New(client), NopTxConstraintsFetcher) + txmp := setup(cfg, proxy.New(client, proxy.NopMetrics()), NopTxConstraintsFetcher) address1 := "0xeD23B3A9DE15e92B9ef9540E587B3661E15A12fA" address2 := "0xfD23B3A9DE15e92B9ef9540E587B3661E15A12fA" @@ -632,7 +632,7 @@ func TestTxMempool_RemoveCacheWhenPendingTxIsFull(t *testing.T) { cfg.CacheSize = 100 cfg.Size = 5 cfg.PendingSize = 0 - txmp := NewTxMempool(cfg, proxy.New(client), NopTxConstraintsFetcher) + txmp := NewTxMempool(cfg, proxy.New(client, proxy.NopMetrics()), NopMetrics(), NopTxConstraintsFetcher) insertedTxs := make([]types.Tx, 0, 2*cfg.Size+1) pruned := false @@ -661,7 +661,7 @@ func TestTxMempool_CheckTxDuplicateRejected(t *testing.T) { client := &application{Application: kvstore.NewApplication()} cfg := TestConfig() cfg.CacheSize = 100 - txmp := setup(cfg, proxy.New(client), NopTxConstraintsFetcher) + txmp := setup(cfg, proxy.New(client, proxy.NopMetrics()), NopTxConstraintsFetcher) rng := rand.New(rand.NewSource(time.Now().UnixNano())) prefix := make([]byte, 20) @@ -682,7 +682,7 @@ func TestTxMempool_ConcurrentTxs(t *testing.T) { client := &application{Application: kvstore.NewApplication()} cfg := TestConfig() cfg.CacheSize = 100 - txmp := setup(cfg, proxy.New(client), NopTxConstraintsFetcher) + txmp := setup(cfg, proxy.New(client, proxy.NopMetrics()), NopTxConstraintsFetcher) rng := rand.New(rand.NewSource(time.Now().UnixNano())) checkTxDone := make(chan struct{}) @@ -753,7 +753,7 @@ func TestTxMempool_ExpiredTxs_NumBlocks(t *testing.T) { cfg := TestConfig() cfg.CacheSize = 500 cfg.TTLNumBlocks = utils.Some(int64(10)) - txmp := setup(cfg, proxy.New(client), NopTxConstraintsFetcher) + txmp := setup(cfg, proxy.New(client, proxy.NopMetrics()), NopTxConstraintsFetcher) txmp.height = 100 tTxs := checkTxs(ctx, t, txmp, 100) @@ -806,7 +806,7 @@ func TestMempoolExpiration(t *testing.T) { cfg.CacheSize = 0 cfg.TTLDuration = utils.Some(time.Nanosecond) cfg.RemoveExpiredTxsFromQueue = true - txmp := setup(cfg, proxy.New(client), NopTxConstraintsFetcher) + txmp := setup(cfg, proxy.New(client, proxy.NopMetrics()), NopTxConstraintsFetcher) txs := checkTxs(ctx, t, txmp, 100) require.Equal(t, len(txs), txmp.Size()) @@ -827,7 +827,7 @@ func TestTxMempool_ReapTxs_EVMFirst(t *testing.T) { client := &application{Application: kvstore.NewApplication()} cfg := TestConfig() cfg.CacheSize = 0 - txmp := setup(cfg, proxy.New(client), NopTxConstraintsFetcher) + txmp := setup(cfg, proxy.New(client, proxy.NopMetrics()), NopTxConstraintsFetcher) evmAddress1 := "0xeD23B3A9DE15e92B9ef9540E587B3661E15A12fA" evmAddress2 := "0xfD23B3A9DE15e92B9ef9540E587B3661E15A12fA" @@ -891,7 +891,7 @@ func TestBlockFailedTxNotReAdmittedAfterSecondFailure(t *testing.T) { app := &application{Application: kvstore.NewApplication()} cfg := TestConfig() cfg.CacheSize = 500 - txmp := setup(cfg, proxy.New(app), NopTxConstraintsFetcher) + txmp := setup(cfg, proxy.New(app, proxy.NopMetrics()), NopTxConstraintsFetcher) tx := types.Tx("sender-0-0=key=1000") @@ -965,7 +965,7 @@ func TestTxMempool_EvmMetadataCacheShortCircuitsAndReadmitsAfterFailedExecution( cfg := TestConfig() cfg.CacheSize = 100 - txmp := setup(cfg, proxy.New(app), NopTxConstraintsFetcher) + txmp := setup(cfg, proxy.New(app, proxy.NopMetrics()), NopTxConstraintsFetcher) _, err := txmp.CheckTx(ctx, tc.betterTx) require.NoError(t, err) @@ -999,7 +999,7 @@ func TestBlockFailedTxTrackerClearedOnSuccess(t *testing.T) { app := &application{Application: kvstore.NewApplication()} cfg := TestConfig() cfg.CacheSize = 500 - txmp := setup(cfg, proxy.New(app), NopTxConstraintsFetcher) + txmp := setup(cfg, proxy.New(app, proxy.NopMetrics()), NopTxConstraintsFetcher) tx := types.Tx("sender-0-0=key=1000") txHash := tx.Hash() diff --git a/sei-tendermint/internal/mempool/metrics.gen.go b/sei-tendermint/internal/mempool/metrics.gen.go index 9c1445fd73..5c31d6905c 100644 --- a/sei-tendermint/internal/mempool/metrics.gen.go +++ b/sei-tendermint/internal/mempool/metrics.gen.go @@ -3,261 +3,177 @@ package mempool import ( - "github.com/prometheus/client_golang/prometheus" - tmprometheus "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/prometheus" + "github.com/go-kit/kit/metrics/discard" + prometheus "github.com/go-kit/kit/metrics/prometheus" + stdprometheus "github.com/prometheus/client_golang/prometheus" ) -var Global = NewMetrics() - -func init() { - prometheus.MustRegister( - Global.Size, - Global.PendingSize, - Global.CacheSize, - Global.TxSizeBytes, - Global.TotalTxsSizeBytes, - Global.DuplicateTxMaxOccurrences, - Global.DuplicateTxTotalOccurrences, - Global.NumberOfDuplicateTxs, - Global.NumberOfNonDuplicateTxs, - Global.NumberOfSuccessfulCheckTxs, - Global.NumberOfFailedCheckTxs, - Global.NumberOfLocalCheckTx, - Global.FailedTxs, - Global.RejectedTxs, - Global.EvictedTxs, - Global.ExpiredTxs, - Global.RecheckTimes, - Global.RemovedTxs, - Global.InsertedTxs, - Global.CheckTxPriorityDistribution, - Global.CheckTxDroppedByPriorityHint, - Global.CheckTxMetDropUtilisationThreshold, - ) -} - -func NewMetrics() *Metrics { +func PrometheusMetrics(namespace string, labelsAndValues ...string) *Metrics { + labels := []string{} + for i := 0; i < len(labelsAndValues); i += 2 { + labels = append(labels, labelsAndValues[i]) + } return &Metrics{ - Size: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + Size: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "size", Help: "Number of uncommitted transactions in the mempool.", - }, nil), - PendingSize: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + PendingSize: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "pending_size", Help: "Number of pending transactions in mempool", - }, nil), - CacheSize: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + CacheSize: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "cache_size", Help: "Number of cached transactions in the mempool cache.", - }, nil), - TxSizeBytes: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + TxSizeBytes: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "tx_size_bytes", Help: "Accumulated transaction sizes in bytes.", - }, nil), - TotalTxsSizeBytes: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + TotalTxsSizeBytes: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "total_txs_size_bytes", Help: "Total current mempool uncommitted txs bytes", - }, nil), - DuplicateTxMaxOccurrences: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + DuplicateTxMaxOccurrences: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "duplicate_tx_max_occurrences", Help: "Track max number of occurrences for a duplicate tx", - }, nil), - DuplicateTxTotalOccurrences: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + DuplicateTxTotalOccurrences: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "duplicate_tx_total_occurrences", Help: "Track the total number of occurrences for all duplicate txs", - }, nil), - NumberOfDuplicateTxs: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + NumberOfDuplicateTxs: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "number_of_duplicate_txs", Help: "Track the number of unique duplicate transactions", - }, nil), - NumberOfNonDuplicateTxs: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + NumberOfNonDuplicateTxs: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "number_of_non_duplicate_txs", Help: "Track the number of unique new tx transactions", - }, nil), - NumberOfSuccessfulCheckTxs: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + NumberOfSuccessfulCheckTxs: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "number_of_successful_check_txs", Help: "Track the number of checkTx calls", - }, nil), - NumberOfFailedCheckTxs: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + NumberOfFailedCheckTxs: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "number_of_failed_check_txs", Help: "Track the number of failed checkTx calls", - }, nil), - NumberOfLocalCheckTx: prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + NumberOfLocalCheckTx: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "number_of_local_check_tx", Help: "Track the number of checkTx from local removed tx", - }, nil), - FailedTxs: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + FailedTxs: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "failed_txs", Help: "Number of failed transactions.", - }, nil), - RejectedTxs: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + RejectedTxs: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "rejected_txs", Help: "Number of rejected transactions.", - }, nil), - EvictedTxs: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + EvictedTxs: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "evicted_txs", Help: "Number of evicted transactions.", - }, nil), - ExpiredTxs: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + ExpiredTxs: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "expired_txs", Help: "Number of expired transactions.", - }, nil), - RecheckTimes: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + RecheckTimes: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "recheck_times", Help: "Number of times transactions are rechecked in the mempool.", - }, nil), - RemovedTxs: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + RemovedTxs: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "removed_txs", Help: "Number of removed tx from mempool", - }, nil), - InsertedTxs: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + InsertedTxs: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "inserted_txs", Help: "Number of txs inserted to mempool", - }, nil), - CheckTxPriorityDistribution: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + CheckTxPriorityDistribution: prometheus.NewHistogramFrom(stdprometheus.HistogramOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "check_tx_priority_distribution", Help: "CheckTxPriorityDistribution is a histogram of the priority of transactions submitted via CheckTx, labeled by whether a priority hint was provided, whether the transaction was submitted locally (i.e. no sender node ID), and whether an error occurred during transaction priority determination. Note that the priority is normalized as a float64 value between zero and maximum tx priority.", - Buckets: prometheus.ExponentialBucketsRange(0.000001, 1.0, 20), - }, []string{"hint", "local", "error"}), - CheckTxDroppedByPriorityHint: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + + Buckets: stdprometheus.ExponentialBucketsRange(0.000001, 1.0, 20), + }, append(labels, "hint", "local", "error")).With(labelsAndValues...), + CheckTxDroppedByPriorityHint: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "check_tx_dropped_by_priority_hint", Help: "CheckTxDroppedByPriorityHint is the number of transactions that were dropped due to low priority based on the priority hint.", - }, nil), - CheckTxMetDropUtilisationThreshold: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + CheckTxMetDropUtilisationThreshold: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "check_tx_met_drop_utilisation_threshold", Help: "CheckTxMetDropUtilisationThreshold is the number of transactions for which CheckTx was executed while the mempool utilisation was above the configured threshold. Note that not all such transactions are dropped, only those that also have a low priority.", - }, nil), + }, labels).With(labelsAndValues...), } } -func (m *Metrics) SizeAt() *tmprometheus.GaugeInt { - return m.Size.WithLabelValues() -} - -func (m *Metrics) PendingSizeAt() *tmprometheus.GaugeInt { - return m.PendingSize.WithLabelValues() -} - -func (m *Metrics) CacheSizeAt() *tmprometheus.GaugeInt { - return m.CacheSize.WithLabelValues() -} - -func (m *Metrics) TxSizeBytesAt() *tmprometheus.CounterInt { - return m.TxSizeBytes.WithLabelValues() -} - -func (m *Metrics) TotalTxsSizeBytesAt() *tmprometheus.GaugeInt { - return m.TotalTxsSizeBytes.WithLabelValues() -} - -func (m *Metrics) DuplicateTxMaxOccurrencesAt() *tmprometheus.GaugeInt { - return m.DuplicateTxMaxOccurrences.WithLabelValues() -} - -func (m *Metrics) DuplicateTxTotalOccurrencesAt() *tmprometheus.GaugeInt { - return m.DuplicateTxTotalOccurrences.WithLabelValues() -} - -func (m *Metrics) NumberOfDuplicateTxsAt() *tmprometheus.GaugeInt { - return m.NumberOfDuplicateTxs.WithLabelValues() -} - -func (m *Metrics) NumberOfNonDuplicateTxsAt() *tmprometheus.GaugeInt { - return m.NumberOfNonDuplicateTxs.WithLabelValues() -} - -func (m *Metrics) NumberOfSuccessfulCheckTxsAt() *tmprometheus.CounterInt { - return m.NumberOfSuccessfulCheckTxs.WithLabelValues() -} - -func (m *Metrics) NumberOfFailedCheckTxsAt() *tmprometheus.CounterInt { - return m.NumberOfFailedCheckTxs.WithLabelValues() -} - -func (m *Metrics) NumberOfLocalCheckTxAt() prometheus.Counter { - return m.NumberOfLocalCheckTx.WithLabelValues() -} - -func (m *Metrics) FailedTxsAt() *tmprometheus.CounterInt { - return m.FailedTxs.WithLabelValues() -} - -func (m *Metrics) RejectedTxsAt() *tmprometheus.CounterInt { - return m.RejectedTxs.WithLabelValues() -} - -func (m *Metrics) EvictedTxsAt() *tmprometheus.CounterInt { - return m.EvictedTxs.WithLabelValues() -} - -func (m *Metrics) ExpiredTxsAt() *tmprometheus.CounterInt { - return m.ExpiredTxs.WithLabelValues() -} - -func (m *Metrics) RecheckTimesAt() *tmprometheus.CounterInt { - return m.RecheckTimes.WithLabelValues() -} - -func (m *Metrics) RemovedTxsAt() *tmprometheus.CounterInt { - return m.RemovedTxs.WithLabelValues() -} - -func (m *Metrics) InsertedTxsAt() *tmprometheus.CounterInt { - return m.InsertedTxs.WithLabelValues() -} - -func (m *Metrics) CheckTxPriorityDistributionAt(l0_hint string, l1_local string, l2_error string) *tmprometheus.Histogram { - return m.CheckTxPriorityDistribution.WithLabelValues(l0_hint, l1_local, l2_error) -} - -func (m *Metrics) CheckTxDroppedByPriorityHintAt() *tmprometheus.CounterInt { - return m.CheckTxDroppedByPriorityHint.WithLabelValues() -} - -func (m *Metrics) CheckTxMetDropUtilisationThresholdAt() *tmprometheus.CounterInt { - return m.CheckTxMetDropUtilisationThreshold.WithLabelValues() +func NopMetrics() *Metrics { + return &Metrics{ + Size: discard.NewGauge(), + PendingSize: discard.NewGauge(), + CacheSize: discard.NewGauge(), + TxSizeBytes: discard.NewCounter(), + TotalTxsSizeBytes: discard.NewGauge(), + DuplicateTxMaxOccurrences: discard.NewGauge(), + DuplicateTxTotalOccurrences: discard.NewGauge(), + NumberOfDuplicateTxs: discard.NewGauge(), + NumberOfNonDuplicateTxs: discard.NewGauge(), + NumberOfSuccessfulCheckTxs: discard.NewCounter(), + NumberOfFailedCheckTxs: discard.NewCounter(), + NumberOfLocalCheckTx: discard.NewCounter(), + FailedTxs: discard.NewCounter(), + RejectedTxs: discard.NewCounter(), + EvictedTxs: discard.NewCounter(), + ExpiredTxs: discard.NewCounter(), + RecheckTimes: discard.NewCounter(), + RemovedTxs: discard.NewCounter(), + InsertedTxs: discard.NewCounter(), + CheckTxPriorityDistribution: discard.NewHistogram(), + CheckTxDroppedByPriorityHint: discard.NewCounter(), + CheckTxMetDropUtilisationThreshold: discard.NewCounter(), + } } diff --git a/sei-tendermint/internal/mempool/metrics.go b/sei-tendermint/internal/mempool/metrics.go index d313e746ec..50bcc89781 100644 --- a/sei-tendermint/internal/mempool/metrics.go +++ b/sei-tendermint/internal/mempool/metrics.go @@ -4,9 +4,9 @@ import ( "math" "strconv" - "github.com/prometheus/client_golang/prometheus" + "github.com/go-kit/kit/metrics" + stdprometheus "github.com/prometheus/client_golang/prometheus" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" - tmprometheus "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/prometheus" "github.com/sei-protocol/sei-chain/sei-tendermint/types" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" @@ -14,8 +14,6 @@ import ( ) const ( - // MetricsNamespace is the namespace shared by all Tendermint Prometheus metrics. - MetricsNamespace = "tendermint" // MetricsSubsystem is a subsystem shared by all metrics exposed by this // package. MetricsSubsystem = "mempool" @@ -36,7 +34,7 @@ var ( "tendermint_mempool_compact_duration_seconds", metric.WithDescription("Wall-clock duration of compact(), which re-sorts and rebuilds indices over the full mempool (O(m log m))."), metric.WithUnit("s"), - metric.WithExplicitBucketBoundaries(prometheus.ExponentialBucketsRange(0.001, 30, 14)...), + metric.WithExplicitBucketBoundaries(stdprometheus.ExponentialBucketsRange(0.001, 30, 14)...), )), } @@ -51,72 +49,72 @@ var ( // see MetricsProvider for descriptions. type Metrics struct { // Number of uncommitted transactions in the mempool. - Size tmprometheus.GaugeIntVec + Size metrics.Gauge // Number of pending transactions in mempool - PendingSize tmprometheus.GaugeIntVec + PendingSize metrics.Gauge // Number of cached transactions in the mempool cache. - CacheSize tmprometheus.GaugeIntVec + CacheSize metrics.Gauge // Accumulated transaction sizes in bytes. - TxSizeBytes tmprometheus.CounterIntVec + TxSizeBytes metrics.Counter // Total current mempool uncommitted txs bytes - TotalTxsSizeBytes tmprometheus.GaugeIntVec + TotalTxsSizeBytes metrics.Gauge // Track max number of occurrences for a duplicate tx - DuplicateTxMaxOccurrences tmprometheus.GaugeIntVec + DuplicateTxMaxOccurrences metrics.Gauge // Track the total number of occurrences for all duplicate txs - DuplicateTxTotalOccurrences tmprometheus.GaugeIntVec + DuplicateTxTotalOccurrences metrics.Gauge // Track the number of unique duplicate transactions - NumberOfDuplicateTxs tmprometheus.GaugeIntVec + NumberOfDuplicateTxs metrics.Gauge // Track the number of unique new tx transactions - NumberOfNonDuplicateTxs tmprometheus.GaugeIntVec + NumberOfNonDuplicateTxs metrics.Gauge // Track the number of checkTx calls - NumberOfSuccessfulCheckTxs tmprometheus.CounterIntVec + NumberOfSuccessfulCheckTxs metrics.Counter // Track the number of failed checkTx calls - NumberOfFailedCheckTxs tmprometheus.CounterIntVec + NumberOfFailedCheckTxs metrics.Counter // Track the number of checkTx from local removed tx - NumberOfLocalCheckTx *prometheus.CounterVec + NumberOfLocalCheckTx metrics.Counter // Number of failed transactions. - FailedTxs tmprometheus.CounterIntVec + FailedTxs metrics.Counter // RejectedTxs defines the number of rejected transactions. These are // transactions that passed CheckTx but failed to make it into the mempool // due to other constraints, e.g. mempool is full and no lower priority // transactions exist in the mempool. //metrics:Number of rejected transactions. - RejectedTxs tmprometheus.CounterIntVec + RejectedTxs metrics.Counter // EvictedTxs defines the number of evicted transactions. These are valid // transactions that passed CheckTx and existed in the mempool but were later // evicted to make room for higher priority valid transactions that passed // CheckTx. //metrics:Number of evicted transactions. - EvictedTxs tmprometheus.CounterIntVec + EvictedTxs metrics.Counter // ExpiredTxs defines the number of expired transactions. These are valid // transactions that passed CheckTx and existed in the mempool but were not // get picked up in time and eventually got expired and removed from mempool //metrics:Number of expired transactions. - ExpiredTxs tmprometheus.CounterIntVec + ExpiredTxs metrics.Counter // Number of times transactions are rechecked in the mempool. - RecheckTimes tmprometheus.CounterIntVec + RecheckTimes metrics.Counter // Number of removed tx from mempool - RemovedTxs tmprometheus.CounterIntVec + RemovedTxs metrics.Counter // Number of txs inserted to mempool - InsertedTxs tmprometheus.CounterIntVec + InsertedTxs metrics.Counter // CheckTxPriorityDistribution is a histogram of the priority of transactions // submitted via CheckTx, labeled by whether a priority hint was provided, @@ -125,22 +123,22 @@ type Metrics struct { // // Note that the priority is normalized as a float64 value between zero and // maximum tx priority. - CheckTxPriorityDistribution tmprometheus.HistogramVec `metrics_buckets:"exprange(0.000001, 1.0, 20)" metrics_labels:"hint, local, error"` + CheckTxPriorityDistribution metrics.Histogram `metrics_buckettype:"exprange" metrics_bucketsizes:"0.000001, 1.0, 20" metrics_labels:"hint, local, error"` // CheckTxDroppedByPriorityHint is the number of transactions that were dropped // due to low priority based on the priority hint. - CheckTxDroppedByPriorityHint tmprometheus.CounterIntVec + CheckTxDroppedByPriorityHint metrics.Counter // CheckTxMetDropUtilisationThreshold is the number of transactions for which CheckTx was executed while the mempool // utilisation was above the configured threshold. Note that not all such transactions are dropped, only those that also have a low priority. - CheckTxMetDropUtilisationThreshold tmprometheus.CounterIntVec + CheckTxMetDropUtilisationThreshold metrics.Counter } func (m *Metrics) observeCheckTxPriorityDistribution(priority int64, hint bool, senderNodeID types.NodeID, isError bool) { normalizedPriority := float64(priority) / float64(math.MaxInt64) // Normalize to [0.0, 1.0] - m.CheckTxPriorityDistributionAt( - strconv.FormatBool(hint), - strconv.FormatBool(senderNodeID == ""), - strconv.FormatBool(isError), + m.CheckTxPriorityDistribution.With( + "hint", strconv.FormatBool(hint), + "local", strconv.FormatBool(senderNodeID == ""), + "error", strconv.FormatBool(isError), ).Observe(normalizedPriority) } diff --git a/sei-tendermint/internal/mempool/reactor/reactor_test.go b/sei-tendermint/internal/mempool/reactor/reactor_test.go index f587cf919e..100692c723 100644 --- a/sei-tendermint/internal/mempool/reactor/reactor_test.go +++ b/sei-tendermint/internal/mempool/reactor/reactor_test.go @@ -49,7 +49,7 @@ func setupMempool(t testing.TB, app *proxy.Proxy, cacheSize int, txConstraintsFe t.Cleanup(func() { os.RemoveAll(cfg.RootDir) }) - return mempool.NewTxMempool(cfg.Mempool.ToMempoolConfig(), app, txConstraintsFetcher) + return mempool.NewTxMempool(cfg.Mempool.ToMempoolConfig(), app, mempool.NopMetrics(), txConstraintsFetcher) } func checkTxs(ctx context.Context, t *testing.T, rng utils.Rng, txmp *mempool.TxMempool, numTxs int) []testTx { @@ -102,7 +102,7 @@ func setupReactorsWithConfig( rts.kvstores[nodeID] = kvstore.NewApplication() app := rts.kvstores[nodeID] - proxyApp := proxy.New(app) + proxyApp := proxy.New(app, proxy.NopMetrics()) txmp := setupMempool(t, proxyApp, 0, txConstraintsFetcher) rts.mempools[nodeID] = txmp @@ -139,7 +139,7 @@ func setupReactorForTest(t *testing.T, txConstraintsFetcher mempool.TxConstraint network := p2p.MakeTestNetwork(t, p2p.TestNetworkOptions{NumNodes: 1}) node := network.Nodes()[0] - txmp := mempool.NewTxMempool(cfg.Mempool.ToMempoolConfig(), kvstore.NewProxy(), txConstraintsFetcher) + txmp := mempool.NewTxMempool(cfg.Mempool.ToMempoolConfig(), kvstore.NewProxy(), mempool.NopMetrics(), txConstraintsFetcher) reactor, err := NewReactor(cfg.Mempool, txmp, node.Router) require.NoError(t, err) reactor.MarkReadyToStart() diff --git a/sei-tendermint/internal/mempool/recheck_drain_test.go b/sei-tendermint/internal/mempool/recheck_drain_test.go index 47ff7b408c..d6a58c9bfa 100644 --- a/sei-tendermint/internal/mempool/recheck_drain_test.go +++ b/sei-tendermint/internal/mempool/recheck_drain_test.go @@ -167,7 +167,7 @@ func TestTxMempool_DescendingNonceDrain(t *testing.T) { app := newEVMNonceApp() cfg := TestConfig() cfg.CacheSize = 5000 - txmp := setup(cfg, proxy.New(app), NopTxConstraintsFetcher) + txmp := setup(cfg, proxy.New(app, proxy.NopMetrics()), NopTxConstraintsFetcher) // Submit nonces N-1, N-2, ..., 1, 0. Every tx except the last enters // pendingTxs because its nonce is ahead of the sender's expected nonce @@ -220,7 +220,7 @@ func TestTxMempool_EvmNextPendingNonceIncludesPendingTransactions(t *testing.T) app.setNonce(sender, 5) cfg := TestConfig() cfg.CacheSize = 5000 - txmp := setup(cfg, proxy.New(app), NopTxConstraintsFetcher) + txmp := setup(cfg, proxy.New(app, proxy.NopMetrics()), NopTxConstraintsFetcher) for _, nonce := range []uint64{7, 5, 6} { tx := []byte(fmt.Sprintf("evm=%s=%d=1", sender.Hex(), nonce)) @@ -241,7 +241,7 @@ func TestTxMempool_EvmNextPendingNonceReplacesSameNonceByPriority(t *testing.T) app.setNonce(sender, 5) cfg := TestConfig() cfg.CacheSize = 5000 - txmp := setup(cfg, proxy.New(app), NopTxConstraintsFetcher) + txmp := setup(cfg, proxy.New(app, proxy.NopMetrics()), NopTxConstraintsFetcher) lowPriorityTx := []byte(fmt.Sprintf("evm=%s=%d=%d", sender.Hex(), 6, 1)) highPriorityTx := []byte(fmt.Sprintf("evm=%s=%d=%d", sender.Hex(), 6, 2)) diff --git a/sei-tendermint/internal/mempool/tx.go b/sei-tendermint/internal/mempool/tx.go index 5a728bde6e..b7146d8f45 100644 --- a/sei-tendermint/internal/mempool/tx.go +++ b/sei-tendermint/internal/mempool/tx.go @@ -156,8 +156,9 @@ type txStoreInner struct { // - we reap by highest prio, while respecting nonces. // - non-evm txs are always ready type txStore struct { - config *Config - app *proxy.Proxy + config *Config + app *proxy.Proxy + metrics *Metrics inner utils.RWMutex[*txStoreInner] state utils.AtomicRecv[txStoreState] @@ -170,7 +171,7 @@ type txStore struct { priorityReservoir *reservoir.Sampler[int64] } -func NewTxStore(cfg *Config, app *proxy.Proxy) *txStore { +func NewTxStore(cfg *Config, app *proxy.Proxy, metrics *Metrics) *txStore { softLimit := txCounter{count: cfg.Size + cfg.PendingSize, bytes: utils.Clamp[uint64](cfg.MaxTxsBytes + cfg.MaxPendingTxsBytes)} hardLimit := txCounter{count: 2 * softLimit.count, bytes: 2 * softLimit.bytes} inner := &txStoreInner{ @@ -187,6 +188,7 @@ func NewTxStore(cfg *Config, app *proxy.Proxy) *txStore { return &txStore{ config: cfg, app: app, + metrics: metrics, inner: utils.NewRWMutex(inner), state: inner.state.Subscribe(), readyTxs: clist.New[types.Tx](), @@ -197,7 +199,7 @@ func NewTxStore(cfg *Config, app *proxy.Proxy) *txStore { func (s *txStore) Clear() { for inner := range s.inner.Lock() { inner.cache.Reset() - Global.CacheSizeAt().Set(int64(inner.cache.Size())) + s.metrics.CacheSize.Set(float64(inner.cache.Size())) inner.failedTxs.Reset() inner.byHash = map[types.TxHash]*WrappedTx{} inner.byEvmHash = map[common.Hash]*WrappedTx{} @@ -229,7 +231,7 @@ func (s *txStore) MarkInvalid(txHash types.TxHash) { if s.config.KeepInvalidTxsInCache { for inner := range s.inner.Lock() { inner.cache.Push(txHash, utils.None[cacheEvm]()) - Global.CacheSizeAt().Set(int64(inner.cache.Size())) + s.metrics.CacheSize.Set(float64(inner.cache.Size())) } } } @@ -398,7 +400,7 @@ func (s *txStore) insert(inner *txStoreInner, wtx *WrappedTx) error { // Remove the old transaction. delete(inner.byHash, old.Hash()) delete(inner.byEvmHash, oldEvm.hash) - Global.RemovedTxsAt().Add(1) + s.metrics.RemovedTxs.Add(1) state.total.Dec(old.Size()) if el, ok := old.readyEl.Get(); ok { s.readyTxs.Remove(el) @@ -513,7 +515,7 @@ func (s *txStore) Insert(wtx *WrappedTx) error { return errMempoolFull } } - Global.CacheSizeAt().Set(int64(inner.cache.Size())) + s.metrics.CacheSize.Set(float64(inner.cache.Size())) } return nil } @@ -539,14 +541,14 @@ func (s *txStore) compact(inner *txStoreInner, clearAccounts bool) { limitOk := total.LessEqual(&inner.softLimit) // NOTE: insertion is lazily evaluated here. if !limitOk || s.insert(inner, wtx) != nil { - Global.RemovedTxsAt().Add(1) - Global.EvictedTxsAt().Add(1) + s.metrics.RemovedTxs.Add(1) + s.metrics.EvictedTxs.Add(1) if el, ok := wtx.readyEl.Get(); ok { s.readyTxs.Remove(el) } } } - Global.CacheSizeAt().Set(int64(inner.cache.Size())) + s.metrics.CacheSize.Set(float64(inner.cache.Size())) } type updateSpec struct { @@ -590,7 +592,7 @@ func (s *txStore) Update(spec updateSpec) { for txHash, wtx := range inner.byHash { expired := isExpired(wtx) if expired { - Global.ExpiredTxsAt().Add(1) + s.metrics.ExpiredTxs.Add(1) } invalid := spec.InvalidTxs[wtx.Hash()] || wtx.check(spec.Constraints) != nil _, executed := spec.TxResults[wtx.Hash()] @@ -602,7 +604,7 @@ func (s *txStore) Update(spec updateSpec) { inner.cache.Push(txHash, utils.None[cacheEvm]()) } delete(inner.byHash, txHash) - Global.RemovedTxsAt().Add(1) + s.metrics.RemovedTxs.Add(1) if el, ok := wtx.readyEl.Get(); ok { s.readyTxs.Remove(el) } @@ -672,7 +674,7 @@ func (s *txStore) Reap(l ReapLimits, remove bool) (types.Txs, int64) { if remove { for _, wtx := range wtxs { delete(inner.byHash, wtx.Hash()) - Global.RemovedTxsAt().Add(1) + s.metrics.RemovedTxs.Add(1) if el, ok := wtx.readyEl.Get(); ok { s.readyTxs.Remove(el) } diff --git a/sei-tendermint/internal/mempool/tx_test.go b/sei-tendermint/internal/mempool/tx_test.go index 79d7c87bd6..f486becb7c 100644 --- a/sei-tendermint/internal/mempool/tx_test.go +++ b/sei-tendermint/internal/mempool/tx_test.go @@ -16,7 +16,7 @@ import ( ) func newTxStoreForTest() *txStore { - return NewTxStore(TestConfig(), proxy.New(kvstore.NewApplication())) + return NewTxStore(TestConfig(), proxy.New(kvstore.NewApplication(), proxy.NopMetrics()), NopMetrics()) } func txStoreCacheRemove(txStore *txStore, txHash types.TxHash) { @@ -294,7 +294,7 @@ func TestTxStore_Size(t *testing.T) { func TestTxStore_RejectsAndEvictsTransactionsBelowAccountNonce(t *testing.T) { rng := utils.TestRng() app := newEVMNonceApp() - txStore := NewTxStore(TestConfig(), proxy.New(app)) + txStore := NewTxStore(TestConfig(), proxy.New(app, proxy.NopMetrics()), NopMetrics()) makeTx := func(address common.Address, nonce uint64) *WrappedTx { requiredBalance := *uint256.NewInt(uint64(rng.Int63n(256))) @@ -379,7 +379,7 @@ func testTxStoreUpdateExpiresTransactions(t *testing.T, removeExpiredTxsFromQueu cfg.RemoveExpiredTxsFromQueue = removeExpiredTxsFromQueue app := newEVMNonceApp() - txStore := NewTxStore(cfg, proxy.New(app)) + txStore := NewTxStore(cfg, proxy.New(app, proxy.NopMetrics()), NopMetrics()) baseTime := time.Unix(1_700_000_000, 0) makeTx := func(address common.Address, nonce uint64, height int64, timestamp time.Time) *WrappedTx { @@ -526,7 +526,7 @@ func TestTxStore_ExpiredTxCacheBehavior(t *testing.T) { cfg.RemoveExpiredTxsFromQueue = tc.removeExpiredFromQueue app := newEVMNonceApp() - txStore := NewTxStore(cfg, proxy.New(app)) + txStore := NewTxStore(cfg, proxy.New(app, proxy.NopMetrics()), NopMetrics()) env := newTestEnv(rng, txStore, app, 1) address := env.accounts[0].address env.app.setNonce(address, 7) @@ -588,7 +588,7 @@ func TestTxStore_NoncePrunedTxsRejectedAsOldNonce(t *testing.T) { cfg.CacheSize = 10 app := newEVMNonceApp() - txStore := NewTxStore(cfg, proxy.New(app)) + txStore := NewTxStore(cfg, proxy.New(app, proxy.NopMetrics()), NopMetrics()) env := newTestEnv(rng, txStore, app, 1) address := env.accounts[0].address env.app.setNonce(address, 7) @@ -619,7 +619,7 @@ func TestTxStore_NoncePrunedTxsRejectedAsOldNonce(t *testing.T) { func TestTxStore_ReplacesReadyTxByHigherPriority(t *testing.T) { rng := utils.TestRng() app := newEVMNonceApp() - txStore := NewTxStore(TestConfig(), proxy.New(app)) + txStore := NewTxStore(TestConfig(), proxy.New(app, proxy.NopMetrics()), NopMetrics()) env := newTestEnv(rng, txStore, app, 1) address := env.accounts[0].address env.app.setNonce(address, 7) @@ -666,7 +666,7 @@ func TestTxStore_ReplacesReadyTxByHigherPriority(t *testing.T) { func TestTxStore_RejectsDuplicateEvmHash(t *testing.T) { rng := utils.TestRng() app := newEVMNonceApp() - txStore := NewTxStore(TestConfig(), proxy.New(app)) + txStore := NewTxStore(TestConfig(), proxy.New(app, proxy.NopMetrics()), NopMetrics()) address := genEvmAddress(rng) app.setNonce(address, 7) app.setBalance(address, 100) @@ -692,7 +692,7 @@ func TestTxStore_RejectsDuplicateEvmHash(t *testing.T) { func TestTxStore_ReplacesReadyThenPendingTxByHigherPriority(t *testing.T) { rng := utils.TestRng() app := newEVMNonceApp() - txStore := NewTxStore(TestConfig(), proxy.New(app)) + txStore := NewTxStore(TestConfig(), proxy.New(app, proxy.NopMetrics()), NopMetrics()) env := newTestEnv(rng, txStore, app, 1) address := env.accounts[0].address env.app.setNonce(address, 7) @@ -729,7 +729,7 @@ func TestTxStore_ReplacesReadyThenPendingTxByHigherPriority(t *testing.T) { func TestTxStore_ReplacesPendingTxByHigherPriority(t *testing.T) { rng := utils.TestRng() app := newEVMNonceApp() - txStore := NewTxStore(TestConfig(), proxy.New(app)) + txStore := NewTxStore(TestConfig(), proxy.New(app, proxy.NopMetrics()), NopMetrics()) env := newTestEnv(rng, txStore, app, 1) address := env.accounts[0].address env.app.setNonce(address, 7) @@ -759,7 +759,7 @@ func TestTxStore_InsertCompactionKeepsReadyListInSync(t *testing.T) { cfg.PendingSize = 0 app := newEVMNonceApp() - txStore := NewTxStore(cfg, proxy.New(app)) + txStore := NewTxStore(cfg, proxy.New(app, proxy.NopMetrics()), NopMetrics()) inserted := map[types.TxHash]*WrappedTx{} for range 20 * cfg.Size { diff --git a/sei-tendermint/internal/p2p/channel.go b/sei-tendermint/internal/p2p/channel.go index 30a6775687..4507293f79 100644 --- a/sei-tendermint/internal/p2p/channel.go +++ b/sei-tendermint/internal/p2p/channel.go @@ -68,12 +68,12 @@ func newChannel(desc conn.ChannelDescriptor) *channel { } func (ch *Channel[T]) send(msg T, queues ...*Queue[sendMsg]) { - Global.channelMsgsAt(fmt.Sprint(ch.desc.ID), "out").Add(1) + ch.router.metrics.ChannelMsgs.With("ch_id", fmt.Sprint(ch.desc.ID), "direction", "out").Add(1.) m := sendMsg{msg, ch.desc.ID} size := proto.Size(msg) for _, q := range queues { if pruned, ok := q.Send(m, size, ch.desc.Priority).Get(); ok { - Global.queueDroppedMsgsAt(fmt.Sprint(pruned.ChannelID), "out").Add(1) + ch.router.metrics.QueueDroppedMsgs.With("ch_id", fmt.Sprint(pruned.ChannelID), "direction", "out").Add(float64(1)) } } } @@ -117,6 +117,6 @@ func (ch *Channel[T]) Recv(ctx context.Context) (RecvMsg[T], error) { if err != nil { return RecvMsg[T]{}, err } - Global.channelMsgsAt(fmt.Sprint(ch.desc.ID), "in").Add(1) + ch.router.metrics.ChannelMsgs.With("ch_id", fmt.Sprint(ch.desc.ID), "direction", "in").Add(1.) return RecvMsg[T]{Message: recv.Message.(T), From: recv.From}, nil } diff --git a/sei-tendermint/internal/p2p/giga/api.go b/sei-tendermint/internal/p2p/giga/api.go index cc7637d71f..7294de0c06 100644 --- a/sei-tendermint/internal/p2p/giga/api.go +++ b/sei-tendermint/internal/p2p/giga/api.go @@ -11,37 +11,37 @@ const MB rpc.InBytes = 1024 * kB type API struct{} -var Ping = rpc.Register[API](0, "ping", +var Ping = rpc.Register[API](0, rpc.Limit{Rate: 1, Concurrent: 2}, rpc.Msg[*pb.PingReq]{MsgSize: kB, Window: 1}, rpc.Msg[*pb.PingResp]{MsgSize: kB, Window: 1}, ) -var StreamLaneProposals = rpc.Register[API](1, "stream_lane_proposals", +var StreamLaneProposals = rpc.Register[API](1, rpc.Limit{Rate: 1, Concurrent: 1}, rpc.Msg[*pb.StreamLaneProposalsReq]{MsgSize: kB, Window: 1}, rpc.Msg[*pb.LaneProposal]{MsgSize: 2 * MB, Window: 5}, ) -var StreamLaneVotes = rpc.Register[API](2, "stream_lane_votes", +var StreamLaneVotes = rpc.Register[API](2, rpc.Limit{Rate: 1, Concurrent: 1}, rpc.Msg[*pb.StreamLaneVotesReq]{MsgSize: kB, Window: 1}, rpc.Msg[*pb.LaneVote]{MsgSize: 10 * kB, Window: 100}, ) -var StreamCommitQCs = rpc.Register[API](3, "stream_commit_qcs", +var StreamCommitQCs = rpc.Register[API](3, rpc.Limit{Rate: 1, Concurrent: 1}, rpc.Msg[*pb.StreamCommitQCsReq]{MsgSize: kB, Window: 1}, rpc.Msg[*apb.CommitQC]{MsgSize: 20 * kB, Window: 20}, ) -var StreamAppVotes = rpc.Register[API](4, "stream_app_votes", +var StreamAppVotes = rpc.Register[API](4, rpc.Limit{Rate: 1, Concurrent: 1}, rpc.Msg[*pb.StreamAppVotesReq]{MsgSize: kB, Window: 1}, rpc.Msg[*pb.AppVote]{MsgSize: 10 * kB, Window: 100}, ) -var StreamAppQCs = rpc.Register[API](5, "stream_app_qcs", +var StreamAppQCs = rpc.Register[API](5, rpc.Limit{Rate: 1, Concurrent: 1}, rpc.Msg[*pb.StreamAppQCsReq]{MsgSize: kB, Window: 1}, rpc.Msg[*pb.StreamAppQCsResp]{MsgSize: 30 * kB, Window: 20}, ) -var Consensus = rpc.Register[API](6, "consensus", +var Consensus = rpc.Register[API](6, // Consensus streams are special in a sense that // * each stream sends just 1 message per view // * messages are streamed from client to server @@ -52,12 +52,12 @@ var Consensus = rpc.Register[API](6, "consensus", rpc.Msg[*apb.ConsensusReq]{MsgSize: 1200 * kB, Window: 1}, rpc.Msg[*pb.ConsensusResp]{MsgSize: kB, Window: 1}, ) -var StreamFullCommitQCs = rpc.Register[API](7, "stream_full_commit_qcs", +var StreamFullCommitQCs = rpc.Register[API](7, rpc.Limit{Rate: 1, Concurrent: 1}, rpc.Msg[*pb.StreamFullCommitQCsReq]{MsgSize: kB, Window: 1}, rpc.Msg[*apb.FullCommitQC]{MsgSize: 300 * kB, Window: 20}, ) -var GetBlock = rpc.Register[API](8, "get_block", +var GetBlock = rpc.Register[API](8, rpc.Limit{Rate: 10, Concurrent: 10}, rpc.Msg[*pb.GetBlockReq]{MsgSize: 10 * kB, Window: 1}, rpc.Msg[*pb.GetBlockResp]{MsgSize: 2 * MB, Window: 1}, diff --git a/sei-tendermint/internal/p2p/giga/avail_test.go b/sei-tendermint/internal/p2p/giga/avail_test.go index 038c0abf07..2406af6350 100644 --- a/sei-tendermint/internal/p2p/giga/avail_test.go +++ b/sei-tendermint/internal/p2p/giga/avail_test.go @@ -8,7 +8,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/avail" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" @@ -17,9 +16,8 @@ import ( func TestAvailClientServer(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) - committee := registry.LatestEpoch().Committee() - env := newTestEnv(registry) + committee, keys := types.GenCommittee(rng, 4) + env := newTestEnv(committee) var nodes []*testNode activeKeys := keys[:3] // keys are sorted by weight, so that's ok. totalWeight := uint64(0) @@ -33,7 +31,7 @@ func TestAvailClientServer(t *testing.T) { } totalBlocks := 3 * avail.BlocksPerLane - firstBlock := nodes[0].data.Registry().FirstBlock() + firstBlock := committee.FirstBlock() if err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { t.Log("Spawn network.") s.SpawnBg(func() error { return env.Run(ctx) }) diff --git a/sei-tendermint/internal/p2p/giga/consensus_test.go b/sei-tendermint/internal/p2p/giga/consensus_test.go index a80fa5aa00..08eb75f77d 100644 --- a/sei-tendermint/internal/p2p/giga/consensus_test.go +++ b/sei-tendermint/internal/p2p/giga/consensus_test.go @@ -6,7 +6,6 @@ import ( "testing" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" ) @@ -14,15 +13,14 @@ import ( func TestConsensusClientServer(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 7) - committee := registry.LatestEpoch().Committee() - env := newTestEnv(registry) + committee, keys := types.GenCommittee(rng, 7) + firstBlock := committee.FirstBlock() + env := newTestEnv(committee) // Run only a subset of replicas, to enforce timeouts. var nodes []*testNode for _, key := range types.TestKeysWithWeight(committee, keys, committee.CommitQuorum()) { nodes = append(nodes, env.AddNode(key)) } - firstBlock := nodes[0].data.Registry().FirstBlock() if err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { s.SpawnBg(func() error { return env.Run(ctx) }) var wantAppProposal utils.Option[*types.AppProposal] @@ -45,7 +43,6 @@ func TestConsensusClientServer(t *testing.T) { idx, types.RoadIndex(offset), types.GenAppHash(rng), - registry.LatestEpoch().EpochIndex(), ) wantAppProposal = utils.Some(p) for _, n := range nodes { @@ -58,7 +55,7 @@ func TestConsensusClientServer(t *testing.T) { if err != nil { return fmt.Errorf("ds.QC(): %w", err) } - want.Timestamp = qc.QC().Proposal().BlockTimestamp(idx).OrPanic("global block not in QC") + want.Timestamp = qc.QC().Proposal().BlockTimestamp(committee, idx).OrPanic("global block not in QC") if err := utils.TestDiff(want, got); err != nil { return err } diff --git a/sei-tendermint/internal/p2p/giga/data_test.go b/sei-tendermint/internal/p2p/giga/data_test.go index 549a234152..f5249517b9 100644 --- a/sei-tendermint/internal/p2p/giga/data_test.go +++ b/sei-tendermint/internal/p2p/giga/data_test.go @@ -9,7 +9,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p/conn" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p/rpc" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" @@ -24,8 +23,8 @@ type testNode struct { func defaultViewTimeout(view types.View) time.Duration { return time.Hour } -func newTestNode(registry *epoch.Registry, cfg *consensus.Config) *testNode { - dataState := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) +func newTestNode(committee *types.Committee, cfg *consensus.Config) *testNode { + dataState := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) consensusState, err := consensus.NewState(cfg, dataState) if err != nil { panic(fmt.Sprintf("consensus.NewState(): %v", err)) @@ -47,18 +46,17 @@ func (n *testNode) Run(ctx context.Context) error { } type testEnv struct { - registry *epoch.Registry committee *types.Committee nodes map[types.PublicKey]*testNode } -func newTestEnv(registry *epoch.Registry) *testEnv { - return &testEnv{registry, registry.LatestEpoch().Committee(), map[types.PublicKey]*testNode{}} +func newTestEnv(committee *types.Committee) *testEnv { + return &testEnv{committee, map[types.PublicKey]*testNode{}} } // Call AddNode BEFORE Run. func (e *testEnv) AddNode(key types.SecretKey) *testNode { - n := newTestNode(e.registry, &consensus.Config{ + n := newTestNode(e.committee, &consensus.Config{ Key: key, ViewTimeout: func(view types.View) time.Duration { if _, ok := e.nodes[e.committee.Leader(view)]; ok { @@ -92,11 +90,11 @@ func (e *testEnv) Run(ctx context.Context) error { func TestDataClientServer(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 2) - env := newTestEnv(registry) + committee, keys := types.GenCommittee(rng, 2) + firstBlock := committee.FirstBlock() + env := newTestEnv(committee) server := env.AddNode(keys[0]) client := env.AddNode(keys[1]) - firstBlock := server.data.Registry().FirstBlock() if err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { s.SpawnBg(func() error { return env.Run(ctx) }) @@ -104,7 +102,7 @@ func TestDataClientServer(t *testing.T) { prev := utils.None[*types.CommitQC]() for i := range 3 { t.Logf("iteration %v", i) - qc, blocks := data.TestCommitQC(rng, server.data.Registry().LatestEpoch(), keys, prev) + qc, blocks := data.TestCommitQC(rng, committee, keys, prev) if err := server.data.PushQC(ctx, qc, blocks); err != nil { return fmt.Errorf("serverState.PushQC(): %w", err) } @@ -133,7 +131,7 @@ func TestDataClientServer(t *testing.T) { return fmt.Errorf("clientState.CommitQC(): %w", err) } if err := utils.TestDiff(wantQC, gotQC); err != nil { - return fmt.Errorf("QC mismatch at block %d: %w", n, err) + return err } } return nil diff --git a/sei-tendermint/internal/p2p/giga/pb/api.wireguard.go b/sei-tendermint/internal/p2p/giga/pb/api.wireguard.go index 4e40412ad9..3ef12ec7ac 100644 --- a/sei-tendermint/internal/p2p/giga/pb/api.wireguard.go +++ b/sei-tendermint/internal/p2p/giga/pb/api.wireguard.go @@ -29,7 +29,7 @@ func (*LaneProposal) MaxSize() int { } func (*AppVote) MaxSize() int { - return 176 + return 165 } func (*StreamLaneProposalsReq) MaxSize() int { @@ -41,7 +41,7 @@ func (*StreamAppQCsReq) MaxSize() int { } func (*StreamAppQCsResp) MaxSize() int { - return 30418 + return 30374 } func (*StreamCommitQCsReq) MaxSize() int { diff --git a/sei-tendermint/internal/p2p/giga_router_common.go b/sei-tendermint/internal/p2p/giga_router_common.go index 9be48343c3..bc75a7d0fb 100644 --- a/sei-tendermint/internal/p2p/giga_router_common.go +++ b/sei-tendermint/internal/p2p/giga_router_common.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "maps" "net/url" "path/filepath" "slices" @@ -15,7 +16,6 @@ import ( atypes "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/crypto" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p/giga" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p/rpc" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/proxy" @@ -70,24 +70,19 @@ func buildDataState(cfg *GigaRouterCommonConfig) (*data.State, error) { if cfg.MaxInboundFullnodePeers < 0 || cfg.MaxInboundFullnodePeers > maxInboundFullnodePeers { return nil, fmt.Errorf("GigaRouterCommonConfig.MaxInboundFullnodePeers = %v, want 0..%v", cfg.MaxInboundFullnodePeers, maxInboundFullnodePeers) } - firstBlock := atypes.GlobalBlockNumber(cfg.GenDoc.InitialHeight) // nolint:gosec // verified to be positive. - genesisWeights := map[atypes.PublicKey]uint64{} - for k := range cfg.ValidatorAddrs { - genesisWeights[k] = 1 - } - genesisCommittee, err := atypes.NewCommittee(genesisWeights) - if err != nil { - return nil, fmt.Errorf("genesis committee: %w", err) - } - registry, err := epoch.NewRegistry(genesisCommittee, firstBlock, cfg.GenDoc.GenesisTime) + committee, err := atypes.NewRoundRobinElection( + slices.Collect(maps.Keys(cfg.ValidatorAddrs)), + atypes.GlobalBlockNumber(cfg.GenDoc.InitialHeight), // nolint:gosec // verified to be positive. + cfg.GenDoc.GenesisTime, + ) if err != nil { - return nil, fmt.Errorf("epoch.NewRegistry(): %w", err) + return nil, fmt.Errorf("atypes.NewRoundRobinElection(): %w", err) } - dataWAL, err := data.NewDataWAL(cfg.PersistentStateDir, registry.FirstBlock()) + dataWAL, err := data.NewDataWAL(cfg.PersistentStateDir, committee) if err != nil { return nil, fmt.Errorf("data.NewDataWAL(): %w", err) } - dataState, err := data.NewState(&data.Config{Registry: registry}, dataWAL) + dataState, err := data.NewState(&data.Config{Committee: committee}, dataWAL) if err != nil { return nil, fmt.Errorf("data.NewState(): %w", err) } @@ -465,9 +460,6 @@ func (r *gigaRouterCommon) dialAndRunConn( return r.poolOut.InsertAndRun(ctx, peerKey, client, func(ctx context.Context) error { return scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { s.Spawn(func() error { return client.Run(ctx, hConn.conn) }) - Global.gigaNewConnsAt("out").Add(1) - Global.gigaConnsAt("out").Add(1) - defer Global.gigaConnsAt("out").Add(-1) return runClient(ctx, client) }) }) @@ -504,9 +496,6 @@ func (r *gigaRouterCommon) RunInboundConn(ctx context.Context, hConn *handshaked return r.poolIn.InsertAndRun(ctx, key, server, func(ctx context.Context) error { return scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { s.Spawn(func() error { return server.Run(ctx, hConn.conn) }) - Global.gigaNewConnsAt("in").Add(1) - Global.gigaConnsAt("in").Add(1) - defer Global.gigaConnsAt("in").Add(-1) if err := r.service.RunInbound(ctx, server, isCommittee); err != nil { return fmt.Errorf("inbound from %v: %w", key, err) } @@ -519,6 +508,6 @@ func (r *gigaRouterCommon) RunInboundConn(ctx context.Context, hConn *handshaked // None if the caller should handle it locally. Overridden on // *gigaValidatorRouter to short-circuit self-shard sends. func (r *gigaRouterCommon) EvmProxy(sender common.Address) utils.Option[*url.URL] { - shardValidator := r.data.Registry().LatestEpoch().Committee().EvmShard(sender) + shardValidator := r.data.Committee().EvmShard(sender) return utils.Some(r.cfg.ValidatorAddrs[shardValidator].EVMRPC) } diff --git a/sei-tendermint/internal/p2p/giga_router_fullnode_test.go b/sei-tendermint/internal/p2p/giga_router_fullnode_test.go index b4f0686c46..ce5a64d778 100644 --- a/sei-tendermint/internal/p2p/giga_router_fullnode_test.go +++ b/sei-tendermint/internal/p2p/giga_router_fullnode_test.go @@ -55,7 +55,7 @@ func TestGigaRouter_Fullnode(t *testing.T) { require.NoError(t, genDoc.ValidateAndComplete()) app := newTestApp() - proxyApp := proxy.New(app) + proxyApp := proxy.New(app, proxy.NopMetrics()) // Fullnodes have no validator key and no Producer config. The data WAL // reads PersistentStateDir = None (in-memory) for this construction- @@ -82,7 +82,7 @@ func TestGigaRouter_Fullnode(t *testing.T) { returnedRemoteURLs := map[string]struct{}{} for range 200 { sender := common.BytesToAddress(utils.GenBytes(rng, common.AddressLength)) - shardValidator := router.data.Registry().LatestEpoch().Committee().EvmShard(sender) + shardValidator := router.data.Committee().EvmShard(sender) expectedURL := urlByValidator[shardValidator] proxyURL, ok := router.EvmProxy(sender).Get() require.True(t, ok) diff --git a/sei-tendermint/internal/p2p/giga_router_validator.go b/sei-tendermint/internal/p2p/giga_router_validator.go index 9fe39e6dbf..4c65cd1538 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator.go +++ b/sei-tendermint/internal/p2p/giga_router_validator.go @@ -89,7 +89,7 @@ func (r *gigaValidatorRouter) Run(ctx context.Context) error { // EvmProxy on the validator returns None when the sender's shard owner is // us (handle locally via mempool, no HTTP round-trip to self). func (r *gigaValidatorRouter) EvmProxy(sender common.Address) utils.Option[*url.URL] { - shardValidator := r.data.Registry().LatestEpoch().Committee().EvmShard(sender) + shardValidator := r.data.Committee().EvmShard(sender) if r.validatorKey == shardValidator { return utils.None[*url.URL]() } diff --git a/sei-tendermint/internal/p2p/giga_router_validator_test.go b/sei-tendermint/internal/p2p/giga_router_validator_test.go index 1ee7e80f8a..8f971356b4 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator_test.go +++ b/sei-tendermint/internal/p2p/giga_router_validator_test.go @@ -59,7 +59,7 @@ func TestGigaRouter_FinalizeBlocks(t *testing.T) { nodeInfo.Network = genDoc.ChainID e := Endpoint{AddrPort: cfg.addr} app := newTestApp() - proxyApp := proxy.New(app) + proxyApp := proxy.New(app, proxy.NopMetrics()) // In giga mode the CometBFT handshaker is skipped; the router's // runExecute calls InitChain itself on fresh start. giga, err := NewGigaValidatorRouter(&GigaValidatorConfig{ @@ -84,6 +84,7 @@ func TestGigaRouter_FinalizeBlocks(t *testing.T) { }, cfg.nodeKey) require.NoError(t, err, "NewGigaValidatorRouter[%v]", i) router, err := NewRouter( + NopMetrics(), cfg.nodeKey, func() *types.NodeInfo { return &nodeInfo }, dbm.NewMemDB(), @@ -225,7 +226,7 @@ func TestGigaRouter_EvmProxy(t *testing.T) { DialInterval: time.Second, ValidatorAddrs: addrs, PersistentStateDir: utils.None[string](), - App: proxy.New(newTestApp()), + App: proxy.New(newTestApp(), proxy.NopMetrics()), GenDoc: genDoc, }, ValidatorKey: validatorKeys[0], @@ -255,7 +256,7 @@ func TestGigaRouter_EvmProxy(t *testing.T) { for range 200 { sender := common.BytesToAddress(utils.GenBytes(rng, common.AddressLength)) - shardValidator := router.data.Registry().LatestEpoch().Committee().EvmShard(sender) + shardValidator := router.data.Committee().EvmShard(sender) proxyURL, ok := router.EvmProxy(sender).Get() expectedURL := urlByValidator[shardValidator] diff --git a/sei-tendermint/internal/p2p/metrics.gen.go b/sei-tendermint/internal/p2p/metrics.gen.go index 0b4fe4b175..cdb1f62508 100644 --- a/sei-tendermint/internal/p2p/metrics.gen.go +++ b/sei-tendermint/internal/p2p/metrics.gen.go @@ -3,107 +3,91 @@ package p2p import ( - "github.com/prometheus/client_golang/prometheus" - tmprometheus "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/prometheus" + "github.com/go-kit/kit/metrics/discard" + prometheus "github.com/go-kit/kit/metrics/prometheus" + stdprometheus "github.com/prometheus/client_golang/prometheus" ) -var Global = NewMetrics() - -func init() { - prometheus.MustRegister( - Global.peers, - Global.peerReceiveBytesTotal, - Global.newConnections, - Global.routerPeerQueueRecv, - Global.channelMsgs, - Global.queueDroppedMsgs, - Global.gigaConns, - Global.gigaNewConns, - ) -} - -func NewMetrics() *Metrics { +func PrometheusMetrics(namespace string, labelsAndValues ...string) *Metrics { + labels := []string{} + for i := 0; i < len(labelsAndValues); i += 2 { + labels = append(labels, labelsAndValues[i]) + } return &Metrics{ - peers: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + Peers: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "peers", Help: "Number of peers.", - }, nil), - peerReceiveBytesTotal: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + PeerReceiveBytesTotal: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "peer_receive_bytes_total", Help: "Number of bytes per channel received from a given peer.", - }, []string{"peer_id", "chID", "message_type"}), - newConnections: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + }, append(labels, "peer_id", "chID", "message_type")).With(labelsAndValues...), + PeerSendBytesTotal: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, + Subsystem: MetricsSubsystem, + Name: "peer_send_bytes_total", + Help: "Number of bytes per channel sent to a given peer.", + }, append(labels, "peer_id", "chID", "message_type")).With(labelsAndValues...), + PeerPendingSendBytes: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, + Subsystem: MetricsSubsystem, + Name: "peer_pending_send_bytes", + Help: "Number of bytes pending being sent to a given peer.", + }, append(labels, "peer_id")).With(labelsAndValues...), + NewConnections: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "new_connections", Help: "Number of newly established connections.", - }, []string{"direction", "success"}), - routerPeerQueueRecv: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, + }, append(labels, "direction", "success")).With(labelsAndValues...), + RouterPeerQueueRecv: prometheus.NewHistogramFrom(stdprometheus.HistogramOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "router_peer_queue_recv", Help: "The time taken to read off of a peer's queue before sending on the connection.", - Buckets: prometheus.DefBuckets, - }, nil), - channelMsgs: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + RouterPeerQueueSend: prometheus.NewHistogramFrom(stdprometheus.HistogramOpts{ + Namespace: namespace, + Subsystem: MetricsSubsystem, + Name: "router_peer_queue_send", + Help: "The time taken to send on a peer's queue which will later be read and sent on the connection.", + }, labels).With(labelsAndValues...), + RouterChannelQueueSend: prometheus.NewHistogramFrom(stdprometheus.HistogramOpts{ + Namespace: namespace, + Subsystem: MetricsSubsystem, + Name: "router_channel_queue_send", + Help: "The time taken to send on a p2p channel's queue which will later be consued by the corresponding reactor/service.", + }, labels).With(labelsAndValues...), + ChannelMsgs: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "channel_msgs", Help: "", - }, []string{"ch_id", "direction"}), - queueDroppedMsgs: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + }, append(labels, "ch_id", "direction")).With(labelsAndValues...), + QueueDroppedMsgs: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "queue_dropped_msgs", Help: "The number of messages dropped from router's queues.", - }, []string{"ch_id", "direction"}), - gigaConns: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, - Subsystem: MetricsSubsystem, - Name: "giga_conns", - Help: "Number of live giga p2p connections.", - }, []string{"direction"}), - gigaNewConns: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, - Subsystem: MetricsSubsystem, - Name: "giga_new_conns", - Help: "Counts established giga p2p connections.", - }, []string{"direction"}), + }, append(labels, "ch_id", "direction")).With(labelsAndValues...), } } -func (m *Metrics) peersAt() *tmprometheus.GaugeInt { - return m.peers.WithLabelValues() -} - -func (m *Metrics) peerReceiveBytesTotalAt(peer_id string, chID string, message_type string) *tmprometheus.CounterInt { - return m.peerReceiveBytesTotal.WithLabelValues(peer_id, chID, message_type) -} - -func (m *Metrics) newConnectionsAt(direction string, success string) *tmprometheus.CounterInt { - return m.newConnections.WithLabelValues(direction, success) -} - -func (m *Metrics) routerPeerQueueRecvAt() *tmprometheus.Histogram { - return m.routerPeerQueueRecv.WithLabelValues() -} - -func (m *Metrics) channelMsgsAt(ch_id string, direction string) *tmprometheus.CounterInt { - return m.channelMsgs.WithLabelValues(ch_id, direction) -} - -func (m *Metrics) queueDroppedMsgsAt(ch_id string, direction string) *tmprometheus.CounterInt { - return m.queueDroppedMsgs.WithLabelValues(ch_id, direction) -} - -func (m *Metrics) gigaConnsAt(direction string) *tmprometheus.GaugeInt { - return m.gigaConns.WithLabelValues(direction) -} - -func (m *Metrics) gigaNewConnsAt(direction string) *tmprometheus.CounterInt { - return m.gigaNewConns.WithLabelValues(direction) +func NopMetrics() *Metrics { + return &Metrics{ + Peers: discard.NewGauge(), + PeerReceiveBytesTotal: discard.NewCounter(), + PeerSendBytesTotal: discard.NewCounter(), + PeerPendingSendBytes: discard.NewGauge(), + NewConnections: discard.NewCounter(), + RouterPeerQueueRecv: discard.NewHistogram(), + RouterPeerQueueSend: discard.NewHistogram(), + RouterChannelQueueSend: discard.NewHistogram(), + ChannelMsgs: discard.NewCounter(), + QueueDroppedMsgs: discard.NewCounter(), + } } diff --git a/sei-tendermint/internal/p2p/metrics.go b/sei-tendermint/internal/p2p/metrics.go index 6dab13c1de..167d91a586 100644 --- a/sei-tendermint/internal/p2p/metrics.go +++ b/sei-tendermint/internal/p2p/metrics.go @@ -6,12 +6,10 @@ import ( "regexp" "sync" - "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/prometheus" + "github.com/go-kit/kit/metrics" ) const ( - // MetricsNamespace is the namespace shared by all Tendermint Prometheus metrics. - MetricsNamespace = "tendermint" // MetricsSubsystem is a subsystem shared by all metrics exposed by this // package. MetricsSubsystem = "p2p" @@ -29,31 +27,40 @@ var ( // Metrics contains metrics exposed by this package. type Metrics struct { // Number of peers. - peers prometheus.GaugeIntVec + Peers metrics.Gauge // Number of bytes per channel received from a given peer. - peerReceiveBytesTotal prometheus.CounterIntVec `metrics_labels:"peer_id, chID, message_type"` + PeerReceiveBytesTotal metrics.Counter `metrics_labels:"peer_id, chID, message_type"` + // Number of bytes per channel sent to a given peer. + PeerSendBytesTotal metrics.Counter `metrics_labels:"peer_id, chID, message_type"` + // Number of bytes pending being sent to a given peer. + PeerPendingSendBytes metrics.Gauge `metrics_labels:"peer_id"` // Number of newly established connections. - newConnections prometheus.CounterIntVec `metrics_labels:"direction, success"` + NewConnections metrics.Counter `metrics_labels:"direction, success"` // RouterPeerQueueRecv defines the time taken to read off of a peer's queue // before sending on the connection. //metrics:The time taken to read off of a peer's queue before sending on the connection. - routerPeerQueueRecv prometheus.HistogramVec + RouterPeerQueueRecv metrics.Histogram - channelMsgs prometheus.CounterIntVec `metrics_labels:"ch_id, direction"` + // RouterPeerQueueSend defines the time taken to send on a peer's queue which + // will later be read and sent on the connection (see RouterPeerQueueRecv). + //metrics:The time taken to send on a peer's queue which will later be read and sent on the connection. + RouterPeerQueueSend metrics.Histogram + + // RouterChannelQueueSend defines the time taken to send on a p2p channel's + // queue which will later be consued by the corresponding reactor/service. + //metrics:The time taken to send on a p2p channel's queue which will later be consued by the corresponding reactor/service. + RouterChannelQueueSend metrics.Histogram + + ChannelMsgs metrics.Counter `metrics_labels:"ch_id, direction"` // QueueDroppedMsgs counts the messages dropped from the router's queues. //metrics:The number of messages dropped from router's queues. - queueDroppedMsgs prometheus.CounterIntVec `metrics_labels:"ch_id, direction"` - - // Number of live giga p2p connections. - gigaConns prometheus.GaugeIntVec `metrics_labels:"direction"` - // Counts established giga p2p connections. - gigaNewConns prometheus.CounterIntVec `metrics_labels:"direction"` + QueueDroppedMsgs metrics.Counter `metrics_labels:"ch_id, direction"` } type metricsLabelCache struct { - mtx sync.RWMutex + mtx *sync.RWMutex messageLabelNames map[reflect.Type]string } @@ -61,7 +68,7 @@ type metricsLabelCache struct { // type that is passed in. // This method uses a map on the Metrics struct so that each label name only needs // to be produced once to prevent expensive string operations. -func (m *metricsLabelCache) ValueToMetricLabel(i any) string { +func (m *metricsLabelCache) ValueToMetricLabel(i interface{}) string { t := reflect.TypeOf(i) m.mtx.RLock() @@ -82,6 +89,7 @@ func (m *metricsLabelCache) ValueToMetricLabel(i any) string { func newMetricsLabelCache() *metricsLabelCache { return &metricsLabelCache{ + mtx: &sync.RWMutex{}, messageLabelNames: map[reflect.Type]string{}, } } diff --git a/sei-tendermint/internal/p2p/mux/metrics/metrics.gen.go b/sei-tendermint/internal/p2p/mux/metrics/metrics.gen.go deleted file mode 100644 index 47f3eff29d..0000000000 --- a/sei-tendermint/internal/p2p/mux/metrics/metrics.gen.go +++ /dev/null @@ -1,87 +0,0 @@ -// Code generated by metricsgen. DO NOT EDIT. - -package metrics - -import ( - "github.com/prometheus/client_golang/prometheus" - tmprometheus "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/prometheus" -) - -var Global = newMetrics() - -func init() { - prometheus.MustRegister( - Global.latency, - Global.inFlight, - Global.sendMsgs, - Global.recvMsgs, - Global.sendBytes, - Global.recvBytes, - ) -} - -func newMetrics() *metrics { - return &metrics{ - latency: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, - Subsystem: MetricsSubsystem, - Name: "latency", - Help: "Latency from Open() to Close() of the stream. Relevant only for short lived streams.", - Buckets: prometheus.ExponentialBuckets(0.001, 1.3, 30), - }, []string{"role", "kind"}), - inFlight: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, - Subsystem: MetricsSubsystem, - Name: "in_flight", - Help: "Number of currently open streams.", - }, []string{"role", "kind"}), - sendMsgs: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, - Subsystem: MetricsSubsystem, - Name: "send_msgs", - Help: "Number of messages sent.", - }, []string{"role", "kind"}), - recvMsgs: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, - Subsystem: MetricsSubsystem, - Name: "recv_msgs", - Help: "Number of messages received.", - }, []string{"role", "kind"}), - sendBytes: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, - Subsystem: MetricsSubsystem, - Name: "send_bytes", - Help: "Number of message bytes sent.", - }, []string{"role", "kind"}), - recvBytes: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, - Subsystem: MetricsSubsystem, - Name: "recv_bytes", - Help: "Number of message bytes received.", - }, []string{"role", "kind"}), - } -} - -func (m *metrics) latencyAt(role string, kind string) *tmprometheus.Histogram { - return m.latency.WithLabelValues(role, kind) -} - -func (m *metrics) inFlightAt(role string, kind string) *tmprometheus.GaugeInt { - return m.inFlight.WithLabelValues(role, kind) -} - -func (m *metrics) sendMsgsAt(role string, kind string) *tmprometheus.CounterInt { - return m.sendMsgs.WithLabelValues(role, kind) -} - -func (m *metrics) recvMsgsAt(role string, kind string) *tmprometheus.CounterInt { - return m.recvMsgs.WithLabelValues(role, kind) -} - -func (m *metrics) sendBytesAt(role string, kind string) *tmprometheus.CounterInt { - return m.sendBytes.WithLabelValues(role, kind) -} - -func (m *metrics) recvBytesAt(role string, kind string) *tmprometheus.CounterInt { - return m.recvBytes.WithLabelValues(role, kind) -} diff --git a/sei-tendermint/internal/p2p/mux/metrics/metrics.go b/sei-tendermint/internal/p2p/mux/metrics/metrics.go deleted file mode 100644 index f5dc3ea496..0000000000 --- a/sei-tendermint/internal/p2p/mux/metrics/metrics.go +++ /dev/null @@ -1,84 +0,0 @@ -package metrics - -import ( - "time" - - "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" - tmprometheus "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/prometheus" -) - -const MetricsNamespace = "tendermint" -const MetricsSubsystem = "internal_p2p_mux" - -//go:generate go run github.com/sei-protocol/sei-chain/sei-tendermint/scripts/metricsgen -struct=metrics -type metrics struct { - // Latency from Open() to Close() of the stream. Relevant only for short lived streams. - latency tmprometheus.HistogramVec `metrics_labels:"role, kind" metrics_buckets:"exp(0.001, 1.3, 30)"` - // Number of currently open streams. - inFlight tmprometheus.GaugeIntVec `metrics_labels:"role, kind"` - // Number of messages sent. - sendMsgs tmprometheus.CounterIntVec `metrics_labels:"role, kind"` - // Number of messages received. - recvMsgs tmprometheus.CounterIntVec `metrics_labels:"role, kind"` - // Number of message bytes sent. - sendBytes tmprometheus.CounterIntVec `metrics_labels:"role, kind"` - // Number of message bytes received. - recvBytes tmprometheus.CounterIntVec `metrics_labels:"role, kind"` -} - -type Role string - -const RoleAccept = Role("accept") -const RoleConnect = Role("connect") - -type Metrics struct { - latency *tmprometheus.Histogram - inFlight *tmprometheus.GaugeInt - sendMsgs *tmprometheus.CounterInt - recvMsgs *tmprometheus.CounterInt - sendBytes *tmprometheus.CounterInt - recvBytes *tmprometheus.CounterInt -} - -func Get(role Role, kind string) *Metrics { - return &Metrics{ - latency: Global.latencyAt(string(role), kind), - inFlight: Global.inFlightAt(string(role), kind), - sendMsgs: Global.sendMsgsAt(string(role), kind), - recvMsgs: Global.recvMsgsAt(string(role), kind), - sendBytes: Global.sendBytesAt(string(role), kind), - recvBytes: Global.recvBytesAt(string(role), kind), - } -} - -type Stream struct { - m *Metrics - start utils.Option[time.Time] -} - -func NewStream(m *Metrics) *Stream { return &Stream{m: m} } - -func (s *Stream) Open() { - if s.start.IsPresent() { - return - } - s.start = utils.Some(time.Now()) - s.m.inFlight.Add(1) -} - -func (s *Stream) Send(size int) { - s.m.sendMsgs.Add(1) - s.m.sendBytes.Add(int64(size)) -} - -func (s *Stream) Recv(size int) { - s.m.recvMsgs.Add(1) - s.m.recvBytes.Add(int64(size)) -} - -func (s *Stream) Close() { - if start, ok := s.start.Get(); ok { - s.m.inFlight.Add(-1) - s.m.latency.Observe(time.Since(start).Seconds()) - } -} diff --git a/sei-tendermint/internal/p2p/mux/mux.go b/sei-tendermint/internal/p2p/mux/mux.go index f31758736a..ad7cf1ec77 100644 --- a/sei-tendermint/internal/p2p/mux/mux.go +++ b/sei-tendermint/internal/p2p/mux/mux.go @@ -30,7 +30,6 @@ import ( "fmt" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p/conn" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p/mux/metrics" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p/mux/pb" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/protoutils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" @@ -58,8 +57,6 @@ type Config struct { } type StreamKindConfig struct { - // Name of the stream kind. Used for metrics. - Name string // Maximal number of concurrent outbound streams. MaxConnects uint64 // Maximal number of concurrent inbound streams. @@ -102,9 +99,6 @@ type frame struct { type kindState struct { connectsQueue chan *streamState acceptsQueue chan *streamState - - connectMetrics *metrics.Metrics - acceptMetrics *metrics.Metrics } type runnerInner struct { @@ -153,18 +147,18 @@ func (r *runner) getOrAccept(h *pb.Header) (*streamState, error) { return nil, errTooManyAccepts } inner.acceptsSem[kind] -= 1 - s := newStreamState(id, kind, r.mux.kinds[kind].acceptMetrics) + s := newStreamState(id, kind) inner.streams[id] = s return s, nil } panic("unreachable") } -func (r *runner) newConnectStream(kind StreamKind, inner *runnerInner) *streamState { +func (i *runnerInner) newConnectStream(kind StreamKind) *streamState { // Non-blocking since we just closed a connect Stream. - s := newStreamState(inner.nextID, kind, r.mux.kinds[kind].connectMetrics) - inner.streams[s.id] = s - inner.nextID += 2 + s := newStreamState(i.nextID, kind) + i.streams[s.id] = s + i.nextID += 2 return s } @@ -184,7 +178,7 @@ func (r *runner) tryPrune(id streamID) { delete(inner.streams, id) // Free the stream capacity. if id.isConnect() { - r.mux.kinds[s.kind].connectsQueue <- r.newConnectStream(s.kind, inner) + r.mux.kinds[s.kind].connectsQueue <- inner.newConnectStream(s.kind) } else { inner.acceptsSem[s.kind] += 1 } @@ -363,7 +357,7 @@ func (m *Mux) Run(ctx context.Context, conn conn.Conn) error { } inner.acceptsSem[kind] = min(cfg.MaxAccepts, remCfg.MaxConnects) for range min(cfg.MaxConnects, remCfg.MaxAccepts) { - m.kinds[kind].connectsQueue <- r.newConnectStream(kind, inner) + m.kinds[kind].connectsQueue <- inner.newConnectStream(kind) } } } @@ -436,9 +430,6 @@ func NewMux(cfg *Config) *Mux { kinds[kind] = &kindState{ acceptsQueue: make(chan *streamState, c.MaxAccepts), connectsQueue: make(chan *streamState, c.MaxConnects), - - acceptMetrics: metrics.Get(metrics.RoleAccept, c.Name), - connectMetrics: metrics.Get(metrics.RoleConnect, c.Name), } } queue := utils.NewWatch(queue{}) diff --git a/sei-tendermint/internal/p2p/mux/stream.go b/sei-tendermint/internal/p2p/mux/stream.go index 9f9b03a378..c0e767586b 100644 --- a/sei-tendermint/internal/p2p/mux/stream.go +++ b/sei-tendermint/internal/p2p/mux/stream.go @@ -50,7 +50,6 @@ func (s *Stream) open(ctx context.Context, maxMsgSize uint64, window uint64) err ctrl.Updated() return err } - inner.metrics.Open() } return nil } @@ -92,7 +91,6 @@ func (s *Stream) Send(ctx context.Context, msg []byte) error { f.Header.PayloadSize = utils.Alloc(uint64(len(msg))) f.Header.MsgEnd = utils.Alloc(true) } - inner.metrics.Send(len(msg)) } return nil } @@ -102,7 +100,6 @@ func (s *Stream) close(inner *streamStateInner) { return } inner.closed.local = true - inner.metrics.Close() for queue, ctrl := range s.queue.Lock() { if len(queue) == 0 { ctrl.Updated() @@ -153,7 +150,6 @@ func (s *Stream) Recv(ctx context.Context, freeBuffer bool) ([]byte, error) { f.Header.WindowEnd = utils.Alloc(inner.recv.end) } } - inner.metrics.Recv(len(msg)) return msg, nil } panic("unreachable") diff --git a/sei-tendermint/internal/p2p/mux/stream_state.go b/sei-tendermint/internal/p2p/mux/stream_state.go index 532451185c..f4685615e3 100644 --- a/sei-tendermint/internal/p2p/mux/stream_state.go +++ b/sei-tendermint/internal/p2p/mux/stream_state.go @@ -3,7 +3,6 @@ package mux import ( "fmt" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p/mux/metrics" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) @@ -40,10 +39,9 @@ type recvState struct { } type streamStateInner struct { - send sendState - recv recvState - closed closeState - metrics *metrics.Stream + send sendState + recv recvState + closed closeState } type streamState struct { @@ -52,11 +50,11 @@ type streamState struct { inner utils.Watch[*streamStateInner] } -func newStreamState(id streamID, kind StreamKind, m *metrics.Metrics) *streamState { +func newStreamState(id streamID, kind StreamKind) *streamState { return &streamState{ id: id, kind: kind, - inner: utils.NewWatch(&streamStateInner{metrics: metrics.NewStream(m)}), + inner: utils.NewWatch(&streamStateInner{}), } } diff --git a/sei-tendermint/internal/p2p/router.go b/sei-tendermint/internal/p2p/router.go index f5e7bba709..e81011a3f7 100644 --- a/sei-tendermint/internal/p2p/router.go +++ b/sei-tendermint/internal/p2p/router.go @@ -32,7 +32,8 @@ type ConnSet = connSet[*ConnV2] type Router struct { *service.BaseService - lc *metricsLabelCache + metrics *Metrics + lc *metricsLabelCache options *RouterOptions privKey NodeSecretKey @@ -60,6 +61,7 @@ func (r *Router) getChannelDescs() []*conn.ChannelDescriptor { // NewRouter creates a new Router. func NewRouter( + metrics *Metrics, privKey NodeSecretKey, nodeInfoProducer func() *types.NodeInfo, db dbm.DB, @@ -90,6 +92,7 @@ func NewRouter( return nil, fmt.Errorf("peerManager.PushPex(initialAddrs): %w", err) } router := &Router{ + metrics: metrics, lc: newMetricsLabelCache(), privKey: privKey, nodeInfoProducer: nodeInfoProducer, @@ -189,7 +192,7 @@ func (r *Router) acceptPeersRoutine(ctx context.Context) error { if err != nil { return err } - Global.newConnectionsAt("in", "true").Add(1) + r.metrics.NewConnections.With("direction", "in", "success", "true").Add(1) addr := tcpConn.RemoteAddr() // Spawn a goroutine per connection. s.Spawn(func() error { @@ -354,7 +357,7 @@ func (r *Router) metricsRoutine(ctx context.Context) error { if err := utils.Sleep(ctx, 10*time.Second); err != nil { return err } - Global.peersAt().Set(int64(r.peerManager.Conns().Len())) + r.metrics.Peers.Set(float64(r.peerManager.Conns().Len())) r.peerManager.LogState() } } @@ -376,7 +379,7 @@ func (r *Router) dial(ctx context.Context, addrs []NodeAddress) (_ tcp.Conn, err if err != nil { success = "false" } - Global.newConnectionsAt("out", success).Add(1) + r.metrics.NewConnections.With("direction", "out", "success", success).Add(1) }() resolveCtx := ctx if d, ok := r.options.ResolveTimeout.Get(); ok { diff --git a/sei-tendermint/internal/p2p/router_test.go b/sei-tendermint/internal/p2p/router_test.go index 4f5b56b1cd..d660b4f7a7 100644 --- a/sei-tendermint/internal/p2p/router_test.go +++ b/sei-tendermint/internal/p2p/router_test.go @@ -259,6 +259,7 @@ func TestRouter_PexOnHandshake_ListenerPeersPropagated(t *testing.T) { func makeRouterWithOptionsAndKey(opts *RouterOptions, key NodeSecretKey) *Router { info := makeInfo(key) return utils.OrPanic1(NewRouter( + NopMetrics(), key, func() *types.NodeInfo { return &info }, dbm.NewMemDB(), @@ -755,6 +756,7 @@ func TestRouter_PeerDB(t *testing.T) { err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { t.Logf("start the second node") r2 := utils.OrPanic1(NewRouter( + NopMetrics(), key, func() *types.NodeInfo { return &info }, db, @@ -784,6 +786,7 @@ func TestRouter_PeerDB(t *testing.T) { t.Logf("restart the second node") r2 := utils.OrPanic1(NewRouter( + NopMetrics(), key, func() *types.NodeInfo { return &info }, db, diff --git a/sei-tendermint/internal/p2p/rpc/rpc.go b/sei-tendermint/internal/p2p/rpc/rpc.go index 795473694a..4a09658043 100644 --- a/sei-tendermint/internal/p2p/rpc/rpc.go +++ b/sei-tendermint/internal/p2p/rpc/rpc.go @@ -43,7 +43,6 @@ type RPC[API any, Req, Resp protoutils.Sized] struct { type rpcConfig struct { limit Limit - name string } type service map[mux.StreamKind]*rpcConfig @@ -52,7 +51,6 @@ func (s service) muxServerConfig() *mux.Config { kinds := map[mux.StreamKind]*mux.StreamKindConfig{} for kind, rpc := range s { kinds[kind] = &mux.StreamKindConfig{ - Name: rpc.name, MaxConnects: rpc.limit.Concurrent, } } @@ -72,7 +70,7 @@ func (s service) muxClientConfig() *mux.Config { return cfg } -func Register[API any, Req, Resp protoutils.Sized](kind mux.StreamKind, name string, limit Limit, req Msg[Req], resp Msg[Resp]) *RPC[API, Req, Resp] { +func Register[API any, Req, Resp protoutils.Sized](kind mux.StreamKind, limit Limit, req Msg[Req], resp Msg[Resp]) *RPC[API, Req, Resp] { t := reflect.TypeFor[API]() if _, ok := registry[t]; !ok { registry[t] = service{} @@ -88,16 +86,8 @@ func Register[API any, Req, Resp protoutils.Sized](kind mux.StreamKind, name str if err := resp.Verify(); err != nil { panic(fmt.Errorf("RPC %v: %w", kind, err)) } - service[kind] = &rpcConfig{ - limit: limit, - name: name, - } - return &RPC[API, Req, Resp]{ - Kind: kind, - Limit: limit, - Req: req, - Resp: resp, - } + service[kind] = &rpcConfig{limit: limit} + return &RPC[API, Req, Resp]{kind, limit, req, resp} } type Server[API any] struct{ mux *mux.Mux } @@ -116,7 +106,7 @@ func NewClient[API any]() Client[API] { func (c Client[API]) Run(ctx context.Context, conn conn.Conn) error { return c.mux.Run(ctx, conn) } -// TODO: add client-side rate limiting. +// TODO: add client-size rate limiting. func (r *RPC[API, Req, Resp]) Call(ctx context.Context, client Client[API]) (Stream[Req, Resp], error) { s, err := client.mux.Accept(ctx, r.Kind, uint64(r.Resp.MsgSize), uint64(r.Resp.Window)) if err != nil { @@ -134,12 +124,11 @@ func (r *RPC[API, Req, Resp]) Serve(ctx context.Context, server Server[API], han if err := limiter.Wait(ctx); err != nil { return err } - muxStream, err := server.mux.Connect(ctx, r.Kind, uint64(r.Req.MsgSize), uint64(r.Req.Window)) //nolint:gosec // MsgSize and Window are validated config values + stream, err := server.mux.Connect(ctx, r.Kind, uint64(r.Req.MsgSize), uint64(r.Req.Window)) //nolint:gosec // MsgSize and Window are validated config values if err != nil { return err } - stream := Stream[Resp, Req]{inner: muxStream} - err = handler(ctx, stream) + err = handler(ctx, Stream[Resp, Req]{inner: stream}) stream.Close() if err != nil { return err @@ -155,11 +144,9 @@ func (r *RPC[API, Req, Resp]) Serve(ctx context.Context, server Server[API], han type Stream[SendT, RecvT protoutils.Message] struct{ inner *mux.Stream } func (s Stream[SendT, RecvT]) Close() { s.inner.Close() } - func (s Stream[SendT, RecvT]) Send(ctx context.Context, msg SendT) error { return s.inner.Send(ctx, protoutils.Marshal(msg)) } - func (s Stream[SendT, RecvT]) Recv(ctx context.Context) (RecvT, error) { raw, err := s.inner.Recv(ctx, true) if err != nil { diff --git a/sei-tendermint/internal/p2p/testonly.go b/sei-tendermint/internal/p2p/testonly.go index a42251b11c..8ecab80641 100644 --- a/sei-tendermint/internal/p2p/testonly.go +++ b/sei-tendermint/internal/p2p/testonly.go @@ -311,6 +311,7 @@ func (n *TestNetwork) MakeNode(t *testing.T, opts TestNodeOptions) *TestNode { } router, err := NewRouter( + NopMetrics(), privKey, func() *types.NodeInfo { return &nodeInfo }, dbm.NewMemDB(), diff --git a/sei-tendermint/internal/p2p/transport.go b/sei-tendermint/internal/p2p/transport.go index b1b59c9896..db228fa99b 100644 --- a/sei-tendermint/internal/p2p/transport.go +++ b/sei-tendermint/internal/p2p/transport.go @@ -52,7 +52,7 @@ func (r *Router) connSendRoutine(ctx context.Context, conn *ConnV2) error { if err != nil { return err } - Global.routerPeerQueueRecvAt().Observe(time.Since(start).Seconds()) + r.metrics.RouterPeerQueueRecv.Observe(time.Since(start).Seconds()) bz, err := gogoproto.Marshal(m.Message) if err != nil { panic(fmt.Sprintf("proto.Marshal(): %v", err)) @@ -92,13 +92,12 @@ func (r *Router) connRecvRoutine(ctx context.Context, conn *ConnV2) error { } // Priority is not used since all messages in this queue are from the same channel. if _, ok := ch.recvQueue.Send(RecvMsg[gogoproto.Message]{From: conn.ID, Message: msg}, gogoproto.Size(msg), 0).Get(); ok { - Global.queueDroppedMsgsAt(fmt.Sprint(chID), "in").Add(1) + r.metrics.QueueDroppedMsgs.With("ch_id", fmt.Sprint(chID), "direction", "in").Add(float64(1)) } - Global.peerReceiveBytesTotalAt( - string(conn.ID), - fmt.Sprint(chID), - r.lc.ValueToMetricLabel(msg), - ).Add(int64(gogoproto.Size(msg))) + r.metrics.PeerReceiveBytesTotal.With( + "chID", fmt.Sprint(chID), + "peer_id", string(conn.ID), + "message_type", r.lc.ValueToMetricLabel(msg)).Add(float64(gogoproto.Size(msg))) logger.Debug("received message", "peer", conn.ID, "message", msg) } } diff --git a/sei-tendermint/internal/proxy/metrics.gen.go b/sei-tendermint/internal/proxy/metrics.gen.go index 34c1e44e17..ea483f83db 100644 --- a/sei-tendermint/internal/proxy/metrics.gen.go +++ b/sei-tendermint/internal/proxy/metrics.gen.go @@ -3,30 +3,30 @@ package proxy import ( - "github.com/prometheus/client_golang/prometheus" - tmprometheus "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/prometheus" + "github.com/go-kit/kit/metrics/discard" + prometheus "github.com/go-kit/kit/metrics/prometheus" + stdprometheus "github.com/prometheus/client_golang/prometheus" ) -var Global = NewMetrics() - -func init() { - prometheus.MustRegister( - Global.MethodTiming, - ) -} - -func NewMetrics() *Metrics { +func PrometheusMetrics(namespace string, labelsAndValues ...string) *Metrics { + labels := []string{} + for i := 0; i < len(labelsAndValues); i += 2 { + labels = append(labels, labelsAndValues[i]) + } return &Metrics{ - MethodTiming: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, + MethodTiming: prometheus.NewHistogramFrom(stdprometheus.HistogramOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "method_timing", Help: "Timing for each ABCI method.", - Buckets: []float64{.0001, .0004, .002, .009, .02, .1, .65, 2, 6, 25}, - }, []string{"method", "type"}), + + Buckets: []float64{.0001, .0004, .002, .009, .02, .1, .65, 2, 6, 25}, + }, append(labels, "method", "type")).With(labelsAndValues...), } } -func (m *Metrics) MethodTimingAt(l0_method string, l1_type string) *tmprometheus.Histogram { - return m.MethodTiming.WithLabelValues(l0_method, l1_type) +func NopMetrics() *Metrics { + return &Metrics{ + MethodTiming: discard.NewHistogram(), + } } diff --git a/sei-tendermint/internal/proxy/metrics.go b/sei-tendermint/internal/proxy/metrics.go index cbd9f79c3d..5fac10e70d 100644 --- a/sei-tendermint/internal/proxy/metrics.go +++ b/sei-tendermint/internal/proxy/metrics.go @@ -1,10 +1,8 @@ package proxy -import tmprometheus "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/prometheus" +import "github.com/go-kit/kit/metrics" const ( - // MetricsNamespace is the namespace shared by all Tendermint Prometheus metrics. - MetricsNamespace = "tendermint" // MetricsSubsystem is a subsystem shared by all metrics exposed by this package. MetricsSubsystem = "abci_connection" ) @@ -14,5 +12,5 @@ const ( // Metrics contains the prometheus metrics exposed by Proxy. type Metrics struct { // Timing for each ABCI method. - MethodTiming tmprometheus.HistogramVec `metrics_buckets:".0001,.0004,.002,.009,.02,.1,.65,2,6,25" metrics_labels:"method, type"` + MethodTiming metrics.Histogram `metrics_bucketsizes:".0001,.0004,.002,.009,.02,.1,.65,2,6,25" metrics_labels:"method, type"` } diff --git a/sei-tendermint/internal/proxy/proxy.go b/sei-tendermint/internal/proxy/proxy.go index 60d57ea691..7c5aa7349d 100644 --- a/sei-tendermint/internal/proxy/proxy.go +++ b/sei-tendermint/internal/proxy/proxy.go @@ -7,59 +7,60 @@ import ( "time" "github.com/ethereum/go-ethereum/common" + "github.com/go-kit/kit/metrics" "github.com/holiman/uint256" - "github.com/prometheus/client_golang/prometheus" "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" ) // Proxy wraps an ABCI application and records ABCI method timings. type Proxy struct { - app types.Application + app types.Application + metrics *Metrics } // New creates a proxied application interface around the provided ABCI application. -func New(app types.Application) *Proxy { - return &Proxy{app: app} +func New(app types.Application, metrics *Metrics) *Proxy { + return &Proxy{app: app, metrics: metrics} } func (app *Proxy) InitChain(ctx context.Context, req *types.RequestInitChain) (*types.ResponseInitChain, error) { - defer addTimeSample(Global.MethodTimingAt("init_chain", "sync"))() + defer addTimeSample(app.metrics.MethodTiming.With("method", "init_chain", "type", "sync"))() return app.app.InitChain(ctx, req) } func (app *Proxy) ProcessProposal(ctx context.Context, req *types.RequestProcessProposal) (*types.ResponseProcessProposal, error) { - defer addTimeSample(Global.MethodTimingAt("process_proposal", "sync"))() + defer addTimeSample(app.metrics.MethodTiming.With("method", "process_proposal", "type", "sync"))() return app.app.ProcessProposal(ctx, req) } func (app *Proxy) FinalizeBlock(ctx context.Context, req *types.RequestFinalizeBlock) (*types.ResponseFinalizeBlock, error) { - defer addTimeSample(Global.MethodTimingAt("finalize_block", "sync"))() + defer addTimeSample(app.metrics.MethodTiming.With("method", "finalize_block", "type", "sync"))() return app.app.FinalizeBlock(ctx, req) } func (app *Proxy) GetTxPriorityHint(ctx context.Context, req *types.RequestGetTxPriorityHintV2) (*types.ResponseGetTxPriorityHint, error) { - defer addTimeSample(Global.MethodTimingAt("get_tx_priority", "sync"))() + defer addTimeSample(app.metrics.MethodTiming.With("method", "get_tx_priority", "type", "sync"))() return app.app.GetTxPriorityHint(ctx, req) } func (app *Proxy) EvmNonce(addr common.Address) uint64 { - defer addTimeSample(Global.MethodTimingAt("evm_nonce", "sync"))() + defer addTimeSample(app.metrics.MethodTiming.With("method", "evm_nonce", "type", "sync"))() return app.app.EvmNonce(addr) } func (app *Proxy) EvmBalance(addr common.Address, seiAddr []byte) uint256.Int { - defer addTimeSample(Global.MethodTimingAt("evm_balance", "sync"))() + defer addTimeSample(app.metrics.MethodTiming.With("method", "evm_balance", "type", "sync"))() return app.app.EvmBalance(addr, seiAddr) } func (app *Proxy) Commit(ctx context.Context) (*types.ResponseCommit, error) { - defer addTimeSample(Global.MethodTimingAt("commit", "sync"))() + defer addTimeSample(app.metrics.MethodTiming.With("method", "commit", "type", "sync"))() return app.app.Commit(ctx) } func (app *Proxy) CheckTxSafe(ctx context.Context, req *types.RequestCheckTxV2) (res *types.ResponseCheckTxV2, err error) { - defer addTimeSample(Global.MethodTimingAt("check_tx", "sync"))() + defer addTimeSample(app.metrics.MethodTiming.With("method", "check_tx", "type", "sync"))() defer func() { if r := recover(); r != nil { err = fmt.Errorf("panic recovered in CheckTxSafe: %v\n%v", r, string(debug.Stack())) @@ -81,12 +82,12 @@ func (app *Proxy) CheckTxSafe(ctx context.Context, req *types.RequestCheckTxV2) } func (app *Proxy) Info(ctx context.Context, req *types.RequestInfo) (*types.ResponseInfo, error) { - defer addTimeSample(Global.MethodTimingAt("info", "sync"))() + defer addTimeSample(app.metrics.MethodTiming.With("method", "info", "type", "sync"))() return app.app.Info(ctx, req) } func (app *Proxy) Query(ctx context.Context, req *types.RequestQuery) (*types.ResponseQuery, error) { - defer addTimeSample(Global.MethodTimingAt("query", "sync"))() + defer addTimeSample(app.metrics.MethodTiming.With("method", "query", "type", "sync"))() return app.app.Query(ctx, req) } @@ -99,22 +100,22 @@ func (app *Proxy) LastBlockHeight() int64 { } func (app *Proxy) ListSnapshots(ctx context.Context, req *types.RequestListSnapshots) (*types.ResponseListSnapshots, error) { - defer addTimeSample(Global.MethodTimingAt("list_snapshots", "sync"))() + defer addTimeSample(app.metrics.MethodTiming.With("method", "list_snapshots", "type", "sync"))() return app.app.ListSnapshots(ctx, req) } func (app *Proxy) OfferSnapshot(ctx context.Context, req *types.RequestOfferSnapshot) (*types.ResponseOfferSnapshot, error) { - defer addTimeSample(Global.MethodTimingAt("offer_snapshot", "sync"))() + defer addTimeSample(app.metrics.MethodTiming.With("method", "offer_snapshot", "type", "sync"))() return app.app.OfferSnapshot(ctx, req) } func (app *Proxy) LoadSnapshotChunk(ctx context.Context, req *types.RequestLoadSnapshotChunk) (*types.ResponseLoadSnapshotChunk, error) { - defer addTimeSample(Global.MethodTimingAt("load_snapshot_chunk", "sync"))() + defer addTimeSample(app.metrics.MethodTiming.With("method", "load_snapshot_chunk", "type", "sync"))() return app.app.LoadSnapshotChunk(ctx, req) } func (app *Proxy) ApplySnapshotChunk(ctx context.Context, req *types.RequestApplySnapshotChunk) (*types.ResponseApplySnapshotChunk, error) { - defer addTimeSample(Global.MethodTimingAt("apply_snapshot_chunk", "sync"))() + defer addTimeSample(app.metrics.MethodTiming.With("method", "apply_snapshot_chunk", "type", "sync"))() return app.app.ApplySnapshotChunk(ctx, req) } @@ -122,7 +123,7 @@ func (app *Proxy) ApplySnapshotChunk(ctx context.Context, req *types.RequestAppl // The observation added to m is the number of seconds ellapsed since addTimeSample // was initially called. addTimeSample is meant to be called in a defer to calculate // the amount of time a function takes to complete. -func addTimeSample(m prometheus.Observer) func() { +func addTimeSample(m metrics.Histogram) func() { start := time.Now() return func() { m.Observe(time.Since(start).Seconds()) } } diff --git a/sei-tendermint/internal/proxy/proxy_test.go b/sei-tendermint/internal/proxy/proxy_test.go index c10e4a8698..bab1025a56 100644 --- a/sei-tendermint/internal/proxy/proxy_test.go +++ b/sei-tendermint/internal/proxy/proxy_test.go @@ -25,7 +25,7 @@ func TestCheckTxSafeReturnsErrorOnPanic(t *testing.T) { checkTx: func(context.Context, *types.RequestCheckTxV2) *types.ResponseCheckTxV2 { panic("boom") }, - }) + }, NopMetrics()) _, err := proxyApp.CheckTxSafe(t.Context(), &types.RequestCheckTxV2{Tx: []byte("tx")}) require.Error(t, err) } @@ -47,7 +47,7 @@ func TestCheckTxSafeReturnsErrorOnMissingEVMHash(t *testing.T) { res.EVMHash = common.Hash{} return res }, - }) + }, NopMetrics()) _, err := proxyApp.CheckTxSafe(t.Context(), &types.RequestCheckTxV2{Tx: []byte("tx")}) require.Error(t, err) } @@ -59,7 +59,7 @@ func TestCheckTxSafeReturnsErrorOnMissingSeiSenderAddress(t *testing.T) { res.SeiSenderAddress = nil return res }, - }) + }, NopMetrics()) _, err := proxyApp.CheckTxSafe(t.Context(), &types.RequestCheckTxV2{Tx: []byte("tx")}) require.Error(t, err) } @@ -69,7 +69,7 @@ func TestCheckTxSafeAllowsValidEVMResponse(t *testing.T) { checkTx: func(context.Context, *types.RequestCheckTxV2) *types.ResponseCheckTxV2 { return validEVMResponse() }, - }) + }, NopMetrics()) _, err := proxyApp.CheckTxSafe(t.Context(), &types.RequestCheckTxV2{Tx: []byte("tx")}) require.NoError(t, err) } diff --git a/sei-tendermint/internal/state/errors.go b/sei-tendermint/internal/state/errors.go index d35160b617..516b20e5f5 100644 --- a/sei-tendermint/internal/state/errors.go +++ b/sei-tendermint/internal/state/errors.go @@ -49,14 +49,6 @@ type ( ErrNoFinalizeBlockResponsesForHeight struct { Height int64 } - - // ErrEmptyValidatorSet flags a persisted validator set that is empty past - // genesis — possible only through corruption, since a committed block - // implies a non-empty set. Empty is legitimate only for a genesis-derived - // state (validators arrive at InitChain, or via state sync). - ErrEmptyValidatorSet struct { - Height int64 - } ) func (e ErrUnknownBlock) Error() string { @@ -110,10 +102,6 @@ func (e ErrNoConsensusParamsForHeight) Error() string { return fmt.Sprintf("could not find consensus params for height #%d", e.Height) } -func (e ErrEmptyValidatorSet) Error() string { - return fmt.Sprintf("validator set at height #%d is empty", e.Height) -} - func (e ErrNoFinalizeBlockResponsesForHeight) Error() string { return fmt.Sprintf("could not find FinalizeBlock responses for height #%d", e.Height) } diff --git a/sei-tendermint/internal/state/execution.go b/sei-tendermint/internal/state/execution.go index 0dfbf70063..23b35dae0a 100644 --- a/sei-tendermint/internal/state/execution.go +++ b/sei-tendermint/internal/state/execution.go @@ -52,6 +52,8 @@ type BlockExecutor struct { mempool *mempool.TxMempool evpool EvidencePool + metrics *Metrics + // consensusPolicy is a compile-time validation bypass that only takes // effect in mock_block_validation builds; production binaries always see // the zero-value (no bypass). Distinct from types.SkipLastResultsHashValidation @@ -70,6 +72,7 @@ func NewBlockExecutor( evpool EvidencePool, blockStore BlockStore, eventBus *eventbus.EventBus, + metrics *Metrics, consensusPolicy types.ConsensusPolicy, ) *BlockExecutor { return &BlockExecutor{ @@ -78,6 +81,7 @@ func NewBlockExecutor( app: app, mempool: pool, evpool: evpool, + metrics: metrics, cache: make(map[string]struct{}), blockStore: blockStore, consensusPolicy: consensusPolicy, @@ -205,7 +209,7 @@ func (blockExec *BlockExecutor) ApplyBlock(ctx context.Context, state State, blo } startTime := time.Now() defer func() { - Global.BlockProcessingTimeAt().Observe(time.Since(startTime).Seconds()) + blockExec.metrics.BlockProcessingTime.Observe(time.Since(startTime).Seconds()) }() var finalizeBlockSpan otrace.Span = nil if tracer != nil { @@ -224,7 +228,7 @@ func (blockExec *BlockExecutor) ApplyBlock(ctx context.Context, state State, blo Header: block.Header.ToProto(), }, ) - Global.FinalizeBlockLatencyAt().Observe(float64(time.Since(finalizeBlockStartTime).Milliseconds())) + blockExec.metrics.FinalizeBlockLatency.Observe(float64(time.Since(finalizeBlockStartTime).Milliseconds())) if finalizeBlockSpan != nil { finalizeBlockSpan.End() } @@ -248,7 +252,7 @@ func (blockExec *BlockExecutor) ApplyBlock(ctx context.Context, state State, blo // Save the results before we commit. saveBlockResponseTime := time.Now() err = blockExec.store.SaveFinalizeBlockResponses(block.Height, fBlockRes) - Global.SaveBlockResponseLatencyAt().Observe(float64(time.Since(saveBlockResponseTime).Milliseconds())) + blockExec.metrics.SaveBlockResponseLatency.Observe(float64(time.Since(saveBlockResponseTime).Milliseconds())) if err != nil && !errors.Is(err, ErrNoFinalizeBlockResponsesForHeight{block.Height}) { // It is correct to have an empty ResponseFinalizeBlock for ApplyBlock, // but not for saving it to the state store @@ -270,10 +274,10 @@ func (blockExec *BlockExecutor) ApplyBlock(ctx context.Context, state State, blo } if len(validatorUpdates) > 0 { logger.Debug("updates to validators", "updates", types.ValidatorListString(validatorUpdates)) - Global.ValidatorSetUpdatesAt().Add(1) + blockExec.metrics.ValidatorSetUpdates.Add(1) } if fBlockRes.ConsensusParamUpdates != nil { - Global.ConsensusParamUpdatesAt().Add(1) + blockExec.metrics.ConsensusParamUpdates.Add(1) } // Update the state with the block and responses. @@ -347,8 +351,8 @@ func (blockExec *BlockExecutor) ApplyBlock(ctx context.Context, state State, blo if block.Height%proposerPriorityHashInterval == 0 { if full := state.Validators.ProposerPriorityHash(); len(full) >= 8 { packed := binary.BigEndian.Uint64(full[:8]) - Global.ProposerPriorityHashAt().Set(float64(packed)) - Global.ProposerPriorityHashHeightAt().Set(block.Height) + blockExec.metrics.ProposerPriorityHash.Set(float64(packed)) + blockExec.metrics.ProposerPriorityHashHeight.Set(float64(block.Height)) // Log both the full 32-byte hash (for unambiguous comparison) // and the packed value (to correlate with the Prometheus gauge). logger.Info("proposer priority hash checkpoint", @@ -399,7 +403,7 @@ func (blockExec *BlockExecutor) ApplyBlock(ctx context.Context, state State, blo if err := blockExec.store.Save(state); err != nil { return state, err } - Global.SaveBlockLatencyAt().Observe(float64(time.Since(saveBlockTime).Milliseconds())) + blockExec.metrics.SaveBlockLatency.Observe(float64(time.Since(saveBlockTime).Milliseconds())) if saveBlockSpan != nil { saveBlockSpan.End() } @@ -418,7 +422,7 @@ func (blockExec *BlockExecutor) ApplyBlock(ctx context.Context, state State, blo logger.Debug("pruned blocks", "pruned", pruned, "retain_height", retainHeight) } } - Global.PruneBlockLatencyAt().Observe(float64(time.Since(pruneBlockTime).Milliseconds())) + blockExec.metrics.PruneBlockLatency.Observe(float64(time.Since(pruneBlockTime).Milliseconds())) if pruneBlockSpan != nil { pruneBlockSpan.End() } @@ -434,7 +438,7 @@ func (blockExec *BlockExecutor) ApplyBlock(ctx context.Context, state State, blo } fireEventsStartTime := time.Now() FireEvents(blockExec.eventBus, block, blockID, fBlockRes, validatorUpdates) - Global.FireEventsLatencyAt().Observe(float64(time.Since(fireEventsStartTime).Milliseconds())) + blockExec.metrics.FireEventsLatency.Observe(float64(time.Since(fireEventsStartTime).Milliseconds())) if fireEventsSpan != nil { fireEventsSpan.End() } @@ -463,7 +467,7 @@ func (blockExec *BlockExecutor) Commit( logger.Error("client error during proxyAppConn.Commit", "err", err) return 0, err } - Global.ApplicationCommitTimeAt().Observe(float64(time.Since(start))) + blockExec.metrics.ApplicationCommitTime.Observe(float64(time.Since(start))) // ResponseCommit has no error code - just data logger.Info( @@ -484,7 +488,7 @@ func (blockExec *BlockExecutor) Commit( TxConstraintsForState(state), state.ConsensusParams.ABCI.RecheckTx, ) - Global.UpdateMempoolTimeAt().Observe(float64(time.Since(start))) + blockExec.metrics.UpdateMempoolTime.Observe(float64(time.Since(start))) return res.RetainHeight, err } diff --git a/sei-tendermint/internal/state/execution_test.go b/sei-tendermint/internal/state/execution_test.go index 4e61af922c..a9bfe18063 100644 --- a/sei-tendermint/internal/state/execution_test.go +++ b/sei-tendermint/internal/state/execution_test.go @@ -2,9 +2,11 @@ package state_test import ( "context" + "encoding/binary" "testing" "time" + "github.com/go-kit/kit/metrics" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -27,6 +29,16 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/version" ) +// recordingGauge is a minimal metrics.Gauge implementation that records every +// Set call for test assertion. No labels expected. +type recordingGauge struct { + sets []float64 +} + +func (g *recordingGauge) With(labelValues ...string) metrics.Gauge { return g } +func (g *recordingGauge) Set(value float64) { g.sets = append(g.sets, value) } +func (g *recordingGauge) Add(float64) {} + var ( chainID = "execution_chain" testPartSize uint32 = 65536 @@ -43,9 +55,9 @@ func TestApplyBlock(t *testing.T) { state, stateDB, _ := makeState(t, 1, 1) stateStore := sm.NewStore(stateDB) blockStore := store.NewBlockStore(dbm.NewMemDB()) - proxyApp := proxy.New(app) + proxyApp := proxy.New(app, proxy.NopMetrics()) mp := makeTxMempool(t, proxyApp) - blockExec := sm.NewBlockExecutor(stateStore, proxyApp, mp, sm.EmptyEvidencePool{}, blockStore, eventBus, types.DefaultConsensusPolicy()) + blockExec := sm.NewBlockExecutor(stateStore, proxyApp, mp, sm.EmptyEvidencePool{}, blockStore, eventBus, sm.NopMetrics(), types.DefaultConsensusPolicy()) block := sf.MakeBlock(state, 1, new(types.Commit)) bps, err := block.MakePartSet(testPartSize) @@ -59,6 +71,62 @@ func TestApplyBlock(t *testing.T) { assert.EqualValues(t, 1, state.Version.Consensus.App, "App version wasn't updated") } +// TestApplyBlockProposerPriorityHash verifies that ApplyBlock emits the +// ProposerPriorityHash metric at heights divisible by the export interval, +// that the encoded value matches the first 6 bytes of the validator set's +// ProposerPriorityHash, and that the paired height metric is also emitted. +func TestApplyBlockProposerPriorityHash(t *testing.T) { + const interval = sm.ProposerPriorityHashInterval + + app := &testApp{} + ctx := t.Context() + + eventBus := eventbus.NewDefault() + require.NoError(t, eventBus.Start(ctx)) + + state, stateDB, privVals := makeState(t, 1, 1) + stateStore := sm.NewStore(stateDB) + blockStore := store.NewBlockStore(dbm.NewMemDB()) + proxyApp := proxy.New(app, proxy.NopMetrics()) + mp := makeTxMempool(t, proxyApp) + + evpool := &mocks.EvidencePool{} + evpool.On("PendingEvidence", mock.Anything).Return([]types.Evidence{}, 0) + evpool.On("Update", ctx, mock.Anything, mock.Anything).Return() + evpool.On("CheckEvidence", ctx, mock.Anything).Return(nil) + + hashGauge := &recordingGauge{} + heightGauge := &recordingGauge{} + testMetrics := sm.NopMetrics() + testMetrics.ProposerPriorityHash = hashGauge + testMetrics.ProposerPriorityHashHeight = heightGauge + + blockExec := sm.NewBlockExecutor(stateStore, proxyApp, mp, evpool, blockStore, eventBus, testMetrics, types.DefaultConsensusPolicy()) + + // Apply blocks 1..interval. Only height == interval should emit the metric. + var lastCommit *types.Commit = new(types.Commit) + for height := int64(1); height <= interval; height++ { + proposerAddr := state.Validators.Validators[0].Address + state, _, lastCommit = makeAndCommitGoodBlock( + ctx, t, state, height, lastCommit, proposerAddr, blockExec, privVals, nil, + ) + } + + // Expect exactly one emission at the interval boundary. + require.Len(t, hashGauge.sets, 1, "hash metric should fire exactly once at height %d", interval) + require.Len(t, heightGauge.sets, 1, "height metric should fire exactly once at height %d", interval) + + // Height metric should equal the interval. + require.Equal(t, float64(interval), heightGauge.sets[0]) + + // Hash metric should equal the first 8 bytes of ProposerPriorityHash + // packed as a big-endian uint64, cast to float64. + full := state.Validators.ProposerPriorityHash() + require.GreaterOrEqual(t, len(full), 8) + expected := binary.BigEndian.Uint64(full[:8]) + require.Equal(t, float64(expected), hashGauge.sets[0], "emitted hash value does not match first 8 bytes of ProposerPriorityHash") +} + // TestFinalizeBlockDecidedLastCommit ensures we correctly send the // DecidedLastCommit to the application. The test ensures that the // DecidedLastCommit properly reflects which validators signed the preceding @@ -88,13 +156,13 @@ func TestFinalizeBlockDecidedLastCommit(t *testing.T) { evpool.On("PendingEvidence", mock.Anything).Return([]types.Evidence{}, 0) evpool.On("Update", ctx, mock.Anything, mock.Anything).Return() evpool.On("CheckEvidence", ctx, mock.Anything).Return(nil) - proxyApp := proxy.New(app) + proxyApp := proxy.New(app, proxy.NopMetrics()) mp := makeTxMempool(t, proxyApp) eventBus := eventbus.NewDefault() require.NoError(t, eventBus.Start(ctx)) - blockExec := sm.NewBlockExecutor(stateStore, proxyApp, mp, evpool, blockStore, eventBus, types.DefaultConsensusPolicy()) + blockExec := sm.NewBlockExecutor(stateStore, proxyApp, mp, evpool, blockStore, eventBus, sm.NopMetrics(), types.DefaultConsensusPolicy()) state, _, lastCommit := makeAndCommitGoodBlock(ctx, t, state, 1, new(types.Commit), state.NextValidators.Validators[0].Address, blockExec, privVals, nil) for idx, isAbsent := range tc.absentCommitSigs { @@ -199,7 +267,7 @@ func TestFinalizeBlockByzantineValidators(t *testing.T) { evpool.On("PendingEvidence", mock.AnythingOfType("int64")).Return(ev, int64(100)) evpool.On("Update", ctx, mock.AnythingOfType("state.State"), mock.AnythingOfType("types.EvidenceList")).Return() evpool.On("CheckEvidence", ctx, mock.AnythingOfType("types.EvidenceList")).Return(nil) - proxyApp := proxy.New(app) + proxyApp := proxy.New(app, proxy.NopMetrics()) mp := makeTxMempool(t, proxyApp) eventBus := eventbus.NewDefault() @@ -207,7 +275,7 @@ func TestFinalizeBlockByzantineValidators(t *testing.T) { blockStore := store.NewBlockStore(dbm.NewMemDB()) - blockExec := sm.NewBlockExecutor(stateStore, proxyApp, mp, evpool, blockStore, eventBus, types.DefaultConsensusPolicy()) + blockExec := sm.NewBlockExecutor(stateStore, proxyApp, mp, evpool, blockStore, eventBus, sm.NopMetrics(), types.DefaultConsensusPolicy()) block := sf.MakeBlock(state, 1, new(types.Commit)) block.Evidence = ev @@ -238,7 +306,7 @@ func TestProcessProposal(t *testing.T) { eventBus := eventbus.NewDefault() require.NoError(t, eventBus.Start(ctx)) - proxyApp := proxy.New(app) + proxyApp := proxy.New(app, proxy.NopMetrics()) mp := makeTxMempool(t, proxyApp) blockExec := sm.NewBlockExecutor( stateStore, @@ -247,6 +315,7 @@ func TestProcessProposal(t *testing.T) { sm.EmptyEvidencePool{}, blockStore, eventBus, + sm.NopMetrics(), types.DefaultConsensusPolicy(), ) @@ -431,7 +500,7 @@ func TestFinalizeBlockValidatorUpdates(t *testing.T) { state, stateDB, _ := makeState(t, 1, 1) stateStore := sm.NewStore(stateDB) blockStore := store.NewBlockStore(dbm.NewMemDB()) - proxyApp := proxy.New(app) + proxyApp := proxy.New(app, proxy.NopMetrics()) mp := makeTxMempool(t, proxyApp) eventBus := eventbus.NewDefault() @@ -444,6 +513,7 @@ func TestFinalizeBlockValidatorUpdates(t *testing.T) { sm.EmptyEvidencePool{}, blockStore, eventBus, + sm.NopMetrics(), types.DefaultConsensusPolicy(), ) @@ -499,7 +569,7 @@ func TestFinalizeBlockValidatorUpdatesResultingInEmptySet(t *testing.T) { state, stateDB, _ := makeState(t, 1, 1) stateStore := sm.NewStore(stateDB) blockStore := store.NewBlockStore(dbm.NewMemDB()) - proxyApp := proxy.New(app) + proxyApp := proxy.New(app, proxy.NopMetrics()) mp := makeTxMempool(t, proxyApp) blockExec := sm.NewBlockExecutor( stateStore, @@ -508,6 +578,7 @@ func TestFinalizeBlockValidatorUpdatesResultingInEmptySet(t *testing.T) { sm.EmptyEvidencePool{}, blockStore, eventBus, + sm.NopMetrics(), types.DefaultConsensusPolicy(), ) diff --git a/sei-tendermint/internal/state/helpers_test.go b/sei-tendermint/internal/state/helpers_test.go index 033beac048..4118b28e75 100644 --- a/sei-tendermint/internal/state/helpers_test.go +++ b/sei-tendermint/internal/state/helpers_test.go @@ -234,7 +234,7 @@ func randomGenesisDoc() *types.GenesisDoc { func makeTxMempool(t testing.TB, app *proxy.Proxy) *mempool.TxMempool { t.Helper() - return mempool.NewTxMempool(mempool.TestConfig(), app, mempool.NopTxConstraintsFetcher) + return mempool.NewTxMempool(mempool.TestConfig(), app, mempool.NopMetrics(), mempool.NopTxConstraintsFetcher) } // used for testing by state store diff --git a/sei-tendermint/internal/state/indexer/indexer_service.go b/sei-tendermint/internal/state/indexer/indexer_service.go index 125b48158c..c4815848b8 100644 --- a/sei-tendermint/internal/state/indexer/indexer_service.go +++ b/sei-tendermint/internal/state/indexer/indexer_service.go @@ -20,6 +20,7 @@ type Service struct { eventSinks []EventSink eventBus *eventbus.EventBus + metrics *Metrics currentBlock struct { header types.EventDataNewBlockHeader @@ -33,6 +34,10 @@ func NewService(args ServiceArgs) *Service { is := &Service{ eventSinks: args.Sinks, eventBus: args.EventBus, + metrics: args.Metrics, + } + if is.metrics == nil { + is.metrics = NopMetrics() } is.BaseService = *service.NewBaseService("IndexerService", is) return is @@ -83,8 +88,8 @@ func (is *Service) publish(msg pubsub.Message) error { logger.Error("failed to index block header", "height", is.currentBlock.height, "err", err) } else { - Global.BlockEventsSecondsAt().Observe(time.Since(start).Seconds()) - Global.BlocksIndexedAt().Add(1) + is.metrics.BlockEventsSeconds.Observe(time.Since(start).Seconds()) + is.metrics.BlocksIndexed.Add(1) logger.Debug("indexed block", "height", is.currentBlock.height, "sink", sink.Type()) } @@ -96,8 +101,8 @@ func (is *Service) publish(msg pubsub.Message) error { logger.Error("failed to index block txs", "height", is.currentBlock.height, "err", err) } else { - Global.TxEventsSecondsAt().Observe(time.Since(start).Seconds()) - Global.TransactionsIndexedAt().Add(int64(curr.Size())) + is.metrics.TxEventsSeconds.Observe(time.Since(start).Seconds()) + is.metrics.TransactionsIndexed.Add(float64(curr.Size())) logger.Debug("indexed txs", "height", is.currentBlock.height, "sink", sink.Type()) } @@ -139,6 +144,7 @@ func (is *Service) OnStop() { type ServiceArgs struct { Sinks []EventSink EventBus *eventbus.EventBus + Metrics *Metrics } // KVSinkEnabled returns the given eventSinks is containing KVEventSink. diff --git a/sei-tendermint/internal/state/indexer/metrics.gen.go b/sei-tendermint/internal/state/indexer/metrics.gen.go index 92ec7b4daa..8b079d8d5c 100644 --- a/sei-tendermint/internal/state/indexer/metrics.gen.go +++ b/sei-tendermint/internal/state/indexer/metrics.gen.go @@ -3,64 +3,49 @@ package indexer import ( - "github.com/prometheus/client_golang/prometheus" - tmprometheus "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/prometheus" + "github.com/go-kit/kit/metrics/discard" + prometheus "github.com/go-kit/kit/metrics/prometheus" + stdprometheus "github.com/prometheus/client_golang/prometheus" ) -var Global = NewMetrics() - -func init() { - prometheus.MustRegister( - Global.BlockEventsSeconds, - Global.TxEventsSeconds, - Global.BlocksIndexed, - Global.TransactionsIndexed, - ) -} - -func NewMetrics() *Metrics { +func PrometheusMetrics(namespace string, labelsAndValues ...string) *Metrics { + labels := []string{} + for i := 0; i < len(labelsAndValues); i += 2 { + labels = append(labels, labelsAndValues[i]) + } return &Metrics{ - BlockEventsSeconds: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, + BlockEventsSeconds: prometheus.NewHistogramFrom(stdprometheus.HistogramOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "block_events_seconds", Help: "Latency for indexing block events.", - Buckets: prometheus.DefBuckets, - }, nil), - TxEventsSeconds: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + TxEventsSeconds: prometheus.NewHistogramFrom(stdprometheus.HistogramOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "tx_events_seconds", Help: "Latency for indexing transaction events.", - Buckets: prometheus.DefBuckets, - }, nil), - BlocksIndexed: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + BlocksIndexed: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "blocks_indexed", Help: "Number of complete blocks indexed.", - }, nil), - TransactionsIndexed: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + TransactionsIndexed: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "transactions_indexed", Help: "Number of transactions indexed.", - }, nil), + }, labels).With(labelsAndValues...), } } -func (m *Metrics) BlockEventsSecondsAt() *tmprometheus.Histogram { - return m.BlockEventsSeconds.WithLabelValues() -} - -func (m *Metrics) TxEventsSecondsAt() *tmprometheus.Histogram { - return m.TxEventsSeconds.WithLabelValues() -} - -func (m *Metrics) BlocksIndexedAt() *tmprometheus.CounterInt { - return m.BlocksIndexed.WithLabelValues() -} - -func (m *Metrics) TransactionsIndexedAt() *tmprometheus.CounterInt { - return m.TransactionsIndexed.WithLabelValues() +func NopMetrics() *Metrics { + return &Metrics{ + BlockEventsSeconds: discard.NewHistogram(), + TxEventsSeconds: discard.NewHistogram(), + BlocksIndexed: discard.NewCounter(), + TransactionsIndexed: discard.NewCounter(), + } } diff --git a/sei-tendermint/internal/state/indexer/metrics.go b/sei-tendermint/internal/state/indexer/metrics.go index a42bb71606..93dd0dc9ec 100644 --- a/sei-tendermint/internal/state/indexer/metrics.go +++ b/sei-tendermint/internal/state/indexer/metrics.go @@ -1,29 +1,25 @@ package indexer import ( - tmprometheus "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/prometheus" + "github.com/go-kit/kit/metrics" ) //go:generate go run ../../../scripts/metricsgen -struct=Metrics -const ( - // MetricsNamespace is the namespace shared by all Tendermint Prometheus metrics. - MetricsNamespace = "tendermint" - // MetricsSubsystem is a the subsystem label for the indexer package. - MetricsSubsystem = "indexer" -) +// MetricsSubsystem is a the subsystem label for the indexer package. +const MetricsSubsystem = "indexer" // Metrics contains metrics exposed by this package. type Metrics struct { // Latency for indexing block events. - BlockEventsSeconds tmprometheus.HistogramVec + BlockEventsSeconds metrics.Histogram // Latency for indexing transaction events. - TxEventsSeconds tmprometheus.HistogramVec + TxEventsSeconds metrics.Histogram // Number of complete blocks indexed. - BlocksIndexed tmprometheus.CounterIntVec + BlocksIndexed metrics.Counter // Number of transactions indexed. - TransactionsIndexed tmprometheus.CounterIntVec + TransactionsIndexed metrics.Counter } diff --git a/sei-tendermint/internal/state/metrics.gen.go b/sei-tendermint/internal/state/metrics.gen.go index 55e50a34ec..2b4eb0bdf3 100644 --- a/sei-tendermint/internal/state/metrics.gen.go +++ b/sei-tendermint/internal/state/metrics.gen.go @@ -3,158 +3,117 @@ package state import ( - "github.com/prometheus/client_golang/prometheus" - tmprometheus "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/prometheus" + "github.com/go-kit/kit/metrics/discard" + prometheus "github.com/go-kit/kit/metrics/prometheus" + stdprometheus "github.com/prometheus/client_golang/prometheus" ) -var Global = NewMetrics() - -func init() { - prometheus.MustRegister( - Global.BlockProcessingTime, - Global.ConsensusParamUpdates, - Global.ValidatorSetUpdates, - Global.ApplicationCommitTime, - Global.UpdateMempoolTime, - Global.FinalizeBlockLatency, - Global.SaveBlockResponseLatency, - Global.SaveBlockLatency, - Global.PruneBlockLatency, - Global.FireEventsLatency, - Global.ProposerPriorityHash, - Global.ProposerPriorityHashHeight, - ) -} - -func NewMetrics() *Metrics { +func PrometheusMetrics(namespace string, labelsAndValues ...string) *Metrics { + labels := []string{} + for i := 0; i < len(labelsAndValues); i += 2 { + labels = append(labels, labelsAndValues[i]) + } return &Metrics{ - BlockProcessingTime: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, + BlockProcessingTime: prometheus.NewHistogramFrom(stdprometheus.HistogramOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "block_processing_time", Help: "Time between BeginBlock and EndBlock.", - Buckets: prometheus.ExponentialBucketsRange(0.01, 10, 10), - }, nil), - ConsensusParamUpdates: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + + Buckets: stdprometheus.ExponentialBucketsRange(0.01, 10, 10), + }, labels).With(labelsAndValues...), + ConsensusParamUpdates: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "consensus_param_updates", Help: "Number of consensus parameter updates returned by the application since process start.", - }, nil), - ValidatorSetUpdates: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + ValidatorSetUpdates: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "validator_set_updates", Help: "Number of validator set updates returned by the application since process start.", - }, nil), - ApplicationCommitTime: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + ApplicationCommitTime: prometheus.NewHistogramFrom(stdprometheus.HistogramOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "application_commit_time", Help: "ApplicationCommitTime measures how long it takes to commit application state", - Buckets: prometheus.DefBuckets, - }, nil), - UpdateMempoolTime: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + UpdateMempoolTime: prometheus.NewHistogramFrom(stdprometheus.HistogramOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "update_mempool_time", Help: "UpdateMempoolTime measures how long it takes to update mempool after committing, including reCheckTx", - Buckets: prometheus.DefBuckets, - }, nil), - FinalizeBlockLatency: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + FinalizeBlockLatency: prometheus.NewHistogramFrom(stdprometheus.HistogramOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "finalize_block_latency", Help: "FinalizeBlockLatency measures how long it takes to run abci FinalizeBlock", - Buckets: prometheus.ExponentialBucketsRange(0.01, 10, 10), - }, nil), - SaveBlockResponseLatency: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, + + Buckets: stdprometheus.ExponentialBucketsRange(0.01, 10, 10), + }, labels).With(labelsAndValues...), + SaveBlockResponseLatency: prometheus.NewHistogramFrom(stdprometheus.HistogramOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "save_block_response_latency", Help: "SaveBlockResponseLatency measures how long it takes to run save the FinalizeBlockRes", - Buckets: prometheus.ExponentialBucketsRange(0.01, 10, 10), - }, nil), - SaveBlockLatency: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, + + Buckets: stdprometheus.ExponentialBucketsRange(0.01, 10, 10), + }, labels).With(labelsAndValues...), + SaveBlockLatency: prometheus.NewHistogramFrom(stdprometheus.HistogramOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "save_block_latency", Help: "SaveBlockLatency measure how long it takes to save the block", - Buckets: prometheus.ExponentialBucketsRange(0.01, 10, 10), - }, nil), - PruneBlockLatency: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, + + Buckets: stdprometheus.ExponentialBucketsRange(0.01, 10, 10), + }, labels).With(labelsAndValues...), + PruneBlockLatency: prometheus.NewHistogramFrom(stdprometheus.HistogramOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "prune_block_latency", Help: "PruneBlockLatency measures how long it takes to prune block from blockstore", - Buckets: prometheus.ExponentialBucketsRange(0.01, 10, 10), - }, nil), - FireEventsLatency: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, + + Buckets: stdprometheus.ExponentialBucketsRange(0.01, 10, 10), + }, labels).With(labelsAndValues...), + FireEventsLatency: prometheus.NewHistogramFrom(stdprometheus.HistogramOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "fire_events_latency", Help: "FireEventsLatency measures how long it takes to fire events for indexing", - Buckets: prometheus.ExponentialBucketsRange(0.01, 10, 10), - }, nil), - ProposerPriorityHash: prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + + Buckets: stdprometheus.ExponentialBucketsRange(0.01, 10, 10), + }, labels).With(labelsAndValues...), + ProposerPriorityHash: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "proposer_priority_hash", Help: "ProposerPriorityHash encodes the first 6 bytes of the hash of the current validator set's proposer priorities as a float64 value. Exported periodically (every proposerPriorityHashInterval heights) for operator visibility; divergence between validators at the same ProposerPriorityHashHeight indicates corrupted ProposerPriority state. Paired with ProposerPriorityHashHeight so operators can correlate.", - }, nil), - ProposerPriorityHashHeight: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + ProposerPriorityHashHeight: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "proposer_priority_hash_height", Help: "ProposerPriorityHashHeight is the block height at which the most recent ProposerPriorityHash was computed. Operators comparing hashes across validators should only compare samples at the same height.", - }, nil), + }, labels).With(labelsAndValues...), } } -func (m *Metrics) BlockProcessingTimeAt() *tmprometheus.Histogram { - return m.BlockProcessingTime.WithLabelValues() -} - -func (m *Metrics) ConsensusParamUpdatesAt() *tmprometheus.CounterInt { - return m.ConsensusParamUpdates.WithLabelValues() -} - -func (m *Metrics) ValidatorSetUpdatesAt() *tmprometheus.CounterInt { - return m.ValidatorSetUpdates.WithLabelValues() -} - -func (m *Metrics) ApplicationCommitTimeAt() *tmprometheus.Histogram { - return m.ApplicationCommitTime.WithLabelValues() -} - -func (m *Metrics) UpdateMempoolTimeAt() *tmprometheus.Histogram { - return m.UpdateMempoolTime.WithLabelValues() -} - -func (m *Metrics) FinalizeBlockLatencyAt() *tmprometheus.Histogram { - return m.FinalizeBlockLatency.WithLabelValues() -} - -func (m *Metrics) SaveBlockResponseLatencyAt() *tmprometheus.Histogram { - return m.SaveBlockResponseLatency.WithLabelValues() -} - -func (m *Metrics) SaveBlockLatencyAt() *tmprometheus.Histogram { - return m.SaveBlockLatency.WithLabelValues() -} - -func (m *Metrics) PruneBlockLatencyAt() *tmprometheus.Histogram { - return m.PruneBlockLatency.WithLabelValues() -} - -func (m *Metrics) FireEventsLatencyAt() *tmprometheus.Histogram { - return m.FireEventsLatency.WithLabelValues() -} - -func (m *Metrics) ProposerPriorityHashAt() prometheus.Gauge { - return m.ProposerPriorityHash.WithLabelValues() -} - -func (m *Metrics) ProposerPriorityHashHeightAt() *tmprometheus.GaugeInt { - return m.ProposerPriorityHashHeight.WithLabelValues() +func NopMetrics() *Metrics { + return &Metrics{ + BlockProcessingTime: discard.NewHistogram(), + ConsensusParamUpdates: discard.NewCounter(), + ValidatorSetUpdates: discard.NewCounter(), + ApplicationCommitTime: discard.NewHistogram(), + UpdateMempoolTime: discard.NewHistogram(), + FinalizeBlockLatency: discard.NewHistogram(), + SaveBlockResponseLatency: discard.NewHistogram(), + SaveBlockLatency: discard.NewHistogram(), + PruneBlockLatency: discard.NewHistogram(), + FireEventsLatency: discard.NewHistogram(), + ProposerPriorityHash: discard.NewGauge(), + ProposerPriorityHashHeight: discard.NewGauge(), + } } diff --git a/sei-tendermint/internal/state/metrics.go b/sei-tendermint/internal/state/metrics.go index cc02dfce12..eae44964d9 100644 --- a/sei-tendermint/internal/state/metrics.go +++ b/sei-tendermint/internal/state/metrics.go @@ -1,13 +1,10 @@ package state import ( - "github.com/prometheus/client_golang/prometheus" - tmprometheus "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/prometheus" + "github.com/go-kit/kit/metrics" ) const ( - // MetricsNamespace is the namespace shared by all Tendermint Prometheus metrics. - MetricsNamespace = "tendermint" // MetricsSubsystem is a subsystem shared by all metrics exposed by this // package. MetricsSubsystem = "state" @@ -18,39 +15,39 @@ const ( // Metrics contains metrics exposed by this package. type Metrics struct { // Time between BeginBlock and EndBlock. - BlockProcessingTime tmprometheus.HistogramVec `metrics_buckets:"exprange(0.01, 10, 10)"` + BlockProcessingTime metrics.Histogram `metrics_buckettype:"exprange" metrics_bucketsizes:"0.01, 10, 10"` // ConsensusParamUpdates is the total number of times the application has // udated the consensus params since process start. //metrics:Number of consensus parameter updates returned by the application since process start. - ConsensusParamUpdates tmprometheus.CounterIntVec + ConsensusParamUpdates metrics.Counter // ValidatorSetUpdates is the total number of times the application has // udated the validator set since process start. //metrics:Number of validator set updates returned by the application since process start. - ValidatorSetUpdates tmprometheus.CounterIntVec + ValidatorSetUpdates metrics.Counter // ApplicationCommitTime measures how long it takes to commit application state - ApplicationCommitTime tmprometheus.HistogramVec + ApplicationCommitTime metrics.Histogram // UpdateMempoolTime measures how long it takes to update mempool after committing, including // reCheckTx - UpdateMempoolTime tmprometheus.HistogramVec + UpdateMempoolTime metrics.Histogram // FinalizeBlockLatency measures how long it takes to run abci FinalizeBlock - FinalizeBlockLatency tmprometheus.HistogramVec `metrics_buckets:"exprange(0.01, 10, 10)"` + FinalizeBlockLatency metrics.Histogram `metrics_buckettype:"exprange" metrics_bucketsizes:"0.01, 10, 10"` // SaveBlockResponseLatency measures how long it takes to run save the FinalizeBlockRes - SaveBlockResponseLatency tmprometheus.HistogramVec `metrics_buckets:"exprange(0.01, 10, 10)"` + SaveBlockResponseLatency metrics.Histogram `metrics_buckettype:"exprange" metrics_bucketsizes:"0.01, 10, 10"` // SaveBlockLatency measure how long it takes to save the block - SaveBlockLatency tmprometheus.HistogramVec `metrics_buckets:"exprange(0.01, 10, 10)"` + SaveBlockLatency metrics.Histogram `metrics_buckettype:"exprange" metrics_bucketsizes:"0.01, 10, 10"` // PruneBlockLatency measures how long it takes to prune block from blockstore - PruneBlockLatency tmprometheus.HistogramVec `metrics_buckets:"exprange(0.01, 10, 10)"` + PruneBlockLatency metrics.Histogram `metrics_buckettype:"exprange" metrics_bucketsizes:"0.01, 10, 10"` // FireEventsLatency measures how long it takes to fire events for indexing - FireEventsLatency tmprometheus.HistogramVec `metrics_buckets:"exprange(0.01, 10, 10)"` + FireEventsLatency metrics.Histogram `metrics_buckettype:"exprange" metrics_bucketsizes:"0.01, 10, 10"` // ProposerPriorityHash encodes the first 6 bytes of the hash of the // current validator set's proposer priorities as a float64 value. @@ -58,10 +55,10 @@ type Metrics struct { // operator visibility; divergence between validators at the same // ProposerPriorityHashHeight indicates corrupted ProposerPriority state. // Paired with ProposerPriorityHashHeight so operators can correlate. - ProposerPriorityHash *prometheus.GaugeVec + ProposerPriorityHash metrics.Gauge // ProposerPriorityHashHeight is the block height at which the most recent // ProposerPriorityHash was computed. Operators comparing hashes across // validators should only compare samples at the same height. - ProposerPriorityHashHeight tmprometheus.GaugeIntVec + ProposerPriorityHashHeight metrics.Gauge } diff --git a/sei-tendermint/internal/state/state.go b/sei-tendermint/internal/state/state.go index c1990acf2d..964032a26c 100644 --- a/sei-tendermint/internal/state/state.go +++ b/sei-tendermint/internal/state/state.go @@ -237,23 +237,11 @@ func FromProto(pb *tmstate.State) (*State, error) { } state.NextValidators = nVals - // An empty validator set is legitimate only for a genesis-derived state - // (validators arrive at InitChain, or via state sync). At any later - // height it can only be corruption; refuse it at load rather than panic - // downstream in consensus. - if pb.LastBlockHeight > 0 && (vals.IsNilOrEmpty() || nVals.IsNilOrEmpty()) { - return nil, ErrEmptyValidatorSet{Height: pb.LastBlockHeight} - } - if state.LastBlockHeight >= 1 { // At Block 1 LastValidators is nil lVals, err := types.ValidatorSetFromProto(pb.LastValidators) if err != nil { return nil, err } - // Same backstop as above: a committed block implies a non-empty set. - if lVals.IsNilOrEmpty() { - return nil, ErrEmptyValidatorSet{Height: pb.LastBlockHeight} - } state.LastValidators = lVals } else { state.LastValidators = types.NewValidatorSet(nil) diff --git a/sei-tendermint/internal/state/state_test.go b/sei-tendermint/internal/state/state_test.go index 85ac4de80e..03e2f74b0f 100644 --- a/sei-tendermint/internal/state/state_test.go +++ b/sei-tendermint/internal/state/state_test.go @@ -1147,52 +1147,6 @@ func TestConsensusParamsChangesSaveLoad(t *testing.T) { } } -// TestStateProtoEmptyRoundTrip pins that a state with empty validator sets -// survives the proto round-trip: the state store persists a genesis-derived -// state before InitChain (or state sync) populates the validators, and must -// be able to reload it. Empty sets canonicalize to types.NewValidatorSet(nil). -func TestStateProtoEmptyRoundTrip(t *testing.T) { - pbs, err := (&sm.State{}).ToProto() - require.NoError(t, err) - - smt, err := sm.FromProto(pbs) - require.NoError(t, err) - require.True(t, smt.Validators.IsNilOrEmpty()) - require.True(t, smt.NextValidators.IsNilOrEmpty()) - require.Zero(t, smt.LastBlockHeight) -} - -// TestStateProtoEmptyRejectedPastGenesis pins the corruption backstop: an -// empty validator set is legitimate only at LastBlockHeight 0. -func TestStateProtoEmptyRejectedPastGenesis(t *testing.T) { - pbs, err := (&sm.State{}).ToProto() - require.NoError(t, err) - pbs.LastBlockHeight = 5 - - _, err = sm.FromProto(pbs) - var errEmpty sm.ErrEmptyValidatorSet - require.ErrorAs(t, err, &errEmpty) - require.EqualValues(t, 5, errEmpty.Height) -} - -// TestStateProtoEmptyLastValidatorsRejected pins the same backstop for -// LastValidators: a committed block implies a non-empty signing set. -func TestStateProtoEmptyLastValidatorsRejected(t *testing.T) { - tearDown, _, state := setupTestCase(t) - defer tearDown(t) - state.LastBlockHeight = 5 - - pbs, err := state.ToProto() - require.NoError(t, err) - pbs.LastValidators, err = types.NewValidatorSet(nil).ToProto() - require.NoError(t, err) - - _, err = sm.FromProto(pbs) - var errEmpty sm.ErrEmptyValidatorSet - require.ErrorAs(t, err, &errEmpty) - require.EqualValues(t, 5, errEmpty.Height) -} - func TestStateProto(t *testing.T) { tearDown, _, state := setupTestCase(t) defer tearDown(t) @@ -1203,6 +1157,7 @@ func TestStateProto(t *testing.T) { expPass1 bool expPass2 bool }{ + {"empty state", &sm.State{}, true, false}, {"nil failure state", nil, false, false}, {"success state", &state, true, true}, } diff --git a/sei-tendermint/internal/state/store.go b/sei-tendermint/internal/state/store.go index d896c7a428..8ed1ef888d 100644 --- a/sei-tendermint/internal/state/store.go +++ b/sei-tendermint/internal/state/store.go @@ -527,12 +527,6 @@ func (store dbStore) LoadValidators(height int64) (*types.ValidatorSet, error) { if err != nil { return nil, err } - // The genesis-derived entry has an empty set until InitChain (or - // state sync) installs the real validators; IncrementProposerPriority - // panics on it, so refuse cleanly instead. - if vs.IsNilOrEmpty() { - return nil, ErrEmptyValidatorSet{Height: lastStoredHeight} - } h, err := tmmath.SafeConvertInt32(height - lastStoredHeight) if err != nil { return nil, err @@ -552,9 +546,6 @@ func (store dbStore) LoadValidators(height int64) (*types.ValidatorSet, error) { if err != nil { return nil, err } - if vip.IsNilOrEmpty() { - return nil, ErrEmptyValidatorSet{Height: height} - } return vip, nil } diff --git a/sei-tendermint/internal/state/store_test.go b/sei-tendermint/internal/state/store_test.go index 887d825a9d..2609896802 100644 --- a/sei-tendermint/internal/state/store_test.go +++ b/sei-tendermint/internal/state/store_test.go @@ -24,31 +24,6 @@ const ( valSetCheckpointInterval = 100000 ) -// TestStoreSaveLoadGenesisStateNoValidators pins that a genesis with no -// validators (they arrive at InitChain via gentxs) saves and reloads through -// the state store — the pre-state-sync bootstrap path. -func TestStoreSaveLoadGenesisStateNoValidators(t *testing.T) { - genesisState, err := sm.MakeGenesisState(&types.GenesisDoc{ - ChainID: "statesync-genesis-chain", - }) - require.NoError(t, err) - require.True(t, genesisState.Validators.IsNilOrEmpty()) - - stateStore := sm.NewStore(dbm.NewMemDB()) - require.NoError(t, stateStore.Save(genesisState)) - - loaded, err := stateStore.Load() - require.NoError(t, err) - require.True(t, loaded.Validators.IsNilOrEmpty()) - require.Equal(t, genesisState.ChainID, loaded.ChainID) - - // The genesis entry is loadable as state, but not usable as a validator - // set until InitChain (or state sync) installs the real validators. - _, err = stateStore.LoadValidators(loaded.InitialHeight) - var errEmpty sm.ErrEmptyValidatorSet - require.ErrorAs(t, err, &errEmpty) -} - func TestStoreBootstrap(t *testing.T) { stateDB := dbm.NewMemDB() stateStore := sm.NewStore(stateDB) diff --git a/sei-tendermint/internal/state/validation_header_default_test.go b/sei-tendermint/internal/state/validation_header_default_test.go index dd95bc471e..f3fd4376c7 100644 --- a/sei-tendermint/internal/state/validation_header_default_test.go +++ b/sei-tendermint/internal/state/validation_header_default_test.go @@ -34,7 +34,7 @@ func TestValidateBlockHeader(t *testing.T) { state, stateDB, privVals := makeState(t, 3, 1) stateStore := sm.NewStore(stateDB) - proxyApp := proxy.New(app) + proxyApp := proxy.New(app, proxy.NopMetrics()) mp := makeTxMempool(t, proxyApp) blockStore := store.NewBlockStore(dbm.NewMemDB()) @@ -45,6 +45,7 @@ func TestValidateBlockHeader(t *testing.T) { sm.EmptyEvidencePool{}, blockStore, eventBus, + sm.NopMetrics(), types.DefaultConsensusPolicy(), ) lastCommit := &types.Commit{} diff --git a/sei-tendermint/internal/state/validation_test.go b/sei-tendermint/internal/state/validation_test.go index be44683857..84aeed2c04 100644 --- a/sei-tendermint/internal/state/validation_test.go +++ b/sei-tendermint/internal/state/validation_test.go @@ -43,7 +43,7 @@ func TestValidateBlockCommit(t *testing.T) { state, stateDB, privVals := makeState(t, 1, 1) stateStore := sm.NewStore(stateDB) - proxyApp := proxy.New(app) + proxyApp := proxy.New(app, proxy.NopMetrics()) mp := makeTxMempool(t, proxyApp) blockStore := store.NewBlockStore(dbm.NewMemDB()) @@ -54,6 +54,7 @@ func TestValidateBlockCommit(t *testing.T) { sm.EmptyEvidencePool{}, blockStore, eventBus, + sm.NopMetrics(), types.DefaultConsensusPolicy(), ) lastCommit := &types.Commit{} @@ -180,7 +181,7 @@ func TestValidateBlockEvidence(t *testing.T) { eventBus := eventbus.NewDefault() require.NoError(t, eventBus.Start(ctx)) - proxyApp := proxy.New(app) + proxyApp := proxy.New(app, proxy.NopMetrics()) mp := makeTxMempool(t, proxyApp) state.ConsensusParams.Evidence.MaxBytes = 1000 @@ -191,6 +192,7 @@ func TestValidateBlockEvidence(t *testing.T) { evpool, blockStore, eventBus, + sm.NopMetrics(), types.DefaultConsensusPolicy(), ) lastCommit := &types.Commit{} diff --git a/sei-tendermint/internal/statesync/metrics.gen.go b/sei-tendermint/internal/statesync/metrics.gen.go index ce5a8b9f9b..b4d5caa12c 100644 --- a/sei-tendermint/internal/statesync/metrics.gen.go +++ b/sei-tendermint/internal/statesync/metrics.gen.go @@ -3,95 +3,70 @@ package statesync import ( - "github.com/prometheus/client_golang/prometheus" - tmprometheus "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/prometheus" + "github.com/go-kit/kit/metrics/discard" + prometheus "github.com/go-kit/kit/metrics/prometheus" + stdprometheus "github.com/prometheus/client_golang/prometheus" ) -var Global = NewMetrics() - -func init() { - prometheus.MustRegister( - Global.TotalSnapshots, - Global.ChunkProcessAvgTime, - Global.SnapshotHeight, - Global.SnapshotChunk, - Global.SnapshotChunkTotal, - Global.BackFilledBlocks, - Global.BackFillBlocksTotal, - ) -} - -func NewMetrics() *Metrics { +func PrometheusMetrics(namespace string, labelsAndValues ...string) *Metrics { + labels := []string{} + for i := 0; i < len(labelsAndValues); i += 2 { + labels = append(labels, labelsAndValues[i]) + } return &Metrics{ - TotalSnapshots: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + TotalSnapshots: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "total_snapshots", Help: "The total number of snapshots discovered.", - }, nil), - ChunkProcessAvgTime: prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + ChunkProcessAvgTime: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "chunk_process_avg_time", Help: "The average processing time per chunk.", - }, nil), - SnapshotHeight: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + SnapshotHeight: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "snapshot_height", Help: "The height of the current snapshot the has been processed.", - }, nil), - SnapshotChunk: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + SnapshotChunk: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "snapshot_chunk", Help: "The current number of chunks that have been processed.", - }, nil), - SnapshotChunkTotal: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + SnapshotChunkTotal: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "snapshot_chunk_total", Help: "The total number of chunks in the current snapshot.", - }, nil), - BackFilledBlocks: tmprometheus.NewCounterIntVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + BackFilledBlocks: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "back_filled_blocks", Help: "The current number of blocks that have been back-filled.", - }, nil), - BackFillBlocksTotal: tmprometheus.NewGaugeIntVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + }, labels).With(labelsAndValues...), + BackFillBlocksTotal: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "back_fill_blocks_total", Help: "The total number of blocks that need to be back-filled.", - }, nil), + }, labels).With(labelsAndValues...), } } -func (m *Metrics) TotalSnapshotsAt() *tmprometheus.CounterInt { - return m.TotalSnapshots.WithLabelValues() -} - -func (m *Metrics) ChunkProcessAvgTimeAt() prometheus.Gauge { - return m.ChunkProcessAvgTime.WithLabelValues() -} - -func (m *Metrics) SnapshotHeightAt() *tmprometheus.GaugeInt { - return m.SnapshotHeight.WithLabelValues() -} - -func (m *Metrics) SnapshotChunkAt() *tmprometheus.CounterInt { - return m.SnapshotChunk.WithLabelValues() -} - -func (m *Metrics) SnapshotChunkTotalAt() *tmprometheus.GaugeInt { - return m.SnapshotChunkTotal.WithLabelValues() -} - -func (m *Metrics) BackFilledBlocksAt() *tmprometheus.CounterInt { - return m.BackFilledBlocks.WithLabelValues() -} - -func (m *Metrics) BackFillBlocksTotalAt() *tmprometheus.GaugeInt { - return m.BackFillBlocksTotal.WithLabelValues() +func NopMetrics() *Metrics { + return &Metrics{ + TotalSnapshots: discard.NewCounter(), + ChunkProcessAvgTime: discard.NewGauge(), + SnapshotHeight: discard.NewGauge(), + SnapshotChunk: discard.NewCounter(), + SnapshotChunkTotal: discard.NewGauge(), + BackFilledBlocks: discard.NewCounter(), + BackFillBlocksTotal: discard.NewGauge(), + } } diff --git a/sei-tendermint/internal/statesync/metrics.go b/sei-tendermint/internal/statesync/metrics.go index 0252857a64..a8a3af9152 100644 --- a/sei-tendermint/internal/statesync/metrics.go +++ b/sei-tendermint/internal/statesync/metrics.go @@ -1,13 +1,10 @@ package statesync import ( - "github.com/prometheus/client_golang/prometheus" - tmprometheus "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/prometheus" + "github.com/go-kit/kit/metrics" ) const ( - // MetricsNamespace is the namespace shared by all Tendermint Prometheus metrics. - MetricsNamespace = "tendermint" // MetricsSubsystem is a subsystem shared by all metrics exposed by this package. MetricsSubsystem = "statesync" ) @@ -17,17 +14,17 @@ const ( // Metrics contains metrics exposed by this package. type Metrics struct { // The total number of snapshots discovered. - TotalSnapshots tmprometheus.CounterIntVec + TotalSnapshots metrics.Counter // The average processing time per chunk. - ChunkProcessAvgTime *prometheus.GaugeVec + ChunkProcessAvgTime metrics.Gauge // The height of the current snapshot the has been processed. - SnapshotHeight tmprometheus.GaugeIntVec + SnapshotHeight metrics.Gauge // The current number of chunks that have been processed. - SnapshotChunk tmprometheus.CounterIntVec + SnapshotChunk metrics.Counter // The total number of chunks in the current snapshot. - SnapshotChunkTotal tmprometheus.GaugeIntVec + SnapshotChunkTotal metrics.Gauge // The current number of blocks that have been back-filled. - BackFilledBlocks tmprometheus.CounterIntVec + BackFilledBlocks metrics.Counter // The total number of blocks that need to be back-filled. - BackFillBlocksTotal tmprometheus.GaugeIntVec + BackFillBlocksTotal metrics.Gauge } diff --git a/sei-tendermint/internal/statesync/reactor.go b/sei-tendermint/internal/statesync/reactor.go index f6b996b2b3..0717cc34c7 100644 --- a/sei-tendermint/internal/statesync/reactor.go +++ b/sei-tendermint/internal/statesync/reactor.go @@ -182,6 +182,7 @@ type Reactor struct { stateProvider StateProvider eventBus *eventbus.EventBus + metrics *Metrics backfillBlockTotal int64 backfilledBlocks int64 @@ -217,6 +218,7 @@ func NewReactor( stateStore sm.Store, blockStore *store.BlockStore, tempDir string, + ssMetrics *Metrics, eventBus *eventbus.EventBus, postSyncHook func(context.Context, sm.State) error, needsStateSync bool, @@ -250,6 +252,7 @@ func NewReactor( blockStore: blockStore, peers: NewPeerList(), providers: make(map[types.NodeID]*BlockProvider), + metrics: ssMetrics, eventBus: eventBus, postSyncHook: postSyncHook, needsStateSync: needsStateSync, @@ -322,6 +325,7 @@ func (r *Reactor) OnStart(ctx context.Context) error { tempDir: r.tempDir, fetchers: r.cfg.Fetchers, retryTimeout: r.cfg.ChunkRequestTimeout, + metrics: r.metrics, useLocalSnapshot: r.cfg.UseLocalSnapshot, } } @@ -493,7 +497,7 @@ func (r *Reactor) backfill( "stopHeight", stopHeight, "stopTime", stopTime, "trustedBlockID", trustedBlockID) r.backfillBlockTotal = startHeight - stopHeight + 1 - Global.BackFillBlocksTotalAt().Set(r.backfillBlockTotal) + r.metrics.BackFillBlocksTotal.Set(float64(r.backfillBlockTotal)) const sleepTime = 1 * time.Second var ( @@ -621,13 +625,13 @@ func (r *Reactor) backfill( lastValidatorSet = resp.block.ValidatorSet r.backfilledBlocks++ - Global.BackFilledBlocksAt().Add(1) + r.metrics.BackFilledBlocks.Add(1) // The block height might be less than the stopHeight because of the stopTime condition // hasn't been fulfilled. if resp.block.Height < stopHeight { r.backfillBlockTotal++ - Global.BackFillBlocksTotalAt().Set(r.backfillBlockTotal) + r.metrics.BackFillBlocksTotal.Set(float64(r.backfillBlockTotal)) } case <-queue.done(): diff --git a/sei-tendermint/internal/statesync/reactor_test.go b/sei-tendermint/internal/statesync/reactor_test.go index c237ae2b9a..119915d573 100644 --- a/sei-tendermint/internal/statesync/reactor_test.go +++ b/sei-tendermint/internal/statesync/reactor_test.go @@ -30,7 +30,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/version" ) -var m = Global +var m = PrometheusMetrics(config.TestConfig().Instrumentation.Namespace) type reactorTestSuite struct { network *p2p.TestNetwork @@ -55,7 +55,7 @@ func setup( if conn == nil { conn = newTestStatesyncApp() } - proxyConn := proxy.New(conn) + proxyConn := proxy.New(conn, proxy.NopMetrics()) network := p2p.MakeTestNetwork(t, p2p.TestNetworkOptions{ NumNodes: 1, @@ -79,6 +79,7 @@ func setup( stateStore, blockStore, "", + m, nil, // eventbus can be nil nil, // post-sync-hook false, // run Sync during Start() @@ -97,6 +98,7 @@ func setup( tempDir: t.TempDir(), fetchers: cfg.Fetchers, retryTimeout: cfg.ChunkRequestTimeout, + metrics: reactor.metrics, } } diff --git a/sei-tendermint/internal/statesync/syncer.go b/sei-tendermint/internal/statesync/syncer.go index b67078b9de..668c27bd35 100644 --- a/sei-tendermint/internal/statesync/syncer.go +++ b/sei-tendermint/internal/statesync/syncer.go @@ -60,8 +60,9 @@ type syncer struct { fetchers int32 retryTimeout time.Duration - mtx sync.RWMutex - chunks *chunkQueue + mtx sync.RWMutex + chunks *chunkQueue + metrics *Metrics avgChunkTime int64 lastSyncedSnapshotHeight int64 @@ -99,7 +100,7 @@ func (s *syncer) AddSnapshot(peerID types.NodeID, snapshot *snapshot) (bool, err return false, err } if added { - Global.TotalSnapshotsAt().Add(1) + s.metrics.TotalSnapshots.Add(1) logger.Info("discovered and added new snapshot", "peer", peerID, "height", snapshot.Height, "format", snapshot.Format, "hash", snapshot.Hash) } return added, nil @@ -175,13 +176,13 @@ func (s *syncer) SyncAny( } s.processingSnapshot = snapshot - Global.SnapshotChunkTotalAt().Set(int64(snapshot.Chunks)) + s.metrics.SnapshotChunkTotal.Set(float64(snapshot.Chunks)) logger.Info("starting state sync with picked snapshot", "height", snapshot.Height) newState, commit, err := s.Sync(ctx, snapshot, chunks) switch { case err == nil: - Global.SnapshotHeightAt().Set(int64(snapshot.Height)) //nolint:gosec // metric precision is not security-sensitive; overflow is acceptable here - s.lastSyncedSnapshotHeight = int64(snapshot.Height) //nolint:gosec // snapshot.Height is a valid block height in this context + s.metrics.SnapshotHeight.Set(float64(snapshot.Height)) + s.lastSyncedSnapshotHeight = int64(snapshot.Height) //nolint:gosec // snapshot.Height is a valid block height return newState, commit, nil case errors.Is(err, errAbort): @@ -415,9 +416,9 @@ func (s *syncer) applyChunks(ctx context.Context, chunks *chunkQueue, start time switch resp.Result { case abci.ResponseApplySnapshotChunk_ACCEPT: - Global.SnapshotChunkAt().Add(1) + s.metrics.SnapshotChunk.Add(1) s.avgChunkTime = time.Since(start).Nanoseconds() / int64(chunks.numChunksReturned()) - Global.ChunkProcessAvgTimeAt().Set(float64(s.avgChunkTime)) + s.metrics.ChunkProcessAvgTime.Set(float64(s.avgChunkTime)) case abci.ResponseApplySnapshotChunk_ABORT: return errAbort case abci.ResponseApplySnapshotChunk_RETRY: diff --git a/sei-tendermint/internal/test/factory/genesis.go b/sei-tendermint/internal/test/factory/genesis.go index 139b76f7b7..7f7cf683b1 100644 --- a/sei-tendermint/internal/test/factory/genesis.go +++ b/sei-tendermint/internal/test/factory/genesis.go @@ -13,10 +13,6 @@ func GenesisDoc( validators []*types.Validator, consensusParams *types.ConsensusParams, ) *types.GenesisDoc { - existing, err := types.GenesisDocFromFile(config.GenesisFile()) - if err != nil { - panic(err) - } genesisValidators := make([]types.GenesisValidator, len(validators)) @@ -30,7 +26,7 @@ func GenesisDoc( return &types.GenesisDoc{ GenesisTime: time, InitialHeight: 1, - ChainID: existing.ChainID, + ChainID: config.ChainID(), Validators: genesisValidators, ConsensusParams: consensusParams, } diff --git a/sei-tendermint/libs/utils/prometheus/histogram.go b/sei-tendermint/libs/utils/prometheus/histogram.go deleted file mode 100644 index 7ac4d75965..0000000000 --- a/sei-tendermint/libs/utils/prometheus/histogram.go +++ /dev/null @@ -1,132 +0,0 @@ -package prometheus - -import ( - "errors" - "math" - "sort" - "sync" - "time" - - "github.com/prometheus/client_golang/prometheus" - dto "github.com/prometheus/client_model/go" -) - -// WARNING: there is no "DEFAULT" buckets. Empty list of buckets is a valid list -// and it results in having single +inf bucket. -type HistogramOpts = prometheus.HistogramOpts - -type HistogramVec struct{ v *prometheus.MetricVec } - -func NewHistogramVec(opts HistogramOpts, labels []string) HistogramVec { - desc := prometheus.NewDesc( - prometheus.BuildFQName(opts.Namespace, opts.Subsystem, opts.Name), - opts.Help, - labels, - opts.ConstLabels, - ) - return HistogramVec{ - v: prometheus.NewMetricVec(desc, func(lvs ...string) prometheus.Metric { - return newHistogram(desc, opts, lvs...) - }), - } -} - -func (h HistogramVec) WithLabelValues(values ...string) *Histogram { - metric, err := h.v.GetMetricWithLabelValues(values...) - if err != nil { - panic(err) - } - return metric.(*Histogram) -} - -// Histogram is equivalent to the standard histogram, except it additionally supports -// efficient ObserveWithWeight call. -type Histogram struct { - desc *prometheus.Desc - variableLabelValues []string - upperBounds []float64 // exclusive of +Inf - - lock sync.Mutex - buckets []uint64 - sum float64 - createdAt time.Time -} - -var _ prometheus.Metric = (*Histogram)(nil) -var _ prometheus.Collector = HistogramVec{} - -func newHistogram(desc *prometheus.Desc, opts HistogramOpts, variableLabelValues ...string) *Histogram { - for i, upperBound := range opts.Buckets { - if i < len(opts.Buckets)-1 { - if upperBound >= opts.Buckets[i+1] { - panic( - errors.New( - "histogram buckets must be in increasing order", - ), - ) - } - } else if math.IsInf(upperBound, 1) { - // The +Inf bucket is implicit in the export format. - opts.Buckets = opts.Buckets[:i] - } - } - - upperBounds := make([]float64, len(opts.Buckets)) - copy(upperBounds, opts.Buckets) - - return &Histogram{ - desc: desc, - variableLabelValues: variableLabelValues, - upperBounds: upperBounds, - buckets: make([]uint64, len(upperBounds)+1), - createdAt: time.Now(), - } -} - -func (h *Histogram) Observe(value float64) { - h.ObserveWithWeight(value, 1) -} - -func (h *Histogram) ObserveWithWeight(value float64, weight uint64) { - idx := sort.SearchFloat64s(h.upperBounds, value) - h.lock.Lock() - defer h.lock.Unlock() - - h.buckets[idx] += weight - h.sum += value * float64(weight) -} - -func (h *Histogram) Desc() *prometheus.Desc { return h.desc } - -func (h *Histogram) Write(dest *dto.Metric) error { - count, sum, buckets, createdAt := func() (uint64, float64, map[float64]uint64, time.Time) { - h.lock.Lock() - defer h.lock.Unlock() - - nBounds := len(h.upperBounds) - buckets := make(map[float64]uint64, nBounds) - var count uint64 - for idx, upperBound := range h.upperBounds { - count += h.buckets[idx] - buckets[upperBound] = count - } - count += h.buckets[nBounds] - return count, h.sum, buckets, h.createdAt - }() - - metric, err := prometheus.NewConstHistogramWithCreatedTimestamp( - h.desc, - count, - sum, - buckets, - createdAt, - h.variableLabelValues..., - ) - if err != nil { - return err - } - return metric.Write(dest) -} - -func (h HistogramVec) Describe(ch chan<- *prometheus.Desc) { h.v.Describe(ch) } -func (h HistogramVec) Collect(ch chan<- prometheus.Metric) { h.v.Collect(ch) } diff --git a/sei-tendermint/libs/utils/prometheus/histogram_test.go b/sei-tendermint/libs/utils/prometheus/histogram_test.go deleted file mode 100644 index 78a3a022af..0000000000 --- a/sei-tendermint/libs/utils/prometheus/histogram_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package prometheus - -import ( - "bytes" - "math" - "strings" - "testing" - "time" - - dto "github.com/prometheus/client_model/go" - "github.com/prometheus/common/expfmt" - - "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" -) - -func TestHistogramNonMonotonicBuckets(t *testing.T) { - testCases := map[string][]float64{ - "not strictly monotonic": {1, 2, 2, 3}, - "not monotonic at all": {1, 2, 4, 3, 5}, - "have +Inf in the middle": {1, 2, math.Inf(1), 3}, - } - - for name, buckets := range testCases { - t.Run(name, func(t *testing.T) { - require.Panics(t, func() { - NewHistogramVec( - HistogramOpts{ - Name: "test_histogram", - Help: "helpless", - Buckets: buckets, - }, - nil, - ).WithLabelValues() - }) - }) - } -} - -func TestHistogramObserveWithWeight(t *testing.T) { - vec := NewHistogramVec( - HistogramOpts{ - Name: "test_histogram", - Help: "helpless", - Buckets: []float64{1, 2, 5}, - }, - []string{"peer"}, - ) - - histogram := vec.WithLabelValues("p1") - histogram.ObserveWithWeight(0.5, 2) - histogram.ObserveWithWeight(1.0, 3) - histogram.ObserveWithWeight(1.5, 4) - histogram.ObserveWithWeight(7.0, 5) - - metric := writeHistogramMetric(t, histogram) - require.Len(t, metric.GetLabel(), 1) - require.Equal(t, "peer", metric.GetLabel()[0].GetName()) - require.Equal(t, "p1", metric.GetLabel()[0].GetValue()) - - exported := metric.GetHistogram() - require.NotNil(t, exported) - require.Equal(t, uint64(14), exported.GetSampleCount()) - require.Equal(t, 45.0, exported.GetSampleSum()) - require.Len(t, exported.GetBucket(), 3) - require.Equal(t, 1.0, exported.GetBucket()[0].GetUpperBound()) - require.Equal(t, uint64(5), exported.GetBucket()[0].GetCumulativeCount()) - require.Equal(t, 2.0, exported.GetBucket()[1].GetUpperBound()) - require.Equal(t, uint64(9), exported.GetBucket()[1].GetCumulativeCount()) - require.Equal(t, 5.0, exported.GetBucket()[2].GetUpperBound()) - require.Equal(t, uint64(9), exported.GetBucket()[2].GetCumulativeCount()) -} - -func TestHistogramWithoutBucketsExportsOnlyInfBucket(t *testing.T) { - vec := NewHistogramVec( - HistogramOpts{ - Name: "test_no_buckets_histogram", - Help: "help", - }, - nil, - ) - histogram := vec.WithLabelValues() - histogram.Observe(3) - - metric := writeHistogramMetric(t, histogram) - require.NotNil(t, metric.GetHistogram()) - require.Equal(t, uint64(1), metric.GetHistogram().GetSampleCount()) - require.Len(t, metric.GetHistogram().GetBucket(), 0) - - name := "test_no_buckets_histogram" - help := "help" - mf := &dto.MetricFamily{ - Name: &name, - Help: &help, - Type: dto.MetricType_HISTOGRAM.Enum(), - Metric: []*dto.Metric{metric}, - } - - var out bytes.Buffer - _, err := expfmt.MetricFamilyToText(&out, mf) - require.NoError(t, err) - text := out.String() - require.Contains(t, text, "test_no_buckets_histogram_bucket{le=\"+Inf\"} 1") - require.Equal(t, 1, strings.Count(text, "test_no_buckets_histogram_bucket{")) - require.Contains(t, text, "test_no_buckets_histogram_sum 3") - require.Contains(t, text, "test_no_buckets_histogram_count 1") -} - -func TestHistogramCreatedTimestamp(t *testing.T) { - before := time.Now() - vec := NewHistogramVec( - HistogramOpts{ - Name: "test_histogram_created", - Help: "helpless", - }, - []string{"peer"}, - ) - histogram := vec.WithLabelValues("p1") - afterCreate := time.Now() - - metric := writeHistogramMetric(t, histogram) - createdAt := metric.GetHistogram().GetCreatedTimestamp() - require.NotNil(t, createdAt) - require.False(t, createdAt.AsTime().Before(before)) - require.False(t, createdAt.AsTime().After(afterCreate)) -} - -func TestHistogramVecCreatedTimestampWithDeletes(t *testing.T) { - vec := NewHistogramVec( - HistogramOpts{ - Name: "test_histogram_delete_recreate", - Help: "helpless", - }, - []string{"peer"}, - ) - - firstMetric := writeHistogramMetric(t, vec.WithLabelValues("p1")) - firstCreatedAt := firstMetric.GetHistogram().GetCreatedTimestamp() - require.NotNil(t, firstCreatedAt) - - require.True(t, vec.v.DeleteLabelValues("p1")) - afterDelete := time.Now() - - secondMetric := writeHistogramMetric(t, vec.WithLabelValues("p1")) - secondCreatedAt := secondMetric.GetHistogram().GetCreatedTimestamp() - require.NotNil(t, secondCreatedAt) - require.False(t, secondCreatedAt.AsTime().Before(afterDelete)) -} - -func writeHistogramMetric(t *testing.T, histogram *Histogram) *dto.Metric { - t.Helper() - - metric := &dto.Metric{} - require.NoError(t, histogram.Write(metric)) - return metric -} diff --git a/sei-tendermint/libs/utils/prometheus/prometheus.go b/sei-tendermint/libs/utils/prometheus/prometheus.go deleted file mode 100644 index bb869241f9..0000000000 --- a/sei-tendermint/libs/utils/prometheus/prometheus.go +++ /dev/null @@ -1,113 +0,0 @@ -package prometheus - -import ( - "sync/atomic" - - "github.com/prometheus/client_golang/prometheus" - dto "github.com/prometheus/client_model/go" - "google.golang.org/protobuf/proto" - - "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" - "github.com/sei-protocol/seilog" -) - -var logger = seilog.NewLogger("tendermint", "libs", "utils", "prometheus") - -var _ prometheus.Metric = (*GaugeInt)(nil) -var _ prometheus.Metric = (*CounterInt)(nil) -var _ prometheus.Collector = GaugeIntVec{} -var _ prometheus.Collector = CounterIntVec{} - -// GaugeInt is a Metric that represents a single int64 value that can -// arbitrarily go up and down. -type GaugeInt struct { - value atomic.Int64 - desc *prometheus.Desc - labelPairs []*dto.LabelPair -} - -func (g *GaugeInt) Set(val int64) { g.value.Store(val) } -func (g *GaugeInt) Add(val int64) { g.value.Add(val) } -func (g *GaugeInt) Desc() *prometheus.Desc { return g.desc } -func (g *GaugeInt) Write(out *dto.Metric) error { - out.Label = g.labelPairs - out.Gauge = &dto.Gauge{Value: proto.Float64(float64(g.value.Load()))} - return nil -} - -// CounterInt is a Metric that represents a single int64 value that only ever -// goes up. -type CounterInt struct { - value atomic.Int64 - desc *prometheus.Desc - labelPairs []*dto.LabelPair -} - -func (c *CounterInt) Desc() *prometheus.Desc { return c.desc } -func (c *CounterInt) Add(val int64) { - if val < 0 { - logger.Error("counter cannot decrease in value") - return - } - c.value.Add(val) -} -func (c *CounterInt) Write(out *dto.Metric) error { - out.Label = c.labelPairs - out.Counter = &dto.Counter{Value: proto.Float64(float64(c.value.Load()))} - return nil -} - -// GaugeIntVec is a Collector that bundles a set of GaugeInt metrics that all -// share the same Desc, but have different values for their variable labels. -type GaugeIntVec struct{ v *prometheus.MetricVec } - -// CounterIntVec is a Collector that bundles a set of CounterInt metrics that -// all share the same Desc, but have different values for their variable labels. -type CounterIntVec struct{ v *prometheus.MetricVec } - -// NewGaugeIntVec creates a new GaugeIntVec based on the provided GaugeOpts and -// partitioned by the given label names. -func NewGaugeIntVec(opts prometheus.GaugeOpts, labelNames []string) GaugeIntVec { - desc := prometheus.NewDesc( - prometheus.BuildFQName(opts.Namespace, opts.Subsystem, opts.Name), - opts.Help, - labelNames, - opts.ConstLabels, - ) - return GaugeIntVec{ - v: prometheus.NewMetricVec(desc, func(lvs ...string) prometheus.Metric { - return &GaugeInt{ - desc: desc, - labelPairs: prometheus.MakeLabelPairs(desc, lvs), - } - }), - } -} - -// NewCounterIntVec creates a new CounterIntVec based on the provided -// CounterOpts and partitioned by the given label names. -func NewCounterIntVec(opts prometheus.CounterOpts, labelNames []string) CounterIntVec { - desc := prometheus.NewDesc( - prometheus.BuildFQName(opts.Namespace, opts.Subsystem, opts.Name), - opts.Help, - labelNames, - opts.ConstLabels, - ) - return CounterIntVec{ - v: prometheus.NewMetricVec(desc, func(lvs ...string) prometheus.Metric { - return &CounterInt{desc: desc, labelPairs: prometheus.MakeLabelPairs(desc, lvs)} - }), - } -} - -func (v GaugeIntVec) Describe(ch chan<- *prometheus.Desc) { v.v.Describe(ch) } -func (v GaugeIntVec) Collect(ch chan<- prometheus.Metric) { v.v.Collect(ch) } -func (v GaugeIntVec) WithLabelValues(lvs ...string) *GaugeInt { - return utils.OrPanic1(v.v.GetMetricWithLabelValues(lvs...)).(*GaugeInt) -} - -func (v CounterIntVec) Describe(ch chan<- *prometheus.Desc) { v.v.Describe(ch) } -func (v CounterIntVec) Collect(ch chan<- prometheus.Metric) { v.v.Collect(ch) } -func (v CounterIntVec) WithLabelValues(lvs ...string) *CounterInt { - return utils.OrPanic1(v.v.GetMetricWithLabelValues(lvs...)).(*CounterInt) -} diff --git a/sei-tendermint/libs/utils/prometheus/prometheus_test.go b/sei-tendermint/libs/utils/prometheus/prometheus_test.go deleted file mode 100644 index abc68eec8e..0000000000 --- a/sei-tendermint/libs/utils/prometheus/prometheus_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package prometheus - -import ( - "testing" - - stdprometheus "github.com/prometheus/client_golang/prometheus" - dto "github.com/prometheus/client_model/go" - - "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" -) - -func TestGaugeInt(t *testing.T) { - desc := stdprometheus.NewDesc("test_gauge_int", "help", nil, nil) - g := &GaugeInt{ - desc: desc, - labelPairs: stdprometheus.MakeLabelPairs(desc, nil), - } - - g.Set(10) - g.Add(5) - g.Add(-3) - - metric := &dto.Metric{} - require.NoError(t, g.Write(metric)) - require.NotNil(t, metric.Gauge) - require.Equal(t, float64(12), metric.GetGauge().GetValue()) -} - -func TestCounterInt(t *testing.T) { - desc := stdprometheus.NewDesc("test_counter_int", "help", nil, nil) - c := &CounterInt{ - desc: desc, - labelPairs: stdprometheus.MakeLabelPairs(desc, nil), - } - - c.Add(5) - - metric := &dto.Metric{} - require.NoError(t, c.Write(metric)) - require.NotNil(t, metric.Counter) - require.Equal(t, float64(5), metric.GetCounter().GetValue()) -} - -func TestCounterIntRejectsNegative(t *testing.T) { - desc := stdprometheus.NewDesc("test_counter_int_negative", "help", nil, nil) - c := &CounterInt{ - desc: desc, - labelPairs: stdprometheus.MakeLabelPairs(desc, nil), - } - - c.Add(5) - c.Add(-1) - - metric := &dto.Metric{} - require.NoError(t, c.Write(metric)) - require.NotNil(t, metric.Counter) - require.Equal(t, float64(5), metric.GetCounter().GetValue()) -} - -func TestGaugeIntVec(t *testing.T) { - vec := NewGaugeIntVec(stdprometheus.GaugeOpts{ - Name: "test_gauge_int_vec", - Help: "help", - }, []string{"peer"}) - - g := vec.WithLabelValues("p1") - g.Add(7) - - dtoMetric := &dto.Metric{} - require.NoError(t, g.Write(dtoMetric)) - require.Equal(t, float64(7), dtoMetric.GetGauge().GetValue()) - require.Len(t, dtoMetric.Label, 1) - require.Equal(t, "peer", dtoMetric.Label[0].GetName()) - require.Equal(t, "p1", dtoMetric.Label[0].GetValue()) -} - -func TestCounterIntVec(t *testing.T) { - vec := NewCounterIntVec(stdprometheus.CounterOpts{ - Name: "test_counter_int_vec", - Help: "help", - }, []string{"peer", "direction"}) - - counter := vec.WithLabelValues("p1", "in") - counter.Add(3) - - dtoMetric := &dto.Metric{} - require.NoError(t, counter.Write(dtoMetric)) - require.Equal(t, float64(3), dtoMetric.GetCounter().GetValue()) - require.Len(t, dtoMetric.Label, 2) - // MakeLabelPairs normalizes labels to lexicographic order by label name. - require.Equal(t, "direction", dtoMetric.Label[0].GetName()) - require.Equal(t, "in", dtoMetric.Label[0].GetValue()) - require.Equal(t, "peer", dtoMetric.Label[1].GetName()) - require.Equal(t, "p1", dtoMetric.Label[1].GetValue()) -} diff --git a/sei-tendermint/light/example_test.go b/sei-tendermint/light/example_test.go index d5f2ee0d49..11056e12ff 100644 --- a/sei-tendermint/light/example_test.go +++ b/sei-tendermint/light/example_test.go @@ -5,7 +5,6 @@ import ( "time" "github.com/sei-protocol/sei-chain/sei-tendermint/abci/example/kvstore" - "github.com/sei-protocol/sei-chain/sei-tendermint/config" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/light" "github.com/sei-protocol/sei-chain/sei-tendermint/light/provider" @@ -33,7 +32,7 @@ func TestExampleClient(t *testing.T) { defer func() { _ = closer(ctx) }() dbDir := t.TempDir() - chainID := config.TestLoadGenesis(conf).ChainID + chainID := conf.ChainID() primary, err := httpp.New(chainID, conf.RPC.ListenAddress) if err != nil { diff --git a/sei-tendermint/light/light_test.go b/sei-tendermint/light/light_test.go index 0290ec0e3a..4d22a3a850 100644 --- a/sei-tendermint/light/light_test.go +++ b/sei-tendermint/light/light_test.go @@ -11,7 +11,6 @@ import ( dbm "github.com/tendermint/tm-db" "github.com/sei-protocol/sei-chain/sei-tendermint/abci/example/kvstore" - "github.com/sei-protocol/sei-chain/sei-tendermint/config" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/light" "github.com/sei-protocol/sei-chain/sei-tendermint/light/provider" @@ -45,7 +44,7 @@ func TestClientIntegration_Update(t *testing.T) { require.NoError(t, err) defer os.RemoveAll(dbDir) - chainID := config.TestLoadGenesis(conf).ChainID + chainID := conf.ChainID() primary, err := httpp.New(chainID, conf.RPC.ListenAddress) require.NoError(t, err) @@ -100,7 +99,7 @@ func TestClientIntegration_VerifyLightBlockAtHeight(t *testing.T) { defer func() { require.NoError(t, closer(ctx)) }() dbDir := t.TempDir() - chainID := config.TestLoadGenesis(conf).ChainID + chainID := conf.ChainID() primary, err := httpp.New(chainID, conf.RPC.ListenAddress) require.NoError(t, err) @@ -172,7 +171,7 @@ func TestClientStatusRPC(t *testing.T) { defer func() { require.NoError(t, closer(ctx)) }() dbDir := t.TempDir() - chainID := config.TestLoadGenesis(conf).ChainID + chainID := conf.ChainID() primary, err := httpp.New(chainID, conf.RPC.ListenAddress) require.NoError(t, err) diff --git a/sei-tendermint/node/node.go b/sei-tendermint/node/node.go index 72ca5e4cd6..2388d5ffee 100644 --- a/sei-tendermint/node/node.go +++ b/sei-tendermint/node/node.go @@ -6,15 +6,12 @@ import ( "net" "net/http" _ "net/http/pprof" // nolint: gosec // securely exposed on separate, optional port - "slices" "strings" "time" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" - dto "github.com/prometheus/client_model/go" "go.opentelemetry.io/otel/sdk/trace" - "google.golang.org/protobuf/proto" atypes "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/config" @@ -47,41 +44,6 @@ import ( _ "github.com/lib/pq" // provide the psql db driver ) -type chainIDGatherer struct{ chainID string } - -func (g chainIDGatherer) Gather() ([]*dto.MetricFamily, error) { - metricFamilies, err := prometheus.DefaultGatherer.Gather() - if err != nil { - return nil, err - } - for _, metricFamily := range metricFamilies { - for _, metric := range metricFamily.Metric { - if hasMetricLabel(metric, "chain_id") { - continue - } - labels := slices.Clone(metric.Label) - labels = append(labels, &dto.LabelPair{ - Name: proto.String("chain_id"), - Value: proto.String(g.chainID), - }) - slices.SortFunc(labels, func(a, b *dto.LabelPair) int { - return strings.Compare(a.GetName(), b.GetName()) - }) - metric.Label = labels - } - } - return metricFamilies, nil -} - -func hasMetricLabel(metric *dto.Metric, name string) bool { - for _, label := range metric.GetLabel() { - if label.GetName() == name { - return true - } - } - return false -} - // nodeImpl is the highest level interface to a full Tendermint node. // It includes all configuration information and running services. type nodeImpl struct { @@ -127,6 +89,7 @@ func makeNode( genesisDocProvider genesisDocProvider, dbProvider config.DBProvider, tracerProviderOptions []trace.TracerProviderOption, + nodeMetrics *NodeMetrics, consensusPolicy types.ConsensusPolicy, ) (_ local.NodeService, err error) { var cancel context.CancelFunc @@ -167,6 +130,7 @@ func makeNode( eventLog, err = eventlog.New(eventlog.LogSettings{ WindowSize: w, MaxItems: cfg.RPC.EventLogMaxItems, + Metrics: nodeMetrics.eventlog, }) if err != nil { return nil, fmt.Errorf("initializing event log: %w", err) @@ -179,6 +143,7 @@ func makeNode( indexerService := indexer.NewService(indexer.ServiceArgs{ Sinks: eventSinks, EventBus: eventBus, + Metrics: nodeMetrics.indexer, }) privValidator, err := createPrivval(ctx, cfg, genDoc, filePrivval) @@ -237,6 +202,7 @@ func makeNode( gigaValidatorKey = utils.Some(atypes.SecretKeyFromED25519(filePrivval.Key.PrivKey)) } router, peerCloser, err := createRouter( + nodeMetrics.p2p, node.NodeInfo, nodeKey, gigaValidatorKey, @@ -254,7 +220,7 @@ func makeNode( node.rpcEnv.Router = router evReactor, evPool, edbCloser, err := createEvidenceReactor(cfg, dbProvider, - stateStore, blockStore, node.router, eventBus) + stateStore, blockStore, node.router, nodeMetrics.evidence, eventBus) closers = append(closers, edbCloser) if err != nil { return nil, fmt.Errorf("createEvidenceReactor(): %w", err) @@ -275,7 +241,7 @@ func makeNode( } if !gigaEnabled { - mp := mempool.NewTxMempool(cfg.Mempool.ToMempoolConfig(), proxyApp, sm.TxConstraintsFetcherFromStore(stateStore)) + mp := mempool.NewTxMempool(cfg.Mempool.ToMempoolConfig(), proxyApp, nodeMetrics.mempool, sm.TxConstraintsFetcherFromStore(stateStore)) node.mempool = utils.Some(mp) node.rpcEnv.Mempool = utils.Some(mp) mpReactor, err := mempoolreactor.NewReactor(cfg.Mempool, mp, router) @@ -293,6 +259,7 @@ func makeNode( evPool, blockStore, eventBus, + nodeMetrics.state, consensusPolicy, ) @@ -325,6 +292,7 @@ func makeNode( evPool, eventBus, tracerProviderOptions, + nodeMetrics.consensus, ) node.rpcEnv.ConsensusState = utils.Some[rpccore.ConsensusState](csState) @@ -333,6 +301,7 @@ func makeNode( node.router, eventBus, waitSync, + nodeMetrics.consensus, cfg, ) if err != nil { @@ -352,6 +321,7 @@ func makeNode( BlockExec: blockExec, ConsReactor: utils.Some[blocksync.ConsensusReactor](csReactor), BlockSync: blockSync && !stateSync, + Metrics: nodeMetrics.consensus, EventBus: eventBus, RestartEvent: restartEvent, SelfRemediationConfig: cfg.SelfRemediation, @@ -366,9 +336,9 @@ func makeNode( // Make ConsensusReactor. Don't enable fully if doing a state sync and/or block sync first. // FIXME We need to update metrics here, since other reactors don't have access to them. if stateSync { - consensus.Global.StateSyncingAt().Set(1) + nodeMetrics.consensus.StateSyncing.Set(1) } else if blockSync { - consensus.Global.BlockSyncingAt().Set(1) + nodeMetrics.consensus.BlockSyncing.Set(1) } postSyncHook := func(ctx context.Context, state sm.State) error { @@ -408,6 +378,7 @@ func makeNode( stateStore, blockStore, cfg.StateSync.TempDir, + nodeMetrics.statesync, eventBus, // the post-sync operation postSyncHook, @@ -638,15 +609,11 @@ func (n *nodeImpl) OnStop() { // startPrometheusServer starts a Prometheus HTTP server, listening for metrics // collectors on addr. func (n *nodeImpl) startPrometheusServer(ctx context.Context, addr string) *http.Server { - gatherer := chainIDGatherer{ - chainID: n.genesisDoc.ChainID, - } - srv := &http.Server{ Addr: addr, Handler: promhttp.InstrumentMetricHandler( prometheus.DefaultRegisterer, promhttp.HandlerFor( - gatherer, + prometheus.DefaultGatherer, promhttp.HandlerOpts{MaxRequestsInFlight: n.config.Instrumentation.MaxOpenConnections}, ), ), @@ -708,6 +675,57 @@ func defaultGenesisDocProviderFunc(cfg *config.Config) genesisDocProvider { } } +type NodeMetrics struct { + consensus *consensus.Metrics + eventlog *eventlog.Metrics + indexer *indexer.Metrics + mempool *mempool.Metrics + p2p *p2p.Metrics + proxy *proxy.Metrics + state *sm.Metrics + statesync *statesync.Metrics + evidence *evidence.Metrics +} + +// metricsProvider returns consensus, p2p, mempool, state, statesync Metrics. +type metricsProvider func(chainID string) *NodeMetrics + +func NoOpMetricsProvider() *NodeMetrics { + return &NodeMetrics{ + consensus: consensus.NopMetrics(), + indexer: indexer.NopMetrics(), + mempool: mempool.NopMetrics(), + p2p: p2p.NopMetrics(), + proxy: proxy.NopMetrics(), + state: sm.NopMetrics(), + statesync: statesync.NopMetrics(), + evidence: evidence.NopMetrics(), + } +} + +// defaultMetricsProvider returns Metrics build using Prometheus client library +// if Prometheus is enabled. Otherwise, it returns no-op Metrics. +func DefaultMetricsProvider(cfg *config.InstrumentationConfig) metricsProvider { + return func(chainID string) *NodeMetrics { + if cfg.Prometheus { + return &NodeMetrics{ + consensus: consensus.PrometheusMetrics(cfg.Namespace, "chain_id", chainID), + eventlog: eventlog.PrometheusMetrics(cfg.Namespace, "chain_id", chainID), + indexer: indexer.PrometheusMetrics(cfg.Namespace, "chain_id", chainID), + mempool: mempool.PrometheusMetrics(cfg.Namespace, "chain_id", chainID), + p2p: p2p.PrometheusMetrics(cfg.Namespace, "chain_id", chainID), + proxy: proxy.PrometheusMetrics(cfg.Namespace, "chain_id", chainID), + state: sm.PrometheusMetrics(cfg.Namespace, "chain_id", chainID), + statesync: statesync.PrometheusMetrics(cfg.Namespace, "chain_id", chainID), + evidence: evidence.PrometheusMetrics(cfg.Namespace, "chain_id", chainID), + } + } + return NoOpMetricsProvider() + } +} + +//------------------------------------------------------------------------------ + // LoadStateFromDBOrGenesisDocProvider attempts to load the state from the // database, or creates one using the given genesisDocProvider. On success this also // returns the genesis doc loaded through the given provider. diff --git a/sei-tendermint/node/node_test.go b/sei-tendermint/node/node_test.go index 5645a117ea..cd07b9a1a4 100644 --- a/sei-tendermint/node/node_test.go +++ b/sei-tendermint/node/node_test.go @@ -47,6 +47,7 @@ func newLocalNodeService(ctx context.Context, cfg *config.Config) (service.Servi app, nil, nil, + DefaultMetricsProvider(cfg.Instrumentation)(cfg.ChainID()), types.DefaultConsensusPolicy(), ) } @@ -114,6 +115,7 @@ func TestNodeRestartEventAllowsRecreate(t *testing.T) { app, nil, nil, + DefaultMetricsProvider(cfg.Instrumentation)(cfg.ChainID()), types.DefaultConsensusPolicy(), ) require.NoError(t, err) @@ -210,7 +212,7 @@ func TestNodeSetPrivValTCP(t *testing.T) { signerServer := privval.NewSignerServer( dialerEndpoint, - config.TestLoadGenesis(cfg).ChainID, + cfg.ChainID(), types.NewMockPV(), ) @@ -269,7 +271,7 @@ func TestNodeSetPrivValIPC(t *testing.T) { pvsc := privval.NewSignerServer( dialerEndpoint, - config.TestLoadGenesis(cfg).ChainID, + cfg.ChainID(), types.NewMockPV(), ) @@ -310,13 +312,14 @@ func TestCreateProposalBlock(t *testing.T) { mp := mempool.NewTxMempool( cfg.Mempool.ToMempoolConfig(), proxyApp, + mempool.NopMetrics(), mempool.NopTxConstraintsFetcher, ) // Make EvidencePool evidenceDB := dbm.NewMemDB() blockStore := store.NewBlockStore(dbm.NewMemDB()) - evidencePool := evidence.NewPool(evidenceDB, stateStore, blockStore, nil) + evidencePool := evidence.NewPool(evidenceDB, stateStore, blockStore, evidence.NopMetrics(), nil) // fill the evidence pool with more evidence // than can fit in a block @@ -351,6 +354,7 @@ func TestCreateProposalBlock(t *testing.T) { evidencePool, blockStore, eventBus, + sm.NopMetrics(), types.DefaultConsensusPolicy(), ) @@ -405,6 +409,7 @@ func TestMaxTxsProposalBlockSize(t *testing.T) { mp := mempool.NewTxMempool( cfg.Mempool.ToMempoolConfig(), proxyApp, + mempool.NopMetrics(), mempool.NopTxConstraintsFetcher, ) @@ -424,6 +429,7 @@ func TestMaxTxsProposalBlockSize(t *testing.T) { sm.EmptyEvidencePool{}, blockStore, eventBus, + sm.NopMetrics(), types.DefaultConsensusPolicy(), ) @@ -468,6 +474,7 @@ func TestMaxProposalBlockSize(t *testing.T) { mp := mempool.NewTxMempool( cfg.Mempool.ToMempoolConfig(), proxyApp, + mempool.NopMetrics(), mempool.NopTxConstraintsFetcher, ) @@ -494,6 +501,7 @@ func TestMaxProposalBlockSize(t *testing.T) { sm.EmptyEvidencePool{}, blockStore, eventBus, + sm.NopMetrics(), types.DefaultConsensusPolicy(), ) @@ -597,6 +605,7 @@ func TestNodeNewSeedNode(t *testing.T) { config.DefaultDBProvider, nodeKey, defaultGenesisDocProviderFunc(cfg), + DefaultMetricsProvider(cfg.Instrumentation)(cfg.ChainID()), ) t.Cleanup(ns.Wait) t.Cleanup(leaktest.CheckTimeout(t, time.Second)) diff --git a/sei-tendermint/node/public.go b/sei-tendermint/node/public.go index ef621cc4ef..628e5e628b 100644 --- a/sei-tendermint/node/public.go +++ b/sei-tendermint/node/public.go @@ -29,9 +29,10 @@ func New( app abci.Application, gen *tmtypes.GenesisDoc, tracerProviderOptions []trace.TracerProviderOption, + nodeMetrics *NodeMetrics, consensusPolicy tmtypes.ConsensusPolicy, ) (local.NodeService, error) { - proxyApp := proxy.New(app) + proxyApp := proxy.New(app, nodeMetrics.proxy) nodeKey, err := tmtypes.LoadOrGenNodeKey(conf.NodeKeyFile()) if err != nil { return nil, fmt.Errorf("failed to load or gen node key %s: %w", conf.NodeKeyFile(), err) @@ -62,6 +63,7 @@ func New( genProvider, config.DefaultDBProvider, tracerProviderOptions, + nodeMetrics, consensusPolicy, ) case config.ModeSeed: @@ -70,6 +72,7 @@ func New( config.DefaultDBProvider, nodeKey, genProvider, + nodeMetrics, ) default: return nil, fmt.Errorf("%q is not a valid mode", conf.Mode) diff --git a/sei-tendermint/node/seed.go b/sei-tendermint/node/seed.go index 359db8506c..840355e972 100644 --- a/sei-tendermint/node/seed.go +++ b/sei-tendermint/node/seed.go @@ -51,6 +51,7 @@ func makeSeedNode( dbProvider config.DBProvider, nodeKey types.NodeKey, genesisDocProvider genesisDocProvider, + nodeMetrics *NodeMetrics, ) (_ local.NodeService, err error) { closers := []closer{} defer func() { @@ -78,6 +79,7 @@ func makeSeedNode( } router, peerCloser, err := createRouter( + nodeMetrics.p2p, func() *types.NodeInfo { return &nodeInfo }, nodeKey, utils.None[atypes.SecretKey](), @@ -119,7 +121,7 @@ func makeSeedNode( pexReactor: pexReactor, rpcEnv: &rpccore.Environment{ - App: proxy.New(abci.BaseApplication{}), + App: proxy.New(abci.BaseApplication{}, proxy.NopMetrics()), StateStore: stateStore, BlockStore: blockStore, diff --git a/sei-tendermint/node/setup.go b/sei-tendermint/node/setup.go index 0b81dbe4db..71c19ff208 100644 --- a/sei-tendermint/node/setup.go +++ b/sei-tendermint/node/setup.go @@ -158,6 +158,7 @@ func createEvidenceReactor( store sm.Store, blockStore *store.BlockStore, router *p2p.Router, + metrics *evidence.Metrics, eventBus *eventbus.EventBus, ) (*evidence.Reactor, *evidence.Pool, closer, error) { evidenceDB, err := dbProvider(&config.DBContext{ID: "evidence", Config: cfg}) @@ -165,7 +166,7 @@ func createEvidenceReactor( return nil, nil, func() error { return nil }, fmt.Errorf("unable to initialize evidence db: %w", err) } - evidencePool := evidence.NewPool(evidenceDB, store, blockStore, eventBus) + evidencePool := evidence.NewPool(evidenceDB, store, blockStore, metrics, eventBus) evidenceReactor, err := evidence.NewReactor(router, evidencePool) if err != nil { return nil, nil, evidenceDB.Close, fmt.Errorf("evidence.NewReactor(): %w", err) @@ -386,6 +387,7 @@ func buildFullnodeGigaConfig( } func createRouter( + p2pMetrics *p2p.Metrics, nodeInfoProducer func() *types.NodeInfo, nodeKey types.NodeKey, validatorKey utils.Option[atypes.SecretKey], @@ -495,6 +497,7 @@ func createRouter( } closer = peerDB.Close router, err := p2p.NewRouter( + p2pMetrics, p2p.NodeSecretKey(nodeKey), nodeInfoProducer, peerDB, diff --git a/sei-tendermint/rpc/client/rpc_test.go b/sei-tendermint/rpc/client/rpc_test.go index e6a6ff8423..3ca2c56139 100644 --- a/sei-tendermint/rpc/client/rpc_test.go +++ b/sei-tendermint/rpc/client/rpc_test.go @@ -508,7 +508,7 @@ func TestClientMethodCalls(t *testing.T) { t.Run("BroadcastDuplicateVote", func(t *testing.T) { ctx := t.Context() - chainID := config.TestLoadGenesis(conf).ChainID + chainID := conf.ChainID() // make sure that the node has produced enough blocks waitForBlock(ctx, t, c, 2) diff --git a/sei-tendermint/rpc/jsonrpc/client/integration_test.go b/sei-tendermint/rpc/jsonrpc/client/integration_test.go index 89042502cc..75e8937b6b 100644 --- a/sei-tendermint/rpc/jsonrpc/client/integration_test.go +++ b/sei-tendermint/rpc/jsonrpc/client/integration_test.go @@ -8,6 +8,7 @@ package client import ( "bytes" + "context" "errors" "net" "regexp" diff --git a/sei-tendermint/rpc/test/helpers.go b/sei-tendermint/rpc/test/helpers.go index 20b03f14a2..5cfefb70bb 100644 --- a/sei-tendermint/rpc/test/helpers.go +++ b/sei-tendermint/rpc/test/helpers.go @@ -103,6 +103,7 @@ func StartTendermint( app, nil, []trace.TracerProviderOption{}, + node.NoOpMetricsProvider(), types.DefaultConsensusPolicy(), ) if err != nil { diff --git a/sei-tendermint/scripts/metricsgen/metricsgen.go b/sei-tendermint/scripts/metricsgen/metricsgen.go index 83bbe33387..d63060aacc 100644 --- a/sei-tendermint/scripts/metricsgen/metricsgen.go +++ b/sei-tendermint/scripts/metricsgen/metricsgen.go @@ -22,8 +22,6 @@ import ( "strconv" "strings" "text/template" - "unicode" - "unicode/utf8" ) func init() { @@ -40,17 +38,13 @@ Options: } } -const ( - stdMetricsPackageName = "github.com/prometheus/client_golang/prometheus" - intMetricsPackageName = "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/prometheus" -) +const metricsPackageName = "github.com/go-kit/kit/metrics" const ( metricNameTag = "metrics_name" labelsTag = "metrics_labels" - bucketsTag = "metrics_buckets" - counterIntVec = "CounterIntVec" - gaugeIntVec = "GaugeIntVec" + bucketTypeTag = "metrics_buckettype" + bucketSizeTag = "metrics_bucketsizes" ) var ( @@ -59,9 +53,9 @@ var ( ) var bucketType = map[string]string{ - "exprange": "prometheus.ExponentialBucketsRange", - "exp": "prometheus.ExponentialBuckets", - "lin": "prometheus.LinearBuckets", + "exprange": "stdprometheus.ExponentialBucketsRange", + "exp": "stdprometheus.ExponentialBuckets", + "lin": "stdprometheus.LinearBuckets", } var tmpl = template.Must(template.New("tmpl").Parse(`// Code generated by metricsgen. DO NOT EDIT. @@ -69,83 +63,67 @@ var tmpl = template.Must(template.New("tmpl").Parse(`// Code generated by metric package {{ .Package }} import ( - "github.com/prometheus/client_golang/prometheus" -{{- if .UsesIntMetrics }} - tmprometheus "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/prometheus" -{{- end }} + "github.com/go-kit/kit/metrics/discard" + prometheus "github.com/go-kit/kit/metrics/prometheus" + stdprometheus "github.com/prometheus/client_golang/prometheus" ) -var Global = {{ .ConstructorName }}() - -func init() { - prometheus.MustRegister( -{{- range $metric := .ParsedMetrics }} - Global.{{ $metric.FieldName }}, -{{- end }} - ) -} - -func {{ .ConstructorName }}() *{{ .StructName }} { - return &{{ .StructName }}{ -{{- range $metric := .ParsedMetrics }} - {{ $metric.FieldName }}: {{ $metric.ConstructorPackage }}.{{ $metric.ConstructorName }}(prometheus.{{ $metric.OptsTypeName }}{ - Namespace: MetricsNamespace, +func PrometheusMetrics(namespace string, labelsAndValues...string) *Metrics { + labels := []string{} + for i := 0; i < len(labelsAndValues); i += 2 { + labels = append(labels, labelsAndValues[i]) + } + return &Metrics{ + {{ range $metric := .ParsedMetrics }} + {{- $metric.FieldName }}: prometheus.New{{ $metric.TypeName }}From(stdprometheus.{{$metric.TypeName }}Opts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "{{$metric.MetricName }}", Help: "{{ $metric.Description }}", -{{- if $metric.HistogramOptions.DefaultBuckets }} - Buckets: prometheus.DefBuckets, -{{- else if ne $metric.HistogramOptions.BucketType "" }} + {{ if ne $metric.HistogramOptions.BucketType "" }} Buckets: {{ $metric.HistogramOptions.BucketType }}({{ $metric.HistogramOptions.BucketSizes }}), -{{- else if ne $metric.HistogramOptions.BucketSizes "" }} + {{ else if ne $metric.HistogramOptions.BucketSizes "" }} Buckets: []float64{ {{ $metric.HistogramOptions.BucketSizes }} }, -{{- end }} - }, {{ if eq (len $metric.Labels) 0 }}nil{{ else }}[]string{ {{$metric.Labels}} }{{ end }}), -{{- end }} + {{ end }} + {{- if eq (len $metric.Labels) 0 }} + }, labels).With(labelsAndValues...), + {{ else }} + }, append(labels, {{$metric.Labels}})).With(labelsAndValues...), + {{ end }} + {{- end }} } } -{{ range $metric := .ParsedMetrics }} -func (m *{{ $.StructName }}) {{ $metric.FieldName }}At({{ $metric.MethodParams }}) {{ $metric.MethodReturnType }} { - return m.{{ $metric.FieldName }}.WithLabelValues({{ $metric.MethodArgs }}) -} -{{ end }} +func NopMetrics() *Metrics { + return &Metrics{ + {{- range $metric := .ParsedMetrics }} + {{ $metric.FieldName }}: discard.New{{ $metric.TypeName }}(), + {{- end }} + } +} `)) // ParsedMetricField is the data parsed for a single field of a metric struct. type ParsedMetricField struct { - TypeName string - ConstructorPackage string - ConstructorName string - OptsTypeName string - FieldName string - MetricName string - Description string - Labels string - LabelNames []string - - MethodParams string - MethodArgs string - MethodReturnType string + TypeName string + FieldName string + MetricName string + Description string + Labels string HistogramOptions HistogramOpts } type HistogramOpts struct { - BucketType string - BucketSizes string - NoBuckets bool - DefaultBuckets bool + BucketType string + BucketSizes string } // TemplateData is all of the data required for rendering a metric file template. type TemplateData struct { - Package string - StructName string - ConstructorName string - ParsedMetrics []ParsedMetricField - UsesIntMetrics bool + Package string + ParsedMetrics []ParsedMetricField } func main() { @@ -188,59 +166,30 @@ func ParseMetricsDir(dir string, structName string) (TemplateData, error) { return TemplateData{}, fmt.Errorf("no go pacakges found in %s", dir) } - // Grab the package name and files. - var ( - pkgName string - pkgFiles map[string]*ast.File - ) - for name, parsedPkg := range d { - pkgName = name - pkgFiles = parsedPkg.Files - break + // Grab the package name. + var pkgName string + var pkg *ast.Package // nolint:staticcheck // SA1019: will replace all metrics gen with OTEL and not worth fixing. + for pkgName, pkg = range d { } td := TemplateData{ - Package: pkgName, - StructName: structName, - ConstructorName: constructorName(structName), + Package: pkgName, } // Grab the metrics struct - m, metricsPackageNames, err := findMetricsStruct(pkgFiles, structName) + m, mPkgName, err := findMetricsStruct(pkg.Files, structName) if err != nil { return TemplateData{}, err } for _, f := range m.Fields.List { - if !isMetric(f.Type, metricsPackageNames) { + if !isMetric(f.Type, mPkgName) { continue } - pmf, err := parseMetricField(f) - if err != nil { - return TemplateData{}, err - } - if pmf.ConstructorPackage == "tmprometheus" || strings.HasPrefix(pmf.HistogramOptions.BucketType, "tmprometheus.") { - td.UsesIntMetrics = true - } + pmf := parseMetricField(f) td.ParsedMetrics = append(td.ParsedMetrics, pmf) } return td, err } -func constructorName(structName string) string { - if structName == "" { - return "NewMetrics" - } - if isExported(structName) { - return "New" + structName - } - r, size := utf8.DecodeRuneInString(structName) - return "new" + string(unicode.ToUpper(r)) + structName[size:] -} - -func isExported(name string) bool { - r, _ := utf8.DecodeRuneInString(name) - return unicode.IsUpper(r) -} - // GenerateMetricsFile executes the metrics file template, writing the result // into the io.Writer. func GenerateMetricsFile(w io.Writer, td TemplateData) error { @@ -261,14 +210,14 @@ func GenerateMetricsFile(w io.Writer, td TemplateData) error { return nil } -func findMetricsStruct(files map[string]*ast.File, structName string) (*ast.StructType, map[string]struct{}, error) { +func findMetricsStruct(files map[string]*ast.File, structName string) (*ast.StructType, string, error) { var ( st *ast.StructType ) for _, file := range files { - metricsPackageNames, err := extractMetricsPackageNames(file.Imports) + mPkgName, err := extractMetricsPackageName(file.Imports) if err != nil { - return nil, nil, fmt.Errorf("unable to determine metrics package name: %v", err) + return nil, "", fmt.Errorf("unable to determine metrics package name: %v", err) } if !ast.FilterFile(file, func(name string) bool { return name == structName @@ -291,82 +240,33 @@ func findMetricsStruct(files map[string]*ast.File, structName string) (*ast.Stru } }) if err != nil { - return nil, nil, err + return nil, "", err } if st != nil { - return st, metricsPackageNames, nil + return st, mPkgName, nil } } - return nil, nil, fmt.Errorf("target struct %q not found in dir", structName) + return nil, "", fmt.Errorf("target struct %q not found in dir", structName) } -func parseMetricField(f *ast.Field) (ParsedMetricField, error) { - typeName := extractTypeName(f.Type) - labelNames := extractLabelNames(f.Tag) - if err := validateLabelNames(labelNames); err != nil { - return ParsedMetricField{}, fmt.Errorf("metric %q: %w", f.Names[0].String(), err) - } +func parseMetricField(f *ast.Field) ParsedMetricField { pmf := ParsedMetricField{ - Description: extractHelpMessage(f.Doc), - MetricName: extractFieldName(f.Names[0].String(), f.Tag), - FieldName: f.Names[0].String(), - TypeName: typeName, - ConstructorPackage: extractConstructorPackage(typeName), - ConstructorName: "New" + typeName, - OptsTypeName: extractOptsTypeName(typeName), - LabelNames: labelNames, - MethodReturnType: extractMethodReturnType(typeName), + Description: extractHelpMessage(f.Doc), + MetricName: extractFieldName(f.Names[0].String(), f.Tag), + FieldName: f.Names[0].String(), + TypeName: extractTypeName(f.Type), + Labels: extractLabels(f.Tag), } - pmf.Labels = joinQuotedLabels(pmf.LabelNames) - pmf.MethodParams = buildMethodParams(pmf.LabelNames) - pmf.MethodArgs = buildMethodArgs(pmf.LabelNames) - if pmf.OptsTypeName == "HistogramOpts" { + if pmf.TypeName == "Histogram" { pmf.HistogramOptions = extractHistogramOptions(f.Tag) } - return pmf, nil + return pmf } func extractTypeName(e ast.Expr) string { return strings.TrimPrefix(path.Ext(types.ExprString(e)), ".") } -func extractConstructorPackage(typeName string) string { - switch typeName { - case counterIntVec, gaugeIntVec, "HistogramVec": - return "tmprometheus" - default: - return "prometheus" - } -} - -func extractOptsTypeName(typeName string) string { - switch typeName { - case counterIntVec: - return "CounterOpts" - case gaugeIntVec: - return "GaugeOpts" - default: - return strings.TrimSuffix(typeName, "Vec") + "Opts" - } -} - -func extractMethodReturnType(typeName string) string { - switch typeName { - case "CounterVec": - return "prometheus.Counter" - case counterIntVec: - return "*tmprometheus.CounterInt" - case "GaugeVec": - return "prometheus.Gauge" - case gaugeIntVec: - return "*tmprometheus.GaugeInt" - case "HistogramVec": - return "*tmprometheus.Histogram" - default: - return "" - } -} - func extractHelpMessage(cg *ast.CommentGroup) string { if cg == nil { return "" @@ -382,111 +282,23 @@ func extractHelpMessage(cg *ast.CommentGroup) string { return strings.Join(help, " ") } -func isMetric(e ast.Expr, metricsPackageNames map[string]struct{}) bool { - expr := types.ExprString(e) - for packageName := range metricsPackageNames { - if strings.Contains(expr, fmt.Sprintf("%s.", packageName)) { - return true - } - } - return false +func isMetric(e ast.Expr, mPkgName string) bool { + return strings.Contains(types.ExprString(e), fmt.Sprintf("%s.", mPkgName)) } -func extractLabelNames(bl *ast.BasicLit) []string { +func extractLabels(bl *ast.BasicLit) string { if bl != nil { t := reflect.StructTag(strings.Trim(bl.Value, "`")) if v := t.Get(labelsTag); v != "" { parts := strings.Split(v, ",") res := make([]string, 0, len(parts)) for _, s := range parts { - res = append(res, strings.TrimSpace(s)) + res = append(res, strconv.Quote(strings.TrimSpace(s))) } - return res + return strings.Join(res, ",") } } - return nil -} - -func joinQuotedLabels(labels []string) string { - if len(labels) == 0 { - return "" - } - quoted := make([]string, 0, len(labels)) - for _, label := range labels { - quoted = append(quoted, strconv.Quote(label)) - } - return strings.Join(quoted, ",") -} - -func buildMethodParams(labels []string) string { - if len(labels) == 0 { - return "" - } - paramNames := buildMethodParamNames(labels) - params := make([]string, 0, len(labels)) - for _, paramName := range paramNames { - params = append(params, fmt.Sprintf("%s string", paramName)) - } - return strings.Join(params, ", ") -} - -func buildMethodArgs(labels []string) string { - if len(labels) == 0 { - return "" - } - return strings.Join(buildMethodParamNames(labels), ", ") -} - -func buildMethodParamNames(labels []string) []string { - if len(labels) == 0 { - return nil - } - if allLabelsAreValidIdentifiers(labels) { - return append([]string(nil), labels...) - } - paramNames := make([]string, 0, len(labels)) - for i, label := range labels { - paramNames = append(paramNames, mangleLabelName(i, label)) - } - return paramNames -} - -func allLabelsAreValidIdentifiers(labels []string) bool { - for _, label := range labels { - if !isValidLabelIdentifier(label) { - return false - } - } - return true -} - -func isValidLabelIdentifier(label string) bool { - if !token.IsIdentifier(label) { - return false - } - if label == "_" { - return false - } - if token.Lookup(label).IsKeyword() { - return false - } - _, isReserved := goReservedIdentifiers[label] - return !isReserved -} - -func validateLabelNames(labels []string) error { - seen := make(map[string]struct{}, len(labels)) - for _, label := range labels { - if _, ok := seen[label]; ok { - return fmt.Errorf("duplicate metrics label %q", label) - } - seen[label] = struct{}{} - } - return nil -} - -func mangleLabelName(i int, label string) string { - return fmt.Sprintf("l%d_%s", i, nonIdentifierChar.ReplaceAllString(label, "_")) + return "" } func extractFieldName(name string, tag *ast.BasicLit) string { @@ -500,109 +312,36 @@ func extractFieldName(name string, tag *ast.BasicLit) string { } func extractHistogramOptions(tag *ast.BasicLit) HistogramOpts { - if tag == nil { - return parseHistogramBuckets("") - } - t := reflect.StructTag(strings.Trim(tag.Value, "`")) - return parseHistogramBuckets(t.Get(bucketsTag)) -} - -func parseHistogramBuckets(spec string) HistogramOpts { - spec = strings.TrimSpace(spec) - if spec == "" { - return HistogramOpts{DefaultBuckets: true} - } - if spec == "none" { - return HistogramOpts{NoBuckets: true} - } - for name, expr := range bucketType { - if name == "none" { - continue + h := HistogramOpts{} + if tag != nil { + t := reflect.StructTag(strings.Trim(tag.Value, "`")) + if v := t.Get(bucketTypeTag); v != "" { + h.BucketType = bucketType[v] } - prefix := name + "(" - if strings.HasPrefix(spec, prefix) && strings.HasSuffix(spec, ")") { - return HistogramOpts{ - BucketType: expr, - BucketSizes: strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(spec, prefix), ")")), - } + if v := t.Get(bucketSizeTag); v != "" { + h.BucketSizes = v } } - return HistogramOpts{BucketSizes: spec} -} - -// ParseHistogramBuckets parses the metrics_buckets tag value into generator options. -func ParseHistogramBuckets(spec string) HistogramOpts { - return parseHistogramBuckets(spec) + return h } -func extractMetricsPackageNames(imports []*ast.ImportSpec) (map[string]struct{}, error) { - names := make(map[string]struct{}, 2) +func extractMetricsPackageName(imports []*ast.ImportSpec) (string, error) { for _, i := range imports { u, err := strconv.Unquote(i.Path.Value) if err != nil { - return nil, err + return "", err } - if u != stdMetricsPackageName && u != intMetricsPackageName { - continue - } - if i.Name != nil { - names[i.Name.Name] = struct{}{} - continue + if u == metricsPackageName { + if i.Name != nil { + return i.Name.Name, nil + } + return path.Base(u), nil } - names[path.Base(u)] = struct{}{} } - return names, nil + return "", nil } var capitalChange = regexp.MustCompile("([a-z0-9])([A-Z])") -var nonIdentifierChar = regexp.MustCompile(`[^a-zA-Z0-9_]`) - -var goReservedIdentifiers = map[string]struct{}{ - "any": {}, - "append": {}, - "bool": {}, - "byte": {}, - "cap": {}, - "clear": {}, - "close": {}, - "comparable": {}, - "complex": {}, - "complex128": {}, - "complex64": {}, - "copy": {}, - "delete": {}, - "error": {}, - "false": {}, - "float32": {}, - "float64": {}, - "imag": {}, - "int": {}, - "int16": {}, - "int32": {}, - "int64": {}, - "int8": {}, - "iota": {}, - "len": {}, - "make": {}, - "max": {}, - "min": {}, - "new": {}, - "nil": {}, - "panic": {}, - "print": {}, - "println": {}, - "real": {}, - "recover": {}, - "rune": {}, - "string": {}, - "true": {}, - "uint": {}, - "uint16": {}, - "uint32": {}, - "uint64": {}, - "uint8": {}, - "uintptr": {}, -} func toSnakeCase(str string) string { snake := capitalChange.ReplaceAllString(str, "${1}_${2}") diff --git a/sei-tendermint/scripts/metricsgen/metricsgen_test.go b/sei-tendermint/scripts/metricsgen/metricsgen_test.go index aa58a84cc4..3669cc9fc6 100644 --- a/sei-tendermint/scripts/metricsgen/metricsgen_test.go +++ b/sei-tendermint/scripts/metricsgen/metricsgen_test.go @@ -20,25 +20,15 @@ const testDataDir = "./testdata" func TestSimpleTemplate(t *testing.T) { m := metricsgen.ParsedMetricField{ - TypeName: "HistogramVec", - ConstructorPackage: "tmprometheus", - ConstructorName: "NewHistogramVec", - OptsTypeName: "HistogramOpts", - FieldName: "MyMetric", - MetricName: "request_count", - Description: "how many requests were made since the start of the process", - Labels: "\"first\",\"second\",\"third\"", - LabelNames: []string{"first", "second", "third"}, - - MethodParams: "first string, second string, third string", - MethodArgs: "first, second, third", - MethodReturnType: "*tmprometheus.Histogram", + TypeName: "Histogram", + FieldName: "MyMetric", + MetricName: "request_count", + Description: "how many requests were made since the start of the process", + Labels: "first, second, third", } td := metricsgen.TemplateData{ - Package: "mypack", - StructName: "Metrics", - ConstructorName: "NewMetrics", - ParsedMetrics: []metricsgen.ParsedMetricField{m}, + Package: "mypack", + ParsedMetrics: []metricsgen.ParsedMetricField{m}, } b := bytes.NewBuffer([]byte{}) err := metricsgen.GenerateMetricsFile(b, td) @@ -106,21 +96,15 @@ func TestParseMetricsStruct(t *testing.T) { { name: "basic", metricsStruct: `type Metrics struct { - myGauge *prometheus.GaugeVec + myGauge metrics.Gauge }`, expected: metricsgen.TemplateData{ - Package: pkgName, - StructName: "Metrics", - ConstructorName: "NewMetrics", + Package: pkgName, ParsedMetrics: []metricsgen.ParsedMetricField{ { - TypeName: "GaugeVec", - ConstructorPackage: "prometheus", - ConstructorName: "NewGaugeVec", - OptsTypeName: "GaugeOpts", - FieldName: "myGauge", - MetricName: "my_gauge", - MethodReturnType: "prometheus.Gauge", + TypeName: "Gauge", + FieldName: "myGauge", + MetricName: "my_gauge", }, }, }, @@ -128,103 +112,36 @@ func TestParseMetricsStruct(t *testing.T) { { name: "histogram", metricsStruct: "type Metrics struct {\n" + - "myHistogram *prometheus.HistogramVec `metrics_buckets:\"exp(1, 100, .8)\"`\n" + + "myHistogram metrics.Histogram `metrics_buckettype:\"exp\" metrics_bucketsizes:\"1, 100, .8\"`\n" + "}", expected: metricsgen.TemplateData{ - Package: pkgName, - StructName: "Metrics", - ConstructorName: "NewMetrics", - UsesIntMetrics: true, + Package: pkgName, ParsedMetrics: []metricsgen.ParsedMetricField{ { - TypeName: "HistogramVec", - ConstructorPackage: "tmprometheus", - ConstructorName: "NewHistogramVec", - OptsTypeName: "HistogramOpts", - FieldName: "myHistogram", - MetricName: "my_histogram", - MethodReturnType: "*tmprometheus.Histogram", + TypeName: "Histogram", + FieldName: "myHistogram", + MetricName: "my_histogram", HistogramOptions: metricsgen.HistogramOpts{ - BucketType: "prometheus.ExponentialBuckets", + BucketType: "stdprometheus.ExponentialBuckets", BucketSizes: "1, 100, .8", }, }, }, }, }, - { - name: "histogram without finite buckets", - metricsStruct: "type Metrics struct {\n" + - "myHistogram *prometheus.HistogramVec `metrics_buckets:\"none\"`\n" + - "}", - expected: metricsgen.TemplateData{ - Package: pkgName, - StructName: "Metrics", - ConstructorName: "NewMetrics", - UsesIntMetrics: true, - ParsedMetrics: []metricsgen.ParsedMetricField{ - { - TypeName: "HistogramVec", - ConstructorPackage: "tmprometheus", - ConstructorName: "NewHistogramVec", - OptsTypeName: "HistogramOpts", - FieldName: "myHistogram", - MetricName: "my_histogram", - MethodReturnType: "*tmprometheus.Histogram", - - HistogramOptions: metricsgen.HistogramOpts{ - NoBuckets: true, - }, - }, - }, - }, - }, - { - name: "histogram without explicit buckets uses defaults", - metricsStruct: "type Metrics struct {\n" + - "myHistogram *prometheus.HistogramVec\n" + - "}", - expected: metricsgen.TemplateData{ - Package: pkgName, - StructName: "Metrics", - ConstructorName: "NewMetrics", - UsesIntMetrics: true, - ParsedMetrics: []metricsgen.ParsedMetricField{ - { - TypeName: "HistogramVec", - ConstructorPackage: "tmprometheus", - ConstructorName: "NewHistogramVec", - OptsTypeName: "HistogramOpts", - FieldName: "myHistogram", - MetricName: "my_histogram", - MethodReturnType: "*tmprometheus.Histogram", - - HistogramOptions: metricsgen.HistogramOpts{ - DefaultBuckets: true, - }, - }, - }, - }, - }, { name: "labeled name", metricsStruct: "type Metrics struct {\n" + - "myCounter *prometheus.CounterVec `metrics_name:\"new_name\"`\n" + + "myCounter metrics.Counter `metrics_name:\"new_name\"`\n" + "}", expected: metricsgen.TemplateData{ - Package: pkgName, - StructName: "Metrics", - ConstructorName: "NewMetrics", + Package: pkgName, ParsedMetrics: []metricsgen.ParsedMetricField{ { - TypeName: "CounterVec", - ConstructorPackage: "prometheus", - ConstructorName: "NewCounterVec", - OptsTypeName: "CounterOpts", - FieldName: "myCounter", - MetricName: "new_name", - MethodReturnType: "prometheus.Counter", + TypeName: "Counter", + FieldName: "myCounter", + MetricName: "new_name", }, }, }, @@ -232,107 +149,33 @@ func TestParseMetricsStruct(t *testing.T) { { name: "metric labels", metricsStruct: "type Metrics struct {\n" + - "myCounter *prometheus.CounterVec `metrics_labels:\"label1,label2\"`\n" + - "}", - expected: metricsgen.TemplateData{ - Package: pkgName, - StructName: "Metrics", - ConstructorName: "NewMetrics", - ParsedMetrics: []metricsgen.ParsedMetricField{ - { - TypeName: "CounterVec", - ConstructorPackage: "prometheus", - ConstructorName: "NewCounterVec", - OptsTypeName: "CounterOpts", - FieldName: "myCounter", - MetricName: "my_counter", - Labels: "\"label1\",\"label2\"", - LabelNames: []string{"label1", "label2"}, - MethodParams: "label1 string, label2 string", - MethodArgs: "label1, label2", - MethodReturnType: "prometheus.Counter", - }, - }, - }, - }, - { - name: "keyword label falls back for all params", - metricsStruct: "type Metrics struct {\n" + - "myCounter *prometheus.CounterVec `metrics_labels:\"method,type\"`\n" + - "}", - expected: metricsgen.TemplateData{ - Package: pkgName, - StructName: "Metrics", - ConstructorName: "NewMetrics", - ParsedMetrics: []metricsgen.ParsedMetricField{ - { - TypeName: "CounterVec", - ConstructorPackage: "prometheus", - ConstructorName: "NewCounterVec", - OptsTypeName: "CounterOpts", - FieldName: "myCounter", - MetricName: "my_counter", - Labels: "\"method\",\"type\"", - LabelNames: []string{"method", "type"}, - MethodParams: "l0_method string, l1_type string", - MethodArgs: "l0_method, l1_type", - MethodReturnType: "prometheus.Counter", - }, - }, - }, - }, - { - name: "reserved and non-identifier labels fall back for all params", - metricsStruct: "type Metrics struct {\n" + - "myCounter *prometheus.CounterVec `metrics_labels:\"peer-id,error\"`\n" + + "myCounter metrics.Counter `metrics_labels:\"label1,label2\"`\n" + "}", expected: metricsgen.TemplateData{ - Package: pkgName, - StructName: "Metrics", - ConstructorName: "NewMetrics", + Package: pkgName, ParsedMetrics: []metricsgen.ParsedMetricField{ { - TypeName: "CounterVec", - ConstructorPackage: "prometheus", - ConstructorName: "NewCounterVec", - OptsTypeName: "CounterOpts", - FieldName: "myCounter", - MetricName: "my_counter", - Labels: "\"peer-id\",\"error\"", - LabelNames: []string{"peer-id", "error"}, - MethodParams: "l0_peer_id string, l1_error string", - MethodArgs: "l0_peer_id, l1_error", - MethodReturnType: "prometheus.Counter", + TypeName: "Counter", + FieldName: "myCounter", + MetricName: "my_counter", + Labels: "\"label1\",\"label2\"", }, }, }, }, - { - name: "duplicate metric labels", - shouldError: true, - metricsStruct: "type Metrics struct {\n" + - "myCounter *prometheus.CounterVec `metrics_labels:\"dup,dup\"`\n" + - "}", - }, { name: "ignore non-metric field", metricsStruct: `type Metrics struct { - myCounter *prometheus.CounterVec + myCounter metrics.Counter nonMetric string }`, expected: metricsgen.TemplateData{ - Package: pkgName, - StructName: "Metrics", - ConstructorName: "NewMetrics", + Package: pkgName, ParsedMetrics: []metricsgen.ParsedMetricField{ { - TypeName: "CounterVec", - ConstructorPackage: "prometheus", - ConstructorName: "NewCounterVec", - OptsTypeName: "CounterOpts", - FieldName: "myCounter", - MetricName: "my_counter", - MethodReturnType: "prometheus.Counter", + TypeName: "Counter", + FieldName: "myCounter", + MetricName: "my_counter", }, }, }, @@ -348,7 +191,7 @@ func TestParseMetricsStruct(t *testing.T) { pkgLine := fmt.Sprintf("package %s\n", pkgName) importClause := ` import( - "github.com/prometheus/client_golang/prometheus" + "github.com/go-kit/kit/metrics" ) ` @@ -370,67 +213,15 @@ func TestParseMetricsStruct(t *testing.T) { } } -func TestParseHistogramBuckets(t *testing.T) { - tests := []struct { - name string - spec string - expected metricsgen.HistogramOpts - }{ - { - name: "list", - spec: "1,2,3,4", - expected: metricsgen.HistogramOpts{ - BucketSizes: "1,2,3,4", - }, - }, - { - name: "exp", - spec: "exp(1, 2, 3)", - expected: metricsgen.HistogramOpts{ - BucketType: "prometheus.ExponentialBuckets", - BucketSizes: "1, 2, 3", - }, - }, - { - name: "exprange", - spec: "exprange(1, 10, 3)", - expected: metricsgen.HistogramOpts{ - BucketType: "prometheus.ExponentialBucketsRange", - BucketSizes: "1, 10, 3", - }, - }, - { - name: "none", - spec: "none", - expected: metricsgen.HistogramOpts{ - NoBuckets: true, - }, - }, - { - name: "empty uses defaults", - spec: "", - expected: metricsgen.HistogramOpts{ - DefaultBuckets: true, - }, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - require.Equal(t, tc.expected, metricsgen.ParseHistogramBuckets(tc.spec)) - }) - } -} - func TestParseAliasedMetric(t *testing.T) { aliasedData := ` package mypkg import( - mymetrics "github.com/prometheus/client_golang/prometheus" + mymetrics "github.com/go-kit/kit/metrics" ) type Metrics struct { - m *mymetrics.GaugeVec + m mymetrics.Gauge } ` dir := t.TempDir() @@ -447,53 +238,14 @@ func TestParseAliasedMetric(t *testing.T) { expected := metricsgen.TemplateData{ - Package: "mypkg", - StructName: "Metrics", - ConstructorName: "NewMetrics", + Package: "mypkg", ParsedMetrics: []metricsgen.ParsedMetricField{ { - TypeName: "GaugeVec", - ConstructorPackage: "prometheus", - ConstructorName: "NewGaugeVec", - OptsTypeName: "GaugeOpts", - FieldName: "m", - MetricName: "m", - MethodReturnType: "prometheus.Gauge", + TypeName: "Gauge", + FieldName: "m", + MetricName: "m", }, }, } require.Equal(t, expected, td) } - -func TestParseLowercaseMetricsStruct(t *testing.T) { - data := ` - package mypkg - - import( - "github.com/prometheus/client_golang/prometheus" - ) - type metrics struct { - latency *prometheus.HistogramVec - } - ` - dir := t.TempDir() - f, err := os.Create(filepath.Join(dir, "metrics.go")) - if err != nil { - t.Fatalf("unable to open file: %v", err) - } - _, err = io.WriteString(f, data) - require.NoError(t, err) - require.NoError(t, f.Close()) - - td, err := metricsgen.ParseMetricsDir(dir, "metrics") - require.NoError(t, err) - require.Equal(t, "metrics", td.StructName) - require.Equal(t, "newMetrics", td.ConstructorName) - - b := bytes.NewBuffer(nil) - require.NoError(t, metricsgen.GenerateMetricsFile(b, td)) - require.Contains(t, b.String(), "var Global = newMetrics()") - require.Contains(t, b.String(), "func newMetrics() *metrics") - require.Contains(t, b.String(), "func (m *metrics) latencyAt() *tmprometheus.Histogram") - require.Contains(t, b.String(), "prometheus.DefBuckets") -} diff --git a/sei-tendermint/scripts/metricsgen/testdata/basic/metrics.gen.go b/sei-tendermint/scripts/metricsgen/testdata/basic/metrics.gen.go index 23bbe1647f..d541cb2dbb 100644 --- a/sei-tendermint/scripts/metricsgen/testdata/basic/metrics.gen.go +++ b/sei-tendermint/scripts/metricsgen/testdata/basic/metrics.gen.go @@ -3,28 +3,28 @@ package basic import ( - "github.com/prometheus/client_golang/prometheus" + "github.com/go-kit/kit/metrics/discard" + prometheus "github.com/go-kit/kit/metrics/prometheus" + stdprometheus "github.com/prometheus/client_golang/prometheus" ) -var Global = NewMetrics() - -func init() { - prometheus.MustRegister( - Global.Height, - ) -} - -func NewMetrics() *Metrics { +func PrometheusMetrics(namespace string, labelsAndValues ...string) *Metrics { + labels := []string{} + for i := 0; i < len(labelsAndValues); i += 2 { + labels = append(labels, labelsAndValues[i]) + } return &Metrics{ - Height: prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + Height: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "height", Help: "simple metric that tracks the height of the chain.", - }, nil), + }, labels).With(labelsAndValues...), } } -func (m *Metrics) HeightAt() prometheus.Gauge { - return m.Height.WithLabelValues() +func NopMetrics() *Metrics { + return &Metrics{ + Height: discard.NewGauge(), + } } diff --git a/sei-tendermint/scripts/metricsgen/testdata/basic/metrics.go b/sei-tendermint/scripts/metricsgen/testdata/basic/metrics.go index 28f1edcf7e..1a361f90f6 100644 --- a/sei-tendermint/scripts/metricsgen/testdata/basic/metrics.go +++ b/sei-tendermint/scripts/metricsgen/testdata/basic/metrics.go @@ -1,16 +1,11 @@ package basic -import "github.com/prometheus/client_golang/prometheus" - -const ( - MetricsNamespace = "tendermint" - MetricsSubsystem = "basic" -) +import "github.com/go-kit/kit/metrics" //go:generate go run ../../../../scripts/metricsgen -struct=Metrics // Metrics contains metrics exposed by this package. type Metrics struct { // simple metric that tracks the height of the chain. - Height *prometheus.GaugeVec + Height metrics.Gauge } diff --git a/sei-tendermint/scripts/metricsgen/testdata/commented/metrics.gen.go b/sei-tendermint/scripts/metricsgen/testdata/commented/metrics.gen.go index e511707386..c1346da384 100644 --- a/sei-tendermint/scripts/metricsgen/testdata/commented/metrics.gen.go +++ b/sei-tendermint/scripts/metricsgen/testdata/commented/metrics.gen.go @@ -3,28 +3,28 @@ package commented import ( - "github.com/prometheus/client_golang/prometheus" + "github.com/go-kit/kit/metrics/discard" + prometheus "github.com/go-kit/kit/metrics/prometheus" + stdprometheus "github.com/prometheus/client_golang/prometheus" ) -var Global = NewMetrics() - -func init() { - prometheus.MustRegister( - Global.Field, - ) -} - -func NewMetrics() *Metrics { +func PrometheusMetrics(namespace string, labelsAndValues ...string) *Metrics { + labels := []string{} + for i := 0; i < len(labelsAndValues); i += 2 { + labels = append(labels, labelsAndValues[i]) + } return &Metrics{ - Field: prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Namespace: MetricsNamespace, + Field: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "field", Help: "Height of the chain. We expect multi-line comments to parse correctly.", - }, nil), + }, labels).With(labelsAndValues...), } } -func (m *Metrics) FieldAt() prometheus.Gauge { - return m.Field.WithLabelValues() +func NopMetrics() *Metrics { + return &Metrics{ + Field: discard.NewGauge(), + } } diff --git a/sei-tendermint/scripts/metricsgen/testdata/commented/metrics.go b/sei-tendermint/scripts/metricsgen/testdata/commented/metrics.go index 7eb8d54ffc..174f1e2333 100644 --- a/sei-tendermint/scripts/metricsgen/testdata/commented/metrics.go +++ b/sei-tendermint/scripts/metricsgen/testdata/commented/metrics.go @@ -1,16 +1,11 @@ package commented -import "github.com/prometheus/client_golang/prometheus" - -const ( - MetricsNamespace = "tendermint" - MetricsSubsystem = "commented" -) +import "github.com/go-kit/kit/metrics" //go:generate go run ../../../../scripts/metricsgen -struct=Metrics type Metrics struct { // Height of the chain. // We expect multi-line comments to parse correctly. - Field *prometheus.GaugeVec + Field metrics.Gauge } diff --git a/sei-tendermint/scripts/metricsgen/testdata/tags/metrics.gen.go b/sei-tendermint/scripts/metricsgen/testdata/tags/metrics.gen.go index 5486296c86..43779c7a16 100644 --- a/sei-tendermint/scripts/metricsgen/testdata/tags/metrics.gen.go +++ b/sei-tendermint/scripts/metricsgen/testdata/tags/metrics.gen.go @@ -3,75 +3,53 @@ package tags import ( - "github.com/prometheus/client_golang/prometheus" - tmprometheus "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/prometheus" + "github.com/go-kit/kit/metrics/discard" + prometheus "github.com/go-kit/kit/metrics/prometheus" + stdprometheus "github.com/prometheus/client_golang/prometheus" ) -var Global = NewMetrics() - -func init() { - prometheus.MustRegister( - Global.WithLabels, - Global.WithExpBuckets, - Global.WithBuckets, - Global.WithNoBuckets, - Global.Named, - ) -} - -func NewMetrics() *Metrics { +func PrometheusMetrics(namespace string, labelsAndValues ...string) *Metrics { + labels := []string{} + for i := 0; i < len(labelsAndValues); i += 2 { + labels = append(labels, labelsAndValues[i]) + } return &Metrics{ - WithLabels: prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + WithLabels: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "with_labels", Help: "", - }, []string{"step", "time"}), - WithExpBuckets: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, + }, append(labels, "step", "time")).With(labelsAndValues...), + WithExpBuckets: prometheus.NewHistogramFrom(stdprometheus.HistogramOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "with_exp_buckets", Help: "", - Buckets: prometheus.ExponentialBuckets(.1, 100, 8), - }, nil), - WithBuckets: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, + + Buckets: stdprometheus.ExponentialBuckets(.1, 100, 8), + }, labels).With(labelsAndValues...), + WithBuckets: prometheus.NewHistogramFrom(stdprometheus.HistogramOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "with_buckets", Help: "", - Buckets: []float64{1, 2, 3, 4, 5}, - }, nil), - WithNoBuckets: tmprometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: MetricsNamespace, - Subsystem: MetricsSubsystem, - Name: "with_no_buckets", - Help: "", - }, nil), - Named: prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: MetricsNamespace, + + Buckets: []float64{1, 2, 3, 4, 5}, + }, labels).With(labelsAndValues...), + Named: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, Subsystem: MetricsSubsystem, Name: "metric_with_name", Help: "", - }, nil), + }, labels).With(labelsAndValues...), } } -func (m *Metrics) WithLabelsAt(step string, time string) prometheus.Counter { - return m.WithLabels.WithLabelValues(step, time) -} - -func (m *Metrics) WithExpBucketsAt() *tmprometheus.Histogram { - return m.WithExpBuckets.WithLabelValues() -} - -func (m *Metrics) WithBucketsAt() *tmprometheus.Histogram { - return m.WithBuckets.WithLabelValues() -} - -func (m *Metrics) WithNoBucketsAt() *tmprometheus.Histogram { - return m.WithNoBuckets.WithLabelValues() -} - -func (m *Metrics) NamedAt() prometheus.Counter { - return m.Named.WithLabelValues() +func NopMetrics() *Metrics { + return &Metrics{ + WithLabels: discard.NewCounter(), + WithExpBuckets: discard.NewHistogram(), + WithBuckets: discard.NewHistogram(), + Named: discard.NewCounter(), + } } diff --git a/sei-tendermint/scripts/metricsgen/testdata/tags/metrics.go b/sei-tendermint/scripts/metricsgen/testdata/tags/metrics.go index 89fa735cea..8562dcf437 100644 --- a/sei-tendermint/scripts/metricsgen/testdata/tags/metrics.go +++ b/sei-tendermint/scripts/metricsgen/testdata/tags/metrics.go @@ -1,21 +1,12 @@ package tags -import ( - "github.com/prometheus/client_golang/prometheus" - tmprometheus "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/prometheus" -) - -const ( - MetricsNamespace = "tendermint" - MetricsSubsystem = "tags" -) +import "github.com/go-kit/kit/metrics" //go:generate go run ../../../../scripts/metricsgen -struct=Metrics type Metrics struct { - WithLabels *prometheus.CounterVec `metrics_labels:"step,time"` - WithExpBuckets tmprometheus.HistogramVec `metrics_buckets:"exp(.1,100,8)"` - WithBuckets tmprometheus.HistogramVec `metrics_buckets:"1, 2, 3, 4, 5"` - WithNoBuckets tmprometheus.HistogramVec `metrics_buckets:"none"` - Named *prometheus.CounterVec `metrics_name:"metric_with_name"` + WithLabels metrics.Counter `metrics_labels:"step,time"` + WithExpBuckets metrics.Histogram `metrics_buckettype:"exp" metrics_bucketsizes:".1,100,8"` + WithBuckets metrics.Histogram `metrics_bucketsizes:"1, 2, 3, 4, 5"` + Named metrics.Counter `metrics_name:"metric_with_name"` } diff --git a/sei-tendermint/test/e2e/node/main.go b/sei-tendermint/test/e2e/node/main.go index c21d42d97b..017b5e0704 100644 --- a/sei-tendermint/test/e2e/node/main.go +++ b/sei-tendermint/test/e2e/node/main.go @@ -129,6 +129,7 @@ func startNode(ctx context.Context, cfg *Config) error { app, nil, []trace.TracerProviderOption{}, + nil, types.DefaultConsensusPolicy(), ) if err != nil { @@ -145,7 +146,7 @@ func startSeedNode(ctx context.Context) error { tmcfg.Mode = config.ModeSeed - n, err := node.New(ctx, tmcfg, func() {}, nil, nil, []trace.TracerProviderOption{}, types.DefaultConsensusPolicy()) + n, err := node.New(ctx, tmcfg, func() {}, nil, nil, []trace.TracerProviderOption{}, nil, types.DefaultConsensusPolicy()) if err != nil { return err } diff --git a/sei-tendermint/test/fuzz/tests/mempool_test.go b/sei-tendermint/test/fuzz/tests/mempool_test.go index e27d6aff08..32bfc3f2ed 100644 --- a/sei-tendermint/test/fuzz/tests/mempool_test.go +++ b/sei-tendermint/test/fuzz/tests/mempool_test.go @@ -14,7 +14,7 @@ func FuzzMempool(f *testing.F) { cfg := config.DefaultMempoolConfig() cfg.Broadcast = false - mp := mempool.NewTxMempool(cfg.ToMempoolConfig(), kvstore.NewProxy(), mempool.NopTxConstraintsFetcher) + mp := mempool.NewTxMempool(cfg.ToMempoolConfig(), kvstore.NewProxy(), mempool.NopMetrics(), mempool.NopTxConstraintsFetcher) f.Fuzz(func(t *testing.T, data []byte) { _, _ = mp.CheckTx(t.Context(), data) diff --git a/sei-tendermint/types/light.go b/sei-tendermint/types/light.go index 6084a73009..3e7a69d1ee 100644 --- a/sei-tendermint/types/light.go +++ b/sei-tendermint/types/light.go @@ -117,13 +117,6 @@ func LightBlockFromProto(pb *tmproto.LightBlock) (*LightBlock, error) { if err != nil { return nil, err } - // A light block's validator set comes from an untrusted peer and is never - // legitimately empty; reject it at decode even though the generic - // round-trip canonicalizes empty sets (for the state store's - // pre-InitChain genesis state). - if vals.IsNilOrEmpty() { - return nil, ErrValidatorSetEmpty - } return &LightBlock{SignedHeader: sh, ValidatorSet: vals}, nil } diff --git a/sei-tendermint/types/validator_set.go b/sei-tendermint/types/validator_set.go index 26e34cd393..be3a5a749d 100644 --- a/sei-tendermint/types/validator_set.go +++ b/sei-tendermint/types/validator_set.go @@ -868,15 +868,6 @@ func ValidatorSetFromProto(vp *tmproto.ValidatorSet) (*ValidatorSet, error) { if vp == nil { return nil, errors.New("nil validator set") // validator set should never be nil, bigger issues are at play if empty } - if len(vp.Validators) == 0 && vp.Proposer == nil { - // Mirror ToProto's IsNilOrEmpty short-circuit so the round-trip is - // lossless. MakeGenesisState represents a validator-less genesis - // (validators arrive at InitChain, or via state sync) as an empty - // set, and the state store must be able to reload what it saved. - // Callers validating untrusted input still reject empty sets via - // ValidateBasic (ErrValidatorSetEmpty). - return NewValidatorSet(nil), nil - } vals := new(ValidatorSet) valsProto := make([]*Validator, len(vp.Validators)) diff --git a/sei-tendermint/types/validator_set_test.go b/sei-tendermint/types/validator_set_test.go index 393f1c92a5..8e82fd184f 100644 --- a/sei-tendermint/types/validator_set_test.go +++ b/sei-tendermint/types/validator_set_test.go @@ -1441,14 +1441,13 @@ func TestValidatorSetProtoBuf(t *testing.T) { testCases := []struct { msg string v1 *ValidatorSet - want *ValidatorSet // expected FromProto result; nil means tc.v1 expPass1 bool expPass2 bool }{ - {"success", valset, nil, true, true}, - {"fail nil Proposer", valset3, nil, false, false}, - {"empty valSet canonicalizes to empty", &ValidatorSet{}, NewValidatorSet(nil), true, true}, - {"nil valSet canonicalizes to empty", nil, NewValidatorSet(nil), true, true}, + {"success", valset, true, true}, + {"fail nil Proposer", valset3, false, false}, + {"fail empty valSet", &ValidatorSet{}, true, false}, + {"false nil", nil, true, false}, } for _, tc := range testCases { protoValSet, err := tc.v1.ToProto() @@ -1461,33 +1460,13 @@ func TestValidatorSetProtoBuf(t *testing.T) { valSet, err := ValidatorSetFromProto(protoValSet) if tc.expPass2 { require.NoError(t, err, tc.msg) - want := tc.want - if want == nil { - want = tc.v1 - } - require.EqualValues(t, want, valSet, tc.msg) + require.EqualValues(t, tc.v1, valSet, tc.msg) } else { require.Error(t, err, tc.msg) } } } -// TestValidatorSetProtoRoundTripEmpty pins the empty-set round-trip: a -// validator-less genesis (validators arrive at InitChain, or via state sync) -// persists an empty set to the state store, and reloading it must return the -// canonical empty set rather than a nil-proposer error. -func TestValidatorSetProtoRoundTripEmpty(t *testing.T) { - empty := NewValidatorSet(nil) - - pb, err := empty.ToProto() - require.NoError(t, err) - - got, err := ValidatorSetFromProto(pb) - require.NoError(t, err) - require.True(t, got.IsNilOrEmpty()) - require.EqualValues(t, empty, got) -} - // --------------------- // Sort validators by priority and address type validatorsByPriority []*Validator diff --git a/sei-wasmd/app/app.go b/sei-wasmd/app/app.go index 505c5a0ca8..3d0b15fb17 100644 --- a/sei-wasmd/app/app.go +++ b/sei-wasmd/app/app.go @@ -535,7 +535,7 @@ func NewWasmApp( encodingConfig.TxConfig, ), auth.NewAppModule(appCodec, app.accountKeeper, nil), - vesting.NewAppModule(app.accountKeeper, app.bankKeeper, app.upgradeKeeper), + vesting.NewAppModule(app.accountKeeper, app.bankKeeper), bank.NewAppModule(appCodec, app.bankKeeper, app.accountKeeper), capability.NewAppModule(appCodec, *app.capabilityKeeper), gov.NewAppModule(appCodec, app.govKeeper, app.accountKeeper, app.bankKeeper), diff --git a/third_party/alpine-gcc10-libgcc/README.md b/third_party/alpine-gcc10-libgcc/README.md deleted file mode 100644 index c2968375f0..0000000000 --- a/third_party/alpine-gcc10-libgcc/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# Pinned pre-gcc-12 libgcc for the static seid build - -`libgcc.a` and `libgcc_eh.a` from Alpine 3.15's gcc 10.3.1 package. The static -build (`scripts/build-static.sh`) links these ahead of the build image's own -libgcc via `STATIC_EXTRA_LDFLAGS=-L`. - -## Why - -gcc >= 12 replaced libgcc's DWARF unwind-frame registry with a lock-free -b-tree (`libgcc/unwind-dw2-btree.h`). Wasmer registers unwind frames for every -JIT-compiled wasm module (`__register_frame`, called from -`wasmer_compiler::engine::unwind::systemv::UnwindRegistry::publish`), and under -that registration pattern the b-tree corrupts and SIGSEGVs (`btree_insert` -walks a null/garbage node pointer). A statically linked musl `seid` gets the -registry from the build image's toolchain (current Alpine ships gcc 15), which -made every static binary crash on ~70% of boots at the genesis wasm store. -Dynamically linked glibc builds use the shared `libgcc_s` runtime path and -never hit this; upstream wasmd's static binaries link a pre-b-tree libgcc, -which is what this pin restores. - -`scripts/build-static.sh` verifies these archives' checksums before the build -and asserts the final binary contains no `version_lock_lock_exclusive` symbol, -so a toolchain upgrade cannot silently reintroduce the b-tree registry. - -## Provenance - -Extracted from -`https://dl-cdn.alpinelinux.org/alpine/v3.15/main/x86_64/gcc-10.3.1_git20211027-r0.apk` - -- apk sha256: `dbbe8d585eb1f8fdb5815168944d442f28c79fbc9be98bba7cfffaff1e5c10bb` -- libgcc.a sha256: `d3e066fafde74d53a89d48f2ceb9ed9934249a5d450e281edd22947a829469d8` -- libgcc_eh.a sha256: `d14c9973a735909e11a863b0c850300bfd3aa683ef4689cbe76a53139766ed79` - -To re-derive: - -```sh -wget https://dl-cdn.alpinelinux.org/alpine/v3.15/main/x86_64/gcc-10.3.1_git20211027-r0.apk -tar -xzf gcc-10.3.1_git20211027-r0.apk \ - usr/lib/gcc/x86_64-alpine-linux-musl/10.3.1/libgcc.a \ - usr/lib/gcc/x86_64-alpine-linux-musl/10.3.1/libgcc_eh.a -``` diff --git a/third_party/alpine-gcc10-libgcc/libgcc.a b/third_party/alpine-gcc10-libgcc/libgcc.a deleted file mode 100644 index 6b14c02875a505bf311a141034070f36fbf4b774..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3030162 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 z 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

P1g@ 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