Skip to content

Commit d04bf53

Browse files
sbalabanov-zzclaude
andcommitted
feat(consumergate): runtime stop/start of queue controllers + deterministic e2e cancel test
Implement doc/rfc/consumer-gate.md: - platform/extension/consumergate: extension contract — Gate (read side for the consumer middleware), Admin (write surface for tests and tooling), Config, Factory interface, and gomock mocks. - platform/extension/consumergate/file: first implementation — gate state as plain files in a shared directory (gate file present = closed, rm = open), parked-delivery records as JSON, temp-file-plus-rename writes. - platform/consumer: WithGate option installs gate middleware that parks deliveries before the controller while the gate is closed, extending visibility on each refresh tick (no retry budget burned, partition order preserved), releasing on open, and dropping cleanly on shutdown. Gate state is cached per (group, partition) with a ~1s refresh; read failures fail open. - Wiring: one option argument at each consumer construction site (gateway, orchestrator primary + DLQ, runway), enabled by CONSUMER_GATE_DIR; the compose stack bind-mounts a shared host directory into every service. - test/e2e/submitqueue: replace TestCancel_RecordsIntent with TestCancel_CaughtPreBatch_NeverLands — the stop→observe→start scenario from the RFC. The gate parks runway's merge-conflict check for the test queue's partition before landing, the parked record proves the request is held pre-batch, the cancel drives it terminal cancelled, and after the gate opens a sentinel request landing on the same partitions proves the stale check signal was consumed and dropped — the cancelled change is never batched and never lands. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7ff27fb commit d04bf53

24 files changed

Lines changed: 1697 additions & 22 deletions

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ local-stovepipe-stop: ## Stop the Stovepipe service
364364

365365
mocks: ## Generate mock files using mockgen
366366
@echo "Generating mocks..."
367-
@$(BAZEL) run @rules_go//go -- generate ./submitqueue/extension/storage/... ./submitqueue/extension/buildrunner/... ./submitqueue/extension/changeprovider/... ./platform/extension/counter/... ./platform/extension/messagequeue/... ./submitqueue/extension/queueconfig/... ./submitqueue/extension/mergechecker/... ./submitqueue/extension/pusher/... ./submitqueue/extension/scorer/... ./submitqueue/extension/conflict/... ./submitqueue/extension/speculation/enumerator/... ./submitqueue/extension/speculation/dependencylimit/... ./submitqueue/extension/speculation/selector/... ./submitqueue/extension/speculation/selectionlimit/... ./submitqueue/extension/speculation/prioritizer/... ./submitqueue/extension/speculation/prioritizationlimit/... ./submitqueue/extension/validator/... ./submitqueue/extension/speculation/pathscorer/... ./platform/consumer/... ./stovepipe/extension/storage/... ./stovepipe/extension/sourcecontrol/...
367+
@$(BAZEL) run @rules_go//go -- generate ./submitqueue/extension/storage/... ./submitqueue/extension/buildrunner/... ./submitqueue/extension/changeprovider/... ./platform/extension/counter/... ./platform/extension/consumergate/... ./platform/extension/messagequeue/... ./submitqueue/extension/queueconfig/... ./submitqueue/extension/mergechecker/... ./submitqueue/extension/pusher/... ./submitqueue/extension/scorer/... ./submitqueue/extension/conflict/... ./submitqueue/extension/speculation/enumerator/... ./submitqueue/extension/speculation/dependencylimit/... ./submitqueue/extension/speculation/selector/... ./submitqueue/extension/speculation/selectionlimit/... ./submitqueue/extension/speculation/prioritizer/... ./submitqueue/extension/speculation/prioritizationlimit/... ./submitqueue/extension/validator/... ./submitqueue/extension/speculation/pathscorer/... ./platform/consumer/... ./stovepipe/extension/storage/... ./stovepipe/extension/sourcecontrol/...
368368
@echo "Mocks generated successfully!"
369369

370370
proto: ## Generate protobuf files from .proto definitions

platform/consumer/BUILD.bazel

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ go_library(
55
srcs = [
66
"consumer.go",
77
"controller.go",
8+
"gate.go",
89
"registry.go",
910
],
1011
importpath = "github.com/uber/submitqueue/platform/consumer",
1112
visibility = ["//visibility:public"],
1213
deps = [
1314
"//platform/base/messagequeue:go_default_library",
1415
"//platform/errs:go_default_library",
16+
"//platform/extension/consumergate:go_default_library",
1517
"//platform/extension/messagequeue:go_default_library",
1618
"//platform/metrics:go_default_library",
1719
"@com_github_uber_go_tally//:go_default_library",
@@ -23,13 +25,15 @@ go_test(
2325
name = "go_default_test",
2426
srcs = [
2527
"consumer_test.go",
28+
"gate_test.go",
2629
"registry_test.go",
2730
],
2831
deps = [
2932
":go_default_library",
3033
"//platform/base/messagequeue:go_default_library",
3134
"//platform/consumer/mock:go_default_library",
3235
"//platform/errs:go_default_library",
36+
"//platform/extension/consumergate:go_default_library",
3337
"//platform/extension/messagequeue:go_default_library",
3438
"//platform/extension/messagequeue/mock:go_default_library",
3539
"//submitqueue/core/topickey:go_default_library",

platform/consumer/consumer.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ type consumer struct {
6767
stopped bool
6868
controllers []Controller
6969
subscriptions map[TopicKey]*activeSubscription // topicKey -> subscription
70+
71+
// gate is the optional consumer-gate middleware (see gate.go). Nil unless
72+
// WithGate was passed to New; nil means deliveries flow straight to their
73+
// controllers exactly as before the gate existed.
74+
gate *gateMiddleware
7075
}
7176

7277
// activeSubscription tracks the state of an active subscription.
@@ -86,14 +91,21 @@ type activeSubscription struct {
8691
// consumers such as DLQ reconciliation that must redeliver on any failure.
8792
// processor must not be nil; callers that genuinely want no transformation
8893
// can pass errs.NewClassifierProcessor() with no classifiers.
89-
func New(logger *zap.SugaredLogger, scope tally.Scope, registry TopicRegistry, processor errs.ErrorProcessor) Consumer {
90-
return &consumer{
94+
//
95+
// opts configure optional behavior, e.g. WithGate to install the consumer-gate
96+
// middleware.
97+
func New(logger *zap.SugaredLogger, scope tally.Scope, registry TopicRegistry, processor errs.ErrorProcessor, opts ...Option) Consumer {
98+
c := &consumer{
9199
logger: logger,
92100
metricsScope: scope.SubScope("consumer"),
93101
registry: registry,
94102
processor: processor,
95103
subscriptions: make(map[TopicKey]*activeSubscription),
96104
}
105+
for _, opt := range opts {
106+
opt(c)
107+
}
108+
return c
97109
}
98110

99111
// Register adds a controller to the consumer. Must be called before Start().
@@ -342,6 +354,14 @@ func (m *consumer) processPartition(ctx context.Context, controller Controller,
342354
func (m *consumer) processDelivery(ctx context.Context, controller Controller, delivery extqueue.Delivery, controllerScope tally.Scope) {
343355
const opName = "process"
344356

357+
// Consumer-gate middleware: park the delivery while the controller's gate
358+
// is closed. A false return means the consumer is shutting down while
359+
// parked — leave the delivery in-flight (no process, no ack/nack) so the
360+
// visibility timeout lapses into a normal redelivery.
361+
if m.gate != nil && !m.gate.hold(ctx, controller, delivery) {
362+
return
363+
}
364+
345365
start := time.Now()
346366
metrics.NamedCounter(controllerScope, opName, "messages_received", 1)
347367

platform/consumer/gate.go

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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 consumer
16+
17+
import (
18+
"context"
19+
"sync"
20+
"time"
21+
22+
"github.com/uber-go/tally"
23+
"github.com/uber/submitqueue/platform/extension/consumergate"
24+
extqueue "github.com/uber/submitqueue/platform/extension/messagequeue"
25+
"github.com/uber/submitqueue/platform/metrics"
26+
"go.uber.org/zap"
27+
)
28+
29+
// Option configures optional consumer behavior at construction time.
30+
type Option func(*consumer)
31+
32+
// WithGate installs the consumer-gate middleware: before each delivery reaches
33+
// its controller, the middleware consults gate state for the controller's
34+
// consumer group (and the delivery's partition) and parks the delivery while
35+
// the gate is closed. When no gate is configured, the middleware is absent and
36+
// the consumer behaves exactly as without it. See
37+
// platform/extension/consumergate and doc/rfc/consumer-gate.md.
38+
func WithGate(gate consumergate.Gate, cfg consumergate.Config) Option {
39+
return func(c *consumer) {
40+
def := consumergate.DefaultConfig()
41+
if cfg.RefreshIntervalMs <= 0 {
42+
cfg.RefreshIntervalMs = def.RefreshIntervalMs
43+
}
44+
if cfg.ParkExtensionMs <= 0 {
45+
cfg.ParkExtensionMs = def.ParkExtensionMs
46+
}
47+
c.gate = &gateMiddleware{
48+
gate: gate,
49+
refreshInterval: time.Duration(cfg.RefreshIntervalMs) * time.Millisecond,
50+
parkExtensionMs: cfg.ParkExtensionMs,
51+
logger: c.logger.Named("consumer_gate"),
52+
scope: c.metricsScope.SubScope("gate"),
53+
cache: make(map[gateCacheKey]gateCacheEntry),
54+
}
55+
}
56+
}
57+
58+
// gateMiddleware parks deliveries whose controller's gate is closed. It sits
59+
// between the partition dispatch and the controller: dispatch is serial per
60+
// partition, so holding one delivery blocks exactly that partition; ack/nack
61+
// belong to the framework, so a delivery is held simply by not yet invoking
62+
// the controller. Stopping is a barrier, not preemption — a message already
63+
// inside Process when a gate closes runs to completion.
64+
type gateMiddleware struct {
65+
gate consumergate.Gate
66+
refreshInterval time.Duration
67+
parkExtensionMs int64
68+
logger *zap.SugaredLogger
69+
scope tally.Scope
70+
71+
// cache holds the last gate verdict per (consumer group, partition),
72+
// refreshed at most once per refreshInterval. The middleware therefore does
73+
// not hit the gate medium per message, and a parked delivery re-checks on
74+
// the same tick cadence.
75+
mu sync.Mutex
76+
cache map[gateCacheKey]gateCacheEntry
77+
}
78+
79+
// gateCacheKey identifies one cached gate verdict.
80+
type gateCacheKey struct {
81+
consumerGroup string
82+
partitionKey string
83+
}
84+
85+
// gateCacheEntry is a cached gate verdict and when it was read.
86+
type gateCacheEntry struct {
87+
gated bool
88+
at time.Time
89+
}
90+
91+
// hold blocks while the controller's gate is closed for the delivery's
92+
// partition. It returns true when the delivery may proceed into the
93+
// controller, and false when the consumer is shutting down while parked — in
94+
// that case the caller must not process, ack, or nack: extension stops,
95+
// visibility lapses, and the queue redelivers normally.
96+
func (g *gateMiddleware) hold(ctx context.Context, controller Controller, delivery extqueue.Delivery) bool {
97+
const opName = "hold"
98+
99+
consumerGroup := controller.ConsumerGroup()
100+
msg := delivery.Message()
101+
102+
if !g.isGated(ctx, consumerGroup, msg.PartitionKey) {
103+
return true
104+
}
105+
106+
// Record the parked delivery before blocking: the record is the observable
107+
// proof that the gate caught exactly this message.
108+
topicName := controller.TopicKey().String()
109+
parked := consumergate.Parked{
110+
ConsumerGroup: consumerGroup,
111+
Topic: topicName,
112+
MessageID: msg.ID,
113+
PartitionKey: msg.PartitionKey,
114+
Payload: msg.Payload,
115+
Attempt: delivery.Attempt(),
116+
ParkedAtMs: time.Now().UnixMilli(),
117+
}
118+
if err := g.gate.RecordParked(ctx, parked); err != nil {
119+
metrics.NamedCounter(g.scope, opName, "park_record_errors", 1)
120+
g.logger.Errorw("failed to record parked delivery",
121+
"consumer_group", consumerGroup,
122+
"topic", topicName,
123+
"message_id", msg.ID,
124+
"error", err,
125+
)
126+
}
127+
128+
metrics.NamedCounter(g.scope, opName, "parked", 1)
129+
g.logger.Infow("delivery parked by consumer gate",
130+
"consumer_group", consumerGroup,
131+
"topic", topicName,
132+
"message_id", msg.ID,
133+
"partition_key", msg.PartitionKey,
134+
"attempt", delivery.Attempt(),
135+
)
136+
137+
ticker := time.NewTicker(g.refreshInterval)
138+
defer ticker.Stop()
139+
for {
140+
select {
141+
case <-ctx.Done():
142+
// Shutdown while parked: leave the delivery in-flight and let the
143+
// visibility timeout lapse into a normal redelivery.
144+
metrics.NamedCounter(g.scope, opName, "shutdown_while_parked", 1)
145+
return false
146+
case <-ticker.C:
147+
}
148+
149+
// Keep the parked delivery in-flight. ExtendVisibilityTimeout does not
150+
// increment the retry count, so parking never burns retry budget.
151+
if err := delivery.ExtendVisibilityTimeout(ctx, g.parkExtensionMs); err != nil {
152+
metrics.NamedCounter(g.scope, opName, "extend_errors", 1)
153+
g.logger.Errorw("failed to extend visibility of parked delivery",
154+
"consumer_group", consumerGroup,
155+
"topic", topicName,
156+
"message_id", msg.ID,
157+
"error", err,
158+
)
159+
}
160+
161+
if g.isGated(ctx, consumerGroup, msg.PartitionKey) {
162+
continue
163+
}
164+
165+
if err := g.gate.RecordReleased(ctx, consumerGroup, topicName, msg.ID, time.Now().UnixMilli()); err != nil {
166+
metrics.NamedCounter(g.scope, opName, "release_record_errors", 1)
167+
g.logger.Errorw("failed to record released delivery",
168+
"consumer_group", consumerGroup,
169+
"topic", topicName,
170+
"message_id", msg.ID,
171+
"error", err,
172+
)
173+
}
174+
metrics.NamedCounter(g.scope, opName, "released", 1)
175+
g.logger.Infow("parked delivery released by consumer gate",
176+
"consumer_group", consumerGroup,
177+
"topic", topicName,
178+
"message_id", msg.ID,
179+
"partition_key", msg.PartitionKey,
180+
)
181+
return true
182+
}
183+
}
184+
185+
// isGated returns the cached gate verdict for the group/partition, refreshing
186+
// it from the gate medium when older than the refresh interval. Read failures
187+
// fail open: gating is auxiliary, and a broken gate medium must not become a
188+
// pipeline stall — the failure is surfaced via log and counter instead.
189+
func (g *gateMiddleware) isGated(ctx context.Context, consumerGroup, partitionKey string) bool {
190+
key := gateCacheKey{consumerGroup: consumerGroup, partitionKey: partitionKey}
191+
now := time.Now()
192+
193+
g.mu.Lock()
194+
entry, ok := g.cache[key]
195+
g.mu.Unlock()
196+
if ok && now.Sub(entry.at) < g.refreshInterval {
197+
return entry.gated
198+
}
199+
200+
gated, err := g.gate.IsGated(ctx, consumerGroup, partitionKey)
201+
if err != nil {
202+
metrics.NamedCounter(g.scope, "check", "read_errors", 1)
203+
g.logger.Errorw("failed to read gate state, failing open",
204+
"consumer_group", consumerGroup,
205+
"partition_key", partitionKey,
206+
"error", err,
207+
)
208+
gated = false
209+
}
210+
211+
// Cache the verdict — including the fail-open one, so a broken medium is
212+
// re-probed once per interval rather than once per message.
213+
g.mu.Lock()
214+
g.cache[key] = gateCacheEntry{gated: gated, at: now}
215+
g.mu.Unlock()
216+
217+
return gated
218+
}

0 commit comments

Comments
 (0)