Skip to content

Commit 9df1ed1

Browse files
committed
chore: log ingest queue, merge and store telemetry
1 parent 4f5c155 commit 9df1ed1

4 files changed

Lines changed: 141 additions & 7 deletions

File tree

internal/db/merge.go

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ import (
1414
"container/list"
1515
"context"
1616
"fmt"
17+
"log/slog"
1718
"sort"
19+
"strings"
1820
"sync"
1921

2022
"github.com/ipfs/go-cid"
@@ -76,6 +78,12 @@ func (db *DB) Merge(ctx context.Context, evt event.Merge) error {
7678
// several, so a transaction's cost grows with the square of the events in it.
7779
const mergeChunkSize = 8
7880

81+
// Phases a merge chunk can fail in, reported on the retry-exhaustion log line.
82+
const (
83+
phaseRead = "read"
84+
phaseCommit = "commit"
85+
)
86+
7987
type mergeEntry struct {
8088
evt event.Merge
8189
col *collection
@@ -197,11 +205,25 @@ func (db *DB) txnAttempts() int {
197205
return 1
198206
}
199207

208+
// namedDocs renders the documents a transaction touched, as collection/docID. Badger
209+
// reports conflicts without naming the contended key, so this is the only lead available
210+
// for working out which documents contend with each other.
211+
func namedDocs(entries []mergeEntry) string {
212+
docIDs := make([]string, len(entries))
213+
for i, e := range entries {
214+
docIDs[i] = e.col.Name() + "/" + e.evt.DocID
215+
}
216+
return strings.Join(docIDs, ",")
217+
}
218+
200219
// mergeChunk merges every event of the chunk inside one transaction, retrying the
201220
// whole chunk on transaction conflict. Isolating a failing event is the caller's job.
202221
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.
222+
// Held so that exhausting the retry budget can report the conflict that caused it,
223+
// along with where the last one was raised and which event raised it.
204224
var conflictErr error
225+
var blamed mergeEntry
226+
var phase string
205227
for i := 0; i < db.txnAttempts(); i++ {
206228
txn, err := db.NewTxn(false)
207229
if err != nil {
@@ -212,6 +234,7 @@ func (db *DB) mergeChunk(ctx context.Context, entries []mergeEntry) error {
212234
var mergeErr error
213235
for _, e := range entries {
214236
if mergeErr = db.mergeInTxn(txnCtx, e.col, e.evt); mergeErr != nil {
237+
blamed, phase = e, phaseRead
215238
break
216239
}
217240
}
@@ -229,6 +252,9 @@ func (db *DB) mergeChunk(ctx context.Context, entries []mergeEntry) error {
229252
txn.Discard()
230253
if errors.Is(err, corekv.ErrTxnConflict) {
231254
conflictErr = err
255+
// A commit conflict belongs to the whole transaction and cannot be
256+
// attributed to one event.
257+
blamed, phase = mergeEntry{}, phaseCommit
232258
continue
233259
}
234260
return err
@@ -237,7 +263,20 @@ func (db *DB) mergeChunk(ctx context.Context, entries []mergeEntry) error {
237263
return nil
238264
}
239265

240-
// Nothing was committed, so callers must not treat the events as merged.
266+
// Nothing was committed, so callers must not treat the events as merged. This is the
267+
// one place a conflict becomes data loss, so it is reported even though the retries
268+
// leading to it are not.
269+
fields := []slog.Attr{
270+
corelog.Int("attempts", db.MaxTxnRetries()),
271+
corelog.String("phase", phase),
272+
corelog.String("docIDs", namedDocs(entries)),
273+
}
274+
// Only a read conflict has a single event to blame; at commit it would just repeat
275+
// the chunk, and these lines are already long.
276+
if phase == phaseRead {
277+
fields = append(fields, corelog.String("blamed", namedDocs([]mergeEntry{blamed})))
278+
}
279+
log.InfoContext(ctx, "merge chunk exhausted its retries", fields...)
241280
return client.NewErrMaxTxnRetries(conflictErr)
242281
}
243282

internal/db/p2p/p2p.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ const (
8282
// msgQueueFallbackMaxBytes is the byte budget used when the process has no memory
8383
// limit set, where a fraction of it would be meaningless.
8484
msgQueueFallbackMaxBytes = 1 << 30
85+
86+
// statsInterval is how often queue depth and merge counters are reported. Rates are
87+
// reported per interval rather than per event, which keeps the ingest path quiet
88+
// under load where per-event logging would dominate the output.
89+
statsInterval = 30 * time.Second
8590
)
8691

8792
// queuedMessage is a decoded request paired with its wire size, so the queue's byte
@@ -206,6 +211,19 @@ type P2P struct {
206211
msgQueueBytes atomic.Int64
207212
// msgQueueMaxBytes caps msgQueueBytes. Zero or less disables the byte bound.
208213
msgQueueMaxBytes int64
214+
215+
// stopStats ends the stats reporter. The context outlives Close, so the reporter
216+
// needs a signal of its own.
217+
stopStats chan struct{}
218+
219+
// Counters reported by reportStats and reset on each report, so each line carries
220+
// the rate for that interval rather than a running total.
221+
statDroppedBudget atomic.Int64
222+
statDroppedFull atomic.Int64
223+
statMergedDocs atomic.Int64
224+
statDroppedDocs atomic.Int64
225+
statBatches atomic.Int64
226+
statBatchFailures atomic.Int64
209227
}
210228

211229
// pushLogCommProcessor implements CommProcessor for push log functionality
@@ -263,12 +281,14 @@ func New(
263281
topicPeerCounts: make(map[string]int),
264282
msgQueue: make(chan queuedMessage, msgQueueSize),
265283
msgQueueMaxBytes: queueByteBudget(),
284+
stopStats: make(chan struct{}),
266285
}
267286

268287
for i := 0; i < dagSyncWorkers; i++ {
269288
p.msgWorkers.Add(1)
270289
go p.processMessageWorker()
271290
}
291+
go p.reportStats()
272292

273293
p.replicatorProtocol = protocol.NewCommChannel(host, "rep", &pushLogCommProcessor{p2p: &p})
274294
p.batcher = newPubsubBatcher(host.ID(), func(topic string, data []byte) error {
@@ -661,6 +681,7 @@ func (p *P2P) docIDsForBlockCID(
661681
func (p *P2P) pubSubMessageHandler(from string, topic string, msg []byte) ([]byte, error) {
662682
size := int64(len(msg))
663683
if !p.claimQueueBytes(size) {
684+
p.statDroppedBudget.Add(1)
664685
log.Info("pubsub message queue over byte budget, dropping message",
665686
corelog.Any("topic", topic),
666687
corelog.Int64("bytes", size),
@@ -681,6 +702,7 @@ func (p *P2P) pubSubMessageHandler(from string, topic string, msg []byte) ([]byt
681702
p.releaseQueueBytes(size)
682703
default:
683704
p.releaseQueueBytes(size)
705+
p.statDroppedFull.Add(1)
684706
log.Info("pubsub message queue full, dropping message", corelog.Any("topic", topic))
685707
}
686708
return nil, nil
@@ -707,6 +729,35 @@ func (p *P2P) releaseQueueBytes(size int64) {
707729
p.msgQueueBytes.Add(-size)
708730
}
709731

732+
// reportStats periodically logs queue occupancy and merge outcomes. Queue depth and the
733+
// split between merged and dropped documents are otherwise only visible from a heap
734+
// profile, which is too invasive to take routinely.
735+
func (p *P2P) reportStats() {
736+
ticker := time.NewTicker(statsInterval)
737+
defer ticker.Stop()
738+
for {
739+
select {
740+
case <-p.ctx.Done():
741+
return
742+
case <-p.stopStats:
743+
return
744+
case <-ticker.C:
745+
log.Info("p2p stats",
746+
corelog.Int("queueDepth", len(p.msgQueue)),
747+
corelog.Int("queueSlots", msgQueueSize),
748+
corelog.Int64("queueBytes", p.msgQueueBytes.Load()),
749+
corelog.Int64("queueBudget", p.msgQueueMaxBytes),
750+
corelog.Int64("droppedOverBudget", p.statDroppedBudget.Swap(0)),
751+
corelog.Int64("droppedQueueFull", p.statDroppedFull.Swap(0)),
752+
corelog.Int64("batches", p.statBatches.Swap(0)),
753+
corelog.Int64("batchFailures", p.statBatchFailures.Swap(0)),
754+
corelog.Int64("docsMerged", p.statMergedDocs.Swap(0)),
755+
corelog.Int64("docsDropped", p.statDroppedDocs.Swap(0)),
756+
)
757+
}
758+
}
759+
}
760+
710761
// processMessageWorker is a long-lived goroutine that drains p.msgQueue and
711762
// calls processPushlogRequest for each message. dagSyncWorkers of these run
712763
// concurrently, bounding the goroutine count regardless of inbound message rate.
@@ -772,7 +823,16 @@ func (p *P2P) processPushlogRequest(
772823
merges[i] = r.merge
773824
}
774825
merged, err := p.db.MergeBatchWithTxn(ctx, merges)
826+
p.statBatches.Add(1)
827+
for _, ok := range merged {
828+
if ok {
829+
p.statMergedDocs.Add(1)
830+
} else {
831+
p.statDroppedDocs.Add(1)
832+
}
833+
}
775834
if err != nil {
835+
p.statBatchFailures.Add(1)
776836
log.ErrorE("Failed to merge documents in batch", err,
777837
corelog.String("PeerID", req.SenderID),
778838
corelog.Int("Documents", len(merges)))
@@ -877,8 +937,10 @@ func (p *P2P) processPushlogRequest(
877937
CollectionID: req.CollectionID,
878938
}
879939
if err = p.db.Merge(ctx, mergeEvt); err != nil {
940+
p.statDroppedDocs.Add(1)
880941
return err
881942
}
943+
p.statMergedDocs.Add(1)
882944

883945
// Notify bus subscribers and the network of peers that we have a new document available.
884946
updateEvt := event.Update{
@@ -1144,6 +1206,7 @@ func (pq *processQueue) close() {
11441206
// and waits for all worker goroutines to exit.
11451207
// It should be called once when the P2P subsystem is shutting down.
11461208
func (p *P2P) Close() {
1209+
close(p.stopStats)
11471210
close(p.msgQueue)
11481211
done := make(chan struct{})
11491212
go func() {

internal/db/p2p/p2p_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ func TestPubSubMessageHandler_OverBudgetDropsBeforeDecode(t *testing.T) {
7777
assert.Nil(t, resp)
7878
assert.Empty(t, p.msgQueue)
7979
assert.Equal(t, int64(0), p.msgQueueBytes.Load())
80+
assert.Equal(t, int64(1), p.statDroppedBudget.Load())
81+
assert.Equal(t, int64(0), p.statDroppedFull.Load())
8082
}
8183

8284
func TestPubSubMessageHandler_ByteBudgetReleasedAfterProcessing(t *testing.T) {
@@ -142,6 +144,32 @@ func TestProcessMessageWorker_ReleasesByteBudget(t *testing.T) {
142144
"the worker must return the budget the handler reserved")
143145
}
144146

147+
// A full queue and an exhausted budget are separate drop reasons, and the stats line is
148+
// only useful if a drop is attributed to the one that caused it.
149+
func TestPubSubMessageHandler_QueueFullCountedSeparately(t *testing.T) {
150+
msg, err := cbor.Marshal(protocol.PushLogRequest{DocID: "docID"})
151+
assert.NoError(t, err)
152+
153+
// Budget large enough to stay out of the way, so only the slot count can reject.
154+
p := &P2P{
155+
ctx: context.Background(),
156+
host: &SimpleMockHost{},
157+
msgQueue: make(chan queuedMessage, 1),
158+
msgQueueMaxBytes: 1 << 20,
159+
}
160+
161+
_, err = p.pubSubMessageHandler("sender", "topic", msg)
162+
assert.NoError(t, err)
163+
164+
_, err = p.pubSubMessageHandler("sender", "topic", msg)
165+
assert.NoError(t, err)
166+
167+
assert.Equal(t, int64(1), p.statDroppedFull.Load())
168+
assert.Equal(t, int64(0), p.statDroppedBudget.Load())
169+
// The dropped message must not keep holding its reservation.
170+
assert.Equal(t, int64(len(msg)), p.msgQueueBytes.Load())
171+
}
172+
145173
func TestQueueByteBudget_DerivesFromMemoryLimit(t *testing.T) {
146174
original := debug.SetMemoryLimit(-1)
147175
t.Cleanup(func() { debug.SetMemoryLimit(original) })

node/store_badger.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ func (s *badgerStore) runValueLogGC() {
145145
// reclaim, an error occurs, or the store starts closing. Each successful call
146146
// rewrites one file; ErrNoRewrite means no file was eligible and ends the loop.
147147
// Any other error is logged, since it means GC could not make progress.
148+
//
149+
// The on-disk size is reported on every pass, reclaimed or not, so whether the store is
150+
// bounded can be read from the log without shell access to the volume.
148151
func (s *badgerStore) reclaimValueLog() {
149152
start := time.Now()
150153
reclaimed := 0
@@ -162,9 +165,10 @@ func (s *badgerStore) reclaimValueLog() {
162165
}
163166
reclaimed++
164167
}
165-
if reclaimed > 0 {
166-
log.Info("Reclaimed badger value log files",
167-
corelog.Int("files", reclaimed),
168-
corelog.Duration("duration", time.Since(start)))
169-
}
168+
lsm, vlog := s.db.Size()
169+
log.Info("Badger value log GC",
170+
corelog.Int("files", reclaimed),
171+
corelog.Int64("lsmBytes", lsm),
172+
corelog.Int64("vlogBytes", vlog),
173+
corelog.Duration("duration", time.Since(start)))
170174
}

0 commit comments

Comments
 (0)