Skip to content

Open caches read-only when their database or directory is not writable or when asked, and stop rewriting unchanged settings on open - #17

Merged
avgvi merged 1 commit into
masterfrom
feat/read-only-caches
Oct 11, 2026
Merged

avgvi merged 1 commit into
masterfrom
feat/read-only-caches

Conversation

@avgvi

@avgvi avgvi commented Oct 11, 2026

Copy link
Copy Markdown
Member

Upstream issue 113, open since 2019 with 14 comments: a cache.db snapshot used only for reads was modified on every run, and a cache on a read-only file system or container layer could not be opened at all, because every open rewrote all settings, re-issued PRAGMA journal_mode, dropped and recreated the tag index and the statistics counters wrote on reads.

No writes on open when nothing changed. Settings are written only when missing or different from the stored row, metadata rows only when missing, the tag index is created or dropped only when sqlite_master disagrees with the setting, and the trigger rewrite already compared first. A test reopens a cache, reads every value type, iterates, checks and closes, twice, and asserts the cache.db hash and mtime are unchanged with no side files. What still writes on a writable cache: the hit and miss counters with statistics=1, the access columns for the LRU and LFU policies, and peek/peekitem deleting expired rows they encounter.

Read-only mode. Cache(directory, read_only=True), a keyword-only constructor argument that is never persisted and passes through FanoutCache and the Django OPTIONS, or automatically when cache.db exists and the file or its directory is not writable. The connection opens with SQLite's mode=ro URI, adding immutable=1 only when the directory cannot hold the WAL side files, which is safe because nothing can write there. Reads, iterkeys, iteration, len, volume, check(fix=False), memoize hits and Deque, Index, FanoutCache and DjangoCache reads all work; get skips the statistics counters and the eviction access update; peek and peekitem filter expired rows in SQL instead of deleting them; stats() reads the stored counters; persistent pragmas are not issued. Every writing method raises ReadOnlyError before any side effect; it subclasses sqlite3.OperationalError, the error SQLite itself raises for this case, so existing handlers keep working. Transactions use a deferred BEGIN in read-only mode. read_only=True on a directory without a database raises instead of creating one. A Cache.read_only property reports the mode.

Refactor for pylint: the read-only decision, the settings loop and the DDL moved into three small methods; max-public-methods goes from 30 to 32.

Tests (tests/test_readonly.py, 15): no-write reopen; only changed settings write; tag index only when changed; statistics write on reads but not when read-only; explicit read-only with every write raising and the database unchanged; missing database; the error type; all eviction policies; a write inside transact(); a read-only file (runs on Windows too); a read-only directory with immutable mode (POSIX, skipped as root); a read-only directory without a Settings table; FanoutCache, Deque, Index and DjangoCache; a snapshot without metadata rows. Tutorial section "Read-only caches" and the Cache docstring.

Verified locally: 372 tests pass at 98.89 % on Python 3.12 with Django 4.2 and 373 on 3.14 with Django 6.0 (reverse-direction golden test enabled); bluecheck, isortcheck, flake8, mypy, pylint, doc8, rstcheck and the Sphinx build pass. No schema change, no new persisted key, writes byte-identical to before. Rebased onto PR 16 once that merges; one expected conflict in Cache.get.

…e or when asked, and stop rewriting unchanged settings, triggers and the tag index on open
@avgvi
avgvi force-pushed the feat/read-only-caches branch from 56148fe to a2846bd Compare October 11, 2026 05:44
@avgvi
avgvi merged commit 5291050 into master Oct 11, 2026
22 checks passed
@avgvi
avgvi deleted the feat/read-only-caches branch October 11, 2026 05:48
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