Skip to content

Commit e9228ed

Browse files
authored
feat(orchestrator): Freeze queue processing while FreezeProcessing is set (#865)
## Why? A paused queue needs to stop advancing accepted requests without a redeploy. The consumer gate already lets a controller be stopped and started at runtime, so freezing a queue is a gate that reads the queue's pause state. ## What? - A gate that wraps the orchestrator's existing consumer gate. While `FreezeProcessing` is set for a delivery's queue (its tenant), the gate is closed for every stage, including the dead-letter consumers. Blocked deliveries are postponed without spending retry budget, and released within a re-check delay of the flag being cleared. Otherwise the wrapped gate decides, so `CONSUMER_GATE_DIR` keeps working. - A failed pause read also closes the gate. - The orchestrator reads `QUEUE_PAUSE_CONFIG_PATH`. The store selection moves into a shared `queuepausestore` package that the gateway now uses too. ## Test Plan `bazel test //service/submitqueue/queuepausestore/... //service/submitqueue/orchestrator/server` CI End to End tests in next PR ## Issue
1 parent 9e40c52 commit e9228ed

10 files changed

Lines changed: 325 additions & 25 deletions

File tree

‎service/submitqueue/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Each service requires the application and queue MySQL databases. Service-specifi
2121
| `MYSQL_DSN` | both | yes | Application database DSN | — |
2222
| `QUEUE_MYSQL_DSN` | both | yes | Queue database DSN | — |
2323
| `QUEUE_CONFIG_PATH` | Gateway | yes | YAML registry of accepted queue names | — |
24-
| `QUEUE_PAUSE_CONFIG_PATH` | Gateway | no | YAML file of per-queue pause switches, re-read on every land | pausing disabled when unset |
24+
| `QUEUE_PAUSE_CONFIG_PATH` | both | no | YAML file of per-queue pause switches, re-read on every land and every gated delivery | pausing disabled when unset |
2525
| `PROFILES_CONFIG_PATH` | Orchestrator | no | YAML extension profiles and per-queue routing | built-in example profiles |
2626
| `HOSTNAME` | both | no | Base subscriber name; Gateway appends its PID | time-based service name |
2727
| `QUEUE_LOG_LEVEL` | both | no | Message-queue logger level | `info` |
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "go_default_library",
5+
srcs = ["queuepause.go"],
6+
importpath = "github.com/uber/submitqueue/service/submitqueue/core",
7+
visibility = ["//visibility:public"],
8+
deps = [
9+
"//submitqueue/extension/queuepause:go_default_library",
10+
"//submitqueue/extension/queuepause/file:go_default_library",
11+
"//submitqueue/extension/queuepause/noop:go_default_library",
12+
"@org_uber_go_zap//:go_default_library",
13+
],
14+
)
15+
16+
go_test(
17+
name = "go_default_test",
18+
srcs = ["queuepause_test.go"],
19+
embed = [":go_default_library"],
20+
deps = [
21+
"//submitqueue/entity:go_default_library",
22+
"@com_github_stretchr_testify//assert:go_default_library",
23+
"@com_github_stretchr_testify//require:go_default_library",
24+
"@org_uber_go_zap//:go_default_library",
25+
],
26+
)
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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 core holds wiring that both SubmitQueue services resolve the same
16+
// way, so the gateway and orchestrator share one implementation.
17+
package core
18+
19+
import (
20+
"fmt"
21+
"os"
22+
23+
"github.com/uber/submitqueue/submitqueue/extension/queuepause"
24+
queuepausefile "github.com/uber/submitqueue/submitqueue/extension/queuepause/file"
25+
queuepausenoop "github.com/uber/submitqueue/submitqueue/extension/queuepause/noop"
26+
"go.uber.org/zap"
27+
)
28+
29+
const pathEnv = "QUEUE_PAUSE_CONFIG_PATH"
30+
31+
// QueuePauseStoreFromEnv returns the file-backed store when QUEUE_PAUSE_CONFIG_PATH is set,
32+
// and otherwise a store under which no queue is ever paused. A path that
33+
// cannot be loaded is an error, so a misconfigured service fails at startup.
34+
func QueuePauseStoreFromEnv(logger *zap.Logger) (queuepause.Store, error) {
35+
path := os.Getenv(pathEnv)
36+
if path == "" {
37+
logger.Info("queue pause disabled")
38+
return queuepausenoop.New(), nil
39+
}
40+
store, err := queuepausefile.NewStore(path)
41+
if err != nil {
42+
return nil, fmt.Errorf("failed to load queue pause config: %w", err)
43+
}
44+
logger.Info("queue pause configured", zap.String("path", path))
45+
return store, nil
46+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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 core
16+
17+
import (
18+
"context"
19+
"os"
20+
"path/filepath"
21+
"testing"
22+
23+
"github.com/stretchr/testify/assert"
24+
"github.com/stretchr/testify/require"
25+
"github.com/uber/submitqueue/submitqueue/entity"
26+
"go.uber.org/zap"
27+
)
28+
29+
func TestQueuePauseStoreFromEnv(t *testing.T) {
30+
pausedFile := filepath.Join(t.TempDir(), "pause.yaml")
31+
require.NoError(t, os.WriteFile(pausedFile, []byte("queues:\n - name: q\n freeze_processing: true\n"), 0o600))
32+
33+
tests := []struct {
34+
name string
35+
path string
36+
wantErr bool
37+
wantState entity.QueuePause
38+
}{
39+
{name: "unset pauses nothing", path: "", wantState: entity.QueuePause{}},
40+
{name: "file store reads the configured file", path: pausedFile, wantState: entity.QueuePause{FreezeProcessing: true}},
41+
{name: "unreadable path fails", path: filepath.Join(t.TempDir(), "absent.yaml"), wantErr: true},
42+
}
43+
for _, tt := range tests {
44+
t.Run(tt.name, func(t *testing.T) {
45+
t.Setenv(pathEnv, tt.path)
46+
47+
store, err := QueuePauseStoreFromEnv(zap.NewNop())
48+
49+
if tt.wantErr {
50+
require.Error(t, err)
51+
return
52+
}
53+
require.NoError(t, err)
54+
got, err := store.Get(context.Background(), "q")
55+
require.NoError(t, err)
56+
assert.Equal(t, tt.wantState, got)
57+
})
58+
}
59+
}

‎service/submitqueue/gateway/server/BUILD.bazel‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,10 @@ go_library(
2929
"//platform/extension/messagequeue:go_default_library",
3030
"//platform/extension/messagequeue/mysql:go_default_library",
3131
"//service/messagequeue:go_default_library",
32+
"//service/submitqueue/core:go_default_library",
3233
"//service/submitqueue/gateway/server/mapper:go_default_library",
3334
"//submitqueue/core/topickey:go_default_library",
3435
"//submitqueue/extension/queueconfig/yaml:go_default_library",
35-
"//submitqueue/extension/queuepause:go_default_library",
36-
"//submitqueue/extension/queuepause/file:go_default_library",
37-
"//submitqueue/extension/queuepause/noop:go_default_library",
3836
"//submitqueue/gateway/controller:go_default_library",
3937
"//submitqueue/gateway/controller/log:go_default_library",
4038
"//submitqueue/gateway/core/request:go_default_library",

‎service/submitqueue/gateway/server/main.go‎

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,10 @@ import (
4141
extqueue "github.com/uber/submitqueue/platform/extension/messagequeue"
4242
queueMySQL "github.com/uber/submitqueue/platform/extension/messagequeue/mysql"
4343
servicemq "github.com/uber/submitqueue/service/messagequeue"
44+
"github.com/uber/submitqueue/service/submitqueue/core"
4445
"github.com/uber/submitqueue/service/submitqueue/gateway/server/mapper"
4546
"github.com/uber/submitqueue/submitqueue/core/topickey"
4647
yamlqueueconfig "github.com/uber/submitqueue/submitqueue/extension/queueconfig/yaml"
47-
"github.com/uber/submitqueue/submitqueue/extension/queuepause"
48-
queuepausefile "github.com/uber/submitqueue/submitqueue/extension/queuepause/file"
49-
queuepausenoop "github.com/uber/submitqueue/submitqueue/extension/queuepause/noop"
5048
"github.com/uber/submitqueue/submitqueue/gateway/controller"
5149
logctrl "github.com/uber/submitqueue/submitqueue/gateway/controller/log"
5250
requestcore "github.com/uber/submitqueue/submitqueue/gateway/core/request"
@@ -171,22 +169,6 @@ func gatewayStatusError(err error) error {
171169
}
172170
}
173171

174-
// newQueuePauseStore enables the file-backed pause store only when
175-
// QUEUE_PAUSE_CONFIG_PATH is set; otherwise no queue is ever paused.
176-
func newQueuePauseStore(logger *zap.Logger) (queuepause.Store, error) {
177-
path := os.Getenv("QUEUE_PAUSE_CONFIG_PATH")
178-
if path == "" {
179-
logger.Info("queue pause disabled")
180-
return queuepausenoop.New(), nil
181-
}
182-
store, err := queuepausefile.NewStore(path)
183-
if err != nil {
184-
return nil, fmt.Errorf("failed to load queue pause config: %w", err)
185-
}
186-
logger.Info("queue pause configured", zap.String("path", path))
187-
return store, nil
188-
}
189-
190172
func main() {
191173
code := 0
192174
if err := run(); err != nil {
@@ -287,7 +269,7 @@ func run() error {
287269
if err != nil {
288270
return fmt.Errorf("failed to load queue configs: %w", err)
289271
}
290-
queuePauses, err := newQueuePauseStore(logger)
272+
queuePauses, err := core.QueuePauseStoreFromEnv(logger)
291273
if err != nil {
292274
return err
293275
}

‎service/submitqueue/orchestrator/server/BUILD.bazel‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ go_library(
1212
"config.go",
1313
"main.go",
1414
"profiles.go",
15+
"queuepausegate.go",
1516
],
1617
importpath = "github.com/uber/submitqueue/service/submitqueue/orchestrator/server",
1718
visibility = ["//visibility:private"],
@@ -37,6 +38,7 @@ go_library(
3738
"//platform/http:go_default_library",
3839
"//platform/pipeline:go_default_library",
3940
"//service/messagequeue:go_default_library",
41+
"//service/submitqueue/core:go_default_library",
4042
"//submitqueue/core/changeset:go_default_library",
4143
"//submitqueue/entity:go_default_library",
4244
"//submitqueue/extension/buildrunner:go_default_library",
@@ -54,6 +56,7 @@ go_library(
5456
"//submitqueue/extension/conflict/fake:go_default_library",
5557
"//submitqueue/extension/conflict/none:go_default_library",
5658
"//submitqueue/extension/conflict/pathoverlap:go_default_library",
59+
"//submitqueue/extension/queuepause:go_default_library",
5760
"//submitqueue/extension/speculation/allocator/sticky:go_default_library",
5861
"//submitqueue/extension/speculation/generator/bestfirst:go_default_library",
5962
"//submitqueue/extension/speculation/scorer:go_default_library",
@@ -116,6 +119,7 @@ go_test(
116119
"config_test.go",
117120
"main_test.go",
118121
"profiles_test.go",
122+
"queuepausegate_test.go",
119123
],
120124
# The pinned git, for the tests that resolve a git change provider against a
121125
# real repository.
@@ -126,20 +130,26 @@ go_test(
126130
},
127131
deps = [
128132
"//platform/base/change:go_default_library",
133+
"//platform/base/messagequeue:go_default_library",
129134
"//platform/errs:go_default_library",
135+
"//platform/extension/consumergate:go_default_library",
136+
"//platform/extension/consumergate/mock:go_default_library",
130137
"//platform/git/exectest:go_default_library",
131138
"//platform/git/repo:go_default_library",
132139
"//platform/githubactions:go_default_library",
133140
"//submitqueue/entity:go_default_library",
134141
"//submitqueue/extension/buildrunner:go_default_library",
135142
"//submitqueue/extension/changeprovider:go_default_library",
136143
"//submitqueue/extension/conflict:go_default_library",
144+
"//submitqueue/extension/queuepause/fake:go_default_library",
137145
"//submitqueue/extension/speculation/scorer:go_default_library",
138146
"//submitqueue/extension/speculation/speculator:go_default_library",
139147
"//submitqueue/orchestrator/extension/storage:go_default_library",
140148
"@com_github_stretchr_testify//assert:go_default_library",
141149
"@com_github_stretchr_testify//require:go_default_library",
142150
"@com_github_uber_go_tally//:go_default_library",
151+
"@org_uber_go_mock//gomock:go_default_library",
152+
"@org_uber_go_zap//:go_default_library",
143153
"@org_uber_go_zap//zaptest:go_default_library",
144154
],
145155
)

‎service/submitqueue/orchestrator/server/main.go‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import (
4848
queueMySQL "github.com/uber/submitqueue/platform/extension/messagequeue/mysql"
4949
"github.com/uber/submitqueue/platform/pipeline"
5050
servicemq "github.com/uber/submitqueue/service/messagequeue"
51+
"github.com/uber/submitqueue/service/submitqueue/core"
5152
"github.com/uber/submitqueue/submitqueue/core/changeset"
5253
"github.com/uber/submitqueue/submitqueue/extension/validator"
5354
validatorfake "github.com/uber/submitqueue/submitqueue/extension/validator/fake"
@@ -223,6 +224,11 @@ func run() error {
223224
Hooks: hookResolver{},
224225
}
225226

227+
queuePauses, err := core.QueuePauseStoreFromEnv(logger)
228+
if err != nil {
229+
return err
230+
}
231+
226232
// Assemble the pipeline: one call builds the topic registry, creates
227233
// primary and DLQ consumers, eagerly constructs all controllers, and
228234
// returns a single lifecycle.Component the host drives with Start/Stop.
@@ -235,7 +241,7 @@ func run() error {
235241
orchestrator.Stages,
236242
pipeline.PublishOnly(orchestrator.PublishOnlyTopics...),
237243
pipeline.Classifiers(primaryErrorClassifiers()...),
238-
pipeline.Gate(newConsumerGate(logger)),
244+
pipeline.Gate(newQueuePauseGate(logger.Sugar(), queuePauses, newConsumerGate(logger))),
239245
)
240246
if err != nil {
241247
return fmt.Errorf("failed to construct pipeline: %w", err)
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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 main
16+
17+
import (
18+
"context"
19+
"errors"
20+
21+
"github.com/uber/submitqueue/platform/extension/consumergate"
22+
"github.com/uber/submitqueue/submitqueue/extension/queuepause"
23+
"go.uber.org/zap"
24+
)
25+
26+
var (
27+
_ consumergate.Gate = queuePauseGate{}
28+
_ consumergate.Entry = frozenEntry{}
29+
)
30+
31+
// queuePauseGate closes the gate for a queue while its pause state freezes
32+
// processing, and otherwise defers to the gate it wraps.
33+
type queuePauseGate struct {
34+
logger *zap.SugaredLogger
35+
pauses queuepause.Store
36+
next consumergate.Gate
37+
}
38+
39+
func newQueuePauseGate(logger *zap.SugaredLogger, pauses queuepause.Store, next consumergate.Gate) queuePauseGate {
40+
return queuePauseGate{logger: logger, pauses: pauses, next: next}
41+
}
42+
43+
// Enter reads the pause state of the delivery's queue, which is its tenant. A
44+
// failed read freezes the queue: the consumer treats a gate error as open, so
45+
// returning it would unfreeze a queue whenever the pause backend is down. A
46+
// cancelled context is the exception: the consumer is shutting down and
47+
// handles that error itself.
48+
func (g queuePauseGate) Enter(ctx context.Context, key consumergate.Key) (consumergate.Entry, error) {
49+
state, err := g.pauses.Get(ctx, key.Partition.Tenant)
50+
if errors.Is(err, context.Canceled) {
51+
return nil, err
52+
}
53+
if err != nil {
54+
g.logger.Errorw("failed to read queue pause state, freezing queue", "queue", key.Partition.Tenant, "error", err)
55+
return frozenEntry{}, nil
56+
}
57+
if state.FreezeProcessing {
58+
return frozenEntry{}, nil
59+
}
60+
return g.next.Enter(ctx, key)
61+
}
62+
63+
// frozenEntry is a blocked entry that keeps no parked record.
64+
type frozenEntry struct{}
65+
66+
func (frozenEntry) Blocked() bool { return true }
67+
68+
func (frozenEntry) Park(context.Context, consumergate.DeliveryDescriptor) error { return nil }
69+
70+
func (frozenEntry) Unpark(context.Context, consumergate.DeliveryDescriptor) error { return nil }

0 commit comments

Comments
 (0)