Skip to content

Commit e28ca86

Browse files
committed
CBG-5555 better fix for activeOnly+limit
1 parent 7ecddfa commit e28ca86

2 files changed

Lines changed: 122 additions & 1 deletion

File tree

db/channel_cache_single.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -440,7 +440,12 @@ func (c *singleChannelCacheImpl) GetChanges(ctx context.Context, options Changes
440440

441441
result := resultFromQuery
442442
room := options.Limit - len(result)
443-
if (options.Limit == 0 || room > 0 || options.ActiveOnly) && len(resultFromCache) > 0 {
443+
// For activeOnly+limit, only append cache when the query reached the cache boundary.
444+
// If the query stopped early (gap exists before cacheValidFrom), the changesFeed pagination
445+
// loop must fetch that range before cache results are valid.
446+
// The overlap entry at cacheValidFrom is deduplicated below when cache is appended.
447+
queryReachedCache := len(resultFromQuery) == 0 || resultFromQuery[len(resultFromQuery)-1].Sequence >= endSeq
448+
if (options.Limit == 0 || room > 0 || (options.ActiveOnly && queryReachedCache)) && len(resultFromCache) > 0 {
444449
// Concatenate the view & cache results:
445450
if len(result) > 0 && resultFromCache[0].Sequence == result[len(result)-1].Sequence {
446451
resultFromCache = resultFromCache[1:]

db/channel_cache_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,15 +755,131 @@ func TestChannelCacheActiveOnlyScenarios(t *testing.T) {
755755
changes = getChanges(t, collection, base.SetOf(activeChannel), changesOptions)
756756
require.Len(t, changes, 0)
757757
})
758+
t.Run("cache populated, query requires pagination", func(t *testing.T) {
759+
cacheOptions := DefaultCacheOptions()
760+
cacheOptions.ChannelCacheMaxLength = 5
761+
cacheOptions.ChannelQueryLimit = 5
762+
ctx, db, collection := setupDBWithChannelCacheSettings(t, cacheOptions)
763+
// seed activeChannel in the cache prior to writing docs
764+
changesOptions := ChangesOptions{Since: SequenceID{Seq: 0}, ChangesCtx: base.TestCtx(t)}
765+
_ = getChanges(t, collection, base.SetOf(activeChannel), changesOptions)
766+
// Write 20 docs to the channel. 5 should be cached, 15 require query
767+
for i := range 20 {
768+
docID := fmt.Sprintf("doc%d", i)
769+
_, _, _ = collection.Put(ctx, docID, Body{"channels": activeChannel})
770+
}
771+
db.WaitForPendingChanges(t)
772+
changesOptions = ChangesOptions{Since: SequenceID{Seq: 0}, ActiveOnly: true, Limit: 0, ChangesCtx: base.TestCtx(t)}
773+
changes := getChanges(t, collection, base.SetOf(activeChannel), changesOptions)
774+
require.Len(t, changes, 20)
775+
for i, change := range changes {
776+
assert.Equal(t, fmt.Sprintf("doc%d", i+1), change.ID, "change at index %d", i)
777+
}
778+
})
779+
t.Run("cache populated, query requires pagination with limit", func(t *testing.T) {
780+
// With ChannelQueryLimit=5 and Limit=10, each GetChanges call returns 5 entries from the
781+
// query range. Without the queryHitActiveLimit guard, the first call would append the
782+
// cache (docs 16-20) immediately and skip docs 6-15.
783+
cacheOptions := DefaultCacheOptions()
784+
cacheOptions.ChannelCacheMaxLength = 5
785+
cacheOptions.ChannelQueryLimit = 5
786+
ctx, db, collection := setupDBWithChannelCacheSettings(t, cacheOptions)
787+
changesOptions := ChangesOptions{Since: SequenceID{Seq: 0}, ChangesCtx: base.TestCtx(t)}
788+
_ = getChanges(t, collection, base.SetOf(activeChannel), changesOptions)
789+
for i := 1; i <= 20; i++ {
790+
_, _, _ = collection.Put(ctx, fmt.Sprintf("doc%d", i), Body{"channels": activeChannel})
791+
}
792+
db.WaitForPendingChanges(t)
793+
// Limit=10 spans two query batches (5+5) before reaching cache. The pagination loop must
794+
// not prematurely append cache after the first batch hits the active limit.
795+
changesOptions = ChangesOptions{Since: SequenceID{Seq: 0}, ActiveOnly: true, Limit: 10, ChangesCtx: base.TestCtx(t)}
796+
changes := getChanges(t, collection, base.SetOf(activeChannel), changesOptions)
797+
require.Len(t, changes, 10)
798+
for i, change := range changes {
799+
assert.Equal(t, fmt.Sprintf("doc%d", i+1), change.ID, "change at index %d", i)
800+
}
801+
})
758802
}
759803

760804
func setupDBWithChannelCacheSize(t *testing.T, maxLength int) (context.Context, *Database, *DatabaseCollectionWithUser) {
761805
cacheOptions := DefaultCacheOptions()
762806
cacheOptions.ChannelCacheMaxLength = maxLength
807+
return setupDBWithChannelCacheSettings(t, cacheOptions)
808+
}
809+
810+
func setupDBWithChannelCacheSettings(t *testing.T, cacheOptions CacheOptions) (context.Context, *Database, *DatabaseCollectionWithUser) {
763811
db, ctx := setupTestDBWithCacheOptions(t, cacheOptions)
764812
t.Cleanup(func() { db.Close(ctx) })
765813
collection, ctx := GetSingleDatabaseCollectionWithUser(ctx, t, db)
766814
_, err := collection.UpdateSyncFun(ctx, channels.DocChannelsSyncFunction)
767815
require.NoError(t, err)
768816
return ctx, db, collection
769817
}
818+
819+
// FuzzChannelCacheActiveOnly verifies that GetChanges with ActiveOnly=true returns every active entry
820+
// exactly once, in sequence order, regardless of how results are split between backing-store query
821+
// batches and the in-memory cache.
822+
func FuzzChannelCacheActiveOnly(f *testing.F) {
823+
f.Add(uint8(20), uint8(5), uint8(5), uint8(10)) // query + cache, limit spans two batches
824+
f.Add(uint8(5), uint8(5), uint8(5), uint8(0)) // all docs fit in cache, no limit
825+
f.Add(uint8(1), uint8(5), uint8(5), uint8(1)) // single doc, limit=1
826+
f.Add(uint8(0), uint8(5), uint8(5), uint8(5)) // no docs
827+
f.Add(uint8(10), uint8(2), uint8(2), uint8(5)) // tiny cache + single-entry query batches
828+
f.Add(uint8(15), uint8(5), uint8(5), uint8(5)) // limit < cache boundary
829+
830+
f.Fuzz(func(t *testing.T, numDocs, cacheMaxLength, queryLimit, requestLimit uint8) {
831+
if cacheMaxLength == 0 {
832+
cacheMaxLength = 1
833+
}
834+
if queryLimit == 0 {
835+
queryLimit = 1
836+
}
837+
const maxDocs = 30
838+
n := int(numDocs)
839+
if n > maxDocs {
840+
n = maxDocs
841+
}
842+
843+
cacheOptions := DefaultCacheOptions()
844+
cacheOptions.ChannelCacheMaxLength = int(cacheMaxLength)
845+
cacheOptions.ChannelQueryLimit = int(queryLimit)
846+
ctx, db, collection := setupDBWithChannelCacheSettings(t, cacheOptions)
847+
848+
// Seed the channel in the cache before writing docs so subsequent writes land
849+
// in the cache from sequence 1.
850+
_ = getChanges(t, collection, base.SetOf("active"), ChangesOptions{
851+
Since: SequenceID{Seq: 0}, ChangesCtx: base.TestCtx(t),
852+
})
853+
for i := 1; i <= n; i++ {
854+
_, _, _ = collection.Put(ctx, fmt.Sprintf("doc%d", i), Body{"channels": "active"})
855+
}
856+
db.WaitForPendingChanges(t)
857+
858+
changes := getChanges(t, collection, base.SetOf("active"), ChangesOptions{
859+
Since: SequenceID{Seq: 0},
860+
ActiveOnly: true,
861+
Limit: int(requestLimit),
862+
ChangesCtx: base.TestCtx(t),
863+
})
864+
865+
// No duplicates.
866+
seen := make(map[string]bool, len(changes))
867+
for _, c := range changes {
868+
require.False(t, seen[c.ID], "duplicate entry %s", c.ID)
869+
seen[c.ID] = true
870+
}
871+
872+
// Correct count: all docs, or exactly requestLimit if that's the smaller constraint.
873+
limit := int(requestLimit)
874+
expectedCount := n
875+
if limit > 0 && limit < n {
876+
expectedCount = limit
877+
}
878+
require.Len(t, changes, expectedCount)
879+
880+
// Entries must be the first expectedCount docs in insertion order.
881+
for i, c := range changes {
882+
require.Equal(t, fmt.Sprintf("doc%d", i+1), c.ID, "wrong doc at index %d", i)
883+
}
884+
})
885+
}

0 commit comments

Comments
 (0)