## Summary - Add the `config_kv` schema and Drizzle migration `0020`. - Keep the ConfigKV schema, cache store, and invalidation contract in the Resource API. - Read ConfigKV through a five-minute Redis cache with PostgreSQL fallback. - Reload Router and TTS voice state through `configkv:invalidate`. - Keep Auth rate limits fixed at 20 requests per 60 seconds. ## Stack - Depends on #2294 for the Redis test implementation. - This PR adds ConfigKV-specific cache-aside and Pub/Sub tests on top of that implementation. ## Deployment Run migration `0020` before this runtime reaches production traffic. Then freeze ConfigKV writes. Audit and backfill the data with [proj-airi/backend#2](https://github.com/proj-airi/backend/pull/2). Merge [proj-airi/backend#4](https://github.com/proj-airi/backend/pull/4) first, so the fixed Auth rate-limit keys are skipped. Keep writes frozen until the hashes match and two API instances pass the Pub/Sub reload check. This PR does not run production DDL or data migration. ## Verification - `pnpm exec vitest run <ConfigKV cache store, sync subscriber, and Auth rate-limit tests>` (12 tests passed) - `pnpm -F @proj-airi/api-server typecheck` - `git diff --check` See #2294 for its frozen-install, ESLint, and 73-test verification. ## Visual changes None. This PR changes backend persistence and rate-limit wiring only. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added centralized configuration storage with validation, caching, refresh, and automatic synchronization across services. - Configuration updates now refresh related language-model and text-to-speech settings automatically. - **Bug Fixes** - Improved recovery after service reconnects by clearing stale configuration and reloading current values. - Invalid or unavailable configuration data now produces clearer service-unavailable responses. - **Changes** - Authentication rate limiting now uses a consistent limit of 20 requests per minute per client. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: RainbowBird <git@luoling.moe> Signed-off-by: RainbowBird <rbxin2003@outlook.com>
94 lines
3.6 KiB
TypeScript
94 lines
3.6 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { mockDB } from '../../../libs/mock-db'
|
|
import { createTestRedis } from '../../../libs/tests/redis'
|
|
import { configKV } from '../../../schemas'
|
|
import { createConfigKVStore } from './store'
|
|
|
|
describe('configKV store', () => {
|
|
let db: Awaited<ReturnType<typeof mockDB>>
|
|
|
|
beforeEach(async () => {
|
|
db = await mockDB({ configKV })
|
|
})
|
|
|
|
it('returns a Redis cache hit without reading PostgreSQL', async () => {
|
|
const redis = createTestRedis()
|
|
await redis.set('cache:config:FLUX_PER_REQUEST', '7')
|
|
const set = vi.spyOn(redis, 'set')
|
|
const store = createConfigKVStore(db, redis)
|
|
|
|
await expect(store.getRaw('FLUX_PER_REQUEST')).resolves.toBe('7')
|
|
expect(set).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('falls back to PostgreSQL and fills Redis for 300 seconds', async () => {
|
|
await db.insert(configKV).values({ key: 'FLUX_PER_REQUEST', value: '8' })
|
|
const redis = createTestRedis()
|
|
const set = vi.spyOn(redis, 'set')
|
|
const store = createConfigKVStore(db, redis)
|
|
|
|
await expect(store.getRaw('FLUX_PER_REQUEST')).resolves.toBe('8')
|
|
expect(set).toHaveBeenCalledWith('cache:config:FLUX_PER_REQUEST', '8', 'EX', 300)
|
|
})
|
|
|
|
it('fails when Redis reads fail', async () => {
|
|
await db.insert(configKV).values({ key: 'FLUX_PER_REQUEST', value: '9' })
|
|
const redis = createTestRedis()
|
|
vi.spyOn(redis, 'get').mockRejectedValueOnce(new Error('redis offline'))
|
|
const store = createConfigKVStore(db, redis)
|
|
|
|
await expect(store.getRaw('FLUX_PER_REQUEST')).rejects.toThrow('redis offline')
|
|
})
|
|
|
|
it('returns null when PostgreSQL has no row', async () => {
|
|
const redis = createTestRedis()
|
|
const set = vi.spyOn(redis, 'set')
|
|
const store = createConfigKVStore(db, redis)
|
|
|
|
await expect(store.getRaw('DEFAULT_CHAT_MODEL')).resolves.toBeNull()
|
|
expect(set).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('fails when Redis cannot store a PostgreSQL value', async () => {
|
|
await db.insert(configKV).values({ key: 'FLUX_PER_REQUEST', value: '9' })
|
|
const redis = createTestRedis()
|
|
vi.spyOn(redis, 'set').mockRejectedValueOnce(new Error('redis offline'))
|
|
const store = createConfigKVStore(db, redis)
|
|
|
|
await expect(store.getRaw('FLUX_PER_REQUEST')).rejects.toThrow('redis offline')
|
|
})
|
|
|
|
it('deletes the derived cache entry during invalidation', async () => {
|
|
const redis = createTestRedis()
|
|
await redis.set('cache:config:LLM_ROUTER_CONFIG', '{}')
|
|
const del = vi.spyOn(redis, 'del')
|
|
const store = createConfigKVStore(db, redis)
|
|
|
|
await store.invalidateCache('LLM_ROUTER_CONFIG')
|
|
|
|
expect(del).toHaveBeenCalledWith('cache:config:LLM_ROUTER_CONFIG')
|
|
await expect(redis.get('cache:config:LLM_ROUTER_CONFIG')).resolves.toBeNull()
|
|
})
|
|
|
|
it('fails invalidation when Redis cannot delete the derived value', async () => {
|
|
const redis = createTestRedis()
|
|
vi.spyOn(redis, 'del').mockRejectedValueOnce(new Error('redis offline'))
|
|
const store = createConfigKVStore(db, redis)
|
|
|
|
await expect(store.invalidateCache('LLM_ROUTER_CONFIG')).rejects.toThrow('redis offline')
|
|
})
|
|
|
|
it('removes a stale cache entry when a fresh database read is missing', async () => {
|
|
const redis = createTestRedis()
|
|
await redis.set('cache:config:FLUX_PER_REQUEST', '20')
|
|
const del = vi.spyOn(redis, 'del')
|
|
const store = createConfigKVStore(db, redis)
|
|
|
|
await expect(store.getFreshRaw('FLUX_PER_REQUEST')).resolves.toBeNull()
|
|
|
|
expect(del).toHaveBeenCalledWith('cache:config:FLUX_PER_REQUEST')
|
|
await expect(redis.get('cache:config:FLUX_PER_REQUEST')).resolves.toBeNull()
|
|
})
|
|
})
|