@remit/drizzle-service 0.0.53 → 0.0.54
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
|
@@ -3,14 +3,17 @@
|
|
|
3
3
|
* `CREATE TABLE` block, not a schema pushed from the drizzle table objects.
|
|
4
4
|
*
|
|
5
5
|
* What it has to hold on a live database: a message whose filing is still in
|
|
6
|
-
* flight is left alone,
|
|
7
|
-
* a second run writes nothing.
|
|
6
|
+
* flight is left alone, one that was never filed becomes visible, one that was
|
|
7
|
+
* filed loses the row that outlived its delete, and a second run writes nothing.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import assert from "node:assert/strict";
|
|
11
11
|
import { after, before, describe, test } from "node:test";
|
|
12
12
|
import Database from "better-sqlite3";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
applyMigration,
|
|
15
|
+
shippedTableDdl,
|
|
16
|
+
} from "../test-shipped-sqlite-schema.js";
|
|
14
17
|
import {
|
|
15
18
|
STRANDED_AFTER_MILLIS,
|
|
16
19
|
type StrandedSentRepairClient,
|
|
@@ -34,31 +37,56 @@ describe("outbox rows stranded at sent", () => {
|
|
|
34
37
|
outboxMessageId: string,
|
|
35
38
|
status: string,
|
|
36
39
|
sentAt: number,
|
|
40
|
+
appendedUid = 0,
|
|
37
41
|
): void => {
|
|
38
42
|
sqlite
|
|
39
43
|
.prepare(
|
|
40
44
|
`INSERT INTO outbox_message (
|
|
41
45
|
outbox_message_id, account_id, account_config_id, from_address,
|
|
42
46
|
to_addresses, cc_addresses, bcc_addresses, "references",
|
|
43
|
-
message_id_value, status, sent_at, created_at, updated_at
|
|
47
|
+
message_id_value, status, sent_at, appended_uid, created_at, updated_at
|
|
44
48
|
) VALUES (?, 'acc', 'cfg', 'me@example.com', '["you@example.com"]',
|
|
45
|
-
'[]', '[]', '[]', 'mid@example.com', ?, ?, ?, ?)`,
|
|
49
|
+
'[]', '[]', '[]', 'mid@example.com', ?, ?, ?, ?, ?)`,
|
|
46
50
|
)
|
|
47
|
-
.run(outboxMessageId, status, sentAt, sentAt, sentAt);
|
|
51
|
+
.run(outboxMessageId, status, sentAt, appendedUid, sentAt, sentAt);
|
|
48
52
|
};
|
|
49
53
|
|
|
50
|
-
const
|
|
54
|
+
const attach = (outboxMessageId: string): void => {
|
|
55
|
+
sqlite
|
|
56
|
+
.prepare(
|
|
57
|
+
`INSERT INTO outbox_attachment (
|
|
58
|
+
outbox_attachment_id, outbox_message_id, account_id,
|
|
59
|
+
account_config_id, filename, content_type, size_bytes, state,
|
|
60
|
+
storage_key, reservation_expires_at, created_at, updated_at
|
|
61
|
+
) VALUES (?, ?, 'acc', 'cfg', 'q.pdf', 'application/pdf', 12,
|
|
62
|
+
'Stored', 'k', 0, 0, 0)`,
|
|
63
|
+
)
|
|
64
|
+
.run(`att-${outboxMessageId}`, outboxMessageId);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const read = (outboxMessageId: string): Row | undefined =>
|
|
51
68
|
sqlite
|
|
52
69
|
.prepare(
|
|
53
70
|
"SELECT status, last_error FROM outbox_message WHERE outbox_message_id = ?",
|
|
54
71
|
)
|
|
55
|
-
.get(outboxMessageId) as Row;
|
|
72
|
+
.get(outboxMessageId) as Row | undefined;
|
|
73
|
+
|
|
74
|
+
const attachmentCount = (outboxMessageId: string): number =>
|
|
75
|
+
(
|
|
76
|
+
sqlite
|
|
77
|
+
.prepare(
|
|
78
|
+
"SELECT count(*) AS n FROM outbox_attachment WHERE outbox_message_id = ?",
|
|
79
|
+
)
|
|
80
|
+
.get(outboxMessageId) as { n: number }
|
|
81
|
+
).n;
|
|
56
82
|
|
|
57
83
|
before(() => {
|
|
58
84
|
sqlite = new Database(":memory:");
|
|
59
85
|
sqlite.exec(
|
|
60
86
|
shippedTableDdl("0000_happy_roland_deschain", "outbox_message"),
|
|
61
87
|
);
|
|
88
|
+
sqlite.exec(shippedTableDdl("0013_low_harpoon", "outbox_attachment"));
|
|
89
|
+
applyMigration(sqlite, "0015_true_meggan");
|
|
62
90
|
});
|
|
63
91
|
|
|
64
92
|
after(() => {
|
|
@@ -72,10 +100,11 @@ describe("outbox rows stranded at sent", () => {
|
|
|
72
100
|
const report = await sweepStrandedSentOutbox(clientOver(sqlite), "repair");
|
|
73
101
|
|
|
74
102
|
assert.equal(report.stranded, 1);
|
|
103
|
+
assert.equal(report.neverFiled, 1);
|
|
75
104
|
assert.equal(report.settled, 1);
|
|
76
105
|
const row = read("stranded");
|
|
77
|
-
assert.equal(row
|
|
78
|
-
assert.match(String(row
|
|
106
|
+
assert.equal(row?.status, "unfiled");
|
|
107
|
+
assert.match(String(row?.last_error), /not filed/);
|
|
79
108
|
});
|
|
80
109
|
|
|
81
110
|
test("a second run writes nothing", async () => {
|
|
@@ -83,6 +112,7 @@ describe("outbox rows stranded at sent", () => {
|
|
|
83
112
|
|
|
84
113
|
assert.equal(report.stranded, 0);
|
|
85
114
|
assert.equal(report.settled, 0);
|
|
115
|
+
assert.equal(report.dropped, 0);
|
|
86
116
|
});
|
|
87
117
|
|
|
88
118
|
test("leaves a filing that is still in flight alone", async () => {
|
|
@@ -91,7 +121,7 @@ describe("outbox rows stranded at sent", () => {
|
|
|
91
121
|
const report = await sweepStrandedSentOutbox(clientOver(sqlite), "repair");
|
|
92
122
|
|
|
93
123
|
assert.equal(report.stranded, 0);
|
|
94
|
-
assert.equal(read("in-flight")
|
|
124
|
+
assert.equal(read("in-flight")?.status, "sent");
|
|
95
125
|
});
|
|
96
126
|
|
|
97
127
|
test("touches no other status", async () => {
|
|
@@ -101,8 +131,8 @@ describe("outbox rows stranded at sent", () => {
|
|
|
101
131
|
|
|
102
132
|
await sweepStrandedSentOutbox(clientOver(sqlite), "repair");
|
|
103
133
|
|
|
104
|
-
assert.equal(read("a-draft")
|
|
105
|
-
assert.equal(read("a-failure")
|
|
134
|
+
assert.equal(read("a-draft")?.status, "draft");
|
|
135
|
+
assert.equal(read("a-failure")?.status, "failed");
|
|
106
136
|
});
|
|
107
137
|
|
|
108
138
|
test("check mode reports what it would do and writes nothing", async () => {
|
|
@@ -112,6 +142,53 @@ describe("outbox rows stranded at sent", () => {
|
|
|
112
142
|
|
|
113
143
|
assert.equal(report.stranded, 1);
|
|
114
144
|
assert.equal(report.settled, 0);
|
|
115
|
-
assert.equal(read("also-stranded")
|
|
145
|
+
assert.equal(read("also-stranded")?.status, "sent");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("drops the leftover row of a message that was filed (#858)", async () => {
|
|
149
|
+
const old = Date.now() - STRANDED_AFTER_MILLIS - 1000;
|
|
150
|
+
insert("filed", "sent", old, 55);
|
|
151
|
+
attach("filed");
|
|
152
|
+
insert("never-filed", "sent", old);
|
|
153
|
+
attach("never-filed");
|
|
154
|
+
|
|
155
|
+
const report = await sweepStrandedSentOutbox(clientOver(sqlite), "repair");
|
|
156
|
+
|
|
157
|
+
// The copy is in the user's Sent folder. Marking it unfiled would say the
|
|
158
|
+
// message was never filed and show it in the Outbox alongside the copy.
|
|
159
|
+
assert.equal(report.filed, 1);
|
|
160
|
+
assert.equal(report.dropped, 1);
|
|
161
|
+
assert.equal(read("filed"), undefined);
|
|
162
|
+
|
|
163
|
+
// Its attachment rows go with it, so the objects stop being vouched for
|
|
164
|
+
// and the scheduler's attachment sweep can collect them.
|
|
165
|
+
assert.equal(attachmentCount("filed"), 0);
|
|
166
|
+
|
|
167
|
+
assert.equal(read("never-filed")?.status, "unfiled");
|
|
168
|
+
assert.equal(attachmentCount("never-filed"), 1);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("check mode reports a filed leftover without dropping it", async () => {
|
|
172
|
+
const old = Date.now() - STRANDED_AFTER_MILLIS - 1000;
|
|
173
|
+
insert("filed-too", "sent", old, 55);
|
|
174
|
+
|
|
175
|
+
const report = await sweepStrandedSentOutbox(clientOver(sqlite), "check");
|
|
176
|
+
|
|
177
|
+
assert.equal(report.filed, 1);
|
|
178
|
+
assert.equal(report.dropped, 0);
|
|
179
|
+
assert.equal(read("filed-too")?.status, "sent");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("counts a copy the server named no uid for as filed", async () => {
|
|
183
|
+
// A server without UIDPLUS files the copy and reports nothing. Settling
|
|
184
|
+
// that row unfiled would be as wrong as settling one with a real uid.
|
|
185
|
+
insert("no-uid", "sent", Date.now() - STRANDED_AFTER_MILLIS - 1000, -1);
|
|
186
|
+
|
|
187
|
+
const report = await sweepStrandedSentOutbox(clientOver(sqlite), "repair");
|
|
188
|
+
|
|
189
|
+
assert.equal(report.filed, 2);
|
|
190
|
+
assert.equal(report.dropped, 2);
|
|
191
|
+
assert.equal(read("no-uid"), undefined);
|
|
192
|
+
assert.equal(read("filed-too"), undefined);
|
|
116
193
|
});
|
|
117
194
|
});
|
|
@@ -14,14 +14,25 @@
|
|
|
14
14
|
* once, at boot — the migrate one-shot is the only step every self-host
|
|
15
15
|
* instance runs before its app containers start (#281).
|
|
16
16
|
*
|
|
17
|
+
* Two different things strand a row, and `appended_uid` is what tells them
|
|
18
|
+
* apart (issue #858). At `0` the APPEND was never confirmed: the message is in
|
|
19
|
+
* no folder, and `unfiled` with an error the user can read is the honest
|
|
20
|
+
* outcome. At anything else the copy is in the user's Sent folder and only the
|
|
21
|
+
* delete that follows the APPEND failed, so the row is a leftover — marking it
|
|
22
|
+
* `unfiled` would tell the user a message that is sitting in Sent was never
|
|
23
|
+
* filed, and show it to them twice. Those rows are dropped instead, finishing
|
|
24
|
+
* the delete the handler owed. Their attachment objects are collected by the
|
|
25
|
+
* scheduler's attachment sweep, which is where every object that outlives its
|
|
26
|
+
* row already ends up.
|
|
27
|
+
*
|
|
17
28
|
* The age bound is what separates a stranded row from an ordinary one. A
|
|
18
29
|
* message sent seconds before a restart has its APPEND event still queued, and
|
|
19
30
|
* that event settles the row itself once the workers come back; rewriting it
|
|
20
31
|
* here would take a filing that was about to succeed. An hour is far past any
|
|
21
32
|
* redrive budget, so a row older than that has no event coming.
|
|
22
33
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
34
|
+
* No message content is touched, and nothing is dropped that the mail server is
|
|
35
|
+
* not already holding a copy of.
|
|
25
36
|
*/
|
|
26
37
|
|
|
27
38
|
/**
|
|
@@ -38,7 +49,12 @@ export type StrandedSentRepairMode = "check" | "repair";
|
|
|
38
49
|
export type StrandedSentReport = {
|
|
39
50
|
readonly mode: StrandedSentRepairMode;
|
|
40
51
|
readonly stranded: number;
|
|
52
|
+
/** Stranded rows whose APPEND was never confirmed. */
|
|
53
|
+
readonly neverFiled: number;
|
|
54
|
+
/** Stranded rows whose copy reached Sent and whose delete did not. */
|
|
55
|
+
readonly filed: number;
|
|
41
56
|
readonly settled: number;
|
|
57
|
+
readonly dropped: number;
|
|
42
58
|
};
|
|
43
59
|
|
|
44
60
|
/**
|
|
@@ -50,46 +66,93 @@ export const STRANDED_AFTER_MILLIS = 60 * 60 * 1000;
|
|
|
50
66
|
const STRANDED_SENT_REASON =
|
|
51
67
|
"Sent, but not filed: filing a copy in the Sent folder never completed. The message was delivered.";
|
|
52
68
|
|
|
53
|
-
|
|
69
|
+
/**
|
|
70
|
+
* The cutoff is computed once and bound to all four statements, rather than
|
|
71
|
+
* each of them subtracting the age bound from its own `unixepoch()`. Four
|
|
72
|
+
* clocks give four different populations: a row that crosses the hour between
|
|
73
|
+
* the count and a write, or between the two deletes, is counted and not written
|
|
74
|
+
* or has its files taken and its row left.
|
|
75
|
+
*/
|
|
76
|
+
const STRANDED = "status = 'sent' AND coalesce(sent_at, updated_at) < ?";
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `isSentCopyFiled` in @remit/data-ports, in SQL. Only the two values that mean
|
|
80
|
+
* "filed" say so, so a value neither writes reads as unfiled — which settles a
|
|
81
|
+
* row rather than dropping it.
|
|
82
|
+
*/
|
|
83
|
+
const FILED_UID = "(appended_uid > 0 OR appended_uid = -1)";
|
|
84
|
+
|
|
85
|
+
const NEVER_FILED = `${STRANDED} AND NOT ${FILED_UID}`;
|
|
54
86
|
|
|
55
|
-
const
|
|
87
|
+
const FILED = `${STRANDED} AND ${FILED_UID}`;
|
|
56
88
|
|
|
57
|
-
const COUNT_SQL = `SELECT
|
|
89
|
+
const COUNT_SQL = `SELECT
|
|
90
|
+
coalesce(sum(CASE WHEN NOT ${FILED_UID} THEN 1 ELSE 0 END), 0) AS never_filed,
|
|
91
|
+
coalesce(sum(CASE WHEN ${FILED_UID} THEN 1 ELSE 0 END), 0) AS filed
|
|
58
92
|
FROM outbox_message
|
|
59
|
-
WHERE
|
|
93
|
+
WHERE ${STRANDED}`;
|
|
60
94
|
|
|
61
95
|
const SETTLE_SQL = `UPDATE outbox_message
|
|
62
96
|
SET status = 'unfiled', last_error = ?
|
|
63
|
-
WHERE
|
|
97
|
+
WHERE ${NEVER_FILED}`;
|
|
98
|
+
|
|
99
|
+
const DROP_ATTACHMENTS_SQL = `DELETE FROM outbox_attachment
|
|
100
|
+
WHERE outbox_message_id IN (
|
|
101
|
+
SELECT outbox_message_id FROM outbox_message WHERE ${FILED}
|
|
102
|
+
)`;
|
|
103
|
+
|
|
104
|
+
const DROP_MESSAGES_SQL = `DELETE FROM outbox_message
|
|
105
|
+
WHERE ${FILED}`;
|
|
64
106
|
|
|
65
107
|
const countStranded = async (
|
|
66
108
|
client: StrandedSentRepairClient,
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
109
|
+
strandedBefore: number,
|
|
110
|
+
): Promise<{ neverFiled: number; filed: number }> => {
|
|
111
|
+
const [row] = (await client.all(COUNT_SQL, [strandedBefore])) as {
|
|
112
|
+
never_filed: number;
|
|
113
|
+
filed: number;
|
|
70
114
|
}[];
|
|
71
|
-
return row?.
|
|
115
|
+
return { neverFiled: row?.never_filed ?? 0, filed: row?.filed ?? 0 };
|
|
72
116
|
};
|
|
73
117
|
|
|
74
118
|
/**
|
|
75
|
-
*
|
|
76
|
-
* write lock the moment
|
|
77
|
-
* nothing, and a lock the migrator cannot get inside its
|
|
78
|
-
* the migration and holds every gated service down. Zero
|
|
119
|
+
* Each write runs only when the count found rows for it. SQLite takes its
|
|
120
|
+
* exclusive write lock the moment a statement begins, before it can know the
|
|
121
|
+
* WHERE matches nothing, and a lock the migrator cannot get inside its
|
|
122
|
+
* `busy_timeout` fails the migration and holds every gated service down. Zero
|
|
123
|
+
* is the steady state.
|
|
124
|
+
*
|
|
125
|
+
* The attachment rows go before the message rows they hang off. Reversed, a
|
|
126
|
+
* crash between the two statements leaves attachment rows naming a message that
|
|
127
|
+
* no longer exists — nothing would ever find them again, and their objects would
|
|
128
|
+
* be vouched for forever.
|
|
79
129
|
*/
|
|
80
130
|
export const sweepStrandedSentOutbox = async (
|
|
81
131
|
client: StrandedSentRepairClient,
|
|
82
132
|
mode: StrandedSentRepairMode,
|
|
83
133
|
): Promise<StrandedSentReport> => {
|
|
84
|
-
const
|
|
134
|
+
const strandedBefore = Date.now() - STRANDED_AFTER_MILLIS;
|
|
135
|
+
const { neverFiled, filed } = await countStranded(client, strandedBefore);
|
|
136
|
+
const stranded = neverFiled + filed;
|
|
137
|
+
const nothingWritten = { mode, stranded, neverFiled, filed } as const;
|
|
138
|
+
|
|
85
139
|
if (mode === "check" || stranded === 0) {
|
|
86
|
-
return {
|
|
140
|
+
return { ...nothingWritten, settled: 0, dropped: 0 };
|
|
87
141
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
142
|
+
|
|
143
|
+
const settled =
|
|
144
|
+
neverFiled === 0
|
|
145
|
+
? 0
|
|
146
|
+
: await client.run(SETTLE_SQL, [STRANDED_SENT_REASON, strandedBefore]);
|
|
147
|
+
|
|
148
|
+
if (filed === 0) {
|
|
149
|
+
return { ...nothingWritten, settled, dropped: 0 };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
await client.run(DROP_ATTACHMENTS_SQL, [strandedBefore]);
|
|
153
|
+
const dropped = await client.run(DROP_MESSAGES_SQL, [strandedBefore]);
|
|
154
|
+
|
|
155
|
+
return { ...nothingWritten, settled, dropped };
|
|
93
156
|
};
|
|
94
157
|
|
|
95
158
|
export const formatStrandedSentReport = (
|
|
@@ -98,12 +161,31 @@ export const formatStrandedSentReport = (
|
|
|
98
161
|
if (report.stranded === 0) {
|
|
99
162
|
return ["No sent message is stranded in the outbox"];
|
|
100
163
|
}
|
|
164
|
+
|
|
165
|
+
const lines: string[] = [];
|
|
101
166
|
if (report.mode === "check") {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
167
|
+
if (report.neverFiled > 0) {
|
|
168
|
+
lines.push(
|
|
169
|
+
`${report.neverFiled} sent message(s) stranded in the outbox, would be marked unfiled`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
if (report.filed > 0) {
|
|
173
|
+
lines.push(
|
|
174
|
+
`${report.filed} sent message(s) already filed in Sent, their outbox row would be dropped`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
return lines;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (report.neverFiled > 0) {
|
|
181
|
+
lines.push(
|
|
182
|
+
`${report.settled} of ${report.neverFiled} stranded sent message(s) marked unfiled`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (report.filed > 0) {
|
|
186
|
+
lines.push(
|
|
187
|
+
`${report.dropped} of ${report.filed} already-filed sent message(s) dropped from the outbox`,
|
|
188
|
+
);
|
|
105
189
|
}
|
|
106
|
-
return
|
|
107
|
-
`${report.settled} of ${report.stranded} stranded sent message(s) marked unfiled`,
|
|
108
|
-
];
|
|
190
|
+
return lines;
|
|
109
191
|
};
|
|
@@ -37,6 +37,7 @@ function rowToOutboxMessage(
|
|
|
37
37
|
lastSmtpCode: row.lastSmtpCode ?? undefined,
|
|
38
38
|
sentAt: row.sentAt ?? undefined,
|
|
39
39
|
smtpMessageId: row.smtpMessageId ?? undefined,
|
|
40
|
+
appendedUid: row.appendedUid,
|
|
40
41
|
createdAt: row.createdAt,
|
|
41
42
|
updatedAt: row.updatedAt,
|
|
42
43
|
};
|
|
@@ -139,6 +140,8 @@ export class OutboxMessageRepo implements IOutboxMessageRepository {
|
|
|
139
140
|
if (input.sentAt !== undefined) updates.sentAt = input.sentAt;
|
|
140
141
|
if (input.smtpMessageId !== undefined)
|
|
141
142
|
updates.smtpMessageId = input.smtpMessageId;
|
|
143
|
+
if (input.appendedUid !== undefined)
|
|
144
|
+
updates.appendedUid = input.appendedUid;
|
|
142
145
|
if (input.toAddresses !== undefined)
|
|
143
146
|
updates.toAddresses = input.toAddresses;
|
|
144
147
|
if (input.ccAddresses !== undefined)
|