layercache 1.2.9 → 1.3.1
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 +3 -7
- 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 +23 -1
- package/dist/cli.js +23 -1
- package/dist/{edge-BXWTKlI1.d.cts → edge-CUHTP9Bc.d.cts} +2 -0
- package/dist/{edge-BXWTKlI1.d.ts → edge-CUHTP9Bc.d.ts} +2 -0
- package/dist/edge.d.cts +1 -1
- package/dist/edge.d.ts +1 -1
- package/dist/index.cjs +414 -71
- package/dist/index.d.cts +62 -5
- package/dist/index.d.ts +62 -5
- package/dist/index.js +412 -69
- package/package.json +12 -2
- package/packages/nestjs/dist/index.cjs +103 -37
- package/packages/nestjs/dist/index.d.cts +2 -0
- package/packages/nestjs/dist/index.d.ts +2 -0
- package/packages/nestjs/dist/index.js +103 -37
|
@@ -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
|
@@ -365,6 +365,18 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
365
365
|
process.exitCode = 1;
|
|
366
366
|
return;
|
|
367
367
|
}
|
|
368
|
+
if (isPlaintextRedisUrl(redisUrl)) {
|
|
369
|
+
if (args.requireTls) {
|
|
370
|
+
process.stderr.write(
|
|
371
|
+
"Error: --require-tls is set but the URL uses redis:// (plaintext). Use rediss:// for TLS-encrypted connections.\n"
|
|
372
|
+
);
|
|
373
|
+
process.exitCode = 1;
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
process.stderr.write(
|
|
377
|
+
"Warning: connecting to Redis without TLS (redis://). All data including cached values and credentials will be transmitted in plaintext. Use rediss:// in production environments, or set --require-tls.\n"
|
|
378
|
+
);
|
|
379
|
+
}
|
|
368
380
|
const redis = new import_ioredis.default(redisUrl, {
|
|
369
381
|
connectTimeout: CONNECT_TIMEOUT_MS,
|
|
370
382
|
lazyConnect: true,
|
|
@@ -484,6 +496,8 @@ function parseArgs(argv) {
|
|
|
484
496
|
} else if (token === "--tag-index-prefix") {
|
|
485
497
|
parsed.tagIndexPrefix = value;
|
|
486
498
|
index += 1;
|
|
499
|
+
} else if (token === "--require-tls") {
|
|
500
|
+
parsed.requireTls = true;
|
|
487
501
|
}
|
|
488
502
|
}
|
|
489
503
|
return parsed;
|
|
@@ -515,7 +529,7 @@ async function scanKeys(redis, pattern) {
|
|
|
515
529
|
}
|
|
516
530
|
function printUsage() {
|
|
517
531
|
process.stdout.write(
|
|
518
|
-
"Usage:\n layercache stats --redis <url> [--pattern <glob>]\n layercache keys --redis <url> [--pattern <glob>]\n layercache inspect --redis <url> --key <key>\n layercache invalidate --redis <url> [--pattern <glob> | --tag <tag>] [--tag-index-prefix <prefix>]\n\nOptions:\n --redis <url> Redis connection URL (e.g. redis://localhost:6379)\n --pattern <glob> Glob pattern to filter keys (default: *)\n --key <key> Exact cache key to inspect\n --tag <tag> Invalidate by tag name\n --tag-index-prefix <prefix> Redis key prefix for tag index (default: layercache:tag-index)\n"
|
|
532
|
+
"Usage:\n layercache stats --redis <url> [--pattern <glob>]\n layercache keys --redis <url> [--pattern <glob>]\n layercache inspect --redis <url> --key <key>\n layercache invalidate --redis <url> [--pattern <glob> | --tag <tag>] [--tag-index-prefix <prefix>]\n\nOptions:\n --redis <url> Redis connection URL (e.g. redis://localhost:6379)\n --pattern <glob> Glob pattern to filter keys (default: *)\n --key <key> Exact cache key to inspect\n --tag <tag> Invalidate by tag name\n --tag-index-prefix <prefix> Redis key prefix for tag index (default: layercache:tag-index)\n --require-tls Reject non-TLS (redis://) connections\n"
|
|
519
533
|
);
|
|
520
534
|
}
|
|
521
535
|
function decodeInspectablePayload(payload) {
|
|
@@ -541,6 +555,14 @@ function summarizeInspectableValue(value) {
|
|
|
541
555
|
}
|
|
542
556
|
return value;
|
|
543
557
|
}
|
|
558
|
+
function isPlaintextRedisUrl(url) {
|
|
559
|
+
try {
|
|
560
|
+
const parsed = new URL(url);
|
|
561
|
+
return parsed.protocol === "redis:";
|
|
562
|
+
} catch {
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
544
566
|
function maskRedisUrl(url) {
|
|
545
567
|
try {
|
|
546
568
|
const parsed = new URL(url);
|
package/dist/cli.js
CHANGED
|
@@ -23,6 +23,18 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
23
23
|
process.exitCode = 1;
|
|
24
24
|
return;
|
|
25
25
|
}
|
|
26
|
+
if (isPlaintextRedisUrl(redisUrl)) {
|
|
27
|
+
if (args.requireTls) {
|
|
28
|
+
process.stderr.write(
|
|
29
|
+
"Error: --require-tls is set but the URL uses redis:// (plaintext). Use rediss:// for TLS-encrypted connections.\n"
|
|
30
|
+
);
|
|
31
|
+
process.exitCode = 1;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
process.stderr.write(
|
|
35
|
+
"Warning: connecting to Redis without TLS (redis://). All data including cached values and credentials will be transmitted in plaintext. Use rediss:// in production environments, or set --require-tls.\n"
|
|
36
|
+
);
|
|
37
|
+
}
|
|
26
38
|
const redis = new Redis(redisUrl, {
|
|
27
39
|
connectTimeout: CONNECT_TIMEOUT_MS,
|
|
28
40
|
lazyConnect: true,
|
|
@@ -142,6 +154,8 @@ function parseArgs(argv) {
|
|
|
142
154
|
} else if (token === "--tag-index-prefix") {
|
|
143
155
|
parsed.tagIndexPrefix = value;
|
|
144
156
|
index += 1;
|
|
157
|
+
} else if (token === "--require-tls") {
|
|
158
|
+
parsed.requireTls = true;
|
|
145
159
|
}
|
|
146
160
|
}
|
|
147
161
|
return parsed;
|
|
@@ -173,7 +187,7 @@ async function scanKeys(redis, pattern) {
|
|
|
173
187
|
}
|
|
174
188
|
function printUsage() {
|
|
175
189
|
process.stdout.write(
|
|
176
|
-
"Usage:\n layercache stats --redis <url> [--pattern <glob>]\n layercache keys --redis <url> [--pattern <glob>]\n layercache inspect --redis <url> --key <key>\n layercache invalidate --redis <url> [--pattern <glob> | --tag <tag>] [--tag-index-prefix <prefix>]\n\nOptions:\n --redis <url> Redis connection URL (e.g. redis://localhost:6379)\n --pattern <glob> Glob pattern to filter keys (default: *)\n --key <key> Exact cache key to inspect\n --tag <tag> Invalidate by tag name\n --tag-index-prefix <prefix> Redis key prefix for tag index (default: layercache:tag-index)\n"
|
|
190
|
+
"Usage:\n layercache stats --redis <url> [--pattern <glob>]\n layercache keys --redis <url> [--pattern <glob>]\n layercache inspect --redis <url> --key <key>\n layercache invalidate --redis <url> [--pattern <glob> | --tag <tag>] [--tag-index-prefix <prefix>]\n\nOptions:\n --redis <url> Redis connection URL (e.g. redis://localhost:6379)\n --pattern <glob> Glob pattern to filter keys (default: *)\n --key <key> Exact cache key to inspect\n --tag <tag> Invalidate by tag name\n --tag-index-prefix <prefix> Redis key prefix for tag index (default: layercache:tag-index)\n --require-tls Reject non-TLS (redis://) connections\n"
|
|
177
191
|
);
|
|
178
192
|
}
|
|
179
193
|
function decodeInspectablePayload(payload) {
|
|
@@ -199,6 +213,14 @@ function summarizeInspectableValue(value) {
|
|
|
199
213
|
}
|
|
200
214
|
return value;
|
|
201
215
|
}
|
|
216
|
+
function isPlaintextRedisUrl(url) {
|
|
217
|
+
try {
|
|
218
|
+
const parsed = new URL(url);
|
|
219
|
+
return parsed.protocol === "redis:";
|
|
220
|
+
} catch {
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
202
224
|
function maskRedisUrl(url) {
|
|
203
225
|
try {
|
|
204
226
|
const parsed = new URL(url);
|
|
@@ -178,6 +178,8 @@ interface CacheStackOptions {
|
|
|
178
178
|
logger?: CacheLogger | boolean;
|
|
179
179
|
metrics?: boolean;
|
|
180
180
|
stampedePrevention?: boolean;
|
|
181
|
+
stampedeMaxInFlight?: number;
|
|
182
|
+
stampedeEntryTimeoutMs?: number;
|
|
181
183
|
invalidationBus?: InvalidationBus;
|
|
182
184
|
tagIndex?: CacheTagIndex;
|
|
183
185
|
generation?: number;
|
|
@@ -178,6 +178,8 @@ interface CacheStackOptions {
|
|
|
178
178
|
logger?: CacheLogger | boolean;
|
|
179
179
|
metrics?: boolean;
|
|
180
180
|
stampedePrevention?: boolean;
|
|
181
|
+
stampedeMaxInFlight?: number;
|
|
182
|
+
stampedeEntryTimeoutMs?: number;
|
|
181
183
|
invalidationBus?: InvalidationBus;
|
|
182
184
|
tagIndex?: CacheTagIndex;
|
|
183
185
|
generation?: number;
|
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-CUHTP9Bc.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-CUHTP9Bc.js';
|
|
2
2
|
import 'node:events';
|