fix(core): reduce memory peaks in long sessions
This commit is contained in:
parent
e707e416ed
commit
fe28f3b1dd
21 changed files with 2332 additions and 105 deletions
|
|
@ -0,0 +1,8 @@
|
|||
ALTER TABLE `part` ADD `data_model` text;
|
||||
--> statement-breakpoint
|
||||
UPDATE part
|
||||
SET data_model = json_remove(data, '$.state.metadata')
|
||||
WHERE length(CAST(data AS BLOB)) > 65536
|
||||
AND json_extract(data, '$.type') = 'tool'
|
||||
AND json_extract(data, '$.state.status') = 'completed'
|
||||
AND length(CAST(json_extract(data, '$.state.metadata') AS BLOB)) > 65536;
|
||||
1755
packages/core/migration/20260603120017_warm_guardsmen/snapshot.json
generated
Normal file
1755
packages/core/migration/20260603120017_warm_guardsmen/snapshot.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -8,12 +8,15 @@ import { Flag } from "../flag/flag"
|
|||
import { isAbsolute, join } from "path"
|
||||
import { DatabaseMigration } from "./migration"
|
||||
import { InstallationChannel } from "../installation/version"
|
||||
import { Sqlite } from "./sqlite"
|
||||
|
||||
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
|
||||
type DatabaseShape = Effect.Success<typeof makeDatabase>
|
||||
|
||||
export interface Interface {
|
||||
db: DatabaseShape
|
||||
// Lazy property getters cannot yield an Effect.
|
||||
sync: Sqlite.DrizzleClient
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/storage/Database") {}
|
||||
|
|
@ -22,6 +25,7 @@ export const layer = Layer.effect(
|
|||
Service,
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDatabase
|
||||
const sync = yield* Sqlite.Drizzle
|
||||
|
||||
yield* db.run("PRAGMA journal_mode = WAL")
|
||||
yield* db.run("PRAGMA synchronous = NORMAL")
|
||||
|
|
@ -31,7 +35,7 @@ export const layer = Layer.effect(
|
|||
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
yield* DatabaseMigration.apply(db)
|
||||
|
||||
return { db }
|
||||
return { db, sync }
|
||||
}).pipe(Effect.orDie),
|
||||
)
|
||||
|
||||
|
|
|
|||
1
packages/core/src/database/migration.gen.ts
generated
1
packages/core/src/database/migration.gen.ts
generated
|
|
@ -27,5 +27,6 @@ export const migrations = (
|
|||
import("./migration/20260601202201_amazing_prowler"),
|
||||
import("./migration/20260602002951_lowly_union_jack"),
|
||||
import("./migration/20260602182828_add_project_directories"),
|
||||
import("./migration/20260603120017_warm_guardsmen"),
|
||||
])
|
||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260603120017_warm_guardsmen",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`part\` ADD \`data_model\` text;`)
|
||||
// Keep canonical history intact while avoiding prompt-time decoding. This
|
||||
// one-time transactional backfill may briefly grow the WAL on large stores.
|
||||
yield* tx.run(`
|
||||
UPDATE part
|
||||
SET data_model = json_remove(data, '$.state.metadata')
|
||||
WHERE length(CAST(data AS BLOB)) > 65536
|
||||
AND json_extract(data, '$.type') = 'tool'
|
||||
AND json_extract(data, '$.state.status') = 'completed'
|
||||
AND length(CAST(json_extract(data, '$.state.metadata') AS BLOB)) > 65536
|
||||
`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
22
packages/core/src/session/model-data.ts
Normal file
22
packages/core/src/session/model-data.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
export * as SessionPartModelData from "./model-data"
|
||||
|
||||
import type { SessionV1 } from "../v1/session"
|
||||
|
||||
type V1PartData<Data extends SessionV1.Part = SessionV1.Part> = Data extends SessionV1.Part
|
||||
? Omit<Data, "id" | "sessionID" | "messageID">
|
||||
: never
|
||||
|
||||
export const THRESHOLD = 64 * 1024
|
||||
|
||||
// Strip UI-only metadata only when the stored prompt projection benefits.
|
||||
export function create(data: unknown): V1PartData | null {
|
||||
if (!data || typeof data !== "object") return null
|
||||
if (!("type" in data) || data.type !== "tool") return null
|
||||
if (!("state" in data) || !data.state || typeof data.state !== "object") return null
|
||||
if (!("status" in data.state) || data.state.status !== "completed") return null
|
||||
if (!("metadata" in data.state)) return null
|
||||
const metadata = JSON.stringify(data.state.metadata)
|
||||
if (!metadata || Buffer.byteLength(metadata) <= THRESHOLD) return null
|
||||
const { metadata: _, ...state } = data.state
|
||||
return { ...data, state } as V1PartData
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import { SessionMessage } from "./message"
|
|||
import { SessionMessageUpdater } from "./message-updater"
|
||||
import { MessageTable, PartTable, SessionMessageTable, SessionTable } from "./sql"
|
||||
import type { DeepMutable } from "../schema"
|
||||
import { SessionPartModelData } from "./model-data"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
|
|
@ -309,7 +310,7 @@ export const layer = Layer.effectDiscard(
|
|||
yield* events.project(SessionV1.Event.MessageRemoved, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.select({ session_id: PartTable.session_id, data: PartTable.data })
|
||||
.from(PartTable)
|
||||
.where(and(eq(PartTable.message_id, event.data.messageID), eq(PartTable.session_id, event.data.sessionID)))
|
||||
.all()
|
||||
|
|
@ -328,7 +329,7 @@ export const layer = Layer.effectDiscard(
|
|||
yield* events.project(SessionV1.Event.PartRemoved, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* db
|
||||
.select()
|
||||
.select({ session_id: PartTable.session_id, data: PartTable.data })
|
||||
.from(PartTable)
|
||||
.where(and(eq(PartTable.id, event.data.partID), eq(PartTable.session_id, event.data.sessionID)))
|
||||
.get()
|
||||
|
|
@ -348,11 +349,17 @@ export const layer = Layer.effectDiscard(
|
|||
const messageID = event.data.part.messageID
|
||||
const sessionID = event.data.part.sessionID
|
||||
const data = partData(event.data.part)
|
||||
const row = yield* db.select().from(PartTable).where(eq(PartTable.id, id)).get().pipe(Effect.orDie)
|
||||
const data_model = SessionPartModelData.create(data)
|
||||
const row = yield* db
|
||||
.select({ session_id: PartTable.session_id, data: PartTable.data })
|
||||
.from(PartTable)
|
||||
.where(eq(PartTable.id, id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(PartTable)
|
||||
.values({ id, message_id: messageID, session_id: sessionID, time_created: event.data.time, data })
|
||||
.onConflictDoUpdate({ target: PartTable.id, set: { data } })
|
||||
.values({ id, message_id: messageID, session_id: sessionID, time_created: event.data.time, data, data_model })
|
||||
.onConflictDoUpdate({ target: PartTable.id, set: { data, data_model } })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const previous = row && usage(row.data)
|
||||
|
|
|
|||
|
|
@ -85,6 +85,8 @@ export const PartTable = sqliteTable(
|
|||
session_id: text().$type<SessionSchema.ID>().notNull(),
|
||||
...Timestamps,
|
||||
data: text({ mode: "json" }).notNull().$type<V1PartData>(),
|
||||
// Derived prompt projection; data remains canonical.
|
||||
data_model: text({ mode: "json" }).$type<V1PartData>(),
|
||||
},
|
||||
(table) => [
|
||||
index("part_message_id_id_idx").on(table.message_id, table.id),
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
|||
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata"
|
||||
import partModelDataMigration from "@opencode-ai/core/database/migration/20260603120017_warm_guardsmen"
|
||||
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
|
||||
|
||||
const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
|
||||
|
|
@ -43,7 +44,7 @@ describe("DatabaseMigration", () => {
|
|||
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({
|
||||
name: "session",
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 25 })
|
||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 26 })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -77,6 +78,30 @@ describe("DatabaseMigration", () => {
|
|||
)
|
||||
})
|
||||
|
||||
test("backfills lightweight model data for oversized completed tool metadata", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE part (id text PRIMARY KEY, data text NOT NULL)`)
|
||||
const large = JSON.stringify({ type: "tool", state: { status: "completed", metadata: { diff: "x".repeat(70_000) } } })
|
||||
const unicode = JSON.stringify({ type: "tool", state: { status: "completed", metadata: { diff: "😀".repeat(20_000) } } })
|
||||
const small = JSON.stringify({ type: "tool", state: { status: "completed", metadata: { diff: "small" } } })
|
||||
yield* db.run(sql`INSERT INTO part (id, data) VALUES (${"large"}, ${large}), (${"unicode"}, ${unicode}), (${"small"}, ${small})`)
|
||||
|
||||
yield* DatabaseMigration.applyOnly(db, [partModelDataMigration])
|
||||
|
||||
expect(yield* db.get(sql`SELECT data, data_model FROM part WHERE id = ${"large"}`)).toEqual({
|
||||
data: large,
|
||||
data_model: JSON.stringify({ type: "tool", state: { status: "completed" } }),
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT data_model FROM part WHERE id = ${"small"}`)).toEqual({ data_model: null })
|
||||
expect(yield* db.get(sql`SELECT data_model FROM part WHERE id = ${"unicode"}`)).toEqual({
|
||||
data_model: JSON.stringify({ type: "tool", state: { status: "completed" } }),
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("normalizes Windows storage paths and leaves POSIX paths untouched", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue