@remit/imap-worker 0.0.32 → 0.0.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/imap-worker",
3
- "version": "0.0.32",
3
+ "version": "0.0.34",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -6,15 +6,20 @@
6
6
  * in remit-account-worker/src/config.ts).
7
7
  *
8
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.
9
+ * EventBridge schedule rate on a managed deployment, the runner's loop delay
10
+ * everywhere else — and must stay well below `offlineIntervalSeconds` so a tick
11
+ * reliably observes every account crossing the threshold. CDK and this runtime
12
+ * default must agree — see infra/lib/config.ts's `mailboxSync` stage config.
13
13
  *
14
14
  * `offlineIntervalSeconds` is the only due-ness threshold: an account is due
15
15
  * once its last successful sync is older than this interval. There is no
16
16
  * "online" tier — client-side polling (useStaleAccountSync) covers an
17
17
  * account while its mail is actively open in the web client.
18
+ *
19
+ * The defaults below are a floor for a deployment that sets neither. The
20
+ * standalone stack sets both, at a cadence sized for one box and a handful of
21
+ * accounts, and the checker's stall threshold is derived from those values —
22
+ * see deploy/vps/docker-compose.sqlite.yml's `scheduler` service.
18
23
  */
19
24
 
20
25
  const DEFAULT_TICK_INTERVAL_SECONDS = 60 * 60; // 1 hour
@@ -16,8 +16,8 @@ const sqsClient = createQueueProducer({ queueUrl: mailboxesQueueUrl });
16
16
  * (#1247, restructured #1251). Ticks at `MAILBOX_SYNC_TICK_INTERVAL_SECONDS`
17
17
  * (rate schedule, wired in infra/stacks/dev/stacks/remit-worker-stack.ts) and
18
18
  * delegates the actual decision + enqueue to `runSchedulerTick` — the same
19
- * function the local dev-stack timer loop calls (see `local-runner.ts`), so
20
- * production and local dev run one code path.
19
+ * function the timer loop calls on a deployment with no EventBridge (see
20
+ * `runner.ts`), so every deployment runs one code path.
21
21
  *
22
22
  * Uses the EventBridge event's own `time` (the scheduled fire time) as the
23
23
  * tick's `now`, rather than `Date.now()` at processing time. This is what
@@ -0,0 +1,133 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { runSchedulerLoop, type SchedulerLoopOptions } from "./loop.js";
4
+ import type { RunSchedulerTickDeps, SchedulerTickResult } from "./run-tick.js";
5
+
6
+ const RESULT: SchedulerTickResult = { scanned: 0, enqueued: 0, skipped: 0 };
7
+
8
+ // Only the loop's own two calls matter here; the tick is a stub, so what it is
9
+ // handed is never read.
10
+ const TICK_DEPS = {} as RunSchedulerTickDeps;
11
+
12
+ const buildLoop = (
13
+ overrides: Partial<SchedulerLoopOptions>,
14
+ ): SchedulerLoopOptions => ({
15
+ tick: () => Promise.resolve(RESULT),
16
+ tickDeps: TICK_DEPS,
17
+ heartbeat: () => Promise.resolve(),
18
+ onHeartbeatError: () => {},
19
+ tickIntervalMs: 1000,
20
+ wait: () => Promise.resolve(),
21
+ ...overrides,
22
+ });
23
+
24
+ describe("runSchedulerLoop", () => {
25
+ it("writes no heartbeat for a tick that throws", async () => {
26
+ let beats = 0;
27
+ await assert.rejects(
28
+ runSchedulerLoop(
29
+ buildLoop({
30
+ tick: () => Promise.reject(new Error("tick failed")),
31
+ heartbeat: () => {
32
+ beats += 1;
33
+ return Promise.resolve();
34
+ },
35
+ }),
36
+ ),
37
+ /tick failed/,
38
+ );
39
+ assert.equal(beats, 0);
40
+ });
41
+
42
+ it("beats once per completed round, and stops beating when a round stops completing", async () => {
43
+ let rounds = 0;
44
+ let beats = 0;
45
+ await assert.rejects(
46
+ runSchedulerLoop(
47
+ buildLoop({
48
+ tick: () => {
49
+ rounds += 1;
50
+ return rounds > 2
51
+ ? Promise.reject(new Error("tick failed"))
52
+ : Promise.resolve(RESULT);
53
+ },
54
+ heartbeat: () => {
55
+ beats += 1;
56
+ return Promise.resolve();
57
+ },
58
+ }),
59
+ ),
60
+ /tick failed/,
61
+ );
62
+ assert.equal(beats, 2);
63
+ });
64
+
65
+ it("keeps ticking when the heartbeat write fails", async () => {
66
+ const errors: unknown[] = [];
67
+ let rounds = 0;
68
+ await assert.rejects(
69
+ runSchedulerLoop(
70
+ buildLoop({
71
+ tick: () => {
72
+ rounds += 1;
73
+ return rounds > 3
74
+ ? Promise.reject(new Error("done"))
75
+ : Promise.resolve(RESULT);
76
+ },
77
+ heartbeat: () => Promise.reject(new Error("ENOSPC")),
78
+ onHeartbeatError: (error) => errors.push(error),
79
+ }),
80
+ ),
81
+ /done/,
82
+ );
83
+ assert.equal(rounds, 4);
84
+ assert.equal(errors.length, 3);
85
+ });
86
+
87
+ it("waits a tick interval between rounds", async () => {
88
+ const waited: number[] = [];
89
+ let rounds = 0;
90
+ await assert.rejects(
91
+ runSchedulerLoop(
92
+ buildLoop({
93
+ tickIntervalMs: 300_000,
94
+ tick: () => {
95
+ rounds += 1;
96
+ return rounds > 2
97
+ ? Promise.reject(new Error("done"))
98
+ : Promise.resolve(RESULT);
99
+ },
100
+ wait: (ms) => {
101
+ waited.push(ms);
102
+ return Promise.resolve();
103
+ },
104
+ }),
105
+ ),
106
+ /done/,
107
+ );
108
+ assert.deepEqual(waited, [300_000, 300_000]);
109
+ });
110
+
111
+ // The default is the only one production uses, so it is the one a wrong unit
112
+ // or a dropped argument would ship in.
113
+ it("sleeps the real timer when no wait is injected", async () => {
114
+ let rounds = 0;
115
+ const started = Date.now();
116
+ await assert.rejects(
117
+ runSchedulerLoop({
118
+ ...buildLoop({
119
+ tickIntervalMs: 25,
120
+ tick: () => {
121
+ rounds += 1;
122
+ return rounds > 2
123
+ ? Promise.reject(new Error("done"))
124
+ : Promise.resolve(RESULT);
125
+ },
126
+ }),
127
+ wait: undefined,
128
+ }),
129
+ /done/,
130
+ );
131
+ assert.ok(Date.now() - started >= 50);
132
+ });
133
+ });
@@ -0,0 +1,38 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import type { Heartbeat } from "@remit/sqs-client/heartbeat";
3
+ import type { RunSchedulerTickDeps, SchedulerTickResult } from "./run-tick.js";
4
+
5
+ /**
6
+ * The scheduled-sync loop, separated from the wiring in runner.ts for the same
7
+ * reason `runQueuePoller` is separate from the container that starts it: the
8
+ * order of the two calls below is a property worth holding, and a module that
9
+ * connects to a queue and a database at import time cannot be asked about it.
10
+ *
11
+ * The beat lands after the tick returns, never before. A tick that throws on
12
+ * every pass exits the process and is restarted every few seconds, so a beat
13
+ * written on the way in would report a scheduler that enqueues nothing as
14
+ * healthy for as long as it kept crashing.
15
+ */
16
+ export interface SchedulerLoopOptions {
17
+ readonly tick: (deps: RunSchedulerTickDeps) => Promise<SchedulerTickResult>;
18
+ readonly tickDeps: RunSchedulerTickDeps;
19
+ readonly heartbeat: Heartbeat;
20
+ readonly onHeartbeatError: (error: unknown) => void;
21
+ readonly tickIntervalMs: number;
22
+ readonly wait?: (ms: number) => Promise<void>;
23
+ }
24
+
25
+ export const runSchedulerLoop = async ({
26
+ tick,
27
+ tickDeps,
28
+ heartbeat,
29
+ onHeartbeatError,
30
+ tickIntervalMs,
31
+ wait = (ms) => delay(ms),
32
+ }: SchedulerLoopOptions): Promise<never> => {
33
+ for (;;) {
34
+ await tick(tickDeps);
35
+ await heartbeat().catch(onHeartbeatError);
36
+ await wait(tickIntervalMs);
37
+ }
38
+ };
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ import { setTimeout as delay } from "node:timers/promises";
3
+ import { getClient } from "@remit/backend/client";
4
+ import { createLogger } from "@remit/logger-lambda";
5
+ import { clearHeartbeats, createHeartbeat } from "@remit/sqs-client/heartbeat";
6
+ import { createQueueProducer } from "@remit/sqs-client/producer";
7
+ import { env } from "expect-env";
8
+ import { getOfflineIntervalMs, getTickIntervalMs } from "./config.js";
9
+ import { runSchedulerLoop } from "./loop.js";
10
+ import { runSchedulerTick } from "./run-tick.js";
11
+
12
+ /**
13
+ * The scheduled-sync runner for every deployment that has no EventBridge: the
14
+ * self-host stack's `scheduler` service and the local dev compose stack. A
15
+ * managed deployment fires `runSchedulerTick` off an EventBridge schedule (see
16
+ * handler.ts); this process ticks the same function on a plain loop at the same
17
+ * `MAILBOX_SYNC_TICK_INTERVAL_SECONDS` cadence, so there is one scheduling
18
+ * implementation and one set of knobs.
19
+ *
20
+ * A tick failure crashes the process loudly rather than being swallowed —
21
+ * compose's `restart: unless-stopped` brings it back for the next tick.
22
+ *
23
+ * Liveness is a heartbeat file, the same mechanism as a worker's poll loop (D1
24
+ * of docs/design/standalone-observability.md); loop.ts carries where in the loop
25
+ * it is written and why. Clearing the previous generation's file here is what
26
+ * extends that answer across a restart. A completed round is still not the same
27
+ * as mail arriving: the accounts that came due are enqueued for workers to
28
+ * fetch, and whether that fetch lands is what
29
+ * `remit_account_sync_age_seconds` measures.
30
+ */
31
+
32
+ const log = createLogger();
33
+
34
+ const mailboxesQueueUrl = env.SQS_QUEUE_URL_MAILBOXES;
35
+ const sqsClient = createQueueProducer({ queueUrl: mailboxesQueueUrl });
36
+
37
+ const tickIntervalMs = getTickIntervalMs();
38
+ const offlineIntervalMs = getOfflineIntervalMs();
39
+
40
+ // A persistent failure (e.g. Postgres not up yet at container boot) throws
41
+ // before the loop ever reaches its own `delay`, so `restart: unless-stopped`
42
+ // would otherwise respawn the process immediately — a tight, log-flooding
43
+ // crash loop (review #1250). This fixed pause before exiting is not retry
44
+ // logic (there is nothing to retry here; the container restart IS the
45
+ // retry) — it only paces how fast that restart can happen.
46
+ const CRASH_BACKOFF_MS = 5_000;
47
+
48
+ log.info(
49
+ { tickIntervalMs, offlineIntervalMs },
50
+ "Scheduled-sync runner started",
51
+ );
52
+
53
+ const runLoop = async (): Promise<void> => {
54
+ // Neither call on the monitoring file may take the scheduler down with it. An
55
+ // unreadable or full /data/heartbeat is a reason to keep enqueuing mail, not
56
+ // to stop. A clear that fails leaves the previous generation's file, which
57
+ // ages out at the same threshold; a write that fails is the missed beat that
58
+ // is itself the signal. Two messages because they are different problems: one
59
+ // is a volume this container could not read at boot, the other a write it
60
+ // could not make this round.
61
+ const onClearError = (error: unknown): void => {
62
+ log.error({ error }, "Scheduled-sync heartbeat clear failed");
63
+ };
64
+ const onBeatError = (error: unknown): void => {
65
+ log.error({ error }, "Scheduled-sync heartbeat write failed");
66
+ };
67
+
68
+ await clearHeartbeats().catch(onClearError);
69
+ const { account } = await getClient();
70
+ await runSchedulerLoop({
71
+ tick: runSchedulerTick,
72
+ tickDeps: {
73
+ accountService: account,
74
+ sqsClient,
75
+ queueUrl: mailboxesQueueUrl,
76
+ log,
77
+ tickIntervalMs,
78
+ offlineIntervalMs,
79
+ },
80
+ heartbeat: createHeartbeat("tick"),
81
+ onHeartbeatError: onBeatError,
82
+ tickIntervalMs,
83
+ });
84
+ };
85
+
86
+ runLoop()
87
+ .catch(async (error) => {
88
+ log.error({ error }, "Scheduled-sync tick failed");
89
+ await delay(CRASH_BACKOFF_MS);
90
+ })
91
+ .finally(() => {
92
+ process.exit(1);
93
+ });
@@ -1,68 +0,0 @@
1
- #!/usr/bin/env node
2
- import { setTimeout as delay } from "node:timers/promises";
3
- import { getClient } from "@remit/backend/client";
4
- import { createLogger } from "@remit/logger-lambda";
5
- import { createQueueProducer } from "@remit/sqs-client/producer";
6
- import { env } from "expect-env";
7
- import { getOfflineIntervalMs, getTickIntervalMs } from "./config.js";
8
- import { runSchedulerTick } from "./run-tick.js";
9
-
10
- /**
11
- * Standalone scheduled-sync runner for the local pg-dev docker-compose stack
12
- * (#1247, restructured #1251). Production ticks `runSchedulerTick` off an
13
- * EventBridge schedule (see handler.ts); ElasticMQ/the pg-dev stack has no
14
- * EventBridge, so this process ticks on a plain loop at the same
15
- * `MAILBOX_SYNC_TICK_INTERVAL_SECONDS` cadence instead — same function, same
16
- * config knobs, so local dev behaves like production rather than needing its
17
- * own scheduling logic.
18
- *
19
- * This is a dev-only harness, not production code: like
20
- * `e2e-processor-shim.ts`, a tick failure crashes the process loudly rather
21
- * than swallowing it — docker-compose's `restart: unless-stopped` brings it
22
- * back for the next tick.
23
- */
24
-
25
- const log = createLogger();
26
-
27
- const mailboxesQueueUrl = env.SQS_QUEUE_URL_MAILBOXES;
28
- const sqsClient = createQueueProducer({ queueUrl: mailboxesQueueUrl });
29
-
30
- const tickIntervalMs = getTickIntervalMs();
31
- const offlineIntervalMs = getOfflineIntervalMs();
32
-
33
- // A persistent failure (e.g. Postgres not up yet at container boot) throws
34
- // before the loop ever reaches its own `delay`, so `restart: unless-stopped`
35
- // would otherwise respawn the process immediately — a tight, log-flooding
36
- // crash loop (review #1250). This fixed pause before exiting is not retry
37
- // logic (there is nothing to retry here; the container restart IS the
38
- // retry) — it only paces how fast that restart can happen.
39
- const CRASH_BACKOFF_MS = 5_000;
40
-
41
- log.info(
42
- { tickIntervalMs, offlineIntervalMs },
43
- "Local scheduled-sync runner started",
44
- );
45
-
46
- const runLoop = async (): Promise<void> => {
47
- const { account } = await getClient();
48
- for (;;) {
49
- await runSchedulerTick({
50
- accountService: account,
51
- sqsClient,
52
- queueUrl: mailboxesQueueUrl,
53
- log,
54
- tickIntervalMs,
55
- offlineIntervalMs,
56
- });
57
- await delay(tickIntervalMs);
58
- }
59
- };
60
-
61
- runLoop()
62
- .catch(async (error) => {
63
- log.error({ error }, "Scheduled-sync tick failed");
64
- await delay(CRASH_BACKOFF_MS);
65
- })
66
- .finally(() => {
67
- process.exit(1);
68
- });