Skip to content

Commit 3bf1077

Browse files
committed
feat(stovepipe): wire SourceControl + stores into ingest, publish to process
## Summary ### Why? Ingest was a thin stub: it minted a request id but never resolved the commit URI, persisted anything, or moved the request onto the pipeline, so `Request.URI` stayed empty and nothing consumed the work. This makes ingest the real pipeline entry and adds the first internal queue contract so the pipeline can hand work to the next stage. ### What? Ingest now resolves the queue's head URI via the SourceControl extension, dedups on the (queue, URI) pair, persists the Request and its URI mapping via storage, and publishes the request id to a new process stage over the messaging queue. Ingestion is idempotent: a re-reported head resolves to the already-minted request and nothing is published again. The URI mapping is claimed before the request row is written, so a lost race leaves no orphan row. Adds the first internal proto message-queue contract under `stovepipe/core/messagequeue` (proto3 + protojson, mirroring `api/runway/messagequeue`): a `ProcessRequest` payload carrying the id, the `TopicKeyProcess` constant, and the protojson glue, wired into the proto codegen (`tool/proto`, `PROTO_PACKAGES`). Per CLAUDE.md, internal contracts live under the domain's `core/`, not `api/`, and the contract package owns both the payload and its topic keys. Adds a minimal `process` consumer (`stovepipe/controller/process`) that reloads the Request by id and logs it; a not-yet-visible request is retryable so redelivery converges. The build-strategy/ancestry logic the RFC assigns to `process` is deferred. Wires the example server (`example/stovepipe/server`) into a MySQL storage + MySQL queue + fake SourceControl stack with the process consumer running, plus docker-compose (two databases) and a schema-init make target. ## Test Plan ✅ `bazel test //stovepipe/...` — contract round-trip + topic-key binding, ingest (happy/dedup/race/unknown-queue/infra-error paths), and process consumer unit tests. ✅ `bazel test //test/integration/stovepipe:stovepipe_test` — compose-backed: Ingest persists the request + URI mapping, publishes to the process topic, and a re-ingest dedups to the same id. ✅ `bazel build //...`, `make fmt`.
1 parent cc75a0b commit 3bf1077

22 files changed

Lines changed: 1285 additions & 60 deletions

File tree

Makefile

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ GOIMPORTS_VERSION ?= v0.33.0
3535
# (the out_dir convention in tool/proto/BUILD.bazel) and copied back here. A
3636
# package may hold multiple .proto files (e.g. an RPC contract plus messagequeue
3737
# contracts); all generated stubs land in the same protopb/ dir.
38-
PROTO_PACKAGES = api/base/change api/base/mergestrategy api/base/messagequeue api/runway/messagequeue api/runway api/submitqueue/gateway api/submitqueue/orchestrator api/stovepipe
38+
PROTO_PACKAGES = api/base/change api/base/mergestrategy api/base/messagequeue api/runway/messagequeue api/runway api/submitqueue/gateway api/submitqueue/orchestrator api/stovepipe stovepipe/core/messagequeue
3939

4040
# Set REPO_ROOT for docker-compose
4141
export REPO_ROOT := $(shell pwd)
@@ -51,7 +51,7 @@ define assert_clean
5151
fi
5252
endef
5353

54-
.PHONY: build build-all-linux build-runway-linux build-submitqueue-gateway-linux build-submitqueue-orchestrator-linux build-stovepipe-linux check-gazelle check-mocks check-tidy clean clean-proto deps e2e-test fmt gazelle integration-test integration-test-submitqueue-consumer integration-test-extensions integration-test-submitqueue-gateway integration-test-submitqueue-orchestrator license-fix lint lint-fmt lint-license local-init-runway-queue-schema local-runway-start local-runway-stop local-submitqueue-clean local-submitqueue-gateway-start local-submitqueue-gateway-stop local-init-submitqueue-schemas local-submitqueue-logs local-submitqueue-orchestrator-start local-submitqueue-orchestrator-stop local-submitqueue-ps local-submitqueue-restart local-submitqueue-start local-stop local-stovepipe-logs local-stovepipe-start local-stovepipe-stop mocks proto query-deps query-targets run-client-runway run-client-submitqueue-gateway run-client-submitqueue-orchestrator run-client-stovepipe run-queue-admin test test-no-cache tidy tidy-bazel tidy-go help
54+
.PHONY: build build-all-linux build-runway-linux build-submitqueue-gateway-linux build-submitqueue-orchestrator-linux build-stovepipe-linux check-gazelle check-mocks check-tidy clean clean-proto deps e2e-test fmt gazelle integration-test integration-test-submitqueue-consumer integration-test-extensions integration-test-submitqueue-gateway integration-test-submitqueue-orchestrator license-fix lint lint-fmt lint-license local-init-runway-queue-schema local-init-stovepipe-schemas local-runway-start local-runway-stop local-submitqueue-clean local-submitqueue-gateway-start local-submitqueue-gateway-stop local-init-submitqueue-schemas local-submitqueue-logs local-submitqueue-orchestrator-start local-submitqueue-orchestrator-stop local-submitqueue-ps local-submitqueue-restart local-submitqueue-start local-stop local-stovepipe-logs local-stovepipe-start local-stovepipe-stop mocks proto query-deps query-targets run-client-runway run-client-submitqueue-gateway run-client-submitqueue-orchestrator run-client-stovepipe run-queue-admin test test-no-cache tidy tidy-bazel tidy-go help
5555

5656

5757
build: ## Build all services and examples
@@ -221,6 +221,19 @@ local-init-runway-queue-schema: ## Apply queue schema only (mysql-queue) for Run
221221
done
222222
@echo "✅ Runway queue schema applied successfully"
223223

224+
local-init-stovepipe-schemas: ## Apply storage (mysql-app) and queue (mysql-queue) schemas for Stovepipe compose stacks
225+
@echo "Applying storage schema to mysql-app..."
226+
@for file in stovepipe/extension/storage/mysql/schema/*.sql; do \
227+
echo " - Applying $$(basename $$file)..."; \
228+
docker exec -i $(STOVEPIPE_LOCAL_PROJECT)-mysql-app-1 mysql -uroot -proot submitqueue < $$file 2>&1 | grep -v "Using a password" || true; \
229+
done
230+
@echo "Applying queue schema to mysql-queue..."
231+
@for file in platform/extension/messagequeue/mysql/schema/*.sql; do \
232+
echo " - Applying $$(basename $$file)..."; \
233+
docker exec -i $(STOVEPIPE_LOCAL_PROJECT)-mysql-queue-1 mysql -uroot -proot submitqueue < $$file 2>&1 | grep -v "Using a password" || true; \
234+
done
235+
@echo "✅ Stovepipe schemas applied successfully"
236+
224237
local-runway-start: build-runway-linux ## Start Runway locally (runway + MySQL queue)
225238
@echo "Starting Runway with compose..."
226239
@$(COMPOSE) -f $(RUNWAY_COMPOSE_FILE) -p $(RUNWAY_LOCAL_PROJECT) up -d --build --wait
@@ -310,9 +323,11 @@ local-stop: ## Stop all services (keep data)
310323
local-stovepipe-logs: ## View logs from the running Stovepipe service
311324
@$(COMPOSE) -f $(STOVEPIPE_COMPOSE_FILE) -p $(STOVEPIPE_LOCAL_PROJECT) logs -f
312325

313-
local-stovepipe-start: build-stovepipe-linux ## Start Stovepipe service (single Ping-only gRPC service)
326+
local-stovepipe-start: build-stovepipe-linux ## Start Stovepipe service (gRPC service + MySQL storage + MySQL queue)
314327
@echo "Starting Stovepipe service with compose..."
315328
@$(COMPOSE) -f $(STOVEPIPE_COMPOSE_FILE) -p $(STOVEPIPE_LOCAL_PROJECT) up -d --build --wait
329+
@echo "Applying storage and queue schemas..."
330+
@$(MAKE) -s local-init-stovepipe-schemas
316331
@echo ""
317332
@echo "✅ Stovepipe service is running!"
318333
@echo ""

example/stovepipe/docker-compose.yml

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
# Docker Compose for the Stovepipe service.
22
#
3-
# Stovepipe is currently a single Ping-only gRPC service with no storage or
4-
# queue dependencies, so this stack runs just the one service.
3+
# Stovepipe ingests a queue's head commit, persists the Request, and publishes it
4+
# to the process stage over a messaging queue; the process consumer reloads the
5+
# Request. The stack therefore runs the service plus two MySQL databases: one for
6+
# storage (request, request_uri) and one for the messaging queue.
57
#
68
# IMPORTANT: Before running compose, build the Linux binary:
79
# make build-stovepipe-linux
@@ -12,8 +14,40 @@
1214
# Quick start:
1315
# make local-stovepipe-start
1416
#
17+
# After `up`, the storage and queue schemas are applied (local-init-stovepipe-schemas).
1518

1619
services:
20+
# Storage database - Stovepipe's request and request_uri tables.
21+
mysql-app:
22+
image: mysql:8.0
23+
environment:
24+
MYSQL_ROOT_PASSWORD: root
25+
MYSQL_DATABASE: submitqueue
26+
ports:
27+
- "3306" # Random ephemeral port to avoid conflicts
28+
healthcheck:
29+
# Use 127.0.0.1 (TCP) instead of localhost (Unix socket). MySQL treats
30+
# "localhost" as a socket connection, which can be ready before the TCP
31+
# listener — causing dependent services that connect over TCP to fail.
32+
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-proot"]
33+
interval: 5s
34+
timeout: 5s
35+
retries: 10
36+
37+
# Queue database - messaging infrastructure (messages, offsets, partition leases).
38+
mysql-queue:
39+
image: mysql:8.0
40+
environment:
41+
MYSQL_ROOT_PASSWORD: root
42+
MYSQL_DATABASE: submitqueue
43+
ports:
44+
- "3306" # Random ephemeral port to avoid conflicts
45+
healthcheck:
46+
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-proot"]
47+
interval: 5s
48+
timeout: 5s
49+
retries: 10
50+
1751
stovepipe-service:
1852
build:
1953
context: ${REPO_ROOT}
@@ -22,3 +56,11 @@ services:
2256
- "8080" # Random ephemeral port to avoid conflicts
2357
environment:
2458
- PORT=:8080
59+
- STORAGE_MYSQL_DSN=root:root@tcp(mysql-app:3306)/submitqueue?parseTime=true
60+
- QUEUE_MYSQL_DSN=root:root@tcp(mysql-queue:3306)/submitqueue?parseTime=true
61+
- HOSTNAME=stovepipe-dev
62+
depends_on:
63+
mysql-app:
64+
condition: service_healthy
65+
mysql-queue:
66+
condition: service_healthy

example/stovepipe/server/BUILD.bazel

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,19 @@ go_library(
77
visibility = ["//visibility:private"],
88
deps = [
99
"//api/stovepipe/protopb",
10+
"//platform/consumer",
11+
"//platform/errs",
12+
"//platform/errs/generic",
13+
"//platform/errs/mysql",
14+
"//platform/extension/messagequeue",
15+
"//platform/extension/messagequeue/mysql",
1016
"//stovepipe/controller",
17+
"//stovepipe/controller/process",
18+
"//stovepipe/core/messagequeue",
19+
"//stovepipe/extension/sourcecontrol",
20+
"//stovepipe/extension/sourcecontrol/fake",
21+
"//stovepipe/extension/storage/mysql",
22+
"@com_github_go_sql_driver_mysql//:mysql",
1123
"@com_github_uber_go_tally//:tally",
1224
"@org_golang_google_grpc//:grpc",
1325
"@org_golang_google_grpc//reflection",

example/stovepipe/server/main.go

Lines changed: 123 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package main
1616

1717
import (
1818
"context"
19+
"database/sql"
1920
"errors"
2021
"fmt"
2122
"net"
@@ -25,9 +26,21 @@ import (
2526
"syscall"
2627
"time"
2728

29+
_ "github.com/go-sql-driver/mysql"
2830
"github.com/uber-go/tally"
2931
pb "github.com/uber/submitqueue/api/stovepipe/protopb"
32+
"github.com/uber/submitqueue/platform/consumer"
33+
"github.com/uber/submitqueue/platform/errs"
34+
genericerrs "github.com/uber/submitqueue/platform/errs/generic"
35+
mysqlerrs "github.com/uber/submitqueue/platform/errs/mysql"
36+
extqueue "github.com/uber/submitqueue/platform/extension/messagequeue"
37+
queueMySQL "github.com/uber/submitqueue/platform/extension/messagequeue/mysql"
3038
"github.com/uber/submitqueue/stovepipe/controller"
39+
"github.com/uber/submitqueue/stovepipe/controller/process"
40+
stovepipemq "github.com/uber/submitqueue/stovepipe/core/messagequeue"
41+
"github.com/uber/submitqueue/stovepipe/extension/sourcecontrol"
42+
sourcecontrolfake "github.com/uber/submitqueue/stovepipe/extension/sourcecontrol/fake"
43+
storageMySQL "github.com/uber/submitqueue/stovepipe/extension/storage/mysql"
3144
"go.uber.org/zap"
3245
"google.golang.org/grpc"
3346
"google.golang.org/grpc/reflection"
@@ -70,6 +83,15 @@ func (c *inMemoryCounter) Next(_ context.Context, domain string) (int64, error)
7083
return c.values[domain], nil
7184
}
7285

86+
// fakeSourceControlFactory is the example SourceControl factory. It seeds each queue with a
87+
// deterministic single-commit history so ingest resolves a stable head URI (and re-ingesting
88+
// the same queue exercises the dedup path). A real deployment supplies a VCS-backed factory.
89+
type fakeSourceControlFactory struct{}
90+
91+
func (fakeSourceControlFactory) For(cfg sourcecontrol.Config) (sourcecontrol.SourceControl, error) {
92+
return sourcecontrolfake.New([]string{fmt.Sprintf("git://%s/HEAD", cfg.QueueName)}), nil
93+
}
94+
7395
func main() {
7496
code := 0
7597
if err := run(); err != nil {
@@ -131,12 +153,85 @@ func run() error {
131153
metricsWgDone.Wait()
132154
}()
133155

156+
// Storage database (request + request_uri tables).
157+
storageDSN := os.Getenv("STORAGE_MYSQL_DSN")
158+
if storageDSN == "" {
159+
return fmt.Errorf("STORAGE_MYSQL_DSN environment variable is required")
160+
}
161+
storageDB, err := sql.Open("mysql", storageDSN)
162+
if err != nil {
163+
return fmt.Errorf("failed to open storage database: %w", err)
164+
}
165+
defer storageDB.Close()
166+
167+
store, err := storageMySQL.NewStorage(storageDB, scope.SubScope("storage"))
168+
if err != nil {
169+
return fmt.Errorf("failed to create storage: %w", err)
170+
}
171+
defer store.Close()
172+
173+
// Queue database (messaging infrastructure for the process stage).
174+
queueDSN := os.Getenv("QUEUE_MYSQL_DSN")
175+
if queueDSN == "" {
176+
return fmt.Errorf("QUEUE_MYSQL_DSN environment variable is required")
177+
}
178+
queueDB, err := sql.Open("mysql", queueDSN)
179+
if err != nil {
180+
return fmt.Errorf("failed to open queue database: %w", err)
181+
}
182+
defer queueDB.Close()
183+
184+
mysqlQueue, err := queueMySQL.NewQueue(queueMySQL.Params{
185+
DB: queueDB,
186+
Logger: logger,
187+
MetricsScope: scope.SubScope("queue"),
188+
})
189+
if err != nil {
190+
return fmt.Errorf("failed to create queue: %w", err)
191+
}
192+
defer mysqlQueue.Close()
193+
194+
subscriberName := os.Getenv("HOSTNAME")
195+
if subscriberName == "" {
196+
subscriberName = fmt.Sprintf("stovepipe-%d", time.Now().Unix())
197+
}
198+
199+
registry, err := newTopicRegistry(mysqlQueue, subscriberName)
200+
if err != nil {
201+
return fmt.Errorf("failed to create topic registry: %w", err)
202+
}
203+
204+
// Consumer running the process stage.
205+
primaryConsumer := consumer.New(logger.Sugar(), scope.SubScope("consumer"), registry,
206+
errs.NewClassifierProcessor(
207+
genericerrs.Classifier,
208+
mysqlerrs.Classifier,
209+
),
210+
)
211+
212+
processController := process.NewController(logger.Sugar(), scope, store, stovepipemq.TopicKeyProcess, "stovepipe-process")
213+
if err := primaryConsumer.Register(processController); err != nil {
214+
return fmt.Errorf("failed to register process controller: %w", err)
215+
}
216+
217+
if err := primaryConsumer.Start(ctx); err != nil {
218+
return fmt.Errorf("failed to start consumer: %w", err)
219+
}
220+
logger.Info("consumer started")
221+
134222
// Create gRPC server
135223
grpcServer := grpc.NewServer()
136224

137225
// Create controllers and wrap them for gRPC
138226
pingController := controller.NewPingController(logger, scope)
139-
ingestController := controller.NewIngestController(logger.Sugar(), scope, newInMemoryCounter())
227+
ingestController := controller.NewIngestController(
228+
logger.Sugar(),
229+
scope,
230+
newInMemoryCounter(),
231+
fakeSourceControlFactory{},
232+
store,
233+
registry,
234+
)
140235
srv := &StovepipeServer{
141236
pingController: pingController,
142237
ingestController: ingestController,
@@ -166,28 +261,50 @@ func run() error {
166261
}()
167262

168263
// Wait for interrupt signal or server critical error
169-
// If interruption is signaled, gracefully stop the server
170-
// If an error happens during shutdown, return the actual error, not the context cancellation error
171264
var serverErr error
172265
select {
173266
case <-ctx.Done():
174267
fmt.Println("Shutting down stovepipe server due to interruption signal...")
175268

176269
// Set the error to the context cancellation error to be surfaced as a desired exit code by the main function
177-
// to indicate that the server was stopped as intended
178-
// It may be overridden by the server error if any
270+
// to indicate that the server was stopped as intended. It may be overridden by the server error if any.
179271
err = ctx.Err()
180272

181273
// stop GRPC server and wait for it to exit
182274
grpcServer.GracefulStop()
183275
serverErr = <-serverErrCh
184276
case serverErr = <-serverErrCh:
185277
fmt.Println("Shutting down stovepipe server due to critical GRPC server error...")
278+
cancel()
186279
}
187280

188281
if serverErr != nil {
189-
err = fmt.Errorf("GRPC server exited with error: %w", serverErr)
282+
serverErr = fmt.Errorf("GRPC server exited with error: %w", serverErr)
283+
}
284+
285+
consumerStopErr := primaryConsumer.Stop(30000)
286+
if consumerStopErr != nil {
287+
consumerStopErr = fmt.Errorf("failed to stop consumer: %w", consumerStopErr)
288+
}
289+
290+
if consumerStopErr != nil || serverErr != nil {
291+
err = errors.Join(err, consumerStopErr, serverErr)
190292
}
191293

192294
return err
193295
}
296+
297+
// newTopicRegistry builds the TopicRegistry for Stovepipe's internal pipeline queues. ingest
298+
// publishes to the process topic and the process consumer subscribes to it.
299+
func newTopicRegistry(q extqueue.Queue, subscriberName string) (consumer.TopicRegistry, error) {
300+
return consumer.NewTopicRegistry([]consumer.TopicConfig{
301+
{
302+
Key: stovepipemq.TopicKeyProcess,
303+
Name: "process",
304+
Queue: q,
305+
Subscription: extqueue.DefaultSubscriptionConfig(
306+
subscriberName, "stovepipe-process",
307+
),
308+
},
309+
})
310+
}

stovepipe/controller/BUILD.bazel

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,15 @@ go_library(
1010
visibility = ["//visibility:public"],
1111
deps = [
1212
"//api/stovepipe/protopb",
13+
"//platform/base/messagequeue",
14+
"//platform/consumer",
1315
"//platform/errs",
1416
"//platform/extension/counter",
1517
"//platform/metrics",
18+
"//stovepipe/core/messagequeue",
1619
"//stovepipe/entity",
20+
"//stovepipe/extension/sourcecontrol",
21+
"//stovepipe/extension/storage",
1722
"@com_github_uber_go_tally//:tally",
1823
"@org_uber_go_zap//:zap",
1924
],
@@ -28,7 +33,14 @@ go_test(
2833
embed = [":controller"],
2934
deps = [
3035
"//api/stovepipe/protopb",
36+
"//platform/consumer",
3137
"//platform/extension/counter/mock",
38+
"//platform/extension/messagequeue/mock",
39+
"//stovepipe/core/messagequeue",
40+
"//stovepipe/extension/sourcecontrol",
41+
"//stovepipe/extension/sourcecontrol/mock",
42+
"//stovepipe/extension/storage",
43+
"//stovepipe/extension/storage/mock",
3244
"@com_github_stretchr_testify//assert",
3345
"@com_github_stretchr_testify//require",
3446
"@com_github_uber_go_tally//:tally",

0 commit comments

Comments
 (0)