Skip to content

Commit ee8673f

Browse files
committed
chore: count and report merge outcomes
1 parent 27eb8f8 commit ee8673f

4 files changed

Lines changed: 429 additions & 13 deletions

File tree

internal/db/db.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@ type DB struct {
125125
lockSet *lock.LockSet
126126

127127
collectionRepository *description.CollectionRepository
128+
129+
// stats are the merge-path counters reported on an interval by reportMergeStats.
130+
stats *mergeStats
128131
}
129132

130133
var _ client.TxnStore = (*DB)(nil)
@@ -176,6 +179,7 @@ func newDB(
176179
p2pBlockSyncTimeout: cfg.P2PBlockSyncTimeout,
177180
lockSet: lockSet,
178181
collectionRepository: description.NewColCache(lockSet, datastore.NewUnsafeDatastore(rootstore)),
182+
stats: &mergeStats{},
179183
}
180184

181185
lensRuntime, err := newLensRuntime(LensRuntimeType(cfg.LensRuntime))
@@ -233,6 +237,8 @@ func newDB(
233237
}
234238
})
235239

240+
go db.reportMergeStats(db.ctx)
241+
236242
return db, nil
237243
}
238244

internal/db/merge.go

Lines changed: 66 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"context"
1616
"fmt"
1717
"sort"
18+
"strings"
1819
"sync"
1920

2021
"github.com/ipfs/go-cid"
@@ -76,6 +77,12 @@ func (db *DB) Merge(ctx context.Context, evt event.Merge) error {
7677
// several, so a bigger chunk sorts a bigger set more times.
7778
const mergeChunkSize = 8
7879

80+
// Phases a merge chunk can fail in, reported on the retry-exhaustion log line.
81+
const (
82+
phaseRead = "read"
83+
phaseCommit = "commit"
84+
)
85+
7986
type mergeEntry struct {
8087
evt event.Merge
8188
col *collection
@@ -106,6 +113,7 @@ func (db *DB) MergeBatchWithTxn(ctx context.Context, merges []event.Merge) ([]bo
106113
col, err := getCollectionFromCollectionID(ctx, db, evt.CollectionID)
107114
if err != nil {
108115
errs = append(errs, NewErrMergeEventDropped(err, evt.DocID, evt.Cid.String()))
116+
db.stats.markDropped(dropCollection)
109117
continue
110118
}
111119
entries = append(entries, mergeEntry{evt: evt, col: col, index: i})
@@ -178,6 +186,7 @@ func (db *DB) MergeBatchWithTxn(ctx context.Context, merges []event.Merge) ([]bo
178186
for i := range chunk {
179187
if err := db.mergeChunk(ctx, chunk[i:i+1]); err != nil {
180188
errs = append(errs, NewErrMergeEventDropped(err, chunk[i].evt.DocID, chunk[i].evt.Cid.String()))
189+
db.stats.markDropped(mergeDropReason(err))
181190
continue
182191
}
183192
db.publishMergeComplete(chunk[i : i+1])
@@ -197,29 +206,50 @@ func (db *DB) txnAttempts() int {
197206
return 1
198207
}
199208

209+
// namedDocs renders the documents a transaction touched, as collection/docID. Badger
210+
// reports conflicts without naming the contended key, so this is the only lead available
211+
// for working out which documents contend with each other.
212+
func namedDocs(entries []mergeEntry) string {
213+
docIDs := make([]string, len(entries))
214+
for i, e := range entries {
215+
docIDs[i] = e.col.Name() + "/" + e.evt.DocID
216+
}
217+
return strings.Join(docIDs, ",")
218+
}
219+
200220
// mergeChunk merges every event of the chunk inside one transaction, retrying the
201221
// whole chunk on transaction conflict. Isolating a failing event is the caller's job.
202222
func (db *DB) mergeChunk(ctx context.Context, entries []mergeEntry) error {
203-
// Held so that exhausting the retry budget can report the conflict that caused it.
223+
// Held so that exhausting the retry budget can report the conflict that caused it and
224+
// where the last one was raised.
204225
var conflictErr error
226+
var phase string
227+
// Whether each event created its document, kept until the transaction commits so a
228+
// retried attempt does not count its events twice.
229+
creates := make([]bool, 0, len(entries))
205230
for i := 0; i < db.txnAttempts(); i++ {
206231
txn, err := db.NewTxn(false)
207232
if err != nil {
208233
return err
209234
}
210235
txnCtx := InitContext(ctx, txn)
211236

237+
creates = creates[:0]
212238
var mergeErr error
213239
for _, e := range entries {
214-
if mergeErr = db.mergeInTxn(txnCtx, e.col, e.evt); mergeErr != nil {
240+
isCreate, err := db.mergeInTxn(txnCtx, e.col, e.evt)
241+
if err != nil {
242+
mergeErr, phase = err, phaseRead
215243
break
216244
}
245+
creates = append(creates, isCreate)
217246
}
218247

219248
if mergeErr != nil {
220249
txn.Discard()
221250
if errors.Is(mergeErr, corekv.ErrTxnConflict) {
222251
conflictErr = mergeErr
252+
db.stats.chunkConflicts.Add(1)
223253
continue
224254
}
225255
return mergeErr
@@ -229,15 +259,29 @@ func (db *DB) mergeChunk(ctx context.Context, entries []mergeEntry) error {
229259
txn.Discard()
230260
if errors.Is(err, corekv.ErrTxnConflict) {
231261
conflictErr = err
262+
db.stats.chunkConflicts.Add(1)
263+
phase = phaseCommit
232264
continue
233265
}
234266
return err
235267
}
236268

269+
for _, isCreate := range creates {
270+
db.stats.markCreateOrUpdate(isCreate)
271+
}
237272
return nil
238273
}
239274

240-
// Nothing was committed, so callers must not treat the events as merged.
275+
// The chunk used its whole retry budget without committing. The caller then re-runs it
276+
// one event at a time, so this counts conflict pressure rather than loss. What was
277+
// actually lost is named in the caller's error.
278+
db.stats.markExhausted()
279+
280+
log.InfoContext(ctx, "merge chunk exhausted its retries",
281+
corelog.Int("attempts", db.txnAttempts()),
282+
corelog.String("phase", phase),
283+
corelog.String("docIDs", namedDocs(entries)),
284+
)
241285
return client.NewErrMaxTxnRetries(conflictErr)
242286
}
243287

@@ -254,13 +298,15 @@ func (db *DB) executeMerge(ctx context.Context, col *collection, dagMerge event.
254298
}
255299
defer txn.Discard()
256300

257-
if err := db.mergeInTxn(ctx, col, dagMerge); err != nil {
301+
isCreate, err := db.mergeInTxn(ctx, col, dagMerge)
302+
if err != nil {
258303
return err
259304
}
260305

261306
if err := txn.Commit(); err != nil {
262307
return err
263308
}
309+
db.stats.markCreateOrUpdate(isCreate)
264310

265311
// send a complete event so we can track merges in the integration tests
266312
db.events.Publish(event.NewMessage(event.MergeCompleteName, event.MergeComplete{Merge: dagMerge}))
@@ -269,40 +315,47 @@ func (db *DB) executeMerge(ctx context.Context, col *collection, dagMerge event.
269315

270316
// mergeInTxn executes the merge logic for a single event using the transaction already
271317
// present on ctx. It does not commit; the caller is responsible for committing.
272-
func (db *DB) mergeInTxn(ctx context.Context, col *collection, dagMerge event.Merge) error {
318+
//
319+
// Reports whether the event created the document rather than updating one already held,
320+
// which the caller counts once the transaction commits.
321+
func (db *DB) mergeInTxn(ctx context.Context, col *collection, dagMerge event.Merge) (bool, error) {
273322
key, exists, err := getDocHeadstoreKey(ctx, col, dagMerge.DocID)
274323
if err != nil {
275-
return err
324+
return false, err
276325
}
277326

278327
mt := newMergeTarget()
279328
if exists {
280329
mt, err = getHeadsAsMergeTarget(ctx, key)
281330
if err != nil {
282-
return NewErrGetMergeTargetHeads(err, dagMerge.DocID, string(key.Bytes()))
331+
return false, NewErrGetMergeTargetHeads(err, dagMerge.DocID, string(key.Bytes()))
283332
}
284333
}
285334

286-
mp, err := db.newMergeProcessor(ctx, col, len(mt.heads) == 0)
335+
// No local heads means the merge is creating the document rather than updating one
336+
// that already exists here.
337+
newDocCreateMode := len(mt.heads) == 0
338+
339+
mp, err := db.newMergeProcessor(ctx, col, newDocCreateMode)
287340
if err != nil {
288-
return err
341+
return false, err
289342
}
290343

291344
if err = mp.loadComposites(ctx, dagMerge.Cid, mt); err != nil {
292-
return NewErrLoadComposites(err, dagMerge.Cid.String(), dagMerge.DocID)
345+
return false, NewErrLoadComposites(err, dagMerge.Cid.String(), dagMerge.DocID)
293346
}
294347

295348
if err = mp.mergeComposites(ctx); err != nil {
296-
return NewErrMergeComposites(err, dagMerge.DocID)
349+
return false, NewErrMergeComposites(err, dagMerge.DocID)
297350
}
298351

299352
for docID, oldDoc := range mp.docIDs {
300353
if err = syncIndexedDoc(ctx, docID, mp.col, oldDoc); err != nil {
301-
return NewErrSyncIndexedDoc(err, docID.String())
354+
return false, NewErrSyncIndexedDoc(err, docID.String())
302355
}
303356
}
304357

305-
return nil
358+
return newDocCreateMode, nil
306359
}
307360

308361
const maxConcurrentMerges = 32

internal/db/stats.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// Copyright 2026 Democratized Data Foundation
2+
//
3+
// Use of this software is governed by the Business Source License
4+
// included in the file licenses/BSL.txt.
5+
//
6+
// As of the Change Date specified in that file, in accordance with
7+
// the Business Source License, use of this software will be governed
8+
// by the Apache License, Version 2.0, included in the file
9+
// licenses/APL.txt.
10+
11+
package db
12+
13+
import (
14+
"context"
15+
"log/slog"
16+
"maps"
17+
"slices"
18+
"sync"
19+
"sync/atomic"
20+
"time"
21+
22+
ipld "github.com/ipfs/go-ipld-format"
23+
24+
"github.com/sourcenetwork/corelog"
25+
26+
"github.com/sourcenetwork/defradb/client"
27+
"github.com/sourcenetwork/defradb/errors"
28+
)
29+
30+
// mergeStatsInterval matches the p2p reporter so the two lines of a single interval line
31+
// up in the log.
32+
const mergeStatsInterval = 30 * time.Second
33+
34+
// mergeStats are the merge-path counters reported once per interval and reset on report,
35+
// so each line carries the rate for that interval rather than a running total.
36+
//
37+
// Counting is an atomic add on paths that already do storage work, so it is not a cost
38+
// worth gating. Nothing here builds a string or allocates until the reporter runs.
39+
type mergeStats struct {
40+
// creates and updates split merges by whether the document already had heads locally.
41+
// A merge with no local heads is creating the document.
42+
creates atomic.Int64
43+
updates atomic.Int64
44+
45+
// chunkConflicts counts merge attempts abandoned for a transaction conflict, whether
46+
// or not a later attempt succeeded. chunkExhausted counts the chunks that ran out of
47+
// attempts, which is where a conflict becomes a dropped document.
48+
chunkConflicts atomic.Int64
49+
chunkExhausted atomic.Int64
50+
51+
// dropReasons counts dropped events by cause. A dropped event is a document this node
52+
// did not store, and the causes need different responses, so the total on its own does
53+
// not say what to do.
54+
dropMu sync.Mutex
55+
dropReasons map[string]int64
56+
}
57+
58+
// Causes a merge event can be dropped for. An unrecognised cause counts as dropOther, so
59+
// that staying non-zero means this list has fallen behind the code.
60+
const (
61+
dropMissingBlock = "missingBlock"
62+
dropUniqueIndex = "uniqueIndex"
63+
dropRetryExhausted = "retryExhausted"
64+
dropCollection = "collectionNotFound"
65+
dropOther = "other"
66+
)
67+
68+
// mergeDropReason names why an event was dropped: the sender could not supply the DAG,
69+
// two documents claim one indexed value, or the write kept losing to a concurrent one.
70+
func mergeDropReason(err error) string {
71+
switch {
72+
case errors.Is(err, ipld.ErrNotFound{}):
73+
return dropMissingBlock
74+
case errors.Is(err, errors.New(errCanNotIndexNonUniqueFields)):
75+
return dropUniqueIndex
76+
case errors.Is(err, client.NewErrMaxTxnRetries(nil)):
77+
return dropRetryExhausted
78+
default:
79+
return dropOther
80+
}
81+
}
82+
83+
// markDropped records an event that did not merge, under the given cause.
84+
func (s *mergeStats) markDropped(reason string) {
85+
if s == nil {
86+
return
87+
}
88+
s.dropMu.Lock()
89+
defer s.dropMu.Unlock()
90+
if s.dropReasons == nil {
91+
s.dropReasons = make(map[string]int64)
92+
}
93+
s.dropReasons[reason]++
94+
}
95+
96+
// drainDropReasons returns the causes counted since the last call and resets them.
97+
func (s *mergeStats) drainDropReasons() []slog.Attr {
98+
s.dropMu.Lock()
99+
defer s.dropMu.Unlock()
100+
if len(s.dropReasons) == 0 {
101+
return nil
102+
}
103+
reasons := slices.Sorted(maps.Keys(s.dropReasons))
104+
attrs := make([]slog.Attr, 0, len(reasons))
105+
for _, reason := range reasons {
106+
attrs = append(attrs, corelog.Int64(reason, s.dropReasons[reason]))
107+
}
108+
clear(s.dropReasons)
109+
return attrs
110+
}
111+
112+
// markCreateOrUpdate records whether a merge is creating the document or updating one that
113+
// already exists locally.
114+
func (s *mergeStats) markCreateOrUpdate(isCreate bool) {
115+
if s == nil {
116+
return
117+
}
118+
if isCreate {
119+
s.creates.Add(1)
120+
return
121+
}
122+
s.updates.Add(1)
123+
}
124+
125+
// markExhausted records a chunk that ran out of attempts.
126+
func (s *mergeStats) markExhausted() {
127+
if s == nil {
128+
return
129+
}
130+
s.chunkExhausted.Add(1)
131+
}
132+
133+
// reportMergeStats logs the merge counters once per interval until the database context is
134+
// cancelled. Rates are reported per interval rather than per event, which keeps the merge
135+
// path quiet under load where per-event logging would dominate the output.
136+
func (db *DB) reportMergeStats(ctx context.Context) {
137+
ticker := time.NewTicker(mergeStatsInterval)
138+
defer ticker.Stop()
139+
for {
140+
select {
141+
case <-ctx.Done():
142+
return
143+
case <-ticker.C:
144+
db.stats.report()
145+
}
146+
}
147+
}
148+
149+
func (s *mergeStats) report() {
150+
creates := s.creates.Swap(0)
151+
updates := s.updates.Swap(0)
152+
conflicts := s.chunkConflicts.Swap(0)
153+
exhausted := s.chunkExhausted.Swap(0)
154+
155+
// Nothing to report on an idle database, or one doing only local writes.
156+
if creates != 0 || updates != 0 || conflicts != 0 || exhausted != 0 {
157+
log.Info("merge stats",
158+
corelog.Int64("creates", creates),
159+
corelog.Int64("updates", updates),
160+
corelog.Int64("chunkConflicts", conflicts),
161+
corelog.Int64("exhausted", exhausted),
162+
)
163+
}
164+
165+
// Its own line, so an interval with no drops stays quiet and the line lists only the
166+
// causes that occurred.
167+
if drops := s.drainDropReasons(); len(drops) > 0 {
168+
log.Error("merge drops", drops...)
169+
}
170+
}

0 commit comments

Comments
 (0)