feat(campaign): persist delivery execution mode
This commit is contained in:
@@ -212,6 +212,8 @@ class CampaignVersion(Base, TimestampMixin):
|
||||
execution_snapshot: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
execution_snapshot_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
execution_snapshot_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
delivery_mode: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True)
|
||||
delivery_mode_selected_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
campaign: Mapped[Campaign] = relationship(back_populates="versions")
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""persist the selected Campaign delivery mode
|
||||
|
||||
Revision ID: c7a2f91e4b60
|
||||
Revises: 4d5e6f7a9203
|
||||
Create Date: 2026-07-22 09:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c7a2f91e4b60"
|
||||
down_revision = "4d5e6f7a9203"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("campaign_versions") as batch:
|
||||
batch.add_column(sa.Column("delivery_mode", sa.String(length=30), nullable=True))
|
||||
batch.add_column(sa.Column("delivery_mode_selected_at", sa.DateTime(timezone=True), nullable=True))
|
||||
batch.create_index("ix_campaign_versions_delivery_mode", ["delivery_mode"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("campaign_versions") as batch:
|
||||
batch.drop_index("ix_campaign_versions_delivery_mode")
|
||||
batch.drop_column("delivery_mode_selected_at")
|
||||
batch.drop_column("delivery_mode")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""persist the selected Campaign delivery mode
|
||||
|
||||
Revision ID: c7a2f91e4b60
|
||||
Revises: 4d5e6f7a9203
|
||||
Create Date: 2026-07-22 09:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c7a2f91e4b60"
|
||||
down_revision = "4d5e6f7a9203"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("campaign_versions") as batch:
|
||||
batch.add_column(sa.Column("delivery_mode", sa.String(length=30), nullable=True))
|
||||
batch.add_column(sa.Column("delivery_mode_selected_at", sa.DateTime(timezone=True), nullable=True))
|
||||
batch.create_index("ix_campaign_versions_delivery_mode", ["delivery_mode"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("campaign_versions") as batch:
|
||||
batch.drop_index("ix_campaign_versions_delivery_mode")
|
||||
batch.drop_column("delivery_mode_selected_at")
|
||||
batch.drop_column("delivery_mode")
|
||||
@@ -87,6 +87,10 @@ def _version_info(
|
||||
),
|
||||
"execution_snapshot_hash": version.execution_snapshot_hash,
|
||||
"execution_snapshot_at": version.execution_snapshot_at.isoformat() if version.execution_snapshot_at else None,
|
||||
"delivery_mode": version.delivery_mode,
|
||||
"delivery_mode_selected_at": (
|
||||
version.delivery_mode_selected_at.isoformat() if version.delivery_mode_selected_at else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +114,12 @@ def _load_delivery_info(
|
||||
"queueable_job_count": 0,
|
||||
"estimated_remaining_send_seconds": None,
|
||||
"estimated_remaining_send_human": None,
|
||||
"delivery_mode": version.delivery_mode if version else None,
|
||||
"delivery_mode_selected_at": (
|
||||
version.delivery_mode_selected_at.isoformat()
|
||||
if version and version.delivery_mode_selected_at
|
||||
else None
|
||||
),
|
||||
}
|
||||
if include_diagnostics:
|
||||
default.update(
|
||||
@@ -159,6 +169,10 @@ def _load_delivery_info(
|
||||
"queueable_job_count": snapshot.queueable_job_count,
|
||||
"estimated_remaining_send_seconds": estimated_seconds,
|
||||
"estimated_remaining_send_human": _human_duration(estimated_seconds),
|
||||
"delivery_mode": version.delivery_mode,
|
||||
"delivery_mode_selected_at": (
|
||||
version.delivery_mode_selected_at.isoformat() if version.delivery_mode_selected_at else None
|
||||
),
|
||||
}
|
||||
if include_diagnostics:
|
||||
result.update(
|
||||
@@ -544,6 +558,27 @@ def _campaign_report_cards(version: CampaignVersion | None, jobs: list[CampaignJ
|
||||
send_counts = _counter([job.send_status for job in jobs])
|
||||
imap_counts = _counter([job.imap_status for job in jobs])
|
||||
queueable = sum(1 for job in jobs if job.validation_status in {"ready", "warning"} and job.build_status == "built")
|
||||
queueable_unattempted = sum(
|
||||
1
|
||||
for job in jobs
|
||||
if job.attempt_count == 0
|
||||
and job.send_status in {"not_queued", "cancelled"}
|
||||
and job.validation_status in {"ready", "warning"}
|
||||
and job.build_status == "built"
|
||||
)
|
||||
retry_max_attempts = _retry_max_attempts(version)
|
||||
retryable = sum(
|
||||
1
|
||||
for job in jobs
|
||||
if job.send_status == "failed_temporary"
|
||||
and (retry_max_attempts is None or job.attempt_count < retry_max_attempts)
|
||||
)
|
||||
cancellable = sum(
|
||||
1
|
||||
for job in jobs
|
||||
if job.send_status
|
||||
not in {"smtp_accepted", "sent", "outcome_unknown", "claimed", "sending", "cancelled"}
|
||||
)
|
||||
needs_attention = sum(
|
||||
1
|
||||
for job in jobs
|
||||
@@ -562,6 +597,9 @@ def _campaign_report_cards(version: CampaignVersion | None, jobs: list[CampaignJ
|
||||
"jobs_total": len(jobs),
|
||||
"inactive": inactive_entries,
|
||||
"queueable": queueable,
|
||||
"queueable_unattempted": queueable_unattempted,
|
||||
"retryable": retryable,
|
||||
"cancellable": cancellable,
|
||||
"needs_attention": needs_attention,
|
||||
"sent": sent,
|
||||
"smtp_accepted": sent,
|
||||
@@ -576,6 +614,15 @@ def _campaign_report_cards(version: CampaignVersion | None, jobs: list[CampaignJ
|
||||
}
|
||||
|
||||
|
||||
def _retry_max_attempts(version: CampaignVersion | None) -> int | None:
|
||||
if version is None or not isinstance(version.execution_snapshot, dict):
|
||||
return None
|
||||
try:
|
||||
return ExecutionSnapshot.model_validate(version.execution_snapshot).delivery.retry.max_attempts
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _inactive_entry_count(version: CampaignVersion | None) -> int:
|
||||
build_summary = version.build_summary if version and isinstance(version.build_summary, dict) else {}
|
||||
return int(build_summary.get("inactive_count") or build_summary.get("inactive_entries_count") or 0)
|
||||
|
||||
@@ -112,6 +112,8 @@ class CampaignVersionResponse(BaseModel):
|
||||
build_summary: dict[str, Any] | None = None
|
||||
execution_snapshot_hash: str | None = None
|
||||
execution_snapshot_at: datetime | None = None
|
||||
delivery_mode: Literal["synchronous", "worker_queue", "database_queue"] | None = None
|
||||
delivery_mode_selected_at: datetime | None = None
|
||||
|
||||
@field_validator("editor_state", mode="before")
|
||||
@classmethod
|
||||
|
||||
@@ -201,6 +201,14 @@ QUEUEABLE_VALIDATION_STATUSES = {
|
||||
JobValidationStatus.WARNING.value,
|
||||
}
|
||||
SMTP_ACCEPTED_STATUSES = {JobSendStatus.SMTP_ACCEPTED.value, JobSendStatus.SENT.value}
|
||||
DELIVERY_MODE_SYNCHRONOUS = "synchronous"
|
||||
DELIVERY_MODE_WORKER_QUEUE = "worker_queue"
|
||||
DELIVERY_MODE_DATABASE_QUEUE = "database_queue"
|
||||
DELIVERY_MODES = {
|
||||
DELIVERY_MODE_SYNCHRONOUS,
|
||||
DELIVERY_MODE_WORKER_QUEUE,
|
||||
DELIVERY_MODE_DATABASE_QUEUE,
|
||||
}
|
||||
AUTOMATICALLY_SENDABLE_STATUSES = {JobSendStatus.QUEUED.value}
|
||||
EXPLICIT_RETRY_STATUSES = {JobSendStatus.FAILED_TEMPORARY.value, JobSendStatus.FAILED_PERMANENT.value}
|
||||
INITIAL_QUEUE_SKIPPED_SEND_STATUSES = SMTP_ACCEPTED_STATUSES | {
|
||||
@@ -273,6 +281,13 @@ def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _set_version_delivery_mode(version: CampaignVersion, mode: str) -> None:
|
||||
if mode not in DELIVERY_MODES:
|
||||
raise QueueingError(f"Unsupported Campaign delivery mode: {mode}")
|
||||
version.delivery_mode = mode
|
||||
version.delivery_mode_selected_at = _utcnow()
|
||||
|
||||
|
||||
def _get_campaign_for_tenant(session: Session, *, campaign_id: str, tenant_id: str) -> Campaign:
|
||||
campaign = session.query(Campaign).filter(Campaign.id == campaign_id, Campaign.tenant_id == tenant_id).one_or_none()
|
||||
if not campaign:
|
||||
@@ -311,6 +326,10 @@ def _should_enqueue_celery(enqueue_celery: bool) -> bool:
|
||||
return bool(enqueue_celery and _celery_enabled())
|
||||
|
||||
|
||||
def _asynchronous_delivery_mode(enqueue_celery: bool) -> str:
|
||||
return DELIVERY_MODE_WORKER_QUEUE if _should_enqueue_celery(enqueue_celery) else DELIVERY_MODE_DATABASE_QUEUE
|
||||
|
||||
|
||||
def _campaign_notification_body(campaign: Campaign, status: str) -> str:
|
||||
return {
|
||||
CampaignStatus.QUEUED.value: f"{campaign.name} has been queued for delivery.",
|
||||
@@ -601,11 +620,13 @@ def _persist_campaign_queue(
|
||||
campaign: Campaign,
|
||||
version: CampaignVersion,
|
||||
queued: list[CampaignJob],
|
||||
delivery_mode: str,
|
||||
) -> None:
|
||||
if queued:
|
||||
previous_status = campaign.status
|
||||
campaign.status = CampaignStatus.QUEUED.value
|
||||
version.workflow_state = CampaignVersionWorkflowState.QUEUED.value
|
||||
_set_version_delivery_mode(version, delivery_mode)
|
||||
if version.locked_at is None:
|
||||
version.locked_at = _utcnow()
|
||||
session.add(version)
|
||||
@@ -638,9 +659,13 @@ def queue_campaign_jobs(
|
||||
enqueue_celery: bool = True,
|
||||
include_warnings: bool = True,
|
||||
dry_run: bool = False,
|
||||
delivery_mode: str | None = None,
|
||||
) -> QueueCampaignResult:
|
||||
"""Move queueable DB jobs to QUEUED and optionally enqueue Celery tasks."""
|
||||
|
||||
selected_delivery_mode = delivery_mode or _asynchronous_delivery_mode(enqueue_celery)
|
||||
if selected_delivery_mode not in DELIVERY_MODES:
|
||||
raise QueueingError(f"Unsupported Campaign delivery mode: {selected_delivery_mode}")
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
||||
version = _get_current_version(session, campaign, version_id=version_id)
|
||||
_ensure_version_validated_and_locked(version)
|
||||
@@ -667,6 +692,7 @@ def queue_campaign_jobs(
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
queued=queued,
|
||||
delivery_mode=selected_delivery_mode,
|
||||
)
|
||||
enqueued_count = _enqueue_campaign_jobs(
|
||||
queued,
|
||||
@@ -680,7 +706,7 @@ def queue_campaign_jobs(
|
||||
skipped_count=skipped_count,
|
||||
blocked_count=blocked_count,
|
||||
enqueued_count=enqueued_count,
|
||||
delivery_mode="worker_queue" if enqueue_celery else "database_queue",
|
||||
delivery_mode=selected_delivery_mode,
|
||||
worker_queue_available=_celery_enabled(),
|
||||
dry_run=dry_run,
|
||||
)
|
||||
@@ -738,6 +764,7 @@ def send_campaign_now(
|
||||
include_warnings=include_warnings,
|
||||
enqueue_celery=False,
|
||||
dry_run=dry_run,
|
||||
delivery_mode=DELIVERY_MODE_SYNCHRONOUS,
|
||||
)
|
||||
if dry_run:
|
||||
return SendCampaignNowResult(
|
||||
@@ -939,6 +966,12 @@ def resume_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str,
|
||||
job.send_status = JobSendStatus.QUEUED.value
|
||||
session.add(job)
|
||||
if jobs:
|
||||
delivery_mode = _asynchronous_delivery_mode(enqueue_celery)
|
||||
for version_id in {job.campaign_version_id for job in jobs}:
|
||||
version = session.get(CampaignVersion, version_id)
|
||||
if version is not None and version.campaign_id == campaign.id:
|
||||
_set_version_delivery_mode(version, delivery_mode)
|
||||
session.add(version)
|
||||
previous_status = campaign.status
|
||||
campaign.status = CampaignStatus.QUEUED.value
|
||||
session.add(campaign)
|
||||
@@ -1065,6 +1098,10 @@ def queue_failed_jobs_for_retry(
|
||||
if selected:
|
||||
campaign.status = CampaignStatus.QUEUED.value
|
||||
version.workflow_state = CampaignVersionWorkflowState.QUEUED.value
|
||||
_set_version_delivery_mode(
|
||||
version,
|
||||
_asynchronous_delivery_mode(enqueue_celery),
|
||||
)
|
||||
session.add(campaign)
|
||||
session.add(version)
|
||||
session.commit()
|
||||
@@ -1134,6 +1171,10 @@ def queue_unattempted_jobs(
|
||||
if selected:
|
||||
campaign.status = CampaignStatus.QUEUED.value
|
||||
version.workflow_state = CampaignVersionWorkflowState.QUEUED.value
|
||||
_set_version_delivery_mode(
|
||||
version,
|
||||
_asynchronous_delivery_mode(enqueue_celery),
|
||||
)
|
||||
session.add(campaign)
|
||||
session.add(version)
|
||||
session.commit()
|
||||
@@ -1202,6 +1243,7 @@ def send_single_campaign_job(
|
||||
previous_status = campaign.status
|
||||
campaign.status = CampaignStatus.QUEUED.value
|
||||
version.workflow_state = CampaignVersionWorkflowState.QUEUED.value
|
||||
_set_version_delivery_mode(version, DELIVERY_MODE_SYNCHRONOUS)
|
||||
session.add(campaign)
|
||||
session.add(version)
|
||||
session.commit()
|
||||
|
||||
Reference in New Issue
Block a user