Skip to content

A column compared with a function of itself no longer collapses onto one Painless parameter (#370) - #374

Merged
fupelaqu merged 1 commit into
mainfrom
fix/370-parameter-identity-per-chain
Sep 19, 2026
Merged

fupelaqu merged 1 commit into
mainfrom
fix/370-parameter-identity-per-chain

Conversation

@fupelaqu

Copy link
Copy Markdown
Contributor

The defect

PainlessParam identity was the doc-value string (Identifier.param = doc['d'].value), while a folding function's method is attached to the parameter object (toPainless → ctx.find(base).addPainlessMethod). The second use of a column in one script resolved, through PainlessContext.get, to the first object — which by then carried the first chain. Measured on ES 8.18 before the fix:

shape emitted outcome
WHERE DATE_TRUNC(d, MONTH) < d left1.isBefore(param1), truncation folded INTO param1 always false, HTTP 200
WHERE DATE_ADD(d, INTERVAL 1 DAY) = d left1.isEqual(param1) always true
WHERE YEAR(d) > MONTH(d) one parameter with .get(YEAR).get(MONTH_OF_YEAR) int.get → shard failure
CASE … WHEN CAST(lastUpdated AS date) - INTERVAL 3 DAY THEN lastUpdated … THEN returned lastUpdated − 3 days wrong branch value — and the bridge fixture had pinned it

The WHERE translator emits each AND branch as its own script filter, so YEAR(d) = 2025 AND MONTH(d) = 1 was never wrong live; the collapse needs one expression, or one script (CASE, COALESCE, Criteria.painless).

The fix — seven rules, each forced by a measurement

  1. Identity = column + the folded leading run of its chain (Identifier.contextKey), keyed on the folded functions' renderings so two spellings of one fold share (YEAR(d) ≡ EXTRACT(YEAR FROM d)). TransformFunction.foldsOntoOperand is the same startsWith(".") test toPainless folds on, taken context-free; the run stops at the first non-folding function (YEAR(DATE_PARSE(name, …)) keys on the raw name, which the DATE_PARSE argument shares). DATE_TRUNC … QUARTER binds instead of folding and overrides the test at the site that knows. equals/hashCode untouched — GenericIdentifier inherits them and the AST must not move.
  2. UTC normalisation first (prependPainlessMethod): a .toLocalDate() narrowing is appended at coercion time; on one shared object the fold masked the order, on two objects …toLocalDate().toInstant() fails.
  3. DateTrunc reads its receiver (rendersLocalDate) off the registered parameter — two instances of one identifier can share a parameter, and reading expr appended both renderings. Context-free the receiver is the type only, else the identity key depends on predicate order.
  4. The interval family folds onto its operand's parameter (ctx.find(base)), not onto "the last parameter created" (ctx.last). Found by review: CASE WHEN DATE_ADD(d, INTERVAL 1 DAY) > ts THEN DATE_ADD(d, INTERVAL 1 DAY) ELSE d END added the day to ts and returned it; with THEN/ELSE swapped it folded onto the boolean condition.
  5. A no-schema CAST declares the fold its toPainless really does, so CAST(x AS DATE) - INTERVAL 3 DAY keys apart from a bare x.
  6. A CAST is the identity on a receiver already narrowed to LocalDate. Rule 4 keeps a coercion ctx.last used to discard — the only reason CAST(d AS DATE) - INTERVAL 3 DAY = <date literal> worked before; and plain CAST(d AS DATE) = <date literal> failed the shard on main itself. One rule fixes both.
  7. Same for LocalTime (CAST(ts AS TIME) + INTERVAL 1 HOUR > …).

Rules 6 and 7 repair two regressions rule 4 introduced; both were found by the independent reviewer executing ~130 renderings against a control clone, not by any suite.

Verification

Every emission that moved was executed on real Elasticsearch, before and after: the four defect shapes; the CASE/COALESCE interval shapes over a fixture covering every branch (before: d → 2024-06-01 (ELSE), after: d → 2025-01-03; e → lastUpdated = 2025-01-04, where the old pin answered 2025-01-01); the CAST-vs-literal shapes ([b, c]); the QUARTER, CAST, YEAR(DATE_PARSE(…)), LAST_DAY, UPPER must-not-move shapes (byte-identical).

  • ParameterIdentitySpec (19): the mechanism (distinct parameters iff folded renderings differ; DATE_FORMAT's outer/inner share), the defect pins, the must-not-move pins, one pin per rule — each rule seen red by mutation.
  • PredicateTransformSurvivalSpec's 4,209-predicate sweep now renders the operand for its predicate (it rendered it in isolation, which rule 3 made a wrong reference), and its DATE_TRUNC = CAST(…) pin — which had certified an emission ES rejects (LocalDate.toInstant) — pins the executed one.
  • PredicateFunctionResultSpec on the five clients / ES 6.8 (rest, jest), 7.17, 8.18, 9.0: the A column compared with a function of itself collapses onto one Painless parameter — silent wrong answer #370 row flipped from the recorded-wrong Set() to the truth; YEAR(d) > MONTH(d), DATE_ADD(d, INTERVAL 1 DAY) = d, CAST DATE/TIME rows added; AND forms and QUARTER kept as controls.
  • Both bridge copies' CASE pins moved and were executed; caseWhenExpr had pinned the wrong branch.
  • sql 1405 · core 1129 · five bridge copies 208 each · 2.12.20 cross-compile · CI lint line — green on the committed bytes.
  • Parse cost ×5 interleaved against a control clone at 650345fa: control median 3404 µs (3200–3467), branch 3408 µs (3247–3646), ranges overlap — no signal. Measured at rules 1–3; the later rules are emission-time only.

Independent review, three passes on its own clone, before commit: the first pass found rule 4's population (ctx.last) and the pinned wrong CASE branch; the second found the two regressions rule 4 introduced; the third confirmed the repairs. The reviewer's own "order A executes correctly" claim did not reproduce — both predicate orders of that shape are loud (residual 1 below) and the pin says so.

Release notes

  • DATE_TRUNC/DATE_ADD/DATE_SUB/extractor chains compared with, or combined with another chain of, their own column now answer correctly; CASE/COALESCE branches over the same column with different intervals return the right value. Queries that silently returned the wrong rows will change.
  • CAST(<date column> AS DATE) = <date literal> (and >, BETWEEN, = CURRENT_DATE) now works; it failed the shard before.
  • Three mixed LocalDate/ZonedDateTime comparisons that were silently false now fail loudly (residual 1).
  • Downstream fixtures pinning these Painless emissions need updating; PainlessParam gains contextKey, prependPainlessMethod, rendersLocalDate/rendersLocalTime; TransformFunction gains foldsOntoOperand (additive, sealed/trait-default).

Residuals — pre-existing, deliberately out of scope, all in one follow-up

See #373 — nine items, each executed on real Elasticsearch before and after. Per the lead's decision they ship as one PR.

Closes #370

🤖 Generated with Claude Code

…pses onto one Painless parameter

`PainlessParam` identity was the doc-value STRING while a folding function's
method was attached to the parameter OBJECT, so the second use of a column in
one script resolved to the first object -- which by then carried the first
chain. `WHERE DATE_TRUNC(d, MONTH) < d` compared the truncation with ITSELF
(always false, HTTP 200); `DATE_ADD(d, INTERVAL 1 DAY) = d` was always true;
`YEAR(d) > MONTH(d)` folded both extractions onto one parameter and failed the
shard with `int.get(ChronoField)`. Measured on ES 8.18 before the fix.

Identity is now the column plus the FOLDED LEADING RUN of its chain
(`Identifier.contextKey`), decided by the same `startsWith(".")` test
`toPainless` folds on (`TransformFunction.foldsOntoOperand`, context-free,
keyed on renderings so two spellings of one fold share). `equals` is untouched:
a case-class identifier inherits it, and the AST must not move. Seven rules
travel together, each forced by a measurement:

  1. one parameter per (column, folded run); `DATE_TRUNC ... QUARTER` binds
     rather than folds and overrides the test at the site that knows
  2. the UTC normalisation of a raw doc-value is PREPENDED -- a `.toLocalDate()`
     narrowing is appended at coercion time, and once two uses are two objects
     the order is what it is
  3. `DateTrunc` reads its receiver off the REGISTERED parameter
     (`rendersLocalDate`), not the column's type: `truncatedTo` is not a
     `LocalDate` method
  4. the interval family folds onto ITS operand's parameter (`ctx.find(base)`),
     not onto "the last parameter created" (`ctx.last`): `CASE WHEN
     DATE_ADD(d, INTERVAL 1 DAY) > ts THEN DATE_ADD(d, INTERVAL 1 DAY) ...`
     added the day to `ts` and returned it, silently
  5. a no-schema CAST declares the fold its `toPainless` really does, so
     `CAST(x AS DATE) - INTERVAL 3 DAY` keys apart from a bare `x`: `THEN x`
     returned `x - 3 days` -- and the bridge fixture had PINNED that value
  6. a CAST is the identity on a receiver already narrowed to `LocalDate`: rule
     4 keeps a coercion the old fold discarded, which is the only reason
     `CAST(d AS DATE) - INTERVAL 3 DAY = <date literal>` worked before, and the
     plain `CAST(d AS DATE) = <date literal>` failed the shard on main itself
  7. the same for `LocalTime`

Every emission that moved was executed on real Elasticsearch: the four defect
shapes, the CASE/COALESCE interval shapes, the CAST-vs-literal shapes, and the
two bridge CASE pins (old vs new over a fixture covering every branch). Guards:
`ParameterIdentitySpec` (19, each rule seen red by mutation), the survival
sweep re-referenced to render the operand FOR its predicate, executed rows in
`PredicateFunctionResultSpec` (the #370 row flipped to truth, `YEAR(d) >
MONTH(d)`, `DATE_ADD(d, INTERVAL 1 DAY) = d`, CAST DATE/TIME) green on the five
clients / ES 6.8, 7.17, 8.18, 9.0. Parse cost measured x5 interleaved against
a control clone: ranges overlap, no signal.

Reviewed independently (three passes) before commit. Pre-existing, surfaced,
deliberately NOT fixed here and owed to one follow-up PR: a mixed
LocalDate/ZonedDateTime comparison (`CAST(d AS DATE) - INTERVAL 3 DAY > d`,
now loud where it was silently false); `LocalTime` has no `isEqual`; the
CASE-expression WHEN has no null guard on its right operand; column-vs-column
temporal comparison translated as a `range` with the other column's NAME as
date math; arithmetic over date functions drops the functions (query and
ingest); Processor/Transform contexts still collapse (`LiteralParam` keys); the
arg-coercion path re-narrows an already narrowed parameter.

Closes #370

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@fupelaqu
fupelaqu merged commit f3da4b5 into main Sep 19, 2026
4 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.

A column compared with a function of itself collapses onto one Painless parameter — silent wrong answer

1 participant