@remit/imap-worker 0.0.38 → 0.0.39
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 +1 -1
- package/src/handlers/append-sent-message.test.ts +16 -0
- package/src/handlers/append-sent-message.ts +14 -0
- package/src/scheduler/attachment-sweep.test.ts +168 -0
- package/src/scheduler/attachment-sweep.ts +46 -0
- package/src/scheduler/handler.ts +7 -2
- package/src/scheduler/loop.test.ts +7 -1
- package/src/scheduler/run-tick.test.ts +87 -0
- package/src/scheduler/run-tick.ts +56 -2
- package/src/scheduler/runner.ts +5 -1
package/package.json
CHANGED
|
@@ -89,6 +89,9 @@ const deps = (): AppendSentMessageDeps =>
|
|
|
89
89
|
get: async () => h.outbox,
|
|
90
90
|
delete: record("outboxMessage.delete"),
|
|
91
91
|
},
|
|
92
|
+
outboxAttachment: {
|
|
93
|
+
discardAll: record("outboxAttachment.discardAll"),
|
|
94
|
+
},
|
|
92
95
|
mailboxSpecialUse: {
|
|
93
96
|
findBySpecialUse: async () => h.specialUseSent,
|
|
94
97
|
},
|
|
@@ -142,6 +145,19 @@ describe("handleAppendSentMessage", () => {
|
|
|
142
145
|
assert.equal(h.disconnectCount, 1);
|
|
143
146
|
});
|
|
144
147
|
|
|
148
|
+
it("takes the draft's stored attachments with the row (#679)", async () => {
|
|
149
|
+
await handleAppendSentMessage(event, noopLog, deps());
|
|
150
|
+
|
|
151
|
+
// The row is the only reference to those objects. Sweeping has to happen
|
|
152
|
+
// here too, not only on a discard, or a sent message leaves its files
|
|
153
|
+
// behind with nothing able to reach them.
|
|
154
|
+
assert.deepEqual(called("outboxAttachment.discardAll")[0]?.args, [
|
|
155
|
+
"cfg-1",
|
|
156
|
+
"acc-1",
|
|
157
|
+
"out-1",
|
|
158
|
+
]);
|
|
159
|
+
});
|
|
160
|
+
|
|
145
161
|
it("builds the message from the outbox row's own headers", async () => {
|
|
146
162
|
await handleAppendSentMessage(event, noopLog, deps());
|
|
147
163
|
|
|
@@ -77,6 +77,7 @@ export const handleAppendSentMessage = async (
|
|
|
77
77
|
const {
|
|
78
78
|
account: accountService,
|
|
79
79
|
outboxMessage: outboxMessageService,
|
|
80
|
+
outboxAttachment: outboxAttachmentService,
|
|
80
81
|
mailboxSpecialUse: mailboxSpecialUseService,
|
|
81
82
|
mailbox: mailboxService,
|
|
82
83
|
secrets,
|
|
@@ -145,10 +146,23 @@ export const handleAppendSentMessage = async (
|
|
|
145
146
|
|
|
146
147
|
// The message now lives in the IMAP Sent folder. Drop the outbox row so
|
|
147
148
|
// the user does not see it twice in the UI (Outbox + Sent). Issue #178.
|
|
149
|
+
//
|
|
150
|
+
// The row goes first, and nothing that can fail may come before it. The
|
|
151
|
+
// APPEND above has already happened; anything between it and this delete
|
|
152
|
+
// that throws leaves the row for the job to retry, and the retry appends
|
|
153
|
+
// a second copy to the user's Sent folder. A storage error is not worth
|
|
154
|
+
// a duplicate message — the attachment objects that outlive their row
|
|
155
|
+
// are exactly what the sweep collects, so losing this delete costs a
|
|
156
|
+
// sweep and nothing else.
|
|
148
157
|
await outboxMessageService.delete(
|
|
149
158
|
account.accountConfigId,
|
|
150
159
|
outboxMessageId,
|
|
151
160
|
);
|
|
161
|
+
await outboxAttachmentService.discardAll(
|
|
162
|
+
account.accountConfigId,
|
|
163
|
+
accountId,
|
|
164
|
+
outboxMessageId,
|
|
165
|
+
);
|
|
152
166
|
log.info(
|
|
153
167
|
{ outboxMessageId },
|
|
154
168
|
"Deleted outbox row after successful APPEND to Sent",
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Both scheduler entry points must hand the tick a sweep.
|
|
3
|
+
*
|
|
4
|
+
* Only the hosted one strictly needs it — a browser PUTs straight to block
|
|
5
|
+
* storage there, so nothing is in the path to refuse an upload against a draft
|
|
6
|
+
* that is already gone — but a deployment that quietly stops collecting is
|
|
7
|
+
* indistinguishable from one that has nothing to collect. Deleting either wiring
|
|
8
|
+
* used to leave the suite green.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { describe, it } from "node:test";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import type { AccountItem } from "@remit/data-ports";
|
|
17
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
18
|
+
import type { StorageService } from "@remit/storage-service";
|
|
19
|
+
import { buildAttachmentSweep } from "./attachment-sweep.js";
|
|
20
|
+
|
|
21
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const TICK_TIME = 1_700_000_000_000;
|
|
23
|
+
|
|
24
|
+
const silentLogger = (): Logger => {
|
|
25
|
+
const noop = () => {};
|
|
26
|
+
const log = {
|
|
27
|
+
info: noop,
|
|
28
|
+
warn: noop,
|
|
29
|
+
error: noop,
|
|
30
|
+
debug: noop,
|
|
31
|
+
fatal: noop,
|
|
32
|
+
trace: noop,
|
|
33
|
+
child: () => log,
|
|
34
|
+
} as unknown as Logger;
|
|
35
|
+
return log;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Enough of an account for the tick's eligibility checks to run. */
|
|
39
|
+
const eligibleAccount = (accountId: string) =>
|
|
40
|
+
({
|
|
41
|
+
accountId,
|
|
42
|
+
accountConfigId: "cfg-1",
|
|
43
|
+
email: `${accountId}@example.com`,
|
|
44
|
+
username: accountId,
|
|
45
|
+
authType: "password",
|
|
46
|
+
imapHost: "imap.example.com",
|
|
47
|
+
imapPort: 993,
|
|
48
|
+
imapTls: true,
|
|
49
|
+
imapStartTls: false,
|
|
50
|
+
isActive: true,
|
|
51
|
+
connectionState: "authenticated",
|
|
52
|
+
// Freshly synced, so nothing is due and no SQS send is attempted. The
|
|
53
|
+
// sweep must still run: abandoned bytes have nothing to do with how
|
|
54
|
+
// recently mail was fetched.
|
|
55
|
+
lastSyncAt: TICK_TIME,
|
|
56
|
+
createdAt: 0,
|
|
57
|
+
updatedAt: 0,
|
|
58
|
+
}) as unknown as AccountItem;
|
|
59
|
+
|
|
60
|
+
describe("the hosted tick sweeps", () => {
|
|
61
|
+
it("runs the sweep for every account, due a sync or not", async () => {
|
|
62
|
+
process.env.SQS_QUEUE_URL_MAILBOXES = "https://queue.test/mailboxes";
|
|
63
|
+
|
|
64
|
+
const swept: string[] = [];
|
|
65
|
+
const { setClient, _resetForTest } = await import("@remit/backend/client");
|
|
66
|
+
setClient({
|
|
67
|
+
account: {
|
|
68
|
+
listAllAccountsPage: async () => ({
|
|
69
|
+
items: [eligibleAccount("acc-1"), eligibleAccount("acc-2")],
|
|
70
|
+
cursor: null,
|
|
71
|
+
}),
|
|
72
|
+
},
|
|
73
|
+
storage: {
|
|
74
|
+
listOutboxDraftsWithAttachments: async (
|
|
75
|
+
_cfg: string,
|
|
76
|
+
accountId: string,
|
|
77
|
+
) => {
|
|
78
|
+
swept.push(accountId);
|
|
79
|
+
return [];
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
outboxAttachment: { reapAndListLive: async () => [] },
|
|
83
|
+
} as never);
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const { handler } = await import("./handler.js");
|
|
87
|
+
await handler(
|
|
88
|
+
{ time: new Date(TICK_TIME).toISOString() } as never,
|
|
89
|
+
{} as never,
|
|
90
|
+
() => {},
|
|
91
|
+
);
|
|
92
|
+
assert.deepEqual(swept.sort(), ["acc-1", "acc-2"]);
|
|
93
|
+
} finally {
|
|
94
|
+
_resetForTest();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("the self-host runner wires the sweep", () => {
|
|
100
|
+
it("hands runSchedulerTick a sweep", () => {
|
|
101
|
+
// runner.ts starts its loop on import, so it cannot be invoked from a test
|
|
102
|
+
// the way handler.ts can. This guards the wiring itself: without it,
|
|
103
|
+
// deleting the line leaves the suite green and self-host silently stops
|
|
104
|
+
// collecting.
|
|
105
|
+
const source = readFileSync(join(here, "runner.ts"), "utf8");
|
|
106
|
+
assert.match(source, /sweepAttachments:\s*buildAttachmentSweep\(/);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("buildAttachmentSweep", () => {
|
|
111
|
+
const account = {
|
|
112
|
+
accountId: "acc-1",
|
|
113
|
+
accountConfigId: "cfg-1",
|
|
114
|
+
} as unknown as AccountItem;
|
|
115
|
+
|
|
116
|
+
it("collects the objects the database does not name", async () => {
|
|
117
|
+
const deleted: string[] = [];
|
|
118
|
+
const storage = {
|
|
119
|
+
listOutboxDraftsWithAttachments: async () => ["draft-1"],
|
|
120
|
+
listOutboxAttachments: async () => [
|
|
121
|
+
{ outboxAttachmentId: "known", key: "k1", sizeBytes: 1 },
|
|
122
|
+
{ outboxAttachmentId: "orphan", key: "k2", sizeBytes: 1 },
|
|
123
|
+
],
|
|
124
|
+
deleteOutboxAttachment: async (
|
|
125
|
+
_cfg: string,
|
|
126
|
+
_acc: string,
|
|
127
|
+
_draft: string,
|
|
128
|
+
id: string,
|
|
129
|
+
) => {
|
|
130
|
+
deleted.push(id);
|
|
131
|
+
},
|
|
132
|
+
} as unknown as StorageService;
|
|
133
|
+
|
|
134
|
+
await buildAttachmentSweep(
|
|
135
|
+
{
|
|
136
|
+
storage,
|
|
137
|
+
outboxAttachment: {
|
|
138
|
+
reapAndListLive: async () => ["known"],
|
|
139
|
+
},
|
|
140
|
+
} as never,
|
|
141
|
+
silentLogger(),
|
|
142
|
+
)(account);
|
|
143
|
+
|
|
144
|
+
assert.deepEqual(deleted, ["orphan"]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("lets a storage failure out, so the tick can count it", async () => {
|
|
148
|
+
// Containment belongs to the tick, which keeps enqueuing mail regardless.
|
|
149
|
+
// Swallowing here would make a broken sweep look like a working one.
|
|
150
|
+
const storage = {
|
|
151
|
+
listOutboxDraftsWithAttachments: async () => {
|
|
152
|
+
throw new Error("storage root does not exist");
|
|
153
|
+
},
|
|
154
|
+
} as unknown as StorageService;
|
|
155
|
+
|
|
156
|
+
await assert.rejects(
|
|
157
|
+
() =>
|
|
158
|
+
buildAttachmentSweep(
|
|
159
|
+
{
|
|
160
|
+
storage,
|
|
161
|
+
outboxAttachment: { reapAndListLive: async () => [] },
|
|
162
|
+
} as never,
|
|
163
|
+
silentLogger(),
|
|
164
|
+
)(account),
|
|
165
|
+
/storage root does not exist/,
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { RemitClient } from "@remit/backend/client";
|
|
2
|
+
import type { AccountItem } from "@remit/data-ports";
|
|
3
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
4
|
+
import { sweepAbandonedOutboxAttachments } from "@remit/storage-service";
|
|
5
|
+
/**
|
|
6
|
+
* Collect attachment bytes the database does not know about. Both scheduler
|
|
7
|
+
* entry points build it the same way, so the hosted tick and the self-host loop
|
|
8
|
+
* collect identically — this is the only defence on the hosted side, where the
|
|
9
|
+
* browser PUTs straight to block storage and nothing consults the database on
|
|
10
|
+
* the way in.
|
|
11
|
+
*/
|
|
12
|
+
export const buildAttachmentSweep =
|
|
13
|
+
(
|
|
14
|
+
client: Pick<RemitClient, "storage" | "outboxAttachment">,
|
|
15
|
+
log: Logger,
|
|
16
|
+
): ((account: AccountItem) => Promise<void>) =>
|
|
17
|
+
async (account) => {
|
|
18
|
+
const { deleted, skipped } = await sweepAbandonedOutboxAttachments(
|
|
19
|
+
{
|
|
20
|
+
storage: client.storage,
|
|
21
|
+
// Reaps the draft's lapsed reservations first, so a row that has
|
|
22
|
+
// stopped holding room stops vouching for its bytes too and the
|
|
23
|
+
// object is collected on this same pass.
|
|
24
|
+
listKnownAttachmentIds: (accountConfigId, outboxMessageId) =>
|
|
25
|
+
client.outboxAttachment.reapAndListLive(
|
|
26
|
+
accountConfigId,
|
|
27
|
+
outboxMessageId,
|
|
28
|
+
),
|
|
29
|
+
onSkipped: (outboxMessageId, error) => {
|
|
30
|
+
log.error(
|
|
31
|
+
{ outboxMessageId, error },
|
|
32
|
+
"Left a draft's attachment objects alone: its rows could not be read",
|
|
33
|
+
);
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
account.accountConfigId,
|
|
37
|
+
account.accountId,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
if (deleted > 0 || skipped > 0) {
|
|
41
|
+
log.warn(
|
|
42
|
+
{ accountId: account.accountId, deleted, skipped },
|
|
43
|
+
"Outbox attachment sweep",
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
};
|
package/src/scheduler/handler.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { createLogger, withTelemetry } from "@remit/logger-lambda";
|
|
|
3
3
|
import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
4
4
|
import type { ScheduledHandler } from "aws-lambda";
|
|
5
5
|
import { env } from "expect-env";
|
|
6
|
+
import { buildAttachmentSweep } from "./attachment-sweep.js";
|
|
6
7
|
import { getOfflineIntervalMs, getTickIntervalMs } from "./config.js";
|
|
7
8
|
import { runSchedulerTick } from "./run-tick.js";
|
|
8
9
|
|
|
@@ -29,16 +30,20 @@ const sqsClient = createQueueProducer({ queueUrl: mailboxesQueueUrl });
|
|
|
29
30
|
* correctly dedupes.
|
|
30
31
|
*/
|
|
31
32
|
export const handler: ScheduledHandler = withTelemetry(async (event) => {
|
|
32
|
-
const
|
|
33
|
+
const client = await getClient();
|
|
33
34
|
const now = Date.parse(event.time);
|
|
34
35
|
|
|
35
36
|
await runSchedulerTick({
|
|
36
|
-
accountService: account,
|
|
37
|
+
accountService: client.account,
|
|
37
38
|
sqsClient,
|
|
38
39
|
queueUrl: mailboxesQueueUrl,
|
|
39
40
|
log,
|
|
40
41
|
tickIntervalMs: getTickIntervalMs(),
|
|
41
42
|
offlineIntervalMs: getOfflineIntervalMs(),
|
|
42
43
|
now,
|
|
44
|
+
// The hosted side is the one that needs this: a browser PUTs straight
|
|
45
|
+
// to block storage, so nothing refuses an upload against a draft that is
|
|
46
|
+
// already gone and this sweep is the only thing that collects it.
|
|
47
|
+
sweepAttachments: buildAttachmentSweep(client, log),
|
|
43
48
|
});
|
|
44
49
|
});
|
|
@@ -3,7 +3,13 @@ import { describe, it } from "node:test";
|
|
|
3
3
|
import { runSchedulerLoop, type SchedulerLoopOptions } from "./loop.js";
|
|
4
4
|
import type { RunSchedulerTickDeps, SchedulerTickResult } from "./run-tick.js";
|
|
5
5
|
|
|
6
|
-
const RESULT: SchedulerTickResult = {
|
|
6
|
+
const RESULT: SchedulerTickResult = {
|
|
7
|
+
scanned: 0,
|
|
8
|
+
enqueued: 0,
|
|
9
|
+
skipped: 0,
|
|
10
|
+
swept: 0,
|
|
11
|
+
sweepFailed: 0,
|
|
12
|
+
};
|
|
7
13
|
|
|
8
14
|
// Only the loop's own two calls matter here; the tick is a stub, so what it is
|
|
9
15
|
// handed is never read.
|
|
@@ -246,3 +246,90 @@ describe("runSchedulerTick", () => {
|
|
|
246
246
|
assert.equal(result.enqueued, 0);
|
|
247
247
|
});
|
|
248
248
|
});
|
|
249
|
+
|
|
250
|
+
describe("the attachment sweep inside a tick", () => {
|
|
251
|
+
const twoAccounts = () => [
|
|
252
|
+
{
|
|
253
|
+
items: [
|
|
254
|
+
baseAccount({ accountId: "acct_1", lastSyncAt: NOW - 1_000 }),
|
|
255
|
+
baseAccount({ accountId: "acct_2", lastSyncAt: 0 }),
|
|
256
|
+
],
|
|
257
|
+
cursor: null,
|
|
258
|
+
} as unknown as AccountSchedulerPage,
|
|
259
|
+
];
|
|
260
|
+
|
|
261
|
+
it("sweeps every account, not only the ones due a sync", async () => {
|
|
262
|
+
const { sqsClient } = fakeSqsClient();
|
|
263
|
+
const swept: string[] = [];
|
|
264
|
+
|
|
265
|
+
const result = await runSchedulerTick({
|
|
266
|
+
accountService: fakeAccountService(twoAccounts()),
|
|
267
|
+
sqsClient,
|
|
268
|
+
queueUrl: "https://queue.test/mailboxes",
|
|
269
|
+
log: createNoopLogger(),
|
|
270
|
+
tickIntervalMs: TICK_INTERVAL_MS,
|
|
271
|
+
offlineIntervalMs: OFFLINE_INTERVAL_MS,
|
|
272
|
+
now: NOW,
|
|
273
|
+
sweepAttachments: async (account) => {
|
|
274
|
+
swept.push(account.accountId);
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
assert.deepEqual(swept.sort(), ["acct_1", "acct_2"]);
|
|
279
|
+
assert.equal(result.swept, 2);
|
|
280
|
+
assert.equal(result.sweepFailed, 0);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("keeps enqueuing mail when the sweep throws", async () => {
|
|
284
|
+
// The sweep is housekeeping; enqueuing mail is not. Uncontained, one
|
|
285
|
+
// storage error here reaches the loop, which exits, and the container
|
|
286
|
+
// restarts every few seconds without ever enqueuing an account again.
|
|
287
|
+
const { sqsClient, sent } = fakeSqsClient();
|
|
288
|
+
const { log, calls } = createCapturingLogger();
|
|
289
|
+
|
|
290
|
+
const result = await runSchedulerTick({
|
|
291
|
+
accountService: fakeAccountService(twoAccounts()),
|
|
292
|
+
sqsClient,
|
|
293
|
+
queueUrl: "https://queue.test/mailboxes",
|
|
294
|
+
log,
|
|
295
|
+
tickIntervalMs: TICK_INTERVAL_MS,
|
|
296
|
+
offlineIntervalMs: OFFLINE_INTERVAL_MS,
|
|
297
|
+
now: NOW,
|
|
298
|
+
sweepAttachments: async () => {
|
|
299
|
+
throw new Error("AccessDenied on ListObjectsV2");
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
assert.equal(result.sweepFailed, 2);
|
|
304
|
+
assert.equal(result.swept, 0);
|
|
305
|
+
// The tick still did its actual job.
|
|
306
|
+
assert.ok(sent.length > 0);
|
|
307
|
+
assert.equal(result.enqueued, sent.length);
|
|
308
|
+
assert.ok(
|
|
309
|
+
calls.some(
|
|
310
|
+
(entry) =>
|
|
311
|
+
entry.level === "error" &&
|
|
312
|
+
entry.args.some((arg) =>
|
|
313
|
+
String(arg).includes("Outbox attachment sweep failed"),
|
|
314
|
+
),
|
|
315
|
+
),
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("ticks without a sweep at all", async () => {
|
|
320
|
+
const { sqsClient } = fakeSqsClient();
|
|
321
|
+
|
|
322
|
+
const result = await runSchedulerTick({
|
|
323
|
+
accountService: fakeAccountService(twoAccounts()),
|
|
324
|
+
sqsClient,
|
|
325
|
+
queueUrl: "https://queue.test/mailboxes",
|
|
326
|
+
log: createNoopLogger(),
|
|
327
|
+
tickIntervalMs: TICK_INTERVAL_MS,
|
|
328
|
+
offlineIntervalMs: OFFLINE_INTERVAL_MS,
|
|
329
|
+
now: NOW,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
assert.equal(result.swept, 0);
|
|
333
|
+
assert.equal(result.sweepFailed, 0);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
@@ -30,12 +30,25 @@ export interface RunSchedulerTickDeps {
|
|
|
30
30
|
tickIntervalMs: number;
|
|
31
31
|
/** Injectable for tests; defaults to `Date.now()`. */
|
|
32
32
|
now?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Collect attachment bytes the database does not know about, for one account.
|
|
35
|
+
*
|
|
36
|
+
* The tick already walks every account on a cadence, which is the only thing
|
|
37
|
+
* this needs. It exists because an upload URL minted for a draft stays valid
|
|
38
|
+
* after that draft is discarded, and where the browser writes straight to
|
|
39
|
+
* block storage nothing is in the path to refuse it — see
|
|
40
|
+
* `sweepAbandonedOutboxAttachments`. Optional only so a test can leave it out;
|
|
41
|
+
* both real entry points pass it.
|
|
42
|
+
*/
|
|
43
|
+
sweepAttachments?: (account: AccountItem) => Promise<void>;
|
|
33
44
|
}
|
|
34
45
|
|
|
35
46
|
export interface SchedulerTickResult {
|
|
36
47
|
scanned: number;
|
|
37
48
|
enqueued: number;
|
|
38
49
|
skipped: number;
|
|
50
|
+
swept: number;
|
|
51
|
+
sweepFailed: number;
|
|
39
52
|
}
|
|
40
53
|
|
|
41
54
|
/**
|
|
@@ -101,6 +114,8 @@ export const runSchedulerTick = async (
|
|
|
101
114
|
let scanned = 0;
|
|
102
115
|
let enqueued = 0;
|
|
103
116
|
let skipped = 0;
|
|
117
|
+
let swept = 0;
|
|
118
|
+
let sweepFailed = 0;
|
|
104
119
|
|
|
105
120
|
do {
|
|
106
121
|
const page = await accountService.listAllAccountsPage({
|
|
@@ -132,10 +147,49 @@ export const runSchedulerTick = async (
|
|
|
132
147
|
);
|
|
133
148
|
enqueued += due.length;
|
|
134
149
|
|
|
150
|
+
if (deps.sweepAttachments) {
|
|
151
|
+
// After the enqueue, never before it. Enqueuing mail is the tick's job
|
|
152
|
+
// and collecting bytes is housekeeping; on a hosted Lambda a slow sweep
|
|
153
|
+
// ahead of the enqueue eats the invocation budget, and a timeout is not
|
|
154
|
+
// catchable, so the remaining pages would never be enqueued at all.
|
|
155
|
+
//
|
|
156
|
+
// Every account, not only the ones due a sync: abandoned bytes have
|
|
157
|
+
// nothing to do with how recently mail was fetched. Bounded to the same
|
|
158
|
+
// concurrency as the enqueues.
|
|
159
|
+
//
|
|
160
|
+
// Contained, because this is housekeeping and enqueuing mail is not. An
|
|
161
|
+
// unreadable input or a storage permission error thrown from here would
|
|
162
|
+
// otherwise reach the loop, which logs and exits, and compose would
|
|
163
|
+
// restart the process every five seconds — no account would ever be
|
|
164
|
+
// enqueued again. A failed sweep costs a tick's collection and nothing
|
|
165
|
+
// else; the count is what makes that visible.
|
|
166
|
+
const sweep = deps.sweepAttachments;
|
|
167
|
+
const outcomes = await pMap(
|
|
168
|
+
page.items,
|
|
169
|
+
(account) =>
|
|
170
|
+
sweep(account).then(
|
|
171
|
+
() => true,
|
|
172
|
+
(error: unknown) => {
|
|
173
|
+
log.error(
|
|
174
|
+
{ accountId: account.accountId, error },
|
|
175
|
+
"Outbox attachment sweep failed for account",
|
|
176
|
+
);
|
|
177
|
+
return false;
|
|
178
|
+
},
|
|
179
|
+
),
|
|
180
|
+
{ concurrency: SCHEDULER_ENQUEUE_CONCURRENCY },
|
|
181
|
+
);
|
|
182
|
+
swept += outcomes.filter(Boolean).length;
|
|
183
|
+
sweepFailed += outcomes.filter((ok) => !ok).length;
|
|
184
|
+
}
|
|
185
|
+
|
|
135
186
|
cursor = page.cursor ?? undefined;
|
|
136
187
|
} while (cursor);
|
|
137
188
|
|
|
138
|
-
log.info(
|
|
189
|
+
log.info(
|
|
190
|
+
{ scanned, enqueued, skipped, swept, sweepFailed },
|
|
191
|
+
"Scheduled-sync tick complete",
|
|
192
|
+
);
|
|
139
193
|
|
|
140
|
-
return { scanned, enqueued, skipped };
|
|
194
|
+
return { scanned, enqueued, skipped, swept, sweepFailed };
|
|
141
195
|
};
|
package/src/scheduler/runner.ts
CHANGED
|
@@ -4,7 +4,9 @@ import { getClient } from "@remit/backend/client";
|
|
|
4
4
|
import { createLogger } from "@remit/logger-lambda";
|
|
5
5
|
import { clearHeartbeats, createHeartbeat } from "@remit/sqs-client/heartbeat";
|
|
6
6
|
import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
7
|
+
|
|
7
8
|
import { env } from "expect-env";
|
|
9
|
+
import { buildAttachmentSweep } from "./attachment-sweep.js";
|
|
8
10
|
import { getOfflineIntervalMs, getTickIntervalMs } from "./config.js";
|
|
9
11
|
import { runSchedulerLoop } from "./loop.js";
|
|
10
12
|
import { runSchedulerTick } from "./run-tick.js";
|
|
@@ -66,7 +68,8 @@ const runLoop = async (): Promise<void> => {
|
|
|
66
68
|
};
|
|
67
69
|
|
|
68
70
|
await clearHeartbeats().catch(onClearError);
|
|
69
|
-
const
|
|
71
|
+
const client = await getClient();
|
|
72
|
+
const { account } = client;
|
|
70
73
|
await runSchedulerLoop({
|
|
71
74
|
tick: runSchedulerTick,
|
|
72
75
|
tickDeps: {
|
|
@@ -76,6 +79,7 @@ const runLoop = async (): Promise<void> => {
|
|
|
76
79
|
log,
|
|
77
80
|
tickIntervalMs,
|
|
78
81
|
offlineIntervalMs,
|
|
82
|
+
sweepAttachments: buildAttachmentSweep(client, log),
|
|
79
83
|
},
|
|
80
84
|
heartbeat: createHeartbeat("tick"),
|
|
81
85
|
onHeartbeatError: onBeatError,
|