blob: 950d7a0bbd3bfa4e3eb3387bcfaa27d6a9dcf783 [file]
// Copyright 2025 The etcd Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cache
import (
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/require"
"go.etcd.io/etcd/api/v3/mvccpb"
clientv3 "go.etcd.io/etcd/client/v3"
)
func TestInit(t *testing.T) {
type want struct {
min int64
max int64
historyRevs []int64
}
tests := []struct {
name string
capacity int
initRev int64
eventRevs []int64
shouldReinit bool
reinitRev int64
want want
}{
{
name: "first init sets only min",
capacity: 8,
initRev: 5,
eventRevs: nil,
shouldReinit: false,
want: want{min: 5, max: 0, historyRevs: nil},
},
{
name: "init on empty demux with events",
capacity: 8,
initRev: 5,
eventRevs: []int64{7, 9, 13},
shouldReinit: false,
want: want{min: 5, max: 13, historyRevs: []int64{7, 9, 13}},
},
{
name: "continuation at max+1 preserves range and history",
capacity: 8,
initRev: 10,
eventRevs: []int64{13, 15, 21},
shouldReinit: true,
reinitRev: 22,
want: want{min: 10, max: 21, historyRevs: []int64{13, 15, 21}},
},
{
name: "gap from max triggers purge and clears history",
capacity: 8,
initRev: 10,
eventRevs: []int64{13, 15, 21},
shouldReinit: true,
reinitRev: 30,
want: want{min: 30, max: 0, historyRevs: nil},
},
{
name: "idempotent reinit at same revision clears history",
capacity: 8,
initRev: 7,
eventRevs: []int64{8, 9, 10},
shouldReinit: true,
reinitRev: 7,
want: want{min: 7, max: 0, historyRevs: nil},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
d := newDemux(tt.capacity, 10*time.Millisecond)
d.Init(tt.initRev)
if len(tt.eventRevs) > 0 {
if err := d.Broadcast(respWithEventRevs(tt.eventRevs...)); err != nil {
t.Fatalf("Broadcast(%v) failed: %v", tt.eventRevs, err)
}
}
if tt.shouldReinit {
d.Init(tt.reinitRev)
}
if d.minRev != tt.want.min || d.maxRev != tt.want.max {
t.Fatalf("revision range: got(min=%d, max=%d), want(min=%d, max=%d)",
d.minRev, d.maxRev, tt.want.min, tt.want.max)
}
var actualHistoryRevs []int64
d.history.AscendGreaterOrEqual(0, func(rev int64, events []*clientv3.Event) bool {
actualHistoryRevs = append(actualHistoryRevs, rev)
return true
})
if diff := cmp.Diff(tt.want.historyRevs, actualHistoryRevs); diff != "" {
t.Fatalf("history validation failed (-want +got):\n%s", diff)
}
})
}
}
func TestBroadcast(t *testing.T) {
type want struct {
min int64
max int64
shouldError bool
}
tests := []struct {
name string
capacity int
initRev int64
initialRevs []int64
followupRevs []int64
want want
}{
{
name: "history not full",
capacity: 2,
initRev: 1,
initialRevs: []int64{2},
want: want{min: 1, max: 2, shouldError: false},
},
{
name: "history at exact capacity",
capacity: 2,
initRev: 1,
initialRevs: []int64{2, 3},
want: want{min: 1, max: 3, shouldError: false},
},
{
name: "history overflow with eviction",
capacity: 2,
initRev: 1,
initialRevs: []int64{2, 3, 4},
want: want{min: 3, max: 4, shouldError: false},
},
{
name: "history overflow not continuous",
capacity: 2,
initRev: 2,
initialRevs: []int64{4, 8, 16},
want: want{min: 5, max: 16, shouldError: false},
},
{
name: "empty broadcast is no-op",
capacity: 8,
initRev: 10,
initialRevs: []int64{},
want: want{min: 10, max: 0, shouldError: false},
},
{
name: "revisions below maxRev are rejected",
capacity: 8,
initRev: 4,
initialRevs: []int64{5, 6},
followupRevs: []int64{4},
want: want{shouldError: true},
},
{
name: "revisions equal to maxRev are rejected",
capacity: 8,
initRev: 4,
initialRevs: []int64{5, 6},
followupRevs: []int64{6},
want: want{shouldError: true},
},
{
name: "revisions above maxRev are accepted",
capacity: 8,
initRev: 4,
initialRevs: []int64{5, 6},
followupRevs: []int64{9, 14, 17},
want: want{min: 4, max: 17, shouldError: false},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
d := newDemux(tt.capacity, 10*time.Millisecond)
d.Init(tt.initRev)
if len(tt.initialRevs) > 0 {
if err := d.Broadcast(respWithEventRevs(tt.initialRevs...)); err != nil {
t.Fatalf("unexpected error broadcasting initial revisions %v: %v", tt.initialRevs, err)
}
}
if len(tt.followupRevs) > 0 {
err := d.Broadcast(respWithEventRevs(tt.followupRevs...))
if tt.want.shouldError {
require.Error(t, err)
return
}
require.NoError(t, err)
}
if d.minRev != tt.want.min || d.maxRev != tt.want.max {
t.Fatalf("revision range: got(min=%d, max=%d), want(min=%d, max=%d)",
d.minRev, d.maxRev, tt.want.min, tt.want.max)
}
})
}
}
func TestBroadcastBatching(t *testing.T) {
tests := []struct {
name string
input []int64
wantRevs []int64
wantSizes []int
}{
{
name: "two groups",
input: []int64{14, 14, 15, 15, 15},
wantRevs: []int64{14},
wantSizes: []int{5},
},
{
name: "single group",
input: []int64{7, 7, 7},
wantRevs: []int64{7},
wantSizes: []int{3},
},
{
name: "all distinct",
input: []int64{1, 2, 3},
wantRevs: []int64{1},
wantSizes: []int{3},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
d := newDemux(16, 10*time.Millisecond)
w := newWatcher(len(tt.input)+1, nil)
d.Init(1)
d.Register(w, 0)
d.Broadcast(respWithEventRevs(tt.input...))
gotRevs, gotSizes := readBatches(t, w, len(tt.wantRevs))
if diff := cmp.Diff(tt.wantRevs, gotRevs); diff != "" {
t.Fatalf("revision mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(tt.wantSizes, gotSizes); diff != "" {
t.Fatalf("batch size mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestSlowWatcherResync(t *testing.T) {
tests := []struct {
name string
input []int64
wantInitialRevs []int64
wantInitialSizes []int
wantResyncRevs []int64
wantResyncSizes []int
}{
{
name: "single event overflow",
input: []int64{1, 2, 3},
wantInitialRevs: []int64{1},
wantInitialSizes: []int{3},
wantResyncRevs: []int64{},
wantResyncSizes: []int{},
},
{
name: "multi events batch overflow",
input: []int64{10, 10, 11, 12, 12},
wantInitialRevs: []int64{10},
wantInitialSizes: []int{5},
wantResyncRevs: []int64{},
wantResyncSizes: []int{},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
d := newDemux(16, 10*time.Millisecond)
w := newWatcher(1, nil)
d.Init(1)
d.Register(w, 0)
d.Broadcast(respWithEventRevs(tt.input...))
gotInitRevs, gotInitSizes := readBatches(t, w, len(tt.wantInitialRevs))
if diff := cmp.Diff(tt.wantInitialRevs, gotInitRevs); diff != "" {
t.Fatalf("initial revs mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(tt.wantInitialSizes, gotInitSizes); diff != "" {
t.Fatalf("initial batch sizes mismatch (-want +got):\n%s", diff)
}
gotRevs, gotSizes := make([]int64, 0, len(tt.wantResyncRevs)), make([]int, 0, len(tt.wantResyncRevs))
for len(gotRevs) < len(tt.wantResyncRevs) {
d.resyncLaggingWatchers()
revs, sizes := readBatches(t, w, 1)
gotRevs = append(gotRevs, revs...)
gotSizes = append(gotSizes, sizes...)
}
if diff := cmp.Diff(tt.wantResyncRevs, gotRevs); diff != "" {
t.Fatalf("resync revs mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(tt.wantResyncSizes, gotSizes); diff != "" {
t.Fatalf("resync batch sizes mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestWatcherCompactResponseHasHeader(t *testing.T) {
w := newWatcher(1, nil)
w.Compact(7)
require.NotNil(t, w.cancelResp.Header)
require.True(t, w.cancelResp.Canceled)
require.Equal(t, int64(7), w.cancelResp.CompactRevision)
}
func TestBroadcastProgress(t *testing.T) {
t.Run("sends progress to active watchers", func(t *testing.T) {
d := newDemux(16, 10*time.Millisecond)
d.Init(1)
d.maxRev = 10
w1 := newWatcher(8, nil)
w2 := newWatcher(8, nil)
d.Register(w1, 0)
d.Register(w2, 0)
d.BroadcastProgress()
resp1 := readResponse(t, w1.respCh)
require.Truef(t, resp1.IsProgressNotify(), "expected progress notify")
require.Equal(t, int64(10), resp1.Header.Revision)
resp2 := readResponse(t, w2.respCh)
require.Truef(t, resp2.IsProgressNotify(), "expected progress notify")
require.Equal(t, int64(10), resp2.Header.Revision)
})
t.Run("is no-op when maxRev is zero", func(t *testing.T) {
d := newDemux(16, 10*time.Millisecond)
d.Init(1)
w := newWatcher(8, nil)
d.Register(w, 0)
d.maxRev = 0
d.BroadcastProgress()
select {
case <-w.respCh:
t.Fatal("expected no response when maxRev is 0")
default:
}
})
t.Run("sends progress only to active watchers", func(t *testing.T) {
d := newDemux(16, 10*time.Millisecond)
d.Init(1)
d.maxRev = 10
active := newWatcher(8, nil)
lagging := newWatcher(8, nil)
d.Register(active, 0) // startingRev becomes maxRev+1 => active
d.Register(lagging, 5)
d.BroadcastProgress()
activeResp := readResponse(t, active.respCh)
require.Truef(t, activeResp.IsProgressNotify(), "expected progress notify for active watcher")
require.Equal(t, int64(10), activeResp.Header.Revision)
select {
case <-lagging.respCh:
t.Fatal("expected no progress notify for lagging watcher")
default:
}
})
}
func TestBroadcastProgressTo(t *testing.T) {
t.Run("sends progress only to the target watcher", func(t *testing.T) {
d := newDemux(16, 10*time.Millisecond)
d.Init(1)
d.maxRev = 10
target := newWatcher(8, nil)
other := newWatcher(8, nil)
d.Register(target, 0)
d.Register(other, 0)
d.BroadcastProgressTo(target)
resp := readResponse(t, target.respCh)
require.Truef(t, resp.IsProgressNotify(), "expected progress notify")
require.Equal(t, int64(10), resp.Header.Revision)
select {
case <-other.respCh:
t.Fatal("expected no progress notify for other watcher")
default:
}
})
t.Run("is no-op when maxRev is zero", func(t *testing.T) {
d := newDemux(16, 10*time.Millisecond)
d.Init(1)
w := newWatcher(8, nil)
d.Register(w, 0)
d.maxRev = 0
d.BroadcastProgressTo(w)
select {
case <-w.respCh:
t.Fatal("expected no response when maxRev is 0")
default:
}
})
t.Run("is no-op for lagging watcher", func(t *testing.T) {
d := newDemux(16, 10*time.Millisecond)
d.Init(1)
d.maxRev = 10
w := newWatcher(8, nil)
d.Register(w, 5) // startingRev <= maxRev => lagging
d.BroadcastProgressTo(w)
select {
case <-w.respCh:
t.Fatal("expected no progress notify for lagging watcher")
default:
}
})
}
func readResponse(t *testing.T, ch <-chan clientv3.WatchResponse) clientv3.WatchResponse {
t.Helper()
select {
case resp := <-ch:
return resp
case <-time.After(2 * time.Second):
t.Fatalf("timed out waiting for response")
return clientv3.WatchResponse{}
}
}
func respWithEventRevs(revs ...int64) clientv3.WatchResponse {
events := make([]*clientv3.Event, 0, len(revs))
for _, r := range revs {
kv := &mvccpb.KeyValue{
Key: []byte("k"),
Value: []byte("v"),
ModRevision: r,
}
events = append(events, &clientv3.Event{
Type: clientv3.EventTypePut,
Kv: kv,
})
}
return clientv3.WatchResponse{Events: events}
}
func readBatches(t *testing.T, w *watcher, n int) (revs []int64, sizes []int) {
t.Helper()
timeout := time.After(2 * time.Second)
for len(revs) < n {
select {
case resp := <-w.respCh:
if resp.Canceled {
t.Fatalf("unexpected canceled response in test: %v", resp.CancelReason)
}
if len(resp.Events) == 0 {
continue
}
require.NotNil(t, resp.Header)
revs = append(revs, resp.Events[0].Kv.ModRevision)
sizes = append(sizes, len(resp.Events))
case <-timeout:
t.Fatalf("timed out waiting for %d batches; got %d", n, len(revs))
}
}
return revs, sizes
}