Skip to content

[3.10] bpo-27334: roll back transaction if sqlite3 context manager fails to commit (GH-26202) #27943

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Aug 28, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 74 additions & 1 deletion Lib/sqlite3/test/dbapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@
# misrepresented as being the original software.
# 3. This notice may not be removed or altered from any source distribution.

import subprocess
import threading
import unittest
import sqlite3 as sqlite
import sys

from test.support import check_disallow_instantiation
from test.support import check_disallow_instantiation, SHORT_TIMEOUT
from test.support.os_helper import TESTFN, unlink


Expand Down Expand Up @@ -958,6 +959,77 @@ def test_on_conflict_replace(self):
self.assertEqual(self.cu.fetchall(), [('Very different data!', 'foo')])


class MultiprocessTests(unittest.TestCase):
CONNECTION_TIMEOUT = SHORT_TIMEOUT / 1000. # Defaults to 30 ms

def tearDown(self):
unlink(TESTFN)

def test_ctx_mgr_rollback_if_commit_failed(self):
# bpo-27334: ctx manager does not rollback if commit fails
SCRIPT = f"""if 1:
import sqlite3
def wait():
print("started")
assert "database is locked" in input()

cx = sqlite3.connect("{TESTFN}", timeout={self.CONNECTION_TIMEOUT})
cx.create_function("wait", 0, wait)
with cx:
cx.execute("create table t(t)")
try:
# execute two transactions; both will try to lock the db
cx.executescript('''
-- start a transaction and wait for parent
begin transaction;
select * from t;
select wait();
rollback;

-- start a new transaction; would fail if parent holds lock
begin transaction;
select * from t;
rollback;
''')
finally:
cx.close()
"""

# spawn child process
proc = subprocess.Popen(
[sys.executable, "-c", SCRIPT],
encoding="utf-8",
bufsize=0,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
)
self.addCleanup(proc.communicate)

# wait for child process to start
self.assertEqual("started", proc.stdout.readline().strip())

cx = sqlite.connect(TESTFN, timeout=self.CONNECTION_TIMEOUT)
try: # context manager should correctly release the db lock
with cx:
cx.execute("insert into t values('test')")
except sqlite.OperationalError as exc:
proc.stdin.write(str(exc))
else:
proc.stdin.write("no error")
finally:
cx.close()

# terminate child process
self.assertIsNone(proc.returncode)
try:
proc.communicate(input="end", timeout=SHORT_TIMEOUT)
except subprocess.TimeoutExpired:
proc.kill()
proc.communicate()
raise
self.assertEqual(proc.returncode, 0)


def suite():
tests = [
ClosedConTests,
Expand All @@ -967,6 +1039,7 @@ def suite():
CursorTests,
ExtensionTests,
ModuleTests,
MultiprocessTests,
SqliteOnConflictTests,
ThreadTests,
UninitialisedConnectionTests,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
The :mod:`sqlite3` context manager now performs a rollback (thus releasing the
database lock) if commit failed. Patch by Luca Citi and Erlend E. Aasland.
29 changes: 22 additions & 7 deletions Modules/_sqlite/connection.c
Original file line number Diff line number Diff line change
Expand Up @@ -1871,17 +1871,32 @@ pysqlite_connection_exit_impl(pysqlite_Connection *self, PyObject *exc_type,
PyObject *exc_value, PyObject *exc_tb)
/*[clinic end generated code: output=0705200e9321202a input=bd66f1532c9c54a7]*/
{
const char* method_name;
int commit = 0;
PyObject* result;

if (exc_type == Py_None && exc_value == Py_None && exc_tb == Py_None) {
method_name = "commit";
} else {
method_name = "rollback";
commit = 1;
result = pysqlite_connection_commit_impl(self);
}

result = PyObject_CallMethod((PyObject*)self, method_name, NULL);
if (!result) {
else {
result = pysqlite_connection_rollback_impl(self);
}

if (result == NULL) {
if (commit) {
/* Commit failed; try to rollback in order to unlock the database.
* If rollback also fails, chain the exceptions. */
PyObject *exc, *val, *tb;
PyErr_Fetch(&exc, &val, &tb);
result = pysqlite_connection_rollback_impl(self);
if (result == NULL) {
_PyErr_ChainExceptions(exc, val, tb);
}
else {
Py_DECREF(result);
PyErr_Restore(exc, val, tb);
}
}
return NULL;
}
Py_DECREF(result);
Expand Down