Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ GOIMPORTS_VERSION ?= v0.33.0
# (the out_dir convention in tool/proto/BUILD.bazel) and copied back here. A
# package may hold multiple .proto files (e.g. an RPC contract plus messagequeue
# contracts); all generated stubs land in the same protopb/ dir.
PROTO_PACKAGES = api/base/change api/base/mergestrategy api/base/messagequeue api/runway/messagequeue api/runway api/submitqueue/gateway api/submitqueue/orchestrator api/stovepipe stovepipe/core/messagequeue
PROTO_PACKAGES = api/base/change api/base/hook api/base/mergestrategy api/base/messagequeue api/runway/messagequeue api/runway api/submitqueue/gateway api/submitqueue/orchestrator api/stovepipe stovepipe/core/messagequeue

# Set REPO_ROOT for docker-compose
export REPO_ROOT := $(shell pwd)
Expand Down
31 changes: 31 additions & 0 deletions api/base/hook/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
load("@rules_go//go:def.bzl", "go_library", "go_test")

go_library(
name = "go_default_library",
srcs = [
"event.go",
"hook.go",
"topics.go",
],
importpath = "github.com/uber/submitqueue/api/base/hook",
visibility = ["//visibility:public"],
deps = [
"//api/base/hook/protopb:go_default_library",
"//api/base/messagequeue/protopb:go_default_library",
"//platform/consumer:go_default_library",
"@org_golang_google_protobuf//encoding/protojson:go_default_library",
"@org_golang_google_protobuf//proto:go_default_library",
],
)

go_test(
name = "go_default_test",
srcs = ["hook_test.go"],
embed = [":go_default_library"],
deps = [
"@com_github_stretchr_testify//assert:go_default_library",
"@com_github_stretchr_testify//require:go_default_library",
"@org_golang_google_protobuf//proto:go_default_library",
"@org_golang_google_protobuf//types/known/structpb:go_default_library",
],
)
49 changes: 49 additions & 0 deletions api/base/hook/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Hook event contract

The published, language-neutral contract for hook events: fire-and-forget lifecycle notifications that let integrations react to a pipeline transition without being able to stall or fail the pipeline. See [the hooks framework RFC](../../../doc/rfc/hook-framework.md) for the design and [the message queue contract RFC](../../../doc/rfc/messagequeue-contract.md) for the conventions it follows.

It lives under `api/base/` rather than `api/{domain}/` because no domain owns it. Every domain publishes this same shape to its own hook topic, so a sink consuming several domains reads one schema rather than one per producer.

Payloads are defined as proto3 messages in [`proto/hook.proto`](proto/hook.proto) and generated into [`protopb/`](protopb); the proto is the authority and a non-Go client compiles against it directly. On the wire, payloads are serialized as protobuf JSON (`protojson`), so the queue keeps storing self-describing JSON. The Go helpers here are generic `protojson` glue — `Marshal(m)` and `Unmarshal[T](b, m)` — plus the two rules that must be identical across producers: how an id is minted and what makes an event well-formed. Field names stay snake_case (`UseProtoNames`) and `int64` fields serialize as JSON strings.

## The envelope

`HookEvent` carries `id`, `source`, `type`, `timestamp_ms`, `version`, and `payload`. The envelope holds only what every consumer keys on uniformly; everything specific to what happened lives in the payload.

`source` and `type` are open strings rather than enums, and `payload` is a `google.protobuf.Struct` rather than a `oneof`. That is the central trade: a producer adds a new event type by publishing it, instead of by changing the wire contract and redeploying every consumer. protojson rejects unknown *enum* values, so an enum here would break existing consumers on every addition.

Subject, queue, and error are deliberately **not** on the envelope. They are facts about a particular occurrence, so they belong in the payload — no major event platform carries a top-level error either.

## Identity and idempotency

`id` is derived from the transition, not random: `source`, `type`, the subject's id, and the subject's post-transition `version`, joined. `NewEventID` mints it. Replaying the delivery that caused the transition therefore mints the *same* id, which is what lets the queue dedupe the redelivery and lets a hook stay idempotent by keying on it. That derivation is why the framework needs no transactional outbox: the publish rides inside the delivery that performed the state write, and a crash before the ack replays both halves safely.

When a transition is not a versioned write there is no version to distinguish occurrences, so the id of the message that caused it stands in, plus an ordinal when one cause publishes several same-typed events. `NewUnversionedEventID` mints that form.

Consumers never parse an id. It is a dedupe and idempotency key, not a structured field.

## Staleness

`version` is the subject's optimistic-locking version immediately after the transition, and `0` when the transition was not a versioned write. Delivery is at-least-once, so a hook can receive an event describing a transition that has since been superseded; comparing this version against the subject's current version in the store is how it tells the two apart. Timestamps cannot answer that, because the clocks belong to different machines.

A domain with no versioned entities (Runway holds no durable state of its own) publishes `0` throughout. That is the normal mode for such a producer, not a degenerate case.

## Payload

Shaped per `type` by the domain that publishes it, add-only, and documented by that domain. It must carry the subject's id, and it must carry any fact recorded nowhere else — merge step outcomes, build failure detail — because for those the event is the only durable record.

It must **not** be an entity snapshot. A snapshot is stale the moment it is redelivered, it competes with the store as a source of truth, and it drags a domain's schema into a contract shared by every domain. Hooks resolve entities from their stores.

## Topic keys

The binding between a topic key and its payload lives in the message's `topic_keys` option (defined in `api/base/messagequeue`); `TopicKeys` reads it back by reflection. A topic key is a stable logical name, not a concrete wire topic — each implementer maps the key to whatever topic name its broker/queue requires, via `consumer.TopicRegistry` in our Go wiring.

| Message | Direction | Topic key |
|---|---|---|
| `HookEvent` | producing domain → hook dispatcher | `hook` |

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.

## Evolution

Contract changes are additive-only: add new fields; never remove, rename, repurpose, or retype an existing field, and never reuse a field number. protojson ignores unknown fields on read and omits zero-valued fields on write, so a new optional field is backward-compatible in both directions. New event types and new payload keys are not contract changes at all — that is the point of the open envelope.
72 changes: 72 additions & 0 deletions api/base/hook/event.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Copyright (c) 2026 Uber Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package hook

import (
"fmt"
"strconv"
"strings"
)

// Consumers never parse an id, so this is a minting convention rather than a
// wire format. All it must guarantee is that two different transitions cannot
// join to the same string.
const idSeparator = "/"

// NewEventID mints the id of an event describing a versioned state write.
//
// Deriving the id rather than randomizing it is what makes replay safe: the same
// transition mints the same id, so the queue dedupes a redelivery and a hook
// stays idempotent without a publisher-side outbox. version is the subject's
// version immediately after the write, which is what separates two transitions
// of the same subject.
func NewEventID(source, eventType, subjectID string, version int32) string {
return strings.Join([]string{source, eventType, subjectID, strconv.Itoa(int(version))}, idSeparator)
}

// NewUnversionedEventID mints the id of an event whose transition was not a
// versioned write, so no version distinguishes one occurrence from the next.
//
// The causing message's id stands in for the version, being stable across
// redeliveries for the same reason a version is. ordinal separates several
// same-typed events published for one cause; pass 0 when there is only one.
func NewUnversionedEventID(source, eventType, subjectID, causeID string, ordinal int) string {
return strings.Join(
[]string{source, eventType, subjectID, causeID, strconv.Itoa(ordinal)},
idSeparator,
)
}

// Validate reports whether e carries the three envelope fields every consumer
// keys on. The rest cannot be checked generically: version is legitimately 0 for
// an unversioned transition and payload is shaped per type.
//
// Both sides call it — a publisher to catch a malformed event before it reaches
// the queue, a consumer because the producer may not have.
func Validate(e *HookEvent) error {
if e == nil {
return fmt.Errorf("hook event is nil")
}
if e.GetId() == "" {
return fmt.Errorf("hook event has no id")
}
if e.GetSource() == "" {
return fmt.Errorf("hook event %q has no source", e.GetId())
}
if e.GetType() == "" {
return fmt.Errorf("hook event %q has no type", e.GetId())
}
return nil
}
62 changes: 62 additions & 0 deletions api/base/hook/hook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright (c) 2026 Uber Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package hook holds the hook event contract: the wire payload every domain
// publishes to its own hook topic for fire-and-forget lifecycle side effects.
package hook

import (
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"

"github.com/uber/submitqueue/api/base/hook/protopb"
basemqpb "github.com/uber/submitqueue/api/base/messagequeue/protopb"
)

// HookEvent aliases the generated binding so callers reference the contract
// through this package rather than protopb.
type HookEvent = protopb.HookEvent

// UseProtoNames keeps JSON field names snake_case, matching the declared
// contract rather than protojson's default lowerCamelCase.
var marshalOpts = protojson.MarshalOptions{UseProtoNames: true}

// DiscardUnknown makes an additive contract change backward-compatible: a field
// this consumer does not know yet is ignored rather than rejected.
var unmarshalOpts = protojson.UnmarshalOptions{DiscardUnknown: true}

// Marshal serializes a contract message to protojson bytes for the queue payload.
func Marshal(m proto.Message) ([]byte, error) {
return marshalOpts.Marshal(m)
}

// Unmarshal deserializes protojson bytes into the contract message m.
func Unmarshal[T proto.Message](b []byte, m T) error {
return unmarshalOpts.Unmarshal(b, m)
}

// TopicKeys returns the logical topic keys bound to a message via the
// topic_keys proto option, or nil if it declares none. These are not wire topic
// names; a caller maps each key to its backend's topic.
func TopicKeys(m proto.Message) []string {
opts := m.ProtoReflect().Descriptor().Options()
if opts == nil {
return nil
}
keys, ok := proto.GetExtension(opts, basemqpb.E_TopicKeys).([]string)
if !ok {
return nil
}
return keys
}
Loading
Loading