@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
package/src/scheduler/config.ts
CHANGED
|
@@ -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
|
|
10
|
-
* must stay well below `offlineIntervalSeconds` so a tick
|
|
11
|
-
* every account crossing the threshold. CDK and this runtime
|
|
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
|
package/src/scheduler/handler.ts
CHANGED
|
@@ -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
|
|
20
|
-
*
|
|
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
|
-
});
|