Stop the tabular workspace connection pool from leaking read-only state - #186
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
Removing a spreadsheet from a chat produced this in the production logs:
TabularWorkspacedeliberately keeps its connection inPRAGMA query_only = ONand only opens narrow write windows.query_onlyis connection state, andMicrosoft.Data.Sqlitepools connections by connection string without resetting it — so the workspace handed a poisoned connection back to the pool on dispose.TabularWorkspaceDocumentEventHandlerthen opened the identicalData Source={path}string, received that connection, and itsDROP TABLEfailed — while the metadataSELECTimmediately 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 emptiedtabular.dbthrewIOExceptioninto a debug-only log and orphan database files accumulated.TabularWorkspaceHistoryClearedHandlerhad the same problem.Both were confirmed empirically before anything was changed —
query_onlyreads back as1on a "fresh" connection, andFile.Deletethrows "used by another process".The fix
New
TabularWorkspaceDatabaseowns how the workspace database is located and opened:Pooling=falsefor file-backed workspaces. The workspace holds a single long-lived connection per scope, so pooling bought nothing and cost correctness.Open()setsquery_only = OFFexplicitly, so no caller ever inherits a write state.documents/{referenceType}/{referenceId}/data/tabular.dblayout 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 (thesalestable survives the removal; the database file is not deleted).Full suite with the fix in place: 3243 passed, 0 failed.
Left alone
DefaultConversationDocumentCleanupServicebuilds the same path independently, but it deletes throughIFileStorerather thanFile.Deleteand 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