@remit/drizzle-service 0.0.30 → 0.0.31
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/package.json
CHANGED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The behaviour `thread_message.category`'s repair (#321) must have, asserted
|
|
3
|
+
* against a real engine of each dialect from one place: the statement ships in
|
|
4
|
+
* two dialects and an assertion that only ran on one of them is how a dialect
|
|
5
|
+
* quietly stops being repaired.
|
|
6
|
+
*
|
|
7
|
+
* Every seed is raw SQL against the real column names, so it exercises the same
|
|
8
|
+
* statement text the migrator runs rather than a drizzle re-spelling of it.
|
|
9
|
+
*/
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { after, before, beforeEach, describe, test } from "node:test";
|
|
12
|
+
import {
|
|
13
|
+
type CategoryDivergenceReport,
|
|
14
|
+
checkThreadMessageCategory,
|
|
15
|
+
formatCheckReport,
|
|
16
|
+
formatRepairResult,
|
|
17
|
+
formatRepairSkipped,
|
|
18
|
+
type RepairSqlClient,
|
|
19
|
+
readResidual,
|
|
20
|
+
repairThreadMessageCategory,
|
|
21
|
+
} from "./thread-message-category.js";
|
|
22
|
+
|
|
23
|
+
export type RepairHarness = {
|
|
24
|
+
readonly client: RepairSqlClient;
|
|
25
|
+
readonly reset: () => Promise<void>;
|
|
26
|
+
readonly close: () => Promise<void>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const literal = (value: string): string => `'${value.replace(/'/g, "''")}'`;
|
|
30
|
+
|
|
31
|
+
type MessageSeed = {
|
|
32
|
+
readonly messageId: string;
|
|
33
|
+
readonly category: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type ThreadMessageSeed = {
|
|
37
|
+
readonly threadMessageId: string;
|
|
38
|
+
readonly messageId: string;
|
|
39
|
+
readonly category: string;
|
|
40
|
+
readonly mailboxId?: string;
|
|
41
|
+
readonly updatedAt?: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const insertMessage = ({ messageId, category }: MessageSeed): string =>
|
|
45
|
+
`INSERT INTO message (
|
|
46
|
+
message_id, mailbox_id, uid, sequence_number, rfc822_size, internal_date,
|
|
47
|
+
envelope_id, root_body_part_id, category, created_at, updated_at
|
|
48
|
+
) VALUES (
|
|
49
|
+
${literal(messageId)}, 'mbx-inbox', 1, 1, 100, 0,
|
|
50
|
+
'env-1', 'part-1', ${literal(category)}, 0, 0
|
|
51
|
+
)`;
|
|
52
|
+
|
|
53
|
+
const insertThreadMessage = ({
|
|
54
|
+
threadMessageId,
|
|
55
|
+
messageId,
|
|
56
|
+
category,
|
|
57
|
+
mailboxId = "mbx-inbox",
|
|
58
|
+
updatedAt = 1000,
|
|
59
|
+
}: ThreadMessageSeed): string =>
|
|
60
|
+
`INSERT INTO thread_message (
|
|
61
|
+
thread_message_id, thread_id, message_id, account_config_id, mailbox_id, uid,
|
|
62
|
+
reference_order, internal_date, sent_date, is_read, has_attachment, has_stars,
|
|
63
|
+
is_deleted, category, created_at, updated_at
|
|
64
|
+
) VALUES (
|
|
65
|
+
${literal(threadMessageId)}, 'thr-1', ${literal(messageId)}, 'acct-1',
|
|
66
|
+
${literal(mailboxId)}, 1, 0, 0, 0, false, false, false, false,
|
|
67
|
+
${literal(category)}, 0, ${updatedAt}
|
|
68
|
+
)`;
|
|
69
|
+
|
|
70
|
+
type ThreadMessageRow = {
|
|
71
|
+
readonly threadMessageId: string;
|
|
72
|
+
readonly category: string;
|
|
73
|
+
readonly updatedAt: number;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const readRows = async (
|
|
77
|
+
client: RepairSqlClient,
|
|
78
|
+
): Promise<ThreadMessageRow[]> => {
|
|
79
|
+
const rows = await client.all(
|
|
80
|
+
"SELECT thread_message_id, category, updated_at FROM thread_message ORDER BY thread_message_id",
|
|
81
|
+
);
|
|
82
|
+
return rows.map((row) => {
|
|
83
|
+
if (typeof row !== "object" || row === null) {
|
|
84
|
+
throw new Error("expected a row object");
|
|
85
|
+
}
|
|
86
|
+
const record: Record<string, unknown> = { ...row };
|
|
87
|
+
return {
|
|
88
|
+
threadMessageId: String(record.thread_message_id),
|
|
89
|
+
category: String(record.category),
|
|
90
|
+
updatedAt: Number(record.updated_at),
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const categoryOf = (
|
|
96
|
+
rows: readonly ThreadMessageRow[],
|
|
97
|
+
threadMessageId: string,
|
|
98
|
+
): string => {
|
|
99
|
+
const row = rows.find((entry) => entry.threadMessageId === threadMessageId);
|
|
100
|
+
if (!row) {
|
|
101
|
+
throw new Error(`no thread_message row ${threadMessageId}`);
|
|
102
|
+
}
|
|
103
|
+
return row.category;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const describeRepairContract = (
|
|
107
|
+
label: string,
|
|
108
|
+
open: () => Promise<RepairHarness>,
|
|
109
|
+
): void => {
|
|
110
|
+
describe(`thread_message.category repair — ${label}`, () => {
|
|
111
|
+
let harness: RepairHarness;
|
|
112
|
+
|
|
113
|
+
before(async () => {
|
|
114
|
+
harness = await open();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
after(async () => {
|
|
118
|
+
await harness.close();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
beforeEach(async () => {
|
|
122
|
+
await harness.reset();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const seed = async (
|
|
126
|
+
messages: readonly MessageSeed[],
|
|
127
|
+
threadMessages: readonly ThreadMessageSeed[],
|
|
128
|
+
): Promise<void> => {
|
|
129
|
+
for (const message of messages) {
|
|
130
|
+
await harness.client.run(insertMessage(message));
|
|
131
|
+
}
|
|
132
|
+
for (const threadMessage of threadMessages) {
|
|
133
|
+
await harness.client.run(insertThreadMessage(threadMessage));
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const check = (): Promise<CategoryDivergenceReport> =>
|
|
138
|
+
checkThreadMessageCategory(harness.client);
|
|
139
|
+
|
|
140
|
+
test("repairs a stale row and does not touch a matching one", async () => {
|
|
141
|
+
await seed(
|
|
142
|
+
[
|
|
143
|
+
{ messageId: "msg-stale", category: "newsletter" },
|
|
144
|
+
{ messageId: "msg-agrees", category: "social" },
|
|
145
|
+
],
|
|
146
|
+
[
|
|
147
|
+
{
|
|
148
|
+
threadMessageId: "tm-stale",
|
|
149
|
+
messageId: "msg-stale",
|
|
150
|
+
category: "uncategorized",
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
threadMessageId: "tm-agrees",
|
|
154
|
+
messageId: "msg-agrees",
|
|
155
|
+
category: "social",
|
|
156
|
+
updatedAt: 4242,
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const before = await check();
|
|
162
|
+
assert.equal(before.rows, 2);
|
|
163
|
+
assert.equal(before.divergent, 1);
|
|
164
|
+
assert.equal(before.repairable, 1);
|
|
165
|
+
assert.equal(before.behind, 1);
|
|
166
|
+
assert.equal(before.crossed, 0);
|
|
167
|
+
|
|
168
|
+
const result = await repairThreadMessageCategory(harness.client);
|
|
169
|
+
assert.equal(result.rowsWritten, 1);
|
|
170
|
+
|
|
171
|
+
const rows = await readRows(harness.client);
|
|
172
|
+
assert.equal(categoryOf(rows, "tm-stale"), "newsletter");
|
|
173
|
+
assert.equal(categoryOf(rows, "tm-agrees"), "social");
|
|
174
|
+
|
|
175
|
+
const untouched = rows.find(
|
|
176
|
+
(row) => row.threadMessageId === "tm-agrees",
|
|
177
|
+
)?.updatedAt;
|
|
178
|
+
assert.equal(untouched, 4242);
|
|
179
|
+
|
|
180
|
+
const after = await check();
|
|
181
|
+
assert.equal(after.divergent, 0);
|
|
182
|
+
assert.equal(after.repairable, 0);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("repairs a row classified differently from its message", async () => {
|
|
186
|
+
await seed(
|
|
187
|
+
[{ messageId: "msg-crossed", category: "marketing" }],
|
|
188
|
+
[
|
|
189
|
+
{
|
|
190
|
+
threadMessageId: "tm-crossed",
|
|
191
|
+
messageId: "msg-crossed",
|
|
192
|
+
category: "personal",
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const before = await check();
|
|
198
|
+
assert.equal(before.crossed, 1);
|
|
199
|
+
assert.equal(before.behind, 0);
|
|
200
|
+
assert.equal(before.repairable, 1);
|
|
201
|
+
|
|
202
|
+
await repairThreadMessageCategory(harness.client);
|
|
203
|
+
const rows = await readRows(harness.client);
|
|
204
|
+
assert.equal(categoryOf(rows, "tm-crossed"), "marketing");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("leaves a row pending when its message is pending", async () => {
|
|
208
|
+
await seed(
|
|
209
|
+
[{ messageId: "msg-pending", category: "uncategorized" }],
|
|
210
|
+
[
|
|
211
|
+
{
|
|
212
|
+
threadMessageId: "tm-pending",
|
|
213
|
+
messageId: "msg-pending",
|
|
214
|
+
category: "uncategorized",
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const before = await check();
|
|
220
|
+
assert.equal(before.notYetClassified, 1);
|
|
221
|
+
assert.equal(before.divergent, 0);
|
|
222
|
+
|
|
223
|
+
const result = await repairThreadMessageCategory(harness.client);
|
|
224
|
+
assert.equal(result.rowsWritten, 0);
|
|
225
|
+
|
|
226
|
+
const rows = await readRows(harness.client);
|
|
227
|
+
assert.equal(categoryOf(rows, "tm-pending"), "uncategorized");
|
|
228
|
+
assert.equal((await check()).notYetClassified, 1);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// `uncategorized` is a declared pending state, not absence (#45). After #326
|
|
232
|
+
// body-sync writes the row before the message, so a row classified against a
|
|
233
|
+
// pending message is a classification in flight — pushing it back would undo
|
|
234
|
+
// a correct classification and serve Unclassified for classified mail. This
|
|
235
|
+
// is #326's closing question, answered: the row is left alone, not converged.
|
|
236
|
+
test("never copies pending over a classified row", async () => {
|
|
237
|
+
await seed(
|
|
238
|
+
[{ messageId: "msg-ahead", category: "uncategorized" }],
|
|
239
|
+
[
|
|
240
|
+
{
|
|
241
|
+
threadMessageId: "tm-ahead",
|
|
242
|
+
messageId: "msg-ahead",
|
|
243
|
+
category: "personal",
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const before = await check();
|
|
249
|
+
assert.equal(before.divergent, 1);
|
|
250
|
+
assert.equal(before.ahead, 1);
|
|
251
|
+
assert.equal(before.repairable, 0);
|
|
252
|
+
|
|
253
|
+
const result = await repairThreadMessageCategory(harness.client);
|
|
254
|
+
assert.equal(result.rowsWritten, 0);
|
|
255
|
+
|
|
256
|
+
const rows = await readRows(harness.client);
|
|
257
|
+
assert.equal(categoryOf(rows, "tm-ahead"), "personal");
|
|
258
|
+
assert.match(
|
|
259
|
+
formatCheckReport(await check()).join("\n"),
|
|
260
|
+
/ahead: 1 row .*Expected non-zero on a live instance mid-sync/,
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("leaves a row with no message row alone and counts it", async () => {
|
|
265
|
+
await seed(
|
|
266
|
+
[],
|
|
267
|
+
[
|
|
268
|
+
{
|
|
269
|
+
threadMessageId: "tm-orphan",
|
|
270
|
+
messageId: "msg-absent",
|
|
271
|
+
category: "personal",
|
|
272
|
+
},
|
|
273
|
+
],
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const before = await check();
|
|
277
|
+
assert.equal(before.rows, 1);
|
|
278
|
+
assert.equal(before.rowsWithMessage, 0);
|
|
279
|
+
assert.equal(before.orphanRows, 1);
|
|
280
|
+
|
|
281
|
+
const result = await repairThreadMessageCategory(harness.client);
|
|
282
|
+
assert.equal(result.rowsWritten, 0);
|
|
283
|
+
assert.equal(
|
|
284
|
+
categoryOf(await readRows(harness.client), "tm-orphan"),
|
|
285
|
+
"personal",
|
|
286
|
+
);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("repairs every row of a message held in two mailboxes", async () => {
|
|
290
|
+
await seed(
|
|
291
|
+
[{ messageId: "msg-fanned", category: "transactional" }],
|
|
292
|
+
[
|
|
293
|
+
{
|
|
294
|
+
threadMessageId: "tm-inbox",
|
|
295
|
+
messageId: "msg-fanned",
|
|
296
|
+
category: "uncategorized",
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
threadMessageId: "tm-archive",
|
|
300
|
+
messageId: "msg-fanned",
|
|
301
|
+
mailboxId: "mbx-archive",
|
|
302
|
+
category: "uncategorized",
|
|
303
|
+
},
|
|
304
|
+
],
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
const before = await check();
|
|
308
|
+
assert.equal(before.fanOutMessages, 1);
|
|
309
|
+
assert.equal(before.fanOutRows, 2);
|
|
310
|
+
assert.deepEqual(before.divergentByMailbox, [
|
|
311
|
+
{ mailboxId: "mbx-archive", rows: 1 },
|
|
312
|
+
{ mailboxId: "mbx-inbox", rows: 1 },
|
|
313
|
+
]);
|
|
314
|
+
|
|
315
|
+
const result = await repairThreadMessageCategory(harness.client);
|
|
316
|
+
assert.equal(result.rowsWritten, 2);
|
|
317
|
+
|
|
318
|
+
const rows = await readRows(harness.client);
|
|
319
|
+
assert.equal(categoryOf(rows, "tm-inbox"), "transactional");
|
|
320
|
+
assert.equal(categoryOf(rows, "tm-archive"), "transactional");
|
|
321
|
+
assert.equal((await check()).divergent, 0);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("is a no-op on the second run", async () => {
|
|
325
|
+
await seed(
|
|
326
|
+
[{ messageId: "msg-twice", category: "automated" }],
|
|
327
|
+
[
|
|
328
|
+
{
|
|
329
|
+
threadMessageId: "tm-twice",
|
|
330
|
+
messageId: "msg-twice",
|
|
331
|
+
category: "uncategorized",
|
|
332
|
+
},
|
|
333
|
+
],
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
assert.equal(
|
|
337
|
+
(await repairThreadMessageCategory(harness.client)).rowsWritten,
|
|
338
|
+
1,
|
|
339
|
+
);
|
|
340
|
+
const afterFirst = await readRows(harness.client);
|
|
341
|
+
assert.equal(
|
|
342
|
+
(await repairThreadMessageCategory(harness.client)).rowsWritten,
|
|
343
|
+
0,
|
|
344
|
+
);
|
|
345
|
+
assert.deepEqual(await readRows(harness.client), afterFirst);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// The guard's mechanics: a row whose stamp is beyond the statement's clock
|
|
349
|
+
// fails the WHERE clause and is left alone. A row stamped that far ahead is
|
|
350
|
+
// not a concurrent writer — it is a clock that jumped or a restored backup —
|
|
351
|
+
// and the residual has to say so, because that row fails the guard on every
|
|
352
|
+
// run and no later start will fix it.
|
|
353
|
+
test("skips a row stamped beyond the statement's clock and says why", async () => {
|
|
354
|
+
await seed(
|
|
355
|
+
[
|
|
356
|
+
{ messageId: "msg-ahead-clock", category: "newsletter" },
|
|
357
|
+
{ messageId: "msg-quiet", category: "newsletter" },
|
|
358
|
+
],
|
|
359
|
+
[
|
|
360
|
+
{
|
|
361
|
+
threadMessageId: "tm-ahead-clock",
|
|
362
|
+
messageId: "msg-ahead-clock",
|
|
363
|
+
category: "uncategorized",
|
|
364
|
+
updatedAt: Date.now() + 600_000,
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
threadMessageId: "tm-quiet",
|
|
368
|
+
messageId: "msg-quiet",
|
|
369
|
+
category: "uncategorized",
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
const result = await repairThreadMessageCategory(harness.client);
|
|
375
|
+
assert.equal(result.rowsWritten, 1);
|
|
376
|
+
|
|
377
|
+
const rows = await readRows(harness.client);
|
|
378
|
+
assert.equal(categoryOf(rows, "tm-ahead-clock"), "uncategorized");
|
|
379
|
+
assert.equal(categoryOf(rows, "tm-quiet"), "newsletter");
|
|
380
|
+
|
|
381
|
+
const residual = await readResidual(harness.client);
|
|
382
|
+
assert.deepEqual(residual, { repairable: 1, aheadOfClock: 1 });
|
|
383
|
+
|
|
384
|
+
const reported = formatRepairResult(result, residual).join("\n");
|
|
385
|
+
assert.match(reported, /ahead of the database clock/);
|
|
386
|
+
assert.match(reported, /every run, not just this one/);
|
|
387
|
+
assert.ok(
|
|
388
|
+
!reported.includes("skipped because a writer touched them"),
|
|
389
|
+
"a clock-ahead row must not be reported as a concurrent writer",
|
|
390
|
+
);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("names the concurrent writer and the update cadence when a run leaves a residual", () => {
|
|
394
|
+
const reported = formatRepairResult(
|
|
395
|
+
{ rowsWritten: 4, elapsedMs: 7 },
|
|
396
|
+
{ repairable: 2, aheadOfClock: 0 },
|
|
397
|
+
).join("\n");
|
|
398
|
+
assert.match(reported, /2 rows skipped because a writer touched them/);
|
|
399
|
+
assert.match(reported, /the next 'remit update'/);
|
|
400
|
+
assert.ok(!reported.includes("ahead of the database clock"));
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("a clean run reports no warning at all", () => {
|
|
404
|
+
assert.deepEqual(
|
|
405
|
+
formatRepairResult(
|
|
406
|
+
{ rowsWritten: 3, elapsedMs: 2 },
|
|
407
|
+
{ repairable: 0, aheadOfClock: 0 },
|
|
408
|
+
),
|
|
409
|
+
[
|
|
410
|
+
"repair wrote 3 rows in 2ms",
|
|
411
|
+
"residual repairable divergence: 0 (expected zero)",
|
|
412
|
+
],
|
|
413
|
+
);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("a skipped repair says no write lock was taken", async () => {
|
|
417
|
+
await seed(
|
|
418
|
+
[{ messageId: "msg-clean", category: "personal" }],
|
|
419
|
+
[
|
|
420
|
+
{
|
|
421
|
+
threadMessageId: "tm-clean",
|
|
422
|
+
messageId: "msg-clean",
|
|
423
|
+
category: "personal",
|
|
424
|
+
},
|
|
425
|
+
],
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
const report = await check();
|
|
429
|
+
assert.equal(report.repairable, 0);
|
|
430
|
+
assert.match(
|
|
431
|
+
formatRepairSkipped(report).join("\n"),
|
|
432
|
+
/repair skipped: nothing repairable .*No write, so no write lock is taken\./,
|
|
433
|
+
);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("--check writes nothing", async () => {
|
|
437
|
+
await seed(
|
|
438
|
+
[{ messageId: "msg-readonly", category: "personal" }],
|
|
439
|
+
[
|
|
440
|
+
{
|
|
441
|
+
threadMessageId: "tm-readonly",
|
|
442
|
+
messageId: "msg-readonly",
|
|
443
|
+
category: "uncategorized",
|
|
444
|
+
updatedAt: 7777,
|
|
445
|
+
},
|
|
446
|
+
],
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
const rowsBefore = await readRows(harness.client);
|
|
450
|
+
await check();
|
|
451
|
+
assert.deepEqual(await readRows(harness.client), rowsBefore);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("the report names a cause and its expected result for every figure", async () => {
|
|
455
|
+
await seed(
|
|
456
|
+
[{ messageId: "msg-report", category: "social" }],
|
|
457
|
+
[
|
|
458
|
+
{
|
|
459
|
+
threadMessageId: "tm-report",
|
|
460
|
+
messageId: "msg-report",
|
|
461
|
+
category: "uncategorized",
|
|
462
|
+
},
|
|
463
|
+
],
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
const lines = formatCheckReport(await check());
|
|
467
|
+
const text = lines.join("\n");
|
|
468
|
+
|
|
469
|
+
for (const cause of [
|
|
470
|
+
"behind:",
|
|
471
|
+
"crossed:",
|
|
472
|
+
"ahead:",
|
|
473
|
+
"fan-out:",
|
|
474
|
+
"orphans:",
|
|
475
|
+
"not-yet-classified:",
|
|
476
|
+
"divergent per mailbox:",
|
|
477
|
+
"category tally:",
|
|
478
|
+
]) {
|
|
479
|
+
assert.ok(text.includes(cause), `missing cause: ${cause}`);
|
|
480
|
+
}
|
|
481
|
+
assert.equal(
|
|
482
|
+
lines.filter((line) => /Expected (zero|non-zero)/.test(line)).length,
|
|
483
|
+
6,
|
|
484
|
+
);
|
|
485
|
+
assert.match(text, /category tally: uncategorized=1/);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// The causes are the deliverable, so each one names something that can
|
|
489
|
+
// actually happen on the deployment this repair ships to. A cause an
|
|
490
|
+
// operator can rule out by inspection reads as a broken figure.
|
|
491
|
+
test("no figure cites a cause that cannot occur on this deployment", async () => {
|
|
492
|
+
const text = formatCheckReport(await check()).join("\n");
|
|
493
|
+
for (const stale of [
|
|
494
|
+
"2026-07-08",
|
|
495
|
+
"drizzle push",
|
|
496
|
+
"Postgres instance whose column was added",
|
|
497
|
+
]) {
|
|
498
|
+
assert.ok(
|
|
499
|
+
!text.includes(stale),
|
|
500
|
+
`the report still cites ${stale}, which no SQLite instance can have experienced`,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
assert.match(text, /behind: .*write-path defects #326 fixes end here/);
|
|
504
|
+
assert.match(text, /crossed: .*no write path leaves a row here/);
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { after, before, describe, test } from "node:test";
|
|
6
|
+
import Database from "better-sqlite3";
|
|
7
|
+
import { messageTable } from "../schema/message-data.js";
|
|
8
|
+
import { threadMessageTable } from "../schema/thread-message.js";
|
|
9
|
+
import { createSqliteTestDb } from "../test-db-sqlite.js";
|
|
10
|
+
import {
|
|
11
|
+
checkThreadMessageCategory,
|
|
12
|
+
type RepairSqlClient,
|
|
13
|
+
repairStatement,
|
|
14
|
+
} from "./thread-message-category.js";
|
|
15
|
+
import { describeRepairContract } from "./thread-message-category-contract.js";
|
|
16
|
+
|
|
17
|
+
const sqliteClient = (sqlite: Database.Database): RepairSqlClient => ({
|
|
18
|
+
dialect: "sqlite",
|
|
19
|
+
all: async (sql) => sqlite.prepare(sql).all(),
|
|
20
|
+
run: async (sql) => sqlite.prepare(sql).run().changes,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// The repair against a real better-sqlite3 database — the engine every self-host
|
|
24
|
+
// instance runs, and the one whose `unixepoch()` and single-writer serialization
|
|
25
|
+
// the statement leans on.
|
|
26
|
+
describeRepairContract("sqlite", async () => {
|
|
27
|
+
const { sqlite, close } = await createSqliteTestDb({
|
|
28
|
+
message: messageTable,
|
|
29
|
+
threadMessage: threadMessageTable,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
client: sqliteClient(sqlite),
|
|
34
|
+
reset: async () => {
|
|
35
|
+
sqlite.exec("DELETE FROM thread_message");
|
|
36
|
+
sqlite.exec("DELETE FROM message");
|
|
37
|
+
},
|
|
38
|
+
close,
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Why the repair is skipped when the check found nothing: SQLite takes its
|
|
43
|
+
// exclusive write lock when the UPDATE begins, before it can know the WHERE
|
|
44
|
+
// matches nothing. Without the skip, every boot of a healthy instance would
|
|
45
|
+
// contend for that lock, and a contended boot fails the whole migration — which
|
|
46
|
+
// holds all six gated services down.
|
|
47
|
+
describe("thread_message.category repair — SQLite's write lock", () => {
|
|
48
|
+
let dir: string;
|
|
49
|
+
let file: string;
|
|
50
|
+
let reader: Database.Database;
|
|
51
|
+
let writer: Database.Database;
|
|
52
|
+
|
|
53
|
+
before(async () => {
|
|
54
|
+
dir = mkdtempSync(join(tmpdir(), "remit-repair-lock-"));
|
|
55
|
+
file = join(dir, "remit.db");
|
|
56
|
+
const { sqlite, close } = await createSqliteTestDb(
|
|
57
|
+
{ message: messageTable, threadMessage: threadMessageTable },
|
|
58
|
+
{ filename: file },
|
|
59
|
+
);
|
|
60
|
+
sqlite.pragma("journal_mode = WAL");
|
|
61
|
+
await close();
|
|
62
|
+
|
|
63
|
+
// 200 ms rather than the migrator's busy_timeout of 5000: the point is that
|
|
64
|
+
// the lock is wanted at all, and waiting five seconds to prove it is waste.
|
|
65
|
+
reader = new Database(file, { timeout: 200 });
|
|
66
|
+
reader.pragma("journal_mode = WAL");
|
|
67
|
+
writer = new Database(file);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
after(() => {
|
|
71
|
+
reader.close();
|
|
72
|
+
writer.close();
|
|
73
|
+
rmSync(dir, { recursive: true, force: true });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("the check reads while another connection holds the write lock", async () => {
|
|
77
|
+
writer.exec("BEGIN IMMEDIATE");
|
|
78
|
+
try {
|
|
79
|
+
const report = await checkThreadMessageCategory(sqliteClient(reader));
|
|
80
|
+
assert.equal(report.rows, 0);
|
|
81
|
+
assert.equal(report.repairable, 0);
|
|
82
|
+
} finally {
|
|
83
|
+
writer.exec("ROLLBACK");
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("the statement still wants the lock with nothing to repair", async () => {
|
|
88
|
+
writer.exec("BEGIN IMMEDIATE");
|
|
89
|
+
try {
|
|
90
|
+
assert.throws(
|
|
91
|
+
() => reader.prepare(repairStatement("sqlite")).run(),
|
|
92
|
+
(error: unknown) =>
|
|
93
|
+
error instanceof Error &&
|
|
94
|
+
"code" in error &&
|
|
95
|
+
error.code === "SQLITE_BUSY",
|
|
96
|
+
);
|
|
97
|
+
} finally {
|
|
98
|
+
writer.exec("ROLLBACK");
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, before, describe, test } from "node:test";
|
|
3
|
+
import { createTestDb } from "../test-db.js";
|
|
4
|
+
import {
|
|
5
|
+
type RepairSqlClient,
|
|
6
|
+
repairStatement,
|
|
7
|
+
repairThreadMessageCategory,
|
|
8
|
+
} from "./thread-message-category.js";
|
|
9
|
+
import { describeRepairContract } from "./thread-message-category-contract.js";
|
|
10
|
+
|
|
11
|
+
const { pool, close } = await createTestDb();
|
|
12
|
+
|
|
13
|
+
after(async () => {
|
|
14
|
+
await close();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const client: RepairSqlClient = {
|
|
18
|
+
dialect: "postgres",
|
|
19
|
+
all: async (sql) => (await pool.query(sql)).rows,
|
|
20
|
+
run: async (sql) => (await pool.query(sql)).rowCount ?? 0,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const truncate = async (): Promise<void> => {
|
|
24
|
+
await pool.query("DELETE FROM thread_message");
|
|
25
|
+
await pool.query("DELETE FROM message");
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describeRepairContract("postgres", async () => ({
|
|
29
|
+
client,
|
|
30
|
+
reset: truncate,
|
|
31
|
+
close: async () => undefined,
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
// Postgres is where a mid-sync run can go wrong, and the two interleavings have
|
|
35
|
+
// opposite outcomes — so each is driven with two real connections rather than
|
|
36
|
+
// argued about. Under READ COMMITTED an UPDATE that meets a row a concurrent
|
|
37
|
+
// transaction just changed re-evaluates its WHERE clause against the new version
|
|
38
|
+
// of that row, but keeps reading other tables at its original snapshot.
|
|
39
|
+
//
|
|
40
|
+
// Both cases need a *re*-classification to be observable at all: `message` moving
|
|
41
|
+
// from one decided category to another. `backfillClassification` cannot do that
|
|
42
|
+
// (it returns early once the category is decided), but the primary path is
|
|
43
|
+
// re-enterable — `if (message.bodyStorageKey && !force)`, where `force` is the
|
|
44
|
+
// read-miss re-arm cue — and a forced re-fetch decides differently across a
|
|
45
|
+
// release that changed the classifier. So the uncovered case below is
|
|
46
|
+
// effectively unreachable rather than impossible. The seeds are synthetic
|
|
47
|
+
// because reproducing it otherwise would mean changing the classifier.
|
|
48
|
+
describe("thread_message.category repair — a writer racing the statement", () => {
|
|
49
|
+
const insertMessage = async (
|
|
50
|
+
messageId: string,
|
|
51
|
+
category: string,
|
|
52
|
+
): Promise<void> => {
|
|
53
|
+
await pool.query(
|
|
54
|
+
`INSERT INTO message (
|
|
55
|
+
message_id, mailbox_id, uid, sequence_number, rfc822_size, internal_date,
|
|
56
|
+
envelope_id, root_body_part_id, category, created_at, updated_at
|
|
57
|
+
) VALUES ($1, 'mbx-inbox', 1, 1, 100, 0, 'env-1', 'part-1', $2, 0, 0)`,
|
|
58
|
+
[messageId, category],
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const insertRow = async (
|
|
63
|
+
messageId: string,
|
|
64
|
+
category: string,
|
|
65
|
+
): Promise<void> => {
|
|
66
|
+
await pool.query(
|
|
67
|
+
`INSERT INTO thread_message (
|
|
68
|
+
thread_message_id, thread_id, message_id, account_config_id, mailbox_id,
|
|
69
|
+
uid, reference_order, internal_date, sent_date, is_read, has_attachment,
|
|
70
|
+
has_stars, is_deleted, category, created_at, updated_at
|
|
71
|
+
) VALUES ($1, 'thr-1', $1, 'acct-1', 'mbx-inbox', 1, 0, 0, 0,
|
|
72
|
+
false, false, false, false, $2, 0, 1000)`,
|
|
73
|
+
[messageId, category],
|
|
74
|
+
);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// A body-sync denormalize, faithful to the shipped order (#326): the row
|
|
78
|
+
// first, the message second, both stamped with the wall clock at write time.
|
|
79
|
+
const reclassify = async (
|
|
80
|
+
connection: {
|
|
81
|
+
query: (sql: string, values?: unknown[]) => Promise<unknown>;
|
|
82
|
+
},
|
|
83
|
+
messageId: string,
|
|
84
|
+
category: string,
|
|
85
|
+
): Promise<void> => {
|
|
86
|
+
await connection.query(
|
|
87
|
+
"UPDATE thread_message SET category = $2, updated_at = $3 WHERE thread_message_id = $1",
|
|
88
|
+
[messageId, category, Date.now()],
|
|
89
|
+
);
|
|
90
|
+
await connection.query(
|
|
91
|
+
"UPDATE message SET category = $2, updated_at = $3 WHERE message_id = $1",
|
|
92
|
+
[messageId, category, Date.now()],
|
|
93
|
+
);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const categories = async (): Promise<Record<string, string>> => {
|
|
97
|
+
const { rows } = await pool.query<{
|
|
98
|
+
thread_message_id: string;
|
|
99
|
+
category: string;
|
|
100
|
+
}>("SELECT thread_message_id, category FROM thread_message");
|
|
101
|
+
return Object.fromEntries(
|
|
102
|
+
rows.map((row) => [row.thread_message_id, row.category]),
|
|
103
|
+
);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
before(truncate);
|
|
107
|
+
|
|
108
|
+
// The case the guard covers: the writer's transaction begins after the
|
|
109
|
+
// statement, so its stamp is beyond the statement's clock and the re-check
|
|
110
|
+
// against the committed row excludes it. The repair is held on an unrelated
|
|
111
|
+
// row lock for the duration, which is what puts the writer inside the
|
|
112
|
+
// statement's window without touching its stamp.
|
|
113
|
+
test("a writer that begins after the statement keeps its value", async () => {
|
|
114
|
+
await truncate();
|
|
115
|
+
await insertMessage("aaa-blocker", "newsletter");
|
|
116
|
+
await insertRow("aaa-blocker", "uncategorized");
|
|
117
|
+
await insertMessage("bbb-raced", "newsletter");
|
|
118
|
+
await insertRow("bbb-raced", "uncategorized");
|
|
119
|
+
|
|
120
|
+
const holder = await pool.connect();
|
|
121
|
+
const writer = await pool.connect();
|
|
122
|
+
try {
|
|
123
|
+
await holder.query("BEGIN");
|
|
124
|
+
await holder.query(
|
|
125
|
+
"UPDATE thread_message SET updated_at = 1001 WHERE thread_message_id = 'aaa-blocker'",
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const repairing = repairThreadMessageCategory(client);
|
|
129
|
+
// The repair reaches the blocker row — inserted first, so first in the
|
|
130
|
+
// sequential scan — and waits there. The sleep is on the holder's own
|
|
131
|
+
// connection, so nothing polls.
|
|
132
|
+
await holder.query("SELECT pg_sleep(0.5)");
|
|
133
|
+
|
|
134
|
+
await writer.query("BEGIN");
|
|
135
|
+
await reclassify(writer, "bbb-raced", "marketing");
|
|
136
|
+
await writer.query("COMMIT");
|
|
137
|
+
|
|
138
|
+
await holder.query("COMMIT");
|
|
139
|
+
|
|
140
|
+
// Only the blocker. A 2 here means the repair finished before the writer
|
|
141
|
+
// began, so the interleaving this test is named for did not happen.
|
|
142
|
+
assert.equal((await repairing).rowsWritten, 1);
|
|
143
|
+
} finally {
|
|
144
|
+
holder.release();
|
|
145
|
+
writer.release();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
assert.deepEqual(await categories(), {
|
|
149
|
+
"aaa-blocker": "newsletter",
|
|
150
|
+
"bbb-raced": "marketing",
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// The live mid-sync case, and it is safe: a writer whose transaction began
|
|
155
|
+
// before the statement, doing what body-sync actually does — filling in the
|
|
156
|
+
// copy of a category the message already holds. The repair blocks on the row,
|
|
157
|
+
// re-checks against the committed version, finds it now agrees, and writes
|
|
158
|
+
// nothing. This is the interleaving D16's safety argument rests on.
|
|
159
|
+
test("a concurrent denormalize of the same row leaves it correct", async () => {
|
|
160
|
+
await truncate();
|
|
161
|
+
await insertMessage("ddd-catchup", "newsletter");
|
|
162
|
+
await insertRow("ddd-catchup", "uncategorized");
|
|
163
|
+
|
|
164
|
+
const writer = await pool.connect();
|
|
165
|
+
try {
|
|
166
|
+
await writer.query("BEGIN");
|
|
167
|
+
await writer.query(
|
|
168
|
+
"UPDATE thread_message SET category = 'newsletter', updated_at = $1 WHERE thread_message_id = 'ddd-catchup'",
|
|
169
|
+
[Date.now()],
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const repairing = repairThreadMessageCategory(client);
|
|
173
|
+
await writer.query("SELECT pg_sleep(0.5)");
|
|
174
|
+
await writer.query("COMMIT");
|
|
175
|
+
|
|
176
|
+
assert.equal((await repairing).rowsWritten, 0);
|
|
177
|
+
} finally {
|
|
178
|
+
writer.release();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
assert.deepEqual(await categories(), { "ddd-catchup": "newsletter" });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// The known limit, pinned so it cannot silently widen. Reaching it needs four
|
|
185
|
+
// things at once: an unrepaired `behind` row, a writer whose transaction began
|
|
186
|
+
// before the statement, that writer's row write committing before the statement
|
|
187
|
+
// while its message write commits after, and that writer moving
|
|
188
|
+
// message.category from one decided value to a different one. Only a forced
|
|
189
|
+
// re-fetch across a classifier change does the fourth, which makes this
|
|
190
|
+
// effectively unreachable rather than impossible; the seed is synthetic and the
|
|
191
|
+
// assertion records the loss rather than endorsing it. If this test ever fails,
|
|
192
|
+
// the guard got stronger and D16 and the module header should say so.
|
|
193
|
+
test("a writer that began before the statement is outside the guard", async () => {
|
|
194
|
+
await truncate();
|
|
195
|
+
await insertMessage("ccc-reclass", "newsletter");
|
|
196
|
+
await insertRow("ccc-reclass", "uncategorized");
|
|
197
|
+
|
|
198
|
+
const writer = await pool.connect();
|
|
199
|
+
try {
|
|
200
|
+
await writer.query("BEGIN");
|
|
201
|
+
await reclassify(writer, "ccc-reclass", "marketing");
|
|
202
|
+
|
|
203
|
+
const repairing = repairThreadMessageCategory(client);
|
|
204
|
+
await writer.query("SELECT pg_sleep(0.5)");
|
|
205
|
+
await writer.query("COMMIT");
|
|
206
|
+
|
|
207
|
+
assert.equal((await repairing).rowsWritten, 1);
|
|
208
|
+
} finally {
|
|
209
|
+
writer.release();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
assert.deepEqual(await categories(), { "ccc-reclass": "newsletter" });
|
|
213
|
+
const { rows } = await pool.query<{ category: string }>(
|
|
214
|
+
"SELECT category FROM message WHERE message_id = 'ccc-reclass'",
|
|
215
|
+
);
|
|
216
|
+
assert.deepEqual(rows, [{ category: "marketing" }]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("only the clock expression differs between the dialects", () => {
|
|
220
|
+
const postgres = repairStatement("postgres");
|
|
221
|
+
const sqlite = repairStatement("sqlite");
|
|
222
|
+
assert.ok(postgres.includes("EXTRACT(EPOCH FROM now())"));
|
|
223
|
+
assert.ok(sqlite.includes("unixepoch('subsec')"));
|
|
224
|
+
assert.equal(
|
|
225
|
+
postgres.replace(/CAST\(EXTRACT.*$/, ""),
|
|
226
|
+
sqlite.replace(/CAST\(unixepoch.*$/, ""),
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The repair for `thread_message.category` (#321, D16 and D17 of
|
|
3
|
+
* `docs/design/mail-list-server-query.md`).
|
|
4
|
+
*
|
|
5
|
+
* `thread_message.category` is a denormalized copy of `message.category`. The
|
|
6
|
+
* read path never used it, so nothing ever depended on its write path being
|
|
7
|
+
* complete, and it has drifted. #304 makes it the SQL predicate behind the
|
|
8
|
+
* inbox category filter, so it has to be correct on every existing instance
|
|
9
|
+
* before that read path goes live — otherwise the filter under-returns mail
|
|
10
|
+
* with nothing masking it.
|
|
11
|
+
*
|
|
12
|
+
* One set-based statement per dialect. The value it copies is reachable through
|
|
13
|
+
* `message`'s primary key, so the write is single-valued by construction and
|
|
14
|
+
* there is no checkpointing, batching or resumability here: an interrupted run
|
|
15
|
+
* leaves a consistent table and the next start finishes the job.
|
|
16
|
+
*
|
|
17
|
+
* The statement runs only when the check found something to repair. SQLite takes
|
|
18
|
+
* its exclusive write lock when an UPDATE begins, before it can know the WHERE
|
|
19
|
+
* matches nothing, so an unconditional statement would take that lock on every
|
|
20
|
+
* boot forever — and a lock it cannot acquire within the migrator's
|
|
21
|
+
* `busy_timeout` fails the migration and holds every gated service down. The
|
|
22
|
+
* steady state after the first run is zero, so the skip is the normal path.
|
|
23
|
+
*
|
|
24
|
+
* Two things the statement will not do, both because the repair must never
|
|
25
|
+
* leave a row worse than it found it:
|
|
26
|
+
*
|
|
27
|
+
* - It never copies `uncategorized` over a classified row. `message` is the
|
|
28
|
+
* authority, but a pending message holds nothing to copy, and after #326
|
|
29
|
+
* body-sync writes the row before the message, so a classification in flight
|
|
30
|
+
* is legitimately `ahead` of its message for a moment. Pushing it back would
|
|
31
|
+
* undo a correct classification and, on the read path #328 builds, serve
|
|
32
|
+
* `Unclassified` for mail that is already classified. Counted as `ahead`.
|
|
33
|
+
* - It never overwrites a row that was written after the statement began. The
|
|
34
|
+
* migrate one-shot normally runs with every app service stopped, but
|
|
35
|
+
* `docker compose up -d` can restart it while workers still run, so the
|
|
36
|
+
* quiet window is not assumed. On SQLite writers are serialized, so a
|
|
37
|
+
* concurrent body-sync write lands wholly before or wholly after and both
|
|
38
|
+
* orders converge on the same value. On Postgres READ COMMITTED an UPDATE
|
|
39
|
+
* re-evaluates its WHERE clause against the version a concurrent
|
|
40
|
+
* transaction just committed, but still reads other tables at its original
|
|
41
|
+
* snapshot — so without the `updated_at` guard it could write a
|
|
42
|
+
* pre-classification value over the fresh one. The guard makes that row fail
|
|
43
|
+
* the re-check and the writer's value stands.
|
|
44
|
+
*
|
|
45
|
+
* The guard covers a writer whose transaction begins after the statement. A
|
|
46
|
+
* writer that began before it and commits during it carries an older stamp and
|
|
47
|
+
* passes, which would revert its write. Four things have to hold at once for
|
|
48
|
+
* that to lose anything:
|
|
49
|
+
*
|
|
50
|
+
* 1. a row the statement's WHERE matches, so an unrepaired `behind` row;
|
|
51
|
+
* 2. a writer overlapping the statement;
|
|
52
|
+
* 3. that writer's row write committing before the statement began while its
|
|
53
|
+
* message write commits after it — the two are separate commits;
|
|
54
|
+
* 4. the writer moving `message.category` from one decided value to a
|
|
55
|
+
* *different* decided value. A first classification cannot lose anything:
|
|
56
|
+
* `message.category` is still `uncategorized`, so the pending exclusion
|
|
57
|
+
* above refuses the row outright.
|
|
58
|
+
*
|
|
59
|
+
* Only the fourth is interesting, and it is not impossible. It is unreachable
|
|
60
|
+
* through `backfillClassification`, which returns early once the category is
|
|
61
|
+
* decided. It is reachable through the primary path, which is re-enterable: the
|
|
62
|
+
* skip guard is `if (message.bodyStorageKey && !force)` (`body-sync.ts`), and
|
|
63
|
+
* `force` is a live event flag — the read-miss re-arm cue, resolved in the
|
|
64
|
+
* imap-worker's `sync-message-body.ts`. A forced re-fetch re-runs
|
|
65
|
+
* `classifyByHeaders` over the same bytes, so it normally rewrites the value it
|
|
66
|
+
* already held; it decides *differently* only across a release that changed the
|
|
67
|
+
* classifier, which #62 was.
|
|
68
|
+
*
|
|
69
|
+
* So: effectively unreachable, needing a forced re-fetch of a message whose
|
|
70
|
+
* stored classification predates a classifier change, overlapping this
|
|
71
|
+
* statement. Not impossible, and the `crossed` figure is what reports it.
|
|
72
|
+
*
|
|
73
|
+
* The statement does not touch `updated_at`. That column means "when the app
|
|
74
|
+
* last wrote this row", which is what the guard above reads, and a repair that
|
|
75
|
+
* bumped it would both lie about the row and defeat its own guard.
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
export type RepairDialect = "sqlite" | "postgres";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The smallest surface the repair needs, so this module imports nothing and can
|
|
82
|
+
* be driven by the migrator's `better-sqlite3` handle, its `pg.Pool`, or a test
|
|
83
|
+
* harness of either dialect.
|
|
84
|
+
*/
|
|
85
|
+
export interface RepairSqlClient {
|
|
86
|
+
readonly dialect: RepairDialect;
|
|
87
|
+
all(sql: string): Promise<unknown[]>;
|
|
88
|
+
run(sql: string): Promise<number>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The declared pending state, not absence (#45). It must never fold into
|
|
93
|
+
* `personal`, and it is the one value the repair refuses to write over a
|
|
94
|
+
* classified row.
|
|
95
|
+
*/
|
|
96
|
+
const PENDING = "uncategorized";
|
|
97
|
+
|
|
98
|
+
const nowMillis = (dialect: RepairDialect): string =>
|
|
99
|
+
dialect === "sqlite"
|
|
100
|
+
? "CAST(unixepoch('subsec') * 1000 AS INTEGER)"
|
|
101
|
+
: "CAST(EXTRACT(EPOCH FROM now()) * 1000 AS bigint)";
|
|
102
|
+
|
|
103
|
+
export const repairStatement = (dialect: RepairDialect): string =>
|
|
104
|
+
`UPDATE thread_message
|
|
105
|
+
SET category = (
|
|
106
|
+
SELECT m.category FROM message m WHERE m.message_id = thread_message.message_id
|
|
107
|
+
)
|
|
108
|
+
WHERE EXISTS (
|
|
109
|
+
SELECT 1 FROM message m
|
|
110
|
+
WHERE m.message_id = thread_message.message_id
|
|
111
|
+
AND m.category <> thread_message.category
|
|
112
|
+
AND m.category <> '${PENDING}'
|
|
113
|
+
)
|
|
114
|
+
AND thread_message.updated_at <= ${nowMillis(dialect)}`;
|
|
115
|
+
|
|
116
|
+
const BUCKETS_SQL = `SELECT t.category AS row_category, m.category AS message_category, count(*) AS row_count
|
|
117
|
+
FROM thread_message t
|
|
118
|
+
JOIN message m ON m.message_id = t.message_id
|
|
119
|
+
GROUP BY t.category, m.category`;
|
|
120
|
+
|
|
121
|
+
const TALLY_SQL = `SELECT category, count(*) AS row_count
|
|
122
|
+
FROM thread_message
|
|
123
|
+
GROUP BY category
|
|
124
|
+
ORDER BY row_count DESC, category ASC`;
|
|
125
|
+
|
|
126
|
+
const BY_MAILBOX_SQL = `SELECT t.mailbox_id AS mailbox_id, count(*) AS row_count
|
|
127
|
+
FROM thread_message t
|
|
128
|
+
JOIN message m ON m.message_id = t.message_id
|
|
129
|
+
WHERE m.category <> t.category
|
|
130
|
+
GROUP BY t.mailbox_id
|
|
131
|
+
ORDER BY row_count DESC, mailbox_id ASC`;
|
|
132
|
+
|
|
133
|
+
const FAN_OUT_SQL = `SELECT count(*) AS message_count, coalesce(sum(row_count), 0) AS row_total
|
|
134
|
+
FROM (
|
|
135
|
+
SELECT message_id, count(*) AS row_count
|
|
136
|
+
FROM thread_message
|
|
137
|
+
GROUP BY message_id
|
|
138
|
+
HAVING count(*) > 1
|
|
139
|
+
) fan_out`;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The residual after a repair, split by why the row was left. A row skipped
|
|
143
|
+
* because a writer touched it during the statement is repaired on the next run;
|
|
144
|
+
* a row whose `updated_at` is ahead of the database clock fails the guard on
|
|
145
|
+
* every run until the stamp is corrected, which is a different thing to tell an
|
|
146
|
+
* operator and would otherwise be indistinguishable in the output.
|
|
147
|
+
*/
|
|
148
|
+
const residualSql = (dialect: RepairDialect): string =>
|
|
149
|
+
`SELECT count(*) AS row_count,
|
|
150
|
+
coalesce(sum(CASE WHEN t.updated_at > ${nowMillis(dialect)} THEN 1 ELSE 0 END), 0) AS ahead_of_clock
|
|
151
|
+
FROM thread_message t
|
|
152
|
+
JOIN message m ON m.message_id = t.message_id
|
|
153
|
+
WHERE m.category <> t.category
|
|
154
|
+
AND m.category <> '${PENDING}'`;
|
|
155
|
+
|
|
156
|
+
export type CategoryCount = {
|
|
157
|
+
readonly category: string;
|
|
158
|
+
readonly rows: number;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export type MailboxCount = {
|
|
162
|
+
readonly mailboxId: string;
|
|
163
|
+
readonly rows: number;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Divergence split by direction, because the direction is the cause. A repair
|
|
168
|
+
* that never ran and a corpus that was already correct both report
|
|
169
|
+
* `divergent: 0`; only the per-cause figures tell them apart, and only the
|
|
170
|
+
* per-cause expectations say which zeros are the healthy answer.
|
|
171
|
+
*
|
|
172
|
+
* - `behind` — the row is pending, its message is classified. Every one of the
|
|
173
|
+
* three write-path defects #326 fixes lands here, and this is the cohort the
|
|
174
|
+
* repair exists for.
|
|
175
|
+
* - `crossed` — both classified, differently. No write path leaves a row here:
|
|
176
|
+
* the row and the message take the same `classifyByHeaders` value off the
|
|
177
|
+
* same bytes in the same pass, so a re-classification moves both.
|
|
178
|
+
* - `ahead` — the row is classified, its message is pending. After #326 that is
|
|
179
|
+
* a classification in flight, so on a live instance it is expected and
|
|
180
|
+
* transient. Not repaired.
|
|
181
|
+
*/
|
|
182
|
+
export type CategoryDivergence = {
|
|
183
|
+
readonly rowsWithMessage: number;
|
|
184
|
+
readonly divergent: number;
|
|
185
|
+
readonly repairable: number;
|
|
186
|
+
readonly behind: number;
|
|
187
|
+
readonly crossed: number;
|
|
188
|
+
readonly ahead: number;
|
|
189
|
+
readonly notYetClassified: number;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
export type CategoryResidual = {
|
|
193
|
+
readonly repairable: number;
|
|
194
|
+
readonly aheadOfClock: number;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export type CategoryDivergenceReport = CategoryDivergence & {
|
|
198
|
+
readonly rows: number;
|
|
199
|
+
readonly orphanRows: number;
|
|
200
|
+
readonly fanOutMessages: number;
|
|
201
|
+
readonly fanOutRows: number;
|
|
202
|
+
readonly divergentByMailbox: readonly MailboxCount[];
|
|
203
|
+
readonly categoryTally: readonly CategoryCount[];
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
export type RepairResult = {
|
|
207
|
+
readonly rowsWritten: number;
|
|
208
|
+
readonly elapsedMs: number;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
212
|
+
typeof value === "object" && value !== null;
|
|
213
|
+
|
|
214
|
+
const columnOf = (row: unknown, column: string): unknown => {
|
|
215
|
+
if (!isRecord(row)) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`thread_message.category check: expected a row object, received ${JSON.stringify(row)}`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return row[column];
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// node-postgres hands back bigint aggregates as strings, so a count is a number
|
|
224
|
+
// or the decimal text of one, and anything else is a query that changed shape.
|
|
225
|
+
const countOf = (row: unknown, column: string): number => {
|
|
226
|
+
const value = columnOf(row, column);
|
|
227
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
228
|
+
return value;
|
|
229
|
+
}
|
|
230
|
+
if (typeof value === "string" && /^\d+$/.test(value)) {
|
|
231
|
+
return Number(value);
|
|
232
|
+
}
|
|
233
|
+
throw new Error(
|
|
234
|
+
`thread_message.category check: expected a count in "${column}", received ${JSON.stringify(value)}`,
|
|
235
|
+
);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const textOf = (row: unknown, column: string): string => {
|
|
239
|
+
const value = columnOf(row, column);
|
|
240
|
+
if (typeof value === "string") {
|
|
241
|
+
return value;
|
|
242
|
+
}
|
|
243
|
+
throw new Error(
|
|
244
|
+
`thread_message.category check: expected text in "${column}", received ${JSON.stringify(value)}`,
|
|
245
|
+
);
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const sum = (values: readonly number[]): number =>
|
|
249
|
+
values.reduce((total, value) => total + value, 0);
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* One grouped scan of the join answers every divergence figure, so the check
|
|
253
|
+
* costs two aggregate passes over `thread_message` rather than one per number.
|
|
254
|
+
*/
|
|
255
|
+
export const readDivergence = async (
|
|
256
|
+
client: RepairSqlClient,
|
|
257
|
+
): Promise<CategoryDivergence> => {
|
|
258
|
+
const buckets = (await client.all(BUCKETS_SQL)).map((row) => ({
|
|
259
|
+
rowCategory: textOf(row, "row_category"),
|
|
260
|
+
messageCategory: textOf(row, "message_category"),
|
|
261
|
+
rows: countOf(row, "row_count"),
|
|
262
|
+
}));
|
|
263
|
+
|
|
264
|
+
const total = (
|
|
265
|
+
predicate: (bucket: (typeof buckets)[number]) => boolean,
|
|
266
|
+
): number => sum(buckets.filter(predicate).map((bucket) => bucket.rows));
|
|
267
|
+
|
|
268
|
+
const behind = total(
|
|
269
|
+
(bucket) =>
|
|
270
|
+
bucket.rowCategory === PENDING && bucket.messageCategory !== PENDING,
|
|
271
|
+
);
|
|
272
|
+
const crossed = total(
|
|
273
|
+
(bucket) =>
|
|
274
|
+
bucket.rowCategory !== PENDING &&
|
|
275
|
+
bucket.messageCategory !== PENDING &&
|
|
276
|
+
bucket.rowCategory !== bucket.messageCategory,
|
|
277
|
+
);
|
|
278
|
+
const ahead = total(
|
|
279
|
+
(bucket) =>
|
|
280
|
+
bucket.rowCategory !== PENDING && bucket.messageCategory === PENDING,
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
rowsWithMessage: total(() => true),
|
|
285
|
+
divergent: total((bucket) => bucket.rowCategory !== bucket.messageCategory),
|
|
286
|
+
repairable: behind + crossed,
|
|
287
|
+
behind,
|
|
288
|
+
crossed,
|
|
289
|
+
ahead,
|
|
290
|
+
notYetClassified: total(
|
|
291
|
+
(bucket) =>
|
|
292
|
+
bucket.rowCategory === PENDING && bucket.messageCategory === PENDING,
|
|
293
|
+
),
|
|
294
|
+
};
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
export const checkThreadMessageCategory = async (
|
|
298
|
+
client: RepairSqlClient,
|
|
299
|
+
): Promise<CategoryDivergenceReport> => {
|
|
300
|
+
const divergence = await readDivergence(client);
|
|
301
|
+
|
|
302
|
+
const categoryTally = (await client.all(TALLY_SQL)).map((row) => ({
|
|
303
|
+
category: textOf(row, "category"),
|
|
304
|
+
rows: countOf(row, "row_count"),
|
|
305
|
+
}));
|
|
306
|
+
|
|
307
|
+
const divergentByMailbox = (await client.all(BY_MAILBOX_SQL)).map((row) => ({
|
|
308
|
+
mailboxId: textOf(row, "mailbox_id"),
|
|
309
|
+
rows: countOf(row, "row_count"),
|
|
310
|
+
}));
|
|
311
|
+
|
|
312
|
+
const [fanOut] = await client.all(FAN_OUT_SQL);
|
|
313
|
+
|
|
314
|
+
const rows = sum(categoryTally.map((entry) => entry.rows));
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
...divergence,
|
|
318
|
+
rows,
|
|
319
|
+
orphanRows: rows - divergence.rowsWithMessage,
|
|
320
|
+
fanOutMessages: countOf(fanOut, "message_count"),
|
|
321
|
+
fanOutRows: countOf(fanOut, "row_total"),
|
|
322
|
+
divergentByMailbox,
|
|
323
|
+
categoryTally,
|
|
324
|
+
};
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
export const repairThreadMessageCategory = async (
|
|
328
|
+
client: RepairSqlClient,
|
|
329
|
+
): Promise<RepairResult> => {
|
|
330
|
+
const startedAt = Date.now();
|
|
331
|
+
const rowsWritten = await client.run(repairStatement(client.dialect));
|
|
332
|
+
return { rowsWritten, elapsedMs: Date.now() - startedAt };
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
export const readResidual = async (
|
|
336
|
+
client: RepairSqlClient,
|
|
337
|
+
): Promise<CategoryResidual> => {
|
|
338
|
+
const [row] = await client.all(residualSql(client.dialect));
|
|
339
|
+
return {
|
|
340
|
+
repairable: countOf(row, "row_count"),
|
|
341
|
+
aheadOfClock: countOf(row, "ahead_of_clock"),
|
|
342
|
+
};
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const plural = (rows: number): string => (rows === 1 ? "row" : "rows");
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Every figure carries the cause it measures and the result a healthy instance
|
|
349
|
+
* is expected to produce. Most of them are legitimately zero, so a bare `0`
|
|
350
|
+
* cannot be told apart from a repair that never ran — and a cause an operator
|
|
351
|
+
* can rule out by inspection is worse than no cause at all, because it reads as
|
|
352
|
+
* a broken figure.
|
|
353
|
+
*/
|
|
354
|
+
export const formatCheckReport = (
|
|
355
|
+
report: CategoryDivergenceReport,
|
|
356
|
+
): string[] => [
|
|
357
|
+
`thread_message rows: ${report.rows} (${report.rowsWithMessage} with a message row)`,
|
|
358
|
+
`divergent (thread_message.category <> message.category): ${report.divergent}, of which ${report.repairable} repairable`,
|
|
359
|
+
` behind: ${report.behind} ${plural(report.behind)} pending against a classified message — the copy of a decided category never landed. All three of the write-path defects #326 fixes end here: a retro classification stranded between its two writes, a denormalize that wrote one of several rows for a message, and a row created for a message that was already classified. Repaired, and this is the cohort the repair exists for. Expected non-zero on an instance where any of those fired, zero otherwise.`,
|
|
360
|
+
` crossed: ${report.crossed} ${plural(report.crossed)} classified differently from the message — no write path leaves a row here: both take the same classifyByHeaders value off the same bytes in the same pass, so a re-classification moves the row and the message together. Repaired. Expected zero; a non-zero means the pair was interrupted between its two writes — a forced re-fetch that re-decided the category and died before the message write — or the database was edited outside the app.`,
|
|
361
|
+
` ahead: ${report.ahead} ${plural(report.ahead)} classified against a pending message — after #326 body-sync writes the row before the message, so this is a classification in flight. Not repaired: pushing it back to pending would undo a correct classification and serve Unclassified for mail that is already classified. Expected non-zero on a live instance mid-sync, zero on a quiescent one.`,
|
|
362
|
+
`fan-out: ${report.fanOutMessages} messages holding ${report.fanOutRows} thread_message rows — the multi-row shape #326 hardens against. Expected zero: deriveMessageId and deriveThreadMessageId are both mailbox-independent, so a message in two mailboxes collapses to one row, and the reachable case is thread-root drift.`,
|
|
363
|
+
`orphans: ${report.orphanRows} ${plural(report.orphanRows)} with no message row — not repaired, nothing to copy. Expected zero.`,
|
|
364
|
+
`not-yet-classified: ${report.notYetClassified} ${plural(report.notYetClassified)} pending against a pending message — not classified yet. Not a defect, not touched by the repair, and unchanged by it. Expected non-zero on a live instance.`,
|
|
365
|
+
`divergent per mailbox: ${
|
|
366
|
+
report.divergentByMailbox.length === 0
|
|
367
|
+
? "none"
|
|
368
|
+
: report.divergentByMailbox
|
|
369
|
+
.map((entry) => `${entry.mailboxId}=${entry.rows}`)
|
|
370
|
+
.join(" ")
|
|
371
|
+
}`,
|
|
372
|
+
`category tally: ${
|
|
373
|
+
report.categoryTally.length === 0
|
|
374
|
+
? "none"
|
|
375
|
+
: report.categoryTally
|
|
376
|
+
.map((entry) => `${entry.category}=${entry.rows}`)
|
|
377
|
+
.join(" ")
|
|
378
|
+
}`,
|
|
379
|
+
];
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The next run of the repair is the next start of the migrate one-shot, which on
|
|
383
|
+
* a self-host instance means the next `remit update` — weeks, not minutes. A
|
|
384
|
+
* residual is therefore named as something an operator may have to act on, not
|
|
385
|
+
* as something that clears itself shortly.
|
|
386
|
+
*/
|
|
387
|
+
export const formatRepairResult = (
|
|
388
|
+
result: RepairResult,
|
|
389
|
+
residual: CategoryResidual,
|
|
390
|
+
): string[] => {
|
|
391
|
+
const lines = [
|
|
392
|
+
`repair wrote ${result.rowsWritten} ${plural(result.rowsWritten)} in ${result.elapsedMs}ms`,
|
|
393
|
+
`residual repairable divergence: ${residual.repairable} (expected zero)`,
|
|
394
|
+
];
|
|
395
|
+
if (residual.repairable === 0) {
|
|
396
|
+
return lines;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const concurrent = residual.repairable - residual.aheadOfClock;
|
|
400
|
+
if (concurrent > 0) {
|
|
401
|
+
lines.push(
|
|
402
|
+
`WARNING: ${concurrent} ${plural(concurrent)} skipped because a writer touched them while the repair ran. The writer's value stands, which is correct, but the copy is only rechecked the next time this one-shot runs — the next 'remit update' — and until then the mail list serves that row's category as it now stands.`,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
if (residual.aheadOfClock > 0) {
|
|
406
|
+
lines.push(
|
|
407
|
+
`WARNING: ${residual.aheadOfClock} ${plural(residual.aheadOfClock)} carry an updated_at ahead of the database clock, so they fail the repair's guard on every run, not just this one. That is a clock that jumped, a restored backup, or a host whose time was wrong — not a concurrent writer. They stay divergent until the stamp is corrected.`,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
return lines;
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* The repair is skipped outright when the check found nothing to write, because
|
|
415
|
+
* SQLite takes the exclusive write lock when an UPDATE begins — before it can
|
|
416
|
+
* know the WHERE matches nothing. The steady state is zero, so without this
|
|
417
|
+
* every boot would take that lock, and a lock it cannot acquire within
|
|
418
|
+
* busy_timeout fails the migration and holds every gated service down. The
|
|
419
|
+
* figures the skip is decided on were just read; a row that diverges in the
|
|
420
|
+
* moment between is skipped by the guard anyway and repaired on the next run.
|
|
421
|
+
*/
|
|
422
|
+
export const formatRepairSkipped = (
|
|
423
|
+
divergence: CategoryDivergence,
|
|
424
|
+
): string[] => [
|
|
425
|
+
`repair skipped: nothing repairable (${divergence.divergent} divergent, ${divergence.ahead} of them a classification in flight). No write, so no write lock is taken.`,
|
|
426
|
+
];
|