fix: handle snapshot isolation errors better (#32318)

* fix: Avoid Snapshot violation

- Main thread created and "read" user
- Other thread modified something
- Main thread wants to delete or "write" to same row.

This violates snapshot isolation.

* fix: treat snapshot violation as deadlock for now

* test: handle snapshot violations
This commit is contained in:
Ankush Menat 2025-04-28 11:48:38 +05:30 committed by GitHub
parent 0535a2f1f6
commit 2dfb96f91c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 21 additions and 4 deletions

View file

@ -496,6 +496,7 @@ def test_user(
user.append_roles(*roles)
user.insert()
yield user
commit and frappe.db.commit()
finally:
user.delete(force=True, ignore_permissions=True)
commit and frappe.db.commit()

View file

@ -25,7 +25,8 @@ class MariaDBExceptionUtil:
@staticmethod
def is_deadlocked(e: pymysql.Error) -> bool:
return e.args[0] == ER.LOCK_DEADLOCK
# Snapshot isolation is also treated as deadlock from User POV
return e.args[0] in (ER.LOCK_DEADLOCK, ER.CHECKREAD)
@staticmethod
def is_timedout(e: pymysql.Error) -> bool:

View file

@ -28,7 +28,8 @@ class MariaDBExceptionUtil:
@staticmethod
def is_deadlocked(e: MySQLdb.Error) -> bool:
return e.args[0] == ER.LOCK_DEADLOCK
# Snapshot isolation is also treated as deadlock from User POV
return e.args[0] in (ER.LOCK_DEADLOCK, ER.CHECKREAD)
@staticmethod
def is_timedout(e: MySQLdb.Error) -> bool:

View file

@ -137,6 +137,8 @@ class TestConnectedApp(IntegrationTestCase):
if doc:
doc.delete(force=True)
frappe.db.commit() # Avoid snapshot violation issues
delete_if_exists("token_cache")
delete_if_exists("connected_app")

View file

@ -107,7 +107,7 @@ def delete_doc(
# Lock the doc without waiting
try:
frappe.db.get_value(doctype, name, for_update=True, wait=False)
except frappe.QueryTimeoutError:
except (frappe.QueryTimeoutError, frappe.QueryDeadlockError):
frappe.throw(
_(
"This document can not be deleted right now as it's being modified by another user. Please try again after some time."

View file

@ -251,7 +251,9 @@ frappe.request.call = function (opts) {
frappe.msgprint({
title: __("Deadlock Occurred"),
indicator: "red",
message: __("Server was too busy to process this request. Please try again."),
message: __(
"Server failed to process this request because of a concurrent conflicting request. Please try again."
),
});
},
};

View file

@ -130,6 +130,10 @@ class FrappeAPITestCase(IntegrationTestCase):
def delete(self, path, **kwargs) -> TestResponse:
return make_request(target=self.TEST_CLIENT.delete, args=(path,), kwargs=kwargs)
def tearDown(self) -> None:
frappe.db.rollback()
return super().tearDown()
class TestResourceAPI(FrappeAPITestCase):
DOCTYPE = "ToDo"
@ -146,6 +150,7 @@ class TestResourceAPI(FrappeAPITestCase):
@classmethod
def tearDownClass(cls):
frappe.db.commit()
for name in cls.GENERATED_DOCUMENTS:
frappe.delete_doc_if_exists(cls.DOCTYPE, name)
frappe.db.commit()

View file

@ -33,6 +33,7 @@ class TestResourceAPIV2(FrappeAPITestCase):
@classmethod
def tearDownClass(cls):
frappe.db.commit()
for name in cls.GENERATED_DOCUMENTS:
frappe.delete_doc_if_exists(cls.DOCTYPE, name)
frappe.db.commit()

View file

@ -49,10 +49,12 @@ class TestAuth(IntegrationTestCase):
@classmethod
def tearDownClass(cls):
frappe.db.rollback()
frappe.delete_doc("User", cls.test_user_email, force=True)
frappe.local.request_ip = None
frappe.form_dict.email = None
frappe.local.response["http_status_code"] = None
frappe.db.commit()
def set_system_settings(self, k, v):
frappe.db.set_single_value("System Settings", k, v)

View file

@ -105,7 +105,9 @@ class TestFrappeClient(IntegrationTestCase):
self.assertEqual(
server.get_value("Website Settings", "title_prefix").get("title_prefix"), "test-prefix"
)
frappe.db.rollback() # Clear snapshot isolation
frappe.db.set_single_value("Website Settings", "title_prefix", "")
frappe.db.commit()
def test_update_doc(self):
server = FrappeClient(get_url(), "Administrator", self.PASSWORD, verify=False)