Skip to content

Commit c93310d

Browse files
authored
feat(stovepipe): Consume hook events (#632)
## Summary **What**: - Register the hook stage and its dead-letter stage in Stovepipe's consumer wiring, on a topic named for the domain, with a resolver that hands every event to the no-op hook. **Why**: - Wire the stage in a second domain, so attaching a real integration to Stovepipe is a resolver swap rather than new plumbing, and two domains on one queue backend keep their own hook topics. ## Test Plan - [x] Add unit tests. ## Revert Plan - Revert this PR. Nothing publishes hook events yet and the resolver returns only a no-op hook. ## Issues - [CODEM-416](https://linear.app/uber/issue/CODEM-416/hooks-integration-downstream-notificaiton) ## Stack 1. #607 1. #608 1. @ #632
1 parent ac94e24 commit c93310d

4 files changed

Lines changed: 180 additions & 4 deletions

File tree

service/stovepipe/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ Runnable wiring for the **Stovepipe** domain — a single-service domain (the do
77
- **process consumer** (`TopicKeyProcess`) — reloads the persisted `Request` from storage and runs the process stage (`stovepipe/controller/process`).
88
- **build consumer** (`TopicKeyBuild`) — reloads the persisted `Request` and triggers the build-runner, then publishes to `buildsignal`.
99
- **buildsignal consumer** (`TopicKeyBuildSignal`) — polls/records the build's terminal status and releases the queue's in-flight slot, then publishes to `record`.
10-
- **record consumer** (`TopicKeyRecord`) — writes the whole-repo validation fact, advances the queue's last-green bookmark and promotion ref, and publishes hook events.
10+
- **record consumer** (`TopicKeyRecord`) — writes the whole-repo validation fact, and advances the queue's last-green bookmark and promotion ref.
11+
- **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.
1112
- **DLQ reconciler** — for each internal topic, a `_dlq` consumer that drives stuck requests to a conservative terminal state so the queue's slot is freed.
1213

1314
The ingest → process → build → buildsignal → record hop stays inside one service and one store, so the queue messages carry only request **IDs**; the consumers reload from storage (the source of truth), which keeps messages small and redelivery idempotent. The process, build, buildsignal, and record topic keys and their internal wire contract are owned by the domain under `stovepipe/core/messagequeue/`.
@@ -43,7 +44,7 @@ The Stovepipe controllers live under [`stovepipe/controller/`](../../stovepipe/c
4344
| `STORAGE_MYSQL_DSN` | yes | Storage database DSN (`request`, `request_uri`) ||
4445
| `QUEUE_MYSQL_DSN` | yes | Queue database DSN ||
4546
| `PORT` | no | gRPC listen address | `:8083` |
46-
| `HOSTNAME` | no | Subscriber name for the process consumer | `stovepipe-<unix_ts>` |
47+
| `HOSTNAME` | no | Subscriber name for the queue consumers | `stovepipe-<unix_ts>` |
4748

4849
## Running
4950

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)