Skip to content
Merged
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
141 changes: 141 additions & 0 deletions crates/openjd-expr/src/budgeted_vec.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// Copyright by contributors to this project.
// SPDX-License-Identifier: (Apache-2.0 OR MIT)

//! Memory-budgeted collection of expression values.

use crate::error::ExpressionError;
use crate::function_library::EvalContext;
use crate::value::ExprValue;

/// A `Vec<ExprValue>` that checks its projected heap footprint before growth.
pub(crate) struct BudgetedVec {
values: Vec<ExprValue>,
value_bytes: usize,
}

impl BudgetedVec {
/// Create an empty dynamically growing vector.
pub(crate) fn new() -> Self {
Self {
values: Vec::new(),
value_bytes: 0,
}
}

/// Create a vector with an exact, pre-checked capacity.
pub(crate) fn with_capacity(
ctx: &mut dyn EvalContext,
capacity: usize,
) -> Result<Self, ExpressionError> {
ctx.check_memory(capacity.saturating_mul(size_of::<ExprValue>()))?;
Ok(Self {
values: Vec::with_capacity(capacity),
value_bytes: 0,
})
}

/// Push a value after checking its bytes and the projected capacity slack.
pub(crate) fn push(
&mut self,
ctx: &mut dyn EvalContext,
value: ExprValue,
) -> Result<(), ExpressionError> {
let value_bytes = self.value_bytes.saturating_add(value.memory_size());
// Vec growth doubles a full allocation, with a minimum non-zero
// capacity of four for ExprValue-sized elements.
let projected_capacity = if self.values.len() == self.values.capacity() {
self.values.capacity().saturating_mul(2).max(4)
} else {
self.values.capacity()
};
let slack = projected_capacity.saturating_sub(self.values.len() + 1);
let projected_bytes =
value_bytes.saturating_add(slack.saturating_mul(size_of::<ExprValue>()));
ctx.check_memory(projected_bytes)?;

self.values.push(value);
self.value_bytes = value_bytes;
Ok(())
}

/// Consume the wrapper and return the collected values.
pub(crate) fn into_vec(self) -> Vec<ExprValue> {
self.values
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::path_mapping::PathFormat;
use std::cell::RefCell;

#[derive(Default)]
struct TestContext {
checks: RefCell<Vec<usize>>,
}

impl EvalContext for TestContext {
fn path_format(&self) -> PathFormat {
PathFormat::host()
}

fn count_op(&mut self) -> Result<(), ExpressionError> {
Ok(())
}

fn count_ops(&mut self, _n: usize) -> Result<(), ExpressionError> {
Ok(())
}

fn count_string_ops(&mut self, _len: usize) -> Result<(), ExpressionError> {
Ok(())
}

fn check_memory(&self, bytes: usize) -> Result<(), ExpressionError> {
self.checks.borrow_mut().push(bytes);
Ok(())
}
}

#[test]
fn checks_exact_capacity_before_allocation() {
let mut ctx = TestContext::default();
let values = BudgetedVec::with_capacity(&mut ctx, 3).unwrap();

assert_eq!(values.values.capacity(), 3);
assert_eq!(*ctx.checks.borrow(), vec![3 * size_of::<ExprValue>()]);
}

#[test]
fn push_checks_projected_doubling_and_value_heap() {
let mut ctx = TestContext::default();
let mut values = BudgetedVec::new();
values
.push(&mut ctx, ExprValue::String("abc".to_owned()))
.unwrap();

assert_eq!(*ctx.checks.borrow(), vec![4 * size_of::<ExprValue>() + 3]);
}

#[test]
fn push_checks_doubled_capacity_before_fifth_value() {
let mut ctx = TestContext::default();
let mut values = BudgetedVec::new();
for value in 0..5 {
values.push(&mut ctx, ExprValue::Int(value)).unwrap();
}

assert_eq!(
*ctx.checks.borrow(),
vec![
4 * size_of::<ExprValue>(),
4 * size_of::<ExprValue>(),
4 * size_of::<ExprValue>(),
4 * size_of::<ExprValue>(),
8 * size_of::<ExprValue>(),
]
);
}
}
8 changes: 4 additions & 4 deletions crates/openjd-expr/src/default_library.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,21 +300,21 @@ fn comparison() -> FunctionLibrary {
lib.register_sig("__ge__", "(T1, T2) -> bool", ge_generic)
.expect("bad builtin signature");
// Containment — container first, item second
lib.register_sig("__contains__", "(list[T1], T1) -> bool", contains_list)
lib.register_sig("__contains__", "(list[T1], T2) -> bool", contains_list)
.expect("bad builtin signature");
lib.register_sig("__contains__", "(range_expr, int) -> bool", contains_range)
lib.register_sig("__contains__", "(range_expr, T1) -> bool", contains_range)
.expect("bad builtin signature");
lib.register_sig("__contains__", "(string, string) -> bool", contains_string)
.expect("bad builtin signature");
lib.register_sig(
"__not_contains__",
"(list[T1], T1) -> bool",
"(list[T1], T2) -> bool",
not_contains_list,
)
.expect("bad builtin signature");
lib.register_sig(
"__not_contains__",
"(range_expr, int) -> bool",
"(range_expr, T1) -> bool",
not_contains_range,
)
.expect("bad builtin signature");
Expand Down
59 changes: 41 additions & 18 deletions crates/openjd-expr/src/eval/evaluator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1323,26 +1323,38 @@ impl<'a> Evaluator<'a> {
return self.track(ExprValue::unresolved(ExprType::list(body_type)));
}

// Materialize iterable elements
let items: Vec<ExprValue> = if let Some(iter) = iterable.list_iter() {
iter.collect()
} else if let ExprValue::RangeExpr(r) = &iterable {
r.iter().map(ExprValue::Int).collect()
} else {
return Err(ExpressionError::type_error(format!(
"Cannot iterate over {}",
iterable.expr_type()
)));
};
self.release(&iterable);
// Iterate the iterable in place — lists via a borrowing ListIter
// (elements were already tracked when the list was built; each
// yielded clone is transient), ranges lazily (a symbolic range's
// element count is bounded only by the range value domain, up to
// ~2^63, so materializing it up front could not be held to the
// memory limit). Neither branch copies the iterable's storage.
// The per-item loop below charges +1 op per element for both.
let iter: Box<dyn Iterator<Item = ExprValue> + '_> =
if let Some(list_iter) = iterable.list_iter() {
Box::new(list_iter)
} else if let ExprValue::RangeExpr(r) = &iterable {
Box::new(r.iter().map(ExprValue::Int))
} else {
return Err(ExpressionError::type_error(format!(
"Cannot iterate over {}",
iterable.expr_type()
)));
};

// Evaluate each element with a child scope
let mut result = Vec::new();
// Evaluate each element with a child scope. Each iteration's
// memory baseline is saved and restored: the child's transients
// (loop-variable clones, intermediates) are gone by the loop end,
// and the finished list is tracked exactly once by
// make_list_checked. BudgetedVec bounds the growing result,
// including projected capacity growth, before each push.
let mut result = crate::budgeted_vec::BudgetedVec::new();
let base_symtabs: Vec<&SymbolTable> = self.symtabs.to_vec();
for item in &items {
for item in iter {
self.count_op()?;
let memory_baseline = self.current_memory;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maintainability data point: with this change, eval_listcomp is now 182 lines (145 code, 32 comment) with a cyclomatic complexity of ~40 (measured by decision-point count: if/while/for/match arms/&&/||/?). Even discounting the ? early-returns as low-cognitive-load, that's well above clippy's cognitive-complexity default of 25 — this function is getting hard to maintain.

The comments documenting the memory-accounting invariants help a lot, but the budget bookkeeping (baseline save/restore, result_bytes, the capacity-slack projection before push) is interleaved with the iteration logic — and the same projected-capacity pre-check pattern now appears in three places (here, slice_range in comparison.rs, and list_from_range in list.rs).

Suggested follow-up (non-blocking): extract a small BudgetedVec-style helper that owns the result-bytes tracking and post-doubling capacity projection. It would shrink this function by ~30 lines, deduplicate the three sites, and put the trickiest invariant behind one independently-testable abstraction.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Follow-up 5bb85b7 created the suggested budgeted vec, centralizing some of this tricky accounting from a bunch of places.

let mut tmp = crate::symbol_table::SymbolTable::new();
tmp.set(&var_name, item.clone())
tmp.set(&var_name, item)
.map_err(|e| ExpressionError::new(e.to_string()))?;
let mut combined = base_symtabs.clone();
combined.push(&tmp);
Expand All @@ -1366,12 +1378,23 @@ impl<'a> Evaluator<'a> {
}
}
if include {
result.push(child.evaluate(&lc.elt)?);
let elt = child.evaluate(&lc.elt)?;
result.push(self, elt)?;
}
self.absorb_counters(&child);
self.regex_cache = child.regex_cache;
self.current_memory = self.current_memory.saturating_sub(item.memory_size());
// Restore the iteration baseline: the child's tracked
// transients (the loop-variable clone and any intermediates)
// do not survive the iteration, and the result elements are
// accounted separately by BudgetedVec. Peak memory keeps
// the high-water mark absorbed above.
self.current_memory = memory_baseline;
}
// The iterable is consumed by the comprehension: release its
// tracked memory now that iteration is done (the borrowing
// iterators above held it until the loop ended).
self.release(&iterable);
let result = result.into_vec();

// Check nesting depth
for e in &result {
Expand Down
Loading
Loading