Files
yunke/yunke-server/app/storage/object_store.py
T
浅念 854efd3119 feat: idempotent upsert repos + object store
Adds the storage layer that bridges scrapers (which yield normalized dicts)
to the DB and the local media store.

app/storage/repos.py
  * upsert_customer / upsert_customers / upsert_messages / upsert_wechat_account
    / upsert_staff_from_account, all taking an AsyncSession + scraper-shaped dicts.
  * Dialect-aware Core upsert: SQLite on_conflict_do_update for tests,
    MySQL on_duplicate_key_update for prod; dialect detected via session.bind.name.
    _build_upsert_stmt(model, values, dialect) is the reusable helper.
  * Type coercion: _coerce_bool / _coerce_int (module-level, unit-tested) map
    is_send/is_del/is_block/is_on_job int-as-bool to bool and gender/type
    numeric strings to int; None preserved; raw_json kept verbatim.
  * For M1 the Message model maps to a single physical `message` table;
    per-month sharding (message_table_name helper) is a LATER task (YAGNI).

app/storage/object_store.py
  * Local-filesystem ObjectStore with put_bytes / put_stream / put_by_hash /
    exists / get_path, all dispatched via asyncio.to_thread so disk I/O is
    genuinely async.
  * put_by_hash computes sha256 and writes only if absent (content-addressed
    dedup); layout is base_dir/<first 2 hash chars>/<hash><ext>.
  * Default base dir is Settings.media_dir; the interface is the seam a
    future MinIO/S3 backend will swap in behind.

tests: 24 new tests (in-memory SQLite for repos; tmp_path for object_store)
covering idempotency (same PK twice -> 1 row, updated), type coercion, batch
dedup (3 messages / 1 dup msg_id -> 2 rows), and hash dedup (corrupt-file
probe proves second put_by_hash does not rewrite). ruff clean; full suite
67/67 green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 11:55:50 +08:00

156 lines
5.7 KiB
Python

"""Local-filesystem object store with sha256 content-addressed dedup.
This is the M1 storage backend for media blobs (image / voice / video / file
downloads referenced by :class:`app.db.models.Media`). The interface is the
seam a future MinIO/S3 backend will swap in behind:
* :meth:`ObjectStore.put_bytes` — write bytes under an opaque key.
* :meth:`ObjectStore.put_stream` — write an async-iterable stream under a key.
* :meth:`ObjectStore.put_by_hash` — content-addressed put with sha256 dedup:
computes the hash, and only writes when no file with that hash exists. The
path layout is ``base_dir/<first 2 hash chars>/<hash><ext>`` (a simple
fan-out directory so no single directory holds millions of entries).
* :meth:`ObjectStore.exists` / :meth:`get_path` — predicate / pure-path helpers.
All disk I/O is dispatched via :func:`asyncio.to_thread`, so the methods are
genuinely async and safe to call from a FastAPI handler / scraper loop without
blocking the event loop on a slow disk.
The default base directory is :attr:`Settings.media_dir`.
"""
from __future__ import annotations
import asyncio
import hashlib
from collections.abc import AsyncIterator
from pathlib import Path
from app.config import get_settings
__all__ = ["ObjectStore"]
class ObjectStore:
"""Async local-filesystem object store with sha256-based dedup."""
def __init__(self, base_dir: Path | None = None) -> None:
"""Create a store rooted at ``base_dir``.
Defaults to :attr:`Settings.media_dir` when omitted so callers can
``ObjectStore()`` and land in the configured media location.
"""
self.base_dir: Path = Path(base_dir) if base_dir is not None else get_settings().media_dir
# ------------------------------------------------------------------
# Path helpers (pure, no I/O)
# ------------------------------------------------------------------
def get_path(self, key: str) -> Path:
"""Return ``base_dir / <key>`` (a pure-path computation; no I/O).
``key`` may contain ``/`` separators (e.g. ``"foo/bar.txt"``); they
become real subdirectories on disk. The key is treated as opaque — no
hash is computed.
"""
return self.base_dir / key
def _hash_path(self, sha256_hex: str, ext: str) -> Path:
"""Return ``base_dir / <first 2 hash chars> / <hash><ext>``."""
if ext and not ext.startswith("."):
ext = "." + ext
return self.base_dir / sha256_hex[:2] / f"{sha256_hex}{ext}"
# ------------------------------------------------------------------
# Writes
# ------------------------------------------------------------------
async def put_bytes(self, key: str, data: bytes) -> Path:
"""Write ``data`` under ``key`` (overwrites if present).
Returns the resolved path. Always writes; for content-addressed dedup
use :meth:`put_by_hash`.
"""
def _write() -> Path:
path = self.get_path(key)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return path
return await asyncio.to_thread(_write)
async def put_stream(
self, key: str, stream: AsyncIterator[bytes]
) -> Path:
"""Concatenate an async-byte-stream under ``key`` (overwrites).
Chunks are consumed in the event-loop thread; the file open + each
write is dispatched to a worker thread to avoid blocking on disk.
"""
path = self.get_path(key)
def _mkdir() -> None:
path.parent.mkdir(parents=True, exist_ok=True)
await asyncio.to_thread(_mkdir)
def _write_chunk(buf: list[bytes], f) -> None:
for chunk in buf:
f.write(chunk)
buf.clear()
# Stream chunks in the event loop but push each disk write to a
# thread so we never block on slow storage mid-iteration.
def _open_and_prep():
return open(path, "wb")
f = await asyncio.to_thread(_open_and_prep)
try:
buffer: list[bytes] = []
async for chunk in stream:
buffer.append(chunk)
# Flush once we have a meaningful amount buffered.
if sum(len(c) for c in buffer) >= 65536:
await asyncio.to_thread(_write_chunk, buffer, f)
if buffer:
await asyncio.to_thread(_write_chunk, buffer, f)
finally:
await asyncio.to_thread(f.close)
return path
async def put_by_hash(
self, data: bytes, *, ext: str = ""
) -> tuple[str, Path]:
"""Content-addressed put with sha256 dedup.
Computes ``sha256(data)``, derives ``base_dir/<2 chars>/<hash><ext>``,
and writes the blob **only if no file with that path already exists**
(deduplication across messages that reference the same blob via md5).
Returns ``(sha256_hex, path)``. On a hit the existing file is left
untouched (no rewrite), so dedup is observable.
"""
sha256_hex = hashlib.sha256(data).hexdigest()
path = self._hash_path(sha256_hex, ext)
def _write_if_missing() -> Path:
if path.exists():
return path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return path
await asyncio.to_thread(_write_if_missing)
return sha256_hex, path
# ------------------------------------------------------------------
# Reads / predicates
# ------------------------------------------------------------------
async def exists(self, key: str) -> bool:
"""True iff ``key`` exists on disk under ``base_dir``."""
return await asyncio.to_thread(self.get_path(key).exists)