@remit/imap-worker 0.0.34 → 0.0.36
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 +2 -2
- package/src/handlers/flag-push.test.ts +213 -1
- package/src/handlers/flag-push.ts +68 -0
- package/src/processor.ts +5 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/imap-worker",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.36",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"@aws-sdk/core": "*",
|
|
29
29
|
"@remit/storage-service": "*",
|
|
30
30
|
"aws-sdk-client-mock": "*",
|
|
31
|
-
"esbuild": "^0.
|
|
31
|
+
"esbuild": "^0.28.1"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@aws-sdk/client-s3": "^3.1055.0",
|
|
@@ -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> => {
|