Skip to content

Bulk Export: _until is accepted but silently ignored by the SQLite, PostgreSQL, and MongoDB backends #657

Description

@smunini

Summary

_until on $export is parsed, validated, and threaded all the way into ExportRequest.until — and then applied by only one backend. SQLite, PostgreSQL, and MongoDB ignore it. A caller passing _until against any of them gets a 202 and an export containing resources modified after the requested upper bound.

Current behavior

GET /Patient/$export?_since=2026-01-01T00:00:00Z&_until=2026-02-01T00:00:00Z
Prefer: respond-async

On a SQLite, PostgreSQL, or MongoDB deployment this is accepted, the _since lower bound is applied, and _until is silently dropped. The manifest contains everything modified from 2026-01-01 to now.

The parameter is not rejected, not warned about, and not reported in the job — it simply has no effect.

Expected behavior

Either the bound is applied — the export contains only resources whose last_updated is at or before _until — or the request is rejected on a backend that cannot honor it. Accepting a filter and ignoring it is the one outcome that should not happen.

Evidence

Parsed and carried. crates/rest/src/handlers/bulk_export.rs:112-120:

// _since / _until
let since = match first_value(&pairs, "_since") { ... };
let until = match first_value(&pairs, "_until") {
    Some(s) => Some(parse_instant(&s)?),
    None => None,
};

Reaches ExportRequest.until at bulk_export.rs:251, declared "Only include resources modified at or before this time (_until)" at crates/persistence/src/core/bulk_export.rs:354-356.

Applied by S3 only. request.until occurs in exactly one backend — crates/persistence/src/backends/s3/bulk_export.rs:83, 127, 279. crates/persistence/src/backends/{sqlite,postgres,mongodb}/bulk_export.rs contain zero occurrences.

S3, backends/s3/bulk_export.rs:78-87 — both bounds:

if let Some(since) = request.since {
    if resource.last_modified() < since { continue; }
}
if let Some(until) = request.until {
    if resource.last_modified() > until { continue; }
}

SQLite, backends/sqlite/bulk_export.rs:1097-1100 — lower bound only, with no <= counterpart anywhere in the file:

// Apply _since filter if present
if let Some(since) = request.since {
    query.push_str(" AND last_updated >= ?3");
    params_vec.push(Box::new(since.to_rfc3339()));
}

PostgreSQL, backends/postgres/bulk_export.rs:1008 and :1059-1061, and MongoDB, backends/mongodb/bulk_export.rs:169, 196, 244, 299, 341, follow the same shape: _since handled, _until absent.

No test anywhere covers _until — a repo-wide grep for it in test code returns nothing.

Suggested approach

The bound belongs beside every existing request.since site. The relevant trait methods, per backend:

SQLite (backends/sqlite/bulk_export.rs)

  • count_export_resources (1082) — since at 1098
  • fetch_export_batch (1113) — since at 1131
  • list_patient_ids (1198) — since at 1211
  • fetch_patient_compartment_batch (1249)
  • list_export_types (1037)

PostgreSQL (backends/postgres/bulk_export.rs)

  • list_export_types (955), count_export_resources (996), fetch_export_batch (1041), list_patient_ids (1126), fetch_patient_compartment_batch (1176)

MongoDB (backends/mongodb/bulk_export.rs)

  • list_export_types (108), count_export_resources (154), fetch_export_batch (179), list_patient_ids (228), fetch_patient_compartment_batch (275)

Notes for whoever picks this up:

  • Every query path, not just the obvious one. Count and fetch are separate query builders; a bound applied to one and not the other makes a job's progress total disagree with what it emits. The cursor/pagination branches (sqlite/bulk_export.rs:1138-1147, postgres/bulk_export.rs:1070-1081) build their own WHERE clauses too.
  • PostgreSQL parameter typing. tokio-postgres cannot bind String/&str to TIMESTAMPTZ, and a $N::timestamptz cast does not change that — it only makes PG infer the param type. Bind the DateTime<Utc> directly, as fetch_export_batch already does at postgres/bulk_export.rs:1061 (params.push(Box::new(since))). SQLite stores RFC 3339 text and binds since.to_rfc3339(); keep each backend's existing convention rather than unifying.
  • Inclusive bound. S3 uses > until { continue }, i.e. last_updated <= until. Match that: <= in SQL, $lte in Mongo.
  • Land this together with Bulk Export: SQLite and PostgreSQL skip the _since filter for Patient resources in fetch_patient_compartment_batch #658. The resource_type == "Patient" branch of fetch_patient_compartment_batch in SQLite (:1266) and PostgreSQL (:1192) is missing the _since filter as well — confirmed, and split out as Bulk Export: SQLite and PostgreSQL skip the _since filter for Patient resources in fetch_patient_compartment_batch #658. Both fixes touch exactly the same two branches.

If applying the bound in all three backends is not wanted now, the acceptable alternative is to reject _until in crates/rest/src/handlers/bulk_export.rs when the active backend cannot honor it — a 400, the way the handler already rejects patient for system-level export at bulk_export.rs:150-153. Do not leave it accepted and ignored.

Acceptance criteria

  • SQLite, PostgreSQL, and MongoDB apply _until across every export query path — type listing, count, fetch, patient ids, and compartment fetch.
  • The bound is inclusive, matching S3's last_updated <= until.
  • Count and fetch agree: a job's reported total matches the number of resources emitted.
  • A test per backend: two resources either side of the bound, only the earlier one exported.
  • A test that _since and _until together produce a bounded window.
  • _until behaves identically across all four backends, or is explicitly rejected where unsupported.

Scope / out of scope

Out of scope: the Bulk Export UI control for _until#656 covers that and is blocked on this.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions