Skip to content

Commit 0e077f7

Browse files
authored
feat: session load perf (anomalyco#17186)
1 parent 776e7a9 commit 0e077f7

9 files changed

Lines changed: 474 additions & 140 deletions

File tree

packages/app/src/context/global-sync.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
2929
import { createChildStoreManager } from "./global-sync/child-store"
3030
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
3131
import { createRefreshQueue } from "./global-sync/queue"
32+
import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch"
3233
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
3334
import { trimSessions } from "./global-sync/session-trim"
3435
import type { ProjectMeta } from "./global-sync/types"
@@ -161,6 +162,7 @@ function createGlobalSync() {
161162
queue.clear(directory)
162163
sessionMeta.delete(directory)
163164
sdkCache.delete(directory)
165+
clearSessionPrefetchDirectory(directory)
164166
},
165167
})
166168

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, expect, test } from "bun:test"
2+
import {
3+
clearSessionPrefetch,
4+
clearSessionPrefetchDirectory,
5+
getSessionPrefetch,
6+
runSessionPrefetch,
7+
setSessionPrefetch,
8+
} from "./session-prefetch"
9+
10+
describe("session prefetch", () => {
11+
test("stores and clears message metadata by directory", () => {
12+
clearSessionPrefetch("/tmp/a", ["ses_1"])
13+
clearSessionPrefetch("/tmp/b", ["ses_1"])
14+
15+
setSessionPrefetch({
16+
directory: "/tmp/a",
17+
sessionID: "ses_1",
18+
limit: 200,
19+
complete: false,
20+
at: 123,
21+
})
22+
23+
expect(getSessionPrefetch("/tmp/a", "ses_1")).toEqual({ limit: 200, complete: false, at: 123 })
24+
expect(getSessionPrefetch("/tmp/b", "ses_1")).toBeUndefined()
25+
26+
clearSessionPrefetch("/tmp/a", ["ses_1"])
27+
28+
expect(getSessionPrefetch("/tmp/a", "ses_1")).toBeUndefined()
29+
})
30+
31+
test("dedupes inflight work", async () => {
32+
clearSessionPrefetch("/tmp/c", ["ses_2"])
33+
34+
let calls = 0
35+
const run = () =>
36+
runSessionPrefetch({
37+
directory: "/tmp/c",
38+
sessionID: "ses_2",
39+
task: async () => {
40+
calls += 1
41+
return { limit: 100, complete: true, at: 456 }
42+
},
43+
})
44+
45+
const [a, b] = await Promise.all([run(), run()])
46+
47+
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+
})
51+
52+
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 })
56+
57+
clearSessionPrefetchDirectory("/tmp/d")
58+
59+
expect(getSessionPrefetch("/tmp/d", "ses_1")).toBeUndefined()
60+
expect(getSessionPrefetch("/tmp/d", "ses_2")).toBeUndefined()
61+
expect(getSessionPrefetch("/tmp/e", "ses_1")).toEqual({ limit: 30, complete: true, at: 3 })
62+
})
63+
})
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
const key = (directory: string, sessionID: string) => `${directory}\n${sessionID}`
2+
3+
export const SESSION_PREFETCH_TTL = 15_000
4+
5+
type Meta = {
6+
limit: number
7+
complete: boolean
8+
at: number
9+
}
10+
11+
const cache = new Map<string, Meta>()
12+
const inflight = new Map<string, Promise<Meta | undefined>>()
13+
const rev = new Map<string, number>()
14+
15+
const version = (id: string) => rev.get(id) ?? 0
16+
17+
export function getSessionPrefetch(directory: string, sessionID: string) {
18+
return cache.get(key(directory, sessionID))
19+
}
20+
21+
export function getSessionPrefetchPromise(directory: string, sessionID: string) {
22+
return inflight.get(key(directory, sessionID))
23+
}
24+
25+
export function clearSessionPrefetchInflight() {
26+
inflight.clear()
27+
}
28+
29+
export function isSessionPrefetchCurrent(directory: string, sessionID: string, value: number) {
30+
return version(key(directory, sessionID)) === value
31+
}
32+
33+
export function runSessionPrefetch(input: {
34+
directory: string
35+
sessionID: string
36+
task: (value: number) => Promise<Meta | undefined>
37+
}) {
38+
const id = key(input.directory, input.sessionID)
39+
const pending = inflight.get(id)
40+
if (pending) return pending
41+
42+
const value = version(id)
43+
44+
const promise = input.task(value).finally(() => {
45+
if (inflight.get(id) === promise) inflight.delete(id)
46+
})
47+
48+
inflight.set(id, promise)
49+
return promise
50+
}
51+
52+
export function setSessionPrefetch(input: {
53+
directory: string
54+
sessionID: string
55+
limit: number
56+
complete: boolean
57+
at?: number
58+
}) {
59+
cache.set(key(input.directory, input.sessionID), {
60+
limit: input.limit,
61+
complete: input.complete,
62+
at: input.at ?? Date.now(),
63+
})
64+
}
65+
66+
export function clearSessionPrefetch(directory: string, sessionIDs: Iterable<string>) {
67+
for (const sessionID of sessionIDs) {
68+
if (!sessionID) continue
69+
const id = key(directory, sessionID)
70+
rev.set(id, version(id) + 1)
71+
cache.delete(id)
72+
inflight.delete(id)
73+
}
74+
}
75+
76+
export function clearSessionPrefetchDirectory(directory: string) {
77+
const prefix = `${directory}\n`
78+
const keys = new Set([...cache.keys(), ...inflight.keys()])
79+
for (const id of keys) {
80+
if (!id.startsWith(prefix)) continue
81+
rev.set(id, version(id) + 1)
82+
cache.delete(id)
83+
inflight.delete(id)
84+
}
85+
}

packages/app/src/context/sync.tsx

Lines changed: 78 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ import { createStore, produce, reconcile } from "solid-js/store"
33
import { Binary } from "@opencode-ai/util/binary"
44
import { retry } from "@opencode-ai/util/retry"
55
import { createSimpleContext } from "@opencode-ai/ui/context"
6+
import {
7+
clearSessionPrefetch,
8+
getSessionPrefetch,
9+
getSessionPrefetchPromise,
10+
setSessionPrefetch,
11+
} from "./global-sync/session-prefetch"
612
import { useGlobalSync } from "./global-sync"
713
import { useSDK } from "./sdk"
814
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
@@ -160,6 +166,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
160166

161167
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
162168
if (sessionIDs.length === 0) return
169+
clearSessionPrefetch(directory, sessionIDs)
163170
for (const sessionID of sessionIDs) {
164171
globalSync.todo.set(sessionID, undefined)
165172
}
@@ -217,6 +224,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
217224
}
218225
setMeta("limit", key, input.limit)
219226
setMeta("complete", key, next.complete)
227+
setSessionPrefetch({
228+
directory: input.directory,
229+
sessionID: input.sessionID,
230+
limit: input.limit,
231+
complete: next.complete,
232+
})
220233
})
221234
})
222235
.finally(() => {
@@ -280,54 +293,82 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
280293
parts: input.parts,
281294
})
282295
},
283-
async sync(sessionID: string) {
296+
async sync(sessionID: string, opts?: { force?: boolean }) {
284297
const directory = sdk.directory
285298
const client = sdk.client
286299
const [store, setStore] = globalSync.child(directory)
287300
const key = keyFor(directory, sessionID)
288-
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
289301

290302
touch(directory, setStore, sessionID)
291303

292-
if (store.message[sessionID] !== undefined && hasSession && meta.limit[key] !== undefined) return
293-
294-
const limit = meta.limit[key] ?? messagePageSize
295-
296-
const sessionReq = hasSession
297-
? Promise.resolve()
298-
: retry(() => client.session.get({ sessionID })).then((session) => {
299-
if (!tracked(directory, sessionID)) return
300-
const data = session.data
301-
if (!data) return
302-
setStore(
303-
"session",
304-
produce((draft) => {
305-
const match = Binary.search(draft, sessionID, (s) => s.id)
306-
if (match.found) {
307-
draft[match.index] = data
308-
return
309-
}
310-
draft.splice(match.index, 0, data)
311-
}),
312-
)
313-
})
314-
315-
const messagesReq = loadMessages({
316-
directory,
317-
client,
318-
setStore,
319-
sessionID,
320-
limit,
321-
})
304+
const seeded = getSessionPrefetch(directory, sessionID)
305+
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
306+
batch(() => {
307+
setMeta("limit", key, seeded.limit)
308+
setMeta("complete", key, seeded.complete)
309+
setMeta("loading", key, false)
310+
})
311+
}
322312

323-
return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
313+
return runInflight(inflight, key, async () => {
314+
const pending = getSessionPrefetchPromise(directory, sessionID)
315+
if (pending) {
316+
await pending
317+
const seeded = getSessionPrefetch(directory, sessionID)
318+
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
319+
batch(() => {
320+
setMeta("limit", key, seeded.limit)
321+
setMeta("complete", key, seeded.complete)
322+
setMeta("loading", key, false)
323+
})
324+
}
325+
}
326+
327+
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
328+
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
329+
if (cached && hasSession && !opts?.force) return
330+
331+
const limit = meta.limit[key] ?? messagePageSize
332+
const sessionReq =
333+
hasSession && !opts?.force
334+
? Promise.resolve()
335+
: retry(() => client.session.get({ sessionID })).then((session) => {
336+
if (!tracked(directory, sessionID)) return
337+
const data = session.data
338+
if (!data) return
339+
setStore(
340+
"session",
341+
produce((draft) => {
342+
const match = Binary.search(draft, sessionID, (s) => s.id)
343+
if (match.found) {
344+
draft[match.index] = data
345+
return
346+
}
347+
draft.splice(match.index, 0, data)
348+
}),
349+
)
350+
})
351+
352+
const messagesReq =
353+
cached && !opts?.force
354+
? Promise.resolve()
355+
: loadMessages({
356+
directory,
357+
client,
358+
setStore,
359+
sessionID,
360+
limit,
361+
})
362+
363+
await Promise.all([sessionReq, messagesReq])
364+
})
324365
},
325-
async diff(sessionID: string) {
366+
async diff(sessionID: string, opts?: { force?: boolean }) {
326367
const directory = sdk.directory
327368
const client = sdk.client
328369
const [store, setStore] = globalSync.child(directory)
329370
touch(directory, setStore, sessionID)
330-
if (store.session_diff[sessionID] !== undefined) return
371+
if (store.session_diff[sessionID] !== undefined && !opts?.force) return
331372

332373
const key = keyFor(directory, sessionID)
333374
return runInflight(inflightDiff, key, () =>
@@ -337,7 +378,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
337378
}),
338379
)
339380
},
340-
async todo(sessionID: string) {
381+
async todo(sessionID: string, opts?: { force?: boolean }) {
341382
const directory = sdk.directory
342383
const client = sdk.client
343384
const [store, setStore] = globalSync.child(directory)
@@ -348,7 +389,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
348389
if (cached === undefined) {
349390
globalSync.todo.set(sessionID, existing)
350391
}
351-
return
392+
if (!opts?.force) return
352393
}
353394

354395
if (cached !== undefined) {

0 commit comments

Comments
 (0)