Nobody has started this yet — be first.
Business impact
This bug was found through code review, not injected -- it is a real gap in the shipped code. For any form with more than one type: "file" field (e.g. "resume" + "photo" on a job-application form), a submitter who gets exactly one of the two files wrong (wrong content type, too large) triggers a 422 as expected -- but the other, validly-uploaded file is left sitting on disk forever, with no database row referencing it (the whole submission, including any Attachment rows, is rolled back). Nothing in this codebase ever queries "files on disk with no matching Attachment row," so there is no automatic cleanup path either. This is a slow, silent disk-usage leak on the shared uploads volume: every retried multi-file submission where the submitter fixes their mistake and resubmits leaves one more orphaned file behind from the failed attempt. At any real submission volume, this becomes an on-call/ops problem eventually -- unexplained disk growth on the uploads volume that standard "find unreferenced files" tooling cannot even detect without being purpose-built for this leak, since the whole premise of the leak is that there is no reference to search for.
Problem
In create_submission (app/blueprints/submissions/routes.py), file fields are processed in a loop. Each iteration calls validate_and_store_file, which -- as soon as its own checks (content type, size) pass -- calls file_storage.save(dest_path) immediately, writing real bytes to disk. If validation fails on a later field in the same loop, the code calls db.session.rollback() and returns a 422. db.session.rollback() discards the pending Submission/Attachment database rows for this request -- but it has no effect whatsoever on files already written to disk by earlier iterations of the same loop. Those files are real, on-disk, and now permanently orphaned.
Current behavior
A submission with two file fields, where the first field's file is valid and the second field's file is rejected, returns 422 as expected but leaves the first field's file sitting on disk with no database row referencing it, forever.
Expected behavior
If a submission is rejected (for any reason, including a later file field failing validation), no file from that submission remains on disk -- either because nothing was written until the whole submission was known to be valid, or because anything written earlier in the same failed request is cleaned up before returning the error response.
Steps to reproduce
cd flask/feedback_service export FLASK_ENV=testing FLASK_ENV=testing .venv/bin/python -m pytest ticket_tests/test_ticket_07_orphaned_upload_files_on_rollback.py -v
Why this matters
This is a transaction-boundary bug that spans two different systems: the SQL database (which db.session.rollback() genuinely does undo) and the filesystem (which it does not, and cannot -- a file write is not part of any SQL transaction). Any code that does "write side effects, then maybe roll back the DB" needs an explicit plan for the side effects that are not inside that transaction. This is a common shape of bug anywhere a request handler mixes a database write with an external effect (a file write, an outbound API call, a queued Celery message) inside the same error-handling path -- exactly the class of bug that only shows up under a specific ordering of failures, long after the happy path has shipped and been trusted.
Suggested approach
Look at the loop in create_submission that iterates file_field_names and calls validate_and_store_file for each one. Consider: what does this function body need to know, or track, in order to undo a file write the way db.session.rollback() undoes a database write? Look at os.remove in Python's standard library, and think about where the cleanup needs to happen relative to the existing if error: db.session.rollback(); return ... line -- both for the field that just failed, and for any field(s) that succeeded earlier in the same loop.
Acceptance criteria
Verification
cd flask/feedback_service && FLASK_ENV=testing .venv/bin/python -m pytest ticket_tests/test_ticket_07_orphaned_upload_files_on_rollback.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 fix/orphaned-upload-files-on-rollbackFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin fix/orphaned-upload-files-on-rollbackSubmit 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 inweb and worker share the same image and only differ in their command: -- web runs flask db upgrade && flask run, worker runs celery -A celery_worker.celery worker. For a prod-style stack (built image, gunicorn, no bind mount) use docker compose -f docker-compose.prod.yml up --build instead; it refuses to start without SECRET_KEY/JWT_SECRET_KEY actually set in the environment.
cd flask/feedback_service
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # defaults are fine for local dev as-is
export FLASK_APP=wsgi.py FLASK_ENV=development
flask db upgrade # applies migrations to a fresh SQLite DB under instance/
# terminal 1
flask run --port 5000
# terminal 2 (needs a local redis-server running — broker for both Celery and the rate limiter)
celery -A celery_worker.celery worker --loglevel=info
Each ticket in ticket_tests/tickets/0N_*.md names one dedicated test file under ticket_tests/:
cd flask/feedback_service
export FLASK_ENV=testing
.venv/bin/python -m pytest ticket_tests/test_ticket_0N_*.py -v # a single ticket
./ticket_tests/run_tickets.sh # all 7, PASS/FAIL scoreboard
TestingConfig runs Celery tasks eagerly (CELERY_TASK_ALWAYS_EAGER=True, no broker needed) and uses a real, per-test-flushed Redis DB for the rate limiter when Redis is reachable, falling back to Flask-Limiter's in-memory backend otherwise -- either way the real Flask-Limiter code path runs, nothing about rate limiting is mocked. Note ticket_tests/ is not collected by a plain pytest -q from the project root (pytest.ini pins testpaths = tests), so the project's own suite and this practice suite stay independent and must be run explicitly.
Level 1
Fix a bug
Read existing behaviour, correct it.