Skip to content

Commit d13d204

Browse files
committed
feat(stovepipe): Consume hook events
**What**: - Consume lifecycle events on Stovepipe's own hook topic and hand each one to the integrations it resolves to, accepting and discarding them while only the no-op integration is attached. - Record and alert on an event that fails every retry, rather than letting it accumulate unread. **Why**: - Show the delivery stage works for a second domain, one that registers its consumers directly rather than through the pipeline topology. - Keep two domains sharing a queue backend from consuming each other's events.
1 parent 4826405 commit d13d204

4 files changed

Lines changed: 181 additions & 5 deletions

File tree

service/stovepipe/README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
# Stovepipe Service
22

3-
Runnable wiring for the **Stovepipe** domain — a single-service domain (the domain *is* the service). The server exposes two RPCs and runs one internal pipeline stage as a queue consumer:
3+
Runnable wiring for the **Stovepipe** domain — a single-service domain (the domain *is* the service). The server exposes two RPCs and runs its internal pipeline stages as queue consumers:
44

55
- **`Ping`** — health check.
66
- **`Ingest`** — resolves a queue's head commit, persists a `Request` (and its head URI) to storage, and publishes the request to the **process** stage.
77
- **process consumer** (`TopicKeyProcess`) — reloads the persisted `Request` from storage and runs the process stage (`stovepipe/controller/process`).
8+
- **hook consumer** (`TopicKeyHook`) — hands each lifecycle event to the hooks `hookResolver` returns (`platform/hook`). Nothing publishes to this topic yet and the resolver returns only `noop`, so events are accepted and discarded. Its topic name is domain-qualified (`stovepipe-hook`) because the key is shared across domains.
89

910
The ingest → process hop stays inside one service and one store, so only the request **ID** travels on the queue; the consumer reloads from storage (the source of truth), which keeps messages small and redelivery idempotent. The process topic key and its internal wire contract are owned by the domain under `stovepipe/core/messagequeue/`.
1011

@@ -23,7 +24,7 @@ Stovepipe therefore needs two MySQL databases: a **storage** database (the `requ
2324
stovepipe/
2425
├── docker-compose.yml # Stovepipe service + storage MySQL + queue MySQL
2526
├── server/
26-
│ ├── main.go # gRPC server (Ping, Ingest) + process-stage consumer wiring
27+
│ ├── main.go # gRPC server (Ping, Ingest) + queue consumer wiring
2728
│ └── Dockerfile
2829
└── client/
2930
└── main.go # Ping client (default :8083)
@@ -38,7 +39,7 @@ The Stovepipe controllers live under [`stovepipe/controller/`](../../stovepipe/c
3839
| `STORAGE_MYSQL_DSN` | yes | Storage database DSN (`request`, `request_uri`) ||
3940
| `QUEUE_MYSQL_DSN` | yes | Queue database DSN ||
4041
| `PORT` | no | gRPC listen address | `:8083` |
41-
| `HOSTNAME` | no | Subscriber name for the process consumer | `stovepipe-<unix_ts>` |
42+
| `HOSTNAME` | no | Subscriber name for the queue consumers | `stovepipe-<unix_ts>` |
4243

4344
## Running
4445

service/stovepipe/server/BUILD.bazel

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
load("@rules_go//go:def.bzl", "go_binary", "go_cross_binary", "go_library")
1+
load("@rules_go//go:def.bzl", "go_binary", "go_cross_binary", "go_library", "go_test")
22

33
go_library(
44
name = "go_default_library",
55
srcs = ["main.go"],
66
importpath = "github.com/uber/submitqueue/service/stovepipe/server",
77
visibility = ["//visibility:private"],
88
deps = [
9+
"//api/base/hook:go_default_library",
910
"//api/stovepipe/protopb:go_default_library",
1011
"//platform/consumer:go_default_library",
1112
"//platform/errs:go_default_library",
@@ -14,8 +15,11 @@ go_library(
1415
"//platform/errs/mysql:go_default_library",
1516
"//platform/extension/consumergate/noop:go_default_library",
1617
"//platform/extension/counter:go_default_library",
18+
"//platform/extension/hook:go_default_library",
19+
"//platform/extension/hook/noop:go_default_library",
1720
"//platform/extension/messagequeue:go_default_library",
1821
"//platform/extension/messagequeue/mysql:go_default_library",
22+
"//platform/hook:go_default_library",
1923
"//service/stovepipe/server/mapper:go_default_library",
2024
"//stovepipe/controller:go_default_library",
2125
"//stovepipe/controller/build:go_default_library",
@@ -66,3 +70,18 @@ filegroup(
6670
],
6771
visibility = ["//test:__subpackages__"],
6872
)
73+
74+
go_test(
75+
name = "go_default_test",
76+
srcs = ["main_test.go"],
77+
embed = [":go_default_library"],
78+
deps = [
79+
"//api/base/hook:go_default_library",
80+
"//platform/consumer:go_default_library",
81+
"//stovepipe/controller/dlq:go_default_library",
82+
"@com_github_stretchr_testify//assert:go_default_library",
83+
"@com_github_stretchr_testify//require:go_default_library",
84+
"@com_github_uber_go_tally//:go_default_library",
85+
"@org_uber_go_zap//zaptest:go_default_library",
86+
],
87+
)

service/stovepipe/server/main.go

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828

2929
_ "github.com/go-sql-driver/mysql"
3030
"github.com/uber-go/tally"
31+
basehook "github.com/uber/submitqueue/api/base/hook"
3132
pb "github.com/uber/submitqueue/api/stovepipe/protopb"
3233
"github.com/uber/submitqueue/platform/consumer"
3334
"github.com/uber/submitqueue/platform/errs"
@@ -36,8 +37,11 @@ import (
3637
mysqlerrs "github.com/uber/submitqueue/platform/errs/mysql"
3738
consumergatenoop "github.com/uber/submitqueue/platform/extension/consumergate/noop"
3839
"github.com/uber/submitqueue/platform/extension/counter"
40+
hookext "github.com/uber/submitqueue/platform/extension/hook"
41+
hooknoop "github.com/uber/submitqueue/platform/extension/hook/noop"
3942
extqueue "github.com/uber/submitqueue/platform/extension/messagequeue"
4043
queueMySQL "github.com/uber/submitqueue/platform/extension/messagequeue/mysql"
44+
platformhook "github.com/uber/submitqueue/platform/hook"
4145
"github.com/uber/submitqueue/service/stovepipe/server/mapper"
4246
"github.com/uber/submitqueue/stovepipe/controller"
4347
"github.com/uber/submitqueue/stovepipe/controller/build"
@@ -149,6 +153,16 @@ func (fakeBuildRunnerFactory) For(cfg buildrunner.Config) (buildrunner.BuildRunn
149153
return buildrunnerfake.New(cfg), nil
150154
}
151155

156+
// hookResolver sends every event to the no-op hook. Which hooks an event goes to is host
157+
// policy, so the resolver lives here rather than in the extension package. A deployment
158+
// with real integrations swaps this for one that selects on the event's source and type.
159+
type hookResolver struct{}
160+
161+
// For returns the hooks that run for event.
162+
func (hookResolver) For(*basehook.HookEvent) []hookext.Hook {
163+
return []hookext.Hook{hooknoop.New()}
164+
}
165+
152166
func main() {
153167
code := 0
154168
if err := run(); err != nil {
@@ -286,7 +300,7 @@ func run() error {
286300
brf := fakeBuildRunnerFactory{}
287301

288302
storageFty := storageFactory{backend: store}
289-
primaryCount, err := registerPrimaryControllers(primaryConsumer, logger.Sugar(), scope, storageFty, registry, sourceControl, brf)
303+
primaryCount, err := registerPrimaryControllers(primaryConsumer, logger.Sugar(), scope, storageFty, registry, sourceControl, brf, hookResolver{})
290304
if err != nil {
291305
return err
292306
}
@@ -400,6 +414,7 @@ func registerPrimaryControllers(
400414
registry consumer.TopicRegistry,
401415
sourceControl sourcecontrol.Factory,
402416
brf buildrunner.Factory,
417+
hooks hookext.Hooks,
403418
) (int, error) {
404419
var count int
405420

@@ -436,6 +451,12 @@ func registerPrimaryControllers(
436451
}
437452
count++
438453

454+
hookController := platformhook.NewController(logger, scope, hooks, basehook.TopicKeyHook, "stovepipe-hook")
455+
if err := c.Register(hookController); err != nil {
456+
return count, fmt.Errorf("failed to register hook controller: %w", err)
457+
}
458+
count++
459+
439460
return count, nil
440461
}
441462

@@ -475,6 +496,12 @@ func registerDLQControllers(
475496
}
476497
count++
477498

499+
hookDLQController := platformhook.NewDLQController(logger, scope, dlq.TopicKey(basehook.TopicKeyHook), "stovepipe-hook-dlq")
500+
if err := c.Register(hookDLQController); err != nil {
501+
return count, fmt.Errorf("failed to register hook dlq controller: %w", err)
502+
}
503+
count++
504+
478505
return count, nil
479506
}
480507

@@ -484,6 +511,9 @@ func registerDLQControllers(
484511
// topic and the buildsignal consumer subscribes to it, and also republishes to itself while
485512
// polling. buildsignal publishes to the record topic once a build reaches a terminal status,
486513
// and the record consumer subscribes to it.
514+
//
515+
// The hook topic name is domain-qualified because its key is shared across domains: two
516+
// domains pointed at one queue backend would otherwise consume each other's events.
487517
func newTopicRegistry(q extqueue.Queue, subscriberName string) (consumer.TopicRegistry, error) {
488518
return consumer.NewTopicRegistry([]consumer.TopicConfig{
489519
{
@@ -518,6 +548,14 @@ func newTopicRegistry(q extqueue.Queue, subscriberName string) (consumer.TopicRe
518548
subscriberName, "stovepipe-record",
519549
),
520550
},
551+
{
552+
Key: basehook.TopicKeyHook,
553+
Name: "stovepipe-hook",
554+
Queue: q,
555+
Subscription: extqueue.DefaultSubscriptionConfig(
556+
subscriberName, "stovepipe-hook",
557+
),
558+
},
521559
{
522560
Key: dlq.TopicKey(stovepipemq.TopicKeyProcess),
523561
Name: "process_dlq",
@@ -542,6 +580,12 @@ func newTopicRegistry(q extqueue.Queue, subscriberName string) (consumer.TopicRe
542580
Queue: q,
543581
Subscription: extqueue.DLQSubscriptionConfig(subscriberName, "stovepipe-record-dlq"),
544582
},
583+
{
584+
Key: dlq.TopicKey(basehook.TopicKeyHook),
585+
Name: "stovepipe-hook_dlq",
586+
Queue: q,
587+
Subscription: extqueue.DLQSubscriptionConfig(subscriberName, "stovepipe-hook-dlq"),
588+
},
545589
})
546590
}
547591

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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+
"strings"
20+
"testing"
21+
22+
"github.com/stretchr/testify/assert"
23+
"github.com/stretchr/testify/require"
24+
"github.com/uber-go/tally"
25+
basehook "github.com/uber/submitqueue/api/base/hook"
26+
"github.com/uber/submitqueue/platform/consumer"
27+
"github.com/uber/submitqueue/stovepipe/controller/dlq"
28+
"go.uber.org/zap/zaptest"
29+
)
30+
31+
// recordingConsumer captures what the host registers instead of subscribing.
32+
type recordingConsumer struct {
33+
controllers []consumer.Controller
34+
}
35+
36+
func (c *recordingConsumer) Register(controller consumer.Controller) error {
37+
c.controllers = append(c.controllers, controller)
38+
return nil
39+
}
40+
41+
func (c *recordingConsumer) Start(context.Context) error { return nil }
42+
43+
func (c *recordingConsumer) Stop(int64) error { return nil }
44+
45+
// registeredControllers runs the host's registration exactly as run() does and
46+
// returns the registry it registers against, the primary controllers, and the
47+
// DLQ controllers.
48+
func registeredControllers(t *testing.T) (consumer.TopicRegistry, []consumer.Controller, []consumer.Controller) {
49+
t.Helper()
50+
51+
registry, err := newTopicRegistry(nil, "subscriber")
52+
require.NoError(t, err)
53+
54+
logger := zaptest.NewLogger(t).Sugar()
55+
store := storageFactory{}
56+
primary := &recordingConsumer{}
57+
deadLetter := &recordingConsumer{}
58+
59+
_, err = registerPrimaryControllers(primary, logger, tally.NoopScope, store, registry,
60+
fakeSourceControlFactory{}, fakeBuildRunnerFactory{}, hookResolver{})
61+
require.NoError(t, err)
62+
63+
_, err = registerDLQControllers(deadLetter, logger, tally.NoopScope, store, registry,
64+
fakeSourceControlFactory{})
65+
require.NoError(t, err)
66+
67+
return registry, primary.controllers, deadLetter.controllers
68+
}
69+
70+
func topicKeys(controllers []consumer.Controller) []consumer.TopicKey {
71+
keys := make([]consumer.TopicKey, 0, len(controllers))
72+
for _, c := range controllers {
73+
keys = append(keys, c.TopicKey())
74+
}
75+
return keys
76+
}
77+
78+
func TestEveryRegisteredControllerResolvesInTheTopicRegistry(t *testing.T) {
79+
registry, primary, deadLetter := registeredControllers(t)
80+
81+
for _, c := range append(primary, deadLetter...) {
82+
t.Run(c.Name(), func(t *testing.T) {
83+
_, ok := registry.TopicName(c.TopicKey())
84+
assert.True(t, ok, "no topic name registered for the key the controller subscribes to")
85+
86+
_, ok = registry.SubscriptionConfig(c.TopicKey(), c.ConsumerGroup())
87+
assert.True(t, ok, "no subscription registered for the controller's consumer group")
88+
})
89+
}
90+
}
91+
92+
func TestHookStage(t *testing.T) {
93+
registry, primary, deadLetter := registeredControllers(t)
94+
95+
t.Run("hook events are consumed", func(t *testing.T) {
96+
assert.Contains(t, topicKeys(primary), basehook.TopicKeyHook)
97+
})
98+
99+
t.Run("hook events that exhaust their retries are consumed", func(t *testing.T) {
100+
assert.Contains(t, topicKeys(deadLetter), dlq.TopicKey(basehook.TopicKeyHook))
101+
})
102+
103+
// The key is shared across domains, so an unqualified name would collide with
104+
// another domain's hook topic on a queue backend the two share.
105+
t.Run("the hook topics are named for this domain", func(t *testing.T) {
106+
for _, key := range []consumer.TopicKey{basehook.TopicKeyHook, dlq.TopicKey(basehook.TopicKeyHook)} {
107+
name, ok := registry.TopicName(key)
108+
require.True(t, ok)
109+
assert.True(t, strings.HasPrefix(name, "stovepipe-"), "topic %q is not domain-qualified", name)
110+
}
111+
})
112+
}

0 commit comments

Comments
 (0)