fix(poll): reconcile concurrent respondent submissions

This commit is contained in:
2026-07-22 02:54:14 +02:00
parent cac7733bee
commit eb003208e4
4 changed files with 557 additions and 31 deletions

View File

@@ -5,8 +5,9 @@ import re
import secrets
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from typing import Any, Callable
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from govoplan_core.db.base import utcnow
@@ -50,6 +51,7 @@ POLL_KINDS = {"single_choice", "multiple_choice", "yes_no", "yes_no_maybe", "ran
POLL_INITIAL_STATUSES = {"draft", "open"}
CHOICE_POLL_KINDS = {"single_choice", "multiple_choice", "yes_no", "yes_no_maybe", "ranked_choice"}
AVAILABILITY_VALUES = {"available", "maybe", "unavailable"}
ACTIVE_RESPONSE_UNIQUE_INDEX = "uq_poll_responses_active_respondent"
YES_NO_OPTIONS = (
PollOptionInput(key="yes", label="Yes"),
PollOptionInput(key="no", label="No"),
@@ -1081,13 +1083,37 @@ def _existing_response(session: Session, *, poll: Poll, respondent_id: str | Non
return (
session.query(PollResponse)
.filter(PollResponse.poll_id == poll.id, PollResponse.respondent_id == respondent_id, PollResponse.deleted_at.is_(None))
.order_by(PollResponse.submitted_at.desc())
.order_by(PollResponse.submitted_at.desc(), PollResponse.id.desc())
.first()
)
def _share_lock_poll_for_response(
session: Session,
*,
tenant_id: str,
poll_id: str,
) -> Poll:
"""Coordinate response writes with lifecycle changes without serializing peers."""
poll = (
session.query(Poll)
.filter(
Poll.tenant_id == tenant_id,
Poll.id == poll_id,
Poll.deleted_at.is_(None),
)
.populate_existing()
.with_for_update(read=True)
.first()
)
if poll is None:
raise PollError("Poll not found")
return poll
def _lock_poll_for_response(session: Session, *, tenant_id: str, poll_id: str) -> Poll:
"""Serialize the read-or-create path for identified respondents."""
"""Serialize policy-sensitive response and option mutations."""
poll = (
session.query(Poll)
@@ -1105,6 +1131,106 @@ def _lock_poll_for_response(session: Session, *, tenant_id: str, poll_id: str) -
return poll
def _is_active_response_uniqueness_conflict(
session: Session,
error: IntegrityError,
) -> bool:
"""Recognize only the active identified-response invariant violation."""
original = error.orig
constraint_name = getattr(
getattr(original, "diag", None),
"constraint_name",
None,
)
if constraint_name is not None:
return constraint_name == ACTIVE_RESPONSE_UNIQUE_INDEX
if session.get_bind().dialect.name != "sqlite":
return False
message = " ".join(str(original).casefold().split())
return (
"unique constraint failed:" in message
and "poll_responses.poll_id" in message
and "poll_responses.respondent_id" in message
)
def _update_existing_response(
session: Session,
*,
poll: Poll,
response: PollResponse,
answers: list[dict[str, Any]],
respondent_label: str | None,
submitted_at: datetime,
metadata: dict[str, Any],
) -> PollResponse:
if not poll.allow_response_update:
raise PollError("Response updates are not allowed for this poll")
response.answers = answers
response.respondent_label = respondent_label
response.submitted_at = submitted_at
response.metadata_ = metadata
session.flush()
return response
def _insert_or_reconcile_identified_response(
session: Session,
*,
poll: Poll,
respondent_id: str,
respondent_label: str | None,
answers: list[dict[str, Any]],
submitted_at: datetime,
metadata: dict[str, Any],
conflict_validator: Callable[[PollResponse], None] | None = None,
) -> tuple[PollResponse, bool]:
"""Insert under a savepoint, or update the row that won the same race."""
try:
with session.begin_nested():
response = PollResponse(
tenant_id=poll.tenant_id,
poll_id=poll.id,
respondent_id=respondent_id,
respondent_label=respondent_label,
answers=answers,
submitted_at=submitted_at,
metadata_=metadata,
)
session.add(response)
session.flush()
except IntegrityError as error:
if not _is_active_response_uniqueness_conflict(session, error):
raise
winner = _existing_response(
session,
poll=poll,
respondent_id=respondent_id,
)
if winner is None:
# A correctly identified conflict always has a visible winner after
# the savepoint rollback. Preserve the database failure otherwise.
raise
if conflict_validator is not None:
conflict_validator(winner)
return (
_update_existing_response(
session,
poll=poll,
response=winner,
answers=answers,
respondent_label=respondent_label,
submitted_at=submitted_at,
metadata=metadata,
),
True,
)
return response, False
def poll_requires_governed_participation(*, poll: Poll) -> bool:
"""Whether responses must pass through the owning module's gateway."""
@@ -1411,7 +1537,7 @@ def _synchronize_mutable_choice_bounds(
def submit_poll_response(session: Session, *, tenant_id: str, poll_id: str, payload: PollSubmitResponseRequest) -> PollResponse:
assert_no_sensitive_participation_metadata(payload.metadata)
poll = _lock_poll_for_response(
poll = _share_lock_poll_for_response(
session,
tenant_id=tenant_id,
poll_id=poll_id,
@@ -1422,26 +1548,39 @@ def submit_poll_response(session: Session, *, tenant_id: str, poll_id: str, payl
raise PollError("Anonymous responses are not allowed for this poll")
answers = normalize_response_answers(poll, payload)
existing = _existing_response(session, poll=poll, respondent_id=payload.respondent_id)
submitted_at = _now()
if existing is not None:
if not poll.allow_response_update:
raise PollError("Response updates are not allowed for this poll")
existing.answers = answers
existing.respondent_label = payload.respondent_label
existing.submitted_at = _now()
existing.metadata_ = payload.metadata
return _update_existing_response(
session,
poll=poll,
response=existing,
answers=answers,
respondent_label=payload.respondent_label,
submitted_at=submitted_at,
metadata=payload.metadata,
)
if payload.respondent_id is None:
response = PollResponse(
tenant_id=tenant_id,
poll_id=poll.id,
respondent_id=None,
respondent_label=payload.respondent_label,
answers=answers,
submitted_at=submitted_at,
metadata_=payload.metadata,
)
session.add(response)
session.flush()
return existing
response = PollResponse(
tenant_id=tenant_id,
poll_id=poll.id,
return response
response, _reconciled = _insert_or_reconcile_identified_response(
session,
poll=poll,
respondent_id=payload.respondent_id,
respondent_label=payload.respondent_label,
answers=answers,
submitted_at=_now(),
metadata_=payload.metadata,
submitted_at=submitted_at,
metadata=payload.metadata,
)
session.add(response)
session.flush()
return response