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.
@@ -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
+ }