Skip to content

Commit 1042bb1

Browse files
rahst12matthewmcneely
authored andcommitted
adding test
1 parent 11ff9ec commit 1042bb1

4 files changed

Lines changed: 220 additions & 8 deletions

File tree

dgraph/cmd/alpha/txn_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,59 @@ func TestConflictAbortReason(t *testing.T) {
311311
"abort reason should be categorized as conflict; got %q", st.Message())
312312
}
313313

314+
// TestPredicateMoveAbortReason proves the "predicate-move" category is surfaced on the
315+
// gRPC status. Zero's checkPreds aborts a commit whose predicate keys don't match the
316+
// tablet's serving group (the same code path that rejects commits during a predicate
317+
// move). We reach it deterministically by committing a real txn context whose Preds have
318+
// been rewritten to claim a group that doesn't serve the predicate, with conflict Keys
319+
// cleared so hasConflict passes and checkPreds runs.
320+
//
321+
// As with TestConflictAbortReason, it uses the raw CommitOrAbort stub to observe the
322+
// unflattened status the server sends.
323+
func TestPredicateMoveAbortReason(t *testing.T) {
324+
op := &api.Operation{}
325+
op.DropAll = true
326+
require.NoError(t, dg.Alter(context.Background(), op))
327+
328+
// A normal mutation gives us a valid, fresh TxnContext (real StartTs and Preds).
329+
txn := dg.NewTxn()
330+
mu := &api.Mutation{SetJson: []byte(`{"name": "Alice"}`)}
331+
resp, err := txn.Mutate(context.Background(), mu)
332+
require.NoError(t, err)
333+
334+
tc := resp.GetTxn()
335+
require.NotEmpty(t, tc.GetPreds(), "mutation response must carry predicate keys")
336+
337+
// Rewrite each predicate key's group id to a group that does not serve it, and drop
338+
// the conflict keys so hasConflict is false and the commit reaches checkPreds.
339+
doctored := make([]string, 0, len(tc.GetPreds()))
340+
for _, p := range tc.GetPreds() {
341+
// Preds look like "<gid>-<predicate>"; claim a nonexistent group 100.
342+
if idx := strings.IndexByte(p, '-'); idx >= 0 {
343+
doctored = append(doctored, "100"+p[idx:])
344+
}
345+
}
346+
require.NotEmpty(t, doctored, "expected parseable predicate keys")
347+
tc.Preds = doctored
348+
tc.Keys = nil
349+
350+
conn, err := grpc.NewClient(alphaSockAdd, grpc.WithTransportCredentials(insecure.NewCredentials()))
351+
require.NoError(t, err)
352+
defer func() { _ = conn.Close() }()
353+
raw := api.NewDgraphClient(conn)
354+
355+
ctx := metadata.NewOutgoingContext(context.Background(),
356+
metadata.Pairs("accessJwt", hc.AccessJwt))
357+
_, err = raw.CommitOrAbort(ctx, tc)
358+
require.Error(t, err)
359+
360+
st := status.Convert(err)
361+
require.Equal(t, codes.Aborted, st.Code(),
362+
"abort must keep codes.Aborted so existing clients still retry")
363+
require.True(t, strings.HasPrefix(st.Message(), "predicate-move: "),
364+
"abort reason should be categorized as predicate-move; got %q", st.Message())
365+
}
366+
314367
func TestConflictTimeout(t *testing.T) {
315368
var uid string
316369
txn := dg.NewTxn()

dgraph/cmd/zero/oracle.go

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -351,11 +351,27 @@ const (
351351
abortReasonPredicateMove = "predicate-move"
352352
)
353353

354+
// Human-readable details paired with the conflict abort codes.
355+
const (
356+
abortDetailConflict = "Transaction has been aborted. Please retry"
357+
abortDetailStaleStartTs = "Transaction has been aborted due to a leader change. Please retry"
358+
)
359+
354360
// abortReason builds the wire string the client parses: "<code>: <detail>".
355361
func abortReason(code, detail string) string {
356362
return code + ": " + detail
357363
}
358364

365+
// conflictAbortReason returns the wire reason for a hasConflict abort, distinguishing a
366+
// write-write conflict from a stale start timestamp (a txn that predates the current
367+
// leader's lease, i.e. a leader change rather than a real conflict).
368+
func conflictAbortReason(stale bool) string {
369+
if stale {
370+
return abortReason(abortReasonStaleStartTs, abortDetailStaleStartTs)
371+
}
372+
return abortReason(abortReasonConflict, abortDetailConflict)
373+
}
374+
359375
func (s *Server) commit(ctx context.Context, src *api.TxnContext) error {
360376
span := trace.SpanFromContext(ctx)
361377
span.SetAttributes(attribute.Int64("startTs", int64(src.StartTs)))
@@ -384,12 +400,7 @@ func (s *Server) commit(ctx context.Context, src *api.TxnContext) error {
384400
stale := src.StartTs < s.orc.startTxnTs
385401
s.orc.RUnlock()
386402
if conflict {
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"))
403+
return abortWithReason(conflictAbortReason(stale))
393404
}
394405

395406
checkPreds := func() error {
@@ -456,8 +467,7 @@ func (s *Server) commit(ctx context.Context, src *api.TxnContext) error {
456467
}
457468
if aborted {
458469
// 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"))
470+
return status.Error(codes.Aborted, conflictAbortReason(false))
461471
}
462472
return nil
463473
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* SPDX-FileCopyrightText: © 2017-2025 Istari Digital, Inc.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package zero
7+
8+
import (
9+
"strings"
10+
"testing"
11+
12+
"github.com/stretchr/testify/require"
13+
14+
"github.com/dgraph-io/dgo/v250/protos/api"
15+
)
16+
17+
// The abort-reason wire format is a contract with gRPC clients (e.g. dgraph4j parses the
18+
// "<code>: " prefix into TxnConflictException.AbortReason). These unit tests pin the
19+
// category prefixes and the logic that selects between them, so the contract can't drift
20+
// silently without an integration cluster.
21+
22+
func TestAbortReasonFormat(t *testing.T) {
23+
require.Equal(t, "conflict: boom", abortReason(abortReasonConflict, "boom"))
24+
require.Equal(t, "stale-startts: x", abortReason(abortReasonStaleStartTs, "x"))
25+
require.Equal(t, "predicate-move: y", abortReason(abortReasonPredicateMove, "y"))
26+
}
27+
28+
func TestConflictAbortReason(t *testing.T) {
29+
// Write-write conflict.
30+
r := conflictAbortReason(false)
31+
require.True(t, strings.HasPrefix(r, abortReasonConflict+": "),
32+
"want conflict prefix, got %q", r)
33+
require.Equal(t, abortReason(abortReasonConflict, abortDetailConflict), r)
34+
35+
// Stale start timestamp (leader change).
36+
r = conflictAbortReason(true)
37+
require.True(t, strings.HasPrefix(r, abortReasonStaleStartTs+": "),
38+
"want stale-startts prefix, got %q", r)
39+
require.Equal(t, abortReason(abortReasonStaleStartTs, abortDetailStaleStartTs), r)
40+
require.Contains(t, r, "leader change")
41+
}
42+
43+
// TestHasConflictStaleStartTs pins the exact discriminator commit() uses to choose the
44+
// stale-startts reason: a txn whose startTs predates the leader's startTxnTs lease is a
45+
// conflict, and is flagged stale; a fresh startTs with no conflicting keys is neither.
46+
func TestHasConflictStaleStartTs(t *testing.T) {
47+
o := &Oracle{}
48+
o.Init()
49+
defer o.close()
50+
51+
o.updateStartTxnTs(100)
52+
53+
// startTs below the lease floor: hasConflict true, and the stale discriminator true.
54+
stale := &api.TxnContext{StartTs: 42}
55+
require.True(t, o.hasConflict(stale), "txn below startTxnTs must conflict")
56+
require.True(t, stale.StartTs < o.startTxnTs, "must be flagged stale")
57+
require.Equal(t, conflictAbortReason(true), conflictAbortReason(stale.StartTs < o.startTxnTs))
58+
59+
// startTs at/above the lease floor with no keys: not a conflict, not stale.
60+
fresh := &api.TxnContext{StartTs: 100}
61+
require.False(t, o.hasConflict(fresh), "fresh txn with no keys must not conflict")
62+
require.False(t, fresh.StartTs < o.startTxnTs, "must not be flagged stale")
63+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
//go:build integration2
2+
3+
/*
4+
* SPDX-FileCopyrightText: © 2017-2025 Istari Digital, Inc.
5+
* SPDX-License-Identifier: Apache-2.0
6+
*/
7+
8+
package main
9+
10+
import (
11+
"context"
12+
"strings"
13+
"testing"
14+
"time"
15+
16+
"github.com/stretchr/testify/require"
17+
"google.golang.org/grpc"
18+
"google.golang.org/grpc/codes"
19+
"google.golang.org/grpc/credentials/insecure"
20+
"google.golang.org/grpc/status"
21+
22+
"github.com/dgraph-io/dgo/v250/protos/api"
23+
"github.com/dgraph-io/dgraph/v25/dgraphtest"
24+
)
25+
26+
// TestStaleStartTsAbortReason proves the "stale-startts" abort category end-to-end against a
27+
// real cluster. A transaction's start timestamp becomes "stale" when it predates the current
28+
// Zero leader's lease — i.e. after a leader change. We reproduce that deterministically by
29+
// opening a transaction, then restarting the (single) Zero: on restart Zero renews its lease and
30+
// advances startTxnTs past every previously-leased start ts, so committing the now-old txn aborts
31+
// with the stale-startts reason rather than a plain write-write conflict.
32+
//
33+
// As in the alpha-level reason tests, the commit goes through the raw CommitOrAbort stub so we
34+
// observe the unflattened codes.Aborted status (dgo's Txn.Commit would replace it with the
35+
// reasonless ErrAborted).
36+
func TestStaleStartTsAbortReason(t *testing.T) {
37+
conf := dgraphtest.NewClusterConfig().WithNumAlphas(1).WithNumZeros(1).WithReplicas(1)
38+
c, err := dgraphtest.NewLocalCluster(conf)
39+
require.NoError(t, err)
40+
t.Cleanup(func() { c.Cleanup(t.Failed()) })
41+
require.NoError(t, c.Start())
42+
43+
gc, cleanup, err := c.Client()
44+
require.NoError(t, err)
45+
defer cleanup()
46+
47+
ctx := context.Background()
48+
require.NoError(t, gc.Alter(ctx, &api.Operation{DropAll: true}))
49+
50+
// Open a transaction and mutate so it gets a real (soon-to-be-stale) start ts and keys.
51+
txn := gc.NewTxn()
52+
resp, err := txn.Mutate(ctx, &api.Mutation{SetJson: []byte(`{"name": "Manish"}`)})
53+
require.NoError(t, err)
54+
tc := resp.GetTxn()
55+
require.NotZero(t, tc.GetStartTs(), "mutation must yield a start ts")
56+
57+
// Restart Zero. On coming back up it renews its lease and sets startTxnTs to MaxTxnTs+1,
58+
// which is strictly greater than the start ts leased above — making our open txn stale.
59+
require.NoError(t, c.StopZero(0))
60+
require.NoError(t, c.StartZero(0))
61+
require.NoError(t, c.HealthCheck(false))
62+
63+
// Wait until a Zero leader is established again (lease renewal, hence startTxnTs bump, runs
64+
// when a Zero becomes leader). Avoids racing the commit against the leaderless window.
65+
require.Eventually(t, func() bool {
66+
_, err := c.GetZeroLeader(0)
67+
return err == nil
68+
}, 60*time.Second, time.Second, "zero leader did not re-establish after restart")
69+
70+
// Commit the stale txn via the raw stub to observe the categorized status.
71+
port, err := c.GetAlphaGrpcPublicPort(0)
72+
require.NoError(t, err)
73+
conn, err := grpc.NewClient("0.0.0.0:"+port, grpc.WithTransportCredentials(insecure.NewCredentials()))
74+
require.NoError(t, err)
75+
defer func() { _ = conn.Close() }()
76+
raw := api.NewDgraphClient(conn)
77+
78+
_, err = raw.CommitOrAbort(ctx, tc)
79+
require.Error(t, err, "committing a txn whose start ts predates the new leader must abort")
80+
81+
st := status.Convert(err)
82+
require.Equal(t, codes.Aborted, st.Code(),
83+
"abort must keep codes.Aborted so existing clients still retry")
84+
require.True(t, strings.HasPrefix(st.Message(), "stale-startts: "),
85+
"abort reason should be categorized as stale-startts; got %q", st.Message())
86+
}

0 commit comments

Comments
 (0)