@relaybus/core 0.1.0 → 0.2.0

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