@relaybus/core 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -36,6 +36,15 @@ var InvalidEnvelopeError = class extends NonRetryableError {
36
36
  };
37
37
  var LeaseLostError = class extends RelayBusError {
38
38
  name = "LeaseLostError";
39
+ /**
40
+ * Whether the store said so (a fenced write returned false). A worker that only noticed
41
+ * locally that its lease ran out may still own the delivery in the store and has to give it back.
42
+ */
43
+ storeConfirmed;
44
+ constructor(message, options = {}) {
45
+ super(message);
46
+ this.storeConfirmed = options.storeConfirmed ?? false;
47
+ }
39
48
  };
40
49
  var HandlerTimeoutError = class extends RelayBusError {
41
50
  name = "HandlerTimeoutError";
@@ -141,6 +150,261 @@ async function validatePayload(type, schema, payload) {
141
150
  return result.value;
142
151
  }
143
152
 
153
+ // src/catalog.ts
154
+ var EVENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
155
+ var descriptions = /* @__PURE__ */ new WeakMap();
156
+ function defineCatalog(events, options = {}) {
157
+ const label = options.name === void 0 ? "Catalog" : `Catalog '${options.name}'`;
158
+ const names = Object.keys(events);
159
+ if (names.length === 0) {
160
+ throw new ConfigurationError(`${label} must contain at least one event`);
161
+ }
162
+ const owners = /* @__PURE__ */ new Map();
163
+ const entries = names.map((name) => {
164
+ const eventClass = events[name];
165
+ if (!EVENT_NAME_PATTERN.test(name)) {
166
+ throw new ConfigurationError(
167
+ `${label} entry '${name}' must be a valid identifier made of letters, digits and underscores because it names the handler method on${name} and its handler id`
168
+ );
169
+ }
170
+ if (typeof eventClass !== "function" || typeof eventClass.type !== "string") {
171
+ throw new ConfigurationError(
172
+ `${label} entry '${name}' must be an event class created with defineEvent`
173
+ );
174
+ }
175
+ assertEventType(eventClass.type);
176
+ const owner = owners.get(eventClass.type);
177
+ if (owner !== void 0) {
178
+ throw new ConfigurationError(
179
+ `${label} entries '${owner}' and '${name}' share the event type '${eventClass.type}'`
180
+ );
181
+ }
182
+ owners.set(eventClass.type, name);
183
+ return Object.freeze({ name, eventClass, type: eventClass.type, handlerName: `on${name}` });
184
+ });
185
+ const catalog = Object.freeze({ ...events });
186
+ descriptions.set(catalog, Object.freeze({ name: options.name, entries: Object.freeze(entries) }));
187
+ return catalog;
188
+ }
189
+ function describeCatalog(catalog) {
190
+ const description = descriptions.get(catalog);
191
+ if (description === void 0) {
192
+ throw new ConfigurationError("Expected a catalog created with defineCatalog");
193
+ }
194
+ return description;
195
+ }
196
+ function catalogLabel(catalog) {
197
+ const { name } = describeCatalog(catalog);
198
+ return name === void 0 ? "the catalog" : `catalog '${name}'`;
199
+ }
200
+ function selectCatalogEntries(catalog, events) {
201
+ const { entries } = describeCatalog(catalog);
202
+ if (events === void 0) {
203
+ return entries;
204
+ }
205
+ const label = catalogLabel(catalog);
206
+ if (events.length === 0) {
207
+ throw new ConfigurationError(
208
+ `The events selected from ${label} must name at least one event; omit events to select all of them`
209
+ );
210
+ }
211
+ const selected = /* @__PURE__ */ new Set();
212
+ for (const event of events) {
213
+ if (!entries.some((entry) => entry.name === event)) {
214
+ throw new ConfigurationError(
215
+ `${capitalize(label)} has no event named '${String(event)}'; its events are ${entries.map((entry) => entry.name).join(", ")}`
216
+ );
217
+ }
218
+ if (selected.has(event)) {
219
+ throw new ConfigurationError(`Event '${String(event)}' of ${label} is selected twice`);
220
+ }
221
+ selected.add(event);
222
+ }
223
+ return entries.filter((entry) => selected.has(entry.name));
224
+ }
225
+ function capitalize(text) {
226
+ return `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
227
+ }
228
+ function requireCatalogEntry(catalog, event) {
229
+ const entry = describeCatalog(catalog).entries.find((candidate) => candidate.type === event.type);
230
+ if (entry === void 0) {
231
+ throw new ConfigurationError(
232
+ `Event type '${String(event.type)}' is not part of ${catalogLabel(catalog)}`
233
+ );
234
+ }
235
+ return entry;
236
+ }
237
+ function resolveCatalogHandlers(catalog, handlers, options) {
238
+ const { name } = describeCatalog(catalog);
239
+ const { idPrefix, events, ...subscribeOptions } = options;
240
+ const entries = selectCatalogEntries(catalog, events);
241
+ const lookup = handlers;
242
+ const missing = entries.filter((entry) => typeof lookup[entry.handlerName] !== "function");
243
+ if (missing.length > 0) {
244
+ throw new ConfigurationError(
245
+ `Handlers for catalog ${name === void 0 ? "events" : `'${name}'`} lack methods: ${missing.map((entry) => entry.handlerName).join(", ")}`
246
+ );
247
+ }
248
+ const prefix = idPrefix ?? name ?? classNameOf(handlers);
249
+ if (prefix === void 0) {
250
+ throw new ConfigurationError(
251
+ "Cannot derive handler ids: name the catalog, pass an idPrefix or use a class instance as handlers"
252
+ );
253
+ }
254
+ return entries.map((entry) => {
255
+ const method = lookup[entry.handlerName];
256
+ return {
257
+ eventClass: entry.eventClass,
258
+ handle: (envelope, context) => method.call(handlers, envelope, context),
259
+ options: { ...subscribeOptions, id: `${prefix}.${entry.handlerName}` }
260
+ };
261
+ });
262
+ }
263
+ function classNameOf(handlers) {
264
+ const className = handlers.constructor.name;
265
+ return className === "Object" || className === "" ? void 0 : className;
266
+ }
267
+ function assertExactHandlers(catalog, entries, handlers) {
268
+ const expected = new Set(entries.map((entry) => entry.handlerName));
269
+ const present = Object.keys(handlers);
270
+ const missing = [...expected].filter((handlerName) => !present.includes(handlerName));
271
+ const extra = present.filter((key) => !expected.has(key));
272
+ const notFunctions = present.filter(
273
+ (key) => expected.has(key) && typeof handlers[key] !== "function"
274
+ );
275
+ if (missing.length + extra.length + notFunctions.length > 0) {
276
+ const problems = [
277
+ ...missing.map((key) => `missing ${key}`),
278
+ ...extra.map((key) => `unexpected ${key}`),
279
+ ...notFunctions.map((key) => `${key} is not a function`)
280
+ ];
281
+ throw new ConfigurationError(
282
+ `Handlers do not match ${catalogLabel(catalog)}: ${problems.join(", ")}`
283
+ );
284
+ }
285
+ }
286
+ function defineHandlers(catalog, handlers, options = {}) {
287
+ assertExactHandlers(catalog, selectCatalogEntries(catalog, options.events), handlers);
288
+ return handlers;
289
+ }
290
+
291
+ // src/catalog-publisher.ts
292
+ var CatalogPublisher = class {
293
+ #catalog;
294
+ #target;
295
+ #entries;
296
+ /**
297
+ * Whether the view publishes the event named `eventName`. Its parameter
298
+ * makes a view of some events unassignable to a view of more events, which
299
+ * the bivariant method parameters alone would allow.
300
+ */
301
+ #publishes;
302
+ /**
303
+ * Throws {@link ConfigurationError} when `catalog` was not created with
304
+ * `defineCatalog` or `options.events` names no event, an unknown event or
305
+ * the same event twice.
306
+ */
307
+ constructor(catalog, target, options = {}) {
308
+ this.#entries = selectCatalogEntries(catalog, options.events);
309
+ const names = new Set(this.#entries.map((entry) => entry.name));
310
+ this.#publishes = (eventName) => names.has(eventName);
311
+ this.#catalog = catalog;
312
+ this.#target = target;
313
+ }
314
+ /** Same as `bus.publish`, limited to the events of the view. */
315
+ async publish(event, options) {
316
+ this.#assertPublishable(event);
317
+ return this.#target.publish(event, options);
318
+ }
319
+ /** Same as `bus.publishAll`, limited to the events of the view. Nothing is stored unless every event belongs to it. */
320
+ async publishAll(events, options) {
321
+ for (const event of events) {
322
+ this.#assertPublishable(event);
323
+ }
324
+ return this.#target.publishAll(events, options);
325
+ }
326
+ #assertPublishable(event) {
327
+ const { name } = requireCatalogEntry(this.#catalog, event);
328
+ if (!this.#publishes(name)) {
329
+ throw new ConfigurationError(
330
+ `Event type '${event.type}' is not one of the events this view of ${catalogLabel(
331
+ this.#catalog
332
+ )} publishes (${this.#entries.map((entry) => entry.name).join(", ")})`
333
+ );
334
+ }
335
+ }
336
+ };
337
+
338
+ // src/catalog-coverage.ts
339
+ var declarations = /* @__PURE__ */ new WeakSet();
340
+ function consume(catalog, options = {}) {
341
+ return declare(catalog, "consume", options);
342
+ }
343
+ function produce(catalog, options = {}) {
344
+ return declare(catalog, "produce", options);
345
+ }
346
+ function declare(catalog, role, options) {
347
+ const entries = selectCatalogEntries(catalog, options.events);
348
+ const declaration = Object.freeze({
349
+ catalog,
350
+ role,
351
+ events: Object.freeze(entries.map((entry) => entry.name)),
352
+ entries
353
+ });
354
+ declarations.add(declaration);
355
+ return declaration;
356
+ }
357
+ function toCatalogRequirement(value) {
358
+ if (declarations.has(value)) {
359
+ const declaration = value;
360
+ return {
361
+ catalogName: describeCatalog(declaration.catalog).name,
362
+ entries: declaration.entries,
363
+ need: declaration.role === "consume" ? "handler" : "route"
364
+ };
365
+ }
366
+ const { name, entries } = describeCatalog(value);
367
+ return { catalogName: name, entries, need: "handler-or-route" };
368
+ }
369
+ function assertCatalogsCovered(requirements, registrationsOf) {
370
+ const problems = /* @__PURE__ */ new Set();
371
+ let onlyBareCatalogs = true;
372
+ for (const { catalogName, entries, need } of requirements) {
373
+ for (const entry of entries) {
374
+ const problem = describeGap(entry, need, registrationsOf(entry.type));
375
+ if (problem !== void 0) {
376
+ problems.add(`${catalogName === void 0 ? "" : `${catalogName}.`}${problem}`);
377
+ onlyBareCatalogs &&= need === "handler-or-route";
378
+ }
379
+ }
380
+ }
381
+ if (problems.size > 0) {
382
+ const heading = onlyBareCatalogs ? "Catalog events without a handler or route:" : "Catalog events this service does not cover:";
383
+ throw new ConfigurationError(
384
+ `${heading}
385
+ ${[...problems].map((line) => ` - ${line}`).join("\n")}`
386
+ );
387
+ }
388
+ }
389
+ function describeGap(entry, need, { handlers, routes }) {
390
+ const subject = `${entry.name} (${entry.type})`;
391
+ if (need === "handler-or-route") {
392
+ return handlers + routes > 0 ? void 0 : `${subject}: expected handler method ${entry.handlerName}`;
393
+ }
394
+ if (need === "handler") {
395
+ if (handlers > 0) {
396
+ return void 0;
397
+ }
398
+ const routeNote = routes > 0 ? "; its route to a transport does not handle it here" : "";
399
+ return `${subject}: consumed here, needs handler method ${entry.handlerName}${routeNote}`;
400
+ }
401
+ if (routes > 0) {
402
+ return void 0;
403
+ }
404
+ const handlerNote = handlers > 0 ? "; a local handler does not deliver it to other services" : "";
405
+ return `${subject}: produced here, needs a route to a transport${handlerNote}`;
406
+ }
407
+
144
408
  // src/event-store.ts
145
409
  var DELIVERY_STATUSES = [
146
410
  "pending",
@@ -202,7 +466,9 @@ var InMemoryEventStore = class {
202
466
  #keySequences = /* @__PURE__ */ new Map();
203
467
  /** Row-lock emulation: event ids and ordering keys written by open transactions. */
204
468
  #locks = /* @__PURE__ */ new Map();
469
+ #writes = /* @__PURE__ */ new WeakMap();
205
470
  #generateIdentifier;
471
+ #retainCompleted;
206
472
  constructor(options = {}) {
207
473
  if (process.env["NODE_ENV"] === "production" && options.allowInProduction !== true) {
208
474
  throw new ConfigurationError(
@@ -210,6 +476,7 @@ var InMemoryEventStore = class {
210
476
  );
211
477
  }
212
478
  this.#generateIdentifier = options.generateIdentifier ?? (() => createUuidV7());
479
+ this.#retainCompleted = options.retainCompleted ?? false;
213
480
  }
214
481
  async transaction(work) {
215
482
  const transaction = new InMemoryTransaction();
@@ -230,13 +497,15 @@ var InMemoryEventStore = class {
230
497
  ...entry.event.key === null ? [] : [`key:${entry.event.key}`]
231
498
  ])
232
499
  );
233
- const pendingEventIds = /* @__PURE__ */ new Set();
234
- const pendingDeliveryKeys = /* @__PURE__ */ new Set();
500
+ const written = this.#written(transaction);
501
+ const pendingEventIds = written.eventIds;
502
+ const pendingDeliveryKeys = written.deliveryKeys;
235
503
  const appendedEventIds = [];
236
504
  const duplicateEventIds = [];
237
505
  let appendedDeliveryCount = 0;
238
506
  for (const entry of entries) {
239
507
  const eventId = entry.event.id;
508
+ const serialized = serializeEnvelope(entry.event);
240
509
  const isDuplicate = this.#events.has(eventId) || pendingEventIds.has(eventId);
241
510
  if (isDuplicate) {
242
511
  duplicateEventIds.push(eventId);
@@ -246,23 +515,26 @@ var InMemoryEventStore = class {
246
515
  }
247
516
  const newDeliveries = entry.deliveries.filter((delivery) => {
248
517
  const deliveryKey = `${eventId}\0${delivery.handler}`;
249
- if (pendingDeliveryKeys.has(deliveryKey) || this.#hasDelivery(eventId, delivery.handler)) {
518
+ if (pendingDeliveryKeys.has(deliveryKey) || this.#events.get(eventId)?.handlers.has(delivery.handler) === true) {
250
519
  return false;
251
520
  }
252
521
  pendingDeliveryKeys.add(deliveryKey);
253
522
  return true;
254
523
  });
255
524
  appendedDeliveryCount += newDeliveries.length;
256
- const serialized = serializeEnvelope(entry.event);
257
525
  transaction.enqueue(() => {
258
526
  if (!isDuplicate) {
259
527
  this.#events.set(eventId, {
260
528
  envelope: serialized,
261
- sequence: this.#nextSequence(serialized.key)
529
+ sequence: this.#nextSequence(serialized.key),
530
+ createdAt: now,
531
+ handlers: /* @__PURE__ */ new Set(),
532
+ completedAt: null
262
533
  });
263
534
  }
264
535
  const stored = this.#events.get(eventId);
265
536
  for (const delivery of newDeliveries) {
537
+ stored.handlers.add(delivery.handler);
266
538
  this.#deliveries.set(delivery.id, {
267
539
  id: delivery.id,
268
540
  eventId,
@@ -320,14 +592,8 @@ var InMemoryEventStore = class {
320
592
  });
321
593
  }
322
594
  async complete(lease, now, transaction) {
323
- const finish = (delivery) => {
324
- delivery.status = "completed";
325
- delivery.completedAt = now;
326
- delivery.updatedAt = now;
327
- clearLease(delivery);
328
- };
329
595
  if (transaction === void 0) {
330
- return this.#updateLeased(lease, finish);
596
+ return this.#updateLeased(lease, (delivery) => this.#finish(delivery, now));
331
597
  }
332
598
  if (this.#findLeased(lease) === void 0) {
333
599
  return false;
@@ -335,11 +601,14 @@ var InMemoryEventStore = class {
335
601
  transaction.enqueue(() => {
336
602
  const delivery = this.#findLeased(lease);
337
603
  if (delivery !== void 0) {
338
- finish(delivery);
604
+ this.#finish(delivery, now);
339
605
  }
340
606
  });
341
607
  return true;
342
608
  }
609
+ async completeMany(leases, now) {
610
+ return Promise.all(leases.map((lease) => this.complete(lease, now)));
611
+ }
343
612
  async reschedule(lease, availableAt, error, now) {
344
613
  return this.#updateLeased(lease, (delivery) => {
345
614
  delivery.status = "pending";
@@ -413,9 +682,16 @@ var InMemoryEventStore = class {
413
682
  const referencedEventIds = new Set(
414
683
  [...this.#deliveries.values()].map((delivery) => delivery.eventId)
415
684
  );
685
+ const cutoffs = [request.completedBefore, request.deadBefore].filter((cutoff) => cutoff !== void 0).map((cutoff) => cutoff.getTime());
686
+ const orphanCutoff = cutoffs.length === 0 ? Number.NEGATIVE_INFINITY : Math.min(...cutoffs);
687
+ const completedCutoff = request.completedBefore?.getTime() ?? Number.NEGATIVE_INFINITY;
416
688
  let deletedEvents = 0;
417
- for (const eventId of touchedEventIds) {
418
- if (!referencedEventIds.has(eventId)) {
689
+ for (const [eventId, event] of [...this.#events]) {
690
+ if (referencedEventIds.has(eventId) || this.#locks.has(`event:${eventId}`)) {
691
+ continue;
692
+ }
693
+ const expired = event.completedAt === null ? touchedEventIds.has(eventId) || event.createdAt.getTime() < orphanCutoff : event.completedAt.getTime() < completedCutoff;
694
+ if (expired) {
419
695
  this.#events.delete(eventId);
420
696
  deletedEvents += 1;
421
697
  }
@@ -446,6 +722,15 @@ var InMemoryEventStore = class {
446
722
  * until `transaction` finishes, the way a database holds row locks. Sorted
447
723
  * acquisition keeps two transactions from waiting on each other.
448
724
  */
725
+ /** The events and deliveries the transaction appended so far, which are not applied before it commits. */
726
+ #written(transaction) {
727
+ let written = this.#writes.get(transaction);
728
+ if (written === void 0) {
729
+ written = { eventIds: /* @__PURE__ */ new Set(), deliveryKeys: /* @__PURE__ */ new Set() };
730
+ this.#writes.set(transaction, written);
731
+ }
732
+ return written;
733
+ }
449
734
  async #lock(transaction, names) {
450
735
  for (const name of [...new Set(names)].sort()) {
451
736
  let held = this.#locks.get(name);
@@ -467,14 +752,6 @@ var InMemoryEventStore = class {
467
752
  });
468
753
  }
469
754
  }
470
- #hasDelivery(eventId, handler) {
471
- for (const delivery of this.#deliveries.values()) {
472
- if (delivery.eventId === eventId && delivery.handler === handler) {
473
- return true;
474
- }
475
- }
476
- return false;
477
- }
478
755
  #nextSequence(key) {
479
756
  if (key === null) {
480
757
  return null;
@@ -505,6 +782,21 @@ var InMemoryEventStore = class {
505
782
  }
506
783
  return false;
507
784
  }
785
+ /** Deletes a completed delivery, or marks it completed when a history is kept. */
786
+ #finish(delivery, now) {
787
+ const event = this.#events.get(delivery.eventId);
788
+ if (event.completedAt === null || event.completedAt < now) {
789
+ event.completedAt = now;
790
+ }
791
+ if (!this.#retainCompleted) {
792
+ this.#deliveries.delete(delivery.id);
793
+ return;
794
+ }
795
+ delivery.status = "completed";
796
+ delivery.completedAt = now;
797
+ delivery.updatedAt = now;
798
+ clearLease(delivery);
799
+ }
508
800
  #findLeased(lease) {
509
801
  const delivery = this.#deliveries.get(lease.deliveryId);
510
802
  if (delivery?.status === "processing" && delivery.leaseToken === lease.leaseToken) {
@@ -562,6 +854,66 @@ function isPurgeable(delivery, request) {
562
854
  }
563
855
  return false;
564
856
  }
857
+
858
+ // src/completion-batcher.ts
859
+ var CompletionBatcher = class {
860
+ #store;
861
+ #clock;
862
+ #settings;
863
+ #runningCount;
864
+ #pending = [];
865
+ #timer;
866
+ #closing = false;
867
+ /**
868
+ * @param runningCount Number of deliveries the dispatcher is running right
869
+ * now, including the ones waiting in this batcher.
870
+ */
871
+ constructor(store, clock, settings, runningCount) {
872
+ this.#store = store;
873
+ this.#clock = clock;
874
+ this.#settings = settings;
875
+ this.#runningCount = runningCount;
876
+ }
877
+ /**
878
+ * Queues a completion. Resolves with whether the lease was still held;
879
+ * rejects with the store error when the batch could not be written.
880
+ */
881
+ submit(lease) {
882
+ return new Promise((resolve, reject) => {
883
+ this.#pending.push({ lease, resolve, reject });
884
+ if (this.#closing || this.#pending.length >= this.#settings.maxBatchSize || this.#pending.length >= this.#runningCount()) {
885
+ this.#flush();
886
+ } else {
887
+ this.#timer ??= setTimeout(() => this.#flush(), this.#settings.windowMs);
888
+ }
889
+ });
890
+ }
891
+ /**
892
+ * Shutdown has begun (or ended): while closing, nothing waits for company any more, so the completions
893
+ * already queued are written now and later ones at once.
894
+ */
895
+ setClosing(closing) {
896
+ this.#closing = closing;
897
+ if (closing && this.#pending.length > 0) {
898
+ this.#flush();
899
+ }
900
+ }
901
+ #flush() {
902
+ clearTimeout(this.#timer);
903
+ this.#timer = void 0;
904
+ const batch = this.#pending;
905
+ this.#pending = [];
906
+ Promise.resolve().then(
907
+ () => this.#store.completeMany(
908
+ batch.map((entry) => entry.lease),
909
+ this.#clock.now()
910
+ )
911
+ ).then(
912
+ (outcomes) => batch.forEach((entry, index) => entry.resolve(outcomes[index])),
913
+ (error) => batch.forEach((entry) => entry.reject(error))
914
+ );
915
+ }
916
+ };
565
917
  var storage = new async_hooks.AsyncLocalStorage();
566
918
  function runInScope(scope, work) {
567
919
  return storage.run({ ...storage.getStore(), ...scope }, work);
@@ -650,18 +1002,51 @@ function isRetryableError(policy, error) {
650
1002
 
651
1003
  // src/dispatcher.ts
652
1004
  var COMPLETION_RETRY_DELAYS_MS = [50, 250, 1e3];
1005
+ var WAKE_COOLDOWN_MS = 5;
1006
+ var QUIET_REFILL_MS = 10;
1007
+ var REFILL_FRACTION = 0.7;
653
1008
  var defaultSleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
654
1009
  var Dispatcher = class {
655
1010
  #dependencies;
656
1011
  #sleep;
657
1012
  #inFlight = /* @__PURE__ */ new Set();
1013
+ #completions;
1014
+ #pending = /* @__PURE__ */ new Map();
1015
+ #recoveryTimer;
1016
+ #recovery;
658
1017
  #running = false;
659
1018
  #loop;
660
1019
  #wakeUp;
661
- #wakeRequested = false;
1020
+ /** Free slots the claim the loop was woken for needs at least; undefined while nobody woke it. */
1021
+ #signalledMinimum;
1022
+ /** Free slots a claim needs unless the poll interval or the quiet refill time passed: claims refill in groups. */
1023
+ #refillThreshold;
1024
+ /** Handlers whose last claim got less than it asked for: the store has nothing more for them right now. */
1025
+ #exhaustedHandlers = /* @__PURE__ */ new Set();
1026
+ /** Grows with every wake-up: a claim that started before one must not mark a handler exhausted. */
1027
+ #wakeGeneration = 0;
1028
+ #lastClaimStartedAt = Number.NEGATIVE_INFINITY;
1029
+ #cooldownTimer;
1030
+ #refillScheduled = false;
1031
+ #quietRefillTimer;
1032
+ #unwatch;
1033
+ #watching;
662
1034
  constructor(dependencies) {
663
1035
  this.#dependencies = dependencies;
664
1036
  this.#sleep = dependencies.sleep ?? defaultSleep;
1037
+ const { store, clock, settings } = dependencies;
1038
+ this.#refillThreshold = Math.ceil(
1039
+ Math.min(settings.concurrency, settings.batchSize) * REFILL_FRACTION
1040
+ );
1041
+ const { completionBatchWindowMs: windowMs = 0, completionBatchSize: maxBatchSize = 1 } = settings;
1042
+ if (store.completeMany !== void 0 && windowMs > 0) {
1043
+ this.#completions = new CompletionBatcher(
1044
+ { completeMany: (leases, now) => store.completeMany(leases, now) },
1045
+ clock,
1046
+ { windowMs, maxBatchSize },
1047
+ () => this.#inFlight.size
1048
+ );
1049
+ }
665
1050
  }
666
1051
  get isRunning() {
667
1052
  return this.#running;
@@ -669,29 +1054,93 @@ var Dispatcher = class {
669
1054
  get inFlightCount() {
670
1055
  return this.#inFlight.size;
671
1056
  }
1057
+ /**
1058
+ * True while the store reports new deliveries to this dispatcher by itself (see
1059
+ * {@link EventStore.watchNewDeliveries}), from every process: a publisher then has no reason to wake it.
1060
+ */
1061
+ get watchesNewDeliveries() {
1062
+ return this.#unwatch !== void 0;
1063
+ }
672
1064
  start() {
673
1065
  if (this.#running) {
674
1066
  return;
675
1067
  }
676
1068
  this.#running = true;
1069
+ this.#completions?.setClosing(false);
1070
+ this.#watching = this.#watchNewDeliveries();
677
1071
  this.#loop = this.#runLoop();
678
1072
  }
679
- /** Shortens the wait before the next claim, for example right after a publish. */
1073
+ /**
1074
+ * Asks for a claim soon, for example right after a publish: at once after a quiet period, otherwise
1075
+ * at the end of a short cooldown that merges the wake-ups arriving meanwhile into one claim.
1076
+ */
680
1077
  wake() {
681
- this.#wakeRequested = true;
1078
+ this.#exhaustedHandlers.clear();
1079
+ this.#wakeGeneration += 1;
1080
+ const wait = this.#lastClaimStartedAt + WAKE_COOLDOWN_MS - Date.now();
1081
+ if (wait <= 0) {
1082
+ this.#requestClaim();
1083
+ return;
1084
+ }
1085
+ this.#cooldownTimer ??= setTimeout(() => {
1086
+ this.#cooldownTimer = void 0;
1087
+ this.#requestClaim();
1088
+ }, wait);
1089
+ }
1090
+ /** Ends the current wait of the claim loop, or the next one at once, for a claim once `minimumFree` slots are free. */
1091
+ #signal(minimumFree) {
1092
+ this.#signalledMinimum = Math.min(this.#signalledMinimum ?? minimumFree, minimumFree);
682
1093
  this.#wakeUp?.();
683
1094
  }
1095
+ /**
1096
+ * Starts listening to the store's reports of new deliveries. Polling stays the safety net: a store
1097
+ * that cannot listen, or fails to, only costs latency, so a failure is reported and otherwise ignored.
1098
+ */
1099
+ async #watchNewDeliveries() {
1100
+ const { store, notifier } = this.#dependencies;
1101
+ try {
1102
+ const unwatch = await store.watchNewDeliveries?.(() => this.wake());
1103
+ if (unwatch !== void 0 && !this.#running) {
1104
+ await unwatch();
1105
+ return;
1106
+ }
1107
+ this.#unwatch = unwatch;
1108
+ if (unwatch !== void 0) {
1109
+ this.wake();
1110
+ }
1111
+ } catch (error) {
1112
+ notifier.failure("watch", error);
1113
+ }
1114
+ }
1115
+ /** Stops listening to the store's reports of new deliveries. */
1116
+ async #stopWatching() {
1117
+ await this.#watching;
1118
+ const unwatch = this.#unwatch;
1119
+ this.#unwatch = void 0;
1120
+ try {
1121
+ await unwatch?.();
1122
+ } catch (error) {
1123
+ this.#dependencies.notifier.failure("watch", error);
1124
+ }
1125
+ }
684
1126
  /**
685
1127
  * Stops claiming, waits up to the shutdown timeout for running handlers,
686
1128
  * then aborts the rest and releases their deliveries for another process.
687
1129
  */
688
1130
  async stop() {
689
1131
  if (!this.#running) {
1132
+ await this.#closeRecovery(Date.now() + this.#dependencies.settings.shutdownTimeoutMs);
690
1133
  return;
691
1134
  }
692
1135
  this.#running = false;
693
- this.wake();
1136
+ this.#completions?.setClosing(true);
1137
+ clearTimeout(this.#cooldownTimer);
1138
+ this.#cooldownTimer = void 0;
1139
+ clearTimeout(this.#quietRefillTimer);
1140
+ this.#quietRefillTimer = void 0;
1141
+ this.#signal(this.#refillThreshold);
694
1142
  const deadline = Date.now() + this.#dependencies.settings.shutdownTimeoutMs;
1143
+ await this.#waitUntil(this.#stopWatching(), deadline);
695
1144
  await this.#waitUntil(this.#loop, deadline);
696
1145
  const settled = Promise.allSettled([...this.#inFlight].map((delivery) => delivery.settled));
697
1146
  if (await this.#waitUntil(settled, deadline) === "timeout") {
@@ -700,10 +1149,71 @@ var Dispatcher = class {
700
1149
  }
701
1150
  await settled;
702
1151
  }
1152
+ await this.#closeRecovery(deadline);
1153
+ await this.#waitUntil(this.#relinquish(), deadline);
1154
+ }
1155
+ /**
1156
+ * Gives the settlements still pending one last try and forgets them. A pass of the timer may be inside the
1157
+ * store: it ends before the last pass starts. A store that does not answer before the deadline gets no
1158
+ * further call, since it may be closed right after.
1159
+ */
1160
+ async #closeRecovery(deadline) {
1161
+ clearInterval(this.#recoveryTimer);
1162
+ this.#recoveryTimer = void 0;
1163
+ const lastTry = (async () => {
1164
+ await this.#recovery;
1165
+ await this.#recoverSettlements();
1166
+ })();
1167
+ await this.#waitUntil(lastTry, deadline);
1168
+ this.#pending.clear();
1169
+ }
1170
+ /**
1171
+ * Retries every pending settlement once; a pass that is already running is the one returned. A store that
1172
+ * still fails leaves the settlement for the next attempt (it was reported when it first failed).
1173
+ */
1174
+ #recoverSettlements() {
1175
+ this.#recovery ??= this.#recoverAll().finally(() => {
1176
+ this.#recovery = void 0;
1177
+ });
1178
+ return this.#recovery;
1179
+ }
1180
+ async #recoverAll() {
1181
+ for (const [key, entry] of [...this.#pending]) {
1182
+ if (this.#pending.get(key) !== entry) {
1183
+ continue;
1184
+ }
1185
+ try {
1186
+ const applied = await entry.settle();
1187
+ this.#pending.delete(pendingKey(entry.delivery));
1188
+ if (applied) {
1189
+ entry.applied();
1190
+ } else {
1191
+ this.#dependencies.notifier.notify("onLeaseLost", { delivery: entry.delivery });
1192
+ }
1193
+ } catch {
1194
+ }
1195
+ }
1196
+ }
1197
+ #retain(entry) {
1198
+ this.#pending.set(pendingKey(entry.delivery), entry);
1199
+ this.#recoveryTimer ??= setInterval(
1200
+ () => void this.#recoverSettlements(),
1201
+ this.#dependencies.settings.pollIntervalMs
1202
+ );
1203
+ this.#recoveryTimer.unref();
1204
+ }
1205
+ /** Gives lease groups the owner no longer works on back to the store, so other workers need not wait for expiry. */
1206
+ async #relinquish() {
1207
+ const { store, clock, notifier, settings } = this.#dependencies;
1208
+ try {
1209
+ await store.relinquish?.(settings.owner, clock.now());
1210
+ } catch (error) {
1211
+ notifier.failure("relinquish", error);
1212
+ }
703
1213
  }
704
1214
  /** Claims one batch and resolves when every claimed delivery settled. Returns how many ran. */
705
1215
  async processBatch() {
706
- const started = await this.#claimAndStart();
1216
+ const { started } = await this.#claimAndStart();
707
1217
  await Promise.allSettled(started.map((delivery) => delivery.settled));
708
1218
  return started.length;
709
1219
  }
@@ -716,44 +1226,102 @@ var Dispatcher = class {
716
1226
  clearTimeout(timer);
717
1227
  return outcome;
718
1228
  }
1229
+ /**
1230
+ * Claims until stopped. A claim that filled its batch is followed by the next one at once while enough
1231
+ * slots are free. Otherwise the loop sleeps until a wake-up (a publish, a store notification, or enough
1232
+ * deliveries settling to refill a group of slots) or the poll interval. Only the poll interval claims
1233
+ * for fewer free slots than the refill threshold, so a busy dispatcher never claims slot by slot.
1234
+ */
719
1235
  async #runLoop() {
1236
+ let minimumFree = 1;
720
1237
  while (this.#running) {
721
- let claimedCount = 0;
722
- try {
723
- claimedCount = (await this.#claimAndStart()).length;
724
- } catch (error) {
725
- this.#dependencies.notifier.failure("claim", error);
1238
+ let round = { requested: 0, claimed: 0 };
1239
+ if (this.#freeSlots() >= minimumFree) {
1240
+ try {
1241
+ round = await this.#claimAndStart();
1242
+ } catch (error) {
1243
+ this.#dependencies.notifier.failure("claim", error);
1244
+ }
726
1245
  }
727
- if (claimedCount > 0 && this.#freeSlots() > 0) {
1246
+ if (round.requested > 0 && round.claimed === round.requested && this.#freeSlots() >= this.#refillThreshold) {
1247
+ minimumFree = this.#refillThreshold;
728
1248
  continue;
729
1249
  }
730
- await this.#waitForWakeUp();
1250
+ minimumFree = await this.#waitForWakeUp();
1251
+ if (this.#freeSlots() < minimumFree) {
1252
+ this.#requestClaim();
1253
+ }
731
1254
  }
732
1255
  }
1256
+ /** Waits for a wake-up or the poll interval. Resolves with the free slots the next claim needs. */
733
1257
  async #waitForWakeUp() {
734
- if (this.#wakeRequested) {
735
- this.#wakeRequested = false;
736
- return;
1258
+ if (this.#signalledMinimum === void 0) {
1259
+ await new Promise((resolve) => {
1260
+ const timer = setTimeout(() => {
1261
+ this.#signalledMinimum = 1;
1262
+ resolve();
1263
+ }, this.#dependencies.settings.pollIntervalMs);
1264
+ this.#wakeUp = () => {
1265
+ clearTimeout(timer);
1266
+ resolve();
1267
+ };
1268
+ });
1269
+ this.#wakeUp = void 0;
1270
+ }
1271
+ const minimumFree = this.#signalledMinimum;
1272
+ this.#signalledMinimum = void 0;
1273
+ return minimumFree;
1274
+ }
1275
+ /**
1276
+ * A delivery settled. Its slot is worth refilling when the store may have more for its handler: a keyed
1277
+ * delivery unblocks the next one of its key, a delivery that did not succeed may be due again at once
1278
+ * (a retry without delay, a release), and a handler whose last claim was full may have a backlog.
1279
+ */
1280
+ #onSettled(delivery, succeeded) {
1281
+ if (delivery.event.key !== null || !succeeded || !this.#exhaustedHandlers.has(delivery.handler)) {
1282
+ this.#requestClaim();
737
1283
  }
738
- await new Promise((resolve) => {
739
- const timer = setTimeout(done, this.#dependencies.settings.pollIntervalMs);
740
- function done() {
741
- clearTimeout(timer);
742
- resolve();
1284
+ }
1285
+ /**
1286
+ * Asks the loop for a claim. Once a group of slots is free the claim waits only for the deliveries
1287
+ * settling in the same tick, so they are claimed for together; fewer free slots are refilled when no
1288
+ * claim started for a short while. With no free slot the next settlement asks again.
1289
+ */
1290
+ #requestClaim() {
1291
+ const free = this.#freeSlots();
1292
+ if (free >= this.#refillThreshold) {
1293
+ if (!this.#refillScheduled) {
1294
+ this.#refillScheduled = true;
1295
+ setImmediate(() => {
1296
+ this.#refillScheduled = false;
1297
+ this.#signal(this.#refillThreshold);
1298
+ });
743
1299
  }
744
- this.#wakeUp = done;
745
- });
746
- this.#wakeUp = void 0;
747
- this.#wakeRequested = false;
1300
+ } else if (free > 0) {
1301
+ this.#quietRefillTimer ??= setTimeout(
1302
+ () => {
1303
+ this.#quietRefillTimer = void 0;
1304
+ this.#signal(1);
1305
+ },
1306
+ Math.max(0, this.#lastClaimStartedAt + QUIET_REFILL_MS - Date.now())
1307
+ );
1308
+ }
748
1309
  }
749
1310
  #freeSlots() {
750
1311
  return this.#dependencies.settings.concurrency - this.#inFlight.size;
751
1312
  }
1313
+ /** Claims for every handler with room and starts what was claimed. */
752
1314
  async #claimAndStart() {
753
1315
  const { store, registry, clock, settings } = this.#dependencies;
754
1316
  const started = [];
1317
+ let requested = 0;
755
1318
  for (const request of this.#planClaims(registry.all())) {
756
1319
  const now = clock.now();
1320
+ const wakeGeneration = this.#wakeGeneration;
1321
+ this.#lastClaimStartedAt = Date.now();
1322
+ clearTimeout(this.#quietRefillTimer);
1323
+ this.#quietRefillTimer = void 0;
1324
+ requested += request.limit;
757
1325
  const claimed = await store.claim({
758
1326
  handlers: request.handlers,
759
1327
  limit: request.limit,
@@ -761,11 +1329,18 @@ var Dispatcher = class {
761
1329
  leaseExpiresAt: new Date(now.getTime() + settings.leaseDurationMs),
762
1330
  now
763
1331
  });
1332
+ for (const handler of request.handlers) {
1333
+ if (claimed.length < request.limit && wakeGeneration === this.#wakeGeneration) {
1334
+ this.#exhaustedHandlers.add(handler);
1335
+ } else {
1336
+ this.#exhaustedHandlers.delete(handler);
1337
+ }
1338
+ }
764
1339
  for (const delivery of claimed) {
765
1340
  started.push(this.#start(delivery));
766
1341
  }
767
1342
  }
768
- return started;
1343
+ return { requested, claimed: started.length, started };
769
1344
  }
770
1345
  /** One claim for handlers without a concurrency limit, one per limited handler with room. */
771
1346
  #planClaims(registrations) {
@@ -799,20 +1374,29 @@ var Dispatcher = class {
799
1374
  }
800
1375
  #start(delivery) {
801
1376
  const controller = new AbortController();
1377
+ let succeeded = false;
802
1378
  const entry = {
803
1379
  handler: delivery.handler,
804
1380
  controller,
805
- settled: Promise.resolve().then(() => this.#execute(delivery, controller)).catch((error) => this.#dependencies.notifier.failure("dispatch", error)).finally(() => {
1381
+ settled: Promise.resolve().then(async () => {
1382
+ succeeded = await this.#execute(delivery, controller);
1383
+ }).catch((error) => this.#dependencies.notifier.failure("dispatch", error)).finally(() => {
806
1384
  this.#inFlight.delete(entry);
807
- this.wake();
1385
+ this.#onSettled(delivery, succeeded);
808
1386
  })
809
1387
  };
810
1388
  this.#inFlight.add(entry);
811
1389
  return entry;
812
1390
  }
1391
+ /** Runs one delivery. Resolves true when its handler succeeded, so the delivery is done for good. */
813
1392
  async #execute(delivery, controller) {
814
1393
  const { registry, notifier, clock } = this.#dependencies;
815
1394
  const registration = registry.get(delivery.handler);
1395
+ for (const key of [...this.#pending.keys()].filter(
1396
+ (pending) => pending.startsWith(`${delivery.deliveryId}\0`)
1397
+ )) {
1398
+ this.#pending.delete(key);
1399
+ }
816
1400
  notifier.notify("onDeliveryStarted", { delivery });
817
1401
  const startedAt = clock.now().getTime();
818
1402
  if (delivery.attempt > registration.retry.attempts) {
@@ -822,26 +1406,35 @@ var Dispatcher = class {
822
1406
  `Delivery reached attempt ${delivery.attempt} of ${registration.retry.attempts} after its lease expired`
823
1407
  )
824
1408
  );
825
- return;
1409
+ return false;
826
1410
  }
827
1411
  let stopHeartbeat = () => void 0;
828
1412
  try {
829
1413
  stopHeartbeat = registration.transactional ? await this.#guardTransactionalLease(registration, delivery, controller) : this.#startHeartbeat(delivery, controller);
830
1414
  const outcome = await this.#runHandler(registration, delivery, controller);
1415
+ const durationMs = clock.now().getTime() - startedAt;
831
1416
  if (outcome === "completed") {
832
- notifier.notify("onDeliverySucceeded", {
1417
+ notifier.notify("onDeliverySucceeded", { delivery, durationMs });
1418
+ } else {
1419
+ this.#retain({
833
1420
  delivery,
834
- durationMs: clock.now().getTime() - startedAt
1421
+ settle: () => this.#complete(delivery),
1422
+ applied: () => notifier.notify("onDeliverySucceeded", { delivery, durationMs })
835
1423
  });
836
1424
  }
1425
+ return true;
837
1426
  } catch (error) {
838
1427
  if (error instanceof LeaseLostError) {
839
1428
  notifier.notify("onLeaseLost", { delivery });
1429
+ if (!error.storeConfirmed) {
1430
+ await this.#giveBack(registration, delivery, error);
1431
+ }
840
1432
  } else if (error instanceof DispatcherStoppedError) {
841
1433
  await this.#release(delivery);
842
1434
  } else {
843
1435
  await this.#fail(registration, delivery, error);
844
1436
  }
1437
+ return false;
845
1438
  } finally {
846
1439
  stopHeartbeat();
847
1440
  }
@@ -858,7 +1451,9 @@ var Dispatcher = class {
858
1451
  await store.transaction(async (transaction) => {
859
1452
  await this.#invoke(registration, event, delivery, controller, transaction);
860
1453
  if (!await store.complete(delivery, clock.now(), transaction)) {
861
- throw new LeaseLostError(`Lease on delivery ${delivery.deliveryId} was lost`);
1454
+ throw new LeaseLostError(`Lease on delivery ${delivery.deliveryId} was lost`, {
1455
+ storeConfirmed: true
1456
+ });
862
1457
  }
863
1458
  });
864
1459
  return "completed";
@@ -871,11 +1466,11 @@ var Dispatcher = class {
871
1466
  * still held. Gives up to lease expiry, which reruns the handler: at-least-once.
872
1467
  */
873
1468
  async #confirmCompletion(delivery) {
874
- const { store, clock, notifier } = this.#dependencies;
1469
+ const { notifier } = this.#dependencies;
875
1470
  for (let attempt = 0; ; attempt += 1) {
876
1471
  let completed;
877
1472
  try {
878
- completed = await store.complete(delivery, clock.now());
1473
+ completed = await this.#complete(delivery);
879
1474
  } catch (error) {
880
1475
  const delay = COMPLETION_RETRY_DELAYS_MS[attempt];
881
1476
  if (delay === void 0) {
@@ -886,11 +1481,18 @@ var Dispatcher = class {
886
1481
  continue;
887
1482
  }
888
1483
  if (!completed) {
889
- throw new LeaseLostError(`Lease on delivery ${delivery.deliveryId} was lost`);
1484
+ throw new LeaseLostError(`Lease on delivery ${delivery.deliveryId} was lost`, {
1485
+ storeConfirmed: true
1486
+ });
890
1487
  }
891
1488
  return "completed";
892
1489
  }
893
1490
  }
1491
+ /** Records one completion, grouped with its neighbours when the store can complete many at once. */
1492
+ #complete(delivery) {
1493
+ const { store, clock } = this.#dependencies;
1494
+ return this.#completions === void 0 ? store.complete(delivery, clock.now()) : this.#completions.submit(delivery);
1495
+ }
894
1496
  async #invoke(registration, event, delivery, controller, transaction) {
895
1497
  const context = {
896
1498
  deliveryId: delivery.deliveryId,
@@ -964,7 +1566,9 @@ var Dispatcher = class {
964
1566
  if (registration.timeoutMs !== null && now.getTime() + registration.timeoutMs >= expiry) {
965
1567
  expiry = now.getTime() + registration.timeoutMs + settings.leaseDurationMs;
966
1568
  if (!await store.extendLease(delivery, new Date(expiry), now)) {
967
- throw new LeaseLostError(`Lease on delivery ${delivery.deliveryId} was lost`);
1569
+ throw new LeaseLostError(`Lease on delivery ${delivery.deliveryId} was lost`, {
1570
+ storeConfirmed: true
1571
+ });
968
1572
  }
969
1573
  }
970
1574
  const timer = setTimeout(
@@ -985,8 +1589,10 @@ var Dispatcher = class {
985
1589
  const { store, clock, notifier, settings } = this.#dependencies;
986
1590
  let confirmedExpiry = delivery.leaseExpiresAt.getTime();
987
1591
  let renewing = false;
988
- const abortLeaseLost = () => {
989
- controller.abort(new LeaseLostError(`Lease on delivery ${delivery.deliveryId} was lost`));
1592
+ const abortLeaseLost = (storeConfirmed = false) => {
1593
+ controller.abort(
1594
+ new LeaseLostError(`Lease on delivery ${delivery.deliveryId} was lost`, { storeConfirmed })
1595
+ );
990
1596
  };
991
1597
  const timer = setInterval(
992
1598
  () => {
@@ -1004,7 +1610,7 @@ var Dispatcher = class {
1004
1610
  if (extended) {
1005
1611
  confirmedExpiry = expiry;
1006
1612
  } else {
1007
- abortLeaseLost();
1613
+ abortLeaseLost(true);
1008
1614
  }
1009
1615
  }).catch((error) => notifier.failure("extend-lease", error)).finally(() => {
1010
1616
  renewing = false;
@@ -1016,12 +1622,17 @@ var Dispatcher = class {
1016
1622
  return () => clearInterval(timer);
1017
1623
  }
1018
1624
  async #fail(registration, delivery, error) {
1019
- const { store, clock, random, notifier } = this.#dependencies;
1020
1625
  const policy = registration.retry;
1021
1626
  if (delivery.attempt >= policy.attempts || !isRetryableError(policy, error)) {
1022
1627
  await this.#bury(delivery, error);
1023
1628
  return;
1024
1629
  }
1630
+ await this.#reschedule(registration, delivery, error);
1631
+ }
1632
+ /** Schedules the next attempt with the retry policy's backoff. */
1633
+ async #reschedule(registration, delivery, error) {
1634
+ const { store, clock, random, notifier } = this.#dependencies;
1635
+ const policy = registration.retry;
1025
1636
  const errorInfo = toErrorInfo(error);
1026
1637
  const now = clock.now();
1027
1638
  const availableAt = new Date(
@@ -1032,6 +1643,11 @@ var Dispatcher = class {
1032
1643
  rescheduled = await store.reschedule(delivery, availableAt, errorInfo, now);
1033
1644
  } catch (storeError) {
1034
1645
  notifier.failure("reschedule", storeError);
1646
+ this.#retain({
1647
+ delivery,
1648
+ settle: () => store.reschedule(delivery, availableAt, errorInfo, now),
1649
+ applied: () => notifier.notify("onDeliveryRetryScheduled", { delivery, error: errorInfo, availableAt })
1650
+ });
1035
1651
  return;
1036
1652
  }
1037
1653
  if (rescheduled) {
@@ -1048,6 +1664,11 @@ var Dispatcher = class {
1048
1664
  buried = await store.bury(delivery, errorInfo, clock.now());
1049
1665
  } catch (storeError) {
1050
1666
  notifier.failure("bury", storeError);
1667
+ this.#retain({
1668
+ delivery,
1669
+ settle: () => store.bury(delivery, errorInfo, clock.now()),
1670
+ applied: () => notifier.notify("onDeliveryDead", { delivery, error: errorInfo })
1671
+ });
1051
1672
  return;
1052
1673
  }
1053
1674
  if (buried) {
@@ -1056,6 +1677,21 @@ var Dispatcher = class {
1056
1677
  notifier.notify("onLeaseLost", { delivery });
1057
1678
  }
1058
1679
  }
1680
+ /**
1681
+ * A worker that only noticed locally that its lease ran out may still own the delivery in the
1682
+ * store, where other deliveries of its lane keep renewing the lane: it must not stay in flight
1683
+ * for ever. The attempt took longer than its lease, so it counts as a failed attempt (an
1684
+ * exhausted delivery is dead-lettered, whatever a custom `retryIf` says): a handler that always
1685
+ * outlives its lease cannot run for ever at attempt 1. If the lease is gone for real the store
1686
+ * refuses and nothing changes. The lost lease was reported already.
1687
+ */
1688
+ async #giveBack(registration, delivery, error) {
1689
+ if (delivery.attempt >= registration.retry.attempts) {
1690
+ await this.#bury(delivery, error);
1691
+ } else {
1692
+ await this.#reschedule(registration, delivery, error);
1693
+ }
1694
+ }
1059
1695
  async #release(delivery) {
1060
1696
  const { store, clock, notifier } = this.#dependencies;
1061
1697
  let released;
@@ -1063,6 +1699,11 @@ var Dispatcher = class {
1063
1699
  released = await store.release(delivery, clock.now());
1064
1700
  } catch (storeError) {
1065
1701
  notifier.failure("release", storeError);
1702
+ this.#retain({
1703
+ delivery,
1704
+ settle: () => store.release(delivery, clock.now()),
1705
+ applied: () => notifier.notify("onDeliveryReleased", { delivery })
1706
+ });
1066
1707
  return;
1067
1708
  }
1068
1709
  if (released) {
@@ -1072,6 +1713,9 @@ var Dispatcher = class {
1072
1713
  }
1073
1714
  }
1074
1715
  };
1716
+ function pendingKey(delivery) {
1717
+ return `${delivery.deliveryId}\0${delivery.leaseToken}`;
1718
+ }
1075
1719
  function rejectOnAbort(signal) {
1076
1720
  return new Promise((_, reject) => {
1077
1721
  signal.addEventListener("abort", () => reject(signal.reason), { once: true });
@@ -1196,6 +1840,8 @@ var DEFAULTS = {
1196
1840
  pollInterval: "1s",
1197
1841
  leaseDuration: "30s",
1198
1842
  shutdownTimeout: "30s",
1843
+ completionBatchWindow: "2ms",
1844
+ completionBatchSize: 200,
1199
1845
  completedRetention: "7d",
1200
1846
  retentionInterval: "1h",
1201
1847
  routeTimeoutMs: 3e4
@@ -1218,6 +1864,7 @@ var RelayBus = class {
1218
1864
  #generateIdentifier;
1219
1865
  #dispatcher;
1220
1866
  #dispatcherEnabled;
1867
+ #catalogRequirements = [];
1221
1868
  #setupStore;
1222
1869
  #retention;
1223
1870
  #retentionTimer;
@@ -1264,6 +1911,9 @@ var RelayBus = class {
1264
1911
  throw new ConfigurationError(`Transport '${transport.name}' cannot subscribe`);
1265
1912
  }
1266
1913
  }
1914
+ for (const catalog of options.catalogs ?? []) {
1915
+ this.registerCatalog(catalog);
1916
+ }
1267
1917
  this.#dispatcherEnabled = options.dispatcher?.enabled ?? true;
1268
1918
  this.#dispatcher = new Dispatcher({
1269
1919
  store: this.#store,
@@ -1305,6 +1955,28 @@ var RelayBus = class {
1305
1955
  this.#defaults
1306
1956
  );
1307
1957
  }
1958
+ subscribeCatalog(catalog, handlers, options = {}) {
1959
+ for (const binding of resolveCatalogHandlers(catalog, handlers, options)) {
1960
+ this.subscribe(binding.eventClass, binding.handle, binding.options);
1961
+ }
1962
+ }
1963
+ /**
1964
+ * Adds a catalog, or a `consume` / `produce` declaration, to the checks
1965
+ * `start()` runs; `start()` throws a {@link ConfigurationError} naming each
1966
+ * event that is not covered and what it lacks. Equivalent to the `catalogs`
1967
+ * option.
1968
+ */
1969
+ registerCatalog(catalog) {
1970
+ this.#catalogRequirements.push(toCatalogRequirement(catalog));
1971
+ }
1972
+ /**
1973
+ * A view of this bus that publishes only the events of `catalog`, or only
1974
+ * the events named in `options.events` (for example the events this service
1975
+ * produces). Other events fail to compile and are rejected at runtime.
1976
+ */
1977
+ forCatalog(catalog, options = {}) {
1978
+ return new CatalogPublisher(catalog, this, options);
1979
+ }
1308
1980
  /** Registered handlers and routes, for diagnostics. */
1309
1981
  handlers() {
1310
1982
  return this.#registry.all().map(toDescriptor);
@@ -1370,6 +2042,7 @@ var RelayBus = class {
1370
2042
  this.#state === "stopped" ? "A stopped relaybus cannot be started again; create a new instance" : `Cannot start a relaybus that is ${this.#state}`
1371
2043
  );
1372
2044
  }
2045
+ assertCatalogsCovered(this.#catalogRequirements, (type) => this.#registrationsOf(type));
1373
2046
  this.#state = "starting";
1374
2047
  this.#starting = this.#startComponents();
1375
2048
  try {
@@ -1389,6 +2062,10 @@ var RelayBus = class {
1389
2062
  if (this.#state === "starting") {
1390
2063
  await this.#starting.catch(() => void 0);
1391
2064
  }
2065
+ if (this.#state === "idle") {
2066
+ await this.#dispatcher.stop();
2067
+ return;
2068
+ }
1392
2069
  if (this.#state !== "running") {
1393
2070
  return;
1394
2071
  }
@@ -1581,6 +2258,11 @@ var RelayBus = class {
1581
2258
  #createDelivery(registration, availableAt) {
1582
2259
  return { id: this.#generateIdentifier(), handler: registration.id, availableAt };
1583
2260
  }
2261
+ #registrationsOf(eventType) {
2262
+ const registrations = this.#registry.forEventType(eventType);
2263
+ const routes = registrations.filter((registration) => registration.forwardsToTransport).length;
2264
+ return { handlers: registrations.length - routes, routes };
2265
+ }
1584
2266
  #registerRoute(route) {
1585
2267
  const transport = this.#requireTransport(route.transport);
1586
2268
  const { type } = describeEvent(route.event);
@@ -1608,7 +2290,7 @@ var RelayBus = class {
1608
2290
  return transport;
1609
2291
  }
1610
2292
  #scheduleWake() {
1611
- if (this.#state !== "running") {
2293
+ if (this.#state !== "running" || this.#dispatcher.watchesNewDeliveries) {
1612
2294
  return;
1613
2295
  }
1614
2296
  setTimeout(() => this.#dispatcher.wake(), WAKE_DELAY_MS).unref();
@@ -1713,6 +2395,14 @@ function createDispatcherSettings(options, generateIdentifier) {
1713
2395
  shutdownTimeoutMs: toTimerMilliseconds(
1714
2396
  options?.shutdownTimeout ?? DEFAULTS.shutdownTimeout,
1715
2397
  "dispatcher.shutdownTimeout"
2398
+ ),
2399
+ completionBatchWindowMs: toMilliseconds(
2400
+ options?.completionBatchWindow ?? DEFAULTS.completionBatchWindow,
2401
+ "dispatcher.completionBatchWindow"
2402
+ ),
2403
+ completionBatchSize: positiveInteger(
2404
+ options?.completionBatchSize ?? DEFAULTS.completionBatchSize,
2405
+ "dispatcher.completionBatchSize"
1716
2406
  )
1717
2407
  };
1718
2408
  }
@@ -1729,6 +2419,7 @@ function createRetentionSettings(options) {
1729
2419
  };
1730
2420
  }
1731
2421
 
2422
+ exports.CatalogPublisher = CatalogPublisher;
1732
2423
  exports.ConfigurationError = ConfigurationError;
1733
2424
  exports.DEFAULT_RETRY_POLICY = DEFAULT_RETRY_POLICY;
1734
2425
  exports.DEFAULT_ROUTE_RETRY_POLICY = DEFAULT_ROUTE_RETRY_POLICY;
@@ -1747,10 +2438,15 @@ exports.RelayBusError = RelayBusError;
1747
2438
  exports.TransactionRequiredError = TransactionRequiredError;
1748
2439
  exports.assertEventType = assertEventType;
1749
2440
  exports.computeRetryDelay = computeRetryDelay;
2441
+ exports.consume = consume;
1750
2442
  exports.createRetryPolicy = createRetryPolicy;
1751
2443
  exports.createUuidV7 = createUuidV7;
2444
+ exports.defineCatalog = defineCatalog;
1752
2445
  exports.defineEvent = defineEvent;
2446
+ exports.defineHandlers = defineHandlers;
2447
+ exports.describeCatalog = describeCatalog;
1753
2448
  exports.isRetryableError = isRetryableError;
2449
+ exports.produce = produce;
1754
2450
  exports.systemClock = systemClock;
1755
2451
  exports.toErrorInfo = toErrorInfo;
1756
2452
  exports.toMilliseconds = toMilliseconds;