@relaybus/mongodb 0.2.0 → 0.3.1

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