261 lines
8.8 KiB
Go
261 lines
8.8 KiB
Go
package channels
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"slices"
|
|
"sync"
|
|
"testing"
|
|
|
|
"telesrv/internal/domain"
|
|
"telesrv/internal/store"
|
|
"telesrv/internal/store/memory"
|
|
)
|
|
|
|
type fakeActiveChannelIDsPageCache struct {
|
|
mu sync.Mutex
|
|
values map[store.ActiveChannelIDsPageKey][]int64
|
|
getErr error
|
|
putErr error
|
|
gets int
|
|
puts int
|
|
}
|
|
|
|
func (f *fakeActiveChannelIDsPageCache) GetActiveChannelIDsPage(
|
|
_ context.Context,
|
|
key store.ActiveChannelIDsPageKey,
|
|
) ([]int64, bool, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.gets++
|
|
if f.getErr != nil {
|
|
return nil, false, f.getErr
|
|
}
|
|
value, found := f.values[key]
|
|
return append([]int64(nil), value...), found, nil
|
|
}
|
|
|
|
func (f *fakeActiveChannelIDsPageCache) PutActiveChannelIDsPage(
|
|
_ context.Context,
|
|
key store.ActiveChannelIDsPageKey,
|
|
value []int64,
|
|
) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.puts++
|
|
if f.putErr != nil {
|
|
return f.putErr
|
|
}
|
|
if f.values == nil {
|
|
f.values = make(map[store.ActiveChannelIDsPageKey][]int64)
|
|
}
|
|
f.values[key] = append([]int64(nil), value...)
|
|
return nil
|
|
}
|
|
|
|
type fakeActiveChannelIDsLoader struct {
|
|
mu sync.Mutex
|
|
values []int64
|
|
err error
|
|
calls int
|
|
onLoad func()
|
|
}
|
|
|
|
func (f *fakeActiveChannelIDsLoader) ListActiveChannelIDsForUser(
|
|
_ context.Context,
|
|
_, _ int64,
|
|
_ int,
|
|
) ([]int64, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.calls++
|
|
if f.onLoad != nil {
|
|
f.onLoad()
|
|
}
|
|
return append([]int64(nil), f.values...), f.err
|
|
}
|
|
|
|
type fakeActiveChannelIDsMetrics struct {
|
|
mu sync.Mutex
|
|
outcomes map[string]int
|
|
}
|
|
|
|
type mutableReadModelVersions struct {
|
|
mu sync.Mutex
|
|
hashes map[store.ReadModelKey]int64
|
|
}
|
|
|
|
func (m *mutableReadModelVersions) ReadModelHash(
|
|
_ context.Context,
|
|
model string,
|
|
ownerUserID int64,
|
|
peerType domain.PeerType,
|
|
peerID int64,
|
|
) (int64, bool, error) {
|
|
key := store.ReadModelKey{Model: model, OwnerUserID: ownerUserID, PeerType: peerType, PeerID: peerID}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
hash := m.hashes[key]
|
|
return hash, hash != 0, nil
|
|
}
|
|
|
|
func (m *mutableReadModelVersions) ReadModelHashes(
|
|
_ context.Context,
|
|
keys []store.ReadModelKey,
|
|
) (map[store.ReadModelKey]int64, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
out := make(map[store.ReadModelKey]int64, len(keys))
|
|
for _, key := range keys {
|
|
out[key] = m.hashes[key]
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (m *mutableReadModelVersions) set(key store.ReadModelKey, hash int64) {
|
|
m.mu.Lock()
|
|
m.hashes[key] = hash
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
func (f *fakeActiveChannelIDsMetrics) ActiveChannelIDsCache(outcome string) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.outcomes == nil {
|
|
f.outcomes = make(map[string]int)
|
|
}
|
|
f.outcomes[outcome]++
|
|
}
|
|
|
|
func TestActiveChannelIDsSharedPageSurvivesServiceRestart(t *testing.T) {
|
|
ctx := context.Background()
|
|
const ownerID int64 = 1001
|
|
key := store.ReadModelKey{Model: "channel_active_memberships", OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}
|
|
versions := &fakeReadModelVersions{hashes: map[store.ReadModelKey]int64{key: 501}}
|
|
shared := &fakeActiveChannelIDsPageCache{}
|
|
firstLoader := &fakeActiveChannelIDsLoader{values: []int64{11, 12}}
|
|
firstMetrics := &fakeActiveChannelIDsMetrics{}
|
|
first := NewService(memory.NewChannelStore(),
|
|
WithReadModelVersions(versions),
|
|
WithActiveChannelIDsReadModel(shared, firstLoader, 32, 0, firstMetrics),
|
|
)
|
|
got, err := first.ActiveChannelIDsForUser(ctx, ownerID, 0, 1000)
|
|
if err != nil || !slices.Equal(got, []int64{11, 12}) {
|
|
t.Fatalf("first page = %v err=%v", got, err)
|
|
}
|
|
if firstLoader.calls != 1 || shared.puts != 1 || firstMetrics.outcomes["miss"] != 1 || firstMetrics.outcomes["fill"] != 1 {
|
|
t.Fatalf("first load calls=%d puts=%d metrics=%v", firstLoader.calls, shared.puts, firstMetrics.outcomes)
|
|
}
|
|
|
|
secondLoader := &fakeActiveChannelIDsLoader{err: errors.New("cold loader must not run")}
|
|
secondMetrics := &fakeActiveChannelIDsMetrics{}
|
|
second := NewService(memory.NewChannelStore(),
|
|
WithReadModelVersions(versions),
|
|
WithActiveChannelIDsReadModel(shared, secondLoader, 32, 0, secondMetrics),
|
|
)
|
|
got, err = second.ActiveChannelIDsForUser(ctx, ownerID, 0, 1000)
|
|
if err != nil || !slices.Equal(got, []int64{11, 12}) {
|
|
t.Fatalf("restart page = %v err=%v", got, err)
|
|
}
|
|
if secondLoader.calls != 0 || secondMetrics.outcomes["hit"] != 1 || secondMetrics.outcomes["served"] != 1 {
|
|
t.Fatalf("restart loader=%d metrics=%v", secondLoader.calls, secondMetrics.outcomes)
|
|
}
|
|
}
|
|
|
|
func TestActiveChannelIDsSharedPageFailsClosedOnRedisError(t *testing.T) {
|
|
const ownerID int64 = 1001
|
|
key := store.ReadModelKey{Model: "channel_active_memberships", OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}
|
|
versions := &fakeReadModelVersions{hashes: map[store.ReadModelKey]int64{key: 601}}
|
|
shared := &fakeActiveChannelIDsPageCache{getErr: errors.New("redis down")}
|
|
loader := &fakeActiveChannelIDsLoader{values: []int64{11}}
|
|
service := NewService(memory.NewChannelStore(),
|
|
WithReadModelVersions(versions),
|
|
WithActiveChannelIDsReadModel(shared, loader, 32, 0, nil),
|
|
)
|
|
if _, err := service.ActiveChannelIDsForUser(context.Background(), ownerID, 0, 1000); err == nil {
|
|
t.Fatal("Redis error was silently bypassed")
|
|
}
|
|
if loader.calls != 0 {
|
|
t.Fatalf("cold loader calls = %d, want 0", loader.calls)
|
|
}
|
|
}
|
|
|
|
func TestActiveChannelIDsSharedPageRetriesGenerationChange(t *testing.T) {
|
|
ctx := context.Background()
|
|
const ownerID int64 = 1001
|
|
key := store.ReadModelKey{Model: "channel_active_memberships", OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}
|
|
versions := &fakeReadModelVersions{hashes: map[store.ReadModelKey]int64{key: 701}}
|
|
shared := &fakeActiveChannelIDsPageCache{}
|
|
loader := &fakeActiveChannelIDsLoader{values: []int64{11}}
|
|
loader.onLoad = func() {
|
|
if loader.calls == 1 {
|
|
versions.hashes[key] = 702
|
|
loader.values = []int64{11, 12}
|
|
}
|
|
}
|
|
metrics := &fakeActiveChannelIDsMetrics{}
|
|
service := NewService(memory.NewChannelStore(),
|
|
WithReadModelVersions(versions),
|
|
WithActiveChannelIDsReadModel(shared, loader, 32, 0, metrics),
|
|
)
|
|
got, err := service.ActiveChannelIDsForUser(ctx, ownerID, 0, 1000)
|
|
if err != nil || !slices.Equal(got, []int64{11, 12}) {
|
|
t.Fatalf("page = %v err=%v", got, err)
|
|
}
|
|
if loader.calls != 2 || shared.puts != 1 || metrics.outcomes["generation_retry"] != 1 {
|
|
t.Fatalf("loader=%d puts=%d metrics=%v", loader.calls, shared.puts, metrics.outcomes)
|
|
}
|
|
oldKey := store.ActiveChannelIDsPageKey{UserID: ownerID, Generation: 701, AfterChannelID: 0, Limit: 1000}
|
|
if _, found := shared.values[oldKey]; found {
|
|
t.Fatal("generation-raced page was stored under old key")
|
|
}
|
|
}
|
|
|
|
func TestActiveChannelIDsSharedMissingGenerationOnlyCachesEmpty(t *testing.T) {
|
|
shared := &fakeActiveChannelIDsPageCache{}
|
|
loader := &fakeActiveChannelIDsLoader{values: []int64{11}}
|
|
service := NewService(memory.NewChannelStore(),
|
|
WithReadModelVersions(&fakeReadModelVersions{}),
|
|
WithActiveChannelIDsReadModel(shared, loader, 32, 0, nil),
|
|
)
|
|
if _, err := service.ActiveChannelIDsForUser(context.Background(), 1001, 0, 1000); err == nil {
|
|
t.Fatal("non-empty page without durable generation accepted")
|
|
}
|
|
if shared.puts != 0 {
|
|
t.Fatalf("shared puts = %d, want 0", shared.puts)
|
|
}
|
|
}
|
|
|
|
func TestActiveChannelIDsLocalWriteInvalidatesCachedGenerationBeforeNotify(t *testing.T) {
|
|
ctx := context.Background()
|
|
const ownerID int64 = 1001
|
|
versionKey := store.ReadModelKey{Model: "channel_active_memberships", OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}
|
|
baseVersions := &mutableReadModelVersions{hashes: map[store.ReadModelKey]int64{versionKey: 801}}
|
|
cachedVersions := store.NewCachedReadModelVersionStore(baseVersions, 0, 32)
|
|
oldPageKey := store.ActiveChannelIDsPageKey{UserID: ownerID, Generation: 801, AfterChannelID: 0, Limit: 1000}
|
|
shared := &fakeActiveChannelIDsPageCache{values: map[store.ActiveChannelIDsPageKey][]int64{oldPageKey: {11}}}
|
|
loader := &fakeActiveChannelIDsLoader{values: []int64{11, 12}}
|
|
service := NewService(memory.NewChannelStore(),
|
|
WithReadModelVersions(cachedVersions),
|
|
WithActiveChannelIDsReadModel(shared, loader, 32, 0, nil),
|
|
)
|
|
first, err := service.ActiveChannelIDsForUser(ctx, ownerID, 0, 1000)
|
|
if err != nil || !slices.Equal(first, []int64{11}) {
|
|
t.Fatalf("first = %v err=%v", first, err)
|
|
}
|
|
baseVersions.set(versionKey, 802)
|
|
// Simulate the synchronous post-commit app hook before PostgreSQL NOTIFY is
|
|
// delivered to this process.
|
|
service.invalidateActiveChannelIDs(ownerID)
|
|
second, err := service.ActiveChannelIDsForUser(ctx, ownerID, 0, 1000)
|
|
if err != nil || !slices.Equal(second, []int64{11, 12}) {
|
|
t.Fatalf("after local invalidation = %v err=%v", second, err)
|
|
}
|
|
if loader.calls != 1 {
|
|
t.Fatalf("cold loader calls = %d, want 1 for new generation", loader.calls)
|
|
}
|
|
newPageKey := store.ActiveChannelIDsPageKey{UserID: ownerID, Generation: 802, AfterChannelID: 0, Limit: 1000}
|
|
if !slices.Equal(shared.values[newPageKey], []int64{11, 12}) {
|
|
t.Fatalf("new generation page = %v", shared.values[newPageKey])
|
|
}
|
|
}
|