Files
silo-server/internal/markers/contribution_repo_test.go
QuickandGitHub 39efe308af fix(markers): stop retrying contribution conflicts (#559)
* fix(markers): stop retrying contribution conflicts

* fix(markers): claim contributions atomically

* fix(markers): recover stale contribution claims
2026-08-07 11:21:26 -04:00

203 lines
6.4 KiB
Go

package markers
import (
"context"
"fmt"
"os"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type contributionStoreFixture struct {
store *ContributionStore
pool *pgxpool.Pool
provider string
fileIDs [2]int
}
func newContributionStoreFixture(t *testing.T) contributionStoreFixture {
t.Helper()
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("SILO_TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect test database: %v", err)
}
t.Cleanup(pool.Close)
var migrated bool
if err := pool.QueryRow(ctx, `
SELECT to_regclass('public.marker_contributions_provider_hash_active_uidx') IS NOT NULL
AND EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'marker_contributions'
AND column_name = 'claim_token'
)`).Scan(&migrated); err != nil {
t.Fatalf("check contribution claim migration: %v", err)
}
if !migrated {
t.Skip("marker contribution claim migration has not been applied")
}
suffix := time.Now().UnixNano()
provider := fmt.Sprintf("claim-test-%d", suffix)
var folderID int
if err := pool.QueryRow(ctx, `
INSERT INTO media_folders (type, name)
VALUES ('shows', $1)
RETURNING id`, provider).Scan(&folderID); err != nil {
t.Fatalf("seed media folder: %v", err)
}
var fileIDs [2]int
for i := range fileIDs {
if err := pool.QueryRow(ctx, `
INSERT INTO media_files (media_folder_id, file_path)
VALUES ($1, $2)
RETURNING id`, folderID, fmt.Sprintf("/claim-test/%d-%d.mkv", suffix, i)).Scan(&fileIDs[i]); err != nil {
t.Fatalf("seed media file: %v", err)
}
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM marker_contributions WHERE provider = $1`, provider)
_, _ = pool.Exec(ctx, `DELETE FROM media_files WHERE id = ANY($1)`, fileIDs[:])
_, _ = pool.Exec(ctx, `DELETE FROM media_folders WHERE id = $1`, folderID)
})
return contributionStoreFixture{
store: NewContributionStore(pool),
pool: pool,
provider: provider,
fileIDs: fileIDs,
}
}
func (f contributionStoreFixture) row(fileID int, hash string) ContributionRow {
start, end, duration := int64(0), int64(60_000), int64(1_800_000)
return ContributionRow{
MediaFileID: fileID,
Provider: f.provider,
SegmentKind: "intro",
Source: "manual",
SubmittedStartMs: &start,
SubmittedEndMs: &end,
VideoDurationMs: &duration,
ContentHash: hash,
Status: contributionStatusClaim,
}
}
func expireContributionClaim(t *testing.T, fixture contributionStoreFixture, id string) {
t.Helper()
if _, err := fixture.pool.Exec(context.Background(), `
UPDATE marker_contributions
SET updated_at = now() - interval '1 hour'
WHERE id = $1`, id); err != nil {
t.Fatalf("expire contribution claim: %v", err)
}
}
func TestContributionStoreRecoversStaleClaimAcrossFiles(t *testing.T) {
fixture := newContributionStoreFixture(t)
ctx := context.Background()
firstRow := fixture.row(fixture.fileIDs[0], "cross-file-stale")
first, claimed, err := fixture.store.Claim(ctx, firstRow, contributionClaimLease)
if err != nil || !claimed {
t.Fatalf("first Claim = (%+v, %v, %v), want claimed", first, claimed, err)
}
secondRow := fixture.row(fixture.fileIDs[1], firstRow.ContentHash)
if _, claimed, err := fixture.store.Claim(ctx, secondRow, contributionClaimLease); err != nil || claimed {
t.Fatalf("fresh duplicate Claim = (claimed=%v, %v), want not claimed", claimed, err)
}
expireContributionClaim(t, fixture, first.ID)
second, claimed, err := fixture.store.Claim(ctx, secondRow, contributionClaimLease)
if err != nil || !claimed {
t.Fatalf("stale cross-file Claim = (%+v, %v, %v), want claimed", second, claimed, err)
}
if second.ID != first.ID || second.Token == first.Token {
t.Fatalf("reclaimed claim = %+v, want row %s with a fresh token", second, first.ID)
}
staleResult := firstRow
staleResult.ID, staleResult.ClaimToken = first.ID, first.Token
staleResult.Status = OutcomeStatusError
if err := fixture.store.Record(ctx, staleResult); err == nil {
t.Fatal("stale worker recorded over reclaimed claim")
}
terminal := secondRow
terminal.ID, terminal.ClaimToken = second.ID, second.Token
terminal.Status = OutcomeStatusConflict
if err := fixture.store.Record(ctx, terminal); err != nil {
t.Fatalf("record current claim: %v", err)
}
expireContributionClaim(t, fixture, second.ID)
if _, claimed, err := fixture.store.Claim(ctx, firstRow, contributionClaimLease); err != nil || claimed {
t.Fatalf("terminal Claim = (claimed=%v, %v), want permanently blocked", claimed, err)
}
}
func TestContributionStoreRecoversStaleClaimForSameFile(t *testing.T) {
fixture := newContributionStoreFixture(t)
ctx := context.Background()
row := fixture.row(fixture.fileIDs[0], "same-file-stale")
first, claimed, err := fixture.store.Claim(ctx, row, contributionClaimLease)
if err != nil || !claimed {
t.Fatalf("first Claim = (%+v, %v, %v), want claimed", first, claimed, err)
}
expireContributionClaim(t, fixture, first.ID)
second, claimed, err := fixture.store.Claim(ctx, row, contributionClaimLease)
if err != nil || !claimed || second.ID != first.ID || second.Token == first.Token {
t.Fatalf("same-file stale Claim = (%+v, %v, %v), want reclaimed row with fresh token", second, claimed, err)
}
}
func TestContributionStoreAllowsOnlyOneConcurrentStaleTakeover(t *testing.T) {
fixture := newContributionStoreFixture(t)
ctx := context.Background()
row := fixture.row(fixture.fileIDs[0], "concurrent-stale")
first, claimed, err := fixture.store.Claim(ctx, row, contributionClaimLease)
if err != nil || !claimed {
t.Fatalf("first Claim = (%+v, %v, %v), want claimed", first, claimed, err)
}
expireContributionClaim(t, fixture, first.ID)
const workers = 12
results := make(chan bool, workers)
errs := make(chan error, workers)
var ready sync.WaitGroup
ready.Add(workers)
start := make(chan struct{})
for range workers {
go func() {
ready.Done()
<-start
_, claimed, err := fixture.store.Claim(ctx, row, contributionClaimLease)
results <- claimed
errs <- err
}()
}
ready.Wait()
close(start)
claimedCount := 0
for range workers {
if err := <-errs; err != nil {
t.Fatalf("concurrent Claim: %v", err)
}
if <-results {
claimedCount++
}
}
if claimedCount != 1 {
t.Fatalf("concurrent stale claims won = %d, want 1", claimedCount)
}
}