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.
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.