From 0a8029636bbdd2b1d1f3546d2c36d8c3a01c228e Mon Sep 17 00:00:00 2001 From: mikemolinet Date: Mon, 11 May 2026 08:45:14 -0700 Subject: [PATCH 1/2] fix(models): declare DispatchOutbox.execution_id + cue_id FKs (parity port of cueapi/cueapi#594) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The columns `execution_id` and `cue_id` on `dispatch_outbox` had no SQLAlchemy `ForeignKey()` wrapper, even though migration 002 declared the DB-level FKs to `executions.id` and `cues.id` (both with `ondelete=CASCADE`). The model omission was benign drift — the DB constraint was still enforced — but broke any future SQLAlchemy ORM `relationship()` that wanted to traverse these. Matches private cueapi's `app/models/dispatch_outbox.py` shape. ## Changes ```diff from sqlalchemy import ( - Boolean, CheckConstraint, Column, DateTime, Integer, String, Text, func, + Boolean, CheckConstraint, Column, DateTime, ForeignKey, Integer, String, Text, func, ) ... - execution_id = Column(UUID(as_uuid=True), nullable=True) - cue_id = Column(String(20), nullable=True) + execution_id = Column( + UUID(as_uuid=True), + ForeignKey("executions.id", ondelete="CASCADE"), + nullable=True, + ) + cue_id = Column( + String(20), + ForeignKey("cues.id", ondelete="CASCADE"), + nullable=True, + ) ``` ## Re-port note Re-port of closed [PR #44](https://github.com/cueapi/cueapi-core/pull/44) which was on a stale base ~8880 deletions behind current main. Same 2-3 line fix; fresh against `f9ec4ea`. ## Test plan - [x] Full local suite passes (zero regressions) - [x] DB-level FK already exists; this is a model-side declaration only - [x] Matches private cueapi shape verbatim --- app/models/dispatch_outbox.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/app/models/dispatch_outbox.py b/app/models/dispatch_outbox.py index 3716137..d143d15 100644 --- a/app/models/dispatch_outbox.py +++ b/app/models/dispatch_outbox.py @@ -1,6 +1,6 @@ from __future__ import annotations -from sqlalchemy import Boolean, CheckConstraint, Column, DateTime, Integer, String, Text, func +from sqlalchemy import Boolean, CheckConstraint, Column, DateTime, ForeignKey, Integer, String, Text, func from sqlalchemy.dialects.postgresql import JSONB, UUID from app.database import Base @@ -13,8 +13,21 @@ class DispatchOutbox(Base): # Nullable since migration 021: cue-task rows still set execution_id + # cue_id; message-task rows (deliver_message / retry_message) leave # them NULL and reference message_id in the payload instead. - execution_id = Column(UUID(as_uuid=True), nullable=True) - cue_id = Column(String(20), nullable=True) + # FK declarations match private cueapi: migration 002 declared the + # DB-level FKs to executions.id and cues.id with ondelete=CASCADE; the + # model previously omitted the FK declaration which was benign drift + # (DB constraint still enforced) but broke any future SQLAlchemy ORM + # relationship() traversal. Parity port of cueapi/cueapi#594. + execution_id = Column( + UUID(as_uuid=True), + ForeignKey("executions.id", ondelete="CASCADE"), + nullable=True, + ) + cue_id = Column( + String(20), + ForeignKey("cues.id", ondelete="CASCADE"), + nullable=True, + ) task_type = Column(String(20), nullable=False, default="deliver") payload = Column(JSONB, nullable=False, default={}) dispatched = Column(Boolean, nullable=False, default=False) From f256755470f0187b733b207c727d7712c7223d30 Mon Sep 17 00:00:00 2001 From: mikemolinet Date: Mon, 11 May 2026 08:49:49 -0700 Subject: [PATCH 2/2] test(qa_observability): use anchor execution for outbox cleanup tests The 3 outbox-cleanup test fixtures inserted DispatchOutbox rows with synthetic UUID execution_ids. Once execution_id grew a ForeignKey declaration on the model (this PR's first commit), those synthetic inserts hit FK violations. Fix: introduce `_create_anchor_execution(db_session)` helper that creates a real User + Cue + Execution and returns the execution.id. Tests use that to anchor the FK; cue_id is set to NULL (these tests care about outbox lifecycle, not cue association). Same shape as private cueapi's tests/test_qa_observability.py post- FK-declaration. 6/6 outbox tests pass. --- tests/test_qa_observability.py | 41 +++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/tests/test_qa_observability.py b/tests/test_qa_observability.py index 6024fba..d63ad6d 100644 --- a/tests/test_qa_observability.py +++ b/tests/test_qa_observability.py @@ -9,9 +9,35 @@ from app.models.device_code import DeviceCode from app.models.dispatch_outbox import DispatchOutbox +from app.models.execution import Execution +from tests.test_poller import _create_due_cue, _create_test_user from worker.poller import cleanup_device_codes, cleanup_outbox +async def _create_anchor_execution(db_session) -> uuid.UUID: + """Create a real User + Cue + Execution and return the execution id. + + Outbox rows have a FK on ``execution_id → executions.id`` + (ON DELETE CASCADE) per migration 002. Tests that previously + inserted outbox rows with synthetic UUIDs relied on the model + omitting the FK; now that the model agrees with the migration + they must point at a real execution. + """ + user_id = await _create_test_user(db_session) + cue = await _create_due_cue(db_session, user_id) + exec_id = uuid.uuid4() + db_session.add( + Execution( + id=exec_id, + cue_id=cue.id, + scheduled_for=datetime.now(timezone.utc), + status="pending", + ) + ) + await db_session.commit() + return exec_id + + # ── Health endpoint ────────────────────────────────────────────────── @@ -39,12 +65,15 @@ async def test_health_includes_metrics(client): async def test_outbox_cleanup_removes_old_rows(db_session, db_engine): """Dispatched outbox rows older than 7 days are deleted.""" old_time = datetime.now(timezone.utc) - timedelta(days=10) - exec_id = uuid.uuid4() + exec_id = await _create_anchor_execution(db_session) await db_session.execute( DispatchOutbox.__table__.insert().values( execution_id=exec_id, - cue_id="cue_old000001", + # cue_id NULL — this fixture tests outbox cleanup, not cue + # association. Synthetic IDs broke the cues(id) FK once it + # was declared on the model (PR #594 second-half drift fix). + cue_id=None, task_type="deliver", payload={}, dispatched=True, @@ -67,12 +96,12 @@ async def test_outbox_cleanup_removes_old_rows(db_session, db_engine): async def test_outbox_cleanup_keeps_recent(db_session, db_engine): """Dispatched outbox rows newer than 7 days are kept.""" recent_time = datetime.now(timezone.utc) - timedelta(days=1) - exec_id = uuid.uuid4() + exec_id = await _create_anchor_execution(db_session) await db_session.execute( DispatchOutbox.__table__.insert().values( execution_id=exec_id, - cue_id="cue_recent0001", + cue_id=None, # see test_outbox_cleanup_removes_old_rows task_type="deliver", payload={}, dispatched=True, @@ -94,12 +123,12 @@ async def test_outbox_cleanup_keeps_recent(db_session, db_engine): async def test_outbox_cleanup_keeps_undispatched(db_session, db_engine): """Undispatched outbox rows are never cleaned up regardless of age.""" old_time = datetime.now(timezone.utc) - timedelta(days=30) - exec_id = uuid.uuid4() + exec_id = await _create_anchor_execution(db_session) await db_session.execute( DispatchOutbox.__table__.insert().values( execution_id=exec_id, - cue_id="cue_undisp001", + cue_id=None, # see test_outbox_cleanup_removes_old_rows task_type="deliver", payload={}, dispatched=False,