Files
minstrel/internal/sync/changes.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

99 lines
3.3 KiB
Go

package sync
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// LogChange writes a row to library_changes via the supplied DBTX. dbq.DBTX
// is satisfied by both *pgxpool.Pool and pgx.Tx, so callers can opt into
// transactional safety where they already hold a tx, or pool-bind for
// best-effort logging when no tx is available.
//
// Callers SHOULD pass a tx that owns the underlying mutation — the change
// row and the mutation then commit or roll back together. When pool-bound,
// the scanner-style "log after success" pattern keeps the log consistent
// with reality at the cost of a tiny race window: if the LogChange call
// itself fails after the mutation succeeded, that mutation won't appear in
// the change log until the entity is touched again.
//
// entityID is the string form of the row's primary key. For composite-key
// entities (likes, playlist_tracks), use EncodeLikeID / EncodePlaylistTrackID
// to produce stable strings. For pgtype.UUID values, use FormatUUID.
func LogChange(ctx context.Context, dbtx dbq.DBTX, entityType EntityType, entityID string, op Op) error {
q := dbq.New(dbtx)
if err := q.InsertLibraryChange(ctx, dbq.InsertLibraryChangeParams{
EntityType: string(entityType),
EntityID: entityID,
Op: string(op),
}); err != nil {
return fmt.Errorf("sync.LogChange(%s, %s, %s): %w", entityType, entityID, op, err)
}
return nil
}
// LogChanges is LogChange for a set of entities of one type sharing one op.
//
// Exists because the scan's reconcile pass can mark a quarter of a library
// missing in a single sweep (#2704), and a per-row loop there would be
// thousands of round-trips inside an operation that is already slow. Every
// other caller mutates one entity and should keep using LogChange.
//
// A no-op on an empty set, so callers don't have to guard.
func LogChanges(ctx context.Context, dbtx dbq.DBTX, entityType EntityType, entityIDs []string, op Op) error {
if len(entityIDs) == 0 {
return nil
}
q := dbq.New(dbtx)
if err := q.InsertLibraryChanges(ctx, dbq.InsertLibraryChangesParams{
EntityType: string(entityType),
EntityIds: entityIDs,
Op: string(op),
}); err != nil {
return fmt.Errorf("sync.LogChanges(%s, %d ids, %s): %w", entityType, len(entityIDs), op, err)
}
return nil
}
// FormatUUID renders a pgtype.UUID as the canonical 8-4-4-4-12 hex form.
// Returns "" if the UUID is not valid.
func FormatUUID(u pgtype.UUID) string {
if !u.Valid {
return ""
}
return formatUUIDBytes(u.Bytes)
}
func formatUUIDBytes(b [16]byte) string {
const hex = "0123456789abcdef"
out := make([]byte, 36)
pos := 0
for i := 0; i < 16; i++ {
if i == 4 || i == 6 || i == 8 || i == 10 {
out[pos] = '-'
pos++
}
out[pos] = hex[b[i]>>4]
out[pos+1] = hex[b[i]&0x0f]
pos += 2
}
return string(out)
}
// EncodeLikeID joins a user UUID and an entity UUID into the stable
// composite identifier used for like_* rows in library_changes. Both
// sides are UUID strings so no escaping is needed.
func EncodeLikeID(userID, entityID string) string {
return userID + ":" + entityID
}
// EncodePlaylistTrackID joins a playlist UUID and a track UUID into the
// stable composite identifier used for playlist_track rows.
func EncodePlaylistTrackID(playlistID, trackID string) string {
return playlistID + ":" + trackID
}