Skip to content

Commit 46ba9c8

Browse files
authored
perf(app): use cursor session history loading (#17329)
1 parent 80f91d3 commit 46ba9c8

4 files changed

Lines changed: 102 additions & 23 deletions

File tree

packages/app/src/context/global-sync/session-prefetch.test.ts

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
getSessionPrefetch,
66
runSessionPrefetch,
77
setSessionPrefetch,
8+
shouldSkipSessionPrefetch,
89
} from "./session-prefetch"
910

1011
describe("session prefetch", () => {
@@ -16,11 +17,12 @@ describe("session prefetch", () => {
1617
directory: "/tmp/a",
1718
sessionID: "ses_1",
1819
limit: 200,
20+
cursor: "abc",
1921
complete: false,
2022
at: 123,
2123
})
2224

23-
expect(getSessionPrefetch("/tmp/a", "ses_1")).toEqual({ limit: 200, complete: false, at: 123 })
25+
expect(getSessionPrefetch("/tmp/a", "ses_1")).toEqual({ limit: 200, cursor: "abc", complete: false, at: 123 })
2426
expect(getSessionPrefetch("/tmp/b", "ses_1")).toBeUndefined()
2527

2628
clearSessionPrefetch("/tmp/a", ["ses_1"])
@@ -38,26 +40,57 @@ describe("session prefetch", () => {
3840
sessionID: "ses_2",
3941
task: async () => {
4042
calls += 1
41-
return { limit: 100, complete: true, at: 456 }
43+
return { limit: 100, cursor: "next", complete: true, at: 456 }
4244
},
4345
})
4446

4547
const [a, b] = await Promise.all([run(), run()])
4648

4749
expect(calls).toBe(1)
48-
expect(a).toEqual({ limit: 100, complete: true, at: 456 })
49-
expect(b).toEqual({ limit: 100, complete: true, at: 456 })
50+
expect(a).toEqual({ limit: 100, cursor: "next", complete: true, at: 456 })
51+
expect(b).toEqual({ limit: 100, cursor: "next", complete: true, at: 456 })
5052
})
5153

5254
test("clears a whole directory", () => {
53-
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_1", limit: 10, complete: true, at: 1 })
54-
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_2", limit: 20, complete: false, at: 2 })
55-
setSessionPrefetch({ directory: "/tmp/e", sessionID: "ses_1", limit: 30, complete: true, at: 3 })
55+
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_1", limit: 10, cursor: "a", complete: true, at: 1 })
56+
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_2", limit: 20, cursor: "b", complete: false, at: 2 })
57+
setSessionPrefetch({ directory: "/tmp/e", sessionID: "ses_1", limit: 30, cursor: "c", complete: true, at: 3 })
5658

5759
clearSessionPrefetchDirectory("/tmp/d")
5860

5961
expect(getSessionPrefetch("/tmp/d", "ses_1")).toBeUndefined()
6062
expect(getSessionPrefetch("/tmp/d", "ses_2")).toBeUndefined()
61-
expect(getSessionPrefetch("/tmp/e", "ses_1")).toEqual({ limit: 30, complete: true, at: 3 })
63+
expect(getSessionPrefetch("/tmp/e", "ses_1")).toEqual({ limit: 30, cursor: "c", complete: true, at: 3 })
64+
})
65+
66+
test("refreshes stale first-page prefetched history", () => {
67+
expect(
68+
shouldSkipSessionPrefetch({
69+
message: true,
70+
info: { limit: 200, cursor: "x", complete: false, at: 1 },
71+
chunk: 200,
72+
now: 1 + 15_001,
73+
}),
74+
).toBe(false)
75+
})
76+
77+
test("keeps deeper or complete history cached", () => {
78+
expect(
79+
shouldSkipSessionPrefetch({
80+
message: true,
81+
info: { limit: 400, cursor: "x", complete: false, at: 1 },
82+
chunk: 200,
83+
now: 1 + 15_001,
84+
}),
85+
).toBe(true)
86+
87+
expect(
88+
shouldSkipSessionPrefetch({
89+
message: true,
90+
info: { limit: 120, complete: true, at: 1 },
91+
chunk: 200,
92+
now: 1 + 15_001,
93+
}),
94+
).toBe(true)
6295
})
6396
})

packages/app/src/context/global-sync/session-prefetch.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,23 @@ export const SESSION_PREFETCH_TTL = 15_000
44

55
type Meta = {
66
limit: number
7+
cursor?: string
78
complete: boolean
89
at: number
910
}
1011

12+
export function shouldSkipSessionPrefetch(input: { message: boolean; info?: Meta; chunk: number; now?: number }) {
13+
if (input.message) {
14+
if (!input.info) return true
15+
if (input.info.complete) return true
16+
if (input.info.limit > input.chunk) return true
17+
} else {
18+
if (!input.info) return false
19+
}
20+
21+
return (input.now ?? Date.now()) - input.info.at < SESSION_PREFETCH_TTL
22+
}
23+
1124
const cache = new Map<string, Meta>()
1225
const inflight = new Map<string, Promise<Meta | undefined>>()
1326
const rev = new Map<string, number>()
@@ -53,11 +66,13 @@ export function setSessionPrefetch(input: {
5366
directory: string
5467
sessionID: string
5568
limit: number
69+
cursor?: string
5670
complete: boolean
5771
at?: number
5872
}) {
5973
cache.set(key(input.directory, input.sessionID), {
6074
limit: input.limit,
75+
cursor: input.cursor,
6176
complete: input.complete,
6277
at: input.at ?? Date.now(),
6378
})

packages/app/src/context/sync.tsx

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ const keyFor = (directory: string, id: string) => `${directory}\n${id}`
3232

3333
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
3434

35+
function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
36+
const map = new Map(a.map((item) => [item.id, item] as const))
37+
for (const item of b) map.set(item.id, item)
38+
return [...map.values()].sort((x, y) => cmp(x.id, y.id))
39+
}
40+
3541
type OptimisticStore = {
3642
message: Record<string, Message[] | undefined>
3743
part: Record<string, Part[] | undefined>
@@ -119,6 +125,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
119125
const seen = new Map<string, Set<string>>()
120126
const [meta, setMeta] = createStore({
121127
limit: {} as Record<string, number>,
128+
cursor: {} as Record<string, string | undefined>,
122129
complete: {} as Record<string, boolean>,
123130
loading: {} as Record<string, boolean>,
124131
})
@@ -157,6 +164,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
157164
for (const sessionID of sessionIDs) {
158165
const key = keyFor(directory, sessionID)
159166
delete draft.limit[key]
167+
delete draft.cursor[key]
160168
delete draft.complete[key]
161169
delete draft.loading[key]
162170
}
@@ -187,17 +195,24 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
187195
evict(directory, setStore, stale)
188196
}
189197

190-
const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
198+
const fetchMessages = async (input: {
199+
client: typeof sdk.client
200+
sessionID: string
201+
limit: number
202+
before?: string
203+
}) => {
191204
const messages = await retry(() =>
192-
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
205+
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
193206
)
194207
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
195208
const session = items.map((x) => x.info).sort((a, b) => cmp(a.id, b.id))
196209
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
210+
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
197211
return {
198212
session,
199213
part,
200-
complete: session.length < input.limit,
214+
cursor,
215+
complete: !cursor,
201216
}
202217
}
203218

@@ -209,6 +224,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
209224
setStore: Setter
210225
sessionID: string
211226
limit: number
227+
before?: string
228+
mode?: "replace" | "prepend"
212229
}) => {
213230
const key = keyFor(input.directory, input.sessionID)
214231
if (meta.loading[key]) return
@@ -217,17 +234,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
217234
await fetchMessages(input)
218235
.then((next) => {
219236
if (!tracked(input.directory, input.sessionID)) return
237+
const [store] = globalSync.child(input.directory, { bootstrap: false })
238+
const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : []
239+
const message = input.mode === "prepend" ? merge(cached, next.session) : next.session
220240
batch(() => {
221-
input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
241+
input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
222242
for (const p of next.part) {
223243
input.setStore("part", p.id, p.part)
224244
}
225-
setMeta("limit", key, input.limit)
245+
setMeta("limit", key, message.length)
246+
setMeta("cursor", key, next.cursor)
226247
setMeta("complete", key, next.complete)
227248
setSessionPrefetch({
228249
directory: input.directory,
229250
sessionID: input.sessionID,
230-
limit: input.limit,
251+
limit: message.length,
252+
cursor: next.cursor,
231253
complete: next.complete,
232254
})
233255
})
@@ -312,6 +334,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
312334
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
313335
batch(() => {
314336
setMeta("limit", key, seeded.limit)
337+
setMeta("cursor", key, seeded.cursor)
315338
setMeta("complete", key, seeded.complete)
316339
setMeta("loading", key, false)
317340
})
@@ -325,6 +348,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
325348
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
326349
batch(() => {
327350
setMeta("limit", key, seeded.limit)
351+
setMeta("cursor", key, seeded.cursor)
328352
setMeta("complete", key, seeded.complete)
329353
setMeta("loading", key, false)
330354
})
@@ -420,7 +444,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
420444
if (store.message[sessionID] === undefined) return false
421445
if (meta.limit[key] === undefined) return false
422446
if (meta.complete[key]) return false
423-
return true
447+
return !!meta.cursor[key]
424448
},
425449
loading(sessionID: string) {
426450
const key = keyFor(sdk.directory, sessionID)
@@ -435,14 +459,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
435459
const step = count ?? messagePageSize
436460
if (meta.loading[key]) return
437461
if (meta.complete[key]) return
462+
const before = meta.cursor[key]
463+
if (!before) return
438464

439-
const currentLimit = meta.limit[key] ?? messagePageSize
440465
await loadMessages({
441466
directory,
442467
client,
443468
setStore,
444469
sessionID,
445-
limit: currentLimit + step,
470+
limit: step,
471+
before,
472+
mode: "prepend",
446473
})
447474
},
448475
},

packages/app/src/pages/layout.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ import {
4141
getSessionPrefetch,
4242
isSessionPrefetchCurrent,
4343
runSessionPrefetch,
44-
SESSION_PREFETCH_TTL,
4544
setSessionPrefetch,
45+
shouldSkipSessionPrefetch,
4646
} from "@/context/global-sync/session-prefetch"
4747
import { useNotification } from "@/context/notification"
4848
import { usePermission } from "@/context/permission"
@@ -770,9 +770,11 @@ export default function Layout(props: ParentProps) {
770770
const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
771771
const sorted = mergeByID([], next)
772772
const stale = markPrefetched(directory, sessionID)
773+
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
773774
const meta = {
774-
limit: prefetchChunk,
775-
complete: sorted.length < prefetchChunk,
775+
limit: sorted.length,
776+
cursor,
777+
complete: !cursor,
776778
at: Date.now(),
777779
}
778780

@@ -846,10 +848,12 @@ export default function Layout(props: ParentProps) {
846848

847849
const [store] = globalSync.child(directory, { bootstrap: false })
848850
const cached = untrack(() => {
849-
if (store.message[session.id] === undefined) return false
850851
const info = getSessionPrefetch(directory, session.id)
851-
if (!info) return false
852-
return Date.now() - info.at < SESSION_PREFETCH_TTL
852+
return shouldSkipSessionPrefetch({
853+
message: store.message[session.id] !== undefined,
854+
info,
855+
chunk: prefetchChunk,
856+
})
853857
})
854858
if (cached) return
855859

0 commit comments

Comments
 (0)