Skip to content

Commit 965998d

Browse files
authored
Merge pull request #1849 from lightninglabs/wip/add-block-header-cache
lndservices: add reorg aware block header cache; use in ChainBridge
2 parents 0f51a1b + 129d128 commit 965998d

File tree

9 files changed

+889
-72
lines changed

9 files changed

+889
-72
lines changed

lndservices/block_header_cache.go

Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
package lndservices
2+
3+
import (
4+
"fmt"
5+
"sync"
6+
7+
"github.com/btcsuite/btcd/chaincfg/chainhash"
8+
"github.com/btcsuite/btcd/wire"
9+
)
10+
11+
const (
12+
// DefaultHeaderCacheSize is the default maximum number of block
13+
// headers to cache.
14+
DefaultHeaderCacheSize = 100_000
15+
16+
// DefaultPurgePercentage is the default percentage of entries to purge
17+
// when the cache reaches capacity (from 1 to 100).
18+
DefaultPurgePercentage = 10
19+
20+
// DefaultMinSettledBlockDepth is the default minimum block depth
21+
// required before a block header is considered settled.
22+
DefaultMinSettledBlockDepth = 6
23+
)
24+
25+
// BlockHeaderCacheConfig holds configuration parameters for the block header
26+
// cache.
27+
type BlockHeaderCacheConfig struct {
28+
// MaxSize is the maximum number of block headers to cache.
29+
MaxSize uint32
30+
31+
// PurgePercentage is the percentage of entries to purge when the cache
32+
// reaches capacity (from 1 to 100, inclusive).
33+
PurgePercentage uint32
34+
35+
// MinSettledBlockDepth is the minimum block depth required before a
36+
// block header is considered settled.
37+
MinSettledBlockDepth uint32
38+
}
39+
40+
// DefaultBlockHeaderCacheConfig returns a BlockHeaderCacheConfig with default
41+
// values.
42+
func DefaultBlockHeaderCacheConfig() BlockHeaderCacheConfig {
43+
return BlockHeaderCacheConfig{
44+
MaxSize: DefaultHeaderCacheSize,
45+
PurgePercentage: DefaultPurgePercentage,
46+
MinSettledBlockDepth: DefaultMinSettledBlockDepth,
47+
}
48+
}
49+
50+
// Validate checks that the configuration parameters are valid.
51+
func (c *BlockHeaderCacheConfig) Validate() error {
52+
if c.PurgePercentage == 0 || c.PurgePercentage > 100 {
53+
return fmt.Errorf("invalid PurgePercentage: %d, must "+
54+
"be > 0 and <= 100", c.PurgePercentage)
55+
}
56+
57+
return nil
58+
}
59+
60+
// headerEntry represents a cached block header with metadata.
61+
type headerEntry struct {
62+
// header is the cached block header.
63+
header wire.BlockHeader
64+
65+
// hash is the cached block hash.
66+
hash chainhash.Hash
67+
68+
// height is the block height of this header.
69+
height uint32
70+
}
71+
72+
// BlockHeaderCache is a reorg-aware cache of block headers.
73+
//
74+
// TODO(ffranr): Once this component is stable, consider moving btcd repo.
75+
type BlockHeaderCache struct {
76+
// cfg is the cache configuration.
77+
cfg BlockHeaderCacheConfig
78+
79+
// mu protects concurrent access to the cache.
80+
mu sync.RWMutex
81+
82+
// byHeight maps block height to header entry.
83+
byHeight map[uint32]*headerEntry
84+
85+
// byHash maps block hash to header entry.
86+
byHash map[chainhash.Hash]*headerEntry
87+
88+
// maxHeight tracks the highest block height we've seen.
89+
maxHeight uint32
90+
}
91+
92+
// NewBlockHeaderCache creates a new block header cache with the given
93+
// configuration.
94+
func NewBlockHeaderCache(cfg BlockHeaderCacheConfig) (*BlockHeaderCache,
95+
error) {
96+
97+
if err := cfg.Validate(); err != nil {
98+
return nil, err
99+
}
100+
101+
return &BlockHeaderCache{
102+
cfg: cfg,
103+
byHeight: make(map[uint32]*headerEntry),
104+
byHash: make(map[chainhash.Hash]*headerEntry),
105+
}, nil
106+
}
107+
108+
// isSettled returns whether an entry is considered settled based on
109+
// block depth.
110+
func (c *BlockHeaderCache) isSettled(height uint32) bool {
111+
settledHeight := height + c.cfg.MinSettledBlockDepth
112+
113+
// If the maximum height among all seen block headers meets or exceeds
114+
// the settled height, this entry is considered settled.
115+
return settledHeight <= c.maxHeight
116+
}
117+
118+
// Put adds a block header to the cache at the given height.
119+
//
120+
// If the insertion exceeded capacity, entries are purged first. If a
121+
// conflicting header exists at this height, a reorg is detected and all headers
122+
// at or above this height are invalidated.
123+
func (c *BlockHeaderCache) Put(height uint32, header wire.BlockHeader) error {
124+
c.mu.Lock()
125+
defer c.mu.Unlock()
126+
127+
hash := header.BlockHash()
128+
129+
// Check if there's already an entry at this height.
130+
if existing, exists := c.byHeight[height]; exists {
131+
existingHash := existing.hash
132+
133+
// If the hashes match, this is a duplicate insertion.
134+
if existingHash == hash {
135+
return nil
136+
}
137+
138+
// The hashes do not match, indicating a reorg. Invalidate
139+
// all known headers at or above this height.
140+
c.invalidateFromHeight(height)
141+
}
142+
143+
// Check capacity and purge if needed.
144+
if uint32(len(c.byHeight)) >= c.cfg.MaxSize {
145+
c.purge()
146+
}
147+
148+
// Create the new entry and store in the cache.
149+
entry := &headerEntry{
150+
header: header,
151+
hash: hash,
152+
height: height,
153+
}
154+
155+
c.byHeight[height] = entry
156+
c.byHash[hash] = entry
157+
158+
// Update max height seen.
159+
if height > c.maxHeight {
160+
c.maxHeight = height
161+
}
162+
163+
return nil
164+
}
165+
166+
// GetByHeight retrieves a block header by height. Returns ok=false if not found
167+
// or if the entry is unsettled (to force external lookup).
168+
func (c *BlockHeaderCache) GetByHeight(height uint32) (wire.BlockHeader, bool) {
169+
c.mu.RLock()
170+
defer c.mu.RUnlock()
171+
172+
var zero wire.BlockHeader
173+
174+
entry, exists := c.byHeight[height]
175+
if !exists || !c.isSettled(height) {
176+
return zero, false
177+
}
178+
179+
return entry.header, true
180+
}
181+
182+
// GetByHash retrieves a block header by hash. Returns ok=false if not found or
183+
// if the entry is unsettled (to force external lookup).
184+
func (c *BlockHeaderCache) GetByHash(hash chainhash.Hash) (wire.BlockHeader,
185+
bool) {
186+
187+
c.mu.RLock()
188+
defer c.mu.RUnlock()
189+
190+
var zero wire.BlockHeader
191+
192+
entry, exists := c.byHash[hash]
193+
if !exists || !c.isSettled(entry.height) {
194+
return zero, false
195+
}
196+
197+
return entry.header, true
198+
}
199+
200+
// invalidateFromHeight removes all entries at or above the given height,
201+
// effectively invalidating the orphaned chain.
202+
func (c *BlockHeaderCache) invalidateFromHeight(heightLowerBound uint32) {
203+
// Track new max height after entries are removed.
204+
var newMaxHeight uint32
205+
206+
// Iterate over all entries and remove those at or above the lower
207+
// bound.
208+
for height, entry := range c.byHeight {
209+
// Skip entries below the lower bound.
210+
if height < heightLowerBound {
211+
// Update new max height if needed.
212+
if height > newMaxHeight {
213+
newMaxHeight = height
214+
}
215+
216+
continue
217+
}
218+
219+
// Remove the entry which is at or above the lower bound.
220+
hash := entry.hash
221+
delete(c.byHeight, height)
222+
delete(c.byHash, hash)
223+
}
224+
225+
c.maxHeight = newMaxHeight
226+
}
227+
228+
// purge removes a random set of entries from the cache at the configured
229+
// purge percentage.
230+
func (c *BlockHeaderCache) purge() {
231+
numToPurge := len(c.byHeight) * int(c.cfg.PurgePercentage) / 100
232+
if numToPurge == 0 {
233+
numToPurge = 1
234+
}
235+
236+
// Remove entries directly from the map iteration (already random
237+
// order).
238+
maxHeightDeleted := false
239+
count := 0
240+
for height, entry := range c.byHeight {
241+
if count >= numToPurge {
242+
break
243+
}
244+
245+
if height == c.maxHeight {
246+
maxHeightDeleted = true
247+
}
248+
249+
hash := entry.hash
250+
delete(c.byHeight, height)
251+
delete(c.byHash, hash)
252+
count++
253+
}
254+
255+
if !maxHeightDeleted {
256+
return
257+
}
258+
259+
// Recalculate max height only if it was deleted.
260+
c.maxHeight = 0
261+
for height := range c.byHeight {
262+
if height > c.maxHeight {
263+
c.maxHeight = height
264+
}
265+
}
266+
}
267+
268+
// Size returns the current number of entries in the cache.
269+
func (c *BlockHeaderCache) Size() int {
270+
c.mu.RLock()
271+
defer c.mu.RUnlock()
272+
273+
return len(c.byHeight)
274+
}
275+
276+
// Clear removes all entries from the cache.
277+
func (c *BlockHeaderCache) Clear() {
278+
c.mu.Lock()
279+
defer c.mu.Unlock()
280+
281+
c.byHeight = make(map[uint32]*headerEntry)
282+
c.byHash = make(map[chainhash.Hash]*headerEntry)
283+
c.maxHeight = 0
284+
}
285+
286+
// Stats returns statistics about the cache.
287+
func (c *BlockHeaderCache) Stats() CacheStats {
288+
c.mu.RLock()
289+
defer c.mu.RUnlock()
290+
291+
settled := 0
292+
for height := range c.byHeight {
293+
if c.isSettled(height) {
294+
settled++
295+
}
296+
}
297+
298+
return CacheStats{
299+
TotalEntries: len(c.byHeight),
300+
SettledEntries: settled,
301+
MaxHeight: c.maxHeight,
302+
}
303+
}
304+
305+
// CacheStats holds statistics about the block header cache.
306+
type CacheStats struct {
307+
// TotalEntries is the total number of entries in the cache.
308+
TotalEntries int
309+
310+
// SettledEntries is the number of settled entries in the cache.
311+
SettledEntries int
312+
313+
// MaxHeight is the highest block height seen.
314+
MaxHeight uint32
315+
}
316+
317+
// String returns a string representation of the cache stats.
318+
func (s CacheStats) String() string {
319+
return fmt.Sprintf("BlockHeaderCacheStats(total=%d, settled=%d, "+
320+
"max_height=%d)", s.TotalEntries, s.SettledEntries,
321+
s.MaxHeight)
322+
}

0 commit comments

Comments
 (0)