@remit/imap-worker 0.0.34 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/imap-worker",
3
- "version": "0.0.34",
3
+ "version": "0.0.35",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -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). Only SYNC_MESSAGE_BODY reads it — it's how the handler knows
22
- * this is the last attempt before the queue's own redrive policy would
23
- * DLQ the record, so it can resolve retry exhaustion into a terminal
24
- * outcome (issue #1270) instead of dead-lettering blindly.
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> => {