From 50ce383e611e6f8b81149fcb59fb6bbec8ad5307 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E5=B0=90=E6=A5=A0?= Date: Thu, 9 Apr 2026 01:17:33 +0800 Subject: [PATCH] fix: refresh channel caches after channel changes - refresh in-memory channel selection state after channel create, update, delete, and status changes - invalidate cached per-group model lists so model discovery and routing stay consistent - add regression coverage for channel cache refresh behavior - document the cache refresh behavior in changelog and FAQ --- CHANGELOG.md | 15 +++++ README.en.md | 1 + README.md | 1 + model/cache.go | 36 ++++++++++ model/channel.go | 20 ++++++ model/channel_cache_test.go | 130 ++++++++++++++++++++++++++++++++++++ 6 files changed, 203 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 model/channel_cache_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..2c66b86c9a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## Unreleased + +### Added +- Added regression coverage for channel cache refresh after channel create, update, and delete operations. + +### Fixed +- Refreshed the in-memory channel cache immediately after channel create, update, delete, and status changes so newly configured channels are available without waiting for the periodic sync loop. +- Invalidated cached per-group model lists after channel changes so `/v1/models` and request routing stay consistent after channel edits. + +### Docs +- Documented the channel cache refresh behavior in the Chinese and English FAQ sections. diff --git a/README.en.md b/README.en.md index 61864241dd..a2b6eaf239 100644 --- a/README.en.md +++ b/README.en.md @@ -304,6 +304,7 @@ If the channel ID is not provided, load balancing will be used to distribute the 3. It says "No available channels" when trying to use a channel. What should I do? + Please check the user and channel group settings. + Also check the channel model settings. + + Channel changes now refresh the channel cache and per-group model cache immediately. If the error still appears after adding or editing a channel, double-check that the group and model assignments match the token you are using. 4. Channel testing reports an error: "invalid character '<' looking for beginning of value" + This error occurs when the returned value is not valid JSON but an HTML page. + Most likely, the IP of your deployment site or the node of the proxy has been blocked by CloudFlare. diff --git a/README.md b/README.md index 5decf66281..00817e60a3 100644 --- a/README.md +++ b/README.md @@ -445,6 +445,7 @@ https://openai.justsong.cn 3. 提示无可用渠道? + 请检查的用户分组和渠道分组设置。 + 以及渠道的模型设置。 + + 如果你刚新增、编辑或删除渠道,当前版本会立即刷新渠道缓存和分组模型缓存;如果仍然报错,优先检查分组和模型配置是否匹配。 4. 渠道测试报错:`invalid character '<' looking for beginning of value` + 这是因为返回值不是合法的 JSON,而是一个 HTML 页面。 + 大概率是你的部署站的 IP 或代理的节点被 CloudFlare 封禁了。 diff --git a/model/cache.go b/model/cache.go index cfb0f8a483..838ddc4cd9 100644 --- a/model/cache.go +++ b/model/cache.go @@ -170,6 +170,42 @@ func CacheGetGroupModels(ctx context.Context, group string) ([]string, error) { var group2model2channels map[string]map[string][]*Channel var channelSyncLock sync.RWMutex +func RefreshChannelCaches() { + if config.MemoryCacheEnabled { + InitChannelCache() + } + InvalidateGroupModelsCache() +} + +// Channel writes are infrequent, so clearing the per-group model cache +// wholesale is the simplest way to avoid stale entries after adds, edits, +// deletions, or group/model moves. +func InvalidateGroupModelsCache() { + if !common.RedisEnabled || common.RDB == nil { + return + } + ctx := context.Background() + var cursor uint64 + for { + keys, nextCursor, err := common.RDB.Scan(ctx, cursor, "group_models:*", 100).Result() + if err != nil { + logger.SysError("Redis scan group models cache error: " + err.Error()) + return + } + if len(keys) > 0 { + err = common.RDB.Del(ctx, keys...).Err() + if err != nil { + logger.SysError("Redis delete group models cache error: " + err.Error()) + return + } + } + if nextCursor == 0 { + return + } + cursor = nextCursor + } +} + func InitChannelCache() { newChannelId2channel := make(map[int]*Channel) var channels []*Channel diff --git a/model/channel.go b/model/channel.go index 4b0f4b01aa..a51e3df3d9 100644 --- a/model/channel.go +++ b/model/channel.go @@ -94,6 +94,7 @@ func BatchInsertChannels(channels []Channel) error { return err } } + RefreshChannelCaches() return nil } @@ -131,6 +132,10 @@ func (channel *Channel) Insert() error { return err } err = channel.AddAbilities() + if err != nil { + return err + } + RefreshChannelCaches() return err } @@ -142,6 +147,10 @@ func (channel *Channel) Update() error { } DB.Model(channel).First(channel, "id = ?", channel.Id) err = channel.UpdateAbilities() + if err != nil { + return err + } + RefreshChannelCaches() return err } @@ -172,6 +181,10 @@ func (channel *Channel) Delete() error { return err } err = channel.DeleteAbilities() + if err != nil { + return err + } + RefreshChannelCaches() return err } @@ -196,6 +209,7 @@ func UpdateChannelStatusById(id int, status int) { if err != nil { logger.SysError("failed to update channel status: " + err.Error()) } + RefreshChannelCaches() } func UpdateChannelUsedQuota(id int, quota int64) { @@ -215,10 +229,16 @@ func updateChannelUsedQuota(id int, quota int64) { func DeleteChannelByStatus(status int64) (int64, error) { result := DB.Where("status = ?", status).Delete(&Channel{}) + if result.Error == nil && result.RowsAffected > 0 { + RefreshChannelCaches() + } return result.RowsAffected, result.Error } func DeleteDisabledChannel() (int64, error) { result := DB.Where("status = ? or status = ?", ChannelStatusAutoDisabled, ChannelStatusManuallyDisabled).Delete(&Channel{}) + if result.Error == nil && result.RowsAffected > 0 { + RefreshChannelCaches() + } return result.RowsAffected, result.Error } diff --git a/model/channel_cache_test.go b/model/channel_cache_test.go new file mode 100644 index 0000000000..c557f1a9c5 --- /dev/null +++ b/model/channel_cache_test.go @@ -0,0 +1,130 @@ +package model + +import ( + "os" + "path/filepath" + "testing" + + "github.com/songquanpeng/one-api/common" + "github.com/songquanpeng/one-api/common/config" +) + +func setupChannelCacheTestDB(t *testing.T) { + t.Helper() + + oldSQLitePath := common.SQLitePath + oldUsingSQLite := common.UsingSQLite + oldUsingMySQL := common.UsingMySQL + oldUsingPostgreSQL := common.UsingPostgreSQL + oldMemoryCacheEnabled := config.MemoryCacheEnabled + oldDB := DB + oldLogDB := LOG_DB + oldGroup2Model2Channels := group2model2channels + + common.SQLitePath = filepath.Join(t.TempDir(), "one-api-test.db") + common.UsingSQLite = false + common.UsingMySQL = false + common.UsingPostgreSQL = false + config.MemoryCacheEnabled = true + group2model2channels = nil + + t.Setenv("SQL_DSN", "") + InitDB() + LOG_DB = DB + + t.Cleanup(func() { + if DB != nil { + _ = closeDB(DB) + } + DB = oldDB + LOG_DB = oldLogDB + common.SQLitePath = oldSQLitePath + common.UsingSQLite = oldUsingSQLite + common.UsingMySQL = oldUsingMySQL + common.UsingPostgreSQL = oldUsingPostgreSQL + config.MemoryCacheEnabled = oldMemoryCacheEnabled + group2model2channels = oldGroup2Model2Channels + _ = os.Remove(common.SQLitePath) + }) +} + +func TestBatchInsertChannelsRefreshesMemoryChannelCache(t *testing.T) { + setupChannelCacheTestDB(t) + + InitChannelCache() + + err := BatchInsertChannels([]Channel{{ + Name: "qwen", + Type: 1, + Key: "test-key", + Status: ChannelStatusEnabled, + Models: "qwen3.6-plus", + Group: "default", + }}) + if err != nil { + t.Fatalf("BatchInsertChannels returned error: %v", err) + } + + _, err = CacheGetRandomSatisfiedChannel("default", "qwen3.6-plus", false) + if err != nil { + t.Fatalf("expected inserted channel to be immediately available from memory cache, got error: %v", err) + } +} + +func TestChannelUpdateRefreshesMemoryChannelCache(t *testing.T) { + setupChannelCacheTestDB(t) + + channel := Channel{ + Name: "qwen", + Type: 1, + Key: "test-key", + Status: ChannelStatusEnabled, + Models: "qwen3.6-plus", + Group: "default", + } + if err := channel.Insert(); err != nil { + t.Fatalf("Insert returned error: %v", err) + } + InitChannelCache() + + channel.Models = "qwen3-coder-next" + if err := channel.Update(); err != nil { + t.Fatalf("Update returned error: %v", err) + } + + _, err := CacheGetRandomSatisfiedChannel("default", "qwen3-coder-next", false) + if err != nil { + t.Fatalf("expected updated model to be immediately available from memory cache, got error: %v", err) + } + + _, err = CacheGetRandomSatisfiedChannel("default", "qwen3.6-plus", false) + if err == nil { + t.Fatalf("expected removed model to disappear from memory cache after update") + } +} + +func TestChannelDeleteRefreshesMemoryChannelCache(t *testing.T) { + setupChannelCacheTestDB(t) + + channel := Channel{ + Name: "qwen", + Type: 1, + Key: "test-key", + Status: ChannelStatusEnabled, + Models: "qwen3.6-plus", + Group: "default", + } + if err := channel.Insert(); err != nil { + t.Fatalf("Insert returned error: %v", err) + } + InitChannelCache() + + if err := channel.Delete(); err != nil { + t.Fatalf("Delete returned error: %v", err) + } + + _, err := CacheGetRandomSatisfiedChannel("default", "qwen3.6-plus", false) + if err == nil { + t.Fatalf("expected deleted channel to be removed from memory cache") + } +}