@remit/imap-worker 0.0.33 → 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.33",
3
+ "version": "0.0.34",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -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
+ };
@@ -2,9 +2,11 @@
2
2
  import { setTimeout as delay } from "node:timers/promises";
3
3
  import { getClient } from "@remit/backend/client";
4
4
  import { createLogger } from "@remit/logger-lambda";
5
+ import { clearHeartbeats, createHeartbeat } from "@remit/sqs-client/heartbeat";
5
6
  import { createQueueProducer } from "@remit/sqs-client/producer";
6
7
  import { env } from "expect-env";
7
8
  import { getOfflineIntervalMs, getTickIntervalMs } from "./config.js";
9
+ import { runSchedulerLoop } from "./loop.js";
8
10
  import { runSchedulerTick } from "./run-tick.js";
9
11
 
10
12
  /**
@@ -17,6 +19,14 @@ import { runSchedulerTick } from "./run-tick.js";
17
19
  *
18
20
  * A tick failure crashes the process loudly rather than being swallowed —
19
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.
20
30
  */
21
31
 
22
32
  const log = createLogger();
@@ -41,18 +51,36 @@ log.info(
41
51
  );
42
52
 
43
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);
44
69
  const { account } = await getClient();
45
- for (;;) {
46
- await runSchedulerTick({
70
+ await runSchedulerLoop({
71
+ tick: runSchedulerTick,
72
+ tickDeps: {
47
73
  accountService: account,
48
74
  sqsClient,
49
75
  queueUrl: mailboxesQueueUrl,
50
76
  log,
51
77
  tickIntervalMs,
52
78
  offlineIntervalMs,
53
- });
54
- await delay(tickIntervalMs);
55
- }
79
+ },
80
+ heartbeat: createHeartbeat("tick"),
81
+ onHeartbeatError: onBeatError,
82
+ tickIntervalMs,
83
+ });
56
84
  };
57
85
 
58
86
  runLoop()