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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,9 @@
1
1
  import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'
2
- import { getCliModules } from '@open-mercato/shared/modules/registry'
3
- import { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'
4
2
  import { createLogger } from '@open-mercato/shared/lib/logger'
5
- import { isSingleDeliveryRequested } from '../../../single-delivery'
3
+ import type { EventBus, QueuedDispatchResult } from '../../../types'
4
+
5
+ /** An `EventBus` proven at runtime to implement the optional `dispatchQueued`. */
6
+ type DispatchCapableBus = EventBus & Required<Pick<EventBus, 'dispatchQueued'>>
6
7
 
7
8
  export const EVENTS_QUEUE_NAME = 'events'
8
9
 
@@ -23,6 +24,12 @@ type EventJobPayload = {
23
24
  tenantId?: string | null
24
25
  organizationId?: string | null
25
26
  }
27
+ /**
28
+ * Producer stamp: the emitting process already ran the persistent subscribers
29
+ * inline because single-delivery was reconciled off there. Dispatching again
30
+ * here would double-run them, so the job is a no-op.
31
+ */
32
+ persistentDeliveredInline?: boolean
26
33
  }
27
34
 
28
35
  type HandlerContext = {
@@ -32,119 +39,124 @@ type HandlerContext = {
32
39
  organizationId?: string | null
33
40
  }
34
41
 
35
- type SubscriberEntry = {
36
- id: string
37
- event: string
38
- persistent?: boolean
39
- handler: (payload: unknown, ctx: unknown) => Promise<void> | void
40
- }
41
-
42
- /**
43
- * Mirror of the event bus single-delivery flag. When enabled, the worker owns
44
- * dispatch of every persistent subscriber and matches by pattern so wildcard
45
- * (`event: '*'`) persistent subscribers are finally reached. Defaults ON;
46
- * reconciled by the server bootstrap against worker availability and read from
47
- * the same env var as the bus so the two always agree within a process.
48
- */
49
- function isSingleDeliveryEnabled(): boolean {
50
- return isSingleDeliveryRequested()
51
- }
52
-
53
42
  /**
54
- * Resolves the subscribers to run for a queued event.
55
- * - Legacy (flag off): exact-match lookup of every subscriber for the event.
56
- * - Single-delivery (flag on): persistent subscribers whose pattern matches the
57
- * event, including wildcards, so they run exactly once here instead of inline.
43
+ * Resolves the DI event bus that owns subscriber dispatch for this job.
44
+ *
45
+ * The worker deliberately has no subscriber registry of its own. It used to build
46
+ * one from `getCliModules()`, which is populated only by the `mercato` bin, so any
47
+ * process that started a worker another way dispatched zero subscribers and
48
+ * completed the job silently - dropping webhook deliveries, workflow event
49
+ * triggers and business-rule triggers with no error and no log. The per-job
50
+ * request container already carries a bus with every module subscriber registered,
51
+ * so that is the single source of truth. When it is missing, fail the job loudly
52
+ * rather than repeat the silent drop.
58
53
  */
59
- function resolveSubscribers(
60
- listeners: Map<string, SubscriberEntry[]>,
61
- event: string,
62
- ): SubscriberEntry[] {
63
- if (!isSingleDeliveryEnabled()) {
64
- return listeners.get(event) ?? []
54
+ function resolveEventBus(ctx: HandlerContext, event: string): DispatchCapableBus {
55
+ let bus: unknown
56
+ try {
57
+ bus = ctx.resolve('eventBus')
58
+ } catch (error) {
59
+ throw new Error(
60
+ `[internal] Events worker cannot dispatch "${event}": no "eventBus" in the job container ` +
61
+ `(${error instanceof Error ? error.message : String(error)}). The events worker must run inside a ` +
62
+ `bootstrapped process, e.g. \`mercato queue worker events\` or \`mercato queue worker --all\`. ` +
63
+ `Failing the job so it retries instead of dropping the event's subscribers.`,
64
+ )
65
65
  }
66
- const matched: SubscriberEntry[] = []
67
- for (const [pattern, subs] of listeners) {
68
- if (!matchEventPattern(event, pattern)) continue
69
- for (const sub of subs) {
70
- if (sub.persistent) matched.push(sub)
71
- }
66
+ if (!bus || typeof (bus as EventBus).dispatchQueued !== 'function') {
67
+ throw new Error(
68
+ `[internal] Events worker cannot dispatch "${event}": the resolved "eventBus" has no ` +
69
+ `dispatchQueued(). Either the bus failed to initialize and the degraded no-op fallback is in ` +
70
+ `place, or a stale @open-mercato/events build is loaded alongside a newer one. Failing the job ` +
71
+ `so it retries instead of dropping the event's subscribers.`,
72
+ )
72
73
  }
73
- return matched
74
+ return bus as DispatchCapableBus
74
75
  }
75
76
 
76
- // Cached listener map - built once on first use
77
- let cachedListenerMap: Map<string, SubscriberEntry[]> | null = null
78
-
79
- /**
80
- * Clear the cached listener map (for testing purposes).
81
- */
82
- export function clearListenerCache(): void {
83
- cachedListenerMap = null
84
- }
85
-
86
- // Build listener map from module subscribers
87
- function buildListenerMap(): Map<string, SubscriberEntry[]> {
88
- const listeners = new Map<string, SubscriberEntry[]>()
89
- for (const mod of getCliModules()) {
90
- const subs = (mod as { subscribers?: SubscriberEntry[] }).subscribers
91
- if (!subs) continue
92
- for (const sub of subs) {
93
- if (!listeners.has(sub.event)) listeners.set(sub.event, [])
94
- listeners.get(sub.event)!.push(sub)
95
- }
77
+ function reportFailures(event: string, results: QueuedDispatchResult[]): void {
78
+ // Keyed on `ok`, never on `error !== undefined`: a handler can reject with
79
+ // `undefined`, which would otherwise be scored as a success and the job
80
+ // completed despite a failed subscriber.
81
+ const failures = results.filter((result) => !result.ok)
82
+ if (failures.length === 0) {
83
+ return
96
84
  }
97
- return listeners
98
- }
99
85
 
100
- // Get cached listener map, building on first access
101
- function getListenerMap(): Map<string, SubscriberEntry[]> {
102
- if (!cachedListenerMap) {
103
- cachedListenerMap = buildListenerMap()
86
+ for (const failure of failures) {
87
+ logger.error('Subscriber failed for event', {
88
+ event,
89
+ subscriberId: failure.subscriberId,
90
+ err: failure.error,
91
+ })
104
92
  }
105
- return cachedListenerMap
93
+
94
+ const failedIds = failures.map((failure) => failure.subscriberId).join(', ')
95
+ throw new Error(
96
+ `[internal] ${failures.length}/${results.length} subscriber(s) failed for event "${event}": ${failedIds}`,
97
+ )
106
98
  }
107
99
 
100
+ // Event names already reported as having no subscriber. The empty-result case is
101
+ // worth surfacing once - it is the residual silent-loss path - but an install
102
+ // carrying none of the wildcard persistent subscribers (webhooks outbound,
103
+ // workflow triggers, business-rule triggers) would otherwise log one `warn` per
104
+ // queued event forever, and a warning that fires in steady state is one operators
105
+ // learn to skip past.
106
+ const eventsReportedWithoutSubscribers = new Set<string>()
107
+
108
108
  /**
109
109
  * Events worker handler.
110
- * Dispatches queued events to registered module subscribers.
110
+ * Dispatches queued events to the subscribers registered on the DI event bus.
111
111
  * Each subscriber is isolated - failures in one don't affect others.
112
112
  */
113
113
  export default async function handle(
114
114
  job: QueuedJob<EventJobPayload>,
115
115
  ctx: JobContext & HandlerContext
116
116
  ): Promise<void> {
117
- const { event, payload, options } = job.payload
118
- const listeners = getListenerMap()
119
- const subscribers = resolveSubscribers(listeners, event)
120
-
121
- if (!subscribers || subscribers.length === 0) return
122
-
123
- const handlerCtx = {
124
- resolve: ctx.resolve,
125
- eventName: event,
126
- tenantId: options?.tenantId ?? null,
127
- organizationId: options?.organizationId ?? null,
117
+ const { event, payload, options, persistentDeliveredInline } = job.payload
118
+ if (persistentDeliveredInline) {
119
+ return
128
120
  }
129
121
 
130
- const results = await Promise.allSettled(
131
- subscribers.map((sub) => Promise.resolve(sub.handler(payload, handlerCtx)))
122
+ const bus = resolveEventBus(ctx, event)
123
+ // `ctx.resolve` is the per-job request container `createPerJobWorkerHandler`
124
+ // built for this job. Handing it to the bus keeps subscribers on that job's
125
+ // `em` instead of whichever container happened to construct the bus.
126
+ const results = await bus.dispatchQueued(
127
+ event,
128
+ payload,
129
+ {
130
+ tenantId: options?.tenantId ?? null,
131
+ organizationId: options?.organizationId ?? null,
132
+ },
133
+ ctx.resolve,
132
134
  )
133
135
 
134
- const errors: Array<{ subscriberId: string; error: unknown }> = []
135
- for (let i = 0; i < results.length; i++) {
136
- const result = results[i]
137
- if (result.status === 'rejected') {
138
- const sub = subscribers[i]
139
- logger.error('Subscriber failed for event', { event, subscriberId: sub.id, err: result.reason })
140
- errors.push({ subscriberId: sub.id, error: result.reason })
136
+ // A resolvable bus with an empty registry is the residual silent-loss path:
137
+ // `core/bootstrap.ts` logs and continues when `getModules()` fails, leaving a
138
+ // valid bus with no subscribers, and this job would then complete green.
139
+ // Zero subscribers is legitimate for an event nobody listens to, so this
140
+ // cannot throw - but at job-dispatch time it is more often a bug than a no-op,
141
+ // so make it visible rather than silent. Once per event name: see the note on
142
+ // `eventsReportedWithoutSubscribers`.
143
+ if (results.length === 0) {
144
+ const alreadyReported = eventsReportedWithoutSubscribers.has(event)
145
+ eventsReportedWithoutSubscribers.add(event)
146
+ if (alreadyReported) {
147
+ logger.debug('Queued event dispatched to zero subscribers', { event, jobId: ctx.jobId })
148
+ return
141
149
  }
150
+ logger.warn('Queued event dispatched to zero subscribers', { event, jobId: ctx.jobId })
151
+ return
142
152
  }
143
153
 
144
- if (errors.length > 0) {
145
- const failedIds = errors.map((e) => e.subscriberId).join(', ')
146
- throw new Error(
147
- `${errors.length}/${subscribers.length} subscriber(s) failed for event "${event}": ${failedIds}`
148
- )
149
- }
154
+ reportFailures(event, results)
150
155
  }
156
+
157
+ /**
158
+ * @deprecated The worker no longer keeps a listener cache; subscriber resolution
159
+ * lives on the DI event bus. Kept as a no-op for one minor so existing callers
160
+ * (tests, custom worker harnesses) keep compiling.
161
+ */
162
+ export function clearListenerCache(): void {}
@@ -19,19 +19,29 @@ type EnvSource = Record<string, string | undefined>
19
19
  * persistent subscribers are reached), ephemeral subscribers still run inline.
20
20
  * This is the correct behavior; the legacy dual-dispatch (inline AND worker)
21
21
  * double-ran exact-match persistent subscribers and never reached wildcard
22
- * persistent subscribers in the worker. Set the env to a false token to opt back
23
- * into legacy dual-dispatch.
22
+ * persistent subscribers in the worker. Set the env to a false token to run
23
+ * persistent subscribers inline instead. That is no longer dual-dispatch: the
24
+ * producer stamps the queued job with what it already delivered, so the worker
25
+ * skips it. Either way a persistent emit runs its subscribers exactly once.
24
26
  *
25
- * The server bootstrap reconciles this against worker availability (see
26
- * {@link reconcileSingleDelivery}) and may rewrite the env to `false` for a
27
- * process that would otherwise skip inline delivery with no worker to drain the
28
- * queue. The bus and the events worker both read the (possibly reconciled) env,
29
- * so they always agree within a process.
27
+ * The `mercato server` bootstrap may rewrite the env to `false` for a process it
28
+ * knows runs no worker (see {@link reconcileSingleDelivery}). The bus does NOT
29
+ * second-guess the flag itself: the queue is durable, so a persistent emit with
30
+ * no worker yet is delayed, not lost, and a worker started later drains the
31
+ * backlog. The bus and the events worker read the same env, so they always agree
32
+ * within a process.
30
33
  */
31
34
  export function isSingleDeliveryRequested(env: EnvSource = process.env): boolean {
32
35
  return parseBooleanWithDefault(env[EVENTS_SINGLE_DELIVERY_ENV], true)
33
36
  }
34
37
 
38
+ /**
39
+ * @deprecated Not read at runtime by this package - the bus never reconciles the
40
+ * flag against worker availability. The live consumer is the `mercato server`
41
+ * bootstrap, which mirrors this logic in
42
+ * `packages/cli/src/lib/events-single-delivery.ts` (the CLI cannot import this
43
+ * package). Kept for one minor because it is published API.
44
+ */
35
45
  export function isExternalWorkerAcknowledged(env: EnvSource = process.env): boolean {
36
46
  return parseBooleanWithDefault(env[EVENTS_EXTERNAL_WORKER_ENV], false)
37
47
  }
@@ -49,12 +59,18 @@ export type SingleDeliveryReconciliation = {
49
59
  * the events worker dispatches them. If no worker drains the queue, those
50
60
  * persistent side effects (notifications, queued emails, indexing) never run.
51
61
  *
52
- * The durable queue already covers transient worker downtime — a job persists
62
+ * The durable queue already covers transient worker downtime - a job persists
53
63
  * and drains when a worker returns. The dangerous case is a process configured
54
64
  * to run with NO events worker at all (auto-spawn off and no external worker).
55
65
  * For that case this fails safe: it disables single-delivery so persistent
56
- * subscribers run inline (dual-dispatch) and side effects are never dropped,
57
- * and surfaces a loud warning telling the operator how to opt back in.
66
+ * subscribers run inline, and surfaces a loud warning telling the operator how
67
+ * to move the work back off the request path.
68
+ *
69
+ * @deprecated Not read at runtime by this package - the bus never reconciles the
70
+ * flag against worker availability. The live consumer is the `mercato server`
71
+ * bootstrap, which mirrors this logic in
72
+ * `packages/cli/src/lib/events-single-delivery.ts` (the CLI cannot import this
73
+ * package). Kept for one minor because it is published API.
58
74
  */
59
75
  export function reconcileSingleDelivery(input: {
60
76
  requested: boolean
@@ -68,8 +84,8 @@ export function reconcileSingleDelivery(input: {
68
84
  `[events] ${EVENTS_SINGLE_DELIVERY_ENV} is on (default) but this process auto-spawns no events worker ` +
69
85
  `(AUTO_SPAWN_WORKERS=off) and ${EVENTS_EXTERNAL_WORKER_ENV} is not set. Persistent subscribers would be ` +
70
86
  `skipped inline with nothing to drain the queue, silently dropping notifications, queued emails, and ` +
71
- `indexing. Falling back to legacy inline dual-dispatch for safety. To keep single-delivery, run an events ` +
72
- `worker (\`mercato queue worker events\`) and set ${EVENTS_EXTERNAL_WORKER_ENV}=true, or enable ` +
73
- `AUTO_SPAWN_WORKERS.`,
87
+ `indexing. Running them inline instead, on the caller's request path. To move this work back off ` +
88
+ `the request path, run an events worker (\`mercato queue worker events\`) and set ` +
89
+ `${EVENTS_EXTERNAL_WORKER_ENV}=true, or enable AUTO_SPAWN_WORKERS.`,
74
90
  }
75
91
  }
package/src/types.ts CHANGED
@@ -55,6 +55,20 @@ export type SubscriberDescriptor = {
55
55
  handler: SubscriberHandler
56
56
  }
57
57
 
58
+ /** Outcome of one subscriber run during a queued dispatch */
59
+ export type QueuedDispatchResult = {
60
+ /** Subscriber id, falling back to its registered event pattern when unnamed */
61
+ subscriberId: string
62
+ /**
63
+ * Whether the handler settled successfully. This is the authoritative outcome:
64
+ * a handler can reject with `undefined` (`throw undefined`, `Promise.reject()`),
65
+ * so `error` alone cannot distinguish failure from success.
66
+ */
67
+ ok: boolean
68
+ /** The rejection reason when `ok` is false. May itself be `undefined`. */
69
+ error?: unknown
70
+ }
71
+
58
72
  // ============================================================================
59
73
  // Event Bus Types
60
74
  // ============================================================================
@@ -140,6 +154,45 @@ export interface EventBus {
140
154
  */
141
155
  registerModuleSubscribers(subs: SubscriberDescriptor[]): void
142
156
 
157
+ /**
158
+ * Dispatch a queued event to the subscribers the events worker owns.
159
+ *
160
+ * Optional so this stays an ADDITIVE-ONLY change to a contract surface
161
+ * (`BACKWARD_COMPATIBILITY.md`): a custom `EventBus` written before this
162
+ * release still satisfies the interface. The events worker runtime-guards for
163
+ * it and throws an actionable error when it is absent, so the fail-loud
164
+ * behaviour does not depend on the type.
165
+ *
166
+ * Selects every subscriber whose registered pattern matches `event` and is
167
+ * marked `persistent`, so wildcard (`event: '*'`) persistent subscribers are
168
+ * reached. The selection does not depend on `OM_EVENTS_SINGLE_DELIVERY`:
169
+ * whether inline delivery already ran is carried by the job's
170
+ * `persistentDeliveredInline` stamp, so a producer and a worker that disagree
171
+ * about the flag still agree about the job.
172
+ *
173
+ * Unlike inline delivery, handler failures are returned rather than swallowed:
174
+ * the caller aggregates them and throws so the queue can retry the job.
175
+ *
176
+ * @param event - Event name from the queued job
177
+ * @param payload - Event payload data
178
+ * @param options - Trusted scope recorded on the queued job
179
+ * @param resolve - DI resolver handed to the dispatched subscribers, defaulting
180
+ * to the one the bus was created with. The events worker passes its per-job
181
+ * `ctx.resolve`, so subscribers bind to the container that job runs in rather
182
+ * than the one that happened to build the bus. That distinction only bites
183
+ * under `OM_BOOTSTRAP_CACHE`, where the cached bus is replayed into later
184
+ * request containers while its captured resolver stays bound to the first —
185
+ * but the worker already holds the correct handle, so there is no reason to
186
+ * depend on the two coinciding.
187
+ * @returns One entry per dispatched subscriber, `error` set on failure
188
+ */
189
+ dispatchQueued?(
190
+ event: string,
191
+ payload: EventPayload,
192
+ options?: EmitOptions,
193
+ resolve?: <T = unknown>(name: string) => T,
194
+ ): Promise<QueuedDispatchResult[]>
195
+
143
196
  /**
144
197
  * Clear all events from the persistent queue.
145
198
  *