Resume cached timelines without replaying their tail (#3329)
* perf(timeline): resume cached history from its exact range Warm launches were repainting cached history and then downloading the same bounded tail to rediscover that nothing changed. Persist synchronization authority only when the complete current canonical window can be encoded losslessly; otherwise retain the existing display-only fallback. * test(timeline): hide replica setup behind E2E helper * fix(timeline): bound persisted resume overflow * test(timeline): cover workspace eviction catch-up order * test(timeline): read persisted range from IndexedDB
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
isTimelineResumeSnapshotAuthoritative,
|
||||
planTimelineOlderFetch,
|
||||
planTimelinePromptJump,
|
||||
planTimelineResumeFetch,
|
||||
planTimelineTailFetch,
|
||||
} from "./timeline-sync-plan";
|
||||
|
||||
@@ -19,6 +20,15 @@ describe("timeline sync planning", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("a restored canonical range catches up after its exact end", () => {
|
||||
expect(planTimelineResumeFetch({ epoch: "epoch-1", endSeq: 49 })).toEqual({
|
||||
direction: "after",
|
||||
cursor: { epoch: "epoch-1", seq: 49 },
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
});
|
||||
});
|
||||
|
||||
test("older history loads one bounded page before the start cursor", () => {
|
||||
const plan = planTimelineOlderFetch({ epoch: "epoch-1", seq: 25 });
|
||||
|
||||
|
||||
@@ -51,6 +51,14 @@ export function planTimelineTailFetch() {
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function planTimelineResumeFetch(
|
||||
range: { epoch: string; endSeq: number } | null | undefined,
|
||||
): ProjectedTimelineForwardFetchPlan {
|
||||
return range
|
||||
? planTimelineCatchUpAfter({ epoch: range.epoch, seq: range.endSeq })
|
||||
: planTimelineTailFetch();
|
||||
}
|
||||
|
||||
export function planTimelineOlderFetch(cursor: TimelineSyncCursor) {
|
||||
return {
|
||||
direction: "before",
|
||||
|
||||
@@ -33,6 +33,7 @@ interface TimelineFetch {
|
||||
|
||||
class TimelineWorld {
|
||||
readonly errors: string[] = [];
|
||||
readonly cursors = new Map<string, { epoch: string; endSeq: number }>();
|
||||
readonly sync = createViewedTimelineSync({
|
||||
initialDeliveryMode: "selective",
|
||||
setSubscription: async (agentIds) => {
|
||||
@@ -45,6 +46,7 @@ class TimelineWorld {
|
||||
this.releaseMembershipWaiter();
|
||||
return result.promise;
|
||||
},
|
||||
readCursor: (agentId) => this.cursors.get(agentId),
|
||||
fetchPage: async (agentId, request) => {
|
||||
const result = deferred<{
|
||||
hasNewer: boolean;
|
||||
@@ -63,6 +65,12 @@ class TimelineWorld {
|
||||
this.releaseFetchWaiters();
|
||||
return result.promise;
|
||||
},
|
||||
fetchLatestTail: (agentId) =>
|
||||
this.fetchTimeline(agentId, {
|
||||
direction: "tail",
|
||||
limit: 40,
|
||||
projection: "projected",
|
||||
}),
|
||||
reportError: (error) => {
|
||||
this.errors.push(error instanceof Error ? error.message : String(error));
|
||||
const waiter = this.errorWaiters.shift();
|
||||
@@ -94,6 +102,25 @@ class TimelineWorld {
|
||||
private readonly scheduled: Array<{ task: () => void; delayMs: number }> = [];
|
||||
private readonly retryWaiters: Array<(retry: () => void) => void> = [];
|
||||
|
||||
private fetchTimeline(
|
||||
agentId: string,
|
||||
request: ProjectedTimelineForwardFetchPlan,
|
||||
): Promise<{ hasNewer: boolean; endCursor: { epoch: string; seq: number } | null }> {
|
||||
const result = deferred<{
|
||||
hasNewer: boolean;
|
||||
endCursor: { epoch: string; seq: number } | null;
|
||||
}>();
|
||||
this.fetches.push({
|
||||
agentId,
|
||||
request,
|
||||
respond: ({ hasNewer, seq = 1 }) =>
|
||||
result.resolve({ hasNewer, endCursor: { epoch: `epoch-${agentId}`, seq } }),
|
||||
fail: (message) => result.reject(new Error(message)),
|
||||
});
|
||||
this.releaseFetchWaiters();
|
||||
return result.promise;
|
||||
}
|
||||
|
||||
nextMembership(): Promise<MembershipRequest> {
|
||||
const request = this.memberships.shift();
|
||||
if (request) return Promise.resolve(request);
|
||||
@@ -166,6 +193,41 @@ test("uses a tail fetch when an agent becomes visible", async () => {
|
||||
fetch.respond({ hasNewer: false });
|
||||
});
|
||||
|
||||
test("catches up after the restored cursor when an agent becomes visible", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 42 });
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
|
||||
const fetch = await world.nextFetch("agent-a");
|
||||
expect(fetch.request).toEqual({
|
||||
direction: "after",
|
||||
cursor: { epoch: "epoch-agent-a", seq: 42 },
|
||||
limit: 40,
|
||||
projection: "projected",
|
||||
});
|
||||
fetch.respond({ hasNewer: false });
|
||||
});
|
||||
|
||||
test("falls back to the latest tail when a restored cursor has more than one catch-up page", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 42 });
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
|
||||
const probe = await world.nextFetch("agent-a");
|
||||
expect(probe.request.direction).toBe("after");
|
||||
probe.respond({ hasNewer: true, seq: 82 });
|
||||
|
||||
const fallback = await world.nextFetch("agent-a");
|
||||
expect(fallback.request).toEqual({ direction: "tail", limit: 40, projection: "projected" });
|
||||
fallback.respond({ hasNewer: false });
|
||||
});
|
||||
|
||||
test("a gap absorbed by a running tail is recovered after the tail completes", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
planTimelineCatchUpAfter,
|
||||
planTimelineTailFetch,
|
||||
planTimelineResumeFetch,
|
||||
type ProjectedTimelineForwardFetchPlan,
|
||||
} from "./timeline-sync-plan";
|
||||
|
||||
@@ -12,10 +12,12 @@ interface TimelinePageResult {
|
||||
interface ViewedTimelineSyncPorts {
|
||||
initialDeliveryMode: TimelineDeliveryMode;
|
||||
setSubscription(agentIds: string[]): Promise<void>;
|
||||
readCursor(agentId: string): { epoch: string; endSeq: number } | undefined;
|
||||
fetchPage(
|
||||
agentId: string,
|
||||
request: ProjectedTimelineForwardFetchPlan,
|
||||
): Promise<TimelinePageResult>;
|
||||
fetchLatestTail(agentId: string): Promise<TimelinePageResult>;
|
||||
reportError(error: unknown): void;
|
||||
schedule(task: () => void, delayMs: number): () => void;
|
||||
}
|
||||
@@ -136,6 +138,12 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
|
||||
const isAcknowledged = (agentId: string) => acknowledged.includes(agentId);
|
||||
const isDesired = (agentId: string) => desired.includes(agentId);
|
||||
const ownsCatchUp = (agentId: string, generation: number) =>
|
||||
!disposed &&
|
||||
connected &&
|
||||
isDesired(agentId) &&
|
||||
isAcknowledged(agentId) &&
|
||||
catchUps.get(agentId)?.generation === generation;
|
||||
|
||||
const notifyListeners = () => {
|
||||
for (const listener of listeners) listener();
|
||||
@@ -168,30 +176,26 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
agentId: string,
|
||||
generation: number,
|
||||
request: ProjectedTimelineForwardFetchPlan,
|
||||
fallbackToLatestTailOnOverflow: boolean,
|
||||
): Promise<void> => {
|
||||
if (
|
||||
disposed ||
|
||||
!connected ||
|
||||
!isDesired(agentId) ||
|
||||
!isAcknowledged(agentId) ||
|
||||
catchUps.get(agentId)?.generation !== generation
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!ownsCatchUp(agentId, generation)) return;
|
||||
|
||||
try {
|
||||
const page = await ports.fetchPage(agentId, request);
|
||||
if (
|
||||
disposed ||
|
||||
!connected ||
|
||||
!isDesired(agentId) ||
|
||||
!isAcknowledged(agentId) ||
|
||||
catchUps.get(agentId)?.generation !== generation
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!ownsCatchUp(agentId, generation)) return;
|
||||
if (page.hasNewer && page.endCursor) {
|
||||
await fetchUntilCurrent(agentId, generation, planTimelineCatchUpAfter(page.endCursor));
|
||||
if (fallbackToLatestTailOnOverflow) {
|
||||
await ports.fetchLatestTail(agentId);
|
||||
catchUps.set(agentId, { generation, status: "complete" });
|
||||
setVisibilityCatchUpReady(agentId);
|
||||
return;
|
||||
}
|
||||
await fetchUntilCurrent(
|
||||
agentId,
|
||||
generation,
|
||||
planTimelineCatchUpAfter(page.endCursor),
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (page.hasNewer) {
|
||||
@@ -230,7 +234,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
if (request) pendingCatchUps.set(agentId, request);
|
||||
return;
|
||||
}
|
||||
const nextRequest = request ?? planTimelineTailFetch();
|
||||
const nextRequest = request ?? planTimelineResumeFetch(ports.readCursor(agentId));
|
||||
const current = catchUps.get(agentId);
|
||||
const decision = decideCatchUp({ current, request: nextRequest, supersede });
|
||||
if (decision === "keep-and-park") {
|
||||
@@ -245,7 +249,12 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
catchUpGenerations.set(agentId, generation);
|
||||
catchUps.set(agentId, { generation, status: "running", request: nextRequest });
|
||||
pendingCatchUps.delete(agentId);
|
||||
void fetchUntilCurrent(agentId, generation, nextRequest);
|
||||
void fetchUntilCurrent(
|
||||
agentId,
|
||||
generation,
|
||||
nextRequest,
|
||||
request === undefined && nextRequest.direction === "after",
|
||||
);
|
||||
};
|
||||
|
||||
const startAcknowledgedCatchUps = () => {
|
||||
|
||||
Reference in New Issue
Block a user