Revert "feat(grasp06): periodic sweep of zero-ref /prs/ repos"

This reverts commit 39c21b29e6.
This commit is contained in:
DanConwayDev
2026-05-15 18:29:04 +00:00
parent a561efa8d9
commit a31094e5d8
4 changed files with 2 additions and 402 deletions
-370
View File
@@ -1,370 +0,0 @@
//! Periodic sweep of zero-ref `/prs/` repositories.
//!
//! The receive-pack handler in [`crate::grasp06::receive`] already removes
//! a `/prs/<submitter>/<identifier>.git` bare repo when post-push
//! validation leaves it with zero refs (probe pushes that produced no
//! valid state). This module backs that up with a periodic walker that
//! catches repos which became zero-ref through other paths — for example
//! a refs/nostr/<event-id> deleted later by mismatched-event logic when
//! the placeholder finally fails validation.
//!
//! ## Removal criteria
//!
//! A `<git_data_path>/prs/<hex>/<id>.git` directory is removed when **all**
//! of the following hold:
//!
//! 1. it has zero refs (via [`crate::git::list_refs`]),
//! 2. no PR purgatory entry is still scoped to `(submitter=<hex>, identifier=<id>)`
//! (via [`Purgatory::has_prs_scope`]), and
//! 3. its directory mtime is older than the purgatory TTL
//! ([`crate::purgatory::DEFAULT_EXPIRY`]).
//!
//! The mtime check is a defensive guard against racing with an in-flight
//! push that has created the directory but not yet finished writing the
//! first ref.
//!
//! ## Cadence
//!
//! The task runs every 10 minutes in production. When `NGIT_TEST=1` is set
//! it tightens to 1 second so integration tests can observe a sweep within
//! a few seconds of the TTL elapsing.
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use nostr_sdk::prelude::*;
use tracing::{debug, info, warn};
use crate::git::list_refs;
use crate::grasp06::paths::prs_base_path;
use crate::purgatory::{Purgatory, DEFAULT_EXPIRY};
/// Production sweep cadence.
const SWEEP_INTERVAL: Duration = Duration::from_secs(600);
/// Test-mode sweep cadence — short enough for an integration test to wait
/// out the TTL and observe a sweep within a few seconds.
const SWEEP_INTERVAL_TEST: Duration = Duration::from_secs(1);
/// Spawn the periodic `/prs/` cleanup task.
///
/// The task runs until the process exits. It is a thin loop around
/// [`sweep_once`] — extract that for unit-level tests.
pub fn spawn(git_data_path: std::path::PathBuf, purgatory: Arc<Purgatory>) {
let interval = if std::env::var("NGIT_TEST").as_deref() == Ok("1") {
SWEEP_INTERVAL_TEST
} else {
SWEEP_INTERVAL
};
tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
// The first `tick()` returns immediately; skip it so the first
// sweep happens one interval after startup, not at startup.
ticker.tick().await;
loop {
ticker.tick().await;
let removed = sweep_once(&git_data_path, &purgatory, DEFAULT_EXPIRY);
if removed > 0 {
info!("/prs/ cleanup sweep removed {} empty repo(s)", removed);
}
}
});
info!(
"/prs/ cleanup task started ({}s interval)",
interval.as_secs()
);
}
/// Run one sweep of `<git_data_path>/prs/`, returning the number of
/// directories removed.
///
/// `ttl` is the purgatory TTL — repos younger than this are skipped even
/// if they currently have zero refs, because they may belong to an
/// in-flight push.
pub fn sweep_once(git_data_path: &Path, purgatory: &Purgatory, ttl: Duration) -> usize {
let base = prs_base_path(git_data_path);
let now = SystemTime::now();
let hex_entries = match std::fs::read_dir(&base) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return 0,
Err(e) => {
warn!("/prs/ cleanup: failed to read {}: {}", base.display(), e);
return 0;
}
};
let mut removed = 0usize;
for hex_entry in hex_entries.flatten() {
let hex_path = hex_entry.path();
if !hex_path.is_dir() {
continue;
}
let submitter_hex = hex_entry.file_name().to_string_lossy().into_owned();
let submitter = match PublicKey::from_hex(&submitter_hex) {
Ok(pk) => pk,
Err(_) => {
// A non-hex first-level entry under /prs/ shouldn't exist;
// skip rather than touch something we don't understand.
debug!(
"/prs/ cleanup: skipping unrecognised subdir {}",
hex_path.display()
);
continue;
}
};
let repo_entries = match std::fs::read_dir(&hex_path) {
Ok(e) => e,
Err(e) => {
warn!(
"/prs/ cleanup: failed to read {}: {}",
hex_path.display(),
e
);
continue;
}
};
let mut hex_dir_now_empty = true;
for repo_entry in repo_entries.flatten() {
let repo_path = repo_entry.path();
if !repo_path.is_dir() {
hex_dir_now_empty = false;
continue;
}
let dir_name = repo_entry.file_name().to_string_lossy().into_owned();
let Some(identifier) = dir_name.strip_suffix(".git") else {
hex_dir_now_empty = false;
continue;
};
if !is_removable(&repo_path, &submitter, identifier, purgatory, ttl, now) {
hex_dir_now_empty = false;
continue;
}
match std::fs::remove_dir_all(&repo_path) {
Ok(()) => {
info!("/prs/ cleanup: removed empty repo {}", repo_path.display());
removed += 1;
}
Err(e) => {
warn!(
"/prs/ cleanup: failed to remove {}: {}",
repo_path.display(),
e
);
hex_dir_now_empty = false;
}
}
}
// Drop the now-empty <hex> dir too so /prs/ doesn't accumulate
// empty submitter directories over time.
if hex_dir_now_empty {
if let Err(e) = std::fs::remove_dir(&hex_path) {
// Concurrent activity may have re-populated this dir
// between the walk and the remove — that's fine, the
// next sweep will pick it up.
debug!(
"/prs/ cleanup: leaving submitter dir {} (remove failed: {})",
hex_path.display(),
e
);
}
}
}
removed
}
/// Decide whether `<git_data_path>/prs/<submitter_hex>/<identifier>.git`
/// is safe to delete right now.
fn is_removable(
repo_path: &Path,
submitter: &PublicKey,
identifier: &str,
purgatory: &Purgatory,
ttl: Duration,
now: SystemTime,
) -> bool {
// 1. Zero refs.
match list_refs(repo_path) {
Ok(refs) if refs.is_empty() => {}
Ok(_) => return false,
Err(e) => {
warn!(
"/prs/ cleanup: list_refs failed for {} ({}); leaving in place",
repo_path.display(),
e
);
return false;
}
}
// 2. No live purgatory scope.
if purgatory.has_prs_scope(submitter, identifier) {
return false;
}
// 3. Older than TTL — protects against deleting a repo that was just
// created by an in-flight push.
let mtime = match std::fs::metadata(repo_path).and_then(|m| m.modified()) {
Ok(t) => t,
Err(e) => {
warn!(
"/prs/ cleanup: stat failed for {} ({}); leaving in place",
repo_path.display(),
e
);
return false;
}
};
match now.duration_since(mtime) {
Ok(age) => age >= ttl,
// mtime in the future — clock skew, leave it alone.
Err(_) => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use std::time::Duration;
use tempfile::tempdir;
fn init_bare(path: &Path) {
std::fs::create_dir_all(path).unwrap();
let status = Command::new("git")
.args(["init", "--bare", "--quiet"])
.arg(path)
.status()
.unwrap();
assert!(status.success());
}
fn backdate(path: &Path, secs_ago: u64) {
let ts = SystemTime::now() - Duration::from_secs(secs_ago);
// `File::set_modified` operates on an opened handle. Open the dir's
// canonical path through a sentinel file we control to avoid OS
// differences with opening a directory directly.
let sentinel = path.join("_backdate_sentinel");
std::fs::write(&sentinel, b"x").unwrap();
let f = std::fs::OpenOptions::new()
.write(true)
.open(&sentinel)
.unwrap();
f.set_modified(ts).unwrap();
// Also backdate the directory itself via `set_modified` on the
// sentinel's parent through an open. On Linux directory mtimes
// update on child changes, so simply backdating now after the
// child writes is sufficient.
let dir = std::fs::File::open(path).unwrap();
dir.set_modified(ts).unwrap();
let _ = std::fs::remove_file(&sentinel);
// The remove above bumps the dir mtime — redo it.
let dir = std::fs::File::open(path).unwrap();
dir.set_modified(ts).unwrap();
}
#[test]
fn sweep_removes_old_zero_ref_repo_without_scope() {
let tmp = tempdir().unwrap();
let git_data_path = tmp.path();
let signer = Keys::generate();
let hex = signer.public_key().to_hex();
let repo = crate::grasp06::paths::prs_repo_path(git_data_path, &hex, "abandoned");
init_bare(&repo);
backdate(&repo, 7200);
let purgatory = Purgatory::new(tmp.path().join("relay-state"));
// With a tiny TTL the directory should be considered old enough.
let removed = sweep_once(git_data_path, &purgatory, Duration::from_secs(1));
assert_eq!(removed, 1, "abandoned repo must be removed");
assert!(!repo.exists(), "repo dir must be gone");
}
#[test]
fn sweep_keeps_repo_within_ttl() {
let tmp = tempdir().unwrap();
let git_data_path = tmp.path();
let signer = Keys::generate();
let hex = signer.public_key().to_hex();
let repo = crate::grasp06::paths::prs_repo_path(git_data_path, &hex, "fresh");
init_bare(&repo);
let purgatory = Purgatory::new(tmp.path().join("relay-state"));
// TTL of one hour vs. just-created repo → must be retained.
let removed = sweep_once(git_data_path, &purgatory, Duration::from_secs(3600));
assert_eq!(removed, 0);
assert!(repo.exists());
}
#[test]
fn sweep_keeps_repo_with_active_purgatory_scope() {
let tmp = tempdir().unwrap();
let git_data_path = tmp.path();
let signer = Keys::generate();
let hex = signer.public_key().to_hex();
let repo = crate::grasp06::paths::prs_repo_path(git_data_path, &hex, "pending");
init_bare(&repo);
backdate(&repo, 7200);
let purgatory = Purgatory::new(tmp.path().join("relay-state"));
// Register a placeholder scoped to this (submitter, identifier).
purgatory.add_prs_pr_placeholder(
"a".repeat(64),
"b".repeat(40),
signer.public_key(),
"pending".to_string(),
);
let removed = sweep_once(git_data_path, &purgatory, Duration::from_secs(1));
assert_eq!(removed, 0, "repo with active scope must be retained");
assert!(repo.exists());
}
#[test]
fn sweep_keeps_repo_with_a_ref() {
let tmp = tempdir().unwrap();
let git_data_path = tmp.path();
let signer = Keys::generate();
let hex = signer.public_key().to_hex();
let repo = crate::grasp06::paths::prs_repo_path(git_data_path, &hex, "has-ref");
init_bare(&repo);
// Create an empty commit + write a refs/nostr/ ref to it.
let oid = Command::new("git")
.args(["--git-dir"])
.arg(&repo)
.args(["commit-tree", "-m", "x"])
.arg(
// empty tree object SHA — git ships this as a constant
"4b825dc642cb6eb9a060e54bf8d69288fbee4904",
)
.output()
.unwrap();
assert!(oid.status.success(), "commit-tree failed: {:?}", oid);
let oid = String::from_utf8(oid.stdout).unwrap().trim().to_string();
let status = Command::new("git")
.args(["--git-dir"])
.arg(&repo)
.args(["update-ref", "refs/nostr/deadbeef", &oid])
.status()
.unwrap();
assert!(status.success());
backdate(&repo, 7200);
let purgatory = Purgatory::new(tmp.path().join("relay-state"));
let removed = sweep_once(git_data_path, &purgatory, Duration::from_secs(1));
assert_eq!(removed, 0, "repo with refs must be retained");
assert!(repo.exists());
}
}
-1
View File
@@ -21,7 +21,6 @@
//! Cross-service mirroring into matching announced repos is not yet
//! implemented.
pub mod cleanup;
pub mod endpoint;
pub mod fetch;
pub mod paths;
-10
View File
@@ -234,16 +234,6 @@ async fn run_relay(config: Config) -> Result<()> {
});
info!("Audit event cleanup task started (30m interval, removes events >2h old)");
// Spawn the GRASP-06 /prs/ periodic cleanup sweep when the feature
// is enabled. Removes zero-ref /prs/ repos that have no active
// purgatory scope and an mtime older than the purgatory TTL.
if config.grasp06_enable {
ngit_grasp::grasp06::cleanup::spawn(
PathBuf::from(config.effective_git_data_path()),
purgatory.clone(),
);
}
// Start purgatory sync loop for background git data fetching
// Create naughty list tracker for git remote domains with persistent errors (12h expiration)
let git_naughty_list = Arc::new(NaughtyListTracker::with_defaults());
+2 -21
View File
@@ -37,12 +37,8 @@ use std::time::{Duration, Instant, SystemTime};
pub use sync::SyncQueueEntry;
/// Default expiry duration for purgatory entries (30 minutes).
///
/// Exposed for periodic cleanup tasks (see [`crate::grasp06::cleanup`]) that
/// must use the same window when deciding whether a freshly-touched `/prs/`
/// repo is still potentially in-flight versus safe to garbage-collect.
pub const DEFAULT_EXPIRY: Duration = Duration::from_secs(1800);
/// Default expiry duration for purgatory entries (30 minutes)
const DEFAULT_EXPIRY: Duration = Duration::from_secs(1800);
/// Extended expiry for soft-expired announcements (24 hours).
///
@@ -555,21 +551,6 @@ impl Purgatory {
})
}
/// Return true if any PR purgatory entry (placeholder or event-bearing)
/// is currently scoped to the GRASP-06 `(submitter, identifier)` tuple.
///
/// Used by the periodic `/prs/` sweep to avoid removing a bare repo
/// while an in-purgatory entry could still bind to it.
pub fn has_prs_scope(&self, submitter: &PublicKey, identifier: &str) -> bool {
self.pr_events.iter().any(|entry| {
if let Some(scope) = &entry.value().prs_scope {
scope.submitter == *submitter && scope.identifier == identifier
} else {
false
}
})
}
/// Find all PR events for a specific repository identifier.
///
/// PR events reference repositories via `a` tags with format `30617:<owner_pubkey>:<identifier>`.