Skip to content

Commit 5e72c55

Browse files
JelteFFennemAI
andauthored
Retry home and commitfest views on serialization failures (#125)
When two requests to the `home` or `commitfest` views race during a commitfest rollover, the lazy commitfest refresh makes one of them fail with "could not serialize access due to concurrent update", because these views run at REPEATABLE READ. That resulted in a 500 for the user. Now these views are retried once in a fresh transaction on serialization failures and deadlocks. At that point the other request has already committed the refresh, so there's nothing left to conflict with. --------- Co-authored-by: Jelterminator FennemAI-Neo <fennemai@jeltef.nl>
1 parent 7c3a2f4 commit 5e72c55

2 files changed

Lines changed: 87 additions & 1 deletion

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from django.db import OperationalError
2+
3+
import pytest
4+
5+
from pgcommitfest.commitfest.views import retry_on_serialization_failure
6+
7+
8+
def _pg_error(pgcode):
9+
# Django re-raises psycopg2 errors with ``from``, so the SQLSTATE is only
10+
# reachable through ``__cause__.pgcode``, which is what the decorator reads.
11+
cause = Exception("underlying psycopg2 error")
12+
cause.pgcode = pgcode
13+
error = OperationalError("could not serialize access due to concurrent update")
14+
error.__cause__ = cause
15+
return error
16+
17+
18+
@pytest.mark.parametrize(
19+
"pgcode,failures,expected_calls",
20+
[
21+
("40001", 1, 2), # serialization failure, recovers
22+
("40P01", 1, 2), # deadlock, recovers
23+
("40001", 99, 2), # persistent failure, gives up after one retry
24+
("57014", 99, 1), # query_canceled is not transient, no retry
25+
],
26+
)
27+
def test_retry_on_serialization_failure(pgcode, failures, expected_calls):
28+
calls = []
29+
30+
@retry_on_serialization_failure
31+
def view():
32+
calls.append(1)
33+
if len(calls) <= failures:
34+
raise _pg_error(pgcode)
35+
return "ok"
36+
37+
if failures >= expected_calls:
38+
with pytest.raises(OperationalError):
39+
view()
40+
else:
41+
assert view() == "ok"
42+
assert len(calls) == expected_calls

‎pgcommitfest/commitfest/views.py‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from django.contrib import messages
33
from django.contrib.auth.decorators import login_required
44
from django.contrib.auth.models import User
5-
from django.db import connection, transaction
5+
from django.db import OperationalError, connection, transaction
66
from django.db.models import Count, Q
77
from django.http import (
88
Http404,
@@ -15,6 +15,7 @@
1515
from django.views.decorators.csrf import csrf_exempt
1616

1717
import collections
18+
import functools
1819
import hmac
1920
import json
2021
import urllib
@@ -48,6 +49,47 @@
4849
UserInputError,
4950
)
5051

52+
# PostgreSQL SQLSTATEs for transient conflicts that are safe to retry once the
53+
# aborted transaction has been rolled back: serialization_failure and
54+
# deadlock_detected.
55+
RETRYABLE_PGCODES = {"40001", "40P01"}
56+
57+
58+
def retry_on_serialization_failure(view_func):
59+
"""Re-run a view when its transaction hits a serialization failure.
60+
61+
A few views (home, commitfest) run the whole request at REPEATABLE READ so
62+
that all of their queries observe a single consistent snapshot. Rendering
63+
also triggers the lazy, roughly-once-a-month commitfest refresh in
64+
CommitFest.relevant_commitfests(), which locks and updates rows with
65+
SELECT ... FOR UPDATE. When two such requests race during a rollover, the
66+
one that loses the race cannot re-read the newer row under REPEATABLE READ,
67+
so PostgreSQL aborts it with "could not serialize access due to concurrent
68+
update" (SQLSTATE 40001) instead of blocking and continuing.
69+
70+
Once that happens the transaction is dead, so the only way to recover is to
71+
roll it back and run the view again from scratch. That is why this decorator
72+
has to sit *outside* transaction.atomic: each attempt then runs in a brand
73+
new transaction with a fresh snapshot. By the time we retry, the request
74+
that won the race has already committed the refresh, so the cheap
75+
up-to-date check short-circuits and the retry neither writes nor conflicts.
76+
"""
77+
78+
@functools.wraps(view_func)
79+
def wrapper(*args, **kwargs):
80+
try:
81+
return view_func(*args, **kwargs)
82+
except OperationalError as e:
83+
if getattr(e.__cause__, "pgcode", None) not in RETRYABLE_PGCODES:
84+
raise
85+
# A single retry is enough: the refresh only writes once per rollover,
86+
# so after the winning request commits there is nothing left to
87+
# conflict with. If the retry fails anyway, something else is going on
88+
# and we'd rather surface the error than keep hammering the database.
89+
return view_func(*args, **kwargs)
90+
91+
return wrapper
92+
5193

5294
def get_tags_data():
5395
"""Generate JSON data for enhanced selectize dropdown with tag colors and descriptions."""
@@ -64,6 +106,7 @@ def get_tags_data():
64106
)
65107

66108

109+
@retry_on_serialization_failure
67110
@transaction.atomic
68111
def home(request):
69112
curs = connection.cursor()
@@ -619,6 +662,7 @@ def patchlist(request, cf, personalized=False):
619662
)
620663

621664

665+
@retry_on_serialization_failure
622666
@transaction.atomic
623667
def commitfest(request, cfid):
624668
curs = connection.cursor()

0 commit comments

Comments
 (0)