Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ Edit `my-config.json`:
| `--track-blocks` | false | Track block statistics |
| `--track-user-latency` | false | Track user latency metrics |
| `--prewarm` | false | Prewarm accounts before test |
| `--chain-file` | | Contract registry file naming the target chain and its deployed contracts. Repeatable; each layers over the registry compiled into the binary, and a later file wins. A path that does not exist fails startup. |
| `--chain-record-path` | | Where to write a chain file describing what this run deployed, for an operator to review and commit. In a pod use `/dev/stdout`. Requires `genesisHash` in the profile. |

## Examples

Expand Down
68 changes: 68 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,26 @@ import (
"io"
"math/big"
"time"

"github.com/ethereum/go-ethereum/common"
)

// LoadConfig stores the configuration for load-related settings.
type LoadConfig struct {
ChainID int64 `json:"chainId,omitempty"`
// GenesisHash is the other half of the chain's identity, and the registry
// matches on it alongside ChainID. An EVM chain id alone does not identify a
// chain instance: a devnet keeps its id across a re-genesis, so an entry
// recorded before the re-genesis names an address that no longer holds its
// contract. Bare hex, matching SeiNetwork.Status.GenesisHash.
GenesisHash string `json:"genesisHash,omitempty"`
// ChainFiles are contract registry files the deployment supplies, layered
// over the registry compiled into the binary in the order given. The
// --chain-file flag appends to this.
ChainFiles []string `json:"chainFiles,omitempty"`
// ChainRecordPath is where a run writes what it deployed, as a chain file an
// operator reviews and commits. Empty writes nothing.
ChainRecordPath string `json:"chainRecordPath,omitempty"`
// SeiChainID is the textual chain ID used for tagging metric collection.
SeiChainID string `json:"seiChainID,omitempty"`
Endpoints []string `json:"endpoints"`
Expand Down Expand Up @@ -171,6 +186,23 @@ type Scenario struct {
// by operation name. Absent (the default) selects the scenario's first
// declared operation; see operation.go.
Operations OperationMix `json:"operations,omitempty"`
// ContractKey is the name this scenario's contract is recorded under in the
// chain file. It defaults to Name.
//
// Set it where two runs drive one chain and must not share a contract.
// Without distinct keys they bind one contract and contend on its storage,
// and that contention is in neither profile.
ContractKey string `json:"contractKey,omitempty"`
// ContractAddress names a contract deployed outside this repo. Set, the run
// binds it and consults no registry and deploys nothing. It is the escape
// hatch for a contract the registry does not and should not describe.
ContractAddress string `json:"contractAddress,omitempty"`
// ForceDeploy deploys a fresh contract even where the registry holds an
// entry for this chain. It exists for measuring deployment itself, and for
// a run that must not touch state another run has already written.
//
// It is an opt-out, not a mode: everything after resolution is unchanged.
ForceDeploy bool `json:"forceDeploy,omitempty"`
}

const (
Expand Down Expand Up @@ -225,9 +257,45 @@ func (s *Scenario) Validate() error {
if s.SizeDistribution == nil && len(s.SizeBuckets) != 0 {
return fmt.Errorf("scenario %q: sizeBuckets has %d entries but no sizeDistribution samples them", s.Name, len(s.SizeBuckets))
}
// Contract selection is the same class of hazard: two ways of naming a
// contract, set together, silently pick one. An operator who set forceDeploy
// expecting a fresh contract would get the configured address instead, and
// measure the wrong thing without being told.
if s.ContractAddress != "" && s.ForceDeploy {
return fmt.Errorf("scenario %q: contractAddress and forceDeploy are both set, "+
"but a run can only do one of bind that address and deploy a fresh contract", s.Name)
}
if s.ContractAddress != "" && !common.IsHexAddress(s.ContractAddress) {
return fmt.Errorf("scenario %q: contractAddress %q is not an address",
s.Name, s.ContractAddress)
}
return s.Operations.validate(s.Name, operationsFor(s.Name))
}

// ValidateRecording rejects a run that would deploy contracts and then fail to
// record them.
//
// WriteChain requires a genesis hash, because half a chain's identity does not
// identify it. Without this check that requirement is discovered at the end of
// startup, after every contract is deployed and paid for — so the run leaves
// contracts on the chain, records none of them, and exits non-zero. Refusing up
// front costs nothing and leaves the chain untouched.
//
// main calls it after merging the flags, for an early exit before the metrics
// server starts. prepareAll calls it too, so no caller of this package can skip
// it.
func (c *LoadConfig) ValidateRecording() error {
if c.ChainRecordPath == "" || c.GenesisHash != "" {
return nil
}
return fmt.Errorf(
"chainRecordPath is %q but genesisHash is empty: a chain file needs the "+
"genesis hash to identify its chain, so the run would deploy every "+
"contract and then fail to write the record. Set genesisHash, or "+
"clear chainRecordPath",
c.ChainRecordPath)
}

// ValidateScenarios runs each scenario's Validate and names the scenario that
// failed. loadConfig calls it after unmarshalling.
func (c *LoadConfig) ValidateScenarios() error {
Expand Down
32 changes: 32 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,3 +244,35 @@ func TestValidateRejectsUnknownOperation(t *testing.T) {
require.NoError(t, err, "a map-typed field accepts the key at parse")
require.ErrorContains(t, cfg.ValidateScenarios(), `unknown operation "reads"`)
}

// TestValidateRecording covers the pairing that would otherwise deploy every
// contract and then fail on the write.
func TestValidateRecording(t *testing.T) {
cases := []struct {
name string
recordPath string
genesisHash string
wantErr bool
}{
{"neither set", "", "", false},
{"recording with an identity", "/dev/stdout", "3f1a", false},
{"an identity and no recording", "", "3f1a", false},
{"recording with no identity", "/dev/stdout", "", true},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := &LoadConfig{
ChainRecordPath: tc.recordPath,
GenesisHash: tc.genesisHash,
}
err := cfg.ValidateRecording()
if tc.wantErr {
require.Error(t, err, "the run would deploy and then fail to record")
require.ErrorContains(t, err, "genesisHash")
return
}
require.NoError(t, err)
})
}
}
2 changes: 1 addition & 1 deletion generator/deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ func TestDeployFailureIsAnError(t *testing.T) {
chain := newMockChain(t, mockChainConfig{revertDeployments: true})

_, err := generator.NewGenerator(t.Context(), newTestRng(1), contractConfig(chain), deployer)
require.ErrorContains(t, err, "failed to deploy scenarios")
require.ErrorContains(t, err, "failed to prepare scenarios")
require.ErrorContains(t, err, scenarios.StorageRW)
require.ErrorContains(t, err, "deployment transaction failed with status 0")
}
Expand Down
51 changes: 41 additions & 10 deletions generator/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,28 @@
//
// 1. createScenarios — one scenario instance per config entry, each bound to an
// account pool (its own, or the shared top-level pool).
// 2. deployAll — deploy the contract each instance needs, in sequence.
// 2. prepareAll — give each instance the contract it drives, from the registry
// where one is recorded and by deploying where none is. It also writes the
// chain file when the run is configured to record what it deployed, which is
// the one step that touches the operator's filesystem.
// 3. build — expand the instances by weight and shuffle them into the
// round-robin the run draws from.
//
// An error in any step fails the run. A generator that cannot deploy has nothing
// valid to generate, so a failed deployment surfaces as a startup error rather
// than as a run that sends transactions to an address holding no contract.
// An error in any step fails the run. A generator that cannot resolve or deploy
// has nothing valid to generate, so the failure surfaces as a startup error
// rather than as a run that sends transactions to an address holding no
// contract.
//
// # Resolution before deployment
//
// prepareAll decides every address and verifies every recorded one before it
// deploys anything. The ordering is the contract: a stale entry on the last
// scenario must not leave a contract from the first one on the chain, paid for
// and recorded nowhere. See prepare.go.
//
// # The deployer is received, not minted
//
// deployAll signs its deployments with the account NewGenerator is handed.
// prepareAll signs its deployments with the account NewGenerator is handed.
// Paying for a deployment is a funding concern, and the funder package owns the
// run's funded identity, so funder.Deployer names the account and this package
// spends it. Minting a key here cannot work: no account pool holds it, so
Expand All @@ -26,17 +37,37 @@
// # Deployment nonces
//
// A deployment leaves its nonce unset, so go-ethereum reads the deployer's
// pending nonce from the chain, and deployAll waits for the receipt before it
// sends the next one. This is what makes a deployer with on-chain history safe:
// pending nonce from the chain, and deployMissing waits for the receipt before
// it sends the next one. This is what makes a deployer with on-chain history safe:
// the funding root has spent nonces before the run, and spends more right after
// these deployments when it funds the pool. A nonce derived from the instance
// index is correct only for a key that starts at zero. Deploying concurrently
// reintroduces the collision the sequence prevents; the funder package doc makes
// the same argument for the same key.
//
// # Per-run contract isolation is not guaranteed
//
// Two runs against one chain each deploy their own contract only when they hold
// different deployer keys. A creation address derives from the sender and its
// nonce, and every other input to a deployment here is a constant: the gas caps,
// the gas limit, and the constructor arguments.
//
// funder.Deployer hands every pod in a release the same funding root account. Two
// pods starting together therefore read the same pending nonce and produce
// byte-identical deployment transactions, so they bind one contract and contend
// on its storage. That contention is in neither profile, so both runs measure a
// workload nobody configured.
//
// Sequential runs on one key are safe, because the second reads a nonce the first
// advanced. Concurrent runs are not, and a profile cannot avoid it: funder.Deployer
// returns the funding root, so an operator has no way to give two pods different
// deployer keys. Closing this needs a code change, not configuration.
//
// # Mock deploy
//
// Under config.MockDeploy no deployment reaches a chain. Each instance attaches
// its binding at a random address, which is enough to shape calldata, and the
// deployer goes unused. This is the path --dry-run and the unit tests take.
// Under config.MockDeploy no deployment reaches a chain. Each contract gets one
// random address, shared by the instances that drive it exactly as a live run
// shares a resolved one, and the bind backend is nil, which is enough to shape
// calldata. The deployer goes unused. This is the path --dry-run and the unit
// tests take.
package generator
54 changes: 10 additions & 44 deletions generator/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ type scenarioInstance struct {
Weight int
Scenario scenarios.TxGenerator
Accounts *types.AccountPool
// Config is the profile entry that produced this instance. The preparation
// step reads it to decide which address the scenario binds.
Config config.Scenario
}

// generatorBuilder manages scenario creation and deployment from config
Expand Down Expand Up @@ -83,6 +86,7 @@ func (g *generatorBuilder) createScenarios() error {
Weight: scenarioCfg.Weight,
Scenario: scenario,
Accounts: accountPool,
Config: scenarioCfg,
}

g.instances = append(g.instances, instance)
Expand All @@ -91,44 +95,6 @@ func (g *generatorBuilder) createScenarios() error {
return nil
}

// mockDeployAll deploys all scenario instances that require deployment (for unit tests).
func (g *generatorBuilder) mockDeployAll() error {
for _, instance := range g.instances {
addr := types.NewAccount(false).Address
if err := instance.Scenario.Attach(g.config, addr); err != nil {
return err
}
}
return nil
}

// deployAll deploys all scenario instances that require deployment, from the
// deployer the run was handed. Sequential by design (see package doc): each
// deployment reads its nonce from the chain and is mined before the next is
// sent, so one deployer key stays in one ordered nonce stream.
func (g *generatorBuilder) deployAll(ctx context.Context, deployer types.Account) error {
if g.config.MockDeploy {
return g.mockDeployAll()
}
if deployer.PrivKey == nil {
return errors.New("deployer has no private key (a live deployment must be signed)")
}

log.Printf("Deploying %d scenarios from %s", len(g.instances), deployer.Address.Hex())
for _, instance := range g.instances {
log.Printf("Deploying scenario %s", instance.Name)
address, err := instance.Scenario.Deploy(ctx, g.config, deployer)
if err != nil {
return fmt.Errorf("deploy %s: %w", instance.Name, err)
}
if address != (common.Address{}) {
log.Printf("🚀 Deployed %s at address: %s\n", instance.Name, address.Hex())
}
}

return nil
}

type Generator struct{ scenarios []*scenarioInstance }

func (g *Generator) Accounts() []types.Account {
Expand All @@ -151,9 +117,9 @@ type TxSender interface {
func (g *Generator) Prewarm(ctx context.Context, rng *mrand.Rand, cfg *config.LoadConfig, txSender TxSender) error {
// Create EVMTransfer scenario for prewarming
evmScenario := scenarios.NewEVMTransferScenario(config.Scenario{})
// EVMTransfer needs no contract, so attaching is all that marks it ready.
if err := evmScenario.Attach(cfg, common.Address{}); err != nil {
return fmt.Errorf("evmScenario.Attach(): %w", err)
// EVMTransfer drives no contract, so marking it ready is all it needs.
if err := evmScenario.Ready(cfg); err != nil {
return fmt.Errorf("evmScenario.Ready(): %w", err)
}
for _, account := range g.Accounts() {
// Create self-transfer transaction
Expand Down Expand Up @@ -269,9 +235,9 @@ func NewGenerator(ctx context.Context, rng *mrand.Rand, cfg *config.LoadConfig,
return nil, fmt.Errorf("failed to create scenarios: %w", err)
}

// Step 2: Deploy all scenarios
if err := b.deployAll(ctx, deployer); err != nil {
return nil, fmt.Errorf("failed to deploy scenarios: %w", err)
// Step 2: give every scenario the contract it drives
if err := b.prepareAll(ctx, deployer); err != nil {
return nil, fmt.Errorf("failed to prepare scenarios: %w", err)
}

// Step 3: Create weighted scenarioGenerator
Expand Down
30 changes: 28 additions & 2 deletions generator/mockchain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"math/big"
"net/http/httptest"
"slices"
"testing"

"github.com/ethereum/go-ethereum/common"
Expand All @@ -24,8 +25,15 @@ type mockChainConfig struct {
baseNonce map[common.Address]uint64
// revertDeployments mines every contract creation with a failed status.
revertDeployments bool
// code serves per-address runtime code, so a test can make a recorded
// address hold nothing or hold the wrong contract. Nil serves defaultCode
// everywhere, which is what a deployment test wants.
code map[common.Address][]byte
}

// defaultCode is what GetCode serves when a test sets no per-address code.
var defaultCode = []byte{0x60, 0x00}

// mockChain serves the smallest eth JSON-RPC surface a deployment needs. It
// mines every transaction on arrival, reports a sender's pending nonce as its
// base plus the transactions that sender has sent, and keeps what it received.
Expand All @@ -39,6 +47,9 @@ type mockChain struct {
type mockChainState struct {
mined []minedTx
byHash map[common.Hash]minedTx
// codeReads records every address GetCode was asked for, in order, so a test
// can assert the startup read count and which contracts it covered.
codeReads []common.Address
}

// minedTx is one transaction the chain accepted. contract is the created
Expand Down Expand Up @@ -121,8 +132,23 @@ func (m *mockChain) GetBalance(_ context.Context, _ common.Address, _ rpc.BlockN
return (*hexutil.Big)(new(big.Int)), nil
}

func (m *mockChain) GetCode(_ context.Context, _ common.Address, _ rpc.BlockNumberOrHash) (hexutil.Bytes, error) {
return hexutil.Bytes{0x60, 0x00}, nil
func (m *mockChain) GetCode(_ context.Context, addr common.Address, _ rpc.BlockNumberOrHash) (hexutil.Bytes, error) {
for state := range m.state.Lock() {
state.codeReads = append(state.codeReads, addr)
}
if m.cfg.code == nil {
return defaultCode, nil
}
return m.cfg.code[addr], nil
}

// codeReads returns every address GetCode was asked for, in order.
func (m *mockChain) codeReads() []common.Address {
var reads []common.Address
for state := range m.state.Lock() {
reads = slices.Clone(state.codeReads)
}
return reads
}

func (m *mockChain) EstimateGas(_ context.Context, _ json.RawMessage, _ *rpc.BlockNumberOrHash) (hexutil.Uint64, error) {
Expand Down
Loading
Loading