Implement FastAPI Temporal MVP pipeline

This commit is contained in:
Codex
2026-03-27 00:10:28 +08:00
commit cc03da8a94
52 changed files with 3619 additions and 0 deletions

View File

@@ -0,0 +1,152 @@
"""Low-end image pipeline workflow."""
from datetime import timedelta
from temporalio import workflow
from temporalio.common import RetryPolicy
with workflow.unsafe.imports_passed_through():
from app.domain.enums import OrderStatus, WorkflowStepName
from app.infra.temporal.task_queues import (
IMAGE_PIPELINE_CONTROL_TASK_QUEUE,
IMAGE_PIPELINE_EXPORT_TASK_QUEUE,
IMAGE_PIPELINE_IMAGE_GEN_TASK_QUEUE,
IMAGE_PIPELINE_QC_TASK_QUEUE,
)
from app.workers.activities.export_activities import run_export_activity
from app.workers.activities.qc_activities import run_qc_activity
from app.workers.activities.review_activities import mark_workflow_failed_activity
from app.workers.activities.scene_activities import run_scene_activity
from app.workers.activities.tryon_activities import prepare_model_activity, run_tryon_activity
from app.workers.workflows.types import (
PipelineWorkflowInput,
StepActivityInput,
WorkflowFailureActivityInput,
)
ACTIVITY_TIMEOUT = timedelta(seconds=30)
ACTIVITY_RETRY_POLICY = RetryPolicy(
initial_interval=timedelta(seconds=1),
backoff_coefficient=2.0,
maximum_attempts=3,
)
@workflow.defn
class LowEndPipelineWorkflow:
"""Low-end fully automated image pipeline."""
@workflow.run
async def run(self, payload: PipelineWorkflowInput) -> dict[str, int | str | None]:
"""Execute the low-end workflow from start to finish."""
current_step = WorkflowStepName.PREPARE_MODEL
try:
prepared = await workflow.execute_activity(
prepare_model_activity,
StepActivityInput(
order_id=payload.order_id,
workflow_run_id=payload.workflow_run_id,
step_name=WorkflowStepName.PREPARE_MODEL,
model_id=payload.model_id,
pose_id=payload.pose_id,
garment_asset_id=payload.garment_asset_id,
scene_ref_asset_id=payload.scene_ref_asset_id,
),
task_queue=IMAGE_PIPELINE_CONTROL_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
retry_policy=ACTIVITY_RETRY_POLICY,
)
current_step = WorkflowStepName.TRYON
tryon_result = await workflow.execute_activity(
run_tryon_activity,
StepActivityInput(
order_id=payload.order_id,
workflow_run_id=payload.workflow_run_id,
step_name=WorkflowStepName.TRYON,
source_asset_id=prepared.asset_id,
garment_asset_id=payload.garment_asset_id,
),
task_queue=IMAGE_PIPELINE_IMAGE_GEN_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
retry_policy=ACTIVITY_RETRY_POLICY,
)
current_step = WorkflowStepName.SCENE
scene_result = await workflow.execute_activity(
run_scene_activity,
StepActivityInput(
order_id=payload.order_id,
workflow_run_id=payload.workflow_run_id,
step_name=WorkflowStepName.SCENE,
source_asset_id=tryon_result.asset_id,
scene_ref_asset_id=payload.scene_ref_asset_id,
),
task_queue=IMAGE_PIPELINE_IMAGE_GEN_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
retry_policy=ACTIVITY_RETRY_POLICY,
)
current_step = WorkflowStepName.QC
qc_result = await workflow.execute_activity(
run_qc_activity,
StepActivityInput(
order_id=payload.order_id,
workflow_run_id=payload.workflow_run_id,
step_name=WorkflowStepName.QC,
source_asset_id=scene_result.asset_id,
),
task_queue=IMAGE_PIPELINE_QC_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
retry_policy=ACTIVITY_RETRY_POLICY,
)
if not qc_result.passed:
await self._mark_failed(payload, current_step, qc_result.message)
return {"order_id": payload.order_id, "status": OrderStatus.FAILED.value, "final_asset_id": None}
current_step = WorkflowStepName.EXPORT
final_result = await workflow.execute_activity(
run_export_activity,
StepActivityInput(
order_id=payload.order_id,
workflow_run_id=payload.workflow_run_id,
step_name=WorkflowStepName.EXPORT,
source_asset_id=(qc_result.candidate_asset_ids or [scene_result.asset_id])[0],
),
task_queue=IMAGE_PIPELINE_EXPORT_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
retry_policy=ACTIVITY_RETRY_POLICY,
)
return {
"order_id": payload.order_id,
"status": OrderStatus.SUCCEEDED.value,
"final_asset_id": final_result.asset_id,
}
except Exception as exc:
await self._mark_failed(payload, current_step, str(exc))
raise
async def _mark_failed(
self,
payload: PipelineWorkflowInput,
current_step: WorkflowStepName,
message: str,
) -> None:
"""Persist workflow failure state."""
await workflow.execute_activity(
mark_workflow_failed_activity,
WorkflowFailureActivityInput(
order_id=payload.order_id,
workflow_run_id=payload.workflow_run_id,
current_step=current_step,
message=message,
),
task_queue=IMAGE_PIPELINE_CONTROL_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
retry_policy=ACTIVITY_RETRY_POLICY,
)

View File

@@ -0,0 +1,315 @@
"""Mid-end image pipeline workflow with review signal support."""
from datetime import timedelta
from temporalio import workflow
from temporalio.common import RetryPolicy
with workflow.unsafe.imports_passed_through():
from app.domain.enums import OrderStatus, ReviewDecision, WorkflowStepName
from app.infra.temporal.task_queues import (
IMAGE_PIPELINE_CONTROL_TASK_QUEUE,
IMAGE_PIPELINE_EXPORT_TASK_QUEUE,
IMAGE_PIPELINE_IMAGE_GEN_TASK_QUEUE,
IMAGE_PIPELINE_POST_PROCESS_TASK_QUEUE,
IMAGE_PIPELINE_QC_TASK_QUEUE,
)
from app.workers.activities.export_activities import run_export_activity
from app.workers.activities.face_activities import run_face_activity
from app.workers.activities.fusion_activities import run_fusion_activity
from app.workers.activities.qc_activities import run_qc_activity
from app.workers.activities.review_activities import (
complete_review_wait_activity,
mark_waiting_for_review_activity,
mark_workflow_failed_activity,
)
from app.workers.activities.scene_activities import run_scene_activity
from app.workers.activities.texture_activities import run_texture_activity
from app.workers.activities.tryon_activities import prepare_model_activity, run_tryon_activity
from app.workers.workflows.types import (
MockActivityResult,
PipelineWorkflowInput,
ReviewResolutionActivityInput,
ReviewSignalPayload,
ReviewWaitActivityInput,
StepActivityInput,
WorkflowFailureActivityInput,
)
ACTIVITY_TIMEOUT = timedelta(seconds=30)
ACTIVITY_RETRY_POLICY = RetryPolicy(
initial_interval=timedelta(seconds=1),
backoff_coefficient=2.0,
maximum_attempts=3,
)
@workflow.defn
class MidEndPipelineWorkflow:
"""Mid-end workflow that pauses for human review and supports reruns."""
def __init__(self) -> None:
self._review_payload: ReviewSignalPayload | None = None
@workflow.signal
def submit_review(self, payload: ReviewSignalPayload) -> None:
"""Receive a review decision from the API layer."""
self._review_payload = payload
@workflow.run
async def run(self, payload: PipelineWorkflowInput) -> dict[str, int | str | None]:
"""Execute the mid-end workflow with a human review loop."""
current_step = WorkflowStepName.PREPARE_MODEL
try:
prepared = await workflow.execute_activity(
prepare_model_activity,
StepActivityInput(
order_id=payload.order_id,
workflow_run_id=payload.workflow_run_id,
step_name=WorkflowStepName.PREPARE_MODEL,
model_id=payload.model_id,
pose_id=payload.pose_id,
garment_asset_id=payload.garment_asset_id,
scene_ref_asset_id=payload.scene_ref_asset_id,
),
task_queue=IMAGE_PIPELINE_CONTROL_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
retry_policy=ACTIVITY_RETRY_POLICY,
)
current_step = WorkflowStepName.TRYON
tryon_result = await workflow.execute_activity(
run_tryon_activity,
StepActivityInput(
order_id=payload.order_id,
workflow_run_id=payload.workflow_run_id,
step_name=WorkflowStepName.TRYON,
source_asset_id=prepared.asset_id,
garment_asset_id=payload.garment_asset_id,
),
task_queue=IMAGE_PIPELINE_IMAGE_GEN_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
retry_policy=ACTIVITY_RETRY_POLICY,
)
current_step = WorkflowStepName.SCENE
scene_result = await self._run_scene(payload, tryon_result.asset_id)
current_step = WorkflowStepName.TEXTURE
texture_result = await self._run_texture(payload, scene_result.asset_id)
current_step = WorkflowStepName.FACE
face_result = await self._run_face(payload, texture_result.asset_id)
current_step = WorkflowStepName.FUSION
fusion_result = await self._run_fusion(payload, scene_result.asset_id, face_result.asset_id)
current_step = WorkflowStepName.QC
qc_result = await self._run_qc(payload, fusion_result.asset_id)
if not qc_result.passed:
await self._mark_failed(payload, current_step, qc_result.message)
return {"order_id": payload.order_id, "status": OrderStatus.FAILED.value, "final_asset_id": None}
while True:
current_step = WorkflowStepName.REVIEW
await workflow.execute_activity(
mark_waiting_for_review_activity,
ReviewWaitActivityInput(
order_id=payload.order_id,
workflow_run_id=payload.workflow_run_id,
candidate_asset_ids=qc_result.candidate_asset_ids,
),
task_queue=IMAGE_PIPELINE_CONTROL_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
retry_policy=ACTIVITY_RETRY_POLICY,
)
review_payload = await self._wait_for_review()
await workflow.execute_activity(
complete_review_wait_activity,
ReviewResolutionActivityInput(
order_id=payload.order_id,
workflow_run_id=payload.workflow_run_id,
decision=review_payload.decision,
reviewer_id=review_payload.reviewer_id,
selected_asset_id=review_payload.selected_asset_id,
comment=review_payload.comment,
),
task_queue=IMAGE_PIPELINE_CONTROL_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
retry_policy=ACTIVITY_RETRY_POLICY,
)
if review_payload.decision == ReviewDecision.APPROVE:
current_step = WorkflowStepName.EXPORT
export_source_id = review_payload.selected_asset_id
if export_source_id is None:
export_source_id = (qc_result.candidate_asset_ids or [fusion_result.asset_id])[0]
final_result = await workflow.execute_activity(
run_export_activity,
StepActivityInput(
order_id=payload.order_id,
workflow_run_id=payload.workflow_run_id,
step_name=WorkflowStepName.EXPORT,
source_asset_id=export_source_id,
),
task_queue=IMAGE_PIPELINE_EXPORT_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
retry_policy=ACTIVITY_RETRY_POLICY,
)
return {
"order_id": payload.order_id,
"status": OrderStatus.SUCCEEDED.value,
"final_asset_id": final_result.asset_id,
}
if review_payload.decision == ReviewDecision.REJECT:
return {"order_id": payload.order_id, "status": OrderStatus.FAILED.value, "final_asset_id": None}
if review_payload.decision == ReviewDecision.RERUN_SCENE:
current_step = WorkflowStepName.SCENE
scene_result = await self._run_scene(payload, tryon_result.asset_id)
current_step = WorkflowStepName.TEXTURE
texture_result = await self._run_texture(payload, scene_result.asset_id)
current_step = WorkflowStepName.FACE
face_result = await self._run_face(payload, texture_result.asset_id)
current_step = WorkflowStepName.FUSION
fusion_result = await self._run_fusion(payload, scene_result.asset_id, face_result.asset_id)
elif review_payload.decision == ReviewDecision.RERUN_FACE:
current_step = WorkflowStepName.FACE
face_result = await self._run_face(payload, texture_result.asset_id)
current_step = WorkflowStepName.FUSION
fusion_result = await self._run_fusion(payload, scene_result.asset_id, face_result.asset_id)
elif review_payload.decision == ReviewDecision.RERUN_FUSION:
current_step = WorkflowStepName.FUSION
fusion_result = await self._run_fusion(payload, scene_result.asset_id, face_result.asset_id)
current_step = WorkflowStepName.QC
qc_result = await self._run_qc(payload, fusion_result.asset_id)
if not qc_result.passed:
await self._mark_failed(payload, current_step, qc_result.message)
return {"order_id": payload.order_id, "status": OrderStatus.FAILED.value, "final_asset_id": None}
except Exception as exc:
await self._mark_failed(payload, current_step, str(exc))
raise
async def _wait_for_review(self) -> ReviewSignalPayload:
"""Suspend the workflow until a review signal arrives."""
if self._review_payload is None:
await workflow.wait_condition(lambda: self._review_payload is not None)
assert self._review_payload is not None
review_payload = self._review_payload
self._review_payload = None
return review_payload
async def _run_scene(self, payload: PipelineWorkflowInput, source_asset_id: int | None) -> MockActivityResult:
"""Execute the scene activity."""
return await workflow.execute_activity(
run_scene_activity,
StepActivityInput(
order_id=payload.order_id,
workflow_run_id=payload.workflow_run_id,
step_name=WorkflowStepName.SCENE,
source_asset_id=source_asset_id,
scene_ref_asset_id=payload.scene_ref_asset_id,
),
task_queue=IMAGE_PIPELINE_IMAGE_GEN_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
retry_policy=ACTIVITY_RETRY_POLICY,
)
async def _run_texture(self, payload: PipelineWorkflowInput, source_asset_id: int | None) -> MockActivityResult:
"""Execute the texture activity."""
return await workflow.execute_activity(
run_texture_activity,
StepActivityInput(
order_id=payload.order_id,
workflow_run_id=payload.workflow_run_id,
step_name=WorkflowStepName.TEXTURE,
source_asset_id=source_asset_id,
),
task_queue=IMAGE_PIPELINE_POST_PROCESS_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
retry_policy=ACTIVITY_RETRY_POLICY,
)
async def _run_face(self, payload: PipelineWorkflowInput, source_asset_id: int | None) -> MockActivityResult:
"""Execute the face activity."""
return await workflow.execute_activity(
run_face_activity,
StepActivityInput(
order_id=payload.order_id,
workflow_run_id=payload.workflow_run_id,
step_name=WorkflowStepName.FACE,
source_asset_id=source_asset_id,
),
task_queue=IMAGE_PIPELINE_POST_PROCESS_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
retry_policy=ACTIVITY_RETRY_POLICY,
)
async def _run_fusion(
self,
payload: PipelineWorkflowInput,
source_asset_id: int | None,
face_asset_id: int | None,
) -> MockActivityResult:
"""Execute the fusion activity."""
return await workflow.execute_activity(
run_fusion_activity,
StepActivityInput(
order_id=payload.order_id,
workflow_run_id=payload.workflow_run_id,
step_name=WorkflowStepName.FUSION,
source_asset_id=source_asset_id,
selected_asset_id=face_asset_id,
),
task_queue=IMAGE_PIPELINE_POST_PROCESS_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
retry_policy=ACTIVITY_RETRY_POLICY,
)
async def _run_qc(self, payload: PipelineWorkflowInput, source_asset_id: int | None) -> MockActivityResult:
"""Execute the QC activity."""
return await workflow.execute_activity(
run_qc_activity,
StepActivityInput(
order_id=payload.order_id,
workflow_run_id=payload.workflow_run_id,
step_name=WorkflowStepName.QC,
source_asset_id=source_asset_id,
),
task_queue=IMAGE_PIPELINE_QC_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
retry_policy=ACTIVITY_RETRY_POLICY,
)
async def _mark_failed(
self,
payload: PipelineWorkflowInput,
current_step: WorkflowStepName,
message: str,
) -> None:
"""Persist workflow failure state."""
await workflow.execute_activity(
mark_workflow_failed_activity,
WorkflowFailureActivityInput(
order_id=payload.order_id,
workflow_run_id=payload.workflow_run_id,
current_step=current_step,
message=message,
),
task_queue=IMAGE_PIPELINE_CONTROL_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
retry_policy=ACTIVITY_RETRY_POLICY,
)

View File

@@ -0,0 +1,137 @@
"""Shared workflow and activity payload types."""
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
from app.domain.enums import CustomerLevel, OrderStatus, ReviewDecision, ServiceMode, WorkflowStepName
def _coerce_enum(value: Any, enum_cls: type[Enum]) -> Any:
"""Coerce raw Temporal payload values back into enum instances."""
if value is None or isinstance(value, enum_cls):
return value
if isinstance(value, list):
value = "".join(str(item) for item in value)
return enum_cls(value)
@dataclass(slots=True)
class PipelineWorkflowInput:
"""Temporal workflow input for an image pipeline order."""
order_id: int
workflow_run_id: int
customer_level: CustomerLevel
service_mode: ServiceMode
model_id: int
pose_id: int
garment_asset_id: int
scene_ref_asset_id: int
def __post_init__(self) -> None:
"""Normalize enum-like values after Temporal deserialization."""
self.customer_level = _coerce_enum(self.customer_level, CustomerLevel)
self.service_mode = _coerce_enum(self.service_mode, ServiceMode)
@dataclass(slots=True)
class StepActivityInput:
"""Input payload shared by the mock pipeline activities."""
order_id: int
workflow_run_id: int
step_name: WorkflowStepName
model_id: int | None = None
pose_id: int | None = None
garment_asset_id: int | None = None
scene_ref_asset_id: int | None = None
source_asset_id: int | None = None
selected_asset_id: int | None = None
metadata: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
"""Normalize enum-like values after Temporal deserialization."""
self.step_name = _coerce_enum(self.step_name, WorkflowStepName)
@dataclass(slots=True)
class MockActivityResult:
"""Common mock activity result structure."""
step_name: WorkflowStepName
success: bool
asset_id: int | None
uri: str | None
score: float | None = None
passed: bool | None = None
message: str = "mock success"
candidate_asset_ids: list[int] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
"""Normalize enum-like values after Temporal deserialization."""
self.step_name = _coerce_enum(self.step_name, WorkflowStepName)
@dataclass(slots=True)
class ReviewSignalPayload:
"""Signal payload sent from the API to the mid-end workflow."""
decision: ReviewDecision
reviewer_id: int
selected_asset_id: int | None = None
comment: str | None = None
def __post_init__(self) -> None:
"""Normalize enum-like values after Temporal deserialization."""
self.decision = _coerce_enum(self.decision, ReviewDecision)
@dataclass(slots=True)
class ReviewWaitActivityInput:
"""Input for marking a workflow as waiting for review."""
order_id: int
workflow_run_id: int
candidate_asset_ids: list[int] = field(default_factory=list)
comment: str | None = None
@dataclass(slots=True)
class ReviewResolutionActivityInput:
"""Input for completing a waiting review state."""
order_id: int
workflow_run_id: int
decision: ReviewDecision
reviewer_id: int
selected_asset_id: int | None = None
comment: str | None = None
def __post_init__(self) -> None:
"""Normalize enum-like values after Temporal deserialization."""
self.decision = _coerce_enum(self.decision, ReviewDecision)
@dataclass(slots=True)
class WorkflowFailureActivityInput:
"""Input for marking a workflow as failed."""
order_id: int
workflow_run_id: int
current_step: WorkflowStepName
message: str
status: OrderStatus = OrderStatus.FAILED
def __post_init__(self) -> None:
"""Normalize enum-like values after Temporal deserialization."""
self.current_step = _coerce_enum(self.current_step, WorkflowStepName)
self.status = _coerce_enum(self.status, OrderStatus)