Nobody has started this yet — be first.
Business impact
The project's own README says this out loud, in "Known limitations": background tasks run in-process with no external queue, so "a process restart loses any task that hadn't started yet (though the record is left at status='processing' in the DB, not silently lost -- a production hardening step would be a periodic sweep that requeues stale 'processing' rows...)". That hardening step has never actually been written. Today, any deploy, crash, or OOM-kill that happens to land between "record created with status=processing" and "background task commits ready/failed" leaves that row stuck at processing forever, with no code path that ever revisits it. To the uploading user, their image never finishes: no error, no thumbnails, just permanent "processing" -- the single worst failure mode this service can produce, because it looks like the request is still in flight when it has actually been abandoned. Every such row is also invisible to normal monitoring (nothing errors, nothing logs) -- the only way anyone finds out today is a user complaining that their upload "has been stuck for 3 days."
Problem
app/maintenance.py exists with the function signature reap_stale_processing_records(session_factory, storage, stale_after_seconds) already defined, matching the shape a periodic job (a cron entry, a Celery beat task, a sidecar loop -- deliberately not wired up here, since the scheduler choice is out of scope for this ticket) would call it with. The body is a stub: it unconditionally raises NotImplementedError.
Current behavior
Calling reap_stale_processing_records(...) at all raises NotImplementedError unconditionally, so there is currently no code path anywhere that ever revisits a processing row abandoned by a crashed or restarted background task -- it just stays stuck at status=processing forever, invisible to monitoring.
Expected behavior
Calling reap_stale_processing_records(session_factory, storage, stale_after_seconds) should find every ImageRecord where status == ImageStatus.PROCESSING and updated_at is older than stale_after_seconds seconds ago; for each one found, set status = ImageStatus.FAILED with a clear, non-empty error_reason explaining why (e.g. something like "timed out: background task did not report back within {stale_after_seconds}s"); commit those changes; return the number of records reaped; and leave every other record -- fresher processing rows, and anything already ready/failed -- completely untouched.
Steps to reproduce
cd fastapi/image_service && source .venv/bin/activate pytest -q practicetickets/test_ticket07_stale_processing_reaper.py -v
Why this matters
This is the same "the response and the work are two different lifetimes" lesson the whole project is built around (see the README's opening section) taken to its logical conclusion: if the response already happened but the work never will, the record needs a way to notice that on its own, since the client that originally uploaded is long gone and isn't coming back to complain in a way the system can act on. updated_at on ImageRecord (app/models.py) is already stamped onupdate=... on every row change, which makes it exactly the signal needed here: a processing row whose updated_at hasn't moved in a long time is a row whose background task is never coming back. This is also the shape every real production hardening of this pattern eventually takes, whether the actual scheduler ends up being a cron entry, a Celery beat task, or a sidecar loop -- the reaping logic itself is scheduler-agnostic, which is exactly why this function takes session_factory and stale_after_seconds as plain arguments instead of assuming anything about how or when it gets called.
Suggested approach
You'll query models.ImageRecord for rows matching both conditions above -- similar in spirit to the querying you'd write for ticket 03's list endpoint, but filtered by status and updated_at instead of paginated. Use session_factory() to get your own session (the same pattern run_thumbnail_pipeline in app/main.py uses for its own out-of-request session), and be careful about timezone-awareness when comparing against updated_at -- look at how models.py stamps it (datetime.now(timezone.utc)) and compare like-for-like. The storage parameter is there in case your implementation wants to account for a partially-written thumbnail from a task that died mid-write; a minimal, correct implementation does not strictly need to touch it.
Acceptance criteria
Verification
pytest practicetickets/test_ticket07_stale_processing_reaper.py -v
Hints (0/2)
Try it without hints first — the reading is the exercise.
Working on this ticket
Work on a branch named for the ticket — that's what you'll submit.
Branch off your fork
$git checkout -b feat/stale-processing-reaperFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin feat/stale-processing-reaperSubmit it below
Paste your fork URL and the branch name, with a short write-up of the root cause.
Questions
Ask about anything unclear in the ticket — the maintainer and anyone who has solved it can answer. Please don't post full solutions.
Sign in to ask a question or reply.
Sign indocker-compose.yml./app./testsuvicorn --reload/app/dataDockerfile.proddocker compose -f docker-compose.prod.yml up --build -dWithout Docker:
cd fastapi/image_service
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements-dev.txt
cp .env.example .env # optional, defaults work out of the box
uvicorn app.main:app --reload # -> http://127.0.0.1:8000/docs
Work the tickets in practicetickets/ (TICKET_01 through TICKET_07); each names one dedicated pytest file:
pytest practicetickets/test_ticket01_upload_size_boundary.py -v # a single ticket
./practicetickets/run_tickets.sh # all 7, clean pass/fail summary
Run run_tickets.sh from anywhere -- it cds to the project root itself before running, since app/main.py resolves its SQLite file and upload directory relative to the working directory at import time. pytest -q tests/ (the project's own pre-existing suite, separate from practicetickets/) is also worth running before and after each fix -- three of the four bugs (tickets 02, 04, 06) also break specific pre-existing tests there, and fixing the ticket correctly should make those green again too, with zero changes needed inside tests/ itself.
Level 2
Implement a feature
Extend the system within its own patterns.