Skip to content

Commit 3a5c86c

Browse files
committed
chore: name the document holding a contested unique index value
1 parent 847d554 commit 3a5c86c

2 files changed

Lines changed: 65 additions & 3 deletions

File tree

internal/db/index.go

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,8 @@ func saveUniqueKey(
438438
}
439439

440440
if len(val) != 0 {
441+
// This read puts the unique key in the transaction's read set, so two transactions
442+
// writing the same index value conflict at commit rather than at this check.
441443
existing, err := txn.Datastore().Get(ctx, &key)
442444
if err != nil && !errors.Is(err, corekv.ErrNotFound) {
443445
return NewErrCheckUniqueIndexConstraint(err)
@@ -446,7 +448,7 @@ func saveUniqueKey(
446448
if tolerateSameDoc && string(existing) == string(val) {
447449
return nil
448450
}
449-
return newUniqueIndexError(doc, fieldsDescs)
451+
return newUniqueIndexError(ctx, doc, fieldsDescs, existing)
450452
}
451453
}
452454

@@ -456,8 +458,33 @@ func saveUniqueKey(
456458
return nil
457459
}
458460

459-
func newUniqueIndexError(doc *client.Document, fieldsDescs []client.CollectionFieldDescription) error {
460-
kvs := make([]errors.KV, 0, len(fieldsDescs))
461+
// incumbentDocID resolves the document already holding a unique index entry, from the
462+
// short ID the entry stores. Returns an empty string if it cannot be resolved, since this
463+
// only decorates an error that is being returned either way.
464+
func incumbentDocID(ctx context.Context, encodedShortID []byte) string {
465+
shortID, err := keys.DecodeDocShortID(encodedShortID)
466+
if err != nil {
467+
return ""
468+
}
469+
docID, found, err := id.GetDocID(ctx, shortID)
470+
if err != nil || !found {
471+
return ""
472+
}
473+
return docID
474+
}
475+
476+
func newUniqueIndexError(
477+
ctx context.Context,
478+
doc *client.Document,
479+
fieldsDescs []client.CollectionFieldDescription,
480+
existing []byte,
481+
) error {
482+
kvs := make([]errors.KV, 0, len(fieldsDescs)+1)
483+
// Naming the document that already holds the value is what makes the two comparable;
484+
// without it the error only says which one lost.
485+
if incumbent := incumbentDocID(ctx, existing); incumbent != "" {
486+
kvs = append(kvs, errors.NewKV("HeldBy", incumbent))
487+
}
461488
for iter := range fieldsDescs {
462489
fieldVal, err := doc.TryGetValue(fieldsDescs[iter].Name)
463490
var val any

internal/db/index_backfill_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,3 +370,38 @@ func TestBackfillBatchTxn_ConflictsWhenReadDocIsModified(t *testing.T) {
370370
require.True(t, errors.Is(commitErr, corekv.ErrTxnConflict),
371371
"expected ErrTxnConflict but got: %v", commitErr)
372372
}
373+
374+
// A unique violation names the document that lost. Naming the one that already holds the
375+
// value is what lets the two be compared, which is the only way to tell a genuine duplicate
376+
// from two documents that disagree on a field they do not share an index on.
377+
func TestSaveUniqueKey_ErrorNamesTheDocumentHoldingTheValue(t *testing.T) {
378+
ctx := context.Background()
379+
380+
db, err := newBadgerDB(ctx)
381+
require.NoError(t, err)
382+
t.Cleanup(func() { db.Close() })
383+
384+
_, err = db.AddCollection(ctx, userSchema)
385+
require.NoError(t, err)
386+
col, err := db.GetCollectionByName(ctx, "User")
387+
require.NoError(t, err)
388+
389+
_, err = col.NewIndex(ctx, client.NewIndexRequest{
390+
Fields: []client.IndexedFieldDescription{{Name: "name"}},
391+
Unique: true,
392+
})
393+
require.NoError(t, err)
394+
395+
incumbent, err := client.NewDocFromJSON(ctx, []byte(`{"name":"alice","age":1}`), col.Version())
396+
require.NoError(t, err)
397+
require.NoError(t, col.AddDocument(ctx, incumbent))
398+
399+
// Same indexed value, different age, so a different document that cannot have the slot.
400+
duplicate, err := client.NewDocFromJSON(ctx, []byte(`{"name":"alice","age":2}`), col.Version())
401+
require.NoError(t, err)
402+
require.NotEqual(t, incumbent.ID().String(), duplicate.ID().String())
403+
404+
err = col.AddDocument(ctx, duplicate)
405+
require.ErrorContains(t, err, "violates unique index")
406+
require.ErrorContains(t, err, incumbent.ID().String())
407+
}

0 commit comments

Comments
 (0)