Skip to content

Commit d97a72d

Browse files
committed
perf: purge in chunks of 8, not 100
1 parent 5f94e2c commit d97a72d

2 files changed

Lines changed: 137 additions & 2 deletions

File tree

internal/db/collection_purge.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@ import (
2727

2828
// purgeChunkSize is the number of documents purged per transaction when no caller
2929
// transaction is supplied. It keeps each commit well under the store's per-transaction
30-
// size limit.
31-
const purgeChunkSize = 100
30+
// size limit. Badger re-sorts the transaction's pending writes on every iterator open,
31+
// and each document opens several, so a bigger chunk sorts a bigger set more times.
32+
const purgeChunkSize = 8
3233

3334
// PurgeByDocIDs permanently removes all state for the given documents from this node:
3435
// datastore values, headstore entries, and, when pruneHistory is true, every blockstore
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
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+
"fmt"
16+
"os"
17+
"testing"
18+
19+
badgerds "github.com/dgraph-io/badger/v4"
20+
"github.com/stretchr/testify/require"
21+
22+
"github.com/sourcenetwork/corekv/badger"
23+
24+
"github.com/sourcenetwork/defradb/client"
25+
acpDB "github.com/sourcenetwork/defradb/internal/db/acp"
26+
)
27+
28+
// newOnDiskDB opens a DB on a badger directory with stock options. It returns a close func
29+
// rather than registering cleanup so a caller can release it per iteration. An in-memory
30+
// store has no value log and different write amplification, so it cannot stand in for a
31+
// deployed node when the cost of a write path is what is being measured.
32+
func newOnDiskDB(b *testing.B, ctx context.Context) (*DB, func()) {
33+
b.Helper()
34+
35+
dir, err := os.MkdirTemp("", "purgebench")
36+
require.NoError(b, err)
37+
38+
rootstore, err := badger.NewDatastore(dir, badgerds.DefaultOptions(dir))
39+
require.NoError(b, err)
40+
41+
adminInfo, err := acpDB.NewNACInfo(ctx, "", false)
42+
require.NoError(b, err)
43+
44+
db, err := newDB(ctx, rootstore, adminInfo)
45+
require.NoError(b, err)
46+
47+
return db, func() {
48+
db.Close()
49+
_ = os.RemoveAll(dir)
50+
}
51+
}
52+
53+
// setupIndexedCollection builds a collection with three secondary indexes, one unique. The
54+
// index count decides how many writes each purged document adds to its transaction, which
55+
// is what the pending-write sort scales with.
56+
func setupIndexedCollection(b *testing.B, ctx context.Context, db *DB) client.Collection {
57+
b.Helper()
58+
59+
_, err := db.AddCollection(ctx, `type Record {
60+
hash: String
61+
blockNumber: Int
62+
groupID: String
63+
payload: String
64+
}`)
65+
require.NoError(b, err)
66+
67+
col, err := db.GetCollectionByName(ctx, "Record")
68+
require.NoError(b, err)
69+
70+
for _, req := range []client.NewIndexRequest{
71+
{Fields: []client.IndexedFieldDescription{{Name: "hash"}}, Unique: true},
72+
{Fields: []client.IndexedFieldDescription{{Name: "blockNumber"}}},
73+
{Fields: []client.IndexedFieldDescription{{Name: "groupID"}}},
74+
} {
75+
_, err := col.NewIndex(ctx, req)
76+
require.NoError(b, err)
77+
}
78+
79+
return col
80+
}
81+
82+
// addRecords writes n documents and returns their IDs. The payload field is unindexed and
83+
// exists only to give each document enough size to reach the value log.
84+
func addRecords(b *testing.B, ctx context.Context, col client.Collection, n int) []client.DocID {
85+
b.Helper()
86+
87+
docIDs := make([]client.DocID, 0, n)
88+
for i := range n {
89+
doc, err := client.NewDocFromJSON(ctx, fmt.Appendf(nil,
90+
`{"hash":"0x%064x","blockNumber":%d,"groupID":"g-%d","payload":%q}`,
91+
i, i/200, i/200, fmt.Sprintf("%0512d", i)), col.Version())
92+
require.NoError(b, err)
93+
require.NoError(b, col.AddDocument(ctx, doc))
94+
docIDs = append(docIDs, doc.ID())
95+
}
96+
97+
return docIDs
98+
}
99+
100+
// BenchmarkPurgeByDocIDsChunkSize measures how long it takes to purge a fixed set of
101+
// documents as the number of them sharing a transaction changes, so purgeChunkSize can be
102+
// chosen from a measurement rather than assumed.
103+
//
104+
// pruneHistory is on because that is the deployed setting and it adds the per-document DAG
105+
// walk. Documents are written locally, so their DAGs are one commit deep; a document built
106+
// up over many merges walks further and costs more than this measures.
107+
func BenchmarkPurgeByDocIDsChunkSize(b *testing.B) {
108+
const docs = 2000
109+
110+
for _, chunkSize := range []int{8, 25, 50, 100, 200} {
111+
b.Run(fmt.Sprintf("chunk=%d", chunkSize), func(b *testing.B) {
112+
ctx := context.Background()
113+
114+
for b.Loop() {
115+
b.StopTimer()
116+
db, closeDB := newOnDiskDB(b, ctx)
117+
col := setupIndexedCollection(b, ctx, db)
118+
docIDs := addRecords(b, ctx, col, docs)
119+
concrete, ok := col.(*collection)
120+
require.True(b, ok)
121+
b.StartTimer()
122+
123+
for i := 0; i < len(docIDs); i += chunkSize {
124+
end := min(i+chunkSize, len(docIDs))
125+
require.NoError(b, concrete.purgeChunk(ctx, docIDs[i:end], true))
126+
}
127+
128+
b.StopTimer()
129+
closeDB()
130+
b.StartTimer()
131+
}
132+
})
133+
}
134+
}

0 commit comments

Comments
 (0)