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 +10 -3
- 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/index.cjs +158 -51
- package/dist/index.d.cts +14 -2
- package/dist/index.d.ts +14 -2
- package/dist/index.js +158 -51
- package/package.json +12 -2
- package/packages/nestjs/dist/index.cjs +47 -27
- package/packages/nestjs/dist/index.js +47 -27
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-
|
|
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> |
|
|
23
24
|
<a href="#-quick-start">Quick Start</a> |
|
|
24
25
|
<a href="#-features">Features</a> |
|
|
25
26
|
<a href="./docs/api.md">API Reference</a> |
|
|
@@ -328,10 +329,16 @@ const cache = new CacheStack(
|
|
|
328
329
|
Run benchmarks locally:
|
|
329
330
|
|
|
330
331
|
```bash
|
|
331
|
-
npm run bench:
|
|
332
|
-
npm run bench:
|
|
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
|
+
}
|