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.
- package/README.md +42 -41
- package/dist/{chunk-BORDQ3LA.js → chunk-7KMKQ6QZ.js} +15 -1
- package/dist/{chunk-5RCAX2BQ.js → chunk-FFZCC7EQ.js} +3 -3
- package/dist/{chunk-4PPBOOXT.js → chunk-KJDFYE5T.js} +38 -26
- package/dist/cli.cjs +9 -9
- package/dist/cli.js +4 -4
- package/dist/{edge-DKkrQ_Ky.d.cts → edge-D2FpRlyS.d.cts} +71 -22
- package/dist/{edge-DKkrQ_Ky.d.ts → edge-D2FpRlyS.d.ts} +71 -22
- package/dist/edge.cjs +9 -9
- package/dist/edge.d.cts +1 -1
- package/dist/edge.d.ts +1 -1
- package/dist/edge.js +2 -2
- package/dist/index.cjs +399 -164
- package/dist/index.d.cts +6 -6
- package/dist/index.d.ts +6 -6
- package/dist/index.js +294 -81
- package/package.json +5 -5
- package/benchmarks/direct.ts +0 -221
- package/benchmarks/edge-utils.ts +0 -28
- package/benchmarks/edge.ts +0 -491
- package/benchmarks/http.ts +0 -99
- package/benchmarks/latency.ts +0 -45
- package/benchmarks/memory-pressure.ts +0 -144
- package/benchmarks/multi-process-fanout.ts +0 -231
- package/benchmarks/multi-process-worker.ts +0 -151
- package/benchmarks/paths.ts +0 -25
- package/benchmarks/queue-amplification-utils.ts +0 -48
- package/benchmarks/queue-amplification.ts +0 -230
- package/benchmarks/redis-latency-proxy.ts +0 -100
- package/benchmarks/redis.ts +0 -107
- package/benchmarks/scenario-utils.ts +0 -38
- package/benchmarks/server.ts +0 -157
- package/benchmarks/slow-redis-latency.ts +0 -309
- package/benchmarks/slow-redis-utils.ts +0 -29
- package/benchmarks/slow-redis.ts +0 -47
- package/benchmarks/stampede.ts +0 -26
- package/benchmarks/stats.ts +0 -46
- package/benchmarks/workload.ts +0 -77
- package/examples/express-api/index.ts +0 -31
- package/examples/nextjs-api-routes/route.ts +0 -16
|
@@ -1,230 +0,0 @@
|
|
|
1
|
-
import { Redis } from 'ioredis'
|
|
2
|
-
import { CacheStack, MemoryLayer, RedisLayer, RedisSingleFlightCoordinator } from '../src'
|
|
3
|
-
import { buildConcurrencyLabel, summarizeQueueAmplification } from './queue-amplification-utils'
|
|
4
|
-
import { REDIS_PORT, ensureRedisContainer, stopRedisContainer, unpauseRedisContainer, waitForRedisReady } from './redis'
|
|
5
|
-
import { startRedisLatencyProxy } from './redis-latency-proxy'
|
|
6
|
-
import { type CountedFetcher, createCountedFetcher, runConcurrent } from './scenario-utils'
|
|
7
|
-
import { buildDelayLabel } from './slow-redis-utils'
|
|
8
|
-
|
|
9
|
-
const LATENCY_LEVELS = [0, 100, 500] as const
|
|
10
|
-
const CONCURRENCY_LEVELS = [1, 10, 50, 100, 250, 500] as const
|
|
11
|
-
const TIMEOUT_MS = 30_000
|
|
12
|
-
|
|
13
|
-
interface QueueAmplificationResult {
|
|
14
|
-
label: string
|
|
15
|
-
count: number
|
|
16
|
-
minMs: number
|
|
17
|
-
maxMs: number
|
|
18
|
-
avgMs: number
|
|
19
|
-
medianMs: number
|
|
20
|
-
p95Ms: number
|
|
21
|
-
delayLabel: string
|
|
22
|
-
scenario: string
|
|
23
|
-
concurrency: number
|
|
24
|
-
concurrencyLabel: string
|
|
25
|
-
totalWallClockMs: number
|
|
26
|
-
amplificationVsSingle: number
|
|
27
|
-
linearityRatio: number
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
interface BenchmarkCacheContext {
|
|
31
|
-
cache: CacheStack
|
|
32
|
-
memory: MemoryLayer
|
|
33
|
-
originFetcher: CountedFetcher<[], { source: string; warmedAt: number }>
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function round(value: number): number {
|
|
37
|
-
return Number(value.toFixed(3))
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function createBenchmarkRedis(port: number): Redis {
|
|
41
|
-
const client = new Redis({
|
|
42
|
-
host: '127.0.0.1',
|
|
43
|
-
port,
|
|
44
|
-
lazyConnect: true,
|
|
45
|
-
maxRetriesPerRequest: 1,
|
|
46
|
-
connectTimeout: 6_000,
|
|
47
|
-
enableOfflineQueue: false,
|
|
48
|
-
autoResendUnfulfilledCommands: false,
|
|
49
|
-
retryStrategy: () => null
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
client.on('error', () => {})
|
|
53
|
-
return client
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
|
57
|
-
return Promise.race([
|
|
58
|
-
promise,
|
|
59
|
-
new Promise<T>((_, reject) => {
|
|
60
|
-
setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs)
|
|
61
|
-
})
|
|
62
|
-
])
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function createLayeredCache(redis: Redis, prefix: string, gracefulDegradation: boolean): BenchmarkCacheContext {
|
|
66
|
-
const memory = new MemoryLayer({ ttl: 60, maxSize: 50 })
|
|
67
|
-
const originFetcher = createCountedFetcher(async () => ({
|
|
68
|
-
source: 'origin',
|
|
69
|
-
warmedAt: Date.now()
|
|
70
|
-
}))
|
|
71
|
-
|
|
72
|
-
return {
|
|
73
|
-
memory,
|
|
74
|
-
originFetcher,
|
|
75
|
-
cache: new CacheStack([memory, new RedisLayer({ client: redis, ttl: 300, prefix: `${prefix}:cache:` })], {
|
|
76
|
-
stampedePrevention: true,
|
|
77
|
-
...(gracefulDegradation ? { gracefulDegradation: { retryAfterMs: 10_000 } } : {}),
|
|
78
|
-
singleFlightCoordinator: new RedisSingleFlightCoordinator({
|
|
79
|
-
client: redis,
|
|
80
|
-
prefix: `${prefix}:sf:`
|
|
81
|
-
})
|
|
82
|
-
})
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
async function warmCache(context: BenchmarkCacheContext, key: string): Promise<void> {
|
|
87
|
-
await context.cache.get(key, () => context.originFetcher.run(), { ttl: 60 })
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
async function measureBatch(
|
|
91
|
-
context: BenchmarkCacheContext,
|
|
92
|
-
key: string,
|
|
93
|
-
delayLabel: string,
|
|
94
|
-
scenario: string,
|
|
95
|
-
concurrency: number,
|
|
96
|
-
baselineWallClockMs: number
|
|
97
|
-
): Promise<QueueAmplificationResult> {
|
|
98
|
-
await context.memory.clear()
|
|
99
|
-
|
|
100
|
-
const originCountBefore = context.originFetcher.getCount()
|
|
101
|
-
const batchStartedAt = process.hrtime.bigint()
|
|
102
|
-
const requestLatenciesMs = await runConcurrent(concurrency, async () => {
|
|
103
|
-
const requestStartedAt = process.hrtime.bigint()
|
|
104
|
-
await withTimeout(
|
|
105
|
-
context.cache.get(key, () => context.originFetcher.run(), { ttl: 60 }),
|
|
106
|
-
TIMEOUT_MS,
|
|
107
|
-
`${delayLabel} ${scenario} ${buildConcurrencyLabel(concurrency)}`
|
|
108
|
-
)
|
|
109
|
-
|
|
110
|
-
return Number(process.hrtime.bigint() - requestStartedAt) / 1_000_000
|
|
111
|
-
})
|
|
112
|
-
const totalWallClockMs = Number(process.hrtime.bigint() - batchStartedAt) / 1_000_000
|
|
113
|
-
|
|
114
|
-
const originFetchCount = context.originFetcher.getCount() - originCountBefore
|
|
115
|
-
if (originFetchCount !== 0) {
|
|
116
|
-
throw new Error(
|
|
117
|
-
`${delayLabel} ${scenario} ${buildConcurrencyLabel(concurrency)} unexpectedly fetched origin ${originFetchCount} times`
|
|
118
|
-
)
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
return summarizeQueueAmplification({
|
|
122
|
-
delayLabel,
|
|
123
|
-
scenario,
|
|
124
|
-
concurrency,
|
|
125
|
-
totalWallClockMs,
|
|
126
|
-
requestLatenciesMs,
|
|
127
|
-
baselineWallClockMs
|
|
128
|
-
})
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
async function runDelayBenchmarks(delayMs: number): Promise<QueueAmplificationResult[]> {
|
|
132
|
-
const delayLabel = buildDelayLabel(delayMs)
|
|
133
|
-
const proxy = await startRedisLatencyProxy(REDIS_PORT)
|
|
134
|
-
proxy.setLatencyMs(delayMs)
|
|
135
|
-
|
|
136
|
-
const strictRedis = createBenchmarkRedis(proxy.port)
|
|
137
|
-
const gracefulRedis = createBenchmarkRedis(proxy.port)
|
|
138
|
-
|
|
139
|
-
await Promise.all([strictRedis.connect(), gracefulRedis.connect()])
|
|
140
|
-
await Promise.all([strictRedis.ping(), gracefulRedis.ping()])
|
|
141
|
-
|
|
142
|
-
const strict = createLayeredCache(strictRedis, `queue:${delayMs}:strict:${Date.now()}`, false)
|
|
143
|
-
const graceful = createLayeredCache(gracefulRedis, `queue:${delayMs}:graceful:${Date.now()}`, true)
|
|
144
|
-
|
|
145
|
-
try {
|
|
146
|
-
const strictKey = `queue:strict:${delayMs}`
|
|
147
|
-
const gracefulKey = `queue:graceful:${delayMs}`
|
|
148
|
-
|
|
149
|
-
await warmCache(strict, strictKey)
|
|
150
|
-
await warmCache(graceful, gracefulKey)
|
|
151
|
-
|
|
152
|
-
const scenarios = [
|
|
153
|
-
{ scenario: 'strict-l2-hit', context: strict, key: strictKey },
|
|
154
|
-
{ scenario: 'graceful-l2-hit', context: graceful, key: gracefulKey }
|
|
155
|
-
]
|
|
156
|
-
|
|
157
|
-
const results: QueueAmplificationResult[] = []
|
|
158
|
-
|
|
159
|
-
for (const { scenario, context, key } of scenarios) {
|
|
160
|
-
let baselineWallClockMs = 0
|
|
161
|
-
|
|
162
|
-
for (const concurrency of CONCURRENCY_LEVELS) {
|
|
163
|
-
const effectiveBaseline = baselineWallClockMs || 1
|
|
164
|
-
const summary = await measureBatch(
|
|
165
|
-
context,
|
|
166
|
-
key,
|
|
167
|
-
delayLabel,
|
|
168
|
-
scenario,
|
|
169
|
-
concurrency,
|
|
170
|
-
concurrency === 1 ? effectiveBaseline : baselineWallClockMs
|
|
171
|
-
)
|
|
172
|
-
|
|
173
|
-
if (concurrency === 1) {
|
|
174
|
-
baselineWallClockMs = summary.totalWallClockMs
|
|
175
|
-
results.push({
|
|
176
|
-
...summary,
|
|
177
|
-
amplificationVsSingle: 1,
|
|
178
|
-
linearityRatio: 1
|
|
179
|
-
})
|
|
180
|
-
continue
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
results.push(summary)
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
return results
|
|
188
|
-
} finally {
|
|
189
|
-
await strict.cache.disconnect().catch(() => {})
|
|
190
|
-
await graceful.cache.disconnect().catch(() => {})
|
|
191
|
-
await strictRedis.quit().catch(() => {})
|
|
192
|
-
await gracefulRedis.quit().catch(() => {})
|
|
193
|
-
await proxy.close().catch(() => {})
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
async function main(): Promise<void> {
|
|
198
|
-
await ensureRedisContainer()
|
|
199
|
-
await waitForRedisReady()
|
|
200
|
-
|
|
201
|
-
try {
|
|
202
|
-
const results: QueueAmplificationResult[] = []
|
|
203
|
-
for (const delayMs of LATENCY_LEVELS) {
|
|
204
|
-
results.push(...(await runDelayBenchmarks(delayMs)))
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
console.table(
|
|
208
|
-
results.map((result) => ({
|
|
209
|
-
delay: result.delayLabel,
|
|
210
|
-
scenario: result.scenario,
|
|
211
|
-
concurrency: result.concurrency,
|
|
212
|
-
totalWallClockMs: round(result.totalWallClockMs),
|
|
213
|
-
avgMs: result.avgMs,
|
|
214
|
-
p95Ms: result.p95Ms,
|
|
215
|
-
maxMs: result.maxMs,
|
|
216
|
-
amplificationVsSingle: result.amplificationVsSingle,
|
|
217
|
-
linearityRatio: result.linearityRatio
|
|
218
|
-
}))
|
|
219
|
-
)
|
|
220
|
-
console.log(JSON.stringify({ type: 'queue-amplification-benchmark', results }, null, 2))
|
|
221
|
-
} finally {
|
|
222
|
-
await unpauseRedisContainer().catch(() => {})
|
|
223
|
-
await stopRedisContainer()
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
void main().catch((error) => {
|
|
228
|
-
console.error(error)
|
|
229
|
-
process.exitCode = 1
|
|
230
|
-
})
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
import { type Server, type Socket, connect, createServer } from 'node:net'
|
|
2
|
-
|
|
3
|
-
export interface RedisLatencyProxy {
|
|
4
|
-
port: number
|
|
5
|
-
setLatencyMs: (latencyMs: number) => void
|
|
6
|
-
close: () => Promise<void>
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export async function startRedisLatencyProxy(targetPort: number): Promise<RedisLatencyProxy> {
|
|
10
|
-
let latencyMs = 0
|
|
11
|
-
const sockets = new Set<Socket>()
|
|
12
|
-
|
|
13
|
-
const server = createServer((clientSocket) => {
|
|
14
|
-
const upstreamSocket = connect({
|
|
15
|
-
host: '127.0.0.1',
|
|
16
|
-
port: targetPort
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
sockets.add(clientSocket)
|
|
20
|
-
sockets.add(upstreamSocket)
|
|
21
|
-
|
|
22
|
-
clientSocket.on('error', () => {})
|
|
23
|
-
upstreamSocket.on('error', () => {})
|
|
24
|
-
clientSocket.on('close', () => {
|
|
25
|
-
sockets.delete(clientSocket)
|
|
26
|
-
if (!upstreamSocket.destroyed) {
|
|
27
|
-
upstreamSocket.destroy()
|
|
28
|
-
}
|
|
29
|
-
})
|
|
30
|
-
upstreamSocket.on('close', () => {
|
|
31
|
-
sockets.delete(upstreamSocket)
|
|
32
|
-
if (!clientSocket.destroyed) {
|
|
33
|
-
clientSocket.destroy()
|
|
34
|
-
}
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
clientSocket.on('data', (chunk) => {
|
|
38
|
-
setTimeout(() => {
|
|
39
|
-
if (!upstreamSocket.destroyed) {
|
|
40
|
-
upstreamSocket.write(chunk)
|
|
41
|
-
}
|
|
42
|
-
}, latencyMs / 2)
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
upstreamSocket.on('data', (chunk) => {
|
|
46
|
-
setTimeout(() => {
|
|
47
|
-
if (!clientSocket.destroyed) {
|
|
48
|
-
clientSocket.write(chunk)
|
|
49
|
-
}
|
|
50
|
-
}, latencyMs / 2)
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
clientSocket.on('end', () => upstreamSocket.end())
|
|
54
|
-
upstreamSocket.on('end', () => clientSocket.end())
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
await listen(server)
|
|
58
|
-
|
|
59
|
-
const address = server.address()
|
|
60
|
-
if (!address || typeof address === 'string') {
|
|
61
|
-
throw new Error('Latency proxy failed to acquire a TCP port')
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
return {
|
|
65
|
-
port: address.port,
|
|
66
|
-
setLatencyMs(nextLatencyMs: number) {
|
|
67
|
-
latencyMs = nextLatencyMs
|
|
68
|
-
},
|
|
69
|
-
async close() {
|
|
70
|
-
for (const socket of sockets) {
|
|
71
|
-
socket.destroy()
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
await closeServer(server)
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function listen(server: Server): Promise<void> {
|
|
80
|
-
return new Promise((resolve, reject) => {
|
|
81
|
-
server.once('error', reject)
|
|
82
|
-
server.listen(0, '127.0.0.1', () => {
|
|
83
|
-
server.off('error', reject)
|
|
84
|
-
resolve()
|
|
85
|
-
})
|
|
86
|
-
})
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function closeServer(server: Server): Promise<void> {
|
|
90
|
-
return new Promise((resolve, reject) => {
|
|
91
|
-
server.close((error) => {
|
|
92
|
-
if (error) {
|
|
93
|
-
reject(error)
|
|
94
|
-
return
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
resolve()
|
|
98
|
-
})
|
|
99
|
-
})
|
|
100
|
-
}
|
package/benchmarks/redis.ts
DELETED
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process'
|
|
2
|
-
import { promisify } from 'node:util'
|
|
3
|
-
import { Redis } from 'ioredis'
|
|
4
|
-
|
|
5
|
-
const execFileAsync = promisify(execFile)
|
|
6
|
-
|
|
7
|
-
export const REDIS_CONTAINER_NAME = 'layercache-bench-redis'
|
|
8
|
-
export const REDIS_PORT = 6390
|
|
9
|
-
export const REDIS_IMAGE = 'redis:7-alpine'
|
|
10
|
-
|
|
11
|
-
async function docker(args: string[]): Promise<string> {
|
|
12
|
-
const { stdout } = await execFileAsync('docker', args, {
|
|
13
|
-
cwd: process.cwd(),
|
|
14
|
-
maxBuffer: 10 * 1024 * 1024
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
return stdout.trim()
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
async function containerExists(): Promise<boolean> {
|
|
21
|
-
try {
|
|
22
|
-
await docker(['inspect', REDIS_CONTAINER_NAME])
|
|
23
|
-
return true
|
|
24
|
-
} catch {
|
|
25
|
-
return false
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
async function isContainerRunning(): Promise<boolean> {
|
|
30
|
-
try {
|
|
31
|
-
return (await docker(['inspect', '-f', '{{.State.Running}}', REDIS_CONTAINER_NAME])) === 'true'
|
|
32
|
-
} catch {
|
|
33
|
-
return false
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
async function isContainerPaused(): Promise<boolean> {
|
|
38
|
-
try {
|
|
39
|
-
return (await docker(['inspect', '-f', '{{.State.Paused}}', REDIS_CONTAINER_NAME])) === 'true'
|
|
40
|
-
} catch {
|
|
41
|
-
return false
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export async function ensureRedisContainer(): Promise<void> {
|
|
46
|
-
await docker(['version', '--format', '{{.Server.Version}}'])
|
|
47
|
-
|
|
48
|
-
if (await isContainerRunning()) {
|
|
49
|
-
return
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (await containerExists()) {
|
|
53
|
-
await docker(['start', REDIS_CONTAINER_NAME])
|
|
54
|
-
return
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
await docker(['run', '-d', '--rm', '--name', REDIS_CONTAINER_NAME, '-p', `${REDIS_PORT}:6379`, REDIS_IMAGE])
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export async function stopRedisContainer(): Promise<void> {
|
|
61
|
-
try {
|
|
62
|
-
await docker(['rm', '-f', REDIS_CONTAINER_NAME])
|
|
63
|
-
} catch {
|
|
64
|
-
// Ignore cleanup failures so benchmark reporting can continue.
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export async function pauseRedisContainer(): Promise<void> {
|
|
69
|
-
if (await isContainerPaused()) {
|
|
70
|
-
return
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
await docker(['pause', REDIS_CONTAINER_NAME])
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export async function unpauseRedisContainer(): Promise<void> {
|
|
77
|
-
if (!(await isContainerPaused())) {
|
|
78
|
-
return
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
await docker(['unpause', REDIS_CONTAINER_NAME])
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export function createRedisClient(port = REDIS_PORT): Redis {
|
|
85
|
-
return new Redis(`redis://127.0.0.1:${port}`, {
|
|
86
|
-
maxRetriesPerRequest: null
|
|
87
|
-
})
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export async function waitForRedisReady(retries = 40): Promise<void> {
|
|
91
|
-
for (let attempt = 0; attempt < retries; attempt += 1) {
|
|
92
|
-
try {
|
|
93
|
-
const response = await docker(['exec', REDIS_CONTAINER_NAME, 'redis-cli', 'ping'])
|
|
94
|
-
if (response === 'PONG') {
|
|
95
|
-
return
|
|
96
|
-
}
|
|
97
|
-
} catch {
|
|
98
|
-
// Allow Redis a short time to start up after the container launches.
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
await new Promise((resolve) => {
|
|
102
|
-
setTimeout(resolve, 250)
|
|
103
|
-
})
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
throw new Error('Redis did not become ready in time')
|
|
107
|
-
}
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { type DurationSummary, summarizeDurations } from './stats'
|
|
2
|
-
|
|
3
|
-
export interface CountedFetcher<TArgs extends unknown[], TResult> {
|
|
4
|
-
run: (...args: TArgs) => Promise<TResult>
|
|
5
|
-
getCount: () => number
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export interface ScenarioSummary extends DurationSummary {
|
|
9
|
-
fetchCount: number
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function createCountedFetcher<TArgs extends unknown[], TResult>(
|
|
13
|
-
fetcher: (...args: TArgs) => Promise<TResult>
|
|
14
|
-
): CountedFetcher<TArgs, TResult> {
|
|
15
|
-
let count = 0
|
|
16
|
-
|
|
17
|
-
return {
|
|
18
|
-
run: async (...args: TArgs) => {
|
|
19
|
-
count += 1
|
|
20
|
-
return fetcher(...args)
|
|
21
|
-
},
|
|
22
|
-
getCount: () => count
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export async function runConcurrent<TResult>(
|
|
27
|
-
count: number,
|
|
28
|
-
task: (index: number) => Promise<TResult>
|
|
29
|
-
): Promise<TResult[]> {
|
|
30
|
-
return Promise.all(Array.from({ length: count }, (_, index) => task(index)))
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function summarizeScenario(label: string, samples: number[], fetchCount: number): ScenarioSummary {
|
|
34
|
-
return {
|
|
35
|
-
...summarizeDurations(label, samples),
|
|
36
|
-
fetchCount
|
|
37
|
-
}
|
|
38
|
-
}
|
package/benchmarks/server.ts
DELETED
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
import { type IncomingMessage, type Server, type ServerResponse, createServer } from 'node:http'
|
|
2
|
-
|
|
3
|
-
import type { Redis } from 'ioredis'
|
|
4
|
-
import { CacheStack, MemoryLayer, RedisLayer, RedisSingleFlightCoordinator } from '../src'
|
|
5
|
-
import { resolveBenchmarkFixturePath } from './paths'
|
|
6
|
-
import { createCountedFetcher } from './scenario-utils'
|
|
7
|
-
import { ensureFixtureFile, loadUserFromFixture } from './workload'
|
|
8
|
-
|
|
9
|
-
export interface BenchmarkServerHandle {
|
|
10
|
-
port: number
|
|
11
|
-
close: () => Promise<void>
|
|
12
|
-
reset: () => Promise<void>
|
|
13
|
-
warm: () => Promise<void>
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const DEFAULT_USER_ID = 4242
|
|
17
|
-
|
|
18
|
-
function createOriginFetcher(filePath: string) {
|
|
19
|
-
return createCountedFetcher(async (userId: number) => loadUserFromFixture(filePath, userId))
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function respondJson(serverResponse: ServerResponse<IncomingMessage>, statusCode: number, payload: unknown): void {
|
|
23
|
-
serverResponse.statusCode = statusCode
|
|
24
|
-
serverResponse.setHeader('content-type', 'application/json')
|
|
25
|
-
serverResponse.end(JSON.stringify(payload))
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export async function startBenchmarkServer(redis: Redis): Promise<BenchmarkServerHandle> {
|
|
29
|
-
const fixturePath = resolveBenchmarkFixturePath()
|
|
30
|
-
await ensureFixtureFile(fixturePath)
|
|
31
|
-
|
|
32
|
-
const memoryCache = new CacheStack([new MemoryLayer({ ttl: 60, maxSize: 2_000 })], {
|
|
33
|
-
stampedePrevention: true
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
const layeredCache = new CacheStack(
|
|
37
|
-
[
|
|
38
|
-
new MemoryLayer({ ttl: 60, maxSize: 2_000 }),
|
|
39
|
-
new RedisLayer({ client: redis, ttl: 300, prefix: 'layercache-bench:http:' })
|
|
40
|
-
],
|
|
41
|
-
{
|
|
42
|
-
stampedePrevention: true,
|
|
43
|
-
singleFlightCoordinator: new RedisSingleFlightCoordinator({
|
|
44
|
-
client: redis,
|
|
45
|
-
prefix: 'layercache-bench:http:single-flight:'
|
|
46
|
-
})
|
|
47
|
-
}
|
|
48
|
-
)
|
|
49
|
-
|
|
50
|
-
const gracefulLayeredCache = new CacheStack(
|
|
51
|
-
[
|
|
52
|
-
new MemoryLayer({ ttl: 60, maxSize: 2_000 }),
|
|
53
|
-
new RedisLayer({ client: redis, ttl: 300, prefix: 'layercache-bench:http:graceful:' })
|
|
54
|
-
],
|
|
55
|
-
{
|
|
56
|
-
stampedePrevention: true,
|
|
57
|
-
gracefulDegradation: { retryAfterMs: 10_000 },
|
|
58
|
-
singleFlightCoordinator: new RedisSingleFlightCoordinator({
|
|
59
|
-
client: redis,
|
|
60
|
-
prefix: 'layercache-bench:http:graceful:single-flight:'
|
|
61
|
-
})
|
|
62
|
-
}
|
|
63
|
-
)
|
|
64
|
-
|
|
65
|
-
const origin = createOriginFetcher(fixturePath)
|
|
66
|
-
|
|
67
|
-
const server = createServer(async (request, response) => {
|
|
68
|
-
const url = new URL(request.url ?? '/', 'http://127.0.0.1')
|
|
69
|
-
const userId = Number(url.searchParams.get('id') ?? DEFAULT_USER_ID)
|
|
70
|
-
|
|
71
|
-
try {
|
|
72
|
-
if (url.pathname === '/nocache') {
|
|
73
|
-
const user = await origin.run(userId)
|
|
74
|
-
respondJson(response, 200, { route: 'nocache', user })
|
|
75
|
-
return
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
if (url.pathname === '/memory') {
|
|
79
|
-
const user = await memoryCache.get(`http:user:${userId}`, () => origin.run(userId))
|
|
80
|
-
respondJson(response, 200, { route: 'memory', user })
|
|
81
|
-
return
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (url.pathname === '/layered') {
|
|
85
|
-
const user = await layeredCache.get(`http:user:${userId}`, () => origin.run(userId))
|
|
86
|
-
respondJson(response, 200, { route: 'layered', user })
|
|
87
|
-
return
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if (url.pathname === '/layered-graceful') {
|
|
91
|
-
const user = await gracefulLayeredCache.get(`http:user:${userId}`, () => origin.run(userId))
|
|
92
|
-
respondJson(response, 200, { route: 'layered-graceful', user })
|
|
93
|
-
return
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
if (url.pathname === '/health') {
|
|
97
|
-
respondJson(response, 200, { ok: true, fetchCount: origin.getCount() })
|
|
98
|
-
return
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
respondJson(response, 404, { error: 'not-found' })
|
|
102
|
-
} catch (error) {
|
|
103
|
-
respondJson(response, 500, {
|
|
104
|
-
error: error instanceof Error ? error.message : String(error)
|
|
105
|
-
})
|
|
106
|
-
}
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
await listen(server)
|
|
110
|
-
|
|
111
|
-
const address = server.address()
|
|
112
|
-
if (!address || typeof address === 'string') {
|
|
113
|
-
throw new Error('Unexpected server address state')
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
return {
|
|
117
|
-
port: address.port,
|
|
118
|
-
reset: async () => {
|
|
119
|
-
await memoryCache.clear()
|
|
120
|
-
await layeredCache.clear()
|
|
121
|
-
await gracefulLayeredCache.clear()
|
|
122
|
-
await redis.flushdb()
|
|
123
|
-
},
|
|
124
|
-
warm: async () => {
|
|
125
|
-
await memoryCache.get(`http:user:${DEFAULT_USER_ID}`, () => origin.run(DEFAULT_USER_ID))
|
|
126
|
-
await layeredCache.get(`http:user:${DEFAULT_USER_ID}`, () => origin.run(DEFAULT_USER_ID))
|
|
127
|
-
await gracefulLayeredCache.get(`http:user:${DEFAULT_USER_ID}`, () => origin.run(DEFAULT_USER_ID))
|
|
128
|
-
},
|
|
129
|
-
close: async () => {
|
|
130
|
-
await Promise.all([memoryCache.disconnect(), layeredCache.disconnect(), gracefulLayeredCache.disconnect()])
|
|
131
|
-
await closeServer(server)
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function listen(server: Server): Promise<void> {
|
|
137
|
-
return new Promise((resolve, reject) => {
|
|
138
|
-
server.once('error', reject)
|
|
139
|
-
server.listen(0, '127.0.0.1', () => {
|
|
140
|
-
server.off('error', reject)
|
|
141
|
-
resolve()
|
|
142
|
-
})
|
|
143
|
-
})
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function closeServer(server: Server): Promise<void> {
|
|
147
|
-
return new Promise((resolve, reject) => {
|
|
148
|
-
server.close((error) => {
|
|
149
|
-
if (error) {
|
|
150
|
-
reject(error)
|
|
151
|
-
return
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
resolve()
|
|
155
|
-
})
|
|
156
|
-
})
|
|
157
|
-
}
|