Split out of #373 (item 8), which is otherwise complete. Measured on ES 8.18.3 at 80f76b1a.
A coercion-driven narrowing mutates a Painless parameter that other instances of the same column read, so an unrelated predicate changes the value they see.
Silent
SELECT CASE WHEN n > 0 THEN d ELSE d END AS c FROM t
-> def param2 = (doc['d'].size() == 0 ? null : doc['d'].value);
param3 ? param2 : param2 -- 2025-01-05T13:45:00.000Z, correct
SELECT CASE WHEN d = CAST('2025-01-05' AS DATE) THEN d ELSE d END AS c FROM t
-> def param1 = (doc['d'].size() == 0 ? null : doc['d'].value.toLocalDate());
param3 ? param1 : param1 -- 2025-01-05, the TIME IS GONE
An unrelated CAST(… AS DATE) comparison truncates the projected value of the same column. HTTP 200, a plausible date, wrong.
Loud, and in both directions
CASE WHEN d = CAST('2025-01-05' AS DATE) THEN 1
WHEN d > CAST('2025-01-05T00:00:00' AS TIMESTAMP) THEN 2 ELSE 0 END
-> param1 is a LocalDate; param1.isAfter(<ZonedDateTime>) class_cast_exception
Reversing the two WHEN arms reverses the failure — the narrowing is lost instead of imposed (Cannot cast java.time.LocalDate to …ChronoZonedDateTime). Whichever predicate registers first decides the shared parameter's rendering.
Cause
contextKey (#370) is the column plus the folded CHAIN, which is what the SQL says. A narrowing can also arrive from a coercion — a comparison against a DATE literal, or a CAST — and that is invisible to the chain. Two BARE instances of one column therefore have identical keys by construction while needing different renderings, and addParam stores the parameter OBJECT, so narrowing it after registration rewrites the declaration every other instance reads.
Two approaches tried and REFUTED — do not repeat them
Both were implemented, measured, and reverted.
1. Apply the narrowing BEFORE registering, with contextKey covering it. Fixes all three shapes and the control diff moves exactly those three — but leaves a dead parameter elsewhere:
LAST_DAY(d) = CAST('2025-01-31' AS DATE)
-> def param1 = (… doc['d'].value.toLocalDate()); -- never referenced
def param2 = (… doc['d'].value.toInstant().atZone(Z).toLocalDate());
painless() prepends the UTC normalisation during rendering, so a key frozen at registration does not describe what is finally emitted and the operand registers a second time. (main emits one parameter here; the branch emitted two.)
2. Make contextKey a recomputed def instead of a lazy val, keeping the original ordering so re-keying happens on mutation. Emits two declarations named param1 — invalid Painless. A parameter's name is assigned at registration, and re-keying desynchronises the lookup from the stored declaration.
The constraint any fix must satisfy
The final rendering is not known at registration time — the UTC normalisation arrives later. Identity therefore cannot be settled when the parameter is created, which is what defeated both attempts.
The place where the rendering is known is render time. The suggested direction: when an identifier renders and finds a registered parameter carrying methods it did not ask for, it takes its own parameter instead of reusing that one.
Ready-made regression truths
CASE WHEN n > 0 THEN d ELSE d END must project the full timestamp.
CASE WHEN d = CAST('2025-01-05' AS DATE) THEN d ELSE d END must project the full timestamp too.
- Both WHEN orders of the loud shape must execute and agree.
LAST_DAY(d) = CAST('2025-01-31' AS DATE) must keep emitting exactly ONE doc-value parameter.
Related
DATE_TRUNC(d, MONTH) = CAST('2025-01-01' AS DATE) AND DATE_TRUNC(d, MONTH) < d fails for this reason, and is characterised in MixedTemporalComparisonSpec as belonging here rather than to #373 item 1. ParameterIdentitySpec's "the identity key should not read the narrowing off the object" pins the current two-parameter shape and its comment records that BOTH orders fail the shard — that expectation will move when this is fixed.
Split out of #373 (item 8), which is otherwise complete. Measured on ES 8.18.3 at
80f76b1a.A coercion-driven narrowing mutates a Painless parameter that other instances of the same column read, so an unrelated predicate changes the value they see.
Silent
An unrelated
CAST(… AS DATE)comparison truncates the projected value of the same column. HTTP 200, a plausible date, wrong.Loud, and in both directions
Reversing the two WHEN arms reverses the failure — the narrowing is lost instead of imposed (
Cannot cast java.time.LocalDate to …ChronoZonedDateTime). Whichever predicate registers first decides the shared parameter's rendering.Cause
contextKey(#370) is the column plus the folded CHAIN, which is what the SQL says. A narrowing can also arrive from a coercion — a comparison against a DATE literal, or a CAST — and that is invisible to the chain. Two BARE instances of one column therefore have identical keys by construction while needing different renderings, andaddParamstores the parameter OBJECT, so narrowing it after registration rewrites the declaration every other instance reads.Two approaches tried and REFUTED — do not repeat them
Both were implemented, measured, and reverted.
1. Apply the narrowing BEFORE registering, with
contextKeycovering it. Fixes all three shapes and the control diff moves exactly those three — but leaves a dead parameter elsewhere:painless()prepends the UTC normalisation during rendering, so a key frozen at registration does not describe what is finally emitted and the operand registers a second time. (mainemits one parameter here; the branch emitted two.)2. Make
contextKeya recomputeddefinstead of alazy val, keeping the original ordering so re-keying happens on mutation. Emits two declarations namedparam1— invalid Painless. A parameter's name is assigned at registration, and re-keying desynchronises the lookup from the stored declaration.The constraint any fix must satisfy
The final rendering is not known at registration time — the UTC normalisation arrives later. Identity therefore cannot be settled when the parameter is created, which is what defeated both attempts.
The place where the rendering is known is render time. The suggested direction: when an identifier renders and finds a registered parameter carrying methods it did not ask for, it takes its own parameter instead of reusing that one.
Ready-made regression truths
CASE WHEN n > 0 THEN d ELSE d ENDmust project the full timestamp.CASE WHEN d = CAST('2025-01-05' AS DATE) THEN d ELSE d ENDmust project the full timestamp too.LAST_DAY(d) = CAST('2025-01-31' AS DATE)must keep emitting exactly ONE doc-value parameter.Related
DATE_TRUNC(d, MONTH) = CAST('2025-01-01' AS DATE) AND DATE_TRUNC(d, MONTH) < dfails for this reason, and is characterised inMixedTemporalComparisonSpecas belonging here rather than to #373 item 1.ParameterIdentitySpec's "the identity key should not read the narrowing off the object" pins the current two-parameter shape and its comment records that BOTH orders fail the shard — that expectation will move when this is fixed.