Git hosting and a container registry in one Rust binary (axum + Astro)
Registry: upload layers to R2 before taking the digest lock; wire up lazily loaded fragments
2 files changed, +22 -5
+17-3backend/src/registry/blobs.rs
| @@ -344,13 +344,27 @@ async fn finalize(state: &AppState, package: &Package, path: &Path, digest: &str | ||
| 344 | 344 | return Err(RegError::digest_invalid("uploaded content does not match the digest").detail(serde_json::json!({ "expected": digest, "actual": actual }))); |
| 345 | 345 | } |
| 346 | 346 | |
| 347 | + // Upload before taking the per-digest lock, so a large layer does not pin | |
| 348 | + // a pooled connection for the whole transfer. Two concurrent uploads of | |
| 349 | + // the same digest write identical bytes to the same key, which is fine. | |
| 350 | + let known: bool = sqlx::query_scalar("select exists(select 1 from blobs where digest = $1)").bind(digest).fetch_one(&state.db).await?; | |
| 351 | + let mut uploaded = false; | |
| 352 | + if !known { | |
| 353 | + let started = std::time::Instant::now(); | |
| 354 | + state.storage.put_file(&keys::blob(digest), path).await?; | |
| 355 | + uploaded = true; | |
| 356 | + tracing::info!(digest, size, ms = started.elapsed().as_millis() as u64, "blob stored in R2"); | |
| 357 | + } | |
| 358 | + | |
| 347 | 359 | let mut tx = state.db.begin().await?; |
| 348 | 360 | sqlx::query("select pg_advisory_xact_lock(hashtextextended($1, 0))").bind(digest).execute(&mut *tx).await?; |
| 349 | 361 | let exists: bool = sqlx::query_scalar("select exists(select 1 from blobs where digest = $1)").bind(digest).fetch_one(&mut *tx).await?; |
| 350 | 362 | if !exists { |
| 351 | - let started = std::time::Instant::now(); | |
| 352 | - state.storage.put_file(&keys::blob(digest), path).await?; | |
| 353 | - tracing::info!(digest, size, ms = started.elapsed().as_millis() as u64, "blob stored in R2"); | |
| 363 | + if !uploaded { | |
| 364 | + // GC removed the unlinked blob between the check and the lock. | |
| 365 | + state.storage.put_file(&keys::blob(digest), path).await?; | |
| 366 | + tracing::info!(digest, "blob re-stored after concurrent cleanup"); | |
| 367 | + } | |
| 354 | 368 | sqlx::query("insert into blobs (digest, size) values ($1, $2) on conflict do nothing") |
| 355 | 369 | .bind(digest) |
| 356 | 370 | .bind(size as i64) |
+5-2frontend/src/scripts/app.ts
| @@ -150,6 +150,9 @@ async function loadLazy(el: HTMLElement) { | ||
| 150 | 150 | if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); |
| 151 | 151 | el.innerHTML = await response.text(); |
| 152 | 152 | el.removeAttribute("aria-busy"); |
| 153 | + // Fragments arrive after page setup ran; wire up what they contain. | |
| 154 | + initConfirmInputs(el); | |
| 155 | + el.querySelectorAll<HTMLElement>("[data-scroll-end]").forEach((s) => (s.scrollLeft = s.scrollWidth)); | |
| 153 | 156 | el.querySelectorAll<HTMLElement>("[data-lazy]").forEach(loadLazy); |
| 154 | 157 | } catch (error) { |
| 155 | 158 | el.innerHTML = `<div class="alert alert-error">Could not load this section (${(error as Error).message}). <button class="btn btn-sm" data-retry>Retry</button></div>`; |
| @@ -190,8 +193,8 @@ function initSidebar() { | ||
| 190 | 193 | |
| 191 | 194 | // --- Dangerous forms: the submit button unlocks when the name is typed ------ |
| 192 | 195 | |
| 193 | -function initConfirmInputs() { | |
| 194 | - document.querySelectorAll<HTMLInputElement>("input[data-confirm-value]").forEach((input) => { | |
| 196 | +function initConfirmInputs(root: ParentNode = document) { | |
| 197 | + root.querySelectorAll<HTMLInputElement>("input[data-confirm-value]").forEach((input) => { | |
| 195 | 198 | const form = input.form; |
| 196 | 199 | const button = form?.querySelector<HTMLButtonElement>("button[type=submit]"); |
| 197 | 200 | if (!button) return; |