Skip to content

Commit 9330a2b

Browse files
committed
perf: purge in chunks of 8, not 100
1 parent df5578f commit 9330a2b

2 files changed

Lines changed: 139 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 cost grows faster than chunk size.
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: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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,
54+
// matching the shape of a heavily indexed production collection. The index count is what
55+
// decides how many writes each purged document adds to its transaction, and that is the
56+
// term the pending-write sort is quadratic in.
57+
func setupIndexedCollection(b *testing.B, ctx context.Context, db *DB) client.Collection {
58+
b.Helper()
59+
60+
_, err := db.AddCollection(ctx, `type Record {
61+
hash: String
62+
blockNumber: Int
63+
groupID: String
64+
payload: String
65+
}`)
66+
require.NoError(b, err)
67+
68+
col, err := db.GetCollectionByName(ctx, "Record")
69+
require.NoError(b, err)
70+
71+
for _, req := range []client.NewIndexRequest{
72+
{Fields: []client.IndexedFieldDescription{{Name: "hash"}}, Unique: true},
73+
{Fields: []client.IndexedFieldDescription{{Name: "blockNumber"}}},
74+
{Fields: []client.IndexedFieldDescription{{Name: "groupID"}}},
75+
} {
76+
_, err := col.NewIndex(ctx, req)
77+
require.NoError(b, err)
78+
}
79+
80+
return col
81+
}
82+
83+
// addRecords writes n documents and returns their IDs. The payload field is unindexed and
84+
// exists only to give each document enough size to reach the value log.
85+
func addRecords(b *testing.B, ctx context.Context, col client.Collection, n int) []client.DocID {
86+
b.Helper()
87+
88+
docIDs := make([]client.DocID, 0, n)
89+
for i := range n {
90+
doc, err := client.NewDocFromJSON(ctx, fmt.Appendf(nil,
91+
`{"hash":"0x%064x","blockNumber":%d,"groupID":"g-%d","payload":%q}`,
92+
i, i/200, i/200, fmt.Sprintf("%0512d", i)), col.Version())
93+
require.NoError(b, err)
94+
require.NoError(b, col.AddDocument(ctx, doc))
95+
docIDs = append(docIDs, doc.ID())
96+
}
97+
98+
return docIDs
99+
}
100+
101+
// BenchmarkPurgeByDocIDsChunkSize measures how long it takes to purge a fixed set of
102+
// documents as the number of them sharing a transaction changes. purgeChunkSize is not a
103+
// free choice: the per-chunk cost grows with the square of the chunk, so total purge time
104+
// is close to linear in it, and this reports that curve.
105+
//
106+
// pruneHistory is on because that is the deployed setting and it adds the per-document DAG
107+
// walk. Documents are written locally, so their DAGs are one commit deep; a document built
108+
// up over many merges walks further and costs more than this measures.
109+
func BenchmarkPurgeByDocIDsChunkSize(b *testing.B) {
110+
const docs = 2000
111+
112+
for _, chunkSize := range []int{8, 25, 50, 100, 200} {
113+
b.Run(fmt.Sprintf("chunk=%d", chunkSize), func(b *testing.B) {
114+
ctx := context.Background()
115+
116+
for b.Loop() {
117+
b.StopTimer()
118+
db, closeDB := newOnDiskDB(b, ctx)
119+
col := setupIndexedCollection(b, ctx, db)
120+
docIDs := addRecords(b, ctx, col, docs)
121+
concrete, ok := col.(*collection)
122+
require.True(b, ok)
123+
b.StartTimer()
124+
125+
for i := 0; i < len(docIDs); i += chunkSize {
126+
end := min(i+chunkSize, len(docIDs))
127+
require.NoError(b, concrete.purgeChunk(ctx, docIDs[i:end], true))
128+
}
129+
130+
b.StopTimer()
131+
closeDB()
132+
b.StartTimer()
133+
}
134+
})
135+
}
136+
}

0 commit comments

Comments
 (0)