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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user