Skip to content

Commit 56a21ac

Browse files
committed
feat(hook): Deliver events to integrations
1 parent b5394eb commit 56a21ac

17 files changed

Lines changed: 1077 additions & 12 deletions

File tree

Makefile

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

378378
mocks: ## Generate mock files using mockgen
379379
@echo "Generating mocks..."
380-
@$(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/scorer/... ./submitqueue/extension/conflict/... ./submitqueue/extension/speculation/... ./submitqueue/extension/validator/... ./platform/consumer/... ./stovepipe/extension/storage/... ./stovepipe/extension/sourcecontrol/...
380+
@$(BAZEL) run @rules_go//go -- generate ./submitqueue/extension/storage/... ./submitqueue/extension/buildrunner/... ./submitqueue/extension/changeprovider/... ./platform/extension/counter/... ./platform/extension/consumergate/... ./platform/extension/hook/... ./platform/extension/messagequeue/... ./submitqueue/extension/queueconfig/... ./submitqueue/extension/mergechecker/... ./submitqueue/extension/scorer/... ./submitqueue/extension/conflict/... ./submitqueue/extension/speculation/... ./submitqueue/extension/validator/... ./platform/consumer/... ./stovepipe/extension/storage/... ./stovepipe/extension/sourcecontrol/...
381381
@echo "Mocks generated successfully!"
382382

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

api/base/hook/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,9 @@ The binding between a topic key and its payload lives in the message's `topic_ke
4040

4141
| Message | Direction | Topic key |
4242
|---|---|---|
43-
| `HookEvent` | producing domain → hook dispatcher | `hook` |
43+
| `HookEvent` | producing domain → hook stage | `hook` |
4444

45-
The key is per-host: each domain runs its own hook topic and its own dispatcher, so two domains sharing one queue backend must map `hook` to distinct topic names.
45+
The key is per-host: each domain runs its own hook topic and its own hook controller, so two domains sharing one queue backend must map `hook` to distinct topic names.
4646

4747
## Evolution
4848

doc/rfc/hook-framework.md

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@ Two requirements: side effects must never stall or fail the pipeline, and "fire
1010

1111
## Proposal
1212

13-
When a controller performs a transition, it also publishes a **hook event** to a durable `hook` topic. A thin per-domain dispatcher stage consumes it and hands each event to the **hooks** the host wired — no-op by default, real integrations as they arrive.
13+
When a controller performs a transition, it also publishes a **hook event** to a durable `hook` topic. A thin per-domain hook stage consumes it, asks the host's **hooks resolver** which integrations that event belongs to, and runs them — none by default, real integrations as they arrive.
1414

1515
```
16-
pipeline controller dispatcher stage (per domain)
17-
state write → hook publish → downstream ──▶ [hook topic] ──▶ decode → validate → hook.Handle
18-
├─ noop (default)
19-
│ retries exhausted └─ composite ─▶ warehouse, code host, …
16+
pipeline controller hook stage (per domain)
17+
state write → hook publish → downstream ──▶ [hook topic] ──▶ decode → validate → Hooks.For(event)
18+
└─▶ warehouse, code host, …
19+
│ retries exhausted
2020
2121
[hook_dlq] ──▶ log full event + page; manual republish
2222
```
@@ -58,12 +58,13 @@ Delivery promise:
5858

5959
### Hooks and dispatch
6060

61-
- Extension at `platform/extension/hook/`, singleton shape (counter precedent), wired once per host; no per-queue factory.
61+
- Extension at `platform/extension/hook/`: the `Hook` contract plus a `Hooks` resolver the host builds in wiring. No `Config` and no `Factory` — selection is the resolver's, and only wiring knows the queue topology.
6262
- Hook contract: at-least-once, idempotent by `id`, plain errors, never writes pipeline state; ignore an event by returning nil (no filter API).
63-
- Ships `noop` (default) and `composite` (runs all children, joins failures, names failing children). A cross-domain sink is the same impl wired into each domain.
64-
- Dispatcher: decode, validate (`id`/`source`/`type` non-empty), invoke. Malformed events dead-letter, never silently acked; hook errors retry then dead-letter, with errs classifiers fast-pathing permanent failures.
63+
- `Hooks.For(event)` keys on the event, not a queue name: the envelope carries no queue, and which scope selects hooks (queue, source, type) differs per domain. Resolving to none is ordinary. Ships `noop` for a host that wants an explicit placeholder. A cross-domain sink is the same impl wired into each domain.
64+
- Controller (`platform/hook`, wired by each service): decode, validate (`id`/`source`/`type` non-empty), resolve, invoke all. Malformed events dead-letter, never silently acked; hook errors retry then dead-letter, with errs classifiers fast-pathing permanent failures.
65+
- Mixed outcomes: every resolved hook runs even after one fails, and the failures are attributed and joined. `errs` weighs each branch of a joined error, so a transient failure alongside a permanent one still retries.
6566
- DLQ reconciler: log the full event with its failure attribution, page (new metric — the log DLQ only warns), then ack. Manual republish recovers; pipeline state is never touched.
66-
- Per-hook retry isolation later: consumer groups on the same `hook` topic key, once the registry supports multiple groups per key and rejection becomes group-local (today it moves the shared row). Until then the composite's shared budget is accepted.
67+
- Per-hook retry isolation later: consumer groups on the same `hook` topic key, once the registry supports multiple groups per key and rejection becomes group-local (today it moves the shared row). Until then one shared budget for all of an event's hooks is accepted.
6768

6869
## Example
6970

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
load("@rules_go//go:def.bzl", "go_library")
2+
3+
go_library(
4+
name = "go_default_library",
5+
srcs = ["hook.go"],
6+
importpath = "github.com/uber/submitqueue/platform/extension/hook",
7+
visibility = ["//visibility:public"],
8+
deps = ["//api/base/hook:go_default_library"],
9+
)

platform/extension/hook/README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Hook
2+
3+
Vendor-agnostic interface for fire-and-forget side effects run in response to pipeline lifecycle events: warehouse exports, code-host comments, notifications, audit trails. See [the hooks framework RFC](../../../doc/rfc/hook-framework.md) for the design and [`api/base/hook`](../../../api/base/hook) for the event contract.
4+
5+
## Interface
6+
7+
### Hook
8+
9+
Handles one lifecycle event. `Name` identifies it in logs, metrics, and failure attribution.
10+
11+
Four obligations, all of them consequences of running behind an at-least-once queue:
12+
13+
- **Idempotent on the event id.** The same event may arrive more than once, including after a successful `Handle`. The id is derived from the transition, so a redelivery carries the id the first delivery did.
14+
- **Return nil to ignore an event.** There is no filter or subscription API. A hook that does not care about a type returns nil and costs nothing; routing can become a wiring decorator if it ever pays for itself.
15+
- **Return plain errors.** Classification is the consumer's job. An error must mean the side effect did not happen — reporting failure for work that succeeded turns at-least-once delivery into repeated duplicate effects.
16+
- **Never write pipeline state.** A hook's outcome is invisible to the pipeline, which is exactly what makes it unable to affect the transition that triggered it.
17+
18+
### Hooks
19+
20+
Resolves the hooks that run for an event. The controller in [`platform/hook`](../../hook) asks it once per delivery and runs everything it returns; returning none is ordinary and means nothing this host wired cares about the event.
21+
22+
`For` takes the event rather than a queue name because the envelope carries no queue. Which scope selects hooks differs per domain — queue, source, event type — and only the host that publishes the payload can read a queue out of it, so the choice belongs to the resolver. Resolution runs on every delivery and cannot fail: an integration that cannot be reached is a `Handle` error, not an absent hook.
23+
24+
## Wiring
25+
26+
There is no `Config` and no `Factory` here. Selection is the resolver's job, and the resolver is built in the wiring layer — the only place that knows the full set of queues and the integrations wired for each. The host constructs its `Hooks` and hands it to the controller in [`platform/hook`](../../hook), which owns the consumer side: decode, validate, resolve, invoke.
27+
28+
Two queues in one host can point at different providers and want different integrations, which is why hooks are resolved per event rather than fixed per deployment.
29+
30+
## Implementations
31+
32+
- **`noop/`** — accepts every event and does nothing. A placeholder for a host that wants the stage registered before it has any integration; a resolver that returns no hooks does the same thing.
33+
34+
A sink that serves several domains is one implementation wired into each domain's host, not one implementation per domain.
35+
36+
## Implementing a Hook
37+
38+
1. Create `platform/extension/hook/{name}/` for a hook reusable across domains, or `{domain}/extension/hook/{name}/` for one that is domain-specific.
39+
2. Implement `Handle` and `Name`, keying any deduplication on `event.GetId()`.
40+
3. Decide per event `type` what to do, and return nil for the types you ignore.
41+
4. Return it from the host's `Hooks` resolver for the events it should run on.
42+
43+
Every hook the resolver returns for an event shares one consumer and therefore one retry budget: one chronically failing integration eventually dead-letters events the others handled fine. See [`platform/hook`](../../hook) before wiring several.

platform/extension/hook/hook.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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 hook defines the contract for a hook: a pluggable side effect run in
16+
// response to a pipeline lifecycle event. Warehouse exports, code-host comments,
17+
// notifications, and audit trails are all hooks.
18+
//
19+
// Which hooks run is a property of the event rather than of the deployment: two
20+
// queues in one host can point at different providers and want different
21+
// integrations. A host therefore supplies a Hooks resolver, and the controller
22+
// in platform/hook asks it once per event.
23+
//
24+
// Hooks run behind a durable queue, never inline in the pipeline, so a slow or
25+
// failing integration cannot stall or fail the work that triggered it.
26+
package hook
27+
28+
//go:generate mockgen -source=hook.go -destination=mock/hook_mock.go -package=mock
29+
30+
import (
31+
"context"
32+
33+
basehook "github.com/uber/submitqueue/api/base/hook"
34+
)
35+
36+
// Hook performs a side effect in response to a lifecycle event.
37+
type Hook interface {
38+
// Handle performs the side effect for event.
39+
//
40+
// Delivery is at-least-once, so the same event — identical id — may arrive
41+
// more than once, including after a successful Handle. Implementations must
42+
// be idempotent on the event id.
43+
//
44+
// Returning nil means "done with this event", which is also how a hook
45+
// ignores one: there is no filter or subscription API, because a hook that
46+
// does not care about a type simply returns nil, and routing can be added as
47+
// a wiring decorator if it ever pays for itself.
48+
//
49+
// Returning an error retries the event and, past the retry budget,
50+
// dead-letters it. Return plain errors; classification is the consumer's
51+
// job. An error must mean the side effect did not happen — reporting failure
52+
// for work that succeeded turns at-least-once into repeated duplicate
53+
// effects.
54+
//
55+
// A hook must never write pipeline state. Its outcome is invisible to the
56+
// pipeline by design: that is what makes the side effect unable to affect
57+
// the transition that triggered it.
58+
Handle(ctx context.Context, event *basehook.HookEvent) error
59+
60+
// Name identifies the hook in logs, metrics, and the failure attribution
61+
// the controller reports. Stable and unique among the hooks a host wires.
62+
Name() string
63+
}
64+
65+
// Hooks resolves the hooks that run for an event.
66+
type Hooks interface {
67+
// For returns the hooks to run for event, in the order they should run.
68+
// Returning none is an ordinary outcome: it means nothing this host wired
69+
// is interested in the event.
70+
//
71+
// It takes the event rather than a queue name because the envelope carries
72+
// no queue. Which scope selects hooks differs per domain — queue, source,
73+
// event type — and only the host that publishes the payload can read a
74+
// queue out of it, so the choice belongs to the resolver.
75+
//
76+
// Called on every delivery, so resolution must be cheap and must not fail:
77+
// an integration that cannot be reached is a Handle error, not an absent
78+
// hook.
79+
For(event *basehook.HookEvent) []Hook
80+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
load("@rules_go//go:def.bzl", "go_library")
2+
3+
go_library(
4+
name = "go_default_library",
5+
srcs = ["hook_mock.go"],
6+
importpath = "github.com/uber/submitqueue/platform/extension/hook/mock",
7+
visibility = ["//visibility:public"],
8+
deps = [
9+
"//api/base/hook:go_default_library",
10+
"//platform/extension/hook:go_default_library",
11+
"@org_uber_go_mock//gomock:go_default_library",
12+
],
13+
)

platform/extension/hook/mock/hook_mock.go

Lines changed: 109 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "go_default_library",
5+
srcs = ["hook.go"],
6+
importpath = "github.com/uber/submitqueue/platform/extension/hook/noop",
7+
visibility = ["//visibility:public"],
8+
deps = [
9+
"//api/base/hook:go_default_library",
10+
"//platform/extension/hook:go_default_library",
11+
],
12+
)
13+
14+
go_test(
15+
name = "go_default_test",
16+
srcs = ["hook_test.go"],
17+
embed = [":go_default_library"],
18+
deps = [
19+
"//api/base/hook:go_default_library",
20+
"@com_github_stretchr_testify//require:go_default_library",
21+
],
22+
)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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 noop provides a hook.Hook that accepts every event and does nothing.
16+
// It is a placeholder for a host that wants the stage registered before it has
17+
// any integration — a resolver returning no hooks does the same thing. Either
18+
// way events are still published, consumed, and acked, so turning a real hook on
19+
// later changes only what happens to the event, not whether the seam works.
20+
package noop
21+
22+
import (
23+
"context"
24+
25+
basehook "github.com/uber/submitqueue/api/base/hook"
26+
"github.com/uber/submitqueue/platform/extension/hook"
27+
)
28+
29+
// Verify interface compliance at compile time.
30+
var _ hook.Hook = Hook{}
31+
32+
// Hook is a hook that discards every event.
33+
type Hook struct{}
34+
35+
// New returns a no-op Hook.
36+
func New() Hook {
37+
return Hook{}
38+
}
39+
40+
// Handle implements hook.Hook. The event is discarded.
41+
func (Hook) Handle(context.Context, *basehook.HookEvent) error { return nil }
42+
43+
// Name implements hook.Hook.
44+
func (Hook) Name() string { return "noop" }

0 commit comments

Comments
 (0)