feat(campaign): bound synchronous delivery
This commit is contained in:
107
src/govoplan_campaign/backend/delivery_policy.py
Normal file
107
src/govoplan_campaign/backend/delivery_policy.py
Normal file
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
|
||||
|
||||
DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS = 25
|
||||
ABSOLUTE_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS = 500
|
||||
SYNCHRONOUS_SEND_MAX_ENV = "GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS"
|
||||
CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY = "campaign_delivery_policy"
|
||||
SYNCHRONOUS_SEND_MAX_SETTINGS_KEY = "synchronous_send_max_recipients"
|
||||
|
||||
|
||||
class CampaignDeliveryPolicyError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SynchronousSendPolicy:
|
||||
max_recipient_jobs: int
|
||||
source: str
|
||||
deployment_max_recipient_jobs: int
|
||||
tenant_max_recipient_jobs: int | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"max_recipient_jobs": self.max_recipient_jobs,
|
||||
"source": self.source,
|
||||
"deployment_max_recipient_jobs": self.deployment_max_recipient_jobs,
|
||||
"tenant_max_recipient_jobs": self.tenant_max_recipient_jobs,
|
||||
"deployment_setting": SYNCHRONOUS_SEND_MAX_ENV,
|
||||
"tenant_setting": (
|
||||
f"tenant.settings.{CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY}."
|
||||
f"{SYNCHRONOUS_SEND_MAX_SETTINGS_KEY}"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def effective_synchronous_send_policy(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
environ: Mapping[str, str] | None = None,
|
||||
) -> SynchronousSendPolicy:
|
||||
env = os.environ if environ is None else environ
|
||||
deployment_value = _configured_limit(
|
||||
env.get(SYNCHRONOUS_SEND_MAX_ENV),
|
||||
source=SYNCHRONOUS_SEND_MAX_ENV,
|
||||
default=DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS,
|
||||
)
|
||||
tenant = session.get(Tenant, tenant_id)
|
||||
tenant_raw = _tenant_limit_value(tenant.settings if tenant is not None else None)
|
||||
if tenant_raw is None:
|
||||
return SynchronousSendPolicy(
|
||||
max_recipient_jobs=deployment_value,
|
||||
source=("deployment" if env.get(SYNCHRONOUS_SEND_MAX_ENV) not in (None, "") else "deployment_default"),
|
||||
deployment_max_recipient_jobs=deployment_value,
|
||||
)
|
||||
|
||||
tenant_value = _configured_limit(
|
||||
tenant_raw,
|
||||
source=(
|
||||
f"tenant.settings.{CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY}."
|
||||
f"{SYNCHRONOUS_SEND_MAX_SETTINGS_KEY}"
|
||||
),
|
||||
)
|
||||
effective_value = min(deployment_value, tenant_value)
|
||||
return SynchronousSendPolicy(
|
||||
max_recipient_jobs=effective_value,
|
||||
source="tenant" if tenant_value <= deployment_value else "deployment_ceiling",
|
||||
deployment_max_recipient_jobs=deployment_value,
|
||||
tenant_max_recipient_jobs=tenant_value,
|
||||
)
|
||||
|
||||
|
||||
def _tenant_limit_value(settings: Mapping[str, Any] | None) -> object | None:
|
||||
if not isinstance(settings, Mapping):
|
||||
return None
|
||||
policy = settings.get(CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY)
|
||||
if not isinstance(policy, Mapping):
|
||||
return None
|
||||
return policy.get(SYNCHRONOUS_SEND_MAX_SETTINGS_KEY)
|
||||
|
||||
|
||||
def _configured_limit(value: object, *, source: str, default: int | None = None) -> int:
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
if default is not None:
|
||||
return default
|
||||
raise CampaignDeliveryPolicyError(f"{source} must be configured as an integer")
|
||||
if isinstance(value, bool):
|
||||
raise CampaignDeliveryPolicyError(f"{source} must be an integer, not a boolean")
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise CampaignDeliveryPolicyError(f"{source} must be an integer") from exc
|
||||
if str(parsed) != str(value).strip() and not isinstance(value, int):
|
||||
raise CampaignDeliveryPolicyError(f"{source} must be an integer")
|
||||
if parsed < 0 or parsed > ABSOLUTE_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS:
|
||||
raise CampaignDeliveryPolicyError(
|
||||
f"{source} must be between 0 and {ABSOLUTE_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS}"
|
||||
)
|
||||
return parsed
|
||||
@@ -2,6 +2,11 @@ from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.modules import DocumentationCondition, DocumentationContext, DocumentationLink, DocumentationTopic
|
||||
|
||||
from govoplan_campaign.backend.delivery_policy import (
|
||||
CampaignDeliveryPolicyError,
|
||||
effective_synchronous_send_policy,
|
||||
)
|
||||
|
||||
|
||||
_CAMPAIGN_USER_SCOPES = (
|
||||
"campaigns:campaign:read",
|
||||
@@ -278,21 +283,20 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
),
|
||||
steps=(
|
||||
"Open Review and send and confirm the selected version, build, recipients, warnings, and review completion.",
|
||||
"Use an authorized supporting client to run the queue dry run and resolve every blocker.",
|
||||
"Invoke the Queue action through that client so background workers can process the build.",
|
||||
"Open the campaign Report and monitor queue, SMTP, IMAP, failure, and uncertain-outcome counts.",
|
||||
"Select Queue for workers and confirm the exact durable execution that will be committed.",
|
||||
"Remain on Review and send or leave and return later; both views read the same durable job states.",
|
||||
"Use the delivery controls to pause, resume, cancel unsent work, or retry eligible failures without requeueing accepted or uncertain outcomes.",
|
||||
),
|
||||
outcome="Eligible jobs queued with an auditable execution snapshot and protected delivery state.",
|
||||
verification="The Report shows the selected version's jobs as queued or progressing, without changing any accepted job back to retryable.",
|
||||
related_topic_ids=("campaigns.workflow.complete-review", "campaigns.workflow.retry-and-reconcile"),
|
||||
related_modules=("mail", "notifications"),
|
||||
limitations=("The current Campaign Web UI does not expose the Queue action; use an authorized supporting client or API.",),
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.send-small-controlled-run",
|
||||
title="Send a campaign immediately",
|
||||
summary="Run the eligible jobs synchronously only after deliberately confirming that the reviewed campaign is small enough for an interactive request.",
|
||||
body="The current synchronous action does not enforce a server-side job-count limit. It must not replace the worker queue for ordinary batches, but it does preserve the same protected SMTP, IMAP, retry, and uncertain-outcome rules.",
|
||||
body="Send now is protected by an effective deployment/tenant recipient-job maximum. The server counts the exact persisted eligible build, rejects an oversized or empty run before SMTP, and preflights every message and the Mail profile revision before the first provider effect.",
|
||||
order=36,
|
||||
audience=("campaign_sender", "campaign_operator"),
|
||||
required_modules=("campaigns", "mail"),
|
||||
@@ -308,12 +312,12 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
help_contexts=("campaign.review-send",),
|
||||
prerequisites=(
|
||||
"The selected version is validated, locked, built, and reviewed.",
|
||||
"An operator has deliberately confirmed that this is a small controlled run; the server does not currently enforce that bound.",
|
||||
"The exact eligible recipient-job count is within the effective limit shown on Review and send.",
|
||||
"Its Mail profile remains available and authorized for the current campaign context.",
|
||||
),
|
||||
steps=(
|
||||
"Open Review and send and inspect the exact build and delivery readiness checks.",
|
||||
"Confirm that this is an intentionally small controlled run; use Queue for an ordinary batch.",
|
||||
"Review the effective synchronous limit and exact eligible count; use Queue for workers when Send now is unavailable or for an ordinary batch.",
|
||||
"Select Send now and confirm the protected delivery action.",
|
||||
"Open Report and inspect each resulting delivery state before attempting any recovery action.",
|
||||
),
|
||||
@@ -321,7 +325,6 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
verification="The Report distinguishes accepted, failed, unattempted, cancelled, and outcome-unknown jobs and retains their attempt history.",
|
||||
related_topic_ids=("campaigns.workflow.queue-delivery", "campaigns.workflow.retry-and-reconcile"),
|
||||
related_modules=("mail",),
|
||||
limitations=("No server-side synchronous job-count limit is currently enforced; use Queue and workers for ordinary batches.",),
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.view-delivery-report",
|
||||
@@ -481,10 +484,13 @@ def documentation_topics(context: DocumentationContext) -> tuple[DocumentationTo
|
||||
current_configuration = list(_actor_capabilities(principal, mail_available=mail_available))
|
||||
integration_configuration, integration_limitations = _integration_summary(context.registry, principal)
|
||||
current_configuration.extend(integration_configuration)
|
||||
delivery_configuration, delivery_limitations = _delivery_policy_summary(context)
|
||||
current_configuration.extend(delivery_configuration)
|
||||
limitations = [
|
||||
"Access to a particular campaign still depends on its owner or sharing rules and on the campaign's current state.",
|
||||
"Profile, file, and address pickers re-evaluate the actual resources visible in the selected campaign; this overview does not claim that an eligible item currently exists.",
|
||||
*integration_limitations,
|
||||
*delivery_limitations,
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -640,6 +646,24 @@ def _integration_summary(registry: object, principal: object) -> tuple[tuple[str
|
||||
return tuple(configured), tuple(limitations)
|
||||
|
||||
|
||||
def _delivery_policy_summary(context: DocumentationContext) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||
session = context.session
|
||||
tenant_id = str(getattr(context.principal, "tenant_id", "") or "").strip()
|
||||
if session is None or not tenant_id:
|
||||
return (), ("The effective synchronous Campaign limit could not be resolved for this documentation request.",)
|
||||
try:
|
||||
policy = effective_synchronous_send_policy(session, tenant_id=tenant_id) # type: ignore[arg-type]
|
||||
except CampaignDeliveryPolicyError as exc:
|
||||
return (), (f"Synchronous Campaign delivery is disabled until its policy configuration is corrected: {exc}",)
|
||||
return (
|
||||
(
|
||||
"Delivery policy: Send now is limited to "
|
||||
f"{policy.max_recipient_jobs} eligible recipient job(s) for this tenant. "
|
||||
"The exact built count and Queue for workers alternative are shown before confirmation."
|
||||
),
|
||||
), ()
|
||||
|
||||
|
||||
def _append_if(
|
||||
target: list[str],
|
||||
principal: object,
|
||||
|
||||
@@ -44,6 +44,7 @@ from govoplan_campaign.backend.schemas import (
|
||||
CampaignSendJobRequest,
|
||||
CampaignSendUnattemptedRequest,
|
||||
CampaignResolveOutcomeRequest,
|
||||
CampaignDeliveryOptionsResponse,
|
||||
CampaignListResponse,
|
||||
CampaignResponse,
|
||||
CampaignVersionDetailResponse,
|
||||
@@ -65,7 +66,7 @@ from govoplan_campaign.backend.schemas import (
|
||||
SendCampaignNowRequest,
|
||||
SendCampaignNowResponse,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope, require_any_scope, require_scope
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.core.access import CAPABILITY_ACCESS_DIRECTORY, AccessDirectory
|
||||
from govoplan_core.core.change_sequence import (
|
||||
@@ -148,6 +149,7 @@ from govoplan_campaign.backend.dev.mock_campaign import MockCampaignSendError, r
|
||||
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, clear_execution_snapshot
|
||||
from govoplan_campaign.backend.sending.jobs import (
|
||||
QueueingError,
|
||||
SynchronousSendRejected,
|
||||
cancel_campaign_jobs,
|
||||
enqueue_pending_imap_appends,
|
||||
pause_campaign_jobs,
|
||||
@@ -158,6 +160,7 @@ from govoplan_campaign.backend.sending.jobs import (
|
||||
resume_campaign_jobs,
|
||||
send_campaign_now,
|
||||
send_single_campaign_job,
|
||||
synchronous_send_options,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||
@@ -3019,6 +3022,30 @@ def revoke_campaign_share(
|
||||
# Queue / delivery control -------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/delivery-options", response_model=CampaignDeliveryOptionsResponse)
|
||||
def campaign_delivery_options(
|
||||
campaign_id: str,
|
||||
version_id: str | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_any_scope("campaigns:campaign:send", "campaigns:campaign:queue")
|
||||
),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
try:
|
||||
return CampaignDeliveryOptionsResponse(
|
||||
**synchronous_send_options(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
)
|
||||
except QueueingError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/queue", response_model=QueueCampaignResponse)
|
||||
def queue_campaign(
|
||||
campaign_id: str,
|
||||
@@ -3264,12 +3291,7 @@ def send_campaign_now_endpoint(
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
"""Validate/build/queue and synchronously send every eligible job.
|
||||
|
||||
No server-side job-count bound is currently enforced. Ordinary batches
|
||||
must use the queue/Celery flow; this endpoint is for deliberately small
|
||||
interactive runs.
|
||||
"""
|
||||
"""Preflight and synchronously send a policy-bounded built execution."""
|
||||
|
||||
payload = payload or SendCampaignNowRequest()
|
||||
try:
|
||||
@@ -3320,6 +3342,20 @@ def send_campaign_now_endpoint(
|
||||
commit=True,
|
||||
)
|
||||
return SendCampaignNowResponse(result=result)
|
||||
except SynchronousSendRejected as exc:
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.send_now_rejected",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details={
|
||||
**exc.audit_details(),
|
||||
"version_id": payload.version_id,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||
except HTTPException:
|
||||
raise
|
||||
except (CampaignPersistenceError, QueueingError) as exc:
|
||||
|
||||
@@ -512,6 +512,13 @@ class SendCampaignNowResponse(BaseModel):
|
||||
result: dict[str, Any]
|
||||
|
||||
|
||||
class CampaignDeliveryOptionsResponse(BaseModel):
|
||||
campaign_id: str
|
||||
version_id: str
|
||||
worker_queue_available: bool
|
||||
synchronous_send: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MockCampaignSendRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
@@ -28,7 +28,17 @@ from govoplan_campaign.backend.db.models import (
|
||||
ImapAppendAttempt,
|
||||
SendAttempt,
|
||||
)
|
||||
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot, ExecutionSnapshotError, ensure_execution_snapshot
|
||||
from govoplan_campaign.backend.delivery_policy import (
|
||||
CampaignDeliveryPolicyError,
|
||||
SynchronousSendPolicy,
|
||||
effective_synchronous_send_policy,
|
||||
)
|
||||
from govoplan_campaign.backend.sending.execution import (
|
||||
ExecutionSnapshot,
|
||||
ExecutionSnapshotError,
|
||||
ensure_execution_snapshot,
|
||||
profile_delivery_summary,
|
||||
)
|
||||
from govoplan_campaign.backend.runtime import get_registry
|
||||
from govoplan_campaign.backend.integrations import (
|
||||
ImapAppendError,
|
||||
@@ -49,6 +59,29 @@ class SendJobError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class SynchronousSendRejected(QueueingError):
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
reason: str,
|
||||
eligible_count: int | None = None,
|
||||
policy: SynchronousSendPolicy | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.reason = reason
|
||||
self.eligible_count = eligible_count
|
||||
self.policy = policy
|
||||
|
||||
def audit_details(self) -> dict[str, Any]:
|
||||
return {
|
||||
"delivery_mode": "synchronous",
|
||||
"rejection_reason": self.reason,
|
||||
"eligible_recipient_job_count": self.eligible_count,
|
||||
"synchronous_send_policy": self.policy.as_dict() if self.policy is not None else None,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class QueueCampaignResult:
|
||||
campaign_id: str
|
||||
@@ -80,6 +113,8 @@ class SendCampaignNowResult:
|
||||
failed_count: int
|
||||
outcome_unknown_count: int
|
||||
skipped_count: int
|
||||
preflight_count: int = 0
|
||||
synchronous_send_policy: dict[str, Any] | None = None
|
||||
dry_run: bool = False
|
||||
results: list[dict[str, Any]] | None = None
|
||||
|
||||
@@ -92,6 +127,9 @@ class SendCampaignNowResult:
|
||||
"failed_count": self.failed_count,
|
||||
"outcome_unknown_count": self.outcome_unknown_count,
|
||||
"skipped_count": self.skipped_count,
|
||||
"preflight_count": self.preflight_count,
|
||||
"delivery_mode": "synchronous",
|
||||
"synchronous_send_policy": self.synchronous_send_policy or {},
|
||||
"dry_run": self.dry_run,
|
||||
"results": self.results or [],
|
||||
}
|
||||
@@ -444,6 +482,115 @@ def _select_campaign_jobs_for_queue(
|
||||
return queued, skipped_count, blocked_count
|
||||
|
||||
|
||||
def synchronous_send_candidate_jobs(
|
||||
version: CampaignVersion,
|
||||
jobs: list[CampaignJob],
|
||||
*,
|
||||
include_warnings: bool = True,
|
||||
) -> list[CampaignJob]:
|
||||
"""Return the exact persisted job set an immediate send may attempt.
|
||||
|
||||
This projection is deliberately side-effect free so the delivery-options
|
||||
API and adaptive documentation can explain the current built execution
|
||||
without changing queue state. The final command repeats the projection
|
||||
after queueing and before any SMTP effect.
|
||||
"""
|
||||
|
||||
allowed_validation = _queue_validation_statuses(include_warnings=include_warnings)
|
||||
reviewed_needs_review_keys = _reviewed_needs_review_keys(version)
|
||||
candidates: list[CampaignJob] = []
|
||||
for job in jobs:
|
||||
if (
|
||||
job.queue_status == JobQueueStatus.QUEUED.value
|
||||
and job.send_status == JobSendStatus.QUEUED.value
|
||||
):
|
||||
candidates.append(job)
|
||||
continue
|
||||
if job.send_status in INITIAL_QUEUE_SKIPPED_SEND_STATUSES:
|
||||
continue
|
||||
if job.queue_status in INITIAL_QUEUE_SKIPPED_QUEUE_STATUSES:
|
||||
continue
|
||||
validation_allowed = job.validation_status in allowed_validation or (
|
||||
job.validation_status == JobValidationStatus.NEEDS_REVIEW.value
|
||||
and _job_review_key(job) in reviewed_needs_review_keys
|
||||
)
|
||||
if job.build_status != JobBuildStatus.BUILT.value or not validation_allowed:
|
||||
continue
|
||||
if not job.eml_local_path and not job.eml_storage_key:
|
||||
continue
|
||||
candidates.append(job)
|
||||
return candidates
|
||||
|
||||
|
||||
def synchronous_send_options(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str | None = None,
|
||||
include_warnings: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Describe the actor-independent effective delivery-mode constraints."""
|
||||
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
||||
version = _get_version_for_campaign(session, campaign, version_id=version_id)
|
||||
jobs = _campaign_jobs_for_version(session, tenant_id=tenant_id, version_id=version.id)
|
||||
candidates = synchronous_send_candidate_jobs(version, jobs, include_warnings=include_warnings)
|
||||
try:
|
||||
policy = effective_synchronous_send_policy(session, tenant_id=tenant_id)
|
||||
except CampaignDeliveryPolicyError as exc:
|
||||
return {
|
||||
"campaign_id": campaign.id,
|
||||
"version_id": version.id,
|
||||
"worker_queue_available": bool(_celery_enabled()),
|
||||
"synchronous_send": {
|
||||
"allowed": False,
|
||||
"reason": "policy_configuration_invalid",
|
||||
"message": str(exc),
|
||||
"eligible_recipient_job_count": len(candidates),
|
||||
"policy": {},
|
||||
},
|
||||
}
|
||||
ready = _version_is_validated_and_locked(version) and bool(version.build_summary)
|
||||
eligible_count = len(candidates)
|
||||
if not ready:
|
||||
reason = "version_not_ready"
|
||||
elif eligible_count == 0:
|
||||
reason = "no_eligible_recipient_jobs"
|
||||
elif eligible_count > policy.max_recipient_jobs:
|
||||
reason = "recipient_limit_exceeded"
|
||||
else:
|
||||
reason = None
|
||||
return {
|
||||
"campaign_id": campaign.id,
|
||||
"version_id": version.id,
|
||||
"worker_queue_available": bool(_celery_enabled()),
|
||||
"synchronous_send": {
|
||||
"allowed": reason is None,
|
||||
"reason": reason,
|
||||
"eligible_recipient_job_count": eligible_count,
|
||||
"policy": policy.as_dict(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _campaign_jobs_for_version(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
version_id: str,
|
||||
) -> list[CampaignJob]:
|
||||
return (
|
||||
session.query(CampaignJob)
|
||||
.filter(
|
||||
CampaignJob.tenant_id == tenant_id,
|
||||
CampaignJob.campaign_version_id == version_id,
|
||||
)
|
||||
.order_by(CampaignJob.entry_index.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def _persist_campaign_queue(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -546,13 +693,36 @@ def send_campaign_now(
|
||||
) -> SendCampaignNowResult:
|
||||
"""Queue and send all eligible jobs for one campaign synchronously.
|
||||
|
||||
The service currently has no server-side job-count bound. Callers must use
|
||||
the worker queue for ordinary batches and reserve this path for deliberately
|
||||
small interactive runs.
|
||||
The effective deployment/tenant limit is checked against the immutable,
|
||||
persisted job set before queue state changes. After queueing, every message
|
||||
and transport revision is preflighted before the first SMTP effect starts.
|
||||
"""
|
||||
|
||||
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)
|
||||
_ensure_campaign_execution_snapshot(session, version)
|
||||
try:
|
||||
synchronous_policy = effective_synchronous_send_policy(session, tenant_id=tenant_id)
|
||||
except CampaignDeliveryPolicyError as exc:
|
||||
raise SynchronousSendRejected(
|
||||
f"Synchronous Campaign delivery is disabled by an invalid policy configuration: {exc}",
|
||||
reason="policy_configuration_invalid",
|
||||
) from exc
|
||||
initial_jobs = _campaign_jobs_for_queue(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
version_id=version.id,
|
||||
)
|
||||
initial_candidates = synchronous_send_candidate_jobs(
|
||||
version,
|
||||
initial_jobs,
|
||||
include_warnings=include_warnings,
|
||||
)
|
||||
_ensure_synchronous_send_count_allowed(
|
||||
len(initial_candidates),
|
||||
policy=synchronous_policy,
|
||||
)
|
||||
|
||||
queue_result = queue_campaign_jobs(
|
||||
session,
|
||||
@@ -572,20 +742,31 @@ def send_campaign_now(
|
||||
failed_count=0,
|
||||
outcome_unknown_count=0,
|
||||
skipped_count=queue_result.skipped_count + queue_result.blocked_count,
|
||||
preflight_count=len(initial_candidates),
|
||||
synchronous_send_policy=synchronous_policy.as_dict(),
|
||||
dry_run=True,
|
||||
results=[queue_result.as_dict()],
|
||||
)
|
||||
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
.filter(
|
||||
CampaignJob.tenant_id == tenant_id,
|
||||
CampaignJob.campaign_version_id == version.id,
|
||||
CampaignJob.queue_status == JobQueueStatus.QUEUED.value,
|
||||
CampaignJob.send_status.in_([JobSendStatus.QUEUED.value, JobSendStatus.FAILED_TEMPORARY.value]),
|
||||
jobs = [
|
||||
job
|
||||
for job in _campaign_jobs_for_version(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
version_id=version.id,
|
||||
)
|
||||
.order_by(CampaignJob.entry_index.asc())
|
||||
.all()
|
||||
if job.queue_status == JobQueueStatus.QUEUED.value
|
||||
and job.send_status == JobSendStatus.QUEUED.value
|
||||
]
|
||||
# Repeat the hard bound against the post-queue set. This closes the window
|
||||
# where a concurrent queue operation could otherwise enlarge an immediate
|
||||
# run between the initial decision and the first provider effect.
|
||||
_ensure_synchronous_send_count_allowed(len(jobs), policy=synchronous_policy)
|
||||
delivery_contexts = _preflight_synchronous_send_batch(
|
||||
session,
|
||||
version=version,
|
||||
jobs=jobs,
|
||||
policy=synchronous_policy,
|
||||
)
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
@@ -595,13 +776,19 @@ def send_campaign_now(
|
||||
skipped_after_queue = 0
|
||||
for job in jobs:
|
||||
try:
|
||||
result = send_campaign_job(
|
||||
session,
|
||||
job_id=job.id,
|
||||
dry_run=False,
|
||||
use_rate_limit=use_rate_limit,
|
||||
enqueue_imap_task=enqueue_imap_task,
|
||||
)
|
||||
claimed = _claimed_campaign_job_for_delivery(session, job)
|
||||
if isinstance(claimed, SendJobResult):
|
||||
result = claimed
|
||||
else:
|
||||
claimed_job, claim_token = claimed
|
||||
result = _send_claimed_campaign_job(
|
||||
session,
|
||||
job=claimed_job,
|
||||
claim_token=claim_token,
|
||||
context=delivery_contexts[job.id],
|
||||
use_rate_limit=use_rate_limit,
|
||||
enqueue_imap_task=enqueue_imap_task,
|
||||
)
|
||||
result_dict = result.as_dict()
|
||||
results.append(result_dict)
|
||||
if result.status in {JobSendStatus.SMTP_ACCEPTED.value, "already_accepted"}:
|
||||
@@ -622,11 +809,75 @@ def send_campaign_now(
|
||||
failed_count=failed_count,
|
||||
outcome_unknown_count=outcome_unknown_count,
|
||||
skipped_count=queue_result.skipped_count + queue_result.blocked_count + skipped_after_queue,
|
||||
preflight_count=len(delivery_contexts),
|
||||
synchronous_send_policy=synchronous_policy.as_dict(),
|
||||
dry_run=False,
|
||||
results=results,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_synchronous_send_count_allowed(
|
||||
eligible_count: int,
|
||||
*,
|
||||
policy: SynchronousSendPolicy,
|
||||
) -> None:
|
||||
if eligible_count <= 0:
|
||||
raise SynchronousSendRejected(
|
||||
"No eligible built recipient messages are available for synchronous delivery.",
|
||||
reason="no_eligible_recipient_jobs",
|
||||
eligible_count=eligible_count,
|
||||
policy=policy,
|
||||
)
|
||||
if eligible_count > policy.max_recipient_jobs:
|
||||
raise SynchronousSendRejected(
|
||||
(
|
||||
f"This built run contains {eligible_count} eligible recipient jobs, above the effective "
|
||||
f"synchronous limit of {policy.max_recipient_jobs}. Queue it for background workers instead."
|
||||
),
|
||||
reason="recipient_limit_exceeded",
|
||||
eligible_count=eligible_count,
|
||||
policy=policy,
|
||||
)
|
||||
|
||||
|
||||
def _preflight_synchronous_send_batch(
|
||||
session: Session,
|
||||
*,
|
||||
version: CampaignVersion,
|
||||
jobs: list[CampaignJob],
|
||||
policy: SynchronousSendPolicy,
|
||||
) -> dict[str, _SendJobDeliveryContext]:
|
||||
"""Freeze and validate every local input before the first SMTP effect."""
|
||||
|
||||
contexts: dict[str, _SendJobDeliveryContext] = {}
|
||||
try:
|
||||
for job in jobs:
|
||||
state_result = _preflight_send_campaign_job(session, job, dry_run=True)
|
||||
if state_result is not None:
|
||||
raise SendJobError(
|
||||
f"Job {job.id} changed to {state_result.status} during synchronous preflight"
|
||||
)
|
||||
contexts[job.id] = _send_job_delivery_context(session, job)
|
||||
profile_summary = profile_delivery_summary(session, version)
|
||||
expected_revision = next(iter(contexts.values())).snapshot.smtp_transport_revision
|
||||
if profile_summary.get("smtp_transport_revision") != expected_revision:
|
||||
raise SendJobError(
|
||||
"The selected Mail profile changed after this execution was built. Revalidate and rebuild before delivery."
|
||||
)
|
||||
except (ExecutionSnapshotError, MailProfileError, SmtpConfigurationError, SendJobError, OSError) as exc:
|
||||
raise SynchronousSendRejected(
|
||||
(
|
||||
"Synchronous preflight stopped before contacting SMTP because one or more frozen message "
|
||||
"inputs or the selected Mail profile no longer match the reviewed build. Rebuild and review "
|
||||
"the version before trying again."
|
||||
),
|
||||
reason="batch_preflight_failed",
|
||||
eligible_count=len(jobs),
|
||||
policy=policy,
|
||||
) from exc
|
||||
return contexts
|
||||
|
||||
|
||||
def enqueue_existing_queued_jobs(session: Session, *, tenant_id: str, campaign_id: str) -> int:
|
||||
if not _celery_enabled():
|
||||
return 0
|
||||
|
||||
Reference in New Issue
Block a user