Skip to content
Merged
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
90 changes: 90 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,28 @@ import (
"io"
"math/big"
"time"

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

"github.com/sei-protocol/sei-load/registry"
)

// 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 +188,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 +259,65 @@ 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.
//
// Every check here is one WriteChain makes at the end of startup, where failing
// means the run left contracts on the chain, recorded none of them, and exited
// non-zero. Both halves of the chain's identity are required, because
// Chain.validate requires them and half an identity identifies nothing. The path
// has to be writable, because a path mistake is otherwise discovered after every
// contract is paid for.
//
// main calls it after merging the flags, for an early exit before the metrics
// server starts. prepareAll calls it too, so no caller of the generator package
// can skip it.
//
// It does not cover a full filesystem, and it does not cover the contents. Those
// stay WriteChain's to report.
func (c *LoadConfig) ValidateRecording() error {
if c.ChainRecordPath == "" {
return nil
}
if c.GenesisHash == "" {
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)
}
if c.ChainID == 0 {
return fmt.Errorf(
"chainRecordPath is %q but chainId is 0: a chain file needs the chain "+
"id as well as the genesis hash, so the run would deploy every "+
"contract and then fail to write the record. Set chainId, or clear "+
"chainRecordPath",
c.ChainRecordPath)
}
// A mock run binds against a nil backend and never records, so probing the
// destination would give a dry run a side effect it should not have.
if c.MockDeploy {
return nil
}
return registry.CheckWritable(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
63 changes: 63 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config

import (
"path/filepath"
"testing"

"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -244,3 +245,65 @@ 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 every check that would otherwise deploy every
// contract and then fail on the write.
//
// The happy cases use /dev/stdout deliberately. It is the destination the flag
// help and the README both name for a pod, and it is not a regular file — so this
// is also the guard that stops the path check from being tightened into something
// that refuses it.
func TestValidateRecording(t *testing.T) {
cases := []struct {
name string
recordPath string
chainID int64
genesisHash string
wantErr string
}{
{name: "neither set"},
{name: "recording with a whole identity", recordPath: "/dev/stdout",
chainID: 713715, genesisHash: "3f1a"},
{name: "an identity and no recording", chainID: 713715, genesisHash: "3f1a"},
{name: "recording with no genesis hash", recordPath: "/dev/stdout",
chainID: 713715, wantErr: "genesisHash"},
// Chain.validate requires both halves, so a missing chain id fails the
// write just as surely as a missing genesis hash.
{name: "recording with no chain id", recordPath: "/dev/stdout",
genesisHash: "3f1a", wantErr: "chainId"},
{name: "recording into a directory that does not exist",
recordPath: filepath.Join("no-such-dir", "out.json"),
chainID: 713715, genesisHash: "3f1a", wantErr: "no-such-dir"},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := &LoadConfig{
ChainID: tc.chainID,
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, tc.wantErr)
return
}
require.NoError(t, err)
})
}
}

// TestValidateRecordingLeavesADryRunAlone asserts a mock run does not touch the
// destination. It binds against a nil backend and never records, so probing the
// path would give --dry-run a side effect it should not have.
func TestValidateRecordingLeavesADryRunAlone(t *testing.T) {
cfg := &LoadConfig{
ChainID: 713715,
GenesisHash: "3f1a",
ChainRecordPath: filepath.Join("no-such-dir", "out.json"),
MockDeploy: true,
}
require.NoError(t, cfg.ValidateRecording(),
"a dry run was refused for a path it never writes")
}
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
Loading
Loading