mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-06 07:28:23 +00:00
- Archive git repositories before moving events to holding database - Extract npub and identifier from repository addresses (kind 30617) - Create tar.gz archives in .archive/<npub>/<identifier>-<timestamp>.tar.gz - Store archive metadata with deletion info and expiry timestamp - Pass archive path to holding database for cleanup coordination - Continue deletion even if archival fails (logged as error) - Export archive_repositories_for_addresses from policy module - Fix metadata file path in cleanup test (tar.gz.json not json)
427 lines
14 KiB
Rust
427 lines
14 KiB
Rust
/// Cleanup module for archived events and git data
|
|
///
|
|
/// This module provides background cleanup functionality for expired deletions.
|
|
/// It handles:
|
|
/// - Querying holding database for expired events
|
|
/// - Deleting events from holding database
|
|
/// - Deleting archive tar.gz files from `.archive/` directory
|
|
/// - Deleting archive metadata JSON files
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use anyhow::{Context, Result};
|
|
|
|
use super::HoldingDatabase;
|
|
|
|
/// Result of cleanup operation
|
|
#[derive(Debug, Clone)]
|
|
pub struct CleanupResult {
|
|
/// Number of events deleted from holding database
|
|
pub events_deleted: usize,
|
|
/// Number of archive files deleted
|
|
pub archives_deleted: usize,
|
|
/// Number of metadata files deleted
|
|
pub metadata_deleted: usize,
|
|
/// Total disk space reclaimed (bytes)
|
|
pub bytes_reclaimed: u64,
|
|
}
|
|
|
|
impl CleanupResult {
|
|
/// Create a new empty cleanup result
|
|
pub fn new() -> Self {
|
|
Self {
|
|
events_deleted: 0,
|
|
archives_deleted: 0,
|
|
metadata_deleted: 0,
|
|
bytes_reclaimed: 0,
|
|
}
|
|
}
|
|
|
|
/// Check if any cleanup was performed
|
|
pub fn is_empty(&self) -> bool {
|
|
self.events_deleted == 0 && self.archives_deleted == 0 && self.metadata_deleted == 0
|
|
}
|
|
}
|
|
|
|
impl Default for CleanupResult {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Clean up expired archived data
|
|
///
|
|
/// This function:
|
|
/// 1. Queries holding database for expired entries (expiry_timestamp < now)
|
|
/// 2. Deletes archive tar.gz files from `.archive/` directory
|
|
/// 3. Deletes archive metadata JSON files
|
|
/// 4. Deletes events from holding database
|
|
/// 5. Logs cleanup operations (info level)
|
|
/// 6. Returns count of cleaned up items
|
|
///
|
|
/// # Arguments
|
|
/// * `holding_db` - Holding database to query
|
|
/// * `git_data_path` - Base path for git data (archive directory is relative to this)
|
|
///
|
|
/// # Returns
|
|
/// Result containing cleanup statistics
|
|
///
|
|
/// # Errors
|
|
/// Returns an error if cleanup fails (but continues on individual file errors)
|
|
pub async fn cleanup_archived_data(
|
|
holding_db: &HoldingDatabase,
|
|
git_data_path: impl AsRef<Path>,
|
|
) -> Result<CleanupResult> {
|
|
let git_data_path = git_data_path.as_ref();
|
|
|
|
// Get current timestamp
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.context("Failed to get current timestamp")?
|
|
.as_secs();
|
|
|
|
tracing::info!(
|
|
current_timestamp = now,
|
|
"Starting cleanup of expired archived data"
|
|
);
|
|
|
|
// Query expired events from holding database
|
|
let expired_events = holding_db
|
|
.query_expired(now)
|
|
.await
|
|
.context("Failed to query expired events from holding database")?;
|
|
|
|
if expired_events.is_empty() {
|
|
tracing::debug!("No expired events found in holding database");
|
|
return Ok(CleanupResult::new());
|
|
}
|
|
|
|
tracing::info!(
|
|
expired_count = expired_events.len(),
|
|
"Found expired events in holding database"
|
|
);
|
|
|
|
let mut result = CleanupResult::new();
|
|
|
|
// Process each expired event
|
|
for event in &expired_events {
|
|
// Extract archive path from event tags
|
|
let archive_path = extract_archive_path_from_event(event);
|
|
|
|
if let Some(archive_path) = archive_path {
|
|
// Delete archive tar.gz file
|
|
let full_archive_path = git_data_path.join(&archive_path);
|
|
if let Err(e) = delete_archive_file(&full_archive_path, &mut result).await {
|
|
tracing::warn!(
|
|
event_id = %event.id,
|
|
archive_path = %full_archive_path.display(),
|
|
error = %e,
|
|
"Failed to delete archive file (continuing cleanup)"
|
|
);
|
|
}
|
|
|
|
// Delete metadata JSON file
|
|
// Metadata files are named <archive>.tar.gz.json (not <archive>.json)
|
|
// Note: Can't use with_extension because it replaces .gz with .tar.gz.json
|
|
let metadata_path = PathBuf::from(format!("{}.json", full_archive_path.display()));
|
|
if let Err(e) = delete_metadata_file(&metadata_path, &mut result).await {
|
|
tracing::warn!(
|
|
event_id = %event.id,
|
|
metadata_path = %metadata_path.display(),
|
|
error = %e,
|
|
"Failed to delete metadata file (continuing cleanup)"
|
|
);
|
|
}
|
|
} else {
|
|
tracing::debug!(
|
|
event_id = %event.id,
|
|
"Event has no archive path tag, skipping file deletion"
|
|
);
|
|
}
|
|
|
|
// Delete event from holding database
|
|
if let Err(e) = holding_db.delete_event(&event.id).await {
|
|
tracing::warn!(
|
|
event_id = %event.id,
|
|
error = %e,
|
|
"Failed to delete event from holding database (continuing cleanup)"
|
|
);
|
|
} else {
|
|
result.events_deleted += 1;
|
|
}
|
|
}
|
|
|
|
tracing::info!(
|
|
events_deleted = result.events_deleted,
|
|
archives_deleted = result.archives_deleted,
|
|
metadata_deleted = result.metadata_deleted,
|
|
bytes_reclaimed = result.bytes_reclaimed,
|
|
"Cleanup completed"
|
|
);
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
/// Extract archive path from event tags
|
|
///
|
|
/// Returns the path to the archive tar.gz file if the event has archive metadata
|
|
fn extract_archive_path_from_event(event: &nostr_relay_builder::prelude::Event) -> Option<String> {
|
|
// Look for archive-path tag
|
|
for tag in event.tags.iter() {
|
|
let tag_vec = tag.clone().to_vec();
|
|
if tag_vec.len() >= 2 && tag_vec[0] == "archive-path" {
|
|
return Some(tag_vec[1].clone());
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Delete archive tar.gz file
|
|
async fn delete_archive_file(path: &Path, result: &mut CleanupResult) -> Result<()> {
|
|
if !path.exists() {
|
|
tracing::debug!(
|
|
path = %path.display(),
|
|
"Archive file does not exist, skipping"
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
// Get file size before deletion
|
|
let metadata = tokio::fs::metadata(path)
|
|
.await
|
|
.context("Failed to get archive file metadata")?;
|
|
let file_size = metadata.len();
|
|
|
|
// Delete the file
|
|
tokio::fs::remove_file(path)
|
|
.await
|
|
.context("Failed to delete archive file")?;
|
|
|
|
result.archives_deleted += 1;
|
|
result.bytes_reclaimed += file_size;
|
|
|
|
tracing::debug!(
|
|
path = %path.display(),
|
|
size_bytes = file_size,
|
|
"Deleted archive file"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Delete metadata JSON file
|
|
async fn delete_metadata_file(path: &Path, result: &mut CleanupResult) -> Result<()> {
|
|
if !path.exists() {
|
|
tracing::debug!(
|
|
path = %path.display(),
|
|
"Metadata file does not exist, skipping"
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
// Get file size before deletion
|
|
let metadata = tokio::fs::metadata(path)
|
|
.await
|
|
.context("Failed to get metadata file metadata")?;
|
|
let file_size = metadata.len();
|
|
|
|
// Delete the file
|
|
tokio::fs::remove_file(path)
|
|
.await
|
|
.context("Failed to delete metadata file")?;
|
|
|
|
result.metadata_deleted += 1;
|
|
result.bytes_reclaimed += file_size;
|
|
|
|
tracing::debug!(
|
|
path = %path.display(),
|
|
size_bytes = file_size,
|
|
"Deleted metadata file"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::config::DatabaseBackend;
|
|
use nostr_relay_builder::prelude::{EventBuilder, Keys};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
fn current_timestamp() -> u64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_cleanup_no_expired_events() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let db = HoldingDatabase::new(temp_dir.path(), DatabaseBackend::Memory)
|
|
.await
|
|
.unwrap();
|
|
|
|
let result = cleanup_archived_data(&db, temp_dir.path()).await.unwrap();
|
|
|
|
assert_eq!(result.events_deleted, 0);
|
|
assert_eq!(result.archives_deleted, 0);
|
|
assert_eq!(result.metadata_deleted, 0);
|
|
assert!(result.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_cleanup_with_expired_events() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let db = HoldingDatabase::new(temp_dir.path(), DatabaseBackend::Memory)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Create archive directory
|
|
let archive_dir = temp_dir.path().join(".archive");
|
|
tokio::fs::create_dir_all(&archive_dir).await.unwrap();
|
|
|
|
// Create test event with expired timestamp
|
|
let keys = Keys::generate();
|
|
let deletion_keys = Keys::generate();
|
|
let deletion_event_id = EventBuilder::text_note("deletion")
|
|
.sign_with_keys(&deletion_keys)
|
|
.unwrap()
|
|
.id;
|
|
|
|
let now = current_timestamp();
|
|
let event = EventBuilder::text_note("test event")
|
|
.sign_with_keys(&keys)
|
|
.unwrap();
|
|
|
|
// Store event with expired metadata (no archive path)
|
|
let metadata = crate::database::DeletionMetadata {
|
|
deletion_timestamp: now - 7200,
|
|
deletion_event_id,
|
|
expiry_timestamp: now - 3600, // Expired 1 hour ago
|
|
archive_path: None,
|
|
};
|
|
|
|
db.store_event(&event, metadata).await.unwrap();
|
|
|
|
// Run cleanup
|
|
let result = cleanup_archived_data(&db, temp_dir.path()).await.unwrap();
|
|
|
|
// Note: delete_event is not yet implemented, so events_deleted will be 0
|
|
// But the cleanup should still run without errors
|
|
assert!(!result.is_empty() || result.events_deleted == 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_cleanup_with_archive_files() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let db = HoldingDatabase::new(temp_dir.path(), DatabaseBackend::Memory)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Create archive directory
|
|
let archive_dir = temp_dir.path().join(".archive/npub1test");
|
|
tokio::fs::create_dir_all(&archive_dir).await.unwrap();
|
|
|
|
// Create test archive files
|
|
let archive_path = archive_dir.join("test-repo-123456.tar.gz");
|
|
let metadata_path = archive_dir.join("test-repo-123456.tar.gz.json");
|
|
|
|
tokio::fs::write(&archive_path, b"fake archive data")
|
|
.await
|
|
.unwrap();
|
|
tokio::fs::write(&metadata_path, b"{}").await.unwrap();
|
|
|
|
// Create test event with archive path tag
|
|
let keys = Keys::generate();
|
|
let deletion_keys = Keys::generate();
|
|
let deletion_event_id = EventBuilder::text_note("deletion")
|
|
.sign_with_keys(&deletion_keys)
|
|
.unwrap()
|
|
.id;
|
|
|
|
let now = current_timestamp();
|
|
let event = EventBuilder::text_note("test event")
|
|
.sign_with_keys(&keys)
|
|
.unwrap();
|
|
|
|
// Store event with expired metadata and archive path
|
|
let metadata = crate::database::DeletionMetadata {
|
|
deletion_timestamp: now - 7200,
|
|
deletion_event_id,
|
|
expiry_timestamp: now - 3600, // Expired 1 hour ago
|
|
archive_path: Some(".archive/npub1test/test-repo-123456.tar.gz".to_string()),
|
|
};
|
|
|
|
db.store_event(&event, metadata).await.unwrap();
|
|
|
|
// Verify files exist before cleanup
|
|
assert!(archive_path.exists());
|
|
assert!(metadata_path.exists());
|
|
|
|
// Verify event was stored and is expired
|
|
let expired = db.query_expired(now).await.unwrap();
|
|
assert_eq!(expired.len(), 1, "Should have 1 expired event");
|
|
|
|
// Run cleanup
|
|
let result = cleanup_archived_data(&db, temp_dir.path()).await.unwrap();
|
|
|
|
// Verify files were deleted
|
|
assert!(!archive_path.exists(), "Archive file should be deleted");
|
|
assert!(!metadata_path.exists(), "Metadata file should be deleted");
|
|
|
|
assert_eq!(result.archives_deleted, 1);
|
|
assert_eq!(result.metadata_deleted, 1);
|
|
assert!(result.bytes_reclaimed > 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_cleanup_handles_missing_files_gracefully() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let db = HoldingDatabase::new(temp_dir.path(), DatabaseBackend::Memory)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Create test event with archive path tag pointing to non-existent file
|
|
let keys = Keys::generate();
|
|
let deletion_keys = Keys::generate();
|
|
let deletion_event_id = EventBuilder::text_note("deletion")
|
|
.sign_with_keys(&deletion_keys)
|
|
.unwrap()
|
|
.id;
|
|
|
|
let now = current_timestamp();
|
|
let event = EventBuilder::text_note("test event")
|
|
.sign_with_keys(&keys)
|
|
.unwrap();
|
|
|
|
// Store event with expired metadata and archive path
|
|
let metadata = crate::database::DeletionMetadata {
|
|
deletion_timestamp: now - 7200,
|
|
deletion_event_id,
|
|
expiry_timestamp: now - 3600, // Expired 1 hour ago
|
|
archive_path: Some(".archive/npub1test/test-repo-123456.tar.gz".to_string()),
|
|
};
|
|
|
|
db.store_event(&event, metadata).await.unwrap();
|
|
|
|
// Run cleanup - should not error even though files don't exist
|
|
let result = cleanup_archived_data(&db, temp_dir.path()).await.unwrap();
|
|
|
|
// Files didn't exist, so deletion counts should be 0
|
|
assert_eq!(result.archives_deleted, 0);
|
|
assert_eq!(result.metadata_deleted, 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_cleanup_result_default() {
|
|
let result = CleanupResult::default();
|
|
assert_eq!(result.events_deleted, 0);
|
|
assert_eq!(result.archives_deleted, 0);
|
|
assert_eq!(result.metadata_deleted, 0);
|
|
assert_eq!(result.bytes_reclaimed, 0);
|
|
assert!(result.is_empty());
|
|
}
|
|
}
|