@remit/imap-worker 0.0.90 → 0.0.92
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/dist/account-check.d.ts +27 -0
- package/dist/account-check.js +70 -0
- package/dist/body-sync-gate.d.ts +12 -0
- package/dist/body-sync-gate.js +53 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +193 -0
- package/dist/config-env.d.ts +5 -0
- package/dist/config-env.js +37 -0
- package/dist/connection-pool.d.ts +22 -0
- package/dist/connection-pool.js +97 -0
- package/dist/connection-scope.d.ts +8 -0
- package/dist/connection-scope.js +10 -0
- package/dist/context.d.ts +46 -0
- package/dist/context.js +15 -0
- package/dist/e2e-processor-shim.d.ts +2 -0
- package/dist/e2e-processor-shim.js +181 -0
- package/dist/emit.d.ts +15 -0
- package/dist/emit.js +76 -0
- package/dist/events.d.ts +203 -0
- package/dist/events.js +1 -0
- package/dist/failure-kind.d.ts +10 -0
- package/{src/failure-kind.ts → dist/failure-kind.js} +6 -6
- package/dist/handlers/append-sent-message.d.ts +3 -0
- package/dist/handlers/append-sent-message.js +152 -0
- package/dist/handlers/delete-account-objects.d.ts +3 -0
- package/dist/handlers/delete-account-objects.js +35 -0
- package/dist/handlers/empty-trash.d.ts +14 -0
- package/dist/handlers/empty-trash.js +217 -0
- package/dist/handlers/flag-push.d.ts +38 -0
- package/dist/handlers/flag-push.js +195 -0
- package/dist/handlers/imported-folder.d.ts +4 -0
- package/dist/handlers/imported-folder.js +23 -0
- package/dist/handlers/mailbox-management.d.ts +6 -0
- package/dist/handlers/mailbox-management.js +294 -0
- package/dist/handlers/mailbox-sync-order.d.ts +21 -0
- package/dist/handlers/mailbox-sync-order.js +50 -0
- package/dist/handlers/message-copy.d.ts +40 -0
- package/dist/handlers/message-copy.js +371 -0
- package/dist/handlers/message-delete-terminal.d.ts +60 -0
- package/dist/handlers/message-delete-terminal.js +77 -0
- package/dist/handlers/message-delete.d.ts +57 -0
- package/dist/handlers/message-delete.js +520 -0
- package/{src/handlers/message-move-terminal.ts → dist/handlers/message-move-terminal.d.ts} +18 -105
- package/dist/handlers/message-move-terminal.js +94 -0
- package/dist/handlers/message-move.d.ts +146 -0
- package/dist/handlers/message-move.js +490 -0
- package/dist/handlers/placement-move-push.d.ts +62 -0
- package/dist/handlers/placement-move-push.js +307 -0
- package/dist/handlers/probe-message-id.d.ts +2 -0
- package/dist/handlers/probe-message-id.js +6 -0
- package/dist/handlers/sync-mailboxes.d.ts +45 -0
- package/dist/handlers/sync-mailboxes.js +233 -0
- package/dist/handlers/sync-message-body.d.ts +23 -0
- package/dist/handlers/sync-message-body.js +182 -0
- package/dist/handlers/sync-messages.d.ts +29 -0
- package/dist/handlers/sync-messages.js +285 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +67 -0
- package/dist/lambda-context.d.ts +8 -0
- package/dist/lambda-context.js +33 -0
- package/dist/mailbox-row.d.ts +9 -0
- package/dist/mailbox-row.js +6 -0
- package/dist/poller.d.ts +1 -0
- package/dist/poller.js +51 -0
- package/dist/process.d.ts +6 -0
- package/dist/process.js +3 -0
- package/dist/processor.d.ts +3 -0
- package/dist/processor.js +44 -0
- package/dist/queues.d.ts +10 -0
- package/dist/queues.js +1 -0
- package/dist/s3-account-objects.d.ts +3 -0
- package/dist/s3-account-objects.js +26 -0
- package/dist/scheduler/attachment-sweep.d.ts +11 -0
- package/dist/scheduler/attachment-sweep.js +23 -0
- package/dist/scheduler/calendar-subscriptions.d.ts +22 -0
- package/dist/scheduler/calendar-subscriptions.js +35 -0
- package/dist/scheduler/config.d.ts +29 -0
- package/{src/scheduler/config.ts → dist/scheduler/config.js} +9 -28
- package/{src/scheduler/decide-due.ts → dist/scheduler/decide-due.d.ts} +3 -12
- package/dist/scheduler/decide-due.js +17 -0
- package/dist/scheduler/handler.d.ts +21 -0
- package/dist/scheduler/handler.js +50 -0
- package/{src/scheduler/loop.ts → dist/scheduler/loop.d.ts} +7 -23
- package/dist/scheduler/loop.js +8 -0
- package/dist/scheduler/run-tick.d.ts +66 -0
- package/dist/scheduler/run-tick.js +164 -0
- package/dist/scheduler/runner.d.ts +2 -0
- package/dist/scheduler/runner.js +102 -0
- package/dist/scheduler/stranded-placement-redrive.d.ts +16 -0
- package/dist/scheduler/stranded-placement-redrive.js +172 -0
- package/dist/scheduler/tick.d.ts +5 -0
- package/dist/scheduler/tick.js +5 -0
- package/dist/sqs-queues.d.ts +4 -0
- package/dist/sqs-queues.js +22 -0
- package/dist/with-oauth-lifecycle-deps.d.ts +5 -0
- package/dist/with-oauth-lifecycle-deps.js +17 -0
- package/dist/with-oauth-lifecycle.d.ts +56 -0
- package/dist/with-oauth-lifecycle.js +92 -0
- package/{src/worker-version.ts → dist/worker-version.d.ts} +1 -2
- package/dist/worker-version.js +12 -0
- package/package.json +79 -69
- package/src/account-check.test.ts +0 -149
- package/src/account-check.ts +0 -101
- package/src/body-sync-gate.test.ts +0 -185
- package/src/body-sync-gate.ts +0 -86
- package/src/cli.ts +0 -218
- package/src/connection-scope.test.ts +0 -266
- package/src/connection-scope.ts +0 -314
- package/src/credential-less-sync-fence.test.ts +0 -152
- package/src/e2e-processor-shim.ts +0 -232
- package/src/emit.test.ts +0 -145
- package/src/emit.ts +0 -115
- package/src/events.ts +0 -252
- package/src/failure-kind.test.ts +0 -42
- package/src/handlers/append-sent-message.sqlite.test.ts +0 -176
- package/src/handlers/append-sent-message.ts +0 -264
- package/src/handlers/delete-account-objects.test.ts +0 -126
- package/src/handlers/delete-account-objects.ts +0 -96
- package/src/handlers/empty-trash.sqlite.test.ts +0 -209
- package/src/handlers/empty-trash.ts +0 -352
- package/src/handlers/flag-push.ts +0 -285
- package/src/handlers/imported-folder.test.ts +0 -63
- package/src/handlers/imported-folder.ts +0 -38
- package/src/handlers/mailbox-management.sqlite.test.ts +0 -265
- package/src/handlers/mailbox-management.ts +0 -486
- package/src/handlers/mailbox-sync-order.test.ts +0 -93
- package/src/handlers/mailbox-sync-order.ts +0 -65
- package/src/handlers/message-copy.sqlite.test.ts +0 -298
- package/src/handlers/message-copy.ts +0 -569
- package/src/handlers/message-delete-terminal.ts +0 -147
- package/src/handlers/message-delete.sqlite.test.ts +0 -291
- package/src/handlers/message-delete.ts +0 -888
- package/src/handlers/message-move.sqlite.test.ts +0 -271
- package/src/handlers/message-move.test.ts +0 -216
- package/src/handlers/message-move.ts +0 -755
- package/src/handlers/placement-move-push.test.ts +0 -137
- package/src/handlers/placement-move-push.ts +0 -478
- package/src/handlers/placement-push.sqlite.test.ts +0 -299
- package/src/handlers/probe-message-id.sqlite.test.ts +0 -192
- package/src/handlers/probe-message-id.ts +0 -15
- package/src/handlers/sync-mailboxes.test.ts +0 -183
- package/src/handlers/sync-mailboxes.ts +0 -425
- package/src/handlers/sync-message-body.sqlite.test.ts +0 -314
- package/src/handlers/sync-message-body.test.ts +0 -150
- package/src/handlers/sync-message-body.ts +0 -366
- package/src/handlers/sync-messages.sqlite.test.ts +0 -195
- package/src/handlers/sync-messages.ts +0 -537
- package/src/index.test.ts +0 -22
- package/src/index.ts +0 -82
- package/src/mail-sync-selection.sqlite.test.ts +0 -227
- package/src/mailbox-row.ts +0 -20
- package/src/pending-sync.sqlite.test.ts +0 -165
- package/src/poller.ts +0 -55
- package/src/processor.ts +0 -62
- package/src/queues.ts +0 -40
- package/src/scheduler/attachment-sweep.sqlite.test.ts +0 -74
- package/src/scheduler/attachment-sweep.ts +0 -46
- package/src/scheduler/calendar-subscriptions.sqlite.test.ts +0 -147
- package/src/scheduler/calendar-subscriptions.ts +0 -103
- package/src/scheduler/config.test.ts +0 -40
- package/src/scheduler/decide-due.test.ts +0 -44
- package/src/scheduler/half-deleted-accounts.sqlite.test.ts +0 -196
- package/src/scheduler/handler.ts +0 -69
- package/src/scheduler/loop.test.ts +0 -143
- package/src/scheduler/run-tick.sqlite.test.ts +0 -191
- package/src/scheduler/run-tick.ts +0 -281
- package/src/scheduler/runner.ts +0 -125
- package/src/scheduler/stranded-placement-redrive.ts +0 -265
- package/src/stranded-placement.sqlite.test.ts +0 -799
- package/src/test-helpers/test-world.ts +0 -425
- package/src/test-helpers/worker-queues.ts +0 -11
- package/src/thread-join.sqlite.test.ts +0 -106
- package/src/with-oauth-lifecycle-deps.sqlite.test.ts +0 -269
- package/src/with-oauth-lifecycle-deps.ts +0 -71
- package/src/with-oauth-lifecycle.ts +0 -158
- package/tsconfig.json +0 -8
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Regression test for issue #1120.
|
|
3
|
-
*
|
|
4
|
-
* A sync triggered for an account that stores no credential used to fail the
|
|
5
|
-
* batch item, so the poller left the message undeleted for its whole
|
|
6
|
-
* visibility timeout. `remit-mailboxes.fifo` groups by accountId and a FIFO
|
|
7
|
-
* queue never delivers past an in-flight message in a group, so the account
|
|
8
|
-
* was fenced from every later sync — including the one the user asks for right
|
|
9
|
-
* after saving the password.
|
|
10
|
-
*
|
|
11
|
-
* Runs against the real QueueStore and the real queue definition the stack
|
|
12
|
-
* ships, with the poller's own contract modelled around it: ack deletes the
|
|
13
|
-
* message, a batch item failure leaves it in flight.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import assert from "node:assert/strict";
|
|
17
|
-
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
18
|
-
import { join } from "node:path";
|
|
19
|
-
import { after, describe, it } from "node:test";
|
|
20
|
-
import { fileURLToPath } from "node:url";
|
|
21
|
-
import { buildSyncMailboxesMessage } from "@remit/backend/trigger-sync";
|
|
22
|
-
import type { AccountItem } from "@remit/data-ports";
|
|
23
|
-
import { ConnectionState } from "@remit/domain-enums";
|
|
24
|
-
import {
|
|
25
|
-
bootstrapQueues,
|
|
26
|
-
loadQueuesConfig,
|
|
27
|
-
QueueStore,
|
|
28
|
-
} from "@remit/queue-sidecar";
|
|
29
|
-
import { DEFAULT_VISIBILITY_TIMEOUT_SECONDS } from "@remit/sqs-client/poller";
|
|
30
|
-
import {
|
|
31
|
-
type OAuthLifecycleDeps,
|
|
32
|
-
withOAuthLifecycle,
|
|
33
|
-
} from "./with-oauth-lifecycle.js";
|
|
34
|
-
|
|
35
|
-
const QUEUE_NAME = "remit-mailboxes.fifo";
|
|
36
|
-
const ACCOUNT_ID = "acct_imported";
|
|
37
|
-
const T0 = 1_700_000_000_000;
|
|
38
|
-
|
|
39
|
-
const here = fileURLToPath(new URL(".", import.meta.url));
|
|
40
|
-
const repoRoot = join(here, "..", "..", "..");
|
|
41
|
-
const tmpRoot = join(here, "..", ".tmp", "credential-less-sync-fence");
|
|
42
|
-
|
|
43
|
-
const silentLogger = {
|
|
44
|
-
info: () => {},
|
|
45
|
-
warn: () => {},
|
|
46
|
-
error: () => {},
|
|
47
|
-
debug: () => {},
|
|
48
|
-
trace: () => {},
|
|
49
|
-
fatal: () => {},
|
|
50
|
-
child: () => silentLogger,
|
|
51
|
-
} as never;
|
|
52
|
-
|
|
53
|
-
/** The account the import wizard lands: no password until its owner types one. */
|
|
54
|
-
const importedAccount = {
|
|
55
|
-
accountId: ACCOUNT_ID,
|
|
56
|
-
accountConfigId: "acfg_1",
|
|
57
|
-
username: "alice@example.com",
|
|
58
|
-
email: "alice@example.com",
|
|
59
|
-
authType: "password",
|
|
60
|
-
imapHost: "imap.example.com",
|
|
61
|
-
imapPort: 993,
|
|
62
|
-
imapTls: true,
|
|
63
|
-
imapStartTls: false,
|
|
64
|
-
isActive: true,
|
|
65
|
-
connectionState: ConnectionState.CredentialsMissing,
|
|
66
|
-
createdAt: T0,
|
|
67
|
-
updatedAt: T0,
|
|
68
|
-
} as unknown as AccountItem;
|
|
69
|
-
|
|
70
|
-
const enqueueSync = (store: QueueStore, now: number): void => {
|
|
71
|
-
const message = buildSyncMailboxesMessage({ accountId: ACCOUNT_ID });
|
|
72
|
-
store.sendMessage({
|
|
73
|
-
queueName: QUEUE_NAME,
|
|
74
|
-
body: JSON.stringify(message.body),
|
|
75
|
-
groupId: message.groupId,
|
|
76
|
-
deduplicationId: message.deduplicationId,
|
|
77
|
-
now,
|
|
78
|
-
});
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
describe("a credential-less sync trigger (issue #1120)", () => {
|
|
82
|
-
after(() => {
|
|
83
|
-
rmSync(tmpRoot, { recursive: true, force: true });
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it("is acked, leaving the account's queue group deliverable", async () => {
|
|
87
|
-
mkdirSync(tmpRoot, { recursive: true });
|
|
88
|
-
const store = new QueueStore(
|
|
89
|
-
join(mkdtempSync(join(tmpRoot, "store-")), "queue.db"),
|
|
90
|
-
);
|
|
91
|
-
bootstrapQueues(
|
|
92
|
-
store,
|
|
93
|
-
loadQueuesConfig(join(repoRoot, "deploy/vps/queues.json")),
|
|
94
|
-
);
|
|
95
|
-
|
|
96
|
-
const stateUpdates: string[] = [];
|
|
97
|
-
const deps: OAuthLifecycleDeps = {
|
|
98
|
-
secrets: {
|
|
99
|
-
decrypt: async () => "",
|
|
100
|
-
encrypt: async () => ({}) as never,
|
|
101
|
-
},
|
|
102
|
-
tokenService: { getAccessToken: async () => ({}) as never },
|
|
103
|
-
persistRotatedToken: async () => {},
|
|
104
|
-
updateConnectionState: async (_accountId, state) => {
|
|
105
|
-
stateUpdates.push(state);
|
|
106
|
-
},
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
// The GET /config poll while the user is still typing the password.
|
|
110
|
-
enqueueSync(store, T0);
|
|
111
|
-
|
|
112
|
-
const [received] = store.receiveMessages({
|
|
113
|
-
queueName: QUEUE_NAME,
|
|
114
|
-
maxMessages: 1,
|
|
115
|
-
visibilityTimeoutSeconds: DEFAULT_VISIBILITY_TIMEOUT_SECONDS,
|
|
116
|
-
now: T0,
|
|
117
|
-
});
|
|
118
|
-
assert.ok(received, "the trigger is on the queue");
|
|
119
|
-
|
|
120
|
-
// The poller's contract: an ack deletes the message, a batch item failure
|
|
121
|
-
// leaves it in flight until the visibility timeout expires.
|
|
122
|
-
let batchItemFailure: unknown;
|
|
123
|
-
await withOAuthLifecycle(deps, importedAccount, silentLogger, async () => {
|
|
124
|
-
throw new Error("IMAP work must not run without a credential");
|
|
125
|
-
}).then(
|
|
126
|
-
() => store.deleteMessage(QUEUE_NAME, received.receiptHandle),
|
|
127
|
-
(err: unknown) => {
|
|
128
|
-
batchItemFailure = err;
|
|
129
|
-
},
|
|
130
|
-
);
|
|
131
|
-
|
|
132
|
-
// The refresh the user presses after saving the password.
|
|
133
|
-
enqueueSync(store, T0 + 2_000);
|
|
134
|
-
const deliverable = store.receiveMessages({
|
|
135
|
-
queueName: QUEUE_NAME,
|
|
136
|
-
maxMessages: 10,
|
|
137
|
-
now: T0 + 3_000,
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
assert.equal(
|
|
141
|
-
deliverable.length,
|
|
142
|
-
1,
|
|
143
|
-
"the account's group must stay deliverable, not fenced for the visibility timeout",
|
|
144
|
-
);
|
|
145
|
-
assert.equal(
|
|
146
|
-
batchItemFailure,
|
|
147
|
-
undefined,
|
|
148
|
-
"the event is acked, not retried",
|
|
149
|
-
);
|
|
150
|
-
assert.deepEqual(stateUpdates, [ConnectionState.CredentialsMissing]);
|
|
151
|
-
});
|
|
152
|
-
});
|
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import cluster from "node:cluster";
|
|
3
|
-
import { randomUUID } from "node:crypto";
|
|
4
|
-
import {
|
|
5
|
-
DeleteMessageCommand,
|
|
6
|
-
ReceiveMessageCommand,
|
|
7
|
-
} from "@aws-sdk/client-sqs";
|
|
8
|
-
import { createLogger } from "@remit/logger";
|
|
9
|
-
import { installNodeLogger } from "@remit/logger-lambda/node";
|
|
10
|
-
import { createSqsClient } from "@remit/sqs-client/producer";
|
|
11
|
-
import type { Context, SQSBatchResponse, SQSEvent } from "aws-lambda";
|
|
12
|
-
import { env } from "expect-env";
|
|
13
|
-
import { handler } from "./index.js";
|
|
14
|
-
|
|
15
|
-
installNodeLogger();
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* E2E-only queue drainer.
|
|
19
|
-
*
|
|
20
|
-
* Production binds the Lambda `handler` (`src/index.ts`) to each queue via an
|
|
21
|
-
* SQS event-source mapping. The e2e/CI stack runs on ElasticMQ, which has no
|
|
22
|
-
* event-source mapping, so this process supplies that missing piece: it
|
|
23
|
-
* long-polls each queue, wraps every received batch in an `SQSEvent`, and
|
|
24
|
-
* invokes the exact production `handler`. It then honours the returned
|
|
25
|
-
* `batchItemFailures` the way the SQS service would — deleting the messages
|
|
26
|
-
* that succeeded and leaving the failures un-deleted so their visibility
|
|
27
|
-
* timeout lapses and SQS redelivers them. No processing or failure logic lives
|
|
28
|
-
* here; the prod handler owns all of it.
|
|
29
|
-
*
|
|
30
|
-
* This is a test harness, not production code. If it crashes the e2e suite
|
|
31
|
-
* fails loudly, which is the desired signal — there is no crash net.
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
// Collect all unique queue URLs to poll. Every required queue URL crashes at
|
|
35
|
-
// init via expect-env instead of silently dropping queues.
|
|
36
|
-
const queueUrls = [
|
|
37
|
-
...new Set([
|
|
38
|
-
// FIFO queues for sync operations
|
|
39
|
-
env.SQS_QUEUE_URL_MAILBOXES,
|
|
40
|
-
env.SQS_QUEUE_URL_MESSAGES,
|
|
41
|
-
env.SQS_QUEUE_URL_FLAGS,
|
|
42
|
-
// Standard body queue (#612) + management queues
|
|
43
|
-
env.SQS_QUEUE_URL_BODY,
|
|
44
|
-
env.SQS_QUEUE_URL_MAILBOX_MGMT,
|
|
45
|
-
env.SQS_QUEUE_URL_MESSAGE_MGMT,
|
|
46
|
-
]),
|
|
47
|
-
];
|
|
48
|
-
|
|
49
|
-
if (cluster.isPrimary) {
|
|
50
|
-
// Primary process: fork a worker for each queue
|
|
51
|
-
const log = createLogger();
|
|
52
|
-
log.info({ queueUrls, workerCount: queueUrls.length }, "Primary started");
|
|
53
|
-
|
|
54
|
-
// Track which queue each worker handles for restart
|
|
55
|
-
const workerQueues = new Map<number, string>();
|
|
56
|
-
|
|
57
|
-
// Fork a worker for each queue
|
|
58
|
-
for (const queueUrl of queueUrls) {
|
|
59
|
-
const worker = cluster.fork({ WORKER_QUEUE_URL: queueUrl });
|
|
60
|
-
workerQueues.set(worker.id, queueUrl);
|
|
61
|
-
const queueName = new URL(queueUrl).pathname.split("/").pop();
|
|
62
|
-
log.info({ workerId: worker.id, queueName }, "Forked worker for queue");
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Restart workers that crash
|
|
66
|
-
cluster.on("exit", (worker, code, signal) => {
|
|
67
|
-
const queueUrl = workerQueues.get(worker.id);
|
|
68
|
-
const queueName = queueUrl
|
|
69
|
-
? new URL(queueUrl).pathname.split("/").pop()
|
|
70
|
-
: "unknown";
|
|
71
|
-
|
|
72
|
-
if (signal) {
|
|
73
|
-
log.info({ workerId: worker.id, queueName, signal }, "Worker killed");
|
|
74
|
-
} else if (code !== 0) {
|
|
75
|
-
log.error(
|
|
76
|
-
{ workerId: worker.id, queueName, code },
|
|
77
|
-
"Worker crashed, exiting primary",
|
|
78
|
-
);
|
|
79
|
-
process.exit(1);
|
|
80
|
-
} else {
|
|
81
|
-
log.info({ workerId: worker.id, queueName }, "Worker exited cleanly");
|
|
82
|
-
workerQueues.delete(worker.id);
|
|
83
|
-
}
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
// Graceful shutdown: signal all workers
|
|
87
|
-
const shutdown = () => {
|
|
88
|
-
log.info("Primary received shutdown signal, stopping workers...");
|
|
89
|
-
for (const worker of Object.values(cluster.workers ?? {})) {
|
|
90
|
-
worker?.process.kill("SIGTERM");
|
|
91
|
-
}
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
process.on("SIGINT", shutdown);
|
|
95
|
-
process.on("SIGTERM", shutdown);
|
|
96
|
-
} else {
|
|
97
|
-
// Worker process: poll a single queue
|
|
98
|
-
const queueUrl = env.WORKER_QUEUE_URL;
|
|
99
|
-
const queueName = new URL(queueUrl).pathname.split("/").pop();
|
|
100
|
-
const log = createLogger().child({ queue: queueName });
|
|
101
|
-
|
|
102
|
-
const maxMessages = 10; // SQS API limit
|
|
103
|
-
|
|
104
|
-
const sqs = createSqsClient({ queueUrl });
|
|
105
|
-
|
|
106
|
-
let isShuttingDown = false;
|
|
107
|
-
|
|
108
|
-
process.on("SIGINT", () => {
|
|
109
|
-
log.info("Worker received SIGINT, shutting down...");
|
|
110
|
-
isShuttingDown = true;
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
process.on("SIGTERM", () => {
|
|
114
|
-
log.info("Worker received SIGTERM, shutting down...");
|
|
115
|
-
isShuttingDown = true;
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
// Minimal Lambda Context: `withTelemetry` reads `functionName` for the line it
|
|
119
|
-
// logs per invocation and `awsRequestId` for the scope it opens around it, so
|
|
120
|
-
// every line one batch produces shares a `requestId` (deploy/vps/README.md,
|
|
121
|
-
// "Logs"). Minted per batch, like the production poller does — a context built
|
|
122
|
-
// once would put the whole run under one id, which correlates nothing.
|
|
123
|
-
const nextContext = (): Context =>
|
|
124
|
-
({
|
|
125
|
-
functionName: `e2e-imap-worker-${queueName}`,
|
|
126
|
-
awsRequestId: randomUUID(),
|
|
127
|
-
}) as Context;
|
|
128
|
-
|
|
129
|
-
const pollQueue = async (): Promise<void> => {
|
|
130
|
-
log.info({ maxMessages }, "Worker started, polling...");
|
|
131
|
-
|
|
132
|
-
let consecutiveEmptyPolls = 0;
|
|
133
|
-
|
|
134
|
-
while (!isShuttingDown) {
|
|
135
|
-
// Use short polling when we just processed messages (likely more waiting)
|
|
136
|
-
// Use long polling after empty polls to reduce API calls
|
|
137
|
-
const waitTime = consecutiveEmptyPolls > 0 ? 20 : 0;
|
|
138
|
-
|
|
139
|
-
const response = await sqs.send(
|
|
140
|
-
new ReceiveMessageCommand({
|
|
141
|
-
QueueUrl: queueUrl,
|
|
142
|
-
MaxNumberOfMessages: maxMessages,
|
|
143
|
-
WaitTimeSeconds: waitTime,
|
|
144
|
-
VisibilityTimeout: 300,
|
|
145
|
-
MessageSystemAttributeNames: ["ApproximateReceiveCount"],
|
|
146
|
-
}),
|
|
147
|
-
);
|
|
148
|
-
|
|
149
|
-
if (!response.Messages || response.Messages.length === 0) {
|
|
150
|
-
consecutiveEmptyPolls++;
|
|
151
|
-
continue;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
consecutiveEmptyPolls = 0;
|
|
155
|
-
|
|
156
|
-
const messages = response.Messages.flatMap((m) =>
|
|
157
|
-
m.Body && m.ReceiptHandle && m.MessageId
|
|
158
|
-
? [
|
|
159
|
-
{
|
|
160
|
-
messageId: m.MessageId,
|
|
161
|
-
receiptHandle: m.ReceiptHandle,
|
|
162
|
-
body: m.Body,
|
|
163
|
-
receiveCount: m.Attributes?.ApproximateReceiveCount ?? "1",
|
|
164
|
-
},
|
|
165
|
-
]
|
|
166
|
-
: [],
|
|
167
|
-
);
|
|
168
|
-
|
|
169
|
-
if (messages.length === 0) {
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
log.info({ count: messages.length }, "Invoking handler for batch");
|
|
174
|
-
|
|
175
|
-
const event: SQSEvent = {
|
|
176
|
-
Records: messages.map((m) => ({
|
|
177
|
-
messageId: m.messageId,
|
|
178
|
-
receiptHandle: m.receiptHandle,
|
|
179
|
-
body: m.body,
|
|
180
|
-
attributes: {
|
|
181
|
-
ApproximateReceiveCount: m.receiveCount,
|
|
182
|
-
SentTimestamp: "0",
|
|
183
|
-
SenderId: "e2e",
|
|
184
|
-
ApproximateFirstReceiveTimestamp: "0",
|
|
185
|
-
},
|
|
186
|
-
messageAttributes: {},
|
|
187
|
-
md5OfBody: "",
|
|
188
|
-
eventSource: "aws:sqs",
|
|
189
|
-
eventSourceARN: queueUrl,
|
|
190
|
-
awsRegion: "local",
|
|
191
|
-
})),
|
|
192
|
-
};
|
|
193
|
-
|
|
194
|
-
const result = (await handler(event, nextContext(), () => {})) as
|
|
195
|
-
| SQSBatchResponse
|
|
196
|
-
| undefined;
|
|
197
|
-
|
|
198
|
-
const failedIds = new Set(
|
|
199
|
-
(result?.batchItemFailures ?? []).map((f) => f.itemIdentifier),
|
|
200
|
-
);
|
|
201
|
-
|
|
202
|
-
// Mirror SQS partial-batch-failure semantics: delete the messages the
|
|
203
|
-
// handler reported as succeeded; leave failures un-deleted so their
|
|
204
|
-
// visibility timeout lapses and SQS redelivers them (and eventually
|
|
205
|
-
// dead-letters once maxReceiveCount is hit).
|
|
206
|
-
const succeeded = messages.filter((m) => !failedIds.has(m.messageId));
|
|
207
|
-
|
|
208
|
-
for (const message of succeeded) {
|
|
209
|
-
await sqs.send(
|
|
210
|
-
new DeleteMessageCommand({
|
|
211
|
-
QueueUrl: queueUrl,
|
|
212
|
-
ReceiptHandle: message.receiptHandle,
|
|
213
|
-
}),
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
log.info(
|
|
218
|
-
{ deleted: succeeded.length, leftForRedelivery: failedIds.size },
|
|
219
|
-
"Batch processed",
|
|
220
|
-
);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
log.info("Worker polling stopped");
|
|
224
|
-
};
|
|
225
|
-
|
|
226
|
-
pollQueue()
|
|
227
|
-
.then(() => process.exit(0))
|
|
228
|
-
.catch((error) => {
|
|
229
|
-
log.error({ error }, "Worker error");
|
|
230
|
-
process.exit(1);
|
|
231
|
-
});
|
|
232
|
-
}
|
package/src/emit.test.ts
DELETED
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
|
-
import {
|
|
4
|
-
_resetForTest,
|
|
5
|
-
type RemitClient,
|
|
6
|
-
setClient,
|
|
7
|
-
} from "@remit/backend/client";
|
|
8
|
-
import {
|
|
9
|
-
type RecordingQueue,
|
|
10
|
-
recordingQueue,
|
|
11
|
-
} from "@remit/data-ports/recording-queue";
|
|
12
|
-
import { MailboxLockRepo } from "@remit/drizzle-service";
|
|
13
|
-
import { createShippedSqliteDb } from "@remit/drizzle-service/test-sqlite";
|
|
14
|
-
import {
|
|
15
|
-
createEmitEvent,
|
|
16
|
-
type EmitEvent,
|
|
17
|
-
SYNC_MESSAGES_PENDING,
|
|
18
|
-
} from "./emit.js";
|
|
19
|
-
import type {
|
|
20
|
-
FlagPushEvent,
|
|
21
|
-
SyncMailboxesEvent,
|
|
22
|
-
SyncMessageBodyEvent,
|
|
23
|
-
SyncMessagesEvent,
|
|
24
|
-
} from "./events.js";
|
|
25
|
-
import { workerQueuesOn } from "./test-helpers/worker-queues.js";
|
|
26
|
-
|
|
27
|
-
type Emitted<T> = Omit<T, "eventId" | "timestamp">;
|
|
28
|
-
|
|
29
|
-
let locks: MailboxLockRepo;
|
|
30
|
-
let closeStore: () => void;
|
|
31
|
-
let queue: RecordingQueue;
|
|
32
|
-
|
|
33
|
-
const sentCommands = () => queue.sent;
|
|
34
|
-
|
|
35
|
-
const emitEvent: EmitEvent = (event) =>
|
|
36
|
-
createEmitEvent(workerQueuesOn(queue))(event);
|
|
37
|
-
|
|
38
|
-
beforeEach(() => {
|
|
39
|
-
queue = recordingQueue();
|
|
40
|
-
const store = createShippedSqliteDb();
|
|
41
|
-
closeStore = store.close;
|
|
42
|
-
locks = new MailboxLockRepo(store.db);
|
|
43
|
-
setClient({ mailboxLock: locks } as unknown as RemitClient);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
afterEach(() => {
|
|
47
|
-
_resetForTest();
|
|
48
|
-
closeStore();
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
describe("emitEvent on a FIFO queue", () => {
|
|
52
|
-
it("groups by account and deduplicates on the event's own id", async () => {
|
|
53
|
-
const event: Emitted<SyncMessagesEvent> = {
|
|
54
|
-
type: "SYNC_MESSAGES",
|
|
55
|
-
accountId: "acc-1",
|
|
56
|
-
mailboxId: "mbx-1",
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
await emitEvent(event);
|
|
60
|
-
|
|
61
|
-
const [sent] = sentCommands();
|
|
62
|
-
if (!sent) throw new Error("expected a send");
|
|
63
|
-
assert.equal(sent.groupId, "acc-1");
|
|
64
|
-
assert.equal(sent.deduplicationId, sent.body.eventId);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
// Issue #37: every SYNC_MESSAGES for a mailbox carried one dedup id, so
|
|
68
|
-
// SQS FIFO's 5-minute window discarded the second sync of that mailbox
|
|
69
|
-
// before any worker saw it — mail appended after a sync could not be
|
|
70
|
-
// fetched until the window elapsed. Two syncs of one mailbox must both
|
|
71
|
-
// reach the queue.
|
|
72
|
-
it("lets a second sync of the same mailbox through", async () => {
|
|
73
|
-
const event: Emitted<SyncMessagesEvent> = {
|
|
74
|
-
type: "SYNC_MESSAGES",
|
|
75
|
-
accountId: "acc-1",
|
|
76
|
-
mailboxId: "mbx-1",
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
await emitEvent(event);
|
|
80
|
-
const [first] = sentCommands();
|
|
81
|
-
if (!first) throw new Error("expected a send");
|
|
82
|
-
await locks.releaseLock(
|
|
83
|
-
"acc-1",
|
|
84
|
-
"mbx-1",
|
|
85
|
-
SYNC_MESSAGES_PENDING,
|
|
86
|
-
String(first.body.eventId),
|
|
87
|
-
);
|
|
88
|
-
await emitEvent(event);
|
|
89
|
-
|
|
90
|
-
const sent = sentCommands();
|
|
91
|
-
assert.equal(sent.length, 2);
|
|
92
|
-
assert.notEqual(sent[0]?.deduplicationId, sent[1]?.deduplicationId);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it("lets a second sync of the same account's mailbox list through", async () => {
|
|
96
|
-
const event: Emitted<SyncMailboxesEvent> = {
|
|
97
|
-
type: "SYNC_MAILBOXES",
|
|
98
|
-
accountId: "acc-1",
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
await emitEvent(event);
|
|
102
|
-
await emitEvent(event);
|
|
103
|
-
|
|
104
|
-
const sent = sentCommands();
|
|
105
|
-
assert.equal(sent.length, 2);
|
|
106
|
-
assert.notEqual(sent[0]?.deduplicationId, sent[1]?.deduplicationId);
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
// A FIFO queue without content-based deduplication rejects a send carrying
|
|
110
|
-
// no MessageDeduplicationId, so every event routed onto one needs its own —
|
|
111
|
-
// not only the sync events.
|
|
112
|
-
it("gives a flag-push re-arm a deduplication id", async () => {
|
|
113
|
-
const event: Emitted<FlagPushEvent> = {
|
|
114
|
-
type: "FLAG_PUSH",
|
|
115
|
-
accountId: "acc-1",
|
|
116
|
-
accountConfigId: "cfg-1",
|
|
117
|
-
messageId: "msg-1",
|
|
118
|
-
flagName: "\\Seen",
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
await emitEvent(event);
|
|
122
|
-
|
|
123
|
-
const [sent] = sentCommands();
|
|
124
|
-
if (!sent) throw new Error("expected a send");
|
|
125
|
-
assert.equal(sent.deduplicationId, sent.body.eventId);
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
describe("emitEvent on a standard queue", () => {
|
|
130
|
-
it("sends a body sync with no group or deduplication id", async () => {
|
|
131
|
-
const event: Emitted<SyncMessageBodyEvent> = {
|
|
132
|
-
type: "SYNC_MESSAGE_BODY",
|
|
133
|
-
accountId: "acc-1",
|
|
134
|
-
mailboxId: "mbx-1",
|
|
135
|
-
messageIds: ["msg-1"],
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
await emitEvent(event);
|
|
139
|
-
|
|
140
|
-
const [sent] = sentCommands();
|
|
141
|
-
if (!sent) throw new Error("expected a send");
|
|
142
|
-
assert.equal(sent.groupId, undefined);
|
|
143
|
-
assert.equal(sent.deduplicationId, undefined);
|
|
144
|
-
});
|
|
145
|
-
});
|
package/src/emit.ts
DELETED
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { getClient } from "@remit/backend/client";
|
|
3
|
-
import type { FifoQueueProducer, QueueProducer } from "@remit/data-ports";
|
|
4
|
-
import { recordImapFailure } from "@remit/logger-lambda/metrics";
|
|
5
|
-
import type { ImapEvent } from "./events.js";
|
|
6
|
-
import type { WorkerQueues } from "./queues.js";
|
|
7
|
-
|
|
8
|
-
type EventInput = Omit<ImapEvent, "eventId" | "timestamp">;
|
|
9
|
-
|
|
10
|
-
export type EmitEvent = (event: EventInput) => Promise<void>;
|
|
11
|
-
|
|
12
|
-
type Send = (event: ImapEvent) => Promise<void>;
|
|
13
|
-
|
|
14
|
-
// The deduplication id is the event's own id, so the queue suppresses a
|
|
15
|
-
// re-send of one event (the retry SQS's own idempotency guard is for) and
|
|
16
|
-
// nothing else. A shared id per account or per mailbox instead made the
|
|
17
|
-
// 5-minute window a rate limiter: the second sync of a mailbox within five
|
|
18
|
-
// minutes was discarded before any worker saw it, so mail that arrived after
|
|
19
|
-
// a sync could not be fetched until the window elapsed (issue #37).
|
|
20
|
-
const fifo =
|
|
21
|
-
(queue: FifoQueueProducer<ImapEvent>): Send =>
|
|
22
|
-
(event) =>
|
|
23
|
-
queue.send({
|
|
24
|
-
body: event,
|
|
25
|
-
groupId: event.accountId,
|
|
26
|
-
deduplicationId: event.eventId,
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
const standard =
|
|
30
|
-
(queue: QueueProducer<ImapEvent>): Send =>
|
|
31
|
-
(event) =>
|
|
32
|
-
queue.send({ body: event });
|
|
33
|
-
|
|
34
|
-
export const SYNC_MESSAGES_PENDING = "SYNC_MESSAGES_PENDING";
|
|
35
|
-
|
|
36
|
-
const SYNC_MESSAGES_PENDING_TTL_SECONDS = 300;
|
|
37
|
-
|
|
38
|
-
const senderFor = (type: ImapEvent["type"], queues: WorkerQueues): Send => {
|
|
39
|
-
switch (type) {
|
|
40
|
-
case "SYNC_MAILBOXES":
|
|
41
|
-
return fifo(queues.mailboxes);
|
|
42
|
-
case "SYNC_MESSAGES":
|
|
43
|
-
return fifo(queues.messages);
|
|
44
|
-
// SYNC_MESSAGE_BODY routes to the single standard body queue (#612).
|
|
45
|
-
case "SYNC_MESSAGE_BODY":
|
|
46
|
-
return standard(queues.body);
|
|
47
|
-
case "MAILBOX_CREATE":
|
|
48
|
-
case "MAILBOX_RENAME":
|
|
49
|
-
case "MAILBOX_DELETE":
|
|
50
|
-
return standard(queues.mailboxManagement);
|
|
51
|
-
case "MESSAGE_DELETE":
|
|
52
|
-
case "MESSAGE_MOVE":
|
|
53
|
-
case "MESSAGE_COPY":
|
|
54
|
-
case "EMPTY_TRASH":
|
|
55
|
-
case "APPEND_SENT_MESSAGE":
|
|
56
|
-
// Rides messageMgmtQueue (issue #1271) rather than a dedicated queue — its
|
|
57
|
-
// payload carries only our message id (never a UID, unlike the legacy
|
|
58
|
-
// MESSAGE_MOVE event); per-event-type payload shape, same queue.
|
|
59
|
-
case "PLACEMENT_MOVE_PUSH":
|
|
60
|
-
return standard(queues.messageManagement);
|
|
61
|
-
// Rides the existing flags queue (issue #1273) — this is imap-worker's OWN
|
|
62
|
-
// re-arm hint (the periodic per-mailbox sync tick catching up a marker
|
|
63
|
-
// stuck `pending`), distinct from `FlagPushService`'s user-facing hint
|
|
64
|
-
// sent from the API onto the sync queue. Both land on a queue this worker
|
|
65
|
-
// already consumes; dispatch is by `type`, not by which queue delivered
|
|
66
|
-
// the message.
|
|
67
|
-
case "FLAG_PUSH":
|
|
68
|
-
return fifo(queues.flags);
|
|
69
|
-
}
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
export const createEmitEvent =
|
|
73
|
-
(queues: WorkerQueues): EmitEvent =>
|
|
74
|
-
async (event) => {
|
|
75
|
-
const fullEvent: ImapEvent = {
|
|
76
|
-
...event,
|
|
77
|
-
eventId: randomUUID(),
|
|
78
|
-
timestamp: Date.now(),
|
|
79
|
-
} as ImapEvent;
|
|
80
|
-
|
|
81
|
-
const send = () => senderFor(fullEvent.type, queues)(fullEvent);
|
|
82
|
-
if (fullEvent.type !== "SYNC_MESSAGES") {
|
|
83
|
-
await send();
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const { mailboxLock } = await getClient();
|
|
88
|
-
const { accountId, mailboxId, eventId } = fullEvent;
|
|
89
|
-
const previous = await mailboxLock.get(
|
|
90
|
-
accountId,
|
|
91
|
-
mailboxId,
|
|
92
|
-
SYNC_MESSAGES_PENDING,
|
|
93
|
-
);
|
|
94
|
-
const acquired = await mailboxLock.tryAcquireLock(
|
|
95
|
-
mailboxId,
|
|
96
|
-
SYNC_MESSAGES_PENDING,
|
|
97
|
-
accountId,
|
|
98
|
-
eventId,
|
|
99
|
-
SYNC_MESSAGES_PENDING_TTL_SECONDS,
|
|
100
|
-
);
|
|
101
|
-
if (!acquired) return;
|
|
102
|
-
if (previous && previous.ttl < Math.floor(Date.now() / 1000)) {
|
|
103
|
-
recordImapFailure("SYNC_PENDING_EXPIRED", "other");
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
await send().catch(async (error: unknown) => {
|
|
107
|
-
await mailboxLock.releaseLock(
|
|
108
|
-
accountId,
|
|
109
|
-
mailboxId,
|
|
110
|
-
SYNC_MESSAGES_PENDING,
|
|
111
|
-
eventId,
|
|
112
|
-
);
|
|
113
|
-
throw error;
|
|
114
|
-
});
|
|
115
|
-
};
|