mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-15 05:04:50 +00:00
perf(app): use cursor session history loading (#17329)
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
|||||||
getSessionPrefetch,
|
getSessionPrefetch,
|
||||||
runSessionPrefetch,
|
runSessionPrefetch,
|
||||||
setSessionPrefetch,
|
setSessionPrefetch,
|
||||||
|
shouldSkipSessionPrefetch,
|
||||||
} from "./session-prefetch"
|
} from "./session-prefetch"
|
||||||
|
|
||||||
describe("session prefetch", () => {
|
describe("session prefetch", () => {
|
||||||
@@ -16,11 +17,12 @@ describe("session prefetch", () => {
|
|||||||
directory: "/tmp/a",
|
directory: "/tmp/a",
|
||||||
sessionID: "ses_1",
|
sessionID: "ses_1",
|
||||||
limit: 200,
|
limit: 200,
|
||||||
|
cursor: "abc",
|
||||||
complete: false,
|
complete: false,
|
||||||
at: 123,
|
at: 123,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(getSessionPrefetch("/tmp/a", "ses_1")).toEqual({ limit: 200, complete: false, at: 123 })
|
expect(getSessionPrefetch("/tmp/a", "ses_1")).toEqual({ limit: 200, cursor: "abc", complete: false, at: 123 })
|
||||||
expect(getSessionPrefetch("/tmp/b", "ses_1")).toBeUndefined()
|
expect(getSessionPrefetch("/tmp/b", "ses_1")).toBeUndefined()
|
||||||
|
|
||||||
clearSessionPrefetch("/tmp/a", ["ses_1"])
|
clearSessionPrefetch("/tmp/a", ["ses_1"])
|
||||||
@@ -38,26 +40,57 @@ describe("session prefetch", () => {
|
|||||||
sessionID: "ses_2",
|
sessionID: "ses_2",
|
||||||
task: async () => {
|
task: async () => {
|
||||||
calls += 1
|
calls += 1
|
||||||
return { limit: 100, complete: true, at: 456 }
|
return { limit: 100, cursor: "next", complete: true, at: 456 }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const [a, b] = await Promise.all([run(), run()])
|
const [a, b] = await Promise.all([run(), run()])
|
||||||
|
|
||||||
expect(calls).toBe(1)
|
expect(calls).toBe(1)
|
||||||
expect(a).toEqual({ limit: 100, complete: true, at: 456 })
|
expect(a).toEqual({ limit: 100, cursor: "next", complete: true, at: 456 })
|
||||||
expect(b).toEqual({ limit: 100, complete: true, at: 456 })
|
expect(b).toEqual({ limit: 100, cursor: "next", complete: true, at: 456 })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("clears a whole directory", () => {
|
test("clears a whole directory", () => {
|
||||||
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_1", limit: 10, complete: true, at: 1 })
|
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_1", limit: 10, cursor: "a", complete: true, at: 1 })
|
||||||
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_2", limit: 20, complete: false, at: 2 })
|
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_2", limit: 20, cursor: "b", complete: false, at: 2 })
|
||||||
setSessionPrefetch({ directory: "/tmp/e", sessionID: "ses_1", limit: 30, complete: true, at: 3 })
|
setSessionPrefetch({ directory: "/tmp/e", sessionID: "ses_1", limit: 30, cursor: "c", complete: true, at: 3 })
|
||||||
|
|
||||||
clearSessionPrefetchDirectory("/tmp/d")
|
clearSessionPrefetchDirectory("/tmp/d")
|
||||||
|
|
||||||
expect(getSessionPrefetch("/tmp/d", "ses_1")).toBeUndefined()
|
expect(getSessionPrefetch("/tmp/d", "ses_1")).toBeUndefined()
|
||||||
expect(getSessionPrefetch("/tmp/d", "ses_2")).toBeUndefined()
|
expect(getSessionPrefetch("/tmp/d", "ses_2")).toBeUndefined()
|
||||||
expect(getSessionPrefetch("/tmp/e", "ses_1")).toEqual({ limit: 30, complete: true, at: 3 })
|
expect(getSessionPrefetch("/tmp/e", "ses_1")).toEqual({ limit: 30, cursor: "c", complete: true, at: 3 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("refreshes stale first-page prefetched history", () => {
|
||||||
|
expect(
|
||||||
|
shouldSkipSessionPrefetch({
|
||||||
|
message: true,
|
||||||
|
info: { limit: 200, cursor: "x", complete: false, at: 1 },
|
||||||
|
chunk: 200,
|
||||||
|
now: 1 + 15_001,
|
||||||
|
}),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps deeper or complete history cached", () => {
|
||||||
|
expect(
|
||||||
|
shouldSkipSessionPrefetch({
|
||||||
|
message: true,
|
||||||
|
info: { limit: 400, cursor: "x", complete: false, at: 1 },
|
||||||
|
chunk: 200,
|
||||||
|
now: 1 + 15_001,
|
||||||
|
}),
|
||||||
|
).toBe(true)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shouldSkipSessionPrefetch({
|
||||||
|
message: true,
|
||||||
|
info: { limit: 120, complete: true, at: 1 },
|
||||||
|
chunk: 200,
|
||||||
|
now: 1 + 15_001,
|
||||||
|
}),
|
||||||
|
).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,10 +4,23 @@ export const SESSION_PREFETCH_TTL = 15_000
|
|||||||
|
|
||||||
type Meta = {
|
type Meta = {
|
||||||
limit: number
|
limit: number
|
||||||
|
cursor?: string
|
||||||
complete: boolean
|
complete: boolean
|
||||||
at: number
|
at: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shouldSkipSessionPrefetch(input: { message: boolean; info?: Meta; chunk: number; now?: number }) {
|
||||||
|
if (input.message) {
|
||||||
|
if (!input.info) return true
|
||||||
|
if (input.info.complete) return true
|
||||||
|
if (input.info.limit > input.chunk) return true
|
||||||
|
} else {
|
||||||
|
if (!input.info) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return (input.now ?? Date.now()) - input.info.at < SESSION_PREFETCH_TTL
|
||||||
|
}
|
||||||
|
|
||||||
const cache = new Map<string, Meta>()
|
const cache = new Map<string, Meta>()
|
||||||
const inflight = new Map<string, Promise<Meta | undefined>>()
|
const inflight = new Map<string, Promise<Meta | undefined>>()
|
||||||
const rev = new Map<string, number>()
|
const rev = new Map<string, number>()
|
||||||
@@ -53,11 +66,13 @@ export function setSessionPrefetch(input: {
|
|||||||
directory: string
|
directory: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
limit: number
|
limit: number
|
||||||
|
cursor?: string
|
||||||
complete: boolean
|
complete: boolean
|
||||||
at?: number
|
at?: number
|
||||||
}) {
|
}) {
|
||||||
cache.set(key(input.directory, input.sessionID), {
|
cache.set(key(input.directory, input.sessionID), {
|
||||||
limit: input.limit,
|
limit: input.limit,
|
||||||
|
cursor: input.cursor,
|
||||||
complete: input.complete,
|
complete: input.complete,
|
||||||
at: input.at ?? Date.now(),
|
at: input.at ?? Date.now(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ const keyFor = (directory: string, id: string) => `${directory}\n${id}`
|
|||||||
|
|
||||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||||
|
|
||||||
|
function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
|
||||||
|
const map = new Map(a.map((item) => [item.id, item] as const))
|
||||||
|
for (const item of b) map.set(item.id, item)
|
||||||
|
return [...map.values()].sort((x, y) => cmp(x.id, y.id))
|
||||||
|
}
|
||||||
|
|
||||||
type OptimisticStore = {
|
type OptimisticStore = {
|
||||||
message: Record<string, Message[] | undefined>
|
message: Record<string, Message[] | undefined>
|
||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
@@ -119,6 +125,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const seen = new Map<string, Set<string>>()
|
const seen = new Map<string, Set<string>>()
|
||||||
const [meta, setMeta] = createStore({
|
const [meta, setMeta] = createStore({
|
||||||
limit: {} as Record<string, number>,
|
limit: {} as Record<string, number>,
|
||||||
|
cursor: {} as Record<string, string | undefined>,
|
||||||
complete: {} as Record<string, boolean>,
|
complete: {} as Record<string, boolean>,
|
||||||
loading: {} as Record<string, boolean>,
|
loading: {} as Record<string, boolean>,
|
||||||
})
|
})
|
||||||
@@ -157,6 +164,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
for (const sessionID of sessionIDs) {
|
for (const sessionID of sessionIDs) {
|
||||||
const key = keyFor(directory, sessionID)
|
const key = keyFor(directory, sessionID)
|
||||||
delete draft.limit[key]
|
delete draft.limit[key]
|
||||||
|
delete draft.cursor[key]
|
||||||
delete draft.complete[key]
|
delete draft.complete[key]
|
||||||
delete draft.loading[key]
|
delete draft.loading[key]
|
||||||
}
|
}
|
||||||
@@ -187,17 +195,24 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
evict(directory, setStore, stale)
|
evict(directory, setStore, stale)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
|
const fetchMessages = async (input: {
|
||||||
|
client: typeof sdk.client
|
||||||
|
sessionID: string
|
||||||
|
limit: number
|
||||||
|
before?: string
|
||||||
|
}) => {
|
||||||
const messages = await retry(() =>
|
const messages = await retry(() =>
|
||||||
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
|
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
|
||||||
)
|
)
|
||||||
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||||
const session = items.map((x) => x.info).sort((a, b) => cmp(a.id, b.id))
|
const session = items.map((x) => x.info).sort((a, b) => cmp(a.id, b.id))
|
||||||
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
|
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
|
||||||
|
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
|
||||||
return {
|
return {
|
||||||
session,
|
session,
|
||||||
part,
|
part,
|
||||||
complete: session.length < input.limit,
|
cursor,
|
||||||
|
complete: !cursor,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +224,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
setStore: Setter
|
setStore: Setter
|
||||||
sessionID: string
|
sessionID: string
|
||||||
limit: number
|
limit: number
|
||||||
|
before?: string
|
||||||
|
mode?: "replace" | "prepend"
|
||||||
}) => {
|
}) => {
|
||||||
const key = keyFor(input.directory, input.sessionID)
|
const key = keyFor(input.directory, input.sessionID)
|
||||||
if (meta.loading[key]) return
|
if (meta.loading[key]) return
|
||||||
@@ -217,17 +234,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
await fetchMessages(input)
|
await fetchMessages(input)
|
||||||
.then((next) => {
|
.then((next) => {
|
||||||
if (!tracked(input.directory, input.sessionID)) return
|
if (!tracked(input.directory, input.sessionID)) return
|
||||||
|
const [store] = globalSync.child(input.directory, { bootstrap: false })
|
||||||
|
const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : []
|
||||||
|
const message = input.mode === "prepend" ? merge(cached, next.session) : next.session
|
||||||
batch(() => {
|
batch(() => {
|
||||||
input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
|
input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
|
||||||
for (const p of next.part) {
|
for (const p of next.part) {
|
||||||
input.setStore("part", p.id, p.part)
|
input.setStore("part", p.id, p.part)
|
||||||
}
|
}
|
||||||
setMeta("limit", key, input.limit)
|
setMeta("limit", key, message.length)
|
||||||
|
setMeta("cursor", key, next.cursor)
|
||||||
setMeta("complete", key, next.complete)
|
setMeta("complete", key, next.complete)
|
||||||
setSessionPrefetch({
|
setSessionPrefetch({
|
||||||
directory: input.directory,
|
directory: input.directory,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
limit: input.limit,
|
limit: message.length,
|
||||||
|
cursor: next.cursor,
|
||||||
complete: next.complete,
|
complete: next.complete,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -312,6 +334,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
|
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setMeta("limit", key, seeded.limit)
|
setMeta("limit", key, seeded.limit)
|
||||||
|
setMeta("cursor", key, seeded.cursor)
|
||||||
setMeta("complete", key, seeded.complete)
|
setMeta("complete", key, seeded.complete)
|
||||||
setMeta("loading", key, false)
|
setMeta("loading", key, false)
|
||||||
})
|
})
|
||||||
@@ -325,6 +348,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
|
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setMeta("limit", key, seeded.limit)
|
setMeta("limit", key, seeded.limit)
|
||||||
|
setMeta("cursor", key, seeded.cursor)
|
||||||
setMeta("complete", key, seeded.complete)
|
setMeta("complete", key, seeded.complete)
|
||||||
setMeta("loading", key, false)
|
setMeta("loading", key, false)
|
||||||
})
|
})
|
||||||
@@ -420,7 +444,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
if (store.message[sessionID] === undefined) return false
|
if (store.message[sessionID] === undefined) return false
|
||||||
if (meta.limit[key] === undefined) return false
|
if (meta.limit[key] === undefined) return false
|
||||||
if (meta.complete[key]) return false
|
if (meta.complete[key]) return false
|
||||||
return true
|
return !!meta.cursor[key]
|
||||||
},
|
},
|
||||||
loading(sessionID: string) {
|
loading(sessionID: string) {
|
||||||
const key = keyFor(sdk.directory, sessionID)
|
const key = keyFor(sdk.directory, sessionID)
|
||||||
@@ -435,14 +459,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const step = count ?? messagePageSize
|
const step = count ?? messagePageSize
|
||||||
if (meta.loading[key]) return
|
if (meta.loading[key]) return
|
||||||
if (meta.complete[key]) return
|
if (meta.complete[key]) return
|
||||||
|
const before = meta.cursor[key]
|
||||||
|
if (!before) return
|
||||||
|
|
||||||
const currentLimit = meta.limit[key] ?? messagePageSize
|
|
||||||
await loadMessages({
|
await loadMessages({
|
||||||
directory,
|
directory,
|
||||||
client,
|
client,
|
||||||
setStore,
|
setStore,
|
||||||
sessionID,
|
sessionID,
|
||||||
limit: currentLimit + step,
|
limit: step,
|
||||||
|
before,
|
||||||
|
mode: "prepend",
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ import {
|
|||||||
getSessionPrefetch,
|
getSessionPrefetch,
|
||||||
isSessionPrefetchCurrent,
|
isSessionPrefetchCurrent,
|
||||||
runSessionPrefetch,
|
runSessionPrefetch,
|
||||||
SESSION_PREFETCH_TTL,
|
|
||||||
setSessionPrefetch,
|
setSessionPrefetch,
|
||||||
|
shouldSkipSessionPrefetch,
|
||||||
} from "@/context/global-sync/session-prefetch"
|
} from "@/context/global-sync/session-prefetch"
|
||||||
import { useNotification } from "@/context/notification"
|
import { useNotification } from "@/context/notification"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
@@ -770,9 +770,11 @@ export default function Layout(props: ParentProps) {
|
|||||||
const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
|
const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
|
||||||
const sorted = mergeByID([], next)
|
const sorted = mergeByID([], next)
|
||||||
const stale = markPrefetched(directory, sessionID)
|
const stale = markPrefetched(directory, sessionID)
|
||||||
|
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
|
||||||
const meta = {
|
const meta = {
|
||||||
limit: prefetchChunk,
|
limit: sorted.length,
|
||||||
complete: sorted.length < prefetchChunk,
|
cursor,
|
||||||
|
complete: !cursor,
|
||||||
at: Date.now(),
|
at: Date.now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -846,10 +848,12 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
const [store] = globalSync.child(directory, { bootstrap: false })
|
const [store] = globalSync.child(directory, { bootstrap: false })
|
||||||
const cached = untrack(() => {
|
const cached = untrack(() => {
|
||||||
if (store.message[session.id] === undefined) return false
|
|
||||||
const info = getSessionPrefetch(directory, session.id)
|
const info = getSessionPrefetch(directory, session.id)
|
||||||
if (!info) return false
|
return shouldSkipSessionPrefetch({
|
||||||
return Date.now() - info.at < SESSION_PREFETCH_TTL
|
message: store.message[session.id] !== undefined,
|
||||||
|
info,
|
||||||
|
chunk: prefetchChunk,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
if (cached) return
|
if (cached) return
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user