From c0f78dd9e9fc1c89e48fb72edb1a8de9c5f44afe Mon Sep 17 00:00:00 2001 From: Albert Wu Date: Wed, 15 Jul 2026 23:09:46 -0700 Subject: [PATCH] feat(sqsim): add modeled runtime primitives --- sqsim/model/BUILD.bazel | 45 +++++++++++++ sqsim/model/clock.go | 51 +++++++++++++++ sqsim/model/clock_test.go | 31 +++++++++ sqsim/model/error.go | 63 +++++++++++++++++++ sqsim/model/error_test.go | 44 +++++++++++++ sqsim/model/profile.go | 119 +++++++++++++++++++++++++++++++++++ sqsim/model/profile_test.go | 50 +++++++++++++++ sqsim/model/runtime.go | 116 ++++++++++++++++++++++++++++++++++ sqsim/model/runtime_test.go | 44 +++++++++++++ sqsim/model/sequence.go | 53 ++++++++++++++++ sqsim/model/sequence_test.go | 52 +++++++++++++++ sqsim/model/state.go | 54 ++++++++++++++++ sqsim/model/state_test.go | 32 ++++++++++ sqsim/model/timeline.go | 34 ++++++++++ sqsim/model/timeline_test.go | 35 +++++++++++ sqsim/model/uri.go | 92 +++++++++++++++++++++++++++ sqsim/model/uri_test.go | 47 ++++++++++++++ 17 files changed, 962 insertions(+) create mode 100644 sqsim/model/BUILD.bazel create mode 100644 sqsim/model/clock.go create mode 100644 sqsim/model/clock_test.go create mode 100644 sqsim/model/error.go create mode 100644 sqsim/model/error_test.go create mode 100644 sqsim/model/profile.go create mode 100644 sqsim/model/profile_test.go create mode 100644 sqsim/model/runtime.go create mode 100644 sqsim/model/runtime_test.go create mode 100644 sqsim/model/sequence.go create mode 100644 sqsim/model/sequence_test.go create mode 100644 sqsim/model/state.go create mode 100644 sqsim/model/state_test.go create mode 100644 sqsim/model/timeline.go create mode 100644 sqsim/model/timeline_test.go create mode 100644 sqsim/model/uri.go create mode 100644 sqsim/model/uri_test.go diff --git a/sqsim/model/BUILD.bazel b/sqsim/model/BUILD.bazel new file mode 100644 index 00000000..26255fd3 --- /dev/null +++ b/sqsim/model/BUILD.bazel @@ -0,0 +1,45 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = [ + "clock.go", + "error.go", + "profile.go", + "runtime.go", + "sequence.go", + "state.go", + "timeline.go", + "uri.go", + ], + importpath = "github.com/uber/submitqueue/sqsim/model", + visibility = ["//visibility:public"], + deps = [ + "//platform/errs:go_default_library", + "//sqsim:go_default_library", + "//sqsim/entity:go_default_library", + ], +) + +go_test( + name = "go_default_test", + srcs = [ + "clock_test.go", + "error_test.go", + "profile_test.go", + "runtime_test.go", + "sequence_test.go", + "state_test.go", + "timeline_test.go", + "uri_test.go", + ], + embed = [":go_default_library"], + deps = [ + "//platform/errs:go_default_library", + "//sqsim:go_default_library", + "//sqsim/entity:go_default_library", + "//sqsim/scenarios:go_default_library", + "@com_github_stretchr_testify//assert:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", + ], +) diff --git a/sqsim/model/clock.go b/sqsim/model/clock.go new file mode 100644 index 00000000..5e62285e --- /dev/null +++ b/sqsim/model/clock.go @@ -0,0 +1,51 @@ +// 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 model + +import ( + "context" + "time" +) + +// Clock supplies wall time and cancellable waits to modeled operations. +type Clock interface { + // Now returns the current wall time. + Now() time.Time + // Wait blocks for the duration or until the context is cancelled. + Wait(ctx context.Context, duration time.Duration) error +} + +// RealClock uses the process wall clock. +type RealClock struct{} + +// Now returns the current wall time. +func (RealClock) Now() time.Time { + return time.Now() +} + +// Wait blocks for the duration or until the context is cancelled. +func (RealClock) Wait(ctx context.Context, duration time.Duration) error { + if duration <= 0 { + return nil + } + timer := time.NewTimer(duration) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/sqsim/model/clock_test.go b/sqsim/model/clock_test.go new file mode 100644 index 00000000..354820c4 --- /dev/null +++ b/sqsim/model/clock_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 model + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestRealClockWaitHonorsCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := (RealClock{}).Wait(ctx, time.Hour) + require.ErrorIs(t, err, context.Canceled) +} diff --git a/sqsim/model/error.go b/sqsim/model/error.go new file mode 100644 index 00000000..cdfde590 --- /dev/null +++ b/sqsim/model/error.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 model + +import ( + "fmt" + + "github.com/uber/submitqueue/platform/errs" + "github.com/uber/submitqueue/sqsim/entity" +) + +// FaultError is an external-system failure selected by a scenario. +type FaultError struct { + // Kind identifies retryability. + Kind entity.FaultKind + // Phase identifies whether the modeled side effect happened. + Phase entity.FaultPhase +} + +// Error describes the modeled failure. +func (e *FaultError) Error() string { + return fmt.Sprintf("sqsim modeled %s fault %s", e.Kind, e.Phase) +} + +// ErrorForFault returns the typed error selected by fault. +func ErrorForFault(fault entity.Fault) error { + if fault.Kind == entity.FaultNone { + return nil + } + return &FaultError{Kind: fault.Kind, Phase: fault.Phase} +} + +// Classifier classifies modeled external-system errors. +var Classifier errs.Classifier = classifier{} + +type classifier struct{} + +func (classifier) Classify(err error) errs.Verdict { + fault, ok := err.(*FaultError) + if !ok { + return errs.Unknown + } + switch fault.Kind { + case entity.FaultRetryable: + return errs.InfraDependencyRetryable + case entity.FaultNonRetryable: + return errs.InfraDependency + default: + return errs.Unknown + } +} diff --git a/sqsim/model/error_test.go b/sqsim/model/error_test.go new file mode 100644 index 00000000..93733fd5 --- /dev/null +++ b/sqsim/model/error_test.go @@ -0,0 +1,44 @@ +// 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 model + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber/submitqueue/platform/errs" + "github.com/uber/submitqueue/sqsim/entity" +) + +func TestClassifierClassifiesModeledFaults(t *testing.T) { + tests := []struct { + name string + kind entity.FaultKind + retryable bool + }{ + {name: "retryable", kind: entity.FaultRetryable, retryable: true}, + {name: "non-retryable", kind: entity.FaultNonRetryable, retryable: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw := ErrorForFault(entity.Fault{Kind: tt.kind, Phase: entity.FaultBeforeSideEffect}) + require.Error(t, raw) + classified := errs.NewClassifierProcessor(Classifier).Process(raw) + assert.Equal(t, tt.retryable, errs.IsRetryable(classified)) + assert.True(t, errs.IsDependencyError(classified)) + }) + } +} diff --git a/sqsim/model/profile.go b/sqsim/model/profile.go new file mode 100644 index 00000000..5fb6a4f3 --- /dev/null +++ b/sqsim/model/profile.go @@ -0,0 +1,119 @@ +// 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 model + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/uber/submitqueue/sqsim" + "github.com/uber/submitqueue/sqsim/entity" +) + +// Profile is the immutable scenario input shared with SQSim adapters. +type Profile struct { + // Name is the public scenario registry name. + Name string `json:"name"` + // Scenario is the workload and modeled external behavior. + Scenario entity.Scenario `json:"scenario"` +} + +// Compile validates a named scenario and returns its runtime profile. +func Compile(name string, scenario sqsim.Scenario) (Profile, error) { + if err := validateName("scenario", name); err != nil { + return Profile{}, err + } + if err := sqsim.Validate(scenario); err != nil { + return Profile{}, fmt.Errorf("validate scenario: %w", err) + } + return cloneProfile(Profile{Name: name, Scenario: scenario}), nil +} + +// Write writes a profile as JSON to path. +func Write(path string, profile Profile) error { + if path == "" { + return fmt.Errorf("profile path is required") + } + if err := validateProfile(profile); err != nil { + return err + } + data, err := json.MarshalIndent(profile, "", " ") + if err != nil { + return fmt.Errorf("marshal profile: %w", err) + } + data = append(data, '\n') + if err := os.WriteFile(path, data, 0o600); err != nil { + return fmt.Errorf("write profile: %w", err) + } + return nil +} + +// Load reads and validates a profile from path. +func Load(path string) (Profile, error) { + if path == "" { + return Profile{}, fmt.Errorf("profile path is required") + } + data, err := os.ReadFile(path) + if err != nil { + return Profile{}, fmt.Errorf("read profile: %w", err) + } + var profile Profile + if err := json.Unmarshal(data, &profile); err != nil { + return Profile{}, fmt.Errorf("decode profile: %w", err) + } + if err := validateProfile(profile); err != nil { + return Profile{}, err + } + return cloneProfile(profile), nil +} + +func validateProfile(profile Profile) error { + if err := validateName("scenario", profile.Name); err != nil { + return err + } + if err := sqsim.Validate(profile.Scenario); err != nil { + return fmt.Errorf("validate scenario: %w", err) + } + return nil +} + +func cloneProfile(profile Profile) Profile { + lands := make([]entity.Land, len(profile.Scenario.Lands)) + for i, land := range profile.Scenario.Lands { + lands[i] = cloneLand(land) + } + profile.Scenario.Lands = lands + return profile +} + +func cloneLand(land entity.Land) entity.Land { + triggers := make([]entity.Invocation[entity.BuildTriggerOutcome], len(land.Behavior.BuildRunner.Triggers)) + for i, trigger := range land.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 + } + land.Behavior.BuildRunner.Triggers = triggers + land.Behavior.MergeConflictCheck.Invocations = append( + []entity.Invocation[entity.MergeConflictCheckOutcome](nil), + land.Behavior.MergeConflictCheck.Invocations..., + ) + land.Behavior.Merge.Invocations = append( + []entity.Invocation[entity.MergeOutcome](nil), + land.Behavior.Merge.Invocations..., + ) + return land +} diff --git a/sqsim/model/profile_test.go b/sqsim/model/profile_test.go new file mode 100644 index 00000000..12f5e64e --- /dev/null +++ b/sqsim/model/profile_test.go @@ -0,0 +1,50 @@ +// 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 model + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber/submitqueue/sqsim" + "github.com/uber/submitqueue/sqsim/scenarios" +) + +func TestProfileWriteLoadRoundTrip(t *testing.T) { + scenario, err := scenarios.HappyPath() + require.NoError(t, err) + profile, err := Compile("happy-path", scenario) + require.NoError(t, err) + + path := filepath.Join(t.TempDir(), "profile.json") + require.NoError(t, Write(path, profile)) + + loaded, err := Load(path) + require.NoError(t, err) + assert.Equal(t, profile, loaded) + + loaded.Scenario.Lands[0].Behavior.BuildRunner.Triggers[0].Outcome.Build.Timeline[0].Status = sqsim.BuildFailed + assert.Equal(t, sqsim.BuildAccepted, profile.Scenario.Lands[0].Behavior.BuildRunner.Triggers[0].Outcome.Build.Timeline[0].Status) +} + +func TestCompileRejectsInvalidName(t *testing.T) { + scenario, err := scenarios.HappyPath() + require.NoError(t, err) + + _, err = Compile("not/a/name", scenario) + require.Error(t, err) +} diff --git a/sqsim/model/runtime.go b/sqsim/model/runtime.go new file mode 100644 index 00000000..0df06e9e --- /dev/null +++ b/sqsim/model/runtime.go @@ -0,0 +1,116 @@ +// 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 model interprets immutable SQSim profiles for external-system adapters. +package model + +import ( + "fmt" + + "github.com/uber/submitqueue/sqsim/entity" +) + +// Runtime owns invocation cursors for one adapter process. +type Runtime struct { + profile Profile + lands map[string]*landRuntime + clock Clock +} + +type landRuntime struct { + land entity.Land + buildTriggers *Sequence[entity.Invocation[entity.BuildTriggerOutcome]] + mergeConflictChecks *Sequence[entity.Invocation[entity.MergeConflictCheckOutcome]] + merges *Sequence[entity.Invocation[entity.MergeOutcome]] +} + +// NewRuntime validates a profile and initializes its invocation cursors. +func NewRuntime(profile Profile, clock Clock) (*Runtime, error) { + if err := validateProfile(profile); err != nil { + return nil, err + } + if clock == nil { + return nil, fmt.Errorf("clock is required") + } + runtime := &Runtime{ + profile: cloneProfile(profile), + lands: make(map[string]*landRuntime, len(profile.Scenario.Lands)), + clock: clock, + } + for _, land := range runtime.profile.Scenario.Lands { + runtime.lands[land.Name] = &landRuntime{ + land: cloneLand(land), + buildTriggers: NewSequence(land.Behavior.BuildRunner.Triggers), + mergeConflictChecks: NewSequence(land.Behavior.MergeConflictCheck.Invocations), + merges: NewSequence(land.Behavior.Merge.Invocations), + } + } + return runtime, nil +} + +// Clock returns the runtime clock. +func (r *Runtime) Clock() Clock { + return r.clock +} + +// Resolve returns the immutable Land selected by a synthetic change URI. +func (r *Runtime) Resolve(changeURI string) (entity.Land, error) { + land, err := r.resolve(changeURI) + if err != nil { + return entity.Land{}, err + } + return cloneLand(land.land), nil +} + +// NextBuildTrigger consumes the next Build Runner Trigger invocation. +func (r *Runtime) NextBuildTrigger(changeURI string) (entity.Invocation[entity.BuildTriggerOutcome], error) { + land, err := r.resolve(changeURI) + if err != nil { + return entity.Invocation[entity.BuildTriggerOutcome]{}, err + } + return land.buildTriggers.Next() +} + +// NextMergeConflictCheck consumes the next merge-conflict-check invocation. +func (r *Runtime) NextMergeConflictCheck(changeURI string) (entity.Invocation[entity.MergeConflictCheckOutcome], error) { + land, err := r.resolve(changeURI) + if err != nil { + return entity.Invocation[entity.MergeConflictCheckOutcome]{}, err + } + return land.mergeConflictChecks.Next() +} + +// NextMerge consumes the next committing Merge invocation. +func (r *Runtime) NextMerge(changeURI string) (entity.Invocation[entity.MergeOutcome], error) { + land, err := r.resolve(changeURI) + if err != nil { + return entity.Invocation[entity.MergeOutcome]{}, err + } + return land.merges.Next() +} + +func (r *Runtime) resolve(changeURI string) (*landRuntime, error) { + ref, err := ParseChangeURI(changeURI) + if err != nil { + return nil, err + } + if ref.Scenario != r.profile.Name { + return nil, fmt.Errorf("change URI scenario %q does not match profile %q", ref.Scenario, r.profile.Name) + } + land, ok := r.lands[ref.Land] + if !ok { + return nil, fmt.Errorf("land %q is not present in scenario %q", ref.Land, ref.Scenario) + } + return land, nil +} diff --git a/sqsim/model/runtime_test.go b/sqsim/model/runtime_test.go new file mode 100644 index 00000000..61454998 --- /dev/null +++ b/sqsim/model/runtime_test.go @@ -0,0 +1,44 @@ +// 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 model + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber/submitqueue/sqsim" + "github.com/uber/submitqueue/sqsim/scenarios" +) + +func TestRuntimeResolvesLandAndConsumesInvocations(t *testing.T) { + scenario, err := scenarios.HappyPath() + require.NoError(t, err) + profile, err := Compile("happy-path", scenario) + require.NoError(t, err) + runtime, err := NewRuntime(profile, RealClock{}) + require.NoError(t, err) + + land, err := runtime.Resolve("sqsim://local/happy-path/l1") + require.NoError(t, err) + assert.Equal(t, "l1", land.Name) + + trigger, err := runtime.NextBuildTrigger("sqsim://local/happy-path/l1") + require.NoError(t, err) + assert.Equal(t, sqsim.BuildAccepted, trigger.Outcome.Build.Timeline[0].Status) + + _, err = runtime.NextBuildTrigger("sqsim://local/happy-path/l1") + require.Error(t, err) +} diff --git a/sqsim/model/sequence.go b/sqsim/model/sequence.go new file mode 100644 index 00000000..722874f7 --- /dev/null +++ b/sqsim/model/sequence.go @@ -0,0 +1,53 @@ +// 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 model + +import ( + "fmt" + "sync" +) + +// Sequence consumes modeled invocations in declaration order. +type Sequence[T any] struct { + mu sync.Mutex + values []T + next int +} + +// NewSequence returns a thread-safe sequence containing a copy of values. +func NewSequence[T any](values []T) *Sequence[T] { + return &Sequence[T]{values: append([]T(nil), values...)} +} + +// Next consumes and returns the next value. +func (s *Sequence[T]) Next() (T, error) { + s.mu.Lock() + defer s.mu.Unlock() + + var zero T + if s.next >= len(s.values) { + return zero, fmt.Errorf("modeled invocation sequence exhausted") + } + value := s.values[s.next] + s.next++ + return value, nil +} + +// Consumed returns the number of values consumed. +func (s *Sequence[T]) Consumed() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.next +} diff --git a/sqsim/model/sequence_test.go b/sqsim/model/sequence_test.go new file mode 100644 index 00000000..3d71c738 --- /dev/null +++ b/sqsim/model/sequence_test.go @@ -0,0 +1,52 @@ +// 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 model + +import ( + "sort" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSequenceConsumesValuesOnceAcrossConcurrentCallers(t *testing.T) { + sequence := NewSequence([]int{1, 2, 3}) + results := make(chan int, 3) + var wg sync.WaitGroup + for range 3 { + wg.Add(1) + go func() { + defer wg.Done() + value, err := sequence.Next() + require.NoError(t, err) + results <- value + }() + } + wg.Wait() + close(results) + + var got []int + for result := range results { + got = append(got, result) + } + sort.Ints(got) + assert.Equal(t, []int{1, 2, 3}, got) + assert.Equal(t, 3, sequence.Consumed()) + + _, err := sequence.Next() + require.Error(t, err) +} diff --git a/sqsim/model/state.go b/sqsim/model/state.go new file mode 100644 index 00000000..0013225e --- /dev/null +++ b/sqsim/model/state.go @@ -0,0 +1,54 @@ +// 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 model + +import "sync" + +// State stores ephemeral modeled execution results by production operation ID. +type State[T any] struct { + mu sync.Mutex + values map[string]T +} + +// NewState returns an empty thread-safe state store. +func NewState[T any]() *State[T] { + return &State[T]{values: make(map[string]T)} +} + +// Get returns the value stored for key. +func (s *State[T]) Get(key string) (T, bool) { + s.mu.Lock() + defer s.mu.Unlock() + value, ok := s.values[key] + return value, ok +} + +// PutIfAbsent stores value when key is absent and returns the stored value. +func (s *State[T]) PutIfAbsent(key string, value T) (T, bool) { + s.mu.Lock() + defer s.mu.Unlock() + if existing, ok := s.values[key]; ok { + return existing, false + } + s.values[key] = value + return value, true +} + +// Set stores value for key. +func (s *State[T]) Set(key string, value T) { + s.mu.Lock() + defer s.mu.Unlock() + s.values[key] = value +} diff --git a/sqsim/model/state_test.go b/sqsim/model/state_test.go new file mode 100644 index 00000000..54a6f76b --- /dev/null +++ b/sqsim/model/state_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 model + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStatePutIfAbsentRetainsFirstValue(t *testing.T) { + state := NewState[int]() + value, inserted := state.PutIfAbsent("operation", 1) + assert.True(t, inserted) + assert.Equal(t, 1, value) + + value, inserted = state.PutIfAbsent("operation", 2) + assert.False(t, inserted) + assert.Equal(t, 1, value) +} diff --git a/sqsim/model/timeline.go b/sqsim/model/timeline.go new file mode 100644 index 00000000..03598aaf --- /dev/null +++ b/sqsim/model/timeline.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 model + +import ( + "time" + + "github.com/uber/submitqueue/sqsim/entity" +) + +// BuildStatusAt returns the modeled build status visible at elapsed wall time. +func BuildStatusAt(timeline []entity.BuildStatusPoint, elapsed time.Duration) entity.BuildStatus { + status := timeline[0].Status + elapsedMs := elapsed.Milliseconds() + for _, point := range timeline[1:] { + if point.AfterMs > elapsedMs { + break + } + status = point.Status + } + return status +} diff --git a/sqsim/model/timeline_test.go b/sqsim/model/timeline_test.go new file mode 100644 index 00000000..d1271f42 --- /dev/null +++ b/sqsim/model/timeline_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 model + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/uber/submitqueue/sqsim/entity" +) + +func TestBuildStatusAtUsesLatestVisiblePoint(t *testing.T) { + timeline := []entity.BuildStatusPoint{ + {AfterMs: 0, Status: entity.BuildAccepted}, + {AfterMs: 1000, Status: entity.BuildRunning}, + {AfterMs: 5000, Status: entity.BuildSucceeded}, + } + + assert.Equal(t, entity.BuildAccepted, BuildStatusAt(timeline, 500*time.Millisecond)) + assert.Equal(t, entity.BuildRunning, BuildStatusAt(timeline, 2*time.Second)) + assert.Equal(t, entity.BuildSucceeded, BuildStatusAt(timeline, 5*time.Second)) +} diff --git a/sqsim/model/uri.go b/sqsim/model/uri.go new file mode 100644 index 00000000..67f0dc08 --- /dev/null +++ b/sqsim/model/uri.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 model + +import ( + "fmt" + "net/url" + "regexp" + "strings" +) + +const ( + uriScheme = "sqsim" + uriHost = "local" +) + +var namePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +// Reference identifies one Land in one named scenario. +type Reference struct { + // Scenario is the public scenario registry name. + Scenario string + // Land is the Land name within the scenario. + Land string +} + +// ChangeURI returns the synthetic change URI for one scenario Land. +func ChangeURI(scenario, land string) (string, error) { + if err := validateName("scenario", scenario); err != nil { + return "", err + } + if err := validateName("land", land); err != nil { + return "", err + } + return (&url.URL{ + Scheme: uriScheme, + Host: uriHost, + Path: "/" + scenario + "/" + land, + }).String(), nil +} + +// ParseChangeURI parses an SQSim synthetic change URI. +func ParseChangeURI(raw string) (Reference, error) { + parsed, err := url.Parse(raw) + if err != nil { + return Reference{}, fmt.Errorf("parse change URI: %w", err) + } + if parsed.Scheme != uriScheme || parsed.Host != uriHost || parsed.User != nil { + return Reference{}, fmt.Errorf("change URI must use %s://%s", uriScheme, uriHost) + } + if parsed.RawQuery != "" || parsed.Fragment != "" { + return Reference{}, fmt.Errorf("change URI must not contain a query or fragment") + } + segments := strings.Split(strings.TrimPrefix(parsed.EscapedPath(), "/"), "/") + if len(segments) != 2 { + return Reference{}, fmt.Errorf("change URI path must contain scenario and land") + } + scenario, err := url.PathUnescape(segments[0]) + if err != nil { + return Reference{}, fmt.Errorf("decode scenario: %w", err) + } + land, err := url.PathUnescape(segments[1]) + if err != nil { + return Reference{}, fmt.Errorf("decode land: %w", err) + } + if err := validateName("scenario", scenario); err != nil { + return Reference{}, err + } + if err := validateName("land", land); err != nil { + return Reference{}, err + } + return Reference{Scenario: scenario, Land: land}, nil +} + +func validateName(kind, name string) error { + if !namePattern.MatchString(name) { + return fmt.Errorf("%s name %q must be one URI-safe path segment", kind, name) + } + return nil +} diff --git a/sqsim/model/uri_test.go b/sqsim/model/uri_test.go new file mode 100644 index 00000000..131234bf --- /dev/null +++ b/sqsim/model/uri_test.go @@ -0,0 +1,47 @@ +// 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 model + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestChangeURIParseRoundTrip(t *testing.T) { + uri, err := ChangeURI("happy-path", "l1") + require.NoError(t, err) + assert.Equal(t, "sqsim://local/happy-path/l1", uri) + + ref, err := ParseChangeURI(uri) + require.NoError(t, err) + assert.Equal(t, Reference{Scenario: "happy-path", Land: "l1"}, ref) +} + +func TestParseChangeURIRejectsUnexpectedShape(t *testing.T) { + tests := []string{ + "https://local/happy-path/l1", + "sqsim://other/happy-path/l1", + "sqsim://local/happy-path", + "sqsim://local/happy-path/l1?attempt=2", + } + for _, raw := range tests { + t.Run(raw, func(t *testing.T) { + _, err := ParseChangeURI(raw) + require.Error(t, err) + }) + } +}