Skip to content

Commit fdf1ccd

Browse files
schedulertest: add buffer-bound and action-budget invariants
Adds two invariants to CheckInvariants, with the driver instrumentation they need: - buffer-bound: total buffered starts must never exceed MaxBufferSize (the generator sizes each fill against this cap). - action-budget: a single InvokerExecuteTask must not take more actions (start + terminate + cancel RPCs) than MaxActionsPerExecution. Guards the double-spend class where the budget resets across the terminate/cancel/start phases. Driver now counts action RPCs via the mock clients, tracks the max taken by any single side-effect task dispatch, and exposes BufferedStartsCount / MaxBufferSize / MaxActionsObserved / MaxActionsPerExecution on Snapshot. Adds WithConfig so tests can set small Tweakables to exercise the caps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2414b39 commit fdf1ccd

3 files changed

Lines changed: 124 additions & 6 deletions

File tree

chasm/lib/scheduler/schedulertest/driver.go

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,27 @@ type Driver struct {
5656
Frontend *workflowservicemock.MockWorkflowServiceClient
5757
History *historyservicemock.MockHistoryServiceClient
5858

59+
// Config is the scheduler config the library was wired with. Snapshot reads
60+
// Tweakables (MaxBufferSize, MaxActionsPerExecution) from it so the
61+
// buffer-bound and action-budget invariants can be evaluated.
62+
Config *scheduler.Config
63+
5964
ref chasm.ComponentRef
6065
execKey chasm.ExecutionKey
6166

6267
// dispatchedSideEffects tracks physical side-effect tasks already executed,
6368
// keyed by pointer identity, since MockNodeBackend accumulates tasks append-only.
6469
dispatchedSideEffects map[*tasks.ChasmTask]struct{}
6570

71+
// actionRPCs points at the running count of action RPCs (StartWorkflowExecution
72+
// + TerminateWorkflowExecution + RequestCancelWorkflowExecution) made through
73+
// the mock clients. It is allocated before the clients so the stub closures can
74+
// increment it. maxActionsObserved is the high-water mark of action RPCs made by
75+
// any single side-effect task dispatch (one InvokerExecuteTask), which backs the
76+
// action-budget invariant.
77+
actionRPCs *int
78+
maxActionsObserved int
79+
6680
// AfterStep, if set, is called after every step's CloseTransaction with the
6781
// driver, so invariant checks can observe each settled state.
6882
AfterStep func(d *Driver)
@@ -72,6 +86,7 @@ type driverConfig struct {
7286
schedule *schedulepb.Schedule
7387
startTime time.Time
7488
afterStep func(d *Driver)
89+
config *scheduler.Config
7590
}
7691

7792
// DriverOption configures a Driver at construction.
@@ -92,6 +107,13 @@ func WithAfterStep(fn func(d *Driver)) DriverOption {
92107
return func(c *driverConfig) { c.afterStep = fn }
93108
}
94109

110+
// WithConfig overrides the scheduler config (default: DefaultConfig). Use it to
111+
// set small Tweakables — e.g. MaxBufferSize or MaxActionsPerExecution — so the
112+
// buffer-bound and action-budget invariants are meaningfully exercised.
113+
func WithConfig(cfg *scheduler.Config) DriverOption {
114+
return func(c *driverConfig) { c.config = cfg }
115+
}
116+
95117
// NewDriver constructs a Driver and starts a scheduler execution via the
96118
// production CreateScheduler path. startTime defaults to a fixed instant for
97119
// determinism.
@@ -134,6 +156,7 @@ func newDriverScaffold(t *testing.T, opts ...DriverOption) (*Driver, *driverConf
134156
cfg := &driverConfig{
135157
schedule: DefaultSchedule(),
136158
startTime: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC),
159+
config: DefaultConfig(),
137160
}
138161
for _, opt := range opts {
139162
opt(cfg)
@@ -147,9 +170,29 @@ func newDriverScaffold(t *testing.T, opts ...DriverOption) (*Driver, *driverConf
147170

148171
frontend := workflowservicemock.NewMockWorkflowServiceClient(ctrl)
149172
history := historyservicemock.NewMockHistoryServiceClient(ctrl)
173+
174+
// actionRPCs counts every action RPC (start/terminate/cancel) the invoker
175+
// makes through the mock clients, so Step can measure how many actions a single
176+
// ExecuteTask takes (the action-budget invariant).
177+
actionRPCs := new(int)
178+
countAction := func() { *actionRPCs++ }
179+
150180
// Default happy-path stubs; tests can layer more specific expectations.
151181
frontend.EXPECT().StartWorkflowExecution(gomock.Any(), gomock.Any()).
152-
Return(&workflowservice.StartWorkflowExecutionResponse{RunId: "run-id"}, nil).AnyTimes()
182+
DoAndReturn(func(context.Context, *workflowservice.StartWorkflowExecutionRequest, ...grpc.CallOption) (*workflowservice.StartWorkflowExecutionResponse, error) {
183+
countAction()
184+
return &workflowservice.StartWorkflowExecutionResponse{RunId: "run-id"}, nil
185+
}).AnyTimes()
186+
history.EXPECT().TerminateWorkflowExecution(gomock.Any(), gomock.Any()).
187+
DoAndReturn(func(context.Context, *historyservice.TerminateWorkflowExecutionRequest, ...grpc.CallOption) (*historyservice.TerminateWorkflowExecutionResponse, error) {
188+
countAction()
189+
return &historyservice.TerminateWorkflowExecutionResponse{}, nil
190+
}).AnyTimes()
191+
history.EXPECT().RequestCancelWorkflowExecution(gomock.Any(), gomock.Any()).
192+
DoAndReturn(func(context.Context, *historyservice.RequestCancelWorkflowExecutionRequest, ...grpc.CallOption) (*historyservice.RequestCancelWorkflowExecutionResponse, error) {
193+
countAction()
194+
return &historyservice.RequestCancelWorkflowExecutionResponse{}, nil
195+
}).AnyTimes()
153196
// The callbacks task (used for migrated running workflows) describes each
154197
// running workflow; report it completed so watchers resolve deterministically.
155198
history.EXPECT().DescribeWorkflowExecution(gomock.Any(), gomock.Any()).
@@ -168,7 +211,7 @@ func newDriverScaffold(t *testing.T, opts ...DriverOption) (*Driver, *driverConf
168211
if err := registry.Register(&chasm.CoreLibrary{}); err != nil {
169212
t.Fatalf("failed to register core library: %v", err)
170213
}
171-
if err := registry.Register(NewTestLibrary(DefaultConfig(), logger, specProcessor, frontend, history)); err != nil {
214+
if err := registry.Register(NewTestLibrary(cfg.config, logger, specProcessor, frontend, history)); err != nil {
172215
t.Fatalf("failed to register scheduler library: %v", err)
173216
}
174217

@@ -196,7 +239,9 @@ func newDriverScaffold(t *testing.T, opts ...DriverOption) (*Driver, *driverConf
196239
Logger: logger,
197240
Frontend: frontend,
198241
History: history,
242+
Config: cfg.config,
199243
dispatchedSideEffects: make(map[*tasks.ChasmTask]struct{}),
244+
actionRPCs: actionRPCs,
200245
AfterStep: cfg.afterStep,
201246
}
202247
return d, cfg
@@ -352,6 +397,7 @@ func (d *Driver) Step() bool {
352397
continue
353398
}
354399
d.dispatchedSideEffects[ct] = struct{}{}
400+
actionsBefore := *d.actionRPCs
355401
execErr := node.ExecuteSideEffectTask(
356402
engineCtx,
357403
d.execKey,
@@ -368,6 +414,11 @@ func (d *Driver) Step() bool {
368414
}
369415
d.T.Fatalf("ExecuteSideEffectTask failed: %v", execErr)
370416
}
417+
// Track the most actions any single side-effect task (i.e. one
418+
// InvokerExecuteTask) took, for the action-budget invariant.
419+
if taken := *d.actionRPCs - actionsBefore; taken > d.maxActionsObserved {
420+
d.maxActionsObserved = taken
421+
}
371422
}
372423
if _, err := node.CloseTransaction(); err != nil {
373424
d.T.Fatalf("CloseTransaction after side-effect tasks failed: %v", err)
@@ -489,14 +540,31 @@ type Snapshot struct {
489540
// GeneratorLPT / InvokerLPT are the generator and invoker high-water marks.
490541
GeneratorLPT time.Time
491542
InvokerLPT time.Time
543+
544+
// BufferedStartsCount is len(invoker.GetBufferedStarts()) — the total buffered
545+
// starts (pending + retained). MaxBufferSize is the configured cap; the
546+
// buffer-bound invariant requires BufferedStartsCount <= MaxBufferSize.
547+
BufferedStartsCount int
548+
MaxBufferSize int
549+
550+
// MaxActionsObserved is the high-water mark of action RPCs (start + terminate +
551+
// cancel) taken by any single InvokerExecuteTask so far. MaxActionsPerExecution
552+
// is the configured per-execution cap; the action-budget invariant requires
553+
// MaxActionsObserved <= MaxActionsPerExecution.
554+
MaxActionsObserved int
555+
MaxActionsPerExecution int
492556
}
493557

494558
// Snapshot reads current scheduler state into a Snapshot.
495559
func (d *Driver) Snapshot() Snapshot {
496560
d.T.Helper()
561+
tweakables := d.Config.Tweakables(Namespace)
497562
snap := Snapshot{
498-
Now: d.Now(),
499-
HasPendingTask: d.HasPendingTask(),
563+
Now: d.Now(),
564+
HasPendingTask: d.HasPendingTask(),
565+
MaxBufferSize: tweakables.MaxBufferSize,
566+
MaxActionsObserved: d.maxActionsObserved,
567+
MaxActionsPerExecution: tweakables.MaxActionsPerExecution,
500568
}
501569
d.ReadScheduler(func(s *scheduler.Scheduler, ctx chasm.Context) {
502570
snap.Closed = s.Closed
@@ -513,8 +581,11 @@ func (d *Driver) Snapshot() Snapshot {
513581
if g := s.Generator.Get(ctx); g != nil && g.LastProcessedTime != nil {
514582
snap.GeneratorLPT = g.LastProcessedTime.AsTime()
515583
}
516-
if inv := s.Invoker.Get(ctx); inv != nil && inv.LastProcessedTime != nil {
517-
snap.InvokerLPT = inv.LastProcessedTime.AsTime()
584+
if inv := s.Invoker.Get(ctx); inv != nil {
585+
snap.BufferedStartsCount = len(inv.GetBufferedStarts())
586+
if inv.LastProcessedTime != nil {
587+
snap.InvokerLPT = inv.LastProcessedTime.AsTime()
588+
}
518589
}
519590
}
520591
})

chasm/lib/scheduler/schedulertest/invariants.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,27 @@ func CheckInvariants(prev *Snapshot, cur Snapshot) []Violation {
4848
cur.Paused, cur.BackfillerCount, cur.IdleCloseTime.Format("2006-01-02T15:04:05"))
4949
}
5050

51+
// (2a) Buffer bound: the total buffered starts must never exceed MaxBufferSize.
52+
// The generator sizes each fill against this cap (remaining = MaxBufferSize -
53+
// len(buffered)); exceeding it means the limit was not enforced and CHASM state
54+
// can grow without bound (oversized state, persistence failures, OOM).
55+
if cur.MaxBufferSize > 0 && cur.BufferedStartsCount > cur.MaxBufferSize {
56+
add("buffer-bound",
57+
"buffered starts = %d, exceeding MaxBufferSize=%d",
58+
cur.BufferedStartsCount, cur.MaxBufferSize)
59+
}
60+
61+
// (2b) Action budget: a single InvokerExecuteTask must not take more actions
62+
// (start + terminate + cancel RPCs) than MaxActionsPerExecution. Exceeding it
63+
// means the per-execution rate protection was bypassed — e.g. the action budget
64+
// reset across the terminate/cancel/start phases, letting one execution take up
65+
// to a multiple of the configured maximum.
66+
if cur.MaxActionsPerExecution > 0 && cur.MaxActionsObserved > cur.MaxActionsPerExecution {
67+
add("action-budget",
68+
"a single ExecuteTask took %d actions, exceeding MaxActionsPerExecution=%d",
69+
cur.MaxActionsObserved, cur.MaxActionsPerExecution)
70+
}
71+
5172
if prev != nil {
5273
// (3) Closed is terminal: a closed schedule must never reopen.
5374
if prev.Closed && !cur.Closed {

chasm/lib/scheduler/schedulertest/invariants_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,29 @@ func TestCheckInvariants_DetectsHwmRegression(t *testing.T) {
113113
require.NotEmpty(t, violations)
114114
require.Equal(t, "hwm-monotonic-generator", violations[0].Name)
115115
}
116+
117+
// TestCheckInvariants_DetectsBufferOverrun confirms the buffer-bound invariant
118+
// fires when buffered starts exceed MaxBufferSize, and that being exactly at the
119+
// cap is allowed.
120+
func TestCheckInvariants_DetectsBufferOverrun(t *testing.T) {
121+
over := schedulertest.Snapshot{HasPendingTask: true, MaxBufferSize: 1000, BufferedStartsCount: 1001}
122+
violations := schedulertest.CheckInvariants(nil, over)
123+
require.NotEmpty(t, violations, "buffer overrun must be flagged")
124+
require.Equal(t, "buffer-bound", violations[0].Name)
125+
126+
atCap := schedulertest.Snapshot{HasPendingTask: true, MaxBufferSize: 1000, BufferedStartsCount: 1000}
127+
require.Empty(t, schedulertest.CheckInvariants(nil, atCap), "exactly at the cap is allowed")
128+
}
129+
130+
// TestCheckInvariants_DetectsBudgetOverrun confirms the action-budget invariant
131+
// fires when a single ExecuteTask takes more actions than MaxActionsPerExecution
132+
// (the double-spend bug class), and that being exactly at the cap is allowed.
133+
func TestCheckInvariants_DetectsBudgetOverrun(t *testing.T) {
134+
over := schedulertest.Snapshot{HasPendingTask: true, MaxActionsPerExecution: 5, MaxActionsObserved: 6}
135+
violations := schedulertest.CheckInvariants(nil, over)
136+
require.NotEmpty(t, violations, "budget overrun must be flagged")
137+
require.Equal(t, "action-budget", violations[0].Name)
138+
139+
atCap := schedulertest.Snapshot{HasPendingTask: true, MaxActionsPerExecution: 5, MaxActionsObserved: 5}
140+
require.Empty(t, schedulertest.CheckInvariants(nil, atCap), "exactly at the cap is allowed")
141+
}

0 commit comments

Comments
 (0)