Skip to content

Commit 0ae75e6

Browse files
committed
fix(network-fabric): propagate ledger info failures instead of rescanning from genesis
Signed-off-by: AkramBitar <akram@il.ibm.com>
1 parent 55b437b commit 0ae75e6

3 files changed

Lines changed: 363 additions & 9 deletions

File tree

docs/services/network-fabric.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,33 @@ sequenceDiagram
294294
- LRU cache for recent transactions
295295
- Automatic retry on connection failures
296296

297+
#### Choosing the Starting Block
298+
299+
The scan resumes at the peer's current ledger height, read via `GetLedgerInfo`
300+
([`delivery.go`](../../token/services/network/fabric/finality/delivery.go)). Because that
301+
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.
304+
305+
If the height is still unavailable after the last attempt, `ScanBlock` returns the error
306+
rather than defaulting to block 0. Starting at genesis would rescan the entire chain and
307+
replay finality notifications for every historical transaction, while the caller would have
308+
no way to tell a transient RPC failure from a genuinely fresh chain. Block 0 is used only
309+
when no ledger is configured at all.
310+
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 |
319+
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.
323+
297324
### Notification Mode
298325

299326
Uses asynchronous event notifications from the FSC layer:

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

Lines changed: 120 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,27 +8,138 @@ package finality
88

99
import (
1010
"context"
11+
"time"
1112

1213
"github.com/LFDT-Panurus/panurus/token/services/logging"
14+
"github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors"
1315
"github.com/hyperledger-labs/fabric-smart-client/platform/fabric"
1416
)
1517

18+
const (
19+
// defaultLedgerInfoAttempts is how many times ScanBlock asks the ledger for
20+
// its current height before giving up. A transient RPC hiccup must not
21+
// decide the starting block, so the call is retried rather than trusted once.
22+
defaultLedgerInfoAttempts = 3
23+
// defaultLedgerInfoRetryDelay is the pause before the first retry. It doubles
24+
// on each further attempt, so the defaults span ~1.5s in total.
25+
defaultLedgerInfoRetryDelay = 500 * time.Millisecond
26+
)
27+
28+
var (
29+
// ErrNoLedgerInfo reports that the ledger returned neither info nor an error,
30+
// so no height could be read. It signals a driver contract violation rather
31+
// than a transient failure: retrying it is not expected to help.
32+
ErrNoLedgerInfo = errors.New("ledger returned no info")
33+
// 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.
38+
ErrLedgerHeightUnavailable = errors.New("ledger height unavailable")
39+
)
40+
41+
// blockFromScanner is the subset of fabric.Delivery used by Delivery: the block
42+
// scan, parameterized by the block to start from.
43+
type blockFromScanner interface {
44+
ScanBlockFrom(ctx context.Context, block uint64, callback fabric.BlockCallback) error
45+
}
46+
47+
// ledgerHeightProvider is the subset of fabric.Ledger used by Delivery: the
48+
// current ledger height, which is where a fresh block scan resumes.
49+
type ledgerHeightProvider interface {
50+
GetLedgerInfo() (*fabric.LedgerInfo, error)
51+
}
52+
1653
type Delivery struct {
17-
*fabric.Delivery
18-
*fabric.Ledger
19-
Logger logging.Logger
54+
Delivery blockFromScanner
55+
Ledger ledgerHeightProvider
56+
Logger logging.Logger
57+
58+
// LedgerInfoAttempts bounds the number of GetLedgerInfo attempts made by
59+
// ScanBlock. Zero or negative selects defaultLedgerInfoAttempts.
60+
LedgerInfoAttempts int
61+
// LedgerInfoRetryDelay is the pause before the first GetLedgerInfo retry; it
62+
// doubles on each further attempt. Zero or negative selects
63+
// defaultLedgerInfoRetryDelay.
64+
LedgerInfoRetryDelay time.Duration
2065
}
2166

67+
// ScanBlock scans blocks starting from the current ledger height.
68+
//
69+
// The height is read from the ledger, retried a bounded number of times so a
70+
// transient RPC failure does not decide where the scan starts. If the height
71+
// remains unavailable, the error is returned instead of falling back to block 0:
72+
// on a chain with history that fallback silently turns a passing RPC hiccup into
73+
// a full rescan from genesis, with duplicate finality notifications and no error
74+
// for the caller to distinguish it from a genuinely fresh chain.
75+
//
76+
// A nil Ledger is the one case that still starts at block 0 — there is no height
77+
// to read, so the whole chain is the intended range.
78+
//
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.
2284
func (d *Delivery) ScanBlock(background context.Context, callback fabric.BlockCallback) error {
2385
startingBlock := uint64(0)
2486
if d.Ledger != nil {
25-
info, err := d.GetLedgerInfo()
26-
if err == nil {
27-
startingBlock = info.Height
28-
} else {
29-
d.Logger.ErrorfContext(background, "failed to get ledger info: %s", err)
87+
height, err := d.ledgerHeight(background)
88+
if err != nil {
89+
return err
90+
}
91+
startingBlock = height
92+
}
93+
94+
return d.Delivery.ScanBlockFrom(background, startingBlock, callback)
95+
}
96+
97+
// ledgerHeight returns the current ledger height, retrying transient failures
98+
// with an exponentially growing delay. It gives up as soon as the context is
99+
// cancelled, and reports the last observed failure once the attempts run out.
100+
//
101+
// 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.
106+
func (d *Delivery) ledgerHeight(ctx context.Context) (uint64, error) {
107+
attempts := d.LedgerInfoAttempts
108+
if attempts <= 0 {
109+
attempts = defaultLedgerInfoAttempts
110+
}
111+
delay := d.LedgerInfoRetryDelay
112+
if delay <= 0 {
113+
delay = defaultLedgerInfoRetryDelay
114+
}
115+
116+
var lastErr error
117+
for attempt := 1; attempt <= attempts; attempt++ {
118+
info, err := d.Ledger.GetLedgerInfo()
119+
switch {
120+
case err != nil:
121+
lastErr = err
122+
case info == nil:
123+
// A driver that reports neither info nor error would otherwise nil
124+
// deref below; treat it as a failure to read the height.
125+
lastErr = ErrNoLedgerInfo
126+
default:
127+
return info.Height, nil
128+
}
129+
130+
d.Logger.ErrorfContext(ctx, "failed to get ledger info (attempt %d/%d): %s", attempt, attempts, lastErr)
131+
132+
if attempt == attempts {
133+
break
134+
}
135+
136+
select {
137+
case <-ctx.Done():
138+
return 0, errors.Wrap(errors.Join(ctx.Err(), lastErr), "cancelled while getting ledger info")
139+
case <-time.After(delay):
30140
}
141+
delay *= 2
31142
}
32143

33-
return d.ScanBlockFrom(background, startingBlock, callback)
144+
return 0, errors.Wrapf(errors.Join(ErrLedgerHeightUnavailable, lastErr), "failed to get ledger info after %d attempt(s), refusing to rescan from genesis", attempts)
34145
}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
/*
2+
Copyright IBM Corp. All Rights Reserved.
3+
4+
SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
package finality_test
8+
9+
import (
10+
"context"
11+
"errors"
12+
"sync/atomic"
13+
"testing"
14+
"time"
15+
16+
"github.com/LFDT-Panurus/panurus/token/services/logging"
17+
"github.com/LFDT-Panurus/panurus/token/services/network/fabric/finality"
18+
"github.com/hyperledger-labs/fabric-smart-client/platform/fabric"
19+
"github.com/hyperledger/fabric-protos-go-apiv2/common"
20+
"github.com/stretchr/testify/assert"
21+
"github.com/stretchr/testify/require"
22+
)
23+
24+
// heightLedgerResult is one scripted GetLedgerInfo outcome.
25+
type heightLedgerResult struct {
26+
info *fabric.LedgerInfo
27+
err error
28+
}
29+
30+
// fakeHeightLedger returns scripted GetLedgerInfo results, replaying the last
31+
// one once the script is exhausted, and counts how often it was called.
32+
type fakeHeightLedger struct {
33+
results []heightLedgerResult
34+
calls atomic.Int32
35+
}
36+
37+
func (l *fakeHeightLedger) GetLedgerInfo() (*fabric.LedgerInfo, error) {
38+
n := int(l.calls.Add(1))
39+
r := l.results[min(n, len(l.results))-1]
40+
41+
return r.info, r.err
42+
}
43+
44+
// recordingBlockDelivery records the block ScanBlockFrom was asked to start
45+
// from, and whether it was called at all — the observable difference between
46+
// "resumed at the current height", "rescanned from genesis", and "did not scan".
47+
type recordingBlockDelivery struct {
48+
called bool
49+
startingBlock uint64
50+
callback fabric.BlockCallback
51+
err error
52+
}
53+
54+
func (d *recordingBlockDelivery) ScanBlockFrom(_ context.Context, block uint64, callback fabric.BlockCallback) error {
55+
d.called = true
56+
d.startingBlock = block
57+
d.callback = callback
58+
59+
return d.err
60+
}
61+
62+
// newTestDelivery builds a Delivery over the two fakes, with a retry delay short
63+
// enough to keep the tests fast.
64+
func newTestDelivery(l *fakeHeightLedger, d *recordingBlockDelivery) *finality.Delivery {
65+
return &finality.Delivery{
66+
Delivery: d,
67+
Ledger: l,
68+
Logger: logging.MustGetLogger(),
69+
LedgerInfoRetryDelay: time.Millisecond,
70+
}
71+
}
72+
73+
// TestScanBlock_StartsFromCurrentLedgerHeight covers the happy path: the scan
74+
// resumes at the height reported by the ledger, with the caller's callback
75+
// passed through untouched.
76+
func TestScanBlock_StartsFromCurrentLedgerHeight(t *testing.T) {
77+
ledger := &fakeHeightLedger{results: []heightLedgerResult{{info: &fabric.LedgerInfo{Height: 42}}}}
78+
delivery := &recordingBlockDelivery{}
79+
80+
callbackInvoked := false
81+
callback := func(context.Context, *common.Block) (bool, error) {
82+
callbackInvoked = true
83+
84+
return false, nil
85+
}
86+
87+
require.NoError(t, newTestDelivery(ledger, delivery).ScanBlock(context.Background(), callback))
88+
89+
assert.True(t, delivery.called)
90+
assert.Equal(t, uint64(42), delivery.startingBlock)
91+
assert.Equal(t, int32(1), ledger.calls.Load(), "a successful GetLedgerInfo must not be retried")
92+
93+
require.NotNil(t, delivery.callback)
94+
_, err := delivery.callback(context.Background(), nil)
95+
require.NoError(t, err)
96+
assert.True(t, callbackInvoked, "the caller's callback must be the one handed to ScanBlockFrom")
97+
}
98+
99+
// TestScanBlock_PropagatesLedgerInfoErrorInsteadOfRescanningFromGenesis is the
100+
// regression test for issue #2058: a persistent GetLedgerInfo failure used to be
101+
// logged and swallowed, leaving startingBlock at 0 and silently triggering a full
102+
// rescan from genesis. The error must now reach the caller, and no scan must start.
103+
func TestScanBlock_PropagatesLedgerInfoErrorInsteadOfRescanningFromGenesis(t *testing.T) {
104+
transient := errors.New("peer connection reset")
105+
ledger := &fakeHeightLedger{results: []heightLedgerResult{{err: transient}}}
106+
delivery := &recordingBlockDelivery{}
107+
108+
err := newTestDelivery(ledger, delivery).ScanBlock(context.Background(), nil)
109+
110+
require.Error(t, err, "GetLedgerInfo failure must be propagated, not swallowed after a log line")
111+
require.ErrorIs(t, err, transient, "the underlying ledger error must stay inspectable")
112+
require.ErrorIs(t, err, finality.ErrLedgerHeightUnavailable, "the caller must classify this without matching on the message")
113+
require.NotErrorIs(t, err, finality.ErrNoLedgerInfo, "an unreachable peer is not a driver returning no info")
114+
assert.False(t, delivery.called, "no scan may start from genesis when the ledger height is unknown")
115+
}
116+
117+
// TestScanBlock_RetriesTransientLedgerInfoFailure verifies the transient case the
118+
// issue describes — an ordinary RPC hiccup — is absorbed by a retry and resumes at
119+
// the real height, rather than either failing or rescanning from block 0.
120+
func TestScanBlock_RetriesTransientLedgerInfoFailure(t *testing.T) {
121+
ledger := &fakeHeightLedger{results: []heightLedgerResult{
122+
{err: errors.New("peer connection reset")},
123+
{info: &fabric.LedgerInfo{Height: 7}},
124+
}}
125+
delivery := &recordingBlockDelivery{}
126+
127+
require.NoError(t, newTestDelivery(ledger, delivery).ScanBlock(context.Background(), nil))
128+
129+
assert.Equal(t, int32(2), ledger.calls.Load())
130+
assert.True(t, delivery.called)
131+
assert.Equal(t, uint64(7), delivery.startingBlock, "must resume at the height read on the retry, not at genesis")
132+
}
133+
134+
// TestScanBlock_RetriesAreBounded verifies the retry loop honours the configured
135+
// attempt budget instead of spinning on a permanently failing ledger.
136+
func TestScanBlock_RetriesAreBounded(t *testing.T) {
137+
ledger := &fakeHeightLedger{results: []heightLedgerResult{{err: errors.New("peer connection reset")}}}
138+
delivery := &recordingBlockDelivery{}
139+
140+
d := newTestDelivery(ledger, delivery)
141+
d.LedgerInfoAttempts = 5
142+
143+
require.Error(t, d.ScanBlock(context.Background(), nil))
144+
assert.Equal(t, int32(5), ledger.calls.Load())
145+
assert.False(t, delivery.called)
146+
}
147+
148+
// TestScanBlock_NilLedgerInfoIsReportedNotDereferenced covers a driver that
149+
// reports neither info nor error: that used to be a nil dereference waiting on
150+
// info.Height, and must instead surface as an error.
151+
func TestScanBlock_NilLedgerInfoIsReportedNotDereferenced(t *testing.T) {
152+
ledger := &fakeHeightLedger{results: []heightLedgerResult{{}}}
153+
delivery := &recordingBlockDelivery{}
154+
155+
err := newTestDelivery(ledger, delivery).ScanBlock(context.Background(), nil)
156+
157+
require.Error(t, err)
158+
require.ErrorIs(t, err, finality.ErrNoLedgerInfo, "the (nil, nil) contract violation must be identifiable, not just described")
159+
require.ErrorIs(t, err, finality.ErrLedgerHeightUnavailable, "it is also a failure to read the height")
160+
require.NotErrorIs(t, err, context.Canceled, "a nil-info driver is not a cancellation")
161+
assert.False(t, delivery.called)
162+
}
163+
164+
// TestScanBlock_CancelledContextStopsRetrying verifies shutdown does not have to
165+
// wait out the retry budget: a cancelled context ends the loop with its own error.
166+
func TestScanBlock_CancelledContextStopsRetrying(t *testing.T) {
167+
transient := errors.New("peer connection reset")
168+
ledger := &fakeHeightLedger{results: []heightLedgerResult{{err: transient}}}
169+
delivery := &recordingBlockDelivery{}
170+
171+
d := newTestDelivery(ledger, delivery)
172+
d.LedgerInfoAttempts = 100
173+
d.LedgerInfoRetryDelay = time.Hour // only cancellation can end this loop in time
174+
175+
ctx, cancel := context.WithCancel(context.Background())
176+
cancel()
177+
178+
done := make(chan error, 1)
179+
go func() { done <- d.ScanBlock(ctx, nil) }()
180+
181+
select {
182+
case err := <-done:
183+
require.ErrorIs(t, err, context.Canceled)
184+
require.ErrorIs(t, err, transient, "the failure that was being retried must stay inspectable alongside the cancellation")
185+
assert.Equal(t, int32(1), ledger.calls.Load(), "must not keep retrying after cancellation")
186+
assert.False(t, delivery.called)
187+
case <-time.After(10 * time.Second):
188+
t.Fatal("ScanBlock kept retrying after its context was cancelled")
189+
}
190+
}
191+
192+
// TestScanBlock_NilLedgerScansFromGenesis pins the one remaining path that
193+
// legitimately starts at block 0: no ledger is configured, so there is no height
194+
// to resume from and the whole chain is the intended range.
195+
func TestScanBlock_NilLedgerScansFromGenesis(t *testing.T) {
196+
delivery := &recordingBlockDelivery{}
197+
d := &finality.Delivery{Delivery: delivery, Logger: logging.MustGetLogger()}
198+
199+
require.NoError(t, d.ScanBlock(context.Background(), nil))
200+
assert.True(t, delivery.called)
201+
assert.Equal(t, uint64(0), delivery.startingBlock)
202+
}
203+
204+
// TestScanBlock_PropagatesScanError verifies the scan's own error is returned
205+
// unchanged once the starting block has been resolved.
206+
func TestScanBlock_PropagatesScanError(t *testing.T) {
207+
scanErr := errors.New("delivery stream broken")
208+
ledger := &fakeHeightLedger{results: []heightLedgerResult{{info: &fabric.LedgerInfo{Height: 3}}}}
209+
delivery := &recordingBlockDelivery{err: scanErr}
210+
211+
err := newTestDelivery(ledger, delivery).ScanBlock(context.Background(), nil)
212+
213+
require.ErrorIs(t, err, scanErr)
214+
require.NotErrorIs(t, err, finality.ErrLedgerHeightUnavailable, "a scan failure must not be mistaken for a failure to read the height")
215+
assert.Equal(t, uint64(3), delivery.startingBlock)
216+
}

0 commit comments

Comments
 (0)