Skip to content

Commit 52b760b

Browse files
committed
fix(network-fabric): widen the ledger-height retry budget and correct its docs
Three corrections to the starting-block handling added earlier in this branch. The retry budget was too short to serve its purpose. Nothing retries ScanBlock — FSC's events.ListenerManager calls it once from a goroutine that only logs the result — so a height that stays unreadable costs the channel its block-based finality until the process restarts. At 3 attempts over ~1.5s a peer restart outlasted the retries, trading a genesis rescan for a silently dead listener. The default is now 7 attempts over ~31.5s. ErrLedgerHeightUnavailable is now joined on the cancellation path as well, not only on budget exhaustion. It therefore accompanies every failure to resolve the starting block, which makes it a single reliable test for "no scan started" and makes "an error without it came from the scan" true. The documented error taxonomy claimed more than it delivered: a context error does not identify which phase ended, since the same context governs the scan, and ErrNoLedgerInfo reports only the last observed attempt. Both godoc and docs/services/network-fabric.md now state those limits, and the doc no longer recommends a fallback to query-based finality that no caller can perform. Adds three tests covering the cancellation/nil-info overlap, the last-failure -only classification, and the default attempt budget. Signed-off-by: AkramBitar <akram@il.ibm.com>
1 parent 0ae75e6 commit 52b760b

3 files changed

Lines changed: 151 additions & 29 deletions

File tree

docs/services/network-fabric.md

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -299,27 +299,50 @@ sequenceDiagram
299299
The scan resumes at the peer's current ledger height, read via `GetLedgerInfo`
300300
([`delivery.go`](../../token/services/network/fabric/finality/delivery.go)). Because that
301301
one RPC decides the starting block, a failure is retried with an exponentially growing
302-
delay (`LedgerInfoAttempts`, `LedgerInfoRetryDelay`; 3 attempts over ~1.5s by default) and
303-
aborts early if the context is cancelled.
302+
delay — 7 attempts spanning ~31.5s, aborting early if the context is cancelled.
303+
304+
That budget is deliberately long. Nothing retries `ScanBlock`: FSC's
305+
`events.ListenerManager` calls it once from a goroutine that only logs the result, so an
306+
error escaping the retry loop leaves the channel with **no block-based finality until the
307+
process restarts**. The retries therefore have to outlast a peer restart, not merely a
308+
dropped packet.
304309

305310
If the height is still unavailable after the last attempt, `ScanBlock` returns the error
306311
rather than defaulting to block 0. Starting at genesis would rescan the entire chain and
307312
replay finality notifications for every historical transaction, while the caller would have
308313
no way to tell a transient RPC failure from a genuinely fresh chain. Block 0 is used only
309314
when no ledger is configured at all.
310315

311-
The returned error is classifiable with `errors.Is`, so a caller does not have to match on
312-
its message:
313-
314-
| Sentinel | Meaning | Reaction |
315-
| --- | --- | --- |
316-
| `finality.ErrLedgerHeightUnavailable` | the height could not be read within the attempt budget, so no scan started | block-delivery finality is degraded; fall back to query-based finality and retry later |
317-
| `finality.ErrNoLedgerInfo` | the ledger returned neither info nor an error — a driver contract violation | will not self-heal; report as a bug |
318-
| `context.Canceled` / `context.DeadlineExceeded` | the context ended the retries | shutdown, not a failure |
316+
The budget is tunable through the `LedgerInfoAttempts` and `LedgerInfoRetryDelay` fields of
317+
`finality.Delivery`. These are Go fields with no YAML counterpart — neither construction site
318+
sets them, so deployments always run the defaults and only tests vary them.
319319

320-
`ErrNoLedgerInfo` is always accompanied by `ErrLedgerHeightUnavailable`, and the underlying
321-
ledger error is preserved in every case. An error matching none of these comes from the
322-
block scan itself rather than from resolving the starting block.
320+
The returned error is classifiable with `errors.Is`, so a caller does not have to match on
321+
its message. `ErrLedgerHeightUnavailable` is the single test for "the starting block could
322+
not be resolved, so no scan started" — it accompanies every such failure, including a
323+
cancelled one:
324+
325+
| Sentinel | Meaning |
326+
| --- | --- |
327+
| `finality.ErrLedgerHeightUnavailable` | the height could not be read, so no scan started |
328+
| `finality.ErrNoLedgerInfo` | the **last** attempt saw the ledger return neither info nor an error — a driver contract violation |
329+
| `context.Canceled` / `context.DeadlineExceeded` | a wait between attempts was cut short, or the scan itself was cancelled |
330+
331+
The two refining sentinels are not discriminators on their own. `ErrNoLedgerInfo` reports
332+
only the last observed failure, so a driver that violates the contract intermittently may
333+
surface an ordinary ledger error instead — its absence is not proof the contract was kept.
334+
And because the same context governs the scan, a context error alone does not say which
335+
phase ended; pair it with `ErrLedgerHeightUnavailable` to tell a cancelled height read from
336+
a cancelled scan.
337+
338+
The underlying ledger error is preserved in every case. An error that does not match
339+
`ErrLedgerHeightUnavailable` comes from the block scan itself rather than from resolving the
340+
starting block.
341+
342+
There is no in-tree caller that inspects these sentinels yet — today the sole consumer logs
343+
the error and stops. Classifying the failure is what a future caller needs to react
344+
(fall back to query-based finality, retry, or restart the manager) rather than a description
345+
of current behaviour.
323346

324347
### Notification Mode
325348

token/services/network/fabric/finality/delivery.go

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,14 @@ const (
1919
// defaultLedgerInfoAttempts is how many times ScanBlock asks the ledger for
2020
// its current height before giving up. A transient RPC hiccup must not
2121
// decide the starting block, so the call is retried rather than trusted once.
22-
defaultLedgerInfoAttempts = 3
22+
//
23+
// The budget is sized to outlast a peer restart, not just a dropped packet:
24+
// nothing retries ScanBlock (see the ledgerHeight comment), so a failure that
25+
// escapes here disables block-based finality until the process restarts.
26+
defaultLedgerInfoAttempts = 7
2327
// defaultLedgerInfoRetryDelay is the pause before the first retry. It doubles
24-
// on each further attempt, so the defaults span ~1.5s in total.
28+
// on each further attempt, so the defaults wait ~31.5s in total (0.5s + 1s +
29+
// 2s + 4s + 8s + 16s over six retries).
2530
defaultLedgerInfoRetryDelay = 500 * time.Millisecond
2631
)
2732

@@ -31,10 +36,11 @@ var (
3136
// than a transient failure: retrying it is not expected to help.
3237
ErrNoLedgerInfo = errors.New("ledger returned no info")
3338
// ErrLedgerHeightUnavailable reports that the current ledger height could not
34-
// be read within the attempt budget, so the block scan was refused instead of
35-
// restarted from genesis. It distinguishes a failure to decide where to scan
36-
// from a failure of the scan itself; the last observed cause remains
37-
// inspectable with errors.Is.
39+
// be read, so the block scan was refused instead of restarted from genesis.
40+
// Every failure to resolve the starting block carries it — whether the attempt
41+
// budget ran out or the context ended the retries — so it is the single test
42+
// for "no scan started"; an error without it comes from the scan. The last
43+
// observed cause remains inspectable with errors.Is.
3844
ErrLedgerHeightUnavailable = errors.New("ledger height unavailable")
3945
)
4046

@@ -76,11 +82,19 @@ type Delivery struct {
7682
// A nil Ledger is the one case that still starts at block 0 — there is no height
7783
// to read, so the whole chain is the intended range.
7884
//
79-
// The returned error is classifiable without inspecting its message: it matches
80-
// ErrLedgerHeightUnavailable when the height could not be read (and additionally
81-
// ErrNoLedgerInfo when the ledger returned neither info nor an error), and
82-
// context.Canceled or context.DeadlineExceeded when the context ended the
83-
// retries. Any other error comes from the scan itself.
85+
// The returned error is classifiable without inspecting its message. Every
86+
// failure to resolve the starting block matches ErrLedgerHeightUnavailable, and
87+
// no scan has started in that case; an error that does not match it came from the
88+
// scan. Two further sentinels refine the height failure, and neither is a
89+
// discriminator on its own:
90+
//
91+
// - ErrNoLedgerInfo, when the last attempt saw a ledger return neither info nor
92+
// an error. Only the last attempt is reported, so a driver that violates the
93+
// contract intermittently may fail with an ordinary ledger error instead.
94+
// - context.Canceled or context.DeadlineExceeded, when the context ended the
95+
// retries. The same context governs the scan, so a context error alone does
96+
// not say which of the two ended: pair it with ErrLedgerHeightUnavailable to
97+
// tell a cancelled height read from a cancelled scan.
8498
func (d *Delivery) ScanBlock(background context.Context, callback fabric.BlockCallback) error {
8599
startingBlock := uint64(0)
86100
if d.Ledger != nil {
@@ -98,11 +112,17 @@ func (d *Delivery) ScanBlock(background context.Context, callback fabric.BlockCa
98112
// with an exponentially growing delay. It gives up as soon as the context is
99113
// cancelled, and reports the last observed failure once the attempts run out.
100114
//
115+
// The budget is generous because nothing retries the caller: FSC's
116+
// events.ListenerManager calls ScanBlock once from a goroutine that only logs the
117+
// result, so an error escaping here leaves the channel without block-based
118+
// finality until the process restarts. Absorbing a peer restart here is therefore
119+
// worth more than failing fast.
120+
//
101121
// Failures are joined with a sentinel rather than described only in the message,
102-
// so callers classify them with errors.Is: ErrLedgerHeightUnavailable once the
103-
// budget is spent, ErrNoLedgerInfo for a driver returning (nil, nil), and the
104-
// context error for a cancelled wait. The underlying ledger error is preserved
105-
// in every case.
122+
// so callers classify them with errors.Is: ErrLedgerHeightUnavailable on both
123+
// exits, additionally ErrNoLedgerInfo for a driver returning (nil, nil) and the
124+
// context error for a cancelled wait. The underlying ledger error is preserved in
125+
// every case.
106126
func (d *Delivery) ledgerHeight(ctx context.Context) (uint64, error) {
107127
attempts := d.LedgerInfoAttempts
108128
if attempts <= 0 {
@@ -135,7 +155,7 @@ func (d *Delivery) ledgerHeight(ctx context.Context) (uint64, error) {
135155

136156
select {
137157
case <-ctx.Done():
138-
return 0, errors.Wrap(errors.Join(ctx.Err(), lastErr), "cancelled while getting ledger info")
158+
return 0, errors.Wrap(errors.Join(ErrLedgerHeightUnavailable, ctx.Err(), lastErr), "cancelled while getting ledger info")
139159
case <-time.After(delay):
140160
}
141161
delay *= 2

token/services/network/fabric/finality/delivery_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,13 +182,92 @@ func TestScanBlock_CancelledContextStopsRetrying(t *testing.T) {
182182
case err := <-done:
183183
require.ErrorIs(t, err, context.Canceled)
184184
require.ErrorIs(t, err, transient, "the failure that was being retried must stay inspectable alongside the cancellation")
185+
require.ErrorIs(t, err, finality.ErrLedgerHeightUnavailable,
186+
"cancellation is still a failure to resolve the starting block: ErrLedgerHeightUnavailable is the single test for \"no scan started\"")
185187
assert.Equal(t, int32(1), ledger.calls.Load(), "must not keep retrying after cancellation")
186188
assert.False(t, delivery.called)
187189
case <-time.After(10 * time.Second):
188190
t.Fatal("ScanBlock kept retrying after its context was cancelled")
189191
}
190192
}
191193

194+
// TestScanBlock_CancellationDuringNilInfoKeepsBothSentinels pins the corner where
195+
// the two refining sentinels coincide: a (nil, nil) driver whose retry wait is
196+
// then cancelled. ErrNoLedgerInfo must survive alongside the context error, and
197+
// ErrLedgerHeightUnavailable must still be present — documentation states it
198+
// accompanies every height failure, so this path may not be the exception.
199+
func TestScanBlock_CancellationDuringNilInfoKeepsBothSentinels(t *testing.T) {
200+
ledger := &fakeHeightLedger{results: []heightLedgerResult{{}}}
201+
delivery := &recordingBlockDelivery{}
202+
203+
d := newTestDelivery(ledger, delivery)
204+
d.LedgerInfoAttempts = 100
205+
d.LedgerInfoRetryDelay = time.Hour
206+
207+
ctx, cancel := context.WithCancel(context.Background())
208+
cancel()
209+
210+
done := make(chan error, 1)
211+
go func() { done <- d.ScanBlock(ctx, nil) }()
212+
213+
select {
214+
case err := <-done:
215+
require.ErrorIs(t, err, context.Canceled)
216+
require.ErrorIs(t, err, finality.ErrNoLedgerInfo, "the contract violation being retried must stay inspectable")
217+
require.ErrorIs(t, err, finality.ErrLedgerHeightUnavailable)
218+
assert.False(t, delivery.called)
219+
case <-time.After(10 * time.Second):
220+
t.Fatal("ScanBlock kept retrying after its context was cancelled")
221+
}
222+
}
223+
224+
// TestScanBlock_OnlyTheLastFailureIsClassified documents the limit of the
225+
// refining sentinels: lastErr is overwritten on each attempt, so an earlier
226+
// (nil, nil) is not reported once a later attempt fails differently. The doc'd
227+
// contract is "the last attempt", not "any attempt".
228+
func TestScanBlock_OnlyTheLastFailureIsClassified(t *testing.T) {
229+
transient := errors.New("peer connection reset")
230+
ledger := &fakeHeightLedger{results: []heightLedgerResult{{}, {err: transient}}}
231+
delivery := &recordingBlockDelivery{}
232+
233+
d := newTestDelivery(ledger, delivery)
234+
d.LedgerInfoAttempts = 2
235+
236+
err := d.ScanBlock(context.Background(), nil)
237+
238+
require.ErrorIs(t, err, finality.ErrLedgerHeightUnavailable)
239+
require.ErrorIs(t, err, transient)
240+
require.NotErrorIs(t, err, finality.ErrNoLedgerInfo,
241+
"only the last observed failure is classified; callers must not treat a missing ErrNoLedgerInfo as proof the driver never returned (nil, nil)")
242+
assert.False(t, delivery.called)
243+
}
244+
245+
// TestScanBlock_DefaultAttemptBudgetOutlastsAPeerRestart guards the sizing
246+
// decision: nothing retries ScanBlock, so a single transient failure must not
247+
// disable block-based finality, and the default budget has to cover a peer
248+
// restart rather than just a dropped packet. Only the attempt count is asserted —
249+
// the real delay would make this test sleep for the whole ~31.5s schedule, so it
250+
// is overridden and the schedule itself is pinned by the const comments.
251+
func TestScanBlock_DefaultAttemptBudgetOutlastsAPeerRestart(t *testing.T) {
252+
const minimumAttempts = 7
253+
254+
ledger := &fakeHeightLedger{results: []heightLedgerResult{{err: errors.New("peer connection reset")}}}
255+
delivery := &recordingBlockDelivery{}
256+
257+
// LedgerInfoAttempts left unset so the default applies.
258+
d := &finality.Delivery{
259+
Delivery: delivery,
260+
Ledger: ledger,
261+
Logger: logging.MustGetLogger(),
262+
LedgerInfoRetryDelay: time.Millisecond,
263+
}
264+
265+
require.Error(t, d.ScanBlock(context.Background(), nil))
266+
assert.GreaterOrEqual(t, int(ledger.calls.Load()), minimumAttempts,
267+
"the default attempt budget must absorb a peer restart: nothing retries ScanBlock, so a failure here is terminal")
268+
assert.False(t, delivery.called)
269+
}
270+
192271
// TestScanBlock_NilLedgerScansFromGenesis pins the one remaining path that
193272
// legitimately starts at block 0: no ledger is configured, so there is no height
194273
// to resume from and the whole chain is the intended range.

0 commit comments

Comments
 (0)