layercache 1.3.4 → 2.0.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.
Files changed (40) hide show
  1. package/README.md +42 -41
  2. package/dist/{chunk-BORDQ3LA.js → chunk-7KMKQ6QZ.js} +15 -1
  3. package/dist/{chunk-5RCAX2BQ.js → chunk-FFZCC7EQ.js} +3 -3
  4. package/dist/{chunk-4PPBOOXT.js → chunk-KJDFYE5T.js} +38 -26
  5. package/dist/cli.cjs +9 -9
  6. package/dist/cli.js +4 -4
  7. package/dist/{edge-DKkrQ_Ky.d.cts → edge-D2FpRlyS.d.cts} +71 -22
  8. package/dist/{edge-DKkrQ_Ky.d.ts → edge-D2FpRlyS.d.ts} +71 -22
  9. package/dist/edge.cjs +9 -9
  10. package/dist/edge.d.cts +1 -1
  11. package/dist/edge.d.ts +1 -1
  12. package/dist/edge.js +2 -2
  13. package/dist/index.cjs +399 -164
  14. package/dist/index.d.cts +6 -6
  15. package/dist/index.d.ts +6 -6
  16. package/dist/index.js +294 -81
  17. package/package.json +5 -5
  18. package/benchmarks/direct.ts +0 -221
  19. package/benchmarks/edge-utils.ts +0 -28
  20. package/benchmarks/edge.ts +0 -491
  21. package/benchmarks/http.ts +0 -99
  22. package/benchmarks/latency.ts +0 -45
  23. package/benchmarks/memory-pressure.ts +0 -144
  24. package/benchmarks/multi-process-fanout.ts +0 -231
  25. package/benchmarks/multi-process-worker.ts +0 -151
  26. package/benchmarks/paths.ts +0 -25
  27. package/benchmarks/queue-amplification-utils.ts +0 -48
  28. package/benchmarks/queue-amplification.ts +0 -230
  29. package/benchmarks/redis-latency-proxy.ts +0 -100
  30. package/benchmarks/redis.ts +0 -107
  31. package/benchmarks/scenario-utils.ts +0 -38
  32. package/benchmarks/server.ts +0 -157
  33. package/benchmarks/slow-redis-latency.ts +0 -309
  34. package/benchmarks/slow-redis-utils.ts +0 -29
  35. package/benchmarks/slow-redis.ts +0 -47
  36. package/benchmarks/stampede.ts +0 -26
  37. package/benchmarks/stats.ts +0 -46
  38. package/benchmarks/workload.ts +0 -77
  39. package/examples/express-api/index.ts +0 -31
  40. package/examples/nextjs-api-routes/route.ts +0 -16
@@ -1,309 +0,0 @@
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
- })
@@ -1,29 +0,0 @@
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
- }
@@ -1,47 +0,0 @@
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
- })
@@ -1,26 +0,0 @@
1
- import Redis from 'ioredis-mock'
2
- import { CacheStack, MemoryLayer, RedisLayer } from '../src'
3
-
4
- async function main(): Promise<void> {
5
- const redis = new Redis()
6
- const cache = new CacheStack([new MemoryLayer({ ttl: 60 }), new RedisLayer({ client: redis, ttl: 300 })])
7
-
8
- let executions = 0
9
-
10
- await Promise.all(
11
- Array.from({ length: 100 }, () =>
12
- cache.get('stampede:key', async () => {
13
- executions += 1
14
- await new Promise((resolve) => setTimeout(resolve, 10))
15
- return { ok: true }
16
- })
17
- )
18
- )
19
-
20
- console.table({
21
- concurrentRequests: 100,
22
- fetcherExecutions: executions
23
- })
24
- }
25
-
26
- void main()
@@ -1,46 +0,0 @@
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
- }
@@ -1,77 +0,0 @@
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
- }
@@ -1,31 +0,0 @@
1
- import express from 'express'
2
- import Redis from 'ioredis'
3
- import { CacheStack, MemoryLayer, RedisLayer } from '../../src'
4
-
5
- const redis = new Redis(process.env.REDIS_URL)
6
- const cache = new CacheStack([
7
- new MemoryLayer({ ttl: 30, maxSize: 5_000 }),
8
- new RedisLayer({ client: redis, ttl: 300 })
9
- ])
10
-
11
- const app = express()
12
-
13
- app.get('/users/:id', async (req, res) => {
14
- const user = await cache.get(
15
- `user:${req.params.id}`,
16
- async () => {
17
- return {
18
- id: Number(req.params.id),
19
- name: `User ${req.params.id}`,
20
- source: 'db'
21
- }
22
- },
23
- {
24
- tags: ['user', `user:${req.params.id}`]
25
- }
26
- )
27
-
28
- res.json(user)
29
- })
30
-
31
- app.listen(3000)
@@ -1,16 +0,0 @@
1
- import Redis from 'ioredis'
2
- import { CacheStack, MemoryLayer, RedisLayer } from '../../src'
3
-
4
- const redis = new Redis(process.env.REDIS_URL)
5
- const cache = new CacheStack([new MemoryLayer({ ttl: 15 }), new RedisLayer({ client: redis, ttl: 120 })])
6
-
7
- export async function GET(_request: Request, context: { params: { id: string } }): Promise<Response> {
8
- const data = await cache.get(`user:${context.params.id}`, async () => {
9
- return {
10
- id: Number(context.params.id),
11
- cachedAt: new Date().toISOString()
12
- }
13
- })
14
-
15
- return Response.json(data)
16
- }