Skip to content

Commit ee829aa

Browse files
committed
cache improvements, faster fail
1 parent ed2df53 commit ee829aa

File tree

2 files changed

+182
-74
lines changed

2 files changed

+182
-74
lines changed

apps/api/src/lib/website-utils.ts

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,21 @@
11
import { auth } from "@databuddy/auth";
2-
import { db, userPreferences, websites } from "@databuddy/db";
2+
import { db, eq, inArray, userPreferences, websites } from "@databuddy/db";
33
import { cacheable } from "@databuddy/redis";
44
import type { Website } from "@databuddy/shared/types/website";
5-
import { eq } from "drizzle-orm";
65
import {
76
getApiKeyFromHeader,
87
hasWebsiteScope,
98
isApiKeyPresent,
109
} from "./api-key";
1110

12-
export interface WebsiteContext {
11+
export type WebsiteContext = {
1312
user: unknown;
1413
session: unknown;
1514
website?: Website;
1615
timezone: string;
17-
}
16+
};
1817

19-
export interface WebsiteValidationResult {
18+
export type WebsiteValidationResult = {
2019
success: boolean;
2120
website?: Website;
2221
error?: string;
@@ -62,15 +61,28 @@ const getWebsiteDomain = cacheable(
6261

6362
const getCachedWebsiteDomain = cacheable(
6463
async (websiteIds: string[]): Promise<Record<string, string | null>> => {
65-
const results: Record<string, string | null> = {};
64+
if (websiteIds.length === 0) {
65+
return {};
66+
}
67+
68+
try {
69+
const websitesList = await db.query.websites.findMany({
70+
where: inArray(websites.id, websiteIds),
71+
columns: { id: true, domain: true },
72+
});
6673

67-
await Promise.all(
68-
websiteIds.map(async (id) => {
69-
results[id] = await getWebsiteDomain(id);
70-
})
71-
);
74+
const results: Record<string, string | null> = {};
75+
for (const id of websiteIds) {
76+
results[id] = null;
77+
}
78+
for (const website of websitesList) {
79+
results[website.id] = website.domain;
80+
}
7281

73-
return results;
82+
return results;
83+
} catch {
84+
return Object.fromEntries(websiteIds.map((id) => [id, null]));
85+
}
7486
},
7587
{
7688
expireInSec: 300,
@@ -98,8 +110,6 @@ const userPreferencesCache = cacheable(
98110
}
99111
);
100112

101-
// Removed unused cached session helper to satisfy linter rules
102-
103113
export async function getTimezone(
104114
request: Request,
105115
session: { user?: { id: string } } | null

packages/redis/cacheable.ts

Lines changed: 158 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ const activeRevalidations = new Map<string, Promise<void>>();
66

77
const stringifyRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.*Z$/;
88

9+
let redisAvailable = true;
10+
let lastRedisCheck = 0;
11+
const REDIS_CHECK_INTERVAL = 30_000;
12+
913
type CacheOptions = {
1014
expireInSec: number;
1115
prefix?: string;
@@ -14,6 +18,7 @@ type CacheOptions = {
1418
staleWhileRevalidate?: boolean;
1519
staleTime?: number;
1620
maxRetries?: number;
21+
timeout?: number;
1722
};
1823

1924
const defaultSerialize = (data: unknown): string => JSON.stringify(data);
@@ -25,6 +30,30 @@ const defaultDeserialize = (data: string): unknown =>
2530
return value;
2631
});
2732

33+
function withTimeout<T>(
34+
promise: Promise<T>,
35+
timeoutMs: number
36+
): Promise<T> {
37+
return Promise.race([
38+
promise,
39+
new Promise<T>((_, reject) =>
40+
setTimeout(() => reject(new Error("Redis timeout")), timeoutMs)
41+
),
42+
]);
43+
}
44+
45+
function shouldSkipRedis(): boolean {
46+
const now = Date.now();
47+
if (!redisAvailable && now - lastRedisCheck < REDIS_CHECK_INTERVAL) {
48+
return true;
49+
}
50+
if (!redisAvailable && now - lastRedisCheck >= REDIS_CHECK_INTERVAL) {
51+
redisAvailable = true;
52+
lastRedisCheck = now;
53+
}
54+
return false;
55+
}
56+
2857
export async function getCache<T>(
2958
key: string,
3059
options: CacheOptions | number,
@@ -36,59 +65,92 @@ export async function getCache<T>(
3665
deserialize = defaultDeserialize,
3766
staleWhileRevalidate = false,
3867
staleTime = 0,
39-
maxRetries = 3,
68+
maxRetries = 1,
69+
timeout = 300,
4070
} = typeof options === "number" ? { expireInSec: options } : options;
4171

72+
if (shouldSkipRedis()) {
73+
return fn();
74+
}
75+
4276
let retries = 0;
4377
while (retries < maxRetries) {
4478
try {
4579
const redis = getRedisCache();
46-
const hit = await redis.get(key);
80+
const hit = await withTimeout(redis.get(key), timeout);
81+
redisAvailable = true;
82+
lastRedisCheck = Date.now();
83+
4784
if (hit) {
4885
const data = deserialize(hit) as T;
4986

5087
if (staleWhileRevalidate) {
51-
const ttl = await redis.ttl(key);
52-
if (ttl < staleTime && !activeRevalidations.has(key)) {
53-
// Return stale data and revalidate in background
54-
const revalidationPromise = fn()
55-
.then(async (freshData: T) => {
56-
if (freshData !== undefined && freshData !== null) {
57-
const redis = getRedisCache();
58-
await redis.setex(key, expireInSec, serialize(freshData));
59-
}
60-
})
61-
.catch((error: unknown) => {
62-
logger.error(
63-
`Background revalidation failed for key ${key}:`,
64-
error
65-
);
66-
})
67-
.finally(() => {
68-
activeRevalidations.delete(key);
69-
});
70-
activeRevalidations.set(key, revalidationPromise);
88+
try {
89+
const ttl = await withTimeout(redis.ttl(key), timeout);
90+
if (ttl < staleTime && !activeRevalidations.has(key)) {
91+
const revalidationPromise = fn()
92+
.then(async (freshData: T) => {
93+
if (
94+
freshData !== undefined &&
95+
freshData !== null &&
96+
redisAvailable
97+
) {
98+
try {
99+
const redis = getRedisCache();
100+
await withTimeout(
101+
redis.setex(key, expireInSec, serialize(freshData)),
102+
timeout
103+
);
104+
} catch {
105+
// Ignore SET failure
106+
}
107+
}
108+
})
109+
.catch((error: unknown) => {
110+
logger.error(
111+
`Background revalidation failed for key ${key}:`,
112+
error
113+
);
114+
})
115+
.finally(() => {
116+
activeRevalidations.delete(key);
117+
});
118+
activeRevalidations.set(key, revalidationPromise);
119+
}
120+
} catch {
121+
// Ignore TTL check failure
71122
}
72123
}
73124

74125
return data;
75126
}
76127

77128
const data = await fn();
78-
if (data !== undefined && data !== null) {
79-
await redis.setex(key, expireInSec, serialize(data));
129+
if (
130+
data !== undefined &&
131+
data !== null &&
132+
redisAvailable &&
133+
!shouldSkipRedis()
134+
) {
135+
try {
136+
await withTimeout(
137+
redis.setex(key, expireInSec, serialize(data)),
138+
timeout
139+
);
140+
} catch {
141+
redisAvailable = false;
142+
lastRedisCheck = Date.now();
143+
}
80144
}
81145
return data;
82146
} catch (error: unknown) {
83147
retries += 1;
84-
if (retries === maxRetries) {
85-
logger.error(
86-
`Cache error for key ${key} after ${maxRetries} retries:`,
87-
error
88-
);
148+
if (retries >= maxRetries) {
149+
redisAvailable = false;
150+
lastRedisCheck = Date.now();
151+
logger.error(`Cache error for key ${key}, skipping Redis:`, error);
89152
return fn();
90153
}
91-
await new Promise((resolve) => setTimeout(resolve, 100 * retries)); // Exponential backoff
92154
}
93155
}
94156

@@ -106,7 +168,6 @@ export function cacheable<T extends (...args: any) => any>(
106168
deserialize = defaultDeserialize,
107169
staleWhileRevalidate = false,
108170
staleTime = 0,
109-
maxRetries = 3,
110171
} = typeof options === "number" ? { expireInSec: options } : options;
111172

112173
const cachePrefix = `cacheable:${prefix}`;
@@ -152,57 +213,94 @@ export function cacheable<T extends (...args: any) => any>(
152213
...args: Parameters<T>
153214
): Promise<Awaited<ReturnType<T>>> => {
154215
const key = getKey(...args);
155-
let retries = 0;
216+
const timeout = typeof options === "number" ? 50 : options.timeout ?? 50;
217+
const retries = typeof options === "number" ? 1 : options.maxRetries ?? 1;
156218

157-
while (retries < maxRetries) {
219+
if (shouldSkipRedis()) {
220+
return fn(...args);
221+
}
222+
223+
let attempt = 0;
224+
while (attempt < retries) {
158225
try {
159226
const redis = getRedisCache();
160-
const cached = await redis.get(key);
227+
const cached = await withTimeout(redis.get(key), timeout);
228+
redisAvailable = true;
229+
lastRedisCheck = Date.now();
230+
161231
if (cached) {
162232
const data = deserialize(cached) as Awaited<ReturnType<T>>;
163233

164234
if (staleWhileRevalidate) {
165-
const ttl = await redis.ttl(key);
166-
if (ttl < staleTime && !activeRevalidations.has(key)) {
167-
// Return stale data and revalidate in background
168-
const revalidationPromise = fn(...args)
169-
.then(async (freshData: Awaited<ReturnType<T>>) => {
170-
if (freshData !== undefined && freshData !== null) {
171-
const redis = getRedisCache();
172-
await redis.setex(key, expireInSec, serialize(freshData));
173-
}
174-
})
175-
.catch((error: unknown) => {
176-
logger.error(
177-
`Background revalidation failed for function ${fn.name}:`,
178-
error
179-
);
180-
})
181-
.finally(() => {
182-
activeRevalidations.delete(key);
183-
});
184-
activeRevalidations.set(key, revalidationPromise);
235+
try {
236+
const ttl = await withTimeout(redis.ttl(key), timeout);
237+
if (ttl < staleTime && !activeRevalidations.has(key)) {
238+
const revalidationPromise = fn(...args)
239+
.then(async (freshData: Awaited<ReturnType<T>>) => {
240+
if (
241+
freshData !== undefined &&
242+
freshData !== null &&
243+
redisAvailable
244+
) {
245+
try {
246+
const redis = getRedisCache();
247+
await withTimeout(
248+
redis.setex(key, expireInSec, serialize(freshData)),
249+
timeout
250+
);
251+
} catch {
252+
// Ignore SET failure
253+
}
254+
}
255+
})
256+
.catch((error: unknown) => {
257+
logger.error(
258+
`Background revalidation failed for function ${fn.name}:`,
259+
error
260+
);
261+
})
262+
.finally(() => {
263+
activeRevalidations.delete(key);
264+
});
265+
activeRevalidations.set(key, revalidationPromise);
266+
}
267+
} catch {
268+
// Ignore TTL check failure
185269
}
186270
}
187271

188272
return data;
189273
}
190274

191275
const result = await fn(...args);
192-
if (result !== undefined && result !== null) {
193-
await redis.setex(key, expireInSec, serialize(result));
276+
if (
277+
result !== undefined &&
278+
result !== null &&
279+
redisAvailable &&
280+
!shouldSkipRedis()
281+
) {
282+
try {
283+
await withTimeout(
284+
redis.setex(key, expireInSec, serialize(result)),
285+
timeout
286+
);
287+
} catch {
288+
redisAvailable = false;
289+
lastRedisCheck = Date.now();
290+
}
194291
}
195292
return result;
196293
} catch (error: unknown) {
197-
retries += 1;
198-
if (retries === maxRetries) {
294+
attempt += 1;
295+
if (attempt >= retries) {
296+
redisAvailable = false;
297+
lastRedisCheck = Date.now();
199298
logger.error(
200-
`Cache error for function ${fn.name} after ${maxRetries} retries:`,
299+
`Cache error for function ${fn.name}, skipping Redis:`,
201300
error
202301
);
203302
return fn(...args);
204303
}
205-
await new Promise((resolve) => setTimeout(resolve, 100 * retries)); // Exponential backoff
206304
}
207305
}
208306

0 commit comments

Comments
 (0)