Skip to content

Commit 7ecd342

Browse files
Erlend Egeberg Aaslandberkerpeksag
Erlend Egeberg Aasland
andauthored
bpo-27334: roll back transaction if sqlite3 context manager fails to commit (GH-26202)
Co-authored-by: Luca Citi Co-authored-by: Berker Peksag <[email protected]>
1 parent 3df0fc8 commit 7ecd342

File tree

3 files changed

+102
-8
lines changed

3 files changed

+102
-8
lines changed

Lib/sqlite3/test/dbapi.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,17 @@
2222

2323
import contextlib
2424
import sqlite3 as sqlite
25+
import subprocess
2526
import sys
2627
import threading
2728
import unittest
2829

29-
from test.support import check_disallow_instantiation, threading_helper, bigmemtest
30+
from test.support import (
31+
bigmemtest,
32+
check_disallow_instantiation,
33+
threading_helper,
34+
SHORT_TIMEOUT,
35+
)
3036
from test.support.os_helper import TESTFN, unlink
3137

3238

@@ -986,6 +992,77 @@ def test_on_conflict_replace(self):
986992
self.assertEqual(self.cu.fetchall(), [('Very different data!', 'foo')])
987993

988994

995+
class MultiprocessTests(unittest.TestCase):
996+
CONNECTION_TIMEOUT = SHORT_TIMEOUT / 1000. # Defaults to 30 ms
997+
998+
def tearDown(self):
999+
unlink(TESTFN)
1000+
1001+
def test_ctx_mgr_rollback_if_commit_failed(self):
1002+
# bpo-27334: ctx manager does not rollback if commit fails
1003+
SCRIPT = f"""if 1:
1004+
import sqlite3
1005+
def wait():
1006+
print("started")
1007+
assert "database is locked" in input()
1008+
1009+
cx = sqlite3.connect("{TESTFN}", timeout={self.CONNECTION_TIMEOUT})
1010+
cx.create_function("wait", 0, wait)
1011+
with cx:
1012+
cx.execute("create table t(t)")
1013+
try:
1014+
# execute two transactions; both will try to lock the db
1015+
cx.executescript('''
1016+
-- start a transaction and wait for parent
1017+
begin transaction;
1018+
select * from t;
1019+
select wait();
1020+
rollback;
1021+
1022+
-- start a new transaction; would fail if parent holds lock
1023+
begin transaction;
1024+
select * from t;
1025+
rollback;
1026+
''')
1027+
finally:
1028+
cx.close()
1029+
"""
1030+
1031+
# spawn child process
1032+
proc = subprocess.Popen(
1033+
[sys.executable, "-c", SCRIPT],
1034+
encoding="utf-8",
1035+
bufsize=0,
1036+
stdin=subprocess.PIPE,
1037+
stdout=subprocess.PIPE,
1038+
)
1039+
self.addCleanup(proc.communicate)
1040+
1041+
# wait for child process to start
1042+
self.assertEqual("started", proc.stdout.readline().strip())
1043+
1044+
cx = sqlite.connect(TESTFN, timeout=self.CONNECTION_TIMEOUT)
1045+
try: # context manager should correctly release the db lock
1046+
with cx:
1047+
cx.execute("insert into t values('test')")
1048+
except sqlite.OperationalError as exc:
1049+
proc.stdin.write(str(exc))
1050+
else:
1051+
proc.stdin.write("no error")
1052+
finally:
1053+
cx.close()
1054+
1055+
# terminate child process
1056+
self.assertIsNone(proc.returncode)
1057+
try:
1058+
proc.communicate(input="end", timeout=SHORT_TIMEOUT)
1059+
except subprocess.TimeoutExpired:
1060+
proc.kill()
1061+
proc.communicate()
1062+
raise
1063+
self.assertEqual(proc.returncode, 0)
1064+
1065+
9891066
def suite():
9901067
tests = [
9911068
ClosedConTests,
@@ -995,6 +1072,7 @@ def suite():
9951072
CursorTests,
9961073
ExtensionTests,
9971074
ModuleTests,
1075+
MultiprocessTests,
9981076
OpenTests,
9991077
SqliteOnConflictTests,
10001078
ThreadTests,
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
The :mod:`sqlite3` context manager now performs a rollback (thus releasing the
2+
database lock) if commit failed. Patch by Luca Citi and Erlend E. Aasland.

Modules/_sqlite/connection.c

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1785,17 +1785,31 @@ pysqlite_connection_exit_impl(pysqlite_Connection *self, PyObject *exc_type,
17851785
PyObject *exc_value, PyObject *exc_tb)
17861786
/*[clinic end generated code: output=0705200e9321202a input=bd66f1532c9c54a7]*/
17871787
{
1788-
const char* method_name;
1788+
int commit = 0;
17891789
PyObject* result;
17901790

17911791
if (exc_type == Py_None && exc_value == Py_None && exc_tb == Py_None) {
1792-
method_name = "commit";
1793-
} else {
1794-
method_name = "rollback";
1792+
commit = 1;
1793+
result = pysqlite_connection_commit_impl(self);
17951794
}
1796-
1797-
result = PyObject_CallMethod((PyObject*)self, method_name, NULL);
1798-
if (!result) {
1795+
else {
1796+
result = pysqlite_connection_rollback_impl(self);
1797+
}
1798+
1799+
if (result == NULL) {
1800+
if (commit) {
1801+
/* Commit failed; try to rollback in order to unlock the database.
1802+
* If rollback also fails, chain the exceptions. */
1803+
PyObject *exc, *val, *tb;
1804+
PyErr_Fetch(&exc, &val, &tb);
1805+
result = pysqlite_connection_rollback_impl(self);
1806+
if (result == NULL) {
1807+
_PyErr_ChainExceptions(exc, val, tb);
1808+
}
1809+
else {
1810+
PyErr_Restore(exc, val, tb);
1811+
}
1812+
}
17991813
return NULL;
18001814
}
18011815
Py_DECREF(result);

0 commit comments

Comments
 (0)