Files
mcpctl/src/mcplocal/tests/file-cache.test.ts

414 lines
14 KiB
TypeScript
Raw Normal View History

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { FileCache, parseMaxSize } from '../src/proxymodel/file-cache.js';
function makeTmpDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'mcpctl-cache-test-'));
}
function rmrf(dir: string): void {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ok */ }
}
describe('FileCache', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = makeTmpDir();
});
afterEach(() => {
rmrf(tmpDir);
});
// -- Basic get/set --
it('returns null on cache miss', async () => {
const cache = new FileCache('test-ns', { dir: tmpDir });
expect(await cache.get('missing')).toBeNull();
});
it('stores and retrieves values', async () => {
const cache = new FileCache('test-ns', { dir: tmpDir });
await cache.set('key1', 'hello world');
expect(await cache.get('key1')).toBe('hello world');
});
it('getOrCompute computes on miss, returns cached on hit', async () => {
const cache = new FileCache('test-ns', { dir: tmpDir });
let calls = 0;
const compute = async () => { calls++; return 'computed'; };
const v1 = await cache.getOrCompute('k', compute);
const v2 = await cache.getOrCompute('k', compute);
expect(v1).toBe('computed');
expect(v2).toBe('computed');
expect(calls).toBe(1);
});
it('hash produces consistent 16-char hex strings', () => {
const cache = new FileCache('test-ns', { dir: tmpDir });
const h1 = cache.hash('hello');
const h2 = cache.hash('hello');
const h3 = cache.hash('world');
expect(h1).toBe(h2);
expect(h1).not.toBe(h3);
expect(h1).toHaveLength(16);
expect(/^[0-9a-f]+$/.test(h1)).toBe(true);
});
// -- Persistence (L2 disk) --
it('persists values to disk across instances', async () => {
const cache1 = new FileCache('persist-ns', { dir: tmpDir });
await cache1.set('pk', 'persistent-value');
// New instance, same namespace — should find it on disk
const cache2 = new FileCache('persist-ns', { dir: tmpDir });
expect(await cache2.get('pk')).toBe('persistent-value');
});
it('creates .dat files on disk', async () => {
const cache = new FileCache('disk-ns', { dir: tmpDir });
await cache.set('mykey', 'data');
const nsDir = path.join(tmpDir, 'disk-ns');
const files = fs.readdirSync(nsDir).filter((f) => f.endsWith('.dat'));
expect(files.length).toBe(1);
});
// -- Namespace isolation --
it('different namespaces are isolated', async () => {
const cacheA = new FileCache('ns-alpha', { dir: tmpDir });
const cacheB = new FileCache('ns-beta', { dir: tmpDir });
await cacheA.set('shared-key', 'alpha-value');
await cacheB.set('shared-key', 'beta-value');
expect(await cacheA.get('shared-key')).toBe('alpha-value');
expect(await cacheB.get('shared-key')).toBe('beta-value');
});
it('provider--model--proxymodel namespaces are separate', async () => {
const ns1 = 'openai--gpt-4o--content-pipeline';
const ns2 = 'anthropic--claude-sonnet-4-20250514--content-pipeline';
const ns3 = 'openai--gpt-4o--default';
const c1 = new FileCache(ns1, { dir: tmpDir });
const c2 = new FileCache(ns2, { dir: tmpDir });
const c3 = new FileCache(ns3, { dir: tmpDir });
await c1.set('k', 'from-gpt4o-pipeline');
await c2.set('k', 'from-claude-pipeline');
await c3.set('k', 'from-gpt4o-default');
expect(await c1.get('k')).toBe('from-gpt4o-pipeline');
expect(await c2.get('k')).toBe('from-claude-pipeline');
expect(await c3.get('k')).toBe('from-gpt4o-default');
// Verify separate directories on disk
const dirs = fs.readdirSync(tmpDir);
expect(dirs.length).toBe(3);
});
// -- L1 memory cache --
it('L1 memory cache has LRU eviction', async () => {
const cache = new FileCache('lru-ns', { dir: tmpDir, maxMemoryEntries: 3 });
await cache.set('a', '1');
await cache.set('b', '2');
await cache.set('c', '3');
expect(cache.memorySize).toBe(3);
await cache.set('d', '4');
expect(cache.memorySize).toBe(3);
// 'a' evicted from memory but still on disk
cache.clearMemory();
expect(await cache.get('a')).toBe('1'); // restored from disk
});
it('get refreshes LRU position in memory', async () => {
const cache = new FileCache('lru2-ns', { dir: tmpDir, maxMemoryEntries: 3 });
await cache.set('a', '1');
await cache.set('b', '2');
await cache.set('c', '3');
// Access 'a' to refresh it
await cache.get('a');
// Adding 'd' should evict 'b' (now oldest), not 'a'
await cache.set('d', '4');
// 'a' should still be in memory
cache.clearMemory();
// All values still on disk regardless
expect(await cache.get('b')).toBe('2');
});
it('clearMemory only clears L1, not disk', async () => {
const cache = new FileCache('clear-ns', { dir: tmpDir });
await cache.set('k', 'val');
expect(cache.memorySize).toBe(1);
cache.clearMemory();
expect(cache.memorySize).toBe(0);
// Still on disk
expect(await cache.get('k')).toBe('val');
expect(cache.memorySize).toBe(1); // re-loaded into L1
});
// -- Static: stats --
it('stats returns empty for non-existent dir', () => {
const stats = FileCache.stats(path.join(tmpDir, 'nonexistent'));
expect(stats.totalEntries).toBe(0);
expect(stats.totalSize).toBe(0);
expect(stats.namespaces).toHaveLength(0);
});
it('stats reports per-namespace breakdown', async () => {
const c1 = new FileCache('ns-one', { dir: tmpDir });
const c2 = new FileCache('ns-two', { dir: tmpDir });
await c1.set('a', 'hello');
await c1.set('b', 'world');
await c2.set('x', 'data');
const stats = FileCache.stats(tmpDir);
expect(stats.totalEntries).toBe(3);
expect(stats.namespaces).toHaveLength(2);
const one = stats.namespaces.find((ns) => ns.name === 'ns-one');
const two = stats.namespaces.find((ns) => ns.name === 'ns-two');
expect(one?.entries).toBe(2);
expect(two?.entries).toBe(1);
expect(stats.totalSize).toBeGreaterThan(0);
});
// -- Static: clear --
it('clear removes all entries', async () => {
const c = new FileCache('clear-all', { dir: tmpDir });
await c.set('a', '1');
await c.set('b', '2');
const result = FileCache.clear({ rootDir: tmpDir });
expect(result.removed).toBe(2);
expect(result.freedBytes).toBeGreaterThan(0);
const stats = FileCache.stats(tmpDir);
expect(stats.totalEntries).toBe(0);
});
it('clear with namespace only removes that namespace', async () => {
const c1 = new FileCache('keep-me', { dir: tmpDir });
const c2 = new FileCache('delete-me', { dir: tmpDir });
await c1.set('a', '1');
await c2.set('b', '2');
const result = FileCache.clear({ rootDir: tmpDir, namespace: 'delete-me' });
expect(result.removed).toBe(1);
const stats = FileCache.stats(tmpDir);
expect(stats.totalEntries).toBe(1);
const withEntries = stats.namespaces.filter((ns) => ns.entries > 0);
expect(withEntries).toHaveLength(1);
expect(withEntries[0].name).toBe('keep-me');
});
// -- Static: cleanup (TTL + size limit) --
it('cleanup evicts entries exceeding maxSizeBytes', async () => {
// Create entries that exceed a 50-byte limit
const c = new FileCache('big-ns', { dir: tmpDir });
await c.set('a', 'x'.repeat(30));
await c.set('b', 'y'.repeat(30));
await c.set('c', 'z'.repeat(30));
const before = FileCache.stats(tmpDir);
expect(before.totalEntries).toBe(3);
// Cleanup with 50-byte limit (well below 90 bytes of content)
const result = FileCache.cleanup(tmpDir, 50, 365 * 24 * 60 * 60 * 1000);
expect(result.removed).toBeGreaterThan(0);
const after = FileCache.stats(tmpDir);
expect(after.totalSize).toBeLessThanOrEqual(50);
});
// -- Keys with special characters --
it('handles keys with colons and special chars', async () => {
const cache = new FileCache('special-ns', { dir: tmpDir });
await cache.set('summary:abc123:200', 'summarized content');
expect(await cache.get('summary:abc123:200')).toBe('summarized content');
});
it('handles very long keys', async () => {
const cache = new FileCache('long-ns', { dir: tmpDir });
const longKey = 'a'.repeat(500);
await cache.set(longKey, 'value');
expect(await cache.get(longKey)).toBe('value');
});
});
// -- parseMaxSize --
describe('parseMaxSize', () => {
it('passes through numbers directly', () => {
expect(parseMaxSize(1024)).toBe(1024);
expect(parseMaxSize(0)).toBe(0);
});
it('parses byte units', () => {
expect(parseMaxSize('100B')).toBe(100);
expect(parseMaxSize('1KB')).toBe(1024);
expect(parseMaxSize('256MB')).toBe(256 * 1024 * 1024);
expect(parseMaxSize('1GB')).toBe(1024 * 1024 * 1024);
expect(parseMaxSize('2TB')).toBe(2 * 1024 * 1024 * 1024 * 1024);
});
it('handles fractional values', () => {
expect(parseMaxSize('1.5GB')).toBe(Math.floor(1.5 * 1024 * 1024 * 1024));
expect(parseMaxSize('0.5MB')).toBe(Math.floor(0.5 * 1024 * 1024));
});
it('is case-insensitive', () => {
expect(parseMaxSize('256mb')).toBe(256 * 1024 * 1024);
expect(parseMaxSize('1gb')).toBe(1024 * 1024 * 1024);
expect(parseMaxSize('1Gb')).toBe(1024 * 1024 * 1024);
});
it('trims whitespace', () => {
expect(parseMaxSize(' 256MB ')).toBe(256 * 1024 * 1024);
expect(parseMaxSize(' 1 GB ')).toBe(1024 * 1024 * 1024);
});
it('parses plain number strings', () => {
expect(parseMaxSize('1048576')).toBe(1048576);
});
it('parses percentage (resolves against filesystem)', () => {
// We can't predict the exact value, but it should be a positive number
const result = parseMaxSize('10%', '/tmp');
expect(result).toBeGreaterThan(0);
expect(typeof result).toBe('number');
});
it('percentage of 100% equals full partition', () => {
const full = parseMaxSize('100%', '/tmp');
const half = parseMaxSize('50%', '/tmp');
// 50% should be roughly half of 100% (within rounding)
expect(Math.abs(half - full / 2)).toBeLessThan(1024);
});
it('throws on invalid specs', () => {
expect(() => parseMaxSize('abc')).toThrow();
expect(() => parseMaxSize('')).toThrow();
expect(() => parseMaxSize('0%')).toThrow();
expect(() => parseMaxSize('101%')).toThrow();
expect(() => parseMaxSize('-5MB')).toThrow();
});
});
// -- Namespace isolation for LLM provider/model/proxymodel combos --
describe('FileCache namespace isolation', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = makeTmpDir();
});
afterEach(() => {
rmrf(tmpDir);
});
const combos = [
{ provider: 'openai', model: 'gpt-4o', proxyModel: 'content-pipeline' },
{ provider: 'openai', model: 'gpt-4o-mini', proxyModel: 'content-pipeline' },
{ provider: 'anthropic', model: 'claude-sonnet-4-20250514', proxyModel: 'content-pipeline' },
{ provider: 'openai', model: 'gpt-4o', proxyModel: 'default' },
{ provider: 'vllm', model: 'qwen-72b', proxyModel: 'content-pipeline' },
];
it('each provider--model--proxymodel combo gets its own cache', async () => {
const caches = combos.map(
(c) => new FileCache(`${c.provider}--${c.model}--${c.proxyModel}`, { dir: tmpDir }),
);
// Write same key with different values to each cache
for (let i = 0; i < caches.length; i++) {
await caches[i].set('summary-key', `value-from-combo-${i}`);
}
// Each reads back its own value
for (let i = 0; i < caches.length; i++) {
expect(await caches[i].get('summary-key')).toBe(`value-from-combo-${i}`);
}
// Verify stats show correct number of namespaces
const stats = FileCache.stats(tmpDir);
expect(stats.namespaces).toHaveLength(combos.length);
expect(stats.totalEntries).toBe(combos.length);
});
it('changing only the model creates a separate cache', async () => {
const c1 = new FileCache('openai--gpt-4o--content-pipeline', { dir: tmpDir });
const c2 = new FileCache('openai--gpt-4o-mini--content-pipeline', { dir: tmpDir });
await c1.set('k', 'gpt4o-result');
await c2.set('k', 'mini-result');
expect(await c1.get('k')).toBe('gpt4o-result');
expect(await c2.get('k')).toBe('mini-result');
});
it('changing only the provider creates a separate cache', async () => {
const c1 = new FileCache('openai--gpt-4o--content-pipeline', { dir: tmpDir });
const c2 = new FileCache('anthropic--gpt-4o--content-pipeline', { dir: tmpDir });
await c1.set('k', 'openai-result');
await c2.set('k', 'anthropic-result');
expect(await c1.get('k')).toBe('openai-result');
expect(await c2.get('k')).toBe('anthropic-result');
});
it('changing only the proxyModel creates a separate cache', async () => {
const c1 = new FileCache('openai--gpt-4o--content-pipeline', { dir: tmpDir });
const c2 = new FileCache('openai--gpt-4o--default', { dir: tmpDir });
await c1.set('k', 'pipeline-result');
await c2.set('k', 'default-result');
expect(await c1.get('k')).toBe('pipeline-result');
expect(await c2.get('k')).toBe('default-result');
});
it('clearing one namespace leaves others intact', async () => {
const c1 = new FileCache('openai--gpt-4o--content-pipeline', { dir: tmpDir });
const c2 = new FileCache('anthropic--claude-sonnet-4-20250514--content-pipeline', { dir: tmpDir });
await c1.set('k', 'v1');
await c2.set('k', 'v2');
FileCache.clear({ rootDir: tmpDir, namespace: 'openai--gpt-4o--content-pipeline' });
// c1's namespace cleared, c2 intact
const fresh1 = new FileCache('openai--gpt-4o--content-pipeline', { dir: tmpDir });
const fresh2 = new FileCache('anthropic--claude-sonnet-4-20250514--content-pipeline', { dir: tmpDir });
expect(await fresh1.get('k')).toBeNull();
expect(await fresh2.get('k')).toBe('v2');
});
});