diff --git a/sqsim/BUILD.bazel b/sqsim/BUILD.bazel new file mode 100644 index 00000000..db6fc6bb --- /dev/null +++ b/sqsim/BUILD.bazel @@ -0,0 +1,36 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = [ + "behavior.go", + "buildrunner.go", + "fault.go", + "land.go", + "merger.go", + "scenario.go", + "types.go", + "validation.go", + ], + importpath = "github.com/uber/submitqueue/sqsim", + visibility = ["//visibility:public"], + deps = ["//sqsim/entity:go_default_library"], +) + +go_test( + name = "go_default_test", + srcs = [ + "behavior_test.go", + "buildrunner_test.go", + "fault_test.go", + "land_test.go", + "merger_test.go", + "scenario_test.go", + "validation_test.go", + ], + embed = [":go_default_library"], + deps = [ + "@com_github_stretchr_testify//assert:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", + ], +) diff --git a/sqsim/behavior.go b/sqsim/behavior.go new file mode 100644 index 00000000..537ee3a1 --- /dev/null +++ b/sqsim/behavior.go @@ -0,0 +1,97 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sqsim + +import ( + "fmt" + + "github.com/uber/submitqueue/sqsim/entity" +) + +// BehaviorBuilder constructs the external behavior attached to one or more Lands. +type BehaviorBuilder struct { + buildRunner *BuildRunnerBehaviorBuilder + mergeConflictCheck *MergeConflictCheckBehaviorBuilder + merge *MergeBehaviorBuilder +} + +// NewBehavior returns an empty behavior builder. +func NewBehavior() *BehaviorBuilder { + return &BehaviorBuilder{} +} + +// BuildRunner sets the build behavior. +func (b *BehaviorBuilder) BuildRunner(behavior *BuildRunnerBehaviorBuilder) *BehaviorBuilder { + b.buildRunner = behavior + return b +} + +// MergeConflictCheck sets the dry-run mergeability behavior. +func (b *BehaviorBuilder) MergeConflictCheck(behavior *MergeConflictCheckBehaviorBuilder) *BehaviorBuilder { + b.mergeConflictCheck = behavior + return b +} + +// Merge sets the committing merge behavior. +func (b *BehaviorBuilder) Merge(behavior *MergeBehaviorBuilder) *BehaviorBuilder { + b.merge = behavior + return b +} + +func (b *BehaviorBuilder) build() (entity.Behavior, error) { + if b == nil { + return entity.Behavior{}, fmt.Errorf("behavior builder is nil") + } + if b.buildRunner == nil { + return entity.Behavior{}, fmt.Errorf("build runner behavior is required") + } + if b.mergeConflictCheck == nil { + return entity.Behavior{}, fmt.Errorf("merge conflict check behavior is required") + } + if b.merge == nil { + return entity.Behavior{}, fmt.Errorf("merge behavior is required") + } + + buildRunner, err := b.buildRunner.build() + if err != nil { + return entity.Behavior{}, fmt.Errorf("build runner: %w", err) + } + mergeConflictCheck, err := b.mergeConflictCheck.build() + if err != nil { + return entity.Behavior{}, fmt.Errorf("merge conflict check: %w", err) + } + merge, err := b.merge.build() + if err != nil { + return entity.Behavior{}, fmt.Errorf("merge: %w", err) + } + return entity.Behavior{ + BuildRunner: buildRunner, + MergeConflictCheck: mergeConflictCheck, + Merge: merge, + }, nil +} + +func cloneBehavior(behavior entity.Behavior) entity.Behavior { + triggers := make([]entity.Invocation[entity.BuildTriggerOutcome], len(behavior.BuildRunner.Triggers)) + for i, trigger := range behavior.BuildRunner.Triggers { + trigger.Outcome.Build.Timeline = append([]entity.BuildStatusPoint(nil), trigger.Outcome.Build.Timeline...) + trigger.Outcome.Build.StatusFaults = append([]entity.FaultOnCall(nil), trigger.Outcome.Build.StatusFaults...) + triggers[i] = trigger + } + behavior.BuildRunner.Triggers = triggers + behavior.MergeConflictCheck.Invocations = append([]entity.Invocation[entity.MergeConflictCheckOutcome](nil), behavior.MergeConflictCheck.Invocations...) + behavior.Merge.Invocations = append([]entity.Invocation[entity.MergeOutcome](nil), behavior.Merge.Invocations...) + return behavior +} diff --git a/sqsim/behavior_test.go b/sqsim/behavior_test.go new file mode 100644 index 00000000..a6ec3deb --- /dev/null +++ b/sqsim/behavior_test.go @@ -0,0 +1,34 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sqsim + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestBehaviorBuilderRequiresEveryExternalOperation(t *testing.T) { + behavior := NewBehavior(). + BuildRunner(SuccessfulBuildRunner()). + MergeConflictCheck(SuccessfulMergeConflictCheck()) + + _, err := NewScenario(). + Timeout(time.Minute). + Land(NewLand("l1").Queue("sqsim").Behavior(behavior).Expect(RequestLanded)). + Build() + require.Error(t, err) +} diff --git a/sqsim/buildrunner.go b/sqsim/buildrunner.go new file mode 100644 index 00000000..187db647 --- /dev/null +++ b/sqsim/buildrunner.go @@ -0,0 +1,112 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sqsim + +import ( + "time" + + "github.com/uber/submitqueue/sqsim/entity" +) + +// BuildRunnerBehaviorBuilder constructs BuildRunner behavior. +type BuildRunnerBehaviorBuilder struct { + triggers []entity.Invocation[entity.BuildTriggerOutcome] +} + +// NewBuildRunnerBehavior returns an empty BuildRunner behavior builder. +func NewBuildRunnerBehavior() *BuildRunnerBehaviorBuilder { + return &BuildRunnerBehaviorBuilder{} +} + +// Trigger appends Trigger invocations. +func (b *BuildRunnerBehaviorBuilder) Trigger(invocations ...entity.Invocation[entity.BuildTriggerOutcome]) *BuildRunnerBehaviorBuilder { + b.triggers = append(b.triggers, invocations...) + return b +} + +// StatusFaultOnCall adds a Status fault to the most recent successful Trigger outcome. +func (b *BuildRunnerBehaviorBuilder) StatusFaultOnCall(call int, fault Fault) *BuildRunnerBehaviorBuilder { + if len(b.triggers) == 0 { + return b + } + last := &b.triggers[len(b.triggers)-1] + last.Outcome.Build.StatusFaults = append(last.Outcome.Build.StatusFaults, entity.FaultOnCall{ + Call: call, + Fault: fault, + }) + return b +} + +func (b *BuildRunnerBehaviorBuilder) build() (entity.BuildRunnerBehavior, error) { + triggers := append([]entity.Invocation[entity.BuildTriggerOutcome](nil), b.triggers...) + for i := range triggers { + triggers[i].Outcome.Build.Timeline = append([]entity.BuildStatusPoint(nil), triggers[i].Outcome.Build.Timeline...) + triggers[i].Outcome.Build.StatusFaults = append([]entity.FaultOnCall(nil), triggers[i].Outcome.Build.StatusFaults...) + } + return entity.BuildRunnerBehavior{Triggers: triggers}, nil +} + +// StatusAt returns a build status timeline point. +func StatusAt(after time.Duration, status BuildStatus) BuildStatusPoint { + return entity.BuildStatusPoint{AfterMs: after.Milliseconds(), Status: status} +} + +// BuildCreated returns a successful Trigger invocation with a build timeline. +func BuildCreated(timeline ...BuildStatusPoint) entity.Invocation[entity.BuildTriggerOutcome] { + return entity.Invocation[entity.BuildTriggerOutcome]{ + Outcome: entity.BuildTriggerOutcome{ + Build: entity.BuildExecution{Timeline: append([]entity.BuildStatusPoint(nil), timeline...)}, + }, + } +} + +// BuildCreatedWithFault returns a Trigger invocation that creates a build and then faults. +func BuildCreatedWithFault(fault Fault, timeline ...BuildStatusPoint) entity.Invocation[entity.BuildTriggerOutcome] { + invocation := BuildCreated(timeline...) + invocation.Fault = fault + return invocation +} + +// BuildTriggerFault returns a Trigger invocation that fails without creating a build. +func BuildTriggerFault(fault Fault) entity.Invocation[entity.BuildTriggerOutcome] { + return entity.Invocation[entity.BuildTriggerOutcome]{Fault: fault} +} + +// SuccessfulBuildRunner returns a build behavior that succeeds immediately. +func SuccessfulBuildRunner() *BuildRunnerBehaviorBuilder { + return NewBuildRunnerBehavior().Trigger(BuildCreated(StatusAt(0, BuildSucceeded))) +} + +// BuildSucceededAfter returns a build behavior that succeeds after the given duration. +func BuildSucceededAfter(after time.Duration) *BuildRunnerBehaviorBuilder { + timeline := []BuildStatusPoint{StatusAt(0, BuildAccepted)} + if after > 0 { + timeline = append(timeline, StatusAt(after, BuildSucceeded)) + } else { + timeline[0] = StatusAt(0, BuildSucceeded) + } + return NewBuildRunnerBehavior().Trigger(BuildCreated(timeline...)) +} + +// BuildFailedAfter returns a build behavior that fails after the given duration. +func BuildFailedAfter(after time.Duration) *BuildRunnerBehaviorBuilder { + timeline := []BuildStatusPoint{StatusAt(0, BuildAccepted)} + if after > 0 { + timeline = append(timeline, StatusAt(after, BuildFailed)) + } else { + timeline[0] = StatusAt(0, BuildFailed) + } + return NewBuildRunnerBehavior().Trigger(BuildCreated(timeline...)) +} diff --git a/sqsim/buildrunner_test.go b/sqsim/buildrunner_test.go new file mode 100644 index 00000000..2e6e4773 --- /dev/null +++ b/sqsim/buildrunner_test.go @@ -0,0 +1,38 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sqsim + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildRunnerBuilderAddsStatusFault(t *testing.T) { + builder := NewBuildRunnerBehavior(). + Trigger(BuildCreated( + StatusAt(0, BuildAccepted), + StatusAt(time.Second, BuildSucceeded), + )). + StatusFaultOnCall(2, RetryableErrorBeforeSideEffect()) + + behavior, err := builder.build() + require.NoError(t, err) + require.Len(t, behavior.Triggers, 1) + require.Len(t, behavior.Triggers[0].Outcome.Build.StatusFaults, 1) + assert.Equal(t, 2, behavior.Triggers[0].Outcome.Build.StatusFaults[0].Call) +} diff --git a/sqsim/entity/BUILD.bazel b/sqsim/entity/BUILD.bazel new file mode 100644 index 00000000..d45e7e95 --- /dev/null +++ b/sqsim/entity/BUILD.bazel @@ -0,0 +1,24 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = [ + "behavior.go", + "scenario.go", + ], + importpath = "github.com/uber/submitqueue/sqsim/entity", + visibility = ["//visibility:public"], +) + +go_test( + name = "go_default_test", + srcs = [ + "behavior_test.go", + "scenario_test.go", + ], + embed = [":go_default_library"], + deps = [ + "@com_github_stretchr_testify//assert:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", + ], +) diff --git a/sqsim/entity/behavior.go b/sqsim/entity/behavior.go new file mode 100644 index 00000000..9d8697e4 --- /dev/null +++ b/sqsim/entity/behavior.go @@ -0,0 +1,158 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package entity + +// Behavior groups the external operations encountered by a Land. +type Behavior struct { + // BuildRunner describes build triggering and status polling. + BuildRunner BuildRunnerBehavior `json:"build_runner"` + // MergeConflictCheck describes Runway dry-run mergeability checks. + MergeConflictCheck MergeConflictCheckBehavior `json:"merge_conflict_check"` + // Merge describes Runway committing merge calls. + Merge MergeBehavior `json:"merge"` +} + +// Invocation describes one call made to an external system. +type Invocation[T any] struct { + // DelayMs is synchronous provider latency before the call returns. + DelayMs int64 `json:"delay_ms"` + // Outcome is the result applied by the external system. + Outcome T `json:"outcome"` + // Fault optionally changes how the result is returned to the caller. + Fault Fault `json:"fault"` +} + +// Fault describes an error returned by a modeled external operation. +type Fault struct { + // Kind identifies the classification of the returned error. + Kind FaultKind `json:"kind"` + // Phase states whether the outcome is applied before the error. + Phase FaultPhase `json:"phase"` +} + +// FaultKind identifies a modeled error classification. +type FaultKind string + +const ( + // FaultNone indicates that an invocation returns normally. + FaultNone FaultKind = "" + // FaultRetryable indicates a transient infrastructure error. + FaultRetryable FaultKind = "retryable" + // FaultNonRetryable indicates a permanent infrastructure error. + FaultNonRetryable FaultKind = "non_retryable" +) + +// FaultPhase identifies when an error occurs relative to an operation's side effect. +type FaultPhase string + +const ( + // FaultBeforeSideEffect indicates that the operation did not apply its outcome. + FaultBeforeSideEffect FaultPhase = "before_side_effect" + // FaultAfterSideEffect indicates that the outcome was applied before the response failed. + FaultAfterSideEffect FaultPhase = "after_side_effect" +) + +// BuildRunnerBehavior describes BuildRunner Trigger and Status behavior. +type BuildRunnerBehavior struct { + // Triggers are consumed by logical Trigger operations in declaration order. + Triggers []Invocation[BuildTriggerOutcome] `json:"triggers"` +} + +// BuildTriggerOutcome describes the external build created by Trigger. +type BuildTriggerOutcome struct { + // Build is the build created by a successful Trigger side effect. + Build BuildExecution `json:"build"` +} + +// BuildExecution describes status over elapsed wall time for one build. +type BuildExecution struct { + // Timeline describes status as elapsed wall time from build creation. + Timeline []BuildStatusPoint `json:"timeline"` + // StatusFaults inject errors into selected Status calls. + StatusFaults []FaultOnCall `json:"status_faults"` +} + +// BuildStatusPoint is the status visible at and after one elapsed offset. +type BuildStatusPoint struct { + // AfterMs is elapsed wall time since build creation in milliseconds. + AfterMs int64 `json:"after_ms"` + // Status is the status visible at and after AfterMs. + Status BuildStatus `json:"status"` +} + +// FaultOnCall injects a fault into one numbered method call. +type FaultOnCall struct { + // Call is the one-based invocation number. + Call int `json:"call"` + // Fault is the error returned by that invocation. + Fault Fault `json:"fault"` +} + +// BuildStatus is a provider-neutral modeled build status. +type BuildStatus string + +const ( + // BuildAccepted indicates that the runner accepted the build. + BuildAccepted BuildStatus = "accepted" + // BuildRunning indicates that the build is executing. + BuildRunning BuildStatus = "running" + // BuildSucceeded indicates terminal build success. + BuildSucceeded BuildStatus = "succeeded" + // BuildFailed indicates terminal build failure. + BuildFailed BuildStatus = "failed" + // BuildCancelled indicates terminal cancellation. + BuildCancelled BuildStatus = "cancelled" +) + +// IsTerminal reports whether the modeled build status is terminal. +func (s BuildStatus) IsTerminal() bool { + return s == BuildSucceeded || s == BuildFailed || s == BuildCancelled +} + +// MergeConflictCheckBehavior describes CheckMergeability calls. +type MergeConflictCheckBehavior struct { + // Invocations are consumed by CheckMergeability calls in declaration order. + Invocations []Invocation[MergeConflictCheckOutcome] `json:"invocations"` +} + +// MergeConflictCheckOutcome is the business result of a mergeability check. +type MergeConflictCheckOutcome string + +const ( + // Mergeable indicates that the changes apply cleanly. + Mergeable MergeConflictCheckOutcome = "mergeable" + // MergeConflict indicates a terminal merge conflict. + MergeConflict MergeConflictCheckOutcome = "conflict" +) + +// MergeBehavior describes committing Merge calls. +type MergeBehavior struct { + // Invocations are consumed by committing Merge calls in declaration order. + Invocations []Invocation[MergeOutcome] `json:"invocations"` +} + +// MergeOutcome describes the business result of a committing merge. +type MergeOutcome struct { + // Result identifies the merge result. + Result MergeResult `json:"result"` +} + +// MergeResult is the modeled business result of a committing merge. +type MergeResult string + +const ( + // MergeSucceeded indicates that the merge committed successfully. + MergeSucceeded MergeResult = "succeeded" +) diff --git a/sqsim/entity/behavior_test.go b/sqsim/entity/behavior_test.go new file mode 100644 index 00000000..de6b7104 --- /dev/null +++ b/sqsim/entity/behavior_test.go @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package entity + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuildStatusIsTerminal(t *testing.T) { + tests := []struct { + status BuildStatus + want bool + }{ + {status: BuildAccepted, want: false}, + {status: BuildRunning, want: false}, + {status: BuildSucceeded, want: true}, + {status: BuildFailed, want: true}, + {status: BuildCancelled, want: true}, + } + + for _, tt := range tests { + t.Run(string(tt.status), func(t *testing.T) { + assert.Equal(t, tt.want, tt.status.IsTerminal()) + }) + } +} diff --git a/sqsim/entity/scenario.go b/sqsim/entity/scenario.go new file mode 100644 index 00000000..dd2bdd81 --- /dev/null +++ b/sqsim/entity/scenario.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package entity defines immutable SQSim scenario data. +package entity + +// Scenario is the complete workload executed against one fresh SubmitQueue stack. +type Scenario struct { + // TimeoutMs is the maximum wall-clock duration of the run in milliseconds. + TimeoutMs int64 `json:"timeout_ms"` + // Lands are the requests submitted by the run in declaration order. + Lands []Land `json:"lands"` +} + +// Land describes one Gateway Land call and its modeled external behavior. +type Land struct { + // Name identifies the Land within its scenario. + Name string `json:"name"` + // Queue is the SubmitQueue queue receiving the request. + Queue string `json:"queue"` + // SubmitAfterMs is the delay from run start before submission in milliseconds. + SubmitAfterMs int64 `json:"submit_after_ms"` + // Behavior describes the external systems encountered by this request. + Behavior Behavior `json:"behavior"` + // Expectation describes the required public terminal outcome. + Expectation Expectation `json:"expectation"` +} + +// Expectation describes the public terminal outcome required by a Land. +type Expectation struct { + // Status is the expected public terminal request status. + Status ExpectedRequestStatus `json:"status"` +} + +// ExpectedRequestStatus is a terminal public request status expected by SQSim. +type ExpectedRequestStatus string + +const ( + // RequestLanded expects the request to land successfully. + RequestLanded ExpectedRequestStatus = "landed" + // RequestError expects the request to reach terminal error. + RequestError ExpectedRequestStatus = "error" + // RequestCancelled expects the request to be cancelled. + RequestCancelled ExpectedRequestStatus = "cancelled" +) diff --git a/sqsim/entity/scenario_test.go b/sqsim/entity/scenario_test.go new file mode 100644 index 00000000..630afb23 --- /dev/null +++ b/sqsim/entity/scenario_test.go @@ -0,0 +1,41 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package entity + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestScenarioJSONRoundTrip(t *testing.T) { + scenario := Scenario{ + TimeoutMs: 1000, + Lands: []Land{{ + Name: "l1", + Queue: "sqsim", + Expectation: Expectation{Status: RequestLanded}, + }}, + } + + data, err := json.Marshal(scenario) + require.NoError(t, err) + + var got Scenario + require.NoError(t, json.Unmarshal(data, &got)) + assert.Equal(t, scenario, got) +} diff --git a/sqsim/fault.go b/sqsim/fault.go new file mode 100644 index 00000000..9fbc5154 --- /dev/null +++ b/sqsim/fault.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sqsim + +import "github.com/uber/submitqueue/sqsim/entity" + +// RetryableErrorBeforeSideEffect returns a transient fault with no applied outcome. +func RetryableErrorBeforeSideEffect() Fault { + return entity.Fault{Kind: entity.FaultRetryable, Phase: entity.FaultBeforeSideEffect} +} + +// RetryableErrorAfterSideEffect returns a transient fault after applying the outcome. +func RetryableErrorAfterSideEffect() Fault { + return entity.Fault{Kind: entity.FaultRetryable, Phase: entity.FaultAfterSideEffect} +} + +// NonRetryableErrorBeforeSideEffect returns a permanent fault with no applied outcome. +func NonRetryableErrorBeforeSideEffect() Fault { + return entity.Fault{Kind: entity.FaultNonRetryable, Phase: entity.FaultBeforeSideEffect} +} diff --git a/sqsim/fault_test.go b/sqsim/fault_test.go new file mode 100644 index 00000000..a2c28129 --- /dev/null +++ b/sqsim/fault_test.go @@ -0,0 +1,27 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sqsim + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRetryableErrorAfterSideEffect(t *testing.T) { + fault := RetryableErrorAfterSideEffect() + assert.Equal(t, FaultRetryable, fault.Kind) + assert.Equal(t, FaultAfterSideEffect, fault.Phase) +} diff --git a/sqsim/land.go b/sqsim/land.go new file mode 100644 index 00000000..947cdf88 --- /dev/null +++ b/sqsim/land.go @@ -0,0 +1,85 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sqsim + +import ( + "fmt" + "time" + + "github.com/uber/submitqueue/sqsim/entity" +) + +// LandBuilder constructs one immutable Land. +type LandBuilder struct { + name string + queue string + submitAfter time.Duration + behavior *BehaviorBuilder + expectation entity.ExpectedRequestStatus +} + +// NewLand returns a builder for a named Land. +func NewLand(name string) *LandBuilder { + return &LandBuilder{name: name} +} + +// Queue sets the SubmitQueue queue receiving the request. +func (b *LandBuilder) Queue(queue string) *LandBuilder { + b.queue = queue + return b +} + +// SubmitAfter sets the delay from run start before submission. +func (b *LandBuilder) SubmitAfter(delay time.Duration) *LandBuilder { + b.submitAfter = delay + return b +} + +// Behavior sets the external-system behavior for this Land. +func (b *LandBuilder) Behavior(behavior *BehaviorBuilder) *LandBuilder { + b.behavior = behavior + return b +} + +// Expect sets the required public terminal status. +func (b *LandBuilder) Expect(status ExpectedRequestStatus) *LandBuilder { + b.expectation = status + return b +} + +func (b *LandBuilder) build() (entity.Land, error) { + if b == nil { + return entity.Land{}, fmt.Errorf("land builder is nil") + } + if b.behavior == nil { + return entity.Land{}, fmt.Errorf("behavior is required") + } + behavior, err := b.behavior.build() + if err != nil { + return entity.Land{}, fmt.Errorf("behavior: %w", err) + } + return entity.Land{ + Name: b.name, + Queue: b.queue, + SubmitAfterMs: b.submitAfter.Milliseconds(), + Behavior: behavior, + Expectation: entity.Expectation{Status: b.expectation}, + }, nil +} + +func cloneLand(land entity.Land) entity.Land { + land.Behavior = cloneBehavior(land.Behavior) + return land +} diff --git a/sqsim/land_test.go b/sqsim/land_test.go new file mode 100644 index 00000000..05f6d41c --- /dev/null +++ b/sqsim/land_test.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sqsim + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestLandBuilderRequiresBehavior(t *testing.T) { + _, err := NewScenario(). + Timeout(time.Minute). + Land(NewLand("l1").Queue("sqsim").Expect(RequestLanded)). + Build() + require.Error(t, err) +} diff --git a/sqsim/merger.go b/sqsim/merger.go new file mode 100644 index 00000000..0737e035 --- /dev/null +++ b/sqsim/merger.go @@ -0,0 +1,122 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sqsim + +import ( + "time" + + "github.com/uber/submitqueue/sqsim/entity" +) + +// MergeConflictCheckBehaviorBuilder constructs CheckMergeability behavior. +type MergeConflictCheckBehaviorBuilder struct { + invocations []entity.Invocation[entity.MergeConflictCheckOutcome] +} + +// NewMergeConflictCheckBehavior returns an empty behavior builder. +func NewMergeConflictCheckBehavior() *MergeConflictCheckBehaviorBuilder { + return &MergeConflictCheckBehaviorBuilder{} +} + +// Invoke appends CheckMergeability invocations. +func (b *MergeConflictCheckBehaviorBuilder) Invoke(invocations ...entity.Invocation[entity.MergeConflictCheckOutcome]) *MergeConflictCheckBehaviorBuilder { + b.invocations = append(b.invocations, invocations...) + return b +} + +func (b *MergeConflictCheckBehaviorBuilder) build() (entity.MergeConflictCheckBehavior, error) { + return entity.MergeConflictCheckBehavior{ + Invocations: append([]entity.Invocation[entity.MergeConflictCheckOutcome](nil), b.invocations...), + }, nil +} + +// SuccessfulMergeConflictCheck returns an immediately mergeable check. +func SuccessfulMergeConflictCheck() *MergeConflictCheckBehaviorBuilder { + return NewMergeConflictCheckBehavior().Invoke(MergeConflictCheckSucceeded()) +} + +// ConflictingMergeConflictCheck returns an immediate terminal conflict. +func ConflictingMergeConflictCheck() *MergeConflictCheckBehaviorBuilder { + return NewMergeConflictCheckBehavior().Invoke(MergeConflictCheckConflicted()) +} + +// MergeConflictCheckSucceeded returns an immediately mergeable invocation. +func MergeConflictCheckSucceeded() entity.Invocation[entity.MergeConflictCheckOutcome] { + return entity.Invocation[entity.MergeConflictCheckOutcome]{Outcome: entity.Mergeable} +} + +// MergeConflictCheckConflicted returns an immediate terminal-conflict invocation. +func MergeConflictCheckConflicted() entity.Invocation[entity.MergeConflictCheckOutcome] { + return entity.Invocation[entity.MergeConflictCheckOutcome]{Outcome: entity.MergeConflict} +} + +// MergeConflictCheckFault returns a failed CheckMergeability invocation. +func MergeConflictCheckFault(fault Fault) entity.Invocation[entity.MergeConflictCheckOutcome] { + return entity.Invocation[entity.MergeConflictCheckOutcome]{Fault: fault} +} + +// MergeInvocationBuilder constructs one Merge invocation. +type MergeInvocationBuilder struct { + invocation entity.Invocation[entity.MergeOutcome] +} + +// Fault applies a fault to the invocation. +func (b *MergeInvocationBuilder) Fault(fault Fault) *MergeInvocationBuilder { + b.invocation.Fault = fault + return b +} + +// MergeBehaviorBuilder constructs committing Merge behavior. +type MergeBehaviorBuilder struct { + invocations []entity.Invocation[entity.MergeOutcome] +} + +// NewMergeBehavior returns an empty Merge behavior builder. +func NewMergeBehavior() *MergeBehaviorBuilder { + return &MergeBehaviorBuilder{} +} + +// Invoke appends committing Merge invocations. +func (b *MergeBehaviorBuilder) Invoke(invocations ...*MergeInvocationBuilder) *MergeBehaviorBuilder { + for _, invocation := range invocations { + if invocation == nil { + b.invocations = append(b.invocations, entity.Invocation[entity.MergeOutcome]{}) + continue + } + b.invocations = append(b.invocations, invocation.invocation) + } + return b +} + +func (b *MergeBehaviorBuilder) build() (entity.MergeBehavior, error) { + return entity.MergeBehavior{ + Invocations: append([]entity.Invocation[entity.MergeOutcome](nil), b.invocations...), + }, nil +} + +// MergeSucceededAfter returns a successful Merge invocation with provider latency. +func MergeSucceededAfter(delay time.Duration) *MergeInvocationBuilder { + return &MergeInvocationBuilder{ + invocation: entity.Invocation[entity.MergeOutcome]{ + DelayMs: delay.Milliseconds(), + Outcome: entity.MergeOutcome{Result: entity.MergeSucceeded}, + }, + } +} + +// SuccessfulMerge returns an immediately successful committing merge. +func SuccessfulMerge() *MergeBehaviorBuilder { + return NewMergeBehavior().Invoke(MergeSucceededAfter(0)) +} diff --git a/sqsim/merger_test.go b/sqsim/merger_test.go new file mode 100644 index 00000000..d4b68b21 --- /dev/null +++ b/sqsim/merger_test.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sqsim + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMergeInvocationBuilderAppliesFault(t *testing.T) { + builder := NewMergeBehavior().Invoke( + MergeSucceededAfter(time.Second).Fault(RetryableErrorAfterSideEffect()), + ) + + behavior, err := builder.build() + require.NoError(t, err) + require.Len(t, behavior.Invocations, 1) + assert.Equal(t, int64(1000), behavior.Invocations[0].DelayMs) + assert.Equal(t, FaultAfterSideEffect, behavior.Invocations[0].Fault.Phase) +} diff --git a/sqsim/scenario.go b/sqsim/scenario.go new file mode 100644 index 00000000..2ae1c586 --- /dev/null +++ b/sqsim/scenario.go @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sqsim + +import ( + "fmt" + "time" + + "github.com/uber/submitqueue/sqsim/entity" +) + +// ScenarioBuilder constructs an immutable Scenario. +type ScenarioBuilder struct { + timeout time.Duration + lands []*LandBuilder +} + +// NewScenario returns an empty scenario builder. +func NewScenario() *ScenarioBuilder { + return &ScenarioBuilder{} +} + +// Timeout sets the maximum wall-clock duration of the run. +func (b *ScenarioBuilder) Timeout(timeout time.Duration) *ScenarioBuilder { + b.timeout = timeout + return b +} + +// Land appends requests in declaration order. +func (b *ScenarioBuilder) Land(lands ...*LandBuilder) *ScenarioBuilder { + b.lands = append(b.lands, lands...) + return b +} + +// Build validates and returns an immutable Scenario. +func (b *ScenarioBuilder) Build() (Scenario, error) { + if b == nil { + return Scenario{}, fmt.Errorf("scenario builder is nil") + } + + lands := make([]entity.Land, 0, len(b.lands)) + for i, landBuilder := range b.lands { + if landBuilder == nil { + return Scenario{}, fmt.Errorf("land %d is nil", i) + } + land, err := landBuilder.build() + if err != nil { + return Scenario{}, fmt.Errorf("land %d: %w", i, err) + } + lands = append(lands, cloneLand(land)) + } + + scenario := entity.Scenario{ + TimeoutMs: b.timeout.Milliseconds(), + Lands: lands, + } + if err := Validate(scenario); err != nil { + return Scenario{}, err + } + return cloneScenario(scenario), nil +} + +func cloneScenario(scenario entity.Scenario) entity.Scenario { + lands := make([]entity.Land, len(scenario.Lands)) + for i, land := range scenario.Lands { + lands[i] = cloneLand(land) + } + scenario.Lands = lands + return scenario +} diff --git a/sqsim/scenario_test.go b/sqsim/scenario_test.go new file mode 100644 index 00000000..70e47739 --- /dev/null +++ b/sqsim/scenario_test.go @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sqsim + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestScenarioBuilderBuildsImmutableValue(t *testing.T) { + behavior := happyBehavior() + builder := NewScenario(). + Timeout(time.Minute). + Land(NewLand("l1").Queue("sqsim").Behavior(behavior).Expect(RequestLanded)) + + scenario, err := builder.Build() + require.NoError(t, err) + scenario.Lands[0].Behavior.BuildRunner.Triggers[0].Outcome.Build.Timeline[0].Status = BuildFailed + + rebuilt, err := builder.Build() + require.NoError(t, err) + assert.Equal(t, BuildSucceeded, rebuilt.Lands[0].Behavior.BuildRunner.Triggers[0].Outcome.Build.Timeline[0].Status) +} + +func happyBehavior() *BehaviorBuilder { + return NewBehavior(). + BuildRunner(SuccessfulBuildRunner()). + MergeConflictCheck(SuccessfulMergeConflictCheck()). + Merge(SuccessfulMerge()) +} diff --git a/sqsim/scenarios/BUILD.bazel b/sqsim/scenarios/BUILD.bazel new file mode 100644 index 00000000..160fefc7 --- /dev/null +++ b/sqsim/scenarios/BUILD.bazel @@ -0,0 +1,26 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = [ + "happy_path.go", + "registry.go", + ], + importpath = "github.com/uber/submitqueue/sqsim/scenarios", + visibility = ["//visibility:public"], + deps = ["//sqsim:go_default_library"], +) + +go_test( + name = "go_default_test", + srcs = [ + "happy_path_test.go", + "registry_test.go", + ], + embed = [":go_default_library"], + deps = [ + "//sqsim:go_default_library", + "@com_github_stretchr_testify//assert:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", + ], +) diff --git a/sqsim/scenarios/happy_path.go b/sqsim/scenarios/happy_path.go new file mode 100644 index 00000000..9d9d05a1 --- /dev/null +++ b/sqsim/scenarios/happy_path.go @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package scenarios + +import ( + "time" + + "github.com/uber/submitqueue/sqsim" +) + +// HappyPath returns one request whose external operations all succeed. +func HappyPath() (sqsim.Scenario, error) { + happy := sqsim.NewBehavior(). + BuildRunner(sqsim.NewBuildRunnerBehavior(). + Trigger(sqsim.BuildCreated( + sqsim.StatusAt(0, sqsim.BuildAccepted), + sqsim.StatusAt(500*time.Millisecond, sqsim.BuildRunning), + sqsim.StatusAt(5*time.Second, sqsim.BuildSucceeded), + ))). + MergeConflictCheck(sqsim.SuccessfulMergeConflictCheck()). + Merge(sqsim.NewMergeBehavior(). + Invoke(sqsim.MergeSucceededAfter(500 * time.Millisecond))) + + return sqsim.NewScenario(). + Timeout(30 * time.Second). + Land( + sqsim.NewLand("l1"). + Queue("sqsim"). + Behavior(happy). + Expect(sqsim.RequestLanded), + ). + Build() +} diff --git a/sqsim/scenarios/happy_path_test.go b/sqsim/scenarios/happy_path_test.go new file mode 100644 index 00000000..a8c89979 --- /dev/null +++ b/sqsim/scenarios/happy_path_test.go @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package scenarios + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber/submitqueue/sqsim" +) + +func TestHappyPath(t *testing.T) { + scenario, err := HappyPath() + require.NoError(t, err) + require.Len(t, scenario.Lands, 1) + assert.Equal(t, "l1", scenario.Lands[0].Name) + assert.Equal(t, sqsim.RequestLanded, scenario.Lands[0].Expectation.Status) +} diff --git a/sqsim/scenarios/registry.go b/sqsim/scenarios/registry.go new file mode 100644 index 00000000..f00847a0 --- /dev/null +++ b/sqsim/scenarios/registry.go @@ -0,0 +1,63 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package scenarios contains the concrete SQSim scenario catalog. +package scenarios + +import ( + "fmt" + "sort" + + "github.com/uber/submitqueue/sqsim" +) + +// Builder constructs and validates one scenario. +type Builder func() (sqsim.Scenario, error) + +var registry = map[string]Builder{ + "happy-path": HappyPath, +} + +// Names returns registered scenario names in lexical order. +func Names() []string { + names := make([]string, 0, len(registry)) + for name := range registry { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// Build constructs the registered scenario with the given name. +func Build(name string) (sqsim.Scenario, error) { + builder, ok := registry[name] + if !ok { + return sqsim.Scenario{}, fmt.Errorf("unknown scenario %q", name) + } + scenario, err := builder() + if err != nil { + return sqsim.Scenario{}, fmt.Errorf("build scenario %q: %w", name, err) + } + return scenario, nil +} + +// ValidateAll builds every registered scenario. +func ValidateAll() error { + for _, name := range Names() { + if _, err := Build(name); err != nil { + return err + } + } + return nil +} diff --git a/sqsim/scenarios/registry_test.go b/sqsim/scenarios/registry_test.go new file mode 100644 index 00000000..67e8fe5d --- /dev/null +++ b/sqsim/scenarios/registry_test.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package scenarios + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRegistry(t *testing.T) { + assert.Equal(t, []string{"happy-path"}, Names()) + require.NoError(t, ValidateAll()) +} + +func TestBuildUnknownScenario(t *testing.T) { + _, err := Build("missing") + require.Error(t, err) +} diff --git a/sqsim/types.go b/sqsim/types.go new file mode 100644 index 00000000..81536537 --- /dev/null +++ b/sqsim/types.go @@ -0,0 +1,64 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package sqsim provides the author-facing SQSim scenario DSL. +package sqsim + +import "github.com/uber/submitqueue/sqsim/entity" + +// Public aliases keep scenario source concise while immutable data remains in entity. +type ( + Scenario = entity.Scenario + Land = entity.Land + Expectation = entity.Expectation + ExpectedRequestStatus = entity.ExpectedRequestStatus + Behavior = entity.Behavior + Fault = entity.Fault + FaultKind = entity.FaultKind + FaultPhase = entity.FaultPhase + BuildRunnerBehavior = entity.BuildRunnerBehavior + BuildTriggerOutcome = entity.BuildTriggerOutcome + BuildExecution = entity.BuildExecution + BuildStatusPoint = entity.BuildStatusPoint + FaultOnCall = entity.FaultOnCall + BuildStatus = entity.BuildStatus + MergeConflictCheckBehavior = entity.MergeConflictCheckBehavior + MergeConflictCheckOutcome = entity.MergeConflictCheckOutcome + MergeBehavior = entity.MergeBehavior + MergeOutcome = entity.MergeOutcome + MergeResult = entity.MergeResult +) + +const ( + RequestLanded = entity.RequestLanded + RequestError = entity.RequestError + RequestCancelled = entity.RequestCancelled + + FaultNone = entity.FaultNone + FaultRetryable = entity.FaultRetryable + FaultNonRetryable = entity.FaultNonRetryable + + FaultBeforeSideEffect = entity.FaultBeforeSideEffect + FaultAfterSideEffect = entity.FaultAfterSideEffect + + BuildAccepted = entity.BuildAccepted + BuildRunning = entity.BuildRunning + BuildSucceeded = entity.BuildSucceeded + BuildFailed = entity.BuildFailed + BuildCancelled = entity.BuildCancelled + + Mergeable = entity.Mergeable + MergeConflict = entity.MergeConflict + MergeSucceeded = entity.MergeSucceeded +) diff --git a/sqsim/validation.go b/sqsim/validation.go new file mode 100644 index 00000000..3035b8cb --- /dev/null +++ b/sqsim/validation.go @@ -0,0 +1,213 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sqsim + +import ( + "fmt" + "regexp" + + "github.com/uber/submitqueue/sqsim/entity" +) + +var namePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +// Validate checks a complete immutable Scenario. +func Validate(scenario Scenario) error { + if scenario.TimeoutMs <= 0 { + return fmt.Errorf("timeout must be positive") + } + if len(scenario.Lands) == 0 { + return fmt.Errorf("at least one land is required") + } + + names := make(map[string]struct{}, len(scenario.Lands)) + for i, land := range scenario.Lands { + if err := validateLand(land, scenario.TimeoutMs); err != nil { + return fmt.Errorf("land %d: %w", i, err) + } + if _, ok := names[land.Name]; ok { + return fmt.Errorf("land %d: duplicate name %q", i, land.Name) + } + names[land.Name] = struct{}{} + } + return nil +} + +func validateLand(land entity.Land, timeoutMs int64) error { + if !namePattern.MatchString(land.Name) { + return fmt.Errorf("name %q must be one URI-safe path segment", land.Name) + } + if land.Queue == "" { + return fmt.Errorf("queue is required") + } + if land.SubmitAfterMs < 0 { + return fmt.Errorf("submit delay must be non-negative") + } + if land.SubmitAfterMs >= timeoutMs { + return fmt.Errorf("submit delay must be less than scenario timeout") + } + if err := validateExpectedStatus(land.Expectation.Status); err != nil { + return err + } + return validateBehavior(land.Behavior) +} + +func validateExpectedStatus(status entity.ExpectedRequestStatus) error { + switch status { + case entity.RequestLanded, entity.RequestError, entity.RequestCancelled: + return nil + default: + return fmt.Errorf("expected terminal request status is required") + } +} + +func validateBehavior(behavior entity.Behavior) error { + if err := validateBuildRunner(behavior.BuildRunner); err != nil { + return fmt.Errorf("build runner: %w", err) + } + if err := validateMergeConflictCheck(behavior.MergeConflictCheck); err != nil { + return fmt.Errorf("merge conflict check: %w", err) + } + if err := validateMerge(behavior.Merge); err != nil { + return fmt.Errorf("merge: %w", err) + } + return nil +} + +func validateBuildRunner(behavior entity.BuildRunnerBehavior) error { + if len(behavior.Triggers) == 0 { + return fmt.Errorf("at least one trigger invocation is required") + } + for i, trigger := range behavior.Triggers { + if trigger.DelayMs < 0 { + return fmt.Errorf("trigger %d: delay must be non-negative", i) + } + if err := validateInvocationFault(trigger.Fault, true); err != nil { + return fmt.Errorf("trigger %d: %w", i, err) + } + if trigger.Fault.Phase == entity.FaultBeforeSideEffect { + continue + } + if err := validateBuildExecution(trigger.Outcome.Build); err != nil { + return fmt.Errorf("trigger %d: %w", i, err) + } + } + return nil +} + +func validateBuildExecution(build entity.BuildExecution) error { + if len(build.Timeline) == 0 { + return fmt.Errorf("build timeline is required") + } + if build.Timeline[0].AfterMs != 0 { + return fmt.Errorf("build timeline must begin at zero") + } + previous := int64(-1) + for i, point := range build.Timeline { + if point.AfterMs < 0 || point.AfterMs <= previous { + return fmt.Errorf("timeline point %d must have a strictly increasing non-negative offset", i) + } + if point.Status == "" { + return fmt.Errorf("timeline point %d status is required", i) + } + previous = point.AfterMs + } + if !build.Timeline[len(build.Timeline)-1].Status.IsTerminal() { + return fmt.Errorf("build timeline must end in a terminal status") + } + + calls := make(map[int]struct{}, len(build.StatusFaults)) + for i, fault := range build.StatusFaults { + if fault.Call <= 0 { + return fmt.Errorf("status fault %d call must be positive", i) + } + if _, ok := calls[fault.Call]; ok { + return fmt.Errorf("status fault %d duplicates call %d", i, fault.Call) + } + calls[fault.Call] = struct{}{} + if err := validateInvocationFault(fault.Fault, false); err != nil { + return fmt.Errorf("status fault %d: %w", i, err) + } + } + return nil +} + +func validateMergeConflictCheck(behavior entity.MergeConflictCheckBehavior) error { + if len(behavior.Invocations) == 0 { + return fmt.Errorf("at least one invocation is required") + } + for i, invocation := range behavior.Invocations { + if invocation.DelayMs < 0 { + return fmt.Errorf("invocation %d: delay must be non-negative", i) + } + if err := validateInvocationFault(invocation.Fault, false); err != nil { + return fmt.Errorf("invocation %d: %w", i, err) + } + if invocation.Fault.Phase == entity.FaultAfterSideEffect { + return fmt.Errorf("invocation %d: after-side-effect faults are not valid for a dry-run check", i) + } + if invocation.Fault.Kind == entity.FaultNone { + switch invocation.Outcome { + case entity.Mergeable, entity.MergeConflict: + default: + return fmt.Errorf("invocation %d outcome is required", i) + } + } + } + return nil +} + +func validateMerge(behavior entity.MergeBehavior) error { + if len(behavior.Invocations) == 0 { + return fmt.Errorf("at least one invocation is required") + } + for i, invocation := range behavior.Invocations { + if invocation.DelayMs < 0 { + return fmt.Errorf("invocation %d: delay must be non-negative", i) + } + if err := validateInvocationFault(invocation.Fault, true); err != nil { + return fmt.Errorf("invocation %d: %w", i, err) + } + if invocation.Fault.Phase != entity.FaultBeforeSideEffect && invocation.Outcome.Result != entity.MergeSucceeded { + return fmt.Errorf("invocation %d outcome is required", i) + } + } + return nil +} + +func validateInvocationFault(fault entity.Fault, allowAfterSideEffect bool) error { + if fault.Kind == entity.FaultNone { + if fault.Phase != "" { + return fmt.Errorf("fault phase requires a fault kind") + } + return nil + } + switch fault.Kind { + case entity.FaultRetryable, entity.FaultNonRetryable: + default: + return fmt.Errorf("unknown fault kind %q", fault.Kind) + } + switch fault.Phase { + case entity.FaultBeforeSideEffect: + return nil + case entity.FaultAfterSideEffect: + if allowAfterSideEffect { + return nil + } + return fmt.Errorf("after-side-effect fault is not supported") + default: + return fmt.Errorf("fault phase is required") + } +} diff --git a/sqsim/validation_test.go b/sqsim/validation_test.go new file mode 100644 index 00000000..738454f9 --- /dev/null +++ b/sqsim/validation_test.go @@ -0,0 +1,92 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sqsim + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestValidateRejectsInvalidScenario(t *testing.T) { + tests := []struct { + name string + builder *ScenarioBuilder + }{ + { + name: "no lands", + builder: NewScenario().Timeout(time.Minute), + }, + { + name: "duplicate names", + builder: NewScenario().Timeout(time.Minute).Land( + NewLand("l1").Queue("sqsim").Behavior(happyBehavior()).Expect(RequestLanded), + NewLand("l1").Queue("sqsim").Behavior(happyBehavior()).Expect(RequestLanded), + ), + }, + { + name: "build timeline does not begin at zero", + builder: NewScenario().Timeout(time.Minute).Land( + NewLand("l1"). + Queue("sqsim"). + Behavior(NewBehavior(). + BuildRunner(NewBuildRunnerBehavior().Trigger(BuildCreated( + StatusAt(time.Second, BuildRunning), + StatusAt(2*time.Second, BuildSucceeded), + ))). + MergeConflictCheck(SuccessfulMergeConflictCheck()). + Merge(SuccessfulMerge())). + Expect(RequestLanded), + ), + }, + { + name: "build timeline offsets are not increasing", + builder: NewScenario().Timeout(time.Minute).Land( + NewLand("l1"). + Queue("sqsim"). + Behavior(NewBehavior(). + BuildRunner(NewBuildRunnerBehavior().Trigger(BuildCreated( + StatusAt(0, BuildRunning), + StatusAt(0, BuildSucceeded), + ))). + MergeConflictCheck(SuccessfulMergeConflictCheck()). + Merge(SuccessfulMerge())). + Expect(RequestLanded), + ), + }, + { + name: "dry run after side effect", + builder: NewScenario().Timeout(time.Minute).Land( + NewLand("l1"). + Queue("sqsim"). + Behavior(NewBehavior(). + BuildRunner(SuccessfulBuildRunner()). + MergeConflictCheck(NewMergeConflictCheckBehavior().Invoke( + MergeConflictCheckFault(RetryableErrorAfterSideEffect()), + )). + Merge(SuccessfulMerge())). + Expect(RequestLanded), + ), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := tt.builder.Build() + require.Error(t, err) + }) + } +} diff --git a/tool/sqsim/BUILD.bazel b/tool/sqsim/BUILD.bazel new file mode 100644 index 00000000..fb7faf48 --- /dev/null +++ b/tool/sqsim/BUILD.bazel @@ -0,0 +1,22 @@ +load("@rules_go//go:def.bzl", "go_binary", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["main.go"], + importpath = "github.com/uber/submitqueue/tool/sqsim", + visibility = ["//visibility:private"], + deps = ["//sqsim/scenarios:go_default_library"], +) + +go_binary( + name = "sqsim", + embed = [":go_default_library"], + visibility = ["//visibility:public"], +) + +go_test( + name = "go_default_test", + srcs = ["main_test.go"], + embed = [":go_default_library"], + deps = ["@com_github_stretchr_testify//assert:go_default_library"], +) diff --git a/tool/sqsim/main.go b/tool/sqsim/main.go new file mode 100644 index 00000000..8d54728d --- /dev/null +++ b/tool/sqsim/main.go @@ -0,0 +1,67 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "io" + "os" + + "github.com/uber/submitqueue/sqsim/scenarios" +) + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +func run(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + printUsage(stderr) + return 2 + } + + switch args[0] { + case "list": + if len(args) != 1 { + printUsage(stderr) + return 2 + } + for _, name := range scenarios.Names() { + fmt.Fprintln(stdout, name) + } + return 0 + case "validate": + if len(args) != 2 { + printUsage(stderr) + return 2 + } + if _, err := scenarios.Build(args[1]); err != nil { + fmt.Fprintf(stderr, "invalid scenario: %v\n", err) + return 1 + } + fmt.Fprintf(stdout, "%s is valid\n", args[1]) + return 0 + default: + fmt.Fprintf(stderr, "unknown command %q\n", args[0]) + printUsage(stderr) + return 2 + } +} + +func printUsage(w io.Writer) { + fmt.Fprintln(w, "usage:") + fmt.Fprintln(w, " sqsim list") + fmt.Fprintln(w, " sqsim validate ") +} diff --git a/tool/sqsim/main_test.go b/tool/sqsim/main_test.go new file mode 100644 index 00000000..2a876f81 --- /dev/null +++ b/tool/sqsim/main_test.go @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRun(t *testing.T) { + tests := []struct { + name string + args []string + wantCode int + wantOutput string + }{ + {name: "list", args: []string{"list"}, wantCode: 0, wantOutput: "happy-path\n"}, + {name: "validate", args: []string{"validate", "happy-path"}, wantCode: 0, wantOutput: "happy-path is valid\n"}, + {name: "unknown scenario", args: []string{"validate", "missing"}, wantCode: 1}, + {name: "unknown command", args: []string{"missing"}, wantCode: 2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + code := run(tt.args, &stdout, &stderr) + assert.Equal(t, tt.wantCode, code) + if tt.wantOutput != "" { + assert.Equal(t, tt.wantOutput, stdout.String()) + } + }) + } +}