Skip to content

Commit 17f58a1

Browse files
committed
SQLite-based read-write lock
1 parent 468ba43 commit 17f58a1

File tree

1 file changed

+74
-0
lines changed

1 file changed

+74
-0
lines changed

src/filelock/_read_write.py

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import os
2+
import sqlite3
3+
import threading
4+
5+
from _error import Timeout
6+
from filelock._api import BaseFileLock
7+
8+
class _SQLiteLock(BaseFileLock):
9+
def __init__(self, lock_file: str | os.PathLike[str], timeout: float = -1, blocking: bool = True):
10+
super().__init__(lock_file, timeout, blocking)
11+
self.procLock = threading.Lock()
12+
self.con = sqlite3.connect(self._context.lock_file, check_same_thread=False)
13+
# Redundant unless there are "rogue" processes that open the db
14+
# and switch the the db to journal_mode=WAL.
15+
# Using the legacy journal mode rather than more modern WAL mode because,
16+
# apparently, in WAL mode it's impossible to enforce that read transactions
17+
# (started with BEGIN TRANSACTION) are blocked if a concurrent write transaction,
18+
# even EXCLUSIVE, is in progress, unless the read transactions actually read
19+
# any pages modified by the write transaction. But in the legacy journal mode,
20+
# it seems, it's possible to do this read-write locking without table data
21+
# modification at each exclusive lock.
22+
# See https://sqlite.org/lang_transaction.html#deferred_immediate_and_exclusive_transactions
23+
self.con.execute('PRAGMA journal_mode=DELETE;')
24+
self.cur = None
25+
26+
def _release(self):
27+
with self.procLock:
28+
if self.cur is None:
29+
return # Nothing to release
30+
try:
31+
self.cur.execute('ROLLBACK TRANSACTION;')
32+
except sqlite3.ProgrammingError:
33+
pass # Already rolled back or transaction not active
34+
finally:
35+
self.cur.close()
36+
self.cur = None
37+
38+
class WriteLock(_SQLiteLock):
39+
def _acquire(self) -> None:
40+
timeout_ms = int(self._context.timeout*1000) if self._context.blocking else 0
41+
with self.procLock:
42+
if self.cur is not None:
43+
return
44+
self.con.execute('PRAGMA busy_timeout=?;', (timeout_ms,))
45+
try:
46+
self.cur = self.con.execute('BEGIN EXCLUSIVE TRANSACTION;')
47+
except sqlite3.OperationalError as e:
48+
if 'database is locked' not in str(e):
49+
raise # Re-raise unexpected errors
50+
raise Timeout(self._context.lock_file)
51+
52+
class ReadLock(_SQLiteLock):
53+
def _acquire(self):
54+
timeout_ms = int(self._context.timeout * 1000) if self._context.blocking else 0
55+
with self.procLock:
56+
if self.cur is not None:
57+
return
58+
self.con.execute('PRAGMA busy_timeout=?;', (timeout_ms,))
59+
cur = None # Initialize cur to avoid potential UnboundLocalError
60+
try:
61+
cur = self.con.execute('BEGIN TRANSACTION;')
62+
# BEGIN doesn't itself acquire a SHARED lock on the db, that is needed for
63+
# effective exclusion with writeLock(). A SELECT is needed.
64+
cur.execute('SELECT name from sqlite_schema LIMIT 1;')
65+
self.cur = cur
66+
except sqlite3.OperationalError as e:
67+
if 'database is locked' not in str(e):
68+
raise # Re-raise unexpected errors
69+
if cur is not None:
70+
cur.close()
71+
raise Timeout(self._context.lock_file)
72+
73+
74+

0 commit comments

Comments
 (0)