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