Skip to content

expr: bound the memory a webhook CHECK may allocate (SQL-431) - #38162

Merged
mtabebe merged 3 commits into
MaterializeInc:mainfrom
mtabebe:fix/webhook-check-memory-cap
Aug 13, 2026
Merged

expr: bound the memory a webhook CHECK may allocate (SQL-431)#38162
mtabebe merged 3 commits into
MaterializeInc:mainfrom
mtabebe:fix/webhook-check-memory-cap

Conversation

@mtabebe

@mtabebe mtabebe commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Problem:
A webhook CHECK is a user defined expression that environmentd evaluates once per in-flight request, and nothing bounds what it could allocate.

The webhook path caps request count (500) and body size (5 MiB), but the evaluation's footprint is per-request and was bounded only by MAX_STRING_FUNC_RESULT_BYTES, a 100 MiB per-call ceiling sized for a cluster.

That ceiling multiplies by request concurrency, so a CHECK doing length(repeat(body, 20)) >= 0 turned 150 concurrent 5 MB posts into 8.7 GiB of RSS, growing with the number of clients.

Solution:

  • Give RowArena an optional budget and build the validation arena with one, defaulting to 20 MiB via a new dyncfg, webhook_validation_memory_budget_bytes.
  • Functions that can predict their result size consult max_string_func_result_bytes, which narrows the constant to the arena's remaining budget, so an over-budget result is never allocated at all.
  • Everything else is caught by a post-call check in the evaluator, which covers functions with no ceiling of their own.
  • Stop copying an owned result into the arena. String and Vec outputs went through push_string/push_bytes, which copied and dropped the original.

An unbudgeted arena, which is every arena in a dataflow, is unaffected

Testing:

  • New integration test webhook_validation_memory_budget: a 2 MiB body that amplifies past the 20 MiB default is refused with 400, a smaller body through the same source succeeds, and raising then lowering the dyncfg flips the result both ways
  • New expr unit tests cover the pre-check and post-call paths
  • New unit tests cover the arena bookkeeping and buffer adoption

@mtabebe
mtabebe force-pushed the fix/webhook-check-memory-cap branch from 81ae96c to de610ce Compare August 11, 2026 16:44
Problem:
A webhook CHECK is a user-authored expression that environmentd evaluates
once per in-flight request, and nothing bounded what it could allocate. The
webhook path caps request count (500) and body size (5 MiB), but the
evaluation's footprint is per-request and was bounded only by
MAX_STRING_FUNC_RESULT_BYTES, a 100 MiB per-call ceiling sized for a
cluster. That ceiling multiplies by request concurrency, so a CHECK doing
length(repeat(body, 20)) >= 0 turned 150 concurrent 5 MB posts into 8.7 GiB
of RSS, growing with the number of clients. Every request returned 200.
Nothing refused the work, so the memory was just held.

Solution:
- Give RowArena an optional budget and build the validation arena with one,
  defaulting to 20 MiB via a new dyncfg, webhook_validation_memory_budget_bytes.
- Enforce in two places, because amplifiers allocate in two shapes. Functions
  that can predict their result size consult max_string_func_result_bytes,
  which narrows the constant to the arena's remaining budget, so an
  over-budget result is never allocated at all. This now covers the string
  amplifiers and array_fill, which sizes its result from a parameter rather
  than its input. Everything else is caught by a post-call check in the
  evaluator, which covers functions with no ceiling of their own.
  string_to_array, for one, builds its array straight into the arena and
  consults none.
- Add EvalError::TempStorageBudgetExceeded for the over-budget case rather
  than reusing LengthTooLarge, whose "requested length too large" text
  misdescribes an over-budget array or arena-built result.
- Report the budget rather than enforce it in the arena itself. Its pushes
  are infallible, and refusing one would hand back a truncated value.
- Stop copying an owned result into the arena. String and Vec<u8> outputs
  went through push_string/push_bytes, which copied and dropped the original,
  so an amplifying call materialized its bytes twice at the peak.
  push_owned_bytes adopts the allocation as a region when the bytes would not
  fit the active region anyway, and copies when they would, so a small value
  still shares a region rather than getting one of its own.
- Register webhook_validation_memory_budget_bytes with parallel-workload's
  FlipFlagsAction and mzcompose's UNINTERESTING_SYSTEM_PARAMETERS, which
  bin/lint-test-flags requires for every new dyncfg.

An unbudgeted arena, which is every arena in a dataflow, is unaffected beyond
one branch on a None.

Testing:
- New integration test webhook_validation_memory_budget in server.rs: a 2 MiB
  body that amplifies past the 20 MiB default is refused with 400, a smaller
  body through the same source succeeds, and raising then lowering the dyncfg
  flips the result both ways.
- New expr unit tests test_repeat_respects_arena_budget,
  test_array_fill_respects_arena_budget, and
  test_arena_built_result_respects_budget cover the pre-check paths (a
  parameter-sized string and array) and the post-call path. miri_test_arena_budget
  and miri_test_arena_adopts_owned_bytes_and_keeps_references in row.rs cover
  the arena bookkeeping and buffer adoption.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mtabebe
mtabebe force-pushed the fix/webhook-check-memory-cap branch from de610ce to 4a5f741 Compare August 11, 2026 16:57
@mtabebe
mtabebe marked this pull request as ready for review August 11, 2026 19:07
@mtabebe
mtabebe requested review from a team as code owners August 11, 2026 19:07

@antiguru antiguru left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this looks good, thank you!

One caveat: If we ever use the budget in compute dataflows, we need to ensure that it never changes for the lifetime of a cluster replica. Changing it mid-run would make the evaluation non-deterministic, in which case we would produce non-accumulating diffs. It might be worth flagging this in the implementation so we don't accidentally run into this problem in the future.

@def- def- left a comment

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.

Two failing extra tests:

diff --git a/src/expr/src/scalar.rs b/src/expr/src/scalar.rs
index 288736f4c6..e5ed44bdaa 100644
--- a/src/expr/src/scalar.rs
+++ b/src/expr/src/scalar.rs
@@ -2634,6 +2634,82 @@ mod tests {
         expr.eval(&datums, &arena).expect("within budget");
     }

+    /// A budget has to bound what a single call allocates, not only what is observable between
+    /// calls (SQL-431).
+    ///
+    /// The evaluator polls the budget only after `func.eval` has built its result and moved it into
+    /// the arena, and erroring then does not give the bytes back: they stay resident until the arena
+    /// drops, which for a webhook `CHECK` is the end of the request. So the property is arena
+    /// residency, not the returned `Result`. Nor is the overshoot a constant. It scales with the
+    /// body and with a multiplier the `CHECK` author picks at DDL time.
+    #[mz_ore::test]
+    #[cfg_attr(miri, ignore)] // multi-MB allocations; the small-size UB coverage is in `row.rs`
+    fn test_single_call_respects_arena_budget() {
+        use crate::scalar::func::variadic::{ArrayCreate, PadLeading, Translate};
+
+        // Scaled down from the shipped 5 MiB body and 20 MiB budget. Every amplifier here is linear
+        // in the body, so the ratios hold at any scale.
+        const BODY_BYTES: usize = 1024 * 1024;
+        const BUDGET: usize = 2 * 1024 * 1024;
+        const WIDE: &str = "\u{1F4A5}"; // one character, four bytes
+
+        let str_lit = |s| MirScalarExpr::literal_ok(Datum::String(s), ReprScalarType::String);
+        // `ARRAY[body, ...]` is not a string function, so no ceiling of its own applies and the
+        // multiplier is just how many times the author wrote `body`.
+        let array_of = |n| {
+            let elem_type = mz_repr::SqlScalarType::String;
+            let refs = vec![MirScalarExpr::column(0); n];
+            MirScalarExpr::call_variadic(ArrayCreate { elem_type }, refs)
+        };
+        let cases = [
+            ("ARRAY[body x4]", array_of(4)),
+            ("ARRAY[body x16]", array_of(16)),
+            // `lpad`'s pre-check is budget-aware but compares `len`, a character count, against a
+            // budget in bytes, so a 4-byte pad passes a check for exactly the budget then writes 4x.
+            (
+                "lpad(body, BUDGET, wide)",
+                MirScalarExpr::call_variadic(
+                    PadLeading,
+                    vec![
+                        MirScalarExpr::column(0),
+                        MirScalarExpr::literal_ok(
+                            Datum::Int32(i32::try_from(BUDGET).unwrap()),
+                            ReprScalarType::Int32,
+                        ),
+                        str_lit(WIDE),
+                    ],
+                ),
+            ),
+            // `translate` has no pre-check at all, and widening each body byte is a 4x amplifier
+            // that needs no length argument to drive it.
+            (
+                "translate(body, 'a', wide)",
+                MirScalarExpr::call_variadic(
+                    Translate,
+                    vec![MirScalarExpr::column(0), str_lit("a"), str_lit(WIDE)],
+                ),
+            ),
+        ];
+
+        let body = "a".repeat(BODY_BYTES);
+        let datums = [Datum::String(&body)];
+        let mut over = Vec::new();
+        for (name, expr) in cases {
+            let arena = RowArena::with_budget(BUDGET);
+            let _ = expr.eval(&datums, &arena); // refused or not is beside the point
+            let held = arena.allocated_bytes();
+            if held > BUDGET {
+                let ratio = held as f64 / BUDGET as f64;
+                over.push(format!("  {name}: held {held} bytes, {ratio:.1}x"));
+            }
+        }
+        assert!(
+            over.is_empty(),
+            "a single call left a {BUDGET} byte arena holding more:\n{}",
+            over.join("\n"),
+        );
+    }
+
     #[mz_ore::test]
     #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
     fn test_reduce() {
diff --git a/src/repr/src/row.rs b/src/repr/src/row.rs
index 94e8c6a100..b04ae011d1 100644
--- a/src/repr/src/row.rs
+++ b/src/repr/src/row.rs
@@ -3712,6 +3712,53 @@ mod tests {
         assert_eq!(arena.push_owned_bytes(vec![]), empty);
     }

+    #[mz_ore::test]
+    fn test_arena_owned_pushes_keep_bump_allocating() {
+        // Adoption never *creates* a region with headroom: it inserts the caller's buffer, whose
+        // capacity equals its length, below whatever is on top. Only `push_bytes` grows the arena
+        // geometrically (`new_cap = max(need, last_cap * 2)`), so once the active region cannot fit
+        // an incoming value it never can again and every later owned push adopts: one retained
+        // allocation and one `Vec<u8>` header per value, rather than `O(log n)` regions. That is the
+        // default path for every `String`- and `Vec<u8>`-returning scalar function, and the arenas
+        // in the MFP and join paths outlive a single row, so the region list grows with the number
+        // of string values in a batch. `RowArena::clear` scans every region, so it degrades too.
+        const VALUES: usize = 500;
+        const VALUE: &str = "0123456789";
+
+        let regions = |arena: &RowArena| arena.inner.borrow().len();
+        let push_all = |arena: &RowArena, owned: bool| {
+            for _ in 0..VALUES {
+                match owned {
+                    true => _ = arena.push_string(VALUE.to_string()),
+                    false => _ = arena.push_bytes(VALUE.as_bytes()),
+                }
+            }
+        };
+
+        // The bump allocator working as intended, as the baseline to hold the owned path to.
+        let copied = RowArena::new();
+        push_all(&copied, false);
+
+        let owned = RowArena::new();
+        push_all(&owned, true);
+
+        // Seeding with ordinary copies first must not change the answer. The arena never recovers,
+        // so this is not just the empty-arena case where the placeholder on top has capacity 0.
+        let seeded = RowArena::new();
+        let _ = seeded.push_bytes(VALUE.as_bytes());
+        push_all(&seeded, true);
+
+        // Held to the copy path rather than an absolute count, so this pins the property (a run of
+        // small owned pushes still ends with a region that has headroom) and leaves the adoption
+        // predicate to the fix.
+        let (copied, owned, seeded) = (regions(&copied), regions(&owned), regions(&seeded));
+        assert!(
+            owned <= copied * 2 && seeded <= copied * 2,
+            "{VALUES} owned pushes left {owned} regions on an empty arena and {seeded} on a seeded \
+             one, against {copied} for the same bytes copied",
+        );
+    }
+
     #[mz_ore::test]
     fn miri_test_arena_budget() {
         // Without a budget nothing is ever over it, however much is pushed.

Running cargo nextest run -p mz-expr -p mz-repr --lib --no-fail-fast test_single_call_respects_arena_budget test_arena_owned_pushes_keep_bump_allocating fails:

        FAIL [   0.004s] mz-repr row::tests::test_arena_owned_pushes_keep_bump_allocating
  stdout ───

    running 1 test
    test row::tests::test_arena_owned_pushes_keep_bump_allocating ... FAILED

    failures:

    failures:
        row::tests::test_arena_owned_pushes_keep_bump_allocating

    test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 159 filtered out; finished in 0.00s

  stderr ───

    thread 'row::tests::test_arena_owned_pushes_keep_bump_allocating' (571281) panicked at src/repr/src/row.rs:3755:9:
    500 owned pushes left 501 regions on an empty arena and 501 on a seeded one, against 9 for the same bytes copied
    note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

        FAIL [   0.120s] mz-expr scalar::tests::test_single_call_respects_arena_budget
  stdout ───

    running 1 test
    test scalar::tests::test_single_call_respects_arena_budget ... FAILED

    failures:

    failures:
        scalar::tests::test_single_call_respects_arena_budget

    test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 134 filtered out; finished in 0.11s

  stderr ───

    thread 'scalar::tests::test_single_call_respects_arena_budget' (571282) panicked at src/expr/src/scalar.rs:2706:9:
    a single call left a 2097152 byte arena holding more:
      ARRAY[body x4]: held 4194350 bytes, 2.0x
      ARRAY[body x16]: held 16777322 bytes, 8.0x
      lpad(body, BUDGET, wide): held 5242880 bytes, 2.5x
      translate(body, 'a', wide): held 4194304 bytes, 2.0x
    note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

────────────

The memory cap not holding seems significant. Also not great to have increased memory usage in general, but we can live with that I guess.

The nightly failure is unrelated.

- Cap amplifying scalar functions against the webhook arena budget before
they build their result, not after.
- fixes push_owned_bytes to stay geometric for small owned pushes
instead of degrading to O(n) regions
- documents that a dataflow arena budget must be replica-stable.
@mtabebe
mtabebe force-pushed the fix/webhook-check-memory-cap branch from bb7cf28 to 2d00dfa Compare August 13, 2026 00:48
@mtabebe
mtabebe requested a review from def- August 13, 2026 01:21

@def- def- left a comment

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.

I extended the test a bit more:

diff --git a/src/expr/src/scalar.rs b/src/expr/src/scalar.rs
index dcb79da9f8..778d2a8bce 100644
--- a/src/expr/src/scalar.rs
+++ b/src/expr/src/scalar.rs
@@ -2579,6 +2579,21 @@ mod tests {
             Err(EvalError::TempStorageBudgetExceeded),
             "an over-budget arena-built result must be refused"
         );
+
+        // A split collects every chunk into a `Vec<&str>` first, and a fat pointer is 16 bytes
+        // against the 2 an empty chunk packs to. A budget the packed array fits under still has to
+        // refuse the call.
+        let intermediate = (body.len() + 1) * std::mem::size_of::<&str>();
+        let budget = 4 * unbudgeted;
+        assert!(
+            unbudgeted < budget && budget < intermediate,
+            "budget sits between"
+        );
+        let arena = RowArena::with_budget(budget);
+        assert!(
+            expr.eval(&datums, &arena).is_err(),
+            "a split costing {intermediate} bytes to build must be refused by a {budget} byte budget"
+        );
     }

     /// `array_fill` sizes its result from a parameter rather than its input, so a budgeted arena

Fails:

    thread 'scalar::tests::test_arena_built_result_respects_budget' (3020774) panicked at src/expr/src/scalar.rs:2594:9:
    a split costing 4194320 bytes to build must be refused by a 2097264 byte budget

@mtabebe
mtabebe requested a review from def- August 13, 2026 10:55

@def- def- left a comment

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.

no qa complaints

@mtabebe
mtabebe merged commit f4650d0 into MaterializeInc:main Aug 13, 2026
87 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants