fix(persistence): harden chat and memory storage - #319
Conversation
create_group read the highest position and inserted afterwards, so two groups made at the same moment (routes run on a thread pool) could take the same position. set_chat_group checked that the group existed and then updated the chat without holding the write lock, so a delete_group landing in between left the chat filed under a group that no longer exists, hidden from the sidebar until the next startup sweep. create_group is now one INSERT ... SELECT, and set_chat_group takes the write lock (BEGIN IMMEDIATE) before it looks at the group. Tests: eight threads behind a barrier must get positions 0-7; a second connection deleting the group exactly between the check and the move must not leave an orphaned chat. Both fail on the previous code. Plan item: BE-54 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…e memory backup os.replace failed on the first PermissionError, so a scanner or indexer holding the target open for a few milliseconds turned a routine save or backup into a PersistenceError; for permanent memory that rolled the in-memory list back and the user's edit was refused. The memory backup copy was also written without an fsync, unlike the primary file it protects. replace_with_retry (repositories/sqlite_backup.py, shared by both stores) tries at most four times, waiting 50, 100 and 200 ms, and only for PermissionError; any other error is raised at once and a lock that does not lift is reported after about a third of a second with nothing moved. It now publishes the chat database backup and its rotation, the settings database backup, the memory file, its backup copy and the set-aside of a corrupt memory file. The memory backup copy is fsynced before it replaces the backup. Recovery's quarantine moves and the sidecar moves are left as they were: they fail closed on purpose and their tests depend on it. Two existing tests simulated a lock by refusing the first rename only; they now refuse every attempt, since a lock that lifts is what succeeds by design. Plan item: BE-57 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…y directory The legacy JSON directory survives every migration pass: migrated files move to <dir>_migrated_<time> and unreadable ones to <dir>/quarantine, so the directory itself stayed behind, empty, and every launch logged "Legacy JSON chat history found" as a warning followed by a zero-count completion line. A pass now lists the .json files first and returns quietly when there are none. After a pass the directory is removed only when it holds nothing (an empty quarantine folder counts as nothing), and only with rmdir, which the operating system refuses for a directory that holds anything: no chat file, quarantined file or stray file is moved or deleted. When only the quarantine folder holds files, that is logged once at info level and everything is left in place. A source file that could not be archived stays put and is picked up again on the next launch. Plan item: BE-58 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…t it found Every API test runs against InMemoryChatRepository while the app runs on LegacyDatabaseChatRepository over SQLite, and the two had drifted, so route behaviour was being proven against semantics users never get. tests/test_chat_repository_contract.py runs the same 31 behaviours against both: create and duplicate, overview against full load, recency order after a message and after a rename, messages on unknown chats, field round trips, replace on missing / non-assistant / foreign ids, stale revisions, forks (content, thoughts, sources, attachments, stats, timestamps, group, independence), group positions and return values, and delete cascade. What it found, and what changed: - In-memory rename bumped the chat's timestamp, moving it to the top of the sidebar in tests and the demo but not in the app. Renaming is not activity; the double no longer bumps it. - In-memory fork rewrote every copied message's timestamp to "now", kept the source's group, and overwrote an existing chat that had the new id. It now keeps the original timestamps (falling back to an ordered offset only for a message with none), starts ungrouped and refuses an id that is taken. - In-memory group creation used the group count as the next position, which reuses a position after a delete; it now appends after the highest, as SQLite does. - Empty sources, attachments and stats were kept as empty values in memory but read back as absent from the database; the double now matches. - SQLite renamed an unknown chat silently, and a message on an unknown chat surfaced as a foreign-key failure. Both now raise a typed not-found (ChatNotFound), and replacing a message that is missing or is not an assistant reply raises MessageNotFound, as the double does. The SQLite fork already kept message timestamps; only the double did not. Plan items: BE-52, BE-59 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The chat revision was the number of messages. A regeneration replaces the last reply without changing that number, so two regenerations started from the same state both passed the compare-and-swap, and a client holding a stale view of a regenerated chat was not told to reload. Schema version 5 adds threads.revision (INTEGER NOT NULL DEFAULT 0), raised by add_message, replace_message and delete_last_assistant_message, and set to the message count for a chat created by a fork or the legacy import. Existing chats start from their message count, which is the number their clients last saw. A rename or a move into a group does not touch it, so neither can turn an answer that is being generated into a conflict. The compare-and-swap in add_message and replace_message keeps its semantics and now compares this column. Reads of a chat take the thread row and its messages in one read transaction, so the revision always belongs to the messages returned with it. chat_revision() returns the persisted counter and falls back to the message count for a chat that carries none. The regeneration route hands admission the revision it read instead of deriving it from the transcript length, and a new turn compares the revision rather than the length. The API field is unchanged, and the frontend needs nothing: it takes the revision from the chat it reloads when a generation completes, and its optimistic +1 for the user message it just had accepted is still what the server does. The in-memory repository keeps the same counter. The column is additive and the upgrade runs through the existing ladder: a pre-upgrade snapshot (<db>.pre-v4.bak) is kept first, the step is idempotent and never lowers a revision, and an older release refuses the upgraded file and names that snapshot. Tests cover the upgrade, that refusal, and restoring the snapshot by following its advice. Plan item: BE-64 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
SQLite never shrinks a file by itself: deleting a chat only turns its pages into free pages, so a history that was once large kept its disk space for good. A new chat database is now created with auto_vacuum = INCREMENTAL (set before write-ahead logging is switched on, which is the only moment it can be). At startup, once the backup of the same content has been written and verified, sqlite_reclaim.reclaim_free_space looks at the file and acts only when more than a quarter of it (and at least 64 pages) is free: - an incremental file has its free pages handed back in small steps, each its own transaction, so stopping part-way loses nothing and the next start carries on; - a file made before this release is rewritten once with VACUUM, which also converts it so later starts take the cheap path. VACUUM is all-or-nothing; it is skipped when it would hold more than 1 GiB of live content or when the drives beside the file and in the temporary directory cannot hold a second copy, and it is interrupted after 20 seconds, which rolls it back. It never runs without a verified backup made in the same launch (a failed backup skips it), it runs in the constructor before the app serves a request so it cannot compete with a write, and another connection holding the write lock only makes it give up. Every outcome that is not a success is returned and logged by type, never raised: a database that could not be shrunk still opens. Plan item: BE-60 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Every repository failure was mapped to HTTP 500 "Could not save message.", so a
full disk looked like a bug in Cortex and told the user nothing they could do.
_raise_repository_error now follows the failure's cause chain (PersistenceError
.cause and "raise ... from") and answers 507 Insufficient Storage, "Could not
<operation> because the disk is full. Free some disk space and try again.", for
SQLite's SQLITE_FULL ("database or disk is full") and for ENOSPC and the
Windows disk-full error codes. Everything else is still a 500 with the same
text as before, and the message carries no path or user text.
Tests inject the SQLite full-disk error at the write itself (a message append,
a reply replacement, a fork's bulk insert) and check that the chat, its
revision and its timestamp come back exactly as they were, that no empty fork is
left behind, that the revision the failed write was guarded by is not spent,
and that the same request succeeds once there is room. The frontend already
shows the detail of any failed request, so it needs no change.
Plan item: BE-55
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ngs stores A database written by a newer release is refused by two gates that had no test of their own beyond the chat file's bytes staying the same: the stored schema version of the file, and the schema version of the settings row. - The chat database from a newer build is refused with every file in the data directory (primary, backups, no new snapshot) byte-for-byte unchanged and the newer version still stamped on it. - The settings database from a newer build is refused the same way, before any backup is rotated over the older-schema generations. - A settings row stored by a newer build is reported as an error from load() and left as it was, and the API answers it with a 500 "Could not load settings." instead of crashing. Plan item: BE-55 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…istory folder The upgrade and recovery section lists what Cortex keeps and does around the chat database. Two behaviours that change what a user sees in the data folder were missing: space from deleted chats is handed back at launch (when it is safe), and the old JSON chat_history folder disappears once it is empty. Plan items: BE-58, BE-60 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…gress reclaim_free_space trimmed free pages in steps until the free list was empty or the time limit passed. A step that freed nothing (a free list that will not shrink for whatever reason) would have made it spin for the whole limit at every start. A step that frees no page now ends the pass as "gave_up"; without the guard the new test counts over 600,000 free-list reads in the five seconds it is given. Plan item: BE-60 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Main's change #315 already took chat-database schema version 5 (messages.original_content). This branch's threads.revision step is renumbered to version 6 (_migrate_to_v6, SCHEMA_VERSION = 6) and ordered after main's v5. The API error helper keeps main's request-id logging and this branch's 507 mapping for a full disk. The in-memory repository keeps both the empty-means- absent normalisation and original_content. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The revision step is schema 6 now, so the upgrade tests cover a v4 store (which
climbs through main's original_content step and then this one) and a v5 store
written by current main (originals kept, revisions backfilled), and the
rollback test is run for a previous release that reads 4 and one that reads 5.
The full-disk tests use the fork route, since POST /chats/{id}/messages was
removed, and the 500 text carries main's request id.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…aunch A database made before auto-vacuum that is under the 1 GiB ceiling but whose VACUUM needs more than the 20 second limit was interrupted, rolled back and tried again at every start, adding up to 20 seconds to each launch for good. A rewrite that fails or runs out of time now leaves a small marker beside the database (<db>.reclaim-backoff, holding only the time), and no rewrite is tried again for seven days. Another program holding the write lock is not a failed rewrite and leaves no marker; an unreadable or future-dated marker is ignored; the incremental path is not delayed. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…deleting it Retiring the legacy chat_history folder used rmdir on the folder and on an empty quarantine folder inside it. The folder is now renamed, after the same emptiness check, to chat_history.retired (.retired-2 and so on when the name is taken), so nothing is deleted even if the check is wrong or a file lands in the folder in between: it is still there under the new name, and the retired folder is the user's to remove. Nothing else about the retirement changes: any chat file, quarantined file or stray file keeps the folder where it is. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…the legacy import revision Reverting load_chat's BEGIN, or importing a legacy chat at revision 0 instead of its message count, left every test green. One test lands a real write between the two reads of load_chat and checks the revision and the messages come from the same moment; another checks an imported chat starts at its message count and that a stale revision is refused. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
Pushed c90828c: main (eb55570) merged in, and three review findings fixed. The description above is updated (schema v6, rollback, checks). Merge with main (by merge, no rebase, no force-push). Conflicts were in Renumbered: schema v5 to v6. Main's #315 already took v5 ( Moved: tests off the removed route. Fixed (a): the two untested hunks. New tests: one lands a real Fixed (b): no folder is deleted any more. The emptied legacy folder (verified empty first, an empty Fixed (c): back-off for the whole-file rewrite. A non-incremental database under the 1 GiB ceiling whose Not fixed, stated in Limits: Checks. ruff, mypy (98 files), contract check, artifact-boundary review (12 of 12): clean. |
Problem
Chat and permanent-memory persistence had eight small but real weaknesses, all of them about user data:
chat_historyfolder survived every migration, so every launch logged a warning that legacy history had been found.Root cause
set_chat_groupchecked the group and then updated without holding the write lock.os.replacewas called unguarded at the publish points, and_atomic_copy_memoscopied withshutil.copy2and no fsync.migrate_from_json_if_neededwarned whenever the directory existed, and nothing ever removed it.VACUUMor an incremental vacuum, and nothing setauto_vacuumor ran either._check_chat_revisioncomparedCOUNT(*), andreplace_messagedoes not change the count._raise_repository_errormapped every failure to 500.Change
One commit per concern (ten on the branch as first opened; after merging main, the four follow-up commits at the end of this section):
create_groupis a singleINSERT ... SELECTandset_chat_grouptakesBEGIN IMMEDIATEbefore it looks at the group.replace_with_retry(insqlite_backup.py, which both stores already share) retries onlyPermissionError, four tries in all, waiting 50, 100, 200 ms. It now publishes the chat backup and its rotation, the settings backup, the memory file, its backup copy and the set-aside of a corrupt memory file. The memory backup copy is fsynced before it replaces the backup..jsonfiles first and returns quietly when there are none. After a pass the directory is renamed tochat_history.retired(.retired-2,.retired-3, ... if that name is taken) only when it is verified empty (an emptyquarantine/counts as empty). Nothing is deleted: normdirremains, so a wrong emptiness check, or a file that lands in the folder between the check and the rename, loses nothing (it is still there under the new name), and the retired folder is the user's to remove. The retired name is not one the migration scans. Quarantined files, un-archivable files and stray files keep the directory where it is; quarantine content is logged once at info level.tests/test_chat_repository_contract.pyruns 31 behaviours against both repositories (62 tests). Drift found and fixed in the in-memory double: rename no longer bumps recency (SQLite never did), fork keeps message timestamps, starts ungrouped and refuses an id that is taken, new groups go after the highest position, empty sources/attachments/stats read back as absent. On the SQLite side, renaming an unknown chat, adding a message to an unknown chat without a title, and replacing a missing or non-assistant message now raiseChatNotFound/MessageNotFoundlike the double (previously silent, a foreign-key error, and a wrapped error).messages.original_content; this step is_migrate_to_v6, ordered after it,SCHEMA_VERSION = 6) addsthreads.revision INTEGER NOT NULL DEFAULT 0, raised byadd_message,replace_messageanddelete_last_assistant_message, set to the message count for a fork or a legacy import, backfilled for existing chats from their message count. Rename and moving into a group do not touch it (so neither can turn a generating answer into a conflict).load_chatreads the thread row and messages in one read transaction.chat_revision()returns the persisted counter (falling back to the count for a chat that carries none), the regeneration route hands admission the revision it read instead oflen(transcript), and a new turn compares the revision. The API field and its name are unchanged. The in-memory repository has the same counter.auto_vacuum = INCREMENTAL(before WAL is enabled, the only moment that works). At startup, after that launch's backup succeeded,sqlite_reclaim.reclaim_free_spaceacts only when more than 25% of the file (and at least 64 pages) is free: incremental files get their free pages back in small transactional steps; older files are rewritten once withVACUUM, which also converts them. Plus a follow-up commit so a step that frees nothing ends the pass instead of spinning. AVACUUMthat fails or exceeds the 20 s limit is rolled back, and used to be retried at every launch (up to 20 s added to each start, for good, for a non-incremental database under the 1 GiB ceiling); it now leaves<db>.reclaim-backoff(only a timestamp) and no rewrite is tried for seven days. See "Follow-up" below.database or disk is full,ENOSPC, the Windows disk-full codes, found through the cause chain) is answered 507 with "Could not because the disk is full. Free some disk space and try again." on every route that reports repository failures through_raise_repository_error(chat and group create, rename, move, delete, fork); everything else there is still 500 with the same text plus main's request id. The send path (POST /generations) is not among them, see Limits. New tests inject the full-disk error at the write and check the chat comes back exactly as it was, and add the newer-schema gates for the chat file, the settings file and the settings row.Follow-up after merging main and an independent review (four commits on top of the merge):
_raise_repository_error(main's request-id logging plus the 507 mapping), and the in-memory repository (empty-means-absent plus main'soriginal_content).POST /chats/{id}/messageswent away in fix(api): small API defects - drop the raw message write, kind-checked /generations, can_cancel, client route check, one-query task list, 1 MiB body ceiling #316, so the two HTTP full-disk tests (507 with the chat unchanged and no half-written fork; a non-disk-full failure still 500) now usePOST /chats/{id}/forks, and the newer-settings test expects main's(Request ID: ...)suffix. The revision tests already used/generations.add_messagebetweenload_chat's two reads (through a trace callback on the reading connection) and requires the revision and the messages to come from the same moment; another checks a legacy-imported chat's revision equals its message count and that a stale revision is refused. Each was verified to fail when the hunk is reverted (BEGINremoved; import revision set to 0).chat_history.retiredinstead ofrmdir(BE-58, above).Compatibility and rollback
Schema change (v5 to v6, on top of main's v5): additive only (one column, plus a backfill of derived values). The existing ladder runs it. A store written by current main (v5) gets
<db>.pre-v5.bak, written and verified first (the upgrade refuses to run without it); a store still at v4 gets<db>.pre-v4.bakand climbs through main's v5 step and then this one in the same launch (one snapshot, of where the upgrade began). Each step is one transaction with its version bump, and this one is idempotent (MAX(revision, count)never lowers a revision). Nothing is dropped or rewritten,original_contentis untouched, andtest_a_v4_database_upgrades_through_5_and_6...andtest_a_v5_database_upgrades_to_v6...check data, originals, snapshots and revisions for both starting points.To go back to the previous release (which reads up to schema 5): close Cortex, move
cortex_db.sqliteand any-waland-shmfiles beside it aside, copycortex_db.sqlite.pre-v5.bak(or.pre-v4.bakfor a store that came straight from v4; the previous release upgrades that itself) tocortex_db.sqlite, start the previous release. Anything written after the upgrade is only in the files moved aside. The previous release refuses a v6 file without touching it and names the newest snapshot it can read, exactly as the ladder already refuses any newer store.test_the_previous_release_refuses_the_upgraded_database_and_the_named_snapshot_restores_itwalks that procedure for a previous release that reads 5 and for one that reads 4 (refusal naming the snapshot, move aside, restore, previous release opens it with its data and originals, upgrading again works). Reverting only this PR's code, without restoring the snapshot, leaves a v6 file that the previous release refuses rather than misreads.The other on-disk changes:
<db>.reclaim-backoff(a timestamp, only after a failed whole-file rewrite; safe to delete) andchat_history.retired(an emptied folder, safe to delete). Neither is read by any earlier release.Behaviour changes to know about:
revisionin a chat response now also moves on a regenerate (it used to move only on appends); a client holding an older revision after someone regenerated gets the existing 409 "reload". The frontend needed no change: it takes the revision from the chat it reloads when a generation completes, and its optimistic +1 for the user message the server just accepted is still what the server does. The backend contract check (generate_contracts.py --check) is unchanged. If that reload fails, the client keeps a stale revision and its next send gets the 409, as it would after any turn.The in-memory repository changes (rename no longer reorders, fork keeps timestamps) change what the demo and API tests see, deliberately, to match SQLite.
Checks
Run from the worktree root, on the merge of this branch with main at eb55570 plus the follow-ups.
python -m ruff check backend tests tools main.py app_factory.py scripts: all checks passed.python -m mypy: no issues in 98 source files.python tools/generate_contracts.py --check: exit 0.python tools/artifact_boundary_review.py --json --strict: exit 0, 12 of 12 cases passed.python -m pytest -qon Python 3.14: 3071 passed, 25 subtests passed.check.ps1quick tier), first attempt, Python 3.14: 12 of 12 checks passed in 334.6 s, including backend tests (3071 passed), artifact-boundary review, contract check, frontend types, eslint and vitest (914 passed in 72 files).py -3.12,ggufsupplied from a copy of the 3.14 package onPYTHONPATH): 3070 passed, 1 failed, 25 subtests passed. The failure istests/test_repository_conventions.py::test_collection_works_without_cwd_on_sys_path, which runs a child pytest withPYTHONPATHremoved on purpose, so that child cannot importggufon this machine; it does not touch this PR's files.load_chatwithout itsBEGIN(the same-moment test fails: the messages include the write that landed after the revision was read), legacy import revision set to 0 (fails: 0 against 3), 507 mapping disabled (500 instead of 507).frontend/changed; the hook's frontend checks are the only frontend run.Security / data-loss / concurrency
.reclaim-backoffmarker, after a rewrite succeeds. Space reclaim is all-or-nothing (VACUUM rolls back on interruption, failure or a full disk; incremental steps are separate transactions) and never runs without a verified backup of the same content from the same launch. Tests interrupt a VACUUM with a zero time limit, hold the write lock from another connection, remove the free disk space and shrink the size ceiling, and each leaves every row in place with a passing integrity check.DatabaseManagerconstructor before the app serves a request, so it never competes with a write; another connection holding the write lock only makes it give up.BEGIN IMMEDIATE; a test races two regenerations from the same revision against both repositories and exactly one wins.Limits
DatabaseManager.set_chat_groupis still translated toChatGroupNotFoundby matching the message text in the adapter (unchanged)._raise_repository_error, so it covers the routes that use it. The send path (POST /generations) writes the user message while the job is prepared, outside that helper, and a full disk there reaches main's outermost handler as500 Internal server error. (Request ID: ...)(measured with the same injected fault). It is a chat turn that is refused and nothing is half saved, but the message does not say why. Not changed here: it means touching the job-start error path or main's request-failure handler, which belong to a separate change.chat_history.retired(an empty folder, or one holding only an emptyquarantine/) beside the data instead of no folder; that is the price of never deleting one.SQLiteSettingsRepository.saveupsertsschema_versionfrom the current release, so a payload written by a newer build would be overwritten if something saved without loading first; the routes load first, so it is not reachable today.test_chat_groups.py; this adds the stricter directory-wide variant rather than a duplicate. The plan's BE-59 was already fixed for SQLite (only the double drifted), and BE-57's "shared helper from BE-56" does not exist yet, soreplace_with_retrylives insqlite_backup.py.Plan item: BE-52, BE-54, BE-55, BE-57, BE-58, BE-59, BE-60, BE-64
🤖 Generated with Claude Code