@relaybus/mongodb 0.2.0 → 0.3.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
@@ -1,56 +1,10 @@
1
1
  'use strict';
2
2
 
3
3
  var core = require('@relaybus/core');
4
+ var internal = require('@relaybus/core/internal');
4
5
 
5
6
  // src/mongo-event-store.ts
6
7
 
7
- // src/claim-scan.ts
8
- var ClaimScan = class {
9
- #keys = /* @__PURE__ */ new Map();
10
- /** Whether an unfinished delivery this scan knows of comes before the delivery with `sequence`. */
11
- waits(orderingId, sequence) {
12
- const known = this.#keys.get(orderingId);
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);
18
- }
19
- /** Records an unfinished delivery of the key with this sequence. */
20
- noteOpen(orderingId, sequence) {
21
- const known = this.#know(orderingId);
22
- known.openSequence = Math.min(known.openSequence ?? sequence, sequence);
23
- }
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;
29
- }
30
- /**
31
- * Ids of the waiting deliveries of every key that accumulated at least
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.
37
- */
38
- waitingBacklogs(minimum, handedBack) {
39
- return [...this.#keys.values()].filter((known) => known.waiting.length >= minimum || handedBack.has(known.laneId)).flatMap((known) => known.waiting);
40
- }
41
- #know(orderingId) {
42
- let known = this.#keys.get(orderingId);
43
- if (known === void 0) {
44
- known = { openSequence: void 0, waiting: [], laneId: void 0 };
45
- this.#keys.set(orderingId, known);
46
- }
47
- return known;
48
- }
49
- };
50
- function orderingIdentity(handler, key) {
51
- return `${handler}\0${key}`;
52
- }
53
-
54
8
  // src/lanes.ts
55
9
  var LANE_COUNT = 256;
56
10
  var FNV_OFFSET_BASIS = 2166136261;
@@ -63,7 +17,7 @@ function laneOf(key, eventId) {
63
17
  }
64
18
  return hash & LANE_COUNT - 1;
65
19
  }
66
- function formatLeaseToken(laneId, epoch, claims) {
20
+ function formatLeaseToken(laneId, { epoch, claims }) {
67
21
  return `${laneId}:${epoch}:${claims}`;
68
22
  }
69
23
  var LEASE_TOKEN_PATTERN = /^(\d{1,3}):(\d{1,15}):(\d{1,15})$/;
@@ -76,7 +30,11 @@ function parseLeaseToken(token) {
76
30
  }
77
31
 
78
32
  // src/documents.ts
79
- function toEventDocument(event, sequence, handlers, createdAt) {
33
+ function toEventDocument(event, {
34
+ sequence,
35
+ handlers,
36
+ createdAt
37
+ }) {
80
38
  const payloadJson = serializeWellFormed(event.payload, "payload");
81
39
  const headersJson = serializeWellFormed(event.headers, "headers");
82
40
  return {
@@ -121,7 +79,11 @@ function toEnvelope(document) {
121
79
  occurredAt: document.occurredAt
122
80
  };
123
81
  }
124
- function toDeliveryDocument(delivery, event, ordering, now) {
82
+ function toDeliveryDocument(delivery, {
83
+ event,
84
+ ordering,
85
+ now
86
+ }) {
125
87
  return {
126
88
  _id: delivery.id,
127
89
  eventId: event.id,
@@ -138,7 +100,10 @@ function toDeliveryDocument(delivery, event, ordering, now) {
138
100
  createdAt: now
139
101
  };
140
102
  }
141
- function toDeliveryRecord(document, event, runningIn) {
103
+ function toDeliveryRecord(document, {
104
+ event,
105
+ runningIn
106
+ }) {
142
107
  return {
143
108
  id: document._id,
144
109
  handler: document.handler,
@@ -183,7 +148,17 @@ function toCompletedDeliveryDocument(document, completedAt) {
183
148
  };
184
149
  }
185
150
  function toErrorInfo(error) {
186
- return error.stack === void 0 ? { name: error.name, message: error.message } : { name: error.name, message: error.message, stack: error.stack };
151
+ const info = {
152
+ name: error.name,
153
+ message: error.message
154
+ };
155
+ if (error.stack !== void 0) {
156
+ info.stack = error.stack;
157
+ }
158
+ if (error.code !== void 0) {
159
+ info.code = error.code;
160
+ }
161
+ return info;
187
162
  }
188
163
  var ILLEGAL_OPERATION = 20;
189
164
  function isTransactionsUnsupportedError(error) {
@@ -195,396 +170,139 @@ function createTransactionsUnsupportedError(cause) {
195
170
  { cause }
196
171
  );
197
172
  }
198
- var COLLECTION_PREFIX_PATTERN = /^[A-Za-z_][A-Za-z0-9_]{0,39}$/;
199
- var DATABASE_NAME_PATTERN = /^[A-Za-z0-9_-]{1,63}$/;
200
- function assertCollectionPrefix(prefix) {
201
- if (!COLLECTION_PREFIX_PATTERN.test(prefix)) {
202
- throw new core.ConfigurationError(
203
- `collectionPrefix '${prefix}' must be 1-40 characters of letters, digits or '_', starting with a letter or '_'`
204
- );
173
+
174
+ // src/mongo-store-state.ts
175
+ async function runInTransaction(state, work) {
176
+ return runInTransactionWith(state, { work, transactionOptions: state.transactionOptions });
177
+ }
178
+ async function runInBudgetedTransaction(state, {
179
+ work,
180
+ timeoutMs
181
+ }) {
182
+ const budget = Math.ceil(timeoutMs);
183
+ return runInTransactionWith(state, {
184
+ work,
185
+ transactionOptions: { ...state.transactionOptions, maxCommitTimeMS: budget }
186
+ });
187
+ }
188
+ async function runInTransactionWith(state, {
189
+ work,
190
+ transactionOptions
191
+ }) {
192
+ try {
193
+ return await state.client.withSession(async (session) => {
194
+ let result;
195
+ await session.withTransaction(async () => {
196
+ result = await work(session);
197
+ }, transactionOptions);
198
+ return result;
199
+ });
200
+ } catch (error) {
201
+ if (isTransactionsUnsupportedError(error)) {
202
+ throw createTransactionsUnsupportedError(error);
203
+ }
204
+ throw error;
205
205
  }
206
206
  }
207
- function assertDatabaseName(name) {
208
- if (!DATABASE_NAME_PATTERN.test(name)) {
207
+ function leaseKey(deliveryId, leaseToken) {
208
+ return `${deliveryId}\0${leaseToken}`;
209
+ }
210
+ function assertInTransaction(session, operation) {
211
+ if (!session.inTransaction()) {
209
212
  throw new core.ConfigurationError(
210
- `database '${name}' must be 1-63 characters of letters, digits, '_' or '-'`
213
+ `MongoEventStore.${operation} needs a ClientSession inside a started transaction (session.withTransaction or startTransaction). Without one, MongoDB commits each write immediately and the write is no longer atomic with yours`
211
214
  );
212
215
  }
213
216
  }
217
+ function atCount(count) {
218
+ return count === 0 ? { $not: { $gt: 0 } } : count;
219
+ }
214
220
 
215
- // src/mongo-event-store.ts
216
- var DEFAULT_TRANSACTION_OPTIONS = {
217
- readConcern: { level: "snapshot" },
218
- writeConcern: { w: "majority" },
219
- readPreference: "primary"
220
- };
221
- var NAMESPACE_EXISTS = 48;
222
- var INDEX_NOT_FOUND = 27;
223
- var DEFAULT_PURGE_BATCH_SIZE = 500;
224
- var DEFAULT_LIST_LIMIT = 100;
225
- var MINIMUM_CANDIDATE_BATCH_SIZE = 32;
226
- var PARK_BACKLOG_THRESHOLD = 32;
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"];
233
- var LEASE_FIELDS = { leaseOwner: "", leaseToken: "", leaseExpiresAt: "" };
234
- var FINISHED_FIELDS = { dueAt: "", lane: "" };
235
- var MongoEventStore = class {
236
- #client;
237
- #database;
238
- #events;
239
- #deliveries;
240
- #keySequences;
241
- #lanes;
242
- #completions;
243
- #completionHistory;
244
- #transactionOptions;
245
- #purgeBatchSize;
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();
268
- constructor(options) {
269
- const prefix = options.collectionPrefix ?? "relaybus_";
270
- assertCollectionPrefix(prefix);
271
- if (options.database !== void 0) {
272
- assertDatabaseName(options.database);
273
- }
274
- const purgeBatchSize = options.purgeBatchSize ?? DEFAULT_PURGE_BATCH_SIZE;
275
- if (!Number.isInteger(purgeBatchSize) || purgeBatchSize < 1) {
276
- throw new core.ConfigurationError(
277
- `purgeBatchSize must be a positive integer, received ${String(purgeBatchSize)}`
278
- );
279
- }
280
- this.#client = options.client;
281
- const database = options.client.db(options.database);
282
- this.#database = database;
283
- const collectionOptions = {
284
- readPreference: "primary",
285
- writeConcern: { w: "majority" }
221
+ // src/mongo-append.ts
222
+ var MongoAppend = class {
223
+ #state;
224
+ constructor(state) {
225
+ this.#state = state;
226
+ }
227
+ /** Implements {@link MongoEventStore.append}. */
228
+ async append(session, { entries, now }) {
229
+ assertInTransaction(session, "append");
230
+ const eventIds = [...new Set(entries.map((entry) => entry.event.id))];
231
+ const storedEvents = new Map(
232
+ (await this.#state.events.find(
233
+ { _id: { $in: eventIds } },
234
+ { session, projection: { key: 1, sequence: 1, handlers: 1 } }
235
+ ).toArray()).map((stored) => [stored._id, stored])
236
+ );
237
+ const plan = await this.#planAppend({ session, entries, storedEvents, now });
238
+ await this.#writeAppend({ session, plan, storedEvents, now });
239
+ return {
240
+ appendedEventIds: plan.appendedEventIds,
241
+ duplicateEventIds: plan.duplicateEventIds,
242
+ appendedDeliveryCount: plan.newDeliveries.length
286
243
  };
287
- this.#events = database.collection(`${prefix}events`, collectionOptions);
288
- this.#deliveries = database.collection(`${prefix}deliveries`, collectionOptions);
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;
293
- this.#transactionOptions = options.transactionOptions ?? DEFAULT_TRANSACTION_OPTIONS;
294
- this.#purgeBatchSize = purgeBatchSize;
295
- this.#logger = options.logger ?? console;
296
244
  }
297
245
  /**
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.
303
- *
304
- * @throws ConfigurationError when the server is a standalone mongod.
246
+ * Decides, in entry order, which events are new (taking their key sequence) and which deliveries
247
+ * each event still lacks.
305
248
  */
306
- async setup() {
307
- const hello = await this.#client.db("admin").command({ hello: 1 });
308
- const supportsTransactions = typeof hello["setName"] === "string" || hello["msg"] === "isdbgrid";
309
- if (!supportsTransactions) {
310
- throw createTransactionsUnsupportedError(void 0);
311
- }
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);
321
- }
322
- if (await this.#lanes.countDocuments() < LANE_COUNT) {
323
- await this.#upgradeFromVersion010();
324
- await this.#seedLanes();
249
+ async #planAppend(input) {
250
+ const { session, entries, storedEvents, now } = input;
251
+ const appendedEventIds = [];
252
+ const duplicateEventIds = [];
253
+ const newEvents = [];
254
+ const newDeliveries = [];
255
+ const knownHandlers = /* @__PURE__ */ new Map();
256
+ 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));
325
264
  }
326
- await this.#dropObsoleteIndexes();
327
- await this.#deliveries.createIndexes([
328
- // Idempotent appends and lookups by event.
329
- { key: { eventId: 1, handler: 1 }, name: "event_handler", unique: true },
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" } },
333
- // Ordering: unfinished predecessors of a keyed delivery, parked or not.
334
- {
335
- key: { handler: 1, key: 1, sequence: 1 },
336
- name: "ordering",
337
- partialFilterExpression: { dueAt: { $exists: true }, sequence: { $exists: true } }
338
- },
339
- // Parked deliveries: the lowest parked delivery of each key, to move it back once it is next.
340
- {
341
- key: { handler: 1, key: 1, sequence: 1 },
342
- name: "parked",
343
- partialFilterExpression: { lane: "parked" }
344
- },
345
- // Statistics: oldest due delivery across handlers.
346
- { key: { dueAt: 1 }, name: "due", partialFilterExpression: { dueAt: { $exists: true } } },
347
- // Purge of completed deliveries left by 0.1.0.
348
- {
349
- key: { completedAt: 1 },
350
- name: "completed",
351
- partialFilterExpression: { completedAt: { $exists: true } }
352
- },
353
- // Listing and status counts.
354
- { key: { status: 1, createdAt: 1 }, name: "status_created" },
355
- // Purge of dead deliveries.
356
- { key: { status: 1, updatedAt: 1 }, name: "status_updated" },
357
- { key: { handler: 1, createdAt: 1 }, name: "handler_created" },
358
- { key: { eventType: 1, createdAt: 1 }, name: "type_created" }
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 } }
265
+ for (const { event, deliveries } of entries) {
266
+ let ordering = orderingByEventId.get(event.id);
267
+ if (ordering === void 0) {
268
+ const sequence = event.key === null ? null : await this.#nextSequence(event.key, session);
269
+ ordering = { key: event.key, sequence };
270
+ orderingByEventId.set(event.id, ordering);
271
+ knownHandlers.set(event.id, /* @__PURE__ */ new Set());
272
+ newEvents.push({ event, sequence });
273
+ appendedEventIds.push(event.id);
274
+ } else {
275
+ duplicateEventIds.push(event.id);
367
276
  }
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
- }
376
- }
377
- async #createCollectionIfMissing(name) {
378
- try {
379
- await this.#database.createCollection(name);
380
- } catch (error) {
381
- if (!hasErrorCode(error, NAMESPACE_EXISTS)) {
382
- throw error;
277
+ const handlers = knownHandlers.get(event.id);
278
+ for (const delivery of deliveries) {
279
+ if (handlers.has(delivery.handler)) {
280
+ continue;
281
+ }
282
+ handlers.add(delivery.handler);
283
+ addedHandlers.set(event.id, [...addedHandlers.get(event.id) ?? [], delivery.handler]);
284
+ newDeliveries.push(toDeliveryDocument(delivery, { event, ordering, now }));
383
285
  }
384
286
  }
287
+ return { appendedEventIds, duplicateEventIds, newEvents, newDeliveries, addedHandlers };
385
288
  }
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) => ({
289
+ /** Writes the new events, the handlers added to existing events and the new deliveries, in that order. */
290
+ async #writeAppend(input) {
291
+ const { session, plan, storedEvents, now } = input;
292
+ const { newEvents, newDeliveries, addedHandlers } = plan;
293
+ 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 {
418
302
  updateOne: {
419
303
  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
- }
500
- /**
501
- * Runs `work` in a transaction on a new session and commits when it resolves.
502
- *
503
- * `work` can run more than once. The driver restarts the whole callback after
504
- * a transient failure such as a write conflict, and re-sends only the commit
505
- * after an unknown commit result. A restart is safe for everything written
506
- * through the session, because the aborted attempt left nothing behind. It is
507
- * not safe for side effects outside the session (HTTP calls, other databases),
508
- * so keep those out of `work`; relaybus handlers are already at-least-once.
509
- *
510
- * @throws ConfigurationError when the deployment does not support transactions.
511
- */
512
- async transaction(work) {
513
- try {
514
- return await this.#client.withSession(async (session) => {
515
- let result;
516
- await session.withTransaction(async () => {
517
- result = await work(session);
518
- }, this.#transactionOptions);
519
- return result;
520
- });
521
- } catch (error) {
522
- if (isTransactionsUnsupportedError(error)) {
523
- throw createTransactionsUnsupportedError(error);
524
- }
525
- throw error;
526
- }
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
- */
534
- async append(session, entries, now) {
535
- assertInTransaction(session, "append");
536
- const eventIds = [...new Set(entries.map((entry) => entry.event.id))];
537
- const storedEvents = new Map(
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])
542
- );
543
- const appendedEventIds = [];
544
- const duplicateEventIds = [];
545
- const newEvents = [];
546
- const newDeliveries = [];
547
- const knownHandlers = /* @__PURE__ */ new Map();
548
- const addedHandlers = /* @__PURE__ */ new Map();
549
- const orderingByEventId = /* @__PURE__ */ new Map();
550
- for (const [eventId, stored] of storedEvents) {
551
- orderingByEventId.set(eventId, {
552
- key: stored.key ?? null,
553
- sequence: stored.sequence ?? null
554
- });
555
- knownHandlers.set(eventId, new Set(stored.handlers));
556
- }
557
- for (const { event, deliveries } of entries) {
558
- let ordering = orderingByEventId.get(event.id);
559
- if (ordering === void 0) {
560
- const sequence = event.key === null ? null : await this.#nextSequence(event.key, session);
561
- ordering = { key: event.key, sequence };
562
- orderingByEventId.set(event.id, ordering);
563
- knownHandlers.set(event.id, /* @__PURE__ */ new Set());
564
- newEvents.push({ event, sequence });
565
- appendedEventIds.push(event.id);
566
- } else {
567
- duplicateEventIds.push(event.id);
568
- }
569
- const handlers = knownHandlers.get(event.id);
570
- for (const delivery of deliveries) {
571
- if (handlers.has(delivery.handler)) {
572
- continue;
573
- }
574
- handlers.add(delivery.handler);
575
- addedHandlers.set(event.id, [...addedHandlers.get(event.id) ?? [], delivery.handler]);
576
- newDeliveries.push(toDeliveryDocument(delivery, event, ordering, now));
577
- }
578
- }
579
- if (newEvents.length > 0) {
580
- await this.#events.bulkWrite(
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
304
+ update: { $setOnInsert: document },
305
+ upsert: true
588
306
  }
589
307
  };
590
308
  }),
@@ -593,7 +311,7 @@ var MongoEventStore = class {
593
311
  }
594
312
  const extendedEvents = [...addedHandlers].filter(([eventId]) => storedEvents.has(eventId));
595
313
  if (extendedEvents.length > 0) {
596
- await this.#events.bulkWrite(
314
+ await this.#state.events.bulkWrite(
597
315
  extendedEvents.map(([eventId, handlers]) => ({
598
316
  updateOne: {
599
317
  filter: { _id: eventId },
@@ -604,7 +322,7 @@ var MongoEventStore = class {
604
322
  );
605
323
  }
606
324
  if (newDeliveries.length > 0) {
607
- await this.#deliveries.bulkWrite(
325
+ await this.#state.deliveries.bulkWrite(
608
326
  newDeliveries.map((document) => ({
609
327
  updateOne: {
610
328
  filter: { eventId: document.eventId, handler: document.handler },
@@ -615,40 +333,106 @@ var MongoEventStore = class {
615
333
  { session, ordered: true }
616
334
  );
617
335
  }
618
- return { appendedEventIds, duplicateEventIds, appendedDeliveryCount: newDeliveries.length };
336
+ }
337
+ /** Atomically increments the counter of `key`; conflicting appends of the key serialize here. */
338
+ async #nextSequence(key, session) {
339
+ const counter = await this.#state.keySequences.findOneAndUpdate(
340
+ { _id: key },
341
+ { $inc: { value: 1 } },
342
+ { upsert: true, returnDocument: "after", session }
343
+ );
344
+ return counter.value;
345
+ }
346
+ };
347
+
348
+ // src/claim-scan.ts
349
+ var ClaimScan = class {
350
+ #keys = /* @__PURE__ */ new Map();
351
+ /** Whether an unfinished delivery this scan knows of comes before the delivery with `sequence`. */
352
+ waits(orderingId, sequence) {
353
+ const known = this.#keys.get(orderingId);
354
+ return known?.openSequence !== void 0 && sequence > known.openSequence;
355
+ }
356
+ /** Whether this scan already learned anything about the key. */
357
+ knows(orderingId) {
358
+ return this.#keys.has(orderingId);
359
+ }
360
+ /** Records an unfinished delivery of the key with this sequence. */
361
+ noteOpen(orderingId, sequence) {
362
+ const known = this.#know(orderingId);
363
+ known.openSequence = Math.min(known.openSequence ?? sequence, sequence);
364
+ }
365
+ /** Remembers a pending delivery of lane `laneId` that waited behind an unfinished predecessor. */
366
+ noteWaiting(orderingId, { deliveryId, laneId }) {
367
+ const known = this.#know(orderingId);
368
+ known.waiting.push(deliveryId);
369
+ known.laneId = laneId;
619
370
  }
620
371
  /**
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.
372
+ * Ids of the waiting deliveries of every key that accumulated at least
373
+ * `minimum` of them, or that lives in one of `handedBack`. Shorter queues
374
+ * of a lane that stays held remain in the claim index: moving them out and
375
+ * back would cost more than skipping them. In a lane handed back they are
376
+ * all its due work, and leaving them ready would make every later claim take
377
+ * the lane for nothing.
639
378
  */
379
+ waitingBacklogs(minimum, handedBack) {
380
+ return [...this.#keys.values()].filter((known) => known.waiting.length >= minimum || handedBack.has(known.laneId)).flatMap((known) => known.waiting);
381
+ }
382
+ #know(orderingId) {
383
+ let known = this.#keys.get(orderingId);
384
+ if (known === void 0) {
385
+ known = { openSequence: void 0, waiting: [], laneId: void 0 };
386
+ this.#keys.set(orderingId, known);
387
+ }
388
+ return known;
389
+ }
390
+ };
391
+ function orderingIdentity(handler, key) {
392
+ return `${handler}\0${key}`;
393
+ }
394
+
395
+ // src/mongo-claim.ts
396
+ var PARK_BACKLOG_THRESHOLD = 32;
397
+ var INITIAL_LANES_PER_ROUND = 4;
398
+ var LANES_PER_CANDIDATE_QUERY = 128;
399
+ function scopeOf(held) {
400
+ return new Map(
401
+ held.map((lane) => [
402
+ lane._id,
403
+ {
404
+ laneId: lane._id,
405
+ epoch: lane.epoch,
406
+ listed: lane.inFlight,
407
+ revision: lane.revision ?? 0
408
+ }
409
+ ])
410
+ );
411
+ }
412
+ var MongoClaims = class {
413
+ #state;
414
+ #laneLeases;
415
+ #ordering;
416
+ #queries;
417
+ constructor(state, collaborators) {
418
+ this.#state = state;
419
+ this.#laneLeases = collaborators.laneLeases;
420
+ this.#ordering = collaborators.ordering;
421
+ this.#queries = collaborators.queries;
422
+ }
423
+ /** Implements {@link MongoEventStore.claim}. */
640
424
  async claim(request) {
641
425
  if (request.handlers.length === 0 || request.limit <= 0) {
642
426
  return [];
643
427
  }
644
428
  const owner = request.leaseOwner;
645
- const previous = this.#claimsInProgress.get(owner) ?? Promise.resolve();
429
+ const previous = this.#state.claimsInProgress.get(owner) ?? Promise.resolve();
646
430
  const claimed = previous.then(() => this.#claimReconciled(request));
647
431
  const settled = Promise.allSettled([claimed]);
648
- this.#claimsInProgress.set(owner, settled);
432
+ this.#state.claimsInProgress.set(owner, settled);
649
433
  void settled.then(() => {
650
- if (this.#claimsInProgress.get(owner) === settled) {
651
- this.#claimsInProgress.delete(owner);
434
+ if (this.#state.claimsInProgress.get(owner) === settled) {
435
+ this.#state.claimsInProgress.delete(owner);
652
436
  }
653
437
  });
654
438
  return claimed;
@@ -663,15 +447,15 @@ var MongoEventStore = class {
663
447
  */
664
448
  async #claimReconciled(request) {
665
449
  const owner = request.leaseOwner;
666
- if (this.#unreconciledOwners.has(owner)) {
450
+ if (this.#state.unreconciledOwners.has(owner)) {
667
451
  await this.#reconcileLanes(owner);
668
- this.#unreconciledOwners.delete(owner);
452
+ this.#state.unreconciledOwners.delete(owner);
669
453
  }
670
454
  try {
671
455
  const claimed = await this.#claimNow(request);
672
456
  for (const delivery of claimed) {
673
457
  const { laneId, epoch } = parseLeaseToken(delivery.leaseToken);
674
- this.#handedOut.set(leaseKey(delivery.deliveryId, delivery.leaseToken), {
458
+ this.#state.handedOut.set(leaseKey(delivery.deliveryId, delivery.leaseToken), {
675
459
  deliveryId: delivery.deliveryId,
676
460
  owner: request.leaseOwner,
677
461
  laneId,
@@ -682,7 +466,7 @@ var MongoEventStore = class {
682
466
  } catch (error) {
683
467
  const [reconciled] = await Promise.allSettled([this.#reconcileLanes(owner)]);
684
468
  if (reconciled.status === "rejected") {
685
- this.#unreconciledOwners.add(owner);
469
+ this.#state.unreconciledOwners.add(owner);
686
470
  }
687
471
  throw error;
688
472
  }
@@ -700,11 +484,11 @@ var MongoEventStore = class {
700
484
  * delivery are dropped.
701
485
  */
702
486
  async #reconcileLanes(owner) {
703
- await this.transaction(async (session) => {
704
- const held = await this.#lanes.find({ owner, holder: this.#instanceId }, { session }).toArray();
487
+ await runInTransaction(this.#state, async (session) => {
488
+ const held = await this.#state.lanes.find({ owner, holder: this.#state.instanceId }, { session }).toArray();
705
489
  const listed = held.flatMap((lane) => lane.inFlight);
706
490
  const claimsById = new Map(
707
- (await this.#deliveries.find(
491
+ (await this.#state.deliveries.find(
708
492
  { _id: { $in: listed }, status: "pending" },
709
493
  { session, projection: { claims: 1 } }
710
494
  ).toArray()).map((document) => [document._id, document.claims ?? 0])
@@ -713,70 +497,81 @@ var MongoEventStore = class {
713
497
  const unclaimed = held.map((lane) => ({
714
498
  lane,
715
499
  ids: lane.inFlight.filter((id) => {
716
- const key = leaseKey(id, formatLeaseToken(lane._id, lane.epoch, claimsById.get(id) ?? 0));
500
+ const key = leaseKey(
501
+ id,
502
+ formatLeaseToken(lane._id, { epoch: lane.epoch, claims: claimsById.get(id) ?? 0 })
503
+ );
717
504
  current.add(key);
718
- return !this.#handedOut.has(key);
505
+ return !this.#state.handedOut.has(key);
719
506
  })
720
507
  }));
721
- for (const [key, entry] of this.#handedOut) {
508
+ for (const [key, entry] of this.#state.handedOut) {
722
509
  if (entry.owner === owner && !current.has(key)) {
723
- this.#handedOut.delete(key);
510
+ this.#state.handedOut.delete(key);
724
511
  }
725
512
  }
726
513
  if (held.length === 0) {
727
514
  return;
728
515
  }
729
- await this.#lanes.bulkWrite(
516
+ await this.#state.lanes.bulkWrite(
730
517
  unclaimed.map(({ lane, ids }) => ({
731
518
  updateOne: {
732
- filter: { _id: lane._id, epoch: lane.epoch, holder: this.#instanceId },
519
+ filter: { _id: lane._id, epoch: lane.epoch, holder: this.#state.instanceId },
733
520
  update: { $pull: { inFlight: { $in: ids } }, $inc: { revision: 1 } }
734
521
  }
735
522
  })),
736
523
  { session, ordered: false }
737
524
  );
738
- await this.#deliveries.updateMany(
525
+ await this.#state.deliveries.updateMany(
739
526
  { _id: { $in: unclaimed.flatMap((entry) => entry.ids) }, status: "pending" },
740
527
  { $inc: { claims: 1 } },
741
528
  { session }
742
529
  );
743
530
  });
744
531
  }
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
532
  async #claimNow(request) {
759
533
  const [held] = await Promise.all([
760
- this.#lanes.find({
534
+ this.#state.lanes.find({
761
535
  owner: request.leaseOwner,
762
- holder: this.#instanceId,
536
+ holder: this.#state.instanceId,
763
537
  leaseExpiresAt: { $gt: request.now }
764
538
  }).toArray(),
765
- this.#unparkNextDeliveries(request.handlers)
539
+ this.#ordering.unparkNextDeliveries(request.handlers)
766
540
  ]);
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
776
- }
777
- ])
541
+ await this.#laneLeases.chargeLost(held);
542
+ const scope = scopeOf(held);
543
+ const running = this.#loadRunning(held);
544
+ const scan = new ClaimScan();
545
+ const picked = [];
546
+ await this.#pickAcrossLanes({ request, scope, scan, running, picked });
547
+ const events = await this.#queries.readEnvelopes(picked.map((document) => document.eventId));
548
+ const runningIds = (await running).ids;
549
+ this.#forgetSettled(held, { runningIds, owner: request.leaseOwner });
550
+ const { recorded, handedBack } = await this.#laneLeases.recordClaim(scope, {
551
+ picked,
552
+ runningIds,
553
+ request
554
+ });
555
+ const claimed = await this.#releaseOvertaken(
556
+ picked.filter((document) => recorded.has(document.laneId)),
557
+ scope
778
558
  );
779
- const running = this.#findRunning(held.flatMap((lane) => lane.inFlight)).then((documents) => ({
559
+ 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
+ }));
571
+ }
572
+ /** The deliveries still running in the held lanes, with their (handler, key) identities. */
573
+ #loadRunning(held) {
574
+ return this.#queries.findRunning(held.flatMap((lane) => lane.inFlight)).then((documents) => ({
780
575
  ids: new Set(documents.map((document) => document._id)),
781
576
  keys: new Set(
782
577
  documents.flatMap(
@@ -784,88 +579,149 @@ var MongoEventStore = class {
784
579
  )
785
580
  )
786
581
  }));
787
- const scan = new ClaimScan();
788
- const picked = [];
582
+ }
583
+ /**
584
+ * Picks from the lanes in `scope`, then takes free lanes with due work, a few more each round
585
+ * (4, 8, 16, ...), and picks from them, until the claim is full or no free lane is left.
586
+ */
587
+ async #pickAcrossLanes(input) {
588
+ const { request, scope, scan, running, picked } = input;
789
589
  const pickFrom = async (lanes) => {
790
590
  for (let offset = 0; offset < lanes.length; offset += LANES_PER_CANDIDATE_QUERY) {
791
591
  if (picked.length < request.limit) {
792
- await this.#pickCandidates(
793
- lanes.slice(offset, offset + LANES_PER_CANDIDATE_QUERY),
592
+ await this.#ordering.pickCandidates({
593
+ lanes: lanes.slice(offset, offset + LANES_PER_CANDIDATE_QUERY),
794
594
  request,
795
595
  scan,
796
596
  running,
797
597
  picked
798
- );
598
+ });
799
599
  }
800
600
  }
801
601
  };
802
602
  await pickFrom([...scope.values()]);
803
603
  const passed = new Set(scope.keys());
804
604
  for (let laneCount = INITIAL_LANES_PER_ROUND; picked.length < request.limit; laneCount *= 2) {
805
- const free = await this.#findFreeLanes(request, laneCount, [...passed]);
605
+ const free = await this.#laneLeases.findFreeLanes(request, {
606
+ laneCount,
607
+ passed: [...passed]
608
+ });
806
609
  if (free.length === 0) {
807
610
  break;
808
611
  }
809
612
  for (const laneId of free) {
810
613
  passed.add(laneId);
811
614
  }
812
- const taken = await this.#takeLanes(free, request);
615
+ const taken = await this.#laneLeases.takeLanes(free, request);
813
616
  for (const lane of taken) {
814
617
  scope.set(lane.laneId, lane);
815
618
  }
816
619
  await pickFrom(taken);
817
620
  }
818
- const events = await this.#readEnvelopes(picked.map((document) => document.eventId));
819
- const runningIds = (await running).ids;
820
- const gone = new Set(
621
+ }
622
+ /**
623
+ * Forgets the claims of `owner` that are listed in the held lanes but no longer pending:
624
+ * completed (a burial or retry would have unlisted it). Such a claim is settled, also when it was
625
+ * completed inside a caller's transaction that committed. Only this owner's registrations of the
626
+ * lane epochs it read: the same delivery may meanwhile have been requeued and handed out again,
627
+ * under another owner or lane epoch.
628
+ */
629
+ #forgetSettled(held, { runningIds, owner }) {
630
+ const gone = new Set(
821
631
  held.flatMap(
822
632
  (lane) => lane.inFlight.filter((id) => !runningIds.has(id)).map((id) => `${id}\0${lane._id}:${lane.epoch}`)
823
633
  )
824
634
  );
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);
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);
828
638
  }
829
639
  }
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
- }));
848
640
  }
849
641
  /**
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.
642
+ * Gives back the recorded keyed picks that a delivery requeued during the
643
+ * claim now precedes, without charging an attempt, and returns the rest. The
644
+ * claim checked their predecessors before recording them; a dead letter
645
+ * requeued in between would otherwise wait behind its own successor. Once a
646
+ * pick is recorded, a later requeue waits for it like for any running delivery.
853
647
  */
854
- async extendLease(lease, leaseExpiresAt, _now) {
648
+ async #releaseOvertaken(claimed, scope) {
649
+ const keyed = claimed.filter((document) => document.key !== void 0);
650
+ if (keyed.length === 0) {
651
+ return [...claimed];
652
+ }
653
+ const blockers = await this.#state.deliveries.find(
654
+ {
655
+ $or: keyed.map((document) => ({
656
+ handler: document.handler,
657
+ key: document.key,
658
+ sequence: { $lt: document.sequence },
659
+ dueAt: { $exists: true }
660
+ }))
661
+ },
662
+ { projection: { handler: 1, key: 1 } }
663
+ ).toArray();
664
+ const blocked = new Set(
665
+ blockers.map((blocker) => orderingIdentity(blocker.handler, blocker.key))
666
+ );
667
+ const overtaken = new Set(
668
+ keyed.filter((document) => blocked.has(orderingIdentity(document.handler, document.key))).map((document) => document._id)
669
+ );
670
+ if (overtaken.size === 0) {
671
+ return [...claimed];
672
+ }
673
+ await this.#releasePicks(
674
+ claimed.filter((document) => overtaken.has(document._id)),
675
+ scope
676
+ );
677
+ return claimed.filter((document) => !overtaken.has(document._id));
678
+ }
679
+ /** Takes recorded picks out of their lanes' in-flight lists again, under the claim's epochs and record revisions. */
680
+ async #releasePicks(documents, scope) {
681
+ const idsByLane = /* @__PURE__ */ new Map();
682
+ for (const document of documents) {
683
+ idsByLane.set(document.laneId, [...idsByLane.get(document.laneId) ?? [], document._id]);
684
+ }
685
+ await Promise.all(
686
+ [...idsByLane].map(
687
+ ([laneId, ids]) => this.#state.lanes.updateOne(
688
+ // Under the revision the record left: a pull that reaches the server late, after a
689
+ // reconciliation and a newer claim of the same delivery, matches nothing.
690
+ {
691
+ _id: laneId,
692
+ epoch: scope.get(laneId).epoch,
693
+ revision: scope.get(laneId).revision + 1
694
+ },
695
+ { $pull: { inFlight: { $in: ids } } }
696
+ )
697
+ )
698
+ );
699
+ }
700
+ };
701
+
702
+ // src/mongo-completion.ts
703
+ var FINISHED_FIELDS = { dueAt: "", lane: "" };
704
+ var MongoCompletion = class {
705
+ #state;
706
+ constructor(state) {
707
+ this.#state = state;
708
+ }
709
+ /** Implements {@link MongoEventStore.extendLease}. */
710
+ async extendLease(lease, leaseExpiresAt) {
855
711
  const held = parseLeaseToken(lease.leaseToken);
856
712
  if (held === void 0) {
857
713
  return false;
858
714
  }
859
715
  const deliveryId = String(lease.deliveryId);
860
- return this.transaction(async (session) => {
861
- const running = await this.#deliveries.findOne(ofClaim({ ...held, deliveryId }), {
716
+ return runInTransaction(this.#state, async (session) => {
717
+ const running = await this.#state.deliveries.findOne(ofClaim({ ...held, deliveryId }), {
862
718
  session,
863
719
  projection: { _id: 1 }
864
720
  });
865
721
  if (running === null) {
866
722
  return false;
867
723
  }
868
- const renewed = await this.#lanes.updateOne(
724
+ const renewed = await this.#state.lanes.updateOne(
869
725
  { _id: held.laneId, epoch: held.epoch, inFlight: deliveryId },
870
726
  { $max: { leaseExpiresAt } },
871
727
  { session }
@@ -873,43 +729,37 @@ var MongoEventStore = class {
873
729
  return renewed.matchedCount === 1;
874
730
  });
875
731
  }
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
- */
881
- async complete(lease, now, session) {
732
+ /** Implements {@link MongoEventStore.complete}. */
733
+ async complete(lease, { now, session }) {
882
734
  if (session !== void 0) {
883
735
  assertInTransaction(session, "complete");
884
736
  }
885
- return (await this.#completeLeases([lease], now, session))[0];
737
+ return (await this.#completeLeases([lease], { now, session }))[0];
886
738
  }
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
- */
739
+ /** Implements {@link MongoEventStore.completeMany}. */
894
740
  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. */
898
- async reschedule(lease, availableAt, error, 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
+ }) {
899
749
  return this.#leaveFlight(lease, {
900
750
  $set: { availableAt, dueAt: availableAt, lastError: toErrorInfo(error), updatedAt: now },
901
751
  $inc: { attempts: 1, claims: 1 }
902
752
  });
903
753
  }
904
- /** Gives a running delivery back without counting the attempt: pending, due `now`. */
754
+ /** Implements {@link MongoEventStore.release}. */
905
755
  async release(lease, now) {
906
756
  return this.#leaveFlight(lease, {
907
757
  $set: { availableAt: now, dueAt: now, updatedAt: now },
908
758
  $inc: { claims: 1 }
909
759
  });
910
760
  }
911
- /** Moves a running delivery to dead and counts the attempt that ended. */
912
- async bury(lease, error, now) {
761
+ /** Implements {@link MongoEventStore.bury}. */
762
+ async bury(lease, { error, now }) {
913
763
  return this.#leaveFlight(lease, {
914
764
  $set: { status: "dead", lastError: toErrorInfo(error), updatedAt: now },
915
765
  $unset: FINISHED_FIELDS,
@@ -917,194 +767,208 @@ var MongoEventStore = class {
917
767
  });
918
768
  }
919
769
  /**
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.
770
+ * Runs `operation`, which ends the claims of `leases` (completion, retry,
771
+ * release, burial), and once it returned forgets exactly those leases as
772
+ * handed out. When it throws, they stay registered: the caller may retry
773
+ * with the same lease, which must still find its claim.
922
774
  */
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
- );
775
+ async #settle(leases, operation) {
776
+ const result = await operation();
777
+ for (const lease of leases) {
778
+ this.#state.handedOut.delete(leaseKey(String(lease.deliveryId), String(lease.leaseToken)));
936
779
  }
780
+ return result;
937
781
  }
938
782
  /**
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`.
783
+ * Deletes the delivery of every lease whose lane still has the lease's epoch
784
+ * and lists the delivery, in one transaction (the caller's, when given).
785
+ * Returns one flag per lease, in order; a lease listed twice completes once.
943
786
  */
944
- async listDeliveries(query) {
945
- const status = query.status === void 0 ? void 0 : String(query.status);
946
- const common = {
947
- ...query.handler === void 0 ? {} : { handler: String(query.handler) },
948
- ...query.eventType === void 0 ? {} : { eventType: String(query.eventType) },
949
- ...query.eventId === void 0 ? {} : { eventId: String(query.eventId) }
950
- };
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);
787
+ async #completeLeases(leases, { now, session }) {
788
+ return session === void 0 ? this.#settle(leases, () => this.#completeIssued(leases, { now, session: void 0 })) : this.#completeIssued(leases, { now, session });
983
789
  }
984
- /** Moves dead deliveries back to pending with a fresh attempt budget. */
985
- async requeue(deliveryIds, now) {
986
- const result = await this.#deliveries.updateMany(
987
- { _id: { $in: deliveryIds.map(String) }, status: "dead" },
988
- {
989
- $set: {
990
- status: "pending",
991
- lane: "ready",
992
- attempts: 0,
993
- availableAt: now,
994
- dueAt: now,
995
- updatedAt: now
996
- }
790
+ async #completeIssued(leases, { now, session }) {
791
+ const parsed = leases.map((lease) => parseLeaseToken(lease.leaseToken));
792
+ const issued = leases.flatMap(
793
+ (lease, index) => parsed[index] === void 0 ? [] : [{ deliveryId: String(lease.deliveryId), ...parsed[index] }]
794
+ );
795
+ let completed = /* @__PURE__ */ new Set();
796
+ if (issued.length > 0) {
797
+ if (session !== void 0) {
798
+ completed = await this.#deleteCompleted(issued, { now, session });
799
+ } else if (this.#state.completionHistory) {
800
+ completed = await runInTransaction(
801
+ this.#state,
802
+ (own) => this.#deleteCompleted(issued, { now, session: own })
803
+ );
804
+ } else {
805
+ completed = await this.#deleteFenced(issued, now);
997
806
  }
807
+ }
808
+ return leases.map(
809
+ (lease, index) => parsed[index] !== void 0 && completed.delete(leaseIdentity({ deliveryId: String(lease.deliveryId), ...parsed[index] }))
998
810
  );
999
- return result.modifiedCount;
1000
811
  }
1001
812
  /**
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.
813
+ * Completion without a transaction, used when no history record has to be
814
+ * written with the deletion. The deliveries' claim generations are read
815
+ * first, then the lanes, and a delivery is deleted only if it still has the
816
+ * lease's generation. A takeover moves the generation of every delivery it
817
+ * takes from the previous owner on before it reads anything, and so do a
818
+ * retry, a release and a burial, so:
819
+ * - a takeover before the delivery read shows up in the lane read (new epoch);
820
+ * - a takeover between the two reads shows up in the lane read as well;
821
+ * - a takeover, retry, release or burial after the lane read changed the
822
+ * generation, so the delete matches nothing.
823
+ * A completion and a takeover (or the end of the claim) therefore never both succeed. Two concurrent
824
+ * completions of the same lease may both report true (the dispatcher never
825
+ * issues them); one call with a lease listed twice reports it once.
1011
826
  */
1012
- async purge(request) {
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
- }
1033
- }
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
- );
1042
- }
1043
- for (const step of steps) {
1044
- const result = await step();
1045
- total.deletedDeliveries += result.deletedDeliveries;
1046
- total.deletedEvents += result.deletedEvents;
1047
- }
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);
827
+ async #deleteFenced(issued, now) {
828
+ const ids = [...new Set(issued.map((lease) => lease.deliveryId))];
829
+ const documents = await this.#state.deliveries.find({ _id: { $in: ids }, status: "pending" }, { projection: { claims: 1, eventId: 1 } }).toArray();
830
+ const lanes = new Map(
831
+ (await this.#state.lanes.find(
832
+ { _id: { $in: [...new Set(issued.map((lease) => lease.laneId))] } },
833
+ { projection: { epoch: 1, inFlight: 1 } }
834
+ ).toArray()).map((lane) => [lane._id, lane])
835
+ );
836
+ const fenced = fencedLeases(issued, { lanes, documents });
837
+ if (fenced.length === 0) {
838
+ return /* @__PURE__ */ new Set();
1051
839
  }
1052
- return total;
840
+ await this.#markEventsCompleted(fenced, { documents, now });
841
+ const deleted = await this.#state.deliveries.deleteMany({ $or: fenced.map(ofClaim) });
842
+ return deleted.deletedCount === fenced.length ? new Set(fenced.map((lease) => leaseIdentity(lease))) : /* @__PURE__ */ new Set();
1053
843
  }
1054
844
  /**
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.
845
+ * The transactional part of a completion. The lanes are only read: the
846
+ * conflict with a takeover comes from the deleted delivery documents, which a
847
+ * takeover writes before it reads them (see `MongoLaneLeases.takeLanes`).
848
+ * So whichever of the two commits second either fails with a write conflict
849
+ * and is retried against the new state, or waits for the first.
1060
850
  */
1061
- async statistics(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] } },
1073
- { sort: { dueAt: 1 }, projection: { dueAt: 1 } }
851
+ async #deleteCompleted(issued, { now, session }) {
852
+ const lanes = new Map(
853
+ (await this.#state.lanes.find(
854
+ { _id: { $in: [...new Set(issued.map((lease) => lease.laneId))] } },
855
+ { session, projection: { epoch: 1, inFlight: 1 } }
856
+ ).toArray()).map((lane) => [lane._id, lane])
1074
857
  );
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());
858
+ const documents = await this.#state.deliveries.find(
859
+ { _id: { $in: [...new Set(issued.map((lease) => lease.deliveryId))] }, status: "pending" },
860
+ { session }
861
+ ).toArray();
862
+ const fenced = fencedLeases(issued, { lanes, documents });
863
+ if (fenced.length === 0) {
864
+ return /* @__PURE__ */ new Set();
1080
865
  }
1081
- return {
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,
1086
- dead,
1087
- oldestDueAt: dueTimes.length === 0 ? null : new Date(Math.min(...dueTimes))
1088
- };
866
+ await this.#markEventsCompleted(fenced, { documents, now, session });
867
+ await this.#state.deliveries.deleteMany({ $or: fenced.map(ofClaim) }, { session });
868
+ if (this.#state.completionHistory) {
869
+ const completedIds = new Set(fenced.map((lease) => lease.deliveryId));
870
+ await this.#state.completions.insertMany(
871
+ documents.filter((document) => completedIds.has(document._id)).map((document) => toCompletedDeliveryDocument(document, now)),
872
+ { session }
873
+ );
874
+ }
875
+ return new Set(fenced.map((lease) => leaseIdentity(lease)));
1089
876
  }
1090
- /** Atomically increments the counter of `key`; conflicting appends of the key serialize here. */
1091
- async #nextSequence(key, session) {
1092
- const counter = await this.#keySequences.findOneAndUpdate(
1093
- { _id: key },
1094
- { $inc: { value: 1 } },
1095
- { upsert: true, returnDocument: "after", session }
877
+ /**
878
+ * Records `now` as the latest completion of the events of the fenced
879
+ * deliveries (`$max`, so an earlier clock never moves it back). Written
880
+ * before the deletion, it can only be later than the real last completion
881
+ * (when the deletion then fails), which keeps the event longer, never shorter.
882
+ * Inside a transaction it is part of it: the transaction may hold the event
883
+ * already (a transactional handler that appended to its own event), and a
884
+ * write from outside would wait for it forever. Deliveries of one event that
885
+ * complete in concurrent transactions (fan-out to transactional handlers)
886
+ * therefore conflict on the event document, and one of them retries.
887
+ */
888
+ async #markEventsCompleted(fenced, {
889
+ documents,
890
+ now,
891
+ session
892
+ }) {
893
+ const completedIds = new Set(fenced.map((lease) => lease.deliveryId));
894
+ const eventIds = [
895
+ ...new Set(
896
+ documents.filter((document) => completedIds.has(document._id)).map((document) => document.eventId)
897
+ )
898
+ ];
899
+ await this.#state.events.updateMany(
900
+ { _id: { $in: eventIds } },
901
+ { $max: { completedAt: now } },
902
+ session === void 0 ? {} : { session }
1096
903
  );
1097
- return counter.value;
1098
904
  }
1099
- /** The listed deliveries that run: those whose document still exists as pending. */
1100
- async #findRunning(listed) {
1101
- if (listed.length === 0) {
1102
- return [];
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;
1103
914
  }
1104
- return this.#deliveries.find(
1105
- { _id: { $in: [...listed] }, status: "pending" },
1106
- { projection: { handler: 1, key: 1, laneId: 1 } }
1107
- ).toArray();
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
+ }
922
+ async #leaveFlightNow(held, {
923
+ deliveryId,
924
+ ofThisClaim,
925
+ update
926
+ }) {
927
+ return runInTransaction(this.#state, async (session) => {
928
+ if (await this.#state.deliveries.findOne(ofThisClaim, { session, projection: { _id: 1 } }) === null) {
929
+ return false;
930
+ }
931
+ const fence = await this.#state.lanes.updateOne(
932
+ { _id: held.laneId, epoch: held.epoch, inFlight: deliveryId },
933
+ { $pull: { inFlight: deliveryId } },
934
+ { session }
935
+ );
936
+ if (fence.matchedCount === 0) {
937
+ return false;
938
+ }
939
+ const changed = await this.#state.deliveries.updateOne(ofThisClaim, update, { session });
940
+ return changed.matchedCount === 1;
941
+ });
942
+ }
943
+ };
944
+ function fencedLeases(issued, {
945
+ lanes,
946
+ documents
947
+ }) {
948
+ const claimsById = new Map(documents.map((document) => [document._id, document.claims ?? 0]));
949
+ const fenced = /* @__PURE__ */ new Map();
950
+ for (const lease of issued) {
951
+ const lane = lanes.get(lease.laneId);
952
+ if (claimsById.get(lease.deliveryId) === lease.claims && lane.epoch === lease.epoch && lane.inFlight.includes(lease.deliveryId)) {
953
+ fenced.set(leaseIdentity(lease), lease);
954
+ }
955
+ }
956
+ return [...fenced.values()];
957
+ }
958
+ function ofClaim(lease) {
959
+ return { _id: lease.deliveryId, status: "pending", claims: atCount(lease.claims) };
960
+ }
961
+ function leaseIdentity(lease) {
962
+ return `${lease.deliveryId}\0${formatLeaseToken(lease.laneId, { epoch: lease.epoch, claims: lease.claims })}`;
963
+ }
964
+
965
+ // src/mongo-lane-leases.ts
966
+ var MongoLaneLeases = class {
967
+ #state;
968
+ #queries;
969
+ constructor(state, collaborators) {
970
+ this.#state = state;
971
+ this.#queries = collaborators.queries;
1108
972
  }
1109
973
  /**
1110
974
  * Up to `laneCount` lanes nobody holds under a live lease that have a due,
@@ -1112,10 +976,10 @@ var MongoEventStore = class {
1112
976
  * delivery first, leaving out `passed`. One round trip: the oldest delivery
1113
977
  * of each lane is one index probe on the server. A lane whose due deliveries
1114
978
  * all wait behind an unfinished predecessor is taken at most once: the claim
1115
- * that hands it back parks them (see {@link ClaimScan.waitingBacklogs}).
979
+ * that hands it back parks them (see `ClaimScan.waitingBacklogs`).
1116
980
  */
1117
- async #findFreeLanes(request, laneCount, passed) {
1118
- const lanes = await this.#lanes.aggregate([
981
+ async findFreeLanes(request, { laneCount, passed }) {
982
+ const lanes = await this.#state.lanes.aggregate([
1119
983
  {
1120
984
  $match: {
1121
985
  _id: { $nin: passed },
@@ -1124,7 +988,7 @@ var MongoEventStore = class {
1124
988
  },
1125
989
  {
1126
990
  $lookup: {
1127
- from: this.#deliveries.collectionName,
991
+ from: this.#state.deliveries.collectionName,
1128
992
  localField: "_id",
1129
993
  foreignField: "laneId",
1130
994
  pipeline: [
@@ -1153,12 +1017,12 @@ var MongoEventStore = class {
1153
1017
  * Takes the lanes with one conditional write each, run in parallel; a lane
1154
1018
  * taken by someone else meanwhile is skipped. The same write moves the
1155
1019
  * previous owner's in-flight list to `lost`, which is charged before any
1156
- * delivery of the lanes is read (see {@link MongoEventStore.#chargeLost}).
1020
+ * delivery of the lanes is read (see {@link MongoLaneLeases.chargeLost}).
1157
1021
  */
1158
- async #takeLanes(laneIds, request) {
1022
+ async takeLanes(laneIds, request) {
1159
1023
  const after = await Promise.all(
1160
1024
  laneIds.map(
1161
- (laneId) => this.#lanes.findOneAndUpdate(
1025
+ (laneId) => this.#state.lanes.findOneAndUpdate(
1162
1026
  {
1163
1027
  _id: laneId,
1164
1028
  $or: [{ leaseExpiresAt: null }, { leaseExpiresAt: { $lte: request.now } }]
@@ -1168,7 +1032,7 @@ var MongoEventStore = class {
1168
1032
  $set: {
1169
1033
  epoch: { $add: ["$epoch", 1] },
1170
1034
  owner: { $literal: request.leaseOwner },
1171
- holder: { $literal: this.#instanceId },
1035
+ holder: { $literal: this.#state.instanceId },
1172
1036
  leaseExpiresAt: { $literal: request.leaseExpiresAt },
1173
1037
  // Each lost delivery keeps the epoch it was claimed under: the key of its charge.
1174
1038
  lost: {
@@ -1186,7 +1050,7 @@ var MongoEventStore = class {
1186
1050
  )
1187
1051
  );
1188
1052
  const taken = after.filter((lane) => lane !== null);
1189
- await this.#chargeLost(taken);
1053
+ await this.chargeLost(taken);
1190
1054
  return taken.map((lane) => ({
1191
1055
  laneId: lane._id,
1192
1056
  epoch: lane.epoch,
@@ -1206,13 +1070,13 @@ var MongoEventStore = class {
1206
1070
  * list again after a failure before the clear skips deliveries already
1207
1071
  * charged for that claim.
1208
1072
  */
1209
- async #chargeLost(lanes) {
1073
+ async chargeLost(lanes) {
1210
1074
  const losing = lanes.filter((lane) => lane.lost.length > 0);
1211
1075
  if (losing.length === 0) {
1212
1076
  return;
1213
1077
  }
1214
1078
  const lost = losing.flatMap((lane) => lane.lost).sort((left, right) => left.epoch - right.epoch);
1215
- await this.#deliveries.bulkWrite(
1079
+ await this.#state.deliveries.bulkWrite(
1216
1080
  lost.map(({ id, epoch }) => ({
1217
1081
  updateOne: {
1218
1082
  filter: { _id: id, status: "pending", lostAt: { $not: { $gte: epoch } } },
@@ -1221,13 +1085,159 @@ var MongoEventStore = class {
1221
1085
  })),
1222
1086
  { ordered: true }
1223
1087
  );
1224
- await this.#lanes.bulkWrite(
1088
+ await this.#state.lanes.bulkWrite(
1225
1089
  losing.map((lane) => ({
1226
1090
  updateOne: { filter: { _id: lane._id, epoch: lane.epoch }, update: { $set: { lost: [] } } }
1227
1091
  })),
1228
1092
  { ordered: false }
1229
1093
  );
1230
1094
  }
1095
+ /**
1096
+ * Records the picks in their lanes, one conditional write per lane and one
1097
+ * round trip for all of them, and frees the lanes that yielded nothing and run
1098
+ * nothing. A picked lane lists its running deliveries plus the new ones (ids
1099
+ * whose documents are gone are pruned) and its lease reaches
1100
+ * `request.leaseExpiresAt`. Returns the lanes whose picks were recorded: every
1101
+ * write is conditional on the claim's epoch, owner and store instance, so a
1102
+ * lane that changed hands, or was handed back by `relinquish`, in the meantime
1103
+ * keeps its deliveries for whoever takes it next.
1104
+ */
1105
+ async recordClaim(scope, {
1106
+ picked,
1107
+ runningIds,
1108
+ request
1109
+ }) {
1110
+ const picksByLane = /* @__PURE__ */ new Map();
1111
+ const handedBack = /* @__PURE__ */ new Set();
1112
+ for (const document of picked) {
1113
+ picksByLane.set(document.laneId, [...picksByLane.get(document.laneId) ?? [], document._id]);
1114
+ }
1115
+ const operations = [];
1116
+ for (const lane of scope.values()) {
1117
+ const gone = lane.listed.filter((id) => !runningIds.has(id));
1118
+ const ids = picksByLane.get(lane.laneId);
1119
+ if (ids !== void 0) {
1120
+ operations.push(
1121
+ recordInFlight({ lane, ids, gone, request, instanceId: this.#state.instanceId })
1122
+ );
1123
+ } else if (gone.length === lane.listed.length) {
1124
+ operations.push(
1125
+ freeLane(lane.laneId, { epoch: lane.epoch, revision: lane.revision, finished: gone })
1126
+ );
1127
+ handedBack.add(lane.laneId);
1128
+ }
1129
+ }
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
+ };
1141
+ }
1142
+ /** The lanes of `laneIds` whose record of this claim applied, read back after a partial bulk write. */
1143
+ async #readRecorded(scope, { laneIds, request }) {
1144
+ const current = await this.#state.lanes.find(
1145
+ { _id: { $in: [...laneIds] } },
1146
+ { projection: { epoch: 1, owner: 1, holder: 1, revision: 1 } }
1147
+ ).toArray();
1148
+ return new Set(
1149
+ current.filter(
1150
+ (lane) => lane.epoch === scope.get(lane._id).epoch && lane.revision === scope.get(lane._id).revision + 1 && lane.owner === request.leaseOwner && lane.holder === this.#state.instanceId
1151
+ ).map((lane) => lane._id)
1152
+ );
1153
+ }
1154
+ /** Implements {@link MongoEventStore.relinquish}. */
1155
+ async relinquish(leaseOwner) {
1156
+ const held = await this.#state.lanes.find({ owner: String(leaseOwner), holder: this.#state.instanceId }).toArray();
1157
+ const runningIds = new Set(
1158
+ (await this.#queries.findRunning(held.flatMap((lane) => lane.inFlight))).map(
1159
+ (document) => document._id
1160
+ )
1161
+ );
1162
+ const idle = held.filter((lane) => lane.inFlight.every((id) => !runningIds.has(id)));
1163
+ if (idle.length > 0) {
1164
+ await this.#state.lanes.bulkWrite(
1165
+ idle.map(
1166
+ (lane) => freeLane(lane._id, {
1167
+ epoch: lane.epoch,
1168
+ revision: lane.revision ?? 0,
1169
+ finished: lane.inFlight
1170
+ })
1171
+ ),
1172
+ { ordered: false }
1173
+ );
1174
+ }
1175
+ }
1176
+ };
1177
+ function recordInFlight(input) {
1178
+ const { lane, ids, gone, request, instanceId } = input;
1179
+ return {
1180
+ updateOne: {
1181
+ filter: {
1182
+ _id: lane.laneId,
1183
+ epoch: lane.epoch,
1184
+ owner: request.leaseOwner,
1185
+ holder: instanceId,
1186
+ // A record that reaches the server late (after its claim failed and reconciled)
1187
+ // finds a newer revision and changes nothing.
1188
+ revision: atCount(lane.revision)
1189
+ },
1190
+ update: [
1191
+ {
1192
+ $set: {
1193
+ inFlight: {
1194
+ $concatArrays: [
1195
+ {
1196
+ $filter: {
1197
+ input: "$inFlight",
1198
+ cond: { $not: [{ $in: ["$$this", { $literal: gone }] }] }
1199
+ }
1200
+ },
1201
+ { $literal: ids }
1202
+ ]
1203
+ },
1204
+ leaseExpiresAt: { $max: ["$leaseExpiresAt", request.leaseExpiresAt] },
1205
+ revision: { $add: [{ $ifNull: ["$revision", 0] }, 1] }
1206
+ }
1207
+ }
1208
+ ]
1209
+ }
1210
+ };
1211
+ }
1212
+ function freeLane(laneId, {
1213
+ epoch,
1214
+ revision,
1215
+ finished
1216
+ }) {
1217
+ return {
1218
+ updateOne: {
1219
+ filter: {
1220
+ _id: laneId,
1221
+ epoch,
1222
+ revision: atCount(revision),
1223
+ inFlight: { $not: { $elemMatch: { $nin: [...finished] } } }
1224
+ },
1225
+ update: {
1226
+ $set: { owner: null, holder: null, leaseExpiresAt: null, inFlight: [] },
1227
+ $inc: { revision: 1 }
1228
+ }
1229
+ }
1230
+ };
1231
+ }
1232
+
1233
+ // src/mongo-ordering.ts
1234
+ var MINIMUM_CANDIDATE_BATCH_SIZE = 32;
1235
+ var PARK_UPDATE_CHUNK_SIZE = 1e3;
1236
+ var MongoOrdering = class {
1237
+ #state;
1238
+ constructor(state) {
1239
+ this.#state = state;
1240
+ }
1231
1241
  /**
1232
1242
  * Adds the claimable candidates of `lanes` to `picked`, in due order, until
1233
1243
  * `request.limit` is reached. A delivery is a candidate when it is ready, due
@@ -1242,7 +1252,8 @@ var MongoEventStore = class {
1242
1252
  * the predecessors of the keys of a page are looked up in parallel, one
1243
1253
  * lookup per key.
1244
1254
  */
1245
- async #pickCandidates(lanes, request, scan, running, picked) {
1255
+ async pickCandidates(input) {
1256
+ const { lanes, request, scan, running, picked } = input;
1246
1257
  const pageSize = Math.max((request.limit - picked.length) * 2, MINIMUM_CANDIDATE_BATCH_SIZE);
1247
1258
  const filter = {
1248
1259
  laneId: { $in: lanes.map((lane) => lane.laneId) },
@@ -1254,7 +1265,7 @@ var MongoEventStore = class {
1254
1265
  let after;
1255
1266
  for (; ; ) {
1256
1267
  const [page, { keys }] = await Promise.all([
1257
- this.#deliveries.find(
1268
+ this.#state.deliveries.find(
1258
1269
  after === void 0 ? filter : {
1259
1270
  $and: [
1260
1271
  filter,
@@ -1270,18 +1281,8 @@ var MongoEventStore = class {
1270
1281
  ).toArray(),
1271
1282
  running
1272
1283
  ]);
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
- }
1284
+ if (await this.#pickFromPage(page, { request, scan, picked, keys })) {
1285
+ return;
1285
1286
  }
1286
1287
  if (page.length < pageSize) {
1287
1288
  return;
@@ -1289,6 +1290,27 @@ var MongoEventStore = class {
1289
1290
  after = page.at(-1);
1290
1291
  }
1291
1292
  }
1293
+ /**
1294
+ * Adds the claimable candidates of `page` to `picked`. Looks up predecessors only for as many
1295
+ * candidates as the claim still needs, then more. True once the claim has `request.limit` picks.
1296
+ */
1297
+ async #pickFromPage(page, claim) {
1298
+ const { request, scan, picked, keys } = claim;
1299
+ for (let offset = 0; offset < page.length; ) {
1300
+ const slice = page.slice(offset, offset + request.limit - picked.length);
1301
+ offset += slice.length;
1302
+ await this.#notePredecessors(slice, scan);
1303
+ for (const candidate of slice) {
1304
+ if (isClaimable(candidate, { scan, blockedKeys: keys })) {
1305
+ picked.push(candidate);
1306
+ }
1307
+ }
1308
+ if (picked.length >= request.limit) {
1309
+ return true;
1310
+ }
1311
+ }
1312
+ return false;
1313
+ }
1292
1314
  /**
1293
1315
  * Looks up, in parallel and once per key, whether an unfinished delivery
1294
1316
  * precedes the earliest candidate of each keyed (handler, key) of `page` that
@@ -1308,181 +1330,45 @@ var MongoEventStore = class {
1308
1330
  }
1309
1331
  await Promise.all(
1310
1332
  [...earliest].map(async ([orderingId, candidate]) => {
1311
- const predecessor = await this.#findUnfinishedPredecessor(
1312
- candidate.handler,
1313
- candidate.key,
1314
- candidate.sequence
1315
- );
1333
+ const predecessor = await this.#findUnfinishedPredecessor(candidate.handler, {
1334
+ key: candidate.key,
1335
+ sequence: candidate.sequence
1336
+ });
1316
1337
  scan.noteOpen(orderingId, predecessor ?? candidate.sequence);
1317
1338
  })
1318
1339
  );
1319
1340
  }
1320
1341
  /**
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.
1342
+ * Parks the waiting backlogs a claim found. Best effort: parking only saves
1343
+ * later claims work, so a failure is logged and the claim still returns its
1344
+ * batch; the next claim that meets the backlog parks it.
1329
1345
  */
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]);
1335
- }
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
- }
1377
- }
1378
- if (operations.length === 0) {
1379
- return { recorded: /* @__PURE__ */ new Set(), handedBack };
1380
- }
1381
- const result = await this.#lanes.bulkWrite(operations, { ordered: false });
1382
- if (result.matchedCount === operations.length) {
1383
- return { recorded: new Set(picksByLane.keys()), handedBack };
1346
+ async parkWaiting(deliveryIds) {
1347
+ try {
1348
+ await this.#parkDeliveries(deliveryIds);
1349
+ } catch (error) {
1350
+ this.#state.logger.warn(
1351
+ `[MongoOrdering](parkWaiting) Parking waiting deliveries failed, a later claim parks them | count=${deliveryIds.length} | error=${String(error)}`
1352
+ );
1384
1353
  }
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)
1354
+ }
1355
+ /** Sequence of the lowest unfinished delivery of the handler and key before `sequence`, if any. */
1356
+ async #findUnfinishedPredecessor(handler, { key, sequence }) {
1357
+ const predecessor = await this.#state.deliveries.findOne(
1358
+ { handler, key, sequence: { $lt: sequence }, dueAt: { $exists: true } },
1359
+ { sort: { sequence: 1 }, projection: { sequence: 1 } }
1393
1360
  );
1394
- return { recorded, handedBack };
1361
+ return predecessor?.sequence;
1395
1362
  }
1396
1363
  /**
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.
1402
- */
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(
1409
- {
1410
- $or: keyed.map((document) => ({
1411
- handler: document.handler,
1412
- key: document.key,
1413
- sequence: { $lt: document.sequence },
1414
- dueAt: { $exists: true }
1415
- }))
1416
- },
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)
1424
- );
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));
1433
- }
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
- )
1453
- );
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
- }
1469
- /** Sequence of the lowest unfinished delivery of the handler and key before `sequence`, if any. */
1470
- async #findUnfinishedPredecessor(handler, key, sequence) {
1471
- const predecessor = await this.#deliveries.findOne(
1472
- { handler, key, sequence: { $lt: sequence }, dueAt: { $exists: true } },
1473
- { sort: { sequence: 1 }, projection: { sequence: 1 } }
1474
- );
1475
- return predecessor?.sequence;
1476
- }
1477
- /**
1478
- * Parks pending deliveries that waited behind an unfinished predecessor, which
1479
- * removes them from the claim index until their turn. Parking is a hint, not a
1480
- * guarantee: a predecessor that finishes meanwhile is found by the next
1481
- * {@link MongoEventStore.claim}, which moves the lowest parked delivery back.
1364
+ * Parks pending deliveries that waited behind an unfinished predecessor, which
1365
+ * removes them from the claim index until their turn. Parking is a hint, not a
1366
+ * guarantee: a predecessor that finishes meanwhile is found by the next
1367
+ * {@link MongoEventStore.claim}, which moves the lowest parked delivery back.
1482
1368
  */
1483
1369
  async #parkDeliveries(deliveryIds) {
1484
1370
  for (let offset = 0; offset < deliveryIds.length; offset += PARK_UPDATE_CHUNK_SIZE) {
1485
- await this.#deliveries.updateMany(
1371
+ await this.#state.deliveries.updateMany(
1486
1372
  {
1487
1373
  _id: { $in: deliveryIds.slice(offset, offset + PARK_UPDATE_CHUNK_SIZE) },
1488
1374
  status: "pending",
@@ -1499,8 +1385,8 @@ var MongoEventStore = class {
1499
1385
  * predecessor finishing while its followers were being parked, can delay a key
1500
1386
  * until the next claim but never strand it.
1501
1387
  */
1502
- async #unparkNextDeliveries(handlers) {
1503
- const anyParked = await this.#deliveries.findOne(
1388
+ async unparkNextDeliveries(handlers) {
1389
+ const anyParked = await this.#state.deliveries.findOne(
1504
1390
  { handler: { $in: [...handlers] }, lane: "parked" },
1505
1391
  // The sort lets the planner pick the small partial index over the handler's whole history.
1506
1392
  { sort: { handler: 1, key: 1, sequence: 1 }, projection: { _id: 1 } }
@@ -1510,7 +1396,7 @@ var MongoEventStore = class {
1510
1396
  }
1511
1397
  const lowest = (await Promise.all(
1512
1398
  [...new Set(handlers)].map(
1513
- (handler) => this.#deliveries.aggregate([
1399
+ (handler) => this.#state.deliveries.aggregate([
1514
1400
  { $match: { handler, lane: "parked" } },
1515
1401
  { $sort: { handler: 1, key: 1, sequence: 1 } },
1516
1402
  {
@@ -1521,167 +1407,83 @@ var MongoEventStore = class {
1521
1407
  )).flat();
1522
1408
  const next = await Promise.all(
1523
1409
  lowest.map(
1524
- async (parked) => await this.#findUnfinishedPredecessor(parked.handler, parked._id, parked.sequence) === void 0 ? [parked.id] : []
1410
+ async (parked) => await this.#findUnfinishedPredecessor(parked.handler, {
1411
+ key: parked._id,
1412
+ sequence: parked.sequence
1413
+ }) === void 0 ? [parked.id] : []
1525
1414
  )
1526
1415
  );
1527
1416
  const ready = next.flat();
1528
1417
  if (ready.length > 0) {
1529
- await this.#deliveries.updateMany(
1418
+ await this.#state.deliveries.updateMany(
1530
1419
  { _id: { $in: ready }, lane: "parked" },
1531
1420
  { $set: { lane: "ready" } }
1532
1421
  );
1533
1422
  }
1534
1423
  }
1535
- async #readEnvelopes(eventIds) {
1536
- const documents = await this.#events.find({ _id: { $in: [...new Set(eventIds)] } }).toArray();
1537
- return new Map(documents.map((document) => [document._id, toEnvelope(document)]));
1424
+ };
1425
+ function isClaimable(candidate, { scan, blockedKeys }) {
1426
+ const { key, sequence } = candidate;
1427
+ if (key === void 0) {
1428
+ return true;
1538
1429
  }
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);
1430
+ const orderingId = orderingIdentity(candidate.handler, key);
1431
+ if (scan.waits(orderingId, sequence)) {
1432
+ scan.noteWaiting(orderingId, { deliveryId: candidate._id, laneId: candidate.laneId });
1433
+ return false;
1546
1434
  }
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
- );
1435
+ if (blockedKeys.has(orderingId)) {
1436
+ return false;
1565
1437
  }
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();
1438
+ blockedKeys.add(orderingId);
1439
+ return true;
1440
+ }
1441
+
1442
+ // src/mongo-purge.ts
1443
+ var MongoPurge = class {
1444
+ #state;
1445
+ constructor(state) {
1446
+ this.#state = state;
1597
1447
  }
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();
1448
+ /** Implements {@link MongoEventStore.purge}. */
1449
+ async purge(request) {
1450
+ const total = { deletedDeliveries: 0, deletedEvents: 0 };
1451
+ const steps = [];
1452
+ const { completedBefore, deadBefore } = request;
1453
+ if (completedBefore !== void 0) {
1454
+ steps.push(
1455
+ () => this.#purgeMatching(this.#purgeable(this.#state.deliveries.collectionName), {
1456
+ filter: { status: "completed", completedAt: { $lt: completedBefore } },
1457
+ completedBefore
1458
+ })
1459
+ );
1460
+ if (this.#state.completionHistory) {
1461
+ steps.push(
1462
+ () => this.#purgeMatching(this.#purgeable(this.#state.completions.collectionName), {
1463
+ filter: { completedAt: { $lt: completedBefore } },
1464
+ completedBefore
1465
+ })
1466
+ );
1467
+ }
1619
1468
  }
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 }
1469
+ if (deadBefore !== void 0) {
1470
+ steps.push(
1471
+ () => this.#purgeMatching(this.#purgeable(this.#state.deliveries.collectionName), {
1472
+ filter: { status: "dead", updatedAt: { $lt: deadBefore } },
1473
+ completedBefore
1474
+ })
1627
1475
  );
1628
1476
  }
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 } },
1652
- session === void 0 ? {} : { session }
1653
- );
1654
- }
1655
- /**
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.
1659
- */
1660
- async #leaveFlight(lease, update) {
1661
- const held = parseLeaseToken(lease.leaseToken);
1662
- if (held === void 0) {
1663
- return false;
1477
+ for (const step of steps) {
1478
+ const result = await step();
1479
+ total.deletedDeliveries += result.deletedDeliveries;
1480
+ total.deletedEvents += result.deletedEvents;
1664
1481
  }
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
- });
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;
1685
1487
  }
1686
1488
  /**
1687
1489
  * Deletes batches of matching documents (deliveries, or completion history
@@ -1692,12 +1494,15 @@ var MongoEventStore = class {
1692
1494
  * without event. A delivery requeued since the batch was read conflicts with
1693
1495
  * its deletion and is read again.
1694
1496
  */
1695
- async #purgeMatching(collection, filter, completedBefore) {
1497
+ async #purgeMatching(collection, {
1498
+ filter,
1499
+ completedBefore
1500
+ }) {
1696
1501
  let deletedDeliveries = 0;
1697
1502
  let deletedEvents = 0;
1698
1503
  for (; ; ) {
1699
- const step = await this.transaction(async (session) => {
1700
- const batch = await collection.find(filter, { session, projection: { eventId: 1 }, limit: this.#purgeBatchSize }).toArray();
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();
1701
1506
  if (batch.length === 0) {
1702
1507
  return { batchSize: 0, deletedDeliveries: 0, deletedEvents: 0 };
1703
1508
  }
@@ -1707,7 +1512,7 @@ var MongoEventStore = class {
1707
1512
  { session }
1708
1513
  );
1709
1514
  const stillReferenced = await this.#referencedEventIds(eventIds, session);
1710
- const events = await this.#events.deleteMany(
1515
+ const events = await this.#state.events.deleteMany(
1711
1516
  {
1712
1517
  _id: { $in: eventIds.filter((eventId) => !stillReferenced.has(eventId)) },
1713
1518
  // An event a delivery of which completed stays as deduplication record until
@@ -1727,14 +1532,14 @@ var MongoEventStore = class {
1727
1532
  });
1728
1533
  deletedDeliveries += step.deletedDeliveries;
1729
1534
  deletedEvents += step.deletedEvents;
1730
- if (step.batchSize < this.#purgeBatchSize) {
1535
+ if (step.batchSize < this.#state.purgeBatchSize) {
1731
1536
  return { deletedDeliveries, deletedEvents };
1732
1537
  }
1733
1538
  }
1734
1539
  }
1735
1540
  /** A view of a collection that purge deletes from: deliveries or completion history records. */
1736
1541
  #purgeable(name) {
1737
- return this.#database.collection(name, {
1542
+ return this.#state.database.collection(name, {
1738
1543
  readPreference: "primary",
1739
1544
  writeConcern: { w: "majority" }
1740
1545
  });
@@ -1744,8 +1549,8 @@ var MongoEventStore = class {
1744
1549
  const filter = { eventId: { $in: [...eventIds] } };
1745
1550
  const options = { session, projection: { eventId: 1 } };
1746
1551
  const references = [
1747
- ...await this.#deliveries.find(filter, options).toArray(),
1748
- ...this.#completionHistory ? await this.#completions.find(filter, options).toArray() : []
1552
+ ...await this.#state.deliveries.find(filter, options).toArray(),
1553
+ ...this.#state.completionHistory ? await this.#state.completions.find(filter, options).toArray() : []
1749
1554
  ];
1750
1555
  return new Set(references.map((reference) => reference.eventId));
1751
1556
  }
@@ -1758,13 +1563,13 @@ var MongoEventStore = class {
1758
1563
  */
1759
1564
  async #purgeOrphanEvents(cutoff, completedBefore) {
1760
1565
  const references = [
1761
- this.#deliveries.collectionName,
1762
- ...this.#completionHistory ? [this.#completions.collectionName] : []
1566
+ this.#state.deliveries.collectionName,
1567
+ ...this.#state.completionHistory ? [this.#state.completions.collectionName] : []
1763
1568
  ];
1764
1569
  let deletedEvents = 0;
1765
1570
  for (; ; ) {
1766
- const step = await this.transaction(async (session) => {
1767
- const orphans = await this.#events.aggregate(
1571
+ const step = await runInTransaction(this.#state, async (session) => {
1572
+ const orphans = await this.#state.events.aggregate(
1768
1573
  [
1769
1574
  {
1770
1575
  $match: {
@@ -1789,58 +1594,368 @@ var MongoEventStore = class {
1789
1594
  },
1790
1595
  { $match: { references: { $size: 0 } } }
1791
1596
  ]),
1792
- { $limit: this.#purgeBatchSize },
1597
+ { $limit: this.#state.purgeBatchSize },
1793
1598
  { $project: { _id: 1 } }
1794
1599
  ],
1795
1600
  { session }
1796
1601
  ).toArray();
1797
- const deleted = await this.#events.deleteMany(
1602
+ const deleted = await this.#state.events.deleteMany(
1798
1603
  { _id: { $in: orphans.map((orphan) => orphan._id) } },
1799
1604
  { session }
1800
1605
  );
1801
1606
  return { found: orphans.length, deleted: deleted.deletedCount };
1802
1607
  });
1803
1608
  deletedEvents += step.deleted;
1804
- if (step.found < this.#purgeBatchSize) {
1609
+ if (step.found < this.#state.purgeBatchSize) {
1805
1610
  return deletedEvents;
1806
1611
  }
1807
1612
  }
1808
1613
  }
1809
1614
  };
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;
1615
+
1616
+ // src/mongo-queries.ts
1617
+ var DEFAULT_LIST_LIMIT = 100;
1618
+ var MongoQueries = class {
1619
+ #state;
1620
+ constructor(state) {
1621
+ this.#state = state;
1822
1622
  }
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 = [];
1623
+ /** Implements {@link MongoEventStore.listDeliveries}. */
1624
+ async listDeliveries(query) {
1625
+ const status = query.status === void 0 ? void 0 : String(query.status);
1626
+ const common = commonFilter(query);
1627
+ const limit = query.limit ?? DEFAULT_LIST_LIMIT;
1628
+ const lanes = await this.#state.lanes.find({ "inFlight.0": { $exists: true } }, { projection: { epoch: 0 } }).toArray();
1629
+ 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
+ };
1638
+ const sort = { createdAt: 1, _id: 1 };
1639
+ const documents = await this.#state.deliveries.find(filter, { sort, limit }).toArray();
1640
+ const history = this.#state.completionHistory && (status === void 0 || status === "completed") ? await this.#state.completions.find(common, { sort, limit }).toArray() : [];
1641
+ const events = await this.readEnvelopes([
1642
+ ...documents.map((document) => document.eventId),
1643
+ ...history.map((document) => document.eventId)
1644
+ ]);
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);
1662
+ }
1663
+ /** Implements {@link MongoEventStore.requeue}. */
1664
+ async requeue(deliveryIds, now) {
1665
+ const result = await this.#state.deliveries.updateMany(
1666
+ { _id: { $in: deliveryIds.map(String) }, status: "dead" },
1667
+ {
1668
+ $set: {
1669
+ status: "pending",
1670
+ lane: "ready",
1671
+ attempts: 0,
1672
+ availableAt: now,
1673
+ dueAt: now,
1674
+ updatedAt: now
1675
+ }
1676
+ }
1677
+ );
1678
+ return result.modifiedCount;
1679
+ }
1680
+ /** Implements {@link MongoEventStore.statistics}. */
1681
+ async statistics(now) {
1682
+ const lanes = await this.#state.lanes.find({ "inFlight.0": { $exists: true } }, { projection: { inFlight: 1, leaseExpiresAt: 1 } }).toArray();
1683
+ const [stored, dead, legacyCompleted, history, running] = await Promise.all([
1684
+ this.#state.deliveries.countDocuments({ status: "pending" }),
1685
+ this.#state.deliveries.countDocuments({ status: "dead" }),
1686
+ this.#state.deliveries.countDocuments({ status: "completed" }),
1687
+ this.#state.completionHistory ? this.#state.completions.countDocuments() : Promise.resolve(0),
1688
+ this.findRunning(lanes.flatMap((lane) => lane.inFlight))
1689
+ ]);
1690
+ const runningIds = new Set(running.map((document) => document._id));
1691
+ const waiting = await this.#state.deliveries.findOne(
1692
+ { dueAt: { $lte: now }, _id: { $nin: [...runningIds] } },
1693
+ { sort: { dueAt: 1 }, projection: { dueAt: 1 } }
1694
+ );
1695
+ const dueTimes = lanes.filter(
1696
+ (lane) => lane.leaseExpiresAt !== null && lane.leaseExpiresAt <= now && lane.inFlight.some((id) => runningIds.has(id))
1697
+ ).map((lane) => lane.leaseExpiresAt.getTime());
1698
+ if (waiting !== null) {
1699
+ dueTimes.push(waiting.dueAt.getTime());
1700
+ }
1701
+ return {
1702
+ // The counts are separate reads; a completion between them must not make this negative.
1703
+ pending: Math.max(0, stored - running.length),
1704
+ processing: running.length,
1705
+ completed: legacyCompleted + history,
1706
+ dead,
1707
+ oldestDueAt: dueTimes.length === 0 ? null : new Date(Math.min(...dueTimes))
1708
+ };
1709
+ }
1710
+ /** The listed deliveries that run: those whose document still exists as pending. */
1711
+ async findRunning(listed) {
1712
+ if (listed.length === 0) {
1713
+ return [];
1714
+ }
1715
+ return this.#state.deliveries.find(
1716
+ { _id: { $in: [...listed] }, status: "pending" },
1717
+ { projection: { handler: 1, key: 1, laneId: 1 } }
1718
+ ).toArray();
1719
+ }
1720
+ async readEnvelopes(eventIds) {
1721
+ const documents = await this.#state.events.find({ _id: { $in: [...new Set(eventIds)] } }).toArray();
1722
+ return new Map(documents.map((document) => [document._id, toEnvelope(document)]));
1723
+ }
1724
+ };
1725
+ function compareText(left, right) {
1726
+ return Number(left > right) - Number(left < right);
1727
+ }
1728
+ function commonFilter(query) {
1729
+ return {
1730
+ ...query.handler === void 0 ? {} : { handler: String(query.handler) },
1731
+ ...query.eventType === void 0 ? {} : { eventType: String(query.eventType) },
1732
+ ...query.eventId === void 0 ? {} : { eventId: String(query.eventId) }
1733
+ };
1734
+ }
1735
+
1736
+ // src/mongo-schema.ts
1737
+ var NAMESPACE_EXISTS = 48;
1738
+ var INDEX_NOT_FOUND = 27;
1739
+ var UPGRADE_BATCH_SIZE = 1e3;
1740
+ var CLAIM_INDEX_KEY = { laneId: 1, dueAt: 1, _id: 1, handler: 1 };
1741
+ var OBSOLETE_INDEXES = ["running"];
1742
+ var LEASE_FIELDS = { leaseOwner: "", leaseToken: "", leaseExpiresAt: "" };
1743
+ var MongoSchema = class {
1744
+ #state;
1745
+ constructor(state) {
1746
+ this.#state = state;
1747
+ }
1748
+ /** Implements {@link MongoEventStore.setup}. */
1749
+ async setup() {
1750
+ const hello = await this.#state.client.db("admin").command({ hello: 1 });
1751
+ const supportsTransactions = typeof hello["setName"] === "string" || hello["msg"] === "isdbgrid";
1752
+ if (!supportsTransactions) {
1753
+ throw createTransactionsUnsupportedError(void 0);
1754
+ }
1755
+ const collectionNames = [
1756
+ this.#state.events.collectionName,
1757
+ this.#state.deliveries.collectionName,
1758
+ this.#state.keySequences.collectionName,
1759
+ this.#state.lanes.collectionName,
1760
+ ...this.#state.completionHistory ? [this.#state.completions.collectionName] : []
1761
+ ];
1762
+ for (const name of collectionNames) {
1763
+ await this.#createCollectionIfMissing(name);
1764
+ }
1765
+ if (await this.#state.lanes.countDocuments() < LANE_COUNT) {
1766
+ await this.#upgradeFromVersion010();
1767
+ await this.#seedLanes();
1768
+ }
1769
+ await this.#dropObsoleteIndexes();
1770
+ await this.#createIndexes();
1771
+ }
1772
+ /** Creates the indexes of every collection; existing indexes are left as they are. */
1773
+ 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
+ ]);
1816
+ 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
+ ]);
1822
+ }
1823
+ }
1824
+ async #createCollectionIfMissing(name) {
1825
+ try {
1826
+ await this.#state.database.createCollection(name);
1827
+ } catch (error) {
1828
+ if (!hasErrorCode(error, NAMESPACE_EXISTS)) {
1829
+ throw error;
1830
+ }
1831
+ }
1832
+ }
1833
+ /**
1834
+ * Converts the documents of a 0.1.0 database; every step is idempotent, so a
1835
+ * run that crashed is simply repeated. Deliveries that were `processing`
1836
+ * become `pending`, due at once (their attempt stays counted, as a lost lease
1837
+ * would count it). The per-delivery lease fields go, every delivery gets its
1838
+ * lane, and every event learns which handlers it already has deliveries for
1839
+ * and when it was created. Deliveries completed under 0.1.0 stay as
1840
+ * `completed` documents until a purge removes them.
1841
+ */
1842
+ async #upgradeFromVersion010() {
1843
+ const legacy = this.#state.database.collection(this.#state.deliveries.collectionName);
1844
+ await legacy.updateMany({ status: "processing" }, [
1845
+ { $set: { status: "pending", lane: "ready", dueAt: "$availableAt" } }
1846
+ ]);
1847
+ await legacy.updateMany(
1848
+ {
1849
+ $or: [
1850
+ { leaseOwner: { $exists: true } },
1851
+ { leaseToken: { $exists: true } },
1852
+ { leaseExpiresAt: { $exists: true } }
1853
+ ]
1854
+ },
1855
+ { $unset: LEASE_FIELDS }
1856
+ );
1857
+ await inBatches(
1858
+ this.#state.deliveries.find(
1859
+ { laneId: { $exists: false } },
1860
+ { projection: { eventId: 1, key: 1 }, batchSize: UPGRADE_BATCH_SIZE }
1861
+ ),
1862
+ async (documents) => {
1863
+ await this.#state.deliveries.bulkWrite(
1864
+ documents.map((document) => ({
1865
+ updateOne: {
1866
+ filter: { _id: document._id },
1867
+ update: { $set: { laneId: laneOf(document.key ?? null, document.eventId) } }
1868
+ }
1869
+ })),
1870
+ { ordered: false }
1871
+ );
1872
+ }
1873
+ );
1874
+ await this.#upgradeEventsFromVersion010();
1875
+ }
1876
+ /** Tells every 0.1.0 event which handlers it already has deliveries for and when it was created. */
1877
+ async #upgradeEventsFromVersion010() {
1878
+ await inBatches(
1879
+ this.#state.events.find(
1880
+ { handlers: { $exists: false } },
1881
+ { projection: { occurredAt: 1 }, batchSize: UPGRADE_BATCH_SIZE }
1882
+ ),
1883
+ 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
+ );
1896
+ 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
+ }),
1909
+ { ordered: false }
1910
+ );
1911
+ }
1912
+ );
1913
+ }
1914
+ /** Creates the lane documents that do not exist yet. */
1915
+ async #seedLanes() {
1916
+ await this.#state.lanes.bulkWrite(
1917
+ Array.from({ length: LANE_COUNT }, (_, laneId) => ({
1918
+ updateOne: {
1919
+ filter: { _id: laneId },
1920
+ update: {
1921
+ $setOnInsert: {
1922
+ epoch: 0,
1923
+ owner: null,
1924
+ holder: null,
1925
+ leaseExpiresAt: null,
1926
+ inFlight: [],
1927
+ lost: []
1928
+ }
1929
+ },
1930
+ upsert: true
1931
+ }
1932
+ })),
1933
+ { ordered: false }
1934
+ );
1935
+ }
1936
+ /** Drops the 0.1.0 indexes that no query uses, and a `claim` index with the 0.1.0 key. */
1937
+ async #dropObsoleteIndexes() {
1938
+ for (const index of await this.#state.deliveries.indexes()) {
1939
+ if (isObsoleteIndex(index)) {
1940
+ await this.#dropIndexIfPresent(index.name);
1941
+ }
1942
+ }
1943
+ }
1944
+ async #dropIndexIfPresent(name) {
1945
+ try {
1946
+ await this.#state.deliveries.dropIndex(name);
1947
+ } catch (error) {
1948
+ if (!hasErrorCode(error, INDEX_NOT_FOUND)) {
1949
+ throw error;
1950
+ }
1951
+ }
1952
+ }
1953
+ };
1954
+ function isObsoleteIndex(index) {
1955
+ return OBSOLETE_INDEXES.includes(index.name) || index.name === "claim" && Object.keys(index.key).join() !== Object.keys(CLAIM_INDEX_KEY).join();
1956
+ }
1957
+ async function inBatches(cursor, work) {
1958
+ let batch = [];
1844
1959
  for await (const document of cursor) {
1845
1960
  batch.push(document);
1846
1961
  if (batch.length === UPGRADE_BATCH_SIZE) {
@@ -1852,43 +1967,237 @@ async function inBatches(cursor, work) {
1852
1967
  await work(batch);
1853
1968
  }
1854
1969
  }
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
1970
  function hasErrorCode(error, code) {
1879
1971
  return typeof error === "object" && error !== null && "code" in error && error.code === code;
1880
1972
  }
1881
- function compareText(left, right) {
1882
- return Number(left > right) - Number(left < right);
1973
+ var COLLECTION_PREFIX_PATTERN = /^[A-Za-z_][A-Za-z0-9_]{0,39}$/;
1974
+ var DATABASE_NAME_PATTERN = /^[A-Za-z0-9_-]{1,63}$/;
1975
+ function assertCollectionPrefix(prefix) {
1976
+ if (!COLLECTION_PREFIX_PATTERN.test(prefix)) {
1977
+ throw new core.ConfigurationError(
1978
+ `collectionPrefix '${prefix}' must be 1-40 characters of letters, digits or '_', starting with a letter or '_'`
1979
+ );
1980
+ }
1883
1981
  }
1884
- function assertInTransaction(session, operation) {
1885
- if (!session.inTransaction()) {
1982
+ function assertDatabaseName(name) {
1983
+ if (!DATABASE_NAME_PATTERN.test(name)) {
1886
1984
  throw new core.ConfigurationError(
1887
- `MongoEventStore.${operation} needs a ClientSession inside a started transaction (session.withTransaction or startTransaction). Without one, MongoDB commits each write immediately and the write is no longer atomic with yours`
1985
+ `database '${name}' must be 1-63 characters of letters, digits, '_' or '-'`
1888
1986
  );
1889
1987
  }
1890
1988
  }
1891
1989
 
1990
+ // src/mongo-event-store.ts
1991
+ var DEFAULT_TRANSACTION_OPTIONS = {
1992
+ readConcern: { level: "snapshot" },
1993
+ writeConcern: { w: "majority" },
1994
+ readPreference: "primary"
1995
+ };
1996
+ var DEFAULT_PURGE_BATCH_SIZE = 500;
1997
+ var MongoEventStore = class {
1998
+ #state;
1999
+ #schema;
2000
+ #appender;
2001
+ #claims;
2002
+ #laneLeases;
2003
+ #completion;
2004
+ #queries;
2005
+ #purger;
2006
+ 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
+ };
2038
+ this.#state = state;
2039
+ const queries = new MongoQueries(state);
2040
+ const ordering = new MongoOrdering(state);
2041
+ const laneLeases = new MongoLaneLeases(state, { queries });
2042
+ this.#schema = new MongoSchema(state);
2043
+ this.#appender = new MongoAppend(state);
2044
+ this.#claims = new MongoClaims(state, { laneLeases, ordering, queries });
2045
+ this.#laneLeases = laneLeases;
2046
+ this.#completion = new MongoCompletion(state);
2047
+ this.#queries = queries;
2048
+ this.#purger = new MongoPurge(state);
2049
+ }
2050
+ /**
2051
+ * Verifies the deployment supports transactions, creates the collections and
2052
+ * indexes and seeds the lane documents. Upgrades a 0.1.0 database in place
2053
+ * first (see `MongoSchema.#upgradeFromVersion010`); the seeded lanes
2054
+ * mark the upgrade as done, so later runs skip it. Safe to run repeatedly and
2055
+ * from several processes. Stop 0.1.0 workers before the first run.
2056
+ *
2057
+ * @throws ConfigurationError when the server is a standalone mongod.
2058
+ */
2059
+ setup() {
2060
+ return this.#schema.setup();
2061
+ }
2062
+ /**
2063
+ * Runs `work` in a transaction on a new session and commits when it resolves.
2064
+ *
2065
+ * `work` can run more than once. The driver restarts the whole callback after
2066
+ * a transient failure such as a write conflict, and re-sends only the commit
2067
+ * after an unknown commit result. A restart is safe for everything written
2068
+ * through the session, because the aborted attempt left nothing behind. It is
2069
+ * not safe for side effects outside the session (HTTP calls, other databases),
2070
+ * so keep those out of `work`; relaybus handlers are already at-least-once.
2071
+ *
2072
+ * With `options.timeoutMs` (from `bus.transaction`) the server bounds the commit with
2073
+ * `maxCommitTimeMS`; the bus aborts a transaction that ran past the budget before its commit.
2074
+ *
2075
+ * @throws ConfigurationError when the deployment does not support transactions.
2076
+ */
2077
+ transaction(work, options) {
2078
+ return options === void 0 ? runInTransaction(this.#state, work) : runInBudgetedTransaction(this.#state, { work, timeoutMs: options.timeoutMs });
2079
+ }
2080
+ /**
2081
+ * Writes the entries in `session`. An existing event gets a delivery only for
2082
+ * handlers it does not list yet; the added handlers are appended to its list
2083
+ * together with a `revision` increment, a real write that makes a concurrent
2084
+ * purge of the event conflict with this transaction.
2085
+ */
2086
+ // eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
2087
+ append(session, entries, now) {
2088
+ return this.#appender.append(session, { entries, now });
2089
+ }
2090
+ /**
2091
+ * Claims up to `limit` due deliveries. It claims from the lanes this owner
2092
+ * (and this store instance) holds under a live lease first, then takes free
2093
+ * lanes with due work, a few at a time (4, 8, 16, ...) and the lane with the
2094
+ * oldest due delivery first, so a worker holds only as many lanes as it
2095
+ * needs. It then records the batch with one write per lane and hands back the
2096
+ * lanes that yielded nothing and run nothing. No delivery document is
2097
+ * written, except for the attempt charged to deliveries of a lane taken over
2098
+ * and for parking (see `MongoOrdering.#parkDeliveries`).
2099
+ *
2100
+ * Taking a lane is one conditional `findOneAndUpdate` that also moves the
2101
+ * previous owner's in-flight list to the lane's `lost` list; a lane another
2102
+ * claimer took first is skipped, never waited for. Lost deliveries are charged
2103
+ * one attempt before anything is read from the lane, and `lost` is cleared
2104
+ * only after that. Whoever holds the lane next charges a `lost` list it finds
2105
+ * first, so a claim that fails anywhere after the take loses no charge; the
2106
+ * charge of a lost claim is recorded on the delivery, so charging the list
2107
+ * again after a failure before the clear charges nothing twice.
2108
+ * Nothing a failed claim did leaves a delivery listed as running.
2109
+ */
2110
+ claim(request) {
2111
+ return this.#claims.claim(request);
2112
+ }
2113
+ /**
2114
+ * Extends the lease of the lane a running delivery belongs to. The lane lease
2115
+ * never shrinks, so a repeated renewal with the same value still holds. An
2116
+ * expired lease nobody took over is still renewed, so `_now` is not needed.
2117
+ */
2118
+ // eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
2119
+ extendLease(lease, leaseExpiresAt, _now) {
2120
+ return this.#completion.extendLease(lease, leaseExpiresAt);
2121
+ }
2122
+ /**
2123
+ * Deletes the delivery (and records it in the completion history, when kept).
2124
+ * With `session` the deletion commits with the caller's transaction; without,
2125
+ * it runs in a transaction of its own. Returns false when the lease was lost.
2126
+ */
2127
+ // eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
2128
+ complete(lease, now, session) {
2129
+ return this.#completion.complete(lease, { now, session });
2130
+ }
2131
+ /**
2132
+ * Completes many leases with a fixed number of round trips, whatever lanes
2133
+ * they belong to: three commands without a completion history, one
2134
+ * transaction with it (see `MongoCompletion.#deleteFenced`).
2135
+ * Returns one flag per lease in input order; a lease listed twice completes
2136
+ * once, and a token this store cannot have issued is refused.
2137
+ */
2138
+ completeMany(leases, now) {
2139
+ return this.#completion.completeMany(leases, now);
2140
+ }
2141
+ /** Returns a running delivery to pending, due at `availableAt`, and counts the attempt that ended. */
2142
+ // eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
2143
+ reschedule(lease, availableAt, error, now) {
2144
+ return this.#completion.reschedule(lease, { availableAt, error, now });
2145
+ }
2146
+ /** Gives a running delivery back without counting the attempt: pending, due `now`. */
2147
+ release(lease, now) {
2148
+ return this.#completion.release(lease, now);
2149
+ }
2150
+ /** Moves a running delivery to dead and counts the attempt that ended. */
2151
+ // eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
2152
+ bury(lease, error, now) {
2153
+ return this.#completion.bury(lease, { error, now });
2154
+ }
2155
+ /**
2156
+ * Frees every lane `leaseOwner` holds that runs no delivery, so other workers
2157
+ * need not wait for the lease to expire. Lanes with running deliveries stay leased.
2158
+ */
2159
+ relinquish(leaseOwner) {
2160
+ return this.#laneLeases.relinquish(leaseOwner);
2161
+ }
2162
+ /**
2163
+ * Lists deliveries in creation order. A delivery is `processing` while its
2164
+ * document exists as `pending` and its lane lists it; its attempt count then
2165
+ * includes the running attempt, and owner and expiry are those of the lane
2166
+ * lease. Completion history records (when kept) are listed as `completed`.
2167
+ */
2168
+ listDeliveries(query) {
2169
+ return this.#queries.listDeliveries(query);
2170
+ }
2171
+ /** Moves dead deliveries back to pending with a fresh attempt budget. */
2172
+ requeue(deliveryIds, now) {
2173
+ return this.#queries.requeue(deliveryIds, now);
2174
+ }
2175
+ /**
2176
+ * Deletes, in batches of `purgeBatchSize`, dead deliveries last updated before
2177
+ * `deadBefore`, and completion history records (and `completed` documents left
2178
+ * by 0.1.0) completed before `completedBefore`, each batch in one transaction
2179
+ * together with the events it left without any reference. Finally, events
2180
+ * nothing references are deleted: one with a completed delivery once its last
2181
+ * completion is before `completedBefore` (never by `deadBefore` alone), one
2182
+ * that never had a completion (no subscribers, or only dead letters) once it
2183
+ * was created before the earliest given cutoff. The batch step applies the
2184
+ * same rule to events whose last completion is recorded.
2185
+ */
2186
+ purge(request) {
2187
+ return this.#purger.purge(request);
2188
+ }
2189
+ /**
2190
+ * Counts by derived status. `pending` excludes running deliveries; `completed`
2191
+ * counts history records and 0.1.0 `completed` documents. `oldestDueAt` is the
2192
+ * oldest instant at which a delivery became claimable: the due time of a
2193
+ * pending delivery nobody runs, or the lease expiry of a lane that still runs
2194
+ * deliveries.
2195
+ */
2196
+ statistics(now) {
2197
+ return this.#queries.statistics(now);
2198
+ }
2199
+ };
2200
+
1892
2201
  exports.MongoEventStore = MongoEventStore;
1893
2202
  //# sourceMappingURL=index.cjs.map
1894
2203
  //# sourceMappingURL=index.cjs.map