fix: a fresh app install syncs the whole library, not the last 30 days of changes (#6778)
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

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>
This commit is contained in:
2026-11-02 22:07:42 -05:00
co-authored by Claude Opus 5.5
parent ad48d8c8c9
commit 598abeb8cf
11 changed files with 278 additions and 56 deletions
+35
View File
@@ -250,3 +250,38 @@ func TestLibrarySync_ACursorPastTheServersLastChangeIsReset(t *testing.T) {
t.Errorf("the last cursor after compaction: status = %d body = %s, want 204", w.Code, w.Body.String())
}
}
// An app is sent back to 0 only when its cursor is below a delete the log has
// dropped, which it may have missed. A cursor at that delete has seen it, and
// one between dropped and kept rows replays what is still there.
func TestLibrarySync_ACursorBelowADroppedDeleteIsReset(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
u := seedUser(t, pool, "alice", "x", false)
ctx := context.Background()
for i := 0; i < 5; i++ {
if _, err := pool.Exec(ctx, `INSERT INTO library_changes (entity_type, entity_id, op) VALUES ('artist', $1, 'upsert')`,
strconv.Itoa(i)); err != nil {
t.Fatal(err)
}
}
var dropped int64
if err := pool.QueryRow(ctx, `SELECT min(id) + 2 FROM library_changes`).Scan(&dropped); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `UPDATE library_changes_trimmed SET through = $1`, dropped); err != nil {
t.Fatal(err)
}
if w := callLibrarySync(h, u, strconv.FormatInt(dropped-1, 10)); w.Code != http.StatusGone ||
!strings.Contains(w.Body.String(), "cursor_too_old") {
t.Errorf("a cursor below the dropped delete: status = %d body = %s, want 410 cursor_too_old", w.Code, w.Body.String())
}
if w := callLibrarySync(h, u, strconv.FormatInt(dropped, 10)); w.Code != http.StatusOK {
t.Errorf("a cursor at the dropped delete: status = %d body = %s, want 200", w.Code, w.Body.String())
}
if w := callLibrarySync(h, u, "0"); w.Code != http.StatusOK {
t.Errorf("a sync from 0: status = %d body = %s, want 200", w.Code, w.Body.String())
}
}