refactor(sp41): align submission_dedup with cyclone.store conventions (main Base, db.SessionLocal, now(timezone.utc), exceptions.py)
This commit is contained in:
@@ -982,3 +982,19 @@ class Resubmission(Base):
|
||||
unique=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SubmissionRecord(Base):
|
||||
"""SP41: one row per (claim_id, submitted_at) pre-flight dedup guard.
|
||||
|
||||
The 30-day dedup window is enforced by ``cyclone.store.submission_dedup``.
|
||||
This table records (claim_id, submitted_at) for every push that passed
|
||||
the pre-flight check. Past the window, the prior record is ignored
|
||||
(the guard lets through re-submits older than
|
||||
``cyclone.store.submission_dedup.DEFAULT_WINDOW_DAYS``).
|
||||
"""
|
||||
|
||||
__tablename__ = "submission_dedup"
|
||||
|
||||
claim_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
submitted_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
|
||||
|
||||
@@ -4,6 +4,8 @@ These are part of the public API — callers (API endpoints) catch them
|
||||
and translate to HTTP 409 Conflict responses.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class AlreadyMatchedError(Exception):
|
||||
"""Raised by ``CycloneStore.manual_match`` when the claim is already paired.
|
||||
@@ -39,3 +41,26 @@ class InvalidStateError(Exception):
|
||||
super().__init__(
|
||||
f"invalid state {current_state} for apply (kind={activity_kind})"
|
||||
)
|
||||
|
||||
|
||||
class DuplicateClaimError(Exception):
|
||||
"""Raised by ``CycloneStore.check_submission_duplicate`` when a claim_id
|
||||
is re-submitted within the 30-day dedup window (SP41).
|
||||
|
||||
Mirrors the other 409-class exceptions: callers catch it at the API
|
||||
boundary and surface it as a 409 Conflict. Carries
|
||||
``original_submission_at`` so the UI can render "already submitted on
|
||||
YYYY-MM-DD" without re-querying.
|
||||
|
||||
Note: the "30-day" literal in the message mirrors
|
||||
``cyclone.store.submission_dedup.DEFAULT_WINDOW_DAYS`` — keep them in
|
||||
sync if either ever moves.
|
||||
"""
|
||||
|
||||
def __init__(self, claim_id: str, original_submission_at: datetime):
|
||||
self.claim_id = claim_id
|
||||
self.original_submission_at = original_submission_at
|
||||
super().__init__(
|
||||
f"claim_id {claim_id!r} was already submitted at "
|
||||
f"{original_submission_at.isoformat()}; within 30-day window"
|
||||
)
|
||||
@@ -1,74 +1,65 @@
|
||||
"""Claim-id dedup at the SFTP pre-flight stage.
|
||||
|
||||
Schema: a ``submission_dedup`` table records (claim_id, submitted_at). A
|
||||
re-submission of the same claim_id within ``DEFAULT_WINDOW_DAYS`` (30)
|
||||
is rejected with ``DuplicateClaimError``. Past the window, the guard
|
||||
lets it through — the operator may be retrying a 999-rejected file
|
||||
from >30 days ago, which is a legitimate rebuild path.
|
||||
Schema: the ``submission_dedup`` table (defined on the main ``Base`` in
|
||||
``cyclone.db``) records (claim_id, submitted_at). A re-submission of
|
||||
the same claim_id within ``DEFAULT_WINDOW_DAYS`` (30) is rejected with
|
||||
``DuplicateClaimError``. Past the window, the guard lets it through —
|
||||
the operator may be retrying a 999-rejected file from >30 days ago,
|
||||
which is a legitimate rebuild path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import Column, DateTime, String, create_engine, insert, select
|
||||
from sqlalchemy.orm import declarative_base
|
||||
from sqlalchemy import select
|
||||
|
||||
from cyclone import db as cycl_db
|
||||
from cyclone.db import SubmissionRecord
|
||||
from cyclone.store.exceptions import DuplicateClaimError
|
||||
|
||||
DEFAULT_WINDOW_DAYS = 30
|
||||
|
||||
_Base = declarative_base()
|
||||
|
||||
|
||||
class _SubmissionRecord(_Base):
|
||||
__tablename__ = "submission_dedup"
|
||||
claim_id = Column(String(64), primary_key=True)
|
||||
submitted_at = Column(DateTime, nullable=False, index=True)
|
||||
|
||||
|
||||
class DuplicateClaimError(Exception):
|
||||
def __init__(self, claim_id: str, original_submission_at: datetime):
|
||||
super().__init__(
|
||||
f"claim_id {claim_id!r} was already submitted at "
|
||||
f"{original_submission_at.isoformat()}; within {DEFAULT_WINDOW_DAYS}-day window"
|
||||
)
|
||||
self.claim_id = claim_id
|
||||
self.original_submission_at = original_submission_at
|
||||
|
||||
|
||||
def _engine(db_url: str):
|
||||
return create_engine(db_url, future=True)
|
||||
|
||||
|
||||
def init_submission_dedup(db_url: str) -> None:
|
||||
"""Create the table if it doesn't exist. Idempotent."""
|
||||
_Base.metadata.create_all(_engine(db_url))
|
||||
|
||||
|
||||
def record_submission(claim_id: str, submitted_at: datetime, db_url: str) -> None:
|
||||
init_submission_dedup(db_url)
|
||||
eng = _engine(db_url)
|
||||
with eng.begin() as conn:
|
||||
conn.execute(insert(_SubmissionRecord).values(
|
||||
claim_id=claim_id, submitted_at=submitted_at,
|
||||
).prefix_with("OR REPLACE"))
|
||||
"""Upsert (claim_id, submitted_at). Idempotent on claim_id.
|
||||
|
||||
``db_url`` is accepted for back-compat with the original Task 8
|
||||
signature but ignored — sessions now flow through the process-wide
|
||||
``cycl_db.SessionLocal()`` factory that ``db.init_db()`` set up.
|
||||
"""
|
||||
del db_url # signature-preserving; conftest wires the per-test DB
|
||||
with cycl_db.SessionLocal()() as session:
|
||||
session.merge(SubmissionRecord(
|
||||
claim_id=claim_id,
|
||||
submitted_at=submitted_at,
|
||||
))
|
||||
session.commit()
|
||||
|
||||
|
||||
def check_duplicate(
|
||||
claim_id: str,
|
||||
db_url: str,
|
||||
db_url: str | None = None,
|
||||
now: datetime | None = None,
|
||||
window_days: int = DEFAULT_WINDOW_DAYS,
|
||||
) -> None:
|
||||
"""Raise DuplicateClaimError if claim_id was submitted within the window."""
|
||||
init_submission_dedup(db_url)
|
||||
"""Raise ``DuplicateClaimError`` if ``claim_id`` was submitted within the window.
|
||||
|
||||
``db_url`` is accepted for back-compat with the original Task 8
|
||||
signature but ignored — sessions flow through ``cycl_db.SessionLocal()``
|
||||
just like the rest of ``cyclone.store``. Tests that pass an explicit
|
||||
``db_url`` keep working because ``cycl_db.SessionLocal()()`` points at
|
||||
the URL the conftest resolved under ``CYCLONE_DB_URL``.
|
||||
"""
|
||||
del db_url # signature-preserving
|
||||
if now is None:
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(timezone.utc)
|
||||
cutoff = now - timedelta(days=window_days)
|
||||
eng = _engine(db_url)
|
||||
with eng.connect() as conn:
|
||||
stmt = select(_SubmissionRecord).where(
|
||||
_SubmissionRecord.claim_id == claim_id,
|
||||
_SubmissionRecord.submitted_at >= cutoff,
|
||||
with cycl_db.SessionLocal()() as session:
|
||||
row = session.scalars(
|
||||
select(SubmissionRecord).where(
|
||||
SubmissionRecord.claim_id == claim_id,
|
||||
SubmissionRecord.submitted_at >= cutoff,
|
||||
)
|
||||
row = conn.execute(stmt).first()
|
||||
).first()
|
||||
if row is not None:
|
||||
raise DuplicateClaimError(claim_id, row.submitted_at)
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Claim-id dedup at the SFTP pre-flight stage."""
|
||||
from datetime import datetime
|
||||
from cyclone.store.exceptions import DuplicateClaimError
|
||||
from cyclone.store.submission_dedup import (
|
||||
DuplicateClaimError,
|
||||
record_submission,
|
||||
check_duplicate,
|
||||
DEFAULT_WINDOW_DAYS,
|
||||
|
||||
Reference in New Issue
Block a user