Files
devplacepy/run_maintenance_cleanup.sh
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

211 lines
6.0 KiB
Bash
Executable File

#!/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."