* fix(library): clean orphaned provisional items * test(metadata): use item not found sentinel in fake repo * fix(library): preserve abs references during orphan cleanup * fix(library): avoid dropped abs cleanup tables * fix(library): harden provisional orphan cleanup * fix(library): preserve home dismissal series orphans * fix(library): Delete matched items created after the first orphan sweep
152 lines
3.9 KiB
Go
152 lines
3.9 KiB
Go
package catalog
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
)
|
|
|
|
// withFastDeadlockRetry shrinks retry timing/attempts for tests and restores
|
|
// the originals on cleanup. Tests using it must not call t.Parallel().
|
|
func withFastDeadlockRetry(t *testing.T, maxAttempts int) {
|
|
t.Helper()
|
|
oldMax, oldBackoff := deadlockMaxAttempts, deadlockBaseBackoff
|
|
deadlockMaxAttempts = maxAttempts
|
|
deadlockBaseBackoff = time.Millisecond
|
|
t.Cleanup(func() {
|
|
deadlockMaxAttempts = oldMax
|
|
deadlockBaseBackoff = oldBackoff
|
|
})
|
|
}
|
|
|
|
func TestRetryOnDeadlockRetriesThenSucceeds(t *testing.T) {
|
|
withFastDeadlockRetry(t, 5)
|
|
calls := 0
|
|
err := retryOnDeadlock(context.Background(), func() error {
|
|
calls++
|
|
if calls < 3 {
|
|
return &pgconn.PgError{Code: "40P01"}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected success, got %v", err)
|
|
}
|
|
if calls != 3 {
|
|
t.Fatalf("expected 3 calls, got %d", calls)
|
|
}
|
|
}
|
|
|
|
func TestRetryOnDeadlockReturnsNonRetryableImmediately(t *testing.T) {
|
|
withFastDeadlockRetry(t, 5)
|
|
sentinel := errors.New("boom")
|
|
calls := 0
|
|
err := retryOnDeadlock(context.Background(), func() error {
|
|
calls++
|
|
return sentinel
|
|
})
|
|
if !errors.Is(err, sentinel) {
|
|
t.Fatalf("expected sentinel, got %v", err)
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("expected 1 call, got %d", calls)
|
|
}
|
|
}
|
|
|
|
func TestRetryOnDeadlockGivesUpAfterMaxAttempts(t *testing.T) {
|
|
withFastDeadlockRetry(t, 4)
|
|
calls := 0
|
|
err := retryOnDeadlock(context.Background(), func() error {
|
|
calls++
|
|
return &pgconn.PgError{Code: "40P01"}
|
|
})
|
|
var pgErr *pgconn.PgError
|
|
if !errors.As(err, &pgErr) || pgErr.Code != "40P01" {
|
|
t.Fatalf("expected 40P01 pg error, got %v", err)
|
|
}
|
|
if calls != 4 {
|
|
t.Fatalf("expected 4 calls, got %d", calls)
|
|
}
|
|
}
|
|
|
|
func TestRetryOnDeadlockStopsOnCanceledContext(t *testing.T) {
|
|
withFastDeadlockRetry(t, 5)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
calls := 0
|
|
// op always runs once at the top of the loop before the canceled ctx is
|
|
// observed in the select, so exactly one call is expected.
|
|
err := retryOnDeadlock(ctx, func() error {
|
|
calls++
|
|
return &pgconn.PgError{Code: "40P01"}
|
|
})
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("expected context.Canceled, got %v", err)
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("expected 1 call before cancel, got %d", calls)
|
|
}
|
|
}
|
|
|
|
func TestDeleteInBatchesLoopsUntilUnderBatchSize(t *testing.T) {
|
|
withFastDeadlockRetry(t, 5)
|
|
counts := []int64{5, 5, 2}
|
|
idx := 0
|
|
total, err := deleteInBatches(context.Background(), 5, func(context.Context) (int64, error) {
|
|
n := counts[idx]
|
|
idx++
|
|
return n, nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if total != 12 {
|
|
t.Fatalf("expected total 12, got %d", total)
|
|
}
|
|
if idx != 3 {
|
|
t.Fatalf("expected 3 batches, got %d", idx)
|
|
}
|
|
}
|
|
|
|
func TestDeleteInBatchesStopsImmediatelyWhenFirstBatchUnderSize(t *testing.T) {
|
|
withFastDeadlockRetry(t, 5)
|
|
calls := 0
|
|
total, err := deleteInBatches(context.Background(), 5, func(context.Context) (int64, error) {
|
|
calls++
|
|
return 0, nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if total != 0 || calls != 1 {
|
|
t.Fatalf("expected total 0 and 1 call, got total=%d calls=%d", total, calls)
|
|
}
|
|
}
|
|
|
|
func TestDeleteInBatchesReturnsError(t *testing.T) {
|
|
withFastDeadlockRetry(t, 5)
|
|
sentinel := errors.New("delete failed")
|
|
_, err := deleteInBatches(context.Background(), 5, func(context.Context) (int64, error) {
|
|
return 0, sentinel
|
|
})
|
|
if !errors.Is(err, sentinel) {
|
|
t.Fatalf("expected sentinel, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLateOrphanCleanupUsesProvisionalSafetyPredicate(t *testing.T) {
|
|
conditions := normalizePredicateSQL(orphanedProvisionalMediaItemConditions)
|
|
for name, query := range map[string]string{
|
|
"collect": collectOrphanedProvisionalIDsByContentIDSQL,
|
|
"delete": deleteOrphanedProvisionalIDsByContentIDSQL,
|
|
} {
|
|
if !strings.Contains(normalizePredicateSQL(query), conditions) {
|
|
t.Fatalf("%s late orphan query does not use provisional safety predicate", name)
|
|
}
|
|
}
|
|
}
|