@remit/imap-worker 0.0.33 → 0.0.35
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 +1 -1
- package/src/handlers/flag-push.test.ts +213 -1
- package/src/handlers/flag-push.ts +68 -0
- package/src/processor.ts +5 -4
- package/src/scheduler/loop.test.ts +133 -0
- package/src/scheduler/loop.ts +38 -0
- package/src/scheduler/runner.ts +33 -5
package/package.json
CHANGED
|
@@ -4,7 +4,9 @@ import { getClient, type RemitClient, setClient } from "@remit/backend/client";
|
|
|
4
4
|
import type { Logger } from "@remit/logger-lambda";
|
|
5
5
|
import type { FlagPushEvent } from "../events.js";
|
|
6
6
|
import {
|
|
7
|
+
FLAG_PUSH_DEFER_MAX_MS,
|
|
7
8
|
FLAG_PUSH_MAX_ATTEMPTS,
|
|
9
|
+
getFlagPushDeferMaxMs,
|
|
8
10
|
getFlagPushMaxAttempts,
|
|
9
11
|
handleFlagPush,
|
|
10
12
|
} from "./flag-push.js";
|
|
@@ -45,6 +47,28 @@ describe("getFlagPushMaxAttempts — env-derived threshold (mirrors #1270's getB
|
|
|
45
47
|
});
|
|
46
48
|
});
|
|
47
49
|
|
|
50
|
+
describe("getFlagPushDeferMaxMs", () => {
|
|
51
|
+
it("parses an env override", () => {
|
|
52
|
+
assert.equal(
|
|
53
|
+
getFlagPushDeferMaxMs({ FLAG_PUSH_DEFER_MAX_MS: "1000" }),
|
|
54
|
+
1000,
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("defaults to 10 minutes when unset or invalid", () => {
|
|
59
|
+
assert.equal(getFlagPushDeferMaxMs({}), 10 * 60 * 1000);
|
|
60
|
+
assert.equal(
|
|
61
|
+
getFlagPushDeferMaxMs({ FLAG_PUSH_DEFER_MAX_MS: "nope" }),
|
|
62
|
+
10 * 60 * 1000,
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("the module-level constant reflects the actual process env at load time", () => {
|
|
67
|
+
assert.equal(typeof FLAG_PUSH_DEFER_MAX_MS, "number");
|
|
68
|
+
assert.ok(FLAG_PUSH_DEFER_MAX_MS > 0);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
48
72
|
describe("handleFlagPush — deleted mailbox is terminal (#287/#289)", () => {
|
|
49
73
|
const accountId = "fp-acc-zzz";
|
|
50
74
|
const messageId = "fp-msg-zzz";
|
|
@@ -82,7 +106,7 @@ describe("handleFlagPush — deleted mailbox is terminal (#287/#289)", () => {
|
|
|
82
106
|
accountConfigId: "fp-cfg-zzz",
|
|
83
107
|
}));
|
|
84
108
|
mock.method(client.message, "get", async () => [
|
|
85
|
-
{ messageId, mailboxId: "gone-mbx", uid: 42 },
|
|
109
|
+
{ messageId, mailboxId: "gone-mbx", uid: 42, status: "active" },
|
|
86
110
|
]);
|
|
87
111
|
mock.method(client.mailbox, "get", async () => {
|
|
88
112
|
throw Object.assign(new Error("Mailbox not found: gone-mbx"), {
|
|
@@ -111,3 +135,191 @@ describe("handleFlagPush — deleted mailbox is terminal (#287/#289)", () => {
|
|
|
111
135
|
]);
|
|
112
136
|
});
|
|
113
137
|
});
|
|
138
|
+
|
|
139
|
+
describe("handleFlagPush — defers while a move is in flight, never on an ordinary pending sync", () => {
|
|
140
|
+
const accountId = "fp-acc-inflight";
|
|
141
|
+
const messageId = "fp-msg-inflight";
|
|
142
|
+
const flagName = "$Junk";
|
|
143
|
+
|
|
144
|
+
const event: FlagPushEvent = {
|
|
145
|
+
type: "FLAG_PUSH",
|
|
146
|
+
accountId,
|
|
147
|
+
accountConfigId: "fp-cfg-inflight",
|
|
148
|
+
messageId,
|
|
149
|
+
flagName,
|
|
150
|
+
} as FlagPushEvent;
|
|
151
|
+
|
|
152
|
+
before(() => {
|
|
153
|
+
setClient({
|
|
154
|
+
account: { get: async () => undefined },
|
|
155
|
+
message: { get: async () => undefined },
|
|
156
|
+
mailbox: { get: async () => undefined },
|
|
157
|
+
flagPush: {
|
|
158
|
+
find: async () => undefined,
|
|
159
|
+
updateState: async () => undefined,
|
|
160
|
+
delete: async () => undefined,
|
|
161
|
+
},
|
|
162
|
+
} as unknown as RemitClient);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
afterEach(() => mock.restoreAll());
|
|
166
|
+
|
|
167
|
+
it("resets the marker to pending and never opens a connection when the message's move has not settled", async () => {
|
|
168
|
+
const client = await getClient();
|
|
169
|
+
mock.method(client.account, "get", async () => ({
|
|
170
|
+
accountId,
|
|
171
|
+
accountConfigId: "fp-cfg-inflight",
|
|
172
|
+
passwordHash: "not-actually-used-if-guard-works",
|
|
173
|
+
}));
|
|
174
|
+
mock.method(client.message, "get", async () => [
|
|
175
|
+
{
|
|
176
|
+
messageId,
|
|
177
|
+
mailboxId: "mbx-junk",
|
|
178
|
+
uid: 42,
|
|
179
|
+
// Exactly what MessageMoveService.moveMessage's local optimistic
|
|
180
|
+
// write leaves in place while the IMAP MOVE is still in flight.
|
|
181
|
+
status: "moving",
|
|
182
|
+
syncStatus: "pending",
|
|
183
|
+
},
|
|
184
|
+
]);
|
|
185
|
+
const mailboxGet = mock.method(client.mailbox, "get", async () => ({
|
|
186
|
+
mailboxId: "mbx-junk",
|
|
187
|
+
fullPath: "Junk",
|
|
188
|
+
}));
|
|
189
|
+
mock.method(client.flagPush, "find", async () => ({
|
|
190
|
+
operation: "add",
|
|
191
|
+
state: "queued",
|
|
192
|
+
createdAt: Date.now(),
|
|
193
|
+
}));
|
|
194
|
+
const updateState = mock.method(
|
|
195
|
+
client.flagPush,
|
|
196
|
+
"updateState",
|
|
197
|
+
async () => {},
|
|
198
|
+
);
|
|
199
|
+
const deleteMarker = mock.method(client.flagPush, "delete", async () => {});
|
|
200
|
+
|
|
201
|
+
await handleFlagPush(event, silentLogger, 1);
|
|
202
|
+
|
|
203
|
+
assert.equal(
|
|
204
|
+
mailboxGet.mock.calls.length,
|
|
205
|
+
0,
|
|
206
|
+
"never even resolves the mailbox — returns before touching IMAP",
|
|
207
|
+
);
|
|
208
|
+
assert.equal(
|
|
209
|
+
deleteMarker.mock.calls.length,
|
|
210
|
+
0,
|
|
211
|
+
"the marker is not cleared",
|
|
212
|
+
);
|
|
213
|
+
assert.equal(updateState.mock.calls.length, 1);
|
|
214
|
+
assert.deepEqual(updateState.mock.calls[0].arguments, [
|
|
215
|
+
messageId,
|
|
216
|
+
flagName,
|
|
217
|
+
"pending",
|
|
218
|
+
]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("drops the marker without ever deferring again once a move has been stuck past the defer window", async () => {
|
|
222
|
+
const client = await getClient();
|
|
223
|
+
mock.method(client.account, "get", async () => ({
|
|
224
|
+
accountId,
|
|
225
|
+
accountConfigId: "fp-cfg-inflight",
|
|
226
|
+
passwordHash: "not-actually-used-if-guard-works",
|
|
227
|
+
}));
|
|
228
|
+
mock.method(client.message, "get", async () => [
|
|
229
|
+
{ messageId, mailboxId: "mbx-junk", uid: 42, status: "moving" },
|
|
230
|
+
]);
|
|
231
|
+
const mailboxGet = mock.method(client.mailbox, "get", async () => ({
|
|
232
|
+
mailboxId: "mbx-junk",
|
|
233
|
+
fullPath: "Junk",
|
|
234
|
+
}));
|
|
235
|
+
mock.method(client.flagPush, "find", async () => ({
|
|
236
|
+
operation: "add",
|
|
237
|
+
state: "pending",
|
|
238
|
+
// Long past FLAG_PUSH_DEFER_MAX_MS — the periodic drain has already
|
|
239
|
+
// re-armed and re-deferred this marker many times over.
|
|
240
|
+
createdAt: Date.now() - (FLAG_PUSH_DEFER_MAX_MS + 60_000),
|
|
241
|
+
}));
|
|
242
|
+
const updateState = mock.method(
|
|
243
|
+
client.flagPush,
|
|
244
|
+
"updateState",
|
|
245
|
+
async () => {},
|
|
246
|
+
);
|
|
247
|
+
const deleteMarker = mock.method(client.flagPush, "delete", async () => {});
|
|
248
|
+
|
|
249
|
+
await handleFlagPush(event, silentLogger, 1);
|
|
250
|
+
|
|
251
|
+
assert.equal(
|
|
252
|
+
mailboxGet.mock.calls.length,
|
|
253
|
+
0,
|
|
254
|
+
"never even resolves the mailbox — returns before touching IMAP",
|
|
255
|
+
);
|
|
256
|
+
assert.equal(
|
|
257
|
+
updateState.mock.calls.length,
|
|
258
|
+
0,
|
|
259
|
+
"never re-armed to pending — this is the terminal outcome, not another defer",
|
|
260
|
+
);
|
|
261
|
+
assert.equal(deleteMarker.mock.calls.length, 1);
|
|
262
|
+
assert.deepEqual(deleteMarker.mock.calls[0].arguments, [
|
|
263
|
+
messageId,
|
|
264
|
+
flagName,
|
|
265
|
+
]);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("does NOT defer an ordinary freshly-synced inbound message — syncStatus stays pending forever on the inbound path", async () => {
|
|
269
|
+
// This is the regression the guard must never reintroduce: every
|
|
270
|
+
// message message-sync.ts creates comes out `syncStatus: "pending"`
|
|
271
|
+
// (DrizzleMessageRepository.create's default) and nothing on the
|
|
272
|
+
// inbound path ever promotes it to `synced`. Only `status` names an
|
|
273
|
+
// actual move in flight.
|
|
274
|
+
const client = await getClient();
|
|
275
|
+
mock.method(client.account, "get", async () => ({
|
|
276
|
+
accountId,
|
|
277
|
+
accountConfigId: "fp-cfg-inflight",
|
|
278
|
+
passwordHash: "not-actually-used-if-guard-works",
|
|
279
|
+
}));
|
|
280
|
+
mock.method(client.message, "get", async () => [
|
|
281
|
+
{
|
|
282
|
+
messageId,
|
|
283
|
+
mailboxId: "mbx-junk",
|
|
284
|
+
uid: 42,
|
|
285
|
+
status: "active",
|
|
286
|
+
syncStatus: "pending",
|
|
287
|
+
},
|
|
288
|
+
]);
|
|
289
|
+
// Trips the (unrelated, already-covered) cursor-rebuild early return
|
|
290
|
+
// right after the mailbox lookup — proves the handler reached past the
|
|
291
|
+
// move-in-flight guard without opening a real IMAP connection.
|
|
292
|
+
const mailboxGet = mock.method(client.mailbox, "get", async () => ({
|
|
293
|
+
mailboxId: "mbx-junk",
|
|
294
|
+
fullPath: "Junk",
|
|
295
|
+
cursorState: "cursor_invalid",
|
|
296
|
+
}));
|
|
297
|
+
mock.method(client.flagPush, "find", async () => ({
|
|
298
|
+
operation: "add",
|
|
299
|
+
state: "queued",
|
|
300
|
+
createdAt: Date.now(),
|
|
301
|
+
}));
|
|
302
|
+
const updateState = mock.method(
|
|
303
|
+
client.flagPush,
|
|
304
|
+
"updateState",
|
|
305
|
+
async () => {},
|
|
306
|
+
);
|
|
307
|
+
const deleteMarker = mock.method(client.flagPush, "delete", async () => {});
|
|
308
|
+
|
|
309
|
+
await handleFlagPush(event, silentLogger, 1);
|
|
310
|
+
|
|
311
|
+
assert.equal(
|
|
312
|
+
mailboxGet.mock.calls.length,
|
|
313
|
+
1,
|
|
314
|
+
"the handler proceeded past the move-in-flight guard",
|
|
315
|
+
);
|
|
316
|
+
assert.equal(deleteMarker.mock.calls.length, 0);
|
|
317
|
+
// "processing" is the advance-to-attempt transition, never "pending" —
|
|
318
|
+
// a re-defer here would be exactly blocker 1 again.
|
|
319
|
+
assert.deepEqual(updateState.mock.calls[0]?.arguments, [
|
|
320
|
+
messageId,
|
|
321
|
+
flagName,
|
|
322
|
+
"processing",
|
|
323
|
+
]);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getClient } from "@remit/backend/client";
|
|
2
|
+
import { MessageStatus } from "@remit/domain-enums";
|
|
2
3
|
import type { Logger } from "@remit/logger-lambda";
|
|
3
4
|
import { recordImapFailure } from "@remit/logger-lambda";
|
|
4
5
|
import {
|
|
@@ -35,6 +36,29 @@ export const getFlagPushMaxAttempts = (
|
|
|
35
36
|
|
|
36
37
|
export const FLAG_PUSH_MAX_ATTEMPTS = getFlagPushMaxAttempts();
|
|
37
38
|
|
|
39
|
+
/**
|
|
40
|
+
* How long a marker may sit deferred behind a move before it is dropped
|
|
41
|
+
* outright. A move that settles takes seconds to low minutes; one stuck past
|
|
42
|
+
* this window has almost certainly already exhausted its own retries with no
|
|
43
|
+
* terminal resolver of its own for a regular move (unlike flag-push and
|
|
44
|
+
* placement-move), so deferring further would cycle one SQS round trip per
|
|
45
|
+
* sync tick forever instead of surfacing the stall.
|
|
46
|
+
*/
|
|
47
|
+
const DEFAULT_FLAG_PUSH_DEFER_MAX_MS = 10 * 60 * 1000;
|
|
48
|
+
|
|
49
|
+
export const getFlagPushDeferMaxMs = (
|
|
50
|
+
processEnv: NodeJS.ProcessEnv = process.env,
|
|
51
|
+
): number => {
|
|
52
|
+
const raw = processEnv.FLAG_PUSH_DEFER_MAX_MS;
|
|
53
|
+
if (!raw) return DEFAULT_FLAG_PUSH_DEFER_MAX_MS;
|
|
54
|
+
const parsed = Number.parseInt(raw, 10);
|
|
55
|
+
return Number.isFinite(parsed) && parsed > 0
|
|
56
|
+
? parsed
|
|
57
|
+
: DEFAULT_FLAG_PUSH_DEFER_MAX_MS;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const FLAG_PUSH_DEFER_MAX_MS = getFlagPushDeferMaxMs();
|
|
61
|
+
|
|
38
62
|
/**
|
|
39
63
|
* Handle FLAG_PUSH events (issue #1273, epic #1281). Drains ONE pending
|
|
40
64
|
* flag-push marker: resolves the message's UID and CURRENT mailbox fresh
|
|
@@ -108,6 +132,50 @@ export const handleFlagPush = async (
|
|
|
108
132
|
return;
|
|
109
133
|
}
|
|
110
134
|
|
|
135
|
+
// The message carries a move still in flight — its `mailboxId`/`uid` are
|
|
136
|
+
// the local optimistic write `MessageMoveService` made before enqueueing
|
|
137
|
+
// the IMAP MOVE, not yet confirmed by the server, so a STORE resolved
|
|
138
|
+
// against this row right now would land on the wrong UID or the wrong
|
|
139
|
+
// folder (or both). `syncStatus` is NOT the right signal here: an ordinary
|
|
140
|
+
// freshly-synced inbound row is `pending` too (nothing on the inbound path
|
|
141
|
+
// ever promotes it to `synced`), so keying off it would defer every
|
|
142
|
+
// outbound flag push in the product forever. `status === moving` is set
|
|
143
|
+
// only by an actual move/delete-to-trash and cleared only once it settles.
|
|
144
|
+
if (message.status === MessageStatus.moving) {
|
|
145
|
+
const deferredForMs = Date.now() - marker.createdAt;
|
|
146
|
+
|
|
147
|
+
if (deferredForMs > FLAG_PUSH_DEFER_MAX_MS) {
|
|
148
|
+
// The move this push was waiting on never settled within any
|
|
149
|
+
// reasonable window — deferring further would cycle one SQS round
|
|
150
|
+
// trip per sync tick forever. Drop the stale marker loudly rather
|
|
151
|
+
// than push against state nobody has confirmed.
|
|
152
|
+
await markerService.delete(messageId, flagName);
|
|
153
|
+
recordImapFailure("FLAG_PUSH_MOVE_NEVER_SETTLED", "other");
|
|
154
|
+
log.error(
|
|
155
|
+
{
|
|
156
|
+
alert: "flag_push_move_never_settled",
|
|
157
|
+
messageId,
|
|
158
|
+
flagName,
|
|
159
|
+
accountId,
|
|
160
|
+
deferredForMs,
|
|
161
|
+
},
|
|
162
|
+
"Message move never settled; dropping the flag-push marker that was waiting on it",
|
|
163
|
+
);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Reset to `pending` rather than advancing: `drainPendingFlagPushes`
|
|
168
|
+
// only re-arms markers in that state, scoped by the marker's own
|
|
169
|
+
// `mailboxId` (the push destination), so the next periodic sync tick
|
|
170
|
+
// of that mailbox picks this back up once the move has settled.
|
|
171
|
+
await markerService.updateState(messageId, flagName, "pending");
|
|
172
|
+
log.info(
|
|
173
|
+
{ messageId, flagName, accountId, deferredForMs },
|
|
174
|
+
"Message has a move in flight; pausing outbound flag push until it settles",
|
|
175
|
+
);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
111
179
|
// The worker has picked up the event and is about to actually attempt the
|
|
112
180
|
// IMAP STORE — advance the state engine (pending/queued -> processing).
|
|
113
181
|
// Idempotent to call again on a redelivered event (a prior attempt that
|
package/src/processor.ts
CHANGED
|
@@ -18,10 +18,11 @@ export const processEvent = async (
|
|
|
18
18
|
log: Logger,
|
|
19
19
|
/**
|
|
20
20
|
* SQS's own delivery count for the record carrying this event (1 on first
|
|
21
|
-
* delivery).
|
|
22
|
-
* this is the last attempt before the queue's
|
|
23
|
-
* DLQ the record, so it can resolve
|
|
24
|
-
* outcome (issue #1270) instead of
|
|
21
|
+
* delivery). Read by SYNC_MESSAGE_BODY, PLACEMENT_MOVE_PUSH and FLAG_PUSH
|
|
22
|
+
* — each knows from it when this is the last attempt before the queue's
|
|
23
|
+
* own redrive policy would DLQ the record, so it can resolve
|
|
24
|
+
* retry exhaustion into a terminal outcome (issue #1270) instead of
|
|
25
|
+
* dead-lettering blindly.
|
|
25
26
|
*/
|
|
26
27
|
receiveCount = 1,
|
|
27
28
|
): Promise<void> => {
|
|
@@ -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
|
+
};
|
package/src/scheduler/runner.ts
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
55
|
-
|
|
79
|
+
},
|
|
80
|
+
heartbeat: createHeartbeat("tick"),
|
|
81
|
+
onHeartbeatError: onBeatError,
|
|
82
|
+
tickIntervalMs,
|
|
83
|
+
});
|
|
56
84
|
};
|
|
57
85
|
|
|
58
86
|
runLoop()
|