Skip to content

Stop the tabular workspace connection pool from leaking read-only state - #186

Merged
MikeAlhayek merged 3 commits into
mainfrom
claude/sqlite-readonly-error-3d68da
Sep 14, 2026
Merged

MikeAlhayek merged 3 commits into
mainfrom
claude/sqlite-readonly-error-3d68da

Conversation

@MikeAlhayek

Copy link
Copy Markdown
Member

The bug

Removing a spreadsheet from a chat produced this in the production logs:

CrestApps.Core.AI.Documents.Handlers.TabularWorkspaceDocumentEventHandler|DEBUG|
Failed to drop tables for document '...' from workspace database.
Microsoft.Data.Sqlite.SqliteException (0x80004005): SQLite Error 8: 'attempt to write a readonly database'.

TabularWorkspace deliberately keeps its connection in PRAGMA query_only = ON and only opens narrow write windows. query_only is connection state, and Microsoft.Data.Sqlite pools connections by connection string without resetting it — so the workspace handed a poisoned connection back to the pool on dispose.

TabularWorkspaceDocumentEventHandler then opened the identical Data Source={path} string, received that connection, and its DROP TABLE failed — while the metadata SELECT immediately before it succeeded, which is exactly the shape of the logged stack trace.

A second, silent defect in the same method: a pooled connection keeps the native SQLite file handle open after Close(), so the follow-up delete of an emptied tabular.db threw IOException into a debug-only log and orphan database files accumulated. TabularWorkspaceHistoryClearedHandler had the same problem.

Both were confirmed empirically before anything was changed — query_only reads back as 1 on a "fresh" connection, and File.Delete throws "used by another process".

The fix

New TabularWorkspaceDatabase owns how the workspace database is located and opened:

  • Pooling=false for file-backed workspaces. The workspace holds a single long-lived connection per scope, so pooling bought nothing and cost correctness.
  • Open() sets query_only = OFF explicitly, so no caller ever inherits a write state.
  • One path builder. The documents/{referenceType}/{referenceId}/data/tabular.db layout was duplicated across four call sites; a drift there would silently orphan databases.

A failed drop leaves a removed document still queryable by the model, so it is now logged as a warning rather than hidden at debug level.

Verification

Two regression tests in TabularWorkspaceDocumentEventHandlerTests, one per defect. They were checked against the pre-fix connection string to confirm they are not vacuous — both reproduce the production symptom (the sales table survives the removal; the database file is not deleted).

Full suite with the fix in place: 3243 passed, 0 failed.

Left alone

DefaultConversationDocumentCleanupService builds the same path independently, but it deletes through IFileStore rather than File.Delete and carries its own path-segment validation, so its shape differs. It benefits from the pooling fix regardless, since there is no longer a lingering handle to block it.

🤖 Generated with Claude Code

MikeAlhayek and others added 3 commits September 14, 2026 10:12
Removing a spreadsheet from a chat logged "SQLite Error 8: 'attempt to
write a readonly database'" and left the document's tables in place.

TabularWorkspace keeps its connection in PRAGMA query_only = ON between
its narrow write windows. query_only is connection state, and
Microsoft.Data.Sqlite pools connections by connection string without
resetting it, so the workspace returned a poisoned connection to the pool
on dispose. TabularWorkspaceDocumentEventHandler then opened the same
"Data Source={path}" string, received that connection, and its DROP TABLE
failed while the preceding metadata SELECT succeeded.

A pooled connection also keeps the native SQLite file handle open after
Close(), so the follow-up delete of an emptied tabular.db threw IOException
into a debug-only log and orphan database files accumulated.
TabularWorkspaceHistoryClearedHandler had the same problem.

TabularWorkspaceDatabase now owns how the workspace database is located and
opened: file-backed connections disable pooling (the workspace holds a
single long-lived connection per scope, so pooling bought nothing), Open()
turns query_only off explicitly so no caller inherits a write state, and
the path layout that was duplicated across four call sites lives in one
place.

A failed drop leaves a removed document queryable by the model, so it is
now logged as a warning instead of at debug level.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every chat session and chat interaction gets its own tabular.db, so
isolation between conversations rests entirely on the reference type and
reference id that form the path. Neither was validated where the path was
built, and a reference id that walked out of its own directory resolved
into a sibling scope: removing a document under "session-1/../session-2"
dropped session-2's tables and deleted its database.

DefaultConversationDocumentCleanupService already guarded its own copy of
the path with a single-segment check. That check now lives in
TabularWorkspaceDatabase alongside the path builders, so all four call
sites are covered by one rule and the cleanup service no longer keeps a
private duplicate of the layout.

An unsafe scope resolves to no path at all, which leaves the workspace
in-memory and makes the delete and drop handlers no-ops, rather than
touching a database the scope does not own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A workspace table could outlive the document it came from. Nothing pruned
the database: SynchronizeTablesAsync only ever added tables, and
LoadMetadataFromDatabase exposed every row in _workspace_meta to the model.
The removal handler was the only thing that dropped anything, so whenever
it did not run, or failed the way the pooled read-only connection made it
fail, the removed spreadsheet stayed queryable for the rest of the
conversation.

EnsureReadyAsync now drops every table whose document is no longer attached
before it imports new ones. The caller passes the complete document set for
the scope, so anything else in the database is detached by definition. This
makes the workspace self-correcting rather than dependent on the removal
handler having succeeded earlier.

Deleting a conversation had a matching gap: CleanupAsync returned as soon
as the document store reported no documents, which skipped the database
delete entirely. A conversation whose spreadsheets were each removed
individually therefore orphaned its tabular.db forever. The database is
separate storage that outlives the document rows, so it is now deleted
whether or not any documents remain.

Tests cover all three delete paths and the isolation between them: tables
pruned on the next request (including every table of a multi-worksheet
document), reattaching a dropped document, conversation cleanup with and
without documents, history clearing, and in each case that the neighbouring
scope's database is untouched — a sibling conversation, and the same
identifier under the other reference type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@MikeAlhayek
MikeAlhayek merged commit b529fc4 into main Sep 14, 2026
9 checks passed
@MikeAlhayek
MikeAlhayek deleted the claude/sqlite-readonly-error-3d68da branch September 14, 2026 17:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant