Skip to content

Commit 766714a

Browse files
Retry scripts on transient gRPC and pruned-state errors (#57)
* Retry on Unavailable gRPC errors, reduce result channel buffer Retry when the connection drops (codes.Unavailable); gRPC reconnects on the next attempt so these errors are transient. Reduce the script result channel buffer from 10000 to 500 to apply backpressure to producers earlier and bound memory usage. * Retry batches at latest scanned height when reference block state is pruned When execution nodes prune state for a batch's reference block, retrying or splitting at the same height can never succeed. Add ScriptErrorActionRetryAtLatestHeight: the batch is resubmitted whole at the latest scanned height once it advances past the batch's height. If the batch is within PrunedStateFatalHeightGap of the latest scanned height (or nothing has been scanned yet), the error is fatal instead: state that close to the tip should never be pruned. All other resubmissions (retry, split, exclude) are also moved to the latest scanned height when it is higher, so retries never re-execute against potentially pruned state. AddressBatch.WithBlockHeight returns a height-adjusted copy that shares done-tracking with the original. Also skip the empty address in event candidate scanning: it is not a real account (e.g. TokensWithdrawn during the initial FLOW mint has no source) and can never be scanned.
1 parent b020e5f commit 766714a

5 files changed

Lines changed: 638 additions & 5 deletions

File tree

candidates/event_scanner.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ func (s *EventCandidatesScanner) Scan(
8383
Msg("could not get candidate address from event")
8484
return NewCandidatesResultError(err)
8585
}
86+
// The empty address is not a real account (e.g. TokensWithdrawn during
87+
// the initial FLOW mint has no source); it can never be scanned.
88+
if address == flow.EmptyAddress {
89+
continue
90+
}
8691
addresses[address] = struct{}{}
8792
}
8893
}

scanner/address_batch.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,20 @@ func (b *AddressBatch) ExcludeAddress(address flow.Address) {
8383
}
8484
}
8585

86+
// WithBlockHeight returns a copy of the batch with a new block height.
87+
// The copy shares the done-tracking state with the original, so
88+
// DoneHandling still fires exactly once for the batch.
89+
func (b *AddressBatch) WithBlockHeight(blockHeight uint64) AddressBatch {
90+
return AddressBatch{
91+
Addresses: b.Addresses,
92+
BlockHeight: blockHeight,
93+
doneHandling: b.doneHandling,
94+
isValid: b.isValid,
95+
96+
doneOnce: b.doneOnce,
97+
}
98+
}
99+
86100
// Split splits the batch into two batches of equal size.
87101
func (b *AddressBatch) Split() (AddressBatch, AddressBatch) {
88102
leftDone := make(chan struct{})

scanner/engine_builder.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,18 @@ func NewScannerEngineBuilder(cfg *engineConfig) *engine.BuilderBase[*engineConfi
6060
// Component: ScriptRunner (depends on ResultProcessor)
6161
var scriptRunner *ScriptRunner
6262
builder.Component("script_runner", func(cfg *engineConfig) (module.ReadyDoneAware, error) {
63+
// Share the latest-scanned height with the script runner, so batches whose
64+
// reference block became unservable (e.g. "execution state is pruned") can
65+
// be retried at a fresh height.
66+
scriptRunnerConfig := cfg.Config.ScriptRunnerConfig
67+
if scriptRunnerConfig.LatestScannedHeight == nil {
68+
scriptRunnerConfig.LatestScannedHeight = cfg.LatestScanned.GetIfScanned
69+
}
6370
scriptRunner = NewScriptRunner(
6471
cfg.Config.Logger,
6572
cfg.Client,
6673
resultProcessor,
67-
cfg.Config.ScriptRunnerConfig,
74+
scriptRunnerConfig,
6875
)
6976
return scriptRunner, nil
7077
})

scanner/script_runner.go

Lines changed: 118 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@ package scanner
1919
import (
2020
"context"
2121
"errors"
22+
"fmt"
2223
"regexp"
2324
"strings"
25+
"time"
2426

2527
"github.com/onflow/cadence"
2628
"github.com/onflow/flow-go-sdk"
@@ -36,19 +38,45 @@ import (
3638
// As long as they don't wait too long, this is not a problem.
3739
const DefaultScriptRunnerMaxConcurrentScripts = 20
3840

41+
// DefaultPrunedStateFatalHeightGap is the default minimum number of blocks the
42+
// latest scanned height must be ahead of an unservable batch's height for the
43+
// batch to be rescheduled instead of treated as fatal.
44+
const DefaultPrunedStateFatalHeightGap = 10
45+
3946
type ScriptRunnerConfig struct {
4047
Script []byte
4148

4249
MaxConcurrentScripts int
4350
HandleScriptError func(AddressBatch, error) ScriptErrorAction
51+
52+
// LatestScannedHeight returns the latest block height known to have servable
53+
// state (typically the latest incrementally scanned height).
54+
// Before a batch is resubmitted (retry, split, or exclude), it is moved to
55+
// this height if it is higher than the batch's current height, so retries
56+
// never re-execute against state that may have been pruned.
57+
// For ScriptErrorActionRetryAtLatestHeight, the resubmission is deferred
58+
// until this advances past the batch's height (a new scanned block).
59+
// May be nil: resubmissions then keep the batch's original height.
60+
LatestScannedHeight func() (uint64, bool)
61+
62+
// PrunedStateFatalHeightGap is the minimum number of blocks the latest
63+
// scanned height must be ahead of a batch's block height for
64+
// ScriptErrorActionRetryAtLatestHeight to reschedule it. If the gap is
65+
// smaller (or no height has been scanned yet), the error is fatal
66+
// (ctx.Throw) instead: state that close to the tip should never be
67+
// pruned, so waiting for a new block cannot be expected to help.
68+
// Zero disables the fatal check.
69+
// Only evaluated when LatestScannedHeight is non-nil.
70+
PrunedStateFatalHeightGap uint64
4471
}
4572

4673
func DefaultScriptRunnerConfig() ScriptRunnerConfig {
4774
return ScriptRunnerConfig{
4875
Script: []byte(defaultScript),
4976

50-
MaxConcurrentScripts: DefaultScriptRunnerMaxConcurrentScripts,
51-
HandleScriptError: DefaultHandleScriptError,
77+
MaxConcurrentScripts: DefaultScriptRunnerMaxConcurrentScripts,
78+
HandleScriptError: DefaultHandleScriptError,
79+
PrunedStateFatalHeightGap: DefaultPrunedStateFatalHeightGap,
5280
}
5381
}
5482

@@ -152,7 +180,52 @@ func (r *ScriptRunner) handleBatch(ctx irrecoverable.SignalerContext, input Addr
152180
Info().
153181
Msg("retrying")
154182
go func() {
155-
r.handleBatch(ctx, input)
183+
r.handleBatch(ctx, r.atLatestHeight(input))
184+
}()
185+
return
186+
case ScriptErrorActionRetryAtLatestHeight:
187+
// The batch's reference block is no longer servable (e.g. its state was
188+
// pruned on the execution nodes). Splitting is pointless — every half
189+
// would fail the same way — and retrying at the same height can never
190+
// succeed, so resubmit the whole batch once the latest scanned height
191+
// advances to a new block.
192+
//
193+
// Exception: if the batch's height is within PrunedStateFatalHeightGap
194+
// of the latest scanned height, state that close to the tip should
195+
// never be unservable, so the error is fatal instead of reschedulable.
196+
if r.LatestScannedHeight != nil && r.PrunedStateFatalHeightGap > 0 {
197+
if latest, ok := r.LatestScannedHeight(); !ok ||
198+
latest < input.BlockHeight ||
199+
latest-input.BlockHeight < r.PrunedStateFatalHeightGap {
200+
ctx.Throw(fmt.Errorf(
201+
"batch at height %d is not servable while latest scanned height is %d (fatal gap < %d): %w",
202+
input.BlockHeight, latest, r.PrunedStateFatalHeightGap, err))
203+
return
204+
}
205+
}
206+
r.log.
207+
Info().
208+
Uint64("block_height", input.BlockHeight).
209+
Msg("reference block not servable, waiting for a new scanned block to retry")
210+
go func() {
211+
ticker := time.NewTicker(newScannedHeightPollInterval)
212+
defer ticker.Stop()
213+
for {
214+
select {
215+
case <-ctx.Done():
216+
return
217+
case <-ticker.C:
218+
}
219+
if r.LatestScannedHeight == nil {
220+
// no way to detect a new block: retry at the same height
221+
r.handleBatch(ctx, input)
222+
return
223+
}
224+
if h, ok := r.LatestScannedHeight(); ok && h > input.BlockHeight {
225+
r.handleBatch(ctx, input.WithBlockHeight(h))
226+
return
227+
}
228+
}
156229
}()
157230
return
158231
case ScriptErrorActionSplit:
@@ -165,6 +238,7 @@ func (r *ScriptRunner) handleBatch(ctx irrecoverable.SignalerContext, input Addr
165238
Info().
166239
Int("addresses", len(input.Addresses)).
167240
Msg("retrying by splitting")
241+
input = r.atLatestHeight(input)
168242
left, right := input.Split()
169243
go func() {
170244
r.handleBatch(ctx, left)
@@ -191,7 +265,7 @@ func (r *ScriptRunner) handleBatch(ctx irrecoverable.SignalerContext, input Addr
191265
input.ExcludeAddress(address)
192266
}
193267
go func() {
194-
r.handleBatch(ctx, input)
268+
r.handleBatch(ctx, r.atLatestHeight(input))
195269
}()
196270
return
197271
case ScriptErrorActionNone:
@@ -211,8 +285,30 @@ func (r *ScriptRunner) handleBatch(ctx irrecoverable.SignalerContext, input Addr
211285
}()
212286
}
213287

288+
// atLatestHeight moves the batch to the latest scanned height if that is
289+
// higher than the batch's current height, so a resubmitted batch never
290+
// re-executes against state that may have been pruned. Returns the batch
291+
// unchanged when LatestScannedHeight is nil or has no higher height.
292+
func (r *ScriptRunner) atLatestHeight(input AddressBatch) AddressBatch {
293+
if r.LatestScannedHeight == nil {
294+
return input
295+
}
296+
if h, ok := r.LatestScannedHeight(); ok && h > input.BlockHeight {
297+
return input.WithBlockHeight(h)
298+
}
299+
return input
300+
}
301+
214302
var accountFrozenRegex = regexp.MustCompile(`\[Error Code: 1204] account (?P<address>\w{16}) is frozen`)
215303

304+
// newScannedHeightPollInterval is how often the script runner checks whether
305+
// the latest scanned height has advanced while waiting to resubmit a batch via
306+
// ScriptErrorActionRetryAtLatestHeight. There is no retry cap: a long-running
307+
// scan should ride out a multi-hour execution-node state-availability outage
308+
// rather than crash.
309+
// It is a variable so tests can stub it.
310+
var newScannedHeightPollInterval = time.Second
311+
216312
// executeScript retries running the cadence script until we get a successful response back,
217313
// returning an array of Balance pairs, along with a boolean representing whether we can continue
218314
// or are finished processing.
@@ -254,6 +350,18 @@ var _ ScriptErrorAction = ScriptErrorActionRetry{}
254350

255351
func (s ScriptErrorActionRetry) isScriptErrorAction() {}
256352

353+
// ScriptErrorActionRetryAtLatestHeight resubmits the whole batch at the latest
354+
// scanned block height (see ScriptRunnerConfig.LatestScannedHeight), after a
355+
// backoff. Use it for errors where the batch's reference block can never
356+
// succeed again, e.g. "execution state is pruned".
357+
// If the batch's height is within ScriptRunnerConfig.PrunedStateFatalHeightGap
358+
// of the latest scanned height, the error is treated as fatal instead.
359+
type ScriptErrorActionRetryAtLatestHeight struct{}
360+
361+
var _ ScriptErrorAction = ScriptErrorActionRetryAtLatestHeight{}
362+
363+
func (s ScriptErrorActionRetryAtLatestHeight) isScriptErrorAction() {}
364+
257365
type ScriptErrorActionNone struct{}
258366

259367
var _ ScriptErrorAction = ScriptErrorActionNone{}
@@ -285,6 +393,12 @@ func DefaultHandleScriptError(_ AddressBatch, err error) ScriptErrorAction {
285393
return ScriptErrorActionNone{}
286394
}
287395

396+
// The execution nodes no longer serve state for the batch's reference
397+
// block; splitting or retrying at the same height cannot succeed.
398+
if strings.Contains(err.Error(), "execution state is pruned") {
399+
return ScriptErrorActionRetryAtLatestHeight{}
400+
}
401+
288402
if strings.Contains(err.Error(), "state commitment not found") {
289403
return ScriptErrorActionNone{}
290404
}

0 commit comments

Comments
 (0)