Skip to content

Commit 8ea502e

Browse files
committed
docs(rfc): add message queue contract RFC
Define how queue payloads are defined, located by audience, and bound to topic keys. Payloads are Protobuf serialized as protobuf JSON: the .proto is the language-neutral authority, the Go binding is generated from it (so it cannot drift), and topic keys bind to payloads via a custom proto option. External contracts live in api/{domain}/messagequeue, internal in {domain}/core/messagequeue, with the split enforced by Bazel visibility.
1 parent aac3892 commit 8ea502e

2 files changed

Lines changed: 105 additions & 0 deletions

File tree

doc/rfc/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ Design documents and technical proposals, grouped by scope. Shared/cross-cutting
55
## Shared
66

77
- [SQL-Based Distributed Queue](sql-queue-rfc.md) - MySQL-based distributed message queue with partition leasing and at-least-once delivery (used by SubmitQueue, Stovepipe, and other repo-local services)
8+
- [Message Queue Contract](messagequeue-contract.md) - How queue payloads are defined (Protobuf, serialized as protobuf JSON), located by audience (external in `api/{domain}/messagequeue/`, internal in `{domain}/core/messagequeue/`), bound to topics (the `topics` proto option), and enforced by Bazel visibility
89

910
## SubmitQueue
1011

doc/rfc/messagequeue-contract.md

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# Message Queue Contract
2+
3+
How queue payloads are defined, located, and bound to topics across domains.
4+
5+
## Problem
6+
7+
Queue payloads are Go structs serialized with `encoding/json` (`submitqueue/entity`, `runway/entity`), so the wire shape is defined only by Go source. Three gaps:
8+
9+
- **No language-neutral contract.** Some payloads cross a domain boundary — a client written in another language has nothing to compile or validate against.
10+
- **No topic-to-payload binding.** `consumer.TopicRegistry` maps a `TopicKey` to a backend, topic name, and subscription — but not to the payload schema. That knowledge lives implicitly in whichever controller (de)serializes.
11+
- **No audience distinction.** Nothing separates private wiring between our own services from a published cross-domain contract.
12+
13+
## Decisions
14+
15+
### Contract language: Protobuf
16+
17+
Payloads are defined as **proto3 messages**. The `.proto` is the language-neutral authority, and the Go binding is generated from it. This is the same mechanism the RPC contracts in `api/` already use, so queue payloads and RPC payloads share one toolchain, one set of shared field types, and one mental model. Generation runs through the repo's existing hermetic `protoc` Bazel rule (`tool/proto`); message-only contracts (no service) skip the gRPC/YARPC plugins.
18+
19+
The decisive property is that the binding is *generated*, not hand-authored. There is no separate Go struct that can drift from the contract, and therefore no drift test to keep them in sync — the only binding is the generated one.
20+
21+
### Wire format: protobuf JSON
22+
23+
Payloads stay JSON on the wire; messages are serialized with **protobuf JSON (`protojson`)**, not binary proto. The MySQL-backed queue keeps storing self-describing JSON, exactly as before — only the source of the (de)serialization changes from a hand-written `encoding/json` struct to a generated message.
24+
25+
protojson has its own conventions, which the contract adopts deliberately:
26+
27+
- **Field names are the proto names (snake_case).** Serialized with `UseProtoNames`, so `queue_name` stays `queue_name` rather than protojson's default `queueName`. The wire matches the declared field names.
28+
- **Enums serialize as their value name in UPPER_SNAKE** (`REBASE`, `SQUASH_REBASE`, `MERGE`) — the proto-conventional spelling.
29+
- **64-bit integers serialize as strings.** A protojson rule for cross-language safety; relevant for any millisecond timestamp or count a future payload carries.
30+
- **Unknown fields are ignored on read** and zero-valued fields are omitted on write, which gives additive evolution for free: a consumer skips fields it does not yet know.
31+
32+
### Location: audience decides
33+
34+
A contract is **external** when something outside its owning domain depends on it — another domain's service, or a client written in another language. It is **internal** when only the owning domain's own services use it. The test is concrete: *does anything outside this domain compile or deserialize against it?*
35+
36+
- **External**`api/{domain}/messagequeue/`. The `api/` prefix is the published surface; outside code is expected to depend on it.
37+
- **Internal**`{domain}/core/messagequeue/`.
38+
39+
The `.proto`, its generated `protopb`, and any Go helpers co-locate in each home; only the home differs.
40+
41+
### Visibility
42+
43+
Bazel [`visibility`](https://bazel.build/concepts/visibility) enforces the split: `{domain}/core/messagequeue/` targets are scoped to the owning domain, so depending on one from outside is a build error; `api/` targets are public. No metadata keyword — the directory carries the distinction.
44+
45+
### Topic-key binding: the `topic_keys` option
46+
47+
Each payload message declares a **`topic_keys`** option: the stable logical topic keys that carry it (a message may list several — one payload can serve a queue pair). It is the single source of truth; the reverse index (key → message) is derivable, not authored. A topic key is **not** a concrete wire name — each implementer maps the key to whatever topic name its broker/queue requires (subject to that backend's naming constraints). On our Go side the keys are the `consumer.TopicKey` values, mapped to concrete names through the `TopicRegistry`.
48+
49+
`topic_keys` is a custom proto option — an extension of `google.protobuf.MessageOptions` defined once in `api/base/messagequeue` — so the binding travels with the message in its compiled descriptor, readable by any proto consumer in any language rather than living in out-of-band Go wiring.
50+
51+
**How it's consumed.** The option is read off the message descriptor by reflection; it is *not* on the publish/consume hot path. Publishing and consuming still resolve a concrete topic name from a `consumer.TopicKey` through the `TopicRegistry`, unchanged — the option does not replace that wiring. Instead, two readers consume it:
52+
53+
- A **contract test** reads the option (via a small `TopicKeys(msg)` helper) and asserts that every `consumer.TopicKey` the domain registers is carried by exactly one message, and that no option names an unknown key. This is the guard that keeps the language-neutral binding and the Go wiring from silently drifting apart.
54+
- A **non-Go client** reads the same option straight from the compiled descriptor to discover which key carries which payload, then maps each key to a concrete topic name per its own backend, with no access to our Go types.
55+
56+
So the option earns its place as the single, language-neutral source of truth for the topic-key↔payload binding and as the anchor the drift test checks the Go wiring against — not as a runtime lookup.
57+
58+
### Go binding: the generated `protopb`
59+
60+
The generated message types in `protopb` are the Go binding, sitting beside `proto/` exactly as for the RPC contracts. The contract package adds only thin helpers — `protojson` (de)serialization and the `topic_keys` reflection lookup. Shared field types (`change.Change`, `mergestrategy.MergeStrategy`) are themselves shared protos under `api/base/{change,mergestrategy}/proto`, imported by every contract that needs them.
61+
62+
## Example
63+
64+
Two illustrative payloads. `ExampleRequest` is carried on a single topic;
65+
`ExampleResult` shows the list form — one payload that serves a queue pair, so
66+
it repeats the `topic_keys` option once per topic key:
67+
68+
```proto
69+
syntax = "proto3";
70+
71+
package uber.example.messagequeue;
72+
73+
import "api/base/messagequeue/proto/messagequeue.proto";
74+
75+
message ExampleRequest {
76+
option (uber.base.messagequeue.topic_keys) = "example-request";
77+
78+
string id = 1; // Client-owned correlation id.
79+
string mode = 2; // "fast" or "thorough".
80+
repeated string items = 3;
81+
}
82+
83+
message ExampleResult {
84+
// One shape, two queues: the same result is published under the check-result
85+
// key for a dry run and the merge-result key for a committing run.
86+
option (uber.base.messagequeue.topic_keys) = "example-check-result";
87+
option (uber.base.messagequeue.topic_keys) = "example-merge-result";
88+
89+
string id = 1; // Echoes the request's correlation id.
90+
bool success = 2;
91+
}
92+
```
93+
94+
A conforming `ExampleRequest` wire value:
95+
96+
```json
97+
{ "id": "req-42", "mode": "fast", "items": ["a", "b"] }
98+
```
99+
100+
## Rejected
101+
102+
- **JSON Schema for payloads.** A hand-authored schema duplicates the message definition and needs a drift test to stay in sync with a hand-authored Go struct. Proto generates the Go binding from the one definition, so the duplication — and the test guarding it — disappears; the contract also shares the toolchain and shared types with the RPC surface.
103+
- **Binary proto / Avro on the wire.** Binary loses the self-describing JSON the MySQL-backed queue relies on, and Avro's value is a schema registry for decoding binary, which we do not have. protojson keeps the wire as JSON while still generating the binding.
104+
- **One unified `api/` tree with audience as metadata.** Fine for inert schemas, but co-locating the generated binding pulls internal types into the published surface; a directory boundary matching audience is more honest.

0 commit comments

Comments
 (0)