Simplify cache: remove reverse indexes, use direct key reconstruction
The reverse index approach (Redis sets tracking HMAC keys per context)
was over-engineered. Scoped purge doesn't need an index — recompute
the HMAC key from the invalidation params and DELETE directly. One
Redis command, no TOCTOU race, no atomicity concern, no stale members.
Broad purge uses key-prefix scan (keys are now "ctx:{context}:{hmac}").
This is rare (Tier 3 fallback) and acceptable as a SCAN operation.
Eliminated from both Python and TypeScript:
- All SET/SADD/SMEMBERS/SREM index operations
- CacheBackend.get_index, remove_from_index, delete_index, delete_indexes_by_prefix
- build_index_keys function
- Pipeline transaction complexity
- TOCTOU race condition (was critical, now impossible)
Backend interface is now 5 methods: get, set, delete, delete_by_prefix, clear.
Redis tests updated — prefix isolation test added, connection leak fixed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
56
packages/mizan-ts/src/cache/backend.ts
vendored
56
packages/mizan-ts/src/cache/backend.ts
vendored
@@ -1,70 +1,44 @@
|
||||
/**
|
||||
* Cache backends — MemoryCache for testing.
|
||||
*
|
||||
* Same API as Python's MemoryCache. RedisCache is implemented
|
||||
* per-adapter for production (not included here).
|
||||
* Simple key-value store. No reverse indexes.
|
||||
*/
|
||||
|
||||
export interface CacheBackend {
|
||||
get(key: string): string | null
|
||||
put(key: string, value: string, indexes: string[]): void
|
||||
deleteMany(keys: string[]): number
|
||||
getIndex(indexKey: string): Set<string>
|
||||
removeFromIndex(indexKey: string, members: Set<string>): void
|
||||
deleteIndex(indexKey: string): void
|
||||
deleteIndexesByPrefix(prefix: string): void
|
||||
set(key: string, value: string): void
|
||||
delete(key: string): boolean
|
||||
deleteByPrefix(prefix: string): number
|
||||
clear(): void
|
||||
}
|
||||
|
||||
export class MemoryCache implements CacheBackend {
|
||||
private _store = new Map<string, string>()
|
||||
private _indexes = new Map<string, Set<string>>()
|
||||
|
||||
get(key: string): string | null {
|
||||
return this._store.get(key) ?? null
|
||||
}
|
||||
|
||||
put(key: string, value: string, indexes: string[]): void {
|
||||
set(key: string, value: string): void {
|
||||
this._store.set(key, value)
|
||||
for (const idx of indexes) {
|
||||
if (!this._indexes.has(idx)) {
|
||||
this._indexes.set(idx, new Set())
|
||||
}
|
||||
this._indexes.get(idx)!.add(key)
|
||||
}
|
||||
}
|
||||
|
||||
deleteMany(keys: string[]): number {
|
||||
delete(key: string): boolean {
|
||||
return this._store.delete(key)
|
||||
}
|
||||
|
||||
deleteByPrefix(prefix: string): number {
|
||||
let count = 0
|
||||
for (const key of keys) {
|
||||
if (this._store.delete(key)) count++
|
||||
for (const key of [...this._store.keys()]) {
|
||||
if (key.startsWith(prefix)) {
|
||||
this._store.delete(key)
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
getIndex(indexKey: string): Set<string> {
|
||||
return new Set(this._indexes.get(indexKey) ?? [])
|
||||
}
|
||||
|
||||
removeFromIndex(indexKey: string, members: Set<string>): void {
|
||||
const idx = this._indexes.get(indexKey)
|
||||
if (!idx) return
|
||||
for (const m of members) idx.delete(m)
|
||||
if (idx.size === 0) this._indexes.delete(indexKey)
|
||||
}
|
||||
|
||||
deleteIndex(indexKey: string): void {
|
||||
this._indexes.delete(indexKey)
|
||||
}
|
||||
|
||||
deleteIndexesByPrefix(prefix: string): void {
|
||||
for (const key of [...this._indexes.keys()]) {
|
||||
if (key.startsWith(prefix)) this._indexes.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this._store.clear()
|
||||
this._indexes.clear()
|
||||
}
|
||||
}
|
||||
|
||||
58
packages/mizan-ts/src/cache/index.ts
vendored
58
packages/mizan-ts/src/cache/index.ts
vendored
@@ -2,15 +2,16 @@
|
||||
* mizan cache — TypeScript adapter.
|
||||
*
|
||||
* Same protocol as Python's mizan.cache. Cross-language conformance
|
||||
* verified by pin tests.
|
||||
* verified by pin tests. No reverse indexes — scoped purge recomputes
|
||||
* the key directly, broad purge uses prefix scan.
|
||||
*/
|
||||
|
||||
export { MemoryCache } from './backend'
|
||||
export type { CacheBackend } from './backend'
|
||||
export { deriveCacheKey, buildIndexKeys } from './keys'
|
||||
export { deriveCacheKey, CONTEXT_KEY_PREFIX } from './keys'
|
||||
|
||||
import type { CacheBackend } from './backend'
|
||||
import { deriveCacheKey, buildIndexKeys } from './keys'
|
||||
import { deriveCacheKey, CONTEXT_KEY_PREFIX } from './keys'
|
||||
|
||||
let _cacheInstance: CacheBackend | null = null
|
||||
|
||||
@@ -48,53 +49,24 @@ export function cachePut(
|
||||
rev: number = 0,
|
||||
): void {
|
||||
const key = deriveCacheKey(secret, context, params, userId, rev)
|
||||
const indexes = buildIndexKeys(context, params)
|
||||
backend.put(key, value, indexes)
|
||||
backend.set(key, value)
|
||||
}
|
||||
|
||||
export function cachePurge(
|
||||
backend: CacheBackend,
|
||||
context: string,
|
||||
params?: Record<string, any> | null,
|
||||
secret?: string | null,
|
||||
userId?: string,
|
||||
rev: number = 0,
|
||||
): number {
|
||||
if (params) {
|
||||
// Scoped purge — AND semantics (intersection)
|
||||
const setsPerParam: Set<string>[] = []
|
||||
const paramIndexKeys: string[] = []
|
||||
for (const [k, v] of Object.entries(params).sort(([a], [b]) => a.localeCompare(b))) {
|
||||
const indexKey = `mizan:idx:${context}:${k}=${String(v)}`
|
||||
paramIndexKeys.push(indexKey)
|
||||
setsPerParam.push(backend.getIndex(indexKey))
|
||||
}
|
||||
|
||||
let keysToDelete: Set<string>
|
||||
if (setsPerParam.length > 0) {
|
||||
keysToDelete = setsPerParam[0]
|
||||
for (let i = 1; i < setsPerParam.length; i++) {
|
||||
keysToDelete = new Set([...keysToDelete].filter(k => setsPerParam[i].has(k)))
|
||||
}
|
||||
} else {
|
||||
keysToDelete = new Set()
|
||||
}
|
||||
|
||||
if (keysToDelete.size > 0) {
|
||||
for (const idxKey of paramIndexKeys) {
|
||||
backend.removeFromIndex(idxKey, keysToDelete)
|
||||
}
|
||||
backend.removeFromIndex(`mizan:idx:${context}`, keysToDelete)
|
||||
return backend.deleteMany([...keysToDelete])
|
||||
}
|
||||
return 0
|
||||
if (params && secret) {
|
||||
// Scoped purge — recompute key and delete directly
|
||||
const key = deriveCacheKey(secret, context, params, userId, rev)
|
||||
return backend.delete(key) ? 1 : 0
|
||||
} else {
|
||||
// Broad purge
|
||||
const indexKey = `mizan:idx:${context}`
|
||||
const keysToDelete = backend.getIndex(indexKey)
|
||||
backend.deleteIndex(indexKey)
|
||||
backend.deleteIndexesByPrefix(`mizan:idx:${context}:`)
|
||||
|
||||
if (keysToDelete.size > 0) {
|
||||
return backend.deleteMany([...keysToDelete])
|
||||
}
|
||||
return 0
|
||||
// Broad purge — prefix scan
|
||||
const prefix = `${CONTEXT_KEY_PREFIX}${context}:`
|
||||
return backend.deleteByPrefix(prefix)
|
||||
}
|
||||
}
|
||||
|
||||
30
packages/mizan-ts/src/cache/keys.ts
vendored
30
packages/mizan-ts/src/cache/keys.ts
vendored
@@ -1,19 +1,16 @@
|
||||
/**
|
||||
* Cache key derivation — HMAC-SHA256 over JSON-canonical form.
|
||||
*
|
||||
* Protocol-critical: must produce identical output to Python's derive_cache_key
|
||||
* for the same inputs. Cross-language conformance verified by pin tests.
|
||||
* Protocol-critical: must produce identical output to Python's derive_cache_key.
|
||||
* Cross-language conformance verified by pin tests.
|
||||
*
|
||||
* Wire format:
|
||||
* HMAC-SHA256(secret, stableStringify({"c": ctx, "p": params, "r": rev}))
|
||||
* - All keys sorted recursively
|
||||
* - No whitespace (compact JSON)
|
||||
* - "u" key included only for user-scoped content
|
||||
* - All param values stringified
|
||||
* Key format: "ctx:{context}:{hmac_hex}" — enables broad purge by prefix scan.
|
||||
*/
|
||||
|
||||
import { createHmac } from 'crypto'
|
||||
|
||||
const CONTEXT_KEY_PREFIX = 'ctx:'
|
||||
|
||||
/**
|
||||
* JSON.stringify with recursively sorted keys and no whitespace.
|
||||
* Equivalent to Python's json.dumps(obj, sort_keys=True, separators=(",", ":"))
|
||||
@@ -33,7 +30,7 @@ function stableStringify(obj: any): string {
|
||||
/**
|
||||
* Derive a deterministic HMAC-SHA256 cache key.
|
||||
*
|
||||
* Must produce identical output to Python's derive_cache_key for the same inputs.
|
||||
* Returns "ctx:{context}:{hmac_hex}".
|
||||
*/
|
||||
export function deriveCacheKey(
|
||||
secret: string,
|
||||
@@ -42,7 +39,6 @@ export function deriveCacheKey(
|
||||
userId?: string,
|
||||
rev: number = 0,
|
||||
): string {
|
||||
// Stringify all param values for cross-language determinism
|
||||
const sortedParams: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries(params).sort(([a], [b]) => a.localeCompare(b))) {
|
||||
sortedParams[k] = String(v)
|
||||
@@ -54,16 +50,8 @@ export function deriveCacheKey(
|
||||
}
|
||||
|
||||
const message = stableStringify(keyData)
|
||||
return createHmac('sha256', secret).update(message).digest('hex')
|
||||
const hmacHex = createHmac('sha256', secret).update(message).digest('hex')
|
||||
return `${CONTEXT_KEY_PREFIX}${context}:${hmacHex}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build reverse index keys for a cache entry.
|
||||
*/
|
||||
export function buildIndexKeys(context: string, params: Record<string, any>): string[] {
|
||||
const keys = [`mizan:idx:${context}`]
|
||||
for (const [k, v] of Object.entries(params).sort(([a], [b]) => a.localeCompare(b))) {
|
||||
keys.push(`mizan:idx:${context}:${k}=${String(v)}`)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
export { CONTEXT_KEY_PREFIX }
|
||||
|
||||
@@ -212,7 +212,7 @@ export async function handleMutationCall(
|
||||
if (typeof entry === 'string') {
|
||||
cachePurge(cb, entry)
|
||||
} else {
|
||||
cachePurge(cb, entry.context, entry.params)
|
||||
cachePurge(cb, entry.context, entry.params, _cacheSecret)
|
||||
}
|
||||
}
|
||||
} catch { /* purge failure is non-fatal */ }
|
||||
|
||||
@@ -285,7 +285,8 @@ describe('Cache Conformance', () => {
|
||||
const k1 = deriveCacheKey(SECRET, 'user', { user_id: '5' })
|
||||
const k2 = deriveCacheKey(SECRET, 'user', { user_id: '5' })
|
||||
expect(k1).toBe(k2)
|
||||
expect(k1).toHaveLength(64)
|
||||
expect(k1).toStartWith('ctx:user:')
|
||||
expect(k1).toHaveLength('ctx:user:'.length + 64)
|
||||
})
|
||||
|
||||
test('deriveCacheKey param order irrelevant', () => {
|
||||
@@ -298,29 +299,29 @@ describe('Cache Conformance', () => {
|
||||
// These exact values are pinned from Python's derive_cache_key output.
|
||||
// If this test fails, cross-language cache key compatibility is broken.
|
||||
const publicKey = deriveCacheKey(SECRET, 'user', { user_id: '5' }, undefined, 0)
|
||||
expect(publicKey).toBe('605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6')
|
||||
expect(publicKey).toBe('ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6')
|
||||
|
||||
const userScopedKey = deriveCacheKey(SECRET, 'user', { user_id: '5' }, '5', 0)
|
||||
expect(userScopedKey).toBe('30fc08eb46ee4ff2cf7d317e97dca90fd616511e0587304416f71dc863338dc2')
|
||||
expect(userScopedKey).toBe('ctx:user:30fc08eb46ee4ff2cf7d317e97dca90fd616511e0587304416f71dc863338dc2')
|
||||
})
|
||||
|
||||
test('MemoryCache get/put/clear', () => {
|
||||
test('MemoryCache get/set/clear', () => {
|
||||
const cache = new MemoryCache()
|
||||
expect(cache.get('k1')).toBeNull()
|
||||
|
||||
cache.put('k1', '{"data":true}', ['mizan:idx:ctx'])
|
||||
cache.set('k1', '{"data":true}')
|
||||
expect(cache.get('k1')).toBe('{"data":true}')
|
||||
|
||||
cache.clear()
|
||||
expect(cache.get('k1')).toBeNull()
|
||||
})
|
||||
|
||||
test('scoped purge AND semantics', () => {
|
||||
test('scoped purge recomputes key directly', () => {
|
||||
const cache = new MemoryCache()
|
||||
cachePut(SECRET, cache, 'user', { user_id: '5' }, '{"u5":true}')
|
||||
cachePut(SECRET, cache, 'user', { user_id: '6' }, '{"u6":true}')
|
||||
|
||||
const count = cachePurge(cache, 'user', { user_id: '5' })
|
||||
const count = cachePurge(cache, 'user', { user_id: '5' }, SECRET)
|
||||
expect(count).toBe(1)
|
||||
|
||||
expect(cacheGet(SECRET, cache, 'user', { user_id: '5' })).toBeNull()
|
||||
|
||||
Reference in New Issue
Block a user