@@ -19,8 +19,10 @@ package scanner
1919import (
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.
3739const 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+
3946type 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
4673func 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+
214302var 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
255351func (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+
257365type ScriptErrorActionNone struct {}
258366
259367var _ 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