1import * as GCP from "alchemy/GCP";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import { EventsTable, Inbox, type EventRow } from "./resources.ts";
5
6/** One pull returns at most this many messages. */
7const BATCH = 100;
8
9/**
10 * The batch half of the pipeline: drain Pub/Sub into BigQuery.
11 *
12 * A Cloud Run Job is the right host for this. It runs to completion and
13 * exits, so nothing is billed between drains, and Cloud Scheduler or an
14 * operator can start it whenever a batch is due.
15 *
16 * The order matters: insert first, ack second. A crash between them
17 * redelivers the batch and duplicates rows, which a `SELECT DISTINCT id`
18 * removes; acking first would lose events outright.
19 */
20export default class Drain extends GCP.Run.Job<Drain>()(
21 "Drain",
22 {
23 main: import.meta.url,
24 },
25 Effect.gen(function* () {
26 const inbox = yield* Inbox;
27 const table = yield* EventsTable;
28
29 // pubsub.subscriber on the subscription, and bigquery.dataEditor on
30 // the table — nothing on the project.
31 const subscription = yield* GCP.PubSub.ReadSubscription(inbox);
32 const warehouse = yield* GCP.BigQuery.WriteTable(table);
33
34 return {
35 // A pull may return fewer messages than are waiting, so drain
36 // batch by batch until one comes back empty.
37 run: Effect.gen(function* () {
38 // A pull waits for messages; an empty subscription answers
39 // nothing, so a quiet 10 seconds means the backlog is drained.
40 const received = yield* subscription
41 .pull({ maxMessages: BATCH })
42 .pipe(Effect.timeoutOption("10 seconds"));
43 const messages = Option.getOrElse(received, () => []);
44 if (messages.length === 0) {
45 yield* Effect.log("drain: nothing left");
46 return 0;
47 }
48
49 const rows = messages.map(
50 (message) => JSON.parse(message.text) as EventRow,
51 );
52
53 // insertIds make the streaming insert idempotent inside
54 // BigQuery's dedup window, so a redelivered batch collapses.
55 yield* warehouse.insert(rows, { insertIds: rows.map((row) => row.id) });
56
57 yield* subscription.acknowledge(
58 messages.map((message) => message.ackId),
59 );
60
61 yield* Effect.log(`drain: wrote ${rows.length} row(s)`);
62 return messages.length;
63 }).pipe(
64 Effect.repeat({ until: (count) => count === 0 }),
65 Effect.asVoid,
66 Effect.orDie,
67 ),
68 };
69 }).pipe(
70 Effect.provide([
71 GCP.PubSub.ReadSubscriptionHttp,
72 GCP.BigQuery.WriteTableHttp,
73 ]),
74 ),
75) {}