feat(files): add source-file upload with MinIO storage and Postgres repositories
Why: - Implements plan 001 Phase 3's upload orchestration. Changes: - ObjectStorage port and MinIO adapter, thread-offloaded per ADR-0017. - Tenant-scoped repositories for api_keys, source_files, ingestion_jobs. - upload_source_file implementing the two-transaction shape with (tenant_id, domain, content_sha256) idempotency. Impact: - This phase stores bytes only -- chunks_indexed is always 0 until Phase 4/5 add parsing/embedding.
This commit is contained in:
19
src/application/files/__init__.py
Normal file
19
src/application/files/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Source-file upload and status use cases (ADR-0008, ADR-0009, ADR-0017)."""
|
||||
|
||||
from src.application.files.errors import FilesError, FileTooLargeError, InvalidUploadError
|
||||
from src.application.files.models import UploadResult, ValidatedUpload
|
||||
from src.application.files.status import FileStatusResult, get_file_status
|
||||
from src.application.files.upload import upload_source_file
|
||||
from src.application.files.validation import validate_and_hash_upload
|
||||
|
||||
__all__ = [
|
||||
"FileStatusResult",
|
||||
"FileTooLargeError",
|
||||
"FilesError",
|
||||
"InvalidUploadError",
|
||||
"UploadResult",
|
||||
"ValidatedUpload",
|
||||
"get_file_status",
|
||||
"upload_source_file",
|
||||
"validate_and_hash_upload",
|
||||
]
|
||||
17
src/application/files/errors.py
Normal file
17
src/application/files/errors.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Upload-validation failures (ADR-0008). No HTTP knowledge here —
|
||||
`src/api/errors.py` maps these to status codes.
|
||||
"""
|
||||
|
||||
|
||||
class FilesError(Exception):
|
||||
"""Base class for file-upload failures."""
|
||||
|
||||
|
||||
class InvalidUploadError(FilesError):
|
||||
"""Missing domain, empty file, or content that doesn't match its
|
||||
declared extension. Maps to `400`.
|
||||
"""
|
||||
|
||||
|
||||
class FileTooLargeError(FilesError):
|
||||
"""The upload exceeds `INGESTION_MAX_UPLOAD_SIZE_MB`. Maps to `413`."""
|
||||
26
src/application/files/models.py
Normal file
26
src/application/files/models.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Domain models for the upload use case (ADR-0008, ADR-0009)."""
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ValidatedUpload:
|
||||
"""The result of extension/content validation, before any I/O."""
|
||||
|
||||
source_type: str
|
||||
content_type: str
|
||||
content_sha256: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UploadResult:
|
||||
"""What `upload_source_file` returns; the route maps this to `FileUploadResponse`."""
|
||||
|
||||
file_id: uuid.UUID
|
||||
ingestion_job_id: uuid.UUID
|
||||
status: str
|
||||
chunks_indexed: int
|
||||
is_new_attempt: bool
|
||||
"""`False` when an identical active upload already succeeded and no new
|
||||
ingestion attempt was made (route returns `200`, not `201`)."""
|
||||
52
src/application/files/status.py
Normal file
52
src/application/files/status.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""`GET /v1/files/{file_id}` read model (ADR-0008, ADR-0009)."""
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from src.infrastructure.postgres.repositories import ingestion_jobs as jobs_repo
|
||||
from src.infrastructure.postgres.repositories import source_files as source_files_repo
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileStatusResult:
|
||||
file_id: uuid.UUID
|
||||
source_filename: str
|
||||
domain: str
|
||||
status: str
|
||||
ingestion_job_id: uuid.UUID | None
|
||||
ingestion_status: str | None
|
||||
chunks_indexed: int
|
||||
|
||||
|
||||
async def get_file_status(
|
||||
sessionmaker: async_sessionmaker[AsyncSession],
|
||||
*,
|
||||
tenant_id: uuid.UUID,
|
||||
source_file_id: uuid.UUID,
|
||||
) -> FileStatusResult | None:
|
||||
"""Returns `None` when the file doesn't exist under this tenant — the
|
||||
route maps that to `404`, never `403` (ADR-0016: cross-tenant access
|
||||
returns 404).
|
||||
"""
|
||||
async with sessionmaker() as session:
|
||||
source_file = await source_files_repo.get_by_id(
|
||||
session, tenant_id=tenant_id, source_file_id=source_file_id
|
||||
)
|
||||
if source_file is None:
|
||||
return None
|
||||
|
||||
latest_job = await jobs_repo.get_latest_for_source_file(
|
||||
session, tenant_id=tenant_id, source_file_id=source_file.id
|
||||
)
|
||||
|
||||
return FileStatusResult(
|
||||
file_id=source_file.id,
|
||||
source_filename=source_file.source_filename,
|
||||
domain=source_file.domain,
|
||||
status=source_file.status,
|
||||
ingestion_job_id=latest_job.id if latest_job else None,
|
||||
ingestion_status=latest_job.status if latest_job else None,
|
||||
chunks_indexed=latest_job.points_created if latest_job else 0,
|
||||
)
|
||||
11
src/application/files/storage_keys.py
Normal file
11
src/application/files/storage_keys.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Object-storage key derivation (ADR-0013).
|
||||
|
||||
Object keys are internal identifiers, never the caller-supplied filename.
|
||||
Pure and synchronous.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
|
||||
def source_file_object_key(tenant_id: uuid.UUID, source_file_id: uuid.UUID) -> str:
|
||||
return f"tenants/{tenant_id}/source-files/{source_file_id}/original"
|
||||
205
src/application/files/upload.py
Normal file
205
src/application/files/upload.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""`POST /v1/files` orchestration: the ADR-0017 three-phase upload.
|
||||
|
||||
This service owns two separate short-lived sessions/transactions rather than
|
||||
one request-scoped session, because the request is two units of work
|
||||
(ADR-0012, ADR-0017):
|
||||
|
||||
txn A (short): source_files [+ ingestion_jobs(status='running')], commit
|
||||
no txn: store bytes in MinIO
|
||||
txn B (short): ingestion_jobs -> succeeded/failed, append event, commit
|
||||
|
||||
No Postgres session is open during the MinIO write. A storage failure between
|
||||
txn A and txn B still leaves a durable, inspectable `failed` job — never a
|
||||
job stuck in `running`.
|
||||
|
||||
Parsing/chunking/Qdrant indexing are Phase 4/5 work, not implemented here:
|
||||
this phase stores bytes only, so a successful job reports `chunks_indexed=0`.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import structlog
|
||||
from anyio import CapacityLimiter, to_thread
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from src.application.auth.context import AuthContext
|
||||
from src.application.files.errors import InvalidUploadError
|
||||
from src.application.files.models import UploadResult
|
||||
from src.application.files.storage_keys import source_file_object_key
|
||||
from src.application.files.validation import validate_and_hash_upload
|
||||
from src.application.ports.object_storage import ObjectStorage
|
||||
from src.infrastructure.postgres.repositories import ingestion_jobs as jobs_repo
|
||||
from src.infrastructure.postgres.repositories import source_files as source_files_repo
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def _mark_job_failed(
|
||||
sessionmaker: async_sessionmaker[AsyncSession],
|
||||
*,
|
||||
tenant_id: uuid.UUID,
|
||||
ingestion_job_id: uuid.UUID,
|
||||
error_code: str,
|
||||
error_message: str,
|
||||
) -> None:
|
||||
async with sessionmaker() as session:
|
||||
job = await jobs_repo.mark_terminal(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
ingestion_job_id=ingestion_job_id,
|
||||
status="failed",
|
||||
error_code=error_code,
|
||||
error_message=error_message,
|
||||
)
|
||||
if job is not None:
|
||||
jobs_repo.append_event(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
ingestion_job_id=ingestion_job_id,
|
||||
level="error",
|
||||
stage="received",
|
||||
message=error_message,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def upload_source_file(
|
||||
*,
|
||||
sessionmaker: async_sessionmaker[AsyncSession],
|
||||
storage: ObjectStorage,
|
||||
auth: AuthContext,
|
||||
domain: str,
|
||||
filename: str,
|
||||
data: bytes,
|
||||
max_upload_size_bytes: int,
|
||||
chunking_strategy: str,
|
||||
validation_limiter: CapacityLimiter,
|
||||
) -> UploadResult:
|
||||
domain = domain.strip()
|
||||
if not domain:
|
||||
raise InvalidUploadError("domain is required")
|
||||
|
||||
validated = await to_thread.run_sync(
|
||||
lambda: validate_and_hash_upload(
|
||||
filename=filename, data=data, max_size_bytes=max_upload_size_bytes
|
||||
),
|
||||
limiter=validation_limiter,
|
||||
)
|
||||
|
||||
async with sessionmaker() as session:
|
||||
existing = await source_files_repo.find_active_by_content_hash(
|
||||
session,
|
||||
tenant_id=auth.tenant_id,
|
||||
domain=domain,
|
||||
content_sha256=validated.content_sha256,
|
||||
)
|
||||
|
||||
if existing is not None:
|
||||
latest_job = await jobs_repo.get_latest_for_source_file(
|
||||
session, tenant_id=auth.tenant_id, source_file_id=existing.id
|
||||
)
|
||||
if latest_job is not None and latest_job.status == "succeeded":
|
||||
logger.info(
|
||||
"files.upload.duplicate",
|
||||
tenant_id=str(auth.tenant_id),
|
||||
file_id=str(existing.id),
|
||||
)
|
||||
return UploadResult(
|
||||
file_id=existing.id,
|
||||
ingestion_job_id=latest_job.id,
|
||||
status=latest_job.status,
|
||||
chunks_indexed=latest_job.points_created,
|
||||
is_new_attempt=False,
|
||||
)
|
||||
source_file_id = existing.id
|
||||
object_key = existing.storage_uri or source_file_object_key(
|
||||
auth.tenant_id, source_file_id
|
||||
)
|
||||
else:
|
||||
source_file_id = uuid.uuid4()
|
||||
object_key = source_file_object_key(auth.tenant_id, source_file_id)
|
||||
source_files_repo.create(
|
||||
session,
|
||||
source_file_id=source_file_id,
|
||||
tenant_id=auth.tenant_id,
|
||||
domain=domain,
|
||||
source_filename=filename,
|
||||
source_type=validated.source_type,
|
||||
content_sha256=validated.content_sha256,
|
||||
byte_size=len(data),
|
||||
storage_uri=object_key,
|
||||
created_by_api_key_id=auth.api_key_id,
|
||||
)
|
||||
# `ingestion_jobs.source_file_id` FKs to this row; flush so the
|
||||
# insert below sees it, since the two mapped classes carry no
|
||||
# ORM relationship for the unit of work to order by itself.
|
||||
await session.flush()
|
||||
|
||||
job = jobs_repo.create_running(
|
||||
session,
|
||||
tenant_id=auth.tenant_id,
|
||||
source_file_id=source_file_id,
|
||||
requested_by_api_key_id=auth.api_key_id,
|
||||
chunking_strategy=chunking_strategy,
|
||||
)
|
||||
jobs_repo.append_event(
|
||||
session,
|
||||
tenant_id=auth.tenant_id,
|
||||
ingestion_job_id=job.id,
|
||||
level="info",
|
||||
stage="received",
|
||||
message="upload accepted, storing object",
|
||||
)
|
||||
await session.commit()
|
||||
ingestion_job_id = job.id
|
||||
|
||||
# Phase 2: no Postgres session open across this work (ADR-0017).
|
||||
try:
|
||||
await storage.put_object(key=object_key, data=data, content_type=validated.content_type)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"files.upload.storage_failed",
|
||||
tenant_id=str(auth.tenant_id),
|
||||
file_id=str(source_file_id),
|
||||
ingestion_job_id=str(ingestion_job_id),
|
||||
)
|
||||
await _mark_job_failed(
|
||||
sessionmaker,
|
||||
tenant_id=auth.tenant_id,
|
||||
ingestion_job_id=ingestion_job_id,
|
||||
error_code="storage_upload_failed",
|
||||
error_message=f"failed to store object: {exc}",
|
||||
)
|
||||
raise
|
||||
|
||||
async with sessionmaker() as session:
|
||||
await jobs_repo.mark_terminal(
|
||||
session,
|
||||
tenant_id=auth.tenant_id,
|
||||
ingestion_job_id=ingestion_job_id,
|
||||
status="succeeded",
|
||||
points_created=0,
|
||||
)
|
||||
jobs_repo.append_event(
|
||||
session,
|
||||
tenant_id=auth.tenant_id,
|
||||
ingestion_job_id=ingestion_job_id,
|
||||
level="info",
|
||||
stage="completed",
|
||||
message="object stored; parsing/embedding/indexing not yet implemented",
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"files.upload.succeeded",
|
||||
tenant_id=str(auth.tenant_id),
|
||||
file_id=str(source_file_id),
|
||||
ingestion_job_id=str(ingestion_job_id),
|
||||
)
|
||||
return UploadResult(
|
||||
file_id=source_file_id,
|
||||
ingestion_job_id=ingestion_job_id,
|
||||
status="succeeded",
|
||||
chunks_indexed=0,
|
||||
is_new_attempt=True,
|
||||
)
|
||||
55
src/application/files/validation.py
Normal file
55
src/application/files/validation.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Upload extension/content-type/size validation (ADR-0008).
|
||||
|
||||
Pure and synchronous: no I/O. `content_sha256` computation lives here too —
|
||||
hashing is blocking CPU work (ADR-0017), so the caller runs this whole
|
||||
function through `anyio.to_thread.run_sync` with the ingestion
|
||||
`CapacityLimiter`, the same rule applied to parsing/chunking.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
|
||||
from src.application.files.errors import FileTooLargeError, InvalidUploadError
|
||||
from src.application.files.models import ValidatedUpload
|
||||
from src.application.ingestion.errors import UnsupportedSourceTypeError
|
||||
|
||||
_CONTENT_TYPES = {
|
||||
"csv": "text/csv",
|
||||
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
}
|
||||
_OOXML_MAGIC = b"PK\x03\x04"
|
||||
|
||||
|
||||
def _source_type_from_filename(filename: str) -> str:
|
||||
suffix = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
||||
if suffix == "doc":
|
||||
raise UnsupportedSourceTypeError(
|
||||
"legacy .doc is not ingestible until an out-of-process conversion "
|
||||
"service exists (ADR-0018)"
|
||||
)
|
||||
if suffix not in _CONTENT_TYPES:
|
||||
raise UnsupportedSourceTypeError(f"'.{suffix}' is not an ingestible file type")
|
||||
return suffix
|
||||
|
||||
|
||||
def validate_and_hash_upload(*, filename: str, data: bytes, max_size_bytes: int) -> ValidatedUpload:
|
||||
source_type = _source_type_from_filename(filename)
|
||||
|
||||
if not data:
|
||||
raise InvalidUploadError("uploaded file is empty")
|
||||
if len(data) > max_size_bytes:
|
||||
raise FileTooLargeError(
|
||||
f"upload is {len(data)} bytes, over the {max_size_bytes}-byte limit"
|
||||
)
|
||||
|
||||
is_ooxml = data[:4] == _OOXML_MAGIC
|
||||
if source_type in ("docx", "xlsx") and not is_ooxml:
|
||||
raise InvalidUploadError(f"content does not match the declared .{source_type} extension")
|
||||
if source_type == "csv" and is_ooxml:
|
||||
raise InvalidUploadError("content does not match the declared .csv extension")
|
||||
|
||||
return ValidatedUpload(
|
||||
source_type=source_type,
|
||||
content_type=_CONTENT_TYPES[source_type],
|
||||
content_sha256=hashlib.sha256(data).hexdigest(),
|
||||
)
|
||||
7
src/application/ports/__init__.py
Normal file
7
src/application/ports/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Narrow contracts for external side effects (ADR-0015).
|
||||
|
||||
Ports exist for external side effects/persistence that need a swappable or
|
||||
fake-able boundary — not as a blanket wrapper around every database access.
|
||||
`object_storage.py` is one: MinIO is a real external system with its own
|
||||
failure modes, and ADR-0016 requires a hand-written fake for it in tests.
|
||||
"""
|
||||
14
src/application/ports/object_storage.py
Normal file
14
src/application/ports/object_storage.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""The object-storage port (ADR-0013).
|
||||
|
||||
`src/infrastructure/minio/storage.py` is the production adapter; tests use a
|
||||
hand-written fake (ADR-0016). Application code depends on this Protocol, not
|
||||
on the `minio` SDK.
|
||||
"""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class ObjectStorage(Protocol):
|
||||
async def put_object(self, *, key: str, data: bytes, content_type: str) -> None:
|
||||
"""Store `data` privately under `key`. Overwrites an existing object."""
|
||||
...
|
||||
Reference in New Issue
Block a user