import asyncio
import logging
import time
from collections.abc import Mapping
from contextlib import suppress
from dataclasses import dataclass
from importlib import import_module
from typing import TYPE_CHECKING, Any, cast
from uuid import uuid4
from litestar_queues._environment import TASK_ID_ENV
from litestar_queues.backends.base import EXTERNAL_DISPATCH_RESERVATION_PREFIX
from litestar_queues.events import QueueEvent
from litestar_queues.exceptions import MissingDependencyError
from litestar_queues.execution.base import (
BaseExecutionBackend,
ExecutionCancelResult,
_queue_metric_attributes,
_queue_observability_attributes,
)
from litestar_queues.execution.cloudrun.config import CloudRunExecutionConfig, _execution_config_from_queue_config
if TYPE_CHECKING:
from litestar_queues.config import QueueConfig
from litestar_queues.execution.cloudrun._typing import (
CloudRunExecutionLike,
CloudRunExecutionsClient,
CloudRunJobsClient,
)
from litestar_queues.models import QueuedTaskRecord
from litestar_queues.service import QueueService
__all__ = ("CloudRunExecutionBackend", "CloudRunExecutionStatus")
_GOOGLE_CLOUD_RUN_PACKAGE = "google-cloud-run"
_CLOUDRUN_EXTRA = "cloudrun"
_DISPATCH_RESERVATION_LEASE_SECONDS = 15 * 60
_DISPATCH_OWNERSHIP_LOST_ERROR = "Cloud Run dispatch reservation ownership was lost before finalization"
_HTTP_NOT_FOUND = 404
logger = logging.getLogger(__name__)
[docs]
@dataclass(frozen=True, slots=True)
class CloudRunExecutionStatus:
"""Backend-neutral status for a Cloud Run execution."""
succeeded: "bool" = False
failed: "bool" = False
cancelled: "bool" = False
running: "bool" = True
error: "str | None" = None
[docs]
class CloudRunExecutionBackend(BaseExecutionBackend):
"""Execution backend that dispatches queued records to Cloud Run Jobs."""
__slots__ = ("_execution_config", "executions_client", "jobs_client")
[docs]
def __init__(
self,
config: "QueueConfig | None" = None,
*,
execution_config: "CloudRunExecutionConfig | None" = None,
jobs_client: "CloudRunJobsClient | None" = None,
executions_client: "CloudRunExecutionsClient | None" = None,
) -> "None":
super().__init__(config=config)
self._execution_config = execution_config
self.jobs_client = jobs_client
self.executions_client = executions_client
@property
def is_external(self) -> "bool":
"""Whether this backend dispatches records to another process."""
return True
@property
def execution_config(self) -> "CloudRunExecutionConfig":
"""Resolved Cloud Run execution config."""
if self._execution_config is None:
self._execution_config = _execution_config_from_queue_config(self.config)
return self._execution_config
[docs]
async def execute(
self, service: "QueueService", record: "QueuedTaskRecord", *, worker_id: "str | None" = None
) -> "QueuedTaskRecord":
"""Dispatch a record and return its persisted state.
The ``worker_id`` argument is accepted for protocol parity but not
forwarded: external dispatch does not run ``service.execute_record``
locally, so the remote runner is responsible for its own worker
identity binding.
Returns:
The persisted queue record after dispatch.
"""
del worker_id
await self.dispatch(service, record)
return await service.get_queue_backend().get_task(record.id) or record
[docs]
async def dispatch(self, service: "QueueService", record: "QueuedTaskRecord") -> "str | None":
"""Dispatch a queue record to Cloud Run Jobs.
Returns:
The Cloud Run execution reference, if dispatch succeeds.
"""
runtime = service.observability_runtime
attributes = _queue_observability_attributes("dispatch", record)
attributes["messaging.message.id"] = str(record.id)
metric_attributes = _queue_metric_attributes(record)
span = runtime.start_span("litestar_queues.dispatch", kind="producer", attributes=attributes)
reservation_ref = (
f"{EXTERNAL_DISPATCH_RESERVATION_PREFIX}{int(time.time() + _DISPATCH_RESERVATION_LEASE_SECONDS)}:{uuid4()}"
)
queue_backend = service.get_queue_backend()
reserved = await queue_backend.reserve_external_dispatch(
record.id, "cloudrun", reservation_ref, execution_profile=record.execution_profile
)
if reserved is None:
runtime.record_counter(
"litestar_queues.execution.dispatch",
attributes={**metric_attributes, "queue.execution.status": "skipped"},
)
runtime.end_span(span)
return None
try:
request = self.build_run_job_request(service, reserved)
client = await self._get_jobs_client()
operation = await client.run_job(request=request)
execution_ref = _require_operation_execution_ref(operation)
except asyncio.CancelledError:
with suppress(Exception):
await asyncio.shield(
queue_backend.release_external_dispatch(
record.id, reservation_ref, record.execution_backend, execution_profile=record.execution_profile
)
)
runtime.record_counter(
"litestar_queues.execution.dispatch",
attributes={**metric_attributes, "queue.execution.status": "cancelled"},
)
raise
except Exception as exc:
runtime.record_exception(span, exc)
await self._publish_dispatch_failure(service, record, exc)
fallback = self.execution_config.fallback_execution_backend
if fallback is None:
await queue_backend.release_external_dispatch(
record.id, reservation_ref, record.execution_backend, execution_profile=record.execution_profile
)
runtime.record_counter(
"litestar_queues.execution.dispatch",
attributes={**metric_attributes, "queue.execution.status": "error"},
)
raise
await queue_backend.release_external_dispatch(
record.id, reservation_ref, fallback, execution_profile=record.execution_profile
)
runtime.record_counter(
"litestar_queues.execution.dispatch",
attributes={**metric_attributes, "queue.execution.status": "fallback"},
)
return None
finally:
runtime.end_span(span)
finalized = await queue_backend.finalize_external_dispatch(
record.id, reservation_ref, "cloudrun", execution_ref, execution_profile=record.execution_profile
)
if finalized is None:
result = await self._cancel_execution_ref(execution_ref)
runtime.record_counter(
"litestar_queues.execution.cancel",
attributes={
**metric_attributes,
"queue.execution.status": result.status,
"queue.cancel.trigger": "dispatch_race",
},
)
if result.status == "retryable":
self._logger.warning(
"Cloud Run execution orphaned by a lost dispatch race could not be cancelled",
extra={"queue_task_id": str(record.id), "cloudrun_execution_ref": execution_ref},
)
runtime.record_counter(
"litestar_queues.execution.dispatch",
attributes={**metric_attributes, "queue.execution.status": "ownership_lost"},
)
raise RuntimeError(_DISPATCH_OWNERSHIP_LOST_ERROR)
runtime.record_counter(
"litestar_queues.execution.dispatch",
attributes={**metric_attributes, "queue.execution.status": "dispatched"},
)
return execution_ref
[docs]
async def cancel_execution(self, service: "QueueService", record: "QueuedTaskRecord") -> "ExecutionCancelResult":
"""Cancel the Cloud Run execution backing this record's attempt.
Returns:
The provider's answer, mapped to the shared cancellation contract.
"""
execution_ref = record.execution_ref
if execution_ref is None or execution_ref.startswith(EXTERNAL_DISPATCH_RESERVATION_PREFIX):
return ExecutionCancelResult.unsupported("no Cloud Run execution reference")
del service
return await self._cancel_execution_ref(execution_ref)
async def _cancel_execution_ref(self, execution_ref: "str") -> "ExecutionCancelResult":
"""Cancel one Cloud Run execution by its full resource name.
Returns:
``accepted`` once the cancellation operation is created,
``already_cancelled`` when the execution no longer exists, and
``retryable`` for anything else.
"""
try:
await (await self._get_executions_client()).cancel_execution(name=execution_ref)
except Exception as exc:
if _is_not_found_error(exc):
return ExecutionCancelResult.already_cancelled("Cloud Run execution not found")
self._logger.warning(
"Cloud Run cancellation failed", exc_info=True, extra={"cloudrun_execution_ref": execution_ref}
)
return ExecutionCancelResult.retryable(str(exc))
return ExecutionCancelResult.accepted(execution_ref)
[docs]
async def reconcile(self, service: "QueueService", record: "QueuedTaskRecord") -> "QueuedTaskRecord | None":
"""Reconcile a Cloud Run execution with the queue record.
Returns:
The terminal queue record when reconciliation completed it.
"""
if record.execution_ref is None:
return None
queue_backend = service.get_queue_backend()
if record.execution_ref.startswith(EXTERNAL_DISPATCH_RESERVATION_PREFIX):
return await self._recover_dispatch_reservation(service, record)
current = await queue_backend.get_task(record.id) or record
if current.is_terminal:
return None
runtime = service.observability_runtime
attributes = _queue_observability_attributes("reconcile", record)
attributes["messaging.message.id"] = str(record.id)
metric_attributes = _queue_metric_attributes(record)
span = runtime.start_span("litestar_queues.reconcile", kind="consumer", attributes=attributes)
try:
status = await self.check_execution_status(record.execution_ref)
except Exception as exc:
runtime.record_exception(span, exc)
runtime.record_counter(
"litestar_queues.execution.reconcile", attributes={**metric_attributes, "queue.task.status": "error"}
)
raise
finally:
runtime.end_span(span)
if status.running:
return None
expected_retry_count = current.retry_count if current.status == "running" else None
if status.succeeded:
if current.status != "running":
return None
updated = await queue_backend.complete_task(
current.id,
result=current.result
if current.result is not None
else {"cloudrun_execution": current.execution_ref, "status": "succeeded"},
expected_retry_count=expected_retry_count,
)
_record_reconcile_result(runtime, metric_attributes, updated)
return updated
if status.cancelled:
if current.status != "running":
return None
cancelled = await queue_backend.cancel_task(
current.id, include_running=True, expected_retry_count=expected_retry_count
)
updated = await queue_backend.get_task(current.id) if cancelled else None
_record_reconcile_result(runtime, metric_attributes, updated)
return updated
if status.failed:
updated = await queue_backend.fail_task(
current.id, status.error or "Cloud Run execution failed", expected_retry_count=expected_retry_count
)
_record_reconcile_result(runtime, metric_attributes, updated)
if updated is not None and updated.status in {"pending", "scheduled"}:
return await queue_backend.set_execution_backend(
updated.id, updated.execution_backend, execution_profile=updated.execution_profile
)
return updated
return None
async def _recover_dispatch_reservation(
self, service: "QueueService", record: "QueuedTaskRecord"
) -> "QueuedTaskRecord | None":
"""Release an elapsed dispatch lease and expire its task when overdue."""
execution_ref = record.execution_ref
if execution_ref is None or not _dispatch_reservation_is_stale(execution_ref):
return None
released = await service.get_queue_backend().release_external_dispatch(
record.id, execution_ref, record.execution_backend, execution_profile=record.execution_profile
)
if released is None:
return None
expired = await service.expire_overdue_tasks()
return next((candidate for candidate in expired if candidate.id == record.id), None)
[docs]
async def check_execution_status(self, execution_ref: "str") -> "CloudRunExecutionStatus":
"""Return Cloud Run execution status.
Transient API failures are treated as still running so reconciliation does
not create false terminal queue states.
"""
try:
execution = await (await self._get_executions_client()).get_execution(name=execution_ref)
except Exception as exc:
if _is_not_found_error(exc):
return CloudRunExecutionStatus(running=False, failed=True, error="Cloud Run execution not found")
self._logger.warning(
"Cloud Run status probe failed", exc_info=True, extra={"cloudrun_execution_ref": execution_ref}
)
return CloudRunExecutionStatus(running=True, error=str(exc))
succeeded = int(getattr(execution, "succeeded_count", 0) or 0) > 0
failed = int(getattr(execution, "failed_count", 0) or 0) > 0
cancelled = int(getattr(execution, "cancelled_count", 0) or 0) > 0
return CloudRunExecutionStatus(
succeeded=succeeded,
failed=failed,
cancelled=cancelled,
running=not (succeeded or failed or cancelled),
error=_execution_error(execution) if failed else None,
)
[docs]
def build_run_job_request(self, service: "QueueService", record: "QueuedTaskRecord") -> "dict[str, Any]":
"""Build the Cloud Run Jobs API request for a queue record.
Returns:
Cloud Run Jobs API request data.
"""
config = self.execution_config
task_obj = service.resolve_task(record.task_name)
timeout = record.metadata.get("timeout", task_obj.timeout)
timeout_seconds = int(timeout if isinstance(timeout, int | float) else config.timeout)
job_name = config.resolve_job_name(record.execution_profile)
env = self.build_task_env(record)
return {
"name": f"projects/{config.project_id}/locations/{config.region}/jobs/{job_name}",
"overrides": {
"container_overrides": [{"env": [{"name": key, "value": value} for key, value in env.items()]}],
"timeout": f"{timeout_seconds}s",
},
}
[docs]
def build_task_env(self, record: "QueuedTaskRecord") -> "dict[str, str]":
"""Build the single-value task environment for a Cloud Run task process.
The record travels as its id in one prefix-aware environment variable
(``QUEUES_TASK_ID``); the consumer re-fetches the
live record by that id. Adopter ``extra_env`` values are merged in.
Returns:
Environment variables for the Cloud Run task process.
"""
config = self.execution_config
env = {TASK_ID_ENV: str(record.id)}
env.update(config.extra_env)
return env
async def _get_jobs_client(self) -> "CloudRunJobsClient":
if self.jobs_client is None:
try:
run_v2 = import_module("google.cloud.run_v2")
except ImportError as exc:
raise MissingDependencyError(_GOOGLE_CLOUD_RUN_PACKAGE, _CLOUDRUN_EXTRA) from exc
self.jobs_client = cast("CloudRunJobsClient", run_v2.JobsAsyncClient())
return self.jobs_client
async def _get_executions_client(self) -> "CloudRunExecutionsClient":
if self.executions_client is None:
try:
run_v2 = import_module("google.cloud.run_v2")
except ImportError as exc:
raise MissingDependencyError(_GOOGLE_CLOUD_RUN_PACKAGE, _CLOUDRUN_EXTRA) from exc
self.executions_client = cast("CloudRunExecutionsClient", run_v2.ExecutionsAsyncClient())
return self.executions_client
async def _publish_dispatch_failure(
self, service: "QueueService", record: "QueuedTaskRecord", exc: "BaseException"
) -> "None":
fallback = self.execution_config.fallback_execution_backend
self._logger.warning(
"Cloud Run dispatch failed",
exc_info=(type(exc), exc, exc.__traceback__),
extra={
"queue_task_id": str(record.id),
"queue_task_name": record.task_name,
"queue_task_queue": record.queue,
"queue_task_execution_backend": record.execution_backend,
"queue_task_execution_profile": record.execution_profile,
"cloudrun_fallback_execution_backend": fallback,
},
)
try:
await service.get_event_publisher().publish(
QueueEvent(
type="task.event",
scope="task",
task_id=str(record.id),
task_name=record.task_name,
queue=record.queue,
execution_backend=record.execution_backend,
execution_profile=record.execution_profile,
attempt=record.retry_count + 1,
level="warning",
message="Cloud Run dispatch failed",
payload={
"phase": "cloudrun.dispatch_fallback",
"error": str(exc),
"fallback_execution_backend": fallback,
},
)
)
except Exception:
self._logger.warning(
"Cloud Run dispatch failure event publish failed",
exc_info=True,
extra={"queue_task_id": str(record.id)},
)
def _dispatch_reservation_is_stale(execution_ref: "str") -> "bool":
"""Return whether a dispatch reservation lease has elapsed."""
payload = execution_ref.removeprefix(EXTERNAL_DISPATCH_RESERVATION_PREFIX)
deadline, separator, _ = payload.partition(":")
if not separator:
return True
try:
return float(deadline) <= time.time()
except ValueError:
return True
def _execution_error(execution: "CloudRunExecutionLike") -> "str | None":
conditions = getattr(execution, "conditions", None) or []
for condition in reversed(conditions):
message = getattr(condition, "message", None)
if message:
return str(message)
return None
def _record_reconcile_result(
runtime: "Any", metric_attributes: "dict[str, str]", record: "QueuedTaskRecord | None"
) -> "None":
if record is not None:
runtime.record_counter(
"litestar_queues.execution.reconcile", attributes={**metric_attributes, "queue.task.status": record.status}
)
def _operation_execution_ref(operation: "object") -> "str | None":
return _execution_ref_from_value(getattr(operation, "metadata", None))
def _require_operation_execution_ref(operation: "object") -> "str":
execution_ref = _operation_execution_ref(operation)
if execution_ref is None:
msg = "Cloud Run run_job operation did not include execution metadata."
raise RuntimeError(msg)
return execution_ref
def _execution_ref_from_value(value: "object") -> "str | None":
if value is None:
return None
if isinstance(value, str):
return value or None
name = getattr(value, "name", None)
if name:
return str(name)
if isinstance(value, Mapping):
mapped_name = value.get("name")
if mapped_name:
return str(mapped_name)
mapped_execution = value.get("execution")
if mapped_execution is not None:
return _execution_ref_from_value(mapped_execution)
execution = getattr(value, "execution", None)
if execution is not None:
return _execution_ref_from_value(execution)
return None
def _is_not_found_error(exc: "BaseException") -> "bool":
if exc.__class__.__name__ == "NotFound":
return True
status_code = getattr(exc, "status_code", None) or getattr(exc, "status", None)
return status_code == _HTTP_NOT_FOUND