@open-mercato/events 0.6.8-develop.6917.1.af45bc96e2 → 0.6.8-develop.6924.1.a8d208fcdc

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/src/bus.ts CHANGED
@@ -19,6 +19,7 @@ import type {
19
19
  SubscriberDescriptor,
20
20
  EventPayload,
21
21
  EmitOptions,
22
+ QueuedDispatchResult,
22
23
  } from './types'
23
24
 
24
25
  /** Queue name for persistent events */
@@ -38,10 +39,16 @@ type RegisteredSubscriber = {
38
39
  * When enabled, a persistent emit delivers each subscriber on exactly one path:
39
40
  * persistent-marked subscribers are skipped inline (the events worker dispatches
40
41
  * them via pattern match, so wildcard persistent subscribers are reached), while
41
- * ephemeral subscribers keep running inline. Defaults ON; the server bootstrap
42
- * reconciles the env against worker availability (and may rewrite it to `false`
43
- * for a worker-less process). Both the bus and the events worker read the same
44
- * (possibly reconciled) env var, so they always agree within a process.
42
+ * ephemeral subscribers keep running inline. Defaults ON.
43
+ *
44
+ * The bus never second-guesses this against "is a worker running": the queue is
45
+ * durable, so a persistent emit with no worker yet is delayed, not lost, and a
46
+ * worker started later drains the backlog. Delivering inline instead would move
47
+ * the work back onto the caller's request path — exactly what a split app/worker
48
+ * deployment sets `AUTO_SPAWN_WORKERS=false` to avoid.
49
+ *
50
+ * The events worker asks this same bus for its subscribers via `dispatchQueued`,
51
+ * so both halves of single-delivery read one decision.
45
52
  */
46
53
  function isSingleDeliveryEnabled(): boolean {
47
54
  return isSingleDeliveryRequested()
@@ -78,6 +85,14 @@ type EventJobData = {
78
85
  event: string
79
86
  payload: EventPayload
80
87
  options?: EmitOptions
88
+ /**
89
+ * Set by the producer when it already ran the persistent subscribers inline
90
+ * (single-delivery reconciled off). The worker skips such jobs, so the app and
91
+ * the worker staying exactly-once no longer depends on both processes agreeing
92
+ * about `OM_EVENTS_SINGLE_DELIVERY`. Absent on jobs queued before this field
93
+ * existed, which keeps their behavior unchanged.
94
+ */
95
+ persistentDeliveredInline?: boolean
81
96
  }
82
97
 
83
98
  // Process-wide cache of the async (BullMQ) persistent-events producer queue.
@@ -201,13 +216,20 @@ export function createEventBus(opts: CreateBusOptions): EventBus {
201
216
  /**
202
217
  * Delivers an event to all registered in-memory handlers.
203
218
  * Supports wildcard pattern matching for event patterns.
219
+ *
220
+ * Handler errors are logged and delivery continues - one bad subscriber must
221
+ * not stop the others. The count of failed PERSISTENT handlers is returned so
222
+ * the caller can decide whether the inline run may be recorded as complete:
223
+ * a persistent subscriber that threw inline has no retry of its own, so the
224
+ * queued job must stay unstamped and let the worker run it again.
204
225
  */
205
226
  async function deliver(
206
227
  event: string,
207
228
  payload: EventPayload,
208
229
  options?: EmitOptions,
209
230
  skipPersistent = false,
210
- ): Promise<void> {
231
+ ): Promise<{ persistentFailures: number }> {
232
+ let persistentFailures = 0
211
233
  // Check all registered patterns (including wildcards)
212
234
  for (const [pattern, handlers] of listeners) {
213
235
  if (!matchEventPattern(event, pattern)) continue
@@ -235,6 +257,9 @@ export function createEventBus(opts: CreateBusOptions): EventBus {
235
257
  }),
236
258
  )
237
259
  } catch (error) {
260
+ if (persistentSubscribers.has(subscriber)) {
261
+ persistentFailures += 1
262
+ }
238
263
  logger.error('Handler error', { event, pattern, err: error })
239
264
  if (options?.rethrowHandlerErrors) {
240
265
  throw error
@@ -242,6 +267,97 @@ export function createEventBus(opts: CreateBusOptions): EventBus {
242
267
  }
243
268
  }
244
269
  }
270
+ return { persistentFailures }
271
+ }
272
+
273
+ /**
274
+ * Selects the subscribers the events worker owns for a queued event: every
275
+ * pattern matching `event`, keeping persistent subscribers only, so wildcard
276
+ * (`event: '*'`) persistent subscribers are reached exactly once here.
277
+ *
278
+ * Deliberately does NOT read `OM_EVENTS_SINGLE_DELIVERY`. Whether inline
279
+ * delivery already happened is carried by the job's `persistentDeliveredInline`
280
+ * stamp, so the producer owns that decision entirely and the worker cannot
281
+ * disagree with it across processes. Reading the flag here would reintroduce
282
+ * exactly that skew: a worker whose env has the flag off would fall back to
283
+ * exact-match, re-running ephemeral subscribers the producer already ran inline
284
+ * and never reaching wildcard persistent subscribers - the failure this whole
285
+ * change exists to remove.
286
+ *
287
+ * Every job that reaches this point wants persistent-pattern selection: a job
288
+ * is only dispatched when its stamp is false, which means either the producer
289
+ * had single-delivery on, or the emit was enqueue-only (`deliverInline: false`,
290
+ * documented as "every subscriber to the event is persistent").
291
+ */
292
+ function selectQueuedSubscribers(event: string): RegisteredSubscriber[] {
293
+ const matched: RegisteredSubscriber[] = []
294
+ for (const [pattern, handlers] of listeners) {
295
+ if (!matchEventPattern(event, pattern)) {
296
+ continue
297
+ }
298
+ for (const subscriber of handlers) {
299
+ if (subscriber.persistent) {
300
+ matched.push(subscriber)
301
+ }
302
+ }
303
+ }
304
+ return matched
305
+ }
306
+
307
+ /**
308
+ * Dispatches a queued event on behalf of the events worker.
309
+ *
310
+ * Handler failures are returned instead of logged-and-swallowed the way
311
+ * `deliver()` does them: the worker needs them to propagate so the queue retries
312
+ * the job and eventually dead-letters it.
313
+ *
314
+ * Subscribers run against the caller's `resolve` when one is given, falling back
315
+ * to the bus's own. The events worker passes its per-job `ctx.resolve`, which is
316
+ * the container `createPerJobWorkerHandler` built for that job - the handle the
317
+ * queue contract already hands it. Defaulting to `opts.resolve` instead would
318
+ * bind subscribers to whichever container built the bus; that is the same object
319
+ * on the default configuration, but not under `OM_BOOTSTRAP_CACHE`, where the
320
+ * cached bus is replayed into later containers while its captured resolver stays
321
+ * bound to the first.
322
+ */
323
+ async function dispatchQueued(
324
+ event: string,
325
+ payload: EventPayload,
326
+ options?: EmitOptions,
327
+ resolve?: <T = unknown>(name: string) => T,
328
+ ): Promise<QueuedDispatchResult[]> {
329
+ const subscribers = selectQueuedSubscribers(event)
330
+ if (subscribers.length === 0) {
331
+ return []
332
+ }
333
+
334
+ const resolver = resolve ?? opts.resolve
335
+
336
+ const settled = await Promise.allSettled(
337
+ subscribers.map((subscriber) =>
338
+ withModuleResourceUsage(
339
+ {
340
+ moduleId: subscriber.moduleId ?? inferModuleIdFromResourceId(subscriber.id),
341
+ surface: 'subscriber',
342
+ operation: `${event} -> ${subscriber.id ?? subscriber.event}`,
343
+ resourceId: subscriber.id ?? subscriber.event,
344
+ },
345
+ () => subscriber.handler(payload, {
346
+ resolve: resolver,
347
+ eventName: event,
348
+ tenantId: options?.tenantId ?? null,
349
+ organizationId: options?.organizationId ?? null,
350
+ }),
351
+ ),
352
+ ),
353
+ )
354
+
355
+ return settled.map((result, index) => {
356
+ const subscriberId = subscribers[index].id ?? subscribers[index].event
357
+ return result.status === 'rejected'
358
+ ? { subscriberId, ok: false, error: result.reason }
359
+ : { subscriberId, ok: true }
360
+ })
245
361
  }
246
362
 
247
363
  /**
@@ -309,9 +425,13 @@ export function createEventBus(opts: CreateBusOptions): EventBus {
309
425
  // happens). Without this, a persistent emit dual-dispatches by default and
310
426
  // runs the subscriber inline in the caller's request too.
311
427
  const enqueueOnly = Boolean(options?.persistent) && options?.deliverInline === false
428
+ // Read the mode once so the inline skip and the stamp below cannot disagree.
429
+ const singleDelivery = isSingleDeliveryEnabled()
430
+ let inlinePersistentFailed = false
312
431
  if (!enqueueOnly) {
313
- const skipPersistentInline = Boolean(options?.persistent) && isSingleDeliveryEnabled()
314
- await deliver(event, payload, options, skipPersistentInline)
432
+ const skipPersistentInline = Boolean(options?.persistent) && singleDelivery
433
+ const delivered = await deliver(event, payload, options, skipPersistentInline)
434
+ inlinePersistentFailed = delivered.persistentFailures > 0
315
435
  }
316
436
 
317
437
  if (isBroadcastEvent(event) && hasTenantScope(payload)) {
@@ -325,7 +445,19 @@ export function createEventBus(opts: CreateBusOptions): EventBus {
325
445
  // If persistent, also enqueue for async processing
326
446
  if (options?.persistent) {
327
447
  const q = getQueue()
328
- await q.enqueue({ event, payload, options })
448
+ await q.enqueue({
449
+ event,
450
+ payload,
451
+ options,
452
+ // Only record the inline run as complete when every persistent
453
+ // subscriber actually succeeded. Inline delivery logs-and-continues, so a
454
+ // handler that threw has no retry of its own; leaving the job unstamped
455
+ // hands it to the worker, where a failure gets queue retry and
456
+ // dead-lettering. Without this, flipping the flag off (which the
457
+ // `mercato server` guard can do automatically) silently downgrades
458
+ // persistent delivery to at-most-once.
459
+ persistentDeliveredInline: !enqueueOnly && !singleDelivery && !inlinePersistentFailed,
460
+ })
329
461
  }
330
462
  }
331
463
 
@@ -345,6 +477,7 @@ export function createEventBus(opts: CreateBusOptions): EventBus {
345
477
  emitEvent, // Alias for backward compatibility
346
478
  on,
347
479
  registerModuleSubscribers,
480
+ dispatchQueued,
348
481
  clearQueue,
349
482
  }
350
483
  }