From 68d0449f8b480255a990d63f8fb9e6769a357c60 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Wed, 19 Aug 2026 20:49:43 -0700 Subject: [PATCH 1/2] feat(config)!: generalize OperationMix to a named weighted-operation basket OperationMix weights operations by name. A scenario declares its own basket in a profile, instead of a hardcoded mix in Go. A scenario declares its operations as an ordered OperationSet. A profile weights them with the same "operations" object. OperationSet.Picker resolves one mix into one cumulative table at scenario construction. OperationPicker.Select then draws from that table per transaction, at 8.5 ns and no allocation. The declared order fixes the draw order. Go map iteration order is unspecified. A draw that walked the weight map would give one sub-range of a draw to a different operation on every process. storagerw declares rmw, read, write, in the order the three-field comparison chain used, so the seeded draw sequence does not move. Scenario.Validate rejects an operation the scenario does not declare, and names both. It keeps the other rules. An absent mix is the default and draws no randomness. An all-zero mix is an error. Weights that sum past uint64 are an error. The JSON wire form does not change. Every storagerw profile parses to the same weights, and the frozen names in config/doc.go stay frozen. BREAKING CHANGE: the Go API changes. This commit removes config.Operation, makes OpRmw, OpRead and OpWrite strings, makes OperationMix a map, and makes Scenario.Operations a value. The JSON wire form is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- config/config.go | 9 +- config/config_test.go | 16 +- config/doc.go | 29 ++- config/operation.go | 174 ++++++++++---- config/operation_test.go | 315 ++++++++++++++++++++++---- generator/scenarios/StorageRW.go | 21 +- generator/scenarios/StorageRW_test.go | 27 ++- generator/scenarios/doc.go | 4 + 8 files changed, 471 insertions(+), 124 deletions(-) diff --git a/config/config.go b/config/config.go index 61f1199..0bc534a 100644 --- a/config/config.go +++ b/config/config.go @@ -100,9 +100,10 @@ type Scenario struct { // empty-pad behavior. Each entry must be between 0 and 1 MiB; Validate // rejects the config otherwise. SizeBuckets []int `json:"sizeBuckets,omitempty"` - // Operations is the read/write/rmw selection mix. Nil (the default) is the - // all-rmw behavior. - Operations *OperationMix `json:"operations,omitempty"` + // Operations weights the scenario's own operations against each other, keyed + // by operation name. Absent (the default) selects the scenario's first + // declared operation; see operation.go. + Operations OperationMix `json:"operations,omitempty"` } const ( @@ -157,7 +158,7 @@ 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)) } - return s.Operations.validate(s.Name) + return s.Operations.validate(s.Name, operationsFor(s.Name)) } // ValidateScenarios runs each scenario's Validate and names the scenario that diff --git a/config/config_test.go b/config/config_test.go index f374e9e..e172db9 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -83,16 +83,16 @@ func TestScenarioValidateAxisPairing(t *testing.T) { } // TestScenarioValidateOperationsPresentButEmpty: an explicit all-zero mix is a -// misconfiguration, not the default. Omitting the field is the default, and -// Select's zero-total guard stays a safety net for that case rather than a +// misconfiguration, not the default. Omitting the field is the default, and the +// picker's zero-total guard stays a safety net for that case rather than a // swallower of this one. func TestScenarioValidateOperationsPresentButEmpty(t *testing.T) { t.Parallel() - empty := Scenario{Name: "s", Operations: &OperationMix{}} + empty := Scenario{Name: "storagerw", Operations: OperationMix{}} require.ErrorContains(t, empty.Validate(), "every weight is 0") - require.NoError(t, (&Scenario{Name: "s"}).Validate()) - require.NoError(t, (&Scenario{Name: "s", Operations: &OperationMix{Rmw: 1}}).Validate()) + require.NoError(t, (&Scenario{Name: "storagerw"}).Validate()) + require.NoError(t, (&Scenario{Name: "storagerw", Operations: OperationMix{OpRmw: 1}}).Validate()) } // TestValidateScenariosReportsOffendingScenario: validation runs across every @@ -102,10 +102,10 @@ func TestValidateScenariosReportsOffendingScenario(t *testing.T) { t.Parallel() cfg := LoadConfig{Scenarios: []Scenario{ {Name: "good"}, - {Name: "bad", Operations: &OperationMix{}}, + {Name: "storagerw", Operations: OperationMix{}}, }} - require.ErrorContains(t, cfg.ValidateScenarios(), `scenario "bad"`) + require.ErrorContains(t, cfg.ValidateScenarios(), `scenario "storagerw"`) - cfg.Scenarios[1].Operations = &OperationMix{Read: 1} + cfg.Scenarios[1].Operations = OperationMix{OpRead: 1} require.NoError(t, cfg.ValidateScenarios()) } diff --git a/config/doc.go b/config/doc.go index e52ab82..1230a0a 100644 --- a/config/doc.go +++ b/config/doc.go @@ -28,10 +28,31 @@ // // "recordCount" the keyspace a keyDistribution indexes // "sizeBuckets" the pad-length histogram a sizeDistribution indexes -// "operations" the operation mix, with keys "read", "write", "rmw" -// -// So is the order OperationMix.Select compares those weights in, which decides -// which operation a given draw selects. +// "operations" the operation mix, keyed by operation name +// +// So are the operation names a scenario declares, and the order it declares them +// in. storagerw declares "rmw", "read", "write" — the three names an operations +// object may weight, and the order a draw walks them in, which decides which +// operation a given draw selects. +// +// # Operation baskets +// +// A scenario declares the operations it supports as an OperationSet, and a +// profile weights them with an "operations" object of name to weight. Selection +// is a per-transaction weighted draw: OperationSet.Picker resolves a mix against +// the set once, and OperationPicker.Select draws from the result. +// +// The set is an ordered declaration rather than a bare set of allowed names, +// because Go map iteration order is unspecified. A draw that walked the mix +// itself would hand the same sub-range of the draw to a different operation on +// every process, so one seed would stop reproducing one sequence. The set's +// slice fixes the order, and Picker precomputes the cumulative weights along it. +// +// scenarioOperations wires a scenario's wire name to its set. A profile naming an +// operation the scenario does not declare fails Scenario.Validate at load, with +// the scenario and the operation named. An absent mix selects the set's first +// operation and draws no randomness. A present but all-zero mix is a config +// error, because it can select nothing. // // # Semantics: uniform vs zipfian(theta) // diff --git a/config/operation.go b/config/operation.go index 4a7ec9e..2b0c134 100644 --- a/config/operation.go +++ b/config/operation.go @@ -1,67 +1,157 @@ package config import ( + "encoding/json" "fmt" + "maps" mrand "math/rand/v2" + "slices" + "strings" ) -// Operation identifies one StorageRW contract method. -type Operation uint8 - +// The operations the storagerw scenario supports. They are FROZEN wire values; +// see package doc. const ( - // OpRmw is the read-modify-write operation. It is the zero value so a - // zero-weight or absent OperationMix selects rmw, matching the default. - OpRmw Operation = iota - OpRead - OpWrite + OpRmw = "rmw" + OpRead = "read" + OpWrite = "write" ) -// OperationMix is the relative weighting of the StorageRW read/write/rmw -// operations. The weights need not sum to anything in particular: a per-tx draw -// selects an operation in proportion to its weight over the total. An all-zero -// mix falls back to rmw, the default. -type OperationMix struct { - Read uint64 `json:"read,omitempty"` - Write uint64 `json:"write,omitempty"` - Rmw uint64 `json:"rmw,omitempty"` +// StorageRWOperations is the operation set the storagerw scenario draws from. +var StorageRWOperations = NewOperationSet(OpRmw, OpRead, OpWrite) + +// scenarioOperations maps a scenario's wire name, lowercased, to the operations +// it supports. A scenario absent from the table supports none. +var scenarioOperations = map[string]*OperationSet{ + "storagerw": StorageRWOperations, +} + +// operationsFor returns the operations a scenario supports, or nil if it +// supports none. +func operationsFor(scenario string) *OperationSet { + return scenarioOperations[strings.ToLower(scenario)] +} + +// OperationSet is the operations one scenario supports, in the order a weighted +// draw walks them. The first is the scenario's default. Both the names and the +// order are part of the saved-workload contract; see package doc. +type OperationSet struct { + names []string + known map[string]struct{} +} + +// NewOperationSet declares a scenario's operations in draw order. It panics on +// an empty declaration, which leaves no default, or on a repeated name, which +// would claim two sub-ranges of one draw. +func NewOperationSet(names ...string) *OperationSet { + if len(names) == 0 { + panic("operation set: at least one operation is required") + } + known := make(map[string]struct{}, len(names)) + for _, name := range names { + if _, repeated := known[name]; repeated { + panic(fmt.Sprintf("operation set: %q is declared twice", name)) + } + known[name] = struct{}{} + } + return &OperationSet{names: slices.Clone(names), known: known} +} + +// Names returns the operations in draw order. +func (s *OperationSet) Names() []string { return slices.Clone(s.names) } + +// OperationMix is the relative weighting of a scenario's operations, keyed by +// operation name. The weights need not sum to anything in particular: a per-tx +// draw selects an operation in proportion to its weight over the total. +type OperationMix map[string]uint64 + +// MarshalJSON omits zero weights, which claim no share of a draw. +func (m OperationMix) MarshalJSON() ([]byte, error) { + weighted := make(map[string]uint64, len(m)) + for name, weight := range m { + if weight != 0 { + weighted[name] = weight + } + } + return json.Marshal(weighted) } // validate rejects a mix that is present but cannot select anything, so an -// operator who writes "operations": {} — or misspells every weight key — gets an -// error instead of a silent all-rmw run. Select's own zero-total guard then -// covers only the absent-mix case it was written for. A nil mix is the -// documented default and passes. -func (m *OperationMix) validate(scenario string) error { +// operator who writes "operations": {} — or misspells every name — gets an error +// instead of a silent default run. It also rejects a name the scenario does not +// support, and weights that sum past uint64. An absent mix is the documented +// default and passes. +func (m OperationMix) validate(scenario string, set *OperationSet) error { if m == nil { return nil } - if m.Read == 0 && m.Write == 0 && m.Rmw == 0 { - return fmt.Errorf("scenario %q: operations is set but every weight is 0; omit it for the all-rmw default", scenario) + if set == nil { + return fmt.Errorf("scenario %q: operations is set but this scenario has no operations", scenario) + } + + var total uint64 + for _, name := range slices.Sorted(maps.Keys(m)) { + if _, ok := set.known[name]; !ok { + return fmt.Errorf("scenario %q: unknown operation %q (supported: %s)", + scenario, name, strings.Join(set.names, ", ")) + } + if total+m[name] < total { + return fmt.Errorf("scenario %q: operations weights sum past uint64", scenario) + } + total += m[name] } - if m.Read+m.Write+m.Rmw < m.Read { - return fmt.Errorf("scenario %q: operations weights sum past uint64", scenario) + if total == 0 { + return fmt.Errorf("scenario %q: operations is set but every weight is 0; omit it for the %q default", + scenario, set.names[0]) } return nil } -// Select draws one operation in proportion to the configured weights. A zero -// total falls back to OpRmw, so an absent mix is the default rather than a -// division by zero, and it draws no randomness. +// OperationPicker is one mix resolved against one set: the weighted operations +// in draw order, with the running totals a draw compares against. +type OperationPicker struct { + names []string + cum []uint64 + total uint64 + fallback string +} + +// Picker resolves mix against the set's draw order once, so a per-transaction +// Select walks a fixed slice and allocates nothing. An absent or all-zero mix +// yields a picker that returns the set's first operation and draws no +// randomness. // -// The comparison order (rmw, then read, then write) fixes which weight owns -// which sub-range of the draw. It is arbitrary but must stay stable, because -// changing it changes which operation a given draw selects. -func (m *OperationMix) Select(rng *mrand.Rand) Operation { - total := m.Read + m.Write + m.Rmw - if total == 0 { - return OpRmw +// Picker panics on a name the set does not declare. A profile reaches it only +// through Scenario.Validate, which rejects that name at load. +func (s *OperationSet) Picker(mix OperationMix) *OperationPicker { + picker := &OperationPicker{fallback: s.names[0]} + for _, name := range slices.Sorted(maps.Keys(mix)) { + if _, ok := s.known[name]; !ok { + panic(fmt.Sprintf("operation %q is not one of %s", name, strings.Join(s.names, ", "))) + } + } + for _, name := range s.names { + weight := mix[name] + if weight == 0 { + continue + } + picker.total += weight + picker.names = append(picker.names, name) + picker.cum = append(picker.cum, picker.total) + } + return picker +} + +// Select draws one operation in proportion to its weight. +func (p *OperationPicker) Select(rng *mrand.Rand) string { + if p.total == 0 { + return p.fallback } - switch u := rng.Uint64N(total); { - case u < m.Rmw: - return OpRmw - case u < m.Rmw+m.Read: - return OpRead - default: - return OpWrite + u := rng.Uint64N(p.total) + for i, cum := range p.cum { + if u < cum { + return p.names[i] + } } + return p.names[len(p.names)-1] } diff --git a/config/operation_test.go b/config/operation_test.go index f898eb7..97d01f4 100644 --- a/config/operation_test.go +++ b/config/operation_test.go @@ -1,6 +1,7 @@ package config_test import ( + "encoding/json" "testing" "github.com/stretchr/testify/require" @@ -8,55 +9,120 @@ import ( "github.com/sei-protocol/sei-load/config" ) -// TestOperationMixEmptyFallsBackToRmw: a zero-weight mix selects rmw, the -// default, rather than dividing by a zero total. -func TestOperationMixEmptyFallsBackToRmw(t *testing.T) { +// tokenOperations is a basket shaped like an upcoming token workload: many +// operations, none of them read/write/rmw, declared in an order that is neither +// sorted nor reverse-sorted. +var tokenOperations = config.NewOperationSet( + "erc20_transfer", + "erc20_mint", + "erc20_approve", + "erc721_mint", + "erc721_transfer", + "erc20_burn", + "erc721_approve", + "erc20_transfer_from", + "erc721_burn", + "erc20_permit", +) + +// uniformOracle predicts the selection sequence a set produces under equal +// weights, from the set's declared order and the same one-draw-per-selection +// RNG budget. It is independent of the picker: an implementation that walked the +// weight map, or that sorted the names, disagrees with it. +func uniformOracle(set *config.OperationSet, seed uint64, draws int) []string { + names := set.Names() + rng := newTestRng(seed) + out := make([]string, draws) + for i := range out { + out[i] = names[rng.Uint64N(uint64(len(names)))] + } + return out +} + +// equalMix weights every operation in set at 1. +func equalMix(set *config.OperationSet) config.OperationMix { + names := set.Names() + mix := make(config.OperationMix, len(names)) + for _, name := range names { + mix[name] = 1 + } + return mix +} + +// selectN drains draws selections from a picker built for mix. +func selectN(set *config.OperationSet, mix config.OperationMix, seed uint64, draws int) []string { + picker := set.Picker(mix) + rng := newTestRng(seed) + out := make([]string, draws) + for i := range out { + out[i] = picker.Select(rng) + } + return out +} + +// marshalMix renders a mix to its wire form. +func marshalMix(t *testing.T, mix config.OperationMix) []byte { + t.Helper() + data, err := json.Marshal(mix) + require.NoError(t, err) + return data +} + +// TestOperationMixAbsentSelectsDefault: an absent mix selects the set's first +// declared operation, rather than dividing by a zero total. +func TestOperationMixAbsentSelectsDefault(t *testing.T) { t.Parallel() - var m config.OperationMix + picker := config.StorageRWOperations.Picker(nil) rng := newTestRng(1) for i := 0; i < 100; i++ { - require.Equal(t, config.OpRmw, m.Select(rng)) + require.Equal(t, config.OpRmw, picker.Select(rng)) } } -// TestOperationMixEmptyDrawsNoRandomness: the zero-weight fallback returns +// TestOperationMixAbsentDrawsNoRandomness: the zero-total fallback returns // before touching the RNG, so an unconfigured mix cannot perturb other draws // that share the same stream. -func TestOperationMixEmptyDrawsNoRandomness(t *testing.T) { +func TestOperationMixAbsentDrawsNoRandomness(t *testing.T) { t.Parallel() - var m config.OperationMix + picker := config.StorageRWOperations.Picker(nil) rng := newTestRng(5) for i := 0; i < 100; i++ { - m.Select(rng) + picker.Select(rng) } require.Equal(t, newTestRng(5).Uint64(), rng.Uint64()) } -// TestOperationMixHonorsWeights: a single-weighted op is selected exclusively, -// and a balanced mix reaches all three. +// TestOperationMixHonorsWeights: a single-weighted operation is selected +// exclusively, a balanced mix reaches every operation in its basket, and a zero +// weight is unreachable. func TestOperationMixHonorsWeights(t *testing.T) { t.Parallel() t.Run("single weight is exclusive", func(t *testing.T) { t.Parallel() - m := config.OperationMix{Read: 1} - rng := newTestRng(1) - for i := 0; i < 100; i++ { - require.Equal(t, config.OpRead, m.Select(rng)) + for _, got := range selectN(config.StorageRWOperations, config.OperationMix{config.OpRead: 1}, 1, 100) { + require.Equal(t, config.OpRead, got) + } + }) + + t.Run("balanced mix reaches every operation", func(t *testing.T) { + t.Parallel() + seen := map[string]int{} + for _, got := range selectN(tokenOperations, equalMix(tokenOperations), 1, 10_000) { + seen[got]++ + } + for _, name := range tokenOperations.Names() { + require.Positive(t, seen[name], "operation %q never selected", name) } + require.Len(t, seen, len(tokenOperations.Names()), "selected an operation outside the basket") }) - t.Run("balanced mix reaches every op", func(t *testing.T) { + t.Run("zero weight is never selected", func(t *testing.T) { t.Parallel() - m := config.OperationMix{Read: 1, Write: 1, Rmw: 1} - rng := newTestRng(1) - seen := map[config.Operation]int{} - for i := 0; i < 3000; i++ { - seen[m.Select(rng)]++ + mix := config.OperationMix{config.OpRmw: 0, config.OpRead: 1, config.OpWrite: 1} + for _, got := range selectN(config.StorageRWOperations, mix, 4, 1000) { + require.NotEqual(t, config.OpRmw, got) } - require.Positive(t, seen[config.OpRead]) - require.Positive(t, seen[config.OpWrite]) - require.Positive(t, seen[config.OpRmw]) }) } @@ -66,24 +132,24 @@ func TestOperationMixHonorsWeights(t *testing.T) { func TestOperationMixApproximatesWeights(t *testing.T) { t.Parallel() const draws = 100_000 - m := config.OperationMix{Rmw: 5, Read: 3, Write: 2} - rng := newTestRng(9) + want := map[string]float64{config.OpRmw: 5, config.OpRead: 3, config.OpWrite: 2} + + mix := config.OperationMix{} + for name, weight := range want { + mix[name] = uint64(weight) + } - seen := map[config.Operation]int{} - for i := 0; i < draws; i++ { - seen[m.Select(rng)]++ + seen := map[string]int{} + for _, got := range selectN(config.StorageRWOperations, mix, 9, draws) { + seen[got]++ } - // Total weight is 10, so each op should land within a fraction of a point of - // its own weight once scaled back. The tolerance is loose enough not to flake - // and tight enough to catch a mis-ordered comparison chain. - for op, wantWeight := range map[config.Operation]float64{ - config.OpRmw: 5, - config.OpRead: 3, - config.OpWrite: 2, - } { - got := float64(seen[op]) / draws * 10 - require.InDelta(t, wantWeight, got, 0.15, "operation %d proportion", op) + // Total weight is 10, so each operation should land within a fraction of a + // point of its own weight once scaled back. The tolerance is loose enough not + // to flake and tight enough to catch a mis-ordered cumulative table. + for name, wantWeight := range want { + got := float64(seen[name]) / draws * 10 + require.InDelta(t, wantWeight, got, 0.15, "operation %q proportion", name) } } @@ -91,14 +157,167 @@ func TestOperationMixApproximatesWeights(t *testing.T) { // so a run is repeatable when the RNG is seeded. func TestOperationMixDeterminism(t *testing.T) { t.Parallel() - draw := func() []config.Operation { - m := config.OperationMix{Read: 2, Write: 3, Rmw: 5} - rng := newTestRng(99) - out := make([]config.Operation, 256) - for i := range out { - out[i] = m.Select(rng) - } - return out + mix := config.OperationMix{config.OpRead: 2, config.OpWrite: 3, config.OpRmw: 5} + require.Equal(t, + selectN(config.StorageRWOperations, mix, 99, 256), + selectN(config.StorageRWOperations, mix, 99, 256)) +} + +// TestOperationMixDrawOrderFollowsTheSetNotTheMap guards the property that +// keying the weights by name puts at risk. Go map iteration order is unspecified +// and re-randomized per range, so a picker built by walking the mix would hand +// the same sub-range of a draw to a different operation on each rebuild. Every +// rebuild here must reproduce one sequence, and that sequence must be the one +// the set's declared order predicts, which also fails a picker that sorted the +// names instead. +func TestOperationMixDrawOrderFollowsTheSetNotTheMap(t *testing.T) { + t.Parallel() + + for _, set := range []*config.OperationSet{config.StorageRWOperations, tokenOperations} { + t.Run(set.Names()[0], func(t *testing.T) { + t.Parallel() + const rebuilds = 200 + const draws = 64 + want := uniformOracle(set, 2026, draws) + + for i := 0; i < rebuilds; i++ { + var mix config.OperationMix + require.NoError(t, json.Unmarshal(marshalMix(t, equalMix(set)), &mix)) + require.Equal(t, want, selectN(set, mix, 2026, draws), "rebuild %d", i) + } + }) } - require.Equal(t, draw(), draw()) +} + +// TestOperationMixSelectAllocatesNothing: selection runs once per transaction, +// so the picker resolves the mix into its draw order ahead of the run and the +// draw itself walks that order without allocating. +func TestOperationMixSelectAllocatesNothing(t *testing.T) { + picker := tokenOperations.Picker(equalMix(tokenOperations)) + rng := newTestRng(3) + require.Zero(t, testing.AllocsPerRun(1000, func() { picker.Select(rng) })) +} + +// TestOperationMixWireForm pins the JSON contract: an object of operation name +// to weight, decoded and re-encoded without gaining or losing a key. +func TestOperationMixWireForm(t *testing.T) { + t.Parallel() + + t.Run("decodes the storagerw weights", func(t *testing.T) { + t.Parallel() + var mix config.OperationMix + require.NoError(t, json.Unmarshal([]byte(`{"read":2,"write":3,"rmw":5}`), &mix)) + require.Equal(t, config.OperationMix{config.OpRead: 2, config.OpWrite: 3, config.OpRmw: 5}, mix) + }) + + t.Run("rejects a weight that is not a whole count", func(t *testing.T) { + t.Parallel() + for _, bad := range []string{`{"read":-1}`, `{"read":1.5}`, `{"read":"2"}`, `[1,2]`} { + var mix config.OperationMix + require.Error(t, json.Unmarshal([]byte(bad), &mix), "input %s", bad) + } + }) + + t.Run("omits zero weights", func(t *testing.T) { + t.Parallel() + require.JSONEq(t, `{"write":1}`, + string(marshalMix(t, config.OperationMix{config.OpRead: 0, config.OpWrite: 1}))) + }) + + t.Run("an absent mix adds no key to a scenario", func(t *testing.T) { + t.Parallel() + encoded, err := json.Marshal(config.Scenario{Name: "storagerw", Weight: 1}) + require.NoError(t, err) + require.NotContains(t, string(encoded), "operations") + }) + + t.Run("a scenario round-trips its weights", func(t *testing.T) { + t.Parallel() + const wire = `{"name":"storagerw","weight":1,"operations":{"read":2,"rmw":5,"write":3}}` + var scenario config.Scenario + require.NoError(t, json.Unmarshal([]byte(wire), &scenario)) + require.NoError(t, scenario.Validate()) + + encoded, err := json.Marshal(scenario) + require.NoError(t, err) + require.JSONEq(t, wire, string(encoded)) + }) +} + +// TestOperationMixRejectedAtLoad: a profile weighting an operation the scenario +// does not declare fails validation at load, naming both, rather than surfacing +// later as a reweighted workload nobody asked for. +func TestOperationMixRejectedAtLoad(t *testing.T) { + t.Parallel() + + t.Run("unknown operation", func(t *testing.T) { + t.Parallel() + cfg := decodeConfig(t, `{"scenarios":[{"name":"StorageRW","weight":1,"operations":{"rmw":1,"erc20_mint":2}}]}`) + err := cfg.ValidateScenarios() + require.ErrorContains(t, err, `scenario "StorageRW"`) + require.ErrorContains(t, err, `unknown operation "erc20_mint"`) + require.ErrorContains(t, err, "rmw, read, write") + }) + + t.Run("scenario with no operations", func(t *testing.T) { + t.Parallel() + cfg := decodeConfig(t, `{"scenarios":[{"name":"erc20","weight":1,"operations":{"rmw":1}}]}`) + require.ErrorContains(t, cfg.ValidateScenarios(), + `scenario "erc20": operations is set but this scenario has no operations`) + }) + + t.Run("every weight zero", func(t *testing.T) { + t.Parallel() + cfg := decodeConfig(t, `{"scenarios":[{"name":"storagerw","weight":1,"operations":{"read":0}}]}`) + require.ErrorContains(t, cfg.ValidateScenarios(), "every weight is 0") + }) + + t.Run("weights sum past uint64", func(t *testing.T) { + t.Parallel() + cfg := decodeConfig(t, `{"scenarios":[{"name":"storagerw","weight":1,"operations":{"read":18446744073709551615,"write":1}}]}`) + require.ErrorContains(t, cfg.ValidateScenarios(), "sum past uint64") + }) + + t.Run("a declared mix passes", func(t *testing.T) { + t.Parallel() + cfg := decodeConfig(t, `{"scenarios":[{"name":"storagerw","weight":1,"operations":{"read":2,"write":3,"rmw":5}}]}`) + require.NoError(t, cfg.ValidateScenarios()) + }) +} + +// decodeConfig parses a profile the way loadConfig does. +func decodeConfig(t *testing.T, wire string) *config.LoadConfig { + t.Helper() + var cfg config.LoadConfig + require.NoError(t, json.Unmarshal([]byte(wire), &cfg)) + return &cfg +} + +// TestOperationSetRejectsAnUnusableDeclaration: an empty set leaves no default +// and a repeated name would claim two sub-ranges of one draw, so both fail where +// they are declared rather than at the first draw. +func TestOperationSetRejectsAnUnusableDeclaration(t *testing.T) { + t.Parallel() + require.Panics(t, func() { config.NewOperationSet() }) + require.Panics(t, func() { config.NewOperationSet(config.OpRead, config.OpWrite, config.OpRead) }) +} + +// TestOperationSetPickerRejectsAnUndeclaredName: Scenario.Validate is the gate a +// profile passes through, so a name that reaches Picker came from Go and is a +// programming error rather than operator input. +func TestOperationSetPickerRejectsAnUndeclaredName(t *testing.T) { + t.Parallel() + require.PanicsWithValue(t, + `operation "erc20_mint" is not one of rmw, read, write`, + func() { config.StorageRWOperations.Picker(config.OperationMix{"erc20_mint": 1}) }) +} + +// TestOperationSetNamesAreCallerOwned: Names hands back a copy, so a caller +// cannot reorder the draw order under a picker. +func TestOperationSetNamesAreCallerOwned(t *testing.T) { + t.Parallel() + names := config.StorageRWOperations.Names() + require.Equal(t, []string{config.OpRmw, config.OpRead, config.OpWrite}, names) + names[0] = "clobbered" + require.Equal(t, config.OpRmw, config.StorageRWOperations.Names()[0]) } diff --git a/generator/scenarios/StorageRW.go b/generator/scenarios/StorageRW.go index 9c03fd6..cd66437 100644 --- a/generator/scenarios/StorageRW.go +++ b/generator/scenarios/StorageRW.go @@ -53,12 +53,15 @@ var storageRWDefaultSlot = big.NewInt(0) // StorageRWScenario implements the TxGenerator interface for StorageRWv1 contract operations type StorageRWScenario struct { *ContractScenarioBase[bindings.StorageRWv1] - contract *bindings.StorageRWv1 + contract *bindings.StorageRWv1 + operations *config.OperationPicker } // NewStorageRWScenario creates a new StorageRW scenario func NewStorageRWScenario(cfg config.Scenario) TxGenerator { - scenario := &StorageRWScenario{} + scenario := &StorageRWScenario{ + operations: config.StorageRWOperations.Picker(cfg.Operations), + } scenario.ContractScenarioBase = NewContractScenarioBase[bindings.StorageRWv1](scenario, cfg) return scenario } @@ -130,8 +133,7 @@ func (s *StorageRWScenario) CreateContractTransaction(rng *mrand.Rand, auth *bin paddedPad := (uint64(len(pad)) + abiWord - 1) / abiWord * abiWord auth.GasLimit = storageRWBaseGas + paddedPad*calldataFloorGasPerByte - op := s.pickOp(rng) - switch op { + switch op := s.operations.Select(rng); op { case config.OpRmw: return s.contract.Rmw(auth, slot, pad) case config.OpRead: @@ -139,7 +141,7 @@ func (s *StorageRWScenario) CreateContractTransaction(rng *mrand.Rand, auth *bin case config.OpWrite: return s.contract.Write(auth, slot, big.NewInt(storageRWWriteValue), pad) default: - return nil, fmt.Errorf("storagerw: no contract method for operation %d", op) + return nil, fmt.Errorf("storagerw: no contract method for operation %q", op) } } @@ -172,12 +174,3 @@ func (s *StorageRWScenario) pickPad(rng *mrand.Rand) ([]byte, error) { } return make([]byte, cfg.SizeBuckets[bucket]), nil } - -// pickOp selects read, write, or rmw from the configured mix. With no mix it -// returns rmw and consumes no randomness. -func (s *StorageRWScenario) pickOp(rng *mrand.Rand) config.Operation { - if s.scenarioConfig.Operations == nil { - return config.OpRmw - } - return s.scenarioConfig.Operations.Select(rng) -} diff --git a/generator/scenarios/StorageRW_test.go b/generator/scenarios/StorageRW_test.go index e5bc353..9aaccf3 100644 --- a/generator/scenarios/StorageRW_test.go +++ b/generator/scenarios/StorageRW_test.go @@ -198,7 +198,7 @@ func TestStorageRWSizeBuckets(t *testing.T) { func TestStorageRWOpMix(t *testing.T) { t.Run("single weight is exclusive", func(t *testing.T) { gen, txs := newAttachedStorageRW(t, config.Scenario{ - Operations: &config.OperationMix{Write: 1}, + Operations: config.OperationMix{config.OpWrite: 1}, }) rng := newTestRng(3) for i := 0; i < 64; i++ { @@ -211,7 +211,7 @@ func TestStorageRWOpMix(t *testing.T) { t.Run("balanced mix reaches every method", func(t *testing.T) { gen, txs := newAttachedStorageRW(t, config.Scenario{ - Operations: &config.OperationMix{Read: 1, Write: 1, Rmw: 1}, + Operations: config.OperationMix{config.OpRead: 1, config.OpWrite: 1, config.OpRmw: 1}, }) rng := newTestRng(3) seen := map[string]int{} @@ -291,7 +291,7 @@ func requireGasCoversFloor(t *testing.T, tx *ethtypes.Transaction) { func TestStorageRWGasClearsFloorAcrossPadSizes(t *testing.T) { for _, pad := range []int{0, 1, 31, 32, 1024, 4096, 4544, 4609, 8192, 65536, 128 << 10} { t.Run(fmt.Sprintf("pad=%d", pad), func(t *testing.T) { - for _, mix := range []*config.OperationMix{{Rmw: 1}, {Read: 1}, {Write: 1}} { + for _, mix := range []config.OperationMix{{config.OpRmw: 1}, {config.OpRead: 1}, {config.OpWrite: 1}} { gen, txs := newAttachedStorageRW(t, config.Scenario{ SizeDistribution: uniformDist(t), SizeBuckets: []int{pad}, @@ -315,7 +315,7 @@ func TestStorageRWDrawOrderIsStable(t *testing.T) { RecordCount: 64, SizeDistribution: uniformDist(t), SizeBuckets: []int{0, 32, 96}, - Operations: &config.OperationMix{Rmw: 1, Read: 1, Write: 1}, + Operations: config.OperationMix{config.OpRmw: 1, config.OpRead: 1, config.OpWrite: 1}, } want := []struct { method string @@ -354,3 +354,22 @@ func TestStorageRWDrawOrderIsStable(t *testing.T) { // Same seed, a fresh scenario: the sequence repeats. require.Equal(t, golden, draw()) } + +// TestStorageRWCoversItsDeclaredOperations closes the seam between the operation +// names config declares for this scenario and the contract methods the scenario +// calls: weighting one name alone must produce calldata for the method of that +// name. A name added to the basket with no method here fails this test rather +// than every transaction of a run. +func TestStorageRWCoversItsDeclaredOperations(t *testing.T) { + for _, name := range config.StorageRWOperations.Names() { + t.Run(name, func(t *testing.T) { + gen, txs := newAttachedStorageRW(t, config.Scenario{ + Operations: config.OperationMix{name: 1}, + }) + tx, err := gen.Generate(newTestRng(1), txs) + require.NoError(t, err) + method, _, _ := decodeStorageRW(t, tx.Data()) + require.Equal(t, name, method) + }) + } +} diff --git a/generator/scenarios/doc.go b/generator/scenarios/doc.go index 618dc95..894263f 100644 --- a/generator/scenarios/doc.go +++ b/generator/scenarios/doc.go @@ -50,6 +50,10 @@ // Operations mix. Slot and pad are the two customer-named axes, key contention // and tx size; the operation mix shapes what each drawn slot is used for. // +// config.StorageRWOperations declares those three operation names and the order +// a weighted draw walks them in. A drawn name is the contract method it calls, +// so the switch in CreateContractTransaction is the whole mapping. +// // Slot and keyspace are what make contention a continuum rather than a binary. // A wide keyspace drawn uniformly approaches zero conflict; a single slot is // total conflict; a zipfian draw sits anywhere between. Throughout this section From d15238caad80832a3eb216d6212d339c0e64c89e Mon Sep 17 00:00:00 2001 From: bdchatham Date: Wed, 19 Aug 2026 21:21:19 -0700 Subject: [PATCH 2/2] fix(config): assert the overflow invariant where the picker builds its table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validate rejects a mix whose weights sum past uint64, but Picker accumulated the same weights without checking. Measured with {rmw: 2^63, read: 2^63, write: 7}: Validate errors, while a picker built on that mix wraps its total to 7, leaves a cumulative table of [2^63, 0, 7], and draws rmw 10000 times out of 10000 — a workload nobody configured, arrived at silently because every individual weight still looks sane. The asymmetry was the tell: the same loop already asserts the undeclared-name invariant with a panic rather than trusting the caller to have validated. It now asserts both. Also corrects what the StorageRW doc says about read and contention. It claimed reads never sweep the key axis. Investigation against sei-chain main established that Sei validates a transaction by comparing its read set to committed writes by value, and readAccumulator sits in both the read and write set of every read. So while a slot still holds zero the accumulator write is value-unchanged and reads do not conflict; once anything has written the keyspace the accumulator changes on every read and they do. Any mix containing rmw or write crosses that threshold within a few blocks, so the limitation is real but conditional, and the doc now says which condition. Co-Authored-By: Claude Opus 5 (1M context) --- config/operation.go | 8 ++++++++ config/operation_test.go | 16 ++++++++++++++++ generator/scenarios/StorageRW_test.go | 18 +++++++++++++++++- generator/scenarios/doc.go | 22 +++++++++++++++++----- 4 files changed, 58 insertions(+), 6 deletions(-) diff --git a/config/operation.go b/config/operation.go index 2b0c134..77d4470 100644 --- a/config/operation.go +++ b/config/operation.go @@ -135,6 +135,14 @@ func (s *OperationSet) Picker(mix OperationMix) *OperationPicker { if weight == 0 { continue } + // Validate rejects a mix whose weights overflow, but a picker built from + // an unvalidated mix would wrap its cumulative total and draw a workload + // nobody configured — silently, since every weight still looks sane. + // Assert it here too rather than trust the caller, the same way an + // undeclared name is asserted below. + if picker.total+weight < picker.total { + panic(fmt.Sprintf("operation weights for %s sum past uint64", strings.Join(s.names, ", "))) + } picker.total += weight picker.names = append(picker.names, name) picker.cum = append(picker.cum, picker.total) diff --git a/config/operation_test.go b/config/operation_test.go index 97d01f4..9c24d96 100644 --- a/config/operation_test.go +++ b/config/operation_test.go @@ -321,3 +321,19 @@ func TestOperationSetNamesAreCallerOwned(t *testing.T) { names[0] = "clobbered" require.Equal(t, config.OpRmw, config.StorageRWOperations.Names()[0]) } + +// TestOperationPickerRejectsOverflow: Validate rejects weights that sum past +// uint64, and the picker asserts the same invariant rather than wrapping its +// cumulative total into a workload nobody configured. +func TestOperationPickerRejectsOverflow(t *testing.T) { + t.Parallel() + set := config.StorageRWOperations + mix := config.OperationMix{config.OpRmw: 1 << 63, config.OpRead: 1 << 63, config.OpWrite: 7} + + require.ErrorContains(t, + (&config.Scenario{Name: "storagerw", Operations: mix}).Validate(), + "sum past uint64") + require.PanicsWithValue(t, + "operation weights for rmw, read, write sum past uint64", + func() { set.Picker(mix) }) +} diff --git a/generator/scenarios/StorageRW_test.go b/generator/scenarios/StorageRW_test.go index 9aaccf3..44493a9 100644 --- a/generator/scenarios/StorageRW_test.go +++ b/generator/scenarios/StorageRW_test.go @@ -305,6 +305,22 @@ func TestStorageRWGasClearsFloorAcrossPadSizes(t *testing.T) { } } +// decodeMix builds a mix the way a profile does. Insertion order matters to the +// guard this feeds: a small map iterates in insertion order most of the time, so +// a mix built in the set's declared order would let a picker that wrongly walked +// the mix still reproduce the golden on most runs. The keys below are +// deliberately alphabetical, which for storagerw inverts the declared order +// (rmw, read, write) and turns that mutation into a reliable failure. +// +// json.Unmarshal inserts in document order, so the ordering lives in the literal +// rather than in the decoding. +func decodeMix(t *testing.T, raw string) config.OperationMix { + t.Helper() + var mix config.OperationMix + require.NoError(t, json.Unmarshal([]byte(raw), &mix)) + return mix +} + // TestStorageRWDrawOrderIsStable pins the documented draw order — slot, then // pad, then operation — with all three axes live. Reordering the picks changes // this sequence, which is what makes a saved workload replayable; without a @@ -315,7 +331,7 @@ func TestStorageRWDrawOrderIsStable(t *testing.T) { RecordCount: 64, SizeDistribution: uniformDist(t), SizeBuckets: []int{0, 32, 96}, - Operations: config.OperationMix{config.OpRmw: 1, config.OpRead: 1, config.OpWrite: 1}, + Operations: decodeMix(t, `{"read":1,"rmw":1,"write":1}`), } want := []struct { method string diff --git a/generator/scenarios/doc.go b/generator/scenarios/doc.go index 894263f..962a84b 100644 --- a/generator/scenarios/doc.go +++ b/generator/scenarios/doc.go @@ -72,11 +72,23 @@ // axes — see the config package doc on what the seed does and does not promise. // // read writes too, and that bounds what the key axis can show. Every read folds -// its load into readAccumulator — one contract-wide slot — so reads conflict with -// each other no matter which key they drew. A read-weighted mix therefore does -// not sweep contention; only rmw and write do. Reads also measure absent slots -// until something has written them, since a fresh deploy starts empty and there -// is no warm-up phase. +// its load into readAccumulator, one contract-wide slot, and reads that slot +// first — so the accumulator sits in both the read set and the write set of +// every read transaction. +// +// Whether that serializes depends on the keyspace. Sei validates a transaction +// by comparing its read set against committed writes by value, so while a slot +// still holds zero the accumulator write leaves it unchanged and concurrent +// reads do not conflict. Once anything has written the keyspace the accumulator +// changes on every read, a later reader finds a different value than it +// recorded, and it conflicts regardless of which key it drew. Any mix containing +// rmw or write reaches that state within a few blocks. +// +// So rmw and write sweep contention across the keyspace; a read-weighted mix +// stops sweeping it as soon as the keyspace is populated. Reads also measure +// absent slots until something has written them, since a fresh deploy starts +// empty and there is no warm-up phase — the same threshold, showing up in gas +// rather than in contention. // // Gas sizing. All three operations share one base GasLimit of 50k. The measured // worst case is a read that first writes readAccumulator, at 46,269 including