-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathcache.ts
More file actions
287 lines (250 loc) · 8.16 KB
/
cache.ts
File metadata and controls
287 lines (250 loc) · 8.16 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
import { Cluster, ClusterOptions } from "ioredis";
import { logger } from "./logger";
import { db, folders } from "../db";
import { eq } from "drizzle-orm";
let client: Cluster | null = null;
export function getCacheClient(): Cluster | null {
if (!process.env.VALKEY_HOST) {
logger.warn("VALKEY_HOST not configured, caching disabled");
return null;
}
if (!client) {
try {
const clusterOptions: ClusterOptions = {
dnsLookup: (address, callback) => callback(null, address),
redisOptions: {
tls: process.env.NODE_ENV === "production" ? {} : undefined,
connectTimeout: 5000,
},
clusterRetryStrategy: (times) => {
if (times > 3) {
logger.error("Valkey connection failed after 3 retries");
return null;
}
return Math.min(times * 200, 2000);
},
};
client = new Cluster(
[
{
host: process.env.VALKEY_HOST,
port: parseInt(process.env.VALKEY_PORT || "6379"),
},
],
clusterOptions
);
client.on("error", (err) => {
logger.error("Valkey client error", { error: err.message }, err);
});
client.on("connect", () => {
logger.info("Connected to Valkey cluster");
});
} catch (error) {
logger.error(
"Failed to initialize Valkey client",
{
error: error instanceof Error ? error.message : String(error),
},
error instanceof Error ? error : undefined
);
client = null;
}
}
return client;
}
export async function closeCache(): Promise<void> {
if (client) {
await client.disconnect();
client = null;
logger.info("Valkey connection closed");
}
}
// Cache utilities
export async function getCache<T>(key: string): Promise<T | null> {
const cache = getCacheClient();
if (!cache) return null;
const startTime = Date.now();
try {
const data = await cache.get(key);
const duration = Date.now() - startTime;
const hit = data !== null;
// Log cache operation with metrics
logger.cacheOperation("get", key, hit, duration);
return data ? JSON.parse(data) : null;
} catch (error) {
logger.cacheError("get", key, error instanceof Error ? error : new Error(String(error)));
return null;
}
}
export async function setCache(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
const cache = getCacheClient();
if (!cache) return;
const startTime = Date.now();
try {
const serialized = JSON.stringify(value);
if (ttlSeconds) {
await cache.setex(key, ttlSeconds, serialized);
} else {
await cache.set(key, serialized);
}
const duration = Date.now() - startTime;
// Log cache operation with metrics
logger.cacheOperation("set", key, undefined, duration, ttlSeconds);
} catch (error) {
logger.cacheError("set", key, error instanceof Error ? error : new Error(String(error)));
}
}
export async function deleteCache(...keys: string[]): Promise<void> {
const cache = getCacheClient();
if (!cache || keys.length === 0) return;
const startTime = Date.now();
try {
// In cluster mode, keys may hash to different slots
// Use pipeline to delete individually (more efficient than separate awaits)
if (keys.length === 1) {
await cache.del(keys[0]);
} else {
const pipeline = cache.pipeline();
for (const key of keys) {
pipeline.del(key);
}
await pipeline.exec();
}
const duration = Date.now() - startTime;
// Log cache operation with metrics (use first key as representative)
logger.cacheOperation("delete", keys[0], undefined, duration, undefined, keys.length);
} catch (error) {
logger.cacheError(
"delete",
keys.join(", "),
error instanceof Error ? error : new Error(String(error))
);
}
}
export async function deleteCachePattern(pattern: string): Promise<void> {
const cache = getCacheClient();
if (!cache) return;
try {
const keys: string[] = [];
// In cluster mode, we need to scan all master nodes
const nodes = cache.nodes("master");
for (const node of nodes) {
let cursor = "0";
do {
// Scan each master node individually
const result = await node.scan(cursor, "MATCH", pattern, "COUNT", 100);
cursor = result[0];
keys.push(...result[1]);
} while (cursor !== "0");
}
if (keys.length > 0) {
// Delete in batches using pipeline (cluster mode compatible)
const batchSize = 100;
for (let i = 0; i < keys.length; i += batchSize) {
const batch = keys.slice(i, i + batchSize);
const pipeline = cache.pipeline();
for (const key of batch) {
pipeline.del(key);
}
await pipeline.exec();
}
logger.info(`Deleted cache keys matching pattern`, {
pattern,
keyCount: keys.length,
nodeCount: nodes.length,
});
}
} catch (error) {
logger.cacheError(
"pattern delete",
pattern,
error instanceof Error ? error : new Error(String(error))
);
}
}
/**
* Recursively get all ancestor folder IDs for a given folder
* @param folderId - The folder ID to start from
* @returns Array of ancestor folder IDs (from immediate parent to root)
*/
async function getAncestorFolderIds(folderId: string): Promise<string[]> {
const ancestorIds: string[] = [];
let currentFolderId: string | null = folderId;
// Traverse up the hierarchy until we reach a root folder (parentId is null)
while (currentFolderId) {
const folder: { parentId: string | null } | undefined = await db.query.folders.findFirst({
where: eq(folders.id, currentFolderId),
columns: {
parentId: true,
},
});
if (!folder || !folder.parentId) {
break;
}
ancestorIds.push(folder.parentId);
currentFolderId = folder.parentId;
}
return ancestorIds;
}
/**
* Invalidate note counts cache for a user and all ancestor folders
* This should be called whenever notes are created, updated, deleted, or their properties change
* @param userId - The user ID
* @param folderId - The folder ID where the note resides (null for root level notes)
*/
export async function invalidateNoteCounts(userId: string, folderId: string | null): Promise<void> {
const cache = getCacheClient();
if (!cache) return;
try {
const cacheKeys: string[] = [];
// Always invalidate user's global counts (matches CacheKeys.notesCounts pattern)
cacheKeys.push(`notes:${userId}:counts`);
// If note is in a folder, invalidate that folder and all ancestors
if (folderId) {
// Invalidate the immediate folder (matches counts.ts line 89 pattern)
cacheKeys.push(`notes:${userId}:folder:${folderId}:counts`);
// Get and invalidate all ancestor folders
const ancestorIds = await getAncestorFolderIds(folderId);
for (const ancestorId of ancestorIds) {
cacheKeys.push(`notes:${userId}:folder:${ancestorId}:counts`);
}
}
// Delete all cache keys using pipeline for cluster compatibility
if (cacheKeys.length > 0) {
await deleteCache(...cacheKeys);
logger.debug("Invalidated note counts cache", {
userId,
folderId: folderId || "root",
keysInvalidated: cacheKeys.length,
});
}
} catch (error) {
logger.error(
"Failed to invalidate note counts cache",
{
userId,
folderId: folderId || "root",
},
error instanceof Error ? error : new Error(String(error))
);
}
}
/**
* Invalidate note counts cache when a note moves between folders
* Invalidates both old and new folder hierarchies
* @param userId - The user ID
* @param oldFolderId - The previous folder ID (null for root)
* @param newFolderId - The new folder ID (null for root)
*/
export async function invalidateNoteCountsForMove(
userId: string,
oldFolderId: string | null,
newFolderId: string | null
): Promise<void> {
// Invalidate old folder hierarchy
await invalidateNoteCounts(userId, oldFolderId);
// Invalidate new folder hierarchy (if different from old)
if (oldFolderId !== newFolderId) {
await invalidateNoteCounts(userId, newFolderId);
}
}