LCOV - code coverage report
Current view: top level - antares-ql/src - regex.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 98.1 % 362 355
Test Date: 2026-11-02 13:21:27 Functions: 68.0 % 153 104

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! Process-wide cache of compiled regular expressions.
       3              : //!
       4              : //! Two NGSI-LD surfaces carry a client-supplied regular expression. The query
       5              : //! language, 4.9 **Match pattern** (production rule `patternOp`): "A matching
       6              : //! entity shall contain the target element and the target value shall be in
       7              : //! the L(R) of the regular pattern specified by the Query Term" — and its
       8              : //! `notPatternOp` mirror. And `idPattern`, on an EntitySelector (5.2.33), an
       9              : //! EntityInfo (5.2.8) and the query parameters of Table 6.4.3.2-1.
      10              : //!
      11              : //! Both are evaluated per candidate entity and, for a subscription, per event
      12              : //! per subscription — while the pattern text belongs to the query or the
      13              : //! subscription, not to the candidate. Compiling at the point of use
      14              : //! therefore pays `Regex::new` again for every candidate; compiling through
      15              : //! here pays it once per distinct pattern and hands out a shared program.
      16              : //!
      17              : //! One compile has to be bounded and a pattern has to be compiled at most
      18              : //! once, because both numbers are multiplied by the candidate count.
      19              : //! `regex` builds an automaton whose size follows counted repetition of a
      20              : //! character class rather than pattern length, so a 21-byte pattern can ask
      21              : //! for a 16 MiB program and the tenth of a second that costs;
      22              : //! `MAX_REGEX_PROGRAM_BYTES` is the ceiling on that, and a pattern above it
      23              : //! is refused with the builder's own error — the error every call site
      24              : //! already maps, so an over-large `idPattern` keeps the 400 BadRequestData
      25              : //! its call site returns (Table 6.3.2-1) and an over-large `~=` operand
      26              : //! keeps having no L(R), i.e. matching nothing (4.9), exactly as a
      27              : //! syntactically invalid one does.
      28              : //!
      29              : //! Every outcome is retained, refusals included, so no pattern is compiled
      30              : //! twice. Retention is bounded in entries and in bytes —
      31              : //! `MAX_REGEX_CACHE` and `MAX_REGEX_CACHE_BYTES` — because the key is
      32              : //! client input and an unbounded map of it is a memory attack, not a cache.
      33              : //! Crossing a bound drops the least recently used half, never the whole map:
      34              : //! a subscription fan-out evaluates the same few patterns per event, and
      35              : //! dropping those along with the one-off pattern that overflowed the map
      36              : //! makes every one of them recompile at once, on the request that was
      37              : //! unlucky enough to cross the line.
      38              : 
      39              : /// Ceiling on the automaton compiled for one pattern, and with it on what
      40              : /// one compile costs. The pattern is client input — the
      41              : /// `patternOp`/`notPatternOp` operand of a query term (4.9) and the
      42              : /// `idPattern` of an EntitySelector (5.2.33), an EntityInfo (5.2.8) or a
      43              : /// query parameter (Table 6.4.3.2-1) — and program size does not follow
      44              : /// pattern length: `(?:\p{Any}{100}){100}` is 21 bytes and compiles to
      45              : /// 16 MiB. Ordinary patterns sit far below the ceiling —
      46              : /// `^urn:ngsi-ld:Vehicle:.*$` compiles to 2 KiB,
      47              : /// `^urn:ngsi-ld:(Vehicle|Sensor|Building):[A-Za-z0-9_-]{1,64}$` to 16 KiB,
      48              : /// a fifty-way alternation of URNs to 128 KiB — and one above it is refused
      49              : /// rather than compiled, so no request can buy an unbounded automaton.
      50              : pub const MAX_REGEX_PROGRAM_BYTES: usize = 256 * 1024;
      51              : /// Distinct patterns retained. A `q` is capped at 4 KiB and so carries at
      52              : /// most a low hundreds of distinct patterns, which the cache holds whole:
      53              : /// one request never evicts its own working set.
      54              : pub const MAX_REGEX_CACHE: usize = 1024;
      55              : /// Retained bytes. Each entry is charged the tier it compiled within, not
      56              : /// its true size, plus its key and, for a refusal, its message — both
      57              : /// client-sized, a syntax error reprinting the pattern — so the number is an
      58              : /// upper bound on what the map holds.
      59              : pub const MAX_REGEX_CACHE_BYTES: usize = 64 * 1024 * 1024;
      60              : 
      61              : /// A program that fits this is charged this much against
      62              : /// [`MAX_REGEX_CACHE_BYTES`]; anything larger is charged the full
      63              : /// [`MAX_REGEX_PROGRAM_BYTES`]. Almost every pattern a deployment writes
      64              : /// lands in the first tier, so the byte budget holds thousands of
      65              : /// Subscription `idPattern`s and still bounds a mix of the largest programs
      66              : /// the ceiling admits.
      67              : const PROGRAM_TIER_BYTES: usize = 32 * 1024;
      68              : 
      69              : use std::collections::HashMap;
      70              : use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
      71              : use std::sync::{Arc, LazyLock, RwLock};
      72              : 
      73              : /// What a pattern compiled to, or the refusal it earned. A refusal is
      74              : /// retained as its message so the pattern is not rebuilt per candidate.
      75              : type Outcome = Result<Arc<regex::Regex>, Box<str>>;
      76              : 
      77              : /// A retained entry: what it holds and when it was last handed out. `Entry`
      78              : /// is what the eviction below orders by, so every cache here stores one.
      79              : type Entry<V> = (V, AtomicU64);
      80              : 
      81              : /// Source of the use stamps. Wrapping after 2^64 hand-outs would mis-order
      82              : /// one eviction; nothing else depends on it.
      83              : static CLOCK: AtomicU64 = AtomicU64::new(0);
      84              : 
      85        13827 : fn stamp<V>(v: V) -> Entry<V> {
      86        13827 :     (v, AtomicU64::new(CLOCK.fetch_add(1, Ordering::Relaxed)))
      87        13827 : }
      88              : 
      89              : /// Record that an entry was just handed out. Done under the READ lock — the
      90              : /// stamp is the only mutable part of an entry, and ordering evictions is the
      91              : /// only thing that reads it, so a lost update costs one entry a place in the
      92              : /// order and nothing else.
      93        41177 : fn touch<V>(e: &Entry<V>) -> &V {
      94        41177 :     e.1.store(CLOCK.fetch_add(1, Ordering::Relaxed), Ordering::Relaxed);
      95        41177 :     &e.0
      96        41177 : }
      97              : 
      98              : /// Drop the least recently used half. The entries a request in flight is
      99              : /// using were stamped by that request, so they are on the surviving side:
     100              : /// crossing a bound costs the pattern that crossed it, not the working set.
     101           30 : fn evict_lru_half<V>(map: &mut HashMap<Box<str>, Entry<V>>) {
     102           30 :     let keep = map.len() / 2;
     103           30 :     if keep == 0 {
     104            4 :         map.clear();
     105            4 :         return;
     106           26 :     }
     107        22430 :     let mut stamps: Vec<u64> = map.values().map(|e| e.1.load(Ordering::Relaxed)).collect();
     108           26 :     stamps.sort_unstable();
     109              :     // `keep` newest survive; a tie on the boundary stamp keeps both, which
     110              :     // costs at most a few entries over the half and never a bound.
     111           26 :     let Some(&cut) = stamps.len().checked_sub(keep).and_then(|i| stamps.get(i)) else {
     112            0 :         return;
     113              :     };
     114        22430 :     map.retain(|_, e| e.1.load(Ordering::Relaxed) >= cut);
     115           30 : }
     116              : 
     117              : /// One bounded cache: client-supplied text to what it compiled or parsed
     118              : /// to, each entry carrying the stamp [`evict_lru_half`] orders by.
     119              : type Cache<V> = LazyLock<RwLock<HashMap<Box<str>, Entry<V>>>>;
     120              : 
     121           31 : static CACHE: Cache<(Outcome, usize)> = LazyLock::new(|| RwLock::new(HashMap::new()));
     122              : #[cfg(test)]
     123              : thread_local! {
     124              :     /// Compilations on this thread. Per thread, so a test counts its own cache misses and never
     125              :     /// those of a test running beside it; a compile runs on the caller's thread.
     126              :     static COMPILES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
     127              : }
     128              : static BYTES: AtomicUsize = AtomicUsize::new(0);
     129              : 
     130        12237 : fn build(pattern: &str, limit: usize) -> Result<regex::Regex, regex::Error> {
     131        12237 :     regex::RegexBuilder::new(pattern).size_limit(limit).build()
     132        12237 : }
     133              : 
     134              : /// The compiled program for `pattern`, shared with every other caller that
     135              : /// asked for the same pattern text. `Err` carries the builder's own message
     136              : /// — a syntax error, or the refusal of a program above
     137              : /// [`MAX_REGEX_PROGRAM_BYTES`] — and each call site keeps mapping it to its
     138              : /// own spec error.
     139        52038 : pub fn compile(pattern: &str) -> Result<Arc<regex::Regex>, String> {
     140        52038 :     if let Some(hit) = CACHE
     141        52038 :         .read()
     142        52038 :         .ok()
     143        52038 :         .and_then(|c| c.get(pattern).map(|e| touch(e).0.clone()))
     144              :     {
     145        40455 :         return hit.map_err(String::from);
     146        11583 :     }
     147              :     #[cfg(test)]
     148        10663 :     COMPILES.with(|n| n.set(n.get().saturating_add(1)));
     149              :     // Compiled OUTSIDE the lock — a pattern compile must never serialize the
     150              :     // matcher — and a poisoned lock degrades to "compile every time", never
     151              :     // to a failed request.
     152        11583 :     let (outcome, charge): (Outcome, usize) = match build(pattern, PROGRAM_TIER_BYTES) {
     153        10931 :         Ok(re) => (Ok(Arc::new(re)), PROGRAM_TIER_BYTES),
     154          652 :         Err(_) => match build(pattern, MAX_REGEX_PROGRAM_BYTES) {
     155          602 :             Ok(re) => (Ok(Arc::new(re)), MAX_REGEX_PROGRAM_BYTES),
     156              :             // A refusal retains a message, not a program.
     157           50 :             Err(e) => (Err(e.to_string().into()), 0),
     158              :         },
     159              :     };
     160              :     // The key and a refusal's message are as long as the client made them:
     161              :     // charged, or a few oversized patterns pin memory the budget never sees.
     162        11583 :     let charge = charge
     163        11583 :         .saturating_add(pattern.len())
     164        11583 :         .saturating_add(outcome.as_ref().err().map_or(0, |m| m.len()));
     165              :     // An entry past the whole budget is not retained at all: the bound holds.
     166        11583 :     if let Some(mut c) = CACHE
     167        11583 :         .write()
     168        11583 :         .ok()
     169        11583 :         .filter(|_| charge <= MAX_REGEX_CACHE_BYTES)
     170        11583 :     {
     171        11583 :         retain(&mut c, pattern, outcome.clone(), charge);
     172        11583 :     }
     173        11583 :     outcome.map_err(String::from)
     174        52038 : }
     175              : 
     176              : /// Retain one compile outcome charged `charge` bytes, evicting first so both
     177              : /// bounds hold, and keep [`BYTES`] the sum of what the map holds.
     178        11587 : fn retain(
     179        11587 :     c: &mut HashMap<Box<str>, Entry<(Outcome, usize)>>,
     180        11587 :     pattern: &str,
     181        11587 :     outcome: Outcome,
     182        11587 :     charge: usize,
     183        11587 : ) {
     184              :     // Halving is repeated rather than assumed sufficient: one charge can
     185              :     // be the whole byte budget's worth of the tier above, so the loop —
     186              :     // which halves what is left each pass — is what makes the bound hold.
     187        11587 :     let mut bytes = BYTES.load(Ordering::Relaxed);
     188        11609 :     while !c.is_empty()
     189        11578 :         && (c.len() >= MAX_REGEX_CACHE || bytes.saturating_add(charge) > MAX_REGEX_CACHE_BYTES)
     190              :     {
     191           22 :         evict_lru_half(c);
     192           22 :         bytes = c.values().map(|e| e.0 .1).sum();
     193              :     }
     194              :     // Two callers that missed the same pattern at once both compile and both
     195              :     // retain it: the second entry replaces the first, and so does its charge.
     196        11587 :     let replaced = c
     197        11587 :         .insert(pattern.into(), stamp((outcome, charge)))
     198        11587 :         .map_or(0, |e| e.0 .1);
     199        11587 :     BYTES.store(
     200        11587 :         bytes.saturating_add(charge).saturating_sub(replaced),
     201        11587 :         Ordering::Relaxed,
     202              :     );
     203        11587 : }
     204              : 
     205              : /// The retained program for `pattern`, without compiling anything. A
     206              : /// retained refusal holds no program and reads as `None` here.
     207          640 : pub fn cached(pattern: &str) -> Option<Arc<regex::Regex>> {
     208          640 :     CACHE.read().ok().and_then(|c| {
     209          640 :         c.get(pattern)
     210          640 :             .and_then(|e| touch(e).0.as_ref().ok())
     211          640 :             .cloned()
     212          640 :     })
     213          640 : }
     214              : 
     215              : /// Compilations performed on the calling thread (i.e. its cache misses).
     216              : #[cfg(test)]
     217           10 : fn compiles() -> u64 {
     218           10 :     COMPILES.with(std::cell::Cell::get)
     219           10 : }
     220              : 
     221              : /// Patterns currently retained — never above `MAX_REGEX_CACHE`.
     222         6152 : pub fn len() -> usize {
     223         6152 :     CACHE.read().map(|c| c.len()).unwrap_or(0)
     224         6152 : }
     225              : 
     226              : /// Program bytes currently charged — never above `MAX_REGEX_CACHE_BYTES`.
     227         6752 : pub fn retained_bytes() -> usize {
     228         6752 :     BYTES.load(Ordering::Relaxed)
     229         6752 : }
     230              : 
     231           16 : static Q_CACHE: Cache<Arc<crate::QNode>> = LazyLock::new(|| RwLock::new(HashMap::new()));
     232            4 : static GEO_CACHE: Cache<Arc<crate::geo::GeoQuery>> = LazyLock::new(|| RwLock::new(HashMap::new()));
     233              : 
     234              : /// The parsed 4.9 query for `q`, shared across every event that evaluates the
     235              : /// same subscription. Like the regex cache this changes no outcome: only an
     236              : /// `Ok` parse is retained, so an invalid or over-complex `q` keeps exactly
     237              : /// the handling its call site has (`parse_q` is re-run and its error stands).
     238              : /// Entry size is already capped by the parser's own complexity ceiling
     239              : /// (`MAX_Q_NODES`), entry count by the same bound and eviction as above.
     240         2294 : pub fn q_node(q: &str) -> Option<Arc<crate::QNode>> {
     241         2294 :     if let Some(hit) = Q_CACHE
     242         2294 :         .read()
     243         2294 :         .ok()
     244         2294 :         .and_then(|c| c.get(q).map(|e| Arc::clone(touch(e))))
     245              :     {
     246           68 :         return Some(hit);
     247         2226 :     }
     248         2226 :     let node = Arc::new(crate::parse_q(q).ok()?);
     249         2212 :     if let Ok(mut c) = Q_CACHE.write() {
     250         2212 :         if c.len() >= MAX_REGEX_CACHE {
     251            2 :             evict_lru_half(&mut c);
     252         2210 :         }
     253         2212 :         c.insert(q.into(), stamp(Arc::clone(&node)));
     254            0 :     }
     255         2212 :     Some(node)
     256         2294 : }
     257              : 
     258              : /// The parsed geoquery (4.10) for a subscription's `geoQ`, keyed by the
     259              : /// caller's serialization of that member. `build` runs on a miss and only a
     260              : /// `Some` is retained — a `geoQ` that does not parse keeps failing exactly as
     261              : /// before, per call. Geometry size is already capped at the parse
     262              : /// (`MAX_GEO_VERTICES`), entry count by the same bound and eviction.
     263           28 : pub fn geo_query(
     264           28 :     key: &str,
     265           28 :     build: impl FnOnce() -> Option<crate::geo::GeoQuery>,
     266           28 : ) -> Option<Arc<crate::geo::GeoQuery>> {
     267           28 :     if let Some(hit) = GEO_CACHE
     268           28 :         .read()
     269           28 :         .ok()
     270           28 :         .and_then(|c| c.get(key).map(|e| Arc::clone(touch(e))))
     271              :     {
     272           12 :         return Some(hit);
     273           16 :     }
     274           16 :     let gq = Arc::new(build()?);
     275              :     // ponytail: a key above MAX_GEO_KEY_BYTES is built per call and never
     276              :     // retained, so the cache holds at most MAX_REGEX_CACHE keys of at most
     277              :     // that size (64 MiB, the regex cache's byte budget)
     278           12 :     if key.len() > MAX_GEO_KEY_BYTES {
     279            2 :         return Some(gq);
     280           10 :     }
     281           10 :     if let Ok(mut c) = GEO_CACHE.write() {
     282           10 :         if c.len() >= MAX_REGEX_CACHE {
     283            0 :             evict_lru_half(&mut c);
     284           10 :         }
     285           10 :         c.insert(key.into(), stamp(Arc::clone(&gq)));
     286            0 :     }
     287           10 :     Some(gq)
     288           28 : }
     289              : 
     290              : /// The largest `geoQ` serialization [`geo_query`] retains. The key, and the
     291              : /// geometry parsed from it, follow the client's vertex count: without a
     292              : /// per-entry ceiling the entry bound alone let 1024 of the largest
     293              : /// geometries the parser admits pin memory no budget counted.
     294              : const MAX_GEO_KEY_BYTES: usize = MAX_REGEX_CACHE_BYTES / MAX_REGEX_CACHE;
     295              : 
     296              : /// Test-only serialization: a test that flushes the shared cache must not
     297              : /// race the tests asserting what is retained.
     298              : #[cfg(test)]
     299           28 : pub(crate) fn serial_lock() -> std::sync::MutexGuard<'static, ()> {
     300              :     static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
     301           28 :     SERIAL.lock().unwrap_or_else(|poison| poison.into_inner())
     302           28 : }
     303              : 
     304              : // regex compiles run for hours under Miri; the fuzz job covers them
     305              : #[cfg(all(test, not(miri)))]
     306              : mod tests {
     307              :     use super::*;
     308              : 
     309            6 :     fn near_point() -> Option<crate::geo::GeoQuery> {
     310            6 :         crate::geo::GeoQuery::from_params(&HashMap::from([
     311            6 :             ("georel".to_owned(), "near;maxDistance==10".to_owned()),
     312            6 :             ("geometry".to_owned(), "Point".to_owned()),
     313            6 :             ("coordinates".to_owned(), "[1,2]".to_owned()),
     314            6 :         ]))
     315            6 :         .ok()
     316            6 :         .flatten()
     317            6 :     }
     318              : 
     319              :     /// The same pattern text is compiled once: the second call gets the very
     320              :     /// program the first one built (4.9 patternOp / 5.2.33 idPattern are
     321              :     /// evaluated per candidate, so this identity is the whole point).
     322              :     #[test]
     323            2 :     fn same_pattern_is_compiled_once() {
     324            2 :         let _serial = serial_lock();
     325            2 :         let p = "^urn:ngsi-ld:Vehicle:compiled-once-[0-9]+$";
     326            2 :         assert!(cached(p).is_none(), "cold pattern must not start retained");
     327            2 :         let before = compiles();
     328            2 :         let first = compile(p).expect("valid pattern");
     329            2 :         assert!(compiles() > before, "a cold pattern is compiled");
     330            2 :         let second = compile(p).expect("valid pattern");
     331            2 :         assert!(
     332            2 :             Arc::ptr_eq(&first, &second),
     333              :             "the second call must reuse the compiled program, not rebuild it"
     334              :         );
     335            2 :         assert!(
     336            2 :             cached(p).is_some_and(|c| Arc::ptr_eq(&c, &first)),
     337              :             "the cache holds that same program"
     338              :         );
     339            2 :         assert!(first.is_match("urn:ngsi-ld:Vehicle:compiled-once-7"));
     340            2 :         assert!(
     341            2 :             !first.is_match("urn:ngsi-ld:Vehicle:compiled-once-x"),
     342              :             "a shared program still rejects what the pattern excludes"
     343              :         );
     344            2 :     }
     345              : 
     346              :     /// An invalid pattern yields `Regex::new`'s own error, verbatim, so the
     347              :     /// spec error each call site maps it to is unchanged — an `idPattern`
     348              :     /// stays 400 BadRequestData (Table 6.3.2-1) and never becomes a 500.
     349              :     /// What is retained for it is the refusal, never a program.
     350              :     #[test]
     351            2 :     fn invalid_pattern_keeps_the_error_regex_new_returns() {
     352            2 :         let _serial = serial_lock();
     353           14 :         for p in ["[", "(", "a{2,1}", "(?P<", "*", "\\", "(?"] {
     354           14 :             let want = regex::Regex::new(p).map_err(|e| e.to_string());
     355           14 :             let got = compile(p).map(|_| ()).map_err(|e| e.to_string());
     356           14 :             assert!(want.is_err(), "fixture must be an invalid pattern: {p:?}");
     357           14 :             assert_eq!(
     358           14 :                 got.map(|_| ()),
     359           14 :                 want.map(|_| ()),
     360              :                 "the cache must return the same error text for {p:?}"
     361              :             );
     362           14 :             assert!(cached(p).is_none(), "an invalid pattern yields no program");
     363           14 :             assert!(
     364           14 :                 compile(p).is_err(),
     365              :                 "and it stays an error on the second call for {p:?}"
     366              :             );
     367              :         }
     368            2 :         assert!(compile("^ok$").is_ok(), "a valid pattern is not rejected");
     369            2 :     }
     370              : 
     371              :     /// Retention is bounded in both dimensions: distinct patterns are
     372              :     /// client input, so many of them must grow the map past neither
     373              :     /// `MAX_REGEX_CACHE` nor `MAX_REGEX_CACHE_BYTES`, and the programs
     374              :     /// handed out across an eviction stay correct.
     375              :     #[test]
     376            2 :     fn cache_stays_bounded_under_many_distinct_patterns() {
     377            2 :         let _serial = serial_lock();
     378         6144 :         for i in 0..MAX_REGEX_CACHE * 3 {
     379         6144 :             let p = format!("^bounded-{i}-[a-z]+$");
     380         6144 :             let re = compile(&p).expect("valid pattern");
     381         6144 :             assert!(re.is_match(&format!("bounded-{i}-abc")));
     382         6144 :             assert!(!re.is_match(&format!("bounded-{i}-123")));
     383         6144 :             assert!(
     384         6144 :                 len() <= MAX_REGEX_CACHE,
     385              :                 "retained {} patterns after {i} distinct ones",
     386            0 :                 len()
     387              :             );
     388         6144 :             assert!(
     389         6144 :                 retained_bytes() <= MAX_REGEX_CACHE_BYTES,
     390              :                 "charged {} bytes after {i} distinct patterns",
     391            0 :                 retained_bytes()
     392              :             );
     393              :         }
     394            2 :         assert!(len() > 0, "the cache must still be caching after a flush");
     395            2 :     }
     396              : 
     397              :     /// A subscription fan-out evaluates the same `idPattern` per event while
     398              :     /// query traffic keeps bringing new `patternOp` operands in, so the
     399              :     /// working set and the entries that overflow a bound are different
     400              :     /// patterns. Crossing a bound must therefore cost the entries nothing is
     401              :     /// using: recompiling the live working set on the request unlucky enough
     402              :     /// to cross the line is the cost the eviction order exists to avoid.
     403              :     #[test]
     404            2 :     fn eviction_drops_the_least_recently_used_half() {
     405            2 :         let mut map: HashMap<Box<str>, Entry<u32>> = HashMap::new();
     406           16 :         for i in 0..8u32 {
     407           16 :             map.insert(format!("p{i}").into(), stamp(i));
     408           16 :         }
     409              :         // three of the eight are in use and are stamped again
     410            6 :         for k in ["p0", "p3", "p7"] {
     411            6 :             touch(map.get(k).expect("present"));
     412            6 :         }
     413            2 :         evict_lru_half(&mut map);
     414            2 :         assert_eq!(map.len(), 4, "half of eight is four");
     415            6 :         for k in ["p0", "p3", "p7"] {
     416            6 :             assert!(map.contains_key(k), "{k} was in use and must survive");
     417              :         }
     418              :         // the survivors are exactly the newest half: the fourth is the
     419              :         // newest of the untouched ones
     420            2 :         assert!(
     421            2 :             map.contains_key("p6"),
     422              :             "the newest untouched entry survives"
     423              :         );
     424            8 :         for k in ["p1", "p2", "p4", "p5"] {
     425            8 :             assert!(!map.contains_key(k), "{k} was the oldest and must go");
     426              :         }
     427            2 :     }
     428              : 
     429              :     /// The bound holds whatever the map's size, so the halving has to be
     430              :     /// defined at the sizes where "half" is not a whole entry.
     431              :     #[test]
     432            2 :     fn eviction_of_a_map_too_small_to_halve_empties_it() {
     433            2 :         let mut map: HashMap<Box<str>, Entry<u32>> = HashMap::new();
     434            2 :         evict_lru_half(&mut map);
     435            2 :         assert!(map.is_empty(), "an empty map stays empty");
     436            2 :         map.insert("only".into(), stamp(0));
     437            2 :         evict_lru_half(&mut map);
     438            2 :         assert!(map.is_empty(), "one entry cannot be halved and is dropped");
     439            2 :     }
     440              : 
     441              :     /// 4.9 evaluates a `patternOp` per candidate Entity and 5.2.33 an
     442              :     /// `idPattern` per event per Subscription, so an unbounded compile is
     443              :     /// multiplied by the candidate count. Program size follows counted
     444              :     /// repetition of a character class, not pattern length, so the bound
     445              :     /// has to be on the program: above the ceiling the pattern is refused,
     446              :     /// and the refusal is remembered so it costs one build pass, ever.
     447              :     #[test]
     448            2 :     fn a_program_above_the_ceiling_is_refused_once_not_rebuilt_per_candidate() {
     449            2 :         let _serial = serial_lock();
     450            2 :         let p = r"(?:\p{Any}{100}){100}";
     451            2 :         assert_eq!(p.len(), 21, "the fixture is what a client can send");
     452            2 :         assert!(
     453            2 :             build(p, MAX_REGEX_PROGRAM_BYTES).is_err(),
     454              :             "fixture must ask for a program above the ceiling"
     455              :         );
     456            2 :         let before = compiles();
     457            2 :         assert!(
     458            2 :             compile(p).is_err(),
     459              :             "a program above the ceiling is refused, not compiled"
     460              :         );
     461            2 :         let after = compiles();
     462            2 :         assert_eq!(after, before + 1, "one refusal costs one build pass");
     463            2 :         for _ in 0..64 {
     464          128 :             assert!(compile(p).is_err(), "and it stays refused");
     465              :         }
     466            2 :         assert_eq!(
     467            2 :             compiles(),
     468              :             after,
     469              :             "the refusal is remembered, not rebuilt for the next candidate"
     470              :         );
     471            2 :         assert!(cached(p).is_none(), "a refusal retains no program");
     472            2 :     }
     473              : 
     474              :     /// The ceiling bounds the automaton, not the expressiveness a
     475              :     /// deployment needs: the patterns an `idPattern` or a `~=` operand
     476              :     /// actually carries compile well inside it and are retained.
     477              :     #[test]
     478            2 :     fn the_ceiling_admits_the_patterns_a_deployment_writes() {
     479            2 :         let _serial = serial_lock();
     480           10 :         for p in [
     481            2 :             r"^urn:ngsi-ld:Vehicle:.*$",
     482            2 :             r"^urn:ngsi-ld:(Vehicle|Sensor|Building):[A-Za-z0-9_-]{1,64}$",
     483            2 :             r"^urn:ngsi-ld:Device:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
     484            2 :             r"(?i)^urn:ngsi-ld:vehicle:.*$",
     485            2 :             r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$",
     486            2 :         ] {
     487           10 :             assert!(compile(p).is_ok(), "the ceiling must not refuse {p:?}");
     488           10 :             assert!(
     489           10 :                 cached(p).is_some(),
     490              :                 "what the ceiling admits, the cache retains: {p:?}"
     491              :             );
     492              :         }
     493            2 :     }
     494              : 
     495              :     /// Below the bounds nothing is evicted: a parsed q, a built geoQ and
     496              :     /// 300 small programs all stay retained, and the counters report them
     497              :     /// (mutants on the eviction conditions and the byte charge).
     498              :     #[test]
     499            2 :     fn below_the_bounds_every_entry_is_kept() {
     500            2 :         let _serial = serial_lock();
     501            2 :         let a = q_node("keep_a>1").expect("valid q");
     502            2 :         q_node("keep_b>1").expect("valid q");
     503            2 :         assert!(Arc::ptr_eq(&a, &q_node("keep_a>1").expect("valid q")));
     504              : 
     505            2 :         let builds = std::cell::Cell::new(0);
     506            4 :         let build = || {
     507            4 :             builds.set(builds.get() + 1);
     508            4 :             near_point()
     509            4 :         };
     510            2 :         geo_query("keep-geo-a", build).expect("geo");
     511            2 :         geo_query("keep-geo-b", build).expect("geo");
     512            2 :         geo_query("keep-geo-a", build).expect("geo");
     513            2 :         assert_eq!(builds.get(), 2, "the first geoQ was evicted");
     514              : 
     515          600 :         let patterns: Vec<String> = (0..300).map(|i| format!("^keep-{i}$")).collect();
     516          600 :         for p in &patterns {
     517          600 :             compile(p).expect("compiles");
     518          600 :         }
     519          600 :         let missing = patterns.iter().filter(|p| cached(p).is_none()).count();
     520            2 :         assert_eq!(missing, 0, "{missing} of 300 small programs were evicted");
     521            2 :         assert!(len() >= 300);
     522              :         // the documented tier, written out: a small program costs 32 KiB
     523            2 :         assert!(retained_bytes() >= 300 * 32 * 1024);
     524            2 :     }
     525              : 
     526              :     /// Two callers that missed one pattern together both retain it; the
     527              :     /// charge follows the entry that stays, so the byte count is still the
     528              :     /// sum of what the map holds.
     529              :     #[test]
     530            2 :     fn retaining_one_pattern_twice_charges_it_once() {
     531            2 :         let _serial = serial_lock();
     532            2 :         let mut c = CACHE.write().expect("cache");
     533            4 :         let re = || Ok(Arc::new(regex::Regex::new("^twice$").expect("regex")));
     534            2 :         retain(&mut c, "^twice$", re(), 100);
     535            2 :         retain(&mut c, "^twice$", re(), 100);
     536            2 :         let held: usize = c.values().map(|e| e.0 .1).sum();
     537            2 :         assert_eq!(retained_bytes(), held, "the replaced entry stayed charged");
     538            2 :     }
     539              : 
     540              :     /// A geoQ larger than the per-entry ceiling is built per call and never
     541              :     /// retained: the cache's bytes stay bounded by entries times ceiling.
     542              :     #[test]
     543            2 :     fn an_oversized_geo_key_is_not_retained() {
     544            2 :         let _serial = serial_lock();
     545            2 :         let key = format!("big-geo-{}", "x".repeat(MAX_GEO_KEY_BYTES));
     546            2 :         geo_query(&key, near_point).expect("geo");
     547            2 :         assert!(
     548            2 :             GEO_CACHE
     549            2 :                 .read()
     550            2 :                 .is_ok_and(|c| !c.contains_key(key.as_str())),
     551              :             "an oversized geoQ key was retained"
     552              :         );
     553            2 :     }
     554              : 
     555              :     /// The byte budget evicts on its own, before the entry count does:
     556              :     /// programs past the 32 KiB tier are charged 256 KiB each, so ~260 of
     557              :     /// them overrun MAX_REGEX_CACHE_BYTES long before MAX_REGEX_CACHE.
     558              :     #[test]
     559            2 :     fn the_byte_budget_evicts_before_the_entry_count() {
     560            2 :         let _serial = serial_lock();
     561          600 :         for i in 0..300 {
     562          600 :             compile(&format!("^[a-z]{{{}}}$", 3000 + i)).expect("compiles");
     563          600 :             assert!(
     564          600 :                 retained_bytes() <= MAX_REGEX_CACHE_BYTES,
     565              :                 "after {i}: {} bytes retained",
     566            0 :                 retained_bytes()
     567              :             );
     568              :         }
     569            2 :         assert!(len() < MAX_REGEX_CACHE);
     570            2 :     }
     571              : 
     572              :     /// A refusal's key and message are client-sized (a syntax error
     573              :     /// reprints the pattern), so the byte budget is charged for them: a
     574              :     /// retained refusal is never free.
     575              :     #[test]
     576            2 :     fn a_retained_refusal_is_charged_its_key_and_message() {
     577            2 :         let _serial = serial_lock();
     578            2 :         let before = retained_bytes();
     579            2 :         let p = format!("({}", "a".repeat(1 << 20));
     580            2 :         assert!(compile(&p).is_err());
     581            2 :         assert!(
     582            2 :             retained_bytes() >= before + p.len(),
     583              :             "a 1 MiB refusal was retained free of charge"
     584              :         );
     585            2 :     }
     586              : 
     587              :     /// The same q text parses once and every caller shares the tree; an
     588              :     /// invalid or over-complex q is parsed as before and never retained, so
     589              :     /// its call-site handling (no-match / 400) is unchanged.
     590              :     #[test]
     591            2 :     fn q_text_is_parsed_once_and_bad_q_is_not_retained() {
     592            2 :         let _serial = serial_lock();
     593            2 :         let q = r#"speed>20;brandName=="cache-q-probe""#;
     594            2 :         let first = q_node(q).expect("valid q");
     595            2 :         let second = q_node(q).expect("valid q");
     596            2 :         assert!(
     597            2 :             Arc::ptr_eq(&first, &second),
     598              :             "the second evaluation must reuse the parsed tree"
     599              :         );
     600            8 :         for bad in ["", "==5", "a==\"unterminated", "a==1)"] {
     601            8 :             assert!(q_node(bad).is_none(), "invalid q {bad:?} must not parse");
     602            8 :             assert!(
     603            8 :                 Q_CACHE.read().is_ok_and(|c| !c.contains_key(bad)),
     604              :                 "invalid q {bad:?} must not be retained"
     605              :             );
     606              :         }
     607              :         // boundedness under distinct client q texts
     608         2176 :         for i in 0..MAX_REGEX_CACHE + 64 {
     609         2176 :             q_node(&format!("qbound{i}>0")).expect("valid q");
     610         2176 :             assert!(
     611         2176 :                 Q_CACHE.read().map(|c| c.len()).unwrap_or(usize::MAX) <= MAX_REGEX_CACHE,
     612              :                 "q cache must stay bounded"
     613              :             );
     614              :         }
     615            2 :     }
     616              : 
     617              :     /// The geo cache shares one parsed geometry per distinct geoQ key, and a
     618              :     /// geoQ whose build fails is rebuilt (and re-fails) per call — never a
     619              :     /// cached wrong answer.
     620              :     #[test]
     621            2 :     fn geo_query_is_shared_per_key_and_failures_are_not_retained() {
     622            2 :         let _serial = serial_lock();
     623            2 :         let build_calls = std::sync::atomic::AtomicUsize::new(0);
     624            4 :         let params = || {
     625            4 :             let mut m = HashMap::new();
     626            4 :             m.insert("georel".to_owned(), "near;maxDistance==2000".to_owned());
     627            4 :             m.insert("geometry".to_owned(), "Point".to_owned());
     628            4 :             m.insert("coordinates".to_owned(), "[13.38,52.52]".to_owned());
     629            4 :             m
     630            4 :         };
     631            4 :         let build = || {
     632            4 :             build_calls.fetch_add(1, Ordering::Relaxed);
     633            4 :             crate::geo::GeoQuery::from_params(&params()).ok().flatten()
     634            4 :         };
     635            2 :         let first = geo_query("geo-probe-1", build).expect("valid geoQ");
     636            2 :         let second = geo_query("geo-probe-1", build).expect("valid geoQ");
     637            2 :         assert!(Arc::ptr_eq(&first, &second), "one parse per key");
     638            2 :         assert_eq!(
     639            2 :             build_calls.load(Ordering::Relaxed),
     640              :             1,
     641              :             "the second call must not rebuild"
     642              :         );
     643            2 :         assert!(
     644            2 :             geo_query("geo-probe-bad", || None).is_none(),
     645              :             "a failing build yields None"
     646              :         );
     647            2 :         assert!(
     648            2 :             geo_query("geo-probe-bad", build).is_some(),
     649              :             "…and is not retained as a failure: the next build runs"
     650              :         );
     651            2 :     }
     652              : 
     653              :     /// Concurrent use: the same and different patterns compiled from many
     654              :     /// threads at once must stay correct and bounded, never deadlock, and
     655              :     /// never hand out a program built for another pattern.
     656              :     #[test]
     657            2 :     fn concurrent_compiles_are_safe() {
     658            2 :         let _serial = serial_lock();
     659            2 :         let pats = [
     660            2 :             ("^shared-a-[0-9]+$", "shared-a-1", "shared-a-x"),
     661            2 :             ("^shared-b-[a-z]+$", "shared-b-z", "shared-b-9"),
     662            2 :             ("shared-c", "xx-shared-c-xx", "shared-d"),
     663            2 :         ];
     664            2 :         let handles: Vec<_> = (0..8)
     665           16 :             .map(|t| {
     666           16 :                 std::thread::spawn(move || {
     667         3200 :                     for round in 0..200 {
     668         3200 :                         let (p, hit, miss) = pats[round % pats.len()];
     669         3200 :                         let re = compile(p).expect("valid pattern");
     670         3200 :                         assert!(re.is_match(hit), "thread {t} pattern {p}");
     671         3200 :                         assert!(!re.is_match(miss), "thread {t} pattern {p}");
     672              :                         // a per-thread pattern exercises concurrent inserts
     673         3200 :                         let own = format!("^t{t}-r{round}-[0-9]+$");
     674         3200 :                         assert!(compile(&own)
     675         3200 :                             .expect("valid")
     676         3200 :                             .is_match(&format!("t{t}-r{round}-5")));
     677         3200 :                         assert!(compile("[").is_err(), "invalid stays invalid");
     678              :                     }
     679           16 :                 })
     680           16 :             })
     681            2 :             .collect();
     682           16 :         for h in handles {
     683           16 :             h.join().expect("no thread panicked");
     684           16 :         }
     685            2 :         assert!(len() <= MAX_REGEX_CACHE, "still bounded after the race");
     686            2 :         assert!(
     687            2 :             cached("shared-c").is_some(),
     688              :             "a hot pattern survives concurrent use"
     689              :         );
     690            2 :     }
     691              : }
        

Generated by: LCOV version 2.0-1