Repository navigation
Description
Summary
Deleting a session removes its V2 rows (session_v2 / session_message) but
leaves the pre-V2 legacy rows (session / message / part / todo)
orphaned in the database. The orphaned rows are invisible in the UI yet keep
occupying space permanently, and VACUUM cannot reclaim what they still hold.
Environment
- opencode version: 2.0.11
- OS: Linux (WSL2), x86_64
- Terminal: xterm-256color
- Shell: /bin/bash
- Install/channel: standalone binary (
~/.opencode/bin/opencode), latest channel - Active plugins: two local file plugins (
opencode-vibeguard,
env-protection) — not implicated in this report;
MCP servers: playwright, context7
Reproduction
- Have a data dir where the same sessions exist in both stores — i.e. an
install that was upgraded from V1 to V2, sosession/message/part
(legacy) andsession_v2/session_message(V2) both hold copies of the
same session. - Delete such a session:
opencode session delete <id>(same behaviour via
DELETE /api/session/{sessionID}and the TUI). - Inspect
~/.local/share/opencode/opencode.db:SELECT COUNT(*) FROM session_v2 WHERE id = '<id>'→0(the V2 row and
itssession_messagechildren are gone)SELECT COUNT(*) FROM session WHERE id = '<id>'→1SELECT COUNT(*) FROM part WHERE session_id = '<id>'→ all of the
session's parts are still there
Expected Behavior
Deleting a session should remove that session's data from every store it exists
in (or the V1→V2 migration should stop keeping a second copy), so no
unreachable rows are left behind.
Actual Behavior
Only session_v2 + session_message are deleted. The legacy session row and
its message / part / todo children remain, with the
ON DELETE CASCADE chain intact because the legacy parent row is never deleted
— PRAGMA foreign_key_check stays clean, which is why this stays silent.
Measured on a developer data dir (2.0.11, WSL2):
- Before:
session_v2= 146, legacysession= 143,message= 21,477,
part= 87,891,todo= 465. - After deleting 145 sessions through the API:
session_v2= 1, but legacy
session= 143,message= 21,477,part= 87,891,todo= 465 remained. SELECT COUNT(*) FROM session s WHERE NOT EXISTS (SELECT 1 FROM session_v2 v WHERE v.id = s.id)= 143 orphans.- The DB file was 5.0 GB, of which 4.55 GB was freelist from earlier deletes;
the orphaned rows alone kept roughly 300 MB of text (message.data+
part.data) alive. AfterDELETE FROM session WHERE id NOT IN (SELECT id FROM session_v2)(cascading intomessage/part/todo) and a
VACUUM, the file shrank to ~6 MB.
Additional Context
- Deleting a parent session does cascade its sub-sessions on the V2 side
(deleting a child afterwards returnsSessionNotFoundError), so only the
legacy store is affected. - Sessions created after the V2 storage took over exist only in
session_v2/
session_message; the bug only affects sessions that predate the storage
switch. - Workaround (opencode must be stopped):
DELETE FROM session WHERE id NOT IN (SELECT id FROM session_v2);followed by
VACUUM; - Related: Mixed stable/beta channels sharing one data dir silently split sessions across
sessionandsession_v2#44028 (mixed channels split sessions acrosssessionand
session_v2).
Activity
holny commentedon Sep 21, 2026
Going to take a swing at this one. Root cause looks straightforward:
Session.removepublishesSessionEvent.Deleted(packages/core/src/session.ts:351-361).SessionTable(=session_v2). For databases that came in via the V1→V2 path, packages/core/src/database/v1-migration.bun.ts copies from legacysession/message/part/todointo the V2 tables but never drops the legacy tables, so each delete leaves orphan rows behind.Fix direction: extend the
SessionEvent.Deletedprojection (or add a guarded cleanup that only runs when the legacy tables exist) so the samesession_idis also removed from the legacy tables. Cascade still works once the parentsessionrow goes (PRAGMA foreign_key_check should stay clean as the report shows). Add a regression test that simulates a V1→V2 migrated DB and asserts the orphan count drops to zero afterSession.remove.I'll open a PR off this branch in a day or two.
holny commentedon Sep 28, 2026
Gentle nudge — this has been open for a week with green checks and no review. Three teams have confirmed the fix (including the Obsidian Copilot maintainers who are blocked on it), and the change is scoped to the deleted-session projector: legacy rows are only touched when the legacy
sessiontable actually exists, so fresh V2-only databases are unaffected. Happy to rebase or split if anything needs adjusting.GladSI commentedon Oct 25, 2026
The legacy block exists without any session delete — and it is most of what a compacted database still holds
The projector fix you describe is right; one addition, because there is a second half to the leak that does not involve
SessionEvent.Deletedat all.Measured on 2.1.4, data dir upgraded in place (so both stores exist), nothing ever deleted:
session185 rows,message29 772,part124 309,todo170 — all frozen at 2026-10-17 06:02, i.e. nothing has written them since the V1→V2 conversion, and nothing in V2 reads them outside the opt-inV1Migrationservice (/api/migration, a noop on node).message434 MiB +part181 MiB, plus their indexes). These rows were never orphaned by a delete; they simply have no V2 counterpart, so the projector never sees them.VACUUMreclaims deleted pages only, so those 615 MiB are unrecoverable while the rows stay: this database was 17 203 MiB with a 16 414 MiB freelist, and afterVACUUMit is 780 MiB — the legacy generation is by far the largest thing left in the compacted file.So a fix that removes legacy rows on session delete leaves the most common upgrade path (a user who never deleted a session) untouched. Dropping the legacy tables after a successful migration — or exposing it through the
opencode dbcleanup proposed in #54618 — closes that half.opencodejr commentedon Oct 28, 2026
Thanks for the detailed report. I can confirm this on 2.0.11, and it still happens on the latest
dev(fa1260d).What I did, in an isolated data dir:
opencode-ai@1.19.5,opencode import).opencode session list, which imported it intosession_v2/session_message. The legacy rows stayed.opencode session delete <id>, which reported success.After that,
session_v2andsession_messagehad no rows for the session, but the legacysession,messageandpartrows were all still inopencode.db, matching what you saw. The script below reproduces it.The cause seems to be that the delete step only removes the V2 session row, while the V1 import intentionally leaves the original rows in place. Two earlier fix attempts for this issue were closed without merging (#50270, #50545). Until there's a fix, your SQL cleanup followed by
VACUUM(with opencode and its background service stopped) is the workaround. Related: #44028 (sessions split acrosssessionandsession_v2) and #47591 (deleted space not reclaimed).Reproduction script (repro.sh)
Run it:
bash repro.sh