From 5d17b6798d0243ca54f2f2e4c2cfd7eabf2052d1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 23 Aug 2026 03:40:50 +0800 Subject: [PATCH] fix(api-gateway): retry journal pages after reconnect --- .../api/gateway/src/client/journal-stream.ts | 31 +++++++++++- .../tests/journal-stream.client.spec.ts | 50 +++++++++++++++++-- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/packages/api/gateway/src/client/journal-stream.ts b/packages/api/gateway/src/client/journal-stream.ts index b485fb1b30..9b64cc5605 100644 --- a/packages/api/gateway/src/client/journal-stream.ts +++ b/packages/api/gateway/src/client/journal-stream.ts @@ -396,7 +396,10 @@ export abstract class RemoteJournalStream>, + initial: Promise>>, + ): Promise<{ readonly type: 'superseded'; readonly item: JournalStreamItem }> { + let pending = initial + while (true) { + let next: IteratorResult> + try { + next = await pending + } finally { + this.releaseNext(pending) + } + if (next.done) { + this.stream.signal.throwIfAborted() + throw new Error(`${this.options.name} ended while replacing an aborted page generation`) + } + const item = next.value + if (item.generation !== generation) return { type: 'superseded', item } + if (item.value.type === 'opened') { + throw new Error(`${this.options.name} emitted more than one opening cursor`) + } + pending = this.nextResult(iterator) + } + } + private mergeReplacement(page: Page, queued: readonly Entry[]): Entry[] | undefined { const entries = [...this.options.entries(page)] this.assertPage(entries) diff --git a/packages/api/gateway/tests/journal-stream.client.spec.ts b/packages/api/gateway/tests/journal-stream.client.spec.ts index ca8653027f..3c50506acc 100644 --- a/packages/api/gateway/tests/journal-stream.client.spec.ts +++ b/packages/api/gateway/tests/journal-stream.client.spec.ts @@ -29,6 +29,8 @@ interface Generation { readonly hold?: boolean } +type PageSource = Page | Promise | ((signal: AbortSignal) => Promise) + const AVAILABLE_CONNECTION = { hostDescription: { getSnapshot: () => ({ @@ -55,7 +57,7 @@ const STREAM_FACTORY = { class FixtureJournal extends RemoteJournalStream { constructor( private readonly generations: Generation[], - private readonly pages: (Page | Promise)[], + private readonly pages: PageSource[], private readonly calls: string[], private readonly pageRequests: PageRequest[], private readonly pageCursors: number[], @@ -95,13 +97,17 @@ class FixtureJournal extends RemoteJournalStream { + protected override readPage( + request: PageRequest, + through: number, + signal: AbortSignal, + ): Promise { this.calls.push('page') this.pageRequests.push(request) this.pageCursors.push(through) const value = this.pages.shift() if (value === undefined) throw new Error('no scripted journal page') - return Promise.resolve(value) + return typeof value === 'function' ? value(signal) : Promise.resolve(value) } /** @inheritdoc */ @@ -112,7 +118,7 @@ class FixtureJournal extends RemoteJournalStream)[], + pages: PageSource[], ): { readonly journal: RemoteJournalStream readonly changes: RemoteJournalChange[] @@ -241,6 +247,42 @@ describe('RemoteJournalStream', () => { await fixture.journal.dispose() }) + it('restarts a page aborted with its carrier generation', async () => { + const fixture = journalFixture( + [ + { + frames: [{ type: 'opened', cursor: 1 }], + terminal: new RemoteStreamCarrierError('carrier lost during page'), + }, + { + frames: [{ type: 'opened', cursor: 2 }], + hold: true, + }, + ], + [ + signal => new Promise((_resolve, reject) => { + const aborted = (): void => { reject(signal.reason) } + signal.addEventListener('abort', aborted, { once: true }) + if (signal.aborted) aborted() + }), + page('replacement', [0, 1, 2]), + ], + ) + + await fixture.journal.open({ limit: 3 }) + + expect(fixture.changes).toEqual([{ + type: 'replace', + page: page('replacement', [0, 1, 2]), + entries: entries(0, 1, 2), + hasMore: false, + }]) + expect(fixture.pageCursors).toEqual([1, 2]) + expect(fixture.followCursors).toEqual([undefined, 1]) + expect(fixture.failed).not.toHaveBeenCalled() + await fixture.journal.dispose() + }) + it('repairs a live gap before publishing another change', async () => { const fixture = journalFixture( [{