@@ -3,6 +3,7 @@ package da
33import (
44 "context"
55 "errors"
6+ "fmt"
67 "testing"
78 "time"
89
@@ -456,3 +457,190 @@ func TestClient_Retrieve_Timeout(t *testing.T) {
456457 assert .Assert (t , result .Message != "" )
457458 })
458459}
460+
461+ func TestClient_Retrieve_PerRequestTimeout (t * testing.T ) {
462+ logger := zerolog .Nop ()
463+ dataLayerHeight := uint64 (100 )
464+ encodedNamespace := coreda .NamespaceFromString ("test-namespace" )
465+
466+ t .Run ("each batch gets independent timeout" , func (t * testing.T ) {
467+ // Create 250 IDs to force 3 batches (100, 100, 50)
468+ mockIDs := make ([][]byte , 250 )
469+ for i := range mockIDs {
470+ mockIDs [i ] = []byte (fmt .Sprintf ("id%d" , i ))
471+ }
472+ mockTimestamp := time .Now ()
473+
474+ batchCount := 0
475+ batchTimeout := 50 * time .Millisecond
476+
477+ mockDAInstance := & mockDA {
478+ getIDsFunc : func (ctx context.Context , height uint64 , namespace []byte ) (* coreda.GetIDsResult , error ) {
479+ return & coreda.GetIDsResult {
480+ IDs : mockIDs ,
481+ Timestamp : mockTimestamp ,
482+ }, nil
483+ },
484+ getFunc : func (ctx context.Context , ids []coreda.ID , namespace []byte ) ([]coreda.Blob , error ) {
485+ batchCount ++
486+ // Simulate some delay for each batch (less than timeout)
487+ time .Sleep (20 * time .Millisecond )
488+
489+ // Verify each batch's context has its own deadline
490+ deadline , ok := ctx .Deadline ()
491+ assert .Assert (t , ok , "batch should have a deadline" )
492+ // The deadline should be roughly batchTimeout from now (within tolerance)
493+ remaining := time .Until (deadline )
494+ assert .Assert (t , remaining > 0 , "deadline should be in the future" )
495+ assert .Assert (t , remaining <= batchTimeout , "deadline should be at most batchTimeout" )
496+
497+ // Return mock blobs
498+ blobs := make ([][]byte , len (ids ))
499+ for i := range blobs {
500+ blobs [i ] = []byte ("blob" )
501+ }
502+ return blobs , nil
503+ },
504+ }
505+
506+ client := NewClient (Config {
507+ DA : mockDAInstance ,
508+ Logger : logger ,
509+ Namespace : "test-namespace" ,
510+ DataNamespace : "test-data-namespace" ,
511+ DefaultTimeout : batchTimeout ,
512+ })
513+
514+ result := client .Retrieve (context .Background (), dataLayerHeight , encodedNamespace .Bytes ())
515+
516+ assert .Equal (t , coreda .StatusSuccess , result .Code )
517+ assert .Equal (t , 3 , batchCount , "should have made 3 batch requests" )
518+ assert .Equal (t , 250 , len (result .Data ), "should have retrieved all blobs" )
519+ })
520+
521+ t .Run ("succeeds even when total time exceeds single timeout" , func (t * testing.T ) {
522+ // This test verifies that even if the total operation takes longer than
523+ // a single timeout period, it succeeds because each individual request
524+ // gets its own fresh timeout.
525+ mockIDs := make ([][]byte , 300 ) // 3 batches
526+ for i := range mockIDs {
527+ mockIDs [i ] = []byte (fmt .Sprintf ("id%d" , i ))
528+ }
529+ mockTimestamp := time .Now ()
530+
531+ perRequestTimeout := 100 * time .Millisecond
532+ delayPerBatch := 40 * time .Millisecond // Each batch takes 40ms
533+
534+ mockDAInstance := & mockDA {
535+ getIDsFunc : func (ctx context.Context , height uint64 , namespace []byte ) (* coreda.GetIDsResult , error ) {
536+ time .Sleep (delayPerBatch ) // GetIDs also takes time
537+ return & coreda.GetIDsResult {
538+ IDs : mockIDs ,
539+ Timestamp : mockTimestamp ,
540+ }, nil
541+ },
542+ getFunc : func (ctx context.Context , ids []coreda.ID , namespace []byte ) ([]coreda.Blob , error ) {
543+ time .Sleep (delayPerBatch )
544+ blobs := make ([][]byte , len (ids ))
545+ for i := range blobs {
546+ blobs [i ] = []byte ("blob" )
547+ }
548+ return blobs , nil
549+ },
550+ }
551+
552+ client := NewClient (Config {
553+ DA : mockDAInstance ,
554+ Logger : logger ,
555+ Namespace : "test-namespace" ,
556+ DataNamespace : "test-data-namespace" ,
557+ DefaultTimeout : perRequestTimeout ,
558+ })
559+
560+ start := time .Now ()
561+ result := client .Retrieve (context .Background (), dataLayerHeight , encodedNamespace .Bytes ())
562+ elapsed := time .Since (start )
563+
564+ // Total time: GetIDs (40ms) + 3 batches * 40ms = 160ms
565+ // This is greater than perRequestTimeout (100ms), but should still succeed
566+ // because each individual request completes within its timeout
567+ assert .Equal (t , coreda .StatusSuccess , result .Code )
568+ assert .Assert (t , elapsed > perRequestTimeout , "total time should exceed single timeout" )
569+ assert .Equal (t , 300 , len (result .Data ))
570+ })
571+
572+ t .Run ("respects parent context cancellation" , func (t * testing.T ) {
573+ // Use 5 batches to ensure we have enough time to cancel mid-operation
574+ mockIDs := make ([][]byte , 500 ) // 5 batches of 100
575+ for i := range mockIDs {
576+ mockIDs [i ] = []byte (fmt .Sprintf ("id%d" , i ))
577+ }
578+ mockTimestamp := time .Now ()
579+ batchCount := 0
580+
581+ mockDAInstance := & mockDA {
582+ getIDsFunc : func (ctx context.Context , height uint64 , namespace []byte ) (* coreda.GetIDsResult , error ) {
583+ return & coreda.GetIDsResult {
584+ IDs : mockIDs ,
585+ Timestamp : mockTimestamp ,
586+ }, nil
587+ },
588+ getFunc : func (ctx context.Context , ids []coreda.ID , namespace []byte ) ([]coreda.Blob , error ) {
589+ batchCount ++
590+ time .Sleep (50 * time .Millisecond ) // Each batch takes 50ms
591+ blobs := make ([][]byte , len (ids ))
592+ for i := range blobs {
593+ blobs [i ] = []byte ("blob" )
594+ }
595+ return blobs , nil
596+ },
597+ }
598+
599+ client := NewClient (Config {
600+ DA : mockDAInstance ,
601+ Logger : logger ,
602+ Namespace : "test-namespace" ,
603+ DataNamespace : "test-data-namespace" ,
604+ DefaultTimeout : 1 * time .Second ,
605+ })
606+
607+ // Create a context that will be cancelled after the second batch completes
608+ // but before all batches finish
609+ ctx , cancel := context .WithCancel (context .Background ())
610+ go func () {
611+ time .Sleep (120 * time .Millisecond ) // Cancel after ~2 batches (2 * 50ms = 100ms)
612+ cancel ()
613+ }()
614+
615+ result := client .Retrieve (ctx , dataLayerHeight , encodedNamespace .Bytes ())
616+
617+ // Should fail due to context cancellation
618+ assert .Equal (t , coreda .StatusError , result .Code )
619+ assert .Assert (t , batchCount < 5 , "should not complete all batches, got %d" , batchCount )
620+ })
621+
622+ t .Run ("returns early if parent context already cancelled" , func (t * testing.T ) {
623+ mockDAInstance := & mockDA {
624+ getIDsFunc : func (ctx context.Context , height uint64 , namespace []byte ) (* coreda.GetIDsResult , error ) {
625+ t .Fatal ("GetIDs should not be called if context is already cancelled" )
626+ return nil , nil
627+ },
628+ }
629+
630+ client := NewClient (Config {
631+ DA : mockDAInstance ,
632+ Logger : logger ,
633+ Namespace : "test-namespace" ,
634+ DataNamespace : "test-data-namespace" ,
635+ DefaultTimeout : 1 * time .Second ,
636+ })
637+
638+ ctx , cancel := context .WithCancel (context .Background ())
639+ cancel () // Cancel immediately
640+
641+ result := client .Retrieve (ctx , dataLayerHeight , encodedNamespace .Bytes ())
642+
643+ assert .Equal (t , coreda .StatusError , result .Code )
644+ assert .Assert (t , result .Message != "" )
645+ })
646+ }
0 commit comments