forked from retoor/devplacepy
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
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import pytest
|
||||
|
||||
from devplacepy.database import get_table, init_db
|
||||
from devplacepy.services.containers import store
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _init_db_containers_store():
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
def _make_instance(project_uid, deleted_at=None):
|
||||
uid = generate_uid()
|
||||
get_table("instances").insert(
|
||||
{
|
||||
"uid": uid,
|
||||
"project_uid": project_uid,
|
||||
"deleted_at": deleted_at,
|
||||
"deleted_by": None,
|
||||
"name": f"inst-{uid[:8]}",
|
||||
"status": "running",
|
||||
"desired_state": "running",
|
||||
}
|
||||
)
|
||||
return uid
|
||||
|
||||
|
||||
def test_gc_workspaces_removes_directories_with_no_live_instance(
|
||||
local_db, tmp_path, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr("devplacepy.config.CONTAINER_WORKSPACES_DIR", tmp_path)
|
||||
active_project = "gcws-active-project"
|
||||
orphan_project = "gcws-orphan-project"
|
||||
(tmp_path / active_project).mkdir()
|
||||
(tmp_path / orphan_project).mkdir()
|
||||
_make_instance(active_project)
|
||||
|
||||
removed = store.gc_workspaces()
|
||||
|
||||
assert removed == 1
|
||||
assert (tmp_path / active_project).exists()
|
||||
assert not (tmp_path / orphan_project).exists()
|
||||
|
||||
|
||||
def test_gc_workspaces_ignores_soft_deleted_instances(local_db, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("devplacepy.config.CONTAINER_WORKSPACES_DIR", tmp_path)
|
||||
project = "gcws-soft-deleted-project"
|
||||
(tmp_path / project).mkdir()
|
||||
_make_instance(project, deleted_at="2020-01-01T00:00:00+00:00")
|
||||
|
||||
removed = store.gc_workspaces()
|
||||
|
||||
assert removed == 1
|
||||
assert not (tmp_path / project).exists()
|
||||
|
||||
|
||||
def test_gc_workspaces_dry_run_changes_nothing(local_db, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("devplacepy.config.CONTAINER_WORKSPACES_DIR", tmp_path)
|
||||
orphan_project = "gcws-dry-run-project"
|
||||
(tmp_path / orphan_project).mkdir()
|
||||
|
||||
removed = store.gc_workspaces(dry_run=True)
|
||||
|
||||
assert removed == 1
|
||||
assert (tmp_path / orphan_project).exists()
|
||||
|
||||
|
||||
def test_gc_workspaces_missing_base_dir_is_a_noop(local_db, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"devplacepy.config.CONTAINER_WORKSPACES_DIR", tmp_path / "does-not-exist"
|
||||
)
|
||||
assert store.gc_workspaces() == 0
|
||||
Reference in New Issue
Block a user