@remit/drizzle-service 0.0.46 → 0.0.47
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,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The repair against the shape a deployment actually runs: the committed
|
|
3
|
+
* `CREATE TABLE` block, not a schema pushed from the drizzle table objects.
|
|
4
|
+
*
|
|
5
|
+
* What it has to hold on a live database: a message whose filing is still in
|
|
6
|
+
* flight is left alone, a stranded one becomes visible, no row is removed, and
|
|
7
|
+
* a second run writes nothing.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { after, before, describe, test } from "node:test";
|
|
12
|
+
import Database from "better-sqlite3";
|
|
13
|
+
import { shippedTableDdl } from "../test-shipped-sqlite-schema.js";
|
|
14
|
+
import {
|
|
15
|
+
STRANDED_AFTER_MILLIS,
|
|
16
|
+
type StrandedSentRepairClient,
|
|
17
|
+
sweepStrandedSentOutbox,
|
|
18
|
+
} from "./stranded-sent-outbox.js";
|
|
19
|
+
|
|
20
|
+
const clientOver = (sqlite: Database.Database): StrandedSentRepairClient => ({
|
|
21
|
+
all: async (sql, params) => sqlite.prepare(sql).all(...params),
|
|
22
|
+
run: async (sql, params) => sqlite.prepare(sql).run(...params).changes,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
interface Row {
|
|
26
|
+
status: string;
|
|
27
|
+
last_error: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("outbox rows stranded at sent", () => {
|
|
31
|
+
let sqlite: Database.Database;
|
|
32
|
+
|
|
33
|
+
const insert = (
|
|
34
|
+
outboxMessageId: string,
|
|
35
|
+
status: string,
|
|
36
|
+
sentAt: number,
|
|
37
|
+
): void => {
|
|
38
|
+
sqlite
|
|
39
|
+
.prepare(
|
|
40
|
+
`INSERT INTO outbox_message (
|
|
41
|
+
outbox_message_id, account_id, account_config_id, from_address,
|
|
42
|
+
to_addresses, cc_addresses, bcc_addresses, "references",
|
|
43
|
+
message_id_value, status, sent_at, created_at, updated_at
|
|
44
|
+
) VALUES (?, 'acc', 'cfg', 'me@example.com', '["you@example.com"]',
|
|
45
|
+
'[]', '[]', '[]', 'mid@example.com', ?, ?, ?, ?)`,
|
|
46
|
+
)
|
|
47
|
+
.run(outboxMessageId, status, sentAt, sentAt, sentAt);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const read = (outboxMessageId: string): Row =>
|
|
51
|
+
sqlite
|
|
52
|
+
.prepare(
|
|
53
|
+
"SELECT status, last_error FROM outbox_message WHERE outbox_message_id = ?",
|
|
54
|
+
)
|
|
55
|
+
.get(outboxMessageId) as Row;
|
|
56
|
+
|
|
57
|
+
before(() => {
|
|
58
|
+
sqlite = new Database(":memory:");
|
|
59
|
+
sqlite.exec(
|
|
60
|
+
shippedTableDdl("0000_happy_roland_deschain", "outbox_message"),
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
after(() => {
|
|
65
|
+
sqlite.close();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("makes a stranded sent message visible again", async () => {
|
|
69
|
+
const stranded = Date.now() - STRANDED_AFTER_MILLIS - 1000;
|
|
70
|
+
insert("stranded", "sent", stranded);
|
|
71
|
+
|
|
72
|
+
const report = await sweepStrandedSentOutbox(clientOver(sqlite), "repair");
|
|
73
|
+
|
|
74
|
+
assert.equal(report.stranded, 1);
|
|
75
|
+
assert.equal(report.settled, 1);
|
|
76
|
+
const row = read("stranded");
|
|
77
|
+
assert.equal(row.status, "unfiled");
|
|
78
|
+
assert.match(String(row.last_error), /not filed/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("a second run writes nothing", async () => {
|
|
82
|
+
const report = await sweepStrandedSentOutbox(clientOver(sqlite), "repair");
|
|
83
|
+
|
|
84
|
+
assert.equal(report.stranded, 0);
|
|
85
|
+
assert.equal(report.settled, 0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("leaves a filing that is still in flight alone", async () => {
|
|
89
|
+
insert("in-flight", "sent", Date.now());
|
|
90
|
+
|
|
91
|
+
const report = await sweepStrandedSentOutbox(clientOver(sqlite), "repair");
|
|
92
|
+
|
|
93
|
+
assert.equal(report.stranded, 0);
|
|
94
|
+
assert.equal(read("in-flight").status, "sent");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("touches no other status", async () => {
|
|
98
|
+
const old = Date.now() - STRANDED_AFTER_MILLIS - 1000;
|
|
99
|
+
insert("a-draft", "draft", old);
|
|
100
|
+
insert("a-failure", "failed", old);
|
|
101
|
+
|
|
102
|
+
await sweepStrandedSentOutbox(clientOver(sqlite), "repair");
|
|
103
|
+
|
|
104
|
+
assert.equal(read("a-draft").status, "draft");
|
|
105
|
+
assert.equal(read("a-failure").status, "failed");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("check mode reports what it would do and writes nothing", async () => {
|
|
109
|
+
insert("also-stranded", "sent", Date.now() - STRANDED_AFTER_MILLIS - 1000);
|
|
110
|
+
|
|
111
|
+
const report = await sweepStrandedSentOutbox(clientOver(sqlite), "check");
|
|
112
|
+
|
|
113
|
+
assert.equal(report.stranded, 1);
|
|
114
|
+
assert.equal(report.settled, 0);
|
|
115
|
+
assert.equal(read("also-stranded").status, "sent");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The repair for outbox rows stranded at `sent` (issue #824).
|
|
3
|
+
*
|
|
4
|
+
* A row holds `sent` only between SMTP accepting the message and the IMAP
|
|
5
|
+
* APPEND that files a copy in Sent, whose last act is to delete the row. Every
|
|
6
|
+
* way that APPEND could fail used to end in a silent return, so the row stayed
|
|
7
|
+
* at `sent` forever — and `sent` is the one non-draft status the Outbox list
|
|
8
|
+
* hides, on the assumption that the APPEND deletes it. The message was
|
|
9
|
+
* delivered, exists in no server folder, and appears in no view.
|
|
10
|
+
*
|
|
11
|
+
* The handler now settles those failures as `unfiled`. That reaches nothing
|
|
12
|
+
* already stranded: the events are long gone, and the rows are on instances
|
|
13
|
+
* that only an image update reaches. So the same settlement is applied here,
|
|
14
|
+
* once, at boot — the migrate one-shot is the only step every self-host
|
|
15
|
+
* instance runs before its app containers start (#281).
|
|
16
|
+
*
|
|
17
|
+
* The age bound is what separates a stranded row from an ordinary one. A
|
|
18
|
+
* message sent seconds before a restart has its APPEND event still queued, and
|
|
19
|
+
* that event settles the row itself once the workers come back; rewriting it
|
|
20
|
+
* here would take a filing that was about to succeed. An hour is far past any
|
|
21
|
+
* redrive budget, so a row older than that has no event coming.
|
|
22
|
+
*
|
|
23
|
+
* It is a status flip and an error string, on rows whose only other outcome is
|
|
24
|
+
* to stay invisible. Nothing is deleted and no message content is touched.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The smallest surface this needs, so the module imports no schema and no
|
|
29
|
+
* driver and can be driven by the migrator's `better-sqlite3` handle or a test.
|
|
30
|
+
*/
|
|
31
|
+
export interface StrandedSentRepairClient {
|
|
32
|
+
all(sql: string, params: unknown[]): Promise<unknown[]>;
|
|
33
|
+
run(sql: string, params: unknown[]): Promise<number>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type StrandedSentRepairMode = "check" | "repair";
|
|
37
|
+
|
|
38
|
+
export type StrandedSentReport = {
|
|
39
|
+
readonly mode: StrandedSentRepairMode;
|
|
40
|
+
readonly stranded: number;
|
|
41
|
+
readonly settled: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One hour. A row younger than this may still have its APPEND event in the
|
|
46
|
+
* queue, and that event settles the row correctly on its own.
|
|
47
|
+
*/
|
|
48
|
+
export const STRANDED_AFTER_MILLIS = 60 * 60 * 1000;
|
|
49
|
+
|
|
50
|
+
const STRANDED_SENT_REASON =
|
|
51
|
+
"Sent, but not filed: filing a copy in the Sent folder never completed. The message was delivered.";
|
|
52
|
+
|
|
53
|
+
const NOW_MILLIS = "CAST(unixepoch('subsec') * 1000 AS INTEGER)";
|
|
54
|
+
|
|
55
|
+
const AGE = `coalesce(sent_at, updated_at) < ${NOW_MILLIS} - ?`;
|
|
56
|
+
|
|
57
|
+
const COUNT_SQL = `SELECT count(*) AS row_count
|
|
58
|
+
FROM outbox_message
|
|
59
|
+
WHERE status = 'sent' AND ${AGE}`;
|
|
60
|
+
|
|
61
|
+
const SETTLE_SQL = `UPDATE outbox_message
|
|
62
|
+
SET status = 'unfiled', last_error = ?
|
|
63
|
+
WHERE status = 'sent' AND ${AGE}`;
|
|
64
|
+
|
|
65
|
+
const countStranded = async (
|
|
66
|
+
client: StrandedSentRepairClient,
|
|
67
|
+
): Promise<number> => {
|
|
68
|
+
const [row] = (await client.all(COUNT_SQL, [STRANDED_AFTER_MILLIS])) as {
|
|
69
|
+
row_count: number;
|
|
70
|
+
}[];
|
|
71
|
+
return row?.row_count ?? 0;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The UPDATE runs only when the count found rows. SQLite takes its exclusive
|
|
76
|
+
* write lock the moment an UPDATE begins, before it can know the WHERE matches
|
|
77
|
+
* nothing, and a lock the migrator cannot get inside its `busy_timeout` fails
|
|
78
|
+
* the migration and holds every gated service down. Zero is the steady state.
|
|
79
|
+
*/
|
|
80
|
+
export const sweepStrandedSentOutbox = async (
|
|
81
|
+
client: StrandedSentRepairClient,
|
|
82
|
+
mode: StrandedSentRepairMode,
|
|
83
|
+
): Promise<StrandedSentReport> => {
|
|
84
|
+
const stranded = await countStranded(client);
|
|
85
|
+
if (mode === "check" || stranded === 0) {
|
|
86
|
+
return { mode, stranded, settled: 0 };
|
|
87
|
+
}
|
|
88
|
+
const settled = await client.run(SETTLE_SQL, [
|
|
89
|
+
STRANDED_SENT_REASON,
|
|
90
|
+
STRANDED_AFTER_MILLIS,
|
|
91
|
+
]);
|
|
92
|
+
return { mode, stranded, settled };
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const formatStrandedSentReport = (
|
|
96
|
+
report: StrandedSentReport,
|
|
97
|
+
): string[] => {
|
|
98
|
+
if (report.stranded === 0) {
|
|
99
|
+
return ["No sent message is stranded in the outbox"];
|
|
100
|
+
}
|
|
101
|
+
if (report.mode === "check") {
|
|
102
|
+
return [
|
|
103
|
+
`${report.stranded} sent message(s) stranded in the outbox, would be marked unfiled`,
|
|
104
|
+
];
|
|
105
|
+
}
|
|
106
|
+
return [
|
|
107
|
+
`${report.settled} of ${report.stranded} stranded sent message(s) marked unfiled`,
|
|
108
|
+
];
|
|
109
|
+
};
|