Files
minstrel/internal/library/reconcile.go
T
bvandeusenandClaude Opus 5.5 7e6228c13f
release / govulncheck (push) Successful in 20s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m15s
release / android (push) Successful in 5m26s
release / Build signed APK (releases and dev) (push) Successful in 5m45s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
docs: library comments say what the scanner and resolver do, not how they came to be (#6277)
Rewrites the internal/library comment blocks to rule 32. Task, milestone,
decision and rule ids are gone; the incidents behind them are restated as
the constraint they left (why a file is deleted before its row, why the
missing mark is a mark, why duplicates block on duration). Counts and
album names from one library are dropped from production comments; test
comments keep the names their fixtures use. The tagReadVersion log keeps
each version's before and after, without the measurements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:27:34 -04:00

201 lines
7.8 KiB
Go

package library
import (
"context"
"errors"
"fmt"
"os"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
// trackReconciler is the slice of dbq.Queries reconcileMissing needs. Narrowed
// to an interface so the guard logic — which is the part that can do damage —
// is unit-testable against a fake without a database.
type trackReconciler interface {
ListTrackPathsForReconcile(ctx context.Context) ([]dbq.ListTrackPathsForReconcileRow, error)
MarkTracksMissing(ctx context.Context, ids []pgtype.UUID) (int64, error)
ClearTracksMissing(ctx context.Context, ids []pgtype.UUID) (int64, error)
InsertLibraryChanges(ctx context.Context, arg dbq.InsertLibraryChangesParams) error
}
// Reconcile marks tracks whose files have disappeared.
//
// The walk only visits paths that exist, so without this a row whose file is
// gone would never be scanned, never error, never be counted. The watcher
// ignores removals by design (see classifyEvent), and the safety-net scan is
// the same walk, so it covers additions only. Unmarked, such rows would keep
// being offered to recommendations and fail at playback.
//
// Why it MARKS rather than deletes: a missing file is a claim about the
// filesystem, and the filesystem lies transiently — an unmounted volume, a
// network-storage blip, a container that started before its media mount
// attached. Every other sweep in this codebase (internal/gc) resolves a truth
// *inside* the database and is safe to run blind. This one isn't, so the
// destructive step is deliberately not here. Marking is reversible: the next
// good scan clears it.
// missingMarkMaxFraction caps how much of the library one reconcile may newly
// mark missing. A partially-attached mount is the failure this defends against:
// the roots resolve, the walk succeeds, and it legitimately sees only part of
// the library — evidence indistinguishable from a mass deletion.
//
// A quarter is deliberately conservative. A genuine bulk deletion trips it and
// gets logged rather than applied, which needs a second scan (or operator
// action) to take effect. That's the right trade: the cost of over-refusing is
// a stale row and a log line, and the cost of over-marking is a chunk of the
// library silently vanishing from every mix.
const missingMarkMaxFraction = 0.25
// reconcileMissing diffs the paths the walk saw against every row in the table.
// Rows not seen get marked; rows seen that carry a mark get cleared.
//
// seen must come from a COMPLETE walk of every configured root. Callers with a
// partial view must not call this.
func (s *Scanner) reconcileMissing(
ctx context.Context, q trackReconciler, seen map[string]struct{}, stats *Stats,
) error {
if err := s.verifyRootsPresent(); err != nil {
return err
}
// Roots resolved but the walk found nothing. Either the library is
// genuinely empty — in which case there is nothing to reconcile — or the
// mount is hollow. Both mean: don't act.
if len(seen) == 0 {
return errors.New("walk saw no audio files; refusing to reconcile")
}
rows, err := q.ListTrackPathsForReconcile(ctx)
if err != nil {
return fmt.Errorf("list track paths: %w", err)
}
if len(rows) == 0 {
return nil
}
var toMark, toClear []pgtype.UUID
for _, row := range rows {
_, present := seen[row.FilePath]
switch {
case !present && !row.MissingSince.Valid:
toMark = append(toMark, row.ID)
case present && row.MissingSince.Valid:
toClear = append(toClear, row.ID)
}
}
// Clear before marking, and unconditionally. Restoring a file is never the
// dangerous direction, so it must not be blocked by the guard below —
// otherwise a library that tripped the cap once could never recover its
// marks even after the mount came back.
if len(toClear) > 0 {
if err := logTrackChanges(ctx, q, toClear); err != nil {
return fmt.Errorf("log restored changes: %w", err)
}
n, err := q.ClearTracksMissing(ctx, toClear)
if err != nil {
return fmt.Errorf("clear missing marks: %w", err)
}
stats.Restored = int(n)
s.logger.Info("library scan: files returned", "count", n)
}
if len(toMark) == 0 {
return nil
}
if fraction := float64(len(toMark)) / float64(len(rows)); fraction > missingMarkMaxFraction {
return fmt.Errorf(
"refusing to mark %d of %d tracks missing (%.0f%% > %.0f%% cap): "+
"this looks like an unavailable mount rather than a deletion",
len(toMark), len(rows), fraction*100, missingMarkMaxFraction*100,
)
}
if err := logTrackChanges(ctx, q, toMark); err != nil {
return fmt.Errorf("log missing changes: %w", err)
}
n, err := q.MarkTracksMissing(ctx, toMark)
if err != nil {
return fmt.Errorf("mark tracks missing: %w", err)
}
stats.Missing = int(n)
// Warn, not Info: every one of these is a library entry an admin probably
// didn't intend to lose.
s.logger.Warn("library scan: tracks marked missing (files not found)",
"count", n, "library_total", len(rows))
// Admins are told after the scan has adopted moves and folded
// replacements, about the ones still gone (notifyStillMissing).
stats.marked = toMark
return nil
}
// logTrackChanges tells the delta sync that these tracks changed, so clients
// pick up the missing mark.
//
// Without this the mark would be invisible to every client: MarkTracksMissing
// and ClearTracksMissing are plain UPDATEs, and /api/library/sync is a
// change-log feed — a row that never produces a change is never re-sent, so a
// client would keep its stale copy until an unrelated edit touched the track or
// the cursor fell out of the retention window.
//
// Logged BEFORE the mutation, deliberately, which is the opposite of the
// scanner's log-after-success pattern. The two failure modes are not
// symmetric: log-then-fail-to-mark makes clients re-fetch a track that has
// not changed, which costs one wasted read. Mark-then-fail-to-log leaves the
// mark in place 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 the cheaper mistake.
func logTrackChanges(ctx context.Context, q trackReconciler, ids []pgtype.UUID) error {
if len(ids) == 0 {
return nil
}
strIDs := make([]string, 0, len(ids))
for _, id := range ids {
strIDs = append(strIDs, syncpkg.FormatUUID(id))
}
// OpUpsert, not OpDelete: the track still exists and keeps its history —
// only its playability changed. A delete would tell clients to drop the
// row, and a missing file is expected to come back.
return q.InsertLibraryChanges(ctx, dbq.InsertLibraryChangesParams{
EntityType: string(syncpkg.EntityTrack),
EntityIds: strIDs,
Op: string(syncpkg.OpUpsert),
})
}
// verifyRootsPresent is the first and most important guard. If a configured
// root doesn't resolve to a readable directory, the walk beneath it found
// nothing and every row under it would look deleted. An unmounted media volume
// is the obvious case, and it is common enough — a container restart racing its
// volume mount does exactly this.
func (s *Scanner) verifyRootsPresent() error {
roots := s.Roots()
if len(roots) == 0 {
return errors.New("no scan roots configured")
}
for _, root := range roots {
info, err := os.Stat(root)
if err != nil {
return fmt.Errorf("scan root %q unavailable: %w", root, err)
}
if !info.IsDir() {
return fmt.Errorf("scan root %q is not a directory", root)
}
entries, err := os.ReadDir(root)
if err != nil {
return fmt.Errorf("scan root %q unreadable: %w", root, err)
}
// An empty root is the signature of a mount point with nothing mounted
// on it. `os.Stat` succeeds on the bare directory, so this is the only
// cheap way to tell the two apart.
if len(entries) == 0 {
return fmt.Errorf("scan root %q is empty; refusing to reconcile", root)
}
}
return nil
}