Skip to content

Commit 9c5cd37

Browse files
Merge pull request #71 from datachainlab/faster-get-boundary-height
Optimize search for hard fork boundary
2 parents 8ea22e9 + 88127d2 commit 9c5cd37

File tree

2 files changed

+213
-20
lines changed

2 files changed

+213
-20
lines changed

module/fork_spec.go

Lines changed: 135 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@ package module
33
import (
44
"context"
55
"fmt"
6-
"github.com/cockroachdb/errors"
7-
"github.com/hyperledger-labs/yui-relayer/log"
6+
"math"
7+
"math/big"
88
"os"
99
"slices"
1010
"strconv"
11+
12+
"github.com/cockroachdb/errors"
13+
"github.com/ethereum/go-ethereum/core/types"
14+
"github.com/hyperledger-labs/yui-relayer/log"
1115
)
1216

1317
type Network string
@@ -225,6 +229,7 @@ func FindTargetForkSpec(forkSpecs []*ForkSpec, height uint64, timestamp uint64)
225229
var boundaryHeightCache = make(map[uint64]uint64)
226230

227231
func GetBoundaryHeight(ctx context.Context, headerFn getHeaderFn, currentHeight uint64, currentForkSpec ForkSpec) (*BoundaryHeight, error) {
232+
var err error
228233
logger := log.GetLogger()
229234
boundaryHeight := uint64(0)
230235
if condition, ok := currentForkSpec.GetHeightOrTimestamp().(*ForkSpec_Height); ok {
@@ -235,27 +240,139 @@ func GetBoundaryHeight(ctx context.Context, headerFn getHeaderFn, currentHeight
235240
boundaryHeight = v
236241
} else {
237242
logger.DebugContext(ctx, "seek fork height", "currentHeight", currentHeight, "ts", ts)
238-
for i := int64(currentHeight); i >= 0; i-- {
239-
h, err := headerFn(ctx, uint64(i))
240-
if err != nil {
241-
return nil, err
242-
}
243-
if MilliTimestamp(h) == ts {
244-
boundaryHeight = h.Number.Uint64()
245-
logger.DebugContext(ctx, "seek fork height found", "currentHeight", currentHeight, "ts", ts, "boundaryHeight", boundaryHeight)
246-
boundaryHeightCache[ts] = boundaryHeight
247-
break
248-
} else if MilliTimestamp(h) < ts {
249-
boundaryHeight = h.Number.Uint64() + 1
250-
logger.DebugContext(ctx, "seek fork height found", "currentHeight", currentHeight, "ts", ts, "boundaryHeight", boundaryHeight)
251-
boundaryHeightCache[ts] = boundaryHeight
252-
break
253-
}
243+
boundaryHeight, err = searchBoundaryHeight(ctx, currentHeight, ts, headerFn)
244+
if err != nil {
245+
return nil, err
254246
}
247+
boundaryHeightCache[ts] = boundaryHeight
255248
}
256249
}
257250
return &BoundaryHeight{
258251
Height: boundaryHeight,
259252
CurrentForkSpec: currentForkSpec,
260253
}, nil
261254
}
255+
256+
func searchBoundaryHeight(ctx context.Context, currentHeight uint64, targetTs uint64, headerFn getHeaderFn) (uint64, error) {
257+
// There are potentially many blocks between the boundary and the current
258+
// blocks. Also, finding the timestamp for a particular block is expensive
259+
// as it requires an RPC call to a node.
260+
//
261+
// Thus, this implementation aims to prune a large number of blocks from the
262+
// search space by estimating the distance between the boundary and the
263+
// current block (based on the average rate of block production) and jumping
264+
// directly to a candidate block at that distance. In case of a miss, all
265+
// blocks on one side of the candidate can be discarded, and a new attempt
266+
// can be made by re-estimating the new distance and jumping to a candidate
267+
// on the other side.
268+
//
269+
// Theoretical worst-case performance is O(N), but since the rate of block
270+
// production can be predicted with high accuracy, this implementation is
271+
// expected to be faster than binary search in practice.
272+
var (
273+
position uint64 = currentHeight // candidate block number currently under consideration
274+
low uint64 = 0 // inclusive lower bound of the current search range
275+
high uint64 = currentHeight + 1 // exclusive upper bound of the current search range
276+
previousHeader *types.Header // header of the block seen in the previous iteration
277+
)
278+
279+
// Loop invariant:
280+
//
281+
// 0 <= low <= position < high <= currentHeight + 1
282+
// &&
283+
// low <= result < high
284+
//
285+
// Bound function (decreases in each iteration, and is always >= 0):
286+
//
287+
// high - low
288+
for low < high {
289+
currentHeader, err := headerFn(ctx, uint64(position))
290+
if err != nil {
291+
return 0, err
292+
}
293+
294+
currentTs := MilliTimestamp(currentHeader)
295+
if currentTs == targetTs {
296+
return currentHeader.Number.Uint64(), nil
297+
}
298+
299+
distance := estimateDistance(previousHeader, currentHeader, targetTs)
300+
301+
if currentTs > targetTs {
302+
// Jump to a lower block.
303+
high = position
304+
305+
// Since these are unsigned, position-distance might underflow.
306+
if low+distance > position {
307+
position = low
308+
} else {
309+
position = position - distance
310+
}
311+
} else {
312+
// Jump to a higher block.
313+
low = position + 1
314+
315+
position = position + distance
316+
317+
if position >= high {
318+
position = high - 1
319+
}
320+
}
321+
322+
previousHeader = currentHeader
323+
}
324+
325+
// If no block with an exact timestamp match was found, then we want the
326+
// earliest block that's _after_ the target timestamp.
327+
return low, nil
328+
}
329+
330+
// estimateDistance returns the estimated number of blocks between the block indicated by currentHeader
331+
// and the boundary block nearest to targetTs. It assumes that previousHeader either is nil, or refers to
332+
// a different block than currentHeader.
333+
func estimateDistance(previousHeader, currentHeader *types.Header, targetTs uint64) uint64 {
334+
if previousHeader == nil {
335+
return 1
336+
}
337+
338+
var (
339+
timeDiffPrevCur uint64 // milliseconds between the previous and current blocks
340+
timeDiffTargetCur uint64 // milliseconds between the current block and target timestamp
341+
)
342+
343+
currentTs := MilliTimestamp(currentHeader)
344+
previousTs := MilliTimestamp(previousHeader)
345+
346+
blockCountPrevCurBig := new(big.Int).Sub(previousHeader.Number, currentHeader.Number)
347+
blockCountPrevCurBig = blockCountPrevCurBig.Abs(blockCountPrevCurBig)
348+
blockCountPrevCur, _ := blockCountPrevCurBig.Float64()
349+
350+
if currentTs > previousTs {
351+
timeDiffPrevCur = currentTs - previousTs
352+
} else {
353+
timeDiffPrevCur = previousTs - currentTs
354+
}
355+
356+
if timeDiffPrevCur == 0 {
357+
// Found two different blocks with the same timestamp. The distance
358+
// should be at least 1 to avoid getting stuck in the current block.
359+
return 1
360+
}
361+
362+
if currentTs > targetTs {
363+
timeDiffTargetCur = currentTs - targetTs
364+
} else {
365+
timeDiffTargetCur = targetTs - currentTs
366+
}
367+
368+
avgBlocksPerMs := blockCountPrevCur / float64(timeDiffPrevCur)
369+
370+
if avgBlocksPerMs > 0 {
371+
return uint64(math.Ceil(avgBlocksPerMs * float64(timeDiffTargetCur)))
372+
}
373+
374+
// Blocks are being produced so slowly that the current block is still expected
375+
// to be the latest block at any future timestamp. Return 1 to avoid getting stuck
376+
// in the current block.
377+
return 1
378+
}

module/fork_spec_test.go

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@ package module
33
import (
44
"context"
55
"fmt"
6+
"math"
7+
"math/big"
8+
"testing"
9+
10+
"errors"
11+
612
"github.com/ethereum/go-ethereum/core/types"
713
"github.com/hyperledger-labs/yui-relayer/log"
814
"github.com/stretchr/testify/suite"
9-
"math/big"
10-
"testing"
1115
)
1216

1317
type ForkSpecTestSuite struct {
@@ -475,3 +479,75 @@ func (ts *ForkSpecTestSuite) Test_Success_GetBoundaryEpochs_After_Maxwell_2() {
475479
ts.Require().Equal(epochs.PreviousEpochBlockNumber(2000), uint64(1000))
476480
ts.Require().Equal(epochs.PreviousEpochBlockNumber(3000), uint64(1000))
477481
}
482+
483+
func (ts *ForkSpecTestSuite) Test_searchBoundaryHeight_Success() {
484+
ctx := context.Background()
485+
var currentHeight uint64 = 200
486+
var targetTs uint64 = 123_456
487+
headerFn := func(ctx context.Context, height uint64) (*types.Header, error) {
488+
return &types.Header{Number: big.NewInt(int64(height)), Time: height}, nil
489+
}
490+
height, err := searchBoundaryHeight(ctx, currentHeight, targetTs, headerFn)
491+
ts.NoError(err)
492+
ts.Equal(uint64(124), height)
493+
}
494+
495+
func (ts *ForkSpecTestSuite) Test_searchBoundaryHeight_Error() {
496+
ctx := context.Background()
497+
var currentHeight uint64 = 200
498+
var targetTs uint64 = 123_456
499+
headerFn := func(ctx context.Context, height uint64) (*types.Header, error) {
500+
return nil, errors.New("test error")
501+
}
502+
_, err := searchBoundaryHeight(ctx, currentHeight, targetTs, headerFn)
503+
ts.Error(err)
504+
}
505+
506+
func (ts *ForkSpecTestSuite) Test_estimateDistance_NoPrevious() {
507+
currentHeader := &types.Header{Number: big.NewInt(int64(123)), Time: 123_456}
508+
targetTs := 123_456
509+
distance := estimateDistance(nil, currentHeader, uint64(targetTs))
510+
ts.Require().Equal(uint64(1), distance)
511+
}
512+
513+
func (ts *ForkSpecTestSuite) Test_estimateDistance_MoveLowAfterLow() {
514+
previousHeader := &types.Header{Number: big.NewInt(int64(200)), Time: 60_000}
515+
currentHeader := &types.Header{Number: big.NewInt(int64(100)), Time: 20_000}
516+
distance := estimateDistance(previousHeader, currentHeader, 10_987_000)
517+
ts.Require().Equal(uint64(23), distance)
518+
}
519+
520+
func (ts *ForkSpecTestSuite) Test_estimateDistance_MoveHighAfterLow() {
521+
previousHeader := &types.Header{Number: big.NewInt(int64(200)), Time: 60_000}
522+
currentHeader := &types.Header{Number: big.NewInt(int64(100)), Time: 20_000}
523+
distance := estimateDistance(previousHeader, currentHeader, 20_720_000)
524+
ts.Require().Equal(uint64(2), distance)
525+
}
526+
527+
func (ts *ForkSpecTestSuite) Test_estimateDistance_MoveHighAfterHigh() {
528+
previousHeader := &types.Header{Number: big.NewInt(int64(100)), Time: 85_000}
529+
currentHeader := &types.Header{Number: big.NewInt(int64(200)), Time: 90_000}
530+
distance := estimateDistance(previousHeader, currentHeader, 97_123_000)
531+
ts.Require().Equal(uint64(143), distance)
532+
}
533+
534+
func (ts *ForkSpecTestSuite) Test_estimateDistance_MoveLowAfterHigh() {
535+
previousHeader := &types.Header{Number: big.NewInt(int64(100)), Time: 85_000}
536+
currentHeader := &types.Header{Number: big.NewInt(int64(200)), Time: 90_000}
537+
distance := estimateDistance(previousHeader, currentHeader, 87_123_000)
538+
ts.Require().Equal(uint64(58), distance)
539+
}
540+
541+
func (ts *ForkSpecTestSuite) Test_estimateDistance_SameTimestamp() {
542+
previousHeader := &types.Header{Number: big.NewInt(int64(100)), Time: 99_900}
543+
currentHeader := &types.Header{Number: big.NewInt(int64(200)), Time: 99_900}
544+
distance := estimateDistance(previousHeader, currentHeader, 50_000)
545+
ts.Require().Equal(uint64(1), distance)
546+
}
547+
548+
func (ts *ForkSpecTestSuite) Test_estimateDistance_MinimumRate() {
549+
previousHeader := &types.Header{Number: big.NewInt(17), Time: math.MaxUint64}
550+
currentHeader := &types.Header{Number: big.NewInt(16), Time: 0}
551+
distance := estimateDistance(previousHeader, currentHeader, 50_000)
552+
ts.Require().Equal(uint64(1), distance)
553+
}

0 commit comments

Comments
 (0)