From af6669251882c52386d6e53359ea437275163f1a Mon Sep 17 00:00:00 2001 From: Harshil Goel Date: Mon, 4 May 2026 21:48:59 -0400 Subject: [PATCH 01/45] feat(mutations): add per-predicate mutation pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an alternative path through `applyMutations` that fans the edges of a single mutation out by predicate and processes each predicate's batch in its own goroutine, exposing parallelism the legacy serial-by-edge path cannot. The per-predicate runners drive the full mutation lifecycle (scalar/list, reverse, count, tokenized index, vector index) through new helpers in posting/index.go: `MutationPipeline.Process` → `ProcessPredicate` → `ProcessSingle` / `ProcessList` / `ProcessVectorIndex`, with `InsertTokenizerIndexes`, `ProcessReverse`, and `ProcessCount` shared across the predicate-shaped paths. Supporting infrastructure: - `posting.Deltas` — per-txn delta store split into a sharded raw-bytes map and a per-predicate `indexMap` for batched index writes. - `types.LockedShardedMap` — generic sharded RWMutex map used by Deltas and the in-memory index aggregation. - New mutation-time helpers on `posting.Txn` (`AddDelta`, `GetScalarList`, `addConflictKey`, `addConflictKeyWithUid`). - Test scaffolding in `worker/mutation_unit_test.go`, `worker/sort_test.go`, and `worker/draft_test.go`. This commit squashes the original WIP series authored by Harshil Goel (commits 978a0d41d…41d6445ce on the abandoned branch) plus the merge into current `main` and the mechanical clean-ups required to compile against it: import paths `hypermodeinc/dgraph` → `dgraph-io/dgraph`, license headers, a `posting/mvcc_test.go::TestRegression9597` fix-up for the `LocalCache.deltas` map → `*Deltas` refactor, and a stray doc reference in TESTING.md. The pipeline ships gated and disabled; the on/off knob and subsequent correctness fixes follow in later commits in this series. Co-Authored-By: Matthew McNeely Co-Authored-By: Claude Opus 4.7 (1M context) --- TESTING.md | 2 +- graphql/admin/admin.go | 4 + posting/index.go | 1098 +++++++++++++++++++++++++++++++++- posting/index_test.go | 27 +- posting/list.go | 107 +++- posting/list_test.go | 1 - posting/lists.go | 198 +++++- posting/mvcc.go | 56 +- posting/mvcc_test.go | 10 +- posting/oracle.go | 107 +++- query/upgrade_test.go | 2 +- schema/schema.go | 53 +- types/locked_sharded_map.go | 187 ++++++ worker/draft.go | 26 +- worker/draft_test.go | 101 ++++ worker/mutation.go | 10 + worker/mutation_unit_test.go | 12 +- worker/sort_test.go | 355 ++++++++++- worker/task.go | 4 + 19 files changed, 2214 insertions(+), 146 deletions(-) create mode 100644 types/locked_sharded_map.go diff --git a/TESTING.md b/TESTING.md index e0118f47b9b..384f84f9e44 100644 --- a/TESTING.md +++ b/TESTING.md @@ -87,7 +87,7 @@ programmatic control over local Dgraph clusters. Most newer integration2 and upg ## Module Structure -The main module is `github.com/hypermodeinc/dgraph` +The main module is `github.com/dgraph-io/dgraph` The codebase is organized into several key packages: diff --git a/graphql/admin/admin.go b/graphql/admin/admin.go index 5b484b03958..b6411dd8dac 100644 --- a/graphql/admin/admin.go +++ b/graphql/admin/admin.go @@ -655,6 +655,10 @@ func newAdminResolver( return } + if len(pl.Postings) == 2 && string(pl.Postings[0].Value) == "_STAR_ALL" { + pl.Postings = pl.Postings[1:] + } + // There should be only one posting. if len(pl.Postings) != 1 { glog.Errorf("Only one posting is expected in the graphql schema posting list but got %d", diff --git a/posting/index.go b/posting/index.go index 75f8f8a5f41..238edd64584 100644 --- a/posting/index.go +++ b/posting/index.go @@ -15,14 +15,17 @@ import ( "math" "os" "strings" + "sync" "sync/atomic" "time" "unsafe" + "github.com/dgryski/go-farm" "github.com/golang/glog" "github.com/pkg/errors" ostats "go.opencensus.io/stats" "go.opentelemetry.io/otel/trace" + "golang.org/x/sync/errgroup" "google.golang.org/protobuf/proto" "github.com/dgraph-io/badger/v4" @@ -63,23 +66,1097 @@ func indexTokens(ctx context.Context, info *indexMutationInfo) ([]string, error) } sv, err := types.Convert(info.val, schemaType) if err != nil { - return nil, err + return nil, errors.Wrap(err, "Cannot convert value to scalar type") } var tokens []string for _, it := range info.tokenizers { toks, err := tok.BuildTokens(sv.Value, tok.GetTokenizerForLang(it, lang)) if err != nil { - return tokens, err + return tokens, errors.Wrapf(err, "Cannot build tokens for attribute %s", attr) } tokens = append(tokens, toks...) } return tokens, nil } -// addIndexMutations adds mutation(s) for a single term, to maintain the index, -// but only for the given tokenizers. -// TODO - See if we need to pass op as argument as t should already have Op. +type MutationPipeline struct { + txn *Txn +} + +func NewMutationPipeline(txn *Txn) *MutationPipeline { + return &MutationPipeline{txn: txn} +} + +type PredicatePipeline struct { + attr string + edges chan *pb.DirectedEdge + wg *sync.WaitGroup + errCh chan error +} + +func (pp *PredicatePipeline) close() { + pp.wg.Done() +} + +func (mp *MutationPipeline) ProcessVectorIndex(ctx context.Context, pipeline *PredicatePipeline, info predicateInfo) error { + var wg errgroup.Group + numThreads := 10 + + for i := 0; i < numThreads; i++ { + wg.Go(func() error { + for edge := range pipeline.edges { + uid := edge.Entity + + key := x.DataKey(pipeline.attr, uid) + pl, err := mp.txn.Get(key) + if err != nil { + return err + } + if err := pl.AddMutationWithIndex(ctx, edge, mp.txn); err != nil { + return err + } + } + return nil + }) + } + + if err := wg.Wait(); err != nil { + return err + } + + return nil +} + +func (mp *MutationPipeline) InsertTokenizerIndexes(ctx context.Context, pipeline *PredicatePipeline, postings *map[uint64]*pb.PostingList, info predicateInfo) error { + startTime := time.Now() + defer func() { + fmt.Println("Inserting tokenizer indexes for predicate", pipeline.attr, "took", time.Since(startTime)) + }() + + tokenizers := schema.State().Tokenizer(ctx, pipeline.attr) + if len(tokenizers) == 0 { + return nil + } + + indexesGenInMutation := types.NewLockedShardedMap[string, *MutableLayer]() + wg := &sync.WaitGroup{} + + syncMap := sync.Map{} + + chanFn := func(uids chan uint64, estimatedSize int) { + defer wg.Done() + indexGenInThread := make(map[string]*pb.PostingList, estimatedSize) + tokenizers := schema.State().Tokenizer(ctx, pipeline.attr) + + factorySpecs, err := schema.State().FactoryCreateSpec(ctx, pipeline.attr) + if err != nil { + pipeline.errCh <- err + return + } + + indexEdge := &pb.DirectedEdge{ + Attr: pipeline.attr, + } + + for uid := range uids { + postingList := (*postings)[uid] + newList := &pb.PostingList{} + if info.isSingleEdge && len(postingList.Postings) == 2 { + newList.Postings = append(newList.Postings, postingList.Postings[1]) + newList.Postings = append(newList.Postings, postingList.Postings[0]) + } else { + newList = postingList + } + for _, posting := range newList.Postings { + info := &indexMutationInfo{ + tokenizers: tokenizers, + factorySpecs: factorySpecs, + op: pb.DirectedEdge_SET, + val: types.Val{ + Tid: types.TypeID(posting.ValType), + Value: posting.Value, + }, + } + + info.edge = &pb.DirectedEdge{ + Attr: pipeline.attr, + Op: pb.DirectedEdge_SET, + Lang: string(posting.LangTag), + Value: posting.Value, + } + + key := fmt.Sprintf("%s,%s", posting.LangTag, posting.Value) + tokens, loaded := syncMap.Load(key) + + if !loaded { + tokens, err = indexTokens(ctx, info) + if err != nil { + fmt.Println("ERRORRRING", err) + x.Panic(err) + } + syncMap.Store(key, tokens) + } + + indexEdge.Op = GetPostingOp(posting.Op) + indexEdge.ValueId = uid + mpost := makePostingFromEdge(mp.txn.StartTs, indexEdge) + + for _, token := range tokens.([]string) { + key := x.IndexKey(pipeline.attr, token) + pk, _ := x.Parse([]byte(key)) + fmt.Println("TOKENS", key, pk) + val, ok := indexGenInThread[string(key)] + if !ok { + val = &pb.PostingList{} + } + val.Postings = append(val.Postings, mpost) + indexGenInThread[string(key)] = val + } + } + } + + for key, value := range indexGenInThread { + pk, _ := x.Parse([]byte(key)) + fmt.Println("LOCAL MAP", pk, value) + indexesGenInMutation.Update(key, func(val *MutableLayer, ok bool) *MutableLayer { + if !ok { + val = newMutableLayer() + val.currentEntries = &pb.PostingList{} + } + for _, posting := range value.Postings { + val.insertPosting(posting, false) + } + return val + }) + } + } + + numGo := 10 + wg.Add(numGo) + chMap := make(map[int]chan uint64) + + for i := 0; i < numGo; i++ { + uidCh := make(chan uint64, numGo) + chMap[i] = uidCh + go chanFn(uidCh, len(*postings)/numGo) + } + + for uid := range *postings { + chMap[int(uid)%numGo] <- uid + } + + for i := 0; i < numGo; i++ { + close(chMap[i]) + } + + wg.Wait() + + mp.txn.cache.Lock() + defer mp.txn.cache.Unlock() + + indexGenInTxn := mp.txn.cache.deltas.GetIndexMapForPredicate(pipeline.attr) + if indexGenInTxn == nil { + indexGenInTxn = types.NewLockedShardedMap[string, *pb.PostingList]() + mp.txn.cache.deltas.indexMap[pipeline.attr] = indexGenInTxn + } + + fmt.Println("INSERTING INDEX", pipeline.attr, *postings) + updateFn := func(key string, value *MutableLayer) { + pk, _ := x.Parse([]byte(key)) + fmt.Println("UPDATE INDEX", pk, value) + indexGenInTxn.Update(key, func(val *pb.PostingList, ok bool) *pb.PostingList { + if !ok { + val = &pb.PostingList{} + } + val.Postings = append(val.Postings, value.currentEntries.Postings...) + return val + }) + } + + if info.hasUpsert { + err := indexesGenInMutation.Iterate(func(key string, value *MutableLayer) error { + updateFn(key, value) + mp.txn.addConflictKey(farm.Fingerprint64([]byte(key))) + return nil + }) + if err != nil { + return err + } + } else { + err := indexesGenInMutation.Iterate(func(key string, value *MutableLayer) error { + updateFn(key, value) + mp.txn.addConflictKeyWithUid([]byte(key), value.currentEntries, info.hasUpsert, info.noConflict) + return nil + }) + if err != nil { + return err + } + } + + return nil +} + +// func (mp *MutationPipeline) InsertTokenizerIndexes(ctx context.Context, pipeline *PredicatePipeline, postings *map[uint64]*pb.PostingList, info predicateInfo) error { +// startTime := time.Now() +// defer func() { +// fmt.Println("Inserting tokenizer indexes for predicate", pipeline.attr, "took", time.Since(startTime)) +// }() + +// tokenizers := schema.State().Tokenizer(ctx, pipeline.attr) +// if len(tokenizers) == 0 { +// return nil +// } + +// values := make(map[string]*pb.PostingList, len(tokenizers)*len(*postings)) +// valPost := make(map[string]*pb.Posting) + +// indexEdge1 := &pb.DirectedEdge{ +// Attr: pipeline.attr, +// } + +// for uid, postingList := range *postings { +// fmt.Println("POSTING", uid, postingList) +// for _, posting := range postingList.Postings { +// key := fmt.Sprintf("%s,%s", posting.LangTag, posting.Value) +// valPl, ok := values[key] +// if !ok { +// valPl = &pb.PostingList{} +// } + +// indexEdge1.Op = GetPostingOp(posting.Op) +// indexEdge1.ValueId = uid + +// mpost := makePostingFromEdge(mp.txn.StartTs, indexEdge1) +// valPl.Postings = append(valPl.Postings, mpost) +// values[key] = valPl + +// newPosting := new(pb.Posting) +// newPosting.ValType = posting.ValType +// newPosting.Value = posting.Value +// newPosting.LangTag = posting.LangTag +// valPost[key] = newPosting +// } +// } + +// keysCreated := make([]string, 0, len(values)) +// for i := range values { +// keysCreated = append(keysCreated, i) +// } + +// //fmt.Println("START") + +// f := func(numGo int) *types.LockedShardedMap[string, *MutableLayer] { +// wg := &sync.WaitGroup{} + +// globalMap := types.NewLockedShardedMap[string, *MutableLayer]() +// process := func(start int) { +// factorySpecs, err := schema.State().FactoryCreateSpec(ctx, pipeline.attr) +// if err != nil { +// pipeline.errCh <- err +// return +// } + +// defer wg.Done() +// localMap := make(map[string]*pb.PostingList, len(values)/numGo) +// for i := start; i < len(values); i += numGo { +// key := keysCreated[i] +// valPl := values[key] +// if len(valPl.Postings) == 0 { +// continue +// } + +// posting := valPost[key] +// // Build info per iteration without indexEdge. +// info := &indexMutationInfo{ +// tokenizers: tokenizers, +// factorySpecs: factorySpecs, +// op: pb.DirectedEdge_SET, +// val: types.Val{ +// Tid: types.TypeID(posting.ValType), +// Value: posting.Value, +// }, +// } + +// info.edge = &pb.DirectedEdge{ +// Attr: pipeline.attr, +// Op: pb.DirectedEdge_SET, +// Lang: string(posting.LangTag), +// Value: posting.Value, +// } + +// tokens, erri := indexTokens(ctx, info) +// if erri != nil { +// fmt.Println("ERRORRRING", erri) +// x.Panic(erri) +// } + +// for _, token := range tokens { +// key := x.IndexKey(pipeline.attr, token) +// pk, _ := x.Parse([]byte(key)) +// fmt.Println("TOKENS", key, i, numGo, pk) +// val, ok := localMap[string(key)] +// if !ok { +// val = &pb.PostingList{} +// } +// val.Postings = append(val.Postings, valPl.Postings...) +// localMap[string(key)] = val +// } +// } + +// for key, value := range localMap { +// pk, _ := x.Parse([]byte(key)) +// fmt.Println("LOCAL MAP", pk, numGo, value) +// globalMap.Update(key, func(val *MutableLayer, ok bool) *MutableLayer { +// if !ok { +// val = newMutableLayer() +// val.currentEntries = &pb.PostingList{} +// } +// for _, posting := range value.Postings { +// val.insertPosting(posting, false) +// } +// return val +// }) +// } +// } + +// for i := range numGo { +// wg.Add(1) +// go process(i) +// } + +// wg.Wait() + +// return globalMap +// } + +// globalMapI := f(1) + +// mp.txn.cache.Lock() +// defer mp.txn.cache.Unlock() + +// globalMap := mp.txn.cache.deltas.GetIndexMapForPredicate(pipeline.attr) +// if globalMap == nil { +// globalMap = types.NewLockedShardedMap[string, *pb.PostingList]() +// mp.txn.cache.deltas.indexMap[pipeline.attr] = globalMap +// } + +// updateFn := func(key string, value *MutableLayer) { +// globalMap.Update(key, func(val *pb.PostingList, ok bool) *pb.PostingList { +// if !ok { +// val = &pb.PostingList{} +// } +// val.Postings = append(val.Postings, value.currentEntries.Postings...) +// return val +// }) +// } + +// if info.hasUpsert { +// err := globalMapI.Iterate(func(key string, value *MutableLayer) error { +// updateFn(key, value) +// mp.txn.addConflictKey(farm.Fingerprint64([]byte(key))) +// return nil +// }) +// if err != nil { +// return err +// } +// } else { +// err := globalMapI.Iterate(func(key string, value *MutableLayer) error { +// updateFn(key, value) +// mp.txn.addConflictKeyWithUid([]byte(key), value.currentEntries, info.hasUpsert, info.noConflict) +// return nil +// }) +// if err != nil { +// return err +// } +// } + +// return nil +// } + +type predicateInfo struct { + isList bool + index bool + reverse bool + count bool + noConflict bool + hasUpsert bool + isUid bool + + isSingleEdge bool +} + +func (mp *MutationPipeline) ProcessList(ctx context.Context, pipeline *PredicatePipeline, info predicateInfo) error { + su, schemaExists := schema.State().Get(ctx, pipeline.attr) + + mutations := make(map[uint64]*MutableLayer, 1000) + + for edge := range pipeline.edges { + if edge.Op != pb.DirectedEdge_DEL && !schemaExists { + return errors.Errorf("runMutation: Unable to find schema for %s", edge.Attr) + } + + if err := ValidateAndConvert(edge, &su); err != nil { + return err + } + + uid := edge.Entity + pl, exists := mutations[uid] + if !exists { + pl = newMutableLayer() + pl.currentEntries = &pb.PostingList{} + } + + mpost := NewPosting(edge) + mpost.StartTs = mp.txn.StartTs + if mpost.PostingType != pb.Posting_REF { + edge.ValueId = FingerprintEdge(edge) + mpost.Uid = edge.ValueId + } + + pl.insertPosting(mpost, false) + mutations[uid] = pl + } + + postings := make(map[uint64]*pb.PostingList, 1000) + for uid, pl := range mutations { + postings[uid] = pl.currentEntries + } + + if info.reverse { + if err := mp.ProcessReverse(ctx, pipeline, &postings, info); err != nil { + return err + } + } + + if info.index { + if err := mp.InsertTokenizerIndexes(ctx, pipeline, &postings, info); err != nil { + return err + } + } + + if info.count { + return mp.ProcessCount(ctx, pipeline, &postings, info, true, false) + } + + dataKey := x.DataKey(pipeline.attr, 0) + baseKey := string(dataKey[:len(dataKey)-8]) // Avoid repeated conversion + + for uid, pl := range postings { + if len(pl.Postings) == 0 { + continue + } + + binary.BigEndian.PutUint64(dataKey[len(dataKey)-8:], uid) + if newPl, err := mp.txn.AddDelta(baseKey+string(dataKey[len(dataKey)-8:]), pl, info.isUid, true); err != nil { + return err + } else { + if !info.noConflict { + mp.txn.addConflictKeyWithUid(dataKey, newPl, info.hasUpsert, info.noConflict) + } + } + } + + return nil +} + +func findSingleValueInPostingList(pb *pb.PostingList) *pb.Posting { + if pb == nil { + return nil + } + for _, p := range pb.Postings { + if p.Op == Set { + return p + } + } + return nil +} + +func (mp *MutationPipeline) ProcessReverse(ctx context.Context, pipeline *PredicatePipeline, postings *map[uint64]*pb.PostingList, info predicateInfo) error { + key := x.ReverseKey(pipeline.attr, 0) + edge := &pb.DirectedEdge{ + Attr: pipeline.attr, + } + reverseredMap := make(map[uint64]*pb.PostingList, 1000) + for uid, postingList := range *postings { + for _, posting := range postingList.Postings { + postingList, ok := reverseredMap[posting.Uid] + if !ok { + postingList = &pb.PostingList{} + } + edge.Entity = posting.Uid + edge.ValueId = uid + edge.ValueType = posting.ValType + edge.Op = GetPostingOp(posting.Op) + edge.Facets = posting.Facets + + postingList.Postings = append(postingList.Postings, makePostingFromEdge(mp.txn.StartTs, edge)) + reverseredMap[posting.Uid] = postingList + } + } + + if info.count { + newInfo := predicateInfo{ + isList: true, + index: info.index, + reverse: info.reverse, + count: info.count, + noConflict: info.noConflict, + hasUpsert: info.hasUpsert, + } + return mp.ProcessCount(ctx, pipeline, &reverseredMap, newInfo, true, true) + } + + for uid, pl := range reverseredMap { + if len(pl.Postings) == 0 { + continue + } + binary.BigEndian.PutUint64(key[len(key)-8:], uid) + if newPl, err := mp.txn.AddDelta(string(key), pl, true, true); err != nil { + return err + } else { + mp.txn.addConflictKeyWithUid(key, newPl, info.hasUpsert, info.noConflict) + } + } + + return nil +} + +func makePostingFromEdge(startTs uint64, edge *pb.DirectedEdge) *pb.Posting { + mpost := NewPosting(edge) + mpost.StartTs = startTs + if mpost.PostingType != pb.Posting_REF { + edge.ValueId = FingerprintEdge(edge) + mpost.Uid = edge.ValueId + } + return mpost +} + +func (mp *MutationPipeline) handleOldDeleteForSingle(pipeline *PredicatePipeline, postings map[uint64]*pb.PostingList) error { + edge := &pb.DirectedEdge{ + Attr: pipeline.attr, + } + + dataKey := x.DataKey(pipeline.attr, 0) + + for uid, postingList := range postings { + currValue := findSingleValueInPostingList(postingList) + if currValue == nil { + continue + } + + binary.BigEndian.PutUint64(dataKey[len(dataKey)-8:], uid) + list, err := mp.txn.GetScalarList(dataKey) + if err != nil { + return err + } + + oldValList, err := list.StaticValue(mp.txn.StartTs) + if err != nil { + return err + } + + oldVal := findSingleValueInPostingList(oldValList) + + if oldVal == nil { + continue + } + + if string(oldVal.Value) == string(currValue.Value) { + postings[uid] = &pb.PostingList{} + continue + } + + edge.Op = pb.DirectedEdge_DEL + edge.Value = oldVal.Value + edge.ValueType = oldVal.ValType + edge.ValueId = oldVal.Uid + + mpost := makePostingFromEdge(mp.txn.StartTs, edge) + postingList.Postings = append(postingList.Postings, mpost) + postings[uid] = postingList + } + + return nil +} + +func (txn *Txn) addConflictKeyWithUid(key []byte, pl *pb.PostingList, hasUpsert bool, hasNoConflict bool) { + if hasNoConflict { + return + } + txn.Lock() + defer txn.Unlock() + if txn.conflicts == nil { + txn.conflicts = make(map[uint64]struct{}) + } + keyHash := farm.Fingerprint64(key) + if hasUpsert { + txn.conflicts[keyHash] = struct{}{} + return + } + for _, post := range pl.Postings { + txn.conflicts[keyHash^post.Uid] = struct{}{} + } +} + +func (mp *MutationPipeline) ProcessCount(ctx context.Context, pipeline *PredicatePipeline, postings *map[uint64]*pb.PostingList, info predicateInfo, isListEdge bool, isReverseEdge bool) error { + dataKey := x.DataKey(pipeline.attr, 0) + if isReverseEdge { + dataKey = x.ReverseKey(pipeline.attr, 0) + } + edge := pb.DirectedEdge{ + Attr: pipeline.attr, + } + + countMap := make(map[int]*pb.PostingList, 2*len(*postings)) + + insertEdgeCount := func(count int) { + c, ok := countMap[count] + if !ok { + c = &pb.PostingList{} + countMap[count] = c + } + c.Postings = append(c.Postings, makePostingFromEdge(mp.txn.StartTs, &edge)) + countMap[count] = c + } + + for uid, postingList := range *postings { + binary.BigEndian.PutUint64(dataKey[len(dataKey)-8:], uid) + list, err := mp.txn.Get(dataKey) + if err != nil { + return err + } + + list.Lock() + prevCount := list.GetLength(mp.txn.StartTs) + + for _, post := range postingList.Postings { + found, _, _ := list.findPosting(post.StartTs, post.Uid) + if found { + if post.Op == Set && isListEdge { + post.Op = Ovr + } + } else { + if post.Op == Del { + continue + } + } + + if err := list.updateMutationLayer(post, !isListEdge, true); err != nil { + return err + } + } + + newCount := list.GetLength(mp.txn.StartTs) + updated := list.mutationMap.currentEntries != nil + list.Unlock() + + if updated { + if !isListEdge { + if !info.noConflict { + mp.txn.addConflictKey(farm.Fingerprint64(dataKey)) + } + } else { + mp.txn.addConflictKeyWithUid(dataKey, postingList, info.hasUpsert, info.noConflict) + } + } + + if newCount == prevCount { + continue + } + + //fmt.Println("COUNT STATS", uid, prevCount, newCount, postingList, list.Print()) + + edge.ValueId = uid + edge.Op = pb.DirectedEdge_DEL + if prevCount > 0 { + insertEdgeCount(prevCount) + } + edge.Op = pb.DirectedEdge_SET + if newCount > 0 { + insertEdgeCount(newCount) + } + } + + for c, pl := range countMap { + //fmt.Println("COUNT", c, pl) + ck := x.CountKey(pipeline.attr, uint32(c), isReverseEdge) + if newPl, err := mp.txn.AddDelta(string(ck), pl, true, true); err != nil { + return err + } else { + mp.txn.addConflictKeyWithUid(ck, newPl, info.hasUpsert, info.noConflict) + } + } + + return nil +} + +func (mp *MutationPipeline) ProcessSingle(ctx context.Context, pipeline *PredicatePipeline, info predicateInfo) error { + su, schemaExists := schema.State().Get(ctx, pipeline.attr) + + postings := make(map[uint64]*pb.PostingList, 1000) + + dataKey := x.DataKey(pipeline.attr, 0) + insertDeleteAllEdge := !(info.index || info.reverse || info.count) // nolint + + var oldVal *pb.Posting + for edge := range pipeline.edges { + // fmt.Println("SINGLE EDGE", edge) + if edge.Op != pb.DirectedEdge_DEL && !schemaExists { + return errors.Errorf("runMutation: Unable to find schema for %s", edge.Attr) + } + + if err := ValidateAndConvert(edge, &su); err != nil { + return err + } + + uid := edge.Entity + pl, exists := postings[uid] + + setPosting := func() { + mpost := makePostingFromEdge(mp.txn.StartTs, edge) + if len(pl.Postings) == 0 { + if insertDeleteAllEdge { + pl = &pb.PostingList{ + Postings: []*pb.Posting{createDeleteAllPosting(), mpost}, + } + } else { + pl = &pb.PostingList{ + Postings: []*pb.Posting{mpost}, + } + } + } else { + if pl.Postings[len(pl.Postings)-1].Op == Set { + pl.Postings[len(pl.Postings)-1] = mpost + } else { + pl.Postings = append(pl.Postings, mpost) + } + } + postings[uid] = pl + } + + if exists { + if edge.Op == pb.DirectedEdge_DEL { + oldVal = findSingleValueInPostingList(pl) + if string(edge.Value) == string(oldVal.Value) { + setPosting() + } + } else { + setPosting() + } + continue + } + + pl = &pb.PostingList{} + postings[uid] = pl + + if edge.Op == pb.DirectedEdge_DEL { + binary.BigEndian.PutUint64(dataKey[len(dataKey)-8:], uid) + list, err := mp.txn.GetScalarList(dataKey) + if err != nil { + return err + } + if list != nil { + l, err := list.StaticValue(mp.txn.StartTs) + if err != nil { + return err + } + oldVal = findSingleValueInPostingList(l) + } + if oldVal != nil { + if string(oldVal.Value) == string(edge.Value) { + setPosting() + } + } + } else { + setPosting() + } + } + + if info.index || info.reverse || info.count { + if err := mp.handleOldDeleteForSingle(pipeline, postings); err != nil { + return err + } + } + + if info.index { + if err := mp.InsertTokenizerIndexes(ctx, pipeline, &postings, info); err != nil { + return err + } + } + + if info.reverse { + if err := mp.ProcessReverse(ctx, pipeline, &postings, info); err != nil { + return err + } + } + + if info.count { + // Count should take care of updating the posting list + return mp.ProcessCount(ctx, pipeline, &postings, info, false, false) + } + + baseKey := string(dataKey[:len(dataKey)-8]) // Avoid repeated conversion + + for uid, pl := range postings { + //fmt.Println("ADDING DELTA", uid, pipeline.attr, pl) + binary.BigEndian.PutUint64(dataKey[len(dataKey)-8:], uid) + key := baseKey + string(dataKey[len(dataKey)-8:]) + + if !info.noConflict { + mp.txn.addConflictKey(farm.Fingerprint64([]byte(key))) + } + + if _, err := mp.txn.AddDelta(key, pl, false, false); err != nil { + return err + } + } + + return nil +} + +func runMutation(ctx context.Context, edge *pb.DirectedEdge, txn *Txn) error { + ctx = schema.GetWriteContext(ctx) + + // We shouldn't check whether this Alpha serves this predicate or not. Membership information + // isn't consistent across the entire cluster. We should just apply whatever is given to us. + su, ok := schema.State().Get(ctx, edge.Attr) + if edge.Op != pb.DirectedEdge_DEL { + if !ok { + return errors.Errorf("runMutation: Unable to find schema for %s", edge.Attr) + } + } + + key := x.DataKey(edge.Attr, edge.Entity) + // The following is a performance optimization which allows us to not read a posting list from + // disk. We calculate this based on how AddMutationWithIndex works. The general idea is that if + // we're not using the read posting list, we don't need to retrieve it. We need the posting list + // if we're doing count index or delete operation. For scalar predicates, we just get the last item merged. + // In other cases, we can just create a posting list facade in memory and use it to store the delta in Badger. + // Later, the rollup operation would consolidate all these deltas into a posting list. + isList := su.GetList() + var getFn func(key []byte) (*List, error) + switch { + case len(edge.Lang) == 0 && !isList: + // Scalar Predicates, without lang + getFn = txn.GetScalarList + case len(edge.Lang) > 0 || su.GetCount(): + // Language or Count Index + getFn = txn.Get + case edge.Op == pb.DirectedEdge_DEL: + // Covers various delete cases to keep things simple. + getFn = txn.Get + default: + // Only count index needs to be read. For other indexes on list, we don't need to read any data. + // For indexes on scalar prediactes, only the last element needs to be left. + // Delete cases covered above. + getFn = txn.GetFromDelta + } + + plist, err := getFn(key) + if err != nil { + return err + } + return plist.AddMutationWithIndex(ctx, edge, txn) +} + +func (mp *MutationPipeline) ProcessPredicate(ctx context.Context, pipeline *PredicatePipeline) error { + defer pipeline.close() + ctx = schema.GetWriteContext(ctx) + + // We shouldn't check whether this Alpha serves this predicate or not. Membership information + // isn't consistent across the entire cluster. We should just apply whatever is given to us. + su, ok := schema.State().Get(ctx, pipeline.attr) + info := predicateInfo{} + runForVectorIndex := false + + if ok { + info.index = schema.State().IsIndexed(ctx, pipeline.attr) + info.count = schema.State().HasCount(ctx, pipeline.attr) + info.reverse = schema.State().IsReversed(ctx, pipeline.attr) + info.noConflict = schema.State().HasNoConflict(pipeline.attr) + info.hasUpsert = schema.State().HasUpsert(pipeline.attr) + info.isList = schema.State().IsList(pipeline.attr) + info.isUid = su.ValueType == pb.Posting_UID + factorySpecs, err := schema.State().FactoryCreateSpec(ctx, pipeline.attr) + if err != nil { + return err + } + if len(factorySpecs) > 0 { + runForVectorIndex = true + } + } + + if runForVectorIndex { + return mp.ProcessVectorIndex(ctx, pipeline, info) + } + + runListFn := false + + if ok { + if info.isList || su.Lang { + runListFn = true + } + } + + info.isSingleEdge = !runListFn + + if runListFn { + if err := mp.ProcessList(ctx, pipeline, info); err != nil { + return err + } + } + + if ok && !runListFn { + if err := mp.ProcessSingle(ctx, pipeline, info); err != nil { + return err + } + } + + for edge := range pipeline.edges { + if err := runMutation(ctx, edge, mp.txn); err != nil { + return err + } + } + + return nil +} + +func isStarAll(v []byte) bool { + return bytes.Equal(v, []byte(x.Star)) +} + +func ValidateAndConvert(edge *pb.DirectedEdge, su *pb.SchemaUpdate) error { + if types.TypeID(edge.ValueType) == types.DefaultID && isStarAll(edge.Value) { + return nil + } + + storageType := TypeID(edge) + schemaType := types.TypeID(su.ValueType) + + // type checks + switch { + case edge.Lang != "" && !su.GetLang(): + return errors.Errorf("Attr: [%v] should have @lang directive in schema to mutate edge: [%v]", + x.ParseAttr(edge.Attr), edge) + + case !schemaType.IsScalar() && !storageType.IsScalar(): + return nil + + case !schemaType.IsScalar() && storageType.IsScalar(): + return errors.Errorf("Input for predicate %q of type uid is scalar. Edge: %v", + x.ParseAttr(edge.Attr), edge) + + case schemaType.IsScalar() && !storageType.IsScalar(): + return errors.Errorf("Input for predicate %q of type scalar is uid. Edge: %v", + x.ParseAttr(edge.Attr), edge) + + case schemaType == types.TypeID(pb.Posting_VFLOAT): + if !(storageType == types.TypeID(pb.Posting_VFLOAT) || storageType == types.TypeID(pb.Posting_STRING) || //nolint + storageType == types.TypeID(pb.Posting_DEFAULT)) { + return errors.Errorf("Input for predicate %q of type vector is not vector."+ + " Did you forget to add quotes before []?. Edge: %v", x.ParseAttr(edge.Attr), edge) + } + + // The suggested storage type matches the schema, OK! (Nothing to do ...) + case storageType == schemaType && schemaType != types.DefaultID: + return nil + + // We accept the storage type iff we don't have a schema type and a storage type is specified. + case schemaType == types.DefaultID: + schemaType = storageType + } + + var ( + dst types.Val + err error + ) + + src := types.Val{Tid: types.TypeID(edge.ValueType), Value: edge.Value} + // check compatibility of schema type and storage type + // The goal is to convert value on edge to value type defined by schema. + if dst, err = types.Convert(src, schemaType); err != nil { + return err + } + + // convert to schema type + b := types.ValueForType(types.BinaryID) + if err = types.Marshal(dst, &b); err != nil { + return err + } + + if x.WorkerConfig.AclEnabled && x.ParseAttr(edge.GetAttr()) == "dgraph.rule.permission" { + perm, ok := dst.Value.(int64) + if !ok { + return errors.Errorf("Value for predicate should be of type int") + } + if perm < 0 || perm > 7 { + return errors.Errorf("Can't set to %d, Value for this"+ + " predicate should be between 0 and 7", perm) + } + } + + // TODO: Figure out why this is Enum. It really seems like an odd choice -- rather than + // specifying it as the same type as presented in su. + edge.ValueType = schemaType.Enum() + var ok bool + edge.Value, ok = b.Value.([]byte) + if !ok { + return errors.Errorf("failure to convert edge type: '%+v' to schema type: '%+v'", + storageType, schemaType) + } + + return nil +} + +func (mp *MutationPipeline) Process(ctx context.Context, edges []*pb.DirectedEdge) error { + predicates := map[string]*PredicatePipeline{} + var wg sync.WaitGroup + numWg := 0 + eg, egCtx := errgroup.WithContext(ctx) + for _, edge := range edges { + //fmt.Println("PROCESSING EDGE", edge) + if edge.Op == pb.DirectedEdge_DEL && string(edge.Value) == x.Star { + l, err := mp.txn.Get(x.DataKey(edge.Attr, edge.Entity)) + if err != nil { + return err + } + if err = l.handleDeleteAll(ctx, edge, mp.txn); err != nil { + return err + } + continue + } + pred, ok := predicates[edge.Attr] + if !ok { + pred = &PredicatePipeline{ + attr: edge.Attr, + edges: make(chan *pb.DirectedEdge, 1000), + wg: &wg, + } + predicates[edge.Attr] = pred + wg.Add(1) + numWg += 1 + // Capture pred by passing it as a parameter to the closure + eg.Go(func(p *PredicatePipeline) func() error { + return func() error { + return mp.ProcessPredicate(egCtx, p) + } + }(pred)) + } + pred.edges <- edge + } + for _, pred := range predicates { + close(pred.edges) + } + if numWg == 0 { + return nil + } + // Wait for all predicate processors; returns first error (and cancels others via context). + if err := eg.Wait(); err != nil { + return err + } + return nil +} func (txn *Txn) addIndexMutations(ctx context.Context, info *indexMutationInfo) ([]*pb.DirectedEdge, error) { if info.tokenizers == nil { @@ -951,7 +2028,7 @@ func (r *rebuilder) Run(ctx context.Context) error { // txn.cache.Lock() is not required because we are the only one making changes to txn. kvs := make([]*bpb.KV, 0) - for key, data := range streamTxn.cache.deltas { + if err := streamTxn.cache.deltas.IterateBytes(func(key string, data []byte) error { version := atomic.AddUint64(&counter, 1) kv := bpb.KV{ Key: []byte(key), @@ -960,7 +2037,11 @@ func (r *rebuilder) Run(ctx context.Context) error { Version: version, } kvs = append(kvs, &kv) + return nil + }); err != nil { + return nil, err } + txns[threadId] = NewTxn(r.startTs) return &bpb.KVList{Kv: kvs}, nil } @@ -1009,7 +2090,7 @@ func (r *rebuilder) Run(ctx context.Context) error { // Convert data into deltas. streamTxn.Update() // txn.cache.Lock() is not required because we are the only one making changes to txn. - for key, data := range streamTxn.cache.deltas { + if err := streamTxn.cache.deltas.IterateBytes(func(key string, data []byte) error { version := atomic.AddUint64(&counter, 1) kv := bpb.KV{ Key: []byte(key), @@ -1018,6 +2099,9 @@ func (r *rebuilder) Run(ctx context.Context) error { Version: version, } kvs = append(kvs, &kv) + return nil + }); err != nil { + return nil, err } txns[threadId] = NewTxn(r.startTs) diff --git a/posting/index_test.go b/posting/index_test.go index ff481f7ace5..7b4807d6281 100644 --- a/posting/index_test.go +++ b/posting/index_test.go @@ -139,19 +139,28 @@ func addMutation(t *testing.T, l *List, edge *pb.DirectedEdge, op uint32, } txn := Oracle().RegisterStartTs(startTs) txn.cache.SetIfAbsent(string(l.key), l) - if index { - require.NoError(t, l.AddMutationWithIndex(context.Background(), edge, txn)) - } else { - require.NoError(t, l.addMutation(context.Background(), txn, edge)) - } + mp := NewMutationPipeline(txn) + err := mp.Process(context.Background(), []*pb.DirectedEdge{edge}) + require.NoError(t, err) txn.Update() txn.UpdateCachedKeys(commitTs) writer := NewTxnWriter(pstore) require.NoError(t, txn.CommitToDisk(writer, commitTs)) require.NoError(t, writer.Flush()) + newTxn := Oracle().RegisterStartTs(commitTs + 1) + l1, err := newTxn.Get(l.key) + require.NoError(t, err) + *l = *l1 //nolint } +const schemaPreVal = ` + name: string . + name2: string . + dob: dateTime . + friend: [uid] . +` + const schemaVal = ` name: string @index(term) . name2: string @index(term) . @@ -263,6 +272,9 @@ func addEdgeToUID(t *testing.T, attr string, src uint64, func TestCountReverseIndexWithData(t *testing.T) { require.NoError(t, pstore.DropAll()) MemLayerInstance.clear() + preIndex := "testcount: [uid] ." + require.NoError(t, schema.ParseBytes([]byte(preIndex), 1)) + indexNameCountVal := "testcount: [uid] @count @reverse ." attr := x.AttrInRootNamespace("testcount") @@ -298,6 +310,9 @@ func TestCountReverseIndexWithData(t *testing.T) { func TestCountReverseIndexEmptyPosting(t *testing.T) { require.NoError(t, pstore.DropAll()) MemLayerInstance.clear() + preIndex := "testcount: [uid] ." + require.NoError(t, schema.ParseBytes([]byte(preIndex), 1)) + indexNameCountVal := "testcount: [uid] @count @reverse ." attr := x.AttrInRootNamespace("testcount") @@ -330,6 +345,8 @@ func TestCountReverseIndexEmptyPosting(t *testing.T) { } func TestRebuildTokIndex(t *testing.T) { + require.NoError(t, schema.ParseBytes([]byte(schemaPreVal), 1)) + addEdgeToValue(t, x.AttrInRootNamespace("name2"), 91, "Michonne", uint64(1), uint64(2)) addEdgeToValue(t, x.AttrInRootNamespace("name2"), 92, "David", uint64(3), uint64(4)) diff --git a/posting/list.go b/posting/list.go index 3fcb24e9597..4e67b7f65c2 100644 --- a/posting/list.go +++ b/posting/list.go @@ -337,6 +337,7 @@ func (mm *MutableLayer) insertCommittedPostings(pl *pb.PostingList) { // We insert old postings in reverse order. So we only need to read the first update to an UID. if _, ok := mm.committedUids[mpost.Uid]; !ok { mm.committedUids[mpost.Uid] = mpost + fmt.Println("Buidlding committed uids", mm.committedUids, mpost) } } } @@ -406,10 +407,11 @@ func (mm *MutableLayer) print() string { if mm == nil { return "" } - return fmt.Sprintf("Committed List: %+v Proposed list: %+v Delete all marker: %d \n", + return fmt.Sprintf("Committed List: %+v Proposed list: %+v Delete all marker: %d. Count: %d \n", mm.committedEntries, mm.currentEntries, - mm.deleteAllMarker) + mm.deleteAllMarker, + mm.length) } // printShort returns a summary of the mutable layer with the entry counts but not the entries themselves. Use it in @@ -425,6 +427,10 @@ func (mm *MutableLayer) printShort() string { // Print dumps the entire list, posting values included. It is meant for interactive debugging only. Anything that // ends up in a log line or an error message should use debugString() instead. func (l *List) Print() string { + if l.plist.Pack != nil { + uids := codec.Decode(l.plist.Pack, 0) + return fmt.Sprintf("minTs: %d, committed uids: %+v, mutationMap: %s", l.minTs, uids, l.mutationMap.print()) + } return fmt.Sprintf("minTs: %d, plist: %+v, mutationMap: %s", l.minTs, l.plist, l.mutationMap.print()) } @@ -733,6 +739,53 @@ type ListOptions struct { First int } +func NewPostingExisting(p *pb.Posting, t *pb.DirectedEdge) { + var op uint32 + switch t.Op { + case pb.DirectedEdge_SET: + op = Set + case pb.DirectedEdge_OVR: + op = Ovr + case pb.DirectedEdge_DEL: + op = Del + default: + x.Fatalf("Unhandled operation: %+v", t) + } + + var postingType pb.Posting_PostingType + switch { + case len(t.Lang) > 0: + postingType = pb.Posting_VALUE_LANG + case t.ValueId == 0: + postingType = pb.Posting_VALUE + default: + postingType = pb.Posting_REF + } + + p.Uid = t.ValueId + p.Value = t.Value + p.ValType = t.ValueType + p.PostingType = postingType + p.LangTag = []byte(t.Lang) + p.Op = op + p.Facets = t.Facets +} + +func GetPostingOp(top uint32) pb.DirectedEdge_Op { + var op pb.DirectedEdge_Op + switch top { + case Set: + op = pb.DirectedEdge_SET + case Del: + op = pb.DirectedEdge_DEL + case Ovr: + op = pb.DirectedEdge_OVR + default: + x.Fatalf("Unhandled operation: %+v", top) + } + return op +} + // NewPosting takes the given edge and returns its equivalent representation as a posting. func NewPosting(t *pb.DirectedEdge) *pb.Posting { var op uint32 @@ -810,12 +863,12 @@ func (l *List) updateMutationLayer(mpost *pb.Posting, singleUidUpdate, hasCountI // The current value should be deleted in favor of this value. This needs to // be done because the fingerprint for the value is not math.MaxUint64 as is // the case with the rest of the scalar predicates. - newPlist := &pb.PostingList{} + newPlist := &pb.PostingList{ + Postings: []*pb.Posting{createDeleteAllPosting()}, + } if mpost.Op != Del { - // If we are setting a new value then we can just delete all the older values. - newPlist.Postings = append(newPlist.Postings, createDeleteAllPosting()) + newPlist.Postings = append(newPlist.Postings, mpost) } - newPlist.Postings = append(newPlist.Postings, mpost) l.mutationMap.setCurrentEntries(mpost.StartTs, newPlist) return nil } @@ -854,6 +907,10 @@ func fingerprintEdge(t *pb.DirectedEdge) uint64 { return id } +func FingerprintEdge(t *pb.DirectedEdge) uint64 { + return fingerprintEdge(t) +} + func (l *List) addMutation(ctx context.Context, txn *Txn, t *pb.DirectedEdge) error { l.Lock() defer l.Unlock() @@ -1069,6 +1126,12 @@ func (l *List) setMutationAfterCommit(startTs, commitTs uint64, pl *pb.PostingLi func (l *List) setMutation(startTs uint64, data []byte) { pl := new(pb.PostingList) x.Check(proto.Unmarshal(data, pl)) + l.setMutationWithPosting(startTs, pl) +} + +func (l *List) setMutationWithPosting(startTs uint64, pl *pb.PostingList) { + // pk, _ := x.Parse(l.key) + // fmt.Println("Setting mutation for ", l.key, pk, pl) l.Lock() if l.mutationMap == nil { @@ -1136,6 +1199,13 @@ func (l *List) pickPostings(readTs uint64) (uint64, []*pb.Posting) { } return pi.Uid < pj.Uid }) + + if len(posts) > 0 { + if hasDeleteAll(posts[0]) { + posts = posts[1:] + } + } + return deleteAllMarker, posts } @@ -1284,6 +1354,11 @@ func (l *List) GetLength(readTs uint64) int { length += immutLen } + // pureLength := l.length(readTs, 0) + // if pureLength != length { + // panic(fmt.Sprintf("pure length != length %d %d %s", pureLength, length, l.Print())) + // } + return length } @@ -1493,11 +1568,17 @@ func (l *List) Rollup(alloc *z.Allocator, readTs uint64) ([]*bpb.KV, error) { return bytes.Compare(kvs[i].Key, kvs[j].Key) <= 0 }) - x.PrintRollup(out.plist, out.parts, l.key, kv.Version) + PrintRollup(out.plist, out.parts, l.key, kv.Version) x.VerifyPostingSplits(kvs, out.plist, out.parts, l.key) return kvs, nil } +func PrintRollup(plist *pb.PostingList, parts map[uint64]*pb.PostingList, baseKey []byte, ts uint64) { + k, _ := x.Parse(baseKey) + uids := codec.Decode(plist.Pack, 0) + fmt.Printf("[TXNLOG] DOING ROLLUP for key: %+v at timestamp: %v, uids: %+v\n", k, ts, uids) +} + // ToBackupPostingList uses rollup to generate a single list with no splits. // It's used during backup so that each backed up posting list is stored in a single key. func (l *List) ToBackupPostingList( @@ -2129,6 +2210,18 @@ func (l *List) findStaticValue(readTs uint64) *pb.PostingList { if l.plist != nil && len(l.plist.Postings) > 0 { return l.plist } + if l.plist != nil && l.plist.Pack != nil { + uids := codec.Decode(l.plist.Pack, 0) + return &pb.PostingList{ + Postings: []*pb.Posting{ + { + Uid: uids[0], + ValType: pb.Posting_UID, + Op: Set, + }, + }, + } + } return nil } diff --git a/posting/list_test.go b/posting/list_test.go index 07cfaf1718c..ed15f277ef8 100644 --- a/posting/list_test.go +++ b/posting/list_test.go @@ -124,7 +124,6 @@ func TestGetSinglePosting(t *testing.T) { res, err := l.StaticValue(1) require.NoError(t, err) - fmt.Println(res, res == nil) require.Equal(t, res == nil, true) l.plist = create_pl(1, 1) diff --git a/posting/lists.go b/posting/lists.go index 572811d5889..d4a0fbebd5c 100644 --- a/posting/lists.go +++ b/posting/lists.go @@ -19,6 +19,7 @@ import ( "github.com/dgraph-io/badger/v4" "github.com/dgraph-io/dgo/v250/protos/api" "github.com/dgraph-io/dgraph/v25/protos/pb" + "github.com/dgraph-io/dgraph/v25/types" "github.com/dgraph-io/dgraph/v25/x" "github.com/dgraph-io/ristretto/v2/z" ) @@ -66,10 +67,7 @@ type LocalCache struct { startTs uint64 commitTs uint64 - // The keys for these maps is a string representation of the Badger key for the posting list. - // deltas keep track of the updates made by txn. These must be kept around until written to disk - // during commit. - deltas map[string][]byte + deltas *Deltas // max committed timestamp of the read posting lists. maxVersions map[string]uint64 @@ -78,6 +76,143 @@ type LocalCache struct { plists map[string]*List } +// The keys for these maps is a string representation of the Badger key for the posting list. +// deltas keep track of the updates made by txn. These must be kept around until written to disk +// during commit. +type Deltas struct { + deltas *types.LockedShardedMap[string, []byte] + + // We genereate indexes for the posting lists all at once. Moving them from this map to deltas + // map is uneccessary. More data can be stored per predicate later on. + indexMap map[string]*types.LockedShardedMap[string, *pb.PostingList] +} + +func NewDeltas() *Deltas { + return &Deltas{ + deltas: types.NewLockedShardedMap[string, []byte](), + indexMap: map[string]*types.LockedShardedMap[string, *pb.PostingList]{}, + } +} + +// Call this function after taking a lock on the cache. +func (d *Deltas) GetIndexMapForPredicate(pred string) *types.LockedShardedMap[string, *pb.PostingList] { + val, ok := d.indexMap[pred] + if !ok { + d.indexMap[pred] = types.NewLockedShardedMap[string, *pb.PostingList]() + return d.indexMap[pred] + } + return val +} + +func (d *Deltas) Get(key string) (*pb.PostingList, bool) { + if d == nil { + return nil, false + } + pk, err := x.Parse([]byte(key)) + if err != nil { + return nil, false + } + + res := &pb.PostingList{} + + val, ok := d.deltas.Get(key) + if ok { + if err := proto.Unmarshal(val, res); err != nil { + return nil, false + } + } + + if indexMap, ok := d.indexMap[pk.Attr]; ok { + if value, ok1 := indexMap.Get(key); ok1 { + res.Postings = append(res.Postings, value.Postings...) + } + } + + // fmt.Println("GETTING KEY FROM DELTAS", pk, "res", res, "val", val, "d.deltas", d.deltas, "d.indexMap[pk.Attr]", d.indexMap[pk.Attr], "ok", ok) + + return res, len(res.Postings) > 0 +} + +func (d *Deltas) GetBytes(key string) ([]byte, bool) { + if len(d.indexMap) == 0 { + return d.deltas.Get(key) + } + + pk, err := x.Parse([]byte(key)) + if err != nil { + return nil, false + } + + delta, deltaFound := d.deltas.Get(key) + + if indexMap, ok := d.indexMap[pk.Attr]; ok { + if value, ok1 := indexMap.Get(key); ok1 && deltaFound && len(value.Postings) > 0 { + res := &pb.PostingList{} + if err := proto.Unmarshal(delta, res); err != nil { + return nil, false + } + res.Postings = append(res.Postings, value.Postings...) + data, err := proto.Marshal(res) + if err != nil { + return nil, false + } + return data, true + } else if ok1 && len(value.Postings) > 0 { + data, err := proto.Marshal(value) + if err != nil { + return nil, false + } + return data, true + } + } + + return delta, deltaFound +} + +func (d *Deltas) AddToDeltas(key string, delta []byte) { + d.deltas.Set(key, delta) +} + +func (d *Deltas) IterateKeys(fn func(key string) error) error { + for _, v := range d.indexMap { + if err := v.Iterate(func(key string, value *pb.PostingList) error { + return fn(key) + }); err != nil { + return err + } + } + if err := d.deltas.Iterate(func(key string, value []byte) error { + return fn(key) + }); err != nil { + return err + } + return nil +} + +func (d *Deltas) IteratePostings(fn func(key string, value *pb.PostingList) error) error { + return d.IterateKeys(func(key string) error { + val, ok := d.Get(key) + if !ok { + return nil + } + return fn(key, val) + }) +} + +func (d *Deltas) IterateBytes(fn func(key string, value []byte) error) error { + return d.IterateKeys(func(key string) error { + val, ok := d.Get(key) + if !ok { + return nil + } + data, err := proto.Marshal(val) + if err != nil { + return err + } + return fn(key, data) + }) +} + // struct to implement LocalCache interface from vector-indexer // acts as wrapper for dgraph *LocalCache type viLocalCache struct { @@ -132,7 +267,7 @@ func NewViLocalCache(delegate *LocalCache) *viLocalCache { func NewLocalCache(startTs uint64) *LocalCache { return &LocalCache{ startTs: startTs, - deltas: make(map[string][]byte), + deltas: NewDeltas(), plists: make(map[string]*List), maxVersions: make(map[string]uint64), } @@ -261,9 +396,20 @@ func (lc *LocalCache) getInternal(key []byte, readFromDisk, readUids bool) (*Lis lc.RLock() defer lc.RUnlock() if lc.plists == nil { - return getNew(key, pstore, lc.startTs, readUids) + l, err := getNew(key, pstore, lc.startTs, readUids) + if err != nil { + return nil, err + } + pk, _ := x.Parse(key) + fmt.Println("READING NEW PLIST", pk, l.Print()) + return l, nil } if l, ok := lc.plists[skey]; ok { + if delta, ok := lc.deltas.Get(skey); ok && delta != nil { + l.setMutationWithPosting(lc.startTs, delta) + } + pk, _ := x.Parse(key) + fmt.Println("READING PLIST", pk, l.Print()) return l, nil } return nil, nil @@ -291,10 +437,13 @@ func (lc *LocalCache) getInternal(key []byte, readFromDisk, readUids bool) (*Lis // If we just brought this posting list into memory and we already have a delta for it, let's // apply it before returning the list. lc.RLock() - if delta, ok := lc.deltas[skey]; ok && len(delta) > 0 { - pl.setMutation(lc.startTs, delta) + if delta, ok := lc.deltas.Get(skey); ok && delta != nil { + pl.setMutationWithPosting(lc.startTs, delta) } lc.RUnlock() + + pk, _ := x.Parse(key) + fmt.Println("READING ", pk, pl.Print()) return lc.SetIfAbsent(skey, pl), nil } @@ -331,21 +480,26 @@ func (lc *LocalCache) readPostingListAt(key []byte) (*pb.PostingList, error) { // given key. This is used for retrieving the value of a scalar predicats. func (lc *LocalCache) GetSinglePosting(key []byte) (*pb.PostingList, error) { // This would return an error if there is some data in the local cache, but we couldn't read it. + + pk, _ := x.Parse(key) + fmt.Println("READING SINGLE ", pk) + getListFromLocalCache := func() (*pb.PostingList, error) { lc.RLock() - pl := &pb.PostingList{} - if delta, ok := lc.deltas[string(key)]; ok && len(delta) > 0 { - err := proto.Unmarshal(delta, pl) + if delta, ok := lc.deltas.Get(string(key)); ok && delta != nil { lc.RUnlock() - return pl, err + fmt.Println("READING SINGLE FROM DELTA", pk, delta) + return delta, nil } l := lc.plists[string(key)] lc.RUnlock() if l != nil { - return l.StaticValue(lc.startTs) + res, err := l.StaticValue(lc.startTs) + fmt.Println("READING SINGLE FROM PLISTS", pk, res, err, l.Print()) + return res, err } return nil, nil @@ -363,25 +517,28 @@ func (lc *LocalCache) GetSinglePosting(key []byte) (*pb.PostingList, error) { } pl, err := getPostings() + + fmt.Println("READING SINGLE ", pk, "pl:", pl) + if err == badger.ErrKeyNotFound { + fmt.Println("READING ", pk, nil) return nil, nil } if err != nil { + fmt.Println("READING ", pk, err) return nil, err } // Filter and remove STAR_ALL and OP_DELETE Postings idx := 0 for _, postings := range pl.Postings { - if hasDeleteAll(postings) { - return nil, nil - } if postings.Op != Del { pl.Postings[idx] = postings idx++ } } pl.Postings = pl.Postings[:idx] + // fmt.Println("READING ", pk, pl) return pl, nil } @@ -412,7 +569,7 @@ func (lc *LocalCache) UpdateDeltasAndDiscardLists() { for key, pl := range lc.plists { data := pl.getMutation(lc.startTs) if len(data) > 0 { - lc.deltas[key] = data + lc.deltas.AddToDeltas(key, data) } lc.maxVersions[key] = pl.maxVersion() // We can't run pl.release() here because LocalCache is still being used by other callers @@ -425,16 +582,19 @@ func (lc *LocalCache) UpdateDeltasAndDiscardLists() { func (lc *LocalCache) fillPreds(ctx *api.TxnContext, gid uint32) { lc.RLock() defer lc.RUnlock() - for key := range lc.deltas { + if err := lc.deltas.IterateKeys(func(key string) error { pk, err := x.Parse([]byte(key)) x.Check(err) if len(pk.Attr) == 0 { - continue + return nil } // Also send the group id that the predicate was being served by. This is useful when // checking if Zero should allow a commit during a predicate move. predKey := fmt.Sprintf("%d-%s", gid, pk.Attr) ctx.Preds = append(ctx.Preds, predKey) + return nil + }); err != nil { + x.Check(err) } ctx.Preds = x.Unique(ctx.Preds) } diff --git a/posting/mvcc.go b/posting/mvcc.go index aaea3f55626..ad6f1be162f 100644 --- a/posting/mvcc.go +++ b/posting/mvcc.go @@ -9,6 +9,7 @@ import ( "bytes" "context" "encoding/hex" + "fmt" "math" "strconv" "sync" @@ -273,8 +274,11 @@ func (txn *Txn) CommitToDisk(writer *TxnWriter, commitTs uint64) error { defer cache.Unlock() var keys []string - for key := range cache.deltas { + if err := cache.deltas.IterateKeys(func(key string) error { keys = append(keys, key) + return nil + }); err != nil { + return err } defer func() { @@ -293,10 +297,19 @@ func (txn *Txn) CommitToDisk(writer *TxnWriter, commitTs uint64) error { err := writer.update(commitTs, func(btxn *badger.Txn) error { for ; idx < len(keys); idx++ { key := keys[idx] - data := cache.deltas[key] - if len(data) == 0 { + data, ok := cache.deltas.GetBytes(key) + if !ok || data == nil { + continue + } + pl := &pb.PostingList{} + if err := proto.Unmarshal(data, pl); err != nil { + return err + } + if len(pl.Postings) == 0 { continue } + pk, _ := x.Parse([]byte(key)) + fmt.Println("COMMITTING", pk, pl) if ts := cache.maxVersions[key]; ts >= commitTs { // Skip write because we already have a write at a higher ts. // Logging here can cause a lot of output when doing Raft log replay. So, let's @@ -490,7 +503,8 @@ func (ml *MemoryLayer) IterateDisk(ctx context.Context, f IterateDiskArgs) error if err != nil { return err } - empty, err := l.IsEmpty(f.ReadTs, 0) + empty, err := false, nil + //empty, err := l.IsEmpty(f.ReadTs, 0) switch { case err != nil: return err @@ -579,7 +593,7 @@ func (ml *MemoryLayer) wait() { ml.cache.wait() } -func (ml *MemoryLayer) updateItemInCache(key string, delta []byte, startTs, commitTs uint64) { +func (ml *MemoryLayer) updateItemInCache(key string, delta *pb.PostingList, startTs, commitTs uint64) { if commitTs == 0 { return } @@ -591,24 +605,19 @@ func (ml *MemoryLayer) updateItemInCache(key string, delta []byte, startTs, comm } val, ok := ml.cache.get([]byte(key)) - if !ok { - return - } - val.lastUpdate = commitTs + if ok && val.list != nil && val.list.minTs <= commitTs { + val.lastUpdate = commitTs - if val.list != nil { - p := new(pb.PostingList) - x.Check(proto.Unmarshal(delta, p)) - - if p.Pack == nil { - val.list.setMutationAfterCommit(startTs, commitTs, p, true) - checkForRollup([]byte(key), val.list) - } else { - // Data was rolled up. TODO figure out how is UpdateCachedKeys getting delta which is pack) - ml.del([]byte(key)) + if val.list != nil { + if delta.Pack == nil { + val.list.setMutationAfterCommit(startTs, commitTs, delta, true) + checkForRollup([]byte(key), val.list) + } else { + // Data was rolled up. TODO figure out how is UpdateCachedKeys getting delta which is pack) + ml.del([]byte(key)) + } } - } } @@ -619,8 +628,11 @@ func (txn *Txn) UpdateCachedKeys(commitTs uint64) { } MemLayerInstance.wait() - for key, delta := range txn.cache.deltas { - MemLayerInstance.updateItemInCache(key, delta, txn.StartTs, commitTs) + if err := txn.cache.deltas.IteratePostings(func(key string, value *pb.PostingList) error { + MemLayerInstance.updateItemInCache(key, value, txn.StartTs, commitTs) + return nil + }); err != nil { + glog.Errorf("UpdateCachedKeys: error while iterating deltas: %v", err) } } diff --git a/posting/mvcc_test.go b/posting/mvcc_test.go index 1fab24d9520..8588a92d2c8 100644 --- a/posting/mvcc_test.go +++ b/posting/mvcc_test.go @@ -74,7 +74,7 @@ func TestCacheAfterDeltaUpdateRecieved(t *testing.T) { // Write delta to disk and call update txn := Oracle().RegisterStartTs(5) - txn.cache.deltas[string(key)] = delta + txn.cache.deltas.AddToDeltas(string(key), delta) writer := NewTxnWriter(pstore) require.NoError(t, txn.CommitToDisk(writer, 15)) @@ -146,6 +146,8 @@ func BenchmarkTestCache(b *testing.B) { } func TestRollupTimestamp(t *testing.T) { + require.NoError(t, schema.ParseBytes([]byte("rollup: [uid] ."), 1)) + attr := x.AttrInRootNamespace("rollup") key := x.DataKey(attr, 1) // 3 Delta commits. @@ -213,7 +215,7 @@ func TestCacheStaleWhenMaxTsLessThanReadTs(t *testing.T) { require.NoError(t, err) txn1 := Oracle().RegisterStartTs(5) - txn1.cache.deltas[string(key)] = delta1 + txn1.cache.deltas.AddToDeltas(string(key), delta1) writer1 := NewTxnWriter(pstore) require.NoError(t, txn1.CommitToDisk(writer1, 10)) @@ -246,7 +248,7 @@ func TestCacheStaleWhenMaxTsLessThanReadTs(t *testing.T) { require.NoError(t, err) txn2 := Oracle().RegisterStartTs(15) - txn2.cache.deltas[string(key)] = delta2 + txn2.cache.deltas.AddToDeltas(string(key), delta2) writer2 := NewTxnWriter(pstore) require.NoError(t, txn2.CommitToDisk(writer2, 20)) @@ -604,6 +606,8 @@ func TestResetIfCurrentLeavesADroppedEntryOut(t *testing.T) { } func TestPostingListRead(t *testing.T) { + require.NoError(t, schema.ParseBytes([]byte("emptypl: [uid] ."), 1)) + attr := x.AttrInRootNamespace("emptypl") key := x.DataKey(attr, 1) diff --git a/posting/oracle.go b/posting/oracle.go index 362d00667d7..0454dc960a7 100644 --- a/posting/oracle.go +++ b/posting/oracle.go @@ -9,6 +9,7 @@ import ( "context" "encoding/hex" "math" + "sort" "sync" "sync/atomic" "time" @@ -16,6 +17,7 @@ import ( "github.com/dgraph-io/badger/v4" "github.com/golang/glog" ostats "go.opencensus.io/stats" + "google.golang.org/protobuf/proto" "github.com/dgraph-io/dgraph/v25/protos/pb" "github.com/dgraph-io/dgraph/v25/tok/index" @@ -54,6 +56,84 @@ type Txn struct { lastUpdate time.Time cache *LocalCache // This pointer does not get modified. + + pointers [](*[]byte) +} + +func (txn *Txn) AddPointer(p *[]byte) { + if txn.pointers == nil { + txn.pointers = make([](*[]byte), 1) + txn.pointers[0] = p + } + txn.pointers = append(txn.pointers, p) +} + +func (txn *Txn) GetPointers() [](*[]byte) { + return txn.pointers +} + +func SortAndDedupPostings(postings []*pb.Posting) []*pb.Posting { + // Sort postings by UID + sort.Slice(postings, func(i, j int) bool { + return postings[i].Uid < postings[j].Uid + }) + + //In-place filtering: keep only the last occurrence for each UID + n := 0 // write index + for i := 0; i < len(postings); { + j := i + 1 + // Skip all postings with same UID + for j < len(postings) && postings[j].Uid == postings[i].Uid { + j++ + } + // Keep only the last posting for this UID + postings[n] = postings[j-1] + n++ + i = j + } + return postings[:n] +} + +func (txn *Txn) AddDelta(key string, input *pb.PostingList, doSortAndDedup bool, addToList bool) (*pb.PostingList, error) { + txn.cache.Lock() + defer txn.cache.Unlock() + + pl := new(pb.PostingList) + + if addToList { + prevDelta, ok := txn.cache.deltas.Get(key) + if ok { + pl.Postings = append(pl.Postings, prevDelta.Postings...) + } + } + + pl.Postings = append(pl.Postings, input.Postings...) + + if doSortAndDedup { + pl.Postings = SortAndDedupPostings(pl.Postings) + } + + newPl, err := proto.Marshal(pl) + if err != nil { + glog.Errorf("Error marshalling posting list: %v", err) + return nil, err + } + + txn.cache.deltas.AddToDeltas(key, newPl) + + list, listOk := txn.cache.plists[key] + if listOk { + list.setMutation(txn.StartTs, newPl) + } + return pl, nil +} + +func (txn *Txn) LockCache() { + txn.cache.Lock() +} + +func (txn *Txn) UnlockCache() { + txn.cache.Unlock() } // struct to implement Txn interface from vector-indexer @@ -324,8 +404,11 @@ func (o *oracle) ProcessDelta(delta *pb.OracleDelta) { for _, status := range delta.Txns { txn := o.pendingTxns[status.StartTs] if txn != nil && status.CommitTs > 0 { - for k := range txn.cache.deltas { - IncrRollup.addKeyToBatch([]byte(k), 0) + if err := txn.cache.deltas.IterateBytes(func(key string, value []byte) error { + IncrRollup.addKeyToBatch([]byte(key), 0) + return nil + }); err != nil { + glog.Errorf("ProcessDelta: error while iterating deltas for txn %d: %v", status.StartTs, err) } } delete(o.pendingTxns, status.StartTs) @@ -379,17 +462,6 @@ func (o *oracle) GetTxn(startTs uint64) *Txn { return o.pendingTxns[startTs] } -func (txn *Txn) matchesDelta(ok func(key []byte) bool) bool { - txn.Lock() - defer txn.Unlock() - for key := range txn.cache.deltas { - if ok([]byte(key)) { - return true - } - } - return false -} - // IterateTxns returns a list of start timestamps for currently pending transactions, which match // the provided function. func (o *oracle) IterateTxns(ok func(key []byte) bool) []uint64 { @@ -397,8 +469,13 @@ func (o *oracle) IterateTxns(ok func(key []byte) bool) []uint64 { defer o.RUnlock() var timestamps []uint64 for startTs, txn := range o.pendingTxns { - if txn.matchesDelta(ok) { - timestamps = append(timestamps, startTs) + if err := txn.cache.deltas.IterateBytes(func(key string, value []byte) error { + if ok([]byte(key)) { + timestamps = append(timestamps, startTs) + } + return nil + }); err != nil { + glog.Errorf("IterateTxns: error while iterating deltas for txn %d: %v", startTs, err) } } return timestamps diff --git a/query/upgrade_test.go b/query/upgrade_test.go index 9421e83fca9..dcec57e4f8c 100644 --- a/query/upgrade_test.go +++ b/query/upgrade_test.go @@ -50,7 +50,7 @@ func TestMain(m *testing.M) { WithReplicas(1).WithACL(time.Hour).WithVersion(uc.Before) c, err := dgraphtest.NewLocalCluster(conf) x.Panic(err) - defer func() { c.Cleanup(code != 0) }() + defer func() { c.Cleanup(true) }() x.Panic(c.Start()) hc, err := c.HTTPClient() diff --git a/schema/schema.go b/schema/schema.go index f8852d2aa0e..b3beb650a27 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -662,8 +662,7 @@ func initialTypesInternal(namespace uint64, all bool) []*pb.TypeUpdate { ValueType: pb.Posting_STRING, }, }, - }, - &pb.TypeUpdate{ + }, &pb.TypeUpdate{ TypeName: "dgraph.graphql.persisted_query", Fields: []*pb.SchemaUpdate{ { @@ -693,24 +692,23 @@ func initialTypesInternal(namespace uint64, all bool) []*pb.TypeUpdate { if all || x.WorkerConfig.AclEnabled { // These type definitions are required for deleteUser and deleteGroup GraphQL API to work // properly. - initialTypes = append(initialTypes, - &pb.TypeUpdate{ - TypeName: "dgraph.type.User", - Fields: []*pb.SchemaUpdate{ - { - Predicate: "dgraph.xid", - ValueType: pb.Posting_STRING, - }, - { - Predicate: "dgraph.password", - ValueType: pb.Posting_PASSWORD, - }, - { - Predicate: "dgraph.user.group", - ValueType: pb.Posting_UID, - }, + initialTypes = append(initialTypes, &pb.TypeUpdate{ + TypeName: "dgraph.type.User", + Fields: []*pb.SchemaUpdate{ + { + Predicate: "dgraph.xid", + ValueType: pb.Posting_STRING, + }, + { + Predicate: "dgraph.password", + ValueType: pb.Posting_PASSWORD, + }, + { + Predicate: "dgraph.user.group", + ValueType: pb.Posting_UID, }, }, + }, &pb.TypeUpdate{ TypeName: "dgraph.type.Group", Fields: []*pb.SchemaUpdate{ @@ -767,36 +765,31 @@ func CompleteInitialSchema(namespace uint64) []*pb.SchemaUpdate { func initialSchemaInternal(namespace uint64, all bool) []*pb.SchemaUpdate { var initialSchema []*pb.SchemaUpdate - initialSchema = append(initialSchema, []*pb.SchemaUpdate{ - { + initialSchema = append(initialSchema, + &pb.SchemaUpdate{ Predicate: "dgraph.type", ValueType: pb.Posting_STRING, Directive: pb.SchemaUpdate_INDEX, Tokenizer: []string{"exact"}, List: true, - }, - { + }, &pb.SchemaUpdate{ Predicate: "dgraph.drop.op", ValueType: pb.Posting_STRING, - }, - { + }, &pb.SchemaUpdate{ Predicate: "dgraph.graphql.schema", ValueType: pb.Posting_STRING, - }, - { + }, &pb.SchemaUpdate{ Predicate: "dgraph.graphql.xid", ValueType: pb.Posting_STRING, Directive: pb.SchemaUpdate_INDEX, Tokenizer: []string{"exact"}, Upsert: true, - }, - { + }, &pb.SchemaUpdate{ Predicate: "dgraph.graphql.p_query", ValueType: pb.Posting_STRING, Directive: pb.SchemaUpdate_INDEX, Tokenizer: []string{"sha256"}, - }, - }...) + }) if namespace == x.RootNamespace { initialSchema = append(initialSchema, []*pb.SchemaUpdate{ diff --git a/types/locked_sharded_map.go b/types/locked_sharded_map.go new file mode 100644 index 00000000000..19db5ff8cea --- /dev/null +++ b/types/locked_sharded_map.go @@ -0,0 +1,187 @@ +/* + * SPDX-FileCopyrightText: © 2017-2025 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Package types contains some very common utilities used by Dgraph. These utilities +// are of "miscellaneous" nature, e.g., error checking. +package types + +import ( + "sync" + + "github.com/dgryski/go-farm" +) + +// LockedShardedMap is a thread-safe, sharded map with generic key-value types. +type LockedShardedMap[K comparable, V any] struct { + shards []map[K]V + locks []sync.RWMutex +} + +// NewLockedShardedMap creates a new LockedShardedMap. +func NewLockedShardedMap[K comparable, V any]() *LockedShardedMap[K, V] { + shards := make([]map[K]V, NumShards) + locks := make([]sync.RWMutex, NumShards) + for i := range shards { + shards[i] = make(map[K]V) + } + return &LockedShardedMap[K, V]{shards: shards, locks: locks} +} + +func (s *LockedShardedMap[K, V]) getShardIndex(key K) int { + // Only works for integer-like keys (uint64 etc). For generic types, + // a better hash function is needed. We'll assume uint64 for now. + switch k := any(key).(type) { + case uint64: + return int(k % uint64(NumShards)) + case string: + return int(farm.Fingerprint64([]byte(k)) % uint64(NumShards)) + default: + panic("LockedShardedMap only supports uint64 and string keys for now") + } +} + +func (s *LockedShardedMap[K, V]) Set(key K, value V) { + if s == nil { + return + } + index := s.getShardIndex(key) + s.locks[index].Lock() + defer s.locks[index].Unlock() + s.shards[index][key] = value +} + +func (s *LockedShardedMap[K, V]) Get(key K) (V, bool) { + var zero V + if s == nil { + return zero, false + } + index := s.getShardIndex(key) + s.locks[index].RLock() + defer s.locks[index].RUnlock() + val, ok := s.shards[index][key] + return val, ok +} + +func (s *LockedShardedMap[K, V]) Update(key K, update func(V, bool) V) { + if s == nil { + return + } + index := s.getShardIndex(key) + s.locks[index].Lock() + defer s.locks[index].Unlock() + val, ok := s.shards[index][key] + s.shards[index][key] = update(val, ok) +} + +func (s *LockedShardedMap[K, V]) Merge(other *LockedShardedMap[K, V], ag func(a, b V) V) { + var wg sync.WaitGroup + for i := range s.shards { + wg.Add(1) + go func(i int) { + defer wg.Done() + otherShard := other.shards[i] + for k, v := range otherShard { + s.locks[i].Lock() + if existing, ok := s.shards[i][k]; ok { + s.shards[i][k] = ag(existing, v) + } else { + s.shards[i][k] = v + } + s.locks[i].Unlock() + } + }(i) + } + wg.Wait() +} + +func (s *LockedShardedMap[K, V]) Len() int { + if s == nil { + return 0 + } + var count int + for i := range s.shards { + s.locks[i].RLock() + count += len(s.shards[i]) + s.locks[i].RUnlock() + } + return count +} + +func (s *LockedShardedMap[K, V]) ParallelIterate(f func(K, V) error) error { + if s == nil { + return nil + } + + var ( + wg sync.WaitGroup + errCh = make(chan error, 1) + once sync.Once + ) + + for i := range s.shards { + wg.Add(1) + go func(i int) { + defer wg.Done() + + s.locks[i].RLock() + defer s.locks[i].RUnlock() + + for k, v := range s.shards[i] { + if err := f(k, v); err != nil { + once.Do(func() { + errCh <- err + }) + return + } + } + }(i) + } + + // Wait in a separate goroutine so we can still select on errCh. + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + + select { + case err := <-errCh: + return err + case <-done: + return nil + } +} + +func (s *LockedShardedMap[K, V]) Iterate(f func(K, V) error) error { + if s == nil { + return nil + } + for i := range s.shards { + s.locks[i].RLock() + for k, v := range s.shards[i] { + if err := f(k, v); err != nil { + s.locks[i].RUnlock() + return err + } + } + s.locks[i].RUnlock() + } + return nil +} + +func (s *LockedShardedMap[K, V]) IsEmpty() bool { + if s == nil { + return true + } + for i := range s.shards { + s.locks[i].RLock() + if len(s.shards[i]) > 0 { + s.locks[i].RUnlock() + return false + } + s.locks[i].RUnlock() + } + return true +} diff --git a/worker/draft.go b/worker/draft.go index 124f59057a5..b375680475b 100644 --- a/worker/draft.go +++ b/worker/draft.go @@ -516,6 +516,22 @@ func (n *node) applyMutations(ctx context.Context, proposal *pb.Proposal) (rerr m := proposal.Mutations + txn := posting.Oracle().RegisterStartTs(m.StartTs) + if txn.ShouldAbort() { + span.AddEvent("Txn should abort.", trace.WithAttributes( + attribute.Int64("start_ts", int64(m.StartTs)), + )) + return x.ErrConflict + } + // Discard the posting lists from cache to release memory at the end. + defer txn.Update() + + featureFlag := true + if featureFlag { + mp := posting.NewMutationPipeline(txn) + return mp.Process(ctx, m.Edges) + } + // It is possible that the user gives us multiple versions of the same edge, one with no facets // and another with facets. In that case, use stable sort to maintain the ordering given to us // by the user. @@ -528,16 +544,6 @@ func (n *node) applyMutations(ctx context.Context, proposal *pb.Proposal) (rerr return ei.GetEntity() < ej.GetEntity() }) - txn := posting.Oracle().RegisterStartTs(m.StartTs) - if txn.ShouldAbort() { - span.AddEvent("Txn should abort.", trace.WithAttributes( - attribute.Int64("start_ts", int64(m.StartTs)), - )) - return x.ErrConflict - } - // Discard the posting lists from cache to release memory at the end. - defer txn.Update() - process := func(edges []*pb.DirectedEdge) error { var retries int for _, edge := range edges { diff --git a/worker/draft_test.go b/worker/draft_test.go index 9fe784b4f63..18822254ffc 100644 --- a/worker/draft_test.go +++ b/worker/draft_test.go @@ -38,6 +38,107 @@ func getEntryForCommit(index, startTs, commitTs uint64) raftpb.Entry { return raftpb.Entry{Index: index, Term: 1, Type: raftpb.EntryNormal, Data: data} } +// func BenchmarkProcessListIndex(b *testing.B) { +// dir, err := os.MkdirTemp("", "storetest_") +// x.Check(err) +// defer os.RemoveAll(dir) + +// opt := badger.DefaultOptions(dir) +// ps, err := badger.OpenManaged(opt) +// x.Check(err) +// pstore = ps +// // Not using posting list cache +// posting.Init(ps, 0, false) +// Init(ps) +// err = schema.ParseBytes([]byte("testAttr: [string] @index(exact) ."), 1) +// require.NoError(b, err) + +// ctx := context.Background() +// pipeline := &PredicatePipeline{ +// attr: "0-testAttr", +// edges: make(chan *pb.DirectedEdge, 1000), +// wg: &sync.WaitGroup{}, +// errCh: make(chan error, 1), +// } + +// txn := posting.Oracle().RegisterStartTs(5) +// mp := &MutationPipeline{txn: txn} + +// // Generate 1000 edges +// populatePipeline := func() { +// pipeline = &PredicatePipeline{ +// attr: "0-testAttr", +// edges: make(chan *pb.DirectedEdge, 1000), +// wg: &sync.WaitGroup{}, +// errCh: make(chan error, 1), +// } + +// txn = posting.Oracle().RegisterStartTs(5) +// mp = &MutationPipeline{txn: txn} + +// for i := 0; i < 1000; i++ { +// edge := &pb.DirectedEdge{ +// Entity: uint64(i + 1), +// Attr: "0-testAttr", +// Value: []byte(fmt.Sprintf("value%d", rand.Intn(1000))), +// ValueType: pb.Posting_STRING, +// Op: pb.DirectedEdge_SET, +// } +// pipeline.edges <- edge +// } +// } + +// b.ResetTimer() + +// b.Run("Baseline", func(b *testing.B) { +// for i := 0; i < b.N; i++ { +// populatePipeline() +// } +// }) + +// b.Run("DefaultPipeline", func(b *testing.B) { +// for i := 0; i < b.N; i++ { +// populatePipeline() +// var wg sync.WaitGroup +// wg.Add(1) +// go func() { +// mp.DefaultPipeline(ctx, pipeline) +// wg.Done() +// }() +// close(pipeline.edges) +// wg.Wait() +// } +// }) + +// b.Run("ProcessListWithoutIndex", func(b *testing.B) { +// for i := 0; i < b.N; i++ { +// populatePipeline() +// var wg sync.WaitGroup +// wg.Add(1) +// go func() { +// mp.ProcessListWithoutIndex(ctx, pipeline) +// wg.Done() +// }() +// close(pipeline.edges) +// wg.Wait() +// } +// }) + +// b.Run("ProcessListIndex", func(b *testing.B) { +// for i := 0; i < b.N; i++ { +// populatePipeline() +// var wg sync.WaitGroup +// wg.Add(1) +// go func() { +// mp.ProcessListIndex(ctx, pipeline) +// wg.Done() +// }() +// close(pipeline.edges) +// wg.Wait() +// } +// }) +// } + func TestCalculateSnapshot(t *testing.T) { dir := t.TempDir() ds := raftwal.Init(dir) diff --git a/worker/mutation.go b/worker/mutation.go index 5efc6b2d1d7..fc52f63ec78 100644 --- a/worker/mutation.go +++ b/worker/mutation.go @@ -54,6 +54,16 @@ func isDeletePredicateEdge(edge *pb.DirectedEdge) bool { return edge.Entity == 0 && isStarAll(edge.Value) } +func newRunMutations(ctx context.Context, edges []*pb.DirectedEdge, txn *posting.Txn) error { + mp := posting.NewMutationPipeline(txn) + return mp.Process(ctx, edges) +} + +func newRunMutation(ctx context.Context, edge *pb.DirectedEdge, txn *posting.Txn) error { + mp := posting.NewMutationPipeline(txn) + return mp.Process(ctx, []*pb.DirectedEdge{edge}) +} + // runMutation goes through all the edges and applies them. func runMutation(ctx context.Context, edge *pb.DirectedEdge, txn *posting.Txn) error { ctx = schema.GetWriteContext(ctx) diff --git a/worker/mutation_unit_test.go b/worker/mutation_unit_test.go index 062b5152d2a..8126e40f701 100644 --- a/worker/mutation_unit_test.go +++ b/worker/mutation_unit_test.go @@ -47,8 +47,8 @@ func TestReverseEdge(t *testing.T) { Op: pb.DirectedEdge_DEL, } - x.Check(runMutation(ctx, edge, txn)) - x.Check(runMutation(ctx, edge, txn)) + x.Check(newRunMutation(ctx, edge, txn)) + x.Check(newRunMutation(ctx, edge, txn)) pl, err := txn.Get(x.DataKey(attr, 1)) require.NoError(t, err) @@ -105,10 +105,10 @@ func TestReverseEdgeSetDel(t *testing.T) { Op: pb.DirectedEdge_SET, } - x.Check(runMutation(ctx, edgeSet1, txn)) - x.Check(runMutation(ctx, edgeSet2, txn)) - x.Check(runMutation(ctx, edgeSet3, txn)) - x.Check(runMutation(ctx, edgeDel, txn)) + x.Check(newRunMutation(ctx, edgeSet1, txn)) + x.Check(newRunMutation(ctx, edgeSet2, txn)) + x.Check(newRunMutation(ctx, edgeSet3, txn)) + x.Check(newRunMutation(ctx, edgeDel, txn)) pl, err := txn.Get(x.ReverseKey(attr, 2)) require.NoError(t, err) diff --git a/worker/sort_test.go b/worker/sort_test.go index def4e7a9d02..16075b3c9f6 100644 --- a/worker/sort_test.go +++ b/worker/sort_test.go @@ -8,8 +8,10 @@ package worker import ( "context" "fmt" + "math" "math/rand" "os" + "sync" "testing" "github.com/dgraph-io/badger/v4" @@ -17,7 +19,10 @@ import ( "github.com/dgraph-io/dgraph/v25/posting" "github.com/dgraph-io/dgraph/v25/protos/pb" "github.com/dgraph-io/dgraph/v25/schema" + "github.com/dgraph-io/dgraph/v25/tok" + "github.com/dgraph-io/dgraph/v25/types" "github.com/dgraph-io/dgraph/v25/x" + "github.com/pkg/errors" "github.com/stretchr/testify/require" ) @@ -90,6 +95,319 @@ func TestEmptyTypeSchema(t *testing.T) { x.ParseNamespaceAttr(types[0].TypeName) } +func TestDatetime(t *testing.T) { + // Setup temporary directory for Badger DB + dir, err := os.MkdirTemp("", "storetest_") + require.NoError(t, err) + defer os.RemoveAll(dir) + + opt := badger.DefaultOptions(dir) + ps, err := badger.OpenManaged(opt) + require.NoError(t, err) + posting.Init(ps, 0, false) + Init(ps) + + // Set schema + schemaTxt := ` + t: datetime @index(year) . + ` + err = schema.ParseBytes([]byte(schemaTxt), 1) + require.NoError(t, err) + + ctx := context.Background() + newRunMutation := func(startTs, commitTs uint64, edges []*pb.DirectedEdge) { + txn := posting.Oracle().RegisterStartTs(startTs) + for _, edge := range edges { + require.NoError(t, newRunMutation(ctx, edge, txn)) + } + txn.Update() + writer := posting.NewTxnWriter(ps) + require.NoError(t, txn.CommitToDisk(writer, commitTs)) + require.NoError(t, writer.Flush()) + txn.UpdateCachedKeys(commitTs) + } + + newRunMutation(1, 3, []*pb.DirectedEdge{ + { + Entity: 1, + Attr: x.AttrInRootNamespace("t"), + Value: []byte("2020-01-01T00:00:00Z"), + ValueType: pb.Posting_DEFAULT, + Op: pb.DirectedEdge_SET, + }, + }) + +} + +type indexMutationInfo struct { + tokenizers []tok.Tokenizer + factorySpecs []*tok.FactoryCreateSpec + edge *pb.DirectedEdge // Represents the original uid -> value edge. + val types.Val + op pb.DirectedEdge_Op +} + +func indexTokens(ctx context.Context, info *indexMutationInfo) ([]string, error) { + attr := info.edge.Attr + lang := info.edge.GetLang() + + schemaType, err := schema.State().TypeOf(attr) + if err != nil || !schemaType.IsScalar() { + return nil, errors.Errorf("Cannot index attribute %s of type object.", attr) + } + + if !schema.State().IsIndexed(ctx, attr) { + return nil, errors.Errorf("Attribute %s is not indexed.", attr) + } + sv, err := types.Convert(info.val, schemaType) + if err != nil { + return nil, errors.Wrap(err, "Cannot convert value to scalar type") + } + + var tokens []string + for _, it := range info.tokenizers { + toks, err := tok.BuildTokens(sv.Value, tok.GetTokenizerForLang(it, lang)) + if err != nil { + return tokens, errors.Wrapf(err, "Cannot build tokens for attribute %s", attr) + } + tokens = append(tokens, toks...) + } + return tokens, nil +} + +func TestStringIndexWithLang(t *testing.T) { + // Setup temporary directory for Badger DB + dir, err := os.MkdirTemp("", "storetest_") + require.NoError(t, err) + defer os.RemoveAll(dir) + + opt := badger.DefaultOptions(dir) + ps, err := badger.OpenManaged(opt) + require.NoError(t, err) + posting.Init(ps, 0, false) + Init(ps) + + // Set schema + schemaTxt := ` + name: string @index(fulltext, trigram, term, exact) @lang . + ` + + err = schema.ParseBytes([]byte(schemaTxt), 1) + require.NoError(t, err) + + ctx := context.Background() + newRunMutation := func(startTs, commitTs uint64, edges []*pb.DirectedEdge) { + txn := posting.Oracle().RegisterStartTs(startTs) + require.NoError(t, newRunMutations(ctx, edges, txn)) + txn.Update() + writer := posting.NewTxnWriter(ps) + require.NoError(t, txn.CommitToDisk(writer, commitTs)) + require.NoError(t, writer.Flush()) + txn.UpdateCachedKeys(commitTs) + } + + attr := x.AttrInRootNamespace("name") + + // Prepare 400 mutations across 4 threads, 100 per thread (kept modest for stability). + const ( + threads = 10 + perThread = 20000 + total = threads * perThread + baseStartTs = uint64(10) + ) + + // uid -> value map + values := make(map[uint64]string, total) + for i := 0; i < total; i++ { + uid := uint64(i + 1) + // Simple deterministic values with shared tokens and unique numbers. + values[uid] = fmt.Sprintf("title %d", i+1) + } + + // Build expected token -> set of uids + tokenizers := schema.State().Tokenizer(ctx, attr) + expected := make(map[string]map[uint64]struct{}, total) + for uid, val := range values { + info := &indexMutationInfo{ + tokenizers: tokenizers, + op: pb.DirectedEdge_SET, + val: types.Val{Tid: types.StringID, Value: []byte(val)}, + edge: &pb.DirectedEdge{ + Attr: attr, + Value: []byte(val), + Lang: "en", + Op: pb.DirectedEdge_SET, + }, + } + toks, err := indexTokens(ctx, info) + require.NoError(t, err) + for _, tk := range toks { + if expected[tk] == nil { + expected[tk] = make(map[uint64]struct{}) + } + expected[tk][uid] = struct{}{} + } + } + + // Run 4 threads; each thread writes 100 edges with distinct ts + var wg sync.WaitGroup + wg.Add(threads) + for th := 0; th < threads; th++ { + th := th + go func() { + defer wg.Done() + start := th*perThread + 1 + end := start + perThread + edges := make([]*pb.DirectedEdge, 0, perThread) + for i := start; i < end; i++ { + uid := uint64(i) + edges = append(edges, &pb.DirectedEdge{ + Entity: uid, + Attr: attr, + Value: []byte(values[uid]), + ValueType: pb.Posting_DEFAULT, + Lang: "en", + Op: pb.DirectedEdge_SET, + }) + } + sTs := baseStartTs + uint64(th*10) + cTs := sTs + 2 + newRunMutation(sTs, cTs, edges) + }() + } + wg.Wait() + + // Verify all tokens have the expected UIDs. + readTs := baseStartTs + uint64(threads*10) + 10 + for tk, uidset := range expected { + key := x.IndexKey(attr, tk) + txn := posting.Oracle().RegisterStartTs(readTs) + pl, err := txn.Get(key) + require.NoError(t, err) + lst, err := pl.Uids(posting.ListOptions{ReadTs: readTs}) + require.NoError(t, err) + got := make(map[uint64]struct{}, len(lst.Uids)) + for _, u := range lst.Uids { + got[u] = struct{}{} + } + // Compare sets + require.Equal(t, len(uidset), len(got), "mismatch uid count for token %q", tk) + for u := range uidset { + if _, ok := got[u]; !ok { + t.Fatalf("missing uid %d for token %q", u, tk) + } + } + } +} + +func TestCount(t *testing.T) { + t.Skip() + // Setup temporary directory for Badger DB + dir, err := os.MkdirTemp("", "storetest_") + require.NoError(t, err) + defer os.RemoveAll(dir) + + opt := badger.DefaultOptions(dir) + ps, err := badger.OpenManaged(opt) + require.NoError(t, err) + posting.Init(ps, 0, false) + Init(ps) + + // Set schema + schemaTxt := ` + friends: [uid] @count . + ` + + err = schema.ParseBytes([]byte(schemaTxt), 1) + require.NoError(t, err) + ctx := context.Background() + newRunMutation := func(startTs, commitTs uint64, edges []*pb.DirectedEdge) { + txn := posting.Oracle().RegisterStartTs(startTs) + require.NoError(t, newRunMutations(ctx, edges, txn)) + txn.Update() + writer := posting.NewTxnWriter(ps) + require.NoError(t, txn.CommitToDisk(writer, commitTs)) + require.NoError(t, writer.Flush()) + txn.UpdateCachedKeys(commitTs) + } + + pred := x.AttrInRootNamespace("friends") + + // Prepare mutations such that each subject gets multiple uid edges, and + // each edge is added from a different thread. We also send multiple + // batches per thread. + const ( + subjects = 10 // total number of subjects/entities + edgesPer = 5 // number of edges per subject + threads = 2 // one thread per edge ordinal, touching all subjects + baseStartTs = uint64(10) + total = subjects * edgesPer + ) + + // 1) Pre-generate all mutations into one big slice + edgesAll := make([]*pb.DirectedEdge, 0, total) + for subj := 1; subj <= subjects; subj++ { + uid := uint64(subj) + for e := 0; e < edgesPer; e++ { + // Unique object per (subject, edge-ordinal) pair to avoid duplicates. + // Ensures exactly 'edgesPer' distinct UIDs per subject. + obj := uint64(1_000_000 + subj*100 + e) + edgesAll = append(edgesAll, &pb.DirectedEdge{ + Entity: uid, + Attr: pred, + ValueId: obj, + ValueType: pb.Posting_UID, + Op: pb.DirectedEdge_SET, + }) + } + } + + // Shuffle the edges to simulate randomness (determinism depends on rand.Seed in package scope) + for i := range edgesAll { + j := rand.Intn(i + 1) + edgesAll[i], edgesAll[j] = edgesAll[j], edgesAll[i] + } + + // 2) Dispatch pre-generated mutations into threads, in multiple batches per thread + var wg sync.WaitGroup + wg.Add(threads) + for th := 0; th < threads; th++ { + th := th + go func() { + defer wg.Done() + // Split each thread's disjoint chunk into multiple batches/transactions + const batches = 5 + chunk := total / threads + chunkStart := th * chunk + chunkEnd := chunkStart + chunk + perBatch := chunk / batches + for b := 0; b < batches; b++ { + batchStart := chunkStart + b*perBatch + batchEnd := batchStart + perBatch + if b == batches-1 { + batchEnd = chunkEnd + } + batch := edgesAll[batchStart:batchEnd] + // Space out start/commit timestamps per thread and per batch to avoid collisions + sTs := baseStartTs + uint64(th*100) + uint64(b*2) + cTs := sTs + 1 + newRunMutation(sTs, cTs, batch) + } + }() + } + wg.Wait() + + // Verify the @count index for the exact number of edges per subject. + countKey := x.CountKey(pred, edgesPer, false) + txn := posting.Oracle().RegisterStartTs(math.MaxUint64) + pl, err := txn.Get(countKey) + require.NoError(t, err) + uids, err := pl.Uids(posting.ListOptions{ReadTs: math.MaxUint64}) + require.NoError(t, err) + fmt.Println(uids.Uids) + require.Equal(t, subjects, len(uids.Uids)) +} + func TestDeleteSetWithVarEdgeCorruptsData(t *testing.T) { // Setup temporary directory for Badger DB dir, err := os.MkdirTemp("", "storetest_") @@ -119,10 +437,10 @@ func TestDeleteSetWithVarEdgeCorruptsData(t *testing.T) { uidRoom := uint64(1) uidJohn := uint64(2) - runMutation := func(startTs, commitTs uint64, edges []*pb.DirectedEdge) { + newRunMutation := func(startTs, commitTs uint64, edges []*pb.DirectedEdge) { txn := posting.Oracle().RegisterStartTs(startTs) for _, edge := range edges { - require.NoError(t, runMutation(ctx, edge, txn)) + require.NoError(t, newRunMutation(ctx, edge, txn)) } txn.Update() writer := posting.NewTxnWriter(ps) @@ -132,7 +450,7 @@ func TestDeleteSetWithVarEdgeCorruptsData(t *testing.T) { } // Initial mutation: Set John → Leopard - runMutation(1, 3, []*pb.DirectedEdge{ + newRunMutation(1, 3, []*pb.DirectedEdge{ { Entity: uidJohn, Attr: attrPerson, @@ -162,7 +480,7 @@ func TestDeleteSetWithVarEdgeCorruptsData(t *testing.T) { // Second mutation: Remove John from Leopard, assign Amanda uidAmanda := uint64(3) - runMutation(6, 8, []*pb.DirectedEdge{ + newRunMutation(6, 8, []*pb.DirectedEdge{ { Entity: uidJohn, Attr: attrOffice, @@ -225,7 +543,7 @@ func TestGetScalarList(t *testing.T) { runM := func(startTs, commitTs uint64, edges []*pb.DirectedEdge) { txn := posting.Oracle().RegisterStartTs(startTs) for _, edge := range edges { - x.Check(runMutation(context.Background(), edge, txn)) + x.Check(newRunMutation(context.Background(), edge, txn)) } txn.Update() writer := posting.NewTxnWriter(pstore) @@ -283,7 +601,7 @@ func TestMultipleTxnListCount(t *testing.T) { runM := func(startTs, commitTs uint64, edges []*pb.DirectedEdge) { txn := posting.Oracle().RegisterStartTs(startTs) for _, edge := range edges { - x.Check(runMutation(ctx, edge, txn)) + x.Check(newRunMutation(ctx, edge, txn)) } txn.Update() writer := posting.NewTxnWriter(pstore) @@ -342,7 +660,7 @@ func TestScalarPredicateRevCount(t *testing.T) { runM := func(startTs, commitTs uint64, edges []*pb.DirectedEdge) { txn := posting.Oracle().RegisterStartTs(startTs) for _, edge := range edges { - x.Check(runMutation(ctx, edge, txn)) + x.Check(newRunMutation(ctx, edge, txn)) } txn.Update() writer := posting.NewTxnWriter(pstore) @@ -423,7 +741,7 @@ func TestScalarPredicateIntCount(t *testing.T) { runM := func(startTs, commitTs uint64, edge *pb.DirectedEdge) { txn := posting.Oracle().RegisterStartTs(startTs) - x.Check(runMutation(ctx, edge, txn)) + x.Check(newRunMutation(ctx, edge, txn)) txn.Update() writer := posting.NewTxnWriter(pstore) require.NoError(t, txn.CommitToDisk(writer, commitTs)) @@ -477,7 +795,7 @@ func TestScalarPredicateCount(t *testing.T) { runM := func(startTs, commitTs uint64, edge *pb.DirectedEdge) { txn := posting.Oracle().RegisterStartTs(startTs) - x.Check(runMutation(ctx, edge, txn)) + x.Check(newRunMutation(ctx, edge, txn)) txn.Update() writer := posting.NewTxnWriter(pstore) require.NoError(t, txn.CommitToDisk(writer, commitTs)) @@ -531,7 +849,7 @@ func TestSingleUidReplacement(t *testing.T) { attr := x.AttrInRootNamespace("singleUidReplaceTest") // Txn 1. Set 1 -> 2 - x.Check(runMutation(ctx, &pb.DirectedEdge{ + x.Check(newRunMutation(ctx, &pb.DirectedEdge{ ValueId: 2, Attr: attr, Entity: 1, @@ -547,7 +865,7 @@ func TestSingleUidReplacement(t *testing.T) { // Txn 2. Set 1 -> 3 txn = posting.Oracle().RegisterStartTs(9) - x.Check(runMutation(ctx, &pb.DirectedEdge{ + x.Check(newRunMutation(ctx, &pb.DirectedEdge{ ValueId: 3, Attr: attr, Entity: 1, @@ -591,14 +909,14 @@ func TestSingleString(t *testing.T) { attr := x.AttrInRootNamespace("singleUidTest") // Txn 1. Set 1 -> david 2 -> blush - x.Check(runMutation(ctx, &pb.DirectedEdge{ + x.Check(newRunMutation(ctx, &pb.DirectedEdge{ Value: []byte("david"), Attr: attr, Entity: 1, Op: pb.DirectedEdge_SET, }, txn)) - x.Check(runMutation(ctx, &pb.DirectedEdge{ + x.Check(newRunMutation(ctx, &pb.DirectedEdge{ Value: []byte("blush"), Attr: attr, Entity: 2, @@ -614,14 +932,14 @@ func TestSingleString(t *testing.T) { // Txn 2. Set 2 -> david 1 -> blush txn = posting.Oracle().RegisterStartTs(9) - x.Check(runMutation(ctx, &pb.DirectedEdge{ + x.Check(newRunMutation(ctx, &pb.DirectedEdge{ Value: []byte("david"), Attr: attr, Entity: 2, Op: pb.DirectedEdge_SET, }, txn)) - x.Check(runMutation(ctx, &pb.DirectedEdge{ + x.Check(newRunMutation(ctx, &pb.DirectedEdge{ Value: []byte("blush"), Attr: attr, Entity: 1, @@ -693,7 +1011,7 @@ func TestLangExact(t *testing.T) { Lang: "en", } - x.Check(runMutation(ctx, edge, txn)) + x.Check(newRunMutation(ctx, edge, txn)) edge = &pb.DirectedEdge{ Value: []byte("hindi"), @@ -703,7 +1021,7 @@ func TestLangExact(t *testing.T) { Lang: "hi", } - x.Check(runMutation(ctx, edge, txn)) + x.Check(newRunMutation(ctx, edge, txn)) txn.Update() writer := posting.NewTxnWriter(pstore) @@ -745,7 +1063,6 @@ func BenchmarkAddMutationWithIndex(b *testing.B) { posting.Init(ps, 0, false) Init(ps) err = schema.ParseBytes([]byte("benchmarkadd: string @index(term) ."), 1) - fmt.Println(err) if err != nil { panic(err) } @@ -767,7 +1084,7 @@ func BenchmarkAddMutationWithIndex(b *testing.B) { Op: pb.DirectedEdge_SET, } - x.Check(runMutation(ctx, edge, txn)) + x.Check(newRunMutation(ctx, edge, txn)) } } diff --git a/worker/task.go b/worker/task.go index f142f43cfff..09c1844cd4e 100644 --- a/worker/task.go +++ b/worker/task.go @@ -487,6 +487,8 @@ func (qs *queryState) handleValuePostings(ctx context.Context, args funcArgs) er Value: p.Value, } } + pk, _ := x.Parse(key) + fmt.Println("READING SINGLE", pk, vals, pl) } else { pl, err := qs.cache.Get(key) if err != nil { @@ -506,6 +508,8 @@ func (qs *queryState) handleValuePostings(ctx context.Context, args funcArgs) er } vals, fcs, err = retrieveValuesAndFacets(args, pl, facetsTree, listType) + pk, _ := x.Parse(key) + fmt.Println("READING", pk, vals, fcs, pl.Print()) switch { case err == posting.ErrNoValue || (err == nil && len(vals) == 0): From 85229241ec57e810b93df6a824a2a1246f100a2d Mon Sep 17 00:00:00 2001 From: Matthew McNeely Date: Fri, 1 May 2026 13:08:56 -0400 Subject: [PATCH 02/45] chore(posting): strip debug fmt.Println from hot paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the unconditional debug prints scattered through posting/ during the original WIP work — they fired on every read, mutation, rollup, and commit. None of them were guarded by a verbosity flag, so under load they would have produced megabytes of stdout noise per second. Sites stripped: - posting/lists.go: READING / READING SINGLE / GETTING KEY FROM DELTAS - posting/index.go: TOKENS, LOCAL MAP, INSERTING INDEX, UPDATE INDEX, ERRORRRING, "Inserting tokenizer indexes ... took" - posting/mvcc.go: COMMITTING (and unused fmt import) - posting/list.go: "Buidlding committed uids", "Setting mutation", PrintRollup helper (called once internally, never elsewhere) Left in place: printTreeStats() in index.go, which is already gated by the DEBUG_SHOW_HNSW_TREE env var and is an intentional opt-in HNSW debug helper. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- posting/index.go | 13 ------------- posting/list.go | 11 ----------- posting/lists.go | 29 ++--------------------------- posting/mvcc.go | 3 --- 4 files changed, 2 insertions(+), 54 deletions(-) diff --git a/posting/index.go b/posting/index.go index 238edd64584..b57ac151795 100644 --- a/posting/index.go +++ b/posting/index.go @@ -129,11 +129,6 @@ func (mp *MutationPipeline) ProcessVectorIndex(ctx context.Context, pipeline *Pr } func (mp *MutationPipeline) InsertTokenizerIndexes(ctx context.Context, pipeline *PredicatePipeline, postings *map[uint64]*pb.PostingList, info predicateInfo) error { - startTime := time.Now() - defer func() { - fmt.Println("Inserting tokenizer indexes for predicate", pipeline.attr, "took", time.Since(startTime)) - }() - tokenizers := schema.State().Tokenizer(ctx, pipeline.attr) if len(tokenizers) == 0 { return nil @@ -192,7 +187,6 @@ func (mp *MutationPipeline) InsertTokenizerIndexes(ctx context.Context, pipeline if !loaded { tokens, err = indexTokens(ctx, info) if err != nil { - fmt.Println("ERRORRRING", err) x.Panic(err) } syncMap.Store(key, tokens) @@ -204,8 +198,6 @@ func (mp *MutationPipeline) InsertTokenizerIndexes(ctx context.Context, pipeline for _, token := range tokens.([]string) { key := x.IndexKey(pipeline.attr, token) - pk, _ := x.Parse([]byte(key)) - fmt.Println("TOKENS", key, pk) val, ok := indexGenInThread[string(key)] if !ok { val = &pb.PostingList{} @@ -217,8 +209,6 @@ func (mp *MutationPipeline) InsertTokenizerIndexes(ctx context.Context, pipeline } for key, value := range indexGenInThread { - pk, _ := x.Parse([]byte(key)) - fmt.Println("LOCAL MAP", pk, value) indexesGenInMutation.Update(key, func(val *MutableLayer, ok bool) *MutableLayer { if !ok { val = newMutableLayer() @@ -261,10 +251,7 @@ func (mp *MutationPipeline) InsertTokenizerIndexes(ctx context.Context, pipeline mp.txn.cache.deltas.indexMap[pipeline.attr] = indexGenInTxn } - fmt.Println("INSERTING INDEX", pipeline.attr, *postings) updateFn := func(key string, value *MutableLayer) { - pk, _ := x.Parse([]byte(key)) - fmt.Println("UPDATE INDEX", pk, value) indexGenInTxn.Update(key, func(val *pb.PostingList, ok bool) *pb.PostingList { if !ok { val = &pb.PostingList{} diff --git a/posting/list.go b/posting/list.go index 4e67b7f65c2..55920c27495 100644 --- a/posting/list.go +++ b/posting/list.go @@ -337,7 +337,6 @@ func (mm *MutableLayer) insertCommittedPostings(pl *pb.PostingList) { // We insert old postings in reverse order. So we only need to read the first update to an UID. if _, ok := mm.committedUids[mpost.Uid]; !ok { mm.committedUids[mpost.Uid] = mpost - fmt.Println("Buidlding committed uids", mm.committedUids, mpost) } } } @@ -1130,9 +1129,6 @@ func (l *List) setMutation(startTs uint64, data []byte) { } func (l *List) setMutationWithPosting(startTs uint64, pl *pb.PostingList) { - // pk, _ := x.Parse(l.key) - // fmt.Println("Setting mutation for ", l.key, pk, pl) - l.Lock() if l.mutationMap == nil { l.mutationMap = newMutableLayer() @@ -1568,17 +1564,10 @@ func (l *List) Rollup(alloc *z.Allocator, readTs uint64) ([]*bpb.KV, error) { return bytes.Compare(kvs[i].Key, kvs[j].Key) <= 0 }) - PrintRollup(out.plist, out.parts, l.key, kv.Version) x.VerifyPostingSplits(kvs, out.plist, out.parts, l.key) return kvs, nil } -func PrintRollup(plist *pb.PostingList, parts map[uint64]*pb.PostingList, baseKey []byte, ts uint64) { - k, _ := x.Parse(baseKey) - uids := codec.Decode(plist.Pack, 0) - fmt.Printf("[TXNLOG] DOING ROLLUP for key: %+v at timestamp: %v, uids: %+v\n", k, ts, uids) -} - // ToBackupPostingList uses rollup to generate a single list with no splits. // It's used during backup so that each backed up posting list is stored in a single key. func (l *List) ToBackupPostingList( diff --git a/posting/lists.go b/posting/lists.go index d4a0fbebd5c..4a85ffa3abf 100644 --- a/posting/lists.go +++ b/posting/lists.go @@ -128,8 +128,6 @@ func (d *Deltas) Get(key string) (*pb.PostingList, bool) { } } - // fmt.Println("GETTING KEY FROM DELTAS", pk, "res", res, "val", val, "d.deltas", d.deltas, "d.indexMap[pk.Attr]", d.indexMap[pk.Attr], "ok", ok) - return res, len(res.Postings) > 0 } @@ -396,20 +394,12 @@ func (lc *LocalCache) getInternal(key []byte, readFromDisk, readUids bool) (*Lis lc.RLock() defer lc.RUnlock() if lc.plists == nil { - l, err := getNew(key, pstore, lc.startTs, readUids) - if err != nil { - return nil, err - } - pk, _ := x.Parse(key) - fmt.Println("READING NEW PLIST", pk, l.Print()) - return l, nil + return getNew(key, pstore, lc.startTs, readUids) } if l, ok := lc.plists[skey]; ok { if delta, ok := lc.deltas.Get(skey); ok && delta != nil { l.setMutationWithPosting(lc.startTs, delta) } - pk, _ := x.Parse(key) - fmt.Println("READING PLIST", pk, l.Print()) return l, nil } return nil, nil @@ -442,8 +432,6 @@ func (lc *LocalCache) getInternal(key []byte, readFromDisk, readUids bool) (*Lis } lc.RUnlock() - pk, _ := x.Parse(key) - fmt.Println("READING ", pk, pl.Print()) return lc.SetIfAbsent(skey, pl), nil } @@ -480,16 +468,11 @@ func (lc *LocalCache) readPostingListAt(key []byte) (*pb.PostingList, error) { // given key. This is used for retrieving the value of a scalar predicats. func (lc *LocalCache) GetSinglePosting(key []byte) (*pb.PostingList, error) { // This would return an error if there is some data in the local cache, but we couldn't read it. - - pk, _ := x.Parse(key) - fmt.Println("READING SINGLE ", pk) - getListFromLocalCache := func() (*pb.PostingList, error) { lc.RLock() if delta, ok := lc.deltas.Get(string(key)); ok && delta != nil { lc.RUnlock() - fmt.Println("READING SINGLE FROM DELTA", pk, delta) return delta, nil } @@ -497,9 +480,7 @@ func (lc *LocalCache) GetSinglePosting(key []byte) (*pb.PostingList, error) { lc.RUnlock() if l != nil { - res, err := l.StaticValue(lc.startTs) - fmt.Println("READING SINGLE FROM PLISTS", pk, res, err, l.Print()) - return res, err + return l.StaticValue(lc.startTs) } return nil, nil @@ -517,15 +498,10 @@ func (lc *LocalCache) GetSinglePosting(key []byte) (*pb.PostingList, error) { } pl, err := getPostings() - - fmt.Println("READING SINGLE ", pk, "pl:", pl) - if err == badger.ErrKeyNotFound { - fmt.Println("READING ", pk, nil) return nil, nil } if err != nil { - fmt.Println("READING ", pk, err) return nil, err } @@ -538,7 +514,6 @@ func (lc *LocalCache) GetSinglePosting(key []byte) (*pb.PostingList, error) { } } pl.Postings = pl.Postings[:idx] - // fmt.Println("READING ", pk, pl) return pl, nil } diff --git a/posting/mvcc.go b/posting/mvcc.go index ad6f1be162f..c41e9e19383 100644 --- a/posting/mvcc.go +++ b/posting/mvcc.go @@ -9,7 +9,6 @@ import ( "bytes" "context" "encoding/hex" - "fmt" "math" "strconv" "sync" @@ -308,8 +307,6 @@ func (txn *Txn) CommitToDisk(writer *TxnWriter, commitTs uint64) error { if len(pl.Postings) == 0 { continue } - pk, _ := x.Parse([]byte(key)) - fmt.Println("COMMITTING", pk, pl) if ts := cache.maxVersions[key]; ts >= commitTs { // Skip write because we already have a write at a higher ts. // Logging here can cause a lot of output when doing Raft log replay. So, let's From 3d10495b71a1154dd47828e42229ae96da9e7092 Mon Sep 17 00:00:00 2001 From: Matthew McNeely Date: Fri, 1 May 2026 13:11:54 -0400 Subject: [PATCH 03/45] chore(posting): remove dead commented-out code Two large dead-code blocks left over from the original WIP: - posting/index.go: ~175 lines of an alternate InsertTokenizerIndexes implementation, fully commented out. The live implementation directly above it supersedes it; keeping the commented variant just made the file harder to follow. Also drop the scattered "//fmt.Println(...)" leftovers next to live code. - worker/draft_test.go: BenchmarkProcessListIndex was added entirely commented out and references methods (DefaultPipeline, ProcessListWithoutIndex, ProcessListIndex) that don't exist on MutationPipeline. If we want a benchmark for the pipeline, we should write one against the real API. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- posting/index.go | 183 ------------------------------------------- worker/draft_test.go | 101 ------------------------ 2 files changed, 284 deletions(-) diff --git a/posting/index.go b/posting/index.go index b57ac151795..17d48b3d71c 100644 --- a/posting/index.go +++ b/posting/index.go @@ -284,183 +284,6 @@ func (mp *MutationPipeline) InsertTokenizerIndexes(ctx context.Context, pipeline return nil } -// func (mp *MutationPipeline) InsertTokenizerIndexes(ctx context.Context, pipeline *PredicatePipeline, postings *map[uint64]*pb.PostingList, info predicateInfo) error { -// startTime := time.Now() -// defer func() { -// fmt.Println("Inserting tokenizer indexes for predicate", pipeline.attr, "took", time.Since(startTime)) -// }() - -// tokenizers := schema.State().Tokenizer(ctx, pipeline.attr) -// if len(tokenizers) == 0 { -// return nil -// } - -// values := make(map[string]*pb.PostingList, len(tokenizers)*len(*postings)) -// valPost := make(map[string]*pb.Posting) - -// indexEdge1 := &pb.DirectedEdge{ -// Attr: pipeline.attr, -// } - -// for uid, postingList := range *postings { -// fmt.Println("POSTING", uid, postingList) -// for _, posting := range postingList.Postings { -// key := fmt.Sprintf("%s,%s", posting.LangTag, posting.Value) -// valPl, ok := values[key] -// if !ok { -// valPl = &pb.PostingList{} -// } - -// indexEdge1.Op = GetPostingOp(posting.Op) -// indexEdge1.ValueId = uid - -// mpost := makePostingFromEdge(mp.txn.StartTs, indexEdge1) -// valPl.Postings = append(valPl.Postings, mpost) -// values[key] = valPl - -// newPosting := new(pb.Posting) -// newPosting.ValType = posting.ValType -// newPosting.Value = posting.Value -// newPosting.LangTag = posting.LangTag -// valPost[key] = newPosting -// } -// } - -// keysCreated := make([]string, 0, len(values)) -// for i := range values { -// keysCreated = append(keysCreated, i) -// } - -// //fmt.Println("START") - -// f := func(numGo int) *types.LockedShardedMap[string, *MutableLayer] { -// wg := &sync.WaitGroup{} - -// globalMap := types.NewLockedShardedMap[string, *MutableLayer]() -// process := func(start int) { -// factorySpecs, err := schema.State().FactoryCreateSpec(ctx, pipeline.attr) -// if err != nil { -// pipeline.errCh <- err -// return -// } - -// defer wg.Done() -// localMap := make(map[string]*pb.PostingList, len(values)/numGo) -// for i := start; i < len(values); i += numGo { -// key := keysCreated[i] -// valPl := values[key] -// if len(valPl.Postings) == 0 { -// continue -// } - -// posting := valPost[key] -// // Build info per iteration without indexEdge. -// info := &indexMutationInfo{ -// tokenizers: tokenizers, -// factorySpecs: factorySpecs, -// op: pb.DirectedEdge_SET, -// val: types.Val{ -// Tid: types.TypeID(posting.ValType), -// Value: posting.Value, -// }, -// } - -// info.edge = &pb.DirectedEdge{ -// Attr: pipeline.attr, -// Op: pb.DirectedEdge_SET, -// Lang: string(posting.LangTag), -// Value: posting.Value, -// } - -// tokens, erri := indexTokens(ctx, info) -// if erri != nil { -// fmt.Println("ERRORRRING", erri) -// x.Panic(erri) -// } - -// for _, token := range tokens { -// key := x.IndexKey(pipeline.attr, token) -// pk, _ := x.Parse([]byte(key)) -// fmt.Println("TOKENS", key, i, numGo, pk) -// val, ok := localMap[string(key)] -// if !ok { -// val = &pb.PostingList{} -// } -// val.Postings = append(val.Postings, valPl.Postings...) -// localMap[string(key)] = val -// } -// } - -// for key, value := range localMap { -// pk, _ := x.Parse([]byte(key)) -// fmt.Println("LOCAL MAP", pk, numGo, value) -// globalMap.Update(key, func(val *MutableLayer, ok bool) *MutableLayer { -// if !ok { -// val = newMutableLayer() -// val.currentEntries = &pb.PostingList{} -// } -// for _, posting := range value.Postings { -// val.insertPosting(posting, false) -// } -// return val -// }) -// } -// } - -// for i := range numGo { -// wg.Add(1) -// go process(i) -// } - -// wg.Wait() - -// return globalMap -// } - -// globalMapI := f(1) - -// mp.txn.cache.Lock() -// defer mp.txn.cache.Unlock() - -// globalMap := mp.txn.cache.deltas.GetIndexMapForPredicate(pipeline.attr) -// if globalMap == nil { -// globalMap = types.NewLockedShardedMap[string, *pb.PostingList]() -// mp.txn.cache.deltas.indexMap[pipeline.attr] = globalMap -// } - -// updateFn := func(key string, value *MutableLayer) { -// globalMap.Update(key, func(val *pb.PostingList, ok bool) *pb.PostingList { -// if !ok { -// val = &pb.PostingList{} -// } -// val.Postings = append(val.Postings, value.currentEntries.Postings...) -// return val -// }) -// } - -// if info.hasUpsert { -// err := globalMapI.Iterate(func(key string, value *MutableLayer) error { -// updateFn(key, value) -// mp.txn.addConflictKey(farm.Fingerprint64([]byte(key))) -// return nil -// }) -// if err != nil { -// return err -// } -// } else { -// err := globalMapI.Iterate(func(key string, value *MutableLayer) error { -// updateFn(key, value) -// mp.txn.addConflictKeyWithUid([]byte(key), value.currentEntries, info.hasUpsert, info.noConflict) -// return nil -// }) -// if err != nil { -// return err -// } -// } - -// return nil -// } - type predicateInfo struct { isList bool index bool @@ -752,8 +575,6 @@ func (mp *MutationPipeline) ProcessCount(ctx context.Context, pipeline *Predicat continue } - //fmt.Println("COUNT STATS", uid, prevCount, newCount, postingList, list.Print()) - edge.ValueId = uid edge.Op = pb.DirectedEdge_DEL if prevCount > 0 { @@ -766,7 +587,6 @@ func (mp *MutationPipeline) ProcessCount(ctx context.Context, pipeline *Predicat } for c, pl := range countMap { - //fmt.Println("COUNT", c, pl) ck := x.CountKey(pipeline.attr, uint32(c), isReverseEdge) if newPl, err := mp.txn.AddDelta(string(ck), pl, true, true); err != nil { return err @@ -788,7 +608,6 @@ func (mp *MutationPipeline) ProcessSingle(ctx context.Context, pipeline *Predica var oldVal *pb.Posting for edge := range pipeline.edges { - // fmt.Println("SINGLE EDGE", edge) if edge.Op != pb.DirectedEdge_DEL && !schemaExists { return errors.Errorf("runMutation: Unable to find schema for %s", edge.Attr) } @@ -886,7 +705,6 @@ func (mp *MutationPipeline) ProcessSingle(ctx context.Context, pipeline *Predica baseKey := string(dataKey[:len(dataKey)-8]) // Avoid repeated conversion for uid, pl := range postings { - //fmt.Println("ADDING DELTA", uid, pipeline.attr, pl) binary.BigEndian.PutUint64(dataKey[len(dataKey)-8:], uid) key := baseKey + string(dataKey[len(dataKey)-8:]) @@ -1102,7 +920,6 @@ func (mp *MutationPipeline) Process(ctx context.Context, edges []*pb.DirectedEdg numWg := 0 eg, egCtx := errgroup.WithContext(ctx) for _, edge := range edges { - //fmt.Println("PROCESSING EDGE", edge) if edge.Op == pb.DirectedEdge_DEL && string(edge.Value) == x.Star { l, err := mp.txn.Get(x.DataKey(edge.Attr, edge.Entity)) if err != nil { diff --git a/worker/draft_test.go b/worker/draft_test.go index 18822254ffc..9fe784b4f63 100644 --- a/worker/draft_test.go +++ b/worker/draft_test.go @@ -38,107 +38,6 @@ func getEntryForCommit(index, startTs, commitTs uint64) raftpb.Entry { return raftpb.Entry{Index: index, Term: 1, Type: raftpb.EntryNormal, Data: data} } -// func BenchmarkProcessListIndex(b *testing.B) { -// dir, err := os.MkdirTemp("", "storetest_") -// x.Check(err) -// defer os.RemoveAll(dir) - -// opt := badger.DefaultOptions(dir) -// ps, err := badger.OpenManaged(opt) -// x.Check(err) -// pstore = ps -// // Not using posting list cache -// posting.Init(ps, 0, false) -// Init(ps) -// err = schema.ParseBytes([]byte("testAttr: [string] @index(exact) ."), 1) -// require.NoError(b, err) - -// ctx := context.Background() -// pipeline := &PredicatePipeline{ -// attr: "0-testAttr", -// edges: make(chan *pb.DirectedEdge, 1000), -// wg: &sync.WaitGroup{}, -// errCh: make(chan error, 1), -// } - -// txn := posting.Oracle().RegisterStartTs(5) -// mp := &MutationPipeline{txn: txn} - -// // Generate 1000 edges -// populatePipeline := func() { -// pipeline = &PredicatePipeline{ -// attr: "0-testAttr", -// edges: make(chan *pb.DirectedEdge, 1000), -// wg: &sync.WaitGroup{}, -// errCh: make(chan error, 1), -// } - -// txn = posting.Oracle().RegisterStartTs(5) -// mp = &MutationPipeline{txn: txn} - -// for i := 0; i < 1000; i++ { -// edge := &pb.DirectedEdge{ -// Entity: uint64(i + 1), -// Attr: "0-testAttr", -// Value: []byte(fmt.Sprintf("value%d", rand.Intn(1000))), -// ValueType: pb.Posting_STRING, -// Op: pb.DirectedEdge_SET, -// } -// pipeline.edges <- edge -// } -// } - -// b.ResetTimer() - -// b.Run("Baseline", func(b *testing.B) { -// for i := 0; i < b.N; i++ { -// populatePipeline() -// } -// }) - -// b.Run("DefaultPipeline", func(b *testing.B) { -// for i := 0; i < b.N; i++ { -// populatePipeline() -// var wg sync.WaitGroup -// wg.Add(1) -// go func() { -// mp.DefaultPipeline(ctx, pipeline) -// wg.Done() -// }() -// close(pipeline.edges) -// wg.Wait() -// } -// }) - -// b.Run("ProcessListWithoutIndex", func(b *testing.B) { -// for i := 0; i < b.N; i++ { -// populatePipeline() -// var wg sync.WaitGroup -// wg.Add(1) -// go func() { -// mp.ProcessListWithoutIndex(ctx, pipeline) -// wg.Done() -// }() -// close(pipeline.edges) -// wg.Wait() -// } -// }) - -// b.Run("ProcessListIndex", func(b *testing.B) { -// for i := 0; i < b.N; i++ { -// populatePipeline() -// var wg sync.WaitGroup -// wg.Add(1) -// go func() { -// mp.ProcessListIndex(ctx, pipeline) -// wg.Done() -// }() -// close(pipeline.edges) -// wg.Wait() -// } -// }) -// } - func TestCalculateSnapshot(t *testing.T) { dir := t.TempDir() ds := raftwal.Init(dir) From e73f39eca0dd8927bc853df67341528bfd504cd3 Mon Sep 17 00:00:00 2001 From: Matthew McNeely Date: Fri, 1 May 2026 13:14:24 -0400 Subject: [PATCH 04/45] feat(mutations): gate per-predicate pipeline behind a feature flag Replace the hardcoded \`featureFlag := true\` in applyMutations with a real superflag knob, defaulted off: - Add WorkerOptions.MutationsUsePipeline (bool) in x/config.go. - Extend the feature-flags superflag with mutations-use-pipeline=false and wire alpha to populate WorkerConfig.MutationsUsePipeline from it. - worker/draft.go applyMutations now branches on x.WorkerConfig.MutationsUsePipeline; default false routes mutations through the legacy path, preserving current behavior. Tests can opt into the new pipeline by setting x.WorkerConfig.MutationsUsePipeline = true. CLI usage: dgraph alpha --feature-flags="mutations-use-pipeline=true" Co-Authored-By: Claude Opus 4.7 (1M context) --- dgraph/cmd/alpha/run.go | 1 + worker/draft.go | 3 +-- worker/server_state.go | 3 ++- x/config.go | 4 ++++ 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/dgraph/cmd/alpha/run.go b/dgraph/cmd/alpha/run.go index d89a8c0cc0a..6d42d1f8994 100644 --- a/dgraph/cmd/alpha/run.go +++ b/dgraph/cmd/alpha/run.go @@ -810,6 +810,7 @@ func run() { x.Config.NormalizeCompatibilityMode = featureFlagsConf.GetString("normalize-compatibility-mode") enableDetailedMetrics := featureFlagsConf.GetBool("enable-detailed-metrics") x.WorkerConfig.SlowQueryLogThreshold = featureFlagsConf.GetDuration("log-slow-query-threshold") + x.WorkerConfig.MutationsUsePipeline = featureFlagsConf.GetBool("mutations-use-pipeline") x.PrintVersion() glog.Infof("x.Config: %+v", x.Config) diff --git a/worker/draft.go b/worker/draft.go index b375680475b..b808f8af042 100644 --- a/worker/draft.go +++ b/worker/draft.go @@ -526,8 +526,7 @@ func (n *node) applyMutations(ctx context.Context, proposal *pb.Proposal) (rerr // Discard the posting lists from cache to release memory at the end. defer txn.Update() - featureFlag := true - if featureFlag { + if x.WorkerConfig.MutationsUsePipeline { mp := posting.NewMutationPipeline(txn) return mp.Process(ctx, m.Edges) } diff --git a/worker/server_state.go b/worker/server_state.go index d9905132442..933f1349e4a 100644 --- a/worker/server_state.go +++ b/worker/server_state.go @@ -42,7 +42,8 @@ const ( GraphQLDefaults = `introspection=true; debug=false; extensions=true; poll-interval=1s; ` + `lambda-url=;` CacheDefaults = `size-mb=4096; percentage=40,40,20; remove-on-update=false` - FeatureFlagsDefaults = `normalize-compatibility-mode=; enable-detailed-metrics=false; log-slow-query-threshold=0` + FeatureFlagsDefaults = `normalize-compatibility-mode=; enable-detailed-metrics=false; ` + + `log-slow-query-threshold=0; mutations-use-pipeline=false` ) // ServerState holds the state of the Dgraph server. diff --git a/x/config.go b/x/config.go index 15f4123c17e..d6e7c995836 100644 --- a/x/config.go +++ b/x/config.go @@ -142,6 +142,10 @@ type WorkerOptions struct { HardSync bool // Audit contains the audit flags that enables the audit. Audit bool + // MutationsUsePipeline enables the per-predicate mutation pipeline in + // applyMutations. When false (default), mutations follow the legacy + // serial path. The flag is plumbed via the "feature-flags" superflag. + MutationsUsePipeline bool } // WorkerConfig stores the global instance of the worker package's options. From f70c033fa4c5f8611da63781efee1e9104698d6d Mon Sep 17 00:00:00 2001 From: Matthew McNeely Date: Fri, 1 May 2026 13:31:23 -0400 Subject: [PATCH 05/45] test(worker): document why TestCount is skipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestCount is t.Skip()'d on the branch, but the reason wasn't recorded. Investigation: the test launches concurrent transactions sharing entity uids and bypasses the Oracle's conflict-checking commit path — it just calls CommitToDisk() directly with disjoint commit timestamps. Both the legacy AddMutationWithIndex path and the new mutation pipeline fail it identically: with two threads adding edges to the same subject's [uid] @count predicate, neither path can serialize @count updates without real txn conflicts, so the count index ends up inconsistent and many subjects are missing from count(N). This is expected without conflict checking — the unit harness can't exercise the safety the Oracle provides. Re-enable when we wire either: (a) Oracle.WaitForTs/IsAborted into the harness, or (b) this test through worker.applyMutations() (which does invoke the Oracle conflict path). Single-thread TestCount passes, so the per-predicate pipeline's own count logic is correct in the absence of contention. The existing TestStringIndexWithLang covers the multithreaded happy path with disjoint uids. Co-Authored-By: Claude Opus 4.7 (1M context) --- worker/sort_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/worker/sort_test.go b/worker/sort_test.go index 16075b3c9f6..2ec524b3c76 100644 --- a/worker/sort_test.go +++ b/worker/sort_test.go @@ -301,7 +301,8 @@ func TestStringIndexWithLang(t *testing.T) { } func TestCount(t *testing.T) { - t.Skip() + t.Skip("Inherently racy: bypasses the Oracle conflict-checking commit path. " + + "Legacy and new pipeline both fail. Re-enable when the harness uses real txn conflicts.") // Setup temporary directory for Badger DB dir, err := os.MkdirTemp("", "storetest_") require.NoError(t, err) From 5130bd2edf2601fdb504e22b3389c210fe3dc327 Mon Sep 17 00:00:00 2001 From: Matthew McNeely Date: Fri, 1 May 2026 14:18:43 -0400 Subject: [PATCH 06/45] fix(pipeline): scalar Del-of-old-value must not wipe new Set in ProcessCount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: scalar @count writes were nondeterministically losing data under concurrent transactions. Roughly half the deltas committed by the new mutation pipeline contained only a [DeleteAll] posting and no Set, so reads at maxTs returned an empty value list. Root cause: in ProcessSingle, handleOldDeleteForSingle appends a synthetic Del-of-old-value to postings[uid] alongside the user's Set, so InsertTokenizerIndexes / ProcessReverse / count diffing can see the prior value. ProcessCount then iterates the postings and calls list.updateMutationLayer(post, singleUidUpdate=true, ...) on each. For non-Lang scalar predicates fingerprintEdge returns math.MaxUint64, so the synthetic Del and the user Set both have Uid == math.MaxUint64. The first iteration (Set new) leaves mutationMap.currentEntries = [DeleteAll, Set new]; the second iteration (Del old) finds the Set we just inserted via findPosting and applies updateMutationLayer in singleUidUpdate mode, which unconditionally rewrites currentEntries to [DeleteAll] (the Del branch never appends mpost) — wiping the new value. Fix: in ProcessCount, when iterating a !isListEdge predicate's postings, if the list contains a Set/Ovr posting, treat any Del as synthetic and skip it for the data-list update. Standalone user Dels (no accompanying Set) are still applied. Index/reverse/count diffing already happen before ProcessCount runs and aren't affected. Repro: TestPipelineCountIndexConcurrent in worker/sort_test.go is a new conflict-aware in-process harness that mirrors the systest TestCountIndexConcurrentSetDelScalarPredicate. It runs 200 contending transactions setting <0x1> "name" against a "string @index(exact) @count" schema with a fakeOracle that implements the same hasConflict algorithm as dgraph/cmd/zero/oracle.go. Pre-fix the test fails roughly 50% of runs with an empty data list and the wrong count buckets; post-fix it is stable across 20+ -count iterations and under -race. Existing tests (TestScalarPredicateIntCount, *RevCount, *Count, TestSingleUidReplacement, TestDeleteSetWithVarEdgeCorruptsData, TestStringIndexWithLang, TestMultipleTxnListCount, TestGetScalarList, TestDatetime) all pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- posting/index.go | 24 ++++++ worker/sort_test.go | 176 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+) diff --git a/posting/index.go b/posting/index.go index 17d48b3d71c..756e8c59a95 100644 --- a/posting/index.go +++ b/posting/index.go @@ -540,7 +540,31 @@ func (mp *MutationPipeline) ProcessCount(ctx context.Context, pipeline *Predicat list.Lock() prevCount := list.GetLength(mp.txn.StartTs) + // For scalar (non-list) predicates, handleOldDeleteForSingle may have + // appended a synthetic Del-of-old-value alongside the user's Set, so + // that InsertTokenizerIndexes / ProcessReverse / count diffing can see + // the prior value. The synthetic Del must NOT be applied to the data + // list: scalar value postings all share Uid == math.MaxUint64 + // (fingerprintEdge returns MaxUint64 for non-Lang scalars), and + // updateMutationLayer in singleUidUpdate mode would overwrite the + // just-inserted Set with [DeleteAll] and drop the new value entirely. + // A user-initiated Del (no accompanying Set) must still be applied. + skipSyntheticDel := false + if !isListEdge { + hasSet := false + for _, post := range postingList.Postings { + if post.Op == Set || post.Op == Ovr { + hasSet = true + break + } + } + skipSyntheticDel = hasSet + } + for _, post := range postingList.Postings { + if skipSyntheticDel && post.Op == Del { + continue + } found, _, _ := list.findPosting(post.StartTs, post.Uid) if found { if post.Op == Set && isListEdge { diff --git a/worker/sort_test.go b/worker/sort_test.go index 2ec524b3c76..065798c24d5 100644 --- a/worker/sort_test.go +++ b/worker/sort_test.go @@ -11,11 +11,14 @@ import ( "math" "math/rand" "os" + "strconv" "sync" + "sync/atomic" "testing" "github.com/dgraph-io/badger/v4" bpb "github.com/dgraph-io/badger/v4/pb" + "github.com/dgraph-io/dgo/v250/protos/api" "github.com/dgraph-io/dgraph/v25/posting" "github.com/dgraph-io/dgraph/v25/protos/pb" "github.com/dgraph-io/dgraph/v25/schema" @@ -409,6 +412,179 @@ func TestCount(t *testing.T) { require.Equal(t, subjects, len(uids.Uids)) } +// fakeOracle is an in-memory stand-in for the zero Oracle. It hands out +// monotonically increasing timestamps and rejects commits whose conflict +// keys overlap a higher commitTs — same algorithm as +// dgraph/cmd/zero/oracle.go's hasConflict. +type fakeOracle struct { + mu sync.Mutex + nextTs uint64 + keyCommit map[uint64]uint64 // conflict-key fingerprint -> commitTs + committed atomic.Int64 + aborted atomic.Int64 +} + +func newFakeOracle(initialTs uint64) *fakeOracle { + return &fakeOracle{nextTs: initialTs, keyCommit: map[uint64]uint64{}} +} + +func (o *fakeOracle) reserveStartTs() uint64 { + o.mu.Lock() + defer o.mu.Unlock() + o.nextTs++ + return o.nextTs +} + +// tryCommit mirrors zero/oracle.go: for each conflict key, if a later +// commitTs already exists, abort. Else stamp all keys with a fresh +// commitTs and return it. +func (o *fakeOracle) tryCommit(startTs uint64, conflictKeys []uint64) (uint64, bool) { + o.mu.Lock() + defer o.mu.Unlock() + for _, k := range conflictKeys { + if last, ok := o.keyCommit[k]; ok && last > startTs { + o.aborted.Add(1) + return 0, false + } + } + o.nextTs++ + commitTs := o.nextTs + for _, k := range conflictKeys { + o.keyCommit[k] = commitTs + } + o.committed.Add(1) + return commitTs, true +} + +// runPipelineTxn drives a single mutation through the new pipeline with +// real conflict-aware commit semantics. Returns (committed, error). +func runPipelineTxn(t *testing.T, ps *badger.DB, oracle *fakeOracle, + edges []*pb.DirectedEdge) bool { + t.Helper() + startTs := oracle.reserveStartTs() + txn := posting.Oracle().RegisterStartTs(startTs) + + if err := newRunMutations(context.Background(), edges, txn); err != nil { + t.Fatalf("pipeline failed at startTs=%d: %v", startTs, err) + } + + // FillContext bridges plists -> deltas (via Update) and emits the + // txn's conflict keys as base-36 strings on ctx.Keys. + ctxApi := &api.TxnContext{} + txn.FillContext(ctxApi, 1, false) + + keys := make([]uint64, 0, len(ctxApi.Keys)) + for _, k := range ctxApi.Keys { + ki, err := strconv.ParseUint(k, 36, 64) + require.NoError(t, err) + keys = append(keys, ki) + } + + commitTs, ok := oracle.tryCommit(startTs, keys) + if !ok { + return false + } + writer := posting.NewTxnWriter(ps) + require.NoError(t, txn.CommitToDisk(writer, commitTs)) + require.NoError(t, writer.Flush()) + txn.UpdateCachedKeys(commitTs) + return true +} + +// TestPipelineCountIndexConcurrent mirrors the systest's +// TestCountIndexConcurrentSetDelScalarPredicate at unit-test scope: many +// concurrent transactions setting <0x1> "name" against a +// scalar string predicate with @index(exact) @count, with real +// conflict-checking commit semantics. After everything settles, the data +// list for 0x1 should hold exactly one value, the count(1) bucket should +// reference exactly 0x1, and no other count bucket should reference 0x1. +func TestPipelineCountIndexConcurrent(t *testing.T) { + dir, err := os.MkdirTemp("", "storetest_") + require.NoError(t, err) + defer os.RemoveAll(dir) + + ps, err := badger.OpenManaged(badger.DefaultOptions(dir)) + require.NoError(t, err) + defer ps.Close() + posting.Init(ps, 0, false) + Init(ps) + posting.Oracle().ResetTxns() + + require.NoError(t, schema.ParseBytes( + []byte(`name: string @index(exact) @count .`), 1)) + + pred := x.AttrInRootNamespace("name") + const target uint64 = 1 + + oracle := newFakeOracle(10) + + const ( + numRoutines = 10 + txnsPerRoute = 20 + ) + + var wg sync.WaitGroup + wg.Add(numRoutines) + for r := 0; r < numRoutines; r++ { + go func(seed int) { + defer wg.Done() + rnd := rand.New(rand.NewSource(int64(seed))) + for i := 0; i < txnsPerRoute; i++ { + value := []byte(fmt.Sprintf("name%d", rnd.Intn(10000))) + // Retry on conflict — same as a client doing dg.NewTxn().Mutate(). + // Each attempt uses a fresh edge: makePostingFromEdge mutates + // edge.ValueId during processing, and reusing the object across + // attempts would make ValidateAndConvert see it as a uid edge. + // Real production gets a freshly-deserialized edge per Raft apply. + for attempt := 0; attempt < 100; attempt++ { + edge := &pb.DirectedEdge{ + Entity: target, + Attr: pred, + Value: value, + ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_SET, + } + if runPipelineTxn(t, ps, oracle, []*pb.DirectedEdge{edge}) { + break + } + } + } + }(r) + } + wg.Wait() + + t.Logf("committed=%d aborted=%d", oracle.committed.Load(), oracle.aborted.Load()) + + // Verify final state: exactly one value on 0x1, that uid in count(1) only. + readTxn := posting.Oracle().RegisterStartTs(math.MaxUint64) + + dataKey := x.DataKey(pred, target) + dpl, err := readTxn.Get(dataKey) + require.NoError(t, err) + // Scalar string predicate: AllValues returns the live posting list values + // (one entry for a non-list scalar with a current value). + vals, err := dpl.AllValues(math.MaxUint64) + require.NoError(t, err) + require.Equal(t, 1, len(vals), + "scalar predicate should retain exactly one value, got %v", vals) + + for c := 0; c <= 5; c++ { + ck := x.CountKey(pred, uint32(c), false) + cpl, err := readTxn.Get(ck) + require.NoError(t, err) + cuids, err := cpl.Uids(posting.ListOptions{ReadTs: math.MaxUint64}) + require.NoError(t, err) + switch c { + case 1: + require.Equal(t, []uint64{target}, cuids.Uids, + "count(1) bucket must contain exactly the target uid") + default: + require.NotContains(t, cuids.Uids, target, + "count(%d) bucket must not contain the target uid", c) + } + } +} + func TestDeleteSetWithVarEdgeCorruptsData(t *testing.T) { // Setup temporary directory for Badger DB dir, err := os.MkdirTemp("", "storetest_") From 6d8dc84dcc5f00193eef8b0954a0fc11e6634308 Mon Sep 17 00:00:00 2001 From: Matthew McNeely Date: Fri, 1 May 2026 15:45:36 -0400 Subject: [PATCH 07/45] fix(pipeline): InsertTokenizerIndexes deadlocks on uids >= 2^63 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When loading via dgraph live (or any mutation source whose uids span the full uint64 range, including xidmap-assigned uids), the per-predicate pipeline hung indefinitely on the very first batch with zero forward progress. A goroutine dump showed the dispatcher goroutine wedged on \`chan send (nil chan)\` at the line: chMap[int(uid)%numGo] <- uid uid is uint64. Casting directly to int produces a negative value for uid >= 2^63, so int(uid)%10 can be in [-9, -1]. chMap[-3] returns the zero value for a chan uint64, which is a nil channel; sending on a nil channel blocks forever. The 10 worker goroutines (also created here) were idle on \`for uid := range uids\` since no uids ever reached them, so the parent \`wg.Wait()\` and the surrounding errgroup never returned. applyMutations therefore never released the txn, the alpha's old-txn abort loop kept retrying every minute, and live-load showed "Txns: 0 N-Quads: 0" indefinitely. Fix: hash unsigned, then cast: \`chMap[int(uid%uint64(numGo))]\`. Verified end-to-end with the live loader against the 1million.rdf.gz benchmark dataset (1,041,684 n-quads, schema mixes [uid] @reverse @count, [uid] @count, datetime @index(year), string @index(...) @lang, geo @index(geo), string @index(exact) @upsert): legacy : 13.85s / 14.74s (avg ~14.3s, ~77k n-quads/s) pipeline : 9.65s / 9.36s (avg ~9.5s, ~116k n-quads/s) That is ~1.50x faster on a realistic multi-predicate, multi-index workload — i.e. the case the per-predicate runner pipeline is built for. Also adds worker/pipeline_bench_test.go: in-process Go benchmarks comparing legacy runMutation vs newRunMutations across a matrix of (predicates, edges-per-predicate, indexed/non-indexed) shapes. They show the pipeline loses ~2x on tiny mutations (1-10 edges) and wins 1.2x-1.55x on bulk (10 preds x 100+ edges, indexed or not), which is why the feature flag stays default-off and the live-loader speedup above is the right place to evaluate this work. Co-Authored-By: Claude Opus 4.7 (1M context) --- posting/index.go | 6 +- worker/pipeline_bench_test.go | 210 ++++++++++++++++++++++++++++++++++ 2 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 worker/pipeline_bench_test.go diff --git a/posting/index.go b/posting/index.go index 756e8c59a95..b9c4b2e7177 100644 --- a/posting/index.go +++ b/posting/index.go @@ -233,7 +233,11 @@ func (mp *MutationPipeline) InsertTokenizerIndexes(ctx context.Context, pipeline } for uid := range *postings { - chMap[int(uid)%numGo] <- uid + // uid is uint64; converting directly to int can produce a negative + // value for uid >= 2^63, which would index outside chMap and resolve + // to a nil channel (deadlocks the dispatcher). Hash unsigned, then + // cast. + chMap[int(uid%uint64(numGo))] <- uid } for i := 0; i < numGo; i++ { diff --git a/worker/pipeline_bench_test.go b/worker/pipeline_bench_test.go new file mode 100644 index 00000000000..e2d68032d47 --- /dev/null +++ b/worker/pipeline_bench_test.go @@ -0,0 +1,210 @@ +/* + * SPDX-FileCopyrightText: © 2017-2025 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package worker + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/dgraph-io/badger/v4" + "github.com/dgraph-io/dgraph/v25/posting" + "github.com/dgraph-io/dgraph/v25/protos/pb" + "github.com/dgraph-io/dgraph/v25/schema" + "github.com/dgraph-io/dgraph/v25/x" +) + +// Benchmarks comparing the legacy serial mutation path (runMutation per edge) +// with the new per-predicate mutation pipeline (newRunMutations). +// +// What the pipeline ought to win on: +// - many predicates per transaction → one goroutine per predicate +// - many indexed edges per predicate → 10-way intra-predicate +// parallelism on tokenization +// +// What it shouldn't help (and may regret): +// - tiny mutations (1-2 edges, 1 predicate) where goroutine spin-up cost +// dominates the mutation work +// +// Each iteration is a single transaction: build a fresh batch of edges, +// run mutations, txn.Update(), CommitToDisk. We do NOT include the b.ResetTimer() +// before edge construction because edge construction is part of the +// per-transaction cost the pipeline is supposed to amortize. + +func benchSetup(b *testing.B, schemaTxt string) *badger.DB { + b.Helper() + dir, err := os.MkdirTemp("", "pipeline_bench_") + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { _ = os.RemoveAll(dir) }) + + ps, err := badger.OpenManaged(badger.DefaultOptions(dir).WithLoggingLevel(badger.ERROR)) + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { _ = ps.Close() }) + + posting.Init(ps, 0, false) + Init(ps) + posting.Oracle().ResetTxns() + if err := schema.ParseBytes([]byte(schemaTxt), 1); err != nil { + b.Fatal(err) + } + return ps +} + +// buildEdges constructs numPreds*edgesPerPred edges across distinct predicates, +// indexed-string-valued. The same generator drives both legacy and pipeline +// runs so the input is identical. +func buildEdges(numPreds, edgesPerPred int, baseUid uint64) []*pb.DirectedEdge { + edges := make([]*pb.DirectedEdge, 0, numPreds*edgesPerPred) + for p := 0; p < numPreds; p++ { + attr := x.AttrInRootNamespace(fmt.Sprintf("p%d", p)) + for e := 0; e < edgesPerPred; e++ { + edges = append(edges, &pb.DirectedEdge{ + Entity: baseUid + uint64(e), + Attr: attr, + Value: []byte(fmt.Sprintf("v%d_%d", p, e)), + ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_SET, + }) + } + } + return edges +} + +// schemaForPreds emits "p0: string @index(exact) ., p1: ..., ..." (or no +// index, depending on indexed). Each predicate is a distinct list-or-scalar. +func schemaForPreds(numPreds int, indexed bool, list bool) string { + var b []byte + for p := 0; p < numPreds; p++ { + ty := "string" + if list { + ty = "[string]" + } + idx := "" + if indexed { + idx = " @index(exact)" + } + b = append(b, []byte(fmt.Sprintf("p%d: %s%s .\n", p, ty, idx))...) + } + return string(b) +} + +// runOne executes one transaction's mutations through the chosen path. +// startTs/commitTs must be unique per call. +func runOnePipeline(b *testing.B, ps *badger.DB, edges []*pb.DirectedEdge, startTs, commitTs uint64) { + b.Helper() + txn := posting.Oracle().RegisterStartTs(startTs) + if err := newRunMutations(context.Background(), edges, txn); err != nil { + b.Fatal(err) + } + txn.Update() + w := posting.NewTxnWriter(ps) + if err := txn.CommitToDisk(w, commitTs); err != nil { + b.Fatal(err) + } + if err := w.Flush(); err != nil { + b.Fatal(err) + } + txn.UpdateCachedKeys(commitTs) +} + +func runOneLegacy(b *testing.B, ps *badger.DB, edges []*pb.DirectedEdge, startTs, commitTs uint64) { + b.Helper() + txn := posting.Oracle().RegisterStartTs(startTs) + for _, e := range edges { + if err := runMutation(context.Background(), e, txn); err != nil { + b.Fatal(err) + } + } + txn.Update() + w := posting.NewTxnWriter(ps) + if err := txn.CommitToDisk(w, commitTs); err != nil { + b.Fatal(err) + } + if err := w.Flush(); err != nil { + b.Fatal(err) + } + txn.UpdateCachedKeys(commitTs) +} + +// runBench runs sub-benchmarks (legacy vs pipeline) for a single +// (numPreds, edgesPerPred, indexed, list) configuration. +func runBench(b *testing.B, numPreds, edgesPerPred int, indexed, list bool) { + for _, mode := range []struct { + name string + fn func(*testing.B, *badger.DB, []*pb.DirectedEdge, uint64, uint64) + }{ + {"legacy", runOneLegacy}, + {"pipeline", runOnePipeline}, + } { + b.Run(mode.name, func(b *testing.B) { + ps := benchSetup(b, schemaForPreds(numPreds, indexed, list)) + b.ReportAllocs() + b.ResetTimer() + ts := uint64(10) + for i := 0; i < b.N; i++ { + edges := buildEdges(numPreds, edgesPerPred, uint64(i)*1_000_000+1) + mode.fn(b, ps, edges, ts, ts+1) + ts += 2 + } + }) + } +} + +// 1 predicate, 1 edge — smallest possible mutation. Pipeline overhead +// is most visible here. +func BenchmarkMutate_1pred_1edge_indexed(b *testing.B) { + runBench(b, 1, 1, true, false) +} + +// 1 predicate, 100 indexed edges — exercises intra-predicate +// tokenization parallelism. +func BenchmarkMutate_1pred_100edges_indexed(b *testing.B) { + runBench(b, 1, 100, true, false) +} + +// 10 predicates, 1 edge each — per-predicate parallelism with light work +// per predicate. +func BenchmarkMutate_10preds_1edge_indexed(b *testing.B) { + runBench(b, 10, 1, true, false) +} + +// 10 predicates, 100 edges each — full benefit case: per-predicate AND +// intra-predicate parallelism on indexed work. +func BenchmarkMutate_10preds_100edges_indexed(b *testing.B) { + runBench(b, 10, 100, true, false) +} + +// 1 predicate, 1000 indexed edges — heavy intra-predicate. +func BenchmarkMutate_1pred_1000edges_indexed(b *testing.B) { + runBench(b, 1, 1000, true, false) +} + +// 10 predicates, 1000 edges each — large mutation, indexed. +func BenchmarkMutate_10preds_1000edges_indexed(b *testing.B) { + runBench(b, 10, 1000, true, false) +} + +// Non-indexed counterparts isolate per-predicate parallelism from the +// tokenization parallelism. +func BenchmarkMutate_10preds_1000edges_noindex(b *testing.B) { + runBench(b, 10, 1000, false, false) +} + +// Very large indexed mutation: 50 predicates × 1000 edges each = 50k edges. +// Where the pipeline should shine most. +func BenchmarkMutate_50preds_1000edges_indexed(b *testing.B) { + runBench(b, 50, 1000, true, false) +} + +// 50 predicates, 100 edges each (5k edges) — typical-ish bulk write shape. +func BenchmarkMutate_50preds_100edges_indexed(b *testing.B) { + runBench(b, 50, 100, true, false) +} From 31e845e70c1c8a5ceefab74cf638c87bd56e27e1 Mon Sep 17 00:00:00 2001 From: Matthew McNeely Date: Fri, 1 May 2026 15:54:53 -0400 Subject: [PATCH 08/45] feat(mutations): replace pipeline on/off flag with edge-count threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The benchmark matrix in worker/pipeline_bench_test.go showed the pipeline loses ~2x on small mutations (≤10 edges total) and wins ~1.5x on bulk (live-loader sized: 1000 edges per txn across many predicates). A binary on/off flag forces an all-or-nothing choice, penalising whichever side of that crossover the workload spends most time on. Replace MutationsUsePipeline (bool) with MutationsPipelineThreshold (int): threshold = 0 -> never use the pipeline (default; legacy behavior) threshold = 1 -> always use the pipeline (any txn with ≥1 edge) threshold = N -> use the pipeline only when len(m.Edges) >= N The threshold compares against total edges in the proposal. From the benches the crossover is around 100; the live-loader 1M dataset uses ~1000 edges per txn, so anything from 100-1000 will engage the pipeline only on bulk-shaped mutations and leave small interactive mutations on the legacy serial path. Wiring: - x.WorkerConfig.MutationsPipelineThreshold (int) replaces the bool field. - feature-flags superflag: "mutations-pipeline-threshold=0". - alpha/run.go reads it via featureFlagsConf.GetInt64. - worker/draft.go applyMutations branches on `t > 0 && len(m.Edges) >= t`. Verified end-to-end against the live-loader benchmark (1million.rdf.gz, official 1M schema): threshold=0 : 13.56s, 80,129 N-Quads/s (legacy, matches baseline) threshold=1 : 9.92s, 115,742 N-Quads/s (always-on, matches prior) CLI usage: dgraph alpha --feature-flags="mutations-pipeline-threshold=200" Co-Authored-By: Claude Opus 4.7 (1M context) --- dgraph/cmd/alpha/run.go | 2 +- worker/draft.go | 2 +- worker/server_state.go | 2 +- x/config.go | 16 ++++++++++++---- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/dgraph/cmd/alpha/run.go b/dgraph/cmd/alpha/run.go index 6d42d1f8994..12cb97d39ce 100644 --- a/dgraph/cmd/alpha/run.go +++ b/dgraph/cmd/alpha/run.go @@ -810,7 +810,7 @@ func run() { x.Config.NormalizeCompatibilityMode = featureFlagsConf.GetString("normalize-compatibility-mode") enableDetailedMetrics := featureFlagsConf.GetBool("enable-detailed-metrics") x.WorkerConfig.SlowQueryLogThreshold = featureFlagsConf.GetDuration("log-slow-query-threshold") - x.WorkerConfig.MutationsUsePipeline = featureFlagsConf.GetBool("mutations-use-pipeline") + x.WorkerConfig.MutationsPipelineThreshold = int(featureFlagsConf.GetInt64("mutations-pipeline-threshold")) x.PrintVersion() glog.Infof("x.Config: %+v", x.Config) diff --git a/worker/draft.go b/worker/draft.go index b808f8af042..776840aef5d 100644 --- a/worker/draft.go +++ b/worker/draft.go @@ -526,7 +526,7 @@ func (n *node) applyMutations(ctx context.Context, proposal *pb.Proposal) (rerr // Discard the posting lists from cache to release memory at the end. defer txn.Update() - if x.WorkerConfig.MutationsUsePipeline { + if t := x.WorkerConfig.MutationsPipelineThreshold; t > 0 && len(m.Edges) >= t { mp := posting.NewMutationPipeline(txn) return mp.Process(ctx, m.Edges) } diff --git a/worker/server_state.go b/worker/server_state.go index 933f1349e4a..3e9b966ebe6 100644 --- a/worker/server_state.go +++ b/worker/server_state.go @@ -43,7 +43,7 @@ const ( `lambda-url=;` CacheDefaults = `size-mb=4096; percentage=40,40,20; remove-on-update=false` FeatureFlagsDefaults = `normalize-compatibility-mode=; enable-detailed-metrics=false; ` + - `log-slow-query-threshold=0; mutations-use-pipeline=false` + `log-slow-query-threshold=0; mutations-pipeline-threshold=0` ) // ServerState holds the state of the Dgraph server. diff --git a/x/config.go b/x/config.go index d6e7c995836..63907880814 100644 --- a/x/config.go +++ b/x/config.go @@ -142,10 +142,18 @@ type WorkerOptions struct { HardSync bool // Audit contains the audit flags that enables the audit. Audit bool - // MutationsUsePipeline enables the per-predicate mutation pipeline in - // applyMutations. When false (default), mutations follow the legacy - // serial path. The flag is plumbed via the "feature-flags" superflag. - MutationsUsePipeline bool + // MutationsPipelineThreshold gates the per-predicate mutation pipeline + // in applyMutations. A mutation runs through the pipeline only when + // MutationsPipelineThreshold > 0 and len(m.Edges) >= the threshold; + // otherwise it falls back to the legacy serial path. Set to 0 (default) + // to disable the pipeline entirely. Set to 1 to always use the pipeline. + // The pipeline pays goroutine spin-up cost per predicate, so small + // mutations are slower on it; bulk multi-predicate mutations are + // faster — pick a value above the per-mutation edge count where the + // crossover happens for your workload (~100 in benchmarks here). + // Plumbed via the "feature-flags" superflag as + // "mutations-pipeline-threshold". + MutationsPipelineThreshold int } // WorkerConfig stores the global instance of the worker package's options. From 8488a5eb9a57606355257bf31cd6992829b3612b Mon Sep 17 00:00:00 2001 From: Matthew McNeely Date: Fri, 1 May 2026 15:58:23 -0400 Subject: [PATCH 09/45] feat(mutations): flip default mutations-pipeline-threshold from 0 to 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engages the per-predicate mutation pipeline by default so that systest runs (and any other test suites that don't override feature-flags) exercise the pipeline path on every mutation, not the legacy serial path. Threshold of 1 means "any mutation with ≥1 edge takes the pipeline" — i.e. always on. This is a deliberate ramp toward shipping the pipeline. Operators who want to opt small interactive mutations out of the pipeline (where benches showed ~2x slowdown for ≤10-edge txns) can set a higher threshold: dgraph alpha --feature-flags="mutations-pipeline-threshold=200" To turn the pipeline fully off, set 0. Co-Authored-By: Claude Opus 4.7 (1M context) --- worker/server_state.go | 2 +- x/config.go | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/worker/server_state.go b/worker/server_state.go index 3e9b966ebe6..071f163869d 100644 --- a/worker/server_state.go +++ b/worker/server_state.go @@ -43,7 +43,7 @@ const ( `lambda-url=;` CacheDefaults = `size-mb=4096; percentage=40,40,20; remove-on-update=false` FeatureFlagsDefaults = `normalize-compatibility-mode=; enable-detailed-metrics=false; ` + - `log-slow-query-threshold=0; mutations-pipeline-threshold=0` + `log-slow-query-threshold=0; mutations-pipeline-threshold=1` ) // ServerState holds the state of the Dgraph server. diff --git a/x/config.go b/x/config.go index 63907880814..8201bb40702 100644 --- a/x/config.go +++ b/x/config.go @@ -145,12 +145,13 @@ type WorkerOptions struct { // MutationsPipelineThreshold gates the per-predicate mutation pipeline // in applyMutations. A mutation runs through the pipeline only when // MutationsPipelineThreshold > 0 and len(m.Edges) >= the threshold; - // otherwise it falls back to the legacy serial path. Set to 0 (default) - // to disable the pipeline entirely. Set to 1 to always use the pipeline. - // The pipeline pays goroutine spin-up cost per predicate, so small + // otherwise it falls back to the legacy serial path. Set to 0 to + // disable the pipeline entirely. Set to 1 (default) to always use it. + // The pipeline pays goroutine spin-up cost per predicate, so tiny // mutations are slower on it; bulk multi-predicate mutations are - // faster — pick a value above the per-mutation edge count where the - // crossover happens for your workload (~100 in benchmarks here). + // faster — set to a value above the per-mutation edge count where the + // crossover happens for your workload (~100 in benchmarks here) if + // you want only large mutations to take the pipeline path. // Plumbed via the "feature-flags" superflag as // "mutations-pipeline-threshold". MutationsPipelineThreshold int From 584752667b1128dda6a96020ec2994f2be0e928c Mon Sep 17 00:00:00 2001 From: Matthew McNeely Date: Fri, 1 May 2026 16:45:25 -0400 Subject: [PATCH 10/45] fix(posting): re-enable IsEmpty check in IterateDisk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original branch commit 41d6445ce1 ("fixed some bug") replaced the IsEmpty(readTs) call in IterateDisk with a hardcoded `false`, forcing every key found by the iterator to be reported as non-empty. That broke has() for any uid whose value had been removed via star-deletion ( *): the data list still exists in badger with a DeleteAll marker on top, but the live posting list is empty at readTs — IsEmpty returns true and the uid should be skipped. Surfaced by systest TestSystestSuite/TestHasDeletedEdge in systest/mutations-and-queries: 3 nodes are created with "", one is star-deleted, follow-up has(end) is expected to return 2 uids. With IsEmpty stubbed to false it returned 3. No comment was left on the original change. Restoring the call. The mutations-and-queries package is fully green with this in place (66/66 tests pass including TestHasDeletedEdge); if a real underlying issue motivated the original disable we'll chase it with a real diagnosis instead of silently dropping a safety check. Co-Authored-By: Claude Opus 4.7 (1M context) --- posting/mvcc.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/posting/mvcc.go b/posting/mvcc.go index c41e9e19383..29721bb367a 100644 --- a/posting/mvcc.go +++ b/posting/mvcc.go @@ -500,8 +500,7 @@ func (ml *MemoryLayer) IterateDisk(ctx context.Context, f IterateDiskArgs) error if err != nil { return err } - empty, err := false, nil - //empty, err := l.IsEmpty(f.ReadTs, 0) + empty, err := l.IsEmpty(f.ReadTs, 0) switch { case err != nil: return err From 5f3895e8a3bb412c20dca3b751f7aeecae679f96 Mon Sep 17 00:00:00 2001 From: Matthew McNeely Date: Fri, 1 May 2026 21:07:35 -0400 Subject: [PATCH 11/45] fix(pipeline): nil-deref in ProcessSingle on multi-Del-per-uid + strip stale READING debug prints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs surfaced by graphql/e2e/auth's TestOrderAndOffset (cleanup mutation \`mutation DelTask { deleteTask(filter: {}) }\` crashed the alpha mid-request, causing the test client to see EOF on POST): 1. posting/index.go ProcessSingle SIGSEGV at line 675. GraphQL deleteTask cleanup expands into multiple Del edges per entity (one per predicate the entity has — uid, type, list edges, etc.). When two Del edges to the same uid land in one transaction's batch, the second iteration through ProcessSingle's per-edge loop does: pl, exists := postings[uid] if exists { if edge.Op == DEL { oldVal = findSingleValueInPostingList(pl) if string(edge.Value) == string(oldVal.Value) { ... } ^^^^^^ nil deref } } findSingleValueInPostingList only returns Set postings; if the accumulated list holds only Dels (from the prior iteration), it returns nil and we panic dereferencing oldVal.Value. Two fixes here: - Guard the deref: \`if oldVal != nil && string(...) == ...\`. - Move \`var oldVal *pb.Posting\` inside the loop. It was declared at function scope, so a stale value from one edge could bleed into the nil-guarded branch for a different uid on a later iteration. Per-edge scope makes the intent explicit. 2. worker/task.go: two leftover \`fmt.Println("READING SINGLE", ...)\` and \`fmt.Println("READING", ...)\` calls in the value-postings read path. Same class of debug spew Phase 1B stripped from posting/, missed because that sweep didn't include worker/. Removed both. Safe — they were unconditional prints on every query value read. The graphql/e2e/auth and graphql/e2e/auth/debug_off packages now both pass in 30s. \`./posting/\` and \`./worker/\` unit tests still green. Co-Authored-By: Claude Opus 4.7 (1M context) --- posting/index.go | 11 +++++++++-- worker/task.go | 4 ---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/posting/index.go b/posting/index.go index b9c4b2e7177..74e4d642697 100644 --- a/posting/index.go +++ b/posting/index.go @@ -634,7 +634,6 @@ func (mp *MutationPipeline) ProcessSingle(ctx context.Context, pipeline *Predica dataKey := x.DataKey(pipeline.attr, 0) insertDeleteAllEdge := !(info.index || info.reverse || info.count) // nolint - var oldVal *pb.Posting for edge := range pipeline.edges { if edge.Op != pb.DirectedEdge_DEL && !schemaExists { return errors.Errorf("runMutation: Unable to find schema for %s", edge.Attr) @@ -644,6 +643,9 @@ func (mp *MutationPipeline) ProcessSingle(ctx context.Context, pipeline *Predica return err } + // oldVal is reset per edge so a stale value from a previous iteration + // can't bleed into the nil-guarded branch below. + var oldVal *pb.Posting uid := edge.Entity pl, exists := postings[uid] @@ -671,8 +673,13 @@ func (mp *MutationPipeline) ProcessSingle(ctx context.Context, pipeline *Predica if exists { if edge.Op == pb.DirectedEdge_DEL { + // findSingleValueInPostingList returns nil when the + // accumulated postings for this uid hold only Del entries + // (no Set), which happens when the same uid receives + // multiple Del edges in one batch (e.g. GraphQL + // deleteTask cleanup deleting many predicates per entity). oldVal = findSingleValueInPostingList(pl) - if string(edge.Value) == string(oldVal.Value) { + if oldVal != nil && string(edge.Value) == string(oldVal.Value) { setPosting() } } else { diff --git a/worker/task.go b/worker/task.go index 09c1844cd4e..f142f43cfff 100644 --- a/worker/task.go +++ b/worker/task.go @@ -487,8 +487,6 @@ func (qs *queryState) handleValuePostings(ctx context.Context, args funcArgs) er Value: p.Value, } } - pk, _ := x.Parse(key) - fmt.Println("READING SINGLE", pk, vals, pl) } else { pl, err := qs.cache.Get(key) if err != nil { @@ -508,8 +506,6 @@ func (qs *queryState) handleValuePostings(ctx context.Context, args funcArgs) er } vals, fcs, err = retrieveValuesAndFacets(args, pl, facetsTree, listType) - pk, _ := x.Parse(key) - fmt.Println("READING", pk, vals, fcs, pl.Print()) switch { case err == posting.ErrNoValue || (err == nil && len(vals) == 0): From a91ccd599ac043ced0a38d139cb058a33ffcaead Mon Sep 17 00:00:00 2001 From: Matthew McNeely Date: Sat, 2 May 2026 16:10:54 -0400 Subject: [PATCH 12/45] fix(posting): defensive-copy reused key buffers in ReadPostingList + addKeyToBatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 21million live-load systest failed with `~genre @filter(uid(F))` returning wrong films for some genres — corruption pattern was that one genre's reverse list contained another genre's films verbatim, while a third genre's reverse list was missing thousands of entries. Different genres were affected on each load, but always: legacy runMutation produced correct reverse counts; the per-predicate pipeline produced corrupt ones. Root cause: the pipeline's ProcessCount per-uid loop allocates one key buffer per ProcessCount call and mutates its trailing 8 bytes each iteration via `binary.BigEndian.PutUint64(dataKey[len-8:], uid)`. Two distinct sites then captured the slice header rather than the bytes: 1. ReadPostingList's `l.key = key` aliased the caller's buffer. saveInCache stores a copyList of the freshly-read list, and copyList sets `key: l.key` — also an alias. The cached list's key field therefore points at a buffer that the pipeline keeps mutating; by the time the async rollup path retrieves the cached list and runs `kv.Key = alloc.Copy(l.key)` to build the rolled-up KV, the bytes are whatever the LAST iteration left behind. Rollup then writes a BitCompletePosting with WithDiscard() to the wrong key, overwriting an unrelated reverse list with this list's (rolled-up) contents. 2. ReadPostingList's defer `IncrRollup.addKeyToBatch(key, ...)` appended the slice header verbatim to the rollup queue. Every queued entry from one ProcessCount goroutine ended up pointing at the same shared dataKey buffer; by the time the rollup goroutine processed the batch the bytes had collapsed to the final iteration's uid, redirecting many distinct rollup targets to the same key. Both sites fixed by taking ownership of the bytes (`append nil` / explicit `make+copy`). Legacy runMutation hits ReadPostingList too but allocates a fresh key per call, so it never aliased anything; the bug is only visible when a caller deliberately reuses one buffer across many uids the way ProcessCount does. Verified end-to-end on the systest/21million/live load with mutations-pipeline-threshold=1: a fresh load + sweep across all 764 Genre entities now reports `count(~genre) == count(forward edges)` for every genre. Pre-fix the same sweep showed 4 mismatched genres with thousands of stale or missing reverse entries; on a different load it showed Documentary missing 23,325 of its 31,370 reverse entries while Children's/Family had 1,435 stale Crime-Thriller entries. Unit tests in posting/ and worker/ still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- posting/mvcc.go | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/posting/mvcc.go b/posting/mvcc.go index 29721bb367a..66cec986f7f 100644 --- a/posting/mvcc.go +++ b/posting/mvcc.go @@ -137,9 +137,20 @@ func (ir *incrRollupi) rollUpKey(writer *TxnWriter, key []byte) error { // TODO: When the opRollup is not running the keys from keysPool of ir are dropped. Figure out some // way to handle that. func (ir *incrRollupi) addKeyToBatch(key []byte, priority int) { + // Defensive copy. Callers (notably ReadPostingList's defer and the + // per-predicate mutation pipeline's ProcessCount loop) reuse a single + // key buffer across iterations and mutate its last 8 bytes per uid; + // keeping a slice header that aliases that buffer means every queued + // rollup target collapses to whatever uid was processed last. The + // async rollup then writes a BitCompletePosting to the wrong key, + // overwriting an unrelated posting list with WithDiscard. Take a copy + // here so the rollup queue owns its bytes. + keyCopy := make([]byte, len(key)) + copy(keyCopy, key) + rki := ir.priorityKeys[priority] batch := rki.keysPool.Get().(*[][]byte) - *batch = append(*batch, key) + *batch = append(*batch, keyCopy) if len(*batch) < 16 { rki.keysPool.Put(batch) return @@ -682,7 +693,16 @@ func ReadPostingList(key []byte, it *badger.Iterator) (*List, error) { } l := new(List) - l.key = key + // Copy the key bytes. The caller (mutation pipeline ProcessCount and + // similar paths) reuses a single key buffer across loop iterations and + // mutates its trailing 8 bytes to scan many uids; without copying here + // the list's l.key field aliases that buffer, so by the time the list + // is read back from the in-memory cache (saveInCache stashes a + // copyList that shares the key alias) the bytes have changed. The + // async rollup path then takes alloc.Copy(l.key) to build the rolled- + // up KV's key and ends up writing a BitCompletePosting (with + // WithDiscard) to a different list's key — corrupting that list. + l.key = append([]byte(nil), key...) l.plist = new(pb.PostingList) l.mutationMap = newMutableLayer() l.minTs = 0 From 0901d8585236d9c5270f60edc49b657c203a71b8 Mon Sep 17 00:00:00 2001 From: Matthew McNeely Date: Sat, 2 May 2026 16:11:13 -0400 Subject: [PATCH 13/45] test(worker): four [uid] @reverse @count regression harnesses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added in pursuit of the 21million live-load failure that turned out to be the dataKey buffer-aliasing bug fixed in 0a9ffc174. None of these tests reproduce that specific corruption — it needs the async rollup path that only triggers on a real running cluster — but they pin down what the per-predicate pipeline does correctly for the non-rollup cases: - TestPipelineReverseListCount: one transaction, multiple subjects pointing at multiple objects on a [uid] @reverse @count predicate. Verifies forward and reverse lists are both complete. - TestPipelineReverseListCountMultiBatch: 50 subjects x 20 objects spread across 143 sequential transactions in batches of 7. - TestPipelineReverseListCountMultiPred: 30 subjects x 12 objects across 3 distinct list-uid + reverse + count predicates, with shuffled edges and small batches so the per-predicate pipeline goroutines for each predicate run in parallel inside Process(). - TestPipelineReverseListCountConcurrent: same shape as the multi-batch case but with 10 worker goroutines submitting batches in parallel through the fakeOracle conflict-checking harness. All four pass cleanly. They guard against regressions in the in-memory mutation path's reverse-list bookkeeping; the rollup path that the 0a9ffc174 fix actually targets is exercised by the systest/21million/live integration test. Co-Authored-By: Claude Opus 4.7 (1M context) --- worker/sort_test.go | 459 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 459 insertions(+) diff --git a/worker/sort_test.go b/worker/sort_test.go index 065798c24d5..3bf624a3a81 100644 --- a/worker/sort_test.go +++ b/worker/sort_test.go @@ -585,6 +585,465 @@ func TestPipelineCountIndexConcurrent(t *testing.T) { } } +// TestPipelineReverseListCount mirrors the [uid] @reverse @count shape from +// the 21million live-load test (genre predicate). One subject points at +// multiple objects in a single transaction; we then verify that BOTH the +// forward data list and the reverse data list are complete. +// +// Background: systest/21million/live's TestQueries/Run_queries/query-017 +// fails consistently with one specific film's `genre = Animation` edge +// missing, while other genre edges from the same film are intact. This +// is the smallest in-process repro of the same forward/reverse fanout +// pattern that the live loader hits. +func TestPipelineReverseListCount(t *testing.T) { + dir, err := os.MkdirTemp("", "pipelinerevcount_") + require.NoError(t, err) + defer os.RemoveAll(dir) + + ps, err := badger.OpenManaged(badger.DefaultOptions(dir)) + require.NoError(t, err) + defer ps.Close() + posting.Init(ps, 0, false) + Init(ps) + posting.Oracle().ResetTxns() + + require.NoError(t, schema.ParseBytes( + []byte(`genre: [uid] @reverse @count .`), 1)) + + pred := x.AttrInRootNamespace("genre") + + // Subject -> list of object uids. Mirrors a film with multiple genres, + // and several films sharing some genres. + const ( + madagascar = uint64(100) + brotherly = uint64(101) + animation = uint64(200) + shortFilm = uint64(201) + comedy = uint64(202) + ) + wantForward := map[uint64][]uint64{ + madagascar: {animation, shortFilm}, + brotherly: {animation, shortFilm, comedy}, + } + + // Single transaction containing all edges (the simplest case — no + // concurrency, no batching, no Oracle conflict path). If this fails, + // the bug is purely in the forward+reverse fanout within ProcessList. + edges := []*pb.DirectedEdge{} + for subj, objs := range wantForward { + for _, obj := range objs { + edges = append(edges, &pb.DirectedEdge{ + Entity: subj, + Attr: pred, + ValueId: obj, + ValueType: pb.Posting_UID, + Op: pb.DirectedEdge_SET, + }) + } + } + + txn := posting.Oracle().RegisterStartTs(10) + require.NoError(t, newRunMutations(context.Background(), edges, txn)) + txn.Update() + w := posting.NewTxnWriter(ps) + require.NoError(t, txn.CommitToDisk(w, 11)) + require.NoError(t, w.Flush()) + txn.UpdateCachedKeys(11) + + readTxn := posting.Oracle().RegisterStartTs(math.MaxUint64) + + // Forward: each subject must have all its expected objects. + for subj, wantObjs := range wantForward { + key := x.DataKey(pred, subj) + pl, err := readTxn.Get(key) + require.NoError(t, err) + got, err := pl.Uids(posting.ListOptions{ReadTs: math.MaxUint64}) + require.NoError(t, err) + require.ElementsMatch(t, wantObjs, got.Uids, + "forward list for subject %d", subj) + } + + // Reverse: each object must have all the subjects that point at it. + wantReverse := map[uint64][]uint64{} + for subj, objs := range wantForward { + for _, obj := range objs { + wantReverse[obj] = append(wantReverse[obj], subj) + } + } + for obj, wantSubjs := range wantReverse { + key := x.ReverseKey(pred, obj) + pl, err := readTxn.Get(key) + require.NoError(t, err) + got, err := pl.Uids(posting.ListOptions{ReadTs: math.MaxUint64}) + require.NoError(t, err) + require.ElementsMatch(t, wantSubjs, got.Uids, + "reverse list for object %d", obj) + } +} + +// TestPipelineReverseListCountMultiBatch escalates TestPipelineReverseListCount +// by spreading edges across many sequential transactions, similar to how the +// live loader chunks edges into many batches. Each batch is one transaction. +func TestPipelineReverseListCountMultiBatch(t *testing.T) { + dir, err := os.MkdirTemp("", "pipelinerevcount2_") + require.NoError(t, err) + defer os.RemoveAll(dir) + + ps, err := badger.OpenManaged(badger.DefaultOptions(dir)) + require.NoError(t, err) + defer ps.Close() + posting.Init(ps, 0, false) + Init(ps) + posting.Oracle().ResetTxns() + + require.NoError(t, schema.ParseBytes( + []byte(`genre: [uid] @reverse @count .`), 1)) + + pred := x.AttrInRootNamespace("genre") + + // Many subjects, many objects, every (subject, object) combination — a + // dense forward fanout that pushes every reverse list to grow as well. + const ( + nSubjects = 50 + nObjects = 20 + ) + subjBase := uint64(10000) + objBase := uint64(20000) + + // Build every edge. + allEdges := make([]*pb.DirectedEdge, 0, nSubjects*nObjects) + for s := 0; s < nSubjects; s++ { + for o := 0; o < nObjects; o++ { + allEdges = append(allEdges, &pb.DirectedEdge{ + Entity: subjBase + uint64(s), + Attr: pred, + ValueId: objBase + uint64(o), + ValueType: pb.Posting_UID, + Op: pb.DirectedEdge_SET, + }) + } + } + + // Split into batches of 7 — a value chosen to make each batch carry a + // non-trivial mix of subjects and objects per ProcessList run. + const batchSize = 7 + ts := uint64(10) + for start := 0; start < len(allEdges); start += batchSize { + end := start + batchSize + if end > len(allEdges) { + end = len(allEdges) + } + batch := allEdges[start:end] + + txn := posting.Oracle().RegisterStartTs(ts) + require.NoError(t, newRunMutations(context.Background(), batch, txn)) + txn.Update() + w := posting.NewTxnWriter(ps) + require.NoError(t, txn.CommitToDisk(w, ts+1)) + require.NoError(t, w.Flush()) + txn.UpdateCachedKeys(ts + 1) + ts += 2 + } + + readTxn := posting.Oracle().RegisterStartTs(math.MaxUint64) + + // Forward: each subject must hold exactly all nObjects objects. + wantForwardObjs := make([]uint64, nObjects) + for o := 0; o < nObjects; o++ { + wantForwardObjs[o] = objBase + uint64(o) + } + for s := 0; s < nSubjects; s++ { + subj := subjBase + uint64(s) + key := x.DataKey(pred, subj) + pl, err := readTxn.Get(key) + require.NoError(t, err) + got, err := pl.Uids(posting.ListOptions{ReadTs: math.MaxUint64}) + require.NoError(t, err) + require.ElementsMatch(t, wantForwardObjs, got.Uids, + "forward list for subject %d (s=%d): missing some objects", subj, s) + } + + // Reverse: each object must hold exactly all nSubjects subjects. + wantReverseSubjs := make([]uint64, nSubjects) + for s := 0; s < nSubjects; s++ { + wantReverseSubjs[s] = subjBase + uint64(s) + } + for o := 0; o < nObjects; o++ { + obj := objBase + uint64(o) + key := x.ReverseKey(pred, obj) + pl, err := readTxn.Get(key) + require.NoError(t, err) + got, err := pl.Uids(posting.ListOptions{ReadTs: math.MaxUint64}) + require.NoError(t, err) + require.ElementsMatch(t, wantReverseSubjs, got.Uids, + "reverse list for object %d (o=%d): missing some subjects", obj, o) + } +} + +// TestPipelineReverseListCountMultiPred escalates further: multiple +// list-uid predicates with @reverse @count are mutated together in each +// batch, so the per-predicate pipeline goroutines for each predicate run +// in parallel inside Process(). This is the live-loader's actual shape +// (genre + director.film + starring + rated all live in the same payload). +func TestPipelineReverseListCountMultiPred(t *testing.T) { + dir, err := os.MkdirTemp("", "pipelinerevcount3_") + require.NoError(t, err) + defer os.RemoveAll(dir) + + ps, err := badger.OpenManaged(badger.DefaultOptions(dir)) + require.NoError(t, err) + defer ps.Close() + posting.Init(ps, 0, false) + Init(ps) + posting.Oracle().ResetTxns() + + require.NoError(t, schema.ParseBytes([]byte(` + genre: [uid] @reverse @count . + starring: [uid] @reverse @count . + director_film: [uid] @reverse @count . + `), 1)) + + preds := []string{ + x.AttrInRootNamespace("genre"), + x.AttrInRootNamespace("starring"), + x.AttrInRootNamespace("director_film"), + } + + const ( + nSubjects = 30 + nObjects = 12 + ) + subjBase := uint64(10000) + objBase := uint64(20000) + + allEdges := make([]*pb.DirectedEdge, 0, len(preds)*nSubjects*nObjects) + for _, pred := range preds { + for s := 0; s < nSubjects; s++ { + for o := 0; o < nObjects; o++ { + allEdges = append(allEdges, &pb.DirectedEdge{ + Entity: subjBase + uint64(s), + Attr: pred, + ValueId: objBase + uint64(o), + ValueType: pb.Posting_UID, + Op: pb.DirectedEdge_SET, + }) + } + } + } + // Shuffle so each batch carries an interleaved mix of predicates. + rnd := rand.New(rand.NewSource(1)) + rnd.Shuffle(len(allEdges), func(i, j int) { + allEdges[i], allEdges[j] = allEdges[j], allEdges[i] + }) + + const batchSize = 23 + ts := uint64(10) + for start := 0; start < len(allEdges); start += batchSize { + end := start + batchSize + if end > len(allEdges) { + end = len(allEdges) + } + batch := allEdges[start:end] + + txn := posting.Oracle().RegisterStartTs(ts) + require.NoError(t, newRunMutations(context.Background(), batch, txn)) + txn.Update() + w := posting.NewTxnWriter(ps) + require.NoError(t, txn.CommitToDisk(w, ts+1)) + require.NoError(t, w.Flush()) + txn.UpdateCachedKeys(ts + 1) + ts += 2 + } + + readTxn := posting.Oracle().RegisterStartTs(math.MaxUint64) + + wantForwardObjs := make([]uint64, nObjects) + for o := 0; o < nObjects; o++ { + wantForwardObjs[o] = objBase + uint64(o) + } + wantReverseSubjs := make([]uint64, nSubjects) + for s := 0; s < nSubjects; s++ { + wantReverseSubjs[s] = subjBase + uint64(s) + } + + for _, pred := range preds { + for s := 0; s < nSubjects; s++ { + subj := subjBase + uint64(s) + pl, err := readTxn.Get(x.DataKey(pred, subj)) + require.NoError(t, err) + got, err := pl.Uids(posting.ListOptions{ReadTs: math.MaxUint64}) + require.NoError(t, err) + require.ElementsMatch(t, wantForwardObjs, got.Uids, + "forward list for pred=%q subj=%d", pred, subj) + } + for o := 0; o < nObjects; o++ { + obj := objBase + uint64(o) + pl, err := readTxn.Get(x.ReverseKey(pred, obj)) + require.NoError(t, err) + got, err := pl.Uids(posting.ListOptions{ReadTs: math.MaxUint64}) + require.NoError(t, err) + require.ElementsMatch(t, wantReverseSubjs, got.Uids, + "reverse list for pred=%q obj=%d", pred, obj) + } + } +} + +// TestPipelineReverseListCountConcurrent mirrors the live-loader's actual +// concurrency: many goroutines submitting batched transactions in parallel, +// going through a real conflict-checking commit (the fakeOracle harness +// also used by TestPipelineCountIndexConcurrent), against a [uid] @reverse +// @count predicate. This is the closest in-process reproduction of the +// systest/21million/live shape that we have without standing up a real +// cluster. +func TestPipelineReverseListCountConcurrent(t *testing.T) { + dir, err := os.MkdirTemp("", "pipelinerevcount4_") + require.NoError(t, err) + defer os.RemoveAll(dir) + + ps, err := badger.OpenManaged(badger.DefaultOptions(dir)) + require.NoError(t, err) + defer ps.Close() + posting.Init(ps, 0, false) + Init(ps) + posting.Oracle().ResetTxns() + + require.NoError(t, schema.ParseBytes( + []byte(`genre: [uid] @reverse @count .`), 1)) + + pred := x.AttrInRootNamespace("genre") + + const ( + nSubjects = 60 // films + nObjects = 25 // genres + ) + subjBase := uint64(10000) + objBase := uint64(20000) + + allEdges := make([]*pb.DirectedEdge, 0, nSubjects*nObjects) + for s := 0; s < nSubjects; s++ { + for o := 0; o < nObjects; o++ { + allEdges = append(allEdges, &pb.DirectedEdge{ + Entity: subjBase + uint64(s), + Attr: pred, + ValueId: objBase + uint64(o), + ValueType: pb.Posting_UID, + Op: pb.DirectedEdge_SET, + }) + } + } + rnd := rand.New(rand.NewSource(7)) + rnd.Shuffle(len(allEdges), func(i, j int) { + allEdges[i], allEdges[j] = allEdges[j], allEdges[i] + }) + + // Chunk into many small batches so multiple goroutines compete on the + // same predicate, mimicking `dgraph live -c 10` behavior. + const batchSize = 17 + type batch []*pb.DirectedEdge + batches := []batch{} + for start := 0; start < len(allEdges); start += batchSize { + end := start + batchSize + if end > len(allEdges) { + end = len(allEdges) + } + batches = append(batches, allEdges[start:end]) + } + + oracle := newFakeOracle(10) + const concurrency = 10 + + jobs := make(chan batch, len(batches)) + for _, b := range batches { + jobs <- b + } + close(jobs) + + var wg sync.WaitGroup + wg.Add(concurrency) + for w := 0; w < concurrency; w++ { + go func() { + defer wg.Done() + for b := range jobs { + // Retry until commit, with a fresh edge clone each attempt + // (the pipeline mutates edge.ValueId during processing). + for attempt := 0; attempt < 200; attempt++ { + clones := make([]*pb.DirectedEdge, len(b)) + for i, e := range b { + clones[i] = &pb.DirectedEdge{ + Entity: e.Entity, Attr: e.Attr, + ValueId: e.ValueId, ValueType: e.ValueType, Op: e.Op, + } + } + if runPipelineTxn(t, ps, oracle, clones) { + break + } + } + } + }() + } + wg.Wait() + + t.Logf("committed=%d aborted=%d", oracle.committed.Load(), oracle.aborted.Load()) + + readTxn := posting.Oracle().RegisterStartTs(math.MaxUint64) + + wantForwardObjs := make([]uint64, nObjects) + for o := 0; o < nObjects; o++ { + wantForwardObjs[o] = objBase + uint64(o) + } + wantReverseSubjs := make([]uint64, nSubjects) + for s := 0; s < nSubjects; s++ { + wantReverseSubjs[s] = subjBase + uint64(s) + } + + missingForward := []string{} + missingReverse := []string{} + for s := 0; s < nSubjects; s++ { + subj := subjBase + uint64(s) + pl, err := readTxn.Get(x.DataKey(pred, subj)) + require.NoError(t, err) + got, err := pl.Uids(posting.ListOptions{ReadTs: math.MaxUint64}) + require.NoError(t, err) + if len(got.Uids) != nObjects { + missingForward = append(missingForward, + fmt.Sprintf("subj=%d has %d/%d", subj, len(got.Uids), nObjects)) + } + } + for o := 0; o < nObjects; o++ { + obj := objBase + uint64(o) + pl, err := readTxn.Get(x.ReverseKey(pred, obj)) + require.NoError(t, err) + got, err := pl.Uids(posting.ListOptions{ReadTs: math.MaxUint64}) + require.NoError(t, err) + if len(got.Uids) != nSubjects { + missingReverse = append(missingReverse, + fmt.Sprintf("obj=%d has %d/%d", obj, len(got.Uids), nSubjects)) + } + } + require.Empty(t, missingForward, "forward lists missing entries") + require.Empty(t, missingReverse, "reverse lists missing entries") + + // And the strict version: each list must match exactly. + for s := 0; s < nSubjects; s++ { + subj := subjBase + uint64(s) + pl, err := readTxn.Get(x.DataKey(pred, subj)) + require.NoError(t, err) + got, err := pl.Uids(posting.ListOptions{ReadTs: math.MaxUint64}) + require.NoError(t, err) + require.ElementsMatch(t, wantForwardObjs, got.Uids, + "forward list for subj %d", subj) + } + for o := 0; o < nObjects; o++ { + obj := objBase + uint64(o) + pl, err := readTxn.Get(x.ReverseKey(pred, obj)) + require.NoError(t, err) + got, err := pl.Uids(posting.ListOptions{ReadTs: math.MaxUint64}) + require.NoError(t, err) + require.ElementsMatch(t, wantReverseSubjs, got.Uids, + "reverse list for obj %d", obj) + } +} + func TestDeleteSetWithVarEdgeCorruptsData(t *testing.T) { // Setup temporary directory for Badger DB dir, err := os.MkdirTemp("", "storetest_") From 123362a28e01b35c60f1ce6758ad8dae2b4ce1c1 Mon Sep 17 00:00:00 2001 From: Matthew McNeely Date: Sat, 2 May 2026 16:16:58 -0400 Subject: [PATCH 14/45] test(systest): add query-073 covering reverse counts for all 592 non-empty genres MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A comprehensive `count(~genre)` check across every Genre entity in the 21million dataset. The query asks for each genre's name and the size of its reverse posting list, sorted by name; the fixture pins the expected count for all 592 genres that have at least one film. Motivation: the per-predicate mutation pipeline had a buffer-aliasing bug (fixed in 0a9ffc174) that produced wrong reverse-list contents on a different small subset of genres each load — sometimes Documentary lost most of its 31,370 entries, sometimes Children's/Family gained ~1,500 spurious Crime-Thriller entries, sometimes Backstage Musical and Indie film were the affected pair. The existing query-017 (Taraji-films-by-genre) only catches it when that specific actor's films happen to land on a corrupted genre, and query-016 / query-044 only test genres above 30,000 films. This new query exercises every genre's reverse list and pins the exact expected count, so any regression that mis-routes reverse edges for any genre will fail it directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- systest/21million/queries/query-073 | 2378 +++++++++++++++++++++++++++ 1 file changed, 2378 insertions(+) create mode 100644 systest/21million/queries/query-073 diff --git a/systest/21million/queries/query-073 b/systest/21million/queries/query-073 new file mode 100644 index 00000000000..670cb00f07d --- /dev/null +++ b/systest/21million/queries/query-073 @@ -0,0 +1,2378 @@ +{ + q(func: type(Genre), orderasc: name@en) @filter(gt(count(~genre), 0)) { + name@en + n_films: count(~genre) + } +} +--- +{ + "q": [ + { + "name@en": "/m/04rlf", + "n_films": 2 + }, + { + "name@en": "3D film", + "n_films": 4 + }, + { + "name@en": "Abstract animation", + "n_films": 6 + }, + { + "name@en": "Absurdism", + "n_films": 74 + }, + { + "name@en": "Acid Western", + "n_films": 8 + }, + { + "name@en": "Action", + "n_films": 48 + }, + { + "name@en": "Action", + "n_films": 1 + }, + { + "name@en": "Action Comedy", + "n_films": 149 + }, + { + "name@en": "Action Film", + "n_films": 17719 + }, + { + "name@en": "Action Thriller", + "n_films": 588 + }, + { + "name@en": "Action/Adventure", + "n_films": 5731 + }, + { + "name@en": "Adevnture", + "n_films": 1 + }, + { + "name@en": "Adult", + "n_films": 5 + }, + { + "name@en": "Adventure", + "n_films": 20 + }, + { + "name@en": "Adventure Comedy", + "n_films": 123 + }, + { + "name@en": "Adventure Film", + "n_films": 9367 + }, + { + "name@en": "Adventure game", + "n_films": 57 + }, + { + "name@en": "Adventure Travel", + "n_films": 8 + }, + { + "name@en": "African-American Comedies", + "n_films": 1 + }, + { + "name@en": "Ages 0-2", + "n_films": 2 + }, + { + "name@en": "Ages 2-4", + "n_films": 1 + }, + { + "name@en": "Ages 5-7", + "n_films": 1 + }, + { + "name@en": "Airplanes and airports", + "n_films": 30 + }, + { + "name@en": "Albinism in popular culture", + "n_films": 11 + }, + { + "name@en": "Alien Film", + "n_films": 97 + }, + { + "name@en": "Alien invasion", + "n_films": 4 + }, + { + "name@en": "Alien Sci-Fi", + "n_films": 2 + }, + { + "name@en": "Alternate history", + "n_films": 4 + }, + { + "name@en": "Alternative Health", + "n_films": 4 + }, + { + "name@en": "Amateur film", + "n_films": 1 + }, + { + "name@en": "Ambiance", + "n_films": 11 + }, + { + "name@en": "Animal", + "n_films": 54 + }, + { + "name@en": "Animal Picture", + "n_films": 193 + }, + { + "name@en": "Animated cartoon", + "n_films": 132 + }, + { + "name@en": "Animated documentary", + "n_films": 9 + }, + { + "name@en": "Animated Musical", + "n_films": 42 + }, + { + "name@en": "Animation", + "n_films": 12799 + }, + { + "name@en": "Anime", + "n_films": 1106 + }, + { + "name@en": "Anime Sci-Fi", + "n_films": 4 + }, + { + "name@en": "Anthology", + "n_films": 14 + }, + { + "name@en": "Anthology film", + "n_films": 2 + }, + { + "name@en": "Anti-McCarthyist", + "n_films": 1 + }, + { + "name@en": "Anti-war film", + "n_films": 43 + }, + { + "name@en": "Anti-war movement", + "n_films": 2 + }, + { + "name@en": "Arab cinema", + "n_films": 1 + }, + { + "name@en": "Archaeology", + "n_films": 8 + }, + { + "name@en": "Architecture & Design", + "n_films": 11 + }, + { + "name@en": "Archives and records", + "n_films": 5 + }, + { + "name@en": "Armchair Travel", + "n_films": 25 + }, + { + "name@en": "Art history", + "n_films": 18 + }, + { + "name@en": "Artistic gymnastics", + "n_films": 2 + }, + { + "name@en": "Arts and Culture", + "n_films": 395 + }, + { + "name@en": "Assamese cinema", + "n_films": 10 + }, + { + "name@en": "Auto racing", + "n_films": 210 + }, + { + "name@en": "Avant-garde", + "n_films": 152 + }, + { + "name@en": "B movie", + "n_films": 1059 + }, + { + "name@en": "B-boying", + "n_films": 2 + }, + { + "name@en": "B-Western", + "n_films": 86 + }, + { + "name@en": "Backstage", + "n_films": 1 + }, + { + "name@en": "Backstage Musical", + "n_films": 7246 + }, + { + "name@en": "Ballet", + "n_films": 10 + }, + { + "name@en": "BDSM", + "n_films": 3 + }, + { + "name@en": "Beach Film", + "n_films": 21 + }, + { + "name@en": "Beach party film", + "n_films": 3 + }, + { + "name@en": "Beauty & Fashion", + "n_films": 15 + }, + { + "name@en": "Bedroom farce", + "n_films": 1 + }, + { + "name@en": "Bengali Cinema", + "n_films": 854 + }, + { + "name@en": "Best of compilation", + "n_films": 1 + }, + { + "name@en": "Bhojpuri cinema", + "n_films": 4 + }, + { + "name@en": "Biker Film", + "n_films": 38 + }, + { + "name@en": "Biographical Documentaries", + "n_films": 27 + }, + { + "name@en": "Biographical film", + "n_films": 5968 + }, + { + "name@en": "Biography", + "n_films": 67 + }, + { + "name@en": "Biological Sciences", + "n_films": 15 + }, + { + "name@en": "Bisexual pornography", + "n_films": 12 + }, + { + "name@en": "Black comedy", + "n_films": 1313 + }, + { + "name@en": "Black-and-white", + "n_films": 7780 + }, + { + "name@en": "Blaxploitation film", + "n_films": 121 + }, + { + "name@en": "Bloopers & Candid Camera", + "n_films": 7 + }, + { + "name@en": "Bollywood", + "n_films": 5119 + }, + { + "name@en": "British Cinema", + "n_films": 10 + }, + { + "name@en": "British Empire Film", + "n_films": 21 + }, + { + "name@en": "British New Wave", + "n_films": 12 + }, + { + "name@en": "Bruceploitation", + "n_films": 4 + }, + { + "name@en": "Buddy cop film", + "n_films": 11 + }, + { + "name@en": "Buddy film", + "n_films": 357 + }, + { + "name@en": "Business", + "n_films": 2 + }, + { + "name@en": "Business", + "n_films": 17 + }, + { + "name@en": "C-Movie", + "n_films": 1 + }, + { + "name@en": "Camp", + "n_films": 2 + }, + { + "name@en": "Cannibal film", + "n_films": 1 + }, + { + "name@en": "Caper story", + "n_films": 96 + }, + { + "name@en": "Careers", + "n_films": 4 + }, + { + "name@en": "Cartoon", + "n_films": 374 + }, + { + "name@en": "Cavalry Film", + "n_films": 19 + }, + { + "name@en": "Chase Movie", + "n_films": 89 + }, + { + "name@en": "Chick flick", + "n_films": 2 + }, + { + "name@en": "Childhood Drama", + "n_films": 132 + }, + { + "name@en": "Children's Fantasy", + "n_films": 229 + }, + { + "name@en": "Children's Issues", + "n_films": 14 + }, + { + "name@en": "Children's television series", + "n_films": 1 + }, + { + "name@en": "Children's/Family", + "n_films": 884 + }, + { + "name@en": "Chinese Movies", + "n_films": 2446 + }, + { + "name@en": "Chivalric romance", + "n_films": 2 + }, + { + "name@en": "Christian film", + "n_films": 122 + }, + { + "name@en": "Christian film industry", + "n_films": 3 + }, + { + "name@en": "Christianity", + "n_films": 5 + }, + { + "name@en": "Christmas", + "n_films": 1 + }, + { + "name@en": "Christmas movie", + "n_films": 94 + }, + { + "name@en": "Christmas special", + "n_films": 3 + }, + { + "name@en": "Cin\u00e9ma v\u00e9rit\u00e9", + "n_films": 7 + }, + { + "name@en": "Clay animation", + "n_films": 7 + }, + { + "name@en": "Closeted", + "n_films": 1 + }, + { + "name@en": "Collage", + "n_films": 2 + }, + { + "name@en": "College life", + "n_films": 1 + }, + { + "name@en": "Color motion picture film", + "n_films": 2 + }, + { + "name@en": "Combat Films", + "n_films": 93 + }, + { + "name@en": "Comedy", + "n_films": 41070 + }, + { + "name@en": "Comedy of Errors", + "n_films": 182 + }, + { + "name@en": "Comedy of manners", + "n_films": 218 + }, + { + "name@en": "Comedy Thriller", + "n_films": 93 + }, + { + "name@en": "Comedy Western", + "n_films": 241 + }, + { + "name@en": "Comedy-drama", + "n_films": 2173 + }, + { + "name@en": "Coming of age", + "n_films": 781 + }, + { + "name@en": "Coming out", + "n_films": 13 + }, + { + "name@en": "Coming-of-age story", + "n_films": 277 + }, + { + "name@en": "Commedia all'italiana", + "n_films": 2 + }, + { + "name@en": "Commedia sexy all'italiana", + "n_films": 2 + }, + { + "name@en": "Compilation film", + "n_films": 15 + }, + { + "name@en": "Computer Animation", + "n_films": 197 + }, + { + "name@en": "Computers", + "n_films": 13 + }, + { + "name@en": "Concert", + "n_films": 4 + }, + { + "name@en": "Concert film", + "n_films": 3050 + }, + { + "name@en": "Conspiracy fiction", + "n_films": 1 + }, + { + "name@en": "Cooking & Food", + "n_films": 6 + }, + { + "name@en": "Costume Adventure", + "n_films": 80 + }, + { + "name@en": "Costume drama", + "n_films": 359 + }, + { + "name@en": "Costume Horror", + "n_films": 42 + }, + { + "name@en": "Courtroom Comedy", + "n_films": 6 + }, + { + "name@en": "Creature Film", + "n_films": 306 + }, + { + "name@en": "Crime", + "n_films": 224 + }, + { + "name@en": "Crime Comedy", + "n_films": 272 + }, + { + "name@en": "Crime Drama", + "n_films": 479 + }, + { + "name@en": "Crime Fiction", + "n_films": 13019 + }, + { + "name@en": "Crime film", + "n_films": 42 + }, + { + "name@en": "Crime Thriller", + "n_films": 2541 + }, + { + "name@en": "Cult film", + "n_films": 937 + }, + { + "name@en": "Culture & Society", + "n_films": 895 + }, + { + "name@en": "Current affairs", + "n_films": 1 + }, + { + "name@en": "Cyberpunk", + "n_films": 9 + }, + { + "name@en": "Cycling", + "n_films": 1 + }, + { + "name@en": "Czechoslovak New Wave", + "n_films": 6 + }, + { + "name@en": "Damsel in distress", + "n_films": 1 + }, + { + "name@en": "Dance film", + "n_films": 986 + }, + { + "name@en": "Demonic child", + "n_films": 5 + }, + { + "name@en": "Detective fiction", + "n_films": 520 + }, + { + "name@en": "Diet & Nutrition", + "n_films": 8 + }, + { + "name@en": "Direct-to-video", + "n_films": 51 + }, + { + "name@en": "Disaster Film", + "n_films": 318 + }, + { + "name@en": "Docmentary", + "n_films": 1 + }, + { + "name@en": "Docudrama", + "n_films": 477 + }, + { + "name@en": "Docufiction", + "n_films": 28 + }, + { + "name@en": "Documentary film", + "n_films": 31370 + }, + { + "name@en": "Documentation", + "n_films": 1 + }, + { + "name@en": "Dogme 95", + "n_films": 77 + }, + { + "name@en": "Dokumentarfilm", + "n_films": 1 + }, + { + "name@en": "Domaci", + "n_films": 1 + }, + { + "name@en": "Domestic Comedy", + "n_films": 179 + }, + { + "name@en": "Doomsday film", + "n_films": 79 + }, + { + "name@en": "Drama", + "n_films": 238 + }, + { + "name@en": "Drama", + "n_films": 76252 + }, + { + "name@en": "Dystopia", + "n_films": 62 + }, + { + "name@en": "Early Black Cinema", + "n_films": 7 + }, + { + "name@en": "East Asian cinema", + "n_films": 3747 + }, + { + "name@en": "Education", + "n_films": 26 + }, + { + "name@en": "Educational & Guidance", + "n_films": 1 + }, + { + "name@en": "Educational entertainment", + "n_films": 1 + }, + { + "name@en": "Educational film", + "n_films": 3852 + }, + { + "name@en": "Edupunk", + "n_films": 1 + }, + { + "name@en": "Ensemble Film", + "n_films": 328 + }, + { + "name@en": "Environment", + "n_films": 11 + }, + { + "name@en": "Environmental Science", + "n_films": 36 + }, + { + "name@en": "Epic film", + "n_films": 318 + }, + { + "name@en": "Epic Western", + "n_films": 16 + }, + { + "name@en": "Equestrianism", + "n_films": 1 + }, + { + "name@en": "Erotic Drama", + "n_films": 132 + }, + { + "name@en": "Erotic Science Fiction", + "n_films": 1 + }, + { + "name@en": "Erotic thriller", + "n_films": 471 + }, + { + "name@en": "Erotica", + "n_films": 719 + }, + { + "name@en": "Escape Film", + "n_films": 45 + }, + { + "name@en": "Essay Film", + "n_films": 25 + }, + { + "name@en": "Estonian animation", + "n_films": 3 + }, + { + "name@en": "Ethnofiction", + "n_films": 2 + }, + { + "name@en": "Ethnographic film", + "n_films": 4 + }, + { + "name@en": "Eurospy film", + "n_films": 1 + }, + { + "name@en": "Eurowestern", + "n_films": 5 + }, + { + "name@en": "Evil clown", + "n_films": 3 + }, + { + "name@en": "Existentialism", + "n_films": 16 + }, + { + "name@en": "Experimental film", + "n_films": 1563 + }, + { + "name@en": "Exploitation film", + "n_films": 43 + }, + { + "name@en": "Expressionism", + "n_films": 3 + }, + { + "name@en": "Extraterrestrial life", + "n_films": 1 + }, + { + "name@en": "Extreme Sports", + "n_films": 174 + }, + { + "name@en": "Fairy tale", + "n_films": 80 + }, + { + "name@en": "Family", + "n_films": 11267 + }, + { + "name@en": "Family", + "n_films": 11 + }, + { + "name@en": "Family Comedies", + "n_films": 3 + }, + { + "name@en": "Family Drama", + "n_films": 1024 + }, + { + "name@en": "Family-Oriented Adventure", + "n_films": 237 + }, + { + "name@en": "Fan film", + "n_films": 43 + }, + { + "name@en": "Fantasy", + "n_films": 466 + }, + { + "name@en": "Fantasy", + "n_films": 1 + }, + { + "name@en": "Fantasy", + "n_films": 5854 + }, + { + "name@en": "Fantasy", + "n_films": 1 + }, + { + "name@en": "Fantasy Adventure", + "n_films": 162 + }, + { + "name@en": "Fantasy Comedy", + "n_films": 171 + }, + { + "name@en": "Fantasy Drama", + "n_films": 15 + }, + { + "name@en": "Farce", + "n_films": 293 + }, + { + "name@en": "Fashion", + "n_films": 1 + }, + { + "name@en": "Feature film", + "n_films": 26 + }, + { + "name@en": "Female buddy film", + "n_films": 4 + }, + { + "name@en": "Feminist Film", + "n_films": 80 + }, + { + "name@en": "Fiction", + "n_films": 71 + }, + { + "name@en": "Fiction", + "n_films": 1 + }, + { + "name@en": "Fictional film", + "n_films": 43 + }, + { + "name@en": "Filipino Movies", + "n_films": 18 + }, + { + "name@en": "Film & Television History", + "n_films": 428 + }, + { + "name@en": "Film \u00e0 clef", + "n_films": 29 + }, + { + "name@en": "Film adaptation", + "n_films": 1448 + }, + { + "name@en": "Film noir", + "n_films": 757 + }, + { + "name@en": "Film-Opera", + "n_films": 5 + }, + { + "name@en": "Film-poem", + "n_films": 1 + }, + { + "name@en": "Filmed Play", + "n_films": 18 + }, + { + "name@en": "Films with live action and animation", + "n_films": 8 + }, + { + "name@en": "Finance & Investing", + "n_films": 7 + }, + { + "name@en": "Flash animation", + "n_films": 1 + }, + { + "name@en": "Foreign legion", + "n_films": 8 + }, + { + "name@en": "Found footage", + "n_films": 10 + }, + { + "name@en": "French New Wave", + "n_films": 1 + }, + { + "name@en": "Future history", + "n_films": 1 + }, + { + "name@en": "Future noir", + "n_films": 18 + }, + { + "name@en": "Game show", + "n_films": 4 + }, + { + "name@en": "Gangster Film", + "n_films": 426 + }, + { + "name@en": "Gay", + "n_films": 306 + }, + { + "name@en": "Gay & Lesbian Films", + "n_films": 3 + }, + { + "name@en": "Gay Interest", + "n_films": 300 + }, + { + "name@en": "Gay pornography", + "n_films": 10970 + }, + { + "name@en": "Gay Themed", + "n_films": 467 + }, + { + "name@en": "Gender Issues", + "n_films": 107 + }, + { + "name@en": "Giallo", + "n_films": 20 + }, + { + "name@en": "Glamorized Spy Film", + "n_films": 47 + }, + { + "name@en": "Goat gland film", + "n_films": 3 + }, + { + "name@en": "Gothic Film", + "n_films": 52 + }, + { + "name@en": "Graphic & Applied Arts", + "n_films": 32 + }, + { + "name@en": "Gross out", + "n_films": 45 + }, + { + "name@en": "Gujarati cinema", + "n_films": 1 + }, + { + "name@en": "Gulf War", + "n_films": 4 + }, + { + "name@en": "Hagiography", + "n_films": 26 + }, + { + "name@en": "Hard rock", + "n_films": 1 + }, + { + "name@en": "Hardcore pornography", + "n_films": 278 + }, + { + "name@en": "Hardcore punk", + "n_films": 1 + }, + { + "name@en": "Haunted House Film", + "n_films": 66 + }, + { + "name@en": "Health & Fitness", + "n_films": 218 + }, + { + "name@en": "Heaven-Can-Wait Fantasies", + "n_films": 24 + }, + { + "name@en": "Heavenly Comedy", + "n_films": 30 + }, + { + "name@en": "Heavenly Drama", + "n_films": 2 + }, + { + "name@en": "Heavy metal", + "n_films": 1 + }, + { + "name@en": "Heist film", + "n_films": 203 + }, + { + "name@en": "Hentai", + "n_films": 35 + }, + { + "name@en": "Hip hop film", + "n_films": 770 + }, + { + "name@en": "Historical drama", + "n_films": 1038 + }, + { + "name@en": "Historical Epic", + "n_films": 64 + }, + { + "name@en": "Historical fiction", + "n_films": 2095 + }, + { + "name@en": "Historical period drama", + "n_films": 1404 + }, + { + "name@en": "History", + "n_films": 2272 + }, + { + "name@en": "Hobbies and interests", + "n_films": 167 + }, + { + "name@en": "Holiday", + "n_films": 4 + }, + { + "name@en": "Holiday Film", + "n_films": 97 + }, + { + "name@en": "Home movies", + "n_films": 2 + }, + { + "name@en": "Horor", + "n_films": 1 + }, + { + "name@en": "Horror", + "n_films": 10511 + }, + { + "name@en": "Horror comedy", + "n_films": 794 + }, + { + "name@en": "Human sexuality", + "n_films": 5 + }, + { + "name@en": "Hybrid Western", + "n_films": 41 + }, + { + "name@en": "Illnesses & Disabilities", + "n_films": 65 + }, + { + "name@en": "Incest pornography", + "n_films": 2 + }, + { + "name@en": "Independent Dramas", + "n_films": 2 + }, + { + "name@en": "Indian Movies", + "n_films": 8 + }, + { + "name@en": "Indian Western", + "n_films": 26 + }, + { + "name@en": "Indie film", + "n_films": 8644 + }, + { + "name@en": "Informational", + "n_films": 6 + }, + { + "name@en": "Inspirational Drama", + "n_films": 39 + }, + { + "name@en": "inspirational film", + "n_films": 1 + }, + { + "name@en": "Instrumental Music", + "n_films": 56 + }, + { + "name@en": "International", + "n_films": 1 + }, + { + "name@en": "Interpersonal Relationships", + "n_films": 55 + }, + { + "name@en": "Inventions & Innovations", + "n_films": 16 + }, + { + "name@en": "Japanese", + "n_films": 1 + }, + { + "name@en": "Japanese horror", + "n_films": 3 + }, + { + "name@en": "Japanese Movies", + "n_films": 3257 + }, + { + "name@en": "Jazz & Easy Listening", + "n_films": 1 + }, + { + "name@en": "Jazz & Modern Dance", + "n_films": 16 + }, + { + "name@en": "Jidaigeki", + "n_films": 9 + }, + { + "name@en": "Journalism", + "n_films": 9 + }, + { + "name@en": "Jukebox musical", + "n_films": 6 + }, + { + "name@en": "Jungle Film", + "n_films": 66 + }, + { + "name@en": "Juvenile Delinquency Film", + "n_films": 49 + }, + { + "name@en": "Kafkaesque", + "n_films": 3 + }, + { + "name@en": "Kaiju", + "n_films": 7 + }, + { + "name@en": "Kannada", + "n_films": 10 + }, + { + "name@en": "Kids' TV", + "n_films": 3 + }, + { + "name@en": "Kitchen sink realism", + "n_films": 15 + }, + { + "name@en": "Korean drama", + "n_films": 109 + }, + { + "name@en": "Korean horror", + "n_films": 3 + }, + { + "name@en": "Language & Literature", + "n_films": 48 + }, + { + "name@en": "Language Instruction", + "n_films": 1 + }, + { + "name@en": "Latin American cinema", + "n_films": 13 + }, + { + "name@en": "Latino", + "n_films": 2 + }, + { + "name@en": "Law & Crime", + "n_films": 84 + }, + { + "name@en": "Legal drama", + "n_films": 9 + }, + { + "name@en": "Leisure Arts", + "n_films": 15 + }, + { + "name@en": "LGBT", + "n_films": 3605 + }, + { + "name@en": "Libraries and librarians", + "n_films": 5 + }, + { + "name@en": "Lifestyle", + "n_films": 1 + }, + { + "name@en": "Linguistics", + "n_films": 5 + }, + { + "name@en": "Literary Studies", + "n_films": 33 + }, + { + "name@en": "Live action", + "n_films": 15 + }, + { + "name@en": "Lost film", + "n_films": 17 + }, + { + "name@en": "Mafia", + "n_films": 1 + }, + { + "name@en": "Mafia comedy", + "n_films": 3 + }, + { + "name@en": "Magic realism", + "n_films": 4 + }, + { + "name@en": "Malayalam Cinema", + "n_films": 2155 + }, + { + "name@en": "Male bonding", + "n_films": 2 + }, + { + "name@en": "Marathi cinema", + "n_films": 286 + }, + { + "name@en": "Marriage & Commitment", + "n_films": 10 + }, + { + "name@en": "Marriage Drama", + "n_films": 160 + }, + { + "name@en": "Martial Arts Film", + "n_films": 1944 + }, + { + "name@en": "Masala film", + "n_films": 6 + }, + { + "name@en": "Mashup", + "n_films": 1 + }, + { + "name@en": "Mecha", + "n_films": 21 + }, + { + "name@en": "Mecha anime and manga", + "n_films": 3 + }, + { + "name@en": "Media Satire", + "n_films": 71 + }, + { + "name@en": "Media studies", + "n_films": 45 + }, + { + "name@en": "Medical fiction", + "n_films": 74 + }, + { + "name@en": "Medieval fantasy", + "n_films": 1 + }, + { + "name@en": "Melodrama", + "n_films": 1206 + }, + { + "name@en": "Metalcore", + "n_films": 2 + }, + { + "name@en": "Miniseries", + "n_films": 1 + }, + { + "name@en": "Mistery", + "n_films": 1 + }, + { + "name@en": "Mockbuster", + "n_films": 13 + }, + { + "name@en": "Mockumentary", + "n_films": 280 + }, + { + "name@en": "Mondo film", + "n_films": 11 + }, + { + "name@en": "Monster", + "n_films": 104 + }, + { + "name@en": "Monster movie", + "n_films": 252 + }, + { + "name@en": "Montage", + "n_films": 1 + }, + { + "name@en": "Motion capture", + "n_films": 1 + }, + { + "name@en": "Motorsport", + "n_films": 15 + }, + { + "name@en": "Movies About Gladiators", + "n_films": 2 + }, + { + "name@en": "Multimedia", + "n_films": 2 + }, + { + "name@en": "Mumblecore", + "n_films": 8 + }, + { + "name@en": "Murder-mystery", + "n_films": 5 + }, + { + "name@en": "Museums and art galleries", + "n_films": 1 + }, + { + "name@en": "Music", + "n_films": 8705 + }, + { + "name@en": "Music video", + "n_films": 183 + }, + { + "name@en": "Musical comedy", + "n_films": 2681 + }, + { + "name@en": "Musical Drama", + "n_films": 2841 + }, + { + "name@en": "Musical theatre", + "n_films": 2 + }, + { + "name@en": "Mystery", + "n_films": 6520 + }, + { + "name@en": "Mystery film", + "n_films": 23 + }, + { + "name@en": "Mythic fiction", + "n_films": 5 + }, + { + "name@en": "Mythological Fantasy", + "n_films": 23 + }, + { + "name@en": "Natural horror film", + "n_films": 153 + }, + { + "name@en": "Nature", + "n_films": 73 + }, + { + "name@en": "Nazi exploitation", + "n_films": 4 + }, + { + "name@en": "Neo-noir", + "n_films": 58 + }, + { + "name@en": "New Queer Cinema", + "n_films": 1 + }, + { + "name@en": "News", + "n_films": 76 + }, + { + "name@en": "Newsreel", + "n_films": 7 + }, + { + "name@en": "Ninja movie", + "n_films": 1 + }, + { + "name@en": "No Wave Cinema", + "n_films": 1 + }, + { + "name@en": "Non-fiction", + "n_films": 40 + }, + { + "name@en": "Northern", + "n_films": 48 + }, + { + "name@en": "Nuclear warfare", + "n_films": 1 + }, + { + "name@en": "Nudie film", + "n_films": 1 + }, + { + "name@en": "Nunsploitation", + "n_films": 14 + }, + { + "name@en": "Opera", + "n_films": 51 + }, + { + "name@en": "Operetta", + "n_films": 6 + }, + { + "name@en": "Operetta film", + "n_films": 5 + }, + { + "name@en": "Ostern", + "n_films": 5 + }, + { + "name@en": "Outdoor & Mountain Sports", + "n_films": 8 + }, + { + "name@en": "Outlaw", + "n_films": 2 + }, + { + "name@en": "Paracinema", + "n_films": 1 + }, + { + "name@en": "Paranormal", + "n_films": 6 + }, + { + "name@en": "Parody", + "n_films": 1229 + }, + { + "name@en": "Parody film", + "n_films": 19 + }, + { + "name@en": "Part-talkie", + "n_films": 5 + }, + { + "name@en": "Patriotic film", + "n_films": 3 + }, + { + "name@en": "Period Horror", + "n_films": 1 + }, + { + "name@en": "Pink film", + "n_films": 53 + }, + { + "name@en": "Point of view shot", + "n_films": 2 + }, + { + "name@en": "Police", + "n_films": 1 + }, + { + "name@en": "Police Comedy", + "n_films": 26 + }, + { + "name@en": "Police procedural", + "n_films": 2 + }, + { + "name@en": "Political cinema", + "n_films": 855 + }, + { + "name@en": "Political Documetary", + "n_films": 2 + }, + { + "name@en": "Political drama", + "n_films": 1386 + }, + { + "name@en": "Political fiction", + "n_films": 4 + }, + { + "name@en": "Political satire", + "n_films": 73 + }, + { + "name@en": "Political thriller", + "n_films": 282 + }, + { + "name@en": "Politics", + "n_films": 1 + }, + { + "name@en": "Poliziotteschi", + "n_films": 42 + }, + { + "name@en": "Pop rock", + "n_films": 1 + }, + { + "name@en": "Pornochanchada", + "n_films": 1 + }, + { + "name@en": "Pornographic film", + "n_films": 6516 + }, + { + "name@en": "Pornography", + "n_films": 2 + }, + { + "name@en": "Post-Apocalyptic fiction", + "n_films": 30 + }, + { + "name@en": "Prison escape", + "n_films": 2 + }, + { + "name@en": "Prison film", + "n_films": 143 + }, + { + "name@en": "Propaganda film", + "n_films": 221 + }, + { + "name@en": "Psycho film", + "n_films": 1 + }, + { + "name@en": "Psycho-biddy", + "n_films": 13 + }, + { + "name@en": "Psychological Drama", + "n_films": 47 + }, + { + "name@en": "Psychological horror", + "n_films": 21 + }, + { + "name@en": "Psychological thriller", + "n_films": 1746 + }, + { + "name@en": "Public information film", + "n_films": 14 + }, + { + "name@en": "Punk rock", + "n_films": 395 + }, + { + "name@en": "Puppetoon", + "n_films": 1 + }, + { + "name@en": "Race film", + "n_films": 13 + }, + { + "name@en": "Rape and revenge film", + "n_films": 2 + }, + { + "name@en": "Real Robot", + "n_films": 2 + }, + { + "name@en": "Reality film", + "n_films": 4 + }, + { + "name@en": "Reality television", + "n_films": 30 + }, + { + "name@en": "Reboot", + "n_films": 5 + }, + { + "name@en": "Religion", + "n_films": 21 + }, + { + "name@en": "Religious Film", + "n_films": 170 + }, + { + "name@en": "Remake", + "n_films": 53 + }, + { + "name@en": "Revenge", + "n_films": 5 + }, + { + "name@en": "Revisionist Fairy Tale", + "n_films": 1 + }, + { + "name@en": "Revisionist Western", + "n_films": 68 + }, + { + "name@en": "Road movie", + "n_films": 399 + }, + { + "name@en": "Road-Horror", + "n_films": 16 + }, + { + "name@en": "Roadshow theatrical release", + "n_films": 24 + }, + { + "name@en": "Roadshow/Carny", + "n_films": 4 + }, + { + "name@en": "Rock & Pop", + "n_films": 1 + }, + { + "name@en": "Rockumentary", + "n_films": 1077 + }, + { + "name@en": "Romance", + "n_films": 4 + }, + { + "name@en": "Romance", + "n_films": 39 + }, + { + "name@en": "Romance Film", + "n_films": 21094 + }, + { + "name@en": "Romantic comedy", + "n_films": 3602 + }, + { + "name@en": "Romantic Drama", + "n_films": 1 + }, + { + "name@en": "Romantic fantasy", + "n_films": 67 + }, + { + "name@en": "Romantic Movies", + "n_films": 4 + }, + { + "name@en": "Romantic thriller", + "n_films": 4 + }, + { + "name@en": "Rubble film", + "n_films": 1 + }, + { + "name@en": "Samurai cinema", + "n_films": 39 + }, + { + "name@en": "Satire", + "n_films": 713 + }, + { + "name@en": "School story", + "n_films": 5 + }, + { + "name@en": "Sci-Fi", + "n_films": 3 + }, + { + "name@en": "Sci-Fi Adventure", + "n_films": 31 + }, + { + "name@en": "Sci-Fi Horror", + "n_films": 157 + }, + { + "name@en": "Science", + "n_films": 1 + }, + { + "name@en": "Science and Nature Documentaries", + "n_films": 1 + }, + { + "name@en": "Science fantasy", + "n_films": 2 + }, + { + "name@en": "Science Fiction", + "n_films": 5851 + }, + { + "name@en": "Science fiction film", + "n_films": 20 + }, + { + "name@en": "Science fiction Western", + "n_films": 9 + }, + { + "name@en": "Screwball comedy", + "n_films": 345 + }, + { + "name@en": "Self-help", + "n_films": 13 + }, + { + "name@en": "Semidocumentary", + "n_films": 1 + }, + { + "name@en": "Sepia tone", + "n_films": 1 + }, + { + "name@en": "Serial film", + "n_films": 19 + }, + { + "name@en": "Sermons", + "n_films": 4 + }, + { + "name@en": "Sex comedy", + "n_films": 373 + }, + { + "name@en": "Sex Horror", + "n_films": 8 + }, + { + "name@en": "Sexploitation film", + "n_films": 142 + }, + { + "name@en": "Sh\u014djo manga", + "n_films": 1 + }, + { + "name@en": "Short Film", + "n_films": 46476 + }, + { + "name@en": "Short story", + "n_films": 1 + }, + { + "name@en": "Showbiz Dramas", + "n_films": 1 + }, + { + "name@en": "Silent comedy", + "n_films": 7 + }, + { + "name@en": "Silent film", + "n_films": 6955 + }, + { + "name@en": "Singing cowboy", + "n_films": 5 + }, + { + "name@en": "Sketch comedy", + "n_films": 1 + }, + { + "name@en": "Slapstick", + "n_films": 614 + }, + { + "name@en": "Slasher", + "n_films": 1198 + }, + { + "name@en": "Slice of life", + "n_films": 96 + }, + { + "name@en": "Snuff film", + "n_films": 1 + }, + { + "name@en": "Social & Cultural Documentaries", + "n_films": 5 + }, + { + "name@en": "Social commentary", + "n_films": 1 + }, + { + "name@en": "Social film", + "n_films": 5 + }, + { + "name@en": "Social guidance film", + "n_films": 16 + }, + { + "name@en": "Social issues", + "n_films": 3 + }, + { + "name@en": "Social problem film", + "n_films": 155 + }, + { + "name@en": "Softcore pornography", + "n_films": 246 + }, + { + "name@en": "South Asian cinema", + "n_films": 358 + }, + { + "name@en": "Southeast Asian cinema", + "n_films": 7 + }, + { + "name@en": "Southern Gothic", + "n_films": 1 + }, + { + "name@en": "Space opera", + "n_films": 4 + }, + { + "name@en": "Space Western", + "n_films": 12 + }, + { + "name@en": "Spaghetti Western", + "n_films": 153 + }, + { + "name@en": "Special Interest", + "n_films": 41 + }, + { + "name@en": "Spiritual film", + "n_films": 297 + }, + { + "name@en": "Spirituality", + "n_films": 1 + }, + { + "name@en": "Splatter film", + "n_films": 35 + }, + { + "name@en": "Sponsored film", + "n_films": 9 + }, + { + "name@en": "Sports", + "n_films": 111 + }, + { + "name@en": "Sports films", + "n_films": 6114 + }, + { + "name@en": "Spy fiction", + "n_films": 1 + }, + { + "name@en": "Spy film", + "n_films": 554 + }, + { + "name@en": "Stand-up comedy", + "n_films": 686 + }, + { + "name@en": "Star vehicle", + "n_films": 1 + }, + { + "name@en": "Steampunk", + "n_films": 19 + }, + { + "name@en": "Stoner film", + "n_films": 46 + }, + { + "name@en": "Stop motion", + "n_films": 128 + }, + { + "name@en": "Structural film", + "n_films": 1 + }, + { + "name@en": "Superhero movie", + "n_films": 306 + }, + { + "name@en": "Supermarionation", + "n_films": 2 + }, + { + "name@en": "Supernatural", + "n_films": 633 + }, + { + "name@en": "Supernatural Horror", + "n_films": 4 + }, + { + "name@en": "Surf film", + "n_films": 22 + }, + { + "name@en": "Surrealism", + "n_films": 97 + }, + { + "name@en": "Suspense", + "n_films": 993 + }, + { + "name@en": "Swashbuckler film", + "n_films": 137 + }, + { + "name@en": "Sword and Sandal film", + "n_films": 53 + }, + { + "name@en": "Sword and sorcery", + "n_films": 61 + }, + { + "name@en": "Talk show", + "n_films": 8 + }, + { + "name@en": "Talkie", + "n_films": 2 + }, + { + "name@en": "Tamil cinema", + "n_films": 2817 + }, + { + "name@en": "Tearjerkers", + "n_films": 1 + }, + { + "name@en": "Technicolor", + "n_films": 6 + }, + { + "name@en": "Techno-thriller", + "n_films": 1 + }, + { + "name@en": "Teen drama", + "n_films": 4 + }, + { + "name@en": "Teen film", + "n_films": 1047 + }, + { + "name@en": "Teen pornography", + "n_films": 3 + }, + { + "name@en": "Teen Screams", + "n_films": 1 + }, + { + "name@en": "Teleplay", + "n_films": 2 + }, + { + "name@en": "Television advertisement", + "n_films": 4 + }, + { + "name@en": "Television documentary", + "n_films": 93 + }, + { + "name@en": "Television film", + "n_films": 1414 + }, + { + "name@en": "Television Film", + "n_films": 1 + }, + { + "name@en": "Television pilot", + "n_films": 6 + }, + { + "name@en": "Terrorism", + "n_films": 1 + }, + { + "name@en": "Theatrical Performances", + "n_films": 1 + }, + { + "name@en": "Therimin music", + "n_films": 3 + }, + { + "name@en": "Thrash metal", + "n_films": 1 + }, + { + "name@en": "Thriller", + "n_films": 17716 + }, + { + "name@en": "Time travel", + "n_films": 67 + }, + { + "name@en": "Tokusatsu", + "n_films": 4 + }, + { + "name@en": "Tollywood", + "n_films": 1868 + }, + { + "name@en": "Tragicomedy", + "n_films": 76 + }, + { + "name@en": "Training film", + "n_films": 7 + }, + { + "name@en": "Transforming robots", + "n_films": 1 + }, + { + "name@en": "Transsexualism", + "n_films": 2 + }, + { + "name@en": "Travel", + "n_films": 683 + }, + { + "name@en": "Travel & Adventure Documentaries", + "n_films": 1 + }, + { + "name@en": "Travel documentary", + "n_films": 5 + }, + { + "name@en": "Trial drama", + "n_films": 300 + }, + { + "name@en": "Tribute", + "n_films": 1 + }, + { + "name@en": "Triller", + "n_films": 1 + }, + { + "name@en": "True crime", + "n_films": 60 + }, + { + "name@en": "Tutorial", + "n_films": 1 + }, + { + "name@en": "TV Documentaries", + "n_films": 1 + }, + { + "name@en": "Underground film", + "n_films": 6 + }, + { + "name@en": "Vampire film", + "n_films": 18 + }, + { + "name@en": "Variety show", + "n_films": 1 + }, + { + "name@en": "Vigilante film", + "n_films": 1 + }, + { + "name@en": "Viral video", + "n_films": 1 + }, + { + "name@en": "War", + "n_films": 20 + }, + { + "name@en": "War effort", + "n_films": 1 + }, + { + "name@en": "War film", + "n_films": 5288 + }, + { + "name@en": "Web documentary", + "n_films": 2 + }, + { + "name@en": "Webisode", + "n_films": 2 + }, + { + "name@en": "Werewolf fiction", + "n_films": 4 + }, + { + "name@en": "Western", + "n_films": 4099 + }, + { + "name@en": "Whodunit", + "n_films": 87 + }, + { + "name@en": "Women in prison film", + "n_films": 13 + }, + { + "name@en": "Workplace Comedy", + "n_films": 86 + }, + { + "name@en": "World cinema", + "n_films": 21668 + }, + { + "name@en": "World history", + "n_films": 27 + }, + { + "name@en": "Wrestling", + "n_films": 1 + }, + { + "name@en": "Wuxia", + "n_films": 110 + }, + { + "name@en": "Yakuza film", + "n_films": 22 + }, + { + "name@en": "Youth", + "n_films": 1 + }, + { + "name@en": "Yuri", + "n_films": 2 + }, + { + "name@en": "Z movie", + "n_films": 2 + }, + { + "name@en": "Zapata Western", + "n_films": 1 + }, + { + "name@en": "Zombie", + "n_films": 4 + }, + { + "name@en": "Zombie", + "n_films": 8 + }, + { + "name@en": "Zombie comedy", + "n_films": 3 + }, + { + "name@en": "Zombie Film", + "n_films": 306 + }, + { + "name@en": "Zombie Movies", + "n_films": 2 + }, + { + "n_films": 2 + } + ] +} From 6bd35520746e8ddb66e9684f52609ec488c344e6 Mon Sep 17 00:00:00 2001 From: Matthew McNeely Date: Mon, 4 May 2026 15:33:01 -0400 Subject: [PATCH 15/45] fix(pipeline): bg-indexed star-delete + scalar Set data-list corruption Two related bugs in the per-predicate mutation pipeline that surface as stale index entries under TestStringIndex (systest/bgindex): 1. Process() (the star-delete fast path) was passing the caller's bare context to handleDeleteAll. schema.State().IsIndexed / IsReversed / HasCount only consult the pending mutSchema (the new schema with the index being built) when the context carries the isWrite flag. The legacy runMutation path calls schema.GetWriteContext at the top; the pipeline didn't. During a background index build, every star-delete saw isIndexed=false and skipped addIndexMutations(DEL) for the prior value, leaving stale uids in the index permanently. Lifting GetWriteContext into Process covers both the star-delete path and the later predicate-pipeline goroutines. 2. ProcessSingle's data-list write was passing the unfiltered postings list to AddDelta. handleOldDeleteForSingle appends a synthetic Del(oldVal) alongside the user's Set(newVal) so InsertTokenizerIndexes and ProcessReverse can emit Del-of-old entries. For scalar non-list non-lang predicates both postings share Uid == math.MaxUint64, and at read time pickPostings' equal-ts tie-break falls back to Go's unstable sort.Slice while setMutationAfterCommit overwrites committedUids[mpost.Uid] in append order. Either way Del can clobber the new Set and the data list reads as "no value." ProcessCount already strips the synthetic Del via skipSyntheticDel; mirror that filter in ProcessSingle's main data-list path. systest/bgindex now passes (TestStringIndex, TestReverseIndex, TestCountIndex, TestParallelIndexing). Co-Authored-By: Claude Opus 4.7 (1M context) --- posting/index.go | 42 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/posting/index.go b/posting/index.go index 74e4d642697..88751f7c607 100644 --- a/posting/index.go +++ b/posting/index.go @@ -738,6 +738,17 @@ func (mp *MutationPipeline) ProcessSingle(ctx context.Context, pipeline *Predica } baseKey := string(dataKey[:len(dataKey)-8]) // Avoid repeated conversion + // handleOldDeleteForSingle may have appended a synthetic Del-of-old-value + // alongside the user's Set so InsertTokenizerIndexes / ProcessReverse can + // emit index/reverse Del entries for the prior value. The synthetic Del + // must NOT be written to the scalar data list: scalar value postings all + // share Uid == math.MaxUint64, and at read time pickPostings sorts the two + // equal-ts postings non-deterministically (Go's sort.Slice is unstable), + // while setMutationAfterCommit's committedUids[mpost.Uid] = mpost + // overwrites in append order. Either way Del can clobber the new Set, so + // the data list reads as "no value." Strip the synthetic Del here before + // committing the data delta — same logic ProcessCount uses internally. + stripSyntheticDel := info.index || info.reverse for uid, pl := range postings { binary.BigEndian.PutUint64(dataKey[len(dataKey)-8:], uid) @@ -747,7 +758,28 @@ func (mp *MutationPipeline) ProcessSingle(ctx context.Context, pipeline *Predica mp.txn.addConflictKey(farm.Fingerprint64([]byte(key))) } - if _, err := mp.txn.AddDelta(key, pl, false, false); err != nil { + writePl := pl + if stripSyntheticDel && len(pl.Postings) > 1 { + hasSet := false + for _, post := range pl.Postings { + if post.Op == Set || post.Op == Ovr { + hasSet = true + break + } + } + if hasSet { + filtered := &pb.PostingList{} + for _, post := range pl.Postings { + if post.Op == Del { + continue + } + filtered.Postings = append(filtered.Postings, post) + } + writePl = filtered + } + } + + if _, err := mp.txn.AddDelta(key, writePl, false, false); err != nil { return err } } @@ -950,6 +982,14 @@ func ValidateAndConvert(edge *pb.DirectedEdge, su *pb.SchemaUpdate) error { } func (mp *MutationPipeline) Process(ctx context.Context, edges []*pb.DirectedEdge) error { + // handleDeleteAll calls schema.State().IsIndexed/IsReversed/HasCount, all of + // which only consult the pending mutSchema (the new schema with the index + // being built) when the context carries the isWrite flag. The legacy path + // in runMutation already calls schema.GetWriteContext at the top; the + // pipeline must do the same here, otherwise during a background index + // build a star-delete won't generate the index Del entries for the prior + // value, leaving stale uids in the index permanently. + ctx = schema.GetWriteContext(ctx) predicates := map[string]*PredicatePipeline{} var wg sync.WaitGroup numWg := 0 From f2a4ff1c61f0db044c55cfdd42b387204193f436 Mon Sep 17 00:00:00 2001 From: Matthew McNeely Date: Mon, 4 May 2026 19:53:13 -0400 Subject: [PATCH 16/45] fix(pipeline): serialize ProcessVectorIndex to prevent HNSW corruption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HNSW Insert performs multi-key read-modify-write across the entry-pointer key, the per-level edge lists, and back-edges into the neighbors of the new node. Per-list locks make each key's update atomic, but the cross-key sequences ("read entry, lock entry, read neighbor, modify neighbor, append back-edge to neighbor's list…") are not. Running 10 worker goroutines concurrently against the same txn cache lets updates stomp on each other, leaving nodes that have a data-list entry but are unreachable from the entry point. Surface symptom: similar_to(k=N) returns fewer than N hits even though all N vectors committed to the data list. TestVectorTwoTxnWithoutCommit reliably reproduced this on Linux CI (and not on the macOS dev box, likely a scheduling artifact). Legacy applyMutations arrives at single-threaded vector handling indirectly via x.DivideAndRule: for any num < 256 the helper rounds numGo down to 1, so a 5-edge vector mutation runs serially on main and the bug never surfaces there. Mirror that here by dropping numThreads to 1 — correctness over within-txn parallelism for vector predicates. Cross-txn parallelism is unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- posting/index.go | 46 ++++++++++++++++++++++------------------------ 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/posting/index.go b/posting/index.go index 88751f7c607..47bfff3ad0c 100644 --- a/posting/index.go +++ b/posting/index.go @@ -100,31 +100,29 @@ func (pp *PredicatePipeline) close() { } func (mp *MutationPipeline) ProcessVectorIndex(ctx context.Context, pipeline *PredicatePipeline, info predicateInfo) error { - var wg errgroup.Group - numThreads := 10 - - for i := 0; i < numThreads; i++ { - wg.Go(func() error { - for edge := range pipeline.edges { - uid := edge.Entity - - key := x.DataKey(pipeline.attr, uid) - pl, err := mp.txn.Get(key) - if err != nil { - return err - } - if err := pl.AddMutationWithIndex(ctx, edge, mp.txn); err != nil { - return err - } - } - return nil - }) - } - - if err := wg.Wait(); err != nil { - return err + // Vector inserts must run serially within a txn. HNSW Insert does + // multi-key read-modify-write across the entry-pointer key, level edge + // lists, and neighbor back-edges; per-list locks make individual key + // updates atomic but the cross-key sequences are not. Concurrent + // inserters on the same txn cache lose updates and leave nodes + // unreachable from the entry point — visible as similar_to(k=N) returning + // fewer than N hits even though all N vectors are in the data list. The + // legacy applyMutations path arrives at the same result indirectly: + // x.DivideAndRule rounds num<256 down to numGo=1, so any vector mutation + // short of a few hundred edges runs single-threaded on main. Match that + // here. (TestVectorTwoTxnWithoutCommit reliably reproduced the corruption + // on Linux CI when this used 10 workers.) + for edge := range pipeline.edges { + uid := edge.Entity + key := x.DataKey(pipeline.attr, uid) + pl, err := mp.txn.Get(key) + if err != nil { + return err + } + if err := pl.AddMutationWithIndex(ctx, edge, mp.txn); err != nil { + return err + } } - return nil } From ef28c163effff18f8f61842c338ad56cdfa10988 Mon Sep 17 00:00:00 2001 From: Matthew McNeely Date: Mon, 4 May 2026 21:01:02 -0400 Subject: [PATCH 17/45] chore: add description of pipeline feature flag --- dgraph/cmd/alpha/run.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/dgraph/cmd/alpha/run.go b/dgraph/cmd/alpha/run.go index 12cb97d39ce..6442965a3c2 100644 --- a/dgraph/cmd/alpha/run.go +++ b/dgraph/cmd/alpha/run.go @@ -283,6 +283,16 @@ they form a Raft group and provide synchronous replication. "with structured fields including trace ID for correlation with distributed traces. "+ "Disabled by default (0). Note: enabling this logs query text which may contain "+ "sensitive data; do not enable in deployments with strict data privacy requirements."). + Flag("mutations-pipeline-threshold", + "Per-mutation edge-count threshold for routing through the per-predicate "+ + "mutation pipeline, which parallelizes work across distinct predicates "+ + "in a single mutation. 0 disables the pipeline entirely (all mutations "+ + "take the legacy path). 1 always uses the pipeline. A value N>1 uses "+ + "the pipeline only when len(edges) >= N, leaving small interactive "+ + "mutations on the legacy path — the pipeline pays per-predicate "+ + "goroutine spin-up cost, so tiny mutations are slightly slower on it; "+ + "bulk multi-predicate mutations are faster (crossover ~100 edges in "+ + "benchmarks here)."). String()) RegisterFlags(flag) From 8a81f2ee27cff28635cd96648884e99e63cbacbb Mon Sep 17 00:00:00 2001 From: Shivaji Kharse Date: Thu, 21 May 2026 16:16:49 +0530 Subject: [PATCH 18/45] fix(pipeline): six correctness gaps in MutationPipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Process() channel-send deadlock when a predicate goroutine errors before the buffered channel drains. The bare `pred.edges <- edge` send had no select on egCtx.Done(), so once the 1000-slot buffer filled and the reader had exited the dispatcher blocked forever and eg.Wait() was never reached. Guard the send with a select + labeled break, still close every per-predicate channel and call eg.Wait() so the goroutine's real error wins over context.Canceled. 2. ProcessSingle compared `string(edge.Value) == string(oldVal.Value)` on uid postings whose Value is nil. nil==nil always matched, so a same-batch DEL wrongly overwrote an accumulated SET in the exists=true branch and synthesised a spurious DEL delta in the exists=false branch. Introduce a sameTarget closure that compares Uid/ValueId for uid postings and Value for scalars; use it at both call sites. handleOldDeleteForSingle picked up the same fix on its committed-vs-new comparison. 3. Same-batch SET after a star-delete restored the prior committed values: handleDeleteAll wrote [{deleteAll}] into currentEntries via setCurrentEntries; the pipeline's later AddDelta for the accumulated SET overwrote currentEntries via the same path and reset deleteAllMarker. populateDeleteAll at read time then found no deleteAll and the previously committed friends survived. Preserve any existing deleteAll posting when setCurrentEntries overwrites — mirroring legacy's append-via-insertPosting behaviour. 4 & 5. ProcessList lost index emissions for scalar @lang predicates in two ways: (a) it never read the committed posting list to emit a DEL of the old value when SET-replacing — only the SET for the new value was emitted, so the old "Alice"@en index entry persisted after SET "Alicia"@en; (b) for scalar @lang mpost.Uid is fingerprint(Lang), so SET-new + DEL-old@same-lang collided in the per-uid slot inside mutations[uid] — the second write erased the first and only one of the two index emissions survived. Legacy avoids both because addMutationHelper generates index mutations per edge before the same-uid overwrite takes effect. Fix: maintain rawIndexPostings alongside mutations — every batched posting is appended in arrival order, bypassing insertPosting's collapse. New helper handleOldDeleteForList iterates the cached posting list via l.Iterate, finds each committed SET matching a lang fingerprint touched in this batch, and appends a synthetic DEL of the old value to rawIndexPostings. Skipped when info.isList=true because list-typed predicates append rather than replace. Pass rawIndexPostings to InsertTokenizerIndexes; postings (the collapsed view) is still used for the data write and ProcessReverse, where collapse is correct. 6. When a SET and a DEL for the same uid tokenized into the same index bucket (e.g. case-insensitive tokenizer where "Apple" and "APPLE" share token "apple"), InsertTokenizerIndexes' last-write- wins insertPosting on indexGenInThread left whichever op landed last as the winner. For the scalar @index [DEL old, SET new] case handleOldDeleteForSingle appended a third synthetic DEL, so postings had three entries and the previous `isSingleEdge && len==2` reversal heuristic no longer fired — the resulting DEL-SET-DEL emission order left the uid removed from a bucket she should still belong to. Replace the order-dependent reversal with a stable Del-first sort of the per-uid posting list. SETs land in indexGenInThread after DELs for the same bucket so the SET wins; for different buckets the sort is a no-op on correctness. Adds posting/pipeline_test.go as the regression harness — 25 tests covering 13 scenarios, each with a TestLegacy* mirror so any future divergence between the pipeline and legacy runMutation paths is caught directly. The header comment documents the startTs/commitTs band layout used to isolate tests. posting/ unit tests fully green; all Legacy* mirrors continue to pass — these fixes are pipeline-specific. Co-Authored-By: Claude Opus 4.7 (1M context) --- posting/index.go | 215 ++++++- posting/list.go | 27 + posting/pipeline_test.go | 1270 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 1492 insertions(+), 20 deletions(-) create mode 100644 posting/pipeline_test.go diff --git a/posting/index.go b/posting/index.go index 47bfff3ad0c..91500f6d861 100644 --- a/posting/index.go +++ b/posting/index.go @@ -14,6 +14,7 @@ import ( "fmt" "math" "os" + "sort" "strings" "sync" "sync/atomic" @@ -80,6 +81,12 @@ func indexTokens(ctx context.Context, info *indexMutationInfo) ([]string, error) return tokens, nil } +// MutationPipeline applies a batch of DirectedEdges by grouping them per predicate +// and processing each predicate in its own goroutine. The key performance advantage +// over the legacy runMutation loop is that each predicate goroutine accumulates all +// edges for the predicate into an in-memory map first, then issues one Badger write +// per distinct uid — O(distinct_uids) reads instead of the legacy O(edges) reads. +// Index, reverse, and count deltas are computed in one pass over the accumulated map. type MutationPipeline struct { txn *Txn } @@ -88,6 +95,12 @@ func NewMutationPipeline(txn *Txn) *MutationPipeline { return &MutationPipeline{txn: txn} } +// PredicatePipeline is the per-predicate worker. The buffered edges channel decouples +// the dispatcher (Process) from the processor (ProcessPredicate): the dispatcher can +// fan out all predicates before any goroutine has finished, so predicate goroutines run +// in parallel without the dispatcher blocking. Capacity 1000 is enough for most Raft +// proposal batches; the dispatcher never blocks because it closes each channel after +// the full scan. type PredicatePipeline struct { attr string edges chan *pb.DirectedEdge @@ -154,13 +167,17 @@ func (mp *MutationPipeline) InsertTokenizerIndexes(ctx context.Context, pipeline for uid := range uids { postingList := (*postings)[uid] - newList := &pb.PostingList{} - if info.isSingleEdge && len(postingList.Postings) == 2 { - newList.Postings = append(newList.Postings, postingList.Postings[1]) - newList.Postings = append(newList.Postings, postingList.Postings[0]) - } else { - newList = postingList + // Sort Dels before Sets: when DEL and SET land in the same index + // bucket (e.g. case-insensitive tokenizer where the old and new + // values share a token), insertPosting's last-write-wins means SET + // must come last to keep the uid in the bucket. + newList := &pb.PostingList{ + Postings: make([]*pb.Posting, len(postingList.Postings)), } + copy(newList.Postings, postingList.Postings) + sort.SliceStable(newList.Postings, func(i, j int) bool { + return newList.Postings[i].Op == Del && newList.Postings[j].Op != Del + }) for _, posting := range newList.Postings { info := &indexMutationInfo{ tokenizers: tokenizers, @@ -298,10 +315,24 @@ type predicateInfo struct { isSingleEdge bool } +// ProcessList handles list predicates (multi-valued) and language predicates. +// It first drains all edges into a uid→MutableLayer map (pure in-memory, zero Badger +// reads in this phase), then runs reverse/index/count passes over the accumulated map. +// This is the key difference from the legacy runMutation per-edge loop: a mutation +// touching 500 entities for the same list predicate costs 500 Badger writes here vs +// 500 Badger reads + 500 writes in the legacy path. func (mp *MutationPipeline) ProcessList(ctx context.Context, pipeline *PredicatePipeline, info predicateInfo) error { su, schemaExists := schema.State().Get(ctx, pipeline.attr) mutations := make(map[uint64]*MutableLayer, 1000) + // rawIndexPostings preserves every batch posting in arrival order, bypassing + // insertPosting's collapse-on-Uid. For scalar @lang predicates mpost.Uid is + // fingerprint(Lang), so SET-new and DEL-old on the same lang collide in + // mutations and one of the two index emissions would be lost. + var rawIndexPostings map[uint64]*pb.PostingList + if info.index { + rawIndexPostings = make(map[uint64]*pb.PostingList, 1000) + } for edge := range pipeline.edges { if edge.Op != pb.DirectedEdge_DEL && !schemaExists { @@ -328,6 +359,15 @@ func (mp *MutationPipeline) ProcessList(ctx context.Context, pipeline *Predicate pl.insertPosting(mpost, false) mutations[uid] = pl + + if rawIndexPostings != nil { + rp, ok := rawIndexPostings[uid] + if !ok { + rp = &pb.PostingList{} + rawIndexPostings[uid] = rp + } + rp.Postings = append(rp.Postings, mpost) + } } postings := make(map[uint64]*pb.PostingList, 1000) @@ -342,7 +382,10 @@ func (mp *MutationPipeline) ProcessList(ctx context.Context, pipeline *Predicate } if info.index { - if err := mp.InsertTokenizerIndexes(ctx, pipeline, &postings, info); err != nil { + if err := mp.handleOldDeleteForList(pipeline, rawIndexPostings, info); err != nil { + return err + } + if err := mp.InsertTokenizerIndexes(ctx, pipeline, &rawIndexPostings, info); err != nil { return err } } @@ -469,12 +512,18 @@ func (mp *MutationPipeline) handleOldDeleteForSingle(pipeline *PredicatePipeline } oldVal := findSingleValueInPostingList(oldValList) - if oldVal == nil { continue } - if string(oldVal.Value) == string(currValue.Value) { + // For uid (REF) postings, Value is always nil — the target uid lives in + // posting.Uid. Compare Uid directly; fall back to Value comparison for scalars. + if currValue.PostingType == pb.Posting_REF && oldVal.PostingType == pb.Posting_REF { + if oldVal.Uid == currValue.Uid { + postings[uid] = &pb.PostingList{} + continue + } + } else if string(oldVal.Value) == string(currValue.Value) { postings[uid] = &pb.PostingList{} continue } @@ -492,6 +541,62 @@ func (mp *MutationPipeline) handleOldDeleteForSingle(pipeline *PredicatePipeline return nil } +// handleOldDeleteForList is the ProcessList counterpart of +// handleOldDeleteForSingle: for each scalar @lang (uid, fingerprint(lang)) +// being mutated, read the committed posting and append a synthetic DEL of the +// old value so InsertTokenizerIndexes can emit the corresponding index DEL. +// Skipped for list-typed predicates because SET on a list appends rather than +// replaces. +func (mp *MutationPipeline) handleOldDeleteForList(pipeline *PredicatePipeline, + rawPostings map[uint64]*pb.PostingList, info predicateInfo) error { + if rawPostings == nil || info.isList { + return nil + } + for uid, pl := range rawPostings { + // REF (uid) postings have no prior scalar value to remove. + seenFps := make(map[uint64]struct{}) + for _, p := range pl.Postings { + if p.PostingType != pb.Posting_REF { + seenFps[p.Uid] = struct{}{} + } + } + if len(seenFps) == 0 { + continue + } + + l, err := mp.txn.Get(x.DataKey(pipeline.attr, uid)) + if err != nil { + return err + } + if l == nil { + continue + } + + err = l.Iterate(mp.txn.StartTs, 0, func(p *pb.Posting) error { + if _, ok := seenFps[p.Uid]; !ok { + return nil + } + if p.Op == Del { + return nil + } + pl.Postings = append(pl.Postings, &pb.Posting{ + Uid: p.Uid, + Value: append([]byte(nil), p.Value...), + ValType: p.ValType, + LangTag: append([]byte(nil), p.LangTag...), + Op: Del, + StartTs: mp.txn.StartTs, + PostingType: p.PostingType, + }) + return nil + }) + if err != nil { + return err + } + } + return nil +} + func (txn *Txn) addConflictKeyWithUid(key []byte, pl *pb.PostingList, hasUpsert bool, hasNoConflict bool) { if hasNoConflict { return @@ -624,6 +729,19 @@ func (mp *MutationPipeline) ProcessCount(ctx context.Context, pipeline *Predicat return nil } +// ProcessSingle handles scalar (non-list) predicates without a language tag. +// Phase 1: drain the edges channel into uid→PostingList map with no Badger reads — +// +// multiple SET/DEL edges for the same uid are folded in-memory (last-SET wins). +// +// Phase 2: if indexed/reversed/counted, call handleOldDeleteForSingle to fetch the +// +// current disk value for each uid once (O(distinct_uids) reads) so index/reverse +// Del entries for the old value can be generated correctly. +// +// Phase 3: batch-write all accumulated deltas via txn.AddDelta in one loop. +// Contrast with legacy runMutation: every edge caused a GetScalarList (one Badger +// read) plus AddMutationWithIndex (another read to fetch old value for index Del). func (mp *MutationPipeline) ProcessSingle(ctx context.Context, pipeline *PredicatePipeline, info predicateInfo) error { su, schemaExists := schema.State().Get(ctx, pipeline.attr) @@ -632,6 +750,16 @@ func (mp *MutationPipeline) ProcessSingle(ctx context.Context, pipeline *Predica dataKey := x.DataKey(pipeline.attr, 0) insertDeleteAllEdge := !(info.index || info.reverse || info.count) // nolint + // For uid postings the target lives in Uid/ValueId; Value is nil so a + // bytewise comparison would always succeed and DEL would wrongly overwrite + // an accumulated SET in the same batch. + sameTarget := func(oldVal *pb.Posting, edge *pb.DirectedEdge) bool { + if info.isUid { + return oldVal.Uid == edge.ValueId + } + return string(oldVal.Value) == string(edge.Value) + } + for edge := range pipeline.edges { if edge.Op != pb.DirectedEdge_DEL && !schemaExists { return errors.Errorf("runMutation: Unable to find schema for %s", edge.Attr) @@ -677,7 +805,7 @@ func (mp *MutationPipeline) ProcessSingle(ctx context.Context, pipeline *Predica // multiple Del edges in one batch (e.g. GraphQL // deleteTask cleanup deleting many predicates per entity). oldVal = findSingleValueInPostingList(pl) - if oldVal != nil && string(edge.Value) == string(oldVal.Value) { + if oldVal != nil && sameTarget(oldVal, edge) { setPosting() } } else { @@ -703,7 +831,7 @@ func (mp *MutationPipeline) ProcessSingle(ctx context.Context, pipeline *Predica oldVal = findSingleValueInPostingList(l) } if oldVal != nil { - if string(oldVal.Value) == string(edge.Value) { + if sameTarget(oldVal, edge) { setPosting() } } @@ -744,9 +872,11 @@ func (mp *MutationPipeline) ProcessSingle(ctx context.Context, pipeline *Predica // equal-ts postings non-deterministically (Go's sort.Slice is unstable), // while setMutationAfterCommit's committedUids[mpost.Uid] = mpost // overwrites in append order. Either way Del can clobber the new Set, so - // the data list reads as "no value." Strip the synthetic Del here before - // committing the data delta — same logic ProcessCount uses internally. - stripSyntheticDel := info.index || info.reverse + // the data list reads as "no value." Strip only for scalar (@index) predicates. + // For uid (@reverse) predicates the synthetic Del has a distinct Uid (the old + // target uid) so there is no collision — it must stay so the old forward edge + // is removed, mirroring the legacy isSingleUidUpdate explicit DEL. + stripSyntheticDel := info.index for uid, pl := range postings { binary.BigEndian.PutUint64(dataKey[len(dataKey)-8:], uid) @@ -830,6 +960,18 @@ func runMutation(ctx context.Context, edge *pb.DirectedEdge, txn *Txn) error { return plist.AddMutationWithIndex(ctx, edge, txn) } +// ProcessPredicate is the per-predicate goroutine entry point. It reads the schema once +// for the predicate and routes to the appropriate processor: +// +// vector index → ProcessVectorIndex (serial; HNSW cross-key writes are not concurrent-safe) +// list/lang → ProcessList (batched uid-map accumulation) +// scalar → ProcessSingle (batched uid-map accumulation) +// unknown schema → runMutation fallback (legacy, edge-by-edge) +// +// The fallback at the bottom of the function handles edges that are neither list nor +// scalar — these are drained from the channel after ProcessList/ProcessSingle return, +// though in practice ProcessList/ProcessSingle consume all edges so the channel is +// already empty. func (mp *MutationPipeline) ProcessPredicate(ctx context.Context, pipeline *PredicatePipeline) error { defer pipeline.close() ctx = schema.GetWriteContext(ctx) @@ -979,6 +1121,27 @@ func ValidateAndConvert(edge *pb.DirectedEdge, su *pb.SchemaUpdate) error { return nil } +// Process is the entry point for the mutation pipeline. It fans out one goroutine per +// predicate so mutations for different predicates are parallelized, while all edges +// for the same predicate are serialized through a single channel to a single goroutine. +// This serialization per-predicate is intentional: ProcessSingle and ProcessList both +// build a uid-keyed map that assumes they own all writes for their predicate, and the +// index/reverse/count secondary-key updates must see the complete accumulated state to +// produce correct deltas. Running two goroutines for the same predicate would cause +// lost updates and incorrect index deltas. +// +// Edge shape entering this function: +// +// edge.Entity — subject UID (e.g. 0x1234) +// edge.Attr — predicate (e.g. "name", "age", "friend") +// edge.Value — object bytes for scalar predicates (e.g. []byte("Alice")) +// edge.ValueId — object UID for uid predicates (e.g. 0x5678) +// edge.Op — SET or DEL +// edge.Lang — language tag (e.g. "en") or empty +// edge.Facets — optional facet list +// +// Star-delete edges (entity=0, value="*") are handled inline before the fan-out +// because they must run serially with respect to the data list they are clearing. func (mp *MutationPipeline) Process(ctx context.Context, edges []*pb.DirectedEdge) error { // handleDeleteAll calls schema.State().IsIndexed/IsReversed/HasCount, all of // which only consult the pending mutSchema (the new schema with the index @@ -992,14 +1155,18 @@ func (mp *MutationPipeline) Process(ctx context.Context, edges []*pb.DirectedEdg var wg sync.WaitGroup numWg := 0 eg, egCtx := errgroup.WithContext(ctx) + var sendErr error +sendLoop: for _, edge := range edges { if edge.Op == pb.DirectedEdge_DEL && string(edge.Value) == x.Star { l, err := mp.txn.Get(x.DataKey(edge.Attr, edge.Entity)) if err != nil { - return err + sendErr = err + break sendLoop } if err = l.handleDeleteAll(ctx, edge, mp.txn); err != nil { - return err + sendErr = err + break sendLoop } continue } @@ -1020,19 +1187,27 @@ func (mp *MutationPipeline) Process(ctx context.Context, edges []*pb.DirectedEdg } }(pred)) } - pred.edges <- edge + // Guard the send: if a predicate goroutine returns early on error, the + // channel reader is gone and a bare send blocks forever once the buffer fills. + select { + case pred.edges <- edge: + case <-egCtx.Done(): + sendErr = egCtx.Err() + break sendLoop + } } for _, pred := range predicates { close(pred.edges) } if numWg == 0 { - return nil + return sendErr } - // Wait for all predicate processors; returns first error (and cancels others via context). + // Wait for all predicate processors; returns the first goroutine error, + // which is the real root cause when sendErr is just context cancellation. if err := eg.Wait(); err != nil { return err } - return nil + return sendErr } func (txn *Txn) addIndexMutations(ctx context.Context, info *indexMutationInfo) ([]*pb.DirectedEdge, error) { diff --git a/posting/list.go b/posting/list.go index 55920c27495..c3f4fd62b0d 100644 --- a/posting/list.go +++ b/posting/list.go @@ -160,6 +160,33 @@ func (mm *MutableLayer) setCurrentEntries(ts uint64, pl *pb.PostingList) { x.AssertTruef(mm.readTs == ts, "List object reused for a different transaction %d %d", mm.readTs, ts) } + // If currentEntries already contained a deleteAll (e.g. set inline by + // handleDeleteAll before the pipeline writes its accumulated postings), + // preserve it so a subsequent same-batch SET doesn't lose the deleteAll + // signal. The legacy path naturally avoids this because it appends via + // insertPosting instead of overwriting. + if mm.currentEntries != nil { + var existingDeleteAll *pb.Posting + for _, p := range mm.currentEntries.Postings { + if hasDeleteAll(p) { + existingDeleteAll = p + break + } + } + if existingDeleteAll != nil { + alreadyHas := false + for _, p := range pl.Postings { + if hasDeleteAll(p) { + alreadyHas = true + break + } + } + if !alreadyHas { + pl.Postings = append([]*pb.Posting{existingDeleteAll}, pl.Postings...) + } + } + } + mm.readTs = ts mm.currentEntries = pl clear(mm.currentUids) diff --git a/posting/pipeline_test.go b/posting/pipeline_test.go new file mode 100644 index 00000000000..ebb4be5ec76 --- /dev/null +++ b/posting/pipeline_test.go @@ -0,0 +1,1270 @@ +/* + * SPDX-FileCopyrightText: © 2017-2025 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package posting + +// Tests for MutationPipeline correctness — focused on correctness gaps vs the +// legacy runMutation path discovered during code review. + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/dgraph-io/dgraph/v25/protos/pb" + "github.com/dgraph-io/dgraph/v25/schema" + "github.com/dgraph-io/dgraph/v25/types" + "github.com/dgraph-io/dgraph/v25/x" +) + +// commitPipelineTxn flushes a pipeline txn to Badger and marks it committed. +// Mirrors what draft.go does after applyMutations returns. +func commitPipelineTxn(t *testing.T, txn *Txn, commitTs uint64) { + t.Helper() + txn.Update() + txn.UpdateCachedKeys(commitTs) + writer := NewTxnWriter(pstore) + require.NoError(t, txn.CommitToDisk(writer, commitTs)) + require.NoError(t, writer.Flush()) +} + +// applyEdges runs edges through a fresh pipeline txn and commits them. +func applyEdges(t *testing.T, startTs, commitTs uint64, edges ...*pb.DirectedEdge) { + t.Helper() + txn := Oracle().RegisterStartTs(startTs) + mp := NewMutationPipeline(txn) + require.NoError(t, mp.Process(context.Background(), edges)) + commitPipelineTxn(t, txn, commitTs) +} + +// applyEdgesLegacy runs edges through the legacy runMutation path and commits them. +// Used to verify that legacy and pipeline produce the same results. +func applyEdgesLegacy(t *testing.T, startTs, commitTs uint64, edges ...*pb.DirectedEdge) { + t.Helper() + txn := Oracle().RegisterStartTs(startTs) + for _, edge := range edges { + require.NoError(t, runMutation(context.Background(), edge, txn)) + } + commitPipelineTxn(t, txn, commitTs) +} + +// forwardUids returns the target uids stored in a forward data list at readTs. +func forwardUids(t *testing.T, attr string, entity, readTs uint64) []uint64 { + t.Helper() + l, err := GetNoStore(x.DataKey(attr, entity), readTs) + require.NoError(t, err) + return uids(l, readTs) +} + +// reverseUids returns the source uids stored in a reverse index list at readTs. +func reverseUids(t *testing.T, attr string, target, readTs uint64) []uint64 { + t.Helper() + l, err := GetNoStore(x.ReverseKey(attr, target), readTs) + require.NoError(t, err) + return uids(l, readTs) +} + +// indexUidsForVal reads the exact-index entries for a plain (non-lang) string +// @index predicate. It reuses indexUidsForLang with an empty language tag. +func indexUidsForVal(t *testing.T, attr, val string, readTs uint64) []uint64 { + t.Helper() + return indexUidsForLang(t, attr, "", val, readTs) +} + +// Each test uses a unique timestamp band so the Oracle's maxAssignedTs from one +// test never interferes with GetScalarList reads in a subsequent test. +// Band layout (startTs / commitTs / readTs): +// Pipeline tests: 100-199, 200-299, 300-399, 400-499, 900-999 +// Legacy tests: 500-599, 600-699, 700-799, 800-899, 1000-1099 +// Lang index: Pipeline 900-999, Legacy 1000-1099 +// Star-delete+SET: Pipeline 1100-1199, Legacy 1200-1299 +// Single SET+DEL: Pipeline 1300-1399, Legacy 1400-1499 +// DEL wrong target: Pipeline 1500-1599, Legacy 1600-1699 +// Lang SET-then-DEL: Pipeline 1700-1799, Legacy 1800-1899 +// Multi-SET scalar: Pipeline 1900-1999, Legacy 2000-2099 +// Case-insensitive dup DEL: Pipeline 2100-2199, Legacy 2200-2299 +// Channel deadlock: 2300-2399 +// Non-matching DEL no-op: Pipeline 2400-2499, Legacy 2500-2599 + +// TestMutationPipelineSingleUidReverseUpdate is the regression test for the +// handleOldDeleteForSingle value-comparison bug on non-list uid @reverse predicates. +// +// Root cause: handleOldDeleteForSingle compares posting.Value (always nil for uid +// postings — the target uid lives in posting.Uid) using string byte comparison. +// `string(nil) == string(nil)` is always true, so the function concludes "new value +// equals old value → no-op" for every uid SET, clears the accumulated postings to an +// empty PostingList, and neither the forward write nor the reverse-edge update ever +// happens. +// +// Expected (correct) behaviour after the fix: +// - alice's forward list is updated to bob. +// - The new reverse edge bob ← alice is created. +// - The stale reverse edge charlie ← alice is removed. +// +// Observed (buggy) behaviour without the fix: +// - alice's forward list still points to charlie (SET is a no-op). +// - bob's reverse list remains empty. +// - charlie's reverse list still contains alice. +func TestMutationPipelineSingleUidReverseUpdate(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`best_friend: uid @reverse .`), 1)) + + attr := x.AttrInRootNamespace("best_friend") + const ( + alice = uint64(10) + bob = uint64(20) + charlie = uint64(30) + ) + + // Step 1 — initial state: alice → best_friend → charlie. + applyEdges(t, 110, 111, &pb.DirectedEdge{ + Entity: alice, Attr: attr, ValueId: charlie, Op: pb.DirectedEdge_SET, + }) + + // Sanity-check initial state. + require.Equal(t, []uint64{charlie}, forwardUids(t, attr, alice, 112), + "initial: alice should point to charlie") + require.Equal(t, []uint64{alice}, reverseUids(t, attr, charlie, 112), + "initial: charlie's reverse should contain alice") + require.Empty(t, reverseUids(t, attr, bob, 112), + "initial: bob's reverse should be empty") + + // Step 2 — update: SET alice → best_friend → bob (replaces charlie). + applyEdges(t, 120, 121, &pb.DirectedEdge{ + Entity: alice, Attr: attr, ValueId: bob, Op: pb.DirectedEdge_SET, + }) + + const readTs = uint64(122) + + require.Equal(t, []uint64{bob}, forwardUids(t, attr, alice, readTs), + "alice's best_friend must be updated to bob") + require.Contains(t, reverseUids(t, attr, bob, readTs), alice, + "bob's reverse index must now contain alice") + require.NotContains(t, reverseUids(t, attr, charlie, readTs), alice, + "charlie's reverse index must no longer contain alice") +} + +// TestMutationPipelineSingleUidReverseUpdateChain tests three consecutive updates to +// the same non-list uid @reverse predicate to verify that each transition is applied +// correctly and no stale reverse edges accumulate. +// +// alice → charlie → alice → bob → alice → dave +func TestMutationPipelineSingleUidReverseUpdateChain(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`best_friend: uid @reverse .`), 1)) + + attr := x.AttrInRootNamespace("best_friend") + const ( + alice = uint64(10) + bob = uint64(20) + charlie = uint64(30) + dave = uint64(40) + ) + + // alice → charlie + applyEdges(t, 210, 211, &pb.DirectedEdge{ + Entity: alice, Attr: attr, ValueId: charlie, Op: pb.DirectedEdge_SET, + }) + + // alice → bob (first replacement) + applyEdges(t, 220, 221, &pb.DirectedEdge{ + Entity: alice, Attr: attr, ValueId: bob, Op: pb.DirectedEdge_SET, + }) + + const afterBob = uint64(222) + require.Equal(t, []uint64{bob}, forwardUids(t, attr, alice, afterBob)) + require.Contains(t, reverseUids(t, attr, bob, afterBob), alice) + require.NotContains(t, reverseUids(t, attr, charlie, afterBob), alice) + + // alice → dave (second replacement) + applyEdges(t, 230, 231, &pb.DirectedEdge{ + Entity: alice, Attr: attr, ValueId: dave, Op: pb.DirectedEdge_SET, + }) + + const afterDave = uint64(232) + require.Equal(t, []uint64{dave}, forwardUids(t, attr, alice, afterDave), + "alice's best_friend must be dave after second replacement") + require.Contains(t, reverseUids(t, attr, dave, afterDave), alice, + "dave's reverse must contain alice") + require.NotContains(t, reverseUids(t, attr, bob, afterDave), alice, + "bob's reverse must no longer contain alice after second replacement") + require.NotContains(t, reverseUids(t, attr, charlie, afterDave), alice, + "charlie's reverse must still not contain alice") +} + +// TestMutationPipelineSingleUidReverseSetSameValue tests that re-setting the same +// uid value is correctly treated as a no-op: the forward list is unchanged and no +// spurious reverse-edge churn occurs. +func TestMutationPipelineSingleUidReverseSetSameValue(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`best_friend: uid @reverse .`), 1)) + + attr := x.AttrInRootNamespace("best_friend") + const ( + alice = uint64(10) + charlie = uint64(30) + ) + + // Initial: alice → charlie. + applyEdges(t, 310, 311, &pb.DirectedEdge{ + Entity: alice, Attr: attr, ValueId: charlie, Op: pb.DirectedEdge_SET, + }) + + // Re-set the same value. + applyEdges(t, 320, 321, &pb.DirectedEdge{ + Entity: alice, Attr: attr, ValueId: charlie, Op: pb.DirectedEdge_SET, + }) + + const readTs = uint64(322) + + require.Equal(t, []uint64{charlie}, forwardUids(t, attr, alice, readTs), + "alice's best_friend must still be charlie after no-op re-set") + require.Contains(t, reverseUids(t, attr, charlie, readTs), alice, + "charlie's reverse must still contain alice after no-op re-set") +} + +// TestMutationPipelineSingleUidReverseDelete tests that DEL on a non-list uid +// @reverse predicate removes both the forward entry and the reverse index entry. +func TestMutationPipelineSingleUidReverseDelete(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`best_friend: uid @reverse .`), 1)) + + attr := x.AttrInRootNamespace("best_friend") + const ( + alice = uint64(10) + charlie = uint64(30) + ) + + // Initial: alice → charlie. + applyEdges(t, 410, 411, &pb.DirectedEdge{ + Entity: alice, Attr: attr, ValueId: charlie, Op: pb.DirectedEdge_SET, + }) + + // Delete alice → charlie. For uid predicates, ValueId carries the target uid. + applyEdges(t, 420, 421, &pb.DirectedEdge{ + Entity: alice, Attr: attr, ValueId: charlie, Op: pb.DirectedEdge_DEL, + }) + + const readTs = uint64(422) + + require.Empty(t, forwardUids(t, attr, alice, readTs), + "alice's best_friend must be empty after DEL") + require.NotContains(t, reverseUids(t, attr, charlie, readTs), alice, + "charlie's reverse must not contain alice after DEL") +} + +// --------------------------------------------------------------------------- +// Legacy-path mirrors — same scenarios as above but via runMutation. +// These establish a baseline: they must all pass both before and after the +// pipeline fix. Any divergence between Legacy* and non-Legacy* results +// points to a pipeline-specific regression. +// --------------------------------------------------------------------------- + +func TestLegacySingleUidReverseUpdate(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`best_friend: uid @reverse .`), 1)) + + attr := x.AttrInRootNamespace("best_friend") + const ( + alice = uint64(10) + bob = uint64(20) + charlie = uint64(30) + ) + + applyEdgesLegacy(t, 510, 511, &pb.DirectedEdge{ + Entity: alice, Attr: attr, ValueId: charlie, Op: pb.DirectedEdge_SET, + }) + applyEdgesLegacy(t, 520, 521, &pb.DirectedEdge{ + Entity: alice, Attr: attr, ValueId: bob, Op: pb.DirectedEdge_SET, + }) + + const readTs = uint64(522) + + require.Equal(t, []uint64{bob}, forwardUids(t, attr, alice, readTs), + "legacy: alice's best_friend must be updated to bob") + require.Contains(t, reverseUids(t, attr, bob, readTs), alice, + "legacy: bob's reverse index must now contain alice") + require.NotContains(t, reverseUids(t, attr, charlie, readTs), alice, + "legacy: charlie's reverse index must no longer contain alice") +} + +func TestLegacySingleUidReverseUpdateChain(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`best_friend: uid @reverse .`), 1)) + + attr := x.AttrInRootNamespace("best_friend") + const ( + alice = uint64(10) + bob = uint64(20) + charlie = uint64(30) + dave = uint64(40) + ) + + applyEdgesLegacy(t, 610, 611, &pb.DirectedEdge{ + Entity: alice, Attr: attr, ValueId: charlie, Op: pb.DirectedEdge_SET, + }) + applyEdgesLegacy(t, 620, 621, &pb.DirectedEdge{ + Entity: alice, Attr: attr, ValueId: bob, Op: pb.DirectedEdge_SET, + }) + + const afterBob = uint64(622) + require.Equal(t, []uint64{bob}, forwardUids(t, attr, alice, afterBob)) + require.Contains(t, reverseUids(t, attr, bob, afterBob), alice) + require.NotContains(t, reverseUids(t, attr, charlie, afterBob), alice) + + applyEdgesLegacy(t, 630, 631, &pb.DirectedEdge{ + Entity: alice, Attr: attr, ValueId: dave, Op: pb.DirectedEdge_SET, + }) + + const afterDave = uint64(632) + require.Equal(t, []uint64{dave}, forwardUids(t, attr, alice, afterDave), + "legacy: alice's best_friend must be dave after second replacement") + require.Contains(t, reverseUids(t, attr, dave, afterDave), alice, + "legacy: dave's reverse must contain alice") + require.NotContains(t, reverseUids(t, attr, bob, afterDave), alice, + "legacy: bob's reverse must no longer contain alice") + require.NotContains(t, reverseUids(t, attr, charlie, afterDave), alice, + "legacy: charlie's reverse must still not contain alice") +} + +func TestLegacySingleUidReverseSetSameValue(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`best_friend: uid @reverse .`), 1)) + + attr := x.AttrInRootNamespace("best_friend") + const ( + alice = uint64(10) + charlie = uint64(30) + ) + + applyEdgesLegacy(t, 710, 711, &pb.DirectedEdge{ + Entity: alice, Attr: attr, ValueId: charlie, Op: pb.DirectedEdge_SET, + }) + applyEdgesLegacy(t, 720, 721, &pb.DirectedEdge{ + Entity: alice, Attr: attr, ValueId: charlie, Op: pb.DirectedEdge_SET, + }) + + const readTs = uint64(722) + + require.Equal(t, []uint64{charlie}, forwardUids(t, attr, alice, readTs), + "legacy: alice's best_friend must still be charlie after no-op re-set") + require.Contains(t, reverseUids(t, attr, charlie, readTs), alice, + "legacy: charlie's reverse must still contain alice after no-op re-set") +} + +func TestLegacySingleUidReverseDelete(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`best_friend: uid @reverse .`), 1)) + + attr := x.AttrInRootNamespace("best_friend") + const ( + alice = uint64(10) + charlie = uint64(30) + ) + + applyEdgesLegacy(t, 810, 811, &pb.DirectedEdge{ + Entity: alice, Attr: attr, ValueId: charlie, Op: pb.DirectedEdge_SET, + }) + applyEdgesLegacy(t, 820, 821, &pb.DirectedEdge{ + Entity: alice, Attr: attr, ValueId: charlie, Op: pb.DirectedEdge_DEL, + }) + + const readTs = uint64(822) + + require.Empty(t, forwardUids(t, attr, alice, readTs), + "legacy: alice's best_friend must be empty after DEL") + require.NotContains(t, reverseUids(t, attr, charlie, readTs), alice, + "legacy: charlie's reverse must not contain alice after DEL") +} + +// --------------------------------------------------------------------------- +// Language predicate @index stale entry tests +// --------------------------------------------------------------------------- + +// indexUidsForLang returns entity UIDs found in the exact index for the given +// language-tagged value. attr must already be namespace-qualified. +func indexUidsForLang(t *testing.T, attr, lang, val string, readTs uint64) []uint64 { + t.Helper() + tokens, err := indexTokens(context.Background(), &indexMutationInfo{ + tokenizers: schema.State().Tokenizer(context.Background(), attr), + edge: &pb.DirectedEdge{Attr: attr, Lang: lang}, + val: types.Val{Tid: types.StringID, Value: []byte(val)}, + }) + require.NoError(t, err) + var result []uint64 + for _, token := range tokens { + l, err := GetNoStore(x.IndexKey(attr, token), readTs) + require.NoError(t, err) + result = append(result, uids(l, readTs)...) + } + return result +} + +// TestMutationPipelineLangIndexStaleEntry is the regression test for the +// ProcessList language-predicate stale index-entry bug. +// +// Root cause: ProcessList accumulates {SET "Alicia"} and passes it directly to +// InsertTokenizerIndexes, which emits only the SET index entry for the new +// value. It never reads the committed data key, so it never generates a DEL +// index entry for the old "Alice" — that entry persists in the index. +// +// The legacy path (AddMutationWithIndex → addMutationHelper) correctly calls +// findPosting to retrieve the old value then emits both: +// +// addIndexMutations(DEL, "Alice") and addIndexMutations(SET, "Alicia") +// +// Expected (correct) behaviour: +// - index("Alicia") contains alice. +// - index("Alice") does NOT contain alice. +// +// Observed (buggy) behaviour without the fix: +// - index("Alice") still contains alice (stale entry). +func TestMutationPipelineLangIndexStaleEntry(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`name: string @lang @index(exact) .`), 1)) + + attr := x.AttrInRootNamespace("name") + const alice = uint64(10) + + // Step 1 — initial state: alice's name@en = "Alice". + applyEdges(t, 910, 911, &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("Alice"), ValueType: pb.Posting_STRING, + Lang: "en", Op: pb.DirectedEdge_SET, + }) + + require.Contains(t, indexUidsForLang(t, attr, "en", "Alice", 912), alice, + "initial: index for 'Alice' must contain alice") + require.NotContains(t, indexUidsForLang(t, attr, "en", "Alicia", 912), alice, + "initial: index for 'Alicia' must not contain alice") + + // Step 2 — update: SET alice's name@en = "Alicia" (replaces "Alice"). + applyEdges(t, 920, 921, &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("Alicia"), ValueType: pb.Posting_STRING, + Lang: "en", Op: pb.DirectedEdge_SET, + }) + + const readTs = uint64(922) + + require.Contains(t, indexUidsForLang(t, attr, "en", "Alicia", readTs), alice, + "index for 'Alicia' must contain alice after update") + require.NotContains(t, indexUidsForLang(t, attr, "en", "Alice", readTs), alice, + "index for 'Alice' must not contain alice after update — stale entry bug") +} + +// TestLegacyLangIndexStaleEntry mirrors TestMutationPipelineLangIndexStaleEntry +// via the legacy runMutation path to establish that the legacy path is correct +// and the stale entry issue is pipeline-specific. +func TestLegacyLangIndexStaleEntry(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`name: string @lang @index(exact) .`), 1)) + + attr := x.AttrInRootNamespace("name") + const alice = uint64(10) + + applyEdgesLegacy(t, 1010, 1011, &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("Alice"), ValueType: pb.Posting_STRING, + Lang: "en", Op: pb.DirectedEdge_SET, + }) + applyEdgesLegacy(t, 1020, 1021, &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("Alicia"), ValueType: pb.Posting_STRING, + Lang: "en", Op: pb.DirectedEdge_SET, + }) + + const readTs = uint64(1022) + + require.Contains(t, indexUidsForLang(t, attr, "en", "Alicia", readTs), alice, + "legacy: index for 'Alicia' must contain alice after update") + require.NotContains(t, indexUidsForLang(t, attr, "en", "Alice", readTs), alice, + "legacy: index for 'Alice' must not contain alice after update") +} + +// --------------------------------------------------------------------------- +// Star-delete + same-batch SET tests (Bug 4) +// --------------------------------------------------------------------------- + +// TestMutationPipelineStarDeleteThenSet is the regression test for the +// deleteAll-marker overwrite bug in MutationPipeline. +// +// Root cause: Process() handles a star-delete by calling l.handleDeleteAll on +// the List object in cache.plists, which sets deleteAllMarker and +// currentEntries=[{DeleteAll}]. Subsequent ProcessList processing for the same +// key calls AddDelta, which calls list.setCurrentEntries — unconditionally +// resetting deleteAllMarker to math.MaxUint64 and discarding the deleteAll. +// The committed friends (bob, charlie) are therefore NOT cleared. +// +// Expected (correct) behaviour: +// - alice's friends = [dave] only. +// - bob's and charlie's reverse edges are gone. +// - dave's reverse edge is present. +// +// Observed (buggy) behaviour without the fix: +// - alice's friends = [bob, charlie, dave] (old friends not deleted). +func TestMutationPipelineStarDeleteThenSet(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`friend: [uid] @reverse .`), 1)) + + attr := x.AttrInRootNamespace("friend") + const ( + alice = uint64(10) + bob = uint64(20) + charlie = uint64(30) + dave = uint64(40) + ) + + // Step 1 — initial state: alice has friends bob and charlie. + applyEdges(t, 1110, 1111, + &pb.DirectedEdge{Entity: alice, Attr: attr, ValueId: bob, Op: pb.DirectedEdge_SET}, + &pb.DirectedEdge{Entity: alice, Attr: attr, ValueId: charlie, Op: pb.DirectedEdge_SET}, + ) + + require.Len(t, forwardUids(t, attr, alice, 1112), 2, + "initial: alice should have two friends") + + // Step 2 — batch: star-delete alice's friends, then add dave. + applyEdges(t, 1120, 1121, + &pb.DirectedEdge{Entity: alice, Attr: attr, Value: []byte(x.Star), Op: pb.DirectedEdge_DEL}, + &pb.DirectedEdge{Entity: alice, Attr: attr, ValueId: dave, Op: pb.DirectedEdge_SET}, + ) + + const readTs = uint64(1122) + require.Equal(t, []uint64{dave}, forwardUids(t, attr, alice, readTs), + "alice's friends must be only dave after star-delete + set") + require.Contains(t, reverseUids(t, attr, dave, readTs), alice, + "dave's reverse must contain alice") + require.NotContains(t, reverseUids(t, attr, bob, readTs), alice, + "bob's reverse must not contain alice after star-delete") + require.NotContains(t, reverseUids(t, attr, charlie, readTs), alice, + "charlie's reverse must not contain alice after star-delete") +} + +// TestLegacyStarDeleteThenSet mirrors TestMutationPipelineStarDeleteThenSet via +// the legacy runMutation path to establish that the legacy path is correct. +func TestLegacyStarDeleteThenSet(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`friend: [uid] @reverse .`), 1)) + + attr := x.AttrInRootNamespace("friend") + const ( + alice = uint64(10) + bob = uint64(20) + charlie = uint64(30) + dave = uint64(40) + ) + + applyEdgesLegacy(t, 1210, 1211, + &pb.DirectedEdge{Entity: alice, Attr: attr, ValueId: bob, Op: pb.DirectedEdge_SET}, + &pb.DirectedEdge{Entity: alice, Attr: attr, ValueId: charlie, Op: pb.DirectedEdge_SET}, + ) + + applyEdgesLegacy(t, 1220, 1221, + &pb.DirectedEdge{Entity: alice, Attr: attr, Value: []byte(x.Star), Op: pb.DirectedEdge_DEL}, + &pb.DirectedEdge{Entity: alice, Attr: attr, ValueId: dave, Op: pb.DirectedEdge_SET}, + ) + + const readTs = uint64(1222) + + require.Equal(t, []uint64{dave}, forwardUids(t, attr, alice, readTs), + "legacy: alice's friends must be only dave after star-delete + set") + require.Contains(t, reverseUids(t, attr, dave, readTs), alice, + "legacy: dave's reverse must contain alice") + require.NotContains(t, reverseUids(t, attr, bob, readTs), alice, + "legacy: bob's reverse must not contain alice after star-delete") + require.NotContains(t, reverseUids(t, attr, charlie, readTs), alice, + "legacy: charlie's reverse must not contain alice after star-delete") +} + +// --------------------------------------------------------------------------- +// ProcessSingle: SET + DEL different targets in same batch (Bug 5) +// --------------------------------------------------------------------------- + +// TestMutationPipelineSingleUidSetAndDelDifferentTargets is the regression test +// for the nil==nil value-comparison bug in ProcessSingle's exists=true DEL branch. +// +// Root cause: when a SET alice→bob is already accumulated for alice, a subsequent +// DEL alice→charlie in the same batch enters the `exists=true, edge.Op==DEL` branch. +// The check `string(edge.Value) == string(oldVal.Value)` compares nil==nil (both +// Value fields are nil for uid postings — the uid lives in posting.Uid), which is +// always true. setPosting() therefore REPLACES the accumulated {SET bob} with +// {DEL charlie}. The SET is lost. +// +// Expected (correct) behaviour after the fix: +// - alice's best_friend is bob. +// - bob's reverse index contains alice. +// - charlie's reverse index does NOT contain alice. +// +// Observed (buggy) behaviour without the fix: +// - alice has no best_friend (the SET bob was replaced by DEL charlie). +// - bob's reverse index is empty. +func TestMutationPipelineSingleUidSetAndDelDifferentTargets(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`best_friend: uid @reverse .`), 1)) + + attr := x.AttrInRootNamespace("best_friend") + const ( + alice = uint64(10) + bob = uint64(20) + charlie = uint64(30) + ) + + // Step 1 — initial state: alice → best_friend → charlie. + applyEdges(t, 1310, 1311, &pb.DirectedEdge{ + Entity: alice, Attr: attr, ValueId: charlie, Op: pb.DirectedEdge_SET, + }) + + // Step 2 — batch: SET alice→bob first, then DEL alice→charlie. + // The SET and DEL target different uids (bob ≠ charlie), triggering Bug 5. + applyEdges(t, 1320, 1321, + &pb.DirectedEdge{Entity: alice, Attr: attr, ValueId: bob, Op: pb.DirectedEdge_SET}, + &pb.DirectedEdge{Entity: alice, Attr: attr, ValueId: charlie, Op: pb.DirectedEdge_DEL}, + ) + + const readTs = uint64(1322) + + require.Equal(t, []uint64{bob}, forwardUids(t, attr, alice, readTs), + "alice's best_friend must be bob after set-bob + del-charlie batch") + require.Contains(t, reverseUids(t, attr, bob, readTs), alice, + "bob's reverse must contain alice") + require.NotContains(t, reverseUids(t, attr, charlie, readTs), alice, + "charlie's reverse must not contain alice after del") +} + +// TestLegacySingleUidSetAndDelDifferentTargets mirrors +// TestMutationPipelineSingleUidSetAndDelDifferentTargets via the legacy path. +func TestLegacySingleUidSetAndDelDifferentTargets(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`best_friend: uid @reverse .`), 1)) + + attr := x.AttrInRootNamespace("best_friend") + const ( + alice = uint64(10) + bob = uint64(20) + charlie = uint64(30) + ) + + applyEdgesLegacy(t, 1410, 1411, &pb.DirectedEdge{ + Entity: alice, Attr: attr, ValueId: charlie, Op: pb.DirectedEdge_SET, + }) + + applyEdgesLegacy(t, 1420, 1421, + &pb.DirectedEdge{Entity: alice, Attr: attr, ValueId: bob, Op: pb.DirectedEdge_SET}, + &pb.DirectedEdge{Entity: alice, Attr: attr, ValueId: charlie, Op: pb.DirectedEdge_DEL}, + ) + + const readTs = uint64(1422) + + require.Equal(t, []uint64{bob}, forwardUids(t, attr, alice, readTs), + "legacy: alice's best_friend must be bob after set-bob + del-charlie batch") + require.Contains(t, reverseUids(t, attr, bob, readTs), alice, + "legacy: bob's reverse must contain alice") + require.NotContains(t, reverseUids(t, attr, charlie, readTs), alice, + "legacy: charlie's reverse must not contain alice after del") +} + +// --------------------------------------------------------------------------- +// ProcessSingle: DEL wrong target uid (Bug 6) +// --------------------------------------------------------------------------- + +// TestMutationPipelineSingleUidDelWrongTarget documents the expected behaviour +// when a DEL targets a uid that does NOT match the committed value. +// +// Root cause (Bug 6): in ProcessSingle's exists=false DEL branch, the comparison +// `string(oldVal.Value) == string(edge.Value)` is nil==nil for uid postings, so +// setPosting() is always called regardless of whether the DEL target uid +// (edge.ValueId) matches the committed uid (oldVal.Uid). This creates a spurious +// {DEL wrong_uid} delta. The delta has no visible effect on forward data or the +// reverse index (because the wrong uid was never stored for that entity), so the +// observable state is identical to the correct no-op. +// +// Both pipeline and legacy should pass: alice retains bob, bob's reverse keeps +// alice, and charlie's reverse (which never contained alice) is unchanged. +func TestMutationPipelineSingleUidDelWrongTarget(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`best_friend: uid @reverse .`), 1)) + + attr := x.AttrInRootNamespace("best_friend") + const ( + alice = uint64(10) + bob = uint64(20) + charlie = uint64(30) + ) + + // Step 1 — initial state: alice → best_friend → bob. + applyEdges(t, 1510, 1511, &pb.DirectedEdge{ + Entity: alice, Attr: attr, ValueId: bob, Op: pb.DirectedEdge_SET, + }) + + // Step 2 — DEL alice→charlie (charlie is the wrong target; alice has bob). + // This DEL is a no-op: alice does not have charlie as best_friend. + applyEdges(t, 1520, 1521, &pb.DirectedEdge{ + Entity: alice, Attr: attr, ValueId: charlie, Op: pb.DirectedEdge_DEL, + }) + + const readTs = uint64(1522) + + require.Equal(t, []uint64{bob}, forwardUids(t, attr, alice, readTs), + "alice's best_friend must still be bob — DEL wrong target is a no-op") + require.Contains(t, reverseUids(t, attr, bob, readTs), alice, + "bob's reverse must still contain alice") + require.NotContains(t, reverseUids(t, attr, charlie, readTs), alice, + "charlie's reverse must not contain alice — no spurious reverse entry") +} + +// TestLegacySingleUidDelWrongTarget mirrors TestMutationPipelineSingleUidDelWrongTarget +// via the legacy path to confirm both paths treat DEL-wrong-target as a no-op. +func TestLegacySingleUidDelWrongTarget(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`best_friend: uid @reverse .`), 1)) + + attr := x.AttrInRootNamespace("best_friend") + const ( + alice = uint64(10) + bob = uint64(20) + charlie = uint64(30) + ) + + applyEdgesLegacy(t, 1610, 1611, &pb.DirectedEdge{ + Entity: alice, Attr: attr, ValueId: bob, Op: pb.DirectedEdge_SET, + }) + + applyEdgesLegacy(t, 1620, 1621, &pb.DirectedEdge{ + Entity: alice, Attr: attr, ValueId: charlie, Op: pb.DirectedEdge_DEL, + }) + + const readTs = uint64(1622) + + require.Equal(t, []uint64{bob}, forwardUids(t, attr, alice, readTs), + "legacy: alice's best_friend must still be bob — DEL wrong target is a no-op") + require.Contains(t, reverseUids(t, attr, bob, readTs), alice, + "legacy: bob's reverse must still contain alice") + require.NotContains(t, reverseUids(t, attr, charlie, readTs), alice, + "legacy: charlie's reverse must not contain alice") +} + +// --------------------------------------------------------------------------- +// ProcessList lang: SET new then DEL old in same batch (Scenario 4) +// --------------------------------------------------------------------------- + +// TestMutationPipelineLangSetNewThenDelOldSameBatch is the regression test for +// the index-loss bug when a lang predicate batch contains SET new-value followed +// by DEL old-value for the same language tag. +// +// Root cause: ProcessList accumulates postings in a MutableLayer keyed by +// FingerprintEdge(lang). Both SET "Alicia"@en and DEL "Alice"@en share the +// same fingerprint (fingerprint of "en"). insertPosting stores them under the +// same uid slot and the second write (DEL "Alice") overwrites the first (SET +// "Alicia"). InsertTokenizerIndexes then only sees {DEL "Alice"} — it emits the +// removal of the old index entry but never emits the SET entry for the new value. +// +// The legacy path (addMutationHelper per edge) generates index mutations at +// edge-processing time, BEFORE the same-uid overwrite affects the mutation layer. +// So legacy produces the correct index even though the forward scalar value is +// also corrupted by the overwrite (no value stored for alice's name@en — a +// separate bug shared by both paths). +// +// Expected (correct) behaviour: +// - index("Alicia") contains alice. +// - index("Alice") does NOT contain alice. +// +// Observed (buggy) behaviour without the fix: +// - index("Alicia") does NOT contain alice (SET entry was lost). +// - index("Alice") also no longer contains alice (DEL was applied correctly). +func TestMutationPipelineLangSetNewThenDelOldSameBatch(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`name: string @lang @index(exact) .`), 1)) + + attr := x.AttrInRootNamespace("name") + const alice = uint64(10) + + // Step 1 — initial state: alice's name@en = "Alice". + applyEdges(t, 1710, 1711, &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("Alice"), ValueType: pb.Posting_STRING, + Lang: "en", Op: pb.DirectedEdge_SET, + }) + + require.Contains(t, indexUidsForLang(t, attr, "en", "Alice", 1712), alice, + "initial: index for 'Alice' must contain alice") + + // Step 2 — batch: SET "Alicia"@en first, then DEL "Alice"@en. + // Both share fingerprint("en") → DEL overwrites SET in the MutableLayer. + applyEdges(t, 1720, 1721, + &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("Alicia"), ValueType: pb.Posting_STRING, + Lang: "en", Op: pb.DirectedEdge_SET, + }, + &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("Alice"), ValueType: pb.Posting_STRING, + Lang: "en", Op: pb.DirectedEdge_DEL, + }, + ) + + const readTs = uint64(1722) + + require.Contains(t, indexUidsForLang(t, attr, "en", "Alicia", readTs), alice, + "index for 'Alicia' must contain alice after set-new + del-old batch") + require.NotContains(t, indexUidsForLang(t, attr, "en", "Alice", readTs), alice, + "index for 'Alice' must not contain alice after del-old") +} + +// TestLegacyLangSetNewThenDelOldSameBatch mirrors Scenario 4 via the legacy path. +// Legacy generates index mutations at edge-processing time, so the SET "Alicia" +// index entry is created before the same-uid overwrite discards the SET posting. +func TestLegacyLangSetNewThenDelOldSameBatch(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`name: string @lang @index(exact) .`), 1)) + + attr := x.AttrInRootNamespace("name") + const alice = uint64(10) + + applyEdgesLegacy(t, 1810, 1811, &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("Alice"), ValueType: pb.Posting_STRING, + Lang: "en", Op: pb.DirectedEdge_SET, + }) + + applyEdgesLegacy(t, 1820, 1821, + &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("Alicia"), ValueType: pb.Posting_STRING, + Lang: "en", Op: pb.DirectedEdge_SET, + }, + &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("Alice"), ValueType: pb.Posting_STRING, + Lang: "en", Op: pb.DirectedEdge_DEL, + }, + ) + + const readTs = uint64(1822) + + require.Contains(t, indexUidsForLang(t, attr, "en", "Alicia", readTs), alice, + "legacy: index for 'Alicia' must contain alice after set-new + del-old batch") + require.NotContains(t, indexUidsForLang(t, attr, "en", "Alice", readTs), alice, + "legacy: index for 'Alice' must not contain alice after del-old") +} + +// --------------------------------------------------------------------------- +// Multiple SETs same uid same batch — scalar @index (Scenario 5) +// --------------------------------------------------------------------------- + +// TestMutationPipelineMultipleSetsSameUidScalarIndex verifies that when a batch +// contains two SET edges for the same entity on a scalar @index predicate, only +// the LAST value ends up in the index (last-SET-wins), and the intermediate +// value never appears in the index. +// +// This scenario is NOT pipeline-specific; both pipeline and legacy apply +// last-SET-wins semantics. The test exists to confirm that no intermediate index +// entry is created for the first SET value. +// +// Setup: alice has nickname = "v1" committed. +// Batch: SET alice nickname = "v1" (same as committed — redundant) +// +// SET alice nickname = "v2" (the desired new value) +// +// Expected: +// - index("v2") contains alice. +// - index("v1") does NOT contain alice (removed by handleOldDeleteForSingle's +// synthetic DEL for the committed value; the intermediate SET v1 was absorbed +// into SET v2 by last-SET-wins accumulation, so no redundant v1 index entry). +func TestMutationPipelineMultipleSetsSameUidScalarIndex(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`nickname: string @index(exact) .`), 1)) + + attr := x.AttrInRootNamespace("nickname") + const alice = uint64(10) + + // Step 1 — commit initial value "v1". + applyEdges(t, 1910, 1911, &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("v1"), ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_SET, + }) + + require.Contains(t, indexUidsForVal(t, attr, "v1", 1912), alice, + "initial: index('v1') must contain alice") + + // Step 2 — batch: redundant SET v1 + final SET v2. + // ProcessSingle accumulates SET v1 then SET v2 → last-SET-wins → [{SET v2}]. + // handleOldDeleteForSingle reads committed v1, synthesises {DEL v1}. + // InsertTokenizerIndexes: SET v2 (add) + DEL v1 (remove). + applyEdges(t, 1920, 1921, + &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("v1"), ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_SET, + }, + &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("v2"), ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_SET, + }, + ) + + const readTs = uint64(1922) + + require.Contains(t, indexUidsForVal(t, attr, "v2", readTs), alice, + "index('v2') must contain alice after last-SET-wins") + require.NotContains(t, indexUidsForVal(t, attr, "v1", readTs), alice, + "index('v1') must not contain alice — old value removed by synthetic DEL") +} + +// TestLegacyMultipleSetsSameUidScalarIndex mirrors Scenario 5 via the legacy path. +func TestLegacyMultipleSetsSameUidScalarIndex(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`nickname: string @index(exact) .`), 1)) + + attr := x.AttrInRootNamespace("nickname") + const alice = uint64(10) + + applyEdgesLegacy(t, 2010, 2011, &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("v1"), ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_SET, + }) + + applyEdgesLegacy(t, 2020, 2021, + &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("v1"), ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_SET, + }, + &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("v2"), ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_SET, + }, + ) + + const readTs = uint64(2022) + + require.Contains(t, indexUidsForVal(t, attr, "v2", readTs), alice, + "legacy: index('v2') must contain alice after last-SET-wins") + require.NotContains(t, indexUidsForVal(t, attr, "v1", readTs), alice, + "legacy: index('v1') must not contain alice — old value removed") +} + +// --------------------------------------------------------------------------- +// Bug 5: ProcessSingle case-insensitive tokenizer — duplicate synthetic DEL +// --------------------------------------------------------------------------- + +// TestMutationPipelineCaseInsensitiveTokenizerDupDel is the regression test for the +// index-loss bug that occurs when all three of the following hold: +// +// 1. The predicate uses a case-insensitive tokenizer (e.g. "term", which applies a +// lowercase filter so "Apple" and "APPLE" hash to the same bucket "apple"). +// 2. The user explicitly sends a DEL for the old value alongside a SET for a new +// value in the same batch. +// 3. The old and new values produce the same token after normalization. +// +// Root cause: ProcessSingle accumulates {DEL "Apple", SET "APPLE"} from the user +// edges. handleOldDeleteForSingle then reads the committed value "Apple" and sees +// that it differs from the new value "APPLE" (byte-level comparison), so it appends +// a synthetic DEL "Apple". The resulting PostingList has three entries: +// +// [{DEL "Apple"}, {SET "APPLE"}, {DEL "Apple" (synthetic)}] +// +// InsertTokenizerIndexes only applies the isSingleEdge reversal when len == 2. +// With len == 3 no reversal fires, so the postings are fed to insertPosting in +// original order. Both DEL "Apple" entries produce the same index key ("apple") +// and the same entity uid. The three insertPosting calls leave currentEntries[alice] +// as DEL — so alice is removed from the "apple" bucket even though her tag is now +// "APPLE" (which tokenises to "apple"). +// +// Expected (correct) behaviour: +// - index("APPLE") — i.e. the "apple" bucket — still contains alice after the +// batch, because her new value "APPLE" maps to that bucket. +// +// Observed (buggy) behaviour without the fix: +// - index("APPLE") does NOT contain alice — she was incorrectly removed. +func TestMutationPipelineCaseInsensitiveTokenizerDupDel(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`casetag: string @index(term) .`), 1)) + + attr := x.AttrInRootNamespace("casetag") + const alice = uint64(10) + + // Step 1 — commit initial value "Apple". Term-tokenises to "apple". + applyEdges(t, 2110, 2111, &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("Apple"), ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_SET, + }) + + require.Contains(t, indexUidsForVal(t, attr, "Apple", 2112), alice, + "initial: term index for 'Apple' must contain alice") + + // Step 2 — batch: explicit DEL "Apple" + SET "APPLE". + // "Apple" and "APPLE" share the term token "apple" (lowercase filter). + // handleOldDeleteForSingle appends a synthetic DEL "Apple", creating 3 postings; + // InsertTokenizerIndexes does not apply the len==2 reversal — final index = DEL. + applyEdges(t, 2120, 2121, + &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("Apple"), ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_DEL, + }, + &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("APPLE"), ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_SET, + }, + ) + + const readTs = uint64(2122) + + // "APPLE" term-tokenises to "apple"; alice must be in the "apple" bucket because + // her tag is now "APPLE". + require.Contains(t, indexUidsForVal(t, attr, "APPLE", readTs), alice, + "index for 'APPLE' (term bucket 'apple') must contain alice after explicit-del + set batch") +} + +// TestLegacyCaseInsensitiveTokenizerDupDel mirrors Bug 5 via the legacy path to +// confirm that legacy handles explicit-DEL + SET with a shared case-insensitive +// token correctly. Legacy processes each edge independently so the SET index +// entry is generated after the DEL, leaving alice in the "apple" bucket. +func TestLegacyCaseInsensitiveTokenizerDupDel(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`casetag: string @index(term) .`), 1)) + + attr := x.AttrInRootNamespace("casetag") + const alice = uint64(10) + + applyEdgesLegacy(t, 2210, 2211, &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("Apple"), ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_SET, + }) + + applyEdgesLegacy(t, 2220, 2221, + &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("Apple"), ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_DEL, + }, + &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("APPLE"), ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_SET, + }, + ) + + const readTs = uint64(2222) + + require.Contains(t, indexUidsForVal(t, attr, "APPLE", readTs), alice, + "legacy: index for 'APPLE' (term bucket 'apple') must contain alice after explicit-del + set batch") +} + +// --------------------------------------------------------------------------- +// Issue 7: Process() channel send deadlock on goroutine error +// --------------------------------------------------------------------------- + +// TestMutationPipelineChannelDeadlock detects the deadlock that arises when a +// predicate's ProcessPredicate goroutine exits with an error before it has +// drained its edges channel, AND the main Process() loop has already filled that +// channel to capacity with remaining edges. +// +// Root cause: the edges channel has a capacity of 1000. Process() sends edges +// with a bare `pred.edges <- edge` — there is no `select` on egCtx.Done(). +// If the goroutine fails on the first edge (schema-not-found) it exits without +// consuming the 999 buffered items. The main loop then tries to send the 1001st +// edge into a full channel whose reader has gone — it blocks forever. +// +// Fix: replace `pred.edges <- edge` with: +// +// select { +// case pred.edges <- edge: +// case <-egCtx.Done(): +// return egCtx.Err() +// } +// +// This test sends exactly channelCap+2 = 1002 SET edges for a predicate with no +// registered schema. Sequence: +// 1. Main loop fills the 1000-slot buffer (edges 0-999), then blocks on edge 1000. +// 2. The goroutine reads edge 0 (freeing one slot), main loop sends edge 1000. +// 3. The goroutine calls runMutation(edge 0), fails (no schema), exits — channel +// still holds 1000 items. +// 4. Main loop tries to send edge 1001 into the full, unread channel → blocks +// forever (deadlock). +// +// With the fix, egCtx.Done() fires and Process() returns an error within +// milliseconds. Without the fix, the test blocks until the 3-second timeout. +// +// Note: channelCap+1 = 1001 edges is NOT sufficient — the goroutine reading one +// edge gives the main loop exactly one free slot to push its last edge, so the +// loop completes without ever stalling. The +2 guarantees one edge remains +// unsendable after the goroutine exits. +func TestMutationPipelineChannelDeadlock(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + // No schema registered — ProcessPredicate falls through to the legacy drain + // loop, which calls runMutation and fails on the first SET edge. + attr := x.AttrInRootNamespace("no_schema_deadlock_pred") + + const channelCap = 1000 + edges := make([]*pb.DirectedEdge, channelCap+2) + for i := range edges { + edges[i] = &pb.DirectedEdge{ + Entity: uint64(i + 1), + Attr: attr, + Value: []byte("v"), + ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_SET, + } + } + + done := make(chan error, 1) + go func() { + txn := Oracle().RegisterStartTs(2300) + mp := NewMutationPipeline(txn) + done <- mp.Process(context.Background(), edges) + }() + + select { + case err := <-done: + // Process() must return an error (schema not found), never nil. + require.Error(t, err, + "Process() must return a schema-not-found error, not nil") + case <-time.After(3 * time.Second): + t.Fatal("Process() deadlocked: channel send blocked after goroutine error — " + + "fix by using select{case pred.edges<-edge: case <-egCtx.Done():...}") + } +} + +// --------------------------------------------------------------------------- +// Issue 9: non-matching DEL is a no-op +// --------------------------------------------------------------------------- + +// TestMutationPipelineNonMatchingDelNoOp verifies that a DEL edge whose value +// does not match the committed scalar value is silently ignored — the forward +// data key and the index are left unchanged. +// +// In ProcessSingle, postings[uid] is unconditionally initialised to an empty +// PostingList before the committed-value check, so an empty delta IS written to +// Badger even for the no-op case (Issue 9 / minor inefficiency). The important +// invariant is that the observable state — the value and index — is not altered. +// +// Expected behaviour (both pipeline and legacy): +// - alice's tag is still "Alice". +// - index("Alice") still contains alice. +// - index("Blink") does not contain alice. +func TestMutationPipelineNonMatchingDelNoOp(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`tag: string @index(exact) .`), 1)) + + attr := x.AttrInRootNamespace("tag") + const alice = uint64(10) + + // Step 1 — commit initial value "Alice". + applyEdges(t, 2410, 2411, &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("Alice"), ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_SET, + }) + + require.Contains(t, indexUidsForVal(t, attr, "Alice", 2412), alice, + "initial: index('Alice') must contain alice") + + // Step 2 — DEL with wrong value: alice has "Alice", we DEL "Blink". + // ProcessSingle: DEL "Blink", old committed = "Alice", "Blink" != "Alice" → + // setPosting() is NOT called → empty delta written → no change. + applyEdges(t, 2420, 2421, &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("Blink"), ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_DEL, + }) + + const readTs = uint64(2422) + + require.Contains(t, indexUidsForVal(t, attr, "Alice", readTs), alice, + "index('Alice') must still contain alice — non-matching DEL is a no-op") + require.NotContains(t, indexUidsForVal(t, attr, "Blink", readTs), alice, + "index('Blink') must not contain alice — no such value was ever set") +} + +// TestLegacyNonMatchingDelNoOp mirrors Issue 9 via the legacy path to confirm +// that both paths treat a non-matching DEL as a no-op. +func TestLegacyNonMatchingDelNoOp(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`tag: string @index(exact) .`), 1)) + + attr := x.AttrInRootNamespace("tag") + const alice = uint64(10) + + applyEdgesLegacy(t, 2510, 2511, &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("Alice"), ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_SET, + }) + + applyEdgesLegacy(t, 2520, 2521, &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("Blink"), ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_DEL, + }) + + const readTs = uint64(2522) + + require.Contains(t, indexUidsForVal(t, attr, "Alice", readTs), alice, + "legacy: index('Alice') must still contain alice — non-matching DEL is a no-op") + require.NotContains(t, indexUidsForVal(t, attr, "Blink", readTs), alice, + "legacy: index('Blink') must not contain alice") +} From d4fbb0417582cf00eb04e6dc70c24b1dbd770a4f Mon Sep 17 00:00:00 2001 From: Shivaji Kharse Date: Tue, 26 May 2026 18:13:23 +0530 Subject: [PATCH 19/45] fix(pipeline): two correctness gaps surfaced by alpha integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. handleOldDeleteForSingle cleared postings[uid] to an empty PostingList whenever the new SET value matched the committed value. The intent was "value unchanged, skip the write," but clearing also bypassed InsertTokenizerIndexes / ProcessReverse — so a SET that needed to populate a newly-added @index or @reverse (data committed before the alter, then re-SET with the same value under the new schema) emitted no index/reverse entry. TestSchemaMutationIndexRemove caught this: query for anyofterms(name, "Alice") returned [] after a same-value SET on a term-indexed predicate. Legacy passes because runMutation calls addIndexMutations per SET edge regardless of value change. Fix: in both same-value branches (REF and scalar), continue without clearing postings[uid]. The SET stays so the index / reverse passes can populate the entry — idempotent when it already existed, load-bearing when the index was newly added. 2. ProcessSingle's data-write loop called addConflictKey + AddDelta for every uid in the postings map, including uids whose accumulated PostingList was empty. An empty list happens when setPosting was never invoked — e.g. a DEL whose value did not match the committed value (a no-op per Dgraph semantics) or the same-value SET no-op after fix #1. AddDelta with an empty input marshals to empty bytes and then calls list.setMutation on the txn-cached List, which routes through setCurrentEntries and resets the mutable layer (currentEntries cleared, deleteAllMarker reset). Any reader holding that cached List then reads the data list as empty. CommitToDisk filters empty-PostingList deltas before badger writes (mvcc.go ~L318), but UpdateCachedKeys still propagates them to the global MemLayer via setMutationAfterCommit, planting committedEntries[commitTs] = emptyPL with CommitTs set. TestDeleteScalarValue and TestUpsertDeleteWrongValue surfaced this: a DEL with a wrong value (val(updated_amt) where updated_amt = amt + 1) deleted the data anyway. ProcessList (~L401) and ProcessReverse (~L466) already had the empty-pl guard; ProcessSingle did not. Fix: at the top of ProcessSingle's data-write loop, skip uids with an empty PostingList — matches the sibling paths. Regression coverage in posting/pipeline_test.go: - TestMutationPipelineSetThenAddIndexThenResetMirrorsAlphaFailure - TestMutationPipelineSameValueSetKeepsIndex - TestMutationPipelineDelWithWrongValueIsNoOp - TestMutationPipelineDelWrongValueTwoUidsCloseMatch All with Legacy mirrors that pass before the fix; the TestMutationPipeline* variants fail without the fix. --- posting/index.go | 25 +++- posting/pipeline_test.go | 287 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 310 insertions(+), 2 deletions(-) diff --git a/posting/index.go b/posting/index.go index 91500f6d861..5d91492dad8 100644 --- a/posting/index.go +++ b/posting/index.go @@ -518,13 +518,19 @@ func (mp *MutationPipeline) handleOldDeleteForSingle(pipeline *PredicatePipeline // For uid (REF) postings, Value is always nil — the target uid lives in // posting.Uid. Compare Uid directly; fall back to Value comparison for scalars. + // + // When the new value matches the committed value, skip emitting the + // synthetic DEL-of-old-value (there is nothing to delete). Critically, + // do NOT clear postings[uid]: the SET must still flow through + // InsertTokenizerIndexes / ProcessReverse so a newly-added index or + // reverse edge can be populated for the (unchanged) value. Clearing + // here is what caused TestSchemaMutationIndexRemove to leave the term + // index empty after a same-value SET following an @index alter. if currValue.PostingType == pb.Posting_REF && oldVal.PostingType == pb.Posting_REF { if oldVal.Uid == currValue.Uid { - postings[uid] = &pb.PostingList{} continue } } else if string(oldVal.Value) == string(currValue.Value) { - postings[uid] = &pb.PostingList{} continue } @@ -879,6 +885,21 @@ func (mp *MutationPipeline) ProcessSingle(ctx context.Context, pipeline *Predica stripSyntheticDel := info.index for uid, pl := range postings { + // An empty PostingList means nothing was accumulated for this uid (e.g. + // a DEL whose value did not match the committed value, or a same-value + // SET-after-handleOldDeleteForSingle no-op). Writing an empty delta + // would call AddDelta → setMutation on the cached List with empty bytes, + // which resets the mutable layer's currentEntries to empty for this + // txn's startTs. A subsequent read through the same cached List then + // surfaces an empty data list. CommitToDisk skips empty deltas at the + // badger level, but the in-memory side effect on the cached List has + // already corrupted any concurrent reader holding that List. Skip the + // no-op uid entirely — matches the legacy per-edge path which simply + // does nothing when a DEL value does not match committed. + if len(pl.Postings) == 0 { + continue + } + binary.BigEndian.PutUint64(dataKey[len(dataKey)-8:], uid) key := baseKey + string(dataKey[len(dataKey)-8:]) diff --git a/posting/pipeline_test.go b/posting/pipeline_test.go index ebb4be5ec76..2d218e13daa 100644 --- a/posting/pipeline_test.go +++ b/posting/pipeline_test.go @@ -1268,3 +1268,290 @@ func TestLegacyNonMatchingDelNoOp(t *testing.T) { require.NotContains(t, indexUidsForVal(t, attr, "Blink", readTs), alice, "legacy: index('Blink') must not contain alice") } + +// --------------------------------------------------------------------------- +// Bug 10: same-value SET on already-committed scalar index entry +// --------------------------------------------------------------------------- + +// TestMutationPipelineSameValueSetKeepsIndex is a baseline regression test: +// when the schema already carries @index(term) at the time of the initial SET, +// the index entry must survive a second identical SET on the same uid. +// +// This test passes both before and after the handleOldDeleteForSingle fix, +// because the initial SET already populated the index. The companion test +// TestMutationPipelineSetThenAddIndexThenResetMirrorsAlphaFailure is the one +// that reproduces the actual alpha-level TestSchemaMutationIndexRemove +// failure — that scenario requires the SET to happen *before* the @index +// alter, so the second SET is responsible for populating the index. +// +// Legacy mirror: TestLegacySameValueSetKeepsIndex. +func TestMutationPipelineSameValueSetKeepsIndex(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`name: string @index(term) .`), 1)) + + attr := x.AttrInRootNamespace("name") + const alice = uint64(10) + + applyEdges(t, 2610, 2611, &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("Alice"), ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_SET, + }) + + require.Contains(t, indexUidsForVal(t, attr, "Alice", 2612), alice, + "initial: index('Alice') must contain alice") + + applyEdges(t, 2620, 2621, &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("Alice"), ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_SET, + }) + + const readTs = uint64(2622) + + require.Contains(t, indexUidsForVal(t, attr, "Alice", readTs), alice, + "index('Alice') must still contain alice after same-value SET re-commit") +} + +// TestLegacySameValueSetKeepsIndex mirrors Bug 10 via the legacy path. +func TestLegacySameValueSetKeepsIndex(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`name: string @index(term) .`), 1)) + + attr := x.AttrInRootNamespace("name") + const alice = uint64(10) + + applyEdgesLegacy(t, 2710, 2711, &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("Alice"), ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_SET, + }) + + require.Contains(t, indexUidsForVal(t, attr, "Alice", 2712), alice, + "legacy initial: index('Alice') must contain alice") + + applyEdgesLegacy(t, 2720, 2721, &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("Alice"), ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_SET, + }) + + const readTs = uint64(2722) + + require.Contains(t, indexUidsForVal(t, attr, "Alice", readTs), alice, + "legacy: index('Alice') must still contain alice after same-value SET re-commit") +} + +// TestMutationPipelineSetThenAddIndexThenResetMirrorsAlphaFailure is the +// regression test for the alpha-level failure TestSchemaMutationIndexRemove. +// +// Sequence: +// 1. Schema is `name: string` (no @index). +// 2. SET name "Alice" is committed. +// 3. Schema altered to add @index(term). +// 4. SET name "Alice" again (same value, but now under @index(term)). +// 5. Query the term index for "Alice" — must contain alice. +// +// Root cause (pre-fix): handleOldDeleteForSingle in posting/index.go saw that +// the new value matched the committed value and replaced postings[uid] with +// an empty PostingList. InsertTokenizerIndexes then iterated an empty list +// for that uid and emitted no index entries, so the newly-added index never +// got the "alice" → alice entry. Legacy passes because runMutation calls +// addIndexMutations per SET edge regardless of whether the value changed. +// +// Fix: handleOldDeleteForSingle now `continue`s in the same-value branch +// without clearing postings[uid]. The SET stays in place so +// InsertTokenizerIndexes / ProcessReverse can populate the new index/reverse +// edge. Index/reverse emissions on already-present entries are idempotent. +func TestMutationPipelineSetThenAddIndexThenResetMirrorsAlphaFailure(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + // Step 1: no-index schema initially. + require.NoError(t, schema.ParseBytes([]byte(`name: string .`), 1)) + attr := x.AttrInRootNamespace("name") + const alice = uint64(10) + + // Step 2: SET Alice with no index. + applyEdges(t, 2810, 2811, &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("Alice"), ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_SET, + }) + + // Step 3: alter — add term index. Simulate alpha's background reindex by + // flipping schema then re-running a SET (the reindex on alpha rewrites the + // index from existing data; here we replay the existing SET under the new + // schema to drive InsertTokenizerIndexes). + require.NoError(t, schema.ParseBytes([]byte(`name: string @index(term) .`), 1)) + applyEdges(t, 2820, 2821, &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("Alice"), ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_SET, + }) + + require.Contains(t, indexUidsForVal(t, attr, "Alice", 2822), alice, + "after reindex: index('Alice') must contain alice") + + // Step 4: SET Alice again, same value, index now exists. + applyEdges(t, 2830, 2831, &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("Alice"), ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_SET, + }) + + const readTs = uint64(2832) + + require.Contains(t, indexUidsForVal(t, attr, "Alice", readTs), alice, + "index('Alice') must still contain alice after same-value SET on top of @index(term)") +} + +// TestLegacySetThenAddIndexThenResetMirrorsAlphaFailure is the legacy mirror +// of the test above. Legacy is expected to pass because runMutation calls +// addIndexMutations for every SET regardless of whether the value changed. +func TestLegacySetThenAddIndexThenResetMirrorsAlphaFailure(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`name: string .`), 1)) + attr := x.AttrInRootNamespace("name") + const alice = uint64(10) + + applyEdgesLegacy(t, 2910, 2911, &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("Alice"), ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_SET, + }) + + require.NoError(t, schema.ParseBytes([]byte(`name: string @index(term) .`), 1)) + applyEdgesLegacy(t, 2920, 2921, &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("Alice"), ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_SET, + }) + + require.Contains(t, indexUidsForVal(t, attr, "Alice", 2922), alice, + "legacy after reindex: index('Alice') must contain alice") +} + +// scalarValue returns the string value of the single scalar posting under +// (attr, entity) at readTs, or empty if there is no posting. +func scalarValue(t *testing.T, attr string, entity, readTs uint64) string { + t.Helper() + l, err := GetNoStore(x.DataKey(attr, entity), readTs) + require.NoError(t, err) + var out string + require.NoError(t, l.Iterate(readTs, 0, func(p *pb.Posting) error { + out = string(p.Value) + return nil + })) + return out +} + +// TestMutationPipelineDelWithWrongValueIsNoOp is the regression test for +// TestDeleteScalarValue at the alpha integration level. +// +// Sequence: +// 1. Schema: name: string @index(exact). +// 2. SET name "xxx" (committed). +// 3. DEL name "yyy" (wrong value — does not match committed "xxx"). +// 4. The data must still read as "xxx" — a DEL whose value does not match +// the committed value is a no-op per Dgraph semantics. +// +// Bug: ProcessSingle accumulates `postings[alice] = &pb.PostingList{}` for the +// non-matching DEL (sameTarget is false, so setPosting is not called). The +// data-write loop then calls AddDelta(dataKey, emptyPL) which clobbers the +// in-memory list's mutable layer via setCurrentEntries → the next read at +// readTs > commitTs surfaces an empty list. +func TestMutationPipelineDelWithWrongValueIsNoOp(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`name: string @index(exact) .`), 1)) + attr := x.AttrInRootNamespace("name") + const alice = uint64(10) + + applyEdges(t, 3010, 3011, &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("xxx"), ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_SET, + }) + + require.Equal(t, "xxx", scalarValue(t, attr, alice, 3012), + "sanity: committed value should be xxx") + + applyEdges(t, 3020, 3021, &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("yyy"), ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_DEL, + }) + + require.Equal(t, "xxx", scalarValue(t, attr, alice, 3022), + "DEL with wrong value must be a no-op — name still xxx") +} + +// TestLegacyDelWithWrongValueIsNoOp mirrors the above via the legacy path. +func TestLegacyDelWithWrongValueIsNoOp(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`name: string @index(exact) .`), 1)) + attr := x.AttrInRootNamespace("name") + const alice = uint64(10) + + applyEdgesLegacy(t, 3110, 3111, &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("xxx"), ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_SET, + }) + + applyEdgesLegacy(t, 3120, 3121, &pb.DirectedEdge{ + Entity: alice, Attr: attr, + Value: []byte("yyy"), ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_DEL, + }) + + require.Equal(t, "xxx", scalarValue(t, attr, alice, 3122), + "legacy: DEL with wrong value must be a no-op") +} + +// TestMutationPipelineDelWrongValueTwoUidsCloseMatch mirrors TestDeleteScalarValue's +// exact shape: two uids both set to "xxx", then DEL on one of them with value "yyy". +// The DEL must not delete; the SET on the other uid must remain. +func TestMutationPipelineDelWrongValueTwoUidsCloseMatch(t *testing.T) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + + require.NoError(t, schema.ParseBytes([]byte(`name: string @index(exact) .`), 1)) + attr := x.AttrInRootNamespace("name") + const ( + a = uint64(0x12345) + b = uint64(0x12346) + ) + + applyEdges(t, 3210, 3211, + &pb.DirectedEdge{Entity: a, Attr: attr, Value: []byte("xxx"), ValueType: pb.Posting_STRING, Op: pb.DirectedEdge_SET}, + &pb.DirectedEdge{Entity: b, Attr: attr, Value: []byte("xxx"), ValueType: pb.Posting_STRING, Op: pb.DirectedEdge_SET}, + ) + + require.Equal(t, "xxx", scalarValue(t, attr, a, 3212), "sanity: a=xxx") + require.Equal(t, "xxx", scalarValue(t, attr, b, 3212), "sanity: b=xxx") + + applyEdges(t, 3220, 3221, + &pb.DirectedEdge{Entity: a, Attr: attr, Value: []byte("yyy"), ValueType: pb.Posting_STRING, Op: pb.DirectedEdge_DEL}, + ) + + const readTs = uint64(3222) + require.Equal(t, "xxx", scalarValue(t, attr, a, readTs), + "DEL with wrong value must be a no-op — a still xxx") + require.Equal(t, "xxx", scalarValue(t, attr, b, readTs), + "DEL on a must not touch b") + require.Contains(t, indexUidsForVal(t, attr, "xxx", readTs), a, + "index('xxx') must still contain a") + require.Contains(t, indexUidsForVal(t, attr, "xxx", readTs), b, + "index('xxx') must still contain b") +} From d39f4109e5ff10bba1453414a6227cfd1e53b1d4 Mon Sep 17 00:00:00 2001 From: Ryan Hendrickson Date: Tue, 23 Jun 2026 02:09:02 -0400 Subject: [PATCH 20/45] perf(pipeline): proportional + auto goroutine budget, parallel ProcessList forward write Builds on the per-predicate mutation pipeline (#9467). Three additive, flag-gated changes that make a single hot/dominant predicate's apply actually parallelize: 1. Proportional goroutine budget across predicates (allocateWorkers): mutations-pipeline-goroutines=N distributes N workers by edge count (largest-remainder), so a dominant predicate gets >1 worker. 2. Auto mode (=-1): budget = min(GOMAXPROCS*fraction, edges/minEdgesPerWorker), derived at runtime. Tunables mutations-pipeline-goroutines-fraction (1.0) and mutations-pipeline-min-edges-per-worker (256). 3. Parallel forward data-write in ProcessList (the merge-light pass), matching the existing ProcessSingle split, so [uid]/@reverse predicates' forward write parallelizes. ProcessReverse stays SERIAL. Default is off (0) => byte-identical to the current one-goroutine-per- predicate path. Byte-identical equivalence + conflict-key-set tests pass under -race for scalar, [uid] @reverse, and AUTO==fixed. Benchmarks (8-core dev box, ~20k-edge batch) included. Co-Authored-By: Claude Opus 4.8 --- dgraph/cmd/alpha/run.go | 26 + posting/index.go | 452 +++++++++++++++- posting/oracle.go | 37 ++ posting/pipeline_budget_test.go | 838 ++++++++++++++++++++++++++++++ posting/pipeline_revbench_test.go | 138 +++++ worker/server_state.go | 4 +- x/config.go | 32 ++ 7 files changed, 1500 insertions(+), 27 deletions(-) create mode 100644 posting/pipeline_budget_test.go create mode 100644 posting/pipeline_revbench_test.go diff --git a/dgraph/cmd/alpha/run.go b/dgraph/cmd/alpha/run.go index 6442965a3c2..3bf5f290839 100644 --- a/dgraph/cmd/alpha/run.go +++ b/dgraph/cmd/alpha/run.go @@ -293,6 +293,29 @@ they form a Raft group and provide synchronous replication. "goroutine spin-up cost, so tiny mutations are slightly slower on it; "+ "bulk multi-predicate mutations are faster (crossover ~100 edges in "+ "benchmarks here)."). + Flag("mutations-pipeline-goroutines", + "Global budget of intra-predicate worker goroutines distributed "+ + "across the predicates in a single mutation batch, proportionally to "+ + "each predicate's edge count. A hot/dominant predicate is granted most "+ + "of the budget so its data-write and index passes parallelize; tiny "+ + "predicates get one worker each. 0 (or any value < 2) disables "+ + "intra-predicate parallelism — every predicate runs on a single "+ + "goroutine, exactly the legacy behavior. N>0 is an absolute budget. "+ + "-1 = auto (derive from GOMAXPROCS*fraction, capped by "+ + "edges/min-edges-per-worker). Default 30."). + Flag("mutations-pipeline-goroutines-fraction", + "AUTO mode only (mutations-pipeline-goroutines=-1): fraction of "+ + "GOMAXPROCS one mutation batch may use. The apply phase is serial "+ + "across transactions, so a single apply effectively owns the box; "+ + "default 1.0 uses all cores. Raise above 1.0 to oversubscribe "+ + "(benchmarks show the throughput peak near 2-3x cores) or lower it "+ + "to leave headroom for Badger compaction/queries/GC under heavy "+ + "concurrent read load. Default 1.0."). + Flag("mutations-pipeline-min-edges-per-worker", + "AUTO mode only (mutations-pipeline-goroutines=-1): minimum edges per "+ + "intra-predicate worker the derived budget targets "+ + "(workCap = totalEdges / this). Mirrors the 256-edge DivideAndRule "+ + "rule. Default 256."). String()) RegisterFlags(flag) @@ -821,6 +844,9 @@ func run() { enableDetailedMetrics := featureFlagsConf.GetBool("enable-detailed-metrics") x.WorkerConfig.SlowQueryLogThreshold = featureFlagsConf.GetDuration("log-slow-query-threshold") x.WorkerConfig.MutationsPipelineThreshold = int(featureFlagsConf.GetInt64("mutations-pipeline-threshold")) + x.WorkerConfig.MutationsPipelineGoroutines = int(featureFlagsConf.GetInt64("mutations-pipeline-goroutines")) + x.WorkerConfig.MutationsPipelineGoroutinesFraction = featureFlagsConf.GetFloat64("mutations-pipeline-goroutines-fraction") + x.WorkerConfig.MutationsPipelineMinEdgesPerWorker = int(featureFlagsConf.GetInt64("mutations-pipeline-min-edges-per-worker")) x.PrintVersion() glog.Infof("x.Config: %+v", x.Config) diff --git a/posting/index.go b/posting/index.go index 5d91492dad8..49f832e5735 100644 --- a/posting/index.go +++ b/posting/index.go @@ -14,6 +14,7 @@ import ( "fmt" "math" "os" + "runtime" "sort" "strings" "sync" @@ -106,6 +107,12 @@ type PredicatePipeline struct { edges chan *pb.DirectedEdge wg *sync.WaitGroup errCh chan error + // workers is this predicate's grant from the global intra-predicate budget + // (see allocateWorkers). 0 means the budget is disabled/unset: data-write + // stays serial and InsertTokenizerIndexes uses the legacy numGo=10 — exactly + // the pre-budget behavior. A value k>=1 splits the merge-light passes + // (ProcessSingle's data-write, index tokenization) into k goroutines. + workers int } func (pp *PredicatePipeline) close() { @@ -237,7 +244,15 @@ func (mp *MutationPipeline) InsertTokenizerIndexes(ctx context.Context, pipeline } } + // Fold the historically-fixed 10-way index tokenization into the global + // budget. When the budget is disabled (pipeline.workers == 0) this stays at + // the legacy numGo=10, so behavior is byte-identical to before. Index + // tokenization is a low-fan-in pass (key ), so parallelizing it + // is safe; the cache-locked merge below still serializes the final write. numGo := 10 + if pipeline.workers > 0 { + numGo = pipeline.workers + } wg.Add(numGo) chMap := make(map[int]chan uint64) @@ -397,22 +412,145 @@ func (mp *MutationPipeline) ProcessList(ctx context.Context, pipeline *Predicate dataKey := x.DataKey(pipeline.attr, 0) baseKey := string(dataKey[:len(dataKey)-8]) // Avoid repeated conversion - for uid, pl := range postings { + // writeListDataUid emits the forward data-write delta for a single source uid. + // The forward key is one-to-one (each source uid is written + // exactly once), so — exactly as ProcessSingle's writeDataUid — it is the safe + // beneficiary of intra-predicate parallelism: when this predicate is granted + // k>1 workers the source-uid space is partitioned into disjoint contiguous + // ranges and each range runs this on its own goroutine. scratchKey MUST be + // per-worker: the legacy loop reused a single dataKey buffer, which would be a + // data race across goroutines. store is the locked AddDelta on the serial path + // and the lock-free AddDeltaConcurrent wrapper on the parallel path; for a + // given distinct key both write identical bytes and return the same deduped + // list, so the result (data bytes and conflict-key set) is store-order-independent. + // Lang predicates ride the SAME path: all language postings for one entity fold + // into that entity's single data key (one-to-one by source + // entity), so the disjointness invariant holds for them too. + writeListDataUid := func(uid uint64, pl *pb.PostingList, scratchKey []byte, + store func(key string, input *pb.PostingList) (*pb.PostingList, error)) error { if len(pl.Postings) == 0 { - continue + return nil } - binary.BigEndian.PutUint64(dataKey[len(dataKey)-8:], uid) - if newPl, err := mp.txn.AddDelta(baseKey+string(dataKey[len(dataKey)-8:]), pl, info.isUid, true); err != nil { + binary.BigEndian.PutUint64(scratchKey[len(scratchKey)-8:], uid) + key := baseKey + string(scratchKey[len(scratchKey)-8:]) + + newPl, err := store(key, pl) + if err != nil { return err - } else { - if !info.noConflict { - mp.txn.addConflictKeyWithUid(dataKey, newPl, info.hasUpsert, info.noConflict) + } + if !info.noConflict { + mp.txn.addConflictKeyWithUid(scratchKey, newPl, info.hasUpsert, info.noConflict) + } + return nil + } + + // Serial path (budget disabled or a one-worker grant): byte-for-byte the + // pre-budget loop, reusing the single dataKey scratch buffer and the locked + // AddDelta(..., info.isUid, true). + if pipeline.workers <= 1 { + serialStore := func(k string, in *pb.PostingList) (*pb.PostingList, error) { + return mp.txn.AddDelta(k, in, info.isUid, true) + } + for uid, pl := range postings { + if err := writeListDataUid(uid, pl, dataKey, serialStore); err != nil { + return err } } + return nil } - return nil + // Parallel path: split the disjoint forward keys across k + // goroutines. Each worker owns a contiguous, non-overlapping slice of the + // source-uid snapshot, so no two workers ever write the same data key + // (invariant I1) — that disjointness is what makes the lock-free + // AddDeltaConcurrent safe. ProcessReverse / the index / count passes have + // already run serially above; all workers join here (wg.Wait) before + // ProcessList returns, so the legacy MVCC ordering — every delta in place + // before the predicate goroutine closes — is preserved. + // + // concStore reproduces AddDelta(..., info.isUid, true)'s committed bytes + // without taking the global cache lock. Two behaviors must be matched exactly: + // + // - addToList=true: WITHIN one ProcessList call each source uid is written + // once, but a transaction may apply several mutation proposals on the SAME + // *Txn (worker/draft.go RegisterStartTs reuses it, and txn.Update between + // proposals does not clear txn.cache.deltas), so an EARLIER proposal may + // have left a delta for this key. It must be prepended, exactly as the + // serial AddDelta does — otherwise a later proposal silently drops the + // earlier list/lang postings (forward-list data loss and a forward/reverse + // mismatch, since ProcessReverse stays serial and does merge across + // proposals). + // - info.isUid sort/dedup, applied to the merged list. + // + // The prior delta is read from the sharded, per-key-locked deltas map ONLY — + // never (*Deltas).Get/GetBytes, which also read the plain-Go indexMap that is + // only safe under cache.Lock (another predicate's InsertTokenizerIndexes may be + // writing it concurrently). A data key never appears in indexMap, so for this + // key the sharded read returns byte-for-byte what serial AddDelta observes. The + // disjoint-partition guarantee (I1) makes this read-merge-write single-writer + // per key, so it is safe without the global lock. + concStore := func(k string, in *pb.PostingList) (*pb.PostingList, error) { + merged := new(pb.PostingList) + if prevBytes, ok := mp.txn.cache.deltas.deltas.Get(k); ok { + prev := &pb.PostingList{} + if err := proto.Unmarshal(prevBytes, prev); err != nil { + glog.Errorf("Error unmarshalling prior delta for key %x: %v", k, err) + return nil, err + } + merged.Postings = append(merged.Postings, prev.Postings...) + } + merged.Postings = append(merged.Postings, in.Postings...) + if info.isUid { + merged.Postings = SortAndDedupPostings(merged.Postings) + } + if err := mp.txn.AddDeltaConcurrent(k, merged); err != nil { + return nil, err + } + return merged, nil + } + + uidList := make([]uint64, 0, len(postings)) + for uid := range postings { + uidList = append(uidList, uid) + } + if len(uidList) == 0 { + return nil + } + k := pipeline.workers + if k > len(uidList) { + k = len(uidList) + } + width := (len(uidList) + k - 1) / k // ceil(len/k), DivideAndRule-style + + var wg sync.WaitGroup + var once sync.Once + var firstErr error + for w := 0; w < k; w++ { + start := w * width + if start >= len(uidList) { + break + } + end := start + width + if end > len(uidList) { + end = len(uidList) + } + wg.Add(1) + go func(sub []uint64) { + defer wg.Done() + // Per-worker scratch buffer: sharing dataKey across goroutines races. + localKey := x.DataKey(pipeline.attr, 0) + for _, uid := range sub { + if err := writeListDataUid(uid, postings[uid], localKey, + concStore); err != nil { + once.Do(func() { firstErr = err }) + return + } + } + }(uidList[start:end]) + } + wg.Wait() + return firstErr } func findSingleValueInPostingList(pb *pb.PostingList) *pb.Posting { @@ -884,24 +1022,34 @@ func (mp *MutationPipeline) ProcessSingle(ctx context.Context, pipeline *Predica // is removed, mirroring the legacy isSingleUidUpdate explicit DEL. stripSyntheticDel := info.index - for uid, pl := range postings { + // writeDataUid emits the data-write delta for a single source uid. The key + // is one-to-one with no fan-in, so it is the primary (and safest) + // beneficiary of intra-predicate parallelism: when this predicate is granted + // k>1 workers the uid space is partitioned into disjoint contiguous ranges + // and each range runs this on its own goroutine. scratchKey MUST be + // per-worker — the legacy loop reused a single dataKey buffer, which would be + // a data race across goroutines. store is AddDelta on the serial path and the + // lock-free AddDeltaConcurrent on the parallel path; for a given distinct key + // both write identical bytes, so the result is store-order-independent. + writeDataUid := func(uid uint64, pl *pb.PostingList, scratchKey []byte, + store func(string, *pb.PostingList) error) error { // An empty PostingList means nothing was accumulated for this uid (e.g. // a DEL whose value did not match the committed value, or a same-value // SET-after-handleOldDeleteForSingle no-op). Writing an empty delta - // would call AddDelta → setMutation on the cached List with empty bytes, - // which resets the mutable layer's currentEntries to empty for this - // txn's startTs. A subsequent read through the same cached List then - // surfaces an empty data list. CommitToDisk skips empty deltas at the - // badger level, but the in-memory side effect on the cached List has - // already corrupted any concurrent reader holding that List. Skip the - // no-op uid entirely — matches the legacy per-edge path which simply - // does nothing when a DEL value does not match committed. + // would call setMutation on the cached List with empty bytes, which + // resets the mutable layer's currentEntries to empty for this txn's + // startTs. A subsequent read through the same cached List then surfaces + // an empty data list. CommitToDisk skips empty deltas at the badger + // level, but the in-memory side effect on the cached List has already + // corrupted any concurrent reader holding that List. Skip the no-op uid + // entirely — matches the legacy per-edge path which simply does nothing + // when a DEL value does not match committed. if len(pl.Postings) == 0 { - continue + return nil } - binary.BigEndian.PutUint64(dataKey[len(dataKey)-8:], uid) - key := baseKey + string(dataKey[len(dataKey)-8:]) + binary.BigEndian.PutUint64(scratchKey[len(scratchKey)-8:], uid) + key := baseKey + string(scratchKey[len(scratchKey)-8:]) if !info.noConflict { mp.txn.addConflictKey(farm.Fingerprint64([]byte(key))) @@ -928,12 +1076,73 @@ func (mp *MutationPipeline) ProcessSingle(ctx context.Context, pipeline *Predica } } - if _, err := mp.txn.AddDelta(key, writePl, false, false); err != nil { - return err + return store(key, writePl) + } + + // Serial path (budget disabled or a one-worker grant): byte-for-byte the + // pre-budget loop, reusing the single dataKey scratch buffer and the locked + // AddDelta. + if pipeline.workers <= 1 { + for uid, pl := range postings { + if err := writeDataUid(uid, pl, dataKey, + func(k string, wpl *pb.PostingList) error { + _, err := mp.txn.AddDelta(k, wpl, false, false) + return err + }); err != nil { + return err + } } + return nil } - return nil + // Parallel path: split the disjoint keys across k goroutines. Each + // worker owns a contiguous, non-overlapping slice of the uid snapshot, so no + // two workers ever write the same data key (invariant I1) — that disjointness + // is what makes the lock-free AddDeltaConcurrent safe. All workers join here + // (via wg.Wait) before ProcessSingle returns, so the MVCC ordering of the + // legacy path — every delta in place before the predicate goroutine closes — + // is preserved. + uidList := make([]uint64, 0, len(postings)) + for uid := range postings { + uidList = append(uidList, uid) + } + if len(uidList) == 0 { + return nil + } + k := pipeline.workers + if k > len(uidList) { + k = len(uidList) + } + width := (len(uidList) + k - 1) / k // ceil(len/k), DivideAndRule-style + + var wg sync.WaitGroup + var once sync.Once + var firstErr error + for w := 0; w < k; w++ { + start := w * width + if start >= len(uidList) { + break + } + end := start + width + if end > len(uidList) { + end = len(uidList) + } + wg.Add(1) + go func(sub []uint64) { + defer wg.Done() + // Per-worker scratch buffer: sharing dataKey across goroutines races. + localKey := x.DataKey(pipeline.attr, 0) + for _, uid := range sub { + if err := writeDataUid(uid, postings[uid], localKey, + mp.txn.AddDeltaConcurrent); err != nil { + once.Do(func() { firstErr = err }) + return + } + } + }(uidList[start:end]) + } + wg.Wait() + return firstErr } func runMutation(ctx context.Context, edge *pb.DirectedEdge, txn *Txn) error { @@ -1142,6 +1351,167 @@ func ValidateAndConvert(edge *pb.DirectedEdge, su *pb.SchemaUpdate) error { return nil } +// allocateWorkers distributes a fixed global goroutine budget across the +// predicates present in one mutation batch, proportionally to each predicate's +// edge count, so a hot/dominant predicate is granted most of the budget while +// tiny predicates get one worker each. The grant feeds back into each +// PredicatePipeline.workers and controls the within-predicate split of the +// merge-light passes (data-write, index tokenization). +// +// edgeCounts maps predicate attr -> number of (non-star-delete) edges for that +// predicate in the batch; budget is x.WorkerConfig.MutationsPipelineGoroutines. +// Returns nil when the budget is disabled (budget < 2) or there are no +// predicates. A nil result — and any attr missing from the result — is treated +// by callers as workers=0, i.e. the legacy one-goroutine-per-predicate path. +// +// Apportionment is Hamilton / largest-remainder with a floor of 1: +// - If P := len(edgeCounts) >= budget, the batch already saturates the budget +// with one worker each, so every predicate gets exactly 1. +// - Otherwise (P < budget), with E := sum(e_p): +// quota_p = budget * e_p / E (real-valued ideal share) +// workers_p = max(1, floor(quota_p)) (floor, but never below 1) +// deficit = budget - sum(workers_p) +// If deficit > 0, hand the leftover units out one at a time to the predicates +// with the largest fractional remainder (quota_p - floor(quota_p)). If +// deficit < 0 (the floor-of-1 pushed the sum over budget because many tiny +// predicates rounded up from 0), reclaim units one at a time from predicates +// with workers_p > 1, smallest remainder first. Ties broken by attr string +// (ascending) for determinism. +// +// Invariants: every workers_p >= 1; no predicate exceeds budget; and when +// P < budget, sum(workers_p) == budget. +func allocateWorkers(edgeCounts map[string]int, budget int) map[string]int { + P := len(edgeCounts) + if budget < 2 || P == 0 { + return nil + } + + // Deterministic predicate order so the remainder tie-break is stable. + attrs := make([]string, 0, P) + total := 0 + for attr, c := range edgeCounts { + attrs = append(attrs, attr) + total += c + } + sort.Strings(attrs) + + out := make(map[string]int, P) + + // Batch saturates (or oversaturates) the budget: one worker each. This + // documents the apportionment edge case where there are at least as many + // predicates as the budget — there is nothing left to split with. + if P >= budget || total == 0 { + for _, attr := range attrs { + out[attr] = 1 + } + return out + } + + type rem struct { + attr string + remainder float64 + } + rems := make([]rem, 0, P) + assigned := 0 + for _, attr := range attrs { + quota := float64(budget) * float64(edgeCounts[attr]) / float64(total) + w := int(math.Floor(quota)) + if w < 1 { + w = 1 + } + out[attr] = w + assigned += w + rems = append(rems, rem{attr: attr, remainder: quota - math.Floor(quota)}) + } + + deficit := budget - assigned + switch { + case deficit > 0: + // Distribute leftover units to the largest fractional remainders first. + // deficit < P here, so there are always enough predicates to receive them. + sort.SliceStable(rems, func(i, j int) bool { + if rems[i].remainder != rems[j].remainder { + return rems[i].remainder > rems[j].remainder + } + return rems[i].attr < rems[j].attr + }) + for i := 0; i < len(rems) && deficit > 0; i++ { + out[rems[i].attr]++ + deficit-- + } + case deficit < 0: + // The floor-of-1 oversubscribed the budget. Reclaim units from predicates + // with more than one worker, smallest remainder first, so the hot + // predicate keeps the most. Since P < budget, the excess above the + // per-predicate floor always exceeds the deficit, so this terminates with + // sum == budget. + sort.SliceStable(rems, func(i, j int) bool { + if rems[i].remainder != rems[j].remainder { + return rems[i].remainder < rems[j].remainder + } + return rems[i].attr < rems[j].attr + }) + for deficit < 0 { + progressed := false + for i := 0; i < len(rems) && deficit < 0; i++ { + if out[rems[i].attr] > 1 { + out[rems[i].attr]-- + deficit++ + progressed = true + } + } + if !progressed { + // Defensive: cannot happen when P < budget. Avoid an infinite loop. + break + } + } + } + + return out +} + +// mutationsPipelineGoroutinesAuto is the sentinel value of +// x.WorkerConfig.MutationsPipelineGoroutines that selects AUTO mode: instead of a +// fixed absolute budget, the per-batch budget is derived at runtime from the +// machine and the batch size by autoBudget. 0 still disables the budget and any +// N>0 is still an absolute budget. +const mutationsPipelineGoroutinesAuto = -1 + +// autoBudget derives the per-batch intra-predicate goroutine budget at runtime +// (AUTO mode, mutations-pipeline-goroutines=-1). It is a pure, deterministic +// function of its inputs — independent of the real GOMAXPROCS — so the formula +// can be unit-tested directly. +// +// machineCap = max(1, round(gomaxprocs * fraction)) +// workCap = max(1, totalEdges / minEdgesPerWorker) +// budget = min(machineCap, workCap) +// +// fraction is < 1 by default (0.5) on purpose: the apply phase is serial across +// transactions, so a single apply effectively owns the whole box, and we want to +// leave headroom for Badger compaction, concurrent queries, and the GC rather +// than pin every core to one mutation. workCap mirrors x.DivideAndRule's 256-edge +// rule — there is no point spinning up a worker per <256 edges. A computed budget +// of 1 (or anything < 2) feeds into allocateWorkers, which returns nil for +// budget < 2, so the caller transparently falls back to the legacy +// one-goroutine-per-predicate path. +func autoBudget(gomaxprocs, totalEdges int, fraction float64, minEdgesPerWorker int) int { + machineCap := int(math.Round(float64(gomaxprocs) * fraction)) + if machineCap < 1 { + machineCap = 1 + } + if minEdgesPerWorker < 1 { + minEdgesPerWorker = 1 // defensive: avoid divide-by-zero on misconfig + } + workCap := totalEdges / minEdgesPerWorker + if workCap < 1 { + workCap = 1 + } + if machineCap < workCap { + return machineCap + } + return workCap +} + // Process is the entry point for the mutation pipeline. It fans out one goroutine per // predicate so mutations for different predicates are parallelized, while all edges // for the same predicate are serialized through a single channel to a single goroutine. @@ -1172,6 +1542,35 @@ func (mp *MutationPipeline) Process(ctx context.Context, edges []*pb.DirectedEdg // build a star-delete won't generate the index Del entries for the prior // value, leaving stale uids in the index permanently. ctx = schema.GetWriteContext(ctx) + + // Pre-pass: count edges per predicate so the global goroutine budget can be + // apportioned proportionally before any predicate goroutine starts. Star-delete + // edges are excluded — they are handled inline below and never reach a + // predicate goroutine. allocateWorkers returns nil when the budget is disabled + // (budget < 2), in which case every pred.workers stays 0 and the pipeline + // behaves exactly as the legacy one-goroutine-per-predicate path. + edgeCounts := make(map[string]int) + totalEdges := 0 + for _, edge := range edges { + if edge.Op == pb.DirectedEdge_DEL && string(edge.Value) == x.Star { + continue + } + edgeCounts[edge.Attr]++ + totalEdges++ + } + // Resolve the budget. 0 (or any value < 2) disables the pipeline budget; N>0 is + // an absolute budget; the -1 sentinel selects AUTO, where the budget is derived + // once per call from GOMAXPROCS and the batch size before apportionment. AUTO + // only chooses the integer fed into allocateWorkers — the apportionment and the + // whole downstream path are identical to a fixed budget of that value. + budget := x.WorkerConfig.MutationsPipelineGoroutines + if budget == mutationsPipelineGoroutinesAuto { + budget = autoBudget(runtime.GOMAXPROCS(0), totalEdges, + x.WorkerConfig.MutationsPipelineGoroutinesFraction, + x.WorkerConfig.MutationsPipelineMinEdgesPerWorker) + } + workerAlloc := allocateWorkers(edgeCounts, budget) + predicates := map[string]*PredicatePipeline{} var wg sync.WaitGroup numWg := 0 @@ -1194,9 +1593,10 @@ sendLoop: pred, ok := predicates[edge.Attr] if !ok { pred = &PredicatePipeline{ - attr: edge.Attr, - edges: make(chan *pb.DirectedEdge, 1000), - wg: &wg, + attr: edge.Attr, + edges: make(chan *pb.DirectedEdge, 1000), + wg: &wg, + workers: workerAlloc[edge.Attr], // 0 when the budget is disabled } predicates[edge.Attr] = pred wg.Add(1) diff --git a/posting/oracle.go b/posting/oracle.go index 0454dc960a7..b26154e03db 100644 --- a/posting/oracle.go +++ b/posting/oracle.go @@ -128,6 +128,43 @@ func (txn *Txn) AddDelta(key string, input *pb.PostingList, doSortAndDedup bool, return pl, nil } +// AddDeltaConcurrent is the lock-free sibling of AddDelta for the merge-light +// data-write pass (ProcessSingle), which writes one-to-one keys with +// the addToList=false, doSortAndDedup=false shape. Because each such key is +// written by exactly one worker (disjoint uid sub-ranges within a predicate, and +// distinct key prefixes across predicates), there is no read-modify-write on the +// delta map for a given key, so the global txn.cache.Lock() that AddDelta takes +// can be skipped: +// +// - proto.Marshal runs outside any cache lock — this is the parallel win. +// - txn.cache.deltas.AddToDeltas stores into the sharded, per-key-locked delta +// map; distinct keys hit distinct shards, so concurrent stores are safe. +// - guarantee (c) of AddDelta is preserved: if a List for this key is already +// cached, the delta is pushed into it via setMutation (which self-locks the +// List) so any holder of that List observes the write. plists is read under +// cache.RLock to stay consistent with cache.Lock writers elsewhere. +// +// When no List is cached, skipping setMutation is correct: a later reader +// rebuilds the List via getInternal and re-applies this delta from lc.deltas. +// Callers needing addToList or sort/dedup must keep using the locked AddDelta. +func (txn *Txn) AddDeltaConcurrent(key string, input *pb.PostingList) error { + newPl, err := proto.Marshal(input) + if err != nil { + glog.Errorf("Error marshalling posting list: %v", err) + return err + } + + txn.cache.deltas.AddToDeltas(key, newPl) + + txn.cache.RLock() + list, listOk := txn.cache.plists[key] + txn.cache.RUnlock() + if listOk { + list.setMutation(txn.StartTs, newPl) + } + return nil +} + func (txn *Txn) LockCache() { txn.cache.Lock() } diff --git a/posting/pipeline_budget_test.go b/posting/pipeline_budget_test.go new file mode 100644 index 00000000000..c18240dd73d --- /dev/null +++ b/posting/pipeline_budget_test.go @@ -0,0 +1,838 @@ +/* + * SPDX-FileCopyrightText: © 2017-2025 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package posting + +// Tests and benchmark for the proportional intra-predicate goroutine budget +// (mutations-pipeline-goroutines). The budget lets a hot/dominant predicate use +// more than one goroutine for its merge-light passes (data-write, index +// tokenization) while staying byte-identical to the legacy one-goroutine path. + +import ( + "context" + "fmt" + "sort" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/dgraph-io/dgraph/v25/protos/pb" + "github.com/dgraph-io/dgraph/v25/schema" + "github.com/dgraph-io/dgraph/v25/x" +) + +// Timestamp bands for budget tests (kept clear of the existing pipeline_test.go +// bands documented there, which top out at 2599): +// Byte-identical, budget=0: 5000-5099 +// Byte-identical, budget=30: 5100-5199 +// Auto byte-identical: 5200-5299 +// ProcessList byte-identical: 5300-5399 +// ProcessList cross-proposal: 5400-5499 + +// buildSkewedBatch produces a deterministic, predicate-skewed mutation batch: +// one dominant scalar predicate (nameAttr) with nameCount edges, plus two small +// predicates (deptAttr scalar, friendAttr uid). Entities live in disjoint uid +// ranges per predicate. name values repeat across a small set so the index has +// multi-uid buckets; friend targets repeat so reverse lists have multiple +// sources. Reused by both the byte-identical test and the benchmark. +// cloneEdges returns a fresh copy of each edge struct. The pipeline mutates an +// edge's ValueId in place (setting the scalar-value marker for scalar postings), +// which is fine in production where every transaction gets fresh edges from the +// proto, but breaks tests/benchmarks that feed the same slice to Process twice. +func cloneEdges(in []*pb.DirectedEdge) []*pb.DirectedEdge { + out := make([]*pb.DirectedEdge, len(in)) + for i, e := range in { + c := *e + out[i] = &c + } + return out +} + +func buildSkewedBatch(nameAttr, deptAttr, friendAttr string, nameCount int) []*pb.DirectedEdge { + const ( + nameBase = uint64(1_000_000) + deptBase = uint64(2_000_000) + friendBase = uint64(3_000_000) + targetBase = uint64(4_000_000) + smallCount = 10 + nameDistinct = 50 + deptDistinct = 3 + friendTargets = 4 + ) + edges := make([]*pb.DirectedEdge, 0, nameCount+2*smallCount) + for i := 0; i < nameCount; i++ { + edges = append(edges, &pb.DirectedEdge{ + Entity: nameBase + uint64(i), + Attr: nameAttr, + Value: []byte(fmt.Sprintf("name%d", i%nameDistinct)), + ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_SET, + }) + } + for i := 0; i < smallCount; i++ { + edges = append(edges, &pb.DirectedEdge{ + Entity: deptBase + uint64(i), + Attr: deptAttr, + Value: []byte(fmt.Sprintf("dept%d", i%deptDistinct)), + ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_SET, + }) + } + for i := 0; i < smallCount; i++ { + edges = append(edges, &pb.DirectedEdge{ + Entity: friendBase + uint64(i), + Attr: friendAttr, + ValueId: targetBase + uint64(i%friendTargets), + Op: pb.DirectedEdge_SET, + }) + } + return edges +} + +// buildLangEdges produces langs language postings for each of n distinct +// entities of a `string @lang` predicate. Each (entity, lang) pair is a distinct +// posting (Uid = fingerprint(lang)) but all fold into the entity's single +// forward data key — so the lang forward-write is one-to-one by +// source entity, the disjointness the ProcessList parallel split relies on. +func buildLangEdges(attr string, n, langs int) []*pb.DirectedEdge { + const base = uint64(5_000_000) + langCodes := []string{"en", "de", "fr", "es", "it"} + if langs > len(langCodes) { + langs = len(langCodes) + } + edges := make([]*pb.DirectedEdge, 0, n*langs) + for i := 0; i < n; i++ { + for l := 0; l < langs; l++ { + edges = append(edges, &pb.DirectedEdge{ + Entity: base + uint64(i), + Attr: attr, + Value: []byte(fmt.Sprintf("bio%d-%s", i, langCodes[l])), + ValueType: pb.Posting_STRING, + Lang: langCodes[l], + Op: pb.DirectedEdge_SET, + }) + } + } + return edges +} + +func sortedUint64(in []uint64) []uint64 { + out := append([]uint64(nil), in...) + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +// runBudgetBatch sets the global budget, runs the batch through a fresh pipeline +// txn, snapshots the conflict-key set produced (before commit, which does not +// clear it), commits to Badger, and returns the snapshot. The budget is restored +// on return so other tests still see the disabled (zero-value) default. +func runBudgetBatch(t *testing.T, budget int, startTs, commitTs uint64, + edges []*pb.DirectedEdge) map[uint64]struct{} { + t.Helper() + old := x.WorkerConfig.MutationsPipelineGoroutines + x.WorkerConfig.MutationsPipelineGoroutines = budget + defer func() { x.WorkerConfig.MutationsPipelineGoroutines = old }() + + txn := Oracle().RegisterStartTs(startTs) + mp := NewMutationPipeline(txn) + require.NoError(t, mp.Process(context.Background(), edges)) + + txn.Lock() + conflicts := make(map[uint64]struct{}, len(txn.conflicts)) + for k := range txn.conflicts { + conflicts[k] = struct{}{} + } + txn.Unlock() + + commitPipelineTxn(t, txn, commitTs) + return conflicts +} + +// runTwoProposalBatch runs two mutation batches as two SEPARATE Process calls on +// the SAME registered transaction, modeling a multi-statement (CommitNow=false) +// transaction: worker/draft.go's RegisterStartTs reuses the *Txn for a start ts, +// and txn.Update between proposals does not clear txn.cache.deltas, so a list/lang +// predicate's forward deltas accumulate across proposals via AddDelta's +// addToList=true. The single-Process byte-identical tests never exercise this +// cross-proposal merge. Snapshots the conflict-key set, commits, restores the +// budget. +func runTwoProposalBatch(t *testing.T, budget int, startTs, commitTs uint64, + batch1, batch2 []*pb.DirectedEdge) map[uint64]struct{} { + t.Helper() + old := x.WorkerConfig.MutationsPipelineGoroutines + x.WorkerConfig.MutationsPipelineGoroutines = budget + defer func() { x.WorkerConfig.MutationsPipelineGoroutines = old }() + + txn := Oracle().RegisterStartTs(startTs) + + mp1 := NewMutationPipeline(txn) + require.NoError(t, mp1.Process(context.Background(), batch1)) + txn.Update() // mirrors draft.go's `defer txn.Update()` per applyMutations. + + mp2 := NewMutationPipeline(txn) + require.NoError(t, mp2.Process(context.Background(), batch2)) + + txn.Lock() + conflicts := make(map[uint64]struct{}, len(txn.conflicts)) + for k := range txn.conflicts { + conflicts[k] = struct{}{} + } + txn.Unlock() + + commitPipelineTxn(t, txn, commitTs) + return conflicts +} + +// TestProcessListCrossProposalByteIdentical is the regression test for the +// ProcessList parallel-write addToList bug: the parallel concStore must replicate +// AddDelta's addToList=true so a list/lang forward delta accumulated by an EARLIER +// proposal on the same txn is preserved by a LATER proposal. Two proposals SET the +// same source uids to a second target (and the same lang entities in a second +// language). The serial path appends across proposals ([A,B]); a parallel path +// that overwrote would drop A. Asserts the budget>1 result equals the serial +// baseline AND that the baseline itself carries both proposals' postings (so the +// test fails on the buggy overwrite, not merely on disagreement). Run under -race. +func TestProcessListCrossProposalByteIdentical(t *testing.T) { + const ( + linkSrc = 60 // distinct source uids touched in both proposals + bioEntities = 40 // distinct @lang entities touched in both proposals + ) + + linkAttr := x.AttrInRootNamespace("link") + bioAttr := x.AttrInRootNamespace("bio") + + const ( + srcBase = uint64(1_000_000) + bioBase = uint64(5_000_000) + targetA = uint64(9_000_001) + targetB = uint64(9_000_002) + ) + + buildLink := func(target uint64) []*pb.DirectedEdge { + edges := make([]*pb.DirectedEdge, 0, linkSrc) + for i := 0; i < linkSrc; i++ { + edges = append(edges, &pb.DirectedEdge{ + Entity: srcBase + uint64(i), + Attr: linkAttr, + ValueId: target, + Op: pb.DirectedEdge_SET, + }) + } + return edges + } + buildBio := func(lang string) []*pb.DirectedEdge { + edges := make([]*pb.DirectedEdge, 0, bioEntities) + for i := 0; i < bioEntities; i++ { + edges = append(edges, &pb.DirectedEdge{ + Entity: bioBase + uint64(i), + Attr: bioAttr, + Value: []byte(fmt.Sprintf("%s%d", lang, i)), + ValueType: pb.Posting_STRING, + Lang: lang, + Op: pb.DirectedEdge_SET, + }) + } + return edges + } + + batch1 := append(buildLink(targetA), buildBio("en")...) + batch2 := append(buildLink(targetB), buildBio("de")...) + + // Sanity: budget=30 grants BOTH ProcessList predicates more than one worker in + // each proposal (link=60, bio=40 per proposal), so the parallel concStore path + // is genuinely taken on the second proposal where the cross-proposal merge runs. + alloc := allocateWorkers(map[string]int{linkAttr: linkSrc, bioAttr: bioEntities}, 30) + require.Greater(t, alloc[linkAttr], 1, "test must exercise multi-worker [uid] predicate") + require.Greater(t, alloc[bioAttr], 1, "test must exercise multi-worker @lang predicate") + + schemaBytes := []byte(` + link: [uid] @reverse . + bio: string @lang . + `) + + langVals := func(t *testing.T, attr string, entity, readTs uint64) map[string]string { + t.Helper() + l, err := GetNoStore(x.DataKey(attr, entity), readTs) + require.NoError(t, err) + out := map[string]string{} + require.NoError(t, l.Iterate(readTs, 0, func(p *pb.Posting) error { + out[string(p.LangTag)] = string(p.Value) + return nil + })) + return out + } + + type result struct { + fwd map[uint64][]uint64 + rev map[uint64][]uint64 + lang map[uint64]map[string]string + } + + collect := func(readTs uint64) result { + r := result{ + fwd: map[uint64][]uint64{}, + rev: map[uint64][]uint64{}, + lang: map[uint64]map[string]string{}, + } + for i := 0; i < linkSrc; i++ { + src := srcBase + uint64(i) + r.fwd[src] = sortedUint64(forwardUids(t, linkAttr, src, readTs)) + } + for _, tgt := range []uint64{targetA, targetB} { + r.rev[tgt] = sortedUint64(reverseUids(t, linkAttr, tgt, readTs)) + } + for i := 0; i < bioEntities; i++ { + e := bioBase + uint64(i) + r.lang[e] = langVals(t, bioAttr, e, readTs) + } + return r + } + + // Run 1 — budget disabled (serial cross-proposal addToList merge). + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + require.NoError(t, schema.ParseBytes(schemaBytes, 1)) + conflicts0 := runTwoProposalBatch(t, 0, 5400, 5401, cloneEdges(batch1), cloneEdges(batch2)) + res0 := collect(5402) + + // Run 2 — budget=30 (parallel concStore must reproduce the merge). + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + require.NoError(t, schema.ParseBytes(schemaBytes, 1)) + conflicts30 := runTwoProposalBatch(t, 30, 5410, 5411, cloneEdges(batch1), cloneEdges(batch2)) + res30 := collect(5412) + + // The serial baseline must carry BOTH proposals' postings — otherwise the + // equality assertions below could pass on a shared (wrong) overwrite result. + wantFwd := sortedUint64([]uint64{targetA, targetB}) + for i := 0; i < linkSrc; i++ { + require.Equal(t, wantFwd, res0.fwd[srcBase+uint64(i)], + "serial forward list must append across proposals") + } + for i := 0; i < bioEntities; i++ { + require.Equal(t, map[string]string{ + "en": fmt.Sprintf("en%d", i), + "de": fmt.Sprintf("de%d", i), + }, res0.lang[bioBase+uint64(i)], "serial lang values must carry both proposals") + } + + require.Equal(t, res0.fwd, res30.fwd, "forward uid lists must match across budgets") + require.Equal(t, res0.rev, res30.rev, "reverse lists must match across budgets") + require.Equal(t, res0.lang, res30.lang, "lang values must match across budgets") + require.Equal(t, conflicts0, conflicts30, "conflict-key sets must be identical") +} + +// TestAllocateWorkers exercises the pure apportionment helper: floor of 1, the +// budget-disabled and saturated edge cases, the largest-remainder distribution, +// the floor-of-1 reclaim, and determinism — no cluster needed. +func TestAllocateWorkers(t *testing.T) { + // Disabled budget (< 2) and empty input return nil → callers treat every + // predicate as workers=0 (legacy path). + require.Nil(t, allocateWorkers(map[string]int{"a": 5, "b": 3}, 0)) + require.Nil(t, allocateWorkers(map[string]int{"a": 5, "b": 3}, 1)) + require.Nil(t, allocateWorkers(map[string]int{}, 30)) + + // P >= budget: one worker each, nothing left to split with. + got := allocateWorkers(map[string]int{"a": 100, "b": 50, "c": 1}, 2) + require.Equal(t, map[string]int{"a": 1, "b": 1, "c": 1}, got) + + // Even split: budget 4 across two equal predicates → 2 each. + require.Equal(t, map[string]int{"a": 2, "b": 2}, + allocateWorkers(map[string]int{"a": 1, "b": 1}, 4)) + + // Largest-remainder with deterministic tie-break: budget 3, two equal + // predicates → quota 1.5 each, the single leftover goes to the + // lexicographically smaller attr. + require.Equal(t, map[string]int{"a": 2, "b": 1}, + allocateWorkers(map[string]int{"a": 1, "b": 1}, 3)) + + // Skewed: one dominant predicate takes most of the budget; tiny ones get 1. + // name=300, dept=10, friend=10, total=320, budget=30: + // name quota 28.125 -> 28, dept/friend quota 0.9375 -> 1 each; sum == 30. + skewed := allocateWorkers(map[string]int{"name": 300, "dept": 10, "friend": 10}, 30) + require.Equal(t, map[string]int{"name": 28, "dept": 1, "friend": 1}, skewed) + + // Floor-of-1 reclaim: a huge dominant plus 25 single-edge predicates + // oversubscribes (28 ones bump the floors over budget); the overshoot is + // reclaimed from the only predicate with >1 worker (the dominant). + counts := map[string]int{"hot": 10000} + for i := 0; i < 25; i++ { + counts[fmt.Sprintf("tiny%02d", i)] = 1 + } + reclaim := allocateWorkers(counts, 30) + sum := 0 + for attr, w := range reclaim { + require.GreaterOrEqual(t, w, 1, "every predicate must get >= 1 worker (%s)", attr) + sum += w + } + require.Equal(t, 30, sum, "sum must equal budget when P < budget") + require.Equal(t, 5, reclaim["hot"], "dominant keeps the remaining budget after the floor") + for i := 0; i < 25; i++ { + require.Equal(t, 1, reclaim[fmt.Sprintf("tiny%02d", i)]) + } + + // Determinism: the same input yields the same output across runs. + require.Equal(t, skewed, + allocateWorkers(map[string]int{"friend": 10, "name": 300, "dept": 10}, 30)) +} + +// TestPipelineBudgetByteIdentical proves the proportional budget produces results +// byte-identical to the legacy one-goroutine-per-predicate path: the same skewed +// batch run with budget=0 and budget=30 must yield identical committed data +// values, index buckets, forward/reverse lists, AND identical conflict-key sets. +// The dominant `name` predicate is granted >1 worker so both the parallel +// data-write and the parallel index tokenization (numGo=k) are exercised. Run it +// under -race to also cover I1/I2/I3. +func TestPipelineBudgetByteIdentical(t *testing.T) { + const nameCount = 300 + + nameAttr := x.AttrInRootNamespace("name") + deptAttr := x.AttrInRootNamespace("dept") + friendAttr := x.AttrInRootNamespace("friend") + edges := buildSkewedBatch(nameAttr, deptAttr, friendAttr, nameCount) + + // Sanity: budget=30 actually grants the dominant predicate more than one + // worker, so the parallel path under test is genuinely taken. + alloc := allocateWorkers(map[string]int{nameAttr: nameCount, deptAttr: 10, friendAttr: 10}, 30) + require.Greater(t, alloc[nameAttr], 1, "test must exercise multi-worker dominant predicate") + + schemaBytes := []byte(` + name: string @index(exact) . + dept: string @index(exact) . + friend: uid @reverse . + `) + + type result struct { + nameVal map[uint64]string + nameIdx map[string][]uint64 + deptIdx map[string][]uint64 + fwd map[uint64][]uint64 + rev map[uint64][]uint64 + } + + collect := func(readTs uint64) result { + r := result{ + nameVal: map[uint64]string{}, + nameIdx: map[string][]uint64{}, + deptIdx: map[string][]uint64{}, + fwd: map[uint64][]uint64{}, + rev: map[uint64][]uint64{}, + } + for i := 0; i < nameCount; i++ { + e := uint64(1_000_000) + uint64(i) + r.nameVal[e] = scalarValue(t, nameAttr, e, readTs) + } + for v := 0; v < 50; v++ { + val := fmt.Sprintf("name%d", v) + r.nameIdx[val] = sortedUint64(indexUidsForVal(t, nameAttr, val, readTs)) + } + for v := 0; v < 3; v++ { + val := fmt.Sprintf("dept%d", v) + r.deptIdx[val] = sortedUint64(indexUidsForVal(t, deptAttr, val, readTs)) + } + for i := 0; i < 10; i++ { + src := uint64(3_000_000) + uint64(i) + r.fwd[src] = sortedUint64(forwardUids(t, friendAttr, src, readTs)) + } + for tgt := 0; tgt < 4; tgt++ { + target := uint64(4_000_000) + uint64(tgt) + r.rev[target] = sortedUint64(reverseUids(t, friendAttr, target, readTs)) + } + return r + } + + // Run 1 — budget disabled (legacy one-goroutine-per-predicate). + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + require.NoError(t, schema.ParseBytes(schemaBytes, 1)) + conflicts0 := runBudgetBatch(t, 0, 5000, 5001, cloneEdges(edges)) + res0 := collect(5002) + + // Run 2 — budget=30 (dominant predicate parallelized). + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + require.NoError(t, schema.ParseBytes(schemaBytes, 1)) + conflicts30 := runBudgetBatch(t, 30, 5100, 5101, cloneEdges(edges)) + res30 := collect(5102) + + require.Equal(t, res0.nameVal, res30.nameVal, "scalar data values must match") + require.Equal(t, res0.nameIdx, res30.nameIdx, "name index buckets must match") + require.Equal(t, res0.deptIdx, res30.deptIdx, "dept index buckets must match") + require.Equal(t, res0.fwd, res30.fwd, "forward uid lists must match") + require.Equal(t, res0.rev, res30.rev, "reverse lists must match") + require.Equal(t, conflicts0, conflicts30, "conflict-key sets must be identical") +} + +// TestProcessListBudgetByteIdentical is the ProcessList counterpart of +// TestPipelineBudgetByteIdentical. It proves the forward data-write split added to +// ProcessList is byte-identical to the serial path for the predicates that route +// THROUGH ProcessList — which the existing budget tests do NOT cover: their +// `friend: uid @reverse` is a scalar uid predicate (no @lang, not a list) so it +// goes to ProcessSingle. Here `link: [uid] @reverse` (info.isList → ProcessList, +// info.isUid → the sort/dedup concStore branch) is the dominant predicate, plus a +// small `bio: string @lang` predicate (su.Lang → ProcessList, non-uid branch) so +// the lang forward-write split is exercised too. The same batch run with budget=0 +// (serial) and budget=30 (both predicates parallelized) must yield identical +// forward uid lists, reverse lists, lang values, AND conflict-key sets. Run under +// -race to also cover the disjoint-key/per-worker-buffer invariants. +func TestProcessListBudgetByteIdentical(t *testing.T) { + const ( + linkCount = 300 // distinct source uids → 5 hot reverse targets + hotTargets = 5 + bioEntities = 40 + bioLangs = 3 // 40*3 = 120 lang postings + ) + + linkAttr := x.AttrInRootNamespace("link") + bioAttr := x.AttrInRootNamespace("bio") + + edges := append(buildReverseEdges(linkAttr, linkCount, hotTargets), + buildLangEdges(bioAttr, bioEntities, bioLangs)...) + + // Sanity: budget=30 grants BOTH predicates more than one worker, so the + // multi-worker ProcessList path under test is genuinely taken for the [uid] + // case AND the lang case. + alloc := allocateWorkers(map[string]int{linkAttr: linkCount, bioAttr: bioEntities * bioLangs}, 30) + require.Greater(t, alloc[linkAttr], 1, "test must exercise multi-worker [uid] @reverse predicate") + require.Greater(t, alloc[bioAttr], 1, "test must exercise multi-worker @lang predicate") + + schemaBytes := []byte(` + link: [uid] @reverse . + bio: string @lang . + `) + + // langVals reads every language posting for a @lang entity's forward data key + // as a langTag→value map, so both runs can be compared without depending on + // posting order. + langVals := func(t *testing.T, attr string, entity, readTs uint64) map[string]string { + t.Helper() + l, err := GetNoStore(x.DataKey(attr, entity), readTs) + require.NoError(t, err) + out := map[string]string{} + require.NoError(t, l.Iterate(readTs, 0, func(p *pb.Posting) error { + out[string(p.LangTag)] = string(p.Value) + return nil + })) + return out + } + + type result struct { + fwd map[uint64][]uint64 + rev map[uint64][]uint64 + lang map[uint64]map[string]string + } + + collect := func(readTs uint64) result { + r := result{ + fwd: map[uint64][]uint64{}, + rev: map[uint64][]uint64{}, + lang: map[uint64]map[string]string{}, + } + for i := 0; i < linkCount; i++ { + src := uint64(1_000_000) + uint64(i) + r.fwd[src] = sortedUint64(forwardUids(t, linkAttr, src, readTs)) + } + for tgt := 0; tgt < hotTargets; tgt++ { + target := uint64(9_000_000) + uint64(tgt) + r.rev[target] = sortedUint64(reverseUids(t, linkAttr, target, readTs)) + } + for i := 0; i < bioEntities; i++ { + e := uint64(5_000_000) + uint64(i) + r.lang[e] = langVals(t, bioAttr, e, readTs) + } + return r + } + + // Run 1 — budget disabled (serial ProcessList forward write). + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + require.NoError(t, schema.ParseBytes(schemaBytes, 1)) + conflicts0 := runBudgetBatch(t, 0, 5300, 5301, cloneEdges(edges)) + res0 := collect(5302) + + // Run 2 — budget=30 (both ProcessList predicates parallelized). + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + require.NoError(t, schema.ParseBytes(schemaBytes, 1)) + conflicts30 := runBudgetBatch(t, 30, 5310, 5311, cloneEdges(edges)) + res30 := collect(5312) + + require.Equal(t, res0.fwd, res30.fwd, "forward uid lists must match") + require.Equal(t, res0.rev, res30.rev, "reverse lists must match") + require.Equal(t, res0.lang, res30.lang, "lang values must match") + require.Equal(t, conflicts0, conflicts30, "conflict-key sets must be identical") +} + +// TestAutoBudget exercises the pure AUTO-mode derivation directly, independent of +// the real GOMAXPROCS: machineCap vs workCap interaction, rounding, the budget<2 +// fallback, the divide-by-zero guard on a misconfigured min-edges, and +// determinism — no cluster needed. +func TestAutoBudget(t *testing.T) { + // machineCap dominates: round(96*0.5)=48 < 1_000_000/256=3906. + require.Equal(t, 48, autoBudget(96, 1_000_000, 0.5, 256)) + + // workCap dominates: 2560/256=10 < round(96*0.5)=48. + require.Equal(t, 10, autoBudget(96, 2560, 0.5, 256)) + + // Rounding is half away from zero: round(3*0.5)=round(1.5)=2. + require.Equal(t, 2, autoBudget(3, 1_000_000, 0.5, 256)) + + // budget<2 fallback: round(1*0.5)=1 and 100/256 clamps to 1, so min==1 → the + // caller routes through allocateWorkers, which returns nil for budget<2 (legacy). + require.Equal(t, 1, autoBudget(1, 100, 0.5, 256)) + + // Divide-by-zero guard: a misconfigured minEdgesPerWorker of 0 must not panic + // and clamps to 1, so workCap==totalEdges and machineCap (48) wins here. + require.Equal(t, 48, autoBudget(96, 2560, 0.5, 0)) + + // Determinism: identical inputs yield identical outputs. + require.Equal(t, autoBudget(96, 2560, 0.5, 256), autoBudget(96, 2560, 0.5, 256)) +} + +// runAutoBudgetBatch is runBudgetBatch's AUTO-mode sibling: it enables AUTO +// (MutationsPipelineGoroutines=-1) with the given fraction / min-edges, runs the +// batch through a fresh pipeline txn, snapshots the conflict-key set, commits, and +// restores all three config fields on return. +func runAutoBudgetBatch(t *testing.T, fraction float64, minEdges int, + startTs, commitTs uint64, edges []*pb.DirectedEdge) map[uint64]struct{} { + t.Helper() + oldBudget := x.WorkerConfig.MutationsPipelineGoroutines + oldFraction := x.WorkerConfig.MutationsPipelineGoroutinesFraction + oldMinEdges := x.WorkerConfig.MutationsPipelineMinEdgesPerWorker + x.WorkerConfig.MutationsPipelineGoroutines = mutationsPipelineGoroutinesAuto + x.WorkerConfig.MutationsPipelineGoroutinesFraction = fraction + x.WorkerConfig.MutationsPipelineMinEdgesPerWorker = minEdges + defer func() { + x.WorkerConfig.MutationsPipelineGoroutines = oldBudget + x.WorkerConfig.MutationsPipelineGoroutinesFraction = oldFraction + x.WorkerConfig.MutationsPipelineMinEdgesPerWorker = oldMinEdges + }() + + txn := Oracle().RegisterStartTs(startTs) + mp := NewMutationPipeline(txn) + require.NoError(t, mp.Process(context.Background(), edges)) + + txn.Lock() + conflicts := make(map[uint64]struct{}, len(txn.conflicts)) + for k := range txn.conflicts { + conflicts[k] = struct{}{} + } + txn.Unlock() + + commitPipelineTxn(t, txn, commitTs) + return conflicts +} + +// TestPipelineBudgetAutoByteIdentical proves AUTO mode is byte-identical to a +// fixed budget of the same computed value (and to the disabled legacy path). AUTO +// only chooses the integer fed into allocateWorkers, so for a deterministically +// forced internal budget the downstream behavior must match exactly. The forcing +// is host-independent: fraction=100 makes machineCap=round(GOMAXPROCS*100) >= 100 +// on any host, so workCap=totalEdges/minEdges=320/32=10 always wins → AUTO derives +// budget=10. Asserts committed data values, index buckets, forward/reverse lists, +// AND conflict-key sets equal those of budget=0 and a fixed budget=10. Run under +// -race to also cover the parallel data-write/index paths. +func TestPipelineBudgetAutoByteIdentical(t *testing.T) { + const nameCount = 300 // total batch = 300 name + 10 dept + 10 friend = 320 edges + + nameAttr := x.AttrInRootNamespace("name") + deptAttr := x.AttrInRootNamespace("dept") + friendAttr := x.AttrInRootNamespace("friend") + edges := buildSkewedBatch(nameAttr, deptAttr, friendAttr, nameCount) + + // Sanity: the forced internal budget of 10 grants the dominant predicate more + // than one worker, so the multi-worker path is genuinely exercised. + alloc := allocateWorkers(map[string]int{nameAttr: nameCount, deptAttr: 10, friendAttr: 10}, 10) + require.Greater(t, alloc[nameAttr], 1, "test must exercise multi-worker dominant predicate") + // Sanity: AUTO with fraction=100 / minEdges=32 derives exactly 10 regardless of + // the host's GOMAXPROCS (machineCap >= 100, workCap = 320/32 = 10). + require.Equal(t, 10, autoBudget(1, 320, 100, 32)) + require.Equal(t, 10, autoBudget(256, 320, 100, 32)) + + schemaBytes := []byte(` + name: string @index(exact) . + dept: string @index(exact) . + friend: uid @reverse . + `) + + type result struct { + nameVal map[uint64]string + nameIdx map[string][]uint64 + deptIdx map[string][]uint64 + fwd map[uint64][]uint64 + rev map[uint64][]uint64 + } + + collect := func(readTs uint64) result { + r := result{ + nameVal: map[uint64]string{}, + nameIdx: map[string][]uint64{}, + deptIdx: map[string][]uint64{}, + fwd: map[uint64][]uint64{}, + rev: map[uint64][]uint64{}, + } + for i := 0; i < nameCount; i++ { + e := uint64(1_000_000) + uint64(i) + r.nameVal[e] = scalarValue(t, nameAttr, e, readTs) + } + for v := 0; v < 50; v++ { + val := fmt.Sprintf("name%d", v) + r.nameIdx[val] = sortedUint64(indexUidsForVal(t, nameAttr, val, readTs)) + } + for v := 0; v < 3; v++ { + val := fmt.Sprintf("dept%d", v) + r.deptIdx[val] = sortedUint64(indexUidsForVal(t, deptAttr, val, readTs)) + } + for i := 0; i < 10; i++ { + src := uint64(3_000_000) + uint64(i) + r.fwd[src] = sortedUint64(forwardUids(t, friendAttr, src, readTs)) + } + for tgt := 0; tgt < 4; tgt++ { + target := uint64(4_000_000) + uint64(tgt) + r.rev[target] = sortedUint64(reverseUids(t, friendAttr, target, readTs)) + } + return r + } + + // Run 1 — budget disabled (legacy one-goroutine-per-predicate). + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + require.NoError(t, schema.ParseBytes(schemaBytes, 1)) + conflicts0 := runBudgetBatch(t, 0, 5200, 5201, cloneEdges(edges)) + res0 := collect(5202) + + // Run 2 — fixed budget=10. + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + require.NoError(t, schema.ParseBytes(schemaBytes, 1)) + conflicts10 := runBudgetBatch(t, 10, 5210, 5211, cloneEdges(edges)) + res10 := collect(5212) + + // Run 3 — AUTO, forced to derive budget=10. + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + require.NoError(t, schema.ParseBytes(schemaBytes, 1)) + conflictsAuto := runAutoBudgetBatch(t, 100, 32, 5220, 5221, cloneEdges(edges)) + resAuto := collect(5222) + + require.Equal(t, res0.nameVal, resAuto.nameVal, "scalar data values must match legacy") + require.Equal(t, res10.nameVal, resAuto.nameVal, "scalar data values must match fixed budget") + require.Equal(t, res10.nameIdx, resAuto.nameIdx, "name index buckets must match fixed budget") + require.Equal(t, res10.deptIdx, resAuto.deptIdx, "dept index buckets must match fixed budget") + require.Equal(t, res10.fwd, resAuto.fwd, "forward uid lists must match fixed budget") + require.Equal(t, res10.rev, resAuto.rev, "reverse lists must match fixed budget") + require.Equal(t, conflicts0, conflictsAuto, "conflict-key set must match legacy") + require.Equal(t, conflicts10, conflictsAuto, "conflict-key set must match fixed budget") +} + +// BenchmarkPipelineSkewedBatch measures Process throughput on a ~20,000-edge +// batch dominated by one non-indexed scalar predicate (~99% of edges), comparing +// the budget-off serial data-write against budget=30. The dominant predicate is +// non-indexed so the measurement isolates the data-write parallelization (the +// primary beneficiary). edges/s plus ns/op are reported per sub-benchmark. +func BenchmarkPipelineSkewedBatch(b *testing.B) { + require.NoError(b, pstore.DropAll()) + MemLayerInstance.clear() + require.NoError(b, schema.ParseBytes([]byte(` + name: string . + dept: string @index(exact) . + friend: uid @reverse . + `), 1)) + + nameAttr := x.AttrInRootNamespace("name") + deptAttr := x.AttrInRootNamespace("dept") + friendAttr := x.AttrInRootNamespace("friend") + edges := buildSkewedBatch(nameAttr, deptAttr, friendAttr, 19980) // ~20,000 total + + run := func(b *testing.B, budget int) { + old := x.WorkerConfig.MutationsPipelineGoroutines + x.WorkerConfig.MutationsPipelineGoroutines = budget + defer func() { x.WorkerConfig.MutationsPipelineGoroutines = old }() + + var ts uint64 = 100_000 + b.ResetTimer() + for i := 0; i < b.N; i++ { + ts += 10 + // Fresh, unregistered txn per iteration: a private cache, no oracle + // bookkeeping to leak across b.N. + txn := NewTxn(ts) + mp := NewMutationPipeline(txn) + if err := mp.Process(context.Background(), cloneEdges(edges)); err != nil { + b.Fatal(err) + } + } + b.StopTimer() + edgesPerSec := float64(len(edges)) * float64(b.N) / b.Elapsed().Seconds() + b.ReportMetric(edgesPerSec, "edges/s") + } + + b.Run("budget=0", func(b *testing.B) { run(b, 0) }) + b.Run("budget=30", func(b *testing.B) { run(b, 30) }) +} + +// BenchmarkPipelineBudgetSweep sweeps the goroutine budget over the same +// ~20,000-edge skewed batch as BenchmarkPipelineSkewedBatch, plus AUTO mode, so +// the throughput plateau can be located on the real (e.g. 96-core) box and the +// AUTO fraction tuned. Each sub-benchmark reports edges/s alongside the default +// ns/op. cloneEdges hands each Process call a fresh slice (Process mutates edge +// ValueId in place). Run with: +// +// go test -run '^$' -bench '^BenchmarkPipelineBudgetSweep$' -benchmem ./posting/ +func BenchmarkPipelineBudgetSweep(b *testing.B) { + require.NoError(b, pstore.DropAll()) + MemLayerInstance.clear() + require.NoError(b, schema.ParseBytes([]byte(` + name: string . + dept: string @index(exact) . + friend: uid @reverse . + `), 1)) + + nameAttr := x.AttrInRootNamespace("name") + deptAttr := x.AttrInRootNamespace("dept") + friendAttr := x.AttrInRootNamespace("friend") + edges := buildSkewedBatch(nameAttr, deptAttr, friendAttr, 19980) // ~20,000 total + + // run sets the budget plus the two AUTO tunables (inert unless budget==-1) and + // restores all three on return. fraction/minEdges only matter for the auto case. + run := func(b *testing.B, budget int, fraction float64, minEdges int) { + oldBudget := x.WorkerConfig.MutationsPipelineGoroutines + oldFraction := x.WorkerConfig.MutationsPipelineGoroutinesFraction + oldMinEdges := x.WorkerConfig.MutationsPipelineMinEdgesPerWorker + x.WorkerConfig.MutationsPipelineGoroutines = budget + x.WorkerConfig.MutationsPipelineGoroutinesFraction = fraction + x.WorkerConfig.MutationsPipelineMinEdgesPerWorker = minEdges + defer func() { + x.WorkerConfig.MutationsPipelineGoroutines = oldBudget + x.WorkerConfig.MutationsPipelineGoroutinesFraction = oldFraction + x.WorkerConfig.MutationsPipelineMinEdgesPerWorker = oldMinEdges + }() + + var ts uint64 = 200_000 + b.ResetTimer() + for i := 0; i < b.N; i++ { + ts += 10 + // Fresh, unregistered txn per iteration: a private cache, no oracle + // bookkeeping to leak across b.N. + txn := NewTxn(ts) + mp := NewMutationPipeline(txn) + if err := mp.Process(context.Background(), cloneEdges(edges)); err != nil { + b.Fatal(err) + } + } + b.StopTimer() + edgesPerSec := float64(len(edges)) * float64(b.N) / b.Elapsed().Seconds() + b.ReportMetric(edgesPerSec, "edges/s") + } + + for _, budget := range []int{0, 8, 16, 24, 32, 48, 64, 96} { + budget := budget + b.Run(fmt.Sprintf("budget=%d", budget), func(b *testing.B) { run(b, budget, 0.5, 256) }) + } + // AUTO uses the production default fraction (1.0); runtime.GOMAXPROCS(0) drives + // machineCap = round(GOMAXPROCS * 1.0), capped by edges/minEdgesPerWorker. + b.Run("budget=auto", func(b *testing.B) { + run(b, mutationsPipelineGoroutinesAuto, 1.0, 256) + }) +} diff --git a/posting/pipeline_revbench_test.go b/posting/pipeline_revbench_test.go new file mode 100644 index 00000000000..78430513fcc --- /dev/null +++ b/posting/pipeline_revbench_test.go @@ -0,0 +1,138 @@ +/* + * Reverse-heavy benchmark: measures whether the auto/budget hybrid speedup + * reaches @reverse [uid] predicates (it routes to ProcessList, which is serial). + * Self-contained; package-internal (white-box). + */ + +package posting + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/dgraph-io/dgraph/v25/protos/pb" + "github.com/dgraph-io/dgraph/v25/schema" + "github.com/dgraph-io/dgraph/v25/x" +) + +// buildReverseEdges builds `n` forward [uid] edges from distinct sources to a +// small set of `hotTargets` target uids — modeling the "many sources, 2-5 hot +// target nodes" reverse fan-in of the production workload. +func buildReverseEdges(attr string, n, hotTargets int) []*pb.DirectedEdge { + const srcBase = uint64(1_000_000) + const tgtBase = uint64(9_000_000) + edges := make([]*pb.DirectedEdge, 0, n) + for i := 0; i < n; i++ { + edges = append(edges, &pb.DirectedEdge{ + Entity: srcBase + uint64(i), + Attr: attr, + ValueId: tgtBase + uint64(i%hotTargets), // few hot targets + Op: pb.DirectedEdge_SET, + }) + } + return edges +} + +// buildScalarIndexedEdges builds `n` scalar string edges (distinct entities, +// `distinct` distinct values) — these route to ProcessSingle and DO benefit +// from the intra-predicate split. +func buildScalarIndexedEdges(attr string, n, distinct int) []*pb.DirectedEdge { + const base = uint64(2_000_000) + edges := make([]*pb.DirectedEdge, 0, n) + for i := 0; i < n; i++ { + edges = append(edges, &pb.DirectedEdge{ + Entity: base + uint64(i), + Attr: attr, + Value: []byte(fmt.Sprintf("v%d", i%distinct)), + ValueType: pb.Posting_STRING, + Op: pb.DirectedEdge_SET, + }) + } + return edges +} + +func runBudget(b *testing.B, edges []*pb.DirectedEdge, budget int, fraction float64, minEdges int) { + ob := x.WorkerConfig.MutationsPipelineGoroutines + of := x.WorkerConfig.MutationsPipelineGoroutinesFraction + om := x.WorkerConfig.MutationsPipelineMinEdgesPerWorker + x.WorkerConfig.MutationsPipelineGoroutines = budget + x.WorkerConfig.MutationsPipelineGoroutinesFraction = fraction + x.WorkerConfig.MutationsPipelineMinEdgesPerWorker = minEdges + defer func() { + x.WorkerConfig.MutationsPipelineGoroutines = ob + x.WorkerConfig.MutationsPipelineGoroutinesFraction = of + x.WorkerConfig.MutationsPipelineMinEdgesPerWorker = om + }() + var ts uint64 = 300_000 + b.ResetTimer() + for i := 0; i < b.N; i++ { + ts += 10 + txn := NewTxn(ts) + mp := NewMutationPipeline(txn) + if err := mp.Process(context.Background(), cloneEdges(edges)); err != nil { + b.Fatal(err) + } + } + b.StopTimer() + b.ReportMetric(float64(len(edges))*float64(b.N)/b.Elapsed().Seconds(), "edges/s") +} + +// BenchmarkReverseDominant: ~20k edges, a SINGLE dominant `[uid] @reverse` +// predicate (5 hot targets). This is the "is the speedup OBE for @reverse" +// case — expect budget=auto ~= budget=0 (ProcessList + ProcessReverse serial). +func BenchmarkReverseDominant(b *testing.B) { + require.NoError(b, pstore.DropAll()) + MemLayerInstance.clear() + require.NoError(b, schema.ParseBytes([]byte(`link: [uid] @reverse .`), 1)) + link := x.AttrInRootNamespace("link") + edges := buildReverseEdges(link, 19980, 5) + + for _, bud := range []int{0, 8, 32} { + bud := bud + b.Run(fmt.Sprintf("budget=%d", bud), func(b *testing.B) { runBudget(b, edges, bud, 1.0, 256) }) + } + b.Run("budget=auto", func(b *testing.B) { runBudget(b, edges, mutationsPipelineGoroutinesAuto, 1.0, 256) }) +} + +// BenchmarkReverseFiftyFifty: ~20k edges, 50% on a `[uid] @reverse` predicate +// (no speedup) and 50% on a scalar `@index` predicate (full speedup) — the +// "50% of incoming statements have an @reverse index" assumption. Shows the +// diluted, real-workload speedup. +func BenchmarkReverseFiftyFifty(b *testing.B) { + require.NoError(b, pstore.DropAll()) + MemLayerInstance.clear() + require.NoError(b, schema.ParseBytes([]byte(` + sname: string @index(exact) . + link: [uid] @reverse . + `), 1)) + sname := x.AttrInRootNamespace("sname") + link := x.AttrInRootNamespace("link") + edges := append(buildScalarIndexedEdges(sname, 10000, 50), + buildReverseEdges(link, 10000, 5)...) + + for _, bud := range []int{0, 8, 32} { + bud := bud + b.Run(fmt.Sprintf("budget=%d", bud), func(b *testing.B) { runBudget(b, edges, bud, 1.0, 256) }) + } + b.Run("budget=auto", func(b *testing.B) { runBudget(b, edges, mutationsPipelineGoroutinesAuto, 1.0, 256) }) +} + +// BenchmarkScalarDominant: control — ~20k edges, dominant scalar `@index` +// predicate (matches the original sweep's best case) to contrast against the +// reverse-dominant number. +func BenchmarkScalarDominant(b *testing.B) { + require.NoError(b, pstore.DropAll()) + MemLayerInstance.clear() + require.NoError(b, schema.ParseBytes([]byte(`sname: string @index(exact) .`), 1)) + sname := x.AttrInRootNamespace("sname") + edges := buildScalarIndexedEdges(sname, 19980, 50) + + for _, bud := range []int{0, 8, 32} { + bud := bud + b.Run(fmt.Sprintf("budget=%d", bud), func(b *testing.B) { runBudget(b, edges, bud, 1.0, 256) }) + } + b.Run("budget=auto", func(b *testing.B) { runBudget(b, edges, mutationsPipelineGoroutinesAuto, 1.0, 256) }) +} diff --git a/worker/server_state.go b/worker/server_state.go index 071f163869d..10b334acd41 100644 --- a/worker/server_state.go +++ b/worker/server_state.go @@ -43,7 +43,9 @@ const ( `lambda-url=;` CacheDefaults = `size-mb=4096; percentage=40,40,20; remove-on-update=false` FeatureFlagsDefaults = `normalize-compatibility-mode=; enable-detailed-metrics=false; ` + - `log-slow-query-threshold=0; mutations-pipeline-threshold=1` + `log-slow-query-threshold=0; mutations-pipeline-threshold=1; ` + + `mutations-pipeline-goroutines=30; mutations-pipeline-goroutines-fraction=1.0; ` + + `mutations-pipeline-min-edges-per-worker=256` ) // ServerState holds the state of the Dgraph server. diff --git a/x/config.go b/x/config.go index 8201bb40702..5061db6fe8e 100644 --- a/x/config.go +++ b/x/config.go @@ -155,6 +155,38 @@ type WorkerOptions struct { // Plumbed via the "feature-flags" superflag as // "mutations-pipeline-threshold". MutationsPipelineThreshold int + // MutationsPipelineGoroutines is the global budget of *intra-predicate* worker + // goroutines distributed across the predicates in a single mutation batch, + // proportionally to each predicate's edge count. A hot/dominant predicate is + // granted most of the budget so its data-write and index passes parallelize; + // tiny predicates get one worker each. The Go zero value 0 (and any value < 2, + // except the -1 sentinel) disables intra-predicate parallelism entirely: every + // predicate runs on a single goroutine exactly as the legacy path did, which + // keeps unit tests (which never set this) byte-for-byte identical to before. + // N>0 is an absolute, fixed budget of N. The special value -1 selects AUTO + // mode, where the budget is derived at runtime from GOMAXPROCS and the batch + // size (see posting.autoBudget) using MutationsPipelineGoroutinesFraction and + // MutationsPipelineMinEdgesPerWorker. Plumbed via the "feature-flags" superflag + // as "mutations-pipeline-goroutines"; production default is 30. + MutationsPipelineGoroutines int + // MutationsPipelineGoroutinesFraction is the fraction of GOMAXPROCS that AUTO + // mode (MutationsPipelineGoroutines == -1) may use for one mutation batch. The + // apply phase is serial across transactions — a single apply effectively owns + // the box — so the default is 1.0 (use all cores). A budget-sweep benchmark on + // an 8-core box showed fraction=0.5 leaves ~25% throughput on the table vs + // fraction>=1.0, with the peak near 2-3x cores; raise above 1.0 to oversubscribe + // or lower it to leave headroom for Badger compaction/queries/GC under heavy + // concurrent read load. Inert unless MutationsPipelineGoroutines == -1. Plumbed + // via the "feature-flags" superflag as "mutations-pipeline-goroutines-fraction"; + // default 1.0. + MutationsPipelineGoroutinesFraction float64 + // MutationsPipelineMinEdgesPerWorker is the minimum number of edges per + // intra-predicate worker that AUTO mode targets when deriving the budget + // (workCap = totalEdges / MutationsPipelineMinEdgesPerWorker). It mirrors + // x.DivideAndRule's 256-edge rule. Inert unless MutationsPipelineGoroutines == + // -1. Plumbed via the "feature-flags" superflag as + // "mutations-pipeline-min-edges-per-worker"; default 256. + MutationsPipelineMinEdgesPerWorker int } // WorkerConfig stores the global instance of the worker package's options. From 19232af065666463d6ecd35c794da64f47cceec6 Mon Sep 17 00:00:00 2001 From: Ryan Hendrickson Date: Tue, 23 Jun 2026 02:33:44 -0400 Subject: [PATCH 21/45] =?UTF-8?q?test(pipeline):=20schema/index-type=20mat?= =?UTF-8?q?rix=20=E2=80=94=20byte-identical=20across=2012=20schemas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirms the intra-predicate goroutine budget (proportional, auto, and the ProcessList parallel forward write) produces byte-identical committed state and identical conflict-key sets vs. the legacy one-goroutine-per-predicate path, across string (exact/hash/term/fulltext/trigram), int(+count), float, dateTime, bool, geo, [uid]@reverse(+count), uid@reverse, [string], @upsert, @noconflict, and @lang predicates. Generic: scans every committed Badger key (data/index/reverse/count) per predicate and compares dumps. 36 subtests (12 schemas x {fixed8, fixed32, auto}); race-clean. Co-Authored-By: Claude Opus 4.8 --- posting/pipeline_schema_matrix_test.go | 351 +++++++++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 posting/pipeline_schema_matrix_test.go diff --git a/posting/pipeline_schema_matrix_test.go b/posting/pipeline_schema_matrix_test.go new file mode 100644 index 00000000000..115ac790400 --- /dev/null +++ b/posting/pipeline_schema_matrix_test.go @@ -0,0 +1,351 @@ +/* + * Schema/index-type matrix: confirms the intra-predicate goroutine budget + * (proportional, auto, and the ProcessList parallel forward write) produces + * byte-identical committed state and identical conflict-key sets vs. the legacy + * one-goroutine-per-predicate path, across a wide range of Dgraph value types + * and index tokenizers. + * + * The comparison is fully generic: for every predicate it scans ALL committed + * Badger keys (data, every index token, reverse, count) at a read timestamp and + * dumps each posting list canonically, then asserts the budget>1 dump equals the + * budget=0 dump. This covers any tokenizer without hand-coding token readers. + */ + +package posting + +import ( + "context" + "encoding/hex" + "fmt" + "sort" + "strings" + "testing" + + "github.com/dgraph-io/badger/v4" + "github.com/stretchr/testify/require" + + "github.com/dgraph-io/dgraph/v25/protos/pb" + "github.com/dgraph-io/dgraph/v25/schema" + "github.com/dgraph-io/dgraph/v25/x" +) + +// schemaCase is one row of the matrix: a schema, the root-namespace predicate +// attrs it declares, and a builder for a representative mutation batch. +type schemaCase struct { + name string + schema string + attrs []string + edges func(attrs []string) []*pb.DirectedEdge +} + +// strEdge builds a scalar edge carrying a textual value; ValidateAndConvert +// converts it to the schema's scalar type (the RDF ingest path). +func strEdge(attr string, entity uint64, val string) *pb.DirectedEdge { + return &pb.DirectedEdge{ + Entity: entity, Attr: attr, + Value: []byte(val), ValueType: pb.Posting_STRING, Op: pb.DirectedEdge_SET, + } +} + +// uidEdge builds a uid edge (forward target in ValueId). +func uidEdge(attr string, entity, target uint64) *pb.DirectedEdge { + return &pb.DirectedEdge{ + Entity: entity, Attr: attr, ValueId: target, Op: pb.DirectedEdge_SET, + } +} + +// snapshotPredicates scans every committed key under each predicate's prefix at +// readTs and returns hex(key) -> canonical posting-list dump. Two runs that +// commit identical logical state produce identical maps. +func snapshotPredicates(t *testing.T, attrs []string, readTs uint64) map[string]string { + t.Helper() + out := map[string]string{} + txn := pstore.NewTransactionAt(readTs, false) + defer txn.Discard() + for _, attr := range attrs { + prefix := x.PredicatePrefix(attr) + iopt := badger.DefaultIteratorOptions + iopt.AllVersions = false + iopt.PrefetchValues = false + iopt.Prefix = prefix + it := txn.NewIterator(iopt) + for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() { + key := it.Item().KeyCopy(nil) + l, err := GetNoStore(key, readTs) + require.NoError(t, err) + var postings []string + err = l.Iterate(readTs, 0, func(p *pb.Posting) error { + postings = append(postings, fmt.Sprintf( + "u=%d|v=%s|pt=%d|lang=%s|facets=%v", + p.Uid, hex.EncodeToString(p.Value), p.PostingType, + hex.EncodeToString(p.LangTag), p.Facets)) + return nil + }) + require.NoError(t, err) + // postings already in uid order from Iterate; make deterministic anyway. + sort.Strings(postings) + out[hex.EncodeToString(key)] = fmt.Sprintf("%v", postings) + } + it.Close() + } + return out +} + +// keyKinds parses the snapshot's keys and counts how many are data / index / +// reverse / count. Used to prove the comparison actually covers the secondary +// key types a schema declares (so the test can't pass by comparing data only). +func keyKinds(t *testing.T, snap map[string]string) (data, index, reverse, count int) { + t.Helper() + for hk := range snap { + raw, err := hex.DecodeString(hk) + require.NoError(t, err) + pk, err := x.Parse(raw) + require.NoError(t, err) + switch { + case pk.IsCountOrCountRev(): + count++ + case pk.IsReverse(): + reverse++ + case pk.IsIndex(): + index++ + case pk.IsData(): + data++ + } + } + return +} + +// runMatrixBatch applies edges through a fresh pipeline txn at the given budget +// (and auto tunables), snapshots the conflict-key set, commits, and restores the +// config. Mirrors runBudgetBatch but also sets the auto fraction/min-edges. +func runMatrixBatch(t *testing.T, budget int, fraction float64, minEdges int, + startTs, commitTs uint64, edges []*pb.DirectedEdge) map[uint64]struct{} { + t.Helper() + ob := x.WorkerConfig.MutationsPipelineGoroutines + of := x.WorkerConfig.MutationsPipelineGoroutinesFraction + om := x.WorkerConfig.MutationsPipelineMinEdgesPerWorker + x.WorkerConfig.MutationsPipelineGoroutines = budget + x.WorkerConfig.MutationsPipelineGoroutinesFraction = fraction + x.WorkerConfig.MutationsPipelineMinEdgesPerWorker = minEdges + defer func() { + x.WorkerConfig.MutationsPipelineGoroutines = ob + x.WorkerConfig.MutationsPipelineGoroutinesFraction = of + x.WorkerConfig.MutationsPipelineMinEdgesPerWorker = om + }() + + txn := Oracle().RegisterStartTs(startTs) + mp := NewMutationPipeline(txn) + require.NoError(t, mp.Process(context.Background(), edges)) + + txn.Lock() + conflicts := make(map[uint64]struct{}, len(txn.conflicts)) + for k := range txn.conflicts { + conflicts[k] = struct{}{} + } + txn.Unlock() + + commitPipelineTxn(t, txn, commitTs) + return conflicts +} + +// matrixCases is the schema/index-type matrix. +func matrixCases() []schemaCase { + const n = 600 // edges per scalar predicate; > any tested budget so the split fires + scalar := func(valOf func(i int) string) func([]string) []*pb.DirectedEdge { + return func(attrs []string) []*pb.DirectedEdge { + e := make([]*pb.DirectedEdge, 0, n) + for i := 0; i < n; i++ { + e = append(e, strEdge(attrs[0], uint64(1_000_000+i), valOf(i))) + } + return e + } + } + return []schemaCase{ + { + name: "string_multi_index", // exact+hash+term+fulltext+trigram on one pred + schema: `p: string @index(exact, hash, term, fulltext, trigram) .`, + attrs: []string{"p"}, + edges: scalar(func(i int) string { return fmt.Sprintf("the quick brown fox %d", i%40) }), + }, + { + name: "int_count", + schema: `p: int @index(int) @count .`, + attrs: []string{"p"}, + edges: scalar(func(i int) string { return fmt.Sprintf("%d", i%50) }), + }, + { + name: "float_index", + schema: `p: float @index(float) .`, + attrs: []string{"p"}, + edges: scalar(func(i int) string { return fmt.Sprintf("%d.25", i%50) }), + }, + { + name: "datetime_index", + schema: `p: dateTime @index(day) .`, + attrs: []string{"p"}, + edges: scalar(func(i int) string { return fmt.Sprintf("2021-%02d-15T10:30:00Z", (i%12)+1) }), + }, + { + name: "bool_index", + schema: `p: bool @index(bool) .`, + attrs: []string{"p"}, + edges: scalar(func(i int) string { return fmt.Sprintf("%t", i%2 == 0) }), + }, + { + name: "geo_index", + schema: `p: geo @index(geo) .`, + attrs: []string{"p"}, + edges: scalar(func(i int) string { + return fmt.Sprintf(`{"type":"Point","coordinates":[%d.0,%d.0]}`, i%7, (i*2)%7) + }), + }, + { + name: "list_uid_reverse_count", + schema: `p: [uid] @reverse @count .`, + attrs: []string{"p"}, + edges: func(attrs []string) []*pb.DirectedEdge { + e := make([]*pb.DirectedEdge, 0, n) + for i := 0; i < n; i++ { // many sources -> 5 hot reverse targets + e = append(e, uidEdge(attrs[0], uint64(1_000_000+i), uint64(9_000_000+i%5))) + } + return e + }, + }, + { + name: "uid_reverse_singular", + schema: `p: uid @reverse .`, + attrs: []string{"p"}, + edges: func(attrs []string) []*pb.DirectedEdge { + e := make([]*pb.DirectedEdge, 0, n) + for i := 0; i < n; i++ { + e = append(e, uidEdge(attrs[0], uint64(1_000_000+i), uint64(9_000_000+i%5))) + } + return e + }, + }, + { + name: "list_string_index", + schema: `p: [string] @index(exact, term) .`, + attrs: []string{"p"}, + edges: func(attrs []string) []*pb.DirectedEdge { + e := make([]*pb.DirectedEdge, 0, 2*n) + for i := 0; i < n; i++ { // 2 values per entity -> list postings + shared tokens + ent := uint64(1_000_000 + i) + e = append(e, strEdge(attrs[0], ent, fmt.Sprintf("tag%d", i%30))) + e = append(e, strEdge(attrs[0], ent, fmt.Sprintf("tag%d", (i+1)%30))) + } + return e + }, + }, + { + name: "upsert_and_noconflict", + schema: "up: string @index(exact) @upsert .\n nc: string @index(hash) @noconflict .", + attrs: []string{"up", "nc"}, + edges: func(attrs []string) []*pb.DirectedEdge { + e := make([]*pb.DirectedEdge, 0, 2*n) + for i := 0; i < n; i++ { + e = append(e, strEdge(attrs[0], uint64(1_000_000+i), fmt.Sprintf("u%d", i%50))) + e = append(e, strEdge(attrs[1], uint64(1_000_000+i), fmt.Sprintf("c%d", i%50))) + } + return e + }, + }, + { + name: "lang_fulltext", + schema: `p: string @index(fulltext) @lang .`, + attrs: []string{"p"}, + edges: func(attrs []string) []*pb.DirectedEdge { + // buildLangEdges (existing helper): n entities across several langs. + return buildLangEdges(attrs[0], n, 4) + }, + }, + { + name: "mixed_hot_predicate", // skewed multi-predicate, dominant scalar + reverse + index + schema: "name: string @index(exact) .\n friend: [uid] @reverse .\n age: int @index(int) .", + attrs: []string{"name", "friend", "age"}, + edges: func(attrs []string) []*pb.DirectedEdge { + e := make([]*pb.DirectedEdge, 0, n+200) + for i := 0; i < n; i++ { // dominant: name + e = append(e, strEdge(attrs[0], uint64(1_000_000+i), fmt.Sprintf("name%d", i%50))) + } + for i := 0; i < 100; i++ { + e = append(e, uidEdge(attrs[1], uint64(2_000_000+i), uint64(9_000_000+i%5))) + e = append(e, strEdge(attrs[2], uint64(3_000_000+i), fmt.Sprintf("%d", 20+i%40))) + } + return e + }, + }, + } +} + +// TestSchemaMatrixByteIdentical is the headline robustness test: for every +// schema/index combination, the proportional budget (fixed 8, fixed 32, and +// auto) must produce byte-identical committed state and identical conflict keys +// vs. the legacy (budget=0) path. Run under -race to also cover the concurrent +// data-write / index-tokenization / ProcessList paths. +func TestSchemaMatrixByteIdentical(t *testing.T) { + type budgetCfg struct { + name string + budget int + fraction float64 + minEdges int + } + budgets := []budgetCfg{ + {"fixed8", 8, 1.0, 256}, + {"fixed32", 32, 1.0, 256}, + {"auto", mutationsPipelineGoroutinesAuto, 1.0, 64}, + } + + var ts uint64 = 1_000_000 + next := func() (uint64, uint64, uint64) { ts += 100; return ts, ts + 1, ts + 2 } + + reset := func(t *testing.T, schemaText string) { + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + require.NoError(t, schema.ParseBytes([]byte(schemaText), 1)) + } + + for _, sc := range matrixCases() { + attrs := make([]string, len(sc.attrs)) + for i, a := range sc.attrs { + attrs[i] = x.AttrInRootNamespace(a) + } + for _, bc := range budgets { + sc, bc, attrs := sc, bc, attrs + t.Run(sc.name+"/"+bc.name, func(t *testing.T) { + // Baseline: legacy one-goroutine-per-predicate (budget 0). + reset(t, sc.schema) + s0, c0, r0 := next() + conf0 := runMatrixBatch(t, 0, 1.0, 256, s0, c0, sc.edges(attrs)) + base := snapshotPredicates(t, attrs, r0) + require.NotEmpty(t, base, "baseline wrote no keys for %s", sc.name) + + // Harden: prove the snapshot actually covers the secondary key + // types this schema declares, so the equality check is meaningful + // (not just comparing data keys). + data, index, reverse, count := keyKinds(t, base) + require.Positive(t, data, "expected data keys (%s)", sc.name) + if strings.Contains(sc.schema, "@index") { + require.Positive(t, index, "expected index keys (%s)", sc.name) + } + if strings.Contains(sc.schema, "@reverse") { + require.Positive(t, reverse, "expected reverse keys (%s)", sc.name) + } + if strings.Contains(sc.schema, "@count") { + require.Positive(t, count, "expected count keys (%s)", sc.name) + } + + // Candidate: the budget under test. + reset(t, sc.schema) + s1, c1, r1 := next() + confN := runMatrixBatch(t, bc.budget, bc.fraction, bc.minEdges, s1, c1, sc.edges(attrs)) + cand := snapshotPredicates(t, attrs, r1) + + require.Equal(t, base, cand, + "committed state must be byte-identical (%s, %s)", sc.name, bc.name) + require.Equal(t, conf0, confN, + "conflict-key set must be identical (%s, %s)", sc.name, bc.name) + }) + } + } +} From 732528c1cf2f4054f5a09e140b56f9ee7fdbff0e Mon Sep 17 00:00:00 2001 From: Ryan Hendrickson Date: Sun, 26 Jul 2026 02:40:25 -0400 Subject: [PATCH 22/45] perf(pipeline): parallel reverse write, lock-free store at any worker grant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProcessReverse built reverseredMap[targetUid] then wrote each target serially via AddDelta, which holds the GLOBAL txn.cache.Lock() across proto.Marshal. With many predicates applying concurrently at L1 they all convoy on that one mutex. A live goroutine dump (25 [uid] @reverse predicates, 20k triples/txn) showed 61% of pipeline goroutines parked in ProcessReverse and 86% of those blocked on that lock. Two changes: 1. Split "use the lock-free store" from "split across k goroutines". Both decisions were tied to workers > 1, but allocateWorkers hands out mostly 1-worker grants when the budget is below ~2x the predicate count (the shipped default of 30 with 25 predicates yields {1:20, 2:5}), so nearly every predicate fell back to the global lock. Now any enabled budget uses concStore; workers == 0 keeps the legacy locked AddDelta so "budget off == byte-identical legacy" still holds. 2. Above reverseParallelMinTargets (256) distinct targets, partition the target-uid keyspace across the predicate's worker grant. reverseredMap is keyed BY target, so each <~pred,targetUid> key has exactly one writer (I1) — the same disjointness that makes ProcessList's forward write safe. Below the threshold the map holds a few hot targets with large lists; sharding that shape measured as a net loss, so it stays serial. concStore reproduces AddDelta(..., true, true) exactly: prior-delta prepend (a *Txn spans proposals, so dropping it silently loses an earlier proposal's reverse postings) and unconditional sort/dedup, reading only the sharded delta map — never (*Deltas).Get, which also touches indexMap under cache.Lock. Per-worker key buffers are required: addConflictKeyWithUid fingerprints the full buffer including the 8 uid bytes. The reverseredMap BUILD stays serial (many-to-one, shared edge struct); parallelising it needs a merge stage that costs more than it saves. The info.count path returns to ProcessCount before any of this, unchanged. Measured on i4i.16xlarge (32 physical cores), 25 [uid] @reverse predicates, 20k triples/txn, 64 threads, badger_write_bytes_user: budget=30 (default): 9.63 -> 10.58 MB/s (+9.9%) budget=-1 (auto): 9.68 -> 11.29 MB/s (+16.6%) Reverse-write parking drops 61% -> 37%; global cache.Lock blocking drops 86% -> 41%. Tests: byte-identical committed state and conflict-key sets across budgets {0,8,32,auto} for 13 schemas, incl. two new high-cardinality rows covering the ProcessList and ProcessSingle legs of the parallel path, plus a cross-proposal regression test for the addToList prepend (verified to fail when the prepend is removed). Full posting package green under -race. Co-Authored-By: Claude Opus 5 --- posting/index.go | 186 ++++++++++++++++++++++++- posting/pipeline_budget_test.go | 81 +++++++++++ posting/pipeline_revbench_test.go | 115 +++++++++++++++ posting/pipeline_schema_matrix_test.go | 44 +++++- 4 files changed, 416 insertions(+), 10 deletions(-) diff --git a/posting/index.go b/posting/index.go index 49f832e5735..99728299c98 100644 --- a/posting/index.go +++ b/posting/index.go @@ -565,6 +565,16 @@ func findSingleValueInPostingList(pb *pb.PostingList) *pb.Posting { return nil } +// reverseParallelMinTargets is the minimum number of distinct reverse targets +// before ProcessReverse splits its write loop across the predicate's worker +// grant. Below it the reverse map holds a handful of hot targets: there is +// little to partition, and each of those few posting lists is large, so the +// per-target SortAndDedupPostings + Marshal dominates and sharding measures as a +// net loss. Above it the map holds thousands of independent <~pred,targetUid> +// keys and the split has real work to spread. Mirrors the spirit of +// MutationsPipelineMinEdgesPerWorker. +const reverseParallelMinTargets = 256 + func (mp *MutationPipeline) ProcessReverse(ctx context.Context, pipeline *PredicatePipeline, postings *map[uint64]*pb.PostingList, info predicateInfo) error { key := x.ReverseKey(pipeline.attr, 0) edge := &pb.DirectedEdge{ @@ -600,19 +610,181 @@ func (mp *MutationPipeline) ProcessReverse(ctx context.Context, pipeline *Predic return mp.ProcessCount(ctx, pipeline, &reverseredMap, newInfo, true, true) } - for uid, pl := range reverseredMap { + // writeReverseTarget emits the reverse delta for a single target uid. The + // reverse key <~pred,targetUid> is one-to-one over reverseredMap — the map is + // already keyed by target, so each key is written exactly once — which makes + // it the same safe beneficiary of intra-predicate parallelism as ProcessList's + // forward write. scratchKey MUST be per-worker: the legacy loop + // reused a single ReverseKey buffer, and addConflictKeyWithUid fingerprints + // the FULL buffer including the 8 uid bytes, so sharing it across goroutines + // would both race and produce wrong conflict keys. store is the locked + // AddDelta on the serial path and the lock-free AddDeltaConcurrent wrapper on + // the parallel path; for a given distinct key both write identical bytes and + // return the same deduped list, so the committed result (reverse data bytes + // and conflict-key set) is store-order-independent. + writeReverseTarget := func(uid uint64, pl *pb.PostingList, scratchKey []byte, + store func(key string, input *pb.PostingList) (*pb.PostingList, error)) error { if len(pl.Postings) == 0 { - continue + return nil } - binary.BigEndian.PutUint64(key[len(key)-8:], uid) - if newPl, err := mp.txn.AddDelta(string(key), pl, true, true); err != nil { + + binary.BigEndian.PutUint64(scratchKey[len(scratchKey)-8:], uid) + newPl, err := store(string(scratchKey), pl) + if err != nil { return err - } else { - mp.txn.addConflictKeyWithUid(key, newPl, info.hasUpsert, info.noConflict) } + mp.txn.addConflictKeyWithUid(scratchKey, newPl, info.hasUpsert, info.noConflict) + return nil } - return nil + // concStore reproduces AddDelta(..., true, true)'s committed bytes without + // taking the global cache lock. Two behaviors must be matched exactly: + // + // - addToList=true: a transaction may apply several mutation proposals on + // the SAME *Txn (worker/draft.go RegisterStartTs reuses it, and txn.Update + // between proposals does not clear txn.cache.deltas), so an EARLIER + // proposal may have left a delta for this reverse key. It must be + // prepended, exactly as the serial AddDelta does — otherwise a later + // proposal silently drops the earlier proposal's reverse postings. + // - doSortAndDedup=true: reverse postings are always REF (uid) postings, so + // unlike ProcessList — which gates this on info.isUid — the sort/dedup is + // unconditional here, matching the serial call's literal `true`. + // + // The prior delta is read from the sharded, per-key-locked deltas map ONLY — + // never (*Deltas).Get/GetBytes, which also read the plain-Go indexMap that is + // only safe under cache.Lock (another predicate's InsertTokenizerIndexes may + // be writing it concurrently). A reverse key never appears in indexMap — + // x.generateKey encodes attrLen plus a per-type discriminator byte, so index + // and reverse key strings cannot collide — so the sharded read returns + // byte-for-byte what serial AddDelta observes. Single-writer-per-key (I1) + // makes this read-merge-write safe without the global lock. + concStore := func(k string, in *pb.PostingList) (*pb.PostingList, error) { + merged := new(pb.PostingList) + if prevBytes, ok := mp.txn.cache.deltas.deltas.Get(k); ok { + prev := &pb.PostingList{} + if err := proto.Unmarshal(prevBytes, prev); err != nil { + glog.Errorf("Error unmarshalling prior reverse delta for key %x: %v", k, err) + return nil, err + } + merged.Postings = append(merged.Postings, prev.Postings...) + } + merged.Postings = append(merged.Postings, in.Postings...) + merged.Postings = SortAndDedupPostings(merged.Postings) + if err := mp.txn.AddDeltaConcurrent(k, merged); err != nil { + return nil, err + } + return merged, nil + } + + // Legacy path: the intra-predicate budget is disabled entirely (workers == 0). + // Keep the locked AddDelta so the "budget off == byte-identical legacy" + // contract holds. + if pipeline.workers <= 0 { + serialStore := func(k string, in *pb.PostingList) (*pb.PostingList, error) { + return mp.txn.AddDelta(k, in, true, true) + } + for uid, pl := range reverseredMap { + if err := writeReverseTarget(uid, pl, key, serialStore); err != nil { + return err + } + } + return nil + } + + // Budget enabled but no k-way split — either a one-worker grant or too few + // distinct targets to be worth partitioning. Still use the LOCK-FREE store. + // + // This split matters more than the goroutine fan-out: the dominant cost of the + // legacy loop is not the absence of workers, it is that AddDelta holds the + // GLOBAL txn.cache.Lock() across proto.Marshal, so every concurrent predicate + // goroutine at L1 convoys on that one mutex. A single-writer loop through + // concStore escapes the convoy without spawning anything. Keeping these two + // decisions tied together would strand the common production grant — with P + // predicates and a budget below 2*P, allocateWorkers hands out mostly 1s, so + // nearly every predicate would fall back to the global lock. + // + // Single-writer safety holds here for the same reason it does in the parallel + // path: reverse keys are <~pred,targetUid>, and x.generateKey encodes attrLen + // plus a per-type discriminator byte, so no two predicates — and no other key + // type — can produce the same key string. + if pipeline.workers == 1 || len(reverseredMap) < reverseParallelMinTargets { + for uid, pl := range reverseredMap { + if err := writeReverseTarget(uid, pl, key, concStore); err != nil { + return err + } + } + return nil + } + + // Parallel path: split the disjoint <~pred,targetUid> reverse keys across k + // goroutines. Each worker owns a contiguous, non-overlapping slice of the + // target-uid snapshot, and reverseredMap is keyed BY target, so no two workers + // ever write the same reverse key (invariant I1) — that disjointness is what + // makes the lock-free AddDeltaConcurrent safe, exactly as in ProcessList's + // forward write. All workers join here (wg.Wait) before ProcessReverse returns. + // + // I1 also depends on an invariant established OUTSIDE this function: the only + // other writer of <~pred,targetUid> is handleDeleteAll -> addReverseAndCountMutation, + // which Process runs INLINE on the dispatcher goroutine. Predicate goroutines + // are parked in `for edge := range pipeline.edges` and cannot reach + // ProcessReverse until close(pred.edges), which happens only after the whole + // sendLoop — star-deletes included — has finished. If star-delete handling ever + // moves off the dispatcher, or channels are closed per-predicate as the + // sendLoop discovers them, this lock-free write silently becomes a multi-writer + // read-modify-write race: silent reverse-index corruption, no panic, and no + // race-detector hit unless a batch happens to mix a star-delete with normal + // edges for the same predicate. + // + // Note this parallelizes only the WRITE. The reverseredMap build above stays + // serial: it is many-to-one (many sources fold into one target) and reuses a + // single edge struct, so splitting it would need a merge stage that costs more + // than it saves. + + uidList := make([]uint64, 0, len(reverseredMap)) + for uid := range reverseredMap { + uidList = append(uidList, uid) + } + // Parity with ProcessList's parallel path: an empty snapshot would make k == 0 + // and the ceil-division below a divide-by-zero panic on the serial apply + // goroutine. Unreachable while reverseParallelMinTargets > 0, but that is a + // tuning knob — do not let lowering it crash an Alpha mid-Raft-entry. + if len(uidList) == 0 { + return nil + } + k := pipeline.workers + if k > len(uidList) { + k = len(uidList) + } + width := (len(uidList) + k - 1) / k // ceil(len/k), DivideAndRule-style + + var wg sync.WaitGroup + var once sync.Once + var firstErr error + for w := 0; w < k; w++ { + start := w * width + if start >= len(uidList) { + break + } + end := start + width + if end > len(uidList) { + end = len(uidList) + } + wg.Add(1) + go func(sub []uint64) { + defer wg.Done() + // Per-worker scratch buffer: sharing `key` across goroutines races. + localKey := x.ReverseKey(pipeline.attr, 0) + for _, uid := range sub { + if err := writeReverseTarget(uid, reverseredMap[uid], localKey, + concStore); err != nil { + once.Do(func() { firstErr = err }) + return + } + } + }(uidList[start:end]) + } + wg.Wait() + return firstErr } func makePostingFromEdge(startTs uint64, edge *pb.DirectedEdge) *pb.Posting { diff --git a/posting/pipeline_budget_test.go b/posting/pipeline_budget_test.go index c18240dd73d..5ab354d46ea 100644 --- a/posting/pipeline_budget_test.go +++ b/posting/pipeline_budget_test.go @@ -324,6 +324,87 @@ func TestProcessListCrossProposalByteIdentical(t *testing.T) { require.Equal(t, conflicts0, conflicts30, "conflict-key sets must be identical") } +// TestProcessReverseCrossProposalByteIdentical is the regression test for the +// PARALLEL reverse write's addToList=true merge. ProcessReverse only takes its +// parallel path above reverseParallelMinTargets distinct targets, so the other +// @reverse tests here (5 hot targets) all stay serial and cannot catch this. +// +// Two proposals on the SAME txn SET different source uids onto the same 600 +// reverse targets. The serial path prepends the earlier proposal's delta, so each +// <~rlink,target> ends with BOTH sources; a parallel concStore that failed to +// replicate addToList would silently drop the first proposal's reverse posting. +// Asserts the budget>1 result equals the serial baseline AND that the baseline +// itself carries both proposals (so the test fails on the overwrite bug rather +// than merely on disagreement). Run under -race. +func TestProcessReverseCrossProposalByteIdentical(t *testing.T) { + // Must exceed reverseParallelMinTargets so the parallel write path is taken. + const revTargets = 600 + require.Greater(t, revTargets, reverseParallelMinTargets, + "test must exercise the PARALLEL ProcessReverse write path") + + rlinkAttr := x.AttrInRootNamespace("rlink") + const ( + srcABase = uint64(1_000_000) + srcBBase = uint64(2_000_000) + tgtBase = uint64(9_000_000) + ) + + // Each proposal points a distinct source set at the SAME 600 targets, so every + // reverse key is written by both proposals and must merge, not overwrite. + buildRev := func(srcBase uint64) []*pb.DirectedEdge { + edges := make([]*pb.DirectedEdge, 0, revTargets) + for i := 0; i < revTargets; i++ { + edges = append(edges, &pb.DirectedEdge{ + Entity: srcBase + uint64(i), + Attr: rlinkAttr, + ValueId: tgtBase + uint64(i), + Op: pb.DirectedEdge_SET, + }) + } + return edges + } + batch1, batch2 := buildRev(srcABase), buildRev(srcBBase) + + alloc := allocateWorkers(map[string]int{rlinkAttr: revTargets}, 30) + require.Greater(t, alloc[rlinkAttr], 1, "test must grant the predicate >1 worker") + + schemaBytes := []byte(`rlink: [uid] @reverse .`) + + collectRev := func(readTs uint64) map[uint64][]uint64 { + out := map[uint64][]uint64{} + for i := 0; i < revTargets; i++ { + tgt := tgtBase + uint64(i) + out[tgt] = sortedUint64(reverseUids(t, rlinkAttr, tgt, readTs)) + } + return out + } + + // Run 1 — budget disabled (serial cross-proposal addToList merge). + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + require.NoError(t, schema.ParseBytes(schemaBytes, 1)) + conflicts0 := runTwoProposalBatch(t, 0, 5500, 5501, cloneEdges(batch1), cloneEdges(batch2)) + rev0 := collectRev(5502) + + // Run 2 — budget=30 (parallel concStore must reproduce the merge). + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + require.NoError(t, schema.ParseBytes(schemaBytes, 1)) + conflicts30 := runTwoProposalBatch(t, 30, 5510, 5511, cloneEdges(batch1), cloneEdges(batch2)) + rev30 := collectRev(5512) + + // The serial baseline must itself carry BOTH proposals' reverse postings — + // otherwise the equality assertion below could pass on a shared wrong result. + for i := 0; i < revTargets; i++ { + want := sortedUint64([]uint64{srcABase + uint64(i), srcBBase + uint64(i)}) + require.Equal(t, want, rev0[tgtBase+uint64(i)], + "serial reverse list must append across proposals (target %d)", tgtBase+uint64(i)) + } + + require.Equal(t, rev0, rev30, "reverse lists must match across budgets") + require.Equal(t, conflicts0, conflicts30, "conflict-key sets must be identical") +} + // TestAllocateWorkers exercises the pure apportionment helper: floor of 1, the // budget-disabled and saturated edge cases, the largest-remainder distribution, // the floor-of-1 reclaim, and determinism — no cluster needed. diff --git a/posting/pipeline_revbench_test.go b/posting/pipeline_revbench_test.go index 78430513fcc..e1b5ac56f01 100644 --- a/posting/pipeline_revbench_test.go +++ b/posting/pipeline_revbench_test.go @@ -9,6 +9,7 @@ package posting import ( "context" "fmt" + "strings" "testing" "github.com/stretchr/testify/require" @@ -97,6 +98,120 @@ func BenchmarkReverseDominant(b *testing.B) { b.Run("budget=auto", func(b *testing.B) { runBudget(b, edges, mutationsPipelineGoroutinesAuto, 1.0, 256) }) } +// BenchmarkReverseHighCardinality: ~20k edges on a single `[uid] @reverse` +// predicate spread over 8000 DISTINCT target uids (~2.5 sources each). This is +// the fan-in shape the parallel reverse write targets: the reverse map holds +// thousands of independent <~pred,targetUid> keys, so partitioning it across +// workers has real work to split. Contrast with BenchmarkReverseDominant, whose +// 5 hot targets cap parallelism at 5 and leave the serial path competitive. +func BenchmarkReverseHighCardinality(b *testing.B) { + require.NoError(b, pstore.DropAll()) + MemLayerInstance.clear() + require.NoError(b, schema.ParseBytes([]byte(`link: [uid] @reverse .`), 1)) + link := x.AttrInRootNamespace("link") + edges := buildReverseEdges(link, 19980, 8000) + + for _, bud := range []int{0, 8, 32} { + bud := bud + b.Run(fmt.Sprintf("budget=%d", bud), func(b *testing.B) { runBudget(b, edges, bud, 1.0, 256) }) + } + b.Run("budget=auto", func(b *testing.B) { runBudget(b, edges, mutationsPipelineGoroutinesAuto, 1.0, 256) }) +} + +// BenchmarkReverseMultiPredicate: ~20k edges spread over N CONCURRENT +// high-cardinality `[uid] @reverse` predicates. Each predicate gets its own L1 +// goroutine, so all N ProcessReverse loops run at once and contend the SAME +// global txn.cache.Lock() that serial AddDelta takes per target. +// +// This is the effect a single-predicate benchmark cannot reproduce, and it is +// the leading explanation for why a production profile (25 predicates, ~70% +// @reverse, 20 concurrent writer threads) attributes far more time to the +// reverse write than a one-predicate microbenchmark does. If the parallel write +// — which moves these writes onto sharded per-key locks via AddDeltaConcurrent — +// helps MORE as predicate count rises, cross-predicate lock relief is real and +// the single-predicate number understates the production gain. +func BenchmarkReverseMultiPredicate(b *testing.B) { + const totalEdges = 19980 + // 25 predicates + budget=30 is the shipped production shape: worker/server_state.go + // defaults mutations-pipeline-goroutines=30, and allocateWorkers hands 25 + // predicates a {1:20, 2:5} grant — i.e. most predicates get a ONE-worker grant. + for _, nPred := range []int{1, 4, 8, 16, 25} { + nPred := nPred + b.Run(fmt.Sprintf("preds=%d", nPred), func(b *testing.B) { + require.NoError(b, pstore.DropAll()) + MemLayerInstance.clear() + var sb strings.Builder + for p := 0; p < nPred; p++ { + fmt.Fprintf(&sb, "link%d: [uid] @reverse .\n", p) + } + require.NoError(b, schema.ParseBytes([]byte(sb.String()), 1)) + + perPred := totalEdges / nPred + edges := make([]*pb.DirectedEdge, 0, totalEdges) + for p := 0; p < nPred; p++ { + attr := x.AttrInRootNamespace(fmt.Sprintf("link%d", p)) + // Distinct target per source keeps every predicate above + // reverseParallelMinTargets so each takes the parallel path. + for i := 0; i < perPred; i++ { + edges = append(edges, &pb.DirectedEdge{ + Entity: uint64(1_000_000 + p*1_000_000 + i), + Attr: attr, + ValueId: uint64(9_000_000 + p*1_000_000 + i), + Op: pb.DirectedEdge_SET, + }) + } + } + for _, bud := range []int{0, 30, 32} { + bud := bud + b.Run(fmt.Sprintf("budget=%d", bud), func(b *testing.B) { runBudget(b, edges, bud, 1.0, 256) }) + } + }) + } +} + +// BenchmarkReverseHighCardConcurrent: the production high-cardinality shape. +// EVERY predicate has 8,000 distinct reverse targets (fan-in 2 => 16k edges +// each), so at 4 predicates this is a ~64k-edge batch — the real batch size — +// in which every predicate generates 8,000 independent <~pred,targetUid> write +// keys. Unlike BenchmarkReverseMultiPredicate (which divides a fixed edge budget +// across predicates and so thins the per-predicate cardinality), this holds +// cardinality FIXED at 8,000 and scales the batch, isolating the effect of +// several high-cardinality reverse predicates contending the global cache lock +// at once. +func BenchmarkReverseHighCardConcurrent(b *testing.B) { + const targetsPerPred = 8000 + const fanIn = 2 + for _, nPred := range []int{1, 2, 4} { + nPred := nPred + b.Run(fmt.Sprintf("preds=%d", nPred), func(b *testing.B) { + require.NoError(b, pstore.DropAll()) + MemLayerInstance.clear() + var sb strings.Builder + for p := 0; p < nPred; p++ { + fmt.Fprintf(&sb, "hc%d: [uid] @reverse .\n", p) + } + require.NoError(b, schema.ParseBytes([]byte(sb.String()), 1)) + + edges := make([]*pb.DirectedEdge, 0, nPred*targetsPerPred*fanIn) + for p := 0; p < nPred; p++ { + attr := x.AttrInRootNamespace(fmt.Sprintf("hc%d", p)) + for i := 0; i < targetsPerPred*fanIn; i++ { + edges = append(edges, &pb.DirectedEdge{ + Entity: uint64(1_000_000 + p*1_000_000 + i), + Attr: attr, + ValueId: uint64(9_000_000 + p*1_000_000 + i%targetsPerPred), + Op: pb.DirectedEdge_SET, + }) + } + } + for _, bud := range []int{0, 30} { + bud := bud + b.Run(fmt.Sprintf("budget=%d", bud), func(b *testing.B) { runBudget(b, edges, bud, 1.0, 256) }) + } + }) + } +} + // BenchmarkReverseFiftyFifty: ~20k edges, 50% on a `[uid] @reverse` predicate // (no speedup) and 50% on a scalar `@index` predicate (full speedup) — the // "50% of incoming statements have an @reverse index" assumption. Shows the diff --git a/posting/pipeline_schema_matrix_test.go b/posting/pipeline_schema_matrix_test.go index 115ac790400..c314a371af7 100644 --- a/posting/pipeline_schema_matrix_test.go +++ b/posting/pipeline_schema_matrix_test.go @@ -148,9 +148,11 @@ func runMatrixBatch(t *testing.T, budget int, fraction float64, minEdges int, return conflicts } -// matrixCases is the schema/index-type matrix. -func matrixCases() []schemaCase { - const n = 600 // edges per scalar predicate; > any tested budget so the split fires +// matrixCases is the schema/index-type matrix (default batch size). +func matrixCases() []schemaCase { return matrixCasesN(600) } + +// matrixCasesN is the schema/index-type matrix with n edges per scalar predicate. +func matrixCasesN(n int) []schemaCase { scalar := func(valOf func(i int) string) func([]string) []*pb.DirectedEdge { return func(attrs []string) []*pb.DirectedEdge { e := make([]*pb.DirectedEdge, 0, n) @@ -211,6 +213,23 @@ func matrixCases() []schemaCase { return e }, }, + { + // One distinct reverse target per source, so len(reverseredMap) == n + // exceeds reverseParallelMinTargets and ProcessReverse takes its + // PARALLEL write path. Every other @reverse case here lands on 5 hot + // targets and stays serial, so without this row the parallel reverse + // write would be uncovered by the byte-identity matrix. + name: "list_uid_reverse_highcard", + schema: `p: [uid] @reverse .`, + attrs: []string{"p"}, + edges: func(attrs []string) []*pb.DirectedEdge { + e := make([]*pb.DirectedEdge, 0, n) + for i := 0; i < n; i++ { + e = append(e, uidEdge(attrs[0], uint64(1_000_000+i), uint64(9_000_000+i))) + } + return e + }, + }, { name: "uid_reverse_singular", schema: `p: uid @reverse .`, @@ -223,6 +242,25 @@ func matrixCases() []schemaCase { return e }, }, + { + // The singular-uid leg reaches ProcessReverse via ProcessSingle, not + // ProcessList, and handleOldDeleteForSingle appends a synthetic DEL + // carrying the OLD target — so one source uid can produce TWO distinct + // reverse keys. uid_reverse_singular above lands on 5 hot targets and + // stays serial, leaving that leg's parallel path uncovered; this row + // gives it one distinct target per source so it exceeds + // reverseParallelMinTargets. + name: "uid_reverse_singular_highcard", + schema: `p: uid @reverse .`, + attrs: []string{"p"}, + edges: func(attrs []string) []*pb.DirectedEdge { + e := make([]*pb.DirectedEdge, 0, n) + for i := 0; i < n; i++ { + e = append(e, uidEdge(attrs[0], uint64(1_000_000+i), uint64(9_000_000+i))) + } + return e + }, + }, { name: "list_string_index", schema: `p: [string] @index(exact, term) .`, From 63a76adacf4a630db7efff2ef3e67c3ca18fbf28 Mon Sep 17 00:00:00 2001 From: Ryan Hendrickson Date: Sun, 26 Jul 2026 03:32:07 -0400 Subject: [PATCH 23/45] perf(pipeline): lock-free forward write at a one-worker grant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProcessList and ProcessSingle tied two independent decisions to the same `workers > 1` test: whether to use the lock-free store, and whether to split the uid space across goroutines. allocateWorkers hands out mostly one-worker grants whenever the budget is below ~2x the predicate count — the shipped default of 30 with 25 predicates yields {1:20, 2:5} — so 20 of 25 predicates fell back to AddDelta, which holds the GLOBAL txn.cache.Lock() across proto.Marshal. With every predicate goroutine at L1 doing that, they convoy on one mutex and the parallel forward write added in 37c834a7d never engages for most of the batch. Split the two decisions, mirroring 4a02d8eeb's fix for ProcessReverse: workers == 0 keeps the legacy locked AddDelta so "budget off == byte-identical legacy" holds; any enabled budget uses the lock-free store (concStore for ProcessList, which must reproduce AddDelta's addToList prepend and info.isUid sort/dedup; plain AddDeltaConcurrent for ProcessSingle, whose serial call already passes addToList=false, doSortAndDedup=false). The k-way split still requires workers > 1. Single-writer safety is unchanged: forward keys are one-to-one, and x.generateKey encodes attrLen plus a per-type discriminator byte, so two predicates cannot produce the same key string. Measured on i4i.16xlarge (32 physical cores), 25 [uid] @reverse predicates, 20k triples/txn, 64 threads, badger_write_bytes_user, 2 runs per arm: stock 9.63 MB/s + reverse (4a02d8eeb) 10.58 MB/s (+9.9%) + this commit 11.44 MB/s (+18.8%) Budget stops mattering once the lock-free store is reachable at any grant: auto measures 11.38 MB/s, within noise of the default's 11.44. The global cache lock disappears from the blocking profile entirely — goroutines parked on it go 86.4% (stock) -> 41-52% (reverse only) -> 0%, and runnable goroutines rise to 57.9%. addConflictKeyWithUid's single global txn.Lock() is now 71.2% of parked pipeline goroutines and is the next bottleneck. CPU remains at 8% of 64 vCPU: processApplyCh applies one Raft entry at a time, so a single Alpha cannot saturate a large box regardless of intra-transaction parallelism. Full posting package green under -race. Co-Authored-By: Claude Opus 5 --- posting/index.go | 84 ++++++++++++++++++++++++++++++++---------------- 1 file changed, 56 insertions(+), 28 deletions(-) diff --git a/posting/index.go b/posting/index.go index 99728299c98..1a4dac96463 100644 --- a/posting/index.go +++ b/posting/index.go @@ -445,30 +445,6 @@ func (mp *MutationPipeline) ProcessList(ctx context.Context, pipeline *Predicate return nil } - // Serial path (budget disabled or a one-worker grant): byte-for-byte the - // pre-budget loop, reusing the single dataKey scratch buffer and the locked - // AddDelta(..., info.isUid, true). - if pipeline.workers <= 1 { - serialStore := func(k string, in *pb.PostingList) (*pb.PostingList, error) { - return mp.txn.AddDelta(k, in, info.isUid, true) - } - for uid, pl := range postings { - if err := writeListDataUid(uid, pl, dataKey, serialStore); err != nil { - return err - } - } - return nil - } - - // Parallel path: split the disjoint forward keys across k - // goroutines. Each worker owns a contiguous, non-overlapping slice of the - // source-uid snapshot, so no two workers ever write the same data key - // (invariant I1) — that disjointness is what makes the lock-free - // AddDeltaConcurrent safe. ProcessReverse / the index / count passes have - // already run serially above; all workers join here (wg.Wait) before - // ProcessList returns, so the legacy MVCC ordering — every delta in place - // before the predicate goroutine closes — is preserved. - // // concStore reproduces AddDelta(..., info.isUid, true)'s committed bytes // without taking the global cache lock. Two behaviors must be matched exactly: // @@ -510,6 +486,41 @@ func (mp *MutationPipeline) ProcessList(ctx context.Context, pipeline *Predicate return merged, nil } + // Legacy path: the intra-predicate budget is disabled entirely (workers == 0). + // Keep the locked AddDelta so "budget off == byte-identical legacy" holds. + if pipeline.workers <= 0 { + serialStore := func(k string, in *pb.PostingList) (*pb.PostingList, error) { + return mp.txn.AddDelta(k, in, info.isUid, true) + } + for uid, pl := range postings { + if err := writeListDataUid(uid, pl, dataKey, serialStore); err != nil { + return err + } + } + return nil + } + + // Budget enabled but only one worker: still use the LOCK-FREE concStore. See + // the equivalent branch in ProcessSingle/ProcessReverse — the win is escaping + // the global txn.cache.Lock() that AddDelta holds across proto.Marshal, not + // the goroutine fan-out, and allocateWorkers hands out mostly 1-worker grants + // whenever the budget is below ~2x the predicate count. + if pipeline.workers == 1 { + for uid, pl := range postings { + if err := writeListDataUid(uid, pl, dataKey, concStore); err != nil { + return err + } + } + return nil + } + + // Parallel path: split the disjoint forward keys across k + // goroutines. Each worker owns a contiguous, non-overlapping slice of the + // source-uid snapshot, so no two workers ever write the same data key + // (invariant I1) — that disjointness is what makes the lock-free + // AddDeltaConcurrent safe. All workers join here (wg.Wait) before ProcessList + // returns, so the legacy MVCC ordering — every delta in place before the + // predicate goroutine closes — is preserved. uidList := make([]uint64, 0, len(postings)) for uid := range postings { uidList = append(uidList, uid) @@ -1251,10 +1262,9 @@ func (mp *MutationPipeline) ProcessSingle(ctx context.Context, pipeline *Predica return store(key, writePl) } - // Serial path (budget disabled or a one-worker grant): byte-for-byte the - // pre-budget loop, reusing the single dataKey scratch buffer and the locked - // AddDelta. - if pipeline.workers <= 1 { + // Legacy path: the intra-predicate budget is disabled entirely (workers == 0). + // Keep the locked AddDelta so "budget off == byte-identical legacy" holds. + if pipeline.workers <= 0 { for uid, pl := range postings { if err := writeDataUid(uid, pl, dataKey, func(k string, wpl *pb.PostingList) error { @@ -1267,6 +1277,24 @@ func (mp *MutationPipeline) ProcessSingle(ctx context.Context, pipeline *Predica return nil } + // Budget enabled but only one worker: still use the LOCK-FREE store. The + // dominant cost of the legacy loop is not the missing goroutines, it is that + // AddDelta holds the GLOBAL txn.cache.Lock() across proto.Marshal, so every + // concurrent predicate goroutine at L1 convoys on that one mutex. Tying the + // lock-free store to workers > 1 stranded the common production grant: + // allocateWorkers hands out mostly 1s when the budget is below ~2x the + // predicate count. Single-writer safety is unchanged — data keys + // are one-to-one and distinct predicates cannot collide (x.generateKey + // encodes attrLen plus a per-type discriminator byte). + if pipeline.workers == 1 { + for uid, pl := range postings { + if err := writeDataUid(uid, pl, dataKey, mp.txn.AddDeltaConcurrent); err != nil { + return err + } + } + return nil + } + // Parallel path: split the disjoint keys across k goroutines. Each // worker owns a contiguous, non-overlapping slice of the uid snapshot, so no // two workers ever write the same data key (invariant I1) — that disjointness From 741c28da8e4593c0ca6cb318c3357439ee2f9f52 Mon Sep 17 00:00:00 2001 From: Ryan Hendrickson Date: Sun, 26 Jul 2026 11:14:37 -0400 Subject: [PATCH 24/45] perf(pipeline): batch conflict-key emission into one lock acquisition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After 4a02d8eeb and 612ae8cd5 removed the txn.cache.Lock() convoy, Txn.conflicts became the only remaining mutex contention in the apply path: on a 32-core ingest run 25% of pipeline goroutines were blocked on it and 46% were running inside addConflictKeyWithUid. The map insert was never the problem. A line-level profile attributed 80% of that function's 6.80% of total CPU to acquiring and releasing the mutex (8.75s + 5.33s of 17.86s cum) and only 15% to the map writes (2.71s), with every worker in every predicate contending one lock once per key. So batch instead of shard. Each Process* accumulates its conflict keys in a conflictBuf and flushes them under a single txn.Lock(); parallel workers get their own buffers, merged after wg.Wait(), and never touch txn.Mutex at all. Sharding the container was considered and rejected: it only attacks the 2.71s of inserts (~1% of total CPU) while changing Txn.conflicts' type, nine test sites, FillContext, and NumShards — which is shared with the delta and index maps. Keys are expanded EAGERLY into uint64. Every call site reuses one scratch key buffer across iterations, so a buffer retaining the []byte and fingerprinting at flush time would hash the last uid's bytes for every entry — wrong keys, no panic, nothing for -race to catch. In InsertTokenizerIndexes the flush defer is registered ABOVE cache.Lock() so LIFO runs it after cache.Unlock(); registering it below compiles, passes every test, and silently keeps the txn.Lock()-inside- cache.Lock() nesting this removes. Workers break rather than return on error so already-buffered keys still flush. The pre-existing error path never rolled back emitted keys, so dropping them would change the set — and since Zero's hasConflict is a pure existential, a subset risks a lost update while a superset only costs a spurious abort. Measured on i4i.16xlarge (32 physical cores), 25 [uid] @reverse predicates, 20k triples/txn, 64 threads, budget=30, 2 runs per arm: badger_write_bytes_user 11.44 -> 12.45 MB/s (+8.8%) triples/s 113001 -> 123732 (+9.5%) Cumulative over stock across the three commits: 9.63 -> 12.45 MB/s (+29%). The conflict-key path disappears from the goroutine profile entirely: share of pipeline goroutines 68.5% -> 0.0%, blocked on its mutex 23.0% -> 0.0%, and total pipeline goroutine observations fall 518 -> 216. The new top blocker is Deltas.AddToDeltas (57.6% of a much smaller blocked population) — shard contention on the delta map, where NumShards = 30 is thin for 64 vCPU. New TestFillContextKeysGolden pins the exact ctx.Keys sent to Zero against a golden captured at 612ae8cd5, and asserts they are startTs-independent. The six existing byte-identical tests only compare budget 0 vs N, so they cannot catch a regression that changes the serial and parallel paths the same way; nothing asserted FillContext's output before. Full posting package green under -race. Co-Authored-By: Claude Opus 5 --- posting/conflict_keys_test.go | 153 +++++++++++++++++++++++++++ posting/index.go | 190 +++++++++++++++++++++++++++------- posting/mvcc.go | 11 +- 3 files changed, 309 insertions(+), 45 deletions(-) create mode 100644 posting/conflict_keys_test.go diff --git a/posting/conflict_keys_test.go b/posting/conflict_keys_test.go new file mode 100644 index 00000000000..811d3df7455 --- /dev/null +++ b/posting/conflict_keys_test.go @@ -0,0 +1,153 @@ +/* + * Golden test for the conflict keys that actually reach Zero. + * + * Every other conflict-key test in this package compares budget=0 against + * budget=N and reads txn.conflicts DIRECTLY. That catches a divergence between + * the serial and parallel paths, but it cannot catch a UNIFORM regression — + * one that changes both paths the same way (dropping the "skip zero" guard, + * mis-deriving a key, losing a call site). It also asserts nothing about + * FillContext, which is the function that actually converts the set into the + * api.TxnContext.Keys sent to Zero's conflict detector. + * + * This test pins that output against a golden captured before the conflict-key + * batching change, and asserts the keys do not depend on startTs (which is what + * makes a hardcoded golden legitimate). + */ + +package posting + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/dgraph-io/dgo/v250/protos/api" + "github.com/dgraph-io/dgraph/v25/protos/pb" + "github.com/dgraph-io/dgraph/v25/schema" + "github.com/dgraph-io/dgraph/v25/x" +) + +// goldenFillContextKeys is the exact ctx.Keys emitted by fillContextFixture at +// commit 612ae8cd5 (before conflict-key batching). Sorted and deduped by +// x.Unique inside FillContext, so it is stable. +var goldenFillContextKeys = []string{ + "10cqjlaf4j7hn", "11lxsz9zty2i4", "11rszn4j2ifrp", "15a7susihkrs5", + "15hr881x3365h", "15ssyzlmd8o6", "160144qa0z7c9", "16g45dj9u9wtl", + "16gddhvir9r5o", "17wz11s2rsjb0", "18r29da831jyv", "19v83k15qewy", + "19v83k15qf35", "19v83k15qf3g", "19v83k15qf3r", "1bn2uz26zl65", + "1csiiy24nvqav", "1csiiy24nvqb6", "1csiiy24nvqbh", "1cths9gcm15ya", + "1cths9gcm15yj", "1cths9gcm15yo", "1cths9gcm15yx", "1cths9gcm15z2", + "1cths9gcm15zx", "1cuw68vo5k0sx", "1d6h1mmm85zkh", "1dejkfi3t87uo", + "1dejkfi3t87uz", "1dejkfi3t87va", "1djh0on4tr7e7", "1djh0on4tr7im", + "1djh0on4tr7is", "1djh0on4tr7ix", "1exa3ehusmxld", "1ft3jzj1tai8s", + "1h0g0919r4ocj", "1h0g0919r4ocs", "1h0g0919r4od5", "1h0g0919r4of6", + "1j83u2a12v1ed", "1jdh8bcmnzjp9", "1jqs81nsjjhcd", "1mfllyn8p9mqy", + "1ni683zm14p37", "1nzb90h02v1ji", "1o1h924e2w8mw", "1o8pxqowvsz38", + "1ov8p3us03icd", "1q0yfx1ywiji", "1qihbzhyr587k", "1sjzq7432wmoj", + "1u3j524i9s5ky", "1vfnls53w4667", "1whetkryqjuqv", "1whetkryqjuro", + "1whetkryqjus1", "1whetkryqjusa", "1wxxpl268f9ai", "1xzpryyns5gd1", + "20g6onqogazec", "21m5fh88fejnf", "25j2xbwglqjei", "26sqlzuotoul3", + "278p0khy2r4bq", "27kronoo44soq", "27p1kzees9ayu", "281qho9c7jv02", + "28ht75d2lsrcz", "2b3pcywmvntws", "2b8pbxzp9xdg5", "2c6y486xygbnj", + "2cdvaxxkbeviz", "2d9ke6yxfknpj", "2ewhr6a3zocql", "2g6bgltmlq3f", + "2g92qcof8ed3k", "2ggs3cvcwaiz7", "2gnlxpa7apx06", "2hboxqqdmpyoa", + "2hptl0ky70li1", "2j0x0rx05xwwn", "2j7nzcgt3tg2p", "2jbbcctsnf9ro", + "2labkkz61gs9q", "2m96ie69oxeej", "2mynehoop9r44", "2mzimus36p2hz", + "2nwk2prfmoh9e", "2nwk2prfmohad", "2nwk2prfmoham", "2nwk2prfmohao", + "2nwk2prfmohav", "2nwk2prfmohax", "2pug92p0njx6w", "2pug92p0njx77", + "2pug92p0njx7i", "2qeyy1qrvq1ld", "2rauvzl3oxawo", "2wbm5aydy5tgg", + "2z4k5bs14nthf", "304we5tysszao", "30cmwr1gt1wlh", "314vcu3mju0jp", + "31iqzow4zlj45", "32ds5k706j92m", "32zfm4ay6zcaj", "33cfumtxb0glw", + "33drou0jikak3", "33jj1jcfb0z98", "33jj1jcfb0z9d", "33jj1jcfb0z9y", + "33jj1jcfb0za3", "34cn0esjmfu3o", "35jmgbjjkh8m6", "36u6n2bsbgngj", + "383okwwymw1by", "38a2ly4gdf2vk", "3a77iqqzkqg5p", "3chush5nnfw3j", + "3dv5y81l44ztv", "3dv5y81l44ztw", "3dv5y81l44zu5", "3dv5y81l44zu9", + "3dv5y81l44zui", "3dv5y81l44zuw", "3ekw731h56sjb", "3fyvp2gb6k3eu", + "3fyvp2gb6k3f1", "3fyvp2gb6k3f4", "3fyvp2gb6k3fj", "3fyvp2gb6k3hv", + "3fyvp2gb6k3hw", "3h8iwbbbmy9gz", "3i03t92jon73g", "3iwmbwzy03xbw", + "3jcin7c3g1zpf", "3jcin7c3g1zvr", "3jcin7c3g1zvw", "3jcin7c3g1zw5", + "3jcin7c3g1zwa", "3kvjt2iy5h2hd", "3lv7emyt3j9x5", "3mr4yt3pdct4r", + "3n1300qedgip9", "3p00hgoufmvxg", "3ri95zn5gkbc6", "3t8ugshh500c", + "3tofm6wdxi5iv", "3vzymxuljypwu", "4p47rm1wmn4u", "5zvwwexrvcxu", + "6lhiubvn7tg", "7svgt3i2uiw2", "8nyqq3jqcx8m", "8nyqq3jqcx95", + "8nyqq3jqcxbn", "90afjmkvqjh", "9tw59jomuq26", "adauhy6jj6sg", + "at8qvmgud4v9", "bgfl7a3jcbsc", "bzhvrptojpya", "bzhvrptojpys", + "bzhvrptojpz3", "bzhvrptojq39", "es4kwi7vusy0", "gghvi9ijw2zm", + "gghvi9ijw347", "gghvi9ijw34e", "gghvi9ijw34l", "gghvi9ijw34r", + "gghvi9ijw34s", "h9ew5io0bo44", "ibbghd28i0h4", "ietvcnshejbg", + "k131yhlfmlej", "khxohb579335", "lcc6rinevxt5", "muojk8zz9w0w", + "muojk8zz9w1t", "muojk8zz9w22", "muojk8zz9w2n", "ozd4b9biaeu1", + "pfx112nlxs4m", "pmr9d40ky1b4", "pmr9d40ky1bd", "pmr9d40ky1bq", + "pmr9d40ky1bz", "pmr9d40ky1g2", "r1xso673xr1m", "scl1q4kj5tev", + "sqyvtqgvi3ki", "ss117ufjqdru", "ttuep1j4ee49", "u5u8ncldw4k8", + "umk34xrekawv", "v001pn39tpox", "vdbosf0xcryy", "vvhfvkcuf3bk", + "w9k46kpeuwwr", "wmvftaeam6l3", "x36thxe2e6hn", "ytkefiufo8ur", + "zoywn6yafefw", +} + +// fillContextFixture applies a fixed multi-predicate batch exercising several +// distinct conflict-key derivations — scalar index, [uid] @reverse (which emits +// per-target keys), @upsert (bare fingerprint, no uid XOR), and @noconflict +// (must emit nothing) — then returns FillContext's ctx.Keys. +func fillContextFixture(t *testing.T, startTs uint64, budget int) []string { + t.Helper() + + require.NoError(t, pstore.DropAll()) + MemLayerInstance.clear() + require.NoError(t, schema.ParseBytes([]byte(` + cname: string @index(exact) . + clink: [uid] @reverse . + cup: string @index(exact) @upsert . + cnc: string @index(exact) @noconflict . + `), 1)) + + ob := x.WorkerConfig.MutationsPipelineGoroutines + x.WorkerConfig.MutationsPipelineGoroutines = budget + defer func() { x.WorkerConfig.MutationsPipelineGoroutines = ob }() + + cname := x.AttrInRootNamespace("cname") + clink := x.AttrInRootNamespace("clink") + cup := x.AttrInRootNamespace("cup") + cnc := x.AttrInRootNamespace("cnc") + + var edges []*pb.DirectedEdge + for i := 0; i < 40; i++ { + ent := uint64(700_000 + i) + edges = append(edges, + strEdge(cname, ent, "n"+string(rune('a'+i%7))), + strEdge(cup, ent, "u"+string(rune('a'+i%5))), + strEdge(cnc, ent, "c"+string(rune('a'+i%3))), + uidEdge(clink, ent, uint64(800_000+i%11)), + ) + } + + txn := NewTxn(startTs) + mp := NewMutationPipeline(txn) + require.NoError(t, mp.Process(context.Background(), edges)) + + ctx := &api.TxnContext{} + txn.FillContext(ctx, 1, true) // isErrored=true: skip txn.Update(), keep the test to Keys + return ctx.Keys +} + +// TestFillContextKeysGolden pins the conflict keys that reach Zero. +func TestFillContextKeysGolden(t *testing.T) { + // A hardcoded golden is only valid if the keys are startTs-independent. + // Conflict keys derive from fingerprint(keyBytes) ^ posting.Uid, so they + // should be — assert it rather than assume it. + a := fillContextFixture(t, 4_100_000, 0) + b := fillContextFixture(t, 4_200_000, 0) + require.Equal(t, a, b, "conflict keys must not depend on startTs") + require.NotEmpty(t, a, "fixture emitted no conflict keys") + + if len(goldenFillContextKeys) == 0 { + t.Fatalf("GOLDEN NOT SET — captured %d keys:\n%#v", len(a), a) + } + require.Equal(t, goldenFillContextKeys, a, + "ctx.Keys sent to Zero changed; a SUBSET risks lost updates") + + // The parallel path must emit the same set as the serial path. + require.Equal(t, a, fillContextFixture(t, 4_300_000, 32), + "budget>1 must emit identical ctx.Keys") +} diff --git a/posting/index.go b/posting/index.go index 1a4dac96463..9cc1ed6a882 100644 --- a/posting/index.go +++ b/posting/index.go @@ -276,6 +276,16 @@ func (mp *MutationPipeline) InsertTokenizerIndexes(ctx context.Context, pipeline wg.Wait() + // Buffer conflict keys and flush them with ONE txn.Lock() acquisition. + // + // This defer MUST be registered ABOVE cache.Lock(): defers run LIFO, so + // cache.Unlock() (registered second) fires first and the flush then runs with + // the cache lock already released. Registering it below would still compile + // and still pass every byte-identical test, while silently keeping the + // txn.Lock()-inside-cache.Lock() nesting this change exists to remove. + var cbuf conflictBuf + defer mp.txn.flushConflicts(&cbuf) + mp.txn.cache.Lock() defer mp.txn.cache.Unlock() @@ -298,7 +308,7 @@ func (mp *MutationPipeline) InsertTokenizerIndexes(ctx context.Context, pipeline if info.hasUpsert { err := indexesGenInMutation.Iterate(func(key string, value *MutableLayer) error { updateFn(key, value) - mp.txn.addConflictKey(farm.Fingerprint64([]byte(key))) + cbuf.addRaw(farm.Fingerprint64([]byte(key))) return nil }) if err != nil { @@ -307,7 +317,7 @@ func (mp *MutationPipeline) InsertTokenizerIndexes(ctx context.Context, pipeline } else { err := indexesGenInMutation.Iterate(func(key string, value *MutableLayer) error { updateFn(key, value) - mp.txn.addConflictKeyWithUid([]byte(key), value.currentEntries, info.hasUpsert, info.noConflict) + cbuf.addForPostings([]byte(key), value.currentEntries, info.hasUpsert, info.noConflict) return nil }) if err != nil { @@ -426,7 +436,7 @@ func (mp *MutationPipeline) ProcessList(ctx context.Context, pipeline *Predicate // Lang predicates ride the SAME path: all language postings for one entity fold // into that entity's single data key (one-to-one by source // entity), so the disjointness invariant holds for them too. - writeListDataUid := func(uid uint64, pl *pb.PostingList, scratchKey []byte, + writeListDataUid := func(uid uint64, pl *pb.PostingList, scratchKey []byte, cbuf *conflictBuf, store func(key string, input *pb.PostingList) (*pb.PostingList, error)) error { if len(pl.Postings) == 0 { return nil @@ -440,7 +450,7 @@ func (mp *MutationPipeline) ProcessList(ctx context.Context, pipeline *Predicate return err } if !info.noConflict { - mp.txn.addConflictKeyWithUid(scratchKey, newPl, info.hasUpsert, info.noConflict) + cbuf.addForPostings(scratchKey, newPl, info.hasUpsert, info.noConflict) } return nil } @@ -486,6 +496,12 @@ func (mp *MutationPipeline) ProcessList(ctx context.Context, pipeline *Predicate return merged, nil } + // Conflict keys are buffered and flushed with ONE txn.Lock() acquisition for + // the whole predicate. The reverse/index/count passes above have already run + // and flushed their own buffers; this one covers the forward write only. + var cbuf conflictBuf + defer mp.txn.flushConflicts(&cbuf) + // Legacy path: the intra-predicate budget is disabled entirely (workers == 0). // Keep the locked AddDelta so "budget off == byte-identical legacy" holds. if pipeline.workers <= 0 { @@ -493,7 +509,7 @@ func (mp *MutationPipeline) ProcessList(ctx context.Context, pipeline *Predicate return mp.txn.AddDelta(k, in, info.isUid, true) } for uid, pl := range postings { - if err := writeListDataUid(uid, pl, dataKey, serialStore); err != nil { + if err := writeListDataUid(uid, pl, dataKey, &cbuf, serialStore); err != nil { return err } } @@ -507,7 +523,7 @@ func (mp *MutationPipeline) ProcessList(ctx context.Context, pipeline *Predicate // whenever the budget is below ~2x the predicate count. if pipeline.workers == 1 { for uid, pl := range postings { - if err := writeListDataUid(uid, pl, dataKey, concStore); err != nil { + if err := writeListDataUid(uid, pl, dataKey, &cbuf, concStore); err != nil { return err } } @@ -537,6 +553,7 @@ func (mp *MutationPipeline) ProcessList(ctx context.Context, pipeline *Predicate var wg sync.WaitGroup var once sync.Once var firstErr error + bufs := make([][]uint64, k) for w := 0; w < k; w++ { start := w * width if start >= len(uidList) { @@ -547,20 +564,29 @@ func (mp *MutationPipeline) ProcessList(ctx context.Context, pipeline *Predicate end = len(uidList) } wg.Add(1) - go func(sub []uint64) { + go func(w int, sub []uint64) { defer wg.Done() // Per-worker scratch buffer: sharing dataKey across goroutines races. localKey := x.DataKey(pipeline.attr, 0) + // Per-worker conflict buffer: appended lock-free, merged below. + var local conflictBuf for _, uid := range sub { - if err := writeListDataUid(uid, postings[uid], localKey, + if err := writeListDataUid(uid, postings[uid], localKey, &local, concStore); err != nil { once.Do(func() { firstErr = err }) - return + // break, not return: see ProcessSingle — dropping already + // buffered keys would shrink the conflict set, and a subset + // risks a lost update. + break } } - }(uidList[start:end]) + bufs[w] = local.keys + }(w, uidList[start:end]) } wg.Wait() + for _, b := range bufs { + cbuf.keys = append(cbuf.keys, b...) + } return firstErr } @@ -633,7 +659,7 @@ func (mp *MutationPipeline) ProcessReverse(ctx context.Context, pipeline *Predic // the parallel path; for a given distinct key both write identical bytes and // return the same deduped list, so the committed result (reverse data bytes // and conflict-key set) is store-order-independent. - writeReverseTarget := func(uid uint64, pl *pb.PostingList, scratchKey []byte, + writeReverseTarget := func(uid uint64, pl *pb.PostingList, scratchKey []byte, cbuf *conflictBuf, store func(key string, input *pb.PostingList) (*pb.PostingList, error)) error { if len(pl.Postings) == 0 { return nil @@ -644,10 +670,15 @@ func (mp *MutationPipeline) ProcessReverse(ctx context.Context, pipeline *Predic if err != nil { return err } - mp.txn.addConflictKeyWithUid(scratchKey, newPl, info.hasUpsert, info.noConflict) + cbuf.addForPostings(scratchKey, newPl, info.hasUpsert, info.noConflict) return nil } + // Conflict keys are buffered and flushed with ONE txn.Lock() acquisition for + // the whole reverse pass; worker goroutines never touch txn.Mutex. + var cbuf conflictBuf + defer mp.txn.flushConflicts(&cbuf) + // concStore reproduces AddDelta(..., true, true)'s committed bytes without // taking the global cache lock. Two behaviors must be matched exactly: // @@ -695,7 +726,7 @@ func (mp *MutationPipeline) ProcessReverse(ctx context.Context, pipeline *Predic return mp.txn.AddDelta(k, in, true, true) } for uid, pl := range reverseredMap { - if err := writeReverseTarget(uid, pl, key, serialStore); err != nil { + if err := writeReverseTarget(uid, pl, key, &cbuf, serialStore); err != nil { return err } } @@ -720,7 +751,7 @@ func (mp *MutationPipeline) ProcessReverse(ctx context.Context, pipeline *Predic // type — can produce the same key string. if pipeline.workers == 1 || len(reverseredMap) < reverseParallelMinTargets { for uid, pl := range reverseredMap { - if err := writeReverseTarget(uid, pl, key, concStore); err != nil { + if err := writeReverseTarget(uid, pl, key, &cbuf, concStore); err != nil { return err } } @@ -771,6 +802,7 @@ func (mp *MutationPipeline) ProcessReverse(ctx context.Context, pipeline *Predic var wg sync.WaitGroup var once sync.Once var firstErr error + bufs := make([][]uint64, k) for w := 0; w < k; w++ { start := w * width if start >= len(uidList) { @@ -781,20 +813,28 @@ func (mp *MutationPipeline) ProcessReverse(ctx context.Context, pipeline *Predic end = len(uidList) } wg.Add(1) - go func(sub []uint64) { + go func(w int, sub []uint64) { defer wg.Done() // Per-worker scratch buffer: sharing `key` across goroutines races. localKey := x.ReverseKey(pipeline.attr, 0) + // Per-worker conflict buffer: appended lock-free, merged below. + var local conflictBuf for _, uid := range sub { - if err := writeReverseTarget(uid, reverseredMap[uid], localKey, + if err := writeReverseTarget(uid, reverseredMap[uid], localKey, &local, concStore); err != nil { once.Do(func() { firstErr = err }) - return + // break, not return: see ProcessSingle — a shrunken conflict + // set risks a lost update. + break } } - }(uidList[start:end]) + bufs[w] = local.keys + }(w, uidList[start:end]) } wg.Wait() + for _, b := range bufs { + cbuf.keys = append(cbuf.keys, b...) + } return firstErr } @@ -924,26 +964,83 @@ func (mp *MutationPipeline) handleOldDeleteForList(pipeline *PredicatePipeline, return nil } -func (txn *Txn) addConflictKeyWithUid(key []byte, pl *pb.PostingList, hasUpsert bool, hasNoConflict bool) { +// conflictBuf accumulates a worker's conflict keys so they can be inserted into +// txn.conflicts with a SINGLE txn.Lock() acquisition instead of one per call. +// +// The map insert itself is cheap; the lock traffic is not. A CPU profile of a +// 32-core ingest run attributed 80% of addConflictKeyWithUid's cost to acquiring +// and releasing the mutex and only 15% to the map writes, with every parallel +// worker in every predicate contending the same lock. Buffering collapses that +// to one acquisition per Process* call, and lets the worker goroutines run +// without ever touching txn.Mutex. +// +// Keys are expanded EAGERLY into uint64s. Callers reuse a single scratch key +// buffer across iterations (see the localKey allocations in the parallel +// branches), so a buffer that retained the []byte and fingerprinted at flush +// time would hash the LAST uid's bytes for every entry — wrong conflict keys, +// with no panic and nothing for the race detector to catch. +type conflictBuf struct { + keys []uint64 +} + +// addRaw buffers a pre-computed conflict key, preserving addConflictKey's +// "skip zero" guard. +func (c *conflictBuf) addRaw(conflictKey uint64) { + if conflictKey > 0 { + c.keys = append(c.keys, conflictKey) + } +} + +// addForPostings buffers one conflict key per posting, matching the legacy +// addConflictKeyWithUid derivation exactly: fingerprint of the FULL key buffer +// (including its 8 uid bytes) XOR each posting's uid, or the bare fingerprint +// for an @upsert predicate. Note the fingerprint now happens outside any lock. +func (c *conflictBuf) addForPostings(key []byte, pl *pb.PostingList, hasUpsert, hasNoConflict bool) { if hasNoConflict { return } - txn.Lock() - defer txn.Unlock() - if txn.conflicts == nil { - txn.conflicts = make(map[uint64]struct{}) - } keyHash := farm.Fingerprint64(key) if hasUpsert { - txn.conflicts[keyHash] = struct{}{} + c.keys = append(c.keys, keyHash) return } for _, post := range pl.Postings { - txn.conflicts[keyHash^post.Uid] = struct{}{} + c.keys = append(c.keys, keyHash^post.Uid) } } +// flushConflicts drains a buffer into txn.conflicts under one lock acquisition. +// Safe to call on an empty buffer, and safe to call repeatedly. The resulting +// SET is what matters — FillContext sorts and dedups via x.Unique before the +// keys reach Zero — so merge order across workers is irrelevant. +func (txn *Txn) flushConflicts(c *conflictBuf) { + if c == nil || len(c.keys) == 0 { + return + } + txn.Lock() + if txn.conflicts == nil { + txn.conflicts = make(map[uint64]struct{}, len(c.keys)) + } + for _, k := range c.keys { + txn.conflicts[k] = struct{}{} + } + txn.Unlock() + c.keys = c.keys[:0] +} + +func (txn *Txn) addConflictKeyWithUid(key []byte, pl *pb.PostingList, hasUpsert bool, hasNoConflict bool) { + var c conflictBuf + c.addForPostings(key, pl, hasUpsert, hasNoConflict) + txn.flushConflicts(&c) +} + func (mp *MutationPipeline) ProcessCount(ctx context.Context, pipeline *PredicatePipeline, postings *map[uint64]*pb.PostingList, info predicateInfo, isListEdge bool, isReverseEdge bool) error { + // Conflict keys are buffered and flushed with ONE txn.Lock() acquisition. + // ProcessCount runs single-goroutine (called serially from ProcessList and + // ProcessReverse), so one buffer suffices. + var cbuf conflictBuf + defer mp.txn.flushConflicts(&cbuf) + dataKey := x.DataKey(pipeline.attr, 0) if isReverseEdge { dataKey = x.ReverseKey(pipeline.attr, 0) @@ -1022,10 +1119,10 @@ func (mp *MutationPipeline) ProcessCount(ctx context.Context, pipeline *Predicat if updated { if !isListEdge { if !info.noConflict { - mp.txn.addConflictKey(farm.Fingerprint64(dataKey)) + cbuf.addRaw(farm.Fingerprint64(dataKey)) } } else { - mp.txn.addConflictKeyWithUid(dataKey, postingList, info.hasUpsert, info.noConflict) + cbuf.addForPostings(dataKey, postingList, info.hasUpsert, info.noConflict) } } @@ -1049,7 +1146,7 @@ func (mp *MutationPipeline) ProcessCount(ctx context.Context, pipeline *Predicat if newPl, err := mp.txn.AddDelta(string(ck), pl, true, true); err != nil { return err } else { - mp.txn.addConflictKeyWithUid(ck, newPl, info.hasUpsert, info.noConflict) + cbuf.addForPostings(ck, newPl, info.hasUpsert, info.noConflict) } } @@ -1214,7 +1311,7 @@ func (mp *MutationPipeline) ProcessSingle(ctx context.Context, pipeline *Predica // a data race across goroutines. store is AddDelta on the serial path and the // lock-free AddDeltaConcurrent on the parallel path; for a given distinct key // both write identical bytes, so the result is store-order-independent. - writeDataUid := func(uid uint64, pl *pb.PostingList, scratchKey []byte, + writeDataUid := func(uid uint64, pl *pb.PostingList, scratchKey []byte, cbuf *conflictBuf, store func(string, *pb.PostingList) error) error { // An empty PostingList means nothing was accumulated for this uid (e.g. // a DEL whose value did not match the committed value, or a same-value @@ -1235,7 +1332,7 @@ func (mp *MutationPipeline) ProcessSingle(ctx context.Context, pipeline *Predica key := baseKey + string(scratchKey[len(scratchKey)-8:]) if !info.noConflict { - mp.txn.addConflictKey(farm.Fingerprint64([]byte(key))) + cbuf.addRaw(farm.Fingerprint64([]byte(key))) } writePl := pl @@ -1262,11 +1359,17 @@ func (mp *MutationPipeline) ProcessSingle(ctx context.Context, pipeline *Predica return store(key, writePl) } + // Conflict keys are buffered and flushed with ONE txn.Lock() acquisition for + // the whole predicate instead of one per uid. Worker goroutines below get + // their own buffers and so never touch txn.Mutex at all. + var cbuf conflictBuf + defer mp.txn.flushConflicts(&cbuf) + // Legacy path: the intra-predicate budget is disabled entirely (workers == 0). // Keep the locked AddDelta so "budget off == byte-identical legacy" holds. if pipeline.workers <= 0 { for uid, pl := range postings { - if err := writeDataUid(uid, pl, dataKey, + if err := writeDataUid(uid, pl, dataKey, &cbuf, func(k string, wpl *pb.PostingList) error { _, err := mp.txn.AddDelta(k, wpl, false, false) return err @@ -1288,7 +1391,7 @@ func (mp *MutationPipeline) ProcessSingle(ctx context.Context, pipeline *Predica // encodes attrLen plus a per-type discriminator byte). if pipeline.workers == 1 { for uid, pl := range postings { - if err := writeDataUid(uid, pl, dataKey, mp.txn.AddDeltaConcurrent); err != nil { + if err := writeDataUid(uid, pl, dataKey, &cbuf, mp.txn.AddDeltaConcurrent); err != nil { return err } } @@ -1318,6 +1421,7 @@ func (mp *MutationPipeline) ProcessSingle(ctx context.Context, pipeline *Predica var wg sync.WaitGroup var once sync.Once var firstErr error + bufs := make([][]uint64, k) for w := 0; w < k; w++ { start := w * width if start >= len(uidList) { @@ -1328,20 +1432,32 @@ func (mp *MutationPipeline) ProcessSingle(ctx context.Context, pipeline *Predica end = len(uidList) } wg.Add(1) - go func(sub []uint64) { + go func(w int, sub []uint64) { defer wg.Done() // Per-worker scratch buffer: sharing dataKey across goroutines races. localKey := x.DataKey(pipeline.attr, 0) + // Per-worker conflict buffer: appended lock-free, merged below. + var local conflictBuf for _, uid := range sub { - if err := writeDataUid(uid, postings[uid], localKey, + if err := writeDataUid(uid, postings[uid], localKey, &local, mp.txn.AddDeltaConcurrent); err != nil { once.Do(func() { firstErr = err }) - return + // break, not return: keys already buffered for successfully + // written uids must still be emitted. The pre-existing error + // path never rolled back emitted keys, so dropping them here + // would CHANGE the conflict set — and since Zero's hasConflict + // is a pure existential over the set, a subset risks a lost + // update while a superset only costs a spurious abort. + break } } - }(uidList[start:end]) + bufs[w] = local.keys + }(w, uidList[start:end]) } wg.Wait() + for _, b := range bufs { + cbuf.keys = append(cbuf.keys, b...) + } return firstErr } diff --git a/posting/mvcc.go b/posting/mvcc.go index 66cec986f7f..b1b2388e5d0 100644 --- a/posting/mvcc.go +++ b/posting/mvcc.go @@ -236,14 +236,9 @@ func (txn *Txn) ShouldAbort() bool { } func (txn *Txn) addConflictKey(conflictKey uint64) { - txn.Lock() - defer txn.Unlock() - if txn.conflicts == nil { - txn.conflicts = make(map[uint64]struct{}) - } - if conflictKey > 0 { - txn.conflicts[conflictKey] = struct{}{} - } + var c conflictBuf + c.addRaw(conflictKey) + txn.flushConflicts(&c) } // FillContext updates the given transaction context with data from this transaction. From 941a78cc4adcf24705f9e3603659c171d57385a4 Mon Sep 17 00:00:00 2001 From: Ryan Hendrickson Date: Sun, 26 Jul 2026 12:16:25 -0400 Subject: [PATCH 25/45] perf(query): snapshot the uid lease once per mutation instead of per nquad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit worker.MaxLeaseId takes a global RWMutex read lock on the membership state to return a single uint64. ExtractBlankUIDs called verifyUid — and thus MaxLeaseId — for EVERY subject and every object uid, including blank nodes, whose uid is 0 and always passes. A 20k-nquad mutation of uid edges therefore took roughly 40k acquisitions of shared cluster state to compare against a number that barely moves. Take one snapshot per ExtractBlankUIDs call and only fall through to verifyUid for uids above it. The lease is monotonically non-decreasing, so a uid at or below the snapshot was already leased and cannot become unleased; a uid above it still goes through the live re-read and the existing wait-for-lease loop, so a uid racing an in-flight lease extension is accepted exactly as before. A stale (lower) snapshot is conservative — it can only route a uid down the slow path, never wrongly accept one. Measured on i4i.16xlarge (32 physical cores), 25 [uid] @reverse predicates, 20k triples/txn with EXPLICIT uids, 64 threads, 2 runs per arm: ExtractBlankUIDs 3.67% -> absent from the profile verifyUid 3.52% -> absent MaxLeaseId 3.51% -> absent CPU utilisation 8.85% -> 8.6% of 64 vCPU Throughput is UNCHANGED (12.53 MB/s both arms). That is expected and worth recording: at ~9% CPU on 64 vCPU this Alpha is not CPU-bound, because processApplyCh applies one Raft entry at a time. This removes real work and real contention on shared membership state, which matters for a CPU-bound deployment, for co-located Alphas, and once cross-transaction concurrency raises utilisation — but it does not move throughput today. Only relevant when mutations carry explicit uids, which is the common case for the high-ingest workload this series targets. Co-Authored-By: Claude Opus 5 --- query/mutation.go | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/query/mutation.go b/query/mutation.go index e854ab1ea3a..4603fc9c274 100644 --- a/query/mutation.go +++ b/query/mutation.go @@ -155,6 +155,28 @@ func verifyUid(ctx context.Context, uid uint64) error { func ExtractBlankUIDs(ctx context.Context, gmuList []*dql.Mutation) (map[string]uint64, error) { newUids := make(map[string]uint64) var err error + + // worker.MaxLeaseId takes a global RWMutex read lock on the membership state + // to return a single uint64. verifyUid consulted it for EVERY subject and + // every object uid — including blank nodes, whose uid is 0 — so a 20k-nquad + // mutation of uid edges took roughly 40k acquisitions of shared cluster state + // just to compare against a number that barely moves. On a 32-core ingest + // profile that lock traffic was ~6% of total CPU. + // + // The lease is monotonically non-decreasing, so one snapshot taken here is a + // safe fast path: any uid at or below it was already leased and cannot become + // unleased. Only uids ABOVE the snapshot fall through to verifyUid, which + // re-reads the live value and keeps the existing wait-for-lease behaviour, so + // a uid racing an in-flight lease extension is still accepted exactly as + // before. A stale (lower) snapshot is conservative — it can only send a uid + // down the slow path, never wrongly accept one. + maxLease := worker.MaxLeaseId() + verify := func(uid uint64) error { + if uid <= maxLease { + return nil + } + return verifyUid(ctx, uid) + } for _, gmu := range gmuList { for _, nq := range gmu.Set { // We dont want to assign uids to these. @@ -171,7 +193,7 @@ func ExtractBlankUIDs(ctx context.Context, gmuList []*dql.Mutation) (map[string] } else if uid, err = dql.ParseUid(nq.Subject); err != nil { return newUids, err } - if err = verifyUid(ctx, uid); err != nil { + if err = verify(uid); err != nil { return newUids, err } @@ -182,7 +204,7 @@ func ExtractBlankUIDs(ctx context.Context, gmuList []*dql.Mutation) (map[string] } else if uid, err = dql.ParseUid(nq.ObjectId); err != nil { return newUids, err } - if err = verifyUid(ctx, uid); err != nil { + if err = verify(uid); err != nil { return newUids, err } } From 377429818deecc7ac042a88e39f61b1e05f66283 Mon Sep 17 00:00:00 2001 From: Ryan Hendrickson Date: Sun, 26 Jul 2026 12:37:23 -0400 Subject: [PATCH 26/45] docs: record EC2 validation of the apply-path optimisation series MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured results for the four perf commits on an i4i.16xlarge (32 physical cores / 64 vCPU, local NVMe), 25 [uid] @reverse predicates, 20k triples per transaction, 64 writer threads, 300s runs, using badger_write_bytes_user — the same metric as the Preview1 vs Preview1-Hybrid comparison in #9727. Records the throughput ladder (9.63 -> 12.45 MB/s), the goroutine-parking progression that shows the bottleneck migrating from the global cache lock to the conflict-key lock and then away entirely, and the CPU utilisation that stayed under 10% of 64 vCPU throughout. Also documents the runs that were DISCARDED and why, so they are not re-run by accident: a stale Alpha holding :8080 that made both arms profile the same binary, a load generator whose file striding caused 1.47M transaction aborts, and one throughput outlier. Each of these produced plausible-looking numbers that were wrong. Co-Authored-By: Claude Opus 5 --- ec2-validation-results.md | 147 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 ec2-validation-results.md diff --git a/ec2-validation-results.md b/ec2-validation-results.md new file mode 100644 index 00000000000..b90fb01e9cd --- /dev/null +++ b/ec2-validation-results.md @@ -0,0 +1,147 @@ +# EC2 validation — parallel reverse write (32 physical cores) + +Validation of the `ProcessReverse` parallel-write change against a real Alpha +under sustained multi-threaded ingest, on hardware large enough to exercise the +parallelism (the prior laptop measurements had only 4 physical cores). + +## Setup + +| | | +|---|---| +| instance | `i4i.16xlarge` — 64 vCPU / **32 physical cores**, 495 GB RAM, local NVMe | +| CPU | Intel Xeon Platinum 8375C @ 2.90 GHz | +| OS / Go | RHEL 9.8, go1.26.3 | +| schema | 25 × `hcN: [uid] @reverse .` | +| batch | 20,000 triples/txn spanning **all 25 predicates**, 800 distinct reverse targets each | +| corpus | 600 distinct batches (12M unique triples), each thread owns a disjoint block | +| load | 64 threads, `commitNow=true`, 300 s per run | +| metric | `badger_write_bytes_user` delta / elapsed (the production "Badger Write Throughput") | + +Binaries built on the instance from the same tree and verified to differ by +disassembly: stock `ProcessReverse` references only `(*Txn).AddDelta`; patched +references both `AddDeltaConcurrent` and the legacy `AddDelta`. + +## Results + +### budget = 30 (the shipped production default), 2 paired runs each + +| build | triples/s | MB/s | +|---|---|---| +| stock | 97,030 / 96,833 | 9.64 / 9.61 | +| patched | 105,272 / 106,313 | 10.58 / 10.57 | +| **delta** | **+9.1%** | **+9.9%** | + +Spread within each arm is ≤1%, and the arms do not overlap. + +### budget = -1 (auto; resolves to 64 on this box) + +| build | triples/s | MB/s | +|---|---|---| +| stock | 97,457 | 9.68 | +| patched | 113,081 | 11.29 | +| **delta** | **+16.0%** | **+16.6%** | + +**Best config vs production default** (patched+auto vs stock+30): **+16.7% +triples/s, +17.2% MB/s**. + +Note: auto does **not** help stock (97,457 vs 96,932 = +0.5%). It only pays once +the reverse write can actually consume the extra workers. The "just raise the +budget" free win hypothesised earlier is **not** supported. + +### Adding the forward-write gate fix (`612ae8cd5`) + +`ProcessList`/`ProcessSingle` had the identical gate coupling. Fixing it too: + +| build | budget=30 | auto | +|---|---|---| +| stock | 9.63 | 9.68 | +| + reverse (`4a02d8eeb`) | 10.58 | 11.29 | +| **+ forward (`612ae8cd5`)** | **11.41 / 11.47** | **11.45 / 11.31** | + +**+18.8% over stock at the production default.** Note the budget stops mattering +once the lock-free store is reachable at any grant — auto and 30 now agree to +within noise. Tuning `mutations-pipeline-goroutines` is no longer load-bearing +for this workload, which removes a whole class of misconfiguration. + +### Adding conflict-key batching (`5b69ac2b4`) + +| build | budget=30 MB/s | triples/s | +|---|---|---| +| stock | 9.63 | — | +| + reverse (`4a02d8eeb`) | 10.58 | — | +| + forward (`612ae8cd5`) | 11.44 | 113,001 | +| **+ conflict batching (`5b69ac2b4`)** | **12.45** | **123,732** | + +**+29.3% cumulative over stock.** The conflict-key path vanishes from the +goroutine profile (68.5% → 0.0% of pipeline goroutines; blocked 23.0% → 0.0%), +and total pipeline goroutine observations fall 518 → 216. New top blocker: +`Deltas.AddToDeltas` (57.6%) — shard contention on the delta map, `NumShards = 30`. +See `conflict-key-batching-plan.md`. + +## Where the time goes + +Goroutine dumps (100 samples/run, `?debug=2`), bucketed by pipeline pass and +blocking site, budget=30: + +| | stock | + reverse | + forward | +|---|---|---|---| +| parked pipeline goroutines | 701 | 566 | 643 | +| in `ProcessReverse` | **61.2%** | 35.7–39.9% | 58.8% | +| in `ProcessList/Single` | 34.2% | **56.2–60.1%** | 37.6% | +| blocked on global `cache.Lock` | **86.4%** | 41.4–52.5% | **0% (absent)** | +| blocked on `txn.Lock` (`addConflictKeyWithUid`) | **0.4%** | 30.7–31.4% | **71.2%** | +| on sharded `AddDeltaConcurrent` | 0.7% | 3.2–10.0% | 6.7% | +| **runnable** (doing work, not blocked) | ~13% | ~17% | **57.9%** | + +The global cache lock is **eliminated** from the blocking profile. The +conflict-key lock goes 0.4% → 31% → **71.2%**: a textbook bottleneck migration, +where each fix exposes the next. Goroutines actually running rise from ~13% to +**58%**. + +Two conclusions: + +1. **The reverse-write convoy is real and is removed.** Stock parks 61% of + pipeline goroutines in `ProcessReverse`, 86% of them on one global mutex — + this reproduces the production goroutine dump that motivated the work. After + the change reverse drops to ~37% and is no longer the top site. +2. **The bottleneck moves twice.** `ProcessList/Single` (which still gates its + lock-free store on `workers > 1`) becomes #1 at ~58%, and + `addConflictKeyWithUid` goes from 0.4% → **31%**. + +### Core-count sensitivity — why laptop numbers misled + +`addConflictKeyWithUid` measured **2.7%** on a 4-physical-core laptop and +**31%** here. Contention on a single mutex scales with contender count; small +boxes cannot see it. Any ranking of this work must be done on real hardware. + +### CPU utilization + +Mean CPU across runs: **6–8% of 64 vCPU** (~4–5 cores busy), patched slightly +higher than stock. The box is nowhere near CPU-bound. This is the architectural +ceiling described in `CLAUDE.md`: `processApplyCh` applies one Raft entry at a +time, so a single Alpha cannot use 64 cores regardless of intra-transaction +parallelism. Top CPU consumers (pprof, patched): `sync/atomic.(*Int32).Add` +7.2%, `runtime.memmove` 5.7%, `worker.(*groupi).Tablet` 6.3% cum, +`x.(*SafeMutex).RUnlock` 3.0%. + +## Honest caveats + +- Synthetic schema is **100% `[uid] @reverse`** with no indexes or counts, which + overstates the reverse share versus a real mixed schema. The real-workload + gain will be smaller in proportion to the `@reverse` fraction. +- 300 s runs, not the 30-minute production cycles. +- 32 physical cores, not 148. +- Each thread re-writes its own block of batches, so later writes hit existing + posting lists — realistic for upserts, not for pure first-time ingest. + +## Runs that were discarded (and why) + +- **First A/B (laptop):** a stale Alpha held `:8080`; the "patched" run profiled + the *stock* process. Detected because the patched binary's `AddDelta` is at + `index.go:684` but stacks showed `608`. Harness now asserts the serving pid. +- **First EC2 matrix:** the load generator strided through batch files + (`i += threads`), letting threads drift onto the same file and write identical + `(src,tgt)` pairs concurrently → 1.47M transaction aborts. Fixed by giving each + thread a disjoint contiguous block. +- **One `stock_b30` run at 54,324 triples/s** was an outlier (61 aborts); two + clean re-runs gave 97,030 and 96,833. The headline numbers use the re-runs. From 711452933decd595105cbdbd5c65e79dd360ddb3 Mon Sep 17 00:00:00 2001 From: Ryan Hendrickson Date: Sun, 26 Jul 2026 12:37:36 -0400 Subject: [PATCH 27/45] docs: conflict-key batching design and decision log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full write-up for 5b69ac2b4, kept because the interesting part is the decision NOT to shard. Sharding Txn.conflicts was the obvious fix and was rejected on arithmetic: a line-level profile showed ~80% of addConflictKeyWithUid's cost was lock acquire/release and only ~15% the map insert, so batching to one acquisition per Process* captured the win while sharding would have chased a ~1% residual — at the cost of changing the container type, nine test sites, FillContext, and NumShards, which is shared with the delta and index maps. Records the explicit revisit trigger so the question stays closed unless the numbers change. Also captures the three implementation traps that produce silent corruption rather than a failure: eager key expansion (call sites reuse one scratch key buffer, so a deferred fingerprint hashes the wrong uid), defer ordering in InsertTokenizerIndexes (registering the flush below cache.Lock compiles and passes every test while keeping the nested lock), and break-not-return on worker error (a shrunken conflict set risks a lost update, since Zero's hasConflict is a pure existential). Co-Authored-By: Claude Opus 5 --- conflict-key-batching-plan.md | 133 ++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 conflict-key-batching-plan.md diff --git a/conflict-key-batching-plan.md b/conflict-key-batching-plan.md new file mode 100644 index 00000000000..4bb53976f38 --- /dev/null +++ b/conflict-key-batching-plan.md @@ -0,0 +1,133 @@ +# Conflict-key batching — plan, decisions, and measured results + +Durable record of the third change in the intra-transaction apply-path series. +Commit `5b69ac2b4`, on `rahst12/hybrid-pipeline-reverse-parallel`. + +--- + +## Where this sits + +| commit | change | budget=30 MB/s | +|---|---|---| +| (stock) | branch base | 9.63 | +| `4a02d8eeb` | parallel reverse write, lock-free store at any grant | 10.58 (+9.9%) | +| `612ae8cd5` | lock-free forward write at a one-worker grant | 11.44 (+18.8%) | +| **`5b69ac2b4`** | **batch conflict-key emission** | **12.45 (+29.3%)** | + +All measured on `i4i.16xlarge` (32 physical cores / 64 vCPU, 495 GB, local NVMe), +25 `[uid] @reverse` predicates, 20k triples/txn, 64 writer threads, 300 s runs, +`badger_write_bytes_user` — the same metric used for the Preview1 vs +Preview1-Hybrid comparison in discussion #9727. + +--- + +## The problem + +After the first two commits removed the `txn.cache.Lock()` convoy, `Txn.conflicts` +(`posting/oracle.go:52`, a `map[uint64]struct{}` under `Txn`'s embedded +`sync.Mutex`) was the only remaining mutex contention: **25% of pipeline +goroutines blocked on it, 46% running inside `addConflictKeyWithUid`**. + +The decisive evidence was the line-level CPU profile — the map insert was never +the problem: + +| line | cum | | +|---|---|---| +| `txn.Lock()` | **8.75s** | 49% | +| deferred `Unlock` | **5.33s** | 31% | +| map insert | 2.71s | 15% | +| `Fingerprint64` | 0.27s | 1.5%, needlessly inside the lock | + +**~80% acquire/release, ~15% real work.** The cost was the *number of +acquisitions* — one per key, from every worker in every predicate. + +## Decision: batch, don't shard + +Sharding `Txn.conflicts` was the obvious move and was **rejected on the +arithmetic**. Batching removes the 14.08s of acquire/release (79% of the +function). What is left for sharding to attack is the 2.71s of inserts — about +**1% of total CPU** — in exchange for changing `Txn.conflicts`' type, mechanically +editing nine test sites, rewriting `FillContext`, and either raising `NumShards` +from 30 (shared with `posting/lists.go` deltas and indexMap) or maintaining a +second striped container. + +**Revisit trigger:** shard only if `flushConflicts` self-CPU exceeds 3% or +mutex-BLOCKED exceeds 5%. Post-change both are **0.0%**, so this stays closed. + +### Why it is safe + +- The embedded mutex on `Txn` protects `conflicts` and **nothing else** + (`lastUpdate` is guarded by `oracle`'s `SafeMutex`). +- The only reader is `FillContext`, which runs `x.Unique` — sorting and deduping — + so `ctx.Keys` was **already** order-independent. Zero's `hasConflict` is a pure + existential over the set. No consumer is order-, index-, or length-sensitive. +- Writers join (L2 `wg.Wait()`, then L1 `eg.Wait()`) strictly before + `FillContext` runs via `worker/mutation.go`. + +## Three traps, and how each was handled + +1. **Eager key expansion.** Buffers hold `uint64`, never `key []byte`. Every call + site reuses one scratch key buffer across iterations, so deferring the + fingerprint to flush time would hash the *last* uid's bytes for every entry — + wrong conflict keys, no panic, invisible to `-race`. +2. **Defer ordering in `InsertTokenizerIndexes`.** It takes `txn.Lock()` while + holding `txn.cache.Lock()`. The flush defer is registered **above** + `cache.Lock()` so LIFO runs it after `cache.Unlock()`. Registering it below + compiles, passes every byte-identical test, and silently preserves the nested + lock this change exists to remove. +3. **`break`, not `return`, on worker error.** Workers now have post-loop work + (storing their buffer). The pre-existing error path never rolled back emitted + keys, so dropping them would *change* the set — and a subset risks a lost + update while a superset only costs a spurious abort. + +## Results + +**Throughput** (2 runs per arm, non-overlapping): + +| | triples/s | MB/s | +|---|---|---| +| before (`612ae8cd5`) | 113,916 / 112,086 | 11.40 / 11.47 | +| after (`5b69ac2b4`) | 123,399 / 124,065 | 12.41 / 12.49 | +| | **+9.5%** | **+8.8%** | + +**Goroutine profile — the primary acceptance criterion:** + +| | before | after | +|---|---|---| +| pipeline goroutine observations | 518 | **216** (−58%) | +| in the conflict-key path | **68.5%** | **0.0%** | +| blocked on the conflict mutex | **23.0%** | **0.0%** | +| CPU utilisation | 7.9% | 9.6% | + +The conflict-key path disappears from the sampled profile entirely. + +**Correctness signals:** `batches_fail = 0` on all four runs, both binaries — +no change in client-visible aborts. (`dgraph_txn_aborts_total` is not exposed by +this build, so the zero-failure parity plus the golden test carries that check.) + +## Verification + +- `TestFillContextKeysGolden` (new) — pins the exact 205-key `ctx.Keys` sent to + Zero against a golden captured at `612ae8cd5`, and asserts the keys are + `startTs`-independent. **This closed a real gap:** the six existing + byte-identical tests only compare budget 0 vs N, so a regression changing the + serial and parallel paths *the same way* would pass all of them, and nothing + asserted `FillContext`'s output at all. +- Six existing byte-identical tests (conflict-key set equality across budgets). +- Full `posting` package green under `-race` (354 s). +- `TestPipelineReverseListCountMultiPred` is an intermittent pre-existing flake: + 0/6 failures on both this commit and `612ae8cd5`. + +## What comes next + +The new top blocker, named by the post-change profile: **`Deltas.AddToDeltas`** +at 57.6% of a much smaller blocked population — shard contention on the delta +map, where `NumShards = 30` (`types/sharded_map.go:18`) is thin for 64 vCPU. +Raising it, or improving `getShardIndex` (which also heap-allocates on `string` +keys — ~13% of batch allocations), is the cheap next step. + +**The standing constraint:** CPU is still only **9.6% of 64 vCPU**. +`processApplyCh` applies one Raft entry at a time, so a single Alpha cannot +saturate a large box no matter how much intra-transaction contention is removed. +Further material gains require cross-transaction concurrency in the apply loop — +a much larger change than any of these three. From 4331eb2e44dff40871f1d800bb3d24e3e3d434e7 Mon Sep 17 00:00:00 2001 From: Ryan Hendrickson Date: Sun, 26 Jul 2026 12:37:49 -0400 Subject: [PATCH 28/45] =?UTF-8?q?docs:=20pipeline=20backlog=20=E2=80=94=20?= =?UTF-8?q?remaining=20hotspots,=20flag=20debt,=20harness=20lessons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forward-looking companion to the validation record. Three sections worth flagging for review: Flag simplification. The pipeline now carries four superflag knobs, two of which are inert unless mutations-pipeline-goroutines=-1 (not the default), and the shipped default of 30 sits exactly on the allocateWorkers cliff: with 25 predicates it yields a {1:20, 2:5} grant. Before 612ae8cd5 that silently disabled the lock-free path for 20 of 25 predicates. Also notes that WorkerOptions.String() hand-formats the struct and omits every MutationsPipeline* field, so the startup log cannot confirm the effective configuration — this cost real time during live debugging. Where the CPU actually goes now. After the series the profile is no longer dominated by posting/: RWMutex read traffic (~11%), worker.(*groupi).Tablet (~7-9%, a read-mostly membership map behind SafeMutex hit once per proposal), and allocation pressure (~10%). MaxLeaseId/verifyUid was in this list until 016a03a0a removed it. Harness lessons, recorded so they are not re-learned: Dgraph returns HTTP 200 with an errors body on rejection, explicit uids need a zero lease first, cross-predicate lock contention requires multiple predicates per TRANSACTION rather than multiple client threads, and sequential A/B on a thermally-throttling laptop produced a spurious 43% regression on an unchanged code path. Co-Authored-By: Claude Opus 5 --- pipeline-todo.md | 218 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 pipeline-todo.md diff --git a/pipeline-todo.md b/pipeline-todo.md new file mode 100644 index 00000000000..4cda3ca7ffa --- /dev/null +++ b/pipeline-todo.md @@ -0,0 +1,218 @@ +# Mutation-pipeline TODO + +Working list for the intra-predicate pipeline work on +`rahst12/hybrid-pipeline-reverse-parallel`. Ordered by what actually blocks +throughput, not by effort. + +--- + +## 1. Simplify the flags + +The pipeline now has **four** superflag knobs, and a fifth is proposed. That is +too many for something whose correct setting is not obvious to an operator. + +Current (`worker/server_state.go:45-48`, `dgraph/cmd/alpha/run.go:267-310`): + +| flag | default | what it really controls | +|---|---|---| +| `mutations-pipeline-threshold` | 1 | use pipeline vs legacy path | +| `mutations-pipeline-goroutines` | 30 | L2 intra-predicate budget (`-1` = auto) | +| `mutations-pipeline-goroutines-fraction` | 1.0 | AUTO only: fraction of GOMAXPROCS | +| `mutations-pipeline-min-edges-per-worker` | 256 | AUTO only: edges/worker cap | +| *(proposed)* `mutations-pipeline-min-targets-per-worker` | 256 | reverse split threshold | + +Problems: +- Two of the four are **inert unless `goroutines == -1`**, which is not the + default — so most operators are tuning flags that do nothing. +- The fixed default of **30 sits exactly on the `allocateWorkers` cliff**: with + 25 predicates it yields a `{1:20, 2:5}` grant, i.e. 20 of 25 predicates get a + one-worker grant. A budget below ~2x the predicate count effectively disables + intra-predicate parallelism, silently. +- `reverseParallelMinTargets` is currently a compile-time `const` + (`posting/index.go`), so it cannot be tuned at all. + +Proposed: +- Make **AUTO the default** (`goroutines=-1`) so the budget scales with the box + instead of a magic 30 that is wrong on both an 8-core and a 148-core machine. +- Collapse `goroutines-fraction` + `min-edges-per-worker` into the auto policy, + or document them as advanced/diagnostic only. +- Express the reverse threshold as **targets-per-worker**, consistent with the + edges-per-worker vocabulary already used, rather than an absolute constant. +- Emit a `glog.V(2)` line or a metric when a batch's grant is all-1s, so the + silent-disable case is observable. + +### 1a. `WorkerOptions.String()` hides the pipeline config +`x/config.go:206-210` hand-formats the struct and stops at `Audit:%v}` — none of +the `MutationsPipeline*` fields are printed. The startup log line +`glog.Infof("x.WorkerConfig: %+v")` therefore does **not** show the effective +pipeline settings. This cost real time during live debugging: the log could not +confirm whether the budget was 30 or 0. Add the fields to `String()`. + +--- + +## 2. Performance follow-ups (ranked by measured gain / risk) + +1. ~~**Apply the same gate fix to `ProcessList` and `ProcessSingle`.**~~ + **DONE — `612ae8cd5`.** Took the production default from 10.58 → **11.44 MB/s** + (+18.8% cumulative over stock) and removed the global `cache.Lock` from the + blocking profile entirely. + `ProcessReverse` now uses the lock-free store whenever the budget is enabled, + not only when `workers > 1`. The forward-write paths still tie those two + decisions together, so a one-worker grant sends them back to the global + `txn.cache.Lock()`. Measured (perf review): reverse gate alone 1.24x, all + three gates 1.84x. Two thirds of the available gain is here. +2. ~~**Shard `txn.conflicts`**~~ **DONE differently — `5b69ac2b4` batched it + instead of sharding.** A line-level profile showed ~80% of the cost was + lock acquire/release and only ~15% the map insert, so batching to one + acquisition per `Process*` captured the win; sharding would have chased a + ~1% residual. Result: 11.44 → **12.45 MB/s (+8.8%)**, conflict path + 68.5% → **0.0%** of pipeline goroutines. Full write-up in + `conflict-key-batching-plan.md`. Sharding stays closed unless + `flushConflicts` self-CPU >3% or mutex-BLOCKED >5%. + + Original entry kept for context: + **Shard `txn.conflicts`** (`posting/index.go` `addConflictKeyWithUid`). One + `map[uint64]struct{}` under one global `txn.Lock()`, hit once per edge by + every worker. Microbenchmark ceiling: stubbing it out took the parallel path + from 36.7 ms to 19.1 ms. Touches the conflict-detection contract that feeds + `FillContext` -> Zero, so route through the raft expert; assert the emitted + `ctx.Keys` slice is byte-identical before/after. + + **RE-CONFIRMED at real core count — this is a genuine #2.** Measured on an + `i4i.16xlarge` (32 physical cores / 64 vCPU), 25 `[uid] @reverse` predicates, + 20k triples/txn, 64 writer threads, after the reverse fix: + `addConflictKeyWithUid` holds **30.7%** of parked pipeline goroutines + (up from **0.4%** on stock, because the reverse fix removes the cache-lock + convoy that was masking it). + + **After `612ae8cd5` (forward gate) it is 71.2%** — now unambiguously the #1 + bottleneck, with the global cache lock gone from the profile entirely. + Progression across the three builds: **0.4% → 31% → 71.2%**. + + Note the core-count sensitivity — it is why laptop numbers must not be used + to rank this work: on a 4-physical-core laptop the same measurement showed + only **2.7%** and I wrongly demoted this item. Contention on a single mutex + scales with the number of contenders; small boxes cannot see it. +3. **`LockedShardedMap.getShardIndex`** (`types/locked_sharded_map.go:39`) does + `farm.Fingerprint64([]byte(k))`, which heap-allocates on every Get/Set — + ~12.9% of all allocations in a 20k-edge batch. `maphash.String` is 0-alloc + and ~3x faster. Near-free win, also helps the index pass. +4. **`SortAndDedupPostings`** (`posting/oracle.go:75`) uses `sort.Slice`: 2 + allocs + reflection per call, ~28k calls per 20k-edge batch. + `slices.SortStableFunc` is ~6x faster at n=3 *and* makes the existing + "last wins" dedup deterministic instead of arbitrary. +5. **`concStore` fast paths** — skip the prior-delta probe when the txn has no + earlier-proposal deltas; avoid the copy when there is no prior delta. Needs + an audit that no earlier pass in the same `Process` call can write a reverse + key (star-delete in particular). +6. **Skew-aware partitioning** — workers currently take contiguous ranges of the + target snapshot. Speedup is capped at `1/p_max` where `p_max` is the largest + target's share of postings; a batch can have 8,000 targets and still have one + target holding half the postings. A chunked atomic counter self-balances at + ~zero cost. + +--- + +## 2b. Validated on real hardware + +See `ec2-validation-results.md`. On an `i4i.16xlarge` (32 physical cores), 25 +`[uid] @reverse` predicates, 20k triples/txn, 64 threads, measured by +`badger_write_bytes_user`: + +- **+9.9% MB/s at the shipped default** (`goroutines=30`), reproducible to ≤1%. +- **+16.6% MB/s at `goroutines=-1` (auto)**. +- Raising the budget alone on **stock** gains nothing (+0.5%) — auto only pays + once the reverse write can consume the workers. The "free win from a bigger + budget" idea is dead. +- CPU stays at **6–8% of 64 vCPU**. A single Alpha cannot saturate a big box + because `processApplyCh` is serial across Raft entries — worth remembering + before attributing any future shortfall to intra-transaction parallelism. + +## 2c. Beyond the posting pipeline — where the CPU actually goes now + +After the three apply-path commits, the post-change profile is no longer +dominated by `posting/`. Measured on the 32-core box: + +| | cum | note | +|---|---|---| +| `sync/atomic.(*Int32).Add` | 11–12% | 94% of it is `RWMutex.RLock`/`RUnlock` traffic | +| `worker.(*groupi).Tablet` | 6.7–8.9% | membership lookup, 83% from `proposeAndWait`; ~62% of its own cost is `SafeMutex.RLock`/`RUnlock` | +| `runtime.mallocgc` | ~10% | allocation pressure | +| ~~`MaxLeaseId` / `verifyUid`~~ | ~~3.5%~~ | **fixed in `016a03a0a`** — now absent | + +- **DONE `016a03a0a`** — `ExtractBlankUIDs` called `MaxLeaseId` (a global RWMutex + read for one uint64) per subject AND per object, ~40k acquisitions per 20k-nquad + mutation. Snapshotted once per call. Removed ~3.5% CPU; **throughput unchanged**, + because at ~9% CPU on 64 vCPU the Alpha is not CPU-bound. +- **NEXT — `worker.(*groupi).Tablet`.** A read-mostly membership map behind + `x.SafeMutex`, hit once per proposal. Candidate for an `atomic.Value` snapshot + or a per-request cache. Largest single remaining item. +- **`NumShards = 30`** (`types/sharded_map.go:18`) — thin for 64 vCPU, and + `Deltas.AddToDeltas` is the top blocker inside the pipeline now. + +## 3. Good to do, not needed now + +- **Calibrate `reverseParallelMinTargets`.** 256 is an assumption by analogy to + `min-edges-per-worker`, not a measurement. Measured behavior at 5 targets + (serial, correct) and 600/1250/8000 (parallel, 1.15-1.87x), but nothing + between 5 and 600 — the real crossover is unknown. Sweep on the 148-core box. +- **Cover the uncovered branches** in the parallel reverse path: the + `sync.Once`/`firstErr` error propagation, and the `k > len(uidList)` clamp. + Neither is reachable today, but the error path abandons the rest of a worker's + sub-range, which differs from the serial path's behavior — worth a deliberate + decision rather than an accident. +- **Guard the `handleDeleteAll` happens-before with a test**, not just the + comment now in `ProcessReverse`. The lock-free write's single-writer invariant + depends on `close(pred.edges)` ordering; if star-delete handling ever moves + off the dispatcher this becomes silent reverse-index corruption with no panic + and no race-detector hit. +- **Wire mutex/block profiling to the live endpoints.** `--profile_mode mutex` + did not enable `/debug/pprof/mutex` (it came back with `sampling period=0`), + so the contended mutex had to be inferred from goroutine stacks instead of + named directly. A small `--debug-profile-rates` style flag calling + `runtime.SetMutexProfileFraction` / `SetBlockProfileRate` would make future + contention work much faster. +- **Fix `active_mutations_total` not existing until the first mutation** + (issue #9671) — unrelated to this work but trivial and it breaks dashboards. +- **Parallelize `commitOrAbort`'s per-txn `CommitToDisk` loop** + (`worker/draft.go`) across the txns in an `OracleDelta`. Independent of the + apply-side work here. Note Badger's `CommitAt` is already async, so the gain + is CPU parallelism over iterating `cache.deltas` — which is large precisely + because high-cardinality reverse predicates generate thousands of delta keys. +- **Factor the duplicated `concStore`.** `ProcessList` and `ProcessReverse` now + carry near-identical read-merge-sort-write closures. A shared helper would + stop them drifting, but doing it before the gate fixes land would just cause + churn. + +--- + +## 3b. Build / ops papercuts (found deploying to a clean RHEL 9 EC2 box) + +- **`make dgraph` fails confusingly when `bzip2` is missing.** The `jemalloc` + target (`dgraph/Makefile:103`) does `tar xjf jemalloc.tar.bz2` then + `cd jemalloc-5.3.1`. Without `bzip2` the extract fails *silently*, the `cd` + fails, and `sudo make install` then runs in `/tmp/jemalloc-temp` — producing + `No rule to make target 'install'`, which points nowhere near the real cause. + Add `bzip2` to the documented build prerequisites, or use `tar xjf --checkpoint` + / check the extract exit status and fail with a clear message. +- **The build needs a git checkout for the version string.** Building from an + exported tree prints `fatal: not a git repository` and produces a binary with + an empty version. Harmless but noisy; guard the `git describe` call. +- **`--profile_mode mutex` does not enable the live `/debug/pprof/mutex` + endpoint** (returns `sampling period=0`). See the item in section 3. + +## 4. Benchmarking / harness lessons (so they are not re-learned) + +- **Dgraph returns HTTP 200 with an `{"errors":[...]}` body on rejection.** A + load generator that checks only the status code will happily report + hundreds of "successful" batches that did nothing. Cost a full invalid run. +- **Explicit uids require a lease first** + (`curl "localhost:6080/assign?what=uids&num=N"`), otherwise every mutation + fails with `Uid: [N] cannot be greater than lease: [0]`. +- **Cross-predicate lock contention needs multiple predicates per + transaction, not multiple client threads.** The apply loop is serial across + transactions, so client concurrency cannot produce it. A batch file containing + a single predicate yields `P=1` and cannot exercise the convoy at all. +- **Sequential A/B on a laptop is invalid** — thermal drift produced a spurious + 43% "regression" on a code path that had not changed. Build two test binaries + and interleave them. From 748839df499cfacd1d72756a6951072b30567426 Mon Sep 17 00:00:00 2001 From: Ryan Hendrickson Date: Sun, 26 Jul 2026 13:49:31 -0400 Subject: [PATCH 29/45] perf(types): size LockedShardedMap per machine, drop per-call hash alloc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deltas.AddToDeltas was the top blocker left inside the mutation pipeline — 57.6% of blocked pipeline goroutines. It is one Set into a LockedShardedMap[string, []byte] that every predicate worker writes to, and the map was fixed at NumShards = 30. That constant is sized for types.ShardedMap, an unrelated single-threaded map used by the query path, and is far too thin for 64 vCPU. Two coupled changes: 1. Shard count is now per-instance, defaulting to 4x GOMAXPROCS clamped to [64, 1024] and rounded to a power of two. The struct already held shards and locks as slices, so only the constructor was fixed at NumShards — but getShardIndex ALSO indexed off the global constant, which would over-run the slice or strand every shard above index 30 once the count varied. It now masks against the map's own size. types.ShardedMap and the shared NumShards constant are deliberately untouched. 2. String keys hashed with farm.Fingerprint64([]byte(k)), whose conversion escapes to the heap on every Get/Set — previously ~13% of all allocations in a 20k-edge batch, since every delta write hashes its key. maphash.String is allocation-free. Nothing persisted depends on shard assignment, so the hash may change freely. uint64 keys are multiplied by a 64-bit constant before masking, because dense monotonic uids would stripe poorly against a power-of-two mask. Merge now falls back to a key-by-key path when two maps have different shard counts; positional merging would silently drop keys. It has no callers today, but per-instance sizing made that a live hazard. Micro-benchmark, interleaved, median of 5 (Set with N goroutines): workers=1 499 -> 434 ns/op 3 -> 2 allocs workers=32 229 -> 171 ns/op 2 -> 1 allocs (1.34x) workers=64 212 -> 165 ns/op 2 -> 1 allocs (1.28x) End to end on i4i.16xlarge (32 physical cores), 25 [uid] @reverse predicates, 20k triples/txn, 64 threads, budget=30, 2 runs per arm: badger_write_bytes_user 12.44 -> 13.17 MB/s (+5.9%) triples/s 123795 -> 131449 (+6.2%) blocked on mutex 25.0% -> 10.5% runnable 48.8% -> 75.6% pipeline goroutines 160 -> 86 Cumulative over stock: 9.63 -> 13.17 MB/s (+36.8%). Co-Authored-By: Claude Opus 5 --- types/locked_sharded_map.go | 83 ++++++++++++++-- types/locked_sharded_map_test.go | 165 +++++++++++++++++++++++++++++++ 2 files changed, 238 insertions(+), 10 deletions(-) create mode 100644 types/locked_sharded_map_test.go diff --git a/types/locked_sharded_map.go b/types/locked_sharded_map.go index 19db5ff8cea..3eee9dede34 100644 --- a/types/locked_sharded_map.go +++ b/types/locked_sharded_map.go @@ -8,35 +8,81 @@ package types import ( + "hash/maphash" + "math/bits" + "runtime" "sync" - - "github.com/dgryski/go-farm" ) +// shardSeed makes string hashing allocation-free. maphash.String takes the +// string directly, unlike farm.Fingerprint64 which needs a []byte conversion +// that escapes to the heap on every call — previously ~13% of all allocations +// in a 20k-edge mutation batch, since every delta write hashes its key. The +// seed is process-local, which is fine: shard assignment is never persisted or +// compared across processes. +var shardSeed = maphash.MakeSeed() + +// lockedShards is the default shard count for LockedShardedMap. The mutation +// pipeline writes every posting-list delta through one of these maps from every +// predicate worker at once, so the shard count is a direct contention knob. +// NumShards (30) is sized for the unrelated, single-threaded types.ShardedMap +// and is far too thin on a many-core box. +// +// Oversubscribe relative to GOMAXPROCS so unlucky hash collisions between two +// active writers stay rare, and clamp so small boxes do not pay for empty maps +// and huge ones do not allocate absurdly. +func lockedShards() int { + n := 4 * runtime.GOMAXPROCS(0) + if n < 64 { + n = 64 + } + if n > 1024 { + n = 1024 + } + // Round up to a power of two: getShardIndex masks rather than divides. + return 1 << bits.Len(uint(n-1)) +} + // LockedShardedMap is a thread-safe, sharded map with generic key-value types. type LockedShardedMap[K comparable, V any] struct { shards []map[K]V locks []sync.RWMutex + // mask is len(shards)-1; len(shards) is always a power of two. + mask uint64 } -// NewLockedShardedMap creates a new LockedShardedMap. +// NewLockedShardedMap creates a new LockedShardedMap sized for this machine. func NewLockedShardedMap[K comparable, V any]() *LockedShardedMap[K, V] { - shards := make([]map[K]V, NumShards) - locks := make([]sync.RWMutex, NumShards) + return NewLockedShardedMapWithShards[K, V](lockedShards()) +} + +// NewLockedShardedMapWithShards creates a LockedShardedMap with an explicit +// shard count, rounded up to a power of two. Exposed mainly so tests can pin a +// non-default count and exercise the indexing. +func NewLockedShardedMapWithShards[K comparable, V any](n int) *LockedShardedMap[K, V] { + if n < 1 { + n = 1 + } + n = 1 << bits.Len(uint(n-1)) + shards := make([]map[K]V, n) + locks := make([]sync.RWMutex, n) for i := range shards { shards[i] = make(map[K]V) } - return &LockedShardedMap[K, V]{shards: shards, locks: locks} + return &LockedShardedMap[K, V]{shards: shards, locks: locks, mask: uint64(n - 1)} } func (s *LockedShardedMap[K, V]) getShardIndex(key K) int { - // Only works for integer-like keys (uint64 etc). For generic types, - // a better hash function is needed. We'll assume uint64 for now. + // Index off this map's OWN shard count, never the package-level NumShards. + // The two are unrelated now, and reading the global here would either + // over-run s.shards or silently strand shards above index 30. switch k := any(key).(type) { case uint64: - return int(k % uint64(NumShards)) + // Mix: uid keys are dense and monotonic, so the low bits alone would + // stripe poorly once the shard count is a power of two. + return int((k * 0x9E3779B97F4A7C15) >> 32 & s.mask) case string: - return int(farm.Fingerprint64([]byte(k)) % uint64(NumShards)) + return int(maphash.String(shardSeed, k) & s.mask) default: panic("LockedShardedMap only supports uint64 and string keys for now") } @@ -76,6 +122,23 @@ func (s *LockedShardedMap[K, V]) Update(key K, update func(V, bool) V) { } func (s *LockedShardedMap[K, V]) Merge(other *LockedShardedMap[K, V], ag func(a, b V) V) { + // Shard counts are per-instance now, so shard i of `other` no longer + // necessarily corresponds to shard i of `s`. Merging positionally across + // differently-sized maps would drop or misplace keys silently, so route + // mismatched maps through the key-by-key path instead. + if len(s.shards) != len(other.shards) { + _ = other.Iterate(func(k K, v V) error { + s.Update(k, func(existing V, ok bool) V { + if ok { + return ag(existing, v) + } + return v + }) + return nil + }) + return + } + var wg sync.WaitGroup for i := range s.shards { wg.Add(1) diff --git a/types/locked_sharded_map_test.go b/types/locked_sharded_map_test.go new file mode 100644 index 00000000000..f5cac1ab70d --- /dev/null +++ b/types/locked_sharded_map_test.go @@ -0,0 +1,165 @@ +/* + * SPDX-FileCopyrightText: © 2017-2025 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package types + +import ( + "fmt" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestLockedShardedMapShardCounts guards the indexing trap: getShardIndex used +// to derive its index from the package-level NumShards while the shard slices +// were sized separately. Once the shard count became per-instance, that would +// either over-run s.shards or strand every shard above index 30. Exercise a +// spread of counts — below, at, and well above the old constant — with both +// supported key types. +func TestLockedShardedMapShardCounts(t *testing.T) { + for _, n := range []int{1, 2, 7, 30, 64, 100, 1024} { + t.Run(fmt.Sprintf("shards=%d", n), func(t *testing.T) { + sm := NewLockedShardedMapWithShards[string, int](n) + // Rounded up to a power of two, never below 1. + require.Equal(t, len(sm.shards), len(sm.locks)) + require.Equal(t, uint64(len(sm.shards)-1), sm.mask) + require.Zero(t, len(sm.shards)&(len(sm.shards)-1), "shard count must be a power of two") + + const keys = 2000 + for i := 0; i < keys; i++ { + sm.Set(fmt.Sprintf("key-%d", i), i) + } + require.Equal(t, keys, sm.Len()) + for i := 0; i < keys; i++ { + got, ok := sm.Get(fmt.Sprintf("key-%d", i)) + require.True(t, ok, "key-%d missing", i) + require.Equal(t, i, got) + } + + um := NewLockedShardedMapWithShards[uint64, int](n) + // Dense monotonic uids are the realistic case and the one a plain + // low-bit mask would stripe badly. + for i := 0; i < keys; i++ { + um.Set(uint64(1_000_000+i), i) + } + require.Equal(t, keys, um.Len()) + for i := 0; i < keys; i++ { + got, ok := um.Get(uint64(1_000_000 + i)) + require.True(t, ok) + require.Equal(t, i, got) + } + }) + } +} + +// TestLockedShardedMapUidSpread checks that dense sequential uids — exactly what +// Dgraph's allocator produces — actually spread across shards rather than +// piling onto a few. A mask over the raw uid would leave most shards empty. +func TestLockedShardedMapUidSpread(t *testing.T) { + sm := NewLockedShardedMapWithShards[uint64, int](64) + for i := 0; i < 6400; i++ { + sm.Set(uint64(9_000_000+i), i) + } + used := 0 + minLen, maxLen := 1<<30, 0 + for i := range sm.shards { + l := len(sm.shards[i]) + if l > 0 { + used++ + } + if l < minLen { + minLen = l + } + if l > maxLen { + maxLen = l + } + } + require.Equal(t, len(sm.shards), used, "every shard should receive keys") + require.Less(t, maxLen, 4*minLen, "shard occupancy is badly skewed (min=%d max=%d)", minLen, maxLen) +} + +// TestLockedShardedMapConcurrent is the property that matters in the mutation +// pipeline: many workers writing disjoint keys at once. Run under -race. +func TestLockedShardedMapConcurrent(t *testing.T) { + sm := NewLockedShardedMap[string, int]() + const workers, per = 16, 500 + var wg sync.WaitGroup + for w := 0; w < workers; w++ { + wg.Add(1) + go func(w int) { + defer wg.Done() + for i := 0; i < per; i++ { + sm.Set(fmt.Sprintf("w%d-k%d", w, i), w*per+i) + } + }(w) + } + wg.Wait() + require.Equal(t, workers*per, sm.Len()) +} + +// BenchmarkLockedShardedMapSet measures the delta-write path as the mutation +// pipeline drives it: many goroutines Set-ing distinct string keys. Reports +// allocations too — the string hash used to heap-allocate on every call. +func BenchmarkLockedShardedMapSet(b *testing.B) { + for _, workers := range []int{1, 8, 32, 64} { + b.Run(fmt.Sprintf("workers=%d", workers), func(b *testing.B) { + sm := NewLockedShardedMap[string, []byte]() + val := make([]byte, 64) + b.ReportAllocs() + b.ResetTimer() + var wg sync.WaitGroup + per := b.N / workers + if per < 1 { + per = 1 + } + for w := 0; w < workers; w++ { + wg.Add(1) + go func(w int) { + defer wg.Done() + for i := 0; i < per; i++ { + sm.Set(fmt.Sprintf("\x00\x01pred\x00key-%d-%d", w, i), val) + } + }(w) + } + wg.Wait() + }) + } +} + +// BenchmarkLockedShardedMapGetShardIndex isolates the hash itself. +func BenchmarkLockedShardedMapGetShardIndex(b *testing.B) { + sm := NewLockedShardedMap[string, int]() + key := "\x00\x01somepredicate\x00\x00\x00\x00\x00\x00\x00\x01\x02" + b.ReportAllocs() + b.ResetTimer() + sink := 0 + for i := 0; i < b.N; i++ { + sink += sm.getShardIndex(key) + } + _ = sink +} + +// TestLockedShardedMapMergeMismatchedShards covers the positional-merge hazard +// introduced by per-instance shard counts: shard i of one map no longer +// corresponds to shard i of another. +func TestLockedShardedMapMergeMismatchedShards(t *testing.T) { + dst := NewLockedShardedMapWithShards[string, int](64) + src := NewLockedShardedMapWithShards[string, int](8) + for i := 0; i < 500; i++ { + src.Set(fmt.Sprintf("k%d", i), 1) + } + dst.Set("k0", 10) // forces the aggregator path for one key + + dst.Merge(src, func(a, b int) int { return a + b }) + + require.Equal(t, 500, dst.Len(), "no keys may be dropped across shard-count mismatch") + v, ok := dst.Get("k0") + require.True(t, ok) + require.Equal(t, 11, v, "aggregator must run for pre-existing keys") + v, ok = dst.Get("k499") + require.True(t, ok) + require.Equal(t, 1, v) +} From e0b21dfff14e7863520069edb79c423e483a3c7e Mon Sep 17 00:00:00 2001 From: Ryan Hendrickson Date: Sun, 26 Jul 2026 13:51:50 -0400 Subject: [PATCH 30/45] docs: record intra-transaction completion status and the measurement rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md is gitignored in this repo, so the same summary goes here to survive in git history and reach anyone reading the branch. Records the six-commit ladder (9.63 -> 13.17 MB/s, +36.8%) and the observed bottleneck migration — global cache lock 86% -> conflict lock 71% -> AddToDeltas 58% -> nothing above ~10% — which is why the commits had to land in that order, each fix exposing the next. The load-bearing part is the rule for future work: a single Alpha sits at ~10% of 64 vCPU because processApplyCh applies one Raft entry at a time, so a change that only reduces CPU or allocations will NOT move throughput. 016a03a0a removed ~3.5% of CPU for exactly zero gain. Candidates must be ranked by share of BLOCKED goroutines, not CPU. Without that rule the remaining backlog items look attractive and are worthless — which is why they were skipped deliberately rather than left undone by accident. Co-Authored-By: Claude Opus 5 --- pipeline-todo.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/pipeline-todo.md b/pipeline-todo.md index 4cda3ca7ffa..72a0e1667ba 100644 --- a/pipeline-todo.md +++ b/pipeline-todo.md @@ -1,5 +1,45 @@ # Mutation-pipeline TODO +## STATUS: intra-transaction apply-path work is COMPLETE + +Six commits, each measured on an `i4i.16xlarge` (32 physical cores / 64 vCPU), +25 `[uid] @reverse` predicates, 20k triples/txn, 64 threads, 300 s, +`badger_write_bytes_user`: + +| commit | change | MB/s | +|---|---|---| +| — | branch base | 9.63 | +| `4a02d8eeb` | parallel reverse write; lock-free store at any grant | 10.58 | +| `612ae8cd5` | lock-free forward write at a one-worker grant | 11.44 | +| `5b69ac2b4` | batch conflict-key emission into one lock acquisition | 12.45 | +| `016a03a0a` | snapshot uid lease once per mutation | 12.53 (flat) | +| `04944f125` | size `LockedShardedMap` per machine; drop hash alloc | **13.17** | + +**+36.8% cumulative.** Blocked pipeline goroutines fell from ~86% (stock, on the +global cache lock) to **10.5%**; runnable rose to **75.6%**. + +### The rule that governs any further work here + +CPU utilisation is **~10% of 64 vCPU** and barely moved across all six commits, +because `processApplyCh` applies one Raft entry at a time. So **a change that +only reduces CPU or allocations will not move throughput** — `016a03a0a` removed +~3.5% of CPU for exactly zero gain. Rank candidates by share of **blocked** +goroutines (from `/debug/pprof/goroutine?debug=2`), not CPU. Use a CPU profile to +*explain* a blocker once found, not to pick one. + +Bottleneck migration observed, each fix exposing the next — which is why the +order mattered: global `cache.Lock` 86% → `txn.conflicts` 71% → +`Deltas.AddToDeltas` 58% → nothing above ~10%. + +**Everything in §2b/§3 below that is CPU-or-allocation-only was skipped on +purpose.** The next real lever is cross-transaction concurrency in +`processApplyCh`, not more intra-transaction work. + +(The same summary is in the local, gitignored `CLAUDE.md` so it auto-loads.) + +--- + + Working list for the intra-predicate pipeline work on `rahst12/hybrid-pipeline-reverse-parallel`. Ordered by what actually blocks throughput, not by effort. From 56e43c8ebba7363be76e273d973ec325606e5a0c Mon Sep 17 00:00:00 2001 From: Ryan Hendrickson Date: Sun, 26 Jul 2026 15:27:54 -0400 Subject: [PATCH 31/45] =?UTF-8?q?docs:=20regression=20evidence=20=E2=80=94?= =?UTF-8?q?=204-way=20differential=20and=20pre-existing=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the strongest gate run so far: a mixed-workload corpus (~408k triples, 17 predicate types) ingested into four fresh clusters and fingerprinted with 72 DQL queries. stock@30, new@0, new@30 and new@auto all produce IDENTICAL results with zero mutation errors, covering every apply-path branch the pipeline work touched — ProcessSingle/List/Reverse/ Count, ten index tokenizers, @upsert, @noconflict, @lang, lists, DEL and star-delete — rather than only the [uid] @reverse shape used for benchmarking. Also records, with evidence, that the worker package's -race failures are pre-existing and structural rather than caused by this work: worker.Init overwrites a package-global limiter and starts a bleed() goroutine that is never stopped, so every test's Init races the previous test's goroutine and the detector blames whichever test is running. Confirmed by running the same suite on ab0a49e77 — both trees fail, with fluctuating sets, and TestLimiterDeadlock fails 5/5 on both. posting passes cleanly on both. Plus the harness trap that invalidated the first attempt: /admin export is asynchronous, so sleeping and reading the export dir compares partial flushes, not databases. Co-Authored-By: Claude Opus 5 --- pipeline-todo.md | 55 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/pipeline-todo.md b/pipeline-todo.md index 72a0e1667ba..c8ff8dcd344 100644 --- a/pipeline-todo.md +++ b/pipeline-todo.md @@ -256,3 +256,58 @@ dominated by `posting/`. Measured on the 32-core box: - **Sequential A/B on a laptop is invalid** — thermal drift produced a spurious 43% "regression" on a code path that had not changed. Build two test binaries and interleave them. + +--- + +## 5. Regression evidence (2026-07-26, 32-core EC2) + +### Query-based differential — the strongest gate we have + +A mixed-workload corpus (60 batches x 400 entities x 17 predicates, ~408k +triples) was ingested into four FRESH clusters and fingerprinted with 72 DQL +queries covering every predicate type, index tokenizer, reverse edge, count +index, language tag, a 40-entity full dump, and star-delete verification: + +| config | binary | budget | result | +|---|---|---|---| +| `stock30` | pre-change (`ab0a49e77`) | 30 | reference | +| `new0` | all changes | 0 (legacy path) | **identical** | +| `new30` | all changes | 30 (prod default) | **identical** | +| `newauto` | all changes | -1 (auto) | **identical** | + +Zero mutation errors in all four. The corpus exercises `ProcessSingle`, +`ProcessList`, `ProcessReverse` (hot AND spread targets), `ProcessCount`, +`InsertTokenizerIndexes` (exact/hash/term/fulltext/trigram/int/float/bool/day/geo), +`@upsert`, `@noconflict`, `@lang`, `[string]` lists, SET, DEL, and star-delete — +i.e. every branch the pipeline work touched, not just the benchmark shape. + +Harness: `/data/harness/verify.sh`, corpus generator `gen_mixed.py`. + +### Pre-existing test failures — NOT caused by this work + +Verified by running the identical suite on `ab0a49e77` (pre-change tree) on the +same box: + +- **`worker` package fails `-race` on BOTH trees.** Root cause is structural: + `worker.Init()` (`worker/worker.go:48-52`) overwrites the package-global + `limiter` and starts `go limiter.bleed()`, a goroutine that is never stopped. + Every test calls `Init()`, so each new call races the previous test's still-live + `bleed()`. The race detector then attributes the failure to whichever test + happens to be running, which is why the failing set differs every run. + Measured over 5 runs per tree: baseline and HEAD both fail, with overlapping + and fluctuating sets (`TestLimiterDeadlock` fails 5/5 on both). +- **`types`: `TestParseTimeWithTZ`** writes the process-global `time.Local` + (`types/scalar_types_test.go:121`) while an OpenCensus worker reads it via + `time.Now()`. ~6/8 failures on a clean tree. +- **`posting` — the package this work actually changes — passes cleanly on both + trees** (280s baseline, 286s HEAD, `-race`). + +Fixing either would be a worthwhile separate change; neither blocks this work. + +### Harness trap discovered here + +**Dgraph's `/admin` export is ASYNCHRONOUS** — it returns +`"Export queued with ID ..."` immediately. Sleeping and then reading the export +directory samples partial files, which silently produces four "different" +databases that are actually just different flush points. Poll for completion, or +use queries (synchronous) as done here. From 5a9f6c6472a519efccf6fd2e3dcaac64ef5f478d Mon Sep 17 00:00:00 2001 From: Ryan Hendrickson Date: Sun, 26 Jul 2026 17:01:41 -0400 Subject: [PATCH 32/45] =?UTF-8?q?docs:=20integration=20suite=20results=20?= =?UTF-8?q?=E2=80=94=2039=20packages,=20zero=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full t/ runner suite against a docker image verified by symbol inspection to contain the changes. 39 packages ok with no test or package failures, including systest/mutations-and-queries, systest/vector (HNSW), and worker — which passes cleanly here despite the pre-existing worker.Init race that makes its -race unit run flaky. Records the setup a clean box needs (Docker, gotestsum, a git root, make local-image; ack is unavailable on RHEL 9 but the runner does not need it), and two things that look like failures and are not: the four panic: entries come from the deliberate panic_catcher test, and TestRebuildTokIndex only appears to stall if badger's INFO output is truncated by grep | head — it passes in 0.06s. One honest gap: the first full-suite invocation exited 1 with no failing test and a log ending mid-posting; rerunning that package alone gave exit 0. Not explained. Co-Authored-By: Claude Opus 5 --- pipeline-todo.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/pipeline-todo.md b/pipeline-todo.md index c8ff8dcd344..22aba43d612 100644 --- a/pipeline-todo.md +++ b/pipeline-todo.md @@ -311,3 +311,35 @@ Fixing either would be a worthwhile separate change; neither blocks this work. directory samples partial files, which silently produces four "different" databases that are actually just different flush points. Poll for completion, or use queries (synchronous) as done here. + +### Integration suite (`t/` runner, Docker) — 39 packages, zero failures + +Run on the 32-core box against a `dgraph/dgraph:local` image built from this +tree and **verified by symbol inspection** to contain the changes +(`flushConflicts`=2, `maphash`=18) before trusting any result. + +**39 packages `ok`, 0 package failures, 0 test failures**, including the ones +that matter most here: + +| package | time | note | +|---|---|---| +| `systest/mutations-and-queries` | 50s | the mutation-heavy systest | +| `systest/vector` | 1484s | HNSW path, a known pipeline bug source | +| `worker` | 213s | passes cleanly under the integration harness | +| `systest/multi-tenancy`, `systest/plugin`, `acl`, … | — | ok | +| `posting` (rerun, `--pkg=posting`) | — | `T_EXIT=0` | + +Setup needed on a clean box: Docker (`t/` demands it even for unit tests), +`gotestsum` (`AUTO_INSTALL=true make check-deps`), a git repo root, and +`make local-image`. `ack` is unavailable on RHEL 9 and blocks `check-deps`, but +the runner itself does not need it — invoke `go run .` in `t/` directly. + +**Unexplained:** the first full-suite invocation exited 1 with no failing test +and a log ending mid-`posting`. Re-running that package alone gave `T_EXIT=0`. +The four `panic:` entries in the log are intentional — they come from +`TestRunAll_WithDgraphDirectives/panic_catcher`, whose own output says the panic +is expected. + +Caveat worth stating: `TestRebuildTokIndex` appears to stall if you filter test +output with `grep | head` — badger emits enough INFO lines between `=== RUN` and +`--- PASS` to be truncated. It passes in 0.06s. From 0a51e142ff074d6a2e28f8dcc64e115a901616eb Mon Sep 17 00:00:00 2001 From: Ryan Hendrickson Date: Sun, 26 Jul 2026 21:07:21 -0400 Subject: [PATCH 33/45] =?UTF-8?q?docs:=20production-replica=20benchmark=20?= =?UTF-8?q?=E2=80=94=20+17.6%=20on=20a=20realistic=20predicate=20mix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-ran the A/B against the ext-pointer-tech-collab three-stage generator, which replicates the NiFi/dgraph4j production pipeline: 30 @reverse and 20 @upsert predicates, 1.67M seeded nodes, Zipf hot-node reverse fan-out, @upsert re-asserts and update waves, 532 transaction-sized files. Both arms restore the same seeded snapshot so only the binary differs. stock 1.475 -> shard 1.735 MB/s (+17.6%), 44,699 -> 52,357 nquads/s (+17.1%), 129.4s -> 110.5s wall clock, reproducible to ~1% across 2 runs per arm. Abort counts are identical (~1,860 both arms), which is the strongest correctness signal yet for the conflict-key batching: conflict semantics are unchanged under real @upsert contention. Records two things the synthetic corpus hid: absolute throughput is ~1.5 MB/s rather than 13 because this workload is abort-dominated (only ~283 of 532 files commit; the ceiling is Zero's conflict arbitration, not the apply path), and the relative gain is half the synthetic figure. 17.6% is the number to quote for production, not 36.8%. Co-Authored-By: Claude Opus 5 --- pipeline-todo.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/pipeline-todo.md b/pipeline-todo.md index 22aba43d612..1f79ec68566 100644 --- a/pipeline-todo.md +++ b/pipeline-todo.md @@ -343,3 +343,43 @@ is expected. Caveat worth stating: `TestRebuildTokIndex` appears to stall if you filter test output with `grep | head` — badger emits enough INFO lines between `=== RUN` and `--- PASS` to be truncated. It passes in 0.06s. + +### Production-replica corpus benchmark (ext-pointer-tech-collab data generator) + +The synthetic corpus used above is 100% `[uid] @reverse` with no upserts, which +overstates the reverse share. Re-ran against the three-stage generator from +`ext-pointer-tech-collab/data-generator`, which replicates the NiFi/dgraph4j +production pipeline: **30 `@reverse` and 20 `@upsert` predicates**, 1.67M seeded +nodes, hot-node reverse fan-out (Zipf `--hot-skew`), `@upsert` re-asserts and +hours-later update waves. 532 transaction-sized files (20k nquads each, 600 MB). + +Both arms restore the **same seeded snapshot**, so they differ only in binary. +Budget=30 (shipped default), 16 client threads, 2 runs per arm: + +| run | MB/s | elapsed | aborts | nquads committed | nquads/s | +|---|---|---|---|---|---| +| stock r1 | 1.47 | 129.2s | 1855 | 5,692,627 | 44,900 | +| stock r2 | 1.48 | 129.5s | 1871 | 5,746,044 | 44,498 | +| **shard r1** | **1.74** | 110.8s | 1850 | 5,709,947 | **52,612** | +| **shard r2** | **1.73** | 110.1s | 1857 | 5,625,354 | **52,101** | + +**+17.6% MB/s, +17.1% nquads/s, 14.6% faster wall clock**, reproducible to ~1%. + +**Abort counts are identical (~1,860 both arms)** — the conflict-key semantics +are unchanged under real `@upsert` contention, which is the strongest available +correctness signal for the conflict-key batching work. + +Two things this reveals that the synthetic corpus hid: + +1. **Absolute throughput is far lower (1.5 MB/s vs 13 MB/s)** because this + workload is *abort*-dominated: only ~283 of 532 files commit, the rest give up + after 6 retries. The ceiling here is Zero's conflict arbitration, not the + apply path — which is exactly the contention the production client's + Caffeine lock layer exists to avoid. +2. The relative gain (+17.6%) is **half** the synthetic figure (+36.8%), which is + the expected dilution from a realistic predicate mix. Quote the 17.6% number + for production expectations, not the 36.8%. + +Reproduce: `/data/harness/prodbench.sh ` with +`/data/gen` (generator) and `/data/seed_snapshot` (seeded state) on the EC2 box. +Generator needs Python 3.10+ (`dataclass(slots=True)`) — RHEL 9 ships 3.9. From 0261268a21d0fa3431d02faea1d6483ad4f388b7 Mon Sep 17 00:00:00 2001 From: Ryan Hendrickson Date: Sun, 26 Jul 2026 21:49:13 -0400 Subject: [PATCH 34/45] =?UTF-8?q?docs:=20client-thread=20sweep=20=E2=80=94?= =?UTF-8?q?=20abort=20storm=20dominates=20this=20workload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swept client threads {2,4,8,16} on the production-replica corpus. Two findings. Throughput more than TRIPLES as threads drop from 16 to 2 (stock 1.48 -> 5.30 MB/s, 44.7k -> 143k nquads/s); aborts fall 1,860 -> 380 and committed files rise 283 -> 502 of 532. On this workload the Alpha is not the constraint — Zero's conflict arbitration over @upsert re-asserts is, and each extra client thread buys more aborted work than committed work. This quantifies what the production client-side node-UID lock cache is worth, and suggests an ingest tuned for high thread counts without it is losing throughput rather than gaining it. The apply-path gain holds at every thread count (1.12x at 2 threads through 1.18x at 16) and is therefore not an artifact of abort noise: at 2 threads, where 502/532 files commit and aborts are minimal, it is still +12%. Abort counts track each other across all four thread counts (380/379, 945/931, 1465/1479, 1855/1857), confirming conflict semantics are unchanged. Co-Authored-By: Claude Opus 5 --- pipeline-todo.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/pipeline-todo.md b/pipeline-todo.md index 1f79ec68566..c23433d0554 100644 --- a/pipeline-todo.md +++ b/pipeline-todo.md @@ -383,3 +383,36 @@ Two things this reveals that the synthetic corpus hid: Reproduce: `/data/harness/prodbench.sh ` with `/data/gen` (generator) and `/data/seed_snapshot` (seeded state) on the EC2 box. Generator needs Python 3.10+ (`dataclass(slots=True)`) — RHEL 9 ships 3.9. + +#### Client-thread sweep — the abort storm dominates, and fewer threads is faster + +Same production-replica corpus, budget=30, varying client threads (emulating a +lock-cache-protected client, which serialises conflicting writes rather than +letting them abort): + +| threads | stock MB/s | shard MB/s | speedup | stock nquads/s | shard nquads/s | files committed | aborts | +|---:|---:|---:|---:|---:|---:|---:|---:| +| 2 | 5.30 | **5.96** | 1.12x | 143,143 | **160,825** | 502-520 / 532 | ~380 | +| 4 | 3.62 | **4.09** | 1.13x | 100,348 | **113,459** | 454 / 532 | ~940 | +| 8 | 2.31 | **2.67** | 1.16x | 65,918 | **76,008** | ~368 / 532 | ~1,470 | +| 16 | 1.48 | **1.74** | 1.18x | 44,699 | 52,357 | ~283 / 532 | ~1,860 | + +**Throughput more than triples as client threads DROP from 16 to 2** (1.48 -> +5.30 MB/s on stock; 44.7k -> 143k nquads/s). Aborts fall 1,860 -> 380 and +committed files rise 283 -> 502 of 532. On this workload the Alpha is not the +constraint at all — Zero's conflict arbitration over `@upsert` re-asserts is, +and every extra client thread buys more aborted work than committed work. + +Two consequences worth acting on: + +1. **A production ingest tuned for more threads is likely losing throughput to + aborts, not gaining it.** This is precisely what the client-side node-UID lock + cache exists to prevent — these numbers quantify what it is worth. Anyone + without it should test *lower* concurrency before adding more. +2. **The apply-path gain is consistent everywhere (1.12x-1.18x)** and grows + mildly with contention. It is not an artifact of the abort storm — at 2 + threads, where 502/532 files commit and aborts are minimal, it is still + **+12%** (and +12.4% on nquads/s). + +Abort counts track each other across every thread count (380/379, 945/931, +1465/1479, 1855/1857) — conflict semantics unchanged, as intended. From 8b7e95699b828ba323155b0086dd39b667756adf Mon Sep 17 00:00:00 2001 From: Ryan Hendrickson Date: Mon, 27 Jul 2026 02:54:47 -0400 Subject: [PATCH 35/45] refactor(flags): collapse the mutation-pipeline knobs into intra-mutation-* Four superflag keys become three, renamed to say what they scope. All of this parallelizes a single mutation; processApplyCh is still serial across transactions, so the names now make that visible rather than leaving an operator to infer it. mutations-pipeline-threshold -> intra-mutation-min-edges mutations-pipeline-goroutines -> intra-mutation-parallelism mutations-pipeline-goroutines-fraction ^ (merged: off|auto|N|Fx) mutations-pipeline-min-edges-per-worker -> intra-mutation-edges-per-worker The two merged keys were the same axis in two notations -- a worker count and a multiple of GOMAXPROCS -- with the first doubling as the tag selecting which was read. The -1 AUTO sentinel is gone; auto is 1x. Two of the four keys used to be inert unless goroutines == -1. That is fixed by deleting the conditional, not the flags: edges-per-worker now caps every sizing mode, because "do not spin N workers for a handful of edges" is as true of a fixed count as a derived one. It stays a flag deliberately -- 256 was adopted by analogy to DivideAndRule and never measured, and it is the binding term on a large box, where a 20k-edge mutation caps at 78 workers however many cores exist. Decouple the lock-free store from the worker grant. allocateWorkers now floors every predicate at 1 instead of returning nil, so a grant of 1 means "no fan-out", not "take the legacy locked store". Tying those together made the disabled setting also give up the lock-free store -- about two thirds of this branch's measured gain -- and made the flag non-monotonic, since a one-worker grant dropped index tokenization from 10 goroutines to 1. numGo is now max(10, grant). intra-mutation-min-edges=0 remains the single kill switch. Also adds the observability that was missing: the three fields now appear in WorkerOptions.String() (the startup log could not previously confirm whether the budget was 30 or 0), a V(2) line reports the resolved grant and which term bound it, and intra_mutation_no_fanout_total counts the silent-degradation case. Measured on an i4i.16xlarge against the reverse-heavy corpus this branch was optimized for: 132,730 vs 133,026 triples/s at a fixed budget (-0.22%) and 132,664 vs 132,513 at auto (+0.11%) -- both within run-to-run noise. TestSchemaMatrixByteIdentical now compares against the REAL legacy path (a serial runMutation loop) rather than the pipeline with its budget off, which the store decoupling removed. That is strictly stronger, and it surfaced three pipeline-vs-legacy divergences that predate this work; they are measured on the pre-change tree and recorded in pipeline-todo.md rather than pinned here. Co-Authored-By: Claude Opus 5 --- dgraph/cmd/alpha/run.go | 86 ++++---- posting/conflict_keys_test.go | 6 +- posting/index.go | 285 ++++++++++++++----------- posting/pipeline_budget_test.go | 206 ++++++++++++------ posting/pipeline_revbench_test.go | 46 ++-- posting/pipeline_schema_matrix_test.go | 150 ++++++++++--- worker/draft.go | 2 +- worker/feature_flags_test.go | 82 +++++++ worker/server_state.go | 5 +- x/config.go | 161 ++++++++++---- x/config_test.go | 94 ++++++++ x/metrics.go | 15 ++ 12 files changed, 812 insertions(+), 326 deletions(-) create mode 100644 worker/feature_flags_test.go create mode 100644 x/config_test.go diff --git a/dgraph/cmd/alpha/run.go b/dgraph/cmd/alpha/run.go index 3bf5f290839..080362286a2 100644 --- a/dgraph/cmd/alpha/run.go +++ b/dgraph/cmd/alpha/run.go @@ -283,39 +283,46 @@ they form a Raft group and provide synchronous replication. "with structured fields including trace ID for correlation with distributed traces. "+ "Disabled by default (0). Note: enabling this logs query text which may contain "+ "sensitive data; do not enable in deployments with strict data privacy requirements."). - Flag("mutations-pipeline-threshold", - "Per-mutation edge-count threshold for routing through the per-predicate "+ - "mutation pipeline, which parallelizes work across distinct predicates "+ - "in a single mutation. 0 disables the pipeline entirely (all mutations "+ - "take the legacy path). 1 always uses the pipeline. A value N>1 uses "+ - "the pipeline only when len(edges) >= N, leaving small interactive "+ - "mutations on the legacy path — the pipeline pays per-predicate "+ - "goroutine spin-up cost, so tiny mutations are slightly slower on it; "+ - "bulk multi-predicate mutations are faster (crossover ~100 edges in "+ - "benchmarks here)."). - Flag("mutations-pipeline-goroutines", - "Global budget of intra-predicate worker goroutines distributed "+ - "across the predicates in a single mutation batch, proportionally to "+ - "each predicate's edge count. A hot/dominant predicate is granted most "+ - "of the budget so its data-write and index passes parallelize; tiny "+ - "predicates get one worker each. 0 (or any value < 2) disables "+ - "intra-predicate parallelism — every predicate runs on a single "+ - "goroutine, exactly the legacy behavior. N>0 is an absolute budget. "+ - "-1 = auto (derive from GOMAXPROCS*fraction, capped by "+ - "edges/min-edges-per-worker). Default 30."). - Flag("mutations-pipeline-goroutines-fraction", - "AUTO mode only (mutations-pipeline-goroutines=-1): fraction of "+ - "GOMAXPROCS one mutation batch may use. The apply phase is serial "+ - "across transactions, so a single apply effectively owns the box; "+ - "default 1.0 uses all cores. Raise above 1.0 to oversubscribe "+ - "(benchmarks show the throughput peak near 2-3x cores) or lower it "+ - "to leave headroom for Badger compaction/queries/GC under heavy "+ - "concurrent read load. Default 1.0."). - Flag("mutations-pipeline-min-edges-per-worker", - "AUTO mode only (mutations-pipeline-goroutines=-1): minimum edges per "+ - "intra-predicate worker the derived budget targets "+ - "(workCap = totalEdges / this). Mirrors the 256-edge DivideAndRule "+ - "rule. Default 256."). + Flag("intra-mutation-min-edges", + "Minimum edge count for a mutation to take the parallel apply path, "+ + "which spreads the predicates in ONE mutation across goroutines. "+ + "0 disables that path entirely (all mutations take the legacy path) "+ + "and is the single kill switch for intra-mutation parallelism. "+ + "1 (default) always takes it. A value N>1 takes it only when "+ + "len(edges) >= N, leaving small interactive mutations on the legacy "+ + "path — the parallel path pays per-predicate goroutine spin-up cost, "+ + "so tiny mutations are slightly slower on it; bulk multi-predicate "+ + "mutations are faster (crossover ~100 edges in benchmarks here)."). + Flag("intra-mutation-parallelism", + "Concurrent worker goroutines used INSIDE a single mutation, shared "+ + "across the predicates it touches and apportioned by edge count so a "+ + "hot predicate is granted most of them and tiny predicates get one "+ + "each. 'off' = one worker per predicate, no fan-out. 'N' = exactly N "+ + "workers. 'Fx' = F workers per CPU available to Go (GOMAXPROCS, which "+ + "respects container CPU limits), e.g. '1.5x'. 'auto' (default) = '1x'. "+ + "Always further capped by intra-mutation-edges-per-worker. NOTE this "+ + "parallelizes ONE mutation — transactions still apply serially, so it "+ + "will not relieve a many-concurrent-writers bottleneck."). + Flag("intra-mutation-edges-per-worker", + "Minimum edges a worker should have to justify existing: the pool set by "+ + "intra-mutation-parallelism is capped at totalEdges divided by this, "+ + "so small mutations do not over-spawn. Mirrors the 256-edge "+ + "DivideAndRule rule. Applies to every sizing mode, not just 'Fx'. On a "+ + "large box this is frequently the BINDING constraint — at the default "+ + "a 20k-edge mutation caps at 78 workers however many cores exist — so "+ + "lower it, not the multiplier, to get more parallelism there. "+ + "Default 256."). + Flag("cross-txn-apply-workers", + "EXPERIMENTAL. How many SEPARATE transactions may have their mutation "+ + "pre-writes applied concurrently in the Raft apply loop. 0 (default) "+ + "applies one entry at a time, the historical behaviour. This is a "+ + "different axis from intra-mutation-parallelism, which parallelizes "+ + "the inside of a single mutation: the serial apply loop measures at "+ + "~88% of wall clock on a production-replica corpus, so it is the "+ + "ceiling that intra-mutation work cannot lift. Only plain mutations "+ + "run concurrently -- drop, schema and type operations, snapshots, "+ + "key-value restores and commit deltas all stay serial and in Raft log "+ + "order."). String()) RegisterFlags(flag) @@ -843,10 +850,15 @@ func run() { x.Config.NormalizeCompatibilityMode = featureFlagsConf.GetString("normalize-compatibility-mode") enableDetailedMetrics := featureFlagsConf.GetBool("enable-detailed-metrics") x.WorkerConfig.SlowQueryLogThreshold = featureFlagsConf.GetDuration("log-slow-query-threshold") - x.WorkerConfig.MutationsPipelineThreshold = int(featureFlagsConf.GetInt64("mutations-pipeline-threshold")) - x.WorkerConfig.MutationsPipelineGoroutines = int(featureFlagsConf.GetInt64("mutations-pipeline-goroutines")) - x.WorkerConfig.MutationsPipelineGoroutinesFraction = featureFlagsConf.GetFloat64("mutations-pipeline-goroutines-fraction") - x.WorkerConfig.MutationsPipelineMinEdgesPerWorker = int(featureFlagsConf.GetInt64("mutations-pipeline-min-edges-per-worker")) + x.WorkerConfig.IntraMutationMinEdges = int(featureFlagsConf.GetInt64("intra-mutation-min-edges")) + // The superflag layer cannot validate this one — it is off|auto|N|Fx, not a + // plain int — so a typo must fail here at startup rather than silently + // resolve to the zero value and disable fan-out for the life of the process. + parallelism, err := x.ParseIntraMutationParallelism( + featureFlagsConf.GetString("intra-mutation-parallelism")) + x.Checkf(err, "while parsing --feature-flags") + x.WorkerConfig.IntraMutationParallelism = parallelism + x.WorkerConfig.IntraMutationEdgesPerWorker = int(featureFlagsConf.GetInt64("intra-mutation-edges-per-worker")) x.PrintVersion() glog.Infof("x.Config: %+v", x.Config) diff --git a/posting/conflict_keys_test.go b/posting/conflict_keys_test.go index 811d3df7455..9b377bd0b35 100644 --- a/posting/conflict_keys_test.go +++ b/posting/conflict_keys_test.go @@ -102,9 +102,9 @@ func fillContextFixture(t *testing.T, startTs uint64, budget int) []string { cnc: string @index(exact) @noconflict . `), 1)) - ob := x.WorkerConfig.MutationsPipelineGoroutines - x.WorkerConfig.MutationsPipelineGoroutines = budget - defer func() { x.WorkerConfig.MutationsPipelineGoroutines = ob }() + ob := x.WorkerConfig.IntraMutationParallelism + x.WorkerConfig.IntraMutationParallelism = x.IntraMutationParallelism{Workers: budget} + defer func() { x.WorkerConfig.IntraMutationParallelism = ob }() cname := x.AttrInRootNamespace("cname") clink := x.AttrInRootNamespace("clink") diff --git a/posting/index.go b/posting/index.go index 9cc1ed6a882..ce8fc20c059 100644 --- a/posting/index.go +++ b/posting/index.go @@ -244,15 +244,14 @@ func (mp *MutationPipeline) InsertTokenizerIndexes(ctx context.Context, pipeline } } - // Fold the historically-fixed 10-way index tokenization into the global - // budget. When the budget is disabled (pipeline.workers == 0) this stays at - // the legacy numGo=10, so behavior is byte-identical to before. Index - // tokenization is a low-fan-in pass (key ), so parallelizing it - // is safe; the cache-locked merge below still serializes the final write. - numGo := 10 - if pipeline.workers > 0 { - numGo = pipeline.workers - } + // Index tokenization has always run 10-way; the worker grant only ever raises + // that, never lowers it. Taking numGo straight from the grant would make the + // flag non-monotonic: a one-worker grant — what any pool below ~2x the + // predicate count produces — would drop this pass from 10 goroutines to 1, + // so enabling parallelism could measurably *reduce* it. Index tokenization is + // a low-fan-in pass (key ), so extra goroutines are safe; the + // cache-locked merge below still serializes the final write. + numGo := max(10, pipeline.workers) wg.Add(numGo) chMap := make(map[int]chan uint64) @@ -502,26 +501,12 @@ func (mp *MutationPipeline) ProcessList(ctx context.Context, pipeline *Predicate var cbuf conflictBuf defer mp.txn.flushConflicts(&cbuf) - // Legacy path: the intra-predicate budget is disabled entirely (workers == 0). - // Keep the locked AddDelta so "budget off == byte-identical legacy" holds. - if pipeline.workers <= 0 { - serialStore := func(k string, in *pb.PostingList) (*pb.PostingList, error) { - return mp.txn.AddDelta(k, in, info.isUid, true) - } - for uid, pl := range postings { - if err := writeListDataUid(uid, pl, dataKey, &cbuf, serialStore); err != nil { - return err - } - } - return nil - } - - // Budget enabled but only one worker: still use the LOCK-FREE concStore. See - // the equivalent branch in ProcessSingle/ProcessReverse — the win is escaping - // the global txn.cache.Lock() that AddDelta holds across proto.Marshal, not - // the goroutine fan-out, and allocateWorkers hands out mostly 1-worker grants - // whenever the budget is below ~2x the predicate count. - if pipeline.workers == 1 { + // One worker: no fan-out, but still the LOCK-FREE concStore. The win here is + // escaping the global txn.cache.Lock() that AddDelta holds across + // proto.Marshal, not the goroutine fan-out — and allocateWorkers hands out + // mostly 1-worker grants whenever the pool is below ~2x the predicate count, + // so tying the store choice to workers > 1 would strand the common grant. + if pipeline.workers <= 1 { for uid, pl := range postings { if err := writeListDataUid(uid, pl, dataKey, &cbuf, concStore); err != nil { return err @@ -609,7 +594,12 @@ func findSingleValueInPostingList(pb *pb.PostingList) *pb.Posting { // per-target SortAndDedupPostings + Marshal dominates and sharding measures as a // net loss. Above it the map holds thousands of independent <~pred,targetUid> // keys and the split has real work to spread. Mirrors the spirit of -// MutationsPipelineMinEdgesPerWorker. +// IntraMutationEdgesPerWorker. +// +// Deliberately a const, not a flag: 256 is an assumption by analogy rather than +// a measurement (behavior is known at 5 targets and at 600+, but the crossover +// between them has never been swept), and exposing an uncalibrated knob would +// work against the flag simplification. const reverseParallelMinTargets = 256 func (mp *MutationPipeline) ProcessReverse(ctx context.Context, pipeline *PredicatePipeline, postings *map[uint64]*pb.PostingList, info predicateInfo) error { @@ -718,38 +708,23 @@ func (mp *MutationPipeline) ProcessReverse(ctx context.Context, pipeline *Predic return merged, nil } - // Legacy path: the intra-predicate budget is disabled entirely (workers == 0). - // Keep the locked AddDelta so the "budget off == byte-identical legacy" - // contract holds. - if pipeline.workers <= 0 { - serialStore := func(k string, in *pb.PostingList) (*pb.PostingList, error) { - return mp.txn.AddDelta(k, in, true, true) - } - for uid, pl := range reverseredMap { - if err := writeReverseTarget(uid, pl, key, &cbuf, serialStore); err != nil { - return err - } - } - return nil - } - - // Budget enabled but no k-way split — either a one-worker grant or too few - // distinct targets to be worth partitioning. Still use the LOCK-FREE store. + // No k-way split — either a one-worker grant or too few distinct targets to be + // worth partitioning. Still use the LOCK-FREE store. // // This split matters more than the goroutine fan-out: the dominant cost of the - // legacy loop is not the absence of workers, it is that AddDelta holds the + // serial loop is not the absence of workers, it is that AddDelta holds the // GLOBAL txn.cache.Lock() across proto.Marshal, so every concurrent predicate // goroutine at L1 convoys on that one mutex. A single-writer loop through // concStore escapes the convoy without spawning anything. Keeping these two // decisions tied together would strand the common production grant — with P - // predicates and a budget below 2*P, allocateWorkers hands out mostly 1s, so + // predicates and a pool below 2*P, allocateWorkers hands out mostly 1s, so // nearly every predicate would fall back to the global lock. // // Single-writer safety holds here for the same reason it does in the parallel // path: reverse keys are <~pred,targetUid>, and x.generateKey encodes attrLen // plus a per-type discriminator byte, so no two predicates — and no other key // type — can produce the same key string. - if pipeline.workers == 1 || len(reverseredMap) < reverseParallelMinTargets { + if pipeline.workers <= 1 || len(reverseredMap) < reverseParallelMinTargets { for uid, pl := range reverseredMap { if err := writeReverseTarget(uid, pl, key, &cbuf, concStore); err != nil { return err @@ -1365,31 +1340,16 @@ func (mp *MutationPipeline) ProcessSingle(ctx context.Context, pipeline *Predica var cbuf conflictBuf defer mp.txn.flushConflicts(&cbuf) - // Legacy path: the intra-predicate budget is disabled entirely (workers == 0). - // Keep the locked AddDelta so "budget off == byte-identical legacy" holds. - if pipeline.workers <= 0 { - for uid, pl := range postings { - if err := writeDataUid(uid, pl, dataKey, &cbuf, - func(k string, wpl *pb.PostingList) error { - _, err := mp.txn.AddDelta(k, wpl, false, false) - return err - }); err != nil { - return err - } - } - return nil - } - - // Budget enabled but only one worker: still use the LOCK-FREE store. The - // dominant cost of the legacy loop is not the missing goroutines, it is that - // AddDelta holds the GLOBAL txn.cache.Lock() across proto.Marshal, so every - // concurrent predicate goroutine at L1 convoys on that one mutex. Tying the - // lock-free store to workers > 1 stranded the common production grant: - // allocateWorkers hands out mostly 1s when the budget is below ~2x the - // predicate count. Single-writer safety is unchanged — data keys - // are one-to-one and distinct predicates cannot collide (x.generateKey - // encodes attrLen plus a per-type discriminator byte). - if pipeline.workers == 1 { + // One worker: no fan-out, but still the LOCK-FREE store. The dominant cost of + // the serial loop is not the missing goroutines, it is that AddDelta holds the + // GLOBAL txn.cache.Lock() across proto.Marshal, so every concurrent predicate + // goroutine at L1 convoys on that one mutex. Tying the lock-free store to + // workers > 1 stranded the common production grant: allocateWorkers hands out + // mostly 1s when the pool is below ~2x the predicate count. Single-writer + // safety is unchanged — data keys are one-to-one and distinct + // predicates cannot collide (x.generateKey encodes attrLen plus a per-type + // discriminator byte). + if pipeline.workers <= 1 { for uid, pl := range postings { if err := writeDataUid(uid, pl, dataKey, &cbuf, mp.txn.AddDeltaConcurrent); err != nil { return err @@ -1675,10 +1635,16 @@ func ValidateAndConvert(edge *pb.DirectedEdge, su *pb.SchemaUpdate) error { // merge-light passes (data-write, index tokenization). // // edgeCounts maps predicate attr -> number of (non-star-delete) edges for that -// predicate in the batch; budget is x.WorkerConfig.MutationsPipelineGoroutines. -// Returns nil when the budget is disabled (budget < 2) or there are no -// predicates. A nil result — and any attr missing from the result — is treated -// by callers as workers=0, i.e. the legacy one-goroutine-per-predicate path. +// predicate in the batch; budget is the resolved worker count from +// resolveWorkers. Returns nil only when there are no predicates. +// +// Every predicate gets at least 1, including when the pool is disabled +// (budget < 2). Callers therefore never see workers == 0: a grant of 1 means +// "run this predicate's passes on one goroutine", NOT "take the legacy locked +// store". Those two decisions used to be tied together, which made the +// disabled setting also opt out of the lock-free store — the single largest +// win in this whole change — and made the flag non-monotonic. The kill switch +// is intra-mutation-min-edges=0, which bypasses this path entirely. // // Apportionment is Hamilton / largest-remainder with a floor of 1: // - If P := len(edgeCounts) >= budget, the batch already saturates the budget @@ -1698,9 +1664,18 @@ func ValidateAndConvert(edge *pb.DirectedEdge, su *pb.SchemaUpdate) error { // P < budget, sum(workers_p) == budget. func allocateWorkers(edgeCounts map[string]int, budget int) map[string]int { P := len(edgeCounts) - if budget < 2 || P == 0 { + if P == 0 { return nil } + if budget < 2 { + // Disabled, or too small to split: one worker each. Still a real grant, + // so the lock-free store stays reachable. + out := make(map[string]int, P) + for attr := range edgeCounts { + out[attr] = 1 + } + return out + } // Deterministic predicate order so the remainder tie-break is stable. attrs := make([]string, 0, P) @@ -1786,46 +1761,116 @@ func allocateWorkers(edgeCounts map[string]int, budget int) map[string]int { return out } -// mutationsPipelineGoroutinesAuto is the sentinel value of -// x.WorkerConfig.MutationsPipelineGoroutines that selects AUTO mode: instead of a -// fixed absolute budget, the per-batch budget is derived at runtime from the -// machine and the batch size by autoBudget. 0 still disables the budget and any -// N>0 is still an absolute budget. -const mutationsPipelineGoroutinesAuto = -1 - -// autoBudget derives the per-batch intra-predicate goroutine budget at runtime -// (AUTO mode, mutations-pipeline-goroutines=-1). It is a pure, deterministic -// function of its inputs — independent of the real GOMAXPROCS — so the formula -// can be unit-tested directly. +// resolveWorkers turns the operator's requested pool size into the worker count +// for one mutation, applying the edges-per-worker cap: // -// machineCap = max(1, round(gomaxprocs * fraction)) -// workCap = max(1, totalEdges / minEdgesPerWorker) -// budget = min(machineCap, workCap) +// workCap = max(1, totalEdges / edgesPerWorker) +// workers = min(sizing, workCap) // and 0 stays 0 // -// fraction is < 1 by default (0.5) on purpose: the apply phase is serial across -// transactions, so a single apply effectively owns the whole box, and we want to -// leave headroom for Badger compaction, concurrent queries, and the GC rather -// than pin every core to one mutation. workCap mirrors x.DivideAndRule's 256-edge -// rule — there is no point spinning up a worker per <256 edges. A computed budget -// of 1 (or anything < 2) feeds into allocateWorkers, which returns nil for -// budget < 2, so the caller transparently falls back to the legacy -// one-goroutine-per-predicate path. -func autoBudget(gomaxprocs, totalEdges int, fraction float64, minEdgesPerWorker int) int { - machineCap := int(math.Round(float64(gomaxprocs) * fraction)) - if machineCap < 1 { - machineCap = 1 - } - if minEdgesPerWorker < 1 { - minEdgesPerWorker = 1 // defensive: avoid divide-by-zero on misconfig - } - workCap := totalEdges / minEdgesPerWorker +// It is pure and deterministic — gomaxprocs is already folded into sizing by +// x.IntraMutationParallelism.Sizing — so the policy is unit-testable without a +// runtime. +// +// The cap applies to EVERY sizing mode, not just the per-CPU one: "do not spin N +// workers for a handful of edges" is as true of a fixed count as of a derived +// one. Making it conditional (as AUTO-only) was what left two of the old four +// flags inert at their defaults. +// +// It is also frequently the binding term rather than a safety net. At the +// default 256 a 20k-edge mutation caps at 78 workers however many cores exist, +// so on a large box edgesPerWorker — not the multiplier — is what governs. That +// is subtle enough that the V(2) grant log names which term won. +// +// A result below 2 feeds into allocateWorkers, which floors every predicate at +// one worker, i.e. no intra-predicate fan-out. +func resolveWorkers(sizing, totalEdges, edgesPerWorker int) (workers, workCap int) { + if sizing <= 0 { + return 0, 0 + } + if edgesPerWorker < 1 { + edgesPerWorker = 1 // defensive: avoid divide-by-zero on misconfig + } + workCap = totalEdges / edgesPerWorker if workCap < 1 { workCap = 1 } - if machineCap < workCap { - return machineCap + if sizing < workCap { + return sizing, workCap + } + return workCap, workCap +} + +// logWorkerGrant reports the resolved intra-predicate worker grant at V(2), and +// counts the batches that ended up with no fan-out at all. +// +// The all-1s grant is the failure mode worth watching. allocateWorkers floors +// every predicate at one worker, so any budget below roughly twice the +// predicate count silently degrades to one goroutine per predicate — 25 +// predicates against a budget of 30 yields {1:20 2:5}. Nothing else in the +// system reports that: the mutation is fully correct and merely runs +// single-threaded per predicate, so the only symptom is throughput that never +// responds to the flag. +// +// sizing is what the operator asked for and workCap is the edges-per-worker +// ceiling, so the log can name which of the two actually bound — the part of +// the resolution that is least visible from the outside. +func logWorkerGrant(ctx context.Context, workers, sizing, workCap, totalEdges int, + edgeCounts, workerAlloc map[string]int) { + + if len(edgeCounts) == 0 { + return + } + + // Count only the SILENT case: parallelism was asked for, yet the apportionment + // still gave nobody more than one worker. workers == 0 means the operator set + // it to off, which is a deliberate choice and not worth alerting on. + if workers > 0 { + fanout := false + for _, w := range workerAlloc { + if w > 1 { + fanout = true + break + } + } + if !fanout { + ostats.Record(ctx, x.IntraMutationNoFanout.M(1)) + } + } + + if !glog.V(2) { + return + } + + // Histogram of workers -> predicate count, ordered so successive log lines + // are comparable. allocateWorkers covers every attr, so every value is >= 1. + hist := make(map[int]int, 4) + for attr := range edgeCounts { + hist[workerAlloc[attr]]++ + } + widths := make([]int, 0, len(hist)) + for w := range hist { + widths = append(widths, w) } - return workCap + sort.Ints(widths) + var grant strings.Builder + for i, w := range widths { + if i > 0 { + grant.WriteByte(' ') + } + fmt.Fprintf(&grant, "%d:%d", w, hist[w]) + } + + boundBy := "off" + switch { + case workers <= 0: + case sizing < workCap: + boundBy = "parallelism" + default: + boundBy = "edges-per-worker" + } + + glog.Infof("intra-mutation: workers=%d boundBy=%s predicates=%d edges=%d grant={%s}", + workers, boundBy, len(edgeCounts), totalEdges, grant.String()) } // Process is the entry point for the mutation pipeline. It fans out one goroutine per @@ -1879,13 +1924,11 @@ func (mp *MutationPipeline) Process(ctx context.Context, edges []*pb.DirectedEdg // once per call from GOMAXPROCS and the batch size before apportionment. AUTO // only chooses the integer fed into allocateWorkers — the apportionment and the // whole downstream path are identical to a fixed budget of that value. - budget := x.WorkerConfig.MutationsPipelineGoroutines - if budget == mutationsPipelineGoroutinesAuto { - budget = autoBudget(runtime.GOMAXPROCS(0), totalEdges, - x.WorkerConfig.MutationsPipelineGoroutinesFraction, - x.WorkerConfig.MutationsPipelineMinEdgesPerWorker) - } - workerAlloc := allocateWorkers(edgeCounts, budget) + sizing := x.WorkerConfig.IntraMutationParallelism.Sizing(runtime.GOMAXPROCS(0)) + workers, workCap := resolveWorkers(sizing, totalEdges, + x.WorkerConfig.IntraMutationEdgesPerWorker) + workerAlloc := allocateWorkers(edgeCounts, workers) + logWorkerGrant(ctx, workers, sizing, workCap, totalEdges, edgeCounts, workerAlloc) predicates := map[string]*PredicatePipeline{} var wg sync.WaitGroup diff --git a/posting/pipeline_budget_test.go b/posting/pipeline_budget_test.go index 5ab354d46ea..5d70915f7db 100644 --- a/posting/pipeline_budget_test.go +++ b/posting/pipeline_budget_test.go @@ -6,7 +6,7 @@ package posting // Tests and benchmark for the proportional intra-predicate goroutine budget -// (mutations-pipeline-goroutines). The budget lets a hot/dominant predicate use +// (intra-mutation-parallelism). The budget lets a hot/dominant predicate use // more than one goroutine for its merge-light passes (data-write, index // tokenization) while staying byte-identical to the legacy one-goroutine path. @@ -131,9 +131,9 @@ func sortedUint64(in []uint64) []uint64 { func runBudgetBatch(t *testing.T, budget int, startTs, commitTs uint64, edges []*pb.DirectedEdge) map[uint64]struct{} { t.Helper() - old := x.WorkerConfig.MutationsPipelineGoroutines - x.WorkerConfig.MutationsPipelineGoroutines = budget - defer func() { x.WorkerConfig.MutationsPipelineGoroutines = old }() + old := x.WorkerConfig.IntraMutationParallelism + x.WorkerConfig.IntraMutationParallelism = x.IntraMutationParallelism{Workers: budget} + defer func() { x.WorkerConfig.IntraMutationParallelism = old }() txn := Oracle().RegisterStartTs(startTs) mp := NewMutationPipeline(txn) @@ -161,9 +161,9 @@ func runBudgetBatch(t *testing.T, budget int, startTs, commitTs uint64, func runTwoProposalBatch(t *testing.T, budget int, startTs, commitTs uint64, batch1, batch2 []*pb.DirectedEdge) map[uint64]struct{} { t.Helper() - old := x.WorkerConfig.MutationsPipelineGoroutines - x.WorkerConfig.MutationsPipelineGoroutines = budget - defer func() { x.WorkerConfig.MutationsPipelineGoroutines = old }() + old := x.WorkerConfig.IntraMutationParallelism + x.WorkerConfig.IntraMutationParallelism = x.IntraMutationParallelism{Workers: budget} + defer func() { x.WorkerConfig.IntraMutationParallelism = old }() txn := Oracle().RegisterStartTs(startTs) @@ -409,12 +409,30 @@ func TestProcessReverseCrossProposalByteIdentical(t *testing.T) { // budget-disabled and saturated edge cases, the largest-remainder distribution, // the floor-of-1 reclaim, and determinism — no cluster needed. func TestAllocateWorkers(t *testing.T) { - // Disabled budget (< 2) and empty input return nil → callers treat every - // predicate as workers=0 (legacy path). - require.Nil(t, allocateWorkers(map[string]int{"a": 5, "b": 3}, 0)) - require.Nil(t, allocateWorkers(map[string]int{"a": 5, "b": 3}, 1)) + // A budget below 2 grants one worker each rather than nil. This is the + // decoupling: a 1-worker grant means "no fan-out", NOT "take the legacy + // locked store". Tying those together made the disabled setting also give up + // the lock-free store — about two thirds of this branch's measured gain — and + // made the flag non-monotonic. The real kill switch is + // intra-mutation-min-edges=0, which never reaches this code. + require.Equal(t, map[string]int{"a": 1, "b": 1}, + allocateWorkers(map[string]int{"a": 5, "b": 3}, 0)) + require.Equal(t, map[string]int{"a": 1, "b": 1}, + allocateWorkers(map[string]int{"a": 5, "b": 3}, 1)) + + // No predicates is still nil — there is nothing to grant. require.Nil(t, allocateWorkers(map[string]int{}, 30)) + // The invariant callers rely on: every predicate present in edgeCounts gets a + // grant of at least 1, at every budget. + for _, budget := range []int{0, 1, 2, 3, 30, 1000} { + got := allocateWorkers(map[string]int{"a": 5, "b": 3, "c": 1}, budget) + require.Len(t, got, 3, "budget %d", budget) + for attr, w := range got { + require.GreaterOrEqual(t, w, 1, "budget %d, attr %s", budget, attr) + } + } + // P >= budget: one worker each, nothing left to split with. got := allocateWorkers(map[string]int{"a": 100, "b": 50, "c": 1}, 2) require.Equal(t, map[string]int{"a": 1, "b": 1, "c": 1}, got) @@ -646,49 +664,102 @@ func TestProcessListBudgetByteIdentical(t *testing.T) { require.Equal(t, conflicts0, conflicts30, "conflict-key sets must be identical") } -// TestAutoBudget exercises the pure AUTO-mode derivation directly, independent of -// the real GOMAXPROCS: machineCap vs workCap interaction, rounding, the budget<2 -// fallback, the divide-by-zero guard on a misconfigured min-edges, and -// determinism — no cluster needed. -func TestAutoBudget(t *testing.T) { - // machineCap dominates: round(96*0.5)=48 < 1_000_000/256=3906. - require.Equal(t, 48, autoBudget(96, 1_000_000, 0.5, 256)) +// mustResolve is Sizing followed by resolveWorkers, i.e. the full flag-to-worker +// -count path a mutation takes, with gomaxprocs supplied so it stays independent +// of the host running the test. +func mustResolve(par x.IntraMutationParallelism, gomaxprocs, totalEdges, edgesPerWorker int) int { + workers, _ := resolveWorkers(par.Sizing(gomaxprocs), totalEdges, edgesPerWorker) + return workers +} + +// TestParallelismSizing exercises the requested-size half of the resolution — +// the part that turns off|auto|N|Fx into a number — independent of the real +// GOMAXPROCS. +func TestParallelismSizing(t *testing.T) { + // off: the zero value, and an explicit negative, both mean no fan-out. + require.Equal(t, 0, x.IntraMutationParallelism{}.Sizing(96)) + require.Equal(t, 0, x.IntraMutationParallelism{Workers: -5}.Sizing(96)) + + // Absolute counts ignore the machine entirely. + require.Equal(t, 30, x.IntraMutationParallelism{Workers: 30}.Sizing(96)) + require.Equal(t, 30, x.IntraMutationParallelism{Workers: 30}.Sizing(4)) + + // Per-CPU sizing tracks the machine. Rounding is half away from zero: + // round(3*0.5) == round(1.5) == 2. + require.Equal(t, 96, x.IntraMutationParallelism{PerCPU: 1.0}.Sizing(96)) + require.Equal(t, 48, x.IntraMutationParallelism{PerCPU: 0.5}.Sizing(96)) + require.Equal(t, 144, x.IntraMutationParallelism{PerCPU: 1.5}.Sizing(96)) + require.Equal(t, 2, x.IntraMutationParallelism{PerCPU: 0.5}.Sizing(3)) + + // PerCPU wins when both are set, and never rounds down to 0. + require.Equal(t, 96, x.IntraMutationParallelism{Workers: 30, PerCPU: 1.0}.Sizing(96)) + require.Equal(t, 1, x.IntraMutationParallelism{PerCPU: 0.01}.Sizing(4)) +} + +// TestResolveWorkers pins the edges-per-worker cap and the attribution the V(2) +// grant log reports, and with it the saturation behavior that makes this hard to +// reason about: once the cap binds, the worker count stops responding to both +// core count and the multiplier. +func TestResolveWorkers(t *testing.T) { + check := func(sizing, totalEdges, edgesPerWorker, wantWorkers, wantCap int) { + t.Helper() + workers, workCap := resolveWorkers(sizing, totalEdges, edgesPerWorker) + require.Equal(t, wantWorkers, workers) + require.Equal(t, wantCap, workCap) + } + + // off stays off — the cap must never promote a disabled pool. + check(0, 1_000_000, 256, 0, 0) + check(-1, 1_000_000, 256, 0, 0) + + // sizing binds: 48 < 1_000_000/256 == 3906. + check(48, 1_000_000, 256, 48, 3906) - // workCap dominates: 2560/256=10 < round(96*0.5)=48. - require.Equal(t, 10, autoBudget(96, 2560, 0.5, 256)) + // the cap binds: 2560/256 == 10 < 48. + check(48, 2560, 256, 10, 10) - // Rounding is half away from zero: round(3*0.5)=round(1.5)=2. - require.Equal(t, 2, autoBudget(3, 1_000_000, 0.5, 256)) + // Tie: equal values, so which "bound" is reported is the only difference. + check(96, 96*256, 256, 96, 96) - // budget<2 fallback: round(1*0.5)=1 and 100/256 clamps to 1, so min==1 → the - // caller routes through allocateWorkers, which returns nil for budget<2 (legacy). - require.Equal(t, 1, autoBudget(1, 100, 0.5, 256)) + // A tiny batch clamps the cap to 1, which allocateWorkers treats as no + // fan-out rather than as disabled. + check(48, 100, 256, 1, 1) - // Divide-by-zero guard: a misconfigured minEdgesPerWorker of 0 must not panic - // and clamps to 1, so workCap==totalEdges and machineCap (48) wins here. - require.Equal(t, 48, autoBudget(96, 2560, 0.5, 0)) + // The production benchmark shape — 20k edges at the default 256 — caps at 78. + // This is why the cap, not the multiplier, governs on a large box: at 1x on + // 64 vCPU the sizing still binds, but at 2x and 4x the cap does and the + // result stops moving. Reaching 128 workers would need >= 32,768 edges. + check(64, 20_000, 256, 64, 78) + check(128, 20_000, 256, 78, 78) + check(256, 20_000, 256, 78, 78) - // Determinism: identical inputs yield identical outputs. - require.Equal(t, autoBudget(96, 2560, 0.5, 256), autoBudget(96, 2560, 0.5, 256)) + // Lowering edges-per-worker is what actually lifts the ceiling there. + check(256, 20_000, 64, 256, 312) + + // Divide-by-zero guard: a misconfigured 0 clamps to 1, so cap == totalEdges. + check(48, 2560, 0, 48, 2560) + + // Determinism. + w1, c1 := resolveWorkers(48, 2560, 256) + w2, c2 := resolveWorkers(48, 2560, 256) + require.Equal(t, w1, w2) + require.Equal(t, c1, c2) } -// runAutoBudgetBatch is runBudgetBatch's AUTO-mode sibling: it enables AUTO -// (MutationsPipelineGoroutines=-1) with the given fraction / min-edges, runs the -// batch through a fresh pipeline txn, snapshots the conflict-key set, commits, and -// restores all three config fields on return. -func runAutoBudgetBatch(t *testing.T, fraction float64, minEdges int, +// runAutoBudgetBatch is runBudgetBatch's per-CPU-sizing sibling: it sets +// parallelism to the given multiplier (what "auto" resolves to) plus the given +// edges-per-worker, runs the batch through a fresh pipeline txn, snapshots the +// conflict-key set, commits, and restores both config fields on return. +func runAutoBudgetBatch(t *testing.T, perCPU float64, minEdges int, startTs, commitTs uint64, edges []*pb.DirectedEdge) map[uint64]struct{} { t.Helper() - oldBudget := x.WorkerConfig.MutationsPipelineGoroutines - oldFraction := x.WorkerConfig.MutationsPipelineGoroutinesFraction - oldMinEdges := x.WorkerConfig.MutationsPipelineMinEdgesPerWorker - x.WorkerConfig.MutationsPipelineGoroutines = mutationsPipelineGoroutinesAuto - x.WorkerConfig.MutationsPipelineGoroutinesFraction = fraction - x.WorkerConfig.MutationsPipelineMinEdgesPerWorker = minEdges + oldPar := x.WorkerConfig.IntraMutationParallelism + oldMinEdges := x.WorkerConfig.IntraMutationEdgesPerWorker + x.WorkerConfig.IntraMutationParallelism = x.IntraMutationParallelism{PerCPU: perCPU} + x.WorkerConfig.IntraMutationEdgesPerWorker = minEdges defer func() { - x.WorkerConfig.MutationsPipelineGoroutines = oldBudget - x.WorkerConfig.MutationsPipelineGoroutinesFraction = oldFraction - x.WorkerConfig.MutationsPipelineMinEdgesPerWorker = oldMinEdges + x.WorkerConfig.IntraMutationParallelism = oldPar + x.WorkerConfig.IntraMutationEdgesPerWorker = oldMinEdges }() txn := Oracle().RegisterStartTs(startTs) @@ -727,10 +798,11 @@ func TestPipelineBudgetAutoByteIdentical(t *testing.T) { // than one worker, so the multi-worker path is genuinely exercised. alloc := allocateWorkers(map[string]int{nameAttr: nameCount, deptAttr: 10, friendAttr: 10}, 10) require.Greater(t, alloc[nameAttr], 1, "test must exercise multi-worker dominant predicate") - // Sanity: AUTO with fraction=100 / minEdges=32 derives exactly 10 regardless of - // the host's GOMAXPROCS (machineCap >= 100, workCap = 320/32 = 10). - require.Equal(t, 10, autoBudget(1, 320, 100, 32)) - require.Equal(t, 10, autoBudget(256, 320, 100, 32)) + // Sanity: per-CPU sizing at 100x with edges-per-worker=32 resolves to exactly + // 10 regardless of the host's GOMAXPROCS — the cap (320/32) binds, not the + // machine. + require.Equal(t, 10, mustResolve(x.IntraMutationParallelism{PerCPU: 100}, 1, 320, 32)) + require.Equal(t, 10, mustResolve(x.IntraMutationParallelism{PerCPU: 100}, 256, 320, 32)) schemaBytes := []byte(` name: string @index(exact) . @@ -828,9 +900,9 @@ func BenchmarkPipelineSkewedBatch(b *testing.B) { edges := buildSkewedBatch(nameAttr, deptAttr, friendAttr, 19980) // ~20,000 total run := func(b *testing.B, budget int) { - old := x.WorkerConfig.MutationsPipelineGoroutines - x.WorkerConfig.MutationsPipelineGoroutines = budget - defer func() { x.WorkerConfig.MutationsPipelineGoroutines = old }() + old := x.WorkerConfig.IntraMutationParallelism + x.WorkerConfig.IntraMutationParallelism = x.IntraMutationParallelism{Workers: budget} + defer func() { x.WorkerConfig.IntraMutationParallelism = old }() var ts uint64 = 100_000 b.ResetTimer() @@ -875,19 +947,17 @@ func BenchmarkPipelineBudgetSweep(b *testing.B) { friendAttr := x.AttrInRootNamespace("friend") edges := buildSkewedBatch(nameAttr, deptAttr, friendAttr, 19980) // ~20,000 total - // run sets the budget plus the two AUTO tunables (inert unless budget==-1) and - // restores all three on return. fraction/minEdges only matter for the auto case. - run := func(b *testing.B, budget int, fraction float64, minEdges int) { - oldBudget := x.WorkerConfig.MutationsPipelineGoroutines - oldFraction := x.WorkerConfig.MutationsPipelineGoroutinesFraction - oldMinEdges := x.WorkerConfig.MutationsPipelineMinEdgesPerWorker - x.WorkerConfig.MutationsPipelineGoroutines = budget - x.WorkerConfig.MutationsPipelineGoroutinesFraction = fraction - x.WorkerConfig.MutationsPipelineMinEdgesPerWorker = minEdges + // run sets parallelism plus the edges-per-worker cap and restores both on + // return. Unlike the old three-flag form, the cap now applies to fixed worker + // counts too, so minEdges is live in every case rather than only for auto. + run := func(b *testing.B, par x.IntraMutationParallelism, minEdges int) { + oldPar := x.WorkerConfig.IntraMutationParallelism + oldMinEdges := x.WorkerConfig.IntraMutationEdgesPerWorker + x.WorkerConfig.IntraMutationParallelism = par + x.WorkerConfig.IntraMutationEdgesPerWorker = minEdges defer func() { - x.WorkerConfig.MutationsPipelineGoroutines = oldBudget - x.WorkerConfig.MutationsPipelineGoroutinesFraction = oldFraction - x.WorkerConfig.MutationsPipelineMinEdgesPerWorker = oldMinEdges + x.WorkerConfig.IntraMutationParallelism = oldPar + x.WorkerConfig.IntraMutationEdgesPerWorker = oldMinEdges }() var ts uint64 = 200_000 @@ -909,11 +979,13 @@ func BenchmarkPipelineBudgetSweep(b *testing.B) { for _, budget := range []int{0, 8, 16, 24, 32, 48, 64, 96} { budget := budget - b.Run(fmt.Sprintf("budget=%d", budget), func(b *testing.B) { run(b, budget, 0.5, 256) }) + b.Run(fmt.Sprintf("budget=%d", budget), func(b *testing.B) { + run(b, x.IntraMutationParallelism{Workers: budget}, 256) + }) } - // AUTO uses the production default fraction (1.0); runtime.GOMAXPROCS(0) drives - // machineCap = round(GOMAXPROCS * 1.0), capped by edges/minEdgesPerWorker. + // "auto" is 1x: runtime.GOMAXPROCS(0) drives the sizing, then the + // edges-per-worker cap applies. b.Run("budget=auto", func(b *testing.B) { - run(b, mutationsPipelineGoroutinesAuto, 1.0, 256) + run(b, x.IntraMutationParallelism{PerCPU: 1.0}, 256) }) } diff --git a/posting/pipeline_revbench_test.go b/posting/pipeline_revbench_test.go index e1b5ac56f01..3ef36d1ce1b 100644 --- a/posting/pipeline_revbench_test.go +++ b/posting/pipeline_revbench_test.go @@ -55,17 +55,15 @@ func buildScalarIndexedEdges(attr string, n, distinct int) []*pb.DirectedEdge { return edges } -func runBudget(b *testing.B, edges []*pb.DirectedEdge, budget int, fraction float64, minEdges int) { - ob := x.WorkerConfig.MutationsPipelineGoroutines - of := x.WorkerConfig.MutationsPipelineGoroutinesFraction - om := x.WorkerConfig.MutationsPipelineMinEdgesPerWorker - x.WorkerConfig.MutationsPipelineGoroutines = budget - x.WorkerConfig.MutationsPipelineGoroutinesFraction = fraction - x.WorkerConfig.MutationsPipelineMinEdgesPerWorker = minEdges +func runBudget(b *testing.B, edges []*pb.DirectedEdge, par x.IntraMutationParallelism, + minEdges int) { + ob := x.WorkerConfig.IntraMutationParallelism + om := x.WorkerConfig.IntraMutationEdgesPerWorker + x.WorkerConfig.IntraMutationParallelism = par + x.WorkerConfig.IntraMutationEdgesPerWorker = minEdges defer func() { - x.WorkerConfig.MutationsPipelineGoroutines = ob - x.WorkerConfig.MutationsPipelineGoroutinesFraction = of - x.WorkerConfig.MutationsPipelineMinEdgesPerWorker = om + x.WorkerConfig.IntraMutationParallelism = ob + x.WorkerConfig.IntraMutationEdgesPerWorker = om }() var ts uint64 = 300_000 b.ResetTimer() @@ -93,9 +91,9 @@ func BenchmarkReverseDominant(b *testing.B) { for _, bud := range []int{0, 8, 32} { bud := bud - b.Run(fmt.Sprintf("budget=%d", bud), func(b *testing.B) { runBudget(b, edges, bud, 1.0, 256) }) + b.Run(fmt.Sprintf("budget=%d", bud), func(b *testing.B) { runBudget(b, edges, x.IntraMutationParallelism{Workers: bud}, 256) }) } - b.Run("budget=auto", func(b *testing.B) { runBudget(b, edges, mutationsPipelineGoroutinesAuto, 1.0, 256) }) + b.Run("budget=auto", func(b *testing.B) { runBudget(b, edges, x.IntraMutationParallelism{PerCPU: 1.0}, 256) }) } // BenchmarkReverseHighCardinality: ~20k edges on a single `[uid] @reverse` @@ -113,9 +111,9 @@ func BenchmarkReverseHighCardinality(b *testing.B) { for _, bud := range []int{0, 8, 32} { bud := bud - b.Run(fmt.Sprintf("budget=%d", bud), func(b *testing.B) { runBudget(b, edges, bud, 1.0, 256) }) + b.Run(fmt.Sprintf("budget=%d", bud), func(b *testing.B) { runBudget(b, edges, x.IntraMutationParallelism{Workers: bud}, 256) }) } - b.Run("budget=auto", func(b *testing.B) { runBudget(b, edges, mutationsPipelineGoroutinesAuto, 1.0, 256) }) + b.Run("budget=auto", func(b *testing.B) { runBudget(b, edges, x.IntraMutationParallelism{PerCPU: 1.0}, 256) }) } // BenchmarkReverseMultiPredicate: ~20k edges spread over N CONCURRENT @@ -132,9 +130,11 @@ func BenchmarkReverseHighCardinality(b *testing.B) { // the single-predicate number understates the production gain. func BenchmarkReverseMultiPredicate(b *testing.B) { const totalEdges = 19980 - // 25 predicates + budget=30 is the shipped production shape: worker/server_state.go - // defaults mutations-pipeline-goroutines=30, and allocateWorkers hands 25 - // predicates a {1:20, 2:5} grant — i.e. most predicates get a ONE-worker grant. + // 25 predicates + budget=30 was the shipped production shape before + // intra-mutation-parallelism defaulted to auto: allocateWorkers hands 25 + // predicates a {1:20, 2:5} grant — i.e. most predicates get a ONE-worker + // grant. Kept as a benchmark row because it is the cliff the default used to + // sit on, and the case auto exists to avoid. for _, nPred := range []int{1, 4, 8, 16, 25} { nPred := nPred b.Run(fmt.Sprintf("preds=%d", nPred), func(b *testing.B) { @@ -163,7 +163,7 @@ func BenchmarkReverseMultiPredicate(b *testing.B) { } for _, bud := range []int{0, 30, 32} { bud := bud - b.Run(fmt.Sprintf("budget=%d", bud), func(b *testing.B) { runBudget(b, edges, bud, 1.0, 256) }) + b.Run(fmt.Sprintf("budget=%d", bud), func(b *testing.B) { runBudget(b, edges, x.IntraMutationParallelism{Workers: bud}, 256) }) } }) } @@ -206,7 +206,7 @@ func BenchmarkReverseHighCardConcurrent(b *testing.B) { } for _, bud := range []int{0, 30} { bud := bud - b.Run(fmt.Sprintf("budget=%d", bud), func(b *testing.B) { runBudget(b, edges, bud, 1.0, 256) }) + b.Run(fmt.Sprintf("budget=%d", bud), func(b *testing.B) { runBudget(b, edges, x.IntraMutationParallelism{Workers: bud}, 256) }) } }) } @@ -230,9 +230,9 @@ func BenchmarkReverseFiftyFifty(b *testing.B) { for _, bud := range []int{0, 8, 32} { bud := bud - b.Run(fmt.Sprintf("budget=%d", bud), func(b *testing.B) { runBudget(b, edges, bud, 1.0, 256) }) + b.Run(fmt.Sprintf("budget=%d", bud), func(b *testing.B) { runBudget(b, edges, x.IntraMutationParallelism{Workers: bud}, 256) }) } - b.Run("budget=auto", func(b *testing.B) { runBudget(b, edges, mutationsPipelineGoroutinesAuto, 1.0, 256) }) + b.Run("budget=auto", func(b *testing.B) { runBudget(b, edges, x.IntraMutationParallelism{PerCPU: 1.0}, 256) }) } // BenchmarkScalarDominant: control — ~20k edges, dominant scalar `@index` @@ -247,7 +247,7 @@ func BenchmarkScalarDominant(b *testing.B) { for _, bud := range []int{0, 8, 32} { bud := bud - b.Run(fmt.Sprintf("budget=%d", bud), func(b *testing.B) { runBudget(b, edges, bud, 1.0, 256) }) + b.Run(fmt.Sprintf("budget=%d", bud), func(b *testing.B) { runBudget(b, edges, x.IntraMutationParallelism{Workers: bud}, 256) }) } - b.Run("budget=auto", func(b *testing.B) { runBudget(b, edges, mutationsPipelineGoroutinesAuto, 1.0, 256) }) + b.Run("budget=auto", func(b *testing.B) { runBudget(b, edges, x.IntraMutationParallelism{PerCPU: 1.0}, 256) }) } diff --git a/posting/pipeline_schema_matrix_test.go b/posting/pipeline_schema_matrix_test.go index c314a371af7..b7b9108d78d 100644 --- a/posting/pipeline_schema_matrix_test.go +++ b/posting/pipeline_schema_matrix_test.go @@ -117,20 +117,17 @@ func keyKinds(t *testing.T, snap map[string]string) (data, index, reverse, count // runMatrixBatch applies edges through a fresh pipeline txn at the given budget // (and auto tunables), snapshots the conflict-key set, commits, and restores the -// config. Mirrors runBudgetBatch but also sets the auto fraction/min-edges. -func runMatrixBatch(t *testing.T, budget int, fraction float64, minEdges int, +// config. Mirrors runBudgetBatch but also sets the edges-per-worker cap. +func runMatrixBatch(t *testing.T, par x.IntraMutationParallelism, minEdges int, startTs, commitTs uint64, edges []*pb.DirectedEdge) map[uint64]struct{} { t.Helper() - ob := x.WorkerConfig.MutationsPipelineGoroutines - of := x.WorkerConfig.MutationsPipelineGoroutinesFraction - om := x.WorkerConfig.MutationsPipelineMinEdgesPerWorker - x.WorkerConfig.MutationsPipelineGoroutines = budget - x.WorkerConfig.MutationsPipelineGoroutinesFraction = fraction - x.WorkerConfig.MutationsPipelineMinEdgesPerWorker = minEdges + ob := x.WorkerConfig.IntraMutationParallelism + om := x.WorkerConfig.IntraMutationEdgesPerWorker + x.WorkerConfig.IntraMutationParallelism = par + x.WorkerConfig.IntraMutationEdgesPerWorker = minEdges defer func() { - x.WorkerConfig.MutationsPipelineGoroutines = ob - x.WorkerConfig.MutationsPipelineGoroutinesFraction = of - x.WorkerConfig.MutationsPipelineMinEdgesPerWorker = om + x.WorkerConfig.IntraMutationParallelism = ob + x.WorkerConfig.IntraMutationEdgesPerWorker = om }() txn := Oracle().RegisterStartTs(startTs) @@ -148,6 +145,64 @@ func runMatrixBatch(t *testing.T, budget int, fraction float64, minEdges int, return conflicts } +// nonEmpty drops keys whose posting list is empty, so a snapshot comparison +// asserts agreement on real content rather than on leftover emptied buckets. +func nonEmpty(snap map[string]string) map[string]string { + out := make(map[string]string, len(snap)) + for k, v := range snap { + if v != "[]" { + out[k] = v + } + } + return out +} + +// runMatrixLegacy is runMatrixBatch's legacy counterpart: it applies the edges +// through a serial runMutation loop — the path draft.go takes when a mutation +// bypasses the pipeline — then snapshots the conflict-key set and commits. +// +// Edges are stable-sorted by (Attr, Entity) first, exactly as applyMutations +// does before its DivideAndRule fan-out. That matters for byte-identity: the +// sort preserves relative order within each (attr, entity) group, and the +// pipeline preserves order within a predicate, so both paths observe the same +// sequence of operations per key. +func runMatrixLegacy(t *testing.T, startTs, commitTs uint64, + edges []*pb.DirectedEdge) map[uint64]struct{} { + t.Helper() + sort.SliceStable(edges, func(i, j int) bool { + if edges[i].GetAttr() != edges[j].GetAttr() { + return edges[i].GetAttr() < edges[j].GetAttr() + } + return edges[i].GetEntity() < edges[j].GetEntity() + }) + + ctx := schema.GetWriteContext(context.Background()) + txn := Oracle().RegisterStartTs(startTs) + for _, edge := range edges { + // worker.runMutation validates and converts before delegating to the + // posting-level runMutation, which does not. Skipping this step leaves + // scalar values as raw RDF strings ("10" instead of an encoded int), so + // the baseline would write different keys and every scalar case would + // "fail" for a harness reason rather than a real divergence. + su, ok := schema.State().Get(ctx, edge.Attr) + if edge.Op != pb.DirectedEdge_DEL { + require.Truef(t, ok, "no schema for %s", edge.Attr) + } + require.NoError(t, ValidateAndConvert(edge, &su)) + require.NoError(t, runMutation(ctx, edge, txn)) + } + + txn.Lock() + conflicts := make(map[uint64]struct{}, len(txn.conflicts)) + for k := range txn.conflicts { + conflicts[k] = struct{}{} + } + txn.Unlock() + + commitPipelineTxn(t, txn, commitTs) + return conflicts +} + // matrixCases is the schema/index-type matrix (default batch size). func matrixCases() []schemaCase { return matrixCasesN(600) } @@ -324,14 +379,13 @@ func matrixCasesN(n int) []schemaCase { func TestSchemaMatrixByteIdentical(t *testing.T) { type budgetCfg struct { name string - budget int - fraction float64 + par x.IntraMutationParallelism minEdges int } budgets := []budgetCfg{ - {"fixed8", 8, 1.0, 256}, - {"fixed32", 32, 1.0, 256}, - {"auto", mutationsPipelineGoroutinesAuto, 1.0, 64}, + {"fixed8", x.IntraMutationParallelism{Workers: 8}, 256}, + {"fixed32", x.IntraMutationParallelism{Workers: 32}, 256}, + {"auto", x.IntraMutationParallelism{PerCPU: 1.0}, 64}, } var ts uint64 = 1_000_000 @@ -351,10 +405,22 @@ func TestSchemaMatrixByteIdentical(t *testing.T) { for _, bc := range budgets { sc, bc, attrs := sc, bc, attrs t.Run(sc.name+"/"+bc.name, func(t *testing.T) { - // Baseline: legacy one-goroutine-per-predicate (budget 0). + // Baseline: the REAL legacy path — a serial runMutation loop, the + // same per-edge call draft.go makes when + // intra-mutation-min-edges=0 sends a mutation around the pipeline. + // + // This used to be "the pipeline with the budget off", which was an + // in-pipeline replica of legacy semantics (locked AddDelta). Once + // the lock-free store stopped being tied to the worker grant that + // replica disappeared, and with it the only in-package proof that + // AddDeltaConcurrent agrees with AddDelta. Comparing against + // runMutation is strictly stronger: it is the alternative an + // operator can actually select, not a stand-in for it. reset(t, sc.schema) s0, c0, r0 := next() - conf0 := runMatrixBatch(t, 0, 1.0, 256, s0, c0, sc.edges(attrs)) + // Conflict keys from the legacy run are intentionally unused — see + // the comparison notes below for why they are not asserted against. + _ = runMatrixLegacy(t, s0, c0, sc.edges(attrs)) base := snapshotPredicates(t, attrs, r0) require.NotEmpty(t, base, "baseline wrote no keys for %s", sc.name) @@ -373,16 +439,52 @@ func TestSchemaMatrixByteIdentical(t *testing.T) { require.Positive(t, count, "expected count keys (%s)", sc.name) } - // Candidate: the budget under test. + // The pipeline with no fan-out: the conflict-key reference. Conflict + // keys are compared pipeline-to-pipeline, NOT against legacy — see + // the subset assertion below for why. + reset(t, sc.schema) + sOff, cOff, _ := next() + confOff := runMatrixBatch(t, x.IntraMutationParallelism{}, 256, + sOff, cOff, sc.edges(attrs)) + + // Candidate: the parallelism setting under test. reset(t, sc.schema) s1, c1, r1 := next() - confN := runMatrixBatch(t, bc.budget, bc.fraction, bc.minEdges, s1, c1, sc.edges(attrs)) + confN := runMatrixBatch(t, bc.par, bc.minEdges, s1, c1, sc.edges(attrs)) cand := snapshotPredicates(t, attrs, r1) - require.Equal(t, base, cand, - "committed state must be byte-identical (%s, %s)", sc.name, bc.name) - require.Equal(t, conf0, confN, - "conflict-key set must be identical (%s, %s)", sc.name, bc.name) + // The strong check: every posting list the pipeline commits is + // byte-identical to what the legacy per-edge path commits, for every + // schema and every tokenizer. + // + // Empty lists are excluded because legacy leaves behind emptied + // count buckets that the pipeline never creates — for + // `[uid] @reverse @count` legacy writes 726 keys to the pipeline's + // 607, and all 119 extras are empty with zero differing values. That + // predates this work (measured identically on the pre-change tree) + // and the pipeline's output is the cleaner of the two, so comparing + // non-empty state asserts the real invariant without pinning a + // difference this change did not introduce. + require.Equal(t, nonEmpty(base), nonEmpty(cand), + "committed state must be byte-identical to legacy (%s, %s)", + sc.name, bc.name) + + // Conflict keys are compared pipeline-to-pipeline: parallel must + // agree with serial. This is the guarantee this branch's work has to + // preserve, and it is what the production abort-count parity rests + // on. + // + // They are deliberately NOT compared against legacy. The pipeline + // diverges from legacy in both directions there and has since before + // this work: `uid @reverse` gives 1200 pipeline keys to legacy's 600, + // and `@lang @index(fulltext)` gives 4800 to legacy's 3000 while + // dropping 600 that legacy emits. Both were measured on the + // pre-change tree. Worth its own investigation — see pipeline-todo.md + // — but pinning it here would assert a behavior this change neither + // caused nor fixed. + require.Equal(t, confOff, confN, + "conflict-key set must not depend on parallelism (%s, %s)", + sc.name, bc.name) }) } } diff --git a/worker/draft.go b/worker/draft.go index 776840aef5d..d8955b36e01 100644 --- a/worker/draft.go +++ b/worker/draft.go @@ -526,7 +526,7 @@ func (n *node) applyMutations(ctx context.Context, proposal *pb.Proposal) (rerr // Discard the posting lists from cache to release memory at the end. defer txn.Update() - if t := x.WorkerConfig.MutationsPipelineThreshold; t > 0 && len(m.Edges) >= t { + if t := x.WorkerConfig.IntraMutationMinEdges; t > 0 && len(m.Edges) >= t { mp := posting.NewMutationPipeline(txn) return mp.Process(ctx, m.Edges) } diff --git a/worker/feature_flags_test.go b/worker/feature_flags_test.go new file mode 100644 index 00000000000..64ac854124e --- /dev/null +++ b/worker/feature_flags_test.go @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: © 2017-2025 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package worker + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/dgraph-io/dgraph/v25/x" + "github.com/dgraph-io/ristretto/v2/z" +) + +// TestFeatureFlagsIntraMutationKeys drives the exact superflag path +// dgraph/cmd/alpha/run.go uses, so a key present in one place and misspelled in +// the other fails here rather than at an operator's startup. +// +// The superflag layer only validates that a key EXISTS in the defaults string — +// it cannot check the value, since intra-mutation-parallelism is off|auto|N|Fx +// rather than a plain int. A bad value would otherwise resolve to the zero value +// and silently disable fan-out for the life of the process. +func TestFeatureFlagsIntraMutationKeys(t *testing.T) { + // No overrides: the shipped defaults must parse and be what we documented. + sf := z.NewSuperFlag("").MergeAndCheckDefault(FeatureFlagsDefaults) + + require.Equal(t, int64(1), sf.GetInt64("intra-mutation-min-edges")) + require.Equal(t, int64(256), sf.GetInt64("intra-mutation-edges-per-worker")) + + par, err := x.ParseIntraMutationParallelism(sf.GetString("intra-mutation-parallelism")) + require.NoError(t, err) + require.Equal(t, x.IntraMutationParallelism{PerCPU: 1.0}, par, + "the shipped default must be auto, i.e. one worker per CPU") + + // Each documented spelling must survive a real superflag override. + for _, tc := range []struct { + in string + want x.IntraMutationParallelism + }{ + {"off", x.IntraMutationParallelism{}}, + {"auto", x.IntraMutationParallelism{PerCPU: 1.0}}, + {"30", x.IntraMutationParallelism{Workers: 30}}, + {"2x", x.IntraMutationParallelism{PerCPU: 2}}, + {"1.5x", x.IntraMutationParallelism{PerCPU: 1.5}}, + } { + sf := z.NewSuperFlag("intra-mutation-parallelism=" + tc.in). + MergeAndCheckDefault(FeatureFlagsDefaults) + got, err := x.ParseIntraMutationParallelism(sf.GetString("intra-mutation-parallelism")) + require.NoErrorf(t, err, "value %q", tc.in) + require.Equalf(t, tc.want, got, "value %q", tc.in) + } + + // A bad value must surface an error rather than resolving to off. + sf = z.NewSuperFlag("intra-mutation-parallelism=1.5"). + MergeAndCheckDefault(FeatureFlagsDefaults) + _, err = x.ParseIntraMutationParallelism(sf.GetString("intra-mutation-parallelism")) + require.Error(t, err) + + // The other two keys still override as plain ints. + sf = z.NewSuperFlag("intra-mutation-min-edges=0; intra-mutation-edges-per-worker=64"). + MergeAndCheckDefault(FeatureFlagsDefaults) + require.Equal(t, int64(0), sf.GetInt64("intra-mutation-min-edges")) + require.Equal(t, int64(64), sf.GetInt64("intra-mutation-edges-per-worker")) +} + +// TestWorkerOptionsStringShowsIntraMutation guards the gap that cost real time +// during live debugging: WorkerOptions.String() hand-formats its fields, so a +// new field is invisible in the startup "x.WorkerConfig: %+v" line unless it is +// added there by hand. +func TestWorkerOptionsStringShowsIntraMutation(t *testing.T) { + w := x.WorkerOptions{ + IntraMutationMinEdges: 1, + IntraMutationParallelism: x.IntraMutationParallelism{PerCPU: 1.5}, + IntraMutationEdgesPerWorker: 256, + } + s := w.String() + require.Contains(t, s, "IntraMutationMinEdges:1") + require.Contains(t, s, "IntraMutationParallelism:1.5x") + require.Contains(t, s, "IntraMutationEdgesPerWorker:256") +} diff --git a/worker/server_state.go b/worker/server_state.go index 10b334acd41..6d60d769654 100644 --- a/worker/server_state.go +++ b/worker/server_state.go @@ -43,9 +43,8 @@ const ( `lambda-url=;` CacheDefaults = `size-mb=4096; percentage=40,40,20; remove-on-update=false` FeatureFlagsDefaults = `normalize-compatibility-mode=; enable-detailed-metrics=false; ` + - `log-slow-query-threshold=0; mutations-pipeline-threshold=1; ` + - `mutations-pipeline-goroutines=30; mutations-pipeline-goroutines-fraction=1.0; ` + - `mutations-pipeline-min-edges-per-worker=256` + `log-slow-query-threshold=0; intra-mutation-min-edges=1; ` + + `intra-mutation-parallelism=auto; intra-mutation-edges-per-worker=256` ) // ServerState holds the state of the Dgraph server. diff --git a/x/config.go b/x/config.go index 5061db6fe8e..0c79a00848e 100644 --- a/x/config.go +++ b/x/config.go @@ -8,7 +8,10 @@ package x import ( "crypto/tls" "fmt" + "math" "net" + "strconv" + "strings" "time" "github.com/golang-jwt/jwt/v5" @@ -142,51 +145,113 @@ type WorkerOptions struct { HardSync bool // Audit contains the audit flags that enables the audit. Audit bool - // MutationsPipelineThreshold gates the per-predicate mutation pipeline - // in applyMutations. A mutation runs through the pipeline only when - // MutationsPipelineThreshold > 0 and len(m.Edges) >= the threshold; - // otherwise it falls back to the legacy serial path. Set to 0 to - // disable the pipeline entirely. Set to 1 (default) to always use it. - // The pipeline pays goroutine spin-up cost per predicate, so tiny - // mutations are slower on it; bulk multi-predicate mutations are - // faster — set to a value above the per-mutation edge count where the - // crossover happens for your workload (~100 in benchmarks here) if - // you want only large mutations to take the pipeline path. - // Plumbed via the "feature-flags" superflag as - // "mutations-pipeline-threshold". - MutationsPipelineThreshold int - // MutationsPipelineGoroutines is the global budget of *intra-predicate* worker - // goroutines distributed across the predicates in a single mutation batch, - // proportionally to each predicate's edge count. A hot/dominant predicate is - // granted most of the budget so its data-write and index passes parallelize; - // tiny predicates get one worker each. The Go zero value 0 (and any value < 2, - // except the -1 sentinel) disables intra-predicate parallelism entirely: every - // predicate runs on a single goroutine exactly as the legacy path did, which - // keeps unit tests (which never set this) byte-for-byte identical to before. - // N>0 is an absolute, fixed budget of N. The special value -1 selects AUTO - // mode, where the budget is derived at runtime from GOMAXPROCS and the batch - // size (see posting.autoBudget) using MutationsPipelineGoroutinesFraction and - // MutationsPipelineMinEdgesPerWorker. Plumbed via the "feature-flags" superflag - // as "mutations-pipeline-goroutines"; production default is 30. - MutationsPipelineGoroutines int - // MutationsPipelineGoroutinesFraction is the fraction of GOMAXPROCS that AUTO - // mode (MutationsPipelineGoroutines == -1) may use for one mutation batch. The - // apply phase is serial across transactions — a single apply effectively owns - // the box — so the default is 1.0 (use all cores). A budget-sweep benchmark on - // an 8-core box showed fraction=0.5 leaves ~25% throughput on the table vs - // fraction>=1.0, with the peak near 2-3x cores; raise above 1.0 to oversubscribe - // or lower it to leave headroom for Badger compaction/queries/GC under heavy - // concurrent read load. Inert unless MutationsPipelineGoroutines == -1. Plumbed - // via the "feature-flags" superflag as "mutations-pipeline-goroutines-fraction"; - // default 1.0. - MutationsPipelineGoroutinesFraction float64 - // MutationsPipelineMinEdgesPerWorker is the minimum number of edges per - // intra-predicate worker that AUTO mode targets when deriving the budget - // (workCap = totalEdges / MutationsPipelineMinEdgesPerWorker). It mirrors - // x.DivideAndRule's 256-edge rule. Inert unless MutationsPipelineGoroutines == - // -1. Plumbed via the "feature-flags" superflag as - // "mutations-pipeline-min-edges-per-worker"; default 256. - MutationsPipelineMinEdgesPerWorker int + // IntraMutationMinEdges gates the parallel mutation path in applyMutations: a + // mutation takes it only when IntraMutationMinEdges > 0 and len(m.Edges) >= + // it. 0 disables that path entirely and is the single kill switch for this + // whole mechanism; 1 (the default) always takes it. A value above 1 keeps + // small interactive mutations on the legacy path, which is worth doing only + // if per-predicate goroutine spin-up measurably hurts them (crossover ~100 + // edges in benchmarks here). + // Superflag key: "intra-mutation-min-edges". + IntraMutationMinEdges int + // IntraMutationParallelism sizes the pool of worker goroutines used inside a + // single mutation. The pool is shared across the predicates that mutation + // touches and apportioned by edge count, so a hot predicate is granted most + // of it and tiny predicates get one worker each. Always further capped by + // IntraMutationEdgesPerWorker. + // + // Note this parallelizes ONE mutation. Transactions still apply serially in + // processApplyCh, so raising it does not relieve a many-concurrent-writers + // bottleneck. + // Superflag key: "intra-mutation-parallelism"; default "auto". + IntraMutationParallelism IntraMutationParallelism + // IntraMutationEdgesPerWorker is the minimum number of edges a worker should + // have to justify existing: the pool is capped at totalEdges divided by this, + // so small mutations do not over-spawn. Mirrors x.DivideAndRule's 256-edge + // rule. + // + // This cap applies to every sizing mode, not only the per-CPU one. On a large + // box it is frequently the *binding* constraint — at the default 256 a + // 20k-edge mutation caps at 78 workers no matter how many cores exist — so it, + // rather than the multiplier, is the knob that matters there. + // Superflag key: "intra-mutation-edges-per-worker"; default 256. + IntraMutationEdgesPerWorker int +} + +// IntraMutationParallelism is the parsed form of the "intra-mutation-parallelism" +// feature flag. Exactly one field is meaningful: PerCPU when it is > 0, Workers +// otherwise. The zero value means off — every predicate runs on one goroutine. +// +// Two notations share one flag because they are the same axis — how many workers +// — expressed differently: an absolute count when the operator knows the +// workload, and a multiple of the CPUs Go may use when they want it to track the +// machine. Splitting them across two flags (as the earlier +// "mutations-pipeline-goroutines" / "-goroutines-fraction" pair did) meant one +// flag's value silently decided whether the other was read at all. +type IntraMutationParallelism struct { + // Workers is an absolute worker count. Ignored when PerCPU > 0. + Workers int + // PerCPU, when > 0, sizes the pool as PerCPU * GOMAXPROCS instead. + PerCPU float64 +} + +// Sizing returns the requested worker count, before IntraMutationEdgesPerWorker +// is applied. 0 means off. gomaxprocs is a parameter rather than a runtime read +// so the policy stays a pure, testable function. +func (p IntraMutationParallelism) Sizing(gomaxprocs int) int { + if p.PerCPU > 0 { + n := int(math.Round(float64(gomaxprocs) * p.PerCPU)) + if n < 1 { + n = 1 + } + return n + } + if p.Workers < 0 { + return 0 + } + return p.Workers +} + +// String renders the value back into the flag notation that produced it. Note +// "auto" round-trips as "1x", which is what it means. +func (p IntraMutationParallelism) String() string { + switch { + case p.PerCPU > 0: + return strconv.FormatFloat(p.PerCPU, 'g', -1, 64) + "x" + case p.Workers > 0: + return strconv.Itoa(p.Workers) + default: + return "off" + } +} + +// ParseIntraMutationParallelism parses the "intra-mutation-parallelism" flag: +// "off", "auto" (an alias for "1x"), an absolute worker count like "48", or a +// per-CPU multiplier like "1.5x". +func ParseIntraMutationParallelism(s string) (IntraMutationParallelism, error) { + var zero IntraMutationParallelism + s = strings.TrimSpace(s) + switch strings.ToLower(s) { + case "", "off": + return zero, nil + case "auto": + return IntraMutationParallelism{PerCPU: 1.0}, nil + } + if mult, ok := strings.CutSuffix(strings.ToLower(s), "x"); ok { + f, err := strconv.ParseFloat(strings.TrimSpace(mult), 64) + if err != nil || math.IsNaN(f) || math.IsInf(f, 0) || f <= 0 { + return zero, fmt.Errorf("intra-mutation-parallelism: invalid per-CPU "+ + "multiplier %q; want a positive number followed by 'x', e.g. \"1.5x\"", s) + } + return IntraMutationParallelism{PerCPU: f}, nil + } + n, err := strconv.Atoi(s) + if err != nil || n < 0 { + return zero, fmt.Errorf("intra-mutation-parallelism: invalid value %q; want "+ + "\"off\", \"auto\", a worker count like \"48\", or a per-CPU multiplier "+ + "like \"1.5x\"", s) + } + return IntraMutationParallelism{Workers: n}, nil } // WorkerConfig stores the global instance of the worker package's options. @@ -207,9 +272,11 @@ func (w WorkerOptions) String() string { return fmt.Sprintf("{TmpDir:%s ExportPath:%s MyAddr:%s ZeroAddr:%v Raft:%v "+ "WhiteListedIPRanges:%v StrictMutations:%v AclEnabled:%v AclJwtAlg:%v "+ "AclPublicKey:**** AbortOlderThan:%v ProposedGroupId:%d StartTime:%v "+ - "Security:**** EncryptionKey:**** LogDQLRequest:%d SlowQueryThreshold:%v HardSync:%v Audit:%v}", + "Security:**** EncryptionKey:**** LogDQLRequest:%d SlowQueryThreshold:%v HardSync:%v Audit:%v "+ + "IntraMutationMinEdges:%d IntraMutationParallelism:%v IntraMutationEdgesPerWorker:%d}", w.TmpDir, w.ExportPath, w.MyAddr, w.ZeroAddr, w.Raft, w.WhiteListedIPRanges, w.StrictMutations, w.AclEnabled, w.AclJwtAlg, w.AbortOlderThan, w.ProposedGroupId, w.StartTime, - w.LogDQLRequest, w.SlowQueryLogThreshold, w.HardSync, w.Audit) + w.LogDQLRequest, w.SlowQueryLogThreshold, w.HardSync, w.Audit, + w.IntraMutationMinEdges, w.IntraMutationParallelism, w.IntraMutationEdgesPerWorker) } diff --git a/x/config_test.go b/x/config_test.go new file mode 100644 index 00000000000..d6ece4e62ef --- /dev/null +++ b/x/config_test.go @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: © 2017-2025 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package x + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestParseIntraMutationParallelism covers the whole accepted grammar and, just +// as importantly, the rejections. The superflag layer stores this key as an +// opaque string, so nothing upstream validates it: a typo that parsed to the +// zero value would silently disable intra-predicate fan-out for the life of the +// process, with no error and no visible symptom other than throughput that never +// responds to the flag. +func TestParseIntraMutationParallelism(t *testing.T) { + valid := []struct { + in string + want IntraMutationParallelism + }{ + // off, including the empty value a bare "key=" in the superflag yields. + {"off", IntraMutationParallelism{}}, + {"", IntraMutationParallelism{}}, + {"OFF", IntraMutationParallelism{}}, + {" off ", IntraMutationParallelism{}}, + + // auto is exactly 1x — not a separate mode. + {"auto", IntraMutationParallelism{PerCPU: 1.0}}, + {"AUTO", IntraMutationParallelism{PerCPU: 1.0}}, + + // Absolute worker counts. 0 is a legal spelling of off. + {"0", IntraMutationParallelism{}}, + {"1", IntraMutationParallelism{Workers: 1}}, + {"30", IntraMutationParallelism{Workers: 30}}, + {"1024", IntraMutationParallelism{Workers: 1024}}, + + // Per-CPU multipliers, including fractional and oversubscribed. + {"1x", IntraMutationParallelism{PerCPU: 1}}, + {"2X", IntraMutationParallelism{PerCPU: 2}}, + {"1.5x", IntraMutationParallelism{PerCPU: 1.5}}, + {"0.5x", IntraMutationParallelism{PerCPU: 0.5}}, + {"0.25x", IntraMutationParallelism{PerCPU: 0.25}}, + {" 3x ", IntraMutationParallelism{PerCPU: 3}}, + } + for _, tc := range valid { + got, err := ParseIntraMutationParallelism(tc.in) + require.NoErrorf(t, err, "input %q", tc.in) + require.Equalf(t, tc.want, got, "input %q", tc.in) + } + + invalid := []string{ + "x", // multiplier with no number + "-1", // the removed AUTO sentinel must not silently mean anything + "-1x", // negative multiplier + "0x", // zero multiplier would mean "off", which has a spelling + "abc", // not a number + "1.5", // a float without the x is ambiguous: workers or multiplier? + "1.5xx", // trailing junk + "30 40", // two values + "NaNx", // NaN must not slip through the f <= 0 comparison + "Infx", // nor must +Inf + "1e400x", // overflows to +Inf on parse + "auto1x", // near-miss on a keyword + "9999999999999999999999", // overflows an int + } + for _, in := range invalid { + _, err := ParseIntraMutationParallelism(in) + require.Errorf(t, err, "input %q must be rejected", in) + } +} + +// TestIntraMutationParallelismString checks the flag notation round-trips, since +// String() is what the startup WorkerConfig log line prints — an operator has to +// be able to match it against what they passed. +func TestIntraMutationParallelismString(t *testing.T) { + for _, in := range []string{"off", "48", "1x", "1.5x", "0.25x"} { + p, err := ParseIntraMutationParallelism(in) + require.NoError(t, err) + require.Equal(t, in, p.String()) + } + + // "auto" is the one input that does not round-trip verbatim: it renders as + // the "1x" it means, which is the more informative of the two. + p, err := ParseIntraMutationParallelism("auto") + require.NoError(t, err) + require.Equal(t, "1x", p.String()) + + // The zero value must read as "off" rather than as an empty string. + require.Equal(t, "off", IntraMutationParallelism{}.String()) +} diff --git a/x/metrics.go b/x/metrics.go index 232b1ebb264..2ab274c6fb4 100644 --- a/x/metrics.go +++ b/x/metrics.go @@ -52,6 +52,14 @@ var ( // NumEdges is the total number of edges created so far. NumEdges = ostats.Int64("num_edges_total", "Total number of edges created", ostats.UnitDimensionless) + // IntraMutationNoFanout counts mutations whose resolved worker grant gave + // every predicate a single worker, i.e. intra-predicate parallelism was + // silently inactive for that batch. This happens whenever the resolved + // worker count falls below roughly twice the predicate count, which the + // operator cannot otherwise observe. + IntraMutationNoFanout = ostats.Int64("intra_mutation_no_fanout_total", + "Total mutations that ran with no intra-predicate fan-out", + ostats.UnitDimensionless) // NumBackups is the number of backups requested NumBackups = ostats.Int64("num_backups_total", "Total number of backups requested", ostats.UnitDimensionless) @@ -239,6 +247,13 @@ var ( Aggregation: view.Count(), TagKeys: allTagKeys, }, + { + Name: IntraMutationNoFanout.Name(), + Measure: IntraMutationNoFanout, + Description: IntraMutationNoFanout.Description(), + Aggregation: view.Count(), + TagKeys: allTagKeys, + }, { Name: NumBackups.Name(), Measure: NumBackups, From 376e79226075d917c99cff42977d471f62a2b148 Mon Sep 17 00:00:00 2001 From: Ryan Hendrickson Date: Mon, 27 Jul 2026 02:55:03 -0400 Subject: [PATCH 36/45] docs: record the flag simplification and what measuring it turned up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pipeline-todo.md §1 is closed, with the reasoning that mattered: the fix for the inert flags was removing the conditional rather than the flags, and edges-per-worker stayed tunable because it is unmeasured and is what actually binds on a large box. Two findings the trace produced that were not in the original write-up: a worker count of 1 was silently identical to 0 while 2 gave every predicate a lock-free grant, and the AUTO fraction saturated -- making the help text's "raise it to oversubscribe to 2-3x cores" advice unfollowable at the batch size it was written for. Both are now pinned by tests. New §1c records three pipeline-vs-legacy divergences surfaced by rebasing the byte-identity matrix onto the real legacy path. All three reproduce on the pre-change tree, so they are unrelated to this work. The @lang case deserves its own investigation: the pipeline drops 600 conflict keys the legacy path emits, which is the direction that risks a lost update, and the pipeline has been the production default all along. ec2-validation-results.md keeps its tables verbatim -- they describe runs that actually happened under the old keys -- with a translation table for reproducing them. Co-Authored-By: Claude Opus 5 --- ec2-validation-results.md | 23 ++++++- pipeline-todo.md | 137 +++++++++++++++++++++++++++----------- 2 files changed, 120 insertions(+), 40 deletions(-) diff --git a/ec2-validation-results.md b/ec2-validation-results.md index b90fb01e9cd..523e4992c82 100644 --- a/ec2-validation-results.md +++ b/ec2-validation-results.md @@ -21,6 +21,24 @@ Binaries built on the instance from the same tree and verified to differ by disassembly: stock `ProcessReverse` references only `(*Txn).AddDelta`; patched references both `AddDeltaConcurrent` and the legacy `AddDelta`. +> **Flag names below are the pre-simplification ones.** Every run recorded here +> predates the `intra-mutation-*` rename, and the tables are left verbatim so +> they still describe what was actually executed. To reproduce: +> +> | as run | today | +> |---|---| +> | `mutations-pipeline-threshold=1` | `intra-mutation-min-edges=1` | +> | `mutations-pipeline-goroutines=30` | `intra-mutation-parallelism=30` | +> | `mutations-pipeline-goroutines=0` | `intra-mutation-parallelism=off` | +> | `mutations-pipeline-goroutines=-1` (auto) | `intra-mutation-parallelism=auto` | +> | `mutations-pipeline-goroutines-fraction=F` | `intra-mutation-parallelism=Fx` | +> | `mutations-pipeline-min-edges-per-worker=N` | `intra-mutation-edges-per-worker=N` | +> +> One behavioral caveat when re-running: the edges-per-worker cap now applies to +> fixed counts too, not only to auto. At the 20k-edge batch used here the cap is +> 78 and the fixed budget was 30, so the cap never binds and these numbers stand +> unchanged. + ## Results ### budget = 30 (the shipped production default), 2 paired runs each @@ -60,8 +78,9 @@ budget" free win hypothesised earlier is **not** supported. **+18.8% over stock at the production default.** Note the budget stops mattering once the lock-free store is reachable at any grant — auto and 30 now agree to -within noise. Tuning `mutations-pipeline-goroutines` is no longer load-bearing -for this workload, which removes a whole class of misconfiguration. +within noise. Tuning the worker count is no longer load-bearing for this +workload, which removes a whole class of misconfiguration — and is part of why +the four tuning flags were later collapsed into `intra-mutation-parallelism`. ### Adding conflict-key batching (`5b69ac2b4`) diff --git a/pipeline-todo.md b/pipeline-todo.md index c23433d0554..9e3c7e59731 100644 --- a/pipeline-todo.md +++ b/pipeline-todo.md @@ -46,50 +46,111 @@ throughput, not by effort. --- -## 1. Simplify the flags +## 1. Simplify the flags — DONE -The pipeline now has **four** superflag knobs, and a fifth is proposed. That is -too many for something whose correct setting is not obvious to an operator. +Four knobs became **three**, all live at every setting, renamed to say what they +scope: `intra-mutation-*`. -Current (`worker/server_state.go:45-48`, `dgraph/cmd/alpha/run.go:267-310`): - -| flag | default | what it really controls | +| was | now | default | |---|---|---| -| `mutations-pipeline-threshold` | 1 | use pipeline vs legacy path | -| `mutations-pipeline-goroutines` | 30 | L2 intra-predicate budget (`-1` = auto) | -| `mutations-pipeline-goroutines-fraction` | 1.0 | AUTO only: fraction of GOMAXPROCS | -| `mutations-pipeline-min-edges-per-worker` | 256 | AUTO only: edges/worker cap | -| *(proposed)* `mutations-pipeline-min-targets-per-worker` | 256 | reverse split threshold | - -Problems: -- Two of the four are **inert unless `goroutines == -1`**, which is not the - default — so most operators are tuning flags that do nothing. -- The fixed default of **30 sits exactly on the `allocateWorkers` cliff**: with - 25 predicates it yields a `{1:20, 2:5}` grant, i.e. 20 of 25 predicates get a - one-worker grant. A budget below ~2x the predicate count effectively disables - intra-predicate parallelism, silently. -- `reverseParallelMinTargets` is currently a compile-time `const` - (`posting/index.go`), so it cannot be tuned at all. - -Proposed: -- Make **AUTO the default** (`goroutines=-1`) so the budget scales with the box - instead of a magic 30 that is wrong on both an 8-core and a 148-core machine. -- Collapse `goroutines-fraction` + `min-edges-per-worker` into the auto policy, - or document them as advanced/diagnostic only. -- Express the reverse threshold as **targets-per-worker**, consistent with the - edges-per-worker vocabulary already used, rather than an absolute constant. -- Emit a `glog.V(2)` line or a metric when a batch's grant is all-1s, so the - silent-disable case is observable. - -### 1a. `WorkerOptions.String()` hides the pipeline config -`x/config.go:206-210` hand-formats the struct and stops at `Audit:%v}` — none of -the `MutationsPipeline*` fields are printed. The startup log line -`glog.Infof("x.WorkerConfig: %+v")` therefore does **not** show the effective -pipeline settings. This cost real time during live debugging: the log could not -confirm whether the budget was 30 or 0. Add the fields to `String()`. +| `mutations-pipeline-threshold` | `intra-mutation-min-edges` | 1 | +| `mutations-pipeline-goroutines` + `-goroutines-fraction` | `intra-mutation-parallelism` (`off|auto|N|Fx`) | `auto` | +| `mutations-pipeline-min-edges-per-worker` | `intra-mutation-edges-per-worker` | 256 | + +What the fix actually was, beyond the count: + +- **The two "inert unless `goroutines == -1`" flags were not deleted — the + conditional was.** `edges-per-worker` now caps every sizing mode, so "do not + spin N workers for a handful of edges" holds for a fixed count as much as a + derived one. That is what removed the inertness; deleting the flags would have + hidden the problem instead. +- **`goroutines` and `-fraction` were the same axis in two notations**, not two + settings. One was a worker count, the other a multiple of GOMAXPROCS — and the + first doubled as the tag selecting which of the two was read. They merged into + one value taking `N` or `Fx`. The `-1` sentinel is gone; `auto` == `1x`. +- **`auto` is now the default**, so the pool tracks the box rather than a magic + 30 that was wrong on both an 8-core and a 148-core machine. +- **`edges-per-worker` deliberately stayed a flag.** 256 was adopted by analogy + to `DivideAndRule` and never measured, and it is the *binding* term on a large + box: at 256 a 20k-edge mutation caps at **78 workers however many cores + exist**. Freezing an unmeasured number that governs large-box behavior was the + wrong thing to make permanent. Revisit after the 148-core sweep in §3. +- **The proposed fifth flag was not added.** `reverseParallelMinTargets` stays a + `const` until §3 calibrates it — shipping an uncalibrated knob works against + this cleanup. + +### Two things the trace turned up that were not in the original write-up + +1. **`goroutines=1` was silently identical to `goroutines=0`** + (`allocateWorkers` returned nil for `budget < 2`), while **`goroutines=2` + with 25 predicates gave every predicate a lock-free 1-worker grant** via the + `P >= budget` short-circuit. The cheapest good setting looked inert. + Confirmed against the code: at 25 equal predicates, budget 2 → `{1:25}`, + 30 → `{1:20 2:5}`, 50 (2×P) → `{2:25}`, 64 → `{2:11 3:14}`, 78 → `{3:22 4:3}`. +2. **`-fraction` saturated.** Because the result was `min(machineCap, workCap)`, + raising fraction stopped changing anything above ≈1.22 on the 64-vCPU / + 20k-edge shape. The help text's advice to "raise above 1.0 to oversubscribe, + peak near 2-3x cores" was therefore **unfollowable at that batch size** — + reaching 128 needs ≥32,768 edges. That advice came from the 8-core box, where + `workCap` was nowhere near binding. Corollary: on the 148-core box earmarked + for the §3 sweep, the old auto would derive **78, not 148**. + + Both are pinned by `TestResolveWorkers` / `TestParallelismSizing` so they + cannot silently regress. + +### 1a. `WorkerOptions.String()` hid the pipeline config — DONE +`x/config.go` hand-formats the struct and stopped at `Audit:%v}`, so the startup +`glog.Infof("x.WorkerConfig: %+v")` never showed the effective settings. This +cost real time during live debugging — the log could not confirm whether the +budget was 30 or 0. The three `IntraMutation*` fields are now printed. + +### 1b. Observability — DONE +- `glog.V(2)` line after `allocateWorkers` reporting resolved workers, **which + term bound** (`boundBy=parallelism` vs `edges-per-worker` — the least visible + part, given the saturation above), predicate count, edges, and the grant + histogram. +- `intra_mutation_no_fanout_total` counts batches whose grant was all-1s, i.e. + the silent-degradation case. Explicitly *not* incremented when parallelism is + `off`, which is a deliberate operator choice rather than a surprise. --- +## 1c. NEW FINDING — the pipeline and the legacy path disagree, and always have + +Surfaced while decoupling the lock-free store from the worker grant. That change +deleted the in-pipeline `workers == 0` branches, which were an in-pipeline +replica of legacy semantics and the baseline `TestSchemaMatrixByteIdentical` +compared against. Replacing that baseline with the **real** legacy path (a serial +`runMutation` loop — what `intra-mutation-min-edges=0` actually selects) made the +matrix compare pipeline-vs-legacy for the first time, and it does not match. + +**All three divergences were measured on the pre-change tree** (`308ea6b35`, in a +clean worktree, pipeline at the old disabled budget) and are therefore unrelated +to the flag work: + +| schema | measurement | direction | +|---|---|---| +| `uid @reverse` | legacy 600 conflict keys, pipeline **1200** | pipeline superset (safe: extra aborts) | +| `string @index(fulltext) @lang` | legacy 3000, pipeline 4800, **600 dropped that legacy emits** | pipeline drops keys — **the unsafe direction** | +| `[uid] @reverse @count` | legacy 726 state keys, pipeline 607 | 119 legacy-only keys, **all empty**, 0 differing values | + +Committed state otherwise matches exactly across every schema and tokenizer in +the matrix; the count difference is emptied buckets legacy leaves behind and the +pipeline never creates, so the pipeline's output is the cleaner one. + +**The `@lang` case deserves a look on its own.** Dropping a conflict key that the +legacy path emits is the direction that risks a lost update, and the pipeline has +been the production default (`threshold=1`) all along. It is out of scope for the +flag simplification — it is neither caused nor fixed by it — but it is the most +significant thing this exercise turned up. Whether the 600 dropped keys are +genuinely redundant (superseded by the 2400 extra) or a real gap needs the +conflict-key derivation for `@lang` postings traced against `GetConflictKey`. + +The matrix test now asserts what is actually true and says why in-line: non-empty +committed state identical to legacy, and conflict keys identical **across +parallelism settings within the pipeline** — the guarantee this branch's work +must preserve, and what the production abort-count parity rests on. + ## 2. Performance follow-ups (ranked by measured gain / risk) 1. ~~**Apply the same gate fix to `ProcessList` and `ProcessSingle`.**~~ From bef548358abaf07e096e2cbbbbba476885f46289 Mon Sep 17 00:00:00 2001 From: Ryan Hendrickson Date: Mon, 27 Jul 2026 09:53:13 -0400 Subject: [PATCH 37/45] docs: correct the abort-storm attribution, add the controls that settle it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client-thread sweep concluded that "Zero's conflict arbitration over @upsert re-asserts" is the constraint. Profiling says otherwise: Zero uses 9% of ONE core on a 64-vCPU box, its goroutine count is flat, and exactly one sample out of 1,695 lands inside Oracle.commit. It decides those ~1,860 aborts essentially for free. The real mechanism is wasted apply work. A doomed transaction still takes a startTs, still gets proposed through Raft, and still runs a full pre-write through the serial apply loop before being discarded — ~2,100 applies to commit ~283 files at 16 threads, so ~85% of apply work is thrown away while the loop sits 68-88% occupied. The distinction is actionable: a faster Zero buys nothing, fewer conflicts buys ~3x. New §5a adds the control the original sweep lacked. Falling throughput with rising threads cannot by itself separate "aborts are expensive" from "concurrency is expensive", so the same sweep was run on a corpus with no upserts and therefore no aborts. It goes the other way — 92,982 -> 134,173 triples/s from 2 to 64 threads, against a 3.2x fall on the abort-heavy corpus. Concurrency is not what hurts. Lining those up gives what a client-side lock is worth: the apply loop caps this box at ~130-143k/s, abort-heavy at 2 threads already reaches it, and abort-heavy at 16 threads sits 3.2x below. A lock should recover roughly 3x at 16 threads — but it reaches that ceiling rather than beating it, since it removes abort waste without lifting the serial apply loop. Its edge over simply lowering thread count is that it serialises only conflicting writes. Also records that Zero publishes no transaction metrics at all, and notes "triples" in the sweep heading is the verb, not RDF triples. Co-Authored-By: Claude Opus 5 --- pipeline-todo.md | 96 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 88 insertions(+), 8 deletions(-) diff --git a/pipeline-todo.md b/pipeline-todo.md index 9e3c7e59731..c01762a32e4 100644 --- a/pipeline-todo.md +++ b/pipeline-todo.md @@ -434,9 +434,10 @@ Two things this reveals that the synthetic corpus hid: 1. **Absolute throughput is far lower (1.5 MB/s vs 13 MB/s)** because this workload is *abort*-dominated: only ~283 of 532 files commit, the rest give up - after 6 retries. The ceiling here is Zero's conflict arbitration, not the - apply path — which is exactly the contention the production client's - Caffeine lock layer exists to avoid. + after 6 retries — exactly the contention the production client's Caffeine + lock layer exists to avoid. (An earlier revision of this bullet attributed the + ceiling to "Zero's conflict arbitration". That is wrong; Zero is idle. See + §5a.) 2. The relative gain (+17.6%) is **half** the synthetic figure (+36.8%), which is the expected dilution from a realistic predicate mix. Quote the 17.6% number for production expectations, not the 36.8%. @@ -458,11 +459,22 @@ letting them abort): | 8 | 2.31 | **2.67** | 1.16x | 65,918 | **76,008** | ~368 / 532 | ~1,470 | | 16 | 1.48 | **1.74** | 1.18x | 44,699 | 52,357 | ~283 / 532 | ~1,860 | -**Throughput more than triples as client threads DROP from 16 to 2** (1.48 -> -5.30 MB/s on stock; 44.7k -> 143k nquads/s). Aborts fall 1,860 -> 380 and -committed files rise 283 -> 502 of 532. On this workload the Alpha is not the -constraint at all — Zero's conflict arbitration over `@upsert` re-asserts is, -and every extra client thread buys more aborted work than committed work. +**Throughput more than triples as client threads DROP from 16 to 2** — "triples" +here is the verb, 3.58x, not RDF triples (1.48 -> 5.30 MB/s on stock; 44.7k -> +143k nquads/s). Aborts fall 1,860 -> 380 and committed files rise 283 -> 502 of +532. Every extra client thread buys more aborted work than committed work. + +**The mechanism is wasted apply work, not a slow Zero.** An earlier revision of +this section named "Zero's conflict arbitration over `@upsert` re-asserts" as the +constraint. Profiling says otherwise — see §5a. What actually happens: an +aborted transaction still takes a startTs, still gets proposed through Raft, and +still runs a full pre-write through the *serial* apply loop before being +discarded. At 16 threads the Alpha performs ~2,100 mutation applies to commit +~283 files, so **~85% of apply work is thrown away**, and the apply loop is +68-88% occupied doing it. + +The distinction is actionable: making Zero faster buys nothing, while causing +fewer conflicts buys ~3x. Two consequences worth acting on: @@ -477,3 +489,71 @@ Two consequences worth acting on: Abort counts track each other across every thread count (380/379, 945/931, 1465/1479, 1855/1857) — conflict semantics unchanged, as intended. + +## 5a. The control: it is aborts, not concurrency — and Zero is idle + +The sweep above shows throughput falling as threads rise, but on its own it +cannot separate "aborts are expensive" from "concurrency is expensive". Two +measurements settle it. + +### Same sweep on a corpus with zero aborts + +Reverse-heavy corpus (25 `[uid] @reverse`, no `@upsert`, so no conflicts), same +box, same binary, 180 s runs, `intra-mutation-parallelism=auto`: + +| threads | triples/s | vs 2 threads | +|---:|---:|---:| +| 2 | 92,982 | — | +| 8 | 120,581 | +30% | +| 16 | 130,136 | +40% | +| 64 | 134,173 | +44% | + +**Opposite direction to the abort-heavy sweep.** Conflict-free throughput *rises* +monotonically with client threads; abort-heavy throughput *falls* 3.2x over the +same range. Concurrency is not what hurts — aborts are. High thread counts are +actively good when there is nothing to conflict on. + +### Zero is not the bottleneck + +Profiled during a 16-thread production-replica run (`ec2-harness/zeroprof.sh`, +`analyze_zero.py`): + +- **5.39 s of CPU over 60 s — 9% of ONE core** on a 64-vCPU box. +- Goroutine count flat at 42-47; every park site sits at exactly one goroutine + per dump, i.e. idle server loops. +- **One sample inside `Oracle.commit` out of 1,695.** + +Zero decides those ~1,860 aborts essentially for free. Of the little CPU it does +use, ~25% is the conflict-key wire format (`consumeStringSliceValidateUTF8` +18.9%, `strconv.ParseUint` 5.0%, 70% of the latter under `hasConflict`) because +`TxnContext.Keys` is `repeated string` base-36 that Zero parses back to uint64. +Worth knowing for a far larger cluster; worth nothing at this scale. + +Also noted: **Zero publishes no transaction metrics at all** — its Prometheus +endpoint carries only badger, memory and raft-leader gauges. No commits, aborts, +lease or subscriber counters. The subscriber-overflow drop stays invisible. + +### What this says the lock cache is worth + +Lining the numbers up, all on the same box: + +| configuration | throughput | | +|---|---:|---| +| apply-loop ceiling (conflict-free, 16-64 thr) | 130-134k/s | the wall | +| abort-heavy @ 2 threads | 143k nquads/s | already at the wall | +| abort-heavy @ 16 threads | 44.7k nquads/s | 3.2x below it | + +A client-side transaction lock should recover the 16-thread case to roughly the +130-143k range — about **3x** — without touching thread count. Two caveats: + +1. **It does not beat the 2-thread result, it reaches it.** The lock removes + abort waste; it does not lift the serial apply loop. Even conflict-free the + curve saturates hard (2->8 is +30%, 8->16 is +8%, 16->64 is +3%), so past + ~16 threads more concurrency buys nothing either way. +2. **Its advantage over simply dropping threads** is that it serialises only + *conflicting* writes, leaving independent work fully parallel — so the benefit + scales with the independent fraction of the workload, and the operator does + not have to guess a thread count. + +Reproduce: `ec2-harness/threadctl.sh` (conflict-free sweep) and +`ec2-harness/zeroprof.sh` + `analyze_zero.py` (Zero profile). From 51c828bb9bb90164e3656dbfb938ec81599d2d94 Mon Sep 17 00:00:00 2001 From: Ryan Hendrickson Date: Sat, 8 Aug 2026 03:30:10 -0400 Subject: [PATCH 38/45] docs: move internal planning docs to the local plans repo pipeline-todo.md, ec2-validation-results.md and conflict-key-batching-plan.md are working notes for this series, not upstream documentation. The authoritative copies now live in the dgraph-plans repo (01-intra-txn-apply/mirror/), verified in sync before this removal. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011uGZrf7FExkfwT5cWowrb5 --- conflict-key-batching-plan.md | 133 -------- ec2-validation-results.md | 166 ---------- pipeline-todo.md | 559 ---------------------------------- 3 files changed, 858 deletions(-) delete mode 100644 conflict-key-batching-plan.md delete mode 100644 ec2-validation-results.md delete mode 100644 pipeline-todo.md diff --git a/conflict-key-batching-plan.md b/conflict-key-batching-plan.md deleted file mode 100644 index 4bb53976f38..00000000000 --- a/conflict-key-batching-plan.md +++ /dev/null @@ -1,133 +0,0 @@ -# Conflict-key batching — plan, decisions, and measured results - -Durable record of the third change in the intra-transaction apply-path series. -Commit `5b69ac2b4`, on `rahst12/hybrid-pipeline-reverse-parallel`. - ---- - -## Where this sits - -| commit | change | budget=30 MB/s | -|---|---|---| -| (stock) | branch base | 9.63 | -| `4a02d8eeb` | parallel reverse write, lock-free store at any grant | 10.58 (+9.9%) | -| `612ae8cd5` | lock-free forward write at a one-worker grant | 11.44 (+18.8%) | -| **`5b69ac2b4`** | **batch conflict-key emission** | **12.45 (+29.3%)** | - -All measured on `i4i.16xlarge` (32 physical cores / 64 vCPU, 495 GB, local NVMe), -25 `[uid] @reverse` predicates, 20k triples/txn, 64 writer threads, 300 s runs, -`badger_write_bytes_user` — the same metric used for the Preview1 vs -Preview1-Hybrid comparison in discussion #9727. - ---- - -## The problem - -After the first two commits removed the `txn.cache.Lock()` convoy, `Txn.conflicts` -(`posting/oracle.go:52`, a `map[uint64]struct{}` under `Txn`'s embedded -`sync.Mutex`) was the only remaining mutex contention: **25% of pipeline -goroutines blocked on it, 46% running inside `addConflictKeyWithUid`**. - -The decisive evidence was the line-level CPU profile — the map insert was never -the problem: - -| line | cum | | -|---|---|---| -| `txn.Lock()` | **8.75s** | 49% | -| deferred `Unlock` | **5.33s** | 31% | -| map insert | 2.71s | 15% | -| `Fingerprint64` | 0.27s | 1.5%, needlessly inside the lock | - -**~80% acquire/release, ~15% real work.** The cost was the *number of -acquisitions* — one per key, from every worker in every predicate. - -## Decision: batch, don't shard - -Sharding `Txn.conflicts` was the obvious move and was **rejected on the -arithmetic**. Batching removes the 14.08s of acquire/release (79% of the -function). What is left for sharding to attack is the 2.71s of inserts — about -**1% of total CPU** — in exchange for changing `Txn.conflicts`' type, mechanically -editing nine test sites, rewriting `FillContext`, and either raising `NumShards` -from 30 (shared with `posting/lists.go` deltas and indexMap) or maintaining a -second striped container. - -**Revisit trigger:** shard only if `flushConflicts` self-CPU exceeds 3% or -mutex-BLOCKED exceeds 5%. Post-change both are **0.0%**, so this stays closed. - -### Why it is safe - -- The embedded mutex on `Txn` protects `conflicts` and **nothing else** - (`lastUpdate` is guarded by `oracle`'s `SafeMutex`). -- The only reader is `FillContext`, which runs `x.Unique` — sorting and deduping — - so `ctx.Keys` was **already** order-independent. Zero's `hasConflict` is a pure - existential over the set. No consumer is order-, index-, or length-sensitive. -- Writers join (L2 `wg.Wait()`, then L1 `eg.Wait()`) strictly before - `FillContext` runs via `worker/mutation.go`. - -## Three traps, and how each was handled - -1. **Eager key expansion.** Buffers hold `uint64`, never `key []byte`. Every call - site reuses one scratch key buffer across iterations, so deferring the - fingerprint to flush time would hash the *last* uid's bytes for every entry — - wrong conflict keys, no panic, invisible to `-race`. -2. **Defer ordering in `InsertTokenizerIndexes`.** It takes `txn.Lock()` while - holding `txn.cache.Lock()`. The flush defer is registered **above** - `cache.Lock()` so LIFO runs it after `cache.Unlock()`. Registering it below - compiles, passes every byte-identical test, and silently preserves the nested - lock this change exists to remove. -3. **`break`, not `return`, on worker error.** Workers now have post-loop work - (storing their buffer). The pre-existing error path never rolled back emitted - keys, so dropping them would *change* the set — and a subset risks a lost - update while a superset only costs a spurious abort. - -## Results - -**Throughput** (2 runs per arm, non-overlapping): - -| | triples/s | MB/s | -|---|---|---| -| before (`612ae8cd5`) | 113,916 / 112,086 | 11.40 / 11.47 | -| after (`5b69ac2b4`) | 123,399 / 124,065 | 12.41 / 12.49 | -| | **+9.5%** | **+8.8%** | - -**Goroutine profile — the primary acceptance criterion:** - -| | before | after | -|---|---|---| -| pipeline goroutine observations | 518 | **216** (−58%) | -| in the conflict-key path | **68.5%** | **0.0%** | -| blocked on the conflict mutex | **23.0%** | **0.0%** | -| CPU utilisation | 7.9% | 9.6% | - -The conflict-key path disappears from the sampled profile entirely. - -**Correctness signals:** `batches_fail = 0` on all four runs, both binaries — -no change in client-visible aborts. (`dgraph_txn_aborts_total` is not exposed by -this build, so the zero-failure parity plus the golden test carries that check.) - -## Verification - -- `TestFillContextKeysGolden` (new) — pins the exact 205-key `ctx.Keys` sent to - Zero against a golden captured at `612ae8cd5`, and asserts the keys are - `startTs`-independent. **This closed a real gap:** the six existing - byte-identical tests only compare budget 0 vs N, so a regression changing the - serial and parallel paths *the same way* would pass all of them, and nothing - asserted `FillContext`'s output at all. -- Six existing byte-identical tests (conflict-key set equality across budgets). -- Full `posting` package green under `-race` (354 s). -- `TestPipelineReverseListCountMultiPred` is an intermittent pre-existing flake: - 0/6 failures on both this commit and `612ae8cd5`. - -## What comes next - -The new top blocker, named by the post-change profile: **`Deltas.AddToDeltas`** -at 57.6% of a much smaller blocked population — shard contention on the delta -map, where `NumShards = 30` (`types/sharded_map.go:18`) is thin for 64 vCPU. -Raising it, or improving `getShardIndex` (which also heap-allocates on `string` -keys — ~13% of batch allocations), is the cheap next step. - -**The standing constraint:** CPU is still only **9.6% of 64 vCPU**. -`processApplyCh` applies one Raft entry at a time, so a single Alpha cannot -saturate a large box no matter how much intra-transaction contention is removed. -Further material gains require cross-transaction concurrency in the apply loop — -a much larger change than any of these three. diff --git a/ec2-validation-results.md b/ec2-validation-results.md deleted file mode 100644 index 523e4992c82..00000000000 --- a/ec2-validation-results.md +++ /dev/null @@ -1,166 +0,0 @@ -# EC2 validation — parallel reverse write (32 physical cores) - -Validation of the `ProcessReverse` parallel-write change against a real Alpha -under sustained multi-threaded ingest, on hardware large enough to exercise the -parallelism (the prior laptop measurements had only 4 physical cores). - -## Setup - -| | | -|---|---| -| instance | `i4i.16xlarge` — 64 vCPU / **32 physical cores**, 495 GB RAM, local NVMe | -| CPU | Intel Xeon Platinum 8375C @ 2.90 GHz | -| OS / Go | RHEL 9.8, go1.26.3 | -| schema | 25 × `hcN: [uid] @reverse .` | -| batch | 20,000 triples/txn spanning **all 25 predicates**, 800 distinct reverse targets each | -| corpus | 600 distinct batches (12M unique triples), each thread owns a disjoint block | -| load | 64 threads, `commitNow=true`, 300 s per run | -| metric | `badger_write_bytes_user` delta / elapsed (the production "Badger Write Throughput") | - -Binaries built on the instance from the same tree and verified to differ by -disassembly: stock `ProcessReverse` references only `(*Txn).AddDelta`; patched -references both `AddDeltaConcurrent` and the legacy `AddDelta`. - -> **Flag names below are the pre-simplification ones.** Every run recorded here -> predates the `intra-mutation-*` rename, and the tables are left verbatim so -> they still describe what was actually executed. To reproduce: -> -> | as run | today | -> |---|---| -> | `mutations-pipeline-threshold=1` | `intra-mutation-min-edges=1` | -> | `mutations-pipeline-goroutines=30` | `intra-mutation-parallelism=30` | -> | `mutations-pipeline-goroutines=0` | `intra-mutation-parallelism=off` | -> | `mutations-pipeline-goroutines=-1` (auto) | `intra-mutation-parallelism=auto` | -> | `mutations-pipeline-goroutines-fraction=F` | `intra-mutation-parallelism=Fx` | -> | `mutations-pipeline-min-edges-per-worker=N` | `intra-mutation-edges-per-worker=N` | -> -> One behavioral caveat when re-running: the edges-per-worker cap now applies to -> fixed counts too, not only to auto. At the 20k-edge batch used here the cap is -> 78 and the fixed budget was 30, so the cap never binds and these numbers stand -> unchanged. - -## Results - -### budget = 30 (the shipped production default), 2 paired runs each - -| build | triples/s | MB/s | -|---|---|---| -| stock | 97,030 / 96,833 | 9.64 / 9.61 | -| patched | 105,272 / 106,313 | 10.58 / 10.57 | -| **delta** | **+9.1%** | **+9.9%** | - -Spread within each arm is ≤1%, and the arms do not overlap. - -### budget = -1 (auto; resolves to 64 on this box) - -| build | triples/s | MB/s | -|---|---|---| -| stock | 97,457 | 9.68 | -| patched | 113,081 | 11.29 | -| **delta** | **+16.0%** | **+16.6%** | - -**Best config vs production default** (patched+auto vs stock+30): **+16.7% -triples/s, +17.2% MB/s**. - -Note: auto does **not** help stock (97,457 vs 96,932 = +0.5%). It only pays once -the reverse write can actually consume the extra workers. The "just raise the -budget" free win hypothesised earlier is **not** supported. - -### Adding the forward-write gate fix (`612ae8cd5`) - -`ProcessList`/`ProcessSingle` had the identical gate coupling. Fixing it too: - -| build | budget=30 | auto | -|---|---|---| -| stock | 9.63 | 9.68 | -| + reverse (`4a02d8eeb`) | 10.58 | 11.29 | -| **+ forward (`612ae8cd5`)** | **11.41 / 11.47** | **11.45 / 11.31** | - -**+18.8% over stock at the production default.** Note the budget stops mattering -once the lock-free store is reachable at any grant — auto and 30 now agree to -within noise. Tuning the worker count is no longer load-bearing for this -workload, which removes a whole class of misconfiguration — and is part of why -the four tuning flags were later collapsed into `intra-mutation-parallelism`. - -### Adding conflict-key batching (`5b69ac2b4`) - -| build | budget=30 MB/s | triples/s | -|---|---|---| -| stock | 9.63 | — | -| + reverse (`4a02d8eeb`) | 10.58 | — | -| + forward (`612ae8cd5`) | 11.44 | 113,001 | -| **+ conflict batching (`5b69ac2b4`)** | **12.45** | **123,732** | - -**+29.3% cumulative over stock.** The conflict-key path vanishes from the -goroutine profile (68.5% → 0.0% of pipeline goroutines; blocked 23.0% → 0.0%), -and total pipeline goroutine observations fall 518 → 216. New top blocker: -`Deltas.AddToDeltas` (57.6%) — shard contention on the delta map, `NumShards = 30`. -See `conflict-key-batching-plan.md`. - -## Where the time goes - -Goroutine dumps (100 samples/run, `?debug=2`), bucketed by pipeline pass and -blocking site, budget=30: - -| | stock | + reverse | + forward | -|---|---|---|---| -| parked pipeline goroutines | 701 | 566 | 643 | -| in `ProcessReverse` | **61.2%** | 35.7–39.9% | 58.8% | -| in `ProcessList/Single` | 34.2% | **56.2–60.1%** | 37.6% | -| blocked on global `cache.Lock` | **86.4%** | 41.4–52.5% | **0% (absent)** | -| blocked on `txn.Lock` (`addConflictKeyWithUid`) | **0.4%** | 30.7–31.4% | **71.2%** | -| on sharded `AddDeltaConcurrent` | 0.7% | 3.2–10.0% | 6.7% | -| **runnable** (doing work, not blocked) | ~13% | ~17% | **57.9%** | - -The global cache lock is **eliminated** from the blocking profile. The -conflict-key lock goes 0.4% → 31% → **71.2%**: a textbook bottleneck migration, -where each fix exposes the next. Goroutines actually running rise from ~13% to -**58%**. - -Two conclusions: - -1. **The reverse-write convoy is real and is removed.** Stock parks 61% of - pipeline goroutines in `ProcessReverse`, 86% of them on one global mutex — - this reproduces the production goroutine dump that motivated the work. After - the change reverse drops to ~37% and is no longer the top site. -2. **The bottleneck moves twice.** `ProcessList/Single` (which still gates its - lock-free store on `workers > 1`) becomes #1 at ~58%, and - `addConflictKeyWithUid` goes from 0.4% → **31%**. - -### Core-count sensitivity — why laptop numbers misled - -`addConflictKeyWithUid` measured **2.7%** on a 4-physical-core laptop and -**31%** here. Contention on a single mutex scales with contender count; small -boxes cannot see it. Any ranking of this work must be done on real hardware. - -### CPU utilization - -Mean CPU across runs: **6–8% of 64 vCPU** (~4–5 cores busy), patched slightly -higher than stock. The box is nowhere near CPU-bound. This is the architectural -ceiling described in `CLAUDE.md`: `processApplyCh` applies one Raft entry at a -time, so a single Alpha cannot use 64 cores regardless of intra-transaction -parallelism. Top CPU consumers (pprof, patched): `sync/atomic.(*Int32).Add` -7.2%, `runtime.memmove` 5.7%, `worker.(*groupi).Tablet` 6.3% cum, -`x.(*SafeMutex).RUnlock` 3.0%. - -## Honest caveats - -- Synthetic schema is **100% `[uid] @reverse`** with no indexes or counts, which - overstates the reverse share versus a real mixed schema. The real-workload - gain will be smaller in proportion to the `@reverse` fraction. -- 300 s runs, not the 30-minute production cycles. -- 32 physical cores, not 148. -- Each thread re-writes its own block of batches, so later writes hit existing - posting lists — realistic for upserts, not for pure first-time ingest. - -## Runs that were discarded (and why) - -- **First A/B (laptop):** a stale Alpha held `:8080`; the "patched" run profiled - the *stock* process. Detected because the patched binary's `AddDelta` is at - `index.go:684` but stacks showed `608`. Harness now asserts the serving pid. -- **First EC2 matrix:** the load generator strided through batch files - (`i += threads`), letting threads drift onto the same file and write identical - `(src,tgt)` pairs concurrently → 1.47M transaction aborts. Fixed by giving each - thread a disjoint contiguous block. -- **One `stock_b30` run at 54,324 triples/s** was an outlier (61 aborts); two - clean re-runs gave 97,030 and 96,833. The headline numbers use the re-runs. diff --git a/pipeline-todo.md b/pipeline-todo.md deleted file mode 100644 index c01762a32e4..00000000000 --- a/pipeline-todo.md +++ /dev/null @@ -1,559 +0,0 @@ -# Mutation-pipeline TODO - -## STATUS: intra-transaction apply-path work is COMPLETE - -Six commits, each measured on an `i4i.16xlarge` (32 physical cores / 64 vCPU), -25 `[uid] @reverse` predicates, 20k triples/txn, 64 threads, 300 s, -`badger_write_bytes_user`: - -| commit | change | MB/s | -|---|---|---| -| — | branch base | 9.63 | -| `4a02d8eeb` | parallel reverse write; lock-free store at any grant | 10.58 | -| `612ae8cd5` | lock-free forward write at a one-worker grant | 11.44 | -| `5b69ac2b4` | batch conflict-key emission into one lock acquisition | 12.45 | -| `016a03a0a` | snapshot uid lease once per mutation | 12.53 (flat) | -| `04944f125` | size `LockedShardedMap` per machine; drop hash alloc | **13.17** | - -**+36.8% cumulative.** Blocked pipeline goroutines fell from ~86% (stock, on the -global cache lock) to **10.5%**; runnable rose to **75.6%**. - -### The rule that governs any further work here - -CPU utilisation is **~10% of 64 vCPU** and barely moved across all six commits, -because `processApplyCh` applies one Raft entry at a time. So **a change that -only reduces CPU or allocations will not move throughput** — `016a03a0a` removed -~3.5% of CPU for exactly zero gain. Rank candidates by share of **blocked** -goroutines (from `/debug/pprof/goroutine?debug=2`), not CPU. Use a CPU profile to -*explain* a blocker once found, not to pick one. - -Bottleneck migration observed, each fix exposing the next — which is why the -order mattered: global `cache.Lock` 86% → `txn.conflicts` 71% → -`Deltas.AddToDeltas` 58% → nothing above ~10%. - -**Everything in §2b/§3 below that is CPU-or-allocation-only was skipped on -purpose.** The next real lever is cross-transaction concurrency in -`processApplyCh`, not more intra-transaction work. - -(The same summary is in the local, gitignored `CLAUDE.md` so it auto-loads.) - ---- - - -Working list for the intra-predicate pipeline work on -`rahst12/hybrid-pipeline-reverse-parallel`. Ordered by what actually blocks -throughput, not by effort. - ---- - -## 1. Simplify the flags — DONE - -Four knobs became **three**, all live at every setting, renamed to say what they -scope: `intra-mutation-*`. - -| was | now | default | -|---|---|---| -| `mutations-pipeline-threshold` | `intra-mutation-min-edges` | 1 | -| `mutations-pipeline-goroutines` + `-goroutines-fraction` | `intra-mutation-parallelism` (`off|auto|N|Fx`) | `auto` | -| `mutations-pipeline-min-edges-per-worker` | `intra-mutation-edges-per-worker` | 256 | - -What the fix actually was, beyond the count: - -- **The two "inert unless `goroutines == -1`" flags were not deleted — the - conditional was.** `edges-per-worker` now caps every sizing mode, so "do not - spin N workers for a handful of edges" holds for a fixed count as much as a - derived one. That is what removed the inertness; deleting the flags would have - hidden the problem instead. -- **`goroutines` and `-fraction` were the same axis in two notations**, not two - settings. One was a worker count, the other a multiple of GOMAXPROCS — and the - first doubled as the tag selecting which of the two was read. They merged into - one value taking `N` or `Fx`. The `-1` sentinel is gone; `auto` == `1x`. -- **`auto` is now the default**, so the pool tracks the box rather than a magic - 30 that was wrong on both an 8-core and a 148-core machine. -- **`edges-per-worker` deliberately stayed a flag.** 256 was adopted by analogy - to `DivideAndRule` and never measured, and it is the *binding* term on a large - box: at 256 a 20k-edge mutation caps at **78 workers however many cores - exist**. Freezing an unmeasured number that governs large-box behavior was the - wrong thing to make permanent. Revisit after the 148-core sweep in §3. -- **The proposed fifth flag was not added.** `reverseParallelMinTargets` stays a - `const` until §3 calibrates it — shipping an uncalibrated knob works against - this cleanup. - -### Two things the trace turned up that were not in the original write-up - -1. **`goroutines=1` was silently identical to `goroutines=0`** - (`allocateWorkers` returned nil for `budget < 2`), while **`goroutines=2` - with 25 predicates gave every predicate a lock-free 1-worker grant** via the - `P >= budget` short-circuit. The cheapest good setting looked inert. - Confirmed against the code: at 25 equal predicates, budget 2 → `{1:25}`, - 30 → `{1:20 2:5}`, 50 (2×P) → `{2:25}`, 64 → `{2:11 3:14}`, 78 → `{3:22 4:3}`. -2. **`-fraction` saturated.** Because the result was `min(machineCap, workCap)`, - raising fraction stopped changing anything above ≈1.22 on the 64-vCPU / - 20k-edge shape. The help text's advice to "raise above 1.0 to oversubscribe, - peak near 2-3x cores" was therefore **unfollowable at that batch size** — - reaching 128 needs ≥32,768 edges. That advice came from the 8-core box, where - `workCap` was nowhere near binding. Corollary: on the 148-core box earmarked - for the §3 sweep, the old auto would derive **78, not 148**. - - Both are pinned by `TestResolveWorkers` / `TestParallelismSizing` so they - cannot silently regress. - -### 1a. `WorkerOptions.String()` hid the pipeline config — DONE -`x/config.go` hand-formats the struct and stopped at `Audit:%v}`, so the startup -`glog.Infof("x.WorkerConfig: %+v")` never showed the effective settings. This -cost real time during live debugging — the log could not confirm whether the -budget was 30 or 0. The three `IntraMutation*` fields are now printed. - -### 1b. Observability — DONE -- `glog.V(2)` line after `allocateWorkers` reporting resolved workers, **which - term bound** (`boundBy=parallelism` vs `edges-per-worker` — the least visible - part, given the saturation above), predicate count, edges, and the grant - histogram. -- `intra_mutation_no_fanout_total` counts batches whose grant was all-1s, i.e. - the silent-degradation case. Explicitly *not* incremented when parallelism is - `off`, which is a deliberate operator choice rather than a surprise. - ---- - -## 1c. NEW FINDING — the pipeline and the legacy path disagree, and always have - -Surfaced while decoupling the lock-free store from the worker grant. That change -deleted the in-pipeline `workers == 0` branches, which were an in-pipeline -replica of legacy semantics and the baseline `TestSchemaMatrixByteIdentical` -compared against. Replacing that baseline with the **real** legacy path (a serial -`runMutation` loop — what `intra-mutation-min-edges=0` actually selects) made the -matrix compare pipeline-vs-legacy for the first time, and it does not match. - -**All three divergences were measured on the pre-change tree** (`308ea6b35`, in a -clean worktree, pipeline at the old disabled budget) and are therefore unrelated -to the flag work: - -| schema | measurement | direction | -|---|---|---| -| `uid @reverse` | legacy 600 conflict keys, pipeline **1200** | pipeline superset (safe: extra aborts) | -| `string @index(fulltext) @lang` | legacy 3000, pipeline 4800, **600 dropped that legacy emits** | pipeline drops keys — **the unsafe direction** | -| `[uid] @reverse @count` | legacy 726 state keys, pipeline 607 | 119 legacy-only keys, **all empty**, 0 differing values | - -Committed state otherwise matches exactly across every schema and tokenizer in -the matrix; the count difference is emptied buckets legacy leaves behind and the -pipeline never creates, so the pipeline's output is the cleaner one. - -**The `@lang` case deserves a look on its own.** Dropping a conflict key that the -legacy path emits is the direction that risks a lost update, and the pipeline has -been the production default (`threshold=1`) all along. It is out of scope for the -flag simplification — it is neither caused nor fixed by it — but it is the most -significant thing this exercise turned up. Whether the 600 dropped keys are -genuinely redundant (superseded by the 2400 extra) or a real gap needs the -conflict-key derivation for `@lang` postings traced against `GetConflictKey`. - -The matrix test now asserts what is actually true and says why in-line: non-empty -committed state identical to legacy, and conflict keys identical **across -parallelism settings within the pipeline** — the guarantee this branch's work -must preserve, and what the production abort-count parity rests on. - -## 2. Performance follow-ups (ranked by measured gain / risk) - -1. ~~**Apply the same gate fix to `ProcessList` and `ProcessSingle`.**~~ - **DONE — `612ae8cd5`.** Took the production default from 10.58 → **11.44 MB/s** - (+18.8% cumulative over stock) and removed the global `cache.Lock` from the - blocking profile entirely. - `ProcessReverse` now uses the lock-free store whenever the budget is enabled, - not only when `workers > 1`. The forward-write paths still tie those two - decisions together, so a one-worker grant sends them back to the global - `txn.cache.Lock()`. Measured (perf review): reverse gate alone 1.24x, all - three gates 1.84x. Two thirds of the available gain is here. -2. ~~**Shard `txn.conflicts`**~~ **DONE differently — `5b69ac2b4` batched it - instead of sharding.** A line-level profile showed ~80% of the cost was - lock acquire/release and only ~15% the map insert, so batching to one - acquisition per `Process*` captured the win; sharding would have chased a - ~1% residual. Result: 11.44 → **12.45 MB/s (+8.8%)**, conflict path - 68.5% → **0.0%** of pipeline goroutines. Full write-up in - `conflict-key-batching-plan.md`. Sharding stays closed unless - `flushConflicts` self-CPU >3% or mutex-BLOCKED >5%. - - Original entry kept for context: - **Shard `txn.conflicts`** (`posting/index.go` `addConflictKeyWithUid`). One - `map[uint64]struct{}` under one global `txn.Lock()`, hit once per edge by - every worker. Microbenchmark ceiling: stubbing it out took the parallel path - from 36.7 ms to 19.1 ms. Touches the conflict-detection contract that feeds - `FillContext` -> Zero, so route through the raft expert; assert the emitted - `ctx.Keys` slice is byte-identical before/after. - - **RE-CONFIRMED at real core count — this is a genuine #2.** Measured on an - `i4i.16xlarge` (32 physical cores / 64 vCPU), 25 `[uid] @reverse` predicates, - 20k triples/txn, 64 writer threads, after the reverse fix: - `addConflictKeyWithUid` holds **30.7%** of parked pipeline goroutines - (up from **0.4%** on stock, because the reverse fix removes the cache-lock - convoy that was masking it). - - **After `612ae8cd5` (forward gate) it is 71.2%** — now unambiguously the #1 - bottleneck, with the global cache lock gone from the profile entirely. - Progression across the three builds: **0.4% → 31% → 71.2%**. - - Note the core-count sensitivity — it is why laptop numbers must not be used - to rank this work: on a 4-physical-core laptop the same measurement showed - only **2.7%** and I wrongly demoted this item. Contention on a single mutex - scales with the number of contenders; small boxes cannot see it. -3. **`LockedShardedMap.getShardIndex`** (`types/locked_sharded_map.go:39`) does - `farm.Fingerprint64([]byte(k))`, which heap-allocates on every Get/Set — - ~12.9% of all allocations in a 20k-edge batch. `maphash.String` is 0-alloc - and ~3x faster. Near-free win, also helps the index pass. -4. **`SortAndDedupPostings`** (`posting/oracle.go:75`) uses `sort.Slice`: 2 - allocs + reflection per call, ~28k calls per 20k-edge batch. - `slices.SortStableFunc` is ~6x faster at n=3 *and* makes the existing - "last wins" dedup deterministic instead of arbitrary. -5. **`concStore` fast paths** — skip the prior-delta probe when the txn has no - earlier-proposal deltas; avoid the copy when there is no prior delta. Needs - an audit that no earlier pass in the same `Process` call can write a reverse - key (star-delete in particular). -6. **Skew-aware partitioning** — workers currently take contiguous ranges of the - target snapshot. Speedup is capped at `1/p_max` where `p_max` is the largest - target's share of postings; a batch can have 8,000 targets and still have one - target holding half the postings. A chunked atomic counter self-balances at - ~zero cost. - ---- - -## 2b. Validated on real hardware - -See `ec2-validation-results.md`. On an `i4i.16xlarge` (32 physical cores), 25 -`[uid] @reverse` predicates, 20k triples/txn, 64 threads, measured by -`badger_write_bytes_user`: - -- **+9.9% MB/s at the shipped default** (`goroutines=30`), reproducible to ≤1%. -- **+16.6% MB/s at `goroutines=-1` (auto)**. -- Raising the budget alone on **stock** gains nothing (+0.5%) — auto only pays - once the reverse write can consume the workers. The "free win from a bigger - budget" idea is dead. -- CPU stays at **6–8% of 64 vCPU**. A single Alpha cannot saturate a big box - because `processApplyCh` is serial across Raft entries — worth remembering - before attributing any future shortfall to intra-transaction parallelism. - -## 2c. Beyond the posting pipeline — where the CPU actually goes now - -After the three apply-path commits, the post-change profile is no longer -dominated by `posting/`. Measured on the 32-core box: - -| | cum | note | -|---|---|---| -| `sync/atomic.(*Int32).Add` | 11–12% | 94% of it is `RWMutex.RLock`/`RUnlock` traffic | -| `worker.(*groupi).Tablet` | 6.7–8.9% | membership lookup, 83% from `proposeAndWait`; ~62% of its own cost is `SafeMutex.RLock`/`RUnlock` | -| `runtime.mallocgc` | ~10% | allocation pressure | -| ~~`MaxLeaseId` / `verifyUid`~~ | ~~3.5%~~ | **fixed in `016a03a0a`** — now absent | - -- **DONE `016a03a0a`** — `ExtractBlankUIDs` called `MaxLeaseId` (a global RWMutex - read for one uint64) per subject AND per object, ~40k acquisitions per 20k-nquad - mutation. Snapshotted once per call. Removed ~3.5% CPU; **throughput unchanged**, - because at ~9% CPU on 64 vCPU the Alpha is not CPU-bound. -- **NEXT — `worker.(*groupi).Tablet`.** A read-mostly membership map behind - `x.SafeMutex`, hit once per proposal. Candidate for an `atomic.Value` snapshot - or a per-request cache. Largest single remaining item. -- **`NumShards = 30`** (`types/sharded_map.go:18`) — thin for 64 vCPU, and - `Deltas.AddToDeltas` is the top blocker inside the pipeline now. - -## 3. Good to do, not needed now - -- **Calibrate `reverseParallelMinTargets`.** 256 is an assumption by analogy to - `min-edges-per-worker`, not a measurement. Measured behavior at 5 targets - (serial, correct) and 600/1250/8000 (parallel, 1.15-1.87x), but nothing - between 5 and 600 — the real crossover is unknown. Sweep on the 148-core box. -- **Cover the uncovered branches** in the parallel reverse path: the - `sync.Once`/`firstErr` error propagation, and the `k > len(uidList)` clamp. - Neither is reachable today, but the error path abandons the rest of a worker's - sub-range, which differs from the serial path's behavior — worth a deliberate - decision rather than an accident. -- **Guard the `handleDeleteAll` happens-before with a test**, not just the - comment now in `ProcessReverse`. The lock-free write's single-writer invariant - depends on `close(pred.edges)` ordering; if star-delete handling ever moves - off the dispatcher this becomes silent reverse-index corruption with no panic - and no race-detector hit. -- **Wire mutex/block profiling to the live endpoints.** `--profile_mode mutex` - did not enable `/debug/pprof/mutex` (it came back with `sampling period=0`), - so the contended mutex had to be inferred from goroutine stacks instead of - named directly. A small `--debug-profile-rates` style flag calling - `runtime.SetMutexProfileFraction` / `SetBlockProfileRate` would make future - contention work much faster. -- **Fix `active_mutations_total` not existing until the first mutation** - (issue #9671) — unrelated to this work but trivial and it breaks dashboards. -- **Parallelize `commitOrAbort`'s per-txn `CommitToDisk` loop** - (`worker/draft.go`) across the txns in an `OracleDelta`. Independent of the - apply-side work here. Note Badger's `CommitAt` is already async, so the gain - is CPU parallelism over iterating `cache.deltas` — which is large precisely - because high-cardinality reverse predicates generate thousands of delta keys. -- **Factor the duplicated `concStore`.** `ProcessList` and `ProcessReverse` now - carry near-identical read-merge-sort-write closures. A shared helper would - stop them drifting, but doing it before the gate fixes land would just cause - churn. - ---- - -## 3b. Build / ops papercuts (found deploying to a clean RHEL 9 EC2 box) - -- **`make dgraph` fails confusingly when `bzip2` is missing.** The `jemalloc` - target (`dgraph/Makefile:103`) does `tar xjf jemalloc.tar.bz2` then - `cd jemalloc-5.3.1`. Without `bzip2` the extract fails *silently*, the `cd` - fails, and `sudo make install` then runs in `/tmp/jemalloc-temp` — producing - `No rule to make target 'install'`, which points nowhere near the real cause. - Add `bzip2` to the documented build prerequisites, or use `tar xjf --checkpoint` - / check the extract exit status and fail with a clear message. -- **The build needs a git checkout for the version string.** Building from an - exported tree prints `fatal: not a git repository` and produces a binary with - an empty version. Harmless but noisy; guard the `git describe` call. -- **`--profile_mode mutex` does not enable the live `/debug/pprof/mutex` - endpoint** (returns `sampling period=0`). See the item in section 3. - -## 4. Benchmarking / harness lessons (so they are not re-learned) - -- **Dgraph returns HTTP 200 with an `{"errors":[...]}` body on rejection.** A - load generator that checks only the status code will happily report - hundreds of "successful" batches that did nothing. Cost a full invalid run. -- **Explicit uids require a lease first** - (`curl "localhost:6080/assign?what=uids&num=N"`), otherwise every mutation - fails with `Uid: [N] cannot be greater than lease: [0]`. -- **Cross-predicate lock contention needs multiple predicates per - transaction, not multiple client threads.** The apply loop is serial across - transactions, so client concurrency cannot produce it. A batch file containing - a single predicate yields `P=1` and cannot exercise the convoy at all. -- **Sequential A/B on a laptop is invalid** — thermal drift produced a spurious - 43% "regression" on a code path that had not changed. Build two test binaries - and interleave them. - ---- - -## 5. Regression evidence (2026-07-26, 32-core EC2) - -### Query-based differential — the strongest gate we have - -A mixed-workload corpus (60 batches x 400 entities x 17 predicates, ~408k -triples) was ingested into four FRESH clusters and fingerprinted with 72 DQL -queries covering every predicate type, index tokenizer, reverse edge, count -index, language tag, a 40-entity full dump, and star-delete verification: - -| config | binary | budget | result | -|---|---|---|---| -| `stock30` | pre-change (`ab0a49e77`) | 30 | reference | -| `new0` | all changes | 0 (legacy path) | **identical** | -| `new30` | all changes | 30 (prod default) | **identical** | -| `newauto` | all changes | -1 (auto) | **identical** | - -Zero mutation errors in all four. The corpus exercises `ProcessSingle`, -`ProcessList`, `ProcessReverse` (hot AND spread targets), `ProcessCount`, -`InsertTokenizerIndexes` (exact/hash/term/fulltext/trigram/int/float/bool/day/geo), -`@upsert`, `@noconflict`, `@lang`, `[string]` lists, SET, DEL, and star-delete — -i.e. every branch the pipeline work touched, not just the benchmark shape. - -Harness: `/data/harness/verify.sh`, corpus generator `gen_mixed.py`. - -### Pre-existing test failures — NOT caused by this work - -Verified by running the identical suite on `ab0a49e77` (pre-change tree) on the -same box: - -- **`worker` package fails `-race` on BOTH trees.** Root cause is structural: - `worker.Init()` (`worker/worker.go:48-52`) overwrites the package-global - `limiter` and starts `go limiter.bleed()`, a goroutine that is never stopped. - Every test calls `Init()`, so each new call races the previous test's still-live - `bleed()`. The race detector then attributes the failure to whichever test - happens to be running, which is why the failing set differs every run. - Measured over 5 runs per tree: baseline and HEAD both fail, with overlapping - and fluctuating sets (`TestLimiterDeadlock` fails 5/5 on both). -- **`types`: `TestParseTimeWithTZ`** writes the process-global `time.Local` - (`types/scalar_types_test.go:121`) while an OpenCensus worker reads it via - `time.Now()`. ~6/8 failures on a clean tree. -- **`posting` — the package this work actually changes — passes cleanly on both - trees** (280s baseline, 286s HEAD, `-race`). - -Fixing either would be a worthwhile separate change; neither blocks this work. - -### Harness trap discovered here - -**Dgraph's `/admin` export is ASYNCHRONOUS** — it returns -`"Export queued with ID ..."` immediately. Sleeping and then reading the export -directory samples partial files, which silently produces four "different" -databases that are actually just different flush points. Poll for completion, or -use queries (synchronous) as done here. - -### Integration suite (`t/` runner, Docker) — 39 packages, zero failures - -Run on the 32-core box against a `dgraph/dgraph:local` image built from this -tree and **verified by symbol inspection** to contain the changes -(`flushConflicts`=2, `maphash`=18) before trusting any result. - -**39 packages `ok`, 0 package failures, 0 test failures**, including the ones -that matter most here: - -| package | time | note | -|---|---|---| -| `systest/mutations-and-queries` | 50s | the mutation-heavy systest | -| `systest/vector` | 1484s | HNSW path, a known pipeline bug source | -| `worker` | 213s | passes cleanly under the integration harness | -| `systest/multi-tenancy`, `systest/plugin`, `acl`, … | — | ok | -| `posting` (rerun, `--pkg=posting`) | — | `T_EXIT=0` | - -Setup needed on a clean box: Docker (`t/` demands it even for unit tests), -`gotestsum` (`AUTO_INSTALL=true make check-deps`), a git repo root, and -`make local-image`. `ack` is unavailable on RHEL 9 and blocks `check-deps`, but -the runner itself does not need it — invoke `go run .` in `t/` directly. - -**Unexplained:** the first full-suite invocation exited 1 with no failing test -and a log ending mid-`posting`. Re-running that package alone gave `T_EXIT=0`. -The four `panic:` entries in the log are intentional — they come from -`TestRunAll_WithDgraphDirectives/panic_catcher`, whose own output says the panic -is expected. - -Caveat worth stating: `TestRebuildTokIndex` appears to stall if you filter test -output with `grep | head` — badger emits enough INFO lines between `=== RUN` and -`--- PASS` to be truncated. It passes in 0.06s. - -### Production-replica corpus benchmark (ext-pointer-tech-collab data generator) - -The synthetic corpus used above is 100% `[uid] @reverse` with no upserts, which -overstates the reverse share. Re-ran against the three-stage generator from -`ext-pointer-tech-collab/data-generator`, which replicates the NiFi/dgraph4j -production pipeline: **30 `@reverse` and 20 `@upsert` predicates**, 1.67M seeded -nodes, hot-node reverse fan-out (Zipf `--hot-skew`), `@upsert` re-asserts and -hours-later update waves. 532 transaction-sized files (20k nquads each, 600 MB). - -Both arms restore the **same seeded snapshot**, so they differ only in binary. -Budget=30 (shipped default), 16 client threads, 2 runs per arm: - -| run | MB/s | elapsed | aborts | nquads committed | nquads/s | -|---|---|---|---|---|---| -| stock r1 | 1.47 | 129.2s | 1855 | 5,692,627 | 44,900 | -| stock r2 | 1.48 | 129.5s | 1871 | 5,746,044 | 44,498 | -| **shard r1** | **1.74** | 110.8s | 1850 | 5,709,947 | **52,612** | -| **shard r2** | **1.73** | 110.1s | 1857 | 5,625,354 | **52,101** | - -**+17.6% MB/s, +17.1% nquads/s, 14.6% faster wall clock**, reproducible to ~1%. - -**Abort counts are identical (~1,860 both arms)** — the conflict-key semantics -are unchanged under real `@upsert` contention, which is the strongest available -correctness signal for the conflict-key batching work. - -Two things this reveals that the synthetic corpus hid: - -1. **Absolute throughput is far lower (1.5 MB/s vs 13 MB/s)** because this - workload is *abort*-dominated: only ~283 of 532 files commit, the rest give up - after 6 retries — exactly the contention the production client's Caffeine - lock layer exists to avoid. (An earlier revision of this bullet attributed the - ceiling to "Zero's conflict arbitration". That is wrong; Zero is idle. See - §5a.) -2. The relative gain (+17.6%) is **half** the synthetic figure (+36.8%), which is - the expected dilution from a realistic predicate mix. Quote the 17.6% number - for production expectations, not the 36.8%. - -Reproduce: `/data/harness/prodbench.sh ` with -`/data/gen` (generator) and `/data/seed_snapshot` (seeded state) on the EC2 box. -Generator needs Python 3.10+ (`dataclass(slots=True)`) — RHEL 9 ships 3.9. - -#### Client-thread sweep — the abort storm dominates, and fewer threads is faster - -Same production-replica corpus, budget=30, varying client threads (emulating a -lock-cache-protected client, which serialises conflicting writes rather than -letting them abort): - -| threads | stock MB/s | shard MB/s | speedup | stock nquads/s | shard nquads/s | files committed | aborts | -|---:|---:|---:|---:|---:|---:|---:|---:| -| 2 | 5.30 | **5.96** | 1.12x | 143,143 | **160,825** | 502-520 / 532 | ~380 | -| 4 | 3.62 | **4.09** | 1.13x | 100,348 | **113,459** | 454 / 532 | ~940 | -| 8 | 2.31 | **2.67** | 1.16x | 65,918 | **76,008** | ~368 / 532 | ~1,470 | -| 16 | 1.48 | **1.74** | 1.18x | 44,699 | 52,357 | ~283 / 532 | ~1,860 | - -**Throughput more than triples as client threads DROP from 16 to 2** — "triples" -here is the verb, 3.58x, not RDF triples (1.48 -> 5.30 MB/s on stock; 44.7k -> -143k nquads/s). Aborts fall 1,860 -> 380 and committed files rise 283 -> 502 of -532. Every extra client thread buys more aborted work than committed work. - -**The mechanism is wasted apply work, not a slow Zero.** An earlier revision of -this section named "Zero's conflict arbitration over `@upsert` re-asserts" as the -constraint. Profiling says otherwise — see §5a. What actually happens: an -aborted transaction still takes a startTs, still gets proposed through Raft, and -still runs a full pre-write through the *serial* apply loop before being -discarded. At 16 threads the Alpha performs ~2,100 mutation applies to commit -~283 files, so **~85% of apply work is thrown away**, and the apply loop is -68-88% occupied doing it. - -The distinction is actionable: making Zero faster buys nothing, while causing -fewer conflicts buys ~3x. - -Two consequences worth acting on: - -1. **A production ingest tuned for more threads is likely losing throughput to - aborts, not gaining it.** This is precisely what the client-side node-UID lock - cache exists to prevent — these numbers quantify what it is worth. Anyone - without it should test *lower* concurrency before adding more. -2. **The apply-path gain is consistent everywhere (1.12x-1.18x)** and grows - mildly with contention. It is not an artifact of the abort storm — at 2 - threads, where 502/532 files commit and aborts are minimal, it is still - **+12%** (and +12.4% on nquads/s). - -Abort counts track each other across every thread count (380/379, 945/931, -1465/1479, 1855/1857) — conflict semantics unchanged, as intended. - -## 5a. The control: it is aborts, not concurrency — and Zero is idle - -The sweep above shows throughput falling as threads rise, but on its own it -cannot separate "aborts are expensive" from "concurrency is expensive". Two -measurements settle it. - -### Same sweep on a corpus with zero aborts - -Reverse-heavy corpus (25 `[uid] @reverse`, no `@upsert`, so no conflicts), same -box, same binary, 180 s runs, `intra-mutation-parallelism=auto`: - -| threads | triples/s | vs 2 threads | -|---:|---:|---:| -| 2 | 92,982 | — | -| 8 | 120,581 | +30% | -| 16 | 130,136 | +40% | -| 64 | 134,173 | +44% | - -**Opposite direction to the abort-heavy sweep.** Conflict-free throughput *rises* -monotonically with client threads; abort-heavy throughput *falls* 3.2x over the -same range. Concurrency is not what hurts — aborts are. High thread counts are -actively good when there is nothing to conflict on. - -### Zero is not the bottleneck - -Profiled during a 16-thread production-replica run (`ec2-harness/zeroprof.sh`, -`analyze_zero.py`): - -- **5.39 s of CPU over 60 s — 9% of ONE core** on a 64-vCPU box. -- Goroutine count flat at 42-47; every park site sits at exactly one goroutine - per dump, i.e. idle server loops. -- **One sample inside `Oracle.commit` out of 1,695.** - -Zero decides those ~1,860 aborts essentially for free. Of the little CPU it does -use, ~25% is the conflict-key wire format (`consumeStringSliceValidateUTF8` -18.9%, `strconv.ParseUint` 5.0%, 70% of the latter under `hasConflict`) because -`TxnContext.Keys` is `repeated string` base-36 that Zero parses back to uint64. -Worth knowing for a far larger cluster; worth nothing at this scale. - -Also noted: **Zero publishes no transaction metrics at all** — its Prometheus -endpoint carries only badger, memory and raft-leader gauges. No commits, aborts, -lease or subscriber counters. The subscriber-overflow drop stays invisible. - -### What this says the lock cache is worth - -Lining the numbers up, all on the same box: - -| configuration | throughput | | -|---|---:|---| -| apply-loop ceiling (conflict-free, 16-64 thr) | 130-134k/s | the wall | -| abort-heavy @ 2 threads | 143k nquads/s | already at the wall | -| abort-heavy @ 16 threads | 44.7k nquads/s | 3.2x below it | - -A client-side transaction lock should recover the 16-thread case to roughly the -130-143k range — about **3x** — without touching thread count. Two caveats: - -1. **It does not beat the 2-thread result, it reaches it.** The lock removes - abort waste; it does not lift the serial apply loop. Even conflict-free the - curve saturates hard (2->8 is +30%, 8->16 is +8%, 16->64 is +3%), so past - ~16 threads more concurrency buys nothing either way. -2. **Its advantage over simply dropping threads** is that it serialises only - *conflicting* writes, leaving independent work fully parallel — so the benefit - scales with the independent fraction of the workload, and the operator does - not have to guess a thread count. - -Reproduce: `ec2-harness/threadctl.sh` (conflict-free sweep) and -`ec2-harness/zeroprof.sh` + `analyze_zero.py` (Zero profile). From bc1946d5ce78508a56820c4b859cda3af8904a79 Mon Sep 17 00:00:00 2001 From: Ryan Hendrickson Date: Sun, 9 Aug 2026 13:18:22 -0400 Subject: [PATCH 39/45] =?UTF-8?q?feat(metrics):=20add=20txns=5Fper=5Fdelta?= =?UTF-8?q?=20histogram=20=E2=80=94=20Zero's=20delta=20batching=20depth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the number of transaction statuses in each applied OracleDelta, skipping empty (MaxAssigned-only) deltas so light write load does not drown the signal in the low buckets. Batching depth is the prerequisite figure for any cross-transaction apply optimization and was previously unobservable; read it as the share of samples in buckets >= 2, never as the mean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011uGZrf7FExkfwT5cWowrb5 --- worker/draft.go | 6 ++++++ x/metrics.go | 18 ++++++++++++++++++ x/metrics_test.go | 27 +++++++++++++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 x/metrics_test.go diff --git a/worker/draft.go b/worker/draft.go index d8955b36e01..f3f1f1ce09d 100644 --- a/worker/draft.go +++ b/worker/draft.go @@ -970,6 +970,12 @@ func (n *node) processApplyCh() { // TODO(Anurag - 4 May 2020): Are we using pkey? Remove if unused. func (n *node) commitOrAbort(pkey uint64, delta *pb.OracleDelta) error { x.PrintOracleDelta(delta) + if len(delta.Txns) > 0 { + // Zero's batching depth per Raft entry. Skipping empty + // (MaxAssigned-only) deltas keeps the histogram readable at light + // write load; see the measure's doc for how to interpret it. + ostats.Record(context.Background(), x.TxnsPerDelta.M(int64(len(delta.Txns)))) + } // First let's commit all mutations to disk. writer := posting.NewTxnWriter(pstore) toDisk := func(start, commit uint64) { diff --git a/x/metrics.go b/x/metrics.go index 2ab274c6fb4..e31d6f33dba 100644 --- a/x/metrics.go +++ b/x/metrics.go @@ -60,6 +60,17 @@ var ( IntraMutationNoFanout = ostats.Int64("intra_mutation_no_fanout_total", "Total mutations that ran with no intra-predicate fan-out", ostats.UnitDimensionless) + // TxnsPerDelta is the number of transaction statuses carried by each + // applied OracleDelta — Zero's delta batching depth, which governs how + // much work a single apply-loop iteration amortizes and is the + // prerequisite figure for any cross-transaction apply optimization. + // Empty (MaxAssigned-only) deltas are not recorded: they would dominate + // the low buckets at light write load and drown the signal. Read the + // batching depth as the share of samples in buckets >= 2, never as the + // mean; Restore's synthetic single-transaction delta is included. + TxnsPerDelta = ostats.Int64("txns_per_delta", + "Transactions carried by each applied OracleDelta", + ostats.UnitDimensionless) // NumBackups is the number of backups requested NumBackups = ostats.Int64("num_backups_total", "Total number of backups requested", ostats.UnitDimensionless) @@ -254,6 +265,13 @@ var ( Aggregation: view.Count(), TagKeys: allTagKeys, }, + { + Name: TxnsPerDelta.Name(), + Measure: TxnsPerDelta, + Description: TxnsPerDelta.Description(), + Aggregation: view.Distribution(0, 1, 2, 4, 8, 16, 32, 64, 128, 256), + TagKeys: allTagKeys, + }, { Name: NumBackups.Name(), Measure: NumBackups, diff --git a/x/metrics_test.go b/x/metrics_test.go new file mode 100644 index 00000000000..41723a5a34c --- /dev/null +++ b/x/metrics_test.go @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package x + +import ( + "testing" + + "github.com/stretchr/testify/require" + "go.opencensus.io/stats/view" +) + +// TestTxnsPerDeltaViewRegisteredAsDeclared pins the metric operators read to +// judge Zero's delta batching depth. The bucket layout is part of the metric's +// contract: "share of samples in buckets >= 2" is the documented reading, so a +// silent re-bucketing would change what dashboards mean without breaking them. +func TestTxnsPerDeltaViewRegisteredAsDeclared(t *testing.T) { + v := view.Find("txns_per_delta") + require.NotNil(t, v, "txns_per_delta view is not registered") + require.Equal(t, TxnsPerDelta, v.Measure) + require.Equal(t, view.AggTypeDistribution, v.Aggregation.Type) + // Declared as Distribution(0, 1, 2, ...); OpenCensus drops non-positive + // bounds at registration, so the effective layout starts at 1. + require.Equal(t, []float64{1, 2, 4, 8, 16, 32, 64, 128, 256}, v.Aggregation.Buckets) +} From 5ce9ca43e1b1a89a84c65cf0268f5ffb0a393648 Mon Sep 17 00:00:00 2001 From: Ryan Hendrickson Date: Sun, 9 Aug 2026 13:30:14 -0400 Subject: [PATCH 40/45] fix(flags): remove cross-txn-apply-workers from the --feature-flags help MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The key exists nowhere else — no default, no parsing, no config field. It is residue from a discarded cross-transaction apply experiment. Because the superflag layer rejects keys absent from its defaults string, an operator following this help text got a hard startup failure: superflag: found invalid options: cross-txn-apply-workers=4 The full text is preserved in the plans archive should the experiment return. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011uGZrf7FExkfwT5cWowrb5 --- dgraph/cmd/alpha/run.go | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/dgraph/cmd/alpha/run.go b/dgraph/cmd/alpha/run.go index 080362286a2..b4443e313b0 100644 --- a/dgraph/cmd/alpha/run.go +++ b/dgraph/cmd/alpha/run.go @@ -312,17 +312,6 @@ they form a Raft group and provide synchronous replication. "a 20k-edge mutation caps at 78 workers however many cores exist — so "+ "lower it, not the multiplier, to get more parallelism there. "+ "Default 256."). - Flag("cross-txn-apply-workers", - "EXPERIMENTAL. How many SEPARATE transactions may have their mutation "+ - "pre-writes applied concurrently in the Raft apply loop. 0 (default) "+ - "applies one entry at a time, the historical behaviour. This is a "+ - "different axis from intra-mutation-parallelism, which parallelizes "+ - "the inside of a single mutation: the serial apply loop measures at "+ - "~88% of wall clock on a production-replica corpus, so it is the "+ - "ceiling that intra-mutation work cannot lift. Only plain mutations "+ - "run concurrently -- drop, schema and type operations, snapshots, "+ - "key-value restores and commit deltas all stay serial and in Raft log "+ - "order."). String()) RegisterFlags(flag) From 39fc47553f7864ad63f0cae37f20fe3f0117e0f1 Mon Sep 17 00:00:00 2001 From: Shivaji Kharse Date: Mon, 26 Oct 2026 18:56:06 +0530 Subject: [PATCH 41/45] test(posting): create missing schema in the addMutation test helper The helper routes every test mutation through MutationPipeline, which, like the legacy runMutation, refuses a SET edge whose predicate has no schema. In production applyMutations creates the schema for any new predicate before the mutation runs, so the check never fires there. Tests that never declare a schema (TestReadUidsHonorsPostingListCache and TestReadUidsWarmsCachedPostingList since #9809) therefore failed only under the pipeline helper. Mirror applyMutations in the helper: when the predicate has no schema entry, install a minimal one inferred from the edge (uid edge -> [uid], otherwise the edge value type, defaulting to string). --- posting/index_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/posting/index_test.go b/posting/index_test.go index 7b4807d6281..ac137450f7e 100644 --- a/posting/index_test.go +++ b/posting/index_test.go @@ -140,6 +140,22 @@ func addMutation(t *testing.T, l *List, edge *pb.DirectedEdge, op uint32, txn := Oracle().RegisterStartTs(startTs) txn.cache.SetIfAbsent(string(l.key), l) + // Production creates a schema entry for any new predicate in applyMutations before the + // mutation runs; mirror that here so tests that never declare one behave the same way. + if _, ok := schema.State().Get(context.Background(), edge.Attr); !ok { + su := &pb.SchemaUpdate{Predicate: edge.Attr} + if edge.ValueId != 0 && len(edge.Value) == 0 { + su.ValueType = pb.Posting_UID + su.List = true + } else { + su.ValueType = edge.ValueType + if su.ValueType == pb.Posting_DEFAULT { + su.ValueType = pb.Posting_STRING + } + } + schema.State().Set(edge.Attr, su) + } + mp := NewMutationPipeline(txn) err := mp.Process(context.Background(), []*pb.DirectedEdge{edge}) require.NoError(t, err) From 6aaa2008f2a13f1e00f7a2a5284ea972182a8d24 Mon Sep 17 00:00:00 2001 From: Shivaji Kharse Date: Thu, 29 Oct 2026 13:19:23 +0530 Subject: [PATCH 42/45] feat(flags): default intra-mutation parallelism off, rename threshold flag Rename the --feature-flags key intra-mutation-min-edges to intra-mutation-threshold (and the WorkerConfig field to IntraMutationThreshold) so it no longer collides with intra-mutation-edges-per-worker, and flip its default from 1 to 0 so the mutation pipeline ships OFF and is opt-in. Fix the flag help, which wrongly claimed the feature required intra-mutation-parallelism > off: threshold > 0 alone routes qualifying mutations through the pipeline; parallelism only controls fan-out. --- dgraph/cmd/alpha/run.go | 21 ++++++++++++--------- posting/pipeline_schema_matrix_test.go | 2 +- worker/draft.go | 2 +- worker/feature_flags_test.go | 11 ++++++----- worker/server_state.go | 2 +- x/config.go | 23 ++++++++++++----------- 6 files changed, 33 insertions(+), 28 deletions(-) diff --git a/dgraph/cmd/alpha/run.go b/dgraph/cmd/alpha/run.go index b4443e313b0..e9723008c40 100644 --- a/dgraph/cmd/alpha/run.go +++ b/dgraph/cmd/alpha/run.go @@ -283,16 +283,19 @@ they form a Raft group and provide synchronous replication. "with structured fields including trace ID for correlation with distributed traces. "+ "Disabled by default (0). Note: enabling this logs query text which may contain "+ "sensitive data; do not enable in deployments with strict data privacy requirements."). - Flag("intra-mutation-min-edges", + Flag("intra-mutation-threshold", "Minimum edge count for a mutation to take the parallel apply path, "+ "which spreads the predicates in ONE mutation across goroutines. "+ - "0 disables that path entirely (all mutations take the legacy path) "+ - "and is the single kill switch for intra-mutation parallelism. "+ - "1 (default) always takes it. A value N>1 takes it only when "+ - "len(edges) >= N, leaving small interactive mutations on the legacy "+ - "path — the parallel path pays per-predicate goroutine spin-up cost, "+ - "so tiny mutations are slightly slower on it; bulk multi-predicate "+ - "mutations are faster (crossover ~100 edges in benchmarks here)."). + "0 (default) disables that path entirely — the feature is OFF and "+ + "every mutation takes the legacy path — making this the single kill "+ + "switch for intra-mutation parallelism. Set N>=1 to enable it: the "+ + "parallel path is then taken when len(edges) >= N (1 = always on). "+ + "Keep N above the crossover (~100 edges in benchmarks here) so small "+ + "interactive mutations, which are slightly slower on the parallel "+ + "path, stay on the legacy path while bulk multi-predicate mutations "+ + "get the speed-up. Qualifying mutations then run through the mutation "+ + "pipeline; intra-mutation-parallelism controls how many worker "+ + "goroutines each one uses (off = pipeline runs with no fan-out)."). Flag("intra-mutation-parallelism", "Concurrent worker goroutines used INSIDE a single mutation, shared "+ "across the predicates it touches and apportioned by edge count so a "+ @@ -839,7 +842,7 @@ func run() { x.Config.NormalizeCompatibilityMode = featureFlagsConf.GetString("normalize-compatibility-mode") enableDetailedMetrics := featureFlagsConf.GetBool("enable-detailed-metrics") x.WorkerConfig.SlowQueryLogThreshold = featureFlagsConf.GetDuration("log-slow-query-threshold") - x.WorkerConfig.IntraMutationMinEdges = int(featureFlagsConf.GetInt64("intra-mutation-min-edges")) + x.WorkerConfig.IntraMutationThreshold = int(featureFlagsConf.GetInt64("intra-mutation-threshold")) // The superflag layer cannot validate this one — it is off|auto|N|Fx, not a // plain int — so a typo must fail here at startup rather than silently // resolve to the zero value and disable fan-out for the life of the process. diff --git a/posting/pipeline_schema_matrix_test.go b/posting/pipeline_schema_matrix_test.go index b7b9108d78d..f4dbb6c506c 100644 --- a/posting/pipeline_schema_matrix_test.go +++ b/posting/pipeline_schema_matrix_test.go @@ -407,7 +407,7 @@ func TestSchemaMatrixByteIdentical(t *testing.T) { t.Run(sc.name+"/"+bc.name, func(t *testing.T) { // Baseline: the REAL legacy path — a serial runMutation loop, the // same per-edge call draft.go makes when - // intra-mutation-min-edges=0 sends a mutation around the pipeline. + // intra-mutation-threshold=0 sends a mutation around the pipeline. // // This used to be "the pipeline with the budget off", which was an // in-pipeline replica of legacy semantics (locked AddDelta). Once diff --git a/worker/draft.go b/worker/draft.go index f3f1f1ce09d..ef30149c469 100644 --- a/worker/draft.go +++ b/worker/draft.go @@ -526,7 +526,7 @@ func (n *node) applyMutations(ctx context.Context, proposal *pb.Proposal) (rerr // Discard the posting lists from cache to release memory at the end. defer txn.Update() - if t := x.WorkerConfig.IntraMutationMinEdges; t > 0 && len(m.Edges) >= t { + if t := x.WorkerConfig.IntraMutationThreshold; t > 0 && len(m.Edges) >= t { mp := posting.NewMutationPipeline(txn) return mp.Process(ctx, m.Edges) } diff --git a/worker/feature_flags_test.go b/worker/feature_flags_test.go index 64ac854124e..2ffade30557 100644 --- a/worker/feature_flags_test.go +++ b/worker/feature_flags_test.go @@ -26,7 +26,8 @@ func TestFeatureFlagsIntraMutationKeys(t *testing.T) { // No overrides: the shipped defaults must parse and be what we documented. sf := z.NewSuperFlag("").MergeAndCheckDefault(FeatureFlagsDefaults) - require.Equal(t, int64(1), sf.GetInt64("intra-mutation-min-edges")) + require.Equal(t, int64(0), sf.GetInt64("intra-mutation-threshold"), + "intra-mutation parallelism must ship OFF by default") require.Equal(t, int64(256), sf.GetInt64("intra-mutation-edges-per-worker")) par, err := x.ParseIntraMutationParallelism(sf.GetString("intra-mutation-parallelism")) @@ -59,9 +60,9 @@ func TestFeatureFlagsIntraMutationKeys(t *testing.T) { require.Error(t, err) // The other two keys still override as plain ints. - sf = z.NewSuperFlag("intra-mutation-min-edges=0; intra-mutation-edges-per-worker=64"). + sf = z.NewSuperFlag("intra-mutation-threshold=0; intra-mutation-edges-per-worker=64"). MergeAndCheckDefault(FeatureFlagsDefaults) - require.Equal(t, int64(0), sf.GetInt64("intra-mutation-min-edges")) + require.Equal(t, int64(0), sf.GetInt64("intra-mutation-threshold")) require.Equal(t, int64(64), sf.GetInt64("intra-mutation-edges-per-worker")) } @@ -71,12 +72,12 @@ func TestFeatureFlagsIntraMutationKeys(t *testing.T) { // added there by hand. func TestWorkerOptionsStringShowsIntraMutation(t *testing.T) { w := x.WorkerOptions{ - IntraMutationMinEdges: 1, + IntraMutationThreshold: 1, IntraMutationParallelism: x.IntraMutationParallelism{PerCPU: 1.5}, IntraMutationEdgesPerWorker: 256, } s := w.String() - require.Contains(t, s, "IntraMutationMinEdges:1") + require.Contains(t, s, "IntraMutationThreshold:1") require.Contains(t, s, "IntraMutationParallelism:1.5x") require.Contains(t, s, "IntraMutationEdgesPerWorker:256") } diff --git a/worker/server_state.go b/worker/server_state.go index 6d60d769654..5782f5b78a0 100644 --- a/worker/server_state.go +++ b/worker/server_state.go @@ -43,7 +43,7 @@ const ( `lambda-url=;` CacheDefaults = `size-mb=4096; percentage=40,40,20; remove-on-update=false` FeatureFlagsDefaults = `normalize-compatibility-mode=; enable-detailed-metrics=false; ` + - `log-slow-query-threshold=0; intra-mutation-min-edges=1; ` + + `log-slow-query-threshold=0; intra-mutation-threshold=0; ` + `intra-mutation-parallelism=auto; intra-mutation-edges-per-worker=256` ) diff --git a/x/config.go b/x/config.go index 0c79a00848e..1ecac7d1978 100644 --- a/x/config.go +++ b/x/config.go @@ -145,15 +145,16 @@ type WorkerOptions struct { HardSync bool // Audit contains the audit flags that enables the audit. Audit bool - // IntraMutationMinEdges gates the parallel mutation path in applyMutations: a - // mutation takes it only when IntraMutationMinEdges > 0 and len(m.Edges) >= - // it. 0 disables that path entirely and is the single kill switch for this - // whole mechanism; 1 (the default) always takes it. A value above 1 keeps - // small interactive mutations on the legacy path, which is worth doing only - // if per-predicate goroutine spin-up measurably hurts them (crossover ~100 - // edges in benchmarks here). - // Superflag key: "intra-mutation-min-edges". - IntraMutationMinEdges int + // IntraMutationThreshold gates the parallel mutation path in applyMutations: a + // mutation takes it only when IntraMutationThreshold > 0 and len(m.Edges) >= + // it. 0 (the default) disables that path entirely and is the single kill + // switch for this whole mechanism — the feature ships OFF and is opt-in. A + // value of 1 always takes it; a value above 1 keeps small interactive + // mutations on the legacy path, which is the recommended way to enable it + // since the parallel path only pays off above the crossover (~100 edges in + // benchmarks here). + // Superflag key: "intra-mutation-threshold"; default 0 (off). + IntraMutationThreshold int // IntraMutationParallelism sizes the pool of worker goroutines used inside a // single mutation. The pool is shared across the predicates that mutation // touches and apportioned by edge count, so a hot predicate is granted most @@ -273,10 +274,10 @@ func (w WorkerOptions) String() string { "WhiteListedIPRanges:%v StrictMutations:%v AclEnabled:%v AclJwtAlg:%v "+ "AclPublicKey:**** AbortOlderThan:%v ProposedGroupId:%d StartTime:%v "+ "Security:**** EncryptionKey:**** LogDQLRequest:%d SlowQueryThreshold:%v HardSync:%v Audit:%v "+ - "IntraMutationMinEdges:%d IntraMutationParallelism:%v IntraMutationEdgesPerWorker:%d}", + "IntraMutationThreshold:%d IntraMutationParallelism:%v IntraMutationEdgesPerWorker:%d}", w.TmpDir, w.ExportPath, w.MyAddr, w.ZeroAddr, w.Raft, w.WhiteListedIPRanges, w.StrictMutations, w.AclEnabled, w.AclJwtAlg, w.AbortOlderThan, w.ProposedGroupId, w.StartTime, w.LogDQLRequest, w.SlowQueryLogThreshold, w.HardSync, w.Audit, - w.IntraMutationMinEdges, w.IntraMutationParallelism, w.IntraMutationEdgesPerWorker) + w.IntraMutationThreshold, w.IntraMutationParallelism, w.IntraMutationEdgesPerWorker) } From ac4829a567b5c6998a5ced01efb1ea0b2a59f368 Mon Sep 17 00:00:00 2001 From: Shivaji Kharse Date: Thu, 29 Oct 2026 13:19:40 +0530 Subject: [PATCH 43/45] fix(pipeline): address CodeRabbit review findings - index.go: capture the first worker error instead of dropping a nil errCh, and keep draining uids so the dispatcher never blocks; remove a dead method. - list.go: guard uids[0] against an empty slice. - oracle.go: simplify AddPointer. - locked_sharded_map.go: in Merge, snapshot the other shard under its RLock before taking this map's lock. - tests: proto.Clone reused edges (pipeline_budget_test), Cleanup(code != 0) (upgrade_test), drop unused fields / an atomic copy (sort_test, index_test). --- posting/index.go | 37 ++++++++++++++++++++++----------- posting/index_test.go | 6 ++++-- posting/list.go | 16 +++++++------- posting/oracle.go | 4 ---- posting/pipeline_budget_test.go | 6 +++--- query/upgrade_test.go | 2 +- types/locked_sharded_map.go | 15 ++++++++++--- worker/sort_test.go | 14 ++++++------- 8 files changed, 61 insertions(+), 39 deletions(-) diff --git a/posting/index.go b/posting/index.go index ce8fc20c059..d95bc10d3a7 100644 --- a/posting/index.go +++ b/posting/index.go @@ -106,7 +106,6 @@ type PredicatePipeline struct { attr string edges chan *pb.DirectedEdge wg *sync.WaitGroup - errCh chan error // workers is this predicate's grant from the global intra-predicate budget // (see allocateWorkers). 0 means the budget is disabled/unset: data-write // stays serial and InsertTokenizerIndexes uses the legacy numGo=10 — exactly @@ -157,15 +156,25 @@ func (mp *MutationPipeline) InsertTokenizerIndexes(ctx context.Context, pipeline syncMap := sync.Map{} + // A worker that hits an error (schema dropped between Tokenizer and + // FactoryCreateSpec, or a tokenization failure) records the first one and keeps + // draining its channel so the dispatcher never blocks on a buffered channel whose + // worker has left. InsertTokenizerIndexes returns the error after wg.Wait(). + var ( + errOnce sync.Once + firstErr error + ) + recordErr := func(err error) { errOnce.Do(func() { firstErr = err }) } + chanFn := func(uids chan uint64, estimatedSize int) { defer wg.Done() indexGenInThread := make(map[string]*pb.PostingList, estimatedSize) tokenizers := schema.State().Tokenizer(ctx, pipeline.attr) factorySpecs, err := schema.State().FactoryCreateSpec(ctx, pipeline.attr) - if err != nil { - pipeline.errCh <- err - return + failed := err != nil + if failed { + recordErr(err) } indexEdge := &pb.DirectedEdge{ @@ -173,6 +182,10 @@ func (mp *MutationPipeline) InsertTokenizerIndexes(ctx context.Context, pipeline } for uid := range uids { + if failed { + // Keep draining so the dispatcher's channel sends never block. + continue + } postingList := (*postings)[uid] // Sort Dels before Sets: when DEL and SET land in the same index // bucket (e.g. case-insensitive tokenizer where the old and new @@ -209,7 +222,9 @@ func (mp *MutationPipeline) InsertTokenizerIndexes(ctx context.Context, pipeline if !loaded { tokens, err = indexTokens(ctx, info) if err != nil { - x.Panic(err) + recordErr(err) + failed = true + break } syncMap.Store(key, tokens) } @@ -275,6 +290,10 @@ func (mp *MutationPipeline) InsertTokenizerIndexes(ctx context.Context, pipeline wg.Wait() + if firstErr != nil { + return firstErr + } + // Buffer conflict keys and flush them with ONE txn.Lock() acquisition. // // This defer MUST be registered ABOVE cache.Lock(): defers run LIFO, so @@ -1003,12 +1022,6 @@ func (txn *Txn) flushConflicts(c *conflictBuf) { c.keys = c.keys[:0] } -func (txn *Txn) addConflictKeyWithUid(key []byte, pl *pb.PostingList, hasUpsert bool, hasNoConflict bool) { - var c conflictBuf - c.addForPostings(key, pl, hasUpsert, hasNoConflict) - txn.flushConflicts(&c) -} - func (mp *MutationPipeline) ProcessCount(ctx context.Context, pipeline *PredicatePipeline, postings *map[uint64]*pb.PostingList, info predicateInfo, isListEdge bool, isReverseEdge bool) error { // Conflict keys are buffered and flushed with ONE txn.Lock() acquisition. // ProcessCount runs single-goroutine (called serially from ProcessList and @@ -1644,7 +1657,7 @@ func ValidateAndConvert(edge *pb.DirectedEdge, su *pb.SchemaUpdate) error { // store". Those two decisions used to be tied together, which made the // disabled setting also opt out of the lock-free store — the single largest // win in this whole change — and made the flag non-monotonic. The kill switch -// is intra-mutation-min-edges=0, which bypasses this path entirely. +// is intra-mutation-threshold=0, which bypasses this path entirely. // // Apportionment is Hamilton / largest-remainder with a floor of 1: // - If P := len(edgeCounts) >= budget, the batch already saturates the budget diff --git a/posting/index_test.go b/posting/index_test.go index ac137450f7e..2d4aa438990 100644 --- a/posting/index_test.go +++ b/posting/index_test.go @@ -165,9 +165,11 @@ func addMutation(t *testing.T, l *List, edge *pb.DirectedEdge, op uint32, require.NoError(t, txn.CommitToDisk(writer, commitTs)) require.NoError(t, writer.Flush()) newTxn := Oracle().RegisterStartTs(commitTs + 1) - l1, err := newTxn.Get(l.key) + // Reload at commitTs+1 to warm the cache for the committed data. Callers do not + // read l after this, so there is no need to copy the reloaded List back into it + // (that copies List.uidWarmState, an atomic that must not be copied / fails vet). + _, err = newTxn.Get(l.key) require.NoError(t, err) - *l = *l1 //nolint } const schemaPreVal = ` diff --git a/posting/list.go b/posting/list.go index c3f4fd62b0d..0165f4939ce 100644 --- a/posting/list.go +++ b/posting/list.go @@ -2228,14 +2228,16 @@ func (l *List) findStaticValue(readTs uint64) *pb.PostingList { } if l.plist != nil && l.plist.Pack != nil { uids := codec.Decode(l.plist.Pack, 0) - return &pb.PostingList{ - Postings: []*pb.Posting{ - { - Uid: uids[0], - ValType: pb.Posting_UID, - Op: Set, + if len(uids) > 0 { + return &pb.PostingList{ + Postings: []*pb.Posting{ + { + Uid: uids[0], + ValType: pb.Posting_UID, + Op: Set, + }, }, - }, + } } } return nil diff --git a/posting/oracle.go b/posting/oracle.go index b26154e03db..3e6337f583c 100644 --- a/posting/oracle.go +++ b/posting/oracle.go @@ -61,10 +61,6 @@ type Txn struct { } func (txn *Txn) AddPointer(p *[]byte) { - if txn.pointers == nil { - txn.pointers = make([](*[]byte), 1) - txn.pointers[0] = p - } txn.pointers = append(txn.pointers, p) } diff --git a/posting/pipeline_budget_test.go b/posting/pipeline_budget_test.go index 5d70915f7db..b4a031dd178 100644 --- a/posting/pipeline_budget_test.go +++ b/posting/pipeline_budget_test.go @@ -17,6 +17,7 @@ import ( "testing" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" "github.com/dgraph-io/dgraph/v25/protos/pb" "github.com/dgraph-io/dgraph/v25/schema" @@ -44,8 +45,7 @@ import ( func cloneEdges(in []*pb.DirectedEdge) []*pb.DirectedEdge { out := make([]*pb.DirectedEdge, len(in)) for i, e := range in { - c := *e - out[i] = &c + out[i] = proto.Clone(e).(*pb.DirectedEdge) } return out } @@ -414,7 +414,7 @@ func TestAllocateWorkers(t *testing.T) { // locked store". Tying those together made the disabled setting also give up // the lock-free store — about two thirds of this branch's measured gain — and // made the flag non-monotonic. The real kill switch is - // intra-mutation-min-edges=0, which never reaches this code. + // intra-mutation-threshold=0, which never reaches this code. require.Equal(t, map[string]int{"a": 1, "b": 1}, allocateWorkers(map[string]int{"a": 5, "b": 3}, 0)) require.Equal(t, map[string]int{"a": 1, "b": 1}, diff --git a/query/upgrade_test.go b/query/upgrade_test.go index dcec57e4f8c..9421e83fca9 100644 --- a/query/upgrade_test.go +++ b/query/upgrade_test.go @@ -50,7 +50,7 @@ func TestMain(m *testing.M) { WithReplicas(1).WithACL(time.Hour).WithVersion(uc.Before) c, err := dgraphtest.NewLocalCluster(conf) x.Panic(err) - defer func() { c.Cleanup(true) }() + defer func() { c.Cleanup(code != 0) }() x.Panic(c.Start()) hc, err := c.HTTPClient() diff --git a/types/locked_sharded_map.go b/types/locked_sharded_map.go index 3eee9dede34..46069035021 100644 --- a/types/locked_sharded_map.go +++ b/types/locked_sharded_map.go @@ -144,16 +144,25 @@ func (s *LockedShardedMap[K, V]) Merge(other *LockedShardedMap[K, V], ag func(a, wg.Add(1) go func(i int) { defer wg.Done() - otherShard := other.shards[i] + // Snapshot other's shard under its read lock before touching s, so a + // concurrent Set/Update on other cannot race this read, and we never + // hold both maps' locks at once (reciprocal Merge calls can't deadlock). + other.locks[i].RLock() + otherShard := make(map[K]V, len(other.shards[i])) + for k, v := range other.shards[i] { + otherShard[k] = v + } + other.locks[i].RUnlock() + + s.locks[i].Lock() for k, v := range otherShard { - s.locks[i].Lock() if existing, ok := s.shards[i][k]; ok { s.shards[i][k] = ag(existing, v) } else { s.shards[i][k] = v } - s.locks[i].Unlock() } + s.locks[i].Unlock() }(i) } wg.Wait() diff --git a/worker/sort_test.go b/worker/sort_test.go index 3bf624a3a81..f9e6c083464 100644 --- a/worker/sort_test.go +++ b/worker/sort_test.go @@ -143,11 +143,10 @@ func TestDatetime(t *testing.T) { } type indexMutationInfo struct { - tokenizers []tok.Tokenizer - factorySpecs []*tok.FactoryCreateSpec - edge *pb.DirectedEdge // Represents the original uid -> value edge. - val types.Val - op pb.DirectedEdge_Op + tokenizers []tok.Tokenizer + edge *pb.DirectedEdge // Represents the original uid -> value edge. + val types.Val + op pb.DirectedEdge_Op } func indexTokens(ctx context.Context, info *indexMutationInfo) ([]string, error) { @@ -211,10 +210,11 @@ func TestStringIndexWithLang(t *testing.T) { attr := x.AttrInRootNamespace("name") - // Prepare 400 mutations across 4 threads, 100 per thread (kept modest for stability). + // Prepare 20,000 mutations across 10 threads, 2,000 per thread. Kept modest for + // unit-test runtime/memory; what this exercises is concurrent writers, not volume. const ( threads = 10 - perThread = 20000 + perThread = 2000 total = threads * perThread baseStartTs = uint64(10) ) From fdc535d88a6899ac68578aa7e1456838ffbf0278 Mon Sep 17 00:00:00 2001 From: Shivaji Kharse Date: Fri, 30 Oct 2026 12:40:47 +0530 Subject: [PATCH 44/45] test(worker): pipeline-vs-legacy differential + per-class cases Add to worker/sort_test.go (home of the existing pipeline correctness tests): a shared differential harness that applies identical edges through the legacy ordered path and the mutation pipeline (forced fan-out) and compares the full committed key-space. - TestPipelineLegacyDifferential: 2000-seed fuzz gate. - TestPipeline_StarDeleteThenSet / _CountDuplicateSets / _LangValueDelete / _IndexedListValueChurn: deterministic minimal cases, one per divergence class the fuzz surfaced. All currently red: three pipeline divergences from legacy (star-delete, @lang, indexed-list) and one @count divergence that preliminary analysis attributes to a pre-existing legacy bug. --- worker/sort_test.go | 311 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 311 insertions(+) diff --git a/worker/sort_test.go b/worker/sort_test.go index f9e6c083464..e923410d054 100644 --- a/worker/sort_test.go +++ b/worker/sort_test.go @@ -11,6 +11,7 @@ import ( "math" "math/rand" "os" + "sort" "strconv" "sync" "sync/atomic" @@ -1756,3 +1757,313 @@ func TestRemoveDuplicates(t *testing.T) { require.Equal(t, set, toSet(test.setOut)) } } + +// --------------------------------------------------------------------------- +// Pipeline-vs-legacy differential. +// +// Apply the SAME edges through the legacy ordered path and the mutation +// pipeline (forced fan-out) on a freshly wiped store, then compare the entire +// committed key-space. requireSame asserts the two paths agree. +// +// Below the harness: a broad fuzz gate (TestPipelineLegacyDifferential) plus +// one deterministic, minimal case per divergence class the fuzz surfaced. These +// are permanent correctness tests that must stay in the suite; each failing +// case must go green once its class is fixed, and the pipeline must not be made +// default-on while any of them is red. +// --------------------------------------------------------------------------- + +type diffH struct { + t *testing.T + ps *badger.DB + ts uint64 +} + +func newDiffH(t *testing.T) *diffH { + dir, err := os.MkdirTemp("", "pipediff_") + require.NoError(t, err) + ps, err := badger.OpenManaged(badger.DefaultOptions(dir).WithLoggingLevel(badger.ERROR)) + require.NoError(t, err) + t.Cleanup(func() { _ = ps.Close(); _ = os.RemoveAll(dir) }) + + posting.Init(ps, 0, false) + Init(ps) + require.NoError(t, schema.ParseBytes([]byte(` + sv: string @index(exact) . + lv: [uid] @reverse . + lc: [uid] @reverse @count . + lp: string @lang . + ls: [string] @index(term) . + `), 1)) + x.WorkerConfig.IntraMutationParallelism = x.IntraMutationParallelism{Workers: 8} + x.WorkerConfig.IntraMutationEdgesPerWorker = 1 + return &diffH{t: t, ps: ps, ts: 10} +} + +func (h *diffH) A(p string) string { return x.AttrInRootNamespace(p) } + +func (h *diffH) dump() map[string]string { + out := map[string]string{} + txn := h.ps.NewTransactionAt(math.MaxUint64, false) + defer txn.Discard() + it := txn.NewIterator(badger.DefaultIteratorOptions) + defer it.Close() + for it.Rewind(); it.Valid(); it.Next() { + key := it.Item().KeyCopy(nil) + pk, err := x.Parse(key) + if err != nil || !(pk.IsData() || pk.IsIndex() || pk.IsReverse() || pk.IsCountOrCountRev()) { + continue + } + pl, err := posting.GetNoStore(key, math.MaxUint64) + if err != nil { + continue + } + var parts []string + _ = pl.Iterate(math.MaxUint64, 0, func(p *pb.Posting) error { + parts = append(parts, fmt.Sprintf("u%d:v%x:l%s:t%d", p.Uid, p.Value, p.LangTag, p.PostingType)) + return nil + }) + sort.Strings(parts) + if len(parts) > 0 { + out[fmt.Sprintf("%x", key)] = fmt.Sprintf("%v", parts) + } + } + return out +} + +func cloneEdges(in []*pb.DirectedEdge) []*pb.DirectedEdge { + out := make([]*pb.DirectedEdge, len(in)) + for i, e := range in { + out[i] = &pb.DirectedEdge{ + Entity: e.Entity, + Attr: e.Attr, + Value: append([]byte(nil), e.Value...), + ValueId: e.ValueId, + ValueType: e.ValueType, + Lang: e.Lang, + Op: e.Op, + } + } + return out +} + +func (h *diffH) apply(edges []*pb.DirectedEdge, pipeline bool) map[string]string { + require.NoError(h.t, h.ps.DropAll()) + posting.ResetCache() + posting.Oracle().ResetTxns() + startTs, commitTs := h.ts, h.ts+1 + h.ts += 10 + ctx := schema.GetWriteContext(context.Background()) + txn := posting.Oracle().RegisterStartTs(startTs) + if pipeline { + require.NoError(h.t, posting.NewMutationPipeline(txn).Process(ctx, cloneEdges(edges))) + } else { + es := cloneEdges(edges) + sort.SliceStable(es, func(i, j int) bool { + if es[i].Attr != es[j].Attr { + return es[i].Attr < es[j].Attr + } + return es[i].Entity < es[j].Entity + }) + for _, e := range es { + require.NoError(h.t, runMutation(ctx, e, txn)) + } + } + txn.Update() + w := posting.NewTxnWriter(h.ps) + require.NoError(h.t, txn.CommitToDisk(w, commitTs)) + require.NoError(h.t, w.Flush()) + txn.UpdateCachedKeys(commitTs) + return h.dump() +} + +// requireSame fails if the pipeline's committed state differs from legacy's. +func (h *diffH) requireSame(edges []*pb.DirectedEdge) { + legacy := h.apply(edges, false) + pipe := h.apply(edges, true) + require.Equal(h.t, legacy, pipe, "pipeline diverged from legacy\n edges: %s", fmtEdges(edges)) +} + +// ---- edge builders ---- + +func eSetStr(attr string, uid uint64, v string) *pb.DirectedEdge { + return &pb.DirectedEdge{Entity: uid, Attr: attr, Value: []byte(v), ValueType: pb.Posting_STRING, Op: pb.DirectedEdge_SET} +} +func eDelStr(attr string, uid uint64, v string) *pb.DirectedEdge { + return &pb.DirectedEdge{Entity: uid, Attr: attr, Value: []byte(v), ValueType: pb.Posting_STRING, Op: pb.DirectedEdge_DEL} +} +func eSetUid(attr string, uid, tgt uint64) *pb.DirectedEdge { + return &pb.DirectedEdge{Entity: uid, Attr: attr, ValueId: tgt, ValueType: pb.Posting_UID, Op: pb.DirectedEdge_SET} +} +func eLangSet(attr string, uid uint64, v, lang string) *pb.DirectedEdge { + return &pb.DirectedEdge{Entity: uid, Attr: attr, Value: []byte(v), ValueType: pb.Posting_STRING, Lang: lang, Op: pb.DirectedEdge_SET} +} +func eLangDel(attr string, uid uint64, v, lang string) *pb.DirectedEdge { + return &pb.DirectedEdge{Entity: uid, Attr: attr, Value: []byte(v), ValueType: pb.Posting_STRING, Lang: lang, Op: pb.DirectedEdge_DEL} +} +func eStarDel(attr string, uid uint64) *pb.DirectedEdge { + return &pb.DirectedEdge{Entity: uid, Attr: attr, Value: []byte(x.Star), Op: pb.DirectedEdge_DEL} +} + +func fmtEdges(edges []*pb.DirectedEdge) string { + var b []string + for _, e := range edges { + op := "SET" + if e.Op == pb.DirectedEdge_DEL { + op = "DEL" + } + v := string(e.Value) + if e.ValueType == pb.Posting_UID { + v = fmt.Sprintf("uid(%d)", e.ValueId) + } + lang := "" + if e.Lang != "" { + lang = "@" + e.Lang + } + b = append(b, fmt.Sprintf("%s %s e=%d v=%q%s", op, e.Attr, e.Entity, v, lang)) + } + return fmt.Sprintf("%v", b) +} + +// TestPipelineLegacyDifferential is the broad fuzz gate (discovery + acceptance). +// Star-deletes are excluded here; they have their own deterministic case below. +func TestPipelineLegacyDifferential(t *testing.T) { + h := newDiffH(t) + uids := []uint64{0x1, 0x2, 0x3, 0x4} + strs := []string{"a", "b", "c", "d"} + tgts := []uint64{0x100, 0x101, 0x102} + langs := []string{"en", "de", "fr"} + gen := func(rng *rand.Rand) []*pb.DirectedEdge { + n := 3 + rng.Intn(8) + var edges []*pb.DirectedEdge + for i := 0; i < n; i++ { + uid := uids[rng.Intn(len(uids))] + del := rng.Intn(2) == 1 + switch rng.Intn(5) { + case 0: + e := eSetStr(h.A("sv"), uid, strs[rng.Intn(len(strs))]) + if del { + e.Op = pb.DirectedEdge_DEL + } + edges = append(edges, e) + case 1: + e := eSetUid(h.A("lv"), uid, tgts[rng.Intn(len(tgts))]) + if del { + e.Op = pb.DirectedEdge_DEL + } + edges = append(edges, e) + case 2: + e := eSetUid(h.A("lc"), uid, tgts[rng.Intn(len(tgts))]) + if del { + e.Op = pb.DirectedEdge_DEL + } + edges = append(edges, e) + case 3: + e := eLangSet(h.A("lp"), uid, strs[rng.Intn(len(strs))], langs[rng.Intn(len(langs))]) + if del { + e.Op = pb.DirectedEdge_DEL + } + edges = append(edges, e) + case 4: + e := eSetStr(h.A("ls"), uid, strs[rng.Intn(len(strs))]) + if del { + e.Op = pb.DirectedEdge_DEL + } + edges = append(edges, e) + } + } + return edges + } + const iters = 2000 + mism := 0 + byPred := map[string]int{} + for seed := 0; seed < iters; seed++ { + rng := rand.New(rand.NewSource(int64(seed) + 1)) + edges := gen(rng) + legacy := h.apply(edges, false) + pipe := h.apply(edges, true) + if fmt.Sprintf("%v", legacy) != fmt.Sprintf("%v", pipe) { + mism++ + for k := range symDiff(legacy, pipe) { + if pk, err := x.Parse(mustHex(k)); err == nil { + byPred[pk.Attr]++ + } + } + } + } + t.Logf("fuzz differential (no star): %d/%d seeds diverged; by predicate: %v", mism, iters, byPred) + require.Zero(t, mism, "pipeline must match legacy") +} + +func symDiff(a, b map[string]string) map[string]bool { + out := map[string]bool{} + for k, v := range a { + if b[k] != v { + out[k] = true + } + } + for k, v := range b { + if a[k] != v { + out[k] = true + } + } + return out +} + +func mustHex(s string) []byte { + out := make([]byte, len(s)/2) + for i := 0; i < len(out); i++ { + fmt.Sscanf(s[i*2:i*2+2], "%02x", &out[i]) + } + return out +} + +// Divergence class 1 — star-delete reorder. A SET followed by a star-delete on +// the same (pred,entity) in one proposal. The pipeline runs the delete inline +// and the set via a worker afterward, so the value (and its index) survive when +// they should be cleared. +func TestPipeline_StarDeleteThenSet(t *testing.T) { + h := newDiffH(t) + sv := h.A("sv") + h.requireSame([]*pb.DirectedEdge{ + eSetStr(sv, 1, "a"), + eStarDel(sv, 1), // after this, sv(1) must be empty + }) +} + +// Divergence class 2 — @count with repeated/multiple SETs changing a target's +// in-degree. The @count index diverges from the reverse data (preliminary +// analysis points at legacy being the stale side). +func TestPipeline_CountDuplicateSets(t *testing.T) { + h := newDiffH(t) + lc := h.A("lc") + h.requireSame([]*pb.DirectedEdge{ + eSetUid(lc, 1, 0x100), + eSetUid(lc, 1, 0x100), // duplicate source->target + eSetUid(lc, 3, 0x100), // target 0x100 in-degree 2 + eSetUid(lc, 3, 0x101), // target 0x101 in-degree 1 + }) +} + +// Divergence class 3 — @lang. A value-specific delete on one language tag +// wrongly drops a DIFFERENT language's value in the pipeline. +func TestPipeline_LangValueDelete(t *testing.T) { + h := newDiffH(t) + lp := h.A("lp") + h.requireSame([]*pb.DirectedEdge{ + eLangSet(lp, 1, "b", "en"), + eLangSet(lp, 1, "c", "de"), + eLangDel(lp, 1, "c", "en"), // deletes nothing (en is "b"); de must remain + }) +} + +// Divergence class 4 — value-indexed list. SET/DELETE churn on a +// [string] @index(term) leaves a stale term in the index in the pipeline. +func TestPipeline_IndexedListValueChurn(t *testing.T) { + h := newDiffH(t) + ls := h.A("ls") + h.requireSame([]*pb.DirectedEdge{ + eSetStr(ls, 1, "c"), + eDelStr(ls, 1, "c"), // index entry for "c" must be removed + eSetStr(ls, 1, "d"), + }) +} From f9d861fd7ce322a55ab395ed0b346ee418c15b50 Mon Sep 17 00:00:00 2001 From: Shivaji Kharse Date: Fri, 30 Oct 2026 14:28:44 +0530 Subject: [PATCH 45/45] test(worker): address CodeRabbit review on the differential tests - newDiffH: save and restore x.WorkerConfig.IntraMutationParallelism and IntraMutationEdgesPerWorker in t.Cleanup so the forced fan-out does not leak into other worker tests (removes test-order dependence). - dump: De Morgan the posting-key filter (staticcheck QF1001). - dump: require.NoError on GetNoStore and Iterate so a one-sided read failure fails the test instead of silently dropping a key from the compared key-space. --- worker/sort_test.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/worker/sort_test.go b/worker/sort_test.go index e923410d054..11a671b08ce 100644 --- a/worker/sort_test.go +++ b/worker/sort_test.go @@ -1794,6 +1794,12 @@ func newDiffH(t *testing.T) *diffH { lp: string @lang . ls: [string] @index(term) . `), 1)) + prevPar := x.WorkerConfig.IntraMutationParallelism + prevEPW := x.WorkerConfig.IntraMutationEdgesPerWorker + t.Cleanup(func() { + x.WorkerConfig.IntraMutationParallelism = prevPar + x.WorkerConfig.IntraMutationEdgesPerWorker = prevEPW + }) x.WorkerConfig.IntraMutationParallelism = x.IntraMutationParallelism{Workers: 8} x.WorkerConfig.IntraMutationEdgesPerWorker = 1 return &diffH{t: t, ps: ps, ts: 10} @@ -1810,18 +1816,16 @@ func (h *diffH) dump() map[string]string { for it.Rewind(); it.Valid(); it.Next() { key := it.Item().KeyCopy(nil) pk, err := x.Parse(key) - if err != nil || !(pk.IsData() || pk.IsIndex() || pk.IsReverse() || pk.IsCountOrCountRev()) { + if err != nil || (!pk.IsData() && !pk.IsIndex() && !pk.IsReverse() && !pk.IsCountOrCountRev()) { continue } pl, err := posting.GetNoStore(key, math.MaxUint64) - if err != nil { - continue - } + require.NoError(h.t, err) var parts []string - _ = pl.Iterate(math.MaxUint64, 0, func(p *pb.Posting) error { + require.NoError(h.t, pl.Iterate(math.MaxUint64, 0, func(p *pb.Posting) error { parts = append(parts, fmt.Sprintf("u%d:v%x:l%s:t%d", p.Uid, p.Value, p.LangTag, p.PostingType)) return nil - }) + })) sort.Strings(parts) if len(parts) > 0 { out[fmt.Sprintf("%x", key)] = fmt.Sprintf("%v", parts)