fix(poll): reconcile concurrent respondent submissions
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user