Skip to content

fix(query): resolve named ES date formats for ignore_malformed validation - #89

Merged
xerj-org merged 3 commits into
xerj-org:mainfrom
Vinz2168:fix/date-format-ignore-malformed
Aug 1, 2026
Merged

fix(query): resolve named ES date formats for ignore_malformed validation#89
xerj-org merged 3 commits into
xerj-org:mainfrom
Vinz2168:fix/date-format-ignore-malformed

Conversation

@Vinz2168

Copy link
Copy Markdown
Collaborator

Summary

is_date_value_valid_with_format — the ingest-time check that decides whether a date/date_nanos field gets silently dropped under ignore_malformed — had two bugs that happened to compensate for each other on OpenSearch's UBI sample dashboards, which is why the breakage was easy to miss:

  • Named ES built-in formats weren't resolved. strict_date_time, date_time, etc. are named shorthands ES expands internally to a real pattern (strict_date_timeyyyy-MM-dd'T'HH:mm:ss.SSSXX). The old code matched the format string as literal text via ad-hoc chrono-strftime substitution, never expanding named formats first — so a value that's genuinely valid under the format (e.g. "2024-01-01T00:00:00.000Z" under strict_date_time) could never match and was silently dropped every time.
  • A JSON number was accepted as valid for any date format, including non-epoch ones — so a bare epoch-millis integer was wrongly accepted even under a format with no epoch_millis/epoch_second in its list.

Confirmed live against a real OpenSearch 2.11.1 node: recreated opensearch_dashboards_sample_ubi_events's exact mapping ({"type":"date","format":"strict_date_time","ignore_malformed":true}) and reindexed its exact documents. Real OpenSearch does the opposite of what xerj was doing — it accepts the ISO strings and rejects the bare numbers. Verified the fix reproduces this exactly with a clean minimal repro (scalar ISO string, array-wrapped ISO string, bare epoch-millis number).

Fix

Delegates to xerj_query::dates::compile_formats + a new date_value_matches_formats, reusing the same format compiler/parser already used (and already tested) for range-query date bounds, instead of maintaining a second, incomplete parallel implementation. Removes the now-dead es_date_format_to_strftime.

Test plan

  • cargo fmt --check
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo test -p xerj-query -p xerj-api --lib (235 passed, including 6 new dates.rs tests and 4 new es_compat.rs tests — one of which is an end-to-end reproduction of the exact UBI mapping)
  • Live repro against a real OpenSearch 2.11.1 node (identical mapping + documents) confirming xerj's fixed behavior now matches
  • Live repro directly against the built binary via HTTP (scalar/array ISO string accepted, bare epoch-millis number correctly ignored)

🤖 Generated with Claude Code

https://claude.ai/code/session_014PRCbyt7Y2HDyhG1tbQTeL

@xerj-org

xerj-org commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Reviewed for inclusion in v1.0.0-rc.9. Holding it — the named-format resolution is a real improvement, but as written it silently drops documents that are currently indexed, and unknown formats fail open.

Every finding was sent to an independent verifier told to refute it; these survived.

Blocker — numeric values under numeric-shaped date formats are now silently dropped

The new Value::Number arm (dates.rs:183-188) accepts a JSON number only when some compiled format is EpochMillis or EpochSecond. The old code returned true for any Value::Number under any non-epoch format.

ES does neither. DateFieldMapper reads the value with parser().text(), which stringifies a VALUE_NUMBER token, then parses that text with the declared formatter. So under basic_date (yyyyMMdd), the JSON number 20240101 renders as "20240101" and parses cleanly.

Verified by running both implementations over the same matrix: basic_date + 20240101 is accepted before this PR, rejected after. With ignore_malformed: true that value is now dropped from _source and recorded in _ignored instead of being indexed — silently, on documents that indexed fine yesterday. The same applies to any numeric-shaped pattern (basic_date, basic_date_time_no_millis, yyyyMMddHHmmss, …).

High — unknown named formats fail OPEN and accept arbitrary garbage

compile_one_format handles ~20 named formats and falls through to other => other, compiling the name itself as a Java pattern. Many real ES format names contain letters that aren't in VALID_JAVA_PATTERN_LETTERSb, o, t, i, r — so they return Err(UnknownPatternLetter), and is_date_value_valid_with_format swallows that into true (es_compat.rs:26741-26744).

Measured, all of these currently accept literally any value: basic_time, basic_ordinal_date, basic_week_date (b), ordinal_date, hour_minute (o), time, week_date (t).

Failing open is the wrong default here: an unsupported format name should be a mapping-time error, or at minimum should fall back to the previous permissive-but-narrow behaviour rather than disabling validation entirely for that field.

Also confirmed

  • The validator now contradicts the ignore_malformed: false rejection path — the stricter setting ends up more permissive than the lenient one, which is backwards.
  • The format string is recompiled per value on the ingest hot path, amplifying an unvalidated user-supplied input by roughly 4× on a hot path — worth a compiled-format cache keyed on the mapping.
  • Leading/trailing whitespace is now silently trimmed, accepting values ES treats as malformed.
  • epoch_second overflow checking depends on JSON spelling — the number and the string form of the same value disagree.
  • null in a date field no longer records _ignored, a behaviour change on existing indices.

What would make this shippable

Stringify numbers and run them through the declared formatter the way DateFieldMapper does, rather than gating on epoch formats; make an unresolvable format name fail closed (or error at mapping time); and add fixtures for the numeric-under-basic_date case and for at least one b/o/t named format, since neither is covered today.

The direction is right — resolving named formats properly is exactly what this needed, and the dates.rs extraction is a good place for it. It's the two failure directions that need inverting.

@Vinz2168

Vinz2168 commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks — the blocker's mechanism (DateFieldMapper stringifying numbers via text()) checked out.

  • Numeric-shaped format blocker: replaced the epoch-only Value::Number branch with n.to_string() run through the exact same per-format parser a String value uses. basic_date + 20240101 (and any other numeric-shaped pattern) is now accepted; strict_date_time + a bare epoch-millis number is still correctly rejected (its stringified form doesn't match the structured yyyy-MM-dd'T'... pattern, same as before, just via the unified path now).
  • Fail-open on unresolvable formats: is_date_value_valid_with_format now returns false on a compile_formats error instead of true. Went with "fail closed at validation time" rather than a mapping-time error, per your "at minimum" — a format this engine can't resolve now makes the field's values ignored (visible in _ignored) rather than silently unvalidated.
  • Whitespace trimming: removed — my fix had added a .trim() on the string path that ES doesn't do; a padded value that only matches after trimming is now correctly rejected.
  • null handling: checked this deliberately rather than changing it — ES treats JSON null as "no value" uniformly across field types (not malformed), so null continuing to always match (and not appear in _ignored) is correct, not a regression.

Not addressed (agreed, tracking separately): the compiled format isn't cached per mapping (still recompiled per value), and ignore_malformed:false's hard-reject path in bulk.rs uses a different, more lenient check than this one — so the stricter setting can currently be more permissive than the lenient one for the same value. That inconsistency needs the two paths unified, which felt like a separate change from this fix.

Full suite green: cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test -p xerj-engine -p xerj-query -p xerj-api --lib (only the 2 pre-existing, unrelated snapshot_path_security_tests failures).

🤖 Generated with Claude Code

@xerj-org

xerj-org commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Housekeeping, not a review comment.

We've just added a Contributor License Agreement to the project (#93). Until now contributions here were covered only by Apache-2.0 §5's inbound=outbound clause, which gives no explicit patent grant — worth tightening for a project this size.

Once #93 merges, a cla-bot check will appear on this PR and it will be red until you're covered. Signing is one small pull request, once per contributor — not per PR:

  1. Read CLA.md
  2. Open a PR adding your GitHub username to .contributors
  3. Comment @cla-bot check back here and it turns green

That PR is the signature — it comes from your own account, so the commit history is the record.

To be explicit about something: we did not add anyone to the signed list on their behalf, including you. That file asserts a person has signed, and that's not ours to assert for someone else — hence the ask rather than a quiet edit.

Sorry for the extra step on work that's already in flight. Thanks for the contributions.

Vincenzo Lombardo and others added 3 commits August 1, 2026 22:16
…tion

`is_date_value_valid_with_format` (the ingest-time check that decides
whether a date field gets silently dropped under `ignore_malformed`)
had two bugs that happened to compensate for each other on OpenSearch's
UBI sample dashboards, making the breakage easy to miss:

- Named ES built-in formats (`strict_date_time`, `date_time`, ...) were
  matched as literal text instead of being resolved to their real
  pattern first, so a value that's actually valid under the format
  (e.g. "2024-01-01T00:00:00.000Z" under `strict_date_time`) could
  never match — silently dropped every time.
- A JSON number was accepted as a valid date for ANY format, including
  non-epoch ones, so a bare epoch-millis integer was wrongly accepted
  under a format with no `epoch_millis`/`epoch_second` in its list.

Confirmed live: recreated `opensearch_dashboards_sample_ubi_events`'s
exact mapping and reindexed its exact documents into a real OpenSearch
2.11.1 node — real OpenSearch does the opposite of what xerj was
doing (accepts the ISO strings, rejects the bare numbers).

Fix: delegate to `xerj_query::dates::compile_formats` +
`date_value_matches_formats` (new), reusing the same format
compiler/parser already used for range-query date bounds instead of
maintaining a second, incomplete parallel implementation. Removes the
now-dead `es_date_format_to_strftime`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014PRCbyt7Y2HDyhG1tbQTeL
…rmats

Addresses maintainer review findings on the date-format-resolution PR —
verified the blocker's exact repro before fixing:

- Numeric values under numeric-shaped (non-epoch) date formats were
  silently dropped. The previous fix accepted a JSON number only when
  some compiled format was EpochMillis/EpochSecond, but real ES's
  DateFieldMapper reads the value via the parser's text(), which
  stringifies a numeric token before handing it to the declared
  formatter — so `basic_date` (`yyyyMMdd`) + the number 20240101
  stringifies to "20240101" and parses cleanly. Fixed by stringifying
  any Number and running it through the exact same per-format parse
  path a String value uses, instead of a separate epoch-only branch.

- Unknown/unresolvable named formats (`basic_time`, `ordinal_date`,
  `hour_minute`, `week_date`, ...) failed OPEN: compile_one_format
  falls through to compiling the format *name itself* as a Java
  pattern, several of those names contain letters outside
  VALID_JAVA_PATTERN_LETTERS, compilation errors, and
  is_date_value_valid_with_format swallowed that error into "valid" —
  disabling validation entirely for that field. Now fails closed: a
  format this engine can't resolve makes every value for that field
  ignored under ignore_malformed, visible via `_ignored`, rather than
  silently accepting anything.

- Removed a leniency this fix's own value-string path had introduced:
  trimming leading/trailing whitespace before matching. ES hands the
  value to the formatter as-is; a padded value that would only match
  after trimming must be rejected too.

null continues to always match (ES treats it as "no value" uniformly
across field types, not malformed input) — confirmed this is correct
ES behavior, not a regression, so left as-is.

Not addressed here (agreed, lower severity, tracking as follow-ups):
the compiled format is still re-parsed from the mapping's format
string on every value rather than cached per field, and
ignore_malformed:false's separate hard-reject path (bulk.rs) uses a
different, more lenient check than this one — meaning the stricter
setting can currently be more permissive than the lenient one for the
same value, which is backwards and needs unifying.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014PRCbyt7Y2HDyhG1tbQTeL
…heir data

PR xerj-org#89 made unresolvable date formats fail closed. Right instinct, wrong
set: 59 named formats ES actually ships (29 distinct names, plus their
strict_ twins) were unresolvable, because they are not java.time patterns
and nothing mapped them to one — `basic_time` trips on `b`, `ordinal_date`
on `o`, `time` on `t`. Under `ignore_malformed: true` a field declaring one
of them had every value dropped from _source; under `false` the whole
document would have been rejected. Nine number-shaped values that main
indexed were measurably lost that way (`basic_ordinal_date` 2024001,
`hour` 12, `weekyear` 2024, and their strict_ spellings).

Implements the formats rather than widening the fail-open hole:

* The full ES name -> pattern table, `strict_` prefix stripped so both
  spellings share one entry. Fail-closed now applies only to strings ES
  itself rejects (`banana` -> `Unknown pattern letter: b`).
* Day-of-year (`D`) and the ISO week date (`x`/`w`/`e`), so the ordinal and
  week formats resolve to real calendar dates — week 53 exists in 2020 and
  not in 2024, day 366 in 2024 and not in 2023.
* `[...]` optional sections, confined to the built-in patterns. ES builds
  `strict_date_time`'s fraction with optionalStart(), so
  `2021-05-01T07:10:00Z` is valid; requiring `.SSS` would have rejected the
  aggregations/range.yml conformance corpus.
* Locale-dependent `MMM`/`E` text is matched as opaque words when it isn't
  English. ES honours the mapping's `locale`, so
  `mer., 6 déc. 2000 02:55:00 -0800` is real data. Everything around the
  word is still validated. This is deliberately not fail-closed: there we
  know nothing about the value, here we have checked all of it but which
  language a word is in.

Also closes the two open findings:

* The `ignore_malformed: false` path had its own validator ending in a
  blanket `true` for named and custom patterns, so the strict setting
  accepted strictly MORE than the lenient one. Both now call one shared
  predicate, `date_value_valid_with_format`.
* The format was recompiled once per value on the ingest hot path. It is a
  property of the mapping, not the value, so it is memoised on the format
  string behind an Arc, capped at 1024 entries.

Truth table over every named format: a string ES accepts, a JSON number
with ES's stringify-then-parse verdict, and garbage — plus the ES-compat
YAML corpus's own (format, value) pairs and the exact malformed/valid calls
ignored_metadata_field.yml asserts.
@xerj-org
xerj-org force-pushed the fix/date-format-ignore-malformed branch from 094c05e to 0be2e8a Compare August 1, 2026 21:28
@xerj-org

xerj-org commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Rebased onto current main and folded the review follow-up into the branch, so this now merges as one complete unit rather than landing a known regression and fixing it afterwards.

The branch is now three commits:

  1. resolve named ES date formats for ignore_malformed validation (yours)
  2. stringify numbers and fail closed on unresolvable date formats (yours, addressing the first review round)
  3. implement the real ES named formats instead of dropping their data

Commit 3 exists because "fail closed on unresolvable formats" from round 2 was the right call for safety but had a cost worth removing: roughly seven named formats this engine could not resolve (basic_time, basic_ordinal_date, basic_week_date, ordinal_date, hour_minute, time, week_date) went from accepting anything to accepting nothing, which silently drops values on fields that indexed fine before. Implementing the formats properly removes the choice between failing open and dropping data.

Verified after the rebase: cargo build --release -p xerj-query -p xerj-api, then cargo test --release -p xerj-query -p xerj-api284 passed, 0 failed.

Thanks for working through two rounds of review on this one. The named-format resolution was the right direction and the dates.rs extraction is what made commit 3 straightforward.

@xerj-org
xerj-org merged commit 573e32a into xerj-org:main Aug 1, 2026
9 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.

2 participants