Story #326: Propagate sync errors and fix sync metrics - #358
Conversation
Implements #326. - syncHistory/syncPlaylists now aggregate per-provider failures with errors.Join instead of swallowing them via continue; Sync() and SyncProvider() surface the aggregated error while preserving partial success (a failing provider no longer hides behind success=true, and healthy providers still persist their data). - metric.sync now emits true per-provider listens_synced, playlists_synced, duration_ms, success, and error per ADR-0019 and SPEC observability REQ-BG-003, instead of one event per provider carrying aggregate totals for all providers. - Background tick loops in cmd/server/main.go use atomic error counters and join each tick's per-user goroutines (per-tick WaitGroup) before emitting metric.background_tick, so the error count is stable and duration_ms measures actual tick work rather than goroutine spawn time (REQ-BG-001, REQ-BG-002). - The "Sync Failed" notification path in internal/handlers/spotify_auth.go (and the equivalent error branches in auth, lastfm_auth, preferences, and recent handlers) is now reachable because Sync() returns real errors. Governing: ADR-0019, ADR-0020, SPEC observability, SPEC listen-playlist-sync. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Independent review — verdict: APPROVE (no blocking defects)Verified adversarially in a detached worktree of the PR head ( What I verified holds
Findings (all non-blocking)MEDIUM — backoff-skip emits LOW — stale spec sequence diagram. LOW — aggregate undercount on mid-fetch failure. When LOW — one failure now logs at Error 3-4 times per tick. NOTE — tick-join cadence trade-off (and #342 overlap). The per-tick join means the next sync tick cannot start until the slowest user finishes; NOTE — factory-stage failures remain invisible. 🤖 Posted on behalf of |
… phases (#34) * Propagate sync errors from provider phases and surface SyncProvider failures Ports joestump#358, implements joestump#326. Gap-fill port: the fork already had per-provider metric.sync stats, errors.Join in Sync(), and atomic error counters with per-tick WaitGroup joins in cmd/server/main.go (runPerUserTick). What was missing: - syncHistory/syncPlaylists always returned nil, so the errors.Join in Sync() never saw a provider failure. Per-provider failures are now aggregated with errors.Join (wrapped with the provider name) while partial success is preserved; backoff skips remain throttling, not failures. - SyncProvider() logged phase errors but returned nil, leaving the manual "Sync Failed" toast paths in the preferences/auth handlers unreachable. It now returns the aggregated error. - Listens persisted by batches before a mid-sync failure now count in metric.sync listens_synced and the returned total (upstream recorded them in the metric but dropped them from the total). - persistPlaylists failures now mark the provider failed in metric.sync, join the aggregated error, and log a sync_failed event instead of a "completed" event (upstream still logged EventTypeSyncCompleted on persistence failure). - Sync() logs "full sync completed with errors" and wraps the joined error; success path unchanged. - Tests: flipped the stale "Sync swallows per-provider errors" assertions, added the issue joestump#326 acceptance tests (all-providers-fail and partial-failure with metric.sync log capture) and a SyncProvider error-surfacing test. Governing: ADR-0019, ADR-0020, SPEC observability REQ "BG-003", SPEC listen-playlist-sync REQ-SYNC-011. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Xyerp7w1SH5vG48z93DxN * Mark persist-failure branches as forward-looking Review follow-up for PR #34: persistListens and persistPlaylists currently handle every internal DB failure with Warn + continue and unconditionally return nil, so the persist-error handling branches in syncHistory/syncPlaylists cannot fire yet. Comment both branches as forward-looking; making the persist layer aggregate and return real errors is tracked in #35. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Xyerp7w1SH5vG48z93DxN --------- Co-authored-by: Claude <noreply@anthropic.com>
What / Why
Part of epic #317. Sync failures were structurally invisible:
syncHistory/syncPlaylistsended withreturn allAdded, niland every failure path wascontinue, soSync()'s error handling, themetric.syncsuccessflag, and the "Sync Failed" notification atinternal/handlers/spotify_auth.go:279were all dead code. Separately, the background-tick error counters raced and always logged stale/zero values, andmetric.background_tick/metric.syncdurations measured spawn time and aggregate totals respectively.Changes
internal/services/sync.go) —syncHistoryandsyncPlaylistsaggregate per-provider failures witherrors.Joinand return them.Sync()andSyncProvider()surface the aggregated error. Partial success is preserved: one provider failing does not stop the others, and successful providers' data is persisted before the error is returned. Backoff skips are deliberate throttling and are not treated as new failures (so the post-failure "skip" sync returns nil, matching existing backoff tests). Playlist persistence failures are also aggregated (defensive;persistPlaylistscurrently always returns nil error).metric.sync(REQ-BG-003) —Sync()now tracks aproviderSyncOutcomeper active provider (listens, playlists, duration, errors across both phases) and emits onemetric.syncper provider carrying that provider's own values and success flag, instead of one event per provider each carrying aggregate totals for all providers.cmd/server/main.go) —syncErrors/metadataErrors/plSyncErrorsare nowatomic.Int64, incremented from per-user goroutines, and each tick joins its own goroutines via a per-ticksync.WaitGroupbefore loggingmetric.background_tick. The shared shutdown WaitGroup usage is untouched (per-tick WaitGroup is additive; Fix graceful shutdown ordering and background-job cancellation #342's shutdown restructure is unaffected).metric.background_tickduration_ms— because the metric is emitted after the per-tick join, it now measures actual tick work instead of goroutine spawn time (REQ-BG-001/002). Side effect: ticks no longer overlap within a loop, matching the metadata loop's existing synchronous precedent (missed ticker fires are dropped bytime.Ticker)."Sync Failed" notification path (traced)
internal/handlers/spotify_auth.go:279publishes the "Sync Failed" notification whenh.Syncer.Sync()returns an error. That branch was unreachable;TestSyncer_AllProvidersFail_SyncReturnsErrorAndMetricSyncFailsnow provesSync()returns an error when the provider fails immediately, making the notification path live. The equivalent previously-dead error branches inauth.go,lastfm_auth.go,preferences.go(manual sync tasks viaSyncProvider/SyncRecentListens/SyncPlaylists), andrecent.goare activated the same way.Test evidence
TestSyncer_AllProvidersFail_SyncReturnsErrorAndMetricSyncFails: every provider fails →Sync()returns an error naming each provider, and each provider'smetric.sync(captured viaslog.NewJSONHandler) logssuccess=falsewith a non-empty error.TestSyncer_PartialFailure_SurfacesErrorButPreservesPartialSuccess: failing Spotify + healthy Navidrome → error surfaces naming only Spotify, Navidrome's 2 listens persist, andmetric.syncshowssuccess=false/success=truewith per-providerlistens_synced(attribution fix)."Sync should not surface per-provider errors") to assert errors now surface; backoff-skip second-sync assertions unchanged.go test ./internal/services/... -race— pass;make test(full suite) — green;go vetclean;gofmt -lclean; golangci-lint v1.64.8 (CI-pinned version, run viago run) — clean on changed packages. Local golangci-lint is v2.11.4 and cannot read the repo's v1 config, so the pinned version was used.Governing artifacts
metric.sync/metric.background_tickschemasNo design-doc updates required (the spec already mandated the per-provider semantics this PR implements).
Closes #326
🤖 Posted on behalf of
@joestumpby Claude.