@open-mercato/events 0.8.1-develop.7296.1.2111d779db → 0.9.0
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/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +18 -0
- package/dist/broadcast-coalescer.js +124 -0
- package/dist/broadcast-coalescer.js.map +7 -0
- package/dist/bus.js +108 -11
- package/dist/bus.js.map +2 -2
- package/dist/index.js +12 -0
- package/dist/index.js.map +2 -2
- package/dist/modules/events/api/route.js +1 -0
- package/dist/modules/events/api/route.js.map +2 -2
- package/dist/modules/events/api/stream/route.js +161 -20
- package/dist/modules/events/api/stream/route.js.map +2 -2
- package/package.json +4 -5
- package/src/__tests__/broadcast-coalescer.test.ts +172 -0
- package/src/__tests__/broadcast-coalescing-bus.test.ts +409 -0
- package/src/broadcast-coalescer.ts +204 -0
- package/src/bus.ts +199 -19
- package/src/index.ts +10 -0
- package/src/modules/events/api/__tests__/route.test.ts +27 -4
- package/src/modules/events/api/route.ts +1 -0
- package/src/modules/events/api/stream/__tests__/route.test.ts +650 -8
- package/src/modules/events/api/stream/route.ts +200 -26
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
const publishCrossProcessEventMock = jest.fn(async () => undefined)
|
|
2
|
+
|
|
3
|
+
jest.mock('../bridge', () => ({
|
|
4
|
+
publishCrossProcessEvent: (...args: unknown[]) => publishCrossProcessEventMock(...args),
|
|
5
|
+
registerCrossProcessEventListener: jest.fn(),
|
|
6
|
+
CROSS_PROCESS_EVENT_INSTANCE_ID: 'test-instance',
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
import { createModuleEvents } from '@open-mercato/shared/modules/events'
|
|
10
|
+
import { createEventBus, registerGlobalEventTap } from '@open-mercato/events/bus'
|
|
11
|
+
import { resetBroadcastCoalescerForTests } from '@open-mercato/events/broadcast-coalescer'
|
|
12
|
+
|
|
13
|
+
const INTERVAL_MS = 50
|
|
14
|
+
|
|
15
|
+
createModuleEvents({
|
|
16
|
+
moduleId: 'coalesce_test',
|
|
17
|
+
events: [
|
|
18
|
+
{ id: 'coalesce_test.bulk.created', label: 'Bulk Created', clientBroadcast: true, broadcastCoalescing: true },
|
|
19
|
+
{ id: 'coalesce_test.plain.created', label: 'Plain Created', clientBroadcast: true },
|
|
20
|
+
{ id: 'coalesce_test.recipient.created', label: 'Recipient Created', clientBroadcast: true, broadcastCoalescing: true },
|
|
21
|
+
{ id: 'coalesce_test.portal.created', label: 'Portal Created', portalBroadcast: true, broadcastCoalescing: true },
|
|
22
|
+
] as const,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
function wait(ms: number): Promise<void> {
|
|
26
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('event bus browser-delivery coalescing', () => {
|
|
30
|
+
const resolve = ((name: string) => name) as never
|
|
31
|
+
const originalInterval = process.env.OM_BROADCAST_COALESCE_INTERVAL_MS
|
|
32
|
+
let unregisterTap: (() => void) | null = null
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
publishCrossProcessEventMock.mockClear()
|
|
36
|
+
process.env.OM_BROADCAST_COALESCE_INTERVAL_MS = String(INTERVAL_MS)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
unregisterTap?.()
|
|
41
|
+
unregisterTap = null
|
|
42
|
+
resetBroadcastCoalescerForTests()
|
|
43
|
+
if (originalInterval === undefined) delete process.env.OM_BROADCAST_COALESCE_INTERVAL_MS
|
|
44
|
+
else process.env.OM_BROADCAST_COALESCE_INTERVAL_MS = originalInterval
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('keeps domain delivery per record while collapsing the browser dispatch', async () => {
|
|
48
|
+
const bus = createEventBus({ resolve, queueStrategy: 'local' })
|
|
49
|
+
const subscriberCalls: string[] = []
|
|
50
|
+
const tappedIds: string[] = []
|
|
51
|
+
|
|
52
|
+
bus.on('coalesce_test.bulk.created', async (payload) => {
|
|
53
|
+
subscriberCalls.push(String((payload as { id: string }).id))
|
|
54
|
+
})
|
|
55
|
+
unregisterTap = registerGlobalEventTap((_event, payload) => {
|
|
56
|
+
tappedIds.push(String((payload as { id: string }).id))
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
for (let index = 0; index < 100; index += 1) {
|
|
60
|
+
await bus.emit(
|
|
61
|
+
'coalesce_test.bulk.created',
|
|
62
|
+
{ id: `product-${index}`, tenantId: 'tenant-1', organizationId: 'org-1' },
|
|
63
|
+
{ tenantId: 'tenant-1', organizationId: 'org-1' },
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
await wait(INTERVAL_MS * 2)
|
|
67
|
+
|
|
68
|
+
// The hard requirement from the issue: the domain event still fires once per
|
|
69
|
+
// record, so webhooks, notification handlers and indexers are untouched.
|
|
70
|
+
expect(subscriberCalls).toHaveLength(100)
|
|
71
|
+
expect(subscriberCalls[0]).toBe('product-0')
|
|
72
|
+
expect(subscriberCalls[99]).toBe('product-99')
|
|
73
|
+
|
|
74
|
+
// The browser half collapses, and the burst still ends on the final record.
|
|
75
|
+
expect(tappedIds.length).toBeLessThan(100)
|
|
76
|
+
expect(tappedIds[0]).toBe('product-0')
|
|
77
|
+
expect(tappedIds[tappedIds.length - 1]).toBe('product-99')
|
|
78
|
+
|
|
79
|
+
// The pg_notify roundtrip collapses with it — half the cost the issue names.
|
|
80
|
+
expect(publishCrossProcessEventMock.mock.calls.length).toBe(tappedIds.length)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('leaves an event that did not opt in on the per-record path', async () => {
|
|
84
|
+
const bus = createEventBus({ resolve, queueStrategy: 'local' })
|
|
85
|
+
const tappedIds: string[] = []
|
|
86
|
+
unregisterTap = registerGlobalEventTap((_event, payload) => {
|
|
87
|
+
tappedIds.push(String((payload as { id: string }).id))
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
for (let index = 0; index < 10; index += 1) {
|
|
91
|
+
await bus.emit(
|
|
92
|
+
'coalesce_test.plain.created',
|
|
93
|
+
{ id: `product-${index}`, tenantId: 'tenant-1', organizationId: 'org-1' },
|
|
94
|
+
{ tenantId: 'tenant-1', organizationId: 'org-1' },
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
expect(tappedIds).toHaveLength(10)
|
|
99
|
+
expect(publishCrossProcessEventMock).toHaveBeenCalledTimes(10)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('never delivers one tenant or organization payload under another scope', async () => {
|
|
103
|
+
const bus = createEventBus({ resolve, queueStrategy: 'local' })
|
|
104
|
+
const seen: Array<{ tenantId: string; organizationId: string; id: string }> = []
|
|
105
|
+
unregisterTap = registerGlobalEventTap((_event, payload) => {
|
|
106
|
+
const data = payload as { id: string; tenantId: string; organizationId: string }
|
|
107
|
+
seen.push({ tenantId: data.tenantId, organizationId: data.organizationId, id: data.id })
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const scopes = [
|
|
111
|
+
{ tenantId: 'tenant-a', organizationId: 'org-1' },
|
|
112
|
+
{ tenantId: 'tenant-b', organizationId: 'org-1' },
|
|
113
|
+
{ tenantId: 'tenant-a', organizationId: 'org-2' },
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
for (let round = 0; round < 5; round += 1) {
|
|
117
|
+
for (const scope of scopes) {
|
|
118
|
+
await bus.emit(
|
|
119
|
+
'coalesce_test.bulk.created',
|
|
120
|
+
{ id: `${scope.tenantId}-${scope.organizationId}-${round}`, ...scope },
|
|
121
|
+
scope,
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
await wait(INTERVAL_MS * 2)
|
|
126
|
+
|
|
127
|
+
// Every scope gets its own leading edge and its own trailing flush, and no
|
|
128
|
+
// delivered payload ever carries a scope other than the one it was keyed by.
|
|
129
|
+
for (const scope of scopes) {
|
|
130
|
+
const forScope = seen.filter((entry) => entry.tenantId === scope.tenantId && entry.organizationId === scope.organizationId)
|
|
131
|
+
expect(forScope.length).toBeGreaterThanOrEqual(2)
|
|
132
|
+
expect(forScope[forScope.length - 1].id).toBe(`${scope.tenantId}-${scope.organizationId}-4`)
|
|
133
|
+
for (const entry of forScope) {
|
|
134
|
+
expect(entry.id.startsWith(`${scope.tenantId}-${scope.organizationId}-`)).toBe(true)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('flushes the tail on a natural exit as well as on a signal', async () => {
|
|
140
|
+
const registered: string[] = []
|
|
141
|
+
const onceSpy = jest.spyOn(process, 'once').mockImplementation(function (this: NodeJS.Process, event: string) {
|
|
142
|
+
registered.push(event)
|
|
143
|
+
return this
|
|
144
|
+
} as never)
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
delete (globalThis as Record<string, unknown>).__openMercatoBroadcastCoalescerShutdown__
|
|
148
|
+
createEventBus({ resolve, queueStrategy: 'local' })
|
|
149
|
+
} finally {
|
|
150
|
+
onceSpy.mockRestore()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// The trailing timer is unref'd, so a CLI that emits and exits normally never
|
|
154
|
+
// sees a signal — beforeExit is the hook that saves its tail.
|
|
155
|
+
expect(registered).toEqual(expect.arrayContaining(['SIGTERM', 'SIGINT', 'beforeExit']))
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('re-raises a signal after the flush only when no other listener owns the exit', async () => {
|
|
159
|
+
const handlers = new Map<string, () => void>()
|
|
160
|
+
const onceSpy = jest.spyOn(process, 'once').mockImplementation(function (this: NodeJS.Process, event: string, handler: () => void) {
|
|
161
|
+
handlers.set(event, handler)
|
|
162
|
+
return this
|
|
163
|
+
} as never)
|
|
164
|
+
try {
|
|
165
|
+
delete (globalThis as Record<string, unknown>).__openMercatoBroadcastCoalescerShutdown__
|
|
166
|
+
createEventBus({ resolve, queueStrategy: 'local' })
|
|
167
|
+
} finally {
|
|
168
|
+
onceSpy.mockRestore()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const killSpy = jest.spyOn(process, 'kill').mockImplementation(() => true)
|
|
172
|
+
const listenerCountSpy = jest.spyOn(process, 'listenerCount')
|
|
173
|
+
const bus = createEventBus({ resolve, queueStrategy: 'local' })
|
|
174
|
+
const tappedIds: string[] = []
|
|
175
|
+
unregisterTap = registerGlobalEventTap((_event, payload) => {
|
|
176
|
+
tappedIds.push(String((payload as { id: string }).id))
|
|
177
|
+
})
|
|
178
|
+
try {
|
|
179
|
+
for (const id of ['first', 'tail']) {
|
|
180
|
+
await bus.emit(
|
|
181
|
+
'coalesce_test.bulk.created',
|
|
182
|
+
{ id, tenantId: 'tenant-1', organizationId: 'org-1' },
|
|
183
|
+
{ tenantId: 'tenant-1', organizationId: 'org-1' },
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
listenerCountSpy.mockReturnValue(0)
|
|
188
|
+
handlers.get('SIGINT')?.()
|
|
189
|
+
await wait(INTERVAL_MS / 5)
|
|
190
|
+
expect(tappedIds).toEqual(['first', 'tail'])
|
|
191
|
+
expect(killSpy).toHaveBeenCalledWith(process.pid, 'SIGINT')
|
|
192
|
+
|
|
193
|
+
killSpy.mockClear()
|
|
194
|
+
listenerCountSpy.mockReturnValue(1)
|
|
195
|
+
handlers.get('SIGTERM')?.()
|
|
196
|
+
await wait(INTERVAL_MS / 5)
|
|
197
|
+
expect(killSpy).not.toHaveBeenCalled()
|
|
198
|
+
} finally {
|
|
199
|
+
killSpy.mockRestore()
|
|
200
|
+
listenerCountSpy.mockRestore()
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('keys a present-but-empty options tenant per payload tenant, so no tenant suppresses another', async () => {
|
|
205
|
+
const bus = createEventBus({ resolve, queueStrategy: 'local' })
|
|
206
|
+
const tenants = ['tenant-a', 'tenant-b']
|
|
207
|
+
|
|
208
|
+
for (let round = 0; round < 3; round += 1) {
|
|
209
|
+
for (const tenantId of tenants) {
|
|
210
|
+
await bus.emit(
|
|
211
|
+
'coalesce_test.bulk.created',
|
|
212
|
+
{ id: `${tenantId}-${round}`, tenantId, organizationId: 'org-1' },
|
|
213
|
+
{ tenantId: undefined },
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
await wait(INTERVAL_MS * 2)
|
|
218
|
+
|
|
219
|
+
const published = publishCrossProcessEventMock.mock.calls.map((call) => (call as unknown[])[1] as { id: string; tenantId: string })
|
|
220
|
+
for (const tenantId of tenants) {
|
|
221
|
+
const forTenant = published.filter((entry) => entry.tenantId === tenantId)
|
|
222
|
+
expect(forTenant.length).toBeGreaterThanOrEqual(2)
|
|
223
|
+
expect(forTenant[forTenant.length - 1].id).toBe(`${tenantId}-2`)
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('keys portal organizations from the payload even when options carry a trusted tenant', async () => {
|
|
228
|
+
const bus = createEventBus({ resolve, queueStrategy: 'local' })
|
|
229
|
+
const seen: Array<{ organizationId: string; id: string }> = []
|
|
230
|
+
unregisterTap = registerGlobalEventTap((_event, payload) => {
|
|
231
|
+
const data = payload as { id: string; organizationId: string }
|
|
232
|
+
seen.push({ organizationId: data.organizationId, id: data.id })
|
|
233
|
+
})
|
|
234
|
+
const organizations = ['org-1', 'org-2']
|
|
235
|
+
|
|
236
|
+
for (let round = 0; round < 3; round += 1) {
|
|
237
|
+
for (const organizationId of organizations) {
|
|
238
|
+
await bus.emit(
|
|
239
|
+
'coalesce_test.portal.created',
|
|
240
|
+
{ id: `${organizationId}-${round}`, tenantId: 'tenant-1', organizationId },
|
|
241
|
+
{ tenantId: 'tenant-1' },
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
await wait(INTERVAL_MS * 2)
|
|
246
|
+
|
|
247
|
+
for (const organizationId of organizations) {
|
|
248
|
+
const forOrganization = seen.filter((entry) => entry.organizationId === organizationId)
|
|
249
|
+
expect(forOrganization.length).toBeGreaterThanOrEqual(2)
|
|
250
|
+
expect(forOrganization[forOrganization.length - 1].id).toBe(`${organizationId}-2`)
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('does not share a key between absent and present-but-empty options tenants', async () => {
|
|
255
|
+
const bus = createEventBus({ resolve, queueStrategy: 'local' })
|
|
256
|
+
const tappedIds: string[] = []
|
|
257
|
+
unregisterTap = registerGlobalEventTap((_event, payload) => {
|
|
258
|
+
tappedIds.push(String((payload as { id: string }).id))
|
|
259
|
+
})
|
|
260
|
+
const payload = { tenantId: 'tenant-1', organizationId: 'org-1' }
|
|
261
|
+
|
|
262
|
+
await bus.emit('coalesce_test.bulk.created', { ...payload, id: 'absent' }, {})
|
|
263
|
+
await bus.emit('coalesce_test.bulk.created', { ...payload, id: 'present-empty' }, { tenantId: undefined })
|
|
264
|
+
|
|
265
|
+
expect(tappedIds).toEqual(['absent', 'present-empty'])
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('never delivers one recipient user\'s payload under another recipient user\'s key', async () => {
|
|
269
|
+
const bus = createEventBus({ resolve, queueStrategy: 'local' })
|
|
270
|
+
const seen: Array<{ recipientUserId: string; id: string }> = []
|
|
271
|
+
unregisterTap = registerGlobalEventTap((_event, payload) => {
|
|
272
|
+
const data = payload as { id: string; recipientUserId: string }
|
|
273
|
+
seen.push({ recipientUserId: data.recipientUserId, id: data.id })
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
// Same tenant/org for both users — options carry no recipient dimension at
|
|
277
|
+
// all, so only the payload-derived union in the coalescing key can keep
|
|
278
|
+
// these two audiences from suppressing each other.
|
|
279
|
+
const users = ['user-a', 'user-b']
|
|
280
|
+
for (let round = 0; round < 5; round += 1) {
|
|
281
|
+
for (const recipientUserId of users) {
|
|
282
|
+
await bus.emit(
|
|
283
|
+
'coalesce_test.recipient.created',
|
|
284
|
+
{ id: `${recipientUserId}-${round}`, tenantId: 'tenant-1', organizationId: 'org-1', recipientUserId },
|
|
285
|
+
{ tenantId: 'tenant-1', organizationId: 'org-1' },
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
await wait(INTERVAL_MS * 2)
|
|
290
|
+
|
|
291
|
+
for (const recipientUserId of users) {
|
|
292
|
+
const forUser = seen.filter((entry) => entry.recipientUserId === recipientUserId)
|
|
293
|
+
expect(forUser.length).toBeGreaterThanOrEqual(2)
|
|
294
|
+
expect(forUser[forUser.length - 1].id).toBe(`${recipientUserId}-4`)
|
|
295
|
+
for (const entry of forUser) {
|
|
296
|
+
expect(entry.id.startsWith(`${recipientUserId}-`)).toBe(true)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('never delivers one recipient role\'s payload under another recipient role\'s key', async () => {
|
|
302
|
+
const bus = createEventBus({ resolve, queueStrategy: 'local' })
|
|
303
|
+
const seen: Array<{ recipientRoleId: string; id: string }> = []
|
|
304
|
+
unregisterTap = registerGlobalEventTap((_event, payload) => {
|
|
305
|
+
const data = payload as { id: string; recipientRoleId: string }
|
|
306
|
+
seen.push({ recipientRoleId: data.recipientRoleId, id: data.id })
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
const roles = ['role-a', 'role-b']
|
|
310
|
+
for (let round = 0; round < 5; round += 1) {
|
|
311
|
+
for (const recipientRoleId of roles) {
|
|
312
|
+
await bus.emit(
|
|
313
|
+
'coalesce_test.recipient.created',
|
|
314
|
+
{ id: `${recipientRoleId}-${round}`, tenantId: 'tenant-1', organizationId: 'org-1', recipientRoleId },
|
|
315
|
+
{ tenantId: 'tenant-1', organizationId: 'org-1' },
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
await wait(INTERVAL_MS * 2)
|
|
320
|
+
|
|
321
|
+
for (const recipientRoleId of roles) {
|
|
322
|
+
const forRole = seen.filter((entry) => entry.recipientRoleId === recipientRoleId)
|
|
323
|
+
expect(forRole.length).toBeGreaterThanOrEqual(2)
|
|
324
|
+
expect(forRole[forRole.length - 1].id).toBe(`${recipientRoleId}-4`)
|
|
325
|
+
for (const entry of forRole) {
|
|
326
|
+
expect(entry.id.startsWith(`${recipientRoleId}-`)).toBe(true)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('never delivers one tenant\'s payload under another tenant\'s key for a portal-only event', async () => {
|
|
332
|
+
const bus = createEventBus({ resolve, queueStrategy: 'local' })
|
|
333
|
+
const seen: Array<{ tenantId: string; id: string }> = []
|
|
334
|
+
unregisterTap = registerGlobalEventTap((_event, payload) => {
|
|
335
|
+
const data = payload as { id: string; tenantId: string }
|
|
336
|
+
seen.push({ tenantId: data.tenantId, id: data.id })
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
// A portal-only broadcast has no `clientBroadcast`, so `resolveCrossProcessEmitOptions`
|
|
340
|
+
// never promotes payload scope into options for it — the coalescing key can
|
|
341
|
+
// only be correct here if it reads tenantId straight off the payload, the
|
|
342
|
+
// way this test never passes it in `options` at all.
|
|
343
|
+
const tenants = ['tenant-a', 'tenant-b']
|
|
344
|
+
for (let round = 0; round < 5; round += 1) {
|
|
345
|
+
for (const tenantId of tenants) {
|
|
346
|
+
await bus.emit(
|
|
347
|
+
'coalesce_test.portal.created',
|
|
348
|
+
{ id: `${tenantId}-${round}`, tenantId },
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
await wait(INTERVAL_MS * 2)
|
|
353
|
+
|
|
354
|
+
for (const tenantId of tenants) {
|
|
355
|
+
const forTenant = seen.filter((entry) => entry.tenantId === tenantId)
|
|
356
|
+
expect(forTenant.length).toBeGreaterThanOrEqual(2)
|
|
357
|
+
expect(forTenant[forTenant.length - 1].id).toBe(`${tenantId}-4`)
|
|
358
|
+
for (const entry of forTenant) {
|
|
359
|
+
expect(entry.id.startsWith(`${tenantId}-`)).toBe(true)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('restores per-record browser delivery when the interval is disabled', async () => {
|
|
365
|
+
process.env.OM_BROADCAST_COALESCE_INTERVAL_MS = '0'
|
|
366
|
+
const bus = createEventBus({ resolve, queueStrategy: 'local' })
|
|
367
|
+
const tappedIds: string[] = []
|
|
368
|
+
unregisterTap = registerGlobalEventTap((_event, payload) => {
|
|
369
|
+
tappedIds.push(String((payload as { id: string }).id))
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
for (let index = 0; index < 10; index += 1) {
|
|
373
|
+
await bus.emit(
|
|
374
|
+
'coalesce_test.bulk.created',
|
|
375
|
+
{ id: `product-${index}`, tenantId: 'tenant-1', organizationId: 'org-1' },
|
|
376
|
+
{ tenantId: 'tenant-1', organizationId: 'org-1' },
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
expect(tappedIds).toHaveLength(10)
|
|
381
|
+
expect(publishCrossProcessEventMock).toHaveBeenCalledTimes(10)
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
it('restores the develop dispatch order (pg_notify publish after inline) when the interval is disabled', async () => {
|
|
385
|
+
process.env.OM_BROADCAST_COALESCE_INTERVAL_MS = '0'
|
|
386
|
+
const bus = createEventBus({ resolve, queueStrategy: 'local' })
|
|
387
|
+
const order: string[] = []
|
|
388
|
+
bus.on('coalesce_test.bulk.created', async () => {
|
|
389
|
+
order.push('inline')
|
|
390
|
+
})
|
|
391
|
+
publishCrossProcessEventMock.mockImplementationOnce(async () => {
|
|
392
|
+
order.push('publish')
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
// On the non-coalesced path (and on develop before this feature existed),
|
|
396
|
+
// the pg_notify publish that reaches other processes always ran AFTER inline
|
|
397
|
+
// in-memory delivery. The coalesced path bundles it with the tap dispatch
|
|
398
|
+
// instead, ahead of inline delivery — `OM_BROADCAST_COALESCE_INTERVAL_MS=0`
|
|
399
|
+
// promises to fully restore the develop order, not merely the per-record
|
|
400
|
+
// delivery count.
|
|
401
|
+
await bus.emit(
|
|
402
|
+
'coalesce_test.bulk.created',
|
|
403
|
+
{ id: 'product-0', tenantId: 'tenant-1', organizationId: 'org-1' },
|
|
404
|
+
{ tenantId: 'tenant-1', organizationId: 'org-1' },
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
expect(order).toEqual(['inline', 'publish'])
|
|
408
|
+
})
|
|
409
|
+
})
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { parseNumberWithDefault } from '@open-mercato/shared/lib/number'
|
|
2
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Coalesces the BROWSER half of an event emit.
|
|
6
|
+
*
|
|
7
|
+
* A `clientBroadcast: true` event costs a serialized `pg_notify` roundtrip plus a
|
|
8
|
+
* tenant-wide SSE fan-out on every emit, so a bulk writer pays both once per
|
|
9
|
+
* record even though its browser consumers only refresh a list. Events that opt
|
|
10
|
+
* in with `broadcastCoalescing: true` route their browser dispatch through here:
|
|
11
|
+
* the first emit of a burst goes out immediately (leading edge) and later emits
|
|
12
|
+
* sharing a key replace the pending payload instead of being delivered.
|
|
13
|
+
*
|
|
14
|
+
* A trailing flush is ALWAYS armed, so the last emit of a burst is delivered and
|
|
15
|
+
* a DataTable never ends the burst stale. This is where the mechanism goes past
|
|
16
|
+
* the progress service's private throttle, which can drop its tail only because
|
|
17
|
+
* its terminal transitions emit through a separate unthrottled path.
|
|
18
|
+
*
|
|
19
|
+
* Inline subscribers, webhooks and the queue never reach this module — the domain
|
|
20
|
+
* event still fires once per record.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const DEFAULT_COALESCE_INTERVAL_MS = 250
|
|
24
|
+
const PENDING_BROADCASTS_KEY = '__openMercatoPendingBroadcasts__'
|
|
25
|
+
|
|
26
|
+
const logger = createLogger('events').child({ component: 'broadcast-coalescer' })
|
|
27
|
+
|
|
28
|
+
type BroadcastDispatch = () => Promise<void>
|
|
29
|
+
|
|
30
|
+
type PendingBroadcast = {
|
|
31
|
+
/** The newest superseding dispatch awaiting the trailing flush, if any. */
|
|
32
|
+
pending: BroadcastDispatch | null
|
|
33
|
+
timer: ReturnType<typeof setTimeout> | null
|
|
34
|
+
suppressed: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Held on `globalThis` for the same reason as the bus's global taps and producer
|
|
39
|
+
* queue: a fresh event bus is built per request, so per-bus state would coalesce
|
|
40
|
+
* nothing across the emits of a single bulk job.
|
|
41
|
+
*/
|
|
42
|
+
function getPendingBroadcasts(): Map<string, PendingBroadcast> {
|
|
43
|
+
const existing = (globalThis as Record<string, unknown>)[PENDING_BROADCASTS_KEY]
|
|
44
|
+
if (existing instanceof Map) {
|
|
45
|
+
return existing as Map<string, PendingBroadcast>
|
|
46
|
+
}
|
|
47
|
+
const created = new Map<string, PendingBroadcast>()
|
|
48
|
+
;(globalThis as Record<string, unknown>)[PENDING_BROADCASTS_KEY] = created
|
|
49
|
+
return created
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Minimum elapsed time between coalesced browser deliveries of one key. `0`
|
|
54
|
+
* disables coalescing process-wide, restoring per-record delivery — the runtime
|
|
55
|
+
* escape hatch, mirroring `OM_PROGRESS_BROADCAST_MIN_INTERVAL_MS=0`.
|
|
56
|
+
*/
|
|
57
|
+
export function resolveBroadcastCoalesceIntervalMs(): number {
|
|
58
|
+
return parseNumberWithDefault(
|
|
59
|
+
process.env.OM_BROADCAST_COALESCE_INTERVAL_MS,
|
|
60
|
+
DEFAULT_COALESCE_INTERVAL_MS,
|
|
61
|
+
{ min: 0, integer: true },
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function armTimer(key: string, intervalMs: number): ReturnType<typeof setTimeout> {
|
|
66
|
+
const timer = setTimeout(() => {
|
|
67
|
+
void onWindowClosed(key, intervalMs)
|
|
68
|
+
}, intervalMs)
|
|
69
|
+
// Never keep a CLI or worker process alive just to flush a browser delivery.
|
|
70
|
+
// The shutdown hook flushes what is still pending on the way out.
|
|
71
|
+
if (typeof (timer as { unref?: () => void }).unref === 'function') {
|
|
72
|
+
;(timer as { unref: () => void }).unref()
|
|
73
|
+
}
|
|
74
|
+
return timer
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function onWindowClosed(key: string, intervalMs: number): Promise<void> {
|
|
78
|
+
const entries = getPendingBroadcasts()
|
|
79
|
+
const entry = entries.get(key)
|
|
80
|
+
if (!entry) return
|
|
81
|
+
|
|
82
|
+
const pending = entry.pending
|
|
83
|
+
if (!pending) {
|
|
84
|
+
// The window closed with nothing superseded: the burst is over.
|
|
85
|
+
entry.timer = null
|
|
86
|
+
entries.delete(key)
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const suppressed = entry.suppressed
|
|
91
|
+
entry.pending = null
|
|
92
|
+
entry.suppressed = 0
|
|
93
|
+
entry.timer = null
|
|
94
|
+
|
|
95
|
+
await runDispatch(pending, key)
|
|
96
|
+
if (suppressed > 0) {
|
|
97
|
+
logger.debug('Coalesced browser deliveries', { key, suppressed })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Re-arm only AFTER the dispatch settles, so one key never has two deliveries
|
|
101
|
+
// in flight. Re-arming first would let a slow pg_notify roundtrip overlap the
|
|
102
|
+
// next window and land an older payload last — the exact staleness coalescing
|
|
103
|
+
// exists to prevent. As a side effect the window self-extends under load,
|
|
104
|
+
// which is the right behavior for a throttle. Submissions arriving during the
|
|
105
|
+
// dispatch still queue on the entry, which is still in the map.
|
|
106
|
+
const current = entries.get(key)
|
|
107
|
+
if (!current) return
|
|
108
|
+
if (current.pending) current.timer = armTimer(key, intervalMs)
|
|
109
|
+
else entries.delete(key)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A deferred dispatch has no caller awaiting it, so its failure must be
|
|
114
|
+
* contained here — `bus.emit` already logs-and-continues on a synchronous
|
|
115
|
+
* publish failure, and one failed flush must not stop the next window.
|
|
116
|
+
*/
|
|
117
|
+
async function runDispatch(dispatch: BroadcastDispatch, key: string): Promise<void> {
|
|
118
|
+
try {
|
|
119
|
+
await dispatch()
|
|
120
|
+
} catch (error) {
|
|
121
|
+
logger.error('Coalesced broadcast dispatch failed', { key, err: error })
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Submit a browser dispatch for `key`. Resolves once the caller's obligation is
|
|
127
|
+
* discharged: immediately-dispatched submissions resolve after delivery,
|
|
128
|
+
* superseded ones resolve as soon as they are queued.
|
|
129
|
+
*/
|
|
130
|
+
export async function submitBroadcast(
|
|
131
|
+
key: string,
|
|
132
|
+
dispatch: BroadcastDispatch,
|
|
133
|
+
options?: { intervalMs?: number },
|
|
134
|
+
): Promise<void> {
|
|
135
|
+
const intervalMs = options?.intervalMs ?? resolveBroadcastCoalesceIntervalMs()
|
|
136
|
+
if (intervalMs <= 0) {
|
|
137
|
+
await dispatch()
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const entries = getPendingBroadcasts()
|
|
142
|
+
const entry = entries.get(key)
|
|
143
|
+
if (!entry) {
|
|
144
|
+
entries.set(key, { pending: null, timer: armTimer(key, intervalMs), suppressed: 0 })
|
|
145
|
+
await dispatch()
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (entry.pending) entry.suppressed += 1
|
|
150
|
+
entry.pending = dispatch
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Upper bound for a flush on the way out of a process. Fixed, and independent of
|
|
155
|
+
* and shorter than OM_BROADCAST_COALESCE_INTERVAL_MS's own window, so a stalled
|
|
156
|
+
* Postgres connection at exit degrades to the already-accepted dropped-tail case
|
|
157
|
+
* instead of hanging the exit.
|
|
158
|
+
*/
|
|
159
|
+
export const BROADCAST_FLUSH_DEADLINE_MS = 3000
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Deliver every survivor still awaiting its trailing flush. Wired into the
|
|
163
|
+
* process shutdown hook and the CLI exit path so a stop does not swallow the tail
|
|
164
|
+
* of a burst. Never rejects: every entry is settled on its own. With
|
|
165
|
+
* `deadlineMs`, resolves no later than that even if a dispatch hangs.
|
|
166
|
+
*/
|
|
167
|
+
export async function flushPendingBroadcasts(options?: { deadlineMs?: number }): Promise<void> {
|
|
168
|
+
const entries = getPendingBroadcasts()
|
|
169
|
+
const drained: Array<Promise<void>> = []
|
|
170
|
+
for (const [key, entry] of entries) {
|
|
171
|
+
if (entry.timer) clearTimeout(entry.timer)
|
|
172
|
+
const pending = entry.pending
|
|
173
|
+
entries.delete(key)
|
|
174
|
+
if (pending) drained.push(runDispatch(pending, key))
|
|
175
|
+
}
|
|
176
|
+
const settled = Promise.allSettled(drained).then(() => undefined)
|
|
177
|
+
const deadlineMs = options?.deadlineMs
|
|
178
|
+
if (deadlineMs === undefined) {
|
|
179
|
+
await settled
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
let expire: () => void = () => {}
|
|
183
|
+
const expired = new Promise<void>((resolve) => {
|
|
184
|
+
expire = resolve
|
|
185
|
+
})
|
|
186
|
+
const deadline = setTimeout(() => expire(), Math.max(0, deadlineMs))
|
|
187
|
+
if (typeof (deadline as { unref?: () => void }).unref === 'function') {
|
|
188
|
+
;(deadline as { unref: () => void }).unref()
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
await Promise.race([settled, expired])
|
|
192
|
+
} finally {
|
|
193
|
+
clearTimeout(deadline)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Drop all pending state without dispatching. Tests only. */
|
|
198
|
+
export function resetBroadcastCoalescerForTests(): void {
|
|
199
|
+
const entries = getPendingBroadcasts()
|
|
200
|
+
for (const entry of entries.values()) {
|
|
201
|
+
if (entry.timer) clearTimeout(entry.timer)
|
|
202
|
+
}
|
|
203
|
+
entries.clear()
|
|
204
|
+
}
|