orchestrata_ultimate.core.graph.retry
Reference › orchestrata_ultimate › core › graph › retry
A bounded, jittered retry for write conflicts in the operational graph. Neo4j blocks writers on locks, but Memgraph's MVCC engine aborts one of two conflicting writers with a transient error, so code that worked unchanged on Neo4j needs a retry against orchgraph (migration plan phase 2, step 2.1). Both Bolt adapters (bolt_sync and bolt_async) use it, and the knowledge-graph client folds is_mvcc_conflict_message into its own transient-error check.
is_mvcc_conflict_message and is_mvcc_conflict recognise a conflict by substrings of the message. The marker list deliberately includes a unique-constraint commit failure, because a concurrent MERGE race can fail that way and re-running the statement is correct. A permanent-failure guard keeps genuinely permanent errors out of the retry budget. retry_on_mvcc_conflict and retry_on_mvcc_conflict_async call a function up to MAX_ATTEMPTS (8, raised from 5 after a live concurrent-writer test exhausted five attempts) times, sleeping an exponential backoff with full jitter (_backoff_delay, base BASE_DELAY_SECONDS of 0.05). An error that is not a conflict propagates at once; when every attempt fails, the last conflict error is raised.
At a glance
| Source | src/orchestrata_ultimate/core/graph/retry.py |
| Lines of code | 139 |
| Classes | 0 |
| Functions | 5 |
| Imports (internal) | none |
| Imported by | orchestrata_ultimate.core.graph.adapters.bolt_async, orchestrata_ultimate.core.graph.adapters.bolt_sync, orchestrata_ultimate.knowledge_graph.graph_client, orchestrata_ultimate.knowledge_graph.synchronizer, orchestrata_ultimate.semantic_graph.adapters.memgraph, orchestrata_ultimate.session.kg_sync._base |
| Tests that import it | tests/core/graph/test_mvcc_retry.py, tests/core/graph/test_retry.py, tests/generated_core/test_graph_retry_generated.py, tests/session/test_kg_sync_extended.py |
Class diagram
No classes; see Functions.
Dependency diagram
API
Functions
| Function | Signature | Summary |
|---|---|---|
is_mvcc_conflict_message |
is_mvcc_conflict_message(msg: str) -> bool |
Return True if msg looks like a Memgraph MVCC write-conflict abort. |
is_mvcc_conflict |
is_mvcc_conflict(exc: BaseException) -> bool |
Return True if exc looks like a Memgraph MVCC write-conflict abort. |
_backoff_delay |
_backoff_delay(attempt: int) -> float |
Exponential backoff with full jitter. attempt is 0-indexed. |
retry_on_mvcc_conflict |
retry_on_mvcc_conflict(fn: Callable[[], T]) -> T |
Call fn(), retrying up to MAX_ATTEMPTS times on an MVCC abort. |
retry_on_mvcc_conflict_async |
async retry_on_mvcc_conflict_async(fn: Callable[[], Awaitable[T]]) -> T |
Await fn(), retrying up to MAX_ATTEMPTS times on an MVCC abort. |
Source
"""MVCC write-conflict retry for the operational-graph repository seam.
Neo4j's write transactions block on locks; Memgraph's MVCC storage engine
instead ABORTS one of two concurrently-conflicting writers with a transient
error (``Cannot resolve conflicting transactions`` / a ``TransientError``
exception class). Code that ran unmodified against the graph (which simply
serializes the writers) needs a bounded, jittered retry to keep working
unchanged against orchgraph. See
``plans/neo4j-to-orchgraph-migration-plan.md`` Phase 2 Step 2.1.
Used by ``core.graph.adapters.{bolt_sync,bolt_async}`` and by
``knowledge_graph.graph_client.GraphClient._is_transient_error`` (which folds
``is_mvcc_conflict_message`` into its own existing transient-error check
rather than duplicating the marker list).
"""
from __future__ import annotations
import asyncio
import random
import time
from collections.abc import Awaitable, Callable
#: Bounded retry budget. Was 5 (the migration plan's worked example: "5
#: tries, 50ms base, jitter") until 2026-08-05: under the 14-worker gate a
#: live concurrent-writer proof (tests/core/graph/test_mvcc_retry.py)
#: measured a real conflict burning ALL 5 attempts and leaking a
#: TransientError — the absorption contract ("execute_write must absorb
#: every MVCC conflict") is the requirement, the worked example was only a
#: starting point. 8 attempts adds three more jittered sleep tiers —
#: attempts 4/5/6, ceilings 0.8s/1.6s/3.2s (0.05 × 2^n; +5.6s max added
#: wait) — that are paid ONLY while conflicts persist; the
#: _PERMANENT_MARKERS guard below keeps genuinely-permanent failures from
#: ever entering this budget.
MAX_ATTEMPTS = 8
BASE_DELAY_SECONDS = 0.05
# Substrings that identify a Memgraph MVCC serialization abort. Plain
# substring checks (not a regex) mirror the existing
# ``GraphClient._is_transient_error`` convention in this codebase.
#
# "constraint violation" is in this list DELIBERATELY, not an oversight:
# measured live 2026-07-26 with a real uniqueness constraint in place
# (every canonical-schema label carries one), a concurrent MERGE race on
# Memgraph does NOT always surface as "Cannot resolve conflicting
# transactions" — it can equally commit-fail as "Unable to commit due to
# unique constraint violation on :Label(prop)", a ClientError, not a
# TransientError. Retrying is still correct: the losing transaction just
# needs to re-run MERGE and observe the winner's now-committed row. Mirrors
# ``GraphClient._is_transient_error``'s existing "already exists" marker,
# which retries the SAME race's Neo4j-side error shape.
_TRANSIENT_MARKERS = (
"Cannot resolve conflicting transactions",
"TransientError",
"SerializationError",
"constraint violation",
"already exists",
)
# Errors orchgraph/Memgraph reports with a TRANSIENT class that are in fact
# PERMANENT — retrying them can never succeed, it only converts a clear failure
# into a long hang. Measured live 2026-07-26: naming a database that does not
# exist answers `Memgraph.TransientError.MemgraphError` ("Tried to retrieve an
# unknown database"), so the driver's own managed-transaction retry (30s
# default) and this helper's 5 attempts NESTED, turning one misconfigured
# `database=` into minutes of silence per call instead of an immediate error.
_PERMANENT_MARKERS = (
"unknown database",
"Database does not exist",
)
def is_mvcc_conflict_message(msg: str) -> bool:
"""Return True if *msg* looks like a Memgraph MVCC write-conflict abort."""
lowered = msg.lower()
if any(marker.lower() in lowered for marker in _PERMANENT_MARKERS):
return False
return any(marker in msg for marker in _TRANSIENT_MARKERS)
def is_mvcc_conflict(exc: BaseException) -> bool:
"""Return True if *exc* looks like a Memgraph MVCC write-conflict abort.
Checks both the exception's type name (``neo4j.exceptions.TransientError``
and Memgraph-side ``Memgraph.TransientError`` both contain "Transient")
and its message text (covers driver-wrapped ``ClientError`` shapes that
carry the Memgraph error text but not the transient type).
"""
message = str(exc)
if any(marker.lower() in message.lower() for marker in _PERMANENT_MARKERS):
# A transient-CLASSED but permanent failure (see _PERMANENT_MARKERS):
# the type name check below would otherwise retry it forever.
return False
if "Transient" in type(exc).__name__:
return True
return is_mvcc_conflict_message(message)
def _backoff_delay(attempt: int) -> float:
"""Exponential backoff with full jitter. ``attempt`` is 0-indexed."""
ceiling = BASE_DELAY_SECONDS * (2**attempt)
return random.uniform(0, ceiling)
def retry_on_mvcc_conflict[T](fn: Callable[[], T]) -> T:
"""Call ``fn()``, retrying up to ``MAX_ATTEMPTS`` times on an MVCC abort.
Non-MVCC exceptions propagate immediately (no retry). Re-raises the last
MVCC exception if every attempt is exhausted.
"""
last_exc: BaseException | None = None
for attempt in range(MAX_ATTEMPTS):
try:
return fn()
except Exception as exc:
if not is_mvcc_conflict(exc):
raise
last_exc = exc
if attempt < MAX_ATTEMPTS - 1:
time.sleep(_backoff_delay(attempt))
assert last_exc is not None
raise last_exc
async def retry_on_mvcc_conflict_async[T](fn: Callable[[], Awaitable[T]]) -> T:
"""Await ``fn()``, retrying up to ``MAX_ATTEMPTS`` times on an MVCC abort."""
last_exc: BaseException | None = None
for attempt in range(MAX_ATTEMPTS):
try:
return await fn()
except Exception as exc:
if not is_mvcc_conflict(exc):
raise
last_exc = exc
if attempt < MAX_ATTEMPTS - 1:
await asyncio.sleep(_backoff_delay(attempt))
assert last_exc is not None
raise last_exc