Skip to content

fix(search): index Postgres dates with negative UTC offsets correctly - #496

Merged
smunini merged 1 commit into
mainfrom
fix/494-postgres-date-negative-offset
Aug 7, 2026
Merged

fix(search): index Postgres dates with negative UTC offsets correctly#496
smunini merged 1 commit into
mainfrom
fix/494-postgres-date-negative-offset

Conversation

@aacruzgon

@aacruzgon aacruzgon commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Stacked on #495 (fix/490-postgres-bare-id-reference-search). Review that one first; this PR's diff is only the date fix. #495 must merge before this can.

Summary

A date value carrying a negative UTC offset2019-05-04T12:12:29-07:00 — failed to parse in the Postgres search index writer and was silently stored as Utc::now(). The resource itself round-tripped correctly, so the corruption was visible only by querying search_index directly: every date search over such a row was answered against the ingestion timestamp, so date=gt<any past date> matched it and date=lt… did not.

FHIR dateTime/instant require an offset when a time is present, and US/Americas data is overwhelmingly negative-offset, so this was the common case rather than an edge case.

Fixes #494.

Why it happened

Two defects compounded, both in postgres/search/writer.rs.

1. The zone check missed negative offsets. It tested the whole value for +, Z, or a trailing -00:00:

if value.contains('+') || value.contains('Z') || value.ends_with("-00:00")

2019-05-04T12:12:29-07:00 matches none of those, so it was treated as zone-less and +00:00 was appended, producing ...-07:00+00:00 — not valid RFC3339. Only -00:00 was ever recognised among negative offsets.

2. The parse failure silently substituted the current time:

.unwrap_or_else(|_| Utc::now())

A malformed value became a plausible-looking timestamp, with nothing logged.

Changes

  • normalize_date_for_pg now tests the time component alone for a zone. After T, the FHIR grammar admits +/- only as the offset sign, so their presence is decisive — whereas testing the whole value would match the date's own YYYY-MM-DD separators. Zone-less and partial values (2024, 2024-01, 2024-01-15, …T10:30:00) still complete to UTC exactly as before.
  • Both call sites (write_entry and write_contained_entry) share a new parse_index_date helper returning Option<DateTime<Utc>>, and skip the index row with a tracing::warn! when a value cannot be parsed. A missing row makes the parameter behave as absent for that resource — still a gap, but a silent under-match is recoverable where a silent wrong match is not.

Scope

Not Observation-specific. Mis-indexed rows on the Inferno fixture set before the fix:

resource / param date rows indexed as ingest time
Encounter.date 384 380
Observation.date 251 203
DocumentReference.period 92 92
Procedure.date 82 80
CarePlan.date 48 48
CareTeam.date 48 48
DiagnosticReport.date 48 47
Condition.recorded-date 46 46
Condition.onset-date 46 43
Immunization.date 42 41
Condition.abatement-date 37 37
MedicationRequest.authoredon 36 34

The value shape correlates exactly — on Observation.date: 178/178 negative-offset values mis-indexed, 0/22 date-only, 0/20 positive-offset-or-Z.

Testing

  • 5 unit tests over normalize_date_for_pg / parse_index_date: negative offsets recognised as zoned, the resulting instant correct (12:12:29-07:0019:12:29Z, so a fix that merely stripped the offset would fail), positive offsets and Z unaffected, partial dates unaffected, and unparseable values yielding None rather than a substituted timestamp.
  • postgres_integration_search_date_negative_utc_offset against a real Postgres: gt/lt land on the correct side of a 2019 vs 2025 pair, plus a one-hour window bracketing 19:12:29Z to pin the offset arithmetic. The existing postgres_integration_search_date uses a date-only birthDate — the one shape that always worked, which is how this survived.
  • cargo test -p helios-persistence --features postgres,sqlite,R4 — full suite, 24 test binaries, 0 failures.
  • Regression guard confirmed: with the fix reverted, the new integration test fails with left: ["obs-2019", "obs-2025"] right: ["obs-2025"].

End to end

Clean postgres:16, fixtures reloaded, index inspected directly:

before after
Observation.date rows carrying an ingest timestamp 203 / 251 0 / 251
indexed value for the resource Inferno flagged 2026-08-04 22:11:53+00 2019-05-04 19:12:29+00

No rows were dropped — all 251 still index, now with correct values.

Inferno

us_core_v800, same fixtures, same local Inferno stack:

pass rate
postgres before #490 (2026-08-02 nightly) 4.6% (25/544)
postgres with #495 only 60.2% (328/544)
postgres with #495 + this 60.8% (331/544)
sqlite (same local run) 59.9% (326/544)

This clears the last 3 Postgres-only failures. Postgres and SQLite now have identical failure sets — 10 each, all already in the workflow's OMITTED_TESTS for v8.0.0.

Notes

  • Existing Postgres deployments need a reindex. This fixes the write path; rows already written carry ingest timestamps and will keep answering date searches wrongly until reindexed.
  • No schema or migration change.
  • Once this lands, the concern I raised on fix(search): match bare-id reference searches on Postgres #495 — that the Postgres Inferno job would go red — no longer applies: with the 3 remaining failures cleared, the job's failure set matches SQLite's, which is fully omitted.
  • Observation for a follow-up, not addressed here: Postgres now passes 5 more tests than SQLite (331 vs 326) with an identical failure set, so the difference is in skips (202 vs 207). That suggests SQLite has its own unrelated precondition gap worth a look; SQLite's date handling on the negative-offset shape is correct (verified directly).
  • Clippy not run locally — the workspace has pre-existing failures under clippy 1.96 in files untouched here. Leaving it to CI.

@claude

claude Bot commented Aug 4, 2026

Copy link
Copy Markdown

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.16393% with 6 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...persistence/src/backends/postgres/search/writer.rs 90.16% 6 Missing ⚠️

📢 Thoughts on this report? Let us know!

@aacruzgon
aacruzgon force-pushed the fix/494-postgres-date-negative-offset branch from 008ad28 to e99d0b3 Compare August 5, 2026 02:11
@aacruzgon
aacruzgon requested a review from smunini August 5, 2026 02:13
@aacruzgon
aacruzgon force-pushed the fix/494-postgres-date-negative-offset branch from e99d0b3 to 6abaeda Compare August 5, 2026 17:41
@aacruzgon
aacruzgon force-pushed the fix/494-postgres-date-negative-offset branch from 6abaeda to 6813d9c Compare August 6, 2026 17:56
A date value carrying a negative UTC offset — `2019-05-04T12:12:29-07:00`
— failed to parse in the Postgres search index writer and was silently
stored as `Utc::now()`. The resource itself round-tripped fine, so the
corruption was visible only by querying `search_index` directly: every
date search over such a row was answered against the ingestion time, so
`date=gt<any past date>` matched it and `date=lt…` did not.

Two defects compounded.

`normalize_date_for_pg` decided a value was already zoned by testing the
whole string for `+`, `Z`, or a trailing `-00:00`. That misses every
other negative offset, so `...T12:12:29-07:00` was treated as zone-less
and became `...-07:00+00:00`, which is not valid RFC3339. The zone test
now looks at the time component alone — after `T`, the FHIR
`dateTime`/`instant` grammar admits `+`/`-` only as the offset sign, so
their presence is decisive, while testing the whole value would match the
date's own `YYYY-MM-DD` separators.

The parse failure then fell through `unwrap_or_else(|_| Utc::now())`,
turning a malformed value into a plausible-looking timestamp with nothing
logged. Both call sites now share a `parse_index_date` helper that
returns `None`, and skip the index row with a warning. A missing row
makes the parameter behave as absent for that resource — still a gap, but
a silent under-match is recoverable where a silent wrong match is not.

Scope: not Observation-specific. On the Inferno fixtures it affected
Encounter.date (380/384), Observation.date (203/251),
DocumentReference.period (92/92), CarePlan, CareTeam, Condition
recorded/onset/abatement-date, Procedure, Immunization, DiagnosticReport
and MedicationRequest.authoredon. Existing Postgres deployments need a
reindex to correct already-written rows.

Tests: five unit tests over `normalize_date_for_pg`/`parse_index_date`
covering negative offsets, the resulting instant, positive offsets and
`Z`, partial dates, and that unparseable values yield `None` rather than
a substituted timestamp. Plus a
`postgres_integration_search_date_negative_utc_offset` integration test
asserting `gt`/`lt` land on the right side and that `12:12:29-07:00`
brackets to `19:12:29Z` — a fix that merely stripped the offset would
fail that. The existing date integration test used a date-only
`birthDate`, the one shape that always worked, which is how this
survived. Full persistence suite green.

Verified end to end on a clean database: of 251 Observation `date` rows,
0 now carry an ingest timestamp (was 203), and `us_core_v800` on Postgres
goes 60.2% -> 60.8% (331/544), clearing the last three Postgres-only
Inferno failures. Postgres and SQLite now have identical failure sets.

Fixes #494
@aacruzgon
aacruzgon force-pushed the fix/494-postgres-date-negative-offset branch from 6813d9c to 7bbb3a5 Compare August 7, 2026 14:48
Base automatically changed from fix/490-postgres-bare-id-reference-search to main August 7, 2026 19:51
@smunini
smunini merged commit 7fe5b00 into main Aug 7, 2026
35 checks passed
@smunini
smunini deleted the fix/494-postgres-date-negative-offset branch August 7, 2026 19:53
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.

postgres search: dates with a negative UTC offset fail to parse and are silently indexed as Utc::now() — every date search over them is wrong

2 participants