@remit/mailbox-service 0.0.41 → 0.0.42

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/mailbox-service",
3
- "version": "0.0.41",
3
+ "version": "0.0.42",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -24,6 +24,7 @@ import type { StorageService } from "@remit/storage-service";
24
24
  import {
25
25
  backfillListIds,
26
26
  type ListIdBackfillCheckpoint,
27
+ type ListIdBackfillCheckpointStore,
27
28
  type ListIdBackfillProgress,
28
29
  } from "./list-id-backfill.js";
29
30
 
@@ -99,6 +100,22 @@ const message = (overrides: Partial<MessageItem>): MessageItem =>
99
100
  ...overrides,
100
101
  }) as unknown as MessageItem;
101
102
 
103
+ const asAccount = (accountConfigId: string): AccountConfigItem =>
104
+ ({ accountConfigId }) as unknown as AccountConfigItem;
105
+
106
+ const inMemoryCheckpointStore = (): ListIdBackfillCheckpointStore => {
107
+ let checkpoint: ListIdBackfillCheckpoint | undefined;
108
+ return {
109
+ load: async () => checkpoint,
110
+ save: async (next) => {
111
+ checkpoint = next;
112
+ },
113
+ clear: async () => {
114
+ checkpoint = undefined;
115
+ },
116
+ };
117
+ };
118
+
102
119
  interface Harness {
103
120
  accountConfigService: Pick<IAccountConfigRepository, "listAll">;
104
121
  threadMessageService: Pick<
@@ -351,6 +368,37 @@ describe("backfillListIds", () => {
351
368
  assert.equal(harness.updates.length, 2);
352
369
  });
353
370
 
371
+ it("scans accounts in account-id order, whatever order listAll returns", async () => {
372
+ const accountIds = ["acc-1", "acc-2", "acc-3"];
373
+ const rows = accountIds.map((accountConfigId) =>
374
+ row({
375
+ threadMessageId: `tm-${accountConfigId}`,
376
+ messageId: `m-${accountConfigId}`,
377
+ accountConfigId,
378
+ }),
379
+ );
380
+ const harness = buildHarness({
381
+ accounts: ["acc-3", "acc-1", "acc-2"].map(asAccount),
382
+ rows,
383
+ messages: rows.map((r) =>
384
+ message({
385
+ messageId: r.messageId,
386
+ bodyStorageKey: `s3://${r.messageId}`,
387
+ }),
388
+ ),
389
+ });
390
+ const progress: ListIdBackfillProgress[] = [];
391
+
392
+ await backfillListIds(harness, {
393
+ onProgress: (p) => progress.push({ ...p }),
394
+ });
395
+
396
+ assert.deepEqual(
397
+ progress.map((p) => p.accountConfigId),
398
+ accountIds,
399
+ );
400
+ });
401
+
354
402
  it("checkpoints after each page and clears it on completion", async () => {
355
403
  const rows = Array.from({ length: 3 }, (_, i) =>
356
404
  row({ threadMessageId: `tm-${i}`, messageId: `m-${i}` }),
@@ -380,6 +428,7 @@ describe("backfillListIds", () => {
380
428
  });
381
429
 
382
430
  assert.equal(saved.length, 2);
431
+ assert.equal(saved[0].accountConfigId, "acc-1");
383
432
  assert.equal(saved[0].continuationToken, "2");
384
433
  assert.equal(saved[1].continuationToken, undefined);
385
434
  assert.equal(cleared, true);
@@ -400,7 +449,10 @@ describe("backfillListIds", () => {
400
449
  const result = await backfillListIds(harness, {
401
450
  batchSize: 2,
402
451
  checkpointStore: {
403
- load: async () => ({ accountIndex: 0, continuationToken: "2" }),
452
+ load: async () => ({
453
+ accountConfigId: "acc-1",
454
+ continuationToken: "2",
455
+ }),
404
456
  save: async () => {},
405
457
  clear: async () => {},
406
458
  },
@@ -413,4 +465,109 @@ describe("backfillListIds", () => {
413
465
  ["tm-2"],
414
466
  );
415
467
  });
468
+
469
+ it("resumes the account it was working through after an earlier account is removed", async () => {
470
+ const accountIds = ["acc-1", "acc-2", "acc-3"];
471
+ const rows = [
472
+ row({
473
+ threadMessageId: "tm-1",
474
+ messageId: "m-1",
475
+ accountConfigId: "acc-1",
476
+ }),
477
+ row({
478
+ threadMessageId: "tm-2a",
479
+ messageId: "m-2a",
480
+ accountConfigId: "acc-2",
481
+ }),
482
+ row({
483
+ threadMessageId: "tm-2b",
484
+ messageId: "m-2b",
485
+ accountConfigId: "acc-2",
486
+ }),
487
+ row({
488
+ threadMessageId: "tm-3",
489
+ messageId: "m-3",
490
+ accountConfigId: "acc-3",
491
+ }),
492
+ ];
493
+ const messages = rows.map((r) =>
494
+ message({
495
+ messageId: r.messageId,
496
+ bodyStorageKey: `s3://${r.messageId}`,
497
+ }),
498
+ );
499
+
500
+ const store = inMemoryCheckpointStore();
501
+
502
+ const interrupted = buildHarness({
503
+ accounts: accountIds.map(asAccount),
504
+ rows,
505
+ messages,
506
+ pageSize: 1,
507
+ });
508
+ const listByAccount = interrupted.threadMessageService.listByAccount;
509
+ interrupted.threadMessageService.listByAccount = async (
510
+ accountConfigId,
511
+ opts,
512
+ ) => {
513
+ if (accountConfigId === "acc-2" && opts?.continuationToken) {
514
+ throw new Error("interrupted");
515
+ }
516
+ return listByAccount(accountConfigId, opts);
517
+ };
518
+
519
+ await assert.rejects(
520
+ backfillListIds(interrupted, { batchSize: 1, checkpointStore: store }),
521
+ /interrupted/,
522
+ );
523
+
524
+ const resumed = buildHarness({
525
+ accounts: ["acc-2", "acc-3"].map(asAccount),
526
+ rows,
527
+ messages,
528
+ pageSize: 1,
529
+ });
530
+
531
+ const result = await backfillListIds(resumed, {
532
+ batchSize: 1,
533
+ checkpointStore: store,
534
+ });
535
+
536
+ assert.deepEqual(
537
+ resumed.updates.map((u) => u.threadMessageId),
538
+ ["tm-2b", "tm-3"],
539
+ );
540
+ assert.equal(result.backfilled, 2);
541
+ });
542
+
543
+ it("restarts the pass when the checkpointed account no longer exists", async () => {
544
+ const rows = Array.from({ length: 3 }, (_, i) =>
545
+ row({ threadMessageId: `tm-${i}`, messageId: `m-${i}` }),
546
+ );
547
+ const messages = rows.map((r) =>
548
+ message({
549
+ messageId: r.messageId,
550
+ bodyStorageKey: `s3://${r.messageId}`,
551
+ }),
552
+ );
553
+ const harness = buildHarness({ rows, messages, pageSize: 2 });
554
+
555
+ const result = await backfillListIds(harness, {
556
+ batchSize: 2,
557
+ checkpointStore: {
558
+ load: async () => ({
559
+ accountConfigId: "acc-removed",
560
+ continuationToken: "2",
561
+ }),
562
+ save: async () => {},
563
+ clear: async () => {},
564
+ },
565
+ });
566
+
567
+ assert.equal(result.scanned, 3);
568
+ assert.deepEqual(
569
+ harness.updates.map((u) => u.threadMessageId),
570
+ ["tm-0", "tm-1", "tm-2"],
571
+ );
572
+ });
416
573
  });
@@ -11,14 +11,14 @@ import { extractListId } from "./filters/list-id.js";
11
11
  const DEFAULT_BATCH_SIZE = 200;
12
12
 
13
13
  /**
14
- * Where the full-corpus pass left off: the index into the account list (in
15
- * `listAll()` order) it was working through, and the page cursor within that
16
- * account. Resuming skips every account before it entirely and re-opens the
17
- * in-flight one at its saved cursor, rather than re-scanning the whole corpus
18
- * from the top after an interruption.
14
+ * Where the full-corpus pass left off: the identity of the account it was
15
+ * working through, and the page cursor within that account. Resuming looks the
16
+ * account up by id and re-opens it at its saved cursor, rather than re-scanning
17
+ * the whole corpus from the top after an interruption. A checkpoint naming no
18
+ * configured account restarts the pass.
19
19
  */
20
20
  export interface ListIdBackfillCheckpoint {
21
- accountIndex: number;
21
+ accountConfigId: string;
22
22
  continuationToken?: string;
23
23
  }
24
24
 
@@ -138,7 +138,8 @@ const deriveAndApplyListId = async (
138
138
  * sync path will populate `listId` for it once the body lands.
139
139
  *
140
140
  * Chunked by `listByAccount`'s existing keyset pagination, one account at a
141
- * time in `listAll()` order. A failure reading or parsing one message's
141
+ * time in account-id order, so an interrupted run and its resume walk the same
142
+ * sequence. A failure reading or parsing one message's
142
143
  * stored body is contained to that message — logged, counted, and the pass
143
144
  * continues — the same containment `BodySyncService`'s classification
144
145
  * backfill uses for the same reason: one unreadable object must not strand
@@ -151,9 +152,29 @@ export const backfillListIds = async (
151
152
  const batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
152
153
  const { logger, checkpointStore } = options;
153
154
 
154
- const accounts = await deps.accountConfigService.listAll();
155
+ const accounts = [...(await deps.accountConfigService.listAll())].sort(
156
+ (left, right) => left.accountConfigId.localeCompare(right.accountConfigId),
157
+ );
155
158
  const startingCheckpoint = await checkpointStore?.load();
156
- const startIndex = startingCheckpoint?.accountIndex ?? 0;
159
+ const checkpointedIndex = startingCheckpoint
160
+ ? accounts.findIndex(
161
+ (account) =>
162
+ account.accountConfigId === startingCheckpoint.accountConfigId,
163
+ )
164
+ : -1;
165
+
166
+ if (startingCheckpoint && checkpointedIndex === -1) {
167
+ logger?.info(
168
+ { accountConfigId: startingCheckpoint.accountConfigId },
169
+ "ListId backfill checkpoint names no configured account; restarting the pass",
170
+ );
171
+ }
172
+
173
+ const startIndex = checkpointedIndex === -1 ? 0 : checkpointedIndex;
174
+ const startContinuationToken =
175
+ checkpointedIndex === -1
176
+ ? undefined
177
+ : startingCheckpoint?.continuationToken;
157
178
 
158
179
  const totals = emptyTotals();
159
180
  const failedThreadMessageIds: string[] = [];
@@ -165,9 +186,7 @@ export const backfillListIds = async (
165
186
  ) {
166
187
  const account = accounts[accountIndex];
167
188
  let continuationToken: string | undefined =
168
- accountIndex === startIndex
169
- ? startingCheckpoint?.continuationToken
170
- : undefined;
189
+ accountIndex === startIndex ? startContinuationToken : undefined;
171
190
 
172
191
  do {
173
192
  const page = await deps.threadMessageService.listByAccount(
@@ -231,7 +250,7 @@ export const backfillListIds = async (
231
250
 
232
251
  continuationToken = page.continuationToken;
233
252
  await checkpointStore?.save({
234
- accountIndex,
253
+ accountConfigId: account.accountConfigId,
235
254
  continuationToken,
236
255
  });
237
256