-- Background garbage-collector / lifecycle queries. All five address -- drift findings from the 2026-06-02 audit (Scribe parent #552): -- #565 #566 #567 #574 #575. Sweeper runs on a long tick (default 1h) -- so per-query cost is amortised; each is idempotent (re-running on -- already-closed/-deleted rows is a no-op). -- name: GcCloseStalePlayEvents :execrows -- #566: play_events rows opened more than 24h ago that never got a -- play_ended. Client crashed mid-track, network dropped, etc. We -- synthesize ended_at = started_at + duration_played_ms when present, -- otherwise leave duration_played_ms null and stamp ended_at = now() -- so the row stops looking "open" for downstream queries that filter -- ended_at IS NULL. Doesn't touch was_skipped (we genuinely don't -- know if the user skipped). UPDATE play_events SET ended_at = COALESCE( started_at + (duration_played_ms * INTERVAL '1 millisecond'), now() ) WHERE ended_at IS NULL AND started_at < now() - INTERVAL '24 hours'; -- name: GcClosePlaySessionsWithNoRecentEvents :execrows -- #565: play_sessions.ended_at was added but never populated by any -- writer. Close sessions whose last_event_at is older than 6h — -- treating that as "user moved on" the same way audio_service does -- after grace periods. Sessions with NO events (track_count = 0) -- older than 1h are also closed (stale handshakes from clients that -- never recorded a play). ended_at is set to last_event_at so the -- session's duration reads naturally. UPDATE play_sessions SET ended_at = COALESCE(last_event_at, started_at) WHERE ended_at IS NULL AND ( (track_count > 0 AND last_event_at < now() - INTERVAL '6 hours') OR (track_count = 0 AND started_at < now() - INTERVAL '1 hour') ); -- name: GcExpireScrobbleQueueFailedRows :execrows -- #567: scrobble_queue rows that have been in status='failed' for -- more than 14 days. The worker stops retrying after maxAttempts; -- failed rows accumulate forever otherwise. CASCADE from play_events -- already drops the row when the underlying event is deleted, so this -- only handles persistent failures (token revoked, etc.). DELETE FROM scrobble_queue WHERE status = 'failed' AND enqueued_at < now() - INTERVAL '14 days'; -- name: GcResetStuckSystemPlaylistRuns :execrows -- #574: system_playlist_runs.in_flight = true can wedge on a -- goroutine panic between SET in_flight=true and SET in_flight=false. -- The duplicate-prevention check refuses to start a fresh regen while -- in_flight, so a stuck row blocks all future regens for that user. -- Reset rows where last_run_at is older than 10 minutes (regens -- shouldn't take that long). UPDATE system_playlist_runs SET in_flight = false, last_error = COALESCE(last_error, 'stuck-row auto-reset by gc') WHERE in_flight = true AND last_run_at < now() - INTERVAL '10 minutes'; -- name: GcDeleteExpiredPasswordResets :execrows -- #575: password_resets accumulates expired + used rows forever. The -- validation path already rejects them; this just keeps the table -- from growing unbounded. Used rows are kept for 7 days for audit; -- unused expired rows go immediately. DELETE FROM password_resets WHERE (used_at IS NOT NULL AND used_at < now() - INTERVAL '7 days') OR (used_at IS NULL AND expires_at < now() - INTERVAL '1 hour');