Skip to content

Commit a09dcab

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

18 files changed

Lines changed: 1112 additions & 1 deletion

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
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: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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+
## Wiring
19+
20+
A hook is wired **once per host**, not resolved per queue, so this package has no `Config` and no `Factory`. What an integration does is a property of the deployment rather than of the queue an event came from; a hook that genuinely needs per-queue behavior resolves the queue from the event payload.
21+
22+
The host constructs its hook and hands it to the dispatcher in [`platform/hook`](../../hook), which owns the consumer side: decode, validate, invoke.
23+
24+
## Implementations
25+
26+
- **`noop/`** — accepts every event and does nothing. The default before a host has any integration, so the seam behaves identically whether or not hooks are configured.
27+
- **`composite/`** — fans an event out to several children, runs all of them even after one fails, and joins the failures with the name of each failing child. Read its package doc before wiring more than one child: they share a single retry budget, so one chronically failing integration eventually dead-letters events the others handled fine.
28+
29+
A sink that serves several domains is one implementation wired into each domain's host, not one implementation per domain.
30+
31+
## Implementing a Hook
32+
33+
1. Create `platform/extension/hook/{name}/` for a hook reusable across domains, or `{domain}/extension/hook/{name}/` for one that is domain-specific.
34+
2. Implement `Handle` and `Name`, keying any deduplication on `event.GetId()`.
35+
3. Decide per event `type` what to do, and return nil for the types you ignore.
36+
4. Wire it into the host's dispatcher — inside a `composite` if the host has more than one.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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/composite",
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+
"//platform/extension/hook:go_default_library",
21+
"@com_github_stretchr_testify//assert:go_default_library",
22+
"@com_github_stretchr_testify//require:go_default_library",
23+
],
24+
)
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 composite provides a hook.Hook that fans one event out to several
16+
// children. It is how a host wires more than one integration, since the
17+
// dispatcher takes a single hook.
18+
//
19+
// Every child runs on every event, even after one fails, so a broken
20+
// integration cannot stop the others from seeing the event. Failures are
21+
// collected and joined, each wrapped with the name of the child that raised it,
22+
// so the error reaching the dispatcher says which integration failed rather than
23+
// just that something did.
24+
//
25+
// # Children share one retry budget
26+
//
27+
// The composite is a single consumer, so a retry re-delivers the event to every
28+
// child, including the ones that already succeeded. Two consequences: children
29+
// must be idempotent on the event id (the hook contract requires this anyway),
30+
// and one persistently failing child spends the budget for all of them, so the
31+
// event eventually dead-letters even though the others were fine.
32+
//
33+
// The fix is a consumer group per hook on the shared hook topic, which the queue
34+
// cannot express today: the registry admits one consumer group per topic key,
35+
// and a rejection moves the shared message row to the DLQ for every group rather
36+
// than only the one that rejected it. Until both change, prefer wiring children
37+
// whose failure modes are independent and short-lived, and treat a chronically
38+
// failing integration as something to remove from the composite rather than to
39+
// absorb.
40+
package composite
41+
42+
import (
43+
"context"
44+
"errors"
45+
"fmt"
46+
47+
basehook "github.com/uber/submitqueue/api/base/hook"
48+
"github.com/uber/submitqueue/platform/extension/hook"
49+
)
50+
51+
// Verify interface compliance at compile time.
52+
var _ hook.Hook = Hook{}
53+
54+
// Hook fans an event out to every child hook.
55+
type Hook struct {
56+
// children are the hooks the event is handed to, in wiring order.
57+
children []hook.Hook
58+
}
59+
60+
// New returns a Hook that hands each event to every child in the order given.
61+
// With no children it accepts every event and does nothing.
62+
func New(children ...hook.Hook) Hook {
63+
return Hook{children: children}
64+
}
65+
66+
// Handle implements hook.Hook. It runs every child and returns the joined
67+
// failures, each attributed to the child that raised it, or nil when all
68+
// succeeded.
69+
func (h Hook) Handle(ctx context.Context, event *basehook.HookEvent) error {
70+
var failures []error
71+
for _, child := range h.children {
72+
if err := child.Handle(ctx, event); err != nil {
73+
failures = append(failures, fmt.Errorf("hook %s: %w", child.Name(), err))
74+
}
75+
}
76+
return errors.Join(failures...)
77+
}
78+
79+
// Name implements hook.Hook.
80+
func (Hook) Name() string { return "composite" }
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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 composite
16+
17+
import (
18+
"context"
19+
"errors"
20+
"testing"
21+
22+
"github.com/stretchr/testify/assert"
23+
"github.com/stretchr/testify/require"
24+
basehook "github.com/uber/submitqueue/api/base/hook"
25+
"github.com/uber/submitqueue/platform/extension/hook"
26+
)
27+
28+
// recordingHook records the events it saw and fails with a fixed error.
29+
type recordingHook struct {
30+
name string
31+
err error
32+
seen []string
33+
}
34+
35+
var _ hook.Hook = (*recordingHook)(nil)
36+
37+
func (h *recordingHook) Handle(_ context.Context, event *basehook.HookEvent) error {
38+
h.seen = append(h.seen, event.GetId())
39+
return h.err
40+
}
41+
42+
func (h *recordingHook) Name() string { return h.name }
43+
44+
func event() *basehook.HookEvent {
45+
return &basehook.HookEvent{Id: "submitqueue/batch.failed/batch-778/4", Source: "submitqueue", Type: "batch.failed"}
46+
}
47+
48+
func TestHandle(t *testing.T) {
49+
t.Run("no children", func(t *testing.T) {
50+
require.NoError(t, New().Handle(context.Background(), event()))
51+
})
52+
53+
t.Run("every child sees the event", func(t *testing.T) {
54+
first := &recordingHook{name: "first"}
55+
second := &recordingHook{name: "second"}
56+
57+
require.NoError(t, New(first, second).Handle(context.Background(), event()))
58+
assert.Equal(t, []string{event().GetId()}, first.seen)
59+
assert.Equal(t, []string{event().GetId()}, second.seen)
60+
})
61+
62+
t.Run("a failing child does not stop the others", func(t *testing.T) {
63+
boom := errors.New("boom")
64+
failing := &recordingHook{name: "failing", err: boom}
65+
healthy := &recordingHook{name: "healthy"}
66+
67+
err := New(failing, healthy).Handle(context.Background(), event())
68+
69+
require.Error(t, err)
70+
assert.ErrorIs(t, err, boom)
71+
assert.Equal(t, []string{event().GetId()}, healthy.seen, "the healthy child runs after the failing one")
72+
})
73+
74+
t.Run("every failure survives the join", func(t *testing.T) {
75+
first := errors.New("first failure")
76+
second := errors.New("second failure")
77+
78+
err := New(
79+
&recordingHook{name: "first", err: first},
80+
&recordingHook{name: "second", err: second},
81+
).Handle(context.Background(), event())
82+
83+
require.Error(t, err)
84+
assert.ErrorIs(t, err, first)
85+
assert.ErrorIs(t, err, second)
86+
})
87+
}

platform/extension/hook/hook.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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+
// A hook is wired once per host rather than resolved per queue, because what an
20+
// integration does — post a comment, write a row — is a property of the
21+
// deployment, not of the queue the event came from. There is therefore no Config
22+
// and no Factory here: the host constructs its hook directly and hands it to the
23+
// dispatcher. A hook that genuinely needs per-queue behavior resolves the queue
24+
// from the event payload.
25+
//
26+
// Hooks run behind a durable queue, never inline in the pipeline, so a slow or
27+
// failing integration cannot stall or fail the work that triggered it.
28+
package hook
29+
30+
//go:generate mockgen -source=hook.go -destination=mock/hook_mock.go -package=mock
31+
32+
import (
33+
"context"
34+
35+
basehook "github.com/uber/submitqueue/api/base/hook"
36+
)
37+
38+
// Hook performs a side effect in response to a lifecycle event.
39+
type Hook interface {
40+
// Handle performs the side effect for event.
41+
//
42+
// Delivery is at-least-once, so the same event — identical id — may arrive
43+
// more than once, including after a successful Handle. Implementations must
44+
// be idempotent on the event id.
45+
//
46+
// Returning nil means "done with this event", which is also how a hook
47+
// ignores one: there is no filter or subscription API, because a hook that
48+
// does not care about a type simply returns nil, and routing can be added as
49+
// a wiring decorator if it ever pays for itself.
50+
//
51+
// Returning an error retries the event and, past the retry budget,
52+
// dead-letters it. Return plain errors; classification is the consumer's
53+
// job. An error must mean the side effect did not happen — reporting failure
54+
// for work that succeeded turns at-least-once into repeated duplicate
55+
// effects.
56+
//
57+
// A hook must never write pipeline state. Its outcome is invisible to the
58+
// pipeline by design: that is what makes the side effect unable to affect
59+
// the transition that triggered it.
60+
Handle(ctx context.Context, event *basehook.HookEvent) error
61+
62+
// Name identifies the hook in logs, metrics, and the failure attribution a
63+
// composite reports. Stable and unique among the hooks a host wires.
64+
Name() string
65+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
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+
"@org_uber_go_mock//gomock:go_default_library",
11+
],
12+
)

platform/extension/hook/mock/hook_mock.go

Lines changed: 70 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)