Repository navigation
STA-5: Add Store::open_read_only and a shared scan lock - #50
Merged
Merged
Conversation
A process that only reads the store (a desktop dashboard) can now open it with Store::open_read_only, which returns a ReadStore: a SQLite read-only (mode=ro, not immutable) connection with query_only on that never creates, migrates, backfills, reprices, chmods or checkpoints, and exposes only audited read APIs. A missing store or a schema older or newer than CURRENT_SCHEMA_VERSION is a typed ReadOnlyOpenError. Scanners now coordinate through an advisory lock in scan.lock beside the database (~/.statsai/scan.lock by default), built on std's flock/LockFileEx file locks. statsai scan and the scanning paths of statsai sync take it before opening the store, wait up to 5 seconds, then print a notice and exit 0. statsai daemon --watch takes it per background pass only, and keeps changed paths queued while another process holds it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3EZeUTrrXQYg1NwFdgV3K
hexnyl
marked this pull request as ready for review
October 10, 2026 14:37
hexnyl
commented
Oct 10, 2026
hexnyl
commented
Oct 10, 2026
hexnyl
commented
Oct 10, 2026
hexnyl
commented
Oct 10, 2026
hexnyl
commented
Oct 10, 2026
hexnyl
commented
Oct 10, 2026
hexnyl
commented
Oct 10, 2026
hexnyl
commented
Oct 10, 2026
hexnyl
commented
Oct 10, 2026
hexnyl
commented
Oct 10, 2026
hexnyl
commented
Oct 10, 2026
hexnyl
commented
Oct 10, 2026
- ReadStore::schema_version reads the version without writing. Store::schema_version created schema_migrations and set the journal mode, which failed on a rollback-journal store and inside a snapshot. - ReadStore::check_schema re-checks the schema, so a long-lived reader can notice a newer statsai migrating the store under it. - ReadOnlyOpenError::Open includes its cause in the message. - The read-only connection is built on snapshot::open_read_only instead of a second copy of the open flags. - sync validates its flags before waiting for the lock. A sync skipped for a running scan still saves its --include-*/--exclude-* preference flags, and says that nothing was sent. --reset-remote now takes the lock, so it cannot interleave with an upload. - The lock is advisory, so an unusable scan.lock (wrong owner, read-only mount) makes scan, sync and the daemon warn and carry on without it, instead of failing every run. - scan_lock_path resolves symlinks, so a linked store path shares the store's lock. - The daemon leaves the lock free for 200 ms after each pass, so a waiting scan or sync gets a turn when passes run back to back. - The comments and docs now name only the commands that take the lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3EZeUTrrXQYg1NwFdgV3K
…read A `sync --reset-remote` that a running scan turns away now fails, having deleted nothing, instead of exiting 0 as a skipped sync does. Its flag errors (`--sink http` required, `--yes` unless a dry run) are checked before the lock wait. `--reset-remote --dry-run` only reads and no longer takes the lock. Every ReadStore read now checks the schema version as the first read of its own transaction, or joins a with_read_snapshot that did. A reader left open while a newer statsai migrates the store gets SchemaTooNew instead of misreading it, and no migration can land between the check and the read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3EZeUTrrXQYg1NwFdgV3K
… lock exemption A sync that a running scan turns away still saves its preference flags, so the running sync can find stored preferences that differ from the ones it read at the start. The debug_assert_eq that assumed otherwise would panic in debug builds; it is removed. The scan_lock module and the statsai re-export docs now also name `--reset-remote --dry-run` among the syncs that don't take the lock, matching the README. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3EZeUTrrXQYg1NwFdgV3K
scan_lock_path fell back to the path as written when the store's directory did not exist yet, and resolved it once it did. Through a linked directory such as macOS's /var -> /private/var the two spellings differ, which failed the macOS CLI test. It is one file, but the path a command prints depended on creation order. A dangling symlink at the store path could split the lock outright: its own directory before the store existed, the target's after. The longest existing prefix of the store's directory is now canonicalized and the missing directories appended as written, a symlink at the store path is followed even before its target exists, and a bare file name resolves against the current directory. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3EZeUTrrXQYg1NwFdgV3K
…p front - A read whose schema check fails to read the version (a busy store, an I/O error) now returns that error with context, not a ReadOnlyOpenError::Open that claims the open failed. Only SchemaTooOld and SchemaTooNew are typed. - ReadStore opens through a snapshot helper without its own path context, so the Open message names the path once instead of three times. - The ReadStore docs and README no longer promise that a migration still in progress is caught: a migration records its version after its schema changes. - validate_sync_command rejects an unknown --sink, so `sync --sink htpp` fails whether or not a running scan would have turned it away. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3EZeUTrrXQYg1NwFdgV3K
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.
Linear: STA-5
StatsAI Desktop reads
~/.statsai/statsai.sqlitewhile the CLI or daemon may be scanning it. Desktop query connections must never write, and two scanners must never run at once. Both additions are generic, so the CLI can use them on their own.Read-only store
Store::open_read_only(path) -> Result<ReadStore, ReadOnlyOpenError>How it opens:
SQLITE_OPEN_READ_ONLY, which is whatmode=rosets; it is notimmutable, so WAL commits stay visible.PRAGMA query_only = ON. No checkpoint on close. Defensive mode, a busy timeout, and the same cache and mmap settings as the writer. It shares its open flags with the snapshot reader.What it never does: create the file, migrate, backfill, reprice, chmod, or run
PRAGMA optimize. Reading the schema version only runs SELECTs, even on a rollback-journal store.Typed errors (
ReadOnlyOpenError, non-exhaustive):NotFoundSchemaTooOld { found, expected }SchemaTooNew { found, expected }Open { reason }, whose message includes the cause.The version is read from
schema_migrations;CURRENT_SCHEMA_VERSIONis 31.The schema is checked on every read, not just at open. Each read runs in a read transaction whose first statement re-reads the version, so the check and the read see one snapshot. A store that a newer
statsaimigrated after the open is refused withSchemaTooNew, inside theanyhow::Error, instead of being misread. A busy or I/O error on that statement stays the read's own error.check_schema()runs the same check without reading anything else.Why a wrapper type instead of a plain
Store:ReadStoreexposes reads only, and each one forwards to the existingStoremethod. SomeStore"reads" also do upkeep:all_sync_rollup_summariescan rebuild stale rollups.ReadStorereturns what is stored and leaves that upkeep to the next writer. A write therefore fails to compile instead of failing at runtime.Available reads:
data_version,with_read_snapshot,schema_version,check_schemasession_rollups_in_period,session_stats_in_periodall_sync_rollup_summaries,daily_rollups_between,cache_reportquota_status,quota_windows,quota_observations,weekly_reset_anchorlist_sources,list_accounts,list_source_account_assignments,list_subscriptions,list_sync_statesapplied_pricing_ruleset_version,event_count,token_totalScan lock
The lock is
scan.lockbeside the database (~/.statsai/scan.lockby default;--storeis respected). It uses std'sFile::try_lock:flockon Unix andLockFileExon Windows. Toolchain 1.97.1 already has it, so no new dependency is needed. The code lives instatsai-storeso the daemon crate can use it, and thestatsailib re-exports it.API:
try_acquire_scan_lock(path) -> Result<Option<ScanLockGuard>>acquire_scan_lock_with_timeout(path, timeout)scan_lock_path(store_path)statsai::default_scan_lock_path()Dropping the guard releases the lock. The lock file is never truncated or deleted. A newly created directory gets mode 0700 and the file 0600.
One lock file per store, however the path is written:
scan_lock_pathfollows a symlink at the store path, even one whose target doesn't exist yet./var→/private/varon macOS, and creation order all lead to the same file. Hard links are not detected.statsai scanandstatsai sync:sync --status,--verifyand--reset-remote --dry-runskip it, because they only read.--sinkare rejected before any wait.Another statsai scan is running (lock: …); skipping.and exits 0, as STA-5 asks. For a sync the message endsskipping this sync. Nothing was sent., and its--include-*/--exclude-*preference flags are still saved.sync --reset-remotefails instead, having deleted nothing.statsai daemon --watch:scanorsyncgets a turn.Advisory: if the lock file can't be opened or locked, the CLI warns and runs without it, and the daemon warns once and keeps scanning.
Breaking change:
statsai_daemon::watch_and_servegains ascan_lock: Option<&Path>parameter. The CLI is its only caller.README: a note under "Scanning and reporting", and a new subsection, "Reading the store from another process". There is no CHANGELOG file.
Tests
statsai-store/tests/read_only_store.rs:NotFoundand creates nothing.SchemaTooOld, including a store that predates schema tracking (version 0). A newer schema returnsSchemaTooNew.Open, and the message carries the cause.check_schemaand refused by every read, including insidewith_read_snapshot.data_versionchanges after a commit, andwith_read_snapshotkeeps its point in time.statsai-store/tests/scan_lock.rs:statsai/tests/scan_lock_cli.rs:scanandsyncskip with exit 0 and don't create a store.scanwaits out a short hold and then runs.Locally on Linux:
cargo fmt --check,clippy --workspace --all-targets -D warningsandcargo test --workspace(1,602 tests) pass. CI passed on Linux and macOS at a1ee0d9.Review round
The automated review posted 12 findings, and all are addressed in 45cbdd2. Each new behavioural test fails on the commit before its fix.
A verification pass then checked every finding against the fix and hunted for regressions. That led to four more commits:
--reset-remotefails rather than skips.debug_assertthat a turned-away sync saving its preferences could trip.Openmessage names the path once.--sinkis rejected before the wait.Limitations
-wal/-shmfiles beside it. In a directory it cannot write, it fails withOpenunless those files exist. This is documented onReadStore.Follow-ups (not in this PR)
import,conversation collectandsource remove --delete-dataunder the scan lock.fetch_snapshotusesStore::open; consider the read-only path.rust-version(File::try_lockneeds 1.89+).🤖 Generated with Claude Code
https://claude.ai/code/session_01G3EZeUTrrXQYg1NwFdgV3K