Skip to content

Commit d44d862

Browse files
authored
fix: prevent Redis rate limiter scripts from blocking (#618)
* fix: prevent Redis rate limiter scripts from blocking * fix: ci lint
1 parent 20b8969 commit d44d862

4 files changed

Lines changed: 205 additions & 48 deletions

File tree

core/common/reqlimit/mem.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,10 @@ func (m *InMemoryRecord) rebuildAggregateLocked(e *entry, windowSeconds, cutoff
8282
func (m *InMemoryRecord) refreshAggregateLocked(e *entry, nowSecond, windowSeconds int64) {
8383
cutoff := nowSecond - windowSeconds
8484

85-
if !e.aggregateInitialized || e.windowSeconds != windowSeconds {
85+
if !e.aggregateInitialized ||
86+
e.windowSeconds != windowSeconds ||
87+
e.lastCleanedCutoff > cutoff ||
88+
cutoff-e.lastCleanedCutoff > windowSeconds {
8689
m.rebuildAggregateLocked(e, windowSeconds, cutoff)
8790
return
8891
}

core/common/reqlimit/mem_internal_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,32 @@ func TestInMemoryRecordDropsExpiredMinuteWindowWithoutRealWait(t *testing.T) {
3232
require.True(t, recentExists)
3333
}
3434

35+
func TestInMemoryRecordRebuildsAggregateAfterTimeDiscontinuity(t *testing.T) {
36+
rl := &InMemoryRecord{}
37+
e := rl.getEntry([]string{"group1", "model1"})
38+
nowSecond := time.Now().Unix()
39+
40+
e.Lock()
41+
e.windows[nowSecond-61] = &windowCounts{normal: 3}
42+
e.windows[nowSecond] = &windowCounts{normal: 2}
43+
e.windowSeconds = 60
44+
e.totalNormal = 5
45+
e.lastCleanedCutoff = nowSecond + 60
46+
e.aggregateInitialized = true
47+
e.Unlock()
48+
49+
totalCount, secondCount := rl.GetRequest(time.Minute, "group1", "model1")
50+
require.Equal(t, int64(2), totalCount)
51+
require.Equal(t, int64(2), secondCount)
52+
53+
e.Lock()
54+
_, expiredExists := e.windows[nowSecond-61]
55+
lastCleanedCutoff := e.lastCleanedCutoff
56+
e.Unlock()
57+
require.False(t, expiredExists)
58+
require.Equal(t, nowSecond-60, lastCleanedCutoff)
59+
}
60+
3561
func TestInMemoryRecordCleanupInactiveEntries(t *testing.T) {
3662
rl := &InMemoryRecord{}
3763

core/common/reqlimit/redis.go

Lines changed: 45 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,9 @@ const pushRequestLuaScript = `
5252
local bucket_key = KEYS[1]
5353
local meta_key = KEYS[2]
5454
local window_seconds = tonumber(ARGV[1])
55-
local current_time = tonumber(ARGV[2])
56-
local max_requests = tonumber(ARGV[3])
57-
local n = tonumber(ARGV[4])
55+
local current_time = tonumber(redis.call('TIME')[1])
56+
local max_requests = tonumber(ARGV[2])
57+
local n = tonumber(ARGV[3])
5858
local cutoff_slice = current_time - window_seconds
5959
6060
local function parse_count(value)
@@ -81,7 +81,8 @@ local count = tonumber(redis.call('HGET', meta_key, 'total_normal')) or 0
8181
local over_count = tonumber(redis.call('HGET', meta_key, 'total_over')) or 0
8282
local last_cleaned = tonumber(redis.call('HGET', meta_key, 'last_cleaned_second'))
8383
84-
if not last_cleaned then
84+
if not last_cleaned or last_cleaned > cutoff_slice or
85+
cutoff_slice - last_cleaned > window_seconds then
8586
last_cleaned = cutoff_slice
8687
local all_fields = redis.call('HGETALL', bucket_key)
8788
count = 0
@@ -131,9 +132,8 @@ return string.format("%d:%d:%d", count, over_count, current_second_count)
131132

132133
const getRequestCountLuaScript = `
133134
local exact_meta_key = KEYS[1]
134-
local pattern = KEYS[2]
135135
local window_seconds = tonumber(ARGV[1])
136-
local current_time = tonumber(ARGV[2])
136+
local current_time = tonumber(redis.call('TIME')[1])
137137
local cutoff_slice = current_time - window_seconds
138138
139139
local function parse_count(value)
@@ -143,11 +143,18 @@ local function parse_count(value)
143143
end
144144
145145
local function cleanup_meta(bucket_key, meta_key)
146+
local bucket_ttl = redis.call('PTTL', bucket_key)
147+
if bucket_ttl == -2 then
148+
redis.call('DEL', meta_key)
149+
return 0, 0
150+
end
151+
146152
local count = tonumber(redis.call('HGET', meta_key, 'total_normal')) or 0
147153
local over = tonumber(redis.call('HGET', meta_key, 'total_over')) or 0
148154
local last_cleaned = tonumber(redis.call('HGET', meta_key, 'last_cleaned_second'))
149155
150-
if not last_cleaned then
156+
if not last_cleaned or last_cleaned > cutoff_slice or
157+
cutoff_slice - last_cleaned > window_seconds then
151158
last_cleaned = cutoff_slice
152159
local all_fields = redis.call('HGETALL', bucket_key)
153160
count = 0
@@ -185,32 +192,20 @@ local function cleanup_meta(bucket_key, meta_key)
185192
cutoff_slice
186193
)
187194
188-
return count, over
189-
end
190-
191-
if exact_meta_key ~= '' then
192-
local exact_bucket_key = string.gsub(exact_meta_key, ':meta$', ':buckets')
193-
local count, over = cleanup_meta(exact_bucket_key, exact_meta_key)
194-
local current_value = redis.call('HGET', exact_bucket_key, tostring(current_time))
195-
local current_c, current_oc = parse_count(current_value)
196-
return string.format("%d:%d", count + over, current_c + current_oc)
197-
end
198-
199-
local total = 0
200-
local current_second_count = 0
201-
202-
local keys = redis.call('KEYS', pattern)
203-
for _, meta_key in ipairs(keys) do
204-
local bucket_key = string.gsub(meta_key, ':meta$', ':buckets')
205-
local count, over = cleanup_meta(bucket_key, meta_key)
206-
total = total + count + over
195+
if bucket_ttl == -1 then
196+
bucket_ttl = window_seconds * 1000
197+
redis.call('PEXPIRE', bucket_key, bucket_ttl)
198+
end
199+
redis.call('PEXPIRE', meta_key, bucket_ttl)
207200
208-
local current_value = redis.call('HGET', bucket_key, tostring(current_time))
209-
local current_c, current_oc = parse_count(current_value)
210-
current_second_count = current_second_count + current_c + current_oc
201+
return count, over
211202
end
212203
213-
return string.format("%d:%d", total, current_second_count)
204+
local exact_bucket_key = string.gsub(exact_meta_key, ':meta$', ':buckets')
205+
local count, over = cleanup_meta(exact_bucket_key, exact_meta_key)
206+
local current_value = redis.call('HGET', exact_bucket_key, tostring(current_time))
207+
local current_c, current_oc = parse_count(current_value)
208+
return string.format("%d:%d", count + over, current_c + current_oc)
214209
`
215210

216211
var (
@@ -240,20 +235,25 @@ func (r *redisRateRecord) GetRequest(
240235
return 0, 0, errors.New("redis client is nil")
241236
}
242237

243-
exactMetaKey := ""
238+
if hasWildcard(keys) {
239+
snapshots, err := r.SnapshotByPattern(ctx, duration, keys...)
240+
if err != nil {
241+
return 0, 0, err
242+
}
244243

245-
pattern := r.buildMetaKey(keys...)
246-
if !hasWildcard(keys) {
247-
exactMetaKey = pattern
248-
pattern = ""
244+
for _, snapshot := range snapshots {
245+
totalCount += snapshot.TotalCount
246+
secondCount += snapshot.SecondCount
247+
}
248+
249+
return totalCount, secondCount, nil
249250
}
250251

251252
result, err := getRequestCountScript.Run(
252253
ctx,
253254
rdb,
254-
[]string{exactMetaKey, pattern},
255+
[]string{r.buildMetaKey(keys...)},
255256
duration.Seconds(),
256-
time.Now().Unix(),
257257
).Text()
258258
if err != nil {
259259
return 0, 0, err
@@ -297,7 +297,6 @@ func (r *redisRateRecord) PushRequest(
297297
rdb,
298298
[]string{bucketKey, metaKey},
299299
duration.Seconds(),
300-
time.Now().Unix(),
301300
overed,
302301
n,
303302
).Text()
@@ -345,26 +344,25 @@ func (r *redisRateRecord) SnapshotByPattern(
345344
return nil, errors.New("redis client is nil")
346345
}
347346

348-
metaPattern := r.buildMetaKey(keys...)
349-
if !hasWildcard(keys) {
350-
metaPattern = r.buildMetaKey(keys...)
351-
}
352-
353-
pattern := metaPattern
347+
pattern := r.buildMetaKey(keys...)
354348
iter := rdb.Scan(ctx, 0, pattern, 0).Iterator()
355-
nowUnix := time.Now().Unix()
356349
windowSeconds := duration.Seconds()
357350
snapshots := make([]recordSnapshot, 0)
351+
seenMetaKeys := make(map[string]struct{})
358352

359353
for iter.Next(ctx) {
360354
metaKey := iter.Val()
355+
if _, seen := seenMetaKeys[metaKey]; seen {
356+
continue
357+
}
358+
359+
seenMetaKeys[metaKey] = struct{}{}
361360

362361
result, err := getRequestCountScript.Run(
363362
ctx,
364363
rdb,
365-
[]string{metaKey, ""},
364+
[]string{metaKey},
366365
windowSeconds,
367-
nowUnix,
368366
).Text()
369367
if err != nil {
370368
return nil, err

core/common/reqlimit/redis_integration_test.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,136 @@ func TestRedisRateRecordGetDoesNotRefreshTTL(t *testing.T) {
321321
}, 4*time.Second, 100*time.Millisecond)
322322
}
323323

324+
func TestRedisRateRecordGetMissingDoesNotCreateMeta(t *testing.T) {
325+
ctx := context.Background()
326+
327+
redisClient, cleanup := setupRedisForReqLimitTest(t, ctx)
328+
defer cleanup()
329+
330+
record := newRedisChannelModelRecord(func() *redis.Client { return redisClient })
331+
bucketKey := record.buildBucketKey("missing-channel", "missing-model")
332+
metaKey := record.buildMetaKey("missing-channel", "missing-model")
333+
334+
totalCount, secondCount, err := record.GetRequest(
335+
ctx,
336+
time.Minute,
337+
"missing-channel",
338+
"missing-model",
339+
)
340+
require.NoError(t, err)
341+
require.Zero(t, totalCount)
342+
require.Zero(t, secondCount)
343+
344+
exists, err := redisClient.Exists(ctx, bucketKey, metaKey).Result()
345+
require.NoError(t, err)
346+
require.Zero(t, exists)
347+
}
348+
349+
func TestRedisRateRecordGetRemovesOrphanedMeta(t *testing.T) {
350+
ctx := context.Background()
351+
352+
redisClient, cleanup := setupRedisForReqLimitTest(t, ctx)
353+
defer cleanup()
354+
355+
record := newRedisChannelModelRecord(func() *redis.Client { return redisClient })
356+
metaKey := record.buildMetaKey("orphan-channel", "orphan-model")
357+
require.NoError(t, redisClient.HSet(
358+
ctx,
359+
metaKey,
360+
"total_normal", 0,
361+
"total_over", 0,
362+
"last_cleaned_second", 1,
363+
).Err())
364+
365+
totalCount, secondCount, err := record.GetRequest(
366+
ctx,
367+
time.Minute,
368+
"orphan-channel",
369+
"orphan-model",
370+
)
371+
require.NoError(t, err)
372+
require.Zero(t, totalCount)
373+
require.Zero(t, secondCount)
374+
375+
exists, err := redisClient.Exists(ctx, metaKey).Result()
376+
require.NoError(t, err)
377+
require.Zero(t, exists)
378+
}
379+
380+
func TestRedisRateRecordGetRebuildsStaleMeta(t *testing.T) {
381+
ctx := context.Background()
382+
383+
redisClient, cleanup := setupRedisForReqLimitTest(t, ctx)
384+
defer cleanup()
385+
386+
record := newRedisChannelModelRecord(func() *redis.Client { return redisClient })
387+
bucketKey := record.buildBucketKey("stale-channel", "stale-model")
388+
metaKey := record.buildMetaKey("stale-channel", "stale-model")
389+
now := time.Now().Unix()
390+
391+
pipe := redisClient.Pipeline()
392+
pipe.HSet(ctx, bucketKey, strconv.FormatInt(now, 10), "7:2")
393+
pipe.Expire(ctx, bucketKey, time.Minute)
394+
pipe.HSet(
395+
ctx,
396+
metaKey,
397+
"total_normal", 100,
398+
"total_over", 50,
399+
"last_cleaned_second", 1,
400+
)
401+
pipe.Expire(ctx, metaKey, time.Minute)
402+
_, err := pipe.Exec(ctx)
403+
require.NoError(t, err)
404+
405+
totalCount, secondCount, err := record.GetRequest(
406+
ctx,
407+
time.Minute,
408+
"stale-channel",
409+
"stale-model",
410+
)
411+
require.NoError(t, err)
412+
require.Equal(t, int64(9), totalCount)
413+
require.Equal(t, int64(9), secondCount)
414+
415+
metaTTL, err := redisClient.TTL(ctx, metaKey).Result()
416+
require.NoError(t, err)
417+
require.Positive(t, metaTTL)
418+
}
419+
420+
func TestRedisRateRecordPushRebuildsStaleMeta(t *testing.T) {
421+
ctx := context.Background()
422+
423+
redisClient, cleanup := setupRedisForReqLimitTest(t, ctx)
424+
defer cleanup()
425+
426+
record := newRedisChannelModelRecord(func() *redis.Client { return redisClient })
427+
metaKey := record.buildMetaKey("stale-push-channel", "stale-push-model")
428+
require.NoError(t, redisClient.HSet(
429+
ctx,
430+
metaKey,
431+
"total_normal", 100,
432+
"total_over", 50,
433+
"last_cleaned_second", 1,
434+
).Err())
435+
436+
normalCount, overCount, secondCount, err := record.PushRequest(
437+
ctx,
438+
0,
439+
time.Minute,
440+
1,
441+
"stale-push-channel",
442+
"stale-push-model",
443+
)
444+
require.NoError(t, err)
445+
require.Equal(t, int64(1), normalCount)
446+
require.Zero(t, overCount)
447+
require.Equal(t, int64(1), secondCount)
448+
449+
metaTTL, err := redisClient.TTL(ctx, metaKey).Result()
450+
require.NoError(t, err)
451+
require.Positive(t, metaTTL)
452+
}
453+
324454
func setupRedisForReqLimitTest(t *testing.T, ctx context.Context) (*redis.Client, func()) {
325455
t.Helper()
326456

0 commit comments

Comments
 (0)