fix(search): index Postgres dates with negative UTC offsets correctly - #496
Merged
Conversation
This was referenced Aug 4, 2026
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. |
This was referenced Aug 4, 2026
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
aacruzgon
force-pushed
the
fix/494-postgres-date-negative-offset
branch
from
August 5, 2026 02:11
008ad28 to
e99d0b3
Compare
aacruzgon
force-pushed
the
fix/494-postgres-date-negative-offset
branch
from
August 5, 2026 17:41
e99d0b3 to
6abaeda
Compare
aacruzgon
force-pushed
the
fix/494-postgres-date-negative-offset
branch
from
August 6, 2026 17:56
6abaeda to
6813d9c
Compare
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
force-pushed
the
fix/494-postgres-date-negative-offset
branch
from
August 7, 2026 14:48
6813d9c to
7bbb3a5
Compare
Base automatically changed from
fix/490-postgres-bare-id-reference-search
to
main
August 7, 2026 19:51
smunini
approved these changes
Aug 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
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 asUtc::now(). The resource itself round-tripped correctly, so the corruption was visible only by queryingsearch_indexdirectly: every date search over such a row was answered against the ingestion timestamp, sodate=gt<any past date>matched it anddate=lt…did not.FHIR
dateTime/instantrequire 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:2019-05-04T12:12:29-07:00matches none of those, so it was treated as zone-less and+00:00was appended, producing...-07:00+00:00— not valid RFC3339. Only-00:00was ever recognised among negative offsets.2. The parse failure silently substituted the current time:
A malformed value became a plausible-looking timestamp, with nothing logged.
Changes
normalize_date_for_pgnow tests the time component alone for a zone. AfterT, the FHIR grammar admits+/-only as the offset sign, so their presence is decisive — whereas testing the whole value would match the date's ownYYYY-MM-DDseparators. Zone-less and partial values (2024,2024-01,2024-01-15,…T10:30:00) still complete to UTC exactly as before.write_entryandwrite_contained_entry) share a newparse_index_datehelper returningOption<DateTime<Utc>>, and skip the index row with atracing::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:
Encounter.dateObservation.dateDocumentReference.periodProcedure.dateCarePlan.dateCareTeam.dateDiagnosticReport.dateCondition.recorded-dateCondition.onset-dateImmunization.dateCondition.abatement-dateMedicationRequest.authoredonThe 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
normalize_date_for_pg/parse_index_date: negative offsets recognised as zoned, the resulting instant correct (12:12:29-07:00→19:12:29Z, so a fix that merely stripped the offset would fail), positive offsets andZunaffected, partial dates unaffected, and unparseable values yieldingNonerather than a substituted timestamp.postgres_integration_search_date_negative_utc_offsetagainst a real Postgres:gt/ltland on the correct side of a 2019 vs 2025 pair, plus a one-hour window bracketing19:12:29Zto pin the offset arithmetic. The existingpostgres_integration_search_dateuses a date-onlybirthDate— 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.left: ["obs-2019", "obs-2025"] right: ["obs-2025"].End to end
Clean
postgres:16, fixtures reloaded, index inspected directly:Observation.daterows carrying an ingest timestamp2026-08-04 22:11:53+002019-05-04 19:12:29+00No rows were dropped — all 251 still index, now with correct values.
Inferno
us_core_v800, same fixtures, same local Inferno stack: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_TESTSfor v8.0.0.Notes