Retries#
Some failures are worth trying again and most are not. The driver classifies a failure and decides whether, and how long, to wait before another attempt. It does not run the loop for you: only you know whether the work is safe to repeat, and whether it had already written something.
Conflicts are expected#
Two transactions that write the same element at once cannot both succeed. Under REPEATABLE READ or
SERIALIZABLE, and for some concurrent graph writes under any level, the loser fails with SQLSTATE
40001:
>>> import psycopg
>>> dsn = "host=localhost dbname=docs user=agens"
>>> first, second = agensgraph.connect(dsn), agensgraph.connect(dsn)
>>> for c in (first, second):
... c.graph("moviekb"); c.commit()
... c.isolation_level = psycopg.IsolationLevel.REPEATABLE_READ
>>> first.execute_query("MATCH (m:movie {title: 'Inception'}) RETURN m.year").records
[(2010,)]
>>> second.execute_query("MATCH (m:movie {title: 'Inception'}) SET m.seen = 1")
Result(records=[], ...)
>>> second.commit()
>>> try:
... first.execute_query("MATCH (m:movie {title: 'Inception'}) SET m.seen = 2")
... except psycopg.Error as exc:
... conflict = exc
>>> conflict.sqlstate, str(conflict)
('40001', 'could not serialize access due to concurrent update')
>>> first.rollback()
That is the server working as designed, not a fault, and running the whole transaction again is the
correct response. The server has no SQLSTATE of its own for graph conflicts, so a graph write
conflict is this same 40001.
Classifying a failure#
retryability(exc) answers with one of six categories, each a different recovery:
>>> from agensgraph import retryability
>>> retryability(conflict)
<Retryability.SAFE: 'safe'>
|
What to do |
For |
|---|---|---|
|
roll back and run it again, on the same connection |
|
|
take a new connection, then run it again |
class |
|
run it again, but wait longer |
|
|
keep the connection, clear the state that went stale |
|
|
find out what happened before doing anything |
|
|
do not retry: it will fail the same way |
everything else, including syntax, data and constraint errors, |
The categories are richer than a yes or no because the recoveries differ, and getting one wrong is
worse than not retrying: 26000 looks as though reconnecting fixes it, because a new connection has
no stale statements, but reconnecting hides the cause and it comes back. is_retryable(exc) is the
yes or no, for code that only needs that, and each category has needs_new_connection,
keeps_connection, clears_prepared_statements and wants_longer_delay.
Two arguments change the answer, and both matter:
wrote=Truesays the transaction had already written when it failed. A lost connection is thenUNKNOWNrather thanRECONNECT, because the write may have committed; see below. A conflict staysSAFE, because a conflict means nothing committed.merging=Truesays the statement was aMERGE. Two writers merging the same new element race, and the loser fails with23505(unique violation) or42P07(duplicate label); for a merge that is the same as a conflict, and becomesSAFE.
57014, a cancelled statement, is FATAL whatever cancelled it, your own deadline included: whether
work that ran out of time should run again is your decision, not the driver’s.
A retry loop#
RetryPolicy makes the decisions: whether to retry, and how long to wait first. You keep the loop.
With a pool:
pool = agensgraph.ConnectionPool("host=localhost dbname=docs user=agens", graph="moviekb",
kwargs={"autocommit": True})
pool.open(wait=True)
import time
from agensgraph import Deadline, RetryPolicy
policy = RetryPolicy(attempts=3) # the first try counts: one try and up to two retries
budget = Deadline(10.0)
previous = []
number = 1
while True:
try:
with pool.connection(deadline=budget) as work:
result = work.execute_query(
"MATCH (m:movie {title: %s}) SET m.views = coalesce(m.views, 0) + 1",
("Inception",),
)
policy.succeeded()
break
except Exception as exc:
attempt = policy.decide(exc, number=number, wrote=True, remaining=budget.remaining())
if not attempt.retry:
if number > 1:
raise policy.exhausted(exc, attempts=number, previous=previous)
raise
previous.append(exc)
time.sleep(attempt.delay)
number += 1
decide() returns an Attempt with retry, delay, recovery and a reason in words. It says no
when the failure is not retryable, when this was the last attempt, when the process-wide allowance
below is spent, or when what is left of remaining would not cover the wait and another attempt.
Pass remaining, or a budget that has run out is retried anyway.
exhausted() marks the exception with how it got there and returns it: attempts, the earlier
failures as previous_errors, and (reached max retries: N) added to its message, so a log says
the work was retried rather than tried once:
psycopg.errors.SerializationFailure: could not serialize access due to concurrent update (reached max retries: 3)
In awaiting code the loop is the same, with await asyncio.sleep(attempt.delay).
How long it waits#
The delay is full jitter: a random wait between zero and a ceiling that doubles with each attempt,
from base (0.05 seconds) up to cap (2 seconds). The cap is applied to the ceiling before the
random draw, so it is a real bound; a BACKPRESSURE retry starts from twice the base, waiting
longer without passing the cap. The randomness is what stops many clients that failed together from
retrying together.
One allowance for the whole process#
A count of attempts is not a budget. Four layers each willing to try three times make eighty-one
attempts out of one request, aimed at a server that is already struggling. So every RetryPolicy
draws on one shared allowance, a TokenBucket of 100 tokens per process. A retry spends 14 tokens,
or 5 for BACKPRESSURE, and retrying stops while the bucket is at half or below:
>>> from agensgraph import Retryability, TokenBucket
>>> bucket = TokenBucket(100)
>>> for _ in range(4):
... bucket.spend(Retryability.SAFE)
>>> round(bucket.tokens()), bucket.allows_retry
(44, False)
Time refills it: an empty bucket fills in two minutes (refill= changes that), so retrying resumes
on the timescale of a failover rather than of a request. policy.succeeded() pays a token back, which
recovers it sooner; call it on every success.
The allowance is shared deliberately, and it throttles conflicts too. A service that expects many
conflicts on one hot path can give that path its own: RetryPolicy(bucket=TokenBucket(...)).
Losing a connection while committing#
One failure cannot be retried blind: a COMMIT whose answer never arrived. The write may have
committed or not, and doing it again could apply it twice. The way out is to keep the transaction’s
id, and ask a different connection what became of it:
with conn.transaction():
xid = conn.transaction_id(assign=True) # keep this
conn.execute_query("MATCH (m:movie {title: 'Inception'}) SET m.checked = true")
>>> with agensgraph.connect("host=localhost dbname=docs user=agens", autocommit=True) as other:
... other.resolve_commit(xid)
<CommitOutcome.COMMITTED: 'committed'>
|
Means |
Do |
|---|---|---|
|
it landed |
do not retry |
|
it did not |
retry, as for a conflict |
|
the server has not finished with it |
wait, and ask again |
|
the server no longer knows |
report it; it is not the same as aborted |
outcome.safe_to_retry is true only for ABORTED, and outcome.is_settled for COMMITTED and
ABORTED.
transaction_id(assign=True) gives the transaction an id at once, which costs one small statement;
a transaction that writes gets one anyway. Call it inside the transaction you want to resolve. In the
retry loop above, a lost connection after a write is UNKNOWN because of wrote=True, and that is the
point at which to ask.