@relaybus/mongodb 0.3.0 → 0.4.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
@@ -1,9 +1,7 @@
1
1
  import { createUuidV7, ConfigurationError, EventValidationError } from '@relaybus/core';
2
- import { assertPositiveInteger } from '@relaybus/core/internal';
2
+ import { LaneLeaseTokenFormat, PositiveInteger, HandedOutLedger, OwnerSerializedClaims, CompositeKey, LeaseSettlement, DeliveryLeaseIdentity } from '@relaybus/core/internal';
3
3
 
4
- // src/mongo-event-store.ts
5
-
6
- // src/lanes.ts
4
+ // src/documents.ts
7
5
  var LANE_COUNT = 256;
8
6
  var FNV_OFFSET_BASIS = 2166136261;
9
7
  var FNV_PRIME = 16777619;
@@ -15,16 +13,20 @@ function laneOf(key, eventId) {
15
13
  }
16
14
  return hash & LANE_COUNT - 1;
17
15
  }
16
+ var LEASE_TOKENS = new LaneLeaseTokenFormat({
17
+ laneCount: LANE_COUNT,
18
+ epochDigits: 15,
19
+ claimsDigits: 15
20
+ });
18
21
  function formatLeaseToken(laneId, { epoch, claims }) {
19
- return `${laneId}:${epoch}:${claims}`;
22
+ return LEASE_TOKENS.format(laneId, { epoch, claims });
20
23
  }
21
- var LEASE_TOKEN_PATTERN = /^(\d{1,3}):(\d{1,15}):(\d{1,15})$/;
22
24
  function parseLeaseToken(token) {
23
- const match = LEASE_TOKEN_PATTERN.exec(token);
24
- if (match === null || Number(match[1]) >= LANE_COUNT) {
25
+ const parts = LEASE_TOKENS.parse(token);
26
+ if (parts === void 0) {
25
27
  return void 0;
26
28
  }
27
- return { laneId: Number(match[1]), epoch: Number(match[2]), claims: Number(match[3]) };
29
+ return { laneId: parts.lane, epoch: Number(parts.epoch), claims: parts.claims };
28
30
  }
29
31
 
30
32
  // src/documents.ts
@@ -202,9 +204,6 @@ async function runInTransactionWith(state, {
202
204
  throw error;
203
205
  }
204
206
  }
205
- function leaseKey(deliveryId, leaseToken) {
206
- return `${deliveryId}\0${leaseToken}`;
207
- }
208
207
  function assertInTransaction(session, operation) {
209
208
  if (!session.inTransaction()) {
210
209
  throw new ConfigurationError(
@@ -250,16 +249,8 @@ var MongoAppend = class {
250
249
  const duplicateEventIds = [];
251
250
  const newEvents = [];
252
251
  const newDeliveries = [];
253
- const knownHandlers = /* @__PURE__ */ new Map();
254
252
  const addedHandlers = /* @__PURE__ */ new Map();
255
- const orderingByEventId = /* @__PURE__ */ new Map();
256
- for (const [eventId, stored] of storedEvents) {
257
- orderingByEventId.set(eventId, {
258
- key: stored.key ?? null,
259
- sequence: stored.sequence ?? null
260
- });
261
- knownHandlers.set(eventId, new Set(stored.handlers));
262
- }
253
+ const { orderingByEventId, knownHandlers } = indexStoredEvents(storedEvents);
263
254
  for (const { event, deliveries } of entries) {
264
255
  let ordering = orderingByEventId.get(event.id);
265
256
  if (ordering === void 0) {
@@ -289,47 +280,23 @@ var MongoAppend = class {
289
280
  const { session, plan, storedEvents, now } = input;
290
281
  const { newEvents, newDeliveries, addedHandlers } = plan;
291
282
  if (newEvents.length > 0) {
292
- await this.#state.events.bulkWrite(
293
- newEvents.map(({ event, sequence }) => {
294
- const document = toEventDocument(event, {
295
- sequence,
296
- handlers: addedHandlers.get(event.id) ?? [],
297
- createdAt: now
298
- });
299
- return {
300
- updateOne: {
301
- filter: { _id: document._id },
302
- update: { $setOnInsert: document },
303
- upsert: true
304
- }
305
- };
306
- }),
307
- { session, ordered: true }
308
- );
283
+ await this.#state.events.bulkWrite(newEventOperations({ newEvents, addedHandlers, now }), {
284
+ session,
285
+ ordered: true
286
+ });
309
287
  }
310
288
  const extendedEvents = [...addedHandlers].filter(([eventId]) => storedEvents.has(eventId));
311
289
  if (extendedEvents.length > 0) {
312
- await this.#state.events.bulkWrite(
313
- extendedEvents.map(([eventId, handlers]) => ({
314
- updateOne: {
315
- filter: { _id: eventId },
316
- update: { $inc: { revision: 1 }, $push: { handlers: { $each: handlers } } }
317
- }
318
- })),
319
- { session, ordered: true }
320
- );
290
+ await this.#state.events.bulkWrite(extendedEventOperations(extendedEvents), {
291
+ session,
292
+ ordered: true
293
+ });
321
294
  }
322
295
  if (newDeliveries.length > 0) {
323
- await this.#state.deliveries.bulkWrite(
324
- newDeliveries.map((document) => ({
325
- updateOne: {
326
- filter: { eventId: document.eventId, handler: document.handler },
327
- update: { $setOnInsert: document },
328
- upsert: true
329
- }
330
- })),
331
- { session, ordered: true }
332
- );
296
+ await this.#state.deliveries.bulkWrite(newDeliveryOperations(newDeliveries), {
297
+ session,
298
+ ordered: true
299
+ });
333
300
  }
334
301
  }
335
302
  /** Atomically increments the counter of `key`; conflicting appends of the key serialize here. */
@@ -342,8 +309,52 @@ var MongoAppend = class {
342
309
  return counter.value;
343
310
  }
344
311
  };
345
-
346
- // src/claim-scan.ts
312
+ function indexStoredEvents(storedEvents) {
313
+ const orderingByEventId = /* @__PURE__ */ new Map();
314
+ const knownHandlers = /* @__PURE__ */ new Map();
315
+ for (const [eventId, stored] of storedEvents) {
316
+ orderingByEventId.set(eventId, {
317
+ key: stored.key ?? null,
318
+ sequence: stored.sequence ?? null
319
+ });
320
+ knownHandlers.set(eventId, new Set(stored.handlers));
321
+ }
322
+ return { orderingByEventId, knownHandlers };
323
+ }
324
+ function newEventOperations(input) {
325
+ const { newEvents, addedHandlers, now } = input;
326
+ return newEvents.map(({ event, sequence }) => {
327
+ const document = toEventDocument(event, {
328
+ sequence,
329
+ handlers: addedHandlers.get(event.id) ?? [],
330
+ createdAt: now
331
+ });
332
+ return {
333
+ updateOne: {
334
+ filter: { _id: document._id },
335
+ update: { $setOnInsert: document },
336
+ upsert: true
337
+ }
338
+ };
339
+ });
340
+ }
341
+ function extendedEventOperations(extendedEvents) {
342
+ return extendedEvents.map(([eventId, handlers]) => ({
343
+ updateOne: {
344
+ filter: { _id: eventId },
345
+ update: { $inc: { revision: 1 }, $push: { handlers: { $each: handlers } } }
346
+ }
347
+ }));
348
+ }
349
+ function newDeliveryOperations(newDeliveries) {
350
+ return newDeliveries.map((document) => ({
351
+ updateOne: {
352
+ filter: { eventId: document.eventId, handler: document.handler },
353
+ update: { $setOnInsert: document },
354
+ upsert: true
355
+ }
356
+ }));
357
+ }
347
358
  var ClaimScan = class {
348
359
  #keys = /* @__PURE__ */ new Map();
349
360
  /** Whether an unfinished delivery this scan knows of comes before the delivery with `sequence`. */
@@ -387,13 +398,104 @@ var ClaimScan = class {
387
398
  }
388
399
  };
389
400
  function orderingIdentity(handler, key) {
390
- return `${handler}\0${key}`;
401
+ return CompositeKey.of(handler, key);
391
402
  }
403
+ var MongoLaneReconciler = class {
404
+ #state;
405
+ constructor(state) {
406
+ this.#state = state;
407
+ }
408
+ /**
409
+ * Takes every delivery this instance does not run out of the lanes `owner`
410
+ * holds on it: every listed delivery whose current claim (lane epoch and
411
+ * claim generation now) was not handed out by this instance, or was settled.
412
+ * One transaction, and the lanes are written first under their epoch and
413
+ * holder: a worker taking a lane over meanwhile conflicts with it, so its
414
+ * claim is never touched. The claim generation of every delivery taken out
415
+ * moves on, so no lease of those claims (a completion that failed and is
416
+ * retried later, say) can ever match a later claim of the same delivery.
417
+ * Registrations of this owner that are not the current claim of a listed
418
+ * delivery are dropped.
419
+ */
420
+ async reconcile(owner) {
421
+ await runInTransaction(this.#state, async (session) => {
422
+ const held = await this.#state.lanes.find({ owner, holder: this.#state.instanceId }, { session }).toArray();
423
+ const claimsById = await this.#claimGenerations(
424
+ held.flatMap((lane) => lane.inFlight),
425
+ session
426
+ );
427
+ const { unclaimed, current } = this.#splitUnclaimed(held, claimsById);
428
+ this.#state.handedOut.forgetWhere(
429
+ (entry, identity) => entry.owner === owner && !current.has(identity)
430
+ );
431
+ if (held.length === 0) {
432
+ return;
433
+ }
434
+ await this.#takeOut(unclaimed, session);
435
+ });
436
+ }
437
+ /** The claim generation now of every listed delivery that is still pending. */
438
+ async #claimGenerations(listed, session) {
439
+ return new Map(
440
+ (await this.#state.deliveries.find({ _id: { $in: listed }, status: "pending" }, { session, projection: { claims: 1 } }).toArray()).map((document) => [document._id, document.claims ?? 0])
441
+ );
442
+ }
443
+ /**
444
+ * Per held lane, the listed deliveries this instance did not hand out under their current claim,
445
+ * and the identities of all current claims.
446
+ */
447
+ #splitUnclaimed(held, claimsById) {
448
+ const current = /* @__PURE__ */ new Set();
449
+ const unclaimed = held.map((lane) => ({
450
+ lane,
451
+ ids: lane.inFlight.filter((id) => {
452
+ const key = DeliveryLeaseIdentity.of(
453
+ id,
454
+ formatLeaseToken(lane._id, { epoch: lane.epoch, claims: claimsById.get(id) ?? 0 })
455
+ );
456
+ current.add(key);
457
+ return !this.#state.handedOut.has(key);
458
+ })
459
+ }));
460
+ return { unclaimed, current };
461
+ }
462
+ /** Takes the unclaimed deliveries out of their lanes, then moves their claim generation on. */
463
+ async #takeOut(unclaimed, session) {
464
+ await this.#state.lanes.bulkWrite(
465
+ unclaimed.map(({ lane, ids }) => ({
466
+ updateOne: {
467
+ filter: { _id: lane._id, epoch: lane.epoch, holder: this.#state.instanceId },
468
+ update: { $pull: { inFlight: { $in: ids } }, $inc: { revision: 1 } }
469
+ }
470
+ })),
471
+ { session, ordered: false }
472
+ );
473
+ await this.#state.deliveries.updateMany(
474
+ { _id: { $in: unclaimed.flatMap((entry) => entry.ids) }, status: "pending" },
475
+ { $inc: { claims: 1 } },
476
+ { session }
477
+ );
478
+ }
479
+ };
392
480
 
393
481
  // src/mongo-claim.ts
394
482
  var PARK_BACKLOG_THRESHOLD = 32;
395
483
  var INITIAL_LANES_PER_ROUND = 4;
396
484
  var LANES_PER_CANDIDATE_QUERY = 128;
485
+ function toClaimedDeliveries(claimed, context) {
486
+ const { scope, events, request } = context;
487
+ return claimed.map((document) => ({
488
+ deliveryId: document._id,
489
+ leaseToken: formatLeaseToken(document.laneId, {
490
+ epoch: scope.get(document.laneId).epoch,
491
+ claims: document.claims ?? 0
492
+ }),
493
+ handler: document.handler,
494
+ attempt: document.attempts + 1,
495
+ leaseExpiresAt: request.leaseExpiresAt,
496
+ event: events.get(document.eventId)
497
+ }));
498
+ }
397
499
  function scopeOf(held) {
398
500
  return new Map(
399
501
  held.map((lane) => [
@@ -407,33 +509,23 @@ function scopeOf(held) {
407
509
  ])
408
510
  );
409
511
  }
410
- var MongoClaims = class {
512
+ var MongoClaims = class extends OwnerSerializedClaims {
411
513
  #state;
412
514
  #laneLeases;
413
515
  #ordering;
414
516
  #queries;
517
+ #reconciler;
415
518
  constructor(state, collaborators) {
519
+ super();
416
520
  this.#state = state;
417
521
  this.#laneLeases = collaborators.laneLeases;
418
522
  this.#ordering = collaborators.ordering;
419
523
  this.#queries = collaborators.queries;
524
+ this.#reconciler = new MongoLaneReconciler(state);
420
525
  }
421
- /** Implements {@link MongoEventStore.claim}. */
422
- async claim(request) {
423
- if (request.handlers.length === 0 || request.limit <= 0) {
424
- return [];
425
- }
426
- const owner = request.leaseOwner;
427
- const previous = this.#state.claimsInProgress.get(owner) ?? Promise.resolve();
428
- const claimed = previous.then(() => this.#claimReconciled(request));
429
- const settled = Promise.allSettled([claimed]);
430
- this.#state.claimsInProgress.set(owner, settled);
431
- void settled.then(() => {
432
- if (this.#state.claimsInProgress.get(owner) === settled) {
433
- this.#state.claimsInProgress.delete(owner);
434
- }
435
- });
436
- return claimed;
526
+ /** Mongo claims nothing for a limit of 0 or below (a limit that is not a number is tried). */
527
+ claimsNothing(request) {
528
+ return request.handlers.length === 0 || request.limit <= 0;
437
529
  }
438
530
  /**
439
531
  * Runs one claim and remembers what it hands out. When the claim fails at any
@@ -443,17 +535,17 @@ var MongoClaims = class {
443
535
  * handed out and seen settled, is taken out of them, so nothing stays listed
444
536
  * as running that nobody runs.
445
537
  */
446
- async #claimReconciled(request) {
538
+ async claimInTurn(request) {
447
539
  const owner = request.leaseOwner;
448
540
  if (this.#state.unreconciledOwners.has(owner)) {
449
- await this.#reconcileLanes(owner);
541
+ await this.#reconciler.reconcile(owner);
450
542
  this.#state.unreconciledOwners.delete(owner);
451
543
  }
452
544
  try {
453
545
  const claimed = await this.#claimNow(request);
454
546
  for (const delivery of claimed) {
455
547
  const { laneId, epoch } = parseLeaseToken(delivery.leaseToken);
456
- this.#state.handedOut.set(leaseKey(delivery.deliveryId, delivery.leaseToken), {
548
+ this.#state.handedOut.register(delivery, {
457
549
  deliveryId: delivery.deliveryId,
458
550
  owner: request.leaseOwner,
459
551
  laneId,
@@ -462,80 +554,15 @@ var MongoClaims = class {
462
554
  }
463
555
  return claimed;
464
556
  } catch (error) {
465
- const [reconciled] = await Promise.allSettled([this.#reconcileLanes(owner)]);
557
+ const [reconciled] = await Promise.allSettled([this.#reconciler.reconcile(owner)]);
466
558
  if (reconciled.status === "rejected") {
467
559
  this.#state.unreconciledOwners.add(owner);
468
560
  }
469
561
  throw error;
470
562
  }
471
563
  }
472
- /**
473
- * Takes every delivery this instance does not run out of the lanes `owner`
474
- * holds on it: every listed delivery whose current claim (lane epoch and
475
- * claim generation now) was not handed out by this instance, or was settled.
476
- * One transaction, and the lanes are written first under their epoch and
477
- * holder: a worker taking a lane over meanwhile conflicts with it, so its
478
- * claim is never touched. The claim generation of every delivery taken out
479
- * moves on, so no lease of those claims (a completion that failed and is
480
- * retried later, say) can ever match a later claim of the same delivery.
481
- * Registrations of this owner that are not the current claim of a listed
482
- * delivery are dropped.
483
- */
484
- async #reconcileLanes(owner) {
485
- await runInTransaction(this.#state, async (session) => {
486
- const held = await this.#state.lanes.find({ owner, holder: this.#state.instanceId }, { session }).toArray();
487
- const listed = held.flatMap((lane) => lane.inFlight);
488
- const claimsById = new Map(
489
- (await this.#state.deliveries.find(
490
- { _id: { $in: listed }, status: "pending" },
491
- { session, projection: { claims: 1 } }
492
- ).toArray()).map((document) => [document._id, document.claims ?? 0])
493
- );
494
- const current = /* @__PURE__ */ new Set();
495
- const unclaimed = held.map((lane) => ({
496
- lane,
497
- ids: lane.inFlight.filter((id) => {
498
- const key = leaseKey(
499
- id,
500
- formatLeaseToken(lane._id, { epoch: lane.epoch, claims: claimsById.get(id) ?? 0 })
501
- );
502
- current.add(key);
503
- return !this.#state.handedOut.has(key);
504
- })
505
- }));
506
- for (const [key, entry] of this.#state.handedOut) {
507
- if (entry.owner === owner && !current.has(key)) {
508
- this.#state.handedOut.delete(key);
509
- }
510
- }
511
- if (held.length === 0) {
512
- return;
513
- }
514
- await this.#state.lanes.bulkWrite(
515
- unclaimed.map(({ lane, ids }) => ({
516
- updateOne: {
517
- filter: { _id: lane._id, epoch: lane.epoch, holder: this.#state.instanceId },
518
- update: { $pull: { inFlight: { $in: ids } }, $inc: { revision: 1 } }
519
- }
520
- })),
521
- { session, ordered: false }
522
- );
523
- await this.#state.deliveries.updateMany(
524
- { _id: { $in: unclaimed.flatMap((entry) => entry.ids) }, status: "pending" },
525
- { $inc: { claims: 1 } },
526
- { session }
527
- );
528
- });
529
- }
530
564
  async #claimNow(request) {
531
- const [held] = await Promise.all([
532
- this.#state.lanes.find({
533
- owner: request.leaseOwner,
534
- holder: this.#state.instanceId,
535
- leaseExpiresAt: { $gt: request.now }
536
- }).toArray(),
537
- this.#ordering.unparkNextDeliveries(request.handlers)
538
- ]);
565
+ const held = await this.#readHeldLanes(request);
539
566
  await this.#laneLeases.chargeLost(held);
540
567
  const scope = scopeOf(held);
541
568
  const running = this.#loadRunning(held);
@@ -555,17 +582,19 @@ var MongoClaims = class {
555
582
  scope
556
583
  );
557
584
  await this.#ordering.parkWaiting(scan.waitingBacklogs(PARK_BACKLOG_THRESHOLD, handedBack));
558
- return claimed.map((document) => ({
559
- deliveryId: document._id,
560
- leaseToken: formatLeaseToken(document.laneId, {
561
- epoch: scope.get(document.laneId).epoch,
562
- claims: document.claims ?? 0
563
- }),
564
- handler: document.handler,
565
- attempt: document.attempts + 1,
566
- leaseExpiresAt: request.leaseExpiresAt,
567
- event: events.get(document.eventId)
568
- }));
585
+ return toClaimedDeliveries(claimed, { scope, events, request });
586
+ }
587
+ /** The lanes the owner holds on this instance, read while the next deliveries are unparked. */
588
+ async #readHeldLanes(request) {
589
+ const [held] = await Promise.all([
590
+ this.#state.lanes.find({
591
+ owner: request.leaseOwner,
592
+ holder: this.#state.instanceId,
593
+ leaseExpiresAt: { $gt: request.now }
594
+ }).toArray(),
595
+ this.#ordering.unparkNextDeliveries(request.handlers)
596
+ ]);
597
+ return held;
569
598
  }
570
599
  /** The deliveries still running in the held lanes, with their (handler, key) identities. */
571
600
  #loadRunning(held) {
@@ -627,14 +656,12 @@ var MongoClaims = class {
627
656
  #forgetSettled(held, { runningIds, owner }) {
628
657
  const gone = new Set(
629
658
  held.flatMap(
630
- (lane) => lane.inFlight.filter((id) => !runningIds.has(id)).map((id) => `${id}\0${lane._id}:${lane.epoch}`)
659
+ (lane) => lane.inFlight.filter((id) => !runningIds.has(id)).map((id) => CompositeKey.of(id, `${lane._id}:${lane.epoch}`))
631
660
  )
632
661
  );
633
- for (const [key, entry] of this.#state.handedOut) {
634
- if (entry.owner === owner && gone.has(`${entry.deliveryId}\0${entry.laneId}:${entry.epoch}`)) {
635
- this.#state.handedOut.delete(key);
636
- }
637
- }
662
+ this.#state.handedOut.forgetWhere(
663
+ (entry) => entry.owner === owner && gone.has(CompositeKey.of(entry.deliveryId, `${entry.laneId}:${entry.epoch}`))
664
+ );
638
665
  }
639
666
  /**
640
667
  * Gives back the recorded keyed picks that a delivery requeued during the
@@ -696,20 +723,34 @@ var MongoClaims = class {
696
723
  );
697
724
  }
698
725
  };
699
-
700
- // src/mongo-completion.ts
701
726
  var FINISHED_FIELDS = { dueAt: "", lane: "" };
702
- var MongoCompletion = class {
727
+ var MongoCompletion = class extends LeaseSettlement {
703
728
  #state;
704
729
  constructor(state) {
730
+ super();
705
731
  this.#state = state;
706
732
  }
707
- /** Implements {@link MongoEventStore.extendLease}. */
708
- async extendLease(lease, leaseExpiresAt) {
709
- const held = parseLeaseToken(lease.leaseToken);
710
- if (held === void 0) {
711
- return false;
733
+ /** Implements {@link MongoEventStore.complete}. */
734
+ async complete(lease, { now, session }) {
735
+ if (session !== void 0) {
736
+ assertInTransaction(session, "complete");
712
737
  }
738
+ return (await this.#completeLeases([lease], { now, session }))[0];
739
+ }
740
+ /** Implements {@link MongoEventStore.completeMany}. */
741
+ completeMany(leases, now) {
742
+ return this.#completeLeases(leases, { now, session: void 0 });
743
+ }
744
+ /** A lease this store issued; any other token cannot be held. */
745
+ findHeld(lease) {
746
+ return parseLeaseToken(lease.leaseToken);
747
+ }
748
+ /**
749
+ * Renews the lane lease of a running claim. One transaction: a stale lease must not extend the
750
+ * lane of a newer claim of the delivery. A claim ending or starting meanwhile writes the delivery
751
+ * or the lane, so the two conflict.
752
+ */
753
+ writeExtension(held, { lease, leaseExpiresAt }) {
713
754
  const deliveryId = String(lease.deliveryId);
714
755
  return runInTransaction(this.#state, async (session) => {
715
756
  const running = await this.#state.deliveries.findOne(ofClaim({ ...held, deliveryId }), {
@@ -727,41 +768,26 @@ var MongoCompletion = class {
727
768
  return renewed.matchedCount === 1;
728
769
  });
729
770
  }
730
- /** Implements {@link MongoEventStore.complete}. */
731
- async complete(lease, { now, session }) {
732
- if (session !== void 0) {
733
- assertInTransaction(session, "complete");
734
- }
735
- return (await this.#completeLeases([lease], { now, session }))[0];
736
- }
737
- /** Implements {@link MongoEventStore.completeMany}. */
738
- completeMany(leases, now) {
739
- return this.#completeLeases(leases, { now, session: void 0 });
740
- }
741
- /** Implements {@link MongoEventStore.reschedule}. */
742
- async reschedule(lease, {
743
- availableAt,
744
- error,
745
- now
746
- }) {
747
- return this.#leaveFlight(lease, {
748
- $set: { availableAt, dueAt: availableAt, lastError: toErrorInfo(error), updatedAt: now },
749
- $inc: { attempts: 1, claims: 1 }
771
+ /**
772
+ * Applies the change to the running delivery and takes it out of its lane's in-flight list, if
773
+ * the lane still has the lease's epoch.
774
+ */
775
+ writeChange(held, { lease, change }) {
776
+ const deliveryId = String(lease.deliveryId);
777
+ return this.#leaveFlightNow(held, {
778
+ deliveryId,
779
+ ofThisClaim: ofClaim({ ...held, deliveryId }),
780
+ update: updateFor(change)
750
781
  });
751
782
  }
752
- /** Implements {@link MongoEventStore.release}. */
753
- async release(lease, now) {
754
- return this.#leaveFlight(lease, {
755
- $set: { availableAt: now, dueAt: now, updatedAt: now },
756
- $inc: { claims: 1 }
757
- });
783
+ forgetHandedOut(lease) {
784
+ this.#forget(lease);
758
785
  }
759
- /** Implements {@link MongoEventStore.bury}. */
760
- async bury(lease, { error, now }) {
761
- return this.#leaveFlight(lease, {
762
- $set: { status: "dead", lastError: toErrorInfo(error), updatedAt: now },
763
- $unset: FINISHED_FIELDS,
764
- $inc: { attempts: 1, claims: 1 }
786
+ /** Forgets a lease as handed out: its claim ended. */
787
+ #forget(lease) {
788
+ this.#state.handedOut.forget({
789
+ deliveryId: String(lease.deliveryId),
790
+ leaseToken: String(lease.leaseToken)
765
791
  });
766
792
  }
767
793
  /**
@@ -773,7 +799,7 @@ var MongoCompletion = class {
773
799
  async #settle(leases, operation) {
774
800
  const result = await operation();
775
801
  for (const lease of leases) {
776
- this.#state.handedOut.delete(leaseKey(String(lease.deliveryId), String(lease.leaseToken)));
802
+ this.#forget(lease);
777
803
  }
778
804
  return result;
779
805
  }
@@ -900,23 +926,7 @@ var MongoCompletion = class {
900
926
  session === void 0 ? {} : { session }
901
927
  );
902
928
  }
903
- /**
904
- * Applies `update` to a running delivery and takes it out of its lane's
905
- * in-flight list, if the lane still has the lease's epoch. One transaction:
906
- * the lane write fences the change against a takeover.
907
- */
908
- async #leaveFlight(lease, update) {
909
- const held = parseLeaseToken(lease.leaseToken);
910
- if (held === void 0) {
911
- return false;
912
- }
913
- const deliveryId = String(lease.deliveryId);
914
- const ofThisClaim = ofClaim({ ...held, deliveryId });
915
- return this.#settle(
916
- [lease],
917
- () => this.#leaveFlightNow(held, { deliveryId, ofThisClaim, update })
918
- );
919
- }
929
+ /** One transaction: the lane write fences the change against a takeover. */
920
930
  async #leaveFlightNow(held, {
921
931
  deliveryId,
922
932
  ofThisClaim,
@@ -957,7 +967,28 @@ function ofClaim(lease) {
957
967
  return { _id: lease.deliveryId, status: "pending", claims: atCount(lease.claims) };
958
968
  }
959
969
  function leaseIdentity(lease) {
960
- return `${lease.deliveryId}\0${formatLeaseToken(lease.laneId, { epoch: lease.epoch, claims: lease.claims })}`;
970
+ return DeliveryLeaseIdentity.of(
971
+ lease.deliveryId,
972
+ formatLeaseToken(lease.laneId, { epoch: lease.epoch, claims: lease.claims })
973
+ );
974
+ }
975
+ function updateFor(change) {
976
+ if (change.kind === "reschedule") {
977
+ const { availableAt, error, now } = change;
978
+ return {
979
+ $set: { availableAt, dueAt: availableAt, lastError: toErrorInfo(error), updatedAt: now },
980
+ $inc: { attempts: 1, claims: 1 }
981
+ };
982
+ }
983
+ if (change.kind === "release") {
984
+ const { now } = change;
985
+ return { $set: { availableAt: now, dueAt: now, updatedAt: now }, $inc: { claims: 1 } };
986
+ }
987
+ return {
988
+ $set: { status: "dead", lastError: toErrorInfo(change.error), updatedAt: change.now },
989
+ $unset: FINISHED_FIELDS,
990
+ $inc: { attempts: 1, claims: 1 }
991
+ };
961
992
  }
962
993
 
963
994
  // src/mongo-lane-leases.ts
@@ -1106,10 +1137,36 @@ var MongoLaneLeases = class {
1106
1137
  request
1107
1138
  }) {
1108
1139
  const picksByLane = /* @__PURE__ */ new Map();
1109
- const handedBack = /* @__PURE__ */ new Set();
1110
1140
  for (const document of picked) {
1111
1141
  picksByLane.set(document.laneId, [...picksByLane.get(document.laneId) ?? [], document._id]);
1112
1142
  }
1143
+ const { operations, handedBack } = this.#planLaneRecords(scope, {
1144
+ picksByLane,
1145
+ runningIds,
1146
+ request
1147
+ });
1148
+ if (operations.length === 0) {
1149
+ return { recorded: /* @__PURE__ */ new Set(), handedBack };
1150
+ }
1151
+ const result = await this.#state.lanes.bulkWrite(operations, { ordered: false });
1152
+ if (result.matchedCount === operations.length) {
1153
+ return { recorded: new Set(picksByLane.keys()), handedBack };
1154
+ }
1155
+ return {
1156
+ recorded: await this.#readRecorded(scope, { laneIds: [...picksByLane.keys()], request }),
1157
+ handedBack
1158
+ };
1159
+ }
1160
+ /**
1161
+ * One conditional write per lane of the claim: a picked lane records its picks, a lane that
1162
+ * yielded nothing and runs nothing is freed (and reported as handed back).
1163
+ */
1164
+ #planLaneRecords(scope, {
1165
+ picksByLane,
1166
+ runningIds,
1167
+ request
1168
+ }) {
1169
+ const handedBack = /* @__PURE__ */ new Set();
1113
1170
  const operations = [];
1114
1171
  for (const lane of scope.values()) {
1115
1172
  const gone = lane.listed.filter((id) => !runningIds.has(id));
@@ -1125,17 +1182,7 @@ var MongoLaneLeases = class {
1125
1182
  handedBack.add(lane.laneId);
1126
1183
  }
1127
1184
  }
1128
- if (operations.length === 0) {
1129
- return { recorded: /* @__PURE__ */ new Set(), handedBack };
1130
- }
1131
- const result = await this.#state.lanes.bulkWrite(operations, { ordered: false });
1132
- if (result.matchedCount === operations.length) {
1133
- return { recorded: new Set(picksByLane.keys()), handedBack };
1134
- }
1135
- return {
1136
- recorded: await this.#readRecorded(scope, { laneIds: [...picksByLane.keys()], request }),
1137
- handedBack
1138
- };
1185
+ return { operations, handedBack };
1139
1186
  }
1140
1187
  /** The lanes of `laneIds` whose record of this claim applied, read back after a partial bulk write. */
1141
1188
  async #readRecorded(scope, { laneIds, request }) {
@@ -1253,30 +1300,11 @@ var MongoOrdering = class {
1253
1300
  async pickCandidates(input) {
1254
1301
  const { lanes, request, scan, running, picked } = input;
1255
1302
  const pageSize = Math.max((request.limit - picked.length) * 2, MINIMUM_CANDIDATE_BATCH_SIZE);
1256
- const filter = {
1257
- laneId: { $in: lanes.map((lane) => lane.laneId) },
1258
- handler: { $in: [...request.handlers] },
1259
- lane: "ready",
1260
- dueAt: { $lte: request.now },
1261
- _id: { $nin: lanes.flatMap((lane) => lane.listed) }
1262
- };
1303
+ const filter = candidateFilter(lanes, request);
1263
1304
  let after;
1264
1305
  for (; ; ) {
1265
1306
  const [page, { keys }] = await Promise.all([
1266
- this.#state.deliveries.find(
1267
- after === void 0 ? filter : {
1268
- $and: [
1269
- filter,
1270
- {
1271
- $or: [
1272
- { dueAt: { $gt: after.dueAt } },
1273
- { dueAt: after.dueAt, _id: { $gt: after._id } }
1274
- ]
1275
- }
1276
- ]
1277
- },
1278
- { sort: { dueAt: 1, _id: 1 }, limit: pageSize }
1279
- ).toArray(),
1307
+ this.#state.deliveries.find(pageAfter(filter, after), { sort: { dueAt: 1, _id: 1 }, limit: pageSize }).toArray(),
1280
1308
  running
1281
1309
  ]);
1282
1310
  if (await this.#pickFromPage(page, { request, scan, picked, keys })) {
@@ -1392,17 +1420,7 @@ var MongoOrdering = class {
1392
1420
  if (anyParked === null) {
1393
1421
  return;
1394
1422
  }
1395
- const lowest = (await Promise.all(
1396
- [...new Set(handlers)].map(
1397
- (handler) => this.#state.deliveries.aggregate([
1398
- { $match: { handler, lane: "parked" } },
1399
- { $sort: { handler: 1, key: 1, sequence: 1 } },
1400
- {
1401
- $group: { _id: "$key", id: { $first: "$_id" }, sequence: { $first: "$sequence" } }
1402
- }
1403
- ]).toArray().then((groups) => groups.map((group) => ({ ...group, handler })))
1404
- )
1405
- )).flat();
1423
+ const lowest = await this.#lowestParkedPerKey(handlers);
1406
1424
  const next = await Promise.all(
1407
1425
  lowest.map(
1408
1426
  async (parked) => await this.#findUnfinishedPredecessor(parked.handler, {
@@ -1419,7 +1437,46 @@ var MongoOrdering = class {
1419
1437
  );
1420
1438
  }
1421
1439
  }
1440
+ /**
1441
+ * The lowest parked delivery of every parked key of `handlers`, one aggregation per handler in
1442
+ * parallel.
1443
+ */
1444
+ async #lowestParkedPerKey(handlers) {
1445
+ return (await Promise.all(
1446
+ [...new Set(handlers)].map(
1447
+ (handler) => this.#state.deliveries.aggregate([
1448
+ { $match: { handler, lane: "parked" } },
1449
+ { $sort: { handler: 1, key: 1, sequence: 1 } },
1450
+ {
1451
+ $group: { _id: "$key", id: { $first: "$_id" }, sequence: { $first: "$sequence" } }
1452
+ }
1453
+ ]).toArray().then((groups) => groups.map((group) => ({ ...group, handler })))
1454
+ )
1455
+ )).flat();
1456
+ }
1422
1457
  };
1458
+ function candidateFilter(lanes, request) {
1459
+ return {
1460
+ laneId: { $in: lanes.map((lane) => lane.laneId) },
1461
+ handler: { $in: [...request.handlers] },
1462
+ lane: "ready",
1463
+ dueAt: { $lte: request.now },
1464
+ _id: { $nin: lanes.flatMap((lane) => lane.listed) }
1465
+ };
1466
+ }
1467
+ function pageAfter(filter, after) {
1468
+ return after === void 0 ? filter : {
1469
+ $and: [
1470
+ filter,
1471
+ {
1472
+ $or: [
1473
+ { dueAt: { $gt: after.dueAt } },
1474
+ { dueAt: after.dueAt, _id: { $gt: after._id } }
1475
+ ]
1476
+ }
1477
+ ]
1478
+ };
1479
+ }
1423
1480
  function isClaimable(candidate, { scan, blockedKeys }) {
1424
1481
  const { key, sequence } = candidate;
1425
1482
  if (key === void 0) {
@@ -1446,6 +1503,21 @@ var MongoPurge = class {
1446
1503
  /** Implements {@link MongoEventStore.purge}. */
1447
1504
  async purge(request) {
1448
1505
  const total = { deletedDeliveries: 0, deletedEvents: 0 };
1506
+ const steps = this.#purgeSteps(request);
1507
+ const { completedBefore, deadBefore } = request;
1508
+ for (const step of steps) {
1509
+ const result = await step();
1510
+ total.deletedDeliveries += result.deletedDeliveries;
1511
+ total.deletedEvents += result.deletedEvents;
1512
+ }
1513
+ const orphanCutoff = [completedBefore, deadBefore].filter((cutoff) => cutoff !== void 0).sort((first, second) => first.getTime() - second.getTime())[0];
1514
+ if (orphanCutoff !== void 0) {
1515
+ total.deletedEvents += await this.#purgeOrphanEvents(orphanCutoff, completedBefore);
1516
+ }
1517
+ return total;
1518
+ }
1519
+ /** The purges the request asks for, in the order they run. */
1520
+ #purgeSteps(request) {
1449
1521
  const steps = [];
1450
1522
  const { completedBefore, deadBefore } = request;
1451
1523
  if (completedBefore !== void 0) {
@@ -1472,16 +1544,7 @@ var MongoPurge = class {
1472
1544
  })
1473
1545
  );
1474
1546
  }
1475
- for (const step of steps) {
1476
- const result = await step();
1477
- total.deletedDeliveries += result.deletedDeliveries;
1478
- total.deletedEvents += result.deletedEvents;
1479
- }
1480
- const orphanCutoff = [completedBefore, deadBefore].filter((cutoff) => cutoff !== void 0).sort((first, second) => first.getTime() - second.getTime())[0];
1481
- if (orphanCutoff !== void 0) {
1482
- total.deletedEvents += await this.#purgeOrphanEvents(orphanCutoff, completedBefore);
1483
- }
1484
- return total;
1547
+ return steps;
1485
1548
  }
1486
1549
  /**
1487
1550
  * Deletes batches of matching documents (deliveries, or completion history
@@ -1499,35 +1562,10 @@ var MongoPurge = class {
1499
1562
  let deletedDeliveries = 0;
1500
1563
  let deletedEvents = 0;
1501
1564
  for (; ; ) {
1502
- const step = await runInTransaction(this.#state, async (session) => {
1503
- const batch = await collection.find(filter, { session, projection: { eventId: 1 }, limit: this.#state.purgeBatchSize }).toArray();
1504
- if (batch.length === 0) {
1505
- return { batchSize: 0, deletedDeliveries: 0, deletedEvents: 0 };
1506
- }
1507
- const eventIds = [...new Set(batch.map((document) => document.eventId))];
1508
- const deleted = await collection.deleteMany(
1509
- { _id: { $in: batch.map((document) => document._id) } },
1510
- { session }
1511
- );
1512
- const stillReferenced = await this.#referencedEventIds(eventIds, session);
1513
- const events = await this.#state.events.deleteMany(
1514
- {
1515
- _id: { $in: eventIds.filter((eventId) => !stillReferenced.has(eventId)) },
1516
- // An event a delivery of which completed stays as deduplication record until
1517
- // `completedBefore` passes its last completion, whatever this batch purged.
1518
- $or: [
1519
- { completedAt: { $exists: false } },
1520
- ...completedBefore === void 0 ? [] : [{ completedAt: { $lt: completedBefore } }]
1521
- ]
1522
- },
1523
- { session }
1524
- );
1525
- return {
1526
- batchSize: batch.length,
1527
- deletedDeliveries: deleted.deletedCount,
1528
- deletedEvents: events.deletedCount
1529
- };
1530
- });
1565
+ const step = await runInTransaction(
1566
+ this.#state,
1567
+ (session) => this.#purgeBatch(collection, { filter, completedBefore, session })
1568
+ );
1531
1569
  deletedDeliveries += step.deletedDeliveries;
1532
1570
  deletedEvents += step.deletedEvents;
1533
1571
  if (step.batchSize < this.#state.purgeBatchSize) {
@@ -1535,6 +1573,40 @@ var MongoPurge = class {
1535
1573
  }
1536
1574
  }
1537
1575
  }
1576
+ /** One batch of {@link MongoPurge.#purgeMatching}, inside the caller's transaction. */
1577
+ async #purgeBatch(collection, {
1578
+ filter,
1579
+ completedBefore,
1580
+ session
1581
+ }) {
1582
+ const batch = await collection.find(filter, { session, projection: { eventId: 1 }, limit: this.#state.purgeBatchSize }).toArray();
1583
+ if (batch.length === 0) {
1584
+ return { batchSize: 0, deletedDeliveries: 0, deletedEvents: 0 };
1585
+ }
1586
+ const eventIds = [...new Set(batch.map((document) => document.eventId))];
1587
+ const deleted = await collection.deleteMany(
1588
+ { _id: { $in: batch.map((document) => document._id) } },
1589
+ { session }
1590
+ );
1591
+ const stillReferenced = await this.#referencedEventIds(eventIds, session);
1592
+ const events = await this.#state.events.deleteMany(
1593
+ {
1594
+ _id: { $in: eventIds.filter((eventId) => !stillReferenced.has(eventId)) },
1595
+ // An event a delivery of which completed stays as deduplication record until
1596
+ // `completedBefore` passes its last completion, whatever this batch purged.
1597
+ $or: [
1598
+ { completedAt: { $exists: false } },
1599
+ ...completedBefore === void 0 ? [] : [{ completedAt: { $lt: completedBefore } }]
1600
+ ]
1601
+ },
1602
+ { session }
1603
+ );
1604
+ return {
1605
+ batchSize: batch.length,
1606
+ deletedDeliveries: deleted.deletedCount,
1607
+ deletedEvents: events.deletedCount
1608
+ };
1609
+ }
1538
1610
  /** A view of a collection that purge deletes from: deliveries or completion history records. */
1539
1611
  #purgeable(name) {
1540
1612
  return this.#state.database.collection(name, {
@@ -1568,33 +1640,12 @@ var MongoPurge = class {
1568
1640
  for (; ; ) {
1569
1641
  const step = await runInTransaction(this.#state, async (session) => {
1570
1642
  const orphans = await this.#state.events.aggregate(
1571
- [
1572
- {
1573
- $match: {
1574
- $or: [
1575
- // Never completed (no subscribers, or every delivery dead): by age.
1576
- { completedAt: { $exists: false }, createdAt: { $lt: cutoff } },
1577
- // Completed: by the time of its last completion, and only under the completed cutoff.
1578
- ...completedBefore === void 0 ? [] : [{ completedAt: { $lt: completedBefore } }]
1579
- ]
1580
- }
1581
- },
1582
- { $sort: { createdAt: 1, _id: 1 } },
1583
- ...references.flatMap((from) => [
1584
- {
1585
- $lookup: {
1586
- from,
1587
- localField: "_id",
1588
- foreignField: "eventId",
1589
- pipeline: [{ $limit: 1 }, { $project: { _id: 1 } }],
1590
- as: "references"
1591
- }
1592
- },
1593
- { $match: { references: { $size: 0 } } }
1594
- ]),
1595
- { $limit: this.#state.purgeBatchSize },
1596
- { $project: { _id: 1 } }
1597
- ],
1643
+ orphanPipeline({
1644
+ cutoff,
1645
+ completedBefore,
1646
+ references,
1647
+ limit: this.#state.purgeBatchSize
1648
+ }),
1598
1649
  { session }
1599
1650
  ).toArray();
1600
1651
  const deleted = await this.#state.events.deleteMany(
@@ -1610,6 +1661,36 @@ var MongoPurge = class {
1610
1661
  }
1611
1662
  }
1612
1663
  };
1664
+ function orphanPipeline(input) {
1665
+ const { cutoff, completedBefore, references, limit } = input;
1666
+ return [
1667
+ {
1668
+ $match: {
1669
+ $or: [
1670
+ // Never completed (no subscribers, or every delivery dead): by age.
1671
+ { completedAt: { $exists: false }, createdAt: { $lt: cutoff } },
1672
+ // Completed: by the time of its last completion, and only under the completed cutoff.
1673
+ ...completedBefore === void 0 ? [] : [{ completedAt: { $lt: completedBefore } }]
1674
+ ]
1675
+ }
1676
+ },
1677
+ { $sort: { createdAt: 1, _id: 1 } },
1678
+ ...references.flatMap((from) => [
1679
+ {
1680
+ $lookup: {
1681
+ from,
1682
+ localField: "_id",
1683
+ foreignField: "eventId",
1684
+ pipeline: [{ $limit: 1 }, { $project: { _id: 1 } }],
1685
+ as: "references"
1686
+ }
1687
+ },
1688
+ { $match: { references: { $size: 0 } } }
1689
+ ]),
1690
+ { $limit: limit },
1691
+ { $project: { _id: 1 } }
1692
+ ];
1693
+ }
1613
1694
 
1614
1695
  // src/mongo-queries.ts
1615
1696
  var DEFAULT_LIST_LIMIT = 100;
@@ -1625,14 +1706,7 @@ var MongoQueries = class {
1625
1706
  const limit = query.limit ?? DEFAULT_LIST_LIMIT;
1626
1707
  const lanes = await this.#state.lanes.find({ "inFlight.0": { $exists: true } }, { projection: { epoch: 0 } }).toArray();
1627
1708
  const listed = lanes.flatMap((lane) => lane.inFlight);
1628
- const derivedStatuses = /* @__PURE__ */ new Map([
1629
- ["processing", { status: "pending", _id: { $in: listed } }],
1630
- ["pending", { status: "pending", _id: { $nin: listed } }]
1631
- ]);
1632
- const filter = {
1633
- ...common,
1634
- ...status === void 0 ? {} : derivedStatuses.get(status) ?? { status }
1635
- };
1709
+ const filter = { ...common, ...statusFilter(status, listed) };
1636
1710
  const sort = { createdAt: 1, _id: 1 };
1637
1711
  const documents = await this.#state.deliveries.find(filter, { sort, limit }).toArray();
1638
1712
  const history = this.#state.completionHistory && (status === void 0 || status === "completed") ? await this.#state.completions.find(common, { sort, limit }).toArray() : [];
@@ -1640,23 +1714,7 @@ var MongoQueries = class {
1640
1714
  ...documents.map((document) => document.eventId),
1641
1715
  ...history.map((document) => document.eventId)
1642
1716
  ]);
1643
- const laneById = new Map(lanes.map((lane) => [lane._id, lane]));
1644
- const records = [
1645
- ...documents.map((document) => {
1646
- const lane = laneById.get(document.laneId);
1647
- const runs = document.status === "pending" && lane?.inFlight.includes(document._id) === true;
1648
- return toDeliveryRecord(document, {
1649
- event: events.get(document.eventId),
1650
- runningIn: runs ? lane : void 0
1651
- });
1652
- }),
1653
- ...history.map(
1654
- (document) => toCompletedDeliveryRecord(document, events.get(document.eventId))
1655
- )
1656
- ];
1657
- return records.sort(
1658
- (left, right) => left.createdAt.getTime() - right.createdAt.getTime() || compareText(left.id, right.id)
1659
- ).slice(0, limit);
1717
+ return toSortedRecords({ documents, history, events, lanes }).slice(0, limit);
1660
1718
  }
1661
1719
  /** Implements {@link MongoEventStore.requeue}. */
1662
1720
  async requeue(deliveryIds, now) {
@@ -1711,7 +1769,7 @@ var MongoQueries = class {
1711
1769
  return [];
1712
1770
  }
1713
1771
  return this.#state.deliveries.find(
1714
- { _id: { $in: [...listed] }, status: "pending" },
1772
+ { _id: { $in: listed }, status: "pending" },
1715
1773
  { projection: { handler: 1, key: 1, laneId: 1 } }
1716
1774
  ).toArray();
1717
1775
  }
@@ -1720,6 +1778,36 @@ var MongoQueries = class {
1720
1778
  return new Map(documents.map((document) => [document._id, toEnvelope(document)]));
1721
1779
  }
1722
1780
  };
1781
+ function statusFilter(status, listed) {
1782
+ if (status === void 0) {
1783
+ return {};
1784
+ }
1785
+ const derivedStatuses = /* @__PURE__ */ new Map([
1786
+ ["processing", { status: "pending", _id: { $in: listed } }],
1787
+ ["pending", { status: "pending", _id: { $nin: listed } }]
1788
+ ]);
1789
+ return derivedStatuses.get(status) ?? { status };
1790
+ }
1791
+ function toSortedRecords(input) {
1792
+ const { documents, history, events, lanes } = input;
1793
+ const laneById = new Map(lanes.map((lane) => [lane._id, lane]));
1794
+ const records = [
1795
+ ...documents.map((document) => {
1796
+ const lane = laneById.get(document.laneId);
1797
+ const runs = document.status === "pending" && lane?.inFlight.includes(document._id) === true;
1798
+ return toDeliveryRecord(document, {
1799
+ event: events.get(document.eventId),
1800
+ runningIn: runs ? lane : void 0
1801
+ });
1802
+ }),
1803
+ ...history.map(
1804
+ (document) => toCompletedDeliveryRecord(document, events.get(document.eventId))
1805
+ )
1806
+ ];
1807
+ return records.sort(
1808
+ (left, right) => left.createdAt.getTime() - right.createdAt.getTime() || compareText(left.id, right.id)
1809
+ );
1810
+ }
1723
1811
  function compareText(left, right) {
1724
1812
  return Number(left > right) - Number(left < right);
1725
1813
  }
@@ -1769,54 +1857,10 @@ var MongoSchema = class {
1769
1857
  }
1770
1858
  /** Creates the indexes of every collection; existing indexes are left as they are. */
1771
1859
  async #createIndexes() {
1772
- await this.#state.deliveries.createIndexes([
1773
- // Idempotent appends and lookups by event.
1774
- { key: { eventId: 1, handler: 1 }, name: "event_handler", unique: true },
1775
- // Claim: due work of a lane in due order. Holds ready deliveries only. The handler is
1776
- // last, so a query over many lanes and handlers stays a merge of one scan per lane.
1777
- { key: CLAIM_INDEX_KEY, name: "claim", partialFilterExpression: { lane: "ready" } },
1778
- // Ordering: unfinished predecessors of a keyed delivery, parked or not.
1779
- {
1780
- key: { handler: 1, key: 1, sequence: 1 },
1781
- name: "ordering",
1782
- partialFilterExpression: { dueAt: { $exists: true }, sequence: { $exists: true } }
1783
- },
1784
- // Parked deliveries: the lowest parked delivery of each key, to move it back once it is next.
1785
- {
1786
- key: { handler: 1, key: 1, sequence: 1 },
1787
- name: "parked",
1788
- partialFilterExpression: { lane: "parked" }
1789
- },
1790
- // Statistics: oldest due delivery across handlers.
1791
- { key: { dueAt: 1 }, name: "due", partialFilterExpression: { dueAt: { $exists: true } } },
1792
- // Purge of completed deliveries left by 0.1.0.
1793
- {
1794
- key: { completedAt: 1 },
1795
- name: "completed",
1796
- partialFilterExpression: { completedAt: { $exists: true } }
1797
- },
1798
- // Listing and status counts.
1799
- { key: { status: 1, createdAt: 1 }, name: "status_created" },
1800
- // Purge of dead deliveries.
1801
- { key: { status: 1, updatedAt: 1 }, name: "status_updated" },
1802
- { key: { handler: 1, createdAt: 1 }, name: "handler_created" },
1803
- { key: { eventType: 1, createdAt: 1 }, name: "type_created" }
1804
- ]);
1805
- await this.#state.events.createIndexes([
1806
- { key: { createdAt: 1, _id: 1 }, name: "created" },
1807
- // Purge of events whose deliveries all completed: they are kept until `completedBefore` passes this.
1808
- {
1809
- key: { completedAt: 1, _id: 1 },
1810
- name: "completed",
1811
- partialFilterExpression: { completedAt: { $exists: true } }
1812
- }
1813
- ]);
1860
+ await this.#state.deliveries.createIndexes(deliveryIndexes());
1861
+ await this.#state.events.createIndexes(eventIndexes());
1814
1862
  if (this.#state.completionHistory) {
1815
- await this.#state.completions.createIndexes([
1816
- { key: { completedAt: 1, _id: 1 }, name: "completed" },
1817
- { key: { eventId: 1 }, name: "event" },
1818
- { key: { createdAt: 1, _id: 1 }, name: "created" }
1819
- ]);
1863
+ await this.#state.completions.createIndexes(completionIndexes());
1820
1864
  }
1821
1865
  }
1822
1866
  async #createCollectionIfMissing(name) {
@@ -1879,36 +1923,29 @@ var MongoSchema = class {
1879
1923
  { projection: { occurredAt: 1 }, batchSize: UPGRADE_BATCH_SIZE }
1880
1924
  ),
1881
1925
  async (events) => {
1882
- const known = new Map(
1883
- (await this.#state.deliveries.aggregate([
1884
- { $match: { eventId: { $in: events.map((event) => event._id) } } },
1885
- {
1886
- $group: {
1887
- _id: "$eventId",
1888
- handlers: { $addToSet: "$handler" },
1889
- createdAt: { $min: "$createdAt" }
1890
- }
1891
- }
1892
- ]).toArray()).map((group) => [group._id, group])
1893
- );
1926
+ const known = await this.#legacyEventGroups(events.map((event) => event._id));
1894
1927
  await this.#state.events.bulkWrite(
1895
- events.map((event) => {
1896
- const group = known.get(event._id);
1897
- return {
1898
- updateOne: {
1899
- // Only an event nobody converted yet: another process may have finished and subscribed a handler.
1900
- filter: { _id: event._id, handlers: { $exists: false } },
1901
- update: {
1902
- $set: group === void 0 ? { handlers: [], createdAt: event.occurredAt } : { handlers: group.handlers, createdAt: group.createdAt }
1903
- }
1904
- }
1905
- };
1906
- }),
1928
+ events.map((event) => convertEvent(event, known.get(event._id))),
1907
1929
  { ordered: false }
1908
1930
  );
1909
1931
  }
1910
1932
  );
1911
1933
  }
1934
+ /** The handlers and first creation time of the deliveries of each of `eventIds`, by event id. */
1935
+ async #legacyEventGroups(eventIds) {
1936
+ return new Map(
1937
+ (await this.#state.deliveries.aggregate([
1938
+ { $match: { eventId: { $in: [...eventIds] } } },
1939
+ {
1940
+ $group: {
1941
+ _id: "$eventId",
1942
+ handlers: { $addToSet: "$handler" },
1943
+ createdAt: { $min: "$createdAt" }
1944
+ }
1945
+ }
1946
+ ]).toArray()).map((group) => [group._id, group])
1947
+ );
1948
+ }
1912
1949
  /** Creates the lane documents that do not exist yet. */
1913
1950
  async #seedLanes() {
1914
1951
  await this.#state.lanes.bulkWrite(
@@ -1949,6 +1986,70 @@ var MongoSchema = class {
1949
1986
  }
1950
1987
  }
1951
1988
  };
1989
+ function deliveryIndexes() {
1990
+ return [
1991
+ // Idempotent appends and lookups by event.
1992
+ { key: { eventId: 1, handler: 1 }, name: "event_handler", unique: true },
1993
+ // Claim: due work of a lane in due order. Holds ready deliveries only. The handler is
1994
+ // last, so a query over many lanes and handlers stays a merge of one scan per lane.
1995
+ { key: CLAIM_INDEX_KEY, name: "claim", partialFilterExpression: { lane: "ready" } },
1996
+ // Ordering: unfinished predecessors of a keyed delivery, parked or not.
1997
+ {
1998
+ key: { handler: 1, key: 1, sequence: 1 },
1999
+ name: "ordering",
2000
+ partialFilterExpression: { dueAt: { $exists: true }, sequence: { $exists: true } }
2001
+ },
2002
+ // Parked deliveries: the lowest parked delivery of each key, to move it back once it is next.
2003
+ {
2004
+ key: { handler: 1, key: 1, sequence: 1 },
2005
+ name: "parked",
2006
+ partialFilterExpression: { lane: "parked" }
2007
+ },
2008
+ // Statistics: oldest due delivery across handlers.
2009
+ { key: { dueAt: 1 }, name: "due", partialFilterExpression: { dueAt: { $exists: true } } },
2010
+ // Purge of completed deliveries left by 0.1.0.
2011
+ {
2012
+ key: { completedAt: 1 },
2013
+ name: "completed",
2014
+ partialFilterExpression: { completedAt: { $exists: true } }
2015
+ },
2016
+ // Listing and status counts.
2017
+ { key: { status: 1, createdAt: 1 }, name: "status_created" },
2018
+ // Purge of dead deliveries.
2019
+ { key: { status: 1, updatedAt: 1 }, name: "status_updated" },
2020
+ { key: { handler: 1, createdAt: 1 }, name: "handler_created" },
2021
+ { key: { eventType: 1, createdAt: 1 }, name: "type_created" }
2022
+ ];
2023
+ }
2024
+ function eventIndexes() {
2025
+ return [
2026
+ { key: { createdAt: 1, _id: 1 }, name: "created" },
2027
+ // Purge of events whose deliveries all completed: they are kept until `completedBefore` passes this.
2028
+ {
2029
+ key: { completedAt: 1, _id: 1 },
2030
+ name: "completed",
2031
+ partialFilterExpression: { completedAt: { $exists: true } }
2032
+ }
2033
+ ];
2034
+ }
2035
+ function completionIndexes() {
2036
+ return [
2037
+ { key: { completedAt: 1, _id: 1 }, name: "completed" },
2038
+ { key: { eventId: 1 }, name: "event" },
2039
+ { key: { createdAt: 1, _id: 1 }, name: "created" }
2040
+ ];
2041
+ }
2042
+ function convertEvent(event, group) {
2043
+ return {
2044
+ updateOne: {
2045
+ // Only an event nobody converted yet: another process may have finished and subscribed a handler.
2046
+ filter: { _id: event._id, handlers: { $exists: false } },
2047
+ update: {
2048
+ $set: group === void 0 ? { handlers: [], createdAt: event.occurredAt } : { handlers: group.handlers, createdAt: group.createdAt }
2049
+ }
2050
+ }
2051
+ };
2052
+ }
1952
2053
  function isObsoleteIndex(index) {
1953
2054
  return OBSOLETE_INDEXES.includes(index.name) || index.name === "claim" && Object.keys(index.key).join() !== Object.keys(CLAIM_INDEX_KEY).join();
1954
2055
  }
@@ -1985,13 +2086,48 @@ function assertDatabaseName(name) {
1985
2086
  }
1986
2087
  }
1987
2088
 
1988
- // src/mongo-event-store.ts
2089
+ // src/mongo-store-factory.ts
1989
2090
  var DEFAULT_TRANSACTION_OPTIONS = {
1990
2091
  readConcern: { level: "snapshot" },
1991
2092
  writeConcern: { w: "majority" },
1992
2093
  readPreference: "primary"
1993
2094
  };
1994
2095
  var DEFAULT_PURGE_BATCH_SIZE = 500;
2096
+ function createMongoStoreState(options) {
2097
+ const prefix = options.collectionPrefix ?? "relaybus_";
2098
+ assertCollectionPrefix(prefix);
2099
+ if (options.database !== void 0) {
2100
+ assertDatabaseName(options.database);
2101
+ }
2102
+ const purgeBatchSize = PositiveInteger.of(
2103
+ options.purgeBatchSize ?? DEFAULT_PURGE_BATCH_SIZE,
2104
+ "purgeBatchSize"
2105
+ );
2106
+ const database = options.client.db(options.database);
2107
+ const collectionOptions = {
2108
+ readPreference: "primary",
2109
+ writeConcern: { w: "majority" }
2110
+ };
2111
+ const state = {
2112
+ client: options.client,
2113
+ database,
2114
+ events: database.collection(`${prefix}events`, collectionOptions),
2115
+ deliveries: database.collection(`${prefix}deliveries`, collectionOptions),
2116
+ keySequences: database.collection(`${prefix}key_sequences`, collectionOptions),
2117
+ lanes: database.collection(`${prefix}lanes`, collectionOptions),
2118
+ completions: database.collection(`${prefix}completed_deliveries`, collectionOptions),
2119
+ completionHistory: options.completionHistory ?? false,
2120
+ transactionOptions: options.transactionOptions ?? DEFAULT_TRANSACTION_OPTIONS,
2121
+ purgeBatchSize,
2122
+ logger: options.logger ?? console,
2123
+ instanceId: createUuidV7(),
2124
+ handedOut: new HandedOutLedger(),
2125
+ unreconciledOwners: /* @__PURE__ */ new Set()
2126
+ };
2127
+ return state;
2128
+ }
2129
+
2130
+ // src/mongo-event-store.ts
1995
2131
  var MongoEventStore = class {
1996
2132
  #state;
1997
2133
  #schema;
@@ -2002,37 +2138,7 @@ var MongoEventStore = class {
2002
2138
  #queries;
2003
2139
  #purger;
2004
2140
  constructor(options) {
2005
- const prefix = options.collectionPrefix ?? "relaybus_";
2006
- assertCollectionPrefix(prefix);
2007
- if (options.database !== void 0) {
2008
- assertDatabaseName(options.database);
2009
- }
2010
- const purgeBatchSize = assertPositiveInteger(
2011
- options.purgeBatchSize ?? DEFAULT_PURGE_BATCH_SIZE,
2012
- "purgeBatchSize"
2013
- );
2014
- const database = options.client.db(options.database);
2015
- const collectionOptions = {
2016
- readPreference: "primary",
2017
- writeConcern: { w: "majority" }
2018
- };
2019
- const state = {
2020
- client: options.client,
2021
- database,
2022
- events: database.collection(`${prefix}events`, collectionOptions),
2023
- deliveries: database.collection(`${prefix}deliveries`, collectionOptions),
2024
- keySequences: database.collection(`${prefix}key_sequences`, collectionOptions),
2025
- lanes: database.collection(`${prefix}lanes`, collectionOptions),
2026
- completions: database.collection(`${prefix}completed_deliveries`, collectionOptions),
2027
- completionHistory: options.completionHistory ?? false,
2028
- transactionOptions: options.transactionOptions ?? DEFAULT_TRANSACTION_OPTIONS,
2029
- purgeBatchSize,
2030
- logger: options.logger ?? console,
2031
- instanceId: createUuidV7(),
2032
- claimsInProgress: /* @__PURE__ */ new Map(),
2033
- handedOut: /* @__PURE__ */ new Map(),
2034
- unreconciledOwners: /* @__PURE__ */ new Set()
2035
- };
2141
+ const state = createMongoStoreState(options);
2036
2142
  this.#state = state;
2037
2143
  const queries = new MongoQueries(state);
2038
2144
  const ordering = new MongoOrdering(state);
@@ -2115,7 +2221,7 @@ var MongoEventStore = class {
2115
2221
  */
2116
2222
  // eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
2117
2223
  extendLease(lease, leaseExpiresAt, _now) {
2118
- return this.#completion.extendLease(lease, leaseExpiresAt);
2224
+ return Promise.resolve(this.#completion.extendLease({ lease, leaseExpiresAt }));
2119
2225
  }
2120
2226
  /**
2121
2227
  * Deletes the delivery (and records it in the completion history, when kept).
@@ -2139,16 +2245,25 @@ var MongoEventStore = class {
2139
2245
  /** Returns a running delivery to pending, due at `availableAt`, and counts the attempt that ended. */
2140
2246
  // eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
2141
2247
  reschedule(lease, availableAt, error, now) {
2142
- return this.#completion.reschedule(lease, { availableAt, error, now });
2248
+ return Promise.resolve(
2249
+ this.#completion.changeClaim({
2250
+ lease,
2251
+ change: { kind: "reschedule", availableAt, error, now }
2252
+ })
2253
+ );
2143
2254
  }
2144
2255
  /** Gives a running delivery back without counting the attempt: pending, due `now`. */
2145
2256
  release(lease, now) {
2146
- return this.#completion.release(lease, now);
2257
+ return Promise.resolve(
2258
+ this.#completion.changeClaim({ lease, change: { kind: "release", now } })
2259
+ );
2147
2260
  }
2148
2261
  /** Moves a running delivery to dead and counts the attempt that ended. */
2149
2262
  // eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
2150
2263
  bury(lease, error, now) {
2151
- return this.#completion.bury(lease, { error, now });
2264
+ return Promise.resolve(
2265
+ this.#completion.changeClaim({ lease, change: { kind: "bury", error, now } })
2266
+ );
2152
2267
  }
2153
2268
  /**
2154
2269
  * Frees every lane `leaseOwner` holds that runs no delivery, so other workers