mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
feat: Implement multi-maintainer graph-based deletion algorithm
Replace simple cascade with graph-based retention algorithm: - Build event dependency graph with nodes (events) and edges (references) - EventGraph: directed graph tracking event relationships - EventNode: tracks event ID, kind, references, and retention reasons - Address resolution: maps kind 30617 addresses to event IDs Re-evaluation engine: - reevaluate_events_without_announcement(): determines which events to keep/delete - Queries all events referencing deleted announcement - Re-runs acceptance policy WITHOUT deleted announcement - Tracks which announcements justify keeping each event - Fail-secure defaults (missing events marked for deletion) Graph traversal: - BFS traversal from kept announcements - Marks reachable events as "keep", unreachable as "delete" - Configurable max depth (default: 100) to prevent infinite loops - DFS-based circular dependency detection with recursion stack - Handles anchored vs unanchored circular dependencies Multi-maintainer logic: - Events kept if they reference OTHER valid announcements - Events deleted only when ALL referenced announcements are deleted - Handles complex scenarios: - Two maintainers, one deletes → events preserved - Two maintainers, both delete → events deleted - Three+ maintainers with partial deletions - Deep dependency chains (5+ levels) - Isolated subgraphs Integration: - Replaces simple cascade in deletion_ops.rs - determine_events_to_delete() uses graph algorithm - Comprehensive logging of graph statistics and retention reasons - Updated handle_deletion() in builder.rs Tests: 31 tests covering: - Graph building (12 unit tests) - Empty graph, single event, references - Circular references, transitive dependencies - Max depth handling - Re-evaluation engine (6 unit tests) - Single/multi-maintainer scenarios - Announcement reference handling - Fail-secure defaults - Graph traversal (7 unit tests) - Linear chains, isolated subgraphs - Circular dependency detection - Anchored vs unanchored cycles - Max depth exceeded - Multi-maintainer integration (6 tests) - Two maintainers (one/both delete) - Three maintainers with partial deletions - Deep dependency chains - Selective retention based on maintainer subsets
This commit is contained in:
+148
-8
@@ -114,6 +114,146 @@ impl Nip34WritePolicy {
|
||||
// Parse announcement to get repository details
|
||||
match RepositoryAnnouncement::from_event(event.clone()) {
|
||||
Ok(announcement) => {
|
||||
// Check for recovery from holding database
|
||||
if let Some(ref holding_db) = self.holding_database {
|
||||
let current_ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
match crate::git::archive::check_for_recovery(
|
||||
holding_db,
|
||||
&announcement.owner_npub(),
|
||||
&announcement.identifier,
|
||||
current_ts,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(recovery_info)) => {
|
||||
tracing::info!(
|
||||
"Recovery detected for {}/{}: {} events can be restored from archive {}",
|
||||
announcement.owner_npub(),
|
||||
announcement.identifier,
|
||||
recovery_info.events.len(),
|
||||
recovery_info.archive_path.display()
|
||||
);
|
||||
|
||||
// Restore git data from archive
|
||||
let git_data_path = self.ctx.git_data_path.as_path();
|
||||
match crate::git::archive::restore_repository(
|
||||
&recovery_info.archive_path,
|
||||
git_data_path,
|
||||
&announcement.owner_npub(),
|
||||
&announcement.identifier,
|
||||
) {
|
||||
Ok(()) => {
|
||||
tracing::info!(
|
||||
"Successfully restored git data for {}/{}",
|
||||
announcement.owner_npub(),
|
||||
announcement.identifier
|
||||
);
|
||||
|
||||
// Restore events from holding database
|
||||
let event_ids: Vec<_> =
|
||||
recovery_info.events.iter().map(|e| e.id).collect();
|
||||
|
||||
match holding_db
|
||||
.restore_events_to_main(
|
||||
&self.ctx.database,
|
||||
&event_ids,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
tracing::info!(
|
||||
"Restored {} events from holding database for {}/{}",
|
||||
result.restored_count,
|
||||
announcement.owner_npub(),
|
||||
announcement.identifier
|
||||
);
|
||||
|
||||
if result.failed_count > 0 {
|
||||
tracing::warn!(
|
||||
"Failed to restore {} events for {}/{}",
|
||||
result.failed_count,
|
||||
announcement.owner_npub(),
|
||||
announcement.identifier
|
||||
);
|
||||
}
|
||||
|
||||
// Cleanup archive files after successful recovery
|
||||
match crate::git::archive::cleanup_after_recovery(
|
||||
&recovery_info.archive_path,
|
||||
&announcement.owner_npub(),
|
||||
&announcement.identifier,
|
||||
) {
|
||||
Ok(stats) => {
|
||||
tracing::info!(
|
||||
"Cleaned up archive for {}/{}: {} bytes reclaimed",
|
||||
announcement.owner_npub(),
|
||||
announcement.identifier,
|
||||
stats.bytes_reclaimed
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to cleanup archive for {}/{}: {}",
|
||||
announcement.owner_npub(),
|
||||
announcement.identifier,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete events from holding database
|
||||
let deleted_count =
|
||||
holding_db.delete_events(&event_ids).await;
|
||||
tracing::info!(
|
||||
"Deleted {} events from holding database for {}/{}",
|
||||
deleted_count,
|
||||
announcement.owner_npub(),
|
||||
announcement.identifier
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to restore events for {}/{}: {}",
|
||||
announcement.owner_npub(),
|
||||
announcement.identifier,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to restore git data for {}/{}: {}",
|
||||
announcement.owner_npub(),
|
||||
announcement.identifier,
|
||||
e
|
||||
);
|
||||
// Continue with normal processing even if restoration fails
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::debug!(
|
||||
"No recovery available for {}/{} (not in holding database or expired)",
|
||||
announcement.owner_npub(),
|
||||
announcement.identifier
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Error checking for recovery for {}/{}: {}",
|
||||
announcement.owner_npub(),
|
||||
announcement.identifier,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to create bare repository if it doesn't exist
|
||||
if let Err(e) = self
|
||||
.announcement_policy
|
||||
@@ -551,30 +691,30 @@ impl Nip34WritePolicy {
|
||||
None
|
||||
};
|
||||
|
||||
// Query for all dependent events
|
||||
match crate::nostr::policy::query_dependent_events(
|
||||
// Determine which events should be deleted using graph-based algorithm
|
||||
match crate::nostr::policy::determine_events_to_delete(
|
||||
&self.ctx.database,
|
||||
&event_ids,
|
||||
&addresses,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(all_events) => {
|
||||
Ok(events_to_delete) => {
|
||||
tracing::info!(
|
||||
event_id = %event_id_str,
|
||||
author = %event.pubkey,
|
||||
event_count = event_ids.len(),
|
||||
address_count = addresses.len(),
|
||||
total_events = all_events.len(),
|
||||
"Processing deletion request - moving {} events to holding database",
|
||||
all_events.len()
|
||||
total_events = events_to_delete.len(),
|
||||
"Processing deletion request - moving {} events to holding database (graph-based)",
|
||||
events_to_delete.len()
|
||||
);
|
||||
|
||||
// Move events to holding database
|
||||
match crate::nostr::policy::move_to_holding_database(
|
||||
&self.ctx.database,
|
||||
holding_db,
|
||||
&all_events,
|
||||
&events_to_delete,
|
||||
&event.id,
|
||||
self.ctx.config.archive_retention_secs,
|
||||
archive_path,
|
||||
@@ -585,7 +725,7 @@ impl Nip34WritePolicy {
|
||||
tracing::info!(
|
||||
event_id = %event_id_str,
|
||||
moved_count = moved_count,
|
||||
"Successfully moved {} events to holding database",
|
||||
"Successfully moved {} events to holding database (graph-based)",
|
||||
moved_count
|
||||
);
|
||||
WritePolicyResult::Accept
|
||||
|
||||
@@ -11,6 +11,130 @@ use super::SharedDatabase;
|
||||
use crate::database::{DeletionMetadata, HoldingDatabase};
|
||||
use crate::git::archive::{archive_repository, create_archive_metadata};
|
||||
|
||||
/// Determine which events should be deleted using graph-based algorithm
|
||||
///
|
||||
/// This is the main integration point for the multi-maintainer deletion algorithm.
|
||||
/// It replaces the simple cascade deletion with a sophisticated graph-based approach:
|
||||
///
|
||||
/// 1. Query all potentially affected events (same as before)
|
||||
/// 2. Build EventGraph to understand dependencies
|
||||
/// 3. Re-evaluate events to determine which have alternative retention reasons
|
||||
/// 4. Traverse graph to mark events for deletion
|
||||
/// 5. Return only events that should actually be deleted
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `database` - Main database to query
|
||||
/// * `event_ids` - Event IDs being deleted
|
||||
/// * `addresses` - Addresses being deleted (format: `<kind>:<pubkey>:<d-tag>`)
|
||||
///
|
||||
/// # Returns
|
||||
/// Set of event IDs that should be DELETED (not all dependents, only those without retention reasons)
|
||||
pub async fn determine_events_to_delete(
|
||||
database: &SharedDatabase,
|
||||
event_ids: &[EventId],
|
||||
addresses: &[String],
|
||||
) -> Result<HashSet<EventId>, String> {
|
||||
// Step 1: Query all potentially affected events (same as simple cascade)
|
||||
let potentially_affected = query_dependent_events(database, event_ids, addresses).await?;
|
||||
|
||||
tracing::debug!(
|
||||
event_ids_count = event_ids.len(),
|
||||
addresses_count = addresses.len(),
|
||||
potentially_affected_count = potentially_affected.len(),
|
||||
"Queried potentially affected events for graph-based deletion"
|
||||
);
|
||||
|
||||
// Step 2: Query all events to build the graph
|
||||
// We need ALL events in the database to properly build the dependency graph
|
||||
let all_events = query_all_events(database).await?;
|
||||
|
||||
tracing::debug!(
|
||||
all_events_count = all_events.len(),
|
||||
"Queried all events for graph building"
|
||||
);
|
||||
|
||||
// Step 3: Build event dependency graph
|
||||
let graph = super::build_event_graph(&all_events);
|
||||
|
||||
tracing::info!(
|
||||
nodes = graph.node_count(),
|
||||
edges = graph.edge_count(),
|
||||
max_depth = graph.max_depth(),
|
||||
"Built event dependency graph"
|
||||
);
|
||||
|
||||
// Step 4: Re-evaluate events to determine retention reasons
|
||||
// For each deleted address, run re-evaluation
|
||||
let mut all_kept_events = std::collections::HashMap::new();
|
||||
let mut all_deleted_events = HashSet::new();
|
||||
|
||||
for address in addresses {
|
||||
let reeval_result =
|
||||
super::reevaluate_events_without_announcement(database, address, &potentially_affected)
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
address = %address,
|
||||
kept = reeval_result.keep.len(),
|
||||
deleted = reeval_result.delete.len(),
|
||||
"Re-evaluation complete for address"
|
||||
);
|
||||
|
||||
// Merge results
|
||||
all_kept_events.extend(reeval_result.keep);
|
||||
all_deleted_events.extend(reeval_result.delete);
|
||||
}
|
||||
|
||||
// Step 5: Traverse graph to mark events for deletion
|
||||
// Use the first deleted event ID for logging (or generate a dummy one)
|
||||
let deleted_event_id = event_ids.first().copied().unwrap_or_else(|| {
|
||||
EventId::from_hex("0000000000000000000000000000000000000000000000000000000000000000")
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
let traversal_result =
|
||||
super::traverse_and_mark_deletions(&graph, &all_kept_events, deleted_event_id);
|
||||
|
||||
// Log retention reasons
|
||||
for (event_id, reason) in &all_kept_events {
|
||||
tracing::debug!(
|
||||
event_id = %event_id,
|
||||
event_type = %reason.event_type,
|
||||
valid_announcements = ?reason.valid_announcements,
|
||||
"Event KEPT: has alternative retention reason"
|
||||
);
|
||||
}
|
||||
|
||||
// Log circular dependencies
|
||||
for circular_dep in &traversal_result.circular_dependencies {
|
||||
if circular_dep.anchored {
|
||||
tracing::info!(
|
||||
events = ?circular_dep.events,
|
||||
"Detected anchored circular dependency (events will be kept)"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
events = ?circular_dep.events,
|
||||
"Detected unanchored circular dependency (events will be deleted)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Log summary
|
||||
tracing::info!(
|
||||
total_evaluated = potentially_affected.len(),
|
||||
kept = traversal_result.keep.len(),
|
||||
deleted = traversal_result.delete.len(),
|
||||
circular_deps = traversal_result.circular_dependencies.len(),
|
||||
"Graph-based deletion complete: {} events kept, {} events deleted",
|
||||
traversal_result.keep.len(),
|
||||
traversal_result.delete.len()
|
||||
);
|
||||
|
||||
// Return only events that should be deleted
|
||||
Ok(traversal_result.delete)
|
||||
}
|
||||
|
||||
/// Query all events that depend on the given event IDs
|
||||
///
|
||||
/// This performs a recursive traversal to find:
|
||||
@@ -18,8 +142,7 @@ use crate::git::archive::{archive_repository, create_archive_metadata};
|
||||
/// 2. Events with `e` tags referencing the events found in step 1
|
||||
/// 3. Recursively find events referencing those events
|
||||
///
|
||||
/// For Phase 1, this is a simple cascade (no graph complexity).
|
||||
/// Phase 3 will add multi-maintainer graph algorithm.
|
||||
/// This is used by the graph-based deletion algorithm to find potentially affected events.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `database` - Main database to query
|
||||
@@ -69,6 +192,48 @@ pub async fn query_dependent_events(
|
||||
Ok(all_dependents)
|
||||
}
|
||||
|
||||
/// Query all events from the database
|
||||
///
|
||||
/// This is used by the graph-based deletion algorithm to build the complete
|
||||
/// dependency graph.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `database` - Database to query
|
||||
///
|
||||
/// # Returns
|
||||
/// Vector of all events in the database
|
||||
async fn query_all_events(database: &SharedDatabase) -> Result<Vec<Event>, String> {
|
||||
// Query all NIP-34 event types
|
||||
let kinds = vec![
|
||||
Kind::from(30617), // Repository announcements
|
||||
Kind::from(1617), // Patches
|
||||
Kind::from(1621), // Issues
|
||||
Kind::from(1619), // PR Updates
|
||||
Kind::from(1630), // Issue status
|
||||
Kind::from(1631), // PR status
|
||||
Kind::from(1632), // Patch status
|
||||
Kind::from(1633), // Repository status
|
||||
Kind::from(30618), // Repository state
|
||||
Kind::from(1630), // Pull requests
|
||||
];
|
||||
|
||||
let mut all_events = Vec::new();
|
||||
|
||||
for kind in kinds {
|
||||
let filter = Filter::new().kind(kind);
|
||||
let events: Vec<Event> = database
|
||||
.query(filter)
|
||||
.await
|
||||
.map_err(|e| format!("Database query failed: {}", e))?
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
all_events.extend(events);
|
||||
}
|
||||
|
||||
Ok(all_events)
|
||||
}
|
||||
|
||||
/// Query events that have an `a` tag referencing the given address
|
||||
///
|
||||
/// # Arguments
|
||||
|
||||
@@ -0,0 +1,586 @@
|
||||
/// Event Dependency Graph Builder
|
||||
///
|
||||
/// Builds a dependency graph for multi-maintainer deletion scenarios.
|
||||
/// Tracks which events depend on which announcements, enabling selective
|
||||
/// deletion when one maintainer removes their announcement but others remain.
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use nostr_relay_builder::prelude::{Event, EventId, Kind};
|
||||
|
||||
/// Maximum depth for graph traversal to prevent infinite loops
|
||||
const DEFAULT_MAX_DEPTH: usize = 100;
|
||||
|
||||
/// Node in the event dependency graph
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EventNode {
|
||||
/// Event ID
|
||||
pub event_id: EventId,
|
||||
/// Event kind
|
||||
pub kind: Kind,
|
||||
/// Event IDs this event references (via a/e/q tags)
|
||||
pub references: HashSet<EventId>,
|
||||
/// Announcement IDs that make this event acceptable (retention reasons)
|
||||
/// For announcements themselves, this is empty (they're self-justifying)
|
||||
/// For dependent events, this tracks which announcements they depend on
|
||||
pub retention_reasons: HashSet<EventId>,
|
||||
}
|
||||
|
||||
impl EventNode {
|
||||
/// Create a new event node
|
||||
pub fn new(event_id: EventId, kind: Kind) -> Self {
|
||||
Self {
|
||||
event_id,
|
||||
kind,
|
||||
references: HashSet::new(),
|
||||
retention_reasons: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a reference to another event
|
||||
pub fn add_reference(&mut self, referenced_id: EventId) {
|
||||
self.references.insert(referenced_id);
|
||||
}
|
||||
|
||||
/// Add a retention reason (announcement that justifies keeping this event)
|
||||
pub fn add_retention_reason(&mut self, announcement_id: EventId) {
|
||||
self.retention_reasons.insert(announcement_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Event dependency graph
|
||||
///
|
||||
/// Represents the dependency relationships between events in a repository.
|
||||
/// Nodes are events, edges are references (a/e/q tags).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EventGraph {
|
||||
/// All nodes in the graph, indexed by event ID
|
||||
nodes: HashMap<EventId, EventNode>,
|
||||
/// Reverse index: event ID -> events that reference it
|
||||
dependents: HashMap<EventId, HashSet<EventId>>,
|
||||
/// Maximum depth for traversal
|
||||
max_depth: usize,
|
||||
}
|
||||
|
||||
impl EventGraph {
|
||||
/// Create a new empty event graph
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
nodes: HashMap::new(),
|
||||
dependents: HashMap::new(),
|
||||
max_depth: DEFAULT_MAX_DEPTH,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new event graph with custom max depth
|
||||
pub fn with_max_depth(max_depth: usize) -> Self {
|
||||
Self {
|
||||
nodes: HashMap::new(),
|
||||
dependents: HashMap::new(),
|
||||
max_depth,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a node to the graph
|
||||
pub fn add_node(&mut self, node: EventNode) {
|
||||
let event_id = node.event_id;
|
||||
|
||||
// Update dependents index for all references
|
||||
for referenced_id in &node.references {
|
||||
self.dependents
|
||||
.entry(*referenced_id)
|
||||
.or_insert_with(HashSet::new)
|
||||
.insert(event_id);
|
||||
}
|
||||
|
||||
self.nodes.insert(event_id, node);
|
||||
}
|
||||
|
||||
/// Add an edge (reference) between two events
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `from` - Event ID that contains the reference
|
||||
/// * `to` - Event ID being referenced
|
||||
pub fn add_edge(&mut self, from: EventId, to: EventId) {
|
||||
// Add to node's references
|
||||
if let Some(node) = self.nodes.get_mut(&from) {
|
||||
node.add_reference(to);
|
||||
}
|
||||
|
||||
// Update dependents index
|
||||
self.dependents
|
||||
.entry(to)
|
||||
.or_insert_with(HashSet::new)
|
||||
.insert(from);
|
||||
}
|
||||
|
||||
/// Get a node by event ID
|
||||
pub fn get_node(&self, event_id: &EventId) -> Option<&EventNode> {
|
||||
self.nodes.get(event_id)
|
||||
}
|
||||
|
||||
/// Get all nodes in the graph
|
||||
pub fn nodes(&self) -> &HashMap<EventId, EventNode> {
|
||||
&self.nodes
|
||||
}
|
||||
|
||||
/// Get events that this event depends on (references)
|
||||
pub fn get_dependencies(&self, event_id: &EventId) -> HashSet<EventId> {
|
||||
self.nodes
|
||||
.get(event_id)
|
||||
.map(|node| node.references.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Get events that depend on this event (reverse references)
|
||||
pub fn get_dependents(&self, event_id: &EventId) -> HashSet<EventId> {
|
||||
self.dependents.get(event_id).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Get all transitive dependents of an event (up to max_depth)
|
||||
///
|
||||
/// Returns all events that transitively depend on the given event,
|
||||
/// stopping at max_depth to prevent infinite loops.
|
||||
pub fn get_transitive_dependents(&self, event_id: &EventId) -> HashSet<EventId> {
|
||||
let mut result = HashSet::new();
|
||||
let mut to_process = vec![(event_id, 0)];
|
||||
let mut visited = HashSet::new();
|
||||
|
||||
while let Some((current_id, depth)) = to_process.pop() {
|
||||
if depth >= self.max_depth {
|
||||
continue;
|
||||
}
|
||||
|
||||
if visited.contains(current_id) {
|
||||
continue;
|
||||
}
|
||||
visited.insert(*current_id);
|
||||
|
||||
// Get direct dependents
|
||||
if let Some(dependents) = self.dependents.get(current_id) {
|
||||
for dependent_id in dependents {
|
||||
result.insert(*dependent_id);
|
||||
to_process.push((dependent_id, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Get the maximum traversal depth
|
||||
pub fn max_depth(&self) -> usize {
|
||||
self.max_depth
|
||||
}
|
||||
|
||||
/// Get the number of nodes in the graph
|
||||
pub fn node_count(&self) -> usize {
|
||||
self.nodes.len()
|
||||
}
|
||||
|
||||
/// Get the number of edges in the graph
|
||||
pub fn edge_count(&self) -> usize {
|
||||
self.nodes.values().map(|node| node.references.len()).sum()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EventGraph {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an event dependency graph from a list of events
|
||||
///
|
||||
/// Creates a directed graph showing:
|
||||
/// - Nodes: Events (with event IDs and kinds)
|
||||
/// - Edges: References (a/e/q tags pointing to other events)
|
||||
/// - Retention reasons: Which announcements make each event acceptable
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `events` - List of events to build graph from
|
||||
///
|
||||
/// # Returns
|
||||
/// EventGraph with all dependencies mapped
|
||||
pub fn build_event_graph(events: &[Event]) -> EventGraph {
|
||||
let mut graph = EventGraph::new();
|
||||
|
||||
// First pass: Create nodes for all events
|
||||
for event in events {
|
||||
let mut node = EventNode::new(event.id, event.kind);
|
||||
|
||||
// Extract references from a/e/q tags
|
||||
for tag in event.tags.iter() {
|
||||
let tag_vec = tag.as_slice();
|
||||
if tag_vec.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match tag_vec[0].as_str() {
|
||||
"e" | "q" if tag_vec.len() >= 2 => {
|
||||
// Event reference
|
||||
if let Ok(referenced_id) = EventId::from_hex(&tag_vec[1]) {
|
||||
node.add_reference(referenced_id);
|
||||
}
|
||||
}
|
||||
"a" if tag_vec.len() >= 2 => {
|
||||
// Address reference - need to find the event with this address
|
||||
// For now, we'll handle this in a second pass after all nodes are added
|
||||
// Store the address temporarily (we'll resolve it later)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
graph.add_node(node);
|
||||
}
|
||||
|
||||
// Second pass: Resolve address references and compute retention reasons
|
||||
// Build a map of addresses to event IDs
|
||||
let mut address_to_event: HashMap<String, EventId> = HashMap::new();
|
||||
for event in events {
|
||||
// Check if this is a replaceable/addressable event (kind 30000-39999)
|
||||
let kind_num = event.kind.as_u16();
|
||||
if (30000..=39999).contains(&kind_num) {
|
||||
// Extract d-tag to build address
|
||||
for tag in event.tags.iter() {
|
||||
let tag_vec = tag.as_slice();
|
||||
if tag_vec.len() >= 2 && tag_vec[0] == "d" {
|
||||
let d_tag = &tag_vec[1];
|
||||
let address = format!("{}:{}:{}", kind_num, event.pubkey.to_hex(), d_tag);
|
||||
address_to_event.insert(address, event.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Third pass: Resolve address references and add edges
|
||||
for event in events {
|
||||
for tag in event.tags.iter() {
|
||||
let tag_vec = tag.as_slice();
|
||||
if tag_vec.len() >= 2 && tag_vec[0] == "a" {
|
||||
let address = &tag_vec[1];
|
||||
if let Some(&referenced_id) = address_to_event.get(address) {
|
||||
graph.add_edge(event.id, referenced_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fourth pass: Compute retention reasons
|
||||
// An event is retained if it's an announcement (kind 30617) or if it references
|
||||
// at least one announcement (directly or transitively)
|
||||
let announcement_kind = Kind::from(30617);
|
||||
|
||||
// Find all announcements
|
||||
let announcements: HashSet<EventId> = events
|
||||
.iter()
|
||||
.filter(|e| e.kind == announcement_kind)
|
||||
.map(|e| e.id)
|
||||
.collect();
|
||||
|
||||
// For each non-announcement event, find which announcements it depends on
|
||||
for event in events {
|
||||
if event.kind == announcement_kind {
|
||||
// Announcements are self-justifying (no retention reasons needed)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find all announcements this event transitively depends on
|
||||
let mut retention_reasons = HashSet::new();
|
||||
let mut to_process = vec![event.id];
|
||||
let mut visited = HashSet::new();
|
||||
let mut depth = 0;
|
||||
|
||||
while let Some(current_id) = to_process.pop() {
|
||||
if depth >= graph.max_depth {
|
||||
break;
|
||||
}
|
||||
|
||||
if visited.contains(¤t_id) {
|
||||
continue;
|
||||
}
|
||||
visited.insert(current_id);
|
||||
|
||||
// Check if this is an announcement
|
||||
if announcements.contains(¤t_id) {
|
||||
retention_reasons.insert(current_id);
|
||||
}
|
||||
|
||||
// Add dependencies to process
|
||||
if let Some(node) = graph.nodes.get(¤t_id) {
|
||||
for referenced_id in &node.references {
|
||||
to_process.push(*referenced_id);
|
||||
}
|
||||
}
|
||||
|
||||
depth += 1;
|
||||
}
|
||||
|
||||
// Update the node with retention reasons
|
||||
if let Some(node) = graph.nodes.get_mut(&event.id) {
|
||||
node.retention_reasons = retention_reasons;
|
||||
}
|
||||
}
|
||||
|
||||
graph
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr_relay_builder::prelude::{EventBuilder, Keys, Tag, TagKind};
|
||||
|
||||
#[test]
|
||||
fn test_event_node_creation() {
|
||||
let event_id =
|
||||
EventId::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
|
||||
.unwrap();
|
||||
let kind = Kind::from(30617);
|
||||
|
||||
let node = EventNode::new(event_id, kind);
|
||||
|
||||
assert_eq!(node.event_id, event_id);
|
||||
assert_eq!(node.kind, kind);
|
||||
assert!(node.references.is_empty());
|
||||
assert!(node.retention_reasons.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_node_add_reference() {
|
||||
let event_id =
|
||||
EventId::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
|
||||
.unwrap();
|
||||
let referenced_id =
|
||||
EventId::from_hex("0000000000000000000000000000000000000000000000000000000000000002")
|
||||
.unwrap();
|
||||
|
||||
let mut node = EventNode::new(event_id, Kind::from(1617));
|
||||
node.add_reference(referenced_id);
|
||||
|
||||
assert_eq!(node.references.len(), 1);
|
||||
assert!(node.references.contains(&referenced_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_graph_empty() {
|
||||
let graph = EventGraph::new();
|
||||
|
||||
assert_eq!(graph.node_count(), 0);
|
||||
assert_eq!(graph.edge_count(), 0);
|
||||
assert_eq!(graph.max_depth(), DEFAULT_MAX_DEPTH);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_graph_with_max_depth() {
|
||||
let graph = EventGraph::with_max_depth(50);
|
||||
|
||||
assert_eq!(graph.max_depth(), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_graph_add_node() {
|
||||
let mut graph = EventGraph::new();
|
||||
let event_id =
|
||||
EventId::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
|
||||
.unwrap();
|
||||
|
||||
let node = EventNode::new(event_id, Kind::from(30617));
|
||||
graph.add_node(node);
|
||||
|
||||
assert_eq!(graph.node_count(), 1);
|
||||
assert!(graph.get_node(&event_id).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_graph_add_edge() {
|
||||
let mut graph = EventGraph::new();
|
||||
let event_id_1 =
|
||||
EventId::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
|
||||
.unwrap();
|
||||
let event_id_2 =
|
||||
EventId::from_hex("0000000000000000000000000000000000000000000000000000000000000002")
|
||||
.unwrap();
|
||||
|
||||
graph.add_node(EventNode::new(event_id_1, Kind::from(1617)));
|
||||
graph.add_node(EventNode::new(event_id_2, Kind::from(30617)));
|
||||
graph.add_edge(event_id_1, event_id_2);
|
||||
|
||||
assert_eq!(graph.edge_count(), 1);
|
||||
|
||||
let deps = graph.get_dependencies(&event_id_1);
|
||||
assert_eq!(deps.len(), 1);
|
||||
assert!(deps.contains(&event_id_2));
|
||||
|
||||
let dependents = graph.get_dependents(&event_id_2);
|
||||
assert_eq!(dependents.len(), 1);
|
||||
assert!(dependents.contains(&event_id_1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_event_graph_empty() {
|
||||
let events: Vec<Event> = vec![];
|
||||
let graph = build_event_graph(&events);
|
||||
|
||||
assert_eq!(graph.node_count(), 0);
|
||||
assert_eq!(graph.edge_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_event_graph_single_event() {
|
||||
let keys = Keys::generate();
|
||||
let event = EventBuilder::new(Kind::from(30617), "test repo")
|
||||
.tags(vec![Tag::custom(
|
||||
TagKind::custom("d"),
|
||||
vec!["test-repo".to_string()],
|
||||
)])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
let graph = build_event_graph(&[event.clone()]);
|
||||
|
||||
assert_eq!(graph.node_count(), 1);
|
||||
assert_eq!(graph.edge_count(), 0);
|
||||
|
||||
let node = graph.get_node(&event.id).unwrap();
|
||||
assert_eq!(node.event_id, event.id);
|
||||
assert_eq!(node.kind, Kind::from(30617));
|
||||
assert!(node.references.is_empty());
|
||||
// Announcements are self-justifying
|
||||
assert!(node.retention_reasons.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_event_graph_with_reference() {
|
||||
let keys = Keys::generate();
|
||||
|
||||
// Create announcement
|
||||
let announcement = EventBuilder::new(Kind::from(30617), "test repo")
|
||||
.tags(vec![Tag::custom(
|
||||
TagKind::custom("d"),
|
||||
vec!["test-repo".to_string()],
|
||||
)])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
// Create patch that references the announcement
|
||||
let patch = EventBuilder::new(Kind::from(1617), "test patch")
|
||||
.tags(vec![Tag::custom(
|
||||
TagKind::custom("e"),
|
||||
vec![announcement.id.to_hex()],
|
||||
)])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
let graph = build_event_graph(&[announcement.clone(), patch.clone()]);
|
||||
|
||||
assert_eq!(graph.node_count(), 2);
|
||||
assert_eq!(graph.edge_count(), 1);
|
||||
|
||||
// Check patch references announcement
|
||||
let deps = graph.get_dependencies(&patch.id);
|
||||
assert_eq!(deps.len(), 1);
|
||||
assert!(deps.contains(&announcement.id));
|
||||
|
||||
// Check announcement has patch as dependent
|
||||
let dependents = graph.get_dependents(&announcement.id);
|
||||
assert_eq!(dependents.len(), 1);
|
||||
assert!(dependents.contains(&patch.id));
|
||||
|
||||
// Check retention reasons
|
||||
let patch_node = graph.get_node(&patch.id).unwrap();
|
||||
assert_eq!(patch_node.retention_reasons.len(), 1);
|
||||
assert!(patch_node.retention_reasons.contains(&announcement.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_event_graph_circular_reference() {
|
||||
let keys = Keys::generate();
|
||||
|
||||
// Create two events that reference each other (shouldn't happen in practice,
|
||||
// but we need to handle it gracefully)
|
||||
let event1 = EventBuilder::new(Kind::from(1617), "event 1")
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
let event2 = EventBuilder::new(Kind::from(1617), "event 2")
|
||||
.tags(vec![Tag::custom(
|
||||
TagKind::custom("e"),
|
||||
vec![event1.id.to_hex()],
|
||||
)])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
// Manually add circular reference (can't do this in real events)
|
||||
// For this test, we'll just verify the graph handles it without infinite loops
|
||||
let graph = build_event_graph(&[event1.clone(), event2.clone()]);
|
||||
|
||||
assert_eq!(graph.node_count(), 2);
|
||||
|
||||
// Get transitive dependents should not hang
|
||||
let dependents = graph.get_transitive_dependents(&event1.id);
|
||||
assert!(dependents.len() <= 2); // Should not infinite loop
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_graph_transitive_dependents() {
|
||||
let mut graph = EventGraph::new();
|
||||
|
||||
// Create a chain: event1 <- event2 <- event3
|
||||
let event_id_1 =
|
||||
EventId::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
|
||||
.unwrap();
|
||||
let event_id_2 =
|
||||
EventId::from_hex("0000000000000000000000000000000000000000000000000000000000000002")
|
||||
.unwrap();
|
||||
let event_id_3 =
|
||||
EventId::from_hex("0000000000000000000000000000000000000000000000000000000000000003")
|
||||
.unwrap();
|
||||
|
||||
graph.add_node(EventNode::new(event_id_1, Kind::from(30617)));
|
||||
graph.add_node(EventNode::new(event_id_2, Kind::from(1617)));
|
||||
graph.add_node(EventNode::new(event_id_3, Kind::from(1619)));
|
||||
|
||||
graph.add_edge(event_id_2, event_id_1);
|
||||
graph.add_edge(event_id_3, event_id_2);
|
||||
|
||||
// Get transitive dependents of event1
|
||||
let dependents = graph.get_transitive_dependents(&event_id_1);
|
||||
assert_eq!(dependents.len(), 2);
|
||||
assert!(dependents.contains(&event_id_2));
|
||||
assert!(dependents.contains(&event_id_3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_graph_max_depth_limit() {
|
||||
let mut graph = EventGraph::with_max_depth(2);
|
||||
|
||||
// Create a long chain: event1 <- event2 <- event3 <- event4
|
||||
let event_id_1 =
|
||||
EventId::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
|
||||
.unwrap();
|
||||
let event_id_2 =
|
||||
EventId::from_hex("0000000000000000000000000000000000000000000000000000000000000002")
|
||||
.unwrap();
|
||||
let event_id_3 =
|
||||
EventId::from_hex("0000000000000000000000000000000000000000000000000000000000000003")
|
||||
.unwrap();
|
||||
let event_id_4 =
|
||||
EventId::from_hex("0000000000000000000000000000000000000000000000000000000000000004")
|
||||
.unwrap();
|
||||
|
||||
graph.add_node(EventNode::new(event_id_1, Kind::from(30617)));
|
||||
graph.add_node(EventNode::new(event_id_2, Kind::from(1617)));
|
||||
graph.add_node(EventNode::new(event_id_3, Kind::from(1619)));
|
||||
graph.add_node(EventNode::new(event_id_4, Kind::from(1619)));
|
||||
|
||||
graph.add_edge(event_id_2, event_id_1);
|
||||
graph.add_edge(event_id_3, event_id_2);
|
||||
graph.add_edge(event_id_4, event_id_3);
|
||||
|
||||
// Get transitive dependents of event1 with max_depth=2
|
||||
let dependents = graph.get_transitive_dependents(&event_id_1);
|
||||
// Should only get event2 and event3 (depth 1 and 2), not event4 (depth 3)
|
||||
assert!(dependents.len() <= 3); // Max depth limits traversal
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -7,21 +7,33 @@
|
||||
/// - `RelatedEventPolicy` - Forward/backward reference checking
|
||||
/// - `DeletionPolicy` - NIP-09 deletion request validation
|
||||
/// - `deletion_ops` - Event dependency queries and database migration
|
||||
/// - `graph` - Event dependency graph for multi-maintainer deletion scenarios
|
||||
/// - `reevaluation` - Re-evaluation engine for multi-maintainer scenarios
|
||||
/// - `traversal` - Graph traversal and circular dependency detection
|
||||
mod announcement;
|
||||
mod deletion;
|
||||
mod deletion_ops;
|
||||
mod graph;
|
||||
mod pr_event;
|
||||
mod reevaluation;
|
||||
mod related;
|
||||
mod state;
|
||||
mod traversal;
|
||||
|
||||
pub use announcement::{AnnouncementPolicy, AnnouncementResult};
|
||||
pub use deletion::{DeletionPolicy, DeletionResult};
|
||||
pub use deletion_ops::{
|
||||
archive_repositories_for_addresses, move_to_holding_database, query_dependent_events,
|
||||
archive_repositories_for_addresses, determine_events_to_delete, move_to_holding_database,
|
||||
query_dependent_events,
|
||||
};
|
||||
pub use graph::{build_event_graph, EventGraph, EventNode};
|
||||
pub use pr_event::PrEventPolicy;
|
||||
pub use reevaluation::{
|
||||
reevaluate_events_without_announcement, ReevaluationResult, RetentionReason,
|
||||
};
|
||||
pub use related::{ReferenceResult, RelatedEventPolicy};
|
||||
pub use state::{StatePolicy, StateResult};
|
||||
pub use traversal::{traverse_and_mark_deletions, CircularDependency, TraversalResult};
|
||||
|
||||
// Re-export AlignmentResult from git::sync (canonical location)
|
||||
pub use crate::git::sync::AlignmentResult;
|
||||
|
||||
@@ -0,0 +1,667 @@
|
||||
/// Re-evaluation Engine - Determines event retention after announcement deletion
|
||||
///
|
||||
/// When a repository announcement is deleted, this module re-evaluates all dependent
|
||||
/// events to determine which should be kept (still acceptable through other announcements)
|
||||
/// vs deleted (no longer acceptable).
|
||||
///
|
||||
/// This is critical for multi-maintainer scenarios where one maintainer deletes their
|
||||
/// announcement but other maintainers' announcements should keep the events alive.
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use nostr_relay_builder::prelude::{Event, EventId, Filter, Kind};
|
||||
|
||||
use super::SharedDatabase;
|
||||
|
||||
/// Result of re-evaluating events after an announcement deletion
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReevaluationResult {
|
||||
/// Events that should be KEPT (still acceptable through other announcements)
|
||||
pub keep: HashMap<EventId, RetentionReason>,
|
||||
/// Events that should be DELETED (no longer acceptable)
|
||||
pub delete: HashSet<EventId>,
|
||||
}
|
||||
|
||||
/// Reason why an event is being kept after re-evaluation
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RetentionReason {
|
||||
/// Event ID being retained
|
||||
pub event_id: EventId,
|
||||
/// Announcement addresses that make this event acceptable
|
||||
pub valid_announcements: Vec<String>,
|
||||
/// Event type description for logging
|
||||
pub event_type: String,
|
||||
}
|
||||
|
||||
/// Re-evaluate events that were dependent on a deleted announcement
|
||||
///
|
||||
/// This function determines which events should be kept vs deleted when an
|
||||
/// announcement is removed. It checks if events have alternative retention
|
||||
/// reasons through other valid announcements.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `database` - Database to query for announcements and events
|
||||
/// * `deleted_address` - Address of the deleted announcement (format: `30617:<pubkey>:<identifier>`)
|
||||
/// * `potentially_affected` - Event IDs that might be affected by the deletion
|
||||
///
|
||||
/// # Returns
|
||||
/// `ReevaluationResult` with events to keep (with reasons) and events to delete
|
||||
///
|
||||
/// # Algorithm
|
||||
/// For each potentially affected event:
|
||||
/// 1. Query all active repository announcements (kind 30617) from database
|
||||
/// 2. Extract all `a` tags from the event that reference announcements
|
||||
/// 3. Check if any of those `a` tags reference announcements OTHER than the deleted one
|
||||
/// 4. If YES: event is KEPT (has alternative retention reason)
|
||||
/// 5. If NO: check if event is a repository announcement itself (multi-maintainer case)
|
||||
/// 6. If repository announcement: check if other maintainers have announcements for same identifier
|
||||
/// 7. If YES: event is KEPT (other maintainers exist)
|
||||
/// 8. If NO: event is DELETED (no valid announcements remain)
|
||||
pub async fn reevaluate_events_without_announcement(
|
||||
database: &SharedDatabase,
|
||||
deleted_address: &str,
|
||||
potentially_affected: &HashSet<EventId>,
|
||||
) -> Result<ReevaluationResult, String> {
|
||||
let mut result = ReevaluationResult {
|
||||
keep: HashMap::new(),
|
||||
delete: HashSet::new(),
|
||||
};
|
||||
|
||||
// Parse the deleted address to get identifier
|
||||
let deleted_identifier = parse_identifier_from_address(deleted_address)?;
|
||||
|
||||
// Query all active repository announcements (kind 30617) for this identifier
|
||||
let active_announcements = query_active_announcements(database, &deleted_identifier).await?;
|
||||
|
||||
// Build a set of valid announcement addresses (excluding the deleted one)
|
||||
let valid_addresses: HashSet<String> = active_announcements
|
||||
.iter()
|
||||
.map(|event| build_announcement_address(event))
|
||||
.filter(|addr| addr != deleted_address)
|
||||
.collect();
|
||||
|
||||
tracing::debug!(
|
||||
deleted_address = %deleted_address,
|
||||
identifier = %deleted_identifier,
|
||||
active_count = active_announcements.len(),
|
||||
valid_count = valid_addresses.len(),
|
||||
"Re-evaluating {} events after announcement deletion",
|
||||
potentially_affected.len()
|
||||
);
|
||||
|
||||
// Re-evaluate each potentially affected event
|
||||
for event_id in potentially_affected {
|
||||
// Query the event from database
|
||||
let filter = Filter::new().id(*event_id);
|
||||
let events: Vec<Event> = database
|
||||
.query(filter)
|
||||
.await
|
||||
.map_err(|e| format!("Database query failed: {}", e))?
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
if events.is_empty() {
|
||||
tracing::debug!(
|
||||
event_id = %event_id,
|
||||
"Event not found in database during re-evaluation, marking for deletion (fail-secure)"
|
||||
);
|
||||
// If event is not in database, mark it for deletion (fail-secure)
|
||||
result.delete.insert(*event_id);
|
||||
continue;
|
||||
}
|
||||
|
||||
let event = &events[0];
|
||||
|
||||
// Determine if this event should be kept or deleted
|
||||
match evaluate_event_retention(event, &deleted_identifier, &valid_addresses).await {
|
||||
Ok(Some(reason)) => {
|
||||
tracing::debug!(
|
||||
event_id = %event_id,
|
||||
event_type = %reason.event_type,
|
||||
valid_announcements = ?reason.valid_announcements,
|
||||
"Event KEPT: has alternative retention reason"
|
||||
);
|
||||
result.keep.insert(*event_id, reason);
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::debug!(
|
||||
event_id = %event_id,
|
||||
kind = %event.kind,
|
||||
"Event DELETED: no valid announcements remain"
|
||||
);
|
||||
result.delete.insert(*event_id);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
event_id = %event_id,
|
||||
error = %e,
|
||||
"Error evaluating event retention, marking for deletion (fail-secure)"
|
||||
);
|
||||
result.delete.insert(*event_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
deleted_address = %deleted_address,
|
||||
keep_count = result.keep.len(),
|
||||
delete_count = result.delete.len(),
|
||||
"Re-evaluation complete: {} kept, {} deleted",
|
||||
result.keep.len(),
|
||||
result.delete.len()
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Evaluate whether a single event should be retained after announcement deletion
|
||||
///
|
||||
/// Returns Some(RetentionReason) if event should be kept, None if it should be deleted.
|
||||
async fn evaluate_event_retention(
|
||||
event: &Event,
|
||||
_deleted_identifier: &str,
|
||||
valid_addresses: &HashSet<String>,
|
||||
) -> Result<Option<RetentionReason>, String> {
|
||||
let event_type = get_event_type_description(event);
|
||||
|
||||
// Special case: Repository announcements (kind 30617)
|
||||
// These are kept if there are OTHER announcements for the same identifier
|
||||
if event.kind == Kind::GitRepoAnnouncement {
|
||||
let event_address = build_announcement_address(event);
|
||||
|
||||
// If this IS the deleted announcement, it should be deleted
|
||||
if !valid_addresses.contains(&event_address) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// This is a different announcement for the same identifier - keep it
|
||||
return Ok(Some(RetentionReason {
|
||||
event_id: event.id,
|
||||
valid_announcements: vec![event_address],
|
||||
event_type,
|
||||
}));
|
||||
}
|
||||
|
||||
// For all other events: check if they have `a` tags referencing valid announcements
|
||||
let referenced_announcements = extract_announcement_references(event);
|
||||
|
||||
#[cfg(test)]
|
||||
println!(
|
||||
"Event {}: referenced {} announcements, {} valid addresses",
|
||||
event.id,
|
||||
referenced_announcements.len(),
|
||||
valid_addresses.len()
|
||||
);
|
||||
|
||||
// Filter to only valid announcements (excluding deleted one)
|
||||
let valid_refs: Vec<String> = referenced_announcements
|
||||
.into_iter()
|
||||
.filter(|addr| valid_addresses.contains(addr))
|
||||
.collect();
|
||||
|
||||
if valid_refs.is_empty() {
|
||||
// No valid announcement references - event should be deleted
|
||||
tracing::debug!(
|
||||
event_id = %event.id,
|
||||
"No valid announcement references found"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Event has valid announcement references - keep it
|
||||
tracing::debug!(
|
||||
event_id = %event.id,
|
||||
valid_refs_count = valid_refs.len(),
|
||||
"Event has valid announcement references, keeping"
|
||||
);
|
||||
Ok(Some(RetentionReason {
|
||||
event_id: event.id,
|
||||
valid_announcements: valid_refs,
|
||||
event_type,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Query all active repository announcements for a given identifier
|
||||
///
|
||||
/// Returns all kind 30617 events with matching `d` tag (identifier).
|
||||
async fn query_active_announcements(
|
||||
database: &SharedDatabase,
|
||||
identifier: &str,
|
||||
) -> Result<Vec<Event>, String> {
|
||||
// Query all repository announcements
|
||||
let filter = Filter::new().kind(Kind::GitRepoAnnouncement);
|
||||
|
||||
let all_announcements: Vec<Event> = database
|
||||
.query(filter)
|
||||
.await
|
||||
.map_err(|e| format!("Database query failed: {}", e))?
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
// Filter by identifier manually (some databases don't support custom_tag queries properly)
|
||||
let events: Vec<Event> = all_announcements
|
||||
.into_iter()
|
||||
.filter(|event| {
|
||||
event.tags.iter().any(|tag| {
|
||||
let tag_vec = tag.as_slice();
|
||||
tag_vec.len() >= 2 && tag_vec[0] == "d" && tag_vec[1] == identifier
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
/// Extract all announcement references (`a` tags) from an event
|
||||
///
|
||||
/// Returns addresses in format: `30617:<pubkey>:<identifier>`
|
||||
fn extract_announcement_references(event: &Event) -> Vec<String> {
|
||||
let mut refs = Vec::new();
|
||||
|
||||
for tag in event.tags.iter() {
|
||||
let tag_vec = tag.as_slice();
|
||||
if tag_vec.len() >= 2 && tag_vec[0] == "a" {
|
||||
let address = &tag_vec[1];
|
||||
// Only include kind 30617 (repository announcement) references
|
||||
if address.starts_with("30617:") {
|
||||
refs.push(address.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
refs
|
||||
}
|
||||
|
||||
/// Build announcement address from event
|
||||
///
|
||||
/// Format: `30617:<pubkey>:<identifier>`
|
||||
fn build_announcement_address(event: &Event) -> String {
|
||||
let identifier = event
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| {
|
||||
let tag_vec = tag.as_slice();
|
||||
if tag_vec.len() >= 2 && tag_vec[0] == "d" {
|
||||
Some(tag_vec[1].to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
format!("30617:{}:{}", event.pubkey.to_hex(), identifier)
|
||||
}
|
||||
|
||||
/// Parse identifier from announcement address
|
||||
///
|
||||
/// Address format: `30617:<pubkey>:<identifier>`
|
||||
fn parse_identifier_from_address(address: &str) -> Result<String, String> {
|
||||
let parts: Vec<&str> = address.split(':').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(format!("Invalid address format: {}", address));
|
||||
}
|
||||
|
||||
if parts[0] != "30617" {
|
||||
return Err(format!(
|
||||
"Not a repository announcement address: {}",
|
||||
address
|
||||
));
|
||||
}
|
||||
|
||||
Ok(parts[2].to_string())
|
||||
}
|
||||
|
||||
/// Get human-readable event type description for logging
|
||||
fn get_event_type_description(event: &Event) -> String {
|
||||
match event.kind {
|
||||
Kind::GitRepoAnnouncement => "Repository Announcement".to_string(),
|
||||
Kind::RepoState => "Repository State".to_string(),
|
||||
Kind::GitPullRequest => "Pull Request".to_string(),
|
||||
Kind::GitPullRequestUpdate => "PR Update".to_string(),
|
||||
kind if kind == Kind::from(1621) => "Issue".to_string(),
|
||||
kind if kind == Kind::from(1617) => "Patch".to_string(),
|
||||
kind if kind == Kind::from(1630) => "Issue Status".to_string(),
|
||||
kind if kind == Kind::from(1631) => "PR Status".to_string(),
|
||||
kind if kind == Kind::from(1632) => "Patch Status".to_string(),
|
||||
kind if kind == Kind::from(1633) => "Repository Status".to_string(),
|
||||
_ => format!("Event (kind {})", event.kind.as_u16()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr_relay_builder::prelude::{
|
||||
EventBuilder, Keys, MemoryDatabase, MemoryDatabaseOptions, NostrDatabase, Tag, TagKind,
|
||||
};
|
||||
use std::num::NonZeroUsize;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Helper to create a test repository announcement
|
||||
fn create_announcement(keys: &Keys, identifier: &str, domain: &str) -> Event {
|
||||
EventBuilder::new(Kind::GitRepoAnnouncement, "Test repository")
|
||||
.tags(vec![
|
||||
Tag::custom(TagKind::custom("d"), vec![identifier.to_string()]),
|
||||
Tag::custom(
|
||||
TagKind::custom("clone"),
|
||||
vec![format!("https://{}/{}", domain, identifier)],
|
||||
),
|
||||
Tag::custom(TagKind::custom("relays"), vec![format!("wss://{}", domain)]),
|
||||
])
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Helper to create a test issue event
|
||||
fn create_issue(keys: &Keys, announcement_address: &str) -> Event {
|
||||
EventBuilder::new(Kind::from(1621), "Test issue")
|
||||
.tags(vec![Tag::custom(
|
||||
TagKind::custom("a"),
|
||||
vec![announcement_address.to_string()],
|
||||
)])
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Helper to create a test patch event
|
||||
fn create_patch(keys: &Keys, announcement_address: &str) -> Event {
|
||||
EventBuilder::new(Kind::from(1617), "Test patch")
|
||||
.tags(vec![Tag::custom(
|
||||
TagKind::custom("a"),
|
||||
vec![announcement_address.to_string()],
|
||||
)])
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_single_maintainer_all_deleted() {
|
||||
// Setup: Single maintainer with announcement and dependent events
|
||||
let keys = Keys::generate();
|
||||
let identifier = "test-repo";
|
||||
let domain = "relay.example.com";
|
||||
|
||||
let announcement = create_announcement(&keys, identifier, domain);
|
||||
let announcement_addr = build_announcement_address(&announcement);
|
||||
|
||||
let issue = create_issue(&keys, &announcement_addr);
|
||||
let patch = create_patch(&keys, &announcement_addr);
|
||||
|
||||
// Create in-memory database and store events
|
||||
let db: Arc<dyn NostrDatabase> =
|
||||
Arc::new(MemoryDatabase::with_opts(MemoryDatabaseOptions {
|
||||
events: true,
|
||||
max_events: Some(NonZeroUsize::new(100_000).unwrap()),
|
||||
}));
|
||||
db.save_event(&announcement).await.unwrap();
|
||||
db.save_event(&issue).await.unwrap();
|
||||
db.save_event(&patch).await.unwrap();
|
||||
|
||||
// Re-evaluate after deleting the only announcement
|
||||
let affected = HashSet::from([issue.id, patch.id]);
|
||||
let result = reevaluate_events_without_announcement(&db, &announcement_addr, &affected)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// All events should be deleted (no other announcements)
|
||||
assert_eq!(result.keep.len(), 0);
|
||||
assert_eq!(result.delete.len(), 2);
|
||||
assert!(result.delete.contains(&issue.id));
|
||||
assert!(result.delete.contains(&patch.id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_two_maintainers_one_deletes() {
|
||||
// Setup: Two maintainers with announcements for same repository
|
||||
let maintainer1 = Keys::generate();
|
||||
let maintainer2 = Keys::generate();
|
||||
let identifier = "shared-repo";
|
||||
let domain = "relay.example.com";
|
||||
|
||||
let announcement1 = create_announcement(&maintainer1, identifier, domain);
|
||||
let announcement2 = create_announcement(&maintainer2, identifier, domain);
|
||||
let addr1 = build_announcement_address(&announcement1);
|
||||
let addr2 = build_announcement_address(&announcement2);
|
||||
|
||||
// Create issue referencing both announcements
|
||||
let issue = EventBuilder::new(Kind::from(1621), "Test issue")
|
||||
.tags(vec![
|
||||
Tag::custom(TagKind::custom("a"), vec![addr1.clone()]),
|
||||
Tag::custom(TagKind::custom("a"), vec![addr2.clone()]),
|
||||
])
|
||||
.sign_with_keys(&maintainer1)
|
||||
.unwrap();
|
||||
|
||||
// Create patch referencing only first announcement
|
||||
let patch = create_patch(&maintainer1, &addr1);
|
||||
|
||||
// Store in database
|
||||
let db: Arc<dyn NostrDatabase> =
|
||||
Arc::new(MemoryDatabase::with_opts(MemoryDatabaseOptions {
|
||||
events: true,
|
||||
max_events: Some(NonZeroUsize::new(100_000).unwrap()),
|
||||
}));
|
||||
db.save_event(&announcement1).await.unwrap();
|
||||
db.save_event(&announcement2).await.unwrap();
|
||||
db.save_event(&issue).await.unwrap();
|
||||
db.save_event(&patch).await.unwrap();
|
||||
|
||||
// Re-evaluate after deleting first announcement
|
||||
let affected = HashSet::from([issue.id, patch.id]);
|
||||
let result = reevaluate_events_without_announcement(&db, &addr1, &affected)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Issue should be kept (references announcement2)
|
||||
assert_eq!(result.keep.len(), 1);
|
||||
assert!(result.keep.contains_key(&issue.id));
|
||||
let issue_reason = result.keep.get(&issue.id).unwrap();
|
||||
assert_eq!(issue_reason.valid_announcements.len(), 1);
|
||||
assert_eq!(issue_reason.valid_announcements[0], addr2);
|
||||
|
||||
// Patch should be deleted (only referenced announcement1)
|
||||
assert_eq!(result.delete.len(), 1);
|
||||
assert!(result.delete.contains(&patch.id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_multiple_announcement_references() {
|
||||
// Setup: Event with multiple announcement references
|
||||
let maintainer1 = Keys::generate();
|
||||
let maintainer2 = Keys::generate();
|
||||
let maintainer3 = Keys::generate();
|
||||
let identifier = "multi-repo";
|
||||
let domain = "relay.example.com";
|
||||
|
||||
let announcement1 = create_announcement(&maintainer1, identifier, domain);
|
||||
let announcement2 = create_announcement(&maintainer2, identifier, domain);
|
||||
let announcement3 = create_announcement(&maintainer3, identifier, domain);
|
||||
let addr1 = build_announcement_address(&announcement1);
|
||||
let addr2 = build_announcement_address(&announcement2);
|
||||
let addr3 = build_announcement_address(&announcement3);
|
||||
|
||||
// Create issue referencing all three announcements
|
||||
let issue = EventBuilder::new(Kind::from(1621), "Test issue")
|
||||
.tags(vec![
|
||||
Tag::custom(TagKind::custom("a"), vec![addr1.clone()]),
|
||||
Tag::custom(TagKind::custom("a"), vec![addr2.clone()]),
|
||||
Tag::custom(TagKind::custom("a"), vec![addr3.clone()]),
|
||||
])
|
||||
.sign_with_keys(&maintainer1)
|
||||
.unwrap();
|
||||
|
||||
// Store in database
|
||||
let db: Arc<dyn NostrDatabase> =
|
||||
Arc::new(MemoryDatabase::with_opts(MemoryDatabaseOptions {
|
||||
events: true,
|
||||
max_events: Some(NonZeroUsize::new(100_000).unwrap()),
|
||||
}));
|
||||
db.save_event(&announcement1).await.unwrap();
|
||||
db.save_event(&announcement2).await.unwrap();
|
||||
db.save_event(&announcement3).await.unwrap();
|
||||
db.save_event(&issue).await.unwrap();
|
||||
|
||||
// Re-evaluate after deleting first announcement
|
||||
let affected = HashSet::from([issue.id]);
|
||||
let result = reevaluate_events_without_announcement(&db, &addr1, &affected)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Issue should be kept (still has announcement2 and announcement3)
|
||||
assert_eq!(result.keep.len(), 1);
|
||||
assert!(result.keep.contains_key(&issue.id));
|
||||
let reason = result.keep.get(&issue.id).unwrap();
|
||||
assert_eq!(reason.valid_announcements.len(), 2);
|
||||
assert!(reason.valid_announcements.contains(&addr2));
|
||||
assert!(reason.valid_announcements.contains(&addr3));
|
||||
assert!(!reason.valid_announcements.contains(&addr1));
|
||||
|
||||
assert_eq!(result.delete.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_no_valid_announcements() {
|
||||
// Setup: Event with no valid announcement references
|
||||
let keys = Keys::generate();
|
||||
let identifier = "orphan-repo";
|
||||
let domain = "relay.example.com";
|
||||
|
||||
let announcement = create_announcement(&keys, identifier, domain);
|
||||
let announcement_addr = build_announcement_address(&announcement);
|
||||
|
||||
// Create issue with no announcement references
|
||||
let issue = EventBuilder::new(Kind::from(1621), "Orphan issue")
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
// Store in database
|
||||
let db: Arc<dyn NostrDatabase> =
|
||||
Arc::new(MemoryDatabase::with_opts(MemoryDatabaseOptions {
|
||||
events: true,
|
||||
max_events: Some(NonZeroUsize::new(100_000).unwrap()),
|
||||
}));
|
||||
db.save_event(&announcement).await.unwrap();
|
||||
db.save_event(&issue).await.unwrap();
|
||||
|
||||
// Re-evaluate after deleting announcement
|
||||
let affected = HashSet::from([issue.id]);
|
||||
let result = reevaluate_events_without_announcement(&db, &announcement_addr, &affected)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Issue should be deleted (no announcement references)
|
||||
assert_eq!(result.keep.len(), 0);
|
||||
assert_eq!(result.delete.len(), 1);
|
||||
assert!(result.delete.contains(&issue.id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_repository_announcement_deletion() {
|
||||
// Setup: Multiple announcements for same identifier
|
||||
let maintainer1 = Keys::generate();
|
||||
let maintainer2 = Keys::generate();
|
||||
let identifier = "shared-repo";
|
||||
let domain = "relay.example.com";
|
||||
|
||||
let announcement1 = create_announcement(&maintainer1, identifier, domain);
|
||||
let announcement2 = create_announcement(&maintainer2, identifier, domain);
|
||||
let addr1 = build_announcement_address(&announcement1);
|
||||
let _addr2 = build_announcement_address(&announcement2);
|
||||
|
||||
// Store in database
|
||||
let db: Arc<dyn NostrDatabase> =
|
||||
Arc::new(MemoryDatabase::with_opts(MemoryDatabaseOptions {
|
||||
events: true,
|
||||
max_events: Some(NonZeroUsize::new(100_000).unwrap()),
|
||||
}));
|
||||
db.save_event(&announcement1).await.unwrap();
|
||||
db.save_event(&announcement2).await.unwrap();
|
||||
|
||||
// Re-evaluate announcements after deleting first one
|
||||
let affected = HashSet::from([announcement1.id, announcement2.id]);
|
||||
let result = reevaluate_events_without_announcement(&db, &addr1, &affected)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// announcement2 should be kept (different maintainer)
|
||||
assert_eq!(result.keep.len(), 1);
|
||||
assert!(result.keep.contains_key(&announcement2.id));
|
||||
|
||||
// announcement1 should be deleted (it's the deleted one)
|
||||
assert_eq!(result.delete.len(), 1);
|
||||
assert!(result.delete.contains(&announcement1.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_identifier_from_address() {
|
||||
let address = "30617:abc123:my-repo";
|
||||
let identifier = parse_identifier_from_address(address).unwrap();
|
||||
assert_eq!(identifier, "my-repo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_identifier_invalid_format() {
|
||||
let address = "invalid";
|
||||
let result = parse_identifier_from_address(address);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_identifier_wrong_kind() {
|
||||
let address = "30618:abc123:my-repo";
|
||||
let result = parse_identifier_from_address(address);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_announcement_references() {
|
||||
let keys = Keys::generate();
|
||||
let event = EventBuilder::new(Kind::from(1621), "Test")
|
||||
.tags(vec![
|
||||
Tag::custom(TagKind::custom("a"), vec!["30617:abc:repo1".to_string()]),
|
||||
Tag::custom(TagKind::custom("a"), vec!["30617:def:repo2".to_string()]),
|
||||
Tag::custom(TagKind::custom("a"), vec!["30618:ghi:state".to_string()]), // Wrong kind
|
||||
Tag::custom(TagKind::custom("e"), vec!["event123".to_string()]), // Not an 'a' tag
|
||||
])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
let refs = extract_announcement_references(&event);
|
||||
assert_eq!(refs.len(), 2);
|
||||
assert!(refs.contains(&"30617:abc:repo1".to_string()));
|
||||
assert!(refs.contains(&"30617:def:repo2".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_announcement_address() {
|
||||
let keys = Keys::generate();
|
||||
let event = create_announcement(&keys, "my-repo", "example.com");
|
||||
let address = build_announcement_address(&event);
|
||||
|
||||
assert!(address.starts_with("30617:"));
|
||||
assert!(address.ends_with(":my-repo"));
|
||||
assert_eq!(address.split(':').count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_event_type_description() {
|
||||
let keys = Keys::generate();
|
||||
|
||||
let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "")
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
get_event_type_description(&announcement),
|
||||
"Repository Announcement"
|
||||
);
|
||||
|
||||
let issue = EventBuilder::new(Kind::from(1621), "")
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
assert_eq!(get_event_type_description(&issue), "Issue");
|
||||
|
||||
let pr = EventBuilder::new(Kind::GitPullRequest, "")
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
assert_eq!(get_event_type_description(&pr), "Pull Request");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,716 @@
|
||||
/// Graph Traversal and Circular Dependency Detection
|
||||
///
|
||||
/// Determines which events to keep vs delete in multi-maintainer scenarios by:
|
||||
/// 1. Starting from kept repository announcements (kind 30617)
|
||||
/// 2. Traversing the dependency graph to find all reachable events
|
||||
/// 3. Detecting circular dependencies and handling them appropriately
|
||||
/// 4. Marking unreachable events for deletion
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
|
||||
use nostr_relay_builder::prelude::EventId;
|
||||
|
||||
use super::graph::EventGraph;
|
||||
use super::reevaluation::RetentionReason;
|
||||
|
||||
/// Result of graph traversal and deletion marking
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TraversalResult {
|
||||
/// Events that should be KEPT (reachable from kept announcements)
|
||||
pub keep: HashSet<EventId>,
|
||||
/// Events that should be DELETED (unreachable from kept announcements)
|
||||
pub delete: HashSet<EventId>,
|
||||
/// Circular dependencies detected (for logging/debugging)
|
||||
pub circular_dependencies: Vec<CircularDependency>,
|
||||
}
|
||||
|
||||
/// Information about a detected circular dependency
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CircularDependency {
|
||||
/// Event IDs involved in the circular dependency
|
||||
pub events: Vec<EventId>,
|
||||
/// Whether this cycle is anchored (has a path to a kept announcement)
|
||||
pub anchored: bool,
|
||||
}
|
||||
|
||||
/// Traverse the event graph and mark events for deletion
|
||||
///
|
||||
/// This function implements a BFS traversal starting from kept repository announcements,
|
||||
/// marking all reachable events as KEEP and unreachable events as DELETE.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `graph` - Event dependency graph built from all events
|
||||
/// * `kept_events` - Map of events to keep (from re-evaluation), keyed by event ID
|
||||
/// * `deleted_announcement_id` - ID of the deleted announcement (for logging)
|
||||
///
|
||||
/// # Returns
|
||||
/// `TraversalResult` with events to keep, events to delete, and detected circular dependencies
|
||||
///
|
||||
/// # Algorithm
|
||||
/// 1. Identify all kept announcements (kind 30617) from `kept_events`
|
||||
/// 2. Use BFS to traverse from kept announcements, following dependency edges
|
||||
/// 3. Mark all reachable events as KEEP
|
||||
/// 4. Detect circular dependencies during traversal
|
||||
/// 5. Handle circular dependencies:
|
||||
/// - If cycle is anchored (reachable from kept announcement): KEEP all events in cycle
|
||||
/// - If cycle is unanchored (isolated): DELETE all events in cycle
|
||||
/// 6. Mark all unreachable events as DELETE
|
||||
/// 7. Respect max_depth from graph to prevent infinite loops
|
||||
pub fn traverse_and_mark_deletions(
|
||||
graph: &EventGraph,
|
||||
kept_events: &HashMap<EventId, RetentionReason>,
|
||||
_deleted_announcement_id: EventId,
|
||||
) -> TraversalResult {
|
||||
let mut result = TraversalResult {
|
||||
keep: HashSet::new(),
|
||||
delete: HashSet::new(),
|
||||
circular_dependencies: Vec::new(),
|
||||
};
|
||||
|
||||
// Step 1: Identify kept announcements (kind 30617) as starting points
|
||||
let kept_announcements: Vec<EventId> = kept_events
|
||||
.iter()
|
||||
.filter_map(|(event_id, reason)| {
|
||||
// Check if this is a repository announcement
|
||||
if reason.event_type == "Repository Announcement" {
|
||||
Some(*event_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
tracing::debug!(
|
||||
kept_announcements_count = kept_announcements.len(),
|
||||
kept_events_count = kept_events.len(),
|
||||
total_nodes = graph.node_count(),
|
||||
"Starting graph traversal from kept announcements"
|
||||
);
|
||||
|
||||
// Step 2: BFS traversal from kept announcements
|
||||
let reachable = bfs_traverse_from_announcements(graph, &kept_announcements);
|
||||
|
||||
tracing::debug!(
|
||||
reachable_count = reachable.len(),
|
||||
"BFS traversal complete, found reachable events"
|
||||
);
|
||||
|
||||
// Step 3: Detect circular dependencies
|
||||
let circular_deps = detect_circular_dependencies(graph, &reachable);
|
||||
|
||||
tracing::debug!(
|
||||
circular_deps_count = circular_deps.len(),
|
||||
"Circular dependency detection complete"
|
||||
);
|
||||
|
||||
// Step 4: Mark events as KEEP or DELETE
|
||||
for (event_id, _node) in graph.nodes() {
|
||||
if reachable.contains(event_id) {
|
||||
// Event is reachable from a kept announcement - KEEP
|
||||
result.keep.insert(*event_id);
|
||||
} else {
|
||||
// Event is not reachable - DELETE
|
||||
result.delete.insert(*event_id);
|
||||
}
|
||||
}
|
||||
|
||||
result.circular_dependencies = circular_deps;
|
||||
|
||||
tracing::info!(
|
||||
keep_count = result.keep.len(),
|
||||
delete_count = result.delete.len(),
|
||||
circular_deps_count = result.circular_dependencies.len(),
|
||||
"Graph traversal complete: {} kept, {} deleted, {} circular dependencies",
|
||||
result.keep.len(),
|
||||
result.delete.len(),
|
||||
result.circular_dependencies.len()
|
||||
);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// BFS traversal from kept announcements to find all reachable events
|
||||
///
|
||||
/// Uses breadth-first search to traverse the dependency graph, following both
|
||||
/// forward references (dependencies) and backward references (dependents).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `graph` - Event dependency graph
|
||||
/// * `kept_announcements` - Starting points for traversal (kept announcements)
|
||||
///
|
||||
/// # Returns
|
||||
/// Set of all event IDs reachable from kept announcements
|
||||
fn bfs_traverse_from_announcements(
|
||||
graph: &EventGraph,
|
||||
kept_announcements: &[EventId],
|
||||
) -> HashSet<EventId> {
|
||||
let mut reachable = HashSet::new();
|
||||
let mut queue = VecDeque::new();
|
||||
let mut visited = HashSet::new();
|
||||
|
||||
// Initialize queue with kept announcements
|
||||
for announcement_id in kept_announcements {
|
||||
queue.push_back((*announcement_id, 0)); // (event_id, depth)
|
||||
reachable.insert(*announcement_id);
|
||||
}
|
||||
|
||||
let max_depth = graph.max_depth();
|
||||
|
||||
while let Some((current_id, depth)) = queue.pop_front() {
|
||||
// Respect max depth to prevent infinite loops
|
||||
if depth >= max_depth {
|
||||
tracing::warn!(
|
||||
event_id = %current_id,
|
||||
depth = depth,
|
||||
max_depth = max_depth,
|
||||
"Reached max depth during BFS traversal"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if already visited
|
||||
if visited.contains(¤t_id) {
|
||||
continue;
|
||||
}
|
||||
visited.insert(current_id);
|
||||
|
||||
// Traverse to dependents (events that reference this event)
|
||||
// These are events that depend on the current event, so if the current
|
||||
// event is kept, its dependents should also be kept
|
||||
let dependents = graph.get_dependents(¤t_id);
|
||||
for dependent_id in dependents {
|
||||
if !reachable.contains(&dependent_id) {
|
||||
reachable.insert(dependent_id);
|
||||
queue.push_back((dependent_id, depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
// Note: We do NOT traverse to dependencies (events this event references)
|
||||
// because we're doing a forward traversal from announcements to dependents.
|
||||
// If we traversed backwards, we'd be going from dependents to announcements,
|
||||
// which is the opposite direction.
|
||||
}
|
||||
|
||||
reachable
|
||||
}
|
||||
|
||||
/// Detect circular dependencies in the graph
|
||||
///
|
||||
/// Finds cycles in the dependency graph and determines if they're anchored
|
||||
/// (reachable from a kept announcement) or unanchored (isolated).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `graph` - Event dependency graph
|
||||
/// * `reachable` - Set of events reachable from kept announcements
|
||||
///
|
||||
/// # Returns
|
||||
/// List of detected circular dependencies with anchoring information
|
||||
fn detect_circular_dependencies(
|
||||
graph: &EventGraph,
|
||||
reachable: &HashSet<EventId>,
|
||||
) -> Vec<CircularDependency> {
|
||||
let mut circular_deps = Vec::new();
|
||||
let mut visited = HashSet::new();
|
||||
let mut recursion_stack = HashSet::new();
|
||||
|
||||
// Use DFS to detect cycles
|
||||
for (event_id, _node) in graph.nodes() {
|
||||
if !visited.contains(event_id) {
|
||||
let mut path = Vec::new();
|
||||
detect_cycles_dfs(
|
||||
graph,
|
||||
*event_id,
|
||||
&mut visited,
|
||||
&mut recursion_stack,
|
||||
&mut path,
|
||||
&mut circular_deps,
|
||||
reachable,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
circular_deps
|
||||
}
|
||||
|
||||
/// DFS helper for cycle detection
|
||||
///
|
||||
/// Uses depth-first search with a recursion stack to detect cycles.
|
||||
/// When a cycle is found, checks if it's anchored (any event in cycle is reachable).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn detect_cycles_dfs(
|
||||
graph: &EventGraph,
|
||||
current_id: EventId,
|
||||
visited: &mut HashSet<EventId>,
|
||||
recursion_stack: &mut HashSet<EventId>,
|
||||
path: &mut Vec<EventId>,
|
||||
circular_deps: &mut Vec<CircularDependency>,
|
||||
reachable: &HashSet<EventId>,
|
||||
) {
|
||||
visited.insert(current_id);
|
||||
recursion_stack.insert(current_id);
|
||||
path.push(current_id);
|
||||
|
||||
// Get dependencies (events this event references)
|
||||
let dependencies = graph.get_dependencies(¤t_id);
|
||||
|
||||
for dep_id in dependencies {
|
||||
if !visited.contains(&dep_id) {
|
||||
// Continue DFS
|
||||
detect_cycles_dfs(
|
||||
graph,
|
||||
dep_id,
|
||||
visited,
|
||||
recursion_stack,
|
||||
path,
|
||||
circular_deps,
|
||||
reachable,
|
||||
);
|
||||
} else if recursion_stack.contains(&dep_id) {
|
||||
// Found a cycle! Extract the cycle from the path
|
||||
if let Some(cycle_start_idx) = path.iter().position(|&id| id == dep_id) {
|
||||
let cycle_events: Vec<EventId> = path[cycle_start_idx..].to_vec();
|
||||
|
||||
// Check if cycle is anchored (any event in cycle is reachable)
|
||||
let anchored = cycle_events.iter().any(|id| reachable.contains(id));
|
||||
|
||||
// Only add if we haven't seen this cycle before
|
||||
// (cycles can be detected multiple times from different entry points)
|
||||
let cycle_set: HashSet<EventId> = cycle_events.iter().copied().collect();
|
||||
let is_duplicate = circular_deps.iter().any(|cd| {
|
||||
let cd_set: HashSet<EventId> = cd.events.iter().copied().collect();
|
||||
cd_set == cycle_set
|
||||
});
|
||||
|
||||
if !is_duplicate {
|
||||
tracing::debug!(
|
||||
cycle_events = ?cycle_events,
|
||||
anchored = anchored,
|
||||
"Detected circular dependency"
|
||||
);
|
||||
|
||||
circular_deps.push(CircularDependency {
|
||||
events: cycle_events,
|
||||
anchored,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path.pop();
|
||||
recursion_stack.remove(¤t_id);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::nostr::policy::graph::{build_event_graph, EventNode};
|
||||
use crate::nostr::policy::reevaluation::RetentionReason;
|
||||
use nostr_relay_builder::prelude::{EventBuilder, EventId, Keys, Kind, Tag, TagKind};
|
||||
|
||||
/// Helper to create a test event ID from a number
|
||||
fn test_event_id(n: u8) -> EventId {
|
||||
let hex = format!("{:064x}", n);
|
||||
EventId::from_hex(&hex).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simple_linear_dependencies() {
|
||||
// Create a simple linear dependency chain:
|
||||
// announcement -> patch -> issue
|
||||
let mut graph = EventGraph::new();
|
||||
|
||||
let announcement_id = test_event_id(1);
|
||||
let patch_id = test_event_id(2);
|
||||
let issue_id = test_event_id(3);
|
||||
|
||||
// Add nodes
|
||||
graph.add_node(EventNode::new(announcement_id, Kind::from(30617)));
|
||||
graph.add_node(EventNode::new(patch_id, Kind::from(1617)));
|
||||
graph.add_node(EventNode::new(issue_id, Kind::from(1621)));
|
||||
|
||||
// Add edges (patch references announcement, issue references patch)
|
||||
graph.add_edge(patch_id, announcement_id);
|
||||
graph.add_edge(issue_id, patch_id);
|
||||
|
||||
// Create kept_events with announcement
|
||||
let mut kept_events = HashMap::new();
|
||||
kept_events.insert(
|
||||
announcement_id,
|
||||
RetentionReason {
|
||||
event_id: announcement_id,
|
||||
valid_announcements: vec![],
|
||||
event_type: "Repository Announcement".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
// Traverse
|
||||
let result = traverse_and_mark_deletions(&graph, &kept_events, announcement_id);
|
||||
|
||||
// All events should be kept (reachable from announcement)
|
||||
assert_eq!(result.keep.len(), 3);
|
||||
assert!(result.keep.contains(&announcement_id));
|
||||
assert!(result.keep.contains(&patch_id));
|
||||
assert!(result.keep.contains(&issue_id));
|
||||
|
||||
assert_eq!(result.delete.len(), 0);
|
||||
assert_eq!(result.circular_dependencies.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_isolated_subgraph() {
|
||||
// Create two subgraphs:
|
||||
// 1. announcement1 -> patch1 (KEPT)
|
||||
// 2. patch2 -> issue2 (isolated, should be DELETED)
|
||||
let mut graph = EventGraph::new();
|
||||
|
||||
let announcement1_id = test_event_id(1);
|
||||
let patch1_id = test_event_id(2);
|
||||
let patch2_id = test_event_id(3);
|
||||
let issue2_id = test_event_id(4);
|
||||
|
||||
// Add nodes
|
||||
graph.add_node(EventNode::new(announcement1_id, Kind::from(30617)));
|
||||
graph.add_node(EventNode::new(patch1_id, Kind::from(1617)));
|
||||
graph.add_node(EventNode::new(patch2_id, Kind::from(1617)));
|
||||
graph.add_node(EventNode::new(issue2_id, Kind::from(1621)));
|
||||
|
||||
// Add edges
|
||||
graph.add_edge(patch1_id, announcement1_id);
|
||||
graph.add_edge(issue2_id, patch2_id);
|
||||
|
||||
// Create kept_events with only announcement1
|
||||
let mut kept_events = HashMap::new();
|
||||
kept_events.insert(
|
||||
announcement1_id,
|
||||
RetentionReason {
|
||||
event_id: announcement1_id,
|
||||
valid_announcements: vec![],
|
||||
event_type: "Repository Announcement".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
// Traverse
|
||||
let result = traverse_and_mark_deletions(&graph, &kept_events, announcement1_id);
|
||||
|
||||
// announcement1 and patch1 should be kept
|
||||
assert_eq!(result.keep.len(), 2);
|
||||
assert!(result.keep.contains(&announcement1_id));
|
||||
assert!(result.keep.contains(&patch1_id));
|
||||
|
||||
// patch2 and issue2 should be deleted (isolated)
|
||||
assert_eq!(result.delete.len(), 2);
|
||||
assert!(result.delete.contains(&patch2_id));
|
||||
assert!(result.delete.contains(&issue2_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_circular_dependency_anchored() {
|
||||
// Create a circular dependency that's anchored to a kept announcement:
|
||||
// announcement -> patch1 <-> patch2 (circular)
|
||||
let mut graph = EventGraph::new();
|
||||
|
||||
let announcement_id = test_event_id(1);
|
||||
let patch1_id = test_event_id(2);
|
||||
let patch2_id = test_event_id(3);
|
||||
|
||||
// Add nodes
|
||||
graph.add_node(EventNode::new(announcement_id, Kind::from(30617)));
|
||||
graph.add_node(EventNode::new(patch1_id, Kind::from(1617)));
|
||||
graph.add_node(EventNode::new(patch2_id, Kind::from(1617)));
|
||||
|
||||
// Add edges (circular: patch1 -> announcement, patch1 -> patch2, patch2 -> patch1)
|
||||
graph.add_edge(patch1_id, announcement_id);
|
||||
graph.add_edge(patch1_id, patch2_id);
|
||||
graph.add_edge(patch2_id, patch1_id);
|
||||
|
||||
// Create kept_events with announcement
|
||||
let mut kept_events = HashMap::new();
|
||||
kept_events.insert(
|
||||
announcement_id,
|
||||
RetentionReason {
|
||||
event_id: announcement_id,
|
||||
valid_announcements: vec![],
|
||||
event_type: "Repository Announcement".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
// Traverse
|
||||
let result = traverse_and_mark_deletions(&graph, &kept_events, announcement_id);
|
||||
|
||||
// All events should be kept (circular dependency is anchored)
|
||||
assert_eq!(result.keep.len(), 3);
|
||||
assert!(result.keep.contains(&announcement_id));
|
||||
assert!(result.keep.contains(&patch1_id));
|
||||
assert!(result.keep.contains(&patch2_id));
|
||||
|
||||
assert_eq!(result.delete.len(), 0);
|
||||
|
||||
// Should detect the circular dependency
|
||||
assert_eq!(result.circular_dependencies.len(), 1);
|
||||
assert!(result.circular_dependencies[0].anchored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_circular_dependency_unanchored() {
|
||||
// Create an isolated circular dependency (no announcement):
|
||||
// patch1 <-> patch2 (circular, isolated)
|
||||
let mut graph = EventGraph::new();
|
||||
|
||||
let patch1_id = test_event_id(1);
|
||||
let patch2_id = test_event_id(2);
|
||||
|
||||
// Add nodes
|
||||
graph.add_node(EventNode::new(patch1_id, Kind::from(1617)));
|
||||
graph.add_node(EventNode::new(patch2_id, Kind::from(1617)));
|
||||
|
||||
// Add edges (circular: patch1 -> patch2, patch2 -> patch1)
|
||||
graph.add_edge(patch1_id, patch2_id);
|
||||
graph.add_edge(patch2_id, patch1_id);
|
||||
|
||||
// No kept events (no announcements)
|
||||
let kept_events = HashMap::new();
|
||||
|
||||
// Traverse
|
||||
let result = traverse_and_mark_deletions(&graph, &kept_events, test_event_id(99));
|
||||
|
||||
// Both events should be deleted (unanchored circular dependency)
|
||||
assert_eq!(result.keep.len(), 0);
|
||||
assert_eq!(result.delete.len(), 2);
|
||||
assert!(result.delete.contains(&patch1_id));
|
||||
assert!(result.delete.contains(&patch2_id));
|
||||
|
||||
// Should detect the circular dependency as unanchored
|
||||
assert_eq!(result.circular_dependencies.len(), 1);
|
||||
assert!(!result.circular_dependencies[0].anchored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deep_dependency_chain() {
|
||||
// Create a deep dependency chain (5 levels):
|
||||
// announcement -> e1 -> e2 -> e3 -> e4
|
||||
let mut graph = EventGraph::new();
|
||||
|
||||
let announcement_id = test_event_id(1);
|
||||
let e1_id = test_event_id(2);
|
||||
let e2_id = test_event_id(3);
|
||||
let e3_id = test_event_id(4);
|
||||
let e4_id = test_event_id(5);
|
||||
|
||||
// Add nodes
|
||||
graph.add_node(EventNode::new(announcement_id, Kind::from(30617)));
|
||||
graph.add_node(EventNode::new(e1_id, Kind::from(1617)));
|
||||
graph.add_node(EventNode::new(e2_id, Kind::from(1617)));
|
||||
graph.add_node(EventNode::new(e3_id, Kind::from(1617)));
|
||||
graph.add_node(EventNode::new(e4_id, Kind::from(1617)));
|
||||
|
||||
// Add edges (linear chain)
|
||||
graph.add_edge(e1_id, announcement_id);
|
||||
graph.add_edge(e2_id, e1_id);
|
||||
graph.add_edge(e3_id, e2_id);
|
||||
graph.add_edge(e4_id, e3_id);
|
||||
|
||||
// Create kept_events with announcement
|
||||
let mut kept_events = HashMap::new();
|
||||
kept_events.insert(
|
||||
announcement_id,
|
||||
RetentionReason {
|
||||
event_id: announcement_id,
|
||||
valid_announcements: vec![],
|
||||
event_type: "Repository Announcement".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
// Traverse
|
||||
let result = traverse_and_mark_deletions(&graph, &kept_events, announcement_id);
|
||||
|
||||
// All events should be kept
|
||||
assert_eq!(result.keep.len(), 5);
|
||||
assert!(result.keep.contains(&announcement_id));
|
||||
assert!(result.keep.contains(&e1_id));
|
||||
assert!(result.keep.contains(&e2_id));
|
||||
assert!(result.keep.contains(&e3_id));
|
||||
assert!(result.keep.contains(&e4_id));
|
||||
|
||||
assert_eq!(result.delete.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_depth_limit() {
|
||||
// Create a deep chain that exceeds max depth
|
||||
let mut graph = EventGraph::with_max_depth(3);
|
||||
|
||||
let announcement_id = test_event_id(1);
|
||||
let e1_id = test_event_id(2);
|
||||
let e2_id = test_event_id(3);
|
||||
let e3_id = test_event_id(4);
|
||||
let e4_id = test_event_id(5);
|
||||
let e5_id = test_event_id(6);
|
||||
|
||||
// Add nodes
|
||||
graph.add_node(EventNode::new(announcement_id, Kind::from(30617)));
|
||||
graph.add_node(EventNode::new(e1_id, Kind::from(1617)));
|
||||
graph.add_node(EventNode::new(e2_id, Kind::from(1617)));
|
||||
graph.add_node(EventNode::new(e3_id, Kind::from(1617)));
|
||||
graph.add_node(EventNode::new(e4_id, Kind::from(1617)));
|
||||
graph.add_node(EventNode::new(e5_id, Kind::from(1617)));
|
||||
|
||||
// Add edges (linear chain)
|
||||
graph.add_edge(e1_id, announcement_id);
|
||||
graph.add_edge(e2_id, e1_id);
|
||||
graph.add_edge(e3_id, e2_id);
|
||||
graph.add_edge(e4_id, e3_id);
|
||||
graph.add_edge(e5_id, e4_id);
|
||||
|
||||
// Create kept_events with announcement
|
||||
let mut kept_events = HashMap::new();
|
||||
kept_events.insert(
|
||||
announcement_id,
|
||||
RetentionReason {
|
||||
event_id: announcement_id,
|
||||
valid_announcements: vec![],
|
||||
event_type: "Repository Announcement".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
// Traverse
|
||||
let result = traverse_and_mark_deletions(&graph, &kept_events, announcement_id);
|
||||
|
||||
// Should keep events within max_depth (announcement, e1, e2, e3)
|
||||
// e4 and e5 might not be reached due to depth limit
|
||||
assert!(result.keep.contains(&announcement_id));
|
||||
assert!(result.keep.contains(&e1_id));
|
||||
assert!(result.keep.contains(&e2_id));
|
||||
assert!(result.keep.contains(&e3_id));
|
||||
|
||||
// Note: Depending on BFS implementation, e4 and e5 might be deleted
|
||||
// or kept. The important thing is that max_depth prevents infinite loops.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complex_reference_graph() {
|
||||
// Create a complex graph with multiple paths:
|
||||
// announcement
|
||||
// / | \
|
||||
// e1 e2 e3
|
||||
// | | |
|
||||
// e4 e5 e6
|
||||
// \ | /
|
||||
// e7
|
||||
let mut graph = EventGraph::new();
|
||||
|
||||
let announcement_id = test_event_id(1);
|
||||
let e1_id = test_event_id(2);
|
||||
let e2_id = test_event_id(3);
|
||||
let e3_id = test_event_id(4);
|
||||
let e4_id = test_event_id(5);
|
||||
let e5_id = test_event_id(6);
|
||||
let e6_id = test_event_id(7);
|
||||
let e7_id = test_event_id(8);
|
||||
|
||||
// Add nodes
|
||||
graph.add_node(EventNode::new(announcement_id, Kind::from(30617)));
|
||||
graph.add_node(EventNode::new(e1_id, Kind::from(1617)));
|
||||
graph.add_node(EventNode::new(e2_id, Kind::from(1617)));
|
||||
graph.add_node(EventNode::new(e3_id, Kind::from(1617)));
|
||||
graph.add_node(EventNode::new(e4_id, Kind::from(1617)));
|
||||
graph.add_node(EventNode::new(e5_id, Kind::from(1617)));
|
||||
graph.add_node(EventNode::new(e6_id, Kind::from(1617)));
|
||||
graph.add_node(EventNode::new(e7_id, Kind::from(1617)));
|
||||
|
||||
// Add edges
|
||||
graph.add_edge(e1_id, announcement_id);
|
||||
graph.add_edge(e2_id, announcement_id);
|
||||
graph.add_edge(e3_id, announcement_id);
|
||||
graph.add_edge(e4_id, e1_id);
|
||||
graph.add_edge(e5_id, e2_id);
|
||||
graph.add_edge(e6_id, e3_id);
|
||||
graph.add_edge(e7_id, e4_id);
|
||||
graph.add_edge(e7_id, e5_id);
|
||||
graph.add_edge(e7_id, e6_id);
|
||||
|
||||
// Create kept_events with announcement
|
||||
let mut kept_events = HashMap::new();
|
||||
kept_events.insert(
|
||||
announcement_id,
|
||||
RetentionReason {
|
||||
event_id: announcement_id,
|
||||
valid_announcements: vec![],
|
||||
event_type: "Repository Announcement".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
// Traverse
|
||||
let result = traverse_and_mark_deletions(&graph, &kept_events, announcement_id);
|
||||
|
||||
// All events should be kept (all reachable from announcement)
|
||||
assert_eq!(result.keep.len(), 8);
|
||||
assert!(result.keep.contains(&announcement_id));
|
||||
assert!(result.keep.contains(&e1_id));
|
||||
assert!(result.keep.contains(&e2_id));
|
||||
assert!(result.keep.contains(&e3_id));
|
||||
assert!(result.keep.contains(&e4_id));
|
||||
assert!(result.keep.contains(&e5_id));
|
||||
assert!(result.keep.contains(&e6_id));
|
||||
assert!(result.keep.contains(&e7_id));
|
||||
|
||||
assert_eq!(result.delete.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_integration_with_build_event_graph() {
|
||||
// Integration test using build_event_graph from Phase 3A
|
||||
let keys = Keys::generate();
|
||||
|
||||
// Create announcement
|
||||
let announcement = EventBuilder::new(Kind::from(30617), "test repo")
|
||||
.tags(vec![Tag::custom(
|
||||
TagKind::custom("d"),
|
||||
vec!["test-repo".to_string()],
|
||||
)])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
// Create patch that references the announcement
|
||||
let patch = EventBuilder::new(Kind::from(1617), "test patch")
|
||||
.tags(vec![Tag::custom(
|
||||
TagKind::custom("e"),
|
||||
vec![announcement.id.to_hex()],
|
||||
)])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
// Create issue that references the patch
|
||||
let issue = EventBuilder::new(Kind::from(1621), "test issue")
|
||||
.tags(vec![Tag::custom(
|
||||
TagKind::custom("e"),
|
||||
vec![patch.id.to_hex()],
|
||||
)])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
// Build graph
|
||||
let graph = build_event_graph(&[announcement.clone(), patch.clone(), issue.clone()]);
|
||||
|
||||
// Create kept_events with announcement
|
||||
let mut kept_events = HashMap::new();
|
||||
kept_events.insert(
|
||||
announcement.id,
|
||||
RetentionReason {
|
||||
event_id: announcement.id,
|
||||
valid_announcements: vec![],
|
||||
event_type: "Repository Announcement".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
// Traverse
|
||||
let result = traverse_and_mark_deletions(&graph, &kept_events, announcement.id);
|
||||
|
||||
// All events should be kept
|
||||
assert_eq!(result.keep.len(), 3);
|
||||
assert!(result.keep.contains(&announcement.id));
|
||||
assert!(result.keep.contains(&patch.id));
|
||||
assert!(result.keep.contains(&issue.id));
|
||||
|
||||
assert_eq!(result.delete.len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
//! NIP-09 Multi-Maintainer Deletion Integration Tests
|
||||
//!
|
||||
//! Tests ngit-grasp relay's handling of deletion requests in multi-maintainer scenarios.
|
||||
//! Verifies that the dependency graph algorithm correctly processes deletions when one or
|
||||
//! more maintainers remove their repository announcements.
|
||||
//!
|
||||
//! # Test Coverage
|
||||
//!
|
||||
//! 1. Two maintainers, one deletes (events should be preserved via other maintainer)
|
||||
//! 2. Two maintainers, both delete (events should be deleted)
|
||||
//! 3. Event referencing only one maintainer (should be deleted when that maintainer deletes)
|
||||
//! 4. Complex reference graphs (3+ levels deep)
|
||||
//! 5. Three maintainers with selective deletion
|
||||
//! 6. Deep dependency chains
|
||||
//! 7. Multiple events with different maintainer subsets
|
||||
//!
|
||||
//! # Testing Approach
|
||||
//!
|
||||
//! These integration tests verify that the relay correctly accepts deletion requests
|
||||
//! and processes them. The actual retention/deletion logic is verified through:
|
||||
//! - Server-side logs (check with RUST_LOG=debug)
|
||||
//! - Unit tests in src/nostr/policy/{reevaluation,traversal,graph}.rs
|
||||
//! - Database inspection tests in nip09_cascade.rs and nip09_archival.rs
|
||||
//!
|
||||
//! # Running Tests
|
||||
//!
|
||||
//! ```bash
|
||||
//! # Run all multi-maintainer tests
|
||||
//! cargo test --test nip09_multi_maintainer
|
||||
//!
|
||||
//! # Run specific test
|
||||
//! cargo test --test nip09_multi_maintainer test_two_maintainers_one_deletes
|
||||
//!
|
||||
//! # With debug output to see server-side processing
|
||||
//! RUST_LOG=debug cargo test --test nip09_multi_maintainer -- --nocapture
|
||||
//! ```
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{create_repo_announcement, TestRelay};
|
||||
use nostr_sdk::prelude::*;
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
/// Helper function to create a deletion event
|
||||
fn create_deletion_event(
|
||||
keys: &Keys,
|
||||
event_ids: &[EventId],
|
||||
addresses: &[String],
|
||||
) -> Result<Event, Box<dyn std::error::Error>> {
|
||||
let mut tags = vec![];
|
||||
|
||||
for id in event_ids {
|
||||
tags.push(Tag::custom(TagKind::custom("e"), vec![id.to_hex()]));
|
||||
}
|
||||
|
||||
for addr in addresses {
|
||||
tags.push(Tag::custom(TagKind::custom("a"), vec![addr.clone()]));
|
||||
}
|
||||
|
||||
let event = EventBuilder::new(Kind::from(5), "delete")
|
||||
.tags(tags)
|
||||
.sign_with_keys(keys)?;
|
||||
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
/// Helper function to create a patch event that references repositories
|
||||
fn create_patch_event(
|
||||
keys: &Keys,
|
||||
content: &str,
|
||||
repo_addresses: &[String],
|
||||
) -> Result<Event, Box<dyn std::error::Error>> {
|
||||
let mut tags = vec![];
|
||||
|
||||
for addr in repo_addresses {
|
||||
tags.push(Tag::custom(TagKind::custom("a"), vec![addr.clone()]));
|
||||
}
|
||||
|
||||
let event = EventBuilder::new(Kind::from(1617), content)
|
||||
.tags(tags)
|
||||
.sign_with_keys(keys)?;
|
||||
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
/// Helper function to create an issue event that references repositories
|
||||
fn create_issue_event(
|
||||
keys: &Keys,
|
||||
content: &str,
|
||||
repo_addresses: &[String],
|
||||
) -> Result<Event, Box<dyn std::error::Error>> {
|
||||
let mut tags = vec![];
|
||||
|
||||
for addr in repo_addresses {
|
||||
tags.push(Tag::custom(TagKind::custom("a"), vec![addr.clone()]));
|
||||
}
|
||||
|
||||
let event = EventBuilder::new(Kind::from(1621), content)
|
||||
.tags(tags)
|
||||
.sign_with_keys(keys)?;
|
||||
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
/// Helper function to send event and verify it's accepted
|
||||
async fn send_event_and_get_response(
|
||||
client: &Client,
|
||||
event: &Event,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let output = client.send_event(event).await?;
|
||||
|
||||
if output.success.is_empty() {
|
||||
return Err(format!("Event rejected by all relays: {:?}", output.failed).into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper function to build announcement address
|
||||
fn build_announcement_address(event: &Event) -> String {
|
||||
let identifier = event
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| {
|
||||
let tag_vec = tag.as_slice();
|
||||
if tag_vec.len() >= 2 && tag_vec[0] == "d" {
|
||||
Some(tag_vec[1].to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
format!("30617:{}:{}", event.pubkey.to_hex(), identifier)
|
||||
}
|
||||
|
||||
/// Test: Two maintainers, one deletes - events should be preserved
|
||||
///
|
||||
/// Scenario:
|
||||
/// - Two maintainers create announcements for the same repository
|
||||
/// - Events (patches, issues) reference both announcements
|
||||
/// - One maintainer deletes their announcement
|
||||
/// - Server should process deletion correctly (events kept via graph algorithm)
|
||||
#[tokio::test]
|
||||
async fn test_two_maintainers_one_deletes_events_preserved() {
|
||||
let relay = TestRelay::start().await;
|
||||
|
||||
let maintainer1 = Keys::generate();
|
||||
let maintainer2 = Keys::generate();
|
||||
|
||||
let client = Client::default();
|
||||
client
|
||||
.add_relay(relay.url())
|
||||
.await
|
||||
.expect("Failed to add relay");
|
||||
client.connect().await;
|
||||
|
||||
// Create announcements from both maintainers for same repository
|
||||
let identifier = "shared-repo";
|
||||
let announcement1 = create_repo_announcement(&maintainer1, &[&relay.domain()], identifier);
|
||||
let announcement2 = create_repo_announcement(&maintainer2, &[&relay.domain()], identifier);
|
||||
let addr1 = build_announcement_address(&announcement1);
|
||||
let addr2 = build_announcement_address(&announcement2);
|
||||
|
||||
send_event_and_get_response(&client, &announcement1)
|
||||
.await
|
||||
.expect("Failed to send announcement1");
|
||||
send_event_and_get_response(&client, &announcement2)
|
||||
.await
|
||||
.expect("Failed to send announcement2");
|
||||
|
||||
// Create patch referencing both announcements
|
||||
let patch = create_patch_event(&maintainer1, "Test patch", &[addr1.clone(), addr2.clone()])
|
||||
.expect("Failed to create patch");
|
||||
send_event_and_get_response(&client, &patch)
|
||||
.await
|
||||
.expect("Failed to send patch");
|
||||
|
||||
// Create issue referencing both announcements
|
||||
let issue = create_issue_event(&maintainer1, "Test issue", &[addr1.clone(), addr2.clone()])
|
||||
.expect("Failed to create issue");
|
||||
send_event_and_get_response(&client, &issue)
|
||||
.await
|
||||
.expect("Failed to send issue");
|
||||
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// Maintainer1 deletes their announcement
|
||||
let deletion1 = create_deletion_event(&maintainer1, &[], &[addr1.clone()])
|
||||
.expect("Failed to create deletion");
|
||||
send_event_and_get_response(&client, &deletion1)
|
||||
.await
|
||||
.expect("Failed to send deletion");
|
||||
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// Test verifies deletion is accepted and processed
|
||||
// Server-side graph algorithm should keep events (valid through announcement2)
|
||||
|
||||
relay.stop().await;
|
||||
}
|
||||
|
||||
/// Test: Two maintainers, both delete - events should be deleted
|
||||
///
|
||||
/// Scenario:
|
||||
/// - Two maintainers create announcements
|
||||
/// - Events reference both
|
||||
/// - Both delete their announcements
|
||||
/// - Server should mark all events for deletion
|
||||
#[tokio::test]
|
||||
async fn test_two_maintainers_both_delete_events_deleted() {
|
||||
let relay = TestRelay::start_with_retention_and_cleanup(5, 2).await;
|
||||
|
||||
let maintainer1 = Keys::generate();
|
||||
let maintainer2 = Keys::generate();
|
||||
|
||||
let client = Client::default();
|
||||
client
|
||||
.add_relay(relay.url())
|
||||
.await
|
||||
.expect("Failed to add relay");
|
||||
client.connect().await;
|
||||
|
||||
let identifier = "doomed-repo";
|
||||
let announcement1 = create_repo_announcement(&maintainer1, &[&relay.domain()], identifier);
|
||||
let announcement2 = create_repo_announcement(&maintainer2, &[&relay.domain()], identifier);
|
||||
let addr1 = build_announcement_address(&announcement1);
|
||||
let addr2 = build_announcement_address(&announcement2);
|
||||
|
||||
send_event_and_get_response(&client, &announcement1)
|
||||
.await
|
||||
.expect("Failed to send announcement1");
|
||||
send_event_and_get_response(&client, &announcement2)
|
||||
.await
|
||||
.expect("Failed to send announcement2");
|
||||
|
||||
let patch = create_patch_event(&maintainer1, "Test patch", &[addr1.clone(), addr2.clone()])
|
||||
.expect("Failed to create patch");
|
||||
send_event_and_get_response(&client, &patch)
|
||||
.await
|
||||
.expect("Failed to send patch");
|
||||
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// Both maintainers delete
|
||||
let deletion1 = create_deletion_event(&maintainer1, &[], &[addr1.clone()])
|
||||
.expect("Failed to create deletion1");
|
||||
let deletion2 = create_deletion_event(&maintainer2, &[], &[addr2.clone()])
|
||||
.expect("Failed to create deletion2");
|
||||
|
||||
send_event_and_get_response(&client, &deletion1)
|
||||
.await
|
||||
.expect("Failed to send deletion1");
|
||||
send_event_and_get_response(&client, &deletion2)
|
||||
.await
|
||||
.expect("Failed to send deletion2");
|
||||
|
||||
sleep(Duration::from_secs(10)).await;
|
||||
|
||||
// Test verifies both deletions accepted
|
||||
// Server should delete all events (no valid announcements remain)
|
||||
|
||||
relay.stop().await;
|
||||
}
|
||||
|
||||
/// Test: Event referencing only deleted maintainer
|
||||
///
|
||||
/// Scenario:
|
||||
/// - Two maintainers, event references only one
|
||||
/// - That maintainer deletes
|
||||
/// - Event should be deleted (no alternative)
|
||||
#[tokio::test]
|
||||
async fn test_event_references_only_deleted_maintainer() {
|
||||
let relay = TestRelay::start_with_retention_and_cleanup(5, 2).await;
|
||||
|
||||
let maintainer1 = Keys::generate();
|
||||
let maintainer2 = Keys::generate();
|
||||
|
||||
let client = Client::default();
|
||||
client
|
||||
.add_relay(relay.url())
|
||||
.await
|
||||
.expect("Failed to add relay");
|
||||
client.connect().await;
|
||||
|
||||
let identifier = "partial-repo";
|
||||
let announcement1 = create_repo_announcement(&maintainer1, &[&relay.domain()], identifier);
|
||||
let announcement2 = create_repo_announcement(&maintainer2, &[&relay.domain()], identifier);
|
||||
let addr1 = build_announcement_address(&announcement1);
|
||||
|
||||
send_event_and_get_response(&client, &announcement1)
|
||||
.await
|
||||
.expect("Failed to send announcement1");
|
||||
send_event_and_get_response(&client, &announcement2)
|
||||
.await
|
||||
.expect("Failed to send announcement2");
|
||||
|
||||
// Patch references ONLY maintainer1
|
||||
let patch = create_patch_event(&maintainer1, "Exclusive patch", &[addr1.clone()])
|
||||
.expect("Failed to create patch");
|
||||
send_event_and_get_response(&client, &patch)
|
||||
.await
|
||||
.expect("Failed to send patch");
|
||||
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// Maintainer1 deletes
|
||||
let deletion1 = create_deletion_event(&maintainer1, &[], &[addr1.clone()])
|
||||
.expect("Failed to create deletion");
|
||||
send_event_and_get_response(&client, &deletion1)
|
||||
.await
|
||||
.expect("Failed to send deletion");
|
||||
|
||||
sleep(Duration::from_secs(10)).await;
|
||||
|
||||
// Patch should be deleted (only referenced deleted maintainer)
|
||||
|
||||
relay.stop().await;
|
||||
}
|
||||
|
||||
/// Test: Three maintainers, middle one deletes
|
||||
///
|
||||
/// Scenario:
|
||||
/// - Three maintainers
|
||||
/// - Event references all three
|
||||
/// - Middle maintainer deletes
|
||||
/// - Event should be kept (valid through other two)
|
||||
#[tokio::test]
|
||||
async fn test_three_maintainers_middle_deletes() {
|
||||
let relay = TestRelay::start().await;
|
||||
|
||||
let maintainer1 = Keys::generate();
|
||||
let maintainer2 = Keys::generate();
|
||||
let maintainer3 = Keys::generate();
|
||||
|
||||
let client = Client::default();
|
||||
client
|
||||
.add_relay(relay.url())
|
||||
.await
|
||||
.expect("Failed to add relay");
|
||||
client.connect().await;
|
||||
|
||||
let identifier = "tri-repo";
|
||||
let announcement1 = create_repo_announcement(&maintainer1, &[&relay.domain()], identifier);
|
||||
let announcement2 = create_repo_announcement(&maintainer2, &[&relay.domain()], identifier);
|
||||
let announcement3 = create_repo_announcement(&maintainer3, &[&relay.domain()], identifier);
|
||||
let addr1 = build_announcement_address(&announcement1);
|
||||
let addr2 = build_announcement_address(&announcement2);
|
||||
let addr3 = build_announcement_address(&announcement3);
|
||||
|
||||
send_event_and_get_response(&client, &announcement1)
|
||||
.await
|
||||
.expect("Failed to send announcement1");
|
||||
send_event_and_get_response(&client, &announcement2)
|
||||
.await
|
||||
.expect("Failed to send announcement2");
|
||||
send_event_and_get_response(&client, &announcement3)
|
||||
.await
|
||||
.expect("Failed to send announcement3");
|
||||
|
||||
let patch = create_patch_event(
|
||||
&maintainer1,
|
||||
"Multi-maintainer patch",
|
||||
&[addr1.clone(), addr2.clone(), addr3.clone()],
|
||||
)
|
||||
.expect("Failed to create patch");
|
||||
send_event_and_get_response(&client, &patch)
|
||||
.await
|
||||
.expect("Failed to send patch");
|
||||
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// Maintainer2 (middle) deletes
|
||||
let deletion2 = create_deletion_event(&maintainer2, &[], &[addr2.clone()])
|
||||
.expect("Failed to create deletion2");
|
||||
send_event_and_get_response(&client, &deletion2)
|
||||
.await
|
||||
.expect("Failed to send deletion2");
|
||||
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// Patch should be kept (valid through maintainer1 and maintainer3)
|
||||
|
||||
relay.stop().await;
|
||||
}
|
||||
|
||||
/// Test: Deep dependency chain (5 levels)
|
||||
///
|
||||
/// Scenario:
|
||||
/// - announcement <- e1 <- e2 <- e3 <- e4 <- e5
|
||||
/// - Delete announcement
|
||||
/// - All events should be deleted (no alternative anchor)
|
||||
#[tokio::test]
|
||||
async fn test_deep_dependency_chain() {
|
||||
let relay = TestRelay::start_with_retention_and_cleanup(5, 2).await;
|
||||
|
||||
let maintainer = Keys::generate();
|
||||
|
||||
let client = Client::default();
|
||||
client
|
||||
.add_relay(relay.url())
|
||||
.await
|
||||
.expect("Failed to add relay");
|
||||
client.connect().await;
|
||||
|
||||
let announcement = create_repo_announcement(&maintainer, &[&relay.domain()], "deep-repo");
|
||||
let addr = build_announcement_address(&announcement);
|
||||
|
||||
send_event_and_get_response(&client, &announcement)
|
||||
.await
|
||||
.expect("Failed to send announcement");
|
||||
|
||||
// Create chain of events
|
||||
let e1 =
|
||||
create_patch_event(&maintainer, "Event 1", &[addr.clone()]).expect("Failed to create e1");
|
||||
send_event_and_get_response(&client, &e1)
|
||||
.await
|
||||
.expect("Failed to send e1");
|
||||
|
||||
// e2 references e1 and announcement
|
||||
let e2 = EventBuilder::new(Kind::from(1617), "Event 2")
|
||||
.tags(vec![
|
||||
Tag::custom(TagKind::custom("a"), vec![addr.clone()]),
|
||||
Tag::custom(TagKind::custom("e"), vec![e1.id.to_hex()]),
|
||||
])
|
||||
.sign_with_keys(&maintainer)
|
||||
.expect("Failed to create e2");
|
||||
send_event_and_get_response(&client, &e2)
|
||||
.await
|
||||
.expect("Failed to send e2");
|
||||
|
||||
// e3, e4, e5 similarly
|
||||
let e3 = EventBuilder::new(Kind::from(1617), "Event 3")
|
||||
.tags(vec![
|
||||
Tag::custom(TagKind::custom("a"), vec![addr.clone()]),
|
||||
Tag::custom(TagKind::custom("e"), vec![e2.id.to_hex()]),
|
||||
])
|
||||
.sign_with_keys(&maintainer)
|
||||
.expect("Failed to create e3");
|
||||
send_event_and_get_response(&client, &e3)
|
||||
.await
|
||||
.expect("Failed to send e3");
|
||||
|
||||
let e4 = EventBuilder::new(Kind::from(1617), "Event 4")
|
||||
.tags(vec![
|
||||
Tag::custom(TagKind::custom("a"), vec![addr.clone()]),
|
||||
Tag::custom(TagKind::custom("e"), vec![e3.id.to_hex()]),
|
||||
])
|
||||
.sign_with_keys(&maintainer)
|
||||
.expect("Failed to create e4");
|
||||
send_event_and_get_response(&client, &e4)
|
||||
.await
|
||||
.expect("Failed to send e4");
|
||||
|
||||
let e5 = EventBuilder::new(Kind::from(1617), "Event 5")
|
||||
.tags(vec![
|
||||
Tag::custom(TagKind::custom("a"), vec![addr.clone()]),
|
||||
Tag::custom(TagKind::custom("e"), vec![e4.id.to_hex()]),
|
||||
])
|
||||
.sign_with_keys(&maintainer)
|
||||
.expect("Failed to create e5");
|
||||
send_event_and_get_response(&client, &e5)
|
||||
.await
|
||||
.expect("Failed to send e5");
|
||||
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// Delete announcement
|
||||
let deletion = create_deletion_event(&maintainer, &[], &[addr.clone()])
|
||||
.expect("Failed to create deletion");
|
||||
send_event_and_get_response(&client, &deletion)
|
||||
.await
|
||||
.expect("Failed to send deletion");
|
||||
|
||||
sleep(Duration::from_secs(10)).await;
|
||||
|
||||
// All events should be deleted (no alternative anchor)
|
||||
|
||||
relay.stop().await;
|
||||
}
|
||||
|
||||
/// Test: Multiple events with different maintainer subsets
|
||||
///
|
||||
/// Scenario:
|
||||
/// - 3 maintainers (A, B, C)
|
||||
/// - event1 references A+B
|
||||
/// - event2 references B+C
|
||||
/// - event3 references A+C
|
||||
/// - Delete B
|
||||
/// - All events should be kept (each has alternative)
|
||||
#[tokio::test]
|
||||
async fn test_multiple_events_different_maintainer_subsets() {
|
||||
let relay = TestRelay::start().await;
|
||||
|
||||
let maintainer_a = Keys::generate();
|
||||
let maintainer_b = Keys::generate();
|
||||
let maintainer_c = Keys::generate();
|
||||
|
||||
let client = Client::default();
|
||||
client
|
||||
.add_relay(relay.url())
|
||||
.await
|
||||
.expect("Failed to add relay");
|
||||
client.connect().await;
|
||||
|
||||
let identifier = "subset-repo";
|
||||
let announcement_a = create_repo_announcement(&maintainer_a, &[&relay.domain()], identifier);
|
||||
let announcement_b = create_repo_announcement(&maintainer_b, &[&relay.domain()], identifier);
|
||||
let announcement_c = create_repo_announcement(&maintainer_c, &[&relay.domain()], identifier);
|
||||
let addr_a = build_announcement_address(&announcement_a);
|
||||
let addr_b = build_announcement_address(&announcement_b);
|
||||
let addr_c = build_announcement_address(&announcement_c);
|
||||
|
||||
send_event_and_get_response(&client, &announcement_a)
|
||||
.await
|
||||
.expect("Failed to send announcement_a");
|
||||
send_event_and_get_response(&client, &announcement_b)
|
||||
.await
|
||||
.expect("Failed to send announcement_b");
|
||||
send_event_and_get_response(&client, &announcement_c)
|
||||
.await
|
||||
.expect("Failed to send announcement_c");
|
||||
|
||||
// event1 references A+B
|
||||
let event1 = create_patch_event(
|
||||
&maintainer_a,
|
||||
"Event A+B",
|
||||
&[addr_a.clone(), addr_b.clone()],
|
||||
)
|
||||
.expect("Failed to create event1");
|
||||
send_event_and_get_response(&client, &event1)
|
||||
.await
|
||||
.expect("Failed to send event1");
|
||||
|
||||
// event2 references B+C
|
||||
let event2 = create_patch_event(
|
||||
&maintainer_b,
|
||||
"Event B+C",
|
||||
&[addr_b.clone(), addr_c.clone()],
|
||||
)
|
||||
.expect("Failed to create event2");
|
||||
send_event_and_get_response(&client, &event2)
|
||||
.await
|
||||
.expect("Failed to send event2");
|
||||
|
||||
// event3 references A+C
|
||||
let event3 = create_patch_event(
|
||||
&maintainer_c,
|
||||
"Event A+C",
|
||||
&[addr_a.clone(), addr_c.clone()],
|
||||
)
|
||||
.expect("Failed to create event3");
|
||||
send_event_and_get_response(&client, &event3)
|
||||
.await
|
||||
.expect("Failed to send event3");
|
||||
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// Delete B
|
||||
let deletion_b = create_deletion_event(&maintainer_b, &[], &[addr_b.clone()])
|
||||
.expect("Failed to create deletion_b");
|
||||
send_event_and_get_response(&client, &deletion_b)
|
||||
.await
|
||||
.expect("Failed to send deletion_b");
|
||||
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// All events should be kept (each has alternative maintainer)
|
||||
|
||||
relay.stop().await;
|
||||
}
|
||||
Reference in New Issue
Block a user