Skip to content

Commit 354212a

Browse files
committed
feat(stovepipe): [DRAFT] apply queue transitions and materialize history
Summary: Queue enablement changes must preserve coordination fields and remain recoverable when history or summary writes fail. Add an internal TransitionController with Initialize, Prepare and Apply, plus queuelog.Materializer.PersistLog. Retain the previous occurrence before replacing internal enablement and materialize the committed winner afterward. Prepared retries preserve the accepted boundary; optimistic locking handles operational races. Startup registers configured queues as disabled without overwriting registered enablement. Part 3 of 4, built on state and read-model storage. Configuration observation, administration, pause and execution gating remain deferred. Test Plan: Unit, service integration and end-to-end tests cover competing decisions, operational races, projection races and failed writes. Run make local-stovepipe-start for local startup with schemas applied before registration. Changing enablement uses the internal transition controller; this layer adds no mutation RPC or CLI.
1 parent 6a9541c commit 354212a

18 files changed

Lines changed: 1394 additions & 12 deletions

File tree

‎Makefile‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -574,9 +574,10 @@ local-stop: ## Stop every local stack — SubmitQueue, Stovepipe, and Runway
574574

575575
local-stovepipe-debug-start: build-stovepipe-linux-debug ## Start Stovepipe under delve in Docker (attach IDE to :2345)
576576
@echo "Starting Stovepipe service with compose (debug)..."
577-
@$(COMPOSE) -f $(STOVEPIPE_COMPOSE_FILE) -f $(STOVEPIPE_DEBUG_COMPOSE_FILE) -p $(STOVEPIPE_LOCAL_PROJECT) up -d --build --wait
577+
@$(COMPOSE) -f $(STOVEPIPE_COMPOSE_FILE) -f $(STOVEPIPE_DEBUG_COMPOSE_FILE) -p $(STOVEPIPE_LOCAL_PROJECT) up -d --build --wait mysql-app mysql-queue
578578
@echo "Applying storage and queue schemas..."
579579
@$(MAKE) -s local-init-stovepipe-schemas
580+
@$(COMPOSE) -f $(STOVEPIPE_COMPOSE_FILE) -f $(STOVEPIPE_DEBUG_COMPOSE_FILE) -p $(STOVEPIPE_LOCAL_PROJECT) up -d --build --wait
580581
@echo ""
581582
@echo "✅ Stovepipe debug is running (delve :2345)!"
582583
@echo ""
@@ -590,9 +591,10 @@ local-stovepipe-logs: ## View logs from the running Stovepipe service
590591

591592
local-stovepipe-start: build-stovepipe-linux ## Start Stovepipe service (gRPC service + MySQL storage + MySQL queue)
592593
@echo "Starting Stovepipe service with compose..."
593-
@$(COMPOSE) -f $(STOVEPIPE_COMPOSE_FILE) -p $(STOVEPIPE_LOCAL_PROJECT) up -d --build --wait
594+
@$(COMPOSE) -f $(STOVEPIPE_COMPOSE_FILE) -p $(STOVEPIPE_LOCAL_PROJECT) up -d --build --wait mysql-app mysql-queue
594595
@echo "Applying storage and queue schemas..."
595596
@$(MAKE) -s local-init-stovepipe-schemas
597+
@$(COMPOSE) -f $(STOVEPIPE_COMPOSE_FILE) -p $(STOVEPIPE_LOCAL_PROJECT) up -d --build --wait
596598
@echo ""
597599
@echo "✅ Stovepipe service is running!"
598600
@echo ""

‎doc/rfc/stovepipe/queue-state.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,18 @@ The existing `queue` row is the authoritative internal snapshot. It contains app
2020

2121
Storage operations are primary-key reads, immutable creates and conditional complete replacements. Controllers compute new versions; stores write supplied old/new versions without arithmetic. Fresh installations use the schema files. Existing installations apply the [queue-column migration](../../../stovepipe/extension/storage/mysql/migrations/queue_enablement.sql) once and create the summary and log tables before deploying these writers.
2222

23+
## Transitions and materialization
24+
25+
`queuestate.TransitionController` explicitly registers queue enablement, prepares accepted decisions and conditionally applies them. Registration preserves operational fields and existing enablement on restart. An existing row without registered enablement is explicitly initialized as disabled; migrating an already enabled queue requires an agreed activation boundary before offering accurate status lookup.
26+
27+
Preparation captures the current enablement occurrence, resolves the immutable tip once through the existing source-control integration and samples the application timestamp. Retries reuse this prepared value. Application checks lineage without replacing the captured boundary. Same-state decisions are no-ops. Configuration freshness remains the decision source's responsibility.
28+
29+
`queuelog.Materializer.PersistLog` idempotently retains a committed occurrence, then creates or advances its queue summary. Older occurrences never regress the summary; matching versions must agree. Duplicate log identities require identical stable content, including the captured timestamp and boundary. Conflicting content is a consistency failure. Projection races reload and converge under optimistic locking.
30+
31+
Before replacing enablement, the transition controller materializes the current occurrence. It then replaces the internal queue under its row-version guard and materializes the winning occurrence. This ordering preserves every applied boundary: after a crash between the internal write and history retention, the queue contains the complete missing occurrence, which must be repaired before the next enablement change. Initialization, preparation and application retries repair history and summary; lookup remains read-only.
32+
33+
A history or summary failure is reported even if internal state committed. Retrying the same prepared value preserves the committed boundary and timestamp. Uncertain writes reconcile against internal state or retained history. Competing stale enablement decisions are rejected. A conflict caused only by operational updates can retry against the latest queue row while preserving the accepted enablement decision and all coordination fields.
34+
2335
## Error semantics
2436

2537
The endpoint returns `INVALID_ARGUMENT` for malformed selectors, `NOT_FOUND` when no queue summary exists, `FAILED_PRECONDITION` for unresolved commits or unsupported lineage, and `INTERNAL` for inconsistent history or projections. Dependency and cancellation failures preserve their identity for transport classification. A supplied but unresolved commit fails the entire call.

‎service/stovepipe/docker-compose.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
# Quick start:
1515
# make local-stovepipe-start
1616
#
17-
# After `up`, the storage and queue schemas are applied (local-init-stovepipe-schemas).
17+
# Start the databases, apply schemas (local-init-stovepipe-schemas), then start the service.
1818

1919
services:
2020
# Storage database - Stovepipe's request and request_uri tables.

‎service/stovepipe/server/BUILD.bazel‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ go_library(
3131
"//stovepipe/controller/process:go_default_library",
3232
"//stovepipe/controller/record:go_default_library",
3333
"//stovepipe/core/messagequeue:go_default_library",
34+
"//stovepipe/core/queuelog:go_default_library",
35+
"//stovepipe/core/queuestate:go_default_library",
3436
"//stovepipe/core/requestlog:go_default_library",
3537
"//stovepipe/extension/buildrunner:go_default_library",
3638
"//stovepipe/extension/buildrunner/fake:go_default_library",

‎service/stovepipe/server/main.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ import (
5353
"github.com/uber/submitqueue/stovepipe/controller/process"
5454
"github.com/uber/submitqueue/stovepipe/controller/record"
5555
stovepipemq "github.com/uber/submitqueue/stovepipe/core/messagequeue"
56+
"github.com/uber/submitqueue/stovepipe/core/queuelog"
57+
"github.com/uber/submitqueue/stovepipe/core/queuestate"
5658
"github.com/uber/submitqueue/stovepipe/core/requestlog"
5759
"github.com/uber/submitqueue/stovepipe/extension/buildrunner"
5860
buildrunnerfake "github.com/uber/submitqueue/stovepipe/extension/buildrunner/fake"
@@ -245,6 +247,12 @@ func run() error {
245247
brf := fakeBuildRunnerFactory{}
246248

247249
storageFty := storageFactory{backend: store}
250+
stateTransitions := queuestate.NewTransitionController(storageFty, sourceControl, queuelog.NewMaterializer(scope))
251+
for _, queue := range tenants {
252+
if _, err := stateTransitions.Initialize(ctx, queue); err != nil {
253+
return fmt.Errorf("initialize queue enablement %q: %w", queue, err)
254+
}
255+
}
248256
materializer := requestlog.NewMaterializer(scope)
249257
primaryCount, err := registerPrimaryControllers(primaryConsumer, logger.Sugar(), scope, storageFty, materializer, registry, sourceControl, brf, hookResolver{})
250258
if err != nil {
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "go_default_library",
5+
srcs = [
6+
"materializer.go",
7+
"queue_summary.go",
8+
],
9+
importpath = "github.com/uber/submitqueue/stovepipe/core/queuelog",
10+
visibility = [
11+
"//service/stovepipe:__subpackages__",
12+
"//stovepipe:__subpackages__",
13+
"//test:__subpackages__",
14+
],
15+
deps = [
16+
"//platform/metrics:go_default_library",
17+
"//stovepipe/entity:go_default_library",
18+
"//stovepipe/extension/storage:go_default_library",
19+
"@com_github_uber_go_tally//:go_default_library",
20+
],
21+
)
22+
23+
go_test(
24+
name = "go_default_test",
25+
srcs = ["materializer_test.go"],
26+
embed = [":go_default_library"],
27+
deps = [
28+
"//stovepipe/entity:go_default_library",
29+
"//stovepipe/extension/storage:go_default_library",
30+
"//stovepipe/extension/storage/mock:go_default_library",
31+
"@com_github_stretchr_testify//require:go_default_library",
32+
"@com_github_uber_go_tally//:go_default_library",
33+
"@org_uber_go_mock//gomock:go_default_library",
34+
],
35+
)
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Copyright (c) 2026 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Package queuelog retains queue history and projects its current status summary.
16+
package queuelog
17+
18+
import (
19+
"context"
20+
"errors"
21+
"fmt"
22+
"math"
23+
24+
"github.com/uber-go/tally"
25+
"github.com/uber/submitqueue/platform/metrics"
26+
"github.com/uber/submitqueue/stovepipe/entity"
27+
"github.com/uber/submitqueue/stovepipe/extension/storage"
28+
)
29+
30+
// ErrInconsistentHistory identifies missing or conflicting committed occurrences or projections.
31+
var ErrInconsistentHistory = errors.New("inconsistent queue history")
32+
33+
// Materializer retains committed queue occurrences and advances their derived summary.
34+
type Materializer interface {
35+
// PersistLog idempotently retains an occurrence, then projects its state.
36+
PersistLog(context.Context, storage.Storage, entity.QueueLog) error
37+
}
38+
39+
type materializer struct{ scope tally.Scope }
40+
41+
// NewMaterializer creates a queue history materializer with bounded outcome metrics.
42+
func NewMaterializer(scope tally.Scope) Materializer {
43+
return &materializer{scope: scope.SubScope("queue_log_materializer")}
44+
}
45+
46+
// NewQueueStateLog represents the committed enablement occurrence in an internal queue snapshot.
47+
func NewQueueStateLog(queue entity.Queue) entity.QueueLog {
48+
return entity.QueueLog{ID: entity.QueueLogID(queue.EnablementVersion), Queue: queue.Name,
49+
State: queue.Enablement, QueueStateVersion: queue.EnablementVersion,
50+
EffectiveFromCommitURI: queue.EffectiveFromCommitURI, TimestampMs: queue.EnablementChangedAtMs}
51+
}
52+
53+
func (m *materializer) PersistLog(ctx context.Context, stores storage.Storage, log entity.QueueLog) (retErr error) {
54+
op := metrics.Begin(m.scope, "persist_log", metrics.StorageLatencyBuckets, metrics.TagsFromContext(ctx)...)
55+
defer func() { op.Complete(retErr) }()
56+
if err := ctx.Err(); err != nil {
57+
return err
58+
}
59+
if err := log.Validate(); err != nil {
60+
return fmt.Errorf("%w: %v", ErrInconsistentHistory, err)
61+
}
62+
logs := stores.GetQueueLogStore()
63+
if err := logs.Create(ctx, log); err != nil {
64+
if !errors.Is(err, storage.ErrAlreadyExists) {
65+
return fmt.Errorf("retain queue log: %w", err)
66+
}
67+
retained, err := logs.Get(ctx, log.ID)
68+
if err != nil {
69+
return fmt.Errorf("reconcile queue log: %w", err)
70+
}
71+
if retained != log {
72+
return ErrInconsistentHistory
73+
}
74+
log = retained
75+
}
76+
return materializeQueueSummary(ctx, stores.GetQueueSummaryStore(), log)
77+
}
78+
79+
func materializeQueueSummary(ctx context.Context, store storage.QueueSummaryStore, log entity.QueueLog) error {
80+
for {
81+
if err := ctx.Err(); err != nil {
82+
return err
83+
}
84+
current, err := store.Get(ctx)
85+
if storage.IsNotFound(err) {
86+
initial := entity.QueueSummary{Queue: log.Queue, State: log.State,
87+
QueueStateVersion: log.QueueStateVersion, EffectiveFromCommitURI: log.EffectiveFromCommitURI,
88+
StateTimestampMs: log.TimestampMs, Version: 1}
89+
if err := store.Create(ctx, initial); errors.Is(err, storage.ErrAlreadyExists) {
90+
continue
91+
} else {
92+
return err
93+
}
94+
}
95+
if err != nil {
96+
return err
97+
}
98+
if current.Queue != log.Queue {
99+
return ErrInconsistentHistory
100+
}
101+
if err := current.Validate(); err != nil {
102+
return fmt.Errorf("%w: %v", ErrInconsistentHistory, err)
103+
}
104+
if log.QueueStateVersion < current.QueueStateVersion {
105+
return nil
106+
}
107+
if log.QueueStateVersion == current.QueueStateVersion {
108+
if current.Snapshot() != log.Snapshot() {
109+
return ErrInconsistentHistory
110+
}
111+
return nil
112+
}
113+
if current.Version == math.MaxInt64 {
114+
return ErrInconsistentHistory
115+
}
116+
updated := current
117+
updated.State, updated.QueueStateVersion = log.State, log.QueueStateVersion
118+
updated.EffectiveFromCommitURI, updated.StateTimestampMs = log.EffectiveFromCommitURI, log.TimestampMs
119+
newVersion := current.Version + 1
120+
if err := store.Update(ctx, updated, current.Version, newVersion); errors.Is(err, storage.ErrVersionMismatch) {
121+
continue
122+
} else if err != nil {
123+
return err
124+
}
125+
return nil
126+
}
127+
}

0 commit comments

Comments
 (0)