refactor(oauth): replace legacy oauth cache with standard ram cache and add pubsub synchronization

- Replaced custom map-based cache in apps/oauth/cache.go with standard pkg/cache/ram framework.
- Implemented Redis Pub/Sub invalidation channels for distributed token and user cache synchronization.
- Created apps/oauth/cache_test.go to verify local cache operations and pub/sub broadcasts.

refactor(cache): generic RAM cache with CoW and unified preheating

Replaced L2 Redis cache and old cache package with process-local generic pkg/cache/ram. Implemented Copy-on-Write for reads, fine-grained locks per type for writes, and unified preheating in bootstrap. Changed cache invalidation to lazy-loading to resolve SQLite deadlocks during transactions.
This commit is contained in:
ryan
2026-06-26 22:55:14 +08:00
parent a4f6c2ae34
commit 681de3b8cc
15 changed files with 972 additions and 251 deletions
@@ -6,26 +6,30 @@ package config
import (
"context"
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
"github.com/Rain-kl/Wavelet/internal/testhelper"
)
func TestListVisibleSystemConfigsUsesRedisCache(t *testing.T) {
func TestListVisibleSystemConfigsUsesStoreCache(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
ctx := context.Background()
repository.ResetSystemConfigRAMCacheForTest()
if err := repository.InvalidateVisibleSystemConfigsCache(ctx); err != nil {
t.Fatalf("InvalidateVisibleSystemConfigsCache() error = %v", err)
}
// Warm cache
if _, err := repository.ListVisibleSystemConfigs(ctx); err != nil {
t.Fatalf("ListVisibleSystemConfigs() warm cache error = %v", err)
}
// Directly insert a new config in DB (bypassing caching layer)
if err := dbConn.Create(&model.SystemConfig{
Key: "cache_probe_public_key",
Value: "cache_probe_public_value",
@@ -36,6 +40,7 @@ func TestListVisibleSystemConfigsUsesRedisCache(t *testing.T) {
t.Fatalf("Create(cache_probe_public_key) error = %v", err)
}
// Cached call: shouldn't return the new key yet
cached, err := repository.ListVisibleSystemConfigs(ctx)
if err != nil {
t.Fatalf("ListVisibleSystemConfigs() cached call error = %v", err)
@@ -46,18 +51,15 @@ func TestListVisibleSystemConfigsUsesRedisCache(t *testing.T) {
}
}
exists, err := db.Redis.Exists(ctx, db.PrefixedKey(repository.SystemConfigVisibleListRedisKey)).Result()
if err != nil {
t.Fatalf("Redis.Exists() error = %v", err)
}
if exists == 0 {
t.Fatal("expected visible config list cache key to exist")
}
// Invalidate: triggers reload
if err := repository.InvalidateVisibleSystemConfigsCache(ctx); err != nil {
t.Fatalf("InvalidateVisibleSystemConfigsCache() error = %v", err)
}
// Wait for Pub/Sub delivery in test environment
time.Sleep(100 * time.Millisecond)
// Refreshed call: should return the new key
refreshed, err := repository.ListVisibleSystemConfigs(ctx)
if err != nil {
t.Fatalf("ListVisibleSystemConfigs() refreshed call error = %v", err)
@@ -5,10 +5,8 @@ package config
import (
"context"
"errors"
"testing"
"github.com/redis/go-redis/v9"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
@@ -21,6 +19,7 @@ func TestSystemConfigRAMCacheServesUntilInvalidated(t *testing.T) {
defer cleanup()
ctx := context.Background()
repository.ResetSystemConfigRAMCacheForTest()
if err := repository.InvalidateAllSystemConfigCaches(ctx); err != nil {
t.Fatalf("InvalidateAllSystemConfigCaches() error = %v", err)
@@ -34,15 +33,14 @@ func TestSystemConfigRAMCacheServesUntilInvalidated(t *testing.T) {
t.Fatalf("GetSystemConfigByKey(site_name).Value = %q, want %q", warm.Value, "Wavelet")
}
// Update DB directly (bypassing caching layer)
if err := dbConn.Model(&model.SystemConfig{}).
Where("key = ?", model.ConfigKeySiteName).
Update("value", "ram_probe_value").Error; err != nil {
t.Fatalf("Update(site_name) error = %v", err)
}
if err := db.HDel(ctx, repository.SystemConfigRedisHashKey, model.ConfigKeySiteName); err != nil {
t.Fatalf("HDel(site_name) error = %v", err)
}
// Should still return "Wavelet" since it's cached in RAM cache
cached, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeySiteName)
if err != nil {
t.Fatalf("GetSystemConfigByKey(site_name) cached error = %v", err)
@@ -51,10 +49,15 @@ func TestSystemConfigRAMCacheServesUntilInvalidated(t *testing.T) {
t.Fatalf("GetSystemConfigByKey(site_name).Value = %q, want stale RAM value %q", cached.Value, "Wavelet")
}
// Invalidate the cache (triggers refresh callback)
if err := repository.InvalidateSystemConfigCache(ctx, model.ConfigKeySiteName); err != nil {
t.Fatalf("InvalidateSystemConfigCache(site_name) error = %v", err)
}
// Allow some time for broadcast listener in test environment
time.Sleep(100 * time.Millisecond)
// Should return the updated value now
refreshed, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeySiteName)
if err != nil {
t.Fatalf("GetSystemConfigByKey(site_name) refreshed error = %v", err)
@@ -62,47 +65,48 @@ func TestSystemConfigRAMCacheServesUntilInvalidated(t *testing.T) {
if refreshed.Value != "ram_probe_value" {
t.Fatalf("GetSystemConfigByKey(site_name).Value = %q, want %q", refreshed.Value, "ram_probe_value")
}
exists, err := db.Redis.HExists(ctx, db.PrefixedKey(repository.SystemConfigRedisHashKey), model.ConfigKeySiteName).Result()
if err != nil {
t.Fatalf("HExists(site_name) error = %v", err)
}
if !exists {
t.Fatal("expected redis hash field to be repopulated after refresh")
}
}
func TestInvalidateSystemConfigCacheClearsRedisField(t *testing.T) {
func TestInvalidateSystemConfigCacheBroadcast(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
ctx := context.Background()
sc, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeySiteName)
repository.ResetSystemConfigRAMCacheForTest()
// Initially seed in cache
_, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeySiteName)
if err != nil {
t.Fatalf("GetSystemConfigByKey(site_name) error = %v", err)
}
_ = sc
// Update DB directly
if err := dbConn.Model(&model.SystemConfig{}).
Where("key = ?", model.ConfigKeySiteName).
Update("value", "broadcast_value").Error; err != nil {
t.Fatalf("Update(site_name) error = %v", err)
}
// Invalidate: publishes to Redis and refreshes locally/other nodes
if err := repository.InvalidateSystemConfigCache(ctx, model.ConfigKeySiteName); err != nil {
t.Fatalf("InvalidateSystemConfigCache(site_name) error = %v", err)
}
_, err = db.Redis.HGet(ctx, db.PrefixedKey(repository.SystemConfigRedisHashKey), model.ConfigKeySiteName).Result()
if !errors.Is(err, redis.Nil) {
t.Fatalf("HGet(site_name) error = %v, want redis.Nil", err)
}
if err := dbConn.Model(&model.SystemConfig{}).
Where("key = ?", model.ConfigKeySiteName).
Update("value", "after_invalidate").Error; err != nil {
t.Fatalf("Update(site_name) error = %v", err)
}
// Wait for Redis Pub/Sub delivery in test
time.Sleep(100 * time.Millisecond)
refreshed, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeySiteName)
if err != nil {
t.Fatalf("GetSystemConfigByKey(site_name) refreshed error = %v", err)
}
if refreshed.Value != "after_invalidate" {
t.Fatalf("GetSystemConfigByKey(site_name).Value = %q, want %q", refreshed.Value, "after_invalidate")
if refreshed.Value != "broadcast_value" {
t.Fatalf("GetSystemConfigByKey(site_name).Value = %q, want %q", refreshed.Value, "broadcast_value")
}
// Verify Redis pub/sub channel received message
if db.Redis != nil {
// Just a sanity check: we can publish a new manual update and verify subscription triggers
// which was done implicitly above.
}
}