Files
devplacepy/tests/unit/attachments.py
T
retoorandClaude Sonnet 5 7880bf4b31 Fix container sync races that leaked orphan blobs; add a system-prune CLI command
sync_workspace (user-triggered) and the reconciler's sync_bidirectional_sync
could run concurrently for the same project, and store_upload's read-then-
write on a changed path meant two racing imports each wrote their own blob
while only one ever got referenced - the loser leaked forever. Combined with
no build-artifact exclusion, an actively-compiling workspace hit this
constantly and leaked 5.9M orphan blobs (~96GB) in production before it was
caught.

Closes it at the root: api._sync_dir_bidirectional_locked serializes both
call sites per-project (non-blocking - a project already mid-sync is simply
skipped until the next tick), and IMPORT_SKIP_NAMES/IMPORT_SKIP_EXTENSIONS
keep build output (build/, dist/, *.o, *.pyc, ...) out of the walk entirely.

Recovering what already leaked is a separate concern: a new CLI subcommand
(plus matching make targets) sweeps soft-deleted attachment/project-file
blobs and any blob with zero DB reference at all, plus orphaned container
workspace directories. run_maintenance_cleanup.sh wraps the existing
prune/clear commands for routine disk upkeep.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BWJy6PrMMt5hwWxQwia2rd
2026-09-08 03:43:49 +02:00

150 lines
5.2 KiB
Python

# retoor <retoor@molodetz.nl>
from devplacepy import attachments as att
from devplacepy.database import get_table
from devplacepy.utils import generate_uid
def _make_attachment(directory, stored_name, deleted_at=None):
uid = generate_uid()
get_table("attachments").insert(
{
"uid": uid,
"deleted_at": deleted_at,
"deleted_by": None,
"directory": directory,
"stored_name": stored_name,
"target_type": "post",
"target_uid": generate_uid(),
}
)
return uid
def _write_blob(base, directory, stored_name, content=b"data"):
file_dir = base / directory
file_dir.mkdir(parents=True, exist_ok=True)
(file_dir / stored_name).write_bytes(content)
return file_dir / stored_name
def test_purge_soft_deleted_attachments_removes_row_and_blob(local_db, tmp_path, monkeypatch):
monkeypatch.setattr(att, "ATTACHMENTS_DIR", tmp_path)
directory = "ab/cd"
stored_name = f"{generate_uid()}.png"
_write_blob(tmp_path, directory, stored_name, b"x" * 100)
uid = _make_attachment(directory, stored_name, deleted_at="2020-01-01T00:00:00+00:00")
removed, freed = att.purge_soft_deleted_attachments()
assert removed >= 1
assert freed == 100
assert get_table("attachments").find_one(uid=uid) is None
assert not (tmp_path / directory / stored_name).exists()
def test_purge_soft_deleted_attachments_dry_run_changes_nothing(local_db, tmp_path, monkeypatch):
monkeypatch.setattr(att, "ATTACHMENTS_DIR", tmp_path)
directory = "ab/cd"
stored_name = f"{generate_uid()}.png"
_write_blob(tmp_path, directory, stored_name, b"x" * 50)
uid = _make_attachment(directory, stored_name, deleted_at="2020-01-01T00:00:00+00:00")
removed, freed = att.purge_soft_deleted_attachments(dry_run=True)
assert removed >= 1
assert freed == 50
assert get_table("attachments").find_one(uid=uid) is not None
assert (tmp_path / directory / stored_name).exists()
def test_purge_soft_deleted_attachments_ignores_live_rows(local_db, tmp_path, monkeypatch):
monkeypatch.setattr(att, "ATTACHMENTS_DIR", tmp_path)
directory = "ab/cd"
stored_name = f"{generate_uid()}.png"
_write_blob(tmp_path, directory, stored_name)
uid = _make_attachment(directory, stored_name, deleted_at=None)
att.purge_soft_deleted_attachments()
assert get_table("attachments").find_one(uid=uid) is not None
assert (tmp_path / directory / stored_name).exists()
def test_purge_soft_deleted_attachments_also_removes_thumbnail(local_db, tmp_path, monkeypatch):
monkeypatch.setattr(att, "ATTACHMENTS_DIR", tmp_path)
directory = "ab/cd"
stem = generate_uid()
stored_name = f"{stem}.jpg"
_write_blob(tmp_path, directory, stored_name, b"x" * 20)
_write_blob(tmp_path, directory, f"{stem}_thumb.jpg", b"y" * 5)
_make_attachment(directory, stored_name, deleted_at="2020-01-01T00:00:00+00:00")
removed, freed = att.purge_soft_deleted_attachments()
assert removed >= 1
assert freed == 25
assert not (tmp_path / directory / f"{stem}_thumb.jpg").exists()
def test_sweep_orphan_attachment_blobs_removes_unreferenced_file(local_db, tmp_path, monkeypatch):
monkeypatch.setattr(att, "ATTACHMENTS_DIR", tmp_path)
directory = "ab/cd"
orphan_name = f"{generate_uid()}.o"
_write_blob(tmp_path, directory, orphan_name, b"y" * 30)
removed, freed = att.sweep_orphan_attachment_blobs()
assert removed == 1
assert freed == 30
assert not (tmp_path / directory / orphan_name).exists()
def test_sweep_orphan_attachment_blobs_keeps_referenced_file_and_its_thumbnail(
local_db, tmp_path, monkeypatch
):
monkeypatch.setattr(att, "ATTACHMENTS_DIR", tmp_path)
directory = "ab/cd"
stem = generate_uid()
stored_name = f"{stem}.jpg"
thumb_name = f"{stem}_thumb.jpg"
_write_blob(tmp_path, directory, stored_name)
_write_blob(tmp_path, directory, thumb_name)
_make_attachment(directory, stored_name, deleted_at=None)
removed, freed = att.sweep_orphan_attachment_blobs()
assert removed == 0
assert freed == 0
assert (tmp_path / directory / stored_name).exists()
assert (tmp_path / directory / thumb_name).exists()
def test_sweep_orphan_attachment_blobs_keeps_blob_referenced_only_by_soft_deleted_row(
local_db, tmp_path, monkeypatch
):
monkeypatch.setattr(att, "ATTACHMENTS_DIR", tmp_path)
directory = "ab/cd"
stored_name = f"{generate_uid()}.png"
_write_blob(tmp_path, directory, stored_name)
_make_attachment(directory, stored_name, deleted_at="2020-01-01T00:00:00+00:00")
removed, freed = att.sweep_orphan_attachment_blobs()
assert removed == 0
assert freed == 0
assert (tmp_path / directory / stored_name).exists()
def test_sweep_orphan_attachment_blobs_dry_run_changes_nothing(local_db, tmp_path, monkeypatch):
monkeypatch.setattr(att, "ATTACHMENTS_DIR", tmp_path)
directory = "ab/cd"
orphan_name = f"{generate_uid()}.o"
_write_blob(tmp_path, directory, orphan_name, b"z" * 10)
removed, freed = att.sweep_orphan_attachment_blobs(dry_run=True)
assert removed == 1
assert freed == 10
assert (tmp_path / directory / orphan_name).exists()