feat(poll): enforce active respondent uniqueness

This commit is contained in:
2026-07-22 02:54:09 +02:00
parent e2e816cb21
commit cac7733bee
3 changed files with 321 additions and 0 deletions

View File

@@ -99,6 +99,18 @@ class PollResponse(Base, TimestampMixin):
__table_args__ = (
Index("ix_poll_responses_poll_submitted", "poll_id", "submitted_at"),
Index("ix_poll_responses_poll_respondent", "poll_id", "respondent_id"),
Index(
"uq_poll_responses_active_respondent",
"poll_id",
"respondent_id",
unique=True,
sqlite_where=text(
"deleted_at IS NULL AND respondent_id IS NOT NULL"
),
postgresql_where=text(
"deleted_at IS NULL AND respondent_id IS NOT NULL"
),
),
Index("ix_poll_responses_tenant_poll", "tenant_id", "poll_id"),
)

View File

@@ -0,0 +1,99 @@
"""v0.1.10 active identified Poll response uniqueness
Revision ID: 6e7f8a9b0c1d
Revises: 5d6e7f8a9b0c
Create Date: 2026-07-22 00:00:00.000000
This migration deduplicates active identified responses before creating the
partial unique index. Run it while Poll response writes are quiesced or while
the platform maintenance lock is held. PostgreSQL additionally takes a table
lock so an unexpected writer cannot enter between cleanup and index creation.
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "6e7f8a9b0c1d"
down_revision = "5d6e7f8a9b0c"
branch_labels = None
depends_on = None
_INDEX_NAME = "uq_poll_responses_active_respondent"
_ACTIVE_IDENTIFIED_PREDICATE = (
"deleted_at IS NULL AND respondent_id IS NOT NULL"
)
def _tombstone_duplicate_active_responses() -> None:
responses = sa.table(
"poll_responses",
sa.column("id", sa.String(length=36)),
sa.column("poll_id", sa.String(length=36)),
sa.column("respondent_id", sa.String(length=255)),
sa.column("submitted_at", sa.DateTime(timezone=True)),
sa.column("deleted_at", sa.DateTime(timezone=True)),
sa.column("updated_at", sa.DateTime(timezone=True)),
)
ranked = (
sa.select(
responses.c.id.label("id"),
sa.func.row_number()
.over(
partition_by=(
responses.c.poll_id,
responses.c.respondent_id,
),
order_by=(
responses.c.submitted_at.desc(),
responses.c.id.desc(),
),
)
.label("response_rank"),
)
.where(
responses.c.deleted_at.is_(None),
responses.c.respondent_id.is_not(None),
)
.subquery("ranked_active_poll_responses")
)
duplicate_ids = sa.select(ranked.c.id).where(ranked.c.response_rank > 1)
migration_timestamp = sa.func.current_timestamp()
op.get_bind().execute(
responses.update()
.where(
responses.c.id.in_(duplicate_ids),
responses.c.deleted_at.is_(None),
)
.values(
deleted_at=migration_timestamp,
updated_at=migration_timestamp,
)
)
def upgrade() -> None:
connection = op.get_bind()
if connection.dialect.name == "postgresql":
connection.execute(
sa.text(
"LOCK TABLE poll_responses IN SHARE ROW EXCLUSIVE MODE"
)
)
_tombstone_duplicate_active_responses()
op.create_index(
_INDEX_NAME,
"poll_responses",
["poll_id", "respondent_id"],
unique=True,
sqlite_where=sa.text(_ACTIVE_IDENTIFIED_PREDICATE),
postgresql_where=sa.text(_ACTIVE_IDENTIFIED_PREDICATE),
)
def downgrade() -> None:
# Deduplicated rows remain tombstoned: a downgrade must not silently revive
# responses that were no longer part of the active result set.
op.drop_index(_INDEX_NAME, table_name="poll_responses")