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
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-467_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> |
|
|
@@ -325,12 +326,7 @@ const cache = new CacheStack(
|
|
|
325
326
|
└─────────────────────┴────────┘
|
|
326
327
|
```
|
|
327
328
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
```bash
|
|
331
|
-
npm run bench:latency
|
|
332
|
-
npm run bench:stampede
|
|
333
|
-
```
|
|
329
|
+
Benchmark commands, fixtures, and scenario notes live in [docs/benchmarking.md](./docs/benchmarking.md).
|
|
334
330
|
|
|
335
331
|
---
|
|
336
332
|
|
|
@@ -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
|
+
}
|