Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6e3ae1922 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@arbiter/core",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"description": "Arbiter core engine: graph indices, relation/reachability, authorization rule evaluator, DSL/AST, condensed & sharded snapshots, and evidence fusion.",
|
||||
"license": "ISC",
|
||||
"author": "",
|
||||
|
||||
@@ -967,6 +967,15 @@ export class AuthorizationChecker {
|
||||
if (!this.decisionCache.directEnabled) return;
|
||||
const cacheKey = this._getDirectCheckCacheKey(userKey, relation, objectKey);
|
||||
this.decisionCache.setDirect(cacheKey, result);
|
||||
// Track the (hashed) key under the checked relation + its base relations
|
||||
// + the subject/object nodes so relation- and node-level invalidation can
|
||||
// delete it without parsing the key.
|
||||
const arb = this.arbiter;
|
||||
if (arb && typeof arb._trackDirectCheckKey === 'function') {
|
||||
const srcId = arb.resolveNodeId(userKey);
|
||||
const dstId = arb.resolveNodeId(objectKey);
|
||||
arb._trackDirectCheckKey(relation, srcId, dstId, cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
_getVisitedMode(visited) {
|
||||
|
||||
@@ -174,32 +174,9 @@ export class DecisionCache {
|
||||
if (!a?.directCheckCache) return;
|
||||
const nodeId = a.nodeIdByKey?.get(nodeKey);
|
||||
if (nodeId === undefined) return;
|
||||
const cache = a.directCheckCache;
|
||||
|
||||
// Pattern-based invalidation is the safest path — the cache file
|
||||
// owns its storage layout and decides how to enumerate keys.
|
||||
if (typeof cache.invalidateByPattern === 'function') {
|
||||
// Match a nodeId that appears in any position of the composite
|
||||
// key. The composite key format is `${srcId}|${rel}|${dstId}` and
|
||||
// components are pipe-delimited, so a digit-boundary regex is
|
||||
// safer than a plain substring match.
|
||||
const escaped = String(nodeId).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pattern = new RegExp(`(?:^|\\|)${escaped}(?:\\||$)`);
|
||||
cache.invalidateByPattern(pattern);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: explicit key iteration. HyperbolicLRUCache does not
|
||||
// expose `keys()`, so this branch is unreachable for that cache
|
||||
// family. If a future cache implementation exposes iteration,
|
||||
// we walk it without leaking storage-layout details.
|
||||
if (typeof cache.keys === 'function') {
|
||||
for (const key of cache.keys()) {
|
||||
if (typeof key === 'string' && key.includes(String(nodeId))) {
|
||||
cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Keys are rolling hashes (not pipe-delimited strings), so node-level
|
||||
// invalidation uses the per-node key index populated at cache-set time.
|
||||
a._invalidateDirectCheckCacheByNode?.(nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,6 +187,9 @@ export class DecisionCache {
|
||||
const a = this.arbiter;
|
||||
if (!a) return;
|
||||
a.directCheckCache?.clear?.();
|
||||
a.directCheckCacheKeysByRelation?.clear?.();
|
||||
a.directCheckCacheKeysByNode?.clear?.();
|
||||
a.directKeyToRelations?.clear?.();
|
||||
a.invalidateAllRuleResultCache?.();
|
||||
}
|
||||
}
|
||||
|
||||
+90
-10
@@ -71,6 +71,16 @@ export class Arbiter {
|
||||
this.directCheckCacheSize = options.directCheckCacheSize || 10000;
|
||||
this.directCheckCacheTTL = options.directCheckCacheTTL || 60000; // 1 minute
|
||||
|
||||
// Direct-check cache key indexes (keys are rolling hashes, so relation-
|
||||
// and node-level invalidation cannot parse them): relation -> keys,
|
||||
// nodeId -> keys, key -> relations. Populated via _trackDirectCheckKey.
|
||||
this.directCheckCacheKeysByRelation = new Map();
|
||||
this.directCheckCacheKeysByNode = new Map();
|
||||
this.directKeyToRelations = new Map();
|
||||
// Reverse of dependencyIndex: checked relation -> Set of base relations it
|
||||
// reads (so a base-relation mutation invalidates overridden-config keys).
|
||||
this.dependencyIndexByRelation = new Map();
|
||||
|
||||
// Only create cache if caching is not disabled
|
||||
if (!this.disableCaching && !this.disableDirectCaching) {
|
||||
this.directCheckCache = this.cacheFactory(this.directCheckCacheSize, {
|
||||
@@ -226,6 +236,14 @@ export class Arbiter {
|
||||
this.dependencyIndex.set(baseRel, entry);
|
||||
}
|
||||
entry.all.add(relation);
|
||||
// Reverse map: the checked relation reads baseRel, so a baseRel
|
||||
// mutation must invalidate this relation's direct-check keys.
|
||||
let bases = this.dependencyIndexByRelation.get(relation);
|
||||
if (!bases) {
|
||||
bases = new Set();
|
||||
this.dependencyIndexByRelation.set(relation, bases);
|
||||
}
|
||||
bases.add(baseRel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -433,20 +451,23 @@ export class Arbiter {
|
||||
for (const rel of entry.all) affected.add(rel);
|
||||
}
|
||||
|
||||
// Keys are rolling hashes (no pipe-delimited string form to match), so
|
||||
// invalidation uses the per-relation key index populated at cache-set
|
||||
// time: every direct-check result is tracked under the CHECKED relation
|
||||
// AND its base relations (via _trackDirectCheckKey), so deleting the
|
||||
// tracked keys for an affected relation covers overridden-config checks.
|
||||
for (const rel of affected) {
|
||||
// Composite cache keys are `<stringId|relationId|stringId>` — the
|
||||
// middle component is the relation ID, so match on that (a name-based
|
||||
// pattern can never hit). This also covers keys built from either
|
||||
// string ids or node ids, since only the middle component matters.
|
||||
if (typeof this.directCheckCache.invalidateByPattern === 'function') {
|
||||
const relId = this.keyManager._getRelationId(rel);
|
||||
const pattern = new RegExp(`^[^|]*\\|${relId}\\|[^|]*$`);
|
||||
this.directCheckCache.invalidateByPattern(pattern);
|
||||
const keys = this.directCheckCacheKeysByRelation.get(rel);
|
||||
if (!keys) continue;
|
||||
for (const key of keys) {
|
||||
this._deleteFromCache(this.directCheckCache, key);
|
||||
this.directKeyToRelations.delete(key);
|
||||
}
|
||||
keys.clear();
|
||||
}
|
||||
|
||||
// Explicit composite-key deletes (kept for cache implementations that
|
||||
// lack invalidateByPattern).
|
||||
// Explicit composite-key deletes (recomputed hashes) — cheap belt for the
|
||||
// exact (src, relation, dst) pair.
|
||||
const srcId = this.keyManager.getStringId(srcKey);
|
||||
const dstId = this.keyManager.getStringId(dstKey);
|
||||
const key1 = this.keyManager.createCompositeKey(srcId, relation, dstId);
|
||||
@@ -462,6 +483,65 @@ export class Arbiter {
|
||||
this.invalidateRuleResultCacheByRelation(relation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a direct-check cache key so relation- and node-level invalidation
|
||||
* can find it without parsing the (hashed) key. Tracked under the CHECKED
|
||||
* relation and its base relations (so a base-relation mutation invalidates
|
||||
* overridden-config checks) and under the subject/object node ids.
|
||||
*/
|
||||
_trackDirectCheckKey(relation, srcNodeId, dstNodeId, cacheKey) {
|
||||
if (!cacheKey) return;
|
||||
const rels = new Set([relation]);
|
||||
const baseEntry = this.dependencyIndexByRelation.get(relation);
|
||||
if (baseEntry) for (const base of baseEntry) rels.add(base);
|
||||
for (const rel of rels) {
|
||||
let set = this.directCheckCacheKeysByRelation.get(rel);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.directCheckCacheKeysByRelation.set(rel, set);
|
||||
}
|
||||
set.add(cacheKey);
|
||||
}
|
||||
if (srcNodeId !== undefined && srcNodeId !== null) {
|
||||
let set = this.directCheckCacheKeysByNode.get(srcNodeId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.directCheckCacheKeysByNode.set(srcNodeId, set);
|
||||
}
|
||||
set.add(cacheKey);
|
||||
}
|
||||
if (dstNodeId !== undefined && dstNodeId !== null && dstNodeId !== srcNodeId) {
|
||||
let set = this.directCheckCacheKeysByNode.get(dstNodeId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.directCheckCacheKeysByNode.set(dstNodeId, set);
|
||||
}
|
||||
set.add(cacheKey);
|
||||
}
|
||||
let byKey = this.directKeyToRelations.get(cacheKey);
|
||||
if (!byKey) {
|
||||
byKey = new Set();
|
||||
this.directKeyToRelations.set(cacheKey, byKey);
|
||||
}
|
||||
for (const rel of rels) byKey.add(rel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate direct-check entries involving a node id (node removal).
|
||||
*/
|
||||
_invalidateDirectCheckCacheByNode(nodeId) {
|
||||
if (!this.directCheckCache || nodeId === undefined) return;
|
||||
const keys = this.directCheckCacheKeysByNode.get(nodeId);
|
||||
if (!keys) return;
|
||||
for (const key of keys) {
|
||||
this._deleteFromCache(this.directCheckCache, key);
|
||||
const rels = this.directKeyToRelations.get(key);
|
||||
if (rels) for (const rel of rels) this.directCheckCacheKeysByRelation.get(rel)?.delete(key);
|
||||
this.directKeyToRelations.delete(key);
|
||||
}
|
||||
keys.clear();
|
||||
}
|
||||
|
||||
_cacheRuleResult(relation, cacheKey) {
|
||||
if (!this.ruleResultCache || !cacheKey) return;
|
||||
let set = this.ruleResultCacheKeysByRelation.get(relation);
|
||||
|
||||
@@ -90,38 +90,41 @@ export class UnifiedKeyManager {
|
||||
}
|
||||
|
||||
const relationId = this._getRelationId(relation);
|
||||
return `${srcId}|${relationId}|${dstId}`;
|
||||
// Rolling hash (no string concatenation). Exact-key caches recompute the
|
||||
// key to delete; the direct-check cache additionally tracks keys by
|
||||
// relation/node so relation-level invalidation does not need the
|
||||
// pipe-delimited string form.
|
||||
return this._rollingHash(srcId, relationId, dstId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create source-relation key
|
||||
* @param {number} srcId - Source ID
|
||||
* @param {string} relation - Relation name
|
||||
* @returns {string} Composite key
|
||||
* @returns {number} Rolling-hash key
|
||||
*/
|
||||
createSrcRelKey(srcId, relation) {
|
||||
if (srcId > this.options.maxSrcId) {
|
||||
throw new Error(`Source ID ${srcId} exceeds max range ${this.options.maxSrcId}`);
|
||||
}
|
||||
const relationId = this._getRelationId(relation);
|
||||
// Direction marker: fromSrc and toDst lookups share the relation lookup
|
||||
// cache, and identical `id|relId` keys made the second-direction lookup
|
||||
// return the first direction's cached edges.
|
||||
return `${srcId}|${relationId}|s`;
|
||||
// Direction marker keeps fromSrc and toDst lookups distinct (identical
|
||||
// srcId|relationId pairs previously shared keys across directions).
|
||||
return this._rollingHash(srcId, relationId, 0x73); // 's'
|
||||
}
|
||||
|
||||
/**
|
||||
* Create destination-relation key
|
||||
* @param {number} dstId - Destination ID
|
||||
* @param {string} relation - Relation name
|
||||
* @returns {number} Composite key
|
||||
* @returns {number} Rolling-hash key
|
||||
*/
|
||||
createDstRelKey(dstId, relation) {
|
||||
if (dstId > this.options.maxDstId) {
|
||||
throw new Error(`Destination ID ${dstId} exceeds max range ${this.options.maxDstId}`);
|
||||
}
|
||||
const relationId = this._getRelationId(relation);
|
||||
return `${dstId}|${relationId}|d`;
|
||||
return this._rollingHash(dstId, relationId, 0x64); // 'd'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -66,11 +66,11 @@ export class RelationCaches {
|
||||
}
|
||||
|
||||
makeValueCacheKeyBySrc(srcId, relationId) {
|
||||
return `${srcId}|${relationId}`;
|
||||
return this.manager.arbiter.keyManager._rollingHash(srcId, relationId, 0x5352); // 'SR'
|
||||
}
|
||||
|
||||
makeValueCacheKeyByDst(dstId, relationId) {
|
||||
return `${dstId}|${relationId}`;
|
||||
return this.manager.arbiter.keyManager._rollingHash(dstId, relationId, 0x4454); // 'DT'
|
||||
}
|
||||
|
||||
deleteFromCache(cache, key) {
|
||||
|
||||
@@ -151,9 +151,14 @@ describe('HyperbolicLRUCache Invalidation', () => {
|
||||
const bobTouchingAlice = arbiter.keyManager.createCompositeKey(bobId, 'can_read', aliceId);
|
||||
const carolKey = arbiter.keyManager.createCompositeKey(carolId, 'can_read', bobId);
|
||||
|
||||
// Direct-check keys are rolling hashes; invalidation-by-node uses the
|
||||
// per-node key index, so every injected key must be tracked.
|
||||
arbiter.directCheckCache.set(aliceKey, { result: { possibility: 0.8 }, timestamp: Date.now() });
|
||||
arbiter._trackDirectCheckKey('can_read', aliceId, bobId, aliceKey);
|
||||
arbiter.directCheckCache.set(bobTouchingAlice, { result: { possibility: 0.9 }, timestamp: Date.now() });
|
||||
arbiter._trackDirectCheckKey('can_read', bobId, aliceId, bobTouchingAlice);
|
||||
arbiter.directCheckCache.set(carolKey, { result: { possibility: 0.7 }, timestamp: Date.now() });
|
||||
arbiter._trackDirectCheckKey('can_read', carolId, bobId, carolKey);
|
||||
|
||||
assert.ok(arbiter.directCheckCache.has(aliceKey));
|
||||
assert.ok(arbiter.directCheckCache.has(bobTouchingAlice));
|
||||
|
||||
@@ -127,18 +127,25 @@ describe('invalidateByNodeKey', () => {
|
||||
const bobId = arbiter.resolveNodeId('user:bob');
|
||||
const carolId = arbiter.resolveNodeId('user:carol');
|
||||
|
||||
// Real cache-key format: `${srcId}|${rel}|${dstId}` (no prefix).
|
||||
cache.setDirect(`${aliceId}|member_of|${bobId}`, { reason: 'a-b' });
|
||||
cache.setDirect(`${bobId}|member_of|${aliceId}`, { reason: 'b-a' });
|
||||
cache.setDirect(`${carolId}|member_of|${bobId}`, { reason: 'c-b' });
|
||||
// Keys are rolling hashes; node-level invalidation uses the per-node
|
||||
// key index, so every injected key must be tracked.
|
||||
const keyAB = arbiter.keyManager.createCompositeKey(aliceId, 'member_of', bobId);
|
||||
const keyBA = arbiter.keyManager.createCompositeKey(bobId, 'member_of', aliceId);
|
||||
const keyCB = arbiter.keyManager.createCompositeKey(carolId, 'member_of', bobId);
|
||||
cache.setDirect(keyAB, { reason: 'a-b' });
|
||||
arbiter._trackDirectCheckKey('member_of', aliceId, bobId, keyAB);
|
||||
cache.setDirect(keyBA, { reason: 'b-a' });
|
||||
arbiter._trackDirectCheckKey('member_of', bobId, aliceId, keyBA);
|
||||
cache.setDirect(keyCB, { reason: 'c-b' });
|
||||
arbiter._trackDirectCheckKey('member_of', carolId, bobId, keyCB);
|
||||
cache.setDirect('unrelated-key', { reason: 'u' });
|
||||
|
||||
cache.invalidateByNodeKey('user:alice');
|
||||
|
||||
// Both alice-involving entries cleared; the others remain.
|
||||
assert.equal(cache.getDirect(`${aliceId}|member_of|${bobId}`), undefined);
|
||||
assert.equal(cache.getDirect(`${bobId}|member_of|${aliceId}`), undefined);
|
||||
assert.ok(cache.getDirect(`${carolId}|member_of|${bobId}`));
|
||||
assert.equal(cache.getDirect(keyAB), undefined);
|
||||
assert.equal(cache.getDirect(keyBA), undefined);
|
||||
assert.ok(cache.getDirect(keyCB));
|
||||
assert.ok(cache.getDirect('unrelated-key'));
|
||||
});
|
||||
});
|
||||
@@ -187,19 +194,26 @@ describe('invalidateByNodeKey', () => {
|
||||
arbiter.addNode('user:bob', 'user');
|
||||
const aliceId = arbiter.resolveNodeId('user:alice');
|
||||
const bobId = arbiter.resolveNodeId('user:bob');
|
||||
// Pre-populate cache with a key that contains alice's id
|
||||
arbiter.decisionCache.setDirect(`${aliceId}|member_of|${bobId}`, { reason: 'stale' });
|
||||
arbiter.decisionCache.setDirect(`${bobId}|member_of|${aliceId}`, { reason: 'stale' });
|
||||
arbiter.decisionCache.setDirect(`${aliceId}|other|${bobId}`, { reason: 'stale' });
|
||||
// Pre-populate cache with keys touching alice (tracked so node-level
|
||||
// invalidation can find them)
|
||||
const k1 = arbiter.keyManager.createCompositeKey(aliceId, 'member_of', bobId);
|
||||
const k2 = arbiter.keyManager.createCompositeKey(bobId, 'member_of', aliceId);
|
||||
const k3 = arbiter.keyManager.createCompositeKey(aliceId, 'other', bobId);
|
||||
arbiter.decisionCache.setDirect(k1, { reason: 'stale' });
|
||||
arbiter._trackDirectCheckKey('member_of', aliceId, bobId, k1);
|
||||
arbiter.decisionCache.setDirect(k2, { reason: 'stale' });
|
||||
arbiter._trackDirectCheckKey('member_of', bobId, aliceId, k2);
|
||||
arbiter.decisionCache.setDirect(k3, { reason: 'stale' });
|
||||
arbiter._trackDirectCheckKey('other', aliceId, bobId, k3);
|
||||
arbiter.decisionCache.setDirect('unrelated', { reason: 'keep' });
|
||||
|
||||
// Trigger invalidation through the NodeManager path
|
||||
arbiter.nodeManager.updateNodeData('user:alice', { foo: 'bar' });
|
||||
|
||||
// All alice-bearing keys should be gone; unrelated key remains.
|
||||
assert.equal(arbiter.decisionCache.getDirect(`${aliceId}|member_of|${bobId}`), undefined);
|
||||
assert.equal(arbiter.decisionCache.getDirect(`${bobId}|member_of|${aliceId}`), undefined);
|
||||
assert.equal(arbiter.decisionCache.getDirect(`${aliceId}|other|${bobId}`), undefined);
|
||||
assert.equal(arbiter.decisionCache.getDirect(k1), undefined);
|
||||
assert.equal(arbiter.decisionCache.getDirect(k2), undefined);
|
||||
assert.equal(arbiter.decisionCache.getDirect(k3), undefined);
|
||||
assert.ok(arbiter.decisionCache.getDirect('unrelated'));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user