feat(sync): split FillPendingDetails into activities/workouts phases
Progress becomes phase-aware ({Phase, Done, Total} instead of a flat
{Done, Total}), with FullSync reporting an indeterminate "discovering"
phase during backfill/incremental sync (there's no meaningful total
before those calls have already happened), then FillPendingDetails
reporting a real Done/Total for its activities pass, then its new,
independent workouts pass.
alignWorkoutTargets now takes a lap count instead of a []garmin.Lap
slice, since it never used lap content, only length -- this lets the
new workouts pass call it against laps read back from the DB rather
than needing the original Garmin lap data again.
Splitting the passes also fixes a latent bug: an activity whose
details were fetched successfully but whose workout fetch failed in
that same run previously had no way to ever retry the workout fetch,
since ActivitiesMissingDetails stops returning it once
details_fetched_at/splits_fetched_at are set. ActivitiesMissingWorkout
queries workout_id/workout_raw_json independently, so it keeps
surfacing that activity until its workout is actually fetched.
This commit is contained in:
@@ -2,6 +2,7 @@ package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -92,7 +93,7 @@ func TestAlignWorkoutTargets_MatchesLapsToFlattenedSteps(t *testing.T) {
|
||||
}},
|
||||
}}
|
||||
|
||||
targets := alignWorkoutTargets(laps, workout)
|
||||
targets := alignWorkoutTargets(len(laps), workout)
|
||||
if len(targets) != 2 {
|
||||
t.Fatalf("expected 2 aligned targets, got %d", len(targets))
|
||||
}
|
||||
@@ -118,7 +119,7 @@ func TestAlignWorkoutTargets_OneExtraTrailingLapKeepsOtherTargets(t *testing.T)
|
||||
}},
|
||||
}}
|
||||
|
||||
targets := alignWorkoutTargets(laps, workout)
|
||||
targets := alignWorkoutTargets(len(laps), workout)
|
||||
if len(targets) != 3 {
|
||||
t.Fatalf("expected 3 slots (one per lap), got %d", len(targets))
|
||||
}
|
||||
@@ -141,7 +142,7 @@ func TestAlignWorkoutTargets_MismatchedCountYieldsAllNil(t *testing.T) {
|
||||
}},
|
||||
}}
|
||||
|
||||
targets := alignWorkoutTargets(laps, workout)
|
||||
targets := alignWorkoutTargets(len(laps), workout)
|
||||
if len(targets) != 3 {
|
||||
t.Fatalf("expected 3 slots (one per lap), got %d", len(targets))
|
||||
}
|
||||
@@ -629,24 +630,27 @@ func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) {
|
||||
func TestFillPendingDetails_ReportsPhaseAwareProgressThroughActivitiesAndWorkoutsPhases(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
userID := provisionTestUser(t, db)
|
||||
|
||||
const n = 2
|
||||
m := &mock.Client{
|
||||
Activities: []garmin.Activity{},
|
||||
Splits: map[int64]garmin.ActivitySplits{},
|
||||
Details: map[int64]garmin.ActivityDetails{},
|
||||
Workouts: map[int64]garmin.Workout{},
|
||||
}
|
||||
const n = 3
|
||||
for i := int64(1); i <= n; i++ {
|
||||
workoutID := i + 100
|
||||
m.Activities = append(m.Activities, garmin.Activity{
|
||||
ActivityID: i, ActivityType: garmin.ActivityType{TypeKey: "running"},
|
||||
ActivityID: i, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutID,
|
||||
StartTimeGMT: "2026-07-0" + string(rune('0'+i)) + " 06:00:00", Distance: 5000, Duration: 1500,
|
||||
})
|
||||
m.Splits[i] = garmin.ActivitySplits{ActivityID: i}
|
||||
m.Details[i] = garmin.ActivityDetails{ActivityID: i}
|
||||
m.Workouts[workoutID] = garmin.Workout{}
|
||||
}
|
||||
|
||||
svc := NewService(m, db, userID, Config{InterCallDelay: 150 * time.Millisecond},
|
||||
@@ -655,27 +659,171 @@ func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) {
|
||||
t.Fatalf("backfillCore: %v", err)
|
||||
}
|
||||
|
||||
if p := svc.Progress(); p.Total != 0 {
|
||||
t.Fatalf("Progress before FillPendingDetails = %+v, want zero value", p)
|
||||
if p := svc.Progress(); p.Phase != PhaseIdle {
|
||||
t.Fatalf("Progress before FillPendingDetails = %+v, want PhaseIdle", p)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- svc.FillPendingDetails(ctx, n) }()
|
||||
|
||||
time.Sleep(200 * time.Millisecond) // into the delay before the 2nd or 3rd item
|
||||
mid := svc.Progress()
|
||||
if mid.Total != n {
|
||||
t.Errorf("mid-flight Progress().Total = %d, want %d", mid.Total, n)
|
||||
// Timeline with InterCallDelay=150ms and n=2 per phase: activities pass
|
||||
// does item0 (instant), delays ~150ms, does item1 -- landing here around
|
||||
// t=75ms should be mid-delay in the activities phase.
|
||||
time.Sleep(75 * time.Millisecond)
|
||||
midActivities := svc.Progress()
|
||||
if midActivities.Phase != PhaseActivities {
|
||||
t.Errorf("mid-activities Progress().Phase = %q, want %q", midActivities.Phase, PhaseActivities)
|
||||
}
|
||||
if mid.Done <= 0 || mid.Done >= n {
|
||||
t.Errorf("mid-flight Progress().Done = %d, want strictly between 0 and %d (i.e. actually in progress)", mid.Done, n)
|
||||
if midActivities.Total != n {
|
||||
t.Errorf("mid-activities Progress().Total = %d, want %d", midActivities.Total, n)
|
||||
}
|
||||
if midActivities.Done != 1 {
|
||||
t.Errorf("mid-activities Progress().Done = %d, want 1", midActivities.Done)
|
||||
}
|
||||
|
||||
// The activities phase finishes its own delay+item1 around t=150ms, then
|
||||
// the workouts phase starts: item0 (instant), delay ~150ms, item1.
|
||||
// Landing around t=150+75=225ms should be mid-delay in the workouts phase.
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
midWorkouts := svc.Progress()
|
||||
if midWorkouts.Phase != PhaseWorkouts {
|
||||
t.Errorf("mid-workouts Progress().Phase = %q, want %q", midWorkouts.Phase, PhaseWorkouts)
|
||||
}
|
||||
if midWorkouts.Total != n {
|
||||
t.Errorf("mid-workouts Progress().Total = %d, want %d", midWorkouts.Total, n)
|
||||
}
|
||||
if midWorkouts.Done != 1 {
|
||||
t.Errorf("mid-workouts Progress().Done = %d, want 1", midWorkouts.Done)
|
||||
}
|
||||
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("FillPendingDetails: %v", err)
|
||||
}
|
||||
if final := svc.Progress(); final.Total != 0 || final.Done != 0 {
|
||||
t.Errorf("Progress after completion = %+v, want zero value (idle)", final)
|
||||
if final := svc.Progress(); final.Phase != PhaseIdle || final.Total != 0 || final.Done != 0 {
|
||||
t.Errorf("Progress after completion = %+v, want zero-value PhaseIdle", final)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillPendingDetails_RetriesWorkoutFetchForActivityWithDetailsAlreadyFetched(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
userID := provisionTestUser(t, db)
|
||||
|
||||
const garminActivityID = 77
|
||||
const workoutID = 888
|
||||
workoutIDPtr := int64(workoutID)
|
||||
id, err := db.UpsertActivity(ctx, userID, store.Activity{
|
||||
GarminActivityID: garminActivityID, WorkoutID: &workoutIDPtr,
|
||||
StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertActivity: %v", err)
|
||||
}
|
||||
// Simulate a prior run that successfully fetched details/splits but
|
||||
// whose workout fetch failed (workout_raw_json stays NULL).
|
||||
if err := db.SetActivityDetails(ctx, userID, id, "{}"); err != nil {
|
||||
t.Fatalf("SetActivityDetails: %v", err)
|
||||
}
|
||||
if err := db.SetActivitySplitsFetched(ctx, userID, id); err != nil {
|
||||
t.Fatalf("SetActivitySplitsFetched: %v", err)
|
||||
}
|
||||
if err := db.ReplaceLaps(ctx, userID, id, []store.Lap{{LapIndex: 1, IntensityType: "ACTIVE"}}); err != nil {
|
||||
t.Fatalf("ReplaceLaps: %v", err)
|
||||
}
|
||||
|
||||
m := &mock.Client{
|
||||
Workouts: map[int64]garmin.Workout{
|
||||
workoutID: {Segments: []garmin.WorkoutSegment{
|
||||
{Steps: []garmin.WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
// A prior FillPendingDetails call (which had ActivitiesMissingDetails
|
||||
// return nothing, since details are already fetched) still reaches this
|
||||
// activity via ActivitiesMissingWorkout.
|
||||
if err := svc.FillPendingDetails(ctx, 10); err != nil {
|
||||
t.Fatalf("FillPendingDetails: %v", err)
|
||||
}
|
||||
|
||||
laps, err := db.LapsForActivity(ctx, userID, id)
|
||||
if err != nil || len(laps) != 1 {
|
||||
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
||||
}
|
||||
if laps[0].TargetPaceLowMps == nil || *laps[0].TargetPaceLowMps != 3.0 {
|
||||
t.Errorf("TargetPaceLowMps = %v, want 3.0 (workout fetch should have been retried)", laps[0].TargetPaceLowMps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillPendingDetails_OneWorkoutFetchFailureDoesNotAbortTheWorkoutsPass(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
userID := provisionTestUser(t, db)
|
||||
|
||||
const failingWorkoutID = 200
|
||||
const okWorkoutID = 201
|
||||
failingPtr, okPtr := int64(failingWorkoutID), int64(okWorkoutID)
|
||||
m := &mock.Client{
|
||||
Activities: []garmin.Activity{
|
||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &failingPtr,
|
||||
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
|
||||
{ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &okPtr,
|
||||
StartTimeGMT: "2026-07-02 06:00:00", Distance: 5000, Duration: 1500},
|
||||
},
|
||||
Splits: map[int64]garmin.ActivitySplits{
|
||||
1: {ActivityID: 1, Laps: []garmin.Lap{{LapIndex: 1, IntensityType: "ACTIVE"}}},
|
||||
2: {ActivityID: 2, Laps: []garmin.Lap{{LapIndex: 1, IntensityType: "ACTIVE"}}},
|
||||
},
|
||||
Details: map[int64]garmin.ActivityDetails{1: {ActivityID: 1}, 2: {ActivityID: 2}},
|
||||
Workouts: map[int64]garmin.Workout{
|
||||
okWorkoutID: {Segments: []garmin.WorkoutSegment{
|
||||
{Steps: []garmin.WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
WorkoutErrByID: map[int64]error{failingWorkoutID: fmt.Errorf("garmin says no")},
|
||||
}
|
||||
// Not timing-sensitive -- keep InterCallDelay negligible so the 2
|
||||
// activities/2 workouts here don't cost real wall-clock seconds
|
||||
// (Config{}'s default is 1s per gap).
|
||||
svc := NewService(m, db, userID, Config{InterCallDelay: time.Millisecond},
|
||||
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
if _, err := svc.backfillCore(ctx); err != nil {
|
||||
t.Fatalf("backfillCore: %v", err)
|
||||
}
|
||||
if err := svc.FillPendingDetails(ctx, 10); err != nil {
|
||||
t.Fatalf("FillPendingDetails should not return an error for a per-activity workout fetch failure: %v", err)
|
||||
}
|
||||
|
||||
remaining, err := db.CountActivitiesMissingWorkout(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("CountActivitiesMissingWorkout: %v", err)
|
||||
}
|
||||
if remaining != 1 {
|
||||
t.Errorf("CountActivitiesMissingWorkout = %d, want 1 (the failing activity stays pending, retryable next sync)", remaining)
|
||||
}
|
||||
|
||||
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListActivities: %v", err)
|
||||
}
|
||||
var okActivity store.Activity
|
||||
for _, a := range activities {
|
||||
if a.GarminActivityID == 2 {
|
||||
okActivity = a
|
||||
}
|
||||
}
|
||||
laps, err := db.LapsForActivity(ctx, userID, okActivity.ID)
|
||||
if err != nil || len(laps) != 1 {
|
||||
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
||||
}
|
||||
if laps[0].TargetPaceLowMps == nil || *laps[0].TargetPaceLowMps != 3.0 {
|
||||
t.Errorf("the succeeding activity's TargetPaceLowMps = %v, want 3.0 (should not be affected by the other activity's failure)", laps[0].TargetPaceLowMps)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user