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