Files
minstrel/internal/db/dbq/gc.sql.go
T
bvandeusen 258bc1f75c
test-go / test (push) Successful in 29s
test-go / integration (push) Failing after 11m57s
feat(server): drift audit batch 7 — periodic GC worker for 5 lifecycle gaps
New `internal/gc` package with a single Worker that runs all five
lifecycle / retention sweeps from the 2026-06-02 drift audit on a
1-hour tick. Each sweep is small, idempotent (re-running on
already-clean rows is a no-op), and logs its affected-row count.

Sweeps (Scribe parent #552):

- **#566** GcCloseStalePlayEvents — play_events rows opened > 24h
  ago that never got a play_ended (client crash, network drop).
  Synthesizes ended_at from duration_played_ms when known, falls
  back to now() so the row stops looking "open" to downstream
  filters (ended_at IS NULL).

- **#565** GcClosePlaySessionsWithNoRecentEvents — play_sessions
  with last_event_at older than 6h get ended_at = last_event_at
  ("user moved on"); empty sessions older than 1h get closed
  too (stale handshakes from clients that never recorded a play).
  The audit caught that the column was added but never populated
  by any writer — every session row was "open" forever, breaking
  downstream dedup queries that assume closed semantics.

- **#567** GcExpireScrobbleQueueFailedRows — drops scrobble_queue
  rows in status='failed' older than 14 days. The worker stops
  retrying after maxAttempts so these otherwise accumulate
  forever on a persistent ListenBrainz outage / revoked token.

- **#574** GcResetStuckSystemPlaylistRuns — flips
  system_playlist_runs.in_flight back to false on rows whose
  last_run_at is older than 10 minutes. Catches goroutine-panic
  wedges where the generator died 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 would
  otherwise deadlock all future regens for that user. Records
  "stuck-row auto-reset by gc" in last_error so the operator can
  tell auto-reset from a recent real failure.

- **#575** GcDeleteExpiredPasswordResets — deletes expired
  password_resets rows. Unused expired rows go after a 1h grace
  (gives the operator time to debug an active reset attempt);
  used rows are kept 7 days for audit.

Wiring:
- main.go `go gcWorker.Run(ctx)` alongside the other periodic
  workers (scrobble, similarity, lidarr).
- tickOnce fires once at start so a freshly-deployed server does
  its initial sweep without waiting a full tick, matching the
  scrobble worker pattern.
- Errors per sweep are logged but do NOT abort the remaining
  ones — a transient pgx error from one query shouldn't prevent
  the others from running.

Tests:
- 4 integration tests, one per UPDATE/DELETE sweep, that seed
  rows-to-sweep + rows-to-leave-alone and assert the right rows
  changed state. Skip unless MINSTREL_TEST_DATABASE_URL is set
  (mirrors the api package pattern).
- Empty-tables no-op smoke test.
- Run() cancellation honoured (no spinning goroutine at
  test-runner exit).

That's all five remaining server-side lifecycle findings from the
audit. The Android LOCAL_USER_ID hardcode (#576) is a separate
refactor that needs auth-store wiring and stays in the queue.
2026-06-02 18:32:22 -04:00

127 lines
4.7 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: gc.sql
package dbq
import (
"context"
)
const gcClosePlaySessionsWithNoRecentEvents = `-- name: GcClosePlaySessionsWithNoRecentEvents :execrows
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')
)
`
// #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.
func (q *Queries) GcClosePlaySessionsWithNoRecentEvents(ctx context.Context) (int64, error) {
result, err := q.db.Exec(ctx, gcClosePlaySessionsWithNoRecentEvents)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const gcCloseStalePlayEvents = `-- name: GcCloseStalePlayEvents :execrows
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'
`
// 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).
// #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).
func (q *Queries) GcCloseStalePlayEvents(ctx context.Context) (int64, error) {
result, err := q.db.Exec(ctx, gcCloseStalePlayEvents)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const gcDeleteExpiredPasswordResets = `-- name: GcDeleteExpiredPasswordResets :execrows
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')
`
// #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.
func (q *Queries) GcDeleteExpiredPasswordResets(ctx context.Context) (int64, error) {
result, err := q.db.Exec(ctx, gcDeleteExpiredPasswordResets)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const gcExpireScrobbleQueueFailedRows = `-- name: GcExpireScrobbleQueueFailedRows :execrows
DELETE FROM scrobble_queue
WHERE status = 'failed'
AND enqueued_at < now() - INTERVAL '14 days'
`
// #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.).
func (q *Queries) GcExpireScrobbleQueueFailedRows(ctx context.Context) (int64, error) {
result, err := q.db.Exec(ctx, gcExpireScrobbleQueueFailedRows)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const gcResetStuckSystemPlaylistRuns = `-- name: GcResetStuckSystemPlaylistRuns :execrows
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'
`
// #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).
func (q *Queries) GcResetStuckSystemPlaylistRuns(ctx context.Context) (int64, error) {
result, err := q.db.Exec(ctx, gcResetStuckSystemPlaylistRuns)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}