diff --git a/.changeset/inmemory-slow-subscriber.md b/.changeset/inmemory-slow-subscriber.md new file mode 100644 index 00000000..29440816 --- /dev/null +++ b/.changeset/inmemory-slow-subscriber.md @@ -0,0 +1,7 @@ +--- +'@codeforbreakfast/eventsourcing-store-inmemory': patch +--- + +Fix the subscription buffer size. The store meant to buffer 256 events per subscription but wrote `2 ^ 8`, which is 10 in JavaScript. A subscriber only ten events behind blocked `append`, and a consumer that appended to a stream it was subscribed to could deadlock. + +The buffer is now 256. A subscriber that falls 256 events behind still blocks `append`. This is deliberate: the in-memory store is meant for tests and similar uses, where failing fast is better than growing memory without limit. diff --git a/packages/eventsourcing-store-inmemory/src/lib/InMemoryStore.ts b/packages/eventsourcing-store-inmemory/src/lib/InMemoryStore.ts index 6b230309..10b1c211 100644 --- a/packages/eventsourcing-store-inmemory/src/lib/InMemoryStore.ts +++ b/packages/eventsourcing-store-inmemory/src/lib/InMemoryStore.ts @@ -21,9 +21,11 @@ interface EventStream { readonly pubsub: PubSub.PubSub; } +const subscriberBufferCapacity = 256; + const emptyStream = (): Effect.Effect, never, never> => pipe( - 2 ^ 8, + subscriberBufferCapacity, PubSub.bounded, Effect.map((pubsub) => ({ events: Chunk.empty(), diff --git a/packages/eventsourcing-store-inmemory/src/lib/inMemory.spec.ts b/packages/eventsourcing-store-inmemory/src/lib/inMemory.spec.ts index 513993aa..eaa30000 100644 --- a/packages/eventsourcing-store-inmemory/src/lib/inMemory.spec.ts +++ b/packages/eventsourcing-store-inmemory/src/lib/inMemory.spec.ts @@ -98,3 +98,44 @@ describe('Subscriptions are live as soon as they return', () => { Effect.runPromise )); }); + +const lagBehindEvents = 100; + +const appendOneEventAt = (store: EventStore) => (eventNumber: number) => + pipe( + ['event'], + Stream.fromIterable, + Stream.run(store.append({ streamId: decodeStreamId('lagging-stream'), eventNumber })) + ); + +const stallOnEveryEvent = () => Effect.never; + +const startSubscriberThatNeverDrains = (store: EventStore) => + pipe(store.subscribeAll(), Effect.flatMap(Stream.runForEach(stallOnEveryEvent)), Effect.fork); + +const appendManyEvents = (store: EventStore) => + Effect.forEach( + Array.from({ length: lagBehindEvents }, (_, i) => i), + appendOneEventAt(store), + { discard: true } + ); + +const runLaggingSubscriberTest = (store: EventStore) => + pipe( + store, + startSubscriberThatNeverDrains, + Effect.andThen(Effect.yieldNow()), + Effect.andThen(appendManyEvents(store)), + Effect.timeout('2 seconds') + ); + +describe('A slow subscriber', () => { + it('should not stall appends while it lags behind by fewer events than the buffer holds', () => + pipe( + StringEventStore, + Effect.flatMap(runLaggingSubscriberTest), + Effect.scoped, + Effect.provide(StringEventStoreLayer), + Effect.runPromise + )); +});