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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
evaluation results use) and functions as the registered callables. Mutating the
returned dict does not change the context.

### Performance

- **A `cel.Context` now builds its CEL-side environment once and reuses it.**
Previously every `evaluate()` and `Program.execute()` call re-converted each
variable and re-wrapped each registered Python function into a fresh cel-rust
context, so the per-call cost grew with the size of the context: executing a
pre-compiled `1 + 2` against a `Context` carrying the 47 extended-stdlib
functions (what the `cel` CLI sets up) took about 9 µs, against about 0.2 µs
with no context. The environment is now cached on the `Context` and shared by
every evaluation until `add_variable`, `add_function` or `update` changes it,
bringing that case down to about 0.2 µs as well. `set_variable_resolver` does
not invalidate the cache: the resolver is bound per call in a child scope
(cel-rust's `Context::new_inner_scope`), with the same lookup order as before
(resolver, then registered variables). Dict contexts are still materialised on
every call, since a dict can change between calls without notice; for hot
loops, prefer a `Context`.
- Behaviour that is unchanged and now pinned by tests: every mutator is visible on
the next evaluation; a Python function may modify, or evaluate against, the
`Context` it was registered on while an evaluation is in progress (the change
applies from the next evaluation); and one `Context` can be shared between
threads.

### Changed

- The `ValueError` raised when the interpreter panics during evaluation now reads
"Internal evaluation error" rather than "Internal parser error"; parse failures
were never what it reported.

### Fixed

- The type stubs (`cel.pyi`) now use the parameter names the runtime actually
Expand Down
6 changes: 6 additions & 0 deletions docs/reference/python-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,12 @@ The Context class provides more control over the evaluation environment than sim
- Register custom Python functions
- Manage complex evaluation scenarios

It is also the fast path for repeated evaluation. A `Context` converts its
variables and wraps its functions for the CEL engine once, on first use, and
reuses that work for every subsequent `evaluate()` or `Program.execute()` call
until the context is modified. A dict passed as the context is converted afresh
on every call, because it can change between calls without notice.

```python
from cel import evaluate, Context

Expand Down
67 changes: 64 additions & 3 deletions src/context.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
use ::cel::objects::TryIntoValue;
use ::cel::Value;
use ::cel::{Context as CelContext, Value};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use pyo3::types::PyDict;
use pyo3::IntoPyObjectExt;
use std::collections::HashMap;
use std::sync::{Arc, Mutex, PoisonError};

#[pyo3::pyclass]
/// Manages the evaluation environment for CEL expressions.
Expand Down Expand Up @@ -41,15 +42,69 @@ use std::collections::HashMap;
/// for concurrent use or implement your own synchronization.
///
/// Performance Tips:
/// - Reuse Context objects for multiple evaluations when possible
/// - Reuse Context objects for multiple evaluations when possible: the
/// CEL-side environment (converted variables and wrapped functions) is
/// built on first use and reused until the context is modified
/// - Pre-populate Context with all needed variables and functions
/// - Avoid frequent add_variable/add_function calls in hot code paths
/// - Avoid frequent add_variable/add_function calls in hot code paths, as
/// each one discards the cached environment
pub struct Context {
pub variables: HashMap<String, Value>,
pub functions: HashMap<String, Py<PyAny>>,
/// Optional Python callable for lazy variable resolution. Invoked with a
/// variable name; returns the value (or None to fall through to `variables`).
pub resolver: Option<Py<PyAny>>,
/// The cel environment built from `variables` and `functions`, created on
/// first use and shared by every evaluation until a mutator clears it.
///
/// Building it boxes each variable and wraps each Python callable in a
/// closure. That used to happen on every `evaluate()`/`execute()` call and
/// dominated the cost of evaluating against a context with many functions
/// (the CLI registers the whole extended stdlib). The resolver is
/// deliberately not part of it: it is bound per call in a child scope, so
/// setting one does not invalidate the cache.
cel: Mutex<Option<Arc<CelContext<'static>>>>,
}

impl Context {
/// Materialises a fresh cel environment from the registered variables and
/// functions. Used for the cache and, on every call, for dict contexts.
pub(crate) fn build_cel_context(&self, py: Python<'_>) -> CelContext<'static> {
let mut environment = crate::new_environment();
for (name, value) in &self.variables {
environment.add_variable_from_value(name.clone(), value.clone());
}
for (name, function) in &self.functions {
crate::register_python_function(&mut environment, name, function.clone_ref(py));
}
environment
}

/// Returns the cached cel environment, building it on first use.
///
/// The `Arc` lets a caller keep evaluating against a consistent snapshot
/// even if a Python callback mutates this `Context` mid-evaluation; the
/// mutation simply takes effect from the next evaluation.
pub(crate) fn cel_context(&self, py: Python<'_>) -> Arc<CelContext<'static>> {
let mut cached = self.cel.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(existing) = cached.as_ref() {
return Arc::clone(existing);
}
let built = Arc::new(self.build_cel_context(py));
*cached = Some(Arc::clone(&built));
built
}

/// Drops the cached environment so the next evaluation rebuilds it.
///
/// Every mutator calls this *before* touching `variables` or `functions`.
/// A mutator can fail part-way (`update()` rejects a later key after
/// inserting earlier ones), and invalidating up front means the cache can
/// never describe state the maps no longer hold. Nothing can repopulate it
/// while the mutator runs, because the mutator holds `&mut self`.
fn invalidate(&mut self) {
*self.cel.get_mut().unwrap_or_else(PoisonError::into_inner) = None;
}
}

#[pyo3::pymethods]
Expand Down Expand Up @@ -130,6 +185,7 @@ impl Context {
variables: HashMap::new(),
functions: HashMap::new(),
resolver: None,
cel: Mutex::new(None),
};

if let Some(variables) = variables {
Expand Down Expand Up @@ -208,6 +264,7 @@ impl Context {
/// >>> context.add_function("regex_match", re.match)
/// >>> # Note: This would need proper error handling in practice
fn add_function(&mut self, name: String, function: Py<PyAny>) {
self.invalidate();
self.functions.insert(name, function);
}

Expand Down Expand Up @@ -346,6 +403,7 @@ impl Context {
/// >>> evaluate("counter", context)
/// 2
pub fn add_variable(&mut self, name: String, value: &Bound<'_, PyAny>) -> PyResult<()> {
self.invalidate();
let value = crate::RustyPyType(value).try_into_value().map_err(|e| {
pyo3::exceptions::PyValueError::new_err(format!(
"Failed to convert variable '{name}': {e}"
Expand Down Expand Up @@ -442,6 +500,9 @@ impl Context {
/// >>> evaluate('join(["user", name, string(age)])', context)
/// 'user-Bob-30'
pub fn update(&mut self, variables: &Bound<'_, PyDict>) -> PyResult<()> {
// Before the loop, not after: a bad key or value part-way through
// returns early with the earlier entries already applied.
self.invalidate();
for (key, value) in variables {
// Attempt to extract the key as a String
let key = key
Expand Down
Loading