Skip to content

Commit 11ff9ec

Browse files
rahst12matthewmcneely
authored andcommitted
first draft of surfacing the reason transaction abort/error occured
1 parent 4309b87 commit 11ff9ec

4 files changed

Lines changed: 140 additions & 7 deletions

File tree

dgraph/cmd/alpha/txn_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ import (
2121

2222
"github.com/stretchr/testify/assert"
2323
"github.com/stretchr/testify/require"
24+
"google.golang.org/grpc"
25+
"google.golang.org/grpc/codes"
26+
"google.golang.org/grpc/credentials/insecure"
27+
"google.golang.org/grpc/metadata"
28+
"google.golang.org/grpc/status"
2429

2530
"github.com/dgraph-io/dgo/v250"
2631
"github.com/dgraph-io/dgo/v250/protos/api"
@@ -248,6 +253,64 @@ func TestConflict(t *testing.T) {
248253
require.True(t, bytes.Equal(resp.Json, []byte("{\"me\":[{\"name\":\"Manish\"}]}")))
249254
}
250255

256+
// TestConflictAbortReason proves the server emits the categorized abort reason on the
257+
// gRPC status of a write-write conflict: the code stays codes.Aborted (so existing
258+
// clients keep retrying) and the message is prefixed with "conflict: ". This is exactly
259+
// the unflattened status a gRPC client such as dgraph4j receives and parses into
260+
// TxnConflictException.AbortReason.
261+
//
262+
// It must inspect the raw CommitOrAbort response rather than dgo's high-level
263+
// Txn.Commit, because dgo intentionally replaces any codes.Aborted error with the
264+
// static dgo.ErrAborted (txn.go), discarding the reason for the Go client.
265+
func TestConflictAbortReason(t *testing.T) {
266+
op := &api.Operation{}
267+
op.DropAll = true
268+
require.NoError(t, dg.Alter(context.Background(), op))
269+
270+
// First transaction creates a node with a name.
271+
txn := dg.NewTxn()
272+
mu := &api.Mutation{}
273+
mu.SetJson = []byte(`{"name": "Manish"}`)
274+
assigned, err := txn.Mutate(context.Background(), mu)
275+
require.NoError(t, err)
276+
require.Len(t, assigned.Uids, 1)
277+
var uid string
278+
for _, u := range assigned.Uids {
279+
uid = u
280+
}
281+
282+
// Second transaction writes the same predicate on the same uid -> conflicts.
283+
txn2 := dg.NewTxn()
284+
mu = &api.Mutation{}
285+
mu.SetJson = []byte(fmt.Sprintf(`{"uid": %q, "name": "Manish"}`, uid))
286+
resp2, err := txn2.Mutate(context.Background(), mu)
287+
require.NoError(t, err)
288+
require.NotEmpty(t, resp2.GetTxn().GetKeys(), "mutation response must carry conflict keys")
289+
290+
// First commit wins; its commitTs is now greater than txn2's startTs.
291+
require.NoError(t, txn.Commit(context.Background()))
292+
293+
// Commit the loser via the raw gRPC stub so we observe the unflattened status the
294+
// server sends (dgo's Txn.Commit would replace it with the reasonless ErrAborted).
295+
conn, err := grpc.NewClient(alphaSockAdd, grpc.WithTransportCredentials(insecure.NewCredentials()))
296+
require.NoError(t, err)
297+
defer func() { _ = conn.Close() }()
298+
raw := api.NewDgraphClient(conn)
299+
300+
// TestMain enables ACL, so the raw stub (unlike the logged-in dg client) must
301+
// carry the access JWT; CommitOrAbort goes through the same auth interceptor.
302+
ctx := metadata.NewOutgoingContext(context.Background(),
303+
metadata.Pairs("accessJwt", hc.AccessJwt))
304+
_, err = raw.CommitOrAbort(ctx, resp2.GetTxn())
305+
require.Error(t, err)
306+
307+
st := status.Convert(err)
308+
require.Equal(t, codes.Aborted, st.Code(),
309+
"abort must keep codes.Aborted so existing clients still retry")
310+
require.True(t, strings.HasPrefix(st.Message(), "conflict: "),
311+
"abort reason should be categorized as conflict; got %q", st.Message())
312+
}
313+
251314
func TestConflictTimeout(t *testing.T) {
252315
var uid string
253316
txn := dg.NewTxn()

dgraph/cmd/zero/oracle.go

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import (
1818
"go.opentelemetry.io/otel"
1919
attribute "go.opentelemetry.io/otel/attribute"
2020
trace "go.opentelemetry.io/otel/trace"
21+
"google.golang.org/grpc/codes"
22+
"google.golang.org/grpc/status"
2123

2224
"github.com/dgraph-io/badger/v4/y"
2325
"github.com/dgraph-io/dgo/v250/protos/api"
@@ -338,21 +340,56 @@ func (s *Server) proposeTxn(ctx context.Context, src *api.TxnContext) error {
338340
return nil
339341
}
340342

343+
// Abort-reason codes. When Zero decides to abort a transaction it surfaces the category to the
344+
// client as the prefix of a codes.Aborted gRPC status message, formatted as "<code>: <detail>".
345+
// api.TxnContext (external dgo module) has no field for the reason, so it rides on the error;
346+
// the status code stays codes.Aborted, so existing abort handling is unaffected. The dgraph4j
347+
// client parses these prefixes into TxnConflictException.AbortReason — keep the two in sync.
348+
const (
349+
abortReasonConflict = "conflict"
350+
abortReasonStaleStartTs = "stale-startts"
351+
abortReasonPredicateMove = "predicate-move"
352+
)
353+
354+
// abortReason builds the wire string the client parses: "<code>: <detail>".
355+
func abortReason(code, detail string) string {
356+
return code + ": " + detail
357+
}
358+
341359
func (s *Server) commit(ctx context.Context, src *api.TxnContext) error {
342360
span := trace.SpanFromContext(ctx)
343361
span.SetAttributes(attribute.Int64("startTs", int64(src.StartTs)))
344362
if src.Aborted {
363+
// Client-initiated discard (txn.Discard); not a server-decided abort, so no reason.
345364
return s.proposeTxn(ctx, src)
346365
}
347366

367+
// abortWithReason marks the txn aborted, proposes it (advancing the watermark exactly as the
368+
// previous code did), and then surfaces the categorized reason to the caller as a gRPC
369+
// Aborted status. proposeTxn still runs identically — only the post-propose return changes.
370+
abortWithReason := func(reason string) error {
371+
span.SetAttributes(attribute.Bool("abort", true))
372+
src.Aborted = true
373+
if err := s.proposeTxn(ctx, src); err != nil {
374+
return err
375+
}
376+
return status.Error(codes.Aborted, reason)
377+
}
378+
348379
// Use the start timestamp to check if we have a conflict, before we need to assign a commit ts.
349380
s.orc.RLock()
350381
conflict := s.orc.hasConflict(src)
382+
// A txn whose startTs predates this leader's lease is aborted by hasConflict, but it's a
383+
// leader change rather than a write-write conflict — report it distinctly.
384+
stale := src.StartTs < s.orc.startTxnTs
351385
s.orc.RUnlock()
352386
if conflict {
353-
span.SetAttributes(attribute.Bool("abort", true))
354-
src.Aborted = true
355-
return s.proposeTxn(ctx, src)
387+
if stale {
388+
return abortWithReason(abortReason(abortReasonStaleStartTs,
389+
"Transaction has been aborted due to a leader change. Please retry"))
390+
}
391+
return abortWithReason(abortReason(abortReasonConflict,
392+
"Transaction has been aborted. Please retry"))
356393
}
357394

358395
checkPreds := func() error {
@@ -385,9 +422,9 @@ func (s *Server) commit(ctx context.Context, src *api.TxnContext) error {
385422
return nil
386423
}
387424
if err := checkPreds(); err != nil {
388-
span.SetAttributes(attribute.Bool("abort", true))
389-
src.Aborted = true
390-
return s.proposeTxn(ctx, src)
425+
// checkPreds builds rich messages, e.g. "Commits on predicate %s are blocked due to
426+
// predicate move" — forward them instead of swallowing the reason.
427+
return abortWithReason(abortReason(abortReasonPredicateMove, err.Error()))
391428
}
392429

393430
num := pb.Num{Val: 1, Type: pb.Num_TXN_TS}
@@ -402,16 +439,27 @@ func (s *Server) commit(ctx context.Context, src *api.TxnContext) error {
402439
span.SetAttributes(attribute.Int64("nodeId", int64(s.Node.Id)))
403440
span.AddEvent(fmt.Sprintf("TXN Context: %+v", src))
404441

442+
aborted := false
405443
if err := s.orc.commit(src); err != nil {
406444
span.SetAttributes(attribute.Bool("abort", true))
407445
src.Aborted = true
446+
aborted = true
408447
}
409448
if err := ctx.Err(); err != nil {
410449
span.SetAttributes(attribute.Bool("abort", true))
411450
src.Aborted = true
451+
aborted = true
412452
}
413453
// Propose txn should be used to set watermark as done.
414-
return s.proposeTxn(ctx, src)
454+
if err := s.proposeTxn(ctx, src); err != nil {
455+
return err
456+
}
457+
if aborted {
458+
// A late write-write conflict detected at commit time (keyCommit), or a cancelled ctx.
459+
return status.Error(codes.Aborted, abortReason(abortReasonConflict,
460+
"Transaction has been aborted. Please retry"))
461+
}
462+
return nil
415463
}
416464

417465
// CommitOrAbort either commits a transaction or aborts it.

edgraph/server.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,10 @@ func (s *Server) doMutate(ctx context.Context, qc *queryContext, resp *api.Respo
646646
if err == dgo.ErrAborted {
647647
err = status.Error(codes.Aborted, err.Error())
648648
resp.Txn.Aborted = true
649+
} else if status.Code(err) == codes.Aborted {
650+
// Server-decided abort carrying a categorized reason; err is already a codes.Aborted
651+
// status (e.g. "conflict: ...", "predicate-move: ...") — surface it unchanged.
652+
resp.Txn.Aborted = true
649653
}
650654

651655
return err
@@ -2085,6 +2089,12 @@ func (s *Server) CommitOrAbort(ctx context.Context, tc *api.TxnContext) (*api.Tx
20852089

20862090
return tctx, status.Error(codes.Aborted, err.Error())
20872091
}
2092+
if status.Code(err) == codes.Aborted {
2093+
// Server-decided abort carrying a categorized reason; err is already a codes.Aborted
2094+
// status (e.g. "conflict: ...", "predicate-move: ...") — surface it unchanged.
2095+
tctx.Aborted = true
2096+
return tctx, err
2097+
}
20882098
tctx.StartTs = tc.StartTs
20892099
tctx.CommitTs = commitTs
20902100
return tctx, err

worker/mutation.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ import (
2020
"go.opentelemetry.io/otel"
2121
"go.opentelemetry.io/otel/attribute"
2222
"go.opentelemetry.io/otel/trace"
23+
"google.golang.org/grpc/codes"
2324
"google.golang.org/grpc/metadata"
25+
"google.golang.org/grpc/status"
2426
"google.golang.org/protobuf/proto"
2527

2628
"github.com/dgraph-io/badger/v4"
@@ -908,6 +910,16 @@ func CommitOverNetwork(ctx context.Context, tc *api.TxnContext) (uint64, error)
908910
tctx, err := zc.CommitOrAbort(ctx, tc)
909911

910912
if err != nil {
913+
// Zero signals a server-decided abort as a codes.Aborted status carrying a categorized
914+
// reason (e.g. "conflict: ...", "predicate-move: ..."). Record the abort metric and
915+
// forward the reason rather than treating it as a generic transport error or flattening
916+
// it to the reasonless dgo.ErrAborted.
917+
if status.Code(err) == codes.Aborted {
918+
if !clientDiscard {
919+
ostats.Record(ctx, x.TxnAborts.M(1))
920+
}
921+
return 0, err
922+
}
911923
span.AddEvent("Error in CommitOrAbort", trace.WithAttributes(
912924
attribute.String("error", err.Error())))
913925
return 0, err

0 commit comments

Comments
 (0)