daf6a0ade9
plan ds-s1-backend-1 잔여 구현 (A·C-1 은 16b0fe1):
- B 중복검사: services/dedup.py (OFF-list law_monitor 공용) + 업로드 채움(B-1)
+ GET /documents/duplicates(B-2) + post-upload near-dup 비동기(B-3)
+ backfill_dedup.py(B-4) + 야간 dedup_reconcile 잡(03:30 KST 멱등 재계산)
- C MD-first: marker_worker office/hwp 분기 _process_office(C-2) + md_status
상태머신 postcondition success|failed(C-5) + backfill_nonpdf_markdown.py(C-4)
+ requirements markitdown
- D 스토리지: services/storage ABC+Range 계약 / LocalBackend / NasApiBackend 503
(D-1) + /file resolver 경유, 로컬 동작 불변(D-2)
- E 운영: pre-change pg_dump + rollback_287.sql + apply runbook(E-3) + 테스트(E-1)
비파괴 불변식 유지(기존 응답 shape 무변경, md_status success→completed read-time 매핑).
어드버서리얼 리뷰 확정 1건(soft-delete canonical 승격 시 stale duplicate_of) → B-1
승격 정규화 + 야간 재계산으로 정합.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
51 lines
1.7 KiB
Python
51 lines
1.7 KiB
Python
"""LocalBackend — 현행 NAS NFS(volume4) 마운트. /file 동작 불변 (plan D-1)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from collections.abc import AsyncIterator
|
|
from pathlib import Path
|
|
|
|
from .base import StatResult, StorageBackend
|
|
|
|
_STREAM_CHUNK = 256 * 1024
|
|
|
|
|
|
class LocalBackend(StorageBackend):
|
|
"""루트(=settings.nas_mount_path) 하위 상대경로를 로컬 파일시스템으로 해석."""
|
|
|
|
is_local = True
|
|
|
|
def __init__(self, root: str) -> None:
|
|
self._root = Path(root)
|
|
|
|
def local_path(self, rel_path: str) -> os.PathLike[str]:
|
|
return self._root / rel_path
|
|
|
|
async def stat(self, rel_path: str) -> StatResult:
|
|
p = self._root / rel_path
|
|
if not p.exists():
|
|
return StatResult(exists=False, size=0)
|
|
return StatResult(exists=True, size=p.stat().st_size)
|
|
|
|
async def stream(
|
|
self, rel_path: str, *, start: int | None = None, end: int | None = None
|
|
) -> AsyncIterator[bytes]:
|
|
"""로컬 파일을 청크 stream (Range 지원). /file 의 로컬 경로는 FileResponse 가
|
|
Range 를 자동 처리하므로 이 메서드는 인터페이스 대칭/원격 동등성을 위한 구현."""
|
|
p = self._root / rel_path
|
|
with p.open("rb") as f:
|
|
if start:
|
|
f.seek(start)
|
|
remaining = None if end is None else (end - (start or 0) + 1)
|
|
while True:
|
|
to_read = _STREAM_CHUNK if remaining is None else min(_STREAM_CHUNK, remaining)
|
|
if to_read <= 0:
|
|
break
|
|
data = f.read(to_read)
|
|
if not data:
|
|
break
|
|
yield data
|
|
if remaining is not None:
|
|
remaining -= len(data)
|