@open-mercato/events 0.6.5-develop.5382.1.f542de69af → 0.6.5
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 +10 -0
- package/dist/bus.js +61 -9
- package/dist/bus.js.map +2 -2
- package/dist/modules/events/workers/events.worker.js +19 -1
- package/dist/modules/events/workers/events.worker.js.map +2 -2
- package/package.json +4 -5
- package/src/__tests__/shared-producer.test.ts +97 -0
- package/src/__tests__/single-delivery.test.ts +95 -0
- package/src/bus.ts +102 -12
- package/src/modules/events/workers/__tests__/events.worker.test.ts +73 -0
- package/src/modules/events/workers/events.worker.ts +37 -1
- package/src/types.ts +9 -1
package/AGENTS.md
CHANGED
|
@@ -76,6 +76,16 @@ export default async function handler(payload, ctx) { /* ... */ }
|
|
|
76
76
|
- When `QUEUE_STRATEGY=local`, persistent events process from `.mercato/queue/` (or `QUEUE_BASE_DIR`)
|
|
77
77
|
- Ephemeral subscribers always run in-process regardless of queue strategy
|
|
78
78
|
|
|
79
|
+
### Persistent delivery: legacy vs single-delivery (`OM_EVENTS_SINGLE_DELIVERY`)
|
|
80
|
+
|
|
81
|
+
By default (`OM_EVENTS_SINGLE_DELIVERY` unset/false) a persistent emit is delivered on **both** paths: inline to every matching in-memory subscriber **and** through the events worker (exact-match). This double-dispatches exact-match subscribers and never reaches wildcard (`event: '*'`) persistent subscribers in the worker.
|
|
82
|
+
|
|
83
|
+
Set `OM_EVENTS_SINGLE_DELIVERY=true` to make persistent delivery single-path:
|
|
84
|
+
- the bus skips inline delivery of **persistent-marked** subscribers on a persistent emit (ephemeral subscribers still run inline);
|
|
85
|
+
- the events worker dispatches **persistent** subscribers via `matchEventPattern`, so wildcard persistent subscribers are finally reached.
|
|
86
|
+
|
|
87
|
+
Both halves are gated by the same flag and MUST move together. When enabling it, ensure the events worker runs (default `AUTO_SPAWN_WORKERS=true`) and validate under both `QUEUE_STRATEGY=local` and `=async` so no persistent subscriber is dropped. This is the "Ask First: changing persistent delivery semantics" gate — the flag defaults off to preserve today's behavior.
|
|
88
|
+
|
|
79
89
|
## Queue Integration
|
|
80
90
|
|
|
81
91
|
| Queue strategy | Ephemeral events | Persistent events |
|
package/dist/bus.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { createQueue } from "@open-mercato/queue";
|
|
2
|
+
import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
|
|
2
3
|
import { matchEventPattern } from "@open-mercato/shared/lib/events/patterns";
|
|
3
4
|
import { getRedisUrlOrThrow } from "@open-mercato/shared/lib/redis/connection";
|
|
4
5
|
import { isBroadcastEvent } from "@open-mercato/shared/modules/events";
|
|
5
6
|
import { registerCrossProcessEventListener } from "./bridge.js";
|
|
6
7
|
import { publishCrossProcessEvent } from "./bridge.js";
|
|
7
8
|
const EVENTS_QUEUE_NAME = "events";
|
|
9
|
+
function isSingleDeliveryEnabled() {
|
|
10
|
+
return parseBooleanWithDefault(process.env.OM_EVENTS_SINGLE_DELIVERY, false);
|
|
11
|
+
}
|
|
8
12
|
const GLOBAL_EVENT_TAPS_KEY = "__openMercatoEventBusGlobalTaps__";
|
|
9
13
|
function hasTenantScope(payload) {
|
|
10
14
|
return typeof payload?.tenantId === "string" && String(payload.tenantId).trim().length > 0;
|
|
@@ -25,23 +29,67 @@ function registerGlobalEventTap(handler) {
|
|
|
25
29
|
taps.delete(handler);
|
|
26
30
|
};
|
|
27
31
|
}
|
|
32
|
+
const EVENTS_PRODUCER_QUEUE_KEY = "__openMercatoEventsProducerQueues__";
|
|
33
|
+
const EVENTS_PRODUCER_SHUTDOWN_KEY = "__openMercatoEventsProducerShutdown__";
|
|
34
|
+
function isSharedProducerEnabled() {
|
|
35
|
+
return parseBooleanWithDefault(process.env.OM_EVENTS_SHARED_PRODUCER, true);
|
|
36
|
+
}
|
|
37
|
+
function getProducerQueueRegistry() {
|
|
38
|
+
const existing = globalThis[EVENTS_PRODUCER_QUEUE_KEY];
|
|
39
|
+
if (existing instanceof Map) {
|
|
40
|
+
return existing;
|
|
41
|
+
}
|
|
42
|
+
const created = /* @__PURE__ */ new Map();
|
|
43
|
+
globalThis[EVENTS_PRODUCER_QUEUE_KEY] = created;
|
|
44
|
+
return created;
|
|
45
|
+
}
|
|
46
|
+
function registerProducerShutdownHook() {
|
|
47
|
+
if (globalThis[EVENTS_PRODUCER_SHUTDOWN_KEY]) return;
|
|
48
|
+
const shutdown = () => {
|
|
49
|
+
const registry = getProducerQueueRegistry();
|
|
50
|
+
for (const sharedQueue of registry.values()) {
|
|
51
|
+
Promise.resolve(sharedQueue.close()).catch(() => {
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
registry.clear();
|
|
55
|
+
};
|
|
56
|
+
process.once("SIGTERM", shutdown);
|
|
57
|
+
process.once("SIGINT", shutdown);
|
|
58
|
+
globalThis[EVENTS_PRODUCER_SHUTDOWN_KEY] = true;
|
|
59
|
+
}
|
|
28
60
|
function createEventBus(opts) {
|
|
29
61
|
const listeners = /* @__PURE__ */ new Map();
|
|
62
|
+
const persistentHandlers = /* @__PURE__ */ new Set();
|
|
30
63
|
const queueStrategy = opts.queueStrategy ?? (process.env.QUEUE_STRATEGY === "async" ? "async" : "local");
|
|
31
64
|
let queue = null;
|
|
32
65
|
function getQueue() {
|
|
33
|
-
if (!
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
66
|
+
if (queueStrategy !== "async" || !isSharedProducerEnabled()) {
|
|
67
|
+
if (!queue) {
|
|
68
|
+
queue = queueStrategy === "async" ? createQueue(EVENTS_QUEUE_NAME, "async", {
|
|
69
|
+
connection: { url: getRedisUrlOrThrow("QUEUE") }
|
|
70
|
+
}) : createQueue(EVENTS_QUEUE_NAME, "local");
|
|
71
|
+
}
|
|
72
|
+
return queue;
|
|
37
73
|
}
|
|
38
|
-
|
|
74
|
+
const redisUrl = getRedisUrlOrThrow("QUEUE");
|
|
75
|
+
const registry = getProducerQueueRegistry();
|
|
76
|
+
const cacheKey = `async:${redisUrl}`;
|
|
77
|
+
let shared = registry.get(cacheKey);
|
|
78
|
+
if (!shared) {
|
|
79
|
+
shared = createQueue(EVENTS_QUEUE_NAME, "async", {
|
|
80
|
+
connection: { url: redisUrl }
|
|
81
|
+
});
|
|
82
|
+
registry.set(cacheKey, shared);
|
|
83
|
+
registerProducerShutdownHook();
|
|
84
|
+
}
|
|
85
|
+
return shared;
|
|
39
86
|
}
|
|
40
|
-
async function deliver(event, payload, options) {
|
|
87
|
+
async function deliver(event, payload, options, skipPersistent = false) {
|
|
41
88
|
for (const [pattern, handlers] of listeners) {
|
|
42
89
|
if (!matchEventPattern(event, pattern)) continue;
|
|
43
90
|
if (!handlers || handlers.size === 0) continue;
|
|
44
91
|
for (const handler of handlers) {
|
|
92
|
+
if (skipPersistent && persistentHandlers.has(handler)) continue;
|
|
45
93
|
try {
|
|
46
94
|
await Promise.resolve(handler(payload, {
|
|
47
95
|
resolve: opts.resolve,
|
|
@@ -55,15 +103,18 @@ function createEventBus(opts) {
|
|
|
55
103
|
}
|
|
56
104
|
}
|
|
57
105
|
}
|
|
58
|
-
function on(event, handler) {
|
|
106
|
+
function on(event, handler, options) {
|
|
59
107
|
if (!listeners.has(event)) {
|
|
60
108
|
listeners.set(event, /* @__PURE__ */ new Set());
|
|
61
109
|
}
|
|
62
110
|
listeners.get(event).add(handler);
|
|
111
|
+
if (options?.persistent) {
|
|
112
|
+
persistentHandlers.add(handler);
|
|
113
|
+
}
|
|
63
114
|
}
|
|
64
115
|
function registerModuleSubscribers(subs) {
|
|
65
116
|
for (const sub of subs) {
|
|
66
|
-
on(sub.event, sub.handler);
|
|
117
|
+
on(sub.event, sub.handler, { persistent: sub.persistent });
|
|
67
118
|
}
|
|
68
119
|
}
|
|
69
120
|
async function emit(event, payload, options) {
|
|
@@ -75,7 +126,8 @@ function createEventBus(opts) {
|
|
|
75
126
|
console.error(`[events] Global tap error for "${event}":`, error);
|
|
76
127
|
}
|
|
77
128
|
}
|
|
78
|
-
|
|
129
|
+
const skipPersistentInline = Boolean(options?.persistent) && isSingleDeliveryEnabled();
|
|
130
|
+
await deliver(event, payload, options, skipPersistentInline);
|
|
79
131
|
if (isBroadcastEvent(event) && hasTenantScope(payload)) {
|
|
80
132
|
try {
|
|
81
133
|
await publishCrossProcessEvent(event, payload, options);
|
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 { 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'\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\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/**\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<SubscriberHandler>>()\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 function getQueue(): Queue<EventJobData> {\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 /**\n * Delivers an event to all registered in-memory handlers.\n * Supports wildcard pattern matching for event patterns.\n */\n async function deliver(event: string, payload: EventPayload, options?: EmitOptions): 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 handler of handlers) {\n try {\n // Pass eventName in context for wildcard handlers\n await Promise.resolve(handler(payload, {\n resolve: opts.resolve,\n eventName: event,\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n }))\n } catch (error) {\n console.error(`[events] Handler error for \"${event}\" (pattern: \"${pattern}\"):`, error)\n }\n }\n }\n }\n\n /**\n * Registers a handler for an event.\n */\n function on(event: string, handler: SubscriberHandler): void {\n if (!listeners.has(event)) {\n listeners.set(event, new Set())\n }\n listeners.get(event)!.add(handler)\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)\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 console.error(`[events] Global tap error for \"${event}\":`, error)\n }\n }\n\n // Always deliver to in-memory handlers first\n await deliver(event, payload, options)\n\n if (isBroadcastEvent(event) && hasTenantScope(payload)) {\n try {\n await publishCrossProcessEvent(event, payload, options)\n } catch (error) {\n console.error(`[events] Cross-process publish error for \"${event}\":`, 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,yBAAyB;AAClC,SAAS,0BAA0B;AACnC,SAAS,wBAAwB;AACjC,SAAS,yCAAyC;AAClD,SAAS,gCAAgC;AAWzC,MAAM,oBAAoB;
|
|
4
|
+
"sourcesContent": ["import { createQueue } from '@open-mercato/queue'\nimport type { Queue } from '@open-mercato/queue'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\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'\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\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. Default off preserves the legacy\n * dual-dispatch behavior. Both this flag and the worker MUST agree, so the worker\n * reads the same env var.\n */\nfunction isSingleDeliveryEnabled(): boolean {\n return parseBooleanWithDefault(process.env.OM_EVENTS_SINGLE_DELIVERY, false)\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<SubscriberHandler>>()\n // Handlers registered as persistent (worker-dispatched). Used by the\n // single-delivery path to skip them inline on a persistent emit.\n const persistentHandlers = new Set<SubscriberHandler>()\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 handler of handlers) {\n // Single-delivery: persistent subscribers are dispatched by the worker,\n // so skip them inline to avoid double execution.\n if (skipPersistent && persistentHandlers.has(handler)) continue\n try {\n // Pass eventName in context for wildcard handlers\n await Promise.resolve(handler(payload, {\n resolve: opts.resolve,\n eventName: event,\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n }))\n } catch (error) {\n console.error(`[events] Handler error for \"${event}\" (pattern: \"${pattern}\"):`, error)\n }\n }\n }\n }\n\n /**\n * Registers a handler for an event.\n */\n function on(event: string, handler: SubscriberHandler, options?: { persistent?: boolean }): void {\n if (!listeners.has(event)) {\n listeners.set(event, new Set())\n }\n listeners.get(event)!.add(handler)\n if (options?.persistent) {\n persistentHandlers.add(handler)\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 })\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 console.error(`[events] Global tap error for \"${event}\":`, 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 const skipPersistentInline = Boolean(options?.persistent) && isSingleDeliveryEnabled()\n await deliver(event, payload, options, skipPersistentInline)\n\n if (isBroadcastEvent(event) && hasTenantScope(payload)) {\n try {\n await publishCrossProcessEvent(event, payload, options)\n } catch (error) {\n console.error(`[events] Cross-process publish error for \"${event}\":`, 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": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { getCliModules } from "@open-mercato/shared/modules/registry";
|
|
2
|
+
import { matchEventPattern } from "@open-mercato/shared/lib/events/patterns";
|
|
3
|
+
import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
|
|
2
4
|
const EVENTS_QUEUE_NAME = "events";
|
|
3
5
|
const DEFAULT_CONCURRENCY = 1;
|
|
4
6
|
const envConcurrency = process.env.WORKERS_EVENTS_CONCURRENCY;
|
|
@@ -6,6 +8,22 @@ const metadata = {
|
|
|
6
8
|
queue: EVENTS_QUEUE_NAME,
|
|
7
9
|
concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY
|
|
8
10
|
};
|
|
11
|
+
function isSingleDeliveryEnabled() {
|
|
12
|
+
return parseBooleanWithDefault(process.env.OM_EVENTS_SINGLE_DELIVERY, false);
|
|
13
|
+
}
|
|
14
|
+
function resolveSubscribers(listeners, event) {
|
|
15
|
+
if (!isSingleDeliveryEnabled()) {
|
|
16
|
+
return listeners.get(event) ?? [];
|
|
17
|
+
}
|
|
18
|
+
const matched = [];
|
|
19
|
+
for (const [pattern, subs] of listeners) {
|
|
20
|
+
if (!matchEventPattern(event, pattern)) continue;
|
|
21
|
+
for (const sub of subs) {
|
|
22
|
+
if (sub.persistent) matched.push(sub);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return matched;
|
|
26
|
+
}
|
|
9
27
|
let cachedListenerMap = null;
|
|
10
28
|
function clearListenerCache() {
|
|
11
29
|
cachedListenerMap = null;
|
|
@@ -31,7 +49,7 @@ function getListenerMap() {
|
|
|
31
49
|
async function handle(job, ctx) {
|
|
32
50
|
const { event, payload, options } = job.payload;
|
|
33
51
|
const listeners = getListenerMap();
|
|
34
|
-
const subscribers = listeners
|
|
52
|
+
const subscribers = resolveSubscribers(listeners, event);
|
|
35
53
|
if (!subscribers || subscribers.length === 0) return;
|
|
36
54
|
const handlerCtx = {
|
|
37
55
|
resolve: ctx.resolve,
|
|
@@ -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 { getCliModules } from '@open-mercato/shared/modules/registry'\n\nexport const EVENTS_QUEUE_NAME = '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\ntype HandlerContext = {\n resolve: <T = unknown>(name: string) => T\n tenantId?: string | null\n organizationId?: string | null\n}\n\ntype SubscriberEntry = {\n id: string\n event: string\n handler: (payload: unknown, ctx: unknown) => Promise<void> | void\n}\n\n// Cached listener map - built once on first use\nlet cachedListenerMap: Map<string, SubscriberEntry[]> | null = null\n\n/**\n * Clear the cached listener map (for testing purposes).\n */\nexport function clearListenerCache(): void {\n cachedListenerMap = null\n}\n\n// Build listener map from module subscribers\nfunction buildListenerMap(): Map<string, SubscriberEntry[]> {\n const listeners = new Map<string, SubscriberEntry[]>()\n for (const mod of getCliModules()) {\n const subs = (mod as { subscribers?: SubscriberEntry[] }).subscribers\n if (!subs) continue\n for (const sub of subs) {\n if (!listeners.has(sub.event)) listeners.set(sub.event, [])\n listeners.get(sub.event)!.push(sub)\n }\n }\n return listeners\n}\n\n// Get cached listener map, building on first access\nfunction getListenerMap(): Map<string, SubscriberEntry[]> {\n if (!cachedListenerMap) {\n cachedListenerMap = buildListenerMap()\n }\n return cachedListenerMap\n}\n\n/**\n * Events worker handler.\n * Dispatches queued events to registered module subscribers.\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 } = job.payload\n const listeners = getListenerMap()\n const subscribers = listeners
|
|
5
|
-
"mappings": "AACA,SAAS,qBAAqB;
|
|
4
|
+
"sourcesContent": ["import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport { getCliModules } from '@open-mercato/shared/modules/registry'\nimport { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\n\nexport const EVENTS_QUEUE_NAME = '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\ntype HandlerContext = {\n resolve: <T = unknown>(name: string) => T\n tenantId?: string | null\n organizationId?: string | null\n}\n\ntype SubscriberEntry = {\n id: string\n event: string\n persistent?: boolean\n handler: (payload: unknown, ctx: unknown) => Promise<void> | void\n}\n\n/**\n * Mirror of the event bus single-delivery flag. When enabled, the worker owns\n * dispatch of every persistent subscriber and matches by pattern so wildcard\n * (`event: '*'`) persistent subscribers are finally reached. Default off keeps\n * the legacy exact-match dispatch of all subscribers.\n */\nfunction isSingleDeliveryEnabled(): boolean {\n return parseBooleanWithDefault(process.env.OM_EVENTS_SINGLE_DELIVERY, false)\n}\n\n/**\n * Resolves the subscribers to run for a queued event.\n * - Legacy (flag off): exact-match lookup of every subscriber for the event.\n * - Single-delivery (flag on): persistent subscribers whose pattern matches the\n * event, including wildcards, so they run exactly once here instead of inline.\n */\nfunction resolveSubscribers(\n listeners: Map<string, SubscriberEntry[]>,\n event: string,\n): SubscriberEntry[] {\n if (!isSingleDeliveryEnabled()) {\n return listeners.get(event) ?? []\n }\n const matched: SubscriberEntry[] = []\n for (const [pattern, subs] of listeners) {\n if (!matchEventPattern(event, pattern)) continue\n for (const sub of subs) {\n if (sub.persistent) matched.push(sub)\n }\n }\n return matched\n}\n\n// Cached listener map - built once on first use\nlet cachedListenerMap: Map<string, SubscriberEntry[]> | null = null\n\n/**\n * Clear the cached listener map (for testing purposes).\n */\nexport function clearListenerCache(): void {\n cachedListenerMap = null\n}\n\n// Build listener map from module subscribers\nfunction buildListenerMap(): Map<string, SubscriberEntry[]> {\n const listeners = new Map<string, SubscriberEntry[]>()\n for (const mod of getCliModules()) {\n const subs = (mod as { subscribers?: SubscriberEntry[] }).subscribers\n if (!subs) continue\n for (const sub of subs) {\n if (!listeners.has(sub.event)) listeners.set(sub.event, [])\n listeners.get(sub.event)!.push(sub)\n }\n }\n return listeners\n}\n\n// Get cached listener map, building on first access\nfunction getListenerMap(): Map<string, SubscriberEntry[]> {\n if (!cachedListenerMap) {\n cachedListenerMap = buildListenerMap()\n }\n return cachedListenerMap\n}\n\n/**\n * Events worker handler.\n * Dispatches queued events to registered module subscribers.\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 } = job.payload\n const listeners = getListenerMap()\n const subscribers = resolveSubscribers(listeners, event)\n\n if (!subscribers || subscribers.length === 0) return\n\n const handlerCtx = {\n resolve: ctx.resolve,\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n }\n\n const results = await Promise.allSettled(\n subscribers.map((sub) => Promise.resolve(sub.handler(payload, handlerCtx)))\n )\n\n const errors: Array<{ subscriberId: string; error: unknown }> = []\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const sub = subscribers[i]\n console.error(`[events] Subscriber \"${sub.id}\" failed for event \"${event}\":`, result.reason)\n errors.push({ subscriberId: sub.id, error: result.reason })\n }\n }\n\n if (errors.length > 0) {\n const failedIds = errors.map((e) => e.subscriberId).join(', ')\n throw new Error(\n `${errors.length}/${subscribers.length} subscriber(s) failed for event \"${event}\": ${failedIds}`\n )\n }\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,qBAAqB;AAC9B,SAAS,yBAAyB;AAClC,SAAS,+BAA+B;AAEjC,MAAM,oBAAoB;AAEjC,MAAM,sBAAsB;AAC5B,MAAM,iBAAiB,QAAQ,IAAI;AAE5B,MAAM,WAAuB;AAAA,EAClC,OAAO;AAAA,EACP,aAAa,iBAAiB,SAAS,gBAAgB,EAAE,IAAI;AAC/D;AA8BA,SAAS,0BAAmC;AAC1C,SAAO,wBAAwB,QAAQ,IAAI,2BAA2B,KAAK;AAC7E;AAQA,SAAS,mBACP,WACA,OACmB;AACnB,MAAI,CAAC,wBAAwB,GAAG;AAC9B,WAAO,UAAU,IAAI,KAAK,KAAK,CAAC;AAAA,EAClC;AACA,QAAM,UAA6B,CAAC;AACpC,aAAW,CAAC,SAAS,IAAI,KAAK,WAAW;AACvC,QAAI,CAAC,kBAAkB,OAAO,OAAO,EAAG;AACxC,eAAW,OAAO,MAAM;AACtB,UAAI,IAAI,WAAY,SAAQ,KAAK,GAAG;AAAA,IACtC;AAAA,EACF;AACA,SAAO;AACT;AAGA,IAAI,oBAA2D;AAKxD,SAAS,qBAA2B;AACzC,sBAAoB;AACtB;AAGA,SAAS,mBAAmD;AAC1D,QAAM,YAAY,oBAAI,IAA+B;AACrD,aAAW,OAAO,cAAc,GAAG;AACjC,UAAM,OAAQ,IAA4C;AAC1D,QAAI,CAAC,KAAM;AACX,eAAW,OAAO,MAAM;AACtB,UAAI,CAAC,UAAU,IAAI,IAAI,KAAK,EAAG,WAAU,IAAI,IAAI,OAAO,CAAC,CAAC;AAC1D,gBAAU,IAAI,IAAI,KAAK,EAAG,KAAK,GAAG;AAAA,IACpC;AAAA,EACF;AACA,SAAO;AACT;AAGA,SAAS,iBAAiD;AACxD,MAAI,CAAC,mBAAmB;AACtB,wBAAoB,iBAAiB;AAAA,EACvC;AACA,SAAO;AACT;AAOA,eAAO,OACL,KACA,KACe;AACf,QAAM,EAAE,OAAO,SAAS,QAAQ,IAAI,IAAI;AACxC,QAAM,YAAY,eAAe;AACjC,QAAM,cAAc,mBAAmB,WAAW,KAAK;AAEvD,MAAI,CAAC,eAAe,YAAY,WAAW,EAAG;AAE9C,QAAM,aAAa;AAAA,IACjB,SAAS,IAAI;AAAA,IACb,UAAU,SAAS,YAAY;AAAA,IAC/B,gBAAgB,SAAS,kBAAkB;AAAA,EAC7C;AAEA,QAAM,UAAU,MAAM,QAAQ;AAAA,IAC5B,YAAY,IAAI,CAAC,QAAQ,QAAQ,QAAQ,IAAI,QAAQ,SAAS,UAAU,CAAC,CAAC;AAAA,EAC5E;AAEA,QAAM,SAA0D,CAAC;AACjE,WAAS,IAAI,GAAG,IAAI,QAAQ,QAAQ,KAAK;AACvC,UAAM,SAAS,QAAQ,CAAC;AACxB,QAAI,OAAO,WAAW,YAAY;AAChC,YAAM,MAAM,YAAY,CAAC;AACzB,cAAQ,MAAM,wBAAwB,IAAI,EAAE,uBAAuB,KAAK,MAAM,OAAO,MAAM;AAC3F,aAAO,KAAK,EAAE,cAAc,IAAI,IAAI,OAAO,OAAO,OAAO,CAAC;AAAA,IAC5D;AAAA,EACF;AAEA,MAAI,OAAO,SAAS,GAAG;AACrB,UAAM,YAAY,OAAO,IAAI,CAAC,MAAM,EAAE,YAAY,EAAE,KAAK,IAAI;AAC7D,UAAM,IAAI;AAAA,MACR,GAAG,OAAO,MAAM,IAAI,YAAY,MAAM,oCAAoC,KAAK,MAAM,SAAS;AAAA,IAChG;AAAA,EACF;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.5
|
|
3
|
+
"version": "0.6.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
}
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@open-mercato/queue": "0.6.5
|
|
36
|
-
"@open-mercato/shared": "0.6.5
|
|
35
|
+
"@open-mercato/queue": "0.6.5",
|
|
36
|
+
"@open-mercato/shared": "0.6.5",
|
|
37
37
|
"pg": "8.21.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
@@ -48,6 +48,5 @@
|
|
|
48
48
|
"type": "git",
|
|
49
49
|
"url": "https://github.com/open-mercato/open-mercato",
|
|
50
50
|
"directory": "packages/events"
|
|
51
|
-
}
|
|
52
|
-
"stableVersion": "0.6.4"
|
|
51
|
+
}
|
|
53
52
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const createQueueMock = jest.fn()
|
|
2
|
+
|
|
3
|
+
jest.mock('@open-mercato/queue', () => ({
|
|
4
|
+
createQueue: (...args: unknown[]) => createQueueMock(...args),
|
|
5
|
+
}))
|
|
6
|
+
|
|
7
|
+
import { createEventBus } from '@open-mercato/events/index'
|
|
8
|
+
|
|
9
|
+
const PRODUCER_QUEUE_KEY = '__openMercatoEventsProducerQueues__'
|
|
10
|
+
const PRODUCER_SHUTDOWN_KEY = '__openMercatoEventsProducerShutdown__'
|
|
11
|
+
|
|
12
|
+
function makeFakeQueue(id: number) {
|
|
13
|
+
return {
|
|
14
|
+
id,
|
|
15
|
+
name: 'events',
|
|
16
|
+
strategy: 'async' as const,
|
|
17
|
+
enqueue: jest.fn(async () => `job-${id}`),
|
|
18
|
+
clear: jest.fn(async () => ({ removed: 0 })),
|
|
19
|
+
close: jest.fn(async () => {}),
|
|
20
|
+
process: jest.fn(),
|
|
21
|
+
getJobCounts: jest.fn(),
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('persistent-events producer memoization (#2959)', () => {
|
|
26
|
+
const resolve = ((name: string) => name) as never
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
createQueueMock.mockReset()
|
|
30
|
+
let created = 0
|
|
31
|
+
createQueueMock.mockImplementation(() => makeFakeQueue(created++))
|
|
32
|
+
delete (globalThis as Record<string, unknown>)[PRODUCER_QUEUE_KEY]
|
|
33
|
+
delete (globalThis as Record<string, unknown>)[PRODUCER_SHUTDOWN_KEY]
|
|
34
|
+
process.env.REDIS_URL = 'redis://localhost:6379'
|
|
35
|
+
delete process.env.OM_EVENTS_SHARED_PRODUCER
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
delete process.env.REDIS_URL
|
|
40
|
+
delete process.env.OM_EVENTS_SHARED_PRODUCER
|
|
41
|
+
delete (globalThis as Record<string, unknown>)[PRODUCER_QUEUE_KEY]
|
|
42
|
+
delete (globalThis as Record<string, unknown>)[PRODUCER_SHUTDOWN_KEY]
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('async strategy reuses a single process-wide producer across buses', async () => {
|
|
46
|
+
const busA = createEventBus({ resolve, queueStrategy: 'async' })
|
|
47
|
+
const busB = createEventBus({ resolve, queueStrategy: 'async' })
|
|
48
|
+
|
|
49
|
+
await busA.emit('demo.event.happened', { id: 1 }, { persistent: true })
|
|
50
|
+
await busB.emit('demo.event.happened', { id: 2 }, { persistent: true })
|
|
51
|
+
|
|
52
|
+
// The leak fix: only one producer queue (one Redis connection) is created
|
|
53
|
+
// even though two separate request-scoped buses emitted persistent events.
|
|
54
|
+
expect(createQueueMock).toHaveBeenCalledTimes(1)
|
|
55
|
+
|
|
56
|
+
const registry = (globalThis as Record<string, unknown>)[PRODUCER_QUEUE_KEY] as Map<string, { enqueue: jest.Mock }>
|
|
57
|
+
expect(registry.size).toBe(1)
|
|
58
|
+
const shared = [...registry.values()][0]
|
|
59
|
+
expect(shared.enqueue).toHaveBeenCalledTimes(2)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('kill switch OM_EVENTS_SHARED_PRODUCER=0 restores per-bus producers', async () => {
|
|
63
|
+
process.env.OM_EVENTS_SHARED_PRODUCER = '0'
|
|
64
|
+
const busA = createEventBus({ resolve, queueStrategy: 'async' })
|
|
65
|
+
const busB = createEventBus({ resolve, queueStrategy: 'async' })
|
|
66
|
+
|
|
67
|
+
await busA.emit('demo.event.happened', { id: 1 }, { persistent: true })
|
|
68
|
+
await busB.emit('demo.event.happened', { id: 2 }, { persistent: true })
|
|
69
|
+
|
|
70
|
+
expect(createQueueMock).toHaveBeenCalledTimes(2)
|
|
71
|
+
expect((globalThis as Record<string, unknown>)[PRODUCER_QUEUE_KEY]).toBeUndefined()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('separate Redis URLs get separate producers', async () => {
|
|
75
|
+
const busA = createEventBus({ resolve, queueStrategy: 'async' })
|
|
76
|
+
await busA.emit('demo.event.happened', { id: 1 }, { persistent: true })
|
|
77
|
+
|
|
78
|
+
process.env.REDIS_URL = 'redis://other-host:6379'
|
|
79
|
+
const busB = createEventBus({ resolve, queueStrategy: 'async' })
|
|
80
|
+
await busB.emit('demo.event.happened', { id: 2 }, { persistent: true })
|
|
81
|
+
|
|
82
|
+
expect(createQueueMock).toHaveBeenCalledTimes(2)
|
|
83
|
+
const registry = (globalThis as Record<string, unknown>)[PRODUCER_QUEUE_KEY] as Map<string, unknown>
|
|
84
|
+
expect(registry.size).toBe(2)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('local strategy is not memoized (no pooled connection to share)', async () => {
|
|
88
|
+
const busA = createEventBus({ resolve, queueStrategy: 'local' })
|
|
89
|
+
const busB = createEventBus({ resolve, queueStrategy: 'local' })
|
|
90
|
+
|
|
91
|
+
await busA.emit('demo.event.happened', { id: 1 }, { persistent: true })
|
|
92
|
+
await busB.emit('demo.event.happened', { id: 2 }, { persistent: true })
|
|
93
|
+
|
|
94
|
+
expect(createQueueMock).toHaveBeenCalledTimes(2)
|
|
95
|
+
expect((globalThis as Record<string, unknown>)[PRODUCER_QUEUE_KEY]).toBeUndefined()
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { createEventBus } from '@open-mercato/events/index'
|
|
6
|
+
import type { SubscriberDescriptor } from '@open-mercato/events/types'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Regression coverage for issue #2960: persistent subscribers must run on
|
|
10
|
+
* exactly one path under the OM_EVENTS_SINGLE_DELIVERY flag, and the default-off
|
|
11
|
+
* behavior must be preserved byte-for-byte.
|
|
12
|
+
*/
|
|
13
|
+
describe('Event bus single-delivery (OM_EVENTS_SINGLE_DELIVERY)', () => {
|
|
14
|
+
const origCwd = process.cwd()
|
|
15
|
+
const origFlag = process.env.OM_EVENTS_SINGLE_DELIVERY
|
|
16
|
+
let tmp: string
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'events-single-delivery-'))
|
|
20
|
+
process.chdir(tmp)
|
|
21
|
+
delete process.env.QUEUE_STRATEGY
|
|
22
|
+
delete process.env.EVENTS_STRATEGY
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
process.chdir(origCwd)
|
|
27
|
+
if (origFlag === undefined) delete process.env.OM_EVENTS_SINGLE_DELIVERY
|
|
28
|
+
else process.env.OM_EVENTS_SINGLE_DELIVERY = origFlag
|
|
29
|
+
try { fs.rmSync(tmp, { recursive: true, force: true }) } catch {}
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
function makeSub(
|
|
33
|
+
id: string,
|
|
34
|
+
event: string,
|
|
35
|
+
persistent: boolean,
|
|
36
|
+
sink: string[],
|
|
37
|
+
): SubscriberDescriptor {
|
|
38
|
+
return { id, event, persistent, handler: () => { sink.push(id) } }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test('flag OFF (default): a persistent subscriber still runs inline on a persistent emit', async () => {
|
|
42
|
+
delete process.env.OM_EVENTS_SINGLE_DELIVERY
|
|
43
|
+
const calls: string[] = []
|
|
44
|
+
const bus = createEventBus({ resolve: ((name: string) => name) as never })
|
|
45
|
+
bus.registerModuleSubscribers([makeSub('persistent-sub', 'demo', true, calls)])
|
|
46
|
+
|
|
47
|
+
await bus.emit('demo', { a: 1 }, { persistent: true })
|
|
48
|
+
|
|
49
|
+
// Legacy dual-dispatch: inline delivery is preserved when the flag is off.
|
|
50
|
+
expect(calls).toEqual(['persistent-sub'])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('flag ON: a persistent subscriber is skipped inline (deferred to the worker)', async () => {
|
|
54
|
+
process.env.OM_EVENTS_SINGLE_DELIVERY = 'true'
|
|
55
|
+
const calls: string[] = []
|
|
56
|
+
const bus = createEventBus({ resolve: ((name: string) => name) as never })
|
|
57
|
+
bus.registerModuleSubscribers([
|
|
58
|
+
makeSub('persistent-sub', 'demo', true, calls),
|
|
59
|
+
makeSub('ephemeral-sub', 'demo', false, calls),
|
|
60
|
+
])
|
|
61
|
+
|
|
62
|
+
await bus.emit('demo', { a: 1 }, { persistent: true })
|
|
63
|
+
|
|
64
|
+
// Only the ephemeral subscriber runs inline; the persistent one is dispatched
|
|
65
|
+
// by the events worker from the queue, so it is skipped here.
|
|
66
|
+
expect(calls).toEqual(['ephemeral-sub'])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('flag ON: a non-persistent emit still delivers persistent subscribers inline', async () => {
|
|
70
|
+
process.env.OM_EVENTS_SINGLE_DELIVERY = 'true'
|
|
71
|
+
const calls: string[] = []
|
|
72
|
+
const bus = createEventBus({ resolve: ((name: string) => name) as never })
|
|
73
|
+
bus.registerModuleSubscribers([makeSub('persistent-sub', 'demo', true, calls)])
|
|
74
|
+
|
|
75
|
+
// Non-persistent emit is never enqueued, so inline delivery is the only path.
|
|
76
|
+
await bus.emit('demo', { a: 1 })
|
|
77
|
+
|
|
78
|
+
expect(calls).toEqual(['persistent-sub'])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('flag ON: persistent emit is still enqueued for the worker', async () => {
|
|
82
|
+
process.env.OM_EVENTS_SINGLE_DELIVERY = 'true'
|
|
83
|
+
const queuePath = path.join(path.resolve('.mercato/queue', 'events'), 'queue.json')
|
|
84
|
+
const calls: string[] = []
|
|
85
|
+
const bus = createEventBus({ resolve: ((name: string) => name) as never })
|
|
86
|
+
bus.registerModuleSubscribers([makeSub('persistent-sub', 'demo', true, calls)])
|
|
87
|
+
|
|
88
|
+
await bus.emit('demo', { a: 1 }, { persistent: true })
|
|
89
|
+
|
|
90
|
+
expect(calls).toEqual([])
|
|
91
|
+
const list = JSON.parse(fs.readFileSync(queuePath, 'utf8'))
|
|
92
|
+
expect(Array.isArray(list)).toBe(true)
|
|
93
|
+
expect(list.length).toBeGreaterThanOrEqual(1)
|
|
94
|
+
})
|
|
95
|
+
})
|
package/src/bus.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createQueue } from '@open-mercato/queue'
|
|
2
2
|
import type { Queue } from '@open-mercato/queue'
|
|
3
|
+
import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
|
|
3
4
|
import { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'
|
|
4
5
|
import { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'
|
|
5
6
|
import { isBroadcastEvent } from '@open-mercato/shared/modules/events'
|
|
@@ -17,6 +18,18 @@ import type {
|
|
|
17
18
|
/** Queue name for persistent events */
|
|
18
19
|
const EVENTS_QUEUE_NAME = 'events'
|
|
19
20
|
|
|
21
|
+
/**
|
|
22
|
+
* When enabled, a persistent emit delivers each subscriber on exactly one path:
|
|
23
|
+
* persistent-marked subscribers are skipped inline (the events worker dispatches
|
|
24
|
+
* them via pattern match, so wildcard persistent subscribers are reached), while
|
|
25
|
+
* ephemeral subscribers keep running inline. Default off preserves the legacy
|
|
26
|
+
* dual-dispatch behavior. Both this flag and the worker MUST agree, so the worker
|
|
27
|
+
* reads the same env var.
|
|
28
|
+
*/
|
|
29
|
+
function isSingleDeliveryEnabled(): boolean {
|
|
30
|
+
return parseBooleanWithDefault(process.env.OM_EVENTS_SINGLE_DELIVERY, false)
|
|
31
|
+
}
|
|
32
|
+
|
|
20
33
|
type GlobalEventTap = (event: string, payload: EventPayload, options?: EmitOptions) => void | Promise<void>
|
|
21
34
|
const GLOBAL_EVENT_TAPS_KEY = '__openMercatoEventBusGlobalTaps__'
|
|
22
35
|
|
|
@@ -50,6 +63,46 @@ type EventJobData = {
|
|
|
50
63
|
options?: EmitOptions
|
|
51
64
|
}
|
|
52
65
|
|
|
66
|
+
// Process-wide cache of the async (BullMQ) persistent-events producer queue.
|
|
67
|
+
// Each authenticated request builds a fresh DI container and event bus, so a
|
|
68
|
+
// per-bus producer queue opened a new ioredis connection per write request that
|
|
69
|
+
// was never closed — leaking one Redis connection per request until maxclients
|
|
70
|
+
// exhaustion. Memoizing the producer on `globalThis` (keyed by Redis URL, so a
|
|
71
|
+
// reconfigured URL still gets its own queue) keeps it at one connection per
|
|
72
|
+
// process, mirroring the `GLOBAL_EVENT_TAPS_KEY` and `getCachedRateLimiterService`
|
|
73
|
+
// patterns. The local (file-based) strategy holds no pooled connection and its
|
|
74
|
+
// base dir is cwd-relative, so it stays per-bus.
|
|
75
|
+
const EVENTS_PRODUCER_QUEUE_KEY = '__openMercatoEventsProducerQueues__'
|
|
76
|
+
const EVENTS_PRODUCER_SHUTDOWN_KEY = '__openMercatoEventsProducerShutdown__'
|
|
77
|
+
|
|
78
|
+
function isSharedProducerEnabled(): boolean {
|
|
79
|
+
return parseBooleanWithDefault(process.env.OM_EVENTS_SHARED_PRODUCER, true)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function getProducerQueueRegistry(): Map<string, Queue<EventJobData>> {
|
|
83
|
+
const existing = (globalThis as Record<string, unknown>)[EVENTS_PRODUCER_QUEUE_KEY]
|
|
84
|
+
if (existing instanceof Map) {
|
|
85
|
+
return existing as Map<string, Queue<EventJobData>>
|
|
86
|
+
}
|
|
87
|
+
const created = new Map<string, Queue<EventJobData>>()
|
|
88
|
+
;(globalThis as Record<string, unknown>)[EVENTS_PRODUCER_QUEUE_KEY] = created
|
|
89
|
+
return created
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function registerProducerShutdownHook(): void {
|
|
93
|
+
if ((globalThis as Record<string, unknown>)[EVENTS_PRODUCER_SHUTDOWN_KEY]) return
|
|
94
|
+
const shutdown = () => {
|
|
95
|
+
const registry = getProducerQueueRegistry()
|
|
96
|
+
for (const sharedQueue of registry.values()) {
|
|
97
|
+
Promise.resolve(sharedQueue.close()).catch(() => {})
|
|
98
|
+
}
|
|
99
|
+
registry.clear()
|
|
100
|
+
}
|
|
101
|
+
process.once('SIGTERM', shutdown)
|
|
102
|
+
process.once('SIGINT', shutdown)
|
|
103
|
+
;(globalThis as Record<string, unknown>)[EVENTS_PRODUCER_SHUTDOWN_KEY] = true
|
|
104
|
+
}
|
|
105
|
+
|
|
53
106
|
/**
|
|
54
107
|
* Creates an event bus instance.
|
|
55
108
|
*
|
|
@@ -83,6 +136,9 @@ type EventJobData = {
|
|
|
83
136
|
export function createEventBus(opts: CreateBusOptions): EventBus {
|
|
84
137
|
// In-memory listeners for immediate event delivery
|
|
85
138
|
const listeners = new Map<string, Set<SubscriberHandler>>()
|
|
139
|
+
// Handlers registered as persistent (worker-dispatched). Used by the
|
|
140
|
+
// single-delivery path to skip them inline on a persistent emit.
|
|
141
|
+
const persistentHandlers = new Set<SubscriberHandler>()
|
|
86
142
|
|
|
87
143
|
// Determine queue strategy from options or environment
|
|
88
144
|
const queueStrategy = opts.queueStrategy ??
|
|
@@ -93,29 +149,57 @@ export function createEventBus(opts: CreateBusOptions): EventBus {
|
|
|
93
149
|
|
|
94
150
|
/**
|
|
95
151
|
* Gets or creates the queue instance for persistent events.
|
|
152
|
+
*
|
|
153
|
+
* The async (BullMQ) producer is memoized process-wide so the per-request
|
|
154
|
+
* event bus reuses one Redis connection instead of leaking one per write
|
|
155
|
+
* request. The local strategy stays per-bus (no pooled connection, cwd-relative
|
|
156
|
+
* base dir). Set `OM_EVENTS_SHARED_PRODUCER=0` to fall back to per-bus producers.
|
|
96
157
|
*/
|
|
97
158
|
function getQueue(): Queue<EventJobData> {
|
|
98
|
-
if (!
|
|
99
|
-
queue
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
159
|
+
if (queueStrategy !== 'async' || !isSharedProducerEnabled()) {
|
|
160
|
+
if (!queue) {
|
|
161
|
+
queue = queueStrategy === 'async'
|
|
162
|
+
? createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'async', {
|
|
163
|
+
connection: { url: getRedisUrlOrThrow('QUEUE') },
|
|
164
|
+
})
|
|
165
|
+
: createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'local')
|
|
166
|
+
}
|
|
167
|
+
return queue
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const redisUrl = getRedisUrlOrThrow('QUEUE')
|
|
171
|
+
const registry = getProducerQueueRegistry()
|
|
172
|
+
const cacheKey = `async:${redisUrl}`
|
|
173
|
+
let shared = registry.get(cacheKey)
|
|
174
|
+
if (!shared) {
|
|
175
|
+
shared = createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'async', {
|
|
176
|
+
connection: { url: redisUrl },
|
|
177
|
+
})
|
|
178
|
+
registry.set(cacheKey, shared)
|
|
179
|
+
registerProducerShutdownHook()
|
|
104
180
|
}
|
|
105
|
-
return
|
|
181
|
+
return shared
|
|
106
182
|
}
|
|
107
183
|
|
|
108
184
|
/**
|
|
109
185
|
* Delivers an event to all registered in-memory handlers.
|
|
110
186
|
* Supports wildcard pattern matching for event patterns.
|
|
111
187
|
*/
|
|
112
|
-
async function deliver(
|
|
188
|
+
async function deliver(
|
|
189
|
+
event: string,
|
|
190
|
+
payload: EventPayload,
|
|
191
|
+
options?: EmitOptions,
|
|
192
|
+
skipPersistent = false,
|
|
193
|
+
): Promise<void> {
|
|
113
194
|
// Check all registered patterns (including wildcards)
|
|
114
195
|
for (const [pattern, handlers] of listeners) {
|
|
115
196
|
if (!matchEventPattern(event, pattern)) continue
|
|
116
197
|
if (!handlers || handlers.size === 0) continue
|
|
117
198
|
|
|
118
199
|
for (const handler of handlers) {
|
|
200
|
+
// Single-delivery: persistent subscribers are dispatched by the worker,
|
|
201
|
+
// so skip them inline to avoid double execution.
|
|
202
|
+
if (skipPersistent && persistentHandlers.has(handler)) continue
|
|
119
203
|
try {
|
|
120
204
|
// Pass eventName in context for wildcard handlers
|
|
121
205
|
await Promise.resolve(handler(payload, {
|
|
@@ -134,11 +218,14 @@ export function createEventBus(opts: CreateBusOptions): EventBus {
|
|
|
134
218
|
/**
|
|
135
219
|
* Registers a handler for an event.
|
|
136
220
|
*/
|
|
137
|
-
function on(event: string, handler: SubscriberHandler): void {
|
|
221
|
+
function on(event: string, handler: SubscriberHandler, options?: { persistent?: boolean }): void {
|
|
138
222
|
if (!listeners.has(event)) {
|
|
139
223
|
listeners.set(event, new Set())
|
|
140
224
|
}
|
|
141
225
|
listeners.get(event)!.add(handler)
|
|
226
|
+
if (options?.persistent) {
|
|
227
|
+
persistentHandlers.add(handler)
|
|
228
|
+
}
|
|
142
229
|
}
|
|
143
230
|
|
|
144
231
|
/**
|
|
@@ -146,7 +233,7 @@ export function createEventBus(opts: CreateBusOptions): EventBus {
|
|
|
146
233
|
*/
|
|
147
234
|
function registerModuleSubscribers(subs: SubscriberDescriptor[]): void {
|
|
148
235
|
for (const sub of subs) {
|
|
149
|
-
on(sub.event, sub.handler)
|
|
236
|
+
on(sub.event, sub.handler, { persistent: sub.persistent })
|
|
150
237
|
}
|
|
151
238
|
}
|
|
152
239
|
|
|
@@ -169,8 +256,11 @@ export function createEventBus(opts: CreateBusOptions): EventBus {
|
|
|
169
256
|
}
|
|
170
257
|
}
|
|
171
258
|
|
|
172
|
-
//
|
|
173
|
-
|
|
259
|
+
// Deliver to in-memory handlers first. Under single-delivery, persistent
|
|
260
|
+
// subscribers are skipped inline on a persistent emit because the events
|
|
261
|
+
// worker will dispatch them from the queue.
|
|
262
|
+
const skipPersistentInline = Boolean(options?.persistent) && isSingleDeliveryEnabled()
|
|
263
|
+
await deliver(event, payload, options, skipPersistentInline)
|
|
174
264
|
|
|
175
265
|
if (isBroadcastEvent(event) && hasTenantScope(payload)) {
|
|
176
266
|
try {
|
|
@@ -431,4 +431,77 @@ describe('Events Worker', () => {
|
|
|
431
431
|
errorSpy.mockRestore()
|
|
432
432
|
})
|
|
433
433
|
})
|
|
434
|
+
|
|
435
|
+
describe('single-delivery dispatch (OM_EVENTS_SINGLE_DELIVERY) — issue #2960', () => {
|
|
436
|
+
const origFlag = process.env.OM_EVENTS_SINGLE_DELIVERY
|
|
437
|
+
|
|
438
|
+
afterEach(() => {
|
|
439
|
+
if (origFlag === undefined) delete process.env.OM_EVENTS_SINGLE_DELIVERY
|
|
440
|
+
else process.env.OM_EVENTS_SINGLE_DELIVERY = origFlag
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
const createMockJob = (event: string, payload: unknown): QueuedJob<{ event: string; payload: unknown }> => ({
|
|
444
|
+
id: 'test-job-id',
|
|
445
|
+
payload: { event, payload },
|
|
446
|
+
createdAt: new Date().toISOString(),
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
const createMockContext = (): JobContext & { resolve: <T = unknown>(name: string) => T } => ({
|
|
450
|
+
jobId: 'test-job-id',
|
|
451
|
+
attemptNumber: 1,
|
|
452
|
+
queueName: 'events',
|
|
453
|
+
resolve: <T = unknown>(name: string): T => { throw new Error(`No mock for ${name}`) },
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
it('flag ON: dispatches wildcard persistent subscribers that exact-match never reached', async () => {
|
|
457
|
+
process.env.OM_EVENTS_SINGLE_DELIVERY = 'true'
|
|
458
|
+
clearListenerCache()
|
|
459
|
+
const calls: string[] = []
|
|
460
|
+
registerCliModules([{
|
|
461
|
+
id: 'm',
|
|
462
|
+
subscribers: [
|
|
463
|
+
{ id: 'wildcard:persistent', event: '*', persistent: true, handler: () => { calls.push('wild') } },
|
|
464
|
+
],
|
|
465
|
+
}])
|
|
466
|
+
|
|
467
|
+
await handle(createMockJob('any.event', {}), createMockContext())
|
|
468
|
+
|
|
469
|
+
expect(calls).toEqual(['wild'])
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
it('flag ON: excludes non-persistent subscribers from worker dispatch', async () => {
|
|
473
|
+
process.env.OM_EVENTS_SINGLE_DELIVERY = 'true'
|
|
474
|
+
clearListenerCache()
|
|
475
|
+
const calls: string[] = []
|
|
476
|
+
registerCliModules([{
|
|
477
|
+
id: 'm',
|
|
478
|
+
subscribers: [
|
|
479
|
+
{ id: 'p', event: 'user.created', persistent: true, handler: () => { calls.push('p') } },
|
|
480
|
+
{ id: 'e', event: 'user.created', persistent: false, handler: () => { calls.push('e') } },
|
|
481
|
+
],
|
|
482
|
+
}])
|
|
483
|
+
|
|
484
|
+
await handle(createMockJob('user.created', {}), createMockContext())
|
|
485
|
+
|
|
486
|
+
expect(calls).toEqual(['p'])
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
it('flag OFF (default): preserves legacy exact-match dispatch and never reaches wildcards', async () => {
|
|
490
|
+
delete process.env.OM_EVENTS_SINGLE_DELIVERY
|
|
491
|
+
clearListenerCache()
|
|
492
|
+
const calls: string[] = []
|
|
493
|
+
registerCliModules([{
|
|
494
|
+
id: 'm',
|
|
495
|
+
subscribers: [
|
|
496
|
+
{ id: 'p', event: 'user.created', persistent: true, handler: () => { calls.push('p') } },
|
|
497
|
+
{ id: 'w', event: '*', persistent: true, handler: () => { calls.push('w') } },
|
|
498
|
+
],
|
|
499
|
+
}])
|
|
500
|
+
|
|
501
|
+
await handle(createMockJob('user.created', {}), createMockContext())
|
|
502
|
+
|
|
503
|
+
// Legacy behavior: exact-match only, so the wildcard subscriber is not reached here.
|
|
504
|
+
expect(calls).toEqual(['p'])
|
|
505
|
+
})
|
|
506
|
+
})
|
|
434
507
|
})
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'
|
|
2
2
|
import { getCliModules } from '@open-mercato/shared/modules/registry'
|
|
3
|
+
import { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'
|
|
4
|
+
import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
|
|
3
5
|
|
|
4
6
|
export const EVENTS_QUEUE_NAME = 'events'
|
|
5
7
|
|
|
@@ -29,9 +31,43 @@ type HandlerContext = {
|
|
|
29
31
|
type SubscriberEntry = {
|
|
30
32
|
id: string
|
|
31
33
|
event: string
|
|
34
|
+
persistent?: boolean
|
|
32
35
|
handler: (payload: unknown, ctx: unknown) => Promise<void> | void
|
|
33
36
|
}
|
|
34
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Mirror of the event bus single-delivery flag. When enabled, the worker owns
|
|
40
|
+
* dispatch of every persistent subscriber and matches by pattern so wildcard
|
|
41
|
+
* (`event: '*'`) persistent subscribers are finally reached. Default off keeps
|
|
42
|
+
* the legacy exact-match dispatch of all subscribers.
|
|
43
|
+
*/
|
|
44
|
+
function isSingleDeliveryEnabled(): boolean {
|
|
45
|
+
return parseBooleanWithDefault(process.env.OM_EVENTS_SINGLE_DELIVERY, false)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolves the subscribers to run for a queued event.
|
|
50
|
+
* - Legacy (flag off): exact-match lookup of every subscriber for the event.
|
|
51
|
+
* - Single-delivery (flag on): persistent subscribers whose pattern matches the
|
|
52
|
+
* event, including wildcards, so they run exactly once here instead of inline.
|
|
53
|
+
*/
|
|
54
|
+
function resolveSubscribers(
|
|
55
|
+
listeners: Map<string, SubscriberEntry[]>,
|
|
56
|
+
event: string,
|
|
57
|
+
): SubscriberEntry[] {
|
|
58
|
+
if (!isSingleDeliveryEnabled()) {
|
|
59
|
+
return listeners.get(event) ?? []
|
|
60
|
+
}
|
|
61
|
+
const matched: SubscriberEntry[] = []
|
|
62
|
+
for (const [pattern, subs] of listeners) {
|
|
63
|
+
if (!matchEventPattern(event, pattern)) continue
|
|
64
|
+
for (const sub of subs) {
|
|
65
|
+
if (sub.persistent) matched.push(sub)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return matched
|
|
69
|
+
}
|
|
70
|
+
|
|
35
71
|
// Cached listener map - built once on first use
|
|
36
72
|
let cachedListenerMap: Map<string, SubscriberEntry[]> | null = null
|
|
37
73
|
|
|
@@ -75,7 +111,7 @@ export default async function handle(
|
|
|
75
111
|
): Promise<void> {
|
|
76
112
|
const { event, payload, options } = job.payload
|
|
77
113
|
const listeners = getListenerMap()
|
|
78
|
-
const subscribers = listeners
|
|
114
|
+
const subscribers = resolveSubscribers(listeners, event)
|
|
79
115
|
|
|
80
116
|
if (!subscribers || subscribers.length === 0) return
|
|
81
117
|
|
package/src/types.ts
CHANGED
|
@@ -43,6 +43,12 @@ export type SubscriberDescriptor = {
|
|
|
43
43
|
id: string
|
|
44
44
|
/** Event name to subscribe to */
|
|
45
45
|
event: string
|
|
46
|
+
/**
|
|
47
|
+
* Whether this subscriber is dispatched through the persistent events queue
|
|
48
|
+
* (events worker) rather than inline. Consulted by the `OM_EVENTS_SINGLE_DELIVERY`
|
|
49
|
+
* single-delivery path to decide which subscribers run inline vs in the worker.
|
|
50
|
+
*/
|
|
51
|
+
persistent?: boolean
|
|
46
52
|
/** Handler function */
|
|
47
53
|
handler: SubscriberHandler
|
|
48
54
|
}
|
|
@@ -100,8 +106,10 @@ export interface EventBus {
|
|
|
100
106
|
*
|
|
101
107
|
* @param event - Event name to listen for
|
|
102
108
|
* @param handler - Handler function
|
|
109
|
+
* @param options - Optional registration options. `persistent: true` marks the
|
|
110
|
+
* handler as worker-dispatched so the single-delivery path skips it inline.
|
|
103
111
|
*/
|
|
104
|
-
on(event: string, handler: SubscriberHandler): void
|
|
112
|
+
on(event: string, handler: SubscriberHandler, options?: { persistent?: boolean }): void
|
|
105
113
|
|
|
106
114
|
/**
|
|
107
115
|
* Register multiple module subscribers at once.
|