layercache 1.2.8 → 1.3.0
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 +11 -4
- package/benchmarks/direct.ts +221 -0
- package/benchmarks/edge-utils.ts +28 -0
- package/benchmarks/edge.ts +491 -0
- package/benchmarks/http.ts +99 -0
- package/benchmarks/memory-pressure.ts +144 -0
- package/benchmarks/multi-process-fanout.ts +231 -0
- package/benchmarks/multi-process-worker.ts +151 -0
- package/benchmarks/paths.ts +25 -0
- package/benchmarks/queue-amplification-utils.ts +48 -0
- package/benchmarks/queue-amplification.ts +230 -0
- package/benchmarks/redis-latency-proxy.ts +100 -0
- package/benchmarks/redis.ts +107 -0
- package/benchmarks/scenario-utils.ts +38 -0
- package/benchmarks/server.ts +157 -0
- package/benchmarks/slow-redis-latency.ts +309 -0
- package/benchmarks/slow-redis-utils.ts +29 -0
- package/benchmarks/slow-redis.ts +47 -0
- package/benchmarks/stats.ts +46 -0
- package/benchmarks/workload.ts +77 -0
- package/dist/cli.cjs +14 -1
- package/dist/cli.js +14 -1
- package/dist/{edge-DBs8Ko5W.d.cts → edge-BXWTKlI1.d.cts} +1 -0
- package/dist/{edge-DBs8Ko5W.d.ts → edge-BXWTKlI1.d.ts} +1 -0
- package/dist/edge.d.cts +1 -1
- package/dist/edge.d.ts +1 -1
- package/dist/index.cjs +304 -112
- package/dist/index.d.cts +17 -4
- package/dist/index.d.ts +17 -4
- package/dist/index.js +301 -109
- package/package.json +12 -2
- package/packages/nestjs/dist/index.cjs +146 -68
- package/packages/nestjs/dist/index.d.cts +1 -0
- package/packages/nestjs/dist/index.d.ts +1 -0
- package/packages/nestjs/dist/index.js +146 -68
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { Redis } from 'ioredis'
|
|
2
|
+
import { CacheStack, MemoryLayer, RedisLayer, RedisSingleFlightCoordinator } from '../src'
|
|
3
|
+
import {
|
|
4
|
+
REDIS_PORT,
|
|
5
|
+
ensureRedisContainer,
|
|
6
|
+
pauseRedisContainer,
|
|
7
|
+
stopRedisContainer,
|
|
8
|
+
unpauseRedisContainer,
|
|
9
|
+
waitForRedisReady
|
|
10
|
+
} from './redis'
|
|
11
|
+
import { startRedisLatencyProxy } from './redis-latency-proxy'
|
|
12
|
+
import { buildDelayLabel } from './slow-redis-utils'
|
|
13
|
+
|
|
14
|
+
const LATENCY_LEVELS = [0, 100, 500] as const
|
|
15
|
+
const TIMEOUT_MS = 6_000
|
|
16
|
+
const COMMAND_TIMEOUT_MS = 200
|
|
17
|
+
|
|
18
|
+
interface SlowRedisResult {
|
|
19
|
+
delayLabel: string
|
|
20
|
+
scenario: string
|
|
21
|
+
success: boolean
|
|
22
|
+
latencyMs: number
|
|
23
|
+
error: string | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface LayeredCacheContext {
|
|
27
|
+
cache: CacheStack
|
|
28
|
+
memory: MemoryLayer
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function round(value: number): number {
|
|
32
|
+
return Number(value.toFixed(3))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeResult(
|
|
36
|
+
delayLabel: string,
|
|
37
|
+
scenario: string,
|
|
38
|
+
success: boolean,
|
|
39
|
+
latencyMs: number,
|
|
40
|
+
error?: string
|
|
41
|
+
): SlowRedisResult {
|
|
42
|
+
return {
|
|
43
|
+
delayLabel,
|
|
44
|
+
scenario,
|
|
45
|
+
success,
|
|
46
|
+
latencyMs: round(latencyMs),
|
|
47
|
+
error: error ?? null
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function createBenchmarkRedis(port: number): Redis {
|
|
52
|
+
const client = new Redis({
|
|
53
|
+
host: '127.0.0.1',
|
|
54
|
+
port,
|
|
55
|
+
lazyConnect: true,
|
|
56
|
+
maxRetriesPerRequest: 1,
|
|
57
|
+
connectTimeout: 6_000,
|
|
58
|
+
enableOfflineQueue: false,
|
|
59
|
+
autoResendUnfulfilledCommands: false,
|
|
60
|
+
retryStrategy: () => null
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
client.on('error', () => {})
|
|
64
|
+
return client
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function measure<TResult>(task: () => Promise<TResult>): Promise<{ durationMs: number; result: TResult }> {
|
|
68
|
+
const startedAt = process.hrtime.bigint()
|
|
69
|
+
const result = await task()
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000,
|
|
73
|
+
result
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
|
78
|
+
return Promise.race([
|
|
79
|
+
promise,
|
|
80
|
+
new Promise<T>((_, reject) => {
|
|
81
|
+
setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs)
|
|
82
|
+
})
|
|
83
|
+
])
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function createLayeredCache(redis: Redis, prefix: string, gracefulDegradation: boolean): LayeredCacheContext {
|
|
87
|
+
const memory = new MemoryLayer({ ttl: 60, maxSize: 50 })
|
|
88
|
+
const cache = new CacheStack(
|
|
89
|
+
[
|
|
90
|
+
memory,
|
|
91
|
+
new RedisLayer({
|
|
92
|
+
client: redis,
|
|
93
|
+
ttl: 300,
|
|
94
|
+
prefix: `${prefix}:cache:`,
|
|
95
|
+
commandTimeoutMs: COMMAND_TIMEOUT_MS
|
|
96
|
+
})
|
|
97
|
+
],
|
|
98
|
+
{
|
|
99
|
+
stampedePrevention: true,
|
|
100
|
+
...(gracefulDegradation ? { gracefulDegradation: { retryAfterMs: 10_000 } } : {}),
|
|
101
|
+
singleFlightCoordinator: new RedisSingleFlightCoordinator({
|
|
102
|
+
client: redis,
|
|
103
|
+
prefix: `${prefix}:sf:`,
|
|
104
|
+
commandTimeoutMs: COMMAND_TIMEOUT_MS
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
cache,
|
|
111
|
+
memory
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function runSlowRedisAtDelay(delayMs: number): Promise<SlowRedisResult[]> {
|
|
116
|
+
const delayLabel = buildDelayLabel(delayMs)
|
|
117
|
+
const proxy = await startRedisLatencyProxy(REDIS_PORT)
|
|
118
|
+
|
|
119
|
+
const strictRedis = createBenchmarkRedis(proxy.port)
|
|
120
|
+
const gracefulRedis = createBenchmarkRedis(proxy.port)
|
|
121
|
+
|
|
122
|
+
await Promise.all([strictRedis.connect(), gracefulRedis.connect()])
|
|
123
|
+
await Promise.all([strictRedis.ping(), gracefulRedis.ping()])
|
|
124
|
+
|
|
125
|
+
const strict = createLayeredCache(strictRedis, `slow:strict:${delayMs}:${Date.now()}`, false)
|
|
126
|
+
const graceful = createLayeredCache(gracefulRedis, `slow:graceful:${delayMs}:${Date.now()}`, true)
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
await strict.cache.get('warm:key', async () => ({ source: 'origin', delayMs }), { ttl: 60 })
|
|
130
|
+
await graceful.cache.get('warm:key', async () => ({ source: 'origin', delayMs }), { ttl: 60 })
|
|
131
|
+
proxy.setLatencyMs(delayMs)
|
|
132
|
+
|
|
133
|
+
const hotStrict = await measure(() =>
|
|
134
|
+
withTimeout(strict.cache.get('warm:key'), TIMEOUT_MS, `${delayLabel} strict hot`)
|
|
135
|
+
)
|
|
136
|
+
.then(({ durationMs }) => normalizeResult(delayLabel, 'strict-hot-hit', true, durationMs))
|
|
137
|
+
.catch((error) =>
|
|
138
|
+
normalizeResult(delayLabel, 'strict-hot-hit', false, 0, error instanceof Error ? error.message : String(error))
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
const hotGraceful = await measure(() =>
|
|
142
|
+
withTimeout(graceful.cache.get('warm:key'), TIMEOUT_MS, `${delayLabel} graceful hot`)
|
|
143
|
+
)
|
|
144
|
+
.then(({ durationMs }) => normalizeResult(delayLabel, 'graceful-hot-hit', true, durationMs))
|
|
145
|
+
.catch((error) =>
|
|
146
|
+
normalizeResult(
|
|
147
|
+
delayLabel,
|
|
148
|
+
'graceful-hot-hit',
|
|
149
|
+
false,
|
|
150
|
+
0,
|
|
151
|
+
error instanceof Error ? error.message : String(error)
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
await Promise.all([strict.memory.clear().catch(() => undefined), graceful.memory.clear().catch(() => undefined)])
|
|
156
|
+
|
|
157
|
+
const l2Strict = await measure(() =>
|
|
158
|
+
withTimeout(strict.cache.get('warm:key'), TIMEOUT_MS, `${delayLabel} strict l2`)
|
|
159
|
+
)
|
|
160
|
+
.then(({ durationMs }) => normalizeResult(delayLabel, 'strict-l2-hit', true, durationMs))
|
|
161
|
+
.catch((error) =>
|
|
162
|
+
normalizeResult(delayLabel, 'strict-l2-hit', false, 0, error instanceof Error ? error.message : String(error))
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
const l2Graceful = await measure(() =>
|
|
166
|
+
withTimeout(graceful.cache.get('warm:key'), TIMEOUT_MS, `${delayLabel} graceful l2`)
|
|
167
|
+
)
|
|
168
|
+
.then(({ durationMs }) => normalizeResult(delayLabel, 'graceful-l2-hit', true, durationMs))
|
|
169
|
+
.catch((error) =>
|
|
170
|
+
normalizeResult(delayLabel, 'graceful-l2-hit', false, 0, error instanceof Error ? error.message : String(error))
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
const coldStrict = await measure(() =>
|
|
174
|
+
withTimeout(
|
|
175
|
+
strict.cache.get('cold:key', async () => ({ source: 'origin', delayMs }), { ttl: 60 }),
|
|
176
|
+
TIMEOUT_MS,
|
|
177
|
+
`${delayLabel} strict cold`
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
.then(({ durationMs }) => normalizeResult(delayLabel, 'strict-cold-miss', true, durationMs))
|
|
181
|
+
.catch((error) =>
|
|
182
|
+
normalizeResult(
|
|
183
|
+
delayLabel,
|
|
184
|
+
'strict-cold-miss',
|
|
185
|
+
false,
|
|
186
|
+
0,
|
|
187
|
+
error instanceof Error ? error.message : String(error)
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
const coldGraceful = await measure(() =>
|
|
192
|
+
withTimeout(
|
|
193
|
+
graceful.cache.get('cold:key', async () => ({ source: 'origin', delayMs }), { ttl: 60 }),
|
|
194
|
+
TIMEOUT_MS,
|
|
195
|
+
`${delayLabel} graceful cold`
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
.then(({ durationMs }) => normalizeResult(delayLabel, 'graceful-cold-miss', true, durationMs))
|
|
199
|
+
.catch((error) =>
|
|
200
|
+
normalizeResult(
|
|
201
|
+
delayLabel,
|
|
202
|
+
'graceful-cold-miss',
|
|
203
|
+
false,
|
|
204
|
+
0,
|
|
205
|
+
error instanceof Error ? error.message : String(error)
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
return [hotStrict, hotGraceful, l2Strict, l2Graceful, coldStrict, coldGraceful]
|
|
210
|
+
} finally {
|
|
211
|
+
await strict.cache.disconnect().catch(() => {})
|
|
212
|
+
await graceful.cache.disconnect().catch(() => {})
|
|
213
|
+
await strictRedis.quit().catch(() => {})
|
|
214
|
+
await gracefulRedis.quit().catch(() => {})
|
|
215
|
+
await proxy.close().catch(() => {})
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function runDeadRedisContrast(): Promise<SlowRedisResult[]> {
|
|
220
|
+
const strictRedis = createBenchmarkRedis(REDIS_PORT)
|
|
221
|
+
const gracefulRedis = createBenchmarkRedis(REDIS_PORT)
|
|
222
|
+
|
|
223
|
+
await Promise.all([strictRedis.connect(), gracefulRedis.connect()])
|
|
224
|
+
await Promise.all([strictRedis.ping(), gracefulRedis.ping()])
|
|
225
|
+
|
|
226
|
+
const strict = createLayeredCache(strictRedis, `dead:strict:${Date.now()}`, false)
|
|
227
|
+
const graceful = createLayeredCache(gracefulRedis, `dead:graceful:${Date.now()}`, true)
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
await strict.cache.get('warm:key', async () => ({ source: 'origin' }), { ttl: 60 })
|
|
231
|
+
await graceful.cache.get('warm:key', async () => ({ source: 'origin' }), { ttl: 60 })
|
|
232
|
+
|
|
233
|
+
await pauseRedisContainer()
|
|
234
|
+
|
|
235
|
+
const strictCold = await measure(() =>
|
|
236
|
+
withTimeout(
|
|
237
|
+
strict.cache.get('cold:key', async () => ({ source: 'origin' }), { ttl: 60 }),
|
|
238
|
+
2_000,
|
|
239
|
+
'dead strict cold'
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
.then(({ durationMs }) => normalizeResult('dead', 'dead-strict-cold-miss', true, durationMs))
|
|
243
|
+
.catch((error) =>
|
|
244
|
+
normalizeResult(
|
|
245
|
+
'dead',
|
|
246
|
+
'dead-strict-cold-miss',
|
|
247
|
+
false,
|
|
248
|
+
0,
|
|
249
|
+
error instanceof Error ? error.message : String(error)
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
const gracefulCold = await measure(() =>
|
|
254
|
+
withTimeout(
|
|
255
|
+
graceful.cache.get('cold:key', async () => ({ source: 'origin' }), { ttl: 60 }),
|
|
256
|
+
2_000,
|
|
257
|
+
'dead graceful cold'
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
.then(({ durationMs }) => normalizeResult('dead', 'dead-graceful-cold-miss', true, durationMs))
|
|
261
|
+
.catch((error) =>
|
|
262
|
+
normalizeResult(
|
|
263
|
+
'dead',
|
|
264
|
+
'dead-graceful-cold-miss',
|
|
265
|
+
false,
|
|
266
|
+
0,
|
|
267
|
+
error instanceof Error ? error.message : String(error)
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
return [strictCold, gracefulCold]
|
|
272
|
+
} finally {
|
|
273
|
+
await unpauseRedisContainer().catch(() => {})
|
|
274
|
+
await waitForRedisReady()
|
|
275
|
+
await strict.cache.disconnect().catch(() => {})
|
|
276
|
+
await graceful.cache.disconnect().catch(() => {})
|
|
277
|
+
await strictRedis.quit().catch(() => {})
|
|
278
|
+
await gracefulRedis.quit().catch(() => {})
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function main(): Promise<void> {
|
|
283
|
+
await ensureRedisContainer()
|
|
284
|
+
await waitForRedisReady()
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
const slowRedisResults: SlowRedisResult[] = []
|
|
288
|
+
for (const delayMs of LATENCY_LEVELS) {
|
|
289
|
+
slowRedisResults.push(...(await runSlowRedisAtDelay(delayMs)))
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
await stopRedisContainer()
|
|
293
|
+
await ensureRedisContainer()
|
|
294
|
+
await waitForRedisReady()
|
|
295
|
+
|
|
296
|
+
const deadRedisResults = await runDeadRedisContrast()
|
|
297
|
+
|
|
298
|
+
console.table(slowRedisResults.concat(deadRedisResults))
|
|
299
|
+
console.log(JSON.stringify({ type: 'slow-redis-latency', slowRedisResults, deadRedisResults }, null, 2))
|
|
300
|
+
} finally {
|
|
301
|
+
await unpauseRedisContainer().catch(() => {})
|
|
302
|
+
await stopRedisContainer()
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
void main().catch((error) => {
|
|
307
|
+
console.error(error)
|
|
308
|
+
process.exitCode = 1
|
|
309
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface GcMetricsSummary {
|
|
2
|
+
gcCount: number
|
|
3
|
+
gcTotalMs: number
|
|
4
|
+
gcMaxMs: number
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function round(value: number): number {
|
|
8
|
+
return Number(value.toFixed(3))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function buildDelayLabel(delayMs: number): string {
|
|
12
|
+
return `${delayMs}ms`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function summarizeGcMetrics(durationsMs: number[]): GcMetricsSummary {
|
|
16
|
+
if (durationsMs.length === 0) {
|
|
17
|
+
return {
|
|
18
|
+
gcCount: 0,
|
|
19
|
+
gcTotalMs: 0,
|
|
20
|
+
gcMaxMs: 0
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
gcCount: durationsMs.length,
|
|
26
|
+
gcTotalMs: round(durationsMs.reduce((sum, duration) => sum + duration, 0)),
|
|
27
|
+
gcMaxMs: round(Math.max(...durationsMs))
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { promisify } from 'node:util'
|
|
4
|
+
|
|
5
|
+
const execFileAsync = promisify(execFile)
|
|
6
|
+
|
|
7
|
+
async function runTsx(scriptName: string): Promise<string> {
|
|
8
|
+
const tsxPath = join(process.cwd(), 'node_modules', '.bin', 'tsx')
|
|
9
|
+
const scriptPath = join(process.cwd(), 'benchmarks', scriptName)
|
|
10
|
+
const { stdout } = await execFileAsync(tsxPath, [scriptPath], {
|
|
11
|
+
cwd: process.cwd(),
|
|
12
|
+
maxBuffer: 50 * 1024 * 1024
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
return stdout.trim()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function extractJsonBlock(output: string): unknown {
|
|
19
|
+
const lines = output.trim().split('\n')
|
|
20
|
+
const startIndex = lines.findIndex((line) => line.startsWith('{'))
|
|
21
|
+
if (startIndex === -1) {
|
|
22
|
+
throw new Error('Benchmark output did not contain a JSON block')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return JSON.parse(lines.slice(startIndex).join('\n'))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function main(): Promise<void> {
|
|
29
|
+
const slowRedisOutput = await runTsx('slow-redis-latency.ts')
|
|
30
|
+
process.stdout.write(`${slowRedisOutput}\n`)
|
|
31
|
+
|
|
32
|
+
const memoryPressureOutput = await runTsx('memory-pressure.ts')
|
|
33
|
+
process.stdout.write(`${memoryPressureOutput}\n`)
|
|
34
|
+
|
|
35
|
+
const combined = {
|
|
36
|
+
type: 'slow-redis-memory-pressure-benchmark',
|
|
37
|
+
slowRedis: extractJsonBlock(slowRedisOutput),
|
|
38
|
+
memoryPressure: extractJsonBlock(memoryPressureOutput)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
console.log(JSON.stringify(combined, null, 2))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
void main().catch((error) => {
|
|
45
|
+
console.error(error)
|
|
46
|
+
process.exitCode = 1
|
|
47
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export interface DurationSummary {
|
|
2
|
+
label: string
|
|
3
|
+
count: number
|
|
4
|
+
minMs: number
|
|
5
|
+
maxMs: number
|
|
6
|
+
avgMs: number
|
|
7
|
+
medianMs: number
|
|
8
|
+
p95Ms: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function round(value: number): number {
|
|
12
|
+
return Number(value.toFixed(3))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function quantile(samples: number[], percentile: number): number {
|
|
16
|
+
if (samples.length === 0) {
|
|
17
|
+
throw new Error('quantile requires at least one sample')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const sorted = [...samples].sort((left, right) => left - right)
|
|
21
|
+
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * percentile) - 1))
|
|
22
|
+
const value = sorted[index]
|
|
23
|
+
if (value === undefined) {
|
|
24
|
+
throw new Error('quantile computed an invalid index')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return value
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function summarizeDurations(label: string, samples: number[]): DurationSummary {
|
|
31
|
+
if (samples.length === 0) {
|
|
32
|
+
throw new Error('summarizeDurations requires at least one sample')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const total = samples.reduce((sum, sample) => sum + sample, 0)
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
label,
|
|
39
|
+
count: samples.length,
|
|
40
|
+
minMs: round(Math.min(...samples)),
|
|
41
|
+
maxMs: round(Math.max(...samples)),
|
|
42
|
+
avgMs: round(total / samples.length),
|
|
43
|
+
medianMs: round(quantile(samples, 0.5)),
|
|
44
|
+
p95Ms: round(quantile(samples, 0.95))
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { dirname } from 'node:path'
|
|
4
|
+
|
|
5
|
+
export interface UserRecord {
|
|
6
|
+
id: number
|
|
7
|
+
email: string
|
|
8
|
+
name: string
|
|
9
|
+
profile: {
|
|
10
|
+
plan: string
|
|
11
|
+
region: string
|
|
12
|
+
score: number
|
|
13
|
+
}
|
|
14
|
+
tags: string[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const REGIONS = ['us-east', 'eu-west', 'ap-northeast', 'sa-east'] as const
|
|
18
|
+
const PLANS = ['starter', 'growth', 'enterprise'] as const
|
|
19
|
+
|
|
20
|
+
export function buildUserDataset(count: number): UserRecord[] {
|
|
21
|
+
return Array.from({ length: count }, (_, index) => {
|
|
22
|
+
const id = index + 1
|
|
23
|
+
const plan = PLANS[index % PLANS.length] ?? PLANS[0]
|
|
24
|
+
const region = REGIONS[index % REGIONS.length] ?? REGIONS[0]
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
id,
|
|
28
|
+
email: `user${id}@example.com`,
|
|
29
|
+
name: `User ${id}`,
|
|
30
|
+
profile: {
|
|
31
|
+
plan,
|
|
32
|
+
region,
|
|
33
|
+
score: (id * 37) % 1000
|
|
34
|
+
},
|
|
35
|
+
tags: [`segment-${id % 10}`, `cohort-${id % 4}`]
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function findUserById(users: UserRecord[], id: number): UserRecord {
|
|
41
|
+
const user = users.find((entry) => entry.id === id)
|
|
42
|
+
if (!user) {
|
|
43
|
+
throw new Error(`User ${id} not found`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return user
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function ensureFixtureFile(filePath: string, count = 5000): Promise<void> {
|
|
50
|
+
try {
|
|
51
|
+
await readFile(filePath, 'utf8')
|
|
52
|
+
return
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
|
|
55
|
+
throw error
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
await mkdir(dirname(filePath), { recursive: true })
|
|
60
|
+
await writeFile(filePath, JSON.stringify(buildUserDataset(count)), 'utf8')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function loadUserFromFixture(filePath: string, userId: number, hashRounds = 600): Promise<UserRecord> {
|
|
64
|
+
const raw = await readFile(filePath, 'utf8')
|
|
65
|
+
const users = JSON.parse(raw) as UserRecord[]
|
|
66
|
+
const user = findUserById(users, userId)
|
|
67
|
+
|
|
68
|
+
let digest = JSON.stringify(user)
|
|
69
|
+
for (let index = 0; index < hashRounds; index += 1) {
|
|
70
|
+
digest = createHash('sha256').update(digest).digest('hex')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
...user,
|
|
75
|
+
tags: [...user.tags, digest.slice(0, 12)]
|
|
76
|
+
}
|
|
77
|
+
}
|
package/dist/cli.cjs
CHANGED
|
@@ -465,6 +465,11 @@ function parseArgs(argv) {
|
|
|
465
465
|
const token = rest[index];
|
|
466
466
|
const value = rest[index + 1];
|
|
467
467
|
if (token === "--redis") {
|
|
468
|
+
if (!value || value.startsWith("--")) {
|
|
469
|
+
process.stderr.write("Error: --redis requires a value (e.g. redis://localhost:6379)\n");
|
|
470
|
+
process.exitCode = 1;
|
|
471
|
+
return parsed;
|
|
472
|
+
}
|
|
468
473
|
parsed.redisUrl = value;
|
|
469
474
|
index += 1;
|
|
470
475
|
} else if (token === "--pattern") {
|
|
@@ -484,6 +489,7 @@ function parseArgs(argv) {
|
|
|
484
489
|
return parsed;
|
|
485
490
|
}
|
|
486
491
|
var BATCH_DELETE_SIZE = 500;
|
|
492
|
+
var SCAN_MAX_KEYS = 1e6;
|
|
487
493
|
async function batchDelete(redis, keys) {
|
|
488
494
|
for (let i = 0; i < keys.length; i += BATCH_DELETE_SIZE) {
|
|
489
495
|
const batch = keys.slice(i, i + BATCH_DELETE_SIZE);
|
|
@@ -497,6 +503,13 @@ async function scanKeys(redis, pattern) {
|
|
|
497
503
|
const [nextCursor, batch] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 100);
|
|
498
504
|
cursor = nextCursor;
|
|
499
505
|
keys.push(...batch);
|
|
506
|
+
if (keys.length >= SCAN_MAX_KEYS) {
|
|
507
|
+
process.stderr.write(
|
|
508
|
+
`Warning: stopped scanning after ${SCAN_MAX_KEYS} keys. Use a more specific --pattern to narrow results.
|
|
509
|
+
`
|
|
510
|
+
);
|
|
511
|
+
return keys;
|
|
512
|
+
}
|
|
500
513
|
} while (cursor !== "0");
|
|
501
514
|
return keys;
|
|
502
515
|
}
|
|
@@ -539,7 +552,7 @@ function maskRedisUrl(url) {
|
|
|
539
552
|
return url.replace(/:([^@/]+)@/, ":***@");
|
|
540
553
|
}
|
|
541
554
|
}
|
|
542
|
-
if (process.argv[1]?.
|
|
555
|
+
if (process.argv[1]?.endsWith("cli.cjs") || process.argv[1]?.endsWith("cli.js")) {
|
|
543
556
|
void main();
|
|
544
557
|
}
|
|
545
558
|
// Annotate the CommonJS export names for ESM import in node:
|
package/dist/cli.js
CHANGED
|
@@ -123,6 +123,11 @@ function parseArgs(argv) {
|
|
|
123
123
|
const token = rest[index];
|
|
124
124
|
const value = rest[index + 1];
|
|
125
125
|
if (token === "--redis") {
|
|
126
|
+
if (!value || value.startsWith("--")) {
|
|
127
|
+
process.stderr.write("Error: --redis requires a value (e.g. redis://localhost:6379)\n");
|
|
128
|
+
process.exitCode = 1;
|
|
129
|
+
return parsed;
|
|
130
|
+
}
|
|
126
131
|
parsed.redisUrl = value;
|
|
127
132
|
index += 1;
|
|
128
133
|
} else if (token === "--pattern") {
|
|
@@ -142,6 +147,7 @@ function parseArgs(argv) {
|
|
|
142
147
|
return parsed;
|
|
143
148
|
}
|
|
144
149
|
var BATCH_DELETE_SIZE = 500;
|
|
150
|
+
var SCAN_MAX_KEYS = 1e6;
|
|
145
151
|
async function batchDelete(redis, keys) {
|
|
146
152
|
for (let i = 0; i < keys.length; i += BATCH_DELETE_SIZE) {
|
|
147
153
|
const batch = keys.slice(i, i + BATCH_DELETE_SIZE);
|
|
@@ -155,6 +161,13 @@ async function scanKeys(redis, pattern) {
|
|
|
155
161
|
const [nextCursor, batch] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 100);
|
|
156
162
|
cursor = nextCursor;
|
|
157
163
|
keys.push(...batch);
|
|
164
|
+
if (keys.length >= SCAN_MAX_KEYS) {
|
|
165
|
+
process.stderr.write(
|
|
166
|
+
`Warning: stopped scanning after ${SCAN_MAX_KEYS} keys. Use a more specific --pattern to narrow results.
|
|
167
|
+
`
|
|
168
|
+
);
|
|
169
|
+
return keys;
|
|
170
|
+
}
|
|
158
171
|
} while (cursor !== "0");
|
|
159
172
|
return keys;
|
|
160
173
|
}
|
|
@@ -197,7 +210,7 @@ function maskRedisUrl(url) {
|
|
|
197
210
|
return url.replace(/:([^@/]+)@/, ":***@");
|
|
198
211
|
}
|
|
199
212
|
}
|
|
200
|
-
if (process.argv[1]?.
|
|
213
|
+
if (process.argv[1]?.endsWith("cli.cjs") || process.argv[1]?.endsWith("cli.js")) {
|
|
201
214
|
void main();
|
|
202
215
|
}
|
|
203
216
|
export {
|
|
@@ -556,6 +556,7 @@ declare class CacheStack extends EventEmitter {
|
|
|
556
556
|
private readonly layerWriter;
|
|
557
557
|
private readonly snapshots;
|
|
558
558
|
private readonly backgroundRefreshes;
|
|
559
|
+
private readonly backgroundRefreshAbort;
|
|
559
560
|
private readonly layerDegradedUntil;
|
|
560
561
|
private readonly maintenance;
|
|
561
562
|
private readonly ttlResolver;
|
|
@@ -556,6 +556,7 @@ declare class CacheStack extends EventEmitter {
|
|
|
556
556
|
private readonly layerWriter;
|
|
557
557
|
private readonly snapshots;
|
|
558
558
|
private readonly backgroundRefreshes;
|
|
559
|
+
private readonly backgroundRefreshAbort;
|
|
559
560
|
private readonly layerDegradedUntil;
|
|
560
561
|
private readonly maintenance;
|
|
561
562
|
private readonly ttlResolver;
|
package/dist/edge.d.cts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { e as CacheGetOptions, f as CacheLayer, h as CacheLayerSetManyEntry, t as CacheMetricsSnapshot, w as CacheRateLimitOptions, B as CacheTtlPolicy, D as CacheTtlPolicyContext, J as CacheWriteOptions, K as EvictionPolicy, M as MemoryLayer, N as MemoryLayerOptions, O as MemoryLayerSnapshotEntry, P as PatternMatcher, T as TagIndex, Q as createHonoCacheMiddleware } from './edge-
|
|
1
|
+
export { e as CacheGetOptions, f as CacheLayer, h as CacheLayerSetManyEntry, t as CacheMetricsSnapshot, w as CacheRateLimitOptions, B as CacheTtlPolicy, D as CacheTtlPolicyContext, J as CacheWriteOptions, K as EvictionPolicy, M as MemoryLayer, N as MemoryLayerOptions, O as MemoryLayerSnapshotEntry, P as PatternMatcher, T as TagIndex, Q as createHonoCacheMiddleware } from './edge-BXWTKlI1.cjs';
|
|
2
2
|
import 'node:events';
|
package/dist/edge.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { e as CacheGetOptions, f as CacheLayer, h as CacheLayerSetManyEntry, t as CacheMetricsSnapshot, w as CacheRateLimitOptions, B as CacheTtlPolicy, D as CacheTtlPolicyContext, J as CacheWriteOptions, K as EvictionPolicy, M as MemoryLayer, N as MemoryLayerOptions, O as MemoryLayerSnapshotEntry, P as PatternMatcher, T as TagIndex, Q as createHonoCacheMiddleware } from './edge-
|
|
1
|
+
export { e as CacheGetOptions, f as CacheLayer, h as CacheLayerSetManyEntry, t as CacheMetricsSnapshot, w as CacheRateLimitOptions, B as CacheTtlPolicy, D as CacheTtlPolicyContext, J as CacheWriteOptions, K as EvictionPolicy, M as MemoryLayer, N as MemoryLayerOptions, O as MemoryLayerSnapshotEntry, P as PatternMatcher, T as TagIndex, Q as createHonoCacheMiddleware } from './edge-BXWTKlI1.js';
|
|
2
2
|
import 'node:events';
|