Files
PotentialStorage/test/events/kv.test.ts
T

201 lines
9.2 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import fc from 'fast-check'
import { KVStorage } from '../../src/kv/node.js'
import { InMemoryKVDriver } from '../../src/kv/memory.js'
import { KVEventStorage } from '../../src/events/kv.js'
import { ConcurrencyError } from '../../src/errors.js'
import { collect, CountingDriver, type Capability } from '../helpers.js'
function setup(hide: Capability[] = [], inner = new InMemoryKVDriver()) {
const driver = new CountingDriver(inner, hide)
let t = 1000
const events = new KVEventStorage(new KVStorage(driver), { now: () => t++ })
return { driver, events }
}
describe('KVEventStorage', () => {
it('appends and reads with versions and metadata', async () => {
const { events } = setup()
const orders = events.child('orders')
expect(await orders.append('o1', [{ type: 'created', total: 40 }], { expectedVersion: 0 })).toEqual({ version: 1, commit: 1 })
expect(await orders.child('o1').append([{ type: 'paid' }, { type: 'shipped' }])).toEqual({ version: 3, commit: 2 })
expect(await collect(orders.read('o1'))).toEqual([
{ version: 1, commit: 1, at: 1000, data: { type: 'created', total: 40 } },
{ version: 2, commit: 2, at: 1001, data: { type: 'paid' } },
{ version: 3, commit: 2, at: 1001, data: { type: 'shipped' } },
])
expect(await orders.version('o1')).toBe(3)
expect(await orders.child('o1').version()).toBe(3)
expect(await orders.version('nope')).toBe(0)
})
it('stores one key per commit under <stream>/~e/<seq:12>', async () => {
const { events, driver } = setup()
await events.child('orders').append('o1', ['a'])
await events.child('orders').append('o1', ['b', 'c'])
expect(await collect(driver.inner.scan({ values: true }))).toEqual([
['orders/o1/~e/000000000001', { firstVersion: 1, at: 1000, events: ['a'] }],
['orders/o1/~e/000000000002', { firstVersion: 2, at: 1001, events: ['b', 'c'] }],
])
})
it('enforces expectedVersion', async () => {
const { events } = setup()
const s = events.child('s')
await s.append(['a'], { expectedVersion: 0 })
await expect(s.append(['b'], { expectedVersion: 0 })).rejects.toBeInstanceOf(ConcurrencyError)
await expect(s.append(['b'], { expectedVersion: 5 })).rejects.toMatchObject({ expectedVersion: 5, actualVersion: 1 })
expect(await s.append(['b'], { expectedVersion: 1 })).toEqual({ version: 2, commit: 2 })
await expect(s.append([], { expectedVersion: 1 })).rejects.toBeInstanceOf(ConcurrencyError)
expect(await s.append([])).toEqual({ version: 2, commit: 2 })
})
it('applies the arity rules', async () => {
const { events } = setup()
const orders = events.child('orders')
const id: string | undefined = undefined
await expect(orders.append(id as never, ['x'] as never)).rejects.toThrow(TypeError)
await expect(orders.version(id as never)).rejects.toThrow(TypeError)
await expect(collect(orders.read(id as never))).rejects.toThrow(TypeError)
await expect(events.append(['x'])).rejects.toThrow(/root/)
await expect(orders.append('o1', 'x' as never)).rejects.toThrow(/array/)
})
it('lists child streams and keeps nested streams separate', async () => {
const { events } = setup()
const orders = events.child('orders')
await orders.append('o1', ['a'])
await orders.append('o2', ['b'])
await orders.child('o1').child('shipments').append('s1', ['ship'])
await orders.child('o1').append('notes', ['n'])
expect(await collect(orders.keys())).toEqual(['o1', 'o2'])
expect(await collect(orders.child('o1').keys())).toEqual(['notes', 'shipments'])
expect((await collect(orders.read('o1'))).map((e) => e.data)).toEqual(['a'])
})
})
describe('read windows (property)', () => {
it('matches brute force for any commit layout', async () => {
await fc.assert(
fc.asyncProperty(
fc.array(fc.integer({ min: 1, max: 4 }), { maxLength: 25 }),
fc.record({
from: fc.option(fc.integer({ min: -1, max: 70 }), { nil: undefined }),
to: fc.option(fc.integer({ min: 0, max: 70 }), { nil: undefined }),
reverse: fc.boolean(),
limit: fc.option(fc.integer({ min: 0, max: 10 }), { nil: undefined }),
}),
async (sizes, opts) => {
const { events } = setup()
const s = events.child('s')
const all: number[] = []
for (const n of sizes) {
const batch = Array.from({ length: n }, (_, i) => all.length + i + 1)
await s.append(batch)
all.push(...batch)
}
let exp = all.filter((v) => v >= Math.max(1, opts.from ?? 1) && v <= (opts.to ?? Infinity))
if (opts.reverse) exp.reverse()
if (opts.limit !== undefined) exp = exp.slice(0, opts.limit)
const got = await collect(s.read(opts))
expect(got.map((e) => e.data)).toEqual(exp)
expect(got.map((e) => e.version)).toEqual(exp)
},
),
{ numRuns: 300 },
)
})
})
describe('costs', () => {
it('cached head: a warm append is a single setIfAbsent', async () => {
const { events, driver } = setup()
const s = events.child('s')
await s.append(['a'])
driver.reset()
await s.append(['b'])
await s.append(['c'], { expectedVersion: 2 })
expect(driver.calls).toEqual({ setIfAbsent: 2 })
})
it('cold head lookup is one reverse scan with limit 1', async () => {
const inner = new InMemoryKVDriver()
await setup([], inner).events.child('s').append(['a', 'b'])
const { events, driver } = setup([], inner)
expect(await events.child('s').append(['c'])).toEqual({ version: 3, commit: 2 })
expect(driver.calls).toEqual({ scan: 1, setIfAbsent: 1 })
})
it('read({ from }) finds single-event commits in one probe', async () => {
const { events, driver } = setup()
const s = events.child('s')
for (let i = 0; i < 200; i++) await s.append([i])
driver.reset()
expect((await collect(s.read({ from: 150, limit: 1 }))).map((e) => e.version)).toEqual([150])
expect(driver.calls).toEqual({ getMany: 1, scan: 1 })
})
it('read({ from }) uses O(log commits) probes for multi-event commits', async () => {
const { events, driver } = setup()
const s = events.child('s')
for (let i = 0; i < 1000; i++) await s.append(i % 3 === 0 ? [i, i] : [i])
driver.reset()
const [first] = await collect(s.read({ from: 777, limit: 1 }))
expect(first?.version).toBe(777)
// 1 guess probe + ceil(log8(1000)) search rounds, then the read scan. The guess hit an
// existing commit, so no head scan was needed.
expect(driver.calls.getMany).toBeLessThanOrEqual(1 + 4)
expect(driver.calls.scan).toBe(1)
})
})
describe('concurrency', () => {
it('a stale writer collides with the existing commit key', async () => {
const inner = new InMemoryKVDriver()
const a = setup([], inner).events.child('s')
const b = setup([], inner).events.child('s')
await a.append(['a1'])
await b.append(['b1']) // a's cached head is now stale
await expect(a.append(['a2'], { expectedVersion: 1 })).rejects.toMatchObject({ actualVersion: 2 })
// 'any' retries after refreshing the head
expect(await a.append(['a3'])).toEqual({ version: 3, commit: 3 })
expect((await collect(b.read())).map((e) => e.data)).toEqual(['a1', 'b1', 'a3'])
})
it('a stale cached head is refreshed before rejecting expectedVersion', async () => {
const inner = new InMemoryKVDriver()
const a = setup([], inner).events.child('s')
const b = setup([], inner).events.child('s')
await a.append(['a1'])
await b.append(['b1'])
expect(await a.append(['a2'], { expectedVersion: 2 })).toEqual({ version: 3, commit: 3 })
})
it('interleaved writers from two instances produce a dense, non-overlapping log', async () => {
const inner = new InMemoryKVDriver()
const a = setup([], inner).events.child('s')
const b = setup([], inner).events.child('s')
await Promise.all([a.append(['a1']), b.append(['b1']), a.append(['a2', 'a3']), b.append(['b2'])])
const log = await collect(a.read())
expect(log.map((e) => e.version)).toEqual([1, 2, 3, 4, 5])
expect(new Set(log.map((e) => e.data))).toEqual(new Set(['a1', 'a2', 'a3', 'b1', 'b2']))
})
it('exactly one of several optimistic writers at the same version wins', async () => {
const inner = new InMemoryKVDriver()
const writers = [0, 1, 2, 3].map(() => setup([], inner).events.child('s'))
const results = await Promise.allSettled(writers.map((w, i) => w.append([i], { expectedVersion: 0 })))
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1)
expect(results.filter((r) => r.status === 'rejected').every((r) => (r as PromiseRejectedResult).reason instanceof ConcurrencyError)).toBe(true)
})
it('without setIfAbsent, an in-process mutex serialises appends (single-writer mode)', async () => {
const { events, driver } = setup(['setIfAbsent'])
const s = events.child('s')
await Promise.all(Array.from({ length: 20 }, (_, i) => s.append([i])))
expect((await collect(s.read())).map((e) => e.version)).toEqual(Array.from({ length: 20 }, (_, i) => i + 1))
expect(driver.calls.setIfAbsent).toBeUndefined()
await expect(Promise.all([s.append(['x'], { expectedVersion: 20 }), s.append(['y'], { expectedVersion: 20 })])).rejects.toBeInstanceOf(ConcurrencyError)
})
})