@relaybus/testing 0.1.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/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # @relaybus/testing
2
2
 
3
+ @relaybus/testing is the test toolkit of relaybus, a reliable, storage-agnostic event bus for NestJS and Node.js: it provides the EventStore contract suite, a `FakeClock` and an in-memory transport for deterministic tests of retries and delivery.
4
+
3
5
  Test utilities for relaybus.
4
6
 
5
7
  ```sh
@@ -25,9 +27,18 @@ eventStoreContract('MyEventStore', {
25
27
  });
26
28
  ```
27
29
 
28
- It covers transactional visibility, idempotent appends, leasing and fencing,
29
- retries and dead letters, release, per-key ordering in commit order, starvation,
30
- purge, statistics, data fidelity and cross-instance claim races.
30
+ It covers transactional visibility, idempotent appends (a handler that already
31
+ completed is never delivered again), leasing and fencing, batched completion
32
+ (`completeMany`), lost leases (redelivery, attempt counting for crashed workers,
33
+ poison deliveries), retries and dead letters, release, relinquish, per-key
34
+ ordering in commit order, starvation, purge, statistics, data fidelity and
35
+ cross-instance claim races.
36
+
37
+ A store may lease groups of deliveries (lanes) instead of single deliveries, so
38
+ the contract never assumes that two claimers see each other's unclaimed work: a
39
+ claimer that keeps claiming drains the workload. Pass `keepsCompletedRecords:
40
+ true` when the store keeps a completion history; run the suite once per mode the
41
+ store supports.
31
42
 
32
43
  ## FakeClock
33
44
 
@@ -43,6 +54,31 @@ await bus.drain();
43
54
  Records publishes, simulates outages with `failNextPublishes(n)` and delivers
44
55
  JSON copies to subscribers with `deliver(destination, envelope, maxRedeliveries)`.
45
56
 
57
+ ## When to use @relaybus/testing, and when not to
58
+
59
+ Use it in your test suite for fast, deterministic tests, or to prove that a custom `EventStore` satisfies the contract. It is a development dependency and requires `vitest` 3 or newer.
60
+
61
+ Documentation: <https://berkcansavur.github.io/relaybus/packages/testing>
62
+
63
+ ### relaybus vs alternatives
64
+
65
+ <!-- relaybus:snippet:comparison -->
66
+
67
+ | | What it is | Survives a restart | Written in your database transaction | Choose it when |
68
+ | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
69
+ | **relaybus** | Event bus built on the transactional outbox: PostgreSQL and MongoDB stores, Kafka and RabbitMQ transports, per-handler deliveries | Yes, events live in your database | Yes | Events must never be lost or phantom, and handlers need retries, dead letters and ordering keys |
70
+ | [`@nestjs/event-emitter`](https://docs.nestjs.com/techniques/events) | In-process event emitter built on `eventemitter2` | No persistence is documented | No | Simple in-process notifications where losing an event on restart is acceptable |
71
+ | [`@nestjs/cqrs`](https://docs.nestjs.com/recipes/cqrs) | CommandBus, QueryBus, EventBus and sagas; the docs say sagas and event handlers run in memory | No built-in persistence | No | You want CQRS structure; it can sit on top of an outbox |
72
+ | [`@nestjs/outbox`](https://github.com/nestjs/outbox) | Transactional outbox module from the NestJS organisation: PostgreSQL, MySQL and in-memory stores, relay with leases and retries, dead-letter table, inbox | Yes with the database stores | Yes | You need MySQL, or a NestJS-organisation module; compare features with relaybus before choosing |
73
+ | [BullMQ](https://docs.bullmq.io/) | Job queue on Redis with retries, delayed jobs and flows; at-least-once in the worst case | Yes, in Redis | No, jobs are stored in Redis and cannot join a SQL or MongoDB transaction | Your stack is Redis-based and background jobs matter more than event atomicity |
74
+ | [pg-boss](https://github.com/timgit/pg-boss) | Job queue on PostgreSQL using `SKIP LOCKED`, with retries, dead letter queues, scheduling and pub/sub; jobs can be created in your own transaction | Yes, in PostgreSQL | Yes, through its database adapters | You want a PostgreSQL job queue with scheduling; relaybus is an event bus with per-handler deliveries and transports |
75
+ | Kafka client directly | A distributed log; the idempotent producer and transactions give exactly-once within Kafka | Yes, in Kafka | No, producing to Kafka is a second write | You consume and produce only inside Kafka; add an outbox when a database write must be announced |
76
+
77
+ This table summarises the vendors' own documentation as of October 2026; check their docs for current details.
78
+ Full comparison and sources: <https://berkcansavur.github.io/relaybus/comparison>.
79
+
80
+ <!-- relaybus:end -->
81
+
46
82
  ## License
47
83
 
48
84
  MIT
package/dist/index.cjs CHANGED
@@ -34,6 +34,7 @@ function createDelivery(handler, availableAt = at(0)) {
34
34
  return { id: core.createUuidV7(), handler, availableAt };
35
35
  }
36
36
  function eventStoreContract(name, options) {
37
+ const keepsCompleted = options.keepsCompletedRecords ?? false;
37
38
  vitest.describe(`${name} satisfies the EventStore contract`, () => {
38
39
  let store;
39
40
  vitest.beforeEach(async () => {
@@ -45,6 +46,16 @@ function eventStoreContract(name, options) {
45
46
  async function append(entries, now = at(0)) {
46
47
  return store.transaction((transaction) => store.append(transaction, entries, now));
47
48
  }
49
+ async function completeMany(target, leases, now) {
50
+ if (target.completeMany !== void 0) {
51
+ return target.completeMany(leases, now);
52
+ }
53
+ const outcomes = [];
54
+ for (const lease of leases) {
55
+ outcomes.push(await target.complete(lease, now));
56
+ }
57
+ return outcomes;
58
+ }
48
59
  async function claim(handlers, now = at(0), limit = 100, owner = "worker-1") {
49
60
  return store.claim({
50
61
  handlers,
@@ -221,12 +232,19 @@ function eventStoreContract(name, options) {
221
232
  }));
222
233
  await append(entries);
223
234
  const batches = await Promise.all(
224
- Array.from(
225
- { length: 8 },
226
- (_, index) => claim(["handler-a"], at(0), 10, `worker-${index}`)
227
- )
235
+ Array.from({ length: 8 }, async (_, index) => {
236
+ const own = [];
237
+ for (; ; ) {
238
+ const batch = await claim(["handler-a"], at(0), 10, `worker-${index}`);
239
+ if (batch.length === 0) {
240
+ return own;
241
+ }
242
+ own.push(...batch);
243
+ }
244
+ })
228
245
  );
229
- const claimedIds = batches.flat().map((delivery) => delivery.deliveryId);
246
+ const leftovers = await claim(["handler-a"], at(0), 100, "worker-late");
247
+ const claimedIds = [...batches.flat(), ...leftovers].map((delivery) => delivery.deliveryId);
230
248
  vitest.expect(new Set(claimedIds).size).toBe(claimedIds.length);
231
249
  vitest.expect(claimedIds).toHaveLength(40);
232
250
  });
@@ -245,6 +263,10 @@ function eventStoreContract(name, options) {
245
263
  false
246
264
  );
247
265
  vitest.expect(await store.complete(current, at(LEASE_MS + 2))).toBe(true);
266
+ if (!keepsCompleted) {
267
+ vitest.expect(await store.listDeliveries({})).toEqual([]);
268
+ return;
269
+ }
248
270
  const [record] = await store.listDeliveries({});
249
271
  vitest.expect(record).toMatchObject({
250
272
  status: "completed",
@@ -285,7 +307,11 @@ function eventStoreContract(name, options) {
285
307
  await store.transaction(async (transaction) => {
286
308
  vitest.expect(await store.complete(delivery, at(2), transaction)).toBe(true);
287
309
  });
288
- vitest.expect((await store.listDeliveries({}))[0].status).toBe("completed");
310
+ if (keepsCompleted) {
311
+ vitest.expect((await store.listDeliveries({}))[0].status).toBe("completed");
312
+ } else {
313
+ vitest.expect(await store.listDeliveries({})).toEqual([]);
314
+ }
289
315
  });
290
316
  vitest.it("refuses a transactional completion with a lost lease", async () => {
291
317
  await append([{ event: createEnvelope(), deliveries: [createDelivery("handler-a")] }]);
@@ -459,7 +485,11 @@ function eventStoreContract(name, options) {
459
485
  });
460
486
  vitest.expect((await store.statistics(at(LEASE_MS))).oldestDueAt).toEqual(at(200));
461
487
  await store.complete(processing, at(1));
462
- vitest.expect(await store.statistics(at(0))).toMatchObject({ completed: 1, oldestDueAt: null });
488
+ vitest.expect(await store.statistics(at(0))).toMatchObject({
489
+ completed: keepsCompleted ? 1 : 0,
490
+ processing: 0,
491
+ oldestDueAt: null
492
+ });
463
493
  });
464
494
  vitest.it("counts an expired lease as due", async () => {
465
495
  await append([
@@ -471,7 +501,7 @@ function eventStoreContract(name, options) {
471
501
  });
472
502
  });
473
503
  vitest.describe("purge", () => {
474
- vitest.it("deletes old completed and dead deliveries and their orphaned events", async () => {
504
+ async function appendDoneFailedShared() {
475
505
  const done = createEnvelope();
476
506
  const failed = createEnvelope();
477
507
  const shared = createEnvelope();
@@ -486,23 +516,52 @@ function eventStoreContract(name, options) {
486
516
  await store.bury(failedDelivery, { name: "Error", message: "x" }, at(10));
487
517
  const [sharedDelivery] = await claim(["handler-c"]);
488
518
  await store.complete(sharedDelivery, at(10));
489
- vitest.expect(await store.purge({})).toEqual({ deletedDeliveries: 0, deletedEvents: 0 });
490
- vitest.expect(await store.purge({ completedBefore: at(10) })).toEqual({
491
- deletedDeliveries: 0,
492
- deletedEvents: 0
493
- });
494
- vitest.expect(await store.purge({ completedBefore: at(11) })).toEqual({
495
- deletedDeliveries: 2,
496
- deletedEvents: 1
497
- });
498
- vitest.expect(await store.purge({ deadBefore: at(11) })).toEqual({
499
- deletedDeliveries: 1,
500
- deletedEvents: 1
501
- });
502
- const remaining = await store.listDeliveries({});
503
- vitest.expect(remaining.map((record) => record.handler)).toEqual(["handler-d"]);
504
- vitest.expect(remaining[0].event.id).toBe(shared.id);
505
- });
519
+ return { shared };
520
+ }
521
+ vitest.it.runIf(keepsCompleted)(
522
+ "deletes old completed and dead deliveries and their orphaned events",
523
+ async () => {
524
+ const { shared } = await appendDoneFailedShared();
525
+ vitest.expect(await store.purge({})).toEqual({ deletedDeliveries: 0, deletedEvents: 0 });
526
+ vitest.expect(await store.purge({ completedBefore: at(10) })).toEqual({
527
+ deletedDeliveries: 0,
528
+ deletedEvents: 0
529
+ });
530
+ vitest.expect(await store.purge({ completedBefore: at(11) })).toEqual({
531
+ deletedDeliveries: 2,
532
+ deletedEvents: 1
533
+ });
534
+ vitest.expect(await store.purge({ deadBefore: at(11) })).toEqual({
535
+ deletedDeliveries: 1,
536
+ deletedEvents: 1
537
+ });
538
+ const remaining = await store.listDeliveries({});
539
+ vitest.expect(remaining.map((record) => record.handler)).toEqual(["handler-d"]);
540
+ vitest.expect(remaining[0].event.id).toBe(shared.id);
541
+ }
542
+ );
543
+ vitest.it.runIf(!keepsCompleted)(
544
+ "deletes old dead deliveries and the events no delivery references any more",
545
+ async () => {
546
+ const { shared } = await appendDoneFailedShared();
547
+ vitest.expect(await store.purge({})).toEqual({ deletedDeliveries: 0, deletedEvents: 0 });
548
+ vitest.expect(await store.purge({ completedBefore: at(0) })).toEqual({
549
+ deletedDeliveries: 0,
550
+ deletedEvents: 0
551
+ });
552
+ vitest.expect(await store.purge({ completedBefore: at(11) })).toEqual({
553
+ deletedDeliveries: 0,
554
+ deletedEvents: 1
555
+ });
556
+ vitest.expect(await store.purge({ deadBefore: at(11) })).toEqual({
557
+ deletedDeliveries: 1,
558
+ deletedEvents: 1
559
+ });
560
+ const remaining = await store.listDeliveries({});
561
+ vitest.expect(remaining.map((record) => record.handler)).toEqual(["handler-d"]);
562
+ vitest.expect(remaining[0].event.id).toBe(shared.id);
563
+ }
564
+ );
506
565
  vitest.it("lets a purged event id be appended again", async () => {
507
566
  const event = createEnvelope();
508
567
  await append([{ event, deliveries: [createDelivery("handler-a")] }]);
@@ -768,6 +827,366 @@ function eventStoreContract(name, options) {
768
827
  vitest.expect(listed.event.headers).toEqual({ a: "1" });
769
828
  });
770
829
  });
830
+ vitest.describe("completion", () => {
831
+ vitest.it("removes a completed delivery from every listing", async () => {
832
+ const event = createEnvelope();
833
+ await append([
834
+ { event, deliveries: [createDelivery("handler-a"), createDelivery("handler-b")] }
835
+ ]);
836
+ const [delivery] = await claim(["handler-a"]);
837
+ vitest.expect(await store.complete(delivery, at(5))).toBe(true);
838
+ const records = await store.listDeliveries({ eventId: event.id });
839
+ if (keepsCompleted) {
840
+ vitest.expect(records.map((record) => [record.handler, record.status]).sort()).toEqual([
841
+ ["handler-a", "completed"],
842
+ ["handler-b", "pending"]
843
+ ]);
844
+ const [completed] = await store.listDeliveries({ status: "completed" });
845
+ vitest.expect(completed).toMatchObject({ handler: "handler-a", attempts: 1 });
846
+ vitest.expect(completed.completedAt).toEqual(at(5));
847
+ } else {
848
+ vitest.expect(records.map((record) => [record.handler, record.status])).toEqual([
849
+ ["handler-b", "pending"]
850
+ ]);
851
+ vitest.expect(await store.listDeliveries({ status: "completed" })).toEqual([]);
852
+ }
853
+ vitest.expect(await claim(["handler-a"], at(LEASE_MS * 10))).toEqual([]);
854
+ vitest.expect(await store.statistics(at(0))).toMatchObject({
855
+ pending: 1,
856
+ processing: 0,
857
+ completed: keepsCompleted ? 1 : 0
858
+ });
859
+ });
860
+ vitest.it("does not create the delivery of a handler that already completed again", async () => {
861
+ const event = createEnvelope();
862
+ await append([{ event, deliveries: [createDelivery("handler-a")] }]);
863
+ const [delivery] = await claim(["handler-a"]);
864
+ await store.complete(delivery, at(1));
865
+ const result = await append([
866
+ { event, deliveries: [createDelivery("handler-a"), createDelivery("handler-b")] }
867
+ ]);
868
+ vitest.expect(result).toEqual({
869
+ appendedEventIds: [],
870
+ duplicateEventIds: [event.id],
871
+ appendedDeliveryCount: 1
872
+ });
873
+ vitest.expect(await claim(["handler-a"], at(LEASE_MS * 10))).toEqual([]);
874
+ vitest.expect(await claim(["handler-b"], at(LEASE_MS * 10))).toHaveLength(1);
875
+ const again = await append([{ event, deliveries: [createDelivery("handler-a")] }]);
876
+ vitest.expect(again.appendedDeliveryCount).toBe(0);
877
+ });
878
+ vitest.it("completes many leases at once and reports each one", async () => {
879
+ await append(
880
+ Array.from({ length: 5 }, () => ({
881
+ event: createEnvelope(),
882
+ deliveries: [createDelivery("handler-a")]
883
+ }))
884
+ );
885
+ const claimed = await claim(["handler-a"]);
886
+ vitest.expect(claimed).toHaveLength(5);
887
+ vitest.expect(await completeMany(store, [], at(1))).toEqual([]);
888
+ vitest.expect(await completeMany(store, claimed.slice(0, 3), at(1))).toEqual([true, true, true]);
889
+ vitest.expect(await store.statistics(at(2))).toMatchObject({ processing: 2, pending: 0 });
890
+ vitest.expect(await completeMany(store, claimed, at(2))).toEqual([
891
+ false,
892
+ false,
893
+ false,
894
+ true,
895
+ true
896
+ ]);
897
+ vitest.expect(await store.statistics(at(3))).toMatchObject({ processing: 0, pending: 0 });
898
+ });
899
+ vitest.it("completes a lease listed twice only once", async () => {
900
+ await append([{ event: createEnvelope(), deliveries: [createDelivery("handler-a")] }]);
901
+ const [delivery] = await claim(["handler-a"]);
902
+ vitest.expect(await completeMany(store, [delivery, delivery], at(1))).toEqual([true, false]);
903
+ });
904
+ vitest.it("fences every lease of a batch on its own", async () => {
905
+ await append(
906
+ Array.from({ length: 3 }, () => ({
907
+ event: createEnvelope(),
908
+ deliveries: [createDelivery("handler-a")]
909
+ }))
910
+ );
911
+ const stale = await claim(["handler-a"], at(0));
912
+ const current = await claim(["handler-a"], at(LEASE_MS), 100, "worker-2");
913
+ vitest.expect(current).toHaveLength(3);
914
+ await append(
915
+ [{ event: createEnvelope(), deliveries: [createDelivery("handler-a")] }],
916
+ at(1)
917
+ );
918
+ const [fresh] = await claim(["handler-a"], at(LEASE_MS + 1), 100, "worker-2");
919
+ vitest.expect(
920
+ await completeMany(store, [stale[0], fresh, stale[1], current[2]], at(LEASE_MS + 2))
921
+ ).toEqual([false, true, false, true]);
922
+ vitest.expect(await store.statistics(at(LEASE_MS + 3))).toMatchObject({ processing: 2 });
923
+ });
924
+ vitest.it("does not complete a lease of a delivery that went back to pending", async () => {
925
+ const error = { name: "Error", message: "x" };
926
+ await append([
927
+ { event: createEnvelope(), deliveries: [createDelivery("a"), createDelivery("b")] }
928
+ ]);
929
+ const [retrying] = await claim(["a"]);
930
+ const [released] = await claim(["b"]);
931
+ await store.reschedule(retrying, at(1e3), error, at(1));
932
+ await store.release(released, at(1));
933
+ vitest.expect(await completeMany(store, [retrying, released], at(2))).toEqual([false, false]);
934
+ vitest.expect(await store.statistics(at(2))).toMatchObject({ pending: 2, processing: 0 });
935
+ });
936
+ });
937
+ vitest.describe("stale leases of one delivery", () => {
938
+ const error = { name: "Error", message: "x" };
939
+ vitest.it("refuses the lease of an attempt that was released when the same owner claims it again", async () => {
940
+ await append([{ event: createEnvelope(), deliveries: [createDelivery("handler-a")] }]);
941
+ const [stale] = await claim(["handler-a"], at(0), 100, "worker-1");
942
+ vitest.expect(await store.release(stale, at(1))).toBe(true);
943
+ const [current] = await claim(["handler-a"], at(2), 100, "worker-1");
944
+ vitest.expect(current.deliveryId).toBe(stale.deliveryId);
945
+ vitest.expect(current.leaseToken).not.toBe(stale.leaseToken);
946
+ vitest.expect(await store.complete(stale, at(3))).toBe(false);
947
+ vitest.expect(await store.bury(stale, error, at(3))).toBe(false);
948
+ vitest.expect(await store.reschedule(stale, at(9), error, at(3))).toBe(false);
949
+ vitest.expect(await store.release(stale, at(3))).toBe(false);
950
+ vitest.expect(await store.extendLease(stale, at(LEASE_MS * 3), at(3))).toBe(false);
951
+ vitest.expect(await store.complete(current, at(4))).toBe(true);
952
+ });
953
+ vitest.it("refuses the lease of an attempt that was rescheduled when the same owner claims it again", async () => {
954
+ await append([{ event: createEnvelope(), deliveries: [createDelivery("handler-a")] }]);
955
+ const [stale] = await claim(["handler-a"], at(0), 100, "worker-1");
956
+ vitest.expect(await store.reschedule(stale, at(10), error, at(1))).toBe(true);
957
+ const [current] = await claim(["handler-a"], at(10), 100, "worker-1");
958
+ vitest.expect(current).toMatchObject({ deliveryId: stale.deliveryId, attempt: 2 });
959
+ vitest.expect(await store.complete(stale, at(11))).toBe(false);
960
+ vitest.expect(await store.bury(stale, error, at(11))).toBe(false);
961
+ vitest.expect(await completeMany(store, [stale], at(11))).toEqual([false]);
962
+ vitest.expect(await store.complete(current, at(12))).toBe(true);
963
+ });
964
+ vitest.it("matches the results of completeMany to the lease, not to the delivery id", async () => {
965
+ await append([{ event: createEnvelope(), deliveries: [createDelivery("handler-a")] }]);
966
+ const [stale] = await claim(["handler-a"], at(0), 100, "worker-1");
967
+ await store.release(stale, at(1));
968
+ const [current] = await claim(["handler-a"], at(2), 100, "worker-1");
969
+ vitest.expect(await completeMany(store, [stale, current], at(3))).toEqual([false, true]);
970
+ await append([{ event: createEnvelope(), deliveries: [createDelivery("handler-b")] }]);
971
+ const [staleB] = await claim(["handler-b"], at(4), 100, "worker-1");
972
+ await store.release(staleB, at(5));
973
+ const [currentB] = await claim(["handler-b"], at(6), 100, "worker-1");
974
+ vitest.expect(await completeMany(store, [currentB, staleB], at(7))).toEqual([true, false]);
975
+ });
976
+ });
977
+ vitest.describe("retention of the deduplication record", () => {
978
+ const DAY = 864e5;
979
+ vitest.it("keeps an event for the retention window after its last delivery completed, however old it is", async () => {
980
+ const event = createEnvelope();
981
+ await append([{ event, deliveries: [createDelivery("handler-a")] }]);
982
+ const [delivery] = await claim(["handler-a"], at(30 * DAY));
983
+ vitest.expect(await store.complete(delivery, at(30 * DAY + 1))).toBe(true);
984
+ vitest.expect(await store.purge({ completedBefore: at(29 * DAY) })).toMatchObject({
985
+ deletedEvents: 0
986
+ });
987
+ const again = await append(
988
+ [{ event, deliveries: [createDelivery("handler-a")] }],
989
+ at(30 * DAY + 2)
990
+ );
991
+ vitest.expect(again.appendedDeliveryCount).toBe(0);
992
+ vitest.expect(await store.purge({ completedBefore: at(31 * DAY) })).toMatchObject({
993
+ deletedEvents: 1
994
+ });
995
+ const afterwards = await append(
996
+ [{ event, deliveries: [createDelivery("handler-a")] }],
997
+ at(31 * DAY)
998
+ );
999
+ vitest.expect(afterwards.appendedEventIds).toEqual([event.id]);
1000
+ });
1001
+ vitest.it("keeps the later completion time of an event when completions are recorded out of order", async () => {
1002
+ const event = createEnvelope();
1003
+ await append([
1004
+ { event, deliveries: [createDelivery("handler-a"), createDelivery("handler-b")] }
1005
+ ]);
1006
+ const claimed = await claim(["handler-a", "handler-b"]);
1007
+ vitest.expect(claimed).toHaveLength(2);
1008
+ const [first, second] = claimed.sort(
1009
+ (left, right) => left.handler.localeCompare(right.handler)
1010
+ );
1011
+ vitest.expect(await store.complete(first, at(200))).toBe(true);
1012
+ vitest.expect(await store.complete(second, at(100))).toBe(true);
1013
+ vitest.expect(await store.purge({ completedBefore: at(150) })).toMatchObject({ deletedEvents: 0 });
1014
+ const again = await append(
1015
+ [{ event, deliveries: [createDelivery("handler-a"), createDelivery("handler-b")] }],
1016
+ at(201)
1017
+ );
1018
+ vitest.expect(again.appendedDeliveryCount).toBe(0);
1019
+ });
1020
+ vitest.it("remembers every handler of an event appended twice in one transaction", async () => {
1021
+ const event = createEnvelope();
1022
+ const result = await store.transaction(async (transaction) => {
1023
+ await store.append(
1024
+ transaction,
1025
+ [{ event, deliveries: [createDelivery("handler-a")] }],
1026
+ at(0)
1027
+ );
1028
+ return store.append(
1029
+ transaction,
1030
+ [{ event, deliveries: [createDelivery("handler-b")] }],
1031
+ at(0)
1032
+ );
1033
+ });
1034
+ vitest.expect(result).toMatchObject({ duplicateEventIds: [event.id], appendedDeliveryCount: 1 });
1035
+ const claimed = await claim(["handler-a", "handler-b"]);
1036
+ vitest.expect(claimed).toHaveLength(2);
1037
+ for (const delivery of claimed) {
1038
+ vitest.expect(await store.complete(delivery, at(1))).toBe(true);
1039
+ }
1040
+ const again = await append(
1041
+ [{ event, deliveries: [createDelivery("handler-a"), createDelivery("handler-b")] }],
1042
+ at(2)
1043
+ );
1044
+ vitest.expect(again).toMatchObject({ duplicateEventIds: [event.id], appendedDeliveryCount: 0 });
1045
+ });
1046
+ vitest.it("does not let the dead-letter cutoff remove events whose deliveries completed", async () => {
1047
+ const event = createEnvelope();
1048
+ await append([{ event, deliveries: [createDelivery("handler-a")] }]);
1049
+ const [delivery] = await claim(["handler-a"]);
1050
+ await store.complete(delivery, at(1));
1051
+ vitest.expect(await store.purge({ deadBefore: at(100 * DAY) })).toMatchObject({
1052
+ deletedEvents: 0
1053
+ });
1054
+ const again = await append([{ event, deliveries: [createDelivery("handler-a")] }], at(2));
1055
+ vitest.expect(again.appendedDeliveryCount).toBe(0);
1056
+ });
1057
+ });
1058
+ vitest.describe("lost leases", () => {
1059
+ vitest.it("hands a delivery of a crashed worker to the next claimer with the attempt counted", async () => {
1060
+ await append(
1061
+ Array.from({ length: 4 }, () => ({
1062
+ event: createEnvelope(),
1063
+ deliveries: [createDelivery("handler-a")]
1064
+ }))
1065
+ );
1066
+ const first = await claim(["handler-a"], at(0), 100, "worker-1");
1067
+ vitest.expect(first.map((delivery) => delivery.attempt)).toEqual([1, 1, 1, 1]);
1068
+ vitest.expect(await claim(["handler-a"], at(LEASE_MS - 1), 100, "worker-2")).toEqual([]);
1069
+ const second = await claim(["handler-a"], at(LEASE_MS), 100, "worker-2");
1070
+ vitest.expect(second.map((delivery) => delivery.deliveryId).sort()).toEqual(
1071
+ first.map((delivery) => delivery.deliveryId).sort()
1072
+ );
1073
+ vitest.expect(second.map((delivery) => delivery.attempt)).toEqual([2, 2, 2, 2]);
1074
+ for (const stale of first) {
1075
+ vitest.expect(await store.complete(stale, at(LEASE_MS + 1))).toBe(false);
1076
+ }
1077
+ vitest.expect(await completeMany(store, first, at(LEASE_MS + 1))).toEqual([
1078
+ false,
1079
+ false,
1080
+ false,
1081
+ false
1082
+ ]);
1083
+ vitest.expect(await completeMany(store, second, at(LEASE_MS + 2))).toEqual([
1084
+ true,
1085
+ true,
1086
+ true,
1087
+ true
1088
+ ]);
1089
+ });
1090
+ vitest.it("keeps counting attempts of a poison delivery until it is dead-lettered", async () => {
1091
+ await append([{ event: createEnvelope(), deliveries: [createDelivery("handler-a")] }]);
1092
+ for (let crash = 0; crash < 4; crash += 1) {
1093
+ const [delivery] = await claim(
1094
+ ["handler-a"],
1095
+ at(crash * LEASE_MS),
1096
+ 100,
1097
+ `worker-${crash}`
1098
+ );
1099
+ vitest.expect(delivery.attempt).toBe(crash + 1);
1100
+ }
1101
+ const [last] = await claim(["handler-a"], at(4 * LEASE_MS), 100, "worker-4");
1102
+ vitest.expect(last.attempt).toBe(5);
1103
+ vitest.expect(
1104
+ await store.bury(last, { name: "Error", message: "poison" }, at(4 * LEASE_MS))
1105
+ ).toBe(true);
1106
+ const [dead] = await store.listDeliveries({ status: "dead" });
1107
+ vitest.expect(dead).toMatchObject({ attempts: 5, lastError: { message: "poison" } });
1108
+ vitest.expect(await claim(["handler-a"], at(100 * LEASE_MS))).toEqual([]);
1109
+ });
1110
+ vitest.it("does not count an attempt that was reported as failed twice", async () => {
1111
+ await append([{ event: createEnvelope(), deliveries: [createDelivery("handler-a")] }]);
1112
+ const [delivery] = await claim(["handler-a"], at(0), 100, "worker-1");
1113
+ await store.reschedule(delivery, at(10), { name: "Error", message: "x" }, at(1));
1114
+ const [retry] = await claim(["handler-a"], at(LEASE_MS), 100, "worker-2");
1115
+ vitest.expect(retry.attempt).toBe(2);
1116
+ const [afterCrash] = await claim(["handler-a"], at(LEASE_MS * 2), 100, "worker-3");
1117
+ vitest.expect(afterCrash.attempt).toBe(3);
1118
+ });
1119
+ vitest.it("keeps the order of a key across a takeover", async () => {
1120
+ const [head, next] = await (async () => {
1121
+ const events = [createEnvelope({ key: "k" }), createEnvelope({ key: "k" })];
1122
+ for (const event of events) {
1123
+ await append([{ event, deliveries: [createDelivery("handler-a")] }]);
1124
+ }
1125
+ return events;
1126
+ })();
1127
+ const [stale] = await claim(["handler-a"], at(0), 100, "worker-1");
1128
+ vitest.expect(stale.event.id).toBe(head.id);
1129
+ const taken = await claim(["handler-a"], at(LEASE_MS), 100, "worker-2");
1130
+ vitest.expect(taken.map((delivery) => delivery.event.id)).toEqual([head.id]);
1131
+ vitest.expect(await store.complete(stale, at(LEASE_MS + 1))).toBe(false);
1132
+ vitest.expect(await store.complete(taken[0], at(LEASE_MS + 2))).toBe(true);
1133
+ const [following] = await claim(["handler-a"], at(LEASE_MS + 3), 100, "worker-2");
1134
+ vitest.expect(following.event.id).toBe(next.id);
1135
+ });
1136
+ vitest.it("lets every worker see only deliveries nobody else holds, and none twice", async () => {
1137
+ await append(
1138
+ Array.from({ length: 30 }, () => ({
1139
+ event: createEnvelope(),
1140
+ deliveries: [createDelivery("handler-a")]
1141
+ }))
1142
+ );
1143
+ const seen = [];
1144
+ for (let round = 0; round < 40; round += 1) {
1145
+ const owners = ["worker-1", "worker-2", "worker-3"];
1146
+ const batches = await Promise.all(
1147
+ owners.map((owner) => claim(["handler-a"], at(round), 4, owner))
1148
+ );
1149
+ for (const batch of batches) {
1150
+ seen.push(...batch.map((delivery) => delivery.deliveryId));
1151
+ vitest.expect(await completeMany(store, batch, at(round))).toEqual(batch.map(() => true));
1152
+ }
1153
+ }
1154
+ vitest.expect(seen).toHaveLength(30);
1155
+ vitest.expect(new Set(seen).size).toBe(30);
1156
+ });
1157
+ });
1158
+ vitest.describe("relinquish", () => {
1159
+ vitest.it("hands idle leases to other workers at once and keeps leases with running work", async (context) => {
1160
+ if (store.relinquish === void 0) {
1161
+ return context.skip();
1162
+ }
1163
+ await append([
1164
+ { event: createEnvelope(), deliveries: [createDelivery("handler-a")] },
1165
+ { event: createEnvelope(), deliveries: [createDelivery("handler-a")] }
1166
+ ]);
1167
+ const claimed = await claim(["handler-a"], at(0), 100, "worker-1");
1168
+ vitest.expect(claimed).toHaveLength(2);
1169
+ await store.relinquish("worker-1", at(1));
1170
+ vitest.expect(await store.complete(claimed[0], at(2))).toBe(true);
1171
+ vitest.expect(await store.complete(claimed[1], at(2))).toBe(true);
1172
+ await store.relinquish("worker-1", at(3));
1173
+ await append(
1174
+ [{ event: createEnvelope(), deliveries: [createDelivery("handler-a")] }],
1175
+ at(4)
1176
+ );
1177
+ vitest.expect(await claim(["handler-a"], at(4), 100, "worker-2")).toHaveLength(1);
1178
+ });
1179
+ vitest.it("does not give away a delivery that is still running", async (context) => {
1180
+ if (store.relinquish === void 0) {
1181
+ return context.skip();
1182
+ }
1183
+ await append([{ event: createEnvelope(), deliveries: [createDelivery("handler-a")] }]);
1184
+ const [running] = await claim(["handler-a"], at(0), 100, "worker-1");
1185
+ await store.relinquish("worker-1", at(1));
1186
+ vitest.expect(await claim(["handler-a"], at(2), 100, "worker-2")).toEqual([]);
1187
+ vitest.expect(await store.complete(running, at(3))).toBe(true);
1188
+ });
1189
+ });
771
1190
  vitest.describe.skipIf(options.createPeerStore === void 0)("across store instances", () => {
772
1191
  let peer;
773
1192
  vitest.beforeEach(async () => {
@@ -812,6 +1231,32 @@ function eventStoreContract(name, options) {
812
1231
  vitest.expect(await store.complete(stale, at(LEASE_MS + 1))).toBe(false);
813
1232
  vitest.expect(await peer.complete(current, at(LEASE_MS + 1))).toBe(true);
814
1233
  });
1234
+ vitest.it("completes batches in either process and fences the one that lost its lease", async () => {
1235
+ await append(
1236
+ Array.from({ length: 6 }, () => ({
1237
+ event: createEnvelope(),
1238
+ deliveries: [createDelivery("handler-a")]
1239
+ }))
1240
+ );
1241
+ const request = (owner, now) => ({
1242
+ handlers: ["handler-a"],
1243
+ limit: 100,
1244
+ leaseOwner: owner,
1245
+ leaseExpiresAt: new Date(now.getTime() + LEASE_MS),
1246
+ now
1247
+ });
1248
+ const stale = await store.claim(request("primary", at(0)));
1249
+ const current = await peer.claim(request("peer", at(LEASE_MS)));
1250
+ vitest.expect(current).toHaveLength(6);
1251
+ vitest.expect(await completeMany(store, stale, at(LEASE_MS + 1))).toEqual(stale.map(() => false));
1252
+ vitest.expect(await completeMany(peer, current, at(LEASE_MS + 1))).toEqual(
1253
+ current.map(() => true)
1254
+ );
1255
+ vitest.expect(await store.statistics(at(LEASE_MS + 2))).toMatchObject({
1256
+ pending: 0,
1257
+ processing: 0
1258
+ });
1259
+ });
815
1260
  });
816
1261
  });
817
1262
  }