Files
ngit-grasp/src/database/cleanup.rs
T
DanConwayDev 0b805089c0 Integrate git archival into deletion flow
- 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)
2026-01-14 12:13:52 +00:00

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());
}
}