Skip to content

Commit 8740c1a

Browse files
committed
Merge pull request #717
Closes #671
2 parents b776e08 + 5122385 commit 8740c1a

5 files changed

Lines changed: 93 additions & 4 deletions

File tree

docs/routes/analytics.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,15 @@ block when the first request completes (success or error).
262262
- Unparseable amounts or malformed DB rows default to zero rather than crashing
263263
the endpoint with a 500 error.
264264

265+
## Shared cache (optional)
266+
267+
Set `REDIS_URL` to enable the Redis-backed analytics cache across backend
268+
replicas. It uses the same `buildAnalyticsCacheKey` format and the configured
269+
`ANALYTICS_CACHE_TTL_MS` expiry. When Redis is unset, the route keeps using the
270+
in-process cache; Redis connection, serialization, and invalidation failures
271+
are treated as cache misses so they do not turn into analytics errors. Use a
272+
private Redis instance and TLS/credentials appropriate for the deployment.
273+
265274
---
266275

267276
## Edge cases intentionally out of scope

src/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,10 @@ export const EnvSchema = z
179179
.optional()
180180
.default(30_000),
181181

182+
// Optional shared cache for analytics responses. When unset, the route
183+
// uses its existing in-process cache.
184+
REDIS_URL: z.string().url().optional(),
185+
182186
// Session token lifetime in milliseconds (sliding expiry) — default 24 hours
183187
SESSION_TTL_MS: z.coerce
184188
.number()

src/routes/analytics.cache.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,11 @@ vi.mock("drizzle-orm", () => ({
9292
/* ── imports after mocks are registered ──────────────────────────────────── */
9393

9494
import { analyticsRouter, analyticsAggregationCache } from "./analytics.js";
95-
import { AnalyticsCache, buildAnalyticsCacheKey } from "../utils/analytics-cache.js";
95+
import {
96+
AnalyticsCache,
97+
RedisAnalyticsCache,
98+
buildAnalyticsCacheKey,
99+
} from "../utils/analytics-cache.js";
96100

97101
/* ── helpers ─────────────────────────────────────────────────────────────── */
98102

@@ -202,6 +206,32 @@ describe("AnalyticsCache unit", () => {
202206
});
203207
});
204208

209+
describe("RedisAnalyticsCache", () => {
210+
it("round-trips JSON values with a millisecond TTL", async () => {
211+
const values = new Map<string, string>();
212+
const redis = {
213+
get: vi.fn(async (key: string) => values.get(key) ?? null),
214+
set: vi.fn(async (key: string, value: string) => void values.set(key, value)),
215+
del: vi.fn(async (key: string) => void values.delete(key)),
216+
};
217+
const cache = new RedisAnalyticsCache<{ total: number }>(redis, 30_000);
218+
219+
await cache.set("k", { total: 7 });
220+
expect(await cache.get("k")).toEqual({ total: 7 });
221+
expect(redis.set).toHaveBeenCalledWith("k", '{"total":7}', "PX", 30_000);
222+
await cache.invalidate("k");
223+
expect(await cache.get("k")).toBeUndefined();
224+
});
225+
226+
it("turns Redis failures into misses", async () => {
227+
const cache = new RedisAnalyticsCache(
228+
{ get: vi.fn().mockRejectedValue(new Error("offline")), set: vi.fn(), del: vi.fn() },
229+
1000,
230+
);
231+
expect(await cache.get("k")).toBeUndefined();
232+
});
233+
});
234+
205235
/* ═══════════════════════════════════════════════════════════════════════════
206236
buildAnalyticsCacheKey unit tests
207237
═══════════════════════════════════════════════════════════════════════════ */

src/routes/analytics.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ import { asc, eq, and, gt, gte, lte, or, sql } from "drizzle-orm";
66
import { StarknetAddress } from "../utils/validation.js";
77
import { DEFAULT_TOKEN_DECIMALS } from "../utils/codec.js";
88
import { env } from "../config.js";
9-
import { AnalyticsCache, buildAnalyticsCacheKey } from "../utils/analytics-cache.js";
9+
import Redis from "ioredis";
10+
import {
11+
AnalyticsCache,
12+
RedisAnalyticsCache,
13+
buildAnalyticsCacheKey,
14+
} from "../utils/analytics-cache.js";
1015

1116
export const analyticsRouter = Router();
1217

@@ -19,6 +24,10 @@ export const ANALYTICS_ROLLUP_BATCH_SIZE = 500;
1924
export const analyticsAggregationCache = new AnalyticsCache(
2025
env.ANALYTICS_CACHE_TTL_MS ?? 30_000,
2126
);
27+
const redisAnalyticsCache = env.REDIS_URL
28+
? new RedisAnalyticsCache(new Redis(env.REDIS_URL), env.ANALYTICS_CACHE_TTL_MS ?? 30_000)
29+
: undefined;
30+
const analyticsCache = redisAnalyticsCache ?? analyticsAggregationCache;
2231

2332
const MONTH_NAMES = [
2433
"Jan",
@@ -242,7 +251,7 @@ analyticsRouter.get("/analytics/:user_address", async (req, res, next) => {
242251

243252
const cacheKey = buildAnalyticsCacheKey(userAddress, { year });
244253

245-
const cached = analyticsAggregationCache.get(cacheKey);
254+
const cached = await Promise.resolve(analyticsCache.get(cacheKey));
246255
if (cached) {
247256
res.set("Cache-Control", "private, max-age=60");
248257
const etag = computeETag(cached);
@@ -482,7 +491,7 @@ analyticsRouter.get("/analytics/:user_address", async (req, res, next) => {
482491
data: chartData,
483492
total: toDisplayNumber(totalRaw),
484493
};
485-
analyticsAggregationCache.set(cacheKey, responseBody);
494+
await Promise.resolve(analyticsCache.set(cacheKey, responseBody));
486495

487496
res.set("Cache-Control", "private, max-age=60");
488497
const etag = computeETag(responseBody);

src/utils/analytics-cache.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,40 @@ export class AnalyticsCache<T> {
138138
return this.store.size;
139139
}
140140
}
141+
142+
/** Redis implementation with cache failures isolated from analytics requests. */
143+
export class RedisAnalyticsCache<T> {
144+
constructor(
145+
private readonly redis: {
146+
get(key: string): Promise<string | null>;
147+
set(key: string, value: string, mode: "PX", ttl: number): Promise<unknown>;
148+
del(key: string): Promise<unknown>;
149+
},
150+
private readonly ttlMs: number,
151+
) {}
152+
153+
async get(key: string): Promise<T | undefined> {
154+
try {
155+
const value = await this.redis.get(key);
156+
return value === null ? undefined : (JSON.parse(value) as T);
157+
} catch {
158+
return undefined;
159+
}
160+
}
161+
162+
async set(key: string, value: T): Promise<void> {
163+
try {
164+
await this.redis.set(key, JSON.stringify(value), "PX", this.ttlMs);
165+
} catch {
166+
// Redis is optional; a failed write only causes the next request to miss.
167+
}
168+
}
169+
170+
async invalidate(key: string): Promise<void> {
171+
try {
172+
await this.redis.del(key);
173+
} catch {
174+
// Best-effort invalidation for an optional cache.
175+
}
176+
}
177+
}

0 commit comments

Comments
 (0)