Skip to content

Serialize per-submission ops via a row lock#61

Open
norbusan wants to merge 1 commit into
developfrom
submission-lock
Open

Serialize per-submission ops via a row lock#61
norbusan wants to merge 1 commit into
developfrom
submission-lock

Conversation

@norbusan

Copy link
Copy Markdown
Contributor

Two browsers on the same submission could race uploads against an in-flight preflight / directives / compile, producing Frankenstein inputs or out-of-order results that clobbered the canonical output.

api.save() already holds SELECT ... FOR UPDATE on the submission row for the duration of event.execute(), so events were covered. The gap was direct file_store mutations in review.py that bypass save(). This change:

  • Adds a SubmissionLocked domain exception (intentionally not a SaveError subclass, so existing except SaveError handlers don't swallow it into a 500).
  • Adds SubmitApi.lock_submission(submission_id) to acquire the same row lock from a separate session; implemented in LegacySubmitImplementation, no-op in NullImplementation, delegating in PubsubEventSubmitImplementation.
  • Translates MySQL ER_LOCK_WAIT_TIMEOUT (1205) to SubmissionLocked in both lock_submission and the existing _load(..., lock_row=True) path.
  • Registers a Flask error handler that returns HTTP 409 Conflict.
  • Wraps the two review.py mutation sites with api.lock_submission(...); explicitly NOT around the subsequent start_preflight(...) (which would deadlock on the same row from a different session via api.save()).
  • Adds SubmissionFileStore.rebuild_source_package(submission_id) and calls it in start_preflight so tex2pdf-api receives a tar reflecting the live src/, not whatever store_source_package last wrote. The rebuild is byte-deterministic (sorted listing, gzip mtime=0, zeroed TarInfo fields).
  • Adds an AST-based guard test that flags any store_*/delete_* call on a file_store in ui/controllers/**.py not inside a with api.lock_submission(...) block.
  • Adds unit tests for errno-1205 translation, commit/rollback paths, Flask handler -> 409, and tar rebuild determinism.

@norbusan
norbusan requested a review from a team as a code owner May 21, 2026 20:30
Two browsers on the same submission could race uploads against an
in-flight preflight / directives / compile, producing Frankenstein
inputs or out-of-order results that clobbered the canonical output.

`api.save()` already holds `SELECT ... FOR UPDATE` on the submission
row for the duration of `event.execute()`, so events were covered.
The gap was direct `file_store` mutations in review.py that bypass
save(). This change:

- Adds a `SubmissionLocked` domain exception (intentionally not a
  `SaveError` subclass, so existing `except SaveError` handlers
  don't swallow it into a 500).
- Adds `SubmitApi.lock_submission(submission_id)` to acquire the
  same row lock from a separate session; implemented in
  LegacySubmitImplementation, no-op in NullImplementation,
  delegating in PubsubEventSubmitImplementation.
- Translates MySQL `ER_LOCK_WAIT_TIMEOUT` (1205) to
  `SubmissionLocked` in both `lock_submission` and the existing
  `_load(..., lock_row=True)` path.
- Registers a Flask error handler that returns HTTP 409 Conflict.
- Wraps the two review.py mutation sites with
  `api.lock_submission(...)`; explicitly NOT around the subsequent
  `start_preflight(...)` (which would deadlock on the same row
  from a different session via api.save()).
- Adds `SubmissionFileStore.rebuild_source_package(submission_id)`
  and calls it in `start_preflight` so tex2pdf-api receives a tar
  reflecting the live `src/`, not whatever `store_source_package`
  last wrote. The rebuild is byte-deterministic (sorted listing,
  gzip mtime=0, zeroed TarInfo fields).
- Adds an AST-based guard test that flags any
  `store_*`/`delete_*` call on a file_store in
  `ui/controllers/**.py` not inside a `with
  api.lock_submission(...)` block.
- Adds unit tests for errno-1205 translation, commit/rollback
  paths, Flask handler -> 409, and tar rebuild determinism.
file_store.store_user_decisions(submission_id, new_decisions)
for path in files_to_delete:
file_store.delete_source_file(submission_id, path)
api = current_app.api

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like we can make this an EventWithSideEffects and use the patter that already exists in that class.

# subsequent start_preflight() goes through api.save(), which
# takes its own row lock from a separate session — re-entering
# the lock from here would deadlock on the same row.
with api.lock_submission(submission_id):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like it could be a EventWithSideEffects too.

@bdc34 bdc34 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to discuss if the changes to the submission and it's files in review_files should just be done in EventWithSideEffect.

I'd like to avoid adding a new locking paradigm to the app if it can be avoided.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants