2026-06-10 08:18:35 -04:00
|
|
|
package tasks
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
2026-07-19 15:48:24 +02:00
|
|
|
"time"
|
2026-06-10 08:18:35 -04:00
|
|
|
|
2026-07-19 15:48:24 +02:00
|
|
|
"github.com/Silo-Server/silo-server/internal/ebooks"
|
2026-06-10 08:18:35 -04:00
|
|
|
"github.com/Silo-Server/silo-server/internal/taskmanager"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type fakeEbookMetadataEnricher struct {
|
2026-07-19 16:52:09 +02:00
|
|
|
results []ebooks.EnrichmentRunResult
|
|
|
|
|
err error
|
|
|
|
|
countErr error
|
|
|
|
|
scopes []ebooks.EnrichmentScope
|
|
|
|
|
limits []int
|
|
|
|
|
readyCountCalls int
|
|
|
|
|
initialReady int
|
|
|
|
|
onRun func(int)
|
2026-06-10 08:18:35 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-19 16:52:09 +02:00
|
|
|
func (f *fakeEbookMetadataEnricher) ReadyCount(_ context.Context, _ ebooks.EnrichmentScope) (int, error) {
|
|
|
|
|
f.readyCountCalls++
|
|
|
|
|
if f.countErr != nil {
|
|
|
|
|
return 0, f.countErr
|
|
|
|
|
}
|
|
|
|
|
if f.initialReady > 0 || len(f.results) == 0 {
|
|
|
|
|
return f.initialReady, nil
|
|
|
|
|
}
|
|
|
|
|
return f.results[0].Claimed + f.results[0].Remaining, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (f *fakeEbookMetadataEnricher) RunLimited(_ context.Context, scope ebooks.EnrichmentScope, limit int) (ebooks.EnrichmentRunResult, error) {
|
2026-07-19 15:48:24 +02:00
|
|
|
f.scopes = append(f.scopes, scope)
|
2026-07-19 16:52:09 +02:00
|
|
|
f.limits = append(f.limits, limit)
|
2026-07-19 15:48:24 +02:00
|
|
|
call := len(f.scopes)
|
|
|
|
|
if f.onRun != nil {
|
|
|
|
|
f.onRun(call)
|
|
|
|
|
}
|
|
|
|
|
if f.err != nil {
|
|
|
|
|
return ebooks.EnrichmentRunResult{}, f.err
|
|
|
|
|
}
|
|
|
|
|
if call > len(f.results) {
|
|
|
|
|
return ebooks.EnrichmentRunResult{}, nil
|
|
|
|
|
}
|
2026-07-19 16:52:09 +02:00
|
|
|
result := f.results[call-1]
|
|
|
|
|
result.HasMore = result.Remaining > 0
|
|
|
|
|
return result, nil
|
2026-06-10 08:18:35 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ebookMetadataProgressReporter struct {
|
2026-07-19 15:48:24 +02:00
|
|
|
percents []float64
|
2026-06-10 08:18:35 -04:00
|
|
|
messages []string
|
2026-07-19 15:48:24 +02:00
|
|
|
results []json.RawMessage
|
2026-06-10 08:18:35 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-19 15:48:24 +02:00
|
|
|
func (p *ebookMetadataProgressReporter) Report(percent float64, message string) {
|
|
|
|
|
p.percents = append(p.percents, percent)
|
2026-06-10 08:18:35 -04:00
|
|
|
p.messages = append(p.messages, message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (p *ebookMetadataProgressReporter) SetResultData(data json.RawMessage) {
|
2026-07-19 15:48:24 +02:00
|
|
|
p.results = append(p.results, append(json.RawMessage(nil), data...))
|
2026-06-10 08:18:35 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-19 15:48:24 +02:00
|
|
|
func TestEbookMetadataTaskPropertiesAndScopes(t *testing.T) {
|
|
|
|
|
enricher := &fakeEbookMetadataEnricher{}
|
|
|
|
|
syncTask := NewSyncEbookMetadataTask(enricher)
|
|
|
|
|
backfillTask := NewBackfillEbookMetadataTask(enricher)
|
2026-06-10 08:18:35 -04:00
|
|
|
|
2026-07-19 15:48:24 +02:00
|
|
|
if syncTask.Key() != "sync_ebook_metadata" || syncTask.Name() != "Sync Ebook Metadata" {
|
|
|
|
|
t.Fatalf("unexpected sync identity: %q %q", syncTask.Key(), syncTask.Name())
|
2026-06-10 08:18:35 -04:00
|
|
|
}
|
2026-07-19 15:48:24 +02:00
|
|
|
if backfillTask.Key() != "backfill_ebook_metadata" {
|
|
|
|
|
t.Fatalf("backfill Key() = %q", backfillTask.Key())
|
2026-06-10 08:18:35 -04:00
|
|
|
}
|
2026-07-19 15:48:24 +02:00
|
|
|
if !strings.Contains(strings.ToLower(backfillTask.Description()), "legacy") {
|
|
|
|
|
t.Fatalf("backfill description does not explain legacy work: %q", backfillTask.Description())
|
2026-06-10 08:18:35 -04:00
|
|
|
}
|
2026-07-19 15:48:24 +02:00
|
|
|
for _, task := range []taskmanager.Task{syncTask, backfillTask} {
|
|
|
|
|
if task.Category() != taskmanager.TaskCategoryMetadata || task.IsHidden() {
|
|
|
|
|
t.Fatalf("unexpected task properties for %q", task.Key())
|
|
|
|
|
}
|
2026-06-10 08:18:35 -04:00
|
|
|
}
|
2026-07-19 15:48:24 +02:00
|
|
|
triggers := syncTask.DefaultTriggers()
|
2026-06-10 08:18:35 -04:00
|
|
|
if len(triggers) != 1 || triggers[0].Type != taskmanager.TriggerTypeInterval || triggers[0].IntervalMs != 5*60*1000 {
|
2026-07-19 15:48:24 +02:00
|
|
|
t.Fatalf("sync DefaultTriggers() = %#v", triggers)
|
2026-06-10 08:18:35 -04:00
|
|
|
}
|
2026-07-20 13:23:20 +02:00
|
|
|
backfillTriggers := backfillTask.DefaultTriggers()
|
|
|
|
|
if len(backfillTriggers) != 1 ||
|
|
|
|
|
backfillTriggers[0].Type != taskmanager.TriggerTypeInterval ||
|
|
|
|
|
backfillTriggers[0].IntervalMs != 15*60*1000 {
|
|
|
|
|
t.Fatalf("backfill DefaultTriggers() = %#v, want a 15m interval", backfillTriggers)
|
2026-06-10 08:18:35 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 15:48:24 +02:00
|
|
|
func TestEbookMetadataTaskDrainsBatchesAndReportsHonestProgress(t *testing.T) {
|
|
|
|
|
enricher := &fakeEbookMetadataEnricher{results: []ebooks.EnrichmentRunResult{
|
|
|
|
|
{Claimed: 4, Enriched: 2, NoMatch: 1, Failed: 1, Remaining: 3},
|
|
|
|
|
{Claimed: 3, Enriched: 1, Deferred: 2, Remaining: 0},
|
|
|
|
|
}}
|
2026-06-10 08:18:35 -04:00
|
|
|
task := NewSyncEbookMetadataTask(enricher)
|
|
|
|
|
progress := &ebookMetadataProgressReporter{}
|
|
|
|
|
|
|
|
|
|
if err := task.Execute(context.Background(), progress); err != nil {
|
|
|
|
|
t.Fatalf("Execute() error = %v", err)
|
|
|
|
|
}
|
2026-07-19 15:48:24 +02:00
|
|
|
if len(enricher.scopes) != 2 {
|
|
|
|
|
t.Fatalf("Run calls = %d, want 2", len(enricher.scopes))
|
|
|
|
|
}
|
2026-07-19 16:52:09 +02:00
|
|
|
if enricher.readyCountCalls != 1 {
|
|
|
|
|
t.Fatalf("initial exact ready counts = %d, want 1", enricher.readyCountCalls)
|
|
|
|
|
}
|
2026-07-19 15:48:24 +02:00
|
|
|
for _, scope := range enricher.scopes {
|
|
|
|
|
if scope != ebooks.EnrichmentScopeIncremental {
|
|
|
|
|
t.Fatalf("sync scope = %q, want incremental", scope)
|
|
|
|
|
}
|
2026-06-10 08:18:35 -04:00
|
|
|
}
|
2026-07-19 15:48:24 +02:00
|
|
|
var result ebooks.EnrichmentRunResult
|
|
|
|
|
if err := json.Unmarshal(progress.results[len(progress.results)-1], &result); err != nil {
|
|
|
|
|
t.Fatalf("result JSON error: %v", err)
|
2026-06-10 08:18:35 -04:00
|
|
|
}
|
2026-07-19 15:48:24 +02:00
|
|
|
want := ebooks.EnrichmentRunResult{Claimed: 7, Enriched: 3, NoMatch: 1, Failed: 1, Deferred: 2}
|
|
|
|
|
if result != want {
|
|
|
|
|
t.Fatalf("result = %+v, want %+v", result, want)
|
2026-06-10 08:18:35 -04:00
|
|
|
}
|
2026-07-19 15:48:24 +02:00
|
|
|
if progress.percents[1] >= 100 {
|
|
|
|
|
t.Fatalf("first batch progress = %.1f, must be below 100 with remaining work", progress.percents[1])
|
|
|
|
|
}
|
|
|
|
|
if got := progress.percents[len(progress.percents)-1]; got != 100 {
|
|
|
|
|
t.Fatalf("final progress = %.1f, want 100", got)
|
2026-06-10 08:18:35 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 16:52:09 +02:00
|
|
|
func TestEbookMetadataTaskDoesNotReportCompleteWhileBoundedCheckFindsMoreWork(t *testing.T) {
|
|
|
|
|
enricher := &fakeEbookMetadataEnricher{
|
|
|
|
|
initialReady: 1,
|
|
|
|
|
results: []ebooks.EnrichmentRunResult{
|
|
|
|
|
{Claimed: 1, Enriched: 1, Remaining: 1},
|
|
|
|
|
{Claimed: 1, Enriched: 1},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
progress := &ebookMetadataProgressReporter{}
|
|
|
|
|
|
|
|
|
|
if err := NewSyncEbookMetadataTask(enricher).Execute(context.Background(), progress); err != nil {
|
|
|
|
|
t.Fatalf("Execute() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got := progress.percents[1]; got >= 100 {
|
|
|
|
|
t.Fatalf("first batch progress = %.1f, must stay below 100 while HasMore is true", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 16:25:18 +02:00
|
|
|
func TestEbookMetadataTaskStopsAfterOneAllFailedBatch(t *testing.T) {
|
2026-07-19 16:36:30 +02:00
|
|
|
t.Setenv("SILO_EBOOK_BACKFILL_MAX_CLAIMS", "1")
|
|
|
|
|
t.Setenv("SILO_EBOOK_BACKFILL_BATCH_DELAY", "1h")
|
2026-07-19 16:25:18 +02:00
|
|
|
enricher := &fakeEbookMetadataEnricher{results: []ebooks.EnrichmentRunResult{
|
|
|
|
|
{Claimed: 4, Failed: 4, Remaining: 100},
|
|
|
|
|
{Claimed: 4, Enriched: 4, Remaining: 96},
|
|
|
|
|
}}
|
|
|
|
|
progress := &ebookMetadataProgressReporter{}
|
2026-07-19 16:36:30 +02:00
|
|
|
task := NewBackfillEbookMetadataTask(enricher)
|
|
|
|
|
task.sleep = func(context.Context, time.Duration) error {
|
|
|
|
|
t.Fatal("no-progress batch must circuit-break before pacing")
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-07-19 16:25:18 +02:00
|
|
|
|
2026-07-19 16:36:30 +02:00
|
|
|
if err := task.Execute(context.Background(), progress); err != nil {
|
2026-07-19 16:25:18 +02:00
|
|
|
t.Fatalf("Execute() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
assertNoProgressCircuitBreak(t, enricher, progress, ebooks.EnrichmentRunResult{
|
|
|
|
|
Claimed: 4, Failed: 4, Remaining: 100,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestEbookMetadataTaskStopsAfterOneAllDeferredBatch(t *testing.T) {
|
|
|
|
|
enricher := &fakeEbookMetadataEnricher{results: []ebooks.EnrichmentRunResult{
|
|
|
|
|
{Claimed: 4, Deferred: 4, Remaining: 0},
|
|
|
|
|
{Claimed: 4, Enriched: 4, Remaining: 0},
|
|
|
|
|
}}
|
|
|
|
|
progress := &ebookMetadataProgressReporter{}
|
|
|
|
|
|
|
|
|
|
if err := NewBackfillEbookMetadataTask(enricher).Execute(context.Background(), progress); err != nil {
|
|
|
|
|
t.Fatalf("Execute() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
assertNoProgressCircuitBreak(t, enricher, progress, ebooks.EnrichmentRunResult{
|
|
|
|
|
Claimed: 4, Deferred: 4, Remaining: 0,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestEbookMetadataTaskContinuesAfterMixedBatchWithProgress(t *testing.T) {
|
|
|
|
|
enricher := &fakeEbookMetadataEnricher{results: []ebooks.EnrichmentRunResult{
|
|
|
|
|
{Claimed: 4, Enriched: 1, Failed: 2, Deferred: 1, Remaining: 2},
|
|
|
|
|
{Claimed: 2, NoMatch: 2, Remaining: 0},
|
|
|
|
|
}}
|
|
|
|
|
progress := &ebookMetadataProgressReporter{}
|
|
|
|
|
|
|
|
|
|
if err := NewBackfillEbookMetadataTask(enricher).Execute(context.Background(), progress); err != nil {
|
|
|
|
|
t.Fatalf("Execute() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(enricher.scopes) != 2 {
|
|
|
|
|
t.Fatalf("Run calls = %d, want 2 when a mixed batch made progress", len(enricher.scopes))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 16:36:30 +02:00
|
|
|
func TestEbookMetadataBackfillStopsAtClaimCap(t *testing.T) {
|
|
|
|
|
t.Setenv("SILO_EBOOK_BACKFILL_MAX_CLAIMS", "4")
|
|
|
|
|
enricher := &fakeEbookMetadataEnricher{results: []ebooks.EnrichmentRunResult{
|
|
|
|
|
{Claimed: 2, Enriched: 2, Remaining: 10},
|
|
|
|
|
{Claimed: 2, Enriched: 2, Remaining: 8},
|
|
|
|
|
{Claimed: 2, Enriched: 2, Remaining: 6},
|
|
|
|
|
}}
|
|
|
|
|
progress := &ebookMetadataProgressReporter{}
|
|
|
|
|
|
|
|
|
|
if err := NewBackfillEbookMetadataTask(enricher).Execute(context.Background(), progress); err != nil {
|
|
|
|
|
t.Fatalf("Execute() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(enricher.scopes) != 2 {
|
|
|
|
|
t.Fatalf("Run calls = %d, want 2 at claim cap", len(enricher.scopes))
|
|
|
|
|
}
|
|
|
|
|
var result ebooks.EnrichmentRunResult
|
|
|
|
|
if err := json.Unmarshal(progress.results[len(progress.results)-1], &result); err != nil {
|
|
|
|
|
t.Fatalf("result JSON error: %v", err)
|
|
|
|
|
}
|
2026-07-19 16:52:09 +02:00
|
|
|
want := ebooks.EnrichmentRunResult{Claimed: 4, Enriched: 4, Remaining: 8}
|
2026-07-19 16:36:30 +02:00
|
|
|
if result != want {
|
|
|
|
|
t.Fatalf("result JSON = %+v, want %+v", result, want)
|
|
|
|
|
}
|
|
|
|
|
if got := progress.percents[len(progress.percents)-1]; got >= 100 {
|
|
|
|
|
t.Fatalf("claim-capped progress = %.1f, must not report completion", got)
|
|
|
|
|
}
|
|
|
|
|
message := strings.ToLower(progress.messages[len(progress.messages)-1])
|
|
|
|
|
if !strings.Contains(message, "claim cap") || !strings.Contains(message, "retry later") {
|
|
|
|
|
t.Fatalf("claim-cap progress message = %q", message)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 16:52:09 +02:00
|
|
|
func TestEbookMetadataBackfillPassesRemainingClaimAllowanceToEnricher(t *testing.T) {
|
|
|
|
|
t.Setenv("SILO_EBOOK_BACKFILL_MAX_CLAIMS", "3")
|
|
|
|
|
enricher := &fakeEbookMetadataEnricher{results: []ebooks.EnrichmentRunResult{
|
|
|
|
|
{Claimed: 2, Enriched: 2, Remaining: 8},
|
|
|
|
|
{Claimed: 1, Enriched: 1, Remaining: 7},
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
if err := NewBackfillEbookMetadataTask(enricher).Execute(context.Background(), &ebookMetadataProgressReporter{}); err != nil {
|
|
|
|
|
t.Fatalf("Execute() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got := enricher.limits; len(got) != 2 || got[0] != 3 || got[1] != 1 {
|
|
|
|
|
t.Fatalf("claim limits = %v, want [3 1]", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 16:36:30 +02:00
|
|
|
func TestEbookMetadataBackfillDelaysOnlyBetweenProductiveBatches(t *testing.T) {
|
|
|
|
|
enricher := &fakeEbookMetadataEnricher{results: []ebooks.EnrichmentRunResult{
|
|
|
|
|
{Claimed: 1, Enriched: 1, Remaining: 2},
|
|
|
|
|
{Claimed: 1, NoMatch: 1, Remaining: 1},
|
|
|
|
|
{Claimed: 1, Enriched: 1, Remaining: 0},
|
|
|
|
|
}}
|
|
|
|
|
task := NewBackfillEbookMetadataTask(enricher)
|
|
|
|
|
task.batchDelay = time.Second
|
|
|
|
|
var sleeps []time.Duration
|
|
|
|
|
task.sleep = func(_ context.Context, delay time.Duration) error {
|
|
|
|
|
sleeps = append(sleeps, delay)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := task.Execute(context.Background(), &ebookMetadataProgressReporter{}); err != nil {
|
|
|
|
|
t.Fatalf("Execute() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(sleeps) != 2 || sleeps[0] != time.Second || sleeps[1] != time.Second {
|
|
|
|
|
t.Fatalf("sleeps = %v, want two 1s inter-batch delays", sleeps)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestEbookMetadataBackfillCancelsDuringBatchDelay(t *testing.T) {
|
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
|
enricher := &fakeEbookMetadataEnricher{results: []ebooks.EnrichmentRunResult{
|
|
|
|
|
{Claimed: 1, Enriched: 1, Remaining: 2},
|
|
|
|
|
{Claimed: 1, Enriched: 1, Remaining: 1},
|
|
|
|
|
}}
|
|
|
|
|
task := NewBackfillEbookMetadataTask(enricher)
|
|
|
|
|
task.batchDelay = time.Second
|
|
|
|
|
task.sleep = func(ctx context.Context, _ time.Duration) error {
|
|
|
|
|
cancel()
|
|
|
|
|
<-ctx.Done()
|
|
|
|
|
return ctx.Err()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
err := task.Execute(ctx, &ebookMetadataProgressReporter{})
|
|
|
|
|
if !errors.Is(err, context.Canceled) {
|
|
|
|
|
t.Fatalf("Execute() error = %v, want context.Canceled", err)
|
|
|
|
|
}
|
|
|
|
|
if len(enricher.scopes) != 1 {
|
|
|
|
|
t.Fatalf("Run calls = %d, want 1 before cancellation", len(enricher.scopes))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestEbookMetadataBackfillDoesNotDelayPastExecutionBudget(t *testing.T) {
|
|
|
|
|
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
|
|
|
|
|
enricher := &fakeEbookMetadataEnricher{results: []ebooks.EnrichmentRunResult{
|
|
|
|
|
{Claimed: 1, Enriched: 1, Remaining: 2},
|
|
|
|
|
{Claimed: 1, Enriched: 1, Remaining: 1},
|
|
|
|
|
}}
|
|
|
|
|
task := NewBackfillEbookMetadataTask(enricher)
|
|
|
|
|
task.now = func() time.Time { return now }
|
|
|
|
|
task.budget = 1500 * time.Millisecond
|
|
|
|
|
task.batchDelay = time.Second
|
|
|
|
|
var sleeps int
|
|
|
|
|
task.sleep = func(_ context.Context, delay time.Duration) error {
|
|
|
|
|
sleeps++
|
|
|
|
|
now = now.Add(delay)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
progress := &ebookMetadataProgressReporter{}
|
|
|
|
|
|
|
|
|
|
if err := task.Execute(context.Background(), progress); err != nil {
|
|
|
|
|
t.Fatalf("Execute() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if sleeps != 1 {
|
|
|
|
|
t.Fatalf("sleep calls = %d, want 1 before remaining budget became too short", sleeps)
|
|
|
|
|
}
|
|
|
|
|
if len(enricher.scopes) != 2 {
|
|
|
|
|
t.Fatalf("Run calls = %d, want 2", len(enricher.scopes))
|
|
|
|
|
}
|
|
|
|
|
message := strings.ToLower(progress.messages[len(progress.messages)-1])
|
|
|
|
|
if !strings.Contains(message, "execution budget") {
|
|
|
|
|
t.Fatalf("budget progress message = %q", message)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 16:52:09 +02:00
|
|
|
func TestEbookMetadataBackfillInvalidNonemptyEnvironmentFailsClosed(t *testing.T) {
|
2026-07-19 16:36:30 +02:00
|
|
|
for _, tc := range []struct {
|
2026-07-19 16:52:09 +02:00
|
|
|
name string
|
|
|
|
|
max string
|
|
|
|
|
delay string
|
2026-07-19 16:36:30 +02:00
|
|
|
}{
|
|
|
|
|
{name: "malformed", max: "many", delay: "later"},
|
|
|
|
|
{name: "negative", max: "-5", delay: "-1s"},
|
|
|
|
|
{name: "zero", max: "0", delay: "0"},
|
2026-07-19 16:52:09 +02:00
|
|
|
{name: "invalid max only", max: "many", delay: "1s"},
|
|
|
|
|
{name: "invalid delay only", max: "20", delay: "later"},
|
2026-07-19 16:36:30 +02:00
|
|
|
} {
|
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
|
t.Setenv("SILO_EBOOK_BACKFILL_MAX_CLAIMS", tc.max)
|
|
|
|
|
t.Setenv("SILO_EBOOK_BACKFILL_BATCH_DELAY", tc.delay)
|
2026-07-19 16:52:09 +02:00
|
|
|
enricher := &fakeEbookMetadataEnricher{}
|
|
|
|
|
err := NewBackfillEbookMetadataTask(enricher).Execute(context.Background(), &ebookMetadataProgressReporter{})
|
|
|
|
|
if err == nil || !strings.Contains(err.Error(), "invalid ebook backfill configuration") {
|
|
|
|
|
t.Fatalf("Execute() error = %v, want clear invalid configuration error", err)
|
|
|
|
|
}
|
|
|
|
|
if len(enricher.scopes) != 0 || enricher.readyCountCalls != 0 {
|
|
|
|
|
t.Fatalf("invalid configuration reached queue: runs=%d counts=%d", len(enricher.scopes), enricher.readyCountCalls)
|
2026-07-19 16:36:30 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 16:52:09 +02:00
|
|
|
func TestEbookMetadataBackfillUnsetAndValidEnvironment(t *testing.T) {
|
|
|
|
|
t.Run("unset", func(t *testing.T) {
|
|
|
|
|
t.Setenv("SILO_EBOOK_BACKFILL_MAX_CLAIMS", "")
|
|
|
|
|
t.Setenv("SILO_EBOOK_BACKFILL_BATCH_DELAY", "")
|
|
|
|
|
task := NewBackfillEbookMetadataTask(&fakeEbookMetadataEnricher{})
|
|
|
|
|
if task.configErr != nil || task.maxClaims != 0 || task.batchDelay != 0 {
|
|
|
|
|
t.Fatalf("unset controls = (%d, %s, %v)", task.maxClaims, task.batchDelay, task.configErr)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
t.Run("valid", func(t *testing.T) {
|
|
|
|
|
t.Setenv("SILO_EBOOK_BACKFILL_MAX_CLAIMS", " 20 ")
|
|
|
|
|
t.Setenv("SILO_EBOOK_BACKFILL_BATCH_DELAY", " 1s ")
|
|
|
|
|
task := NewBackfillEbookMetadataTask(&fakeEbookMetadataEnricher{})
|
|
|
|
|
if task.configErr != nil || task.maxClaims != 20 || task.batchDelay != time.Second {
|
|
|
|
|
t.Fatalf("valid controls = (%d, %s, %v)", task.maxClaims, task.batchDelay, task.configErr)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 16:36:30 +02:00
|
|
|
func TestEbookMetadataSyncIgnoresBackfillCanaryEnvironment(t *testing.T) {
|
|
|
|
|
t.Setenv("SILO_EBOOK_BACKFILL_MAX_CLAIMS", "1")
|
|
|
|
|
t.Setenv("SILO_EBOOK_BACKFILL_BATCH_DELAY", "1h")
|
|
|
|
|
task := NewSyncEbookMetadataTask(&fakeEbookMetadataEnricher{})
|
|
|
|
|
if task.maxClaims != 0 || task.batchDelay != 0 {
|
|
|
|
|
t.Fatalf("sync controls = (%d, %s), want disabled", task.maxClaims, task.batchDelay)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 16:25:18 +02:00
|
|
|
func assertNoProgressCircuitBreak(
|
|
|
|
|
t *testing.T,
|
|
|
|
|
enricher *fakeEbookMetadataEnricher,
|
|
|
|
|
progress *ebookMetadataProgressReporter,
|
|
|
|
|
want ebooks.EnrichmentRunResult,
|
|
|
|
|
) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
if len(enricher.scopes) != 1 {
|
|
|
|
|
t.Fatalf("Run calls = %d, want exactly 1", len(enricher.scopes))
|
|
|
|
|
}
|
|
|
|
|
if len(progress.results) == 0 {
|
|
|
|
|
t.Fatal("no result JSON reported")
|
|
|
|
|
}
|
|
|
|
|
var result ebooks.EnrichmentRunResult
|
|
|
|
|
if err := json.Unmarshal(progress.results[len(progress.results)-1], &result); err != nil {
|
|
|
|
|
t.Fatalf("result JSON error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if result != want {
|
|
|
|
|
t.Fatalf("result JSON = %+v, want %+v", result, want)
|
|
|
|
|
}
|
|
|
|
|
if got := progress.percents[len(progress.percents)-1]; got >= 100 {
|
|
|
|
|
t.Fatalf("circuit-break progress = %.1f, must not report completion", got)
|
|
|
|
|
}
|
|
|
|
|
message := progress.messages[len(progress.messages)-1]
|
|
|
|
|
if !strings.Contains(strings.ToLower(message), "no progress") ||
|
|
|
|
|
!strings.Contains(strings.ToLower(message), "retry later") {
|
|
|
|
|
t.Fatalf("circuit-break progress message = %q", message)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 15:48:24 +02:00
|
|
|
func TestEbookMetadataBackfillUsesLegacyScope(t *testing.T) {
|
|
|
|
|
enricher := &fakeEbookMetadataEnricher{results: []ebooks.EnrichmentRunResult{{Remaining: 0}}}
|
|
|
|
|
task := NewBackfillEbookMetadataTask(enricher)
|
|
|
|
|
if err := task.Execute(context.Background(), &ebookMetadataProgressReporter{}); err != nil {
|
|
|
|
|
t.Fatalf("Execute() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(enricher.scopes) != 1 || enricher.scopes[0] != ebooks.EnrichmentScopeLegacy {
|
|
|
|
|
t.Fatalf("backfill scopes = %#v, want legacy", enricher.scopes)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestEbookMetadataTaskStopsBetweenBatchesOnCancellation(t *testing.T) {
|
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
|
enricher := &fakeEbookMetadataEnricher{
|
|
|
|
|
results: []ebooks.EnrichmentRunResult{{Claimed: 1, Enriched: 1, Remaining: 2}},
|
|
|
|
|
onRun: func(int) {
|
|
|
|
|
cancel()
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
err := NewSyncEbookMetadataTask(enricher).Execute(ctx, &ebookMetadataProgressReporter{})
|
|
|
|
|
if !errors.Is(err, context.Canceled) {
|
|
|
|
|
t.Fatalf("Execute() error = %v, want context.Canceled", err)
|
|
|
|
|
}
|
|
|
|
|
if len(enricher.scopes) != 1 {
|
|
|
|
|
t.Fatalf("Run calls = %d, want 1", len(enricher.scopes))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestEbookMetadataTaskTreatsBudgetAsCleanDeferredCompletion(t *testing.T) {
|
|
|
|
|
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
|
|
|
|
|
enricher := &fakeEbookMetadataEnricher{
|
|
|
|
|
results: []ebooks.EnrichmentRunResult{{Claimed: 2, Enriched: 2, Remaining: 5}},
|
|
|
|
|
onRun: func(int) {
|
|
|
|
|
now = now.Add(5 * time.Minute)
|
|
|
|
|
},
|
|
|
|
|
}
|
2026-06-10 08:18:35 -04:00
|
|
|
task := NewSyncEbookMetadataTask(enricher)
|
2026-07-19 15:48:24 +02:00
|
|
|
task.now = func() time.Time { return now }
|
|
|
|
|
task.budget = 4 * time.Minute
|
|
|
|
|
progress := &ebookMetadataProgressReporter{}
|
|
|
|
|
|
|
|
|
|
if err := task.Execute(context.Background(), progress); err != nil {
|
|
|
|
|
t.Fatalf("Execute() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(enricher.scopes) != 1 {
|
|
|
|
|
t.Fatalf("Run calls = %d, want 1", len(enricher.scopes))
|
|
|
|
|
}
|
|
|
|
|
var result ebooks.EnrichmentRunResult
|
|
|
|
|
if err := json.Unmarshal(progress.results[len(progress.results)-1], &result); err != nil {
|
|
|
|
|
t.Fatalf("result JSON error: %v", err)
|
|
|
|
|
}
|
2026-07-19 16:52:09 +02:00
|
|
|
if result.Remaining != 5 || result.Deferred != 0 {
|
|
|
|
|
t.Fatalf("budget result = %+v, want remaining=5 without double-counting it as deferred", result)
|
2026-07-19 15:48:24 +02:00
|
|
|
}
|
|
|
|
|
if got := progress.percents[len(progress.percents)-1]; got >= 100 {
|
|
|
|
|
t.Fatalf("budget-limited progress = %.1f, must remain below 100", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-10 08:18:35 -04:00
|
|
|
|
2026-07-19 15:48:24 +02:00
|
|
|
func TestEbookMetadataTaskWrapsRunError(t *testing.T) {
|
|
|
|
|
enricher := &fakeEbookMetadataEnricher{err: errors.New("boom")}
|
|
|
|
|
err := NewSyncEbookMetadataTask(enricher).Execute(context.Background(), &ebookMetadataProgressReporter{})
|
2026-06-10 08:18:35 -04:00
|
|
|
if err == nil || !strings.Contains(err.Error(), "ebook metadata sync") {
|
|
|
|
|
t.Fatalf("Execute() error = %v, want wrapped ebook metadata sync error", err)
|
|
|
|
|
}
|
|
|
|
|
}
|