@open-mercato/events 0.6.8-develop.6918.1.7b3f32b0c8 → 0.6.8-develop.6925.1.cefa200fa4
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/AGENTS.md +7 -3
- package/agentic/standalone-guide.md +8 -1
- package/dist/bus.js +67 -3
- package/dist/bus.js.map +2 -2
- package/dist/modules/events/workers/events.worker.js +52 -60
- package/dist/modules/events/workers/events.worker.js.map +2 -2
- package/dist/single-delivery.js +1 -1
- package/dist/single-delivery.js.map +2 -2
- package/package.json +3 -3
- package/src/__tests__/dispatch-queued.test.ts +191 -0
- package/src/__tests__/queued-delivery-roundtrip.test.ts +178 -0
- package/src/__tests__/single-delivery.test.ts +63 -8
- package/src/bus.ts +141 -8
- package/src/modules/events/workers/__tests__/events.worker.test.ts +360 -391
- package/src/modules/events/workers/events.worker.ts +104 -92
- package/src/single-delivery.ts +29 -13
- package/src/types.ts +53 -0
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
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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<
|
|
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) &&
|
|
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({
|
|
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
|
}
|