Files
minstrel/internal/sync/compactor.go
T
bvandeusenandClaude Opus 5.5 598abeb8cf
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m44s
release / android (push) Successful in 4m54s
release / Build signed APK (push) Successful in 5m10s
release / integration (push) Successful in 5m19s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
fix: a fresh app install syncs the whole library, not the last 30 days of changes (#6778)
The change feed dropped every row older than 30 days, and an app syncing
from cursor 0 builds its library from the feed, so a fresh install, cleared
data or a 410 reset got only what had changed lately.

The compactor now keeps each entity's latest change however old. It drops a
row once a newer one for the same entity exists, and a delete once it is 30
days old, raising a stored mark (library_changes_trimmed) past it; an app
whose cursor is below the mark is sent back to 0, as before. Migration 0101
adds the index the compactor needs, seeds the mark where the old compactor
left off, and gives every artist, album and track whose latest row is not an
upsert a fresh one, so the rows the old compactor dropped come back. The
backup carries the mark with the feed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:07:42 -05:00

98 lines
3.3 KiB
Go

// Periodic compactor for the library_changes log. Every mutation in the
// library writes one row, so the log grows without bound unless trimmed. This
// worker trims it daily, keeping each entity's latest row:
//
// - A row a newer row for the same entity has superseded goes at once. An
// app reading from any cursor still meets the newer row.
// - A delete goes once it is CompactorRetention old, and raises the trimmed
// mark (library_changes_trimmed) past its id. An app whose cursor is below
// the mark may have missed it, so /api/library/sync answers 410 and the
// app syncs again from 0.
// - The latest upsert of an entity that still exists stays, however old. An
// app syncing from 0 (a fresh install, or after a 410) builds its whole
// library from the log, so it must hold every artist, album and track.
package sync
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// CompactorRetention is how long a delete stays in the log. An app that
// checks in at least once in that window never misses one; one away longer
// is sent back to 0. Mutable for tests.
var CompactorRetention = 30 * 24 * time.Hour
// CompactorInterval is how often the worker checks. Daily is enough —
// the table grows on the order of a few thousand rows/day for an
// active library, well below a size where intra-day trimming matters.
var CompactorInterval = 24 * time.Hour
// Compactor is a background worker that trims the library_changes log down
// to each entity's latest change.
type Compactor struct {
pool *pgxpool.Pool
logger *slog.Logger
interval time.Duration
}
// NewCompactor builds a compactor with production defaults.
func NewCompactor(pool *pgxpool.Pool, logger *slog.Logger) *Compactor {
return &Compactor{
pool: pool,
logger: logger,
interval: CompactorInterval,
}
}
// Run blocks until ctx is cancelled, ticking every c.interval.
// Fires one tick immediately on startup so a long-running process
// that hasn't been compacted in a while catches up.
func (c *Compactor) Run(ctx context.Context) {
c.tickOnce(ctx)
t := time.NewTicker(c.interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
c.tickOnce(ctx)
}
}
}
// tickOnce trims the log once and logs what went. Errors are logged at WARN;
// the loop never stops on a transient DB failure.
func (c *Compactor) tickOnce(ctx context.Context) {
if err := Compact(ctx, c.pool, time.Now().Add(-CompactorRetention), c.logger); err != nil {
c.logger.Warn("library_changes compactor: trim failed", "err", err)
}
}
// Compact drops every superseded row, then every delete older than cutoff.
func Compact(ctx context.Context, db dbq.DBTX, cutoff time.Time, logger *slog.Logger) error {
q := dbq.New(db)
superseded, err := q.DeleteSupersededLibraryChanges(ctx)
if err != nil {
return fmt.Errorf("drop superseded changes: %w", err)
}
deletes, err := q.DeleteOldLibraryDeletes(ctx, pgtype.Timestamptz{Time: cutoff, Valid: true})
if err != nil {
return fmt.Errorf("drop old deletes: %w", err)
}
if superseded > 0 || deletes > 0 {
logger.Info("library_changes compactor: trimmed rows",
"superseded", superseded, "old_deletes", deletes, "deletes_older_than", CompactorRetention)
}
return nil
}