Skip to content

Commit 34905d2

Browse files
authored
Merge pull request #63 from keep-network/pipe-it
New Ethereum event subscription API, background event pull loop # Overview There are two major changes to Ethereum subscription API proposed here: - new subscription API with `OnEvent` and `Pipe` functions, - background monitoring loop pulling past events from the chain. The first change should allow implementing some handler logic easier and to avoid complex logic leading to bugs such as threshold-network/keep-core#1333 or threshold-network/keep-core#2052. The second change should improve client responsiveness for operators running their nodes against Ethereum deployments that are not very reliable on the event delivery front. This code has been integrated with ECDSA keep client in `keep-ecdsa` repository and can be tested there on the branch `pipe-it` (keep-network/keep-ecdsa#671). # New API Event subscription API has been refactored to resemble the proposition from threshold-network/keep-core#491. The new event subscription mechanism allows installing event callback handler function with `OnEvent` function as well as piping events from a subscription to a channel with `Pipe` function. Example usage of `OnEvent`: ``` handlerFn := func( submittingMember common.Address, conflictingPublicKey []byte, blockNumber uint64, ) { // (...) } subscription := keepContract.ConflictingPublicKeySubmitted( nil, // default SubscribeOpts nil, // no filtering on submitting member ).OnEvent(handlerFn) ``` The same subscription but with a `Pipe`: ``` sink := make(chan *abi.BondedECDSAKeepConflictingPublicKeySubmitted) subscription := keepContract.ConflictingPublicKeySubmitted( nil, // default SubscribeOpts nil, // no filtering on submitting member ).Pipe(sink) ``` Currently, all our event subscriptions use function handlers. While it is convenient in some cases, in some other cases it is the opposite. For example, `OnBondedECDSAKeepCreated` handler in ECDSA client works perfectly fine as a function. It triggers the protocol and does not have to constantly monitor the state of the chain. On the other hand, `OnDKGResultSubmitted` handler from the beacon client needs to monitor the chain and exit the process of event publication in case another node has published the result. In this case, the code could be better structured with a channel-based subscription that would allow listening for block counter events and events from DKG result submitted subscription in one for-select loop. # Background monitoring loop Some nodes in the network are running against Ethereum setups that are not particularly reliable in delivering events. Events are not delivered, nodes are not starting key-generation, or are not participating in redemption signing. Another problem is the stability of the event subscription mechanism (see #62). If the web socket connection is dropped too often, the resubscription mechanism is not enough to receive events emitted when the connection was in a weird, stale state. To address this problem, we introduce a background loop periodically pulling past events from the chain next to a regular `watchLogs` subscription. How often events are pulled and how many blocks are taken into account can be configured with `SubscribeOpts` parameters. This way, even if the event was lost by `watchLogs` subscription for whatever reason, it should be pulled by a background monitoring loop later. An extremely important implication of this change is that handlers should have a logic in place allowing them to de-duplicate received events even if a lot of time passed between receiving the original event and the duplicate. I have been experimenting with various options here, including de-duplication events in the chain implementation layer, but none of them proved to be successful as the correct de-duplication algorithm requires domain knowledge about a certain type of an event and in what circumstances identical event emitted later should or should not be identified as a duplicate. De-duplicator implementations should be added to `keep-core` and `keep-ecdsa` clients and are out of the scope of `keep-common` and this PR.
2 parents bb217b9 + b3b56f6 commit 34905d2

File tree

8 files changed

+375
-120
lines changed

8 files changed

+375
-120
lines changed
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package ethutil
2+
3+
import "time"
4+
5+
const (
6+
// DefaultSubscribeOptsTick is the default duration with which
7+
// past events are pulled from the chain by the subscription monitoring
8+
// mechanism if no other value is provided in SubscribeOpts when creating
9+
// the subscription.
10+
DefaultSubscribeOptsTick = 15 * time.Minute
11+
12+
// DefaultSubscribeOptsPastBlocks is the default number of past blocks
13+
// pulled from the chain by the subscription monitoring mechanism if no
14+
// other value is provided in SubscribeOpts when creating the subscription.
15+
DefaultSubscribeOptsPastBlocks = 100
16+
17+
// SubscriptionBackoffMax is the maximum backoff time between event
18+
// resubscription attempts.
19+
SubscriptionBackoffMax = 2 * time.Minute
20+
21+
// SubscriptionAlertThreshold is time threshold below which event
22+
// resubscription emits an error to the logs.
23+
// WS connection can be dropped at any moment and event resubscription will
24+
// follow. However, if WS connection for event subscription is getting
25+
// dropped too often, it may indicate something is wrong with Ethereum
26+
// client. This constant defines the minimum lifetime of an event
27+
// subscription required before the subscription failure happens and
28+
// resubscription follows so that the resubscription does not emit an error
29+
// to the logs alerting about potential problems with Ethereum client
30+
// connection.
31+
SubscriptionAlertThreshold = 15 * time.Minute
32+
)
33+
34+
// SubscribeOpts specifies optional configuration options that can be passed
35+
// when creating Ethereum event subscription.
36+
type SubscribeOpts struct {
37+
38+
// Tick is the duration with which subscription monitoring mechanism
39+
// pulls events from the chain. This mechanism is an additional process
40+
// next to a regular watchLogs subscription making sure no events are lost
41+
// even in case the regular subscription missed them because of, for
42+
// example, connectivity problems.
43+
Tick time.Duration
44+
45+
// PastBlocks is the number of past blocks subscription monitoring mechanism
46+
// takes into consideration when pulling past events from the chain.
47+
// This event pull mechanism is an additional process next to a regular
48+
// watchLogs subscription making sure no events are lost even in case the
49+
// regular subscription missed them because of, for example, connectivity
50+
// problems.
51+
PastBlocks uint64
52+
}

tools/generators/ethereum/command.go.tmpl

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/ethereum/go-ethereum/common/hexutil"
1111
"github.com/ethereum/go-ethereum/core/types"
1212

13+
"github.com/keep-network/keep-common/pkg/chain/ethereum/blockcounter"
1314
"github.com/keep-network/keep-common/pkg/chain/ethereum/ethutil"
1415
"github.com/keep-network/keep-common/pkg/cmd"
1516

@@ -226,6 +227,14 @@ func initialize{{.Class}}(c *cli.Context) (*contract.{{.Class}}, error) {
226227

227228
miningWaiter := ethutil.NewMiningWaiter(client, checkInterval, maxGasPrice)
228229

230+
blockCounter, err := blockcounter.CreateBlockCounter(client)
231+
if err != nil {
232+
return nil, fmt.Errorf(
233+
"failed to create Ethereum blockcounter: [%v]",
234+
err,
235+
)
236+
}
237+
229238
address := common.HexToAddress(config.ContractAddresses["{{.Class}}"])
230239

231240
return contract.New{{.Class}}(
@@ -234,6 +243,7 @@ func initialize{{.Class}}(c *cli.Context) (*contract.{{.Class}}, error) {
234243
client,
235244
ethutil.NewNonceManager(key.Address, client),
236245
miningWaiter,
246+
blockCounter,
237247
&sync.Mutex{},
238248
)
239249
}

tools/generators/ethereum/command_template_content.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/ethereum/go-ethereum/common/hexutil"
1414
"github.com/ethereum/go-ethereum/core/types"
1515
16+
"github.com/keep-network/keep-common/pkg/chain/ethereum/blockcounter"
1617
"github.com/keep-network/keep-common/pkg/chain/ethereum/ethutil"
1718
"github.com/keep-network/keep-common/pkg/cmd"
1819
@@ -229,6 +230,14 @@ func initialize{{.Class}}(c *cli.Context) (*contract.{{.Class}}, error) {
229230
230231
miningWaiter := ethutil.NewMiningWaiter(client, checkInterval, maxGasPrice)
231232
233+
blockCounter, err := blockcounter.CreateBlockCounter(client)
234+
if err != nil {
235+
return nil, fmt.Errorf(
236+
"failed to create Ethereum blockcounter: [%v]",
237+
err,
238+
)
239+
}
240+
232241
address := common.HexToAddress(config.ContractAddresses["{{.Class}}"])
233242
234243
return contract.New{{.Class}}(
@@ -237,6 +246,7 @@ func initialize{{.Class}}(c *cli.Context) (*contract.{{.Class}}, error) {
237246
client,
238247
ethutil.NewNonceManager(key.Address, client),
239248
miningWaiter,
249+
blockCounter,
240250
&sync.Mutex{},
241251
)
242252
}

tools/generators/ethereum/contract.go.tmpl

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616

1717
"github.com/ipfs/go-log"
1818

19+
"github.com/keep-network/keep-common/pkg/chain/ethereum/blockcounter"
1920
"github.com/keep-network/keep-common/pkg/chain/ethereum/ethutil"
2021
"github.com/keep-network/keep-common/pkg/subscription"
2122
)
@@ -25,21 +26,6 @@ import (
2526
// included or excluded from logging at startup by name.
2627
var {{.ShortVar}}Logger = log.Logger("keep-contract-{{.Class}}")
2728

28-
const (
29-
// Maximum backoff time between event resubscription attempts.
30-
{{.ShortVar}}SubscriptionBackoffMax = 2 * time.Minute
31-
32-
// Threshold below which event resubscription emits an error to the logs.
33-
// WS connection can be dropped at any moment and event resubscription will
34-
// follow. However, if WS connection for event subscription is getting
35-
// dropped too often, it may indicate something is wrong with Ethereum
36-
// client. This constant defines the minimum lifetime of an event
37-
// subscription required before the subscription failure happens and
38-
// resubscription follows so that the resubscription does not emit an error
39-
// to the logs alerting about potential problems with Ethereum client.
40-
{{.ShortVar}}SubscriptionAlertThreshold = 15 * time.Minute
41-
)
42-
4329
type {{.Class}} struct {
4430
contract *abi.{{.AbiClass}}
4531
contractAddress common.Address
@@ -51,6 +37,7 @@ type {{.Class}} struct {
5137
errorResolver *ethutil.ErrorResolver
5238
nonceManager *ethutil.NonceManager
5339
miningWaiter *ethutil.MiningWaiter
40+
blockCounter *blockcounter.EthereumBlockCounter
5441

5542
transactionMutex *sync.Mutex
5643
}
@@ -61,6 +48,7 @@ func New{{.Class}}(
6148
backend bind.ContractBackend,
6249
nonceManager *ethutil.NonceManager,
6350
miningWaiter *ethutil.MiningWaiter,
51+
blockCounter *blockcounter.EthereumBlockCounter,
6452
transactionMutex *sync.Mutex,
6553
) (*{{.Class}}, error) {
6654
callerOptions := &bind.CallOpts{
@@ -99,6 +87,7 @@ func New{{.Class}}(
9987
errorResolver: ethutil.NewErrorResolver(backend, &contractABI, &contractAddress),
10088
nonceManager: nonceManager,
10189
miningWaiter: miningWaiter,
90+
blockCounter: blockCounter,
10291
transactionMutex: transactionMutex,
10392
}, nil
10493
}

tools/generators/ethereum/contract_events.go.tmpl

Lines changed: 134 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,66 +2,132 @@
22
{{- $logger := (print $contract.ShortVar "Logger") -}}
33
{{- range $i, $event := .Events }}
44

5-
type {{$contract.FullVar}}{{$event.CapsName}}Func func(
6-
{{$event.ParamDeclarations -}}
7-
)
8-
9-
func ({{$contract.ShortVar}} *{{$contract.Class}}) Past{{$event.CapsName}}Events(
10-
startBlock uint64,
11-
endBlock *uint64,
5+
func ({{$contract.ShortVar}} *{{$contract.Class}}) {{$event.CapsName}}(
6+
opts *ethutil.SubscribeOpts,
127
{{$event.IndexedFilterDeclarations -}}
13-
) ([]*abi.{{$contract.AbiClass}}{{$event.CapsName}}, error){
14-
iterator, err := {{$contract.ShortVar}}.contract.Filter{{$event.CapsName}}(
15-
&bind.FilterOpts{
16-
Start: startBlock,
17-
End: endBlock,
18-
},
19-
{{$event.IndexedFilters}}
20-
)
21-
if err != nil {
22-
return nil, fmt.Errorf(
23-
"error retrieving past {{$event.CapsName}} events: [%v]",
24-
err,
25-
)
8+
) *{{$event.SubscriptionCapsName}} {
9+
if opts == nil {
10+
opts = new(ethutil.SubscribeOpts)
11+
}
12+
if opts.Tick == 0 {
13+
opts.Tick = ethutil.DefaultSubscribeOptsTick
14+
}
15+
if opts.PastBlocks == 0 {
16+
opts.PastBlocks = ethutil.DefaultSubscribeOptsPastBlocks
2617
}
2718

28-
events := make([]*abi.{{$contract.AbiClass}}{{$event.CapsName}}, 0)
29-
30-
for iterator.Next() {
31-
event := iterator.Event
32-
events = append(events, event)
19+
return &{{$event.SubscriptionCapsName}}{
20+
{{$contract.ShortVar}},
21+
opts,
22+
{{$event.IndexedFilters}}
3323
}
24+
}
3425

35-
return events, nil
26+
type {{$event.SubscriptionCapsName}} struct {
27+
contract *{{$contract.Class}}
28+
opts *ethutil.SubscribeOpts
29+
{{$event.IndexedFilterFields -}}
3630
}
3731

38-
func ({{$contract.ShortVar}} *{{$contract.Class}}) Watch{{$event.CapsName}}(
39-
success {{$contract.FullVar}}{{$event.CapsName}}Func,
40-
{{$event.IndexedFilterDeclarations -}}
41-
) (subscription.EventSubscription) {
42-
eventOccurred := make(chan *abi.{{$contract.AbiClass}}{{$event.CapsName}})
32+
type {{$contract.FullVar}}{{$event.CapsName}}Func func(
33+
{{$event.ParamDeclarations -}}
34+
)
4335

36+
func ({{$event.SubscriptionShortVar}} *{{$event.SubscriptionCapsName}}) OnEvent(
37+
handler {{$contract.FullVar}}{{$event.CapsName}}Func,
38+
) subscription.EventSubscription {
39+
eventChan := make(chan *abi.{{$contract.AbiClass}}{{$event.CapsName}})
4440
ctx, cancelCtx := context.WithCancel(context.Background())
4541

46-
// TODO: Watch* function will soon accept channel as a parameter instead
47-
// of the callback. This loop will be eliminated then.
4842
go func() {
4943
for {
5044
select {
5145
case <-ctx.Done():
5246
return
53-
case event := <-eventOccurred:
54-
success(
55-
{{$event.ParamExtractors}}
47+
case event := <- eventChan:
48+
handler(
49+
{{$event.ParamExtractors}}
5650
)
5751
}
5852
}
5953
}()
6054

55+
sub := {{$event.SubscriptionShortVar}}.Pipe(eventChan)
56+
return subscription.NewEventSubscription(func() {
57+
sub.Unsubscribe()
58+
cancelCtx()
59+
})
60+
}
61+
62+
func ({{$event.SubscriptionShortVar}} *{{$event.SubscriptionCapsName}}) Pipe(
63+
sink chan *abi.{{$contract.AbiClass}}{{$event.CapsName}},
64+
) subscription.EventSubscription {
65+
ctx, cancelCtx := context.WithCancel(context.Background())
66+
go func() {
67+
ticker := time.NewTicker({{$event.SubscriptionShortVar}}.opts.Tick)
68+
defer ticker.Stop()
69+
for {
70+
select {
71+
case <-ctx.Done():
72+
return
73+
case <-ticker.C:
74+
lastBlock, err := {{$event.SubscriptionShortVar}}.contract.blockCounter.CurrentBlock()
75+
if err != nil {
76+
{{$logger}}.Errorf(
77+
"subscription failed to pull events: [%v]",
78+
err,
79+
)
80+
}
81+
fromBlock := lastBlock-{{$event.SubscriptionShortVar}}.opts.PastBlocks
82+
83+
{{$logger}}.Infof(
84+
"subscription monitoring fetching past {{$event.CapsName}} events " +
85+
"starting from block [%v]",
86+
fromBlock,
87+
)
88+
events, err := {{$event.SubscriptionShortVar}}.contract.Past{{$event.CapsName}}Events(
89+
fromBlock,
90+
nil,
91+
{{$event.IndexedFilterExtractors}}
92+
)
93+
if err != nil {
94+
{{$logger}}.Errorf(
95+
"subscription failed to pull events: [%v]",
96+
err,
97+
)
98+
continue
99+
}
100+
{{$logger}}.Infof(
101+
"subscription monitoring fetched [%v] past {{$event.CapsName}} events",
102+
len(events),
103+
)
104+
105+
for _, event := range events {
106+
sink <- event
107+
}
108+
}
109+
}
110+
}()
111+
112+
sub := {{$event.SubscriptionShortVar}}.contract.watch{{$event.CapsName}}(
113+
sink,
114+
{{$event.IndexedFilterExtractors}}
115+
)
116+
117+
return subscription.NewEventSubscription(func() {
118+
sub.Unsubscribe()
119+
cancelCtx()
120+
})
121+
}
122+
123+
func ({{$contract.ShortVar}} *{{$contract.Class}}) watch{{$event.CapsName}}(
124+
sink chan *abi.{{$contract.AbiClass}}{{$event.CapsName}},
125+
{{$event.IndexedFilterDeclarations -}}
126+
) event.Subscription {
61127
subscribeFn := func(ctx context.Context) (event.Subscription, error) {
62128
return {{$contract.ShortVar}}.contract.Watch{{$event.CapsName}}(
63129
&bind.WatchOpts{Context: ctx},
64-
eventOccurred,
130+
sink,
65131
{{$event.IndexedFilters}}
66132
)
67133
}
@@ -84,18 +150,42 @@ func ({{$contract.ShortVar}} *{{$contract.Class}}) Watch{{$event.CapsName}}(
84150
)
85151
}
86152

87-
sub := ethutil.WithResubscription(
88-
{{$contract.ShortVar}}SubscriptionBackoffMax,
153+
return ethutil.WithResubscription(
154+
ethutil.SubscriptionBackoffMax,
89155
subscribeFn,
90-
{{$contract.ShortVar}}SubscriptionAlertThreshold,
156+
ethutil.SubscriptionAlertThreshold,
91157
thresholdViolatedFn,
92158
subscriptionFailedFn,
93159
)
160+
}
94161

95-
return subscription.NewEventSubscription(func() {
96-
sub.Unsubscribe()
97-
cancelCtx()
98-
})
162+
func ({{$contract.ShortVar}} *{{$contract.Class}}) Past{{$event.CapsName}}Events(
163+
startBlock uint64,
164+
endBlock *uint64,
165+
{{$event.IndexedFilterDeclarations -}}
166+
) ([]*abi.{{$contract.AbiClass}}{{$event.CapsName}}, error){
167+
iterator, err := {{$contract.ShortVar}}.contract.Filter{{$event.CapsName}}(
168+
&bind.FilterOpts{
169+
Start: startBlock,
170+
End: endBlock,
171+
},
172+
{{$event.IndexedFilters}}
173+
)
174+
if err != nil {
175+
return nil, fmt.Errorf(
176+
"error retrieving past {{$event.CapsName}} events: [%v]",
177+
err,
178+
)
179+
}
180+
181+
events := make([]*abi.{{$contract.AbiClass}}{{$event.CapsName}}, 0)
182+
183+
for iterator.Next() {
184+
event := iterator.Event
185+
events = append(events, event)
186+
}
187+
188+
return events, nil
99189
}
100190

101191
{{- end -}}

0 commit comments

Comments
 (0)