Consolidate rate limit Redis operations into a single EVAL call
Consolidate the rate limit evaluator's Redis operations into a single `EVAL` (Lua script) call, replacing the current multi-round-trip approach.
Parent epic: https://gitlab.com/groups/gitlab-com/gl-infra/-/work_items/2021
Context: https://gitlab.com/gitlab-org/ruby/gems/labkit-ruby/-/merge_requests/274#note_3302529826
## Problem
The rate limit evaluator currently makes 2-3 Redis round-trips per check:
1. `INCR` — increment counter
2. `EXPIRE` — set TTL (only on first write when count == 1)
3. `TTL` — read remaining time for `reset_at`
MR !274 optimizes this by pipelining INCR + TTL into one round-trip, with a separate EXPIRE when needed. This issue takes it further: a single `EVAL` call that does everything atomically.
## Proposed Lua script
```lua
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
local ttl = redis.call('TTL', KEYS[1])
return {count, ttl}
```
## Benefits
- **1 round-trip** always (vs 1-2 with the pipeline approach)
- **Atomic**: the INCR, conditional EXPIRE, and TTL happen as a single atomic operation — no race between INCR and EXPIRE
- **Less Ruby overhead**: the counter logic moves to Redis, reducing per-request Ruby work on the hot path
## Considerations
- Use `EVALSHA` with `SCRIPT LOAD` fallback for performance (avoid sending the script text on every call)
- Single-key operation — Redis Cluster safe (`KEYS[1]` only)
- `FakeRedis` in tests will need an `eval` method, or tests mock the eval response
- The Redis client passed to labkit must support `eval`/`evalsha` — standard for all Redis clients GitLab uses
## Acceptance Criteria
- `incr_with_ttl` in `Labkit::RateLimit::Evaluator` replaced with a single `EVAL` call
- Script cached via `EVALSHA` with `SCRIPT LOAD` fallback on `NOSCRIPT` error
- All existing rate limit tests pass (behavior unchanged)
- New test covering the Lua script execution path
issue