Skip to content

fix(21.8 Part C): a date function must emit Painless that RUNS, in every context - #315

Merged
fupelaqu merged 3 commits into
mainfrom
feature/21.8-C
Sep 9, 2026
Merged

fix(21.8 Part C): a date function must emit Painless that RUNS, in every context#315
fupelaqu merged 3 commits into
mainfrom
feature/21.8-C

Conversation

@fupelaqu

@fupelaqu fupelaqu commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Story 21.8 Part C, lead-scoped to "everything": the deferred assembly defect, the routed ingest
residual, and three more that the real-index rule turned up.

What was actually wrong

The assembly (AD-1 / AC-5). TransformFunction.toPainless appended a function's rendering to
its operand assuming the call is a method CHAINED onto it. A STANDALONE call already carries the
operand in its own arguments, so it was emitted twice and two tokens fused — "2025-01-10"LocalDate.parse(…),
e0def arg0 = …. Fixed at the assembly, not in the four toPainlessCalls: the context-bearing
branch beside it already made this decision with its else p, so the two renderings now agree by
construction. The record's owed confirmation is discharged — measured on ES 8.18.3, both pre-fix
strings are script_exception :: compile error. The fusion also propagated outward
(YEAR(DATE_PARSE(name, fmt)) carried it), which the record did not have.

Three more, all LIVE on the production path and all pre-existing. Found by executing the
assembly's own output. Each has a different cause; none is the assembly:

shape ES 8.18.3 said
DATE_FORMAT('<string>', fmt), DATETIME_FORMAT(<keyword col>, fmt) Cannot cast from [java.lang.String] to [TemporalAccessor]
YEAR(DATE_PARSE(col, fmt)), MONTH(...) Cannot cast from [int] to [java.lang.Object]
DATE_TRUNC(DATE_PARSE(col, fmt), MONTH) member method [java.time.LocalDate, truncatedTo/1] not found

The documented spelling DATE_FORMAT('2025-01-10'::DATE, '%Y-%m-%d') escaped the first only because
the CAST inserted the parse — which is why the operand is now parsed through the same coerce
arms, so the two spellings cannot accept different format sets.

The ingest path (the routed residual). ctx.<field> is the raw JSON value of the document, so
YEAR / MONTH / DATE_TRUNC / DATE_ADD / DATE_FORMAT / DATE_DIFF threw, ignore_failure: true swallowed it, and the computed column was silently absent. Three things had to be true and
only the first was known:

  1. the shape is decided at RUNTIME (instanceof), per your ruling — Elasticsearch accepts BOTH an
    ISO string and epoch millis into a date field, so neither guess was right. The previous code
    hard-coded from = SQLTypes.BigInt, untested, and was wrong for every documented example;
  2. 🔴 the ingest clock was NULL on every supported major. ctx['_ingest']['timestamp'] throws
    on ES 6.8.23, 7.17.29, 8.18.3 AND 9.0.3 — verified on real indices, not _simulate — so
    CURRENT_DATE / CURRENT_TIMESTAMP / NOW / TODAY in SCRIPT AS had never worked anywhere.
    Not a version split: naming metadata() fails COMPILATION on ES 6/7 even in a branch never
    taken. System.currentTimeMillis() is whitelisted on all four and is already the unit the
    surrounding Instant.ofEpochMilli(...) expects;
  3. a processor collapses to ONE temporal type, ZonedDateTimeChronoUnit.between refuses mixed
    types and Elasticsearch refuses a LocalDate in ctx at all. The same collapse
    SQLTypeUtils.runtimeType already applies to a query.

The published DATE_DIFF(birthdate, CURRENT_DATE, YEAR) now stores age: 36 for
{"birthdate":"1990-05-20"} and {"birthdate":643161600000} on all four majors.

🔴 A bridge fixture was pinning a script Elasticsearch REJECTS

SQLQuerySpec's datetime_parse expectation returns class_cast_exception on ES 8.18.3. Nothing
ever executed it. Updated in both bridge copies, and the same statement is now a unit test whose
emission was executed. Story 21.8's AC-G3 "byte for byte" ingest pin is deliberately gone for
the same reason: it proved the bytes had not moved and could not notice they did not run.

Release notes

  • DATE_PARSE / DATETIME_PARSE / DATE_FORMAT / DATETIME_FORMAT produce valid Painless in the
    context-free rendering; DATE_FORMAT/DATETIME_FORMAT over a STRING operand and
    YEAR/MONTH/DATE_TRUNC over a parse result now execute instead of failing the shard.
  • ⚠️ Date and time functions in a computed column now work. A table created on an earlier
    version keeps its old pipeline: re-run its CREATE TABLE (or ALTER TABLE … SET SCRIPT AS) and
    reindex documents whose computed column is absent. documentation/sql/ddl_statements.md carried a
    limitation note that is now false; it is replaced.
  • ⚠️ CURRENT_DATE in an INGEST script is a ZonedDateTime, not a LocalDate. A query is
    unaffected.
  • Unchanged: a document missing the source field still leaves the computed column absent.
  • Downstream repos pinning generated Painless need updating for the date family.

Verification

  • sql 993 · core 944 · bridge 197 · es6bridge 197 · macrosTests 21, both Scala
    legs; scalafmtCheckAll and headerCheck clean.
  • Six mutations, six predicted REDs, restore byte-identical. A seventh whose mutation did not
    apply (two occurrences, one replaced) was re-run rather than counted as green.
  • Emissions executed on real Elasticsearch 6.8.23, 7.17.29, 8.18.3 and 9.0.3 — script fields for
    the query path, ingest pipelines for the DDL path, both document shapes and the null path.

Not covered

The five-CLIENT integration suites were not run; verification went straight to the four ES servers
over HTTP, which is stronger for these emissions but does not exercise the client stacks. Worth a
run before merge.

Records (local-only, per your standing rule):
docs/issues/local-21.8-date-parse-emits-malformed-painless.md ·
docs/issues/local-21.8-ingest-temporal-function-unusable.md — both closed.

🤖 Generated with Claude Code

…ery context

Story 21.8 Part C, lead-scoped to "everything": the deferred assembly defect, the
routed ingest residual, and three more the real-index rule turned up.

## The assembly (AD-1 / AC-5)

`TransformFunction.toPainless` appended the function's own rendering to the operand
on the assumption that it is a method CHAINED onto it. True of most of the date
family, false of the four whose call is STANDALONE -- those already carry the operand
in their own arguments, so it was emitted twice and two tokens fused:

    DATE_PARSE('2025-01-10','yyyy-MM-dd')
      -> "2025-01-10"LocalDate.parse("2025-01-10", ...)
    DATE_PARSE(name,'yyyy-MM-dd')
      -> ... e0 != null ? e0def arg0 = (doc['name']...

Fixed at the assembly, not in the four `toPainlessCall`s: the context-bearing branch
beside it already made this decision with its `else p`, so the two renderings now
agree by construction. The record's OWED confirmation is discharged -- measured on
ES 8.18.3, both pre-fix strings are `script_exception :: compile error`.

The fusion also PROPAGATED outward (`YEAR(DATE_PARSE(name, fmt))` carried it), which
the record did not have.

## Three more, all LIVE on the production path and all pre-existing

Found by executing the assembly's own output, which is what AC-4's real-index rule is
for. Each has a different cause; none is the assembly.

  - `DATE_FORMAT('<string>', fmt)` and `DATETIME_FORMAT(<keyword col>, fmt)`:
    `Cannot cast from [java.lang.String] to [TemporalAccessor]`. The operand is now
    parsed through the same `coerce` arms a `::DATE` cast uses, so the two spellings
    cannot accept different format sets. The `SQLTypes.Varchar` case-object test that
    should have caught it was dead for every real column -- the same defect story 21.5
    fixed in `coerce`.
  - `YEAR(DATE_PARSE(col, fmt))`, `MONTH(...)`: `Cannot cast from [int] to
    [java.lang.Object]`. A primitive-returning method landed OUTSIDE the parse's null
    guard, where Painless cannot unify it with `null`. Neither boxing the whole
    expression nor `?.` fixes it -- Painless types `cond ? null : X` as `Object` -- so
    the guarded expression is bound to a name and the method applied inside.
  - `DATE_TRUNC(DATE_PARSE(col, fmt), MONTH)`: `member method [java.time.LocalDate,
    truncatedTo/1] not found`. A date-only value has no time to truncate.

## The ingest path (the routed residual)

`ctx.<field>` is the RAW JSON value of the document, so `YEAR`, `MONTH`, `DATE_TRUNC`,
`DATE_ADD`/`DATE_SUB`, `DATE_FORMAT` and `DATE_DIFF` threw, `ignore_failure: true`
swallowed it, and the computed column was silently ABSENT.

  - the shape is decided at RUNTIME (`instanceof`), per the lead's ruling: Elasticsearch
    accepts BOTH an ISO string and epoch millis into a `date` field, so neither guess
    was right. The previous code hard-coded `from = SQLTypes.BigInt`, untested, and was
    wrong for every documented example;
  - the ingest CLOCK was `ctx['_ingest']['timestamp']`, which is NULL on ES 6.8.23,
    7.17.29, 8.18.3 AND 9.0.3 -- verified on REAL indices, not `_simulate`. So
    `CURRENT_DATE`/`CURRENT_TIMESTAMP`/`NOW`/`TODAY` in `SCRIPT AS` had never worked on
    any version. Not a version split: naming `metadata()` fails COMPILATION on ES 6/7
    even in a branch never taken. `System.currentTimeMillis()` is whitelisted on all
    four and is already the unit the surrounding `Instant.ofEpochMilli(...)` expects;
  - a processor collapses to ONE temporal type, `ZonedDateTime`: `ChronoUnit.between`
    refuses mixed types and Elasticsearch refuses a `LocalDate` in `ctx` at all. Same
    collapse `SQLTypeUtils.runtimeType` already applies to a query.

The published `DATE_DIFF(birthdate, CURRENT_DATE, YEAR)` now stores `age: 36` for
`{"birthdate":"1990-05-20"}` and `{"birthdate":643161600000}` on all four majors.

## A bridge fixture was pinning a script Elasticsearch REJECTS

`SQLQuerySpec`'s `datetime_parse` expectation returns `class_cast_exception` on
ES 8.18.3. Nothing ever executed it. Updated in both bridge copies, and the same
statement is now a unit test whose emission was executed.

## Release notes

  - `DATE_PARSE`/`DATETIME_PARSE`/`DATE_FORMAT`/`DATETIME_FORMAT` produce valid Painless
    in the context-free rendering; `DATE_FORMAT`/`DATETIME_FORMAT` over a STRING operand
    and `YEAR`/`MONTH`/`DATE_TRUNC` over a parse result now execute instead of failing
    the shard.
  - ⚠️ Date and time functions in a computed column now WORK. A table created on an
    earlier version keeps its old pipeline: re-run its `CREATE TABLE` (or `ALTER TABLE
    ... ALTER COLUMN ... SET SCRIPT AS`) and reindex documents whose computed column is
    absent. `documentation/sql/ddl_statements.md` carried a limitation note that is now
    false; it is replaced.
  - ⚠️ `CURRENT_DATE` in an INGEST script is a `ZonedDateTime`, not a `LocalDate`. A
    query is unaffected.
  - Unchanged: a document missing the source field still leaves the computed column
    absent.
  - Downstream repos pinning generated Painless need updating for the date family.

## Verification

sql 993 / core 944 / bridge 197 / es6bridge 197 / macrosTests 21, both Scala legs.
Six mutations, six predicted REDs, restore byte-identical -- and a seventh whose
mutation did not apply was re-run rather than counted as green.
Emissions executed on real Elasticsearch 6.8.23, 7.17.29, 8.18.3 and 9.0.3: script
fields for the query path, ingest pipelines for the DDL path, both document shapes.

Records: docs/issues/local-21.8-date-parse-emits-malformed-painless.md ·
docs/issues/local-21.8-ingest-temporal-function-unusable.md (both closed, local-only).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fupelaqu
fupelaqu marked this pull request as ready for review September 8, 2026 19:03
fupelaqu and others added 2 commits September 8, 2026 21:16
⚠️ Caught by CI, not locally, and the reason is worth recording: `sbt scalafmtAll`
followed by `scalafmtCheckAll` BOTH passed in the worktree the files were written in,
while `sql/Test/scalafmtCheck` on a fresh checkout reported them unformatted. scalafmt's
per-project cache agreed with the run that populated it. A fresh worktree reproduces
what CI sees; the same worktree does not.

Line wrapping only -- no assertion, expectation or emission changes. `sql` 993/993 green
after the reformat, and `headerCheck scalafmtSbtCheck scalafmtCheck test:scalafmtCheck`
(the exact CI lint command) is clean on a fresh checkout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI's integration leg failed on all five clients, and it was the right kind of failure:
`GatewayApiIntegrationSpec`'s "record what an ingest script sees for a DATE column"
asserted `Option(days) shouldBe None` — it was written to RECORD that the computed
column did not compute, with the note that "a change in either direction is loud".
Part C changed it, so it was loud. That is the test earning its keep, not a regression.

The half of its reading that was right is kept: `ctx.d` IS the raw JSON value and not
the temporal object a query sees, which is still why `SQLTypeUtils.coerce` guards its
temporal arms on `isProcessorContext`. The conclusion drawn from it was the wrong half —
that `DATEDIFF(d, CURRENT_DATE, DAY)` therefore CANNOT compute at ingest. It can, once
the operand is parsed first.

⚠️ The expected value is COMPUTED, not pinned: `days` is a distance from `now`, so a
literal would have been correct for exactly one day. It is derived the way the ingest
script derives it, with ±1 tolerance for an ingest and an assertion that straddle UTC
midnight.

Verified on real Elasticsearch 8.18 via the Docker testkit: the named test passes, and
the whole `JavaClientGatewayApiSpec` suite is 73/73. One testkit edit covers all five
clients (`copyTestkit`).

⚠️ The two `ctx['_ingest']['timestamp']` strings elsewhere in this file are deliberately
untouched: they are hand-written INPUT SQL for `CREATE OR REPLACE PIPELINE`, not engine
output, and a supplied processor source is kept as written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fupelaqu
fupelaqu merged commit 011bc91 into main Sep 9, 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.

1 participant