Skip to content

Commit c076f1d

Browse files
committed
fix(files): clean up orphaned linked objects
1 parent 9f9baf7 commit c076f1d

3 files changed

Lines changed: 70 additions & 6 deletions

File tree

src/services/file.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,16 @@ async def _has_link_references(self, session_id: str, file_id: str) -> bool:
108108
links_key = self._get_file_links_key(session_id, file_id)
109109
return bool(await self.redis_client.smembers(links_key))
110110

111+
async def _delete_object(self, object_key: str) -> None:
112+
"""Delete a backing object from MinIO."""
113+
loop = asyncio.get_event_loop()
114+
await loop.run_in_executor(
115+
None,
116+
self.minio_client.remove_object,
117+
self.bucket_name,
118+
object_key,
119+
)
120+
111121
async def _find_linked_file(
112122
self, target_session_id: str, source_session_id: str, source_file_id: str
113123
) -> Optional[str]:
@@ -173,7 +183,7 @@ async def get_file_metadata(
173183
# Convert string values back to appropriate types
174184
if "size" in metadata:
175185
metadata["size"] = int(metadata["size"])
176-
if "created_at" in metadata:
186+
if "created_at" in metadata and isinstance(metadata["created_at"], str):
177187
metadata["created_at"] = datetime.fromisoformat(metadata["created_at"])
178188

179189
return metadata
@@ -485,6 +495,31 @@ async def delete_file(self, session_id: str, file_id: str) -> bool:
485495
session_id=session_id,
486496
file_id=file_id,
487497
)
498+
499+
source_metadata = await self.get_file_metadata(
500+
metadata["source_session_id"],
501+
metadata["source_file_id"],
502+
)
503+
if source_metadata is None and not await self._has_link_references(
504+
metadata["source_session_id"],
505+
metadata["source_file_id"],
506+
):
507+
try:
508+
await self._delete_object(metadata["object_key"])
509+
logger.debug(
510+
"Deleted orphaned shared object after final alias cleanup",
511+
source_session_id=metadata["source_session_id"],
512+
source_file_id=metadata["source_file_id"],
513+
object_key=metadata["object_key"],
514+
)
515+
except S3Error as e:
516+
logger.warning(
517+
"Failed to delete orphaned shared object",
518+
source_session_id=metadata["source_session_id"],
519+
source_file_id=metadata["source_file_id"],
520+
object_key=metadata["object_key"],
521+
error=str(e),
522+
)
488523
return True
489524

490525
if await self._has_link_references(session_id, file_id):
@@ -500,10 +535,7 @@ async def delete_file(self, session_id: str, file_id: str) -> bool:
500535

501536
try:
502537
# Delete from MinIO
503-
loop = asyncio.get_event_loop()
504-
await loop.run_in_executor(
505-
None, self.minio_client.remove_object, self.bucket_name, object_key
506-
)
538+
await self._delete_object(object_key)
507539

508540
# Delete metadata from Redis
509541
await self._delete_file_metadata(session_id, file_id)

src/services/programmatic.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import uuid
1616
from dataclasses import dataclass
1717
from pathlib import Path
18-
from typing import Any, Callable, Dict, List, Optional
18+
from typing import Callable, Dict, List, Optional
1919

2020
import structlog
2121

tests/unit/test_file_service.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,3 +402,35 @@ async def test_delete_source_file_keeps_object_when_aliases_exist(
402402
assert result is True
403403
mock_minio_client.remove_object.assert_not_called()
404404
mock_redis_client.delete.assert_called_once()
405+
406+
@pytest.mark.asyncio
407+
async def test_delete_last_linked_file_cleans_orphaned_shared_object(
408+
self, file_service, mock_minio_client, mock_redis_client
409+
):
410+
"""The final alias cleanup should delete the shared object once the source is gone."""
411+
mock_redis_client.hgetall.side_effect = [
412+
{
413+
"file_id": "linked-file",
414+
"filename": "report.csv",
415+
"content_type": "text/csv",
416+
"object_key": "sessions/source/uploads/source-file",
417+
"session_id": "target-session",
418+
"created_at": datetime.utcnow().isoformat(),
419+
"size": "12",
420+
"path": "/report.csv",
421+
"type": "linked_input",
422+
"source_session_id": "source-session",
423+
"source_file_id": "source-file",
424+
"is_read_only": "1",
425+
},
426+
{},
427+
]
428+
mock_redis_client.smembers.return_value = set()
429+
430+
result = await file_service.delete_file("target-session", "linked-file")
431+
432+
assert result is True
433+
mock_minio_client.remove_object.assert_called_once_with(
434+
file_service.bucket_name,
435+
"sessions/source/uploads/source-file",
436+
)

0 commit comments

Comments
 (0)