Skip to content

Commit a88698f

Browse files
touwaeriolclaude
andcommitted
feat: cleanup stale concurrency slots on startup
When the service restarts, concurrency slots from the old process remain in Redis, causing phantom occupancy. On startup, scan all concurrency sorted sets and remove members with non-current process prefix, then clear orphaned wait queue counters. Uses Go-side SCAN to discover keys (compatible with Redis client prefix hooks in tests), then passes them to a Lua script for atomic member-level cleanup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c8eff34 commit a88698f

10 files changed

Lines changed: 254 additions & 23 deletions

backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ func (f *fakeConcurrencyCache) GetAccountConcurrencyBatch(_ context.Context, acc
127127
return result, nil
128128
}
129129
func (f *fakeConcurrencyCache) CleanupExpiredAccountSlots(context.Context, int64) error { return nil }
130+
func (f *fakeConcurrencyCache) CleanupStaleProcessSlots(context.Context, string) error { return nil }
130131

131132
func newTestGatewayHandler(t *testing.T, group *service.Group, accounts []*service.Account) (*GatewayHandler, func()) {
132133
t.Helper()

backend/internal/handler/gateway_helper_fastpath_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ func (m *concurrencyCacheMock) CleanupExpiredAccountSlots(ctx context.Context, a
8989
return nil
9090
}
9191

92+
func (m *concurrencyCacheMock) CleanupStaleProcessSlots(ctx context.Context, activeRequestPrefix string) error {
93+
return nil
94+
}
95+
9296
func TestConcurrencyHelper_TryAcquireUserSlot(t *testing.T) {
9397
cache := &concurrencyCacheMock{
9498
acquireUserSlotFn: func(ctx context.Context, userID int64, maxConcurrency int, requestID string) (bool, error) {

backend/internal/handler/gateway_helper_hotpath_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,10 @@ func (s *helperConcurrencyCacheStub) CleanupExpiredAccountSlots(ctx context.Cont
120120
return nil
121121
}
122122

123+
func (s *helperConcurrencyCacheStub) CleanupStaleProcessSlots(ctx context.Context, activeRequestPrefix string) error {
124+
return nil
125+
}
126+
123127
func newHelperTestContext(method, path string) (*gin.Context, *httptest.ResponseRecorder) {
124128
gin.SetMode(gin.TestMode)
125129
rec := httptest.NewRecorder()

backend/internal/repository/concurrency_cache.go

Lines changed: 109 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -147,17 +147,47 @@ var (
147147
return 1
148148
`)
149149

150-
// cleanupExpiredSlotsScript - remove expired slots
151-
// KEYS[1] = concurrency:account:{accountID}
152-
// ARGV[1] = TTL (seconds)
150+
// cleanupExpiredSlotsScript 清理单个账号/用户有序集合中过期槽位
151+
// KEYS[1] = 有序集合键
152+
// ARGV[1] = TTL(秒)
153153
cleanupExpiredSlotsScript = redis.NewScript(`
154-
local key = KEYS[1]
155-
local ttl = tonumber(ARGV[1])
156-
local timeResult = redis.call('TIME')
157-
local now = tonumber(timeResult[1])
158-
local expireBefore = now - ttl
159-
return redis.call('ZREMRANGEBYSCORE', key, '-inf', expireBefore)
160-
`)
154+
local key = KEYS[1]
155+
local ttl = tonumber(ARGV[1])
156+
local timeResult = redis.call('TIME')
157+
local now = tonumber(timeResult[1])
158+
local expireBefore = now - ttl
159+
redis.call('ZREMRANGEBYSCORE', key, '-inf', expireBefore)
160+
if redis.call('ZCARD', key) == 0 then
161+
redis.call('DEL', key)
162+
else
163+
redis.call('EXPIRE', key, ttl)
164+
end
165+
return 1
166+
`)
167+
168+
// startupCleanupScript 清理非当前进程前缀的槽位成员。
169+
// KEYS 是有序集合键列表,ARGV[1] 是当前进程前缀,ARGV[2] 是槽位 TTL。
170+
// 遍历每个 KEYS[i],移除前缀不匹配的成员,清空后删 key,否则刷新 EXPIRE。
171+
startupCleanupScript = redis.NewScript(`
172+
local activePrefix = ARGV[1]
173+
local slotTTL = tonumber(ARGV[2])
174+
local removed = 0
175+
for i = 1, #KEYS do
176+
local key = KEYS[i]
177+
local members = redis.call('ZRANGE', key, 0, -1)
178+
for _, member in ipairs(members) do
179+
if string.sub(member, 1, string.len(activePrefix)) ~= activePrefix then
180+
removed = removed + redis.call('ZREM', key, member)
181+
end
182+
end
183+
if redis.call('ZCARD', key) == 0 then
184+
redis.call('DEL', key)
185+
else
186+
redis.call('EXPIRE', key, slotTTL)
187+
end
188+
end
189+
return removed
190+
`)
161191
)
162192

163193
type concurrencyCache struct {
@@ -463,3 +493,72 @@ func (c *concurrencyCache) CleanupExpiredAccountSlots(ctx context.Context, accou
463493
_, err := cleanupExpiredSlotsScript.Run(ctx, c.rdb, []string{key}, c.slotTTLSeconds).Result()
464494
return err
465495
}
496+
497+
func (c *concurrencyCache) CleanupStaleProcessSlots(ctx context.Context, activeRequestPrefix string) error {
498+
if activeRequestPrefix == "" {
499+
return nil
500+
}
501+
502+
// 1. 清理有序集合中非当前进程前缀的成员
503+
slotPatterns := []string{accountSlotKeyPrefix + "*", userSlotKeyPrefix + "*"}
504+
for _, pattern := range slotPatterns {
505+
if err := c.cleanupSlotsByPattern(ctx, pattern, activeRequestPrefix); err != nil {
506+
return err
507+
}
508+
}
509+
510+
// 2. 删除所有等待队列计数器(重启后计数器失效)
511+
waitPatterns := []string{accountWaitKeyPrefix + "*", waitQueueKeyPrefix + "*"}
512+
for _, pattern := range waitPatterns {
513+
if err := c.deleteKeysByPattern(ctx, pattern); err != nil {
514+
return err
515+
}
516+
}
517+
518+
return nil
519+
}
520+
521+
// cleanupSlotsByPattern 扫描匹配 pattern 的有序集合键,批量调用 Lua 脚本清理非当前进程成员。
522+
func (c *concurrencyCache) cleanupSlotsByPattern(ctx context.Context, pattern, activePrefix string) error {
523+
const scanCount = 200
524+
var cursor uint64
525+
for {
526+
keys, nextCursor, err := c.rdb.Scan(ctx, cursor, pattern, scanCount).Result()
527+
if err != nil {
528+
return fmt.Errorf("scan %s: %w", pattern, err)
529+
}
530+
if len(keys) > 0 {
531+
_, err := startupCleanupScript.Run(ctx, c.rdb, keys, activePrefix, c.slotTTLSeconds).Result()
532+
if err != nil {
533+
return fmt.Errorf("cleanup slots %s: %w", pattern, err)
534+
}
535+
}
536+
cursor = nextCursor
537+
if cursor == 0 {
538+
break
539+
}
540+
}
541+
return nil
542+
}
543+
544+
// deleteKeysByPattern 扫描匹配 pattern 的键并删除。
545+
func (c *concurrencyCache) deleteKeysByPattern(ctx context.Context, pattern string) error {
546+
const scanCount = 200
547+
var cursor uint64
548+
for {
549+
keys, nextCursor, err := c.rdb.Scan(ctx, cursor, pattern, scanCount).Result()
550+
if err != nil {
551+
return fmt.Errorf("scan %s: %w", pattern, err)
552+
}
553+
if len(keys) > 0 {
554+
if err := c.rdb.Del(ctx, keys...).Err(); err != nil {
555+
return fmt.Errorf("del %s: %w", pattern, err)
556+
}
557+
}
558+
cursor = nextCursor
559+
if cursor == 0 {
560+
break
561+
}
562+
}
563+
return nil
564+
}

backend/internal/repository/concurrency_cache_integration_test.go

Lines changed: 86 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ type ConcurrencyCacheSuite struct {
2525
cache service.ConcurrencyCache
2626
}
2727

28+
func TestConcurrencyCacheSuite(t *testing.T) {
29+
suite.Run(t, new(ConcurrencyCacheSuite))
30+
}
31+
2832
func (s *ConcurrencyCacheSuite) SetupTest() {
2933
s.IntegrationRedisSuite.SetupTest()
3034
s.cache = NewConcurrencyCache(s.rdb, testSlotTTLMinutes, int(testSlotTTL.Seconds()))
@@ -247,17 +251,41 @@ func (s *ConcurrencyCacheSuite) TestAccountWaitQueue_IncrementAndDecrement() {
247251
require.Equal(s.T(), 1, val, "expected account wait count 1")
248252
}
249253

250-
func (s *ConcurrencyCacheSuite) TestAccountWaitQueue_DecrementNoNegative() {
251-
accountID := int64(301)
252-
waitKey := fmt.Sprintf("%s%d", accountWaitKeyPrefix, accountID)
254+
func (s *ConcurrencyCacheSuite) TestCleanupStaleProcessSlots() {
255+
accountID := int64(901)
256+
userID := int64(902)
257+
accountKey := fmt.Sprintf("%s%d", accountSlotKeyPrefix, accountID)
258+
userKey := fmt.Sprintf("%s%d", userSlotKeyPrefix, userID)
259+
userWaitKey := fmt.Sprintf("%s%d", waitQueueKeyPrefix, userID)
260+
accountWaitKey := fmt.Sprintf("%s%d", accountWaitKeyPrefix, accountID)
253261

254-
require.NoError(s.T(), s.cache.DecrementAccountWaitCount(s.ctx, accountID), "DecrementAccountWaitCount on non-existent key")
262+
now := time.Now().Unix()
263+
require.NoError(s.T(), s.rdb.ZAdd(s.ctx, accountKey,
264+
redis.Z{Score: float64(now), Member: "oldproc-1"},
265+
redis.Z{Score: float64(now), Member: "keep-1"},
266+
).Err())
267+
require.NoError(s.T(), s.rdb.ZAdd(s.ctx, userKey,
268+
redis.Z{Score: float64(now), Member: "oldproc-2"},
269+
redis.Z{Score: float64(now), Member: "keep-2"},
270+
).Err())
271+
require.NoError(s.T(), s.rdb.Set(s.ctx, userWaitKey, 3, time.Minute).Err())
272+
require.NoError(s.T(), s.rdb.Set(s.ctx, accountWaitKey, 2, time.Minute).Err())
273+
274+
require.NoError(s.T(), s.cache.CleanupStaleProcessSlots(s.ctx, "keep-"))
275+
276+
accountMembers, err := s.rdb.ZRange(s.ctx, accountKey, 0, -1).Result()
277+
require.NoError(s.T(), err)
278+
require.Equal(s.T(), []string{"keep-1"}, accountMembers)
255279

256-
val, err := s.rdb.Get(s.ctx, waitKey).Int()
257-
if !errors.Is(err, redis.Nil) {
258-
require.NoError(s.T(), err, "Get waitKey")
259-
}
260-
require.GreaterOrEqual(s.T(), val, 0, "expected non-negative account wait count after decrement on empty")
280+
userMembers, err := s.rdb.ZRange(s.ctx, userKey, 0, -1).Result()
281+
require.NoError(s.T(), err)
282+
require.Equal(s.T(), []string{"keep-2"}, userMembers)
283+
284+
_, err = s.rdb.Get(s.ctx, userWaitKey).Result()
285+
require.True(s.T(), errors.Is(err, redis.Nil))
286+
287+
_, err = s.rdb.Get(s.ctx, accountWaitKey).Result()
288+
require.True(s.T(), errors.Is(err, redis.Nil))
261289
}
262290

263291
func (s *ConcurrencyCacheSuite) TestGetAccountConcurrency_Missing() {
@@ -407,6 +435,53 @@ func (s *ConcurrencyCacheSuite) TestCleanupExpiredAccountSlots_NoExpired() {
407435
require.Equal(s.T(), 2, cur)
408436
}
409437

410-
func TestConcurrencyCacheSuite(t *testing.T) {
411-
suite.Run(t, new(ConcurrencyCacheSuite))
438+
func (s *ConcurrencyCacheSuite) TestCleanupStaleProcessSlots_RemovesOldPrefixesAndWaitCounters() {
439+
accountID := int64(901)
440+
userID := int64(902)
441+
accountSlotKey := fmt.Sprintf("%s%d", accountSlotKeyPrefix, accountID)
442+
userSlotKey := fmt.Sprintf("%s%d", userSlotKeyPrefix, userID)
443+
userWaitKey := fmt.Sprintf("%s%d", waitQueueKeyPrefix, userID)
444+
accountWaitKey := fmt.Sprintf("%s%d", accountWaitKeyPrefix, accountID)
445+
446+
now := float64(time.Now().Unix())
447+
require.NoError(s.T(), s.rdb.ZAdd(s.ctx, accountSlotKey,
448+
redis.Z{Score: now, Member: "oldproc-1"},
449+
redis.Z{Score: now, Member: "activeproc-1"},
450+
).Err())
451+
require.NoError(s.T(), s.rdb.Expire(s.ctx, accountSlotKey, testSlotTTL).Err())
452+
require.NoError(s.T(), s.rdb.ZAdd(s.ctx, userSlotKey,
453+
redis.Z{Score: now, Member: "oldproc-2"},
454+
redis.Z{Score: now, Member: "activeproc-2"},
455+
).Err())
456+
require.NoError(s.T(), s.rdb.Expire(s.ctx, userSlotKey, testSlotTTL).Err())
457+
require.NoError(s.T(), s.rdb.Set(s.ctx, userWaitKey, 3, testSlotTTL).Err())
458+
require.NoError(s.T(), s.rdb.Set(s.ctx, accountWaitKey, 2, testSlotTTL).Err())
459+
460+
require.NoError(s.T(), s.cache.CleanupStaleProcessSlots(s.ctx, "activeproc-"))
461+
462+
accountMembers, err := s.rdb.ZRange(s.ctx, accountSlotKey, 0, -1).Result()
463+
require.NoError(s.T(), err)
464+
require.Equal(s.T(), []string{"activeproc-1"}, accountMembers)
465+
466+
userMembers, err := s.rdb.ZRange(s.ctx, userSlotKey, 0, -1).Result()
467+
require.NoError(s.T(), err)
468+
require.Equal(s.T(), []string{"activeproc-2"}, userMembers)
469+
470+
_, err = s.rdb.Get(s.ctx, userWaitKey).Result()
471+
require.ErrorIs(s.T(), err, redis.Nil)
472+
_, err = s.rdb.Get(s.ctx, accountWaitKey).Result()
473+
require.ErrorIs(s.T(), err, redis.Nil)
474+
}
475+
476+
func (s *ConcurrencyCacheSuite) TestCleanupStaleProcessSlots_DeletesEmptySlotKeys() {
477+
accountID := int64(903)
478+
accountSlotKey := fmt.Sprintf("%s%d", accountSlotKeyPrefix, accountID)
479+
require.NoError(s.T(), s.rdb.ZAdd(s.ctx, accountSlotKey, redis.Z{Score: float64(time.Now().Unix()), Member: "oldproc-1"}).Err())
480+
require.NoError(s.T(), s.rdb.Expire(s.ctx, accountSlotKey, testSlotTTL).Err())
481+
482+
require.NoError(s.T(), s.cache.CleanupStaleProcessSlots(s.ctx, "activeproc-"))
483+
484+
exists, err := s.rdb.Exists(s.ctx, accountSlotKey).Result()
485+
require.NoError(s.T(), err)
486+
require.EqualValues(s.T(), 0, exists)
412487
}

backend/internal/service/concurrency_service.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ type ConcurrencyCache interface {
4343

4444
// 清理过期槽位(后台任务)
4545
CleanupExpiredAccountSlots(ctx context.Context, accountID int64) error
46+
47+
// 启动时清理旧进程遗留槽位与等待计数
48+
CleanupStaleProcessSlots(ctx context.Context, activeRequestPrefix string) error
4649
}
4750

4851
var (
@@ -59,13 +62,22 @@ func initRequestIDPrefix() string {
5962
return "r" + strconv.FormatUint(fallback, 36)
6063
}
6164

62-
// generateRequestID generates a unique request ID for concurrency slot tracking.
63-
// Format: {process_random_prefix}-{base36_counter}
65+
func RequestIDPrefix() string {
66+
return requestIDPrefix
67+
}
68+
6469
func generateRequestID() string {
6570
seq := requestIDCounter.Add(1)
6671
return requestIDPrefix + "-" + strconv.FormatUint(seq, 36)
6772
}
6873

74+
func (s *ConcurrencyService) CleanupStaleProcessSlots(ctx context.Context) error {
75+
if s == nil || s.cache == nil {
76+
return nil
77+
}
78+
return s.cache.CleanupStaleProcessSlots(ctx, RequestIDPrefix())
79+
}
80+
6981
const (
7082
// Default extra wait slots beyond concurrency limit
7183
defaultExtraWaitSlots = 20

backend/internal/service/concurrency_service_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,32 @@ func (c *stubConcurrencyCacheForTest) CleanupExpiredAccountSlots(_ context.Conte
9191
return c.cleanupErr
9292
}
9393

94+
func (c *stubConcurrencyCacheForTest) CleanupStaleProcessSlots(_ context.Context, _ string) error {
95+
return c.cleanupErr
96+
}
97+
98+
type trackingConcurrencyCache struct {
99+
stubConcurrencyCacheForTest
100+
cleanupPrefix string
101+
}
102+
103+
func (c *trackingConcurrencyCache) CleanupStaleProcessSlots(_ context.Context, prefix string) error {
104+
c.cleanupPrefix = prefix
105+
return c.cleanupErr
106+
}
107+
108+
func TestCleanupStaleProcessSlots_NilCache(t *testing.T) {
109+
svc := &ConcurrencyService{cache: nil}
110+
require.NoError(t, svc.CleanupStaleProcessSlots(context.Background()))
111+
}
112+
113+
func TestCleanupStaleProcessSlots_DelegatesPrefix(t *testing.T) {
114+
cache := &trackingConcurrencyCache{}
115+
svc := NewConcurrencyService(cache)
116+
require.NoError(t, svc.CleanupStaleProcessSlots(context.Background()))
117+
require.Equal(t, RequestIDPrefix(), cache.cleanupPrefix)
118+
}
119+
94120
func TestAcquireAccountSlot_Success(t *testing.T) {
95121
cache := &stubConcurrencyCacheForTest{acquireResult: true}
96122
svc := NewConcurrencyService(cache)

backend/internal/service/gateway_multiplatform_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1986,6 +1986,10 @@ func (m *mockConcurrencyCache) CleanupExpiredAccountSlots(ctx context.Context, a
19861986
return nil
19871987
}
19881988

1989+
func (m *mockConcurrencyCache) CleanupStaleProcessSlots(ctx context.Context, activeRequestPrefix string) error {
1990+
return nil
1991+
}
1992+
19891993
func (m *mockConcurrencyCache) GetUsersLoadBatch(ctx context.Context, users []UserWithConcurrency) (map[int64]*UserLoadInfo, error) {
19901994
result := make(map[int64]*UserLoadInfo, len(users))
19911995
for _, user := range users {

backend/internal/service/wire.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ func ProvideDeferredService(accountRepo AccountRepository, timingWheel *TimingWh
105105
// ProvideConcurrencyService creates ConcurrencyService and starts slot cleanup worker.
106106
func ProvideConcurrencyService(cache ConcurrencyCache, accountRepo AccountRepository, cfg *config.Config) *ConcurrencyService {
107107
svc := NewConcurrencyService(cache)
108+
if err := svc.CleanupStaleProcessSlots(context.Background()); err != nil {
109+
logger.LegacyPrintf("service.concurrency", "Warning: startup cleanup stale process slots failed: %v", err)
110+
}
108111
if cfg != nil {
109112
svc.StartSlotCleanupWorker(accountRepo, cfg.Gateway.Scheduling.SlotCleanupInterval)
110113
}

backend/internal/testutil/stubs.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ func (c StubConcurrencyCache) GetAccountConcurrencyBatch(_ context.Context, acco
7676
func (c StubConcurrencyCache) CleanupExpiredAccountSlots(_ context.Context, _ int64) error {
7777
return nil
7878
}
79+
func (c StubConcurrencyCache) CleanupStaleProcessSlots(_ context.Context, _ string) error {
80+
return nil
81+
}
7982

8083
// ============================================================
8184
// StubGatewayCache — service.GatewayCache 的空实现

0 commit comments

Comments
 (0)