@remit/imap-worker 0.0.1
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 +100 -0
- package/build.mjs +17 -0
- package/package.json +50 -0
- package/src/account-check.test.ts +122 -0
- package/src/account-check.ts +88 -0
- package/src/body-sync-gate.test.ts +185 -0
- package/src/body-sync-gate.ts +86 -0
- package/src/cli.ts +211 -0
- package/src/connection-scope.test.ts +266 -0
- package/src/connection-scope.ts +335 -0
- package/src/e2e-processor-shim.ts +248 -0
- package/src/emit.test.ts +44 -0
- package/src/emit.ts +142 -0
- package/src/events.ts +221 -0
- package/src/handlers/append-sent-message.ts +163 -0
- package/src/handlers/delete-account-objects.test.ts +81 -0
- package/src/handlers/delete-account-objects.ts +116 -0
- package/src/handlers/empty-trash.ts +136 -0
- package/src/handlers/flag-push.test.ts +25 -0
- package/src/handlers/flag-push.ts +224 -0
- package/src/handlers/mailbox-management.ts +266 -0
- package/src/handlers/mailbox-sync-order.test.ts +93 -0
- package/src/handlers/mailbox-sync-order.ts +65 -0
- package/src/handlers/message-copy.ts +219 -0
- package/src/handlers/message-delete.test.ts +176 -0
- package/src/handlers/message-delete.ts +283 -0
- package/src/handlers/message-move.test.ts +168 -0
- package/src/handlers/message-move.ts +298 -0
- package/src/handlers/placement-move-push.test.ts +234 -0
- package/src/handlers/placement-move-push.ts +434 -0
- package/src/handlers/sync-mailboxes.ts +241 -0
- package/src/handlers/sync-message-body.test.ts +375 -0
- package/src/handlers/sync-message-body.ts +337 -0
- package/src/handlers/sync-messages-deleted-account.test.ts +141 -0
- package/src/handlers/sync-messages.test.ts +204 -0
- package/src/handlers/sync-messages.ts +412 -0
- package/src/handlers/sync-reserved-host.test.ts +97 -0
- package/src/index.test.ts +22 -0
- package/src/index.ts +70 -0
- package/src/poller.ts +49 -0
- package/src/processor.test.ts +58 -0
- package/src/processor.ts +66 -0
- package/src/scheduler/config.test.ts +40 -0
- package/src/scheduler/config.ts +52 -0
- package/src/scheduler/decide-due.test.ts +44 -0
- package/src/scheduler/decide-due.ts +26 -0
- package/src/scheduler/handler.ts +52 -0
- package/src/scheduler/local-runner.ts +76 -0
- package/src/scheduler/run-tick.test.ts +248 -0
- package/src/scheduler/run-tick.ts +141 -0
- package/src/with-oauth-lifecycle-deps.ts +62 -0
- package/src/with-oauth-lifecycle.test.ts +227 -0
- package/src/with-oauth-lifecycle.ts +125 -0
- package/tsconfig.json +8 -0
package/src/processor.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
2
|
+
import type { WorkerEvent } from "./events.js";
|
|
3
|
+
import { handleAppendSentMessage } from "./handlers/append-sent-message.js";
|
|
4
|
+
import { handleDeleteAccountObjects } from "./handlers/delete-account-objects.js";
|
|
5
|
+
import { handleEmptyTrash } from "./handlers/empty-trash.js";
|
|
6
|
+
import { handleFlagPush } from "./handlers/flag-push.js";
|
|
7
|
+
import { processMailboxManagement } from "./handlers/mailbox-management.js";
|
|
8
|
+
import { handleMessageCopy } from "./handlers/message-copy.js";
|
|
9
|
+
import { handleMessageDelete } from "./handlers/message-delete.js";
|
|
10
|
+
import { handleMessageMove } from "./handlers/message-move.js";
|
|
11
|
+
import { handlePlacementMovePush } from "./handlers/placement-move-push.js";
|
|
12
|
+
import { syncMailboxes } from "./handlers/sync-mailboxes.js";
|
|
13
|
+
import { syncMessageBody } from "./handlers/sync-message-body.js";
|
|
14
|
+
import { syncMessages } from "./handlers/sync-messages.js";
|
|
15
|
+
|
|
16
|
+
export const processEvent = async (
|
|
17
|
+
event: WorkerEvent,
|
|
18
|
+
log: Logger,
|
|
19
|
+
/**
|
|
20
|
+
* SQS's own delivery count for the record carrying this event (1 on first
|
|
21
|
+
* delivery). Only SYNC_MESSAGE_BODY reads it — it's how the handler knows
|
|
22
|
+
* this is the last attempt before the queue's own redrive policy would
|
|
23
|
+
* DLQ the record, so it can resolve retry exhaustion into a terminal
|
|
24
|
+
* outcome (issue #1270) instead of dead-lettering blindly.
|
|
25
|
+
*/
|
|
26
|
+
receiveCount = 1,
|
|
27
|
+
): Promise<void> => {
|
|
28
|
+
switch (event.type) {
|
|
29
|
+
case "SYNC_MAILBOXES":
|
|
30
|
+
return syncMailboxes(event, log);
|
|
31
|
+
case "SYNC_MESSAGES":
|
|
32
|
+
return syncMessages(event, log);
|
|
33
|
+
case "SYNC_MESSAGE_BODY":
|
|
34
|
+
return syncMessageBody(event, log, receiveCount);
|
|
35
|
+
case "MAILBOX_CREATE":
|
|
36
|
+
case "MAILBOX_RENAME":
|
|
37
|
+
case "MAILBOX_DELETE":
|
|
38
|
+
return processMailboxManagement(event, log);
|
|
39
|
+
case "MESSAGE_DELETE":
|
|
40
|
+
return handleMessageDelete(event, log);
|
|
41
|
+
case "MESSAGE_MOVE":
|
|
42
|
+
return handleMessageMove(event, log);
|
|
43
|
+
case "PLACEMENT_MOVE_PUSH":
|
|
44
|
+
return handlePlacementMovePush(event, log, receiveCount);
|
|
45
|
+
case "FLAG_PUSH":
|
|
46
|
+
return handleFlagPush(event, log, receiveCount);
|
|
47
|
+
case "MESSAGE_COPY":
|
|
48
|
+
return handleMessageCopy(event, log);
|
|
49
|
+
case "EMPTY_TRASH":
|
|
50
|
+
return handleEmptyTrash(event, log);
|
|
51
|
+
case "APPEND_SENT_MESSAGE":
|
|
52
|
+
return handleAppendSentMessage(event, log);
|
|
53
|
+
case "DELETE_ACCOUNT_OBJECTS":
|
|
54
|
+
return handleDeleteAccountObjects(event, log);
|
|
55
|
+
case "IMAP_WORKER_STOP":
|
|
56
|
+
// Tombstone fence on the account row already stops processing;
|
|
57
|
+
// this event acks the cascade contract and is a no-op today.
|
|
58
|
+
log.info(
|
|
59
|
+
{ accountConfigId: event.accountConfigId, accountId: event.accountId },
|
|
60
|
+
"Imap worker stop signal received",
|
|
61
|
+
);
|
|
62
|
+
return;
|
|
63
|
+
default:
|
|
64
|
+
throw new Error(`Unknown event type: ${(event as WorkerEvent).type}`);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { getOfflineIntervalMs, getTickIntervalMs } from "./config.js";
|
|
4
|
+
|
|
5
|
+
describe("getTickIntervalMs", () => {
|
|
6
|
+
it("defaults to 1 hour when unset", () => {
|
|
7
|
+
assert.equal(getTickIntervalMs({}), 60 * 60 * 1000);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("reads MAILBOX_SYNC_TICK_INTERVAL_SECONDS", () => {
|
|
11
|
+
assert.equal(
|
|
12
|
+
getTickIntervalMs({ MAILBOX_SYNC_TICK_INTERVAL_SECONDS: "60" }),
|
|
13
|
+
60_000,
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("falls back to the default on a non-positive or non-numeric value", () => {
|
|
18
|
+
assert.equal(
|
|
19
|
+
getTickIntervalMs({ MAILBOX_SYNC_TICK_INTERVAL_SECONDS: "0" }),
|
|
20
|
+
60 * 60 * 1000,
|
|
21
|
+
);
|
|
22
|
+
assert.equal(
|
|
23
|
+
getTickIntervalMs({ MAILBOX_SYNC_TICK_INTERVAL_SECONDS: "nope" }),
|
|
24
|
+
60 * 60 * 1000,
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("getOfflineIntervalMs", () => {
|
|
30
|
+
it("defaults to 12 hours when unset", () => {
|
|
31
|
+
assert.equal(getOfflineIntervalMs({}), 12 * 60 * 60 * 1000);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("reads MAILBOX_SYNC_OFFLINE_INTERVAL_SECONDS", () => {
|
|
35
|
+
assert.equal(
|
|
36
|
+
getOfflineIntervalMs({ MAILBOX_SYNC_OFFLINE_INTERVAL_SECONDS: "3600" }),
|
|
37
|
+
3_600_000,
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Periodic mailbox-sync scheduler (#1247, restructured #1251): the tick rate
|
|
3
|
+
* and the offline-sync threshold are separate, independently configured
|
|
4
|
+
* knobs — never hardcode a cadence — following the repo's
|
|
5
|
+
* env-var-with-a-safe-default convention (see `ACCOUNT_DELETION_GRACE_SECONDS`
|
|
6
|
+
* in remit-account-worker/src/config.ts).
|
|
7
|
+
*
|
|
8
|
+
* `tickIntervalSeconds` drives how often the tick itself runs — the
|
|
9
|
+
* EventBridge schedule rate in prod, the local-runner loop delay in dev — and
|
|
10
|
+
* must stay well below `offlineIntervalSeconds` so a tick reliably observes
|
|
11
|
+
* every account crossing the threshold. CDK and this runtime default must
|
|
12
|
+
* agree — see infra/lib/config.ts's `mailboxSync` stage config.
|
|
13
|
+
*
|
|
14
|
+
* `offlineIntervalSeconds` is the only due-ness threshold: an account is due
|
|
15
|
+
* once its last successful sync is older than this interval. There is no
|
|
16
|
+
* "online" tier — client-side polling (useStaleAccountSync) covers an
|
|
17
|
+
* account while its mail is actively open in the web client.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const DEFAULT_TICK_INTERVAL_SECONDS = 60 * 60; // 1 hour
|
|
21
|
+
const DEFAULT_OFFLINE_INTERVAL_SECONDS = 12 * 60 * 60; // 12 hours
|
|
22
|
+
|
|
23
|
+
const parsePositiveIntSeconds = (
|
|
24
|
+
raw: string | undefined,
|
|
25
|
+
fallback: number,
|
|
26
|
+
): number => {
|
|
27
|
+
if (!raw) return fallback;
|
|
28
|
+
const parsed = Number.parseInt(raw, 10);
|
|
29
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const getTickIntervalMs = (
|
|
33
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
34
|
+
): number =>
|
|
35
|
+
parsePositiveIntSeconds(
|
|
36
|
+
env.MAILBOX_SYNC_TICK_INTERVAL_SECONDS,
|
|
37
|
+
DEFAULT_TICK_INTERVAL_SECONDS,
|
|
38
|
+
) * 1000;
|
|
39
|
+
|
|
40
|
+
export const getOfflineIntervalMs = (
|
|
41
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
42
|
+
): number =>
|
|
43
|
+
parsePositiveIntSeconds(
|
|
44
|
+
env.MAILBOX_SYNC_OFFLINE_INTERVAL_SECONDS,
|
|
45
|
+
DEFAULT_OFFLINE_INTERVAL_SECONDS,
|
|
46
|
+
) * 1000;
|
|
47
|
+
|
|
48
|
+
// Page size + enqueue concurrency are implementation details of the tick
|
|
49
|
+
// itself, not stage config — unlike the two intervals above, nothing outside
|
|
50
|
+
// this worker needs to agree with them.
|
|
51
|
+
export const SCHEDULER_PAGE_SIZE = 100;
|
|
52
|
+
export const SCHEDULER_ENQUEUE_CONCURRENCY = 10;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { isSyncDue } from "./decide-due.js";
|
|
4
|
+
|
|
5
|
+
const OFFLINE_INTERVAL_MS = 12 * 60 * 60 * 1000;
|
|
6
|
+
const NOW = 1_700_000_000_000;
|
|
7
|
+
|
|
8
|
+
describe("isSyncDue", () => {
|
|
9
|
+
it("is always due when the account has never synced", () => {
|
|
10
|
+
assert.equal(isSyncDue({ accountId: "a" }, NOW, OFFLINE_INTERVAL_MS), true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("is due once the sync is older than the offline interval", () => {
|
|
14
|
+
const account = {
|
|
15
|
+
accountId: "a",
|
|
16
|
+
lastSyncAt: NOW - OFFLINE_INTERVAL_MS - 1,
|
|
17
|
+
};
|
|
18
|
+
assert.equal(isSyncDue(account, NOW, OFFLINE_INTERVAL_MS), true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("is not due within the offline interval", () => {
|
|
22
|
+
const account = {
|
|
23
|
+
accountId: "a",
|
|
24
|
+
lastSyncAt: NOW - 60_000,
|
|
25
|
+
};
|
|
26
|
+
assert.equal(isSyncDue(account, NOW, OFFLINE_INTERVAL_MS), false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("is due exactly at the threshold", () => {
|
|
30
|
+
const account = {
|
|
31
|
+
accountId: "a",
|
|
32
|
+
lastSyncAt: NOW - OFFLINE_INTERVAL_MS,
|
|
33
|
+
};
|
|
34
|
+
assert.equal(isSyncDue(account, NOW, OFFLINE_INTERVAL_MS), true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("is not due one millisecond before the threshold", () => {
|
|
38
|
+
const account = {
|
|
39
|
+
accountId: "a",
|
|
40
|
+
lastSyncAt: NOW - OFFLINE_INTERVAL_MS + 1,
|
|
41
|
+
};
|
|
42
|
+
assert.equal(isSyncDue(account, NOW, OFFLINE_INTERVAL_MS), false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure scheduling decision for the periodic mailbox-sync tick (#1247,
|
|
3
|
+
* restructured #1251).
|
|
4
|
+
*
|
|
5
|
+
* One tier: an account is due once its last successful sync is older than
|
|
6
|
+
* `offlineIntervalMs`. An account that has never synced is always due. The
|
|
7
|
+
* tick interval is decoupled from this threshold and runs far more often
|
|
8
|
+
* than `offlineIntervalMs` (see config.ts), so — unlike the prior two-tier
|
|
9
|
+
* design — no slack is needed to compensate for sampling lag: a tick that
|
|
10
|
+
* misses an account by a few seconds simply catches it on the next tick,
|
|
11
|
+
* which arrives long before the threshold matters again.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface SchedulableAccount {
|
|
15
|
+
readonly accountId: string;
|
|
16
|
+
readonly lastSyncAt?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const isSyncDue = (
|
|
20
|
+
account: SchedulableAccount,
|
|
21
|
+
now: number,
|
|
22
|
+
offlineIntervalMs: number,
|
|
23
|
+
): boolean => {
|
|
24
|
+
if (!account.lastSyncAt) return true;
|
|
25
|
+
return now - account.lastSyncAt >= offlineIntervalMs;
|
|
26
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { SQSClient } from "@aws-sdk/client-sqs";
|
|
2
|
+
import { AwsQueryProtocol } from "@aws-sdk/core/protocols";
|
|
3
|
+
import { getClient } from "@remit/backend/client";
|
|
4
|
+
import { createLogger, withTelemetry } from "@remit/logger-lambda";
|
|
5
|
+
import { resolveSqsCredentials } from "@remit/sqs-client";
|
|
6
|
+
import type { ScheduledHandler } from "aws-lambda";
|
|
7
|
+
import { env } from "expect-env";
|
|
8
|
+
import { getOfflineIntervalMs, getTickIntervalMs } from "./config.js";
|
|
9
|
+
import { runSchedulerTick } from "./run-tick.js";
|
|
10
|
+
|
|
11
|
+
const log = createLogger();
|
|
12
|
+
|
|
13
|
+
const mailboxesQueueUrl = env.SQS_QUEUE_URL_MAILBOXES;
|
|
14
|
+
const isLocal = mailboxesQueueUrl.startsWith("http://localhost");
|
|
15
|
+
|
|
16
|
+
const sqsClient = new SQSClient({
|
|
17
|
+
endpoint: isLocal ? new URL(mailboxesQueueUrl).origin : undefined,
|
|
18
|
+
...(isLocal && { protocol: AwsQueryProtocol }),
|
|
19
|
+
credentials: resolveSqsCredentials(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* EventBridge-scheduled entry point for the periodic mailbox-sync tick
|
|
24
|
+
* (#1247, restructured #1251). Ticks at `MAILBOX_SYNC_TICK_INTERVAL_SECONDS`
|
|
25
|
+
* (rate schedule, wired in infra/stacks/dev/stacks/remit-worker-stack.ts) and
|
|
26
|
+
* delegates the actual decision + enqueue to `runSchedulerTick` — the same
|
|
27
|
+
* function the local dev-stack timer loop calls (see `local-runner.ts`), so
|
|
28
|
+
* production and local dev run one code path.
|
|
29
|
+
*
|
|
30
|
+
* Uses the EventBridge event's own `time` (the scheduled fire time) as the
|
|
31
|
+
* tick's `now`, rather than `Date.now()` at processing time. This is what
|
|
32
|
+
* keeps `buildScheduledSyncDedupId`'s time bucket aligned to the schedule
|
|
33
|
+
* instead of wall-clock/Lambda-cold-start jitter (review #1250): every
|
|
34
|
+
* distinct scheduled firing gets a `time` exactly `tickIntervalMs` apart
|
|
35
|
+
* from the last, so consecutive ticks always land in different buckets,
|
|
36
|
+
* while a genuine retry of the same invocation carries the same `time` and
|
|
37
|
+
* correctly dedupes.
|
|
38
|
+
*/
|
|
39
|
+
export const handler: ScheduledHandler = withTelemetry(async (event) => {
|
|
40
|
+
const { account } = await getClient();
|
|
41
|
+
const now = Date.parse(event.time);
|
|
42
|
+
|
|
43
|
+
await runSchedulerTick({
|
|
44
|
+
accountService: account,
|
|
45
|
+
sqsClient,
|
|
46
|
+
queueUrl: mailboxesQueueUrl,
|
|
47
|
+
log,
|
|
48
|
+
tickIntervalMs: getTickIntervalMs(),
|
|
49
|
+
offlineIntervalMs: getOfflineIntervalMs(),
|
|
50
|
+
now,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
import { SQSClient } from "@aws-sdk/client-sqs";
|
|
4
|
+
import { AwsQueryProtocol } from "@aws-sdk/core/protocols";
|
|
5
|
+
import { getClient } from "@remit/backend/client";
|
|
6
|
+
import { createLogger } from "@remit/logger-lambda";
|
|
7
|
+
import { resolveSqsCredentials } from "@remit/sqs-client";
|
|
8
|
+
import { env } from "expect-env";
|
|
9
|
+
import { getOfflineIntervalMs, getTickIntervalMs } from "./config.js";
|
|
10
|
+
import { runSchedulerTick } from "./run-tick.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Standalone scheduled-sync runner for the local pg-dev docker-compose stack
|
|
14
|
+
* (#1247, restructured #1251). Production ticks `runSchedulerTick` off an
|
|
15
|
+
* EventBridge schedule (see handler.ts); ElasticMQ/the pg-dev stack has no
|
|
16
|
+
* EventBridge, so this process ticks on a plain loop at the same
|
|
17
|
+
* `MAILBOX_SYNC_TICK_INTERVAL_SECONDS` cadence instead — same function, same
|
|
18
|
+
* config knobs, so local dev behaves like production rather than needing its
|
|
19
|
+
* own scheduling logic.
|
|
20
|
+
*
|
|
21
|
+
* This is a dev-only harness, not production code: like
|
|
22
|
+
* `e2e-processor-shim.ts`, a tick failure crashes the process loudly rather
|
|
23
|
+
* than swallowing it — docker-compose's `restart: unless-stopped` brings it
|
|
24
|
+
* back for the next tick.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const log = createLogger();
|
|
28
|
+
|
|
29
|
+
const mailboxesQueueUrl = env.SQS_QUEUE_URL_MAILBOXES;
|
|
30
|
+
const isLocal = mailboxesQueueUrl.startsWith("http://localhost");
|
|
31
|
+
|
|
32
|
+
const sqsClient = new SQSClient({
|
|
33
|
+
endpoint: isLocal ? new URL(mailboxesQueueUrl).origin : undefined,
|
|
34
|
+
...(isLocal && { protocol: AwsQueryProtocol }),
|
|
35
|
+
credentials: resolveSqsCredentials(),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const tickIntervalMs = getTickIntervalMs();
|
|
39
|
+
const offlineIntervalMs = getOfflineIntervalMs();
|
|
40
|
+
|
|
41
|
+
// A persistent failure (e.g. Postgres not up yet at container boot) throws
|
|
42
|
+
// before the loop ever reaches its own `delay`, so `restart: unless-stopped`
|
|
43
|
+
// would otherwise respawn the process immediately — a tight, log-flooding
|
|
44
|
+
// crash loop (review #1250). This fixed pause before exiting is not retry
|
|
45
|
+
// logic (there is nothing to retry here; the container restart IS the
|
|
46
|
+
// retry) — it only paces how fast that restart can happen.
|
|
47
|
+
const CRASH_BACKOFF_MS = 5_000;
|
|
48
|
+
|
|
49
|
+
log.info(
|
|
50
|
+
{ tickIntervalMs, offlineIntervalMs },
|
|
51
|
+
"Local scheduled-sync runner started",
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const runLoop = async (): Promise<void> => {
|
|
55
|
+
const { account } = await getClient();
|
|
56
|
+
for (;;) {
|
|
57
|
+
await runSchedulerTick({
|
|
58
|
+
accountService: account,
|
|
59
|
+
sqsClient,
|
|
60
|
+
queueUrl: mailboxesQueueUrl,
|
|
61
|
+
log,
|
|
62
|
+
tickIntervalMs,
|
|
63
|
+
offlineIntervalMs,
|
|
64
|
+
});
|
|
65
|
+
await delay(tickIntervalMs);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
runLoop()
|
|
70
|
+
.catch(async (error) => {
|
|
71
|
+
log.error({ error }, "Scheduled-sync tick failed");
|
|
72
|
+
await delay(CRASH_BACKOFF_MS);
|
|
73
|
+
})
|
|
74
|
+
.finally(() => {
|
|
75
|
+
process.exit(1);
|
|
76
|
+
});
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
|
|
4
|
+
import type { AccountItem, AccountSchedulerPage } from "@remit/data-ports";
|
|
5
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
6
|
+
import { runSchedulerTick } from "./run-tick.js";
|
|
7
|
+
|
|
8
|
+
const TICK_INTERVAL_MS = 60 * 60 * 1000;
|
|
9
|
+
const OFFLINE_INTERVAL_MS = 12 * 60 * 60 * 1000;
|
|
10
|
+
const NOW = 1_700_000_000_000;
|
|
11
|
+
|
|
12
|
+
const createNoopLogger = (): Logger => {
|
|
13
|
+
const noop = () => {};
|
|
14
|
+
const log = {
|
|
15
|
+
info: noop,
|
|
16
|
+
warn: noop,
|
|
17
|
+
error: noop,
|
|
18
|
+
debug: noop,
|
|
19
|
+
fatal: noop,
|
|
20
|
+
trace: noop,
|
|
21
|
+
child: () => log,
|
|
22
|
+
} as unknown as Logger;
|
|
23
|
+
return log;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const createCapturingLogger = (): {
|
|
27
|
+
log: Logger;
|
|
28
|
+
calls: { level: string; args: unknown[] }[];
|
|
29
|
+
} => {
|
|
30
|
+
const calls: { level: string; args: unknown[] }[] = [];
|
|
31
|
+
const capture =
|
|
32
|
+
(level: string) =>
|
|
33
|
+
(...args: unknown[]) =>
|
|
34
|
+
calls.push({ level, args });
|
|
35
|
+
const log = {
|
|
36
|
+
info: capture("info"),
|
|
37
|
+
warn: capture("warn"),
|
|
38
|
+
error: capture("error"),
|
|
39
|
+
debug: capture("debug"),
|
|
40
|
+
fatal: capture("fatal"),
|
|
41
|
+
trace: capture("trace"),
|
|
42
|
+
child: () => log,
|
|
43
|
+
} as unknown as Logger;
|
|
44
|
+
return { log, calls };
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const baseAccount = (overrides: Partial<AccountItem>): AccountItem =>
|
|
48
|
+
({
|
|
49
|
+
accountId: "acct_1",
|
|
50
|
+
accountConfigId: "acfg_1",
|
|
51
|
+
username: "user",
|
|
52
|
+
email: "user@example.com",
|
|
53
|
+
authType: "password",
|
|
54
|
+
imapHost: "imap.example.com",
|
|
55
|
+
imapPort: 993,
|
|
56
|
+
imapTls: true,
|
|
57
|
+
imapStartTls: false,
|
|
58
|
+
smtpEnabled: false,
|
|
59
|
+
smtpHost: "",
|
|
60
|
+
smtpPort: 587,
|
|
61
|
+
smtpTls: false,
|
|
62
|
+
smtpStartTls: true,
|
|
63
|
+
smtpUsername: "",
|
|
64
|
+
isActive: true,
|
|
65
|
+
connectionState: "authenticated",
|
|
66
|
+
createdAt: NOW - 1_000_000,
|
|
67
|
+
updatedAt: NOW - 1_000_000,
|
|
68
|
+
...overrides,
|
|
69
|
+
}) as AccountItem;
|
|
70
|
+
|
|
71
|
+
const fakeAccountService = (pages: AccountSchedulerPage[]) => {
|
|
72
|
+
let call = 0;
|
|
73
|
+
return {
|
|
74
|
+
listAllAccountsPage: async (): Promise<AccountSchedulerPage> => {
|
|
75
|
+
const page = pages[call];
|
|
76
|
+
call++;
|
|
77
|
+
if (!page) throw new Error("no more pages configured");
|
|
78
|
+
return page;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const fakeSqsClient = (): {
|
|
84
|
+
sqsClient: SQSClient;
|
|
85
|
+
sent: SendMessageCommand[];
|
|
86
|
+
} => {
|
|
87
|
+
const sent: SendMessageCommand[] = [];
|
|
88
|
+
const sqsClient = {
|
|
89
|
+
send: async (cmd: SendMessageCommand) => {
|
|
90
|
+
sent.push(cmd);
|
|
91
|
+
return {};
|
|
92
|
+
},
|
|
93
|
+
} as unknown as SQSClient;
|
|
94
|
+
return { sqsClient, sent };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
describe("runSchedulerTick", () => {
|
|
98
|
+
it("enqueues only accounts that are due, and pages through every account", async () => {
|
|
99
|
+
const due = baseAccount({
|
|
100
|
+
accountId: "acct_due",
|
|
101
|
+
lastSyncAt: NOW - OFFLINE_INTERVAL_MS - 1,
|
|
102
|
+
});
|
|
103
|
+
const notDue = baseAccount({
|
|
104
|
+
accountId: "acct_not_due",
|
|
105
|
+
lastSyncAt: NOW - 60_000,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const accountService = fakeAccountService([
|
|
109
|
+
{ items: [due], cursor: "page2" },
|
|
110
|
+
{ items: [notDue], cursor: null },
|
|
111
|
+
]);
|
|
112
|
+
const { sqsClient, sent } = fakeSqsClient();
|
|
113
|
+
|
|
114
|
+
const result = await runSchedulerTick({
|
|
115
|
+
accountService,
|
|
116
|
+
sqsClient,
|
|
117
|
+
queueUrl:
|
|
118
|
+
"https://sqs.eu-west-1.amazonaws.com/123/remit-dev-mailboxes.fifo",
|
|
119
|
+
log: createNoopLogger(),
|
|
120
|
+
tickIntervalMs: TICK_INTERVAL_MS,
|
|
121
|
+
offlineIntervalMs: OFFLINE_INTERVAL_MS,
|
|
122
|
+
now: NOW,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
assert.equal(result.scanned, 2);
|
|
126
|
+
assert.equal(result.enqueued, 1);
|
|
127
|
+
assert.equal(result.skipped, 1);
|
|
128
|
+
assert.equal(sent.length, 1);
|
|
129
|
+
const body = JSON.parse(sent[0]?.input.MessageBody ?? "{}");
|
|
130
|
+
assert.equal(body.accountId, "acct_due");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("skips deleted, unsyncable-host, and reauth-required accounts", async () => {
|
|
134
|
+
const deleted = baseAccount({ accountId: "acct_deleted", deletedAt: NOW });
|
|
135
|
+
const unsyncable = baseAccount({
|
|
136
|
+
accountId: "acct_unsyncable",
|
|
137
|
+
imapHost: "mail.invalid",
|
|
138
|
+
});
|
|
139
|
+
const reauth = baseAccount({
|
|
140
|
+
accountId: "acct_reauth",
|
|
141
|
+
connectionState: "reauth_required",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const accountService = fakeAccountService([
|
|
145
|
+
{ items: [deleted, unsyncable, reauth], cursor: null },
|
|
146
|
+
]);
|
|
147
|
+
const { sqsClient, sent } = fakeSqsClient();
|
|
148
|
+
|
|
149
|
+
const result = await runSchedulerTick({
|
|
150
|
+
accountService,
|
|
151
|
+
sqsClient,
|
|
152
|
+
queueUrl:
|
|
153
|
+
"https://sqs.eu-west-1.amazonaws.com/123/remit-dev-mailboxes.fifo",
|
|
154
|
+
log: createNoopLogger(),
|
|
155
|
+
tickIntervalMs: TICK_INTERVAL_MS,
|
|
156
|
+
offlineIntervalMs: OFFLINE_INTERVAL_MS,
|
|
157
|
+
now: NOW,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
assert.equal(result.enqueued, 0);
|
|
161
|
+
assert.equal(result.skipped, 3);
|
|
162
|
+
assert.equal(sent.length, 0);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("never logs per-account for ineligible accounts — only the aggregate tick summary (review #1250)", async () => {
|
|
166
|
+
const deleted = baseAccount({ accountId: "acct_deleted", deletedAt: NOW });
|
|
167
|
+
const unsyncable = baseAccount({
|
|
168
|
+
accountId: "acct_unsyncable",
|
|
169
|
+
imapHost: "mail.invalid",
|
|
170
|
+
});
|
|
171
|
+
const reauth = baseAccount({
|
|
172
|
+
accountId: "acct_reauth",
|
|
173
|
+
connectionState: "reauth_required",
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const accountService = fakeAccountService([
|
|
177
|
+
{ items: [deleted, unsyncable, reauth], cursor: null },
|
|
178
|
+
]);
|
|
179
|
+
const { sqsClient } = fakeSqsClient();
|
|
180
|
+
const { log, calls } = createCapturingLogger();
|
|
181
|
+
|
|
182
|
+
await runSchedulerTick({
|
|
183
|
+
accountService,
|
|
184
|
+
sqsClient,
|
|
185
|
+
queueUrl:
|
|
186
|
+
"https://sqs.eu-west-1.amazonaws.com/123/remit-dev-mailboxes.fifo",
|
|
187
|
+
log,
|
|
188
|
+
tickIntervalMs: TICK_INTERVAL_MS,
|
|
189
|
+
offlineIntervalMs: OFFLINE_INTERVAL_MS,
|
|
190
|
+
now: NOW,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Sweeping 3 ineligible accounts must not produce 3 (or more) log
|
|
194
|
+
// lines through the tick's own logger — only the one aggregate
|
|
195
|
+
// "tick complete" summary. Per-account noise from the shared
|
|
196
|
+
// isAccountDeleted/isUnsyncableHost/isAccountReauthRequired helpers
|
|
197
|
+
// must be swallowed by a silent logger inside the sweep.
|
|
198
|
+
assert.equal(
|
|
199
|
+
calls.length,
|
|
200
|
+
1,
|
|
201
|
+
`expected exactly one aggregate log line, got ${calls.length}: ${JSON.stringify(calls)}`,
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("gives each enqueued account a scheduler-namespaced, time-bucketed dedup id", async () => {
|
|
206
|
+
const due = baseAccount({ accountId: "acct_due" });
|
|
207
|
+
const accountService = fakeAccountService([{ items: [due], cursor: null }]);
|
|
208
|
+
const { sqsClient, sent } = fakeSqsClient();
|
|
209
|
+
|
|
210
|
+
await runSchedulerTick({
|
|
211
|
+
accountService,
|
|
212
|
+
sqsClient,
|
|
213
|
+
queueUrl:
|
|
214
|
+
"https://sqs.eu-west-1.amazonaws.com/123/remit-dev-mailboxes.fifo",
|
|
215
|
+
log: createNoopLogger(),
|
|
216
|
+
tickIntervalMs: TICK_INTERVAL_MS,
|
|
217
|
+
offlineIntervalMs: OFFLINE_INTERVAL_MS,
|
|
218
|
+
now: NOW,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const dedupId = sent[0]?.input.MessageDeduplicationId;
|
|
222
|
+
assert.ok(dedupId?.startsWith("SYNC_MAILBOXES:scheduled:acct_due:"));
|
|
223
|
+
assert.notEqual(dedupId, "SYNC_MAILBOXES:acct_due");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("terminates pagination and never revisits a page", async () => {
|
|
227
|
+
const accountService = fakeAccountService([
|
|
228
|
+
{ items: [], cursor: "page2" },
|
|
229
|
+
{ items: [], cursor: "page3" },
|
|
230
|
+
{ items: [], cursor: null },
|
|
231
|
+
]);
|
|
232
|
+
const { sqsClient } = fakeSqsClient();
|
|
233
|
+
|
|
234
|
+
const result = await runSchedulerTick({
|
|
235
|
+
accountService,
|
|
236
|
+
sqsClient,
|
|
237
|
+
queueUrl:
|
|
238
|
+
"https://sqs.eu-west-1.amazonaws.com/123/remit-dev-mailboxes.fifo",
|
|
239
|
+
log: createNoopLogger(),
|
|
240
|
+
tickIntervalMs: TICK_INTERVAL_MS,
|
|
241
|
+
offlineIntervalMs: OFFLINE_INTERVAL_MS,
|
|
242
|
+
now: NOW,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
assert.equal(result.scanned, 0);
|
|
246
|
+
assert.equal(result.enqueued, 0);
|
|
247
|
+
});
|
|
248
|
+
});
|