@@ -9,12 +9,14 @@ package common
99import (
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
216349func TestValidateStructure (t * testing.T ) {
0 commit comments