From 1587d1cdf38c3eee42bfaf39ae7a89681290ab7a Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Mon, 13 Apr 2026 12:35:05 +0200 Subject: [PATCH] =?UTF-8?q?=20=20-=20brut:=20=5Fmerged/index.json=20has=20?= =?UTF-8?q?586=20activities=20=E2=80=94=20the=20count=20when=20merge=5Fall?= =?UTF-8?q?=20last=20ran.=20The=20SSE=20rebuild=20bug=20(already=20fixed)?= =?UTF-8?q?=20meant=20it=20never=20re-ran=20after=20the=20full=20Strava=20?= =?UTF-8?q?sync=20=20=20=20added=203256=20more.=20=20=20-=20danilo:=20=5Fm?= =?UTF-8?q?erged/=20is=208=20KB=20=E2=80=94=20basically=20empty.=20merge?= =?UTF-8?q?=5Fall=20likely=20ran=20concurrently=20(multiple=20file=20uploa?= =?UTF-8?q?ds=20trigger=20multiple=20rebuilds=20without=20a=20lock=20in=20?= =?UTF-8?q?--no-build=20mode),=20=20=20causing=20a=20race=20where=20shutil?= =?UTF-8?q?.rmtree(merged=5Facts)=20from=20one=20run=20wiped=20what=20anot?= =?UTF-8?q?her=20run=20was=20writing.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes: serialize --no-build rebuilds with the same lock, and add a "Rebuild" button to the admin page. Root causes fixed: 1. merge_all race condition — --no-build rebuilds now hold _rebuild_lock, same as full builds 2. The SSE rebuild-trigger bug (already fixed earlier) was brut's original cause --- bincio/serve/server.py | 37 +++++++++++++++++++-------- site/src/pages/admin/index.astro | 43 +++++++++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 4c74fbf..9a94e70 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -243,16 +243,19 @@ def _trigger_rebuild(handle: str) -> None: def _run() -> None: try: if _webroot is None: - # Fast: only update data, skip Astro build - subprocess.run( - [uv, "run", "bincio", "render", - "--data-dir", _data_dir, - "--site-dir", _site_dir, - "--handle", _handle, - "--no-build"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) + # 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. + with _rebuild_lock: + subprocess.run( + [uv, "run", "bincio", "render", + "--data-dir", _data_dir, + "--site-dir", _site_dir, + "--handle", _handle, + "--no-build"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) else: # Full build + rsync — serialised so concurrent uploads don't race with _rebuild_lock: @@ -500,6 +503,20 @@ async def admin_disk(bincio_session: Optional[str] = Cookie(default=None)) -> JS }) +@app.post("/api/admin/users/{handle}/rebuild") +async def admin_rebuild( + handle: str, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Trigger a merge_all + site rebuild for a user. Admin only.""" + _require_admin(bincio_session) + user_dir = _get_data_dir() / handle + if not user_dir.is_dir(): + raise HTTPException(404, f"No data directory for user '{handle}'") + _trigger_rebuild(handle) + return JSONResponse({"ok": True}) + + @app.delete("/api/admin/users/{handle}/activities") async def admin_delete_activities( handle: str, diff --git a/site/src/pages/admin/index.astro b/site/src/pages/admin/index.astro index 9070c77..c67ac33 100644 --- a/site/src/pages/admin/index.astro +++ b/site/src/pages/admin/index.astro @@ -115,15 +115,50 @@ import Base from '../../layouts/Base.astro'; ${u.merged_mb > 0 ? fmt(u.merged_mb) : '—'} ${u.images_mb > 0 ? fmt(u.images_mb) : '—'} - +
+ + +
`; }).join(''); + tbodyEl.querySelectorAll('.rebuild-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const h = btn.dataset.handle!; + btn.disabled = true; + btn.textContent = 'Queued…'; + try { + const r = await fetch(`/api/admin/users/${h}/rebuild`, { + method: 'POST', + credentials: 'include', + }); + if (r.ok) { + btn.textContent = 'Rebuilding…'; + btn.classList.add('text-blue-400'); + // Rebuild is async — reload sizes after a delay + setTimeout(() => load(), 8000); + } else { + btn.disabled = false; + btn.textContent = 'Error'; + btn.classList.add('text-red-400'); + } + } catch { + btn.disabled = false; + btn.textContent = 'Error'; + btn.classList.add('text-red-400'); + } + }); + }); + tbodyEl.querySelectorAll('.delete-btn').forEach(btn => { btn.addEventListener('click', () => { pendingHandle = btn.dataset.handle!;