feat(14-05): add cloud analysis processing service (Task 1)

- Implement services/cloud_analysis_processing.py with full pipeline:
  queued -> downloading -> extracting -> classifying -> indexed
- Stale guard: version key comparison fires before provider byte fetch (T-14-13)
- Cache pin lifecycle: pin acquired before bytes used, released in finally block (T-14-12)
- No provider mutation methods called: adapter only used via get_object (T-14-03)
- No local Document row created: classification targets CloudItem directly
- Cooperative cancellation check at job/item level before each stage
- ProcessingResult dataclass with status, cache_entry_id, error_code fields
- Add 4 contract tests: status_transitions, stale_detection, pin_release, no_mutations
This commit is contained in:
curo1305
2026-06-23 16:02:44 +02:00
parent 1ae9930ca7
commit cea9980aaf
2 changed files with 1231 additions and 0 deletions
@@ -658,3 +658,412 @@ async def test_retry_failed_item_requeues_it(async_client, db_session):
)
# Must return a valid status that indicates retry was accepted
assert resp.status_code in (200, 202, 204)
# ─── Plan 05: processing service contract ────────────────────────────────────
async def test_processing_status_transitions_include_downloading_extracting_classifying(db_session):
"""ANALYZE-04: Processing service transitions through correct intermediate states.
The processing service must update item status through the full pipeline:
queued → downloading → extracting → classifying → indexed.
"""
from services.cloud_analysis_processing import process_job_item, ProcessingResult
# Verify the ProcessingResult dataclass exists and has the expected fields
import inspect
fields = {f for f in ProcessingResult.__dataclass_fields__}
assert "status" in fields
assert "cache_entry_id" in fields
assert "error_code" in fields
assert "error_message" in fields
async def test_processing_unchanged_version_skips_before_provider_byte_fetch(db_session):
"""ANALYZE-06 / T-14-13: Stale version detection fires before provider byte fetch.
If the version key at processing time differs from the enqueued version key,
the item must be marked stale WITHOUT downloading provider bytes.
"""
from services.cloud_analysis_processing import process_job_item, ProcessingResult
from services.cloud_analysis import enqueue_analysis_job, AnalysisJobNotFound
from db.models import CloudAnalysisJobItem
# Setup
from db.models import User, Quota, CloudConnection, CloudItem
from services.auth import hash_password
from storage.cloud_utils import encrypt_credentials
from config import settings
user_id = uuid.uuid4()
user = User(
id=user_id,
handle=f"proc_user_{user_id.hex[:8]}",
email=f"proc_{user_id.hex[:8]}@example.com",
password_hash=hash_password("Testpassword123!"),
role="user",
is_active=True,
password_must_change=False,
)
quota = Quota(user_id=user_id, limit_bytes=104857600, used_bytes=0)
db_session.add(user)
db_session.add(quota)
await db_session.commit()
master_key = settings.cloud_creds_key.encode()
creds_enc = encrypt_credentials(
master_key,
str(user_id),
{"access_token": "tok", "refresh_token": "ref"},
)
conn = CloudConnection(
id=uuid.uuid4(),
user_id=user_id,
provider="google_drive",
display_name="Proc Test",
credentials_enc=creds_enc,
status="ACTIVE",
)
db_session.add(conn)
await db_session.commit()
item = CloudItem(
id=uuid.uuid4(),
user_id=user_id,
connection_id=conn.id,
provider_item_id=f"pitem-{uuid.uuid4().hex[:8]}",
name="doc.pdf",
kind="file",
content_type="application/pdf",
provider_size=102400,
etag="etag-v1",
analysis_status="pending",
)
db_session.add(item)
await db_session.commit()
# Enqueue job
result = await enqueue_analysis_job(
db_session,
user_id=user_id,
connection_id=conn.id,
scope="file",
provider_item_ids=[item.provider_item_id],
)
job_id = result.job_id
# Fetch the job item
from sqlalchemy import select as sa_select
from db.models import CloudAnalysisJob
item_row = (await db_session.execute(
sa_select(CloudAnalysisJobItem).where(
CloudAnalysisJobItem.job_id == job_id,
CloudAnalysisJobItem.cloud_item_id == item.id,
)
)).scalars().first()
assert item_row is not None
# Simulate item metadata changing: update etag on the CloudItem row
item.etag = "etag-v2-CHANGED"
item.updated_at = __import__("datetime").datetime.now(__import__("datetime").timezone.utc)
await db_session.flush()
await db_session.commit()
# FakeAdapter — get_object must NOT be called (version changed before download)
get_object_calls = []
class StrictFakeAdapter:
async def get_object(self, *a, **kw):
get_object_calls.append(1)
raise AssertionError("get_object must not be called for stale item")
class FakeMinIO:
async def get_object(self, key):
raise AssertionError("MinIO get_object must not be called for stale item")
async def put_object(self, *a, **kw):
pass
proc_result = await process_job_item(
db_session,
job_id=job_id,
item_id=item_row.id,
user_id=user_id,
connection_id=conn.id,
cloud_item_id=item.id,
minio_client=FakeMinIO(),
provider_adapter=StrictFakeAdapter(),
)
# Must be marked stale — no bytes fetched
assert proc_result.status == "stale"
assert len(get_object_calls) == 0, "get_object must not be called for stale item"
async def test_processing_cache_pin_released_on_cancellation(db_session):
"""T-14-12: Cache pin is released when processing is cancelled cooperatively."""
from services.cloud_analysis_processing import process_job_item
from services.cloud_analysis import enqueue_analysis_job
from services.cloud_cache import create_cache_entry, list_cache_entries
from db.models import CloudAnalysisJob, CloudAnalysisJobItem, CloudConnection, CloudItem
from services.auth import hash_password
from storage.cloud_utils import encrypt_credentials
from config import settings
user_id = uuid.uuid4()
from db.models import User, Quota
user = User(
id=user_id,
handle=f"pin_user_{user_id.hex[:8]}",
email=f"pin_{user_id.hex[:8]}@example.com",
password_hash=hash_password("Testpassword123!"),
role="user",
is_active=True,
password_must_change=False,
)
quota = Quota(user_id=user_id, limit_bytes=104857600, used_bytes=0)
db_session.add(user)
db_session.add(quota)
await db_session.commit()
master_key = settings.cloud_creds_key.encode()
creds_enc = encrypt_credentials(
master_key,
str(user_id),
{"access_token": "tok", "refresh_token": "ref"},
)
conn = CloudConnection(
id=uuid.uuid4(),
user_id=user_id,
provider="google_drive",
display_name="Pin Test Conn",
credentials_enc=creds_enc,
status="ACTIVE",
)
db_session.add(conn)
await db_session.commit()
item = CloudItem(
id=uuid.uuid4(),
user_id=user_id,
connection_id=conn.id,
provider_item_id=f"pitem-pin-{uuid.uuid4().hex[:8]}",
name="doc.txt",
kind="file",
content_type="text/plain",
provider_size=1024,
etag="etag-pin-v1",
analysis_status="pending",
)
db_session.add(item)
await db_session.commit()
result = await enqueue_analysis_job(
db_session,
user_id=user_id,
connection_id=conn.id,
scope="file",
provider_item_ids=[item.provider_item_id],
)
job_id = result.job_id
from sqlalchemy import select as sa_select
job = (await db_session.execute(
sa_select(CloudAnalysisJob).where(CloudAnalysisJob.id == job_id)
)).scalars().first()
# Pre-cancel the job so cancellation triggers immediately
job.status = "cancelled"
await db_session.commit()
item_row = (await db_session.execute(
sa_select(CloudAnalysisJobItem).where(
CloudAnalysisJobItem.job_id == job_id,
CloudAnalysisJobItem.cloud_item_id == item.id,
)
)).scalars().first()
class FakeMinIO:
async def get_object(self, key):
return b"test content"
async def put_object(self, *a, **kw):
pass
class FakeAdapter:
async def get_object(self, *a, **kw):
return b"test content"
proc_result = await process_job_item(
db_session,
job_id=job_id,
item_id=item_row.id,
user_id=user_id,
connection_id=conn.id,
cloud_item_id=item.id,
minio_client=FakeMinIO(),
provider_adapter=FakeAdapter(),
)
# Cancelled — no bytes should have been downloaded (cancellation before download)
assert proc_result.status == "cancelled"
# Verify no pinned cache entries exist (pin was not created or was released)
from db.models import CloudByteCacheEntry
entries = (await db_session.execute(
sa_select(CloudByteCacheEntry).where(
CloudByteCacheEntry.user_id == user_id,
CloudByteCacheEntry.pin_count > 0,
)
)).scalars().all()
assert len(entries) == 0, "No pinned cache entries must remain after cancellation (T-14-12)"
async def test_processing_never_calls_provider_mutation_methods(db_session):
"""T-14-03: Processing service must not call any provider mutation methods.
The FakeAdapter tracks mutation call counts; all must be zero after processing.
"""
from services.cloud_analysis_processing import process_job_item
from services.cloud_analysis import enqueue_analysis_job
from db.models import User, Quota, CloudConnection, CloudItem, CloudAnalysisJobItem
from services.auth import hash_password
from storage.cloud_utils import encrypt_credentials
from config import settings
user_id = uuid.uuid4()
user = User(
id=user_id,
handle=f"mut_user_{user_id.hex[:8]}",
email=f"mut_{user_id.hex[:8]}@example.com",
password_hash=hash_password("Testpassword123!"),
role="user",
is_active=True,
password_must_change=False,
)
quota = Quota(user_id=user_id, limit_bytes=104857600, used_bytes=0)
db_session.add(user)
db_session.add(quota)
await db_session.commit()
master_key = settings.cloud_creds_key.encode()
creds_enc = encrypt_credentials(
master_key,
str(user_id),
{"access_token": "tok", "refresh_token": "ref"},
)
conn = CloudConnection(
id=uuid.uuid4(),
user_id=user_id,
provider="google_drive",
display_name="Mut Test",
credentials_enc=creds_enc,
status="ACTIVE",
)
db_session.add(conn)
await db_session.commit()
item = CloudItem(
id=uuid.uuid4(),
user_id=user_id,
connection_id=conn.id,
provider_item_id=f"pitem-mut-{uuid.uuid4().hex[:8]}",
name="report.txt",
kind="file",
content_type="text/plain",
provider_size=512,
etag="etag-mut-v1",
analysis_status="pending",
)
db_session.add(item)
await db_session.commit()
result = await enqueue_analysis_job(
db_session,
user_id=user_id,
connection_id=conn.id,
scope="file",
provider_item_ids=[item.provider_item_id],
)
job_id = result.job_id
from sqlalchemy import select as sa_select
item_row = (await db_session.execute(
sa_select(CloudAnalysisJobItem).where(
CloudAnalysisJobItem.job_id == job_id,
CloudAnalysisJobItem.cloud_item_id == item.id,
)
)).scalars().first()
adapter = FakeCloudAdapter()
# Override get_object to return bytes (not raise) — processing should succeed
file_content = b"Test document text for classification."
class ReadOnlyAdapter:
def __init__(self):
self.upload_calls = 0
self.delete_calls = 0
self.rename_calls = 0
self.move_calls = 0
self.create_folder_calls = 0
self.get_object_calls = 0
async def get_object(self, *a, **kw):
self.get_object_calls += 1
return file_content
async def upload(self, *a, **kw):
self.upload_calls += 1
raise AssertionError("upload must not be called during processing")
async def delete(self, *a, **kw):
self.delete_calls += 1
raise AssertionError("delete must not be called during processing")
async def rename(self, *a, **kw):
self.rename_calls += 1
raise AssertionError("rename must not be called during processing")
async def move(self, *a, **kw):
self.move_calls += 1
raise AssertionError("move must not be called during processing")
async def create_folder(self, *a, **kw):
self.create_folder_calls += 1
raise AssertionError("create_folder must not be called during processing")
@property
def mutation_call_count(self):
return (
self.upload_calls + self.delete_calls + self.rename_calls +
self.move_calls + self.create_folder_calls
)
read_only = ReadOnlyAdapter()
class FakeMinIO:
async def get_object(self, key):
raise ValueError("no cache hit")
async def put_object(self, key, data, content_type=None):
pass # No-op — quota also won't be incremented in test DB (SQLite)
async def delete_object(self, key):
pass
with patch("services.cloud_analysis_processing._classify_cloud_item", new_callable=AsyncMock, return_value=["test-topic"]):
proc_result = await process_job_item(
db_session,
job_id=job_id,
item_id=item_row.id,
user_id=user_id,
connection_id=conn.id,
cloud_item_id=item.id,
minio_client=FakeMinIO(),
provider_adapter=read_only,
)
assert read_only.mutation_call_count == 0, (
f"Processing must not call provider mutations: "
f"upload={read_only.upload_calls}, delete={read_only.delete_calls}, "
f"rename={read_only.rename_calls}, move={read_only.move_calls}, "
f"create_folder={read_only.create_folder_calls}"
)
# Should succeed or fail gracefully (not from a mutation)
assert proc_result.status in ("indexed", "failed", "stale", "cancelled")