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>
60 lines
2.4 KiB
Go
60 lines
2.4 KiB
Go
package sync_test
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
|
)
|
|
|
|
// The compactor keeps each entity's latest change however old, so an app
|
|
// syncing from 0 still gets an artist nobody has touched in months. It drops
|
|
// what a newer row supersedes, and deletes past their retention, raising the
|
|
// trimmed mark past the newest delete it dropped.
|
|
func TestCompact_KeepsEachEntitysLatestChange_Integration(t *testing.T) {
|
|
pool := testPool(t)
|
|
ctx := context.Background()
|
|
old := time.Now().Add(-90 * 24 * time.Hour)
|
|
recent := time.Now().Add(-time.Hour)
|
|
|
|
add := func(entity, op string, at time.Time) int64 {
|
|
t.Helper()
|
|
var id int64
|
|
require.NoError(t, pool.QueryRow(ctx, `INSERT INTO library_changes (entity_type, entity_id, op, changed_at)
|
|
VALUES ('artist', $1, $2, $3) RETURNING id`, entity, op, at).Scan(&id))
|
|
return id
|
|
}
|
|
untouched := add("untouched", "upsert", old) // kept: latest, however old
|
|
add("edited", "upsert", old) // dropped: superseded
|
|
edited := add("edited", "upsert", recent) // kept
|
|
add("removed-long-ago", "upsert", old) // dropped: superseded
|
|
removedLongAgo := add("removed-long-ago", "delete", old) // dropped: past retention
|
|
add("removed-lately", "upsert", old) // dropped: superseded
|
|
removedLately := add("removed-lately", "delete", recent) // kept: within retention
|
|
|
|
require.NoError(t, sync.Compact(ctx, pool, time.Now().Add(-sync.CompactorRetention),
|
|
slog.New(slog.NewTextHandler(io.Discard, nil))))
|
|
|
|
rows, err := pool.Query(ctx, `SELECT id FROM library_changes ORDER BY id`)
|
|
require.NoError(t, err)
|
|
left, err := pgx.CollectRows(rows, pgx.RowTo[int64])
|
|
require.NoError(t, err)
|
|
require.Equal(t, []int64{untouched, edited, removedLately}, left)
|
|
|
|
var through int64
|
|
require.NoError(t, pool.QueryRow(ctx, `SELECT through FROM library_changes_trimmed`).Scan(&through))
|
|
require.Equal(t, removedLongAgo, through, "the trimmed mark stands at the newest dropped delete")
|
|
|
|
// A second pass finds nothing more, and leaves the mark where it is.
|
|
require.NoError(t, sync.Compact(ctx, pool, time.Now().Add(-sync.CompactorRetention),
|
|
slog.New(slog.NewTextHandler(io.Discard, nil))))
|
|
require.NoError(t, pool.QueryRow(ctx, `SELECT through FROM library_changes_trimmed`).Scan(&through))
|
|
require.Equal(t, removedLongAgo, through)
|
|
}
|