Skip to content

Wrong results with no error: ES 8 answers 0.0 for MIN / MAX / AVG of a column a whole group lacks, CREATE TABLE … AS SELECT … GROUP BY stores one empty document per source document, and values come back in the class Elasticsearch's JSON gives them (a date per group as epoch milliseconds) #414

Open

Description

@fupelaqu

Three defects return HTTP 200 with wrong data: item 1 on Elasticsearch 8 only, items 2 and 4 on every build. Item 3 is a loud failure of the same CREATE TABLE … AS SELECT path.

Measured on main (f63222d) and on #412's head (0687815), through the gateway. PostgreSQL 16.14 ran on the same rows.

Fixture

CREATE TABLE t (id KEYWORD, g KEYWORD, n INT, x DOUBLE, d DATE, d2 DATE, …, PRIMARY KEY (id)). "—" means the field is absent from the document; PostgreSQL stores it as NULL.

id g n x d d2
r1 g1 3 0.25 2024-01-31 2024-02-01
r2 g1 5 1.5 2024-03-01 2024-02-28
r3 g2 -2 — 2023-12-25 —
r4 g3 — 2.0 — 2024-01-31
r5 g3 10 -1.25 2024-02-29 2024-02-29

Group g2 has one document, and neither x nor d2 appears in it.

1. ES 8 reports 0.0 for MIN / MAX / AVG over a group whose documents all lack the column

statement ES 8.18.3 (main and #412) ES 7.17.29 and 6.8.23 (main and #412) PostgreSQL 16
SELECT g, MIN(x) AS mn, MAX(x) AS mx, AVG(x) AS av FROM t GROUP BY g, group g2 0.0, 0.0, 0.0 NULL, NULL, NULL NULL, NULL, NULL
SELECT g, MIN(d2) AS mn, MAX(d2) AS mx FROM t GROUP BY g, group g2 0.0, 0.0 (epoch: 1970-01-01) NULL, NULL NULL, NULL
SELECT MIN(x) AS mn, MAX(x) AS mx, AVG(x) AS av FROM t WHERE g = 'g2' 0.0, 0.0, 0.0 NULL, NULL, NULL NULL, NULL, NULL
SELECT MIN(x) AS mn, MAX(x) AS mx, AVG(x) AS av FROM t WHERE g = 'zz' (no document) NULL, NULL, NULL NULL, NULL, NULL NULL, NULL, NULL

Groups g1 and g3 are right on every major (0.25 / 1.5 / 0.875 and -1.25 / 2.0 / 0.375). COUNT(x) is 0 for g2, as it should be. SUM(x) is 0.0 on every major, which #336 kept deliberately.

This is the residual of #336. That fix recovers NULL when the input has zero documents: docCount.contains(0L) && … nullOverEmptyInput (core/…/client/ElasticConversion.scala:1129–1130 on #412's head). Group g2 has one document, so the ES 8 module's typed response, where Elasticsearch's {"value": null} has already become 0.0, goes through unchanged. ES 7.17 and 6.8 read the JSON, where the value is null.

The wrong value then flows into everything that reads it. A 0.0 minimum compares as a number, and MIN(d2) reads as 1970-01-01.

  • Expected: NULL, on every major.
  • Fix direction: for MIN / MAX / AVG, take NULL from the field's own value count (for example a hidden value_count beside the metric), or read the raw value on ES 8. The bucket's document count is not enough.

2. CREATE TABLE … AS SELECT … GROUP BY stores one empty document per source document

statement main = #412 (ES 8.18.3) PostgreSQL 16
CREATE TABLE t2 AS SELECT g, MAX(n) AS m FROM t GROUP BY g DmlResult(inserted = 5) SELECT 3
SELECT COUNT(*) FROM t2 5 3
SELECT * FROM t2 {}, {}, {}, {}, {}: no g, no m (g1, 5), (g2, -2), (g3, 10)
DESCRIBE TABLE t2 g KEYWORD, m INT g text, m integer
the SELECT alone g1 5, g2 -2, g3 10 same

The table and its declared columns are right, and the SELECT alone is right. What is stored is one document per source document, each without either column. The population goes through INSERT … AS SELECT (GatewayApi.scala:1207–1212), which reads its source with scroll(single) (IndicesApi.scala:1638).

  • Expected: one document per group, holding g and m.
  • Fix direction: populate a grouped SELECT from its aggregation results (the rows the SELECT returns), not from a scroll of the source documents. Otherwise refuse it by name until then.

3. CREATE TABLE … AS SELECT of a DATEDIFF fails at index creation

statement main = #412 (ES 8.18.3) PostgreSQL 16 (d - d2)
CREATE TABLE t3 AS SELECT id, DATEDIFF(d, d2) AS x FROM t 400 mapper_parsing_exception: Failed to parse mapping: Field [scaling_factor] is required, and no table is created creates x integer: -1, 2, NULL, NULL, 0
SELECT id, DATEDIFF(d, d2) AS x FROM t -1, 2, NULL, NULL, 0 same

DATEDIFF reports NUMERIC, and SQLTypeUtils.elasticType maps NUMERIC to scaled_float without a scaling_factor (sql/…/type/SQLTypeUtils.scala:72 on #412, :71 on main). Any other expression reported NUMERIC reaches the same mapping (not executed here).

  • Expected: the table is created with an integer column.
  • Fix direction: report DATEDIFF / DATE_DIFF / TIMESTAMPDIFF as BIGINT, and give NUMERIC a mapping Elasticsearch accepts (double, or scaled_float with a factor).

4. A value comes back in the class Elasticsearch's JSON gives it: a date per group as epoch milliseconds, a BIGINT as an Integer

statement main #412 PostgreSQL 16
SELECT g, MIN(d2) AS mn, MAX(d2) AS mx FROM t GROUP BY g g1 1.7067456E12, 1.7090784E12; g3 1.7066592E12, 1.7091648E12 (Doubles) same g1 2024-02-01, 2024-02-28; g3 2024-01-31, 2024-02-29
SELECT g, MAX(d) + 1 AS v FROM t GROUP BY g fails in Elasticsearch on every major (ES 8: runtime dynamic method [java.lang.Double, toInstant/0] not found) 1.7093376E12, 1.7035488E12, 1.7092512E12 (Doubles) 2024-03-02, 2023-12-26, 2024-03-01

The numbers are the right instants (1.7093376E12 ms is 2024-03-02), on Elasticsearch 8.18.3, 7.17.29 and 6.8.23 alike. A row SELECT d FROM t answers dates, and a client that reads the column as a DATE gets a Double. #412 makes per-group date arithmetic run (MAX(d) + 1 is a DATE, as PostgreSQL's date + integer), so its result inherits the same representation. Group g2 is item 1.

The same mechanism gives a BIGINT that fits 32 bits back as an Integer: SELECT id, CAST(n AS BIGINT) AS v FROM t answers Integers 3, 5, -2, NULL, 10 on #412 (Elasticsearch 8.18.3), where a client reading a BIGINT column expects Longs (PostgreSQL: bigint).

  • Expected: a date (a timestamp for a TIMESTAMP column) and a Long for a BIGINT: the type the column or the calculation is reported as.
  • Fix direction: when a row is built, convert each value to its reported type: a metric or a per-group calculation reported as DATE / TIMESTAMP from epoch milliseconds (Elasticsearch's value_as_string covers MIN / MAX, but not a bucket_script), and a whole number reported as BIGINT to a Long.

Evidence level

RUN through the gateway (items 1 and 4 on ES 8.18.3, 7.17.29 and 6.8.23; items 2 and 3 on ES 8.18.3), on both builds. PostgreSQL 16.14 answers measured. Code locations come from reading #412's head.

Activity

  1. fupelaqu commented on Oct 6, 2026

    @fupelaqu
    ContributorAuthor

    A per-group GREATEST / LEAST answers NULL when one of its aggregates is empty; DuckDB and PostgreSQL skip the NULL

    Another wrong result with no error, in the family of #414 (item 1 has the same fixture shape: a column a whole group lacks).

    Measured on core 0.24.0-SNAPSHOT (core main 7fe1d66, #412 merged), through the gateway, on Elasticsearch 8.18.3 and 7.17.29. DuckDB 1.5.5 and PostgreSQL 16.14 ran on the same rows.

    Fixture

    CREATE TABLE zi_gl (id INT, station_id INT, dt DATE, dt2 DATE, n INT, n2 INT, PRIMARY KEY (id)). "—" means the field is absent from the document; DuckDB and PostgreSQL store it as NULL.

    id station_id dt dt2 n n2
    1 1 2024-01-11 — 5 —
    2 2 2024-02-05 2024-02-03 3 7
    3 3 — — — —

    Station 1 has no dt2 and no n2.

    Measured

    statement station ES 8.18.3 ES 7.17.29 DuckDB 1.5.5 PostgreSQL 16
    SELECT station_id, MAX(dt) AS a, MAX(dt2) AS b, GREATEST(MAX(dt), MAX(dt2)) AS g, LEAST(MAX(dt), MAX(dt2)) AS l FROM zi_gl GROUP BY station_id 1 g, l: NULL, NULL NULL, NULL 2024-01-11, 2024-01-11 2024-01-11, 2024-01-11
    same 2 2024-02-05, 2024-02-03 (as epoch milliseconds, #414 item 4) same 2024-02-05, 2024-02-03 same
    SELECT station_id, MAX(n) AS a, MAX(n2) AS b, GREATEST(MAX(n), MAX(n2)) AS g, LEAST(MAX(n), MAX(n2)) AS l FROM zi_gl GROUP BY station_id 1 NULL, NULL NULL, NULL 5, 5 5, 5
    same 2 7, 3 7, 3 7, 3 7, 3
    both statements 3 NULL, NULL NULL, NULL NULL, NULL NULL, NULL
    row level: SELECT id, GREATEST(dt, dt2) AS g, LEAST(dt, dt2) AS l, GREATEST(n, n2) AS gn, LEAST(n, n2) AS ln FROM zi_gl id 1 2024-01-11, 2024-01-11, 5, 5 same same same

    The row-level form is right; only the per-group form loses the value. On ES 8, b (the empty MAX) reads 0.0 for station 1 (#414 item 1) while g reads NULL. On ES 7, b is NULL.

    A materialized view gives the same answer. With SOFTNETWORK-APP/softclient4es-extensions#72, this view stores no g and no l for one_null, and 7 / 3 (or 2024-02-05 / 2024-02-03 for the date form) for both, on Elasticsearch 8.18.3 and 7.17.29:

    CREATE OR REPLACE MATERIALIZED VIEW v REFRESH EVERY 8 SECONDS WITH (delay = '1s', user_latency = '1s') AS
    SELECT s.label, MAX(m.n) AS a, MAX(m.n2) AS b, GREATEST(MAX(m.n), MAX(m.n2)) AS g, LEAST(MAX(m.n), MAX(m.n2)) AS l
    FROM zi_gl AS m JOIN zi_gl_st AS s ON m.station_id = s.id GROUP BY s.label;

    (zi_gl_st: 1 one_null, 2 both, 3 none.) Extensions main does not show the case: its views store no per-group calculation at all.

    Cause

    Core sends the per-group call as a bucket_script with no NULL handling and no gap_policy. From the request it sent on Elasticsearch 8.18.3:

    • numbers: "g": {"bucket_script": {"buckets_path": {"a": "a", "b": "b"}, "script": "Math.max(params.a, params.b)"}};
    • dates: "script": "Math.max(Math.floor(params.a / 86400000.0) * 86400000.0, Math.floor(params.b / 86400000.0) * 86400000.0)".

    Elasticsearch's default gap policy, skip, skips the script for a bucket where one of its buckets_path values is missing. So the cell is absent, and reads NULL. The row-level form follows the rule GREATEST / LEAST were built with: a NULL argument is skipped, and the result is NULL only when every argument is NULL.

    • Expected: 2024-01-11 and 5 for station 1, as DuckDB and PostgreSQL answer; NULL only for station 3.
    • Fix direction: let the per-group script see an empty operand instead of skipping the bucket (a gap policy that keeps the bucket, or the operands' value counts), and drop NULL operands in the script as the row-level form does.

    Evidence level

    The SOFTNETWORK-APP/softclient4es-extensions#72 runs were taken before its final revision, which does not touch these paths. RUN through the gateway on Elasticsearch 8.18.3 and 7.17.29 (core's search, and the view with and without SOFTNETWORK-APP/softclient4es-extensions#72). DuckDB 1.5.5 and PostgreSQL 16.14 answers measured. The bucket_script bodies are the requests core logged.

  2. fupelaqu commented on Oct 7, 2026

    @fupelaqu
    ContributorAuthor

    In arrow's set operations

    Item 4 (a per-group date comes back as epoch milliseconds) also breaks arrow's set operations. SELECT MIN(d) AS k FROM t UNION SELECT ts AS k FROM t fails in arrow with Conversion Error: Unimplemented type for cast (DOUBLE -> TIMESTAMP_MS), because the MIN(d) leg reaches DuckDB as a DOUBLE. DuckDB 1.5.5 answers TIMESTAMP over the same data.

    Measured through softclient4es-arrow main (264eac3) with core 0.24.0-SNAPSHOT (#412), on Elasticsearch 8.18.3.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions