feat(serve): debounced site rebuild — burst uploads trigger one build, not N

Replace per-upload Astro build threads with a single background worker
(_site_rebuild_worker) that waits on an event, sleeps 60 s to let upload
bursts settle, then runs one full build + rsync. 271 concurrent uploads now
produce one build instead of 271 serialised builds, eliminating the OOM kill.
--webroot is re-enabled; merge-only path still runs immediately per upload.

Also: date filter row added to ActivityFeed.svelte (sport + date presets
with dynamic year pills); deploy/vps gitignored for VPS config backups.
This commit is contained in:
Davide Scaini
2026-04-30 21:23:29 +02:00
parent 5e36806392
commit f6e9fe8198
3 changed files with 128 additions and 73 deletions
+77 -68
View File
@@ -204,8 +204,8 @@ app = FastAPI(title="BincioActivity Serve")
@app.on_event("startup")
async def _cleanup_orphaned_tmp_zips() -> None:
"""Remove tmp*.zip files left in user data dirs by the pre-fix upload handler."""
async def _on_startup() -> None:
"""Startup tasks: clean orphaned tmp zips; launch site-rebuild worker if --webroot set."""
import glob as _glob
data_dir = _get_data_dir()
for p in _glob.glob(str(data_dir / "*" / "tmp*.zip")):
@@ -213,6 +213,8 @@ async def _cleanup_orphaned_tmp_zips() -> None:
Path(p).unlink()
except Exception:
pass
if webroot is not None:
threading.Thread(target=_site_rebuild_worker, daemon=True, name="site-rebuild").start()
app.add_middleware(GZipMiddleware, minimum_size=1024)
@@ -329,20 +331,65 @@ def _unique_image_name(directory: Path, filename: str) -> str:
# ── Post-write rebuild ────────────────────────────────────────────────────────
# Serialises concurrent rebuilds — only one full build runs at a time.
# A second upload that arrives while a build is in progress will queue and
# run after the first finishes, picking up all data written in between.
# Serialises per-user merge subprocesses — concurrent merge_all runs on the
# same user dir would corrupt _merged/activities/.
_rebuild_lock = threading.Lock()
# Signals the site-rebuild worker that at least one merge has completed.
# Using an Event as a boolean flag: set() by any merge, cleared by the worker.
_site_rebuild_event = threading.Event()
def _site_rebuild_worker() -> None:
"""Single background thread: debounced Astro build + rsync after uploads.
Waits for _site_rebuild_event, sleeps 60 s to let upload bursts settle,
then runs one full build. 271 concurrent uploads → 1 build, not 271.
Uploads that arrive during the build set the event again, so a follow-up
build starts after the current one finishes.
"""
_webroot = str(webroot)
_data_dir = str(data_dir)
_site_dir = str(site_dir)
uv = shutil.which("uv") or str(Path.home() / ".local" / "bin" / "uv")
while True:
_site_rebuild_event.wait()
_site_rebuild_event.clear()
time.sleep(60) # collect burst uploads
_site_rebuild_event.clear() # discard signals from the sleep window
log.info("site-rebuild: starting full build + rsync to %s", _webroot)
try:
result = subprocess.run(
[uv, "run", "bincio", "render",
"--data-dir", _data_dir,
"--site-dir", _site_dir],
capture_output=True,
text=True,
)
if result.returncode != 0:
log.error("site-rebuild: build failed (rc=%d):\n%s\n%s",
result.returncode, result.stdout, result.stderr)
continue
dist_data = Path(_site_dir) / "dist" / "data"
if dist_data.exists():
shutil.rmtree(dist_data)
rsync = subprocess.run(
["rsync", "-a", "--delete", "--exclude=data/",
f"{_site_dir}/dist/", _webroot + "/"],
capture_output=True,
text=True,
)
if rsync.returncode != 0:
log.error("site-rebuild: rsync failed (rc=%d):\n%s\n%s",
rsync.returncode, rsync.stdout, rsync.stderr)
else:
log.info("site-rebuild: done")
except Exception:
log.exception("site-rebuild: unexpected error")
def _trigger_rebuild(handle: str) -> None:
"""Asynchronously re-merge and optionally rebuild + rsync the site.
- Without --webroot: fast path — merges sidecars + rewrites root manifest
(~1 s). New activity pages require the nginx try_files fallback to work.
- With --webroot: full Astro build + rsync to the nginx webroot (~3060 s,
serialised). New activity pages are immediately accessible.
"""
"""Merge sidecars for handle asynchronously; signal the site-rebuild worker."""
if site_dir is None:
return
if not _VALID_HANDLE.match(handle):
@@ -351,66 +398,28 @@ def _trigger_rebuild(handle: str) -> None:
uv = shutil.which("uv") or str(Path.home() / ".local" / "bin" / "uv")
_data_dir = str(data_dir)
_site_dir = str(site_dir)
_webroot = str(webroot) if webroot else None
_handle = handle
def _run() -> None:
try:
if _webroot is None:
# Fast: only update data, skip Astro build.
# Serialised with the same lock: merge_all wipes and recreates
# _merged/activities/ — concurrent runs would corrupt each other.
log.info("rebuild[%s]: merge-only (no webroot)", _handle)
with _rebuild_lock:
result = subprocess.run(
[uv, "run", "bincio", "render",
"--data-dir", _data_dir,
"--site-dir", _site_dir,
"--handle", _handle,
"--no-build"],
capture_output=True,
text=True,
)
if result.returncode != 0:
log.error("rebuild[%s]: merge failed (rc=%d):\n%s\n%s",
_handle, result.returncode, result.stdout, result.stderr)
else:
log.info("rebuild[%s]: merge done", _handle)
else:
# Full build + rsync — serialised so concurrent uploads don't race
log.info("rebuild[%s]: full build + rsync to %s", _handle, _webroot)
with _rebuild_lock:
result = subprocess.run(
[uv, "run", "bincio", "render",
"--data-dir", _data_dir,
"--site-dir", _site_dir,
"--handle", _handle],
capture_output=True,
text=True,
)
if result.returncode != 0:
log.error("rebuild[%s]: build failed (rc=%d):\n%s\n%s",
_handle, result.returncode, result.stdout, result.stderr)
else:
log.info("rebuild[%s]: build done, rsyncing", _handle)
# Prune dist/data/ before rsync: Astro resolves the
# public/data symlink and copies all activity JSON into
# dist/, but nginx already serves /data/ directly from
# the live data dir — rsyncing it would duplicate GBs.
dist_data = Path(_site_dir) / "dist" / "data"
if dist_data.exists():
shutil.rmtree(dist_data)
rsync = subprocess.run(
["rsync", "-a", "--delete", "--exclude=data/",
f"{_site_dir}/dist/", _webroot + "/"],
capture_output=True,
text=True,
)
if rsync.returncode != 0:
log.error("rebuild[%s]: rsync failed (rc=%d):\n%s\n%s",
_handle, rsync.returncode, rsync.stdout, rsync.stderr)
else:
log.info("rebuild[%s]: rsync done", _handle)
log.info("rebuild[%s]: merge-only", _handle)
with _rebuild_lock:
result = subprocess.run(
[uv, "run", "bincio", "render",
"--data-dir", _data_dir,
"--site-dir", _site_dir,
"--handle", _handle,
"--no-build"],
capture_output=True,
text=True,
)
if result.returncode != 0:
log.error("rebuild[%s]: merge failed (rc=%d):\n%s\n%s",
_handle, result.returncode, result.stdout, result.stderr)
else:
log.info("rebuild[%s]: merge done", _handle)
if webroot is not None:
_site_rebuild_event.set()
except Exception:
log.exception("rebuild[%s]: unexpected error", _handle)