Files
sub2api/backend/internal/repository/session_limit_cache_integration_test.go
T
wall-wxkandClaude Opus 4.6 9be9c0b68c fix(gateway): 转发失败的请求立即释放会话槽,不再占用整个空闲超时窗口
会话数量限制(max_sessions)此前只有空闲超时自动过期,没有任何主动释放
机制:请求在选号阶段注册会话后,即使转发失败(上游错误/客户端断开/换号
耗尽),该 session hash 也会占住槽位直到空闲超时(默认 5 分钟),导致
max_sessions 受限的账号在窗口内拒绝所有新会话。

修复:
- SessionLimitCache 新增 UnregisterSession(Redis ZREM),立即移除会话注册
- GatewayService 新增 ReleaseAccountSession,适用条件与 checkAndRegisterSession 对齐
- Messages 主路径:记录 failover 链上注册过会话槽的账号,请求最终失败时
  统一释放;failover continue / profit veto / 兜底分组切换时按次即时释放;
  成功及"上游已计量部分结果"(#5148)保持既有空闲超时语义
- CountTokens:转发失败时同样释放选号时注册的会话槽

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-02 18:45:20 +08:00

93 lines
3.6 KiB
Go

//go:build integration
package repository
import (
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/suite"
)
type SessionLimitCacheSuite struct {
IntegrationRedisSuite
cache service.SessionLimitCache
}
func (s *SessionLimitCacheSuite) SetupTest() {
s.IntegrationRedisSuite.SetupTest()
s.cache = NewSessionLimitCache(s.rdb, 5)
}
// TestUnregisterSession_FreesSlotForNewSession 复现并验证修复场景:
// max_sessions=1 的账号上,会话 A 注册后若请求转发失败,会话槽会被 A 占住
// 整个空闲超时窗口(默认 5 分钟),导致窗口内新会话 B 全部被拒。
// UnregisterSession 必须立即释放槽位,让 B 无需等待空闲超时即可注册。
func (s *SessionLimitCacheSuite) TestUnregisterSession_FreesSlotForNewSession() {
const accountID = int64(101)
maxSessions := 1
idleTimeout := 5 * time.Minute
// 会话 A 注册成功(请求开始转发)
allowed, err := s.cache.RegisterSession(s.ctx, accountID, "session-A", maxSessions, idleTimeout)
s.RequireNoError(err, "RegisterSession A")
s.True(allowed, "首个会话应被允许")
// 转发失败后的新会话 B 在空闲超时窗口内被拒(修复前的行为)
allowed, err = s.cache.RegisterSession(s.ctx, accountID, "session-B", maxSessions, idleTimeout)
s.RequireNoError(err, "RegisterSession B before release")
s.False(allowed, "槽位被 A 占用时新会话应被拒绝")
// 请求最终失败:立即释放 A 的会话注册(不等待空闲超时)
s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, "session-A"), "UnregisterSession A")
// A 释放后 B 立即可注册,无需等待空闲超时
allowed, err = s.cache.RegisterSession(s.ctx, accountID, "session-B", maxSessions, idleTimeout)
s.RequireNoError(err, "RegisterSession B after release")
s.True(allowed, "释放后新会话应立即可注册")
count, err := s.cache.GetActiveSessionCount(s.ctx, accountID)
s.RequireNoError(err, "GetActiveSessionCount")
s.Equal(1, count, "释放后活跃会话数应为 1(仅 B)")
}
// TestUnregisterSession_IdempotentAndMissing 验证释放操作幂等:
// 重复释放与释放不存在的会话均不应报错。
func (s *SessionLimitCacheSuite) TestUnregisterSession_IdempotentAndMissing() {
const accountID = int64(102)
s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, "missing"), "释放不存在的会话")
s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, "missing"), "重复释放")
s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, ""), "空 sessionUUID 应为 no-op")
}
// TestUnregisterSession_OnlyTargetsSpecifiedSession 验证释放只影响目标会话,
// 不影响同账号上的其他活跃会话。
func (s *SessionLimitCacheSuite) TestUnregisterSession_OnlyTargetsSpecifiedSession() {
const accountID = int64(103)
maxSessions := 2
idleTimeout := 5 * time.Minute
for _, sid := range []string{"session-A", "session-B"} {
allowed, err := s.cache.RegisterSession(s.ctx, accountID, sid, maxSessions, idleTimeout)
s.RequireNoError(err, "RegisterSession "+sid)
s.True(allowed)
}
s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, "session-A"), "UnregisterSession A")
active, err := s.cache.IsSessionActive(s.ctx, accountID, "session-A")
s.RequireNoError(err, "IsSessionActive A")
s.False(active, "A 应已释放")
active, err = s.cache.IsSessionActive(s.ctx, accountID, "session-B")
s.RequireNoError(err, "IsSessionActive B")
s.True(active, "B 不应受影响")
}
func TestSessionLimitCacheSuite(t *testing.T) {
suite.Run(t, new(SessionLimitCacheSuite))
}