fix(store): gate ActivitiesMissingWorkout on details already fetched
fillPendingDetails and fillPendingWorkouts are each independently LIMIT-bounded over different candidate sets, so on a large first backfill a structured-workout activity could fall inside the workouts pass's window while still outside the details pass's window. fillActivityWorkout would then align workout targets against zero laps (details/laps never written yet) and unconditionally mark workout_raw_json non-NULL, permanently losing that activity's target pace/HR bands once its real laps arrived later -- silently, with no error. Add details_fetched_at IS NOT NULL to ActivitiesMissingWorkout's and CountActivitiesMissingWorkout's WHERE clause: an activity is only eligible for a workout fetch once its laps actually exist to align against. This still covers the intended retry case (an activity whose workout fetch previously failed always has details_fetched_at already set) while excluding never-yet-processed activities. Rename/rewrite the store test to assert the corrected exclusion (count=1, not 2) as an explicit regression test, and fix the TestSyncStatus_ReportsPhaseAwareProgressAndWorkoutsPending API fixture, which exercised the same buggy shape. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -276,16 +276,24 @@ func (db *DB) CountActivitiesMissingDetails(ctx context.Context, userID int64) (
|
||||
// ActivitiesMissingWorkout returns userID's activities that have a
|
||||
// structured workout (WorkoutID set at initial upsert time, straight from
|
||||
// Garmin's activity summary) but haven't had get_workout_by_id fetched yet.
|
||||
// Independently queryable from ActivitiesMissingDetails: workout_id is
|
||||
// known well before any detail fetch, and workout_raw_json is only ever
|
||||
// set by SetActivityWorkout, so this also picks up an activity whose
|
||||
// details were fetched successfully in some prior run but whose workout
|
||||
// fetch failed back then -- ActivitiesMissingDetails would never surface
|
||||
// that activity again (details_fetched_at/splits_fetched_at are already
|
||||
// set), silently losing its target pace/HR bands forever without this.
|
||||
// Gated on details_fetched_at IS NOT NULL: fillActivityWorkout resolves each
|
||||
// lap's target pace/HR band via alignWorkoutTargets, which needs the
|
||||
// activity's laps already written by fillActivityDetails -- without this
|
||||
// gate, a structured-workout activity whose details/laps haven't been
|
||||
// fetched yet would fall into a separate, independently LIMIT-bounded batch
|
||||
// than fillPendingDetails's, get its workout_raw_json set against zero laps
|
||||
// (a silent no-op alignment), and then never be retried once its laps
|
||||
// finally arrive, since workout_raw_json IS NULL is the only signal this
|
||||
// query has left. This is still independent of splits_fetched_at (unlike
|
||||
// ActivitiesMissingDetails, which requires both): splits/laps aren't needed
|
||||
// to resolve workout targets, only details_fetched_at is. It also still
|
||||
// covers the intended retry case -- an activity whose workout fetch
|
||||
// previously failed after details succeeded always has details_fetched_at
|
||||
// already set, so it's still surfaced here -- while excluding activities
|
||||
// that simply haven't been processed by fillActivityDetails at all yet.
|
||||
func (db *DB) ActivitiesMissingWorkout(ctx context.Context, userID int64, limit int) ([]Activity, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT `+activityColumns+` FROM activities
|
||||
WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL
|
||||
WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL AND details_fetched_at IS NOT NULL
|
||||
ORDER BY start_time_utc DESC LIMIT ?`, userID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list activities missing workout for user %d: %w", userID, err)
|
||||
@@ -306,11 +314,12 @@ func (db *DB) ActivitiesMissingWorkout(ctx context.Context, userID int64, limit
|
||||
// CountActivitiesMissingWorkout returns how many of userID's activities
|
||||
// still need get_workout_by_id fetched, regardless of any per-call batch
|
||||
// limit -- used to report overall remaining work, mirroring
|
||||
// CountActivitiesMissingDetails.
|
||||
// CountActivitiesMissingDetails. See ActivitiesMissingWorkout for why this
|
||||
// is additionally gated on details_fetched_at IS NOT NULL.
|
||||
func (db *DB) CountActivitiesMissingWorkout(ctx context.Context, userID int64) (int, error) {
|
||||
var n int
|
||||
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM activities
|
||||
WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL`, userID).Scan(&n)
|
||||
WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL AND details_fetched_at IS NOT NULL`, userID).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count activities missing workout for user %d: %w", userID, err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user