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:
Executable
+210
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env bash
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
set -uo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null || true
|
||||
|
||||
REPO_DIR="${DEVPLACE_REPO_DIR:-/home/retoor/projects/devplacepy}"
|
||||
cd "$REPO_DIR" || { echo "Cannot cd into $REPO_DIR - set DEVPLACE_REPO_DIR"; exit 1; }
|
||||
|
||||
if [ -x "$REPO_DIR/.venv/bin/devplace" ]; then
|
||||
DEVPLACE="$REPO_DIR/.venv/bin/devplace"
|
||||
elif command -v devplace >/dev/null 2>&1; then
|
||||
DEVPLACE="devplace"
|
||||
else
|
||||
echo "Cannot find the devplace binary (looked in $REPO_DIR/.venv/bin and PATH)."
|
||||
echo "Run 'make install' once to create the .venv, or activate it yourself."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -x "$REPO_DIR/.venv/bin/python" ]; then
|
||||
PYTHON="$REPO_DIR/.venv/bin/python"
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
PYTHON="python3"
|
||||
else
|
||||
echo "Cannot find a python interpreter (looked in $REPO_DIR/.venv/bin and PATH)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DIRS=(
|
||||
data/uploads
|
||||
data/uploads/attachments
|
||||
data/uploads/project_files
|
||||
data/zips
|
||||
data/zip_staging
|
||||
data/fork_staging
|
||||
data/seo_reports
|
||||
data/deepsearch
|
||||
data/isslop
|
||||
data/container_workspaces
|
||||
data/workspace_state
|
||||
)
|
||||
|
||||
show_sizes() {
|
||||
echo "--- $1 ---"
|
||||
for d in "${DIRS[@]}"; do
|
||||
if [ -d "$d" ]; then
|
||||
printf " %-10s %s\n" "$(du -sh "$d" 2>/dev/null | cut -f1)" "$d"
|
||||
else
|
||||
printf " %-10s %s\n" "-" "$d (absent)"
|
||||
fi
|
||||
done
|
||||
echo
|
||||
}
|
||||
|
||||
FAILED=()
|
||||
|
||||
run() {
|
||||
echo "=== devplace $* ==="
|
||||
"$DEVPLACE" "$@"
|
||||
local status=$?
|
||||
if [ "$status" -ne 0 ]; then
|
||||
echo "!!! FAILED (exit $status): devplace $*"
|
||||
FAILED+=("devplace $*")
|
||||
fi
|
||||
echo
|
||||
}
|
||||
|
||||
echo "########################################"
|
||||
echo "# DevPlace maintenance cleanup"
|
||||
echo "# backups and news are deliberately excluded"
|
||||
echo "########################################"
|
||||
echo
|
||||
|
||||
show_sizes "BEFORE"
|
||||
|
||||
run attachments prune
|
||||
run zips prune
|
||||
run zips clear
|
||||
run forks prune
|
||||
run forks clear
|
||||
run seo prune
|
||||
run seo clear
|
||||
run seo-meta prune
|
||||
run seo-meta clear
|
||||
run deepsearch prune
|
||||
run deepsearch clear
|
||||
run isslop prune
|
||||
run isslop clear
|
||||
run quiz prune
|
||||
run game market prune
|
||||
run game steals prune
|
||||
run messaging prune-tickets
|
||||
run devii tasks prune
|
||||
run accounts prune
|
||||
run containers prune
|
||||
run containers prune-builds
|
||||
run containers gc-workspaces
|
||||
|
||||
show_sizes "AFTER"
|
||||
|
||||
echo "########################################"
|
||||
echo "# Soft-deleted attachment / project-file blobs still on disk"
|
||||
echo "# (READ-ONLY report - nothing below this line deletes anything;"
|
||||
echo "# none of the commands above purge these, only the admin Trash"
|
||||
echo "# 'Purge' button per-event, or accounts prune, do that today)"
|
||||
echo "########################################"
|
||||
"$PYTHON" - "$REPO_DIR" <<'PY'
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
repo_dir = Path(sys.argv[1])
|
||||
data_dir = Path(__import__("os").environ.get("DEVPLACE_DATA_DIR", str(repo_dir / "data")))
|
||||
db_path = data_dir / "devplace.db"
|
||||
|
||||
|
||||
def human(n):
|
||||
n = float(n)
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if n < 1024:
|
||||
return f"{n:.1f}{unit}"
|
||||
n /= 1024
|
||||
return f"{n:.1f}PB"
|
||||
|
||||
|
||||
if not db_path.exists():
|
||||
print(f" {db_path} does not exist, skipping")
|
||||
raise SystemExit(0)
|
||||
|
||||
con = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
try:
|
||||
cur = con.cursor()
|
||||
|
||||
def columns(table):
|
||||
return {row[1] for row in cur.execute(f"PRAGMA table_info('{table}')")}
|
||||
|
||||
# attachments: data/uploads/attachments/{directory}/{stored_name}, plus any
|
||||
# {stem}_thumb.* sibling thumbnail (see attachments._unlink_attachment_files)
|
||||
cols = columns("attachments")
|
||||
if {"directory", "stored_name", "deleted_at"} <= cols:
|
||||
rows = cur.execute(
|
||||
"SELECT directory, stored_name FROM attachments WHERE deleted_at IS NOT NULL"
|
||||
).fetchall()
|
||||
base = data_dir / "uploads" / "attachments"
|
||||
present, total = 0, 0
|
||||
for directory, stored_name in rows:
|
||||
if not directory or not stored_name:
|
||||
continue
|
||||
fp = base / directory / stored_name
|
||||
if fp.exists():
|
||||
present += 1
|
||||
try:
|
||||
total += fp.stat().st_size
|
||||
except OSError:
|
||||
pass
|
||||
stem = Path(stored_name).stem
|
||||
for thumb in (base / directory).glob(f"{stem}_thumb.*"):
|
||||
try:
|
||||
total += thumb.stat().st_size
|
||||
except OSError:
|
||||
pass
|
||||
print(
|
||||
f" attachments: {len(rows)} soft-deleted row(s), "
|
||||
f"{present} still on disk, {human(total)} reclaimable"
|
||||
)
|
||||
else:
|
||||
print(f" attachments: unexpected schema, columns={sorted(cols)}")
|
||||
|
||||
# project_files: data/uploads/project_files/{directory}/{stored_name},
|
||||
# only rows with is_binary=1 have a blob at all (text lives in the DB row)
|
||||
cols = columns("project_files")
|
||||
if {"directory", "stored_name", "deleted_at", "is_binary"} <= cols:
|
||||
rows = cur.execute(
|
||||
"SELECT directory, stored_name FROM project_files "
|
||||
"WHERE deleted_at IS NOT NULL AND is_binary = 1"
|
||||
).fetchall()
|
||||
base = data_dir / "uploads" / "project_files"
|
||||
present, total = 0, 0
|
||||
for directory, stored_name in rows:
|
||||
if not directory or not stored_name:
|
||||
continue
|
||||
fp = base / directory / stored_name
|
||||
if fp.exists():
|
||||
present += 1
|
||||
try:
|
||||
total += fp.stat().st_size
|
||||
except OSError:
|
||||
pass
|
||||
print(
|
||||
f" project_files: {len(rows)} soft-deleted binary row(s), "
|
||||
f"{present} still on disk, {human(total)} reclaimable"
|
||||
)
|
||||
else:
|
||||
print(f" project_files: unexpected schema, columns={sorted(cols)}")
|
||||
finally:
|
||||
con.close()
|
||||
PY
|
||||
echo
|
||||
|
||||
if [ "${#FAILED[@]}" -gt 0 ]; then
|
||||
echo "########################################"
|
||||
echo "# ${#FAILED[@]} command(s) failed:"
|
||||
for cmd in "${FAILED[@]}"; do
|
||||
echo "# $cmd"
|
||||
done
|
||||
echo "########################################"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All commands completed successfully."
|
||||
Reference in New Issue
Block a user