2026-08-09 22:47:43 +02:00
|
|
|
package processing
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
|
|
|
|
|
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// StuckAgeInterval is the shared SQL age used by CleanupStuck and claim-time
|
|
|
|
|
// reclaim in loadPendingItems for stranded processing_job_products.
|
|
|
|
|
const StuckAgeInterval = "2 hours"
|
|
|
|
|
|
|
|
|
|
// StuckCleanupResult counts rows touched by CleanupStuck.
|
|
|
|
|
type StuckCleanupResult struct {
|
|
|
|
|
JobsMarkedFailed int64
|
|
|
|
|
ProductsReset int64
|
|
|
|
|
SyncJobsMarkedFailed int64
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 17:38:15 +02:00
|
|
|
// OrphanReclaimResult counts rows touched by ReclaimOrphanedRunning.
|
|
|
|
|
type OrphanReclaimResult struct {
|
|
|
|
|
JobsRequeued int64
|
|
|
|
|
ProductsReset int64
|
|
|
|
|
SyncJobsRequeued int64
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ReclaimOrphanedRunning resets in-flight work left by a dead worker process so
|
2026-08-16 18:59:29 +02:00
|
|
|
// ClaimNext / feed sync claim can pick it up again. Call on every worker process
|
|
|
|
|
// start (before TouchHeartbeat) under the single-worker architecture — a live
|
|
|
|
|
// heartbeat from a just-killed process must not skip reclaim. Never call while
|
2026-08-16 17:38:15 +02:00
|
|
|
// another live worker may own the rows.
|
|
|
|
|
//
|
|
|
|
|
// Unlike CleanupStuck (age-gated fail), this requeues immediately: running → pending
|
|
|
|
|
// for jobs/sync jobs, and processing → pending for job products without a result.
|
|
|
|
|
func ReclaimOrphanedRunning(ctx context.Context, pool *pgxpool.Pool) (OrphanReclaimResult, error) {
|
|
|
|
|
var out OrphanReclaimResult
|
|
|
|
|
if pool == nil {
|
|
|
|
|
return out, fmt.Errorf("reclaim orphaned running: nil pool")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ctJobs, err := pool.Exec(ctx, `
|
|
|
|
|
UPDATE processing_jobs
|
|
|
|
|
SET status = 'pending', started_at = NULL, error = NULL, updated_at = now()
|
|
|
|
|
WHERE status = 'running'`)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return out, fmt.Errorf("reclaim orphaned jobs: %w", err)
|
|
|
|
|
}
|
|
|
|
|
out.JobsRequeued = ctJobs.RowsAffected()
|
|
|
|
|
|
|
|
|
|
ctProd, err := pool.Exec(ctx, `
|
|
|
|
|
UPDATE processing_job_products
|
|
|
|
|
SET status = 'pending', error = NULL, updated_at = now()
|
|
|
|
|
WHERE status = 'processing' AND processed_product_id IS NULL`)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return out, fmt.Errorf("reclaim orphaned products: %w", err)
|
|
|
|
|
}
|
|
|
|
|
out.ProductsReset = ctProd.RowsAffected()
|
|
|
|
|
|
|
|
|
|
ctSync, err := pool.Exec(ctx, `
|
|
|
|
|
UPDATE feed_sync_jobs
|
|
|
|
|
SET status = 'pending', started_at = NULL, error = NULL, completed_at = NULL, updated_at = now()
|
|
|
|
|
WHERE status = 'running'`)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return out, fmt.Errorf("reclaim orphaned sync jobs: %w", err)
|
|
|
|
|
}
|
|
|
|
|
out.SyncJobsRequeued = ctSync.RowsAffected()
|
|
|
|
|
|
|
|
|
|
return out, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 22:47:43 +02:00
|
|
|
// CleanupStuck aligns worker and admin stuck-cleanup semantics:
|
|
|
|
|
// mark long-running jobs failed, reset stranded processing_job_products
|
|
|
|
|
// from 'processing' back to 'pending', and fail aged running feed_sync_jobs
|
|
|
|
|
// so they are not left unclaimable forever.
|
|
|
|
|
func CleanupStuck(ctx context.Context, pool *pgxpool.Pool) (StuckCleanupResult, error) {
|
|
|
|
|
var out StuckCleanupResult
|
|
|
|
|
if pool == nil {
|
|
|
|
|
return out, fmt.Errorf("cleanup stuck: nil pool")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ctJobs, err := pool.Exec(ctx, `
|
|
|
|
|
UPDATE processing_jobs
|
|
|
|
|
SET status = 'failed', error = 'stuck cleanup', completed_at = now(), updated_at = now()
|
|
|
|
|
WHERE status = 'running' AND updated_at < now() - interval '`+StuckAgeInterval+`'`)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return out, fmt.Errorf("cleanup stuck jobs: %w", err)
|
|
|
|
|
}
|
|
|
|
|
out.JobsMarkedFailed = ctJobs.RowsAffected()
|
|
|
|
|
|
|
|
|
|
ctProd, err := pool.Exec(ctx, `
|
|
|
|
|
UPDATE processing_job_products
|
|
|
|
|
SET status = 'pending', updated_at = now()
|
|
|
|
|
WHERE status = 'processing' AND updated_at < now() - interval '`+StuckAgeInterval+`'`)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return out, fmt.Errorf("cleanup stuck products: %w", err)
|
|
|
|
|
}
|
|
|
|
|
out.ProductsReset = ctProd.RowsAffected()
|
|
|
|
|
|
|
|
|
|
ctSync, err := pool.Exec(ctx, `
|
|
|
|
|
UPDATE feed_sync_jobs
|
|
|
|
|
SET status = 'failed', error = 'stuck cleanup', completed_at = now(), updated_at = now()
|
|
|
|
|
WHERE status = 'running' AND updated_at < now() - interval '`+StuckAgeInterval+`'`)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return out, fmt.Errorf("cleanup stuck sync jobs: %w", err)
|
|
|
|
|
}
|
|
|
|
|
out.SyncJobsMarkedFailed = ctSync.RowsAffected()
|
|
|
|
|
|
|
|
|
|
return out, nil
|
|
|
|
|
}
|