Covers four confirmed bugs, superseded-at-write-time refactor, error classification improvements, and code quality cleanup. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
10 KiB
GallerySubscriber Comprehensive Review — Design Spec
Date: 2026-03-18 Approach: Layered fixes (bugs → superseded logic → error classification → code quality) Scope: Confirmed bugs, retry/superseded behaviour, error classification, code quality
Section 1: Bug Fixes
Bug 1 — process_download retry silently skips
File: backend/app/tasks/downloads.py:574
process_download(download_id) fetches the download record, gets its source_id, then calls
_download_source_async(source_id) without passing download_id. Since download_id is None,
the function enters the not download_id or download is None branch (line 172) and queries for any
QUEUED or RUNNING download for that source. The API retry endpoint already reset that record to
QUEUED, so the guard at line 180 finds it and returns source_already_active. The retry never
runs. Recovery eventually happens via the 30-minute stale-job maintenance task.
Fix: Pass download_id through:
return await _download_source_async(download.source_id, download_id=download_id)
Bug 2 — Orphaned job reset message always says "None"
File: backend/app/tasks/maintenance.py:177-178
job.started_at = None # cleared on line 177
job.error_message = f"Reset from orphaned running state (was running since {job.started_at})" # now None on line 178
Fix: Capture started_at before clearing it:
original_started_at = job.started_at
job.started_at = None
job.error_message = f"Reset from orphaned running state (was running since {original_started_at})"
Bug 3 — Deprecated asyncio.get_event_loop()
File: backend/app/services/gallery_dl.py:616
asyncio.get_event_loop() is fragile inside a running async context in Python 3.10+. The safer and
more explicit replacement is asyncio.get_running_loop(), which raises RuntimeError rather than
silently returning a potentially wrong loop if the call path changes.
Fix: Replace with asyncio.get_running_loop().
Bug 4 — datetime.utcnow() inconsistency
File: backend/app/services/gallery_dl.py:561, 628
Two uses of datetime.utcnow() (naive datetime, deprecated in Python 3.12) while the rest of the
codebase uses utcnow() from app.models.base (timezone-aware).
Fix: Import and use utcnow() from app.models.base in gallery_dl.py.
Section 2: Superseded Logic (Write-time)
Problem
_not_superseded_condition() is a correlated subquery evaluated at query time on every call to
/api/downloads/stats, /api/downloads/recent-activity, and filtered download lists. It is
duplicated across multiple call sites and adds complexity to every query.
Model Change
Add a superseded boolean column to the Download model and downloads table (default False).
Add the mapped column to backend/app/models/download.py alongside the other columns, and add
"superseded": self.superseded to Download.to_dict() so the field is visible in API responses.
Write Logic
When a download completes successfully (status COMPLETED, regardless of file_count), bulk-update
all prior FAILED downloads for the same source to superseded = True. This write happens in Phase
3 of _download_source_async, after setting download.status = COMPLETED but before calling
publish_event("download.completed", ...). Placing it before the event ensures the database state
is already correct if any event consumer queries it before the session commits:
download.status = DownloadStatus.COMPLETED
# Mark prior failures as superseded before publishing the event
await session.execute(
update(Download)
.where(
Download.source_id == source_id,
Download.status == DownloadStatus.FAILED,
)
.values(superseded=True)
)
# Now safe to publish — subscribers see updated state
publish_event("download.completed", { ... })
This only affects FAILED records. QUEUED retries of a failed download are not touched — they
will either run and complete or be superseded by the next successful run. There is no meaningful
race risk: if two tasks complete for the same source concurrently, both will mark prior failures as
superseded, which is idempotent and correct.
Migration
One Alembic migration with three steps:
-- 1. Add column
ALTER TABLE downloads ADD COLUMN superseded BOOLEAN NOT NULL DEFAULT FALSE;
-- 2. Backfill: mark FAILED downloads as superseded where a later COMPLETED download
-- exists for the same source. Required — omitting this causes all historic
-- superseded failures to reappear in the dashboard immediately after deployment.
UPDATE downloads d
SET superseded = TRUE
WHERE d.status = 'failed'
AND EXISTS (
SELECT 1 FROM downloads d2
WHERE d2.source_id = d.source_id
AND d2.status = 'completed'
AND d2.created_at > d.created_at
);
-- 3. Partial index covering the dominant query pattern (status + superseded = FALSE).
-- A single-column boolean index would be ignored by the planner in favour of
-- status-based indexes; this form is used by all three affected query sites.
CREATE INDEX idx_downloads_not_superseded ON downloads (status)
WHERE superseded = FALSE;
The backfill is required, not optional. Without it, all previously-superseded failures will appear as active failures immediately after the migration runs.
Query Changes
All uses of _not_superseded_condition() are replaced with Download.superseded == False.
The _not_superseded_condition() function is removed.
Affected sites:
GET /api/downloads/stats—active_failedcountGET /api/downloads/recent-activity— failed downloads filterGET /api/downloadswithexclude_superseded=true— list filter
Impact on UI
- "Retry All Failed" in Dashboard — only sees unsuperseded failures
- "Sources Needing Attention" — clears naturally when source recovers
- Downloads list
exclude_supersededfilter — works via column instead of subquery
Section 3: Error Classification Improvements
3a — Debug logging for pattern matches
Add logger.debug() output when _categorize_error() returns a result, identifying which pattern
triggered the classification:
logger.debug(f"Error classified as {error_type} via pattern: '{matched_pattern}'")
This is zero-cost in production (DEBUG level) and makes the export-failed-logs analysis tool much more useful for diagnosing misclassifications.
3b — Extract pattern lists to class-level constants
Move inline pattern lists (auth_error_patterns, rate_limit_patterns, not_found_patterns, etc.)
from inside _categorize_error() to named class-level constants on GalleryDLService. No logic
change. Makes patterns easy to scan, extend, and test in isolation.
3c — Fix no_new_content for empty output on return code 1
File: backend/app/services/gallery_dl.py:393-394
if return_code in (0, 1) and not stdout.strip():
return ErrorType.NO_NEW_CONTENT, "No new content to download"
Gallery-dl return code 1 means "some extractions failed". Treating it as no_new_content when
stdout is empty can mask real failures.
Fix: Only treat empty stdout as NO_NEW_CONTENT for return code 0:
if return_code == 0 and not stdout.strip():
return ErrorType.NO_NEW_CONTENT, "No new content to download"
Trade-off: Gallery-dl may also return code 1 with empty stdout for genuinely empty sources
(user with no posts, empty gallery). After this fix those runs will fall through to UNKNOWN_ERROR
rather than NO_NEW_CONTENT. This is acceptable: it is better to surface these as needing attention
than to silently swallow potential errors. If this becomes noisy in practice, a specific
gallery-dl "no URLs extracted" pattern can be added to the no_new_content detection phase.
Section 4: Code Quality
4a — Dashboard checkAllSources serializes requests
File: frontend/src/views/Dashboard.vue:464-471
Sequential await inside a for loop means sources are checked one at a time. With many sources
this blocks the UI.
Fix: Use Promise.allSettled() to fire all triggerCheck calls in parallel, then count
resolved vs rejected results. Note: with a large number of sources (> 20), the simultaneous burst
of requests may be worth rate-limiting or batching; the existing source_already_active guard on
the backend prevents duplicate records regardless.
4b — AsyncSession not closed in API endpoints
File: backend/app/api/downloads.py — all six route handlers use the same unclosed-session
pattern: list_downloads (line 45), get_download (line 105), retry_download (line 139),
recent_activity (line 179), get_stats (line 304), export_failed_logs (line 373).
Other API blueprint files (sources.py, subscriptions.py, credentials.py) should also be
checked and fixed if they use the same pattern.
Fix: Replace session = AsyncSession(bind=conn) with async with AsyncSession(bind=conn) as session:
in all affected handlers to ensure proper cleanup on all paths.
4c — _count_downloaded_files over-counts
File: backend/app/services/gallery_dl.py:517-531
The current heuristic (exclude lines starting with # or containing "skip") still counts
gallery-dl log lines and, with --verbose enabled, HTTP request/response lines. The most robust
fix is to count only lines that look like absolute file paths (starting with /), which is the
actual gallery-dl output format for downloaded files. This replaces the current approach entirely
and handles the --verbose output correctly.
4d — Duplicate DB helpers across task files
Files: backend/app/tasks/downloads.py and backend/app/tasks/maintenance.py
Both files define identical get_async_session() and _cleanup_engine() functions.
Fix: Extract to backend/app/tasks/db.py and import from both task files.
Constraint: db.py must only import from app.config and SQLAlchemy — no imports from
downloads.py or maintenance.py — to avoid creating a circular import. The existing late import
in maintenance.py (from app.tasks.downloads import download_source) is already there to prevent
a circular dependency and must be preserved.
Delivery Order
- Section 1 bugs (all four — small, safe, independently verifiable)
- Section 2 superseded migration + model + write logic + query cleanup
- Section 3 error classification improvements
- Section 4 code quality (can be done alongside or after sections 2–3)