layercache 1.3.3 → 1.4.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-CUHTP9Bc.d.cts → edge-D2FpRlyS.d.cts} +74 -36
  8. package/dist/{edge-CUHTP9Bc.d.ts → edge-D2FpRlyS.d.ts} +74 -36
  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 +787 -466
  14. package/dist/index.d.cts +6 -6
  15. package/dist/index.d.ts +6 -6
  16. package/dist/index.js +682 -383
  17. package/package.json +6 -6
  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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "layercache",
3
- "version": "1.3.3",
3
+ "version": "1.4.0",
4
4
  "description": "Production-ready multi-layer caching for Node.js. Stack memory + Redis + disk behind one API with stampede prevention, tag invalidation, stale serving, and full observability.",
5
5
  "keywords": [
6
6
  "cache",
@@ -52,18 +52,18 @@
52
52
  }
53
53
  },
54
54
  "files": [
55
- "dist",
56
- "examples",
57
- "benchmarks"
55
+ "dist"
58
56
  ],
59
57
  "engines": {
60
58
  "node": ">=20"
61
59
  },
62
60
  "scripts": {
63
- "build": "tsup src/index.ts src/cli.ts src/edge.ts --format esm,cjs --dts",
61
+ "build": "tsup src/index.ts src/cli.ts src/edge.ts --format esm,cjs --dts --clean",
64
62
  "build:all": "npm run build",
65
63
  "test": "vitest run",
66
64
  "test:coverage": "vitest run --coverage",
65
+ "test:integration": "node ./scripts/run-integration-tests.mjs",
66
+ "test:all": "npm test && npm run test:integration",
67
67
  "test:watch": "vitest",
68
68
  "lint": "biome check .",
69
69
  "lint:fix": "biome check --write .",
@@ -93,7 +93,7 @@
93
93
  "@types/autocannon": "^7.12.7",
94
94
  "@types/node": "^22.15.2",
95
95
  "@vitest/coverage-v8": "^4.1.2",
96
- "autocannon": "^8.0.0",
96
+ "autocannon": "^2.0.1",
97
97
  "ioredis": "^5.6.1",
98
98
  "ioredis-mock": "^8.13.0",
99
99
  "tsup": "^8.5.0",
@@ -1,221 +0,0 @@
1
- import { performance } from 'node:perf_hooks'
2
- import type { Redis } from 'ioredis'
3
- import { CacheStack, MemoryLayer, RedisLayer, RedisSingleFlightCoordinator } from '../src'
4
- import { resolveBenchmarkFixturePath } from './paths'
5
- import { createRedisClient, ensureRedisContainer, stopRedisContainer, waitForRedisReady } from './redis'
6
- import { type ScenarioSummary, createCountedFetcher, runConcurrent, summarizeScenario } from './scenario-utils'
7
- import { ensureFixtureFile, loadUserFromFixture } from './workload'
8
-
9
- const USER_ID = 4242
10
- const COLD_SAMPLES = 15
11
- const WARM_SAMPLES = 120
12
- const STAMPEDE_CONCURRENCY = 75
13
- const STAMPEDE_RUNS = 5
14
-
15
- type BenchmarkMode = 'no-cache' | 'memory' | 'layered'
16
-
17
- interface FlattenedResult extends ScenarioSummary {
18
- mode: BenchmarkMode
19
- scenario: string
20
- }
21
-
22
- type CacheRunner = {
23
- read: <T>(key: string, fetcher: () => Promise<T>) => Promise<T>
24
- clear: () => Promise<void>
25
- disconnect?: () => Promise<void>
26
- }
27
-
28
- function createOriginFetcher(fixturePath: string) {
29
- return createCountedFetcher(async (userId: number) => loadUserFromFixture(fixturePath, userId))
30
- }
31
-
32
- function createNoCacheRunner(): CacheRunner {
33
- return {
34
- read: async <T>(_key: string, fetcher: () => Promise<T>) => fetcher(),
35
- clear: async () => {}
36
- }
37
- }
38
-
39
- function createMemoryRunner(): CacheRunner {
40
- const cache = new CacheStack([new MemoryLayer({ ttl: 60, maxSize: 2_000 })], {
41
- stampedePrevention: true
42
- })
43
-
44
- return {
45
- read: async <T>(key: string, fetcher: () => Promise<T>) => {
46
- const value = await cache.get(key, fetcher)
47
- if (value === null) {
48
- throw new Error(`Cache unexpectedly returned null for ${key}`)
49
- }
50
-
51
- return value
52
- },
53
- clear: async () => {
54
- await cache.clear()
55
- },
56
- disconnect: async () => {
57
- await cache.disconnect()
58
- }
59
- }
60
- }
61
-
62
- function createLayeredRunner(redis: Redis): CacheRunner {
63
- const cache = new CacheStack(
64
- [
65
- new MemoryLayer({ ttl: 60, maxSize: 2_000 }),
66
- new RedisLayer({ client: redis, ttl: 300, prefix: 'layercache-bench:direct:' })
67
- ],
68
- {
69
- stampedePrevention: true,
70
- singleFlightCoordinator: new RedisSingleFlightCoordinator({
71
- client: redis,
72
- prefix: 'layercache-bench:direct:single-flight:'
73
- })
74
- }
75
- )
76
-
77
- return {
78
- read: async <T>(key: string, fetcher: () => Promise<T>) => {
79
- const value = await cache.get(key, fetcher)
80
- if (value === null) {
81
- throw new Error(`Cache unexpectedly returned null for ${key}`)
82
- }
83
-
84
- return value
85
- },
86
- clear: async () => {
87
- await cache.clear()
88
- await redis.flushdb()
89
- },
90
- disconnect: async () => {
91
- await cache.disconnect()
92
- }
93
- }
94
- }
95
-
96
- async function measure<TResult>(task: () => Promise<TResult>): Promise<{ durationMs: number; result: TResult }> {
97
- const startedAt = process.hrtime.bigint()
98
- const result = await task()
99
- const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000
100
-
101
- return { durationMs, result }
102
- }
103
-
104
- async function runColdMiss(mode: BenchmarkMode, runner: CacheRunner, fixturePath: string): Promise<FlattenedResult> {
105
- const samples: number[] = []
106
- let fetchCount = 0
107
-
108
- for (let index = 0; index < COLD_SAMPLES; index += 1) {
109
- await runner.clear()
110
- const origin = createOriginFetcher(fixturePath)
111
- const { durationMs } = await measure(() => runner.read(`user:${USER_ID}`, () => origin.run(USER_ID)))
112
- samples.push(durationMs)
113
- fetchCount += origin.getCount()
114
- }
115
-
116
- return {
117
- ...summarizeScenario('cold-miss', samples, fetchCount),
118
- mode,
119
- scenario: 'cold-miss'
120
- }
121
- }
122
-
123
- async function runWarmHit(mode: BenchmarkMode, runner: CacheRunner, fixturePath: string): Promise<FlattenedResult> {
124
- const samples: number[] = []
125
- const warmOrigin = createOriginFetcher(fixturePath)
126
-
127
- await runner.clear()
128
- await runner.read(`user:${USER_ID}`, () => warmOrigin.run(USER_ID))
129
-
130
- const measuredOrigin = createOriginFetcher(fixturePath)
131
- for (let index = 0; index < WARM_SAMPLES; index += 1) {
132
- const { durationMs } = await measure(() => runner.read(`user:${USER_ID}`, () => measuredOrigin.run(USER_ID)))
133
- samples.push(durationMs)
134
- }
135
-
136
- return {
137
- ...summarizeScenario('warm-hit', samples, measuredOrigin.getCount()),
138
- mode,
139
- scenario: 'warm-hit'
140
- }
141
- }
142
-
143
- async function runStampede(mode: BenchmarkMode, runner: CacheRunner, fixturePath: string): Promise<FlattenedResult> {
144
- const samples: number[] = []
145
- let fetchCount = 0
146
-
147
- for (let index = 0; index < STAMPEDE_RUNS; index += 1) {
148
- await runner.clear()
149
- const origin = createOriginFetcher(fixturePath)
150
- const { durationMs } = await measure(() =>
151
- runConcurrent(STAMPEDE_CONCURRENCY, () => runner.read(`user:${USER_ID}`, () => origin.run(USER_ID)))
152
- )
153
-
154
- samples.push(durationMs)
155
- fetchCount += origin.getCount()
156
- }
157
-
158
- return {
159
- ...summarizeScenario('stampede', samples, fetchCount),
160
- mode,
161
- scenario: 'stampede'
162
- }
163
- }
164
-
165
- async function main(): Promise<void> {
166
- const fixturePath = resolveBenchmarkFixturePath()
167
- await ensureFixtureFile(fixturePath)
168
- await ensureRedisContainer()
169
-
170
- await waitForRedisReady()
171
- const redis = createRedisClient()
172
- await redis.ping()
173
-
174
- const runners: Record<BenchmarkMode, CacheRunner> = {
175
- 'no-cache': createNoCacheRunner(),
176
- memory: createMemoryRunner(),
177
- layered: createLayeredRunner(redis)
178
- }
179
-
180
- try {
181
- const results = [
182
- await runColdMiss('no-cache', runners['no-cache'], fixturePath),
183
- await runColdMiss('memory', runners.memory, fixturePath),
184
- await runColdMiss('layered', runners.layered, fixturePath),
185
- await runWarmHit('no-cache', runners['no-cache'], fixturePath),
186
- await runWarmHit('memory', runners.memory, fixturePath),
187
- await runWarmHit('layered', runners.layered, fixturePath),
188
- await runStampede('no-cache', runners['no-cache'], fixturePath),
189
- await runStampede('memory', runners.memory, fixturePath),
190
- await runStampede('layered', runners.layered, fixturePath)
191
- ]
192
-
193
- console.table(
194
- results.map((result) => ({
195
- mode: result.mode,
196
- scenario: result.scenario,
197
- avgMs: result.avgMs,
198
- p95Ms: result.p95Ms,
199
- minMs: result.minMs,
200
- maxMs: result.maxMs,
201
- fetchCount: result.fetchCount
202
- }))
203
- )
204
-
205
- console.log(JSON.stringify({ type: 'direct-benchmark', results }, null, 2))
206
- } finally {
207
- await Promise.all(
208
- Object.values(runners)
209
- .map((runner) => runner.disconnect)
210
- .filter((value): value is NonNullable<typeof value> => Boolean(value))
211
- .map((disconnect) => disconnect())
212
- )
213
- await redis.quit()
214
- await stopRedisContainer()
215
- }
216
- }
217
-
218
- void main().catch((error) => {
219
- console.error(error)
220
- process.exitCode = 1
221
- })
@@ -1,28 +0,0 @@
1
- export interface OutageResult {
2
- scenario: string
3
- success: boolean
4
- latencyMs: number
5
- error: string | null
6
- }
7
-
8
- function round(value: number): number {
9
- return Number(value.toFixed(3))
10
- }
11
-
12
- export function buildPayloadString(bytes: number): string {
13
- return 'x'.repeat(bytes)
14
- }
15
-
16
- export function normalizeOutageResult(
17
- scenario: string,
18
- success: boolean,
19
- latencyMs: number,
20
- error?: string
21
- ): OutageResult {
22
- return {
23
- scenario,
24
- success,
25
- latencyMs: round(latencyMs),
26
- error: error ?? null
27
- }
28
- }