@remit/imap-worker 0.0.85 → 0.0.87
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/README.md +3 -1
- package/package.json +10 -2
- package/src/account-check.ts +1 -1
- package/src/body-sync-gate.test.ts +1 -1
- package/src/body-sync-gate.ts +1 -1
- package/src/e2e-processor-shim.ts +6 -21
- package/src/emit.test.ts +32 -4
- package/src/emit.ts +58 -23
- package/src/failure-kind.ts +1 -1
- package/src/handlers/append-sent-message.sqlite.test.ts +176 -0
- package/src/handlers/append-sent-message.ts +3 -24
- package/src/handlers/delete-account-objects.test.ts +1 -1
- package/src/handlers/delete-account-objects.ts +1 -1
- package/src/handlers/empty-trash.sqlite.test.ts +209 -0
- package/src/handlers/empty-trash.ts +3 -24
- package/src/handlers/flag-push.ts +11 -18
- package/src/handlers/mailbox-management.sqlite.test.ts +265 -0
- package/src/handlers/mailbox-management.ts +12 -48
- package/src/handlers/message-copy.sqlite.test.ts +298 -0
- package/src/handlers/message-copy.ts +31 -49
- package/src/handlers/message-delete-terminal.ts +12 -10
- package/src/handlers/message-delete.sqlite.test.ts +291 -0
- package/src/handlers/message-delete.ts +26 -32
- package/src/handlers/message-move-terminal.ts +12 -10
- package/src/handlers/message-move.sqlite.test.ts +271 -0
- package/src/handlers/message-move.test.ts +76 -863
- package/src/handlers/message-move.ts +49 -39
- package/src/handlers/placement-move-push.test.ts +96 -329
- package/src/handlers/placement-move-push.ts +75 -29
- package/src/handlers/placement-push.sqlite.test.ts +298 -0
- package/src/handlers/probe-message-id.sqlite.test.ts +197 -0
- package/src/handlers/probe-message-id.ts +15 -0
- package/src/handlers/sync-mailboxes.test.ts +0 -1
- package/src/handlers/sync-mailboxes.ts +1 -1
- package/src/handlers/sync-message-body.sqlite.test.ts +314 -0
- package/src/handlers/sync-message-body.test.ts +3 -299
- package/src/handlers/sync-message-body.ts +21 -11
- package/src/handlers/sync-messages.sqlite.test.ts +183 -0
- package/src/handlers/sync-messages.ts +35 -34
- package/src/index.ts +5 -3
- package/src/mail-sync-selection.sqlite.test.ts +20 -18
- package/src/pending-sync.sqlite.test.ts +167 -0
- package/src/poller.ts +5 -4
- package/src/processor.test.ts +1 -1
- package/src/processor.ts +1 -1
- package/src/scheduler/attachment-sweep.sqlite.test.ts +74 -0
- package/src/scheduler/attachment-sweep.ts +1 -1
- package/src/scheduler/{calendar-subscriptions.test.ts → calendar-subscriptions.sqlite.test.ts} +43 -55
- package/src/scheduler/calendar-subscriptions.ts +6 -6
- package/src/scheduler/half-deleted-accounts.sqlite.test.ts +203 -0
- package/src/scheduler/handler.ts +12 -1
- package/src/scheduler/loop.test.ts +2 -0
- package/src/scheduler/run-tick.sqlite.test.ts +192 -0
- package/src/scheduler/run-tick.ts +39 -1
- package/src/scheduler/runner.ts +21 -1
- package/src/scheduler/stranded-placement-redrive.ts +265 -0
- package/src/stranded-placement.sqlite.test.ts +795 -0
- package/src/test-helpers/test-world.ts +398 -0
- package/src/thread-join.sqlite.test.ts +115 -0
- package/src/with-oauth-lifecycle-deps.sqlite.test.ts +138 -14
- package/src/with-oauth-lifecycle.ts +1 -1
- package/src/handlers/append-sent-message.test.ts +0 -529
- package/src/handlers/empty-trash.test.ts +0 -728
- package/src/handlers/flag-push-placement-race.test.ts +0 -385
- package/src/handlers/flag-push.test.ts +0 -325
- package/src/handlers/mailbox-management.test.ts +0 -850
- package/src/handlers/message-copy.test.ts +0 -654
- package/src/handlers/message-delete.test.ts +0 -1869
- package/src/handlers/message-move-terminal.test.ts +0 -412
- package/src/handlers/sync-messages.test.ts +0 -567
- package/src/scheduler/attachment-sweep.test.ts +0 -169
- package/src/scheduler/run-tick.test.ts +0 -478
- package/src/with-oauth-lifecycle.test.ts +0 -318
package/src/scheduler/handler.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { getClient } from "@remit/backend/client";
|
|
2
|
-
import { createLogger
|
|
2
|
+
import { createLogger } from "@remit/logger";
|
|
3
|
+
import { installNodeLogger, withTelemetry } from "@remit/logger-lambda/node";
|
|
3
4
|
import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
4
5
|
import type { ScheduledHandler } from "aws-lambda";
|
|
5
6
|
import { env } from "expect-env";
|
|
7
|
+
import { emitEvent } from "../emit.js";
|
|
6
8
|
import { buildAttachmentSweep } from "./attachment-sweep.js";
|
|
7
9
|
import { buildCalendarSubscriptionRefresh } from "./calendar-subscriptions.js";
|
|
8
10
|
import { getOfflineIntervalMs, getTickIntervalMs } from "./config.js";
|
|
9
11
|
import { runSchedulerTick } from "./run-tick.js";
|
|
12
|
+
import { redriveStrandedPlacements } from "./stranded-placement-redrive.js";
|
|
13
|
+
|
|
14
|
+
installNodeLogger();
|
|
10
15
|
|
|
11
16
|
const log = createLogger();
|
|
12
17
|
|
|
@@ -47,5 +52,11 @@ export const handler: ScheduledHandler = withTelemetry(async (event) => {
|
|
|
47
52
|
// already gone and this sweep is the only thing that collects it.
|
|
48
53
|
sweepAttachments: buildAttachmentSweep(client, log),
|
|
49
54
|
refreshCalendarSubscriptions: buildCalendarSubscriptionRefresh(client, log),
|
|
55
|
+
redriveStrandedPlacements: (account, tickNow) =>
|
|
56
|
+
redriveStrandedPlacements(
|
|
57
|
+
{ client, emit: emitEvent, log },
|
|
58
|
+
account,
|
|
59
|
+
tickNow,
|
|
60
|
+
),
|
|
50
61
|
});
|
|
51
62
|
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
|
+
import type { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
|
|
4
|
+
import type { AccountItem } from "@remit/data-ports";
|
|
5
|
+
import {
|
|
6
|
+
AccountAuthType,
|
|
7
|
+
AccountService,
|
|
8
|
+
ConnectionState,
|
|
9
|
+
} from "@remit/domain-enums";
|
|
10
|
+
import { AccountRepo } from "@remit/drizzle-service";
|
|
11
|
+
import { createShippedSqliteDb } from "@remit/drizzle-service/test-sqlite";
|
|
12
|
+
import { capturingLogger, type LogLine } from "../test-helpers/test-world.js";
|
|
13
|
+
import { runSchedulerTick } from "./run-tick.js";
|
|
14
|
+
|
|
15
|
+
const TICK_INTERVAL_MS = 60 * 60 * 1000;
|
|
16
|
+
const OFFLINE_INTERVAL_MS = 12 * 60 * 60 * 1000;
|
|
17
|
+
const NOW = 1_700_000_000_000;
|
|
18
|
+
|
|
19
|
+
let accounts: AccountRepo;
|
|
20
|
+
let close: () => void;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
const store = createShippedSqliteDb();
|
|
24
|
+
close = store.close;
|
|
25
|
+
accounts = new AccountRepo(store.db, store.db);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => close());
|
|
29
|
+
|
|
30
|
+
const account = async (
|
|
31
|
+
name: string,
|
|
32
|
+
overrides: Partial<AccountItem> = {},
|
|
33
|
+
stored: { credential: boolean } = { credential: true },
|
|
34
|
+
): Promise<AccountItem> => {
|
|
35
|
+
const created = await accounts.create({
|
|
36
|
+
accountConfigId: `cfg-${name}`,
|
|
37
|
+
username: `${name}@example.com`,
|
|
38
|
+
email: `${name}@example.com`,
|
|
39
|
+
authType: AccountAuthType.Password,
|
|
40
|
+
passwordHash: stored.credential ? '{"ciphertext":"x"}' : undefined,
|
|
41
|
+
imapHost: "imap.example.com",
|
|
42
|
+
imapPort: 993,
|
|
43
|
+
imapTls: true,
|
|
44
|
+
imapStartTls: false,
|
|
45
|
+
isActive: true,
|
|
46
|
+
connectionState: ConnectionState.Authenticated,
|
|
47
|
+
} as Parameters<AccountRepo["create"]>[0]);
|
|
48
|
+
if (Object.keys(overrides).length === 0) return created;
|
|
49
|
+
await accounts.update(created.accountId, overrides);
|
|
50
|
+
return accounts.get(created.accountId);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const tick = async (
|
|
54
|
+
options: Partial<Parameters<typeof runSchedulerTick>[0]> = {},
|
|
55
|
+
) => {
|
|
56
|
+
const sent: SendMessageCommand[] = [];
|
|
57
|
+
const logs: LogLine[] = [];
|
|
58
|
+
const result = await runSchedulerTick({
|
|
59
|
+
accountService: accounts,
|
|
60
|
+
sqsClient: {
|
|
61
|
+
send: async (command: SendMessageCommand) => {
|
|
62
|
+
sent.push(command);
|
|
63
|
+
return {};
|
|
64
|
+
},
|
|
65
|
+
} as unknown as SQSClient,
|
|
66
|
+
queueUrl:
|
|
67
|
+
"https://sqs.eu-west-1.amazonaws.com/123/remit-dev-mailboxes.fifo",
|
|
68
|
+
log: capturingLogger(logs),
|
|
69
|
+
tickIntervalMs: TICK_INTERVAL_MS,
|
|
70
|
+
offlineIntervalMs: OFFLINE_INTERVAL_MS,
|
|
71
|
+
now: NOW,
|
|
72
|
+
redriveStrandedPlacements: async () => 0,
|
|
73
|
+
...options,
|
|
74
|
+
});
|
|
75
|
+
const enqueued = sent.map(
|
|
76
|
+
(command) => JSON.parse(command.input.MessageBody ?? "{}").accountId,
|
|
77
|
+
);
|
|
78
|
+
return { result, sent, enqueued, logs };
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
describe("a scheduler tick", () => {
|
|
82
|
+
it("enqueues the accounts that are due and only those", async () => {
|
|
83
|
+
const due = await account("due", {
|
|
84
|
+
lastSyncAt: NOW - OFFLINE_INTERVAL_MS - 1,
|
|
85
|
+
});
|
|
86
|
+
const never = await account("never");
|
|
87
|
+
await account("fresh", { lastSyncAt: NOW - 60_000 });
|
|
88
|
+
|
|
89
|
+
const { enqueued } = await tick();
|
|
90
|
+
|
|
91
|
+
assert.deepEqual(enqueued.sort(), [due.accountId, never.accountId].sort());
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("skips an account it cannot or should not sync, and says so once in aggregate", async () => {
|
|
95
|
+
const live = await account("live");
|
|
96
|
+
await account("unsyncable", { imapHost: "mail.invalid" });
|
|
97
|
+
await account("reauth", {
|
|
98
|
+
connectionState: ConnectionState.ReauthRequired,
|
|
99
|
+
});
|
|
100
|
+
await account("calendar", { syncedServices: [AccountService.Calendar] });
|
|
101
|
+
|
|
102
|
+
const { enqueued, result, logs } = await tick();
|
|
103
|
+
|
|
104
|
+
assert.deepEqual(enqueued, [live.accountId]);
|
|
105
|
+
assert.equal(result.skipped, 3);
|
|
106
|
+
assert.equal(logs.length, 1, "one aggregate line, no per-account noise");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Issue #1120: a sync for an account with no stored credential fenced the
|
|
110
|
+
// account's FIFO group for its whole visibility timeout.
|
|
111
|
+
it("skips an account that stores no credential", async () => {
|
|
112
|
+
const live = await account("live");
|
|
113
|
+
await account("missing", {}, { credential: false });
|
|
114
|
+
|
|
115
|
+
const { enqueued } = await tick();
|
|
116
|
+
|
|
117
|
+
assert.deepEqual(enqueued, [live.accountId]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("gives each enqueued account a scheduler-namespaced, time-bucketed dedup id", async () => {
|
|
121
|
+
const live = await account("live");
|
|
122
|
+
|
|
123
|
+
const { sent } = await tick();
|
|
124
|
+
|
|
125
|
+
const dedupId = sent[0]?.input.MessageDeduplicationId;
|
|
126
|
+
assert.ok(
|
|
127
|
+
dedupId?.startsWith(`SYNC_MAILBOXES:scheduled:${live.accountId}:`),
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("sweeps every account's attachments, due a sync or not", async () => {
|
|
132
|
+
const due = await account("due");
|
|
133
|
+
const fresh = await account("fresh", { lastSyncAt: NOW - 60_000 });
|
|
134
|
+
const swept: string[] = [];
|
|
135
|
+
|
|
136
|
+
const { result } = await tick({
|
|
137
|
+
sweepAttachments: async (item) => {
|
|
138
|
+
swept.push(item.accountId);
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
assert.deepEqual(swept.sort(), [due.accountId, fresh.accountId].sort());
|
|
143
|
+
assert.equal(result.swept, 2);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Uncontained, one storage error reaches the loop, which exits, and the
|
|
147
|
+
// container restarts without ever enqueuing an account again.
|
|
148
|
+
it("keeps enqueuing mail when the attachment sweep throws", async () => {
|
|
149
|
+
const live = await account("live");
|
|
150
|
+
|
|
151
|
+
const { enqueued, result } = await tick({
|
|
152
|
+
sweepAttachments: async () => {
|
|
153
|
+
throw new Error("AccessDenied on ListObjectsV2");
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
assert.deepEqual(enqueued, [live.accountId]);
|
|
158
|
+
assert.equal(result.sweepFailed, 1);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("keeps the tick alive when the calendar subscription refresh throws", async () => {
|
|
162
|
+
const live = await account("live");
|
|
163
|
+
|
|
164
|
+
const { enqueued, logs } = await tick({
|
|
165
|
+
refreshCalendarSubscriptions: async () => {
|
|
166
|
+
throw new Error("database is locked");
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
assert.deepEqual(enqueued, [live.accountId]);
|
|
171
|
+
assert.ok(logs.some((line) => line.level === "error"));
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("counts what each account re-drove and keeps enqueuing past one that fails", async () => {
|
|
175
|
+
const live = await account("live");
|
|
176
|
+
const busy = await account("busy");
|
|
177
|
+
|
|
178
|
+
const { enqueued, result, logs } = await tick({
|
|
179
|
+
redriveStrandedPlacements: async (redriven) => {
|
|
180
|
+
if (redriven.accountId === busy.accountId) {
|
|
181
|
+
throw new Error("SQLITE_BUSY");
|
|
182
|
+
}
|
|
183
|
+
return 2;
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
assert.deepEqual(enqueued.sort(), [live.accountId, busy.accountId].sort());
|
|
188
|
+
assert.equal(result.redriven, 2);
|
|
189
|
+
assert.equal(result.redriveFailed, 1);
|
|
190
|
+
assert.ok(logs.some((line) => line.level === "error"));
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
triggerAccountSync,
|
|
5
5
|
} from "@remit/backend/trigger-sync";
|
|
6
6
|
import type { AccountItem, IAccountRepository } from "@remit/data-ports";
|
|
7
|
-
import type { Logger } from "@remit/logger
|
|
7
|
+
import type { Logger } from "@remit/logger";
|
|
8
8
|
import { hasStoredCredential } from "@remit/mailbox-service/account-credentials";
|
|
9
9
|
import pMap from "p-map";
|
|
10
10
|
import {
|
|
@@ -53,6 +53,10 @@ export interface RunSchedulerTickDeps {
|
|
|
53
53
|
now: number,
|
|
54
54
|
intervalMs: number,
|
|
55
55
|
) => Promise<CalendarSubscriptionTally>;
|
|
56
|
+
redriveStrandedPlacements: (
|
|
57
|
+
account: AccountItem,
|
|
58
|
+
now: number,
|
|
59
|
+
) => Promise<number>;
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
export interface SchedulerTickResult {
|
|
@@ -63,6 +67,8 @@ export interface SchedulerTickResult {
|
|
|
63
67
|
sweepFailed: number;
|
|
64
68
|
subscriptionsRefreshed: number;
|
|
65
69
|
subscriptionsFailed: number;
|
|
70
|
+
redriven: number;
|
|
71
|
+
redriveFailed: number;
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
/**
|
|
@@ -135,6 +141,8 @@ export const runSchedulerTick = async (
|
|
|
135
141
|
let skipped = 0;
|
|
136
142
|
let swept = 0;
|
|
137
143
|
let sweepFailed = 0;
|
|
144
|
+
let redriven = 0;
|
|
145
|
+
let redriveFailed = 0;
|
|
138
146
|
|
|
139
147
|
do {
|
|
140
148
|
const page = await accountService.listAllAccountsPage({
|
|
@@ -202,6 +210,32 @@ export const runSchedulerTick = async (
|
|
|
202
210
|
sweepFailed += outcomes.filter((ok) => !ok).length;
|
|
203
211
|
}
|
|
204
212
|
|
|
213
|
+
// Contained like the sweep, and for the same reason: a failed re-drive
|
|
214
|
+
// costs one account this tick, never the scheduler. Eligible accounts
|
|
215
|
+
// only, since a re-driven event needs a connection to settle anything.
|
|
216
|
+
const redrives = await pMap(
|
|
217
|
+
page.items.filter(isEligible),
|
|
218
|
+
(account) =>
|
|
219
|
+
deps.redriveStrandedPlacements(account, now).then(
|
|
220
|
+
(count) => count,
|
|
221
|
+
(error: unknown) => {
|
|
222
|
+
log.error(
|
|
223
|
+
{ accountId: account.accountId, error },
|
|
224
|
+
"Stranded placement re-drive failed for account",
|
|
225
|
+
);
|
|
226
|
+
return null;
|
|
227
|
+
},
|
|
228
|
+
),
|
|
229
|
+
{ concurrency: SCHEDULER_ENQUEUE_CONCURRENCY },
|
|
230
|
+
);
|
|
231
|
+
for (const count of redrives) {
|
|
232
|
+
if (count === null) {
|
|
233
|
+
redriveFailed += 1;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
redriven += count;
|
|
237
|
+
}
|
|
238
|
+
|
|
205
239
|
cursor = page.cursor ?? undefined;
|
|
206
240
|
} while (cursor);
|
|
207
241
|
|
|
@@ -231,6 +265,8 @@ export const runSchedulerTick = async (
|
|
|
231
265
|
sweepFailed,
|
|
232
266
|
subscriptionsRefreshed,
|
|
233
267
|
subscriptionsFailed,
|
|
268
|
+
redriven,
|
|
269
|
+
redriveFailed,
|
|
234
270
|
},
|
|
235
271
|
"Scheduled-sync tick complete",
|
|
236
272
|
);
|
|
@@ -243,5 +279,7 @@ export const runSchedulerTick = async (
|
|
|
243
279
|
sweepFailed,
|
|
244
280
|
subscriptionsRefreshed,
|
|
245
281
|
subscriptionsFailed,
|
|
282
|
+
redriven,
|
|
283
|
+
redriveFailed,
|
|
246
284
|
};
|
|
247
285
|
};
|
package/src/scheduler/runner.ts
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
import { finishHalfDeletedAccounts } from "@remit/backend/account-deletion";
|
|
3
4
|
import { getClient } from "@remit/backend/client";
|
|
4
|
-
import { createLogger } from "@remit/logger
|
|
5
|
+
import { createLogger } from "@remit/logger";
|
|
6
|
+
import { installNodeLogger } from "@remit/logger-lambda/node";
|
|
5
7
|
import { clearHeartbeats, createHeartbeat } from "@remit/sqs-client/heartbeat";
|
|
6
8
|
import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
7
9
|
|
|
8
10
|
import { env } from "expect-env";
|
|
11
|
+
import { emitEvent } from "../emit.js";
|
|
9
12
|
import { buildAttachmentSweep } from "./attachment-sweep.js";
|
|
10
13
|
import { buildCalendarSubscriptionRefresh } from "./calendar-subscriptions.js";
|
|
11
14
|
import { getOfflineIntervalMs, getTickIntervalMs } from "./config.js";
|
|
12
15
|
import { runSchedulerLoop } from "./loop.js";
|
|
13
16
|
import { runSchedulerTick } from "./run-tick.js";
|
|
17
|
+
import { redriveStrandedPlacements } from "./stranded-placement-redrive.js";
|
|
18
|
+
|
|
19
|
+
installNodeLogger();
|
|
14
20
|
|
|
15
21
|
/**
|
|
16
22
|
* The scheduled-sync runner for every deployment that has no EventBridge: the
|
|
@@ -36,6 +42,7 @@ const log = createLogger();
|
|
|
36
42
|
|
|
37
43
|
const mailboxesQueueUrl = env.SQS_QUEUE_URL_MAILBOXES;
|
|
38
44
|
const sqsClient = createQueueProducer({ queueUrl: mailboxesQueueUrl });
|
|
45
|
+
const accountFanoutQueueUrl = env.SQS_QUEUE_URL_ACCOUNT_FANOUT;
|
|
39
46
|
|
|
40
47
|
const tickIntervalMs = getTickIntervalMs();
|
|
41
48
|
const offlineIntervalMs = getOfflineIntervalMs();
|
|
@@ -71,6 +78,13 @@ const runLoop = async (): Promise<void> => {
|
|
|
71
78
|
await clearHeartbeats().catch(onClearError);
|
|
72
79
|
const client = await getClient();
|
|
73
80
|
const { account } = client;
|
|
81
|
+
await finishHalfDeletedAccounts({
|
|
82
|
+
account,
|
|
83
|
+
configImport: client.configImport,
|
|
84
|
+
sqsClient: createQueueProducer({ queueUrl: accountFanoutQueueUrl }),
|
|
85
|
+
accountFanoutQueueUrl,
|
|
86
|
+
log,
|
|
87
|
+
});
|
|
74
88
|
await runSchedulerLoop({
|
|
75
89
|
tick: runSchedulerTick,
|
|
76
90
|
tickDeps: {
|
|
@@ -85,6 +99,12 @@ const runLoop = async (): Promise<void> => {
|
|
|
85
99
|
client,
|
|
86
100
|
log,
|
|
87
101
|
),
|
|
102
|
+
redriveStrandedPlacements: (stranded, now) =>
|
|
103
|
+
redriveStrandedPlacements(
|
|
104
|
+
{ client, emit: emitEvent, log },
|
|
105
|
+
stranded,
|
|
106
|
+
now,
|
|
107
|
+
),
|
|
88
108
|
},
|
|
89
109
|
heartbeat: createHeartbeat("tick"),
|
|
90
110
|
onHeartbeatError: onBeatError,
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import type { RemitClient } from "@remit/backend/client";
|
|
2
|
+
import type { AccountItem, MessageItem } from "@remit/data-ports";
|
|
3
|
+
import { trashMailboxAt } from "@remit/data-ports/folder-role";
|
|
4
|
+
import {
|
|
5
|
+
deriveCopyMessageId,
|
|
6
|
+
deriveMessageId,
|
|
7
|
+
isValidMessageId,
|
|
8
|
+
} from "@remit/data-ports/id";
|
|
9
|
+
import { PATHLESS_MUTATION_EVENT_SCHEMA_VERSION } from "@remit/data-ports/mutation-events";
|
|
10
|
+
import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
|
|
11
|
+
import type { Logger } from "@remit/logger";
|
|
12
|
+
import { isCursorRebuildNeeded } from "@remit/mailbox-service";
|
|
13
|
+
import type {
|
|
14
|
+
MessageCopyEventV3,
|
|
15
|
+
MessageDeleteEventV3,
|
|
16
|
+
MessageMoveEventV3,
|
|
17
|
+
PlacementMovePushEvent,
|
|
18
|
+
} from "../events.js";
|
|
19
|
+
|
|
20
|
+
/** Well past three attempts on a 60 s visibility timeout. */
|
|
21
|
+
export const STRANDED_PLACEMENT_AFTER_MILLIS = 60 * 60 * 1000;
|
|
22
|
+
|
|
23
|
+
type Unstamped<T> = Omit<T, "eventId" | "timestamp">;
|
|
24
|
+
|
|
25
|
+
export type StrandedPlacementEvent =
|
|
26
|
+
| Unstamped<PlacementMovePushEvent>
|
|
27
|
+
| Unstamped<MessageDeleteEventV3>
|
|
28
|
+
| Unstamped<MessageMoveEventV3>
|
|
29
|
+
| Unstamped<MessageCopyEventV3>;
|
|
30
|
+
|
|
31
|
+
export interface StrandedPlacementRedriveDeps {
|
|
32
|
+
client: Pick<
|
|
33
|
+
RemitClient,
|
|
34
|
+
| "mailbox"
|
|
35
|
+
| "mailboxSpecialUse"
|
|
36
|
+
| "message"
|
|
37
|
+
| "placementMove"
|
|
38
|
+
| "threadMessage"
|
|
39
|
+
>;
|
|
40
|
+
emit: (event: StrandedPlacementEvent) => Promise<void>;
|
|
41
|
+
log: Logger;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const isStrandedCopy = (row: MessageItem): boolean =>
|
|
45
|
+
row.status === MessageStatus.moving &&
|
|
46
|
+
row.uid === 0 &&
|
|
47
|
+
typeof row.originalUid !== "number";
|
|
48
|
+
|
|
49
|
+
type CopyRedrive =
|
|
50
|
+
| { kind: "redrive"; event: Unstamped<MessageCopyEventV3> }
|
|
51
|
+
| { kind: "unresolvable"; reason: string }
|
|
52
|
+
| { kind: "wait" };
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The message a uid-0 copy was made from: its id derives from the Message-ID
|
|
56
|
+
* the copy's listing row carries, and the copy's own id derives from it.
|
|
57
|
+
*/
|
|
58
|
+
const copyRedriveFor = async (
|
|
59
|
+
client: StrandedPlacementRedriveDeps["client"],
|
|
60
|
+
account: AccountItem,
|
|
61
|
+
copy: MessageItem,
|
|
62
|
+
): Promise<CopyRedrive> => {
|
|
63
|
+
const listing = await client.threadMessage.findByMessageId(
|
|
64
|
+
account.accountConfigId,
|
|
65
|
+
copy.messageId,
|
|
66
|
+
);
|
|
67
|
+
if (!listing) return { kind: "unresolvable", reason: "listing row missing" };
|
|
68
|
+
const header = listing.messageIdHeader;
|
|
69
|
+
if (!header || !isValidMessageId(header)) {
|
|
70
|
+
return { kind: "unresolvable", reason: "listing row has no Message-ID" };
|
|
71
|
+
}
|
|
72
|
+
const sourceId = deriveMessageId(account.accountId, header);
|
|
73
|
+
if (deriveCopyMessageId(sourceId, copy.mailboxId) !== copy.messageId) {
|
|
74
|
+
return {
|
|
75
|
+
kind: "unresolvable",
|
|
76
|
+
reason: "copy was not made from the message its Message-ID names",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const [source] = await client.message.get([sourceId]);
|
|
80
|
+
if (!source) return { kind: "unresolvable", reason: "source row missing" };
|
|
81
|
+
if (source.status === MessageStatus.deleted) {
|
|
82
|
+
return { kind: "unresolvable", reason: "source row deleted" };
|
|
83
|
+
}
|
|
84
|
+
if (source.status !== MessageStatus.active) return { kind: "wait" };
|
|
85
|
+
return {
|
|
86
|
+
kind: "redrive",
|
|
87
|
+
event: {
|
|
88
|
+
type: "MESSAGE_COPY",
|
|
89
|
+
schemaVersion: PATHLESS_MUTATION_EVENT_SCHEMA_VERSION,
|
|
90
|
+
accountId: account.accountId,
|
|
91
|
+
sourceMessageId: source.messageId,
|
|
92
|
+
newMessageId: copy.messageId,
|
|
93
|
+
sourceMailboxId: source.mailboxId,
|
|
94
|
+
destinationMailboxId: copy.mailboxId,
|
|
95
|
+
uid: source.uid,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** A copy with no source to copy from has nothing to bind and nothing to hide. */
|
|
101
|
+
const removeUnresolvableCopy = async (
|
|
102
|
+
deps: StrandedPlacementRedriveDeps,
|
|
103
|
+
account: AccountItem,
|
|
104
|
+
row: MessageItem,
|
|
105
|
+
reason: string,
|
|
106
|
+
now: number,
|
|
107
|
+
): Promise<boolean> => {
|
|
108
|
+
const removed = await deps.client.message.deleteIfPlacement(row.messageId, {
|
|
109
|
+
status: MessageStatus.moving,
|
|
110
|
+
mailboxId: row.mailboxId,
|
|
111
|
+
uid: 0,
|
|
112
|
+
});
|
|
113
|
+
if (!removed) return false;
|
|
114
|
+
deps.log.error(
|
|
115
|
+
{
|
|
116
|
+
alert: "stranded_copy_unresolvable",
|
|
117
|
+
accountId: account.accountId,
|
|
118
|
+
messageId: row.messageId,
|
|
119
|
+
mailboxId: row.mailboxId,
|
|
120
|
+
reason,
|
|
121
|
+
untouchedForMs: now - row.updatedAt,
|
|
122
|
+
},
|
|
123
|
+
"A copy stranded at uid 0 has no source to copy from; the copy row was removed",
|
|
124
|
+
);
|
|
125
|
+
return true;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const eventFor = async (
|
|
129
|
+
client: StrandedPlacementRedriveDeps["client"],
|
|
130
|
+
account: AccountItem,
|
|
131
|
+
row: MessageItem,
|
|
132
|
+
trashMailboxId: string | undefined,
|
|
133
|
+
): Promise<StrandedPlacementEvent | undefined> => {
|
|
134
|
+
const { accountId, accountConfigId } = account;
|
|
135
|
+
const { messageId } = row;
|
|
136
|
+
|
|
137
|
+
if (row.status === MessageStatus.deleting) {
|
|
138
|
+
return {
|
|
139
|
+
type: "MESSAGE_DELETE",
|
|
140
|
+
schemaVersion: PATHLESS_MUTATION_EVENT_SCHEMA_VERSION,
|
|
141
|
+
accountId,
|
|
142
|
+
messageId,
|
|
143
|
+
mailboxId: row.mailboxId,
|
|
144
|
+
uid: row.uid,
|
|
145
|
+
operation: "permanent_delete",
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (
|
|
149
|
+
typeof row.originalMailboxId !== "string" ||
|
|
150
|
+
typeof row.originalUid !== "number"
|
|
151
|
+
) {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const marker = await client.placementMove.find(messageId);
|
|
156
|
+
if (marker && marker.destinationMailboxId === row.mailboxId) {
|
|
157
|
+
return {
|
|
158
|
+
type: "PLACEMENT_MOVE_PUSH",
|
|
159
|
+
accountId,
|
|
160
|
+
accountConfigId,
|
|
161
|
+
messageId,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (row.mailboxId === trashMailboxId) {
|
|
165
|
+
return {
|
|
166
|
+
type: "MESSAGE_DELETE",
|
|
167
|
+
schemaVersion: PATHLESS_MUTATION_EVENT_SCHEMA_VERSION,
|
|
168
|
+
accountId,
|
|
169
|
+
messageId,
|
|
170
|
+
mailboxId: row.originalMailboxId,
|
|
171
|
+
uid: row.originalUid,
|
|
172
|
+
operation: "move_to_trash",
|
|
173
|
+
destinationMailboxId: row.mailboxId,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
type: "MESSAGE_MOVE",
|
|
178
|
+
schemaVersion: PATHLESS_MUTATION_EVENT_SCHEMA_VERSION,
|
|
179
|
+
accountId,
|
|
180
|
+
messageId,
|
|
181
|
+
sourceMailboxId: row.originalMailboxId,
|
|
182
|
+
destinationMailboxId: row.mailboxId,
|
|
183
|
+
uid: row.originalUid,
|
|
184
|
+
};
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/** The claim restarts the row's clock; a folder awaiting a cursor rebuild waits until it is normal. */
|
|
188
|
+
export const redriveStrandedPlacements = async (
|
|
189
|
+
deps: StrandedPlacementRedriveDeps,
|
|
190
|
+
account: AccountItem,
|
|
191
|
+
now: number,
|
|
192
|
+
): Promise<number> => {
|
|
193
|
+
const { client, emit, log } = deps;
|
|
194
|
+
const { accountId } = account;
|
|
195
|
+
|
|
196
|
+
const mailboxes = await client.mailbox.listAllByAccount(accountId);
|
|
197
|
+
const paused = new Set(
|
|
198
|
+
mailboxes
|
|
199
|
+
.filter((mailbox) => isCursorRebuildNeeded(mailbox.cursorState))
|
|
200
|
+
.map((mailbox) => mailbox.mailboxId),
|
|
201
|
+
);
|
|
202
|
+
const stranded = (
|
|
203
|
+
await client.message.listStrandedPlacements(
|
|
204
|
+
mailboxes.map((mailbox) => mailbox.mailboxId),
|
|
205
|
+
now - STRANDED_PLACEMENT_AFTER_MILLIS,
|
|
206
|
+
)
|
|
207
|
+
).filter(
|
|
208
|
+
(row) =>
|
|
209
|
+
!paused.has(row.mailboxId) &&
|
|
210
|
+
!(row.originalMailboxId && paused.has(row.originalMailboxId)),
|
|
211
|
+
);
|
|
212
|
+
if (stranded.length === 0) return 0;
|
|
213
|
+
|
|
214
|
+
const trash = trashMailboxAt(
|
|
215
|
+
await client.mailboxSpecialUse.resolveTrashRole(accountId),
|
|
216
|
+
"resolved",
|
|
217
|
+
);
|
|
218
|
+
const trashMailboxId = trash.allowed ? trash.mailbox.mailboxId : undefined;
|
|
219
|
+
|
|
220
|
+
let redriven = 0;
|
|
221
|
+
for (const row of stranded) {
|
|
222
|
+
const copy = isStrandedCopy(row)
|
|
223
|
+
? await copyRedriveFor(client, account, row)
|
|
224
|
+
: undefined;
|
|
225
|
+
if (copy?.kind === "wait") continue;
|
|
226
|
+
if (copy?.kind === "unresolvable") {
|
|
227
|
+
if (await removeUnresolvableCopy(deps, account, row, copy.reason, now)) {
|
|
228
|
+
redriven += 1;
|
|
229
|
+
}
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const event =
|
|
233
|
+
copy?.kind === "redrive"
|
|
234
|
+
? copy.event
|
|
235
|
+
: await eventFor(client, account, row, trashMailboxId);
|
|
236
|
+
if (!event) continue;
|
|
237
|
+
|
|
238
|
+
// A copy's earlier COPY may have landed with its answer lost, so the
|
|
239
|
+
// re-drive marks it `failed`: the copy handler then asks the destination
|
|
240
|
+
// before it copies again.
|
|
241
|
+
const claimed = await client.message.transitionPlacement(
|
|
242
|
+
row.messageId,
|
|
243
|
+
{ status: row.status, mailboxId: row.mailboxId, uid: row.uid },
|
|
244
|
+
{
|
|
245
|
+
syncStatus: copy ? MessageSyncStatus.failed : MessageSyncStatus.pending,
|
|
246
|
+
},
|
|
247
|
+
);
|
|
248
|
+
if (!claimed) continue;
|
|
249
|
+
|
|
250
|
+
await emit(event);
|
|
251
|
+
redriven += 1;
|
|
252
|
+
log.warn(
|
|
253
|
+
{
|
|
254
|
+
metric: "stranded_placement_redriven",
|
|
255
|
+
accountId,
|
|
256
|
+
messageId: row.messageId,
|
|
257
|
+
event: event.type,
|
|
258
|
+
syncStatus: row.syncStatus,
|
|
259
|
+
untouchedForMs: now - row.updatedAt,
|
|
260
|
+
},
|
|
261
|
+
"A move or delete had no event left to settle it; re-driven",
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
return redriven;
|
|
265
|
+
};
|