Coverage for app/services/task_service.py: 70.73%
41 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-25 15:51 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-25 15:51 +0000
1"""Task lifecycle helpers.
3Two-phase dispatch:
51. `prepare_task_in_tx` — INSERT a PENDING task row in the caller's
6 transaction (no commit, no Celery I/O). The caller commits the
7 surrounding business state and the new task row atomically.
92. `dispatch_to_celery` — outside the original TX, push the task to
10 RabbitMQ. On success the row's `celeryTaskId` is stamped; on
11 failure the row is marked FAILED. Either way the user sees a row
12 in the deployment list reflecting reality — no splitbrain.
13"""
14from __future__ import annotations
16import logging
17import uuid
19from sqlalchemy.exc import IntegrityError
20from sqlalchemy.orm import Session
22from app.celery_app import celery_app
23from app.crud import tasks as crud_tasks
24from app.models import Task, TaskStatus, TaskType
26logger = logging.getLogger(__name__)
29# Name of the Postgres partial unique index backing the "one active
30# task per deployment" rule. String-matched when translating
31# IntegrityError → ActiveTaskExistsError so other unique-constraint
32# violations aren't swallowed.
33_ACTIVE_TASK_UNIQUE_INDEX = "uq_tasks_active_per_deployment"
36class ActiveTaskExistsError(Exception):
37 """A PENDING/RUNNING task already exists for this deployment."""
40def prepare_task_in_tx(
41 db: Session,
42 deployment_id: uuid.UUID,
43 task_type: TaskType,
44) -> Task:
45 """Insert a PENDING task row in the caller's transaction.
47 Does NOT call `db.commit()` — the caller is responsible for
48 committing the surrounding state alongside this row, so that
49 deployment + teams + task are all visible (or all rolled back)
50 atomically.
52 Raises `ActiveTaskExistsError` if the deployment already has a
53 PENDING/RUNNING task. A Postgres partial unique index enforces this
54 at the DB level too: the pre-check catches the common case, and the
55 ``except IntegrityError`` around ``db.flush()`` catches the racy one
56 (two concurrent requests both pass the pre-check).
57 """
58 existing = crud_tasks.get_tasks(db, deployment_id=deployment_id)
59 for task in existing:
60 if task.status in (TaskStatus.PENDING, TaskStatus.RUNNING):
61 raise ActiveTaskExistsError(
62 f"Deployment {deployment_id} already has active task {task.taskId}"
63 )
65 db_task = Task(
66 deploymentId=deployment_id,
67 type=task_type,
68 status=TaskStatus.PENDING,
69 celeryTaskId=None,
70 )
71 db.add(db_task)
72 try:
73 db.flush()
74 except IntegrityError as e:
75 # Race: another transaction inserted a PENDING/RUNNING task for
76 # the same deployment between our pre-check and flush. Translate
77 # the constraint violation into the domain exception so the
78 # caller's 409 branch fires.
79 if _ACTIVE_TASK_UNIQUE_INDEX in str(e.orig):
80 raise ActiveTaskExistsError(
81 f"Deployment {deployment_id} already has an active task "
82 "(detected via DB unique constraint)"
83 ) from e
84 raise
85 db.refresh(db_task)
86 return db_task
89def dispatch_to_celery(
90 db: Session,
91 task: Task,
92 celery_task_name: str,
93 celery_args: list,
94) -> tuple[Task, str]:
95 """Push a prepared task to Celery.
97 MUST be called after the surrounding TX committed. Runs in fresh
98 transactions so the task row is updated independently of any later
99 request-handling commit.
101 On `send_task` failure the task is marked FAILED and the original
102 exception is re-raised — the caller turns that into a 503.
103 """
104 try:
105 result = celery_app.send_task(celery_task_name, args=celery_args)
106 except Exception:
107 logger.exception(
108 "Celery dispatch failed for task %s (deployment %s)",
109 task.taskId,
110 task.deploymentId,
111 )
112 task.status = TaskStatus.FAILED
113 task.logs = "Failed to dispatch to Celery"
114 db.commit()
115 db.refresh(task)
116 raise
118 task.celeryTaskId = result.id
119 db.commit()
120 db.refresh(task)
121 logger.info("Task %s dispatched as celery task %s", task.taskId, result.id)
122 return task, result.id