Skip to content

Lock the round when activating it, so a double click can't create every task twice - #626

Merged
lgelauff merged 2 commits into
masterfrom
fix/activate-round-race
Oct 4, 2026
Merged

lgelauff merged 2 commits into
masterfrom
fix/activate-round-race

Conversation

@lgelauff

@lgelauff lgelauff commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #334 ("Photos appear in the voting sequence twice"). The main cause turns out to be a double click on
Activate, which gives every juror a second copy of every task.

What goes wrong

Activating a round creates all juror tasks inside the request, which can take seconds for a large round. If
the coordinator clicks Activate again while the first request is still running, the second request also reads
the round as paused, and creates a second full set of tasks. Nothing stops this: activate_round checks the
status without a lock, and the button stays clickable.

On production this has happened in at least 22 rounds since April 2025: every task doubled (one round tripled,
after three clicks within 18 seconds). The audit log shows two or three open_round entries a few seconds apart
for each of them. Jurors voted on both copies of almost every duplicated task, often differently, and the
round average used to decide which entries advance counts both votes.

The fix

  • activate_round re-reads the round with SELECT ... FOR UPDATE before checking its status. A second
    activation waits for the first to commit, then sees the round active and gets the normal "can only activate
    round in a paused state" error.
  • The Activate button is disabled while its request runs.

Tests

  • New test test_concurrent_activation_creates_no_duplicate_tasks: another connection commits the round as
    active after the request has loaded it. It fails on master (the activation creates a second set of tasks)
    and passes with the fix. SQLite ignores FOR UPDATE, so the test covers the re-read; the lock itself was
    tested on MariaDB, below.
  • Backend suite: 15 passed, 1 xfailed. Frontend: prettier, eslint, build clean.
  • On montage-dev (MariaDB), as part of an integration branch with the pending import PRs: a double click in the UI sent one request and
    created exactly one task per entry; two activation requests sent at the same moment gave 200 and 400 (the
    second waited for the first's lock), again one task per entry.

Not in this PR

  • The duplicate tasks that already exist, and finalized rounds whose results counted duplicated votes.
  • Rarer ways to get a duplicate task, such as changing a round's jurors (22 duplicated tasks on production).
  • If the first activation takes longer than MariaDB's lock wait timeout (50 s by default), the second click
    gets a 500 instead of the clean error. Still no duplicates. The upcoming import-worker PR turns this into a 400.

When the import-worker PR is rebased on this, activate_round conflicts: keep the import-worker version,
which has the same lock.

Written with Claude (Claude Code) on behalf of @lgelauff.

🤖 Generated with Claude Code

…e twice

Two activation requests a few seconds apart (a double click while the
first is still creating tasks) both read the round as paused and each
created a full set of juror tasks. On production this left 22 rounds
with every task doubled (one with every task tripled), and jurors voted
on both copies, so their vote counted twice in the round's averages
(#334).

activate_round now re-reads the round with SELECT ... FOR UPDATE (and
populate_existing, so no stale copy from the session) before checking
its status. A second activation waits for the first to commit, then
sees the round active and is refused. The Activate button is disabled
while its request runs.

The new test commits the round as active from another connection
after the request has loaded it; it fails without the lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolves the conflict with #632 (#619) in
montage/tests/test_web_basic.py: both PRs added a test at the same
spot; both are kept unchanged (the concurrent-activation test for
#334, then the batched task insert test).
rdb.py merged cleanly: activate_round takes the round lock before
create_initial_tasks, which now inserts in batches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@lgelauff
lgelauff merged commit cb50599 into master Oct 4, 2026
4 checks passed
@lgelauff
lgelauff deleted the fix/activate-round-race branch October 4, 2026 09:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant