Skip to content

Commit d2d41d6

Browse files
authored
Merge pull request Wei-Shaw#894 from touwaeriol/pr/startup-concurrency-cleanup
feat: cleanup stale concurrency slots on startup
2 parents 944b7f7 + a88698f commit d2d41d6

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)