pi-roundtable-line 0.9.13 → 0.11.0
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/CHANGELOG.md +25 -0
- package/README.md +203 -19
- package/package.json +3 -3
- package/src/api.ts +179 -15
- package/src/consumer.ts +503 -0
- package/src/content.ts +210 -0
- package/src/icon.ts +46 -0
- package/src/index.ts +34 -0
- package/src/intake.ts +2 -2
- package/src/join.ts +136 -0
- package/src/messages.ts +267 -0
- package/src/migrations.ts +100 -1
- package/src/options.ts +86 -2
- package/src/outbox-worker.ts +469 -0
- package/src/outbox.ts +273 -0
- package/src/plugin.ts +125 -9
- package/src/port.ts +23 -6
- package/src/quota.ts +195 -0
- package/src/retention.ts +243 -0
- package/src/route.ts +71 -0
- package/src/sender.ts +110 -0
- package/src/surface.ts +76 -0
package/src/options.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
+
import type { MediaOptions } from "./content.ts";
|
|
2
|
+
import { checkIcon, type SenderIconRoute } from "./icon.ts";
|
|
1
3
|
import type { InboxOptions } from "./inbox.ts";
|
|
4
|
+
import { checkJoinNotice, type JoinNoticeOptions } from "./join.ts";
|
|
5
|
+
import { checkOutboxOptions, type OutboxOptions } from "./outbox.ts";
|
|
6
|
+
import type { QuotaOptions } from "./quota.ts";
|
|
7
|
+
import { checkRetention, type RetentionOptions } from "./retention.ts";
|
|
8
|
+
import { checkSenderOptions, type SenderOptions } from "./sender.ts";
|
|
2
9
|
import type { LineCredentials } from "./token.ts";
|
|
3
10
|
|
|
4
11
|
export interface LineOptions {
|
|
@@ -10,6 +17,29 @@ export interface LineOptions {
|
|
|
10
17
|
listener?: string;
|
|
11
18
|
/** Tunes the inbox worker. */
|
|
12
19
|
inbox?: Partial<InboxOptions>;
|
|
20
|
+
/** Tunes how message content is fetched: `maxBytes` (default 10 MiB) and the transcoding wait. */
|
|
21
|
+
media?: Partial<MediaOptions>;
|
|
22
|
+
/** Tunes how long the package keeps what it stores; default 7 days for everything. */
|
|
23
|
+
retention?: Partial<RetentionOptions>;
|
|
24
|
+
/** Tunes the outbox: how pushes are gathered, retried and kept inside the retry key's life. */
|
|
25
|
+
outbox?: Partial<OutboxOptions>;
|
|
26
|
+
/** The words a sender name may not use; default `["LINE", "LY"]`. */
|
|
27
|
+
sender?: Partial<SenderOptions>;
|
|
28
|
+
/** The monthly budget a push is kept inside, besides the plan's own quota. */
|
|
29
|
+
quota?: QuotaOptions;
|
|
30
|
+
/**
|
|
31
|
+
* The notice sent when the bot joins a chat, as a Reply, and only when the SHA-256 of the text's
|
|
32
|
+
* UTF-8 bytes is `sha256`. Without it no notice is ever sent.
|
|
33
|
+
*/
|
|
34
|
+
joinNotice?: JoinNoticeOptions;
|
|
35
|
+
/**
|
|
36
|
+
* Hands the text of messages of allowed chats to the host's router, as messages of the `line`
|
|
37
|
+
* surface; default `false`. Turn it on only with the access rules in mind: never admit `line`
|
|
38
|
+
* through `everyone`.
|
|
39
|
+
*/
|
|
40
|
+
route?: boolean;
|
|
41
|
+
/** Serves one fixed PNG at `path`, so a sender can name it as its icon without another service. */
|
|
42
|
+
senderIconRoute?: SenderIconRoute;
|
|
13
43
|
/** Replaces `fetch` for every call to LINE, for tests. */
|
|
14
44
|
fetch?: typeof fetch;
|
|
15
45
|
}
|
|
@@ -37,8 +67,17 @@ const TIMER_KEYS: readonly (keyof InboxOptions)[] = [
|
|
|
37
67
|
"pollMs",
|
|
38
68
|
];
|
|
39
69
|
|
|
70
|
+
const MEDIA_KEYS: Record<keyof MediaOptions, true> = {
|
|
71
|
+
maxBytes: true,
|
|
72
|
+
transcodingPollMs: true,
|
|
73
|
+
transcodingMaxMs: true,
|
|
74
|
+
};
|
|
75
|
+
|
|
40
76
|
/** Refuses a mistake in the options, naming the field; the host stops before it starts. */
|
|
41
|
-
export function checkOptions(options: LineOptions): {
|
|
77
|
+
export function checkOptions(options: LineOptions): {
|
|
78
|
+
path: string;
|
|
79
|
+
iconPath: string | undefined;
|
|
80
|
+
} {
|
|
42
81
|
const path = options.webhookPath ?? "/line/webhook";
|
|
43
82
|
if (!/^\/[A-Za-z0-9._~/-]*[A-Za-z0-9._~-]$/.test(path))
|
|
44
83
|
throw new Error(
|
|
@@ -59,5 +98,50 @@ export function checkOptions(options: LineOptions): { path: string } {
|
|
|
59
98
|
`line: inbox.${key} must be at most ${MAX_TIMER_MS} milliseconds (about 24 days); got ${String(value)}`,
|
|
60
99
|
);
|
|
61
100
|
}
|
|
62
|
-
|
|
101
|
+
for (const [key, value] of Object.entries(options.media ?? {})) {
|
|
102
|
+
if (!(key in MEDIA_KEYS))
|
|
103
|
+
throw new Error(`line: media.${key} is not a media option`);
|
|
104
|
+
if (!Number.isInteger(value) || (value as number) < 1)
|
|
105
|
+
throw new Error(
|
|
106
|
+
`line: media.${key} must be a positive whole number; got ${String(value)}`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
checkRetention(options.retention ?? {});
|
|
110
|
+
checkOutboxOptions(options.outbox ?? {});
|
|
111
|
+
checkSenderOptions(options.sender ?? {});
|
|
112
|
+
const cap = options.quota?.monthlyCap;
|
|
113
|
+
if (options.quota !== undefined) {
|
|
114
|
+
for (const key of Object.keys(options.quota))
|
|
115
|
+
if (key !== "monthlyCap" && key !== "holdMs")
|
|
116
|
+
throw new Error(`line: quota.${key} is not a quota option`);
|
|
117
|
+
if (cap !== undefined && (!Number.isInteger(cap) || cap < 1))
|
|
118
|
+
throw new Error(
|
|
119
|
+
`line: quota.monthlyCap must be a positive whole number; got ${String(cap)}`,
|
|
120
|
+
);
|
|
121
|
+
const hold = options.quota.holdMs;
|
|
122
|
+
if (hold !== undefined && (!Number.isInteger(hold) || hold < 1))
|
|
123
|
+
throw new Error(
|
|
124
|
+
`line: quota.holdMs must be a positive whole number of milliseconds; got ${String(hold)}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (options.joinNotice !== undefined) checkJoinNotice(options.joinNotice);
|
|
128
|
+
if (options.route !== undefined && typeof options.route !== "boolean")
|
|
129
|
+
throw new Error("line: route must be true or false");
|
|
130
|
+
let iconPath: string | undefined;
|
|
131
|
+
if (options.senderIconRoute !== undefined) {
|
|
132
|
+
iconPath = options.senderIconRoute.path;
|
|
133
|
+
if (
|
|
134
|
+
typeof iconPath !== "string" ||
|
|
135
|
+
!/^\/[A-Za-z0-9._~/-]*[A-Za-z0-9._~-]$/.test(iconPath)
|
|
136
|
+
)
|
|
137
|
+
throw new Error(
|
|
138
|
+
`line: senderIconRoute.path ${JSON.stringify(iconPath)} must start with / and not end with one, such as /line/sender-icon.png`,
|
|
139
|
+
);
|
|
140
|
+
if (iconPath === path)
|
|
141
|
+
throw new Error(
|
|
142
|
+
"line: senderIconRoute.path must differ from webhookPath",
|
|
143
|
+
);
|
|
144
|
+
checkIcon(options.senderIconRoute.png);
|
|
145
|
+
}
|
|
146
|
+
return { path, iconPath };
|
|
63
147
|
}
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import type { Logger } from "pi-roundtable";
|
|
3
|
+
import type { LineApi } from "./api.ts";
|
|
4
|
+
import { errorCode, LineError } from "./errors.ts";
|
|
5
|
+
import { parseSourceKey } from "./events.ts";
|
|
6
|
+
import { backoffMs } from "./inbox.ts";
|
|
7
|
+
import type { LineOutboxResultEvent } from "./messages.ts";
|
|
8
|
+
import { OUTBOX_DEFAULTS, type OutboxOptions } from "./outbox.ts";
|
|
9
|
+
import type { QuotaGate } from "./quota.ts";
|
|
10
|
+
import type { LineSources } from "./sources.ts";
|
|
11
|
+
|
|
12
|
+
/** Attempts to tell the subscriber how a push ended, before it is given up. */
|
|
13
|
+
const MAX_NOTIFY_ATTEMPTS = 12;
|
|
14
|
+
|
|
15
|
+
interface Claimed {
|
|
16
|
+
id: string | number;
|
|
17
|
+
source_key: string;
|
|
18
|
+
sender_name: string | null;
|
|
19
|
+
sender_icon_url: string | null;
|
|
20
|
+
messages: Record<string, unknown>[];
|
|
21
|
+
retry_key: string;
|
|
22
|
+
attempts: number;
|
|
23
|
+
first_attempt_at: Date | null;
|
|
24
|
+
claimed_at: Date;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface OutboxWorkerDeps {
|
|
28
|
+
sql: SQL;
|
|
29
|
+
logger: Logger;
|
|
30
|
+
now: () => Date;
|
|
31
|
+
api: LineApi;
|
|
32
|
+
quota: QuotaGate;
|
|
33
|
+
sources: LineSources;
|
|
34
|
+
/**
|
|
35
|
+
* Tells the subscriber how a push ended; answers false when there is none to tell, and the result
|
|
36
|
+
* waits. Rejecting means the subscriber failed, and it is told again later.
|
|
37
|
+
*/
|
|
38
|
+
emit: (event: LineOutboxResultEvent, signal: AbortSignal) => Promise<boolean>;
|
|
39
|
+
options?: Partial<OutboxOptions>;
|
|
40
|
+
random?: () => number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Sends the queued pushes, the oldest of each chat first and one at a time per chat, and tells the
|
|
45
|
+
* subscriber how each ended.
|
|
46
|
+
*
|
|
47
|
+
* - A push is tried under the retry key it was created with, on every attempt and after a crash, until
|
|
48
|
+
* it is accepted or the key is 24 hours old; past that it is dead, never sent under a new key.
|
|
49
|
+
* - `2xx` and `409` (LINE has this key already) are `sent`; `429`, `5xx` and no answer back off and
|
|
50
|
+
* retry; any other `4xx` is `dead` with the code LINE's refusal maps to; a push the quota gate
|
|
51
|
+
* refuses is `refused`. Nothing but a code is kept of a failure.
|
|
52
|
+
* - A push in hand has a lease the worker's own process does not outlive; one left in hand by a dead
|
|
53
|
+
* process goes back to waiting and is sent again under the same key.
|
|
54
|
+
* - A finished push loses its message objects.
|
|
55
|
+
*/
|
|
56
|
+
export class OutboxWorker {
|
|
57
|
+
readonly #sql: SQL;
|
|
58
|
+
readonly #logger: Logger;
|
|
59
|
+
readonly #now: () => Date;
|
|
60
|
+
readonly #api: LineApi;
|
|
61
|
+
readonly #quota: QuotaGate;
|
|
62
|
+
readonly #sources: LineSources;
|
|
63
|
+
readonly #emit: OutboxWorkerDeps["emit"];
|
|
64
|
+
readonly #random: () => number;
|
|
65
|
+
readonly options: OutboxOptions;
|
|
66
|
+
#running = false;
|
|
67
|
+
#stopping = false;
|
|
68
|
+
#loop: Promise<void> | undefined;
|
|
69
|
+
#timer: ReturnType<typeof setTimeout> | undefined;
|
|
70
|
+
#resume: (() => void) | undefined;
|
|
71
|
+
#wake = false;
|
|
72
|
+
readonly #inflight = new Set<Promise<void>>();
|
|
73
|
+
readonly #active = new Set<string>();
|
|
74
|
+
readonly #aborts = new Set<AbortController>();
|
|
75
|
+
#notifying: Promise<number> | undefined;
|
|
76
|
+
|
|
77
|
+
constructor(deps: OutboxWorkerDeps) {
|
|
78
|
+
this.#sql = deps.sql;
|
|
79
|
+
this.#logger = deps.logger;
|
|
80
|
+
this.#now = deps.now;
|
|
81
|
+
this.#api = deps.api;
|
|
82
|
+
this.#quota = deps.quota;
|
|
83
|
+
this.#sources = deps.sources;
|
|
84
|
+
this.#emit = deps.emit;
|
|
85
|
+
this.#random = deps.random ?? Math.random;
|
|
86
|
+
this.options = { ...OUTBOX_DEFAULTS, ...deps.options };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Push ids being sent now, for the shutdown drain. */
|
|
90
|
+
busy(): string[] {
|
|
91
|
+
return [...this.#active];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Looks at the outbox now instead of at the next poll. */
|
|
95
|
+
kick(): void {
|
|
96
|
+
this.#wake = true;
|
|
97
|
+
if (this.#timer) {
|
|
98
|
+
clearTimeout(this.#timer);
|
|
99
|
+
this.#timer = undefined;
|
|
100
|
+
this.#resume?.();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Looks at the outbox in `delayMs`, when a push that is gathering becomes ready. */
|
|
105
|
+
kickIn(delayMs: number): void {
|
|
106
|
+
const timer = setTimeout(() => this.kick(), delayMs + 1);
|
|
107
|
+
timer.unref?.();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Puts back pushes whose lease ran out; returns how many. */
|
|
111
|
+
async recoverLeases(): Promise<number> {
|
|
112
|
+
const now = this.#now();
|
|
113
|
+
const expired = new Date(now.getTime() - this.options.leaseMs);
|
|
114
|
+
const own = JSON.stringify([...this.#active]);
|
|
115
|
+
const rows: unknown[] = await this.#sql`UPDATE line_outbox
|
|
116
|
+
SET status = 'queued', claimed_at = NULL, next_attempt_at = ${now}, last_error_code = 'lease_expired'
|
|
117
|
+
WHERE status = 'sending' AND claimed_at < ${expired}
|
|
118
|
+
AND id::text NOT IN (SELECT jsonb_array_elements_text(${own}::text::jsonb))
|
|
119
|
+
RETURNING 1`;
|
|
120
|
+
if (rows.length > 0)
|
|
121
|
+
this.#logger.warn(
|
|
122
|
+
{ count: rows.length },
|
|
123
|
+
"LINE pushes put back after their lease ran out",
|
|
124
|
+
);
|
|
125
|
+
return rows.length;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Claims the oldest due push of a chat that has none in hand. */
|
|
129
|
+
async #claim(): Promise<Claimed | undefined> {
|
|
130
|
+
const now = this.#now();
|
|
131
|
+
return this.#sql.begin(async (tx) => {
|
|
132
|
+
const [head] = (await tx`WITH heads AS (
|
|
133
|
+
SELECT DISTINCT ON (source_key) id, status, ready_at, next_attempt_at
|
|
134
|
+
FROM line_outbox WHERE status IN ('queued', 'sending')
|
|
135
|
+
ORDER BY source_key, id)
|
|
136
|
+
SELECT id FROM heads
|
|
137
|
+
WHERE status = 'queued' AND ready_at <= ${now} AND next_attempt_at <= ${now}
|
|
138
|
+
ORDER BY id LIMIT 1`) as { id: string | number }[];
|
|
139
|
+
if (!head) return undefined;
|
|
140
|
+
const [claimed] = (await tx`UPDATE line_outbox
|
|
141
|
+
SET status = 'sending', claimed_at = ${now}, attempts = attempts + 1,
|
|
142
|
+
first_attempt_at = COALESCE(first_attempt_at, ${now})
|
|
143
|
+
WHERE id = (SELECT id FROM line_outbox WHERE id = ${head.id} AND status = 'queued' FOR UPDATE SKIP LOCKED)
|
|
144
|
+
RETURNING id, source_key, sender_name, sender_icon_url, messages, retry_key, attempts,
|
|
145
|
+
first_attempt_at, claimed_at`) as Claimed[];
|
|
146
|
+
return claimed;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Sends the oldest due push; returns false when none was due. For tests and for the loop. */
|
|
151
|
+
async runOnce(): Promise<boolean> {
|
|
152
|
+
const row = await this.#claim();
|
|
153
|
+
if (!row) return false;
|
|
154
|
+
const id = String(row.id);
|
|
155
|
+
this.#active.add(id);
|
|
156
|
+
try {
|
|
157
|
+
await this.#handle(row);
|
|
158
|
+
} finally {
|
|
159
|
+
this.#active.delete(id);
|
|
160
|
+
}
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async #handle(row: Claimed): Promise<void> {
|
|
165
|
+
const log = this.#logger.child({
|
|
166
|
+
push: String(row.id),
|
|
167
|
+
attempt: row.attempts,
|
|
168
|
+
});
|
|
169
|
+
const controller = new AbortController();
|
|
170
|
+
this.#aborts.add(controller);
|
|
171
|
+
if (this.#stopping) controller.abort(stopped());
|
|
172
|
+
const now = this.#now().getTime();
|
|
173
|
+
try {
|
|
174
|
+
// A key older than LINE keeps it is dead rather than resent, even when the request that
|
|
175
|
+
// first used it was cut short by a stop and so is not counted as an attempt.
|
|
176
|
+
if (
|
|
177
|
+
row.first_attempt_at &&
|
|
178
|
+
now - row.first_attempt_at.getTime() > this.options.retryKeyMs
|
|
179
|
+
)
|
|
180
|
+
return await this.#finish(row, "dead", "retry_key_expired", log);
|
|
181
|
+
if ((await this.#sources.get(row.source_key))?.status !== "allowed")
|
|
182
|
+
return await this.#finish(row, "dead", "source_not_allowed", log);
|
|
183
|
+
const source = parseSourceKey(row.source_key);
|
|
184
|
+
if (!source)
|
|
185
|
+
return await this.#finish(row, "dead", "bad_source_key", log);
|
|
186
|
+
const recipients = await this.#quota.recipients(row.source_key);
|
|
187
|
+
const reserved = await this.#quota.reserve(recipients);
|
|
188
|
+
if (!reserved.ok) {
|
|
189
|
+
log.warn(
|
|
190
|
+
{
|
|
191
|
+
used: reserved.used,
|
|
192
|
+
reserved: reserved.reserved,
|
|
193
|
+
recipients: reserved.recipients,
|
|
194
|
+
cap: reserved.cap,
|
|
195
|
+
},
|
|
196
|
+
"a LINE push was refused by the quota gate",
|
|
197
|
+
);
|
|
198
|
+
return await this.#finish(
|
|
199
|
+
row,
|
|
200
|
+
"refused",
|
|
201
|
+
"quota_exhausted",
|
|
202
|
+
log,
|
|
203
|
+
recipients,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
// Counted as sent until the push is known not to have gone out: a timeout or a 5xx may have
|
|
207
|
+
// been taken by LINE, so its reservation is held like a sent push's.
|
|
208
|
+
let sent = true;
|
|
209
|
+
try {
|
|
210
|
+
const sender = {
|
|
211
|
+
...(row.sender_name ? { name: row.sender_name } : {}),
|
|
212
|
+
...(row.sender_icon_url ? { iconUrl: row.sender_icon_url } : {}),
|
|
213
|
+
};
|
|
214
|
+
const messages = row.messages.map((message) =>
|
|
215
|
+
Object.keys(sender).length > 0 ? { ...message, sender } : message,
|
|
216
|
+
);
|
|
217
|
+
const pushed = await this.#api.push(
|
|
218
|
+
source.id,
|
|
219
|
+
messages,
|
|
220
|
+
row.retry_key,
|
|
221
|
+
controller.signal,
|
|
222
|
+
);
|
|
223
|
+
if (pushed.outcome === "sent") {
|
|
224
|
+
return await this.#finish(
|
|
225
|
+
row,
|
|
226
|
+
"sent",
|
|
227
|
+
null,
|
|
228
|
+
log,
|
|
229
|
+
recipients,
|
|
230
|
+
pushed.requestId,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (pushed.status < 500) sent = false;
|
|
234
|
+
if (pushed.status === 429 || pushed.status >= 500)
|
|
235
|
+
return await this.#retry(row, log, pushed.code, pushed.retryAfterMs);
|
|
236
|
+
return await this.#finish(
|
|
237
|
+
row,
|
|
238
|
+
"dead",
|
|
239
|
+
pushed.code,
|
|
240
|
+
log,
|
|
241
|
+
recipients,
|
|
242
|
+
pushed.requestId,
|
|
243
|
+
);
|
|
244
|
+
} finally {
|
|
245
|
+
this.#quota.settle(reserved.reservation, sent);
|
|
246
|
+
}
|
|
247
|
+
} catch (error) {
|
|
248
|
+
// Giving up because the worker stops is the restart's doing: the push goes back as it was.
|
|
249
|
+
if (this.#stopping && controller.signal.aborted) {
|
|
250
|
+
await this.#later(row, 0, "worker_stopped", false);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const code = errorCode(error);
|
|
254
|
+
log.warn({ code }, "a LINE push could not be sent");
|
|
255
|
+
await this.#retry(row, log, code);
|
|
256
|
+
} finally {
|
|
257
|
+
this.#aborts.delete(controller);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Ends a push: its objects are dropped and the subscriber is told when it can be. */
|
|
262
|
+
async #finish(
|
|
263
|
+
row: Claimed,
|
|
264
|
+
status: "sent" | "dead" | "refused",
|
|
265
|
+
code: string | null,
|
|
266
|
+
log: Logger,
|
|
267
|
+
recipients?: number,
|
|
268
|
+
requestId?: string,
|
|
269
|
+
): Promise<void> {
|
|
270
|
+
const rows: unknown[] = await this.#sql`UPDATE line_outbox
|
|
271
|
+
SET status = ${status}, messages = NULL, claimed_at = NULL, completed_at = ${this.#now()},
|
|
272
|
+
last_error_code = ${code}, line_request_id = ${requestId ?? null}, recipients = ${recipients ?? null}
|
|
273
|
+
WHERE id = ${row.id} AND status = 'sending' AND claimed_at = ${row.claimed_at}
|
|
274
|
+
RETURNING 1`;
|
|
275
|
+
if (rows.length === 0) {
|
|
276
|
+
log.warn(
|
|
277
|
+
{},
|
|
278
|
+
"a LINE push was recovered while it was sent, so its outcome was dropped",
|
|
279
|
+
);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (status === "sent") log.info({}, "a LINE push was sent");
|
|
283
|
+
else log.warn({ code, status }, "a LINE push did not go out");
|
|
284
|
+
this.kick();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Backs off after a failure, or ends the push when it has failed `maxAttempts` times. */
|
|
288
|
+
async #retry(
|
|
289
|
+
row: Claimed,
|
|
290
|
+
log: Logger,
|
|
291
|
+
code: string,
|
|
292
|
+
retryAfterMs?: number,
|
|
293
|
+
): Promise<void> {
|
|
294
|
+
if (row.attempts >= this.options.maxAttempts)
|
|
295
|
+
return this.#finish(row, "dead", code, log);
|
|
296
|
+
const delay = Math.max(
|
|
297
|
+
backoffMs(row.attempts, this.options, this.#random),
|
|
298
|
+
retryAfterMs ?? 0,
|
|
299
|
+
);
|
|
300
|
+
await this.#later(row, delay, code, true);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async #later(
|
|
304
|
+
row: Claimed,
|
|
305
|
+
delayMs: number,
|
|
306
|
+
code: string,
|
|
307
|
+
counted: boolean,
|
|
308
|
+
): Promise<void> {
|
|
309
|
+
await this.#sql`UPDATE line_outbox
|
|
310
|
+
SET status = 'queued', claimed_at = NULL, attempts = attempts - ${counted ? 0 : 1},
|
|
311
|
+
next_attempt_at = ${new Date(this.#now().getTime() + delayMs)}, last_error_code = ${code}
|
|
312
|
+
WHERE id = ${row.id} AND status = 'sending' AND claimed_at = ${row.claimed_at}`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Tells the subscriber how finished pushes ended, oldest first, once each per origin; a push
|
|
317
|
+
* whose telling fails is tried again later, up to 12 times. Returns how many pushes were told.
|
|
318
|
+
*/
|
|
319
|
+
notify(): Promise<number> {
|
|
320
|
+
this.#notifying ??= this.#notify().finally(() => {
|
|
321
|
+
this.#notifying = undefined;
|
|
322
|
+
});
|
|
323
|
+
return this.#notifying;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async #notify(): Promise<number> {
|
|
327
|
+
const now = this.#now();
|
|
328
|
+
const rows: {
|
|
329
|
+
id: string | number;
|
|
330
|
+
source_key: string;
|
|
331
|
+
status: "sent" | "dead" | "refused";
|
|
332
|
+
last_error_code: string | null;
|
|
333
|
+
origin_refs: (string | null)[];
|
|
334
|
+
notify_attempts: number;
|
|
335
|
+
}[] = await this
|
|
336
|
+
.#sql`SELECT id, source_key, status, last_error_code, origin_refs, notify_attempts
|
|
337
|
+
FROM line_outbox
|
|
338
|
+
WHERE notified_at IS NULL AND status IN ('sent', 'dead', 'refused')
|
|
339
|
+
AND (notify_after IS NULL OR notify_after <= ${now})
|
|
340
|
+
ORDER BY id LIMIT 20`;
|
|
341
|
+
let told = 0;
|
|
342
|
+
const controller = new AbortController();
|
|
343
|
+
this.#aborts.add(controller);
|
|
344
|
+
if (this.#stopping) controller.abort(stopped());
|
|
345
|
+
try {
|
|
346
|
+
for (const row of rows) {
|
|
347
|
+
if (controller.signal.aborted) break;
|
|
348
|
+
try {
|
|
349
|
+
const origins = row.origin_refs.length > 0 ? row.origin_refs : [null];
|
|
350
|
+
let heard = true;
|
|
351
|
+
for (const originRef of origins) {
|
|
352
|
+
heard = await this.#emit(
|
|
353
|
+
{
|
|
354
|
+
type: "outboxResult",
|
|
355
|
+
sourceKey: row.source_key,
|
|
356
|
+
originRef,
|
|
357
|
+
status: row.status,
|
|
358
|
+
errorCode: row.last_error_code,
|
|
359
|
+
},
|
|
360
|
+
controller.signal,
|
|
361
|
+
);
|
|
362
|
+
if (!heard) break;
|
|
363
|
+
}
|
|
364
|
+
// No subscriber: the results wait for one.
|
|
365
|
+
if (!heard) break;
|
|
366
|
+
await this
|
|
367
|
+
.#sql`UPDATE line_outbox SET notified_at = ${this.#now()} WHERE id = ${row.id}`;
|
|
368
|
+
told++;
|
|
369
|
+
} catch (error) {
|
|
370
|
+
if (controller.signal.aborted) break;
|
|
371
|
+
const attempts = row.notify_attempts + 1;
|
|
372
|
+
this.#logger.warn(
|
|
373
|
+
{ push: String(row.id), code: errorCode(error), attempts },
|
|
374
|
+
"a LINE push result could not be handed to the subscriber",
|
|
375
|
+
);
|
|
376
|
+
const giveUp = attempts >= MAX_NOTIFY_ATTEMPTS;
|
|
377
|
+
await this.#sql`UPDATE line_outbox
|
|
378
|
+
SET notify_attempts = ${attempts},
|
|
379
|
+
notify_after = ${new Date(this.#now().getTime() + backoffMs(attempts, this.options, this.#random))},
|
|
380
|
+
notified_at = ${giveUp ? this.#now() : null}
|
|
381
|
+
WHERE id = ${row.id}`;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
} finally {
|
|
385
|
+
this.#aborts.delete(controller);
|
|
386
|
+
}
|
|
387
|
+
return told;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Starts the loop: leases are recovered first, then pushes are sent as they come due. */
|
|
391
|
+
async start(): Promise<void> {
|
|
392
|
+
if (this.#running) return;
|
|
393
|
+
this.#running = true;
|
|
394
|
+
this.#stopping = false;
|
|
395
|
+
await this.recoverLeases();
|
|
396
|
+
this.#loop = this.#run();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async #run(): Promise<void> {
|
|
400
|
+
let lastSweep = this.#now().getTime();
|
|
401
|
+
while (this.#running) {
|
|
402
|
+
try {
|
|
403
|
+
if (this.#now().getTime() - lastSweep >= this.options.leaseMs / 2) {
|
|
404
|
+
lastSweep = this.#now().getTime();
|
|
405
|
+
await this.recoverLeases();
|
|
406
|
+
}
|
|
407
|
+
await this.#fill();
|
|
408
|
+
await this.notify();
|
|
409
|
+
} catch (error) {
|
|
410
|
+
this.#logger.error(
|
|
411
|
+
{ code: errorCode(error) },
|
|
412
|
+
"the LINE outbox worker could not read the outbox",
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
if (!this.#running) break;
|
|
416
|
+
if (this.#wake) {
|
|
417
|
+
this.#wake = false;
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
await new Promise<void>((resolve) => {
|
|
421
|
+
this.#resume = resolve;
|
|
422
|
+
this.#timer = setTimeout(resolve, this.options.pollMs);
|
|
423
|
+
});
|
|
424
|
+
this.#timer = undefined;
|
|
425
|
+
this.#resume = undefined;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async #fill(): Promise<void> {
|
|
430
|
+
while (this.#running && this.#inflight.size < this.options.concurrency) {
|
|
431
|
+
const row = await this.#claim();
|
|
432
|
+
if (!row) break;
|
|
433
|
+
const id = String(row.id);
|
|
434
|
+
this.#active.add(id);
|
|
435
|
+
const task: Promise<void> = this.#handle(row)
|
|
436
|
+
.catch((error) =>
|
|
437
|
+
this.#logger.error(
|
|
438
|
+
{ code: errorCode(error) },
|
|
439
|
+
"a LINE push could not be recorded",
|
|
440
|
+
),
|
|
441
|
+
)
|
|
442
|
+
.finally(() => {
|
|
443
|
+
this.#active.delete(id);
|
|
444
|
+
this.#inflight.delete(task);
|
|
445
|
+
this.kick();
|
|
446
|
+
});
|
|
447
|
+
this.#inflight.add(task);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Stops claiming, aborts the pushes in hand (they go back as they were) and waits for them. */
|
|
452
|
+
async stop(): Promise<void> {
|
|
453
|
+
this.#running = false;
|
|
454
|
+
this.#stopping = true;
|
|
455
|
+
for (const controller of this.#aborts) controller.abort(stopped());
|
|
456
|
+
if (this.#timer) {
|
|
457
|
+
clearTimeout(this.#timer);
|
|
458
|
+
this.#timer = undefined;
|
|
459
|
+
this.#resume?.();
|
|
460
|
+
}
|
|
461
|
+
await this.#loop;
|
|
462
|
+
this.#loop = undefined;
|
|
463
|
+
await Promise.allSettled([...this.#inflight]);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function stopped(): LineError {
|
|
468
|
+
return new LineError("worker_stopped", "line: the outbox worker is stopping");
|
|
469
|
+
}
|