@open-mercato/events 0.6.8-develop.6917.1.af45bc96e2 → 0.6.8-develop.6924.1.a8d208fcdc
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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
|
@@ -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 {
|
|
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
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
74
|
+
return bus as DispatchCapableBus
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
119
|
-
|
|
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
|
|
131
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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 {}
|
package/src/single-delivery.ts
CHANGED
|
@@ -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
|
|
23
|
-
*
|
|
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
|
|
26
|
-
* {@link reconcileSingleDelivery})
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* so they always agree
|
|
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
|
|
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
|
|
57
|
-
*
|
|
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.
|
|
72
|
-
`worker (\`mercato queue worker events\`) and set
|
|
73
|
-
|
|
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
|
*
|