Skip to content

Commit d33fdad

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 f5c6597 commit d33fdad

17 files changed

Lines changed: 1382 additions & 12 deletions

File tree

‎Makefile‎

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

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

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

‎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)