mirror of
https://github.com/Twigpine/node.git
synced 2026-11-08 15:39:37 -10:00
fix(storage): harden marker machinery — durable pre-ack, locked divergence checks, stable lock hash
Address beardthelion + jatmn rounds on 75060ea/f154768, plus findings from an internal adversarial review pass: Reviewer findings: - Marker persistence is fallible and atomic (tmp+rename); the write-back path only acks after the marker is durably on disk, else it falls back to strict upload-before-ack. An existing marker's base is preserved on re-mark so consecutive upload failures can't corrupt it into a false divergence (regression test per the reported repro). - release_after_write (fork publication) now uploads under the per-repo advisory lock, closing the torn-snapshot race against pushes to a freshly addressable fork; on divergence it refuses and leaves the local copy marked instead of clobbering the concurrent writer. - Startup sweep (retry_pending_uploads) re-uploads marked repos and clears markers; diverged repos stay marked and are counted in a new gitlawb_pending_upload_markers gauge. - try_exists for the marker probe (EIO must not read as "no marker"); markers skipped entirely in local-only mode; stale markers cleared on repo recreation (init) and in fork's undo_claim. - fs backend: etag sidecar published before the blob rename; sidecar content asserted in tests; GITLAWB_ADVISORY_LOCK_POOL_SIZE rejects 0 at parse; claim-first comments scoped to per-node databases. - New FlakyStore failing-backend double + sqlx-backed guard tests cover the ack window, consecutive failures, failed existence checks, init cleanup, and head-failure branches (531 tests). Internal review findings: - retry/fork upload decisions now happen UNDER the advisory lock (upload_locked_with_marker): an unlocked base check could pass, block on a concurrent push's lock, then clobber that push's fresh upload. - The marker base used for the locked check is read from the marker itself, not the versions cache — the cache is invalidated by upload failures and lost to restarts. - Marker cleared before the lock releases, and its base is atomically refreshed to the new etag before unlink, so a crash between upload and clear self-heals instead of wedging on spurious divergence. - Lazy migration skips marked repos instead of stranding their markers. - advisory_lock_key: DefaultHasher (unstable across Rust releases) replaced with a SHA-256 prefix — mixed-toolchain rolling deploys can no longer silently lose cross-machine mutual exclusion. - fs put serializes the sidecar+blob publish pair per key so concurrent same-key puts can't pair one put's etag with another's content. - Known limitations documented in place: unlocked read-download swap (pre-existing) and the fork creation compound-failure window.
This commit is contained in:
1 parent
f15476837f
commit
5605b9cf45
6 files changed
+750
-69
No files matched your search
@@ -208,10 +208,12 @@ pub async fn create_repo(
|
||||
let verified_proof = proof.consume(&state.db).await?;
|
||||
|
||||
// Claim-first ordering: insert the DB row before creating anything durable.
|
||||
// The row is the claim on (owner, name) — a concurrent same-name create
|
||||
// loses at the insert with nothing on disk or in storage yet, so failure
|
||||
// compensation below only ever touches state THIS attempt created and can
|
||||
// never delete a racing winner's repo or archive.
|
||||
// Within one node's database the row is the claim on (owner, name) — a
|
||||
// concurrent same-name create loses at the insert with nothing on disk or
|
||||
// in storage yet. Across nodes (each fly app has its own Postgres) the
|
||||
// insert arbitrates nothing; the cross-node safety property is that
|
||||
// failure compensation below only ever touches state THIS attempt created
|
||||
// (its own row by id, its own local dir) and never deletes a storage key.
|
||||
let disk_path = store::repo_disk_path(&state.config.repos_dir, &owner_did, &req.name);
|
||||
let now = Utc::now();
|
||||
let record = crate::db::RepoRecord {
|
||||
@@ -955,7 +957,11 @@ pub async fn git_receive_pack(
|
||||
// from blocking subsequent pushes. Only upload to storage when the push
|
||||
// succeeded; uploading a half-applied repo would propagate corruption.
|
||||
let push_ok = receive_result.is_ok();
|
||||
// `Some` = the guard still needs a synchronous (strict) release; taken by
|
||||
// the write-back path only once its intent marker is durably on disk.
|
||||
let mut strict_guard = Some(guard);
|
||||
if push_ok && state.config.async_upload {
|
||||
let guard = strict_guard.take().expect("guard present before release");
|
||||
// Write-back: ack the client now; the durable upload to object storage
|
||||
// and the advisory-lock release run in the background. The lock is held
|
||||
// until the upload finishes, so a concurrent writer on another machine
|
||||
@@ -972,17 +978,29 @@ pub async fn git_receive_pack(
|
||||
// The intent marker must be on disk BEFORE the ack: the spawned task
|
||||
// may never be polled if the process stops right after the response,
|
||||
// and without the marker a restart would treat the stale storage
|
||||
// archive as newer and roll the acked push back.
|
||||
guard.mark_pending().await;
|
||||
let repo_label = name.to_string();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = guard.release(true).await {
|
||||
tracing::error!(repo = %repo_label, err = %e,
|
||||
"write-back durable upload failed after push was acked");
|
||||
// archive as newer and roll the acked push back. If the marker itself
|
||||
// cannot be persisted, do NOT ack early — fall back to the strict
|
||||
// upload-before-ack path below.
|
||||
match guard.mark_pending().await {
|
||||
Ok(()) => {
|
||||
let repo_label = name.to_string();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = guard.release(true).await {
|
||||
tracing::error!(repo = %repo_label, err = %e,
|
||||
"write-back durable upload failed after push was acked");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Strict path (or failed push): upload-before-ack.
|
||||
Err(e) => {
|
||||
tracing::warn!(repo = %name, err = %e,
|
||||
"pending-upload marker write failed — falling back to strict upload-before-ack");
|
||||
strict_guard = Some(guard);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(guard) = strict_guard {
|
||||
// Strict path (failed push, async_upload off, or marker write failure):
|
||||
// upload-before-ack.
|
||||
if let Err(e) = guard.release(push_ok).await {
|
||||
if push_ok {
|
||||
// A successful push whose durable upload then failed — the
|
||||
@@ -1622,11 +1640,13 @@ pub async fn fork_repo(
|
||||
let disk_path = store::repo_disk_path(&state.config.repos_dir, &forker_did, &fork_name);
|
||||
|
||||
// Claim-first ordering: insert the DB row before cloning or uploading
|
||||
// anything. The row is the claim on (owner, name) — a concurrent same-name
|
||||
// fork loses at the insert with nothing on disk or in storage, so the
|
||||
// failure compensation below only ever removes state THIS attempt created
|
||||
// (its own row by id, its own clone dir) and never touches a storage
|
||||
// archive that may belong to a racing winner's live repo.
|
||||
// anything. Within one node's database the row is the claim on (owner,
|
||||
// name) — a concurrent same-name fork loses at the insert with nothing on
|
||||
// disk or in storage. Across nodes (per-node Postgres) the insert
|
||||
// arbitrates nothing; the cross-node safety property is that the failure
|
||||
// compensation below only ever removes state THIS attempt created (its own
|
||||
// row by id, its own clone dir) and never touches a storage archive that
|
||||
// may belong to a racing winner's live repo.
|
||||
let now = Utc::now();
|
||||
let record = crate::db::RepoRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
@@ -1647,6 +1667,13 @@ pub async fn fork_repo(
|
||||
// our generated id) and whatever `git clone` left at `disk_path` — clone
|
||||
// can create the destination before failing, and an orphaned dir makes a
|
||||
// retry fail on an existing destination.
|
||||
//
|
||||
// KNOWN COMPOUND-FAILURE WINDOW: the row makes the fork pushable before
|
||||
// the clone+upload complete, and this flow holds no advisory lock across
|
||||
// that phase. A push acked in the window, whose own upload also failed,
|
||||
// lives only in this dir — undoing the claim then discards it. Requires
|
||||
// fork-upload failure + an interleaved push + that push's upload failure;
|
||||
// the follow-up is to hold the advisory lock across clone+publication.
|
||||
let undo_claim = |state: AppState, record_id: String, disk_path: std::path::PathBuf| async move {
|
||||
match tokio::fs::remove_dir_all(&disk_path).await {
|
||||
Ok(()) => {}
|
||||
@@ -1656,6 +1683,9 @@ pub async fn fork_repo(
|
||||
"failed to clean up local fork dir after fork error"
|
||||
),
|
||||
}
|
||||
// Also drop the pending-upload marker a failed upload just wrote: left
|
||||
// behind, it would read as divergence and wedge a same-name recreate.
|
||||
crate::git::repo_store::clear_pending_upload(&disk_path);
|
||||
if let Err(e) = state.db.delete_repo_by_id(&record_id).await {
|
||||
tracing::warn!(record_id = %record_id, err = %e,
|
||||
"failed to remove fork row after fork error");
|
||||
|
||||
@@ -174,8 +174,14 @@ pub struct Config {
|
||||
/// Max connections in the dedicated advisory-lock pool. A push pins one
|
||||
/// connection for its whole lifetime (lock held across receive-pack and
|
||||
/// the storage upload), so this bounds per-node push concurrency. Counts
|
||||
/// against Postgres `max_connections` on top of the handler pool.
|
||||
#[arg(long, env = "GITLAWB_ADVISORY_LOCK_POOL_SIZE", default_value_t = 16)]
|
||||
/// against Postgres `max_connections` on top of the handler pool. Must be
|
||||
/// at least 1 — zero would let the node start but time out every write.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_ADVISORY_LOCK_POOL_SIZE",
|
||||
default_value_t = 16,
|
||||
value_parser = clap::value_parser!(u32).range(1..)
|
||||
)]
|
||||
pub advisory_lock_pool_size: u32,
|
||||
|
||||
/// Maximum pack body size for git-receive-pack and git-upload-pack, in bytes.
|
||||
@@ -316,4 +322,21 @@ mod tests {
|
||||
Config::try_parse_from(["gitlawb-node", "--git-service-timeout-secs", "0"]).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advisory_lock_pool_size_defaults_to_16_and_rejects_zero() {
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node"]).advisory_lock_pool_size,
|
||||
16
|
||||
);
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node", "--advisory-lock-pool-size", "1"])
|
||||
.advisory_lock_pool_size,
|
||||
1
|
||||
);
|
||||
// 0 would let the node start but time out every write on pool acquire.
|
||||
assert!(
|
||||
Config::try_parse_from(["gitlawb-node", "--advisory-lock-pool-size", "0"]).is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,21 @@ impl RepoStore {
|
||||
};
|
||||
|
||||
let marker = pending_upload_marker(local_path);
|
||||
if marker.exists() {
|
||||
// try_exists, not exists(): a transient EACCES/EIO must not read as
|
||||
// "no marker" — that path downloads and can roll back a pending local
|
||||
// write. Fail the write path closed; treat as present on the read path.
|
||||
let marker_present = match marker.try_exists() {
|
||||
Ok(present) => present,
|
||||
Err(e) => {
|
||||
if require_fresh {
|
||||
return Err(e).context("probing pending-upload marker");
|
||||
}
|
||||
warn!(repo = %repo_name, err = %e,
|
||||
"pending-upload marker probe failed — treating as present");
|
||||
true
|
||||
}
|
||||
};
|
||||
if marker_present {
|
||||
if local_path.exists() {
|
||||
// The local copy has a write that storage never received (its
|
||||
// upload failed, or the node stopped first). The marker records
|
||||
@@ -172,6 +186,12 @@ impl RepoStore {
|
||||
}
|
||||
}
|
||||
|
||||
// KNOWN LIMITATION (pre-dates this layer): read-path downloads and
|
||||
// their swap-into-place are not serialized against the advisory write
|
||||
// lock, so a slow in-flight download decided before a push began can
|
||||
// swap a stale tree under a running receive-pack on the same node.
|
||||
// Requires a cache-miss/stale read racing a same-repo write; the
|
||||
// follow-up is to serialize download+swap with writers.
|
||||
match archive.download(owner_slug, repo_name, local_path).await {
|
||||
Ok(()) => {
|
||||
self.versions.lock().await.insert(key, remote_etag);
|
||||
@@ -207,7 +227,14 @@ impl RepoStore {
|
||||
if self.archive.is_some() {
|
||||
let key = format!("{owner_slug}/{repo_name}");
|
||||
let already_migrated = self.migrated.lock().await.contains(&key);
|
||||
if !already_migrated {
|
||||
// A pending-upload marker means the marker machinery already
|
||||
// owns this repo's next upload (next write, or the startup
|
||||
// retry). Migration must not steal it: `upload_under_lock`
|
||||
// knows nothing about markers, so its upload would strand the
|
||||
// marker with a base that no longer matches storage, wedging
|
||||
// the repo's writes on a spurious divergence.
|
||||
let marker_pending = pending_upload_marker(&local_path).exists();
|
||||
if !already_migrated && !marker_pending {
|
||||
let this = self.clone();
|
||||
let slug = owner_slug.clone();
|
||||
let name = repo_name.to_string();
|
||||
@@ -311,6 +338,11 @@ impl RepoStore {
|
||||
let _ = std::fs::remove_dir_all(&local_path);
|
||||
return Err(e).context("initializing bare repo");
|
||||
}
|
||||
// A marker left by a previous same-name repo (failed creation, deleted
|
||||
// repo) describes THAT repo's history, not this fresh one — once this
|
||||
// repo's archive exists, a stale empty-base marker would read as
|
||||
// divergence and wedge its writes.
|
||||
clear_pending_upload(&local_path);
|
||||
|
||||
// Upload the new repo synchronously under the advisory lock: a background
|
||||
// upload could land the empty repo *after* a racing first push and clobber
|
||||
@@ -336,25 +368,45 @@ impl RepoStore {
|
||||
/// Call this after any operation that modifies the git repo on disk. Returns
|
||||
/// `Err` if the durable upload fails so the caller can surface it rather than
|
||||
/// acking a write that never reached storage.
|
||||
///
|
||||
/// The upload runs under the per-repo advisory lock: claim-first creation
|
||||
/// makes a fork addressable (and pushable) before this upload finishes, so
|
||||
/// an unlocked PUT could compress a pre-push snapshot and land it AFTER a
|
||||
/// concurrent locked push's upload — which that push's cleared marker no
|
||||
/// longer protects against.
|
||||
pub async fn release_after_write(&self, owner_did: &str, repo_name: &str) -> Result<()> {
|
||||
let Some(ref archive) = self.archive else {
|
||||
if self.archive.is_none() {
|
||||
return Ok(());
|
||||
};
|
||||
}
|
||||
let (owner_slug, local_path) = self
|
||||
.local_path(owner_did, repo_name)
|
||||
.context("rejected unsafe path in release_after_write")?;
|
||||
let key = format!("{owner_slug}/{repo_name}");
|
||||
let base = self.versions.lock().await.get(&key).cloned();
|
||||
mark_pending_upload(&local_path, repo_name, base.as_deref());
|
||||
match archive.upload(&owner_slug, repo_name, &local_path).await {
|
||||
Ok(Some(etag)) => {
|
||||
self.versions.lock().await.insert(key, etag);
|
||||
clear_pending_upload(&local_path);
|
||||
Ok(())
|
||||
}
|
||||
Ok(None) => {
|
||||
clear_pending_upload(&local_path);
|
||||
Ok(())
|
||||
mark_pending_upload(&local_path, base.as_deref())
|
||||
.context("persisting pending-upload marker")?;
|
||||
// The marker's recorded base is authoritative, not the cache: if a
|
||||
// marker already existed (earlier failed upload), mark_pending_upload
|
||||
// preserved its ORIGINAL base while the versions cache was invalidated
|
||||
// by that failure — or emptied entirely by a restart. Comparing
|
||||
// against the cache value would misread that state as divergence.
|
||||
let base = std::fs::read_to_string(pending_upload_marker(&local_path))
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|_| base.unwrap_or_default());
|
||||
match self
|
||||
.upload_locked_with_marker(&owner_slug, repo_name, &local_path, &base)
|
||||
.await
|
||||
{
|
||||
Ok(PendingUploadOutcome::Uploaded) => Ok(()),
|
||||
Ok(PendingUploadOutcome::Diverged) => {
|
||||
// Another writer advanced storage between our sync and this
|
||||
// upload. Refusing (rather than uploading) protects their
|
||||
// acked write; ours stays local behind the marker.
|
||||
self.versions.lock().await.remove(&key);
|
||||
anyhow::bail!(
|
||||
"storage for {key} advanced during the write — upload aborted to \
|
||||
avoid clobbering the concurrent writer; local copy left marked"
|
||||
)
|
||||
}
|
||||
Err(e) => {
|
||||
// Storage is now behind local. Drop the cached etag, and leave
|
||||
@@ -367,6 +419,148 @@ impl RepoStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Startup sweep re-attempting the durable upload for every repo whose
|
||||
/// pending-upload marker survived a crash or a failed upload. Without this,
|
||||
/// a repo that receives no further writes stays divergent from storage
|
||||
/// indefinitely, visible only as one log line at failure time.
|
||||
///
|
||||
/// Applies the same base-etag rule as `sync_down_if_stale`: a repo whose
|
||||
/// storage advanced past the marker's base is left marked (its writes stay
|
||||
/// wedged pending manual reconciliation) and only logged. Returns
|
||||
/// `(reuploaded, still_pending)`.
|
||||
pub async fn retry_pending_uploads(&self) -> (usize, usize) {
|
||||
if self.archive.is_none() {
|
||||
return (0, 0);
|
||||
}
|
||||
let mut reuploaded = 0usize;
|
||||
let mut still_pending = 0usize;
|
||||
|
||||
let mut markers: Vec<(String, String, PathBuf)> = Vec::new(); // (slug, repo, local)
|
||||
let Ok(owners) = std::fs::read_dir(&self.repos_dir) else {
|
||||
return (0, 0);
|
||||
};
|
||||
for owner in owners.flatten() {
|
||||
if !owner.path().is_dir() {
|
||||
continue;
|
||||
}
|
||||
let slug = owner.file_name().to_string_lossy().into_owned();
|
||||
let Ok(entries) = std::fs::read_dir(owner.path()) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
// Marker layout: `.{repo}.git.pending-upload`
|
||||
let Some(repo_dir) = name
|
||||
.strip_prefix('.')
|
||||
.and_then(|n| n.strip_suffix(".pending-upload"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(repo_name) = repo_dir.strip_suffix(".git") else {
|
||||
continue;
|
||||
};
|
||||
markers.push((
|
||||
slug.clone(),
|
||||
repo_name.to_string(),
|
||||
owner.path().join(repo_dir),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
for (slug, repo_name, local_path) in markers {
|
||||
if !local_path.exists() {
|
||||
// Stale litter (repo dir gone) — storage is the best remaining
|
||||
// state; drop the marker.
|
||||
clear_pending_upload(&local_path);
|
||||
continue;
|
||||
}
|
||||
let base =
|
||||
std::fs::read_to_string(pending_upload_marker(&local_path)).unwrap_or_default();
|
||||
let base = base.trim().to_string();
|
||||
// The base-vs-remote decision and the upload both happen inside
|
||||
// `upload_locked_with_marker`, UNDER the advisory lock: an
|
||||
// unlocked pre-check here could pass, then block on a concurrent
|
||||
// push's lock for that push's whole duration, and the stale
|
||||
// decision would clobber the push's freshly-uploaded archive.
|
||||
match self
|
||||
.upload_locked_with_marker(&slug, &repo_name, &local_path, &base)
|
||||
.await
|
||||
{
|
||||
Ok(PendingUploadOutcome::Uploaded) => {
|
||||
debug!(repo = %repo_name, "pending-upload retry: re-synced storage");
|
||||
reuploaded += 1;
|
||||
}
|
||||
Ok(PendingUploadOutcome::Diverged) => {
|
||||
warn!(repo = %repo_name,
|
||||
"pending-upload retry: storage diverged from marker base — \
|
||||
leaving marked; writes stay blocked pending manual reconciliation");
|
||||
still_pending += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(repo = %repo_name, err = %e,
|
||||
"pending-upload retry: upload failed — will retry on next write");
|
||||
still_pending += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::metrics::set_pending_upload_markers(still_pending as i64);
|
||||
(reuploaded, still_pending)
|
||||
}
|
||||
|
||||
/// Marker-protected upload: takes the per-repo advisory lock, re-checks
|
||||
/// that storage still matches `base` UNDER the lock, and only then uploads,
|
||||
/// updates the versions cache, and clears the marker — all before the lock
|
||||
/// is released.
|
||||
///
|
||||
/// Both halves of that ordering are load-bearing:
|
||||
/// - The divergence check must run under the lock. An unlocked check can
|
||||
/// pass just before a concurrent locked push advances storage (the check
|
||||
/// then blocks on that push's lock), and blindly uploading afterwards
|
||||
/// would clobber the acked push it lost the race to.
|
||||
/// - The marker must be cleared before the lock is released, or a writer
|
||||
/// queued on the lock could observe marker + fresh etag and fail with a
|
||||
/// spurious "diverged — reconcile manually" on a consistent repo.
|
||||
async fn upload_locked_with_marker(
|
||||
&self,
|
||||
owner_slug: &str,
|
||||
repo_name: &str,
|
||||
local_path: &Path,
|
||||
base: &str,
|
||||
) -> Result<PendingUploadOutcome> {
|
||||
let Some(ref archive) = self.archive else {
|
||||
anyhow::bail!("upload_locked_with_marker called without a storage backend");
|
||||
};
|
||||
let lock_key = advisory_lock_key(owner_slug, repo_name);
|
||||
let label = format!("{owner_slug}/{repo_name}");
|
||||
let lock = LockedConn::acquire(&self.lock_pool, lock_key, &label).await?;
|
||||
|
||||
let outcome: Result<PendingUploadOutcome> = async {
|
||||
let remote = archive
|
||||
.head_etag(owner_slug, repo_name)
|
||||
.await
|
||||
.context("storage head under lock before pending upload")?;
|
||||
if remote.as_deref().unwrap_or("") != base {
|
||||
return Ok(PendingUploadOutcome::Diverged);
|
||||
}
|
||||
let etag = archive
|
||||
.upload(owner_slug, repo_name, local_path)
|
||||
.await
|
||||
.context("uploading repo to storage under lock")?;
|
||||
if let Some(ref etag) = etag {
|
||||
self.versions
|
||||
.lock()
|
||||
.await
|
||||
.insert(label.clone(), etag.clone());
|
||||
}
|
||||
clear_pending_upload_after_success(local_path, etag.as_deref());
|
||||
Ok(PendingUploadOutcome::Uploaded)
|
||||
}
|
||||
.await;
|
||||
|
||||
lock.unlock().await;
|
||||
outcome
|
||||
}
|
||||
|
||||
/// Upload `local_path` to storage while holding the per-repo advisory lock,
|
||||
/// so a background or init-time upload can't clobber a concurrent locked
|
||||
/// write by landing an older snapshot after it. With `skip_if_exists`, skips
|
||||
@@ -665,15 +859,21 @@ impl RepoWriteGuard {
|
||||
}
|
||||
|
||||
/// Durably record intent-to-upload NOW, before the caller acks the client.
|
||||
/// Write-back callers must call this before spawning `release()`: the
|
||||
/// Write-back callers must call this before spawning `release()` — the
|
||||
/// spawned task may never be polled if the process stops right after the
|
||||
/// ack, and without the marker already on disk a restart would treat the
|
||||
/// stale storage archive as newer and roll the acked write back.
|
||||
/// Idempotent with the marker `release()` writes itself.
|
||||
pub async fn mark_pending(&self) {
|
||||
/// stale storage archive as newer and roll the acked write back. On `Err`
|
||||
/// the caller must NOT ack early; fall back to strict upload-before-ack.
|
||||
/// Idempotent with the marker `release()` writes itself. No-op without a
|
||||
/// storage backend (markers would be inert until a backend appears, then
|
||||
/// wedge repos whose archives predate them).
|
||||
pub async fn mark_pending(&self) -> Result<()> {
|
||||
if self.archive.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let key = format!("{}/{}", self.owner_slug, self.repo_name);
|
||||
let base = self.versions.lock().await.get(&key).cloned();
|
||||
mark_pending_upload(&self.local_path, &self.repo_name, base.as_deref());
|
||||
mark_pending_upload(&self.local_path, base.as_deref())
|
||||
}
|
||||
|
||||
/// Upload to storage (only when the write succeeded) and release the advisory
|
||||
@@ -696,14 +896,20 @@ impl RepoWriteGuard {
|
||||
let upload_result: Result<()> = if success {
|
||||
if let Some(ref archive) = self.archive {
|
||||
let base = self.versions.lock().await.get(&key).cloned();
|
||||
mark_pending_upload(&self.local_path, &self.repo_name, base.as_deref());
|
||||
if let Err(e) = mark_pending_upload(&self.local_path, base.as_deref()) {
|
||||
// Proceed with the upload anyway: if it succeeds, no marker
|
||||
// is needed; if both fail, the error below reaches the
|
||||
// caller (double-failure corner, same exposure as
|
||||
// pre-marker behavior).
|
||||
warn!(repo = %self.repo_name, err = %e, "failed to write pending-upload marker");
|
||||
}
|
||||
match archive
|
||||
.upload(&self.owner_slug, &self.repo_name, &self.local_path)
|
||||
.await
|
||||
{
|
||||
Ok(Some(etag)) => {
|
||||
self.versions.lock().await.insert(key.clone(), etag);
|
||||
clear_pending_upload(&self.local_path);
|
||||
self.versions.lock().await.insert(key.clone(), etag.clone());
|
||||
clear_pending_upload_after_success(&self.local_path, Some(&etag));
|
||||
Ok(())
|
||||
}
|
||||
Ok(None) => {
|
||||
@@ -757,33 +963,86 @@ fn pending_upload_marker(local_path: &Path) -> PathBuf {
|
||||
local_path.with_file_name(format!(".{name}.pending-upload"))
|
||||
}
|
||||
|
||||
/// Persist the intent-to-upload marker. Fallible (write-back callers must NOT
|
||||
/// ack the client if this fails) and atomic (tmp + rename, so a crash cannot
|
||||
/// leave a torn marker).
|
||||
///
|
||||
/// `base_etag` is the storage etag the local write was built on (empty when
|
||||
/// storage held nothing). `sync_down_if_stale` compares it against the current
|
||||
/// remote etag to distinguish "local strictly ahead" from cross-node
|
||||
/// divergence.
|
||||
fn mark_pending_upload(local_path: &Path, repo_name: &str, base_etag: Option<&str>) {
|
||||
if let Err(e) = std::fs::write(
|
||||
pending_upload_marker(local_path),
|
||||
base_etag.unwrap_or_default(),
|
||||
) {
|
||||
// Best-effort: without the marker we fall back to the pre-marker
|
||||
// behavior (a failed upload risks rollback), but the upload itself
|
||||
// must still proceed.
|
||||
warn!(repo = %repo_name, err = %e, "failed to write pending-upload marker");
|
||||
///
|
||||
/// An existing marker is preserved untouched: its base is the last storage
|
||||
/// etag this node confirmed, which stays correct for every further write
|
||||
/// stacked on the same undiverged local copy. Re-marking would record the
|
||||
/// CURRENT cache — emptied by the preceding upload failure — and a corrupted
|
||||
/// (empty) base makes the next sync read unchanged storage as divergence,
|
||||
/// wedging the repo's whole write surface after two consecutive upload
|
||||
/// failures.
|
||||
fn mark_pending_upload(local_path: &Path, base_etag: Option<&str>) -> Result<()> {
|
||||
let marker = pending_upload_marker(local_path);
|
||||
match marker.try_exists() {
|
||||
Ok(true) => return Ok(()), // keep the original base
|
||||
Ok(false) => {}
|
||||
Err(e) => return Err(e).context("probing pending-upload marker"),
|
||||
}
|
||||
let tmp = marker.with_file_name(format!(".pending-upload.tmp-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::write(&tmp, base_etag.unwrap_or_default()).context("writing pending-upload marker")?;
|
||||
std::fs::rename(&tmp, &marker)
|
||||
.inspect_err(|_| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
})
|
||||
.context("publishing pending-upload marker")
|
||||
}
|
||||
|
||||
fn clear_pending_upload(local_path: &Path) {
|
||||
pub(crate) fn clear_pending_upload(local_path: &Path) {
|
||||
let _ = std::fs::remove_file(pending_upload_marker(local_path));
|
||||
}
|
||||
|
||||
/// Remove the marker after a *successful* upload, first atomically rewriting
|
||||
/// its base to the just-uploaded etag. A crash between the rewrite and the
|
||||
/// unlink then reads as "local ahead, base matches" — which self-heals on the
|
||||
/// next write or startup retry — instead of "base predates storage", which
|
||||
/// would wedge the repo behind a spurious permanent divergence even though
|
||||
/// local and storage are identical.
|
||||
fn clear_pending_upload_after_success(local_path: &Path, new_etag: Option<&str>) {
|
||||
let marker = pending_upload_marker(local_path);
|
||||
if let Some(etag) = new_etag {
|
||||
if marker.exists() {
|
||||
let tmp =
|
||||
marker.with_file_name(format!(".pending-upload.tmp-{}", uuid::Uuid::new_v4()));
|
||||
if std::fs::write(&tmp, etag).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, &marker);
|
||||
} else {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_file(&marker);
|
||||
}
|
||||
|
||||
/// Outcome of a marker-protected upload attempt.
|
||||
enum PendingUploadOutcome {
|
||||
/// Uploaded, versions cache updated, marker cleared — all under the lock.
|
||||
Uploaded,
|
||||
/// Storage no longer matches the marker's base: another writer advanced it.
|
||||
/// Nothing was uploaded and the marker was left in place.
|
||||
Diverged,
|
||||
}
|
||||
|
||||
/// Compute a stable i64 hash for a Postgres advisory lock key.
|
||||
///
|
||||
/// SHA-256 prefix, NOT `DefaultHasher`: the default hasher's algorithm is
|
||||
/// explicitly unspecified across Rust releases, and this lock is the sole
|
||||
/// cross-machine write serializer — two machines built with different
|
||||
/// toolchains hashing the same repo to different keys would silently stop
|
||||
/// excluding each other mid rolling deploy. (Changing the scheme is itself a
|
||||
/// one-deploy exclusion gap between old and new binaries; accepted once,
|
||||
/// here, to get onto a stable function.)
|
||||
fn advisory_lock_key(owner_slug: &str, repo_name: &str) -> i64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
owner_slug.hash(&mut hasher);
|
||||
repo_name.hash(&mut hasher);
|
||||
hasher.finish() as i64
|
||||
use sha2::{Digest, Sha256};
|
||||
let digest = Sha256::digest(format!("{owner_slug}/{repo_name}").as_bytes());
|
||||
i64::from_be_bytes(digest[..8].try_into().expect("digest has at least 8 bytes"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1000,11 +1259,18 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_marker_prevents_rollback_and_clears_on_next_upload() {
|
||||
// Needs a real pool: `release_after_write` uploads under the advisory lock.
|
||||
#[sqlx::test]
|
||||
async fn pending_marker_prevents_rollback_and_clears_on_next_upload(pool: PgPool) {
|
||||
let store_root = tempfile::tempdir().unwrap();
|
||||
let repos_dir = tempfile::tempdir().unwrap();
|
||||
let store = store_with_fs_archive(repos_dir.path().to_path_buf(), store_root.path());
|
||||
let blob: Arc<dyn crate::storage::BlobStore> =
|
||||
Arc::new(crate::storage::fs::FsBlobStore::new(store_root.path()).unwrap());
|
||||
let store = RepoStore::new(
|
||||
repos_dir.path().to_path_buf(),
|
||||
Some(crate::storage::archive::RepoArchive::new(blob)),
|
||||
pool,
|
||||
);
|
||||
|
||||
// Storage holds v1; local downloads it.
|
||||
let seed = tempfile::tempdir().unwrap();
|
||||
@@ -1034,7 +1300,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
std::fs::write(local.join("HEAD"), b"ACKED-WRITE\n").unwrap();
|
||||
mark_pending_upload(&local, "repo", Some(&base));
|
||||
mark_pending_upload(&local, Some(&base)).unwrap();
|
||||
store.versions.lock().await.clear();
|
||||
|
||||
// Both the read and the write path must serve local, not roll it back.
|
||||
@@ -1090,7 +1356,7 @@ mod tests {
|
||||
.unwrap();
|
||||
let base = archive.head_etag("owner", "repo").await.unwrap().unwrap();
|
||||
std::fs::write(local.join("HEAD"), b"LOCAL-AHEAD\n").unwrap();
|
||||
mark_pending_upload(&local, "repo", Some(&base));
|
||||
mark_pending_upload(&local, Some(&base)).unwrap();
|
||||
store.versions.lock().await.clear();
|
||||
|
||||
// Another node advances storage past our base.
|
||||
@@ -1135,7 +1401,7 @@ mod tests {
|
||||
// the marker is stale — drop it and download normally.
|
||||
let local = repos_dir.path().join("owner").join("repo.git");
|
||||
std::fs::create_dir_all(local.parent().unwrap()).unwrap();
|
||||
mark_pending_upload(&local, "repo", Some("whatever"));
|
||||
mark_pending_upload(&local, Some("whatever")).unwrap();
|
||||
store
|
||||
.sync_down_if_stale("owner", "repo", &local, true)
|
||||
.await
|
||||
@@ -1197,4 +1463,291 @@ mod tests {
|
||||
.expect("require_fresh=false must fall back to the local copy");
|
||||
assert_eq!(std::fs::read(local.join("HEAD")).unwrap(), b"v1\n");
|
||||
}
|
||||
|
||||
// ── failing-store double: exercises error branches no real backend can ──
|
||||
|
||||
/// BlobStore wrapper whose `put`/`head` can be flipped to fail, unlocking
|
||||
/// deterministic coverage of the upload-failure and head-failure branches.
|
||||
struct FlakyStore {
|
||||
inner: crate::storage::fs::FsBlobStore,
|
||||
fail_put: std::sync::atomic::AtomicBool,
|
||||
fail_head: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl FlakyStore {
|
||||
fn new(root: &Path) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
inner: crate::storage::fs::FsBlobStore::new(root).unwrap(),
|
||||
fail_put: std::sync::atomic::AtomicBool::new(false),
|
||||
fail_head: std::sync::atomic::AtomicBool::new(false),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::storage::BlobStore for FlakyStore {
|
||||
fn backend_name(&self) -> &'static str {
|
||||
"flaky"
|
||||
}
|
||||
async fn get(&self, key: &str) -> Result<Option<bytes::Bytes>> {
|
||||
self.inner.get(key).await
|
||||
}
|
||||
async fn put(&self, key: &str, body: bytes::Bytes) -> Result<crate::storage::ObjectMeta> {
|
||||
if self.fail_put.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
anyhow::bail!("injected put failure");
|
||||
}
|
||||
self.inner.put(key, body).await
|
||||
}
|
||||
async fn head(&self, key: &str) -> Result<Option<crate::storage::ObjectMeta>> {
|
||||
if self.fail_head.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
anyhow::bail!("injected head failure");
|
||||
}
|
||||
self.inner.head(key).await
|
||||
}
|
||||
async fn delete(&self, key: &str) -> Result<()> {
|
||||
self.inner.delete(key).await
|
||||
}
|
||||
}
|
||||
|
||||
fn store_with_flaky(repos_dir: PathBuf, flaky: Arc<FlakyStore>, pool: PgPool) -> RepoStore {
|
||||
let blob: Arc<dyn crate::storage::BlobStore> = flaky;
|
||||
let archive = crate::storage::archive::RepoArchive::new(blob);
|
||||
RepoStore::new(repos_dir, Some(archive), pool)
|
||||
}
|
||||
|
||||
/// beardthelion P1 regression: two consecutive failed uploads must not
|
||||
/// corrupt the marker's base — the second `release` re-marks while the
|
||||
/// versions cache is empty, and overwriting the base with "" would make
|
||||
/// the third write read unchanged storage as divergence and wedge the
|
||||
/// repo's entire write surface.
|
||||
#[sqlx::test]
|
||||
async fn two_consecutive_failed_uploads_preserve_marker_base(pool: PgPool) {
|
||||
let store_root = tempfile::tempdir().unwrap();
|
||||
let repos_dir = tempfile::tempdir().unwrap();
|
||||
let flaky = FlakyStore::new(store_root.path());
|
||||
let store = store_with_flaky(repos_dir.path().to_path_buf(), Arc::clone(&flaky), pool);
|
||||
|
||||
// Storage v1, synced down.
|
||||
let seed = tempfile::tempdir().unwrap();
|
||||
std::fs::write(seed.path().join("HEAD"), b"v1\n").unwrap();
|
||||
store
|
||||
.archive
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.upload("owner", "repo", seed.path())
|
||||
.await
|
||||
.unwrap();
|
||||
let local = repos_dir.path().join("owner").join("repo.git");
|
||||
store
|
||||
.sync_down_if_stale("owner", "repo", &local, true)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Write 1: mutate, upload fails.
|
||||
flaky
|
||||
.fail_put
|
||||
.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
std::fs::write(local.join("HEAD"), b"write-1\n").unwrap();
|
||||
let guard = store.acquire_write("owner", "repo").await.unwrap();
|
||||
assert!(guard.release(true).await.is_err(), "injected put must fail");
|
||||
let base_after_first = std::fs::read_to_string(pending_upload_marker(&local)).unwrap();
|
||||
assert!(!base_after_first.trim().is_empty(), "base must be recorded");
|
||||
|
||||
// Write 2: acquire must succeed (storage unchanged == local-ahead),
|
||||
// and the second failed release must NOT re-mark with an empty base.
|
||||
let guard = store.acquire_write("owner", "repo").await.unwrap();
|
||||
std::fs::write(local.join("HEAD"), b"write-2\n").unwrap();
|
||||
assert!(guard.release(true).await.is_err());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(pending_upload_marker(&local)).unwrap(),
|
||||
base_after_first,
|
||||
"an existing marker's base must be preserved on re-mark"
|
||||
);
|
||||
|
||||
// Write 3: still not wedged — and once the store heals, everything
|
||||
// re-syncs and the marker clears.
|
||||
let guard = store
|
||||
.acquire_write("owner", "repo")
|
||||
.await
|
||||
.expect("repeated upload failures must not wedge the write surface");
|
||||
flaky
|
||||
.fail_put
|
||||
.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
guard.release(true).await.unwrap();
|
||||
assert!(!pending_upload_marker(&local).exists());
|
||||
}
|
||||
|
||||
/// jatmn P1 regression: the write-back ack window. `mark_pending` runs
|
||||
/// before the ack; if the process dies before the spawned release is ever
|
||||
/// polled (simulated by dropping the guard), the marker alone must keep
|
||||
/// the next sync from rolling the acked write back.
|
||||
#[sqlx::test]
|
||||
async fn mark_pending_alone_protects_the_ack_window(pool: PgPool) {
|
||||
let store_root = tempfile::tempdir().unwrap();
|
||||
let repos_dir = tempfile::tempdir().unwrap();
|
||||
let flaky = FlakyStore::new(store_root.path());
|
||||
let store = store_with_flaky(repos_dir.path().to_path_buf(), Arc::clone(&flaky), pool);
|
||||
|
||||
let seed = tempfile::tempdir().unwrap();
|
||||
std::fs::write(seed.path().join("HEAD"), b"v1\n").unwrap();
|
||||
store
|
||||
.archive
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.upload("owner", "repo", seed.path())
|
||||
.await
|
||||
.unwrap();
|
||||
let local = repos_dir.path().join("owner").join("repo.git");
|
||||
|
||||
let guard = store.acquire_write("owner", "repo").await.unwrap();
|
||||
std::fs::write(local.join("HEAD"), b"ACKED\n").unwrap();
|
||||
guard.mark_pending().await.unwrap();
|
||||
drop(guard); // crash before release() is ever polled
|
||||
|
||||
store
|
||||
.sync_down_if_stale("owner", "repo", &local, true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read(local.join("HEAD")).unwrap(),
|
||||
b"ACKED\n",
|
||||
"the pre-ack marker alone must prevent rollback"
|
||||
);
|
||||
}
|
||||
|
||||
/// The lazy-migration existence check must propagate failure instead of
|
||||
/// reading it as "absent" and uploading over a possibly-newer archive.
|
||||
#[sqlx::test]
|
||||
async fn upload_under_lock_propagates_failed_existence_check(pool: PgPool) {
|
||||
let store_root = tempfile::tempdir().unwrap();
|
||||
let repos_dir = tempfile::tempdir().unwrap();
|
||||
let flaky = FlakyStore::new(store_root.path());
|
||||
let store = store_with_flaky(repos_dir.path().to_path_buf(), Arc::clone(&flaky), pool);
|
||||
|
||||
let local = repos_dir.path().join("owner").join("repo.git");
|
||||
std::fs::create_dir_all(&local).unwrap();
|
||||
std::fs::write(local.join("HEAD"), b"local\n").unwrap();
|
||||
|
||||
flaky
|
||||
.fail_head
|
||||
.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
assert!(
|
||||
store
|
||||
.upload_under_lock("owner", "repo", &local, true)
|
||||
.await
|
||||
.is_err(),
|
||||
"a failed existence check must not read as absent"
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.archive
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.head_etag("owner", "repo")
|
||||
.await
|
||||
.is_err(),
|
||||
"sanity: head still failing"
|
||||
);
|
||||
}
|
||||
|
||||
/// init() must remove its local dir when the initial upload fails, so a
|
||||
/// retry of the same name doesn't hit an existing destination.
|
||||
#[sqlx::test]
|
||||
async fn init_removes_local_dir_when_upload_fails(pool: PgPool) {
|
||||
let store_root = tempfile::tempdir().unwrap();
|
||||
let repos_dir = tempfile::tempdir().unwrap();
|
||||
let flaky = FlakyStore::new(store_root.path());
|
||||
let store = store_with_flaky(repos_dir.path().to_path_buf(), Arc::clone(&flaky), pool);
|
||||
|
||||
flaky
|
||||
.fail_put
|
||||
.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
assert!(store.init("did:key:z6MkOwner", "newrepo").await.is_err());
|
||||
let local = repos_dir
|
||||
.path()
|
||||
.join("did_key_z6MkOwner")
|
||||
.join("newrepo.git");
|
||||
assert!(
|
||||
!local.exists(),
|
||||
"failed init must not leave a local dir behind"
|
||||
);
|
||||
}
|
||||
|
||||
/// Marker + head failure: the write path fails closed, the read path
|
||||
/// serves the local copy.
|
||||
#[tokio::test]
|
||||
async fn marker_with_failing_head_fails_write_closed_serves_read() {
|
||||
let store_root = tempfile::tempdir().unwrap();
|
||||
let repos_dir = tempfile::tempdir().unwrap();
|
||||
let flaky = FlakyStore::new(store_root.path());
|
||||
// sync_down never touches the pool — lazy is fine here.
|
||||
let pool = sqlx::PgPool::connect_lazy("postgres://invalid").unwrap();
|
||||
let store = store_with_flaky(repos_dir.path().to_path_buf(), Arc::clone(&flaky), pool);
|
||||
|
||||
let local = repos_dir.path().join("owner").join("repo.git");
|
||||
std::fs::create_dir_all(&local).unwrap();
|
||||
std::fs::write(local.join("HEAD"), b"pending\n").unwrap();
|
||||
mark_pending_upload(&local, Some("base-etag")).unwrap();
|
||||
|
||||
flaky
|
||||
.fail_head
|
||||
.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
assert!(
|
||||
store
|
||||
.sync_down_if_stale("owner", "repo", &local, true)
|
||||
.await
|
||||
.is_err(),
|
||||
"write path must fail closed when freshness is unknowable"
|
||||
);
|
||||
store
|
||||
.sync_down_if_stale("owner", "repo", &local, false)
|
||||
.await
|
||||
.expect("read path serves the local copy");
|
||||
assert_eq!(std::fs::read(local.join("HEAD")).unwrap(), b"pending\n");
|
||||
}
|
||||
|
||||
/// Startup sweep: re-uploads marked repos whose storage didn't move, and
|
||||
/// leaves diverged ones marked.
|
||||
#[sqlx::test]
|
||||
async fn retry_pending_uploads_heals_and_respects_divergence(pool: PgPool) {
|
||||
let store_root = tempfile::tempdir().unwrap();
|
||||
let repos_dir = tempfile::tempdir().unwrap();
|
||||
let flaky = FlakyStore::new(store_root.path());
|
||||
let store = store_with_flaky(repos_dir.path().to_path_buf(), Arc::clone(&flaky), pool);
|
||||
let archive = store.archive.as_ref().unwrap();
|
||||
|
||||
// Repo A: storage v1, local ahead with matching base — heals.
|
||||
let seed = tempfile::tempdir().unwrap();
|
||||
std::fs::write(seed.path().join("HEAD"), b"v1\n").unwrap();
|
||||
archive.upload("owner", "heals", seed.path()).await.unwrap();
|
||||
let base_a = archive.head_etag("owner", "heals").await.unwrap().unwrap();
|
||||
let local_a = repos_dir.path().join("owner").join("heals.git");
|
||||
std::fs::create_dir_all(&local_a).unwrap();
|
||||
std::fs::write(local_a.join("HEAD"), b"local-ahead\n").unwrap();
|
||||
mark_pending_upload(&local_a, Some(&base_a)).unwrap();
|
||||
|
||||
// Repo B: marker base predates current storage — stays marked.
|
||||
archive
|
||||
.upload("owner", "diverged", seed.path())
|
||||
.await
|
||||
.unwrap();
|
||||
let local_b = repos_dir.path().join("owner").join("diverged.git");
|
||||
std::fs::create_dir_all(&local_b).unwrap();
|
||||
std::fs::write(local_b.join("HEAD"), b"local-b\n").unwrap();
|
||||
mark_pending_upload(&local_b, Some("stale-base")).unwrap();
|
||||
|
||||
let (reuploaded, still_pending) = store.retry_pending_uploads().await;
|
||||
assert_eq!((reuploaded, still_pending), (1, 1));
|
||||
assert!(!pending_upload_marker(&local_a).exists());
|
||||
assert!(pending_upload_marker(&local_b).exists());
|
||||
|
||||
// A's local content is now durably in storage.
|
||||
let out = tempfile::tempdir().unwrap();
|
||||
let restored = out.path().join("restored.git");
|
||||
archive.download("owner", "heals", &restored).await.unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read(restored.join("HEAD")).unwrap(),
|
||||
b"local-ahead\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -303,6 +303,20 @@ async fn main() -> Result<()> {
|
||||
|
||||
let repo_store = git::repo_store::RepoStore::new(config.repos_dir.clone(), archive, lock_pool);
|
||||
|
||||
// Re-attempt uploads for repos whose pending-upload marker survived a
|
||||
// crash or failed upload — otherwise a repo with no further writes stays
|
||||
// divergent from storage indefinitely. Background task: it takes the
|
||||
// per-repo advisory locks, so it serializes correctly with live pushes.
|
||||
{
|
||||
let store = repo_store.clone();
|
||||
tokio::spawn(async move {
|
||||
let (reuploaded, still_pending) = store.retry_pending_uploads().await;
|
||||
if reuploaded > 0 || still_pending > 0 {
|
||||
info!(reuploaded, still_pending, "pending-upload marker sweep");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Per-DID limiter for the creation endpoints. Keyed on the authenticated
|
||||
// DID (attacker-varied), so bound its key set to cap memory.
|
||||
let rate_limiter =
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
//! `gitlawb_pack_size_bytes`
|
||||
//! * a single `gitlawb_info{version, did}` gauge = 1, for joins/dashboards
|
||||
//! * currently-connected peer count — `gitlawb_peers_connected`
|
||||
//! * repos whose local copy is ahead of durable storage —
|
||||
//! `gitlawb_pending_upload_markers`
|
||||
//!
|
||||
//! All metrics live in a single process-wide registry initialized by
|
||||
//! [`init`]. Increment helpers (`record_push`, `record_auth_failure`, ...)
|
||||
@@ -51,6 +53,7 @@ static SYNC_PROCESSED: OnceLock<IntCounterVec> = OnceLock::new();
|
||||
static WEBHOOK_DELIVERIES: OnceLock<IntCounterVec> = OnceLock::new();
|
||||
static PACK_SIZE: OnceLock<Histogram> = OnceLock::new();
|
||||
static PEERS_CONNECTED: OnceLock<IntGauge> = OnceLock::new();
|
||||
static PENDING_UPLOAD_MARKERS: OnceLock<IntGauge> = OnceLock::new();
|
||||
|
||||
/// One-time initializer. Builds the registry, registers every metric,
|
||||
/// and sets the constant `gitlawb_info` gauge. Idempotent — calling
|
||||
@@ -197,6 +200,18 @@ pub fn init(version: &str, node_did: &str) {
|
||||
.set(peers_connected)
|
||||
.expect("set PEERS_CONNECTED once");
|
||||
|
||||
let pending_markers = IntGauge::with_opts(Opts::new(
|
||||
"gitlawb_pending_upload_markers",
|
||||
"Repos whose local copy is ahead of durable storage (pending-upload markers outstanding)",
|
||||
))
|
||||
.expect("gitlawb_pending_upload_markers definition");
|
||||
registry
|
||||
.register(Box::new(pending_markers.clone()))
|
||||
.expect("register gitlawb_pending_upload_markers");
|
||||
PENDING_UPLOAD_MARKERS
|
||||
.set(pending_markers)
|
||||
.expect("set PENDING_UPLOAD_MARKERS once");
|
||||
|
||||
REGISTRY
|
||||
.set(registry)
|
||||
.expect("set REGISTRY once (init must be called exactly once)");
|
||||
@@ -264,6 +279,14 @@ pub fn set_peers_connected(count: i64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the outstanding pending-upload marker gauge (repos whose local copy
|
||||
/// is ahead of durable storage).
|
||||
pub fn set_pending_upload_markers(count: i64) {
|
||||
if let Some(g) = PENDING_UPLOAD_MARKERS.get() {
|
||||
g.set(count);
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode the registry as the standard Prometheus text exposition format.
|
||||
/// Returns an error if `init` was never called.
|
||||
pub fn encode() -> Result<String, prometheus::Error> {
|
||||
|
||||
@@ -49,6 +49,23 @@ impl FsBlobStore {
|
||||
PathBuf::from(os)
|
||||
}
|
||||
|
||||
/// Per-final-path lock serializing the sidecar+blob publish step of `put`.
|
||||
/// The two renames are only pairwise-consistent when same-key puts don't
|
||||
/// interleave: unserialized, put A's content can land under put B's etag,
|
||||
/// and "etag unchanged ⇒ content unchanged" breaks in the direction that
|
||||
/// serves stale data as current. (Same never-evicted-map tradeoff as
|
||||
/// `archive::publish_lock` — bounded by distinct keys per process life.)
|
||||
fn put_publish_lock(path: &Path) -> std::sync::Arc<std::sync::Mutex<()>> {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
static LOCKS: OnceLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> = OnceLock::new();
|
||||
let locks = LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
let mut map = locks.lock().expect("put publish lock map poisoned");
|
||||
map.entry(path.to_path_buf())
|
||||
.or_insert_with(|| Arc::new(Mutex::new(())))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Fallback fingerprint for objects written before the sidecar existed.
|
||||
fn legacy_etag(md: &std::fs::Metadata) -> String {
|
||||
let mtime = md
|
||||
@@ -92,20 +109,32 @@ impl BlobStore for FsBlobStore {
|
||||
// no synchronous fs call ever touches the async runtime.
|
||||
tokio::task::spawn_blocking(move || -> Result<ObjectMeta> {
|
||||
std::fs::create_dir_all(&parent).context("creating blob parent dir")?;
|
||||
let etag = uuid::Uuid::new_v4().to_string();
|
||||
let sidecar = Self::sidecar_of(&path2);
|
||||
let sidecar_tmp = sidecar.with_extension(format!("{}.tmp-put", uuid::Uuid::new_v4()));
|
||||
let write_and_swap = (|| -> Result<()> {
|
||||
std::fs::write(&tmp, &body).context("writing temp blob")?;
|
||||
// Publish the new etag BEFORE the blob (each via its own
|
||||
// tmp+rename): a crash between the two renames then yields
|
||||
// new-etag/old-content — a redundant download, or at worst a
|
||||
// loud-and-safe pending-marker divergence — instead of
|
||||
// old-etag/new-content, which etag-equality consumers would
|
||||
// silently serve as current while stale. The publish pair is
|
||||
// serialized per key so concurrent same-key puts can't
|
||||
// interleave one put's etag with another's content.
|
||||
let publish = Self::put_publish_lock(&path2);
|
||||
let _guard = publish.lock().expect("put publish lock poisoned");
|
||||
std::fs::write(&sidecar_tmp, &etag).context("writing etag sidecar")?;
|
||||
std::fs::rename(&sidecar_tmp, &sidecar).context("publishing etag sidecar")?;
|
||||
std::fs::rename(&tmp, &path2).context("renaming blob into place")?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(e) = write_and_swap {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
let _ = std::fs::remove_file(&sidecar_tmp);
|
||||
return Err(e);
|
||||
}
|
||||
let md = std::fs::metadata(&path2).context("stat blob after write")?;
|
||||
// Persist a fresh per-write etag; see `sidecar_of` for why
|
||||
// size-mtime is not collision-resistant enough here.
|
||||
let etag = uuid::Uuid::new_v4().to_string();
|
||||
std::fs::write(Self::sidecar_of(&path2), &etag).context("writing etag sidecar")?;
|
||||
Ok(ObjectMeta {
|
||||
size: md.len(),
|
||||
etag: Some(etag),
|
||||
@@ -250,9 +279,18 @@ mod tests {
|
||||
let m2 = store.put(key, body).await.unwrap();
|
||||
assert_ne!(m1.etag, m2.etag, "every put must produce a new etag");
|
||||
|
||||
// head() reports the latest persisted etag.
|
||||
// head() reports the latest persisted etag, and it really is the
|
||||
// sidecar's content — not a size-mtime fingerprint that would also
|
||||
// pass the assert_ne above when consecutive writes get distinct
|
||||
// mtimes.
|
||||
let h = store.head(key).await.unwrap().unwrap();
|
||||
assert_eq!(h.etag, m2.etag);
|
||||
let sidecar = dir.path().join(format!("{key}.etag"));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&sidecar).unwrap(),
|
||||
m2.etag.clone().unwrap(),
|
||||
"etag must come from the persisted sidecar"
|
||||
);
|
||||
|
||||
// delete() removes the sidecar with the object.
|
||||
store.delete(key).await.unwrap();
|
||||
|
||||
Reference in new issue
Block a user