Skip to content

Commit 89ffdad

Browse files
authored
Merge pull request #196 from Dzarlax-AI/codex/healthkit-import-dedup-batching
Implement staged Apple Health XML import
2 parents 03585ba + ed5460d commit 89ffdad

14 files changed

Lines changed: 1490 additions & 149 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,11 @@ Single binary HTTP server (`cmd/server/main.go`) that wires together several pac
6060
```
6161
`MaybeFireAll` is called from the morning scheduler tick (see `runMorningSmartRetry` in `cmd/server/main.go`). It honours per-rule cadence + eligibility and never bubbles errors — a misbehaving rule cannot block the morning report path. Render returning `""` is treated as "skip with no persist" (idiomatic when the render itself re-evaluates a condition). See `digest.go` and `energy_nudge.go` for production examples.
6262

63-
- **`internal/applehealth`** — streaming XML parser for Apple Health export files (`export.xml` or `.zip`). Memory-efficient, maps 100+ HK metric types to internal metric names. Normalizes fraction-based percentage metrics (SpO₂, body fat, etc.) to 0–100 scale during import.
63+
- **`internal/applehealth`** — streaming XML parser for Apple Health export files (`export.xml` or `.zip`). Memory-efficient, maps 100+ HK metric types to internal metric names. Normalizes fraction-based percentage metrics (SpO₂, body fat, etc.) to 0–100 scale during import. Admin UI and CLI XML imports stage through `storage.ImportSession`: one logical `import_runs` row + one `health_records` row per import, `pgx.CopyFrom` temp staging, coverage metadata, set-based promote, and one cache rebuild after commit. XML snapshot freshness is time-based using `HealthData@exportDate` when available: XML supersedes older live/HAE exact rows, later live/mobile exact rows remain fresher, and a later XML snapshot may supersede them again. Do not reintroduce broad `RemoveAutoExportForRange` cleanup for XML imports.
6464

6565
- **`cmd/backfill`** — standalone CLI to rebuild caches. Flags: `--force` / `-f`.
6666

67-
- **`cmd/import`** — standalone CLI to import Apple Health export files. Flags: `--file`, `--batch`, `--pause`, `--dry-run`. Streams XML to avoid memory overload.
67+
- **`cmd/import`** — standalone CLI to import Apple Health export files. Flags: `--file`, `--batch`, `--pause`, `--dry-run`; `--pause` is retained for compatibility but staged imports ignore per-batch sleeps. Streams XML to avoid memory overload and uses the same `storage.ImportSession` path as Admin UI.
6868

6969
## Data Flow
7070

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,11 @@ Single binary HTTP server (`cmd/server/main.go`) that wires together several pac
6060
```
6161
`MaybeFireAll` is called from the morning scheduler tick (see `runMorningSmartRetry` in `cmd/server/main.go`). It honours per-rule cadence + eligibility and never bubbles errors — a misbehaving rule cannot block the morning report path. Render returning `""` is treated as "skip with no persist" (idiomatic when the render itself re-evaluates a condition). See `digest.go` and `energy_nudge.go` for production examples.
6262

63-
- **`internal/applehealth`** — streaming XML parser for Apple Health export files (`export.xml` or `.zip`). Memory-efficient, maps 100+ HK metric types to internal metric names. Normalizes fraction-based percentage metrics (SpO₂, body fat, etc.) to 0–100 scale during import.
63+
- **`internal/applehealth`** — streaming XML parser for Apple Health export files (`export.xml` or `.zip`). Memory-efficient, maps 100+ HK metric types to internal metric names. Normalizes fraction-based percentage metrics (SpO₂, body fat, etc.) to 0–100 scale during import. Admin UI and CLI XML imports stage through `storage.ImportSession`: one logical `import_runs` row + one `health_records` row per import, `pgx.CopyFrom` temp staging, coverage metadata, set-based promote, and one cache rebuild after commit. XML snapshot freshness is time-based using `HealthData@exportDate` when available: XML supersedes older live/HAE exact rows, later live/mobile exact rows remain fresher, and a later XML snapshot may supersede them again. Do not reintroduce broad `RemoveAutoExportForRange` cleanup for XML imports.
6464

6565
- **`cmd/backfill`** — standalone CLI to rebuild caches. Flags: `--force` / `-f`.
6666

67-
- **`cmd/import`** — standalone CLI to import Apple Health export files. Flags: `--file`, `--batch`, `--pause`, `--dry-run`. Streams XML to avoid memory overload.
67+
- **`cmd/import`** — standalone CLI to import Apple Health export files. Flags: `--file`, `--batch`, `--pause`, `--dry-run`; `--pause` is retained for compatibility but staged imports ignore per-batch sleeps. Streams XML to avoid memory overload and uses the same `storage.ImportSession` path as Admin UI.
6868

6969
## Data Flow
7070

README.md

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ The endpoint stores summary fields only (duration, distance, energy, average / m
225225

226226
If `HEALTH_HR_ZONES_BPM` is configured, the ingest path also computes time-in-zone (Z1..Z5) from the per-minute HR samples in the payload and stores five integer columns per workout. Pick zone borders that match your physiology — the Karvonen (HR Reserve) method using observed MaxHR and resting HR is more accurate than the textbook `220 - age` formula.
227227

228-
Apple Health XML imports (`cmd/import` and the Admin import UI) also ingest `<Workout>` summaries into the same `workouts` table. XML exports provide nested `WorkoutStatistics` for distance, energy, and aggregate heart-rate fields on some workouts, so imported rows may include `distance_km`, `energy_kcal`, `avg_hr_bpm`, and `max_hr_bpm`. GPX workout routes are deliberately not imported.
228+
Apple Health XML imports (`cmd/import` and the Admin import UI) also ingest `<Workout>` summaries into the same `workouts` table. XML exports provide nested `WorkoutStatistics` for distance, energy, and aggregate heart-rate fields on some workouts, so imported rows may include `distance_km`, `energy_kcal`, `avg_hr_bpm`, and `max_hr_bpm`. GPX workout routes are deliberately not imported. XML workouts with stable IDs are upserted by `external_id`; synthetic `applexml:` workout IDs are additionally deduplicated by stable workout window fields so corrected XML snapshots do not leave duplicate rows behind.
229229

230230
The `/health` endpoint (no suffix) still accepts all metrics unfiltered for backward compatibility.
231231

@@ -402,11 +402,21 @@ Import a full Apple Health export (from iPhone Settings > Health > Export All He
402402

403403
**Via CLI**:
404404
```bash
405-
DATABASE_URL=postgres://... go run ./cmd/import --file path/to/export.zip
405+
DATABASE_URL=postgres://... go run ./cmd/import --file path/to/export.zip --batch 100000
406406
```
407407

408408
The import streams the XML to avoid memory issues with large files. Percentage metrics (SpO2, body fat, walking asymmetry, etc.) are automatically normalized from Apple Health's fraction format (0.96) to percentage scale (96%).
409409

410+
Admin UI and CLI imports share the same staged storage path:
411+
412+
1. create one logical `import_runs` row and one `health_records` row for the export;
413+
2. read `HealthData@exportDate` as the snapshot freshness timestamp, falling back to import start only when it is unavailable;
414+
3. stream parsed metric points and workouts into temporary staging tables with bulk database operations;
415+
4. promote staged rows in one final transaction, recording coverage metadata and provenance;
416+
5. invalidate affected aggregate rows and run one cache rebuild after commit.
417+
418+
If XML parsing or staging fails, the import is marked failed and no cleanup/backfill is run. The old row-by-row `BulkInsertPoints` path is retained only for narrow compatibility helpers.
419+
410420
After import, run `make energy-backfill` (CLI) or open **Settings → Historical EnergyBank** (web UI) to compute retrospective EnergyBank snapshots from the imported daily scores. This unlocks per-user verdict band calibration; without it, the cold-start defaults are used until the live orchestrator has accumulated 30+ days of snapshots on its own.
411421

412422
## Multi-Device Source Priority
@@ -429,7 +439,14 @@ This priority is applied consistently across dashboard, daily scores, readiness
429439

430440
### Apple Health Import & Auto Export Conflict Resolution
431441

432-
When re-importing from Apple Health (e.g. to fill gaps from missed Auto Export days), the system automatically removes Auto Export (`Health dash - Hourly`/`Vitals`) data for overlapping dates. Apple Health export is treated as ground truth. A full cache rebuild (force backfill) runs after each import.
442+
Apple Health XML export is treated as a timestamped HealthKit snapshot, not as a permanent lock on historical rows:
443+
444+
- XML import supersedes older live / Health Auto Export rows on the same exact `(metric_name, date, source)` key.
445+
- Live/mobile data ingested after that XML snapshot is considered fresher and can overwrite the exact key.
446+
- A later XML export may supersede those rows again as a newer HealthKit snapshot.
447+
- Rows missing from a newer XML snapshot are cleaned up conservatively only within coverage groups proven by staging metadata; the importer does not delete every row in a broad `min_date` / `max_date` range.
448+
449+
This lets full exports repair old HealthKit history while still allowing the first-party app to keep recent data fresh.
433450

434451
## Readiness Score
435452

cmd/import/main.go

Lines changed: 69 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,16 @@
22
//
33
// Usage:
44
//
5-
// import --db /app/data/health.db --file export.zip [--batch 500] [--pause 200ms] [--dry-run]
5+
// import --file export.zip [--batch 100000] [--dry-run]
66
//
77
// The importer streams the XML so it never loads the full file into memory.
8-
// Batches are inserted with a configurable pause between them so the running
9-
// server is not starved of DB connections during a large historical import.
8+
// Parsed rows are staged with bulk database operations, then promoted in one
9+
// final transaction so parse errors do not leave partial imports behind.
1010
package main
1111

1212
import (
1313
"context"
1414
"flag"
15-
"fmt"
1615
"log"
1716
"os"
1817
"time"
@@ -22,17 +21,19 @@ import (
2221
)
2322

2423
func main() {
25-
filePath := flag.String("file", "", "Apple Health export (.zip or export.xml) required")
26-
batchSize := flag.Int("batch", 500, "metric points or workouts per DB transaction")
27-
pauseDur := flag.Duration("pause", 150*time.Millisecond, "sleep between batches (rate-limits DB load)")
28-
dryRun := flag.Bool("dry-run", false, "parse only do not write to DB")
24+
filePath := flag.String("file", "", "Apple Health export (.zip or export.xml) - required")
25+
batchSize := flag.Int("batch", 100000, "metric points or workouts per staging flush")
26+
pauseDur := flag.Duration("pause", 0, "deprecated; staged import ignores per-batch sleeps")
27+
dryRun := flag.Bool("dry-run", false, "parse only - do not write to DB")
2928
flag.Parse()
29+
_ = pauseDur
3030

3131
if *filePath == "" {
3232
log.Fatal("--file is required")
3333
}
3434

3535
var db *storage.DB
36+
var session *storage.ImportSession
3637
if !*dryRun {
3738
dbURL := os.Getenv("DATABASE_URL")
3839
if dbURL == "" {
@@ -44,79 +45,80 @@ func main() {
4445
log.Fatalf("open db: %v", err)
4546
}
4647
defer db.Close()
48+
49+
snapshotAt := time.Now()
50+
var exportDateErr error
51+
var exportDateFound bool
52+
switch {
53+
case len(*filePath) > 4 && (*filePath)[len(*filePath)-4:] == ".zip":
54+
if t, ok, err := applehealth.ExportDateFromZip(*filePath); err != nil {
55+
exportDateErr = err
56+
} else if ok {
57+
snapshotAt = t
58+
exportDateFound = true
59+
}
60+
default:
61+
if t, ok, err := applehealth.ExportDateFromXMLFile(*filePath); err != nil {
62+
exportDateErr = err
63+
} else if ok {
64+
snapshotAt = t
65+
exportDateFound = true
66+
}
67+
}
68+
if exportDateErr != nil {
69+
log.Printf("could not read Apple Health exportDate, using import start as snapshot time: %v", exportDateErr)
70+
} else if !exportDateFound {
71+
log.Printf("Apple Health exportDate not found, using import start as snapshot time")
72+
}
73+
74+
session, err = db.BeginAppleHealthXMLImport(storage.ImportOptions{
75+
SourceName: "apple-health-cli-import",
76+
SnapshotAt: snapshotAt,
77+
})
78+
if err != nil {
79+
log.Fatalf("begin import: %v", err)
80+
}
4781
}
4882

4983
var (
5084
totalParsed int
51-
totalInserted int
5285
totalWorkoutsParsed int
53-
totalWorkoutsUpsert int
54-
totalWorkoutsFailed int
55-
batchN int
56-
workoutBatchN int
5786
pending []storage.MetricPoint
5887
pendingWorkouts []storage.Workout
5988
startTime = time.Now()
6089
)
6190

6291
flush := func(pts []storage.MetricPoint) {
63-
batchN++
6492
totalParsed += len(pts)
65-
6693
if *dryRun {
67-
if totalParsed%100_000 == 0 || totalParsed < 1000 {
68-
log.Printf("[dry-run] parsed %d points so far", totalParsed)
94+
if totalParsed%100000 == 0 || totalParsed < 1000 {
95+
log.Printf("[dry-run] parsed %d points so far", totalParsed)
6996
}
7097
return
7198
}
72-
73-
desc := fmt.Sprintf("apple-health-import batch %d", batchN)
74-
n, err := db.BulkInsertPoints(desc, pts)
75-
if err != nil {
76-
log.Printf("batch %d insert error: %v (continuing)", batchN, err)
99+
if err := session.AddPoints(pts); err != nil {
100+
session.Abort(err)
101+
log.Fatalf("stage points: %v", err)
77102
}
78-
totalInserted += n
79-
80-
elapsed := time.Since(startTime).Round(time.Second)
81-
log.Printf("batch %d: %d parsed / %d new (total %d inserted, %s elapsed)",
82-
batchN, len(pts), n, totalInserted, elapsed)
83-
84-
if *pauseDur > 0 {
85-
time.Sleep(*pauseDur)
103+
if totalParsed%100000 == 0 {
104+
log.Printf("staged %d points so far", totalParsed)
86105
}
87106
}
88107

89108
flushWorkouts := func(workouts []storage.Workout) {
90-
workoutBatchN++
91109
totalWorkoutsParsed += len(workouts)
92-
93110
if *dryRun {
94111
if totalWorkoutsParsed%100 == 0 || totalWorkoutsParsed < 20 {
95-
log.Printf("[dry-run] parsed %d workouts so far", totalWorkoutsParsed)
112+
log.Printf("[dry-run] parsed %d workouts so far", totalWorkoutsParsed)
96113
}
97114
return
98115
}
99-
100-
var failed int
101-
for _, w := range workouts {
102-
if err := db.UpsertWorkout(0, w); err != nil {
103-
failed++
104-
log.Printf("workout batch %d upsert %s error: %v", workoutBatchN, w.ExternalID, err)
105-
}
106-
}
107-
totalWorkoutsFailed += failed
108-
totalWorkoutsUpsert += len(workouts) - failed
109-
110-
elapsed := time.Since(startTime).Round(time.Second)
111-
log.Printf("workout batch %d: %d parsed / %d upserted / %d failed (total %d upserted, %s elapsed)",
112-
workoutBatchN, len(workouts), len(workouts)-failed, failed, totalWorkoutsUpsert, elapsed)
113-
114-
if *pauseDur > 0 {
115-
time.Sleep(*pauseDur)
116+
if err := session.AddWorkouts(workouts); err != nil {
117+
session.Abort(err)
118+
log.Fatalf("stage workouts: %v", err)
116119
}
117120
}
118121

119-
// Collect points up to batchSize, then flush.
120122
emit := func(pts []storage.MetricPoint) {
121123
pending = append(pending, pts...)
122124
for len(pending) >= *batchSize {
@@ -133,8 +135,7 @@ func main() {
133135
}
134136
}
135137

136-
log.Printf("starting import from %s (batch=%d pause=%s dry-run=%v)",
137-
*filePath, *batchSize, *pauseDur, *dryRun)
138+
log.Printf("starting import from %s (batch=%d dry-run=%v)", *filePath, *batchSize, *dryRun)
138139

139140
opts := applehealth.EmitOptions{Points: emit, Workouts: emitWorkouts}
140141
var parseErr error
@@ -150,22 +151,30 @@ func main() {
150151
if len(pendingWorkouts) > 0 {
151152
flushWorkouts(pendingWorkouts)
152153
}
153-
154154
if parseErr != nil {
155-
log.Printf("parse error (partial import may have succeeded): %v", parseErr)
155+
if session != nil {
156+
session.Abort(parseErr)
157+
}
158+
log.Fatalf("parse error: %v", parseErr)
156159
}
157160

158161
elapsed := time.Since(startTime).Round(time.Second)
159162
if *dryRun {
160163
log.Printf("dry-run complete: %d points and %d workouts parsed in %s", totalParsed, totalWorkoutsParsed, elapsed)
161164
return
162165
}
163-
log.Printf("import complete: %d points parsed, %d inserted, %d duplicates skipped; %d workouts parsed, %d upserted, %d failed in %s",
164-
totalParsed, totalInserted, totalParsed-totalInserted, totalWorkoutsParsed, totalWorkoutsUpsert, totalWorkoutsFailed, elapsed)
165166

166-
// Trigger a full backfill so daily_scores and caches are up to date.
167-
log.Println("running backfill (this may take a few minutes)…")
167+
counters, err := session.Commit()
168+
if err != nil {
169+
log.Fatalf("commit import: %v", err)
170+
}
171+
log.Printf("import committed: run=%d %d points parsed, %d inserted, %d updated, %d stale deleted, %d duplicate-stage skipped; %d workouts parsed, %d upserted, %d stale deleted in %s",
172+
counters.ImportRunID, counters.ParsedPoints, counters.InsertedPoints, counters.UpdatedPoints,
173+
counters.DeletedPoints, counters.SkippedPoints, counters.ParsedWorkouts, counters.UpsertedWorkouts,
174+
counters.DeletedWorkouts, elapsed)
175+
176+
log.Println("running backfill (this may take a few minutes)")
168177
db.BackfillAggregates(true)
169178
db.BackfillScores(true)
170-
log.Println("backfill done import complete")
179+
log.Println("backfill done - import complete")
171180
}

0 commit comments

Comments
 (0)