fix: broadcast channel cache invalidations

This commit is contained in:
haruka
2026-08-26 11:16:32 +08:00
parent 6ca1e15b0a
commit 1ee929e4b9
12 changed files with 210 additions and 13 deletions
+2 -1
View File
@@ -137,7 +137,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
tlsFingerprintProfileCache := repository.NewTLSFingerprintProfileCache(redisClient)
tlsFingerprintProfileService := service.NewTLSFingerprintProfileService(tlsFingerprintProfileRepository, tlsFingerprintProfileCache)
channelRepository := repository.NewChannelRepository(db)
channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService)
channelCachePubSub := repository.NewChannelCache(redisClient)
channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService, channelCachePubSub)
modelPricingResolver := service.NewModelPricingResolver(channelService, billingService)
compositeModelRouteRepository := repository.NewCompositeModelRouteRepository(client)
compositeRouteResolver := service.NewCompositeRouteResolver(compositeModelRouteRepository)
+2
View File
@@ -255,6 +255,8 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:E
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
@@ -2868,7 +2868,7 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
BillingModelSource: tc.billingModelSource,
}},
groupPlatforms: map[int64]string{groupID: service.PlatformOpenAI},
}, nil, nil, nil)
}, nil, nil, nil, nil)
}
billingCacheSvc := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
@@ -0,0 +1,53 @@
package repository
import (
"context"
"log/slog"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/redis/go-redis/v9"
)
const channelCachePubSubKey = "channel_cache_updated"
type channelCache struct {
rdb *redis.Client
}
// NewChannelCache creates the Redis-backed channel cache invalidation bus.
func NewChannelCache(rdb *redis.Client) service.ChannelCachePubSub {
return &channelCache{rdb: rdb}
}
// NotifyUpdate notifies all instances to invalidate their local channel cache.
func (c *channelCache) NotifyUpdate(ctx context.Context) error {
return c.rdb.Publish(ctx, channelCachePubSubKey, "refresh").Err()
}
// SubscribeUpdates subscribes to channel cache invalidation notifications.
func (c *channelCache) SubscribeUpdates(ctx context.Context, handler func()) {
go func() {
pubsub := c.rdb.Subscribe(ctx, channelCachePubSubKey)
defer func() {
if err := pubsub.Close(); err != nil {
slog.Warn("failed to close channel cache subscriber", "error", err)
}
}()
messages := pubsub.Channel()
for {
select {
case <-ctx.Done():
return
case message, ok := <-messages:
if !ok {
slog.Warn("channel cache subscriber stopped", "reason", "channel_closed")
return
}
if message != nil {
handler()
}
}
}
}()
}
@@ -0,0 +1,44 @@
package repository
import (
"context"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
)
func TestChannelCachePubSub(t *testing.T) {
redisServer := miniredis.RunT(t)
publisherRedis := redis.NewClient(&redis.Options{Addr: redisServer.Addr()})
subscriberRedis := redis.NewClient(&redis.Options{Addr: redisServer.Addr()})
t.Cleanup(func() {
_ = publisherRedis.Close()
_ = subscriberRedis.Close()
})
publisher := NewChannelCache(publisherRedis)
subscriber := NewChannelCache(subscriberRedis)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
received := make(chan struct{}, 1)
subscriber.SubscribeUpdates(ctx, func() {
select {
case received <- struct{}{}:
default:
}
})
require.Eventually(t, func() bool {
return redisServer.PubSubNumSub(channelCachePubSubKey)[channelCachePubSubKey] == 1
}, time.Second, 10*time.Millisecond)
require.NoError(t, publisher.NotifyUpdate(context.Background()))
select {
case <-received:
case <-time.After(time.Second):
t.Fatal("channel cache invalidation notification was not received")
}
}
+1
View File
@@ -137,6 +137,7 @@ var ProviderSet = wire.NewSet(
NewRefreshTokenCache,
NewErrorPassthroughCache,
NewTLSFingerprintProfileCache,
NewChannelCache,
NewContentModerationHashCache,
// Encryptors
@@ -23,7 +23,7 @@ func newTokenCostTestEnv(t *testing.T, groupPlatform string, pricing []ChannelMo
return map[int64]string{100: groupPlatform}, nil
},
}
cs := NewChannelService(repo, nil, nil, nil)
cs := NewChannelService(repo, nil, nil, nil, nil)
bs := NewBillingService(&config.Config{}, catalog)
return bs, NewModelPricingResolver(cs, bs)
}
@@ -75,7 +75,7 @@ func newAvailableChannelService(channels []Channel, groupRepo GroupRepository) *
repo := &mockChannelRepository{
listAllFn: func(ctx context.Context) ([]Channel, error) { return channels, nil },
}
return NewChannelService(repo, groupRepo, nil, nil)
return NewChannelService(repo, groupRepo, nil, nil, nil)
}
func TestListAvailable_EmptyActiveGroups_NoGroupsAttached(t *testing.T) {
@@ -134,7 +134,7 @@ func TestListAvailable_ListAllErrorPropagates(t *testing.T) {
listAllFn: func(ctx context.Context) ([]Channel, error) { return nil, sentinel },
}
groupRepo := &stubGroupRepoForAvailable{}
svc := NewChannelService(repo, groupRepo, nil, nil)
svc := NewChannelService(repo, groupRepo, nil, nil, nil)
out, err := svc.ListAvailable(context.Background())
require.Nil(t, out)
require.ErrorIs(t, err, sentinel)
+41 -3
View File
@@ -147,14 +147,22 @@ const (
channelCacheTTL = 10 * time.Minute
channelErrorTTL = 5 * time.Second // DB 错误时的短缓存
channelCacheDBTimeout = 10 * time.Second
channelCacheNotifyTTL = 3 * time.Second
)
// ChannelCachePubSub broadcasts channel cache invalidations between instances.
type ChannelCachePubSub interface {
NotifyUpdate(ctx context.Context) error
SubscribeUpdates(ctx context.Context, handler func())
}
// ChannelService 渠道管理服务
type ChannelService struct {
repo ChannelRepository
groupRepo GroupRepository
authCacheInvalidator APIKeyAuthCacheInvalidator
pricingService *PricingService // 用于「可用渠道」展示时回落到全局定价;可为 nil(测试场景)
cachePubSub ChannelCachePubSub
cache atomic.Value // *channelCache
cacheSF singleflight.Group
@@ -163,13 +171,15 @@ type ChannelService struct {
// NewChannelService 创建渠道服务实例。
// pricingService 仅供 ListAvailable 在渠道未配置定价时回落到全局 LiteLLM 数据;
// 计费热路径走独立的 ModelPricingResolver,与此参数无关。可传 nil。
func NewChannelService(repo ChannelRepository, groupRepo GroupRepository, authCacheInvalidator APIKeyAuthCacheInvalidator, pricingService *PricingService) *ChannelService {
func NewChannelService(repo ChannelRepository, groupRepo GroupRepository, authCacheInvalidator APIKeyAuthCacheInvalidator, pricingService *PricingService, cachePubSub ChannelCachePubSub) *ChannelService {
s := &ChannelService{
repo: repo,
groupRepo: groupRepo,
authCacheInvalidator: authCacheInvalidator,
pricingService: pricingService,
cachePubSub: cachePubSub,
}
s.subscribeCacheUpdates(context.Background())
return s
}
@@ -383,13 +393,41 @@ func (s *ChannelService) InvalidateCache() {
}
func (s *ChannelService) invalidateCache() {
s.cache.Store((*channelCache)(nil))
s.cacheSF.Forget("channel_cache")
s.clearCache()
// 主动重建缓存,确保 CRUD 后立即生效
if _, err := s.buildCache(context.Background()); err != nil {
slog.Warn("failed to rebuild channel cache after invalidation", "error", err)
}
s.notifyCacheUpdate()
}
// clearCache clears only the in-process snapshot. Keeping this separate from
// invalidateCache prevents notifications received from Redis from being
// published again in a loop.
func (s *ChannelService) clearCache() {
s.cache.Store((*channelCache)(nil))
s.cacheSF.Forget("channel_cache")
}
func (s *ChannelService) notifyCacheUpdate() {
if s.cachePubSub == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), channelCacheNotifyTTL)
defer cancel()
if err := s.cachePubSub.NotifyUpdate(ctx); err != nil {
slog.Warn("failed to publish channel cache invalidation", "error", err)
}
}
func (s *ChannelService) subscribeCacheUpdates(ctx context.Context) {
if s.cachePubSub == nil {
return
}
s.cachePubSub.SubscribeUpdates(ctx, s.clearCache)
}
// matchWildcard 在通配符定价中查找匹配项(最先匹配到优先)
@@ -6,6 +6,7 @@ import (
"context"
"errors"
"net/http"
"sync"
"testing"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
@@ -175,6 +176,27 @@ type mockChannelAuthCacheInvalidator struct {
invalidatedUserIDs []int64
}
type mockChannelCachePubSub struct {
mu sync.Mutex
handlers []func()
}
func (m *mockChannelCachePubSub) NotifyUpdate(context.Context) error {
m.mu.Lock()
handlers := append([]func(){}, m.handlers...)
m.mu.Unlock()
for _, handler := range handlers {
handler()
}
return nil
}
func (m *mockChannelCachePubSub) SubscribeUpdates(_ context.Context, handler func()) {
m.mu.Lock()
m.handlers = append(m.handlers, handler)
m.mu.Unlock()
}
func (m *mockChannelAuthCacheInvalidator) InvalidateAuthCacheByKey(_ context.Context, key string) {
m.invalidatedKeys = append(m.invalidatedKeys, key)
}
@@ -192,11 +214,11 @@ func (m *mockChannelAuthCacheInvalidator) InvalidateAuthCacheByGroupID(_ context
// ---------------------------------------------------------------------------
func newTestChannelService(repo *mockChannelRepository) *ChannelService {
return NewChannelService(repo, nil, nil, nil)
return NewChannelService(repo, nil, nil, nil, nil)
}
func newTestChannelServiceWithAuth(repo *mockChannelRepository, auth *mockChannelAuthCacheInvalidator) *ChannelService {
return NewChannelService(repo, nil, auth, nil)
return NewChannelService(repo, nil, auth, nil, nil)
}
// makeStandardRepo returns a repo that serves one active channel with anthropic pricing
@@ -1384,6 +1406,42 @@ func TestInvalidateCache(t *testing.T) {
require.Equal(t, 2, callCount) // rebuilt
}
func TestInvalidateCachePublishesToOtherInstances(t *testing.T) {
cachePubSub := &mockChannelCachePubSub{}
publisher := NewChannelService(&mockChannelRepository{}, nil, nil, nil, cachePubSub)
updated := false
subscriberRepo := &mockChannelRepository{
listAllFn: func(_ context.Context) ([]Channel, error) {
model := "old-model"
if updated {
model = "new-model"
}
return []Channel{{
ID: 1,
Status: StatusActive,
GroupIDs: []int64{10},
ModelPricing: []ChannelModelPricing{{
ID: 100,
Platform: PlatformAnthropic,
Models: []string{model},
}},
}}, nil
},
getGroupPlatformsFn: func(_ context.Context, _ []int64) (map[int64]string, error) {
return map[int64]string{10: PlatformAnthropic}, nil
},
}
subscriber := NewChannelService(subscriberRepo, nil, nil, nil, cachePubSub)
require.NotNil(t, subscriber.GetChannelModelPricing(context.Background(), 10, "old-model"))
require.Nil(t, subscriber.GetChannelModelPricing(context.Background(), 10, "new-model"))
updated = true
publisher.invalidateCache()
require.NotNil(t, subscriber.GetChannelModelPricing(context.Background(), 10, "new-model"))
}
// ===========================================================================
// 5. CRUD Methods
// ===========================================================================
@@ -339,7 +339,7 @@ func newPlazaServiceWithBilling(channels []Channel, groups []Group, groupPlatfor
return groupPlatforms, nil
},
}
cs := NewChannelService(repo, nil, nil, nil)
cs := NewChannelService(repo, nil, nil, nil, nil)
bs := NewBillingService(&config.Config{}, catalog)
return NewModelPlazaService(repo, &stubGroupRepoForAvailable{activeGroups: groups}, catalog, bs, NewModelPricingResolver(cs, bs))
}
@@ -230,7 +230,7 @@ func newResolverWithChannel(t *testing.T, pricing []ChannelModelPricing) *ModelP
return map[int64]string{groupID: "anthropic"}, nil
},
}
cs := NewChannelService(repo, nil, nil, nil)
cs := NewChannelService(repo, nil, nil, nil, nil)
bs := newTestBillingServiceForResolver()
return NewModelPricingResolver(cs, bs)
}
@@ -566,7 +566,7 @@ func TestResolve_WithChannelOverride_CacheError(t *testing.T) {
return nil, errors.New("database unavailable")
},
}
cs := NewChannelService(repo, nil, nil, nil)
cs := NewChannelService(repo, nil, nil, nil, nil)
bs := newTestBillingServiceForResolver()
r := NewModelPricingResolver(cs, bs)