diff --git a/package.json b/package.json index 55e4c5a..ed2318d 100644 --- a/package.json +++ b/package.json @@ -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": "", diff --git a/src/authorization/AuthorizationChecker.js b/src/authorization/AuthorizationChecker.js index 2ab87bb..fc62735 100644 --- a/src/authorization/AuthorizationChecker.js +++ b/src/authorization/AuthorizationChecker.js @@ -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) { diff --git a/src/authorization/DecisionCache.js b/src/authorization/DecisionCache.js index 5788c35..a65ab90 100644 --- a/src/authorization/DecisionCache.js +++ b/src/authorization/DecisionCache.js @@ -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?.(); } } diff --git a/src/core/Arbiter.js b/src/core/Arbiter.js index 920faf4..b8499d6 100644 --- a/src/core/Arbiter.js +++ b/src/core/Arbiter.js @@ -70,6 +70,16 @@ export class Arbiter { // Direct check cache - use HyperbolicLRUCache for better memory management 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) { @@ -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 `` — 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); diff --git a/src/core/UnifiedKeyManager.js b/src/core/UnifiedKeyManager.js index a6c54a1..4a56ed5 100644 --- a/src/core/UnifiedKeyManager.js +++ b/src/core/UnifiedKeyManager.js @@ -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' } /** diff --git a/src/core/relation/RelationCaches.js b/src/core/relation/RelationCaches.js index 008ffc2..c64ac70 100644 --- a/src/core/relation/RelationCaches.js +++ b/src/core/relation/RelationCaches.js @@ -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) { diff --git a/tests/integration/hyperbolic-lru-invalidation-test.js b/tests/integration/hyperbolic-lru-invalidation-test.js index 38a6425..1b0b400 100644 --- a/tests/integration/hyperbolic-lru-invalidation-test.js +++ b/tests/integration/hyperbolic-lru-invalidation-test.js @@ -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)); diff --git a/tests/rules/decision-cache-port.test.js b/tests/rules/decision-cache-port.test.js index 511a48a..3955682 100644 --- a/tests/rules/decision-cache-port.test.js +++ b/tests/rules/decision-cache-port.test.js @@ -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')); }); });