Skip to content

Commit 1f6cde4

Browse files
roll back a read acquire's open transaction when its SELECT fails (#575)
Co-authored-by: Bernát Gábor <gaborjbernat@gmail.com>
1 parent c76dee6 commit 1f6cde4

2 files changed

Lines changed: 85 additions & 4 deletions

File tree

src/filelock/_read_write.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -220,10 +220,19 @@ def _acquire(self, mode: Literal["read", "write"], timeout: float, *, blocking:
220220
self._acquire_transaction_lock(blocking=blocking, timeout=timeout)
221221
try:
222222
return self._do_acquire_inner(mode, timeout, blocking=blocking, start_time=start_time)
223-
except sqlite3.OperationalError as exc:
224-
if "database is locked" not in str(exc):
225-
raise
226-
raise Timeout(self.lock_file) from None
223+
except sqlite3.Error as exc:
224+
# A read acquire runs BEGIN (a deferred transaction that takes no database lock) and only then the
225+
# SELECT that actually takes the SHARED lock. If a writer grabs the EXCLUSIVE lock between the two,
226+
# the SELECT fails but BEGIN's transaction is left open on the shared connection. Roll it back here,
227+
# while we still hold _transaction_lock, otherwise the next acquire's BEGIN dies with "cannot start a
228+
# transaction within a transaction" and the instance is wedged for good. Catch only sqlite3.Error: a
229+
# reentrant-validation RuntimeError from _do_acquire_inner's double-check must not roll back, or it
230+
# would drop a transaction another thread legitimately holds on the shared connection.
231+
with suppress(sqlite3.Error):
232+
self._con.rollback()
233+
if isinstance(exc, sqlite3.OperationalError) and "database is locked" in str(exc):
234+
raise Timeout(self.lock_file) from None
235+
raise
227236
finally:
228237
self._transaction_lock.release()
229238

tests/test_read_write_unit.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,78 @@ def acquirer() -> None:
624624
lock.release()
625625

626626

627+
def test_read_acquire_rolls_back_begin_when_select_loses_lock_race(lock_file: str) -> None:
628+
# A read acquire runs BEGIN (a deferred transaction that takes no lock) and only then the SELECT that takes
629+
# the SHARED lock. If a writer grabs EXCLUSIVE between the two, the SELECT fails but BEGIN's transaction is
630+
# left open on the shared connection; without a rollback the next acquire's BEGIN dies with "cannot start a
631+
# transaction within a transaction" and the instance is wedged. Force that interleaving and confirm the
632+
# connection is rolled back and the instance still works.
633+
reader = ReadWriteLock(lock_file, is_singleton=False)
634+
writer = ReadWriteLock(lock_file, is_singleton=False)
635+
real_con = reader._con
636+
637+
class _RaceCon:
638+
def execute(self, sql: str) -> object: # noqa: PLR6301
639+
if sql.lstrip().upper().startswith("SELECT") and not writer._con.in_transaction:
640+
writer.acquire_write() # writer slips in between the reader's BEGIN and SELECT
641+
return real_con.execute(sql)
642+
643+
def __getattr__(self, name: str) -> object:
644+
return getattr(real_con, name)
645+
646+
reader._con = _RaceCon() # ty: ignore[invalid-assignment]
647+
with pytest.raises(Timeout):
648+
reader.acquire_read(timeout=0.3)
649+
reader._con = real_con
650+
assert real_con.in_transaction is False
651+
652+
writer.release()
653+
with reader.read_lock(timeout=1):
654+
assert reader._current_mode == "read"
655+
reader.close()
656+
writer.close()
657+
658+
659+
def test_failed_reentrant_double_check_keeps_a_concurrent_holders_lock(lock_file: str) -> None:
660+
# The acquire-failure handler must not roll back on a reentrant-validation RuntimeError: a writer that reaches
661+
# _do_acquire_inner's double-check after a reader took the lock would otherwise roll back the reader's still-open
662+
# transaction on the shared connection, dropping a lock the reader believes it holds. Force that interleaving.
663+
lock = ReadWriteLock(lock_file, is_singleton=False)
664+
writer_at_gate = threading.Event()
665+
reader_acquired = threading.Event()
666+
real_acquire_tx = lock._acquire_transaction_lock
667+
writer_tid: dict[str, int] = {}
668+
errors: dict[str, RuntimeError] = {}
669+
670+
def gated(*, blocking: bool, timeout: float) -> None:
671+
if threading.get_ident() == writer_tid.get("id"): # writer has passed its early check at lock_level == 0
672+
writer_at_gate.set()
673+
reader_acquired.wait(5) # let the reader fully acquire (and open its transaction) first
674+
real_acquire_tx(blocking=blocking, timeout=timeout)
675+
676+
def acquire_write_in_thread() -> None:
677+
writer_tid["id"] = threading.get_ident()
678+
try:
679+
lock.acquire_write(timeout=5)
680+
except RuntimeError as exc:
681+
errors["writer"] = exc
682+
683+
lock._acquire_transaction_lock = gated # ty: ignore[invalid-assignment]
684+
thread = threading.Thread(target=acquire_write_in_thread)
685+
thread.start()
686+
assert writer_at_gate.wait(5)
687+
lock.acquire_read(timeout=5) # reader opens the SHARED transaction on the shared connection
688+
reader_acquired.set()
689+
thread.join(5)
690+
691+
assert "writer" in errors # the upgrade was rejected with RuntimeError, as expected
692+
assert lock._con.in_transaction is True # the reader's transaction survived the writer's failure
693+
assert lock._lock_level == 1
694+
assert lock._current_mode == "read"
695+
lock.release()
696+
lock.close()
697+
698+
627699
def test_pytest_session_finish_calls_cleanup(mocker: MockerFixture) -> None:
628700
mock = mocker.patch("conftest._cleanup_connections")
629701
pytest_sessionfinish()

0 commit comments

Comments
 (0)