fix(painless): surface script resource-limit trips instead of returning a wrong score - #116
Merged
Conversation
… score A Painless script that recursed past MAX_CALL_DEPTH did not fail — it returned a WRONG SCORE. PR #88 bounded the recursion correctly and both process-abort repros stay dead; the defect is the failure mode. The scoring and matching paths (apply_function_score, apply_rescore, terms_set min_should_match, script-bucketed terms aggs) return bare f32/Value with no error channel, so they mapped the limit error to a neutral value and served it as a success. Captured verbatim from the pre-fix tree: `{"max_score":0.0,"hits":[{"_score":0.0,...}]}` with a 200 status, from a scoring script that never ran. Two failure classes must not collapse, and the error type now distinguishes them: - Unsupported or unparseable script syntax keeps degrading quietly. Seven of the nine eval_painless call sites swallow that error on purpose, because the ES-compat surface depends on an out-of-subset script scoring neutrally rather than 400-ing the request. None of those fallbacks changed. - A resource limit (call depth, eval depth, invocation count, source size) means the script was understood and we refused to finish it. Publishing 0.0 / [] there is an answer that looks real and is not. `is_resource_limit_error` marks exactly this class, and the call-depth sentinel is split from the invocation-count one so the two remedies (recurse less vs. call less) are distinguishable. The transport is a task-local fault sink (`with_script_fault_capture`). Threading a sink through doc_matches_query/score_query_against_doc would touch ~90 call sites; a thread-local would mis-attribute a fault across an await. Index::search installs the sink and reports the fault on SearchResult.script_failure, alongside timed_out — and re-raises it into any enclosing scope on the way out, so a sub-search cannot hide a fault from the request that contains it. Faulted results are never cached, so degraded scores are not replayed. _search, _msearch, _search/template, _msearch/template, scroll and async_search return an ES-shaped script_exception 400 instead of the degraded page. _count, _reindex, _delete_by_query and _update_by_query refuse rather than act on a selection a fail-closed script truncated — under-deleting and calling it a completed run is the same defect wearing a different hat. The _search handler additionally wraps its own script evaluation (script_fields, runtime_mappings emit scripts run during response assembly, outside the search task), which previously dropped a failed field silently. MAX_CALL_DEPTH stays at 32, and that is measured rather than assumed. Probing the stack address at each call level in a release build, with the recursive call nested inside the closure body's blocks so every level also pays that body's exec_stmt frames: 2,272 bytes/level at one nested block, 40,720 at the parser's 90-block maximum — 1,262,320 bytes, 1.20 MiB of the 2 MiB tokio worker stack, at depth 32. Raising the ceiling would overflow, so the issue's "make it high enough" option is not available; lifting it safely needs body nesting charged against one stack budget, not a larger constant. The harness ships behind the off-by-default `stackprobe` feature. Rc<Vec<Stmt>> becomes Arc, making PainlessValue Send/Sync. A/B'd on the same benchmark, three runs each, reported as the closure/closure-free ratio because wall-clock on a shared host swings ±40%: Arc 3.328, 3.334, 3.224 against Rc 3.367, 3.344, 2.360. The ~1% gap is an order of magnitude below Rc's own spread — a closure call allocates a fresh HashMap scope, which dominates the refcount either way. Regression coverage is at the wire, in tests that compile against the unfixed engine: script_score, script_fields, script-bucketed aggs, delete_by_query and cache non-poisoning all fail on the pre-fix tree with the 200s quoted above, while the out-of-subset-script test passes before and after — the guard against fixing this by making every unparseable script a 400. Engine-level tests add the multi-thread block_in_place path, rescore, terms_set, Send/Sync, and both abort repros on a 2 MiB stack including the 1.20 MiB worst case. Closes #97
xerj-org
added a commit
that referenced
this pull request
Aug 2, 2026
… and pivot transforms (#126) * fix(api): surface script resource-limit trips on _rank_eval, _explain and pivot transforms (#123) PR #116 split Painless failures into "cannot evaluate" (degrade quietly, the ES-compat contract) and "refused because a resource limit tripped" (must surface), and wired the second class through ten request surfaces via the task-local fault sink and `SearchResult::script_failure`. Three surfaces still read that field's absence as success. This uses the same mechanism on all three — no new machinery, only new call sites, plus the enum needed to carry a fault out of the transform runner in the existing `script_exception` shape. Captured verbatim from the pre-fix tree, all HTTP 200: _rank_eval, script_score past MAX_CALL_DEPTH: {"metric_score":0.5,"details":{"q1":{"metric_score":0.5, ... }},"failures":{}} _rank_eval, terms_set minimum_should_match_script past the same limit: {"metric_score":0.0,"details":{"q1":{"metric_score":0.0,"hits":[]}},"failures":{}} _explain, same query: {"matched":false, ... "description":"document [1] does not match the query"} POST /_transform/t1/_start over a source.query with that script: {"acknowledged":true} _rank_eval is the sharpest case in the set and the reason the issue was filed against it specifically: it exists to MEASURE relevance quality, so a degraded score does not merely return a bad answer, it corrupts the number a caller would use to notice bad answers. The 0.5 above is a precision figure computed over a ranking whose scores no script produced. _explain answers a yes/no question, and a fail-closed script answers it "no" — a confident negative indistinguishable from a document that genuinely does not match. The pivot transform is worse than an under-delete: it WRITES the truncated summary into dest, where later queries read the wrong numbers as fact. Both halves of a pivot can carry a user script (source.query and pivot.aggregations), and the check sits before the writes. The runner is not transactional, so buckets from earlier pages that completed cleanly stay written, as with any mid-run failure; that is stated in the comment rather than papered over. run_rollup_job is deliberately left unchecked, and the reason is structural rather than a judgement call: its request body is built entirely from the job config's groups and metrics — composite sources over date_histogram / terms / histogram with a field, plus named metric aggs over a field. There is no query key and nowhere to put a script, so the search it issues carries none. The other Index::search callers reached in this audit are unchanged for the same kind of reason: terms_enum, execute_enrich_policy, clone_index_to and open_index's timestamp probe all issue a literal match_all; eql_search and sql_query parse their own query languages, neither of which admits Painless; the sub-searches inside the _search handler are already covered, because Index::search re-raises a consumed fault into the enclosing capture scope. Second defect in the same issue. _delete_by_query and _update_by_query refused correctly but shipped the refusal as 200 OK carrying {"error": ..., "status": 400}, so a client branching on the HTTP status saw a completed run and then found no deleted / updated key. Both runners return a plain Value because the detached wait_for_completion=false path stores it as a _tasks/{id} result, which has no HTTP status of its own; the synchronous path handed that value to Json(..), which is always 200. by_query_response now takes the status from the body, which both error builders (script_limit_error_value and ApiError::into_value) already populate correctly. A successful run has no status key and stays 200 — pinned by its own test, since that mapping is now load-bearing in both directions. Regression coverage is at the wire in tests/script_limits_remaining_surfaces_http.rs. Verified by reverting only es_compat.rs and keeping the tests: six of the eight fail on the unfixed tree with exactly the bodies quoted above, and pass with the fix. The two that pass in both states are the guard rails — an out-of-subset script must keep scoring neutrally on a 200 through _rank_eval and _explain, and a script-free _delete_by_query must stay a 200 with its deleted count. Without them this could be "fixed" by turning every unparseable script into a 400, which would pass every loudness assertion and break the compat surface. cargo test -p xerj-api: 122 lib tests, 6 script_limits_http, 8 new — all pass. clippy --lib --tests -D warnings and fmt --check both clean. Closes #123 * fix(api): report script-limit trips on _rank_eval and _explain without over-refusing Follow-up to the first cut, which surfaced the faults but refused too much. `_rank_eval` failed the WHOLE batch. A body with ten requests where one carries a limit-tripping script 400'd all ten. ES provides `failures` for exactly this, keyed by request id, and the response already carried an empty one. The fault is now recorded there, the faulted request contributes nothing to `metric_score` and does not appear in `details`, and its siblings are still measured. That keeps the property the fix exists for — a `metric_score` is never published over scores a refused script produced — without discarding good measurements. `_explain` returned 400 for a correct answer. Measured: a `function_score` `script_score` past MAX_CALL_DEPTH used to return 200 `{"matched": true, ...}`, which is right, because `matched` comes from the ids-filtered hit list and scoring does not decide it. Refusing turned a correct response into an error for precisely the person debugging the script. `_explain` is a diagnostic endpoint, so the fault is now REPORTED rather than refused: the verdict is still answered, and an extra `explanation.details` node names the limit that tripped. Where the script IS load-bearing for matching, `matched: false` would be a confident negative, which is why it cannot simply be published silently either — disclosing it satisfies both. The three tests that pinned the old behaviour are rewritten to pin this one, and a fourth is added asserting a failure stays scoped to the offending request while a healthy sibling is still measured. 9 passed, 0 failed.
xerj-org
added a commit
that referenced
this pull request
Aug 2, 2026
…ape (#122) Every limit the interpreter had bounds a structural property of a script: how deep it nests (`MAX_PARSE_DEPTH`, `MAX_EVAL_DEPTH`), how many closure calls it makes (`MAX_CALL_DEPTH`, `MAX_CALL_COUNT`), how many bytes of source it is (`MAX_SCRIPT_LEN`). None of them bounds work, and shape and work are not the same thing. Measured on this branch's base in a release build, one core, `taskset`-pinned. Every script below is flat, closure-free, nests nothing, calls nothing, and is well inside the 64 KiB source cap — so every limit named above passes it: | script | source | per document | |---------------------------------------------------|---------:|-------------:| | 3,000 x `params.blob;`, params of 50,000 nodes | 38.1 KiB | 9.03 s | | 3,000 x `params.blob;`, params of 200,000 nodes | 38.1 KiB | 37.35 s | | 3,000 x `params['_source'];`, doc of 100,000 nodes | 55.7 KiB | 17.17 s | | 2,000 x `doc['rank'].value`, doc of 20,000 nodes | 52.8 KiB | 1.13 s | All four are O(size) behind an expression that looks O(1): `params.x` converts a caller-supplied subtree into interpreter values, `params['_source']` clones the document, and `get_doc_value` clones the whole document before walking into it. None of that cost is visible in the statement count, which is why a step counter alone would not have caught any of it. The doc-scan cooperative timeout poll cannot help either: it only decides how often a document *boundary* is checked, so a request `timeout` cannot interrupt a single expensive document at all. ## The budget A work counter inside `eval_painless`, charged for two things: * one unit per interpreter step (`eval_expr` / `exec_stmt` entry), and * one unit per node, plus one per 64 bytes, of every value a step produces. The second half is the part that matters, and it has to be charged on *every* path that produces a value — including the fast paths in `eval_access_chain` that resolve `doc`, `params` and `Math` roots without a preceding value. Those are exactly the paths the table above exploits. `params` member reads are charged *before* the value is materialised (`charged_params_read`), so the budget bounds how many such conversions an evaluation may perform rather than performing one and asking afterwards. `params['_source']` pays the document's weight through the same `charge_document_read` that `doc['x'].value` uses. `MAX_SCRIPT_OPS` = 5,000,000 is calibrated, not guessed: the largest *benign* script the source cap admits (4,300 statements of `t = t + N.5;`, 59,773 bytes) costs a measured 34,407 work units, so the ceiling keeps a 145x margin over the worst legitimate script that can be submitted. `painless::tests::the_budget_leaves_the_largest_benign_script_a_wide_margin` pins that number so a change to the charging scheme reports how far the margin moved. ## Integration with the request deadline Every 1,024 work units the counter also reads the clock. The slice an evaluation gets is the enclosing request's *remaining* time, clamped to [100 ms, 500 ms], published by `with_script_deadline` from `Index::search` — the same task-local mechanism as the fault sink. That deadline is resolved in `PainlessCtx::new`, before any work is charged, and this is load-bearing rather than tidy. Deriving it inside the first `clock_check` costs one sampling window to *establish* the deadline and a second one to be able to fail it, and a window is bounded in work units, not in time — so "one window" is however long the largest single charge takes. It cost one clock read per evaluation to fix; the measured overshoot against a 100 ms slice is now 0.48 ms on a 10,000-node document, 1.17 ms at 40,000 and 7.11 ms at 160,000, growing with the cost of one charged step exactly as that argument says it should. The 100 ms floor is what keeps the trip meaningful. The request deadline is routinely already past when a script runs, because the doc scan only checks it every 4,096 documents, so cutting evaluations off the moment it passes would turn every ordinary `timeout` into a `script_exception` 400. 100 ms is ~360x the measured 278 µs cost of the largest benign script that can be submitted. Both trips classify as resource limits (`is_resource_limit_error`), so they reach the caller as a 400 through the sink #116 built rather than degrading to a plausible wrong score. Once any evaluation in a request has tripped, later ones short-circuit on the recorded fault, which makes ONE budget the bound for a whole request rather than one per document. ## Result | script | before | after | |---------------------------------------------------|--------:|-------:| | 3,000 x `params.blob;`, params of 50,000 nodes | 9.03 s | 97 ms | | 3,000 x `params.blob;`, params of 200,000 nodes | 37.35 s | 109 ms | | 3,000 x `params['_source'];`, doc of 100,000 nodes | 17.17 s | 168 ms | | 2,000 x `doc['rank'].value`, doc of 20,000 nodes | 1.13 s | 155 ms | Flat in the size of the input, because the trip happens at a fixed work count. Through `_search`, the first of those is now a 400 `script_exception` naming the budget. ## Cost to ordinary scripts Interleaved A/B: base and fixed binaries alternated inside each repetition and pinned to the same idle core, 15 repetitions, each figure already the best of 40 trials. Reporting the best-of-repetitions, which is the least-interrupted observation on each side: | script | base | fixed | delta | |------------------------------------|-----------:|-----------:|--------:| | 42 B, reads `doc[...]` and `params`| 274.8 ns | 353.8 ns | +28.7% | | 237 B arithmetic | 1124.7 ns | 1230.6 ns | +9.4% | | 59,773 B arithmetic, 34,407 units | 271.1 µs | 298.5 µs | +10.1% | These are larger than the numbers the first revision of this change quoted (+6.9% / +0.46%), and the difference is not the round-2 fixes: measured on the same run, the first revision alone is +19.5% / +0.9% / +5.3% against the same base. A probe build carrying only the `eval_access_chain` charge lands within 1% of it on all three shapes, so the security fix itself is not what costs — the accounting was always the price of the budget. Of the remainder, the tiny script's +25 ns is the one clock read per evaluation that anchoring the deadline requires, which is the right order for a vDSO `clock_gettime`. The sub-5% gaps on the two arithmetic shapes are not attributable: a probe that does strictly *less* work than the final build measures slower than it on one shape and faster on the other, which is build-to-build codegen variation of the same magnitude as the effect. ## Query width: `max_clause_count` There was no clause limit anywhere, so a query could be arbitrarily *wide* as well, and width is what multiplies per-document work. `MAX_CLAUSE_COUNT` = 1,024 matches Elasticsearch's documented `indices.query.bool.max_clause_count` default and returns its `too_many_clauses` error, so a query this refuses is one the system being emulated already refused. Clauses are charged before they are parsed and summed across the whole query, not per `bool`, and every clause-list shape counts towards the same total: `bool`, `span_or`/`span_near` clauses, `dis_max.queries` and an array `knn.filter`. A cap is only worth having if tripping it is *visible*. `parse_span_or` and `parse_span_near` collected clauses with `filter_map(|v| parse_query(v).ok())`, discarding any clause the parser refused. That was survivable while every refusable clause was malformed; with a width cap it becomes a correctness bug, because a well-formed clause that is merely too wide now fails to parse. A `span_or` over [an over-wide `bool`, a `span_term`] parsed to a `SpanOr` containing only the `span_term` — 200 OK for a query nobody asked for — and a `span_near` whose only clause was over-wide collapsed to `MatchNone`: 200 OK, zero hits, no error anywhere. A silently different query is worse than an unbounded one. Every clause error now propagates. ## Verification * Reverting only `painless.rs` and keeping the tests: `bare_params_reads_are_charged_for_what_they_materialise`, `subscripted_params_reads_are_charged_too` and `params_source_reads_are_charged_for_the_document_they_clone` all fail, each reporting that the script was charged 2,048 work units, and `script_cpu_budget_http` fails on the wall clock having to notice what the op ceiling should have. The work-count assertions are deliberate: an uncharged read is still stopped *eventually*, after ~15 s, so a test that only asserts "some resource limit tripped" passes on the broken tree. * Reverting only `parser.rs` and keeping `tests/span_clause_refusal.rs`: 6 of its 9 tests fail, reporting the dropped clause and the `MatchNone` collapse. That file lives outside `parser.rs` precisely so it survives the revert. * `the_time_budget_trips_on_its_own_inside_an_expired_request` exercises the wall-clock half on its own; every other test here trips the deterministic op ceiling first. * `cargo test --release -p xerj-query -p xerj-engine -p xerj-api` green. * `cargo fmt --check` and `cargo clippy --lib --tests -- -D warnings` clean on all three crates.
xerj-org
added a commit
that referenced
this pull request
Aug 2, 2026
…ape (#122) Every limit the interpreter had bounds a structural property of a script: how deep it nests (`MAX_PARSE_DEPTH`, `MAX_EVAL_DEPTH`), how many closure calls it makes (`MAX_CALL_DEPTH`, `MAX_CALL_COUNT`), how many bytes of source it is (`MAX_SCRIPT_LEN`). None of them bounds work, and shape and work are not the same thing. Measured on this branch's base in a release build, one core, `taskset`-pinned. Every script below is flat, closure-free, nests nothing, calls nothing, and is well inside the 64 KiB source cap — so every limit named above passes it: | script | source | per document | |---------------------------------------------------|---------:|-------------:| | 3,000 x `params.blob;`, params of 50,000 nodes | 38.1 KiB | 9.03 s | | 3,000 x `params.blob;`, params of 200,000 nodes | 38.1 KiB | 37.35 s | | 3,000 x `params['_source'];`, doc of 100,000 nodes | 55.7 KiB | 17.17 s | | 2,000 x `doc['rank'].value`, doc of 20,000 nodes | 52.8 KiB | 1.13 s | All four are O(size) behind an expression that looks O(1): `params.x` converts a caller-supplied subtree into interpreter values, `params['_source']` clones the document, and `get_doc_value` clones the whole document before walking into it. None of that cost is visible in the statement count, which is why a step counter alone would not have caught any of it. The doc-scan cooperative timeout poll cannot help either: it only decides how often a document *boundary* is checked, so a request `timeout` cannot interrupt a single expensive document at all. ## The budget A work counter inside `eval_painless`, charged for two things: * one unit per interpreter step (`eval_expr` / `exec_stmt` entry), and * one unit per node, plus one per 64 bytes, of every value a step produces. The second half is the part that matters, and it has to be charged on *every* path that produces a value — including the fast paths in `eval_access_chain` that resolve `doc`, `params` and `Math` roots without a preceding value. Those are exactly the paths the table above exploits. `params` member reads are charged *before* the value is materialised (`charged_params_read`), so the budget bounds how many such conversions an evaluation may perform rather than performing one and asking afterwards. `params['_source']` pays the document's weight through the same `charge_document_read` that `doc['x'].value` uses. `MAX_SCRIPT_OPS` = 5,000,000 is calibrated, not guessed: the largest *benign* script the source cap admits (4,300 statements of `t = t + N.5;`, 59,773 bytes) costs a measured 34,407 work units, so the ceiling keeps a 145x margin over the worst legitimate script that can be submitted. `painless::tests::the_budget_leaves_the_largest_benign_script_a_wide_margin` pins that number so a change to the charging scheme reports how far the margin moved. ## Integration with the request deadline Every 1,024 work units the counter also reads the clock. The slice an evaluation gets is the enclosing request's *remaining* time, clamped to [100 ms, 500 ms], published by `with_script_deadline` from `Index::search` — the same task-local mechanism as the fault sink. That deadline is resolved in `PainlessCtx::new`, before any work is charged, and this is load-bearing rather than tidy. Deriving it inside the first `clock_check` costs one sampling window to *establish* the deadline and a second one to be able to fail it, and a window is bounded in work units, not in time — so "one window" is however long the largest single charge takes. It cost one clock read per evaluation to fix; the measured overshoot against a 100 ms slice is now 0.48 ms on a 10,000-node document, 1.17 ms at 40,000 and 7.11 ms at 160,000, growing with the cost of one charged step exactly as that argument says it should. The 100 ms floor is what keeps the trip meaningful. The request deadline is routinely already past when a script runs, because the doc scan only checks it every 4,096 documents, so cutting evaluations off the moment it passes would turn every ordinary `timeout` into a `script_exception` 400. 100 ms is ~360x the measured 278 µs cost of the largest benign script that can be submitted. Both trips classify as resource limits (`is_resource_limit_error`), so they reach the caller as a 400 through the sink #116 built rather than degrading to a plausible wrong score. Once any evaluation in a request has tripped, later ones short-circuit on the recorded fault, which makes ONE budget the bound for a whole request rather than one per document. ## Result | script | before | after | |---------------------------------------------------|--------:|-------:| | 3,000 x `params.blob;`, params of 50,000 nodes | 9.03 s | 97 ms | | 3,000 x `params.blob;`, params of 200,000 nodes | 37.35 s | 109 ms | | 3,000 x `params['_source'];`, doc of 100,000 nodes | 17.17 s | 168 ms | | 2,000 x `doc['rank'].value`, doc of 20,000 nodes | 1.13 s | 155 ms | Flat in the size of the input, because the trip happens at a fixed work count. Through `_search`, the first of those is now a 400 `script_exception` naming the budget. ## Cost to ordinary scripts Interleaved A/B: base and fixed binaries alternated inside each repetition and pinned to the same idle core, 15 repetitions, each figure already the best of 40 trials. Reporting the best-of-repetitions, which is the least-interrupted observation on each side: | script | base | fixed | delta | |------------------------------------|-----------:|-----------:|--------:| | 42 B, reads `doc[...]` and `params`| 274.8 ns | 353.8 ns | +28.7% | | 237 B arithmetic | 1124.7 ns | 1230.6 ns | +9.4% | | 59,773 B arithmetic, 34,407 units | 271.1 µs | 298.5 µs | +10.1% | These are larger than the numbers the first revision of this change quoted (+6.9% / +0.46%), and the difference is not the round-2 fixes: measured on the same run, the first revision alone is +19.5% / +0.9% / +5.3% against the same base. A probe build carrying only the `eval_access_chain` charge lands within 1% of it on all three shapes, so the security fix itself is not what costs — the accounting was always the price of the budget. Of the remainder, the tiny script's +25 ns is the one clock read per evaluation that anchoring the deadline requires, which is the right order for a vDSO `clock_gettime`. The sub-5% gaps on the two arithmetic shapes are not attributable: a probe that does strictly *less* work than the final build measures slower than it on one shape and faster on the other, which is build-to-build codegen variation of the same magnitude as the effect. ## Query width: `max_clause_count` There was no clause limit anywhere, so a query could be arbitrarily *wide* as well, and width is what multiplies per-document work. `MAX_CLAUSE_COUNT` = 1,024 matches Elasticsearch's documented `indices.query.bool.max_clause_count` default and returns its `too_many_clauses` error, so a query this refuses is one the system being emulated already refused. Clauses are charged before they are parsed and summed across the whole query, not per `bool`, and every clause-list shape counts towards the same total: `bool`, `span_or`/`span_near` clauses, `dis_max.queries` and an array `knn.filter`. A cap is only worth having if tripping it is *visible*. `parse_span_or` and `parse_span_near` collected clauses with `filter_map(|v| parse_query(v).ok())`, discarding any clause the parser refused. That was survivable while every refusable clause was malformed; with a width cap it becomes a correctness bug, because a well-formed clause that is merely too wide now fails to parse. A `span_or` over [an over-wide `bool`, a `span_term`] parsed to a `SpanOr` containing only the `span_term` — 200 OK for a query nobody asked for — and a `span_near` whose only clause was over-wide collapsed to `MatchNone`: 200 OK, zero hits, no error anywhere. A silently different query is worse than an unbounded one. Every clause error now propagates. ## Verification * Reverting only `painless.rs` and keeping the tests: `bare_params_reads_are_charged_for_what_they_materialise`, `subscripted_params_reads_are_charged_too` and `params_source_reads_are_charged_for_the_document_they_clone` all fail, each reporting that the script was charged 2,048 work units, and `script_cpu_budget_http` fails on the wall clock having to notice what the op ceiling should have. The work-count assertions are deliberate: an uncharged read is still stopped *eventually*, after ~15 s, so a test that only asserts "some resource limit tripped" passes on the broken tree. * Reverting only `parser.rs` and keeping `tests/span_clause_refusal.rs`: 6 of its 9 tests fail, reporting the dropped clause and the `MatchNone` collapse. That file lives outside `parser.rs` precisely so it survives the revert. * `the_time_budget_trips_on_its_own_inside_an_expired_request` exercises the wall-clock half on its own; every other test here trips the deterministic op ceiling first. * `cargo test --release -p xerj-query -p xerj-engine -p xerj-api` green. * `cargo fmt --check` and `cargo clippy --lib --tests -- -D warnings` clean on all three crates.
xerj-org
added a commit
that referenced
this pull request
Aug 2, 2026
…U DoS (#133) * fix(painless): bound what a script DOES per document, not just its shape (#122) Every limit the interpreter had bounds a structural property of a script: how deep it nests (`MAX_PARSE_DEPTH`, `MAX_EVAL_DEPTH`), how many closure calls it makes (`MAX_CALL_DEPTH`, `MAX_CALL_COUNT`), how many bytes of source it is (`MAX_SCRIPT_LEN`). None of them bounds work, and shape and work are not the same thing. Measured on this branch's base in a release build, one core, `taskset`-pinned. Every script below is flat, closure-free, nests nothing, calls nothing, and is well inside the 64 KiB source cap — so every limit named above passes it: | script | source | per document | |---------------------------------------------------|---------:|-------------:| | 3,000 x `params.blob;`, params of 50,000 nodes | 38.1 KiB | 9.03 s | | 3,000 x `params.blob;`, params of 200,000 nodes | 38.1 KiB | 37.35 s | | 3,000 x `params['_source'];`, doc of 100,000 nodes | 55.7 KiB | 17.17 s | | 2,000 x `doc['rank'].value`, doc of 20,000 nodes | 52.8 KiB | 1.13 s | All four are O(size) behind an expression that looks O(1): `params.x` converts a caller-supplied subtree into interpreter values, `params['_source']` clones the document, and `get_doc_value` clones the whole document before walking into it. None of that cost is visible in the statement count, which is why a step counter alone would not have caught any of it. The doc-scan cooperative timeout poll cannot help either: it only decides how often a document *boundary* is checked, so a request `timeout` cannot interrupt a single expensive document at all. ## The budget A work counter inside `eval_painless`, charged for two things: * one unit per interpreter step (`eval_expr` / `exec_stmt` entry), and * one unit per node, plus one per 64 bytes, of every value a step produces. The second half is the part that matters, and it has to be charged on *every* path that produces a value — including the fast paths in `eval_access_chain` that resolve `doc`, `params` and `Math` roots without a preceding value. Those are exactly the paths the table above exploits. `params` member reads are charged *before* the value is materialised (`charged_params_read`), so the budget bounds how many such conversions an evaluation may perform rather than performing one and asking afterwards. `params['_source']` pays the document's weight through the same `charge_document_read` that `doc['x'].value` uses. `MAX_SCRIPT_OPS` = 5,000,000 is calibrated, not guessed: the largest *benign* script the source cap admits (4,300 statements of `t = t + N.5;`, 59,773 bytes) costs a measured 34,407 work units, so the ceiling keeps a 145x margin over the worst legitimate script that can be submitted. `painless::tests::the_budget_leaves_the_largest_benign_script_a_wide_margin` pins that number so a change to the charging scheme reports how far the margin moved. ## Integration with the request deadline Every 1,024 work units the counter also reads the clock. The slice an evaluation gets is the enclosing request's *remaining* time, clamped to [100 ms, 500 ms], published by `with_script_deadline` from `Index::search` — the same task-local mechanism as the fault sink. That deadline is resolved in `PainlessCtx::new`, before any work is charged, and this is load-bearing rather than tidy. Deriving it inside the first `clock_check` costs one sampling window to *establish* the deadline and a second one to be able to fail it, and a window is bounded in work units, not in time — so "one window" is however long the largest single charge takes. It cost one clock read per evaluation to fix; the measured overshoot against a 100 ms slice is now 0.48 ms on a 10,000-node document, 1.17 ms at 40,000 and 7.11 ms at 160,000, growing with the cost of one charged step exactly as that argument says it should. The 100 ms floor is what keeps the trip meaningful. The request deadline is routinely already past when a script runs, because the doc scan only checks it every 4,096 documents, so cutting evaluations off the moment it passes would turn every ordinary `timeout` into a `script_exception` 400. 100 ms is ~360x the measured 278 µs cost of the largest benign script that can be submitted. Both trips classify as resource limits (`is_resource_limit_error`), so they reach the caller as a 400 through the sink #116 built rather than degrading to a plausible wrong score. Once any evaluation in a request has tripped, later ones short-circuit on the recorded fault, which makes ONE budget the bound for a whole request rather than one per document. ## Result | script | before | after | |---------------------------------------------------|--------:|-------:| | 3,000 x `params.blob;`, params of 50,000 nodes | 9.03 s | 97 ms | | 3,000 x `params.blob;`, params of 200,000 nodes | 37.35 s | 109 ms | | 3,000 x `params['_source'];`, doc of 100,000 nodes | 17.17 s | 168 ms | | 2,000 x `doc['rank'].value`, doc of 20,000 nodes | 1.13 s | 155 ms | Flat in the size of the input, because the trip happens at a fixed work count. Through `_search`, the first of those is now a 400 `script_exception` naming the budget. ## Cost to ordinary scripts Interleaved A/B: base and fixed binaries alternated inside each repetition and pinned to the same idle core, 15 repetitions, each figure already the best of 40 trials. Reporting the best-of-repetitions, which is the least-interrupted observation on each side: | script | base | fixed | delta | |------------------------------------|-----------:|-----------:|--------:| | 42 B, reads `doc[...]` and `params`| 274.8 ns | 353.8 ns | +28.7% | | 237 B arithmetic | 1124.7 ns | 1230.6 ns | +9.4% | | 59,773 B arithmetic, 34,407 units | 271.1 µs | 298.5 µs | +10.1% | These are larger than the numbers the first revision of this change quoted (+6.9% / +0.46%), and the difference is not the round-2 fixes: measured on the same run, the first revision alone is +19.5% / +0.9% / +5.3% against the same base. A probe build carrying only the `eval_access_chain` charge lands within 1% of it on all three shapes, so the security fix itself is not what costs — the accounting was always the price of the budget. Of the remainder, the tiny script's +25 ns is the one clock read per evaluation that anchoring the deadline requires, which is the right order for a vDSO `clock_gettime`. The sub-5% gaps on the two arithmetic shapes are not attributable: a probe that does strictly *less* work than the final build measures slower than it on one shape and faster on the other, which is build-to-build codegen variation of the same magnitude as the effect. ## Query width: `max_clause_count` There was no clause limit anywhere, so a query could be arbitrarily *wide* as well, and width is what multiplies per-document work. `MAX_CLAUSE_COUNT` = 1,024 matches Elasticsearch's documented `indices.query.bool.max_clause_count` default and returns its `too_many_clauses` error, so a query this refuses is one the system being emulated already refused. Clauses are charged before they are parsed and summed across the whole query, not per `bool`, and every clause-list shape counts towards the same total: `bool`, `span_or`/`span_near` clauses, `dis_max.queries` and an array `knn.filter`. A cap is only worth having if tripping it is *visible*. `parse_span_or` and `parse_span_near` collected clauses with `filter_map(|v| parse_query(v).ok())`, discarding any clause the parser refused. That was survivable while every refusable clause was malformed; with a width cap it becomes a correctness bug, because a well-formed clause that is merely too wide now fails to parse. A `span_or` over [an over-wide `bool`, a `span_term`] parsed to a `SpanOr` containing only the `span_term` — 200 OK for a query nobody asked for — and a `span_near` whose only clause was over-wide collapsed to `MatchNone`: 200 OK, zero hits, no error anywhere. A silently different query is worse than an unbounded one. Every clause error now propagates. ## Verification * Reverting only `painless.rs` and keeping the tests: `bare_params_reads_are_charged_for_what_they_materialise`, `subscripted_params_reads_are_charged_too` and `params_source_reads_are_charged_for_the_document_they_clone` all fail, each reporting that the script was charged 2,048 work units, and `script_cpu_budget_http` fails on the wall clock having to notice what the op ceiling should have. The work-count assertions are deliberate: an uncharged read is still stopped *eventually*, after ~15 s, so a test that only asserts "some resource limit tripped" passes on the broken tree. * Reverting only `parser.rs` and keeping `tests/span_clause_refusal.rs`: 6 of its 9 tests fail, reporting the dropped clause and the `MatchNone` collapse. That file lives outside `parser.rs` precisely so it survives the revert. * `the_time_budget_trips_on_its_own_inside_an_expired_request` exercises the wall-clock half on its own; every other test here trips the deterministic op ceiling first. * `cargo test --release -p xerj-query -p xerj-engine -p xerj-api` green. * `cargo fmt --check` and `cargo clippy --lib --tests -- -D warnings` clean on all three crates. * fix(query): keep span-clause parsing lenient for unsupported types The clause-cap fix made parse_span_clauses propagate EVERY clause parse error, not just the MAX_CLAUSE_COUNT trip it was meant to surface. That regressed a previously-passing ES-compat case: a `span_near` containing a `span_multi` (a clause type this engine does not implement) went from silently dropping the unsupported clause to 400-ing the whole search — conformance search/190_index_prefix_search. The cap must stay fatal — that was the #122 defect, an over-cap span quietly becoming a different query. An unsupported clause type must not be. So charge_clause() still propagates via `?`, while an ordinary parse error for an unknown clause type is dropped as before. Verified: full ES-compat suite 1365 passed / 0 failed (was 1 failed on the span_multi case); the clause-cap refusal tests still pass. * fix(query): propagate only the clause-cap error from span clauses, drop the rest My first conformance fix dropped ALL span-clause parse errors, which silently re-dropped a cap-refused clause: MAX_CLAUSE_COUNT trips INSIDE parse_query when a clause is a wide `bool`, not in charge_clause, so `if let Ok` swallowed it and a span with one over-cap bool collapsed to MatchNone again. That broke the branch's own span_clause_refusal tests (a_refused_span_near_clause_does_not_ become_match_none, and the dis_max case). Correct rule: the cap is fatal wherever it trips — propagate any error whose message is `too_many_clauses`, from charge_clause OR from a nested bool. Every other parse failure (an unimplemented `span_multi`, a malformed clause) is dropped, which is the pre-#122 lenient behaviour ES-compat conformance depends on (search/190_index_prefix_search). Retargeted the one test that asserted a malformed clause is fatal: it now asserts the unsupported clause DROPS and the supported one survives, matching conformance. All 9 span_clause_refusal tests pass; the cap-fatal ones still refuse, and full conformance was 1365/0 with unsupported clauses dropping.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #97
MAX_CALL_DEPTH = 32is a real functional ceiling on legitimate recursion, and in the scoring paths it failed silently: a script recursing deeper than 32 did not error, it returned a wrong score. The bound itself is correct and stays. Wrong-quietly was the defect.The distinction that drives the fix
Two failure classes had been collapsed into one, and they need opposite treatment:
eval_painlesscall sites discard the error deliberately for that compatibility. Left alone.is_resource_limit_errormarks the interpreter's resource-limit sentinels (call depth, now its own sentinel split from invocation count, plus eval depth, invocation count, source size). Everything else stays an ordinary script error.A tokio task-local fault sink records limit trips from inside
eval_painless, so the seven ES-compat call sites keep their neutral-value fallbacks byte-identical while a refused evaluation still becomes visible.Index::searchinstalls the sink, reports it on a newSearchResult.script_failure, and re-raises into any enclosing scope so a sub-search cannot hide a fault from the request containing it. Faulted results are never cached or single-flight-replayed._search,_msearch,_search/template,_msearch/template, scroll and async_search now return an ES-shapedscript_exception400._count,_reindex,_delete_by_queryand_update_by_queryrefuse rather than act on a selection a fail-closed script silently truncated.Why MAX_CALL_DEPTH stays 32
The issue offered raising it as an alternative. Re-measured in release: 40,720 bytes per call level in the parser's worst case, so depth 32 already consumes 1,262,320 bytes (1.20 MiB) of a 2 MiB tokio worker stack. Raising it overflows. Making deeper recursion possible needs closure-body nesting charged against a shared stack budget first; that is the real follow-up, not a bigger constant.
Also fixed
Rc<Vec<Stmt>>becomesArc, soPainlessValueisSend/Syncand no longer constrains code that moves a value across threads.Call-site audit
All nine
eval_painlesssites reviewed; behaviour changed at zero of them — the sink observes without altering. Still degrading for ordinary errors and now loud only on resource limits: terms-agg bucket keys, runtime-mapping emit,runtime_mappings,script_fields,terms_setmin_should_match, rescore, andfunction_scorescript_score. Left untouched because they already propagate loudly:eval_update_exprand/_scripts/painless/_execute.Known gaps, not fixed here
_rank_evalis the one remaining surface in this defect class and is still silent._delete_by_query/_update_by_queryrefuse correctly but return HTTP 200 with a 400-shaped body, so a client branching on HTTP status sees success.script_failureis fail-loud only under a capture scope and opt-in outside one. MakingIndex::searchreturnErrwould be un-ignorable but changes error shape across ~23 callers and turns tolerantif let Ok(result)sites into different silent drops. Judged not worth the blast radius here.20 tests added across
xerj-api,xerj-engineintegration andpainless.rsunits, including a negative control asserting unsupported syntax still degrades quietly.