Files
minstrel/internal/db/queries/gc.sql
T
bvandeusenandClaude Opus 5.5 755f997b0d feat(auth): sessions expire server-side; password change and reset end other sessions (M462 #4978)
Sessions had no server-side expiry: only the web cookie's 30-day Max-Age
limited them, and a bearer token (Android) lived until revoked by hand.
GetSessionByTokenHash and ListSessionsForUser now ignore sessions idle for
30 days or older than a year, and the GC worker deletes them hourly.

A password change was a plain UPDATE, so a session opened with the old
password survived it. Now:
- self-service change signs out every other device and keeps this one;
- reset by email ends every session the account has;
- an admin reset ends the target's sessions (keeping the admin's own when
  they reset themselves).

The success copy on web and Android says the other devices were signed out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:32:39 -04:00

78 lines
3.6 KiB
SQL

-- 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');
-- name: GcDeleteExpiredSessions :execrows
-- Sessions past their idle (30 days) or absolute (1 year) limit. They already
-- fail auth through GetSessionByTokenHash's filter; this only clears the rows.
DELETE FROM sessions
WHERE last_seen_at <= now() - interval '30 days'
OR created_at <= now() - interval '365 days';