feat!: add cooperative execution-time limits across engine, VM, and binding (#539)

- Introduce ExecutionTimer/ExecutionTimerConfig to allow limiting evaluating time.
- To amortize time checking costs, checking interval can be configured via the notion of work units
- A global fallback time limit can be set to universally limit all evaluation in addition to engine level limit setting.
- Implement limnits in interpreter and RVM. In RVM, also handle suspend/resume so that time during pause is not counted.
- Add engine-level APIs to set/clear per-engine timer configuration and apply global fallback defaults.
- Surface execution-time limits through FFI and C# bindings
- Add C# tests and example usage to validate engine overrides, global fallback behavior, and compiled policy enforcement.
- Expand docs for execution-time limit
- Add interpreter YAML cases and VM unit tests for time-limit behavior and deterministic time sources.

Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>
This commit is contained in:
Anand Krishnamoorthi
2026-01-28 05:58:03 +05:30
committed by GitHub
parent e68e852ee3
commit 394625d4bc
32 changed files with 2259 additions and 183 deletions

View File

@@ -15,11 +15,197 @@
use std::env;
use crate::test_utils::{check_output, ValueOrVec};
use crate::utils::limits::{
acquire_limits_test_lock, fallback_execution_timer_config, ExecutionTimerConfig,
};
use crate::*;
use anyhow::{bail, Result};
use core::num::NonZeroU32;
use core::time::Duration;
use serde::{Deserialize, Serialize};
use test_generator::test_resources;
use timer_test_support::{
apply_engine_timer, configure_time_source, reset_time_source, GlobalTimerGuard,
};
mod timer_test_support {
use super::{ExecutionTimerTestConfig, TimeSourceTestConfig};
#[cfg(any(test, not(feature = "std")))]
use crate::utils::limits::set_time_source;
use crate::utils::limits::{
fallback_execution_timer_config, set_fallback_execution_timer_config, ExecutionTimerConfig,
TimeSource,
};
use crate::Engine;
use anyhow::{anyhow, Result};
use core::num::NonZeroU32;
use core::time::Duration;
use std::collections::VecDeque;
use std::sync::{Mutex, Once};
use std::vec::Vec;
pub struct GlobalTimerGuard {
previous: Option<ExecutionTimerConfig>,
changed: bool,
}
impl GlobalTimerGuard {
pub fn apply(spec: Option<&ExecutionTimerTestConfig>) -> Result<Self> {
let previous = fallback_execution_timer_config();
let mut changed = false;
if let Some(config_spec) = spec {
if config_spec.disable.unwrap_or(false) {
set_fallback_execution_timer_config(None);
changed = true;
} else {
let config = config_from_spec(config_spec)?;
set_fallback_execution_timer_config(config);
changed = true;
}
}
Ok(Self { previous, changed })
}
}
impl Drop for GlobalTimerGuard {
fn drop(&mut self) {
if self.changed {
set_fallback_execution_timer_config(self.previous);
}
}
}
pub fn configure_time_source(spec: Option<&TimeSourceTestConfig>) {
ensure_time_source_registered();
let mut state = TIME_SOURCE_STATE
.lock()
.expect("time source mutex poisoned");
if let Some(cfg) = spec {
state.default_increment = cfg
.default_increment_ms
.map(Duration::from_millis)
.unwrap_or(DEFAULT_INCREMENT);
state.template_increments = cfg
.increments_ms
.iter()
.copied()
.map(Duration::from_millis)
.collect();
} else {
state.default_increment = DEFAULT_INCREMENT;
state.template_increments.clear();
}
state.reset_from_template();
}
pub fn reset_time_source() {
let mut state = TIME_SOURCE_STATE
.lock()
.expect("time source mutex poisoned");
state.reset_from_template();
}
pub fn apply_engine_timer(engine: &mut Engine, spec: &ExecutionTimerTestConfig) -> Result<()> {
if spec.disable.unwrap_or(false) {
engine.clear_execution_timer_config();
return Ok(());
}
match config_from_spec(spec)? {
Some(config) => engine.set_execution_timer_config(config),
None => engine.clear_execution_timer_config(),
}
Ok(())
}
const DEFAULT_INCREMENT: Duration = Duration::from_millis(1);
struct TestTimeSource;
struct TimeSourceState {
current: Duration,
started: bool,
default_increment: Duration,
increments: VecDeque<Duration>,
template_increments: Vec<Duration>,
}
impl TimeSourceState {
const fn new() -> Self {
Self {
current: Duration::ZERO,
started: false,
default_increment: DEFAULT_INCREMENT,
increments: VecDeque::new(),
template_increments: Vec::new(),
}
}
fn reset_from_template(&mut self) {
self.current = Duration::ZERO;
self.started = false;
self.increments = VecDeque::from(self.template_increments.clone());
}
}
impl TimeSource for TestTimeSource {
fn now(&self) -> Option<Duration> {
let mut state = TIME_SOURCE_STATE
.lock()
.expect("time source mutex poisoned");
if !state.started {
state.started = true;
return Some(state.current);
}
let increment = state
.increments
.pop_front()
.unwrap_or(state.default_increment);
state.current = state.current.saturating_add(increment);
Some(state.current)
}
}
static TEST_TIME_SOURCE: TestTimeSource = TestTimeSource;
static TIME_SOURCE_STATE: Mutex<TimeSourceState> = Mutex::new(TimeSourceState::new());
static TIME_SOURCE_ONCE: Once = Once::new();
fn ensure_time_source_registered() {
#[cfg(any(test, not(feature = "std")))]
TIME_SOURCE_ONCE.call_once(|| {
let _ = set_time_source(&TEST_TIME_SOURCE);
});
}
fn config_from_spec(spec: &ExecutionTimerTestConfig) -> Result<Option<ExecutionTimerConfig>> {
let limit_ms = match spec.limit_ms {
Some(value) => value,
None => return Ok(None),
};
let check_interval = spec
.check_interval
.map(|interval| {
NonZeroU32::new(interval)
.ok_or_else(|| anyhow!("execution_timer.check_interval must be non-zero"))
})
.transpose()? // Result<Option<NonZeroU32>>
.unwrap_or(NonZeroU32::MIN);
Ok(Some(ExecutionTimerConfig {
limit: Duration::from_millis(limit_ms),
check_interval,
}))
}
}
#[cfg(feature = "azure_policy")]
mod load_target_definitions {
@@ -158,6 +344,7 @@ fn push_query_results(query_results: QueryResults, results: &mut Vec<Value>) {
}
}
#[allow(clippy::too_many_arguments)]
pub fn eval_file(
regos: &[String],
data_opt: Option<Value>,
@@ -166,6 +353,7 @@ pub fn eval_file(
enable_tracing: bool,
strict: bool,
v0: bool,
execution_timer: Option<&ExecutionTimerTestConfig>,
) -> Result<(Vec<Value>, Vec<String>)> {
let mut engine: Engine = Engine::new();
engine.set_rego_v0(v0);
@@ -175,6 +363,15 @@ pub fn eval_file(
#[cfg(feature = "coverage")]
engine.set_enable_coverage(true);
let use_default_timer =
execution_timer.is_none() && fallback_execution_timer_config().is_none();
if let Some(spec) = execution_timer {
apply_engine_timer(&mut engine, spec)?;
} else if use_default_timer {
engine.set_execution_timer_config(default_engine_execution_timer_config());
}
let mut results = vec![];
let mut files = vec![];
@@ -199,10 +396,17 @@ pub fn eval_file(
}
let mut engine_full = engine.clone();
if let Some(spec) = execution_timer {
apply_engine_timer(&mut engine_full, spec)?;
} else if use_default_timer {
engine_full.set_execution_timer_config(default_engine_execution_timer_config());
}
if inputs.is_empty() {
// Now eval the query.
reset_time_source();
let r = engine.eval_query(query.to_string(), enable_tracing)?;
reset_time_source();
let r_full = engine_full.eval_query_and_all_rules(query.to_string(), enable_tracing)?;
if r != r_full {
std::println!(
@@ -220,7 +424,9 @@ pub fn eval_file(
engine_full.set_input(input);
// Now eval the query.
reset_time_source();
let r = engine.eval_query(query.to_string(), enable_tracing)?;
reset_time_source();
let r_full = engine_full.eval_query_and_all_rules(query.to_string(), enable_tracing)?;
if r != r_full {
std::println!(
@@ -239,6 +445,7 @@ pub fn eval_file(
}
#[cfg(feature = "azure_policy")]
#[allow(clippy::too_many_arguments)]
pub fn eval_file_with_rule_evaluation(
regos: &[String],
data_opt: Option<Value>,
@@ -247,6 +454,7 @@ pub fn eval_file_with_rule_evaluation(
_enable_tracing: bool,
strict: bool,
v0: bool,
execution_timer: Option<&ExecutionTimerTestConfig>,
) -> Result<(Vec<Value>, Vec<String>)> {
let mut engine: Engine = Engine::new();
engine.set_rego_v0(v0);
@@ -256,6 +464,15 @@ pub fn eval_file_with_rule_evaluation(
#[cfg(feature = "coverage")]
engine.set_enable_coverage(true);
let use_default_timer =
execution_timer.is_none() && fallback_execution_timer_config().is_none();
if let Some(spec) = execution_timer {
apply_engine_timer(&mut engine, spec)?;
} else if use_default_timer {
engine.set_execution_timer_config(default_engine_execution_timer_config());
}
let mut results = vec![];
let mut files = vec![];
@@ -288,7 +505,9 @@ pub fn eval_file_with_rule_evaluation(
for input in inputs {
engine.set_input(input.clone());
// Use eval_rule instead of eval_query for target tests
reset_time_source();
let r_engine = engine.eval_rule(query.to_string())?;
reset_time_source();
let r_compiled_policy = compiled_policy.eval_with_input(input)?;
assert_eq!(r_engine, r_compiled_policy);
results.push(r_engine);
@@ -297,6 +516,21 @@ pub fn eval_file_with_rule_evaluation(
Ok((results, engine.take_prints()?))
}
#[derive(Serialize, Deserialize, PartialEq, Debug, Default)]
#[serde(default)]
pub struct ExecutionTimerTestConfig {
limit_ms: Option<u64>,
check_interval: Option<u32>,
disable: Option<bool>,
}
#[derive(Serialize, Deserialize, PartialEq, Debug, Default)]
#[serde(default)]
pub struct TimeSourceTestConfig {
increments_ms: Vec<u64>,
default_increment_ms: Option<u64>,
}
#[derive(Serialize, Deserialize, PartialEq, Debug)]
struct TestCase {
data: Option<Value>,
@@ -315,18 +549,33 @@ struct TestCase {
want_error_code: Option<String>,
#[serde(default = "default_strict")]
strict: bool,
#[serde(default)]
execution_timer: Option<ExecutionTimerTestConfig>,
#[serde(default)]
global_execution_timer: Option<ExecutionTimerTestConfig>,
#[serde(default)]
time_source: Option<TimeSourceTestConfig>,
}
fn default_strict() -> bool {
true
}
fn default_engine_execution_timer_config() -> ExecutionTimerConfig {
ExecutionTimerConfig {
limit: Duration::from_secs(5),
check_interval: NonZeroU32::new(100).unwrap_or(NonZeroU32::MIN),
}
}
#[derive(Serialize, Deserialize, PartialEq, Debug)]
struct YamlTest {
cases: Vec<TestCase>,
}
fn yaml_test_impl(file: &str) -> Result<()> {
let _limits_lock = acquire_limits_test_lock();
let yaml_str = std::fs::read_to_string(file)?;
let test: YamlTest = serde_yaml::from_str(&yaml_str)?;
@@ -382,6 +631,9 @@ fn yaml_test_impl(file: &str) -> Result<()> {
continue;
}
let _timer_guard = GlobalTimerGuard::apply(case.global_execution_timer.as_ref())?;
configure_time_source(case.time_source.as_ref());
match (&case.want_result, &case.error) {
(Some(_), None) | (None, Some(_)) => (),
_ if case.no_result != Some(true) => {
@@ -405,6 +657,7 @@ fn yaml_test_impl(file: &str) -> Result<()> {
enable_tracing,
case.strict,
v0,
case.execution_timer.as_ref(),
)
}
#[cfg(not(feature = "azure_policy"))]
@@ -420,6 +673,7 @@ fn yaml_test_impl(file: &str) -> Result<()> {
enable_tracing,
case.strict,
v0,
case.execution_timer.as_ref(),
)
};