@relaybus/mongodb 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -7,46 +7,41 @@ var core = require('@relaybus/core');
7
7
  // src/claim-scan.ts
8
8
  var ClaimScan = class {
9
9
  #keys = /* @__PURE__ */ new Map();
10
- /** Why the delivery must wait according to what this scan already knows, if it does. */
11
- verdict(orderingId, sequence) {
10
+ /** Whether an unfinished delivery this scan knows of comes before the delivery with `sequence`. */
11
+ waits(orderingId, sequence) {
12
12
  const known = this.#keys.get(orderingId);
13
- if (known?.openSequence !== void 0 && sequence > known.openSequence) {
14
- return "open-predecessor";
15
- }
16
- return known?.leaseHeld === true ? "live-lease" : void 0;
13
+ return known?.openSequence !== void 0 && sequence > known.openSequence;
14
+ }
15
+ /** Whether this scan already learned anything about the key. */
16
+ knows(orderingId) {
17
+ return this.#keys.has(orderingId);
17
18
  }
18
19
  /** Records an unfinished delivery of the key with this sequence. */
19
20
  noteOpen(orderingId, sequence) {
20
21
  const known = this.#know(orderingId);
21
22
  known.openSequence = Math.min(known.openSequence ?? sequence, sequence);
22
23
  }
23
- /** Records that this scan leased the delivery, which is now unfinished and holds a live lease. */
24
- noteClaimed(orderingId, sequence) {
25
- this.noteOpen(orderingId, sequence);
26
- this.#know(orderingId).leaseHeld = true;
27
- }
28
- /** Records that a live lease of the key blocks every delivery of it. */
29
- noteLeaseHeld(orderingId) {
30
- this.#know(orderingId).leaseHeld = true;
31
- }
32
- /** Remembers a delivery that waited behind an unfinished predecessor. */
33
- noteWaiting(orderingId, delivery) {
34
- if (delivery.status === "pending") {
35
- this.#know(orderingId).waiting.push(delivery._id);
36
- }
24
+ /** Remembers a pending delivery of lane `laneId` that waited behind an unfinished predecessor. */
25
+ noteWaiting(orderingId, deliveryId, laneId) {
26
+ const known = this.#know(orderingId);
27
+ known.waiting.push(deliveryId);
28
+ known.laneId = laneId;
37
29
  }
38
30
  /**
39
31
  * Ids of the waiting deliveries of every key that accumulated at least
40
- * `minimum` of them. Shorter queues stay in the claim index: moving them out
41
- * and back would cost more than skipping them.
32
+ * `minimum` of them, or that lives in one of `handedBack`. Shorter queues
33
+ * of a lane that stays held remain in the claim index: moving them out and
34
+ * back would cost more than skipping them. In a lane handed back they are
35
+ * all its due work, and leaving them ready would make every later claim take
36
+ * the lane for nothing.
42
37
  */
43
- waitingBacklogs(minimum) {
44
- return [...this.#keys.values()].filter((known) => known.waiting.length >= minimum).flatMap((known) => known.waiting);
38
+ waitingBacklogs(minimum, handedBack) {
39
+ return [...this.#keys.values()].filter((known) => known.waiting.length >= minimum || handedBack.has(known.laneId)).flatMap((known) => known.waiting);
45
40
  }
46
41
  #know(orderingId) {
47
42
  let known = this.#keys.get(orderingId);
48
43
  if (known === void 0) {
49
- known = { openSequence: void 0, leaseHeld: false, waiting: [] };
44
+ known = { openSequence: void 0, waiting: [], laneId: void 0 };
50
45
  this.#keys.set(orderingId, known);
51
46
  }
52
47
  return known;
@@ -55,21 +50,49 @@ var ClaimScan = class {
55
50
  function orderingIdentity(handler, key) {
56
51
  return `${handler}\0${key}`;
57
52
  }
58
- function toEventDocument(event, sequence) {
53
+
54
+ // src/lanes.ts
55
+ var LANE_COUNT = 256;
56
+ var FNV_OFFSET_BASIS = 2166136261;
57
+ var FNV_PRIME = 16777619;
58
+ var utf8 = new TextEncoder();
59
+ function laneOf(key, eventId) {
60
+ let hash = FNV_OFFSET_BASIS;
61
+ for (const byte of utf8.encode(key ?? eventId)) {
62
+ hash = Math.imul(hash ^ byte, FNV_PRIME);
63
+ }
64
+ return hash & LANE_COUNT - 1;
65
+ }
66
+ function formatLeaseToken(laneId, epoch, claims) {
67
+ return `${laneId}:${epoch}:${claims}`;
68
+ }
69
+ var LEASE_TOKEN_PATTERN = /^(\d{1,3}):(\d{1,15}):(\d{1,15})$/;
70
+ function parseLeaseToken(token) {
71
+ const match = LEASE_TOKEN_PATTERN.exec(token);
72
+ if (match === null || Number(match[1]) >= LANE_COUNT) {
73
+ return void 0;
74
+ }
75
+ return { laneId: Number(match[1]), epoch: Number(match[2]), claims: Number(match[3]) };
76
+ }
77
+
78
+ // src/documents.ts
79
+ function toEventDocument(event, sequence, handlers, createdAt) {
59
80
  const payloadJson = serializeWellFormed(event.payload, "payload");
60
81
  const headersJson = serializeWellFormed(event.headers, "headers");
61
82
  return {
62
83
  _id: event.id,
63
84
  type: event.type,
64
85
  source: event.source,
65
- key: event.key,
66
- sequence,
86
+ // Fields that are null or 0 are left out: every byte of a new document is written to the oplog too.
87
+ ...event.key === null ? {} : { key: event.key },
88
+ ...sequence === null ? {} : { sequence },
67
89
  ...payloadJson === void 0 ? {} : { payload: JSON.parse(payloadJson) },
68
90
  headers: JSON.parse(headersJson),
69
- correlationId: event.correlationId,
70
- causationId: event.causationId,
91
+ ...event.correlationId === null ? {} : { correlationId: event.correlationId },
92
+ ...event.causationId === null ? {} : { causationId: event.causationId },
71
93
  occurredAt: event.occurredAt,
72
- revision: 0
94
+ handlers,
95
+ createdAt
73
96
  };
74
97
  }
75
98
  var LONE_SURROGATE_ESCAPE = /(?<!\\)(?:\\\\)*\\ud[89a-f][0-9a-f]{2}/i;
@@ -90,11 +113,11 @@ function toEnvelope(document) {
90
113
  id: document._id,
91
114
  type: document.type,
92
115
  source: document.source,
93
- key: document.key,
116
+ key: document.key ?? null,
94
117
  payload: document.payload,
95
118
  headers: document.headers,
96
- correlationId: document.correlationId,
97
- causationId: document.causationId,
119
+ correlationId: document.correlationId ?? null,
120
+ causationId: document.causationId ?? null,
98
121
  occurredAt: document.occurredAt
99
122
  };
100
123
  }
@@ -105,31 +128,60 @@ function toDeliveryDocument(delivery, event, ordering, now) {
105
128
  eventType: event.type,
106
129
  handler: delivery.handler,
107
130
  ...ordering.key === null || ordering.sequence === null ? {} : { key: ordering.key, sequence: ordering.sequence },
131
+ laneId: laneOf(ordering.key, event.id),
108
132
  status: "pending",
109
133
  lane: "ready",
110
134
  attempts: 0,
135
+ // No updatedAt (it equals createdAt): every byte of a new document is written to the oplog too.
111
136
  availableAt: delivery.availableAt,
112
137
  dueAt: delivery.availableAt,
113
- createdAt: now,
114
- updatedAt: now
138
+ createdAt: now
115
139
  };
116
140
  }
117
- function toDeliveryRecord(document, event) {
141
+ function toDeliveryRecord(document, event, runningIn) {
118
142
  return {
119
143
  id: document._id,
120
144
  handler: document.handler,
121
- status: document.status,
122
- attempts: document.attempts,
145
+ status: runningIn === void 0 ? document.status : "processing",
146
+ attempts: runningIn === void 0 ? document.attempts : document.attempts + 1,
123
147
  availableAt: document.availableAt,
124
- leaseOwner: document.leaseOwner ?? null,
125
- leaseExpiresAt: document.leaseExpiresAt ?? null,
148
+ leaseOwner: runningIn === void 0 ? null : runningIn.owner,
149
+ leaseExpiresAt: runningIn === void 0 ? null : runningIn.leaseExpiresAt,
126
150
  lastError: document.lastError === void 0 ? null : toErrorInfo(document.lastError),
127
151
  createdAt: document.createdAt,
128
- updatedAt: document.updatedAt,
152
+ updatedAt: document.updatedAt ?? document.createdAt,
129
153
  completedAt: document.completedAt ?? null,
130
154
  event
131
155
  };
132
156
  }
157
+ function toCompletedDeliveryRecord(document, event) {
158
+ return {
159
+ id: document._id,
160
+ handler: document.handler,
161
+ status: "completed",
162
+ attempts: document.attempts,
163
+ availableAt: document.availableAt,
164
+ leaseOwner: null,
165
+ leaseExpiresAt: null,
166
+ lastError: null,
167
+ createdAt: document.createdAt,
168
+ updatedAt: document.completedAt,
169
+ completedAt: document.completedAt,
170
+ event
171
+ };
172
+ }
173
+ function toCompletedDeliveryDocument(document, completedAt) {
174
+ return {
175
+ _id: document._id,
176
+ eventId: document.eventId,
177
+ eventType: document.eventType,
178
+ handler: document.handler,
179
+ attempts: document.attempts + 1,
180
+ availableAt: document.availableAt,
181
+ createdAt: document.createdAt,
182
+ completedAt
183
+ };
184
+ }
133
185
  function toErrorInfo(error) {
134
186
  return error.stack === void 0 ? { name: error.name, message: error.message } : { name: error.name, message: error.message, stack: error.stack };
135
187
  }
@@ -167,11 +219,17 @@ var DEFAULT_TRANSACTION_OPTIONS = {
167
219
  readPreference: "primary"
168
220
  };
169
221
  var NAMESPACE_EXISTS = 48;
222
+ var INDEX_NOT_FOUND = 27;
170
223
  var DEFAULT_PURGE_BATCH_SIZE = 500;
171
224
  var DEFAULT_LIST_LIMIT = 100;
172
225
  var MINIMUM_CANDIDATE_BATCH_SIZE = 32;
173
226
  var PARK_BACKLOG_THRESHOLD = 32;
174
227
  var PARK_UPDATE_CHUNK_SIZE = 1e3;
228
+ var UPGRADE_BATCH_SIZE = 1e3;
229
+ var INITIAL_LANES_PER_ROUND = 4;
230
+ var LANES_PER_CANDIDATE_QUERY = 128;
231
+ var CLAIM_INDEX_KEY = { laneId: 1, dueAt: 1, _id: 1, handler: 1 };
232
+ var OBSOLETE_INDEXES = ["running"];
175
233
  var LEASE_FIELDS = { leaseOwner: "", leaseToken: "", leaseExpiresAt: "" };
176
234
  var FINISHED_FIELDS = { dueAt: "", lane: "" };
177
235
  var MongoEventStore = class {
@@ -180,9 +238,33 @@ var MongoEventStore = class {
180
238
  #events;
181
239
  #deliveries;
182
240
  #keySequences;
241
+ #lanes;
242
+ #completions;
243
+ #completionHistory;
183
244
  #transactionOptions;
184
245
  #purgeBatchSize;
185
- #generateIdentifier;
246
+ #logger;
247
+ /**
248
+ * Identifies this store instance in the lanes it holds. A process that restarts
249
+ * with the same `leaseOwner` gets a new instance, so it never mistakes the
250
+ * lanes of its dead predecessor for its own: it waits for their leases to
251
+ * expire and takes them over like any other worker.
252
+ */
253
+ #instanceId = core.createUuidV7();
254
+ /** The last claim of each owner still running on this instance; the next one waits for it. */
255
+ #claimsInProgress = /* @__PURE__ */ new Map();
256
+ /**
257
+ * Claims this instance handed out that are not settled yet, keyed by
258
+ * {@link leaseKey} (delivery id and exact lease token), with the owner. A
259
+ * claim is settled when a completion, retry, release or burial of exactly
260
+ * that lease returned, or, for a completion inside the caller's transaction
261
+ * (which may still retry or roll back), when a later claim of the owner finds
262
+ * the delivery gone. After a failed claim, a listed delivery stays listed only
263
+ * if one of these is its current claim (current lane epoch and claim generation).
264
+ */
265
+ #handedOut = /* @__PURE__ */ new Map();
266
+ /** Owners whose lanes on this instance still need reconciling after a failed claim. */
267
+ #unreconciledOwners = /* @__PURE__ */ new Set();
186
268
  constructor(options) {
187
269
  const prefix = options.collectionPrefix ?? "relaybus_";
188
270
  assertCollectionPrefix(prefix);
@@ -205,13 +287,19 @@ var MongoEventStore = class {
205
287
  this.#events = database.collection(`${prefix}events`, collectionOptions);
206
288
  this.#deliveries = database.collection(`${prefix}deliveries`, collectionOptions);
207
289
  this.#keySequences = database.collection(`${prefix}key_sequences`, collectionOptions);
290
+ this.#lanes = database.collection(`${prefix}lanes`, collectionOptions);
291
+ this.#completions = database.collection(`${prefix}completed_deliveries`, collectionOptions);
292
+ this.#completionHistory = options.completionHistory ?? false;
208
293
  this.#transactionOptions = options.transactionOptions ?? DEFAULT_TRANSACTION_OPTIONS;
209
294
  this.#purgeBatchSize = purgeBatchSize;
210
- this.#generateIdentifier = options.generateIdentifier ?? (() => core.createUuidV7());
295
+ this.#logger = options.logger ?? console;
211
296
  }
212
297
  /**
213
- * Verifies the deployment supports transactions and creates the indexes.
214
- * Safe to run repeatedly and from several processes.
298
+ * Verifies the deployment supports transactions, creates the collections and
299
+ * indexes and seeds the lane documents. Upgrades a 0.1.0 database in place
300
+ * first (see {@link MongoEventStore.#upgradeFromVersion010}); the seeded lanes
301
+ * mark the upgrade as done, so later runs skip it. Safe to run repeatedly and
302
+ * from several processes. Stop 0.1.0 workers before the first run.
215
303
  *
216
304
  * @throws ConfigurationError when the server is a standalone mongod.
217
305
  */
@@ -221,18 +309,27 @@ var MongoEventStore = class {
221
309
  if (!supportsTransactions) {
222
310
  throw createTransactionsUnsupportedError(void 0);
223
311
  }
224
- for (const collection of [this.#events, this.#deliveries, this.#keySequences]) {
225
- await this.#createCollectionIfMissing(collection.collectionName);
312
+ const collectionNames = [
313
+ this.#events.collectionName,
314
+ this.#deliveries.collectionName,
315
+ this.#keySequences.collectionName,
316
+ this.#lanes.collectionName,
317
+ ...this.#completionHistory ? [this.#completions.collectionName] : []
318
+ ];
319
+ for (const name of collectionNames) {
320
+ await this.#createCollectionIfMissing(name);
226
321
  }
322
+ if (await this.#lanes.countDocuments() < LANE_COUNT) {
323
+ await this.#upgradeFromVersion010();
324
+ await this.#seedLanes();
325
+ }
326
+ await this.#dropObsoleteIndexes();
227
327
  await this.#deliveries.createIndexes([
228
328
  // Idempotent appends and lookups by event.
229
329
  { key: { eventId: 1, handler: 1 }, name: "event_handler", unique: true },
230
- // Claim: due work of a handler in due order. Holds ready deliveries only.
231
- {
232
- key: { handler: 1, dueAt: 1, _id: 1 },
233
- name: "claim",
234
- partialFilterExpression: { lane: "ready" }
235
- },
330
+ // Claim: due work of a lane in due order. Holds ready deliveries only. The handler is
331
+ // last, so a query over many lanes and handlers stays a merge of one scan per lane.
332
+ { key: CLAIM_INDEX_KEY, name: "claim", partialFilterExpression: { lane: "ready" } },
236
333
  // Ordering: unfinished predecessors of a keyed delivery, parked or not.
237
334
  {
238
335
  key: { handler: 1, key: 1, sequence: 1 },
@@ -245,15 +342,9 @@ var MongoEventStore = class {
245
342
  name: "parked",
246
343
  partialFilterExpression: { lane: "parked" }
247
344
  },
248
- // Live leases: a keyed delivery may not start while another of its key is running.
249
- {
250
- key: { handler: 1, key: 1, dueAt: 1 },
251
- name: "running",
252
- partialFilterExpression: { status: "processing", key: { $exists: true } }
253
- },
254
345
  // Statistics: oldest due delivery across handlers.
255
346
  { key: { dueAt: 1 }, name: "due", partialFilterExpression: { dueAt: { $exists: true } } },
256
- // Purge of completed deliveries.
347
+ // Purge of completed deliveries left by 0.1.0.
257
348
  {
258
349
  key: { completedAt: 1 },
259
350
  name: "completed",
@@ -266,16 +357,146 @@ var MongoEventStore = class {
266
357
  { key: { handler: 1, createdAt: 1 }, name: "handler_created" },
267
358
  { key: { eventType: 1, createdAt: 1 }, name: "type_created" }
268
359
  ]);
360
+ await this.#events.createIndexes([
361
+ { key: { createdAt: 1, _id: 1 }, name: "created" },
362
+ // Purge of events whose deliveries all completed: they are kept until `completedBefore` passes this.
363
+ {
364
+ key: { completedAt: 1, _id: 1 },
365
+ name: "completed",
366
+ partialFilterExpression: { completedAt: { $exists: true } }
367
+ }
368
+ ]);
369
+ if (this.#completionHistory) {
370
+ await this.#completions.createIndexes([
371
+ { key: { completedAt: 1, _id: 1 }, name: "completed" },
372
+ { key: { eventId: 1 }, name: "event" },
373
+ { key: { createdAt: 1, _id: 1 }, name: "created" }
374
+ ]);
375
+ }
269
376
  }
270
377
  async #createCollectionIfMissing(name) {
271
378
  try {
272
379
  await this.#database.createCollection(name);
273
380
  } catch (error) {
274
- if (!(typeof error === "object" && error !== null && "code" in error && error.code === NAMESPACE_EXISTS)) {
381
+ if (!hasErrorCode(error, NAMESPACE_EXISTS)) {
275
382
  throw error;
276
383
  }
277
384
  }
278
385
  }
386
+ /**
387
+ * Converts the documents of a 0.1.0 database; every step is idempotent, so a
388
+ * run that crashed is simply repeated. Deliveries that were `processing`
389
+ * become `pending`, due at once (their attempt stays counted, as a lost lease
390
+ * would count it). The per-delivery lease fields go, every delivery gets its
391
+ * lane, and every event learns which handlers it already has deliveries for
392
+ * and when it was created. Deliveries completed under 0.1.0 stay as
393
+ * `completed` documents until a purge removes them.
394
+ */
395
+ async #upgradeFromVersion010() {
396
+ const legacy = this.#database.collection(this.#deliveries.collectionName);
397
+ await legacy.updateMany({ status: "processing" }, [
398
+ { $set: { status: "pending", lane: "ready", dueAt: "$availableAt" } }
399
+ ]);
400
+ await legacy.updateMany(
401
+ {
402
+ $or: [
403
+ { leaseOwner: { $exists: true } },
404
+ { leaseToken: { $exists: true } },
405
+ { leaseExpiresAt: { $exists: true } }
406
+ ]
407
+ },
408
+ { $unset: LEASE_FIELDS }
409
+ );
410
+ await inBatches(
411
+ this.#deliveries.find(
412
+ { laneId: { $exists: false } },
413
+ { projection: { eventId: 1, key: 1 }, batchSize: UPGRADE_BATCH_SIZE }
414
+ ),
415
+ async (documents) => {
416
+ await this.#deliveries.bulkWrite(
417
+ documents.map((document) => ({
418
+ updateOne: {
419
+ filter: { _id: document._id },
420
+ update: { $set: { laneId: laneOf(document.key ?? null, document.eventId) } }
421
+ }
422
+ })),
423
+ { ordered: false }
424
+ );
425
+ }
426
+ );
427
+ await inBatches(
428
+ this.#events.find(
429
+ { handlers: { $exists: false } },
430
+ { projection: { occurredAt: 1 }, batchSize: UPGRADE_BATCH_SIZE }
431
+ ),
432
+ async (events) => {
433
+ const known = new Map(
434
+ (await this.#deliveries.aggregate([
435
+ { $match: { eventId: { $in: events.map((event) => event._id) } } },
436
+ {
437
+ $group: {
438
+ _id: "$eventId",
439
+ handlers: { $addToSet: "$handler" },
440
+ createdAt: { $min: "$createdAt" }
441
+ }
442
+ }
443
+ ]).toArray()).map((group) => [group._id, group])
444
+ );
445
+ await this.#events.bulkWrite(
446
+ events.map((event) => {
447
+ const group = known.get(event._id);
448
+ return {
449
+ updateOne: {
450
+ // Only an event nobody converted yet: another process may have finished and subscribed a handler.
451
+ filter: { _id: event._id, handlers: { $exists: false } },
452
+ update: {
453
+ $set: group === void 0 ? { handlers: [], createdAt: event.occurredAt } : { handlers: group.handlers, createdAt: group.createdAt }
454
+ }
455
+ }
456
+ };
457
+ }),
458
+ { ordered: false }
459
+ );
460
+ }
461
+ );
462
+ }
463
+ /** Creates the lane documents that do not exist yet. */
464
+ async #seedLanes() {
465
+ await this.#lanes.bulkWrite(
466
+ Array.from({ length: LANE_COUNT }, (_, laneId) => ({
467
+ updateOne: {
468
+ filter: { _id: laneId },
469
+ update: {
470
+ $setOnInsert: {
471
+ epoch: 0,
472
+ owner: null,
473
+ holder: null,
474
+ leaseExpiresAt: null,
475
+ inFlight: [],
476
+ lost: []
477
+ }
478
+ },
479
+ upsert: true
480
+ }
481
+ })),
482
+ { ordered: false }
483
+ );
484
+ }
485
+ /** Drops the 0.1.0 indexes that no query uses, and a `claim` index with the 0.1.0 key. */
486
+ async #dropObsoleteIndexes() {
487
+ for (const index of await this.#deliveries.indexes()) {
488
+ const obsolete = OBSOLETE_INDEXES.includes(index.name) || index.name === "claim" && Object.keys(index.key).join() !== Object.keys(CLAIM_INDEX_KEY).join();
489
+ if (obsolete) {
490
+ try {
491
+ await this.#deliveries.dropIndex(index.name);
492
+ } catch (error) {
493
+ if (!hasErrorCode(error, INDEX_NOT_FOUND)) {
494
+ throw error;
495
+ }
496
+ }
497
+ }
498
+ }
499
+ }
279
500
  /**
280
501
  * Runs `work` in a transaction on a new session and commits when it resolves.
281
502
  *
@@ -304,23 +525,34 @@ var MongoEventStore = class {
304
525
  throw error;
305
526
  }
306
527
  }
528
+ /**
529
+ * Writes the entries in `session`. An existing event gets a delivery only for
530
+ * handlers it does not list yet; the added handlers are appended to its list
531
+ * together with a `revision` increment, a real write that makes a concurrent
532
+ * purge of the event conflict with this transaction.
533
+ */
307
534
  async append(session, entries, now) {
308
535
  assertInTransaction(session, "append");
309
536
  const eventIds = [...new Set(entries.map((entry) => entry.event.id))];
310
537
  const storedEvents = new Map(
311
- (await this.#events.find({ _id: { $in: eventIds } }, { session, projection: { key: 1, sequence: 1 } }).toArray()).map((stored) => [stored._id, stored])
312
- );
313
- const storedDeliveryKeys = new Set(
314
- (await this.#deliveries.find({ eventId: { $in: eventIds } }, { session, projection: { eventId: 1, handler: 1 } }).toArray()).map((stored) => deliveryPairKey(stored.eventId, stored.handler))
538
+ (await this.#events.find(
539
+ { _id: { $in: eventIds } },
540
+ { session, projection: { key: 1, sequence: 1, handlers: 1 } }
541
+ ).toArray()).map((stored) => [stored._id, stored])
315
542
  );
316
543
  const appendedEventIds = [];
317
544
  const duplicateEventIds = [];
318
545
  const newEvents = [];
319
546
  const newDeliveries = [];
320
- const touchedEventIds = /* @__PURE__ */ new Set();
547
+ const knownHandlers = /* @__PURE__ */ new Map();
548
+ const addedHandlers = /* @__PURE__ */ new Map();
321
549
  const orderingByEventId = /* @__PURE__ */ new Map();
322
550
  for (const [eventId, stored] of storedEvents) {
323
- orderingByEventId.set(eventId, { key: stored.key, sequence: stored.sequence });
551
+ orderingByEventId.set(eventId, {
552
+ key: stored.key ?? null,
553
+ sequence: stored.sequence ?? null
554
+ });
555
+ knownHandlers.set(eventId, new Set(stored.handlers));
324
556
  }
325
557
  for (const { event, deliveries } of entries) {
326
558
  let ordering = orderingByEventId.get(event.id);
@@ -328,42 +560,49 @@ var MongoEventStore = class {
328
560
  const sequence = event.key === null ? null : await this.#nextSequence(event.key, session);
329
561
  ordering = { key: event.key, sequence };
330
562
  orderingByEventId.set(event.id, ordering);
331
- newEvents.push(toEventDocument(event, sequence));
563
+ knownHandlers.set(event.id, /* @__PURE__ */ new Set());
564
+ newEvents.push({ event, sequence });
332
565
  appendedEventIds.push(event.id);
333
566
  } else {
334
567
  duplicateEventIds.push(event.id);
335
568
  }
569
+ const handlers = knownHandlers.get(event.id);
336
570
  for (const delivery of deliveries) {
337
- const pairKey = deliveryPairKey(event.id, delivery.handler);
338
- if (storedDeliveryKeys.has(pairKey)) {
571
+ if (handlers.has(delivery.handler)) {
339
572
  continue;
340
573
  }
341
- storedDeliveryKeys.add(pairKey);
574
+ handlers.add(delivery.handler);
575
+ addedHandlers.set(event.id, [...addedHandlers.get(event.id) ?? [], delivery.handler]);
342
576
  newDeliveries.push(toDeliveryDocument(delivery, event, ordering, now));
343
- if (storedEvents.has(event.id)) {
344
- touchedEventIds.add(event.id);
345
- }
346
577
  }
347
578
  }
348
579
  if (newEvents.length > 0) {
349
580
  await this.#events.bulkWrite(
350
- newEvents.map((document) => ({
581
+ newEvents.map(({ event, sequence }) => {
582
+ const document = toEventDocument(event, sequence, addedHandlers.get(event.id) ?? [], now);
583
+ return {
584
+ updateOne: {
585
+ filter: { _id: document._id },
586
+ update: { $setOnInsert: document },
587
+ upsert: true
588
+ }
589
+ };
590
+ }),
591
+ { session, ordered: true }
592
+ );
593
+ }
594
+ const extendedEvents = [...addedHandlers].filter(([eventId]) => storedEvents.has(eventId));
595
+ if (extendedEvents.length > 0) {
596
+ await this.#events.bulkWrite(
597
+ extendedEvents.map(([eventId, handlers]) => ({
351
598
  updateOne: {
352
- filter: { _id: document._id },
353
- update: { $setOnInsert: document },
354
- upsert: true
599
+ filter: { _id: eventId },
600
+ update: { $inc: { revision: 1 }, $push: { handlers: { $each: handlers } } }
355
601
  }
356
602
  })),
357
603
  { session, ordered: true }
358
604
  );
359
605
  }
360
- if (touchedEventIds.size > 0) {
361
- await this.#events.updateMany(
362
- { _id: { $in: [...touchedEventIds] } },
363
- { $inc: { revision: 1 } },
364
- { session }
365
- );
366
- }
367
606
  if (newDeliveries.length > 0) {
368
607
  await this.#deliveries.bulkWrite(
369
608
  newDeliveries.map((document) => ({
@@ -378,86 +617,371 @@ var MongoEventStore = class {
378
617
  }
379
618
  return { appendedEventIds, duplicateEventIds, appendedDeliveryCount: newDeliveries.length };
380
619
  }
620
+ /**
621
+ * Claims up to `limit` due deliveries. It claims from the lanes this owner
622
+ * (and this store instance) holds under a live lease first, then takes free
623
+ * lanes with due work, a few at a time (4, 8, 16, ...) and the lane with the
624
+ * oldest due delivery first, so a worker holds only as many lanes as it
625
+ * needs. It then records the batch with one write per lane and hands back the
626
+ * lanes that yielded nothing and run nothing. No delivery document is
627
+ * written, except for the attempt charged to deliveries of a lane taken over
628
+ * and for parking (see {@link MongoEventStore.#parkDeliveries}).
629
+ *
630
+ * Taking a lane is one conditional `findOneAndUpdate` that also moves the
631
+ * previous owner's in-flight list to the lane's `lost` list; a lane another
632
+ * claimer took first is skipped, never waited for. Lost deliveries are charged
633
+ * one attempt before anything is read from the lane, and `lost` is cleared
634
+ * only after that. Whoever holds the lane next charges a `lost` list it finds
635
+ * first, so a claim that fails anywhere after the take loses no charge; the
636
+ * charge of a lost claim is recorded on the delivery, so charging the list
637
+ * again after a failure before the clear charges nothing twice.
638
+ * Nothing a failed claim did leaves a delivery listed as running.
639
+ */
381
640
  async claim(request) {
382
641
  if (request.handlers.length === 0 || request.limit <= 0) {
383
642
  return [];
384
643
  }
385
- await this.#unparkNextDeliveries(request.handlers);
386
- const claimed = [];
387
- const scan = new ClaimScan();
388
- const candidates = this.#deliveries.find(
389
- { handler: { $in: [...request.handlers] }, dueAt: { $lte: request.now }, lane: "ready" },
390
- {
391
- sort: { dueAt: 1, _id: 1 },
392
- batchSize: Math.max(request.limit * 2, MINIMUM_CANDIDATE_BATCH_SIZE)
644
+ const owner = request.leaseOwner;
645
+ const previous = this.#claimsInProgress.get(owner) ?? Promise.resolve();
646
+ const claimed = previous.then(() => this.#claimReconciled(request));
647
+ const settled = Promise.allSettled([claimed]);
648
+ this.#claimsInProgress.set(owner, settled);
649
+ void settled.then(() => {
650
+ if (this.#claimsInProgress.get(owner) === settled) {
651
+ this.#claimsInProgress.delete(owner);
393
652
  }
394
- );
653
+ });
654
+ return claimed;
655
+ }
656
+ /**
657
+ * Runs one claim and remembers what it hands out. When the claim fails at any
658
+ * point, also after its record reached the database without the reply
659
+ * reaching this process, the lanes this owner holds on this instance are
660
+ * reconciled: every listed delivery this instance has not handed out, or has
661
+ * handed out and seen settled, is taken out of them, so nothing stays listed
662
+ * as running that nobody runs.
663
+ */
664
+ async #claimReconciled(request) {
665
+ const owner = request.leaseOwner;
666
+ if (this.#unreconciledOwners.has(owner)) {
667
+ await this.#reconcileLanes(owner);
668
+ this.#unreconciledOwners.delete(owner);
669
+ }
395
670
  try {
396
- for await (const candidate of candidates) {
397
- const leased = await this.#claimCandidate(candidate, request, scan);
398
- if (leased !== void 0) {
399
- claimed.push(leased);
400
- if (claimed.length >= request.limit) {
401
- break;
671
+ const claimed = await this.#claimNow(request);
672
+ for (const delivery of claimed) {
673
+ const { laneId, epoch } = parseLeaseToken(delivery.leaseToken);
674
+ this.#handedOut.set(leaseKey(delivery.deliveryId, delivery.leaseToken), {
675
+ deliveryId: delivery.deliveryId,
676
+ owner: request.leaseOwner,
677
+ laneId,
678
+ epoch
679
+ });
680
+ }
681
+ return claimed;
682
+ } catch (error) {
683
+ const [reconciled] = await Promise.allSettled([this.#reconcileLanes(owner)]);
684
+ if (reconciled.status === "rejected") {
685
+ this.#unreconciledOwners.add(owner);
686
+ }
687
+ throw error;
688
+ }
689
+ }
690
+ /**
691
+ * Takes every delivery this instance does not run out of the lanes `owner`
692
+ * holds on it: every listed delivery whose current claim (lane epoch and
693
+ * claim generation now) was not handed out by this instance, or was settled.
694
+ * One transaction, and the lanes are written first under their epoch and
695
+ * holder: a worker taking a lane over meanwhile conflicts with it, so its
696
+ * claim is never touched. The claim generation of every delivery taken out
697
+ * moves on, so no lease of those claims (a completion that failed and is
698
+ * retried later, say) can ever match a later claim of the same delivery.
699
+ * Registrations of this owner that are not the current claim of a listed
700
+ * delivery are dropped.
701
+ */
702
+ async #reconcileLanes(owner) {
703
+ await this.transaction(async (session) => {
704
+ const held = await this.#lanes.find({ owner, holder: this.#instanceId }, { session }).toArray();
705
+ const listed = held.flatMap((lane) => lane.inFlight);
706
+ const claimsById = new Map(
707
+ (await this.#deliveries.find(
708
+ { _id: { $in: listed }, status: "pending" },
709
+ { session, projection: { claims: 1 } }
710
+ ).toArray()).map((document) => [document._id, document.claims ?? 0])
711
+ );
712
+ const current = /* @__PURE__ */ new Set();
713
+ const unclaimed = held.map((lane) => ({
714
+ lane,
715
+ ids: lane.inFlight.filter((id) => {
716
+ const key = leaseKey(id, formatLeaseToken(lane._id, lane.epoch, claimsById.get(id) ?? 0));
717
+ current.add(key);
718
+ return !this.#handedOut.has(key);
719
+ })
720
+ }));
721
+ for (const [key, entry] of this.#handedOut) {
722
+ if (entry.owner === owner && !current.has(key)) {
723
+ this.#handedOut.delete(key);
724
+ }
725
+ }
726
+ if (held.length === 0) {
727
+ return;
728
+ }
729
+ await this.#lanes.bulkWrite(
730
+ unclaimed.map(({ lane, ids }) => ({
731
+ updateOne: {
732
+ filter: { _id: lane._id, epoch: lane.epoch, holder: this.#instanceId },
733
+ update: { $pull: { inFlight: { $in: ids } }, $inc: { revision: 1 } }
402
734
  }
735
+ })),
736
+ { session, ordered: false }
737
+ );
738
+ await this.#deliveries.updateMany(
739
+ { _id: { $in: unclaimed.flatMap((entry) => entry.ids) }, status: "pending" },
740
+ { $inc: { claims: 1 } },
741
+ { session }
742
+ );
743
+ });
744
+ }
745
+ /**
746
+ * Runs `operation`, which ends the claims of `leases` (completion, retry,
747
+ * release, burial), and once it returned forgets exactly those leases as
748
+ * handed out. When it throws, they stay registered: the caller may retry
749
+ * with the same lease, which must still find its claim.
750
+ */
751
+ async #settle(leases, operation) {
752
+ const result = await operation();
753
+ for (const lease of leases) {
754
+ this.#handedOut.delete(leaseKey(String(lease.deliveryId), String(lease.leaseToken)));
755
+ }
756
+ return result;
757
+ }
758
+ async #claimNow(request) {
759
+ const [held] = await Promise.all([
760
+ this.#lanes.find({
761
+ owner: request.leaseOwner,
762
+ holder: this.#instanceId,
763
+ leaseExpiresAt: { $gt: request.now }
764
+ }).toArray(),
765
+ this.#unparkNextDeliveries(request.handlers)
766
+ ]);
767
+ await this.#chargeLost(held);
768
+ const scope = new Map(
769
+ held.map((lane) => [
770
+ lane._id,
771
+ {
772
+ laneId: lane._id,
773
+ epoch: lane.epoch,
774
+ listed: lane.inFlight,
775
+ revision: lane.revision ?? 0
403
776
  }
777
+ ])
778
+ );
779
+ const running = this.#findRunning(held.flatMap((lane) => lane.inFlight)).then((documents) => ({
780
+ ids: new Set(documents.map((document) => document._id)),
781
+ keys: new Set(
782
+ documents.flatMap(
783
+ (document) => document.key === void 0 ? [] : [orderingIdentity(document.handler, document.key)]
784
+ )
785
+ )
786
+ }));
787
+ const scan = new ClaimScan();
788
+ const picked = [];
789
+ const pickFrom = async (lanes) => {
790
+ for (let offset = 0; offset < lanes.length; offset += LANES_PER_CANDIDATE_QUERY) {
791
+ if (picked.length < request.limit) {
792
+ await this.#pickCandidates(
793
+ lanes.slice(offset, offset + LANES_PER_CANDIDATE_QUERY),
794
+ request,
795
+ scan,
796
+ running,
797
+ picked
798
+ );
799
+ }
800
+ }
801
+ };
802
+ await pickFrom([...scope.values()]);
803
+ const passed = new Set(scope.keys());
804
+ for (let laneCount = INITIAL_LANES_PER_ROUND; picked.length < request.limit; laneCount *= 2) {
805
+ const free = await this.#findFreeLanes(request, laneCount, [...passed]);
806
+ if (free.length === 0) {
807
+ break;
808
+ }
809
+ for (const laneId of free) {
810
+ passed.add(laneId);
811
+ }
812
+ const taken = await this.#takeLanes(free, request);
813
+ for (const lane of taken) {
814
+ scope.set(lane.laneId, lane);
815
+ }
816
+ await pickFrom(taken);
817
+ }
818
+ const events = await this.#readEnvelopes(picked.map((document) => document.eventId));
819
+ const runningIds = (await running).ids;
820
+ const gone = new Set(
821
+ held.flatMap(
822
+ (lane) => lane.inFlight.filter((id) => !runningIds.has(id)).map((id) => `${id}\0${lane._id}:${lane.epoch}`)
823
+ )
824
+ );
825
+ for (const [key, entry] of this.#handedOut) {
826
+ if (entry.owner === request.leaseOwner && gone.has(`${entry.deliveryId}\0${entry.laneId}:${entry.epoch}`)) {
827
+ this.#handedOut.delete(key);
404
828
  }
405
- } finally {
406
- await candidates.close();
407
829
  }
408
- await this.#parkDeliveries(scan.waitingBacklogs(PARK_BACKLOG_THRESHOLD));
409
- return this.#toClaimedDeliveries(claimed, request.leaseExpiresAt);
830
+ const { recorded, handedBack } = await this.#recordClaim(scope, picked, runningIds, request);
831
+ const claimed = await this.#releaseOvertaken(
832
+ picked.filter((document) => recorded.has(document.laneId)),
833
+ scope
834
+ );
835
+ await this.#parkWaiting(scan.waitingBacklogs(PARK_BACKLOG_THRESHOLD, handedBack));
836
+ return claimed.map((document) => ({
837
+ deliveryId: document._id,
838
+ leaseToken: formatLeaseToken(
839
+ document.laneId,
840
+ scope.get(document.laneId).epoch,
841
+ document.claims ?? 0
842
+ ),
843
+ handler: document.handler,
844
+ attempt: document.attempts + 1,
845
+ leaseExpiresAt: request.leaseExpiresAt,
846
+ event: events.get(document.eventId)
847
+ }));
410
848
  }
411
- async extendLease(lease, leaseExpiresAt, now) {
412
- return this.#updateLeased(lease, void 0, {
413
- $set: { leaseExpiresAt, dueAt: leaseExpiresAt, updatedAt: now }
849
+ /**
850
+ * Extends the lease of the lane a running delivery belongs to. The lane lease
851
+ * never shrinks, so a repeated renewal with the same value still holds. An
852
+ * expired lease nobody took over is still renewed, so `_now` is not needed.
853
+ */
854
+ async extendLease(lease, leaseExpiresAt, _now) {
855
+ const held = parseLeaseToken(lease.leaseToken);
856
+ if (held === void 0) {
857
+ return false;
858
+ }
859
+ const deliveryId = String(lease.deliveryId);
860
+ return this.transaction(async (session) => {
861
+ const running = await this.#deliveries.findOne(ofClaim({ ...held, deliveryId }), {
862
+ session,
863
+ projection: { _id: 1 }
864
+ });
865
+ if (running === null) {
866
+ return false;
867
+ }
868
+ const renewed = await this.#lanes.updateOne(
869
+ { _id: held.laneId, epoch: held.epoch, inFlight: deliveryId },
870
+ { $max: { leaseExpiresAt } },
871
+ { session }
872
+ );
873
+ return renewed.matchedCount === 1;
414
874
  });
415
875
  }
876
+ /**
877
+ * Deletes the delivery (and records it in the completion history, when kept).
878
+ * With `session` the deletion commits with the caller's transaction; without,
879
+ * it runs in a transaction of its own. Returns false when the lease was lost.
880
+ */
416
881
  async complete(lease, now, session) {
417
882
  if (session !== void 0) {
418
883
  assertInTransaction(session, "complete");
419
884
  }
420
- return this.#updateLeased(lease, session, {
421
- $set: { status: "completed", completedAt: now, updatedAt: now },
422
- $unset: { ...LEASE_FIELDS, ...FINISHED_FIELDS }
423
- });
885
+ return (await this.#completeLeases([lease], now, session))[0];
424
886
  }
887
+ /**
888
+ * Completes many leases with a fixed number of round trips, whatever lanes
889
+ * they belong to: three commands without a completion history, one
890
+ * transaction with it (see {@link MongoEventStore.#deleteFenced}).
891
+ * Returns one flag per lease in input order; a lease listed twice completes
892
+ * once, and a token this store cannot have issued is refused.
893
+ */
894
+ completeMany(leases, now) {
895
+ return this.#completeLeases(leases, now, void 0);
896
+ }
897
+ /** Returns a running delivery to pending, due at `availableAt`, and counts the attempt that ended. */
425
898
  async reschedule(lease, availableAt, error, now) {
426
- return this.#updateLeased(lease, void 0, {
427
- $set: {
428
- status: "pending",
429
- availableAt,
430
- dueAt: availableAt,
431
- lastError: toErrorInfo(error),
432
- updatedAt: now
433
- },
434
- $unset: LEASE_FIELDS
899
+ return this.#leaveFlight(lease, {
900
+ $set: { availableAt, dueAt: availableAt, lastError: toErrorInfo(error), updatedAt: now },
901
+ $inc: { attempts: 1, claims: 1 }
435
902
  });
436
903
  }
904
+ /** Gives a running delivery back without counting the attempt: pending, due `now`. */
437
905
  async release(lease, now) {
438
- return this.#updateLeased(lease, void 0, {
439
- $set: { status: "pending", availableAt: now, dueAt: now, updatedAt: now },
440
- $inc: { attempts: -1 },
441
- $unset: LEASE_FIELDS
906
+ return this.#leaveFlight(lease, {
907
+ $set: { availableAt: now, dueAt: now, updatedAt: now },
908
+ $inc: { claims: 1 }
442
909
  });
443
910
  }
911
+ /** Moves a running delivery to dead and counts the attempt that ended. */
444
912
  async bury(lease, error, now) {
445
- return this.#updateLeased(lease, void 0, {
913
+ return this.#leaveFlight(lease, {
446
914
  $set: { status: "dead", lastError: toErrorInfo(error), updatedAt: now },
447
- $unset: { ...LEASE_FIELDS, ...FINISHED_FIELDS }
915
+ $unset: FINISHED_FIELDS,
916
+ $inc: { attempts: 1, claims: 1 }
448
917
  });
449
918
  }
919
+ /**
920
+ * Frees every lane `leaseOwner` holds that runs no delivery, so other workers
921
+ * need not wait for the lease to expire. Lanes with running deliveries stay leased.
922
+ */
923
+ async relinquish(leaseOwner) {
924
+ const held = await this.#lanes.find({ owner: String(leaseOwner), holder: this.#instanceId }).toArray();
925
+ const runningIds = new Set(
926
+ (await this.#findRunning(held.flatMap((lane) => lane.inFlight))).map(
927
+ (document) => document._id
928
+ )
929
+ );
930
+ const idle = held.filter((lane) => lane.inFlight.every((id) => !runningIds.has(id)));
931
+ if (idle.length > 0) {
932
+ await this.#lanes.bulkWrite(
933
+ idle.map((lane) => freeLane(lane._id, lane.epoch, lane.revision ?? 0, lane.inFlight)),
934
+ { ordered: false }
935
+ );
936
+ }
937
+ }
938
+ /**
939
+ * Lists deliveries in creation order. A delivery is `processing` while its
940
+ * document exists as `pending` and its lane lists it; its attempt count then
941
+ * includes the running attempt, and owner and expiry are those of the lane
942
+ * lease. Completion history records (when kept) are listed as `completed`.
943
+ */
450
944
  async listDeliveries(query) {
451
- const filter = {
452
- ...query.status === void 0 ? {} : { status: String(query.status) },
945
+ const status = query.status === void 0 ? void 0 : String(query.status);
946
+ const common = {
453
947
  ...query.handler === void 0 ? {} : { handler: String(query.handler) },
454
948
  ...query.eventType === void 0 ? {} : { eventType: String(query.eventType) },
455
949
  ...query.eventId === void 0 ? {} : { eventId: String(query.eventId) }
456
950
  };
457
- const documents = await this.#deliveries.find(filter, { sort: { createdAt: 1, _id: 1 }, limit: query.limit ?? DEFAULT_LIST_LIMIT }).toArray();
458
- const events = await this.#readEnvelopes(documents.map((document) => document.eventId));
459
- return documents.map((document) => toDeliveryRecord(document, events.get(document.eventId)));
951
+ const limit = query.limit ?? DEFAULT_LIST_LIMIT;
952
+ const lanes = await this.#lanes.find({ "inFlight.0": { $exists: true } }, { projection: { epoch: 0 } }).toArray();
953
+ const listed = lanes.flatMap((lane) => lane.inFlight);
954
+ const derivedStatuses = /* @__PURE__ */ new Map([
955
+ ["processing", { status: "pending", _id: { $in: listed } }],
956
+ ["pending", { status: "pending", _id: { $nin: listed } }]
957
+ ]);
958
+ const filter = {
959
+ ...common,
960
+ ...status === void 0 ? {} : derivedStatuses.get(status) ?? { status }
961
+ };
962
+ const sort = { createdAt: 1, _id: 1 };
963
+ const documents = await this.#deliveries.find(filter, { sort, limit }).toArray();
964
+ const history = this.#completionHistory && (status === void 0 || status === "completed") ? await this.#completions.find(common, { sort, limit }).toArray() : [];
965
+ const events = await this.#readEnvelopes([
966
+ ...documents.map((document) => document.eventId),
967
+ ...history.map((document) => document.eventId)
968
+ ]);
969
+ const laneById = new Map(lanes.map((lane) => [lane._id, lane]));
970
+ const records = [
971
+ ...documents.map((document) => {
972
+ const lane = laneById.get(document.laneId);
973
+ const runs = document.status === "pending" && lane?.inFlight.includes(document._id) === true;
974
+ return toDeliveryRecord(document, events.get(document.eventId), runs ? lane : void 0);
975
+ }),
976
+ ...history.map(
977
+ (document) => toCompletedDeliveryRecord(document, events.get(document.eventId))
978
+ )
979
+ ];
980
+ return records.sort(
981
+ (left, right) => left.createdAt.getTime() - right.createdAt.getTime() || compareText(left.id, right.id)
982
+ ).slice(0, limit);
460
983
  }
984
+ /** Moves dead deliveries back to pending with a fresh attempt budget. */
461
985
  async requeue(deliveryIds, now) {
462
986
  const result = await this.#deliveries.updateMany(
463
987
  { _id: { $in: deliveryIds.map(String) }, status: "dead" },
@@ -474,38 +998,93 @@ var MongoEventStore = class {
474
998
  );
475
999
  return result.modifiedCount;
476
1000
  }
1001
+ /**
1002
+ * Deletes, in batches of `purgeBatchSize`, dead deliveries last updated before
1003
+ * `deadBefore`, and completion history records (and `completed` documents left
1004
+ * by 0.1.0) completed before `completedBefore`, each batch in one transaction
1005
+ * together with the events it left without any reference. Finally, events
1006
+ * nothing references are deleted: one with a completed delivery once its last
1007
+ * completion is before `completedBefore` (never by `deadBefore` alone), one
1008
+ * that never had a completion (no subscribers, or only dead letters) once it
1009
+ * was created before the earliest given cutoff. The batch step applies the
1010
+ * same rule to events whose last completion is recorded.
1011
+ */
477
1012
  async purge(request) {
478
- const filters = [];
479
- if (request.completedBefore !== void 0) {
480
- filters.push({ status: "completed", completedAt: { $lt: request.completedBefore } });
1013
+ const total = { deletedDeliveries: 0, deletedEvents: 0 };
1014
+ const steps = [];
1015
+ const { completedBefore, deadBefore } = request;
1016
+ if (completedBefore !== void 0) {
1017
+ steps.push(
1018
+ () => this.#purgeMatching(
1019
+ this.#purgeable(this.#deliveries.collectionName),
1020
+ { status: "completed", completedAt: { $lt: completedBefore } },
1021
+ completedBefore
1022
+ )
1023
+ );
1024
+ if (this.#completionHistory) {
1025
+ steps.push(
1026
+ () => this.#purgeMatching(
1027
+ this.#purgeable(this.#completions.collectionName),
1028
+ { completedAt: { $lt: completedBefore } },
1029
+ completedBefore
1030
+ )
1031
+ );
1032
+ }
481
1033
  }
482
- if (request.deadBefore !== void 0) {
483
- filters.push({ status: "dead", updatedAt: { $lt: request.deadBefore } });
1034
+ if (deadBefore !== void 0) {
1035
+ steps.push(
1036
+ () => this.#purgeMatching(
1037
+ this.#purgeable(this.#deliveries.collectionName),
1038
+ { status: "dead", updatedAt: { $lt: deadBefore } },
1039
+ completedBefore
1040
+ )
1041
+ );
484
1042
  }
485
- let deletedDeliveries = 0;
486
- let deletedEvents = 0;
487
- for (const filter of filters) {
488
- const result = await this.#purgeMatching(filter);
489
- deletedDeliveries += result.deletedDeliveries;
490
- deletedEvents += result.deletedEvents;
1043
+ for (const step of steps) {
1044
+ const result = await step();
1045
+ total.deletedDeliveries += result.deletedDeliveries;
1046
+ total.deletedEvents += result.deletedEvents;
491
1047
  }
492
- return { deletedDeliveries, deletedEvents };
1048
+ const orphanCutoff = [completedBefore, deadBefore].filter((cutoff) => cutoff !== void 0).sort((first, second) => first.getTime() - second.getTime())[0];
1049
+ if (orphanCutoff !== void 0) {
1050
+ total.deletedEvents += await this.#purgeOrphanEvents(orphanCutoff, completedBefore);
1051
+ }
1052
+ return total;
493
1053
  }
1054
+ /**
1055
+ * Counts by derived status. `pending` excludes running deliveries; `completed`
1056
+ * counts history records and 0.1.0 `completed` documents. `oldestDueAt` is the
1057
+ * oldest instant at which a delivery became claimable: the due time of a
1058
+ * pending delivery nobody runs, or the lease expiry of a lane that still runs
1059
+ * deliveries.
1060
+ */
494
1061
  async statistics(now) {
495
- const statuses = ["pending", "processing", "completed", "dead"];
496
- const [pending, processing, completed, dead] = await Promise.all(
497
- statuses.map((status) => this.#deliveries.countDocuments({ status }))
498
- );
499
- const oldestDue = await this.#deliveries.findOne(
500
- { dueAt: { $lte: now } },
1062
+ const lanes = await this.#lanes.find({ "inFlight.0": { $exists: true } }, { projection: { inFlight: 1, leaseExpiresAt: 1 } }).toArray();
1063
+ const [stored, dead, legacyCompleted, history, running] = await Promise.all([
1064
+ this.#deliveries.countDocuments({ status: "pending" }),
1065
+ this.#deliveries.countDocuments({ status: "dead" }),
1066
+ this.#deliveries.countDocuments({ status: "completed" }),
1067
+ this.#completionHistory ? this.#completions.countDocuments() : Promise.resolve(0),
1068
+ this.#findRunning(lanes.flatMap((lane) => lane.inFlight))
1069
+ ]);
1070
+ const runningIds = new Set(running.map((document) => document._id));
1071
+ const waiting = await this.#deliveries.findOne(
1072
+ { dueAt: { $lte: now }, _id: { $nin: [...runningIds] } },
501
1073
  { sort: { dueAt: 1 }, projection: { dueAt: 1 } }
502
1074
  );
1075
+ const dueTimes = lanes.filter(
1076
+ (lane) => lane.leaseExpiresAt !== null && lane.leaseExpiresAt <= now && lane.inFlight.some((id) => runningIds.has(id))
1077
+ ).map((lane) => lane.leaseExpiresAt.getTime());
1078
+ if (waiting !== null) {
1079
+ dueTimes.push(waiting.dueAt.getTime());
1080
+ }
503
1081
  return {
504
- pending,
505
- processing,
506
- completed,
1082
+ // The counts are separate reads; a completion between them must not make this negative.
1083
+ pending: Math.max(0, stored - running.length),
1084
+ processing: running.length,
1085
+ completed: legacyCompleted + history,
507
1086
  dead,
508
- oldestDueAt: oldestDue?.dueAt ?? null
1087
+ oldestDueAt: dueTimes.length === 0 ? null : new Date(Math.min(...dueTimes))
509
1088
  };
510
1089
  }
511
1090
  /** Atomically increments the counter of `key`; conflicting appends of the key serialize here. */
@@ -517,92 +1096,376 @@ var MongoEventStore = class {
517
1096
  );
518
1097
  return counter.value;
519
1098
  }
520
- async #claimCandidate(candidate, request, scan) {
521
- const { key, sequence } = candidate;
522
- if (key === void 0) {
523
- return this.#lease(candidate, request);
1099
+ /** The listed deliveries that run: those whose document still exists as pending. */
1100
+ async #findRunning(listed) {
1101
+ if (listed.length === 0) {
1102
+ return [];
524
1103
  }
525
- const orderingId = orderingIdentity(candidate.handler, key);
526
- const known = scan.verdict(orderingId, sequence);
527
- if (known === "open-predecessor") {
528
- scan.noteWaiting(orderingId, candidate);
529
- return void 0;
1104
+ return this.#deliveries.find(
1105
+ { _id: { $in: [...listed] }, status: "pending" },
1106
+ { projection: { handler: 1, key: 1, laneId: 1 } }
1107
+ ).toArray();
1108
+ }
1109
+ /**
1110
+ * Up to `laneCount` lanes nobody holds under a live lease that have a due,
1111
+ * ready delivery of the requested handlers, the lane with the oldest such
1112
+ * delivery first, leaving out `passed`. One round trip: the oldest delivery
1113
+ * of each lane is one index probe on the server. A lane whose due deliveries
1114
+ * all wait behind an unfinished predecessor is taken at most once: the claim
1115
+ * that hands it back parks them (see {@link ClaimScan.waitingBacklogs}).
1116
+ */
1117
+ async #findFreeLanes(request, laneCount, passed) {
1118
+ const lanes = await this.#lanes.aggregate([
1119
+ {
1120
+ $match: {
1121
+ _id: { $nin: passed },
1122
+ $or: [{ leaseExpiresAt: null }, { leaseExpiresAt: { $lte: request.now } }]
1123
+ }
1124
+ },
1125
+ {
1126
+ $lookup: {
1127
+ from: this.#deliveries.collectionName,
1128
+ localField: "_id",
1129
+ foreignField: "laneId",
1130
+ pipeline: [
1131
+ {
1132
+ $match: {
1133
+ handler: { $in: [...request.handlers] },
1134
+ lane: "ready",
1135
+ dueAt: { $lte: request.now }
1136
+ }
1137
+ },
1138
+ { $sort: { dueAt: 1, _id: 1 } },
1139
+ { $limit: 1 },
1140
+ { $project: { dueAt: 1 } }
1141
+ ],
1142
+ as: "oldest"
1143
+ }
1144
+ },
1145
+ { $unwind: "$oldest" },
1146
+ { $sort: { "oldest.dueAt": 1, "oldest._id": 1 } },
1147
+ { $limit: laneCount },
1148
+ { $project: { _id: 1 } }
1149
+ ]).toArray();
1150
+ return lanes.map((lane) => lane._id);
1151
+ }
1152
+ /**
1153
+ * Takes the lanes with one conditional write each, run in parallel; a lane
1154
+ * taken by someone else meanwhile is skipped. The same write moves the
1155
+ * previous owner's in-flight list to `lost`, which is charged before any
1156
+ * delivery of the lanes is read (see {@link MongoEventStore.#chargeLost}).
1157
+ */
1158
+ async #takeLanes(laneIds, request) {
1159
+ const after = await Promise.all(
1160
+ laneIds.map(
1161
+ (laneId) => this.#lanes.findOneAndUpdate(
1162
+ {
1163
+ _id: laneId,
1164
+ $or: [{ leaseExpiresAt: null }, { leaseExpiresAt: { $lte: request.now } }]
1165
+ },
1166
+ [
1167
+ {
1168
+ $set: {
1169
+ epoch: { $add: ["$epoch", 1] },
1170
+ owner: { $literal: request.leaseOwner },
1171
+ holder: { $literal: this.#instanceId },
1172
+ leaseExpiresAt: { $literal: request.leaseExpiresAt },
1173
+ // Each lost delivery keeps the epoch it was claimed under: the key of its charge.
1174
+ lost: {
1175
+ $setUnion: [
1176
+ { $ifNull: ["$lost", []] },
1177
+ { $map: { input: "$inFlight", in: { id: "$$this", epoch: "$epoch" } } }
1178
+ ]
1179
+ },
1180
+ inFlight: { $literal: [] }
1181
+ }
1182
+ }
1183
+ ],
1184
+ { returnDocument: "after" }
1185
+ )
1186
+ )
1187
+ );
1188
+ const taken = after.filter((lane) => lane !== null);
1189
+ await this.#chargeLost(taken);
1190
+ return taken.map((lane) => ({
1191
+ laneId: lane._id,
1192
+ epoch: lane.epoch,
1193
+ listed: lane.inFlight,
1194
+ revision: lane.revision ?? 0
1195
+ }));
1196
+ }
1197
+ /**
1198
+ * Charges one attempt to every pending delivery listed in the `lost` lists of
1199
+ * `lanes` (deliveries whose lane was taken while they ran), then clears those
1200
+ * lists under the lanes' current epochs. The charge is a write on each lost
1201
+ * delivery that comes before any read of the lane's deliveries: a completion
1202
+ * of the previous owner deletes the same documents, so the two conflict and
1203
+ * cannot both succeed. The charge also moves the claim generation on, which
1204
+ * fences every lease of the lost claim, and records the lost claim
1205
+ * (`lostAt` = lane and epoch it was claimed under): a holder that charges the
1206
+ * list again after a failure before the clear skips deliveries already
1207
+ * charged for that claim.
1208
+ */
1209
+ async #chargeLost(lanes) {
1210
+ const losing = lanes.filter((lane) => lane.lost.length > 0);
1211
+ if (losing.length === 0) {
1212
+ return;
530
1213
  }
531
- if (known === "live-lease") {
532
- return void 0;
1214
+ const lost = losing.flatMap((lane) => lane.lost).sort((left, right) => left.epoch - right.epoch);
1215
+ await this.#deliveries.bulkWrite(
1216
+ lost.map(({ id, epoch }) => ({
1217
+ updateOne: {
1218
+ filter: { _id: id, status: "pending", lostAt: { $not: { $gte: epoch } } },
1219
+ update: { $inc: { attempts: 1, claims: 1 }, $set: { lostAt: epoch } }
1220
+ }
1221
+ })),
1222
+ { ordered: true }
1223
+ );
1224
+ await this.#lanes.bulkWrite(
1225
+ losing.map((lane) => ({
1226
+ updateOne: { filter: { _id: lane._id, epoch: lane.epoch }, update: { $set: { lost: [] } } }
1227
+ })),
1228
+ { ordered: false }
1229
+ );
1230
+ }
1231
+ /**
1232
+ * Adds the claimable candidates of `lanes` to `picked`, in due order, until
1233
+ * `request.limit` is reached. A delivery is a candidate when it is ready, due
1234
+ * and not in flight in its lane. A keyed candidate is claimable only when
1235
+ * (a) no earlier delivery of its handler and key is pending (a running one is
1236
+ * still a pending document) and (b) no other delivery of them runs. Running
1237
+ * deliveries of a key live in the key's lane, which this owner holds, so the
1238
+ * running keys (loaded once from the held lanes) and this claim's own picks
1239
+ * are all of them.
1240
+ *
1241
+ * Candidates are read in pages with a limit, so no cursor is left open, and
1242
+ * the predecessors of the keys of a page are looked up in parallel, one
1243
+ * lookup per key.
1244
+ */
1245
+ async #pickCandidates(lanes, request, scan, running, picked) {
1246
+ const pageSize = Math.max((request.limit - picked.length) * 2, MINIMUM_CANDIDATE_BATCH_SIZE);
1247
+ const filter = {
1248
+ laneId: { $in: lanes.map((lane) => lane.laneId) },
1249
+ handler: { $in: [...request.handlers] },
1250
+ lane: "ready",
1251
+ dueAt: { $lte: request.now },
1252
+ _id: { $nin: lanes.flatMap((lane) => lane.listed) }
1253
+ };
1254
+ let after;
1255
+ for (; ; ) {
1256
+ const [page, { keys }] = await Promise.all([
1257
+ this.#deliveries.find(
1258
+ after === void 0 ? filter : {
1259
+ $and: [
1260
+ filter,
1261
+ {
1262
+ $or: [
1263
+ { dueAt: { $gt: after.dueAt } },
1264
+ { dueAt: after.dueAt, _id: { $gt: after._id } }
1265
+ ]
1266
+ }
1267
+ ]
1268
+ },
1269
+ { sort: { dueAt: 1, _id: 1 }, limit: pageSize }
1270
+ ).toArray(),
1271
+ running
1272
+ ]);
1273
+ for (let offset = 0; offset < page.length; ) {
1274
+ const slice = page.slice(offset, offset + request.limit - picked.length);
1275
+ offset += slice.length;
1276
+ await this.#notePredecessors(slice, scan);
1277
+ for (const candidate of slice) {
1278
+ if (isClaimable(candidate, scan, keys)) {
1279
+ picked.push(candidate);
1280
+ }
1281
+ }
1282
+ if (picked.length >= request.limit) {
1283
+ return;
1284
+ }
1285
+ }
1286
+ if (page.length < pageSize) {
1287
+ return;
1288
+ }
1289
+ after = page.at(-1);
1290
+ }
1291
+ }
1292
+ /**
1293
+ * Looks up, in parallel and once per key, whether an unfinished delivery
1294
+ * precedes the earliest candidate of each keyed (handler, key) of `page` that
1295
+ * this claim knows nothing about yet, and tells `scan`. The earliest candidate
1296
+ * is itself unfinished, so every later candidate of its key waits at least for it.
1297
+ */
1298
+ async #notePredecessors(page, scan) {
1299
+ const earliest = /* @__PURE__ */ new Map();
1300
+ for (const candidate of page) {
1301
+ if (candidate.key !== void 0) {
1302
+ const orderingId = orderingIdentity(candidate.handler, candidate.key);
1303
+ const known = earliest.get(orderingId);
1304
+ if (!scan.knows(orderingId) && (known?.sequence ?? Infinity) > candidate.sequence) {
1305
+ earliest.set(orderingId, candidate);
1306
+ }
1307
+ }
533
1308
  }
534
- const predecessor = await this.#findUnfinishedPredecessor(candidate.handler, key, sequence);
535
- if (predecessor !== void 0) {
536
- scan.noteOpen(orderingId, predecessor);
537
- scan.noteWaiting(orderingId, candidate);
538
- return void 0;
1309
+ await Promise.all(
1310
+ [...earliest].map(async ([orderingId, candidate]) => {
1311
+ const predecessor = await this.#findUnfinishedPredecessor(
1312
+ candidate.handler,
1313
+ candidate.key,
1314
+ candidate.sequence
1315
+ );
1316
+ scan.noteOpen(orderingId, predecessor ?? candidate.sequence);
1317
+ })
1318
+ );
1319
+ }
1320
+ /**
1321
+ * Records the picks in their lanes, one conditional write per lane and one
1322
+ * round trip for all of them, and frees the lanes that yielded nothing and run
1323
+ * nothing. A picked lane lists its running deliveries plus the new ones (ids
1324
+ * whose documents are gone are pruned) and its lease reaches
1325
+ * `request.leaseExpiresAt`. Returns the lanes whose picks were recorded: every
1326
+ * write is conditional on the claim's epoch, owner and store instance, so a
1327
+ * lane that changed hands, or was handed back by `relinquish`, in the meantime
1328
+ * keeps its deliveries for whoever takes it next.
1329
+ */
1330
+ async #recordClaim(scope, picked, runningIds, request) {
1331
+ const picksByLane = /* @__PURE__ */ new Map();
1332
+ const handedBack = /* @__PURE__ */ new Set();
1333
+ for (const document of picked) {
1334
+ picksByLane.set(document.laneId, [...picksByLane.get(document.laneId) ?? [], document._id]);
539
1335
  }
540
- if (await this.#hasLiveLease(candidate.handler, key, request.now)) {
541
- scan.noteLeaseHeld(orderingId);
542
- return void 0;
1336
+ const operations = [];
1337
+ for (const lane of scope.values()) {
1338
+ const gone = lane.listed.filter((id) => !runningIds.has(id));
1339
+ const ids = picksByLane.get(lane.laneId);
1340
+ if (ids !== void 0) {
1341
+ operations.push({
1342
+ updateOne: {
1343
+ filter: {
1344
+ _id: lane.laneId,
1345
+ epoch: lane.epoch,
1346
+ owner: request.leaseOwner,
1347
+ holder: this.#instanceId,
1348
+ // A record that reaches the server late (after its claim failed and reconciled)
1349
+ // finds a newer revision and changes nothing.
1350
+ revision: atCount(lane.revision)
1351
+ },
1352
+ update: [
1353
+ {
1354
+ $set: {
1355
+ inFlight: {
1356
+ $concatArrays: [
1357
+ {
1358
+ $filter: {
1359
+ input: "$inFlight",
1360
+ cond: { $not: [{ $in: ["$$this", { $literal: gone }] }] }
1361
+ }
1362
+ },
1363
+ { $literal: ids }
1364
+ ]
1365
+ },
1366
+ leaseExpiresAt: { $max: ["$leaseExpiresAt", request.leaseExpiresAt] },
1367
+ revision: { $add: [{ $ifNull: ["$revision", 0] }, 1] }
1368
+ }
1369
+ }
1370
+ ]
1371
+ }
1372
+ });
1373
+ } else if (gone.length === lane.listed.length) {
1374
+ operations.push(freeLane(lane.laneId, lane.epoch, lane.revision, gone));
1375
+ handedBack.add(lane.laneId);
1376
+ }
543
1377
  }
544
- const leased = await this.#lease(candidate, request);
545
- if (leased === void 0) {
546
- return void 0;
1378
+ if (operations.length === 0) {
1379
+ return { recorded: /* @__PURE__ */ new Set(), handedBack };
547
1380
  }
548
- const lateBlocker = await this.#findUnfinishedPredecessor(candidate.handler, key, sequence);
549
- if (lateBlocker !== void 0) {
550
- await this.#restore(candidate, leased);
551
- scan.noteOpen(orderingId, lateBlocker);
552
- return void 0;
1381
+ const result = await this.#lanes.bulkWrite(operations, { ordered: false });
1382
+ if (result.matchedCount === operations.length) {
1383
+ return { recorded: new Set(picksByLane.keys()), handedBack };
553
1384
  }
554
- scan.noteClaimed(orderingId, sequence);
555
- return leased;
1385
+ const current = await this.#lanes.find(
1386
+ { _id: { $in: [...picksByLane.keys()] } },
1387
+ { projection: { epoch: 1, owner: 1, holder: 1, revision: 1 } }
1388
+ ).toArray();
1389
+ const recorded = new Set(
1390
+ current.filter(
1391
+ (lane) => lane.epoch === scope.get(lane._id).epoch && lane.revision === scope.get(lane._id).revision + 1 && lane.owner === request.leaseOwner && lane.holder === this.#instanceId
1392
+ ).map((lane) => lane._id)
1393
+ );
1394
+ return { recorded, handedBack };
556
1395
  }
557
1396
  /**
558
- * Takes the delivery with one conditional update that pins the exact state
559
- * that was selected, so a competing claimer, a finished lease, a reschedule
560
- * or a parking since then makes it match nothing.
1397
+ * Gives back the recorded keyed picks that a delivery requeued during the
1398
+ * claim now precedes, without charging an attempt, and returns the rest. The
1399
+ * claim checked their predecessors before recording them; a dead letter
1400
+ * requeued in between would otherwise wait behind its own successor. Once a
1401
+ * pick is recorded, a later requeue waits for it like for any running delivery.
561
1402
  */
562
- async #lease(candidate, request) {
563
- const leased = await this.#deliveries.findOneAndUpdate(
1403
+ async #releaseOvertaken(claimed, scope) {
1404
+ const keyed = claimed.filter((document) => document.key !== void 0);
1405
+ if (keyed.length === 0) {
1406
+ return [...claimed];
1407
+ }
1408
+ const blockers = await this.#deliveries.find(
564
1409
  {
565
- _id: candidate._id,
566
- status: candidate.status,
567
- lane: "ready",
568
- attempts: candidate.attempts,
569
- dueAt: candidate.dueAt
1410
+ $or: keyed.map((document) => ({
1411
+ handler: document.handler,
1412
+ key: document.key,
1413
+ sequence: { $lt: document.sequence },
1414
+ dueAt: { $exists: true }
1415
+ }))
570
1416
  },
571
- {
572
- $set: {
573
- status: "processing",
574
- leaseOwner: request.leaseOwner,
575
- leaseToken: this.#generateIdentifier(),
576
- leaseExpiresAt: request.leaseExpiresAt,
577
- dueAt: request.leaseExpiresAt,
578
- updatedAt: request.now
579
- },
580
- $inc: { attempts: 1 }
581
- },
582
- { returnDocument: "after" }
1417
+ { projection: { handler: 1, key: 1 } }
1418
+ ).toArray();
1419
+ const blocked = new Set(
1420
+ blockers.map((blocker) => orderingIdentity(blocker.handler, blocker.key))
1421
+ );
1422
+ const overtaken = new Set(
1423
+ keyed.filter((document) => blocked.has(orderingIdentity(document.handler, document.key))).map((document) => document._id)
583
1424
  );
584
- return leased ?? void 0;
1425
+ if (overtaken.size === 0) {
1426
+ return [...claimed];
1427
+ }
1428
+ await this.#releasePicks(
1429
+ claimed.filter((document) => overtaken.has(document._id)),
1430
+ scope
1431
+ );
1432
+ return claimed.filter((document) => !overtaken.has(document._id));
585
1433
  }
586
- /** Puts a delivery that was leased a moment ago back exactly as it was selected. */
587
- async #restore(selected, leased) {
588
- const state = {
589
- status: selected.status,
590
- attempts: selected.attempts,
591
- dueAt: selected.dueAt,
592
- updatedAt: selected.updatedAt
593
- };
594
- await this.#deliveries.updateOne(
595
- { _id: leased._id, status: "processing", leaseToken: leased.leaseToken },
596
- selected.status === "processing" ? {
597
- $set: {
598
- ...state,
599
- leaseOwner: selected.leaseOwner,
600
- leaseToken: selected.leaseToken,
601
- leaseExpiresAt: selected.leaseExpiresAt
602
- }
603
- } : { $set: state, $unset: LEASE_FIELDS }
1434
+ /** Takes recorded picks out of their lanes' in-flight lists again, under the claim's epochs and record revisions. */
1435
+ async #releasePicks(documents, scope) {
1436
+ const idsByLane = /* @__PURE__ */ new Map();
1437
+ for (const document of documents) {
1438
+ idsByLane.set(document.laneId, [...idsByLane.get(document.laneId) ?? [], document._id]);
1439
+ }
1440
+ await Promise.all(
1441
+ [...idsByLane].map(
1442
+ ([laneId, ids]) => this.#lanes.updateOne(
1443
+ // Under the revision the record left: a pull that reaches the server late, after a
1444
+ // reconciliation and a newer claim of the same delivery, matches nothing.
1445
+ {
1446
+ _id: laneId,
1447
+ epoch: scope.get(laneId).epoch,
1448
+ revision: scope.get(laneId).revision + 1
1449
+ },
1450
+ { $pull: { inFlight: { $in: ids } } }
1451
+ )
1452
+ )
604
1453
  );
605
1454
  }
1455
+ /**
1456
+ * Parks the waiting backlogs a claim found. Best effort: parking only saves
1457
+ * later claims work, so a failure is logged and the claim still returns its
1458
+ * batch; the next claim that meets the backlog parks it.
1459
+ */
1460
+ async #parkWaiting(deliveryIds) {
1461
+ try {
1462
+ await this.#parkDeliveries(deliveryIds);
1463
+ } catch (error) {
1464
+ this.#logger.warn(
1465
+ `[MongoEventStore](parkWaiting) Parking waiting deliveries failed, a later claim parks them | count=${deliveryIds.length} | error=${String(error)}`
1466
+ );
1467
+ }
1468
+ }
606
1469
  /** Sequence of the lowest unfinished delivery of the handler and key before `sequence`, if any. */
607
1470
  async #findUnfinishedPredecessor(handler, key, sequence) {
608
1471
  const predecessor = await this.#deliveries.findOne(
@@ -611,14 +1474,6 @@ var MongoEventStore = class {
611
1474
  );
612
1475
  return predecessor?.sequence;
613
1476
  }
614
- /** Whether some delivery of the handler and key runs under a lease that has not expired. */
615
- async #hasLiveLease(handler, key, now) {
616
- const running = await this.#deliveries.findOne(
617
- { handler, key, status: "processing", dueAt: { $gt: now } },
618
- { projection: { _id: 1 } }
619
- );
620
- return running !== null;
621
- }
622
1477
  /**
623
1478
  * Parks pending deliveries that waited behind an unfinished predecessor, which
624
1479
  * removes them from the claim index until their turn. Parking is a hint, not a
@@ -653,82 +1508,220 @@ var MongoEventStore = class {
653
1508
  if (anyParked === null) {
654
1509
  return;
655
1510
  }
656
- for (const handler of new Set(handlers)) {
657
- let afterKey;
658
- for (; ; ) {
659
- const lowest = await this.#deliveries.findOne(
1511
+ const lowest = (await Promise.all(
1512
+ [...new Set(handlers)].map(
1513
+ (handler) => this.#deliveries.aggregate([
1514
+ { $match: { handler, lane: "parked" } },
1515
+ { $sort: { handler: 1, key: 1, sequence: 1 } },
660
1516
  {
661
- handler,
662
- lane: "parked",
663
- ...afterKey === void 0 ? {} : { key: { $gt: afterKey } }
664
- },
665
- { sort: { key: 1, sequence: 1 }, projection: { key: 1, sequence: 1 } }
666
- );
667
- if (lowest === null) {
668
- break;
669
- }
670
- afterKey = lowest.key;
671
- if (await this.#findUnfinishedPredecessor(handler, afterKey, lowest.sequence) === void 0) {
672
- await this.#deliveries.updateOne(
673
- { _id: lowest._id, lane: "parked" },
674
- { $set: { lane: "ready" } }
675
- );
676
- }
677
- }
1517
+ $group: { _id: "$key", id: { $first: "$_id" }, sequence: { $first: "$sequence" } }
1518
+ }
1519
+ ]).toArray().then((groups) => groups.map((group) => ({ ...group, handler })))
1520
+ )
1521
+ )).flat();
1522
+ const next = await Promise.all(
1523
+ lowest.map(
1524
+ async (parked) => await this.#findUnfinishedPredecessor(parked.handler, parked._id, parked.sequence) === void 0 ? [parked.id] : []
1525
+ )
1526
+ );
1527
+ const ready = next.flat();
1528
+ if (ready.length > 0) {
1529
+ await this.#deliveries.updateMany(
1530
+ { _id: { $in: ready }, lane: "parked" },
1531
+ { $set: { lane: "ready" } }
1532
+ );
678
1533
  }
679
1534
  }
680
- async #toClaimedDeliveries(leased, leaseExpiresAt) {
681
- const events = await this.#readEnvelopes(leased.map((document) => document.eventId));
682
- return leased.map((document) => ({
683
- deliveryId: document._id,
684
- leaseToken: document.leaseToken,
685
- handler: document.handler,
686
- attempt: document.attempts,
687
- leaseExpiresAt,
688
- event: events.get(document.eventId)
689
- }));
690
- }
691
1535
  async #readEnvelopes(eventIds) {
692
1536
  const documents = await this.#events.find({ _id: { $in: [...new Set(eventIds)] } }).toArray();
693
1537
  return new Map(documents.map((document) => [document._id, toEnvelope(document)]));
694
1538
  }
695
- async #updateLeased(lease, session, update) {
696
- const result = await this.#deliveries.updateOne(
697
- { _id: lease.deliveryId, status: "processing", leaseToken: lease.leaseToken },
698
- update,
1539
+ /**
1540
+ * Deletes the delivery of every lease whose lane still has the lease's epoch
1541
+ * and lists the delivery, in one transaction (the caller's, when given).
1542
+ * Returns one flag per lease, in order; a lease listed twice completes once.
1543
+ */
1544
+ async #completeLeases(leases, now, session) {
1545
+ return session === void 0 ? this.#settle(leases, () => this.#completeIssued(leases, now, void 0)) : this.#completeIssued(leases, now, session);
1546
+ }
1547
+ async #completeIssued(leases, now, session) {
1548
+ const parsed = leases.map((lease) => parseLeaseToken(lease.leaseToken));
1549
+ const issued = leases.flatMap(
1550
+ (lease, index) => parsed[index] === void 0 ? [] : [{ deliveryId: String(lease.deliveryId), ...parsed[index] }]
1551
+ );
1552
+ let completed = /* @__PURE__ */ new Set();
1553
+ if (issued.length > 0) {
1554
+ if (session !== void 0) {
1555
+ completed = await this.#deleteCompleted(issued, now, session);
1556
+ } else if (this.#completionHistory) {
1557
+ completed = await this.transaction((own) => this.#deleteCompleted(issued, now, own));
1558
+ } else {
1559
+ completed = await this.#deleteFenced(issued, now);
1560
+ }
1561
+ }
1562
+ return leases.map(
1563
+ (lease, index) => parsed[index] !== void 0 && completed.delete(leaseIdentity({ deliveryId: String(lease.deliveryId), ...parsed[index] }))
1564
+ );
1565
+ }
1566
+ /**
1567
+ * Completion without a transaction, used when no history record has to be
1568
+ * written with the deletion. The deliveries' claim generations are read
1569
+ * first, then the lanes, and a delivery is deleted only if it still has the
1570
+ * lease's generation. A takeover moves the generation of every delivery it
1571
+ * takes from the previous owner on before it reads anything, and so do a
1572
+ * retry, a release and a burial, so:
1573
+ * - a takeover before the delivery read shows up in the lane read (new epoch);
1574
+ * - a takeover between the two reads shows up in the lane read as well;
1575
+ * - a takeover, retry, release or burial after the lane read changed the
1576
+ * generation, so the delete matches nothing.
1577
+ * A completion and a takeover (or the end of the claim) therefore never both succeed. Two concurrent
1578
+ * completions of the same lease may both report true (the dispatcher never
1579
+ * issues them); one call with a lease listed twice reports it once.
1580
+ */
1581
+ async #deleteFenced(issued, now) {
1582
+ const ids = [...new Set(issued.map((lease) => lease.deliveryId))];
1583
+ const documents = await this.#deliveries.find({ _id: { $in: ids }, status: "pending" }, { projection: { claims: 1, eventId: 1 } }).toArray();
1584
+ const lanes = new Map(
1585
+ (await this.#lanes.find(
1586
+ { _id: { $in: [...new Set(issued.map((lease) => lease.laneId))] } },
1587
+ { projection: { epoch: 1, inFlight: 1 } }
1588
+ ).toArray()).map((lane) => [lane._id, lane])
1589
+ );
1590
+ const fenced = fencedLeases(issued, lanes, documents);
1591
+ if (fenced.length === 0) {
1592
+ return /* @__PURE__ */ new Set();
1593
+ }
1594
+ await this.#markEventsCompleted(fenced, documents, now);
1595
+ const deleted = await this.#deliveries.deleteMany({ $or: fenced.map(ofClaim) });
1596
+ return deleted.deletedCount === fenced.length ? new Set(fenced.map((lease) => leaseIdentity(lease))) : /* @__PURE__ */ new Set();
1597
+ }
1598
+ /**
1599
+ * The transactional part of a completion. The lanes are only read: the
1600
+ * conflict with a takeover comes from the deleted delivery documents, which a
1601
+ * takeover writes before it reads them (see {@link MongoEventStore.#takeLanes}).
1602
+ * So whichever of the two commits second either fails with a write conflict
1603
+ * and is retried against the new state, or waits for the first.
1604
+ */
1605
+ async #deleteCompleted(issued, now, session) {
1606
+ const lanes = new Map(
1607
+ (await this.#lanes.find(
1608
+ { _id: { $in: [...new Set(issued.map((lease) => lease.laneId))] } },
1609
+ { session, projection: { epoch: 1, inFlight: 1 } }
1610
+ ).toArray()).map((lane) => [lane._id, lane])
1611
+ );
1612
+ const documents = await this.#deliveries.find(
1613
+ { _id: { $in: [...new Set(issued.map((lease) => lease.deliveryId))] }, status: "pending" },
1614
+ { session }
1615
+ ).toArray();
1616
+ const fenced = fencedLeases(issued, lanes, documents);
1617
+ if (fenced.length === 0) {
1618
+ return /* @__PURE__ */ new Set();
1619
+ }
1620
+ await this.#markEventsCompleted(fenced, documents, now, session);
1621
+ await this.#deliveries.deleteMany({ $or: fenced.map(ofClaim) }, { session });
1622
+ if (this.#completionHistory) {
1623
+ const completedIds = new Set(fenced.map((lease) => lease.deliveryId));
1624
+ await this.#completions.insertMany(
1625
+ documents.filter((document) => completedIds.has(document._id)).map((document) => toCompletedDeliveryDocument(document, now)),
1626
+ { session }
1627
+ );
1628
+ }
1629
+ return new Set(fenced.map((lease) => leaseIdentity(lease)));
1630
+ }
1631
+ /**
1632
+ * Records `now` as the latest completion of the events of the fenced
1633
+ * deliveries (`$max`, so an earlier clock never moves it back). Written
1634
+ * before the deletion, it can only be later than the real last completion
1635
+ * (when the deletion then fails), which keeps the event longer, never shorter.
1636
+ * Inside a transaction it is part of it: the transaction may hold the event
1637
+ * already (a transactional handler that appended to its own event), and a
1638
+ * write from outside would wait for it forever. Deliveries of one event that
1639
+ * complete in concurrent transactions (fan-out to transactional handlers)
1640
+ * therefore conflict on the event document, and one of them retries.
1641
+ */
1642
+ async #markEventsCompleted(fenced, documents, now, session) {
1643
+ const completedIds = new Set(fenced.map((lease) => lease.deliveryId));
1644
+ const eventIds = [
1645
+ ...new Set(
1646
+ documents.filter((document) => completedIds.has(document._id)).map((document) => document.eventId)
1647
+ )
1648
+ ];
1649
+ await this.#events.updateMany(
1650
+ { _id: { $in: eventIds } },
1651
+ { $max: { completedAt: now } },
699
1652
  session === void 0 ? {} : { session }
700
1653
  );
701
- return result.matchedCount === 1;
702
1654
  }
703
1655
  /**
704
- * Deletes batches of matching deliveries together with the events no delivery
705
- * references any more, each batch in one transaction, so a crash can never
706
- * leave an event without deliveries behind. An append adding a delivery to the
707
- * same event writes the event document, so the two conflict and one restarts
708
- * instead of leaving a delivery without event. A delivery requeued since the
709
- * batch was read conflicts with its deletion and is read again.
1656
+ * Applies `update` to a running delivery and takes it out of its lane's
1657
+ * in-flight list, if the lane still has the lease's epoch. One transaction:
1658
+ * the lane write fences the change against a takeover.
710
1659
  */
711
- async #purgeMatching(filter) {
1660
+ async #leaveFlight(lease, update) {
1661
+ const held = parseLeaseToken(lease.leaseToken);
1662
+ if (held === void 0) {
1663
+ return false;
1664
+ }
1665
+ const deliveryId = String(lease.deliveryId);
1666
+ const ofThisClaim = ofClaim({ ...held, deliveryId });
1667
+ return this.#settle([lease], () => this.#leaveFlightNow(held, deliveryId, ofThisClaim, update));
1668
+ }
1669
+ async #leaveFlightNow(held, deliveryId, ofThisClaim, update) {
1670
+ return this.transaction(async (session) => {
1671
+ if (await this.#deliveries.findOne(ofThisClaim, { session, projection: { _id: 1 } }) === null) {
1672
+ return false;
1673
+ }
1674
+ const fence = await this.#lanes.updateOne(
1675
+ { _id: held.laneId, epoch: held.epoch, inFlight: deliveryId },
1676
+ { $pull: { inFlight: deliveryId } },
1677
+ { session }
1678
+ );
1679
+ if (fence.matchedCount === 0) {
1680
+ return false;
1681
+ }
1682
+ const changed = await this.#deliveries.updateOne(ofThisClaim, update, { session });
1683
+ return changed.matchedCount === 1;
1684
+ });
1685
+ }
1686
+ /**
1687
+ * Deletes batches of matching documents (deliveries, or completion history
1688
+ * records) together with the events nothing references any more, each batch
1689
+ * in one transaction, so a crash can never leave an event without deliveries
1690
+ * behind. An append adding a delivery to the same event writes the event
1691
+ * document, so the two conflict and one restarts instead of leaving a delivery
1692
+ * without event. A delivery requeued since the batch was read conflicts with
1693
+ * its deletion and is read again.
1694
+ */
1695
+ async #purgeMatching(collection, filter, completedBefore) {
712
1696
  let deletedDeliveries = 0;
713
1697
  let deletedEvents = 0;
714
1698
  for (; ; ) {
715
1699
  const step = await this.transaction(async (session) => {
716
- const batch = await this.#deliveries.find(filter, { session, projection: { eventId: 1 }, limit: this.#purgeBatchSize }).toArray();
1700
+ const batch = await collection.find(filter, { session, projection: { eventId: 1 }, limit: this.#purgeBatchSize }).toArray();
1701
+ if (batch.length === 0) {
1702
+ return { batchSize: 0, deletedDeliveries: 0, deletedEvents: 0 };
1703
+ }
717
1704
  const eventIds = [...new Set(batch.map((document) => document.eventId))];
718
- const deliveries = await this.#deliveries.deleteMany(
1705
+ const deleted = await collection.deleteMany(
719
1706
  { _id: { $in: batch.map((document) => document._id) } },
720
1707
  { session }
721
1708
  );
722
- const stillReferenced = new Set(
723
- (await this.#deliveries.find({ eventId: { $in: eventIds } }, { session, projection: { eventId: 1 } }).toArray()).map((document) => document.eventId)
724
- );
1709
+ const stillReferenced = await this.#referencedEventIds(eventIds, session);
725
1710
  const events = await this.#events.deleteMany(
726
- { _id: { $in: eventIds.filter((eventId) => !stillReferenced.has(eventId)) } },
1711
+ {
1712
+ _id: { $in: eventIds.filter((eventId) => !stillReferenced.has(eventId)) },
1713
+ // An event a delivery of which completed stays as deduplication record until
1714
+ // `completedBefore` passes its last completion, whatever this batch purged.
1715
+ $or: [
1716
+ { completedAt: { $exists: false } },
1717
+ ...completedBefore === void 0 ? [] : [{ completedAt: { $lt: completedBefore } }]
1718
+ ]
1719
+ },
727
1720
  { session }
728
1721
  );
729
1722
  return {
730
1723
  batchSize: batch.length,
731
- deletedDeliveries: deliveries.deletedCount,
1724
+ deletedDeliveries: deleted.deletedCount,
732
1725
  deletedEvents: events.deletedCount
733
1726
  };
734
1727
  });
@@ -739,9 +1732,154 @@ var MongoEventStore = class {
739
1732
  }
740
1733
  }
741
1734
  }
1735
+ /** A view of a collection that purge deletes from: deliveries or completion history records. */
1736
+ #purgeable(name) {
1737
+ return this.#database.collection(name, {
1738
+ readPreference: "primary",
1739
+ writeConcern: { w: "majority" }
1740
+ });
1741
+ }
1742
+ /** Those of `eventIds` that a delivery or a completion history record references. */
1743
+ async #referencedEventIds(eventIds, session) {
1744
+ const filter = { eventId: { $in: [...eventIds] } };
1745
+ const options = { session, projection: { eventId: 1 } };
1746
+ const references = [
1747
+ ...await this.#deliveries.find(filter, options).toArray(),
1748
+ ...this.#completionHistory ? await this.#completions.find(filter, options).toArray() : []
1749
+ ];
1750
+ return new Set(references.map((reference) => reference.eventId));
1751
+ }
1752
+ /**
1753
+ * Deletes, in batches, events created before `cutoff` that no delivery (or
1754
+ * completion history record) references. Each batch is found and deleted in
1755
+ * one transaction: an append adding a delivery to one of them writes the
1756
+ * event, so it conflicts with the deletion, which then restarts and sees the
1757
+ * delivery. Returns how many events were deleted.
1758
+ */
1759
+ async #purgeOrphanEvents(cutoff, completedBefore) {
1760
+ const references = [
1761
+ this.#deliveries.collectionName,
1762
+ ...this.#completionHistory ? [this.#completions.collectionName] : []
1763
+ ];
1764
+ let deletedEvents = 0;
1765
+ for (; ; ) {
1766
+ const step = await this.transaction(async (session) => {
1767
+ const orphans = await this.#events.aggregate(
1768
+ [
1769
+ {
1770
+ $match: {
1771
+ $or: [
1772
+ // Never completed (no subscribers, or every delivery dead): by age.
1773
+ { completedAt: { $exists: false }, createdAt: { $lt: cutoff } },
1774
+ // Completed: by the time of its last completion, and only under the completed cutoff.
1775
+ ...completedBefore === void 0 ? [] : [{ completedAt: { $lt: completedBefore } }]
1776
+ ]
1777
+ }
1778
+ },
1779
+ { $sort: { createdAt: 1, _id: 1 } },
1780
+ ...references.flatMap((from) => [
1781
+ {
1782
+ $lookup: {
1783
+ from,
1784
+ localField: "_id",
1785
+ foreignField: "eventId",
1786
+ pipeline: [{ $limit: 1 }, { $project: { _id: 1 } }],
1787
+ as: "references"
1788
+ }
1789
+ },
1790
+ { $match: { references: { $size: 0 } } }
1791
+ ]),
1792
+ { $limit: this.#purgeBatchSize },
1793
+ { $project: { _id: 1 } }
1794
+ ],
1795
+ { session }
1796
+ ).toArray();
1797
+ const deleted = await this.#events.deleteMany(
1798
+ { _id: { $in: orphans.map((orphan) => orphan._id) } },
1799
+ { session }
1800
+ );
1801
+ return { found: orphans.length, deleted: deleted.deletedCount };
1802
+ });
1803
+ deletedEvents += step.deleted;
1804
+ if (step.found < this.#purgeBatchSize) {
1805
+ return deletedEvents;
1806
+ }
1807
+ }
1808
+ }
742
1809
  };
743
- function deliveryPairKey(eventId, handler) {
744
- return `${eventId}\0${handler}`;
1810
+ function isClaimable(candidate, scan, blockedKeys) {
1811
+ const { key, sequence } = candidate;
1812
+ if (key === void 0) {
1813
+ return true;
1814
+ }
1815
+ const orderingId = orderingIdentity(candidate.handler, key);
1816
+ if (scan.waits(orderingId, sequence)) {
1817
+ scan.noteWaiting(orderingId, candidate._id, candidate.laneId);
1818
+ return false;
1819
+ }
1820
+ if (blockedKeys.has(orderingId)) {
1821
+ return false;
1822
+ }
1823
+ blockedKeys.add(orderingId);
1824
+ return true;
1825
+ }
1826
+ function freeLane(laneId, epoch, revision, finished) {
1827
+ return {
1828
+ updateOne: {
1829
+ filter: {
1830
+ _id: laneId,
1831
+ epoch,
1832
+ revision: atCount(revision),
1833
+ inFlight: { $not: { $elemMatch: { $nin: [...finished] } } }
1834
+ },
1835
+ update: {
1836
+ $set: { owner: null, holder: null, leaseExpiresAt: null, inFlight: [] },
1837
+ $inc: { revision: 1 }
1838
+ }
1839
+ }
1840
+ };
1841
+ }
1842
+ async function inBatches(cursor, work) {
1843
+ let batch = [];
1844
+ for await (const document of cursor) {
1845
+ batch.push(document);
1846
+ if (batch.length === UPGRADE_BATCH_SIZE) {
1847
+ await work(batch);
1848
+ batch = [];
1849
+ }
1850
+ }
1851
+ if (batch.length > 0) {
1852
+ await work(batch);
1853
+ }
1854
+ }
1855
+ function atCount(count) {
1856
+ return count === 0 ? { $not: { $gt: 0 } } : count;
1857
+ }
1858
+ function fencedLeases(issued, lanes, documents) {
1859
+ const claimsById = new Map(documents.map((document) => [document._id, document.claims ?? 0]));
1860
+ const fenced = /* @__PURE__ */ new Map();
1861
+ for (const lease of issued) {
1862
+ const lane = lanes.get(lease.laneId);
1863
+ if (claimsById.get(lease.deliveryId) === lease.claims && lane.epoch === lease.epoch && lane.inFlight.includes(lease.deliveryId)) {
1864
+ fenced.set(leaseIdentity(lease), lease);
1865
+ }
1866
+ }
1867
+ return [...fenced.values()];
1868
+ }
1869
+ function ofClaim(lease) {
1870
+ return { _id: lease.deliveryId, status: "pending", claims: atCount(lease.claims) };
1871
+ }
1872
+ function leaseKey(deliveryId, leaseToken) {
1873
+ return `${deliveryId}\0${leaseToken}`;
1874
+ }
1875
+ function leaseIdentity(lease) {
1876
+ return `${lease.deliveryId}\0${formatLeaseToken(lease.laneId, lease.epoch, lease.claims)}`;
1877
+ }
1878
+ function hasErrorCode(error, code) {
1879
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
1880
+ }
1881
+ function compareText(left, right) {
1882
+ return Number(left > right) - Number(left < right);
745
1883
  }
746
1884
  function assertInTransaction(session, operation) {
747
1885
  if (!session.inTransaction()) {