Skip to content

Commit a91d24b

Browse files
authored
Fix: Stop silently dropping failed finality events (#1419)
Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
1 parent eb995ec commit a91d24b

9 files changed

Lines changed: 231 additions & 25 deletions

File tree

integration/token/common/views/finality.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,7 @@ func (l *finalityListener) OnStatus(ctx context.Context, txID string, status int
9090
fmt.Printf("Received finality from network for TX [%s][%d]", txID, status)
9191
l.success()
9292
}
93+
94+
func (l *finalityListener) OnError(ctx context.Context, txID string, err error) {
95+
fmt.Printf("Finality error for TX [%s]: %v", txID, err)
96+
}

token/services/network/driver/network.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import (
2121
type FinalityListener interface {
2222
// OnStatus is called when the status of a transaction changes
2323
OnStatus(ctx context.Context, txID string, status int, message string, tokenRequestHash []byte)
24+
// OnError is called when the finality event cannot be delivered after all retries are exhausted
25+
OnError(ctx context.Context, txID string, err error)
2426
}
2527

2628
type TransientMap = map[string][]byte

token/services/network/fabricx/finality/finality.go

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"context"
1111
"fmt"
1212
"sync"
13+
"time"
1314

1415
"github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors"
1516
cdriver "github.com/hyperledger-labs/fabric-smart-client/platform/common/driver"
@@ -27,6 +28,13 @@ import (
2728

2829
var logger = logging.MustGetLogger()
2930

31+
const (
32+
// defaultMaxRetries is the number of times a ListenerEvent will retry on transient errors.
33+
defaultMaxRetries = 3
34+
// defaultRetryInterval is the initial backoff delay; it doubles after each attempt.
35+
defaultRetryInterval = time.Second
36+
)
37+
3038
// ConfigService models the configuration service needed by the NSListenerManager
3139
//
3240
//go:generate counterfeiter -o mock/cs.go -fake-name ConfigService . ConfigService
@@ -101,17 +109,58 @@ type ListenerEvent struct {
101109
StatusMessage string
102110
// Namespace is the namespace of the transaction
103111
Namespace string
112+
113+
// MaxRetries is the number of retry attempts on transient errors (0 uses defaultMaxRetries).
114+
MaxRetries int
115+
// RetryInterval is the initial backoff delay between retries, doubling each attempt (0 uses defaultRetryInterval).
116+
RetryInterval time.Duration
104117
}
105118

106-
// Process handles a finality event notification.
119+
// Process handles a finality event notification with exponential-backoff retries.
107120
// If the status is Unknown or Busy, it triggers a manual transaction check.
108121
// If the status is Valid, it retrieves the token request hash from the ledger.
109-
// Finally, it notifies the wrapped listener with the transaction's status and hash.
122+
// It notifies the wrapped listener on success, or calls OnError if all retries are exhausted.
110123
func (l *ListenerEvent) Process(ctx context.Context) error {
124+
maxRetries := l.MaxRetries
125+
if maxRetries <= 0 {
126+
maxRetries = defaultMaxRetries
127+
}
128+
retryInterval := l.RetryInterval
129+
if retryInterval <= 0 {
130+
retryInterval = defaultRetryInterval
131+
}
132+
133+
delay := retryInterval
134+
for attempt := 0; attempt <= maxRetries; attempt++ {
135+
err := l.process(ctx)
136+
if err == nil {
137+
return nil
138+
}
139+
if attempt == maxRetries {
140+
logger.Errorf("[ListenerEvent] tx [%s] failed after %d attempts: %v — notifying listener", l.TxID, maxRetries+1, err)
141+
l.Listener.OnError(ctx, l.TxID, err)
142+
143+
return nil
144+
}
145+
logger.Warnf("[ListenerEvent] tx [%s] attempt %d/%d failed: %v, retrying in %v", l.TxID, attempt+1, maxRetries+1, err, delay)
146+
select {
147+
case <-time.After(delay):
148+
delay *= 2
149+
case <-ctx.Done():
150+
logger.Warnf("[ListenerEvent] tx [%s] context canceled during retry backoff", l.TxID)
151+
152+
return nil
153+
}
154+
}
155+
156+
return nil
157+
}
158+
159+
// process executes a single attempt at handling the finality event.
160+
func (l *ListenerEvent) process(ctx context.Context) error {
111161
logger.Debugf("[ListenerEvent] get notification for [%s], status [%d]", l.TxID, l.Status)
112162

113163
if l.Status == fdriver.Unknown || l.Status == fdriver.Busy {
114-
// perform a query
115164
txCheck := TxCheck{
116165
QueryService: l.QueryService,
117166
KeyTranslator: l.KeyTranslator,
@@ -120,14 +169,12 @@ func (l *ListenerEvent) Process(ctx context.Context) error {
120169
Namespace: l.Namespace,
121170
}
122171
if err := txCheck.Process(ctx); err == nil {
123-
// this means that the query has notified the event
124172
return nil
125173
}
126174
}
127175

128176
var tokenRequestHash []byte
129177
if l.Status == fdriver.Valid {
130-
// fetch token request hash key
131178
key, err := l.KeyTranslator.CreateTokenRequestKey(l.TxID)
132179
if err != nil {
133180
return errors.Wrapf(err, "can't create for token request [%s]", l.TxID)
@@ -345,6 +392,13 @@ func (o *OnlyOnceListener) OnStatus(ctx context.Context, txID string, status int
345392
})
346393
}
347394

395+
// OnError forwards the error to the wrapped listener only if it hasn't been notified before.
396+
func (o *OnlyOnceListener) OnError(ctx context.Context, txID string, err error) {
397+
o.once.Do(func() {
398+
o.listener.OnError(ctx, txID, err)
399+
})
400+
}
401+
348402
// fabricXFSCStatus maps Fabric-X transaction status codes to FSC validation codes.
349403
func fabricXFSCStatus(c int32) fdriver.ValidationCode {
350404
switch protoblocktx.Status(c) {

token/services/network/fabricx/finality/finality_test.go

Lines changed: 96 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"context"
1111
"sync"
1212
"testing"
13+
"time"
1314

1415
"github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors"
1516
cdriver "github.com/hyperledger-labs/fabric-smart-client/platform/common/driver"
@@ -206,6 +207,83 @@ func TestListenerEvent_Process_Busy_TxCheckSucceeds(t *testing.T) {
206207
assert.Equal(t, 1, mockListener.OnStatusCallCount())
207208
}
208209

210+
func TestListenerEvent_Process_RetriesOnTransientError(t *testing.T) {
211+
ctx := t.Context()
212+
txID := "tx123"
213+
namespace := "token-namespace"
214+
tokenRequestHash := []byte("hash123")
215+
key := "token-request-key"
216+
217+
mockQS := &mock.QueryService{}
218+
mockKT := &mock.KeyTranslator{}
219+
mockListener := &mock.Listener{}
220+
221+
var attempts int
222+
// Fail twice, succeed on third attempt
223+
mockQS.GetStateStub = func(_ string, _ string) (*cdriver.VaultValue, error) {
224+
attempts++
225+
if attempts < 3 {
226+
return nil, errors.New("transient peer error")
227+
}
228+
229+
return &cdriver.VaultValue{Raw: tokenRequestHash}, nil
230+
}
231+
mockKT.CreateTokenRequestKeyReturns(key, nil)
232+
233+
event := &finality.ListenerEvent{
234+
QueryService: mockQS,
235+
KeyTranslator: mockKT,
236+
Listener: mockListener,
237+
TxID: txID,
238+
Status: fdriver.Valid,
239+
Namespace: namespace,
240+
MaxRetries: 3,
241+
RetryInterval: 10 * time.Millisecond,
242+
}
243+
244+
err := event.Process(ctx)
245+
require.NoError(t, err)
246+
247+
// Should have retried and eventually called OnStatus
248+
assert.Equal(t, 3, attempts)
249+
assert.Equal(t, 1, mockListener.OnStatusCallCount())
250+
assert.Equal(t, 0, mockListener.OnErrorCallCount())
251+
}
252+
253+
func TestListenerEvent_Process_CallsOnErrorAfterAllRetriesExhausted(t *testing.T) {
254+
ctx := t.Context()
255+
txID := "tx123"
256+
key := "token-request-key"
257+
258+
mockQS := &mock.QueryService{}
259+
mockKT := &mock.KeyTranslator{}
260+
mockListener := &mock.Listener{}
261+
262+
mockKT.CreateTokenRequestKeyReturns(key, nil)
263+
mockQS.GetStateReturns(nil, errors.New("persistent peer error"))
264+
265+
event := &finality.ListenerEvent{
266+
QueryService: mockQS,
267+
KeyTranslator: mockKT,
268+
Listener: mockListener,
269+
TxID: txID,
270+
Status: fdriver.Valid,
271+
Namespace: "token-namespace",
272+
MaxRetries: 2,
273+
RetryInterval: 10 * time.Millisecond,
274+
}
275+
276+
err := event.Process(ctx)
277+
require.NoError(t, err)
278+
279+
// All retries exhausted — OnStatus must NOT be called, OnError must be called once
280+
assert.Equal(t, 0, mockListener.OnStatusCallCount())
281+
assert.Equal(t, 1, mockListener.OnErrorCallCount())
282+
_, callTxID, callErr := mockListener.OnErrorArgsForCall(0)
283+
assert.Equal(t, txID, callTxID)
284+
assert.Contains(t, callErr.Error(), "persistent peer error")
285+
}
286+
209287
func TestListenerEvent_Process_CreateTokenRequestKeyError(t *testing.T) {
210288
ctx := t.Context()
211289
txID := "tx123"
@@ -224,12 +302,18 @@ func TestListenerEvent_Process_CreateTokenRequestKeyError(t *testing.T) {
224302
Status: fdriver.Valid,
225303
StatusMessage: "",
226304
Namespace: "token-namespace",
305+
MaxRetries: 2,
306+
RetryInterval: 10 * time.Millisecond,
227307
}
228308

229309
err := event.Process(ctx)
230-
require.Error(t, err)
231-
assert.Contains(t, err.Error(), "can't create for token request")
232-
assert.Contains(t, err.Error(), "key creation failed")
310+
require.NoError(t, err)
311+
// All retries exhausted — listener must be notified via OnError
312+
assert.Equal(t, 0, mockListener.OnStatusCallCount())
313+
assert.Equal(t, 1, mockListener.OnErrorCallCount())
314+
_, callTxID, callErr := mockListener.OnErrorArgsForCall(0)
315+
assert.Equal(t, txID, callTxID)
316+
assert.Contains(t, callErr.Error(), "key creation failed")
233317
}
234318

235319
func TestListenerEvent_Process_GetStateError(t *testing.T) {
@@ -252,12 +336,18 @@ func TestListenerEvent_Process_GetStateError(t *testing.T) {
252336
Status: fdriver.Valid,
253337
StatusMessage: "",
254338
Namespace: "token-namespace",
339+
MaxRetries: 2,
340+
RetryInterval: 10 * time.Millisecond,
255341
}
256342

257343
err := event.Process(ctx)
258-
require.Error(t, err)
259-
assert.Contains(t, err.Error(), "can't get state for token request")
260-
assert.Contains(t, err.Error(), "state retrieval failed")
344+
require.NoError(t, err)
345+
// All retries exhausted — listener must be notified via OnError
346+
assert.Equal(t, 0, mockListener.OnStatusCallCount())
347+
assert.Equal(t, 1, mockListener.OnErrorCallCount())
348+
_, callTxID, callErr := mockListener.OnErrorArgsForCall(0)
349+
assert.Equal(t, txID, callTxID)
350+
assert.Contains(t, callErr.Error(), "state retrieval failed")
261351
}
262352

263353
func TestListenerEvent_String(t *testing.T) {

token/services/network/fabricx/finality/mock/fl.go

Lines changed: 42 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

token/services/network/fabricx/finality/queue/queue.go

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -98,39 +98,45 @@ func (eq *EventQueue) start() {
9898
}
9999
}
100100

101-
// worker represents a single goroutine that pulls events from the queue
102-
// and processes them until the channel is closed or the context is canceled.
103-
// It includes panic recovery to prevent worker crashes from affecting the pool.
101+
// worker is the top-level goroutine for a worker. It delegates to runWorker
102+
// and restarts on panic so the pool does not degrade over time.
104103
func (eq *EventQueue) worker(id int) {
105104
defer eq.wg.Done()
105+
for {
106+
if stopped := eq.runWorker(id); stopped {
107+
return
108+
}
109+
// runWorker returned false after recovering from a panic — restart the loop.
110+
}
111+
}
112+
113+
// runWorker processes events until the channel is closed or context is canceled.
114+
// It returns true for a normal exit and false when recovered from a panic.
115+
func (eq *EventQueue) runWorker(id int) (stopped bool) {
106116
defer func() {
107117
if r := recover(); r != nil {
108-
logger.Errorf("Worker %d recovered from panic: %v", id, r)
109-
// Don't restart worker to prevent unbounded goroutine creation
110-
// The pool will continue with remaining workers
118+
logger.Errorf("Worker %d recovered from panic: %v, restarting", id, r)
119+
stopped = false
111120
}
112121
}()
113122

114123
for {
115124
select {
116125
case event, ok := <-eq.events:
117126
if !ok {
118-
// Channel closed, worker exits
119127
logger.Debugf("Worker %d shutting down", id)
120128

121-
return
129+
return true
122130
}
123131

124-
// Process the event with context
125132
if err := event.Process(eq.ctx); err != nil {
126-
logger.Debugf("Worker %d: error processing event: %v", id, err)
133+
logger.Errorf("Worker %d: error processing event [%v]: %v", id, event, err)
127134
}
128135

129136
case <-eq.ctx.Done():
130-
// Context canceled, exit gracefully
131137
logger.Debugf("Worker %d received shutdown signal", id)
132138

133-
return
139+
return true
134140
}
135141
}
136142
}

token/services/network/fabricx/finality/queue/queue_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ func TestEventProcessing_Success(t *testing.T) {
307307
}
308308
}
309309

310-
// TestEventProcessing_WithError tests event processing that returns errors
310+
// TestEventProcessing_WithError tests that a failing event is processed once and the error is logged
311311
func TestEventProcessing_WithError(t *testing.T) {
312312
cfg := queue.Config{Workers: 2, QueueSize: 10}
313313
eq, err := queue.NewEventQueue(cfg)
@@ -326,7 +326,8 @@ func TestEventProcessing_WithError(t *testing.T) {
326326

327327
// Wait for processing
328328
time.Sleep(100 * time.Millisecond)
329-
assert.True(t, event.wasProcessed())
329+
// Queue does not retry — event is processed exactly once
330+
assert.Equal(t, int32(1), atomic.LoadInt32(&event.processed))
330331
}
331332

332333
// TestEventProcessing_WithPanic tests worker recovery from panic

0 commit comments

Comments
 (0)