@chidchanun/bcp 0.2.14 → 0.2.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +190 -12
- package/docs/README.md +45 -43
- package/docs/api-manifest.json +13 -5
- package/docs/api-reference.md +108 -3
- package/docs/cache-platform-v2.md +487 -0
- package/docs/docs-web-manifest.json +9 -5
- package/docs/platform-manifest.json +29 -4
- package/docs/plugin-module-platform.md +391 -0
- package/docs/releases/0.2.15.md +118 -0
- package/docs/releases/0.2.16.md +147 -0
- package/package.json +7 -2
- package/packages/bundler/src/client-boundary.ts +1 -0
- package/packages/cache/src/platform-v2.ts +1705 -0
- package/packages/client/src/cache.mjs +1554 -0
- package/packages/client/src/cache.ts +29 -0
- package/packages/client/src/plugins.mjs +811 -0
- package/packages/client/src/plugins.ts +26 -0
- package/packages/server/src/plugins.ts +1225 -0
|
@@ -0,0 +1,1705 @@
|
|
|
1
|
+
import {
|
|
2
|
+
randomUUID,
|
|
3
|
+
} from "node:crypto";
|
|
4
|
+
|
|
5
|
+
export interface CacheAdapterEntry<T = unknown> {
|
|
6
|
+
key: string;
|
|
7
|
+
value: T;
|
|
8
|
+
expiresAt: number | null;
|
|
9
|
+
tags: string[];
|
|
10
|
+
paths: string[];
|
|
11
|
+
createdAt: number;
|
|
12
|
+
updatedAt: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CacheAdapterSetOptions {
|
|
16
|
+
expiresAt?: number | null;
|
|
17
|
+
tags?: readonly string[];
|
|
18
|
+
paths?: readonly string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CacheAdapter {
|
|
22
|
+
get<T = unknown>(
|
|
23
|
+
key: string
|
|
24
|
+
): Promise<CacheAdapterEntry<T> | null>;
|
|
25
|
+
set<T = unknown>(
|
|
26
|
+
key: string,
|
|
27
|
+
value: T,
|
|
28
|
+
options?: CacheAdapterSetOptions
|
|
29
|
+
): Promise<void>;
|
|
30
|
+
delete(key: string): Promise<boolean>;
|
|
31
|
+
clear(): Promise<number>;
|
|
32
|
+
revalidateTag(tag: string): Promise<number>;
|
|
33
|
+
revalidatePath(pathname: string): Promise<number>;
|
|
34
|
+
entries?(): Promise<number>;
|
|
35
|
+
close?(): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CacheLockAdapter {
|
|
39
|
+
acquire(
|
|
40
|
+
key: string,
|
|
41
|
+
ownerId: string,
|
|
42
|
+
ttlMs: number
|
|
43
|
+
): Promise<boolean>;
|
|
44
|
+
extend?(
|
|
45
|
+
key: string,
|
|
46
|
+
ownerId: string,
|
|
47
|
+
ttlMs: number
|
|
48
|
+
): Promise<boolean>;
|
|
49
|
+
release(
|
|
50
|
+
key: string,
|
|
51
|
+
ownerId: string
|
|
52
|
+
): Promise<boolean>;
|
|
53
|
+
close?(): Promise<void>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface MemoryCacheAdapter
|
|
57
|
+
extends CacheAdapter {
|
|
58
|
+
readonly size: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface MemoryCacheLockAdapter
|
|
62
|
+
extends CacheLockAdapter {
|
|
63
|
+
readonly size: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface CacheStoreSetOptions {
|
|
67
|
+
ttlMs?: number | false;
|
|
68
|
+
tags?: readonly string[];
|
|
69
|
+
paths?: readonly string[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface CacheGetOrSetOptions
|
|
73
|
+
extends CacheStoreSetOptions {
|
|
74
|
+
lockTtlMs?: number;
|
|
75
|
+
waitTimeoutMs?: number;
|
|
76
|
+
pollIntervalMs?: number;
|
|
77
|
+
onLockTimeout?: "load" | "error";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface CacheStoreStats {
|
|
81
|
+
hits: number;
|
|
82
|
+
misses: number;
|
|
83
|
+
sets: number;
|
|
84
|
+
deletes: number;
|
|
85
|
+
invalidations: number;
|
|
86
|
+
loads: number;
|
|
87
|
+
loadErrors: number;
|
|
88
|
+
lockAcquired: number;
|
|
89
|
+
lockContentions: number;
|
|
90
|
+
lockTimeouts: number;
|
|
91
|
+
inFlight: number;
|
|
92
|
+
adapterEntries?: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type CacheStoreEvent =
|
|
96
|
+
| "hit"
|
|
97
|
+
| "miss"
|
|
98
|
+
| "set"
|
|
99
|
+
| "delete"
|
|
100
|
+
| "invalidate"
|
|
101
|
+
| "load"
|
|
102
|
+
| "load-error"
|
|
103
|
+
| "lock-acquired"
|
|
104
|
+
| "lock-contention"
|
|
105
|
+
| "lock-timeout";
|
|
106
|
+
|
|
107
|
+
export interface CacheMetricsSink {
|
|
108
|
+
record(
|
|
109
|
+
event: CacheStoreEvent,
|
|
110
|
+
value?: number
|
|
111
|
+
): void;
|
|
112
|
+
setInFlight?(value: number): void;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface CacheMetricsRegistryLike {
|
|
116
|
+
counter(
|
|
117
|
+
name: string,
|
|
118
|
+
options?: {
|
|
119
|
+
help?: string;
|
|
120
|
+
labelNames?: readonly string[];
|
|
121
|
+
}
|
|
122
|
+
): {
|
|
123
|
+
inc(
|
|
124
|
+
value?: number,
|
|
125
|
+
labels?: Readonly<Record<
|
|
126
|
+
string,
|
|
127
|
+
string | number | boolean
|
|
128
|
+
>>
|
|
129
|
+
): void;
|
|
130
|
+
};
|
|
131
|
+
gauge(
|
|
132
|
+
name: string,
|
|
133
|
+
options?: {
|
|
134
|
+
help?: string;
|
|
135
|
+
labelNames?: readonly string[];
|
|
136
|
+
}
|
|
137
|
+
): {
|
|
138
|
+
set(
|
|
139
|
+
value: number,
|
|
140
|
+
labels?: Readonly<Record<
|
|
141
|
+
string,
|
|
142
|
+
string | number | boolean
|
|
143
|
+
>>
|
|
144
|
+
): void;
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface CacheStoreOptions {
|
|
149
|
+
adapter?: CacheAdapter;
|
|
150
|
+
lock?: CacheLockAdapter;
|
|
151
|
+
now?: () => number;
|
|
152
|
+
idFactory?: () => string;
|
|
153
|
+
metrics?: CacheMetricsSink;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface CacheStore {
|
|
157
|
+
readonly adapter: CacheAdapter;
|
|
158
|
+
readonly lock: CacheLockAdapter | undefined;
|
|
159
|
+
get<T = unknown>(key: string): Promise<T | undefined>;
|
|
160
|
+
set<T = unknown>(
|
|
161
|
+
key: string,
|
|
162
|
+
value: T,
|
|
163
|
+
options?: CacheStoreSetOptions
|
|
164
|
+
): Promise<void>;
|
|
165
|
+
delete(key: string): Promise<boolean>;
|
|
166
|
+
clear(): Promise<number>;
|
|
167
|
+
revalidateTag(tag: string): Promise<number>;
|
|
168
|
+
revalidatePath(pathname: string): Promise<number>;
|
|
169
|
+
getOrSet<T>(
|
|
170
|
+
key: string,
|
|
171
|
+
loader: () => T | Promise<T>,
|
|
172
|
+
options?: CacheGetOrSetOptions
|
|
173
|
+
): Promise<T>;
|
|
174
|
+
stats(): Promise<CacheStoreStats>;
|
|
175
|
+
close(): Promise<void>;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface RedisCacheCommandClient {
|
|
179
|
+
sendCommand(
|
|
180
|
+
command: string[]
|
|
181
|
+
): Promise<unknown>;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface RedisCacheAdapterOptions {
|
|
185
|
+
client: RedisCacheCommandClient;
|
|
186
|
+
namespace?: string;
|
|
187
|
+
now?: () => number;
|
|
188
|
+
serialize?: (value: unknown) => string;
|
|
189
|
+
deserialize?: (value: string) => unknown;
|
|
190
|
+
close?: () => Promise<void>;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface RedisCacheAdapter
|
|
194
|
+
extends CacheAdapter {
|
|
195
|
+
readonly namespace: string;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface RedisCacheLockAdapterOptions {
|
|
199
|
+
client: RedisCacheCommandClient;
|
|
200
|
+
namespace?: string;
|
|
201
|
+
close?: () => Promise<void>;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface RedisCacheLockAdapter
|
|
205
|
+
extends CacheLockAdapter {
|
|
206
|
+
readonly namespace: string;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
interface MemoryCacheRecord
|
|
210
|
+
extends CacheAdapterEntry {
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
interface MemoryLockRecord {
|
|
214
|
+
ownerId: string;
|
|
215
|
+
expiresAt: number;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
interface RedisStoredRecord {
|
|
219
|
+
key: string;
|
|
220
|
+
value: string;
|
|
221
|
+
expiresAt: number | null;
|
|
222
|
+
tags: string[];
|
|
223
|
+
paths: string[];
|
|
224
|
+
tagIndexKeys: string[];
|
|
225
|
+
pathIndexPairs: Array<{
|
|
226
|
+
path: string;
|
|
227
|
+
key: string;
|
|
228
|
+
}>;
|
|
229
|
+
createdAt: number;
|
|
230
|
+
updatedAt: number;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const DEFAULT_LOCK_TTL_MS = 30_000;
|
|
234
|
+
const DEFAULT_WAIT_TIMEOUT_MS = 5_000;
|
|
235
|
+
const DEFAULT_POLL_INTERVAL_MS = 25;
|
|
236
|
+
|
|
237
|
+
export function createMemoryCacheAdapter(
|
|
238
|
+
options: {
|
|
239
|
+
now?: () => number;
|
|
240
|
+
} = {}
|
|
241
|
+
): MemoryCacheAdapter {
|
|
242
|
+
const now =
|
|
243
|
+
options.now ?? Date.now;
|
|
244
|
+
const records =
|
|
245
|
+
new Map<string, MemoryCacheRecord>();
|
|
246
|
+
|
|
247
|
+
const adapter:
|
|
248
|
+
MemoryCacheAdapter = {
|
|
249
|
+
get size() {
|
|
250
|
+
prune();
|
|
251
|
+
return records.size;
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
async get<T>(
|
|
255
|
+
rawKey: string
|
|
256
|
+
): Promise<CacheAdapterEntry<T> | null> {
|
|
257
|
+
const key = normalizeKey(rawKey);
|
|
258
|
+
const entry = records.get(key);
|
|
259
|
+
if (!entry) {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
if (isExpired(entry, now())) {
|
|
263
|
+
records.delete(key);
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
return cloneEntry(entry) as CacheAdapterEntry<T>;
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
async set<T>(
|
|
270
|
+
rawKey: string,
|
|
271
|
+
value: T,
|
|
272
|
+
setOptions: CacheAdapterSetOptions = {}
|
|
273
|
+
): Promise<void> {
|
|
274
|
+
const key = normalizeKey(rawKey);
|
|
275
|
+
const timestamp = now();
|
|
276
|
+
assertFiniteTimestamp(timestamp, "cache timestamp");
|
|
277
|
+
const current = records.get(key);
|
|
278
|
+
const expiresAt = normalizeExpiresAt(
|
|
279
|
+
setOptions.expiresAt
|
|
280
|
+
);
|
|
281
|
+
records.set(key, {
|
|
282
|
+
key,
|
|
283
|
+
value,
|
|
284
|
+
expiresAt,
|
|
285
|
+
tags: normalizeTags(setOptions.tags),
|
|
286
|
+
paths: normalizePaths(setOptions.paths),
|
|
287
|
+
createdAt: current?.createdAt ?? timestamp,
|
|
288
|
+
updatedAt: timestamp,
|
|
289
|
+
});
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
async delete(rawKey) {
|
|
293
|
+
return records.delete(
|
|
294
|
+
normalizeKey(rawKey)
|
|
295
|
+
);
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
async clear() {
|
|
299
|
+
const count = records.size;
|
|
300
|
+
records.clear();
|
|
301
|
+
return count;
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
async revalidateTag(rawTag) {
|
|
305
|
+
const tag = normalizeTag(rawTag);
|
|
306
|
+
let removed = 0;
|
|
307
|
+
for (const [key, record] of records) {
|
|
308
|
+
if (record.tags.includes(tag)) {
|
|
309
|
+
records.delete(key);
|
|
310
|
+
removed++;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return removed;
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
async revalidatePath(rawPath) {
|
|
317
|
+
const pathname = normalizePath(rawPath);
|
|
318
|
+
let removed = 0;
|
|
319
|
+
for (const [key, record] of records) {
|
|
320
|
+
if (
|
|
321
|
+
record.paths.some(path =>
|
|
322
|
+
pathMatches(path, pathname)
|
|
323
|
+
)
|
|
324
|
+
) {
|
|
325
|
+
records.delete(key);
|
|
326
|
+
removed++;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return removed;
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
async entries() {
|
|
333
|
+
prune();
|
|
334
|
+
return records.size;
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
return adapter;
|
|
339
|
+
|
|
340
|
+
function prune(): void {
|
|
341
|
+
const timestamp = now();
|
|
342
|
+
for (const [key, record] of records) {
|
|
343
|
+
if (isExpired(record, timestamp)) {
|
|
344
|
+
records.delete(key);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function createMemoryCacheLockAdapter(
|
|
351
|
+
options: {
|
|
352
|
+
now?: () => number;
|
|
353
|
+
} = {}
|
|
354
|
+
): MemoryCacheLockAdapter {
|
|
355
|
+
const now =
|
|
356
|
+
options.now ?? Date.now;
|
|
357
|
+
const locks =
|
|
358
|
+
new Map<string, MemoryLockRecord>();
|
|
359
|
+
|
|
360
|
+
const adapter:
|
|
361
|
+
MemoryCacheLockAdapter = {
|
|
362
|
+
get size() {
|
|
363
|
+
prune();
|
|
364
|
+
return locks.size;
|
|
365
|
+
},
|
|
366
|
+
|
|
367
|
+
async acquire(rawKey, rawOwnerId, ttlMs) {
|
|
368
|
+
const key = normalizeKey(rawKey);
|
|
369
|
+
const ownerId = normalizeOwnerId(rawOwnerId);
|
|
370
|
+
const ttl = positiveInteger(ttlMs, "lock ttlMs");
|
|
371
|
+
const timestamp = now();
|
|
372
|
+
const current = locks.get(key);
|
|
373
|
+
if (
|
|
374
|
+
current &&
|
|
375
|
+
current.expiresAt > timestamp
|
|
376
|
+
) {
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
locks.set(key, {
|
|
380
|
+
ownerId,
|
|
381
|
+
expiresAt: timestamp + ttl,
|
|
382
|
+
});
|
|
383
|
+
return true;
|
|
384
|
+
},
|
|
385
|
+
|
|
386
|
+
async extend(rawKey, rawOwnerId, ttlMs) {
|
|
387
|
+
const key = normalizeKey(rawKey);
|
|
388
|
+
const ownerId = normalizeOwnerId(rawOwnerId);
|
|
389
|
+
const ttl = positiveInteger(ttlMs, "lock ttlMs");
|
|
390
|
+
const timestamp = now();
|
|
391
|
+
const current = locks.get(key);
|
|
392
|
+
if (
|
|
393
|
+
!current ||
|
|
394
|
+
current.ownerId !== ownerId ||
|
|
395
|
+
current.expiresAt <= timestamp
|
|
396
|
+
) {
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
current.expiresAt = timestamp + ttl;
|
|
400
|
+
return true;
|
|
401
|
+
},
|
|
402
|
+
|
|
403
|
+
async release(rawKey, rawOwnerId) {
|
|
404
|
+
const key = normalizeKey(rawKey);
|
|
405
|
+
const ownerId = normalizeOwnerId(rawOwnerId);
|
|
406
|
+
const current = locks.get(key);
|
|
407
|
+
if (!current || current.ownerId !== ownerId) {
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
locks.delete(key);
|
|
411
|
+
return true;
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
return adapter;
|
|
416
|
+
|
|
417
|
+
function prune(): void {
|
|
418
|
+
const timestamp = now();
|
|
419
|
+
for (const [key, record] of locks) {
|
|
420
|
+
if (record.expiresAt <= timestamp) {
|
|
421
|
+
locks.delete(key);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function createCacheStore(
|
|
428
|
+
options: CacheStoreOptions = {}
|
|
429
|
+
): CacheStore {
|
|
430
|
+
const adapter =
|
|
431
|
+
options.adapter ??
|
|
432
|
+
createMemoryCacheAdapter({
|
|
433
|
+
now: options.now,
|
|
434
|
+
});
|
|
435
|
+
const lock = options.lock;
|
|
436
|
+
const now = options.now ?? Date.now;
|
|
437
|
+
const idFactory =
|
|
438
|
+
options.idFactory ?? randomUUID;
|
|
439
|
+
const metrics = options.metrics;
|
|
440
|
+
const inFlight =
|
|
441
|
+
new Map<string, Promise<unknown>>();
|
|
442
|
+
const counters = {
|
|
443
|
+
hits: 0,
|
|
444
|
+
misses: 0,
|
|
445
|
+
sets: 0,
|
|
446
|
+
deletes: 0,
|
|
447
|
+
invalidations: 0,
|
|
448
|
+
loads: 0,
|
|
449
|
+
loadErrors: 0,
|
|
450
|
+
lockAcquired: 0,
|
|
451
|
+
lockContentions: 0,
|
|
452
|
+
lockTimeouts: 0,
|
|
453
|
+
};
|
|
454
|
+
let closed = false;
|
|
455
|
+
|
|
456
|
+
const store:
|
|
457
|
+
CacheStore = {
|
|
458
|
+
adapter,
|
|
459
|
+
lock,
|
|
460
|
+
|
|
461
|
+
async get<T>(
|
|
462
|
+
rawKey: string
|
|
463
|
+
): Promise<T | undefined> {
|
|
464
|
+
assertOpen();
|
|
465
|
+
const key = normalizeKey(rawKey);
|
|
466
|
+
const entry = await adapter.get<T>(key);
|
|
467
|
+
if (!entry) {
|
|
468
|
+
count("misses", "miss");
|
|
469
|
+
return undefined;
|
|
470
|
+
}
|
|
471
|
+
if (
|
|
472
|
+
entry.expiresAt !== null &&
|
|
473
|
+
entry.expiresAt <= now()
|
|
474
|
+
) {
|
|
475
|
+
await adapter.delete(key);
|
|
476
|
+
count("misses", "miss");
|
|
477
|
+
return undefined;
|
|
478
|
+
}
|
|
479
|
+
count("hits", "hit");
|
|
480
|
+
return entry.value;
|
|
481
|
+
},
|
|
482
|
+
|
|
483
|
+
async set<T>(
|
|
484
|
+
rawKey: string,
|
|
485
|
+
value: T,
|
|
486
|
+
setOptions: CacheStoreSetOptions = {}
|
|
487
|
+
): Promise<void> {
|
|
488
|
+
assertOpen();
|
|
489
|
+
const key = normalizeKey(rawKey);
|
|
490
|
+
if (setOptions.ttlMs === 0) {
|
|
491
|
+
await store.delete(key);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
const expiresAt =
|
|
495
|
+
setOptions.ttlMs === undefined ||
|
|
496
|
+
setOptions.ttlMs === false
|
|
497
|
+
? null
|
|
498
|
+
: now() + positiveInteger(
|
|
499
|
+
setOptions.ttlMs,
|
|
500
|
+
"cache ttlMs"
|
|
501
|
+
);
|
|
502
|
+
await adapter.set(
|
|
503
|
+
key,
|
|
504
|
+
value,
|
|
505
|
+
{
|
|
506
|
+
expiresAt,
|
|
507
|
+
tags: normalizeTags(setOptions.tags),
|
|
508
|
+
paths: normalizePaths(setOptions.paths),
|
|
509
|
+
}
|
|
510
|
+
);
|
|
511
|
+
count("sets", "set");
|
|
512
|
+
},
|
|
513
|
+
|
|
514
|
+
async delete(rawKey) {
|
|
515
|
+
assertOpen();
|
|
516
|
+
const removed =
|
|
517
|
+
await adapter.delete(
|
|
518
|
+
normalizeKey(rawKey)
|
|
519
|
+
);
|
|
520
|
+
if (removed) {
|
|
521
|
+
count("deletes", "delete");
|
|
522
|
+
}
|
|
523
|
+
return removed;
|
|
524
|
+
},
|
|
525
|
+
|
|
526
|
+
async clear() {
|
|
527
|
+
assertOpen();
|
|
528
|
+
const removed = await adapter.clear();
|
|
529
|
+
if (removed > 0) {
|
|
530
|
+
counters.invalidations += removed;
|
|
531
|
+
metrics?.record("invalidate", removed);
|
|
532
|
+
}
|
|
533
|
+
return removed;
|
|
534
|
+
},
|
|
535
|
+
|
|
536
|
+
async revalidateTag(rawTag) {
|
|
537
|
+
assertOpen();
|
|
538
|
+
const removed =
|
|
539
|
+
await adapter.revalidateTag(
|
|
540
|
+
normalizeTag(rawTag)
|
|
541
|
+
);
|
|
542
|
+
if (removed > 0) {
|
|
543
|
+
counters.invalidations += removed;
|
|
544
|
+
metrics?.record("invalidate", removed);
|
|
545
|
+
}
|
|
546
|
+
return removed;
|
|
547
|
+
},
|
|
548
|
+
|
|
549
|
+
async revalidatePath(rawPath) {
|
|
550
|
+
assertOpen();
|
|
551
|
+
const removed =
|
|
552
|
+
await adapter.revalidatePath(
|
|
553
|
+
normalizePath(rawPath)
|
|
554
|
+
);
|
|
555
|
+
if (removed > 0) {
|
|
556
|
+
counters.invalidations += removed;
|
|
557
|
+
metrics?.record("invalidate", removed);
|
|
558
|
+
}
|
|
559
|
+
return removed;
|
|
560
|
+
},
|
|
561
|
+
|
|
562
|
+
async getOrSet<T>(
|
|
563
|
+
rawKey: string,
|
|
564
|
+
loader: () => T | Promise<T>,
|
|
565
|
+
loadOptions: CacheGetOrSetOptions = {}
|
|
566
|
+
): Promise<T> {
|
|
567
|
+
assertOpen();
|
|
568
|
+
if (typeof loader !== "function") {
|
|
569
|
+
throw new TypeError(
|
|
570
|
+
"BCP Cache: loader must be a function."
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
const key = normalizeKey(rawKey);
|
|
574
|
+
const cached = await store.get<T>(key);
|
|
575
|
+
if (cached !== undefined) {
|
|
576
|
+
return cached;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const pending = inFlight.get(key);
|
|
580
|
+
if (pending) {
|
|
581
|
+
return await pending as T;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const promise =
|
|
585
|
+
loadWithStampedeProtection<T>(
|
|
586
|
+
key,
|
|
587
|
+
loader,
|
|
588
|
+
loadOptions
|
|
589
|
+
);
|
|
590
|
+
inFlight.set(key, promise);
|
|
591
|
+
metrics?.setInFlight?.(inFlight.size);
|
|
592
|
+
try {
|
|
593
|
+
return await promise;
|
|
594
|
+
} finally {
|
|
595
|
+
inFlight.delete(key);
|
|
596
|
+
metrics?.setInFlight?.(inFlight.size);
|
|
597
|
+
}
|
|
598
|
+
},
|
|
599
|
+
|
|
600
|
+
async stats() {
|
|
601
|
+
assertOpen();
|
|
602
|
+
return {
|
|
603
|
+
...counters,
|
|
604
|
+
inFlight: inFlight.size,
|
|
605
|
+
adapterEntries:
|
|
606
|
+
adapter.entries
|
|
607
|
+
? await adapter.entries()
|
|
608
|
+
: undefined,
|
|
609
|
+
};
|
|
610
|
+
},
|
|
611
|
+
|
|
612
|
+
async close() {
|
|
613
|
+
if (closed) {
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
closed = true;
|
|
617
|
+
inFlight.clear();
|
|
618
|
+
metrics?.setInFlight?.(0);
|
|
619
|
+
const lockClose =
|
|
620
|
+
lock &&
|
|
621
|
+
!Object.is(lock, adapter)
|
|
622
|
+
? lock.close?.()
|
|
623
|
+
: undefined;
|
|
624
|
+
await Promise.all([
|
|
625
|
+
adapter.close?.(),
|
|
626
|
+
lockClose,
|
|
627
|
+
]);
|
|
628
|
+
},
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
return store;
|
|
632
|
+
|
|
633
|
+
async function loadWithStampedeProtection<T>(
|
|
634
|
+
key: string,
|
|
635
|
+
loader: () => T | Promise<T>,
|
|
636
|
+
loadOptions: CacheGetOrSetOptions
|
|
637
|
+
): Promise<T> {
|
|
638
|
+
if (!lock) {
|
|
639
|
+
return executeLoader(
|
|
640
|
+
key,
|
|
641
|
+
loader,
|
|
642
|
+
loadOptions
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const lockTtlMs =
|
|
647
|
+
positiveInteger(
|
|
648
|
+
loadOptions.lockTtlMs ??
|
|
649
|
+
DEFAULT_LOCK_TTL_MS,
|
|
650
|
+
"lock ttlMs"
|
|
651
|
+
);
|
|
652
|
+
const waitTimeoutMs =
|
|
653
|
+
nonNegativeInteger(
|
|
654
|
+
loadOptions.waitTimeoutMs ??
|
|
655
|
+
DEFAULT_WAIT_TIMEOUT_MS,
|
|
656
|
+
"lock waitTimeoutMs"
|
|
657
|
+
);
|
|
658
|
+
const pollIntervalMs =
|
|
659
|
+
positiveInteger(
|
|
660
|
+
loadOptions.pollIntervalMs ??
|
|
661
|
+
DEFAULT_POLL_INTERVAL_MS,
|
|
662
|
+
"lock pollIntervalMs"
|
|
663
|
+
);
|
|
664
|
+
const ownerId =
|
|
665
|
+
normalizeOwnerId(idFactory());
|
|
666
|
+
const lockKey =
|
|
667
|
+
`cache-load:${key}`;
|
|
668
|
+
|
|
669
|
+
if (
|
|
670
|
+
await lock.acquire(
|
|
671
|
+
lockKey,
|
|
672
|
+
ownerId,
|
|
673
|
+
lockTtlMs
|
|
674
|
+
)
|
|
675
|
+
) {
|
|
676
|
+
count(
|
|
677
|
+
"lockAcquired",
|
|
678
|
+
"lock-acquired"
|
|
679
|
+
);
|
|
680
|
+
return executeUnderLock(
|
|
681
|
+
key,
|
|
682
|
+
ownerId,
|
|
683
|
+
lockKey,
|
|
684
|
+
lockTtlMs,
|
|
685
|
+
loader,
|
|
686
|
+
loadOptions
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
count(
|
|
691
|
+
"lockContentions",
|
|
692
|
+
"lock-contention"
|
|
693
|
+
);
|
|
694
|
+
const waitStartedAt = Date.now();
|
|
695
|
+
while (
|
|
696
|
+
Date.now() - waitStartedAt <
|
|
697
|
+
waitTimeoutMs
|
|
698
|
+
) {
|
|
699
|
+
await sleep(pollIntervalMs);
|
|
700
|
+
const cached = await store.get<T>(key);
|
|
701
|
+
if (cached !== undefined) {
|
|
702
|
+
return cached;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
count("lockTimeouts", "lock-timeout");
|
|
707
|
+
if (loadOptions.onLockTimeout === "error") {
|
|
708
|
+
throw new Error(
|
|
709
|
+
`BCP Cache: timed out waiting for distributed cache lock for "${key}".`
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (
|
|
714
|
+
await lock.acquire(
|
|
715
|
+
lockKey,
|
|
716
|
+
ownerId,
|
|
717
|
+
lockTtlMs
|
|
718
|
+
)
|
|
719
|
+
) {
|
|
720
|
+
count(
|
|
721
|
+
"lockAcquired",
|
|
722
|
+
"lock-acquired"
|
|
723
|
+
);
|
|
724
|
+
return executeUnderLock(
|
|
725
|
+
key,
|
|
726
|
+
ownerId,
|
|
727
|
+
lockKey,
|
|
728
|
+
lockTtlMs,
|
|
729
|
+
loader,
|
|
730
|
+
loadOptions
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
return executeLoader(
|
|
735
|
+
key,
|
|
736
|
+
loader,
|
|
737
|
+
loadOptions
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
async function executeUnderLock<T>(
|
|
742
|
+
key: string,
|
|
743
|
+
ownerId: string,
|
|
744
|
+
lockKey: string,
|
|
745
|
+
lockTtlMs: number,
|
|
746
|
+
loader: () => T | Promise<T>,
|
|
747
|
+
loadOptions: CacheGetOrSetOptions
|
|
748
|
+
): Promise<T> {
|
|
749
|
+
let timer:
|
|
750
|
+
ReturnType<typeof setTimeout> |
|
|
751
|
+
undefined;
|
|
752
|
+
let active = true;
|
|
753
|
+
|
|
754
|
+
if (lock?.extend) {
|
|
755
|
+
const intervalMs =
|
|
756
|
+
Math.max(
|
|
757
|
+
1,
|
|
758
|
+
Math.floor(lockTtlMs / 3)
|
|
759
|
+
);
|
|
760
|
+
const heartbeat = async () => {
|
|
761
|
+
if (!active) {
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
try {
|
|
765
|
+
await lock.extend?.(
|
|
766
|
+
lockKey,
|
|
767
|
+
ownerId,
|
|
768
|
+
lockTtlMs
|
|
769
|
+
);
|
|
770
|
+
} catch {
|
|
771
|
+
// A failed renewal is allowed to expire naturally.
|
|
772
|
+
} finally {
|
|
773
|
+
if (active) {
|
|
774
|
+
timer = setTimeout(
|
|
775
|
+
heartbeat,
|
|
776
|
+
intervalMs
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
timer = setTimeout(
|
|
782
|
+
heartbeat,
|
|
783
|
+
intervalMs
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
try {
|
|
788
|
+
const cached = await store.get<T>(key);
|
|
789
|
+
if (cached !== undefined) {
|
|
790
|
+
return cached;
|
|
791
|
+
}
|
|
792
|
+
return await executeLoader(
|
|
793
|
+
key,
|
|
794
|
+
loader,
|
|
795
|
+
loadOptions
|
|
796
|
+
);
|
|
797
|
+
} finally {
|
|
798
|
+
active = false;
|
|
799
|
+
if (timer) {
|
|
800
|
+
clearTimeout(timer);
|
|
801
|
+
}
|
|
802
|
+
await lock?.release(
|
|
803
|
+
lockKey,
|
|
804
|
+
ownerId
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async function executeLoader<T>(
|
|
810
|
+
key: string,
|
|
811
|
+
loader: () => T | Promise<T>,
|
|
812
|
+
loadOptions: CacheGetOrSetOptions
|
|
813
|
+
): Promise<T> {
|
|
814
|
+
count("loads", "load");
|
|
815
|
+
try {
|
|
816
|
+
const value = await loader();
|
|
817
|
+
await store.set(
|
|
818
|
+
key,
|
|
819
|
+
value,
|
|
820
|
+
loadOptions
|
|
821
|
+
);
|
|
822
|
+
return value;
|
|
823
|
+
} catch (error) {
|
|
824
|
+
count(
|
|
825
|
+
"loadErrors",
|
|
826
|
+
"load-error"
|
|
827
|
+
);
|
|
828
|
+
throw error;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function count(
|
|
833
|
+
key: keyof typeof counters,
|
|
834
|
+
event: CacheStoreEvent,
|
|
835
|
+
value = 1
|
|
836
|
+
): void {
|
|
837
|
+
counters[key] += value;
|
|
838
|
+
metrics?.record(event, value);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function assertOpen(): void {
|
|
842
|
+
if (closed) {
|
|
843
|
+
throw new Error(
|
|
844
|
+
"BCP Cache: cache store is closed."
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
export function createCacheMetrics(
|
|
851
|
+
registry: CacheMetricsRegistryLike,
|
|
852
|
+
options: {
|
|
853
|
+
prefix?: string;
|
|
854
|
+
} = {}
|
|
855
|
+
): CacheMetricsSink {
|
|
856
|
+
const prefix =
|
|
857
|
+
normalizeMetricPrefix(
|
|
858
|
+
options.prefix ?? "bcp_cache"
|
|
859
|
+
);
|
|
860
|
+
const operations =
|
|
861
|
+
registry.counter(
|
|
862
|
+
`${prefix}_operations_total`,
|
|
863
|
+
{
|
|
864
|
+
help:
|
|
865
|
+
"BCP Cache operations by event.",
|
|
866
|
+
labelNames: [
|
|
867
|
+
"event",
|
|
868
|
+
],
|
|
869
|
+
}
|
|
870
|
+
);
|
|
871
|
+
const inFlight =
|
|
872
|
+
registry.gauge(
|
|
873
|
+
`${prefix}_in_flight`,
|
|
874
|
+
{
|
|
875
|
+
help:
|
|
876
|
+
"BCP Cache local in-flight loaders.",
|
|
877
|
+
}
|
|
878
|
+
);
|
|
879
|
+
|
|
880
|
+
return {
|
|
881
|
+
record(event, value = 1) {
|
|
882
|
+
operations.inc(
|
|
883
|
+
value,
|
|
884
|
+
{
|
|
885
|
+
event,
|
|
886
|
+
}
|
|
887
|
+
);
|
|
888
|
+
},
|
|
889
|
+
setInFlight(value) {
|
|
890
|
+
inFlight.set(value);
|
|
891
|
+
},
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
export function createRedisCacheAdapter(
|
|
896
|
+
options: RedisCacheAdapterOptions
|
|
897
|
+
): RedisCacheAdapter {
|
|
898
|
+
assertRedisClient(options.client);
|
|
899
|
+
const namespace =
|
|
900
|
+
normalizeNamespace(
|
|
901
|
+
options.namespace ??
|
|
902
|
+
"bcp:{cache}"
|
|
903
|
+
);
|
|
904
|
+
const now = options.now ?? Date.now;
|
|
905
|
+
const serialize =
|
|
906
|
+
options.serialize ?? JSON.stringify;
|
|
907
|
+
const deserialize =
|
|
908
|
+
options.deserialize ?? JSON.parse;
|
|
909
|
+
const client = options.client;
|
|
910
|
+
|
|
911
|
+
const adapter:
|
|
912
|
+
RedisCacheAdapter = {
|
|
913
|
+
namespace,
|
|
914
|
+
|
|
915
|
+
async get<T>(
|
|
916
|
+
rawKey: string
|
|
917
|
+
): Promise<CacheAdapterEntry<T> | null> {
|
|
918
|
+
const key = normalizeKey(rawKey);
|
|
919
|
+
const entryKey = redisEntryKey(
|
|
920
|
+
namespace,
|
|
921
|
+
key
|
|
922
|
+
);
|
|
923
|
+
const raw =
|
|
924
|
+
redisString(
|
|
925
|
+
await client.sendCommand([
|
|
926
|
+
"GET",
|
|
927
|
+
entryKey,
|
|
928
|
+
])
|
|
929
|
+
);
|
|
930
|
+
if (raw === null) {
|
|
931
|
+
await client.sendCommand([
|
|
932
|
+
"SREM",
|
|
933
|
+
redisAllKeysKey(namespace),
|
|
934
|
+
entryKey,
|
|
935
|
+
]);
|
|
936
|
+
return null;
|
|
937
|
+
}
|
|
938
|
+
const stored =
|
|
939
|
+
parseStoredRecord(raw);
|
|
940
|
+
if (
|
|
941
|
+
stored.expiresAt !== null &&
|
|
942
|
+
stored.expiresAt <= now()
|
|
943
|
+
) {
|
|
944
|
+
await deleteStoredRecord(
|
|
945
|
+
stored,
|
|
946
|
+
entryKey
|
|
947
|
+
);
|
|
948
|
+
return null;
|
|
949
|
+
}
|
|
950
|
+
return {
|
|
951
|
+
key: stored.key,
|
|
952
|
+
value:
|
|
953
|
+
deserialize(stored.value) as T,
|
|
954
|
+
expiresAt: stored.expiresAt,
|
|
955
|
+
tags: [...stored.tags],
|
|
956
|
+
paths: [...stored.paths],
|
|
957
|
+
createdAt: stored.createdAt,
|
|
958
|
+
updatedAt: stored.updatedAt,
|
|
959
|
+
};
|
|
960
|
+
},
|
|
961
|
+
|
|
962
|
+
async set<T>(
|
|
963
|
+
rawKey: string,
|
|
964
|
+
value: T,
|
|
965
|
+
setOptions: CacheAdapterSetOptions = {}
|
|
966
|
+
): Promise<void> {
|
|
967
|
+
const key = normalizeKey(rawKey);
|
|
968
|
+
const entryKey = redisEntryKey(
|
|
969
|
+
namespace,
|
|
970
|
+
key
|
|
971
|
+
);
|
|
972
|
+
const timestamp = now();
|
|
973
|
+
const tags = normalizeTags(setOptions.tags);
|
|
974
|
+
const paths = normalizePaths(setOptions.paths);
|
|
975
|
+
const current = await adapter.get(key);
|
|
976
|
+
const stored:
|
|
977
|
+
RedisStoredRecord = {
|
|
978
|
+
key,
|
|
979
|
+
value: serializeValue(
|
|
980
|
+
value,
|
|
981
|
+
serialize
|
|
982
|
+
),
|
|
983
|
+
expiresAt:
|
|
984
|
+
normalizeExpiresAt(
|
|
985
|
+
setOptions.expiresAt
|
|
986
|
+
),
|
|
987
|
+
tags,
|
|
988
|
+
paths,
|
|
989
|
+
tagIndexKeys:
|
|
990
|
+
tags.map(tag =>
|
|
991
|
+
redisTagKey(
|
|
992
|
+
namespace,
|
|
993
|
+
tag
|
|
994
|
+
)
|
|
995
|
+
),
|
|
996
|
+
pathIndexPairs:
|
|
997
|
+
paths.map(pathname => ({
|
|
998
|
+
path: pathname,
|
|
999
|
+
key: redisPathKey(
|
|
1000
|
+
namespace,
|
|
1001
|
+
pathname
|
|
1002
|
+
),
|
|
1003
|
+
})),
|
|
1004
|
+
createdAt:
|
|
1005
|
+
current?.createdAt ??
|
|
1006
|
+
timestamp,
|
|
1007
|
+
updatedAt: timestamp,
|
|
1008
|
+
};
|
|
1009
|
+
const ttlMs =
|
|
1010
|
+
stored.expiresAt === null
|
|
1011
|
+
? 0
|
|
1012
|
+
: Math.max(
|
|
1013
|
+
1,
|
|
1014
|
+
stored.expiresAt - timestamp
|
|
1015
|
+
);
|
|
1016
|
+
await evalRedis(
|
|
1017
|
+
client,
|
|
1018
|
+
REDIS_SET_SCRIPT,
|
|
1019
|
+
[
|
|
1020
|
+
entryKey,
|
|
1021
|
+
redisAllKeysKey(namespace),
|
|
1022
|
+
redisPathsIndexKey(namespace),
|
|
1023
|
+
],
|
|
1024
|
+
[
|
|
1025
|
+
JSON.stringify(stored),
|
|
1026
|
+
String(ttlMs),
|
|
1027
|
+
]
|
|
1028
|
+
);
|
|
1029
|
+
},
|
|
1030
|
+
|
|
1031
|
+
async delete(rawKey) {
|
|
1032
|
+
const key = normalizeKey(rawKey);
|
|
1033
|
+
const entryKey = redisEntryKey(
|
|
1034
|
+
namespace,
|
|
1035
|
+
key
|
|
1036
|
+
);
|
|
1037
|
+
const result = await evalRedis(
|
|
1038
|
+
client,
|
|
1039
|
+
REDIS_DELETE_SCRIPT,
|
|
1040
|
+
[
|
|
1041
|
+
entryKey,
|
|
1042
|
+
redisAllKeysKey(namespace),
|
|
1043
|
+
redisPathsIndexKey(namespace),
|
|
1044
|
+
],
|
|
1045
|
+
[]
|
|
1046
|
+
);
|
|
1047
|
+
return Number(result) > 0;
|
|
1048
|
+
},
|
|
1049
|
+
|
|
1050
|
+
async clear() {
|
|
1051
|
+
const entryKeys =
|
|
1052
|
+
redisStringArray(
|
|
1053
|
+
await client.sendCommand([
|
|
1054
|
+
"SMEMBERS",
|
|
1055
|
+
redisAllKeysKey(namespace),
|
|
1056
|
+
])
|
|
1057
|
+
);
|
|
1058
|
+
let removed = 0;
|
|
1059
|
+
for (const entryKey of entryKeys) {
|
|
1060
|
+
const result = await evalRedis(
|
|
1061
|
+
client,
|
|
1062
|
+
REDIS_DELETE_SCRIPT,
|
|
1063
|
+
[
|
|
1064
|
+
entryKey,
|
|
1065
|
+
redisAllKeysKey(namespace),
|
|
1066
|
+
redisPathsIndexKey(namespace),
|
|
1067
|
+
],
|
|
1068
|
+
[]
|
|
1069
|
+
);
|
|
1070
|
+
removed += Number(result) > 0
|
|
1071
|
+
? 1
|
|
1072
|
+
: 0;
|
|
1073
|
+
}
|
|
1074
|
+
await client.sendCommand([
|
|
1075
|
+
"DEL",
|
|
1076
|
+
redisAllKeysKey(namespace),
|
|
1077
|
+
redisPathsIndexKey(namespace),
|
|
1078
|
+
]);
|
|
1079
|
+
return removed;
|
|
1080
|
+
},
|
|
1081
|
+
|
|
1082
|
+
async revalidateTag(rawTag) {
|
|
1083
|
+
const tag = normalizeTag(rawTag);
|
|
1084
|
+
const tagKey = redisTagKey(
|
|
1085
|
+
namespace,
|
|
1086
|
+
tag
|
|
1087
|
+
);
|
|
1088
|
+
const result = await evalRedis(
|
|
1089
|
+
client,
|
|
1090
|
+
REDIS_INVALIDATE_SET_SCRIPT,
|
|
1091
|
+
[
|
|
1092
|
+
tagKey,
|
|
1093
|
+
redisAllKeysKey(namespace),
|
|
1094
|
+
redisPathsIndexKey(namespace),
|
|
1095
|
+
],
|
|
1096
|
+
[]
|
|
1097
|
+
);
|
|
1098
|
+
return Number(result) || 0;
|
|
1099
|
+
},
|
|
1100
|
+
|
|
1101
|
+
async revalidatePath(rawPath) {
|
|
1102
|
+
const pathname = normalizePath(rawPath);
|
|
1103
|
+
const registeredPaths =
|
|
1104
|
+
redisStringArray(
|
|
1105
|
+
await client.sendCommand([
|
|
1106
|
+
"SMEMBERS",
|
|
1107
|
+
redisPathsIndexKey(namespace),
|
|
1108
|
+
])
|
|
1109
|
+
);
|
|
1110
|
+
const matches = registeredPaths
|
|
1111
|
+
.filter(path =>
|
|
1112
|
+
pathMatches(path, pathname)
|
|
1113
|
+
)
|
|
1114
|
+
.map(path => ({
|
|
1115
|
+
path,
|
|
1116
|
+
key: redisPathKey(
|
|
1117
|
+
namespace,
|
|
1118
|
+
path
|
|
1119
|
+
),
|
|
1120
|
+
}));
|
|
1121
|
+
if (matches.length === 0) {
|
|
1122
|
+
return 0;
|
|
1123
|
+
}
|
|
1124
|
+
const result = await evalRedis(
|
|
1125
|
+
client,
|
|
1126
|
+
REDIS_INVALIDATE_PATHS_SCRIPT,
|
|
1127
|
+
[
|
|
1128
|
+
redisAllKeysKey(namespace),
|
|
1129
|
+
redisPathsIndexKey(namespace),
|
|
1130
|
+
],
|
|
1131
|
+
[
|
|
1132
|
+
JSON.stringify(matches),
|
|
1133
|
+
]
|
|
1134
|
+
);
|
|
1135
|
+
return Number(result) || 0;
|
|
1136
|
+
},
|
|
1137
|
+
|
|
1138
|
+
async entries() {
|
|
1139
|
+
return Number(
|
|
1140
|
+
await client.sendCommand([
|
|
1141
|
+
"SCARD",
|
|
1142
|
+
redisAllKeysKey(namespace),
|
|
1143
|
+
])
|
|
1144
|
+
) || 0;
|
|
1145
|
+
},
|
|
1146
|
+
|
|
1147
|
+
async close() {
|
|
1148
|
+
await options.close?.();
|
|
1149
|
+
},
|
|
1150
|
+
};
|
|
1151
|
+
|
|
1152
|
+
return adapter;
|
|
1153
|
+
|
|
1154
|
+
async function deleteStoredRecord(
|
|
1155
|
+
_stored: RedisStoredRecord,
|
|
1156
|
+
entryKey: string
|
|
1157
|
+
): Promise<void> {
|
|
1158
|
+
await evalRedis(
|
|
1159
|
+
client,
|
|
1160
|
+
REDIS_DELETE_SCRIPT,
|
|
1161
|
+
[
|
|
1162
|
+
entryKey,
|
|
1163
|
+
redisAllKeysKey(namespace),
|
|
1164
|
+
redisPathsIndexKey(namespace),
|
|
1165
|
+
],
|
|
1166
|
+
[]
|
|
1167
|
+
);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
export function createRedisCacheLockAdapter(
|
|
1172
|
+
options: RedisCacheLockAdapterOptions
|
|
1173
|
+
): RedisCacheLockAdapter {
|
|
1174
|
+
assertRedisClient(options.client);
|
|
1175
|
+
const namespace =
|
|
1176
|
+
normalizeNamespace(
|
|
1177
|
+
options.namespace ??
|
|
1178
|
+
"bcp:{cache}"
|
|
1179
|
+
);
|
|
1180
|
+
const client = options.client;
|
|
1181
|
+
|
|
1182
|
+
return {
|
|
1183
|
+
namespace,
|
|
1184
|
+
|
|
1185
|
+
async acquire(rawKey, rawOwnerId, ttlMs) {
|
|
1186
|
+
const key = redisLockKey(
|
|
1187
|
+
namespace,
|
|
1188
|
+
normalizeKey(rawKey)
|
|
1189
|
+
);
|
|
1190
|
+
const ownerId = normalizeOwnerId(rawOwnerId);
|
|
1191
|
+
const ttl = positiveInteger(
|
|
1192
|
+
ttlMs,
|
|
1193
|
+
"lock ttlMs"
|
|
1194
|
+
);
|
|
1195
|
+
const result =
|
|
1196
|
+
redisString(
|
|
1197
|
+
await client.sendCommand([
|
|
1198
|
+
"SET",
|
|
1199
|
+
key,
|
|
1200
|
+
ownerId,
|
|
1201
|
+
"NX",
|
|
1202
|
+
"PX",
|
|
1203
|
+
String(ttl),
|
|
1204
|
+
])
|
|
1205
|
+
);
|
|
1206
|
+
return result === "OK";
|
|
1207
|
+
},
|
|
1208
|
+
|
|
1209
|
+
async extend(rawKey, rawOwnerId, ttlMs) {
|
|
1210
|
+
const result = await evalRedis(
|
|
1211
|
+
client,
|
|
1212
|
+
REDIS_LOCK_EXTEND_SCRIPT,
|
|
1213
|
+
[
|
|
1214
|
+
redisLockKey(
|
|
1215
|
+
namespace,
|
|
1216
|
+
normalizeKey(rawKey)
|
|
1217
|
+
),
|
|
1218
|
+
],
|
|
1219
|
+
[
|
|
1220
|
+
normalizeOwnerId(rawOwnerId),
|
|
1221
|
+
String(
|
|
1222
|
+
positiveInteger(
|
|
1223
|
+
ttlMs,
|
|
1224
|
+
"lock ttlMs"
|
|
1225
|
+
)
|
|
1226
|
+
),
|
|
1227
|
+
]
|
|
1228
|
+
);
|
|
1229
|
+
return Number(result) > 0;
|
|
1230
|
+
},
|
|
1231
|
+
|
|
1232
|
+
async release(rawKey, rawOwnerId) {
|
|
1233
|
+
const result = await evalRedis(
|
|
1234
|
+
client,
|
|
1235
|
+
REDIS_LOCK_RELEASE_SCRIPT,
|
|
1236
|
+
[
|
|
1237
|
+
redisLockKey(
|
|
1238
|
+
namespace,
|
|
1239
|
+
normalizeKey(rawKey)
|
|
1240
|
+
),
|
|
1241
|
+
],
|
|
1242
|
+
[
|
|
1243
|
+
normalizeOwnerId(rawOwnerId),
|
|
1244
|
+
]
|
|
1245
|
+
);
|
|
1246
|
+
return Number(result) > 0;
|
|
1247
|
+
},
|
|
1248
|
+
|
|
1249
|
+
async close() {
|
|
1250
|
+
await options.close?.();
|
|
1251
|
+
},
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
function cloneEntry<T>(
|
|
1256
|
+
entry: CacheAdapterEntry<T>
|
|
1257
|
+
): CacheAdapterEntry<T> {
|
|
1258
|
+
return {
|
|
1259
|
+
...entry,
|
|
1260
|
+
tags: [...entry.tags],
|
|
1261
|
+
paths: [...entry.paths],
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function isExpired(
|
|
1266
|
+
entry: CacheAdapterEntry,
|
|
1267
|
+
now: number
|
|
1268
|
+
): boolean {
|
|
1269
|
+
return (
|
|
1270
|
+
entry.expiresAt !== null &&
|
|
1271
|
+
entry.expiresAt <= now
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
function normalizeExpiresAt(
|
|
1276
|
+
value: number | null | undefined
|
|
1277
|
+
): number | null {
|
|
1278
|
+
if (value === undefined || value === null) {
|
|
1279
|
+
return null;
|
|
1280
|
+
}
|
|
1281
|
+
assertFiniteTimestamp(value, "cache expiresAt");
|
|
1282
|
+
return value;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function normalizeKey(value: string): string {
|
|
1286
|
+
const normalized = String(value ?? "").trim();
|
|
1287
|
+
if (!normalized) {
|
|
1288
|
+
throw new TypeError(
|
|
1289
|
+
"BCP Cache: key must be a non-empty string."
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1292
|
+
return normalized;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function normalizeOwnerId(value: string): string {
|
|
1296
|
+
const normalized = String(value ?? "").trim();
|
|
1297
|
+
if (!normalized) {
|
|
1298
|
+
throw new TypeError(
|
|
1299
|
+
"BCP Cache: lock ownerId must be a non-empty string."
|
|
1300
|
+
);
|
|
1301
|
+
}
|
|
1302
|
+
return normalized;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
function normalizeTag(value: string): string {
|
|
1306
|
+
const normalized = String(value ?? "").trim();
|
|
1307
|
+
if (!normalized) {
|
|
1308
|
+
throw new TypeError(
|
|
1309
|
+
"BCP Cache: cache tags cannot be empty."
|
|
1310
|
+
);
|
|
1311
|
+
}
|
|
1312
|
+
return normalized;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function normalizeTags(
|
|
1316
|
+
values: readonly string[] | undefined
|
|
1317
|
+
): string[] {
|
|
1318
|
+
return Array.from(
|
|
1319
|
+
new Set(
|
|
1320
|
+
(values ?? []).map(normalizeTag)
|
|
1321
|
+
)
|
|
1322
|
+
).sort();
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
function normalizePath(value: string): string {
|
|
1326
|
+
let pathname = String(value ?? "").trim();
|
|
1327
|
+
if (!pathname) {
|
|
1328
|
+
return "/";
|
|
1329
|
+
}
|
|
1330
|
+
try {
|
|
1331
|
+
if (
|
|
1332
|
+
pathname.startsWith("http://") ||
|
|
1333
|
+
pathname.startsWith("https://")
|
|
1334
|
+
) {
|
|
1335
|
+
pathname = new URL(pathname).pathname;
|
|
1336
|
+
}
|
|
1337
|
+
} catch {
|
|
1338
|
+
// Fall through to pathname normalization.
|
|
1339
|
+
}
|
|
1340
|
+
pathname = pathname
|
|
1341
|
+
.split("?")[0]
|
|
1342
|
+
.split("#")[0];
|
|
1343
|
+
if (!pathname.startsWith("/")) {
|
|
1344
|
+
pathname = `/${pathname}`;
|
|
1345
|
+
}
|
|
1346
|
+
if (
|
|
1347
|
+
pathname.length > 1 &&
|
|
1348
|
+
pathname.endsWith("/")
|
|
1349
|
+
) {
|
|
1350
|
+
pathname = pathname.slice(0, -1);
|
|
1351
|
+
}
|
|
1352
|
+
return pathname;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function normalizePaths(
|
|
1356
|
+
values: readonly string[] | undefined
|
|
1357
|
+
): string[] {
|
|
1358
|
+
return Array.from(
|
|
1359
|
+
new Set(
|
|
1360
|
+
(values ?? []).map(normalizePath)
|
|
1361
|
+
)
|
|
1362
|
+
).sort();
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
function pathMatches(
|
|
1366
|
+
cachedPath: string,
|
|
1367
|
+
invalidatedPath: string
|
|
1368
|
+
): boolean {
|
|
1369
|
+
if (invalidatedPath === "/") {
|
|
1370
|
+
return true;
|
|
1371
|
+
}
|
|
1372
|
+
return (
|
|
1373
|
+
cachedPath === invalidatedPath ||
|
|
1374
|
+
cachedPath.startsWith(
|
|
1375
|
+
`${invalidatedPath}/`
|
|
1376
|
+
)
|
|
1377
|
+
);
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function positiveInteger(
|
|
1381
|
+
value: number,
|
|
1382
|
+
field: string
|
|
1383
|
+
): number {
|
|
1384
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
1385
|
+
throw new TypeError(
|
|
1386
|
+
`BCP Cache: ${field} must be a positive integer.`
|
|
1387
|
+
);
|
|
1388
|
+
}
|
|
1389
|
+
return value;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
function nonNegativeInteger(
|
|
1393
|
+
value: number,
|
|
1394
|
+
field: string
|
|
1395
|
+
): number {
|
|
1396
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
1397
|
+
throw new TypeError(
|
|
1398
|
+
`BCP Cache: ${field} must be a non-negative integer.`
|
|
1399
|
+
);
|
|
1400
|
+
}
|
|
1401
|
+
return value;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
function assertFiniteTimestamp(
|
|
1405
|
+
value: number,
|
|
1406
|
+
field: string
|
|
1407
|
+
): void {
|
|
1408
|
+
if (!Number.isFinite(value)) {
|
|
1409
|
+
throw new TypeError(
|
|
1410
|
+
`BCP Cache: ${field} must be a finite number.`
|
|
1411
|
+
);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
function sleep(ms: number): Promise<void> {
|
|
1416
|
+
return new Promise(resolve => {
|
|
1417
|
+
setTimeout(resolve, ms);
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
function normalizeMetricPrefix(
|
|
1422
|
+
value: string
|
|
1423
|
+
): string {
|
|
1424
|
+
const normalized = value.trim();
|
|
1425
|
+
if (!/^[a-zA-Z_:][a-zA-Z0-9_:]*$/.test(normalized)) {
|
|
1426
|
+
throw new TypeError(
|
|
1427
|
+
"BCP Cache: metrics prefix contains unsupported characters."
|
|
1428
|
+
);
|
|
1429
|
+
}
|
|
1430
|
+
return normalized;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
function normalizeNamespace(value: string): string {
|
|
1434
|
+
const normalized = value.trim();
|
|
1435
|
+
if (!normalized) {
|
|
1436
|
+
throw new TypeError(
|
|
1437
|
+
"BCP Cache: Redis namespace must be a non-empty string."
|
|
1438
|
+
);
|
|
1439
|
+
}
|
|
1440
|
+
return normalized.replace(/:+$/g, "");
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
function encodeRedisPart(value: string): string {
|
|
1444
|
+
return encodeURIComponent(value);
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
function redisEntryKey(
|
|
1448
|
+
namespace: string,
|
|
1449
|
+
key: string
|
|
1450
|
+
): string {
|
|
1451
|
+
return `${namespace}:entry:${encodeRedisPart(key)}`;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
function redisTagKey(
|
|
1455
|
+
namespace: string,
|
|
1456
|
+
tag: string
|
|
1457
|
+
): string {
|
|
1458
|
+
return `${namespace}:tag:${encodeRedisPart(tag)}`;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function redisPathKey(
|
|
1462
|
+
namespace: string,
|
|
1463
|
+
pathname: string
|
|
1464
|
+
): string {
|
|
1465
|
+
return `${namespace}:path:${encodeRedisPart(pathname)}`;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
function redisAllKeysKey(namespace: string): string {
|
|
1469
|
+
return `${namespace}:entries`;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
function redisPathsIndexKey(namespace: string): string {
|
|
1473
|
+
return `${namespace}:paths`;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
function redisLockKey(
|
|
1477
|
+
namespace: string,
|
|
1478
|
+
key: string
|
|
1479
|
+
): string {
|
|
1480
|
+
return `${namespace}:lock:${encodeRedisPart(key)}`;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
function serializeValue(
|
|
1484
|
+
value: unknown,
|
|
1485
|
+
serialize: (value: unknown) => string
|
|
1486
|
+
): string {
|
|
1487
|
+
const result = serialize(value);
|
|
1488
|
+
if (typeof result !== "string") {
|
|
1489
|
+
throw new TypeError(
|
|
1490
|
+
"BCP Cache: Redis serializer must return a string."
|
|
1491
|
+
);
|
|
1492
|
+
}
|
|
1493
|
+
return result;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
function parseStoredRecord(
|
|
1497
|
+
value: string
|
|
1498
|
+
): RedisStoredRecord {
|
|
1499
|
+
const parsed = JSON.parse(value) as
|
|
1500
|
+
Partial<RedisStoredRecord>;
|
|
1501
|
+
if (
|
|
1502
|
+
!parsed ||
|
|
1503
|
+
typeof parsed.key !== "string" ||
|
|
1504
|
+
typeof parsed.value !== "string" ||
|
|
1505
|
+
!Array.isArray(parsed.tags) ||
|
|
1506
|
+
!Array.isArray(parsed.paths) ||
|
|
1507
|
+
!Array.isArray(parsed.tagIndexKeys) ||
|
|
1508
|
+
!Array.isArray(parsed.pathIndexPairs) ||
|
|
1509
|
+
typeof parsed.createdAt !== "number" ||
|
|
1510
|
+
typeof parsed.updatedAt !== "number"
|
|
1511
|
+
) {
|
|
1512
|
+
throw new Error(
|
|
1513
|
+
"BCP Cache: invalid Redis cache record."
|
|
1514
|
+
);
|
|
1515
|
+
}
|
|
1516
|
+
return parsed as RedisStoredRecord;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
function assertRedisClient(
|
|
1520
|
+
client: RedisCacheCommandClient
|
|
1521
|
+
): void {
|
|
1522
|
+
if (
|
|
1523
|
+
!client ||
|
|
1524
|
+
typeof client.sendCommand !== "function"
|
|
1525
|
+
) {
|
|
1526
|
+
throw new TypeError(
|
|
1527
|
+
"BCP Cache: Redis adapter requires a sendCommand client."
|
|
1528
|
+
);
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
function redisString(value: unknown): string | null {
|
|
1533
|
+
if (value === null || value === undefined) {
|
|
1534
|
+
return null;
|
|
1535
|
+
}
|
|
1536
|
+
if (typeof value === "string") {
|
|
1537
|
+
return value;
|
|
1538
|
+
}
|
|
1539
|
+
if (value instanceof Uint8Array) {
|
|
1540
|
+
return new TextDecoder().decode(value);
|
|
1541
|
+
}
|
|
1542
|
+
return String(value);
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
function redisStringArray(value: unknown): string[] {
|
|
1546
|
+
if (!Array.isArray(value)) {
|
|
1547
|
+
return [];
|
|
1548
|
+
}
|
|
1549
|
+
return value
|
|
1550
|
+
.map(redisString)
|
|
1551
|
+
.filter((item): item is string =>
|
|
1552
|
+
item !== null
|
|
1553
|
+
);
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
async function evalRedis(
|
|
1557
|
+
client: RedisCacheCommandClient,
|
|
1558
|
+
script: string,
|
|
1559
|
+
keys: string[],
|
|
1560
|
+
args: string[]
|
|
1561
|
+
): Promise<unknown> {
|
|
1562
|
+
return client.sendCommand([
|
|
1563
|
+
"EVAL",
|
|
1564
|
+
script,
|
|
1565
|
+
String(keys.length),
|
|
1566
|
+
...keys,
|
|
1567
|
+
...args,
|
|
1568
|
+
]);
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
const REDIS_SET_SCRIPT = `
|
|
1572
|
+
local old = redis.call('GET', KEYS[1])
|
|
1573
|
+
if old then
|
|
1574
|
+
local previous = cjson.decode(old)
|
|
1575
|
+
for _, tagKey in ipairs(previous.tagIndexKeys or {}) do
|
|
1576
|
+
redis.call('SREM', tagKey, KEYS[1])
|
|
1577
|
+
if redis.call('SCARD', tagKey) == 0 then redis.call('DEL', tagKey) end
|
|
1578
|
+
end
|
|
1579
|
+
for _, pair in ipairs(previous.pathIndexPairs or {}) do
|
|
1580
|
+
redis.call('SREM', pair.key, KEYS[1])
|
|
1581
|
+
if redis.call('SCARD', pair.key) == 0 then
|
|
1582
|
+
redis.call('DEL', pair.key)
|
|
1583
|
+
redis.call('SREM', KEYS[3], pair.path)
|
|
1584
|
+
end
|
|
1585
|
+
end
|
|
1586
|
+
end
|
|
1587
|
+
local record = cjson.decode(ARGV[1])
|
|
1588
|
+
if tonumber(ARGV[2]) > 0 then
|
|
1589
|
+
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
|
|
1590
|
+
else
|
|
1591
|
+
redis.call('SET', KEYS[1], ARGV[1])
|
|
1592
|
+
end
|
|
1593
|
+
redis.call('SADD', KEYS[2], KEYS[1])
|
|
1594
|
+
for _, tagKey in ipairs(record.tagIndexKeys or {}) do
|
|
1595
|
+
redis.call('SADD', tagKey, KEYS[1])
|
|
1596
|
+
end
|
|
1597
|
+
for _, pair in ipairs(record.pathIndexPairs or {}) do
|
|
1598
|
+
redis.call('SADD', pair.key, KEYS[1])
|
|
1599
|
+
redis.call('SADD', KEYS[3], pair.path)
|
|
1600
|
+
end
|
|
1601
|
+
return 1
|
|
1602
|
+
`;
|
|
1603
|
+
|
|
1604
|
+
const REDIS_DELETE_SCRIPT = `
|
|
1605
|
+
local raw = redis.call('GET', KEYS[1])
|
|
1606
|
+
if not raw then
|
|
1607
|
+
redis.call('SREM', KEYS[2], KEYS[1])
|
|
1608
|
+
return 0
|
|
1609
|
+
end
|
|
1610
|
+
local record = cjson.decode(raw)
|
|
1611
|
+
for _, tagKey in ipairs(record.tagIndexKeys or {}) do
|
|
1612
|
+
redis.call('SREM', tagKey, KEYS[1])
|
|
1613
|
+
if redis.call('SCARD', tagKey) == 0 then redis.call('DEL', tagKey) end
|
|
1614
|
+
end
|
|
1615
|
+
for _, pair in ipairs(record.pathIndexPairs or {}) do
|
|
1616
|
+
redis.call('SREM', pair.key, KEYS[1])
|
|
1617
|
+
if redis.call('SCARD', pair.key) == 0 then
|
|
1618
|
+
redis.call('DEL', pair.key)
|
|
1619
|
+
redis.call('SREM', KEYS[3], pair.path)
|
|
1620
|
+
end
|
|
1621
|
+
end
|
|
1622
|
+
redis.call('DEL', KEYS[1])
|
|
1623
|
+
redis.call('SREM', KEYS[2], KEYS[1])
|
|
1624
|
+
return 1
|
|
1625
|
+
`;
|
|
1626
|
+
|
|
1627
|
+
const REDIS_INVALIDATE_SET_SCRIPT = `
|
|
1628
|
+
local members = redis.call('SMEMBERS', KEYS[1])
|
|
1629
|
+
local removed = 0
|
|
1630
|
+
for _, entryKey in ipairs(members) do
|
|
1631
|
+
local raw = redis.call('GET', entryKey)
|
|
1632
|
+
if raw then
|
|
1633
|
+
local record = cjson.decode(raw)
|
|
1634
|
+
for _, tagKey in ipairs(record.tagIndexKeys or {}) do
|
|
1635
|
+
redis.call('SREM', tagKey, entryKey)
|
|
1636
|
+
if redis.call('SCARD', tagKey) == 0 then redis.call('DEL', tagKey) end
|
|
1637
|
+
end
|
|
1638
|
+
for _, pair in ipairs(record.pathIndexPairs or {}) do
|
|
1639
|
+
redis.call('SREM', pair.key, entryKey)
|
|
1640
|
+
if redis.call('SCARD', pair.key) == 0 then
|
|
1641
|
+
redis.call('DEL', pair.key)
|
|
1642
|
+
redis.call('SREM', KEYS[3], pair.path)
|
|
1643
|
+
end
|
|
1644
|
+
end
|
|
1645
|
+
redis.call('DEL', entryKey)
|
|
1646
|
+
redis.call('SREM', KEYS[2], entryKey)
|
|
1647
|
+
removed = removed + 1
|
|
1648
|
+
else
|
|
1649
|
+
redis.call('SREM', KEYS[2], entryKey)
|
|
1650
|
+
end
|
|
1651
|
+
end
|
|
1652
|
+
redis.call('DEL', KEYS[1])
|
|
1653
|
+
return removed
|
|
1654
|
+
`;
|
|
1655
|
+
|
|
1656
|
+
const REDIS_INVALIDATE_PATHS_SCRIPT = `
|
|
1657
|
+
local pairs = cjson.decode(ARGV[1])
|
|
1658
|
+
local seen = {}
|
|
1659
|
+
local removed = 0
|
|
1660
|
+
for _, pair in ipairs(pairs) do
|
|
1661
|
+
local members = redis.call('SMEMBERS', pair.key)
|
|
1662
|
+
for _, entryKey in ipairs(members) do
|
|
1663
|
+
if not seen[entryKey] then
|
|
1664
|
+
seen[entryKey] = true
|
|
1665
|
+
local raw = redis.call('GET', entryKey)
|
|
1666
|
+
if raw then
|
|
1667
|
+
local record = cjson.decode(raw)
|
|
1668
|
+
for _, tagKey in ipairs(record.tagIndexKeys or {}) do
|
|
1669
|
+
redis.call('SREM', tagKey, entryKey)
|
|
1670
|
+
if redis.call('SCARD', tagKey) == 0 then redis.call('DEL', tagKey) end
|
|
1671
|
+
end
|
|
1672
|
+
for _, pathPair in ipairs(record.pathIndexPairs or {}) do
|
|
1673
|
+
redis.call('SREM', pathPair.key, entryKey)
|
|
1674
|
+
if redis.call('SCARD', pathPair.key) == 0 then
|
|
1675
|
+
redis.call('DEL', pathPair.key)
|
|
1676
|
+
redis.call('SREM', KEYS[2], pathPair.path)
|
|
1677
|
+
end
|
|
1678
|
+
end
|
|
1679
|
+
redis.call('DEL', entryKey)
|
|
1680
|
+
redis.call('SREM', KEYS[1], entryKey)
|
|
1681
|
+
removed = removed + 1
|
|
1682
|
+
else
|
|
1683
|
+
redis.call('SREM', KEYS[1], entryKey)
|
|
1684
|
+
end
|
|
1685
|
+
end
|
|
1686
|
+
end
|
|
1687
|
+
redis.call('DEL', pair.key)
|
|
1688
|
+
redis.call('SREM', KEYS[2], pair.path)
|
|
1689
|
+
end
|
|
1690
|
+
return removed
|
|
1691
|
+
`;
|
|
1692
|
+
|
|
1693
|
+
const REDIS_LOCK_RELEASE_SCRIPT = `
|
|
1694
|
+
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
|
1695
|
+
return redis.call('DEL', KEYS[1])
|
|
1696
|
+
end
|
|
1697
|
+
return 0
|
|
1698
|
+
`;
|
|
1699
|
+
|
|
1700
|
+
const REDIS_LOCK_EXTEND_SCRIPT = `
|
|
1701
|
+
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
|
1702
|
+
return redis.call('PEXPIRE', KEYS[1], ARGV[2])
|
|
1703
|
+
end
|
|
1704
|
+
return 0
|
|
1705
|
+
`;
|