1use std::collections::{HashSet, VecDeque};
3#[cfg(feature = "async-source")]
4use std::sync::{
5 Arc,
6 atomic::{AtomicBool, Ordering},
7};
8#[cfg(feature = "async-source")]
9use std::time::Instant;
10
11use serde::{Deserialize, Serialize};
12
13#[cfg(feature = "async-source")]
14use crate::store::AsyncObjectSource;
15use crate::{
16 error::{HeddleError, Result},
17 object::{
18 AnnotatedTag, BindingDelta, ContentHash, RedactionsBlob, ReverseDependencyIndex,
19 SemanticEntryKind, SemanticIndexRoot, SemanticTreeNode, State, StateAttachment,
20 StateAttachmentBody, StateAttachmentId, StateAttachmentKind, StateId, TreeEntryTarget,
21 decode_tree_delta_header, is_delta_tree,
22 },
23 store::{ObjectSource, ObjectStore, pack::ObjectType as PackObjectType},
24};
25
26#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
27pub enum ObjectId {
28 Hash(ContentHash),
29 StateId(StateId),
30 StateAttachment {
31 state: StateId,
32 id: StateAttachmentId,
33 kind: StateAttachmentKind,
39 },
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ObjectInfo {
44 pub id: ObjectId,
45 pub obj_type: ObjectType,
46 pub size: u64,
47 pub delta_base: Option<ContentHash>,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
51pub struct PlannedObject {
52 pub id: ObjectId,
53 pub obj_type: ObjectType,
54}
55
56#[derive(Debug, Clone)]
57pub struct StateClosureTransferObjects {
58 pub planned_objects: Vec<PlannedObject>,
59 pub full_objects: Option<Vec<ObjectInfo>>,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
63pub enum ObjectType {
64 Blob,
65 Tree,
66 State,
67 Action,
68 AnnotatedTag,
69 Redaction,
75 Purge,
79 StateVisibility,
86 StateAttachment,
87 KeyBinding,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
95pub enum ObjectTypeBucket {
96 Blob,
97 Tree,
98 State,
99 Action,
100 AnnotatedTag,
101 Redaction,
102 Purge,
103 StateVisibility,
104 StateAttachment,
105 KeyBinding,
106}
107
108impl ObjectType {
109 pub fn wire_name(self) -> &'static str {
110 match self {
111 ObjectType::Blob => "blob",
112 ObjectType::Tree => "tree",
113 ObjectType::State => "state",
114 ObjectType::Action => "action",
115 ObjectType::AnnotatedTag => "annotated_tag",
116 ObjectType::Redaction => "redaction",
117 ObjectType::Purge => "purge",
118 ObjectType::StateVisibility => "state_visibility",
119 ObjectType::StateAttachment => "state_attachment",
120 ObjectType::KeyBinding => "key_binding",
121 }
122 }
123
124 pub fn from_wire(value: &str) -> Result<Self> {
125 match value {
126 "blob" => Ok(ObjectType::Blob),
127 "tree" => Ok(ObjectType::Tree),
128 "state" => Ok(ObjectType::State),
129 "action" => Ok(ObjectType::Action),
130 "annotated_tag" => Ok(ObjectType::AnnotatedTag),
131 "redaction" => Ok(ObjectType::Redaction),
132 "purge" => Ok(ObjectType::Purge),
133 "state_visibility" => Ok(ObjectType::StateVisibility),
134 "state_attachment" => Ok(ObjectType::StateAttachment),
135 "key_binding" => Ok(ObjectType::KeyBinding),
136 _ => Err(HeddleError::InvalidObject(format!(
137 "unknown object type: {value}"
138 ))),
139 }
140 }
141
142 pub fn packable(self) -> bool {
153 !matches!(
154 self,
155 ObjectType::Redaction
156 | ObjectType::Purge
157 | ObjectType::StateVisibility
158 | ObjectType::KeyBinding
159 )
160 }
161
162 pub fn packable_for_push(self) -> bool {
172 self.packable() && !matches!(self, ObjectType::StateAttachment)
173 }
174
175 pub fn packable_for_pull(self) -> bool {
183 self.packable()
184 }
185
186 pub fn pack_object_type(self) -> Result<PackObjectType> {
187 match self {
188 ObjectType::Blob => Ok(PackObjectType::Blob),
189 ObjectType::Tree => Ok(PackObjectType::Tree),
190 ObjectType::State => Ok(PackObjectType::State),
191 ObjectType::Action => Ok(PackObjectType::Action),
192 ObjectType::AnnotatedTag => Ok(PackObjectType::AnnotatedTag),
193 ObjectType::StateAttachment => Ok(PackObjectType::StateAttachment),
194 ObjectType::Redaction => Err(HeddleError::InvalidObject(
195 "Redaction sidecar records cannot be packed into the content-addressed object pack"
196 .to_string(),
197 )),
198 ObjectType::Purge => Err(HeddleError::InvalidObject(
199 "Purge sidecar records cannot be packed into the content-addressed object pack"
200 .to_string(),
201 )),
202 ObjectType::StateVisibility => Err(HeddleError::InvalidObject(
203 "StateVisibility sidecar records cannot be packed into the content-addressed object pack"
204 .to_string(),
205 )),
206 ObjectType::KeyBinding => Err(HeddleError::InvalidObject(
207 "KeyBinding registry objects cannot be packed into the content-addressed object pack"
208 .to_string(),
209 )),
210 }
211 }
212
213 pub fn bucket(self) -> ObjectTypeBucket {
214 match self {
215 ObjectType::Blob => ObjectTypeBucket::Blob,
216 ObjectType::Tree => ObjectTypeBucket::Tree,
217 ObjectType::State => ObjectTypeBucket::State,
218 ObjectType::Action => ObjectTypeBucket::Action,
219 ObjectType::AnnotatedTag => ObjectTypeBucket::AnnotatedTag,
220 ObjectType::Redaction => ObjectTypeBucket::Redaction,
221 ObjectType::Purge => ObjectTypeBucket::Purge,
222 ObjectType::StateVisibility => ObjectTypeBucket::StateVisibility,
223 ObjectType::StateAttachment => ObjectTypeBucket::StateAttachment,
224 ObjectType::KeyBinding => ObjectTypeBucket::KeyBinding,
225 }
226 }
227}
228
229#[derive(Debug, Clone, Default)]
230pub struct StateClosureOptions {
231 pub depth: Option<u32>,
232 pub exclude_states: Vec<StateId>,
233}
234
235pub fn enumerate_state_closure(
236 store: &impl ObjectStore,
237 state_id: StateId,
238) -> Result<Vec<ObjectInfo>> {
239 enumerate_state_closure_with_options(store, state_id, StateClosureOptions::default())
240}
241
242pub fn enumerate_state_closure_with_options(
243 store: &impl ObjectStore,
244 state_id: StateId,
245 options: StateClosureOptions,
246) -> Result<Vec<ObjectInfo>> {
247 let mut out = Vec::new();
248 walk_state_closure(store, state_id, options, |event| {
249 if let Some(info) = object_info_from_event(store, event)? {
250 out.push(info);
251 }
252 Ok(())
253 })?;
254 for (hash, tag) in annotated_tags_for_state(store, state_id)? {
255 out.push(annotated_tag_info(hash, &tag));
256 }
257
258 Ok(out)
259}
260
261pub fn enumerate_state_closure_plan(
262 store: &impl ObjectStore,
263 state_id: StateId,
264) -> Result<Vec<PlannedObject>> {
265 enumerate_state_closure_plan_with_options(store, state_id, StateClosureOptions::default())
266}
267
268pub fn enumerate_state_closure_plan_with_options(
269 store: &impl ObjectStore,
270 state_id: StateId,
271 options: StateClosureOptions,
272) -> Result<Vec<PlannedObject>> {
273 let mut out = Vec::new();
274 walk_state_closure(store, state_id, options, |event| {
275 if let Some(object) = planned_object_from_event(store, event)? {
276 out.push(object);
277 }
278 Ok(())
279 })?;
280 out.extend(
281 annotated_tags_for_state(store, state_id)?
282 .into_iter()
283 .map(|(hash, _)| PlannedObject {
284 id: ObjectId::Hash(hash),
285 obj_type: ObjectType::AnnotatedTag,
286 }),
287 );
288
289 Ok(out)
290}
291
292pub fn enumerate_state_closure_transfer_with_options(
293 store: &impl ObjectStore,
294 state_id: StateId,
295 options: StateClosureOptions,
296 full_descriptor_object_threshold: usize,
297) -> Result<StateClosureTransferObjects> {
298 let mut planned_objects = Vec::new();
299 let mut full_objects = Some(Vec::new());
300
301 walk_state_closure(store, state_id, options, |event| {
302 if let Some(object) = planned_object_from_event(store, event)? {
303 planned_objects.push(object);
304 }
305
306 if full_objects.is_some() && planned_objects.len() > full_descriptor_object_threshold {
307 full_objects = None;
308 }
309 if let Some(objects) = full_objects.as_mut()
310 && let Some(info) = object_info_from_event(store, event)?
311 {
312 objects.push(info);
313 }
314
315 Ok(())
316 })?;
317 let tags = annotated_tags_for_state(store, state_id)?;
318 planned_objects.extend(tags.iter().map(|(hash, _)| PlannedObject {
319 id: ObjectId::Hash(*hash),
320 obj_type: ObjectType::AnnotatedTag,
321 }));
322 if full_objects.is_some() && planned_objects.len() > full_descriptor_object_threshold {
323 full_objects = None;
324 }
325 if let Some(objects) = full_objects.as_mut() {
326 objects.extend(
327 tags.iter()
328 .map(|(hash, tag)| annotated_tag_info(*hash, tag)),
329 );
330 }
331
332 Ok(StateClosureTransferObjects {
333 planned_objects,
334 full_objects,
335 })
336}
337
338fn annotated_tags_for_state(
339 store: &impl ObjectStore,
340 state_id: StateId,
341) -> Result<Vec<(ContentHash, AnnotatedTag)>> {
342 let mut roots = Vec::new();
343 for hash in store.list_annotated_tags()? {
344 let Some(tag) = store.get_annotated_tag(&hash)? else {
345 continue;
346 };
347 if tag
348 .marker()
349 .is_some_and(|marker| marker.peeled_state == state_id)
350 {
351 roots.push((hash, tag));
352 }
353 }
354
355 let mut tags = Vec::new();
356 let mut seen = HashSet::new();
357 let mut stack = roots;
358 while let Some((hash, tag)) = stack.pop() {
359 if !seen.insert(hash) {
360 continue;
361 }
362 if let Some(inner_hash) = tag.target_tag() {
363 let inner = store.get_annotated_tag(&inner_hash)?.ok_or_else(|| {
364 HeddleError::NotFound(format!(
365 "annotated tag {hash} references missing inner tag {inner_hash}"
366 ))
367 })?;
368 stack.push((inner_hash, inner));
369 }
370 tags.push((hash, tag));
371 }
372 Ok(tags)
373}
374
375fn annotated_tag_info(hash: ContentHash, tag: &AnnotatedTag) -> ObjectInfo {
376 ObjectInfo {
377 id: ObjectId::Hash(hash),
378 obj_type: ObjectType::AnnotatedTag,
379 size: tag.encode_current_msgpack().len() as u64,
380 delta_base: None,
381 }
382}
383
384pub fn enumerate_state_closure_transfer_from_boundaries(
391 store: &impl ObjectStore,
392 state_id: StateId,
393 boundary_states: &[StateId],
394 full_descriptor_object_threshold: usize,
395) -> Result<StateClosureTransferObjects> {
396 let mut planned_objects = Vec::new();
397 let mut full_objects = Some(Vec::new());
398 let excluded_states = boundary_states.iter().copied().collect();
399
400 walk_state_closure_with_exclusions(
401 store,
402 state_id,
403 None,
404 excluded_states,
405 HashSet::new(),
406 |event| {
407 if let Some(object) = planned_object_from_event(store, event)? {
408 planned_objects.push(object);
409 }
410
411 if full_objects.is_some() && planned_objects.len() > full_descriptor_object_threshold {
412 full_objects = None;
413 }
414 if let Some(objects) = full_objects.as_mut()
415 && let Some(info) = object_info_from_event(store, event)?
416 {
417 objects.push(info);
418 }
419
420 Ok(())
421 },
422 )?;
423
424 Ok(StateClosureTransferObjects {
425 planned_objects,
426 full_objects,
427 })
428}
429
430#[derive(Debug, Clone, Copy)]
431enum StateClosureEvent<'a> {
432 State {
433 id: StateId,
434 state: &'a State,
435 },
436 Tree {
437 hash: ContentHash,
438 storage_size: u64,
439 delta_base: Option<ContentHash>,
440 },
441 Blob {
442 hash: ContentHash,
443 },
444 Redaction {
445 blob: ContentHash,
446 },
447 StateVisibility {
448 state: StateId,
449 },
450 StateAttachment {
451 state: StateId,
452 attachment: &'a StateAttachment,
453 },
454 ExcludedState {
455 id: StateId,
456 },
457 ExcludedHash {
458 hash: ContentHash,
459 },
460}
461
462fn walk_state_closure(
463 store: &impl ObjectStore,
464 state_id: StateId,
465 options: StateClosureOptions,
466 visit: impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
467) -> Result<()> {
468 let (excluded_states, excluded_hashes) = collect_excluded(store, &options.exclude_states)?;
469
470 walk_state_closure_with_exclusions(
471 store,
472 state_id,
473 options.depth,
474 excluded_states,
475 excluded_hashes,
476 visit,
477 )
478}
479
480fn walk_state_closure_with_exclusions(
481 store: &impl ObjectStore,
482 state_id: StateId,
483 max_depth: Option<u32>,
484 excluded_states: HashSet<StateId>,
485 excluded_hashes: HashSet<ContentHash>,
486 mut visit: impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
487) -> Result<()> {
488 let mut seen_states: HashSet<StateId> = HashSet::new();
489 let mut seen_hashes: HashSet<ContentHash> = HashSet::new();
490 let mut queue: VecDeque<(StateId, u32)> = VecDeque::new();
491 queue.push_back((state_id, 0));
492
493 while let Some((id, depth)) = queue.pop_front() {
494 if excluded_states.contains(&id) {
495 visit(StateClosureEvent::ExcludedState { id })?;
496 continue;
497 }
498 if !seen_states.insert(id) {
499 continue;
500 }
501
502 let state = store
503 .get_state(&id)?
504 .ok_or_else(|| HeddleError::MissingObject {
505 object_type: "state".to_string(),
506 id: id.to_string(),
507 })?;
508
509 visit(StateClosureEvent::State { id, state: &state })?;
510 if store.has_state_visibility_for_state(&id)? {
511 visit(StateClosureEvent::StateVisibility { state: id })?;
512 }
513 for attachment in store.list_state_attachments(&id)? {
514 visit(StateClosureEvent::StateAttachment {
515 state: id,
516 attachment: &attachment,
517 })?;
518 match attachment.body {
519 StateAttachmentBody::Context(root) => walk_tree_closure_filtered(
520 store,
521 root,
522 &excluded_hashes,
523 &mut seen_hashes,
524 &mut visit,
525 )?,
526 StateAttachmentBody::RiskSignals(hash)
527 | StateAttachmentBody::ReviewSignatures(hash)
528 | StateAttachmentBody::Discussions(hash)
529 | StateAttachmentBody::StructuredConflicts(hash) => {
530 walk_blob_filtered(store, hash, &excluded_hashes, &mut seen_hashes, &mut visit)?
531 }
532 StateAttachmentBody::SemanticIndex(root) => walk_semantic_index_closure(
533 store,
534 root,
535 &excluded_hashes,
536 &mut seen_hashes,
537 &mut visit,
538 )?,
539 StateAttachmentBody::Signature(_) => {}
540 }
541 }
542
543 if max_depth.map(|max| depth < max).unwrap_or(true) {
544 for parent in &state.parents {
545 queue.push_back((*parent, depth + 1));
546 }
547 }
548
549 walk_tree_closure_filtered(
550 store,
551 state.tree,
552 &excluded_hashes,
553 &mut seen_hashes,
554 &mut visit,
555 )?;
556 if let Some(provenance_root) = state.provenance {
557 walk_tree_closure_filtered(
558 store,
559 provenance_root,
560 &excluded_hashes,
561 &mut seen_hashes,
562 &mut visit,
563 )?;
564 }
565 }
566
567 Ok(())
568}
569
570fn walk_tree_closure_filtered(
571 store: &impl ObjectStore,
572 tree_hash: ContentHash,
573 excluded: &HashSet<ContentHash>,
574 seen: &mut HashSet<ContentHash>,
575 visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
576) -> Result<()> {
577 if excluded.contains(&tree_hash) {
578 visit(StateClosureEvent::ExcludedHash { hash: tree_hash })?;
579 return Ok(());
580 }
581 if !seen.insert(tree_hash) {
582 return Ok(());
583 }
584
585 let tree = store
586 .get_tree(&tree_hash)?
587 .ok_or_else(|| HeddleError::MissingObject {
588 object_type: "tree".to_string(),
589 id: tree_hash.to_hex(),
590 })?;
591
592 let (storage_size, delta_base) = tree_storage_metadata(store, tree_hash)?;
593 visit(StateClosureEvent::Tree {
594 hash: tree_hash,
595 storage_size,
596 delta_base,
597 })?;
598
599 if let Some(anchor) = delta_base {
600 walk_tree_storage_anchor(store, anchor, seen, visit)?;
601 }
602
603 for entry in tree.entries() {
604 match entry.target() {
605 TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
606 walk_blob_filtered(store, *hash, excluded, seen, visit)?;
607 }
608 TreeEntryTarget::Tree { hash } => {
609 walk_tree_closure_filtered(store, *hash, excluded, seen, visit)?;
610 }
611 TreeEntryTarget::Gitlink { .. } => {}
612 TreeEntryTarget::Spoollink { .. } => {}
615 }
616 }
617
618 Ok(())
619}
620
621fn tree_storage_metadata(
622 store: &impl ObjectStore,
623 tree_hash: ContentHash,
624) -> Result<(u64, Option<ContentHash>)> {
625 let body =
626 store
627 .get_tree_serialized(&tree_hash)?
628 .ok_or_else(|| HeddleError::MissingObject {
629 object_type: "tree".to_string(),
630 id: tree_hash.to_hex(),
631 })?;
632 let delta_base = if is_delta_tree(&body) {
633 let header = decode_tree_delta_header(&body)?;
634 if header.anchor == tree_hash {
635 return Err(HeddleError::InvalidObject(
636 "HDC1 result id must differ from its anchor id".to_string(),
637 ));
638 }
639 Some(header.anchor)
640 } else {
641 None
642 };
643 Ok((body.len() as u64, delta_base))
644}
645
646fn walk_tree_storage_anchor(
647 store: &impl ObjectStore,
648 anchor_hash: ContentHash,
649 seen: &mut HashSet<ContentHash>,
650 visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
651) -> Result<()> {
652 if !seen.insert(anchor_hash) {
653 return Ok(());
654 }
655 if store.get_tree(&anchor_hash)?.is_none() {
656 return Err(HeddleError::MissingObject {
657 object_type: "tree delta anchor".to_string(),
658 id: anchor_hash.to_hex(),
659 });
660 }
661 let (storage_size, delta_base) = tree_storage_metadata(store, anchor_hash)?;
662 if delta_base.is_some() {
663 return Err(HeddleError::InvalidObject(
664 "HDC1 anchor must be materialized; delta chains are forbidden".to_string(),
665 ));
666 }
667 visit(StateClosureEvent::Tree {
668 hash: anchor_hash,
669 storage_size,
670 delta_base: None,
671 })
672}
673
674fn walk_blob_filtered(
675 store: &impl ObjectStore,
676 blob_hash: ContentHash,
677 excluded: &HashSet<ContentHash>,
678 seen: &mut HashSet<ContentHash>,
679 visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
680) -> Result<()> {
681 if excluded.contains(&blob_hash) {
682 visit(StateClosureEvent::ExcludedHash { hash: blob_hash })?;
683 return Ok(());
684 }
685 if !seen.insert(blob_hash) {
686 return Ok(());
687 }
688 visit(StateClosureEvent::Blob { hash: blob_hash })?;
689 if store.has_redactions_for_blob(&blob_hash)? {
690 visit(StateClosureEvent::Redaction { blob: blob_hash })?;
691 }
692 Ok(())
693}
694
695fn walk_semantic_index_closure(
708 store: &impl ObjectStore,
709 root_hash: ContentHash,
710 excluded: &HashSet<ContentHash>,
711 seen: &mut HashSet<ContentHash>,
712 visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
713) -> Result<()> {
714 let mut stack: Vec<ContentHash> = vec![root_hash];
717 while let Some(node_hash) = stack.pop() {
718 if !emit_semantic_blob(store, node_hash, excluded, seen, visit)? {
719 continue; }
721 let blob = store
722 .get_blob(&node_hash)?
723 .ok_or_else(|| missing_blob(node_hash))?;
724 let node = decode_semantic_container(&blob, node_hash)?;
728 for child in node {
729 match child {
730 SemanticChild::Interior(hash) => stack.push(hash),
731 SemanticChild::Leaf(hash) => {
732 emit_semantic_blob(store, hash, excluded, seen, visit)?;
734 }
735 SemanticChild::BindingDelta(hash) => {
736 emit_binding_delta(store, hash, excluded, seen, visit)?;
742 }
743 SemanticChild::ImporterIndex(hash) => {
744 emit_importer_index(store, hash, excluded, seen, visit)?;
745 }
746 }
747 }
748 }
749 Ok(())
750}
751
752enum SemanticChild {
754 Interior(ContentHash),
755 Leaf(ContentHash),
756 BindingDelta(ContentHash),
757 ImporterIndex(ContentHash),
758}
759
760fn decode_semantic_container(
763 blob: &crate::object::Blob,
764 node_hash: ContentHash,
765) -> Result<Vec<SemanticChild>> {
766 if let Ok(root) = SemanticIndexRoot::decode(blob.content()) {
770 let mut children = vec![SemanticChild::Interior(root.tree)];
771 if let Some(binding_delta) = root.binding_delta {
772 children.push(SemanticChild::BindingDelta(binding_delta));
773 }
774 if let Some(importer_index) = root.importer_index {
775 children.push(SemanticChild::ImporterIndex(importer_index));
776 }
777 return Ok(children);
778 }
779 let node = SemanticTreeNode::decode(blob.content())
780 .map_err(|err| HeddleError::Serialization(format!("semantic node {node_hash}: {err}")))?;
781 Ok(node
782 .entries
783 .iter()
784 .filter_map(|entry| match entry.kind {
785 SemanticEntryKind::Dir => Some(SemanticChild::Interior(entry.node)),
786 SemanticEntryKind::File => Some(SemanticChild::Leaf(entry.node)),
787 SemanticEntryKind::Opaque => None,
789 })
790 .collect())
791}
792
793fn emit_binding_delta(
794 store: &impl ObjectStore,
795 hash: ContentHash,
796 excluded: &HashSet<ContentHash>,
797 seen: &mut HashSet<ContentHash>,
798 visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
799) -> Result<()> {
800 if !emit_semantic_blob(store, hash, excluded, seen, visit)? {
801 return Ok(());
802 }
803 let blob = store.get_blob(&hash)?.ok_or_else(|| missing_blob(hash))?;
804 BindingDelta::decode(blob.content()).map_err(|err| {
805 HeddleError::Serialization(format!("semantic binding delta {hash}: {err}"))
806 })?;
807 Ok(())
808}
809
810fn emit_importer_index(
811 store: &impl ObjectStore,
812 hash: ContentHash,
813 excluded: &HashSet<ContentHash>,
814 seen: &mut HashSet<ContentHash>,
815 visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
816) -> Result<()> {
817 if !emit_semantic_blob(store, hash, excluded, seen, visit)? {
818 return Ok(());
819 }
820 let blob = store.get_blob(&hash)?.ok_or_else(|| missing_blob(hash))?;
821 ReverseDependencyIndex::decode(blob.content()).map_err(|err| {
822 HeddleError::Serialization(format!("semantic reverse-dependency index {hash}: {err}"))
823 })?;
824 Ok(())
825}
826
827fn emit_semantic_blob(
832 store: &impl ObjectStore,
833 hash: ContentHash,
834 excluded: &HashSet<ContentHash>,
835 seen: &mut HashSet<ContentHash>,
836 visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
837) -> Result<bool> {
838 if excluded.contains(&hash) {
839 visit(StateClosureEvent::ExcludedHash { hash })?;
840 return Ok(false);
841 }
842 if !seen.insert(hash) {
843 return Ok(false);
844 }
845 if store.get_blob(&hash)?.is_none() {
846 return Err(missing_blob(hash));
847 }
848 visit(StateClosureEvent::Blob { hash })?;
849 Ok(true)
850}
851
852fn collect_semantic_hashes(
857 store: &impl ObjectStore,
858 root_hash: ContentHash,
859 excluded: &mut HashSet<ContentHash>,
860) -> Result<()> {
861 let mut stack: Vec<ContentHash> = vec![root_hash];
862 while let Some(node_hash) = stack.pop() {
863 if !excluded.insert(node_hash) {
864 continue;
865 }
866 let Some(blob) = store.get_blob(&node_hash)? else {
867 continue;
868 };
869 let children = match decode_semantic_container(&blob, node_hash) {
870 Ok(children) => children,
871 Err(_) => continue,
872 };
873 for child in children {
874 match child {
875 SemanticChild::Interior(hash) => stack.push(hash),
876 SemanticChild::Leaf(hash) => {
877 excluded.insert(hash);
878 }
879 SemanticChild::BindingDelta(hash) | SemanticChild::ImporterIndex(hash) => {
880 excluded.insert(hash);
881 }
882 }
883 }
884 }
885 Ok(())
886}
887
888fn object_info_from_event(
889 store: &impl ObjectStore,
890 event: StateClosureEvent<'_>,
891) -> Result<Option<ObjectInfo>> {
892 match event {
893 StateClosureEvent::State { id, state } => {
894 let state_bytes = state.encode_current_msgpack()?;
895 Ok(Some(ObjectInfo {
896 id: ObjectId::StateId(id),
897 obj_type: ObjectType::State,
898 size: state_bytes.len() as u64,
899 delta_base: None,
900 }))
901 }
902 StateClosureEvent::Tree {
903 hash,
904 storage_size,
905 delta_base,
906 } => Ok(Some(ObjectInfo {
907 id: ObjectId::Hash(hash),
908 obj_type: ObjectType::Tree,
909 size: storage_size,
910 delta_base,
911 })),
912 StateClosureEvent::Blob { hash, .. } => {
913 let Some(blob) = store.get_blob(&hash)? else {
914 if blob_has_purge_evidence(store, &hash)? {
915 return Ok(None);
916 }
917 return Err(missing_blob(hash));
918 };
919 Ok(Some(ObjectInfo {
920 id: ObjectId::Hash(hash),
921 obj_type: ObjectType::Blob,
922 size: blob.size() as u64,
923 delta_base: None,
924 }))
925 }
926 StateClosureEvent::Redaction { blob } => Ok(store
927 .get_redactions_bytes_for_blob(&blob)?
928 .map(|bytes| ObjectInfo {
929 id: ObjectId::Hash(blob),
930 obj_type: ObjectType::Redaction,
931 size: bytes.len() as u64,
932 delta_base: None,
933 })),
934 StateClosureEvent::StateVisibility { state } => Ok(store
935 .get_state_visibility_bytes_for_state(&state)?
936 .map(|bytes| ObjectInfo {
937 id: ObjectId::StateId(state),
938 obj_type: ObjectType::StateVisibility,
939 size: bytes.len() as u64,
940 delta_base: None,
941 })),
942 StateClosureEvent::StateAttachment { state, attachment } => {
943 let bytes = attachment.encode_current_msgpack()?;
944 Ok(Some(ObjectInfo {
945 id: ObjectId::StateAttachment {
946 state,
947 id: attachment.id(),
948 kind: attachment.body.kind(),
949 },
950 obj_type: ObjectType::StateAttachment,
951 size: bytes.len() as u64,
952 delta_base: None,
953 }))
954 }
955 StateClosureEvent::ExcludedState { id } => {
956 let _ = id;
957 Ok(None)
958 }
959 StateClosureEvent::ExcludedHash { hash } => {
960 let _ = hash;
961 Ok(None)
962 }
963 }
964}
965
966fn planned_object_from_event(
967 store: &impl ObjectStore,
968 event: StateClosureEvent<'_>,
969) -> Result<Option<PlannedObject>> {
970 match event {
971 StateClosureEvent::State { id, .. } => Ok(Some(PlannedObject {
972 id: ObjectId::StateId(id),
973 obj_type: ObjectType::State,
974 })),
975 StateClosureEvent::Tree { hash, .. } => Ok(Some(PlannedObject {
976 id: ObjectId::Hash(hash),
977 obj_type: ObjectType::Tree,
978 })),
979 StateClosureEvent::Blob { hash, .. } => {
980 if store.get_blob(&hash)?.is_none() {
981 if blob_has_purge_evidence(store, &hash)? {
982 return Ok(None);
983 }
984 return Err(missing_blob(hash));
985 }
986 Ok(Some(PlannedObject {
987 id: ObjectId::Hash(hash),
988 obj_type: ObjectType::Blob,
989 }))
990 }
991 StateClosureEvent::Redaction { blob } => Ok(Some(PlannedObject {
992 id: ObjectId::Hash(blob),
993 obj_type: ObjectType::Redaction,
994 })),
995 StateClosureEvent::StateVisibility { state } => Ok(Some(PlannedObject {
996 id: ObjectId::StateId(state),
997 obj_type: ObjectType::StateVisibility,
998 })),
999 StateClosureEvent::StateAttachment { state, attachment } => Ok(Some(PlannedObject {
1000 id: ObjectId::StateAttachment {
1001 state,
1002 id: attachment.id(),
1003 kind: attachment.body.kind(),
1004 },
1005 obj_type: ObjectType::StateAttachment,
1006 })),
1007 StateClosureEvent::ExcludedState { id } => {
1008 let _ = id;
1009 Ok(None)
1010 }
1011 StateClosureEvent::ExcludedHash { hash } => {
1012 let _ = hash;
1013 Ok(None)
1014 }
1015 }
1016}
1017
1018fn missing_blob(hash: ContentHash) -> HeddleError {
1022 HeddleError::MissingObject {
1023 object_type: "blob".to_string(),
1024 id: hash.to_hex(),
1025 }
1026}
1027
1028fn blob_has_purge_evidence(store: &impl ObjectStore, hash: &ContentHash) -> Result<bool> {
1029 let Some(bytes) = store.get_redactions_bytes_for_blob(hash)? else {
1030 return Ok(false);
1031 };
1032 let redactions = RedactionsBlob::decode(&bytes).map_err(|error| {
1033 HeddleError::InvalidObject(format!(
1034 "invalid redaction sidecar for missing blob {}: {error}",
1035 hash.to_hex()
1036 ))
1037 })?;
1038 Ok(redactions
1039 .redactions
1040 .iter()
1041 .any(|redaction| redaction.redacted_blob == *hash && redaction.is_purged()))
1042}
1043
1044pub fn missing_blobs_in_tree(
1045 store: &impl ObjectStore,
1046 tree_hash: ContentHash,
1047) -> Result<Vec<ContentHash>> {
1048 let mut missing = Vec::new();
1049 collect_missing_blobs_recursive(store, &tree_hash, &mut missing)?;
1050 Ok(missing)
1051}
1052
1053fn collect_missing_blobs_recursive(
1054 store: &impl ObjectStore,
1055 tree_hash: &ContentHash,
1056 missing: &mut Vec<ContentHash>,
1057) -> Result<()> {
1058 let Some(tree) = store.get_tree(tree_hash).map_err(|err| {
1059 HeddleError::InvalidObject(format!(
1060 "load tree {} while collecting lazy hydration missing blobs: {err}",
1061 tree_hash.to_hex()
1062 ))
1063 })?
1064 else {
1065 return Ok(());
1066 };
1067
1068 for entry in tree.entries() {
1069 match entry.target() {
1070 TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
1071 if !store.has_blob(hash).map_err(|err| {
1072 HeddleError::InvalidObject(format!(
1073 "check blob {} while collecting lazy hydration missing blobs: {err}",
1074 hash.to_hex()
1075 ))
1076 })? {
1077 missing.push(*hash);
1078 }
1079 }
1080 TreeEntryTarget::Tree { hash } => {
1081 collect_missing_blobs_recursive(store, hash, missing)?;
1082 }
1083 TreeEntryTarget::Gitlink { .. } => {}
1084 TreeEntryTarget::Spoollink { .. } => {}
1087 }
1088 }
1089 Ok(())
1090}
1091
1092fn collect_excluded(
1093 store: &impl ObjectStore,
1094 roots: &[StateId],
1095) -> Result<(HashSet<StateId>, HashSet<ContentHash>)> {
1096 if roots.is_empty() {
1097 return Ok((HashSet::new(), HashSet::new()));
1098 }
1099
1100 let mut excluded_states: HashSet<StateId> = HashSet::new();
1101 let mut excluded_hashes: HashSet<ContentHash> = HashSet::new();
1102 let mut queue: VecDeque<StateId> = VecDeque::new();
1103
1104 for id in roots {
1105 queue.push_back(*id);
1106 }
1107
1108 while let Some(id) = queue.pop_front() {
1109 if !excluded_states.insert(id) {
1110 continue;
1111 }
1112
1113 let state = match store.get_state(&id)? {
1114 Some(state) => state,
1115 None => continue,
1116 };
1117
1118 for parent in &state.parents {
1119 queue.push_back(*parent);
1120 }
1121
1122 collect_tree_hashes(store, state.tree, &mut excluded_hashes)?;
1123 if let Some(provenance_root) = state.provenance {
1124 collect_tree_hashes(store, provenance_root, &mut excluded_hashes)?;
1125 }
1126 for attachment in store.list_state_attachments(&id)? {
1127 match attachment.body {
1128 StateAttachmentBody::Context(root) => {
1129 collect_tree_hashes(store, root, &mut excluded_hashes)?
1130 }
1131 StateAttachmentBody::RiskSignals(hash)
1132 | StateAttachmentBody::ReviewSignatures(hash)
1133 | StateAttachmentBody::Discussions(hash)
1134 | StateAttachmentBody::StructuredConflicts(hash) => {
1135 excluded_hashes.insert(hash);
1136 }
1137 StateAttachmentBody::SemanticIndex(root) => {
1138 collect_semantic_hashes(store, root, &mut excluded_hashes)?;
1139 }
1140 StateAttachmentBody::Signature(_) => {}
1141 }
1142 }
1143 }
1144
1145 Ok((excluded_states, excluded_hashes))
1146}
1147
1148fn collect_tree_hashes(
1149 store: &impl ObjectStore,
1150 tree_hash: ContentHash,
1151 excluded: &mut HashSet<ContentHash>,
1152) -> Result<()> {
1153 if !excluded.insert(tree_hash) {
1154 return Ok(());
1155 }
1156
1157 let tree = match store.get_tree(&tree_hash)? {
1158 Some(tree) => tree,
1159 None => return Ok(()),
1160 };
1161
1162 for entry in tree.entries() {
1163 match entry.target() {
1164 TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
1165 excluded.insert(*hash);
1166 }
1167 TreeEntryTarget::Tree { hash } => {
1168 collect_tree_hashes(store, *hash, excluded)?;
1169 }
1170 TreeEntryTarget::Gitlink { .. } => {}
1171 TreeEntryTarget::Spoollink { .. } => {}
1174 }
1175 }
1176
1177 Ok(())
1178}
1179
1180pub fn is_ancestor(
1181 store: &impl ObjectStore,
1182 ancestor: StateId,
1183 descendant: StateId,
1184) -> Result<bool> {
1185 walk_ancestor(ancestor, descendant, |id| ObjectStore::get_state(store, id))
1186}
1187
1188pub fn is_ancestor_from_source(
1190 source: &(impl ObjectSource + ?Sized),
1191 ancestor: StateId,
1192 descendant: StateId,
1193) -> Result<bool> {
1194 walk_ancestor(ancestor, descendant, |id| source.get_state(id))
1195}
1196
1197#[cfg(feature = "async-source")]
1200#[derive(Clone, Debug)]
1201pub struct AncestryBudget {
1202 pub max_states: usize,
1203 pub cancelled: Arc<AtomicBool>,
1204 pub deadline: Option<Instant>,
1205}
1206
1207#[cfg(feature = "async-source")]
1208#[derive(Debug, thiserror::Error)]
1209pub enum AncestryError {
1210 #[error("ancestry traversal cancelled")]
1211 Cancelled,
1212 #[error("ancestry traversal deadline exceeded")]
1213 Deadline,
1214 #[error("ancestry work limit exhausted")]
1215 WorkLimit,
1216 #[error(transparent)]
1217 Source(#[from] HeddleError),
1218}
1219
1220#[cfg(feature = "async-source")]
1224pub async fn is_ancestor_async_bounded<S>(
1225 source: &S,
1226 ancestor: &StateId,
1227 descendant: &StateId,
1228 budget: &AncestryBudget,
1229) -> std::result::Result<bool, AncestryError>
1230where
1231 S: AsyncObjectSource + ?Sized,
1232{
1233 if ancestor == descendant {
1234 return Ok(true);
1235 }
1236 let check_interruption = || {
1237 if budget.cancelled.load(Ordering::Acquire) {
1238 return Err(AncestryError::Cancelled);
1239 }
1240 if budget
1241 .deadline
1242 .is_some_and(|deadline| Instant::now() >= deadline)
1243 {
1244 return Err(AncestryError::Deadline);
1245 }
1246 Ok(())
1247 };
1248 let mut seen = HashSet::new();
1249 let mut stack = vec![*descendant];
1250 while let Some(id) = stack.pop() {
1251 check_interruption()?;
1252 if !seen.insert(id) {
1253 continue;
1254 }
1255 if seen.len() > budget.max_states {
1256 return Err(AncestryError::WorkLimit);
1257 }
1258 let state = source.get_state(&id).await?;
1259 check_interruption()?;
1260 let Some(state) = state else {
1261 continue;
1262 };
1263 heddle_perf_contract::record_history_object_decode();
1264 if id == *ancestor {
1265 return Ok(true);
1266 }
1267 stack.extend(state.parents);
1268 }
1269 Ok(false)
1270}
1271
1272#[cfg(feature = "async-source")]
1275pub async fn is_ancestor_async<S>(
1276 source: &S,
1277 ancestor: &StateId,
1278 descendant: &StateId,
1279) -> Result<bool>
1280where
1281 S: AsyncObjectSource + ?Sized,
1282{
1283 let budget = AncestryBudget {
1284 max_states: usize::MAX,
1285 cancelled: Arc::new(AtomicBool::new(false)),
1286 deadline: None,
1287 };
1288 is_ancestor_async_bounded(source, ancestor, descendant, &budget)
1289 .await
1290 .map_err(|error| match error {
1291 AncestryError::Source(error) => error,
1292 other => HeddleError::InvalidObject(other.to_string()),
1293 })
1294}
1295
1296fn walk_ancestor(
1297 ancestor: StateId,
1298 descendant: StateId,
1299 mut get_state: impl FnMut(&StateId) -> Result<Option<State>>,
1300) -> Result<bool> {
1301 if ancestor == descendant {
1302 return Ok(true);
1303 }
1304
1305 let mut seen: HashSet<StateId> = HashSet::new();
1306 let mut queue: VecDeque<StateId> = VecDeque::new();
1307 queue.push_back(descendant);
1308
1309 while let Some(id) = queue.pop_front() {
1310 if !seen.insert(id) {
1311 continue;
1312 }
1313 let state = match get_state(&id)? {
1314 Some(s) => s,
1315 None => return Ok(false),
1316 };
1317 for parent in state.parents {
1318 if parent == ancestor {
1319 return Ok(true);
1320 }
1321 queue.push_back(parent);
1322 }
1323 }
1324
1325 Ok(false)
1326}
1327
1328#[cfg(all(test, feature = "async-source"))]
1329mod async_ancestry_tests {
1330 use std::{
1331 collections::HashMap,
1332 future::Future,
1333 sync::atomic::AtomicUsize,
1334 task::{Context, Poll, Waker},
1335 };
1336
1337 use super::*;
1338 use crate::object::{Attribution, Blob, Principal, Tree};
1339
1340 struct Source {
1341 states: HashMap<StateId, State>,
1342 reads: AtomicUsize,
1343 fail: Option<StateId>,
1344 cancel: Option<Arc<AtomicBool>>,
1345 }
1346
1347 impl AsyncObjectSource for Source {
1348 async fn get_tree(&self, _: &ContentHash) -> Result<Option<Tree>> {
1349 Ok(None)
1350 }
1351 async fn get_blob(&self, _: &ContentHash) -> Result<Option<Blob>> {
1352 Ok(None)
1353 }
1354 async fn get_state(&self, id: &StateId) -> Result<Option<State>> {
1355 self.reads.fetch_add(1, Ordering::Relaxed);
1356 if self.fail == Some(*id) {
1357 return Err(HeddleError::InvalidObject("source failed".into()));
1358 }
1359 if let Some(cancel) = &self.cancel {
1360 cancel.store(true, Ordering::Release);
1361 }
1362 Ok(self.states.get(id).cloned())
1363 }
1364 }
1365
1366 fn run<T>(future: impl Future<Output = T>) -> T {
1367 let mut future = Box::pin(future);
1368 let mut context = Context::from_waker(Waker::noop());
1369 loop {
1370 match future.as_mut().poll(&mut context) {
1371 Poll::Ready(value) => return value,
1372 Poll::Pending => std::thread::yield_now(),
1373 }
1374 }
1375 }
1376
1377 fn state(name: &str, parents: Vec<StateId>) -> State {
1378 State::new(
1379 Tree::new().hash(),
1380 parents,
1381 Attribution::human(Principal::new(name, "test@example.com")),
1382 )
1383 }
1384
1385 fn budget(limit: usize) -> AncestryBudget {
1386 AncestryBudget {
1387 max_states: limit,
1388 cancelled: Arc::new(AtomicBool::new(false)),
1389 deadline: None,
1390 }
1391 }
1392
1393 #[test]
1394 fn bounded_walk_distinguishes_complete_missing_error_and_interruption() {
1395 let root = state("root", vec![]);
1396 let left = state("left", vec![root.id()]);
1397 let right = state("right", vec![root.id()]);
1398 let merge = state("merge", vec![left.id(), right.id()]);
1399 let missing = state("missing", vec![]).id();
1400 let states = [root.clone(), left.clone(), right.clone(), merge.clone()]
1401 .into_iter()
1402 .map(|state| (state.id(), state))
1403 .collect();
1404 let mut source = Source {
1405 states,
1406 reads: AtomicUsize::new(0),
1407 fail: None,
1408 cancel: None,
1409 };
1410 assert!(
1411 run(is_ancestor_async_bounded(
1412 &source,
1413 &merge.id(),
1414 &merge.id(),
1415 &budget(0)
1416 ))
1417 .unwrap()
1418 );
1419 assert_eq!(source.reads.load(Ordering::Relaxed), 0);
1420
1421 assert!(
1422 run(is_ancestor_async_bounded(
1423 &source,
1424 &root.id(),
1425 &merge.id(),
1426 &budget(4)
1427 ))
1428 .unwrap()
1429 );
1430 source.states.remove(&root.id());
1431 assert!(
1432 !run(is_ancestor_async_bounded(
1433 &source,
1434 &root.id(),
1435 &merge.id(),
1436 &budget(4)
1437 ))
1438 .unwrap()
1439 );
1440 source.states.insert(root.id(), root.clone());
1441 assert!(
1442 !run(is_ancestor_async_bounded(
1443 &source,
1444 &merge.id(),
1445 &left.id(),
1446 &budget(4)
1447 ))
1448 .unwrap()
1449 );
1450 assert!(
1451 !run(is_ancestor_async_bounded(
1452 &source,
1453 &missing,
1454 &merge.id(),
1455 &budget(5)
1456 ))
1457 .unwrap()
1458 );
1459 assert!(matches!(
1460 run(is_ancestor_async_bounded(
1461 &source,
1462 &root.id(),
1463 &merge.id(),
1464 &budget(1)
1465 )),
1466 Err(AncestryError::WorkLimit)
1467 ));
1468
1469 source.fail = Some(merge.id());
1470 assert!(matches!(
1471 run(is_ancestor_async_bounded(
1472 &source,
1473 &root.id(),
1474 &merge.id(),
1475 &budget(4)
1476 )),
1477 Err(AncestryError::Source(_))
1478 ));
1479 source.fail = None;
1480
1481 let cancelled = budget(4);
1482 cancelled.cancelled.store(true, Ordering::Release);
1483 let before = source.reads.load(Ordering::Relaxed);
1484 assert!(matches!(
1485 run(is_ancestor_async_bounded(
1486 &source,
1487 &root.id(),
1488 &merge.id(),
1489 &cancelled
1490 )),
1491 Err(AncestryError::Cancelled)
1492 ));
1493 assert_eq!(source.reads.load(Ordering::Relaxed), before);
1494
1495 let mut expired = budget(4);
1496 expired.deadline = Some(Instant::now());
1497 assert!(matches!(
1498 run(is_ancestor_async_bounded(
1499 &source,
1500 &root.id(),
1501 &merge.id(),
1502 &expired
1503 )),
1504 Err(AncestryError::Deadline)
1505 ));
1506
1507 let mid_cancel = budget(4);
1508 source.cancel = Some(mid_cancel.cancelled.clone());
1509 assert!(matches!(
1510 run(is_ancestor_async_bounded(
1511 &source,
1512 &root.id(),
1513 &merge.id(),
1514 &mid_cancel
1515 )),
1516 Err(AncestryError::Cancelled)
1517 ));
1518 }
1519}