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-27 14:32:27 +08:00
parent 02ebb81929
commit 8d8814b416
7 changed files with 72 additions and 31 deletions
@@ -250,12 +250,14 @@ func TestLoadUsesEnvConfigWhenFileIsMissing(t *testing.T) {
}
func TestLoadDetectsOutboundIPWhenNodeIPMissing(t *testing.T) {
nodeip.ResetCacheForTest()
previousLookup := nodeip.LookupOutboundIP
nodeip.LookupOutboundIP = func(ctx context.Context, strategies ...geoip.OutboundIPStrategy) (net.IP, error) {
return net.ParseIP("8.8.8.8"), nil
}
defer func() {
nodeip.LookupOutboundIP = previousLookup
nodeip.ResetCacheForTest()
}()
dir := t.TempDir()
@@ -283,6 +285,7 @@ func TestLoadDetectsOutboundIPWhenNodeIPMissing(t *testing.T) {
}
func TestLoadFallsBackToLocalIPWhenOutboundLookupFails(t *testing.T) {
nodeip.ResetCacheForTest()
previousOutboundLookup := nodeip.LookupOutboundIP
previousLocalLookup := nodeip.LookupLocalIP
nodeip.LookupOutboundIP = func(ctx context.Context, strategies ...geoip.OutboundIPStrategy) (net.IP, error) {
@@ -294,6 +297,7 @@ func TestLoadFallsBackToLocalIPWhenOutboundLookupFails(t *testing.T) {
defer func() {
nodeip.LookupOutboundIP = previousOutboundLookup
nodeip.LookupLocalIP = previousLocalLookup
nodeip.ResetCacheForTest()
}()
dir := t.TempDir()
@@ -7,7 +7,6 @@ import (
"context"
"testing"
"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"
@@ -46,13 +45,8 @@ 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")
}
// Since system configs are now purely cached in process-local RAM (L1) and not written to Redis (L2),
// we do not verify the existence of the legacy Redis key here.
if err := repository.InvalidateVisibleSystemConfigsCache(ctx); err != nil {
t.Fatalf("InvalidateVisibleSystemConfigsCache() error = %v", err)
@@ -7,6 +7,7 @@ import (
"context"
"errors"
"testing"
"time"
"github.com/redis/go-redis/v9"
@@ -25,6 +26,7 @@ func TestSystemConfigRAMCacheServesUntilInvalidated(t *testing.T) {
if err := repository.InvalidateAllSystemConfigCaches(ctx); err != nil {
t.Fatalf("InvalidateAllSystemConfigCaches() error = %v", err)
}
time.Sleep(50 * time.Millisecond) // Wait for async Redis broadcast to be processed
warm, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeySiteName)
if err != nil {
@@ -54,6 +56,7 @@ func TestSystemConfigRAMCacheServesUntilInvalidated(t *testing.T) {
if err := repository.InvalidateSystemConfigCache(ctx, model.ConfigKeySiteName); err != nil {
t.Fatalf("InvalidateSystemConfigCache(site_name) error = %v", err)
}
time.Sleep(50 * time.Millisecond) // Wait for async Redis broadcast to be processed
refreshed, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeySiteName)
if err != nil {
@@ -63,13 +66,8 @@ func TestSystemConfigRAMCacheServesUntilInvalidated(t *testing.T) {
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")
}
// Since system configs are now purely cached in process-local RAM (L1) and not written to Redis (L2),
// we do not verify if the Redis hash field is repopulated.
}
func TestInvalidateSystemConfigCacheClearsRedisField(t *testing.T) {
@@ -86,6 +84,7 @@ func TestInvalidateSystemConfigCacheClearsRedisField(t *testing.T) {
if err := repository.InvalidateSystemConfigCache(ctx, model.ConfigKeySiteName); err != nil {
t.Fatalf("InvalidateSystemConfigCache(site_name) error = %v", err)
}
time.Sleep(50 * time.Millisecond) // Wait for async Redis broadcast to be processed
_, err = db.Redis.HGet(ctx, db.PrefixedKey(repository.SystemConfigRedisHashKey), model.ConfigKeySiteName).Result()
if !errors.Is(err, redis.Nil) {
+8
View File
@@ -104,3 +104,11 @@ func DetectLocal() string {
}
return bestIP
}
// ResetCacheForTest clears the cached IP.
func ResetCacheForTest() {
cacheMu.Lock()
cachedIP = ""
lastDetected = time.Time{}
cacheMu.Unlock()
}