diff --git a/CHANGELOG.md b/CHANGELOG.md index 2051d8a..b891537 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Removed superseded same-author repository states from purgatory after their + replacement is promoted when the pushed Git data cannot reconstruct them. + Reconstructable rollback states and other maintainers' states are retained. + ## [2.0.0] - 2026-07-27 ### Breaking changes diff --git a/docs/explanation/purgatory-design.md b/docs/explanation/purgatory-design.md index ca64a98..a118463 100644 --- a/docs/explanation/purgatory-design.md +++ b/docs/explanation/purgatory-design.md @@ -440,6 +440,12 @@ sequenceDiagram end ``` +State promotion remains oldest-first so reconstructable predecessors reach +relay history before a newer replacement. After a promotion pass, purgatory +removes only older events on the promoted event's exact coordinate (same author +and `d` tag) when their Git objects are still unavailable. Reconstructable +predecessors and other maintainers' states remain as rollback candidates. + ### PR Event Arrival (Kind 1617/1618) ```mermaid diff --git a/src/git/sync.rs b/src/git/sync.rs index 12ffbc1..8ccac63 100644 --- a/src/git/sync.rs +++ b/src/git/sync.rs @@ -1060,6 +1060,7 @@ async fn process_purgatory_state_events( promotion_hooks: Option<&dyn PurgatoryPromotionHooks>, ) -> ProcessResult { let mut result = ProcessResult::default(); + let mut promoted_states = Vec::new(); // Find state events in purgatory for this identifier let mut purgatory_states = purgatory.find_state(identifier); @@ -1235,6 +1236,7 @@ async fn process_purgatory_state_events( // Remove from purgatory purgatory.remove_state_event(identifier, &entry.event.id); result.states_released += 1; + promoted_states.push(entry.event.clone()); // Add the newly saved state to db_repo_data so subsequent iterations // can correctly determine if they're the latest @@ -1331,6 +1333,20 @@ async fn process_purgatory_state_events( } } + let removed = purgatory.remove_unreconstructable_state_replacements( + identifier, + &promoted_states, + source_repo_path, + ); + result.states_released += removed; + if removed > 0 { + info!( + identifier = %identifier, + removed, + "Removed unreconstructable superseded state events from purgatory" + ); + } + result } diff --git a/src/purgatory/mod.rs b/src/purgatory/mod.rs index a5ab41e..b93e7f8 100644 --- a/src/purgatory/mod.rs +++ b/src/purgatory/mod.rs @@ -640,6 +640,58 @@ impl Purgatory { } } + /// Remove older states on coordinates promoted in this pass when their Git + /// objects are still unavailable. Reconstructable predecessors and states + /// from other authors remain available as rollback candidates. + pub(crate) fn remove_unreconstructable_state_replacements( + &self, + identifier: &str, + promoted: &[Event], + source_repo_path: &Path, + ) -> usize { + let is_preferred = |candidate: &Event, current: &Event| { + candidate.created_at > current.created_at + || (candidate.created_at == current.created_at && candidate.id < current.id) + }; + let mut winners = HashMap::::new(); + for event in promoted { + winners + .entry(event.pubkey) + .and_modify(|current| { + if is_preferred(event, current) { + *current = event; + } + }) + .or_insert(event); + } + + let removable: HashSet = self + .find_state(identifier) + .into_iter() + .filter(|entry| { + winners.get(&entry.event.pubkey).is_some_and(|winner| { + is_preferred(winner, &entry.event) + && !can_apply_state(&entry.event, source_repo_path) + }) + }) + .map(|entry| entry.event.id) + .collect(); + if removable.is_empty() { + return 0; + } + + let Some(mut entries) = self.state_events.get_mut(identifier) else { + return 0; + }; + let before = entries.len(); + entries.retain(|entry| !removable.contains(&entry.event.id)); + let removed = before - entries.len(); + drop(entries); + self.state_events + .remove_if(identifier, |_, entries| entries.is_empty()); + removed + } + /// Find state events that could be satisfied by ref updates. /// /// Returns state events waiting for this identifier where applying the @@ -2799,6 +2851,58 @@ async fn test_multiple_state_events_same_identifier() { assert!(authors.contains(&keys3.public_key())); } +#[test] +fn cleanup_keeps_reconstructable_and_independent_state_fallbacks() { + let purgatory = Purgatory::new(PathBuf::new()); + let author = Keys::generate(); + let other_author = Keys::generate(); + let identifier = "state-cleanup"; + let state = |keys: &Keys, created_at: u64, oid: Option<&str>| { + let mut tags = vec![Tag::identifier(identifier)]; + if let Some(oid) = oid { + tags.push(Tag::custom("refs/heads/main", [oid.to_string()])); + } + EventBuilder::new(Kind::RepoState, "") + .tags(tags) + .custom_created_at(Timestamp::from_secs(created_at)) + .finalize(keys) + .unwrap() + }; + + let missing_oid = "a".repeat(40); + let missing_older = state(&author, 100, Some(&missing_oid)); + let reconstructable_older = state(&author, 101, None); + let promoted = state(&author, 200, None); + let missing_newer = state(&author, 300, Some(&missing_oid)); + let other_coordinate = state(&other_author, 100, Some(&missing_oid)); + + for event in [ + &missing_older, + &reconstructable_older, + &missing_newer, + &other_coordinate, + ] { + purgatory.add_state(event.clone(), identifier.to_string(), event.pubkey, false); + } + + let removed = purgatory.remove_unreconstructable_state_replacements( + identifier, + &[promoted], + Path::new("/not/a/git/repository"), + ); + + assert_eq!(removed, 1); + let remaining: HashSet = purgatory + .find_state(identifier) + .into_iter() + .map(|entry| entry.event.id) + .collect(); + assert!(!remaining.contains(&missing_older.id)); + assert!(remaining.contains(&reconstructable_older.id)); + assert!(remaining.contains(&missing_newer.id)); + assert!(remaining.contains(&other_coordinate.id)); +} + #[tokio::test] async fn test_mixed_pr_events_and_placeholders() { use nostr_sdk::prelude::{Kind, Tag};