WIP: fix yet another SQLite concurrency bug
diff --git a/HISTORY.md b/HISTORY.md index cb165b1..c706748 100644 --- a/HISTORY.md +++ b/HISTORY.md
@@ -1,5 +1,9 @@ # History +## Unreleased +* Fix intermittent data corruption (and resulting `EOFError`) in the SQLite backend under + concurrent thread access + ## 1.3.3 (2026-07-03) * Fix SQLite `vacuum()` not freeing disk space * Fix DynamoDB item enumeration when the table exceeds 1MB
diff --git a/requests_cache/backends/base.py b/requests_cache/backends/base.py index e980ae7..a74a3bb 100644 --- a/requests_cache/backends/base.py +++ b/requests_cache/backends/base.py
@@ -25,7 +25,7 @@ from ..serializers import SerializerType, init_serializer # Specific exceptions that may be raised during deserialization -DESERIALIZE_ERRORS = (AttributeError, ImportError, PickleError, TypeError, ValueError) +DESERIALIZE_ERRORS = (EOFError, AttributeError, ImportError, PickleError, TypeError, ValueError) logger = getLogger(__name__)
diff --git a/requests_cache/backends/sqlite.py b/requests_cache/backends/sqlite.py index 5b6d7ee..fa3d7a5 100644 --- a/requests_cache/backends/sqlite.py +++ b/requests_cache/backends/sqlite.py
@@ -246,7 +246,14 @@ @contextmanager def connection(self, commit=False) -> Iterator[sqlite3.Connection]: - """Get a thread-local database connection""" + """Get a synchronized database connection. + + A single connection object is shared across all threads for this cache (see + ``check_same_thread=False`` above), so all access to it -- reads included -- must be + serialized under ``self._lock`` for the full duration it's in use, not just while it's + being lazily opened. Otherwise a read could run concurrently with an in-progress write + transaction on the same connection object, corrupting results. + """ with self._lock: if not self._connection: logger.debug(f'Opening connection to {self.db_path}:{self.table_name}') @@ -264,13 +271,13 @@ if self.wal and not self.fast_save: self._connection.execute('PRAGMA synchronous=NORMAL') - # Multithreaded write operations must be run in serial - if commit and not self._active_transaction: - with self._acquire_sqlite_lock(): + # Multithreaded write operations must be run in serial + if commit and not self._active_transaction: + with self._acquire_sqlite_lock(): + yield self._connection + # Reads are also serialized (behind the same lock), since the connection is shared + else: yield self._connection - # Read operations can be run in parallel (no lock or COMMIT) - else: - yield self._connection def close(self): """Close any active connections"""