Skip to content

Commit aac9ef9

Browse files
committed
CBG-5555 better fix for activeOnly+limit
1 parent ba35f1e commit aac9ef9

2 files changed

Lines changed: 296 additions & 3 deletions

File tree

db/channel_cache_single.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -440,14 +440,22 @@ 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) && 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:]
447452
}
448453
n := len(resultFromCache)
449-
if options.Limit > 0 && room > 0 && room < n {
450-
n = room
454+
// Limit evaluation only valid when not activeOnly, since view and cache results don't apply activeOnly filtering
455+
if !options.ActiveOnly {
456+
if options.Limit > 0 && room > 0 && room < n {
457+
n = room
458+
}
451459
}
452460
result = append(result, resultFromCache[0:n]...)
453461
}

db/channel_cache_test.go

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,3 +598,288 @@ func TestChannelCacheBackgroundTaskWithIllegalTimeInterval(t *testing.T) {
598598
assert.Equal(t, "CleanAgedItems", backgroundTaskError.TaskName)
599599
assert.Equal(t, options.ChannelCacheAge, backgroundTaskError.Interval)
600600
}
601+
602+
// - The channel cache is validFrom sequence n, with one active mutation resident in the cache
603+
// - There are channel removals (only) in the bucket with m < sequence < n
604+
// - Client issues a GetChanges request with since=m
605+
func TestChannelCacheActiveOnlyAndLimit(t *testing.T) {
606+
ctx, db, collection := setupDBWithChannelCacheSize(t, 2)
607+
608+
const (
609+
activeChannel = "active"
610+
inactiveChannel = "inactive"
611+
doc1 = "doc1"
612+
doc2 = "doc2"
613+
doc3 = "doc3"
614+
)
615+
616+
// doc1 rev1: channel active
617+
// doc1 rev2: channel inactive
618+
// doc2 rev1: channel active
619+
// doc2 rev2: channel inactive
620+
// doc3 rev1: channel active
621+
revID, _, err := collection.Put(ctx, doc1, Body{"channels": activeChannel})
622+
require.NoError(t, err)
623+
_, _, err = collection.Put(ctx, doc1, Body{"channels": inactiveChannel, "_rev": revID})
624+
require.NoError(t, err)
625+
revID, _, err = collection.Put(ctx, doc2, Body{"channels": activeChannel})
626+
require.NoError(t, err)
627+
_, _, err = collection.Put(ctx, doc2, Body{"channels": inactiveChannel, "_rev": revID})
628+
require.NoError(t, err)
629+
630+
_, _, err = collection.Put(ctx, doc3, Body{"channels": activeChannel})
631+
require.NoError(t, err)
632+
633+
db.WaitForPendingChanges(t)
634+
635+
// prime channel cache, doc2 and doc3 should be in cache
636+
changesOptions := ChangesOptions{
637+
Since: SequenceID{Seq: 0},
638+
ActiveOnly: false,
639+
ChangesCtx: base.TestCtx(t),
640+
}
641+
require.Len(t, getChanges(t, collection, base.SetOf(activeChannel), changesOptions), 3)
642+
643+
// whether limit or no limit, should only be 1 active entry
644+
for _, limit := range []int{0, 1} {
645+
t.Run("limit="+fmt.Sprint(limit), func(t *testing.T) {
646+
changesOptions = ChangesOptions{
647+
Since: SequenceID{Seq: 0},
648+
ActiveOnly: true,
649+
ChangesCtx: base.TestCtx(t),
650+
Limit: limit,
651+
}
652+
require.Len(t, getChanges(t, collection, base.SetOf(activeChannel), changesOptions), 1)
653+
})
654+
}
655+
}
656+
657+
func TestChannelCacheActiveOnlyScenarios(t *testing.T) {
658+
const activeChannel = "active"
659+
660+
t.Run("query returns an active rev, cache is all removals", func(t *testing.T) {
661+
ctx, db, collection := setupDBWithChannelCacheSize(t, 2)
662+
663+
// doc1: active (seq 1) - will be in backing store, not cache
664+
_, _, _ = collection.Put(ctx, "doc1", Body{"channels": activeChannel})
665+
666+
// doc2: active (seq 2) -> inactive (seq 3) - seq 3 in cache
667+
revID2, _, _ := collection.Put(ctx, "doc2", Body{"channels": activeChannel})
668+
_, _, _ = collection.Put(ctx, "doc2", Body{"channels": "other", "_rev": revID2})
669+
670+
// doc3: active (seq 4) -> inactive (seq 5) - seq 5 in cache
671+
revID3, _, _ := collection.Put(ctx, "doc3", Body{"channels": activeChannel})
672+
_, _, _ = collection.Put(ctx, "doc3", Body{"channels": "other", "_rev": revID3})
673+
674+
db.WaitForPendingChanges(t)
675+
676+
// With limit 1 (before)
677+
changesOptions := ChangesOptions{Since: SequenceID{Seq: 0}, ActiveOnly: true, Limit: 1, ChangesCtx: base.TestCtx(t)}
678+
changes := getChanges(t, collection, base.SetOf(activeChannel), changesOptions)
679+
require.Len(t, changes, 1)
680+
assert.Equal(t, "doc1", changes[0].ID)
681+
682+
// No limit
683+
changesOptions.Limit = 0
684+
changes = getChanges(t, collection, base.SetOf(activeChannel), changesOptions)
685+
require.Len(t, changes, 1)
686+
assert.Equal(t, "doc1", changes[0].ID)
687+
688+
// With limit 1 (after)
689+
changesOptions.Limit = 1
690+
changes = getChanges(t, collection, base.SetOf(activeChannel), changesOptions)
691+
require.Len(t, changes, 1)
692+
assert.Equal(t, "doc1", changes[0].ID)
693+
})
694+
695+
t.Run("query returns an active rev, cache also has an active rev", func(t *testing.T) {
696+
ctx, db, collection := setupDBWithChannelCacheSize(t, 2)
697+
698+
// doc1: active (seq 1) - backing store
699+
_, _, _ = collection.Put(ctx, "doc1", Body{"channels": activeChannel})
700+
701+
// doc2: active (seq 2) -> inactive (seq 3) - cache
702+
revID2, _, _ := collection.Put(ctx, "doc2", Body{"channels": activeChannel})
703+
_, _, _ = collection.Put(ctx, "doc2", Body{"channels": "other", "_rev": revID2})
704+
705+
// doc3: active (seq 4) - cache
706+
_, _, _ = collection.Put(ctx, "doc3", Body{"channels": activeChannel})
707+
708+
db.WaitForPendingChanges(t)
709+
710+
// With limit 1 (before)
711+
changesOptions := ChangesOptions{Since: SequenceID{Seq: 0}, ActiveOnly: true, Limit: 1, ChangesCtx: base.TestCtx(t)}
712+
changes := getChanges(t, collection, base.SetOf(activeChannel), changesOptions)
713+
require.Len(t, changes, 1)
714+
assert.Equal(t, "doc1", changes[0].ID)
715+
716+
// No limit: should get doc1 and doc3
717+
changesOptions.Limit = 0
718+
changes = getChanges(t, collection, base.SetOf(activeChannel), changesOptions)
719+
require.Len(t, changes, 2)
720+
assert.Equal(t, "doc1", changes[0].ID)
721+
assert.Equal(t, "doc3", changes[1].ID)
722+
723+
// With limit 1 (after)
724+
changesOptions.Limit = 1
725+
changes = getChanges(t, collection, base.SetOf(activeChannel), changesOptions)
726+
require.Len(t, changes, 1)
727+
assert.Equal(t, "doc1", changes[0].ID)
728+
})
729+
730+
t.Run("query has no active revs, cache has no active revs", func(t *testing.T) {
731+
ctx, db, collection := setupDBWithChannelCacheSize(t, 2)
732+
733+
// doc1: active (seq 1) -> inactive (seq 2)
734+
revID1, _, _ := collection.Put(ctx, "doc1", Body{"channels": activeChannel})
735+
_, _, _ = collection.Put(ctx, "doc1", Body{"channels": "other", "_rev": revID1})
736+
737+
// doc2: active (seq 3) -> inactive (seq 4)
738+
revID2, _, _ := collection.Put(ctx, "doc2", Body{"channels": activeChannel})
739+
_, _, _ = collection.Put(ctx, "doc2", Body{"channels": "other", "_rev": revID2})
740+
741+
db.WaitForPendingChanges(t)
742+
743+
// With limit 1 (before)
744+
changesOptions := ChangesOptions{Since: SequenceID{Seq: 0}, ActiveOnly: true, Limit: 1, ChangesCtx: base.TestCtx(t)}
745+
changes := getChanges(t, collection, base.SetOf(activeChannel), changesOptions)
746+
require.Len(t, changes, 0)
747+
748+
// No limit: should get nothing
749+
changesOptions.Limit = 0
750+
changes = getChanges(t, collection, base.SetOf(activeChannel), changesOptions)
751+
require.Len(t, changes, 0)
752+
753+
// With limit 1 (after)
754+
changesOptions.Limit = 1
755+
changes = getChanges(t, collection, base.SetOf(activeChannel), changesOptions)
756+
require.Len(t, changes, 0)
757+
})
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+1)
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+
})
802+
}
803+
804+
func setupDBWithChannelCacheSize(t *testing.T, maxLength int) (context.Context, *Database, *DatabaseCollectionWithUser) {
805+
cacheOptions := DefaultCacheOptions()
806+
cacheOptions.ChannelCacheMaxLength = maxLength
807+
return setupDBWithChannelCacheSettings(t, cacheOptions)
808+
}
809+
810+
func setupDBWithChannelCacheSettings(t *testing.T, cacheOptions CacheOptions) (context.Context, *Database, *DatabaseCollectionWithUser) {
811+
db, ctx := setupTestDBWithCacheOptions(t, cacheOptions)
812+
t.Cleanup(func() { db.Close(ctx) })
813+
collection, ctx := GetSingleDatabaseCollectionWithUser(ctx, t, db)
814+
_, err := collection.UpdateSyncFun(ctx, channels.DocChannelsSyncFunction)
815+
require.NoError(t, err)
816+
return ctx, db, collection
817+
}
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)