99package db
1010
1111import (
12+ "context"
1213 "fmt"
1314 "log"
1415 "reflect"
@@ -19,6 +20,7 @@ import (
1920 "github.com/couchbase/sync_gateway/channels"
2021 "github.com/couchbase/sync_gateway/testing/assert"
2122 "github.com/couchbase/sync_gateway/testing/require"
23+ "github.com/google/uuid"
2224)
2325
2426func TestFilterToAvailableChannels (t * testing.T ) {
@@ -572,3 +574,190 @@ func TestActiveOnlyWithLimit(t *testing.T) {
572574 assert .Equal (t , "doc_act_2" , changes [1 ].ID )
573575 assert .Equal (t , "doc_act_3" , changes [2 ].ID )
574576}
577+
578+ // stubSingleChannelCache is a minimal SingleChannelCache test double that returns a scripted
579+ // sequence of batches from GetChanges, one per call, in call order. It lets tests drive
580+ // changesFeed's own pagination bookkeeping directly, without needing real documents, DCP, or a
581+ // backing query/view implementation. Only GetChanges and ChannelID are exercised by changesFeed;
582+ // the remaining methods are unused stubs to satisfy the interface.
583+ type stubSingleChannelCache struct {
584+ channelID channels.ID
585+ batches [][]* LogEntry // one slice per call to GetChanges; calls past the end return empty
586+ calls int
587+ }
588+
589+ func (s * stubSingleChannelCache ) GetChanges (_ context.Context , _ ChangesOptions ) ([]* LogEntry , error ) {
590+ if s .calls >= len (s .batches ) {
591+ return nil , nil
592+ }
593+ batch := s .batches [s .calls ]
594+ s .calls ++
595+ return batch , nil
596+ }
597+
598+ func (s * stubSingleChannelCache ) GetCachedChanges (_ ChangesOptions ) (uint64 , []* LogEntry ) {
599+ return 0 , nil
600+ }
601+
602+ func (s * stubSingleChannelCache ) ChannelID () channels.ID {
603+ return s .channelID
604+ }
605+
606+ func (s * stubSingleChannelCache ) SupportsLateFeed () bool {
607+ return false
608+ }
609+
610+ func (s * stubSingleChannelCache ) LateSequenceUUID () uuid.UUID {
611+ return uuid.UUID {}
612+ }
613+
614+ func (s * stubSingleChannelCache ) GetLateSequencesSince (_ uint64 ) ([]* LogEntry , uint64 , error ) {
615+ return nil , 0 , nil
616+ }
617+
618+ func (s * stubSingleChannelCache ) RegisterLateSequenceClient () uint64 {
619+ return 0
620+ }
621+
622+ func (s * stubSingleChannelCache ) ReleaseLateSequenceClient (_ uint64 ) bool {
623+ return false
624+ }
625+
626+ // drainChangesFeed reads a changesFeed's output channel to completion, failing the test on any
627+ // error entry.
628+ func drainChangesFeed (t * testing.T , feed <- chan * ChangeEntry ) []* ChangeEntry {
629+ var received []* ChangeEntry
630+ for entry := range feed {
631+ require .NoError (t , entry .Err )
632+ received = append (received , entry )
633+ }
634+ return received
635+ }
636+
637+ // TestChangesFeedActiveOnlyContinuesPastInactiveBatch reproduces the CBG-5555 bug directly against
638+ // changesFeed, bypassing the cache/query layer entirely: a changesFeed that counts every entry it
639+ // forwards (active or not) against the caller's requested Limit will believe it's done as soon as
640+ // it has forwarded `Limit` raw entries - even if none of them were active. Here the first batch is
641+ // two channel-removal entries (exactly Limit=2 raw rows, zero active), and the second batch holds
642+ // the two active entries the caller actually asked for. A correct changesFeed must call GetChanges
643+ // a second time to find them.
644+ func TestChangesFeedActiveOnlyContinuesPastInactiveBatch (t * testing.T ) {
645+ db , ctx := setupTestDB (t )
646+ defer db .Close (ctx )
647+ collection , ctx := GetSingleDatabaseCollectionWithUser (ctx , t , db )
648+ collectionID := collection .GetCollectionID ()
649+ channelID := channels .NewID ("active" , collectionID )
650+
651+ stub := & stubSingleChannelCache {
652+ channelID : channelID ,
653+ batches : [][]* LogEntry {
654+ {
655+ {DocID : "removed1" , RevID : "1-a" , Sequence : 1 , Flags : channels .Removed , CollectionID : collectionID },
656+ {DocID : "removed2" , RevID : "1-a" , Sequence : 2 , Flags : channels .Removed , CollectionID : collectionID },
657+ },
658+ {
659+ {DocID : "active1" , RevID : "1-a" , Sequence : 3 , CollectionID : collectionID },
660+ {DocID : "active2" , RevID : "1-a" , Sequence : 4 , CollectionID : collectionID },
661+ },
662+ },
663+ }
664+
665+ options := ChangesOptions {
666+ Since : SequenceID {Seq : 0 },
667+ ActiveOnly : true ,
668+ Limit : 2 ,
669+ ChangesCtx : base .TestCtx (t ),
670+ }
671+
672+ received := drainChangesFeed (t , collection .changesFeed (ctx , stub , options , "test" ))
673+
674+ // changesFeed forwards every entry it sees, active or not - ActiveOnly filtering happens
675+ // upstream in SimpleMultiChangesFeed. What matters here is that all 4 entries were retrieved at
676+ // all, which requires a second call to GetChanges.
677+ require .Len (t , received , 4 )
678+ assert .Equal (t , "removed1" , received [0 ].ID )
679+ assert .Equal (t , "removed2" , received [1 ].ID )
680+ assert .Equal (t , "active1" , received [2 ].ID )
681+ assert .Equal (t , "active2" , received [3 ].ID )
682+ assert .Equal (t , 2 , stub .calls , "changesFeed should have called GetChanges a second time to find the requested 2 active entries" )
683+ }
684+
685+ // TestChangesFeedActiveOnlyMultipleInactiveBatches is the same shape as
686+ // TestChangesFeedActiveOnlyContinuesPastInactiveBatch but spans three all-inactive batches before
687+ // the active entries appear, verifying pagination genuinely continues round after round rather than
688+ // tolerating a single extra retry.
689+ func TestChangesFeedActiveOnlyMultipleInactiveBatches (t * testing.T ) {
690+ db , ctx := setupTestDB (t )
691+ defer db .Close (ctx )
692+ collection , ctx := GetSingleDatabaseCollectionWithUser (ctx , t , db )
693+ collectionID := collection .GetCollectionID ()
694+ channelID := channels .NewID ("active" , collectionID )
695+
696+ inactiveBatch := func (seqStart uint64 ) []* LogEntry {
697+ return []* LogEntry {
698+ {DocID : fmt .Sprintf ("removed%d" , seqStart ), RevID : "1-a" , Sequence : seqStart , Flags : channels .Removed , CollectionID : collectionID },
699+ {DocID : fmt .Sprintf ("removed%d" , seqStart + 1 ), RevID : "1-a" , Sequence : seqStart + 1 , Flags : channels .Removed , CollectionID : collectionID },
700+ }
701+ }
702+
703+ stub := & stubSingleChannelCache {
704+ channelID : channelID ,
705+ batches : [][]* LogEntry {
706+ inactiveBatch (1 ),
707+ inactiveBatch (3 ),
708+ inactiveBatch (5 ),
709+ {
710+ {DocID : "active1" , RevID : "1-a" , Sequence : 7 , CollectionID : collectionID },
711+ {DocID : "active2" , RevID : "1-a" , Sequence : 8 , CollectionID : collectionID },
712+ },
713+ },
714+ }
715+
716+ options := ChangesOptions {
717+ Since : SequenceID {Seq : 0 },
718+ ActiveOnly : true ,
719+ Limit : 2 ,
720+ ChangesCtx : base .TestCtx (t ),
721+ }
722+
723+ received := drainChangesFeed (t , collection .changesFeed (ctx , stub , options , "test" ))
724+
725+ require .Len (t , received , 8 )
726+ assert .Equal (t , "active1" , received [6 ].ID )
727+ assert .Equal (t , "active2" , received [7 ].ID )
728+ assert .Equal (t , 4 , stub .calls , "changesFeed should have paged through all three inactive batches to find the requested 2 active entries" )
729+ }
730+
731+ // TestChangesFeedActiveOnlyStopsWhenChannelExhausted verifies changesFeed terminates (rather than
732+ // looping forever) when the channel runs out of data before satisfying the requested ActiveOnly
733+ // Limit: the final batch is shorter than the pagination limit requested for that call, which is
734+ // changesFeed's signal that the channel has no more data.
735+ func TestChangesFeedActiveOnlyStopsWhenChannelExhausted (t * testing.T ) {
736+ db , ctx := setupTestDB (t )
737+ defer db .Close (ctx )
738+ collection , ctx := GetSingleDatabaseCollectionWithUser (ctx , t , db )
739+ collectionID := collection .GetCollectionID ()
740+ channelID := channels .NewID ("active" , collectionID )
741+
742+ stub := & stubSingleChannelCache {
743+ channelID : channelID ,
744+ batches : [][]* LogEntry {
745+ {
746+ {DocID : "removed1" , RevID : "1-a" , Sequence : 1 , Flags : channels .Removed , CollectionID : collectionID },
747+ },
748+ },
749+ }
750+
751+ options := ChangesOptions {
752+ Since : SequenceID {Seq : 0 },
753+ ActiveOnly : true ,
754+ Limit : 5 ,
755+ ChangesCtx : base .TestCtx (t ),
756+ }
757+
758+ received := drainChangesFeed (t , collection .changesFeed (ctx , stub , options , "test" ))
759+
760+ require .Len (t , received , 1 )
761+ assert .Equal (t , "removed1" , received [0 ].ID )
762+ assert .Equal (t , 1 , stub .calls , "changesFeed should stop after a short batch signals the channel is exhausted" )
763+ }
0 commit comments