feat(poll): make exact transitions idempotent
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
@@ -24,6 +25,9 @@ from govoplan_poll.backend.schemas import (
|
||||
from govoplan_poll.backend.transitions import DEFAULT_POLL_TRANSITION_ENGINE, PollTransitionEngine
|
||||
|
||||
|
||||
logger = logging.getLogger("govoplan.poll.lifecycle")
|
||||
|
||||
|
||||
class PollError(ValueError):
|
||||
pass
|
||||
|
||||
@@ -67,7 +71,7 @@ YES_NO_MAYBE_OPTIONS = (
|
||||
@dataclass(frozen=True)
|
||||
class PollTransitionResult:
|
||||
poll: Poll
|
||||
transition: PollLifecycleTransition
|
||||
transition: PollLifecycleTransition | None
|
||||
replayed: bool = False
|
||||
|
||||
|
||||
@@ -721,6 +725,77 @@ def _replayed_transition(
|
||||
return PollTransitionResult(poll=poll, transition=existing, replayed=True)
|
||||
|
||||
|
||||
def _latest_lifecycle_transition(
|
||||
session: Session,
|
||||
*,
|
||||
poll_id: str,
|
||||
) -> PollLifecycleTransition | None:
|
||||
return (
|
||||
session.query(PollLifecycleTransition)
|
||||
.filter(PollLifecycleTransition.poll_id == poll_id)
|
||||
.order_by(
|
||||
PollLifecycleTransition.created_at.desc(),
|
||||
PollLifecycleTransition.id.desc(),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _is_exact_transition_noop(
|
||||
session: Session,
|
||||
*,
|
||||
poll: Poll,
|
||||
action: str,
|
||||
decision_option: PollOption | None,
|
||||
transition_engine: PollTransitionEngine,
|
||||
) -> bool:
|
||||
"""Recognize commands whose requested lifecycle state already holds."""
|
||||
|
||||
rule = transition_engine.policy.rules.get(action)
|
||||
if rule is None:
|
||||
return False
|
||||
if action == "decide":
|
||||
return (
|
||||
rule.target_status == poll.status
|
||||
and decision_option is not None
|
||||
and decision_option.id == poll.decided_option_id
|
||||
)
|
||||
if rule.restore_archived_status:
|
||||
latest = _latest_lifecycle_transition(session, poll_id=poll.id)
|
||||
return (
|
||||
latest is not None
|
||||
and latest.action == action
|
||||
and latest.to_status == poll.status
|
||||
)
|
||||
return rule.target_status == poll.status
|
||||
|
||||
|
||||
def _log_duplicate_transition(
|
||||
*,
|
||||
poll: Poll,
|
||||
action: str,
|
||||
actor_user_id: str | None,
|
||||
actor_api_key_id: str | None,
|
||||
reason: str,
|
||||
has_idempotency_key: bool,
|
||||
) -> None:
|
||||
"""Record command duplication as operational telemetry, not lifecycle audit."""
|
||||
|
||||
logger.info(
|
||||
"Ignored repeated exact Poll lifecycle transition",
|
||||
extra={
|
||||
"event": "poll.lifecycle_transition.duplicate",
|
||||
"tenant_id": poll.tenant_id,
|
||||
"poll_id": poll.id,
|
||||
"transition_action": action,
|
||||
"actor_user_id": actor_user_id,
|
||||
"actor_api_key_id": actor_api_key_id,
|
||||
"duplicate_reason": reason,
|
||||
"has_idempotency_key": has_idempotency_key,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _clear_expired_close_for_open(poll: Poll, *, action: str, now: datetime) -> datetime | None:
|
||||
if action != "open" or poll.closes_at is None:
|
||||
return None
|
||||
@@ -793,7 +868,7 @@ def transition_poll(
|
||||
mutation_owner: PollMutationOwner | None = None,
|
||||
transition_engine: PollTransitionEngine = DEFAULT_POLL_TRANSITION_ENGINE,
|
||||
) -> PollTransitionResult:
|
||||
"""Apply one policy-controlled transition and append its durable audit record."""
|
||||
"""Apply a policy transition, or return an audit-free exact no-op."""
|
||||
|
||||
poll = _lock_poll_for_transition(session, tenant_id=tenant_id, poll_id=poll_id)
|
||||
_assert_poll_mutation_owner(poll, mutation_owner=mutation_owner)
|
||||
@@ -817,8 +892,33 @@ def transition_poll(
|
||||
decision_option=decision_option,
|
||||
)
|
||||
if replayed is not None:
|
||||
_log_duplicate_transition(
|
||||
poll=poll,
|
||||
action=action,
|
||||
actor_user_id=actor_user_id,
|
||||
actor_api_key_id=actor_api_key_id,
|
||||
reason="idempotency_key_replay",
|
||||
has_idempotency_key=True,
|
||||
)
|
||||
return replayed
|
||||
|
||||
if _is_exact_transition_noop(
|
||||
session,
|
||||
poll=poll,
|
||||
action=action,
|
||||
decision_option=decision_option,
|
||||
transition_engine=transition_engine,
|
||||
):
|
||||
_log_duplicate_transition(
|
||||
poll=poll,
|
||||
action=action,
|
||||
actor_user_id=actor_user_id,
|
||||
actor_api_key_id=actor_api_key_id,
|
||||
reason="requested_state_already_active",
|
||||
has_idempotency_key=normalized_key is not None,
|
||||
)
|
||||
return PollTransitionResult(poll=poll, transition=None, replayed=True)
|
||||
|
||||
try:
|
||||
plan = transition_engine.plan(
|
||||
current_status=poll.status,
|
||||
|
||||
Reference in New Issue
Block a user