From 575b23adb61e469ba21d160f12ad3fca35423984 Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:08:50 -0700 Subject: [PATCH] Revert "feat(relay): inventory unreachable Git objects (#2264)" This reverts commit 3afc9dae159262220c4149e9c8add50772869318. --- .env.example | 8 - bin/.helm-3.16.4.pkg | 1 - bin/helm | 1 - crates/buzz-db/src/lib.rs | 21 +- crates/buzz-relay/src/api/git/gc.rs | 529 ------------------- crates/buzz-relay/src/api/git/mod.rs | 1 - crates/buzz-relay/src/api/git/store.rs | 84 --- crates/buzz-relay/src/main.rs | 31 +- crates/buzz-relay/src/metrics.rs | 5 - deploy/charts/buzz/templates/deployment.yaml | 6 - deploy/charts/buzz/values.schema.json | 14 +- deploy/charts/buzz/values.yaml | 7 - docs/git-on-object-storage.md | 11 - 13 files changed, 19 insertions(+), 700 deletions(-) delete mode 120000 bin/.helm-3.16.4.pkg delete mode 120000 bin/helm delete mode 100644 crates/buzz-relay/src/api/git/gc.rs diff --git a/.env.example b/.env.example index 3dccd6a174..1e69c6a0e3 100644 --- a/.env.example +++ b/.env.example @@ -75,14 +75,6 @@ RELAY_URL=ws://localhost:3000 # BUZZ_GIT_PACK_CACHE_PATH=./repos/.pack-cache # BUZZ_GIT_PACK_CACHE_MAX_BYTES=5368709120 # BUZZ_GIT_PACK_CACHE_MAX_CONCURRENT_POPULATIONS=2 -# Deployment-global dry-run inventory for future physical object-store GC. -# No objects are deleted. Disabled by default. -# BUZZ_GIT_GC_ENABLED=false -# BUZZ_GIT_GC_INTERVAL_SECS=3600 -# BUZZ_GIT_GC_MAX_POINTERS=10000 -# BUZZ_GIT_GC_MAX_OBJECTS_PER_PREFIX=10000 -# BUZZ_GIT_GC_MAX_MANIFEST_BYTES_PER_SCAN=536870912 -# BUZZ_GIT_GC_SCAN_TIMEOUT_SECS=300 # ----------------------------------------------------------------------------- # Media Upload Admission diff --git a/bin/.helm-3.16.4.pkg b/bin/.helm-3.16.4.pkg deleted file mode 120000 index 383f4511d4..0000000000 --- a/bin/.helm-3.16.4.pkg +++ /dev/null @@ -1 +0,0 @@ -hermit \ No newline at end of file diff --git a/bin/helm b/bin/helm deleted file mode 120000 index 840f4f2a13..0000000000 --- a/bin/helm +++ /dev/null @@ -1 +0,0 @@ -.helm-3.16.4.pkg \ No newline at end of file diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 3eeff5dee7..fdd72c3c32 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -198,16 +198,16 @@ pub struct DbPoolStats { pub max: u32, } -/// Owns a detached Postgres session holding a relay advisory leader lock. +/// Owns the detached Postgres session holding the relay usage-metrics advisory lock. /// /// The connection deliberately does not return to the main pool: session advisory /// locks must remain bound to this exact physical connection, and the poller /// pings it before each leader-only collection tick. -pub struct AdvisoryLockLeader { +pub struct UsageMetricsLeader { connection: PgConnection, } -impl AdvisoryLockLeader { +impl UsageMetricsLeader { /// Returns whether the lock-owning session is still reachable. /// /// Bounded to 5 seconds — a blackholed connection (no RST) would otherwise @@ -508,20 +508,23 @@ impl Db { }) } - /// Try to acquire a detached session advisory lock for singleton relay work. + /// Try to acquire the detached session advisory lock for relay usage metrics. /// /// The returned guard owns the exact connection that acquired the lock. It is /// detached from the shared pool so a stable leader neither returns a locked /// session to other callers nor permanently consumes a pool slot. Dropping the /// guard closes the connection and releases the session-scoped lock. - pub async fn try_advisory_lock(&self, lock_key: i64) -> Result> { + pub async fn try_lock_usage_metrics( + &self, + lock_key: i64, + ) -> Result> { let mut connection = self.pool.acquire().await?; let acquired = sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)") .bind(lock_key) .fetch_one(&mut *connection) .await?; if acquired { - Ok(Some(AdvisoryLockLeader { + Ok(Some(UsageMetricsLeader { connection: connection.detach(), })) } else { @@ -4660,14 +4663,14 @@ mod tests { let key = 0x4255_5A5A_4D45_5452; let mut leader = first - .try_advisory_lock(key) + .try_lock_usage_metrics(key) .await .expect("first lock attempt") .expect("first database handle becomes leader"); assert!(leader.is_live().await, "lock owner remains reachable"); assert!( second - .try_advisory_lock(key) + .try_lock_usage_metrics(key) .await .expect("second lock attempt") .is_none(), @@ -4677,7 +4680,7 @@ mod tests { drop(leader); assert!( second - .try_advisory_lock(key) + .try_lock_usage_metrics(key) .await .expect("lock attempt after leader drop") .is_some(), diff --git a/crates/buzz-relay/src/api/git/gc.rs b/crates/buzz-relay/src/api/git/gc.rs deleted file mode 100644 index 992b8c0220..0000000000 --- a/crates/buzz-relay/src/api/git/gc.rs +++ /dev/null @@ -1,529 +0,0 @@ -//! Dry-run inventory for future physical Git object-store garbage collection. -//! -//! This module deliberately does not delete objects. It computes a -//! deployment-global reachability snapshot and reports immutable objects that -//! were not referenced by any current repository pointer. Deletion needs a -//! durable, continuous-unreachability grace period plus coordination with -//! concurrent publishers; neither safety condition should be approximated. - -use std::collections::HashSet; -use std::sync::Arc; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; - -use super::manifest::Manifest; -use super::store::{GitStore, ObjectList, StoreError, StoredObject}; -use crate::state::AppState; - -const MAX_GC_MANIFEST_BYTES: u64 = 4 * 1024 * 1024; -const IMMUTABLE_PREFIXES: [&str; 3] = ["manifests/", "packs/", "idx/"]; -const GIT_GC_LOCK_KEY: i64 = 0x4255_5A5A_4749_5447; - -/// Configuration for the singleton dry-run inventory worker. -#[derive(Debug, Clone, Copy)] -pub struct GitGcWorkerConfig { - /// Whether the worker is enabled. - pub enabled: bool, - /// Delay between inventory scans. - pub interval: Duration, - /// Per-scan inventory limits. - pub limits: GitGcScanLimits, -} - -impl GitGcWorkerConfig { - /// Read worker configuration from `BUZZ_GIT_GC_*` environment variables. - pub fn from_env() -> Result { - Ok(Self { - enabled: parse_bool_env("BUZZ_GIT_GC_ENABLED", false)?, - interval: Duration::from_secs(parse_positive_u64_env( - "BUZZ_GIT_GC_INTERVAL_SECS", - 3_600, - )?), - limits: GitGcScanLimits { - max_pointers: parse_positive_usize_env("BUZZ_GIT_GC_MAX_POINTERS", 10_000)?, - max_objects_per_prefix: parse_positive_usize_env( - "BUZZ_GIT_GC_MAX_OBJECTS_PER_PREFIX", - 10_000, - )?, - max_manifest_bytes: parse_positive_u64_env( - "BUZZ_GIT_GC_MAX_MANIFEST_BYTES_PER_SCAN", - 512 * 1024 * 1024, - )?, - timeout: Duration::from_secs(parse_positive_u64_env( - "BUZZ_GIT_GC_SCAN_TIMEOUT_SECS", - 300, - )?), - }, - }) - } -} - -/// Limits for one dry-run inventory scan. -#[derive(Debug, Clone, Copy)] -pub struct GitGcScanLimits { - /// Maximum repository pointers that may be considered. - pub max_pointers: usize, - /// Maximum objects listed from each immutable Git prefix. - pub max_objects_per_prefix: usize, - /// Maximum manifest bytes downloaded while marking live pointers. - pub max_manifest_bytes: u64, - /// Hard deadline for one complete inventory attempt. - pub timeout: Duration, -} - -impl Default for GitGcScanLimits { - fn default() -> Self { - Self { - max_pointers: 10_000, - max_objects_per_prefix: 10_000, - max_manifest_bytes: 512 * 1024 * 1024, - timeout: Duration::from_secs(300), - } - } -} - -/// Run the leader-elected dry-run inventory worker until process shutdown. -pub async fn run_git_gc_worker(state: Arc, config: GitGcWorkerConfig) { - if !config.enabled { - return; - } - - let jitter_bound = config.interval.as_secs().max(1); - tokio::time::sleep(Duration::from_secs(rand::random::() % jitter_bound)).await; - let mut interval = tokio::time::interval(config.interval); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - let mut leader: Option = None; - - loop { - interval.tick().await; - let mut demoted = false; - if let Some(leader_guard) = leader.as_mut() { - if !leader_guard.is_live().await { - tracing::warn!("Git object-store GC inventory leader demoting"); - leader = None; - demoted = true; - } - } - if leader.is_none() && !demoted { - match state.db.try_advisory_lock(GIT_GC_LOCK_KEY).await { - Ok(acquired) => { - leader = acquired; - if leader.is_some() { - tracing::info!("Acquired Git object-store GC inventory leader lock"); - } - } - Err(error) => { - tracing::warn!(%error, "Git object-store GC leader election failed"); - } - } - } - metrics::gauge!("buzz_git_object_store_gc_is_leader").set(if leader.is_some() { - 1.0 - } else { - 0.0 - }); - - if leader.is_none() { - continue; - } - match scan_git_object_store(&state.git_store, config.limits).await { - Ok(report) => { - tracing::info!( - pointers = report.pointers, - reachable_objects = report.reachable_objects, - observed_candidate_objects = report.observed_candidate_objects, - observed_candidate_bytes = report.observed_candidate_bytes, - pagination_complete = report.pagination_complete, - "Git object-store GC dry-run inventory completed" - ); - } - Err(error) => { - tracing::warn!(%error, "Git object-store GC dry-run inventory failed"); - } - } - } -} - -/// Result of one dry-run inventory scan. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct GitGcScanReport { - /// Number of current repository pointers inspected. - pub pointers: usize, - /// Number of immutable Git objects marked reachable. - pub reachable_objects: usize, - /// Number of listed immutable objects not in the reachability set. - pub observed_candidate_objects: usize, - /// Total bytes represented by the candidates. - pub observed_candidate_bytes: u64, - /// False when any bounded prefix listing was truncated. - pub pagination_complete: bool, -} - -/// Errors from a dry-run inventory scan. -#[derive(Debug, thiserror::Error)] -pub enum GitGcError { - /// Object-store operation failed. - #[error(transparent)] - Store(#[from] StoreError), - /// A live pointer or manifest was malformed, so classification failed closed. - #[error("invalid live Git object {key}: {reason}")] - InvalidLiveObject { - /// Pointer or manifest key. - key: String, - /// Validation failure. - reason: String, - }, - /// Pointer inventory exceeded its safety bound. - #[error("repository pointer inventory exceeded the configured limit")] - PointerInventoryTruncated, - /// Live-manifest reads exceeded the configured transfer budget. - #[error("live manifest inventory exceeded the configured byte budget")] - ManifestBudgetExceeded, - /// The scan did not finish before its configured deadline. - #[error("Git object-store inventory scan timed out")] - TimedOut, -} - -/// Scan all current Git pointers and classify unreferenced immutable objects. -/// -/// This is observability only. A candidate is not necessarily safe to delete: -/// the scan does not prove continuous unreachability or exclude a concurrent -/// publisher from making an existing content-addressed object reachable. -pub async fn scan_git_object_store( - store: &GitStore, - limits: GitGcScanLimits, -) -> Result { - let started = Instant::now(); - let result = tokio::time::timeout(limits.timeout, scan_git_object_store_inner(store, limits)) - .await - .map_err(|_| GitGcError::TimedOut) - .and_then(|result| result); - match &result { - Ok(report) => { - metrics::counter!("buzz_git_object_store_gc_scans_total", "result" => "success") - .increment(1); - metrics::gauge!("buzz_git_object_store_gc_observed_candidate_objects") - .set(report.observed_candidate_objects as f64); - metrics::gauge!("buzz_git_object_store_gc_observed_candidate_bytes") - .set(report.observed_candidate_bytes as f64); - metrics::gauge!("buzz_git_object_store_gc_scan_complete") - .set(if report.pagination_complete { 1.0 } else { 0.0 }); - metrics::gauge!("buzz_git_object_store_gc_last_success_timestamp_seconds") - .set(unix_timestamp_seconds()); - } - Err(_) => { - metrics::counter!("buzz_git_object_store_gc_scans_total", "result" => "error") - .increment(1); - metrics::gauge!("buzz_git_object_store_gc_scan_complete").set(0.0); - } - } - metrics::histogram!("buzz_git_object_store_gc_scan_seconds") - .record(started.elapsed().as_secs_f64()); - result -} - -async fn scan_git_object_store_inner( - store: &GitStore, - limits: GitGcScanLimits, -) -> Result { - let pointer_list = store.list_prefix("repos/", limits.max_pointers).await?; - if pointer_list.truncated { - return Err(GitGcError::PointerInventoryTruncated); - } - - let pointers: Vec<_> = pointer_list - .objects - .into_iter() - .filter(|object| object.key.ends_with("/pointer")) - .collect(); - let mut reachable = HashSet::new(); - let mut manifest_bytes = 0u64; - for pointer in &pointers { - let remaining = limits.max_manifest_bytes.saturating_sub(manifest_bytes); - if remaining == 0 { - return Err(GitGcError::ManifestBudgetExceeded); - } - manifest_bytes = manifest_bytes.saturating_add( - mark_current_manifest(store, &pointer.key, &mut reachable, remaining).await?, - ); - } - - let mut observed_candidate_objects = 0usize; - let mut observed_candidate_bytes = 0u64; - let mut pagination_complete = true; - for prefix in IMMUTABLE_PREFIXES { - let listed = store - .list_prefix(prefix, limits.max_objects_per_prefix) - .await?; - pagination_complete &= !listed.truncated; - let (count, bytes) = classify_candidates(&listed, &reachable); - observed_candidate_objects = observed_candidate_objects.saturating_add(count); - observed_candidate_bytes = observed_candidate_bytes.saturating_add(bytes); - } - - Ok(GitGcScanReport { - pointers: pointers.len(), - reachable_objects: reachable.len(), - observed_candidate_objects, - observed_candidate_bytes, - pagination_complete, - }) -} - -async fn mark_current_manifest( - store: &GitStore, - pointer_key: &str, - reachable: &mut HashSet, - remaining_manifest_bytes: u64, -) -> Result { - let Some((_etag, pointer_body)) = store.get_pointer(pointer_key).await? else { - return Ok(0); - }; - let digest = std::str::from_utf8(&pointer_body) - .map(str::trim) - .map_err(|error| GitGcError::InvalidLiveObject { - key: pointer_key.to_string(), - reason: error.to_string(), - })?; - if !is_digest(digest) { - return Err(GitGcError::InvalidLiveObject { - key: pointer_key.to_string(), - reason: "pointer body is not a SHA-256 digest".to_string(), - }); - } - - let manifest_key = format!("manifests/{digest}"); - let read_limit = remaining_manifest_bytes.min(MAX_GC_MANIFEST_BYTES); - let bytes = match store - .get_verified_limited(&manifest_key, digest, read_limit) - .await - { - Err(StoreError::ObjectTooLarge { .. }) - if remaining_manifest_bytes < MAX_GC_MANIFEST_BYTES => - { - return Err(GitGcError::ManifestBudgetExceeded); - } - result => result?, - }; - let manifest = Manifest::from_bytes(&bytes).map_err(|error| GitGcError::InvalidLiveObject { - key: manifest_key.clone(), - reason: error.to_string(), - })?; - manifest - .validate() - .map_err(|error| GitGcError::InvalidLiveObject { - key: manifest_key.clone(), - reason: error.to_string(), - })?; - - reachable.insert(manifest_key); - for pack_key in manifest.packs { - if let Some(digest) = pack_key.strip_prefix("packs/") { - reachable.insert(format!("idx/{digest}")); - } - reachable.insert(pack_key); - } - Ok(bytes.len() as u64) -} - -fn classify_candidates(listed: &ObjectList, reachable: &HashSet) -> (usize, u64) { - listed - .objects - .iter() - .filter(|object| is_gc_object(object) && !reachable.contains(&object.key)) - .fold((0usize, 0u64), |(count, bytes), object| { - (count.saturating_add(1), bytes.saturating_add(object.size)) - }) -} - -fn is_gc_object(object: &StoredObject) -> bool { - IMMUTABLE_PREFIXES - .iter() - .any(|prefix| object.key.strip_prefix(prefix).is_some_and(is_digest)) -} - -fn is_digest(value: &str) -> bool { - value.len() == 64 && value.chars().all(|character| character.is_ascii_hexdigit()) -} - -fn unix_timestamp_seconds() -> f64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs_f64() -} - -fn parse_bool_env(name: &str, default: bool) -> Result { - match std::env::var(name) { - Ok(value) => match value.trim().to_ascii_lowercase().as_str() { - "true" | "1" => Ok(true), - "false" | "0" => Ok(false), - _ => Err(format!("{name} must be true or false")), - }, - Err(std::env::VarError::NotPresent) => Ok(default), - Err(std::env::VarError::NotUnicode(_)) => Err(format!("{name} must be valid Unicode")), - } -} - -fn parse_positive_u64_env(name: &str, default: u64) -> Result { - match std::env::var(name) { - Ok(value) => value - .parse::() - .ok() - .filter(|value| *value > 0) - .ok_or_else(|| format!("{name} must be a positive integer")), - Err(std::env::VarError::NotPresent) => Ok(default), - Err(std::env::VarError::NotUnicode(_)) => Err(format!("{name} must be valid Unicode")), - } -} - -fn parse_positive_usize_env(name: &str, default: usize) -> Result { - match std::env::var(name) { - Ok(value) => value - .parse::() - .ok() - .filter(|value| *value > 0) - .ok_or_else(|| format!("{name} must be a positive integer")), - Err(std::env::VarError::NotPresent) => Ok(default), - Err(std::env::VarError::NotUnicode(_)) => Err(format!("{name} must be valid Unicode")), - } -} - -#[cfg(test)] -mod tests { - use std::collections::{BTreeMap, HashSet}; - use std::time::Duration; - - use super::{ - classify_candidates, is_digest, parse_bool_env, parse_positive_u64_env, - parse_positive_usize_env, scan_git_object_store, GitGcScanLimits, - }; - use crate::api::git::manifest::{Manifest, MANIFEST_VERSION}; - use crate::api::git::store::{GitStore, ObjectList, Precond, StoredObject}; - - fn object(key: &str, size: u64) -> StoredObject { - StoredObject { - key: key.to_string(), - size, - last_modified: "2026-07-21T00:00:00Z".to_string(), - } - } - - #[test] - fn candidate_classification_is_prefix_scoped_and_reachability_aware() { - let live_pack = format!("packs/{}", "a".repeat(64)); - let orphan_pack = format!("packs/{}", "b".repeat(64)); - let listed = ObjectList { - objects: vec![ - object(&live_pack, 10), - object(&orphan_pack, 20), - object("packs/not-a-digest", 30), - object(&format!("media/{}", "c".repeat(64)), 40), - ], - truncated: false, - }; - let reachable = HashSet::from([live_pack]); - - assert_eq!(classify_candidates(&listed, &reachable), (1, 20)); - } - - #[test] - fn digest_validation_is_exact() { - assert!(is_digest(&"a".repeat(64))); - assert!(!is_digest(&"a".repeat(63))); - assert!(!is_digest(&"g".repeat(64))); - } - - #[test] - fn gc_environment_values_are_strict() { - assert_eq!( - parse_bool_env("BUZZ_TEST_GC_MISSING_BOOL", false), - Ok(false) - ); - assert_eq!( - parse_positive_u64_env("BUZZ_TEST_GC_MISSING_U64", 10), - Ok(10) - ); - assert_eq!( - parse_positive_usize_env("BUZZ_TEST_GC_MISSING_USIZE", 10), - Ok(10) - ); - } - - fn live_store() -> GitStore { - let endpoint = std::env::var("BUZZ_GIT_S3_ENDPOINT") - .or_else(|_| std::env::var("BUZZ_S3_ENDPOINT")) - .unwrap_or_else(|_| "http://localhost:9000".into()); - let access_key = std::env::var("BUZZ_GIT_S3_ACCESS_KEY") - .or_else(|_| std::env::var("BUZZ_S3_ACCESS_KEY")) - .unwrap_or_else(|_| "buzz_dev".into()); - let secret_key = std::env::var("BUZZ_GIT_S3_SECRET_KEY") - .or_else(|_| std::env::var("BUZZ_S3_SECRET_KEY")) - .unwrap_or_else(|_| "buzz_dev_secret".into()); - let bucket = std::env::var("BUZZ_GIT_S3_BUCKET") - .or_else(|_| std::env::var("BUZZ_S3_BUCKET")) - .unwrap_or_else(|_| "buzz-media".into()); - let region = std::env::var("BUZZ_GIT_S3_REGION") - .or_else(|_| std::env::var("BUZZ_S3_REGION")) - .unwrap_or_else(|_| "us-east-1".into()); - GitStore::new(&endpoint, &access_key, &secret_key, &bucket, ®ion) - .expect("connect to live object store") - } - - #[tokio::test] - async fn live_scan_marks_current_objects_and_observes_orphans() { - if std::env::var("BUZZ_GIT_S3_PROBE").as_deref() != Ok("1") { - return; - } - - let store = live_store(); - let live_pack_key = store - .put_pack(b"live-gc-pack") - .await - .expect("put live pack"); - let orphan_pack_key = store - .put_pack(b"orphan-gc-pack") - .await - .expect("put orphan pack"); - let commit = "1".repeat(40); - let manifest = Manifest { - version: MANIFEST_VERSION, - head: "refs/heads/main".to_string(), - refs: BTreeMap::from([("refs/heads/main".to_string(), commit)]), - packs: vec![live_pack_key.clone()], - parent: None, - }; - let manifest_key = store - .put_manifest(&manifest.canonical_bytes().expect("manifest bytes")) - .await - .expect("put manifest"); - let digest = manifest_key - .strip_prefix("manifests/") - .expect("manifest digest"); - let pointer_key = format!("repos/{}/gc-test/repo/pointer", uuid::Uuid::new_v4()); - store - .put_pointer(&pointer_key, digest.as_bytes(), Precond::IfNoneMatchStar) - .await - .expect("put pointer"); - - let report = scan_git_object_store( - &store, - GitGcScanLimits { - max_pointers: 10_000, - max_objects_per_prefix: 10_000, - max_manifest_bytes: 64 * 1024 * 1024, - timeout: Duration::from_secs(30), - }, - ) - .await - .expect("scan object store"); - assert!(report.pointers >= 1); - assert!(report.reachable_objects >= 3); - assert!(report.observed_candidate_objects >= 1); - assert!(report.observed_candidate_bytes >= b"orphan-gc-pack".len() as u64); - - store.delete_for_test(&pointer_key).await; - store.delete_for_test(&manifest_key).await; - store.delete_for_test(&live_pack_key).await; - store.delete_for_test(&orphan_pack_key).await; - } -} diff --git a/crates/buzz-relay/src/api/git/mod.rs b/crates/buzz-relay/src/api/git/mod.rs index c6c4370274..ab0510fbeb 100644 --- a/crates/buzz-relay/src/api/git/mod.rs +++ b/crates/buzz-relay/src/api/git/mod.rs @@ -23,7 +23,6 @@ use tower_http::limit::RequestBodyLimitLayer; use crate::state::AppState; pub mod cas_publish; -pub mod gc; pub mod hook; pub mod hydrate; pub mod manifest; diff --git a/crates/buzz-relay/src/api/git/store.rs b/crates/buzz-relay/src/api/git/store.rs index 8712509d0c..43d210e648 100644 --- a/crates/buzz-relay/src/api/git/store.rs +++ b/crates/buzz-relay/src/api/git/store.rs @@ -145,26 +145,6 @@ pub struct ProbeReport { pub transport_drops: usize, } -/// One object returned by a bounded prefix listing. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct StoredObject { - /// Full object key. - pub key: String, - /// Object size reported by the backend. - pub size: u64, - /// Backend-provided last-modified timestamp. - pub last_modified: String, -} - -/// Bounded result from listing one object-store prefix. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ObjectList { - /// Objects returned, up to the caller's limit. - pub objects: Vec, - /// Whether more objects exist beyond this result. - pub truncated: bool, -} - /// Failure carrying the phase that failed plus enough context to diagnose. #[derive(Debug, thiserror::Error)] #[error("conformance probe failed in phase '{phase}' (round {round}, key {key}): {reason}")] @@ -260,70 +240,6 @@ impl GitStore { Ok(format!("idx/{pack_digest}")) } - /// List at most `max_objects` objects below `prefix`. - /// - /// Pagination is handled internally, but the caller-provided bound keeps - /// background inventory work from growing without limit. A truncated - /// result must never be treated as a complete reachability snapshot. - pub async fn list_prefix( - &self, - prefix: &str, - max_objects: usize, - ) -> Result { - if max_objects == 0 { - return Ok(ObjectList { - objects: Vec::new(), - truncated: true, - }); - } - - let mut objects = Vec::new(); - let mut continuation_token = None; - loop { - let remaining = max_objects.saturating_sub(objects.len()); - if remaining == 0 { - return Ok(ObjectList { - objects, - truncated: continuation_token.is_some(), - }); - } - let page_size = remaining.min(1_000); - let (page, status) = self - .bucket - .list_page( - prefix.to_string(), - None, - continuation_token, - None, - Some(page_size), - ) - .await?; - if !(200..300).contains(&status) { - return Err(StoreError::Backend(S3Error::HttpFailWithBody( - status, - "unexpected list status".to_string(), - ))); - } - objects.extend(page.contents.into_iter().map(|object| StoredObject { - key: object.key, - size: object.size, - last_modified: object.last_modified, - })); - continuation_token = page.next_continuation_token; - if continuation_token.is_none() { - return Ok(ObjectList { - objects, - truncated: false, - }); - } - } - } - - #[cfg(test)] - pub(crate) async fn delete_for_test(&self, key: &str) { - let _ = self.bucket.delete_object(key).await; - } - /// Create-only write of a content-addressed object (pack or manifest). /// /// **The caller does not choose the key.** It is derived as diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 5f04b894d1..eed4a53f8e 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -133,14 +133,7 @@ async fn main() -> anyhow::Result<()> { ); let usage_interval_secs = usage_metrics_interval_secs(); - let git_gc_config = - buzz_relay::api::git::gc::GitGcWorkerConfig::from_env().map_err(anyhow::Error::msg)?; - let metrics_refresh_interval_secs = if git_gc_config.enabled { - usage_interval_secs.max(git_gc_config.interval.as_secs()) - } else { - usage_interval_secs - }; - let usage_idle_timeout_secs = usage_metrics_idle_timeout_secs(metrics_refresh_interval_secs); + let usage_idle_timeout_secs = usage_metrics_idle_timeout_secs(usage_interval_secs); relay_metrics::install(config.metrics_port, usage_idle_timeout_secs); metrics::gauge!("buzz_audit_enabled").set(if config.audit_enabled { 1.0 } else { 0.0 }); info!( @@ -505,21 +498,6 @@ async fn main() -> anyhow::Result<()> { ); } - if git_gc_config.enabled { - info!( - interval_seconds = git_gc_config.interval.as_secs(), - max_pointers = git_gc_config.limits.max_pointers, - max_objects_per_prefix = git_gc_config.limits.max_objects_per_prefix, - max_manifest_bytes = git_gc_config.limits.max_manifest_bytes, - scan_timeout_seconds = git_gc_config.limits.timeout.as_secs(), - "Git object-store GC dry-run inventory enabled" - ); - tokio::spawn(buzz_relay::api::git::gc::run_git_gc_worker( - Arc::clone(&state), - git_gc_config, - )); - } - // NIP-43: reconcile the event-backed roster for every provisioned // community before opening the listener. `relay_members` is canonical; // this repairs pre-snapshot communities and any publication that failed @@ -1393,7 +1371,7 @@ fn dropped_in_memory_keys( async fn run_usage_metrics_tick( state: &AppState, emission_scope: &EmissionScope, - leader: &mut Option, + leader: &mut Option, emitted_in_memory: &mut HashSet, ) -> anyhow::Result<()> { let host_map: HashMap = match state.db.usage_community_hosts().await { @@ -1421,7 +1399,10 @@ async fn run_usage_metrics_tick( } } if leader.is_none() && !demoted { - *leader = state.db.try_advisory_lock(USAGE_METRICS_LOCK_KEY).await?; + *leader = state + .db + .try_lock_usage_metrics(USAGE_METRICS_LOCK_KEY) + .await?; if leader.is_some() { info!("Acquired usage metrics leader lock"); } diff --git a/crates/buzz-relay/src/metrics.rs b/crates/buzz-relay/src/metrics.rs index 7ff27fb5f8..16e521a44e 100644 --- a/crates/buzz-relay/src/metrics.rs +++ b/crates/buzz-relay/src/metrics.rs @@ -102,11 +102,6 @@ pub fn install(port: u16, gauge_idle_timeout_secs: u64) { &GIT_DURATION_BUCKETS_S, ) .expect("valid git compaction duration bucket boundaries") - .set_buckets_for_metric( - Matcher::Full("buzz_git_object_store_gc_scan_seconds".to_owned()), - &GIT_DURATION_BUCKETS_S, - ) - .expect("valid git GC inventory duration bucket boundaries") .set_buckets_for_metric( Matcher::Full("buzz_git_hydrate_bytes".to_owned()), &GIT_BYTES_BUCKETS, diff --git a/deploy/charts/buzz/templates/deployment.yaml b/deploy/charts/buzz/templates/deployment.yaml index 5b22566dd4..f8d67de31d 100644 --- a/deploy/charts/buzz/templates/deployment.yaml +++ b/deploy/charts/buzz/templates/deployment.yaml @@ -150,12 +150,6 @@ spec: - { name: BUZZ_GIT_PACK_CACHE_MAX_CONCURRENT_POPULATIONS, value: {{ .Values.git.packCacheMaxConcurrentPopulations | quote }} } - { name: BUZZ_GIT_MAX_REPOS_PER_PUBKEY, value: {{ .Values.git.maxReposPerPubkey | quote }} } - { name: BUZZ_GIT_MAX_CONCURRENT_OPS, value: {{ .Values.git.maxConcurrentOps | quote }} } - - { name: BUZZ_GIT_GC_ENABLED, value: {{ .Values.git.gc.enabled | quote }} } - - { name: BUZZ_GIT_GC_INTERVAL_SECS, value: {{ .Values.git.gc.intervalSeconds | quote }} } - - { name: BUZZ_GIT_GC_MAX_POINTERS, value: {{ .Values.git.gc.maxPointers | quote }} } - - { name: BUZZ_GIT_GC_MAX_OBJECTS_PER_PREFIX, value: {{ .Values.git.gc.maxObjectsPerPrefix | quote }} } - - { name: BUZZ_GIT_GC_MAX_MANIFEST_BYTES_PER_SCAN, value: {{ .Values.git.gc.maxManifestBytesPerScan | quote }} } - - { name: BUZZ_GIT_GC_SCAN_TIMEOUT_SECS, value: {{ .Values.git.gc.scanTimeoutSeconds | quote }} } # ── S3 (non-secret) ────────────────────────────────────── {{- $s3Endpoint := include "buzz.s3Endpoint" . }} diff --git a/deploy/charts/buzz/values.schema.json b/deploy/charts/buzz/values.schema.json index 16d10a67e2..203fd9b69b 100644 --- a/deploy/charts/buzz/values.schema.json +++ b/deploy/charts/buzz/values.schema.json @@ -204,19 +204,7 @@ "packCacheMaxConcurrentPopulations": { "type": "integer", "minimum": 1 }, "packCacheVolumeSize": { "type": "string", "pattern": "^[0-9]+(\\.[0-9]+)?(E|P|T|G|M|K|Ei|Pi|Ti|Gi|Mi|Ki)?$" }, "maxReposPerPubkey": { "type": "integer", "minimum": 1 }, - "maxConcurrentOps": { "type": "integer", "minimum": 1 }, - "gc": { - "type": "object", - "additionalProperties": false, - "properties": { - "enabled": { "type": "boolean" }, - "intervalSeconds": { "type": "integer", "minimum": 1 }, - "maxPointers": { "type": "integer", "minimum": 1 }, - "maxObjectsPerPrefix": { "type": "integer", "minimum": 1 }, - "maxManifestBytesPerScan": { "type": "integer", "minimum": 1 }, - "scanTimeoutSeconds": { "type": "integer", "minimum": 1 } - } - } + "maxConcurrentOps": { "type": "integer", "minimum": 1 } } }, "migrate": { diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index add4c78f1a..27c6503793 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -329,13 +329,6 @@ git: packCacheVolumeSize: 7Gi # per-pod emptyDir; includes cold-population staging maxReposPerPubkey: 100 maxConcurrentOps: 20 - gc: - enabled: false # dry-run inventory only; never deletes objects - intervalSeconds: 3600 - maxPointers: 10000 - maxObjectsPerPrefix: 10000 - maxManifestBytesPerScan: 536870912 - scanTimeoutSeconds: 300 # ── Migrations ─────────────────────────────────────────────────────────────── # Relay runs sqlx migrations at startup via BUZZ_AUTO_MIGRATE=true. diff --git a/docs/git-on-object-storage.md b/docs/git-on-object-storage.md index a84c349c2f..1d87720edc 100644 --- a/docs/git-on-object-storage.md +++ b/docs/git-on-object-storage.md @@ -158,17 +158,6 @@ assumption for any S3-compatible backend. orphaned could 404 a concurrent reader mid-hydrate — see Theorem 2's reliance on every named pack being GETtable.) - The relay includes an opt-in, leader-elected **dry-run inventory** for this - future GC (`BUZZ_GIT_GC_ENABLED=true`). It lists only `manifests/`, `packs/`, - and `idx/`, computes reachability from every current `repos/*/pointer`, and - exports observed candidate counts and bytes. Pointer count, listed objects, - manifest bytes, and scan duration are bounded; truncated prefix inventories - are explicitly reported as incomplete. It never deletes. A candidate report - is not a deletion proof: production reclamation additionally requires - durable evidence that an object stayed unreachable longer than the maximum - read lifetime and coordination preventing a concurrent publisher from - making an old content-addressed object reachable during sweep. - - **(A2) Strong read-after-write.** A read issued after a successful `PUT` observes that write. (AWS S3 provides this for all regions and all PUT/DELETE.)