Skip to content

Commit 5850f2a

Browse files
headerfs: allow deleting multi indices from store
1 parent 6c68b9e commit 5850f2a

File tree

4 files changed

+256
-104
lines changed

4 files changed

+256
-104
lines changed

headerfs/index.go

Lines changed: 84 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package headerfs
33
import (
44
"bytes"
55
"encoding/binary"
6+
"errors"
67
"fmt"
78
"sort"
89

@@ -320,11 +321,25 @@ func (h *headerIndex) chainTipWithTx(tx walletdb.ReadTx) (*chainhash.Hash,
320321
return tipHash, tipHeight, nil
321322
}
322323

323-
// truncateIndex truncates the index for a particular header type by a single
324-
// header entry. The passed newTip pointer should point to the hash of the new
325-
// chain tip. Optionally, if the entry is to be deleted as well, then the
326-
// remove flag should be set to true.
327-
func (h *headerIndex) truncateIndex(newTip *chainhash.Hash, remove bool) error {
324+
// truncateIndices truncates the index for a particular header type by removing
325+
// a set of header entries. The passed newTip pointer should point to the hash
326+
// of the new chain tip. The blockHeadersToTruncate contains the hashes of all
327+
// block headers that should be removed from the index, which are the block
328+
// headers after the new tip. Optionally, if the entries are to be deleted as
329+
// well, then the remove flag should be set to true.
330+
func (h *headerIndex) truncateIndices(newTip *chainhash.Hash,
331+
blockHeadersToTruncate []*chainhash.Hash, remove bool) error {
332+
333+
if remove && len(blockHeadersToTruncate) == 0 {
334+
return errors.New("remove flag set but headers to truncate " +
335+
"beyond new tip not provided")
336+
}
337+
338+
if !remove && len(blockHeadersToTruncate) != 0 {
339+
return errors.New("headers to truncate beyond new tip " +
340+
"provided but remove flag not set")
341+
}
342+
328343
return walletdb.Update(h.db, func(tx walletdb.ReadWriteTx) error {
329344
rootBucket := tx.ReadWriteBucket(indexBucket)
330345

@@ -338,13 +353,13 @@ func (h *headerIndex) truncateIndex(newTip *chainhash.Hash, remove bool) error {
338353
return err
339354
}
340355

341-
// If the remove flag is set, then we'll also delete this entry
342-
// from the database as the primary index (block headers) is
343-
// being rolled back.
356+
// If the remove flag is set, then we'll also delete those
357+
// entries from the database as the primary index
358+
// (block headers) is being rolled back.
344359
if remove {
345-
prevTipHash := rootBucket.Get(tipKey)
346-
err := delHeaderEntry(rootBucket, prevTipHash)
347-
if err != nil {
360+
if err := deleteHeaderEntries(
361+
rootBucket, blockHeadersToTruncate,
362+
); err != nil {
348363
return err
349364
}
350365
}
@@ -417,26 +432,66 @@ func getHeaderEntryFallback(rootBucket walletdb.ReadBucket,
417432
return binary.BigEndian.Uint32(heightBytes), nil
418433
}
419434

420-
// delHeaderEntry tries to remove a header entry from the bbolt database. It
421-
// first looks if a key for it exists in the old place, the root bucket. If it
422-
// does, it's deleted from there. If not, it's attempted to be deleted from the
423-
// sub bucket instead.
424-
func delHeaderEntry(rootBucket walletdb.ReadWriteBucket, hashBytes []byte) error {
425-
// In case this header was stored in the old place (the root bucket
426-
// directly), let's remove it from there.
427-
if len(rootBucket.Get(hashBytes)) == 4 {
428-
return rootBucket.Delete(hashBytes)
435+
// deleteHeaderEntries tries to remove multiple header entries from the bbolt
436+
// database. For each header hash, it first looks if a key exists in the old
437+
// place, the root bucket. If it does, it's deleted from there. If not, it's
438+
// attempted to be deleted from the appropriate sub-bucket instead.
439+
func deleteHeaderEntries(rootBucket walletdb.ReadWriteBucket,
440+
headerHashes []*chainhash.Hash) error {
441+
442+
if len(headerHashes) == 0 {
443+
return nil
429444
}
430445

431-
// The hash wasn't stored in the root bucket. So we try the sub bucket
432-
// now. If that doesn't exist, something is wrong and we want to return
433-
// an error here.
434-
subBucket := rootBucket.NestedReadWriteBucket(
435-
hashBytes[0:numSubBucketBytes],
436-
)
437-
if subBucket == nil {
438-
return ErrHashNotFound
446+
// Group hashes by their sub-bucket for more efficient deletion.
447+
bySubBucket := make(map[string][]*chainhash.Hash)
448+
rootBucketHashes := make([]*chainhash.Hash, 0, len(headerHashes))
449+
450+
// Check which hashes are in the root bucket and group the rest by their
451+
// sub-bucket prefix.
452+
for _, hash := range headerHashes {
453+
// Convert hash to bytes for DB operations.
454+
hashBytes := hash.CloneBytes()
455+
456+
// In case this header was stored in the old place
457+
// (the root bucket directly), let's check and mark it for
458+
// removal from there.
459+
if len(rootBucket.Get(hashBytes)) == 4 {
460+
rootBucketHashes = append(rootBucketHashes, hash)
461+
continue
462+
}
463+
// The hash wasn't stored in the root bucket. So we need
464+
// to use the sub-bucket. We extract the prefix to
465+
// determine which sub-bucket to use.
466+
prefix := string(hashBytes[0:numSubBucketBytes])
467+
bySubBucket[prefix] = append(bySubBucket[prefix], hash)
468+
}
469+
470+
// Delete entries from root bucket.
471+
for _, hash := range rootBucketHashes {
472+
hashBytes := hash.CloneBytes()
473+
if err := rootBucket.Delete(hashBytes); err != nil {
474+
return err
475+
}
476+
}
477+
478+
// Delete enties from sub-buckets.
479+
for prefix, hashes := range bySubBucket {
480+
// Try to get the sub-bucket for this prefix. If it doesn't
481+
// exist, something is wrong and we want to return an error.
482+
subBucket := rootBucket.NestedReadWriteBucket([]byte(prefix))
483+
if subBucket == nil {
484+
return fmt.Errorf("%w: sub-bucket for prefix %x not "+
485+
"found", ErrHashNotFound, prefix)
486+
}
487+
488+
for _, hash := range hashes {
489+
hashBytes := hash.CloneBytes()
490+
if err := subBucket.Delete(hashBytes); err != nil {
491+
return err
492+
}
493+
}
439494
}
440495

441-
return subBucket.Delete(hashBytes)
496+
return nil
442497
}

headerfs/index_test.go

Lines changed: 105 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import (
1111
"testing"
1212
"time"
1313

14+
"github.com/btcsuite/btcd/chaincfg/chainhash"
1415
"github.com/btcsuite/btcwallet/walletdb"
1516
_ "github.com/btcsuite/btcwallet/walletdb/bdb"
17+
"github.com/stretchr/testify/require"
1618
)
1719

1820
func createTestIndex(t testing.TB) (func(), *headerIndex, error) {
@@ -43,6 +45,16 @@ func createTestIndex(t testing.TB) (func(), *headerIndex, error) {
4345
return cleanUp, filterDB, nil
4446
}
4547

48+
// TestAddHeadersIndexRetrieve tests the header index functionality by verifying
49+
// the writing of random headers, ensuring the database tip matches the last
50+
// inserted header, checking each header can be retrieved by hash, and testing
51+
// index truncation.
52+
// It specifically exercises the truncateIndices method with an explicit list of
53+
// headers to remove, ensuring proper index maintenance when headers are removed
54+
// from the chain. The test first writes a batch of headers, verifies the chain
55+
// tip, confirms retrieval by hash for all entries, truncates the last header,
56+
// and finally verifies the tip has been properly updated to the second-to-last
57+
// entry.
4658
func TestAddHeadersIndexRetrieve(t *testing.T) {
4759
cleanUp, hIndex, err := createTestIndex(t)
4860
defer cleanUp()
@@ -90,8 +102,12 @@ func TestAddHeadersIndexRetrieve(t *testing.T) {
90102
// Next if we truncate the index by one, then we should end up at the
91103
// second to last entry for the tip.
92104
newTip := headerIndex[numHeaders-2]
93-
if err := hIndex.truncateIndex(&newTip.hash, true); err != nil {
94-
t.Fatalf("unable to truncate index: %v", err)
105+
106+
// Truncate just the last header.
107+
headersToTruncate := []*chainhash.Hash{&lastEntry.hash}
108+
err = hIndex.truncateIndices(&newTip.hash, headersToTruncate, true)
109+
if err != nil {
110+
t.Fatalf("unable to truncate indices: %v", err)
95111
}
96112

97113
// This time the database tip should be the _second_ to last entry
@@ -113,7 +129,11 @@ func TestAddHeadersIndexRetrieve(t *testing.T) {
113129

114130
// TestHeaderStorageFallback makes sure that the changes to the header storage
115131
// location in the bbolt database for reduced memory consumption don't impact
116-
// existing users that already have entries in their database.
132+
// existing users that already have entries in their database. The test verifies
133+
// compatibility with both old format headers stored directly in the root bucket
134+
// and new format headers (stored in sub-buckets). It tests reading from both
135+
// formats and ensures that the truncation functionality correctly handles
136+
// removing headers from either storage format.
117137
func TestHeaderStorageFallback(t *testing.T) {
118138
cleanUp, hIndex, err := createTestIndex(t)
119139
if err != nil {
@@ -184,49 +204,95 @@ func TestHeaderStorageFallback(t *testing.T) {
184204
}
185205
}
186206

187-
// And finally, we trim the chain all the way down to the first header.
188-
// To do so, we first need to make sure the tip points to the last entry
189-
// we added.
190-
lastEntry := newHeaderEntries[len(newHeaderEntries)-1]
191-
if err := hIndex.truncateIndex(&lastEntry.hash, false); err != nil {
192-
t.Fatalf("error setting new tip: %v", err)
193-
}
194-
for _, header := range newHeaderEntries {
195-
if err := hIndex.truncateIndex(&header.hash, true); err != nil {
196-
t.Fatalf("error truncating tip: %v", err)
197-
}
198-
}
199-
for _, header := range oldHeaderEntries {
200-
if err := hIndex.truncateIndex(&header.hash, true); err != nil {
201-
t.Fatalf("error truncating tip: %v", err)
202-
}
207+
// Now we'll test the truncation functionality by truncating all the way
208+
// back to the first old header. We'll do this in steps to verify the
209+
// truncation works properly on both new and old format headers.
210+
211+
// First, set the chain tip to the last new header without removing
212+
// anything.
213+
lastNewHeader := newHeaderEntries[len(newHeaderEntries)-1]
214+
err = hIndex.truncateIndices(&lastNewHeader.hash, nil, false)
215+
require.NoError(t, err)
216+
217+
// Next, truncate all new headers except the first one.
218+
truncationPoint := newHeaderEntries[0]
219+
headersToTruncate := make([]*chainhash.Hash, 0, len(newHeaderEntries)-1)
220+
for i := 1; i < len(newHeaderEntries); i++ {
221+
headersToTruncate = append(
222+
headersToTruncate, &newHeaderEntries[i].hash,
223+
)
203224
}
225+
err = hIndex.truncateIndices(
226+
&truncationPoint.hash, headersToTruncate, true,
227+
)
228+
require.NoError(t, err)
204229

205-
// All the headers except the very last should now be deleted.
206-
for i := 0; i < len(oldHeaderEntries)-1; i++ {
207-
header := oldHeaderEntries[i]
208-
if _, err := hIndex.heightFromHash(&header.hash); err == nil {
209-
t.Fatalf("expected error reading old entry %x",
210-
header.hash[:])
230+
// Verify that only the first new header remains and all others
231+
// are gone.
232+
for i, header := range newHeaderEntries {
233+
height, err := hIndex.heightFromHash(&header.hash)
234+
if i == 0 {
235+
// First header should still be there.
236+
msg := "first new header should still exist"
237+
require.NoError(t, err, msg)
238+
require.Equal(t, header.height, height)
239+
continue
211240
}
212-
}
213-
for _, header := range newHeaderEntries {
214-
if _, err := hIndex.heightFromHash(&header.hash); err == nil {
215-
t.Fatalf("expected error reading old entry %x",
216-
header.hash[:])
241+
242+
if err == nil {
243+
// All other headers should be gone.
244+
msg := fmt.Sprintf("header at index %d should be "+
245+
"deleted, but still exists", i)
246+
require.Fail(t, msg)
217247
}
218248
}
219249

220-
// The last entry should still be there.
221-
lastEntry = oldHeaderEntries[len(oldHeaderEntries)-1]
222-
height, err := hIndex.heightFromHash(&lastEntry.hash)
223-
if err != nil {
224-
t.Fatalf("error reading old entry: %v", err)
225-
}
250+
// Now truncate back to the last old header.
251+
truncationPoint = oldHeaderEntries[len(oldHeaderEntries)-1]
252+
headersToTruncate = []*chainhash.Hash{&newHeaderEntries[0].hash}
253+
err = hIndex.truncateIndices(
254+
&truncationPoint.hash, headersToTruncate, true,
255+
)
256+
require.NoError(t, err, "error truncating to old headers")
257+
258+
// Verify all new headers are gone.
259+
for i, header := range newHeaderEntries {
260+
_, err := hIndex.heightFromHash(&header.hash)
261+
msg := fmt.Sprintf("new header at index %d should be deleted, "+
262+
"but still exists", i)
263+
require.Error(t, err, msg)
264+
}
265+
266+
// Finally, truncate to the first old header.
267+
truncationPoint = oldHeaderEntries[0]
268+
headersToTruncate = make([]*chainhash.Hash, 0, len(oldHeaderEntries)-1)
269+
for i := 1; i < len(oldHeaderEntries); i++ {
270+
headersToTruncate = append(
271+
headersToTruncate, &oldHeaderEntries[i].hash,
272+
)
273+
}
274+
err = hIndex.truncateIndices(
275+
&truncationPoint.hash, headersToTruncate, true,
276+
)
277+
require.NoError(t, err, "error truncating to old headers")
226278

227-
if height != lastEntry.height {
228-
t.Fatalf("unexpected height, got %d wanted %d", height,
229-
lastEntry.height)
279+
// Verify only the first old header remains.
280+
for i, header := range oldHeaderEntries {
281+
height, err := hIndex.heightFromHash(&header.hash)
282+
if i == 0 {
283+
// First header should still be there.
284+
msg := "first old header should still exist"
285+
require.NoError(t, err, msg)
286+
require.Equal(t, header.height, height)
287+
continue
288+
}
289+
290+
if err == nil {
291+
// All other headers should be gone.
292+
msg := fmt.Sprintf("old header at index %d should be "+
293+
"deleted, but still exists", i)
294+
require.Fail(t, msg)
295+
}
230296
}
231297
}
232298

headerfs/store.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,7 @@ func (h *blockHeaderStore) RollbackLastBlock() (*BlockStamp, error) {
405405
defer h.mtx.Unlock()
406406

407407
// First, we'll obtain the latest height that the index knows of.
408-
_, chainTipHeight, err := h.chainTip()
408+
lastBlock, chainTipHeight, err := h.chainTip()
409409
if err != nil {
410410
return nil, err
411411
}
@@ -419,13 +419,19 @@ func (h *blockHeaderStore) RollbackLastBlock() (*BlockStamp, error) {
419419
}
420420
prevHeaderHash := prevHeader.BlockHash()
421421

422+
// Compute the block headers to truncate.
423+
headersToTruncate := []*chainhash.Hash{lastBlock}
424+
422425
// Now that we have the information we need to return from this
423426
// function, we can now truncate the header file, and then use the hash
424427
// of the prevHeader to set the proper index chain tip.
425428
if err := h.truncateHeaders(1, h.indexType); err != nil {
426429
return nil, err
427430
}
428-
if err := h.truncateIndex(&prevHeaderHash, true); err != nil {
431+
432+
if err := h.truncateIndices(
433+
&prevHeaderHash, headersToTruncate, true,
434+
); err != nil {
429435
return nil, err
430436
}
431437

@@ -989,7 +995,7 @@ func (f *filterHeaderStore) WriteHeaders(hdrs ...FilterHeader) error {
989995
// As the block headers should already be written, we only need to
990996
// update the tip pointer for this particular header type.
991997
newTip := hdrs[len(hdrs)-1].toIndexEntry().hash
992-
return f.truncateIndex(&newTip, false)
998+
return f.truncateIndices(&newTip, []*chainhash.Hash{}, false)
993999
}
9941000

9951001
// ChainTip returns the latest filter header and height known to the
@@ -1047,7 +1053,9 @@ func (f *filterHeaderStore) RollbackLastBlock(
10471053
if err := f.truncateHeaders(1, f.indexType); err != nil {
10481054
return nil, err
10491055
}
1050-
if err := f.truncateIndex(newTip, false); err != nil {
1056+
1057+
err = f.truncateIndices(newTip, []*chainhash.Hash{}, false)
1058+
if err != nil {
10511059
return nil, err
10521060
}
10531061

0 commit comments

Comments
 (0)