You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
158 lines
6.3 KiB
158 lines
6.3 KiB
from __future__ import annotations
|
|
|
|
import builtins
|
|
import json
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
import anyio
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.adapters.storage.local import LocalStorageAdapter
|
|
from app.adapters.storage.s3 import S3StorageAdapter
|
|
from app.core.config import get_settings
|
|
from app.core.ids import new_id
|
|
from app.models.entities import Backend, Blob, BlobReplica, CacheEntry
|
|
from app.repositories.backend_repository import BackendRepository
|
|
from app.repositories.blob_replica_repository import BlobReplicaRepository
|
|
from app.repositories.cache_entry_repository import CacheEntryRepository
|
|
from app.services.policy_service import PlacementDecision, PolicyService
|
|
|
|
|
|
class StorageService:
|
|
def __init__(self, session: AsyncSession) -> None:
|
|
self.session = session
|
|
self.settings = get_settings()
|
|
self.backend_repository = BackendRepository(session)
|
|
self.blob_replica_repository = BlobReplicaRepository(session)
|
|
self.cache_entry_repository = CacheEntryRepository(session)
|
|
self.policy_service = PolicyService(session)
|
|
|
|
async def ensure_cache_dir(self) -> Path:
|
|
cache_dir = Path(self.settings.cache_dir)
|
|
await anyio.to_thread.run_sync(lambda: cache_dir.mkdir(parents=True, exist_ok=True))
|
|
return cache_dir
|
|
|
|
async def get_replication_targets_for_mime_type(
|
|
self,
|
|
mime_type: str | None,
|
|
size_bytes: int | None = None,
|
|
) -> PlacementDecision:
|
|
return await self.policy_service.get_placement_decision(mime_type, size_bytes=size_bytes)
|
|
|
|
async def persist_blob_to_backend(self, blob: Blob, backend: Backend, source_path: str) -> BlobReplica:
|
|
existing = await self.blob_replica_repository.get_by_blob_and_backend(blob.id, backend.id)
|
|
storage_key = self._build_storage_key(blob)
|
|
adapter = self._build_adapter(backend)
|
|
existing_is_usable = False
|
|
if existing is not None and existing.status == "ready":
|
|
try:
|
|
existing_is_usable = await adapter.object_exists(storage_key)
|
|
except Exception:
|
|
existing_is_usable = False
|
|
if existing_is_usable:
|
|
return existing
|
|
|
|
if backend.type == "local":
|
|
await adapter.put_file(source_path, storage_key)
|
|
etag = None
|
|
else:
|
|
result = await adapter.put_file(source_path, storage_key)
|
|
etag = result.get("etag")
|
|
|
|
replica = existing or BlobReplica(
|
|
id=new_id("rep"),
|
|
blob_id=blob.id,
|
|
backend_id=backend.id,
|
|
storage_key=storage_key,
|
|
size_bytes=blob.size_bytes,
|
|
checksum=blob.content_hash,
|
|
status="ready",
|
|
etag=etag,
|
|
last_verified_at=datetime.now(UTC),
|
|
)
|
|
replica.storage_key = storage_key
|
|
replica.status = "ready"
|
|
replica.size_bytes = blob.size_bytes
|
|
replica.checksum = blob.content_hash
|
|
replica.etag = etag
|
|
replica.last_verified_at = datetime.now(UTC)
|
|
|
|
if existing is None:
|
|
created = await self.blob_replica_repository.create(replica)
|
|
else:
|
|
created = await self.blob_replica_repository.save(replica)
|
|
await self.session.commit()
|
|
return created
|
|
|
|
async def materialize_replica_to_local_cache(self, blob: Blob, replica: BlobReplica, backend: Backend) -> Path:
|
|
if backend.type == "local":
|
|
adapter = self._build_adapter(backend)
|
|
return await adapter.resolve_path(replica.storage_key)
|
|
|
|
cache_dir = await self.ensure_cache_dir()
|
|
cache_path = cache_dir / self._build_storage_key(blob)
|
|
adapter = self._build_adapter(backend)
|
|
await adapter.download_file(replica.storage_key, cache_path)
|
|
|
|
existing_entry = await self.cache_entry_repository.get_ready_by_blob(blob.id)
|
|
now = datetime.now(UTC)
|
|
if existing_entry is None:
|
|
entry = CacheEntry(
|
|
id=new_id("cch"),
|
|
blob_id=blob.id,
|
|
local_path=str(cache_path),
|
|
cache_kind="remote-fetch",
|
|
size_bytes=blob.size_bytes,
|
|
status="ready",
|
|
last_accessed_at=now,
|
|
expires_at=None,
|
|
)
|
|
await self.cache_entry_repository.create(entry)
|
|
else:
|
|
existing_entry.local_path = str(cache_path)
|
|
existing_entry.status = "ready"
|
|
existing_entry.size_bytes = blob.size_bytes
|
|
existing_entry.last_accessed_at = now
|
|
await self.cache_entry_repository.save(existing_entry)
|
|
await self.session.commit()
|
|
return cache_path
|
|
|
|
async def resolve_best_local_path(self, blob: Blob) -> Path:
|
|
cached_entry = await self.cache_entry_repository.get_ready_by_blob(blob.id)
|
|
if cached_entry is not None:
|
|
cached_path = Path(cached_entry.local_path)
|
|
if await anyio.to_thread.run_sync(cached_path.exists):
|
|
cached_entry.last_accessed_at = datetime.now(UTC)
|
|
await self.cache_entry_repository.save(cached_entry)
|
|
await self.session.commit()
|
|
return cached_path
|
|
|
|
replicas = await self.blob_replica_repository.list_ready_replicas_with_backends(blob.id)
|
|
for replica, backend in replicas:
|
|
try:
|
|
return await self.materialize_replica_to_local_cache(blob, replica, backend)
|
|
except builtins.FileNotFoundError:
|
|
continue
|
|
except Exception:
|
|
continue
|
|
raise builtins.FileNotFoundError(blob.id)
|
|
|
|
async def check_backend(self, backend: Backend) -> tuple[str, str | None]:
|
|
adapter = self._build_adapter(backend)
|
|
result = await adapter.check()
|
|
return result["status"], result.get("detail")
|
|
|
|
def _build_adapter(self, backend: Backend):
|
|
config = json.loads(backend.config_json)
|
|
if backend.type == "local":
|
|
return LocalStorageAdapter(config["base_path"])
|
|
if backend.type == "s3":
|
|
return S3StorageAdapter(config)
|
|
raise ValueError(f"Unsupported backend type: {backend.type}")
|
|
|
|
@staticmethod
|
|
def _build_storage_key(blob: Blob) -> str:
|
|
content_hash = blob.content_hash
|
|
prefix = content_hash[:2] if len(content_hash) >= 2 else "xx"
|
|
return f"blobs/{prefix}/{content_hash}"
|
|
|