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
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- *(rvm)* add opt-in per-execution memory budgets for run-to-completion evaluation, including typed Rust and binding errors ([#792](https://github.com/microsoft/regorus/pull/792))

## [0.11.0](https://github.com/microsoft/regorus/compare/regorus-v0.10.1...regorus-v0.11.0) - 2026-07-21

### Added
Expand Down Expand Up @@ -634,4 +638,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- LICENSE committed
- CODE_OF_CONDUCT.md committed
- Initial commit

41 changes: 32 additions & 9 deletions benches/rvm_benchmark.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@

use std::hint::black_box;
use std::num::NonZeroU32;
#[cfg(all(feature = "allocator-memory-limits", not(miri)))]
use std::num::NonZeroU64;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
Expand All @@ -50,14 +52,16 @@ use regorus::languages::rego::compiler::Compiler;
use regorus::rvm::program::Program;
use regorus::rvm::vm::{ExecutionMode, RegoVM};
use regorus::utils::limits::ExecutionTimerConfig;
#[cfg(all(feature = "allocator-memory-limits", not(miri)))]
use regorus::MemoryBudgetConfig;
use regorus::{Engine, Rc, Value};

// ---------------------------------------------------------------------------
// Limit constants – generous ceilings that still exercise the limit-checking
// hot path (memory_check, execution_timer_tick, instruction-limit compare).
// ---------------------------------------------------------------------------

#[cfg(feature = "allocator-memory-limits")]
#[cfg(all(feature = "allocator-memory-limits", not(miri)))]
const MEMORY_LIMIT_BYTES: u64 = 256 * 1024 * 1024;
const TIME_LIMIT: Duration = Duration::from_secs(30);
const TIMER_CHECK_INTERVAL: NonZeroU32 = NonZeroU32::new(16).unwrap();
Expand All @@ -68,28 +72,39 @@ struct EvalConfig {
name: &'static str,
mode: ExecutionMode,
limits: bool,
memory_budget: bool,
}

const EVAL_CONFIGS: [EvalConfig; 4] = [
const EVAL_CONFIGS: [EvalConfig; 5] = [
EvalConfig {
name: "regular_no_limits",
mode: ExecutionMode::RunToCompletion,
limits: false,
memory_budget: false,
},
EvalConfig {
name: "regular_memory_budget",
mode: ExecutionMode::RunToCompletion,
limits: false,
memory_budget: true,
},
EvalConfig {
name: "regular_with_limits",
mode: ExecutionMode::RunToCompletion,
limits: true,
memory_budget: true,
},
EvalConfig {
name: "suspendable_no_limits",
mode: ExecutionMode::Suspendable,
limits: false,
memory_budget: false,
},
EvalConfig {
name: "suspendable_with_limits",
mode: ExecutionMode::Suspendable,
limits: true,
memory_budget: false,
},
];

Expand Down Expand Up @@ -358,22 +373,30 @@ fn compile_all_programs() -> Vec<BenchmarkProgram> {
// Limit helpers
// ---------------------------------------------------------------------------

/// Apply or remove production-style limits based on a boolean flag.
fn configure_limits(vm: &mut RegoVM, limits: bool) {
if limits {
#[cfg(feature = "allocator-memory-limits")]
/// Apply the limits selected for one benchmark configuration.
fn configure_limits(vm: &mut RegoVM, config: EvalConfig) {
#[cfg(any(miri, not(feature = "allocator-memory-limits")))]
let _ = config.memory_budget;

if config.limits {
#[cfg(all(feature = "allocator-memory-limits", not(miri)))]
regorus::set_global_memory_limit(Some(MEMORY_LIMIT_BYTES));
vm.set_execution_timer_config(Some(ExecutionTimerConfig {
limit: TIME_LIMIT,
check_interval: TIMER_CHECK_INTERVAL,
}));
vm.set_max_instructions(INSTRUCTION_LIMIT);
} else {
#[cfg(feature = "allocator-memory-limits")]
#[cfg(all(feature = "allocator-memory-limits", not(miri)))]
regorus::set_global_memory_limit(None);
vm.set_execution_timer_config(None);
vm.set_max_instructions(usize::MAX);
}

#[cfg(all(feature = "allocator-memory-limits", not(miri)))]
vm.set_memory_budget_config(config.memory_budget.then(|| MemoryBudgetConfig {

@anakrish Anand Krishnamoorthi (anakrish) Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Low: These benchmark imports/calls use #[cfg(feature = "allocator-memory-limits")], but the core MemoryBudgetConfig export and RegoVM::set_memory_budget_config are gated by all(feature = "allocator-memory-limits", not(miri)). A bench build selected under Miri can therefore fail to compile.

Suggested change:

#[cfg(all(feature = "allocator-memory-limits", not(miri)))]
use std::num::NonZeroU64;

#[cfg(all(feature = "allocator-memory-limits", not(miri)))]
use regorus::MemoryBudgetConfig;

#[cfg(all(feature = "allocator-memory-limits", not(miri)))]
vm.set_memory_budget_config(config.memory_budget.then(|| MemoryBudgetConfig {
    limit: NonZeroU64::new(MEMORY_LIMIT_BYTES).expect("non-zero memory budget"),
}));

Please use the same predicate for both imports and the configuration block.

limit: NonZeroU64::new(MEMORY_LIMIT_BYTES).expect("non-zero memory budget"),
}));
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -408,7 +431,7 @@ fn bench_cold(c: &mut Criterion) {
vm.set_data(black_box(d.clone())).unwrap();
}
vm.set_input(black_box(input.clone()));
configure_limits(&mut vm, config.limits);
configure_limits(&mut vm, config);
black_box(vm.execute().unwrap())
})
});
Expand Down Expand Up @@ -445,7 +468,7 @@ fn bench_hot(c: &mut Criterion) {
if let Some(ref d) = data {
vm.set_data(d.clone()).unwrap();
}
configure_limits(&mut vm, config.limits);
configure_limits(&mut vm, config);

// Warm up: fill register window pools, caches, etc.
vm.set_input(inputs[0].clone());
Expand Down
43 changes: 43 additions & 0 deletions bindings/csharp/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,49 @@ The Regorus C# bindings provide a modern, thread-safe API for compiling and eval
- **Thread Safety**: All operations are thread-safe without external synchronization
- **Registry Management**: Centralized management of targets and schemas
- **Policy Introspection**: Rich metadata about compiled policies
- **RVM Memory Budgets**: Optional per-execution live-memory limits for run-to-completion evaluation

## RVM Memory Budgets

`Rvm.SetMemoryBudgetConfig` configures a fresh non-zero budget for each run-to-completion execution. `Rvm.ClearMemoryBudgetConfig` restores unlimited execution.

```csharp
public readonly struct MemoryBudgetConfig
{
public MemoryBudgetConfig(ulong limitBytes);
public ulong LimitBytes { get; }
}

public sealed class Rvm : IDisposable
{
public void SetMemoryBudgetConfig(MemoryBudgetConfig config);
public void ClearMemoryBudgetConfig();
}
```

Ordinary `Execute` and `ExecuteEntryPoint` calls use the existing VM data and start a fresh budget for execution. Program compilation, program loading, and prior `SetDataJson`, `SetInputJson`, and `SetContextJson` calls occur before and outside the budget; use this path for static or preloaded data.

```csharp
using var vm = new Rvm();
vm.SetMemoryBudgetConfig(new MemoryBudgetConfig(16UL * 1024 * 1024));

try
{
var result = vm.Execute();
}
catch (RegorusMemoryBudgetExceededException)
{
// The execution exceeded its configured budget.
}
```

Native result JSON serialization and Rust `CString` allocation are included before an `Execute` or `ExecuteEntryPoint` call returns. Managed UTF-8 decoding and the managed C# `string` allocation after the native call returns are excluded.

Memory budgets require a native library built with allocator memory tracking and are supported only for run-to-completion execution. `RegorusMemoryBudgetExceededException` is thrown when a budget is exceeded. `RegorusMemoryBudgetUnsupportedException` is thrown if a configured budget is used to start or resume suspendable execution.

Enforcement samples observed live bytes at checkpoints; it is not an allocation-time peak-memory cap. An instruction, builtin, native serialization, or `CString` allocation can overshoot before it is rejected. The reported native usage is a change in the execution thread's live-byte counter, not exact memory owned by the query. Synchronous host work affects the sample, cross-thread frees can temporarily skew it, and downward baseline ratcheting does not restore lost headroom.

A reused VM gets a fresh baseline, but capacities and pools retained before that baseline can make a warm execution allocate differently from a fresh one. Failed terminal execution clears retained state. Public multi-call begin/end scopes are intentionally absent because allocator counters are thread-local and abandoned or cross-thread scopes would be unsafe. The [detailed RVM memory budget documentation](../../docs/limits/memory_budget.md) is authoritative for accounting behavior.

## Core Classes

Expand Down
29 changes: 29 additions & 0 deletions bindings/csharp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,35 @@ var result = vm.Execute();
Console.WriteLine($"allow: {result}");
```

### Per-execution memory budget

RVM run-to-completion evaluation can use an optional additional live-memory budget. Each ordinary `Execute` or `ExecuteEntryPoint` call starts with a fresh budget for execution; program compilation, program loading, and prior `SetDataJson`, `SetInputJson`, and `SetContextJson` calls occur before and outside that budget.

```csharp
using var vm = new Rvm();
vm.LoadProgram(program);
vm.SetDataJson(Data);
vm.SetInputJson(Input);
vm.SetMemoryBudgetConfig(new MemoryBudgetConfig(16 * 1024 * 1024));

try
{
var result = vm.Execute();
}
catch (RegorusMemoryBudgetExceededException ex)
{
Console.WriteLine(ex.Message);
}
```

The native execution budget includes result JSON serialization and Rust `CString` allocation before the native call returns. Managed UTF-8 decoding and C# `string` allocation after that return are not charged.

This is a cooperative observed-live-bytes budget, not an allocation-time peak-memory cap. A single instruction, builtin, native serialization, or `CString` allocation may exceed the limit before the next checkpoint; configure headroom for that overshoot.

Accounting observes the execution thread rather than allocation ownership. Synchronous host work on that thread contributes to usage, cross-thread frees can temporarily skew observations, and a downward baseline ratchet can permanently remove headroom during an execution. A reused VM receives a new baseline, but retained capacities and pools can make it allocate differently from a fresh VM. See the [RVM memory budget documentation](../../docs/limits/memory_budget.md) for the detailed accounting model.

Budgets are not supported in suspendable execution mode. `ClearMemoryBudgetConfig` restores the previous unlimited per-execution behavior. Public multi-call begin/end scopes are intentionally absent because allocator counters are thread-local. Failed terminal execution clears retained state. The process-wide limit exposed by `MemoryLimits` remains a separate safeguard.

## Azure RBAC Condition Evaluation

Evaluate Azure RBAC condition expressions directly with a JSON evaluation context:
Expand Down
120 changes: 120 additions & 0 deletions bindings/csharp/Regorus.Tests/RvmMemoryBudgetTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System;
using System.Linq;
using System.Text.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Regorus.Tests;

[TestClass]
public sealed class RvmMemoryBudgetTests
{
private const ulong TightMemoryBudgetBytes = 64 * 1024;

private const string Policy = """
package limits.memory
import rego.v1

large_array := json.unmarshal(data.large_json)
""";

private const string EntryPoint = "data.limits.memory.large_array";

private const string PreloadedResultPolicy = """
package limits.memory

large_string := data.large_string
""";

private const string PreloadedResultEntryPoint = "data.limits.memory.large_string";

[TestMethod]
public void Memory_budget_must_be_non_zero()
{
Assert.ThrowsException<ArgumentOutOfRangeException>(() => new MemoryBudgetConfig(0));

using var vm = new Rvm();
Assert.ThrowsException<ArgumentOutOfRangeException>(() => vm.SetMemoryBudgetConfig(default));
}

[TestMethod]
public void Execute_exceeding_memory_budget_throws_typed_exception()
{
using var program = CreateProgram();
using var vm = CreateRvm(program);
vm.SetMemoryBudgetConfig(new MemoryBudgetConfig(TightMemoryBudgetBytes));

Assert.ThrowsException<RegorusMemoryBudgetExceededException>(() => vm.ExecuteEntryPoint(EntryPoint));
}

[TestMethod]
public void Clearing_memory_budget_restores_unlimited_execution()
{
using var program = CreateProgram();
using var vm = CreateRvm(program);
vm.SetMemoryBudgetConfig(new MemoryBudgetConfig(TightMemoryBudgetBytes));
Assert.ThrowsException<RegorusMemoryBudgetExceededException>(() => vm.ExecuteEntryPoint(EntryPoint));

vm.ClearMemoryBudgetConfig();

var result = vm.ExecuteEntryPoint(EntryPoint);
Assert.IsFalse(string.IsNullOrWhiteSpace(result));
}

[TestMethod]
public void Serialization_budget_failure_leaves_error_state()
{
var data = JsonSerializer.Serialize(new
{
large_string = new string('x', 2 * 1024 * 1024),
});
var modules = new[] { new PolicyModule("memory_budget.rego", PreloadedResultPolicy) };
using var program = Program.CompileFromModules(data, modules, new[] { PreloadedResultEntryPoint });
using var vm = new Rvm();
vm.LoadProgram(program);
vm.SetDataJson(data);
vm.SetMemoryBudgetConfig(new MemoryBudgetConfig(512 * 1024));

Assert.ThrowsException<RegorusMemoryBudgetExceededException>(
() => vm.ExecuteEntryPoint(PreloadedResultEntryPoint));

var state = vm.GetExecutionState();
Assert.IsNotNull(state);
StringAssert.Contains(state, "Error { error: MemoryBudgetExceeded");
}

[TestMethod]
public void Suspendable_execution_rejects_memory_budget()
{
using var vm = new Rvm();
vm.SetExecutionMode(ExecutionMode.Suspendable);
vm.SetMemoryBudgetConfig(new MemoryBudgetConfig(1024));

Assert.ThrowsException<RegorusMemoryBudgetUnsupportedException>(() => vm.Execute());
}

private static Program CreateProgram()
{
var modules = new[] { new PolicyModule("memory_budget.rego", Policy) };
return Program.CompileFromModules(CreateData(), modules, new[] { EntryPoint });
}

private static Rvm CreateRvm(Program program)
{
var vm = new Rvm();
vm.LoadProgram(program);
vm.SetDataJson(CreateData());
return vm;
}

private static string CreateData()
{
var values = Enumerable.Range(0, 200_000).ToArray();
return JsonSerializer.Serialize(new
{
large_json = JsonSerializer.Serialize(values),
});
}
}
46 changes: 46 additions & 0 deletions bindings/csharp/Regorus/MemoryBudgetConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System;

namespace Regorus
{
/// <summary>
/// Configures the additional live-memory budget for one RVM execution.
/// </summary>
public readonly struct MemoryBudgetConfig
{
/// <summary>
/// Initializes a new instance of the <see cref="MemoryBudgetConfig"/> struct.
/// </summary>
/// <param name="limitBytes">Maximum additional live bytes allowed during one execution.</param>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="limitBytes"/> is zero.</exception>
public MemoryBudgetConfig(ulong limitBytes)
Comment thread
maksym-mishchenko marked this conversation as resolved.
{
if (limitBytes == 0)
{
throw new ArgumentOutOfRangeException(nameof(limitBytes), "Memory budget must be non-zero.");
}

LimitBytes = limitBytes;
}

/// <summary>
/// Gets the maximum additional live bytes allowed during one execution.
/// </summary>
public ulong LimitBytes { get; }

internal Regorus.Internal.RegorusMemoryBudgetConfig ToNative()
{
if (LimitBytes == 0)
{
throw new ArgumentOutOfRangeException(nameof(LimitBytes), "Memory budget must be non-zero.");
}

return new Regorus.Internal.RegorusMemoryBudgetConfig
{
limit_bytes = LimitBytes,
};
}
}
}
Loading
Loading