Skip to content

Commit e90d383

Browse files
authored
feat(stovepipe): apply queue transitions and materialize history (#856)
## Why? Queue enablement changes must preserve coordination fields and remain recoverable when history or summary writes fail. ## What? 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. ## 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. ## Revert Plan Revert this layer together with any dependent layers in the stack. ## Issue Links None — this is a standalone queue-status proposal. ## Stack Review in this order; each PR targets the preceding branch. 1. [API and RFC](#854) 2. [Storage](#855) 3. [Transitions and materialization](#856) (this PR) 4. [Lookup](#857)
1 parent 1f6af7e commit e90d383

19 files changed

Lines changed: 1423 additions & 20 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: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
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 queue history or summaries.
31+
var ErrInconsistentHistory = errors.New("inconsistent queue history")
32+
33+
// Materializer retains committed state changes and updates their 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+
func NewMaterializer(scope tally.Scope) Materializer {
42+
return &materializer{scope: scope.SubScope("queue_log_materializer")}
43+
}
44+
45+
func NewQueueStateLog(queue entity.Queue) entity.QueueLog {
46+
return entity.QueueLog{ID: entity.QueueLogID(queue.EnablementVersion), Queue: queue.Name,
47+
State: queue.Enablement, QueueStateVersion: queue.EnablementVersion,
48+
EffectiveFromCommitURI: queue.EffectiveFromCommitURI, TimestampMs: queue.EnablementChangedAtMs}
49+
}
50+
51+
func (m *materializer) PersistLog(ctx context.Context, stores storage.Storage, log entity.QueueLog) (retErr error) {
52+
op := metrics.Begin(m.scope, "persist_log", metrics.StorageLatencyBuckets, metrics.TagsFromContext(ctx)...)
53+
defer func() { op.Complete(retErr) }()
54+
if err := ctx.Err(); err != nil {
55+
return err
56+
}
57+
if err := log.Validate(); err != nil {
58+
return fmt.Errorf("queue %q: invalid log %q at state version %d: %w: %v", log.Queue, log.ID, log.QueueStateVersion, ErrInconsistentHistory, err)
59+
}
60+
logs := stores.GetQueueLogStore()
61+
if err := logs.Create(ctx, log); err != nil {
62+
if !errors.Is(err, storage.ErrAlreadyExists) {
63+
return fmt.Errorf("retain queue log: %w", err)
64+
}
65+
retained, err := logs.Get(ctx, log.ID)
66+
if err != nil {
67+
return fmt.Errorf("reconcile queue log: %w", err)
68+
}
69+
if retained != log {
70+
return fmt.Errorf("queue %q: log %q at state version %d conflicts with retained log %q for queue %q at state version %d: %w", log.Queue, log.ID, log.QueueStateVersion, retained.ID, retained.Queue, retained.QueueStateVersion, ErrInconsistentHistory)
71+
}
72+
log = retained
73+
}
74+
return materializeQueueSummary(ctx, stores.GetQueueSummaryStore(), log)
75+
}
76+
77+
func materializeQueueSummary(ctx context.Context, store storage.QueueSummaryStore, log entity.QueueLog) error {
78+
for {
79+
if err := ctx.Err(); err != nil {
80+
return err
81+
}
82+
current, err := store.Get(ctx)
83+
if storage.IsNotFound(err) {
84+
initial := entity.QueueSummary{Queue: log.Queue, State: log.State,
85+
QueueStateVersion: log.QueueStateVersion, EffectiveFromCommitURI: log.EffectiveFromCommitURI,
86+
StateTimestampMs: log.TimestampMs, Version: 1}
87+
if err := store.Create(ctx, initial); errors.Is(err, storage.ErrAlreadyExists) {
88+
continue
89+
} else {
90+
return err
91+
}
92+
}
93+
if err != nil {
94+
return err
95+
}
96+
if current.Queue != log.Queue {
97+
return fmt.Errorf("queue %q log %q: summary belongs to queue %q at projection version %d: %w", log.Queue, log.ID, current.Queue, current.Version, ErrInconsistentHistory)
98+
}
99+
if err := current.Validate(); err != nil {
100+
return fmt.Errorf("queue %q: invalid summary at projection version %d, state version %d: %w: %v", log.Queue, current.Version, current.QueueStateVersion, ErrInconsistentHistory, err)
101+
}
102+
if log.QueueStateVersion < current.QueueStateVersion {
103+
return nil
104+
}
105+
if log.QueueStateVersion == current.QueueStateVersion {
106+
if current.Snapshot() != log.Snapshot() {
107+
return fmt.Errorf("queue %q: log %q conflicts with summary state version %d (projection version %d): %w", log.Queue, log.ID, current.QueueStateVersion, current.Version, ErrInconsistentHistory)
108+
}
109+
return nil
110+
}
111+
if current.Version == math.MaxInt64 {
112+
return fmt.Errorf("queue %q log %q: summary projection version %d exhausted: %w", log.Queue, log.ID, current.Version, ErrInconsistentHistory)
113+
}
114+
updated := current
115+
updated.State, updated.QueueStateVersion = log.State, log.QueueStateVersion
116+
updated.EffectiveFromCommitURI, updated.StateTimestampMs = log.EffectiveFromCommitURI, log.TimestampMs
117+
newVersion := current.Version + 1
118+
if err := store.Update(ctx, updated, current.Version, newVersion); errors.Is(err, storage.ErrVersionMismatch) {
119+
continue
120+
} else if err != nil {
121+
return err
122+
}
123+
return nil
124+
}
125+
}

0 commit comments

Comments
 (0)