fix: replace sort-based rate-limit eviction with Map insertion-order iteration (BUG-S9)
O(k) oldest-first eviction instead of O(n log n) sort. Added 10k benchmark test. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
f18b04ba20
commit
2576221e24
@@ -91,6 +91,32 @@ describe('rateLimit', () => {
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('evicts oldest entries when map exceeds MAX_MAP_SIZE (10k benchmark)', async () => {
|
||||
vi.useFakeTimers()
|
||||
const app = new Hono()
|
||||
// Window of 60s so entries don't expire during test
|
||||
app.get('/test', rateLimit(60_000, 100), (c) => c.json({ ok: true }))
|
||||
|
||||
// Fill with 10,001 unique IPs to exceed MAX_MAP_SIZE (10,000)
|
||||
for (let i = 0; i < 10_001; i++) {
|
||||
const ip = `${(i >> 24) & 0xff}.${(i >> 16) & 0xff}.${(i >> 8) & 0xff}.${i & 0xff}`
|
||||
await app.request('/test', { headers: { 'x-real-ip': ip } })
|
||||
}
|
||||
|
||||
// Trigger cleanup interval (every 5 min)
|
||||
vi.advanceTimersByTime(5 * 60 * 1000)
|
||||
|
||||
// First IP (0.0.0.0) should have been evicted — gets a fresh counter
|
||||
const res = await app.request('/test', { headers: { 'x-real-ip': '0.0.0.0' } })
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
// Last IP (0.0.39.17) should still be tracked — counter increments
|
||||
const last = await app.request('/test', { headers: { 'x-real-ip': '0.0.39.17' } })
|
||||
expect(last.status).toBe(200) // still under limit (count now 2)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('poll endpoint config: 429 after 30 requests in 1s window', async () => {
|
||||
const app = new Hono()
|
||||
// Matches the actual /poll endpoint configuration
|
||||
|
||||
@@ -14,18 +14,22 @@ export const cleanupInterval = setInterval(() => {
|
||||
for (const [key, entry] of map) {
|
||||
if (now > entry.resetAt) map.delete(key)
|
||||
}
|
||||
// Evict oldest if over limit
|
||||
// Evict oldest entries (Map iterates in insertion order)
|
||||
if (map.size > MAX_MAP_SIZE) {
|
||||
const sorted = [...map.entries()].sort((a, b) => a[1].resetAt - b[1].resetAt)
|
||||
const toRemove = sorted.slice(0, map.size - MAX_MAP_SIZE)
|
||||
for (const [key] of toRemove) map.delete(key)
|
||||
let toRemove = map.size - MAX_MAP_SIZE
|
||||
for (const key of map.keys()) {
|
||||
if (toRemove-- <= 0) break
|
||||
map.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also clean bot hit counts
|
||||
// Also clean bot hit counts (Map iterates in insertion order)
|
||||
if (botHitCounts.size > MAX_MAP_SIZE) {
|
||||
const sorted = [...botHitCounts.entries()].sort((a, b) => a[1] - b[1])
|
||||
const toRemove = sorted.slice(0, botHitCounts.size - MAX_MAP_SIZE)
|
||||
for (const [key] of toRemove) botHitCounts.delete(key)
|
||||
let toRemove = botHitCounts.size - MAX_MAP_SIZE
|
||||
for (const key of botHitCounts.keys()) {
|
||||
if (toRemove-- <= 0) break
|
||||
botHitCounts.delete(key)
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user