Files
minstrel/internal/library/reconcile_test.go
T
bvandeusen 7ba673ed83
test-go / test (push) Successful in 53s
test-go / integration (push) Successful in 5m2s
fix(library): tell clients when a file goes missing or comes back — #2704
The wire field shipped in 366692a1 was inert. MarkTracksMissing and
ClearTracksMissing are plain UPDATEs, and /api/library/sync is a
change-log feed: a row that never produces a change row is never
re-sent. Clients would have kept their stale copy until an unrelated
edit touched the track or the cursor fell out of the retention window
and forced a full resync -- so the flag existed and nothing ever told
anyone to read it.

Found by checking the consumer set rather than the code: the field was
threaded end to end and every test passed, because none of them asked
the question "how does this reach a client?".

Logged BEFORE the mutation, which is the opposite of the scanner's
log-after-success pattern, and deliberately so. The failure modes are
not symmetric. Log-then-fail-to-mark makes clients re-read a track that
has not changed: one wasted fetch. Mark-then-fail-to-log leaves the mark
with no change row -- and because both statements are idempotent
(missing_since IS NULL / IS NOT NULL guards), the next scan will not
retry the pair, so the client never learns. Permanently. A spurious
re-read is much the cheaper mistake.

Restoring logs too. A file coming back that nobody is told about stays
greyed out on every device until something unrelated touches it, which
would be a worse bug than the one being fixed.

Op is upsert, not delete: the track still exists and keeps its history.
Delete would tell clients to drop the row, which is precisely the design
#2704 rejected when it chose to ship state instead of filtering the feed.

Adds sync.LogChanges alongside LogChange, backed by an unnest batch
insert. Every existing caller mutates one entity, so per-row was right
for them; reconcile can mark a quarter of a library in one sweep, where
a loop would be thousands of round-trips inside an already-slow scan.
2026-08-17 13:04:14 -04:00

401 lines
12 KiB
Go

package library
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// fakeReconciler records what reconcileMissing decided to do, so the guards can
// be tested without a database. The guards are the whole point of this pass —
// they are what stands between an unmounted volume and the library disappearing
// from every mix — so they get tested directly rather than via integration.
type fakeReconciler struct {
rows []dbq.ListTrackPathsForReconcileRow
marked []pgtype.UUID
cleared []pgtype.UUID
listErr error
markErr error
clearErr error
// Change rows the reconcile pass asked the delta sync to emit (#2704).
loggedChanges []dbq.InsertLibraryChangesParams
}
func (f *fakeReconciler) ListTrackPathsForReconcile(context.Context) ([]dbq.ListTrackPathsForReconcileRow, error) {
return f.rows, f.listErr
}
func (f *fakeReconciler) MarkTracksMissing(_ context.Context, ids []pgtype.UUID) (int64, error) {
if f.markErr != nil {
return 0, f.markErr
}
f.marked = append(f.marked, ids...)
return int64(len(ids)), nil
}
func (f *fakeReconciler) InsertLibraryChanges(
_ context.Context, arg dbq.InsertLibraryChangesParams,
) error {
f.loggedChanges = append(f.loggedChanges, arg)
return nil
}
func (f *fakeReconciler) ClearTracksMissing(_ context.Context, ids []pgtype.UUID) (int64, error) {
if f.clearErr != nil {
return 0, f.clearErr
}
f.cleared = append(f.cleared, ids...)
return int64(len(ids)), nil
}
// Compile-time proof the real queries still satisfy what reconcile needs — the
// interface exists to narrow dbq.Queries, not to diverge from it.
var _ trackReconciler = (*dbq.Queries)(nil)
func testUUID(n byte) pgtype.UUID {
var u pgtype.UUID
u.Bytes[15] = n
u.Valid = true
return u
}
func markedAt() pgtype.Timestamptz {
return pgtype.Timestamptz{Valid: true}
}
func row(n byte, path string, missing bool) dbq.ListTrackPathsForReconcileRow {
r := dbq.ListTrackPathsForReconcileRow{ID: testUUID(n), FilePath: path}
if missing {
r.MissingSince = markedAt()
}
return r
}
// populatedRoot returns a directory containing one file, so verifyRootsPresent
// treats it as a real, mounted library root.
func populatedRoot(t *testing.T) string {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "a.mp3"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
return dir
}
func testScanner(t *testing.T, roots ...string) *Scanner {
t.Helper()
return &Scanner{
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
paths: roots,
}
}
func TestReconcileMissing_MarksRowsTheWalkDidNotSee(t *testing.T) {
root := populatedRoot(t)
s := testScanner(t, root)
// 10 rows with 2 absent — 20%, deliberately under missingMarkMaxFraction so
// this exercises marking rather than the cap. (An earlier version of this
// test used 2-of-4 and was really testing the guard by accident.)
rows := make([]dbq.ListTrackPathsForReconcileRow, 0, 10)
seen := map[string]struct{}{}
for i := 0; i < 10; i++ {
p := fmt.Sprintf("/music/track-%02d.mp3", i)
rows = append(rows, row(byte(i), p, false))
if i >= 2 {
seen[p] = struct{}{}
}
}
q := &fakeReconciler{rows: rows}
var stats Stats
if err := s.reconcileMissing(context.Background(), q, seen, &stats); err != nil {
t.Fatalf("reconcile: %v", err)
}
if len(q.marked) != 2 {
t.Fatalf("marked %d rows, want 2", len(q.marked))
}
if q.marked[0] != testUUID(0) || q.marked[1] != testUUID(1) {
t.Errorf("marked the wrong rows: %v", q.marked)
}
if stats.Missing != 2 {
t.Errorf("stats.Missing = %d, want 2", stats.Missing)
}
if len(q.cleared) != 0 {
t.Errorf("cleared %d rows, want 0", len(q.cleared))
}
}
func TestReconcileMissing_ClearsRowsWhoseFileReturned(t *testing.T) {
root := populatedRoot(t)
s := testScanner(t, root)
q := &fakeReconciler{rows: []dbq.ListTrackPathsForReconcileRow{
row(1, "/music/back.mp3", true),
row(2, "/music/still-here.mp3", false),
}}
seen := map[string]struct{}{
"/music/back.mp3": {},
"/music/still-here.mp3": {},
}
var stats Stats
if err := s.reconcileMissing(context.Background(), q, seen, &stats); err != nil {
t.Fatalf("reconcile: %v", err)
}
if len(q.cleared) != 1 || q.cleared[0] != testUUID(1) {
t.Fatalf("cleared = %v, want just row 1", q.cleared)
}
if stats.Restored != 1 {
t.Errorf("stats.Restored = %d, want 1", stats.Restored)
}
if len(q.marked) != 0 {
t.Errorf("marked %d rows, want 0", len(q.marked))
}
}
// An already-marked row must not be re-marked: the timestamp is the "how long
// has this been gone" clock that any future cleanup policy depends on.
func TestReconcileMissing_DoesNotRemarkAlreadyMissingRows(t *testing.T) {
root := populatedRoot(t)
s := testScanner(t, root)
q := &fakeReconciler{rows: []dbq.ListTrackPathsForReconcileRow{
row(1, "/music/long-gone.mp3", true),
row(2, "/music/present.mp3", false),
}}
seen := map[string]struct{}{"/music/present.mp3": {}}
var stats Stats
if err := s.reconcileMissing(context.Background(), q, seen, &stats); err != nil {
t.Fatalf("reconcile: %v", err)
}
if len(q.marked) != 0 {
t.Errorf("re-marked an already-missing row: %v", q.marked)
}
if len(q.cleared) != 0 {
t.Errorf("cleared = %v, want none", q.cleared)
}
}
// The guard that matters most. A half-attached mount makes the walk succeed
// while seeing only part of the library — evidence indistinguishable from a mass
// deletion, so reconcile must refuse rather than guess.
func TestReconcileMissing_RefusesWhenTooMuchWouldBeMarked(t *testing.T) {
root := populatedRoot(t)
s := testScanner(t, root)
rows := make([]dbq.ListTrackPathsForReconcileRow, 0, 100)
seen := map[string]struct{}{}
for i := 0; i < 100; i++ {
p := fmt.Sprintf("/music/track-%03d.mp3", i)
rows = append(rows, row(byte(i), p, false))
// Only 60 of 100 present -> 40% would be marked, over the 25% cap.
if i < 60 {
seen[p] = struct{}{}
}
}
q := &fakeReconciler{rows: rows}
var stats Stats
err := s.reconcileMissing(context.Background(), q, seen, &stats)
if err == nil {
t.Fatal("expected reconcile to refuse, got nil error")
}
if len(q.marked) != 0 {
t.Errorf("marked %d rows despite refusing", len(q.marked))
}
if stats.Missing != 0 {
t.Errorf("stats.Missing = %d, want 0", stats.Missing)
}
}
// Restoring is never the dangerous direction, so it must survive the cap —
// otherwise a library that tripped the cap once could never clear its marks
// even after the volume came back.
func TestReconcileMissing_ClearsEvenWhenMarkCapTrips(t *testing.T) {
root := populatedRoot(t)
s := testScanner(t, root)
rows := []dbq.ListTrackPathsForReconcileRow{row(1, "/music/back.mp3", true)}
seen := map[string]struct{}{"/music/back.mp3": {}}
// Add enough absent rows to blow the cap.
for i := 2; i < 10; i++ {
rows = append(rows, row(byte(i), fmt.Sprintf("/music/absent-%02d.mp3", i), false))
}
q := &fakeReconciler{rows: rows}
var stats Stats
if err := s.reconcileMissing(context.Background(), q, seen, &stats); err == nil {
t.Fatal("expected the mark cap to trip")
}
if len(q.cleared) != 1 {
t.Errorf("cleared %d rows, want 1 — restores must not be blocked by the cap", len(q.cleared))
}
if stats.Restored != 1 {
t.Errorf("stats.Restored = %d, want 1", stats.Restored)
}
}
func TestReconcileMissing_RefusesOnEmptyWalk(t *testing.T) {
root := populatedRoot(t)
s := testScanner(t, root)
q := &fakeReconciler{rows: []dbq.ListTrackPathsForReconcileRow{
row(1, "/music/a.mp3", false),
}}
var stats Stats
if err := s.reconcileMissing(context.Background(), q, map[string]struct{}{}, &stats); err == nil {
t.Fatal("expected refusal when the walk saw no files")
}
if len(q.marked) != 0 {
t.Errorf("marked rows on an empty walk: %v", q.marked)
}
}
// The unmounted-volume case: the configured root doesn't exist at all.
func TestReconcileMissing_RefusesWhenRootMissing(t *testing.T) {
s := testScanner(t, filepath.Join(t.TempDir(), "not-mounted"))
q := &fakeReconciler{rows: []dbq.ListTrackPathsForReconcileRow{
row(1, "/music/a.mp3", false),
}}
var stats Stats
if err := s.reconcileMissing(context.Background(), q, map[string]struct{}{"/x": {}}, &stats); err == nil {
t.Fatal("expected refusal when a scan root is absent")
}
if len(q.marked) != 0 {
t.Errorf("marked rows with an absent root: %v", q.marked)
}
}
// A mount point that exists but has nothing mounted on it: os.Stat succeeds on
// the bare directory, which is why emptiness is checked separately.
func TestReconcileMissing_RefusesWhenRootEmpty(t *testing.T) {
s := testScanner(t, t.TempDir())
q := &fakeReconciler{rows: []dbq.ListTrackPathsForReconcileRow{
row(1, "/music/a.mp3", false),
}}
var stats Stats
if err := s.reconcileMissing(context.Background(), q, map[string]struct{}{"/x": {}}, &stats); err == nil {
t.Fatal("expected refusal when a scan root is empty")
}
}
// Several roots, one detached. Marking must not proceed on partial evidence just
// because the other roots looked fine.
func TestReconcileMissing_RefusesWhenAnyRootMissing(t *testing.T) {
good := populatedRoot(t)
s := testScanner(t, good, filepath.Join(t.TempDir(), "detached"))
q := &fakeReconciler{rows: []dbq.ListTrackPathsForReconcileRow{
row(1, "/music/a.mp3", false),
}}
var stats Stats
if err := s.reconcileMissing(context.Background(), q, map[string]struct{}{"/x": {}}, &stats); err == nil {
t.Fatal("expected refusal when one of several roots is absent")
}
}
func TestReconcileMissing_NoRowsIsNotAnError(t *testing.T) {
root := populatedRoot(t)
s := testScanner(t, root)
q := &fakeReconciler{}
var stats Stats
if err := s.reconcileMissing(context.Background(), q, map[string]struct{}{"/x": {}}, &stats); err != nil {
t.Fatalf("empty library should reconcile cleanly, got %v", err)
}
}
func TestReconcileMissing_PropagatesListError(t *testing.T) {
root := populatedRoot(t)
s := testScanner(t, root)
sentinel := errors.New("boom")
q := &fakeReconciler{listErr: sentinel}
var stats Stats
err := s.reconcileMissing(context.Background(), q, map[string]struct{}{"/x": {}}, &stats)
if !errors.Is(err, sentinel) {
t.Fatalf("err = %v, want it to wrap %v", err, sentinel)
}
}
func TestVerifyRootsPresent_NoRootsConfigured(t *testing.T) {
s := testScanner(t)
if err := s.verifyRootsPresent(); err == nil {
t.Fatal("expected an error with no scan roots configured")
}
}
// Without a change row the mark is invisible to every client:
// /api/library/sync is a change-log feed, so a plain UPDATE never reaches
// anyone. This was the gap that made #2704's wire field inert — the flag
// existed and nothing ever told a client to re-read the track.
func TestReconcileMissing_MarkingEmitsSyncChanges(t *testing.T) {
s := testScanner(t, populatedRoot(t))
// 10 rows, 2 absent — under the cap, so this exercises marking.
rows := make([]dbq.ListTrackPathsForReconcileRow, 0, 10)
seen := map[string]struct{}{}
for i := 0; i < 10; i++ {
path := fmt.Sprintf("/music/track-%02d.mp3", i)
rows = append(rows, row(byte(i), path, false))
if i >= 2 {
seen[path] = struct{}{}
}
}
q := &fakeReconciler{rows: rows}
var stats Stats
if err := s.reconcileMissing(context.Background(), q, seen, &stats); err != nil {
t.Fatalf("reconcile: %v", err)
}
if len(q.loggedChanges) != 1 {
t.Fatalf("want one batch of change rows, got %d", len(q.loggedChanges))
}
got := q.loggedChanges[0]
if got.EntityType != "track" {
t.Errorf("entity_type = %q, want track", got.EntityType)
}
// Upsert, not delete: the track still exists and keeps its history. A
// delete would tell clients to drop the row, which is the behaviour
// #2704 deliberately rejected.
if got.Op != "upsert" {
t.Errorf("op = %q, want upsert — the row survives, only its state changed", got.Op)
}
if len(got.EntityIds) != 2 {
t.Errorf("want both missing tracks logged, got %d ids", len(got.EntityIds))
}
}
// A file coming back must reach clients too, or a restored track stays
// greyed out on every device until something unrelated touches it.
func TestReconcileMissing_RestoringEmitsSyncChanges(t *testing.T) {
s := testScanner(t, populatedRoot(t))
q := &fakeReconciler{rows: []dbq.ListTrackPathsForReconcileRow{
row(1, "/music/back.mp3", true),
}}
var stats Stats
if err := s.reconcileMissing(
context.Background(), q, map[string]struct{}{"/music/back.mp3": {}}, &stats,
); err != nil {
t.Fatalf("reconcile: %v", err)
}
if len(q.loggedChanges) != 1 {
t.Fatalf("a restored file must produce a change row, got %d batches", len(q.loggedChanges))
}
if q.loggedChanges[0].Op != "upsert" {
t.Errorf("op = %q, want upsert", q.loggedChanges[0].Op)
}
}