Skip to content

Commit 656a87a

Browse files
committed
make a simpler test
1 parent 869dca6 commit 656a87a

8 files changed

Lines changed: 233 additions & 281 deletions

db/changes_test.go

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
package db
1010

1111
import (
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

2426
func 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+
}

db/changes_view.go

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -90,17 +90,14 @@ func (dbc *DatabaseContext) getQueryHandlerForCollection(collectionID uint32) (C
9090
}
9191

9292
// Queries the 'channels' view to get a range of sequences of a single channel as LogEntries.
93-
// The second return value, reachedEnd, is true if the query scanned all the way through to endSeq
94-
// rather than stopping early once an activeOnly+limit active-entry count was satisfied.
95-
func (c *DatabaseCollection) getChangesInChannelFromQuery(ctx context.Context, channelName string, startSeq, endSeq uint64, limit int, activeOnly bool) (entries LogEntries, reachedEnd bool, err error) {
93+
func (c *DatabaseCollection) getChangesInChannelFromQuery(ctx context.Context, channelName string, startSeq, endSeq uint64, limit int, activeOnly bool) (LogEntries, error) {
9694
if c.dataStore == nil {
97-
return nil, false, errors.New("No data store available for channel query")
95+
return nil, errors.New("No data store available for channel query")
9896
}
9997
start := time.Now()
10098
usingViews := c.useViews()
101-
entries = make(LogEntries, 0)
99+
entries := make(LogEntries, 0)
102100
activeEntryCount := 0
103-
reachedEnd = true
104101

105102
base.InfofCtx(ctx, base.KeyCache, " Querying 'channels' for %q (start=#%d, end=#%d, limit=%d)", base.UD(channelName), startSeq, endSeq, limit)
106103

@@ -113,7 +110,7 @@ func (c *DatabaseCollection) getChangesInChannelFromQuery(ctx context.Context, c
113110
// Query the view or index
114111
queryResults, err := c.QueryChannels(ctx, channelName, startSeq, endSeq, limit, activeOnly)
115112
if err != nil {
116-
return nil, false, err
113+
return nil, err
117114
}
118115
queryRowCount := 0
119116

@@ -148,27 +145,22 @@ func (c *DatabaseCollection) getChangesInChannelFromQuery(ctx context.Context, c
148145
// Close query results
149146
closeErr := queryResults.Close(ctx)
150147
if closeErr != nil {
151-
return nil, false, closeErr
148+
return nil, closeErr
152149
}
153150

154151
if queryRowCount == 0 {
155152
if len(entries) > 0 {
156153
break
157154
}
158155
base.InfofCtx(ctx, base.KeyCache, " Got no rows from query for channel:%q", base.UD(channelName))
159-
return nil, true, nil
156+
return nil, nil
160157
}
161158

162159
// If active-only, loop until either retrieve (limit) active entries, or reach endSeq. Non-active entries are still
163160
// included in the result set for potential cache prepend
164161
if activeOnly {
165-
// If we've reached limit before exhausting the range up to endSeq, we're done. highSeq
166-
// alone can't tell us whether the range was fully scanned - channels are often sparse,
167-
// so the last entry is frequently well below endSeq even when nothing was missed. But a
168-
// query call returning fewer than `limit` rows means the store had no more matching
169-
// entries in range, regardless of highSeq; otherwise fall back to the highSeq comparison.
170-
if limit != 0 && activeEntryCount >= limit {
171-
reachedEnd = queryRowCount < limit || (endSeq > 0 && highSeq >= endSeq)
162+
// If we've reached limit, we're done
163+
if activeEntryCount >= limit || limit == 0 {
172164
break
173165
}
174166
// If we've reached endSeq, we're done
@@ -194,5 +186,5 @@ func (c *DatabaseCollection) getChangesInChannelFromQuery(ctx context.Context, c
194186
elapsed, len(entries), base.UD(channelName), startSeq, endSeq, limit)
195187
}
196188
c.dbStats().Cache().ViewQueries.Add(1)
197-
return entries, reachedEnd, nil
189+
return entries, nil
198190
}

db/channel_cache.go

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,7 @@ var _ ChannelCache = &channelCacheImpl{}
8080

8181
// ChannelQueryHandler interface is implemented by databaseContext and databaseCollection.
8282
type ChannelQueryHandler interface {
83-
// getChangesInChannelFromQuery returns the LogEntries for a channel within [startSeq, endSeq], and
84-
// reachedEnd, which is true if the query scanned all the way through to endSeq without stopping
85-
// early due to satisfying an activeOnly+limit active-entry count first.
86-
getChangesInChannelFromQuery(ctx context.Context, channelName string, startSeq, endSeq uint64, limit int, activeOnly bool) (entries LogEntries, reachedEnd bool, err error)
83+
getChangesInChannelFromQuery(ctx context.Context, channelName string, startSeq, endSeq uint64, limit int, activeOnly bool) (LogEntries, error)
8784
}
8885

8986
// Function that returns a ChannelQueryHandlerFunc for the specified collectionID

db/channel_cache_single.go

Lines changed: 11 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,7 @@ func (c *singleChannelCacheImpl) GetChanges(ctx context.Context, options Changes
419419
// overlap, which helps confirm that we've got everything.
420420
c.cacheStats.ChannelCacheMisses.Add(1)
421421
endSeq := cacheValidFrom
422-
resultFromQuery, queryReachedEnd, err := c.queryHandler.getChangesInChannelFromQuery(ctx, c.channelID.Name, startSeq, endSeq, options.Limit, options.ActiveOnly)
422+
resultFromQuery, err := c.queryHandler.getChangesInChannelFromQuery(ctx, c.channelID.Name, startSeq, endSeq, options.Limit, options.ActiveOnly)
423423
if err != nil {
424424
return nil, err
425425
}
@@ -439,29 +439,17 @@ func (c *singleChannelCacheImpl) GetChanges(ctx context.Context, options Changes
439439
}
440440

441441
result := resultFromQuery
442-
if options.ActiveOnly {
443-
// The query's row limit doesn't correspond to a count of active entries, so cache is only
444-
// safe to append once the query itself confirms (via queryReachedEnd) that it scanned all
445-
// the way to the cache boundary - otherwise there may be unscanned active entries in between.
446-
if queryReachedEnd && len(resultFromCache) > 0 {
447-
if len(result) > 0 && resultFromCache[0].Sequence == result[len(result)-1].Sequence {
448-
resultFromCache = resultFromCache[1:]
449-
}
450-
result = append(result, resultFromCache...)
442+
room := options.Limit - len(result)
443+
if (options.Limit == 0 || room > 0) && len(resultFromCache) > 0 {
444+
// Concatenate the view & cache results:
445+
if len(result) > 0 && resultFromCache[0].Sequence == result[len(result)-1].Sequence {
446+
resultFromCache = resultFromCache[1:]
451447
}
452-
} else {
453-
room := options.Limit - len(result)
454-
if (options.Limit == 0 || room > 0) && len(resultFromCache) > 0 {
455-
// Concatenate the view & cache results:
456-
if len(result) > 0 && resultFromCache[0].Sequence == result[len(result)-1].Sequence {
457-
resultFromCache = resultFromCache[1:]
458-
}
459-
n := len(resultFromCache)
460-
if options.Limit > 0 && room > 0 && room < n {
461-
n = room
462-
}
463-
result = append(result, resultFromCache[0:n]...)
448+
n := len(resultFromCache)
449+
if options.Limit > 0 && room > 0 && room < n {
450+
n = room
464451
}
452+
result = append(result, resultFromCache[0:n]...)
465453
}
466454
base.InfofCtx(ctx, base.KeyCache, "GetChangesInChannel(%q) --> %d rows", base.UD(c.channelID), len(result))
467455

@@ -853,8 +841,7 @@ type bypassChannelCache struct {
853841
func (b *bypassChannelCache) GetChanges(ctx context.Context, options ChangesOptions) ([]*LogEntry, error) {
854842
startSeq := options.Since.SafeSequence() + 1
855843
endSeq := uint64(math.MaxUint64)
856-
entries, _, err := b.queryHandler.getChangesInChannelFromQuery(ctx, b.channel.Name, startSeq, endSeq, options.Limit, options.ActiveOnly)
857-
return entries, err
844+
return b.queryHandler.getChangesInChannelFromQuery(ctx, b.channel.Name, startSeq, endSeq, options.Limit, options.ActiveOnly)
858845
}
859846

860847
// No cached changes for bypassChannelCache

0 commit comments

Comments
 (0)