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
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { PerformanceObserver, monitorEventLoopDelay } from 'node:perf_hooks'
|
|
2
|
+
import { CacheStack, MemoryLayer, RedisLayer } from '../src'
|
|
3
|
+
import { createRedisClient, ensureRedisContainer, stopRedisContainer, waitForRedisReady } from './redis'
|
|
4
|
+
import { summarizeGcMetrics } from './slow-redis-utils'
|
|
5
|
+
|
|
6
|
+
const EVICTION_CAPACITY = 25
|
|
7
|
+
const EVICTION_KEYS = 180
|
|
8
|
+
const EVICTION_PAYLOAD_BYTES = 256 * 1024
|
|
9
|
+
const REVISIT_COUNT = 25
|
|
10
|
+
|
|
11
|
+
interface MemoryPressureResult {
|
|
12
|
+
scenario: string
|
|
13
|
+
maxSize: number
|
|
14
|
+
uniqueKeys: number
|
|
15
|
+
evictions: number
|
|
16
|
+
l1RetainedKeys: number
|
|
17
|
+
revisitAvgMs: number
|
|
18
|
+
revisitP95Ms: number
|
|
19
|
+
revisitOriginFetches: number
|
|
20
|
+
gcCount: number
|
|
21
|
+
gcTotalMs: number
|
|
22
|
+
gcMaxMs: number
|
|
23
|
+
eventLoopMaxMs: number
|
|
24
|
+
heapDeltaMb: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function round(value: number): number {
|
|
28
|
+
return Number(value.toFixed(3))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function buildLargePayload(bytes: number): { size: number; payload: string } {
|
|
32
|
+
return {
|
|
33
|
+
size: bytes,
|
|
34
|
+
payload: 'p'.repeat(bytes)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function measure<TResult>(task: () => Promise<TResult>): Promise<{ durationMs: number; result: TResult }> {
|
|
39
|
+
const startedAt = process.hrtime.bigint()
|
|
40
|
+
const result = await task()
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000,
|
|
44
|
+
result
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function main(): Promise<void> {
|
|
49
|
+
await ensureRedisContainer()
|
|
50
|
+
await waitForRedisReady()
|
|
51
|
+
|
|
52
|
+
const redis = createRedisClient()
|
|
53
|
+
await redis.ping()
|
|
54
|
+
|
|
55
|
+
const gcDurations: number[] = []
|
|
56
|
+
const observer = new PerformanceObserver((list) => {
|
|
57
|
+
for (const entry of list.getEntries()) {
|
|
58
|
+
gcDurations.push(entry.duration)
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
observer.observe({ entryTypes: ['gc'] })
|
|
62
|
+
|
|
63
|
+
const loopDelay = monitorEventLoopDelay({ resolution: 10 })
|
|
64
|
+
loopDelay.enable()
|
|
65
|
+
|
|
66
|
+
let evictions = 0
|
|
67
|
+
const memoryLayer = new MemoryLayer({
|
|
68
|
+
ttl: 60,
|
|
69
|
+
maxSize: EVICTION_CAPACITY,
|
|
70
|
+
onEvict: () => {
|
|
71
|
+
evictions += 1
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const cache = new CacheStack([
|
|
76
|
+
memoryLayer,
|
|
77
|
+
new RedisLayer({ client: redis, ttl: 300, prefix: 'pressure:benchmark:' })
|
|
78
|
+
])
|
|
79
|
+
|
|
80
|
+
const heapBeforeMb = process.memoryUsage().heapUsed / (1024 * 1024)
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
await cache.clear()
|
|
84
|
+
await redis.flushdb()
|
|
85
|
+
|
|
86
|
+
for (let index = 0; index < EVICTION_KEYS; index += 1) {
|
|
87
|
+
await cache.get(`pressure:${index}`, async () => ({ key: index, ...buildLargePayload(EVICTION_PAYLOAD_BYTES) }), {
|
|
88
|
+
ttl: 60
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const retainedKeys = memoryLayer.exportState().length
|
|
93
|
+
let revisitOriginFetches = 0
|
|
94
|
+
const revisitSamples: number[] = []
|
|
95
|
+
|
|
96
|
+
for (let index = 0; index < REVISIT_COUNT; index += 1) {
|
|
97
|
+
const { durationMs } = await measure(() =>
|
|
98
|
+
cache.get(
|
|
99
|
+
`pressure:${index}`,
|
|
100
|
+
async () => {
|
|
101
|
+
revisitOriginFetches += 1
|
|
102
|
+
return { key: index, ...buildLargePayload(EVICTION_PAYLOAD_BYTES) }
|
|
103
|
+
},
|
|
104
|
+
{ ttl: 60 }
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
revisitSamples.push(durationMs)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const sortedSamples = [...revisitSamples].sort((left, right) => left - right)
|
|
111
|
+
const gcSummary = summarizeGcMetrics(gcDurations)
|
|
112
|
+
const heapAfterMb = process.memoryUsage().heapUsed / (1024 * 1024)
|
|
113
|
+
|
|
114
|
+
const result: MemoryPressureResult = {
|
|
115
|
+
scenario: 'memory-pressure-eviction',
|
|
116
|
+
maxSize: EVICTION_CAPACITY,
|
|
117
|
+
uniqueKeys: EVICTION_KEYS,
|
|
118
|
+
evictions,
|
|
119
|
+
l1RetainedKeys: retainedKeys,
|
|
120
|
+
revisitAvgMs: round(revisitSamples.reduce((sum, sample) => sum + sample, 0) / revisitSamples.length),
|
|
121
|
+
revisitP95Ms: round(sortedSamples[Math.ceil(sortedSamples.length * 0.95) - 1] ?? 0),
|
|
122
|
+
revisitOriginFetches,
|
|
123
|
+
gcCount: gcSummary.gcCount,
|
|
124
|
+
gcTotalMs: gcSummary.gcTotalMs,
|
|
125
|
+
gcMaxMs: gcSummary.gcMaxMs,
|
|
126
|
+
eventLoopMaxMs: round(loopDelay.max / 1_000_000),
|
|
127
|
+
heapDeltaMb: round(heapAfterMb - heapBeforeMb)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
console.table([result])
|
|
131
|
+
console.log(JSON.stringify({ type: 'memory-pressure', result }, null, 2))
|
|
132
|
+
} finally {
|
|
133
|
+
loopDelay.disable()
|
|
134
|
+
observer.disconnect()
|
|
135
|
+
await cache.disconnect().catch(() => {})
|
|
136
|
+
await redis.quit().catch(() => {})
|
|
137
|
+
await stopRedisContainer()
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
void main().catch((error) => {
|
|
142
|
+
console.error(error)
|
|
143
|
+
process.exitCode = 1
|
|
144
|
+
})
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { type ChildProcess, fork } from 'node:child_process'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import { createRedisClient, ensureRedisContainer, stopRedisContainer, waitForRedisReady } from './redis'
|
|
5
|
+
|
|
6
|
+
const PROCESS_COUNT = 4
|
|
7
|
+
const BURST_CONCURRENCY_PER_PROCESS = 25
|
|
8
|
+
const FETCH_DELAY_MS = 25
|
|
9
|
+
const COMMAND_TIMEOUT_MS = 200
|
|
10
|
+
|
|
11
|
+
interface WorkerResponse {
|
|
12
|
+
id: string
|
|
13
|
+
ok: boolean
|
|
14
|
+
result?: unknown
|
|
15
|
+
error?: unknown
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface MultiProcessInvalidationResult {
|
|
19
|
+
scenario: string
|
|
20
|
+
success: boolean
|
|
21
|
+
observedVersion: number | null
|
|
22
|
+
latencyMs: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface MultiProcessFanoutResult {
|
|
26
|
+
scenario: string
|
|
27
|
+
processCount: number
|
|
28
|
+
concurrencyPerProcess: number
|
|
29
|
+
totalConcurrency: number
|
|
30
|
+
latencyMs: number
|
|
31
|
+
originFetchCount: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function spawnWorker(): ChildProcess {
|
|
35
|
+
const workerPath = resolve(process.cwd(), 'benchmarks', 'multi-process-worker.ts')
|
|
36
|
+
return fork(workerPath, [], {
|
|
37
|
+
cwd: process.cwd(),
|
|
38
|
+
execArgv: ['--import', 'tsx'],
|
|
39
|
+
stdio: ['inherit', 'inherit', 'inherit', 'ipc']
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sendMessage<T>(worker: ChildProcess, message: Record<string, unknown>): Promise<T> {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const id = String(message.id)
|
|
46
|
+
|
|
47
|
+
const onMessage = (response: WorkerResponse): void => {
|
|
48
|
+
if (response.id !== id) {
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
worker.off('message', onMessage)
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
reject(new Error(typeof response.error === 'string' ? response.error : JSON.stringify(response.error)))
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
resolve(response.result as T)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
worker.on('message', onMessage)
|
|
62
|
+
worker.send(message)
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function sleep(ms: number): Promise<void> {
|
|
67
|
+
await new Promise((resolve) => {
|
|
68
|
+
setTimeout(resolve, ms)
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function runInvalidationScenario(
|
|
73
|
+
workers: ChildProcess[],
|
|
74
|
+
prefix: string
|
|
75
|
+
): Promise<MultiProcessInvalidationResult> {
|
|
76
|
+
const busChannel = `${prefix}:bus`
|
|
77
|
+
const sharedPrefix = `${prefix}:invalidate`
|
|
78
|
+
await Promise.all(
|
|
79
|
+
workers.slice(0, 2).map((worker) =>
|
|
80
|
+
sendMessage(worker, {
|
|
81
|
+
id: randomUUID(),
|
|
82
|
+
type: 'init',
|
|
83
|
+
prefix: sharedPrefix,
|
|
84
|
+
busChannel,
|
|
85
|
+
commandTimeoutMs: COMMAND_TIMEOUT_MS
|
|
86
|
+
})
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
const writer = workers[0]
|
|
91
|
+
const reader = workers[1]
|
|
92
|
+
if (!writer || !reader) {
|
|
93
|
+
throw new Error('Expected at least two workers for invalidation scenario.')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await sendMessage(writer, {
|
|
97
|
+
id: randomUUID(),
|
|
98
|
+
type: 'seed',
|
|
99
|
+
key: 'shared:key',
|
|
100
|
+
value: { version: 1 }
|
|
101
|
+
})
|
|
102
|
+
await sendMessage(reader, {
|
|
103
|
+
id: randomUUID(),
|
|
104
|
+
type: 'read',
|
|
105
|
+
key: 'shared:key'
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const startedAt = performance.now()
|
|
109
|
+
await sendMessage(writer, {
|
|
110
|
+
id: randomUUID(),
|
|
111
|
+
type: 'seed',
|
|
112
|
+
key: 'shared:key',
|
|
113
|
+
value: { version: 2 }
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
let observedVersion: number | null = null
|
|
117
|
+
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
118
|
+
const value =
|
|
119
|
+
(await sendMessage<{ version: number } | null>(reader, {
|
|
120
|
+
id: randomUUID(),
|
|
121
|
+
type: 'read',
|
|
122
|
+
key: 'shared:key'
|
|
123
|
+
})) ?? null
|
|
124
|
+
observedVersion = value?.version ?? null
|
|
125
|
+
if (observedVersion === 2) {
|
|
126
|
+
return {
|
|
127
|
+
scenario: 'multi-process-invalidation',
|
|
128
|
+
success: true,
|
|
129
|
+
observedVersion,
|
|
130
|
+
latencyMs: Number((performance.now() - startedAt).toFixed(3))
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
await sleep(25)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
scenario: 'multi-process-invalidation',
|
|
139
|
+
success: false,
|
|
140
|
+
observedVersion,
|
|
141
|
+
latencyMs: Number((performance.now() - startedAt).toFixed(3))
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function runDistributedSingleFlightScenario(
|
|
146
|
+
workers: ChildProcess[],
|
|
147
|
+
prefix: string
|
|
148
|
+
): Promise<MultiProcessFanoutResult> {
|
|
149
|
+
const redis = createRedisClient()
|
|
150
|
+
const originCounterKey = `${prefix}:origin-count`
|
|
151
|
+
const sharedPrefix = `${prefix}:fanout`
|
|
152
|
+
await redis.del(originCounterKey)
|
|
153
|
+
|
|
154
|
+
await Promise.all(
|
|
155
|
+
workers.map((worker) =>
|
|
156
|
+
sendMessage(worker, {
|
|
157
|
+
id: randomUUID(),
|
|
158
|
+
type: 'init',
|
|
159
|
+
prefix: sharedPrefix,
|
|
160
|
+
commandTimeoutMs: COMMAND_TIMEOUT_MS
|
|
161
|
+
}).catch(() => undefined)
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
const startedAt = performance.now()
|
|
166
|
+
const startAt = Date.now() + 300
|
|
167
|
+
await Promise.all(
|
|
168
|
+
workers.map((worker) =>
|
|
169
|
+
sendMessage(worker, {
|
|
170
|
+
id: randomUUID(),
|
|
171
|
+
type: 'burst',
|
|
172
|
+
key: 'fanout:key',
|
|
173
|
+
startAt,
|
|
174
|
+
concurrency: BURST_CONCURRENCY_PER_PROCESS,
|
|
175
|
+
originCounterKey,
|
|
176
|
+
fetchDelayMs: FETCH_DELAY_MS
|
|
177
|
+
})
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
const originFetchCount = Number(await redis.get(originCounterKey)) || 0
|
|
181
|
+
await redis.quit()
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
scenario: 'multi-process-distributed-single-flight',
|
|
185
|
+
processCount: workers.length,
|
|
186
|
+
concurrencyPerProcess: BURST_CONCURRENCY_PER_PROCESS,
|
|
187
|
+
totalConcurrency: workers.length * BURST_CONCURRENCY_PER_PROCESS,
|
|
188
|
+
latencyMs: Number((performance.now() - startedAt).toFixed(3)),
|
|
189
|
+
originFetchCount
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function disposeWorkers(workers: ChildProcess[]): Promise<void> {
|
|
194
|
+
await Promise.all(
|
|
195
|
+
workers.map(async (worker) => {
|
|
196
|
+
try {
|
|
197
|
+
await sendMessage(worker, {
|
|
198
|
+
id: randomUUID(),
|
|
199
|
+
type: 'dispose'
|
|
200
|
+
})
|
|
201
|
+
} catch {
|
|
202
|
+
worker.kill('SIGKILL')
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function main(): Promise<void> {
|
|
209
|
+
await ensureRedisContainer()
|
|
210
|
+
await waitForRedisReady()
|
|
211
|
+
|
|
212
|
+
const invalidationWorkers = Array.from({ length: 2 }, () => spawnWorker())
|
|
213
|
+
const fanoutWorkers = Array.from({ length: PROCESS_COUNT }, () => spawnWorker())
|
|
214
|
+
const prefix = `layercache-bench:multiprocess:${Date.now()}`
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
const invalidationResult = await runInvalidationScenario(invalidationWorkers, prefix)
|
|
218
|
+
const fanoutResult = await runDistributedSingleFlightScenario(fanoutWorkers, prefix)
|
|
219
|
+
|
|
220
|
+
console.table([invalidationResult, fanoutResult])
|
|
221
|
+
console.log(JSON.stringify({ type: 'multi-process-fanout-benchmark', invalidationResult, fanoutResult }, null, 2))
|
|
222
|
+
} finally {
|
|
223
|
+
await disposeWorkers([...invalidationWorkers, ...fanoutWorkers]).catch(() => undefined)
|
|
224
|
+
await stopRedisContainer()
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
void main().catch((error) => {
|
|
229
|
+
console.error(error)
|
|
230
|
+
process.exitCode = 1
|
|
231
|
+
})
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { CacheStack, MemoryLayer, RedisInvalidationBus, RedisLayer, RedisSingleFlightCoordinator } from '../src'
|
|
2
|
+
import { createRedisClient } from './redis'
|
|
3
|
+
import { runConcurrent } from './scenario-utils'
|
|
4
|
+
|
|
5
|
+
interface InitMessage {
|
|
6
|
+
id: string
|
|
7
|
+
type: 'init'
|
|
8
|
+
prefix: string
|
|
9
|
+
busChannel?: string
|
|
10
|
+
commandTimeoutMs?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SeedMessage {
|
|
14
|
+
id: string
|
|
15
|
+
type: 'seed'
|
|
16
|
+
key: string
|
|
17
|
+
value: unknown
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface ReadMessage {
|
|
21
|
+
id: string
|
|
22
|
+
type: 'read'
|
|
23
|
+
key: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface BurstMessage {
|
|
27
|
+
id: string
|
|
28
|
+
type: 'burst'
|
|
29
|
+
key: string
|
|
30
|
+
startAt: number
|
|
31
|
+
concurrency: number
|
|
32
|
+
originCounterKey: string
|
|
33
|
+
fetchDelayMs: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface DisposeMessage {
|
|
37
|
+
id: string
|
|
38
|
+
type: 'dispose'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type WorkerMessage = InitMessage | SeedMessage | ReadMessage | BurstMessage | DisposeMessage
|
|
42
|
+
|
|
43
|
+
let cache: CacheStack | undefined
|
|
44
|
+
const redis = createRedisClient()
|
|
45
|
+
|
|
46
|
+
function reply(id: string, ok: boolean, result?: unknown, error?: unknown): void {
|
|
47
|
+
process.send?.({
|
|
48
|
+
id,
|
|
49
|
+
ok,
|
|
50
|
+
result,
|
|
51
|
+
error: error instanceof Error ? error.message : error
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function sleep(ms: number): Promise<void> {
|
|
56
|
+
await new Promise((resolve) => {
|
|
57
|
+
setTimeout(resolve, ms)
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function ensureInitialized(message: WorkerMessage): Promise<CacheStack> {
|
|
62
|
+
if (!cache) {
|
|
63
|
+
throw new Error(`Worker received "${message.type}" before init.`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return cache
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
process.on('message', async (message: WorkerMessage) => {
|
|
70
|
+
try {
|
|
71
|
+
if (message.type === 'init') {
|
|
72
|
+
if (cache) {
|
|
73
|
+
throw new Error('Worker already initialized.')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const layer = new RedisLayer({
|
|
77
|
+
client: redis,
|
|
78
|
+
ttl: 300,
|
|
79
|
+
prefix: `${message.prefix}:cache:`,
|
|
80
|
+
commandTimeoutMs: message.commandTimeoutMs
|
|
81
|
+
})
|
|
82
|
+
const bus =
|
|
83
|
+
message.busChannel === undefined
|
|
84
|
+
? undefined
|
|
85
|
+
: new RedisInvalidationBus({
|
|
86
|
+
publisher: redis,
|
|
87
|
+
subscriber: redis.duplicate(),
|
|
88
|
+
channel: message.busChannel
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
cache = new CacheStack([new MemoryLayer({ ttl: 60, maxSize: 1_000 }), layer], {
|
|
92
|
+
stampedePrevention: true,
|
|
93
|
+
invalidationBus: bus,
|
|
94
|
+
broadcastL1Invalidation: bus ? true : undefined,
|
|
95
|
+
singleFlightCoordinator: new RedisSingleFlightCoordinator({
|
|
96
|
+
client: redis,
|
|
97
|
+
prefix: `${message.prefix}:sf:`,
|
|
98
|
+
commandTimeoutMs: message.commandTimeoutMs
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
reply(message.id, true, { initialized: true })
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (message.type === 'dispose') {
|
|
106
|
+
await cache?.disconnect()
|
|
107
|
+
await redis.quit()
|
|
108
|
+
reply(message.id, true, { disposed: true })
|
|
109
|
+
process.exit(0)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const initializedCache = await ensureInitialized(message)
|
|
114
|
+
|
|
115
|
+
if (message.type === 'seed') {
|
|
116
|
+
await initializedCache.set(message.key, message.value, { ttl: 60 })
|
|
117
|
+
reply(message.id, true, { seeded: true })
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (message.type === 'read') {
|
|
122
|
+
const value = await initializedCache.get(message.key)
|
|
123
|
+
reply(message.id, true, value)
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (message.type === 'burst') {
|
|
128
|
+
const waitMs = Math.max(0, message.startAt - Date.now())
|
|
129
|
+
if (waitMs > 0) {
|
|
130
|
+
await sleep(waitMs)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const startedAt = process.hrtime.bigint()
|
|
134
|
+
await runConcurrent(message.concurrency, async () => {
|
|
135
|
+
await initializedCache.get(
|
|
136
|
+
message.key,
|
|
137
|
+
async () => {
|
|
138
|
+
await redis.incr(message.originCounterKey)
|
|
139
|
+
await sleep(message.fetchDelayMs)
|
|
140
|
+
return { key: message.key, fetchedAt: Date.now() }
|
|
141
|
+
},
|
|
142
|
+
{ ttl: 60 }
|
|
143
|
+
)
|
|
144
|
+
})
|
|
145
|
+
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000
|
|
146
|
+
reply(message.id, true, { durationMs })
|
|
147
|
+
}
|
|
148
|
+
} catch (error) {
|
|
149
|
+
reply(message.id, false, undefined, error)
|
|
150
|
+
}
|
|
151
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
|
|
3
|
+
export interface BenchmarkPathOptions {
|
|
4
|
+
fixturePath?: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function buildBenchmarkFixtureCandidates(options: BenchmarkPathOptions = {}): string[] {
|
|
8
|
+
const candidates = [
|
|
9
|
+
options.fixturePath,
|
|
10
|
+
process.env.LAYERCACHE_BENCH_FIXTURE_PATH,
|
|
11
|
+
'/root/cache-test/data/users.json',
|
|
12
|
+
join(process.cwd(), 'data', 'users.json')
|
|
13
|
+
].filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
|
14
|
+
|
|
15
|
+
return [...new Set(candidates)]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function resolveBenchmarkFixturePath(options: BenchmarkPathOptions = {}): string {
|
|
19
|
+
const [firstCandidate] = buildBenchmarkFixtureCandidates(options)
|
|
20
|
+
if (!firstCandidate) {
|
|
21
|
+
throw new Error('Unable to resolve a benchmark fixture path.')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return firstCandidate
|
|
25
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type DurationSummary, summarizeDurations } from './stats'
|
|
2
|
+
|
|
3
|
+
export interface QueueAmplificationSummary extends DurationSummary {
|
|
4
|
+
delayLabel: string
|
|
5
|
+
scenario: string
|
|
6
|
+
concurrency: number
|
|
7
|
+
concurrencyLabel: string
|
|
8
|
+
totalWallClockMs: number
|
|
9
|
+
amplificationVsSingle: number
|
|
10
|
+
linearityRatio: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SummarizeQueueAmplificationInput {
|
|
14
|
+
delayLabel: string
|
|
15
|
+
scenario: string
|
|
16
|
+
concurrency: number
|
|
17
|
+
totalWallClockMs: number
|
|
18
|
+
requestLatenciesMs: number[]
|
|
19
|
+
baselineWallClockMs: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function round(value: number): number {
|
|
23
|
+
return Number(value.toFixed(3))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function buildConcurrencyLabel(concurrency: number): string {
|
|
27
|
+
return `x${concurrency}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function summarizeQueueAmplification(input: SummarizeQueueAmplificationInput): QueueAmplificationSummary {
|
|
31
|
+
if (input.baselineWallClockMs <= 0) {
|
|
32
|
+
throw new Error('baselineWallClockMs must be greater than 0')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const concurrencyLabel = buildConcurrencyLabel(input.concurrency)
|
|
36
|
+
const totalWallClockMs = round(input.totalWallClockMs)
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
...summarizeDurations(`${input.delayLabel}-${input.scenario}-${concurrencyLabel}`, input.requestLatenciesMs),
|
|
40
|
+
delayLabel: input.delayLabel,
|
|
41
|
+
scenario: input.scenario,
|
|
42
|
+
concurrency: input.concurrency,
|
|
43
|
+
concurrencyLabel,
|
|
44
|
+
totalWallClockMs,
|
|
45
|
+
amplificationVsSingle: round(totalWallClockMs / input.baselineWallClockMs),
|
|
46
|
+
linearityRatio: round(totalWallClockMs / (input.baselineWallClockMs * input.concurrency))
|
|
47
|
+
}
|
|
48
|
+
}
|