feat(store): add workout_not_found_at, exclude it from missing-workout queries
A confirmed-404 workout must stop being retried forever, but workout_raw_json should never be fabricated -- it stays null exactly as it does for "not yet fetched." workout_not_found_at is the separate marker ActivitiesMissingWorkout/CountActivitiesMissingWorkout now check for, mirroring the existing pattern of the other _fetched_at columns. UpsertActivity's ON CONFLICT clause already never touches these columns, so the marker persists across every later re-sync.
This commit is contained in:
@@ -45,6 +45,11 @@ type Activity struct {
|
|||||||
// internal/sync/mapping.go's alignWorkoutTargets). Nil when the activity
|
// internal/sync/mapping.go's alignWorkoutTargets). Nil when the activity
|
||||||
// has no WorkoutID, or was synced before this column existed.
|
// has no WorkoutID, or was synced before this column existed.
|
||||||
WorkoutRawJSON *string
|
WorkoutRawJSON *string
|
||||||
|
// WorkoutNotFoundAt is set when get_workout_by_id returned a definitive
|
||||||
|
// HTTP 404 for this activity's WorkoutID -- distinct from
|
||||||
|
// WorkoutRawJSON staying nil for "not yet fetched" (see
|
||||||
|
// SetActivityWorkoutNotFound).
|
||||||
|
WorkoutNotFoundAt *string
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
}
|
}
|
||||||
@@ -102,7 +107,7 @@ func scanActivity(row interface{ Scan(...any) error }) (Activity, error) {
|
|||||||
&a.AvgSpeedMps, &a.ElevationGainM,
|
&a.AvgSpeedMps, &a.ElevationGainM,
|
||||||
&a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect, &a.VO2MaxValue,
|
&a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect, &a.VO2MaxValue,
|
||||||
&a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt, &a.WorkoutRawJSON,
|
&a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt, &a.WorkoutRawJSON,
|
||||||
&a.CreatedAt, &a.UpdatedAt,
|
&a.WorkoutNotFoundAt, &a.CreatedAt, &a.UpdatedAt,
|
||||||
)
|
)
|
||||||
return a, err
|
return a, err
|
||||||
}
|
}
|
||||||
@@ -113,7 +118,7 @@ const activityColumns = `
|
|||||||
avg_speed_mps, elevation_gain_m,
|
avg_speed_mps, elevation_gain_m,
|
||||||
aerobic_training_effect, anaerobic_training_effect, vo2max_value,
|
aerobic_training_effect, anaerobic_training_effect, vo2max_value,
|
||||||
raw_json, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json,
|
raw_json, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json,
|
||||||
created_at, updated_at
|
workout_not_found_at, created_at, updated_at
|
||||||
`
|
`
|
||||||
|
|
||||||
// GetActivity fetches one activity by its internal id, scoped to userID.
|
// GetActivity fetches one activity by its internal id, scoped to userID.
|
||||||
@@ -225,6 +230,21 @@ func (db *DB) SetActivityWorkout(ctx context.Context, userID, activityID int64,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetActivityWorkoutNotFound records that get_workout_by_id returned a
|
||||||
|
// definitive HTTP 404 for this activity's WorkoutID -- the workout was
|
||||||
|
// deleted on Garmin's side after being linked to this activity.
|
||||||
|
// WorkoutRawJSON is deliberately left nil (never fabricated); this is a
|
||||||
|
// separate marker so ActivitiesMissingWorkout stops retrying it forever.
|
||||||
|
func (db *DB) SetActivityWorkoutNotFound(ctx context.Context, userID, activityID int64) error {
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
UPDATE activities SET workout_not_found_at = datetime('now'), updated_at = datetime('now')
|
||||||
|
WHERE id = ? AND user_id = ?`, activityID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set activity %d workout not found for user %d: %w", activityID, userID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// SetActivitySplitsFetched records that get_activity_splits has been fetched
|
// SetActivitySplitsFetched records that get_activity_splits has been fetched
|
||||||
// for this activity.
|
// for this activity.
|
||||||
func (db *DB) SetActivitySplitsFetched(ctx context.Context, userID, activityID int64) error {
|
func (db *DB) SetActivitySplitsFetched(ctx context.Context, userID, activityID int64) error {
|
||||||
@@ -293,7 +313,8 @@ func (db *DB) CountActivitiesMissingDetails(ctx context.Context, userID int64) (
|
|||||||
// that simply haven't been processed by fillActivityDetails at all yet.
|
// that simply haven't been processed by fillActivityDetails at all yet.
|
||||||
func (db *DB) ActivitiesMissingWorkout(ctx context.Context, userID int64, limit int) ([]Activity, error) {
|
func (db *DB) ActivitiesMissingWorkout(ctx context.Context, userID int64, limit int) ([]Activity, error) {
|
||||||
rows, err := db.QueryContext(ctx, `SELECT `+activityColumns+` FROM activities
|
rows, err := db.QueryContext(ctx, `SELECT `+activityColumns+` FROM activities
|
||||||
WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL AND details_fetched_at IS NOT NULL
|
WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL
|
||||||
|
AND details_fetched_at IS NOT NULL AND workout_not_found_at IS NULL
|
||||||
ORDER BY start_time_utc DESC LIMIT ?`, userID, limit)
|
ORDER BY start_time_utc DESC LIMIT ?`, userID, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list activities missing workout for user %d: %w", userID, err)
|
return nil, fmt.Errorf("list activities missing workout for user %d: %w", userID, err)
|
||||||
@@ -319,7 +340,8 @@ func (db *DB) ActivitiesMissingWorkout(ctx context.Context, userID int64, limit
|
|||||||
func (db *DB) CountActivitiesMissingWorkout(ctx context.Context, userID int64) (int, error) {
|
func (db *DB) CountActivitiesMissingWorkout(ctx context.Context, userID int64) (int, error) {
|
||||||
var n int
|
var n int
|
||||||
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM activities
|
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM activities
|
||||||
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)
|
WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL
|
||||||
|
AND details_fetched_at IS NOT NULL AND workout_not_found_at IS NULL`, userID).Scan(&n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("count activities missing workout for user %d: %w", userID, err)
|
return 0, fmt.Errorf("count activities missing workout for user %d: %w", userID, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,6 +151,14 @@ CREATE TABLE activities (
|
|||||||
-- Genuine raw get_workout_by_id() response, the source used to compute
|
-- Genuine raw get_workout_by_id() response, the source used to compute
|
||||||
-- alignWorkoutTargets. Null when the activity has no workout_id.
|
-- alignWorkoutTargets. Null when the activity has no workout_id.
|
||||||
workout_raw_json TEXT,
|
workout_raw_json TEXT,
|
||||||
|
-- Set when get_workout_by_id returned a definitive HTTP 404 (the
|
||||||
|
-- workout was deleted on Garmin's side after being linked to this
|
||||||
|
-- activity) -- distinct from workout_raw_json staying null for "not yet
|
||||||
|
-- fetched": this activity is excluded from ActivitiesMissingWorkout so
|
||||||
|
-- it stops being retried forever (see
|
||||||
|
-- docs/superpowers/specs/2026-07-27-workout-not-found-design.md).
|
||||||
|
-- workout_raw_json itself is never fabricated; it just stays null.
|
||||||
|
workout_not_found_at TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
UNIQUE(user_id, garmin_activity_id)
|
UNIQUE(user_id, garmin_activity_id)
|
||||||
|
|||||||
@@ -459,6 +459,80 @@ func TestActivitiesMissingWorkout_OnlyIncludesActivitiesWithDetailsAlreadyFetche
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestActivitiesMissingWorkout_ExcludesConfirmedNotFound(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
workoutID := int64(999)
|
||||||
|
// Details already fetched, workout confirmed 404 on Garmin -- must not
|
||||||
|
// be retried, so must not appear in ActivitiesMissingWorkout/Count.
|
||||||
|
notFound := Activity{
|
||||||
|
GarminActivityID: 1, WorkoutID: &workoutID,
|
||||||
|
StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}",
|
||||||
|
}
|
||||||
|
idA, err := db.UpsertActivity(ctx, userID, notFound)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertActivity (a): %v", err)
|
||||||
|
}
|
||||||
|
if err := db.SetActivityDetails(ctx, userID, idA, "{}"); err != nil {
|
||||||
|
t.Fatalf("SetActivityDetails (a): %v", err)
|
||||||
|
}
|
||||||
|
if err := db.SetActivitySplitsFetched(ctx, userID, idA); err != nil {
|
||||||
|
t.Fatalf("SetActivitySplitsFetched (a): %v", err)
|
||||||
|
}
|
||||||
|
if err := db.SetActivityWorkoutNotFound(ctx, userID, idA); err != nil {
|
||||||
|
t.Fatalf("SetActivityWorkoutNotFound (a): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A genuinely still-pending activity (details fetched, workout not yet
|
||||||
|
// attempted) must still be included, for contrast.
|
||||||
|
stillPending := Activity{
|
||||||
|
GarminActivityID: 2, WorkoutID: &workoutID,
|
||||||
|
StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}",
|
||||||
|
}
|
||||||
|
idB, err := db.UpsertActivity(ctx, userID, stillPending)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertActivity (b): %v", err)
|
||||||
|
}
|
||||||
|
if err := db.SetActivityDetails(ctx, userID, idB, "{}"); err != nil {
|
||||||
|
t.Fatalf("SetActivityDetails (b): %v", err)
|
||||||
|
}
|
||||||
|
if err := db.SetActivitySplitsFetched(ctx, userID, idB); err != nil {
|
||||||
|
t.Fatalf("SetActivitySplitsFetched (b): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := db.CountActivitiesMissingWorkout(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CountActivitiesMissingWorkout: %v", err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("CountActivitiesMissingWorkout = %d, want 1 (only activity b)", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
pending, err := db.ActivitiesMissingWorkout(ctx, userID, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ActivitiesMissingWorkout: %v", err)
|
||||||
|
}
|
||||||
|
if len(pending) != 1 || pending[0].ID != idB {
|
||||||
|
t.Fatalf("ActivitiesMissingWorkout = %+v, want only activity b (id %d)", pending, idB)
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmed, _, err := db.GetActivity(ctx, userID, idA)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetActivity (a): %v", err)
|
||||||
|
}
|
||||||
|
if confirmed.WorkoutNotFoundAt == nil {
|
||||||
|
t.Error("activity a's WorkoutNotFoundAt is nil, want it set")
|
||||||
|
}
|
||||||
|
if confirmed.WorkoutRawJSON != nil {
|
||||||
|
t.Error("activity a's WorkoutRawJSON should stay nil -- not-found is recorded separately, not faked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func contains(s string, substrs ...string) bool {
|
func contains(s string, substrs ...string) bool {
|
||||||
lower := strings.ToLower(s)
|
lower := strings.ToLower(s)
|
||||||
for _, substr := range substrs {
|
for _, substr := range substrs {
|
||||||
|
|||||||
@@ -150,6 +150,14 @@ CREATE TABLE activities (
|
|||||||
-- Genuine raw get_workout_by_id() response, the source used to compute
|
-- Genuine raw get_workout_by_id() response, the source used to compute
|
||||||
-- alignWorkoutTargets. Null when the activity has no workout_id.
|
-- alignWorkoutTargets. Null when the activity has no workout_id.
|
||||||
workout_raw_json TEXT,
|
workout_raw_json TEXT,
|
||||||
|
-- Set when get_workout_by_id returned a definitive HTTP 404 (the
|
||||||
|
-- workout was deleted on Garmin's side after being linked to this
|
||||||
|
-- activity) -- distinct from workout_raw_json staying null for "not yet
|
||||||
|
-- fetched": this activity is excluded from ActivitiesMissingWorkout so
|
||||||
|
-- it stops being retried forever (see
|
||||||
|
-- docs/superpowers/specs/2026-07-27-workout-not-found-design.md).
|
||||||
|
-- workout_raw_json itself is never fabricated; it just stays null.
|
||||||
|
workout_not_found_at TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
UNIQUE(user_id, garmin_activity_id)
|
UNIQUE(user_id, garmin_activity_id)
|
||||||
|
|||||||
Reference in New Issue
Block a user