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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down
16 changes: 8 additions & 8 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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())
}
29 changes: 25 additions & 4 deletions config/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
//
Expand Down
182 changes: 140 additions & 42 deletions config/operation.go
Original file line number Diff line number Diff line change
@@ -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]
}
Loading
Loading