1# gcp-pubsub-fanout
2
3Event fan-out on Cloud Run with Pub/Sub push. A public orders API
4publishes one event per order change to a single topic. Two private
5consumers subscribe to it independently: one sends confirmation emails,
6the other records every event in BigQuery. Emails that can never be sent
7are dead-lettered instead of retried forever.
8
9```
10 ┌─ type = "order.created" ──> Email ──> gs://…/emails/:eventId.json
11POST /orders ──> Orders ──> OrderEvents │ 5 failed attempts
12POST /orders/:id/cancel │ v
13 │ DeadOrderEvents ──> DeadOrderEventsInbox
14 └─ every event ──> Analytics ──> BigQuery order_events
15```
16
17- `src/resources.ts` — the `OrderEvents` topic, the `DeadOrderEvents`
18 topic and its `DeadOrderEventsInbox` pull subscription, the `Outbox`
19 bucket, and the `Warehouse` dataset with its `order_events` table.
20- `src/Orders.ts` — public `GCP.Function`, the producer:
21 - `POST /orders` `{ email, total }` publishes `order.created`.
22 - `POST /orders/:id/cancel` `{ email }` publishes `order.cancelled`.
23 - Both return `202 { orderId, eventId }`. The event type is also set as
24 the message's `type` attribute, which is what subscription filters
25 match on.
26- `src/Email.ts` — private `GCP.Function`. Its subscription filters on
27 `attributes.type = "order.created"`, so Pub/Sub never pushes it any
28 other event type. It writes one "sent email" object per event to the
29 outbox. Mail to a `.invalid` address always fails; after 5 attempts
30 Pub/Sub forwards the event to `DeadOrderEvents`.
31- `src/Analytics.ts` — private `GCP.Function` with no filter. It inserts a
32 row for every event, using the event id as the BigQuery `insertId` so a
33 redelivery does not duplicate it.
34
35Adding a third consumer is one more `consumeTopicMessages` on the topic;
36the producer does not change.
37
38## Bindings and event sources
39
40| Host | Binding / event source | IAM granted |
41| --------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
42| Orders | `GCP.PubSub.WriteTopic(OrderEvents)` | `roles/pubsub.publisher` on the topic, to the host's service account |
43| Email | `GCP.Storage.WriteBucket(Outbox)` | `roles/storage.objectUser` on the bucket, to the host's service account |
44| Email | `GCP.PubSub.consumeTopicMessages(OrderEvents, { filter, deadLetter })` | `roles/run.invoker` on Email, to its own service account; `roles/pubsub.publisher` on `DeadOrderEvents` and `roles/pubsub.subscriber` on the push subscription, to the Pub/Sub service agent |
45| Analytics | `GCP.BigQuery.WriteTable(OrderEventsTable)` | `roles/bigquery.dataEditor` on the table, to the host's service account |
46| Analytics | `GCP.PubSub.consumeTopicMessages(OrderEvents)` | `roles/run.invoker` on Analytics, to its own service account |
47
48Both consumers use `GCP.Run.TopicEventSource`, the push implementation.
49Each call creates its own push subscription on the topic, pointed at
50`/__alchemy/pubsub/orderevents` on the consuming service and signed with
51an OIDC token for that service's runtime account. The runtime verifies the
52token before the handler runs, so the consumers stay private. A handler
53that fails answers `500`, and Pub/Sub redelivers.
54
55The dead-letter option:
56
57```ts
58yield* GCP.PubSub.consumeTopicMessages(
59 orders,
60 {
61 filter: 'attributes.type = "order.created"',
62 deadLetter: { topic: deadOrders, maxDeliveryAttempts: 5 },
63 },
64 handler,
65);
66```
67
68With `deadLetter` set, the subscription also gets a 10s–600s exponential
69retry backoff (override it with `retryPolicy`). Without it, Pub/Sub
70redelivers a failed push almost at once, and all 5 attempts could be spent
71in the seconds a fresh deploy's `run.invoker` grant takes to propagate,
72dead-lettering good orders.
73
74Pub/Sub forwards dead letters as the project's service agent
75(`service-{projectNumber}@gcp-sa-pubsub.iam.gserviceaccount.com`); the
76subscription grants it the two roles it needs. A topic with no
77subscription drops what it receives, which is why the dead-letter topic
78has `DeadOrderEventsInbox`. Dead letters keep their original attributes
79and gain `CloudPubSubDeadLetterSourceSubscription`.
80
81## Deploy
82
83Requires Docker (the services are built from `main`). Credentials come from your alchemy profile: run `alchemy profile` once and pick GCP (*Service account JSON* for a key file, or *Stored* for an access token or key kept in `~/.alchemy/credentials`, plus a default region), then deploy with `--profile <name>`.
84
85```sh
86pnpm deploy --profile <name>
87```
88
89```sh
90curl -X POST "$url/orders" -H 'content-type: application/json' \
91 -d '{"email":"ada@example.com","total":42}'
92curl -X POST "$url/orders/$orderId/cancel" -H 'content-type: application/json' \
93 -d '{"email":"ada@example.com"}'
94gcloud storage ls "gs://$bucketName/emails/"
95gcloud pubsub subscriptions pull "$deadLetterSubscription" --auto-ack
96```
97
98## Test
99
100```sh
101ALCHEMY_PROFILE=<name> bun test
102```
103
104Deploys the stack and checks both push subscriptions (filter, dead-letter
105policy, the service agent's grants). It then places orders and waits for
106each one's email object in Cloud Storage and its row in BigQuery; cancels
107an order and checks the cancellation reaches BigQuery but not the email
108consumer; and places an order to a `.invalid` address and pulls it from
109the dead-letter subscription. Finally it destroys the stack and checks the
110services, subscriptions, topics, bucket, and dataset are gone.
111
112## Destroy
113
114```sh
115pnpm destroy --profile <name>
116```
117
118The bucket and dataset use `forceDestroy: true`, so destroy removes their
119contents too. Drop it for real data.