Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,11 @@ jobs:
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
# Keep CI on the patched toolchain used by the Docker build. The
# go.mod language version remains 1.25 for source compatibility;
# Keep CI on the patched Go 1.25 toolchain used by the Docker build.
# The go.mod language version remains 1.25 for source compatibility;
# pin the patch version so a newly published standard-library
# vulnerability cannot silently select an older toolchain.
go-version: '1.26.6'
go-version: '1.25.13'
cache: true

- name: Run race detector
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1.26@sha256:ecfaec9ed6d810b56388c508f4121597bfbba70d41a6dfeee4d8cad5f295fc32
FROM golang:1.26.6-alpine@sha256:af8d6740070b8906d12eae1c3e3ea0957fb63f492051ea05e354c38ef9fe88df AS dependencies
FROM golang:1.25.13-alpine@sha256:844b27705f54e73773e0f9bc3c780633b9d7f4b4831bf35cdad02a81a4c80bd0 AS dependencies
WORKDIR /src
RUN apk add --no-cache ca-certificates tzdata
COPY go.mod go.sum* ./
Expand Down
8 changes: 8 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ tooling-check:
echo "unpinned container helper reference found" >&2; \
exit 1; \
fi
@docker_go=$$(sed -n 's/^FROM golang:\([0-9.]*\)-alpine.*/\1/p' Dockerfile | head -1); \
ci_go=$$(sed -n "s/.*go-version: '\([^']*\)'.*/\1/p" .github/workflows/docker.yml | head -1); \
module_go=$$(sed -n 's/^go //p' go.mod | cut -d. -f1-2); \
docker_line=$$(printf '%s' "$$docker_go" | cut -d. -f1-2); \
if [ -z "$$docker_go" ] || [ -z "$$ci_go" ] || [ -z "$$module_go" ] || [ "$$docker_go" != "$$ci_go" ] || [ "$$docker_line" != "$$module_go" ]; then \
echo "Go toolchain drift: module=$$module_go Docker=$$docker_go CI=$$ci_go" >&2; \
exit 1; \
fi

lint:
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 run ./...
Expand Down
37 changes: 30 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ releases without creating releases or notifications.
Use the moon/sun button in the header to switch between light and dark mode;
your choice is remembered in the browser.
The running application version and project repository are available in the
footer. The current release is `v0.42.0`; release images display the injected
footer. The current release is `v0.43.0`; release images display the injected
semantic version while local builds identify themselves as `dev`. Operational
timestamps are stored
in UTC and rendered in the configured system timezone; existing databases are
Expand All @@ -57,10 +57,15 @@ error when a data lookup fails, while the detailed cause remains in structured
logs. Static assets use immutable, version-stamped URLs and continue to serve
their unversioned paths for compatibility.

The v0.42.0 reliability-guardrails release routes production SQLite writes
through a bounded busy/locked retry path, rejects malformed operational
timestamps instead of silently showing zero values, and preflights database
paths and MusicBrainz contact input before startup. The v0.40.0 operations
The v0.43.0 retention-governance release adds an administrator dry-run and
explicit cleanup action for bounded operational state. Notification events,
delivery rows, inbox state, blocked work, and delivery-attempt audit records
are retained indefinitely; only expired sessions/tokens, old login-attempt
records, completed transient work, and application logs inside the documented
windows are eligible for cleanup. The v0.42.0 reliability release routes
production SQLite writes through a bounded busy/locked retry path, rejects
malformed operational timestamps instead of silently showing zero values, and
preflights database paths and MusicBrainz contact input before startup. The v0.40.0 operations
release adds strict boolean configuration validation,
polling-cadence-aware provider freshness, and bounded scheduler, provider, and
delivery metrics in the administrator diagnostics report. The v0.39.0
Expand Down Expand Up @@ -212,17 +217,21 @@ GitHub Actions builds and publishes the Docker image to

- `latest` and `main` follow the current `main` branch.
- `sha-<commit>` identifies an exact source revision.
- Pushing a tag such as `v0.42.0` publishes `0.42.0`, `0.42`, and `latest`.
- Pushing a tag such as `v0.43.0` publishes `0.43.0`, `0.43`, and `latest`.

Release images receive their version through the Docker build's `APP_VERSION`
argument. Tag builds inject the semantic tag (without the leading `v`), while
branch and local builds use `dev` or `dev-<short-sha>` so development images are
not confused with a release.

The module targets Go 1.25 for source compatibility; CI and the Docker build
use the pinned patched Go 1.25.13 toolchain so local builds and release images
share the same supported language/runtime line.

Pin a deployment to a release by setting the Compose image before starting:

```console
ARTIST_TRACKARR_IMAGE=ghcr.io/crypt0rr/artist-trackarr:0.42.0 docker compose up -d
ARTIST_TRACKARR_IMAGE=ghcr.io/crypt0rr/artist-trackarr:0.43.0 docker compose up -d
```

## Configuration
Expand Down Expand Up @@ -365,6 +374,20 @@ owner can retry after replacing/recovering the destination. A newly added
destination receives future events only and is not backfilled with historical
notifications.

### Retention and cleanup

The administrator page includes a retention dry-run with the effective policy
before any cleanup is possible. Application logs are kept for seven days;
expired sessions and tokens, old login-attempt records, completed manual-sync
requests, and import jobs are transient operational state and use a 30-day
window (login attempts use a 24-hour safety window). Cleanup is never run from
the web request automatically: an administrator must explicitly confirm it.
Notification events, deliveries, inbox state, blocked deliveries, and
delivery-attempt audit records have no automatic expiry and are not removed by
this action while the account exists (account deletion still removes that
account's private data). Backups should therefore be treated as confidential and retained
according to the household's own recovery policy.

Users can choose whether albums, EPs, singles, announcements, and release-day
reminders should be delivered. Followed artists show their last and next
synchronization times, and **Sync now** queues a rate-limited refresh. Release
Expand Down
3 changes: 2 additions & 1 deletion internal/jobs/jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -571,7 +571,8 @@ func (r *Runner) runMaintenance(ctx context.Context) {
} else if reconciled > 0 {
r.logger.Info("stale delivery attempts reconciled", "attempts", reconciled)
}
if err := r.store.PruneApplicationLogs(ctx, time.Now().UTC().Add(-7*24*time.Hour)); err != nil {
policy := r.store.RetentionPolicy()
if err := r.store.PruneApplicationLogs(ctx, time.Now().UTC().Add(-time.Duration(policy.ApplicationLogsDays)*24*time.Hour)); err != nil {
r.logger.Debug("application log pruning failed", "error", err)
}
if maintenance, err := r.store.PruneExpiredState(ctx, time.Now().UTC()); err != nil {
Expand Down
3 changes: 2 additions & 1 deletion internal/store/imports.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,9 @@ type MaintenanceStats struct {
}

func (s *Store) PruneExpiredState(ctx context.Context, now time.Time) (MaintenanceStats, error) {
policy := s.retention()
cutoff := now.Add(-24 * time.Hour)
manualCutoff := now.Add(-30 * 24 * time.Hour)
manualCutoff := now.Add(-time.Duration(policy.TransientStateDays) * 24 * time.Hour)
var stats MaintenanceStats
statements := []struct {
query string
Expand Down
138 changes: 138 additions & 0 deletions internal/store/retention.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package store

import (
"context"
"database/sql"
"time"
)

// RetentionReport is a safe, read-only dry run for the administrator. The
// history counters are informational; only the two operational candidate
// counts can be affected by CleanupRetention.
type RetentionReport struct {
CheckedAt time.Time
Policy RetentionPolicy
NotificationEvents int64
Deliveries int64
DeliveryAttempts int64
ApplicationLogs int64
OldestNotificationEvent *time.Time
OldestDelivery *time.Time
OldestDeliveryAttempt *time.Time
OldestApplicationLog *time.Time
PrunableApplicationLogs int64
PrunableTransientSessions int64
PrunableAuthTokens int64
PrunableLoginAttempts int64
PrunableManualSyncs int64
PrunableImportJobs int64
}

// RetentionCleanupStats contains only rows removed from transient state. It
// deliberately has no notification or delivery fields so callers cannot
// accidentally imply that user-facing history was purged.
type RetentionCleanupStats struct {
ApplicationLogs int64
Sessions int64
AuthTokens int64
LoginAttempts int64
ManualSyncs int64
ImportJobs int64
}

func (s *Store) RetentionReport(ctx context.Context, now time.Time) (RetentionReport, error) {
if now.IsZero() {
now = time.Now().UTC()
}
policy := s.retention()
report := RetentionReport{CheckedAt: now.UTC(), Policy: policy}

queries := []struct {
query string
count *int64
oldest **time.Time
name string
}{
{`SELECT COUNT(*), MIN(created_at) FROM notification_events`, &report.NotificationEvents, &report.OldestNotificationEvent, "notification event"},
{`SELECT COUNT(*), MIN(COALESCE(sent_at,next_attempt_at)) FROM deliveries`, &report.Deliveries, &report.OldestDelivery, "delivery"},
{`SELECT COUNT(*), MIN(started_at) FROM delivery_attempts`, &report.DeliveryAttempts, &report.OldestDeliveryAttempt, "delivery attempt"},
{`SELECT COUNT(*), MIN(created_at) FROM application_logs`, &report.ApplicationLogs, &report.OldestApplicationLog, "application log"},
}
for _, item := range queries {
var oldest sql.NullString
if err := s.readerDB().QueryRowContext(ctx, item.query).Scan(item.count, &oldest); err != nil {
return RetentionReport{}, err
}
parsed, err := parseStoredNullableTime(oldest, item.name+" oldest timestamp")
if err != nil {
return RetentionReport{}, err
}
*item.oldest = parsed
}

logCutoff := timeText(report.CheckedAt.Add(-time.Duration(policy.ApplicationLogsDays) * 24 * time.Hour))
transientCutoff := timeText(report.CheckedAt.Add(-time.Duration(policy.TransientStateDays) * 24 * time.Hour))
if err := s.readerDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM application_logs WHERE created_at < ?`, logCutoff).Scan(&report.PrunableApplicationLogs); err != nil {
return RetentionReport{}, err
}
if err := s.readerDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM sessions WHERE expires_at < ?`, timeText(report.CheckedAt)).Scan(&report.PrunableTransientSessions); err != nil {
return RetentionReport{}, err
}
if err := s.readerDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM auth_tokens WHERE expires_at < ? OR used_at IS NOT NULL`, timeText(report.CheckedAt)).Scan(&report.PrunableAuthTokens); err != nil {
return RetentionReport{}, err
}
if err := s.readerDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM login_attempts WHERE first_at < ?`, timeText(report.CheckedAt.Add(-24*time.Hour))).Scan(&report.PrunableLoginAttempts); err != nil {
return RetentionReport{}, err
}
if err := s.readerDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM manual_sync_requests WHERE status IN ('completed','failed') AND finished_at IS NOT NULL AND finished_at < ?`, transientCutoff).Scan(&report.PrunableManualSyncs); err != nil {
return RetentionReport{}, err
}
if err := s.readerDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM import_jobs WHERE created_at < ?`, transientCutoff).Scan(&report.PrunableImportJobs); err != nil {
return RetentionReport{}, err
}
return report, nil
}

// CleanupRetention performs the same bounded cleanup as scheduled
// maintenance, but only when an administrator explicitly requests it. It
// never deletes notification events, deliveries, inbox state, blocked rows,
// or delivery-attempt audit records.
func (s *Store) CleanupRetention(ctx context.Context, now time.Time) (RetentionCleanupStats, error) {
if now.IsZero() {
now = time.Now().UTC()
}
policy := s.retention()
stats := RetentionCleanupStats{}
tx, err := s.beginWriteTx(ctx)
if err != nil {
return stats, err
}
defer func() { _ = tx.Rollback() }()
statements := []struct {
query string
args []any
out *int64
}{
{`DELETE FROM application_logs WHERE created_at < ?`, []any{timeText(now.Add(-time.Duration(policy.ApplicationLogsDays) * 24 * time.Hour))}, &stats.ApplicationLogs},
{`DELETE FROM sessions WHERE expires_at < ?`, []any{timeText(now)}, &stats.Sessions},
{`DELETE FROM auth_tokens WHERE expires_at < ? OR used_at IS NOT NULL`, []any{timeText(now)}, &stats.AuthTokens},
{`DELETE FROM login_attempts WHERE first_at < ?`, []any{timeText(now.Add(-24 * time.Hour))}, &stats.LoginAttempts},
{`DELETE FROM manual_sync_requests WHERE status IN ('completed','failed') AND finished_at IS NOT NULL AND finished_at < ?`, []any{timeText(now.Add(-time.Duration(policy.TransientStateDays) * 24 * time.Hour))}, &stats.ManualSyncs},
{`DELETE FROM import_jobs WHERE created_at < ?`, []any{timeText(now.Add(-time.Duration(policy.TransientStateDays) * 24 * time.Hour))}, &stats.ImportJobs},
}
for _, statement := range statements {
result, err := tx.ExecContext(ctx, statement.query, statement.args...)
if err != nil {
return RetentionCleanupStats{}, err
}
count, err := result.RowsAffected()
if err != nil {
return RetentionCleanupStats{}, err
}
*statement.out = count
}
if err := tx.Commit(); err != nil {
return RetentionCleanupStats{}, err
}
return stats, nil
}
110 changes: 110 additions & 0 deletions internal/store/retention_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package store

import (
"context"
"testing"
"time"
)

func TestRetentionReportAndCleanupPreserveNotificationHistory(t *testing.T) {
ctx := context.Background()
s := testStore(t)
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
old := timeText(now.Add(-10 * 24 * time.Hour))
if _, err := s.DB.ExecContext(ctx, `INSERT INTO application_logs(created_at,level,message,attributes_json) VALUES(?,?,?,?)`, old, "INFO", "old", "[]"); err != nil {
t.Fatal(err)
}
if _, err := s.DB.ExecContext(ctx, `INSERT INTO application_logs(created_at,level,message,attributes_json) VALUES(?,?,?,?)`, timeText(now), "INFO", "current", "[]"); err != nil {
t.Fatal(err)
}
userID, err := s.CreateUser(ctx, "retention@example.com", "hash", "member", "UTC", "retention")
if err != nil {
t.Fatal(err)
}
if _, err := s.DB.ExecContext(ctx, `INSERT INTO sessions(token_hash,user_id,csrf_token,expires_at,created_at) VALUES(?,?,?,?,?)`, []byte("expired"), userID, "csrf", old, old); err != nil {
t.Fatal(err)
}
if _, err := s.DB.ExecContext(ctx, `INSERT INTO login_attempts(key_hash,failures,first_at) VALUES(?,?,?)`, []byte("attempt"), 1, old); err != nil {
t.Fatal(err)
}

report, err := s.RetentionReport(ctx, now)
if err != nil {
t.Fatal(err)
}
if report.Policy.ApplicationLogsDays != 7 || report.Policy.TransientStateDays != 30 {
t.Fatalf("unexpected policy: %#v", report.Policy)
}
if report.PrunableApplicationLogs != 1 || report.PrunableTransientSessions != 1 || report.PrunableLoginAttempts != 1 {
t.Fatalf("unexpected dry-run counts: %#v", report)
}

stats, err := s.CleanupRetention(ctx, now)
if err != nil {
t.Fatal(err)
}
if stats.ApplicationLogs != 1 || stats.Sessions != 1 || stats.LoginAttempts != 1 {
t.Fatalf("unexpected cleanup stats: %#v", stats)
}
var logs int
if err := s.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM application_logs`).Scan(&logs); err != nil {
t.Fatal(err)
}
if logs != 1 {
t.Fatalf("application logs=%d, want current row only", logs)
}
var sessions int
if err := s.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM sessions`).Scan(&sessions); err != nil {
t.Fatal(err)
}
if sessions != 0 {
t.Fatalf("sessions=%d, want expired session removed", sessions)
}
}

func TestRetentionCleanupNeverDeletesDeliveryHistory(t *testing.T) {
ctx := context.Background()
s := testStore(t)
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
userID, err := s.CreateUser(ctx, "history@example.com", "hash", "member", "UTC", "history")
if err != nil {
t.Fatal(err)
}
artist, err := s.UpsertArtist(ctx, Artist{MBID: "retention-history-artist", Name: "Retention History"})
if err != nil {
t.Fatal(err)
}
if _, err := s.Follow(ctx, userID, artist.ID); err != nil {
t.Fatal(err)
}
result, err := s.DB.ExecContext(ctx, `INSERT INTO release_groups
(mbid,artist_id,title,primary_type,secondary_types,first_release_date,date_precision,musicbrainz_url,source,first_observed_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`, "mb-retention", artist.ID, "Retained release", "Album", "[]", "2026-01-01", 3, "https://musicbrainz.org/release-group/mb-retention", "musicbrainz", timeText(now.Add(-365*24*time.Hour)), timeText(now.Add(-365*24*time.Hour)))
if err != nil {
t.Fatal(err)
}
releaseID, err := result.LastInsertId()
if err != nil {
t.Fatal(err)
}
if _, err := s.DB.ExecContext(ctx, `INSERT INTO notification_events(user_id,release_group_id,event_type,title,body,created_at) VALUES(?,?,?,?,?,?)`, userID, releaseID, "announcement", "Retained release", "body", timeText(now.Add(-365*24*time.Hour))); err != nil {
t.Fatal(err)
}
report, err := s.RetentionReport(ctx, now)
if err != nil {
t.Fatal(err)
}
if report.NotificationEvents != 1 {
t.Fatalf("notification events=%d, want 1", report.NotificationEvents)
}
if _, err := s.CleanupRetention(ctx, now); err != nil {
t.Fatal(err)
}
var events int
if err := s.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM notification_events WHERE user_id=? AND release_group_id=?`, userID, releaseID).Scan(&events); err != nil {
t.Fatal(err)
}
if events != 1 {
t.Fatalf("notification events after cleanup=%d, want 1", events)
}
}
Loading