Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions packages/core/expression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
type ASTNode,
clearExpressionCache,
ExpressionError,
evaluate,
expressionCacheHas,
expressionCacheSize,
parse,
TokenType,
tokenize,
Expand Down Expand Up @@ -689,3 +692,91 @@ describe("security -- no eval or Function constructor", () => {
expect(stripped).not.toMatch(/new\s+Function\s*\(/);
});
});

// =============================================================================
// AST cache (issue #46, Candidate 2)
//
// evaluate() caches the tokenize/parse result per raw template string so the
// hot path skips re-parsing. The cache must be transparent: a cached evaluation
// is identical to an uncached one, distinct templates never collide, and
// eviction at the soft cap never corrupts a result. evaluateNode still runs per
// call against the live context.
// =============================================================================

describe("expression AST cache", () => {
const EXPRESSION_CACHE_CAP = 1000;

it("caches the parsed AST: re-evaluating a template does not grow the cache", () => {
clearExpressionCache();
const tmpl = "{{ output.score > 0.5 }}";
evaluate(tmpl, { output: { score: 0.9 } });
evaluate(tmpl, { output: { score: 0.1 } });
evaluate(tmpl, { output: { score: 0.7 } });
expect(expressionCacheSize()).toBe(1);
});

it("returns correct per-context results for a cached template", () => {
clearExpressionCache();
const tmpl = "{{ output.score > 0.5 }}";
// First call populates the cache; subsequent calls hit it.
expect(evaluate(tmpl, { output: { score: 0.9 } })).toBe(true);
expect(evaluate(tmpl, { output: { score: 0.1 } })).toBe(false);
expect(evaluate(tmpl, { output: { score: 0.5 } })).toBe(false);
expect(evaluate(tmpl, { output: { score: 0.51 } })).toBe(true);
});

it("does not collide across distinct templates", () => {
clearExpressionCache();
const ctx = { output: { score: 0.4, count: 10 } };
// Interleave two distinct templates; each must keep its own AST.
expect(evaluate("{{ output.score > 0.5 }}", ctx)).toBe(false);
expect(evaluate("{{ output.count > 5 }}", ctx)).toBe(true);
expect(evaluate("{{ output.score > 0.5 }}", ctx)).toBe(false);
expect(evaluate("{{ output.count > 5 }}", ctx)).toBe(true);
expect(expressionCacheSize()).toBe(2);
});

it("evicts at the soft cap without exceeding it", () => {
clearExpressionCache();
for (let i = 0; i < EXPRESSION_CACHE_CAP + 50; i++) {
evaluate(`{{ output.v == ${i} }}`, { output: { v: i } });
}
expect(expressionCacheSize()).toBe(EXPRESSION_CACHE_CAP);
});

it("re-evaluates an evicted template correctly", () => {
clearExpressionCache();
const firstTmpl = "{{ output.v == 0 }}";
// Prime, then overflow the cap so the first template is evicted (LRU).
evaluate(firstTmpl, { output: { v: 0 } });
for (let i = 1; i <= EXPRESSION_CACHE_CAP + 10; i++) {
evaluate(`{{ output.v == ${i} }}`, { output: { v: i } });
}
// Evicted: must re-parse and still produce correct per-context results.
expect(evaluate(firstTmpl, { output: { v: 0 } })).toBe(true);
expect(evaluate(firstTmpl, { output: { v: 9 } })).toBe(false);
});

it("evicts the least-recently-used entry, not the oldest-inserted (LRU not FIFO)", () => {
clearExpressionCache();
const hot = "{{ output.v == 0 }}"; // inserted first
const firstFiller = "{{ output.v == 1 }}"; // inserted second
evaluate(hot, { output: { v: 0 } });
// Fill to exactly the cap with distinct templates, touching `hot` after
// each so it stays most-recently-used while `firstFiller` ages out.
for (let i = 1; i < EXPRESSION_CACHE_CAP; i++) {
evaluate(`{{ output.v == ${i} }}`, { output: { v: i } });
evaluate(hot, { output: { v: 0 } });
}
expect(expressionCacheSize()).toBe(EXPRESSION_CACHE_CAP);

// One more distinct template forces a single eviction. Under LRU the
// victim is `firstFiller` (least recently used) and `hot` survives; under
// FIFO the victim would be `hot` (oldest inserted). Membership, not value,
// is what distinguishes the two policies (a re-parse yields the same value).
evaluate(`{{ output.v == ${EXPRESSION_CACHE_CAP} }}`, { output: { v: EXPRESSION_CACHE_CAP } });
expect(expressionCacheSize()).toBe(EXPRESSION_CACHE_CAP);
expect(expressionCacheHas(hot)).toBe(true);
expect(expressionCacheHas(firstFiller)).toBe(false);
});
});
65 changes: 63 additions & 2 deletions packages/core/expression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -600,9 +600,70 @@ function evaluateNode(node: ASTNode, context: ExpressionContext): unknown {
// Public API
// -----------------------------------------------------------------------------

export function evaluate(template: string, context: ExpressionContext): unknown {
/**
* Soft cap on the number of cached ASTs. When exceeded, the least-recently-used
* entry is evicted. Templates are reused verbatim rarely enough that a workflow
* never approaches this; the cap only bounds memory for pathological callers.
*/
const AST_CACHE_CAP = 1000;

/**
* Module-level cache of parsed ASTs keyed on the raw template string.
*
* The expression engine is pure (no eval/Function, and evaluateNode never
* mutates the AST), so a parsed AST is valid forever and safe to share across
* calls and contexts. Map insertion order gives us LRU for free: a cache hit
* re-inserts the key (now most-recently-used), and eviction removes the first
* (oldest) key.
*/
const astCache = new Map<string, ASTNode>();

/**
* Resolve the AST for a template, parsing and caching on a miss. Only
* successful parses are cached; a malformed template re-throws on every call,
* identical to the uncached behaviour.
*/
function getCachedAst(template: string): ASTNode {
const cached = astCache.get(template);
if (cached !== undefined) {
// Mark as most-recently-used.
astCache.delete(template);
astCache.set(template, cached);
return cached;
}

const expr = extractExpression(template);
const tokens = tokenize(expr);
const ast = parse(tokens);
return evaluateNode(ast, context);

if (astCache.size >= AST_CACHE_CAP) {
const oldest = astCache.keys().next().value;
if (oldest !== undefined) {
astCache.delete(oldest);
}
}
astCache.set(template, ast);
return ast;
}

/** Clear the AST cache. Primarily for tests and memory-sensitive callers. */
export function clearExpressionCache(): void {
astCache.clear();
}

/** Current number of cached ASTs. Primarily for tests and diagnostics. */
export function expressionCacheSize(): number {
return astCache.size;
}

/**
* Whether a template's AST is currently cached. Does not affect LRU recency
* (a pure read). Primarily for tests and diagnostics.
*/
export function expressionCacheHas(template: string): boolean {
return astCache.has(template);
}

export function evaluate(template: string, context: ExpressionContext): unknown {
return evaluateNode(getCachedAst(template), context);
}