* fix(markers): stop retrying contribution conflicts * fix(markers): claim contributions atomically * fix(markers): recover stale contribution claims
203 lines
6.4 KiB
Go
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)
|
|
}
|
|
}
|