pi-roundtable-line 0.15.3 → 0.15.4
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 +13 -0
- package/README.md +3 -2
- package/package.json +2 -2
- package/src/messages.ts +7 -0
- package/src/migrations.ts +12 -0
- package/src/outbox-worker.ts +13 -4
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.15.4] - 2026-10-15
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `LineOutboxResultEvent.attempts`: how many tries the push took, the one that ended it included (1 for a push LINE refused at once, 2 after a `5xx` and then a refusal), so a subscriber no longer reads `line_outbox`.
|
|
13
|
+
- Migration `outbox-requested` adds the nullable `line_outbox.requested_at`, set just before a push's request is made and cleared when the push is claimed again.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- A stop no longer gives back an attempt whose request may have reached LINE. A push interrupted by the worker stopping used to be put back with its attempt uncounted even when the request was already out, so the count (and `outbox.maxAttempts`) understated the tries; it is now given back only when the stop came before the request.
|
|
18
|
+
|
|
19
|
+
The core peer range stays `>=0.15.0 <0.16.0`.
|
|
20
|
+
|
|
8
21
|
## [0.15.3] - 2026-10-15
|
|
9
22
|
|
|
10
23
|
- Release in lockstep with pi-roundtable 0.15.3; no package-specific behavior changes. The core peer range stays `>=0.15.0 <0.16.0`.
|
package/README.md
CHANGED
|
@@ -184,7 +184,7 @@ The processor that serves it is the package's own; there is no way to read raw i
|
|
|
184
184
|
| `joined` | `notice`: `"sent"`, `"failed"` or `"unconfirmed"`, truthfully (see [The join notice](#the-join-notice)) |
|
|
185
185
|
| `left` | |
|
|
186
186
|
| `memberJoined`, `memberLeft` | `userIds` |
|
|
187
|
-
| `outboxResult` | `sourceKey`, `originRef`, `status` (`sent`, `dead`, `refused`) and `
|
|
187
|
+
| `outboxResult` | `sourceKey`, `originRef`, `status` (`sent`, `dead`, `refused`), `errorCode` and `attempts` (see [Sending](#sending)) |
|
|
188
188
|
|
|
189
189
|
Every event but `outboxResult` has `eventId` (LINE's `webhookEventId`, the same on every redelivery), `sourceKey` and `timestamp`; a push is not a webhook event, so `outboxResult` has none of the three.
|
|
190
190
|
No event carries a reply token, a quote token or a URL.
|
|
@@ -277,7 +277,8 @@ Message objects hold what you send, so they live in `line_outbox.messages` until
|
|
|
277
277
|
- **Pushes of at most five objects, per chat and sender.** Objects queued for the same chat and the same sender within `outbox.coalesceMs` (2 seconds, counted from the first object, not from the last) share one push, in the order they were queued; a sixth object starts the next push, and a push that holds five goes at once. Different senders never share a push, and a push is never joined if a later push of the chat exists (that would reorder the chat). The chat's pushes go out strictly in order: a push that is backing off holds the ones behind it (a dead or refused one does not).
|
|
278
278
|
- **A retry key from the start.** Each push gets a UUID the moment its row exists, before any attempt, and every attempt and every retry after a crash sends it as `X-Line-Retry-Key`. LINE keeps a key for 24 hours (`outbox.retryKeyMs`): a push still unsent then is `dead` with `retry_key_expired` and is never sent under a new key.
|
|
279
279
|
- **Outcomes.** `2xx` and `409` (LINE already has this key) are `sent`. `429`, `5xx` and no answer back off (5 seconds doubling to 1 hour, at least `Retry-After`) and retry, up to `outbox.maxAttempts` (12), then `dead` with the last code. Any other `4xx` is `dead` at once with a code: `sender_name_refused` or `sender_icon_refused` when LINE names that property of the request, else `http_<status>`; nothing else of LINE's answer is kept. A push the quota gate refuses is `refused` and is never sent. A push for a chat that stopped being allowed is `dead` with `source_not_allowed`.
|
|
280
|
-
- **Results.** The subscriber hears an `outboxResult` for each push once it has ended, once per distinct `origin` the push carries (once with `originRef: null` when none was given), at least once: a result waits while no subscriber is attached and is told again later, up to 12 times, if the subscriber throws.
|
|
280
|
+
- **Results.** The subscriber hears an `outboxResult` for each push once it has ended, once per distinct `origin` the push carries (once with `originRef: null` when none was given), at least once: a result waits while no subscriber is attached and is told again later, up to 12 times, if the subscriber throws. The event carries `attempts`, how many tries the push took, the one that ended it included (1 for a push LINE refused at once, 2 after a `5xx` and then a refusal), so there is no need to read `line_outbox`.
|
|
281
|
+
- **What counts as a try.** An attempt is counted when a push is taken up and is given back only when the worker stops before the request was made; the migration `outbox-requested` adds `line_outbox.requested_at`, set just before the request goes out. A stop after it cannot say LINE did not take the request, so that try stays counted (and it counts towards `outbox.maxAttempts`).
|
|
281
282
|
- **Crashes.** A push in hand when the process dies has a 2-minute lease; the worker puts it back and sends it again under the same key, which LINE answers with `409` if it had taken the first request.
|
|
282
283
|
- **Service.** The `line-outbox` service runs the worker and counts as `busy` while a push is in flight, so the shutdown waits for it; a push still in flight when the host stops is put back as it was.
|
|
283
284
|
- **Nothing sensitive in logs.** The worker logs push ids, attempt numbers, status words and codes; never message objects, sender names, retry keys or tokens.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-roundtable-line",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.4",
|
|
4
4
|
"description": "A LINE Messaging API transport for pi-roundtable: a signed webhook that persists events before it answers, a durable inbox worker, and an allowlist of the groups and chats it mirrors",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"@earendil-works/pi-ai": ">=1.0.0 <2",
|
|
41
41
|
"@earendil-works/pi-coding-agent": ">=1.0.0 <2",
|
|
42
42
|
"@types/bun": "1.4.2",
|
|
43
|
-
"pi-roundtable": "0.15.
|
|
43
|
+
"pi-roundtable": "0.15.4",
|
|
44
44
|
"typebox": "1.3.34",
|
|
45
45
|
"typescript": "7.0.2"
|
|
46
46
|
}
|
package/src/messages.ts
CHANGED
|
@@ -115,6 +115,13 @@ export interface LineOutboxResultEvent {
|
|
|
115
115
|
* `source_not_allowed`); null when it was sent.
|
|
116
116
|
*/
|
|
117
117
|
errorCode: string | null;
|
|
118
|
+
/**
|
|
119
|
+
* How many tries the push took, the one that ended it included: 1 for a push LINE refused at once,
|
|
120
|
+
* 2 after a `5xx` and then a refusal. A try counts once its request has been made to LINE, also
|
|
121
|
+
* when a stop cut it short, and once the worker took the push up only to end it without a request
|
|
122
|
+
* (`refused`, `source_not_allowed`); a stop before the request does not count.
|
|
123
|
+
*/
|
|
124
|
+
attempts: number;
|
|
118
125
|
}
|
|
119
126
|
|
|
120
127
|
export type LineEvent =
|
package/src/migrations.ts
CHANGED
|
@@ -136,9 +136,21 @@ export const OUTBOX_MIGRATION: Migration = {
|
|
|
136
136
|
},
|
|
137
137
|
};
|
|
138
138
|
|
|
139
|
+
/**
|
|
140
|
+
* When the request of the push's current attempt was handed to the network. A stop that comes after
|
|
141
|
+
* it cannot say LINE did not take the request, so the attempt stays counted.
|
|
142
|
+
*/
|
|
143
|
+
export const OUTBOX_REQUESTED_MIGRATION: Migration = {
|
|
144
|
+
name: "outbox-requested",
|
|
145
|
+
up: async (sql) => {
|
|
146
|
+
await sql`ALTER TABLE line_outbox ADD COLUMN IF NOT EXISTS requested_at TIMESTAMPTZ`;
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
|
|
139
150
|
/** Every migration of the package, in the order they run. */
|
|
140
151
|
export const MIGRATIONS: Migration[] = [
|
|
141
152
|
INBOX_MIGRATION,
|
|
142
153
|
MESSAGES_MIGRATION,
|
|
143
154
|
OUTBOX_MIGRATION,
|
|
155
|
+
OUTBOX_REQUESTED_MIGRATION,
|
|
144
156
|
];
|
package/src/outbox-worker.ts
CHANGED
|
@@ -138,7 +138,7 @@ export class OutboxWorker {
|
|
|
138
138
|
ORDER BY id LIMIT 1`) as { id: string | number }[];
|
|
139
139
|
if (!head) return undefined;
|
|
140
140
|
const [claimed] = (await tx`UPDATE line_outbox
|
|
141
|
-
SET status = 'sending', claimed_at = ${now}, attempts = attempts + 1,
|
|
141
|
+
SET status = 'sending', claimed_at = ${now}, requested_at = NULL, attempts = attempts + 1,
|
|
142
142
|
first_attempt_at = COALESCE(first_attempt_at, ${now})
|
|
143
143
|
WHERE id = (SELECT id FROM line_outbox WHERE id = ${head.id} AND status = 'queued' FOR UPDATE SKIP LOCKED)
|
|
144
144
|
RETURNING id, source_key, sender_name, sender_icon_url, messages, retry_key, attempts,
|
|
@@ -214,6 +214,11 @@ export class OutboxWorker {
|
|
|
214
214
|
const messages = row.messages.map((message) =>
|
|
215
215
|
Object.keys(sender).length > 0 ? { ...message, sender } : message,
|
|
216
216
|
);
|
|
217
|
+
// A stop that comes before this leaves the attempt uncounted; one after it cannot say LINE
|
|
218
|
+
// did not take the request.
|
|
219
|
+
controller.signal.throwIfAborted();
|
|
220
|
+
await this.#sql`UPDATE line_outbox SET requested_at = ${this.#now()}
|
|
221
|
+
WHERE id = ${row.id} AND status = 'sending' AND claimed_at = ${row.claimed_at}`;
|
|
217
222
|
const pushed = await this.#api.push(
|
|
218
223
|
source.id,
|
|
219
224
|
messages,
|
|
@@ -245,7 +250,8 @@ export class OutboxWorker {
|
|
|
245
250
|
this.#quota.settle(reserved.reservation, sent);
|
|
246
251
|
}
|
|
247
252
|
} catch (error) {
|
|
248
|
-
// Giving up because the worker stops is the restart's doing: the push goes back as it was
|
|
253
|
+
// Giving up because the worker stops is the restart's doing: the push goes back as it was,
|
|
254
|
+
// unless its request had already been made.
|
|
249
255
|
if (this.#stopping && controller.signal.aborted) {
|
|
250
256
|
await this.#later(row, 0, "worker_stopped", false);
|
|
251
257
|
return;
|
|
@@ -307,7 +313,8 @@ export class OutboxWorker {
|
|
|
307
313
|
counted: boolean,
|
|
308
314
|
): Promise<void> {
|
|
309
315
|
await this.#sql`UPDATE line_outbox
|
|
310
|
-
SET status = 'queued', claimed_at = NULL,
|
|
316
|
+
SET status = 'queued', claimed_at = NULL,
|
|
317
|
+
attempts = attempts - CASE WHEN ${counted ? 1 : 0}::int = 0 AND requested_at IS NULL THEN 1 ELSE 0 END,
|
|
311
318
|
next_attempt_at = ${new Date(this.#now().getTime() + delayMs)}, last_error_code = ${code}
|
|
312
319
|
WHERE id = ${row.id} AND status = 'sending' AND claimed_at = ${row.claimed_at}`;
|
|
313
320
|
}
|
|
@@ -332,8 +339,9 @@ export class OutboxWorker {
|
|
|
332
339
|
last_error_code: string | null;
|
|
333
340
|
origin_refs: (string | null)[];
|
|
334
341
|
notify_attempts: number;
|
|
342
|
+
attempts: number;
|
|
335
343
|
}[] = await this
|
|
336
|
-
.#sql`SELECT id, source_key, status, last_error_code, origin_refs, notify_attempts
|
|
344
|
+
.#sql`SELECT id, source_key, status, last_error_code, origin_refs, notify_attempts, attempts
|
|
337
345
|
FROM line_outbox
|
|
338
346
|
WHERE notified_at IS NULL AND status IN ('sent', 'dead', 'refused')
|
|
339
347
|
AND (notify_after IS NULL OR notify_after <= ${now})
|
|
@@ -356,6 +364,7 @@ export class OutboxWorker {
|
|
|
356
364
|
originRef,
|
|
357
365
|
status: row.status,
|
|
358
366
|
errorCode: row.last_error_code,
|
|
367
|
+
attempts: row.attempts,
|
|
359
368
|
},
|
|
360
369
|
controller.signal,
|
|
361
370
|
);
|