@relaybus/testing 0.0.0 → 0.2.0

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/dist/index.js CHANGED
@@ -32,6 +32,7 @@ function createDelivery(handler, availableAt = at(0)) {
32
32
  return { id: createUuidV7(), handler, availableAt };
33
33
  }
34
34
  function eventStoreContract(name, options) {
35
+ const keepsCompleted = options.keepsCompletedRecords ?? false;
35
36
  describe(`${name} satisfies the EventStore contract`, () => {
36
37
  let store;
37
38
  beforeEach(async () => {
@@ -43,6 +44,16 @@ function eventStoreContract(name, options) {
43
44
  async function append(entries, now = at(0)) {
44
45
  return store.transaction((transaction) => store.append(transaction, entries, now));
45
46
  }
47
+ async function completeMany(target, leases, now) {
48
+ if (target.completeMany !== void 0) {
49
+ return target.completeMany(leases, now);
50
+ }
51
+ const outcomes = [];
52
+ for (const lease of leases) {
53
+ outcomes.push(await target.complete(lease, now));
54
+ }
55
+ return outcomes;
56
+ }
46
57
  async function claim(handlers, now = at(0), limit = 100, owner = "worker-1") {
47
58
  return store.claim({
48
59
  handlers,
@@ -219,12 +230,19 @@ function eventStoreContract(name, options) {
219
230
  }));
220
231
  await append(entries);
221
232
  const batches = await Promise.all(
222
- Array.from(
223
- { length: 8 },
224
- (_, index) => claim(["handler-a"], at(0), 10, `worker-${index}`)
225
- )
233
+ Array.from({ length: 8 }, async (_, index) => {
234
+ const own = [];
235
+ for (; ; ) {
236
+ const batch = await claim(["handler-a"], at(0), 10, `worker-${index}`);
237
+ if (batch.length === 0) {
238
+ return own;
239
+ }
240
+ own.push(...batch);
241
+ }
242
+ })
226
243
  );
227
- const claimedIds = batches.flat().map((delivery) => delivery.deliveryId);
244
+ const leftovers = await claim(["handler-a"], at(0), 100, "worker-late");
245
+ const claimedIds = [...batches.flat(), ...leftovers].map((delivery) => delivery.deliveryId);
228
246
  expect(new Set(claimedIds).size).toBe(claimedIds.length);
229
247
  expect(claimedIds).toHaveLength(40);
230
248
  });
@@ -243,6 +261,10 @@ function eventStoreContract(name, options) {
243
261
  false
244
262
  );
245
263
  expect(await store.complete(current, at(LEASE_MS + 2))).toBe(true);
264
+ if (!keepsCompleted) {
265
+ expect(await store.listDeliveries({})).toEqual([]);
266
+ return;
267
+ }
246
268
  const [record] = await store.listDeliveries({});
247
269
  expect(record).toMatchObject({
248
270
  status: "completed",
@@ -283,7 +305,11 @@ function eventStoreContract(name, options) {
283
305
  await store.transaction(async (transaction) => {
284
306
  expect(await store.complete(delivery, at(2), transaction)).toBe(true);
285
307
  });
286
- expect((await store.listDeliveries({}))[0].status).toBe("completed");
308
+ if (keepsCompleted) {
309
+ expect((await store.listDeliveries({}))[0].status).toBe("completed");
310
+ } else {
311
+ expect(await store.listDeliveries({})).toEqual([]);
312
+ }
287
313
  });
288
314
  it("refuses a transactional completion with a lost lease", async () => {
289
315
  await append([{ event: createEnvelope(), deliveries: [createDelivery("handler-a")] }]);
@@ -457,7 +483,11 @@ function eventStoreContract(name, options) {
457
483
  });
458
484
  expect((await store.statistics(at(LEASE_MS))).oldestDueAt).toEqual(at(200));
459
485
  await store.complete(processing, at(1));
460
- expect(await store.statistics(at(0))).toMatchObject({ completed: 1, oldestDueAt: null });
486
+ expect(await store.statistics(at(0))).toMatchObject({
487
+ completed: keepsCompleted ? 1 : 0,
488
+ processing: 0,
489
+ oldestDueAt: null
490
+ });
461
491
  });
462
492
  it("counts an expired lease as due", async () => {
463
493
  await append([
@@ -469,7 +499,7 @@ function eventStoreContract(name, options) {
469
499
  });
470
500
  });
471
501
  describe("purge", () => {
472
- it("deletes old completed and dead deliveries and their orphaned events", async () => {
502
+ async function appendDoneFailedShared() {
473
503
  const done = createEnvelope();
474
504
  const failed = createEnvelope();
475
505
  const shared = createEnvelope();
@@ -484,23 +514,52 @@ function eventStoreContract(name, options) {
484
514
  await store.bury(failedDelivery, { name: "Error", message: "x" }, at(10));
485
515
  const [sharedDelivery] = await claim(["handler-c"]);
486
516
  await store.complete(sharedDelivery, at(10));
487
- expect(await store.purge({})).toEqual({ deletedDeliveries: 0, deletedEvents: 0 });
488
- expect(await store.purge({ completedBefore: at(10) })).toEqual({
489
- deletedDeliveries: 0,
490
- deletedEvents: 0
491
- });
492
- expect(await store.purge({ completedBefore: at(11) })).toEqual({
493
- deletedDeliveries: 2,
494
- deletedEvents: 1
495
- });
496
- expect(await store.purge({ deadBefore: at(11) })).toEqual({
497
- deletedDeliveries: 1,
498
- deletedEvents: 1
499
- });
500
- const remaining = await store.listDeliveries({});
501
- expect(remaining.map((record) => record.handler)).toEqual(["handler-d"]);
502
- expect(remaining[0].event.id).toBe(shared.id);
503
- });
517
+ return { shared };
518
+ }
519
+ it.runIf(keepsCompleted)(
520
+ "deletes old completed and dead deliveries and their orphaned events",
521
+ async () => {
522
+ const { shared } = await appendDoneFailedShared();
523
+ expect(await store.purge({})).toEqual({ deletedDeliveries: 0, deletedEvents: 0 });
524
+ expect(await store.purge({ completedBefore: at(10) })).toEqual({
525
+ deletedDeliveries: 0,
526
+ deletedEvents: 0
527
+ });
528
+ expect(await store.purge({ completedBefore: at(11) })).toEqual({
529
+ deletedDeliveries: 2,
530
+ deletedEvents: 1
531
+ });
532
+ expect(await store.purge({ deadBefore: at(11) })).toEqual({
533
+ deletedDeliveries: 1,
534
+ deletedEvents: 1
535
+ });
536
+ const remaining = await store.listDeliveries({});
537
+ expect(remaining.map((record) => record.handler)).toEqual(["handler-d"]);
538
+ expect(remaining[0].event.id).toBe(shared.id);
539
+ }
540
+ );
541
+ it.runIf(!keepsCompleted)(
542
+ "deletes old dead deliveries and the events no delivery references any more",
543
+ async () => {
544
+ const { shared } = await appendDoneFailedShared();
545
+ expect(await store.purge({})).toEqual({ deletedDeliveries: 0, deletedEvents: 0 });
546
+ expect(await store.purge({ completedBefore: at(0) })).toEqual({
547
+ deletedDeliveries: 0,
548
+ deletedEvents: 0
549
+ });
550
+ expect(await store.purge({ completedBefore: at(11) })).toEqual({
551
+ deletedDeliveries: 0,
552
+ deletedEvents: 1
553
+ });
554
+ expect(await store.purge({ deadBefore: at(11) })).toEqual({
555
+ deletedDeliveries: 1,
556
+ deletedEvents: 1
557
+ });
558
+ const remaining = await store.listDeliveries({});
559
+ expect(remaining.map((record) => record.handler)).toEqual(["handler-d"]);
560
+ expect(remaining[0].event.id).toBe(shared.id);
561
+ }
562
+ );
504
563
  it("lets a purged event id be appended again", async () => {
505
564
  const event = createEnvelope();
506
565
  await append([{ event, deliveries: [createDelivery("handler-a")] }]);
@@ -766,6 +825,366 @@ function eventStoreContract(name, options) {
766
825
  expect(listed.event.headers).toEqual({ a: "1" });
767
826
  });
768
827
  });
828
+ describe("completion", () => {
829
+ it("removes a completed delivery from every listing", async () => {
830
+ const event = createEnvelope();
831
+ await append([
832
+ { event, deliveries: [createDelivery("handler-a"), createDelivery("handler-b")] }
833
+ ]);
834
+ const [delivery] = await claim(["handler-a"]);
835
+ expect(await store.complete(delivery, at(5))).toBe(true);
836
+ const records = await store.listDeliveries({ eventId: event.id });
837
+ if (keepsCompleted) {
838
+ expect(records.map((record) => [record.handler, record.status]).sort()).toEqual([
839
+ ["handler-a", "completed"],
840
+ ["handler-b", "pending"]
841
+ ]);
842
+ const [completed] = await store.listDeliveries({ status: "completed" });
843
+ expect(completed).toMatchObject({ handler: "handler-a", attempts: 1 });
844
+ expect(completed.completedAt).toEqual(at(5));
845
+ } else {
846
+ expect(records.map((record) => [record.handler, record.status])).toEqual([
847
+ ["handler-b", "pending"]
848
+ ]);
849
+ expect(await store.listDeliveries({ status: "completed" })).toEqual([]);
850
+ }
851
+ expect(await claim(["handler-a"], at(LEASE_MS * 10))).toEqual([]);
852
+ expect(await store.statistics(at(0))).toMatchObject({
853
+ pending: 1,
854
+ processing: 0,
855
+ completed: keepsCompleted ? 1 : 0
856
+ });
857
+ });
858
+ it("does not create the delivery of a handler that already completed again", async () => {
859
+ const event = createEnvelope();
860
+ await append([{ event, deliveries: [createDelivery("handler-a")] }]);
861
+ const [delivery] = await claim(["handler-a"]);
862
+ await store.complete(delivery, at(1));
863
+ const result = await append([
864
+ { event, deliveries: [createDelivery("handler-a"), createDelivery("handler-b")] }
865
+ ]);
866
+ expect(result).toEqual({
867
+ appendedEventIds: [],
868
+ duplicateEventIds: [event.id],
869
+ appendedDeliveryCount: 1
870
+ });
871
+ expect(await claim(["handler-a"], at(LEASE_MS * 10))).toEqual([]);
872
+ expect(await claim(["handler-b"], at(LEASE_MS * 10))).toHaveLength(1);
873
+ const again = await append([{ event, deliveries: [createDelivery("handler-a")] }]);
874
+ expect(again.appendedDeliveryCount).toBe(0);
875
+ });
876
+ it("completes many leases at once and reports each one", async () => {
877
+ await append(
878
+ Array.from({ length: 5 }, () => ({
879
+ event: createEnvelope(),
880
+ deliveries: [createDelivery("handler-a")]
881
+ }))
882
+ );
883
+ const claimed = await claim(["handler-a"]);
884
+ expect(claimed).toHaveLength(5);
885
+ expect(await completeMany(store, [], at(1))).toEqual([]);
886
+ expect(await completeMany(store, claimed.slice(0, 3), at(1))).toEqual([true, true, true]);
887
+ expect(await store.statistics(at(2))).toMatchObject({ processing: 2, pending: 0 });
888
+ expect(await completeMany(store, claimed, at(2))).toEqual([
889
+ false,
890
+ false,
891
+ false,
892
+ true,
893
+ true
894
+ ]);
895
+ expect(await store.statistics(at(3))).toMatchObject({ processing: 0, pending: 0 });
896
+ });
897
+ it("completes a lease listed twice only once", async () => {
898
+ await append([{ event: createEnvelope(), deliveries: [createDelivery("handler-a")] }]);
899
+ const [delivery] = await claim(["handler-a"]);
900
+ expect(await completeMany(store, [delivery, delivery], at(1))).toEqual([true, false]);
901
+ });
902
+ it("fences every lease of a batch on its own", async () => {
903
+ await append(
904
+ Array.from({ length: 3 }, () => ({
905
+ event: createEnvelope(),
906
+ deliveries: [createDelivery("handler-a")]
907
+ }))
908
+ );
909
+ const stale = await claim(["handler-a"], at(0));
910
+ const current = await claim(["handler-a"], at(LEASE_MS), 100, "worker-2");
911
+ expect(current).toHaveLength(3);
912
+ await append(
913
+ [{ event: createEnvelope(), deliveries: [createDelivery("handler-a")] }],
914
+ at(1)
915
+ );
916
+ const [fresh] = await claim(["handler-a"], at(LEASE_MS + 1), 100, "worker-2");
917
+ expect(
918
+ await completeMany(store, [stale[0], fresh, stale[1], current[2]], at(LEASE_MS + 2))
919
+ ).toEqual([false, true, false, true]);
920
+ expect(await store.statistics(at(LEASE_MS + 3))).toMatchObject({ processing: 2 });
921
+ });
922
+ it("does not complete a lease of a delivery that went back to pending", async () => {
923
+ const error = { name: "Error", message: "x" };
924
+ await append([
925
+ { event: createEnvelope(), deliveries: [createDelivery("a"), createDelivery("b")] }
926
+ ]);
927
+ const [retrying] = await claim(["a"]);
928
+ const [released] = await claim(["b"]);
929
+ await store.reschedule(retrying, at(1e3), error, at(1));
930
+ await store.release(released, at(1));
931
+ expect(await completeMany(store, [retrying, released], at(2))).toEqual([false, false]);
932
+ expect(await store.statistics(at(2))).toMatchObject({ pending: 2, processing: 0 });
933
+ });
934
+ });
935
+ describe("stale leases of one delivery", () => {
936
+ const error = { name: "Error", message: "x" };
937
+ it("refuses the lease of an attempt that was released when the same owner claims it again", async () => {
938
+ await append([{ event: createEnvelope(), deliveries: [createDelivery("handler-a")] }]);
939
+ const [stale] = await claim(["handler-a"], at(0), 100, "worker-1");
940
+ expect(await store.release(stale, at(1))).toBe(true);
941
+ const [current] = await claim(["handler-a"], at(2), 100, "worker-1");
942
+ expect(current.deliveryId).toBe(stale.deliveryId);
943
+ expect(current.leaseToken).not.toBe(stale.leaseToken);
944
+ expect(await store.complete(stale, at(3))).toBe(false);
945
+ expect(await store.bury(stale, error, at(3))).toBe(false);
946
+ expect(await store.reschedule(stale, at(9), error, at(3))).toBe(false);
947
+ expect(await store.release(stale, at(3))).toBe(false);
948
+ expect(await store.extendLease(stale, at(LEASE_MS * 3), at(3))).toBe(false);
949
+ expect(await store.complete(current, at(4))).toBe(true);
950
+ });
951
+ it("refuses the lease of an attempt that was rescheduled when the same owner claims it again", async () => {
952
+ await append([{ event: createEnvelope(), deliveries: [createDelivery("handler-a")] }]);
953
+ const [stale] = await claim(["handler-a"], at(0), 100, "worker-1");
954
+ expect(await store.reschedule(stale, at(10), error, at(1))).toBe(true);
955
+ const [current] = await claim(["handler-a"], at(10), 100, "worker-1");
956
+ expect(current).toMatchObject({ deliveryId: stale.deliveryId, attempt: 2 });
957
+ expect(await store.complete(stale, at(11))).toBe(false);
958
+ expect(await store.bury(stale, error, at(11))).toBe(false);
959
+ expect(await completeMany(store, [stale], at(11))).toEqual([false]);
960
+ expect(await store.complete(current, at(12))).toBe(true);
961
+ });
962
+ it("matches the results of completeMany to the lease, not to the delivery id", async () => {
963
+ await append([{ event: createEnvelope(), deliveries: [createDelivery("handler-a")] }]);
964
+ const [stale] = await claim(["handler-a"], at(0), 100, "worker-1");
965
+ await store.release(stale, at(1));
966
+ const [current] = await claim(["handler-a"], at(2), 100, "worker-1");
967
+ expect(await completeMany(store, [stale, current], at(3))).toEqual([false, true]);
968
+ await append([{ event: createEnvelope(), deliveries: [createDelivery("handler-b")] }]);
969
+ const [staleB] = await claim(["handler-b"], at(4), 100, "worker-1");
970
+ await store.release(staleB, at(5));
971
+ const [currentB] = await claim(["handler-b"], at(6), 100, "worker-1");
972
+ expect(await completeMany(store, [currentB, staleB], at(7))).toEqual([true, false]);
973
+ });
974
+ });
975
+ describe("retention of the deduplication record", () => {
976
+ const DAY = 864e5;
977
+ it("keeps an event for the retention window after its last delivery completed, however old it is", async () => {
978
+ const event = createEnvelope();
979
+ await append([{ event, deliveries: [createDelivery("handler-a")] }]);
980
+ const [delivery] = await claim(["handler-a"], at(30 * DAY));
981
+ expect(await store.complete(delivery, at(30 * DAY + 1))).toBe(true);
982
+ expect(await store.purge({ completedBefore: at(29 * DAY) })).toMatchObject({
983
+ deletedEvents: 0
984
+ });
985
+ const again = await append(
986
+ [{ event, deliveries: [createDelivery("handler-a")] }],
987
+ at(30 * DAY + 2)
988
+ );
989
+ expect(again.appendedDeliveryCount).toBe(0);
990
+ expect(await store.purge({ completedBefore: at(31 * DAY) })).toMatchObject({
991
+ deletedEvents: 1
992
+ });
993
+ const afterwards = await append(
994
+ [{ event, deliveries: [createDelivery("handler-a")] }],
995
+ at(31 * DAY)
996
+ );
997
+ expect(afterwards.appendedEventIds).toEqual([event.id]);
998
+ });
999
+ it("keeps the later completion time of an event when completions are recorded out of order", async () => {
1000
+ const event = createEnvelope();
1001
+ await append([
1002
+ { event, deliveries: [createDelivery("handler-a"), createDelivery("handler-b")] }
1003
+ ]);
1004
+ const claimed = await claim(["handler-a", "handler-b"]);
1005
+ expect(claimed).toHaveLength(2);
1006
+ const [first, second] = claimed.sort(
1007
+ (left, right) => left.handler.localeCompare(right.handler)
1008
+ );
1009
+ expect(await store.complete(first, at(200))).toBe(true);
1010
+ expect(await store.complete(second, at(100))).toBe(true);
1011
+ expect(await store.purge({ completedBefore: at(150) })).toMatchObject({ deletedEvents: 0 });
1012
+ const again = await append(
1013
+ [{ event, deliveries: [createDelivery("handler-a"), createDelivery("handler-b")] }],
1014
+ at(201)
1015
+ );
1016
+ expect(again.appendedDeliveryCount).toBe(0);
1017
+ });
1018
+ it("remembers every handler of an event appended twice in one transaction", async () => {
1019
+ const event = createEnvelope();
1020
+ const result = await store.transaction(async (transaction) => {
1021
+ await store.append(
1022
+ transaction,
1023
+ [{ event, deliveries: [createDelivery("handler-a")] }],
1024
+ at(0)
1025
+ );
1026
+ return store.append(
1027
+ transaction,
1028
+ [{ event, deliveries: [createDelivery("handler-b")] }],
1029
+ at(0)
1030
+ );
1031
+ });
1032
+ expect(result).toMatchObject({ duplicateEventIds: [event.id], appendedDeliveryCount: 1 });
1033
+ const claimed = await claim(["handler-a", "handler-b"]);
1034
+ expect(claimed).toHaveLength(2);
1035
+ for (const delivery of claimed) {
1036
+ expect(await store.complete(delivery, at(1))).toBe(true);
1037
+ }
1038
+ const again = await append(
1039
+ [{ event, deliveries: [createDelivery("handler-a"), createDelivery("handler-b")] }],
1040
+ at(2)
1041
+ );
1042
+ expect(again).toMatchObject({ duplicateEventIds: [event.id], appendedDeliveryCount: 0 });
1043
+ });
1044
+ it("does not let the dead-letter cutoff remove events whose deliveries completed", async () => {
1045
+ const event = createEnvelope();
1046
+ await append([{ event, deliveries: [createDelivery("handler-a")] }]);
1047
+ const [delivery] = await claim(["handler-a"]);
1048
+ await store.complete(delivery, at(1));
1049
+ expect(await store.purge({ deadBefore: at(100 * DAY) })).toMatchObject({
1050
+ deletedEvents: 0
1051
+ });
1052
+ const again = await append([{ event, deliveries: [createDelivery("handler-a")] }], at(2));
1053
+ expect(again.appendedDeliveryCount).toBe(0);
1054
+ });
1055
+ });
1056
+ describe("lost leases", () => {
1057
+ it("hands a delivery of a crashed worker to the next claimer with the attempt counted", async () => {
1058
+ await append(
1059
+ Array.from({ length: 4 }, () => ({
1060
+ event: createEnvelope(),
1061
+ deliveries: [createDelivery("handler-a")]
1062
+ }))
1063
+ );
1064
+ const first = await claim(["handler-a"], at(0), 100, "worker-1");
1065
+ expect(first.map((delivery) => delivery.attempt)).toEqual([1, 1, 1, 1]);
1066
+ expect(await claim(["handler-a"], at(LEASE_MS - 1), 100, "worker-2")).toEqual([]);
1067
+ const second = await claim(["handler-a"], at(LEASE_MS), 100, "worker-2");
1068
+ expect(second.map((delivery) => delivery.deliveryId).sort()).toEqual(
1069
+ first.map((delivery) => delivery.deliveryId).sort()
1070
+ );
1071
+ expect(second.map((delivery) => delivery.attempt)).toEqual([2, 2, 2, 2]);
1072
+ for (const stale of first) {
1073
+ expect(await store.complete(stale, at(LEASE_MS + 1))).toBe(false);
1074
+ }
1075
+ expect(await completeMany(store, first, at(LEASE_MS + 1))).toEqual([
1076
+ false,
1077
+ false,
1078
+ false,
1079
+ false
1080
+ ]);
1081
+ expect(await completeMany(store, second, at(LEASE_MS + 2))).toEqual([
1082
+ true,
1083
+ true,
1084
+ true,
1085
+ true
1086
+ ]);
1087
+ });
1088
+ it("keeps counting attempts of a poison delivery until it is dead-lettered", async () => {
1089
+ await append([{ event: createEnvelope(), deliveries: [createDelivery("handler-a")] }]);
1090
+ for (let crash = 0; crash < 4; crash += 1) {
1091
+ const [delivery] = await claim(
1092
+ ["handler-a"],
1093
+ at(crash * LEASE_MS),
1094
+ 100,
1095
+ `worker-${crash}`
1096
+ );
1097
+ expect(delivery.attempt).toBe(crash + 1);
1098
+ }
1099
+ const [last] = await claim(["handler-a"], at(4 * LEASE_MS), 100, "worker-4");
1100
+ expect(last.attempt).toBe(5);
1101
+ expect(
1102
+ await store.bury(last, { name: "Error", message: "poison" }, at(4 * LEASE_MS))
1103
+ ).toBe(true);
1104
+ const [dead] = await store.listDeliveries({ status: "dead" });
1105
+ expect(dead).toMatchObject({ attempts: 5, lastError: { message: "poison" } });
1106
+ expect(await claim(["handler-a"], at(100 * LEASE_MS))).toEqual([]);
1107
+ });
1108
+ it("does not count an attempt that was reported as failed twice", async () => {
1109
+ await append([{ event: createEnvelope(), deliveries: [createDelivery("handler-a")] }]);
1110
+ const [delivery] = await claim(["handler-a"], at(0), 100, "worker-1");
1111
+ await store.reschedule(delivery, at(10), { name: "Error", message: "x" }, at(1));
1112
+ const [retry] = await claim(["handler-a"], at(LEASE_MS), 100, "worker-2");
1113
+ expect(retry.attempt).toBe(2);
1114
+ const [afterCrash] = await claim(["handler-a"], at(LEASE_MS * 2), 100, "worker-3");
1115
+ expect(afterCrash.attempt).toBe(3);
1116
+ });
1117
+ it("keeps the order of a key across a takeover", async () => {
1118
+ const [head, next] = await (async () => {
1119
+ const events = [createEnvelope({ key: "k" }), createEnvelope({ key: "k" })];
1120
+ for (const event of events) {
1121
+ await append([{ event, deliveries: [createDelivery("handler-a")] }]);
1122
+ }
1123
+ return events;
1124
+ })();
1125
+ const [stale] = await claim(["handler-a"], at(0), 100, "worker-1");
1126
+ expect(stale.event.id).toBe(head.id);
1127
+ const taken = await claim(["handler-a"], at(LEASE_MS), 100, "worker-2");
1128
+ expect(taken.map((delivery) => delivery.event.id)).toEqual([head.id]);
1129
+ expect(await store.complete(stale, at(LEASE_MS + 1))).toBe(false);
1130
+ expect(await store.complete(taken[0], at(LEASE_MS + 2))).toBe(true);
1131
+ const [following] = await claim(["handler-a"], at(LEASE_MS + 3), 100, "worker-2");
1132
+ expect(following.event.id).toBe(next.id);
1133
+ });
1134
+ it("lets every worker see only deliveries nobody else holds, and none twice", async () => {
1135
+ await append(
1136
+ Array.from({ length: 30 }, () => ({
1137
+ event: createEnvelope(),
1138
+ deliveries: [createDelivery("handler-a")]
1139
+ }))
1140
+ );
1141
+ const seen = [];
1142
+ for (let round = 0; round < 40; round += 1) {
1143
+ const owners = ["worker-1", "worker-2", "worker-3"];
1144
+ const batches = await Promise.all(
1145
+ owners.map((owner) => claim(["handler-a"], at(round), 4, owner))
1146
+ );
1147
+ for (const batch of batches) {
1148
+ seen.push(...batch.map((delivery) => delivery.deliveryId));
1149
+ expect(await completeMany(store, batch, at(round))).toEqual(batch.map(() => true));
1150
+ }
1151
+ }
1152
+ expect(seen).toHaveLength(30);
1153
+ expect(new Set(seen).size).toBe(30);
1154
+ });
1155
+ });
1156
+ describe("relinquish", () => {
1157
+ it("hands idle leases to other workers at once and keeps leases with running work", async (context) => {
1158
+ if (store.relinquish === void 0) {
1159
+ return context.skip();
1160
+ }
1161
+ await append([
1162
+ { event: createEnvelope(), deliveries: [createDelivery("handler-a")] },
1163
+ { event: createEnvelope(), deliveries: [createDelivery("handler-a")] }
1164
+ ]);
1165
+ const claimed = await claim(["handler-a"], at(0), 100, "worker-1");
1166
+ expect(claimed).toHaveLength(2);
1167
+ await store.relinquish("worker-1", at(1));
1168
+ expect(await store.complete(claimed[0], at(2))).toBe(true);
1169
+ expect(await store.complete(claimed[1], at(2))).toBe(true);
1170
+ await store.relinquish("worker-1", at(3));
1171
+ await append(
1172
+ [{ event: createEnvelope(), deliveries: [createDelivery("handler-a")] }],
1173
+ at(4)
1174
+ );
1175
+ expect(await claim(["handler-a"], at(4), 100, "worker-2")).toHaveLength(1);
1176
+ });
1177
+ it("does not give away a delivery that is still running", async (context) => {
1178
+ if (store.relinquish === void 0) {
1179
+ return context.skip();
1180
+ }
1181
+ await append([{ event: createEnvelope(), deliveries: [createDelivery("handler-a")] }]);
1182
+ const [running] = await claim(["handler-a"], at(0), 100, "worker-1");
1183
+ await store.relinquish("worker-1", at(1));
1184
+ expect(await claim(["handler-a"], at(2), 100, "worker-2")).toEqual([]);
1185
+ expect(await store.complete(running, at(3))).toBe(true);
1186
+ });
1187
+ });
769
1188
  describe.skipIf(options.createPeerStore === void 0)("across store instances", () => {
770
1189
  let peer;
771
1190
  beforeEach(async () => {
@@ -810,6 +1229,32 @@ function eventStoreContract(name, options) {
810
1229
  expect(await store.complete(stale, at(LEASE_MS + 1))).toBe(false);
811
1230
  expect(await peer.complete(current, at(LEASE_MS + 1))).toBe(true);
812
1231
  });
1232
+ it("completes batches in either process and fences the one that lost its lease", async () => {
1233
+ await append(
1234
+ Array.from({ length: 6 }, () => ({
1235
+ event: createEnvelope(),
1236
+ deliveries: [createDelivery("handler-a")]
1237
+ }))
1238
+ );
1239
+ const request = (owner, now) => ({
1240
+ handlers: ["handler-a"],
1241
+ limit: 100,
1242
+ leaseOwner: owner,
1243
+ leaseExpiresAt: new Date(now.getTime() + LEASE_MS),
1244
+ now
1245
+ });
1246
+ const stale = await store.claim(request("primary", at(0)));
1247
+ const current = await peer.claim(request("peer", at(LEASE_MS)));
1248
+ expect(current).toHaveLength(6);
1249
+ expect(await completeMany(store, stale, at(LEASE_MS + 1))).toEqual(stale.map(() => false));
1250
+ expect(await completeMany(peer, current, at(LEASE_MS + 1))).toEqual(
1251
+ current.map(() => true)
1252
+ );
1253
+ expect(await store.statistics(at(LEASE_MS + 2))).toMatchObject({
1254
+ pending: 0,
1255
+ processing: 0
1256
+ });
1257
+ });
813
1258
  });
814
1259
  });
815
1260
  }