@open-mercato/events 0.6.8-develop.6918.1.7b3f32b0c8 → 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/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/AGENTS.md
CHANGED
|
@@ -83,9 +83,11 @@ Single-delivery is the default. A persistent emit is delivered on exactly one pa
|
|
|
83
83
|
- the bus skips inline delivery of **persistent-marked** subscribers on a persistent emit (ephemeral subscribers still run inline — read-your-writes paths like `query_index.upsert_one` are `persistent: false` and are unaffected);
|
|
84
84
|
- the events worker dispatches **persistent** subscribers via `matchEventPattern`, so wildcard (`event: '*'`) persistent subscribers (workflow triggers, business-rules CRUD trigger, webhook outbound dispatch) are reached.
|
|
85
85
|
|
|
86
|
-
This avoids the legacy dual-dispatch
|
|
86
|
+
This avoids the legacy dual-dispatch, where persistent emits ran inline **and** in the worker - double-running exact-match persistent subscribers (duplicate notifications/emails) and never reaching wildcard persistent subscribers in the worker. Both halves ask the SAME bus (`EventBus.dispatchQueued`), so they cannot disagree within a process.
|
|
87
87
|
|
|
88
|
-
**
|
|
88
|
+
**No worker yet is not the same as lost.** With single-delivery on, persistent subscribers run ONLY in the worker. The bus does not second-guess that against "is a worker running": the queue is durable, so a persistent emit with no worker yet is delayed, not dropped, and a worker started later drains the backlog. Delivering inline instead would move the work back onto the caller's request path - exactly what a split app/worker deployment sets `AUTO_SPAWN_WORKERS=false` to avoid. The `mercato server`/`start` bootstrap still reconciles the flag for a process it *knows* runs no worker (`reconcileSingleDelivery`, mirrored in `packages/cli/src/lib/events-single-delivery.ts`); when it does, the bus stamps the queued job `persistentDeliveredInline: true` so a worker draining the queue skips it. Delivery is exactly-once on either path when handlers succeed; see the failure note below.
|
|
89
|
+
|
|
90
|
+
**Failure on the inline path.** Inline delivery logs each handler error and continues, so a persistent subscriber that throws inline has no retry of its own. The producer therefore stamps `persistentDeliveredInline` only when every persistent subscriber succeeded; if one threw, the job stays unstamped and the worker runs it with queue retry + dead-lettering. A retry re-runs the persistent subscribers that already succeeded, which is why persistent subscribers MUST be idempotent.
|
|
89
91
|
|
|
90
92
|
**Enqueue-only emits.** Pass `{ persistent: true, deliverInline: false }` to hand a heavy persistent job (e.g. a full query-index rebuild) to the durable queue without ANY inline delivery, independent of the single-delivery flag. Only use it when every subscriber to the event is `persistent: true`. This is the "Ask First: changing persistent delivery semantics" surface — coordinate before altering these defaults.
|
|
91
93
|
|
|
@@ -99,7 +101,7 @@ This avoids the legacy dual-dispatch (set `OM_EVENTS_SINGLE_DELIVERY=false` to o
|
|
|
99
101
|
When `QUEUE_STRATEGY=async`, persistent event workers run as background processes. Start them with:
|
|
100
102
|
|
|
101
103
|
```bash
|
|
102
|
-
yarn mercato
|
|
104
|
+
yarn mercato queue worker events --concurrency=5
|
|
103
105
|
```
|
|
104
106
|
|
|
105
107
|
## Structure
|
|
@@ -116,6 +118,8 @@ packages/events/src/
|
|
|
116
118
|
|
|
117
119
|
Workers in `modules/events/workers/` handle async event processing. Follow the standard worker contract: export default handler + `metadata` with `{ queue, id?, concurrency? }`.
|
|
118
120
|
|
|
121
|
+
The events worker MUST NOT keep a subscriber registry of its own - it resolves `eventBus` from the per-job DI container and calls `dispatchQueued`. It previously built one from `getCliModules()`, which only the `mercato` bin populates, so a worker started any other way dispatched zero subscribers and completed the job silently. When no bus is resolvable it now **throws**, so the job retries and dead-letters with a visible cause.
|
|
122
|
+
|
|
119
123
|
## Testing
|
|
120
124
|
|
|
121
125
|
- Tests inside `packages/events` SHOULD import the public `@open-mercato/events/...` API when validating package behavior
|
|
@@ -113,5 +113,12 @@ Missing `tenantId` in payload = no delivery.
|
|
|
113
113
|
|
|
114
114
|
For production, start the event worker:
|
|
115
115
|
```bash
|
|
116
|
-
yarn mercato
|
|
116
|
+
yarn mercato queue worker events --concurrency=5
|
|
117
117
|
```
|
|
118
|
+
|
|
119
|
+
If the app and the worker run as separate services, make sure the worker service actually drains the
|
|
120
|
+
`events` queue. When the app is started through `mercato server start` with `AUTO_SPAWN_WORKERS=false`,
|
|
121
|
+
that bootstrap rewrites `OM_EVENTS_SINGLE_DELIVERY=false` (running subscribers inline) unless you set
|
|
122
|
+
`OM_EVENTS_EXTERNAL_WORKER=true` to acknowledge the out-of-process worker. The event bus itself never
|
|
123
|
+
second-guesses worker availability - the queue is durable, so an emit with no worker is delayed rather
|
|
124
|
+
than lost.
|
package/dist/bus.js
CHANGED
|
@@ -92,6 +92,7 @@ function createEventBus(opts) {
|
|
|
92
92
|
return shared;
|
|
93
93
|
}
|
|
94
94
|
async function deliver(event, payload, options, skipPersistent = false) {
|
|
95
|
+
let persistentFailures = 0;
|
|
95
96
|
for (const [pattern, handlers] of listeners) {
|
|
96
97
|
if (!matchEventPattern(event, pattern)) continue;
|
|
97
98
|
if (!handlers || handlers.size === 0) continue;
|
|
@@ -114,6 +115,9 @@ function createEventBus(opts) {
|
|
|
114
115
|
})
|
|
115
116
|
);
|
|
116
117
|
} catch (error) {
|
|
118
|
+
if (persistentSubscribers.has(subscriber)) {
|
|
119
|
+
persistentFailures += 1;
|
|
120
|
+
}
|
|
117
121
|
logger.error("Handler error", { event, pattern, err: error });
|
|
118
122
|
if (options?.rethrowHandlerErrors) {
|
|
119
123
|
throw error;
|
|
@@ -121,6 +125,50 @@ function createEventBus(opts) {
|
|
|
121
125
|
}
|
|
122
126
|
}
|
|
123
127
|
}
|
|
128
|
+
return { persistentFailures };
|
|
129
|
+
}
|
|
130
|
+
function selectQueuedSubscribers(event) {
|
|
131
|
+
const matched = [];
|
|
132
|
+
for (const [pattern, handlers] of listeners) {
|
|
133
|
+
if (!matchEventPattern(event, pattern)) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
for (const subscriber of handlers) {
|
|
137
|
+
if (subscriber.persistent) {
|
|
138
|
+
matched.push(subscriber);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return matched;
|
|
143
|
+
}
|
|
144
|
+
async function dispatchQueued(event, payload, options, resolve) {
|
|
145
|
+
const subscribers = selectQueuedSubscribers(event);
|
|
146
|
+
if (subscribers.length === 0) {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
const resolver = resolve ?? opts.resolve;
|
|
150
|
+
const settled = await Promise.allSettled(
|
|
151
|
+
subscribers.map(
|
|
152
|
+
(subscriber) => withModuleResourceUsage(
|
|
153
|
+
{
|
|
154
|
+
moduleId: subscriber.moduleId ?? inferModuleIdFromResourceId(subscriber.id),
|
|
155
|
+
surface: "subscriber",
|
|
156
|
+
operation: `${event} -> ${subscriber.id ?? subscriber.event}`,
|
|
157
|
+
resourceId: subscriber.id ?? subscriber.event
|
|
158
|
+
},
|
|
159
|
+
() => subscriber.handler(payload, {
|
|
160
|
+
resolve: resolver,
|
|
161
|
+
eventName: event,
|
|
162
|
+
tenantId: options?.tenantId ?? null,
|
|
163
|
+
organizationId: options?.organizationId ?? null
|
|
164
|
+
})
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
);
|
|
168
|
+
return settled.map((result, index) => {
|
|
169
|
+
const subscriberId = subscribers[index].id ?? subscribers[index].event;
|
|
170
|
+
return result.status === "rejected" ? { subscriberId, ok: false, error: result.reason } : { subscriberId, ok: true };
|
|
171
|
+
});
|
|
124
172
|
}
|
|
125
173
|
function on(event, handler, options) {
|
|
126
174
|
if (!listeners.has(event)) {
|
|
@@ -153,9 +201,12 @@ function createEventBus(opts) {
|
|
|
153
201
|
}
|
|
154
202
|
}
|
|
155
203
|
const enqueueOnly = Boolean(options?.persistent) && options?.deliverInline === false;
|
|
204
|
+
const singleDelivery = isSingleDeliveryEnabled();
|
|
205
|
+
let inlinePersistentFailed = false;
|
|
156
206
|
if (!enqueueOnly) {
|
|
157
|
-
const skipPersistentInline = Boolean(options?.persistent) &&
|
|
158
|
-
await deliver(event, payload, options, skipPersistentInline);
|
|
207
|
+
const skipPersistentInline = Boolean(options?.persistent) && singleDelivery;
|
|
208
|
+
const delivered = await deliver(event, payload, options, skipPersistentInline);
|
|
209
|
+
inlinePersistentFailed = delivered.persistentFailures > 0;
|
|
159
210
|
}
|
|
160
211
|
if (isBroadcastEvent(event) && hasTenantScope(payload)) {
|
|
161
212
|
try {
|
|
@@ -166,7 +217,19 @@ function createEventBus(opts) {
|
|
|
166
217
|
}
|
|
167
218
|
if (options?.persistent) {
|
|
168
219
|
const q = getQueue();
|
|
169
|
-
await q.enqueue({
|
|
220
|
+
await q.enqueue({
|
|
221
|
+
event,
|
|
222
|
+
payload,
|
|
223
|
+
options,
|
|
224
|
+
// Only record the inline run as complete when every persistent
|
|
225
|
+
// subscriber actually succeeded. Inline delivery logs-and-continues, so a
|
|
226
|
+
// handler that threw has no retry of its own; leaving the job unstamped
|
|
227
|
+
// hands it to the worker, where a failure gets queue retry and
|
|
228
|
+
// dead-lettering. Without this, flipping the flag off (which the
|
|
229
|
+
// `mercato server` guard can do automatically) silently downgrades
|
|
230
|
+
// persistent delivery to at-most-once.
|
|
231
|
+
persistentDeliveredInline: !enqueueOnly && !singleDelivery && !inlinePersistentFailed
|
|
232
|
+
});
|
|
170
233
|
}
|
|
171
234
|
}
|
|
172
235
|
async function clearQueue() {
|
|
@@ -180,6 +243,7 @@ function createEventBus(opts) {
|
|
|
180
243
|
// Alias for backward compatibility
|
|
181
244
|
on,
|
|
182
245
|
registerModuleSubscribers,
|
|
246
|
+
dispatchQueued,
|
|
183
247
|
clearQueue
|
|
184
248
|
};
|
|
185
249
|
}
|
package/dist/bus.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/bus.ts"],
|
|
4
|
-
"sourcesContent": ["import { createQueue } from '@open-mercato/queue'\nimport type { Queue } from '@open-mercato/queue'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { isSingleDeliveryRequested } from './single-delivery'\nimport { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'\nimport { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'\nimport { isBroadcastEvent } from '@open-mercato/shared/modules/events'\nimport {\n inferModuleIdFromResourceId,\n withModuleResourceUsage,\n} from '@open-mercato/shared/lib/modules/resource-usage'\nexport { registerCrossProcessEventListener } from './bridge'\nimport { publishCrossProcessEvent } from './bridge'\nimport type {\n EventBus,\n CreateBusOptions,\n SubscriberHandler,\n SubscriberDescriptor,\n EventPayload,\n EmitOptions,\n} from './types'\n\n/** Queue name for persistent events */\nconst EVENTS_QUEUE_NAME = 'events'\n\nconst logger = createLogger('events')\n\ntype RegisteredSubscriber = {\n id?: string\n moduleId?: string\n event: string\n handler: SubscriberHandler\n persistent?: boolean\n}\n\n/**\n * When enabled, a persistent emit delivers each subscriber on exactly one path:\n * persistent-marked subscribers are skipped inline (the events worker dispatches\n * them via pattern match, so wildcard persistent subscribers are reached), while\n * ephemeral subscribers keep running inline. Defaults ON; the server bootstrap\n * reconciles the env against worker availability (and may rewrite it to `false`\n * for a worker-less process). Both the bus and the events worker read the same\n * (possibly reconciled) env var, so they always agree within a process.\n */\nfunction isSingleDeliveryEnabled(): boolean {\n return isSingleDeliveryRequested()\n}\n\ntype GlobalEventTap = (event: string, payload: EventPayload, options?: EmitOptions) => void | Promise<void>\nconst GLOBAL_EVENT_TAPS_KEY = '__openMercatoEventBusGlobalTaps__'\n\nfunction hasTenantScope(payload: EventPayload): boolean {\n return typeof (payload as Record<string, unknown>)?.tenantId === 'string'\n && String((payload as Record<string, unknown>).tenantId).trim().length > 0\n}\n\nfunction getGlobalEventTaps(): Set<GlobalEventTap> {\n const existing = (globalThis as Record<string, unknown>)[GLOBAL_EVENT_TAPS_KEY]\n if (existing instanceof Set) {\n return existing as Set<GlobalEventTap>\n }\n const created = new Set<GlobalEventTap>()\n ;(globalThis as Record<string, unknown>)[GLOBAL_EVENT_TAPS_KEY] = created\n return created\n}\n\nexport function registerGlobalEventTap(handler: GlobalEventTap): () => void {\n const taps = getGlobalEventTaps()\n taps.add(handler)\n return () => {\n taps.delete(handler)\n }\n}\n\n/** Job data structure for queued events */\ntype EventJobData = {\n event: string\n payload: EventPayload\n options?: EmitOptions\n}\n\n// Process-wide cache of the async (BullMQ) persistent-events producer queue.\n// Each authenticated request builds a fresh DI container and event bus, so a\n// per-bus producer queue opened a new ioredis connection per write request that\n// was never closed \u2014 leaking one Redis connection per request until maxclients\n// exhaustion. Memoizing the producer on `globalThis` (keyed by Redis URL, so a\n// reconfigured URL still gets its own queue) keeps it at one connection per\n// process, mirroring the `GLOBAL_EVENT_TAPS_KEY` and `getCachedRateLimiterService`\n// patterns. The local (file-based) strategy holds no pooled connection and its\n// base dir is cwd-relative, so it stays per-bus.\nconst EVENTS_PRODUCER_QUEUE_KEY = '__openMercatoEventsProducerQueues__'\nconst EVENTS_PRODUCER_SHUTDOWN_KEY = '__openMercatoEventsProducerShutdown__'\n\nfunction isSharedProducerEnabled(): boolean {\n return parseBooleanWithDefault(process.env.OM_EVENTS_SHARED_PRODUCER, true)\n}\n\nfunction getProducerQueueRegistry(): Map<string, Queue<EventJobData>> {\n const existing = (globalThis as Record<string, unknown>)[EVENTS_PRODUCER_QUEUE_KEY]\n if (existing instanceof Map) {\n return existing as Map<string, Queue<EventJobData>>\n }\n const created = new Map<string, Queue<EventJobData>>()\n ;(globalThis as Record<string, unknown>)[EVENTS_PRODUCER_QUEUE_KEY] = created\n return created\n}\n\nfunction registerProducerShutdownHook(): void {\n if ((globalThis as Record<string, unknown>)[EVENTS_PRODUCER_SHUTDOWN_KEY]) return\n const shutdown = () => {\n const registry = getProducerQueueRegistry()\n for (const sharedQueue of registry.values()) {\n Promise.resolve(sharedQueue.close()).catch(() => {})\n }\n registry.clear()\n }\n process.once('SIGTERM', shutdown)\n process.once('SIGINT', shutdown)\n ;(globalThis as Record<string, unknown>)[EVENTS_PRODUCER_SHUTDOWN_KEY] = true\n}\n\n/**\n * Creates an event bus instance.\n *\n * The event bus provides:\n * - In-memory event delivery to registered handlers\n * - Optional persistence via the queue package when `persistent: true`\n *\n * @param opts - Configuration options\n * @returns An EventBus instance\n *\n * @example\n * ```typescript\n * const bus = createEventBus({\n * resolve: container.resolve.bind(container),\n * queueStrategy: 'local', // or 'async' for BullMQ\n * })\n *\n * // Register a handler\n * bus.on('user.created', async (payload, ctx) => {\n * const userService = ctx.resolve('userService')\n * await userService.sendWelcomeEmail(payload.userId)\n * })\n *\n * // Emit an event (immediate delivery)\n * await bus.emit('user.created', { userId: '123' })\n *\n * // Emit with persistence (for async worker processing)\n * await bus.emit('order.placed', { orderId: '456' }, { persistent: true })\n * ```\n */\nexport function createEventBus(opts: CreateBusOptions): EventBus {\n // In-memory listeners for immediate event delivery\n const listeners = new Map<string, Set<RegisteredSubscriber>>()\n // Subscribers registered as persistent (worker-dispatched). Used by the\n // single-delivery path to skip them inline on a persistent emit.\n const persistentSubscribers = new Set<RegisteredSubscriber>()\n\n // Determine queue strategy from options or environment\n const queueStrategy = opts.queueStrategy ??\n (process.env.QUEUE_STRATEGY === 'async' ? 'async' : 'local')\n\n // Lazy-initialized queue for persistent events\n let queue: Queue<EventJobData> | null = null\n\n /**\n * Gets or creates the queue instance for persistent events.\n *\n * The async (BullMQ) producer is memoized process-wide so the per-request\n * event bus reuses one Redis connection instead of leaking one per write\n * request. The local strategy stays per-bus (no pooled connection, cwd-relative\n * base dir). Set `OM_EVENTS_SHARED_PRODUCER=0` to fall back to per-bus producers.\n */\n function getQueue(): Queue<EventJobData> {\n if (queueStrategy !== 'async' || !isSharedProducerEnabled()) {\n if (!queue) {\n queue = queueStrategy === 'async'\n ? createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'async', {\n connection: { url: getRedisUrlOrThrow('QUEUE') },\n })\n : createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'local')\n }\n return queue\n }\n\n const redisUrl = getRedisUrlOrThrow('QUEUE')\n const registry = getProducerQueueRegistry()\n const cacheKey = `async:${redisUrl}`\n let shared = registry.get(cacheKey)\n if (!shared) {\n shared = createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'async', {\n connection: { url: redisUrl },\n })\n registry.set(cacheKey, shared)\n registerProducerShutdownHook()\n }\n return shared\n }\n\n /**\n * Delivers an event to all registered in-memory handlers.\n * Supports wildcard pattern matching for event patterns.\n */\n async function deliver(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n skipPersistent = false,\n ): Promise<void> {\n // Check all registered patterns (including wildcards)\n for (const [pattern, handlers] of listeners) {\n if (!matchEventPattern(event, pattern)) continue\n if (!handlers || handlers.size === 0) continue\n\n for (const subscriber of handlers) {\n // Single-delivery: persistent subscribers are dispatched by the worker,\n // so skip them inline to avoid double execution.\n if (skipPersistent && persistentSubscribers.has(subscriber)) continue\n try {\n // Pass eventName in context for wildcard handlers\n const moduleId = subscriber.moduleId ?? inferModuleIdFromResourceId(subscriber.id)\n await withModuleResourceUsage(\n {\n moduleId,\n surface: 'subscriber',\n operation: `${event} -> ${subscriber.id ?? subscriber.event}`,\n resourceId: subscriber.id ?? subscriber.event,\n },\n () => subscriber.handler(payload, {\n resolve: opts.resolve,\n eventName: event,\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n }),\n )\n } catch (error) {\n logger.error('Handler error', { event, pattern, err: error })\n if (options?.rethrowHandlerErrors) {\n throw error\n }\n }\n }\n }\n }\n\n /**\n * Registers a handler for an event. Pass `moduleId` whenever the subscribing\n * module is known (e.g. a module reacting to another module's event) so\n * resource-usage telemetry attributes the handler correctly instead of\n * guessing a module id from the event name.\n */\n function on(\n event: string,\n handler: SubscriberHandler,\n options?: { persistent?: boolean; moduleId?: string; id?: string },\n ): void {\n if (!listeners.has(event)) {\n listeners.set(event, new Set())\n }\n const subscriber: RegisteredSubscriber = {\n id: options?.id,\n moduleId: options?.moduleId,\n event,\n handler,\n persistent: options?.persistent,\n }\n listeners.get(event)!.add(subscriber)\n if (options?.persistent) {\n persistentSubscribers.add(subscriber)\n }\n }\n\n /**\n * Registers multiple module subscribers at once.\n */\n function registerModuleSubscribers(subs: SubscriberDescriptor[]): void {\n for (const sub of subs) {\n on(sub.event, sub.handler, { persistent: sub.persistent, moduleId: sub.moduleId, id: sub.id })\n }\n }\n\n /**\n * Emits an event to all registered handlers.\n *\n * If `persistent: true`, also enqueues the event for async processing.\n */\n async function emit(\n event: string,\n payload: EventPayload,\n options?: EmitOptions\n ): Promise<void> {\n const taps = getGlobalEventTaps()\n for (const tap of taps) {\n try {\n await Promise.resolve(tap(event, payload, options))\n } catch (error) {\n logger.error('Global tap error', { event, err: error })\n }\n }\n\n // Deliver to in-memory handlers first. Under single-delivery, persistent\n // subscribers are skipped inline on a persistent emit because the events\n // worker will dispatch them from the queue.\n //\n // `deliverInline: false` on a persistent emit is enqueue-only: skip inline\n // delivery entirely so a heavy persistent job runs solely in the events\n // worker, off the caller's request path (the queued enqueue below still\n // happens). Without this, a persistent emit dual-dispatches by default and\n // runs the subscriber inline in the caller's request too.\n const enqueueOnly = Boolean(options?.persistent) && options?.deliverInline === false\n if (!enqueueOnly) {\n const skipPersistentInline = Boolean(options?.persistent) && isSingleDeliveryEnabled()\n await deliver(event, payload, options, skipPersistentInline)\n }\n\n if (isBroadcastEvent(event) && hasTenantScope(payload)) {\n try {\n await publishCrossProcessEvent(event, payload, options)\n } catch (error) {\n logger.error('Cross-process publish error', { event, err: error })\n }\n }\n\n // If persistent, also enqueue for async processing\n if (options?.persistent) {\n const q = getQueue()\n await q.enqueue({ event, payload, options })\n }\n }\n\n /**\n * Clears all events from the persistent queue.\n */\n async function clearQueue(): Promise<{ removed: number }> {\n const q = getQueue()\n return q.clear()\n }\n\n // Backward compatibility alias\n const emitEvent = emit\n\n return {\n emit,\n emitEvent, // Alias for backward compatibility\n on,\n registerModuleSubscribers,\n clearQueue,\n }\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,mBAAmB;AAE5B,SAAS,+BAA+B;AACxC,SAAS,oBAAoB;AAC7B,SAAS,iCAAiC;AAC1C,SAAS,yBAAyB;AAClC,SAAS,0BAA0B;AACnC,SAAS,wBAAwB;AACjC;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,yCAAyC;AAClD,SAAS,gCAAgC;
|
|
4
|
+
"sourcesContent": ["import { createQueue } from '@open-mercato/queue'\nimport type { Queue } from '@open-mercato/queue'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { isSingleDeliveryRequested } from './single-delivery'\nimport { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'\nimport { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'\nimport { isBroadcastEvent } from '@open-mercato/shared/modules/events'\nimport {\n inferModuleIdFromResourceId,\n withModuleResourceUsage,\n} from '@open-mercato/shared/lib/modules/resource-usage'\nexport { registerCrossProcessEventListener } from './bridge'\nimport { publishCrossProcessEvent } from './bridge'\nimport type {\n EventBus,\n CreateBusOptions,\n SubscriberHandler,\n SubscriberDescriptor,\n EventPayload,\n EmitOptions,\n QueuedDispatchResult,\n} from './types'\n\n/** Queue name for persistent events */\nconst EVENTS_QUEUE_NAME = 'events'\n\nconst logger = createLogger('events')\n\ntype RegisteredSubscriber = {\n id?: string\n moduleId?: string\n event: string\n handler: SubscriberHandler\n persistent?: boolean\n}\n\n/**\n * When enabled, a persistent emit delivers each subscriber on exactly one path:\n * persistent-marked subscribers are skipped inline (the events worker dispatches\n * them via pattern match, so wildcard persistent subscribers are reached), while\n * ephemeral subscribers keep running inline. Defaults ON.\n *\n * The bus never second-guesses this against \"is a worker running\": the queue is\n * durable, so a persistent emit with no worker yet is delayed, not lost, and a\n * worker started later drains the backlog. Delivering inline instead would move\n * the work back onto the caller's request path \u2014 exactly what a split app/worker\n * deployment sets `AUTO_SPAWN_WORKERS=false` to avoid.\n *\n * The events worker asks this same bus for its subscribers via `dispatchQueued`,\n * so both halves of single-delivery read one decision.\n */\nfunction isSingleDeliveryEnabled(): boolean {\n return isSingleDeliveryRequested()\n}\n\ntype GlobalEventTap = (event: string, payload: EventPayload, options?: EmitOptions) => void | Promise<void>\nconst GLOBAL_EVENT_TAPS_KEY = '__openMercatoEventBusGlobalTaps__'\n\nfunction hasTenantScope(payload: EventPayload): boolean {\n return typeof (payload as Record<string, unknown>)?.tenantId === 'string'\n && String((payload as Record<string, unknown>).tenantId).trim().length > 0\n}\n\nfunction getGlobalEventTaps(): Set<GlobalEventTap> {\n const existing = (globalThis as Record<string, unknown>)[GLOBAL_EVENT_TAPS_KEY]\n if (existing instanceof Set) {\n return existing as Set<GlobalEventTap>\n }\n const created = new Set<GlobalEventTap>()\n ;(globalThis as Record<string, unknown>)[GLOBAL_EVENT_TAPS_KEY] = created\n return created\n}\n\nexport function registerGlobalEventTap(handler: GlobalEventTap): () => void {\n const taps = getGlobalEventTaps()\n taps.add(handler)\n return () => {\n taps.delete(handler)\n }\n}\n\n/** Job data structure for queued events */\ntype EventJobData = {\n event: string\n payload: EventPayload\n options?: EmitOptions\n /**\n * Set by the producer when it already ran the persistent subscribers inline\n * (single-delivery reconciled off). The worker skips such jobs, so the app and\n * the worker staying exactly-once no longer depends on both processes agreeing\n * about `OM_EVENTS_SINGLE_DELIVERY`. Absent on jobs queued before this field\n * existed, which keeps their behavior unchanged.\n */\n persistentDeliveredInline?: boolean\n}\n\n// Process-wide cache of the async (BullMQ) persistent-events producer queue.\n// Each authenticated request builds a fresh DI container and event bus, so a\n// per-bus producer queue opened a new ioredis connection per write request that\n// was never closed \u2014 leaking one Redis connection per request until maxclients\n// exhaustion. Memoizing the producer on `globalThis` (keyed by Redis URL, so a\n// reconfigured URL still gets its own queue) keeps it at one connection per\n// process, mirroring the `GLOBAL_EVENT_TAPS_KEY` and `getCachedRateLimiterService`\n// patterns. The local (file-based) strategy holds no pooled connection and its\n// base dir is cwd-relative, so it stays per-bus.\nconst EVENTS_PRODUCER_QUEUE_KEY = '__openMercatoEventsProducerQueues__'\nconst EVENTS_PRODUCER_SHUTDOWN_KEY = '__openMercatoEventsProducerShutdown__'\n\nfunction isSharedProducerEnabled(): boolean {\n return parseBooleanWithDefault(process.env.OM_EVENTS_SHARED_PRODUCER, true)\n}\n\nfunction getProducerQueueRegistry(): Map<string, Queue<EventJobData>> {\n const existing = (globalThis as Record<string, unknown>)[EVENTS_PRODUCER_QUEUE_KEY]\n if (existing instanceof Map) {\n return existing as Map<string, Queue<EventJobData>>\n }\n const created = new Map<string, Queue<EventJobData>>()\n ;(globalThis as Record<string, unknown>)[EVENTS_PRODUCER_QUEUE_KEY] = created\n return created\n}\n\nfunction registerProducerShutdownHook(): void {\n if ((globalThis as Record<string, unknown>)[EVENTS_PRODUCER_SHUTDOWN_KEY]) return\n const shutdown = () => {\n const registry = getProducerQueueRegistry()\n for (const sharedQueue of registry.values()) {\n Promise.resolve(sharedQueue.close()).catch(() => {})\n }\n registry.clear()\n }\n process.once('SIGTERM', shutdown)\n process.once('SIGINT', shutdown)\n ;(globalThis as Record<string, unknown>)[EVENTS_PRODUCER_SHUTDOWN_KEY] = true\n}\n\n/**\n * Creates an event bus instance.\n *\n * The event bus provides:\n * - In-memory event delivery to registered handlers\n * - Optional persistence via the queue package when `persistent: true`\n *\n * @param opts - Configuration options\n * @returns An EventBus instance\n *\n * @example\n * ```typescript\n * const bus = createEventBus({\n * resolve: container.resolve.bind(container),\n * queueStrategy: 'local', // or 'async' for BullMQ\n * })\n *\n * // Register a handler\n * bus.on('user.created', async (payload, ctx) => {\n * const userService = ctx.resolve('userService')\n * await userService.sendWelcomeEmail(payload.userId)\n * })\n *\n * // Emit an event (immediate delivery)\n * await bus.emit('user.created', { userId: '123' })\n *\n * // Emit with persistence (for async worker processing)\n * await bus.emit('order.placed', { orderId: '456' }, { persistent: true })\n * ```\n */\nexport function createEventBus(opts: CreateBusOptions): EventBus {\n // In-memory listeners for immediate event delivery\n const listeners = new Map<string, Set<RegisteredSubscriber>>()\n // Subscribers registered as persistent (worker-dispatched). Used by the\n // single-delivery path to skip them inline on a persistent emit.\n const persistentSubscribers = new Set<RegisteredSubscriber>()\n\n // Determine queue strategy from options or environment\n const queueStrategy = opts.queueStrategy ??\n (process.env.QUEUE_STRATEGY === 'async' ? 'async' : 'local')\n\n // Lazy-initialized queue for persistent events\n let queue: Queue<EventJobData> | null = null\n\n /**\n * Gets or creates the queue instance for persistent events.\n *\n * The async (BullMQ) producer is memoized process-wide so the per-request\n * event bus reuses one Redis connection instead of leaking one per write\n * request. The local strategy stays per-bus (no pooled connection, cwd-relative\n * base dir). Set `OM_EVENTS_SHARED_PRODUCER=0` to fall back to per-bus producers.\n */\n function getQueue(): Queue<EventJobData> {\n if (queueStrategy !== 'async' || !isSharedProducerEnabled()) {\n if (!queue) {\n queue = queueStrategy === 'async'\n ? createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'async', {\n connection: { url: getRedisUrlOrThrow('QUEUE') },\n })\n : createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'local')\n }\n return queue\n }\n\n const redisUrl = getRedisUrlOrThrow('QUEUE')\n const registry = getProducerQueueRegistry()\n const cacheKey = `async:${redisUrl}`\n let shared = registry.get(cacheKey)\n if (!shared) {\n shared = createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'async', {\n connection: { url: redisUrl },\n })\n registry.set(cacheKey, shared)\n registerProducerShutdownHook()\n }\n return shared\n }\n\n /**\n * Delivers an event to all registered in-memory handlers.\n * Supports wildcard pattern matching for event patterns.\n *\n * Handler errors are logged and delivery continues - one bad subscriber must\n * not stop the others. The count of failed PERSISTENT handlers is returned so\n * the caller can decide whether the inline run may be recorded as complete:\n * a persistent subscriber that threw inline has no retry of its own, so the\n * queued job must stay unstamped and let the worker run it again.\n */\n async function deliver(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n skipPersistent = false,\n ): Promise<{ persistentFailures: number }> {\n let persistentFailures = 0\n // Check all registered patterns (including wildcards)\n for (const [pattern, handlers] of listeners) {\n if (!matchEventPattern(event, pattern)) continue\n if (!handlers || handlers.size === 0) continue\n\n for (const subscriber of handlers) {\n // Single-delivery: persistent subscribers are dispatched by the worker,\n // so skip them inline to avoid double execution.\n if (skipPersistent && persistentSubscribers.has(subscriber)) continue\n try {\n // Pass eventName in context for wildcard handlers\n const moduleId = subscriber.moduleId ?? inferModuleIdFromResourceId(subscriber.id)\n await withModuleResourceUsage(\n {\n moduleId,\n surface: 'subscriber',\n operation: `${event} -> ${subscriber.id ?? subscriber.event}`,\n resourceId: subscriber.id ?? subscriber.event,\n },\n () => subscriber.handler(payload, {\n resolve: opts.resolve,\n eventName: event,\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n }),\n )\n } catch (error) {\n if (persistentSubscribers.has(subscriber)) {\n persistentFailures += 1\n }\n logger.error('Handler error', { event, pattern, err: error })\n if (options?.rethrowHandlerErrors) {\n throw error\n }\n }\n }\n }\n return { persistentFailures }\n }\n\n /**\n * Selects the subscribers the events worker owns for a queued event: every\n * pattern matching `event`, keeping persistent subscribers only, so wildcard\n * (`event: '*'`) persistent subscribers are reached exactly once here.\n *\n * Deliberately does NOT read `OM_EVENTS_SINGLE_DELIVERY`. Whether inline\n * delivery already happened is carried by the job's `persistentDeliveredInline`\n * stamp, so the producer owns that decision entirely and the worker cannot\n * disagree with it across processes. Reading the flag here would reintroduce\n * exactly that skew: a worker whose env has the flag off would fall back to\n * exact-match, re-running ephemeral subscribers the producer already ran inline\n * and never reaching wildcard persistent subscribers - the failure this whole\n * change exists to remove.\n *\n * Every job that reaches this point wants persistent-pattern selection: a job\n * is only dispatched when its stamp is false, which means either the producer\n * had single-delivery on, or the emit was enqueue-only (`deliverInline: false`,\n * documented as \"every subscriber to the event is persistent\").\n */\n function selectQueuedSubscribers(event: string): RegisteredSubscriber[] {\n const matched: RegisteredSubscriber[] = []\n for (const [pattern, handlers] of listeners) {\n if (!matchEventPattern(event, pattern)) {\n continue\n }\n for (const subscriber of handlers) {\n if (subscriber.persistent) {\n matched.push(subscriber)\n }\n }\n }\n return matched\n }\n\n /**\n * Dispatches a queued event on behalf of the events worker.\n *\n * Handler failures are returned instead of logged-and-swallowed the way\n * `deliver()` does them: the worker needs them to propagate so the queue retries\n * the job and eventually dead-letters it.\n *\n * Subscribers run against the caller's `resolve` when one is given, falling back\n * to the bus's own. The events worker passes its per-job `ctx.resolve`, which is\n * the container `createPerJobWorkerHandler` built for that job - the handle the\n * queue contract already hands it. Defaulting to `opts.resolve` instead would\n * bind subscribers to whichever container built the bus; that is the same object\n * on the default configuration, but not under `OM_BOOTSTRAP_CACHE`, where the\n * cached bus is replayed into later containers while its captured resolver stays\n * bound to the first.\n */\n async function dispatchQueued(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n resolve?: <T = unknown>(name: string) => T,\n ): Promise<QueuedDispatchResult[]> {\n const subscribers = selectQueuedSubscribers(event)\n if (subscribers.length === 0) {\n return []\n }\n\n const resolver = resolve ?? opts.resolve\n\n const settled = await Promise.allSettled(\n subscribers.map((subscriber) =>\n withModuleResourceUsage(\n {\n moduleId: subscriber.moduleId ?? inferModuleIdFromResourceId(subscriber.id),\n surface: 'subscriber',\n operation: `${event} -> ${subscriber.id ?? subscriber.event}`,\n resourceId: subscriber.id ?? subscriber.event,\n },\n () => subscriber.handler(payload, {\n resolve: resolver,\n eventName: event,\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n }),\n ),\n ),\n )\n\n return settled.map((result, index) => {\n const subscriberId = subscribers[index].id ?? subscribers[index].event\n return result.status === 'rejected'\n ? { subscriberId, ok: false, error: result.reason }\n : { subscriberId, ok: true }\n })\n }\n\n /**\n * Registers a handler for an event. Pass `moduleId` whenever the subscribing\n * module is known (e.g. a module reacting to another module's event) so\n * resource-usage telemetry attributes the handler correctly instead of\n * guessing a module id from the event name.\n */\n function on(\n event: string,\n handler: SubscriberHandler,\n options?: { persistent?: boolean; moduleId?: string; id?: string },\n ): void {\n if (!listeners.has(event)) {\n listeners.set(event, new Set())\n }\n const subscriber: RegisteredSubscriber = {\n id: options?.id,\n moduleId: options?.moduleId,\n event,\n handler,\n persistent: options?.persistent,\n }\n listeners.get(event)!.add(subscriber)\n if (options?.persistent) {\n persistentSubscribers.add(subscriber)\n }\n }\n\n /**\n * Registers multiple module subscribers at once.\n */\n function registerModuleSubscribers(subs: SubscriberDescriptor[]): void {\n for (const sub of subs) {\n on(sub.event, sub.handler, { persistent: sub.persistent, moduleId: sub.moduleId, id: sub.id })\n }\n }\n\n /**\n * Emits an event to all registered handlers.\n *\n * If `persistent: true`, also enqueues the event for async processing.\n */\n async function emit(\n event: string,\n payload: EventPayload,\n options?: EmitOptions\n ): Promise<void> {\n const taps = getGlobalEventTaps()\n for (const tap of taps) {\n try {\n await Promise.resolve(tap(event, payload, options))\n } catch (error) {\n logger.error('Global tap error', { event, err: error })\n }\n }\n\n // Deliver to in-memory handlers first. Under single-delivery, persistent\n // subscribers are skipped inline on a persistent emit because the events\n // worker will dispatch them from the queue.\n //\n // `deliverInline: false` on a persistent emit is enqueue-only: skip inline\n // delivery entirely so a heavy persistent job runs solely in the events\n // worker, off the caller's request path (the queued enqueue below still\n // happens). Without this, a persistent emit dual-dispatches by default and\n // runs the subscriber inline in the caller's request too.\n const enqueueOnly = Boolean(options?.persistent) && options?.deliverInline === false\n // Read the mode once so the inline skip and the stamp below cannot disagree.\n const singleDelivery = isSingleDeliveryEnabled()\n let inlinePersistentFailed = false\n if (!enqueueOnly) {\n const skipPersistentInline = Boolean(options?.persistent) && singleDelivery\n const delivered = await deliver(event, payload, options, skipPersistentInline)\n inlinePersistentFailed = delivered.persistentFailures > 0\n }\n\n if (isBroadcastEvent(event) && hasTenantScope(payload)) {\n try {\n await publishCrossProcessEvent(event, payload, options)\n } catch (error) {\n logger.error('Cross-process publish error', { event, err: error })\n }\n }\n\n // If persistent, also enqueue for async processing\n if (options?.persistent) {\n const q = getQueue()\n await q.enqueue({\n event,\n payload,\n options,\n // Only record the inline run as complete when every persistent\n // subscriber actually succeeded. Inline delivery logs-and-continues, so a\n // handler that threw has no retry of its own; leaving the job unstamped\n // hands it to the worker, where a failure gets queue retry and\n // dead-lettering. Without this, flipping the flag off (which the\n // `mercato server` guard can do automatically) silently downgrades\n // persistent delivery to at-most-once.\n persistentDeliveredInline: !enqueueOnly && !singleDelivery && !inlinePersistentFailed,\n })\n }\n }\n\n /**\n * Clears all events from the persistent queue.\n */\n async function clearQueue(): Promise<{ removed: number }> {\n const q = getQueue()\n return q.clear()\n }\n\n // Backward compatibility alias\n const emitEvent = emit\n\n return {\n emit,\n emitEvent, // Alias for backward compatibility\n on,\n registerModuleSubscribers,\n dispatchQueued,\n clearQueue,\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
import { getCliModules } from "@open-mercato/shared/modules/registry";
|
|
2
|
-
import { matchEventPattern } from "@open-mercato/shared/lib/events/patterns";
|
|
3
1
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
4
|
-
import { isSingleDeliveryRequested } from "../../../single-delivery.js";
|
|
5
2
|
const EVENTS_QUEUE_NAME = "events";
|
|
6
3
|
const logger = createLogger("events");
|
|
7
4
|
const DEFAULT_CONCURRENCY = 1;
|
|
@@ -10,73 +7,68 @@ const metadata = {
|
|
|
10
7
|
queue: EVENTS_QUEUE_NAME,
|
|
11
8
|
concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY
|
|
12
9
|
};
|
|
13
|
-
function
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
10
|
+
function resolveEventBus(ctx, event) {
|
|
11
|
+
let bus;
|
|
12
|
+
try {
|
|
13
|
+
bus = ctx.resolve("eventBus");
|
|
14
|
+
} catch (error) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
`[internal] Events worker cannot dispatch "${event}": no "eventBus" in the job container (${error instanceof Error ? error.message : String(error)}). The events worker must run inside a bootstrapped process, e.g. \`mercato queue worker events\` or \`mercato queue worker --all\`. Failing the job so it retries instead of dropping the event's subscribers.`
|
|
17
|
+
);
|
|
19
18
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (sub.persistent) matched.push(sub);
|
|
25
|
-
}
|
|
19
|
+
if (!bus || typeof bus.dispatchQueued !== "function") {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`[internal] Events worker cannot dispatch "${event}": the resolved "eventBus" has no dispatchQueued(). Either the bus failed to initialize and the degraded no-op fallback is in place, or a stale @open-mercato/events build is loaded alongside a newer one. Failing the job so it retries instead of dropping the event's subscribers.`
|
|
22
|
+
);
|
|
26
23
|
}
|
|
27
|
-
return
|
|
28
|
-
}
|
|
29
|
-
let cachedListenerMap = null;
|
|
30
|
-
function clearListenerCache() {
|
|
31
|
-
cachedListenerMap = null;
|
|
24
|
+
return bus;
|
|
32
25
|
}
|
|
33
|
-
function
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (!subs) continue;
|
|
38
|
-
for (const sub of subs) {
|
|
39
|
-
if (!listeners.has(sub.event)) listeners.set(sub.event, []);
|
|
40
|
-
listeners.get(sub.event).push(sub);
|
|
41
|
-
}
|
|
26
|
+
function reportFailures(event, results) {
|
|
27
|
+
const failures = results.filter((result) => !result.ok);
|
|
28
|
+
if (failures.length === 0) {
|
|
29
|
+
return;
|
|
42
30
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
31
|
+
for (const failure of failures) {
|
|
32
|
+
logger.error("Subscriber failed for event", {
|
|
33
|
+
event,
|
|
34
|
+
subscriberId: failure.subscriberId,
|
|
35
|
+
err: failure.error
|
|
36
|
+
});
|
|
48
37
|
}
|
|
49
|
-
|
|
38
|
+
const failedIds = failures.map((failure) => failure.subscriberId).join(", ");
|
|
39
|
+
throw new Error(
|
|
40
|
+
`[internal] ${failures.length}/${results.length} subscriber(s) failed for event "${event}": ${failedIds}`
|
|
41
|
+
);
|
|
50
42
|
}
|
|
43
|
+
const eventsReportedWithoutSubscribers = /* @__PURE__ */ new Set();
|
|
51
44
|
async function handle(job, ctx) {
|
|
52
|
-
const { event, payload, options } = job.payload;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
45
|
+
const { event, payload, options, persistentDeliveredInline } = job.payload;
|
|
46
|
+
if (persistentDeliveredInline) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const bus = resolveEventBus(ctx, event);
|
|
50
|
+
const results = await bus.dispatchQueued(
|
|
51
|
+
event,
|
|
52
|
+
payload,
|
|
53
|
+
{
|
|
54
|
+
tenantId: options?.tenantId ?? null,
|
|
55
|
+
organizationId: options?.organizationId ?? null
|
|
56
|
+
},
|
|
57
|
+
ctx.resolve
|
|
64
58
|
);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
errors.push({ subscriberId: sub.id, error: result.reason });
|
|
59
|
+
if (results.length === 0) {
|
|
60
|
+
const alreadyReported = eventsReportedWithoutSubscribers.has(event);
|
|
61
|
+
eventsReportedWithoutSubscribers.add(event);
|
|
62
|
+
if (alreadyReported) {
|
|
63
|
+
logger.debug("Queued event dispatched to zero subscribers", { event, jobId: ctx.jobId });
|
|
64
|
+
return;
|
|
72
65
|
}
|
|
66
|
+
logger.warn("Queued event dispatched to zero subscribers", { event, jobId: ctx.jobId });
|
|
67
|
+
return;
|
|
73
68
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
`${errors.length}/${subscribers.length} subscriber(s) failed for event "${event}": ${failedIds}`
|
|
78
|
-
);
|
|
79
|
-
}
|
|
69
|
+
reportFailures(event, results);
|
|
70
|
+
}
|
|
71
|
+
function clearListenerCache() {
|
|
80
72
|
}
|
|
81
73
|
export {
|
|
82
74
|
EVENTS_QUEUE_NAME,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/events/workers/events.worker.ts"],
|
|
4
|
-
"sourcesContent": ["import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport {
|
|
5
|
-
"mappings": "AACA,SAAS,
|
|
4
|
+
"sourcesContent": ["import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport type { EventBus, QueuedDispatchResult } from '../../../types'\n\n/** An `EventBus` proven at runtime to implement the optional `dispatchQueued`. */\ntype DispatchCapableBus = EventBus & Required<Pick<EventBus, 'dispatchQueued'>>\n\nexport const EVENTS_QUEUE_NAME = 'events'\n\nconst logger = createLogger('events')\n\nconst DEFAULT_CONCURRENCY = 1\nconst envConcurrency = process.env.WORKERS_EVENTS_CONCURRENCY\n\nexport const metadata: WorkerMeta = {\n queue: EVENTS_QUEUE_NAME,\n concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY,\n}\n\ntype EventJobPayload = {\n event: string\n payload: unknown\n options?: {\n tenantId?: string | null\n organizationId?: string | null\n }\n /**\n * Producer stamp: the emitting process already ran the persistent subscribers\n * inline because single-delivery was reconciled off there. Dispatching again\n * here would double-run them, so the job is a no-op.\n */\n persistentDeliveredInline?: boolean\n}\n\ntype HandlerContext = {\n resolve: <T = unknown>(name: string) => T\n eventName?: string\n tenantId?: string | null\n organizationId?: string | null\n}\n\n/**\n * Resolves the DI event bus that owns subscriber dispatch for this job.\n *\n * The worker deliberately has no subscriber registry of its own. It used to build\n * one from `getCliModules()`, which is populated only by the `mercato` bin, so any\n * process that started a worker another way dispatched zero subscribers and\n * completed the job silently - dropping webhook deliveries, workflow event\n * triggers and business-rule triggers with no error and no log. The per-job\n * request container already carries a bus with every module subscriber registered,\n * so that is the single source of truth. When it is missing, fail the job loudly\n * rather than repeat the silent drop.\n */\nfunction resolveEventBus(ctx: HandlerContext, event: string): DispatchCapableBus {\n let bus: unknown\n try {\n bus = ctx.resolve('eventBus')\n } catch (error) {\n throw new Error(\n `[internal] Events worker cannot dispatch \"${event}\": no \"eventBus\" in the job container ` +\n `(${error instanceof Error ? error.message : String(error)}). The events worker must run inside a ` +\n `bootstrapped process, e.g. \\`mercato queue worker events\\` or \\`mercato queue worker --all\\`. ` +\n `Failing the job so it retries instead of dropping the event's subscribers.`,\n )\n }\n if (!bus || typeof (bus as EventBus).dispatchQueued !== 'function') {\n throw new Error(\n `[internal] Events worker cannot dispatch \"${event}\": the resolved \"eventBus\" has no ` +\n `dispatchQueued(). Either the bus failed to initialize and the degraded no-op fallback is in ` +\n `place, or a stale @open-mercato/events build is loaded alongside a newer one. Failing the job ` +\n `so it retries instead of dropping the event's subscribers.`,\n )\n }\n return bus as DispatchCapableBus\n}\n\nfunction reportFailures(event: string, results: QueuedDispatchResult[]): void {\n // Keyed on `ok`, never on `error !== undefined`: a handler can reject with\n // `undefined`, which would otherwise be scored as a success and the job\n // completed despite a failed subscriber.\n const failures = results.filter((result) => !result.ok)\n if (failures.length === 0) {\n return\n }\n\n for (const failure of failures) {\n logger.error('Subscriber failed for event', {\n event,\n subscriberId: failure.subscriberId,\n err: failure.error,\n })\n }\n\n const failedIds = failures.map((failure) => failure.subscriberId).join(', ')\n throw new Error(\n `[internal] ${failures.length}/${results.length} subscriber(s) failed for event \"${event}\": ${failedIds}`,\n )\n}\n\n// Event names already reported as having no subscriber. The empty-result case is\n// worth surfacing once - it is the residual silent-loss path - but an install\n// carrying none of the wildcard persistent subscribers (webhooks outbound,\n// workflow triggers, business-rule triggers) would otherwise log one `warn` per\n// queued event forever, and a warning that fires in steady state is one operators\n// learn to skip past.\nconst eventsReportedWithoutSubscribers = new Set<string>()\n\n/**\n * Events worker handler.\n * Dispatches queued events to the subscribers registered on the DI event bus.\n * Each subscriber is isolated - failures in one don't affect others.\n */\nexport default async function handle(\n job: QueuedJob<EventJobPayload>,\n ctx: JobContext & HandlerContext\n): Promise<void> {\n const { event, payload, options, persistentDeliveredInline } = job.payload\n if (persistentDeliveredInline) {\n return\n }\n\n const bus = resolveEventBus(ctx, event)\n // `ctx.resolve` is the per-job request container `createPerJobWorkerHandler`\n // built for this job. Handing it to the bus keeps subscribers on that job's\n // `em` instead of whichever container happened to construct the bus.\n const results = await bus.dispatchQueued(\n event,\n payload,\n {\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n },\n ctx.resolve,\n )\n\n // A resolvable bus with an empty registry is the residual silent-loss path:\n // `core/bootstrap.ts` logs and continues when `getModules()` fails, leaving a\n // valid bus with no subscribers, and this job would then complete green.\n // Zero subscribers is legitimate for an event nobody listens to, so this\n // cannot throw - but at job-dispatch time it is more often a bug than a no-op,\n // so make it visible rather than silent. Once per event name: see the note on\n // `eventsReportedWithoutSubscribers`.\n if (results.length === 0) {\n const alreadyReported = eventsReportedWithoutSubscribers.has(event)\n eventsReportedWithoutSubscribers.add(event)\n if (alreadyReported) {\n logger.debug('Queued event dispatched to zero subscribers', { event, jobId: ctx.jobId })\n return\n }\n logger.warn('Queued event dispatched to zero subscribers', { event, jobId: ctx.jobId })\n return\n }\n\n reportFailures(event, results)\n}\n\n/**\n * @deprecated The worker no longer keeps a listener cache; subscriber resolution\n * lives on the DI event bus. Kept as a no-op for one minor so existing callers\n * (tests, custom worker harnesses) keep compiling.\n */\nexport function clearListenerCache(): void {}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,oBAAoB;AAMtB,MAAM,oBAAoB;AAEjC,MAAM,SAAS,aAAa,QAAQ;AAEpC,MAAM,sBAAsB;AAC5B,MAAM,iBAAiB,QAAQ,IAAI;AAE5B,MAAM,WAAuB;AAAA,EAClC,OAAO;AAAA,EACP,aAAa,iBAAiB,SAAS,gBAAgB,EAAE,IAAI;AAC/D;AAoCA,SAAS,gBAAgB,KAAqB,OAAmC;AAC/E,MAAI;AACJ,MAAI;AACF,UAAM,IAAI,QAAQ,UAAU;AAAA,EAC9B,SAAS,OAAO;AACd,UAAM,IAAI;AAAA,MACR,6CAA6C,KAAK,0CAC9C,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,CAAC;AAAA,IAG5D;AAAA,EACF;AACA,MAAI,CAAC,OAAO,OAAQ,IAAiB,mBAAmB,YAAY;AAClE,UAAM,IAAI;AAAA,MACR,6CAA6C,KAAK;AAAA,IAIpD;AAAA,EACF;AACA,SAAO;AACT;AAEA,SAAS,eAAe,OAAe,SAAuC;AAI5E,QAAM,WAAW,QAAQ,OAAO,CAAC,WAAW,CAAC,OAAO,EAAE;AACtD,MAAI,SAAS,WAAW,GAAG;AACzB;AAAA,EACF;AAEA,aAAW,WAAW,UAAU;AAC9B,WAAO,MAAM,+BAA+B;AAAA,MAC1C;AAAA,MACA,cAAc,QAAQ;AAAA,MACtB,KAAK,QAAQ;AAAA,IACf,CAAC;AAAA,EACH;AAEA,QAAM,YAAY,SAAS,IAAI,CAAC,YAAY,QAAQ,YAAY,EAAE,KAAK,IAAI;AAC3E,QAAM,IAAI;AAAA,IACR,cAAc,SAAS,MAAM,IAAI,QAAQ,MAAM,oCAAoC,KAAK,MAAM,SAAS;AAAA,EACzG;AACF;AAQA,MAAM,mCAAmC,oBAAI,IAAY;AAOzD,eAAO,OACL,KACA,KACe;AACf,QAAM,EAAE,OAAO,SAAS,SAAS,0BAA0B,IAAI,IAAI;AACnE,MAAI,2BAA2B;AAC7B;AAAA,EACF;AAEA,QAAM,MAAM,gBAAgB,KAAK,KAAK;AAItC,QAAM,UAAU,MAAM,IAAI;AAAA,IACxB;AAAA,IACA;AAAA,IACA;AAAA,MACE,UAAU,SAAS,YAAY;AAAA,MAC/B,gBAAgB,SAAS,kBAAkB;AAAA,IAC7C;AAAA,IACA,IAAI;AAAA,EACN;AASA,MAAI,QAAQ,WAAW,GAAG;AACxB,UAAM,kBAAkB,iCAAiC,IAAI,KAAK;AAClE,qCAAiC,IAAI,KAAK;AAC1C,QAAI,iBAAiB;AACnB,aAAO,MAAM,+CAA+C,EAAE,OAAO,OAAO,IAAI,MAAM,CAAC;AACvF;AAAA,IACF;AACA,WAAO,KAAK,+CAA+C,EAAE,OAAO,OAAO,IAAI,MAAM,CAAC;AACtF;AAAA,EACF;AAEA,iBAAe,OAAO,OAAO;AAC/B;AAOO,SAAS,qBAA2B;AAAC;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/single-delivery.js
CHANGED
|
@@ -12,7 +12,7 @@ function reconcileSingleDelivery(input) {
|
|
|
12
12
|
if (input.workersAvailable) return { effective: true };
|
|
13
13
|
return {
|
|
14
14
|
effective: false,
|
|
15
|
-
warning: `[events] ${EVENTS_SINGLE_DELIVERY_ENV} is on (default) but this process auto-spawns no events worker (AUTO_SPAWN_WORKERS=off) and ${EVENTS_EXTERNAL_WORKER_ENV} is not set. Persistent subscribers would be skipped inline with nothing to drain the queue, silently dropping notifications, queued emails, and indexing.
|
|
15
|
+
warning: `[events] ${EVENTS_SINGLE_DELIVERY_ENV} is on (default) but this process auto-spawns no events worker (AUTO_SPAWN_WORKERS=off) and ${EVENTS_EXTERNAL_WORKER_ENV} is not set. Persistent subscribers would be skipped inline with nothing to drain the queue, silently dropping notifications, queued emails, and indexing. Running them inline instead, on the caller's request path. To move this work back off the request path, run an events worker (\`mercato queue worker events\`) and set ${EVENTS_EXTERNAL_WORKER_ENV}=true, or enable AUTO_SPAWN_WORKERS.`
|
|
16
16
|
};
|
|
17
17
|
}
|
|
18
18
|
export {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/single-delivery.ts"],
|
|
4
|
-
"sourcesContent": ["import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\n\n/** Env var that toggles single-path persistent delivery. Defaults ON (see below). */\nexport const EVENTS_SINGLE_DELIVERY_ENV = 'OM_EVENTS_SINGLE_DELIVERY'\n/**\n * Operator acknowledgment that an events worker runs OUT OF PROCESS (separate\n * `mercato queue worker` container/process), so the server bootstrap must not\n * disable single-delivery just because it does not auto-spawn workers itself.\n */\nexport const EVENTS_EXTERNAL_WORKER_ENV = 'OM_EVENTS_EXTERNAL_WORKER'\n\ntype EnvSource = Record<string, string | undefined>\n\n/**\n * Whether single-path persistent delivery is requested.\n *\n * Default ON: a persistent emit is delivered on exactly one path \u2014 persistent\n * subscribers run in the events worker (matched by pattern, so wildcard\n * persistent subscribers are reached), ephemeral subscribers still run inline.\n * This is the correct behavior; the legacy dual-dispatch (inline AND worker)\n * double-ran exact-match persistent subscribers and never reached wildcard\n * persistent subscribers in the worker. Set the env to a false token to
|
|
5
|
-
"mappings": "AAAA,SAAS,+BAA+B;AAGjC,MAAM,6BAA6B;AAMnC,MAAM,6BAA6B;
|
|
4
|
+
"sourcesContent": ["import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\n\n/** Env var that toggles single-path persistent delivery. Defaults ON (see below). */\nexport const EVENTS_SINGLE_DELIVERY_ENV = 'OM_EVENTS_SINGLE_DELIVERY'\n/**\n * Operator acknowledgment that an events worker runs OUT OF PROCESS (separate\n * `mercato queue worker` container/process), so the server bootstrap must not\n * disable single-delivery just because it does not auto-spawn workers itself.\n */\nexport const EVENTS_EXTERNAL_WORKER_ENV = 'OM_EVENTS_EXTERNAL_WORKER'\n\ntype EnvSource = Record<string, string | undefined>\n\n/**\n * Whether single-path persistent delivery is requested.\n *\n * Default ON: a persistent emit is delivered on exactly one path \u2014 persistent\n * subscribers run in the events worker (matched by pattern, so wildcard\n * persistent subscribers are reached), ephemeral subscribers still run inline.\n * This is the correct behavior; the legacy dual-dispatch (inline AND worker)\n * double-ran exact-match persistent subscribers and never reached wildcard\n * persistent subscribers in the worker. Set the env to a false token to run\n * persistent subscribers inline instead. That is no longer dual-dispatch: the\n * producer stamps the queued job with what it already delivered, so the worker\n * skips it. Either way a persistent emit runs its subscribers exactly once.\n *\n * The `mercato server` bootstrap may rewrite the env to `false` for a process it\n * knows runs no worker (see {@link reconcileSingleDelivery}). The bus does NOT\n * second-guess the flag itself: the queue is durable, so a persistent emit with\n * no worker yet is delayed, not lost, and a worker started later drains the\n * backlog. The bus and the events worker read the same env, so they always agree\n * within a process.\n */\nexport function isSingleDeliveryRequested(env: EnvSource = process.env): boolean {\n return parseBooleanWithDefault(env[EVENTS_SINGLE_DELIVERY_ENV], true)\n}\n\n/**\n * @deprecated Not read at runtime by this package - the bus never reconciles the\n * flag against worker availability. The live consumer is the `mercato server`\n * bootstrap, which mirrors this logic in\n * `packages/cli/src/lib/events-single-delivery.ts` (the CLI cannot import this\n * package). Kept for one minor because it is published API.\n */\nexport function isExternalWorkerAcknowledged(env: EnvSource = process.env): boolean {\n return parseBooleanWithDefault(env[EVENTS_EXTERNAL_WORKER_ENV], false)\n}\n\nexport type SingleDeliveryReconciliation = {\n /** The value the process should actually use. */\n effective: boolean\n /** Set when single-delivery was requested but disabled for safety. */\n warning?: string\n}\n\n/**\n * Guards the default-on single-delivery against the silent-loss failure mode:\n * with single-delivery on, persistent subscribers are skipped inline and ONLY\n * the events worker dispatches them. If no worker drains the queue, those\n * persistent side effects (notifications, queued emails, indexing) never run.\n *\n * The durable queue already covers transient worker downtime - a job persists\n * and drains when a worker returns. The dangerous case is a process configured\n * to run with NO events worker at all (auto-spawn off and no external worker).\n * For that case this fails safe: it disables single-delivery so persistent\n * subscribers run inline, and surfaces a loud warning telling the operator how\n * to move the work back off the request path.\n *\n * @deprecated Not read at runtime by this package - the bus never reconciles the\n * flag against worker availability. The live consumer is the `mercato server`\n * bootstrap, which mirrors this logic in\n * `packages/cli/src/lib/events-single-delivery.ts` (the CLI cannot import this\n * package). Kept for one minor because it is published API.\n */\nexport function reconcileSingleDelivery(input: {\n requested: boolean\n workersAvailable: boolean\n}): SingleDeliveryReconciliation {\n if (!input.requested) return { effective: false }\n if (input.workersAvailable) return { effective: true }\n return {\n effective: false,\n warning:\n `[events] ${EVENTS_SINGLE_DELIVERY_ENV} is on (default) but this process auto-spawns no events worker ` +\n `(AUTO_SPAWN_WORKERS=off) and ${EVENTS_EXTERNAL_WORKER_ENV} is not set. Persistent subscribers would be ` +\n `skipped inline with nothing to drain the queue, silently dropping notifications, queued emails, and ` +\n `indexing. Running them inline instead, on the caller's request path. To move this work back off ` +\n `the request path, run an events worker (\\`mercato queue worker events\\`) and set ` +\n `${EVENTS_EXTERNAL_WORKER_ENV}=true, or enable AUTO_SPAWN_WORKERS.`,\n }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,+BAA+B;AAGjC,MAAM,6BAA6B;AAMnC,MAAM,6BAA6B;AAwBnC,SAAS,0BAA0B,MAAiB,QAAQ,KAAc;AAC/E,SAAO,wBAAwB,IAAI,0BAA0B,GAAG,IAAI;AACtE;AASO,SAAS,6BAA6B,MAAiB,QAAQ,KAAc;AAClF,SAAO,wBAAwB,IAAI,0BAA0B,GAAG,KAAK;AACvE;AA4BO,SAAS,wBAAwB,OAGP;AAC/B,MAAI,CAAC,MAAM,UAAW,QAAO,EAAE,WAAW,MAAM;AAChD,MAAI,MAAM,iBAAkB,QAAO,EAAE,WAAW,KAAK;AACrD,SAAO;AAAA,IACL,WAAW;AAAA,IACX,SACE,YAAY,0BAA0B,+FACN,0BAA0B,qUAIvD,0BAA0B;AAAA,EACjC;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/events",
|
|
3
|
-
"version": "0.6.8-develop.
|
|
3
|
+
"version": "0.6.8-develop.6924.1.a8d208fcdc",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
}
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@open-mercato/queue": "0.6.8-develop.
|
|
37
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
36
|
+
"@open-mercato/queue": "0.6.8-develop.6924.1.a8d208fcdc",
|
|
37
|
+
"@open-mercato/shared": "0.6.8-develop.6924.1.a8d208fcdc",
|
|
38
38
|
"pg": "8.22.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|