mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-29 12:46:53 -06:00
GREP styles: a scan and the pattern cache are bounded (#1598)
Two unbounded things in the GREP-style path. `BACKTRACK_LIMIT` bounds one search, not a scan: `find_all` was called with `usize::MAX` and pays the limit again for every match, so a pattern tuned just under it cost `200_000 x matches` per paragraph on every relayout. A scan now stops at `MAX_MATCHES_PER_PARAGRAPH` (4096) -- more styled runs than a paragraph can usefully carry -- and says which pattern reached it. The `PATTERNS` thread-local never evicted, so it grew across every pattern a session ever composed. It now holds at most 64, least recently used dropped first: a tick counter marks each use, so the entry evicted is the one longest unused rather than the one inserted earliest. The test checks that the pattern just used is still there after the cache overflows, which insertion-order eviction would not give. Left for a separate change: DC-17 also asks for a per-compose step budget, so the cost cannot add up across paragraphs. That means threading a budget from `compose_story` through `shape` into `overlays`, a compose-internal signature change that wants its own review rather than riding along here.
This commit is contained in:
@@ -23,6 +23,13 @@ use designcraft_doc::story;
|
||||
pub const MAX_PATTERN: usize = 4096;
|
||||
/// Backtracking steps one search may take before it gives up.
|
||||
pub const BACKTRACK_LIMIT: usize = 200_000;
|
||||
/// Matches one GREP style may contribute to one paragraph.
|
||||
///
|
||||
/// [`BACKTRACK_LIMIT`] bounds a single search, not a scan: `find_all` pays it again for every
|
||||
/// match, so a pattern tuned just under the limit cost `BACKTRACK_LIMIT × matches` per paragraph
|
||||
/// on every relayout (#1598). A paragraph cannot usefully carry more styled runs than this, and
|
||||
/// a scan that reaches it stops with what it has.
|
||||
pub const MAX_MATCHES_PER_PARAGRAPH: usize = 4096;
|
||||
/// Compiled program size cap (bytes) for the underlying regex engine.
|
||||
const SIZE_LIMIT: usize = 4 << 20;
|
||||
|
||||
|
||||
@@ -9,9 +9,42 @@ use designcraft_doc::{GrepStyle, NestedStyle, NestedUntil};
|
||||
|
||||
use crate::grep::{Grep, GrepOptions};
|
||||
|
||||
/// Patterns kept compiled at once. A document's GREP styles are few; the cache grew without
|
||||
/// bound across every pattern a session ever composed (#1598).
|
||||
const MAX_CACHED_PATTERNS: usize = 64;
|
||||
|
||||
thread_local! {
|
||||
/// Compiled GREP styles by pattern; None for one that doesn't compile or gave up on a search.
|
||||
static PATTERNS: RefCell<HashMap<String, Option<Grep>>> = RefCell::new(HashMap::new());
|
||||
/// Compiled GREP styles by pattern, with the tick they were last used at; `None` for one that
|
||||
/// doesn't compile or gave up on a search. Bounded to [`MAX_CACHED_PATTERNS`], least recently
|
||||
/// used evicted first.
|
||||
static PATTERNS: RefCell<HashMap<String, (Option<Grep>, u64)>> = RefCell::new(HashMap::new());
|
||||
/// Counts cache uses, so the least recently used entry can be found.
|
||||
static TICK: RefCell<u64> = const { RefCell::new(0) };
|
||||
}
|
||||
|
||||
/// The compiled pattern for `pattern`, compiling it on first use and evicting the least recently
|
||||
/// used entry once the cache is full. `f` is given `None` when the pattern does not compile.
|
||||
fn with_pattern<R>(pattern: &str, f: impl FnOnce(&mut Option<Grep>) -> R) -> R {
|
||||
let now = TICK.with(|t| {
|
||||
let mut t = t.borrow_mut();
|
||||
*t += 1;
|
||||
*t
|
||||
});
|
||||
PATTERNS.with(|p| {
|
||||
let mut p = p.borrow_mut();
|
||||
if !p.contains_key(pattern)
|
||||
&& p.len() >= MAX_CACHED_PATTERNS
|
||||
&& let Some(oldest) = p.iter().min_by_key(|(_, (_, used))| *used).map(|(k, _)| k.clone())
|
||||
{
|
||||
p.remove(&oldest);
|
||||
}
|
||||
let slot = p.entry(pattern.to_string()).or_insert_with(|| {
|
||||
let re = Grep::new(pattern, GrepOptions::default()).map_err(|e| log::warn!("GREP style {pattern:?} skipped: {e}")).ok();
|
||||
(re, now)
|
||||
});
|
||||
slot.1 = now;
|
||||
f(&mut slot.0)
|
||||
})
|
||||
}
|
||||
|
||||
fn is_event(u: &NestedUntil, c: char, prev: Option<char>, next: Option<char>) -> bool {
|
||||
@@ -71,14 +104,15 @@ pub(crate) fn overlays(text: &str, range: Range<usize>, nested: &[NestedStyle],
|
||||
if g.style.is_empty() || g.pattern.is_empty() {
|
||||
continue;
|
||||
}
|
||||
PATTERNS.with(|p| {
|
||||
let mut p = p.borrow_mut();
|
||||
let slot = p.entry(g.pattern.clone()).or_insert_with(|| {
|
||||
Grep::new(&g.pattern, GrepOptions::default()).map_err(|e| log::warn!("GREP style {:?} skipped: {e}", g.pattern)).ok()
|
||||
});
|
||||
with_pattern(&g.pattern, |slot| {
|
||||
let Some(re) = slot else { return };
|
||||
match re.find_all(text.get(range.start..end).unwrap_or(""), usize::MAX) {
|
||||
Ok(ms) => out.extend(ms.into_iter().filter(|m| !m.is_empty()).map(|m| (range.start + m.start..range.start + m.end, g.style.clone()))),
|
||||
match re.find_all(text.get(range.start..end).unwrap_or(""), crate::grep::MAX_MATCHES_PER_PARAGRAPH) {
|
||||
Ok(ms) => {
|
||||
if ms.len() == crate::grep::MAX_MATCHES_PER_PARAGRAPH {
|
||||
log::warn!("GREP style {:?} matched more than {} times in one paragraph; the rest are not styled", g.pattern, ms.len());
|
||||
}
|
||||
out.extend(ms.into_iter().filter(|m| !m.is_empty()).map(|m| (range.start + m.start..range.start + m.end, g.style.clone())));
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("GREP style {:?} disabled: {e}", g.pattern);
|
||||
*slot = None;
|
||||
@@ -93,6 +127,31 @@ pub(crate) fn overlays(text: &str, range: Range<usize>, nested: &[NestedStyle],
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A GREP style's scan is bounded two ways (#1598): the matches one paragraph can take, and
|
||||
/// the patterns kept compiled at once.
|
||||
#[test]
|
||||
fn a_grep_scan_and_the_pattern_cache_are_bounded() {
|
||||
// More matches in one paragraph than the cap: the scan stops with what it has instead of
|
||||
// paying the backtrack limit again for each one.
|
||||
let text = "a ".repeat(crate::grep::MAX_MATCHES_PER_PARAGRAPH + 500);
|
||||
let o = overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "A".into(), pattern: "a".into() }], 0);
|
||||
assert_eq!(o.len(), crate::grep::MAX_MATCHES_PER_PARAGRAPH, "the scan stops at the cap");
|
||||
|
||||
// The cache holds at most MAX_CACHED_PATTERNS, and the one just used is not the one
|
||||
// dropped: a document's own styles stay compiled however many patterns a session saw.
|
||||
PATTERNS.with(|p| p.borrow_mut().clear());
|
||||
for i in 0..MAX_CACHED_PATTERNS + 20 {
|
||||
let pattern = format!("x{i}y");
|
||||
overlays("x1y", 0..3, &[], &[GrepStyle { style: "S".into(), pattern }], 0);
|
||||
}
|
||||
let (len, has_last) = PATTERNS.with(|p| {
|
||||
let p = p.borrow();
|
||||
(p.len(), p.contains_key(&format!("x{}y", MAX_CACHED_PATTERNS + 19)))
|
||||
});
|
||||
assert_eq!(len, MAX_CACHED_PATTERNS, "the cache is bounded");
|
||||
assert!(has_last, "the most recently used pattern is still cached");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_and_grep_ranges() {
|
||||
let text = "Q: what is 42? A drop of 7 words.\n".to_string();
|
||||
|
||||
Reference in New Issue
Block a user