Skip to content
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
422f176
feat(redis): add centralized Redis client and cache utilities
BillChirico Mar 2, 2026
6859442
feat(redis): integrate Redis into startup/shutdown and health endpoint
BillChirico Mar 2, 2026
67075f7
feat(cache): add Discord API and reputation cache layers
BillChirico Mar 2, 2026
a42bfab
feat(cache): integrate caching into modules and API routes
BillChirico Mar 2, 2026
713cd4d
feat(cache): config invalidation, tests, and test mocks for all modules
BillChirico Mar 2, 2026
a82a08e
feat(redis): add distributed Redis-backed rate limiter
BillChirico Mar 2, 2026
1e92e97
chore: add Redis service to Railway and Docker Compose
BillChirico Mar 2, 2026
c6f09b6
fix: check pipeline command errors and remove unused windowSec
BillChirico Mar 2, 2026
193aaa8
fix: remove unused cacheGet and cacheSet imports from community routes
BillChirico Mar 2, 2026
a8d573e
fix: standardize leaderboard cache invalidation to use pattern delete
BillChirico Mar 2, 2026
4c68ec5
fix: repair corrupted JSDoc header in reviewHandler.js
BillChirico Mar 2, 2026
7f7e798
fix: move cache invalidation after all reputation DB writes
BillChirico Mar 2, 2026
d0ace9a
fix: escape regex metacharacters in glob-to-regex conversion
BillChirico Mar 2, 2026
6efff59
fix: replace flushdb() with prefix-scoped SCAN+DEL in cacheClear
BillChirico Mar 2, 2026
6aac00f
fix: close existing connection in _resetRedis to prevent leaks
BillChirico Mar 2, 2026
196c84e
fix: return Redis-cached channel metadata on DJS cache miss
BillChirico Mar 2, 2026
fc6c67d
fix: replace dynamic import with static import for cacheDelPattern
BillChirico Mar 2, 2026
648527e
fix: wrap fake timers in try/finally to prevent timer leaks
BillChirico Mar 2, 2026
3181c26
chore: remove flushdb mock from cache test (no longer called)
BillChirico Mar 2, 2026
1f0eae5
fix: update reputationCache tests for paginated leaderboard keys
BillChirico Mar 2, 2026
60862ce
fix: correct leaderboard cache key pattern and fetchChannelCached ret…
BillChirico Mar 2, 2026
5c0cbd5
fix: make _resetRedisClient async so callers can await full reset
BillChirico Mar 2, 2026
e98ae3a
test: await _resetRedis() in hooks; assert new cache-invalidation lis…
BillChirico Mar 2, 2026
600b870
feat(redis): wire distributed rate limiter and extend cache to commands
BillChirico Mar 2, 2026
e0c1bf4
fix: resolve merge conflicts with main
BillChirico Mar 2, 2026
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
23 changes: 20 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: volvoxbot
# Host port mapping removed — something on the Docker Desktop VM holds :5432.
# Bot and web connect via internal network (db:5432). Use `docker compose exec db psql` for queries.
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
Expand All @@ -16,6 +14,19 @@ services:
timeout: 5s
retries: 5

redis:
image: redis:7-alpine
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5

bot:
build:
context: .
Expand All @@ -24,11 +35,14 @@ services:
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
env_file:
- .env
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/volvoxbot
DATABASE_SSL: "false"
REDIS_URL: redis://redis:6379
DISABLE_PROMPT_CACHING: "1"

web:
Expand All @@ -39,15 +53,17 @@ services:
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "3000:3000"
env_file:
- .env
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/volvoxbot
DATABASE_SSL: "false"
REDIS_URL: redis://redis:6379
NEXTAUTH_URL: http://localhost:3000
# BOT_API_URL: http://bot:3001 # Uncomment when bot HTTP API is implemented (Issue #29)
profiles:
- full

Expand All @@ -68,3 +84,4 @@ services:

volumes:
pgdata:
redisdata:
5 changes: 5 additions & 0 deletions railway.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,8 @@ dockerLayerCaching = false
restartPolicyType = "ON_FAILURE"
restartPolicyMaxRetries = 10
numReplicas = 1

[services.redis]
serviceName = "redis"
type = "redis"
plan = "free"
78 changes: 78 additions & 0 deletions src/api/middleware/redisRateLimit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Redis-backed Rate Limiter
* Distributed rate limiting using Redis for multi-instance deployments.
* Falls back to the existing in-memory rate limiter when Redis is unavailable.
*
* @see https://github.com/VolvoxLLC/volvox-bot/issues/177
*/

import { getRedis } from '../../redis.js';
import { rateLimit as inMemoryRateLimit } from './rateLimit.js';

/**
* Creates Redis-backed rate limiting middleware using a sliding window counter.
* Automatically falls back to in-memory rate limiting if Redis is not available.
*
* @param {Object} [options] - Rate limiter configuration
* @param {number} [options.windowMs=900000] - Time window in milliseconds (default: 15 minutes)
* @param {number} [options.max=100] - Maximum requests per window per IP (default: 100)
* @param {string} [options.keyPrefix='rl'] - Redis key prefix
* @returns {import('express').RequestHandler & { destroy: () => void }}
*/
export function redisRateLimit({ windowMs = 15 * 60 * 1000, max = 100, keyPrefix = 'rl' } = {}) {
// Create in-memory fallback (always available)
const fallback = inMemoryRateLimit({ windowMs, max });

const middleware = async (req, res, next) => {
const redis = getRedis();

// Fall back to in-memory if Redis isn't available
if (!redis) {
return fallback(req, res, next);
}

const ip = req.ip;
const key = `${keyPrefix}:${ip}`;

try {
// Atomic increment + TTL set via pipeline
const results = await redis.multi().incr(key).pttl(key).exec();

// multi().exec() returns [[err, value], ...] tuples — check each command
const [incrErr, count] = results[0];
const [pttlErr, pttl] = results[1];

if (incrErr || pttlErr) {
// Pipeline command failed — fall back gracefully
return fallback(req, res, next);
}

// Set TTL on first request (when key was just created with INCR)
if (pttl === -1) {
await redis.pexpire(key, windowMs);
}

const resetAt = Date.now() + (pttl > 0 ? pttl : windowMs);

// Set rate-limit headers
res.set('X-RateLimit-Limit', String(max));
res.set('X-RateLimit-Remaining', String(Math.max(0, max - count)));
res.set('X-RateLimit-Reset', String(Math.ceil(resetAt / 1000)));

if (count > max) {
const retryAfter = Math.ceil((pttl > 0 ? pttl : windowMs) / 1000);
res.set('Retry-After', String(retryAfter));
return res.status(429).json({ error: 'Too many requests, please try again later' });
}

next();
} catch {
// Redis error — fall back to in-memory
return fallback(req, res, next);
}
};

middleware.destroy = () => fallback.destroy();

return middleware;
}
63 changes: 40 additions & 23 deletions src/api/routes/community.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,18 @@
import { getConfig } from '../../modules/config.js';
import { computeLevel } from '../../modules/reputation.js';
import { REPUTATION_DEFAULTS } from '../../modules/reputationDefaults.js';
import { rateLimit } from '../middleware/rateLimit.js';
import { cacheGetOrSet, TTL } from '../../utils/cache.js';
import { redisRateLimit } from '../middleware/redisRateLimit.js';

const router = Router();

/** Aggressive rate limiter for public endpoints: 30 req/min per IP */
const communityRateLimit = rateLimit({ windowMs: 60 * 1000, max: 30 });
const communityRateLimit = redisRateLimit({
windowMs: 60 * 1000,
max: 30,
keyPrefix: 'rl:community',
});
router.use(communityRateLimit);

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
a database access
, but is not rate-limited.
This route handler performs
a database access
, but is not rate-limited.

/**
* Obtain the reputation configuration for a guild by merging guild-specific settings with defaults.
Expand Down Expand Up @@ -142,35 +147,47 @@
try {
const repConfig = getRepConfig(guildId);

const [countResult, membersResult] = await Promise.all([
pool.query(
`SELECT COUNT(*)::int AS total
FROM user_stats us
INNER JOIN reputation r ON r.guild_id = us.guild_id AND r.user_id = us.user_id
WHERE us.guild_id = $1 AND us.public_profile = TRUE`,
[guildId],
),
pool.query(
`SELECT us.user_id, r.xp, r.level
FROM user_stats us
INNER JOIN reputation r ON r.guild_id = us.guild_id AND r.user_id = us.user_id
WHERE us.guild_id = $1 AND us.public_profile = TRUE
ORDER BY r.xp DESC
LIMIT $2 OFFSET $3`,
[guildId, limit, offset],
),
]);
// Cache leaderboard DB results per guild+page (most expensive query)
const cacheKey = `leaderboard:${guildId}:${page}:${limit}`;
const dbResult = await cacheGetOrSet(
cacheKey,
async () => {
const [countResult, membersResult] = await Promise.all([
pool.query(
`SELECT COUNT(*)::int AS total
FROM user_stats us
INNER JOIN reputation r ON r.guild_id = us.guild_id AND r.user_id = us.user_id
WHERE us.guild_id = $1 AND us.public_profile = TRUE`,
[guildId],
),
pool.query(
`SELECT us.user_id, r.xp, r.level
FROM user_stats us
INNER JOIN reputation r ON r.guild_id = us.guild_id AND r.user_id = us.user_id
WHERE us.guild_id = $1 AND us.public_profile = TRUE
ORDER BY r.xp DESC
LIMIT $2 OFFSET $3`,
[guildId, limit, offset],
),
]);
return {
total: countResult.rows[0]?.total ?? 0,
rows: membersResult.rows,
};
},
TTL.LEADERBOARD,
);

const total = countResult.rows[0]?.total ?? 0;
const { total, rows: memberRows } = dbResult;
const { client } = req.app.locals;
const guild = client?.guilds?.cache?.get(guildId);

const leaderboardUserIds = membersResult.rows.map((r) => r.user_id);
const leaderboardUserIds = memberRows.map((r) => r.user_id);
const fetchedLeaderboardMembers = guild
? await guild.members.fetch({ user: leaderboardUserIds }).catch(() => new Map())
: new Map();

const members = membersResult.rows.map((row, idx) => {
const members = memberRows.map((row, idx) => {
const level = computeLevel(row.xp, repConfig.levelThresholds);
let username = row.user_id;
let displayName = row.user_id;
Expand Down
4 changes: 4 additions & 0 deletions src/api/routes/health.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import { Router } from 'express';
import { getRedisStats } from '../../redis.js';
import { isValidSecret } from '../middleware/auth.js';

/** Lazy-loaded queryLogs — optional diagnostic feature, not required for health */
Expand Down Expand Up @@ -170,6 +171,9 @@ router.get('/', async (req, res) => {
}
}

// Redis stats (authenticated only)
body.redis = getRedisStats();

// Error counts from logs table (optional — partial data on failure)
const queryLogs = await getQueryLogs();
if (queryLogs) {
Expand Down
6 changes: 3 additions & 3 deletions src/api/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import express from 'express';
import { error, info, warn } from '../logger.js';
import apiRouter from './index.js';
import { rateLimit } from './middleware/rateLimit.js';
import { redisRateLimit } from './middleware/redisRateLimit.js';
import { stopAuthCleanup } from './routes/auth.js';
import { swaggerSpec } from './swagger.js';
import { stopGuildCacheCleanup } from './utils/discordApi.js';
Expand All @@ -15,7 +15,7 @@
/** @type {import('node:http').Server | null} */
let server = null;

/** @type {ReturnType<typeof rateLimit> | null} */
/** @type {ReturnType<typeof redisRateLimit> | null} */
let rateLimiter = null;

/**
Expand Down Expand Up @@ -62,8 +62,8 @@
rateLimiter.destroy();
rateLimiter = null;
}
rateLimiter = rateLimit();
rateLimiter = redisRateLimit();
app.use(rateLimiter);

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
a database access
, but is not rate-limited.
This route handler performs
a database access
, but is not rate-limited.

// Raw OpenAPI spec (JSON) — public for Mintlify
app.get('/api/docs.json', (_req, res) => res.json(swaggerSpec));
Expand Down
61 changes: 14 additions & 47 deletions src/api/utils/redisClient.js
Original file line number Diff line number Diff line change
@@ -1,71 +1,38 @@
/**
* Redis Client
* Lazily-initialised ioredis client for session storage.
* If REDIS_URL is not configured, getRedisClient() returns null and all
* callers fall back to the in-memory implementation.
* Redis Client (API Compatibility Layer)
* Re-exports from the centralized src/redis.js module.
*
* Existing code that imports from this file continues to work without changes.
* New code should import directly from src/redis.js.
*
* @see https://github.com/VolvoxLLC/volvox-bot/issues/177
*/

import Redis from 'ioredis';
import { error as logError, warn } from '../../logger.js';

/** @type {Redis | null} */
let _client = null;
let _initialized = false;
import { _resetRedis, closeRedisClient, getRedis } from '../../redis.js';

/**
* Return the ioredis client, initialising it on first call.
* Return the ioredis client.
* Returns null if REDIS_URL is not configured.
*
* @returns {Redis | null}
* @returns {import('ioredis').Redis | null}
*/
export function getRedisClient() {
if (_initialized) return _client;
_initialized = true;

const redisUrl = process.env.REDIS_URL;
if (!redisUrl) return null;

try {
_client = new Redis(redisUrl, {
maxRetriesPerRequest: 3,
enableReadyCheck: true,
lazyConnect: false,
});

_client.on('error', (err) => {
logError('Redis connection error', { error: err.message });
});
} catch (err) {
logError('Failed to initialise Redis client', { error: err.message });
_client = null;
}

return _client;
return getRedis();
}

/**
* Gracefully close the Redis connection.
* Safe to call even if Redis was never configured.
*
* @returns {Promise<void>}
*/
export async function closeRedis() {
if (!_client) return;
try {
await _client.quit();
} catch (err) {
warn('Redis quit error during shutdown', { error: err.message });
} finally {
_client = null;
_initialized = false;
}
return closeRedisClient();
}

/**
* Reset internal state — for testing only.
* @internal
*/
export function _resetRedisClient() {
_client = null;
_initialized = false;
export async function _resetRedisClient() {
await _resetRedis();
}
Loading