mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-30 17:50:07 +08:00
198 lines
7.9 KiB
TypeScript
198 lines
7.9 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import type { CachePolicy, DistributedCacheBackend } from '../../usecases/ports'
|
|
import { createRuntimeCache, resolveCacheMode } from './runtime-cache'
|
|
|
|
const stringPolicy: CachePolicy<string | null> = {
|
|
namespace: 'test',
|
|
version: 2,
|
|
ttlMs: 1_000,
|
|
negativeTtlMs: 100,
|
|
maxEntries: 2,
|
|
validate(value): value is string | null {
|
|
return typeof value === 'string' || value === null
|
|
},
|
|
}
|
|
|
|
function fakeBackend() {
|
|
const values = new Map<string, string>()
|
|
const backend: DistributedCacheBackend = {
|
|
get: vi.fn(async (key) => values.get(key) ?? null),
|
|
put: vi.fn(async (key, value) => {
|
|
values.set(key, value)
|
|
}),
|
|
delete: vi.fn(async (key) => {
|
|
values.delete(key)
|
|
}),
|
|
}
|
|
return { backend, values }
|
|
}
|
|
|
|
describe('runtime cache', () => {
|
|
it('bypasses every tier when disabled', async () => {
|
|
const loader = vi.fn(async () => 'source')
|
|
const cache = createRuntimeCache({ mode: 'off' })
|
|
|
|
expect(await cache.getOrLoad(stringPolicy, 'a', loader)).toMatchObject({ value: 'source', tier: 'bypass' })
|
|
expect(await cache.getOrLoad(stringPolicy, 'a', loader)).toMatchObject({ value: 'source', tier: 'bypass' })
|
|
expect(loader).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('serves fresh values from memory and reloads expired values', async () => {
|
|
let now = 1_000
|
|
const loader = vi.fn(async () => `value-${loader.mock.calls.length}`)
|
|
const cache = createRuntimeCache({ mode: 'memory', now: () => now })
|
|
|
|
expect((await cache.getOrLoad(stringPolicy, 'a', loader)).tier).toBe('source')
|
|
expect(await cache.getOrLoad(stringPolicy, 'a', loader)).toMatchObject({ value: 'value-1', tier: 'memory' })
|
|
now = 2_001
|
|
expect(await cache.getOrLoad(stringPolicy, 'a', loader)).toMatchObject({ value: 'value-2', tier: 'source' })
|
|
})
|
|
|
|
it('reads an existing value without loading a missing value', async () => {
|
|
const cache = createRuntimeCache({ mode: 'memory' })
|
|
|
|
expect(await cache.get(stringPolicy, 'missing')).toBeUndefined()
|
|
await cache.replace(stringPolicy, 'present', 'value')
|
|
expect(await cache.get(stringPolicy, 'present')).toMatchObject({ value: 'value', tier: 'memory' })
|
|
})
|
|
|
|
it('coalesces concurrent loads for the same key', async () => {
|
|
let release: (value: string) => void = () => undefined
|
|
const loader = vi.fn(() => new Promise<string>((resolve) => (release = resolve)))
|
|
const cache = createRuntimeCache({ mode: 'memory' })
|
|
|
|
const first = cache.getOrLoad(stringPolicy, 'a', loader)
|
|
const second = cache.getOrLoad(stringPolicy, 'a', loader)
|
|
expect(loader).toHaveBeenCalledTimes(1)
|
|
|
|
release('shared')
|
|
expect(await first).toMatchObject({ value: 'shared', tier: 'source' })
|
|
expect(await second).toMatchObject({ value: 'shared', tier: 'coalesced' })
|
|
})
|
|
|
|
it('uses the shorter negative-cache TTL', async () => {
|
|
let now = 1_000
|
|
const loader = vi.fn(async () => null)
|
|
const cache = createRuntimeCache({ mode: 'memory', now: () => now })
|
|
|
|
await cache.getOrLoad(stringPolicy, 'missing', loader)
|
|
now = 1_099
|
|
expect((await cache.getOrLoad(stringPolicy, 'missing', loader)).tier).toBe('memory')
|
|
now = 1_101
|
|
expect((await cache.getOrLoad(stringPolicy, 'missing', loader)).tier).toBe('source')
|
|
})
|
|
|
|
it('evicts the least recently used entry at the policy capacity', async () => {
|
|
const cache = createRuntimeCache({ mode: 'memory' })
|
|
await cache.replace(stringPolicy, 'a', 'a')
|
|
await cache.replace(stringPolicy, 'b', 'b')
|
|
await cache.getOrLoad(stringPolicy, 'a', async () => 'unexpected')
|
|
await cache.replace(stringPolicy, 'c', 'c')
|
|
|
|
expect((await cache.getOrLoad(stringPolicy, 'a', async () => 'unexpected')).tier).toBe('memory')
|
|
expect((await cache.getOrLoad(stringPolicy, 'b', async () => 'reloaded')).tier).toBe('source')
|
|
})
|
|
|
|
it('reads through distributed cache and populates memory', async () => {
|
|
let now = 1_000
|
|
const { backend, values } = fakeBackend()
|
|
values.set('zpan:v2:test:a', JSON.stringify({ freshUntil: 1_500, value: 'kv' }))
|
|
const cache = createRuntimeCache({ mode: 'distributed', distributed: backend, now: () => now })
|
|
const loader = vi.fn(async () => 'source')
|
|
|
|
expect(await cache.getOrLoad(stringPolicy, 'a', loader)).toMatchObject({ value: 'kv', tier: 'distributed' })
|
|
now = 1_100
|
|
expect((await cache.getOrLoad(stringPolicy, 'a', loader)).tier).toBe('memory')
|
|
expect(loader).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('keeps memory-only policies out of the distributed backend', async () => {
|
|
const { backend } = fakeBackend()
|
|
const cache = createRuntimeCache({ mode: 'distributed', distributed: backend })
|
|
const memoryOnlyPolicy = { ...stringPolicy, namespace: 'sensitive', distributed: false }
|
|
|
|
expect(await cache.getOrLoad(memoryOnlyPolicy, 'a', async () => 'source')).toMatchObject({
|
|
value: 'source',
|
|
tier: 'source',
|
|
})
|
|
expect(await cache.getOrLoad(memoryOnlyPolicy, 'a', async () => 'unexpected')).toMatchObject({
|
|
value: 'source',
|
|
tier: 'memory',
|
|
})
|
|
await cache.replace(memoryOnlyPolicy, 'b', 'replacement')
|
|
await cache.invalidate(memoryOnlyPolicy, 'a')
|
|
|
|
expect(backend.get).not.toHaveBeenCalled()
|
|
expect(backend.put).not.toHaveBeenCalled()
|
|
expect(backend.delete).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects expired or invalid distributed envelopes and refreshes them from source', async () => {
|
|
const { backend, values } = fakeBackend()
|
|
values.set('zpan:v2:test:expired', JSON.stringify({ freshUntil: 999, value: 'old' }))
|
|
values.set('zpan:v2:test:invalid', JSON.stringify({ freshUntil: 2_000, value: 123 }))
|
|
const cache = createRuntimeCache({ mode: 'distributed', distributed: backend, now: () => 1_000 })
|
|
|
|
expect(await cache.getOrLoad(stringPolicy, 'expired', async () => 'fresh')).toMatchObject({
|
|
value: 'fresh',
|
|
tier: 'source',
|
|
})
|
|
expect(await cache.getOrLoad(stringPolicy, 'invalid', async () => 'valid')).toMatchObject({
|
|
value: 'valid',
|
|
tier: 'source',
|
|
})
|
|
})
|
|
|
|
it('falls back to source when distributed reads fail', async () => {
|
|
const backend: DistributedCacheBackend = {
|
|
get: vi.fn(async () => {
|
|
throw new Error('unavailable')
|
|
}),
|
|
put: vi.fn(async () => {
|
|
throw new Error('unavailable')
|
|
}),
|
|
delete: vi.fn(async () => {
|
|
throw new Error('unavailable')
|
|
}),
|
|
}
|
|
const cache = createRuntimeCache({ mode: 'distributed', distributed: backend })
|
|
|
|
expect(await cache.getOrLoad(stringPolicy, 'a', async () => 'source')).toMatchObject({
|
|
value: 'source',
|
|
tier: 'source',
|
|
})
|
|
await expect(cache.invalidate(stringPolicy, 'a')).resolves.toBeUndefined()
|
|
})
|
|
|
|
it('replace updates both tiers and invalidate removes both tiers', async () => {
|
|
const { backend, values } = fakeBackend()
|
|
const cache = createRuntimeCache({ mode: 'distributed', distributed: backend })
|
|
|
|
await cache.replace(stringPolicy, 'a', 'new')
|
|
expect((await cache.getOrLoad(stringPolicy, 'a', async () => 'source')).value).toBe('new')
|
|
expect(values.has('zpan:v2:test:a')).toBe(true)
|
|
|
|
await cache.invalidate(stringPolicy, 'a')
|
|
expect(values.has('zpan:v2:test:a')).toBe(false)
|
|
expect(await cache.getOrLoad(stringPolicy, 'a', async () => 'source')).toMatchObject({
|
|
value: 'source',
|
|
tier: 'source',
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('resolveCacheMode', () => {
|
|
it('uses the runtime-appropriate default', () => {
|
|
expect(resolveCacheMode(undefined, false)).toBe('memory')
|
|
expect(resolveCacheMode(undefined, true)).toBe('distributed')
|
|
})
|
|
|
|
it('validates explicit values and distributed availability', () => {
|
|
expect(resolveCacheMode('off', false)).toBe('off')
|
|
expect(resolveCacheMode('memory', true)).toBe('memory')
|
|
expect(() => resolveCacheMode('invalid', false)).toThrow('Invalid ZPAN_CACHE_MODE')
|
|
expect(() => resolveCacheMode('distributed', false)).toThrow('requires a distributed cache binding')
|
|
expect(() => createRuntimeCache({ mode: 'distributed' })).toThrow('requires a backend')
|
|
})
|
|
})
|