Files
silo-server/internal/ebooks/enrichment_queue.go
T
rxwatcherandClaude Fable 5 dfce4972e4 fix(ebooks): floor rate-limited requeue delays
The ebook-metadata plugin attaches ~1s RetryInfo to ResourceExhausted
errors as request-pacing advice for its internal token bucket. Adopting
that hint verbatim as the queue horizon made rate-limited rows claimable
again immediately, so every backfill run re-claimed the same saturated
tail. Clamp rate-limited requeues to a 15m floor (SILO_EBOOK_RATE_LIMIT_COOLDOWN
to tune); hints above the floor are honored up to the existing 24h cap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 13:43:57 +02:00

831 lines
23 KiB
Go

package ebooks
import (
"context"
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/catalog"
)
const (
defaultEnrichmentLease = 10 * time.Minute
maxEnrichmentRetry = 24 * time.Hour
transientRetryBase = 5 * time.Minute
skippedRetryHorizon = 15 * time.Minute
claimCandidateWindow = maxEnrichWorkers
defaultRateLimitCooldown = 15 * time.Minute
rateLimitCooldownEnv = "SILO_EBOOK_RATE_LIMIT_COOLDOWN"
)
func rateLimitCooldownFloor() time.Duration {
if v := os.Getenv(rateLimitCooldownEnv); v != "" {
if parsed, err := time.ParseDuration(v); err == nil && parsed > 0 {
return parsed
}
}
return defaultRateLimitCooldown
}
type EnrichmentOutcome string
const (
EnrichmentOutcomeSuccess EnrichmentOutcome = "success"
EnrichmentOutcomeNoMatch EnrichmentOutcome = "no_match"
EnrichmentOutcomeSkipped EnrichmentOutcome = "skipped"
)
type EnrichmentErrorClass string
const (
EnrichmentErrorTransient EnrichmentErrorClass = "transient"
EnrichmentErrorRateLimited EnrichmentErrorClass = "rate_limited"
EnrichmentErrorPermanent EnrichmentErrorClass = "permanent"
)
var ErrEnrichmentLeaseLost = errors.New("ebook enrichment lease lost")
type EnrichmentScope string
const (
EnrichmentScopeIncremental EnrichmentScope = "incremental"
EnrichmentScopeLegacy EnrichmentScope = "legacy"
)
func (s EnrichmentScope) validate() error {
switch s {
case EnrichmentScopeIncremental, EnrichmentScopeLegacy:
return nil
default:
return fmt.Errorf("unsupported ebook enrichment scope %q", s)
}
}
type EnrichmentJob struct {
ContentID string
Token string
Attempts int
LastAttemptAt time.Time
ProtectedFields []string
}
type EnrichmentQueue struct {
pool *pgxpool.Pool
}
func NewEnrichmentQueue(pool *pgxpool.Pool) *EnrichmentQueue {
return &EnrichmentQueue{pool: pool}
}
const ebookProtectedFieldsSQL = `
ARRAY_REMOVE(ARRAY[
CASE WHEN lower(trim(mi.status)) = 'pending' AND trim(COALESCE(mi.title, '')) <> '' THEN 'title' END,
CASE WHEN lower(trim(mi.status)) = 'pending' AND COALESCE(mi.year, 0) > 0 THEN 'year' END,
CASE WHEN lower(trim(mi.status)) = 'pending' AND trim(COALESCE(mi.overview, '')) <> '' THEN 'overview' END,
CASE WHEN lower(trim(mi.status)) = 'pending' AND trim(COALESCE(mi.tagline, '')) <> '' THEN 'tagline' END,
CASE WHEN lower(trim(mi.status)) = 'pending' AND trim(COALESCE(mi.content_rating, '')) <> '' THEN 'content_rating' END,
CASE WHEN lower(trim(mi.status)) = 'pending' AND COALESCE(mi.runtime, 0) > 0 THEN 'runtime' END,
CASE WHEN lower(trim(mi.status)) = 'pending' AND trim(COALESCE(mi.release_date::text, '')) <> '' THEN 'release_date' END,
CASE WHEN lower(trim(mi.status)) = 'pending' AND cardinality(COALESCE(mi.genres, '{}'::text[])) > 0 THEN 'genres' END,
CASE WHEN lower(trim(mi.status)) = 'pending' AND cardinality(COALESCE(mi.studios, '{}'::text[])) > 0 THEN 'studios' END,
CASE WHEN lower(trim(mi.status)) = 'pending' AND EXISTS (
SELECT 1 FROM item_people ip WHERE ip.content_id = mi.content_id AND ip.kind = 7
) THEN 'authors' END,
CASE WHEN trim(COALESCE(mi.poster_path, '')) <> ''
AND lower(trim(mi.poster_path)) NOT LIKE 'http://%'
AND lower(trim(mi.poster_path)) NOT LIKE 'https://%'
AND lower(trim(mi.poster_path)) NOT LIKE 'ebook-metadata/ebooks/%'
THEN 'poster_path' END,
CASE WHEN trim(COALESCE(mi.backdrop_path, '')) <> ''
AND lower(trim(mi.backdrop_path)) NOT LIKE 'http://%'
AND lower(trim(mi.backdrop_path)) NOT LIKE 'https://%'
AND lower(trim(mi.backdrop_path)) NOT LIKE 'ebook-metadata/ebooks/%'
THEN 'backdrop_path' END,
CASE WHEN trim(COALESCE(mi.logo_path, '')) <> ''
AND lower(trim(mi.logo_path)) NOT LIKE 'http://%'
AND lower(trim(mi.logo_path)) NOT LIKE 'https://%'
AND lower(trim(mi.logo_path)) NOT LIKE 'ebook-metadata/ebooks/%'
THEN 'logo_path' END
]::text[], NULL)
`
const mergeEbookProtectedFieldsSQL = `
ARRAY(
SELECT DISTINCT field
FROM unnest(
ebook_enrichment_state.protected_fields ||
ARRAY(
SELECT candidate
FROM unnest(EXCLUDED.protected_fields) AS candidate
WHERE candidate IN ('poster_path', 'backdrop_path', 'logo_path')
)
) AS field
ORDER BY field
)
`
var enqueueEnrichmentJobQuery = `
INSERT INTO ebook_enrichment_state (
content_id, status, priority, next_attempt_at, protected_fields, updated_at
)
SELECT mi.content_id, 'pending', $2, now(), ` + ebookProtectedFieldsSQL + `, now()
FROM media_items mi
WHERE mi.content_id = $1
AND mi.type = 'ebook'
AND ` + catalog.MangaChapterExclusionWhere("mi") + `
ON CONFLICT (content_id) DO UPDATE SET
status = CASE
WHEN ebook_enrichment_state.status = 'running' THEN ebook_enrichment_state.status
ELSE 'pending'
END,
priority = CASE
WHEN ebook_enrichment_state.status = 'running' THEN ebook_enrichment_state.priority
ELSE GREATEST(ebook_enrichment_state.priority, EXCLUDED.priority)
END,
next_attempt_at = CASE
WHEN ebook_enrichment_state.status = 'running' THEN ebook_enrichment_state.next_attempt_at
ELSE now()
END,
requeue_requested = ebook_enrichment_state.requeue_requested OR ebook_enrichment_state.status = 'running',
protected_fields = ` + mergeEbookProtectedFieldsSQL + `,
updated_at = now()
`
func (q *EnrichmentQueue) Enqueue(ctx context.Context, contentID string, priority int) error {
if q == nil || q.pool == nil {
return errors.New("ebook enrichment queue is not configured")
}
if strings.TrimSpace(contentID) == "" {
return errors.New("ebook enrichment content id is required")
}
_, err := q.pool.Exec(ctx, enqueueEnrichmentJobQuery, contentID, priority)
return err
}
const ensureEnrichmentReconcileCursorQuery = `
INSERT INTO ebook_enrichment_reconcile_cursors (
folder_id, after_first_seen_at, after_content_id, updated_at
)
VALUES ($1, NULL, NULL, now())
ON CONFLICT (folder_id) DO NOTHING
`
const lockEnrichmentReconcileCursorQuery = `
SELECT after_first_seen_at, after_content_id
FROM ebook_enrichment_reconcile_cursors
WHERE folder_id = $1
FOR UPDATE
`
var reconcileRecentMissingEnrichmentJobsQuery = `
WITH recent_memberships AS MATERIALIZED (
SELECT membership.content_id
FROM media_item_libraries membership
WHERE membership.media_folder_id = $1
ORDER BY membership.first_seen_at DESC, membership.content_id DESC
LIMIT $3
),
candidates AS MATERIALIZED (
SELECT candidate.content_id, ` + ebookProtectedFieldsSQL + ` AS protected_fields
FROM recent_memberships candidate
JOIN media_items mi ON mi.content_id = candidate.content_id
LEFT JOIN ebook_enrichment_state state ON state.content_id = candidate.content_id
WHERE mi.type = 'ebook'
AND ` + catalog.MangaChapterExclusionWhere("mi") + `
AND state.content_id IS NULL
),
inserted AS (
INSERT INTO ebook_enrichment_state (
content_id, status, priority, next_attempt_at, protected_fields, updated_at
)
SELECT candidates.content_id, 'pending', $2, now(), candidates.protected_fields, now()
FROM candidates
ON CONFLICT (content_id) DO NOTHING
RETURNING content_id
)
SELECT
(SELECT COUNT(*)::integer FROM inserted) AS reconciled,
(SELECT COUNT(*)::integer FROM recent_memberships) AS inspected
`
var reconcileMissingEnrichmentJobsQuery = `
WITH membership_candidates AS MATERIALIZED (
SELECT membership.content_id, membership.first_seen_at
FROM media_item_libraries membership
WHERE membership.media_folder_id = $1
AND (
$4::timestamptz IS NULL
OR (membership.first_seen_at, membership.content_id) < ($4, $5)
)
ORDER BY membership.first_seen_at DESC, membership.content_id DESC
LIMIT $3
),
candidates AS MATERIALIZED (
SELECT candidate.content_id, ` + ebookProtectedFieldsSQL + ` AS protected_fields
FROM membership_candidates candidate
JOIN media_items mi ON mi.content_id = candidate.content_id
LEFT JOIN ebook_enrichment_state state ON state.content_id = candidate.content_id
WHERE mi.type = 'ebook'
AND ` + catalog.MangaChapterExclusionWhere("mi") + `
AND state.content_id IS NULL
),
inserted AS (
INSERT INTO ebook_enrichment_state (
content_id, status, priority, next_attempt_at, protected_fields, updated_at
)
SELECT candidates.content_id, 'pending', $2, now(), candidates.protected_fields, now()
FROM candidates
ON CONFLICT (content_id) DO NOTHING
RETURNING content_id
),
window_stats AS MATERIALIZED (
SELECT
COUNT(*)::integer AS inspected,
(
SELECT first_seen_at
FROM membership_candidates
ORDER BY first_seen_at, content_id
LIMIT 1
) AS last_first_seen_at,
(
SELECT content_id
FROM membership_candidates
ORDER BY first_seen_at, content_id
LIMIT 1
) AS last_content_id
FROM membership_candidates
)
SELECT
(SELECT COUNT(*)::integer FROM inserted) AS reconciled,
window_stats.inspected,
window_stats.last_first_seen_at,
window_stats.last_content_id
FROM window_stats
`
const updateEnrichmentReconcileCursorQuery = `
UPDATE ebook_enrichment_reconcile_cursors
SET after_first_seen_at = $2,
after_content_id = $3,
updated_at = now()
WHERE folder_id = $1
`
// ReconcileMissing advances a database-persisted keyset cursor while holding
// its folder row lock. Queue repairs and cursor movement commit atomically.
// A server restart resumes from the persisted cursor; completed repairs remain
// durable, and reaching the end resets the cursor so later passes wrap safely.
func (q *EnrichmentQueue) ReconcileMissing(
ctx context.Context,
folderID, priority, limit int,
) (reconciled, inspected int, wrapped bool, err error) {
if q == nil || q.pool == nil {
return 0, 0, false, errors.New("ebook enrichment queue is not configured")
}
if folderID <= 0 {
return 0, 0, false, errors.New("ebook enrichment folder id is required")
}
if limit <= 0 {
return 0, 0, false, nil
}
tx, err := q.pool.Begin(ctx)
if err != nil {
return 0, 0, false, err
}
defer func() {
_ = tx.Rollback(ctx)
}()
if _, err = tx.Exec(ctx, ensureEnrichmentReconcileCursorQuery, folderID); err != nil {
return 0, 0, false, err
}
var afterFirstSeenAt *time.Time
var afterContentID *string
if err = tx.QueryRow(
ctx,
lockEnrichmentReconcileCursorQuery,
folderID,
).Scan(&afterFirstSeenAt, &afterContentID); err != nil {
return 0, 0, false, err
}
var recentReconciled, recentInspected int
if err = tx.QueryRow(
ctx,
reconcileRecentMissingEnrichmentJobsQuery,
folderID,
priority,
limit,
).Scan(&recentReconciled, &recentInspected); err != nil {
return 0, 0, false, err
}
var lastFirstSeenAt *time.Time
var lastContentID *string
err = tx.QueryRow(
ctx,
reconcileMissingEnrichmentJobsQuery,
folderID,
priority,
limit,
afterFirstSeenAt,
afterContentID,
).Scan(&reconciled, &inspected, &lastFirstSeenAt, &lastContentID)
if err != nil {
return 0, 0, false, err
}
wrapped = inspected < limit
if wrapped {
lastFirstSeenAt = nil
lastContentID = nil
}
if _, err = tx.Exec(
ctx,
updateEnrichmentReconcileCursorQuery,
folderID,
lastFirstSeenAt,
lastContentID,
); err != nil {
return 0, 0, false, err
}
if err = tx.Commit(ctx); err != nil {
return 0, 0, false, err
}
reconciled += recentReconciled
inspected += recentInspected
return reconciled, inspected, wrapped, nil
}
// Each indexed window is fixed-size; only their deduplicated union pays for
// aging/ranking, while the oldest-due window guarantees eventual promotion.
const claimEnrichmentJobsQueryTemplate = `
WITH high_priority_candidates AS MATERIALIZED (
SELECT content_id, priority, next_attempt_at, updated_at
FROM ebook_enrichment_state
WHERE next_attempt_at <= now()
AND (status = 'pending' OR (status = 'running' AND lease_until < now()))
AND {{lane_predicate}}
ORDER BY priority DESC, next_attempt_at, updated_at
LIMIT $3
),
oldest_due_candidates AS MATERIALIZED (
SELECT content_id, priority, next_attempt_at, updated_at
FROM ebook_enrichment_state
WHERE next_attempt_at <= now()
AND (status = 'pending' OR (status = 'running' AND lease_until < now()))
AND {{lane_predicate}}
ORDER BY next_attempt_at, updated_at, priority DESC
LIMIT $3
),
candidate_pool AS MATERIALIZED (
SELECT * FROM high_priority_candidates
UNION
SELECT * FROM oldest_due_candidates
),
ranked_candidates AS MATERIALIZED (
SELECT
content_id,
priority,
next_attempt_at,
updated_at,
(priority + FLOOR(EXTRACT(EPOCH FROM (now() - next_attempt_at)) / 3600)::integer) AS effective_priority
FROM candidate_pool
),
candidates AS (
SELECT state.content_id
FROM ranked_candidates ranked
JOIN ebook_enrichment_state state ON state.content_id = ranked.content_id
WHERE state.next_attempt_at <= now()
AND (state.status = 'pending' OR (state.status = 'running' AND state.lease_until < now()))
AND {{state_lane_predicate}}
ORDER BY
ranked.effective_priority DESC,
ranked.priority DESC,
ranked.next_attempt_at,
ranked.updated_at
FOR UPDATE OF state SKIP LOCKED
LIMIT $1
)
UPDATE ebook_enrichment_state state
SET status = 'running',
lease_until = now() + $2::interval,
claim_token = gen_random_uuid()::text,
last_attempt_at = now(),
attempts = attempts + 1,
updated_at = now()
FROM candidates
WHERE state.content_id = candidates.content_id
RETURNING state.content_id, state.claim_token, state.attempts, state.last_attempt_at, state.protected_fields
`
var (
claimIncrementalEnrichmentJobsQuery = buildClaimEnrichmentJobsQuery("priority >= 0", "state.priority >= 0")
claimLegacyEnrichmentJobsQuery = buildClaimEnrichmentJobsQuery("priority < 0", "state.priority < 0")
)
func buildClaimEnrichmentJobsQuery(lanePredicate, stateLanePredicate string) string {
query := strings.ReplaceAll(claimEnrichmentJobsQueryTemplate, "{{lane_predicate}}", lanePredicate)
return strings.ReplaceAll(query, "{{state_lane_predicate}}", stateLanePredicate)
}
func claimEnrichmentJobsQueryForScope(scope EnrichmentScope) string {
if scope == EnrichmentScopeLegacy {
return claimLegacyEnrichmentJobsQuery
}
return claimIncrementalEnrichmentJobsQuery
}
func (q *EnrichmentQueue) ClaimBatch(
ctx context.Context,
scope EnrichmentScope,
limit int,
leaseDuration time.Duration,
) ([]EnrichmentJob, error) {
if q == nil || q.pool == nil {
return nil, errors.New("ebook enrichment queue is not configured")
}
if err := scope.validate(); err != nil {
return nil, err
}
if limit <= 0 {
return nil, nil
}
if leaseDuration <= 0 {
leaseDuration = defaultEnrichmentLease
}
query := claimEnrichmentJobsQueryForScope(scope)
rows, err := q.pool.Query(
ctx,
query,
limit,
postgresInterval(leaseDuration),
claimCandidateWindow,
)
if err != nil {
return nil, err
}
defer rows.Close()
jobs := make([]EnrichmentJob, 0, limit)
for rows.Next() {
var job EnrichmentJob
if err := rows.Scan(&job.ContentID, &job.Token, &job.Attempts, &job.LastAttemptAt, &job.ProtectedFields); err != nil {
return nil, err
}
jobs = append(jobs, job)
}
if err := rows.Err(); err != nil {
return nil, err
}
return jobs, nil
}
const countReadyEnrichmentJobsQueryTemplate = `
SELECT COUNT(*)
FROM ebook_enrichment_state
WHERE next_attempt_at <= now()
AND (status = 'pending' OR (status = 'running' AND lease_until < now()))
AND {{lane_predicate}}
`
var (
countReadyIncrementalEnrichmentJobsQuery = strings.ReplaceAll(
countReadyEnrichmentJobsQueryTemplate,
"{{lane_predicate}}",
"priority >= 0",
)
countReadyLegacyEnrichmentJobsQuery = strings.ReplaceAll(
countReadyEnrichmentJobsQueryTemplate,
"{{lane_predicate}}",
"priority < 0",
)
)
const hasReadyEnrichmentJobsQueryTemplate = `
SELECT COALESCE((
SELECT true
FROM ebook_enrichment_state
WHERE next_attempt_at <= now()
AND (status = 'pending' OR (status = 'running' AND lease_until < now()))
AND {{lane_predicate}}
ORDER BY next_attempt_at, updated_at, priority DESC
LIMIT 1
), false)
`
var (
hasReadyIncrementalEnrichmentJobsQuery = strings.ReplaceAll(
hasReadyEnrichmentJobsQueryTemplate,
"{{lane_predicate}}",
"priority >= 0",
)
hasReadyLegacyEnrichmentJobsQuery = strings.ReplaceAll(
hasReadyEnrichmentJobsQueryTemplate,
"{{lane_predicate}}",
"priority < 0",
)
)
func hasReadyEnrichmentJobsQueryForScope(scope EnrichmentScope) string {
if scope == EnrichmentScopeLegacy {
return hasReadyLegacyEnrichmentJobsQuery
}
return hasReadyIncrementalEnrichmentJobsQuery
}
func (q *EnrichmentQueue) HasReady(ctx context.Context, scope EnrichmentScope) (bool, error) {
if q == nil || q.pool == nil {
return false, errors.New("ebook enrichment queue is not configured")
}
if err := scope.validate(); err != nil {
return false, err
}
var ready bool
if err := q.pool.QueryRow(ctx, hasReadyEnrichmentJobsQueryForScope(scope)).Scan(&ready); err != nil {
return false, err
}
return ready, nil
}
func countReadyEnrichmentJobsQueryForScope(scope EnrichmentScope) string {
if scope == EnrichmentScopeLegacy {
return countReadyLegacyEnrichmentJobsQuery
}
return countReadyIncrementalEnrichmentJobsQuery
}
func (q *EnrichmentQueue) ReadyCount(ctx context.Context, scope EnrichmentScope) (int, error) {
if q == nil || q.pool == nil {
return 0, errors.New("ebook enrichment queue is not configured")
}
if err := scope.validate(); err != nil {
return 0, err
}
var count int
if err := q.pool.QueryRow(ctx, countReadyEnrichmentJobsQueryForScope(scope)).Scan(&count); err != nil {
return 0, err
}
return count, nil
}
var checkEnrichmentClaimQuery = `
SELECT EXISTS (
SELECT 1
FROM ebook_enrichment_state
WHERE content_id = $1
AND status = 'running'
AND claim_token = $2
AND lease_until > now()
)
`
func (q *EnrichmentQueue) CheckClaim(ctx context.Context, job EnrichmentJob) error {
if q == nil || q.pool == nil {
return errors.New("ebook enrichment queue is not configured")
}
if job.ContentID == "" || job.Token == "" {
return ErrEnrichmentLeaseLost
}
var owned bool
if err := q.pool.QueryRow(ctx, checkEnrichmentClaimQuery, job.ContentID, job.Token).Scan(&owned); err != nil {
return err
}
if !owned {
return ErrEnrichmentLeaseLost
}
return nil
}
var completeEnrichmentJobQuery = `
UPDATE ebook_enrichment_state
SET status = 'pending',
lease_until = NULL,
claim_token = NULL,
completed_at = now(),
next_attempt_at = CASE
WHEN requeue_requested THEN now()
ELSE now() + $3::interval
END,
outcome = $2,
attempts = 0,
priority = CASE WHEN requeue_requested THEN 100 ELSE 0 END,
requeue_requested = false,
last_error_class = NULL,
last_error = NULL,
updated_at = now()
WHERE content_id = $1
AND status = 'running'
AND claim_token = $4
`
func (q *EnrichmentQueue) Complete(
ctx context.Context,
job EnrichmentJob,
outcome EnrichmentOutcome,
refreshAfter time.Duration,
) error {
if q == nil || q.pool == nil {
return errors.New("ebook enrichment queue is not configured")
}
if job.ContentID == "" || job.Token == "" {
return ErrEnrichmentLeaseLost
}
if refreshAfter <= 0 {
refreshAfter = enrichmentRefreshHorizon(outcome)
}
if refreshAfter <= 0 {
return fmt.Errorf("unsupported ebook enrichment outcome %q", outcome)
}
tag, err := q.pool.Exec(
ctx,
completeEnrichmentJobQuery,
job.ContentID,
string(outcome),
postgresInterval(refreshAfter),
job.Token,
)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrEnrichmentLeaseLost
}
return nil
}
var failEnrichmentJobQuery = `
UPDATE ebook_enrichment_state
SET status = 'pending',
lease_until = NULL,
claim_token = NULL,
next_attempt_at = CASE
WHEN requeue_requested THEN now()
ELSE now() + $4::interval
END,
priority = CASE WHEN requeue_requested THEN 100 ELSE priority END,
requeue_requested = false,
outcome = 'failed',
last_error_class = $2,
last_error = $3,
updated_at = now()
WHERE content_id = $1
AND status = 'running'
AND claim_token = $5
`
func (q *EnrichmentQueue) Fail(
ctx context.Context,
job EnrichmentJob,
errorClass EnrichmentErrorClass,
message string,
retryAfter time.Duration,
) error {
if q == nil || q.pool == nil {
return errors.New("ebook enrichment queue is not configured")
}
if job.ContentID == "" || job.Token == "" {
return ErrEnrichmentLeaseLost
}
delay := enrichmentRetryDelay(errorClass, job.Attempts, retryAfter)
tag, err := q.pool.Exec(
ctx,
failEnrichmentJobQuery,
job.ContentID,
string(errorClass),
message,
postgresInterval(delay),
job.Token,
)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrEnrichmentLeaseLost
}
return nil
}
var releaseEnrichmentJobQuery = `
UPDATE ebook_enrichment_state
SET status = 'pending',
lease_until = NULL,
claim_token = NULL,
attempts = GREATEST(attempts - 1, 0),
next_attempt_at = CASE WHEN requeue_requested THEN now() ELSE next_attempt_at END,
priority = CASE WHEN requeue_requested THEN 100 ELSE priority END,
requeue_requested = false,
updated_at = now()
WHERE content_id = $1
AND status = 'running'
AND claim_token = $2
`
func (q *EnrichmentQueue) Release(ctx context.Context, job EnrichmentJob) error {
if q == nil || q.pool == nil {
return errors.New("ebook enrichment queue is not configured")
}
if job.ContentID == "" || job.Token == "" {
return ErrEnrichmentLeaseLost
}
tag, err := q.pool.Exec(ctx, releaseEnrichmentJobQuery, job.ContentID, job.Token)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrEnrichmentLeaseLost
}
return nil
}
var discardEnrichmentJobQuery = `
UPDATE ebook_enrichment_state
SET status = 'discarded',
lease_until = NULL,
claim_token = NULL,
completed_at = now(),
outcome = 'discarded',
attempts = 0,
priority = 0,
requeue_requested = false,
last_error_class = NULL,
last_error = NULL,
updated_at = now()
WHERE content_id = $1
AND status = 'running'
AND claim_token = $2
`
func (q *EnrichmentQueue) Discard(ctx context.Context, job EnrichmentJob) error {
if q == nil || q.pool == nil {
return errors.New("ebook enrichment queue is not configured")
}
if job.ContentID == "" || job.Token == "" {
return ErrEnrichmentLeaseLost
}
tag, err := q.pool.Exec(ctx, discardEnrichmentJobQuery, job.ContentID, job.Token)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrEnrichmentLeaseLost
}
return nil
}
func enrichmentRefreshHorizon(outcome EnrichmentOutcome) time.Duration {
switch outcome {
case EnrichmentOutcomeSuccess:
return 90 * 24 * time.Hour
case EnrichmentOutcomeNoMatch:
return 30 * 24 * time.Hour
case EnrichmentOutcomeSkipped:
return skippedRetryHorizon
default:
return 0
}
}
func enrichmentRetryDelay(errorClass EnrichmentErrorClass, attempts int, retryAfter time.Duration) time.Duration {
switch errorClass {
case EnrichmentErrorRateLimited:
if retryAfter <= 0 {
retryAfter = transientRetryDelay(attempts)
}
// A provider's short RetryInfo is request-pacing advice for its own
// token bucket, not a queue horizon; requeueing that fast re-claims
// the same saturated tail every run.
retryAfter = max(retryAfter, rateLimitCooldownFloor())
return min(retryAfter, maxEnrichmentRetry)
case EnrichmentErrorPermanent:
return 30 * 24 * time.Hour
default:
return transientRetryDelay(attempts)
}
}
func transientRetryDelay(attempts int) time.Duration {
if attempts < 1 {
attempts = 1
}
delay := transientRetryBase
for i := 1; i < attempts; i++ {
if delay >= maxEnrichmentRetry/2 {
return maxEnrichmentRetry
}
delay *= 2
}
return min(delay, maxEnrichmentRetry)
}
func postgresInterval(duration time.Duration) string {
return fmt.Sprintf("%d microseconds", duration.Microseconds())
}