diff --git a/app/benchmark/generator.go b/app/benchmark/generator.go index 9fc9119b14..def51a3c79 100644 --- a/app/benchmark/generator.go +++ b/app/benchmark/generator.go @@ -443,7 +443,7 @@ func (g *Generator) transitionToLoadPhase() { panic("benchmark: No scenarios available for load generation") } - g.loadGenerator = generator.NewWeightedGenerator(weightedConfigs...) + g.loadGenerator = generator.NewWeightedGenerator(nil, weightedConfigs...) logger.Info("benchmark: Load generator initialized and ready", "scenarios", len(weightedConfigs)) } diff --git a/giga/evmonly/README.md b/giga/evmonly/README.md index 828dc13035..756d052006 100644 --- a/giga/evmonly/README.md +++ b/giga/evmonly/README.md @@ -34,6 +34,8 @@ The `evmonly` package currently provides: contract address, and effective gas price - a map-backed `MemoryState` for tests and early integration - fail-closed custom precompile placeholders +- a standalone load harness at `giga/evmonly/cmd/evmonly-loadtest` that feeds + generated transfer blocks into the executor with mock state and receipt sinks The executor accepts config for nonce checks, gas-price checks, minimum gas price, chain config, parse workers, OCC workers, result pooling, and the custom diff --git a/giga/evmonly/cmd/evmonly-loadtest/README.md b/giga/evmonly/cmd/evmonly-loadtest/README.md new file mode 100644 index 0000000000..8226b8c7e3 --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/README.md @@ -0,0 +1,204 @@ +# evmonly-loadtest + +`evmonly-loadtest` is a standalone executable for feeding synthetic blocks to +the EVM-only executor without Cosmos SDK state, mempool, RPC, or chain +persistence. + +The synthetic workload defaults to local EVM chain ID `1337`; override it with +`--chain-id` when testing another signing domain. + +It currently generates pure EVM legacy transfer transactions, ERC20 transfer +transactions using `sei-load`'s compiled contract runtime, and a contract-call +workload that exercises nested StateDB +snapshot/revert behavior. By default, each generated sender account has one +nonce-0 transaction and is funded in the command's in-memory genesis state +before its block is queued. Recipients are unique by default so the transfer +workloads exercise the optimistic no-overlap case. Pass +`--recipient-conflict-rate=<0..1>` to pair that fraction of each block's +transactions onto shared recipients, or pass `--recipient=0x...` to force all +transactions to a single recipient. Pass `--same-sender` to use one sender per +block with sequential transaction nonces. + +Run a bounded prebuilt test: + +```bash +go run ./giga/evmonly/cmd/evmonly-loadtest --blocks=1000 --txs-per-block=1000 +``` + +Example local saturation run: + +```bash +go run ./giga/evmonly/cmd/evmonly-loadtest \ + --metrics-addr= \ + --report-interval=5s \ + --blocks=2000 \ + --txs-per-block=1000 \ + --builders=16 \ + --workers=1 \ + --executor-workers=12 \ + --gas-price-wei=0 \ + --min-gas-price-wei=0 \ + --queue-size=512 +``` + +The three executor OCC scenarios have direct command-line forms: + +```bash +# Conflict-free: unique senders and recipients. +go run ./giga/evmonly/cmd/evmonly-loadtest \ + --blocks=400 \ + --gas-price-wei=0 --min-gas-price-wei=0 + +# Hot recipient: unique senders all credit one account. +go run ./giga/evmonly/cmd/evmonly-loadtest \ + --blocks=400 \ + --gas-price-wei=0 --min-gas-price-wei=0 \ + --recipient=0x00000000000000000000000000000000000000f1 + +# Same-sender nonce chain: one sender and sequential nonces within each block. +go run ./giga/evmonly/cmd/evmonly-loadtest \ + --blocks=400 \ + --gas-price-wei=0 --min-gas-price-wei=0 \ + --same-sender +``` + +Example conflict run: + +```bash +go run ./giga/evmonly/cmd/evmonly-loadtest \ + --metrics-addr= \ + --report-interval=5s \ + --blocks=400 \ + --txs-per-block=5000 \ + --builders=16 \ + --workers=1 \ + --executor-workers=12 \ + --gas-price-wei=0 \ + --min-gas-price-wei=0 \ + --recipient-conflict-rate=0.10 +``` + +Example snapshot/revert contract-call run: + +```bash +go run ./giga/evmonly/cmd/evmonly-loadtest \ + --metrics-addr= \ + --report-interval=5s \ + --workload=snapshot-revert \ + --blocks=400 \ + --txs-per-block=5000 \ + --builders=16 \ + --workers=1 \ + --executor-workers=12 \ + --gas-price-wei=0 \ + --min-gas-price-wei=0 +``` + +The command prebuilds every bounded run before starting the prepare/recover and +executor workers, which isolates executor throughput from block generation: + +```bash +go run ./giga/evmonly/cmd/evmonly-loadtest \ + --metrics-addr= \ + --report-interval=5s \ + --blocks=400 \ + --txs-per-block=5000 \ + --builders=48 \ + --workers=1 \ + --executor-workers=24 \ + --gas-price-wei=0 \ + --min-gas-price-wei=0 \ + --queue-size=512 +``` + +Prebuilding requires `--blocks > 0` and stores every raw block in memory. Sender +recovery still runs in the measured phase, but it is pipelined ahead of +execution through `--prepare-workers`. + +The zero gas price/min-gas settings keep the conflict-free transfer workload +focused on the optimistic no-overlap case. Non-zero fees make every transaction +update the same coinbase balance, which is a real intra-block conflict. + +Useful knobs: + +- `--blocks`: number of blocks to prebuild and execute. This is required and + must be greater than `0`. +- `--workers`: parallel executor workers. The default is `1`. Prepared blocks + are forwarded to workers in block-number order, but `--workers > 1` can still + finish execution out of order; this is safe for the harness because generated + state is frozen for prebuilt runs and executor changesets are not applied back + into the input state. +- `--executor-workers`: parallel OCC workers inside each executor. The default + is `min(12, GOMAXPROCS)`, following the `sei-v3` OCC worker default. +- `--prepare-workers`: parallel stateless preparation workers used for + transaction RLP decode and sender recovery. The default is `GOMAXPROCS`. +- `--parse-workers`: parallel transaction decode/sender recovery workers inside + each prepared block. The default `0` uses `1` when `--prepare-workers > 1` to + avoid multiplying block-level and intra-block parser pools, otherwise it uses + `GOMAXPROCS`. +- `--builders`: parallel block builders used to keep the input queue full. The + default is `GOMAXPROCS`. +- `--queue-size`: buffered raw and prepared blocks. The default is `64`. +- `--result-pool-size`: reusable executor result slots. The default `0` + sizes the pool for in-flight executor and async sink results; negative + disables result pooling. +- `--result-sink`: executor output sink, either `discard` or `file`. The + default is `discard`. +- `--persist-dir`: directory used by `--result-sink=file` for temporary + append-only changeset and receipt files. Files are removed on shutdown or + interrupt cleanup. +- `--persist-buffer-size`: buffered writer size for `--result-sink=file`. +- `--persist-queue-size`: async file-sink record queue size. The default `0` + uses `2 * --queue-size`. +- `--metrics-addr`: Prometheus endpoint. The default is + `127.0.0.1:9698`; set it to empty to disable HTTP metrics. +- `--report-interval`: stdout rate reporting interval. The default is `5s`. +- `--gas-price-wei`, `--min-gas-price-wei`, `--sender-balance-wei`, + `--transfer-value-wei`: transaction economics for the generated accounts. +- `--recipient-conflict-rate`: fraction of each block's transactions that are + paired onto shared recipients; `0` keeps recipients unique and `1` pairs all + possible transactions. +- `--same-sender`: use one sender per native-transfer block and assign + transaction nonces in block order. +- `--workload`: workload type, either `transfer`, `erc20-transfer`, or + `snapshot-revert`. +- `--snapshot-revert-contract`, `--snapshot-revert-helper`: generated contract + addresses used by `--workload=snapshot-revert`. + +The command reports these saturation signals on stdout and at `/metrics`: + +- block input throughput +- block preparation throughput +- prepared transactions per second +- block finishing throughput +- finished, successful, and failed transactions per second +- total gas consumed per second +- total OCC transaction rerun attempts +- prepared blocks queued for execution and cumulative totals +- result-sink records queued, enqueued, written, bytes written, enqueue wait, + and write time +- result-pool capacity, available slots, and overflow allocations + +The default executor output path intentionally discards results through mocks: + +- `generatedState` implements `evmonly.StateReader` and supplies generated + genesis balances, nonces, code, and storage. +- `discardResultSink` applies the executor `StateChangeSet` to + `discardStateWriter` and discards Ethereum receipts. + +With `--result-sink=file`, the loadtest harness hands pooled +`evmonly.BlockResult` values to an async writer through the executor's +`evmonly.ResultSink` interface. The writer appends changesets to +`changesets.rlp` and receipts to `receipts.rlp` under `--persist-dir`; each +record is framed as an 8-byte big-endian block height, an 8-byte big-endian RLP +payload length, and the RLP payload. The files are temporary calibration +artifacts and are removed when the process exits normally or handles +`SIGINT`/`SIGTERM`. `sink_enqueue_wait` is the primary backpressure signal: a +non-zero value means executor workers waited for async sink queue capacity. + +Native and ERC20 transaction construction and genesis seeding come from +`github.com/sei-protocol/sei-load/generator/offline`. Future reusable EVM +workloads should add an offline scenario there and a block/recipient adapter in +this command. Executor-specific workloads can remain beside +`TransferWorkload`, `ERC20TransferWorkload`, and `SnapshotRevertWorkload` and +reuse the same block producer/prepare/executor/metrics pipeline. diff --git a/giga/evmonly/cmd/evmonly-loadtest/config.go b/giga/evmonly/cmd/evmonly-loadtest/config.go new file mode 100644 index 0000000000..58965a7f5e --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/config.go @@ -0,0 +1,377 @@ +package main + +import ( + "flag" + "fmt" + "math" + "math/big" + "os" + "runtime" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/sei-protocol/sei-chain/giga/evmonly" + "github.com/sei-protocol/sei-chain/giga/evmonly/cmd/evmonly-loadtest/scenarios" +) + +const ( + defaultChainID = "1337" + defaultGasPriceWei = "1000000000" + defaultMinGasPriceWei = "1000000000" + defaultSenderBalance = "1000000000000000000" + defaultTransferValue = "1" + defaultERC20Contract = "0x000000000000000000000000000000000000e20c" + defaultSnapshotRevertContract = "0x0000000000000000000000000000000000005a90" + defaultSnapshotRevertHelper = "0x0000000000000000000000000000000000005a91" + defaultMetricsAddr = "127.0.0.1:9698" + defaultReportInterval = 5 * time.Second + defaultQueueSize = 64 + defaultTxGasLimit = 21_000 + defaultERC20TxGasLimit = 100_000 + defaultSnapshotRevertTxGasLimit = 100_000 + defaultTxsPerBlock = 1_000 + defaultPersistBuffer = 4 << 20 + defaultGenesisTimestamp = scenarios.DefaultGenesisTimestamp + defaultWorkerCount = 1 + defaultCoinbaseAddress = "0x00000000000000000000000000000000000000cb" + workloadTransfer = scenarios.WorkloadTransfer + workloadERC20Transfer = scenarios.WorkloadERC20Transfer + workloadSnapshotRevert = scenarios.WorkloadSnapshotRevert + resultSinkDiscard = "discard" + resultSinkFile = "file" + resultSinkChangeSet = "changeset" + resultSinkReceipts = "receipts" +) + +type config struct { + blocks uint64 + txsPerBlock int + queueSize int + builders int + prepareWorkers int + parseWorkers int + workers int + executorWorkers int + reportInterval time.Duration + metricsAddr string + resultSink string + resultPoolSize int + persistDir string + persistSync bool + persistBufferSize int + persistQueueSize int + cpuProfile string + heapProfile string + traceProfile string + workload string + chainID *big.Int + gasPrice *big.Int + minGasPrice *big.Int + senderBalance *big.Int + transferValue *big.Int + txGasLimit uint64 + blockGasLimit uint64 + coinbase common.Address + erc20Contract common.Address + snapshotRevertContract common.Address + snapshotRevertHelper common.Address + fixedRecipient *common.Address + recipientConflictRate float64 + sameSender bool + disableGasPriceRule bool +} + +func scenarioConfig(cfg config) scenarios.Config { + return scenarios.Config{ + TxsPerBlock: cfg.txsPerBlock, + ChainID: cfg.chainID, + GasPrice: cfg.gasPrice, + SenderBalance: cfg.senderBalance, + TransferValue: cfg.transferValue, + TxGasLimit: cfg.txGasLimit, + BlockGasLimit: cfg.blockGasLimit, + Coinbase: cfg.coinbase, + ERC20Contract: cfg.erc20Contract, + SnapshotRevertContract: cfg.snapshotRevertContract, + SnapshotRevertHelper: cfg.snapshotRevertHelper, + FixedRecipient: cfg.fixedRecipient, + RecipientConflictRate: cfg.recipientConflictRate, + SameSender: cfg.sameSender, + } +} + +type blockEnvelope struct { + number uint64 + request evmonly.BlockRequest +} + +type preparedBlockEnvelope struct { + number uint64 + block evmonly.PreparedBlock +} + +func parseConfig(args []string) (config, error) { + cfg := config{} + fs := flag.NewFlagSet("evmonly-loadtest", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + + chainID := fs.String("chain-id", defaultChainID, "EVM chain ID used to sign and execute transactions") + gasPrice := fs.String("gas-price-wei", defaultGasPriceWei, "legacy transaction gas price in wei") + minGasPrice := fs.String("min-gas-price-wei", defaultMinGasPriceWei, "executor minimum gas price in wei") + senderBalance := fs.String("sender-balance-wei", defaultSenderBalance, "generated sender genesis balance in wei") + transferValue := fs.String("transfer-value-wei", defaultTransferValue, "wei or token units transferred by each generated transaction") + coinbase := fs.String("coinbase", defaultCoinbaseAddress, "block coinbase address") + erc20Contract := fs.String("erc20-contract", defaultERC20Contract, "EVM address for the generated ERC20 transfer contract") + snapshotRevertContract := fs.String("snapshot-revert-contract", defaultSnapshotRevertContract, "EVM address for the generated snapshot-revert outer contract") + snapshotRevertHelper := fs.String("snapshot-revert-helper", defaultSnapshotRevertHelper, "EVM address for the generated snapshot-revert helper contract") + recipient := fs.String("recipient", "", "optional fixed transfer recipient; empty creates one recipient per tx") + fs.Float64Var(&cfg.recipientConflictRate, "recipient-conflict-rate", 0, "fraction [0,1] of transactions per block paired onto shared recipients; 0 keeps recipients unique") + fs.BoolVar(&cfg.sameSender, "same-sender", false, "use one sender with sequential nonces for every transaction in a transfer block") + + fs.Uint64Var(&cfg.blocks, "blocks", 0, "number of blocks to prebuild and execute; must be positive") + fs.IntVar(&cfg.txsPerBlock, "txs-per-block", defaultTxsPerBlock, "transactions generated per block") + fs.IntVar(&cfg.queueSize, "queue-size", defaultQueueSize, "buffered blocks waiting for executor workers") + fs.IntVar(&cfg.builders, "builders", runtime.GOMAXPROCS(0), "parallel block builder goroutines") + fs.IntVar(&cfg.prepareWorkers, "prepare-workers", defaultPrepareWorkers(), "parallel block preparation workers for transaction decode and sender recovery") + fs.IntVar(&cfg.parseWorkers, "parse-workers", 0, "parallel transaction decode/sender recovery workers inside each prepared block; 0 defaults to 1 when prepare-workers > 1, otherwise GOMAXPROCS") + fs.IntVar(&cfg.workers, "workers", defaultWorkerCount, "parallel executor workers") + fs.IntVar(&cfg.executorWorkers, "executor-workers", defaultExecutorWorkers(), "parallel OCC workers inside each executor") + fs.DurationVar(&cfg.reportInterval, "report-interval", defaultReportInterval, "stdout and rate-gauge reporting interval; 0 disables periodic reports") + fs.StringVar(&cfg.metricsAddr, "metrics-addr", defaultMetricsAddr, "Prometheus listen address; empty disables HTTP metrics") + fs.StringVar(&cfg.resultSink, "result-sink", resultSinkDiscard, "result sink mode: discard or file") + fs.IntVar(&cfg.resultPoolSize, "result-pool-size", 0, "pooled executor BlockResult slots; 0 sizes for in-flight sink results, negative disables pooling") + fs.StringVar(&cfg.persistDir, "persist-dir", "", "directory for --result-sink=file append-only changeset and receipt files, removed at shutdown") + fs.BoolVar(&cfg.persistSync, "persist-sync", false, "fsync persistent result files from the async sink writer") + fs.IntVar(&cfg.persistBufferSize, "persist-buffer-size", defaultPersistBuffer, "buffer size in bytes for --result-sink=file") + fs.IntVar(&cfg.persistQueueSize, "persist-queue-size", 0, "record queue size for async file persistence; 0 defaults to 2*queue-size") + fs.StringVar(&cfg.cpuProfile, "cpu-profile", "", "write Go CPU profile to this file; starts after prebuild") + fs.StringVar(&cfg.heapProfile, "heap-profile", "", "write Go heap profile to this file after execution") + fs.StringVar(&cfg.traceProfile, "trace-profile", "", "write Go runtime trace to this file; starts after prebuild") + fs.StringVar(&cfg.workload, "workload", workloadTransfer, "workload type: transfer, erc20-transfer, or snapshot-revert") + fs.Uint64Var(&cfg.txGasLimit, "tx-gas-limit", defaultTxGasLimit, "gas limit for each generated transaction") + fs.Uint64Var(&cfg.blockGasLimit, "block-gas-limit", 0, "block gas limit; 0 lets the executor use its maximum") + fs.BoolVar(&cfg.disableGasPriceRule, "disable-gas-price-rule", false, "disable the executor min-gas-price validity rule") + + if err := fs.Parse(args); err != nil { + return config{}, err + } + var err error + if cfg.chainID, err = parsePositiveBig("chain-id", *chainID); err != nil { + return config{}, err + } + if cfg.gasPrice, err = parseNonNegativeBig("gas-price-wei", *gasPrice); err != nil { + return config{}, err + } + if cfg.minGasPrice, err = parseNonNegativeBig("min-gas-price-wei", *minGasPrice); err != nil { + return config{}, err + } + if cfg.senderBalance, err = parseNonNegativeBig("sender-balance-wei", *senderBalance); err != nil { + return config{}, err + } + if cfg.transferValue, err = parseNonNegativeBig("transfer-value-wei", *transferValue); err != nil { + return config{}, err + } + if !common.IsHexAddress(*coinbase) { + return config{}, fmt.Errorf("coinbase must be a hex EVM address") + } + cfg.coinbase = common.HexToAddress(*coinbase) + if *recipient != "" { + if !common.IsHexAddress(*recipient) { + return config{}, fmt.Errorf("recipient must be a hex EVM address") + } + addr := common.HexToAddress(*recipient) + cfg.fixedRecipient = &addr + } + if !common.IsHexAddress(*erc20Contract) { + return config{}, fmt.Errorf("erc20-contract must be a hex EVM address") + } + cfg.erc20Contract = common.HexToAddress(*erc20Contract) + if !common.IsHexAddress(*snapshotRevertContract) { + return config{}, fmt.Errorf("snapshot-revert-contract must be a hex EVM address") + } + cfg.snapshotRevertContract = common.HexToAddress(*snapshotRevertContract) + if !common.IsHexAddress(*snapshotRevertHelper) { + return config{}, fmt.Errorf("snapshot-revert-helper must be a hex EVM address") + } + cfg.snapshotRevertHelper = common.HexToAddress(*snapshotRevertHelper) + cfg.workload = strings.ToLower(strings.TrimSpace(cfg.workload)) + if cfg.workload != workloadTransfer && cfg.workload != workloadERC20Transfer && cfg.workload != workloadSnapshotRevert { + return config{}, fmt.Errorf("unsupported workload %q", cfg.workload) + } + txGasLimitSet := false + fs.Visit(func(f *flag.Flag) { + if f.Name == "tx-gas-limit" { + txGasLimitSet = true + } + }) + if cfg.workload == workloadERC20Transfer && !txGasLimitSet { + cfg.txGasLimit = defaultERC20TxGasLimit + } + if cfg.workload == workloadSnapshotRevert && !txGasLimitSet { + cfg.txGasLimit = defaultSnapshotRevertTxGasLimit + } + if cfg.blocks == 0 { + return config{}, fmt.Errorf("blocks must be positive") + } + if cfg.txsPerBlock <= 0 { + return config{}, fmt.Errorf("txs-per-block must be positive") + } + if cfg.queueSize <= 0 { + return config{}, fmt.Errorf("queue-size must be positive") + } + if cfg.queueSize > math.MaxInt/2 { + return config{}, fmt.Errorf("queue-size must be at most %d", math.MaxInt/2) + } + if cfg.builders <= 0 { + return config{}, fmt.Errorf("builders must be positive") + } + if cfg.prepareWorkers <= 0 { + return config{}, fmt.Errorf("prepare-workers must be positive") + } + if cfg.parseWorkers < 0 { + return config{}, fmt.Errorf("parse-workers must be non-negative") + } + if cfg.parseWorkers == 0 { + cfg.parseWorkers = defaultParseWorkers(cfg.prepareWorkers) + } + if cfg.workers <= 0 { + return config{}, fmt.Errorf("workers must be positive") + } + if cfg.executorWorkers <= 0 { + return config{}, fmt.Errorf("executor-workers must be positive") + } + if cfg.recipientConflictRate < 0 || cfg.recipientConflictRate > 1 { + return config{}, fmt.Errorf("recipient-conflict-rate must be between 0 and 1") + } + if cfg.fixedRecipient != nil && cfg.recipientConflictRate != 0 { + return config{}, fmt.Errorf("recipient cannot be combined with recipient-conflict-rate") + } + if cfg.workload == workloadSnapshotRevert && cfg.fixedRecipient != nil { + return config{}, fmt.Errorf("recipient is not supported with snapshot-revert workload") + } + if cfg.workload == workloadSnapshotRevert && cfg.recipientConflictRate != 0 { + return config{}, fmt.Errorf("recipient-conflict-rate is not supported with snapshot-revert workload") + } + if cfg.sameSender && cfg.workload != workloadTransfer { + return config{}, fmt.Errorf("same-sender is only supported with transfer workload") + } + if cfg.workload == workloadSnapshotRevert && cfg.snapshotRevertContract == cfg.snapshotRevertHelper { + return config{}, fmt.Errorf("snapshot-revert-contract and snapshot-revert-helper must differ") + } + if cfg.reportInterval < 0 { + return config{}, fmt.Errorf("report-interval must be non-negative") + } + cfg.resultSink = strings.ToLower(strings.TrimSpace(cfg.resultSink)) + if cfg.resultSink != resultSinkDiscard && cfg.resultSink != resultSinkFile { + return config{}, fmt.Errorf("unsupported result-sink %q", cfg.resultSink) + } + if cfg.persistBufferSize <= 0 { + return config{}, fmt.Errorf("persist-buffer-size must be positive") + } + if cfg.persistQueueSize < 0 { + return config{}, fmt.Errorf("persist-queue-size must be non-negative") + } + if cfg.persistQueueSize == 0 { + cfg.persistQueueSize = 2 * cfg.queueSize + } + if cfg.resultPoolSize < 0 { + cfg.resultPoolSize = 0 + } else if cfg.resultPoolSize == 0 { + cfg.resultPoolSize = defaultResultPoolSize(cfg) + } + if cfg.resultSink == resultSinkFile && strings.TrimSpace(cfg.persistDir) == "" { + return config{}, fmt.Errorf("persist-dir is required when result-sink=file") + } + if cfg.txGasLimit == 0 { + return config{}, fmt.Errorf("tx-gas-limit must be positive") + } + if cfg.transferValue.BitLen() > 256 { + return config{}, fmt.Errorf("transfer-value-wei must fit in uint256") + } + if !cfg.disableGasPriceRule && cfg.gasPrice.Cmp(cfg.minGasPrice) < 0 { + return config{}, fmt.Errorf("gas-price-wei must be greater than or equal to min-gas-price-wei unless disable-gas-price-rule is set") + } + requiredBalance := new(big.Int).Mul(new(big.Int).SetUint64(cfg.txGasLimit), cfg.gasPrice) + requiredBalanceReason := "max gas cost" + if cfg.workload == workloadTransfer { + requiredBalance.Add(requiredBalance, cfg.transferValue) + requiredBalanceReason = "transfer value plus max gas cost" + } + if cfg.sameSender { + txCount := new(big.Int).SetUint64(uint64(cfg.txsPerBlock)) //nolint:gosec // txsPerBlock is validated as positive above. + requiredBalance.Mul(requiredBalance, txCount) + requiredBalanceReason = "all same-sender transactions' transfer value plus max gas cost" + } + if cfg.senderBalance.Cmp(requiredBalance) < 0 { + return config{}, fmt.Errorf("sender-balance-wei must cover %s: need at least %s", requiredBalanceReason, requiredBalance.String()) + } + return cfg, nil +} + +func defaultPrepareWorkers() int { + return runtime.GOMAXPROCS(0) +} + +func defaultParseWorkers(prepareWorkers int) int { + if prepareWorkers > 1 { + return 1 + } + return runtime.GOMAXPROCS(0) +} + +func defaultExecutorWorkers() int { + workers := runtime.GOMAXPROCS(0) + if workers > 12 { + return 12 + } + return workers +} + +func defaultResultPoolSize(cfg config) int { + size := cfg.workers + 1 + if cfg.resultSink == resultSinkFile { + size += cfg.persistQueueSize + } + return size +} + +func executorConfig(cfg config) evmonly.Config { + return evmonly.Config{ + MinGasPrice: new(big.Int).Set(cfg.minGasPrice), + DisableGasPriceCheck: cfg.disableGasPriceRule, + OCCWorkers: cfg.executorWorkers, + ParseWorkers: cfg.parseWorkers, + BlockResultPoolSize: cfg.resultPoolSize, + } +} + +func parsePositiveBig(name, raw string) (*big.Int, error) { + v, err := parseBig(name, raw) + if err != nil { + return nil, err + } + if v.Sign() <= 0 { + return nil, fmt.Errorf("%s must be positive", name) + } + return v, nil +} + +func parseNonNegativeBig(name, raw string) (*big.Int, error) { + v, err := parseBig(name, raw) + if err != nil { + return nil, err + } + if v.Sign() < 0 { + return nil, fmt.Errorf("%s must be non-negative", name) + } + return v, nil +} + +func parseBig(name, raw string) (*big.Int, error) { + v, ok := new(big.Int).SetString(strings.TrimSpace(raw), 10) + if !ok { + return nil, fmt.Errorf("%s must be a base-10 integer", name) + } + return v, nil +} diff --git a/giga/evmonly/cmd/evmonly-loadtest/main.go b/giga/evmonly/cmd/evmonly-loadtest/main.go new file mode 100644 index 0000000000..90c8a63c63 --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/main.go @@ -0,0 +1,18 @@ +package main + +import ( + "fmt" + "os" +) + +func main() { + cfg, err := parseConfig(os.Args[1:]) + if err != nil { + fmt.Fprintf(os.Stderr, "evmonly-loadtest: %v\n", err) + os.Exit(2) + } + if err := run(cfg); err != nil { + fmt.Fprintf(os.Stderr, "evmonly-loadtest: %v\n", err) + os.Exit(1) + } +} diff --git a/giga/evmonly/cmd/evmonly-loadtest/main_test.go b/giga/evmonly/cmd/evmonly-loadtest/main_test.go new file mode 100644 index 0000000000..8b76c37897 --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/main_test.go @@ -0,0 +1,885 @@ +package main + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "math" + "math/big" + "os" + "path/filepath" + "runtime" + "sync/atomic" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rlp" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-chain/giga/evmonly" + "github.com/sei-protocol/sei-chain/giga/evmonly/cmd/evmonly-loadtest/scenarios" +) + +func TestTransferWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { + cfg, err := parseConfig([]string{ + "--metrics-addr=", + "--blocks=1", + "--txs-per-block=4", + }) + require.NoError(t, err) + + state := newGeneratedState() + workload, err := scenarios.NewTransferWorkload(scenarioConfig(cfg), state) + require.NoError(t, err) + request, err := workload.BuildBlock(t.Context(), 1) + require.NoError(t, err) + + executor := evmonly.NewExecutor(evmonly.Config{ + MinGasPrice: cfg.minGasPrice, + }, evmonly.WithState(state)) + result, err := executor.ExecuteBlock(t.Context(), request) + require.NoError(t, err) + + require.Len(t, result.Txs, cfg.txsPerBlock) + require.Len(t, result.Receipts, cfg.txsPerBlock) + require.Equal(t, uint64(cfg.txsPerBlock)*cfg.txGasLimit, result.GasUsed) + for _, tx := range result.Txs { + require.Equal(t, ethtypes.ReceiptStatusSuccessful, tx.Status) + require.NoError(t, tx.Err) + } + + var released atomic.Bool + require.NoError(t, discardResultSink{writer: &discardStateWriter{}}.StoreBlockResult(t.Context(), request.Context.Number, result, func() { + released.Store(true) + })) + require.True(t, released.Load()) +} + +func TestTransferWorkloadOCCScenarios(t *testing.T) { + tests := []struct { + name string + args []string + wantConflicts bool + wantReruns bool + wantSameSender bool + wantSameRecipient bool + }{ + {name: "conflict free"}, + { + name: "hot recipient", + args: []string{"--recipient=0x00000000000000000000000000000000000000f1"}, + wantConflicts: true, + wantReruns: true, + wantSameRecipient: true, + }, + { + name: "same sender nonce chain", + args: []string{"--same-sender"}, + wantReruns: true, + wantSameSender: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + args := append([]string{ + "--metrics-addr=", + "--blocks=1", + "--txs-per-block=4", + "--gas-price-wei=0", + "--min-gas-price-wei=0", + }, tc.args...) + cfg, err := parseConfig(args) + require.NoError(t, err) + + state := newGeneratedState() + workload, err := scenarios.NewTransferWorkload(scenarioConfig(cfg), state) + require.NoError(t, err) + request, err := workload.BuildBlock(t.Context(), 1) + require.NoError(t, err) + + signer := ethtypes.LatestSignerForChainID(cfg.chainID) + senders := make([]common.Address, len(request.Txs)) + recipients := make([]common.Address, len(request.Txs)) + for i, raw := range request.Txs { + tx := new(ethtypes.Transaction) + require.NoError(t, tx.UnmarshalBinary(raw)) + senders[i], err = ethtypes.Sender(signer, tx) + require.NoError(t, err) + require.NotNil(t, tx.To()) + recipients[i] = *tx.To() + if tc.wantSameSender { + require.Equal(t, uint64(i), tx.Nonce()) + } else { + require.Zero(t, tx.Nonce()) + } + } + for i := 1; i < len(request.Txs); i++ { + require.Equal(t, tc.wantSameSender, senders[i] == senders[0]) + require.Equal(t, tc.wantSameRecipient, recipients[i] == recipients[0]) + } + + executor := evmonly.NewExecutor(evmonly.Config{ + MinGasPrice: cfg.minGasPrice, + OCCWorkers: 4, + }, evmonly.WithState(state)) + result, err := executor.ExecuteBlock(t.Context(), request) + require.NoError(t, err) + require.True(t, result.OCCStats.Attempted) + require.False(t, result.OCCStats.Fallback) + require.Len(t, result.Txs, cfg.txsPerBlock) + for _, tx := range result.Txs { + require.Equal(t, ethtypes.ReceiptStatusSuccessful, tx.Status) + require.NoError(t, tx.Err) + } + if tc.wantConflicts { + require.Greater(t, result.OCCStats.ConflictCount, uint64(0)) + } else { + require.Zero(t, result.OCCStats.ConflictCount) + } + if tc.wantReruns { + require.Greater(t, result.OCCStats.RerunCount, uint64(0)) + } else { + require.Zero(t, result.OCCStats.RerunCount) + } + + applyGeneratedStateChangeSet(state, result.ChangeSet) + if tc.wantSameSender { + require.Equal(t, uint64(cfg.txsPerBlock), state.GetNonce(senders[0])) + } + result.Release() + executor.Close() + }) + } +} + +func TestERC20TransferWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { + cfg, err := parseConfig([]string{ + "--metrics-addr=", + "--blocks=1", + "--workload=erc20-transfer", + "--txs-per-block=4", + "--gas-price-wei=0", + "--min-gas-price-wei=0", + }) + require.NoError(t, err) + require.Equal(t, uint64(defaultERC20TxGasLimit), cfg.txGasLimit) + + state := newGeneratedState() + workload, err := newWorkload(cfg, state) + require.NoError(t, err) + request, err := workload.BuildBlock(t.Context(), 1) + require.NoError(t, err) + + executor := evmonly.NewExecutor(evmonly.Config{ + MinGasPrice: cfg.minGasPrice, + OCCWorkers: cfg.executorWorkers, + }, evmonly.WithState(state)) + result, err := executor.ExecuteBlock(t.Context(), request) + require.NoError(t, err) + + require.Len(t, result.Txs, cfg.txsPerBlock) + require.Len(t, result.Receipts, cfg.txsPerBlock) + require.NotEmpty(t, result.ChangeSet.Storage) + require.True(t, result.OCCStats.Attempted) + require.False(t, result.OCCStats.Fallback) + for _, tx := range result.Txs { + require.Equal(t, ethtypes.ReceiptStatusSuccessful, tx.Status) + require.NoError(t, tx.Err) + require.Greater(t, tx.GasUsed, uint64(21_000)) + require.Len(t, tx.Logs, 1) + } + for _, receipt := range result.Receipts { + require.Len(t, receipt.Logs, 1) + } + + applyGeneratedStateChangeSet(state, result.ChangeSet) + transferWorkload := workload.(*scenarios.ERC20TransferWorkload) + for i := uint64(1); i <= uint64(cfg.txsPerBlock); i++ { + key, err := scenarios.DeterministicPrivateKey(i) + require.NoError(t, err) + sender := crypto.PubkeyToAddress(key.PublicKey) + recipient := transferWorkload.Recipient(1, int(i-1), i) + require.Equal(t, common.Hash{}, state.GetState(cfg.erc20Contract, scenarios.ERC20BalanceSlot(sender))) + require.Equal(t, common.BigToHash(cfg.transferValue), state.GetState(cfg.erc20Contract, scenarios.ERC20BalanceSlot(recipient))) + } +} + +func TestSnapshotRevertWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { + cfg, err := parseConfig([]string{ + "--metrics-addr=", + "--blocks=1", + "--workload=snapshot-revert", + "--txs-per-block=4", + "--gas-price-wei=0", + "--min-gas-price-wei=0", + }) + require.NoError(t, err) + require.Equal(t, uint64(defaultSnapshotRevertTxGasLimit), cfg.txGasLimit) + + state := newGeneratedState() + workload, err := newWorkload(cfg, state) + require.NoError(t, err) + request, err := workload.BuildBlock(t.Context(), 1) + require.NoError(t, err) + + executor := evmonly.NewExecutor(evmonly.Config{ + MinGasPrice: cfg.minGasPrice, + OCCWorkers: 4, + }, evmonly.WithState(state)) + result, err := executor.ExecuteBlock(t.Context(), request) + require.NoError(t, err) + + require.Len(t, result.Txs, cfg.txsPerBlock) + require.Len(t, result.Receipts, cfg.txsPerBlock) + require.True(t, result.OCCStats.Attempted) + require.False(t, result.OCCStats.Fallback) + require.Zero(t, result.OCCStats.ConflictCount) + for _, tx := range result.Txs { + require.Equal(t, ethtypes.ReceiptStatusSuccessful, tx.Status) + require.NoError(t, tx.Err) + require.Greater(t, tx.GasUsed, uint64(21_000)) + } + + require.Len(t, result.ChangeSet.Storage, cfg.txsPerBlock) + for _, change := range result.ChangeSet.Storage { + require.Equal(t, cfg.snapshotRevertContract, change.Address) + require.Equal(t, common.BigToHash(big.NewInt(1)), change.Value) + } + + applyGeneratedStateChangeSet(state, result.ChangeSet) + for i := uint64(1); i <= uint64(cfg.txsPerBlock); i++ { + key, err := scenarios.DeterministicPrivateKey(i) + require.NoError(t, err) + sender := crypto.PubkeyToAddress(key.PublicKey) + slot := scenarios.SnapshotRevertStorageSlot(sender) + require.Equal(t, common.BigToHash(big.NewInt(1)), state.GetState(cfg.snapshotRevertContract, slot)) + require.Equal(t, common.Hash{}, state.GetState(cfg.snapshotRevertHelper, slot)) + } +} + +func TestTransferWorkloadRecipientConflictRate(t *testing.T) { + cfg, err := parseConfig([]string{ + "--metrics-addr=", + "--blocks=1", + "--txs-per-block=4", + "--recipient-conflict-rate=0.5", + "--gas-price-wei=0", + "--min-gas-price-wei=0", + }) + require.NoError(t, err) + + state := newGeneratedState() + workload, err := scenarios.NewTransferWorkload(scenarioConfig(cfg), state) + require.NoError(t, err) + request, err := workload.BuildBlock(t.Context(), 1) + require.NoError(t, err) + + executor := evmonly.NewExecutor(evmonly.Config{ + MinGasPrice: cfg.minGasPrice, + OCCWorkers: 4, + }, evmonly.WithState(state)) + result, err := executor.ExecuteBlock(t.Context(), request) + require.NoError(t, err) + require.True(t, result.OCCStats.Attempted) + require.False(t, result.OCCStats.Fallback) + require.Greater(t, result.OCCStats.ConflictCount, uint64(0)) + require.Greater(t, result.OCCStats.RerunCount, uint64(0)) + + conflictRecipient := workload.Recipient(1, 0, 1) + require.Equal(t, conflictRecipient, workload.Recipient(1, 1, 2)) + require.NotEqual(t, conflictRecipient, workload.Recipient(1, 2, 3)) + require.NotEqual(t, workload.Recipient(1, 2, 3), workload.Recipient(1, 3, 4)) + + applyGeneratedStateChangeSet(state, result.ChangeSet) + twoTransfers := new(big.Int).Mul(cfg.transferValue, big.NewInt(2)) + require.Equal(t, twoTransfers, state.GetBalance(conflictRecipient)) + require.Equal(t, cfg.transferValue, state.GetBalance(workload.Recipient(1, 2, 3))) + require.Equal(t, cfg.transferValue, state.GetBalance(workload.Recipient(1, 3, 4))) +} + +func applyGeneratedStateChangeSet(state *generatedState, changeSet evmonly.StateChangeSet) { + for _, change := range changeSet.Balances { + state.SetBalance(change.Address, change.Balance) + } + for _, change := range changeSet.Nonces { + state.SetNonce(change.Address, change.Nonce) + } + for _, change := range changeSet.Code { + if change.Delete { + state.SetCode(change.Address, nil) + } else { + state.SetCode(change.Address, change.Code) + } + } + for _, change := range changeSet.Storage { + value := change.Value + if change.Delete { + value = common.Hash{} + } + state.SetState(change.Address, change.Key, value) + } +} + +func TestBlocksRequiresBoundedRun(t *testing.T) { + _, err := parseConfig([]string{}) + require.ErrorContains(t, err, "blocks must be positive") +} + +func TestDefaultChainIDIsLocal(t *testing.T) { + cfg, err := parseConfig([]string{"--blocks=1"}) + require.NoError(t, err) + require.Equal(t, "1337", cfg.chainID.String()) +} + +func TestRecipientConflictRateValidation(t *testing.T) { + _, err := parseConfig([]string{ + "--blocks=1", + "--recipient-conflict-rate=1.1", + }) + require.ErrorContains(t, err, "recipient-conflict-rate must be between 0 and 1") + + _, err = parseConfig([]string{ + "--blocks=1", + "--recipient=0x0000000000000000000000000000000000000001", + "--recipient-conflict-rate=0.5", + }) + require.ErrorContains(t, err, "recipient cannot be combined with recipient-conflict-rate") +} + +func TestSameSenderValidation(t *testing.T) { + _, err := parseConfig([]string{ + "--blocks=1", + "--workload=erc20-transfer", + "--same-sender", + }) + require.ErrorContains(t, err, "same-sender is only supported with transfer workload") + + _, err = parseConfig([]string{ + "--blocks=1", + "--same-sender", + "--txs-per-block=4", + "--gas-price-wei=0", + "--min-gas-price-wei=0", + "--sender-balance-wei=3", + "--transfer-value-wei=1", + }) + require.ErrorContains(t, err, "sender-balance-wei must cover all same-sender transactions") +} + +func TestSnapshotRevertValidation(t *testing.T) { + _, err := parseConfig([]string{ + "--blocks=1", + "--workload=snapshot-revert", + "--recipient=0x0000000000000000000000000000000000000001", + }) + require.ErrorContains(t, err, "recipient is not supported with snapshot-revert workload") + + _, err = parseConfig([]string{ + "--blocks=1", + "--workload=snapshot-revert", + "--recipient-conflict-rate=0.5", + }) + require.ErrorContains(t, err, "recipient-conflict-rate is not supported with snapshot-revert workload") + + _, err = parseConfig([]string{ + "--blocks=1", + "--workload=snapshot-revert", + "--snapshot-revert-contract=0x0000000000000000000000000000000000000001", + "--snapshot-revert-helper=0x0000000000000000000000000000000000000001", + }) + require.ErrorContains(t, err, "snapshot-revert-contract and snapshot-revert-helper must differ") +} + +func TestParseWorkersConfig(t *testing.T) { + cfg, err := parseConfig([]string{ + "--blocks=1", + "--prepare-workers=2", + }) + require.NoError(t, err) + require.Equal(t, 1, cfg.parseWorkers) + + cfg, err = parseConfig([]string{ + "--blocks=1", + "--prepare-workers=1", + }) + require.NoError(t, err) + require.Equal(t, runtime.GOMAXPROCS(0), cfg.parseWorkers) + + cfg, err = parseConfig([]string{ + "--blocks=1", + "--prepare-workers=8", + "--parse-workers=3", + }) + require.NoError(t, err) + require.Equal(t, 3, cfg.parseWorkers) + + _, err = parseConfig([]string{ + "--blocks=1", + "--parse-workers=-1", + }) + require.ErrorContains(t, err, "parse-workers must be non-negative") +} + +func TestRunPrebuiltBlocks(t *testing.T) { + cfg, err := parseConfig([]string{ + "--metrics-addr=", + "--report-interval=0", + "--blocks=2", + "--txs-per-block=2", + }) + require.NoError(t, err) + require.NoError(t, run(cfg)) +} + +func TestPrepareBlocksCancelsWorkersOnOrderingInvariantError(t *testing.T) { + cfg, err := parseConfig([]string{ + "--metrics-addr=", + "--blocks=1", + "--report-interval=0", + "--queue-size=1", + "--prepare-workers=1", + }) + require.NoError(t, err) + blocks := make(chan blockEnvelope, 2) + blocks <- blockEnvelope{number: 1} + blocks <- blockEnvelope{number: 1} + out := make(chan preparedBlockEnvelope, 1) + metrics := newLoadMetrics(prometheus.NewRegistry()) + + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + errCh := make(chan error, 1) + go func() { + errCh <- prepareBlocks(ctx, cfg, preparedOnlyExecutor{}, blocks, out, metrics) + }() + + select { + case err := <-errCh: + require.ErrorContains(t, err, "prepared block 1 arrived after block 1") + case <-ctx.Done(): + t.Fatal("prepareBlocks timed out after ordering invariant error") + } +} + +func TestPrepareBlocksPrefersWorkerErrorOverOrderingDrainError(t *testing.T) { + cfg, err := parseConfig([]string{ + "--metrics-addr=", + "--blocks=1", + "--report-interval=0", + "--queue-size=2", + "--prepare-workers=1", + }) + require.NoError(t, err) + blocks := make(chan blockEnvelope, 2) + blocks <- blockEnvelope{number: 2, request: evmonly.BlockRequest{Context: evmonly.BlockContext{Number: 2}}} + blocks <- blockEnvelope{number: 1, request: evmonly.BlockRequest{Context: evmonly.BlockContext{Number: 1}}} + close(blocks) + out := make(chan preparedBlockEnvelope, 2) + metrics := newLoadMetrics(prometheus.NewRegistry()) + + err = prepareBlocks(t.Context(), cfg, failingPreparedExecutor{}, blocks, out, metrics) + require.ErrorContains(t, err, "prepare worker 0 prepare block 1: injected prepare failure") + require.ErrorContains(t, err, "prepared block stream closed before block 1") +} + +func TestPrepareBlocksDrainsPreparedBlocksAfterWorkersFinish(t *testing.T) { + cfg, err := parseConfig([]string{ + "--metrics-addr=", + "--blocks=1", + "--report-interval=0", + "--queue-size=2", + "--prepare-workers=1", + }) + require.NoError(t, err) + blocks := make(chan blockEnvelope, 2) + blocks <- blockEnvelope{number: 1} + blocks <- blockEnvelope{number: 2} + close(blocks) + out := make(chan preparedBlockEnvelope, 2) + metrics := newLoadMetrics(prometheus.NewRegistry()) + + require.NoError(t, prepareBlocks(t.Context(), cfg, preparedOnlyExecutor{}, blocks, out, metrics)) + require.Len(t, out, 2) +} + +func TestLoadMetricsRecordsOCCRerunsWithoutConflicts(t *testing.T) { + metrics := newLoadMetrics(prometheus.NewRegistry()) + metrics.recordFinished(txStatusCounts{total: 4, successful: 4}, 84_000, evmonly.OCCStats{ + Attempted: true, + RerunCount: 3, + }) + + snapshot := metrics.snapshot() + require.Equal(t, uint64(1), snapshot.occAttempts) + require.Equal(t, uint64(3), snapshot.occReruns) + require.Zero(t, snapshot.occConflicts) +} + +func TestLoadMetricsCountsOnlySuccessfulTxsAsTPS(t *testing.T) { + metrics := newLoadMetrics(prometheus.NewRegistry()) + counts := countTxStatuses([]evmonly.TxResult{ + {Status: ethtypes.ReceiptStatusSuccessful}, + {Status: ethtypes.ReceiptStatusFailed}, + {Status: 99}, + }) + metrics.recordFinished(counts, 63_000, evmonly.OCCStats{}) + + snapshot := metrics.snapshot() + require.Equal(t, uint64(3), snapshot.finishedTxs) + require.Equal(t, uint64(1), snapshot.successfulTxs) + require.Equal(t, uint64(2), snapshot.failedTxs) +} + +func TestBlockContextTimestampIsDeterministic(t *testing.T) { + cfg, err := parseConfig([]string{"--metrics-addr=", "--blocks=1"}) + require.NoError(t, err) + + require.Equal(t, defaultGenesisTimestamp+10, scenarios.BlockContext(scenarioConfig(cfg), 10).Time) + require.Equal(t, scenarios.BlockContext(scenarioConfig(cfg), 10).Time, scenarios.BlockContext(scenarioConfig(cfg), 10).Time) + require.Less(t, scenarios.BlockContext(scenarioConfig(cfg), 10).Time, scenarios.BlockContext(scenarioConfig(cfg), 11).Time) +} + +func TestQueueSizeDefaultPersistQueueOverflowValidation(t *testing.T) { + _, err := parseConfig([]string{ + "--blocks=1", + "--queue-size=" + fmt.Sprint(math.MaxInt), + }) + require.ErrorContains(t, err, "queue-size must be at most") +} + +func TestFileResultSinkWritesRLPRecordsAndCleansUpOnCancel(t *testing.T) { + dir := t.TempDir() + cfg, err := parseConfig([]string{ + "--metrics-addr=", + "--blocks=1", + "--result-sink=file", + "--persist-dir=" + dir, + "--persist-sync", + }) + require.NoError(t, err) + metrics := newLoadMetrics(prometheus.NewRegistry()) + sinks, err := newResultSinks(cfg, metrics) + require.NoError(t, err) + + changePath := filepath.Join(dir, "changesets.rlp") + receiptPath := filepath.Join(dir, "receipts.rlp") + writtenChangeSet := evmonly.StateChangeSet{ + Balances: []evmonly.BalanceChange{{ + Address: common.HexToAddress("0x0000000000000000000000000000000000000001"), + Balance: big.NewInt(7), + }}, + } + writtenReceipts := ethtypes.Receipts{{ + Type: ethtypes.LegacyTxType, + Status: ethtypes.ReceiptStatusSuccessful, + TxHash: common.HexToHash("0x01"), + }} + var released atomic.Bool + require.NoError(t, sinks.StoreBlockResult(t.Context(), 1, &evmonly.BlockResult{ + ChangeSet: writtenChangeSet, + Receipts: writtenReceipts, + }, func() { + released.Store(true) + })) + require.Eventually(t, func() bool { + return metrics.snapshot().sinkWritten == 2 && released.Load() + }, time.Second, time.Millisecond) + + requireFileExists(t, changePath) + requireFileExists(t, receiptPath) + var changeSet evmonly.StateChangeSet + height := readPersistedRLPRecord(t, changePath, &changeSet) + require.Equal(t, uint64(1), height) + require.NotEmpty(t, changeSet.Balances) + + var receipts ethtypes.Receipts + height = readPersistedRLPRecord(t, receiptPath, &receipts) + require.Equal(t, uint64(1), height) + require.Len(t, receipts, 1) + + ctx, cancel := context.WithCancel(t.Context()) + stopCleanup := cleanupSinksOnContextCancel(ctx, sinks) + cancel() + stopCleanup() + requireNoFileExists(t, changePath) + requireNoFileExists(t, receiptPath) + require.NoError(t, sinks.Close()) + requireNoFileExists(t, changePath) + requireNoFileExists(t, receiptPath) +} + +func TestAsyncFileResultSinkReportsMetrics(t *testing.T) { + dir := t.TempDir() + cfg, err := parseConfig([]string{ + "--metrics-addr=", + "--blocks=1", + "--result-sink=file", + "--persist-dir=" + dir, + "--persist-queue-size=1", + }) + require.NoError(t, err) + metrics := newLoadMetrics(prometheus.NewRegistry()) + sinks, err := newResultSinks(cfg, metrics) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + require.NoError(t, sinks.StoreBlockResult(ctx, 1, &evmonly.BlockResult{}, func() {})) + require.NoError(t, sinks.Close()) + + snapshot := metrics.snapshot() + require.Equal(t, uint64(2), snapshot.sinkEnqueued) + require.Equal(t, uint64(2), snapshot.sinkWritten) + require.Equal(t, int64(0), snapshot.sinkQueued) + require.Greater(t, snapshot.sinkBytes, uint64(0)) +} + +func TestAsyncFileResultSinkStoresBlockResultAndReleases(t *testing.T) { + dir := t.TempDir() + cfg, err := parseConfig([]string{ + "--metrics-addr=", + "--blocks=1", + "--result-sink=file", + "--persist-dir=" + dir, + "--persist-queue-size=1", + }) + require.NoError(t, err) + metrics := newLoadMetrics(prometheus.NewRegistry()) + sinks, err := newResultSinks(cfg, metrics) + require.NoError(t, err) + + result := &evmonly.BlockResult{ + ChangeSet: evmonly.StateChangeSet{ + Balances: []evmonly.BalanceChange{{ + Address: common.HexToAddress("0x0000000000000000000000000000000000000001"), + Balance: big.NewInt(7), + }}, + }, + Receipts: ethtypes.Receipts{{ + Type: ethtypes.LegacyTxType, + Status: ethtypes.ReceiptStatusSuccessful, + TxHash: common.HexToHash("0x02"), + }}, + } + var released atomic.Bool + require.NoError(t, sinks.StoreBlockResult(t.Context(), 1, result, func() { + released.Store(true) + })) + require.Eventually(t, func() bool { + return metrics.snapshot().sinkWritten == 2 && released.Load() + }, time.Second, time.Millisecond) + require.NoError(t, sinks.Close()) + + snapshot := metrics.snapshot() + require.Equal(t, uint64(2), snapshot.sinkEnqueued) + require.Equal(t, uint64(2), snapshot.sinkWritten) +} + +func TestAsyncFileResultSinkReleasesQueuedResultsAfterWriteError(t *testing.T) { + dir := t.TempDir() + recordFile, err := newAppendRLPFile(filepath.Join(dir, "records.rlp"), 1024, true) + require.NoError(t, err) + require.NoError(t, recordFile.file.Close()) + metrics := newLoadMetrics(prometheus.NewRegistry()) + sink := &asyncFileResultSinks{ + files: &fileResultSinks{ + changeSetFile: recordFile, + receiptFile: recordFile, + metrics: metrics, + }, + metrics: metrics, + records: make(chan resultSinkRecord, 3), + done: make(chan struct{}), + } + + var releases atomic.Uint64 + for i := 0; i < cap(sink.records); i++ { + sink.records <- resultSinkRecord{ + result: &evmonly.BlockResult{}, + release: func() { + releases.Add(1) + }, + } + } + close(sink.records) + sink.run() + + require.Error(t, sink.getErr()) + require.Equal(t, uint64(3), releases.Load()) +} + +func TestExecutorResultPoolReusesSlotsWithFileSink(t *testing.T) { + dir := t.TempDir() + cfg, err := parseConfig([]string{ + "--metrics-addr=", + "--blocks=1", + "--result-sink=file", + "--persist-dir=" + dir, + "--result-pool-size=1", + "--txs-per-block=2", + "--gas-price-wei=0", + "--min-gas-price-wei=0", + }) + require.NoError(t, err) + state := newGeneratedState() + workload, err := scenarios.NewTransferWorkload(scenarioConfig(cfg), state) + require.NoError(t, err) + metrics := newLoadMetrics(prometheus.NewRegistry()) + sinks, err := newResultSinks(cfg, metrics) + require.NoError(t, err) + defer func() { + require.NoError(t, sinks.Close()) + }() + executor := evmonly.NewExecutor(executorConfig(cfg), evmonly.WithState(state), evmonly.WithResultSink(sinks)) + defer executor.Close() + + for blockNumber := uint64(1); blockNumber <= 20; blockNumber++ { + request, err := workload.BuildBlock(t.Context(), blockNumber) + require.NoError(t, err) + result, err := executor.ExecuteBlock(t.Context(), request) + require.NoError(t, err) + result.Release() + require.Eventually(t, func() bool { + return executor.ResultPoolStats().Available == 1 + }, time.Second, time.Millisecond) + } + require.Equal(t, evmonly.BlockResultPoolStats{ + Capacity: 1, + Available: 1, + }, executor.ResultPoolStats()) +} + +func TestRunPrebuiltBlocksWithFileResultSinkCleansUp(t *testing.T) { + dir := t.TempDir() + cfg, err := parseConfig([]string{ + "--metrics-addr=", + "--report-interval=0", + "--blocks=2", + "--txs-per-block=2", + "--gas-price-wei=0", + "--min-gas-price-wei=0", + "--result-sink=file", + "--persist-dir=" + dir, + }) + require.NoError(t, err) + require.NoError(t, run(cfg)) + requireNoFileExists(t, filepath.Join(dir, "changesets.rlp")) + requireNoFileExists(t, filepath.Join(dir, "receipts.rlp")) +} + +type preparedOnlyExecutor struct{} + +func (preparedOnlyExecutor) ExecuteBlock(context.Context, evmonly.BlockRequest) (*evmonly.BlockResult, error) { + return nil, errors.New("ExecuteBlock is unused") +} + +func (preparedOnlyExecutor) PrepareBlock(_ context.Context, request evmonly.BlockRequest) (evmonly.PreparedBlock, error) { + return evmonly.PreparedBlock{Context: request.Context}, nil +} + +func (preparedOnlyExecutor) ExecutePreparedBlock(context.Context, evmonly.PreparedBlock) (*evmonly.BlockResult, error) { + return nil, errors.New("ExecutePreparedBlock is unused") +} + +type failingPreparedExecutor struct{} + +func (failingPreparedExecutor) ExecuteBlock(context.Context, evmonly.BlockRequest) (*evmonly.BlockResult, error) { + return nil, errors.New("ExecuteBlock is unused") +} + +func (failingPreparedExecutor) PrepareBlock(_ context.Context, request evmonly.BlockRequest) (evmonly.PreparedBlock, error) { + if request.Context.Number == 1 { + return evmonly.PreparedBlock{}, errors.New("injected prepare failure") + } + return evmonly.PreparedBlock{Context: request.Context}, nil +} + +func (failingPreparedExecutor) ExecutePreparedBlock(context.Context, evmonly.PreparedBlock) (*evmonly.BlockResult, error) { + return nil, errors.New("ExecutePreparedBlock is unused") +} + +func readPersistedRLPRecord(t *testing.T, path string, out any) uint64 { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + require.GreaterOrEqual(t, len(data), 16) + height := binary.BigEndian.Uint64(data[:8]) + payloadLen := binary.BigEndian.Uint64(data[8:16]) + require.LessOrEqual(t, payloadLen, uint64(len(data)-16)) + payload := data[16 : 16+payloadLen] + require.NoError(t, rlp.DecodeBytes(payload, out), fmt.Sprintf("decode %s", path)) + return height +} + +func requireFileExists(t *testing.T, path string) { + t.Helper() + _, err := os.Stat(path) + require.NoError(t, err) +} + +func requireNoFileExists(t *testing.T, path string) { + t.Helper() + _, err := os.Stat(path) + require.Truef(t, errors.Is(err, os.ErrNotExist), "expected %s to be removed, got %v", path, err) +} + +func BenchmarkExecuteTransferBlock(b *testing.B) { + tests := []struct { + name string + args []string + }{ + {name: "conflict_free"}, + { + name: "hot_recipient", + args: []string{"--recipient=0x00000000000000000000000000000000000000f1"}, + }, + { + name: "same_sender_nonce_chain", + args: []string{"--same-sender"}, + }, + } + for _, tc := range tests { + b.Run(tc.name, func(b *testing.B) { + args := append([]string{ + "--metrics-addr=", + "--blocks=1", + "--txs-per-block=1000", + "--gas-price-wei=0", + "--min-gas-price-wei=0", + }, tc.args...) + cfg, err := parseConfig(args) + require.NoError(b, err) + + state := newGeneratedState() + workload, err := scenarios.NewTransferWorkload(scenarioConfig(cfg), state) + require.NoError(b, err) + request, err := workload.BuildBlock(b.Context(), 1) + require.NoError(b, err) + executor := evmonly.NewExecutor(evmonly.Config{ + MinGasPrice: cfg.minGasPrice, + OCCWorkers: cfg.executorWorkers, + }, evmonly.WithState(state)) + + b.ReportAllocs() + b.SetBytes(int64(cfg.txsPerBlock)) + b.ResetTimer() + for i := 0; i < b.N; i++ { + result, err := executor.ExecuteBlock(b.Context(), request) + if err != nil { + b.Fatal(err) + } + if len(result.Txs) != cfg.txsPerBlock { + b.Fatalf("expected %d txs, got %d", cfg.txsPerBlock, len(result.Txs)) + } + result.Release() + } + executor.Close() + }) + } +} diff --git a/giga/evmonly/cmd/evmonly-loadtest/metrics.go b/giga/evmonly/cmd/evmonly-loadtest/metrics.go new file mode 100644 index 0000000000..6bf7e41c7a --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/metrics.go @@ -0,0 +1,639 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "sync/atomic" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/sei-protocol/sei-chain/giga/evmonly" +) + +type loadMetrics struct { + inputBlocks atomic.Uint64 + preparedBlocks atomic.Uint64 + preparedTxs atomic.Uint64 + finishedBlocks atomic.Uint64 + finishedTxs atomic.Uint64 + successfulTxs atomic.Uint64 + failedTxs atomic.Uint64 + gasConsumed atomic.Uint64 + prepareErrors atomic.Uint64 + executionErrors atomic.Uint64 + occAttempts atomic.Uint64 + occFallbacks atomic.Uint64 + occReruns atomic.Uint64 + occConflicts atomic.Uint64 + sinkEnqueued atomic.Uint64 + sinkWritten atomic.Uint64 + sinkBytes atomic.Uint64 + sinkWaitNanos atomic.Uint64 + sinkWaitEvents atomic.Uint64 + sinkWriteNanos atomic.Uint64 + sinkQueued atomic.Int64 + poolCapacity atomic.Int64 + poolAvailable atomic.Int64 + poolOverflow atomic.Uint64 + + inputBlocksTotal prometheus.Counter + preparedBlocksTotal prometheus.Counter + preparedTxsTotal prometheus.Counter + finishedBlocksTotal prometheus.Counter + finishedTxsTotal prometheus.Counter + successfulTxsTotal prometheus.Counter + failedTxsTotal prometheus.Counter + gasConsumedTotal prometheus.Counter + prepareErrorsTotal prometheus.Counter + executionErrorsTotal prometheus.Counter + occAttemptsTotal prometheus.Counter + occFallbacksTotal prometheus.Counter + occRerunsTotal prometheus.Counter + occConflictsTotal prometheus.Counter + + occFallbackReasonTotal *prometheus.CounterVec + occConflictAccessTotal *prometheus.CounterVec + sinkEnqueuedTotal *prometheus.CounterVec + sinkWrittenTotal *prometheus.CounterVec + sinkBytesTotal *prometheus.CounterVec + sinkEnqueueWaitTotal prometheus.Counter + sinkEnqueueWaitEvents prometheus.Counter + sinkWriteSecondsTotal *prometheus.CounterVec + + inputBlockRate prometheus.Gauge + preparedBlockRate prometheus.Gauge + preparedTxRate prometheus.Gauge + finishedBlockRate prometheus.Gauge + txRate prometheus.Gauge + finishedTxRate prometheus.Gauge + failedTxRate prometheus.Gauge + gasRate prometheus.Gauge + queuedBlocks prometheus.Gauge + sinkQueuedRecords prometheus.Gauge + sinkQueueCapacity prometheus.Gauge + poolCapacityGauge prometheus.Gauge + poolAvailableGauge prometheus.Gauge + poolOverflowGauge prometheus.Gauge +} + +type metricsSnapshot struct { + at time.Time + inputBlocks uint64 + preparedBlocks uint64 + preparedTxs uint64 + finishedBlocks uint64 + finishedTxs uint64 + successfulTxs uint64 + failedTxs uint64 + gasConsumed uint64 + prepareErrors uint64 + executionErrors uint64 + occAttempts uint64 + occFallbacks uint64 + occReruns uint64 + occConflicts uint64 + sinkEnqueued uint64 + sinkWritten uint64 + sinkBytes uint64 + sinkWaitNanos uint64 + sinkWaitEvents uint64 + sinkWriteNanos uint64 + sinkQueued int64 + poolCapacity int64 + poolAvailable int64 + poolOverflow uint64 +} + +func newLoadMetrics(registry *prometheus.Registry) *loadMetrics { + m := &loadMetrics{ + inputBlocksTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_block_input_total", + Help: "Total blocks fed to the EVM-only executor input queue.", + }), + preparedBlocksTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_block_prepared_total", + Help: "Total blocks decoded and sender-recovered before EVM-only executor execution.", + }), + preparedTxsTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_transactions_prepared_total", + Help: "Total transactions decoded and sender-recovered before EVM-only executor execution.", + }), + finishedBlocksTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_block_finished_total", + Help: "Total blocks that finished EVM-only executor execution.", + }), + finishedTxsTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_transactions_finished_total", + Help: "Total transactions that finished EVM-only executor execution.", + }), + successfulTxsTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_transactions_successful_total", + Help: "Total successful transactions that finished EVM-only executor execution.", + }), + failedTxsTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_transactions_failed_total", + Help: "Total failed transactions that finished EVM-only executor execution.", + }), + gasConsumedTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_gas_consumed_total", + Help: "Total EVM gas consumed by finished blocks.", + }), + prepareErrorsTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_prepare_errors_total", + Help: "Total block preparation errors returned while decoding transactions or recovering senders.", + }), + executionErrorsTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_execution_errors_total", + Help: "Total block execution errors returned by the EVM-only executor.", + }), + occAttemptsTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_occ_attempts_total", + Help: "Total blocks executed with optimistic concurrency control.", + }), + occFallbacksTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_occ_fallbacks_total", + Help: "Total OCC blocks that fell back to sequential execution.", + }), + occRerunsTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_occ_reruns_total", + Help: "Total OCC transaction rerun attempts caused by stale speculative execution state.", + }), + occConflictsTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_occ_conflicts_total", + Help: "Total OCC conflict accesses observed before sequential fallback.", + }), + occFallbackReasonTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "evmonly_loadtest_occ_fallback_reasons_total", + Help: "OCC fallback count by reason.", + }, []string{"reason"}), + occConflictAccessTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "evmonly_loadtest_occ_conflict_accesses_total", + Help: "OCC conflict accesses by access type and state kind.", + }, []string{"access", "kind"}), + sinkEnqueuedTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "evmonly_loadtest_result_sink_records_enqueued_total", + Help: "Total records accepted by the result sink.", + }, []string{"kind"}), + sinkWrittenTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "evmonly_loadtest_result_sink_records_written_total", + Help: "Total records written by the result sink.", + }, []string{"kind"}), + sinkBytesTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "evmonly_loadtest_result_sink_bytes_written_total", + Help: "Total bytes written by the result sink, including record framing.", + }, []string{"kind"}), + sinkEnqueueWaitTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_result_sink_enqueue_wait_seconds_total", + Help: "Total time executor workers spent blocked enqueueing result sink records.", + }), + sinkEnqueueWaitEvents: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_result_sink_enqueue_wait_events_total", + Help: "Total result sink enqueue attempts that had to wait for queue capacity or cancellation.", + }), + sinkWriteSecondsTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "evmonly_loadtest_result_sink_write_seconds_total", + Help: "Total time spent by result sink writers serializing, writing, flushing, and optionally syncing records.", + }, []string{"kind"}), + inputBlockRate: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_block_input_throughput", + Help: "Most recent measured block input throughput in blocks per second.", + }), + preparedBlockRate: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_block_prepared_throughput", + Help: "Most recent measured block preparation throughput in blocks per second.", + }), + preparedTxRate: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_transactions_prepared_per_second", + Help: "Most recent measured transaction decode and sender recovery throughput in transactions per second.", + }), + finishedBlockRate: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_block_finished_throughput", + Help: "Most recent measured block completion throughput in blocks per second.", + }), + txRate: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_transactions_per_second", + Help: "Most recent measured successful transaction execution throughput in transactions per second.", + }), + finishedTxRate: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_transactions_finished_per_second", + Help: "Most recent measured total finished transaction execution throughput in transactions per second.", + }), + failedTxRate: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_transactions_failed_per_second", + Help: "Most recent measured failed transaction execution throughput in transactions per second.", + }), + gasRate: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_gas_consumed_per_second", + Help: "Most recent measured gas consumption throughput in gas per second.", + }), + queuedBlocks: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_queued_blocks", + Help: "Blocks currently waiting in the executor input queue.", + }), + sinkQueuedRecords: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_result_sink_queued_records", + Help: "Persistent result sink records currently waiting for the async writer.", + }), + sinkQueueCapacity: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_result_sink_queue_capacity", + Help: "Capacity of the persistent result sink async record queue.", + }), + poolCapacityGauge: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_result_pool_capacity", + Help: "Capacity of the reusable executor block result pool.", + }), + poolAvailableGauge: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_result_pool_available", + Help: "Reusable executor block result slots currently available.", + }), + poolOverflowGauge: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_result_pool_overflow_allocations", + Help: "Total block results allocated outside the reusable pool because all slots were retained.", + }), + } + registry.MustRegister( + m.inputBlocksTotal, + m.preparedBlocksTotal, + m.preparedTxsTotal, + m.finishedBlocksTotal, + m.finishedTxsTotal, + m.successfulTxsTotal, + m.failedTxsTotal, + m.gasConsumedTotal, + m.prepareErrorsTotal, + m.executionErrorsTotal, + m.occAttemptsTotal, + m.occFallbacksTotal, + m.occRerunsTotal, + m.occConflictsTotal, + m.occFallbackReasonTotal, + m.occConflictAccessTotal, + m.sinkEnqueuedTotal, + m.sinkWrittenTotal, + m.sinkBytesTotal, + m.sinkEnqueueWaitTotal, + m.sinkEnqueueWaitEvents, + m.sinkWriteSecondsTotal, + m.inputBlockRate, + m.preparedBlockRate, + m.preparedTxRate, + m.finishedBlockRate, + m.txRate, + m.finishedTxRate, + m.failedTxRate, + m.gasRate, + m.queuedBlocks, + m.sinkQueuedRecords, + m.sinkQueueCapacity, + m.poolCapacityGauge, + m.poolAvailableGauge, + m.poolOverflowGauge, + ) + return m +} + +func (m *loadMetrics) recordInput() { + m.inputBlocks.Add(1) + m.inputBlocksTotal.Inc() +} + +func (m *loadMetrics) recordPrepared(txCount int) { + m.preparedBlocks.Add(1) + m.preparedTxs.Add(uint64FromNonNegativeInt(txCount)) + m.preparedBlocksTotal.Inc() + m.preparedTxsTotal.Add(float64(txCount)) +} + +func (m *loadMetrics) recordPrepareError() { + m.prepareErrors.Add(1) + m.prepareErrorsTotal.Inc() +} + +func (m *loadMetrics) recordFinished(counts txStatusCounts, gasUsed uint64, occStats evmonly.OCCStats) { + m.finishedBlocks.Add(1) + m.finishedTxs.Add(uint64FromNonNegativeInt(counts.total)) + m.successfulTxs.Add(uint64FromNonNegativeInt(counts.successful)) + m.failedTxs.Add(uint64FromNonNegativeInt(counts.failed)) + m.gasConsumed.Add(gasUsed) + m.finishedBlocksTotal.Inc() + m.finishedTxsTotal.Add(float64(counts.total)) + m.successfulTxsTotal.Add(float64(counts.successful)) + m.failedTxsTotal.Add(float64(counts.failed)) + m.gasConsumedTotal.Add(float64(gasUsed)) + m.recordOCC(occStats) +} + +func (m *loadMetrics) recordOCC(stats evmonly.OCCStats) { + if !stats.Attempted { + return + } + m.occAttempts.Add(1) + m.occAttemptsTotal.Inc() + if stats.Fallback { + reason := stats.FallbackReason + if reason == "" { + reason = "unknown" + } + m.occFallbacks.Add(1) + m.occFallbacksTotal.Inc() + m.occFallbackReasonTotal.WithLabelValues(reason).Inc() + } + if stats.RerunCount > 0 { + m.occReruns.Add(stats.RerunCount) + m.occRerunsTotal.Add(float64(stats.RerunCount)) + } + if stats.ConflictCount == 0 { + return + } + m.occConflicts.Add(stats.ConflictCount) + m.occConflictsTotal.Add(float64(stats.ConflictCount)) + for _, conflict := range stats.ConflictSamples { + m.occConflictAccessTotal.WithLabelValues( + conflict.Access, + conflict.Kind, + ).Add(float64(conflict.Count)) + } +} + +func (m *loadMetrics) recordExecutionError() { + m.executionErrors.Add(1) + m.executionErrorsTotal.Inc() +} + +func (m *loadMetrics) recordSinkEnqueued(kind string) { + m.sinkEnqueued.Add(1) + m.sinkEnqueuedTotal.WithLabelValues(kind).Inc() +} + +func (m *loadMetrics) recordSinkEnqueueWait(elapsed time.Duration) { + if elapsed <= 0 { + return + } + m.sinkWaitNanos.Add(uint64FromNonNegativeInt64(elapsed.Nanoseconds())) + m.sinkWaitEvents.Add(1) + m.sinkEnqueueWaitTotal.Add(elapsed.Seconds()) + m.sinkEnqueueWaitEvents.Inc() +} + +func (m *loadMetrics) recordSinkWrite(kind string, bytes int, elapsed time.Duration, completed bool) { + if elapsed > 0 { + m.sinkWriteNanos.Add(uint64FromNonNegativeInt64(elapsed.Nanoseconds())) + m.sinkWriteSecondsTotal.WithLabelValues(kind).Add(elapsed.Seconds()) + } + if !completed { + return + } + m.sinkWritten.Add(1) + m.sinkBytes.Add(uint64FromNonNegativeInt(bytes)) + m.sinkWrittenTotal.WithLabelValues(kind).Inc() + m.sinkBytesTotal.WithLabelValues(kind).Add(float64(bytes)) +} + +func (m *loadMetrics) recordResultPoolStats(stats evmonly.BlockResultPoolStats) { + m.poolCapacity.Store(int64(stats.Capacity)) + m.poolAvailable.Store(int64(stats.Available)) + m.poolOverflow.Store(stats.OverflowAllocations) + m.poolCapacityGauge.Set(float64(stats.Capacity)) + m.poolAvailableGauge.Set(float64(stats.Available)) + m.poolOverflowGauge.Set(float64(stats.OverflowAllocations)) +} + +func (m *loadMetrics) setSinkQueued(records int) { + m.sinkQueued.Store(int64(records)) + m.sinkQueuedRecords.Set(float64(records)) +} + +func (m *loadMetrics) setSinkQueueCapacity(records int) { + m.sinkQueueCapacity.Set(float64(records)) +} + +func (m *loadMetrics) snapshot() metricsSnapshot { + return metricsSnapshot{ + at: time.Now(), + inputBlocks: m.inputBlocks.Load(), + preparedBlocks: m.preparedBlocks.Load(), + preparedTxs: m.preparedTxs.Load(), + finishedBlocks: m.finishedBlocks.Load(), + finishedTxs: m.finishedTxs.Load(), + successfulTxs: m.successfulTxs.Load(), + failedTxs: m.failedTxs.Load(), + gasConsumed: m.gasConsumed.Load(), + prepareErrors: m.prepareErrors.Load(), + executionErrors: m.executionErrors.Load(), + occAttempts: m.occAttempts.Load(), + occFallbacks: m.occFallbacks.Load(), + occReruns: m.occReruns.Load(), + occConflicts: m.occConflicts.Load(), + sinkEnqueued: m.sinkEnqueued.Load(), + sinkWritten: m.sinkWritten.Load(), + sinkBytes: m.sinkBytes.Load(), + sinkWaitNanos: m.sinkWaitNanos.Load(), + sinkWaitEvents: m.sinkWaitEvents.Load(), + sinkWriteNanos: m.sinkWriteNanos.Load(), + sinkQueued: m.sinkQueued.Load(), + poolCapacity: m.poolCapacity.Load(), + poolAvailable: m.poolAvailable.Load(), + poolOverflow: m.poolOverflow.Load(), + } +} + +func (m *loadMetrics) setRates(prev, curr metricsSnapshot, queued int) { + elapsed := curr.at.Sub(prev.at).Seconds() + if elapsed <= 0 { + return + } + inputRate := float64(curr.inputBlocks-prev.inputBlocks) / elapsed + preparedRate := float64(curr.preparedBlocks-prev.preparedBlocks) / elapsed + preparedTxRate := float64(curr.preparedTxs-prev.preparedTxs) / elapsed + finishedRate := float64(curr.finishedBlocks-prev.finishedBlocks) / elapsed + finishedTxRate := float64(curr.finishedTxs-prev.finishedTxs) / elapsed + txRate := float64(curr.successfulTxs-prev.successfulTxs) / elapsed + failedTxRate := float64(curr.failedTxs-prev.failedTxs) / elapsed + gasRate := float64(curr.gasConsumed-prev.gasConsumed) / elapsed + m.inputBlockRate.Set(inputRate) + m.preparedBlockRate.Set(preparedRate) + m.preparedTxRate.Set(preparedTxRate) + m.finishedBlockRate.Set(finishedRate) + m.txRate.Set(txRate) + m.finishedTxRate.Set(finishedTxRate) + m.failedTxRate.Set(failedTxRate) + m.gasRate.Set(gasRate) + m.queuedBlocks.Set(float64(queued)) +} + +func reportLoop(ctx context.Context, interval time.Duration, metrics *loadMetrics, blocks <-chan preparedBlockEnvelope) { + if interval == 0 { + <-ctx.Done() + return + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + + prev := metrics.snapshot() + for ctx.Err() == nil { + select { + case <-ctx.Done(): + return + case <-ticker.C: + curr := metrics.snapshot() + queued := len(blocks) + metrics.setRates(prev, curr, queued) + elapsed := curr.at.Sub(prev.at).Seconds() + if elapsed <= 0 { + prev = curr + continue + } + sinkWaitSeconds := float64(curr.sinkWaitNanos-prev.sinkWaitNanos) / float64(time.Second) + sinkWriteSeconds := float64(curr.sinkWriteNanos-prev.sinkWriteNanos) / float64(time.Second) + fmt.Printf( + "input_blocks/s=%.2f prepared_blocks/s=%.2f prepared_tx/s=%.2f finished_blocks/s=%.2f finished_tx/s=%.2f success_tx/s=%.2f failed_tx/s=%.2f gas/s=%.2f queued_blocks=%d sink_queue=%d pool_available=%d pool_overflow=%d sink_enqueue_wait/s=%.6f sink_write/s=%.6f totals(input_blocks=%d prepared_blocks=%d prepared_txs=%d finished_blocks=%d txs=%d successful_txs=%d failed_txs=%d gas=%d prepare_errors=%d errors=%d occ_attempts=%d occ_fallbacks=%d occ_reruns=%d sink_enqueued=%d sink_written=%d)\n", + float64(curr.inputBlocks-prev.inputBlocks)/elapsed, + float64(curr.preparedBlocks-prev.preparedBlocks)/elapsed, + float64(curr.preparedTxs-prev.preparedTxs)/elapsed, + float64(curr.finishedBlocks-prev.finishedBlocks)/elapsed, + float64(curr.finishedTxs-prev.finishedTxs)/elapsed, + float64(curr.successfulTxs-prev.successfulTxs)/elapsed, + float64(curr.failedTxs-prev.failedTxs)/elapsed, + float64(curr.gasConsumed-prev.gasConsumed)/elapsed, + queued, + curr.sinkQueued, + curr.poolAvailable, + curr.poolOverflow, + sinkWaitSeconds/elapsed, + sinkWriteSeconds/elapsed, + curr.inputBlocks, + curr.preparedBlocks, + curr.preparedTxs, + curr.finishedBlocks, + curr.finishedTxs, + curr.successfulTxs, + curr.failedTxs, + curr.gasConsumed, + curr.prepareErrors, + curr.executionErrors, + curr.occAttempts, + curr.occFallbacks, + curr.occReruns, + curr.sinkEnqueued, + curr.sinkWritten, + ) + prev = curr + } + } +} + +func printFinalReport(startedAt time.Time, snapshot metricsSnapshot) { + elapsed := snapshot.at.Sub(startedAt).Seconds() + if elapsed <= 0 { + elapsed = 1 + } + fmt.Printf( + "complete elapsed=%s input_blocks=%d prepared_blocks=%d prepared_txs=%d finished_blocks=%d txs=%d successful_txs=%d failed_txs=%d gas=%d prepare_errors=%d errors=%d occ_attempts=%d occ_fallbacks=%d occ_reruns=%d sink_queue=%d sink_enqueued=%d sink_written=%d sink_bytes=%d sink_enqueue_wait=%s sink_enqueue_wait_events=%d sink_write=%s pool_capacity=%d pool_available=%d pool_overflow=%d avg_input_blocks/s=%.2f avg_prepared_blocks/s=%.2f avg_prepared_tx/s=%.2f avg_finished_blocks/s=%.2f avg_finished_tx/s=%.2f avg_success_tx/s=%.2f avg_failed_tx/s=%.2f avg_gas/s=%.2f\n", + snapshot.at.Sub(startedAt).Round(time.Millisecond), + snapshot.inputBlocks, + snapshot.preparedBlocks, + snapshot.preparedTxs, + snapshot.finishedBlocks, + snapshot.finishedTxs, + snapshot.successfulTxs, + snapshot.failedTxs, + snapshot.gasConsumed, + snapshot.prepareErrors, + snapshot.executionErrors, + snapshot.occAttempts, + snapshot.occFallbacks, + snapshot.occReruns, + snapshot.sinkQueued, + snapshot.sinkEnqueued, + snapshot.sinkWritten, + snapshot.sinkBytes, + durationFromUint64Nanos(snapshot.sinkWaitNanos).Round(time.Microsecond), + snapshot.sinkWaitEvents, + durationFromUint64Nanos(snapshot.sinkWriteNanos).Round(time.Microsecond), + snapshot.poolCapacity, + snapshot.poolAvailable, + snapshot.poolOverflow, + float64(snapshot.inputBlocks)/elapsed, + float64(snapshot.preparedBlocks)/elapsed, + float64(snapshot.preparedTxs)/elapsed, + float64(snapshot.finishedBlocks)/elapsed, + float64(snapshot.finishedTxs)/elapsed, + float64(snapshot.successfulTxs)/elapsed, + float64(snapshot.failedTxs)/elapsed, + float64(snapshot.gasConsumed)/elapsed, + ) +} + +func printResultSinkReport(closeElapsed time.Duration, snapshot metricsSnapshot) { + fmt.Printf( + "result sink close elapsed=%s sink_queue=%d sink_enqueued=%d sink_written=%d sink_bytes=%d sink_enqueue_wait=%s sink_enqueue_wait_events=%d sink_write=%s\n", + closeElapsed.Round(time.Millisecond), + snapshot.sinkQueued, + snapshot.sinkEnqueued, + snapshot.sinkWritten, + snapshot.sinkBytes, + durationFromUint64Nanos(snapshot.sinkWaitNanos).Round(time.Microsecond), + snapshot.sinkWaitEvents, + durationFromUint64Nanos(snapshot.sinkWriteNanos).Round(time.Microsecond), + ) +} + +func printPrebuildReport(elapsed time.Duration, blocks []blockEnvelope, txsPerBlock int) { + seconds := elapsed.Seconds() + if seconds <= 0 { + seconds = 1 + } + txCount := len(blocks) * txsPerBlock + fmt.Printf( + "prebuild complete elapsed=%s blocks=%d txs=%d build_blocks/s=%.2f build_tx/s=%.2f\n", + elapsed.Round(time.Millisecond), + len(blocks), + txCount, + float64(len(blocks))/seconds, + float64(txCount)/seconds, + ) +} + +type metricsServer struct { + server *http.Server + done chan error +} + +func startMetricsServer(addr string, registry *prometheus.Registry) (*metricsServer, error) { + listener, err := net.Listen("tcp", addr) + if err != nil { + return nil, fmt.Errorf("listen for metrics on %s: %w", addr, err) + } + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.HandlerFor(registry, promhttp.HandlerOpts{})) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("ok\n")) + }) + server := &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: 3 * time.Second, + } + ms := &metricsServer{server: server, done: make(chan error, 1)} + go func() { + err := server.Serve(listener) + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + ms.done <- err + }() + return ms, nil +} + +func (s *metricsServer) stop(timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + if err := s.server.Shutdown(ctx); err != nil { + return err + } + return <-s.done +} diff --git a/giga/evmonly/cmd/evmonly-loadtest/pipeline.go b/giga/evmonly/cmd/evmonly-loadtest/pipeline.go new file mode 100644 index 0000000000..8d2fdf6ae7 --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/pipeline.go @@ -0,0 +1,409 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "os/signal" + "sync/atomic" + "syscall" + "time" + + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/prometheus/client_golang/prometheus" + "github.com/sei-protocol/sei-chain/giga/evmonly" + "github.com/sei-protocol/sei-chain/giga/evmonly/cmd/evmonly-loadtest/scenarios" + "golang.org/x/sync/errgroup" +) + +func run(cfg config) (err error) { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + state := newGeneratedState() + workload, err := newWorkload(cfg, state) + if err != nil { + return err + } + registry := prometheus.NewRegistry() + metrics := newLoadMetrics(registry) + sinks, err := newResultSinks(cfg, metrics) + if err != nil { + return err + } + defer func() { + closeStartedAt := time.Now() + if closeErr := sinks.Close(); closeErr != nil { + if err != nil { + fmt.Fprintf(os.Stderr, "evmonly-loadtest: result sink close: %v\n", closeErr) + return + } + err = closeErr + } + if cfg.resultSink == resultSinkFile { + printResultSinkReport(time.Since(closeStartedAt), metrics.snapshot()) + } + }() + stopSinkSignalCleanup := cleanupSinksOnContextCancel(ctx, sinks) + defer stopSinkSignalCleanup() + + var server *metricsServer + if cfg.metricsAddr != "" { + var err error + server, err = startMetricsServer(cfg.metricsAddr, registry) + if err != nil { + return err + } + defer func() { + if err := server.stop(3 * time.Second); err != nil { + fmt.Fprintf(os.Stderr, "evmonly-loadtest: metrics server shutdown: %v\n", err) + } + }() + fmt.Printf("metrics listening on http://%s/metrics\n", cfg.metricsAddr) + } + + return runPrebuilt(ctx, cfg, state, workload, sinks, metrics) +} + +func cleanupSinksOnContextCancel(ctx context.Context, sinks *resultSinks) func() { + cleanupCtx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + select { + case <-ctx.Done(): + if err := sinks.Cleanup(); err != nil { + fmt.Fprintf(os.Stderr, "evmonly-loadtest: result sink cleanup: %v\n", err) + } + case <-cleanupCtx.Done(): + } + }() + return func() { + select { + case <-ctx.Done(): + default: + cancel() + } + <-done + } +} + +type blockWorkload = scenarios.Workload + +func newWorkload(cfg config, state *generatedState) (blockWorkload, error) { + return scenarios.NewWorkload(cfg.workload, scenarioConfig(cfg), state) +} + +func runPrebuilt(ctx context.Context, cfg config, state *generatedState, workload blockWorkload, sinks *resultSinks, metrics *loadMetrics) (err error) { + prebuildStartedAt := time.Now() + prebuilt, err := prebuildBlockRequests(ctx, cfg, workload) + if err != nil { + return err + } + state.Freeze() + prebuildElapsed := time.Since(prebuildStartedAt) + printPrebuildReport(prebuildElapsed, prebuilt, cfg.txsPerBlock) + + profiles, err := startProfiles(cfg) + if err != nil { + return err + } + + blocks := make(chan blockEnvelope, cfg.queueSize) + preparedBlocks := make(chan preparedBlockEnvelope, cfg.queueSize) + reportCtx, stopReporter := context.WithCancel(ctx) + reportDone := make(chan struct{}) + go func() { + defer close(reportDone) + reportLoop(reportCtx, cfg.reportInterval, metrics, preparedBlocks) + }() + + startedAt := time.Now() + group, groupCtx := errgroup.WithContext(ctx) + executor := evmonly.NewExecutor(executorConfig(cfg), evmonly.WithState(state), evmonly.WithResultSink(sinks)) + defer executor.Close() + metrics.recordResultPoolStats(executor.ResultPoolStats()) + group.Go(func() error { + defer close(blocks) + return feedPrebuiltBlocks(groupCtx, prebuilt, blocks, metrics) + }) + group.Go(func() error { + defer close(preparedBlocks) + return prepareBlocks(groupCtx, cfg, executor, blocks, preparedBlocks, metrics) + }) + for workerID := 0; workerID < cfg.workers; workerID++ { + workerID := workerID + group.Go(func() error { + return executeBlocks(groupCtx, workerID, executor, preparedBlocks, metrics) + }) + } + + err = group.Wait() + metrics.recordResultPoolStats(executor.ResultPoolStats()) + stopReporter() + <-reportDone + + if errors.Is(err, context.Canceled) { + err = nil + } + printFinalReport(startedAt, metrics.snapshot()) + // Drop raw block retention before heap profiling forces a GC. + prebuilt = nil + finishProfiles(profiles, &err) + return err +} + +func prebuildBlockRequests(ctx context.Context, cfg config, workload blockWorkload) ([]blockEnvelope, error) { + blockCount, err := checkedIntFromUint64("blocks", cfg.blocks) + if err != nil { + return nil, err + } + prebuilt := make([]blockEnvelope, blockCount) + var nextBlock atomic.Uint64 + group, groupCtx := errgroup.WithContext(ctx) + for builderID := 0; builderID < cfg.builders; builderID++ { + group.Go(func() error { + for groupCtx.Err() == nil { + number := nextBlock.Add(1) + if number > cfg.blocks { + return nil + } + request, err := workload.BuildBlock(groupCtx, number) + if err != nil { + if groupCtx.Err() != nil { + return nil + } + return err + } + prebuilt[number-1] = blockEnvelope{ + number: number, + request: request, + } + } + return nil + }) + } + if err := group.Wait(); err != nil { + return nil, err + } + return prebuilt, nil +} + +func feedPrebuiltBlocks(ctx context.Context, prebuilt []blockEnvelope, out chan<- blockEnvelope, metrics *loadMetrics) error { + for _, block := range prebuilt { + select { + case out <- block: + metrics.recordInput() + case <-ctx.Done(): + return nil + } + } + return nil +} + +func prepareBlocks( + ctx context.Context, + cfg config, + executor evmonly.PreparedBlockExecutor, + blocks <-chan blockEnvelope, + out chan<- preparedBlockEnvelope, + metrics *loadMetrics, +) error { + unordered := make(chan preparedBlockEnvelope, cfg.queueSize) + workerCtx, cancelWorkers := context.WithCancel(ctx) + defer cancelWorkers() + group, groupCtx := errgroup.WithContext(workerCtx) + for workerID := 0; workerID < cfg.prepareWorkers; workerID++ { + workerID := workerID + group.Go(func() error { + for groupCtx.Err() == nil { + select { + case <-groupCtx.Done(): + return nil + case block, ok := <-blocks: + if !ok { + return nil + } + prepared, err := executor.PrepareBlock(groupCtx, block.request) + if err != nil { + if groupCtx.Err() != nil { + return nil + } + metrics.recordPrepareError() + return fmt.Errorf("prepare worker %d prepare block %d: %w", workerID, block.number, err) + } + select { + case unordered <- preparedBlockEnvelope{number: block.number, block: prepared}: + metrics.recordPrepared(len(prepared.Txs)) + case <-groupCtx.Done(): + return nil + } + } + } + return nil + }) + } + done := make(chan error, 1) + go func() { + err := group.Wait() + close(unordered) + done <- err + }() + orderErr := forwardPreparedBlocksInOrder(ctx, unordered, out) + if orderErr != nil { + cancelWorkers() + } + workerErr := <-done + if workerErr != nil { + return errors.Join(workerErr, orderErr) + } + if orderErr != nil { + return orderErr + } + return nil +} + +func forwardPreparedBlocksInOrder(ctx context.Context, in <-chan preparedBlockEnvelope, out chan<- preparedBlockEnvelope) error { + next := uint64(1) + pending := make(map[uint64]preparedBlockEnvelope) + for ctx.Err() == nil { + for block, ok := pending[next]; ok; block, ok = pending[next] { + if err := sendPreparedBlock(ctx, out, block); err != nil { + return err + } + delete(pending, next) + next++ + } + + select { + case <-ctx.Done(): + return nil + case block, ok := <-in: + if !ok { + for len(pending) > 0 { + block, ok := pending[next] + if !ok { + if ctx.Err() != nil { + return nil + } + return fmt.Errorf("prepared block stream closed before block %d", next) + } + if err := sendPreparedBlock(ctx, out, block); err != nil { + return err + } + delete(pending, next) + next++ + } + return nil + } + if block.number < next { + return fmt.Errorf("prepared block %d arrived after block %d", block.number, next-1) + } + if block.number == next { + if err := sendPreparedBlock(ctx, out, block); err != nil { + return err + } + next++ + continue + } + if _, exists := pending[block.number]; exists { + return fmt.Errorf("duplicate prepared block %d", block.number) + } + pending[block.number] = block + } + } + return nil +} + +func sendPreparedBlock(ctx context.Context, out chan<- preparedBlockEnvelope, block preparedBlockEnvelope) error { + select { + case out <- block: + return nil + case <-ctx.Done(): + return nil + } +} + +func checkedIntFromUint64(name string, value uint64) (int, error) { + if value > maxIntAsUint64() { + return 0, fmt.Errorf("%s cannot allocate %d blocks on this platform", name, value) + } + return int(value), nil //nolint:gosec // checked against max int above. +} + +func maxIntAsUint64() uint64 { + return uint64(^uint(0) >> 1) +} + +func uint64FromNonNegativeInt(value int) uint64 { + if value < 0 { + panic("negative integer cannot be converted to uint64") + } + return uint64(value) //nolint:gosec // negative values are rejected above. +} + +func uint64FromNonNegativeInt64(value int64) uint64 { + if value < 0 { + return 0 + } + return uint64(value) //nolint:gosec // negative values are rejected above. +} + +func durationFromUint64Nanos(nanos uint64) time.Duration { + const maxDurationNanos = uint64(1<<63 - 1) + if nanos > maxDurationNanos { + return time.Duration(maxDurationNanos) //nolint:gosec // capped to max int64 duration by construction. + } + return time.Duration(nanos) //nolint:gosec // capped to max int64 duration above. +} + +func executeBlocks( + ctx context.Context, + workerID int, + executor *evmonly.Executor, + blocks <-chan preparedBlockEnvelope, + metrics *loadMetrics, +) error { + for ctx.Err() == nil { + select { + case <-ctx.Done(): + return nil + case block, ok := <-blocks: + if !ok { + return nil + } + result, err := executor.ExecutePreparedBlock(ctx, block.block) + if err != nil { + if ctx.Err() != nil { + return nil + } + metrics.recordExecutionError() + return fmt.Errorf("worker %d execute block %d: %w", workerID, block.number, err) + } + counts := countTxStatuses(result.Txs) + metrics.recordFinished(counts, result.GasUsed, result.OCCStats) + result.Release() + metrics.recordResultPoolStats(executor.ResultPoolStats()) + } + } + return nil +} + +type txStatusCounts struct { + total int + successful int + failed int +} + +func countTxStatuses(txs []evmonly.TxResult) txStatusCounts { + counts := txStatusCounts{total: len(txs)} + for _, tx := range txs { + if tx.Status == ethtypes.ReceiptStatusSuccessful { + counts.successful++ + } else { + counts.failed++ + } + } + return counts +} diff --git a/giga/evmonly/cmd/evmonly-loadtest/profiles.go b/giga/evmonly/cmd/evmonly-loadtest/profiles.go new file mode 100644 index 0000000000..ac1fca6bb2 --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/profiles.go @@ -0,0 +1,117 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "runtime/pprof" + "runtime/trace" +) + +type profileSession struct { + cpuFile *os.File + traceFile *os.File + heapPath string + cpuActive bool + traceActive bool + heapComplete bool +} + +func startProfiles(cfg config) (*profileSession, error) { + if cfg.cpuProfile == "" && cfg.heapProfile == "" && cfg.traceProfile == "" { + return nil, nil + } + session := &profileSession{heapPath: cfg.heapProfile} + if cfg.cpuProfile != "" { + file, err := createProfileFile(cfg.cpuProfile) + if err != nil { + return nil, err + } + session.cpuFile = file + if err := pprof.StartCPUProfile(file); err != nil { + _ = session.Close() + return nil, fmt.Errorf("start CPU profile: %w", err) + } + session.cpuActive = true + } + if cfg.traceProfile != "" { + file, err := createProfileFile(cfg.traceProfile) + if err != nil { + _ = session.Close() + return nil, err + } + session.traceFile = file + if err := trace.Start(file); err != nil { + _ = session.Close() + return nil, fmt.Errorf("start runtime trace: %w", err) + } + session.traceActive = true + } + return session, nil +} + +func createProfileFile(path string) (*os.File, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return nil, fmt.Errorf("create profile dir for %s: %w", path, err) + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) //nolint:gosec // profile output path is an explicit CLI argument. + if err != nil { + return nil, fmt.Errorf("create profile file %s: %w", path, err) + } + return file, nil +} + +func (p *profileSession) Close() error { + if p == nil { + return nil + } + var errs []error + if p.traceActive { + trace.Stop() + p.traceActive = false + } + if p.cpuActive { + pprof.StopCPUProfile() + p.cpuActive = false + } + if p.cpuFile != nil { + errs = append(errs, p.cpuFile.Close()) + p.cpuFile = nil + } + if p.traceFile != nil { + errs = append(errs, p.traceFile.Close()) + p.traceFile = nil + } + if p.heapPath != "" && !p.heapComplete { + errs = append(errs, writeHeapProfile(p.heapPath)) + p.heapComplete = true + } + return errors.Join(errs...) +} + +func writeHeapProfile(path string) (err error) { + file, err := createProfileFile(path) + if err != nil { + return err + } + defer func() { + err = errors.Join(err, file.Close()) + }() + runtime.GC() + if err := pprof.WriteHeapProfile(file); err != nil { + return fmt.Errorf("write heap profile %s: %w", path, err) + } + return nil +} + +func finishProfiles(session *profileSession, err *error) { + if closeErr := session.Close(); closeErr != nil { + if *err != nil { + fmt.Fprintf(os.Stderr, "evmonly-loadtest: write profile: %v\n", closeErr) + return + } + *err = closeErr + } +} diff --git a/giga/evmonly/cmd/evmonly-loadtest/scenarios/erc20_transfer.go b/giga/evmonly/cmd/evmonly-loadtest/scenarios/erc20_transfer.go new file mode 100644 index 0000000000..fdbc83184e --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/scenarios/erc20_transfer.go @@ -0,0 +1,83 @@ +package scenarios + +import ( + "context" + "sync/atomic" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/sei-protocol/sei-chain/giga/evmonly" + loadoffline "github.com/sei-protocol/sei-load/generator/offline" +) + +type ERC20TransferWorkload struct { + cfg Config + state State + scenario loadoffline.Scenario + conflictParticipants int + accountCursor atomic.Uint64 +} + +func NewERC20TransferWorkload(cfg Config, state State) (*ERC20TransferWorkload, error) { + scenario, err := loadoffline.NewScenario(loadoffline.ERC20Transfer, offlineConfig(cfg)) + if err != nil { + return nil, err + } + if err := scenario.SetupGenesis(state); err != nil { + return nil, err + } + return &ERC20TransferWorkload{ + cfg: cfg, + state: state, + scenario: scenario, + conflictParticipants: recipientConflictParticipants(cfg.TxsPerBlock, cfg.RecipientConflictRate), + }, nil +} + +func (w *ERC20TransferWorkload) BuildBlock(ctx context.Context, number uint64) (evmonly.BlockRequest, error) { + txs := make([][]byte, w.cfg.TxsPerBlock) + for i := 0; i < w.cfg.TxsPerBlock; i++ { + select { + case <-ctx.Done(): + return evmonly.BlockRequest{}, ctx.Err() + default: + } + accountIndex := w.accountCursor.Add(1) + recipient := w.Recipient(number, i, accountIndex) + raw, sender, err := w.buildTransferTx(accountIndex, recipient) + if err != nil { + return evmonly.BlockRequest{}, err + } + w.scenario.SeedSender(w.state, sender) + txs[i] = raw + } + return evmonly.BlockRequest{ + Context: BlockContext(w.cfg, number), + Txs: txs, + }, nil +} + +func (w *ERC20TransferWorkload) buildTransferTx(accountIndex uint64, recipient common.Address) ([]byte, common.Address, error) { + key, err := DeterministicPrivateKey(accountIndex) + if err != nil { + return nil, common.Address{}, err + } + sender := crypto.PubkeyToAddress(key.PublicKey) + signed, err := w.scenario.BuildTransaction(key, 0, recipient) + if err != nil { + return nil, common.Address{}, err + } + raw, err := signed.MarshalBinary() + if err != nil { + return nil, common.Address{}, err + } + return raw, sender, nil +} + +func (w *ERC20TransferWorkload) Recipient(blockNumber uint64, txIndex int, accountIndex uint64) common.Address { + return workloadRecipient(w.cfg, w.conflictParticipants, "sei-evmonly-loadtest-erc20-recipient", "sei-evmonly-loadtest-erc20-conflict-recipient", blockNumber, txIndex, accountIndex) +} + +func ERC20BalanceSlot(owner common.Address) common.Hash { + return loadoffline.ERC20BalanceSlot(owner) +} diff --git a/giga/evmonly/cmd/evmonly-loadtest/scenarios/helpers.go b/giga/evmonly/cmd/evmonly-loadtest/scenarios/helpers.go new file mode 100644 index 0000000000..d027eb273c --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/scenarios/helpers.go @@ -0,0 +1,118 @@ +package scenarios + +import ( + "crypto/ecdsa" + "encoding/binary" + "fmt" + "math" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/sei-protocol/sei-chain/giga/evmonly" +) + +func workloadRecipient(cfg Config, conflictParticipants int, uniquePrefix string, conflictPrefix string, blockNumber uint64, txIndex int, accountIndex uint64) common.Address { + if cfg.FixedRecipient != nil { + return *cfg.FixedRecipient + } + if txIndex < conflictParticipants { + return blockScopedAddressFromSeed(conflictPrefix, blockNumber, uint64FromNonNegativeInt(txIndex/2)) + } + return addressFromSeed(uniquePrefix, accountIndex) +} + +func recipientConflictParticipants(txsPerBlock int, rate float64) int { + if rate <= 0 || txsPerBlock < 2 { + return 0 + } + if rate >= 1 { + if txsPerBlock%2 == 0 { + return txsPerBlock + } + return txsPerBlock - 1 + } + count := int(math.Round(rate * float64(txsPerBlock))) + if count < 2 { + count = 2 + } + if count > txsPerBlock { + count = txsPerBlock + } + if count%2 != 0 { + if count == txsPerBlock { + count-- + } else { + count++ + } + } + return count +} + +func uint64FromNonNegativeInt(value int) uint64 { + if value < 0 { + panic("negative integer cannot be converted to uint64") + } + return uint64(value) //nolint:gosec // negative values are rejected above. +} + +func BlockContext(cfg Config, number uint64) evmonly.BlockContext { + gasLimit := cfg.BlockGasLimit + if gasLimit == 0 { + gasLimit = math.MaxUint64 + } + return evmonly.BlockContext{ + Number: number, + Time: blockTimestamp(number), + GasLimit: gasLimit, + ChainID: new(big.Int).Set(cfg.ChainID), + BaseFee: big.NewInt(0), + BlobBaseFee: big.NewInt(0), + Coinbase: cfg.Coinbase, + ParentHash: hashFromSeed("sei-evmonly-loadtest-parent", number-1), + BlockHash: hashFromSeed("sei-evmonly-loadtest-block", number), + PrevRandao: hashFromSeed("sei-evmonly-loadtest-randao", number), + } +} + +func blockTimestamp(number uint64) uint64 { + if number > math.MaxUint64-DefaultGenesisTimestamp { + return math.MaxUint64 + } + return DefaultGenesisTimestamp + number +} + +func DeterministicPrivateKey(index uint64) (*ecdsa.PrivateKey, error) { + var buf [16]byte + binary.BigEndian.PutUint64(buf[:8], index) + for attempt := uint64(0); ; attempt++ { + binary.BigEndian.PutUint64(buf[8:], attempt) + key, err := crypto.ToECDSA(crypto.Keccak256([]byte("sei-evmonly-loadtest-sender"), buf[:])) + if err == nil { + return key, nil + } + if attempt == ^uint64(0) { + break + } + } + return nil, fmt.Errorf("could not derive private key for account %d", index) +} + +func addressFromSeed(prefix string, index uint64) common.Address { + hash := hashFromSeed(prefix, index) + return common.BytesToAddress(hash[12:]) +} + +func blockScopedAddressFromSeed(prefix string, blockNumber uint64, index uint64) common.Address { + var buf [16]byte + binary.BigEndian.PutUint64(buf[:8], blockNumber) + binary.BigEndian.PutUint64(buf[8:], index) + hash := crypto.Keccak256Hash([]byte(prefix), buf[:]) + return common.BytesToAddress(hash[12:]) +} + +func hashFromSeed(prefix string, index uint64) common.Hash { + var buf [8]byte + binary.BigEndian.PutUint64(buf[:], index) + return crypto.Keccak256Hash([]byte(prefix), buf[:]) +} diff --git a/giga/evmonly/cmd/evmonly-loadtest/scenarios/snapshot_revert.go b/giga/evmonly/cmd/evmonly-loadtest/scenarios/snapshot_revert.go new file mode 100644 index 0000000000..8c3f87c91c --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/scenarios/snapshot_revert.go @@ -0,0 +1,94 @@ +package scenarios + +import ( + "context" + "math/big" + "sync/atomic" + + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/sei-protocol/sei-chain/giga/evmonly" +) + +type SnapshotRevertWorkload struct { + cfg Config + state State + signer ethtypes.Signer + accountCursor atomic.Uint64 +} + +var ( + // The outer runtime stores 1 at storage[CALLER], delegatecalls the helper + // address encoded in calldata, ignores the helper's reverted status, and + // returns successfully. + snapshotRevertOuterRuntimeCode = common.FromHex("0x6001335560006000600060006000355af450600160005260206000f3") + // The helper runtime runs under DELEGATECALL, overwrites storage[CALLER] + // with 2 in the outer storage context, then executes REVERT. + snapshotRevertHelperRuntimeCode = common.FromHex("0x6002335560006000fd") +) + +func NewSnapshotRevertWorkload(cfg Config, state State) *SnapshotRevertWorkload { + state.SetCode(cfg.SnapshotRevertContract, snapshotRevertOuterRuntimeCode) + state.SetCode(cfg.SnapshotRevertHelper, snapshotRevertHelperRuntimeCode) + return &SnapshotRevertWorkload{ + cfg: cfg, + state: state, + signer: ethtypes.LatestSignerForChainID(cfg.ChainID), + } +} + +func (w *SnapshotRevertWorkload) BuildBlock(ctx context.Context, number uint64) (evmonly.BlockRequest, error) { + txs := make([][]byte, w.cfg.TxsPerBlock) + for i := 0; i < w.cfg.TxsPerBlock; i++ { + select { + case <-ctx.Done(): + return evmonly.BlockRequest{}, ctx.Err() + default: + } + accountIndex := w.accountCursor.Add(1) + raw, sender, err := w.buildCallTx(accountIndex) + if err != nil { + return evmonly.BlockRequest{}, err + } + w.state.SetBalance(sender, w.cfg.SenderBalance) + txs[i] = raw + } + return evmonly.BlockRequest{ + Context: BlockContext(w.cfg, number), + Txs: txs, + }, nil +} + +func (w *SnapshotRevertWorkload) buildCallTx(accountIndex uint64) ([]byte, common.Address, error) { + key, err := DeterministicPrivateKey(accountIndex) + if err != nil { + return nil, common.Address{}, err + } + sender := crypto.PubkeyToAddress(key.PublicKey) + tx := ethtypes.NewTx(ðtypes.LegacyTx{ + Nonce: 0, + GasPrice: new(big.Int).Set(w.cfg.GasPrice), + Gas: w.cfg.TxGasLimit, + To: &w.cfg.SnapshotRevertContract, + Value: new(big.Int), + Data: addressCalldata(w.cfg.SnapshotRevertHelper), + }) + signed, err := ethtypes.SignTx(tx, w.signer, key) + if err != nil { + return nil, common.Address{}, err + } + raw, err := signed.MarshalBinary() + if err != nil { + return nil, common.Address{}, err + } + return raw, sender, nil +} +func addressCalldata(addr common.Address) []byte { + data := make([]byte, 32) + copy(data[12:], addr.Bytes()) + return data +} +func SnapshotRevertStorageSlot(sender common.Address) common.Hash { + return common.BytesToHash(sender.Bytes()) +} diff --git a/giga/evmonly/cmd/evmonly-loadtest/scenarios/transfer.go b/giga/evmonly/cmd/evmonly-loadtest/scenarios/transfer.go new file mode 100644 index 0000000000..ae2cf61065 --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/scenarios/transfer.go @@ -0,0 +1,96 @@ +package scenarios + +import ( + "context" + "sync/atomic" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/sei-protocol/sei-chain/giga/evmonly" + loadoffline "github.com/sei-protocol/sei-load/generator/offline" +) + +type TransferWorkload struct { + cfg Config + state State + scenario loadoffline.Scenario + conflictParticipants int + accountCursor atomic.Uint64 +} + +func NewTransferWorkload(cfg Config, state State) (*TransferWorkload, error) { + scenario, err := loadoffline.NewScenario(loadoffline.Transfer, offlineConfig(cfg)) + if err != nil { + return nil, err + } + if err := scenario.SetupGenesis(state); err != nil { + return nil, err + } + return &TransferWorkload{ + cfg: cfg, + state: state, + scenario: scenario, + conflictParticipants: recipientConflictParticipants(cfg.TxsPerBlock, cfg.RecipientConflictRate), + }, nil +} + +func (w *TransferWorkload) BuildBlock(ctx context.Context, number uint64) (evmonly.BlockRequest, error) { + txs := make([][]byte, w.cfg.TxsPerBlock) + for i := 0; i < w.cfg.TxsPerBlock; i++ { + select { + case <-ctx.Done(): + return evmonly.BlockRequest{}, ctx.Err() + default: + } + accountIndex := w.accountCursor.Add(1) + senderIndex := accountIndex + nonce := uint64(0) + if w.cfg.SameSender { + senderIndex = number + nonce = uint64(i) //nolint:gosec // i is bounded by txsPerBlock. + } + recipient := w.Recipient(number, i, accountIndex) + raw, sender, err := w.buildTransferTx(senderIndex, nonce, recipient) + if err != nil { + return evmonly.BlockRequest{}, err + } + w.scenario.SeedSender(w.state, sender) + txs[i] = raw + } + return evmonly.BlockRequest{ + Context: BlockContext(w.cfg, number), + Txs: txs, + }, nil +} + +func (w *TransferWorkload) buildTransferTx(accountIndex, nonce uint64, recipient common.Address) ([]byte, common.Address, error) { + key, err := DeterministicPrivateKey(accountIndex) + if err != nil { + return nil, common.Address{}, err + } + sender := crypto.PubkeyToAddress(key.PublicKey) + signed, err := w.scenario.BuildTransaction(key, nonce, recipient) + if err != nil { + return nil, common.Address{}, err + } + raw, err := signed.MarshalBinary() + if err != nil { + return nil, common.Address{}, err + } + return raw, sender, nil +} + +func (w *TransferWorkload) Recipient(blockNumber uint64, txIndex int, accountIndex uint64) common.Address { + return workloadRecipient(w.cfg, w.conflictParticipants, "sei-evmonly-loadtest-recipient", "sei-evmonly-loadtest-conflict-recipient", blockNumber, txIndex, accountIndex) +} + +func offlineConfig(cfg Config) loadoffline.Config { + return loadoffline.Config{ + ChainID: cfg.ChainID, + GasPrice: cfg.GasPrice, + SenderBalance: cfg.SenderBalance, + TransferValue: cfg.TransferValue, + GasLimit: cfg.TxGasLimit, + ERC20Contract: cfg.ERC20Contract, + } +} diff --git a/giga/evmonly/cmd/evmonly-loadtest/scenarios/types.go b/giga/evmonly/cmd/evmonly-loadtest/scenarios/types.go new file mode 100644 index 0000000000..294f963ef7 --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/scenarios/types.go @@ -0,0 +1,58 @@ +package scenarios + +import ( + "context" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/sei-protocol/sei-chain/giga/evmonly" +) + +const ( + WorkloadTransfer = "transfer" + WorkloadERC20Transfer = "erc20-transfer" + WorkloadSnapshotRevert = "snapshot-revert" + + DefaultGenesisTimestamp = uint64(1_700_000_000) +) + +type Config struct { + TxsPerBlock int + ChainID *big.Int + GasPrice *big.Int + SenderBalance *big.Int + TransferValue *big.Int + TxGasLimit uint64 + BlockGasLimit uint64 + Coinbase common.Address + ERC20Contract common.Address + SnapshotRevertContract common.Address + SnapshotRevertHelper common.Address + FixedRecipient *common.Address + RecipientConflictRate float64 + SameSender bool +} + +type State interface { + SetBalance(common.Address, *big.Int) + SetCode(common.Address, []byte) + SetState(common.Address, common.Hash, common.Hash) +} + +type Workload interface { + BuildBlock(context.Context, uint64) (evmonly.BlockRequest, error) +} + +func NewWorkload(kind string, cfg Config, state State) (Workload, error) { + switch kind { + case WorkloadTransfer: + return NewTransferWorkload(cfg, state) + case WorkloadERC20Transfer: + return NewERC20TransferWorkload(cfg, state) + case WorkloadSnapshotRevert: + return NewSnapshotRevertWorkload(cfg, state), nil + default: + return nil, fmt.Errorf("unsupported workload %q", kind) + } +} diff --git a/giga/evmonly/cmd/evmonly-loadtest/sinks.go b/giga/evmonly/cmd/evmonly-loadtest/sinks.go new file mode 100644 index 0000000000..a26f123a96 --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/sinks.go @@ -0,0 +1,373 @@ +package main + +import ( + "bufio" + "context" + "encoding/binary" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/ethereum/go-ethereum/rlp" + "github.com/sei-protocol/sei-chain/giga/evmonly" +) + +type discardStateWriter struct{} + +var _ evmonly.StateWriter = (*discardStateWriter)(nil) + +func (*discardStateWriter) ApplyChangeSet(evmonly.StateChangeSet) {} + +type resultSinks struct { + sink evmonly.ResultSink + close func() error + cleanup func() error +} + +var _ evmonly.ResultSink = (*resultSinks)(nil) + +func newResultSinks(cfg config, metrics *loadMetrics) (*resultSinks, error) { + switch cfg.resultSink { + case resultSinkDiscard: + return &resultSinks{ + sink: discardResultSink{writer: &discardStateWriter{}}, + }, nil + case resultSinkFile: + return newFileResultSinks(cfg, metrics) + default: + return nil, fmt.Errorf("unsupported result-sink %q", cfg.resultSink) + } +} + +func (s *resultSinks) StoreBlockResult(ctx context.Context, height uint64, result *evmonly.BlockResult, release func()) error { + return s.sink.StoreBlockResult(ctx, height, result, release) +} + +func (s *resultSinks) Close() error { + var closeErr error + if s.close == nil { + closeErr = nil + } else { + closeErr = s.close() + } + return errors.Join(closeErr, s.Cleanup()) +} + +func (s *resultSinks) Cleanup() error { + if s.cleanup == nil { + return nil + } + return s.cleanup() +} + +type discardResultSink struct { + writer evmonly.StateWriter +} + +func (s discardResultSink) StoreBlockResult(_ context.Context, _ uint64, result *evmonly.BlockResult, release func()) error { + defer release() + s.writer.ApplyChangeSet(result.ChangeSet) + return nil +} + +type fileResultSinks struct { + changeSetFile *appendRLPFile + receiptFile *appendRLPFile + metrics *loadMetrics + cleanupMu sync.Mutex + paths []string + cleaned map[string]struct{} +} + +func newFileResultSinks(cfg config, metrics *loadMetrics) (*resultSinks, error) { + if err := os.MkdirAll(cfg.persistDir, 0o750); err != nil { + return nil, fmt.Errorf("create persist dir %s: %w", cfg.persistDir, err) + } + changeSetPath := filepath.Join(cfg.persistDir, "changesets.rlp") + receiptPath := filepath.Join(cfg.persistDir, "receipts.rlp") + files := &fileResultSinks{ + metrics: metrics, + paths: []string{changeSetPath, receiptPath}, + cleaned: map[string]struct{}{}, + } + var err error + files.changeSetFile, err = newAppendRLPFile(changeSetPath, cfg.persistBufferSize, cfg.persistSync) + if err != nil { + return nil, err + } + files.receiptFile, err = newAppendRLPFile(receiptPath, cfg.persistBufferSize, cfg.persistSync) + if err != nil { + return nil, errors.Join(err, files.Close()) + } + async := newAsyncFileResultSinks(files, cfg.persistQueueSize, metrics) + return &resultSinks{ + sink: async, + close: async.Close, + cleanup: files.Cleanup, + }, nil +} + +func (s *fileResultSinks) Close() error { + var errs []error + if s.changeSetFile != nil { + errs = append(errs, s.changeSetFile.Close()) + } + if s.receiptFile != nil { + errs = append(errs, s.receiptFile.Close()) + } + errs = append(errs, s.Cleanup()) + return errors.Join(errs...) +} + +func (s *fileResultSinks) Cleanup() error { + s.cleanupMu.Lock() + defer s.cleanupMu.Unlock() + + var errs []error + for _, path := range s.paths { + if _, ok := s.cleaned[path]; ok { + continue + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + errs = append(errs, fmt.Errorf("remove persist file %s: %w", path, err)) + continue + } + s.cleaned[path] = struct{}{} + } + return errors.Join(errs...) +} + +func (s *fileResultSinks) WriteRecord(kind string, height uint64, value any) error { + var file *appendRLPFile + switch kind { + case resultSinkChangeSet: + file = s.changeSetFile + case resultSinkReceipts: + file = s.receiptFile + default: + return fmt.Errorf("unsupported result sink record kind %q", kind) + } + startedAt := time.Now() + bytes, err := file.WriteRecord(height, value) + elapsed := time.Since(startedAt) + if s.metrics != nil { + s.metrics.recordSinkWrite(kind, bytes, elapsed, err == nil) + } + return err +} + +type resultSinkRecord struct { + height uint64 + result *evmonly.BlockResult + release func() +} + +type asyncFileResultSinks struct { + files *fileResultSinks + metrics *loadMetrics + records chan resultSinkRecord + done chan struct{} + closeErr error + close sync.Once + errMu sync.Mutex + err error +} + +func newAsyncFileResultSinks(files *fileResultSinks, queueSize int, metrics *loadMetrics) *asyncFileResultSinks { + s := &asyncFileResultSinks{ + files: files, + metrics: metrics, + records: make(chan resultSinkRecord, queueSize), + done: make(chan struct{}), + } + if metrics != nil { + metrics.setSinkQueueCapacity(queueSize) + } + go s.run() + return s +} + +func (s *asyncFileResultSinks) StoreBlockResult(ctx context.Context, height uint64, result *evmonly.BlockResult, release func()) error { + return s.enqueue(ctx, resultSinkRecord{ + height: height, + result: result, + release: release, + }) +} + +func (s *asyncFileResultSinks) enqueue(ctx context.Context, record resultSinkRecord) error { + if err := s.getErr(); err != nil { + return err + } + select { + case s.records <- record: + s.recordEnqueued() + return nil + default: + } + + startedAt := time.Now() + select { + case s.records <- record: + if s.metrics != nil { + s.metrics.recordSinkEnqueueWait(time.Since(startedAt)) + } + s.recordEnqueued() + return nil + case <-s.done: + if err := s.getErr(); err != nil { + return err + } + return fmt.Errorf("result sink is closed") + case <-ctx.Done(): + if s.metrics != nil { + s.metrics.recordSinkEnqueueWait(time.Since(startedAt)) + } + return ctx.Err() + } +} + +func (s *asyncFileResultSinks) recordEnqueued() { + if s.metrics == nil { + return + } + s.metrics.recordSinkEnqueued(resultSinkChangeSet) + s.metrics.recordSinkEnqueued(resultSinkReceipts) + s.metrics.setSinkQueued(len(s.records)) +} + +func (s *asyncFileResultSinks) run() { + defer close(s.done) + var writeErr error + for record := range s.records { + if s.metrics != nil { + s.metrics.setSinkQueued(len(s.records)) + } + if writeErr == nil { + if err := s.writeRecord(record); err != nil { + writeErr = err + s.setErr(err) + } + } + record.releaseResult() + if s.metrics != nil { + s.metrics.setSinkQueued(len(s.records)) + } + } + if s.metrics != nil { + s.metrics.setSinkQueued(0) + } +} + +func (s *asyncFileResultSinks) writeRecord(record resultSinkRecord) error { + if err := s.files.WriteRecord(resultSinkChangeSet, record.height, record.result.ChangeSet); err != nil { + return err + } + return s.files.WriteRecord(resultSinkReceipts, record.height, record.result.Receipts) +} + +func (r resultSinkRecord) releaseResult() { + if r.release != nil { + r.release() + } +} + +func (s *asyncFileResultSinks) Close() error { + s.close.Do(func() { + close(s.records) + <-s.done + if s.metrics != nil { + s.metrics.setSinkQueued(0) + } + s.closeErr = errors.Join(s.getErr(), s.files.Close()) + }) + return s.closeErr +} + +func (s *asyncFileResultSinks) setErr(err error) { + s.errMu.Lock() + defer s.errMu.Unlock() + if s.err == nil { + s.err = err + } +} + +func (s *asyncFileResultSinks) getErr() error { + s.errMu.Lock() + defer s.errMu.Unlock() + return s.err +} + +type appendRLPFile struct { + mu sync.Mutex + file *os.File + writer *bufio.Writer + syncOnWrite bool + closed bool +} + +func newAppendRLPFile(path string, bufferSize int, syncOnWrite bool) (*appendRLPFile, error) { + file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) //nolint:gosec // persist output path is an explicit CLI argument. + if err != nil { + return nil, fmt.Errorf("open persist file %s: %w", path, err) + } + return &appendRLPFile{ + file: file, + writer: bufio.NewWriterSize(file, bufferSize), + syncOnWrite: syncOnWrite, + }, nil +} + +func (f *appendRLPFile) WriteRecord(height uint64, value any) (int, error) { + payload, err := rlp.EncodeToBytes(value) + if err != nil { + return 0, fmt.Errorf("encode rlp record for height %d: %w", height, err) + } + var header [16]byte + binary.BigEndian.PutUint64(header[:8], height) + binary.BigEndian.PutUint64(header[8:], uint64(len(payload))) + + f.mu.Lock() + defer f.mu.Unlock() + if f.closed { + return 0, fmt.Errorf("write record for height %d: persist file is closed", height) + } + if _, err := f.writer.Write(header[:]); err != nil { + return 0, fmt.Errorf("write record header for height %d: %w", height, err) + } + if _, err := f.writer.Write(payload); err != nil { + return 0, fmt.Errorf("write record payload for height %d: %w", height, err) + } + if f.syncOnWrite { + if err := f.writer.Flush(); err != nil { + return 0, fmt.Errorf("flush record for height %d: %w", height, err) + } + if err := f.file.Sync(); err != nil { + return 0, fmt.Errorf("sync record for height %d: %w", height, err) + } + } + return len(header) + len(payload), nil +} + +func (f *appendRLPFile) sync() error { + if err := f.writer.Flush(); err != nil { + return err + } + if err := f.file.Sync(); err != nil { + return fmt.Errorf("sync persist file: %w", err) + } + return nil +} + +func (f *appendRLPFile) Close() error { + f.mu.Lock() + defer f.mu.Unlock() + if f.closed { + return nil + } + f.closed = true + return errors.Join(f.sync(), f.file.Close()) +} diff --git a/giga/evmonly/cmd/evmonly-loadtest/state.go b/giga/evmonly/cmd/evmonly-loadtest/state.go new file mode 100644 index 0000000000..95b8b3c322 --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/state.go @@ -0,0 +1,140 @@ +package main + +import ( + "math/big" + "sync" + "sync/atomic" + + "github.com/ethereum/go-ethereum/common" + "github.com/sei-protocol/sei-chain/giga/evmonly" +) + +type generatedState struct { + mu sync.RWMutex + frozen atomic.Bool + balances map[common.Address]*big.Int + nonces map[common.Address]uint64 + code map[common.Address][]byte + storage map[common.Address]map[common.Hash]common.Hash +} + +var _ evmonly.StateReader = (*generatedState)(nil) + +var frozenZeroBalance = new(big.Int) + +func newGeneratedState() *generatedState { + return &generatedState{ + balances: map[common.Address]*big.Int{}, + nonces: map[common.Address]uint64{}, + code: map[common.Address][]byte{}, + storage: map[common.Address]map[common.Hash]common.Hash{}, + } +} + +func (s *generatedState) Freeze() { + s.frozen.Store(true) +} + +func (s *generatedState) GetBalance(addr common.Address) *big.Int { + if s.frozen.Load() { + // Frozen reads return shared, non-owned pointers; StateReader consumers + // must copy before mutation. + if balance, ok := s.balances[addr]; ok && balance != nil { + return balance + } + return frozenZeroBalance + } + s.mu.RLock() + defer s.mu.RUnlock() + if balance, ok := s.balances[addr]; ok && balance != nil { + return new(big.Int).Set(balance) + } + return new(big.Int) +} + +func (s *generatedState) SetBalance(addr common.Address, balance *big.Int) { + s.requireMutable() + s.mu.Lock() + defer s.mu.Unlock() + if balance == nil { + s.balances[addr] = new(big.Int) + return + } + s.balances[addr] = new(big.Int).Set(balance) +} + +func (s *generatedState) GetNonce(addr common.Address) uint64 { + if s.frozen.Load() { + return s.nonces[addr] + } + s.mu.RLock() + defer s.mu.RUnlock() + return s.nonces[addr] +} + +func (s *generatedState) SetNonce(addr common.Address, nonce uint64) { + s.requireMutable() + s.mu.Lock() + defer s.mu.Unlock() + s.nonces[addr] = nonce +} + +func (s *generatedState) GetCode(addr common.Address) []byte { + if s.frozen.Load() { + return cloneBytes(s.code[addr]) + } + s.mu.RLock() + defer s.mu.RUnlock() + return cloneBytes(s.code[addr]) +} + +func (s *generatedState) SetCode(addr common.Address, code []byte) { + s.requireMutable() + s.mu.Lock() + defer s.mu.Unlock() + s.code[addr] = cloneBytes(code) +} + +func (s *generatedState) GetState(addr common.Address, key common.Hash) common.Hash { + if s.frozen.Load() { + if accountStorage, ok := s.storage[addr]; ok { + return accountStorage[key] + } + return common.Hash{} + } + s.mu.RLock() + defer s.mu.RUnlock() + if accountStorage, ok := s.storage[addr]; ok { + return accountStorage[key] + } + return common.Hash{} +} + +func (s *generatedState) SetState(addr common.Address, key common.Hash, value common.Hash) { + s.requireMutable() + s.mu.Lock() + defer s.mu.Unlock() + accountStorage, ok := s.storage[addr] + if !ok { + accountStorage = map[common.Hash]common.Hash{} + s.storage[addr] = accountStorage + } + if value == (common.Hash{}) { + delete(accountStorage, key) + return + } + accountStorage[key] = value +} + +func (s *generatedState) requireMutable() { + if s.frozen.Load() { + panic("generated state is frozen") + } +} + +func cloneBytes(v []byte) []byte { + if len(v) == 0 { + return nil + } + return append([]byte(nil), v...) +} diff --git a/go.mod b/go.mod index 0caea25634..fc7540b492 100644 --- a/go.mod +++ b/go.mod @@ -71,7 +71,7 @@ require ( github.com/segmentio/kafka-go v0.4.50 github.com/sei-protocol/goutils v0.0.2 github.com/sei-protocol/sei-config v0.0.21 - github.com/sei-protocol/sei-load v0.0.0-20251007135253-78fbdc141082 + github.com/sei-protocol/sei-load v0.0.1 github.com/sei-protocol/sei-tm-db v0.0.5 github.com/sei-protocol/seilog v0.0.3 github.com/snikch/goodman v0.0.0-20171125024755-10e37e294daa @@ -89,21 +89,21 @@ require ( github.com/tidwall/wal v1.2.1 github.com/zbiljic/go-filelock v0.0.0-20170914061330-1dbf7103ab7d github.com/zeebo/blake3 v0.2.4 - go.opentelemetry.io/otel v1.39.0 + go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/exporters/jaeger v1.9.0 go.opentelemetry.io/otel/exporters/prometheus v0.60.0 - go.opentelemetry.io/otel/metric v1.39.0 - go.opentelemetry.io/otel/sdk v1.39.0 - go.opentelemetry.io/otel/sdk/metric v1.39.0 - go.opentelemetry.io/otel/trace v1.39.0 - golang.org/x/crypto v0.47.0 + go.opentelemetry.io/otel/metric v1.43.0 + go.opentelemetry.io/otel/sdk v1.43.0 + go.opentelemetry.io/otel/sdk/metric v1.43.0 + go.opentelemetry.io/otel/trace v1.43.0 + golang.org/x/crypto v0.49.0 golang.org/x/exp v0.0.0-20260112195511-716be5621a96 - golang.org/x/net v0.49.0 - golang.org/x/sync v0.19.0 - golang.org/x/sys v0.40.0 + golang.org/x/net v0.52.0 + golang.org/x/sync v0.20.0 + golang.org/x/sys v0.42.0 golang.org/x/time v0.13.0 - google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 - google.golang.org/grpc v1.75.0 + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 + google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v3 v3.0.1 @@ -265,11 +265,11 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/image v0.36.0 - golang.org/x/mod v0.32.0 - golang.org/x/term v0.39.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/mod v0.33.0 + golang.org/x/term v0.41.0 // indirect + golang.org/x/text v0.35.0 // indirect google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gotest.tools v2.2.0+incompatible // indirect nhooyr.io/websocket v1.8.6 // indirect diff --git a/go.sum b/go.sum index cfab424426..311d7c986e 100644 --- a/go.sum +++ b/go.sum @@ -1813,8 +1813,8 @@ github.com/sei-protocol/goutils v0.0.2 h1:Bfa7Sv+4CVLNM20QcpvGb81B8C5HkQC/kW1CQp github.com/sei-protocol/goutils v0.0.2/go.mod h1:iYE2DuJfEnM+APPehr2gOUXfuLuPsVxorcDO+Tzq9q8= github.com/sei-protocol/sei-config v0.0.21 h1:0VXOz91v9YeM2dmpKi1XaIFy+p3EamDAJtnj02XLKIw= github.com/sei-protocol/sei-config v0.0.21/go.mod h1:zcEdLzyIH2AyP0/QRBE3s4Y9eGn0C/qAUx1c4o4EROU= -github.com/sei-protocol/sei-load v0.0.0-20251007135253-78fbdc141082 h1:f2sY8OcN60UL1/6POx+HDMZ4w04FTZtSScnrFSnGZHg= -github.com/sei-protocol/sei-load v0.0.0-20251007135253-78fbdc141082/go.mod h1:V0fNURAjS6A8+sA1VllegjNeSobay3oRUW5VFZd04bA= +github.com/sei-protocol/sei-load v0.0.1 h1:sGlG1QRbc2pKCeeD4P0H2lkqFcljlup5l6JNi6HydyM= +github.com/sei-protocol/sei-load v0.0.1/go.mod h1:AjEkVo91siRmr5r7REcK3fSSbHC59hq+nCNtxwJUG8Q= github.com/sei-protocol/sei-tm-db v0.0.5 h1:3WONKdSXEqdZZeLuWYfK5hP37TJpfaUa13vAyAlvaQY= github.com/sei-protocol/sei-tm-db v0.0.5/go.mod h1:Cpa6rGyczgthq7/0pI31jys2Fw0Nfrc+/jKdP1prVqY= github.com/sei-protocol/seilog v0.0.3 h1:Zi7oWXdX5jv92dY8n482xH032LtNebC89Y+qYZlBn0Y= @@ -2061,20 +2061,20 @@ go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/jaeger v1.9.0 h1:gAEgEVGDWwFjcis9jJTOJqZNxDzoZfR12WNIxr7g9Ww= go.opentelemetry.io/otel/exporters/jaeger v1.9.0/go.mod h1:hquezOLVAybNW6vanIxkdLXTXvzlj2Vn3wevSP15RYs= go.opentelemetry.io/otel/exporters/prometheus v0.60.0 h1:cGtQxGvZbnrWdC2GyjZi0PDKVSLWP/Jocix3QWfXtbo= go.opentelemetry.io/otel/exporters/prometheus v0.60.0/go.mod h1:hkd1EekxNo69PTV4OWFGZcKQiIqg0RfuWExcPKFvepk= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.opentelemetry.io/proto/otlp v0.15.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= @@ -2176,8 +2176,8 @@ golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -2263,8 +2263,8 @@ golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.0.0-20170207211851-4464e7848382/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -2317,8 +2317,8 @@ golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -2459,8 +2459,8 @@ golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -2482,8 +2482,8 @@ golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -2507,8 +2507,8 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -2829,12 +2829,12 @@ google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuO google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= google.golang.org/genproto/googleapis/api v0.0.0-20230525234020-1aefcd67740a/go.mod h1:ts19tUU+Z0ZShN1y3aPyq2+O3d5FUNNgT6FtOzmrNn8= google.golang.org/genproto/googleapis/api v0.0.0-20230525234035-dd9d682886f9/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig= -google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 h1:FiusG7LWj+4byqhbvmB+Q93B/mOxJLN2DTozDuZm4EU= -google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234015-3fc162c6f38a/go.mod h1:xURIpW9ES5+/GZhnV6beoEtxQrnkRGIfP5VQG2tCBLc= google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234030-28d5490b6b19/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.57.1 h1:upNTNqv0ES+2ZOOqACwVtS3Il8M12/+Hz41RCPzAjQg= google.golang.org/grpc v1.57.1/go.mod h1:Sd+9RMTACXwmub0zcNY2c4arhtrbBYD1AUHI/dt16Mo= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= diff --git a/sei-cosmos/baseapp/config_fuzz_test.go b/sei-cosmos/baseapp/config_fuzz_test.go index 1dd9cbbaf2..c6a5dda3dc 100644 --- a/sei-cosmos/baseapp/config_fuzz_test.go +++ b/sei-cosmos/baseapp/config_fuzz_test.go @@ -475,7 +475,7 @@ func TestBaseAppTracingEnabledInstallsAProviderNothingShutsDown(t *testing.T) { } }) - leaked := batchSpanProcessorGoroutines() - exportersBefore + leaked := waitForBatchSpanProcessorGoroutines(exportersBefore+1) - exportersBefore if leaked != 1 { t.Errorf("tracing = true started %d batch-processor goroutines, want 1. Zero has four causes "+ "and this line cannot tell them apart: construction now shuts the provider down, the "+ @@ -520,7 +520,7 @@ func TestBaseAppTracingEnabledInstallsAProviderNothingShutsDown(t *testing.T) { // this fails too, and on a change in sei only the assertions above do. controlBefore := batchSpanProcessorGoroutines() control := sdktrace.NewTracerProvider(sdktrace.WithBatcher(tracetest.NewInMemoryExporter())) - if got := batchSpanProcessorGoroutines() - controlBefore; got != 1 { + if got := waitForBatchSpanProcessorGoroutines(controlBefore+1) - controlBefore; got != 1 { t.Errorf("a provider built with exactly one batcher moved the count by %d, want 1. The frame "+ "batchSpanProcessorGoroutines matches no longer names a batch-processor goroutine, so "+ "every count in this row reads zero and none of its assertions can fail", got) @@ -540,15 +540,14 @@ func TestBaseAppTracingEnabledInstallsAProviderNothingShutsDown(t *testing.T) { // Every trace.WithBatcher starts exactly one and only Shutdown stops it, so the count is how // the row above sees a provider nobody owns rather than describing one. // -// It counts the creator line the runtime records for each such goroutine. That line is present -// from creation rather than from first scheduling, since runtime.Stack dumps runnable goroutines -// too and the creator PC is set before the new goroutine becomes runnable; it appears exactly -// once per goroutine; and it names an exported constructor rather than an internal. If the SDK -// renames it this reads zero for every provider, sei's and the row's control alike — which is -// why the row above pairs it with one, since a zero delta on its own says nothing about whether -// sei stopped leaking. +// It counts the processQueue frame that remains on the stack for the goroutine's lifetime. The +// SDK starts that goroutine through sync.WaitGroup.Go, so its creator frame no longer identifies +// NewBatchSpanProcessor. The startup assertions poll until processQueue is scheduled. If the SDK +// renames it this reads zero for every provider, sei's and the row's control alike — which is why +// the row above pairs it with one, since a zero delta on its own says nothing about whether sei +// stopped leaking. func batchSpanProcessorGoroutines() int { - const frame = "created by go.opentelemetry.io/otel/sdk/trace.NewBatchSpanProcessor" + const frame = "go.opentelemetry.io/otel/sdk/trace.(*batchSpanProcessor).processQueue" buf := make([]byte, 1<<16) for { n := runtime.Stack(buf, true) @@ -560,9 +559,9 @@ func batchSpanProcessorGoroutines() int { } // waitForBatchSpanProcessorGoroutines polls until the count reaches want, or gives up and -// reports what it saw. Shutdown returns once the processor has signalled completion, which is -// a moment before the goroutine itself finishes unwinding, so an immediate read can still see -// it and the wait is what keeps that from being a flake. +// reports what it saw. A new processor may not have reached processQueue when construction +// returns. Shutdown can return a moment before that frame finishes unwinding. Waiting keeps +// both transitions from making the characterization flaky. func waitForBatchSpanProcessorGoroutines(want int) int { deadline := time.Now().Add(5 * time.Second) for {