layercache 1.2.9 → 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 CHANGED
@@ -15,11 +15,12 @@
15
15
  <a href="./LICENSE"><img src="https://img.shields.io/badge/license-Apache_2.0-green" alt="license"></a>
16
16
  <a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-first-3178C6?logo=typescript&logoColor=white" alt="TypeScript"></a>
17
17
  <img src="https://img.shields.io/badge/Node.js-%E2%89%A5_20-339933?logo=nodedotjs&logoColor=white" alt="Node.js >= 20">
18
- <img src="https://img.shields.io/badge/tests-411_passing-brightgreen" alt="tests">
18
+ <img src="https://img.shields.io/badge/tests-431_passing-brightgreen" alt="tests">
19
19
  <a href="https://coveralls.io/github/flyingsquirrel0419/layercache?branch=main"><img src="https://coveralls.io/repos/github/flyingsquirrel0419/layercache/badge.svg?branch=main&t=20260410" alt="Coveralls"></a>
20
20
  </p>
21
21
 
22
22
  <p align="center">
23
+ <a href="https://layercache.flyingsquirrel.me">Website</a>&nbsp;&nbsp;|&nbsp;&nbsp;
23
24
  <a href="#-quick-start">Quick Start</a>&nbsp;&nbsp;|&nbsp;&nbsp;
24
25
  <a href="#-features">Features</a>&nbsp;&nbsp;|&nbsp;&nbsp;
25
26
  <a href="./docs/api.md">API Reference</a>&nbsp;&nbsp;|&nbsp;&nbsp;
@@ -328,10 +329,16 @@ const cache = new CacheStack(
328
329
  Run benchmarks locally:
329
330
 
330
331
  ```bash
331
- npm run bench:latency
332
- npm run bench:stampede
332
+ npm run bench:direct
333
+ npm run bench:edge
334
+ npm run bench:slow-redis
335
+ npm run bench:queue-amplification
336
+ npm run bench:http
337
+ npm run bench:multi-process-fanout
333
338
  ```
334
339
 
340
+ The benchmark harness defaults to `/root/cache-test/data/users.json` so the in-repo scripts stay aligned with the external reproduction workspace. Set `LAYERCACHE_BENCH_FIXTURE_PATH` if you want to point at a different workload fixture.
341
+
335
342
  ---
336
343
 
337
344
  ## Comparison
@@ -0,0 +1,221 @@
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
+ })
@@ -0,0 +1,28 @@
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
+ }