This repository has been archived on 2026-05-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
GallerySubscriber/docs/superpowers/specs/2026-03-18-comprehensive-review-design.md
T
bvandeusen 0698ee1fa7 Add comprehensive review design spec
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>
2026-03-18 23:14:19 -04:00

264 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```python
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`
```python
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:
```python
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:
```python
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:
```sql
-- 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_failed` count
- `GET /api/downloads/recent-activity` — failed downloads filter
- `GET /api/downloads` with `exclude_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_superseded` filter — 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:
```python
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`
```python
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`:
```python
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
1. Section 1 bugs (all four — small, safe, independently verifiable)
2. Section 2 superseded migration + model + write logic + query cleanup
3. Section 3 error classification improvements
4. Section 4 code quality (can be done alongside or after sections 23)