fix(engine): bucket_script resolves TSVB filter_ratio's _count + null/ternary script - #95
fix(engine): bucket_script resolves TSVB filter_ratio's _count + null/ternary script#95Vinz2168 wants to merge 2 commits into
Conversation
…unt`
`resolve_bucket_script`'s buckets_path lookup only checked for a field
literally named `_count` with a nested `.value` — e.g. for
`"numerator": "numerator>_count"` it did `sib.get("_count")`, which
never matches, since a `filter` (singular) sub-aggregation's bucket is
shaped `{"doc_count": N}`, not `{"_count": {"value": N}}`. The special
`_count` buckets_path segment (meaning "this sibling's own doc_count")
was already handled correctly elsewhere in this file
(`get_bucket_metric_value`, `extract_bucket_value_opt`) but that
handling was never applied to `resolve_bucket_script`, so any
`bucket_script` referencing a sibling `filter` agg's count always
resolved to null.
This is exactly the shape Kibana/OpenSearch Dashboards' TSVB
`filter_ratio` metric compiles to (a `filter` sub-agg named
"numerator", another named "denominator", and a `bucket_script`
dividing their `_count`s) — found live while testing the sample
Flights dashboard: the "[Flights] Delays & Cancellations" panel
rendered as a flat 0%/empty line, every bucket null, despite the
underlying `filter` doc counts being correct.
Fixed by adding the same `_count` -> `doc_count` special case already
used elsewhere in this file, plus the same `.as_f64()` fallback for
plain-numeric sibling values.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014PRCbyt7Y2HDyhG1tbQTeL
… ternary Kibana/OpenSearch Dashboards' TSVB `filter_ratio` metric compiles its `bucket_script` to a script that null-guards its division: params.numerator != null && params.denominator != null && params.denominator > 0 ? params.numerator / params.denominator : 0 Confirmed by temporarily logging the real request body xerj received from a live OSD instance (removed before this commit) while debugging the sample Flights dashboard's "Delays & Cancellations" panel, which stayed empty even after fixing the sibling `_count` buckets_path lookup in the previous commit. `eval_script_expr`'s tokenizer had no `?`/`:` support at all, and treated the bare word `null` as a `params.<name>` identifier lookup — which always failed (no such param), aborting the entire script parse and returning `None`. So this exact real-world script could never evaluate, independent of the buckets_path fix. Adds: - A `null` literal token, meaningful only in `==`/`!=` comparisons (matching the only way TSVB's script class actually uses it — as a missing-sibling guard). Any other operator applied to `null` fails the script, same as before. - Ternary `cond ? a : b`, implemented as a pre-pass that splits the token stream on the first top-level `?`/matching `:` (respecting paren depth) and recurses, rather than folding it into the operator-precedence shunting-yard (which doesn't model ternary well as a plain binary operator). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014PRCbyt7Y2HDyhG1tbQTeL
|
Reviewed for merge. Holding this one, because the review found a way to kill the process from a single request. The Blocker: unauthenticated remote process kill
This is the same shape the project has already shipped and fixed three times: nested SQL Two things to fix:
AlsoThe diff deletes 78 lines in Merge mechanicsThe commits carry Genuinely useful fix and a real Kibana/TSVB compatibility gap. It is the evaluator's missing bound that needs closing first. |
…the bucket_script evaluator ahead of #95 Two changes with different urgency. Stating that plainly because an earlier draft of this message claimed a process kill that shipped XERJ never had. LIVE DEFECT — `/_msearch` skipped the script guard. `/_msearch` built its sub-requests straight off `parse_request`, skipping `build_search_request` and therefore the request-time script validation the single-search path applies. That is a general bypass of the guard, not something specific to any one script shape: a 240 KB `_msearch` body whose items carry scripts that `_search` rejects with a 400 came back 200 on every item. `_search/template` and `_msearch/template` skipped the same guard on their rendered bodies. All three now run it, `_msearch` per sub-search item, failing only the offending item with a 400 in ES's per-item error shape and letting the rest of the batch run. PRE-EMPTIVE BOUND — the evaluator is not recursive today, and is about to be. On this tree `eval_script_expr` is `tokenize_script` -> `shunting_yard` -> `evaluate_rpn`, all three iterative; `?` and `:` are not even tokens. An 80,001-byte nested-ternary source returns `None` in 4.5 us on an optimized build. There is no stack growth to exhaust and no released version was process-killable through this path. The recursion arrives with PR #95, which adds `eval_tokens` and `find_ternary_split` and re-enters the evaluator once per `?`. With #95 applied and this guard bypassed, the same 80,001-byte source overflows the worker stack and, because the release profile sets `panic = "abort"`, aborts the process rather than returning an error. So the bound lands first and #95 lands onto it. `check_expr_limits` now runs on ENTRY to `eval_script_expr`, before a single token is produced, rejecting a source past 64 KiB, past `MAX_EXPR_DEPTH` (64) paren nesting, or carrying more than `MAX_EXPR_DEPTH` ternaries, which matches `sql::MAX_SQL_DEPTH` and `xerj-query`'s `MAX_QUERY_DEPTH`. Ternaries are counted rather than depth-tracked because a right-associative chain (`a?b:c?d:e`) descends one level per `?` while never opening a paren. A violation resolves to the same null bucket value every other unevaluable script already produces. Closes #111
…the bucket_script evaluator ahead of #95 (#115) Two changes with different urgency. Stating that plainly because an earlier draft of this message claimed a process kill that shipped XERJ never had. LIVE DEFECT — `/_msearch` skipped the script guard. `/_msearch` built its sub-requests straight off `parse_request`, skipping `build_search_request` and therefore the request-time script validation the single-search path applies. That is a general bypass of the guard, not something specific to any one script shape: a 240 KB `_msearch` body whose items carry scripts that `_search` rejects with a 400 came back 200 on every item. `_search/template` and `_msearch/template` skipped the same guard on their rendered bodies. All three now run it, `_msearch` per sub-search item, failing only the offending item with a 400 in ES's per-item error shape and letting the rest of the batch run. PRE-EMPTIVE BOUND — the evaluator is not recursive today, and is about to be. On this tree `eval_script_expr` is `tokenize_script` -> `shunting_yard` -> `evaluate_rpn`, all three iterative; `?` and `:` are not even tokens. An 80,001-byte nested-ternary source returns `None` in 4.5 us on an optimized build. There is no stack growth to exhaust and no released version was process-killable through this path. The recursion arrives with PR #95, which adds `eval_tokens` and `find_ternary_split` and re-enters the evaluator once per `?`. With #95 applied and this guard bypassed, the same 80,001-byte source overflows the worker stack and, because the release profile sets `panic = "abort"`, aborts the process rather than returning an error. So the bound lands first and #95 lands onto it. `check_expr_limits` now runs on ENTRY to `eval_script_expr`, before a single token is produced, rejecting a source past 64 KiB, past `MAX_EXPR_DEPTH` (64) paren nesting, or carrying more than `MAX_EXPR_DEPTH` ternaries, which matches `sql::MAX_SQL_DEPTH` and `xerj-query`'s `MAX_QUERY_DEPTH`. Ternaries are counted rather than depth-tracked because a right-associative chain (`a?b:c?d:e`) descends one level per `?` while never opening a paren. A violation resolves to the same null bucket value every other unevaluable script already produces. Closes #111
…sed ternaries Combines PR xerj-org#95 and PR xerj-org#105 (xerj-org#105's head already contained xerj-org#95's commits verbatim, so this is one stack, not two rival changes) and reworks the parts an adversarial review measured as wrong. Rebased onto main now that xerj-org#111 has landed as xerj-org#115; the `check_expr_limits` entry bound this relies on comes from there. `_count` BUCKETS_PATH — TWO FORMS, ONE INVARIANT. `resolve_bucket_script` now resolves `"<sibling>>_count"` (it looked for a sub-agg literally named `_count`, found nothing, and nulled the bucket) and the bare `"_count"` that Timelion's `.opensearch()` datasource emits for its per-bucket count metric. The bare form refers to the CONTAINING bucket's own doc_count, so `run_aggs_in_bucket` stages one under a transient `doc_count` key for the resolver to read, then strips it back out. The invariant, stated once because getting it wrong is what blocked the previous attempt: whatever number a bucket publishes as its `doc_count`, a bare `_count` inside that bucket resolves to THE SAME number. Not "the weighted count" and not "the physical count" — the bucket aggs in this file split into two families and `_count` has to follow each of them rather than pick a side: sum_doc_count(docs) — honours the `_doc_count` metadata field rollup / downsampled indices attach: terms, date_histogram, histogram, filter, filters, date_range, composite. docs.len() — physical rows, `_doc_count` ignored: range, multi_terms, ip_range, ip_prefix, rare_terms, geo_distance, geotile_grid, geohash_grid, adjacency_matrix, time_series, variable_width_histogram, sampler, diversified_sampler, nested, reverse_nested, global. So `run_aggs_in_bucket` stages the count its CALLER is about to publish, passed in as an argument, rather than recomputing one. Recomputing is wrong with either function, and both spellings were measured on a two-doc corpus `[{s:a,n:1,_doc_count:5},{s:a,n:2,_doc_count:3}]` with a `{"buckets_path": "_count"}` sub-agg: staging sum_doc_count -> range doc_count 2 vs `_count` 8 multi_terms 1 vs 5, and the same split for adjacency_matrix, sampler, diversified_sampler, global, reverse_nested, time_series, rare_terms and variable_width_histogram (10 types, not the 2 the report named) staging docs.len() -> terms doc_count 8 vs `_count` 2 Passing the caller's number gives agreement for all 24 bucket types that run sub-aggs, measured one by one — that sweep is now `bare_count_agrees_with_every_bucket_types_doc_count`, which fails on `range` with the recomputing version restored. significant_terms, significant_text and missing never call into sub-aggs at all, so they have no `_count` surface. At the TOP level of an aggs tree there is no enclosing bucket, so `_count` is the physical size of the result set — the number `hits.total.value` carries in the same response. A previous version of this change asserted in a shipped comment, and in its commit message, that `range` builds its doc_count with `sum_doc_count`. It does not: `run_range` uses `bucket_docs.len()`, as does `run_multi_terms`. Both the comment and this message now describe the code that is actually here. THE EXPRESSION EVALUATOR. Three changes, one of which is a deliberate behaviour delta: * `null` literal, so TSVB's `filter_ratio` guard (`params.x != null && ...`) evaluates instead of nulling the bucket. * Ternaries INSIDE PARENTHESES now evaluate. `find_ternary_split` only ever looked at paren-depth 0, and any `?` that survived into `shunting_yard` made it bail, so `(1?1:0)` — and therefore every nested ternary, and `(params.a > 0 ? 1 : 0) + 1` — silently produced a null bucket value for a legal Elasticsearch script. Measured as `None`, verbatim, for `(1?1:0)`, `(1?1:0) + 1`, `1 + (1?1:0)`, `0?1:(0?2:3)`, `(0?1:2) * 10`, `(1>0 ? 1 : 0) + 1` and `1?(1?5:6):0`. Fixed rather than rejected: rejecting would need an error channel `resolve_pipeline_agg_full` does not have (it returns a Value, and every unevaluable script degrades to `{"value": null}`), and a hard error is the wrong answer for a script ES accepts. `fold_paren_ternaries` collapses each parenthesised group containing a `?` to the number it evaluates to before shunting-yard sees it. Groups without a `?` are untouched. That adds a second recursion path, so the entry bound's argument changed with it: a frame consumes either the `?` it split on or one paren level, and `check_expr_limits` caps both at MAX_EXPR_DEPTH, giving 2*MAX_EXPR_DEPTH + 1 frames rather than MAX_EXPR_DEPTH + 1. The doc comment says so now, and `parenthesised_ternaries_stay_inside_the_entry_bound` exercises a chain that alternates the two forms. * `!=` ANSWERS DIFFERENTLY AGAINST NaN. This is the delta an earlier summary denied. `==` / `!=` are derived from one equality test so they can also compare the `null` literal; `!=` is now the negation of `==` instead of its own `(a - b).abs() >= 1e-9`. Every comparison against NaN is false, so the old form answered `NaN != 0` with 0.0 — "they are equal". Measured end to end on `params.a * params.a - params.a * params.a != 0` over one doc with `n = 1e308` (the product overflows to +inf; inf - inf is NaN): 0.0 with the old arms restored on this tree, 1.0 with the current form. `==` is unchanged, 0.0 both ways, and non-NaN operands keep the identical 1e-9 epsilon. 1.0 is what IEEE-754, and therefore Java, and therefore Painless say, so the new answer is kept — but it is a change, it is pinned by `ne_against_a_nan_operand_is_true_not_false` and `nan_operand_ne_reports_inequality_end_to_end`, and it is no longer described as "identical NaN behaviour". TEST CORRECTION. `sibling_underscore_count_matches_a_doc_count_weighted_filter_bucket` was documented as pinning the weighted staging. It does not: with the staging reverted to `docs.len()` it still passes, and only `bare_count_buckets_path_matches_a_doc_count_weighted_bucket` fails. What it actually pins is `"<sibling>>_count"` resolving at all. Its comment now says that, and records the measurement.
…sed ternaries (#105) Contains PR #95 verbatim, so this closes both. `bucket_script` could not resolve `_count` as a bare-string buckets_path, which is what Kibana's TSVB `filter_ratio` emits, and its expression evaluator had no null literal and no ternary. Together those made a whole class of ordinary TSVB panels return null. `_count` NOW AGREES WITH `_doc_count`. A `_doc_count`-weighted bucket and the `bucket_script` inside it used to disagree about how many documents the bucket held, because the bare `_count` reported the physical document count. Verified per aggregation type against a weighted fixture: terms, range, multi_terms, histogram, date_histogram, auto_date_histogram, filter, filters, ip_range, geo_distance, nested and the sampler family all report a `_count` equal to their own `doc_count`. PARENTHESISED TERNARIES EVALUATE. `find_ternary_split` only looked at paren depth 0, so `(1?1:0)` — and by extension any nested ternary, and `(params.a > 0 ? 1 : 0) + 1` — silently resolved to a null bucket value. TWO EQUAL INFINITIES COMPARE EQUAL. Deriving `==` from `(x - y).abs() < 1e-9` alone is wrong for infinities: `inf - inf` is NaN, NaN fails the epsilon test, and two identical values therefore compared unequal. Measured with one document at `n = 1e308`, `params.a * params.a != params.a * params.a` answered 1.0 where IEEE-754 and the JLS both say `inf == inf`. Exact equality is checked before the epsilon. BEHAVIOUR CHANGES, each pinned by a test rather than asserted in prose: - `!=` against a NaN operand: 0.0 -> 1.0. The old form reported `NaN != 0` as "equal", because every comparison against NaN is false. 1.0 is the IEEE-correct answer and the change is deliberate. - A sub-agg literally named `_count`: the bucket's doc count now wins, per ES's reserved-path rule. - Object-form buckets_paths at a plain numeric sub-metric now resolve where they previously returned null. The expression evaluator's depth bound from #111 is underneath this, so the recursion `eval_tokens` introduces is bounded before it can be reached.
|
Closing as already merged, not as rejected. #105's branch was stacked directly on this one — Landed as 5df22b7 on The
The expression-evaluator depth bound from #111 landed first, so the recursion Thanks for both of these. |
Summary
Fixes
bucket_scriptso it correctly resolves the exact pattern Kibana/OpenSearch Dashboards' TSVBfilter_ratiometric compiles to — found live while testing the sample "[Flights] Global Flight Dashboard": the "Delays & Cancellations" panel (afilter_ratioline chart) rendered as a flat, empty 0% line for every time bucket.Two independent bugs in the same function, both required to fix the panel:
1.
_countbuckets_path segment not resolved (resolve_bucket_script)TSVB's
filter_ratiogenerates afiltersub-agg named "numerator" and another named "denominator", then abucket_scriptreferencing each one's doc count via"numerator>_count"/"denominator>_count".resolve_bucket_script's lookup only checked for a field literally named_countwith a nested.value(sib.get("_count").and_then(|x| x.get("value"))) — but afilter(singular) aggregation's bucket is shaped{"doc_count": N}, not{"_count": {"value": N}}. The special_count→doc_countmapping already existed elsewhere in this file (get_bucket_metric_value,extract_bucket_value_opt) but was never applied here, so anybucket_scriptreferencing a siblingfilter's count always resolved tonull.2. Script evaluator had no
nullliteral or ternary supportThe real script TSVB sends is:
eval_script_expr's tokenizer treated the bare wordnullas aparams.<name>lookup (always failing — no such param — aborting the whole parse), and had no?/:support at all. So this exact script could never evaluate, independent of fix #1.How this was found
Confirmed via a temporary debug log of the real request body xerj received from a live OSD instance (removed before this PR) — the panel's actual query didn't match my first, simpler manual reproduction, which is what surfaced bug #2.
Fix
resolve_bucket_script: added the same_count→doc_countspecial case (+.as_f64()fallback for plain-numeric siblings) already used elsewhere in this file.eval_script_expr: added anullliteral token (meaningful only in==/!=, matching how TSVB actually uses it), and ternarycond ? a : bvia a pre-pass that splits the token stream on the first top-level?/matching:and recurses — rather than folding it into the operator-precedence shunting-yard, which doesn't model ternary well as a plain binary op.Test plan
cargo fmt --checkcargo clippy -p xerj-engine -p xerj-api --all-targets -- -D warningscargo test -p xerj-engine --lib(287/289 — the 2 failures are the pre-existing, unrelatedsnapshot_path_security_testsfailures also present onupstream/main)bucket_script_resolves_sibling_filter_doc_count_via_underscore_count,bucket_script_supports_null_literal_and_ternary_like_tsvb_filter_ratio,bucket_script_ternary_false_branch_avoids_division_by_zero(denominator=0 guard)curlagainst the exact captured live request now returns real ratios (was all-null before) for all 27 buckets, and the "[Flights] Delays & Cancellations" dashboard panel renders a populated area chart instead of a flat empty 0% line.🤖 Generated with Claude Code
https://claude.ai/code/session_014PRCbyt7Y2HDyhG1tbQTeL