Skip to content

Commit 18f0bef

Browse files
authored
DomainEventEmitter dispose improvements (#581)
* DomainEventEmitter dispose improvements - process pending messages and throw error on emit after dispose * Linting * Wait for foreground listeners after dispose
1 parent 6fc0a32 commit 18f0bef

2 files changed

Lines changed: 132 additions & 3 deletions

File tree

‎packages/core/lib/events/DomainEventEmitter.spec.ts‎

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -455,14 +455,67 @@ describe('DomainEventEmitter', () => {
455455
expect(fakeListener2.receivedEvents[0]).toMatchObject(expectedUpdatedPayload)
456456
})
457457

458-
it('after dispose handlers are not called', async () => {
458+
it('rejects emitting after dispose instead of dropping the event', async () => {
459459
const fakeListener = new FakeListener()
460460
eventEmitter.onAny(fakeListener)
461461

462462
await eventEmitter.dispose()
463463

464-
await eventEmitter.emit(TestEvents.created, createdEventPayload)
464+
await expect(
465+
eventEmitter.emit(TestEvents.created, createdEventPayload),
466+
).rejects.toMatchObject({ errorCode: 'EVENT_EMITTER_DISPOSED' })
465467
expect(fakeListener.receivedEvents).toHaveLength(0)
466468
})
469+
470+
it('awaits background handlers that register while draining', async () => {
471+
// The chaining listener emits a follow-up event from within a background handler, so the
472+
// follow-up's own background handler only registers once dispose() started draining.
473+
const chainedListener = new FakeListener(100)
474+
eventEmitter.onMany(['entity.updated'], chainedListener, true)
475+
eventEmitter.onMany(
476+
['entity.created'],
477+
{
478+
eventHandlerId: 'ChainingListener',
479+
handleEvent: async () => {
480+
await eventEmitter.emit(TestEvents.updated, updatedEventPayload)
481+
},
482+
},
483+
true,
484+
)
485+
486+
await eventEmitter.emit(TestEvents.created, createdEventPayload)
487+
expect(chainedListener.receivedEvents).toHaveLength(0)
488+
489+
await eventEmitter.dispose()
490+
491+
expect(chainedListener.receivedEvents).toHaveLength(1)
492+
})
493+
494+
it('awaits an event still running its foreground handlers when draining starts', async () => {
495+
// An event is only tracked as an in-progress background handler once its foreground handlers
496+
// have completed, so disposal starting mid-foreground must not miss it.
497+
const backgroundListener = new FakeListener(100)
498+
eventEmitter.onMany(['entity.created'], backgroundListener, true)
499+
500+
let releaseForegroundHandler: () => void = () => {}
501+
const foregroundHandlerGate = new Promise<void>((resolve) => {
502+
releaseForegroundHandler = resolve
503+
})
504+
eventEmitter.onMany(['entity.created'], {
505+
eventHandlerId: 'GatedForegroundListener',
506+
handleEvent: () => foregroundHandlerGate,
507+
})
508+
509+
// Not awaited: emit() only resolves once the gated foreground handler is released
510+
const emitPromise = eventEmitter.emit(TestEvents.created, createdEventPayload)
511+
expect(backgroundListener.receivedEvents).toHaveLength(0)
512+
513+
const disposePromise = eventEmitter.dispose()
514+
releaseForegroundHandler()
515+
await disposePromise
516+
517+
expect(backgroundListener.receivedEvents).toHaveLength(1)
518+
await emitPromise
519+
})
467520
})
468521
})

‎packages/core/lib/events/DomainEventEmitter.ts‎

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ import type {
2121
SingleEventHandler,
2222
} from './eventTypes.ts'
2323

24+
/**
25+
* Upper bound on how many times dispose() re-checks for event dispatches that registered while it
26+
* was draining. Each pass only continues if the previous one found work, so this is a guard against
27+
* endless event chains rather than a limit on normal shutdowns.
28+
*/
29+
const MAX_DISPOSE_DRAIN_PASSES = 10
30+
2431
export type DomainEventEmitterDependencies<SupportedEvents extends CommonEventDefinition[]> = {
2532
eventRegistry: EventRegistry<SupportedEvents>
2633
metadataFiller: MetadataFiller
@@ -48,7 +55,9 @@ export class DomainEventEmitter<SupportedEvents extends CommonEventDefinition[]>
4855
string,
4956
Handlers<EventHandler<CommonEventDefinitionConsumerSchemaType<SupportedEvents[number]>>>
5057
>
58+
private readonly inProgressDispatchByEventId: Map<string, Promise<void>>
5159
private readonly inProgressBackgroundHandlerByEventId: Map<string, Promise<void>>
60+
private isDisposed = false
5261

5362
constructor(
5463
deps: DomainEventEmitterDependencies<SupportedEvents>,
@@ -66,6 +75,7 @@ export class DomainEventEmitter<SupportedEvents extends CommonEventDefinition[]>
6675
resolveHandlerSpy<CommonEventDefinitionConsumerSchemaType<SupportedEvents[number]>>(options)
6776

6877
this.eventHandlerMap = new Map()
78+
this.inProgressDispatchByEventId = new Map()
6979
this.inProgressBackgroundHandlerByEventId = new Map()
7080
}
7181

@@ -80,8 +90,39 @@ export class DomainEventEmitter<SupportedEvents extends CommonEventDefinition[]>
8090
return this._handlerSpy
8191
}
8292

93+
/**
94+
* Waits for accepted events to finish being dispatched, then stops accepting new ones.
95+
*
96+
* Both maps are drained: an event is only in `inProgressBackgroundHandlerByEventId` once its
97+
* foreground handlers are done, so mid-foreground dispatches are tracked separately. Re-checked
98+
* until empty, since handlers can emit events of their own, and bounded so an endless chain
99+
* cannot hold shutdown open.
100+
*/
83101
public async dispose(): Promise<void> {
84-
await Promise.all(this.inProgressBackgroundHandlerByEventId.values())
102+
for (let pass = 0; pass < MAX_DISPOSE_DRAIN_PASSES; pass++) {
103+
const pending = [
104+
...this.inProgressDispatchByEventId.values(),
105+
...this.inProgressBackgroundHandlerByEventId.values(),
106+
]
107+
if (pending.length === 0) break
108+
109+
await Promise.all(pending)
110+
}
111+
112+
const pendingCount =
113+
this.inProgressDispatchByEventId.size + this.inProgressBackgroundHandlerByEventId.size
114+
if (pendingCount > 0) {
115+
this.logger.error(
116+
{ pendingEventDispatches: pendingCount },
117+
`Event dispatches still pending after ${MAX_DISPOSE_DRAIN_PASSES} drain passes, giving up on them`,
118+
)
119+
}
120+
121+
// Set before clearing the handlers, so that an event emitted from this point on fails loudly
122+
// rather than finding an empty handler map and being silently discarded.
123+
this.isDisposed = true
124+
125+
this.inProgressDispatchByEventId.clear()
85126
this.inProgressBackgroundHandlerByEventId.clear()
86127
this.eventHandlerMap.clear()
87128
this._handlerSpy?.clear()
@@ -100,6 +141,19 @@ export class DomainEventEmitter<SupportedEvents extends CommonEventDefinition[]>
100141
})
101142
}
102143

144+
/*
145+
A disposed emitter has no handlers left, so the event would reach neither its listeners nor
146+
any listener that forwards it onwards (for example to a message broker). Failing here lets the
147+
caller fail and be retried, instead of completing successfully while its event silently went nowhere.
148+
*/
149+
if (this.isDisposed) {
150+
throw new InternalError({
151+
errorCode: 'EVENT_EMITTER_DISPOSED',
152+
message: `Cannot emit event ${eventTypeName}, emitter is already disposed`,
153+
details: { eventTypeName },
154+
})
155+
}
156+
103157
if (!data.timestamp) data.timestamp = this.metadataFiller.produceTimestamp()
104158
if (!data.id) data.id = this.metadataFiller.produceId()
105159
if (!data.metadata) {
@@ -165,6 +219,28 @@ export class DomainEventEmitter<SupportedEvents extends CommonEventDefinition[]>
165219
const eventHandlers = this.eventHandlerMap.get(event.type)
166220
if (!eventHandlers) return
167221

222+
const dispatch = this.executeHandlers(event, eventHandlers)
223+
/**
224+
* Registering it so that when dispose is executed before we get to background handler, we still finish processing
225+
*/
226+
this.inProgressDispatchByEventId.set(
227+
event.id,
228+
dispatch.catch(() => {}),
229+
)
230+
231+
try {
232+
await dispatch
233+
} finally {
234+
this.inProgressDispatchByEventId.delete(event.id)
235+
}
236+
}
237+
238+
private async executeHandlers<SupportedEvent extends SupportedEvents[number]>(
239+
event: CommonEventDefinitionConsumerSchemaType<SupportedEvent>,
240+
eventHandlers: Handlers<
241+
EventHandler<CommonEventDefinitionConsumerSchemaType<SupportedEvents[number]>>
242+
>,
243+
): Promise<void> {
168244
for (const handler of eventHandlers.foreground) {
169245
await this.executeEventHandler(event, handler, false)
170246
}

0 commit comments

Comments
 (0)