changes to task scheduling and readability change on downloads view

This commit is contained in:
Bryan Van Deusen
2026-01-29 19:33:04 -05:00
parent 575e20f58c
commit fcccfb24ee
18 changed files with 527 additions and 72 deletions
+4
View File
@@ -59,6 +59,7 @@ DB_NAME=gallery_subscriber
REDIS_HOST=redis REDIS_HOST=redis
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_DB=0
# =========================================== # ===========================================
# Data Storage Paths # Data Storage Paths
@@ -84,3 +85,6 @@ REDIS_PORT=6379
# Only set these directly for special configurations # Only set these directly for special configurations
# DATABASE_URL=postgresql://gdl:password@localhost:5432/gallery_subscriber # DATABASE_URL=postgresql://gdl:password@localhost:5432/gallery_subscriber
# REDIS_URL=redis://localhost:6379/0 # REDIS_URL=redis://localhost:6379/0
#
# IMPORTANT: All services (app, scheduler, worker) MUST use the same Redis database.
# If you change REDIS_DB, ensure all containers are restarted together.
+1 -1
View File
@@ -92,7 +92,7 @@ docker compose exec app alembic upgrade head
| `PORT` | External port | `8080` | | `PORT` | External port | `8080` |
| `DOWNLOAD_PARALLEL_LIMIT` | Max concurrent downloads | `3` | | `DOWNLOAD_PARALLEL_LIMIT` | Max concurrent downloads | `3` |
| `DOWNLOAD_RATE_LIMIT` | Delay between requests (seconds) | `3.0` | | `DOWNLOAD_RATE_LIMIT` | Delay between requests (seconds) | `3.0` |
| `DEFAULT_CHECK_INTERVAL` | Auto-check interval (seconds) | `3600` | | `DEFAULT_CHECK_INTERVAL` | Auto-check interval (seconds) | `28800` (8 hours) |
| `LOG_LEVEL` | Logging verbosity | `INFO` | | `LOG_LEVEL` | Logging verbosity | `INFO` |
See `.env.example` for the complete list with descriptions. See `.env.example` for the complete list with descriptions.
@@ -27,7 +27,7 @@ def upgrade() -> None:
sa.Column('url', sa.Text(), nullable=False), sa.Column('url', sa.Text(), nullable=False),
sa.Column('enabled', sa.Boolean(), default=True), sa.Column('enabled', sa.Boolean(), default=True),
sa.Column('priority', sa.Integer(), default=0), sa.Column('priority', sa.Integer(), default=0),
sa.Column('check_interval', sa.Integer(), default=3600), sa.Column('check_interval', sa.Integer(), default=28800),
sa.Column('last_check', sa.DateTime(), nullable=True), sa.Column('last_check', sa.DateTime(), nullable=True),
sa.Column('last_success', sa.DateTime(), nullable=True), sa.Column('last_success', sa.DateTime(), nullable=True),
sa.Column('error_count', sa.Integer(), default=0), sa.Column('error_count', sa.Integer(), default=0),
@@ -115,7 +115,7 @@ def upgrade() -> None:
('download.parallel_limit', '3'), ('download.parallel_limit', '3'),
('download.rate_limit', '3.0'), ('download.rate_limit', '3.0'),
('download.retry_count', '3'), ('download.retry_count', '3'),
('download.schedule_interval', '3600'), ('download.schedule_interval', '28800'),
('notification.enabled', 'false'), ('notification.enabled', 'false'),
('notification.webhook_url', 'null') ('notification.webhook_url', 'null')
""") """)
+35
View File
@@ -207,6 +207,41 @@ async def reset_orphaned_downloads():
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@bp.route("/requeue-stale", methods=["POST"])
async def requeue_stale_downloads():
"""Re-queue Celery tasks for downloads stuck in 'queued' status.
Downloads can get stuck in 'queued' database status if the Celery task
was lost (e.g., sent to wrong Redis DB, Redis restart, etc.).
This endpoint re-sends the Celery tasks for those jobs.
"""
from app.tasks.maintenance import requeue_stale_jobs, _requeue_all_queued_jobs_async
data = await request.get_json() or {}
threshold_minutes = data.get("threshold_minutes", 0) # 0 = re-queue ALL queued jobs
try:
if threshold_minutes == 0:
# Re-queue all queued jobs immediately (synchronous for immediate feedback)
result = await _requeue_all_queued_jobs_async()
current_app.logger.info(f"Manual re-queue of all queued jobs: {result}")
return jsonify({
"message": f"Re-queued {result['requeued_count']} queued jobs",
"requeued_count": result['requeued_count'],
})
else:
# Queue the task for threshold-based re-queue
task = requeue_stale_jobs.delay(threshold_minutes)
return jsonify({
"message": "Stale job re-queue queued",
"task_id": task.id,
"threshold_minutes": threshold_minutes,
}), 202
except Exception as e:
current_app.logger.error(f"Failed to re-queue stale jobs: {e}")
return jsonify({"error": str(e)}), 500
@bp.route("/stats", methods=["GET"]) @bp.route("/stats", methods=["GET"])
async def get_stats(): async def get_stats():
"""Get download statistics.""" """Get download statistics."""
+48
View File
@@ -118,8 +118,10 @@ async def update_settings():
setting = result.scalar() setting = result.scalar()
if setting: if setting:
current_app.logger.info(f"Updating setting '{key}': {setting.value} -> {value} (type: {type(value).__name__})")
setting.value = value setting.value = value
else: else:
current_app.logger.info(f"Creating setting '{key}': {value} (type: {type(value).__name__})")
setting = Setting(key=key, value=value) setting = Setting(key=key, value=value)
session.add(setting) session.add(setting)
@@ -321,3 +323,49 @@ async def get_system_info():
"download_path": config.download_path, "download_path": config.download_path,
"config_path": config.config_path, "config_path": config.config_path,
}) })
@bp.route("/debug/schedule-interval", methods=["GET"])
async def debug_schedule_interval():
"""Debug endpoint to verify schedule_interval setting storage and retrieval.
Returns detailed information about how the setting is stored and what value
the worker would read.
"""
async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn)
# Query the raw setting from database
result = await session.execute(
select(Setting).where(Setting.key == "download.schedule_interval")
)
setting = result.scalar()
if setting:
raw_value = setting.value
db_info = {
"found_in_database": True,
"raw_value": raw_value,
"raw_type": type(raw_value).__name__,
"as_int": int(raw_value) if raw_value is not None else None,
"updated_at": setting.updated_at.isoformat() if setting.updated_at else None,
}
else:
db_info = {
"found_in_database": False,
"raw_value": None,
"raw_type": None,
"as_int": None,
"updated_at": None,
}
# What the worker would use
fallback_value = DEFAULT_SETTINGS.get("download.schedule_interval", 3600)
worker_would_use = db_info["as_int"] if db_info["found_in_database"] else fallback_value
return jsonify({
"database": db_info,
"default_settings_value": fallback_value,
"worker_would_use": worker_would_use,
"worker_would_use_hours": worker_would_use / 3600 if worker_would_use else None,
})
+1 -1
View File
@@ -105,7 +105,7 @@ async def create_source():
# Get default check interval from database settings # Get default check interval from database settings
default_interval = await get_setting_value( default_interval = await get_setting_value(
session, "download.schedule_interval", 3600 session, "download.schedule_interval", 28800
) )
source = Source( source = Source(
+2 -2
View File
@@ -89,7 +89,7 @@ async def create_subscription():
# Get default check interval from database settings # Get default check interval from database settings
default_interval = await get_setting_value( default_interval = await get_setting_value(
session, "download.schedule_interval", 3600 session, "download.schedule_interval", 28800
) )
# Add sources if provided # Add sources if provided
@@ -263,7 +263,7 @@ async def add_source_to_subscription(subscription_id: int):
# Get default check interval from database settings # Get default check interval from database settings
default_interval = await get_setting_value( default_interval = await get_setting_value(
session, "download.schedule_interval", 3600 session, "download.schedule_interval", 28800
) )
source = Source( source = Source(
+1 -1
View File
@@ -40,7 +40,7 @@ DEFAULT_SETTINGS = {
"download.parallel_limit": 3, "download.parallel_limit": 3,
"download.rate_limit": 3.0, "download.rate_limit": 3.0,
"download.retry_count": 3, "download.retry_count": 3,
"download.schedule_interval": 3600, "download.schedule_interval": 28800, # 8 hours
"notification.enabled": False, "notification.enabled": False,
"notification.webhook_url": None, "notification.webhook_url": None,
} }
+1 -1
View File
@@ -28,7 +28,7 @@ class Source(Base, TimestampMixin):
platform: Mapped[str] = mapped_column(String(50), nullable=False, index=True) platform: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
url: Mapped[str] = mapped_column(Text, nullable=False) url: Mapped[str] = mapped_column(Text, nullable=False)
enabled: Mapped[bool] = mapped_column(Boolean, default=True) enabled: Mapped[bool] = mapped_column(Boolean, default=True)
check_interval: Mapped[int] = mapped_column(Integer, default=3600) # seconds check_interval: Mapped[int] = mapped_column(Integer, default=28800) # 8 hours in seconds
# Status tracking # Status tracking
last_check: Mapped[Optional[datetime]] = mapped_column(nullable=True) last_check: Mapped[Optional[datetime]] = mapped_column(nullable=True)
+88 -18
View File
@@ -9,6 +9,10 @@ from app.config import get_settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
settings = get_settings() 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(): def _get_parallel_limit_from_db():
"""Try to get parallel_limit from database, fall back to environment. """Try to get parallel_limit from database, fall back to environment.
@@ -56,7 +60,7 @@ celery_app = Celery(
# Celery configuration # Celery configuration
celery_app.conf.update( celery_app.conf.update(
task_serializer="json", task_serializer="json",
accept_content=["json"], accept_content=["json", "pickle"], # Accept pickle for backward compatibility during upgrades
result_serializer="json", result_serializer="json",
timezone="UTC", timezone="UTC",
enable_utc=True, enable_utc=True,
@@ -64,16 +68,31 @@ celery_app.conf.update(
task_time_limit=3600, # 1 hour max per task task_time_limit=3600, # 1 hour max per task
worker_prefetch_multiplier=1, # Process one task at a time worker_prefetch_multiplier=1, # Process one task at a time
worker_concurrency=parallel_limit, 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 # Celery Beat schedule for periodic tasks
# Note: The scheduler runs frequently (every 60s) but only triggers downloads # The scheduler runs periodically to check if any sources are due for download.
# for sources whose individual check_interval has elapsed. Per-source intervals # The actual schedule_interval (e.g., 8 hours) is enforced inside the task
# are stored in the database and can be configured via the UI. # 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 = { celery_app.conf.beat_schedule = {
"check-sources-scheduler": { "check-sources-scheduler": {
"task": "app.tasks.downloads.scheduled_check", "task": "app.tasks.downloads.scheduled_check",
"schedule": 60, # Run every 60 seconds to check for due sources "schedule": 900, # Run every 15 minutes to check for due sources
}, },
"cleanup-old-downloads-daily": { "cleanup-old-downloads-daily": {
"task": "app.tasks.downloads.cleanup_old_downloads", "task": "app.tasks.downloads.cleanup_old_downloads",
@@ -87,28 +106,79 @@ celery_app.conf.beat_schedule = {
"task": "tasks.reset_orphaned_jobs", "task": "tasks.reset_orphaned_jobs",
"schedule": crontab(minute="*/15"), # Every 15 minutes "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 - reset any orphaned running jobs # Worker startup signal - only reset orphaned jobs on scheduler worker
@celery_app.on_after_configure.connect
def setup_worker_startup(sender, **kwargs):
"""Called after Celery is configured but before workers start processing."""
pass # Placeholder - actual reset happens in worker_ready signal
from celery.signals import worker_ready from celery.signals import worker_ready
@worker_ready.connect @worker_ready.connect
def on_worker_ready(sender, **kwargs): def on_worker_ready(sender, **kwargs):
"""Called when a worker is ready to accept tasks. """Called when a worker is ready to accept tasks.
Resets any jobs stuck in 'running' status from previous runs. Only the scheduler worker resets orphaned jobs to avoid duplicate resets
This handles the case where workers crashed or were restarted. 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: try:
from app.tasks.maintenance import reset_all_running_jobs_sync queues = [q.name for q in sender.app.amqp.queues.consume_from.values()]
result = reset_all_running_jobs_sync() is_scheduler = 'scheduling' in queues
logger.info(f"Worker startup: {result}") 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: except Exception as e:
logger.error(f"Failed to reset orphaned jobs on worker startup: {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)")
+57 -6
View File
@@ -59,10 +59,13 @@ async def _get_db_setting(session: AsyncSession, key: str, default=None):
setting = result.scalar() setting = result.scalar()
if setting: if setting:
logger.debug(f"DB setting '{key}' found: {setting.value} (type: {type(setting.value).__name__})")
return setting.value return setting.value
# Fall back to DEFAULT_SETTINGS, then to provided default # Fall back to DEFAULT_SETTINGS, then to provided default
return DEFAULT_SETTINGS.get(key, default) fallback = DEFAULT_SETTINGS.get(key, default)
logger.debug(f"DB setting '{key}' not found, using fallback: {fallback}")
return fallback
def _get_db_setting_sync(key: str, default=None): def _get_db_setting_sync(key: str, default=None):
@@ -138,6 +141,28 @@ async def _download_source_async(source_id: int, download_id: int = None) -> dic
) )
download = result.scalar() download = result.scalar()
if download: if download:
# Guard against race conditions: only proceed if still QUEUED
if download.status != DownloadStatus.QUEUED:
logger.info(f"Download {download_id} is no longer QUEUED (status: {download.status}), skipping")
return {"source_id": source_id, "download_id": download_id, "status": "skipped", "reason": "already_processed"}
# Also check if source already has another RUNNING download
running_check = await session.execute(
select(Download.id).where(
Download.source_id == source_id,
Download.status == DownloadStatus.RUNNING,
Download.id != download_id
)
)
running_download_id = running_check.scalar()
if running_download_id:
# Mark this download as skipped since another is already running
download.status = DownloadStatus.SKIPPED
download.error_message = f"Skipped - another download ({running_download_id}) already running for this source"
await session.commit()
logger.info(f"Source {source_id} already has a running download, marked {download_id} as SKIPPED")
return {"source_id": source_id, "download_id": download_id, "status": "skipped", "reason": "source_already_running"}
download.status = DownloadStatus.RUNNING download.status = DownloadStatus.RUNNING
download.started_at = utcnow() download.started_at = utcnow()
else: else:
@@ -145,6 +170,17 @@ async def _download_source_async(source_id: int, download_id: int = None) -> dic
download = None download = None
if not download_id or download is None: if not download_id or download is None:
# Check if source already has an active download before creating new one
active_check = await session.execute(
select(Download.id).where(
Download.source_id == source_id,
Download.status.in_([DownloadStatus.QUEUED, DownloadStatus.RUNNING])
)
)
if active_check.scalar():
logger.info(f"Source {source_id} already has an active download, skipping new download creation")
return {"source_id": source_id, "status": "skipped", "reason": "source_already_active"}
download = Download( download = Download(
source_id=source_id, source_id=source_id,
url=source_url, url=source_url,
@@ -317,10 +353,12 @@ async def _scheduled_check_async() -> dict:
# Get global schedule interval from database settings # Get global schedule interval from database settings
# This is the primary interval that controls how often sources are checked # This is the primary interval that controls how often sources are checked
global_interval = await _get_db_setting( raw_interval = await _get_db_setting(
session, "download.schedule_interval", 3600 # Default 1 hour session, "download.schedule_interval", 28800 # Default 8 hours
) )
logger.debug(f"Using global schedule interval: {global_interval} seconds") # Ensure we have an integer value (JSONB might return different types)
global_interval = int(raw_interval) if raw_interval is not None else 28800
logger.info(f"Schedule check: using interval {global_interval}s (raw value: {raw_interval}, type: {type(raw_interval).__name__})")
# Find sources due for checking: # Find sources due for checking:
# - enabled = True # - enabled = True
@@ -352,15 +390,21 @@ async def _scheduled_check_async() -> dict:
# Filter by global schedule interval from Settings # Filter by global schedule interval from Settings
# All sources use the same interval - no per-source overrides # All sources use the same interval - no per-source overrides
due_sources = [] due_sources = []
not_due_count = 0
for source in sources: for source in sources:
if source.last_check is None: if source.last_check is None:
due_sources.append(source) due_sources.append(source)
logger.debug(f"Source {source.id} ({source.platform}) - never checked, marking due")
else: else:
time_since_check = (now - source.last_check).total_seconds() time_since_check = (now - source.last_check).total_seconds()
if time_since_check >= global_interval: if time_since_check >= global_interval:
due_sources.append(source) due_sources.append(source)
logger.debug(f"Source {source.id} ({source.platform}) - last check {time_since_check:.0f}s ago >= {global_interval}s, marking due")
else:
not_due_count += 1
logger.debug(f"Source {source.id} ({source.platform}) - last check {time_since_check:.0f}s ago < {global_interval}s, not due yet")
logger.info(f"Found {len(due_sources)} sources due for checking") logger.info(f"Schedule check: {len(due_sources)} sources due, {not_due_count} sources not due (interval: {global_interval}s)")
# Get source IDs that already have a queued or running download # Get source IDs that already have a queued or running download
due_source_ids = [s.id for s in due_sources] due_source_ids = [s.id for s in due_sources]
@@ -483,7 +527,14 @@ def scheduled_check():
This is called periodically by Celery Beat. This is called periodically by Celery Beat.
""" """
return asyncio.run(_scheduled_check_async()) logger.info("=== SCHEDULED_CHECK TASK STARTING ===")
try:
result = asyncio.run(_scheduled_check_async())
logger.info(f"=== SCHEDULED_CHECK TASK COMPLETE: {result} ===")
return result
except Exception as e:
logger.exception(f"=== SCHEDULED_CHECK TASK FAILED: {e} ===")
raise
@celery_app.task @celery_app.task
+166
View File
@@ -23,6 +23,11 @@ settings = get_settings()
# This should be longer than the task_time_limit (1 hour) to account for legitimate long runs # This should be longer than the task_time_limit (1 hour) to account for legitimate long runs
STALE_RUNNING_THRESHOLD_MINUTES = 90 STALE_RUNNING_THRESHOLD_MINUTES = 90
# Maximum time a job can be in "queued" state before considered lost
# If a queued job hasn't been picked up in this time, the Celery task was probably lost
# (e.g., due to Redis DB mismatch or Redis restart)
STALE_QUEUED_THRESHOLD_MINUTES = 30
def get_async_session(): def get_async_session():
"""Get async session factory for Celery tasks.""" """Get async session factory for Celery tasks."""
@@ -242,3 +247,164 @@ def reset_all_running_jobs_sync() -> dict:
logger.info("Resetting all running jobs on worker startup...") logger.info("Resetting all running jobs on worker startup...")
result = asyncio.run(_reset_all_running_jobs_async()) result = asyncio.run(_reset_all_running_jobs_async())
return result return result
async def _requeue_stale_queued_jobs_async(threshold_minutes: int = None) -> dict:
"""Re-queue Celery tasks for downloads stuck in 'queued' status.
Downloads can get stuck in 'queued' status if the Celery task was lost
(e.g., sent to wrong Redis DB, Redis restart, etc.). This function
detects these stale queued jobs and re-sends the Celery tasks.
Only one download per source is re-queued to prevent duplicate work.
Args:
threshold_minutes: Jobs queued longer than this are considered stale.
If None, uses STALE_QUEUED_THRESHOLD_MINUTES.
Returns:
Dict with count of re-queued jobs
"""
if threshold_minutes is None:
threshold_minutes = STALE_QUEUED_THRESHOLD_MINUTES
# Import here to avoid circular imports
from app.tasks.downloads import download_source
session_factory, engine = get_async_session()
try:
async with session_factory() as session:
now = utcnow()
cutoff = now - timedelta(minutes=threshold_minutes)
# Find queued jobs created before the cutoff, ordered by creation time
# so we process older jobs first
query = select(Download).where(
Download.status == DownloadStatus.QUEUED,
Download.created_at < cutoff
).order_by(Download.created_at.asc())
result = await session.execute(query)
stale_jobs = result.scalars().all()
# Track which sources we've already queued to avoid duplicates
queued_source_ids = set()
requeued_count = 0
skipped_duplicates = 0
for job in stale_jobs:
# Skip if we've already queued a job for this source
if job.source_id in queued_source_ids:
# Mark duplicate as skipped so it won't be checked again
job.status = DownloadStatus.SKIPPED
job.error_message = "Duplicate queued job - another job for this source was re-queued"
skipped_duplicates += 1
logger.info(f"Marked duplicate job {job.id} as SKIPPED (source {job.source_id} already re-queued)")
continue
try:
# Re-send the Celery task
download_source.delay(job.source_id, download_id=job.id)
queued_source_ids.add(job.source_id)
requeued_count += 1
logger.info(f"Re-queued stale job {job.id} (source_id: {job.source_id}, queued since: {job.created_at})")
except Exception as e:
logger.error(f"Failed to re-queue job {job.id}: {e}")
# Commit the status changes for skipped duplicates
if skipped_duplicates > 0:
await session.commit()
if requeued_count > 0:
logger.info(f"Re-queued {requeued_count} stale queued jobs (marked {skipped_duplicates} duplicates as SKIPPED)")
else:
logger.debug("No stale queued jobs found")
return {"requeued_count": requeued_count, "skipped_duplicates": skipped_duplicates, "threshold_minutes": threshold_minutes}
finally:
await _cleanup_engine(engine)
async def _requeue_all_queued_jobs_async() -> dict:
"""Re-queue Celery tasks for ALL downloads in 'queued' status.
This is useful on scheduler startup to recover from Redis DB mismatch
or other issues that caused Celery tasks to be lost.
Only one download per source is re-queued to prevent duplicate work.
Returns:
Dict with count of re-queued jobs
"""
# Import here to avoid circular imports
from app.tasks.downloads import download_source
session_factory, engine = get_async_session()
try:
async with session_factory() as session:
# Find all queued jobs, ordered by creation time (oldest first)
query = select(Download).where(
Download.status == DownloadStatus.QUEUED
).order_by(Download.created_at.asc())
result = await session.execute(query)
queued_jobs = result.scalars().all()
# Track which sources we've already queued to avoid duplicates
queued_source_ids = set()
requeued_count = 0
skipped_duplicates = 0
for job in queued_jobs:
# Skip if we've already queued a job for this source
if job.source_id in queued_source_ids:
# Mark duplicate as skipped so it won't be checked again
job.status = DownloadStatus.SKIPPED
job.error_message = "Duplicate queued job - another job for this source was re-queued"
skipped_duplicates += 1
logger.info(f"Marked duplicate job {job.id} as SKIPPED (source {job.source_id} already re-queued)")
continue
try:
download_source.delay(job.source_id, download_id=job.id)
queued_source_ids.add(job.source_id)
requeued_count += 1
logger.info(f"Re-queued job {job.id} (source_id: {job.source_id}) on startup")
except Exception as e:
logger.error(f"Failed to re-queue job {job.id}: {e}")
# Commit the status changes for skipped duplicates
if skipped_duplicates > 0:
await session.commit()
if requeued_count > 0:
logger.info(f"Re-queued {requeued_count} queued jobs on startup (marked {skipped_duplicates} duplicates as SKIPPED)")
return {"requeued_count": requeued_count, "skipped_duplicates": skipped_duplicates}
finally:
await _cleanup_engine(engine)
@celery_app.task(name="tasks.requeue_stale_jobs")
def requeue_stale_jobs(threshold_minutes: int = None) -> dict:
"""Celery task to re-queue stale queued jobs.
Jobs that have been in 'queued' status for longer than the threshold
are considered lost (Celery task missing) and re-queued.
"""
logger.info("Checking for stale queued jobs...")
result = asyncio.run(_requeue_stale_queued_jobs_async(threshold_minutes))
return result
def requeue_all_queued_jobs_sync() -> dict:
"""Synchronous function to re-queue all queued jobs.
Called on scheduler startup to recover lost Celery tasks.
"""
logger.info("Re-queuing all queued jobs on scheduler startup...")
result = asyncio.run(_requeue_all_queued_jobs_async())
return result
+17 -17
View File
@@ -1,28 +1,28 @@
# Web framework # Web framework
quart==0.19.4 quart
quart-cors==0.7.0 quart-cors
hypercorn==0.16.0 hypercorn
flask>=3.0.0,<3.1.0 flask
# Database # Database
sqlalchemy[asyncio]==2.0.25 sqlalchemy[asyncio]
asyncpg==0.29.0 asyncpg
alembic==1.13.1 alembic
# Task queue # Task queue
celery[redis]==5.3.6 celery[redis]
redis==5.0.1 redis
# Utilities # Utilities
pydantic==2.5.3 pydantic
pydantic-settings==2.1.0 pydantic-settings
python-dotenv==1.0.0 python-dotenv
cryptography==41.0.7 cryptography
pyyaml==6.0.1 pyyaml
# gallery-dl # gallery-dl
gallery-dl>=1.31.0 gallery-dl
# Testing # Testing
pytest==7.4.4 pytest
pytest-asyncio==0.23.3 pytest-asyncio
+21 -5
View File
@@ -16,7 +16,7 @@ services:
environment: environment:
- TZ=${TZ:-America/New_York} - TZ=${TZ:-America/New_York}
- DATABASE_URL=postgresql://${DB_USER:-gdl}:${DB_PASSWORD}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-gallery_subscriber} - DATABASE_URL=postgresql://${DB_USER:-gdl}:${DB_PASSWORD}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-gallery_subscriber}
- REDIS_URL=redis://${REDIS_HOST:-redis}:${REDIS_PORT:-6379}/0 - REDIS_URL=redis://${REDIS_HOST:-redis}:${REDIS_PORT:-6379}/${REDIS_DB:-0}
- SECRET_KEY=${SECRET_KEY} - SECRET_KEY=${SECRET_KEY}
- DOWNLOAD_PARALLEL_LIMIT=${DOWNLOAD_PARALLEL_LIMIT:-3} - DOWNLOAD_PARALLEL_LIMIT=${DOWNLOAD_PARALLEL_LIMIT:-3}
- DOWNLOAD_RATE_LIMIT=${DOWNLOAD_RATE_LIMIT:-3.0} - DOWNLOAD_RATE_LIMIT=${DOWNLOAD_RATE_LIMIT:-3.0}
@@ -31,14 +31,14 @@ services:
condition: service_started condition: service_started
restart: unless-stopped restart: unless-stopped
# Celery worker for background tasks # Celery download workers - only process download tasks (heavy work)
celery: worker:
image: git.fabledsword.com/bvandeusen/gallerysubscriber:latest image: git.fabledsword.com/bvandeusen/gallerysubscriber:latest
command: celery -A app.tasks.celery_app worker -B --loglevel=info command: celery -A app.tasks.celery_app worker --queues=downloads --loglevel=info
environment: environment:
- TZ=${TZ:-America/New_York} - TZ=${TZ:-America/New_York}
- DATABASE_URL=postgresql://${DB_USER:-gdl}:${DB_PASSWORD}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-gallery_subscriber} - DATABASE_URL=postgresql://${DB_USER:-gdl}:${DB_PASSWORD}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-gallery_subscriber}
- REDIS_URL=redis://${REDIS_HOST:-redis}:${REDIS_PORT:-6379}/0 - REDIS_URL=redis://${REDIS_HOST:-redis}:${REDIS_PORT:-6379}/${REDIS_DB:-0}
- SECRET_KEY=${SECRET_KEY} - SECRET_KEY=${SECRET_KEY}
- DOWNLOAD_PARALLEL_LIMIT=${DOWNLOAD_PARALLEL_LIMIT:-3} - DOWNLOAD_PARALLEL_LIMIT=${DOWNLOAD_PARALLEL_LIMIT:-3}
- DOWNLOAD_RATE_LIMIT=${DOWNLOAD_RATE_LIMIT:-3.0} - DOWNLOAD_RATE_LIMIT=${DOWNLOAD_RATE_LIMIT:-3.0}
@@ -53,6 +53,22 @@ services:
condition: service_started condition: service_started
restart: unless-stopped restart: unless-stopped
# Celery scheduler - runs beat + handles lightweight scheduling tasks
scheduler:
image: git.fabledsword.com/bvandeusen/gallerysubscriber:latest
command: celery -A app.tasks.celery_app worker --queues=scheduling --beat --loglevel=info --concurrency=1
environment:
- TZ=${TZ:-America/New_York}
- DATABASE_URL=postgresql://${DB_USER:-gdl}:${DB_PASSWORD}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-gallery_subscriber}
- REDIS_URL=redis://${REDIS_HOST:-redis}:${REDIS_PORT:-6379}/${REDIS_DB:-0}
- SECRET_KEY=${SECRET_KEY}
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
restart: unless-stopped
# PostgreSQL database # PostgreSQL database
db: db:
image: postgres:15-alpine image: postgres:15-alpine
+15 -3
View File
@@ -143,8 +143,8 @@
<td style="word-break: break-all">{{ selectedDownload.url }}</td> <td style="word-break: break-all">{{ selectedDownload.url }}</td>
</tr> </tr>
<tr> <tr>
<td class="font-weight-bold">Source ID</td> <td class="font-weight-bold">Source</td>
<td>{{ selectedDownload.source_id || 'N/A' }}</td> <td>{{ getSourceLabel(selectedDownload.source_id) }}</td>
</tr> </tr>
<tr> <tr>
<td class="font-weight-bold">Files Downloaded</td> <td class="font-weight-bold">Files Downloaded</td>
@@ -228,7 +228,10 @@ const statusOptions = [
] ]
const sourceOptions = computed(() => const sourceOptions = computed(() =>
sourcesStore.sources.map(s => ({ title: s.name, value: s.id })) sourcesStore.sources.map(s => ({
title: `${s.subscription_name || 'Unknown'}:${s.platform}`,
value: s.id
}))
) )
const headers = [ const headers = [
@@ -308,6 +311,15 @@ function truncateUrl(url) {
return url.substring(0, 47) + '...' return url.substring(0, 47) + '...'
} }
function getSourceLabel(sourceId) {
if (!sourceId) return 'N/A'
const source = sourcesStore.sources.find(s => s.id === sourceId)
if (source) {
return `${source.subscription_name || 'Unknown'}:${source.platform}`
}
return `Source #${sourceId}`
}
function showDetails(download) { function showDetails(download) {
selectedDownload.value = download selectedDownload.value = download
detailsDialog.value = true detailsDialog.value = true
+1 -1
View File
@@ -420,7 +420,7 @@ const sortedAllPackages = computed(() => {
}) })
const scheduleIntervalHint = computed(() => { const scheduleIntervalHint = computed(() => {
const seconds = settings.value['download.schedule_interval'] || 3600 const seconds = settings.value['download.schedule_interval'] || 28800
if (seconds < 60) return `Check every ${seconds} seconds` if (seconds < 60) return `Check every ${seconds} seconds`
if (seconds < 3600) return `Check every ${Math.round(seconds / 60)} minutes` if (seconds < 3600) return `Check every ${Math.round(seconds / 60)} minutes`
if (seconds < 86400) { if (seconds < 86400) {
+1 -1
View File
@@ -190,7 +190,7 @@ const formData = ref({
platform: '', platform: '',
url: '', url: '',
enabled: true, enabled: true,
check_interval: 3600, check_interval: 28800,
metadata: {}, metadata: {},
}) })
+66 -13
View File
@@ -1,6 +1,6 @@
# GallerySubscriber - Project Summary # GallerySubscriber - Project Summary
**Updated:** 2026-01-28 (settings integration, orphan cleanup, error categorization fixes) **Updated:** 2026-01-29 (task routing, scheduler/worker separation, 8-hour default interval)
## Overview ## Overview
@@ -95,7 +95,8 @@ GallerySubscriber/
| Component | Entry Point | Command | | Component | Entry Point | Command |
|-----------|-------------|---------| |-----------|-------------|---------|
| Backend API | `backend/app/main.py` | `hypercorn app.main:app --bind 0.0.0.0:8080` | | Backend API | `backend/app/main.py` | `hypercorn app.main:app --bind 0.0.0.0:8080` |
| Celery Worker | `backend/app/tasks/celery_app.py` | `celery -A app.tasks.celery_app worker -B` | | Scheduler | `backend/app/tasks/celery_app.py` | `celery -A app.tasks.celery_app worker --queues=scheduling --beat --concurrency=1` |
| Download Workers | `backend/app/tasks/celery_app.py` | `celery -A app.tasks.celery_app worker --queues=downloads` |
| Frontend | `frontend/src/main.js` | Built with Vite to `/static` | | Frontend | `frontend/src/main.js` | Built with Vite to `/static` |
## Database Schema ## Database Schema
@@ -142,6 +143,7 @@ Download (1) ──→ (many) ContentItem
| `/api/downloads/stats` | GET | Download counts by status/platform | | `/api/downloads/stats` | GET | Download counts by status/platform |
| `/api/downloads/recent-activity` | GET | Recent failures and completions | | `/api/downloads/recent-activity` | GET | Recent failures and completions |
| `/api/downloads/reset-orphaned` | POST | Reset stuck running jobs | | `/api/downloads/reset-orphaned` | POST | Reset stuck running jobs |
| `/api/downloads/requeue-stale` | POST | Re-queue lost queued jobs |
| `/api/credentials` | GET, POST | List/upload credentials | | `/api/credentials` | GET, POST | List/upload credentials |
| `/api/credentials/<platform>` | DELETE | Remove credential | | `/api/credentials/<platform>` | DELETE | Remove credential |
| `/api/settings` | GET, PUT | App configuration | | `/api/settings` | GET, PUT | App configuration |
@@ -175,17 +177,18 @@ Download (1) ──→ (many) ContentItem
3. **Service Layer** - GalleryDLService wraps gallery-dl subprocess 3. **Service Layer** - GalleryDLService wraps gallery-dl subprocess
4. **Encrypted Credentials** - Fernet symmetric encryption with PBKDF2 key derivation 4. **Encrypted Credentials** - Fernet symmetric encryption with PBKDF2 key derivation
5. **Global Schedule Interval** - All sources checked at the same interval (configured in Settings) 5. **Global Schedule Interval** - All sources checked at the same interval (configured in Settings)
6. **Celery Beat** - Runs `scheduled_check` every 60s to trigger due downloads 6. **Task Routing** - Separate queues for scheduling vs download tasks
7. **Database-Driven Settings** - Worker reads rate_limit, retry_count, parallel_limit from DB 7. **Database-Driven Settings** - Worker reads rate_limit, retry_count, parallel_limit, schedule_interval from DB
8. **Orphan Job Cleanup** - Worker startup + periodic task resets stuck "running" jobs 8. **Orphan Job Cleanup** - Scheduler startup + periodic task resets stuck "running" jobs
## Docker Services ## Docker Services
```yaml ```yaml
app: Backend API + Frontend (port 8080) app: Backend API + Frontend (port 8080)
celery: Background worker with scheduler scheduler: Celery beat + scheduling worker (lightweight tasks)
db: PostgreSQL 15-Alpine worker: Celery download workers (heavy gallery-dl tasks)
redis: Redis 7-Alpine db: PostgreSQL 15-Alpine
redis: Redis 7-Alpine (task queue + result backend)
Volumes: Volumes:
downloads: /data/downloads (downloaded files) downloads: /data/downloads (downloaded files)
@@ -193,6 +196,39 @@ Volumes:
postgres_data: Database persistence postgres_data: Database persistence
``` ```
## Task Routing Architecture
Tasks are routed to separate queues for clean separation of concerns:
```
┌─────────────────────────────────────────────────────────────────┐
│ SCHEDULER (1 container) │
│ - Runs Celery Beat (triggers periodic tasks) │
│ - 1 worker consuming from "scheduling" queue │
│ - Handles: scheduled_check, cleanup, storage_stats, etc. │
│ - Resets orphaned jobs on startup (only here, not workers) │
└─────────────────────────────────────────────────────────────────┘
│ Queues download tasks
┌─────────────────────────────────────────────────────────────────┐
│ Redis Queues │
│ - "scheduling" queue → scheduler only │
│ - "downloads" queue → download workers only │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ WORKERS (scalable, 1+ containers) │
│ - Consume from "downloads" queue only │
│ - Handles: download_source, process_download │
│ - Heavy work (running gallery-dl) │
└─────────────────────────────────────────────────────────────────┘
```
**Task Routing Config** (in `celery_app.py`):
- `scheduled_check`, `cleanup_old_downloads`, `update_storage_stats`, `reset_orphaned_jobs` → "scheduling" queue
- `download_source`, `process_download` → "downloads" queue
## Common Operations ## Common Operations
**Run migrations:** **Run migrations:**
@@ -207,7 +243,7 @@ docker compose up -d --build
**View logs:** **View logs:**
```bash ```bash
docker compose logs -f app celery docker compose logs -f app scheduler worker
``` ```
**Development mode:** **Development mode:**
@@ -233,22 +269,39 @@ docker compose -f docker-compose.dev.yml up
5. Download records and content items stored in database 5. Download records and content items stored in database
6. Real-time progress broadcast via WebSocket 6. Real-time progress broadcast via WebSocket
## Source Scheduling Rules
A source gets queued for download when ALL of these conditions are met:
1. `source.enabled = True`
2. `subscription.enabled = True`
3. `(now - source.last_check) >= schedule_interval` OR `source.last_check is NULL` (never checked)
4. No existing QUEUED or RUNNING download for this source
**Note:** `last_check` is updated when a download **completes** (success or failure), not when queued. This means the interval is measured from completion to completion.
## Scheduled Tasks (Celery Beat) ## Scheduled Tasks (Celery Beat)
| Task | Schedule | Purpose | | Task | Schedule | Purpose |
|------|----------|---------| |------|----------|---------|
| `scheduled_check` | Every 60s | Check if sources are due (based on global schedule_interval) | | `scheduled_check` | Every 15 min | Check if sources are due (based on global schedule_interval) |
| `cleanup_old_downloads` | Daily 3 AM | Remove old download records (30d completed, 7d failed) | | `cleanup_old_downloads` | Daily 3 AM | Remove old download records (30d completed, 7d failed) |
| `update_storage_stats` | Every 30 min | Calculate downloads folder size | | `update_storage_stats` | Every 30 min | Calculate downloads folder size |
| `reset_orphaned_jobs` | Every 15 min | Reset jobs stuck in "running" > 90 min | | `reset_orphaned_jobs` | Every 15 min | Reset jobs stuck in "running" > 90 min |
| `requeue_stale_jobs` | Every 15 min | Re-queue jobs stuck in "queued" > 30 min (lost Celery tasks) |
**Worker Startup Behavior:** When Celery worker starts, it resets ALL "running" jobs to "queued" (handles crash recovery). **Scheduler Startup Behavior:** When the scheduler container starts:
1. Resets ALL "running" jobs to "queued" (handles crash recovery)
2. Re-queues ALL "queued" jobs (recovers from Redis DB mismatch or lost tasks)
3. Triggers an immediate `scheduled_check` (don't wait 15 min for first check)
Download workers do NOT perform these startup tasks to avoid duplicates.
## Settings (Database-Driven) ## Settings (Database-Driven)
| Key | Default | Purpose | | Key | Default | Purpose |
|-----|---------|---------| |-----|---------|---------|
| `download.schedule_interval` | 3600 | Seconds between source checks (applies to all sources) | | `download.schedule_interval` | 28800 | Seconds between source checks (8 hours, applies to all sources) |
| `download.rate_limit` | 3.0 | Seconds between gallery-dl requests | | `download.rate_limit` | 3.0 | Seconds between gallery-dl requests |
| `download.parallel_limit` | 3 | Concurrent Celery workers (requires restart) | | `download.parallel_limit` | 3 | Concurrent Celery workers (requires restart) |
| `download.retry_count` | 3 | Max retries for failed downloads | | `download.retry_count` | 3 | Max retries for failed downloads |