From 0d184785a3b6f063a220671001011df8079c9e53 Mon Sep 17 00:00:00 2001 From: Ryan Hendrickson Date: Wed, 17 Jun 2026 02:01:30 -0400 Subject: [PATCH 1/2] surfacing transaction errors to clients --- docusaurus-docs/docs/clients/index.md | 29 ++++++++++++++++++ docusaurus-docs/docs/clients/java.md | 38 ++++++++++++++++++++++++ docusaurus-docs/docs/clients/python.md | 31 +++++++++++++++++++ docusaurus-docs/docs/clients/raw-http.md | 22 +++++++++++++- 4 files changed, 119 insertions(+), 1 deletion(-) diff --git a/docusaurus-docs/docs/clients/index.md b/docusaurus-docs/docs/clients/index.md index d024e93d..bb3be994 100644 --- a/docusaurus-docs/docs/clients/index.md +++ b/docusaurus-docs/docs/clients/index.md @@ -52,5 +52,34 @@ transactions conflict when both transactions: When a transaction is aborted, all its changes are discarded. Transactions can be manually aborted. +#### Abort reasons + +A write-write conflict is not the only reason a commit can abort. When Dgraph +aborts a commit it now reports a *category* describing why, so clients can decide +how to react (for example, retry immediately versus back off). The category is +carried as a `": "` prefix on the gRPC `ABORTED` status message +(and on the HTTP error message), where `` is one of: + +- `conflict` — a write-write conflict with another concurrent transaction, as + described above. Retrying the transaction typically succeeds. +- `stale-startts` — the transaction's start timestamp predates the current Zero + leader's lease (for example, after a leader change). Retrying with a fresh + transaction succeeds. +- `predicate-move` — a predicate the transaction wrote is being moved between + groups, so commits on it are temporarily blocked. Retrying once the move + completes succeeds. + +For example, a conflict abort carries the message +`conflict: Transaction has been aborted. Please retry`. + +:::note +Older Dgraph servers do not emit a category prefix. Clients that parse the +reason degrade gracefully and report it as `UNKNOWN` in that case, so existing +error handling keeps working unchanged. +::: + +The official [Java](/clients/java) and [Python](/clients/python) clients parse +this category into a typed enum; see those pages for the API. + ### In this section diff --git a/docusaurus-docs/docs/clients/java.md b/docusaurus-docs/docs/clients/java.md index 730d8db4..3637f4c8 100644 --- a/docusaurus-docs/docs/clients/java.md +++ b/docusaurus-docs/docs/clients/java.md @@ -81,6 +81,44 @@ client.loginIntoNamespace("groot", "password", 123); Once logged in, the client can perform all operations allowed for that user in the specified namespace. +## Handling aborted transactions + +When a commit aborts, the client throws a `TxnConflictException`. In addition to +the full server message, the exception exposes the [abort reason](/clients#abort-reasons) +as a typed `TxnConflictException.AbortReason`, so you can branch on *why* the +commit aborted: + +```java +import io.dgraph.TxnConflictException; +import io.dgraph.TxnConflictException.AbortReason; + +Transaction txn = client.newTransaction(); +try { + // ... mutations ... + txn.commit(); +} catch (TxnConflictException e) { + switch (e.getReason()) { + case CONFLICT: + case STALE_STARTTS: + // Safe to retry with a fresh transaction. + break; + case PREDICATE_MOVE: + // A predicate is being moved between groups; retry after a short backoff. + break; + case UNKNOWN: + // No category reported (e.g. an older server). Fall back to the message. + break; + } + System.err.println("commit aborted: " + e.getMessage()); +} finally { + txn.discard(); +} +``` + +`getReason()` returns `AbortReason.UNKNOWN` when the server reports no category, +so the code above works unchanged against older Dgraph servers. `isRetryable()` +remains available for code that only needs the retry/no-retry distinction. + ## Documentation For complete API documentation, examples, and advanced usage: diff --git a/docusaurus-docs/docs/clients/python.md b/docusaurus-docs/docs/clients/python.md index 44b5fee9..7e16233f 100644 --- a/docusaurus-docs/docs/clients/python.md +++ b/docusaurus-docs/docs/clients/python.md @@ -82,6 +82,37 @@ client.login_into_namespace("groot", "password", "123") Once logged in, the client can perform all operations allowed for that user in the specified namespace. +## Handling aborted transactions + +When a commit aborts, the client raises `pydgraph.AbortedError`. In addition to +the full server message, the error exposes the [abort reason](/clients#abort-reasons) +as a typed `pydgraph.AbortReason`, so you can branch on *why* the commit aborted: + +```python +import pydgraph + +txn = client.txn() +try: + txn.mutate(set_obj={"name": "Alice"}) + txn.commit() +except pydgraph.AbortedError as e: + if e.reason in (pydgraph.AbortReason.CONFLICT, pydgraph.AbortReason.STALE_STARTTS): + # Safe to retry with a fresh transaction. + ... + elif e.reason == pydgraph.AbortReason.PREDICATE_MOVE: + # A predicate is being moved between groups; retry after a short backoff. + ... + else: # pydgraph.AbortReason.UNKNOWN + # No category reported (e.g. an older server). Fall back to the message. + ... + print("commit aborted:", e) +finally: + txn.discard() +``` + +`AbortedError.reason` is `pydgraph.AbortReason.UNKNOWN` when the server reports no +category, so the code above works unchanged against older Dgraph servers. + ## Documentation For complete API documentation, examples, and advanced usage: diff --git a/docusaurus-docs/docs/clients/raw-http.md b/docusaurus-docs/docs/clients/raw-http.md index ecd4f392..8a96fd53 100644 --- a/docusaurus-docs/docs/clients/raw-http.md +++ b/docusaurus-docs/docs/clients/raw-http.md @@ -399,7 +399,7 @@ If another client were to perform another transaction concurrently affecting the "errors": [ { "code": "Error", - "message": "Transaction has been aborted. Please retry." + "message": "conflict: Transaction has been aborted. Please retry." } ] } @@ -407,6 +407,26 @@ If another client were to perform another transaction concurrently affecting the In this case, it should be up to the user of the client to decide if they wish to retry the transaction. +The `message` is prefixed with a `": "` category describing why the +commit aborted, so a client can react appropriately. The `` is one of: + +| Code | Meaning | +| ---------------- | ----------------------------------------------------------------------------------------- | +| `conflict` | Write-write conflict with another concurrent transaction. Retrying typically succeeds. | +| `stale-startts` | The transaction's start timestamp predates the current Zero leader's lease (e.g. a leader change). Retry with a fresh transaction. | +| `predicate-move` | A predicate the transaction wrote is being moved between groups, blocking commits on it. Retry after the move completes. | + +For example, a stale start-timestamp abort returns +`stale-startts: Transaction has been aborted due to a leader change. Please retry`, +and a blocked predicate move returns +`predicate-move: Commits on predicate are blocked due to predicate move`. + +:::note +Older Dgraph servers return the bare detail with no category prefix (e.g. +`Transaction has been aborted. Please retry.`). Clients should treat a missing or +unrecognized prefix as an unknown reason rather than failing to parse. +::: + ### Abort the Transaction To abort a transaction, use the same `/commit` endpoint with the `abort=true` parameter while specifying the `startTs` value for the transaction: From 089d06e8cc32d35a20990f698102bfb4f9e255df Mon Sep 17 00:00:00 2001 From: Ryan Hendrickson Date: Fri, 31 Jul 2026 01:37:05 -0400 Subject: [PATCH 2/2] docs(clients): correct abort-reason docs against the shipped server behaviour Three things had gone stale or were wrong. UNKNOWN was documented as meaning "an older server", in four places. A current server also returns uncategorized aborts, on purpose: where no published category fits the cause it sends the explanation without a code rather than borrowing one that implies the wrong remedy. A reader hitting UNKNOWN would otherwise conclude their server was out of date. The three categories are also no longer presented as exhaustive. raw-http.md quoted a message the server cannot produce ("stale-startts: Transaction has been aborted due to a leader change. Please retry") and told readers the category is a prefix on the message. It is not: the HTTP layer renders the underlying gRPC error, so an "rpc error: code = Aborted desc = " preamble precedes it. Anchoring a match to the start of the string fails. The JSON example now shows the real shape, and the guidance is to match the code within the message - better still, to treat the error response itself as the abort signal and use the category only to decide how to react, since message text is not a stable contract. stale-startts described one of its two causes. Zero raises its validation floor on a leader change and also when trimming its conflict map at a snapshot, which is not a leader change. Adds what a conflict can and cannot tell you: it names no predicate or uid and cannot, but the collision may be on an index or count key derived from the data written, and @upsert excludes the uid so any two transactions writing the same value collide. Reverse edges never conflict. Co-Authored-By: Claude Opus 5 --- docusaurus-docs/docs/clients/index.md | 64 +++++++++++++++++++----- docusaurus-docs/docs/clients/java.md | 18 +++++-- docusaurus-docs/docs/clients/python.md | 13 +++-- docusaurus-docs/docs/clients/raw-http.md | 45 ++++++++++++----- 4 files changed, 108 insertions(+), 32 deletions(-) diff --git a/docusaurus-docs/docs/clients/index.md b/docusaurus-docs/docs/clients/index.md index bb3be994..590bbf39 100644 --- a/docusaurus-docs/docs/clients/index.md +++ b/docusaurus-docs/docs/clients/index.md @@ -55,29 +55,67 @@ When a transaction is aborted, all its changes are discarded. Transactions can #### Abort reasons A write-write conflict is not the only reason a commit can abort. When Dgraph -aborts a commit it now reports a *category* describing why, so clients can decide +aborts a commit it reports a *category* describing why, so clients can decide how to react (for example, retry immediately versus back off). The category is carried as a `": "` prefix on the gRPC `ABORTED` status message (and on the HTTP error message), where `` is one of: - `conflict` — a write-write conflict with another concurrent transaction, as described above. Retrying the transaction typically succeeds. -- `stale-startts` — the transaction's start timestamp predates the current Zero - leader's lease (for example, after a leader change). Retrying with a fresh - transaction succeeds. +- `stale-startts` — the transaction's start timestamp is older than the oldest + timestamp the server can still validate against. That happens after a Zero + leader change, and also when Zero trims its conflict map at a snapshot, which + is not a leader change. Retrying with a fresh transaction succeeds. - `predicate-move` — a predicate the transaction wrote is being moved between - groups, so commits on it are temporarily blocked. Retrying once the move - completes succeeds. - -For example, a conflict abort carries the message -`conflict: Transaction has been aborted. Please retry`. + groups so commits on it are blocked, or it finished moving while the + transaction was open. Retrying once the move completes succeeds. + +:::note[Not every abort has a category] +Some aborts carry **no** prefix, and that is deliberate. Where the server cannot +map a cause onto one of the categories above, it sends the explanation without a +code rather than borrowing one that would imply the wrong remedy. Clients report +those as `UNKNOWN`. + +That covers aborts from **older servers**, which do not categorize at all, and +also causes a **current** server declines to categorize — for example a +transaction already aborted out of band by a schema change or the +idle-transaction reaper, a cancelled or timed-out request, or a predicate no +group currently serves. `UNKNOWN` therefore does not mean your server is out of +date. The description still explains what happened; only the machine-readable +category is absent. +::: -:::note -Older Dgraph servers do not emit a category prefix. Clients that parse the -reason degrade gracefully and report it as `UNKNOWN` in that case, so existing -error handling keeps working unchanged. +:::tip[Detect aborts by status code, not message text] +Treat the gRPC `ABORTED` status code (or the HTTP error response) as the signal +that a commit aborted, and the message as detail only. Message text differs per +category and is not a stable contract — it may be reworded or extended between +releases. The official clients all branch on the status code, and an +unrecognized or absent prefix degrades to `UNKNOWN`, so a newer server can add +categories without breaking an older client. ::: +#### What a `conflict` can and cannot tell you + +A conflict does not name a predicate or UID, and cannot: conflict keys are +one-way fingerprints by the time the server compares them, so the specific key +is unrecoverable. + +This matters when tuning a schema, because the culprit is not necessarily the +data you wrote directly. Writing one triple can write several keys, and any of +them can collide: + +- the **data** key you set; +- an **index** key, if the predicate is indexed; +- a **count** key, if the predicate has `@count`. + +`@upsert` changes the rule rather than adding a key: the UID is excluded from +the comparison, so **any** two transactions writing the same value conflict, not +just two writing the same node. That is how uniqueness is enforced, and it is a +common source of surprising conflicts in upsert-heavy ingest. + +Reverse edges (`@reverse`) never cause conflicts, and predicates marked +`@noconflict` are excluded from conflict detection entirely. + The official [Java](/clients/java) and [Python](/clients/python) clients parse this category into a typed enum; see those pages for the API. diff --git a/docusaurus-docs/docs/clients/java.md b/docusaurus-docs/docs/clients/java.md index 3637f4c8..ab737315 100644 --- a/docusaurus-docs/docs/clients/java.md +++ b/docusaurus-docs/docs/clients/java.md @@ -106,7 +106,9 @@ try { // A predicate is being moved between groups; retry after a short backoff. break; case UNKNOWN: - // No category reported (e.g. an older server). Fall back to the message. + // No category reported. Fall back to the message, which still explains + // what happened. Reached against an older server, and also when a + // current server declines to categorize a cause. break; } System.err.println("commit aborted: " + e.getMessage()); @@ -115,9 +117,17 @@ try { } ``` -`getReason()` returns `AbortReason.UNKNOWN` when the server reports no category, -so the code above works unchanged against older Dgraph servers. `isRetryable()` -remains available for code that only needs the retry/no-retry distinction. +`getReason()` returns `AbortReason.UNKNOWN` whenever the server reports no +category, so the code above works unchanged against older Dgraph servers — and +also against current ones, which deliberately leave some causes uncategorized +rather than implying the wrong remedy (see +[abort reasons](/clients#abort-reasons)). Always keep an `UNKNOWN` branch; +`getMessage()` still carries the explanation. An unrecognized category also +degrades to `UNKNOWN`, so a newer server can add categories without breaking +this code. + +`isRetryable()` remains available for code that only needs the retry/no-retry +distinction. ## Documentation diff --git a/docusaurus-docs/docs/clients/python.md b/docusaurus-docs/docs/clients/python.md index 7e16233f..e27320ef 100644 --- a/docusaurus-docs/docs/clients/python.md +++ b/docusaurus-docs/docs/clients/python.md @@ -103,15 +103,22 @@ except pydgraph.AbortedError as e: # A predicate is being moved between groups; retry after a short backoff. ... else: # pydgraph.AbortReason.UNKNOWN - # No category reported (e.g. an older server). Fall back to the message. + # No category reported. Fall back to the message, which still explains + # what happened. Reached against an older server, and also when a + # current server declines to categorize a cause. ... print("commit aborted:", e) finally: txn.discard() ``` -`AbortedError.reason` is `pydgraph.AbortReason.UNKNOWN` when the server reports no -category, so the code above works unchanged against older Dgraph servers. +`AbortedError.reason` is `pydgraph.AbortReason.UNKNOWN` whenever the server +reports no category, so the code above works unchanged against older Dgraph +servers — and also against current ones, which deliberately leave some causes +uncategorized rather than implying the wrong remedy (see +[abort reasons](/clients#abort-reasons)). Always keep the `UNKNOWN` branch; the +message still carries the explanation. An unrecognized category also degrades to +`UNKNOWN`, so a newer server can add categories without breaking this code. ## Documentation diff --git a/docusaurus-docs/docs/clients/raw-http.md b/docusaurus-docs/docs/clients/raw-http.md index 8a96fd53..06b85078 100644 --- a/docusaurus-docs/docs/clients/raw-http.md +++ b/docusaurus-docs/docs/clients/raw-http.md @@ -399,7 +399,7 @@ If another client were to perform another transaction concurrently affecting the "errors": [ { "code": "Error", - "message": "conflict: Transaction has been aborted. Please retry." + "message": "rpc error: code = Aborted desc = conflict: Transaction has been aborted. Please retry. Another transaction committed to one of the same keys. The conflicting key cannot be identified: it may be the data key written directly, or an index or count key derived from it. On an @upsert predicate the uid is excluded, so any two transactions writing the same value conflict" } ] } @@ -407,26 +407,47 @@ If another client were to perform another transaction concurrently affecting the In this case, it should be up to the user of the client to decide if they wish to retry the transaction. -The `message` is prefixed with a `": "` category describing why the -commit aborted, so a client can react appropriately. The `` is one of: +The `message` embeds a `": "` category describing why the commit +aborted, so a client can react appropriately. The `` is one of: | Code | Meaning | | ---------------- | ----------------------------------------------------------------------------------------- | | `conflict` | Write-write conflict with another concurrent transaction. Retrying typically succeeds. | -| `stale-startts` | The transaction's start timestamp predates the current Zero leader's lease (e.g. a leader change). Retry with a fresh transaction. | -| `predicate-move` | A predicate the transaction wrote is being moved between groups, blocking commits on it. Retry after the move completes. | +| `stale-startts` | The transaction's start timestamp is older than the oldest timestamp the server can still validate against — after a Zero leader change, or when Zero trims its conflict map at a snapshot. Retry with a fresh transaction. | +| `predicate-move` | A predicate the transaction wrote is being moved between groups so commits on it are blocked, or it finished moving mid-transaction. Retry after the move completes. | -For example, a stale start-timestamp abort returns -`stale-startts: Transaction has been aborted due to a leader change. Please retry`, -and a blocked predicate move returns +For example, a blocked predicate move reports `predicate-move: Commits on predicate are blocked due to predicate move`. -:::note -Older Dgraph servers return the bare detail with no category prefix (e.g. -`Transaction has been aborted. Please retry.`). Clients should treat a missing or -unrecognized prefix as an unknown reason rather than failing to parse. +:::caution[Match the code, do not match the whole message] +The category appears *within* the `message`, not at the start of it: the HTTP +layer renders the underlying gRPC error, so the string also carries an +`rpc error: code = Aborted desc = ` preamble ahead of the category. Search for +the category within the message rather than anchoring to the beginning. + +Better still, treat the presence of an error response as the signal that the +commit aborted, and use the category only to decide how to react. Message text +differs per category and is not a stable contract — it may be reworded or +extended between releases, and it is considerably longer than the examples here. ::: +:::note[Not every abort has a category] +Some aborts carry no category, deliberately: where the server cannot map a cause +onto one of the codes above, it sends the explanation alone rather than +borrowing a code that would imply the wrong remedy. Treat a missing or +unrecognized category as *unknown* rather than failing to parse. + +This covers aborts from **older servers**, which do not categorize at all, and +causes a **current** server declines to categorize — for example a transaction +already aborted out of band by a schema change or the idle-transaction reaper, a +cancelled or timed-out request, or a predicate no group currently serves. An +uncategorized abort does not mean your server is out of date. +::: + +See [abort reasons](/clients#abort-reasons) for what a `conflict` can and cannot +tell you — in particular, why an index, count or `@upsert` key derived from the +data you wrote may be the one that actually collided. + ### Abort the Transaction To abort a transaction, use the same `/commit` endpoint with the `abort=true` parameter while specifying the `startTs` value for the transaction: