173 lines
6.3 KiB
Go
173 lines
6.3 KiB
Go
package plugins
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"testing"
|
||
|
|
)
|
||
|
|
|
||
|
|
// countingInstallationStore wraps the shared fake store and counts GetByID
|
||
|
|
// calls so tests can assert the in-memory installation cache absorbs repeat
|
||
|
|
// reads.
|
||
|
|
type countingInstallationStore struct {
|
||
|
|
*fakeServiceInstallationStore
|
||
|
|
getByIDCalls int
|
||
|
|
// onGetByID, when set, runs after the call is counted but before the row is
|
||
|
|
// returned, so tests can simulate an invalidation racing an in-flight read.
|
||
|
|
onGetByID func()
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *countingInstallationStore) GetByID(ctx context.Context, id int) (*Installation, error) {
|
||
|
|
s.getByIDCalls++
|
||
|
|
if s.onGetByID != nil {
|
||
|
|
s.onGetByID()
|
||
|
|
}
|
||
|
|
return s.fakeServiceInstallationStore.GetByID(ctx, id)
|
||
|
|
}
|
||
|
|
|
||
|
|
// newCachedInstallationService builds a Service backed by the counting store and
|
||
|
|
// wires the installation-cache invalidation exactly as NewService does, so the
|
||
|
|
// OnLifecycleChange -> invalidate path is exercised.
|
||
|
|
func newCachedInstallationService(installations ...*Installation) (*Service, *countingInstallationStore) {
|
||
|
|
store := &countingInstallationStore{
|
||
|
|
fakeServiceInstallationStore: newFakeServiceInstallationStore(installations...),
|
||
|
|
}
|
||
|
|
svc := &Service{installations: store}
|
||
|
|
svc.AddLifecycleHook(func(context.Context) { svc.invalidateInstallationCache() })
|
||
|
|
return svc, store
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLoadInstallationCachesAndInvalidatesOnLifecycleChange(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
svc, store := newCachedInstallationService(&Installation{ID: 7, PluginID: "silo.metadb", Enabled: true})
|
||
|
|
|
||
|
|
// First read hits the store.
|
||
|
|
if _, err := svc.loadInstallation(ctx, 7, false); err != nil {
|
||
|
|
t.Fatalf("first loadInstallation err = %v", err)
|
||
|
|
}
|
||
|
|
if store.getByIDCalls != 1 {
|
||
|
|
t.Fatalf("after first read GetByID calls = %d, want 1", store.getByIDCalls)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Subsequent reads are served from the cache.
|
||
|
|
for i := 0; i < 5; i++ {
|
||
|
|
if _, err := svc.loadInstallation(ctx, 7, false); err != nil {
|
||
|
|
t.Fatalf("cached loadInstallation err = %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if store.getByIDCalls != 1 {
|
||
|
|
t.Fatalf("after cached reads GetByID calls = %d, want still 1", store.getByIDCalls)
|
||
|
|
}
|
||
|
|
|
||
|
|
// A lifecycle change wipes the cache and forces a re-read.
|
||
|
|
svc.OnLifecycleChange(ctx)
|
||
|
|
if _, err := svc.loadInstallation(ctx, 7, false); err != nil {
|
||
|
|
t.Fatalf("post-invalidate loadInstallation err = %v", err)
|
||
|
|
}
|
||
|
|
if store.getByIDCalls != 2 {
|
||
|
|
t.Fatalf("after lifecycle change GetByID calls = %d, want 2", store.getByIDCalls)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestIsInstallationEnabledReflectsCacheAndInvalidation(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
svc, store := newCachedInstallationService(&Installation{ID: 7, PluginID: "silo.metadb", Enabled: true})
|
||
|
|
|
||
|
|
enabled, err := svc.IsInstallationEnabled(ctx, 7)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("IsInstallationEnabled err = %v", err)
|
||
|
|
}
|
||
|
|
if !enabled {
|
||
|
|
t.Fatal("IsInstallationEnabled = false, want true")
|
||
|
|
}
|
||
|
|
if store.getByIDCalls != 1 {
|
||
|
|
t.Fatalf("GetByID calls = %d, want 1", store.getByIDCalls)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Disable the underlying row. Until a lifecycle change, the cache still
|
||
|
|
// reports the stale (enabled) value and issues no further reads.
|
||
|
|
falseVal := false
|
||
|
|
if err := store.Update(ctx, 7, UpdateInstallationInput{Enabled: &falseVal}); err != nil {
|
||
|
|
t.Fatalf("store.Update err = %v", err)
|
||
|
|
}
|
||
|
|
enabled, err = svc.IsInstallationEnabled(ctx, 7)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("cached IsInstallationEnabled err = %v", err)
|
||
|
|
}
|
||
|
|
if !enabled {
|
||
|
|
t.Fatal("cached IsInstallationEnabled = false, want stale true before invalidation")
|
||
|
|
}
|
||
|
|
if store.getByIDCalls != 1 {
|
||
|
|
t.Fatalf("GetByID calls = %d, want still 1 (served from cache)", store.getByIDCalls)
|
||
|
|
}
|
||
|
|
|
||
|
|
// After a lifecycle change the cache is wiped and the new value is read.
|
||
|
|
svc.OnLifecycleChange(ctx)
|
||
|
|
enabled, err = svc.IsInstallationEnabled(ctx, 7)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("post-invalidate IsInstallationEnabled err = %v", err)
|
||
|
|
}
|
||
|
|
if enabled {
|
||
|
|
t.Fatal("post-invalidate IsInstallationEnabled = true, want false")
|
||
|
|
}
|
||
|
|
if store.getByIDCalls != 2 {
|
||
|
|
t.Fatalf("GetByID calls = %d, want 2 after invalidation", store.getByIDCalls)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestCachedInstallationSkipsWriteOnRacingInvalidation proves the generation
|
||
|
|
// guard: if a lifecycle invalidation lands while GetByID is in flight, the
|
||
|
|
// fetched (potentially pre-mutation) row is returned to the caller but is not
|
||
|
|
// written back into the freshly-cleared cache, so the next read re-fetches
|
||
|
|
// instead of serving a resurrected stale row.
|
||
|
|
func TestCachedInstallationSkipsWriteOnRacingInvalidation(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
svc, store := newCachedInstallationService(&Installation{ID: 7, PluginID: "silo.metadb", Enabled: true})
|
||
|
|
|
||
|
|
// Simulate the race by invalidating the cache from inside the store read,
|
||
|
|
// i.e. between the generation capture and the write-back.
|
||
|
|
store.onGetByID = func() { svc.invalidateInstallationCache() }
|
||
|
|
|
||
|
|
if _, err := svc.loadInstallation(ctx, 7, false); err != nil {
|
||
|
|
t.Fatalf("racing loadInstallation err = %v", err)
|
||
|
|
}
|
||
|
|
if store.getByIDCalls != 1 {
|
||
|
|
t.Fatalf("GetByID calls = %d, want 1", store.getByIDCalls)
|
||
|
|
}
|
||
|
|
|
||
|
|
// The racing read must not have populated the cache; the next read re-fetches.
|
||
|
|
store.onGetByID = nil
|
||
|
|
if _, err := svc.loadInstallation(ctx, 7, false); err != nil {
|
||
|
|
t.Fatalf("post-race loadInstallation err = %v", err)
|
||
|
|
}
|
||
|
|
if store.getByIDCalls != 2 {
|
||
|
|
t.Fatalf("GetByID calls = %d, want 2 (racing write skipped, cache empty)", store.getByIDCalls)
|
||
|
|
}
|
||
|
|
|
||
|
|
// A subsequent read with no interference is served from the cache.
|
||
|
|
if _, err := svc.loadInstallation(ctx, 7, false); err != nil {
|
||
|
|
t.Fatalf("cached loadInstallation err = %v", err)
|
||
|
|
}
|
||
|
|
if store.getByIDCalls != 2 {
|
||
|
|
t.Fatalf("GetByID calls = %d, want still 2 (served from cache)", store.getByIDCalls)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLoadInstallationRequireEnabledGateAppliesAfterCache(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
svc, store := newCachedInstallationService(&Installation{ID: 7, PluginID: "silo.metadb", Enabled: false})
|
||
|
|
|
||
|
|
// requireEnabled=false caches the disabled row.
|
||
|
|
if _, err := svc.loadInstallation(ctx, 7, false); err != nil {
|
||
|
|
t.Fatalf("loadInstallation err = %v", err)
|
||
|
|
}
|
||
|
|
// requireEnabled=true returns ErrInstallationDisabled without a second read,
|
||
|
|
// proving the gate is applied after the cache lookup.
|
||
|
|
if _, err := svc.loadInstallation(ctx, 7, true); !errors.Is(err, ErrInstallationDisabled) {
|
||
|
|
t.Fatalf("loadInstallation requireEnabled err = %v, want ErrInstallationDisabled", err)
|
||
|
|
}
|
||
|
|
if store.getByIDCalls != 1 {
|
||
|
|
t.Fatalf("GetByID calls = %d, want 1 (gate applied after cache)", store.getByIDCalls)
|
||
|
|
}
|
||
|
|
}
|