Skip to content

Commit 290045d

Browse files
committed
Added Backoff
Signed-off-by: Effi-S <effi.szt@gmail.com>
1 parent 7ce2972 commit 290045d

2 files changed

Lines changed: 199 additions & 2 deletions

File tree

token/core/common/auditor.go

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package common
88

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

1213
"github.com/LFDT-Panurus/panurus/token/driver"
1314
"github.com/LFDT-Panurus/panurus/token/driver/protos-go/v1/request"
@@ -17,6 +18,16 @@ import (
1718
"go.opentelemetry.io/otel/trace"
1819
)
1920

21+
// AuditTokensNumRetries and AuditTokensRetryDelay control how RetrieveAuditTokens
22+
// tolerates the read-timing race in which a referenced token's producing
23+
// transaction is still pending, so its outputs have not yet been persisted to the
24+
// token store by the asynchronous finality listener. They mirror the retry/backoff
25+
// already applied on the sibling token.QueryEngine audit path (see token/vault.go).
26+
var (
27+
AuditTokensNumRetries = 3
28+
AuditTokensRetryDelay = 3 * time.Second
29+
)
30+
2031
// AuditContext contains the context for token request auditing.
2132
type AuditContext[P driver.PublicParameters, IA driver.IssueAction, TA driver.TransferAction, DS driver.Deserializer] struct {
2233
Logger logging.Logger
@@ -267,7 +278,7 @@ func ExtractTokenIDsAndCheckDuplicates(
267278
// The returned map uses token ID pointers as keys, allowing callers to efficiently look up
268279
// tokens by their ID during validation.
269280
//
270-
// IMPORTANT: This function always returns a non-nil map (possibly empty) to ensure
281+
// This function always returns a non-nil map (possibly empty) to ensure
271282
// validation logic can distinguish between "no tokens requested" and "tokens not found".
272283
func RetrieveAuditTokens(
273284
ctx context.Context,
@@ -288,7 +299,7 @@ func RetrieveAuditTokens(
288299
}
289300

290301
logger.DebugfContext(ctx, "[%s] retrieving [%d] audit tokens...", anchor, len(tokenIDs))
291-
tokens, err := queryEngine.ListAuditTokens(ctx, tokenIDs...)
302+
tokens, err := listAuditTokensWithRetry(ctx, logger, queryEngine, tokenIDs, anchor)
292303
if err != nil {
293304
return nil, errors.WithMessagef(err, "failed to retrieve audit tokens for tx [%s]", anchor)
294305
}
@@ -304,6 +315,59 @@ func RetrieveAuditTokens(
304315
return auditTokens, nil
305316
}
306317

318+
// listAuditTokensWithRetry calls queryEngine.ListAuditTokens, tolerating the
319+
// read-timing race where a referenced token is momentarily missing from the token
320+
// store because its producing transaction is still pending (its outputs are
321+
// persisted only later, by the asynchronous finality listener). On failure, it
322+
// checks whether any requested token belongs to a still-pending transaction and,
323+
// if so, waits AuditTokensRetryDelay and retries up to AuditTokensNumRetries times
324+
// before giving up. This mirrors the tolerance already implemented for the sibling
325+
// Audit() path in token/vault.go, so the earlier AuditorCheck gate no longer
326+
// spuriously rejects a validly-audited, quickly-chained transaction.
327+
func listAuditTokensWithRetry(
328+
ctx context.Context,
329+
logger logging.Logger,
330+
queryEngine driver.QueryEngine,
331+
tokenIDs []*token.ID,
332+
anchor driver.TokenRequestAnchor,
333+
) ([]*token.Token, error) {
334+
var tokens []*token.Token
335+
var err error
336+
337+
for i := range AuditTokensNumRetries {
338+
tokens, err = queryEngine.ListAuditTokens(ctx, tokenIDs...)
339+
if err == nil {
340+
return tokens, nil
341+
}
342+
343+
// The lookup failed. Check whether any requested token belongs to a
344+
// transaction that is still pending; if so, the row is expected to appear
345+
// once the finality listener persists it, so wait a bit and retry.
346+
retry := false
347+
for _, id := range tokenIDs {
348+
pending, pErr := queryEngine.IsPending(ctx, id)
349+
if pending || pErr != nil {
350+
logger.Warnf("[%s] cannot get audit token for id [%s] because the relative transaction is pending, retry [%d/%d]: with err [%v]", anchor, id, i+1, AuditTokensNumRetries, pErr)
351+
if i == AuditTokensNumRetries-1 {
352+
return nil, errors.Errorf("failed to get audit tokens, tx [%s] is still pending", id.TxId)
353+
}
354+
retry = true
355+
356+
break
357+
}
358+
}
359+
360+
if !retry {
361+
// None of the tokens is pending: this is a genuine failure, do not retry.
362+
return nil, err
363+
}
364+
365+
time.Sleep(AuditTokensRetryDelay)
366+
}
367+
368+
return tokens, err
369+
}
370+
307371
// ValidateStructure ensures complete structural correspondence between TokenRequest and TokenRequestMetadata.
308372
// It validates that:
309373
// - Action counts match between request and metadata

token/core/common/auditor_test.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ package common
99
import (
1010
"context"
1111
"testing"
12+
"time"
1213

1314
"github.com/LFDT-Panurus/panurus/token/driver"
1415
"github.com/LFDT-Panurus/panurus/token/driver/mock"
1516
"github.com/LFDT-Panurus/panurus/token/driver/protos-go/v1/request"
1617
"github.com/LFDT-Panurus/panurus/token/services/logging"
1718
"github.com/LFDT-Panurus/panurus/token/token"
19+
"github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors"
1820
"github.com/stretchr/testify/assert"
1921
"github.com/stretchr/testify/require"
2022
)
@@ -211,6 +213,137 @@ func TestRetrieveAuditTokens(t *testing.T) {
211213
assert.Equal(t, tok1, tokens[id1.String()])
212214
assert.Nil(t, tokens[id2.String()])
213215
})
216+
217+
t.Run("RetriesWhilePendingThenSucceeds", func(t *testing.T) {
218+
// The producing tx is still pending on the first lookup (row not yet
219+
// persisted), then becomes available on the retry. This is the exact
220+
// read-timing race from issue #2105: the token must be resolved, not
221+
// spuriously rejected.
222+
defer withFastAuditRetries(t)()
223+
224+
qe := &mock.QueryEngine{}
225+
id1 := &token.ID{TxId: "tx1", Index: 0}
226+
tokenIDs := []*token.ID{id1}
227+
228+
tok1 := &token.Token{Type: "USD", Quantity: "100"}
229+
qe.ListAuditTokensReturnsOnCall(0, nil, errors.New("token not found for key [tx1:0]"))
230+
qe.ListAuditTokensReturnsOnCall(1, []*token.Token{tok1}, nil)
231+
qe.IsPendingReturns(true, nil)
232+
233+
tokens, err := RetrieveAuditTokens(ctx, logger, qe, tokenIDs, anchor)
234+
require.NoError(t, err)
235+
assert.Len(t, tokens, 1)
236+
assert.Equal(t, tok1, tokens[id1.String()])
237+
assert.Equal(t, 2, qe.ListAuditTokensCallCount())
238+
})
239+
240+
t.Run("NoRetryWhenNotPending", func(t *testing.T) {
241+
// A genuine failure (no requested token is pending) must fail fast,
242+
// without spending the retry budget.
243+
defer withFastAuditRetries(t)()
244+
245+
qe := &mock.QueryEngine{}
246+
id1 := &token.ID{TxId: "tx1", Index: 0}
247+
tokenIDs := []*token.ID{id1}
248+
249+
qe.ListAuditTokensReturns(nil, assert.AnError)
250+
qe.IsPendingReturns(false, nil)
251+
252+
tokens, err := RetrieveAuditTokens(ctx, logger, qe, tokenIDs, anchor)
253+
require.Error(t, err)
254+
assert.Nil(t, tokens)
255+
assert.Equal(t, 1, qe.ListAuditTokensCallCount())
256+
})
257+
258+
t.Run("ExhaustsRetriesWhileStillPending", func(t *testing.T) {
259+
// The producing tx never leaves the pending state within the grace
260+
// window: we give up with a clear "still pending" error.
261+
defer withFastAuditRetries(t)()
262+
263+
qe := &mock.QueryEngine{}
264+
id1 := &token.ID{TxId: "tx1", Index: 0}
265+
tokenIDs := []*token.ID{id1}
266+
267+
qe.ListAuditTokensReturns(nil, errors.New("token not found for key [tx1:0]"))
268+
qe.IsPendingReturns(true, nil)
269+
270+
tokens, err := RetrieveAuditTokens(ctx, logger, qe, tokenIDs, anchor)
271+
require.Error(t, err)
272+
assert.Contains(t, err.Error(), "still pending")
273+
assert.Nil(t, tokens)
274+
assert.Equal(t, AuditTokensNumRetries, qe.ListAuditTokensCallCount())
275+
})
276+
}
277+
278+
// withFastAuditRetries shrinks the audit-token retry delay for the duration of a
279+
// test so the pending-status retry path can be exercised without real sleeps, and
280+
// restores the original values on cleanup.
281+
func withFastAuditRetries(t *testing.T) func() {
282+
t.Helper()
283+
origDelay := AuditTokensRetryDelay
284+
AuditTokensRetryDelay = time.Millisecond
285+
286+
return func() { AuditTokensRetryDelay = origDelay }
287+
}
288+
289+
// BenchmarkRetrieveAuditTokens measures the latency added by the pending-status
290+
// retry/backoff introduced for issue #2105.
291+
//
292+
// - NoRace: the common case — the token is present on the first lookup,
293+
// so no retry occurs and the added latency is only the (skipped) retry check.
294+
// - RaceResolvesOnRetry: the issue #2105 race — the first lookup misses while
295+
// the producing tx is pending, and the token is resolved on the retry. This
296+
// is where the one AuditTokensRetryDelay backoff is paid.
297+
//
298+
// Run with a real delay to see the actual grace-window cost:
299+
//
300+
// go test ./token/core/common/ -run '^$' -bench BenchmarkRetrieveAuditTokens -benchtime=20x
301+
func BenchmarkRetrieveAuditTokens(b *testing.B) {
302+
ctx := context.Background()
303+
logger := &logging.MockLogger{}
304+
anchor := driver.TokenRequestAnchor("bench-tx")
305+
id1 := &token.ID{TxId: "tx1", Index: 0}
306+
tokenIDs := []*token.ID{id1}
307+
tok1 := &token.Token{Type: "USD", Quantity: "100"}
308+
309+
b.Run("NoRace", func(b *testing.B) {
310+
qe := &mock.QueryEngine{}
311+
qe.ListAuditTokensReturns([]*token.Token{tok1}, nil)
312+
313+
b.ReportAllocs()
314+
for range b.N {
315+
if _, err := RetrieveAuditTokens(ctx, logger, qe, tokenIDs, anchor); err != nil {
316+
b.Fatal(err)
317+
}
318+
}
319+
})
320+
321+
b.Run("RaceResolvesOnRetry", func(b *testing.B) {
322+
// Keep the retry mechanics but use a tiny backoff so the benchmark
323+
// measures the added path cost rather than the wall-clock delay itself.
324+
origDelay := AuditTokensRetryDelay
325+
AuditTokensRetryDelay = time.Millisecond
326+
defer func() { AuditTokensRetryDelay = origDelay }()
327+
328+
qe := &mock.QueryEngine{}
329+
qe.IsPendingReturns(true, nil)
330+
qe.ListAuditTokensStub = func(_ context.Context, _ ...*token.ID) ([]*token.Token, error) {
331+
// Miss on every odd call, hit on every even call, so each iteration
332+
// pays exactly one retry.
333+
if qe.ListAuditTokensCallCount()%2 == 1 {
334+
return nil, errors.New("token not found for key [tx1:0]")
335+
}
336+
337+
return []*token.Token{tok1}, nil
338+
}
339+
340+
b.ReportAllocs()
341+
for range b.N {
342+
if _, err := RetrieveAuditTokens(ctx, logger, qe, tokenIDs, anchor); err != nil {
343+
b.Fatal(err)
344+
}
345+
}
346+
})
214347
}
215348

216349
func TestValidateStructure(t *testing.T) {

0 commit comments

Comments
 (0)