Skip to content

Commit e5ca486

Browse files
chore: extract transport-neutral caught-up tracker (#326)
## Summary - extract `isCaughtUp()` / `caughtUp()` lifecycle behavior into a transport-neutral `CaughtUpTracker` - represent observed caught-up points as delivery boundaries that transition only after preceding visible records drain - invalidate stale boundaries after newer observations, reconnects, and terminal states - keep raw sequence accounting ahead of command-record filtering - export the tracker for normalized SSE or other direct read clients ## Why The retrying read-session wrapper previously owned waiter settlement, reconnect behavior, termination, tail comparison, and delivery ordering alongside transport retry and queue mechanics. Moving those state transitions into a focused component makes the invariants independently testable and reusable without coupling them to Fetch, S2S, or `ReadableStream` construction. The read-session delivery layer now retains the returned boundary beside visible records and calls `markDelivered()` when that queue entry drains. Empty heartbeats use the same path, while filtered command records still contribute their unfiltered final sequence number. ## Validation - `bunx vitest --run packages/streamstore/src/tests/caughtUpTracker.test.ts packages/streamstore/src/tests/retryReadSession.test.ts` — 30 tests passed - `bunx vitest --run packages/streamstore/src/tests/h2-pool.test.ts` — 13 tests passed - `bun run --cwd packages/streamstore check` - Biome checks on all changed files - `git diff --check`
1 parent 24e0622 commit e5ca486

4 files changed

Lines changed: 406 additions & 83 deletions

File tree

packages/streamstore/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,11 @@ export {
181181

182182
export type { StreamOptions } from "./basin.js";
183183
export type { BatchOutput, BatchTransformOptions } from "./batch-transform.js";
184+
export type {
185+
CaughtUpBatch,
186+
CaughtUpBoundary,
187+
} from "./lib/stream/caught-up-tracker.js";
188+
export { CaughtUpTracker } from "./lib/stream/caught-up-tracker.js";
184189
export type {
185190
AcksStream,
186191
AppendHeaders,

packages/streamstore/src/lib/retry.ts

Lines changed: 25 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import { isCommandRecord, meteredBytes } from "../utils.js";
1313
import { FifoQueue } from "./queue.js";
1414
import type { AppendResult, CloseResult } from "./result.js";
1515
import { err, errClose, ok, okClose } from "./result.js";
16+
import {
17+
type CaughtUpBoundary,
18+
CaughtUpTracker,
19+
} from "./stream/caught-up-tracker.js";
1620
import type {
1721
AcksStream,
1822
AppendRecord,
@@ -229,25 +233,10 @@ export async function withRetries<T>(
229233

230234
throw lastError;
231235
}
232-
/** Error a pending `caughtUp()` settles with when the session ends first. */
233-
function sessionClosedError(): S2Error {
234-
return new S2Error({
235-
message: "Read session ended before catching up",
236-
code: "SESSION_CLOSED",
237-
status: 0,
238-
origin: "sdk",
239-
});
240-
}
241-
242-
type CaughtUpWaiter = {
243-
resolve: (tail: Types.StreamPosition) => void;
244-
reject: (error: S2Error) => void;
245-
};
246-
247236
type ReadDelivery<Format extends "string" | "bytes"> = {
248237
records: Types.ReadRecord<Format>[];
249238
next: number;
250-
caughtUpTail?: API.StreamPosition;
239+
boundary?: CaughtUpBoundary;
251240
onDrained?: () => void;
252241
};
253242

@@ -262,41 +251,7 @@ export class RetryReadSession<Format extends "string" | "bytes" = "string">
262251
private _recordsRead: number = 0;
263252
private _bytesRead: number = 0;
264253

265-
private _isCaughtUp = false;
266-
private _caughtUpTail: API.StreamPosition | undefined = undefined;
267-
private caughtUpWaiters: CaughtUpWaiter[] = [];
268-
private terminal: { error?: S2Error } | undefined = undefined;
269-
270-
/** Mark caught up and resolve pending waits with this tail. */
271-
private markCaughtUp(tail: API.StreamPosition): void {
272-
this._isCaughtUp = true;
273-
this._caughtUpTail = tail;
274-
const waiters = this.caughtUpWaiters.splice(0);
275-
const position = toSDKStreamPosition(tail);
276-
for (const waiter of waiters) {
277-
waiter.resolve(position);
278-
}
279-
}
280-
281-
private markBehind(): void {
282-
this._isCaughtUp = false;
283-
}
284-
285-
/** End the session and reject pending waits. Clean ends preserve caught-up state. */
286-
private settleTerminal(error?: S2Error): void {
287-
if (this.terminal) {
288-
return;
289-
}
290-
this.terminal = { error };
291-
if (error) {
292-
this._isCaughtUp = false;
293-
}
294-
const waiters = this.caughtUpWaiters.splice(0);
295-
const rejection = error ?? sessionClosedError();
296-
for (const waiter of waiters) {
297-
waiter.reject(rejection);
298-
}
299-
}
254+
private readonly caughtUpTracker: CaughtUpTracker;
300255

301256
static async create<Format extends "string" | "bytes" = "string">(
302257
generator: (
@@ -361,6 +316,7 @@ export class RetryReadSession<Format extends "string" | "bytes" = "string">
361316
...config,
362317
};
363318
const format = (args?.as ?? "string") as Format;
319+
const caughtUpTracker = new CaughtUpTracker();
364320
let session: TransportReadSession<Format> | undefined = initialSession;
365321
let currentReader:
366322
| ReadableStreamDefaultReader<TransportReadEvent<Format>>
@@ -379,9 +335,9 @@ export class RetryReadSession<Format extends "string" | "bytes" = "string">
379335
};
380336
const enqueueBatch = async (
381337
records: Types.ReadRecord<Format>[],
382-
caughtUpTail?: API.StreamPosition,
338+
boundary?: CaughtUpBoundary,
383339
) => {
384-
if (!caughtUpTail) {
340+
if (!boundary) {
385341
if (records.length > 0) {
386342
deliveries.push({ records, next: 0 });
387343
wake();
@@ -391,22 +347,22 @@ export class RetryReadSession<Format extends "string" | "bytes" = "string">
391347
await new Promise<void>((onDrained) => {
392348
resolvePendingDelivery = onDrained;
393349
if (records.length === 0 && deliveries.length === 0) {
394-
this.markCaughtUp(caughtUpTail);
350+
boundary.markDelivered();
395351
onDrained();
396352
return;
397353
}
398354
deliveries.push({
399355
records,
400356
next: 0,
401-
caughtUpTail,
357+
boundary,
402358
onDrained,
403359
});
404360
wake();
405361
});
406362
resolvePendingDelivery = undefined;
407363
};
408364
const failPump = (error: S2Error) => {
409-
this.settleTerminal(error);
365+
caughtUpTracker.end(error);
410366
pumpError = error;
411367
deliveries.clear();
412368
wake();
@@ -504,7 +460,7 @@ export class RetryReadSession<Format extends "string" | "bytes" = "string">
504460
if (done || cancelled) {
505461
reader.releaseLock();
506462
currentReader = undefined;
507-
this.settleTerminal();
463+
caughtUpTracker.end();
508464
pumpDone = true;
509465
wake();
510466
return;
@@ -549,7 +505,7 @@ export class RetryReadSession<Format extends "string" | "bytes" = "string">
549505
nextArgs.wait = baselineWait;
550506
}
551507
}
552-
this.markBehind();
508+
caughtUpTracker.reconnect();
553509

554510
try {
555511
await session.cancel?.("retry");
@@ -580,15 +536,13 @@ export class RetryReadSession<Format extends "string" | "bytes" = "string">
580536
return;
581537
}
582538

583-
this.markBehind();
584539
const { records, tail } = result.batch;
585540
const lastRecord = records.at(-1);
586-
const caughtUpTail =
587-
tail &&
588-
(lastRecord === undefined ||
589-
lastRecord.seq_num + 1 === tail.seq_num)
590-
? tail
591-
: undefined;
541+
const boundary = caughtUpTracker.observeBatch({
542+
recordCount: records.length,
543+
lastSeqNum: lastRecord?.seq_num,
544+
tail: tail ? toSDKStreamPosition(tail) : undefined,
545+
});
592546
const visibleRecords: Types.ReadRecord<Format>[] = [];
593547
for (const record of records) {
594548
this._nextReadPosition = {
@@ -602,14 +556,12 @@ export class RetryReadSession<Format extends "string" | "bytes" = "string">
602556
visibleRecords.push(toSDKReadRecord(record, format));
603557
}
604558
}
605-
await enqueueBatch(visibleRecords, caughtUpTail);
559+
await enqueueBatch(visibleRecords, boundary);
606560
}
607561
}
608562
};
609563
const finishDelivery = (delivery: ReadDelivery<Format>) => {
610-
if (delivery.caughtUpTail) {
611-
this.markCaughtUp(delivery.caughtUpTail);
612-
}
564+
delivery.boundary?.markDelivered();
613565
delivery.onDrained?.();
614566
};
615567

@@ -654,7 +606,7 @@ export class RetryReadSession<Format extends "string" | "bytes" = "string">
654606
},
655607
cancel: async (reason) => {
656608
cancelled = true;
657-
this.settleTerminal();
609+
caughtUpTracker.end();
658610
pumpDone = true;
659611
deliveries.clear();
660612
resolvePendingDelivery?.();
@@ -676,6 +628,7 @@ export class RetryReadSession<Format extends "string" | "bytes" = "string">
676628
},
677629
{ highWaterMark: 0 },
678630
);
631+
this.caughtUpTracker = caughtUpTracker;
679632
}
680633

681634
async [Symbol.asyncDispose]() {
@@ -743,22 +696,11 @@ export class RetryReadSession<Format extends "string" | "bytes" = "string">
743696
}
744697

745698
isCaughtUp(): boolean {
746-
return this._isCaughtUp;
699+
return this.caughtUpTracker.isCaughtUp();
747700
}
748701

749702
caughtUp(): Promise<Types.StreamPosition> {
750-
if (this._isCaughtUp && this._caughtUpTail) {
751-
return Promise.resolve(toSDKStreamPosition(this._caughtUpTail));
752-
}
753-
if (this.terminal) {
754-
return Promise.reject(this.terminal.error ?? sessionClosedError());
755-
}
756-
const promise = new Promise<Types.StreamPosition>((resolve, reject) => {
757-
this.caughtUpWaiters.push({ resolve, reject });
758-
});
759-
// Prevent an unhandled rejection when the promise is ignored.
760-
promise.catch(() => {});
761-
return promise;
703+
return this.caughtUpTracker.caughtUp();
762704
}
763705
}
764706

0 commit comments

Comments
 (0)