2026-06-10 08:18:35 -04:00
|
|
|
package tasks
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
2026-07-19 16:52:09 +02:00
|
|
|
"errors"
|
2026-06-10 08:18:35 -04:00
|
|
|
"fmt"
|
2026-07-19 16:36:30 +02:00
|
|
|
"os"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
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"
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-19 16:36:30 +02:00
|
|
|
const (
|
|
|
|
|
ebookMetadataExecutionBudget = 4 * time.Minute
|
|
|
|
|
ebookBackfillMaxClaimsEnv = "SILO_EBOOK_BACKFILL_MAX_CLAIMS"
|
|
|
|
|
ebookBackfillBatchDelayEnv = "SILO_EBOOK_BACKFILL_BATCH_DELAY"
|
|
|
|
|
)
|
2026-07-19 15:48:24 +02:00
|
|
|
|
2026-06-10 08:18:35 -04:00
|
|
|
type ebookMetadataEnricher interface {
|
2026-07-19 16:52:09 +02:00
|
|
|
ReadyCount(ctx context.Context, scope ebooks.EnrichmentScope) (int, error)
|
|
|
|
|
RunLimited(ctx context.Context, scope ebooks.EnrichmentScope, maxClaims int) (ebooks.EnrichmentRunResult, error)
|
2026-07-19 15:48:24 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ebookMetadataTask struct {
|
|
|
|
|
enricher ebookMetadataEnricher
|
|
|
|
|
scope ebooks.EnrichmentScope
|
|
|
|
|
key string
|
|
|
|
|
name string
|
|
|
|
|
description string
|
|
|
|
|
triggers []taskmanager.TriggerConfig
|
|
|
|
|
budget time.Duration
|
|
|
|
|
now func() time.Time
|
2026-07-19 16:36:30 +02:00
|
|
|
maxClaims int
|
|
|
|
|
batchDelay time.Duration
|
|
|
|
|
sleep func(context.Context, time.Duration) error
|
2026-07-19 15:48:24 +02:00
|
|
|
errorPrefix string
|
2026-07-19 16:52:09 +02:00
|
|
|
configErr error
|
2026-06-10 08:18:35 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-19 15:48:24 +02:00
|
|
|
// SyncEbookMetadataTask drains new and recurring ebook metadata work.
|
2026-06-10 08:18:35 -04:00
|
|
|
type SyncEbookMetadataTask struct {
|
2026-07-19 15:48:24 +02:00
|
|
|
*ebookMetadataTask
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 13:23:20 +02:00
|
|
|
// BackfillEbookMetadataTask drains the legacy ebook backlog on its own
|
|
|
|
|
// interval, isolated from the scheduled sync lane so backlog work never
|
|
|
|
|
// competes with fresh-item enrichment.
|
2026-07-19 15:48:24 +02:00
|
|
|
type BackfillEbookMetadataTask struct {
|
|
|
|
|
*ebookMetadataTask
|
2026-06-10 08:18:35 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewSyncEbookMetadataTask(enricher ebookMetadataEnricher) *SyncEbookMetadataTask {
|
2026-07-19 15:48:24 +02:00
|
|
|
return &SyncEbookMetadataTask{ebookMetadataTask: &ebookMetadataTask{
|
|
|
|
|
enricher: enricher,
|
|
|
|
|
scope: ebooks.EnrichmentScopeIncremental,
|
|
|
|
|
key: "sync_ebook_metadata",
|
|
|
|
|
name: "Sync Ebook Metadata",
|
|
|
|
|
description: "Fetches metadata for new ebooks and ebooks due for a recurring refresh",
|
|
|
|
|
triggers: []taskmanager.TriggerConfig{{Type: taskmanager.TriggerTypeInterval, IntervalMs: 5 * 60 * 1000}},
|
|
|
|
|
budget: ebookMetadataExecutionBudget,
|
|
|
|
|
now: time.Now,
|
|
|
|
|
errorPrefix: "ebook metadata sync",
|
|
|
|
|
}}
|
2026-06-10 08:18:35 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-19 15:48:24 +02:00
|
|
|
func NewBackfillEbookMetadataTask(enricher ebookMetadataEnricher) *BackfillEbookMetadataTask {
|
2026-07-19 16:52:09 +02:00
|
|
|
maxClaims, maxClaimsErr := parseEbookBackfillMaxClaims(os.Getenv(ebookBackfillMaxClaimsEnv))
|
|
|
|
|
batchDelay, batchDelayErr := parseEbookBackfillBatchDelay(os.Getenv(ebookBackfillBatchDelayEnv))
|
2026-07-19 15:48:24 +02:00
|
|
|
return &BackfillEbookMetadataTask{ebookMetadataTask: &ebookMetadataTask{
|
|
|
|
|
enricher: enricher,
|
|
|
|
|
scope: ebooks.EnrichmentScopeLegacy,
|
|
|
|
|
key: "backfill_ebook_metadata",
|
|
|
|
|
name: "Backfill Ebook Metadata",
|
2026-07-20 13:23:20 +02:00
|
|
|
description: "Enriches the legacy ebook backlog without competing with scheduled metadata sync",
|
|
|
|
|
triggers: []taskmanager.TriggerConfig{{Type: taskmanager.TriggerTypeInterval, IntervalMs: 15 * 60 * 1000}},
|
2026-07-19 15:48:24 +02:00
|
|
|
budget: ebookMetadataExecutionBudget,
|
|
|
|
|
now: time.Now,
|
2026-07-19 16:52:09 +02:00
|
|
|
maxClaims: maxClaims,
|
|
|
|
|
batchDelay: batchDelay,
|
2026-07-19 16:36:30 +02:00
|
|
|
sleep: sleepEbookBackfill,
|
2026-07-19 15:48:24 +02:00
|
|
|
errorPrefix: "ebook metadata backfill",
|
2026-07-19 16:52:09 +02:00
|
|
|
configErr: errors.Join(maxClaimsErr, batchDelayErr),
|
2026-07-19 15:48:24 +02:00
|
|
|
}}
|
2026-06-10 08:18:35 -04:00
|
|
|
}
|
2026-07-19 15:48:24 +02:00
|
|
|
|
|
|
|
|
func (t *ebookMetadataTask) Key() string { return t.key }
|
|
|
|
|
func (t *ebookMetadataTask) Name() string { return t.name }
|
|
|
|
|
func (t *ebookMetadataTask) Description() string { return t.description }
|
|
|
|
|
func (t *ebookMetadataTask) Category() taskmanager.TaskCategory {
|
2026-06-10 08:18:35 -04:00
|
|
|
return taskmanager.TaskCategoryMetadata
|
|
|
|
|
}
|
2026-07-19 15:48:24 +02:00
|
|
|
func (t *ebookMetadataTask) IsHidden() bool { return false }
|
|
|
|
|
|
|
|
|
|
func (t *ebookMetadataTask) DefaultTriggers() []taskmanager.TriggerConfig {
|
|
|
|
|
return append([]taskmanager.TriggerConfig(nil), t.triggers...)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (t *ebookMetadataTask) Execute(ctx context.Context, progress taskmanager.ProgressReporter) error {
|
2026-07-19 16:52:09 +02:00
|
|
|
if t.configErr != nil {
|
|
|
|
|
return fmt.Errorf("invalid ebook backfill configuration: %w", t.configErr)
|
|
|
|
|
}
|
2026-07-19 15:48:24 +02:00
|
|
|
progress.Report(0, fmt.Sprintf("%s started", t.name))
|
|
|
|
|
started := t.now()
|
2026-07-19 16:52:09 +02:00
|
|
|
initialReady, err := t.enricher.ReadyCount(ctx, t.scope)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("%s: count initial work: %w", t.errorPrefix, err)
|
|
|
|
|
}
|
|
|
|
|
total := ebooks.EnrichmentRunResult{Remaining: initialReady}
|
2026-06-10 08:18:35 -04:00
|
|
|
|
2026-07-19 15:48:24 +02:00
|
|
|
for {
|
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 16:52:09 +02:00
|
|
|
claimLimit := 0
|
|
|
|
|
if t.maxClaims > 0 {
|
|
|
|
|
claimLimit = t.maxClaims - total.Claimed
|
|
|
|
|
if claimLimit <= 0 {
|
|
|
|
|
reportEbookEnrichmentPause(progress, total, fmt.Sprintf(
|
|
|
|
|
"Paused after reaching manual backfill claim cap of %d; remaining work will retry later.",
|
|
|
|
|
t.maxClaims,
|
|
|
|
|
))
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
batch, err := t.enricher.RunLimited(ctx, t.scope, claimLimit)
|
2026-07-19 15:48:24 +02:00
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("%s: %w", t.errorPrefix, err)
|
|
|
|
|
}
|
|
|
|
|
addEbookEnrichmentResult(&total, batch)
|
2026-07-19 16:52:09 +02:00
|
|
|
total.Remaining = max(total.Remaining-batch.Claimed, 0)
|
|
|
|
|
total.HasMore = batch.HasMore
|
2026-07-19 15:48:24 +02:00
|
|
|
reportEbookEnrichmentProgress(progress, total, false)
|
|
|
|
|
|
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-07-19 16:25:18 +02:00
|
|
|
if ebookEnrichmentBatchMadeNoProgress(batch) {
|
|
|
|
|
reportEbookEnrichmentCircuitBreak(progress, total)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-07-19 16:52:09 +02:00
|
|
|
if !batch.HasMore {
|
2026-07-19 15:48:24 +02:00
|
|
|
reportEbookEnrichmentProgress(progress, total, true)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if batch.Claimed == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-07-19 16:36:30 +02:00
|
|
|
if t.maxClaims > 0 && total.Claimed >= t.maxClaims {
|
|
|
|
|
reportEbookEnrichmentPause(progress, total, fmt.Sprintf(
|
|
|
|
|
"Paused after reaching manual backfill claim cap of %d; remaining work will retry later.",
|
|
|
|
|
t.maxClaims,
|
|
|
|
|
))
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-07-19 15:48:24 +02:00
|
|
|
if t.now().Sub(started) >= t.budget {
|
|
|
|
|
reportEbookEnrichmentProgress(progress, total, false)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-07-19 16:36:30 +02:00
|
|
|
if t.batchDelay > 0 && ebookEnrichmentBatchMadeProgress(batch) {
|
|
|
|
|
if t.batchDelay >= t.budget-t.now().Sub(started) {
|
|
|
|
|
reportEbookEnrichmentPause(progress, total,
|
|
|
|
|
"Paused before the next batch because its delay would exceed the ebook metadata execution budget; remaining work will retry later.")
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if err := t.sleep(ctx, t.batchDelay); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if t.now().Sub(started) >= t.budget {
|
|
|
|
|
reportEbookEnrichmentPause(progress, total,
|
|
|
|
|
"Paused after reaching the ebook metadata execution budget; remaining work will retry later.")
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 16:52:09 +02:00
|
|
|
func parseEbookBackfillMaxClaims(raw string) (int, error) {
|
|
|
|
|
raw = strings.TrimSpace(raw)
|
|
|
|
|
if raw == "" {
|
|
|
|
|
return 0, nil
|
|
|
|
|
}
|
|
|
|
|
value, err := strconv.Atoi(raw)
|
2026-07-19 16:36:30 +02:00
|
|
|
if err != nil || value <= 0 {
|
2026-07-19 16:52:09 +02:00
|
|
|
return 0, fmt.Errorf("%s must be a positive integer, got %q", ebookBackfillMaxClaimsEnv, raw)
|
2026-07-19 16:36:30 +02:00
|
|
|
}
|
2026-07-19 16:52:09 +02:00
|
|
|
return value, nil
|
2026-07-19 16:36:30 +02:00
|
|
|
}
|
|
|
|
|
|
2026-07-19 16:52:09 +02:00
|
|
|
func parseEbookBackfillBatchDelay(raw string) (time.Duration, error) {
|
|
|
|
|
raw = strings.TrimSpace(raw)
|
|
|
|
|
if raw == "" {
|
|
|
|
|
return 0, nil
|
|
|
|
|
}
|
|
|
|
|
value, err := time.ParseDuration(raw)
|
2026-07-19 16:36:30 +02:00
|
|
|
if err != nil || value <= 0 {
|
2026-07-19 16:52:09 +02:00
|
|
|
return 0, fmt.Errorf("%s must be a positive Go duration, got %q", ebookBackfillBatchDelayEnv, raw)
|
2026-07-19 16:36:30 +02:00
|
|
|
}
|
2026-07-19 16:52:09 +02:00
|
|
|
return value, nil
|
2026-07-19 16:36:30 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func sleepEbookBackfill(ctx context.Context, delay time.Duration) error {
|
|
|
|
|
timer := time.NewTimer(delay)
|
|
|
|
|
defer timer.Stop()
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return ctx.Err()
|
|
|
|
|
case <-timer.C:
|
|
|
|
|
return nil
|
2026-06-10 08:18:35 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 16:25:18 +02:00
|
|
|
func ebookEnrichmentBatchMadeNoProgress(batch ebooks.EnrichmentRunResult) bool {
|
|
|
|
|
return batch.Claimed > 0 &&
|
|
|
|
|
batch.Enriched == 0 &&
|
|
|
|
|
batch.NoMatch == 0 &&
|
|
|
|
|
batch.Failed+batch.Deferred >= batch.Claimed
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 16:36:30 +02:00
|
|
|
func ebookEnrichmentBatchMadeProgress(batch ebooks.EnrichmentRunResult) bool {
|
|
|
|
|
return batch.Enriched > 0 || batch.NoMatch > 0
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 15:48:24 +02:00
|
|
|
func addEbookEnrichmentResult(total *ebooks.EnrichmentRunResult, batch ebooks.EnrichmentRunResult) {
|
|
|
|
|
total.Claimed += batch.Claimed
|
|
|
|
|
total.Enriched += batch.Enriched
|
|
|
|
|
total.NoMatch += batch.NoMatch
|
|
|
|
|
total.Failed += batch.Failed
|
|
|
|
|
total.Deferred += batch.Deferred
|
2026-07-20 12:42:56 +02:00
|
|
|
total.Discarded += batch.Discarded
|
2026-07-19 15:48:24 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func reportEbookEnrichmentProgress(
|
|
|
|
|
progress taskmanager.ProgressReporter,
|
|
|
|
|
result ebooks.EnrichmentRunResult,
|
|
|
|
|
complete bool,
|
|
|
|
|
) {
|
|
|
|
|
data, _ := json.Marshal(result)
|
|
|
|
|
progress.SetResultData(data)
|
2026-06-10 08:18:35 -04:00
|
|
|
|
2026-07-19 15:48:24 +02:00
|
|
|
percent := ebookEnrichmentPercent(result)
|
|
|
|
|
if complete && result.Remaining == 0 {
|
|
|
|
|
percent = 100
|
2026-06-10 08:18:35 -04:00
|
|
|
}
|
2026-07-19 15:48:24 +02:00
|
|
|
progress.Report(percent, fmt.Sprintf(
|
2026-07-20 12:42:56 +02:00
|
|
|
"Claimed %d, enriched %d, no match %d, failed %d, deferred %d, discarded %d, remaining %d",
|
2026-07-19 15:48:24 +02:00
|
|
|
result.Claimed,
|
|
|
|
|
result.Enriched,
|
|
|
|
|
result.NoMatch,
|
|
|
|
|
result.Failed,
|
|
|
|
|
result.Deferred,
|
2026-07-20 12:42:56 +02:00
|
|
|
result.Discarded,
|
2026-07-19 15:48:24 +02:00
|
|
|
result.Remaining,
|
|
|
|
|
))
|
|
|
|
|
}
|
2026-06-10 08:18:35 -04:00
|
|
|
|
2026-07-19 16:25:18 +02:00
|
|
|
func reportEbookEnrichmentCircuitBreak(
|
|
|
|
|
progress taskmanager.ProgressReporter,
|
|
|
|
|
result ebooks.EnrichmentRunResult,
|
|
|
|
|
) {
|
|
|
|
|
data, _ := json.Marshal(result)
|
|
|
|
|
progress.SetResultData(data)
|
|
|
|
|
percent := ebookEnrichmentPercent(result)
|
|
|
|
|
if percent >= 100 {
|
|
|
|
|
percent = 99
|
|
|
|
|
}
|
|
|
|
|
progress.Report(percent, fmt.Sprintf(
|
|
|
|
|
"Paused after a full batch made no progress; remaining work will retry later. Claimed %d, failed %d, deferred %d, remaining %d",
|
|
|
|
|
result.Claimed,
|
|
|
|
|
result.Failed,
|
|
|
|
|
result.Deferred,
|
|
|
|
|
result.Remaining,
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 16:36:30 +02:00
|
|
|
func reportEbookEnrichmentPause(
|
|
|
|
|
progress taskmanager.ProgressReporter,
|
|
|
|
|
result ebooks.EnrichmentRunResult,
|
|
|
|
|
message string,
|
|
|
|
|
) {
|
|
|
|
|
data, _ := json.Marshal(result)
|
|
|
|
|
progress.SetResultData(data)
|
|
|
|
|
percent := ebookEnrichmentPercent(result)
|
|
|
|
|
if percent >= 100 {
|
|
|
|
|
percent = 99
|
|
|
|
|
}
|
|
|
|
|
progress.Report(percent, fmt.Sprintf(
|
2026-07-20 12:42:56 +02:00
|
|
|
"%s Claimed %d, enriched %d, no match %d, failed %d, deferred %d, discarded %d, remaining %d",
|
2026-07-19 16:36:30 +02:00
|
|
|
message,
|
|
|
|
|
result.Claimed,
|
|
|
|
|
result.Enriched,
|
|
|
|
|
result.NoMatch,
|
|
|
|
|
result.Failed,
|
|
|
|
|
result.Deferred,
|
2026-07-20 12:42:56 +02:00
|
|
|
result.Discarded,
|
2026-07-19 16:36:30 +02:00
|
|
|
result.Remaining,
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 15:48:24 +02:00
|
|
|
func ebookEnrichmentPercent(result ebooks.EnrichmentRunResult) float64 {
|
|
|
|
|
if result.Remaining == 0 {
|
2026-07-19 16:52:09 +02:00
|
|
|
if result.HasMore {
|
|
|
|
|
return 99
|
|
|
|
|
}
|
2026-07-19 15:48:24 +02:00
|
|
|
return 100
|
|
|
|
|
}
|
|
|
|
|
total := result.Claimed + result.Remaining
|
|
|
|
|
if total <= 0 {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
percent := float64(result.Claimed) * 100 / float64(total)
|
|
|
|
|
if percent >= 100 {
|
|
|
|
|
return 99
|
|
|
|
|
}
|
|
|
|
|
return percent
|
2026-06-10 08:18:35 -04:00
|
|
|
}
|