This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / bobbin / crates / edge-index / src / state_index.rs
15 kB 455 lines
1use std::collections::BTreeMap; 2use std::sync::Mutex; 3 4use bobbin_runtime::RuntimeHasher; 5use bobbin_types::edges::Record; 6use bobbin_types::sh_tangled::repo::issue::state::StateState; 7use bobbin_types::sh_tangled::repo::pull::status::StatusStatus; 8use jacquard_common::DefaultStr; 9use jacquard_common::types::string::AtUri; 10use scc::HashMap as SccMap; 11use scc::hash_map::Entry; 12 13pub trait StateKind: Copy + Eq + std::fmt::Debug + Send + Sync + 'static { 14 fn wire(self) -> &'static str; 15} 16 17#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] 18pub enum IssueStateKind { 19 #[default] 20 Open, 21 Closed, 22} 23 24impl StateKind for IssueStateKind { 25 fn wire(self) -> &'static str { 26 match self { 27 Self::Open => "open", 28 Self::Closed => "closed", 29 } 30 } 31} 32 33#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] 34pub enum PullStatusKind { 35 #[default] 36 Open, 37 Closed, 38 Merged, 39} 40 41impl StateKind for PullStatusKind { 42 fn wire(self) -> &'static str { 43 match self { 44 Self::Open => "open", 45 Self::Closed => "closed", 46 Self::Merged => "merged", 47 } 48 } 49} 50 51#[derive(Clone, Debug)] 52struct ReverseRef<V: StateKind> { 53 entity: AtUri<DefaultStr>, 54 sort_micros: u64, 55 kind: V, 56} 57 58#[derive(Clone, Debug)] 59struct SortableSource(AtUri<DefaultStr>); 60 61impl PartialEq for SortableSource { 62 fn eq(&self, other: &Self) -> bool { 63 self.0.as_ref() == other.0.as_ref() 64 } 65} 66 67impl Eq for SortableSource {} 68 69impl PartialOrd for SortableSource { 70 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { 71 Some(self.cmp(other)) 72 } 73} 74 75impl Ord for SortableSource { 76 fn cmp(&self, other: &Self) -> std::cmp::Ordering { 77 self.0.as_ref().cmp(other.0.as_ref()) 78 } 79} 80 81type ForwardKey = (u64, SortableSource); 82 83pub struct StateIndex<V: StateKind> { 84 forward: SccMap<AtUri<DefaultStr>, BTreeMap<ForwardKey, V>, RuntimeHasher>, 85 reverse: SccMap<AtUri<DefaultStr>, ReverseRef<V>, RuntimeHasher>, 86 writer: Mutex<()>, 87} 88 89impl<V: StateKind> StateIndex<V> { 90 pub fn new(hasher: RuntimeHasher) -> Self { 91 Self { 92 forward: SccMap::with_hasher(hasher.clone()), 93 reverse: SccMap::with_hasher(hasher), 94 writer: Mutex::new(()), 95 } 96 } 97 98 pub fn upsert( 99 &self, 100 source: AtUri<DefaultStr>, 101 entity: AtUri<DefaultStr>, 102 sort_micros: u64, 103 kind: V, 104 ) { 105 let _w = self 106 .writer 107 .lock() 108 .expect("state-index writer mutex poisoned"); 109 let rev = self.reverse.entry_sync(source.clone()); 110 let prior = match &rev { 111 Entry::Occupied(occ) => Some((occ.get().entity.clone(), occ.get().sort_micros)), 112 Entry::Vacant(_) => None, 113 }; 114 if let Some((prior_entity, prior_micros)) = prior.as_ref() 115 && (prior_entity != &entity || *prior_micros != sort_micros) 116 { 117 self.remove_forward(prior_entity, *prior_micros, &source); 118 } 119 self.insert_forward(entity.clone(), sort_micros, kind, source); 120 match rev { 121 Entry::Occupied(mut occ) => { 122 let slot = occ.get_mut(); 123 slot.entity = entity; 124 slot.sort_micros = sort_micros; 125 slot.kind = kind; 126 } 127 Entry::Vacant(vac) => { 128 vac.insert_entry(ReverseRef { 129 entity, 130 sort_micros, 131 kind, 132 }); 133 } 134 } 135 } 136 137 pub fn remove_source(&self, source: &AtUri<DefaultStr>) { 138 let _w = self 139 .writer 140 .lock() 141 .expect("state-index writer mutex poisoned"); 142 let Entry::Occupied(occ) = self.reverse.entry_sync(source.clone()) else { 143 return; 144 }; 145 let entity = occ.get().entity.clone(); 146 let sort_micros = occ.get().sort_micros; 147 let _ = occ.remove(); 148 self.remove_forward(&entity, sort_micros, source); 149 } 150 151 pub fn remove_entity(&self, entity: &AtUri<DefaultStr>) { 152 let _w = self 153 .writer 154 .lock() 155 .expect("state-index writer mutex poisoned"); 156 let Some((_, set)) = self.forward.remove_sync(entity) else { 157 return; 158 }; 159 set.into_iter().for_each(|((_, src), _)| { 160 let Entry::Occupied(occ) = self.reverse.entry_sync(src.0.clone()) else { 161 return; 162 }; 163 if occ.get().entity == *entity { 164 let _ = occ.remove(); 165 } 166 }); 167 } 168 169 fn insert_forward( 170 &self, 171 entity: AtUri<DefaultStr>, 172 sort_micros: u64, 173 kind: V, 174 source: AtUri<DefaultStr>, 175 ) { 176 let mut entry = self.forward.entry_sync(entity).or_default(); 177 entry 178 .get_mut() 179 .insert((sort_micros, SortableSource(source)), kind); 180 } 181 182 fn remove_forward( 183 &self, 184 entity: &AtUri<DefaultStr>, 185 sort_micros: u64, 186 source: &AtUri<DefaultStr>, 187 ) { 188 let Entry::Occupied(mut entry) = self.forward.entry_sync(entity.clone()) else { 189 return; 190 }; 191 entry 192 .get_mut() 193 .remove(&(sort_micros, SortableSource(source.clone()))); 194 if entry.get().is_empty() { 195 let _ = entry.remove(); 196 } 197 } 198 199 pub fn latest(&self, entity: &AtUri<DefaultStr>) -> Option<(V, u64)> { 200 self.latest_by(entity, |_| true) 201 } 202 203 pub fn latest_by<F>(&self, entity: &AtUri<DefaultStr>, accept: F) -> Option<(V, u64)> 204 where 205 F: Fn(&AtUri<DefaultStr>) -> bool, 206 { 207 self.forward 208 .read_sync(entity, |_, set| { 209 set.iter() 210 .rev() 211 .find_map(|((m, src), k)| accept(&src.0).then_some((*k, *m))) 212 }) 213 .flatten() 214 } 215 216 pub fn entity_count(&self) -> usize { 217 self.forward.len() 218 } 219 220 pub fn source_count(&self) -> usize { 221 self.reverse.len() 222 } 223} 224 225#[derive(Clone, Copy, Debug, Eq, PartialEq)] 226pub enum ApplyOutcome { 227 Applied, 228 UnknownVariant, 229 NotStateRecord, 230 Removed, 231} 232 233fn issue_kind_from(s: &StateState<DefaultStr>) -> Option<IssueStateKind> { 234 match s { 235 StateState::ShTangledRepoIssueStateOpen => Some(IssueStateKind::Open), 236 StateState::ShTangledRepoIssueStateClosed => Some(IssueStateKind::Closed), 237 StateState::Other(_) => None, 238 } 239} 240 241fn pull_kind_from(s: &StatusStatus<DefaultStr>) -> Option<PullStatusKind> { 242 match s { 243 StatusStatus::ShTangledRepoPullStatusOpen => Some(PullStatusKind::Open), 244 StatusStatus::ShTangledRepoPullStatusClosed => Some(PullStatusKind::Closed), 245 StatusStatus::ShTangledRepoPullStatusMerged => Some(PullStatusKind::Merged), 246 StatusStatus::Other(_) => None, 247 } 248} 249 250pub fn apply_record_state( 251 issue_idx: &StateIndex<IssueStateKind>, 252 pull_idx: &StateIndex<PullStatusKind>, 253 source: &AtUri<DefaultStr>, 254 record: &Record, 255) -> ApplyOutcome { 256 let sort_micros = record.sort_micros_for(source); 257 match record { 258 Record::IssueState(r) => match issue_kind_from(&r.state) { 259 Some(kind) => { 260 issue_idx.upsert(source.clone(), r.issue.clone(), sort_micros, kind); 261 ApplyOutcome::Applied 262 } 263 None => ApplyOutcome::UnknownVariant, 264 }, 265 Record::PullStatus(r) => match pull_kind_from(&r.status) { 266 Some(kind) => { 267 pull_idx.upsert(source.clone(), r.pull.clone(), sort_micros, kind); 268 ApplyOutcome::Applied 269 } 270 None => ApplyOutcome::UnknownVariant, 271 }, 272 _ => ApplyOutcome::NotStateRecord, 273 } 274} 275 276#[cfg(test)] 277mod tests { 278 use super::*; 279 280 fn idx() -> StateIndex<IssueStateKind> { 281 StateIndex::new(RuntimeHasher::default()) 282 } 283 284 fn at(s: &str) -> AtUri<DefaultStr> { 285 AtUri::new_owned(s).unwrap() 286 } 287 288 #[test] 289 fn latest_returns_largest_sort_micros() { 290 let s = idx(); 291 let issue = at("at://did:plc:limpet/sh.tangled.repo.issue/i1"); 292 s.upsert( 293 at("at://did:plc:nel/sh.tangled.repo.issue.state/s1"), 294 issue.clone(), 295 100, 296 IssueStateKind::Open, 297 ); 298 s.upsert( 299 at("at://did:plc:nel/sh.tangled.repo.issue.state/s2"), 300 issue.clone(), 301 200, 302 IssueStateKind::Closed, 303 ); 304 assert_eq!(s.latest(&issue), Some((IssueStateKind::Closed, 200))); 305 } 306 307 #[test] 308 fn remove_source_drops_entry_and_recovers_prior() { 309 let s = idx(); 310 let issue = at("at://did:plc:limpet/sh.tangled.repo.issue/i1"); 311 let s1 = at("at://did:plc:nel/sh.tangled.repo.issue.state/s1"); 312 let s2 = at("at://did:plc:nel/sh.tangled.repo.issue.state/s2"); 313 s.upsert(s1.clone(), issue.clone(), 100, IssueStateKind::Open); 314 s.upsert(s2.clone(), issue.clone(), 200, IssueStateKind::Closed); 315 s.remove_source(&s2); 316 assert_eq!(s.latest(&issue), Some((IssueStateKind::Open, 100))); 317 s.remove_source(&s1); 318 assert!(s.latest(&issue).is_none()); 319 assert_eq!(s.entity_count(), 0); 320 assert_eq!(s.source_count(), 0); 321 } 322 323 #[test] 324 fn upsert_same_source_replaces() { 325 let s = idx(); 326 let issue = at("at://did:plc:limpet/sh.tangled.repo.issue/i1"); 327 let src = at("at://did:plc:nel/sh.tangled.repo.issue.state/s1"); 328 s.upsert(src.clone(), issue.clone(), 100, IssueStateKind::Open); 329 s.upsert(src.clone(), issue.clone(), 100, IssueStateKind::Closed); 330 assert_eq!(s.latest(&issue), Some((IssueStateKind::Closed, 100))); 331 assert_eq!(s.source_count(), 1); 332 } 333 334 #[test] 335 fn distinct_sources_with_identical_micros_and_kind_survive_individual_removal() { 336 let s = idx(); 337 let issue = at("at://did:plc:limpet/sh.tangled.repo.issue/i1"); 338 let s1 = at("at://did:plc:nel/sh.tangled.repo.issue.state/s1"); 339 let s2 = at("at://did:plc:olaren/sh.tangled.repo.issue.state/s2"); 340 s.upsert(s1.clone(), issue.clone(), 100, IssueStateKind::Open); 341 s.upsert(s2.clone(), issue.clone(), 100, IssueStateKind::Open); 342 s.remove_source(&s1); 343 assert_eq!( 344 s.latest(&issue), 345 Some((IssueStateKind::Open, 100)), 346 "removing one source must not wipe the other's matching state" 347 ); 348 s.remove_source(&s2); 349 assert!(s.latest(&issue).is_none()); 350 } 351 352 #[test] 353 fn unknown_variant_is_reported() { 354 use bobbin_types::sh_tangled::repo::issue::state::State as IssueStateRec; 355 use jacquard_common::deps::smol_str::SmolStr; 356 use jacquard_common::types::string::Datetime; 357 let issue_idx = idx(); 358 let pull_idx = StateIndex::<PullStatusKind>::new(RuntimeHasher::default()); 359 let rec = Record::IssueState(IssueStateRec { 360 created_at: Datetime::raw_str("2026-06-11T00:00:00Z"), 361 issue: at("at://did:plc:limpet/sh.tangled.repo.issue/i1"), 362 state: StateState::Other(SmolStr::new_static("sh.tangled.repo.issue.state.reopened")), 363 extra_data: None, 364 }); 365 let outcome = apply_record_state( 366 &issue_idx, 367 &pull_idx, 368 &at("at://did:plc:nel/sh.tangled.repo.issue.state/s1"), 369 &rec, 370 ); 371 assert_eq!(outcome, ApplyOutcome::UnknownVariant); 372 assert_eq!(issue_idx.entity_count(), 0); 373 } 374 375 #[test] 376 fn latest_by_filters_unauthorized_sources() { 377 let s = idx(); 378 let issue = at("at://did:plc:limpet/sh.tangled.repo.issue/i1"); 379 let owner_state = at("at://did:plc:limpet/sh.tangled.repo.issue.state/legit"); 380 let attacker_state = at("at://did:plc:nautilus/sh.tangled.repo.issue.state/spoof"); 381 s.upsert( 382 owner_state.clone(), 383 issue.clone(), 384 100, 385 IssueStateKind::Open, 386 ); 387 s.upsert( 388 attacker_state.clone(), 389 issue.clone(), 390 500, 391 IssueStateKind::Closed, 392 ); 393 let only_owner = |src: &AtUri<DefaultStr>| src.as_ref().starts_with("at://did:plc:limpet/"); 394 assert_eq!( 395 s.latest_by(&issue, only_owner), 396 Some((IssueStateKind::Open, 100)), 397 "spoofed attacker state must be ignored", 398 ); 399 assert_eq!( 400 s.latest(&issue), 401 Some((IssueStateKind::Closed, 500)), 402 "unfiltered latest still surfaces the spoof for sanity", 403 ); 404 } 405 406 #[test] 407 fn remove_entity_drops_forward_and_reverse() { 408 let s = idx(); 409 let issue = at("at://did:plc:limpet/sh.tangled.repo.issue/i1"); 410 let s1 = at("at://did:plc:nel/sh.tangled.repo.issue.state/s1"); 411 let s2 = at("at://did:plc:olaren/sh.tangled.repo.issue.state/s2"); 412 s.upsert(s1.clone(), issue.clone(), 100, IssueStateKind::Open); 413 s.upsert(s2.clone(), issue.clone(), 200, IssueStateKind::Closed); 414 s.remove_entity(&issue); 415 assert!(s.latest(&issue).is_none()); 416 assert_eq!(s.entity_count(), 0); 417 assert_eq!( 418 s.source_count(), 419 0, 420 "reverse entries pointing at the dead entity must clear too", 421 ); 422 } 423 424 #[test] 425 fn remove_entity_does_not_touch_reverse_pointing_elsewhere() { 426 let s = idx(); 427 let dead = at("at://did:plc:limpet/sh.tangled.repo.issue/dead"); 428 let alive = at("at://did:plc:limpet/sh.tangled.repo.issue/alive"); 429 let src = at("at://did:plc:nel/sh.tangled.repo.issue.state/s1"); 430 s.upsert(src.clone(), dead.clone(), 100, IssueStateKind::Open); 431 s.upsert(src.clone(), alive.clone(), 200, IssueStateKind::Closed); 432 s.remove_entity(&dead); 433 assert_eq!( 434 s.latest(&alive), 435 Some((IssueStateKind::Closed, 200)), 436 "removing the dead entity must not touch state for the live one", 437 ); 438 assert_eq!(s.source_count(), 1); 439 } 440 441 #[test] 442 fn same_micros_distinct_kind_picks_by_source_not_kind() { 443 let s = StateIndex::<PullStatusKind>::new(RuntimeHasher::default()); 444 let pull = at("at://did:plc:limpet/sh.tangled.repo.pull/p1"); 445 let early = at("at://did:plc:nel/sh.tangled.repo.pull.status/aaa"); 446 let later = at("at://did:plc:nel/sh.tangled.repo.pull.status/zzz"); 447 s.upsert(early.clone(), pull.clone(), 1000, PullStatusKind::Merged); 448 s.upsert(later.clone(), pull.clone(), 1000, PullStatusKind::Open); 449 assert_eq!( 450 s.latest(&pull), 451 Some((PullStatusKind::Open, 1000)), 452 "tied micros must use source ordering as the tiebreak", 453 ); 454 } 455}