fix(core): reduce memory peaks in long sessions

This commit is contained in:
starptech 2026-06-03 16:26:06 +02:00
commit fe28f3b1dd
21 changed files with 2332 additions and 105 deletions

View file

@ -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;

File diff suppressed because it is too large Load diff

View file

@ -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),
)

View file

@ -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[]

View file

@ -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

View 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
}

View file

@ -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)

View file

@ -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),

View file

@ -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* () {