feat: provider-result caching with time expiry + DSL fact-level TTL
Automatically-retrieved facts (balances, sessions, etc.) are now cached with
a time expiry so repeated checks don't re-invoke the underlying data store.
- check(): registered providers cache their normalized edges per
(relation, subject, object) with a TTL; a fresh entry is reused without
re-invoking the provider. Per-check factProviders are one-off observations
and stay cache-transparent (no read, no write).
- TTL resolution: DSL-declared 'BEHAVES { ttl <duration> }' on a fact >
setFactTTL(relation, ms) > policy.providerCacheTTL (default 30s; 0 disables).
- Grammar: facts may now declare a freshness window via
'fact balance(user, amount) BEHAVES { ttl 1h }' (previously BEHAVES only
accepted 'AS edge|transitive|hierarchical|symmetrical_graph'). The runtime
indexes it as the fact's ttlMs.
- registerFact/unregisterFact, graph mutations (add/update/remove node or
relation), and invalidateProviderCache(relation?) keep the cache consistent.
- Injectable clock (default wall clock) drives cache freshness, mirroring the
core's unpinned-clock contract.
Tests: DSLRuntimeCache (reuse within TTL, expiry re-invoke, per-check
override transparency, registerFact invalidation, mutation invalidation,
per-relation/all invalidation, policy default, DSL-declared 1h TTL).
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* tests/DSLRuntimeCache.test.js — provider-result caching with time expiry.
|
||||
*
|
||||
* Registered providers retrieve missing facts from a data store; caching the
|
||||
* retrieval avoids hammering the store on repeated checks. TTL resolution:
|
||||
* DSL-declared `BEHAVES { ttl <duration> }` on a fact > per-fact setFactTTL >
|
||||
* policy default (30s). Per-check factProviders are cache-transparent (one-off
|
||||
* observations: no cache read, no cache write). Registering a provider or
|
||||
* mutating the graph invalidates the cache.
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Arbiter } from '@arbiter/core';
|
||||
import { DSLRuntime } from '../src/runtime/DSLRuntime.js';
|
||||
|
||||
const BASE_DSL = `
|
||||
definition Employee { id: string }
|
||||
definition Doc { id: string }
|
||||
fact *owns(user: Employee, doc: Doc)
|
||||
evidence can_read(user: Employee, doc: Doc) { owns(user, doc) }
|
||||
`;
|
||||
|
||||
function makeRuntime(options = {}) {
|
||||
let t = 0;
|
||||
const clock = () => t;
|
||||
const rt = new DSLRuntime(new Arbiter(), { clock, ...options }).compile(BASE_DSL, 'rt-cache');
|
||||
rt._test_advance = (ms) => { t += ms; };
|
||||
return rt;
|
||||
}
|
||||
|
||||
describe('DSLRuntime provider-result caching', () => {
|
||||
it('reuses a registered provider result within the TTL', async () => {
|
||||
const rt = makeRuntime();
|
||||
rt.addNode('u:1', 'Employee', {});
|
||||
rt.addNode('doc:9', 'Doc', {});
|
||||
let calls = 0;
|
||||
rt.registerFact('owns', async () => { calls++; return 0.9; });
|
||||
await rt.check('u:1', 'can_read', 'doc:9');
|
||||
await rt.check('u:1', 'can_read', 'doc:9');
|
||||
assert.equal(calls, 1, 'provider should be invoked once within TTL');
|
||||
});
|
||||
|
||||
it('re-invokes the provider after the TTL expires', async () => {
|
||||
const rt = makeRuntime();
|
||||
rt.setFactTTL('owns', 100);
|
||||
rt.addNode('u:1', 'Employee', {});
|
||||
rt.addNode('doc:9', 'Doc', {});
|
||||
let calls = 0;
|
||||
let value = 0.9;
|
||||
rt.registerFact('owns', async () => { calls++; return value; });
|
||||
const first = await rt.check('u:1', 'can_read', 'doc:9');
|
||||
assert.equal(first.possibility, 0.9);
|
||||
rt._test_advance(50);
|
||||
await rt.check('u:1', 'can_read', 'doc:9'); // within TTL -> cached
|
||||
assert.equal(calls, 1);
|
||||
rt._test_advance(60); // past TTL (110 total)
|
||||
value = 0.4;
|
||||
const after = await rt.check('u:1', 'can_read', 'doc:9');
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(after.possibility, 0.4);
|
||||
});
|
||||
|
||||
it('per-check factProviders override the cache (fresh observation)', async () => {
|
||||
const rt = makeRuntime();
|
||||
rt.addNode('u:1', 'Employee', {});
|
||||
rt.addNode('doc:9', 'Doc', {});
|
||||
rt.registerFact('owns', async () => 0.9);
|
||||
await rt.check('u:1', 'can_read', 'doc:9');
|
||||
// Per-check override is cache-transparent: it must NOT be masked by the
|
||||
// cached 0.9, and it must NOT overwrite the cached value.
|
||||
const over = await rt.check('u:1', 'can_read', 'doc:9', {
|
||||
factProviders: { owns: async () => 0.2 }
|
||||
});
|
||||
assert.equal(over.possibility, 0.2);
|
||||
const next = await rt.check('u:1', 'can_read', 'doc:9');
|
||||
assert.equal(next.possibility, 0.9, 'registered provider cache untouched by per-check override');
|
||||
});
|
||||
|
||||
it('registerFact invalidates the cached result for that relation', async () => {
|
||||
const rt = makeRuntime();
|
||||
rt.addNode('u:1', 'Employee', {});
|
||||
rt.addNode('doc:9', 'Doc', {});
|
||||
rt.registerFact('owns', async () => 0.9);
|
||||
await rt.check('u:1', 'can_read', 'doc:9');
|
||||
rt.registerFact('owns', async () => 0.3); // re-register -> cache invalidated
|
||||
const res = await rt.check('u:1', 'can_read', 'doc:9');
|
||||
assert.equal(res.possibility, 0.3);
|
||||
});
|
||||
|
||||
it('invalidates cached results on graph mutations', async () => {
|
||||
const rt = makeRuntime();
|
||||
rt.addNode('u:1', 'Employee', {});
|
||||
rt.addNode('doc:9', 'Doc', {});
|
||||
let calls = 0;
|
||||
rt.registerFact('owns', async () => { calls++; return 0.9; });
|
||||
await rt.check('u:1', 'can_read', 'doc:9');
|
||||
assert.equal(calls, 1);
|
||||
rt.addRelation('u:1', 'owns', 'doc:9', { possibility: 1.0 }); // mutation clears cache
|
||||
const res = await rt.check('u:1', 'can_read', 'doc:9');
|
||||
assert.equal(calls, 2, 'graph mutation should invalidate the provider cache');
|
||||
});
|
||||
|
||||
it('invalidateProviderCache() clears all or per relation', async () => {
|
||||
const dsl = `
|
||||
definition Employee { id: string }
|
||||
definition Doc { id: string }
|
||||
fact *owns(user: Employee, doc: Doc)
|
||||
fact *banned(user: Employee)
|
||||
evidence can_read(user: Employee, doc: Doc) { owns(user, doc) }
|
||||
evidence can_open(user: Employee, doc: Doc) { WHEN can_read(user, doc) UNLESS banned(user) }
|
||||
`;
|
||||
const rt = new DSLRuntime(new Arbiter()).compile(dsl, 'rt-cache2');
|
||||
rt.addNode('u:1', 'Employee', {});
|
||||
rt.addNode('doc:9', 'Doc', {});
|
||||
let ownsCalls = 0, bannedCalls = 0;
|
||||
rt.registerFact('owns', async () => { ownsCalls++; return 0.9; });
|
||||
rt.registerFact('banned', async () => { bannedCalls++; return 0; });
|
||||
await rt.check('u:1', 'can_open', 'doc:9');
|
||||
assert.equal(ownsCalls, 1);
|
||||
assert.equal(bannedCalls, 1);
|
||||
// Invalidate a non-dependency relation: can_open's cache (owns+banned) survives.
|
||||
rt.invalidateProviderCache('does_not_exist');
|
||||
await rt.check('u:1', 'can_open', 'doc:9');
|
||||
assert.equal(ownsCalls, 1);
|
||||
assert.equal(bannedCalls, 1);
|
||||
// Invalidate owns only: banned survives, owns re-fetched.
|
||||
rt.invalidateProviderCache('owns');
|
||||
await rt.check('u:1', 'can_open', 'doc:9');
|
||||
assert.equal(ownsCalls, 2, 'owns cache cleared by per-relation invalidation');
|
||||
assert.equal(bannedCalls, 1, 'banned cache survives per-relation invalidation');
|
||||
// Clear all.
|
||||
rt.invalidateProviderCache();
|
||||
await rt.check('u:1', 'can_open', 'doc:9');
|
||||
assert.equal(bannedCalls, 2, 'full invalidation clears every relation');
|
||||
});
|
||||
|
||||
it('policy default TTL applies when no per-fact TTL is set', async () => {
|
||||
const rt = makeRuntime({ policy: { providerCacheTTL: 50 } });
|
||||
rt.addNode('u:1', 'Employee', {});
|
||||
rt.addNode('doc:9', 'Doc', {});
|
||||
let calls = 0;
|
||||
rt.registerFact('owns', async () => { calls++; return 0.9; });
|
||||
await rt.check('u:1', 'can_read', 'doc:9');
|
||||
rt._test_advance(40);
|
||||
await rt.check('u:1', 'can_read', 'doc:9');
|
||||
assert.equal(calls, 1, 'within 50ms policy TTL -> cached');
|
||||
rt._test_advance(20);
|
||||
await rt.check('u:1', 'can_read', 'doc:9');
|
||||
assert.equal(calls, 2, 'past 50ms policy TTL -> re-invoked');
|
||||
});
|
||||
|
||||
it('uses the DSL-declared fact TTL (BEHAVES { ttl X })', async () => {
|
||||
const dsl = `
|
||||
definition Employee { id: string }
|
||||
definition Doc { id: string }
|
||||
fact *balance(user: Employee, amount: number) BEHAVES { ttl 1h }
|
||||
evidence can_spend(user: Employee, doc: Doc) { balance(user, 1) }
|
||||
`;
|
||||
let t = 0;
|
||||
const rt = new DSLRuntime(new Arbiter(), { clock: () => t }).compile(dsl, 'rt-dsl-ttl');
|
||||
// The DSL declares a 1h TTL for the balance fact.
|
||||
assert.equal(rt.relations.get('balance').ttlMs, 3600_000);
|
||||
rt.addNode('u:1', 'Employee', {});
|
||||
rt.addNode('doc:9', 'Doc', {});
|
||||
let calls = 0;
|
||||
rt.registerFact('balance', async () => { calls++; return { possibility: 1.0, value: 50 }; });
|
||||
await rt.check('u:1', 'can_spend', 'doc:9');
|
||||
assert.equal(calls, 1);
|
||||
t += 60 * 60 * 1000 - 1; // just under 1h
|
||||
await rt.check('u:1', 'can_spend', 'doc:9');
|
||||
assert.equal(calls, 1, 'cached within DSL-declared 1h TTL');
|
||||
t += 2;
|
||||
await rt.check('u:1', 'can_spend', 'doc:9');
|
||||
assert.equal(calls, 2, 're-invoked past the DSL-declared 1h TTL');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user