Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## 2026-05-31 - [Redis Rate Limiter Pipeline Optimization]
**Learning:** In a `SlidingWindowRateLimiter`, all required state for a successful request (current count, window expiration, and the oldest member's timestamp for the reset time) can be retrieved in a single Redis round-trip by including `zrange` in the initial pipeline.
**Action:** Always include all necessary read operations in the initial pipeline when the cost of an extra read is negligible compared to a separate network round-trip.
115 changes: 115 additions & 0 deletions packages/cache/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, expect, it, vi } from "vitest";
import { SlidingWindowRateLimiter } from "./index";

Check failure on line 2 in packages/cache/src/index.test.ts

View workflow job for this annotation

GitHub Actions / ci

Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './index.js'?
import { Redis } from "ioredis";

vi.mock("ioredis", () => {
const mockPipeline = {
zremrangebyscore: vi.fn().mockReturnThis(),
zadd: vi.fn().mockReturnThis(),
zcard: vi.fn().mockReturnThis(),
zrange: vi.fn().mockReturnThis(),
pexpire: vi.fn().mockReturnThis(),
exec: vi.fn(),
};

const mockRedis = {
status: "ready",
pipeline: vi.fn().mockReturnValue(mockPipeline),
zrange: vi.fn(),
zrem: vi.fn(),
on: vi.fn(),
disconnect: vi.fn(),
quit: vi.fn().mockResolvedValue("OK"),
};

return {
Redis: vi.fn().mockImplementation(() => mockRedis),
};
});

describe("SlidingWindowRateLimiter", () => {
describe("local mode", () => {
it("allows requests within the limit", async () => {
const limiter = new SlidingWindowRateLimiter();
const limit = { limit: 2, windowMs: 1000 };

const res1 = await limiter.consume("user1", limit);
expect(res1.allowed).toBe(true);
expect(res1.remaining).toBe(1);

const res2 = await limiter.consume("user1", limit);
expect(res2.allowed).toBe(true);
expect(res2.remaining).toBe(0);
});

it("denies requests exceeding the limit", async () => {
const limiter = new SlidingWindowRateLimiter();
const limit = { limit: 1, windowMs: 1000 };

await limiter.consume("user2", limit);
const res = await limiter.consume("user2", limit);

expect(res.allowed).toBe(false);
expect(res.remaining).toBe(0);
expect(res.retryAfterMs).toBeGreaterThan(0);
});
});

describe("redis mode", () => {
it("uses redis pipeline correctly for allowed requests", async () => {
const limiter = new SlidingWindowRateLimiter({ redisUrl: "redis://localhost:6379" });
// biome-ignore lint/suspicious/noExplicitAny: needed to access private property for testing
const redis = (limiter as any).redis;
const pipeline = redis.pipeline();

const now = Date.now();
pipeline.exec.mockResolvedValue([
[null, 0], // zremrangebyscore
[null, 1], // zadd
[null, 1], // zcard
[null, ["member", (now - 500).toString()]], // zrange
[null, 1], // pexpire
]);

const limit = { limit: 5, windowMs: 1000 };
const res = await limiter.consume("user3", limit);

expect(res.allowed).toBe(true);
expect(pipeline.zremrangebyscore).toHaveBeenCalled();
expect(pipeline.zadd).toHaveBeenCalled();
expect(pipeline.zcard).toHaveBeenCalled();
expect(pipeline.zrange).toHaveBeenCalledWith(expect.any(String), 0, 0, "WITHSCORES");
expect(pipeline.pexpire).toHaveBeenCalled();

// Verification of round-trips: zrange should NOT have been called on redis directly
expect(redis.zrange).not.toHaveBeenCalled();

expect(res.resetAt).toBe(now - 500 + 1000);
});

it("handles rate limiting correctly with redis", async () => {
const limiter = new SlidingWindowRateLimiter({ redisUrl: "redis://localhost:6379" });
// biome-ignore lint/suspicious/noExplicitAny: needed to access private property for testing
const redis = (limiter as any).redis;
const pipeline = redis.pipeline();

const now = Date.now();
pipeline.exec.mockResolvedValue([
[null, 0], // zremrangebyscore
[null, 1], // zadd
[null, 6], // zcard (over limit of 5)
[null, ["member", (now - 800).toString()]], // zrange
[null, 1], // pexpire
]);
redis.zrem.mockResolvedValue(1);

const limit = { limit: 5, windowMs: 1000 };
const res = await limiter.consume("user4", limit);

expect(res.allowed).toBe(false);
expect(redis.zrem).toHaveBeenCalled();
expect(res.remaining).toBe(0);
expect(res.resetAt).toBe(now - 800 + 1000);
});
});
});
11 changes: 7 additions & 4 deletions packages/cache/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,17 +132,20 @@ export class SlidingWindowRateLimiter {
const member = `${now}:${crypto.randomUUID()}`;
const cutoff = now - limit.windowMs;

// PERFORMANCE: Include zrange in the initial pipeline to reduce round-trips from 2 to 1 for successful requests.
const pipeline = this.redis.pipeline();
pipeline.zremrangebyscore(key, 0, cutoff);
pipeline.zadd(key, now, member);
pipeline.zcard(key);
pipeline.zrange(key, 0, 0, "WITHSCORES");
pipeline.pexpire(key, limit.windowMs);

const results = await pipeline.exec();
const count = Number(results?.[2]?.[1] ?? 0);
const oldestRaw = (results?.[3]?.[1] as string[]) ?? [];
const oldest = oldestRaw.length >= 2 ? Number(oldestRaw[1]) : now;

if (count <= limit.limit) {
const oldestRaw = await this.redis.zrange(key, 0, 0, "WITHSCORES");
const oldest = oldestRaw.length >= 2 ? Number(oldestRaw[1]) : now;
return {
allowed: true,
identifier,
Expand All @@ -153,9 +156,9 @@ export class SlidingWindowRateLimiter {
};
}

// If we exceeded the limit, we need to remove the member we just added
// and recalculate the state.
await this.redis.zrem(key, member);
const oldestRaw = await this.redis.zrange(key, 0, 0, "WITHSCORES");
const oldest = oldestRaw.length >= 2 ? Number(oldestRaw[1]) : now;
const stableCount = Math.max(0, count - 1);
return {
allowed: false,
Expand Down
Loading