feat(06.2-03): backend — cloud-aware delete routing + skip_quota + remove_only param

- storage.delete_document gains skip_quota=False param; quota decrement gated on it
- DELETE /api/documents/{id} gains remove_only=bool query param
- Cloud docs (storage_backend != minio): attempt cloud backend delete_object first
  - On failure: return HTTP 200 {success: false, cloud_delete_failed: true} (not 4xx)
  - On success or remove_only: delete DB row with skip_quota=True
- Cloud creds/exception message never included in response body (T-06.2-03-02)
- Promote 3 xfail stubs to real tests (propagates, failure, remove_only)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-05-31 15:09:44 +02:00
parent e812922a26
commit 95c7ed786a
3 changed files with 139 additions and 23 deletions
+17 -13
View File
@@ -140,12 +140,15 @@ async def list_metadata(
return rows
async def delete_document(session: AsyncSession, doc_id: str) -> bool:
async def delete_document(session: AsyncSession, doc_id: str, skip_quota: bool = False) -> bool:
"""Delete a document's MinIO object and its PostgreSQL row.
Returns False if the document is not found; True on success.
MinIO deletion failures are logged to stderr but do not prevent the DB row
deletion (the bytes may already be gone).
skip_quota=True skips the quota decrement — used for cloud-stored documents
that were never charged against the user's MinIO quota (T-06.2-03-01).
"""
try:
uid = uuid.UUID(doc_id)
@@ -161,18 +164,19 @@ async def delete_document(session: AsyncSession, doc_id: str) -> bool:
except Exception as exc:
print(f"[storage] WARNING: MinIO delete_object failed for {doc.object_key!r}: {exc}", file=sys.stderr)
# Atomic quota decrement (STORE-06, D-07).
# user_id is always set post-migration (Plan 03-03+) — guard removed.
# Use CASE WHEN instead of GREATEST() for SQLite compatibility
# (PostgreSQL supports both; SQLite lacks the GREATEST scalar function).
await session.execute(
text(
"UPDATE quotas "
"SET used_bytes = CASE WHEN used_bytes > :delta THEN used_bytes - :delta ELSE 0 END "
"WHERE user_id = :uid"
),
{"delta": doc.size_bytes, "uid": str(doc.user_id)},
)
if not skip_quota:
# Atomic quota decrement (STORE-06, D-07).
# user_id is always set post-migration (Plan 03-03+) — guard removed.
# Use CASE WHEN instead of GREATEST() for SQLite compatibility
# (PostgreSQL supports both; SQLite lacks the GREATEST scalar function).
await session.execute(
text(
"UPDATE quotas "
"SET used_bytes = CASE WHEN used_bytes > :delta THEN used_bytes - :delta ELSE 0 END "
"WHERE user_id = :uid"
),
{"delta": doc.size_bytes, "uid": str(doc.user_id)},
)
await session.delete(doc)
await session.commit()