Move backfill horizon from a startup env var into the editable profile

SMARTRUN_BACKFILL_HORIZON_DAYS was a server-startup-only env var with no UI,
defaulting to 3 years -- so editing the unrelated "Rolling window" profile
field (for classification, not sync) had no effect on how far back Sync Now
reached. Backfill horizon is now Profile.BackfillHorizonDays, read fresh on
every Backfill call, with its own field on the Profile page.
This commit is contained in:
2026-07-19 12:57:18 +02:00
parent 93746666b5
commit 249826afc0
10 changed files with 84 additions and 37 deletions

View File

@@ -18,10 +18,10 @@ import (
)
// Config tunes sync behavior. Zero values fall back to sensible defaults in
// NewService.
// NewService. How far back Backfill reaches is not here -- it's
// Profile.BackfillHorizonDays, read fresh on every call so a user-edited
// value takes effect on the next sync without a server restart.
type Config struct {
// BackfillHorizonDays bounds how far back a full backfill reaches.
BackfillHorizonDays int
// BackfillWindowDays is the page size for each get_activities call
// during backfill.
BackfillWindowDays int
@@ -39,9 +39,6 @@ type Config struct {
}
func (c Config) withDefaults() Config {
if c.BackfillHorizonDays == 0 {
c.BackfillHorizonDays = 3 * 365
}
if c.BackfillWindowDays == 0 {
c.BackfillWindowDays = 90
}
@@ -98,19 +95,29 @@ func (s *Service) setProgress(done, total int) {
}
// Backfill pages backward in Config.BackfillWindowDays windows until
// Config.BackfillHorizonDays is reached or Garmin returns an empty page.
// Safe to re-run: activities are upserted by garmin_activity_id, and thanks
// to the sync_state watermark (Garmin history is immutable once recorded)
// a repeat call only fetches whatever's newer than the last completed
// backfill, or is a fast no-op if the configured horizon is already fully
// covered -- it does not re-walk years of already-known history.
// Profile.BackfillHorizonDays is reached or Garmin returns an empty page.
// The horizon is read fresh from the profile on every call (not fixed at
// server startup), so a user-edited value takes effect on the very next
// sync. Safe to re-run: activities are upserted by garmin_activity_id, and
// thanks to the sync_state watermark (Garmin history is immutable once
// recorded) a repeat call only fetches whatever's newer than the last
// completed backfill, or is a fast no-op if the configured horizon is
// already fully covered -- it does not re-walk years of already-known
// history. Widening the horizon between calls resumes further back instead
// of re-fetching everything.
func (s *Service) Backfill(ctx context.Context) error {
runID, err := s.db.StartSyncRun(ctx, store.SyncKindBackfill)
if err != nil {
return err
}
horizon := s.now().AddDate(0, 0, -s.cfg.BackfillHorizonDays)
profile, err := s.db.GetProfile(ctx)
if err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, runID, 0, &msg)
return fmt.Errorf("load profile: %w", err)
}
horizon := s.now().AddDate(0, 0, -profile.BackfillHorizonDays)
state, err := s.db.GetSyncState(ctx)
if err != nil {

View File

@@ -28,6 +28,21 @@ func fixedNow(t time.Time) func() time.Time {
return func() time.Time { return t }
}
// setBackfillHorizon sets Profile.BackfillHorizonDays, which Backfill reads
// fresh on every call (it's no longer part of Config).
func setBackfillHorizon(t *testing.T, db *store.DB, days int) {
t.Helper()
ctx := context.Background()
profile, err := db.GetProfile(ctx)
if err != nil {
t.Fatalf("GetProfile: %v", err)
}
profile.BackfillHorizonDays = days
if err := db.UpdateProfile(ctx, profile); err != nil {
t.Fatalf("UpdateProfile: %v", err)
}
}
func TestBackfill_StoresActivitiesAndRecordsSyncRun(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
@@ -399,8 +414,9 @@ func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) {
m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
}}
svc := NewService(m, db, Config{BackfillHorizonDays: 10, BackfillWindowDays: 10},
svc := NewService(m, db, Config{BackfillWindowDays: 10},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
setBackfillHorizon(t, db, 10)
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("first Backfill: %v", err)
@@ -434,8 +450,9 @@ func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
}}
svc := NewService(m, db, Config{BackfillHorizonDays: 10, BackfillWindowDays: 10},
svc := NewService(m, db, Config{BackfillWindowDays: 10},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
setBackfillHorizon(t, db, 10)
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("first Backfill: %v", err)
@@ -477,7 +494,8 @@ func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
}}
now := fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))
svc := NewService(m, db, Config{BackfillHorizonDays: 10, BackfillWindowDays: 10}, now)
svc := NewService(m, db, Config{BackfillWindowDays: 10}, now)
setBackfillHorizon(t, db, 10)
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("first Backfill: %v", err)
}
@@ -486,7 +504,8 @@ func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
// Simulate the user widening the horizon later -- should resume from the
// watermark (not re-fetch the already-covered recent window) but still
// make progress toward the new, deeper horizon.
svc2 := NewService(m, db, Config{BackfillHorizonDays: 30, BackfillWindowDays: 10}, now)
setBackfillHorizon(t, db, 30)
svc2 := NewService(m, db, Config{BackfillWindowDays: 10}, now)
if err := svc2.Backfill(ctx); err != nil {
t.Fatalf("second Backfill: %v", err)
}