185 lines
7.8 KiB
Python
185 lines
7.8 KiB
Python
"""Celery application configuration."""
|
|
|
|
import logging
|
|
from celery import Celery
|
|
from celery.schedules import crontab
|
|
|
|
from app.config import get_settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
settings = get_settings()
|
|
|
|
# Log Redis configuration at import time (helps diagnose DB mismatch issues)
|
|
_redis_db = settings.redis_url.split('/')[-1] if '/' in settings.redis_url else '0'
|
|
logger.info(f"Celery using Redis: {settings.redis_url} (database: {_redis_db})")
|
|
|
|
|
|
def _get_parallel_limit_from_db():
|
|
"""Try to get parallel_limit from database, fall back to environment.
|
|
|
|
This is called once at Celery startup. Changes require worker restart.
|
|
"""
|
|
try:
|
|
import asyncio
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
|
|
|
async def _fetch():
|
|
engine = create_async_engine(settings.async_database_url, echo=False)
|
|
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
|
try:
|
|
async with session_factory() as session:
|
|
from app.models.setting import Setting, DEFAULT_SETTINGS
|
|
result = await session.execute(
|
|
select(Setting).where(Setting.key == "download.parallel_limit")
|
|
)
|
|
setting = result.scalar()
|
|
if setting:
|
|
return setting.value
|
|
return DEFAULT_SETTINGS.get("download.parallel_limit", settings.download_parallel_limit)
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
return asyncio.run(_fetch())
|
|
except Exception as e:
|
|
logger.warning(f"Could not read parallel_limit from database, using environment: {e}")
|
|
return settings.download_parallel_limit
|
|
|
|
|
|
# Get parallel limit (database takes precedence over environment)
|
|
parallel_limit = _get_parallel_limit_from_db()
|
|
logger.info(f"Celery worker concurrency set to: {parallel_limit}")
|
|
|
|
celery_app = Celery(
|
|
"gallery_subscriber",
|
|
broker=settings.redis_url,
|
|
backend=settings.redis_url,
|
|
include=["app.tasks.downloads", "app.tasks.maintenance"],
|
|
)
|
|
|
|
# Celery configuration
|
|
celery_app.conf.update(
|
|
task_serializer="json",
|
|
accept_content=["json", "pickle"], # Accept pickle for backward compatibility during upgrades
|
|
result_serializer="json",
|
|
timezone="UTC",
|
|
enable_utc=True,
|
|
task_track_started=True,
|
|
task_time_limit=3600, # 1 hour max per task
|
|
worker_prefetch_multiplier=1, # Process one task at a time
|
|
worker_concurrency=parallel_limit,
|
|
# Task routing - send tasks to specific queues
|
|
task_routes={
|
|
# Lightweight scheduling/maintenance tasks go to 'scheduling' queue
|
|
"app.tasks.downloads.scheduled_check": {"queue": "scheduling"},
|
|
"app.tasks.downloads.cleanup_old_downloads": {"queue": "scheduling"},
|
|
"tasks.update_storage_stats": {"queue": "scheduling"},
|
|
"tasks.reset_orphaned_jobs": {"queue": "scheduling"},
|
|
"tasks.requeue_stale_jobs": {"queue": "scheduling"},
|
|
# Heavy download tasks go to 'downloads' queue (default)
|
|
"app.tasks.downloads.download_source": {"queue": "downloads"},
|
|
"app.tasks.downloads.process_download": {"queue": "downloads"},
|
|
},
|
|
# Default queue for any unrouted tasks
|
|
task_default_queue="downloads",
|
|
)
|
|
|
|
# Celery Beat schedule for periodic tasks
|
|
# The scheduler runs periodically to check if any sources are due for download.
|
|
# The actual schedule_interval (e.g., 8 hours) is enforced inside the task
|
|
# by comparing each source's last_check timestamp against the DB setting.
|
|
# 15 minutes is a good balance - responsive enough without excessive overhead.
|
|
celery_app.conf.beat_schedule = {
|
|
"check-sources-scheduler": {
|
|
"task": "app.tasks.downloads.scheduled_check",
|
|
"schedule": 900, # Run every 15 minutes to check for due sources
|
|
},
|
|
"cleanup-old-downloads-daily": {
|
|
"task": "app.tasks.downloads.cleanup_old_downloads",
|
|
"schedule": crontab(hour=3, minute=0), # Daily at 3 AM
|
|
},
|
|
"update-storage-stats": {
|
|
"task": "tasks.update_storage_stats",
|
|
"schedule": crontab(minute="*/30"), # Every 30 minutes
|
|
},
|
|
"reset-orphaned-jobs": {
|
|
"task": "tasks.reset_orphaned_jobs",
|
|
"schedule": crontab(minute="*/15"), # Every 15 minutes
|
|
},
|
|
"requeue-stale-jobs": {
|
|
"task": "tasks.requeue_stale_jobs",
|
|
"schedule": crontab(minute="*/15"), # Every 15 minutes, check for stale queued jobs
|
|
},
|
|
}
|
|
|
|
|
|
# Worker startup signal - only reset orphaned jobs on scheduler worker
|
|
from celery.signals import worker_ready
|
|
|
|
@worker_ready.connect
|
|
def on_worker_ready(sender, **kwargs):
|
|
"""Called when a worker is ready to accept tasks.
|
|
|
|
Only the scheduler worker resets orphaned jobs to avoid duplicate resets
|
|
when multiple download workers start.
|
|
"""
|
|
# Check if this worker is consuming from the 'scheduling' queue
|
|
# sender.app.amqp.queues contains the queues this worker consumes from
|
|
try:
|
|
queues = [q.name for q in sender.app.amqp.queues.consume_from.values()]
|
|
is_scheduler = 'scheduling' in queues
|
|
except Exception:
|
|
# Fallback: check hostname or other indicators
|
|
is_scheduler = False
|
|
|
|
# Verify Redis connectivity and log configuration
|
|
try:
|
|
import redis
|
|
redis_url = settings.redis_url
|
|
redis_db = redis_url.split('/')[-1] if '/' in redis_url else '0'
|
|
r = redis.from_url(redis_url)
|
|
r.ping()
|
|
logger.info(f"Redis connectivity verified: {redis_url} (DB: {redis_db})")
|
|
|
|
# Check for other workers' markers to detect DB mismatches
|
|
marker_key = f"gallerysubscriber:worker:{'scheduler' if is_scheduler else 'download'}"
|
|
r.setex(marker_key, 3600, redis_url) # Expires in 1 hour
|
|
|
|
# Check if scheduler marker exists (helps detect if scheduler is on different DB)
|
|
scheduler_marker = r.get("gallerysubscriber:worker:scheduler")
|
|
download_marker = r.get("gallerysubscriber:worker:download")
|
|
if scheduler_marker and download_marker:
|
|
if scheduler_marker != download_marker:
|
|
logger.error(f"REDIS DB MISMATCH DETECTED! Scheduler: {scheduler_marker.decode()}, Worker: {download_marker.decode()}")
|
|
except Exception as e:
|
|
logger.error(f"Redis connectivity check failed: {e}")
|
|
|
|
if is_scheduler:
|
|
logger.info("Scheduler worker starting - resetting orphaned jobs...")
|
|
try:
|
|
from app.tasks.maintenance import reset_all_running_jobs_sync
|
|
result = reset_all_running_jobs_sync()
|
|
logger.info(f"Scheduler startup: Reset {result.get('reset_count', 0)} orphaned running jobs")
|
|
except Exception as e:
|
|
logger.error(f"Failed to reset orphaned jobs on scheduler startup: {e}")
|
|
|
|
# Re-queue any jobs stuck in 'queued' status (Celery tasks may have been lost)
|
|
logger.info("Scheduler startup: Re-queuing any stale queued jobs...")
|
|
try:
|
|
from app.tasks.maintenance import requeue_all_queued_jobs_sync
|
|
result = requeue_all_queued_jobs_sync()
|
|
logger.info(f"Scheduler startup: Re-queued {result.get('requeued_count', 0)} queued jobs")
|
|
except Exception as e:
|
|
logger.error(f"Failed to re-queue jobs on scheduler startup: {e}")
|
|
|
|
# Trigger an immediate scheduled_check on startup (don't wait for beat timer)
|
|
logger.info("Scheduler startup: Triggering immediate scheduled_check...")
|
|
try:
|
|
from app.tasks.downloads import scheduled_check
|
|
scheduled_check.delay()
|
|
logger.info("Scheduler startup: scheduled_check task queued")
|
|
except Exception as e:
|
|
logger.error(f"Failed to queue initial scheduled_check: {e}")
|
|
else:
|
|
logger.info("Download worker starting - skipping orphaned job reset (handled by scheduler)")
|