feat(14-05): add Celery cloud analysis task with retry harness (Task 2)

- Implement tasks/cloud_analysis_tasks.py: sync Celery bridge to async processing
- Broker payload contains IDs only — credentials decrypted in worker (T-14-10)
- _TransientError and _ClassificationRetry sentinels for bounded retry (max=3)
- Auth errors are terminal (not retried); MaxRetriesExceeded writes failure status
- celery_app.py: add cloud_analysis_tasks route and import registration
- Add 6 contract tests: task registration, broker payload shape, queue routing,
  bounded retries, sentinel pattern, connection-not-found graceful drop
This commit is contained in:
curo1305
2026-06-23 16:06:07 +02:00
parent cea9980aaf
commit f235d894fb
3 changed files with 518 additions and 0 deletions
+2
View File
@@ -36,6 +36,7 @@ celery_app.conf.task_routes = {
"tasks.email_tasks.*": {"queue": "email"}, "tasks.email_tasks.*": {"queue": "email"},
"tasks.audit_tasks.*": {"queue": "documents"}, "tasks.audit_tasks.*": {"queue": "documents"},
"tasks.cloud_tasks.*": {"queue": "documents"}, "tasks.cloud_tasks.*": {"queue": "documents"},
"tasks.cloud_analysis_tasks.*": {"queue": "documents"},
} }
# Celery beat schedule: # Celery beat schedule:
@@ -56,6 +57,7 @@ celery_app.conf.timezone = "UTC"
# Explicitly import task modules — autodiscover_tasks(["tasks"]) looks for # Explicitly import task modules — autodiscover_tasks(["tasks"]) looks for
# tasks.tasks (appends ".tasks") which doesn't exist in our structure. # tasks.tasks (appends ".tasks") which doesn't exist in our structure.
import tasks.audit_tasks # noqa: F401, E402 import tasks.audit_tasks # noqa: F401, E402
import tasks.cloud_analysis_tasks # noqa: F401, E402
import tasks.cloud_tasks # noqa: F401, E402 import tasks.cloud_tasks # noqa: F401, E402
import tasks.document_tasks # noqa: F401, E402 import tasks.document_tasks # noqa: F401, E402
import tasks.email_tasks # noqa: F401, E402 import tasks.email_tasks # noqa: F401, E402
+344
View File
@@ -0,0 +1,344 @@
"""
Celery tasks for cloud analysis processing — Phase 14 Plan 05.
Provides the sync Celery bridge to the async processing service.
Task: process_cloud_analysis_item
- Broker payload: job_id (str), item_id (str), user_id (str), connection_id (str),
cloud_item_id (str) — IDs only, no credentials, bytes, or filenames (T-14-10).
- Credentials are decrypted inside the worker after ownership revalidation.
- Supports bounded transient provider/classifier retry (max_retries=3).
- Terminal auth failures do NOT retry — they write a controlled failure status.
- MaxRetriesExceededError writes final "failed" status to DB.
- Cooperative cancellation is checked between all major processing stages by the
processing service.
Retry harness (same pattern as cloud_tasks.py / document_tasks.py):
CRITICAL: self.retry() MUST be raised from the outer sync task, NOT inside
asyncio.run(). _TransientError and _ClassificationRetry are sentinels raised by
_run_processing() to signal retryable failures that escape asyncio.run().
Aggregate job counter updates:
- Handled by services.cloud_analysis_processing._set_item_status and
_update_job_completion (the processing service owns all counter transitions).
- The Celery task only records the final outcome returned by process_job_item.
Queue routing:
- Added to celery_app.py task_routes under "tasks.cloud_analysis_tasks.*""documents".
Security:
- T-14-10: Broker payload contains no credentials, provider URLs, filenames, or bytes.
- Owner revalidation inside the worker — no trust in the broker message alone.
"""
from __future__ import annotations
import asyncio
import random
from celery.exceptions import MaxRetriesExceededError
from celery_app import celery_app
# ── Retry sentinels ───────────────────────────────────────────────────────────
class _TransientError(Exception):
"""Retryable transient provider or network failure.
Raised by _run_processing() to escape asyncio.run() so self.retry()
can be called in the outer sync Celery task layer.
"""
class _ClassificationRetry(Exception):
"""Retryable AI classification failure.
Separate sentinel from _TransientError so the caller can apply appropriate
backoff and log the classification failure path distinctly.
"""
# ── Async processing worker ───────────────────────────────────────────────────
async def _run_processing(
job_id: str,
item_id: str,
user_id: str,
connection_id: str,
cloud_item_id: str,
) -> dict:
"""Async body of process_cloud_analysis_item.
Opens its own AsyncSession (never shared with other tasks/requests).
Decrypts credentials inside the worker — never put them in the broker message.
Returns a result dict with at least: {"status": "indexed"|"failed"|"cancelled"|"stale"}.
Raises:
_TransientError: Provider/network failure that should be retried.
_ClassificationRetry: AI classification failure that should be retried.
"""
import uuid as _uuid
from db.session import AsyncSessionLocal
from services.cloud_items import resolve_owned_connection, ConnectionNotFound
from storage.cloud_backend_factory import build_cloud_resource_adapter
from storage.cloud_utils import decrypt_credentials
from storage import get_storage_backend
from config import settings
master_key = settings.cloud_creds_key.encode()
async with AsyncSessionLocal() as session:
# Revalidate ownership — worker never trusts broker payload alone (T-14-10)
try:
conn = await resolve_owned_connection(
session,
connection_id=connection_id,
user_id=user_id,
)
except ConnectionNotFound:
# Connection deleted or user changed — silently drop, do not retry
return {"status": "cancelled", "reason": "connection_not_found"}
# Decrypt credentials inside the worker (T-14-10)
try:
credentials = decrypt_credentials(master_key, user_id, conn.credentials_enc)
except Exception as exc:
# Decryption failure is terminal — write failure status
await _write_item_failure(
session,
job_id=job_id,
item_id=item_id,
user_id=user_id,
error_code="credential_error",
error_message="Credential decryption failed. Re-connect the account.",
)
return {"status": "failed", "reason": "credential_error"}
# Build read-only provider adapter
try:
adapter = build_cloud_resource_adapter(conn.provider, credentials)
except Exception as exc:
await _write_item_failure(
session,
job_id=job_id,
item_id=item_id,
user_id=user_id,
error_code="adapter_error",
error_message="Failed to build provider adapter.",
)
return {"status": "failed", "reason": "adapter_error"}
# Build MinIO client for cache byte storage
minio_client = _build_minio_client()
# Delegate to the processing service
from services.cloud_analysis_processing import (
process_job_item,
ProcessingResult,
_ClassificationError,
OwnerValidationError,
)
try:
result: ProcessingResult = await process_job_item(
session,
job_id=_uuid.UUID(job_id),
item_id=_uuid.UUID(item_id),
user_id=_uuid.UUID(user_id),
connection_id=_uuid.UUID(connection_id),
cloud_item_id=_uuid.UUID(cloud_item_id),
minio_client=minio_client,
provider_adapter=adapter,
)
await session.commit()
return {
"status": result.status,
"error_code": result.error_code,
"cache_entry_id": str(result.cache_entry_id) if result.cache_entry_id else None,
}
except _ClassificationError as exc:
# Re-raise as our sentinel so the outer sync layer can self.retry()
raise _ClassificationRetry(str(exc)) from exc
except Exception as exc:
err_str = str(exc).lower()
# Detect auth/scope errors — terminal, do not retry
if any(kw in err_str for kw in (
"invalid_grant", "unauthorized", "401", "403", "scope", "revoked",
"credential_error", "auth_error",
)):
await _write_item_failure(
session,
job_id=job_id,
item_id=item_id,
user_id=user_id,
error_code="auth_error",
error_message="Authentication failed. Re-connect the account.",
)
await session.commit()
return {"status": "failed", "reason": "auth_error"}
# Transient failure — signal outer layer to retry
raise _TransientError(f"Transient error during processing: {exc}") from exc
def _build_minio_client():
"""Build a MinIO-compatible storage client for cache byte operations.
Returns an object with async get_object(key), put_object(key, data, content_type),
and delete_object(key) methods backed by the project's MinIO storage backend.
"""
from storage import get_storage_backend
class _MinIOClientWrapper:
def __init__(self, backend):
self._backend = backend
async def get_object(self, key: str) -> bytes:
return await self._backend.get_object(key)
async def put_object(self, key: str, data: bytes, content_type: str = None) -> None:
await self._backend.put_object(key, data, content_type=content_type or "application/octet-stream")
async def delete_object(self, key: str) -> None:
try:
await self._backend.delete_object(key)
except Exception:
pass
return _MinIOClientWrapper(get_storage_backend())
async def _write_item_failure(
session,
*,
job_id: str,
item_id: str,
user_id: str,
error_code: str,
error_message: str,
) -> None:
"""Write a failure status to a CloudAnalysisJobItem without leaking exceptions.
Used for terminal errors (credential, adapter) where the processing service
could not run its normal status-transition path.
"""
import uuid as _uuid
from sqlalchemy import select
from db.models import CloudAnalysisJob, CloudAnalysisJobItem
try:
uid = _uuid.UUID(user_id)
jid = _uuid.UUID(job_id)
iid = _uuid.UUID(item_id)
job = (await session.execute(
select(CloudAnalysisJob).where(
CloudAnalysisJob.id == jid,
CloudAnalysisJob.user_id == uid,
)
)).scalars().first()
job_item = (await session.execute(
select(CloudAnalysisJobItem).where(
CloudAnalysisJobItem.id == iid,
CloudAnalysisJobItem.job_id == jid,
)
)).scalars().first()
if job_item is not None and job is not None:
from services.cloud_analysis_processing import _set_item_status, _update_job_completion
old_status = job_item.status
await _set_item_status(
session,
job_item=job_item,
job=job,
new_status="failed",
old_status=old_status,
error_code=error_code,
error_message=error_message,
)
await _update_job_completion(session, job=job)
except Exception:
pass # Best-effort — do not mask the original failure
async def _write_max_retry_failure(
job_id: str, item_id: str, user_id: str
) -> None:
"""Write final 'failed' status after all Celery retries are exhausted."""
from db.session import AsyncSessionLocal
async with AsyncSessionLocal() as session:
await _write_item_failure(
session,
job_id=job_id,
item_id=item_id,
user_id=user_id,
error_code="max_retries_exceeded",
error_message="Processing failed after 3 retry attempts. Use per-item retry to requeue.",
)
await session.commit()
# ── Celery task ───────────────────────────────────────────────────────────────
@celery_app.task(
bind=True,
max_retries=3,
name="tasks.cloud_analysis_tasks.process_cloud_analysis_item",
serializer="json",
acks_late=True,
reject_on_worker_lost=True,
)
def process_cloud_analysis_item(
self,
job_id: str,
item_id: str,
user_id: str,
connection_id: str,
cloud_item_id: str,
) -> dict:
"""Sync Celery entry-point for processing one cloud analysis job item.
Broker payload contains IDs only — no credentials, provider URLs, filenames,
or bytes (T-14-10). Credentials are decrypted inside the worker.
Retry harness:
- Bounded exponential backoff: 30s / 90s / 270s (± 10s jitter).
- _TransientError and _ClassificationRetry sentinels escape asyncio.run()
and trigger self.retry() here in the sync Celery layer.
- MaxRetriesExceededError writes final failure status to DB.
- Terminal auth failures are NOT retried.
Args:
job_id: CloudAnalysisJob UUID string.
item_id: CloudAnalysisJobItem UUID string.
user_id: Owner User UUID string.
connection_id: CloudConnection UUID string.
cloud_item_id: CloudItem UUID string.
Returns:
Result dict: {"status": "indexed"|"failed"|"cancelled"|"stale", ...}
"""
try:
return asyncio.run(
_run_processing(job_id, item_id, user_id, connection_id, cloud_item_id)
)
except (_TransientError, _ClassificationRetry) as exc:
try:
# Exponential backoff with jitter: 30s, 90s, 270s (± 10s)
jitter = random.randint(-10, 10)
countdown = (30 * (3 ** self.request.retries)) + jitter
raise self.retry(exc=exc, countdown=countdown)
except MaxRetriesExceededError:
# All retries exhausted — write final failure status
asyncio.run(_write_max_retry_failure(job_id, item_id, user_id))
return {
"status": "failed",
"reason": "max_retries_exceeded",
"job_id": job_id,
"item_id": item_id,
}
@@ -1067,3 +1067,175 @@ async def test_processing_never_calls_provider_mutation_methods(db_session):
) )
# Should succeed or fail gracefully (not from a mutation) # Should succeed or fail gracefully (not from a mutation)
assert proc_result.status in ("indexed", "failed", "stale", "cancelled") assert proc_result.status in ("indexed", "failed", "stale", "cancelled")
# ─── Plan 05 Task 2: Celery task broker payload and retry contract ────────────
def test_celery_task_registered_in_app():
"""ANALYZE-04: process_cloud_analysis_item task is registered in the Celery app.
Verifies that the task module is imported and the task name is in the registry.
"""
from celery_app import celery_app
import tasks.cloud_analysis_tasks # noqa: F401 — side effect: registers the task
task_names = list(celery_app.tasks.keys())
assert "tasks.cloud_analysis_tasks.process_cloud_analysis_item" in task_names, (
"process_cloud_analysis_item must be registered in the Celery task registry"
)
def test_celery_broker_payload_contains_ids_only():
"""T-14-10: Celery task signature accepts only ID strings — no credentials/bytes.
Inspects the task function signature to verify it accepts only string ID
parameters (job_id, item_id, user_id, connection_id, cloud_item_id).
"""
import inspect
from tasks.cloud_analysis_tasks import process_cloud_analysis_item
sig = inspect.signature(process_cloud_analysis_item)
params = list(sig.parameters.keys())
# Required ID-only parameters
required = ["job_id", "item_id", "user_id", "connection_id", "cloud_item_id"]
for param in required:
assert param in params, (
f"Celery task must accept '{param}' ID parameter — broker payload "
"must contain IDs only (T-14-10)"
)
# Forbidden parameters that would indicate credential leakage
forbidden = ["credentials", "credentials_enc", "access_token", "file_bytes",
"provider_url", "filename", "object_key"]
for param in forbidden:
assert param not in params, (
f"Celery task must NOT accept '{param}' — credentials and bytes "
"must not appear in broker payload (T-14-10)"
)
def test_celery_task_routes_to_documents_queue():
"""ANALYZE-04: cloud_analysis_tasks routes to the 'documents' queue."""
from celery_app import celery_app
routes = celery_app.conf.task_routes or {}
assert "tasks.cloud_analysis_tasks.*" in routes, (
"cloud_analysis_tasks must have a task route entry"
)
route = routes["tasks.cloud_analysis_tasks.*"]
assert route.get("queue") == "documents", (
"cloud_analysis_tasks must route to the 'documents' queue"
)
def test_celery_task_max_retries_is_bounded():
"""ANALYZE-04: Celery task has bounded max_retries (not unlimited).
Unlimited retries could cause runaway retry storms on persistent failures.
"""
from tasks.cloud_analysis_tasks import process_cloud_analysis_item
# Access the underlying task object's max_retries attribute
task_obj = process_cloud_analysis_item
assert hasattr(task_obj, "max_retries"), "Task must have max_retries set"
assert task_obj.max_retries is not None, "max_retries must not be None (unlimited)"
assert 1 <= task_obj.max_retries <= 10, (
f"max_retries must be a reasonable bounded value, got {task_obj.max_retries}"
)
def test_celery_task_retry_sentinel_escapes_asyncio_run():
"""ANALYZE-04: _TransientError and _ClassificationRetry sentinels escape asyncio.run().
The sentinel pattern requires that exceptions raised inside asyncio.run()
propagate out to the sync Celery task layer where self.retry() can be called.
This test verifies that the sentinels are plain Exception subclasses (not
coroutines or awaitables), so they can propagate through asyncio.run() normally.
"""
from tasks.cloud_analysis_tasks import _TransientError, _ClassificationRetry
# The sentinels must be importable and be plain Exception subclasses
assert issubclass(_TransientError, Exception), (
"_TransientError must be a plain Exception subclass to escape asyncio.run()"
)
assert issubclass(_ClassificationRetry, Exception), (
"_ClassificationRetry must be a plain Exception subclass to escape asyncio.run()"
)
# Verify they are NOT derived from asyncio-specific types that would be swallowed
import asyncio
assert not issubclass(_TransientError, asyncio.CancelledError), (
"_TransientError must not derive from CancelledError (would be swallowed)"
)
assert not issubclass(_ClassificationRetry, asyncio.CancelledError), (
"_ClassificationRetry must not derive from CancelledError (would be swallowed)"
)
# Verify they can be raised and caught normally (synchronous check)
caught_transient = False
try:
raise _TransientError("test")
except _TransientError:
caught_transient = True
assert caught_transient, "_TransientError must be catchable as Exception"
caught_classification = False
try:
raise _ClassificationRetry("test")
except _ClassificationRetry:
caught_classification = True
assert caught_classification, "_ClassificationRetry must be catchable as Exception"
async def test_run_processing_returns_cancelled_when_connection_deleted():
"""ANALYZE-04: _run_processing returns cancelled/skipped when connection no longer exists.
The worker revalidates ownership at the start — if the connection is deleted
between enqueue and execution, the task silently drops (no retry).
Uses mocked AsyncSessionLocal to avoid requiring a live PostgreSQL connection
in the test environment.
"""
from tasks.cloud_analysis_tasks import _run_processing
from unittest.mock import patch, AsyncMock, MagicMock
from services.cloud_items import ConnectionNotFound
import uuid
# Mock the AsyncSessionLocal so the task uses an in-memory session
mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
# Mock resolve_owned_connection to raise ConnectionNotFound (deleted connection)
with patch("tasks.cloud_analysis_tasks._run_processing") as patched_run:
# Verify the function exists and is importable
from tasks.cloud_analysis_tasks import _run_processing as real_run
assert callable(real_run)
# Test the connection-not-found path by patching at the service layer
with patch("services.cloud_items.resolve_owned_connection",
new_callable=AsyncMock,
side_effect=ConnectionNotFound("not found")):
with patch("db.session.AsyncSessionLocal") as mock_session_local:
mock_ctx = AsyncMock()
mock_ctx.__aenter__ = AsyncMock(return_value=mock_ctx)
mock_ctx.__aexit__ = AsyncMock(return_value=False)
mock_session_local.return_value = mock_ctx
result = await _run_processing(
job_id=str(uuid.uuid4()),
item_id=str(uuid.uuid4()),
user_id=str(uuid.uuid4()),
connection_id=str(uuid.uuid4()), # Does not exist in DB
cloud_item_id=str(uuid.uuid4()),
)
# Worker must return a graceful status for deleted connection
assert result.get("status") in ("cancelled", "failed"), (
f"Deleted connection must return cancelled/failed status, got: {result}"
)
assert result.get("reason") == "connection_not_found", (
f"Expected reason='connection_not_found', got: {result.get('reason')}"
)