Skip to content

Commit 6b52214

Browse files
committed
Add tests, move journal_mode=MEMORY pragma into transaction blocks
1 parent 8938c15 commit 6b52214

File tree

3 files changed

+931
-24
lines changed

3 files changed

+931
-24
lines changed

src/filelock/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from ._api import AcquireReturnProxy, BaseFileLock
1616
from ._error import Timeout
17+
from ._read_write import ReadWriteLock
1718
from ._soft import SoftFileLock
1819
from ._unix import UnixFileLock, has_fcntl
1920
from ._windows import WindowsFileLock
@@ -62,6 +63,7 @@
6263
"BaseAsyncFileLock",
6364
"BaseFileLock",
6465
"FileLock",
66+
"ReadWriteLock",
6567
"SoftFileLock",
6668
"Timeout",
6769
"UnixFileLock",

src/filelock/_read_write.py

Lines changed: 45 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import threading
44
import logging
55
import time
6-
from _error import Timeout
6+
from ._error import Timeout
77
from filelock._api import AcquireReturnProxy, BaseFileLock
88
from typing import Literal, Any
99
from contextlib import contextmanager
@@ -62,8 +62,12 @@ def get_lock(cls, lock_file: str | os.PathLike[str],
6262
normalized = os.path.abspath(lock_file)
6363
with cls._instances_lock:
6464
if normalized not in cls._instances:
65-
cls._instances[normalized] = cls(lock_file, timeout, blocking)
66-
instance = cls._instances[normalized]
65+
# Create the instance with a strong reference first
66+
instance = super(_ReadWriteLockMeta, cls).__call__(lock_file, timeout, blocking, is_singleton=False)
67+
cls._instances[normalized] = instance
68+
else:
69+
instance = cls._instances[normalized]
70+
6771
if instance.timeout != timeout or instance.blocking != blocking:
6872
raise ValueError("Singleton lock created with timeout=%s, blocking=%s, cannot be changed to timeout=%s, blocking=%s", instance.timeout, instance.blocking, timeout, blocking)
6973
return instance
@@ -89,19 +93,6 @@ def __init__(
8993
# _lock_level is the reentrance counter.
9094
self._lock_level = 0
9195
self.con = sqlite3.connect(self.lock_file, check_same_thread=False)
92-
# Using the legacy journal mode rather than more modern WAL mode because,
93-
# apparently, in WAL mode it's impossible to enforce that read transactions
94-
# (started with BEGIN TRANSACTION) are blocked if a concurrent write transaction,
95-
# even EXCLUSIVE, is in progress, unless the read transactions actually read
96-
# any pages modified by the write transaction. But in the legacy journal mode,
97-
# it seems, it's possible to do this read-write locking without table data
98-
# modification at each exclusive lock.
99-
# See https://sqlite.org/lang_transaction.html#deferred_immediate_and_exclusive_transactions
100-
# "MEMORY" journal mode is fine because no actual writes to the are happening in write-lock
101-
# acquire, so crashes cannot adversely affect the DB. Even journal_mode=OFF would probably
102-
# be fine, too, but the SQLite documentation says that ROLLBACK becomes *undefined behaviour*
103-
# with journal_mode=OFF which sounds scarier.
104-
self.con.execute('PRAGMA journal_mode=MEMORY;')
10596

10697
def acquire_read(self, timeout: float = -1, blocking: bool = True) -> AcquireReturnProxy:
10798
"""Acquire a read lock. If a lock is already held, it must be a read lock.
@@ -119,8 +110,6 @@ def acquire_read(self, timeout: float = -1, blocking: bool = True) -> AcquireRet
119110
self._lock_level += 1
120111
return AcquireReturnProxy(lock=self)
121112

122-
timeout_ms = timeout_for_sqlite(timeout, blocking)
123-
124113
start_time = time.perf_counter()
125114
# Acquire the transaction lock so that the (possibly blocking) SQLite work
126115
# happens without conflicting with other threads' transaction work.
@@ -140,8 +129,31 @@ def acquire_read(self, timeout: float = -1, blocking: bool = True) -> AcquireRet
140129

141130
waited = time.perf_counter() - start_time
142131
timeout_ms = timeout_for_sqlite(timeout, blocking, waited)
143-
144-
self.con.execute('PRAGMA busy_timeout=?;', (timeout_ms,))
132+
self.con.execute('PRAGMA busy_timeout=%d;' % timeout_ms)
133+
# WHY journal_mode=MEMORY?
134+
# Using the legacy journal mode rather than more modern WAL mode because,
135+
# apparently, in WAL mode it's impossible to enforce that read transactions
136+
# (started with BEGIN TRANSACTION) are blocked if a concurrent write transaction,
137+
# even EXCLUSIVE, is in progress, unless the read transactions actually read
138+
# any pages modified by the write transaction. But in the legacy journal mode,
139+
# it seems, it's possible to do this read-write locking without table data
140+
# modification at each exclusive lock.
141+
# See https://sqlite.org/lang_transaction.html#deferred_immediate_and_exclusive_transactions
142+
# "MEMORY" journal mode is fine because no actual writes to the are happening in write-lock
143+
# acquire, so crashes cannot adversely affect the DB. Even journal_mode=OFF would probably
144+
# be fine, too, but the SQLite documentation says that ROLLBACK becomes *undefined behaviour*
145+
# with journal_mode=OFF which sounds scarier.
146+
#
147+
# WHY SETTING THIS PRAGMA HERE RATHER THAN IN ReadWriteLock.__init__()?
148+
# Because setting this pragma may block on the database if it is locked at the moment,
149+
# so we must set this pragma *after* `PRAGMA busy_timeout` above.
150+
self.con.execute('PRAGMA journal_mode=MEMORY;')
151+
# Recompute the remaining timeout after the potentially blocking pragma
152+
# statement above.
153+
waited = time.perf_counter() - start_time
154+
timeout_ms_2 = timeout_for_sqlite(timeout, blocking, waited)
155+
if timeout_ms_2 != timeout_ms:
156+
self.con.execute('PRAGMA busy_timeout=%d;' % timeout_ms_2)
145157
self.con.execute('BEGIN TRANSACTION;')
146158
# Need to make SELECT to compel SQLite to actually acquire a SHARED db lock.
147159
# See https://www.sqlite.org/lockingv3.html#transaction_control
@@ -194,8 +206,17 @@ def acquire_write(self, timeout: float = -1, blocking: bool = True) -> AcquireRe
194206

195207
waited = time.perf_counter() - start_time
196208
timeout_ms = timeout_for_sqlite(timeout, blocking, waited)
197-
198-
self.con.execute('PRAGMA busy_timeout=?;', (timeout_ms,))
209+
self.con.execute('PRAGMA busy_timeout=%d;' % timeout_ms)
210+
# For explanations for both why we use journal_mode=MEMORY and why we set
211+
# this pragma here rather than in ReadWriteLock.__init__(), see the comments
212+
# in acquire_read().
213+
self.con.execute('PRAGMA journal_mode=MEMORY;')
214+
# Recompute the remaining timeout after the potentially blocking pragma
215+
# statement above.
216+
waited = time.perf_counter() - start_time
217+
timeout_ms_2 = timeout_for_sqlite(timeout, blocking, waited)
218+
if timeout_ms_2 != timeout_ms:
219+
self.con.execute('PRAGMA busy_timeout=%d;' % timeout_ms_2)
199220
self.con.execute('BEGIN EXCLUSIVE TRANSACTION;')
200221

201222
with self._internal_lock:
@@ -206,7 +227,7 @@ def acquire_write(self, timeout: float = -1, blocking: bool = True) -> AcquireRe
206227

207228
except sqlite3.OperationalError as e:
208229
if 'database is locked' not in str(e):
209-
raise # Re-raise if it is an unexpected error.
230+
raise e # Re-raise unexpected errors.
210231
raise Timeout(self.lock_file)
211232
finally:
212233
self._transaction_lock.release()
@@ -226,7 +247,7 @@ def release(self, force: bool = False) -> None:
226247
self._current_mode = None
227248
# Unless there are bugs in this code, sqlite3.ProgrammingError
228249
# must not be raise here, that is, the transaction should have been
229-
# started in acquire().
250+
# started in acquire_read() or acquire_write().
230251
self.con.rollback()
231252

232253
# ----- Context Manager Protocol -----

0 commit comments

Comments
 (0)