openvibers/openvibe.ai Commits

Schema cache: the compiled-validator cache shared by every caller (ai.extract/ai.enrich schemas are caller-supplied) is an LRU capped by entry count and total schema bytes (AI_SCHEMA_CACHE_MAX=500, AI_SCHEMA_CACHE_MAX_BYTES=8 MiB); evicted and failed schemas are removed from Ajv's own cache too, and a schema larger than the byte budget is compiled for that call only

OP OpenVibers committed 23 Sept 2026 · 549ec14b29 · 6 files, +146 −8


Changes

ddiff --git a/.env.example b/.env.example
iindex b288e47..ebcd2fd 100644
---- a/.env.example
++++ b/.env.example
@@@ -84,6 +84,9 @@ AI_MAX_COST_USD_PER_DAY=0
#AI_CACHE_TTL_SEC=604800
#AI_MAX_INPUT_BYTES=6291456
#AI_RUN_RETENTION_DAYS=30
++# Compiled-schema cache (LRU shared by every caller; ai.extract/ai.enrich schemas are caller-supplied)
++#AI_SCHEMA_CACHE_MAX=500
++#AI_SCHEMA_CACHE_MAX_BYTES=8388608
# Raw prompt/response logging: opt-in debugging only, and only for runs whose caller sets options.debug
#AI_DEBUG_RAW_LOG=false
#AI_BREAKER_FAILURES=3
ddiff --git a/README.md b/README.md
iindex ac33315..d45bd7d 100644
---- a/README.md
++++ b/README.md
@@@ -142,6 +142,7 @@ stub joins every route as a last resort outside production only (`AI_STUB_FALLBA
| The cache never crosses requester, actor, target or attribution scope | `test/cache.test.js` |
| Idempotency, cancel (running and queued), retry, async polling, audit rows, restart recovery, no raw prompts or inline images kept | `test/runs.test.js` |
| Media fetched only from allow-listed https OpenVibe hosts; DNS answers and every redirect hop re-checked (a public hop never reaches internal Media); size caps | `test/ssrf.test.js` |
++| The shared compiled-schema cache (caller-supplied schemas) is an LRU bounded by count and bytes, in Ajv too | `test/schemas.test.js` |
| Token and capability denial, namespaces, no secret values in any response, runs private to the requester | `test/auth.test.js` |
| Ported adapters against fake OpenAI/Anthropic servers, stub determinism, whisper filter, templates | `test/providers.test.js` |
| Import from a Live snapshot: dry run, holds, idempotent re-run | `test/import.test.js` |
ddiff --git a/server/config.js b/server/config.js
iindex e5d12af..c377b8e 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -140,6 +140,12 @@ function load(env = process.env) {
maxInputBytes: int(env.AI_MAX_INPUT_BYTES, 6 * 1024 * 1024),
retentionDays: int(env.AI_RUN_RETENTION_DAYS, 30),
},
++ // Compiled JSON Schema validators (server/schemas.js): an LRU shared by every caller, some of
++ // whose schemas are caller-supplied, so it is bounded by count and by total schema size.
++ schemaCache: {
++ maxEntries: Math.max(1, int(env.AI_SCHEMA_CACHE_MAX, 500)),
++ maxBytes: Math.max(1024, int(env.AI_SCHEMA_CACHE_MAX_BYTES, 8 * 1024 * 1024)),
++ },
// Raw prompt/response logging is opt-in debugging only, and only for callers that ask.
debugRawLog: bool(env.AI_DEBUG_RAW_LOG, false),
ddiff --git a/server/index.js b/server/index.js
iindex 9b0eb97..e0aba82 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -18,10 +18,12 @@ const { createQuotas } = require('./quota');
const { createRuns } = require('./runs');
const { seed } = require('./workflows/seed');
const { createKeyStore, createAuth } = require('./auth');
++const schemas = require('./schemas');
const { createApp } = require('./app');
async function start({ config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true } = {}) {
config = config || load(env);
++ schemas.configure(config.schemaCache);
const db = openDb(config.dbPath);
const registry = createRegistry(db, { clock, env });
const quotas = createQuotas(db, { clock, registry });
ddiff --git a/server/schemas.js b/server/schemas.js
iindex 6c3e46c..4a81e36 100644
---- a/server/schemas.js
++++ b/server/schemas.js
@@@ -2,20 +2,66 @@
/**
* JSON Schema (2020-12) validation for workflow/template input and output schemas. Compiled
* validators are cached by schema hash, so a new version of a workflow compiles once.
++ *
++ * The cache is shared by every caller and some schemas are caller-supplied (ai.extract / ai.enrich
++ * send their own), so it is an LRU bounded by entry count and by the total size of the cached
++ * schemas (AI_SCHEMA_CACHE_MAX, AI_SCHEMA_CACHE_MAX_BYTES). Ajv keeps its own reference to every
++ * schema it compiles; an evicted entry is removed from Ajv too, so neither side grows without bound.
++ * A schema larger than the whole byte budget is compiled for that call and not kept.
*/
const Ajv2020 = require('ajv/dist/2020');
--const { sha256 } = require('./util');
++const { sha256, stableStringify } = require('./util');
const ajv = new Ajv2020({ allErrors: true, strict: false, allowUnionTypes: true, validateFormats: false });
--const compiled = new Map();
++const compiled = new Map(); // hash -> { fn, schema, bytes }; Map order is recency (oldest first)
++const limits = { maxEntries: 500, maxBytes: 8 * 1024 * 1024 };
++let bytesHeld = 0;
++
++function forget(key) {
++ const e = compiled.get(key);
++ if (!e) return;
++ compiled.delete(key);
++ bytesHeld -= e.bytes;
++ try { ajv.removeSchema(e.schema); } catch { /* not held by Ajv */ }
++}
++
++function trim() {
++ for (const key of compiled.keys()) {
++ if (compiled.size <= limits.maxEntries && bytesHeld <= limits.maxBytes) break;
++ forget(key);
++ }
++}
++
++/** Set the bounds (server/index.js passes config.schemaCache); shrinking evicts at once. */
++function configure({ maxEntries, maxBytes } = {}) {
++ if (Number.isFinite(maxEntries) && maxEntries >= 1) limits.maxEntries = Math.floor(maxEntries);
++ if (Number.isFinite(maxBytes) && maxBytes >= 1) limits.maxBytes = Math.floor(maxBytes);
++ trim();
++}
function compile(schema) {
-- const key = sha256(schema || {});
-- let fn = compiled.get(key);
-- if (!fn) {
-- fn = ajv.compile(schema || {});
-- compiled.set(key, fn);
++ const s = schema || {};
++ const text = stableStringify(s);
++ const key = sha256(text);
++ const hit = compiled.get(key);
++ if (hit) {
++ compiled.delete(key); // most recently used goes to the end
++ compiled.set(key, hit);
++ return hit.fn;
++ }
++ let fn;
++ try { fn = ajv.compile(s); } catch (e) {
++ try { ajv.removeSchema(s); } catch { /* */ }
++ throw e;
++ }
++ const bytes = Buffer.byteLength(text);
++ if (bytes > limits.maxBytes) {
++ try { ajv.removeSchema(s); } catch { /* */ }
++ return fn;
}
++ compiled.set(key, { fn, schema: s, bytes });
++ bytesHeld += bytes;
++ trim();
return fn;
}
@@@ -32,4 +78,9 @@ function validate(schema, value) {
return { valid: Boolean(valid), errors: valid ? [] : (fn.errors || []).slice(0, 20).map(e => ({ path: e.instancePath || '/', message: e.message })) };
}
--module.exports = { compile, assertSchema, validate };
++/** Cache occupancy (tests, status): our entries and bytes, and the schemas Ajv itself still holds. */
++function stats() {
++ return { entries: compiled.size, bytes: bytesHeld, max_entries: limits.maxEntries, max_bytes: limits.maxBytes, ajv_held: ajv._cache ? ajv._cache.size : null };
++}
++
++module.exports = { compile, assertSchema, validate, configure, stats };
ddiff --git a/test/schemas.test.js b/test/schemas.test.js
nnew file mode 100644
iindex 0000000..7979fa9
---- /dev/null
++++ b/test/schemas.test.js
@@@ -0,0 +1,75 @@
++'use strict';
++// The compiled-schema cache is shared by every caller and holds caller-supplied schemas
++// (ai.extract / ai.enrich), so it must stay bounded: an LRU capped by entry count and by total schema
++// size, with evicted schemas released by Ajv too. Validation stays correct across evictions.
++
++const assert = require('assert');
++const { suite, boot, request, token, ALL } = require('./helpers');
++const schemas = require('../server/schemas');
++
++const t = suite('schemas');
++const distinct = (i) => ({ type: 'object', required: [`k${i}`], properties: { [`k${i}`]: { type: 'string' } } });
++
++t.test('the cache never grows past its entry cap, and Ajv lets go of evicted schemas', () => {
++ schemas.configure({ maxEntries: 50, maxBytes: 1024 * 1024 });
++ const base = schemas.stats().ajv_held - schemas.stats().entries; // Ajv's own meta-schemas
++ for (let i = 0; i < 2000; i++) schemas.compile(distinct(i));
++ const s = schemas.stats();
++ assert.ok(s.entries <= 50, `entries ${s.entries}`);
++ assert.ok(s.ajv_held <= base + 50, `Ajv still holds ${s.ajv_held - base} compiled schemas`);
++});
++
++t.test('least recently used goes first; a hit refreshes an entry', () => {
++ schemas.configure({ maxEntries: 3, maxBytes: 1024 * 1024 });
++ const a = schemas.compile(distinct('a'));
++ schemas.compile(distinct('b'));
++ schemas.compile(distinct('c'));
++ assert.strictEqual(schemas.compile(distinct('a')), a, 'a is cached (and now most recent)');
++ schemas.compile(distinct('d')); // evicts b, not a
++ assert.strictEqual(schemas.compile(distinct('a')), a, 'a survived');
++ assert.strictEqual(schemas.stats().entries, 3);
++});
++
++t.test('the byte cap bounds total schema size; an oversized schema is compiled but not kept', () => {
++ schemas.configure({ maxEntries: 1000, maxBytes: 4096 });
++ const big = (i) => ({ type: 'object', description: `${i}`.padEnd(1500, 'x') });
++ for (let i = 0; i < 40; i++) schemas.compile(big(i));
++ const s = schemas.stats();
++ assert.ok(s.bytes <= 4096, `bytes ${s.bytes}`);
++ assert.ok(s.entries <= 3, `entries ${s.entries}`);
++ const huge = { type: 'object', required: ['x'], description: 'y'.repeat(10000) };
++ const before = schemas.stats();
++ assert.strictEqual(schemas.validate(huge, {}).valid, false);
++ assert.strictEqual(schemas.validate(huge, { x: 1 }).valid, true);
++ assert.strictEqual(schemas.stats().entries, before.entries, 'not cached');
++ assert.ok(schemas.stats().ajv_held <= before.ajv_held, 'not held by Ajv either');
++});
++
++t.test('invalid schemas leave nothing behind; evicted schemas still validate correctly', () => {
++ schemas.configure({ maxEntries: 5, maxBytes: 1024 * 1024 });
++ const held = schemas.stats().ajv_held;
++ for (let i = 0; i < 200; i++) assert.throws(() => schemas.assertSchema({ type: 'object', properties: { [`p${i}`]: { type: 'no-such-type' } } }), /does not compile/);
++ assert.ok(schemas.stats().ajv_held <= held, `failed compiles leaked ${schemas.stats().ajv_held - held} schemas into Ajv`);
++ const s0 = distinct(0);
++ assert.strictEqual(schemas.validate(s0, {}).valid, false);
++ for (let i = 1; i < 20; i++) schemas.compile(distinct(i)); // s0 evicted
++ assert.strictEqual(schemas.validate(s0, {}).valid, false);
++ assert.strictEqual(schemas.validate(s0, { k0: 'v' }).valid, true);
++});
++
++t.test('config: AI_SCHEMA_CACHE_MAX / AI_SCHEMA_CACHE_MAX_BYTES reach the cache at boot', async () => {
++ const h = await boot({ env: { AI_SCHEMA_CACHE_MAX: '7', AI_SCHEMA_CACHE_MAX_BYTES: '65536' } });
++ try {
++ const tok = token('live', ALL);
++ for (let i = 0; i < 20; i++) {
++ const r = await request(h.base, 'POST', '/api/v1/extract?wait=3000', { tok, body: { text: `item ${i}`, schema: distinct(`x${i}`), options: { cache: false } } });
++ assert.ok(r.status < 500, `extract ${i}: ${r.status}`);
++ }
++ const s = schemas.stats();
++ assert.strictEqual(s.max_entries, 7);
++ assert.strictEqual(s.max_bytes, 65536);
++ assert.ok(s.entries <= 7, `entries ${s.entries}`);
++ } finally { await h.stop(); }
++});
++
++t.run();