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..77d4470 100644 --- a/config/operation.go +++ b/config/operation.go @@ -1,67 +1,165 @@ 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 + } + // 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) + } + 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..9c24d96 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} - seen := map[config.Operation]int{} - for i := 0; i < draws; i++ { - seen[m.Select(rng)]++ + mix := config.OperationMix{} + for name, weight := range want { + mix[name] = uint64(weight) } - // 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) + seen := map[string]int{} + for _, got := range selectN(config.StorageRWOperations, mix, 9, draws) { + seen[got]++ + } + + // 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,183 @@ 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]) +} + +// 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.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..44493a9 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}, @@ -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{Rmw: 1, Read: 1, Write: 1}, + Operations: decodeMix(t, `{"read":1,"rmw":1,"write":1}`), } want := []struct { method string @@ -354,3 +370,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..962a84b 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 @@ -68,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