fix(rate-limiter): check-then-set race in the load test, plus three more bugs - #4
Open
frankstupak wants to merge 1 commit into
Open
fix(rate-limiter): check-then-set race in the load test, plus three more bugs#4frankstupak wants to merge 1 commit into
frankstupak wants to merge 1 commit into
Conversation
…-After; test the real Lua; 800x+ hot-path speedup - Sliding window (Lua + memory): unique ZADD members (ARGV[4]) — member tostring(now) deduped same-millisecond hits, undercounting and over-admitting under burst (the actual race behind the 'CI flakiness' load test) - Sliding window: reject-before-add — denied hits are no longer recorded; bounds storage at limit entries/key under flood (was unbounded) and removes self-lockout of clients that back off - Memory sliding window: head-index prune instead of filter+realloc per hit; 4.4M ops/s under flood (was 5.5k), 7.7M ops/s at high-limit steady state - Token bucket (Lua + memory): resetMs now credits elapsed time toward the next token (was returning the full refill interval — Retry-After overstated by up to one period); full idle bucket restarts its refill clock - Fixed window Lua: INCR instead of GET/SET; TTL set once at window creation, expiring at window end (was re-armed every hit, keys lived a window too long); float-safe windowStart math (32-bit Lua integer overflow under fengari) - Tests now execute the REAL Lua via ioredis-mock (fengari) instead of a hand-written JS mock that never ran the scripts and diverged from Redis ZADD semantics - Fixed-window load test: pinned baseTime to a window boundary (the true flake: unpinned Date.now() rolled the window mid-loop ~3% of runs) and restored the strict remaining assertion relaxed in 44447fe - +9 regression tests (collision, flood bound, recovery, boundary, TTL, Retry-After accuracy); bench/bench-sliding-window.js with before/after
frankstupak
force-pushed
the
lumen-uplift/rate-limiter
branch
from
July 4, 2026 04:22
94048ce to
92b58ee
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The "CI flakiness" commit was hiding three real bugs
HEAD of this repo is
fix(rate-limiter): relax remaining assertion in load test for CI flakiness. The assertion wasn't flaky — the code and the test were broken, three different ways. This PR fixes all of them, makes the test suite actually execute the Lua scripts (it never did), and picks up an 800x+ hot-path win along the way.Bug 1 — Sliding window undercounts same-millisecond bursts (Lua)
ZADD key now tostring(now)uses the timestamp as the sorted-set member. Members are unique, so two hits in the same millisecond silently dedupe into one entry: the limiter undercounts and over-admits under exactly the bursty traffic it exists to stop. Fix: the caller passes a unique member per hit (ARGV[4],now-seq-rand) — the standard pattern in every production Redis sliding-window implementation.Regression test: 3 hits with identical
nowMs, limit 2 → third must deny. On the old Lua it was allowed.Bug 2 — Denied hits were recorded: unbounded memory + self-lockout
Every rejected request was still ZADDed (and pushed, in the memory path). Two consequences: (a) a flooding client grows the sorted set without bound for as long as the flood lasts; (b) a client that backs off stays locked out by its own rejected traffic instead of recovering when its allowed hits age out. Fixed with reject-before-add: storage is now bounded at
limitentries per key.Measured (bench included,
bench/bench-sliding-window.js): limit=100, 50,000 hits in one window → old stores 50,000 timestamps, new stores 100.Bug 3 — The load test itself:
baseTime = Date.now()The fixed-window load test walks 2,000 ms of simulated time from an unpinned wall-clock start. Land within 2s of a window boundary (~3% of runs) and the window rolls over mid-loop, the counter resets, and assertions fail. The relaxation commit loosened the
remainingassertion but leftallowedCountstrict — the test was still flaky after the "fix". PinnedbaseTimeto a window boundary and restored the strict assertion.Bonus: the Lua scripts were never tested
The suite's
MockRedisClientre-implemented all three algorithms in JS and never evaluated the Lua. The "Redis vs memory equivalence" tests were comparing two JS re-implementations with each other. The mock even pushed duplicate members where real ZADD dedupes — which is precisely how Bug 1 stayed invisible. All Redis-path tests (61 pre-existing + 9 new) now execute the real scripts through ioredis-mock's fengari Lua VM. That immediately surfaced a fourth issue:math.floor(now/win)*winoverflows 32-bit Lua integers on millisecond epochs (fine on real Redis Lua 5.1 doubles, broken under fengari) — rewritten float-safe so the same script is correct on both.Also fixed
resetMs: returned the full refill interval whenever the bucket hit zero, ignoring time already elapsed toward the next token —Retry-Afteroverstated by up to one whole period. NowrefillMs - (now - lastRefill). A long-idle full bucket also restarts its refill clock atnow.INCRinstead of GET/SET; TTL set once at window creation and expiring at the window end — the old per-hitPEXPIRE(win)kept dead window keys alive up to a full extra window after the last hit.Performance (in-memory sliding window, hot key)
The old memory path ran
filter()+ reallocated the timestamp array on every request. Replaced with an advancing head index (amortized O(1) prune, compaction when the dead prefix dominates):Reproduce:
node bench/bench-sliding-window.js(old algorithm embedded verbatim as baseline).Verification
tsc --noEmitclean,eslintcleanmain— pre-existing, untouched here.Public API unchanged — same functions, same
LimitOpts/LimitResult, same result semantics on the documented paths.— Lumen Industries