Skip to content

Commit a672c3c

Browse files
committed
feat(evmreader): add a fast inputs sync
Previously the node had to scan the blockchain logs from input box deployment up to the latest block to find all inputs of a new application. This new feature implements an optimization that skips this scanning when the application has no inputs and does a binary search to find the exact blocks where the inputs happened. The objective is to reduce provider usage and reduce the time it takes to bring new applications online. On the implementation side, we've added a binary search utility function that caches the partial search ranges from one input to the next to further reduce provider usage instead of multiple individual searches. The function accepts any f, x, count such that f(x) -> count. MBSearch finds the xs where count has changed. Concretely, x is a block number, count is the number of inputs and f is GetNumberOfInputs.
1 parent 85ee681 commit a672c3c

6 files changed

Lines changed: 273 additions & 14 deletions

File tree

internal/evmreader/application_adapter.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,7 @@ func (a *ApplicationContractAdapterImpl) RetrieveOutputExecutionEvents(
9595
}
9696
return events, nil
9797
}
98+
99+
func (a *ApplicationContractAdapterImpl) GetDeploymentBlockNumber(opts *bind.CallOpts) (*big.Int, error) {
100+
return a.application.GetDeploymentBlockNumber(opts)
101+
}

internal/evmreader/evmreader.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ type ApplicationContractAdapter interface {
5757
RetrieveOutputExecutionEvents(
5858
opts *bind.FilterOpts,
5959
) ([]*iapplication.IApplicationOutputExecuted, error)
60+
61+
GetDeploymentBlockNumber(opts *bind.CallOpts) (*big.Int, error)
6062
}
6163

6264
// Interface for Input reading
@@ -65,6 +67,8 @@ type InputSourceAdapter interface {
6567
// by go-ethereum and cannot be used for testing
6668
RetrieveInputs(opts *bind.FilterOpts, appAddresses []common.Address, index []*big.Int,
6769
) ([]iinputbox.IInputBoxInputAdded, error)
70+
71+
GetNumberOfInputs(opts *bind.CallOpts, appContract common.Address) (*big.Int, error)
6872
}
6973

7074
type SubscriptionError struct {

internal/evmreader/input.go

Lines changed: 165 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,152 @@ import (
77
"context"
88
"errors"
99
"fmt"
10+
"math/big"
11+
"sort"
1012

1113
. "github.com/cartesi/rollups-node/internal/model"
1214
"github.com/ethereum/go-ethereum/accounts/abi/bind"
1315
"github.com/ethereum/go-ethereum/common"
1416
)
1517

18+
// readInputsOnMachingBlocks fetches the inputAdded events from matching the blocks
19+
// on the blockchain they appear instead of searching for a range.
20+
func (r *Service) readInputsOnMachingBlocks(
21+
ctx context.Context,
22+
app *appContracts,
23+
blocks []uint64,
24+
inputSource InputSourceAdapter,
25+
) ([]*Input, error) {
26+
inputs := []*Input{}
27+
28+
for i, block := range blocks {
29+
if (i > 0) && (blocks[i-1] == block) { // skip repetitions
30+
continue
31+
}
32+
opts := bind.FilterOpts{
33+
Context: ctx,
34+
Start: block,
35+
End: &block,
36+
}
37+
inputsEvents, err := inputSource.RetrieveInputs(
38+
&opts,
39+
[]common.Address{app.application.IApplicationAddress},
40+
nil,
41+
)
42+
if err != nil {
43+
return nil, fmt.Errorf("failed to retrieve inputs: %w", err)
44+
}
45+
46+
// NOTE: there may be more than one input in the same block
47+
for _, event := range inputsEvents {
48+
r.Logger.Debug("Received input",
49+
"address", event.AppContract,
50+
"index", event.Index,
51+
"block", event.Raw.BlockNumber)
52+
input := &Input{
53+
Index: event.Index.Uint64(),
54+
Status: InputCompletionStatus_None,
55+
RawData: event.Input,
56+
BlockNumber: event.Raw.BlockNumber,
57+
TransactionReference: common.BigToHash(event.Index),
58+
}
59+
inputs = append(inputs, input)
60+
}
61+
}
62+
63+
sort.Slice(inputs, func(i, j int) bool {
64+
return inputs[i].Index < inputs[j].Index
65+
})
66+
return inputs, nil
67+
}
68+
69+
// fastSyncInputs finds inputs via getNumberOfInputs instead of reading the logs
70+
// this is cheaper when the logs span many blocks, especially when the application
71+
// has no inputs. In case there are inputs, search for the block in which they
72+
// appear with a binary search.
73+
func (r *Service) fastSyncInputs(
74+
ctx context.Context,
75+
lastProcessedBlock uint64,
76+
mostRecentBlockNumber uint64,
77+
app *appContracts,
78+
) ([]*Input, error) {
79+
r.Logger.Debug("Fast sync inputs",
80+
"application", app.application.Name,
81+
)
82+
83+
getNumberOfInputs := func(nr uint64)(uint64,error){
84+
n, err := app.inputSource.GetNumberOfInputs(&bind.CallOpts{
85+
BlockNumber: new(big.Int).SetUint64(nr),
86+
}, app.application.IApplicationAddress)
87+
if err != nil {
88+
return 0, fmt.Errorf("call to GetNumberOfInputs failed: %w", err)
89+
}
90+
return n.Uint64(), nil
91+
}
92+
93+
noi, err := getNumberOfInputs(mostRecentBlockNumber)
94+
if err != nil {
95+
r.Logger.Debug("Fast sync failed, application will do a regular sync.",
96+
"application", app.application.IApplicationAddress,
97+
"error", err,
98+
)
99+
return nil, err
100+
}
101+
102+
// application has no inputs, sync is done
103+
if noi == 0 {
104+
r.Logger.Info("No inputs, fast sync done",
105+
"application", app.application.Name,
106+
)
107+
return nil, nil
108+
}
109+
110+
// application has inputs, find their blocks and read them in
111+
deployedAtBig, err := app.applicationContract.GetDeploymentBlockNumber(&bind.CallOpts{
112+
BlockNumber: new(big.Int).SetUint64(mostRecentBlockNumber),
113+
})
114+
if err != nil {
115+
r.Logger.Info("Fast sync failed, application will do a regular sync.",
116+
"application", app.application.IApplicationAddress,
117+
"error", err,
118+
)
119+
return nil, err
120+
}
121+
122+
// assume that most inputs happen after this application deployment.
123+
// Do the first range split according to it. Fallback to searching the whole
124+
// range if the assumption is false.
125+
applicationDeploymentBlock := deployedAtBig.Uint64()
126+
noiOld, err := getNumberOfInputs(applicationDeploymentBlock)
127+
if err != nil {
128+
r.Logger.Debug("Fast sync failed, application will do a regular sync.",
129+
"application", app.application.IApplicationAddress,
130+
"error", err,
131+
)
132+
return nil, err
133+
}
134+
135+
startSearchBlock := applicationDeploymentBlock
136+
endSearchBlock := mostRecentBlockNumber
137+
if noiOld == 0 { // all inputs are newer than application deployment
138+
// use current values
139+
} else if noi == noiOld { // all inputs are older than application deployment
140+
startSearchBlock = app.application.IInputBoxBlock
141+
endSearchBlock = applicationDeploymentBlock
142+
} else { // there are both old and new inputs
143+
startSearchBlock = app.application.IInputBoxBlock
144+
}
145+
146+
inputsBlockNumbers, err := MBSearch(startSearchBlock, endSearchBlock, getNumberOfInputs)
147+
r.Logger.Info("Fast sync found inputs on the following blocks",
148+
"application", app.application.Name,
149+
"inputBlockNumbers", inputsBlockNumbers,
150+
)
151+
152+
inputs, err := r.readInputsOnMachingBlocks(ctx, app, inputsBlockNumbers, app.inputSource)
153+
return inputs, err
154+
}
155+
16156
// checkForNewInputs checks if is there new Inputs for all running Applications
17157
func (r *Service) checkForNewInputs(
18158
ctx context.Context,
@@ -44,15 +184,7 @@ func (r *Service) checkForNewInputs(
44184
for lastProcessedBlock, apps := range appsByLastInputCheckBlock {
45185
appAddresses := appsToAddresses(apps)
46186

47-
// Only check blocks starting from the block where the InputBox
48-
// contract was deployed as Inputs can be added to that same block
49-
inputBoxDeploymentBlock := apps[0].application.IInputBoxBlock
50-
if lastProcessedBlock < inputBoxDeploymentBlock {
51-
lastProcessedBlock = inputBoxDeploymentBlock - 1
52-
}
53-
54187
if mostRecentBlockNumber > lastProcessedBlock {
55-
56188
r.Logger.Debug("Checking inputs for applications",
57189
"apps", appAddresses,
58190
"last_processed_block", lastProcessedBlock,
@@ -107,12 +239,31 @@ func (r *Service) readAndStoreInputs(
107239

108240
// Retrieve Inputs from blockchain
109241
nextSearchBlock := lastProcessedBlock + 1
110-
appInputsMap, err := r.readInputsFromBlockchain(ctx, apps, nextSearchBlock, mostRecentBlockNumber)
111-
if err != nil {
112-
return fmt.Errorf("failed to read inputs from block %v to block %v. %w",
113-
nextSearchBlock,
114-
mostRecentBlockNumber,
115-
err)
242+
var appInputsMap = make(map[common.Address][]*Input)
243+
244+
// try to fast sync
245+
if lastProcessedBlock == 0 {
246+
for _, app := range apps {
247+
inputs, err := r.fastSyncInputs(ctx, lastProcessedBlock, mostRecentBlockNumber, &app)
248+
if err != nil {
249+
return fmt.Errorf("failed to read inputs of application %v: %w",
250+
app.application.IApplicationAddress,
251+
err,
252+
)
253+
}
254+
appInputsMap[app.application.IApplicationAddress] = inputs
255+
}
256+
lastProcessedBlock = mostRecentBlockNumber
257+
nextSearchBlock = mostRecentBlockNumber + 1
258+
} else {
259+
var err error
260+
appInputsMap, err = r.readInputsFromBlockchain(ctx, apps, nextSearchBlock, mostRecentBlockNumber)
261+
if err != nil {
262+
return fmt.Errorf("failed to read inputs from block %v to block %v. %w",
263+
nextSearchBlock,
264+
mostRecentBlockNumber,
265+
err)
266+
}
116267
}
117268

118269
addrToApp := mapAddressToApp(apps)

internal/evmreader/inputsource_adapter.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,7 @@ func (i *InputSourceAdapterImpl) RetrieveInputs(
110110
}
111111
return events, nil
112112
}
113+
114+
func (i *InputSourceAdapterImpl) GetNumberOfInputs(opts *bind.CallOpts, addr common.Address) (*big.Int, error) {
115+
return i.inputbox.GetNumberOfInputs(opts, addr)
116+
}

internal/evmreader/util.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package evmreader
55

66
import (
77
"cmp"
8+
"fmt"
89
"slices"
910

1011
. "github.com/cartesi/rollups-node/internal/model"
@@ -64,3 +65,52 @@ func indexApps[K comparable](
6465
}
6566
return result
6667
}
68+
69+
// MBSearch is a multiple binary search over the function f.
70+
// It will find zero, one or multiple transition points x such that f(x-1) < f(x).
71+
// In addition, it will narrow the search space of subsequent points while probing f.
72+
// NOTE: This function assumes that f(0) == 0. In other words: that the transition
73+
// from 0 to 1 exists in the function image.
74+
func MBSearch(minBlock uint64, maxBlock uint64, f func(uint64) (uint64, error)) ([]uint64, error) {
75+
max, err := f(maxBlock)
76+
if err != nil {
77+
return nil, fmt.Errorf("call failed with index %v: %w", maxBlock, err)
78+
}
79+
if max == 0 {
80+
return nil, nil
81+
}
82+
83+
low := make([]uint64, max+1)
84+
high := make([]uint64, max+1)
85+
86+
for i := range max+1 {
87+
low[i] = minBlock
88+
high[i] = maxBlock
89+
}
90+
91+
for end := max+1; end > 1; {
92+
guess := (high[end-1] + low[end-1]) / 2
93+
index, err := f(guess)
94+
95+
if err != nil {
96+
return nil, fmt.Errorf("call failed with index %v: %w", guess, err)
97+
}
98+
99+
for i := uint64(1); i < index+1; i++ {
100+
if high[i] > guess {
101+
high[i] = guess
102+
}
103+
}
104+
105+
for i := index+1; i < end; i++ {
106+
if low[i] < guess {
107+
low[i] = guess
108+
}
109+
}
110+
111+
if low[end-1]+1 == high[end-1] {
112+
end--;
113+
}
114+
}
115+
return high[1:], nil // discard the 0 entry.
116+
}

internal/evmreader/util_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// (c) Cartesi and individual authors (see AUTHORS)
2+
// SPDX-License-Identifier: Apache-2.0 (see LICENSE)
3+
4+
package evmreader
5+
6+
import (
7+
"testing"
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func fN(xs []uint64)(func(uint64)(uint64,error)) {
12+
return func(guess uint64)(uint64,error) {
13+
for i,x := range xs {
14+
if (x > guess) {
15+
return uint64(i), nil
16+
}
17+
}
18+
return uint64(len(xs)), nil
19+
}
20+
}
21+
22+
// test no values
23+
func TestMBSearch0(t *testing.T) {
24+
result, err := MBSearch(0, 1, fN([]uint64{}))
25+
assert.Nil(t, err)
26+
assert.Equal(t, 0, len(result))
27+
}
28+
29+
// test a single values
30+
func TestMBSearch1(t *testing.T) {
31+
result, err := MBSearch(0, 4, fN([]uint64{1}))
32+
assert.Nil(t, err)
33+
assert.Equal(t, 1, len(result))
34+
assert.Equal(t, uint64(1), result[0])
35+
}
36+
37+
// test "many" values, including repeated ones.
38+
func TestMBSearch4(t *testing.T) {
39+
result, err := MBSearch(0, 1024, fN([]uint64{1, 100, 100, 1000}))
40+
assert.Nil(t, err)
41+
assert.Equal(t, 4, len(result))
42+
assert.Equal(t, uint64(1), result[0])
43+
assert.Equal(t, uint64(100), result[1])
44+
assert.Equal(t, uint64(100), result[2])
45+
assert.Equal(t, uint64(1000), result[3])
46+
}

0 commit comments

Comments
 (0)