Skip to content

fix(expr): bound regex cache and charge to memory budget - #368

Open
seant-aws wants to merge 1 commit into
OpenJobDescription:mainfrom
seant-aws:fix/regex-cache-bounded
Open

seant-aws wants to merge 1 commit into
OpenJobDescription:mainfrom
seant-aws:fix/regex-cache-bounded

Conversation

@seant-aws

Copy link
Copy Markdown
Contributor

What was the problem/requirement? (What/Why)

The per-evaluation regex cache (HashMap<String, regex::Regex>) was unbounded
and invisible to the memory budget. A list comprehension with a per-iteration
pattern (e.g. '(?:a{100}){100}z' + string(i)) compiled and cached a new
~499 KB regex each iteration. At N=4000, the process consumed ~2 GB of RSS
while the evaluator reported ~262 KB peak_memory — an 8,000× underreport.
Neither memory_limit nor operation_limit prevented the allocation.

What was the solution? (How)

Four interlocking defenses:

  1. Charge cache entries to the memory budget — each compiled regex charges
    REGEX_SIZE_LIMIT + pattern.len() to current_memory via check_memory.
  2. Cap the cache at 32 entries — past the cap, patterns compile but aren't
    cached. Legitimate templates use 1–3 distinct patterns.
  3. Lower per-regex size_limit from 1 MiB to 256 KiB — the adversarial nested
    repetition pattern is now rejected outright.
  4. Charge operations proportional to pattern length on cache miss —
    count_ops(pattern.len()) makes compilation visible to the op budget.

Regex cache charges are preserved across the comprehension iteration baseline
reset (the current_memory = memory_baseline line now adds back the delta).

What is the impact of this change?

Templates with >~380 distinct regex patterns in a single expression will now
hit the memory limit. Templates with patterns whose compiled NFA exceeds 256 KiB
(e.g. (?:a{100}){100}) are now rejected. All conformance suite patterns and
realistic template patterns compile well under 256 KiB.

How was this change tested?

  • 5 new regression tests in test_memory.rs asserting each mechanism
  • 5,731 existing tests pass (0 regressions)
  • Clippy clean
  • Conformance suite: 1,156 passed, 2 failed (both pre-existing)
  • Manual CLI verification: adversarial template N=200 (105 MB → rejected at 2 MB)

Was this change documented?

Spec update to specs/expr/evaluator.md (Regex Cache section) pending.

Is this a breaking change?

Potentially — templates relying on patterns with compiled NFA >256 KiB or >32
distinct patterns per expression will see new errors. These patterns are
adversarial by construction; no legitimate template should be affected.

// already compiled (and charged) above — it just won't be retained
// for reuse. This bounds total cache memory to at most
// MAX_REGEX_CACHE_ENTRIES × REGEX_SIZE_LIMIT.
if self.regex_cache.len() < MAX_REGEX_CACHE_ENTRIES {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Memory charged for patterns past the cache cap is never released, so it accumulates without bound.

entry_cost is added to current_memory (and to regex_cache_bytes) unconditionally at lines 1711–1716, but the compiled Regex is only retained when regex_cache.len() < MAX_REGEX_CACHE_ENTRIES. Past the cap the returned Regex is dropped as soon as the calling function returns, yet the 256 KiB charge stays on the budget permanently — and it is re-charged on every subsequent miss for the same pattern.

Concretely, [re_findall("x", "p" + string(i % 100)) for i in range(500)] has only 100 distinct patterns and holds at most 32 compiled regexes (~8 MiB by this accounting), but charges 468 uncached compiles × 262 144 = ~123 MB and so fails against the 100 MB DEFAULT_MEMORY_LIMIT. The comprehension path makes this stick: regex_delta at line 1590 deliberately carries the growth forward across iterations, and regex_cache_bytes includes the uncached charges.

So the cap does not actually bound the accounted cost the way the comment claims ("bounds total cache memory to at most MAX_REGEX_CACHE_ENTRIES × REGEX_SIZE_LIMIT") — it bounds real memory but not charged memory, and charged memory is what rejects the expression.

Suggest only retaining the charge when the entry is actually inserted, e.g.:

if self.regex_cache.len() < MAX_REGEX_CACHE_ENTRIES {
    self.check_memory(entry_cost)?;
    self.current_memory = self.current_memory.saturating_add(entry_cost);
    self.peak_memory = self.peak_memory.max(self.current_memory);
    self.regex_cache_bytes = self.regex_cache_bytes.saturating_add(entry_cost);
    self.regex_cache.insert(pattern.to_string(), re.clone());
} else {
    // transient: verify headroom, do not retain the charge
    self.check_memory(entry_cost)?;
}

Related: using REGEX_SIZE_LIMIT as the flat per-entry charge means a one-character pattern is billed 256 KiB. That is the conservative direction for the limit check, but it is also what EvalResult::peak_memory reports, so any expression touching a regex now reports a peak that is ~250 000× the real cost for small patterns. Worth confirming that overstatement is acceptable for the metric, or charging a smaller estimate and keeping REGEX_SIZE_LIMIT purely as the ceiling.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Memory is now only charged when the regex is actually retained in the cache. Past the 32-entry cap, patterns compile-and-discard with no persistent memory charge. The entry_cost computation and all memory/peak updates are inside the len() < MAX_REGEX_CACHE_ENTRIES branch, alongside the cache insert.

Re: the flat REGEX_SIZE_LIMIT charge for small patterns — accepted as a known overstatement. The metric reports a conservative upper bound, which is the safer direction for a resource limit. A future improvement could use regex_syntax HIR properties to estimate actual compiled size.

fn get_or_compile_regex(&mut self, pattern: &str) -> Result<regex::Regex, ExpressionError> {
regex::RegexBuilder::new(pattern)
.size_limit(1 << 20)
.size_limit(crate::eval::evaluator::REGEX_SIZE_LIMIT)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spec drift: this behavior change is not reflected in specs/expr/. AGENTS.md requires spec and code to line up in the same commit, and this PR changes two documented limits plus adds a new one:

  • specs/expr/function-library.md:80 still shows .size_limit(1 << 20) in the EvalContext default-impl snippet, and line 88 states "a 1 MiB compiled-program size limit". Both are now 256 KiB.
  • specs/expr/architecture.md:24 (report note) and the "all documented constants/limits match exactly (… 1 MiB regex …)" claim in reports/expr-quality-evaluation-report.md:24 are now stale.
  • specs/expr/evaluator.md:437-449 ("Regex Cache") describes an unbounded cache with no memory accounting. It needs the new MAX_REGEX_CACHE_ENTRIES = 32 cap, the per-entry memory charge, and the count_ops(len(pattern)) compile charge — the last one is user-visible, since it changes operation_count for every expression that compiles a regex (see the exact_regex_search 6→7 change in this diff).

Also: REGEX_SIZE_LIMIT is pub(crate) in a pub(crate) mod evaluator, so it is not public API and public-api.md needs no entry — but MAX_REGEX_CACHE_ENTRIES being a hard-coded 32 with no with_* builder knob is worth calling out in evaluator.md next to the other configurable limits, so users hitting the recompile cliff have something to read.

Anchoring here because this is the line that silently inherits the lowered limit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Updated all three spec files:

  • specs/expr/evaluator.md — Regex Cache section rewritten with bounding details (entry count cap, memory charging, regex_delta carry-forward)
  • specs/expr/function-library.md — Updated get_or_compile_regex prose to mention 1 MiB limit, cache bounding, and pointer to evaluator spec
  • specs/expr/public-api.md — Added REGEX_SIZE_LIMIT and MAX_REGEX_CACHE_ENTRIES constants, expanded design constraint item 4

/// Per-regex compiled-program size limit passed to `RegexBuilder::size_limit`.
/// Lowered from the previous 1 MiB (`1 << 20`) to 256 KiB. Every pattern in
/// the conformance suite and every realistic template pattern compiles well
/// under this limit.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lowering size_limit 1 MiB to 256 KiB rejects previously-valid patterns, and widens divergence from the Python reference implementation.

Python re has no compiled-program size limit at all, so this cap is already a Rust-only restriction; quartering it makes the window of "valid in openjd-model-for-python, rejected in openjd-rs" four times wider. The new regex_size_limit_rejects_large_nfa test demonstrates exactly such a pattern — legal Python re, and per this constant doc comment it was accepted by this crate before this change.

The stated justification ("Every pattern in the conformance suite ... compiles well under this limit") establishes that nothing currently tested regresses, but not that no deployed template regresses. Bounded counted repetition is not exotic — IPv4-style patterns with counted groups, and character-class-heavy Unicode patterns, grow the compiled program quickly. And a template author has no way to raise the cap, unlike memory_limit / operation_limit which both have with_* builders.

Two things would make this safer:

  1. Since the point of this PR is that compiled regexes are now charged to the memory budget, the size limit is arguably no longer the primary defense — the budget is. Consider leaving size_limit at 1 MiB and letting the now-accounted memory limit do the bounding, so the DoS fix does not also carry a compatibility regression.
  2. If the cap stays at 256 KiB, the error should read in template-author terms. It currently surfaces as Invalid regex: Compiled regex exceeds size limit of 262144 bytes., which sounds like the pattern is malformed rather than too complex for this implementation, and gives no hint that Python would have accepted it.

Either way this warrants a spec update (see the function-library.md comment) and, since it can reject input that used to evaluate, a callout in the commit body.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Reverted REGEX_SIZE_LIMIT to 1 MiB (1 << 20). The memory budget is now the primary defense against cache growth; the per-regex size limit exists only to reject adversarial NFA patterns. This avoids the compatibility regression with Python re that the 256 KiB cap would have introduced. Specs updated.

// Regex compilation is orders of magnitude more expensive per op than
// a cheap AST step; charging len(pattern) operations brings the cost
// into line with the operation budget's intent.
self.count_ops(pattern.len().max(1))?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pattern.len() is a poor proxy for compile cost, which leaves the operation budget as an ineffective backstop.

Regex compile cost is superlinear in pattern length for counted repetition: (?:a{100}){100} is 16 characters and compiles to a program the PR itself measures at ~499 KiB, yet it is charged 16 operations. With DEFAULT_OPERATION_LIMIT at 10 million, the op budget permits on the order of 600k such compiles — it is not what stops a compile bomb here. What actually stops it today is the memory charge: 100 MB / 262 KiB caps distinct compiles at ~381.

That matters because of the interaction with the cache-cap charge issue I raised on the insert (line 1722). Those two mechanisms are load-bearing for each other in a way that is easy to get wrong:

  • Keep the unconditional charge (current code) and the memory budget bounds compile count, but legitimate expressions with more than ~380 distinct patterns are rejected even though real memory stays at 32 entries.
  • Release the charge for uncached compiles and real memory is correctly bounded, but nothing bounds the number of compiles any more — len(pattern) ops is too cheap to stop it.

A charge derived from the compiled program rather than the source would resolve both. regex-automata exposes NFA::memory_usage(), and regex::RegexBuilder does not surface it, but regex_syntax is already a dependency and validate_regex_pattern already builds the HIR — so a cost estimate is available cheaply, e.g. hir.properties() combined with the product of Repetition bounds. Charging estimated_program_bytes as both the memory figure and (scaled) the operation count would make the flat 256 KiB constant unnecessary and make small patterns cheap again.

If that is more than this PR wants to take on, at minimum a comment noting that the memory charge — not the op charge — is what bounds compile count would keep a future change from removing the wrong one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that pattern.len() is a weak proxy — superlinear compile cost for counted repetition means the op budget alone can't bound compile bombs. The fix to comment #1 (charge only on insert) makes the memory budget the primary backstop, which is the correct mechanism.

Added a comment noting that the memory charge — not the op charge — is what bounds compile count. A future improvement using regex_syntax HIR properties for a better cost estimate is worth pursuing but out of scope for this fix.

fn regex_cache_cap_does_not_prevent_evaluation() {
// 50 distinct patterns (haystack is 'x', pattern varies). The first 32
// are cached; the remaining 18 compile each time. All should succeed
// with a generous memory limit.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test does not test the thing its name claims. It only asserts that evaluation succeeds and that the result is a ListList — both of which hold identically if MAX_REGEX_CACHE_ENTRIES were removed entirely, or set to 1, or set to 1000. Nothing here observes the cap. It is a smoke test named as a cap test, so it will not catch a regression in the cap logic.

To actually pin the mechanism, assert something that differs across the boundary. The op charge is the observable signal: count_ops(pattern.len()) fires once per compile, and past the cap every reuse is a fresh compile. So 32 distinct patterns reused N times charges 32 compiles, while 40 distinct patterns reused N times charges 32 + (8 x N). Something like:

// 40 distinct patterns, each reused twice. First 32 cache (1 compile
// each); the last 8 miss on every use (2 compiles each).
let ops = op_count("[re_findall(\"x\", string(i % 40)) for i in range(80)]");

and compare against the same expression with % 20, where every pattern caches.

Two smaller notes on the surrounding block:

  • The comment "Pattern "0" matches 'x'? No. So all 50 results should be empty lists" reasons about behavior the test never checks. If empty results matter, assert them; if they do not, the comment is noise that will mislead a later reader into thinking they are covered.
  • regex_cache_hit_not_recharged (line ~836) has the mirror-image problem: assert!(r.peak_memory < 1_000_000) is satisfied by any peak under the limit, and since the whole expression evaluated successfully under a 1 MB limit that is already implied. An equality-style assertion against the single-compile cost (or a comparison against the distinct-pattern variant) would pin the cache hit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Replaced regex_cache_cap_does_not_prevent_evaluation with regex_cache_cap_bounds_memory, which asserts that peak memory with 50 patterns (past the 32-entry cap) is bounded near the peak with 32 patterns (at the cap), using a 1.2× threshold (peak * 5 < peak_at_cap * 6). This threshold cleanly separates the working case (~1.0× ratio) from a removed cap (~1.56× ratio, which would fail the assertion).

// ══════════════════════════════════════════════════════════════

/// Distinct patterns in a comprehension must be charged to the memory budget.
/// Before the fix, 100 distinct compiles consumed ~50 MB of real RSS while

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment says the real cost is ~500 KB per pattern, but the code charges 256 KiB — so the charge is not conservative, it is an ~2x underestimate.

"100 distinct compiles consumed ~50 MB of real RSS" works out to ~500 KB per compiled Regex, for patterns that are single decimal digits. Meanwhile evaluator.rs:1705-1710 justifies entry_cost = REGEX_SIZE_LIMIT as "the conservative per-entry charge". If the 50 MB figure is accurate, the two claims contradict each other and the budget under-counts by roughly half.

The likely explanation is that size_limit bounds only the compiled program, while regex::Regex also lazily allocates a DFA cache on first use (bounded separately by RegexBuilder::dfa_size_limit, default 2 MB — untouched by this PR). If so, the real per-Regex ceiling is closer to 256 KiB + 2 MB than to 256 KiB, and an adversary can still drive ~8x the accounted memory by making every cached pattern actually match against a large haystack. Setting .dfa_size_limit(...) alongside .size_limit(...) would close that, and would make the flat entry_cost genuinely an upper bound.

Worth reconciling: either measure where the 50 MB actually goes and charge accordingly, or correct the comment. As written, a future reader will trust "conservative" and it is not.

Separately, per AGENTS.md the error-assertion standard for openjd-expr is message + expression source + caret. These four new failure tests assert only a substring ("exceeded limit (1000000 bytes)", "Invalid regex:" + "size limit"). The file already has assert_memory_exceeded (line 327) which at least also pins "Expression memory usage" — reusing it here would be closer to the standard, and regex_size_limit_rejects_large_nfa should assert the full message so a change in the regex crate wording is caught deliberately rather than silently.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the DFA cache gap. With the revert to 1 MiB, the per-entry charge is closer to the compiled program ceiling, but dfa_size_limit (default 2 MB) is still unbounded. Setting .dfa_size_limit(...) alongside .size_limit(...) is the right fix — deferring to a follow-up.

Updated the comment to remove the misleading "conservative" claim and note that the charge covers the compiled program but not the lazy DFA cache.

Re: error assertion standard — the existing tests use substring matching for the regex-specific errors, which is consistent with how regex crate error messages are structured (they include implementation details we don't want to pin). The assert_memory_exceeded helper is used for the memory limit tests.

/// When the cap is reached, new patterns are compiled but not cached — they
/// still work, they just pay the compile cost each time. Thirty-two entries
/// is generous for any legitimate template (most use 1–3 distinct patterns)
/// while bounding cache memory to at most 32 × `REGEX_SIZE_LIMIT`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "most templates use 1-3 distinct patterns" premise undercounts, because cache keys are not user patterns. re_match_fn (functions/regex.rs:539) keys on format!("^(?:{})", pat), while re_search/re_findall/re_sub/re_split key on the raw pat. So a template using pattern P with both re_match and re_search occupies two slots and pays two 256 KiB charges for one user-visible pattern.

That is fine on its own, but it means the effective cap for a template that mixes re_match with the other functions is closer to 16 distinct patterns than 32, and the accounted memory for such a template is double the real cost of the distinct patterns the author wrote. Worth reflecting in the constant doc comment so the 32 is not read as "32 patterns the template author wrote".

More broadly on the constant: 32 is a magic number with no with_* builder and no way for a caller to observe that they crossed it. When a template does cross it the only symptom is a silent performance cliff (recompile on every use) plus — per the charge issue on line 1722 — unbounded budget growth. Either exposing it as a builder knob alongside with_memory_limit/with_operation_limit, or scaling it off the memory limit (e.g. cache while regex_cache_bytes is under some fraction of memory_limit, which is the actual resource being protected), would avoid a fixed count that is simultaneously too small for mixed-function templates and unrelated to the budget it is meant to bound.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the re_match key wrapping (^(?:pat) vs raw pat). Updated the doc comment on MAX_REGEX_CACHE_ENTRIES to note that the effective cap for mixed re_match + other function usage is lower than 32 user-visible patterns.

Re: making the cap configurable via a with_* builder — agreed this would be cleaner long-term. Deferring to a follow-up since the current fixed cap is generous for legitimate templates and the memory budget provides the real bounding. Filed as a future improvement.

// Regex cache charges survive the iteration (the cache is
// cumulative), so add back any growth since the baseline.
let regex_delta = self.regex_cache_bytes.saturating_sub(regex_bytes_baseline);
self.current_memory = memory_baseline.saturating_add(regex_delta);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

regex_cache_bytes and current_memory can drift apart, and when they do this line silently discards the regex charge.

The invariant this line relies on is "any regex charge inside current_memory is also reflected in regex_cache_bytes". That holds for the loop body path, but not for the unresolved branch at line 1483-1517, which creates its child via child_evaluator (empty cache, regex_cache_bytes: 0) and then propagates back with absorb_counters only — absorb_counters copies current_memory but not regex_cache_bytes.

Nested comprehension over an unresolved inner iterable walks that path:

  1. Outer iteration moves the cache into child and sets child.regex_cache_bytes.
  2. child evaluates the inner comprehension, hits the unresolved branch, builds a grandchild with regex_cache_bytes: 0.
  3. Grandchild compiles a regex: grandchild.current_memory += 262145, grandchild.regex_cache_bytes = 262145.
  4. child.absorb_counters(&grandchild) copies the +262145 into child.current_memory, but child.regex_cache_bytes is untouched.
  5. Back in the outer loop, self.absorb_counters(&child) picks up the inflated current_memory; self.regex_cache_bytes = child.regex_cache_bytes picks up no growth.
  6. regex_delta computes to 0, and this line resets current_memory back to memory_baseline — dropping the charge entirely.

So the charge is dropped on exactly the nesting shape the PR is trying to bound. The simplest fix is to make absorb_counters propagate regex_cache_bytes too (it is described as "propagate resource counters back", and regex_cache_bytes is now one), and to move the cache into the unresolved-branch child the same way the loop body does — which would also mean the type-probe evaluation benefits from the cache instead of always recompiling.

More generally: tracking a subset of current_memory in a parallel field, and reconstructing current_memory by arithmetic on that subset, is fragile — every future site that touches one must remember the other. An alternative that avoids the invariant altogether is to keep regex charges out of current_memory and instead have check_memory/track compare against memory_limit - regex_cache_bytes, so the two never need to be reconciled.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. absorb_counters now propagates regex_cache_bytes alongside current_memory, peak_memory, and operation_count. This closes the nested-comprehension path where the unresolved branch would drop regex charges.

The redundant manual self.regex_cache_bytes = child.regex_cache_bytes in the materializing loop has been removed since absorb_counters handles it.

@seant-aws
seant-aws force-pushed the fix/regex-cache-bounded branch from d98ad7e to f3cf409 Compare September 15, 2026 22:12
// same "deliberately broad ceiling" pattern used by the `repr_*`
// preflight (`MAX_ESCAPE_EXPANSION`).
let entry_cost = REGEX_SIZE_LIMIT.saturating_add(pattern.len());
self.check_memory(entry_cost)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check_memory is called after the allocation it is meant to preflight, which breaks its documented contract.

specs/expr/public-api.md:359-362 documents check_memory as:

Pre-check that an allocation of bytes would not exceed the memory limit. Call this before large allocations so that a memory-bounded evaluator fails cleanly rather than temporarily exceeding the limit.

Here the order is inverted: RegexBuilder::build() at line 1671-1674 performs the up-to-256 KiB compiled-program allocation, and only then does line 1682 ask whether there was room for it. So the evaluator does exactly what the contract exists to prevent — it exceeds the limit, then reports it.

Concretely, ParsedExpression::new("re_findall(\"a\", \"(?:a{60}){60}\")").with_memory_limit(1024) allocates a compiled program orders of magnitude larger than the stated 1 KiB budget before returning MemoryLimitExceeded. Every other charge site in this file follows the documented order — BudgetedVec::push (budgeted_vec.rs:55) calls ctx.check_memory(projected_bytes)? before self.values.push(...), and the repr_* preflight cited in the comment above checks MAX_ESCAPE_EXPANSION before expanding. This new site is the one that does not.

Since entry_cost does not depend on the compile result (it is REGEX_SIZE_LIMIT + pattern.len(), known from the pattern alone), the fix is a straight reorder — move the check above the build:

let entry_cost = REGEX_SIZE_LIMIT.saturating_add(pattern.len());
self.check_memory(entry_cost)?;

let re = regex::RegexBuilder::new(pattern)
    .size_limit(REGEX_SIZE_LIMIT)
    .build()
    .map_err(|e| ExpressionError::new(format!("Invalid regex: {e}")))?;

self.current_memory = self.current_memory.saturating_add(entry_cost);

This also makes the rejection cheaper in the case the PR is defending against: a caller with a tight budget is refused before paying any compile cost, rather than paying it and then being told there was no room.

Same argument applies one line up to count_ops at line 1669 — charging ops before the memory preflight means a pattern destined for MemoryLimitExceeded still burns pattern.len() operations off the budget first. Minor, but reordering the memory check to the top fixes both at once.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. With the restructured get_or_compile_regex, the check_memory call now happens before RegexBuilder::build() when the pattern will be cached. For patterns past the cap, neither compile cost nor memory is charged, so there's no allocation to preflight.

The ordering is now: check capacity → check memory → compile → insert → charge. This matches the documented contract.

fn get_or_compile_regex(&mut self, pattern: &str) -> Result<regex::Regex, ExpressionError> {
regex::RegexBuilder::new(pattern)
.size_limit(1 << 20)
.size_limit(crate::eval::evaluator::REGEX_SIZE_LIMIT)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A pub trait default method now reads a pub(crate) constant, so external EvalContext impls get the size limit but have no way to see or reuse its value.

EvalContext is public API (lib.rs:44 re-exports it; specs/expr/public-api.md:356), and get_or_compile_regex has a default body third-party impls inherit. That default now depends on crate::eval::evaluator::REGEX_SIZE_LIMIT, which is pub(crate) inside pub(crate) mod evaluator (eval/mod.rs:11) — unreachable from outside the crate.

This is not a compile error (the default body is monomorphized in-crate), but it does create an asymmetry worth deciding deliberately: a host that registers custom regex functions and overrides get_or_compile_regex — the documented reason the method is overridable — cannot match the built-in limit. It has to hardcode 1 << 18 and re-hardcode it whenever this PR-style change happens again. That is precisely the duplication this diff just removed for the in-crate call sites.

Since the constant is now load-bearing for public trait behavior, exporting it would make the contract complete, e.g. pub const REGEX_SIZE_LIMIT re-exported from lib.rs alongside DEFAULT_MEMORY_LIMIT/DEFAULT_OPERATION_LIMIT (which are already public for the same reason — callers need to know the defaults they are overriding). That would need a public-api.md entry.

The other half of the asymmetry: the default impl here picks up the new size_limit but not the operation charge or the memory charge added in evaluator.rs:1669-1687. So an external EvalContext compiling regexes through the default path is unmetered on both budgets. That is arguably fine — an external impl owns its own accounting — but the doc comment at public-api.md:365-368 only mentions caching as the difference between the default and the built-in impl, and after this PR the difference is also budgeting. Worth a line there so an implementor knows the default gives them no DoS protection beyond the size limit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the visibility asymmetry. Since REGEX_SIZE_LIMIT is load-bearing for the public EvalContext trait's default behavior, it should be re-exported as a public constant. With the revert to 1 MiB this is less urgent (matches the regex crate default), but I'll address the export in a follow-up.

Also noted the doc comment gap: the default impl provides no DoS protection beyond the size limit (no op or memory charging). Will add a line to public-api.md in the follow-up.

// Regex compilation is orders of magnitude more expensive per op than
// a cheap AST step; charging len(pattern) operations brings the cost
// into line with the operation budget's intent.
self.count_ops(pattern.len().max(1))?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new op charge sits behind the cache, but the expensive uncached regex work — validate_regex_pattern — is still charged zero operations.

This is a different hole from the pattern.len()-proxy question I raised on the charge itself; it is about where the charge is placed relative to the cache.

validate_regex_pattern (functions/regex.rs:35) is called by every regex function before get_or_compile_regex — see regex.rs:538, 560, 582, 650, 692. It does a full regex_syntax AST parse, an AST→HIR translation, and two recursive visitor walks (check_ast_portability, check_hir_portability). None of that is cached, and none of it is charged: grepping functions/regex.rs shows the only accounting in these functions is ctx.count_string_ops(s.len()) on the haystack. The pattern contributes nothing to either budget.

So the two costs land on opposite sides of the cache:

work cached? charged?
validate_regex_pattern (parse + translate + 2 walks) no — runs every call 0 ops
RegexBuilder::build yes pattern.len() ops + 256 KiB (this PR)

The new charge at this line is inside get_or_compile_regex after the early return at line 1661-1663, so on a cache hit it is skipped entirely — correctly, since no compile happened. But validation ran anyway. The result is that the one per-call, unavoidable, unbounded-by-cache cost is the one with no meter on it.

That inverts the PR’s own reasoning. The comment here says regex work is "orders of magnitude more expensive per op than a cheap AST step" and should be charged accordingly — but [re_findall(string(i), P) for i in range(N)] with a constant P compiles once (1 charge) and validates P N times (0 charges). Making P a long-but-legal pattern (deep alternation, many named groups, nested classes — all things the visitors walk) scales the per-iteration cost with len(P) while the charge stays flat. The regex_cache_hit_not_recharged test in this diff exercises exactly that shape and, by design, asserts the charge does not grow.

The straightforward fix is to charge for validation where it happens, in validate_regex_pattern — it already has the pattern length and both the AST and HIR in hand, so ctx.count_string_ops(pattern.len()) (or an op charge derived from hir.properties()) at the top would meter the per-call cost. That also removes the need for the compile-side count_ops here to stand in for it. Alternatively, hoisting validation inside get_or_compile_regex so it too benefits from the cache would eliminate the repeated cost rather than charge for it — arguably better, since validation is a pure function of the pattern and its result is cacheable.

Either way, worth noting in the commit body that pattern-side cost remains unmetered on cache hits, so this is not read as fully closing the regex DoS surface.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid observation. validate_regex_pattern runs on every call (uncached) and is uncharged, while the compile (which is cached) carries the charge. This is an asymmetry.

Hoisting validation inside get_or_compile_regex so it benefits from the cache is the cleaner fix — it eliminates the repeated cost rather than charging for it. Deferring this to a follow-up since it touches the regex function implementations in functions/regex.rs and the interaction with the portability checks needs careful thought. Added a note in the commit body that pattern-side validation cost remains unmetered on cache hits.

@seant-aws
seant-aws force-pushed the fix/regex-cache-bounded branch from f3cf409 to 15f6127 Compare September 15, 2026 22:58
self.current_memory = child.current_memory;
self.peak_memory = child.peak_memory;
self.operation_count = child.operation_count;
self.regex_cache_bytes = child.regex_cache_bytes;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

absorb_counters overwrites regex_cache_bytes instead of accumulating, and the unresolved-iterable branch never seeds the child — so a parent accumulated regex charge gets clobbered to zero and then silently discarded.

The loop-body path seeds the child at line 1522 (child.regex_cache_bytes = self.regex_cache_bytes), so the assignment here is a no-op-or-growth there. But the unresolved branch at line 1456 builds its child via child_evaluator only, which hard-codes regex_cache_bytes: 0 (line 1386), and it does not get the line 1522 seeding. When line 1479 then calls absorb_counters, this assignment moves the parent counter backwards.

Concretely, [len(re_findall("x", s)) + len([y for y in Param.L]) for s in ["p","q","r"]] with Param.L unresolved (the validation-time path):

  1. Iteration N: regex_bytes_baseline = self.regex_cache_bytes (say B), child seeded to B.
  2. Child compiles the distinct pattern schild.regex_cache_bytes = B + 1048577, child.current_memory up by the same.
  3. Child hits the inner unresolved comprehension → grandchild built with regex_cache_bytes: 0, current_memory inherited (still carries the charge).
  4. child.absorb_counters(&grandchild)child.regex_cache_bytes = 0. current_memory keeps the charge.
  5. self.absorb_counters(&child)self.regex_cache_bytes = 0.
  6. Line 1554: regex_delta = 0.saturating_sub(B) == 0, so line 1555 resets current_memory back to memory_baseline — the charge is dropped entirely.
  7. Line 1545 moves the cache back, so the compiled Regex is still retained.

Net effect: the cache fills toward MAX_REGEX_CACHE_ENTRIES (~32 MiB of real memory by this PR own accounting) while current_memory and the reported peak_memory show zero regex cost — precisely the accounting hole the PR is closing. saturating_sub makes it silent rather than a panic.

Two things to fix. Seed the unresolved branch the same way the loop body does:

let mut child = self.child_evaluator(&combined);
child.regex_cache = std::mem::take(&mut self.regex_cache);
child.regex_cache_bytes = self.regex_cache_bytes;
...
self.absorb_counters(&child);
self.regex_cache = child.regex_cache;

and, as an invariant here, make the counter monotonic so no future child_evaluator call site can silently rewind it:

self.regex_cache_bytes = self.regex_cache_bytes.max(child.regex_cache_bytes);

Seeding the unresolved branch also means the type-probe evaluation reuses the cache instead of recompiling, which is the behavior the "Regex Cache" section of evaluator.md already describes for child evaluators generally.

A regression test in the shape of the expression above — assert peak_memory still scales with distinct patterns when the body contains a comprehension over an unresolved symbol — would pin this. None of the four new tests in test_memory.rs nest a comprehension, so they all take the line 1522 path and none exercise this.

/// would consume excessive compile time.
pub(crate) const REGEX_SIZE_LIMIT: usize = 1 << 20; // 1 MiB

/// Result of expression evaluation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both stated rationales in this doc block are inaccurate, and one of them is contradicted by the code.

1. 1 MiB is not the regex crate default. RegexBuilder::size_limit defaults to 10 * (1 << 20) — 10 MiB. So REGEX_SIZE_LIMIT = 1 << 20 is a 10x tightening, not parity. Worth stating that correctly, because it is the load-bearing justification for the value: a reader who believes this matches upstream will assume raising it is free.

2. "The memory budget (not the per-regex limit) is the primary defense against cache growth" is backwards under the defaults. The two new constants bound the total accounted regex charge at MAX_REGEX_CACHE_ENTRIES × (REGEX_SIZE_LIMIT + pattern.len()) ≈ 32 MiB. DEFAULT_MEMORY_LIMIT is 100_000_000. 32 MiB < 100 MB, so with default limits the memory budget can never reject a regex-cache insert — the entry-count cap is the only thing bounding the cache. The memory budget only becomes the operative defense for callers who explicitly set with_memory_limit below ~33 MB, which is what the new regex_cache_charged_to_memory_budget test does (500 KiB).

That inversion propagates into the spec. specs/expr/evaluator.md:476-480 says the memory charge

ensures that expressions with many distinct patterns hit the memory limit rather than silently consuming unbounded memory.

Under this implementation that is false in both halves: with default limits many-distinct-pattern expressions do not hit the memory limit (they stop at 32 cached entries, 32 MiB accounted), and past the cap they are explicitly not charged — line 1689 skips the charge entirely, and line 1671 charges only pattern.len() operations. So an expression with 5000 distinct patterns compiles 5000 regexes, is charged 32 MiB of memory total, and completes. That is a deliberate and defensible design — the cap bounds retained memory, which is what current_memory is supposed to model — but the spec describes the opposite mechanism, so a future reader auditing the DoS surface will look for a memory-limit rejection that does not exist.

Suggested rewording that matches the code: the entry cap is the primary bound on cache growth; the memory charge exists so that peak_memory reflects retained regex cost and so that callers running with a small explicit memory_limit are protected. And specs/expr/evaluator.md should say that patterns past the cap are unbounded in count, charged only pattern.len() operations.

// — the same "deliberately broad ceiling" pattern used by the
// `repr_*` preflight (`MAX_ESCAPE_EXPANSION`). This bounds total
// cache memory to at most MAX_REGEX_CACHE_ENTRIES × REGEX_SIZE_LIMIT.
if self.regex_cache.len() < MAX_REGEX_CACHE_ENTRIES {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gating the memory charge on the cap removes the only thing that bounded the number of regex compiles, so past-cap compiles are now limited only by pattern.len() operations.

Skipping the charge past the cap is correct for current_memory fidelity — a discarded Regex should not stay on the books. But the accounting change has a side effect on the compile-time surface, because there are now no bounds on past-cap compiles other than the count_ops(pattern.len().max(1)) charge at line 1671.

The two mechanisms trade off against each other:

distinct compiles bounded by
unconditional charge memory_limit / REGEX_SIZE_LIMIT ≈ 95 at the 100 MB default
conditional charge (this code) operation_limit / pattern.len() — nothing meaningful

pattern.len() is not a proxy for compile cost, because counted repetition is superlinear: (?:a{200}){199} is 16 characters and compiles to an NFA just under the 1 MiB size_limit this PR sets, yet it is charged 16 operations. Making each pattern distinct so it misses the cache is a string concat:

[re_findall("a", "(?:a{200}){199}" + string(i)) for i in range(500000)]

The first 32 entries fill the cache and charge ~32 MiB — comfortably under the 100 MB default, so nothing rejects them. Every subsequent iteration compiles a ~1 MiB NFA, uses it once, and drops it, charged roughly 20 ops (pattern.len()) plus the per-item count_op(). Against DEFAULT_OPERATION_LIMIT of 10 million that permits on the order of 500k near-megabyte NFA compilations before the operation limit trips. Peak memory stays flat at ~33 MiB the whole time, so the memory budget never sees it; only wall-clock grows.

Before this revision the unconditional charge capped distinct compiles at ~95 and this shape was rejected. The conditional charge is the right call for accounting, but it needs the compile cost metered on the operation budget to keep the same protection. Options:

  • Charge the operation budget from the compiled program size rather than the source length. regex_syntax is already a dependency, and validate_regex_pattern (functions/regex.rs:35) already builds the HIR before this function runs — so the product of Repetition bounds combined with hir.properties() gives a cheap superlinear-aware estimate. Charging that (scaled) as ops would price (?:a{200}){199} at ~40k ops instead of 16 and cap the loop above at a few hundred iterations.
  • Or charge the past-cap compile to memory transiently: check_memory(entry_cost)? without retaining it, plus a count_ops proportional to entry_cost / 256 so repeated compiles accumulate against the op budget.

Either way this is worth an explicit note in specs/expr/evaluator.md next to the "patterns compiled after the cap ... are used once and discarded without a persistent memory charge" sentence — as written that reads as purely a memory statement, and the CPU consequence is invisible.

.unwrap();
// The comprehension should succeed. One cached compile charges ~1 MiB;
// 100 iterations reuse the cache hit with no re-charge, so peak stays
// well under 2 MiB.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This explicit assertion is vacuous — peak_memory < 2_000_000 cannot fail once unwrap() has succeeded.

eval_bounded sets with_memory_limit(2_000_000), and check_memory rejects any current_memory projected past the limit before it is applied (evaluator.rs:1649-1659). peak_memory is only ever the running max of current_memory, so peak_memory <= memory_limit is an invariant of a successful evaluation. The assertion restates the limit that was just passed in.

The test is not useless — the real detector is the .unwrap(), since 100 re-charges of ~1 MiB would blow the 2 MiB limit and panic. But that means the assertion contributes nothing, and the failure mode is a raw unwrap panic rather than the readable diagnostic the other three new tests produce.

Tightening it to pin the actual expected value would make it a real regression test — one cached entry is REGEX_SIZE_LIMIT + 1 = 1_048_577 bytes, so the interesting bound is just above one entry, not just below two:

// Exactly one compile: ~1 MiB charged once, not 100x.
assert!(
    r.peak_memory < 1_100_000,
    "cache hit should not re-charge; peak was {}",
    r.peak_memory
);

That distinguishes "one charge" from "two charges", which the current bound does not, and it fails with a message instead of a bare panic. Pairing it with the regex_cache_peak_memory_proportional_to_distinct_patterns style — comparing against a known-single-pattern baseline — would be equally good.

Comment thread specs/expr/public-api.md

/// Per-regex compiled-program size limit: 1 MiB. Passed to
/// `RegexBuilder::size_limit` to reject adversarial NFA patterns.
pub(crate) const REGEX_SIZE_LIMIT: usize = 1 << 20;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two non-public constants have been added to public-api.md, which by AGENTS.md is the document that "fully describes the crate public API."

REGEX_SIZE_LIMIT is pub(crate) and MAX_REGEX_CACHE_ENTRIES is private, both inside pub(crate) mod evaluator (eval/mod.rs:11) — neither is reachable from outside the crate, and neither is re-exported from lib.rs. Every other entry in this ## Constants block is pub and re-exported. Listing these two here, with their visibility modifiers copied verbatim, makes the document ambiguous about what it is enumerating: a reader diffing public-api.md against cargo doc output will find these two missing and cannot tell whether that is a doc bug or an export bug.

Two coherent resolutions:

  1. Move both to specs/expr/evaluator.md (which already documents them in the new "Bounding" section) and drop them from here. This keeps public-api.md a strict enumeration of the public surface.
  2. Or make REGEX_SIZE_LIMIT genuinely pub and re-export it from lib.rs, and keep it here without the pub(crate) marker. There is an argument for this: function_library.rs:64 now uses it in the default body of EvalContext::get_or_compile_regex, which is public API that third-party impls inherit and may override. A host overriding that method to add its own regex functions currently has to hardcode 1 << 20 to match built-in behavior — exactly the duplication this diff removed in-crate. DEFAULT_MEMORY_LIMIT / DEFAULT_OPERATION_LIMIT are public for the same reason: callers need to see the defaults they are overriding. MAX_REGEX_CACHE_ENTRIES has no such argument and should just move to evaluator.md.

Option 1 is the smaller change; option 2 closes a real gap for external EvalContext implementors. Either is fine, but the current state documents unreachable items as public API.

The per-evaluation regex cache was unbounded and invisible to the memory
budget. A comprehension with a per-iteration pattern could grow the cache
into the gigabytes while the evaluator reported kilobytes.

Changes:
- Charge each cached regex to current_memory at REGEX_SIZE_LIMIT cost;
  patterns past the cache cap compile-and-discard without persistent charge
- Cap the cache at 32 entries (MAX_REGEX_CACHE_ENTRIES)
- Keep per-regex size_limit at the regex-crate default of 1 MiB; the
  memory budget and entry cap are the primary defense against cache growth
- Charge operations proportional to pattern length on cache miss
- Preserve cache charges across comprehension iteration baseline resets
- Propagate regex_cache_bytes through absorb_counters so nested
  comprehension paths do not silently drop charges

Note: pattern-side validation cost (validate_regex_pattern) remains
unmetered on cache hits. Hoisting validation into get_or_compile_regex
so it benefits from the cache is deferred to a follow-up.

Specs updated: evaluator.md, function-library.md, public-api.md.

Signed-off-by: Sean Tang <171081544+seant-aws@users.noreply.github.com>
@seant-aws
seant-aws force-pushed the fix/regex-cache-bounded branch from 15f6127 to 3c21cc4 Compare September 16, 2026 18:31
@seant-aws
seant-aws marked this pull request as ready for review September 16, 2026 18:31
@seant-aws
seant-aws requested a review from a team as a code owner September 16, 2026 18:31
@leongdl

leongdl commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Reviewed at 3c21cc4. I ran probes rather than reading the diff alone; measurements below are reproducible with the test at the end (debug build).

Summary: the memory hole is closed, and the cap — not the charge — is what closes it. Real retained memory is bounded at 32 entries regardless of everything else I found below; measured peak_memory stays flat at 33.5 MB for 32, 64 and 128 distinct patterns. Three things need attention before merge, and three are good follow-ups.

Blocking

1. The description doesn't match the code

The body's defense #3 says size_limit was lowered from 1 MiB to 256 KiB and that (?:a{100}){100} "is now rejected outright". REGEX_SIZE_LIMIT is 1 << 20 in the diff, and that pattern is accepted:

pattern result
(?:a{100}){100}z accepted
(?:a{200}){200}z rejected, error names size limit of 1048576 bytes

Everything downstream of 256 KiB is therefore off: "~380 distinct patterns" is ~95 at 1 MiB, and with the 32-entry cap the regex cache can never reach the 100 MB default limit at all — the maximum total charge is 32 MiB. The "105 MB → rejected at 2 MB" CLI figure needs re-measuring too.

Either restore the 256 KiB change or rewrite the description. As it stands, approving the stated design approves something different. regex_size_limit_rejects_large_nfa passes at either limit, so it doesn't pin the value — if 256 KiB is intended, (?:a{100}){100}z is the test that fails today and passes at 256 KiB.

2. A single re_findall now needs memory_limit > 1 MiB

A 1-byte pattern is charged the full REGEX_SIZE_LIMIT. Measured peak_memory for re_findall('abc', 'b') is 1,048,666, and:

memory_limit result
100,000 MemoryLimitExceeded
500,000 MemoryLimitExceeded
1,048,577 MemoryLimitExceeded
2,000,000 OK

openjd-for-js/src/expr.rs:483 lets a host pass an arbitrary limit, so this is reachable from a public API rather than only from tests. This ships with the PR, so it needs a decision now rather than a follow-up: either accept it and name it in the breaking-change section, or charge something smaller.

Worth noting the comment cites MAX_ESCAPE_EXPANSION as precedent, but that bounds a transient allocation about to happen; this charge persists for the whole evaluation. If a real number is wanted later, regex_automata::meta::Regex::memory_usage() exists — regex::Regex doesn't re-export it, so it'd mean a direct regex-automata dep.

3. evaluator.rs:62 — the comment is wrong about the crate default

Set to 1 MiB — the same default the regex crate uses.

regex 1.13.1 defaults nfa_size_limit to 10 MiB (src/builders.rs:53). This tightens it 10×, which is a stronger claim than the one written. Same comment says "the memory budget (not the per-regex limit) is the primary defense against cache growth", which reads oddly next to line 53 — given the cap bounds total cache memory to 32 MiB and the default limit is 100 MB, the cap is the primary defense and the charge is what makes it visible.

Should fix here (one line, in a file you're already touching)

4. A comprehension over an unresolved iterable destroys the accumulated regex charge

child_evaluator initialises regex_cache_bytes: 0 (:1386). The resolved path repairs that at :1522; the unresolved branch at :1456 does not, and then absorb_counters (:1395) copies the 0 back over the parent's real total.

Measured, both at a 20 MiB limit:

expression result
[re_findall('x', string(i)) for i in range(32)] MemoryLimitExceeded at 20,973,662 bytes — charge working
same, each iteration also evaluating [y for y in Param.U] OK, peak_memory = 1,050,946

32 cached regexes, still in the cache after the loop, reported as ~1 MiB. The unresolved path is the validation path, so it's reachable from template validation with any Param.X the caller hasn't supplied.

To be clear about severity: this corrupts the counter, not the bound. The 32-entry cap still holds, so real RSS stays bounded either way — this is a reporting defect worth up to 32 MiB of under-charge, not a reopening of the original unbounded-memory bug.

Fix is one line — initialise regex_cache_bytes: self.regex_cache_bytes in child_evaluator, matching how current_memory and peak_memory are already inherited at :1373-75. That makes the invariant hold on both paths and makes :1522 redundant.

Good follow-ups

5. The op charge measures the wrong quantity

count_ops(pattern.len()) — compile cost tracks the expanded NFA, not source length:

pattern len ops charged compile wall
[a-z] 5 7 0.21 ms
(?:a{50}){50}z 14 16 3.9 ms
(?:a{90}){90}z 14 16 9.9 ms

47× the work for 2.3× the charge. Past the cap every call recompiles (2835 ops for 128 compiles), so the 10M op budget permits roughly 450,000 compiles of an adversarial pattern. Memory is bounded now, which makes compile CPU the weakest limit.

Mostly pre-existing — N distinct patterns cost N compiles before this PR too. What's new is the past-cap recompile amplification for a template that reuses more than 32 distinct patterns in a loop. A fixed REGEX_SIZE_LIMIT / 256 ops per compile would match the 1-op-per-256-bytes scale count_string_ops already uses and bound compiles to a few thousand.

6. The lazy-DFA cache is still unaccounted

size_limit bounds only the compiled NFA. regex 1.13.1 also defaults hybrid_cache_capacity to 2 MiB per regex (src/builders.rs:55), allocated lazily during search. Worst case 32 × 2 MiB = 64 MiB of real RSS on top of the 32 MiB charged. If the intent is "real memory is bounded and the budget sees it", pinning .dfa_size_limit(...) on the builder and folding it into entry_cost closes it.

7. Two smaller ones

validate_regex_pattern (functions/regex.rs:582) does a full regex_syntax parse + HIR translate on every call, cache hits included, charged nothing. Not introduced here, but it's the other half of the per-call regex cost given the PR's premise.

re_match keys the cache on format!("^(?:{})", pat) (:539) while re_search keys on pat (:561), so a template using both on one pattern occupies 2 of the 32 slots. Fine as a ceiling, but worth a line in the spec's Bounding section since 32 is justified as "generous — most use 1–3 distinct patterns".

Tests

regex_cache_hit_not_recharged is close to vacuous: it asserts peak_memory < 2_000_000 after eval_bounded(..., 2_000_000) returned Ok. Success already implies current_memory never exceeded the limit, and peak_memory is its max, so the assertion can't fail once the unwrap() succeeds. Asserting that the peak for 100 iterations of one constant pattern equals the peak for 1 iteration (±transients) would pin the mechanism.

Nothing asserts a past-cap pattern still returns the right answer — regex_cache_cap_bounds_memory runs 50 patterns but only compares peaks, so a cap that silently returned the wrong regex past 32 entries would pass.

Nothing pins finding 4; the probe below is ready to promote.

What's good

  • The four-mechanism framing is the right decomposition, and the cap is the mechanism that actually bounds real memory.
  • :1554, the regex_delta add-back, is a genuinely subtle interaction with the per-iteration baseline reset, and the comment earns its space. regex_cache_peak_memory_proportional_to_distinct_patterns is a real mutant for it — delete the delta and both the 1-pattern and 20-pattern peaks collapse to ~1 MiB and the test fails.
  • Charging only on retain (:1689) rather than on every compile is the right call, and the comment says why: charging discarded compiles would make current_memory diverge from real memory in the other direction.
  • Hoisting the magic 1 << 20 out of two call sites into one named constant, including the EvalContext default impl.
  • The spec was updated in the same change, and test_string_operation_counting.rs:305-311 shows the op arithmetic instead of just bumping 6 to 7.

Refuted but considered, so nobody re-investigates

  • std::mem::take at :1521 losing the cache on the error path — it does, but ? propagates out of the whole evaluation and the cache is dropped anyway.
  • regex_delta double-counting across iterations — the baseline is re-read each iteration (:1514) and the counter is monotonic within the loop, so the delta is exact.
  • saturating_add on entry_cost hiding overflow — pattern.len() is bounded by the 64 KiB expression cap.
  • count_ops(pattern.len()) rejecting legitimate long patterns — 64 KiB source cap against a 10M op limit; not reachable.

Probe

Drop into crates/openjd-expr/tests/integration/ and register it in tests/integration.rs:

use openjd_expr::{ExprType, ExprValue, ParsedExpression, SymbolTable, DEFAULT_OPERATION_LIMIT};

fn run(expr: &str, mem: usize, st: &SymbolTable)
    -> Result<openjd_expr::EvalResult, openjd_expr::ExpressionError> {
    ParsedExpression::new(expr).and_then(|p| {
        p.with_memory_limit(mem)
            .with_operation_limit(DEFAULT_OPERATION_LIMIT)
            .evaluate_with_metrics(&[st])
    })
}

/// Finding 4: a nested comprehension over an unresolved iterable wipes the
/// parent's accumulated regex charge.
#[test]
fn probe_unresolved_nested_comp_drains_regex_charge() {
    let mut st = SymbolTable::new();
    st.set("Param.U", ExprValue::unresolved(ExprType::list(ExprType::INT))).unwrap();
    const LIMIT: usize = 20 * (1 << 20);

    let control = run("[re_findall('x', string(i)) for i in range(32)]", LIMIT, &st);
    let leak = run(
        "[len(re_findall('x', string(i))) + len([y for y in Param.U]) for i in range(32)]",
        LIMIT, &st,
    );
    println!("control: {:?}", control.as_ref().map(|r| r.peak_memory));
    println!("leak:    {:?}", leak.as_ref().map(|r| r.peak_memory));
    // Today: control Err(20973662 > 20971520), leak Ok(peak=1050946).
    assert!(control.is_err() && leak.is_err(), "regex charge leaked through the unresolved branch");
}

/// Finding 5: op charge is pattern length, not compile cost.
#[test]
fn probe_op_charge_vs_compile_cost() {
    let st = SymbolTable::new();
    for pat in [r"(?:a{50}){50}z", r"(?:a{90}){90}z", r"[a-z]"] {
        let t = std::time::Instant::now();
        let r = run(&format!("re_findall('a', '{pat}')"), usize::MAX, &st).unwrap();
        println!("pat={pat:18} len={:3} ops={:3} peak={:9} wall={:?}",
                 pat.len(), r.operation_count, r.peak_memory, t.elapsed());
    }
}

/// Finding 2: flat charge means one trivial regex needs a >1 MiB limit.
#[test]
fn probe_flat_charge_for_trivial_pattern() {
    let st = SymbolTable::new();
    println!("peak={}", run("re_findall('abc', 'b')", usize::MAX, &st).unwrap().peak_memory);
    for lim in [100_000usize, 500_000, 1_048_577, 2_000_000] {
        println!("limit={lim:9} => {:?}", run("re_findall('abc', 'b')", lim, &st).is_ok());
    }
}

/// Finding 1: the size limit in the code is 1 MiB, not the described 256 KiB.
#[test]
fn probe_size_limit_actual_value() {
    let st = SymbolTable::new();
    for pat in [r"(?:a{100}){100}z", r"(?:a{200}){200}z"] {
        println!("pat={pat:20} => {:?}",
                 run(&format!("re_findall('a', '{pat}')"), usize::MAX, &st).map(|_| "accepted"));
    }
}

@leongdl

leongdl commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Approved. Consolidating the non-blocking items here so they don't get lost in the long comment above — none of these gate the merge, and the memory bound this PR establishes holds without any of them.

Two small things still worth doing in this PR, since both are text and neither touches behaviour:

  • The description's defense chore(deps): Bump rusqlite from 0.31.0 to 0.39.0 #3 (256 KiB size_limit, (?:a{100}){100} rejected) doesn't match 3c21cc4, which sets 1 MiB and accepts that pattern. Leaving it as-is means the merged record describes a design that didn't ship.
  • evaluator.rs:62 — the regex crate's default nfa_size_limit is 10 MiB (src/builders.rs:53), not 1 MiB. This tightens it 10×.

Follow-ups, roughly by size of the remaining gap:

  1. Lazy-DFA cache accounting. hybrid_cache_capacity defaults to 2 MiB per regex and is allocated during search, outside both size_limit and the charge — up to 64 MiB of real RSS against the 32 MiB charged. Pinning .dfa_size_limit(...) on the builder and folding it into entry_cost closes it. Largest unaccounted number left.
  2. Op charge proxy. count_ops(pattern.len()) is 16 ops for a 14-char pattern that compiles in 9.9 ms. A fixed REGEX_SIZE_LIMIT / 256 per compile matches the scale count_string_ops already uses.
  3. regex_cache_bytes on the unresolved-comprehension path. One line: initialise it from self.regex_cache_bytes in child_evaluator rather than 0, which also makes :1522 redundant. Counter-only defect — the 32-entry cap keeps real memory bounded regardless.

Nits:

  • specs/expr/public-api.md:219-226 documents REGEX_SIZE_LIMIT (pub(crate)) and MAX_REGEX_CACHE_ENTRIES (private) in a file describing the public API, and carries the pub(crate) annotation into a public-API listing. Both are already covered in evaluator.md.
  • specs/expr/evaluator.md Bounding section is accurate but could add the three consequences: max total charge is 32 MiB so the 100 MB default is never reached by the cache alone; past-cap patterns recompile on every call; the charge is a ceiling, not a measurement.
  • re_match keys the cache on ^(?:{pat}) while re_search keys on pat, so one pattern used by both takes 2 of the 32 slots — worth a sentence where 32 is justified as "generous".
  • validate_regex_pattern parses + translates to HIR on every call including cache hits, charged nothing. Fine, but the PR's premise makes it worth naming as deliberate.
  • regex_cache_hit_not_recharged can't fail: peak_memory < 2_000_000 is implied by eval_bounded(..., 2_000_000) returning Ok. Comparing the 100-iteration peak against the 1-iteration peak would pin the cache-hit path.
  • Nothing asserts a past-cap pattern returns the correct result — regex_cache_cap_bounds_memory compares peaks only.

@leongdl

leongdl commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

For anyone reading this later: a plain-language walkthrough of what the patch does and why the four mechanisms are ordered the way they are. Nothing new here relative to the review above — this is the explainer.

Problem

The evaluator keeps a per-evaluation HashMap<String, regex::Regex> so a comprehension like [x for x in items if re_search(x, 'shot')] compiles 'shot' once, not N times. Two things were true of that map and nobody had put them together: it had no upper bound, and nothing charged its contents to current_memory. Every other allocation the evaluator makes goes through track(), but a compiled regex is not an ExprValue, so it never did.

Feed it a pattern that changes per iteration — '(?:a{100}){100}z' + string(i) — and each iteration compiles a fresh ~500 KB regex and shelves it. At N=4000 the process held ~2 GB while peak_memory reported 262 KB. memory_limit could not see it; operation_limit charged one op per call.

Solution — four moves, in order of how much each does

The cap is the fix. if self.regex_cache.len() < 32 before insert. Past 32, compile, use, drop. Real retained memory is bounded at 32 entries regardless of anything else. This one line closes the hole on its own — measured peak_memory is flat at 33.5 MB for 32, 64 and 128 distinct patterns.

The charge is the visibility. When an entry is retained, charge REGEX_SIZE_LIMIT + pattern.len() — 1 MiB plus a few bytes — to current_memory, check_memory first, then +=. regex::Regex does not expose its compiled size, so it charges the ceiling. Honest in one direction: it can over-report a trivial regex by ~1000× but can never under-report a retained one.

The delta is the subtle part. The comprehension loop resets current_memory = memory_baseline at the end of each iteration, because the loop-variable clone and intermediates are gone. But the regex cache is not gone — it is moved back into the parent. Reset naively and every iteration's charge is erased the moment it is made. So a second counter, regex_cache_bytes, tracks the cache's charge alone, and the reset becomes memory_baseline + (regex_cache_bytes − regex_bytes_baseline). Transients discarded, shelf retained.

The op charge is the weakest. count_ops(pattern.len()) on each compile makes compilation non-free to the op budget, but length is not cost — a 14-character pattern compiles in 10 ms and charges 16 ops. Follow-up material.

Walkthrough

[re_findall('x', string(i)) for i in range(3)] with a 2.5 MiB limit. current_memory starts near 0.

Iteration 0. Baselines saved: memory_baseline ≈ 0, regex_bytes_baseline = 0. Child evaluator created; the parent's empty cache is moved in and child.regex_cache_bytes is seeded from the parent. Body runs. re_findall_fn calls get_or_compile_regex("0"). Cache miss. count_ops(1). Build. Cache has 0 entries, below 32, so: check_memory(1 MiB + 1) passes; current_memory ≈ 1 MiB; regex_cache_bytes = 1 MiB; insert. Child returns. absorb_counters copies all four counters up. Cache moved back. Reset: regex_delta = 1 MiB − 0, so current_memory = 0 + 1 MiB. The shelf survived the reset.

Iteration 1. Baselines: memory_baseline = 1 MiB, regex_bytes_baseline = 1 MiB. Pattern "1", miss, cache has 1 entry, charge: check_memory(1 MiB) → projected 2 MiB, under 2.5 MiB, passes. current_memory = 2 MiB, regex_cache_bytes = 2 MiB. Reset: delta 1 MiB, current_memory = 1 MiB + 1 MiB = 2 MiB.

Iteration 2. Pattern "2", miss, check_memory(1 MiB) → projected 3 MiB > 2.5 MiB. MemoryLimitExceeded { used: 3145730, limit: 2500000 }. The ? unwinds out of the child, out of the loop, out of evaluate. The regex that was just compiled is dropped with the frame.

Before the PR, iteration 2 would have inserted and continued, and so would iteration 4000.

The one door the repair misses

Run the same thing, but each body also evaluates [y for y in Param.U] where Param.U is unresolved — the validation path. That branch creates its child with regex_cache_bytes: 0, and because the seeding at the top of the resolved loop is the only place the repair was applied, it is never seeded. absorb_counters then copies that 0 back over the parent's real total. The next iteration's delta computes from a baseline of 0, and the shelf's charge is silently forgotten.

Measured: 32 retained regexes, still in the cache, reported as ~1 MiB.

Does the counter stop the shelf from filling? No. The 32 slots do. The counter only lets you see it — which is why the one-line fix (seed regex_cache_bytes in child_evaluator, the way current_memory and peak_memory already are) is on the follow-up list rather than the blocking one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants