Files
HealthLog/docs/ops/blood-pressure-missing-diastolic.md
T
Marc-André Bombeck b55850967d fix(idempotency): replay a key only for the same request body
The replay cell was keyed on (user, key, method, path). A client that sends
two different writes under one key got the first write's cached response for
the second, and the second was never saved. Native clients up to 1.1.0 post
the two halves of a manual blood pressure exactly that way, so the diastolic
value was silently dropped.

Each cell now stores a SHA-256 fingerprint of the request body (JSON with
recursively sorted keys, otherwise the raw bytes; scoped by method and path;
at most the first MiB is read). Lookup replays, or refuses as in flight, only
when the fingerprint matches. A different body, or a row written before the
column existed, runs the handler without claiming or caching and leaves the
existing cell for its own retries; a lost claim race re-checks the winner's
fingerprint the same way. The body itself is never stored.

Migration 0371 adds the nullable idempotency_keys.request_fingerprint column.
The OpenAPI parameter description, the client notes and the Home Assistant
guide say what a replay now requires. docs/ops gains a read-only query that
lists systolic readings left without their diastolic half, held to the
schema by an integration test.
2026-10-04 10:14:22 +02:00

2.2 KiB

Blood pressure readings missing their diastolic half

What happened

The iPhone app (every App Store build up to 1.0.3 and TestFlight 1.1.0) saves a manually entered blood pressure as two requests to POST /api/measurements: one BLOOD_PRESSURE_SYS, one BLOOD_PRESSURE_DIA, both carrying the same Idempotency-Key. Until the replay cache learned to compare request bodies, the server treated the second request as a retry of the first. It answered with the first request's cached 201 (header X-Idempotent-Replay: true) and never wrote the diastolic value. The app showed success, so nobody noticed.

The fix makes the cache replay only a request with the same body. A different body under the same key now runs normally. Nothing recovers the values that were never sent to the database: they exist only on the person's phone, if at all.

Finding the affected readings

Read-only. Run it against the HealthLog database (with the bundled compose file: docker compose exec db psql -U healthlog healthlog). It lists every live systolic reading that has no live diastolic reading for the same account at the same instant.

SELECT u.username,
       m.id,
       m.measured_at,
       m.value AS systolic,
       m.source
FROM measurements m
JOIN users u ON u.id = m.user_id
WHERE m.type = 'BLOOD_PRESSURE_SYS'
  AND m.deleted_at IS NULL
  AND NOT EXISTS (
    SELECT 1
    FROM measurements d
    WHERE d.user_id = m.user_id
      AND d.measured_at = m.measured_at
      AND d.type = 'BLOOD_PRESSURE_DIA'
      AND d.deleted_at IS NULL
  )
ORDER BY u.username, m.measured_at;

Readings from the phone app carry source = 'MANUAL'. Rows from a synced source (Withings, Apple Health) that show up here are a different gap, not this one: those sources write both halves themselves.

What to tell users

There is nothing the server can repair: the diastolic value never reached it. A person whose reading appears in the list can re-enter it: delete the incomplete reading, then add the blood pressure again with both values and the original date and time. From this release on, both halves are saved on the first try, from any app version.