api: build per-user garmin.Client/sync.Service via lazy caching

Server no longer holds one fixed Garmin/Sync pair -- garminFor/syncFor
build and cache one instance per user, keyed off their own profile's
Garmin credentials and a per-user token-store subdirectory. The background
incremental sync loop now iterates every provisioned user each tick
instead of syncing one global account.
This commit is contained in:
2026-07-25 18:10:09 +02:00
parent 2cda0adbf8
commit 5930cc4ef5
4 changed files with 216 additions and 92 deletions

View File

@@ -31,23 +31,11 @@ func main() {
} }
defer db.Close() defer db.Close()
profile, err := db.GetProfile(context.Background()) if cfg.LegacyOwnerOIDCSub != "" {
if err != nil { if err := db.ClaimLegacyOwner(context.Background(), cfg.LegacyOwnerOIDCSub); err != nil {
log.Fatalf("load profile: %v", err) log.Fatalf("claim legacy owner: %v", err)
}
} }
garminClient := garmin.NewClient(garmin.Config{
PythonPath: cfg.GarminPythonPath,
ServerPath: cfg.GarminServerPath,
GarminEmail: profile.GarminEmail,
GarminPassword: profile.GarminPassword,
TokenStorePath: cfg.GarminTokenStore,
})
defer garminClient.Close()
syncSvc := appsync.NewService(garminClient, db, appsync.Config{
MinConfidence: cfg.MinConfidence,
}, nil)
authVerifier, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCConfig{ authVerifier, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCConfig{
IssuerURL: cfg.OIDCIssuerURL, IssuerURL: cfg.OIDCIssuerURL,
@@ -60,7 +48,13 @@ func main() {
log.Fatalf("oidc: %v", err) log.Fatalf("oidc: %v", err)
} }
server := api.NewServer(db, garminClient, syncSvc, authVerifier, api.SessionConfig{ server := api.NewServer(db, garmin.NewClient, garmin.Config{
PythonPath: cfg.GarminPythonPath,
ServerPath: cfg.GarminServerPath,
TokenStorePath: cfg.GarminTokenStoreRoot,
}, appsync.Config{
MinConfidence: cfg.MinConfidence,
}, authVerifier, api.SessionConfig{
Secret: cfg.SessionSecret, Secret: cfg.SessionSecret,
Duration: cfg.SessionDuration, Duration: cfg.SessionDuration,
Secure: cfg.SessionSecure, Secure: cfg.SessionSecure,
@@ -70,7 +64,7 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop() defer stop()
go runIncrementalSyncLoop(ctx, syncSvc, cfg.IncrementalSyncEvery) go runIncrementalSyncLoop(ctx, server, cfg.IncrementalSyncEvery)
httpServer := &http.Server{Addr: cfg.Addr, Handler: server.Router()} httpServer := &http.Server{Addr: cfg.Addr, Handler: server.Router()}
go func() { go func() {
@@ -89,9 +83,10 @@ func main() {
} }
} }
// runIncrementalSyncLoop periodically syncs new activities in the // runIncrementalSyncLoop periodically syncs new activities for every
// background so the frontend doesn't need to trigger every sync manually. // provisioned user in the background so the frontend doesn't need to
func runIncrementalSyncLoop(ctx context.Context, svc *appsync.Service, every time.Duration) { // trigger every sync manually.
func runIncrementalSyncLoop(ctx context.Context, server *api.Server, every time.Duration) {
ticker := time.NewTicker(every) ticker := time.NewTicker(every)
defer ticker.Stop() defer ticker.Stop()
for { for {
@@ -99,13 +94,7 @@ func runIncrementalSyncLoop(ctx context.Context, svc *appsync.Service, every tim
case <-ctx.Done(): case <-ctx.Done():
return return
case <-ticker.C: case <-ticker.C:
if err := svc.IncrementalSync(ctx); err != nil { server.RunIncrementalSyncForAllUsers(ctx)
log.Printf("incremental sync: %v", err)
continue
}
if err := svc.FillPendingDetails(ctx, 50); err != nil {
log.Printf("fill pending details: %v", err)
}
} }
} }
} }

View File

@@ -15,6 +15,7 @@ import (
"geniusrun/backend/internal/auth" "geniusrun/backend/internal/auth"
authmock "geniusrun/backend/internal/auth/mock" authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock" "geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync" appsync "geniusrun/backend/internal/sync"
@@ -37,9 +38,14 @@ func newTestServer(t *testing.T) (*Server, *store.DB) {
} }
t.Cleanup(func() { db.Close() }) t.Cleanup(func() { db.Close() })
if _, err := db.ProvisionUser(context.Background(), "test-user", "Test User"); err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
m := &mock.Client{} m := &mock.Client{}
svc := appsync.NewService(m, db, appsync.Config{}, func() time.Time { return time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC) }) garminFactory := func(garmin.Config) garmin.Client { return m }
return NewServer(db, m, svc, &authmock.Verifier{}, testSessionConfig), db s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
return s, db
} }
func doJSON(t *testing.T, handler http.Handler, method, path string, body any) *httptest.ResponseRecorder { func doJSON(t *testing.T, handler http.Handler, method, path string, body any) *httptest.ResponseRecorder {

View File

@@ -5,8 +5,11 @@ package api
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"net/http" "net/http"
"path/filepath"
"strconv"
"sync" "sync"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -17,23 +20,125 @@ import (
appsync "geniusrun/backend/internal/sync" appsync "geniusrun/backend/internal/sync"
) )
// Server wires the HTTP handlers to the app's dependencies. // Server wires the HTTP handlers to the app's dependencies. garmin.Client
// and sync.Service are per-user (each user might have their own Garmin
// account), built lazily on first use via GarminFactory and cached.
type Server struct { type Server struct {
DB *store.DB DB *store.DB
Garmin garmin.Client
Sync *appsync.Service
Auth auth.Verifier Auth auth.Verifier
Session SessionConfig Session SessionConfig
// GarminFactory builds a real (or fake, in tests) garmin.Client from a
// fully-resolved per-user Config. Production wiring passes
// garmin.NewClient; tests inject a factory returning a shared
// *mock.Client (see newTestServer in api_test.go).
GarminFactory func(garmin.Config) garmin.Client
// GarminBase holds the plumbing shared by every user's garmin.Config
// (subprocess paths + the token-store root directory); only
// GarminEmail/GarminPassword/TokenStorePath vary per user, filled in by
// garminFor.
GarminBase garmin.Config
SyncConfig appsync.Config
mu sync.Mutex mu sync.Mutex
authStatus garmin.AuthStatus userGarmin map[int64]garmin.Client
authMessage string userSync map[int64]*appsync.Service
syncRunning bool userAuthStatus map[int64]garmin.AuthStatus
userAuthMessage map[int64]string
userSyncRunning map[int64]bool
} }
// NewServer builds a Server. // NewServer builds a Server.
func NewServer(db *store.DB, g garmin.Client, s *appsync.Service, authVerifier auth.Verifier, session SessionConfig) *Server { func NewServer(db *store.DB, garminFactory func(garmin.Config) garmin.Client, garminBase garmin.Config, syncConfig appsync.Config, authVerifier auth.Verifier, session SessionConfig) *Server {
return &Server{DB: db, Garmin: g, Sync: s, Auth: authVerifier, Session: session} return &Server{
DB: db, GarminFactory: garminFactory, GarminBase: garminBase, SyncConfig: syncConfig,
Auth: authVerifier, Session: session,
userGarmin: map[int64]garmin.Client{},
userSync: map[int64]*appsync.Service{},
userAuthStatus: map[int64]garmin.AuthStatus{},
userAuthMessage: map[int64]string{},
userSyncRunning: map[int64]bool{},
}
}
// garminFor returns userID's garmin.Client, building and caching it (from
// userID's own profile row) on first use.
func (s *Server) garminFor(ctx context.Context, userID int64) (garmin.Client, error) {
s.mu.Lock()
if c, ok := s.userGarmin[userID]; ok {
s.mu.Unlock()
return c, nil
}
s.mu.Unlock()
profile, err := s.DB.GetProfile(ctx, userID)
if err != nil {
return nil, fmt.Errorf("load profile for garmin client (user %d): %w", userID, err)
}
cfg := s.GarminBase
cfg.GarminEmail = profile.GarminEmail
cfg.GarminPassword = profile.GarminPassword
if cfg.TokenStorePath != "" {
cfg.TokenStorePath = filepath.Join(cfg.TokenStorePath, strconv.FormatInt(userID, 10))
}
s.mu.Lock()
defer s.mu.Unlock()
if c, ok := s.userGarmin[userID]; ok {
return c, nil // built concurrently by another request between our unlock and re-lock
}
client := s.GarminFactory(cfg)
s.userGarmin[userID] = client
return client, nil
}
// syncFor returns userID's sync.Service, building and caching it on first use.
func (s *Server) syncFor(ctx context.Context, userID int64) (*appsync.Service, error) {
s.mu.Lock()
if svc, ok := s.userSync[userID]; ok {
s.mu.Unlock()
return svc, nil
}
s.mu.Unlock()
client, err := s.garminFor(ctx, userID)
if err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if svc, ok := s.userSync[userID]; ok {
return svc, nil
}
svc := appsync.NewService(client, s.DB, userID, s.SyncConfig, nil)
s.userSync[userID] = svc
return svc, nil
}
// RunIncrementalSyncForAllUsers is called on a timer (see main.go) to sync
// every provisioned user in turn, replacing the old single-global-Service
// background loop.
func (s *Server) RunIncrementalSyncForAllUsers(ctx context.Context) {
users, err := s.DB.ListUsers(ctx)
if err != nil {
log.Printf("api: list users for incremental sync: %v", err)
return
}
for _, u := range users {
svc, err := s.syncFor(ctx, u.ID)
if err != nil {
log.Printf("api: sync service for user %d: %v", u.ID, err)
continue
}
if err := svc.IncrementalSync(ctx); err != nil {
log.Printf("api: incremental sync for user %d: %v", u.ID, err)
continue
}
if err := svc.FillPendingDetails(ctx, 50); err != nil {
log.Printf("api: fill pending details for user %d: %v", u.ID, err)
}
}
} }
// Router builds the HTTP routes. // Router builds the HTTP routes.
@@ -56,6 +161,9 @@ func (s *Server) Router() http.Handler {
r.Post("/session/logout", s.handleSessionLogout) r.Post("/session/logout", s.handleSessionLogout)
r.Post("/setup", s.handleSetup) r.Post("/setup", s.handleSetup)
r.Group(func(r chi.Router) {
r.Use(requireProvisionedUser)
r.Route("/profile", func(r chi.Router) { r.Route("/profile", func(r chi.Router) {
r.Get("/", s.handleGetProfile) r.Get("/", s.handleGetProfile)
r.Put("/", s.handleUpdateProfile) r.Put("/", s.handleUpdateProfile)
@@ -97,6 +205,7 @@ func (s *Server) Router() http.Handler {
r.Get("/progression/{kindID}", s.handleProgression) r.Get("/progression/{kindID}", s.handleProgression)
}) })
}) })
})
return r return r
} }
@@ -137,25 +246,25 @@ func writeError(w http.ResponseWriter, status int, msg string) {
} }
// backgroundSync runs fn in a goroutine with a fresh context, guarded so // backgroundSync runs fn in a goroutine with a fresh context, guarded so
// only one sync operation runs at a time. Returns false if one is already // only one sync operation per userID runs at a time. Returns false if one
// in progress. // is already in progress for that user.
func (s *Server) backgroundSync(fn func(ctx context.Context) error) bool { func (s *Server) backgroundSync(userID int64, fn func(ctx context.Context) error) bool {
s.mu.Lock() s.mu.Lock()
if s.syncRunning { if s.userSyncRunning[userID] {
s.mu.Unlock() s.mu.Unlock()
return false return false
} }
s.syncRunning = true s.userSyncRunning[userID] = true
s.mu.Unlock() s.mu.Unlock()
go func() { go func() {
defer func() { defer func() {
s.mu.Lock() s.mu.Lock()
s.syncRunning = false s.userSyncRunning[userID] = false
s.mu.Unlock() s.mu.Unlock()
}() }()
if err := fn(context.Background()); err != nil { if err := fn(context.Background()); err != nil {
log.Printf("api: background sync error: %v", err) log.Printf("api: background sync error (user %d): %v", userID, err)
} }
}() }()
return true return true

View File

@@ -4,11 +4,27 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"path/filepath"
"testing" "testing"
authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
) )
func TestSetup_ProvisionsNewUserWithDisplayName(t *testing.T) { func TestSetup_ProvisionsNewUserWithDisplayName(t *testing.T) {
s, db := newTestServer(t) // This test specifically needs an *unprovisioned* session, unlike every
// other test in this package -- build the server without the
// newTestServer helper's automatic ProvisionUser call.
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
m := &mock.Client{}
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
router := s.Router() router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/session/me", nil) rec := doJSON(t, router, http.MethodGet, "/api/session/me", nil)
@@ -39,7 +55,14 @@ func TestSetup_ProvisionsNewUserWithDisplayName(t *testing.T) {
} }
func TestSetup_RejectsEmptyDisplayName(t *testing.T) { func TestSetup_RejectsEmptyDisplayName(t *testing.T) {
s, _ := newTestServer(t) db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
m := &mock.Client{}
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": ""}) rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": ""})
if rec.Code != http.StatusBadRequest { if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code) t.Fatalf("status = %d, want 400", rec.Code)
@@ -47,10 +70,7 @@ func TestSetup_RejectsEmptyDisplayName(t *testing.T) {
} }
func TestSetup_RejectsWhenAlreadyProvisioned(t *testing.T) { func TestSetup_RejectsWhenAlreadyProvisioned(t *testing.T) {
s, db := newTestServer(t) s, _ := newTestServer(t)
if _, err := db.ProvisionUser(newCtx(), "test-user", "Already Here"); err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": "Someone Else"}) rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": "Someone Else"})
if rec.Code != http.StatusConflict { if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String()) t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())