Skip to content

Commit 38c5a90

Browse files
Clear savepoint stack unconditionally on COMMIT/END
The autobegin-deferral logic (when an outer untracked SAVEPOINT has set ``_has_untracked_savepoint=True``) deliberately allows ``_savepoint_stack`` to be non-empty while ``_in_transaction`` is False. The COMMIT/END branch was gated on ``_in_transaction``, so a sequence SAVEPOINT "Foo" # untracked → _has_untracked_savepoint=True SAVEPOINT inner # autobegin DEFERRED → stack=["inner"], # _in_transaction=False COMMIT # closes server-side tx but leaves the # ghost frame "inner" in the local stack left ``_savepoint_stack`` populated with stale frames. A subsequent ``RELEASE inner`` would pop the local frame and then the server would raise "no such savepoint", and the pool's connection-return safety predicate (which ORs ``_in_transaction`` and ``_has_untracked_savepoint``) would skip the defensive ROLLBACK, handing the next acquirer stale state. Mirror the symmetric ROLLBACK branch's defensive double-clear: keep ``_in_transaction = False`` and ``_tx_owner = None`` inside the ``if _in_transaction:`` gate (their semantics depend on it), but move ``_savepoint_stack.clear()`` and ``_savepoint_implicit_begin = False`` outside.
1 parent 9e5f9f4 commit 38c5a90

2 files changed

Lines changed: 103 additions & 2 deletions

File tree

src/dqliteclient/connection.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1568,8 +1568,18 @@ def _update_tx_flags_from_sql(self, sql: str) -> None:
15681568
if self._in_transaction:
15691569
self._in_transaction = False
15701570
self._tx_owner = None
1571-
self._savepoint_stack.clear()
1572-
self._savepoint_implicit_begin = False
1571+
# The autobegin-deferral path (when an outer
1572+
# untracked SAVEPOINT had set
1573+
# ``_has_untracked_savepoint=True``) deliberately
1574+
# allows ``stack`` non-empty AND
1575+
# ``_in_transaction=False``. Clear the stack and
1576+
# the implicit-begin flag UNCONDITIONALLY here so a
1577+
# COMMIT that closes the server-side autobegun tx
1578+
# does not leave a ghost frame in the local stack.
1579+
# Mirrors the defensive double-clear the symmetric
1580+
# ROLLBACK branch already performs in both arms.
1581+
self._savepoint_stack.clear()
1582+
self._savepoint_implicit_begin = False
15731583
self._has_untracked_savepoint = False
15741584
return
15751585

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""Pin: COMMIT/END clears the savepoint stack EVEN when
2+
``_in_transaction`` is False.
3+
4+
The earlier autobegin-deferral fix (when an outer untracked
5+
SAVEPOINT had set ``_has_untracked_savepoint=True``) deliberately
6+
allows a state where ``_savepoint_stack`` is non-empty AND
7+
``_in_transaction=False``. The COMMIT/END branch in
8+
``_update_tx_flags_from_sql`` was not updated to match: the
9+
stack-clear is gated on ``_in_transaction``, leaving a ghost frame.
10+
11+
Symmetric ROLLBACK already does the defensive double-clear in both
12+
``if`` and ``else`` arms; COMMIT/END must follow.
13+
14+
Sequence that produces the ghost frame today:
15+
SAVEPOINT "Foo" -> _has_untracked_savepoint=True, stack=[],
16+
_in_transaction=False
17+
SAVEPOINT inner -> stack=["inner"], _in_transaction=False
18+
(autobegin DEFERRED because of untracked)
19+
COMMIT -> _in_transaction=False; stack="inner"
20+
(GHOST), _has_untracked_savepoint=False
21+
22+
User-visible consequences:
23+
1. Subsequent RELEASE inner finds "inner" in the local stack and
24+
pops it, then the server raises "no such savepoint".
25+
2. The pool-return safety predicate (which ORs ``_in_transaction``
26+
and ``_has_untracked_savepoint``) sees neither flag and skips
27+
the defensive ROLLBACK; the next acquirer reuses stale state.
28+
"""
29+
30+
from __future__ import annotations
31+
32+
from dqliteclient import DqliteConnection
33+
34+
35+
def _make_connection() -> DqliteConnection:
36+
"""Connection with no ``connect()`` so the pure-state-machine
37+
code path of ``_update_tx_flags_from_sql`` is exercised."""
38+
return DqliteConnection("127.0.0.1:9001")
39+
40+
41+
def test_commit_clears_savepoint_stack_after_untracked_deferred_autobegin() -> None:
42+
conn = _make_connection()
43+
conn._update_tx_flags_from_sql('SAVEPOINT "Foo"')
44+
assert conn._has_untracked_savepoint is True
45+
assert conn._savepoint_stack == []
46+
assert conn._in_transaction is False
47+
48+
conn._update_tx_flags_from_sql("SAVEPOINT inner")
49+
# Autobegin is deferred because _has_untracked_savepoint=True;
50+
# the stack tracks the inner name even though we're "not" in tx.
51+
assert conn._savepoint_stack == ["inner"]
52+
assert conn._in_transaction is False
53+
54+
conn._update_tx_flags_from_sql("COMMIT")
55+
# The stack must be cleared even though _in_transaction was False.
56+
# Without this, "inner" remains as a ghost frame.
57+
assert conn._savepoint_stack == [], (
58+
"COMMIT must clear ghost savepoint stack frames even when "
59+
"_in_transaction is False (mirrors ROLLBACK's defensive clear)"
60+
)
61+
assert conn._has_untracked_savepoint is False
62+
assert conn._in_transaction is False
63+
assert conn._savepoint_implicit_begin is False
64+
65+
66+
def test_end_clears_savepoint_stack_after_untracked_deferred_autobegin() -> None:
67+
"""END is an alias for COMMIT in SQLite. Same defensive clear
68+
must apply."""
69+
conn = _make_connection()
70+
conn._update_tx_flags_from_sql('SAVEPOINT "Foo"')
71+
conn._update_tx_flags_from_sql("SAVEPOINT inner")
72+
assert conn._savepoint_stack == ["inner"]
73+
74+
conn._update_tx_flags_from_sql("END")
75+
assert conn._savepoint_stack == []
76+
77+
78+
def test_commit_in_active_tx_still_clears_state() -> None:
79+
"""Sanity: the existing in-tx COMMIT path is unchanged."""
80+
conn = _make_connection()
81+
conn._update_tx_flags_from_sql("BEGIN")
82+
conn._update_tx_flags_from_sql("SAVEPOINT a")
83+
assert conn._in_transaction is True
84+
assert conn._savepoint_stack == ["a"]
85+
86+
conn._update_tx_flags_from_sql("COMMIT")
87+
assert conn._in_transaction is False
88+
assert conn._tx_owner is None
89+
assert conn._savepoint_stack == []
90+
assert conn._savepoint_implicit_begin is False
91+
assert conn._has_untracked_savepoint is False

0 commit comments

Comments
 (0)