Architecture
Roles, the ingestion pipeline, storage, tenancy and reliability.
Open Health Sync is one Go binary, ohs, and one Postgres database with the TimescaleDB
extension. The binary runs up to three roles in one process or split across several.
providers (OAuth pull, push notifications) iOS apps (HealthKit)
| |
v v
+------------------------- ohs binary --------------------------+
| collect: OAuth, token refresh, pull scheduler, inbound hooks |
| api: tenants, users, tokens, connect flow, uploads, reads, |
| MCP, health and metrics |
| | raw file first, then a job row in Postgres |
| etl: read raw -> connector Map -> canonical records -> |
| units, validation, dedup, attribution -> Postgres |
+---------------------------------------------------------------+Roles
OHS_ROLES is a comma separated list, api,collect,etl by default.
| Role | Runs |
|---|---|
api | The HTTP server: public REST API, upload endpoint, OAuth callback, inbound hooks, MCP, health and metrics. |
collect | Background sync: pulls on push notifications within seconds, plus a backstop pull every 6 hours while notifications arrive and every hour when they do not; renews provider subscriptions that went silent; OAuth token refresh. See background sync. |
etl | The ETL workers (two tiers, standard and high_freq, four jobs at a time each), the deletion worker (users, and one provider's data of a user), the outgoing webhook sender, and the upkeep jobs described under Reliability. |
One process with all roles is the simplest deployment. To scale, run several api instances
behind a proxy and a separate etl process. The deletion worker lives in the etl role: a
deployment that runs api without etl anywhere never completes user deletions.
Pipeline
- Capture. A device upload (
POST /v1/ingest/apple-health), a provider pull, or a provider notice a pull cannot fetch back (a deleted record) writes the payload to the raw store, verbatim, as gzip NDJSON, one data type per object. The key israw/<tenant>/<user>/<provider>/<yyyy-mm-dd>/<batch>_<part>.ndjson.gz. A raw object is never rewritten. - Queue. In the same transaction as the
raw_objectsrow, anetl_jobsrow is inserted. Jobs are claimed withFOR UPDATE SKIP LOCKED. - Map. The ETL reads the object, parses each line and hands it to the owning connector's
Map, a pure function that returns canonical records (see data-model.md). - Process. Units are converted to the storage unit of each quantity name, values are validated against plausible ranges (hard bounds reject a record, soft bounds flag it), duplicates are dropped by content hash, and sources are attributed.
- Write. A deletion the provider reported (
MapResult.Deletions) is applied first: the stored rows of that provider record go (its sleep or workout with their children, and the samples stored under its id or<id>_<suffix>, found through an index on provider and external id), the daily summary figures it filled are cleared when the deletion names them, and a tombstone inohs_data.deleted_recordskeeps any later copy of it from being written. A Replaceable sleep, workout or (opted in withSupersedeMoved) quantity sample whose start moved replaces the stored copy of the same provider record instead of adding a second one. Records go to theohs_datahypertables. Daily totals for step count, energy, distance and flights are derived per user, provider and local day when the provider sent no daily summary of its own. The local day comes from the sample'stimezonemetadata (UTC when absent). Where several sources report the same day, the largest per-source sum wins, so a phone and a watch are not added together. - Read. The REST API and the MCP server read through one query layer that always filters on
tenant_idanduser_id.
Because the raw payload is the source of truth, a user can be re-processed from the raw store after a mapper fix (self-hosting has the requeue recipe).
Storage
Three schemas:
ohs_core: tenants, API keys, end users, user tokens, idempotency keys, deletion jobs.ohs_sync: connections with encrypted OAuth tokens, OAuth states, pull jobs and coverage, the sync log, inbound subscriptions, raw object registry, ETL jobs.ohs_data: the canonical tables (hypertables for time series, plain tables for sleep phases, workout tracks and the reference tablesquantity_codesanddevice_aliases), plusfailed_records.
Every row in ohs_sync and ohs_data carries tenant_id and user_id, except the two
reference tables. quantity_samples, the largest table, is compressed once a chunk is 30 days
old (about 7 times smaller on disk). Compression is a Timescale License feature, included in
the default timescale/timescaledb image; on an Apache-only build the migration leaves the
table uncompressed and everything else works the same. One continuous aggregate,
quantity_hourly (same license), keeps count, sum, min and max of quantity samples per user,
type, provider, source, unit and hour; GET /v1/users/{id}/samples/stats sums its hours into
local days and falls back to the samples when it is absent or the time zone has a
half-hour offset. Daily summaries are still written by the ETL (they do not need it).
Duplicates are dropped on write. Most tables do it with a unique index on (tenant_id, user_id, content_hash, <time>). quantity_samples has no such index, because checking a unique
index on a compressed chunk decompresses whole batches: the writer reads the stored hashes of
the batch's time window for that user and inserts only the missing rows, under a per-user lock.
The raw store is a local folder (OHS_RAW_STORE=file:///var/lib/ohs/raw) or any S3 compatible
store (s3://bucket?endpoint=...®ion=...&path_style=true). Writes are conditional (never
overwrite), which S3 stores must support through If-None-Match.
Secrets: API keys and user tokens are stored as SHA-256 hashes only. OAuth access and refresh
tokens are sealed with AES-256-GCM under OHS_ENCRYPTION_KEY, bound to
tenant_id|user_id|provider so a stolen ciphertext cannot be moved to another row. Each sealed
value names the key that sealed it, so the key can be rotated online
(key rotation). Logs never contain tokens, keys or health values. The
security model covers the trust boundaries, the mitigations and the
residual risks; hardening.md is the operator checklist.
Tenancy and auth
A tenant is a customer of the instance with one or more API keys. An end user belongs to one
tenant. Your server holds a tenant key; your apps get short-lived user tokens scoped to one user
and to upload, connect or read. A user or object of another tenant reads as 404. See
api.md.
Connectors
A connector implements the contract in internal/connector and registers itself in init().
Three kinds, and one connector may be several:
OAuthConnector: the server pulls (AuthorizeURL,Exchange,Refresh,Fetch).PushConnector: the provider notifies (HandleInbound).DeviceConnector: an SDK uploads (ParseUpload).
A connector is enabled when its keys (OHS_<PROVIDER>_CLIENT_ID and so on) are set; a device
connector needs none. See writing-a-connector.md.
Reliability
- Crash safety. Ingest writes the raw file first, then the
raw_objectsandetl_jobsrows in one transaction. A crash between the two leaves an orphan raw object, which the orphan scanner queues. The scanner looks at the last 7 days only. - Retries. A failed ETL job is retried with exponential backoff (30 s, doubling) and parked
after 5 attempts. A claimed job that is not settled within 10 minutes is claimed again.
Parked jobs show in
parked_jobsof the coverage report and need an operator. - Drainer. Records that failed for a recoverable reason (mapping, unit, or a whole object that failed) are replayed once per process start, so a fixed binary picks them up after a restart. Records that failed deterministically (parse, validation) are closed and kept for inspection.
- Reconciler. Reports what the pipeline lost or stalled on.
- Rate limits. A provider 429 defers that connection's job until
Retry-After, without counting a failed attempt. - Re-authorization. A refused refresh token marks the connection
needs_reauth, visible in the API. - Retention.
OHS_RETENTION_DAYSprunes old rows and raw files; 0 keeps everything. - Deletion.
DELETE /v1/users/{id}is a hard delete of raw files and rows, run by a background worker that survives restarts.