pi-roundtable-line 0.0.0-stage → 0.9.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/inbox.ts ADDED
@@ -0,0 +1,760 @@
1
+ import type { SQL } from "bun";
2
+ import type { Logger } from "pi-roundtable";
3
+ import { errorCode, LineError } from "./errors.ts";
4
+ import type { PgSources } from "./sources.ts";
5
+
6
+ /** One stored event as the processor sees it. */
7
+ export interface InboxEvent {
8
+ id: string;
9
+ type: string;
10
+ sourceKey: string | null;
11
+ messageId: string | null;
12
+ /** LINE's event time, milliseconds since the epoch. */
13
+ timestamp: number;
14
+ /** The event as LINE sent it. */
15
+ payload: Record<string, unknown>;
16
+ /** How many times this event has been handed out, this one included. */
17
+ attempt: number;
18
+ }
19
+
20
+ /**
21
+ * What a processor answers: nothing (or `undefined`) when the event is done, `{ retryInMs }` to
22
+ * look at it again later without counting a failure. A thrown error is a failure: the event is
23
+ * retried with backoff and, after `maxAttempts` handings out, set aside as dead. Only the error's
24
+ * `code` word is stored, never its message.
25
+ */
26
+ export type InboxResult = undefined | { retryInMs: number };
27
+ export type InboxProcessor = (
28
+ event: InboxEvent,
29
+ /**
30
+ * Aborted when the handler has run `maxHandleMs`, when the worker stops, and when the worker
31
+ * finds the event is no longer its own (the lease was lost); give up whatever it waits on.
32
+ * `signal.reason` is a `LineError` coded `handler_timeout`, `worker_stopped` or `lease_lost`.
33
+ * To give up, throw (`throw signal.reason`, or the abort error a fetch rejects with) or return
34
+ * `{ retryInMs }`: returning `undefined` means the event was handled, and it is marked done.
35
+ */
36
+ signal: AbortSignal,
37
+ ) => Promise<InboxResult>;
38
+
39
+ export interface InboxOptions {
40
+ /** How long a claimed event may take before another worker may take it; default 2 minutes. At most 2_147_483_647. */
41
+ leaseMs: number;
42
+ /**
43
+ * Longest a handler may run, renewals included; default 10 minutes. Past it the processor's signal
44
+ * is aborted, the lease is no longer renewed and the event fails with `handler_timeout` (retried
45
+ * with backoff, dead after `maxAttempts`), so a handler that never settles cannot hold its chat or
46
+ * the shutdown. At most 2_147_483_647, the longest delay a timer can hold.
47
+ */
48
+ maxHandleMs: number;
49
+ /** Handings out after which a failing event is dead; default 12. */
50
+ maxAttempts: number;
51
+ /** Retries that asked for later (`retryInMs`) before the event is dead; default 720. */
52
+ maxRequeues: number;
53
+ /** First backoff; each failure doubles it, up to `backoffCapMs`; default 5 seconds. */
54
+ backoffBaseMs: number;
55
+ /** Longest backoff; default 1 hour. */
56
+ backoffCapMs: number;
57
+ /** Events handled at once, always of different chats; default 2. */
58
+ concurrency: number;
59
+ /** How long the worker waits between looks at the inbox when nothing woke it; default 2 seconds. At most 2_147_483_647. */
60
+ pollMs: number;
61
+ /** How long a `unsend` or edit waits for a message that has not reached the inbox; default 10 minutes. */
62
+ messageGraceMs: number;
63
+ /** How long an event waits for the message it depends on while that message is in the inbox; default 5 seconds. */
64
+ dependencyRetryMs: number;
65
+ }
66
+
67
+ export const INBOX_DEFAULTS: InboxOptions = {
68
+ leaseMs: 2 * 60_000,
69
+ maxHandleMs: 10 * 60_000,
70
+ maxAttempts: 12,
71
+ maxRequeues: 720,
72
+ backoffBaseMs: 5_000,
73
+ backoffCapMs: 60 * 60_000,
74
+ concurrency: 2,
75
+ pollMs: 2_000,
76
+ messageGraceMs: 10 * 60_000,
77
+ dependencyRetryMs: 5_000,
78
+ };
79
+
80
+ /** The events that act on a message an earlier event brought. */
81
+ const DEPENDENT = new Set(["unsend", "messageEdited"]);
82
+
83
+ /**
84
+ * Exponential backoff with jitter: the delay doubles with each failure up to the cap, and the
85
+ * jitter spreads it over its upper half so a burst of failures does not retry in step.
86
+ */
87
+ export function backoffMs(
88
+ attempt: number,
89
+ options: Pick<InboxOptions, "backoffBaseMs" | "backoffCapMs">,
90
+ random: () => number = Math.random,
91
+ ): number {
92
+ const exponent = Math.min(Math.max(attempt - 1, 0), 30);
93
+ const raw = Math.min(
94
+ options.backoffCapMs,
95
+ options.backoffBaseMs * 2 ** exponent,
96
+ );
97
+ return Math.round(raw * (0.5 + random() * 0.5));
98
+ }
99
+
100
+ interface Claimed {
101
+ seq: string | number;
102
+ webhook_event_id: string;
103
+ source_key: string | null;
104
+ event_type: string;
105
+ message_id: string | null;
106
+ event_ts: string | number;
107
+ payload: Record<string, unknown> | null;
108
+ attempts: number;
109
+ requeues: number;
110
+ created_at: Date;
111
+ claimed_at: Date;
112
+ }
113
+
114
+ /** The claim an event is handled under; every write that ends the handling is fenced by it. */
115
+ interface Lease {
116
+ /** `claimed_at` as the row holds it now, moved on by each renewal. */
117
+ at: Date;
118
+ }
119
+
120
+ export interface InboxWorkerDeps {
121
+ sql: SQL;
122
+ logger: Logger;
123
+ now: () => Date;
124
+ sources: PgSources;
125
+ /** The processor to hand events to; while there is none the worker claims nothing. */
126
+ processor: () => InboxProcessor | undefined;
127
+ options?: Partial<InboxOptions>;
128
+ /** Replaces `Math.random`, for tests. */
129
+ random?: () => number;
130
+ }
131
+
132
+ /**
133
+ * Takes stored events one chat at a time, oldest event first, and hands them to the processor.
134
+ * Delivery is at least once: a handler that outlives its lease (the worker's process died, or it
135
+ * could not renew) may run again, so a processor must be idempotent.
136
+ *
137
+ * - A claim is `FOR UPDATE SKIP LOCKED` under a per-chat advisory lock, so two workers (or two
138
+ * processes) do not hold events of one chat together while leases are kept.
139
+ * - A claimed event has a lease, which the worker renews while the handler runs; one still claimed
140
+ * when the lease ends goes back to waiting, and that is counted as a handing out, so an event that
141
+ * crashes its worker is eventually dead. Every write that ends a handling is fenced by its claim,
142
+ * so a handler that lost the event cannot change what the next claim wrote.
143
+ * - Order is by LINE's event time among the events that are due; an event that is backing off does
144
+ * not hold the ones behind it, because a stuck head would then stall a whole chat. The events
145
+ * that act on a message wait, without limit, until the message and the earlier events on it are
146
+ * settled, so events of one message run strictly in order.
147
+ * - Only the events of allowed chats (and the join, leave and unsend of any chat that is not blocked)
148
+ * are handed out; what a blocked chat still has waiting is dropped with its payload, and what a
149
+ * chat the bot left or one not decided has waiting is dropped too, apart from those three.
150
+ * - A finished event keeps its row and loses its payload.
151
+ */
152
+ export class InboxWorker {
153
+ readonly #sql: SQL;
154
+ readonly #logger: Logger;
155
+ readonly #now: () => Date;
156
+ readonly #sources: PgSources;
157
+ readonly #processor: () => InboxProcessor | undefined;
158
+ readonly #random: () => number;
159
+ readonly options: InboxOptions;
160
+ #running = false;
161
+ #timer: ReturnType<typeof setTimeout> | undefined;
162
+ #wake = false;
163
+ readonly #inflight = new Set<Promise<void>>();
164
+ readonly #active = new Set<string>();
165
+ /** One per handling in progress, aborted when the worker stops. */
166
+ readonly #aborts = new Set<AbortController>();
167
+ #stopping = false;
168
+ #loop: Promise<void> | undefined;
169
+
170
+ constructor(deps: InboxWorkerDeps) {
171
+ this.#sql = deps.sql;
172
+ this.#logger = deps.logger;
173
+ this.#now = deps.now;
174
+ this.#sources = deps.sources;
175
+ this.#processor = deps.processor;
176
+ this.#random = deps.random ?? Math.random;
177
+ this.options = { ...INBOX_DEFAULTS, ...deps.options };
178
+ }
179
+
180
+ /** Event ids being handled now, one entry each, for the shutdown drain. */
181
+ busy(): string[] {
182
+ return [...this.#active];
183
+ }
184
+
185
+ /** Looks at the inbox now instead of at the next poll. */
186
+ kick(): void {
187
+ this.#wake = true;
188
+ if (this.#timer) {
189
+ clearTimeout(this.#timer);
190
+ this.#timer = undefined;
191
+ this.#resume?.();
192
+ }
193
+ }
194
+
195
+ #resume: (() => void) | undefined;
196
+
197
+ /** Puts back events whose lease ran out; returns how many. */
198
+ async recoverLeases(): Promise<number> {
199
+ const now = this.#now();
200
+ const expired = new Date(now.getTime() - this.options.leaseMs);
201
+ // Events this worker is handling renew their own lease; it does not take them back.
202
+ const own = JSON.stringify([...this.#active]);
203
+ const rows: { attempts: number }[] = await this.#sql`UPDATE line_inbox
204
+ SET status = CASE WHEN attempts >= ${this.options.maxAttempts} THEN 'dead' ELSE 'pending' END,
205
+ next_attempt_at = ${now},
206
+ claimed_at = NULL,
207
+ payload = CASE WHEN attempts >= ${this.options.maxAttempts} THEN NULL ELSE payload END,
208
+ completed_at = CASE WHEN attempts >= ${this.options.maxAttempts} THEN ${now}::timestamptz ELSE NULL::timestamptz END,
209
+ last_error_code = 'lease_expired'
210
+ WHERE status = 'processing' AND claimed_at < ${expired}
211
+ AND webhook_event_id NOT IN (SELECT jsonb_array_elements_text(${own}::text::jsonb))
212
+ RETURNING attempts`;
213
+ if (rows.length > 0)
214
+ this.#logger.warn(
215
+ { count: rows.length },
216
+ "LINE inbox events put back after their lease ran out",
217
+ );
218
+ return rows.length;
219
+ }
220
+
221
+ /**
222
+ * Claims the oldest due event whose chat has none being handled, and hands it to the processor.
223
+ * Returns false when nothing was due (or there is no processor).
224
+ */
225
+ async runOnce(): Promise<boolean> {
226
+ const taken = await this.#take();
227
+ if (!taken) return false;
228
+ this.#active.add(taken.event.webhook_event_id);
229
+ try {
230
+ await this.#handle(taken.event, taken.processor);
231
+ } finally {
232
+ this.#active.delete(taken.event.webhook_event_id);
233
+ }
234
+ return true;
235
+ }
236
+
237
+ /**
238
+ * Claims the oldest due event and the processor to hand it to. The processor is looked up again
239
+ * after the claim, which takes time: one detached by then leaves the event waiting (not counted as a
240
+ * handing out), and one attached in the meantime is the one that gets it. Once `stop` has been
241
+ * called nothing is handed out, so an event claimed as it began goes back too.
242
+ */
243
+ async #take(): Promise<
244
+ { event: Claimed; processor: InboxProcessor } | undefined
245
+ > {
246
+ if (!this.#processor()) return undefined;
247
+ const event = await this.#claim();
248
+ if (!event) return undefined;
249
+ const processor = this.#processor();
250
+ // A stop that began during the claim hands nothing out: the event waits for the next start.
251
+ if (!processor || this.#stopping) {
252
+ await this.#later(
253
+ event,
254
+ { at: event.claimed_at },
255
+ processor ? 0 : this.options.pollMs,
256
+ processor ? "worker_stopped" : "no_processor",
257
+ false,
258
+ );
259
+ return undefined;
260
+ }
261
+ return { event, processor };
262
+ }
263
+
264
+ async #claim(): Promise<Claimed | undefined> {
265
+ const now = this.#now();
266
+ return this.#sql.begin(async (tx) => {
267
+ // A chat the bot left, or one not decided, hands out only its join, leave and unsend. What else
268
+ // waits there (it was backing off when the chat changed) is dropped with its payload, because
269
+ // it could otherwise surface much later, after a rejoin. An unsend carries no content and only
270
+ // takes content down, so it is kept.
271
+ await tx`UPDATE line_inbox i
272
+ SET status = 'skipped', payload = NULL, last_error_code = CASE s.status WHEN 'left' THEN 'source_left' ELSE 'source_pending' END,
273
+ completed_at = ${now}
274
+ FROM line_sources s
275
+ WHERE s.source_key = i.source_key AND s.status IN ('pending', 'left')
276
+ AND i.status = 'pending' AND i.event_type NOT IN ('join', 'leave', 'unsend')`;
277
+ // The oldest due event of each chat that has none in hand, and the events that name no chat.
278
+ const heads: {
279
+ webhook_event_id: string;
280
+ source_key: string | null;
281
+ event_ts: string | number;
282
+ seq: string | number;
283
+ source_status: string | null;
284
+ }[] =
285
+ await tx`(SELECT DISTINCT ON (i.source_key) i.webhook_event_id, i.source_key, i.event_ts, i.seq,
286
+ s.status AS source_status
287
+ FROM line_inbox i
288
+ JOIN line_sources s ON s.source_key = i.source_key
289
+ WHERE i.status = 'pending' AND i.next_attempt_at <= ${now} AND i.source_key IS NOT NULL
290
+ AND (s.status IN ('allowed', 'blocked')
291
+ OR (s.status IN ('pending', 'left') AND i.event_type IN ('join', 'leave', 'unsend')))
292
+ AND NOT EXISTS (SELECT 1 FROM line_inbox p WHERE p.source_key = i.source_key AND p.status = 'processing')
293
+ ORDER BY i.source_key, i.event_ts, i.seq)
294
+ UNION ALL
295
+ (SELECT i.webhook_event_id, i.source_key, i.event_ts, i.seq, NULL AS source_status FROM line_inbox i
296
+ WHERE i.status = 'pending' AND i.next_attempt_at <= ${now} AND i.source_key IS NULL
297
+ ORDER BY i.event_ts, i.seq LIMIT 20)`;
298
+ heads.sort(
299
+ (x, y) =>
300
+ Number(x.event_ts) - Number(y.event_ts) ||
301
+ Number(x.seq) - Number(y.seq),
302
+ );
303
+ for (const head of heads) {
304
+ if (head.source_key) {
305
+ // Every claimer takes the chat's lock before it looks at the chat's events, so none claims
306
+ // an event that has an older one in front of it; a busy lock means another worker has the chat.
307
+ const [lock] =
308
+ (await tx`SELECT pg_try_advisory_xact_lock(hashtextextended(${head.source_key}, 7)) AS held`) as {
309
+ held: boolean;
310
+ }[];
311
+ if (!lock?.held) continue;
312
+ const [busy] = (await tx`SELECT 1 AS busy FROM line_inbox
313
+ WHERE source_key = ${head.source_key} AND status = 'processing' LIMIT 1`) as unknown[];
314
+ if (busy) continue;
315
+ if (head.source_status === "blocked") {
316
+ // What a blocked chat still has waiting is never handed out; the chat is left to the next look.
317
+ await tx`UPDATE line_inbox
318
+ SET status = 'skipped', payload = NULL, last_error_code = 'source_blocked', completed_at = ${now}
319
+ WHERE source_key = ${head.source_key} AND status = 'pending'
320
+ AND EXISTS (SELECT 1 FROM line_sources WHERE source_key = ${head.source_key} AND status = 'blocked')`;
321
+ continue;
322
+ }
323
+ }
324
+ // Locked, then claimed: a row another transaction holds (a block, say) is left to it.
325
+ const claimed: Claimed[] = await tx`UPDATE line_inbox
326
+ SET status = 'processing', claimed_at = ${now}, attempts = attempts + 1
327
+ WHERE webhook_event_id = (
328
+ SELECT webhook_event_id FROM line_inbox
329
+ WHERE webhook_event_id = ${head.webhook_event_id} AND status = 'pending' AND next_attempt_at <= ${now}
330
+ FOR UPDATE SKIP LOCKED)
331
+ RETURNING seq, webhook_event_id, source_key, event_type, message_id, event_ts, payload,
332
+ attempts, requeues, created_at, claimed_at`;
333
+ if (claimed[0]) return claimed[0];
334
+ }
335
+ return undefined;
336
+ });
337
+ }
338
+
339
+ async #handle(row: Claimed, processor: InboxProcessor): Promise<void> {
340
+ const log = this.#logger.child({
341
+ event: row.webhook_event_id,
342
+ type: row.event_type,
343
+ attempt: row.attempts,
344
+ });
345
+ const lease: Lease = { at: row.claimed_at };
346
+ const controller = new AbortController();
347
+ this.#aborts.add(controller);
348
+ if (this.#stopping) controller.abort(stopped());
349
+ const beat = this.#keepLease(row, lease, log, controller);
350
+ try {
351
+ const wait = await this.#mustWait(row);
352
+ if (wait !== undefined) {
353
+ await beat.stop();
354
+ return await this.#later(
355
+ row,
356
+ lease,
357
+ wait,
358
+ "waiting_for_message",
359
+ false,
360
+ );
361
+ }
362
+ const timestamp = Number(row.event_ts);
363
+ if (row.source_key && row.event_type === "join")
364
+ await this.#sources.markJoined(row.source_key, timestamp);
365
+ if (row.source_key && row.event_type === "leave")
366
+ await this.#sources.markLeft(row.source_key, timestamp);
367
+ // The waits above take time too: a processor detached or replaced meanwhile gets nothing,
368
+ // and the event waits, uncounted, for the one attached now.
369
+ if (this.#processor() !== processor) {
370
+ await beat.stop();
371
+ return await this.#later(
372
+ row,
373
+ lease,
374
+ this.options.pollMs,
375
+ "no_processor",
376
+ false,
377
+ );
378
+ }
379
+ const result = await this.#callProcessor(
380
+ processor,
381
+ {
382
+ id: row.webhook_event_id,
383
+ type: row.event_type,
384
+ sourceKey: row.source_key,
385
+ messageId: row.message_id,
386
+ timestamp,
387
+ payload: row.payload ?? {},
388
+ attempt: row.attempts,
389
+ },
390
+ controller,
391
+ );
392
+ await beat.stop();
393
+ if (result && Number.isFinite(result.retryInMs))
394
+ return await this.#later(
395
+ row,
396
+ lease,
397
+ Math.max(0, result.retryInMs),
398
+ "retry_requested",
399
+ true,
400
+ );
401
+ this.#settled(
402
+ row,
403
+ await this.#sql`UPDATE line_inbox
404
+ SET status = 'done', payload = NULL, claimed_at = NULL, last_error_code = NULL, completed_at = ${this.#now()}
405
+ WHERE webhook_event_id = ${row.webhook_event_id} AND status = 'processing' AND claimed_at = ${lease.at}
406
+ RETURNING 1`,
407
+ );
408
+ } catch (error) {
409
+ const code = errorCode(error);
410
+ await beat.stop();
411
+ // Giving up on being told the worker stops is the restart's doing, not the event's: it goes
412
+ // back without being counted. Any other error the handler throws meanwhile is its own, and so
413
+ // is a timeout.
414
+ if (isStopped(controller.signal) && gaveUp(error, controller.signal)) {
415
+ await this.#later(row, lease, 0, "worker_stopped", false);
416
+ return;
417
+ }
418
+ log.warn({ code }, "a LINE inbox event failed");
419
+ await this.#fail(row, lease, code);
420
+ } finally {
421
+ await beat.stop();
422
+ this.#aborts.delete(controller);
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Runs the processor for at most `maxHandleMs`. Past that its signal is aborted and the call
428
+ * fails with `handler_timeout` while the handler is left behind: whatever it settles with later
429
+ * is ignored, and its writes to the inbox are refused by the claim fence.
430
+ */
431
+ async #callProcessor(
432
+ processor: InboxProcessor,
433
+ event: InboxEvent,
434
+ controller: AbortController,
435
+ ): Promise<InboxResult> {
436
+ let timer: ReturnType<typeof setTimeout> | undefined;
437
+ const limit = new Promise<never>((_, reject) => {
438
+ timer = setTimeout(() => {
439
+ const error = new LineError(
440
+ "handler_timeout",
441
+ `line: a handler ran past ${this.options.maxHandleMs} ms`,
442
+ );
443
+ controller.abort(error);
444
+ reject(error);
445
+ }, this.options.maxHandleMs);
446
+ });
447
+ try {
448
+ return await Promise.race([
449
+ Promise.resolve().then(() => processor(event, controller.signal)),
450
+ limit,
451
+ ]);
452
+ } finally {
453
+ clearTimeout(timer);
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Renews the claim while the handler runs, so a slow handler (an upload, say) is not taken for a
459
+ * crashed one. A renewal that finds the claim changed means the event was recovered by someone
460
+ * else: the heartbeat stops, the handler's signal is aborted and its last write will be refused.
461
+ */
462
+ #keepLease(
463
+ row: Claimed,
464
+ lease: Lease,
465
+ log: Logger,
466
+ controller: AbortController,
467
+ ): { stop: () => Promise<void> } {
468
+ let renewing: Promise<void> | undefined;
469
+ let timer: ReturnType<typeof setInterval> | undefined;
470
+ const renew = async () => {
471
+ try {
472
+ const next = this.#now();
473
+ const rows: unknown[] = await this.#sql`UPDATE line_inbox
474
+ SET claimed_at = ${next}
475
+ WHERE webhook_event_id = ${row.webhook_event_id} AND status = 'processing' AND claimed_at = ${lease.at}
476
+ RETURNING 1`;
477
+ if (rows.length > 0) {
478
+ lease.at = next;
479
+ return;
480
+ }
481
+ log.warn({}, "a LINE inbox event lost its lease while it was handled");
482
+ controller.abort(
483
+ new LineError(
484
+ "lease_lost",
485
+ "line: the event is no longer this worker's",
486
+ ),
487
+ );
488
+ } catch (error) {
489
+ log.warn(
490
+ { code: errorCode(error) },
491
+ "a LINE inbox lease could not be renewed",
492
+ );
493
+ return;
494
+ }
495
+ if (timer) clearInterval(timer);
496
+ timer = undefined;
497
+ };
498
+ timer = setInterval(
499
+ () => {
500
+ renewing ??= renew().finally(() => {
501
+ renewing = undefined;
502
+ });
503
+ },
504
+ Math.max(1, Math.floor(this.options.leaseMs / 3)),
505
+ );
506
+ timer.unref?.();
507
+ return {
508
+ stop: async () => {
509
+ if (timer) clearInterval(timer);
510
+ timer = undefined;
511
+ await renewing;
512
+ },
513
+ };
514
+ }
515
+
516
+ /** Logs a write that changed nothing: the event was no longer this handler's. */
517
+ #settled(row: Claimed, rows: unknown[]): boolean {
518
+ if (rows.length > 0) return true;
519
+ this.#logger.warn(
520
+ { event: row.webhook_event_id, type: row.event_type },
521
+ "a LINE inbox event was recovered while it was handled, so its outcome was dropped",
522
+ );
523
+ return false;
524
+ }
525
+
526
+ /**
527
+ * For an event that acts on a message: how long to wait when the message, or an earlier event on
528
+ * it, is not settled yet, or undefined when it may go on. Events of one message run in order:
529
+ * the message first, then its edits and unsends by LINE's time, each only once the ones before it
530
+ * are done or dead (an event backing off is not done). A message that is not in the inbox at all
531
+ * may still be on its way, so it is waited for until the grace is over; after that the processor
532
+ * decides. The wait lasts until the soonest event it waits for is due again, and at least
533
+ * `dependencyRetryMs`.
534
+ */
535
+ async #mustWait(row: Claimed): Promise<number | undefined> {
536
+ if (!DEPENDENT.has(row.event_type) || !row.message_id) return undefined;
537
+ const found: {
538
+ event_type: string;
539
+ status: string;
540
+ event_ts: string | number;
541
+ seq: string | number;
542
+ next_attempt_at: Date;
543
+ }[] = await this
544
+ .#sql`SELECT event_type, status, event_ts, seq, next_attempt_at FROM line_inbox
545
+ WHERE message_id = ${row.message_id} AND source_key IS NOT DISTINCT FROM ${row.source_key}::text
546
+ AND webhook_event_id <> ${row.webhook_event_id}
547
+ AND (event_type = 'message' OR event_type IN ('unsend', 'messageEdited'))`;
548
+ const open = (e: { status: string }) =>
549
+ e.status === "pending" || e.status === "processing";
550
+ const before = (e: { event_ts: string | number; seq: string | number }) =>
551
+ Number(e.event_ts) < Number(row.event_ts) ||
552
+ (Number(e.event_ts) === Number(row.event_ts) &&
553
+ Number(e.seq) < Number(row.seq));
554
+ const originals = found.filter((e) => e.event_type === "message");
555
+ const blockers = found.filter(
556
+ (e) => open(e) && (e.event_type === "message" || before(e)),
557
+ );
558
+ if (blockers.length > 0) {
559
+ // Nothing changes before the soonest blocker is due again, so wait for that instead of polling
560
+ // an event that is backing off for up to an hour; one being handled is looked at again soon.
561
+ const now = this.#now().getTime();
562
+ return Math.min(
563
+ ...blockers.map((e) =>
564
+ e.status === "pending"
565
+ ? Math.max(
566
+ this.options.dependencyRetryMs,
567
+ e.next_attempt_at.getTime() - now,
568
+ )
569
+ : this.options.dependencyRetryMs,
570
+ ),
571
+ );
572
+ }
573
+ if (originals.length > 0) return undefined;
574
+ const age = this.#now().getTime() - row.created_at.getTime();
575
+ return age < this.options.messageGraceMs
576
+ ? this.options.dependencyRetryMs
577
+ : undefined;
578
+ }
579
+
580
+ /**
581
+ * Drops the event when its chat was blocked while it was in hand, so it is never handed out
582
+ * again; true when it did.
583
+ */
584
+ async #dropIfBlocked(row: Claimed, lease: Lease): Promise<boolean> {
585
+ if (!row.source_key) return false;
586
+ const [source] = (await this.#sql`SELECT status FROM line_sources
587
+ WHERE source_key = ${row.source_key}`) as { status: string }[];
588
+ if (source?.status !== "blocked") return false;
589
+ this.#settled(
590
+ row,
591
+ await this.#sql`UPDATE line_inbox
592
+ SET status = 'skipped', payload = NULL, claimed_at = NULL, last_error_code = 'source_blocked',
593
+ completed_at = ${this.#now()}
594
+ WHERE webhook_event_id = ${row.webhook_event_id} AND status = 'processing' AND claimed_at = ${lease.at}
595
+ RETURNING 1`,
596
+ );
597
+ return true;
598
+ }
599
+
600
+ /**
601
+ * Back to waiting without a failure: the handing out is not counted. `counted` says whether the
602
+ * return counts towards `maxRequeues`; waiting for a message does not, because the message is
603
+ * settled by its own attempts and the wait must outlast them.
604
+ */
605
+ async #later(
606
+ row: Claimed,
607
+ lease: Lease,
608
+ delayMs: number,
609
+ code: string,
610
+ counted: boolean,
611
+ ): Promise<void> {
612
+ if (await this.#dropIfBlocked(row, lease)) return;
613
+ const now = this.#now();
614
+ if (counted && row.requeues + 1 > this.options.maxRequeues) {
615
+ await this.#die(row, lease, "requeue_limit");
616
+ return;
617
+ }
618
+ this.#settled(
619
+ row,
620
+ await this.#sql`UPDATE line_inbox
621
+ SET status = 'pending', claimed_at = NULL, attempts = attempts - 1,
622
+ requeues = requeues + ${counted ? 1 : 0},
623
+ next_attempt_at = ${new Date(now.getTime() + delayMs)}, last_error_code = ${code}
624
+ WHERE webhook_event_id = ${row.webhook_event_id} AND status = 'processing' AND claimed_at = ${lease.at}
625
+ RETURNING 1`,
626
+ );
627
+ }
628
+
629
+ async #fail(row: Claimed, lease: Lease, code: string): Promise<void> {
630
+ if (await this.#dropIfBlocked(row, lease)) return;
631
+ if (row.attempts >= this.options.maxAttempts)
632
+ return this.#die(row, lease, code);
633
+ const delay = backoffMs(row.attempts, this.options, this.#random);
634
+ this.#settled(
635
+ row,
636
+ await this.#sql`UPDATE line_inbox
637
+ SET status = 'pending', claimed_at = NULL, last_error_code = ${code},
638
+ next_attempt_at = ${new Date(this.#now().getTime() + delay)}
639
+ WHERE webhook_event_id = ${row.webhook_event_id} AND status = 'processing' AND claimed_at = ${lease.at}
640
+ RETURNING 1`,
641
+ );
642
+ }
643
+
644
+ async #die(row: Claimed, lease: Lease, code: string): Promise<void> {
645
+ const rows = await this.#sql`UPDATE line_inbox
646
+ SET status = 'dead', payload = NULL, claimed_at = NULL, last_error_code = ${code}, completed_at = ${this.#now()}
647
+ WHERE webhook_event_id = ${row.webhook_event_id} AND status = 'processing' AND claimed_at = ${lease.at}
648
+ RETURNING 1`;
649
+ if (!this.#settled(row, rows)) return;
650
+ this.#logger.error(
651
+ { event: row.webhook_event_id, type: row.event_type, code },
652
+ "a LINE inbox event is dead",
653
+ );
654
+ }
655
+
656
+ /** Starts the loop: leases are recovered first, then events are handled as they come. */
657
+ async start(): Promise<void> {
658
+ if (this.#running) return;
659
+ this.#running = true;
660
+ this.#stopping = false;
661
+ await this.recoverLeases();
662
+ this.#loop = this.#run();
663
+ }
664
+
665
+ async #run(): Promise<void> {
666
+ let lastSweep = this.#now().getTime();
667
+ while (this.#running) {
668
+ try {
669
+ if (this.#now().getTime() - lastSweep >= this.options.leaseMs / 2) {
670
+ lastSweep = this.#now().getTime();
671
+ await this.recoverLeases();
672
+ }
673
+ await this.#fill();
674
+ } catch (error) {
675
+ this.#logger.error(
676
+ { code: errorCode(error) },
677
+ "the LINE inbox worker could not read the inbox",
678
+ );
679
+ }
680
+ if (!this.#running) break;
681
+ if (this.#wake) {
682
+ this.#wake = false;
683
+ continue;
684
+ }
685
+ await new Promise<void>((resolve) => {
686
+ this.#resume = resolve;
687
+ this.#timer = setTimeout(resolve, this.options.pollMs);
688
+ });
689
+ this.#timer = undefined;
690
+ this.#resume = undefined;
691
+ }
692
+ }
693
+
694
+ /**
695
+ * Hands due events out until every slot of `concurrency` is taken or none is due. The loop comes
696
+ * back on a kick, on a poll and whenever a handler ends, so a slow handler holds one slot only.
697
+ */
698
+ async #fill(): Promise<void> {
699
+ while (this.#running && this.#inflight.size < this.options.concurrency)
700
+ if (!(await this.#startOne())) break;
701
+ }
702
+
703
+ /** Claims one event and runs it in the background; false when none was due. */
704
+ async #startOne(): Promise<boolean> {
705
+ const taken = await this.#take();
706
+ if (!taken) return false;
707
+ const { event, processor } = taken;
708
+ this.#active.add(event.webhook_event_id);
709
+ const task: Promise<void> = this.#handle(event, processor)
710
+ .catch((error) =>
711
+ this.#logger.error(
712
+ { code: errorCode(error) },
713
+ "a LINE inbox event could not be recorded",
714
+ ),
715
+ )
716
+ .finally(() => {
717
+ this.#active.delete(event.webhook_event_id);
718
+ this.#inflight.delete(task);
719
+ // The slot is free: look at the inbox again now instead of at the next poll.
720
+ this.kick();
721
+ });
722
+ this.#inflight.add(task);
723
+ return true;
724
+ }
725
+
726
+ /**
727
+ * Stops claiming, aborts the signals of the handlers in hand and waits for them to settle. A
728
+ * handler that ignores its signal is waited for, up to `maxHandleMs`.
729
+ */
730
+ async stop(): Promise<void> {
731
+ this.#running = false;
732
+ this.#stopping = true;
733
+ for (const controller of this.#aborts) controller.abort(stopped());
734
+ if (this.#timer) {
735
+ clearTimeout(this.#timer);
736
+ this.#timer = undefined;
737
+ this.#resume?.();
738
+ }
739
+ await this.#loop;
740
+ this.#loop = undefined;
741
+ await Promise.allSettled([...this.#inflight]);
742
+ }
743
+ }
744
+
745
+ function stopped(): LineError {
746
+ return new LineError("worker_stopped", "line: the inbox worker is stopping");
747
+ }
748
+
749
+ /** The handler threw the signal's reason, an abort, or a `worker_stopped` error of its own. */
750
+ function gaveUp(error: unknown, signal: AbortSignal): boolean {
751
+ return (
752
+ error === signal.reason ||
753
+ errorCode(error) === "worker_stopped" ||
754
+ (error instanceof Error && error.name === "AbortError")
755
+ );
756
+ }
757
+
758
+ function isStopped(signal: AbortSignal): boolean {
759
+ return signal.aborted && errorCode(signal.reason) === "worker_stopped";
760
+ }