1use super::analysis::{RelationAnalysisDraft, RelationAnalysisQuery, RelationAnalysisReport};
4use super::relations::{
5 DetailedRelationBudget, DetailedRelationPageDraft, DetailedRelationQuery,
6 DetailedRelationReport, ExternalRelationIdentity, external_relation_identities,
7 relation_request_control, serialized_equivalent_bytes,
8};
9use super::{ServiceError, ServiceResult, canonical_root_digest, selected_project_binding};
10use projectatlas_core::graph::{
11 ExtendedRelationKind, ExternalSelector, GraphLimitKind, GraphRelationKind, LogicalRelation,
12 ProjectInstanceId, RelationResolution,
13};
14use projectatlas_core::language::ContentClassification;
15use projectatlas_core::symbols::RelationKind;
16use projectatlas_core::{CanonicalProjectRoot, IndexGeneration, IndexWorkControl, IndexWorkStage};
17use projectatlas_db::{AtlasStore, DbError};
18use serde::{Deserialize, Serialize};
19use std::collections::{BTreeMap, BTreeSet};
20use std::fs;
21use std::path::PathBuf;
22use std::time::{Duration, Instant};
23
24const MIN_FEDERATED_ROOTS: usize = 2;
26const MAX_FEDERATED_ROOTS: usize = 8;
28pub const MAX_FEDERATED_DATABASE_BYTES: u64 = 64 * 1_024 * 1_024 * 1_024;
30pub const MAX_FEDERATED_INPUT_BYTES: u64 = 16 * 1_024 * 1_024 * 1_024;
32const MAX_FEDERATED_CLOSE_MS: u64 = 1_000;
34
35const FEDERATED_CURSOR_VERSION: u16 = 1;
37const FEDERATED_CURSOR_MAX_BYTES: usize = 128 * 1_024;
39const FEDERATED_ROOT_DIGEST_DOMAIN: &str = "projectatlas:federated-root:v1";
41
42const FEDERATED_RENDEZVOUS_RELATIONS: [GraphRelationKind; 6] = [
44 GraphRelationKind::Legacy(RelationKind::Imports),
45 GraphRelationKind::Legacy(RelationKind::Calls),
46 GraphRelationKind::Legacy(RelationKind::DependsOn),
47 GraphRelationKind::Extended(ExtendedRelationKind::RoutesTo),
48 GraphRelationKind::Extended(ExtendedRelationKind::Configures),
49 GraphRelationKind::Extended(ExtendedRelationKind::Deploys),
50];
51
52pub fn validate_federated_root_count(count: usize) -> ServiceResult<()> {
58 if (MIN_FEDERATED_ROOTS..=MAX_FEDERATED_ROOTS).contains(&count) {
59 Ok(())
60 } else {
61 Err(ServiceError::InvalidInput(format!(
62 "federation requires {MIN_FEDERATED_ROOTS}..={MAX_FEDERATED_ROOTS} explicit ordered roots"
63 )))
64 }
65}
66
67#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize)]
69pub struct FederatedInputWork {
70 pub filesystem_entries: u64,
72 pub filesystem_bytes: u64,
74 pub sqlite_read_statements: u64,
76 pub decoded_nodes: u64,
78 pub elapsed_ms: u64,
80}
81
82impl FederatedInputWork {
83 fn checked_add(self, other: Self) -> ServiceResult<Self> {
85 Ok(Self {
86 filesystem_entries: checked_sum(
87 self.filesystem_entries,
88 other.filesystem_entries,
89 "federated filesystem-entry work",
90 )?,
91 filesystem_bytes: checked_sum(
92 self.filesystem_bytes,
93 other.filesystem_bytes,
94 "federated input bytes",
95 )?,
96 sqlite_read_statements: checked_sum(
97 self.sqlite_read_statements,
98 other.sqlite_read_statements,
99 "federated freshness statements",
100 )?,
101 decoded_nodes: checked_sum(
102 self.decoded_nodes,
103 other.decoded_nodes,
104 "federated freshness nodes",
105 )?,
106 elapsed_ms: checked_sum(
107 self.elapsed_ms,
108 other.elapsed_ms,
109 "federated freshness time",
110 )?,
111 })
112 }
113}
114
115pub struct FederatedStore {
117 store: AtlasStore,
119 database_path: PathBuf,
121 root: PathBuf,
123 database_bytes: u64,
125 input_work: FederatedInputWork,
127 worktree: Option<String>,
129}
130
131impl FederatedStore {
132 pub fn new(
139 store: AtlasStore,
140 database_path: PathBuf,
141 root: PathBuf,
142 input_work: FederatedInputWork,
143 ) -> ServiceResult<Self> {
144 Self::new_with_worktree(store, database_path, root, input_work, None)
145 }
146
147 pub fn new_with_worktree(
153 store: AtlasStore,
154 database_path: PathBuf,
155 root: PathBuf,
156 input_work: FederatedInputWork,
157 worktree: Option<String>,
158 ) -> ServiceResult<Self> {
159 if !store.is_read_only() || !store.has_active_read_snapshot() {
160 return Err(ServiceError::InvalidInput(
161 "federation accepts only read-only stores with active snapshots".to_string(),
162 ));
163 }
164 let explicit_root = CanonicalProjectRoot::from_path(&root)
165 .map_err(|error| ServiceError::InvalidInput(error.to_string()))?;
166 if !store.project_root_identity_matches(&explicit_root) {
167 return Err(ServiceError::InvalidInput(
168 "federated store does not match its explicit root".to_string(),
169 ));
170 }
171 let metadata = fs::metadata(&database_path).map_err(|source| ServiceError::Io {
172 path: database_path.clone(),
173 source,
174 })?;
175 if !metadata.is_file() {
176 return Err(ServiceError::InvalidInput(
177 "federated database path is not a regular file".to_string(),
178 ));
179 }
180 Ok(Self {
181 store,
182 database_path,
183 root,
184 database_bytes: metadata.len(),
185 input_work,
186 worktree,
187 })
188 }
189
190 #[must_use]
192 pub const fn store(&self) -> &AtlasStore {
193 &self.store
194 }
195
196 pub fn finish(self) -> ServiceResult<()> {
202 self.store.finish_index_read_snapshot().map_err(Into::into)
203 }
204}
205
206#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
208pub struct FederatedParticipant {
209 pub order: u32,
211 #[serde(skip_serializing_if = "Option::is_none")]
213 pub worktree: Option<String>,
214 pub project: ProjectInstanceId,
216 pub generation: IndexGeneration,
218 pub authored_purpose_revision: u64,
220}
221
222#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
224pub struct FederatedRelationEvidence {
225 #[serde(skip_serializing_if = "Option::is_none")]
227 pub worktree: Option<String>,
228 pub project: ProjectInstanceId,
230 pub generation: IndexGeneration,
232 pub source: projectatlas_core::graph::GraphEntity,
234 pub classification: Option<ContentClassification>,
236 pub relation: LogicalRelation,
238}
239
240#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
242pub struct FederatedRendezvous {
243 pub relation: GraphRelationKind,
245 pub external: ExternalSelector,
247 pub evidence: Vec<FederatedRelationEvidence>,
249}
250
251#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
253pub struct FederatedRelationWork {
254 pub input: FederatedInputWork,
256 pub participating_database_bytes: u64,
258 pub simultaneously_open_snapshots: u32,
260 pub rendezvous_database_rows: u64,
262 pub rendezvous_database_bytes: u64,
264 pub rendezvous_relations: u32,
266 pub intermediate_bytes: u64,
268 pub close_ms: u64,
270 pub elapsed_ms: u64,
272 pub rendered_output_bytes: u64,
274}
275
276#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
278pub struct FederatedDetailedRelationReport {
279 pub participants: Vec<FederatedParticipant>,
281 #[serde(skip_serializing_if = "Option::is_none")]
283 pub primary_worktree: Option<String>,
284 #[serde(skip_serializing_if = "Option::is_none")]
286 pub continuation_worktrees: Option<Vec<String>>,
287 pub primary: DetailedRelationReport,
289 pub rendezvous: Vec<FederatedRendezvous>,
291 pub truncated: bool,
293 pub reached_limits: Vec<GraphLimitKind>,
295 pub work: FederatedRelationWork,
297}
298
299#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
301pub struct FederatedAnalysisReport {
302 pub participants: Vec<FederatedParticipant>,
304 #[serde(skip_serializing_if = "Option::is_none")]
306 pub primary_worktree: Option<String>,
307 #[serde(skip_serializing_if = "Option::is_none")]
309 pub continuation_worktrees: Option<Vec<String>>,
310 pub primary: RelationAnalysisReport,
312 pub rendezvous: Vec<FederatedRendezvous>,
314 pub truncated: bool,
316 pub reached_limits: Vec<GraphLimitKind>,
318 pub work: FederatedRelationWork,
320}
321
322pub struct FederatedDetailedRelationDraft {
324 primary: DetailedRelationPageDraft,
326 context: FederatedContext,
328}
329
330impl FederatedDetailedRelationDraft {
331 pub fn fit_output<F, E>(
338 self,
339 control: Option<&IndexWorkControl>,
340 mut encode: F,
341 ) -> Result<(FederatedDetailedRelationReport, String), E>
342 where
343 F: FnMut(&FederatedDetailedRelationReport) -> Result<String, E>,
344 E: From<ServiceError>,
345 {
346 let context = self.context;
347 let (primary, encoded) = self.primary.fit_output(control, |primary| {
348 let report = context.detailed_report(primary.clone()).map_err(E::from)?;
349 encode(&report)
350 })?;
351 let report = context.detailed_report(primary).map_err(E::from)?;
352 Ok((report, encoded))
353 }
354}
355
356pub struct FederatedAnalysisDraft {
358 primary: RelationAnalysisDraft,
360 context: FederatedContext,
362}
363
364impl FederatedAnalysisDraft {
365 pub fn fit_output<F, E, O>(self, mut encode: F) -> Result<(FederatedAnalysisReport, O), E>
372 where
373 F: FnMut(&FederatedAnalysisReport, &IndexWorkControl) -> Result<O, E>,
374 E: From<ServiceError>,
375 O: AsRef<[u8]>,
376 {
377 let context = self.context;
378 let control = self.primary.control().clone();
379 let (primary, encoded) = self.primary.fit_output(|primary, control| {
380 let report = context
381 .analysis_report(primary.clone(), Some(control))
382 .map_err(E::from)?;
383 encode(&report, control)
384 })?;
385 check_control(Some(&control)).map_err(E::from)?;
386 let report = context
387 .analysis_report(primary, Some(&control))
388 .map_err(E::from)?;
389 Ok((report, encoded))
390 }
391}
392
393#[derive(Clone)]
395struct FederatedContext {
396 participants: Vec<FederatedParticipant>,
398 cursor_participants: Vec<FederatedCursorParticipant>,
400 rendezvous: Vec<FederatedRendezvous>,
402 rendezvous_limits: Vec<GraphLimitKind>,
404 base_work: FederatedRelationWork,
406 rendezvous_identity_bytes: u64,
408 budget: DetailedRelationBudget,
410}
411
412impl FederatedContext {
413 fn detailed_report(
415 &self,
416 mut primary: DetailedRelationReport,
417 ) -> ServiceResult<FederatedDetailedRelationReport> {
418 primary.continuation = wrap_continuation(
419 primary.continuation.as_deref(),
420 &self.cursor_participants,
421 self.budget,
422 )?;
423 let mut reached_limits = primary.reached_limits.clone();
424 for limit in &self.rendezvous_limits {
425 push_limit(&mut reached_limits, *limit);
426 }
427 let mut work = self.base_work.clone();
428 work.rendered_output_bytes = primary.work.rendered_output_bytes;
429 work.intermediate_bytes = federated_intermediate_bytes(
430 primary
431 .work
432 .intermediate_bytes
433 .saturating_add(self.rendezvous_identity_bytes),
434 &self.participants,
435 &self.rendezvous,
436 primary.continuation.as_deref(),
437 self.budget,
438 )?;
439 Ok(FederatedDetailedRelationReport {
440 participants: self.participants.clone(),
441 primary_worktree: self
442 .participants
443 .first()
444 .and_then(|participant| participant.worktree.clone()),
445 continuation_worktrees: federation_continuation_worktrees(
446 &self.participants,
447 primary.continuation.is_some(),
448 ),
449 truncated: primary.truncated || !self.rendezvous_limits.is_empty(),
450 primary,
451 rendezvous: self.rendezvous.clone(),
452 reached_limits,
453 work,
454 })
455 }
456
457 fn analysis_report(
459 &self,
460 mut primary: RelationAnalysisReport,
461 control: Option<&IndexWorkControl>,
462 ) -> ServiceResult<FederatedAnalysisReport> {
463 check_control(control)?;
464 primary.continuation = wrap_continuation(
465 primary.continuation.as_deref(),
466 &self.cursor_participants,
467 self.budget,
468 )?;
469 let mut reached_limits = primary.reached_limits.clone();
470 for limit in &self.rendezvous_limits {
471 check_control(control)?;
472 push_limit(&mut reached_limits, *limit);
473 }
474 let mut work = self.base_work.clone();
475 work.rendered_output_bytes = primary.work.rendered_output_bytes;
476 work.intermediate_bytes = federated_intermediate_bytes(
477 primary
478 .work
479 .peak_intermediate_bytes
480 .saturating_add(self.rendezvous_identity_bytes),
481 &self.participants,
482 &self.rendezvous,
483 primary.continuation.as_deref(),
484 self.budget,
485 )?;
486 check_control(control)?;
487 Ok(FederatedAnalysisReport {
488 participants: self.participants.clone(),
489 primary_worktree: self
490 .participants
491 .first()
492 .and_then(|participant| participant.worktree.clone()),
493 continuation_worktrees: federation_continuation_worktrees(
494 &self.participants,
495 primary.continuation.is_some(),
496 ),
497 truncated: primary.truncated || !self.rendezvous_limits.is_empty(),
498 primary,
499 rendezvous: self.rendezvous.clone(),
500 reached_limits,
501 work,
502 })
503 }
504}
505
506fn federation_continuation_worktrees(
508 participants: &[FederatedParticipant],
509 has_continuation: bool,
510) -> Option<Vec<String>> {
511 has_continuation
512 .then(|| {
513 participants
514 .iter()
515 .map(|participant| participant.worktree.clone())
516 .collect::<Option<Vec<_>>>()
517 })
518 .flatten()
519}
520
521#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
523#[serde(deny_unknown_fields)]
524struct FederatedCursor {
525 version: u16,
527 participants: Vec<FederatedCursorParticipant>,
529 inner: String,
531}
532
533#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
535#[serde(deny_unknown_fields)]
536struct FederatedCursorParticipant {
537 project: ProjectInstanceId,
539 root_digest: [u8; 32],
541 generation: IndexGeneration,
543 authored_purpose_revision: u64,
545}
546
547struct CapturedFederation {
549 participants: Vec<FederatedParticipant>,
551 cursor_participants: Vec<FederatedCursorParticipant>,
553 input_work: FederatedInputWork,
555 database_bytes: u64,
557}
558
559struct RendezvousLoad {
561 rows: Vec<FederatedRendezvous>,
563 database_rows: u64,
565 database_bytes: u64,
567 relation_rows: u32,
569 reached_limits: Vec<GraphLimitKind>,
571}
572
573struct ClosedParticipant {
575 database_path: PathBuf,
577 root: PathBuf,
579 cursor: FederatedCursorParticipant,
581}
582
583pub fn load_federated_detailed_relations(
590 stores: Vec<FederatedStore>,
591 query: &DetailedRelationQuery,
592 control: Option<&IndexWorkControl>,
593) -> ServiceResult<FederatedDetailedRelationDraft> {
594 let started = Instant::now();
595 let budget = query.budget;
596 let captured = capture_federation(&stores, control)?;
597 let mut primary_query = query.clone();
598 primary_query.cursor = decode_continuation(
599 query.cursor.as_deref(),
600 &captured.cursor_participants,
601 budget,
602 )?;
603 let operation: ServiceResult<(DetailedRelationPageDraft, RendezvousLoad, u64)> = (|| {
604 check_control(control)?;
605 let primary = super::relations::load_detailed_relation_page(
606 stores[0].store(),
607 &primary_query,
608 control,
609 )?;
610 let candidate = primary.report_for_prefix(primary.candidate_rows())?;
611 let rendezvous_identities = external_relation_identities(&candidate);
612 let rendezvous_identity_bytes = serialized_equivalent_bytes(&rendezvous_identities)?;
613 let primary_edges = u64::from(candidate.work.inspected_edges);
614 let primary_rows = u64::from(candidate.work.database_returned_rows);
615 let remaining_edges = u64::from(budget.edges()).saturating_sub(primary_edges);
616 let rendezvous = load_rendezvous(
617 &stores,
618 query,
619 &rendezvous_identities,
620 remaining_edges,
621 aggregate_row_limit(budget).saturating_sub(primary_rows),
622 budget
623 .intermediate_bytes()
624 .saturating_sub(candidate.work.intermediate_bytes)
625 .saturating_sub(rendezvous_identity_bytes),
626 started,
627 control,
628 )?;
629 Ok((primary, rendezvous, rendezvous_identity_bytes))
630 })();
631 let (closed, close_ms, close_error) = close_participants(stores);
632 let (primary, rendezvous, rendezvous_identity_bytes) = operation?;
633 if let Some(error) = close_error {
634 return Err(error);
635 }
636 revalidate_participants(&closed, control)?;
637 let base_work = base_work(&captured, &rendezvous, close_ms, elapsed_ms(started))?;
638 Ok(FederatedDetailedRelationDraft {
639 primary,
640 context: FederatedContext {
641 participants: captured.participants,
642 cursor_participants: captured.cursor_participants,
643 rendezvous: rendezvous.rows,
644 rendezvous_limits: rendezvous.reached_limits,
645 base_work,
646 rendezvous_identity_bytes,
647 budget,
648 },
649 })
650}
651
652pub fn load_federated_relation_analysis(
659 stores: Vec<FederatedStore>,
660 query: &RelationAnalysisQuery,
661 control: Option<&IndexWorkControl>,
662) -> ServiceResult<FederatedAnalysisDraft> {
663 let started = Instant::now();
664 let budget = query.relations.budget;
665 let captured = capture_federation(&stores, control)?;
666 let mut primary_query = query.clone();
667 primary_query.relations.cursor = decode_continuation(
668 query.relations.cursor.as_deref(),
669 &captured.cursor_participants,
670 budget,
671 )?;
672 let operation: ServiceResult<(RelationAnalysisDraft, RendezvousLoad)> = (|| {
673 check_control(control)?;
674 let mut primary = super::analysis::load_relation_analysis_for_federation(
675 stores[0].store(),
676 &primary_query,
677 control,
678 )?;
679 let rendezvous_identities = primary.take_external_relation_identities();
680 let candidate = primary.candidate_report();
681 let primary_edges = u64::from(candidate.work.relations.inspected_edges)
682 .saturating_add(u64::from(candidate.work.closure_inspected_edges));
683 let primary_rows = u64::from(candidate.work.relations.database_returned_rows)
684 .saturating_add(u64::from(candidate.work.closure_inspected_edges));
685 let remaining_edges = u64::from(budget.edges()).saturating_sub(primary_edges);
686 let rendezvous = load_rendezvous(
687 &stores,
688 &query.relations,
689 &rendezvous_identities,
690 remaining_edges,
691 aggregate_row_limit(budget).saturating_sub(primary_rows),
692 budget
693 .intermediate_bytes()
694 .saturating_sub(candidate.work.peak_intermediate_bytes),
695 started,
696 control,
697 )?;
698 Ok((primary, rendezvous))
699 })();
700 let (closed, close_ms, close_error) = close_participants(stores);
701 let (primary, rendezvous) = operation?;
702 if let Some(error) = close_error {
703 return Err(error);
704 }
705 revalidate_participants(&closed, control)?;
706 let base_work = base_work(&captured, &rendezvous, close_ms, elapsed_ms(started))?;
707 Ok(FederatedAnalysisDraft {
708 primary,
709 context: FederatedContext {
710 participants: captured.participants,
711 cursor_participants: captured.cursor_participants,
712 rendezvous: rendezvous.rows,
713 rendezvous_limits: rendezvous.reached_limits,
714 base_work,
715 rendezvous_identity_bytes: 0,
716 budget,
717 },
718 })
719}
720
721fn capture_federation(
723 stores: &[FederatedStore],
724 control: Option<&IndexWorkControl>,
725) -> ServiceResult<CapturedFederation> {
726 validate_federated_root_count(stores.len())?;
727 let mut roots = BTreeSet::new();
728 let mut projects = Vec::new();
729 let mut participants = Vec::with_capacity(stores.len());
730 let mut cursor_participants = Vec::with_capacity(stores.len());
731 let mut input_work = FederatedInputWork::default();
732 let mut database_bytes = 0_u64;
733 for (order, participant) in stores.iter().enumerate() {
734 check_control(control)?;
735 if !participant.store.is_read_only() || !participant.store.has_active_read_snapshot() {
736 return Err(ServiceError::InvalidInput(
737 "federated participant lost its read-only snapshot".to_string(),
738 ));
739 }
740 let binding = selected_project_binding(&participant.store)?;
741 let root_digest = federated_root_digest(&binding.project_root_identity)?;
742 if !roots.insert(root_digest) || projects.contains(&binding.project_instance_id) {
743 return Err(ServiceError::InvalidInput(
744 "federated roots or project identities must be unique".to_string(),
745 ));
746 }
747 projects.push(binding.project_instance_id);
748 let generation = participant
749 .store
750 .repository_graph_generation()?
751 .ok_or_else(|| {
752 ServiceError::InvalidInput(
753 "federated root has no complete repository graph generation".to_string(),
754 )
755 })?;
756 let authored_purpose_revision = participant.store.authored_purpose_revision()?;
757 let order = u32::try_from(order).map_err(|_overflow| {
758 ServiceError::InvalidInput("federated root order overflowed".to_string())
759 })?;
760 participants.push(FederatedParticipant {
761 order,
762 worktree: participant.worktree.clone(),
763 project: binding.project_instance_id,
764 generation,
765 authored_purpose_revision,
766 });
767 cursor_participants.push(FederatedCursorParticipant {
768 project: binding.project_instance_id,
769 root_digest,
770 generation,
771 authored_purpose_revision,
772 });
773 input_work = input_work.checked_add(participant.input_work)?;
774 database_bytes = checked_sum(
775 database_bytes,
776 participant.database_bytes,
777 "participating database bytes",
778 )?;
779 if input_work.filesystem_bytes > MAX_FEDERATED_INPUT_BYTES {
780 return Err(ServiceError::InvalidInput(format!(
781 "federated source verification exceeds {MAX_FEDERATED_INPUT_BYTES} bytes"
782 )));
783 }
784 if database_bytes > MAX_FEDERATED_DATABASE_BYTES {
785 return Err(ServiceError::InvalidInput(format!(
786 "participating databases exceed {MAX_FEDERATED_DATABASE_BYTES} bytes"
787 )));
788 }
789 }
790 Ok(CapturedFederation {
791 participants,
792 cursor_participants,
793 input_work,
794 database_bytes,
795 })
796}
797
798fn load_rendezvous(
800 stores: &[FederatedStore],
801 query: &DetailedRelationQuery,
802 identities: &BTreeSet<ExternalRelationIdentity>,
803 mut remaining_edges: u64,
804 mut remaining_rows: u64,
805 mut remaining_intermediate_bytes: u64,
806 started: Instant,
807 control: Option<&IndexWorkControl>,
808) -> ServiceResult<RendezvousLoad> {
809 if identities.is_empty()
810 || !matches!(
811 query.resolution,
812 super::relations::RelationResolutionFilter::Any
813 | super::relations::RelationResolutionFilter::External
814 )
815 {
816 return Ok(RendezvousLoad {
817 rows: Vec::new(),
818 database_rows: 0,
819 database_bytes: 0,
820 relation_rows: 0,
821 reached_limits: Vec::new(),
822 });
823 }
824 let deadline = started
825 .checked_add(Duration::from_millis(query.budget.deadline_ms()))
826 .unwrap_or(started);
827 let request_control = relation_request_control(control, deadline);
828 let control = Some(&request_control);
829 let families = query.relation.map_or_else(
830 || FEDERATED_RENDEZVOUS_RELATIONS.to_vec(),
831 |relation| vec![relation],
832 );
833 let mut groups: BTreeMap<(String, String, String), FederatedRendezvous> = BTreeMap::new();
834 let mut database_rows = 0_u64;
835 let mut database_bytes = 0_u64;
836 let mut reached_limits = Vec::new();
837 let mut queries_left = stores.len().saturating_mul(families.len());
838 'queries: for family in families {
839 for participant in stores {
840 if elapsed_ms(started) >= query.budget.deadline_ms() {
841 push_limit(&mut reached_limits, GraphLimitKind::Deadline);
842 break 'queries;
843 }
844 check_control(control)?;
845 if remaining_edges == 0 {
846 push_limit(&mut reached_limits, GraphLimitKind::Edges);
847 break 'queries;
848 }
849 if remaining_rows == 0 {
850 push_limit(&mut reached_limits, GraphLimitKind::Rows);
851 break 'queries;
852 }
853 if remaining_intermediate_bytes == 0 {
854 push_limit(&mut reached_limits, GraphLimitKind::IntermediateBytes);
855 break 'queries;
856 }
857 let fair_share = remaining_edges
858 .div_ceil(u64::try_from(queries_left).unwrap_or(u64::MAX))
859 .max(1)
860 .min(u64::from(projectatlas_core::graph::GraphLimits::MAX_ROWS));
861 let limit = u32::try_from(fair_share).map_err(|_overflow| {
862 ServiceError::InvalidInput("federated relation page limit overflowed".to_string())
863 })?;
864 let page = participant
865 .store
866 .repository_graph_classified_relation_family_rows(
867 family,
868 query.content_selection,
869 limit,
870 control,
871 )?;
872 queries_left = queries_left.saturating_sub(1);
873 let decoded_rows = u64::try_from(page.rows.len()).map_err(|_overflow| {
874 ServiceError::InvalidInput("federated database row count overflowed".to_string())
875 })?;
876 let inspected_rows = decoded_rows.saturating_add(u64::from(page.truncated));
877 database_rows = checked_sum(database_rows, inspected_rows, "federated database rows")?;
878 if inspected_rows > remaining_rows {
879 return Err(ServiceError::InvalidInput(
880 "federated database row budget was exhausted".to_string(),
881 ));
882 }
883 remaining_rows = remaining_rows.saturating_sub(inspected_rows);
884 remaining_edges = remaining_edges.saturating_sub(decoded_rows);
885 if page.truncated {
886 push_limit(&mut reached_limits, GraphLimitKind::Edges);
887 }
888 for row in page.rows {
889 let encoded_bytes = serialized_equivalent_bytes(&(
890 &row.detail.source,
891 &row.detail.relation,
892 row.source_classification,
893 ))?;
894 if encoded_bytes > remaining_intermediate_bytes {
895 push_limit(&mut reached_limits, GraphLimitKind::IntermediateBytes);
896 break 'queries;
897 }
898 remaining_intermediate_bytes =
899 remaining_intermediate_bytes.saturating_sub(encoded_bytes);
900 database_bytes =
901 checked_sum(database_bytes, encoded_bytes, "federated decoded bytes")?;
902 if !super::relations::relation_matches(&row.detail.relation, query) {
903 continue;
904 }
905 let RelationResolution::External { external, .. } =
906 row.detail.relation.resolution()
907 else {
908 continue;
909 };
910 let key = (
911 family.as_str().to_string(),
912 external.system.as_str().to_string(),
913 external.identity.as_str().to_string(),
914 );
915 if !identities.contains(&key) {
916 continue;
917 }
918 let project = row.detail.relation.key().project();
919 let generation = row.detail.relation.generation();
920 let group = groups.entry(key).or_insert_with(|| FederatedRendezvous {
921 relation: family,
922 external: external.clone(),
923 evidence: Vec::new(),
924 });
925 group.evidence.push(FederatedRelationEvidence {
926 worktree: participant.worktree.clone(),
927 project,
928 generation,
929 source: row.detail.source,
930 classification: row.source_classification,
931 relation: row.detail.relation,
932 });
933 }
934 }
935 }
936 let rows = groups
937 .into_values()
938 .filter(|group| {
939 let mut projects = Vec::new();
940 for evidence in &group.evidence {
941 if !projects.contains(&evidence.project) {
942 projects.push(evidence.project);
943 }
944 }
945 projects.len() >= 2
946 })
947 .collect::<Vec<_>>();
948 let relation_rows = u32::try_from(rows.iter().map(|row| row.evidence.len()).sum::<usize>())
949 .map_err(|_overflow| {
950 ServiceError::InvalidInput("federated rendezvous count overflowed".to_string())
951 })?;
952 Ok(RendezvousLoad {
953 rows,
954 database_rows,
955 database_bytes,
956 relation_rows,
957 reached_limits,
958 })
959}
960
961fn close_participants(
963 stores: Vec<FederatedStore>,
964) -> (Vec<ClosedParticipant>, u64, Option<ServiceError>) {
965 let started = Instant::now();
966 let mut closed = Vec::with_capacity(stores.len());
967 let mut first_error = None;
968 for participant in stores {
969 let binding = participant.store.captured_project_binding();
970 let generation = participant.store.repository_graph_generation();
971 let purpose_revision = participant.store.authored_purpose_revision();
972 if let Err(error) = participant.store.finish_index_read_snapshot()
973 && first_error.is_none()
974 {
975 first_error = Some(ServiceError::Db(error));
976 }
977 drop(participant.store);
978 match (binding, generation, purpose_revision) {
979 (Ok(binding), Ok(Some(generation)), Ok(authored_purpose_revision)) => {
980 match federated_root_digest(&binding.project_root_identity) {
981 Ok(root_digest) => closed.push(ClosedParticipant {
982 database_path: participant.database_path,
983 root: participant.root,
984 cursor: FederatedCursorParticipant {
985 project: binding.project_instance_id,
986 root_digest,
987 generation,
988 authored_purpose_revision,
989 },
990 }),
991 Err(error) if first_error.is_none() => first_error = Some(error),
992 Err(_) => {}
993 }
994 }
995 (Err(error), _, _) | (_, Err(error), _) | (_, _, Err(error))
996 if first_error.is_none() =>
997 {
998 first_error = Some(ServiceError::Db(error));
999 }
1000 _ => {
1001 if first_error.is_none() {
1002 first_error = Some(ServiceError::InvalidInput(
1003 "federated participant lost its graph generation before close".to_string(),
1004 ));
1005 }
1006 }
1007 }
1008 }
1009 let elapsed = elapsed_ms(started);
1010 if elapsed > MAX_FEDERATED_CLOSE_MS && first_error.is_none() {
1011 first_error = Some(ServiceError::InvalidInput(format!(
1012 "federated snapshots took {elapsed} ms to close; limit is {MAX_FEDERATED_CLOSE_MS} ms"
1013 )));
1014 }
1015 (closed, elapsed, first_error)
1016}
1017
1018fn revalidate_participants(
1020 participants: &[ClosedParticipant],
1021 control: Option<&IndexWorkControl>,
1022) -> ServiceResult<()> {
1023 for participant in participants {
1024 check_control(control)?;
1025 let store =
1026 AtlasStore::open_read_only_for_project(&participant.database_path, &participant.root)?;
1027 let binding = selected_project_binding(&store)?;
1028 let generation = store.repository_graph_generation()?.ok_or_else(|| {
1029 ServiceError::InvalidInput(
1030 "federated root lost its complete graph generation".to_string(),
1031 )
1032 })?;
1033 let purpose_revision = store.authored_purpose_revision()?;
1034 let current = FederatedCursorParticipant {
1035 project: binding.project_instance_id,
1036 root_digest: federated_root_digest(&binding.project_root_identity)?,
1037 generation,
1038 authored_purpose_revision: purpose_revision,
1039 };
1040 store.finish_index_read_snapshot()?;
1041 drop(store);
1042 if current.project != participant.cursor.project
1043 || current.root_digest != participant.cursor.root_digest
1044 {
1045 return Err(ServiceError::RelationCursorStale {
1046 field: "federated project binding",
1047 });
1048 }
1049 if current.generation != participant.cursor.generation {
1050 return Err(ServiceError::RelationCursorStale {
1051 field: "federated graph generation",
1052 });
1053 }
1054 if current.authored_purpose_revision != participant.cursor.authored_purpose_revision {
1055 return Err(ServiceError::RelationCursorStale {
1056 field: "federated authored-purpose revision",
1057 });
1058 }
1059 }
1060 Ok(())
1061}
1062
1063fn base_work(
1065 captured: &CapturedFederation,
1066 rendezvous: &RendezvousLoad,
1067 close_ms: u64,
1068 elapsed_ms: u64,
1069) -> ServiceResult<FederatedRelationWork> {
1070 Ok(FederatedRelationWork {
1071 input: captured.input_work,
1072 participating_database_bytes: captured.database_bytes,
1073 simultaneously_open_snapshots: u32::try_from(captured.participants.len()).map_err(
1074 |_overflow| {
1075 ServiceError::InvalidInput("federated open-snapshot count overflowed".to_string())
1076 },
1077 )?,
1078 rendezvous_database_rows: rendezvous.database_rows,
1079 rendezvous_database_bytes: rendezvous.database_bytes,
1080 rendezvous_relations: rendezvous.relation_rows,
1081 intermediate_bytes: 0,
1082 close_ms,
1083 elapsed_ms: captured.input_work.elapsed_ms.saturating_add(elapsed_ms),
1084 rendered_output_bytes: 0,
1085 })
1086}
1087
1088fn decode_continuation(
1090 encoded: Option<&str>,
1091 expected: &[FederatedCursorParticipant],
1092 budget: DetailedRelationBudget,
1093) -> ServiceResult<Option<String>> {
1094 let Some(encoded) = encoded else {
1095 return Ok(None);
1096 };
1097 if encoded.is_empty()
1098 || encoded.len() > FEDERATED_CURSOR_MAX_BYTES
1099 || encoded.len() > budget.intermediate_bytes() as usize
1100 {
1101 return Err(ServiceError::RelationCursorInvalid {
1102 reason: "federated cursor length is empty or above the product ceiling",
1103 });
1104 }
1105 let cursor: FederatedCursor =
1106 serde_json::from_str(encoded).map_err(|_source| ServiceError::RelationCursorInvalid {
1107 reason: "federated cursor JSON is malformed or contains unknown fields",
1108 })?;
1109 if cursor.version != FEDERATED_CURSOR_VERSION {
1110 return Err(ServiceError::RelationCursorStale {
1111 field: "federated cursor version",
1112 });
1113 }
1114 if cursor.participants.len() != expected.len() {
1115 return Err(ServiceError::RelationCursorStale {
1116 field: "federated roots",
1117 });
1118 }
1119 for (actual, expected) in cursor.participants.iter().zip(expected) {
1120 if actual.project != expected.project || actual.root_digest != expected.root_digest {
1121 return Err(ServiceError::RelationCursorStale {
1122 field: "federated roots",
1123 });
1124 }
1125 if actual.generation != expected.generation {
1126 return Err(ServiceError::RelationCursorStale {
1127 field: "federated graph generation",
1128 });
1129 }
1130 if actual.authored_purpose_revision != expected.authored_purpose_revision {
1131 return Err(ServiceError::RelationCursorStale {
1132 field: "federated authored-purpose revision",
1133 });
1134 }
1135 }
1136 if cursor.inner.is_empty() {
1137 return Err(ServiceError::RelationCursorInvalid {
1138 reason: "federated cursor omitted its inner continuation",
1139 });
1140 }
1141 Ok(Some(cursor.inner))
1142}
1143
1144fn wrap_continuation(
1146 inner: Option<&str>,
1147 participants: &[FederatedCursorParticipant],
1148 budget: DetailedRelationBudget,
1149) -> ServiceResult<Option<String>> {
1150 let Some(inner) = inner else {
1151 return Ok(None);
1152 };
1153 let encoded = serde_json::to_string(&FederatedCursor {
1154 version: FEDERATED_CURSOR_VERSION,
1155 participants: participants.to_vec(),
1156 inner: inner.to_string(),
1157 })?;
1158 if encoded.len() > FEDERATED_CURSOR_MAX_BYTES
1159 || encoded.len() > budget.intermediate_bytes() as usize
1160 {
1161 return Err(ServiceError::RelationCursorInvalid {
1162 reason: "encoded federated cursor exceeds the intermediate-state ceiling",
1163 });
1164 }
1165 Ok(Some(encoded))
1166}
1167
1168fn federated_intermediate_bytes(
1170 primary_bytes: u64,
1171 participants: &[FederatedParticipant],
1172 rendezvous: &[FederatedRendezvous],
1173 cursor: Option<&str>,
1174 budget: DetailedRelationBudget,
1175) -> ServiceResult<u64> {
1176 let cursor_bytes = u64::try_from(cursor.map_or(0, str::len)).map_err(|_overflow| {
1177 ServiceError::InvalidInput("federated cursor byte count overflowed".to_string())
1178 })?;
1179 let federation_bytes = checked_sum(
1180 serialized_equivalent_bytes(&(participants, rendezvous))?,
1181 cursor_bytes,
1182 "federated intermediate bytes",
1183 )?;
1184 let total = checked_sum(
1185 primary_bytes,
1186 federation_bytes,
1187 "federated intermediate bytes",
1188 )?;
1189 if total > budget.intermediate_bytes() {
1190 return Err(ServiceError::InvalidInput(
1191 "federated aggregate intermediate-byte budget was exhausted".to_string(),
1192 ));
1193 }
1194 Ok(total)
1195}
1196
1197fn aggregate_row_limit(budget: DetailedRelationBudget) -> u64 {
1199 u64::from(budget.nodes())
1200 .saturating_add(u64::from(budget.edges()))
1201 .saturating_add(u64::from(budget.occurrences_total()))
1202 .saturating_add(u64::from(budget.page_rows()))
1203}
1204
1205fn federated_root_digest(root: &CanonicalProjectRoot) -> ServiceResult<[u8; 32]> {
1207 canonical_root_digest(FEDERATED_ROOT_DIGEST_DOMAIN, root)
1208}
1209
1210fn check_control(control: Option<&IndexWorkControl>) -> ServiceResult<()> {
1212 if let Some(control) = control {
1213 control
1214 .check(IndexWorkStage::RepositoryTraversal)
1215 .map_err(DbError::from)?;
1216 }
1217 Ok(())
1218}
1219
1220fn push_limit(limits: &mut Vec<GraphLimitKind>, limit: GraphLimitKind) {
1222 if !limits.contains(&limit) {
1223 limits.push(limit);
1224 }
1225}
1226
1227fn checked_sum(left: u64, right: u64, context: &'static str) -> ServiceResult<u64> {
1229 left.checked_add(right)
1230 .ok_or_else(|| ServiceError::InvalidInput(format!("{context} overflowed")))
1231}
1232
1233fn elapsed_ms(started: Instant) -> u64 {
1235 u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
1236}
1237
1238#[cfg(test)]
1239mod tests {
1240 use super::*;
1241 use crate::relations::{RelationAnchor, RelationDirection, RelationResolutionFilter};
1242 use projectatlas_core::graph::{
1243 Completeness, EntitySelector, GraphEntity, GraphIdentityText, GraphLimits,
1244 RepositoryFilePath,
1245 };
1246 use projectatlas_core::language::ContentSelection;
1247 use projectatlas_core::symbols::RelationKind;
1248 use projectatlas_core::{IndexCancellation, Node, NodeKind};
1249 use projectatlas_db::sqlite_progress_test_observer::{
1250 SqliteReadProgressEvent, observe_sqlite_read_progress,
1251 };
1252 use std::cell::Cell;
1253 use std::error::Error;
1254 use std::io;
1255 use std::path::Path;
1256 use std::rc::Rc;
1257
1258 #[cfg(unix)]
1259 #[test]
1260 fn federation_root_digest_preserves_non_utf8_root_collisions() -> Result<(), Box<dyn Error>> {
1261 use std::os::unix::ffi::OsStringExt;
1262
1263 let temp = tempfile::tempdir()?;
1264 let native = temp
1265 .path()
1266 .join(std::ffi::OsString::from_vec(vec![b'r', b'o', b'o', 0x80]));
1267 let replacement = temp.path().join("roo�");
1268 fs::create_dir(&native)?;
1269 fs::create_dir(&replacement)?;
1270 let native_root = CanonicalProjectRoot::from_path(&native)?;
1271 let replacement_root = CanonicalProjectRoot::from_path(&replacement)?;
1272 require(
1273 federated_root_digest(&native_root)? != federated_root_digest(&replacement_root)?,
1274 "federation identity collapsed non-UTF-8 and replacement roots",
1275 )?;
1276 Ok(())
1277 }
1278
1279 #[cfg(windows)]
1280 #[test]
1281 fn federation_recanonicalizes_case_only_root_and_rejects_case_sensitive_sibling()
1282 -> Result<(), Box<dyn Error>> {
1283 let temp = tempfile::tempdir()?;
1284 let original_root = temp.path().join("FederatedCaseRoot");
1285 let staging_root = temp.path().join("FederatedCaseRootStaging");
1286 let renamed_root = temp.path().join("federatedcaseroot");
1287 let original_database = original_root.join("projectatlas.db");
1288 publish_fixture(
1289 &original_root,
1290 &original_database,
1291 IndexGeneration::new(1),
1292 1,
1293 )?;
1294 let duplicate_database = temp.path().join("federated-duplicate.db");
1295 publish_fixture(
1296 &original_root,
1297 &duplicate_database,
1298 IndexGeneration::new(1),
1299 1,
1300 )?;
1301 let captured_root = CanonicalProjectRoot::from_path(&original_root)?;
1302 fs::rename(&original_root, &staging_root)?;
1303 fs::rename(&staging_root, &renamed_root)?;
1304
1305 let renamed_identity = CanonicalProjectRoot::from_path(&renamed_root)?;
1306 let renamed_database = renamed_root.join("projectatlas.db");
1307 let before_repair_store =
1308 AtlasStore::open_read_only_for_project(&renamed_database, &renamed_root)?;
1309 let before_repair = crate::relations::load_detailed_relations(
1310 &before_repair_store,
1311 &relation_query(
1312 None,
1313 RelationResolutionFilter::External,
1314 RelationDirection::Outbound,
1315 )?,
1316 None,
1317 )?;
1318 before_repair_store.finish_index_read_snapshot()?;
1319 drop(before_repair_store);
1320 let captured_digest = federated_root_digest(&captured_root)?;
1321 require(
1322 captured_digest == federated_root_digest(&renamed_identity)?,
1323 "case-only root rename changed the service root digest",
1324 )?;
1325 let repair_store = AtlasStore::open_for_project(&renamed_database, &renamed_root)?;
1326 drop(repair_store);
1327 let after_repair_store =
1328 AtlasStore::open_read_only_for_project(&renamed_database, &renamed_root)?;
1329 let resumed = crate::relations::load_detailed_relations(
1330 &after_repair_store,
1331 &relation_query(
1332 Some(
1333 before_repair
1334 .continuation
1335 .ok_or("case-only rename fixture omitted a relation continuation")?,
1336 ),
1337 RelationResolutionFilter::External,
1338 RelationDirection::Outbound,
1339 )?,
1340 None,
1341 );
1342 after_repair_store.finish_index_read_snapshot()?;
1343 drop(after_repair_store);
1344 require(
1345 resumed.is_ok(),
1346 "case-only root rename invalidated a relation continuation",
1347 )?;
1348
1349 let repaired_binding =
1350 AtlasStore::open_read_only(&renamed_database)?.captured_project_binding()?;
1351 let duplicate_binding =
1352 AtlasStore::open_read_only(&duplicate_database)?.captured_project_binding()?;
1353 require(
1354 repaired_binding.project_instance_id != duplicate_binding.project_instance_id,
1355 "duplicate federation fixture reused the repaired project identity",
1356 )?;
1357 require(
1358 repaired_binding.project_root_identity.encode()?
1359 != duplicate_binding.project_root_identity.encode()?,
1360 "duplicate federation fixture did not retain old and fresh root spellings",
1361 )?;
1362
1363 let secondary_root = temp.path().join("federated-secondary");
1364 let secondary_database = secondary_root.join("projectatlas.db");
1365 publish_fixture(
1366 &secondary_root,
1367 &secondary_database,
1368 IndexGeneration::new(1),
1369 1,
1370 )?;
1371 let participants = open_participants(&[
1372 (renamed_root.clone(), renamed_database.clone()),
1373 (secondary_root, secondary_database),
1374 ])?;
1375 let draft = load_federated_detailed_relations(
1376 participants,
1377 &relation_query(
1378 None,
1379 RelationResolutionFilter::External,
1380 RelationDirection::Outbound,
1381 )?,
1382 None,
1383 )?;
1384 let (report, _) = draft.fit_output(None, |report| {
1385 serde_json::to_string(report).map_err(ServiceError::from)
1386 })?;
1387 require(
1388 report.participants.len() == 2,
1389 "federated query rejected a case-only root rename",
1390 )?;
1391
1392 let duplicate = load_federated_detailed_relations(
1393 open_participants(&[
1394 (renamed_root.clone(), renamed_database),
1395 (renamed_root, duplicate_database),
1396 ])?,
1397 &relation_query(
1398 None,
1399 RelationResolutionFilter::External,
1400 RelationDirection::Outbound,
1401 )?,
1402 None,
1403 );
1404 require(
1405 matches!(
1406 duplicate,
1407 Err(ServiceError::InvalidInput(message))
1408 if message == "federated roots or project identities must be unique"
1409 ),
1410 "federation admitted two databases for one case-renamed root",
1411 )?;
1412
1413 let case_sensitive_parent = temp.path().join("federated-case-sensitive-parent");
1414 fs::create_dir(&case_sensitive_parent)?;
1415 let enabled = std::process::Command::new("fsutil")
1416 .args(["file", "SetCaseSensitiveInfo"])
1417 .arg(&case_sensitive_parent)
1418 .arg("enable")
1419 .status()
1420 .is_ok_and(|status| status.success());
1421 if !enabled {
1422 return Ok(());
1423 }
1424 let stored_root = case_sensitive_parent.join("Repo");
1425 let selected_root = case_sensitive_parent.join("repo");
1426 let stored_database = stored_root.join("projectatlas.db");
1427 fs::create_dir(&stored_root)?;
1428 fs::create_dir(&selected_root)?;
1429 publish_fixture(&stored_root, &stored_database, IndexGeneration::new(1), 1)?;
1430 let store = AtlasStore::open_read_only(&stored_database)?;
1431 let result = FederatedStore::new(
1432 store,
1433 stored_database,
1434 selected_root,
1435 FederatedInputWork::default(),
1436 );
1437 require(
1438 matches!(result, Err(ServiceError::InvalidInput(message)) if message == "federated store does not match its explicit root"),
1439 "federated admission accepted a case-sensitive sibling",
1440 )?;
1441 Ok(())
1442 }
1443
1444 #[test]
1445 fn federation_is_project_qualified_fresh_bounded_and_handle_free() -> Result<(), Box<dyn Error>>
1446 {
1447 let temp = tempfile::tempdir()?;
1448 let mut participants = Vec::new();
1449 for index in 0..4 {
1450 let root = temp.path().join(format!("project-{index}"));
1451 let database = root.join("projectatlas.db");
1452 publish_fixture(&root, &database, IndexGeneration::new(1), 1)?;
1453 participants.push((root, database));
1454 }
1455 let before = participants
1456 .iter()
1457 .map(|(_, database)| fs::read(database))
1458 .collect::<Result<Vec<_>, _>>()?;
1459 let query = relation_query(
1460 None,
1461 RelationResolutionFilter::External,
1462 RelationDirection::Outbound,
1463 )?;
1464 let draft =
1465 load_federated_detailed_relations(open_participants(&participants)?, &query, None)?;
1466 let (report, encoded) = draft.fit_output(None, |report| {
1467 serde_json::to_string(report).map_err(ServiceError::from)
1468 })?;
1469 require(
1470 report.participants.len() == 4
1471 && report.work.simultaneously_open_snapshots == 4
1472 && report.rendezvous.len() == 2
1473 && report.rendezvous.iter().all(|row| row.evidence.len() == 4),
1474 "many-root rendezvous or snapshot accounting changed",
1475 )?;
1476 let projects = report
1477 .rendezvous
1478 .iter()
1479 .flat_map(|row| row.evidence.iter().map(|evidence| evidence.project))
1480 .collect::<BTreeSet<_>>();
1481 require(
1482 projects.len() == 4
1483 && report
1484 .rendezvous
1485 .iter()
1486 .flat_map(|row| &row.evidence)
1487 .all(|evidence| {
1488 evidence.classification == Some(ContentClassification::Source)
1489 && matches!(
1490 evidence.source.selector(),
1491 EntitySelector::File { path } if path.as_str() == "src/same.rs"
1492 )
1493 }),
1494 "same relative paths collapsed across project identities",
1495 )?;
1496 let classified_query = relation_query_with_selection(
1497 None,
1498 RelationResolutionFilter::External,
1499 RelationDirection::Outbound,
1500 ContentSelection::Source,
1501 )?;
1502 let classified = load_federated_detailed_relations(
1503 open_participants(&participants)?,
1504 &classified_query,
1505 None,
1506 )?;
1507 let (classified, _) = classified.fit_output(None, |report| {
1508 serde_json::to_string(report).map_err(ServiceError::from)
1509 })?;
1510 require(
1511 classified.rendezvous == report.rendezvous
1512 && classified.primary.content_selection == Some(ContentSelection::Source),
1513 "classified federation changed eligible rendezvous evidence or lost selection",
1514 )?;
1515 require(
1516 encoded.len() <= query.budget.output_bytes() as usize
1517 && report.work.intermediate_bytes <= query.budget.intermediate_bytes(),
1518 "federated output escaped its aggregate byte budgets",
1519 )?;
1520 let rendezvous_identities = external_relation_identities(&report.primary);
1521 let encoded_identity_bytes =
1522 u64::try_from(serde_json::to_vec(&rendezvous_identities)?.len())?;
1523 require(
1524 !rendezvous_identities.is_empty()
1525 && serialized_equivalent_bytes(&rendezvous_identities)? == encoded_identity_bytes,
1526 "streamed federation identity accounting diverged from exact JSON bytes",
1527 )?;
1528 let after = participants
1529 .iter()
1530 .map(|(_, database)| fs::read(database))
1531 .collect::<Result<Vec<_>, _>>()?;
1532 require(
1533 before == after,
1534 "read-only federation changed database bytes",
1535 )?;
1536
1537 let mut analysis = load_federated_relation_analysis(
1538 open_participants(&participants)?,
1539 &RelationAnalysisQuery {
1540 relations: query,
1541 mode: crate::analysis::RelationAnalysisMode::Architecture,
1542 trace_target: None,
1543 vcs: None,
1544 include_communities: false,
1545 include_cycles: false,
1546 include_dead_code: false,
1547 entrypoint_profile: None,
1548 },
1549 None,
1550 )?;
1551 require(
1552 analysis
1553 .primary
1554 .take_external_relation_identities()
1555 .is_empty(),
1556 "federated analysis retained temporary rendezvous identities through output fitting",
1557 )?;
1558 let (analysis, _) = analysis.fit_output(|report, _control| {
1559 serde_json::to_vec(report).map_err(ServiceError::from)
1560 })?;
1561 require(
1562 analysis.rendezvous.len() == 2
1563 && analysis
1564 .rendezvous
1565 .iter()
1566 .flat_map(|row| &row.evidence)
1567 .all(|evidence| {
1568 matches!(
1569 evidence.source.selector(),
1570 EntitySelector::File { path } if path.as_str() == "src/same.rs"
1571 )
1572 }),
1573 "analysis rendezvous escaped the primary anchored traversal",
1574 )?;
1575
1576 let inbound_query = relation_query(
1577 None,
1578 RelationResolutionFilter::External,
1579 RelationDirection::Inbound,
1580 )?;
1581 let inbound = load_federated_detailed_relations(
1582 open_participants(&participants)?,
1583 &inbound_query,
1584 None,
1585 )?;
1586 let (inbound, _) = inbound.fit_output(None, |report| {
1587 serde_json::to_string(report).map_err(ServiceError::from)
1588 })?;
1589 require(
1590 inbound.rendezvous.is_empty() && inbound.work.rendezvous_database_rows == 0,
1591 "inbound traversal scanned or returned unrelated external rendezvous",
1592 )?;
1593 let inbound_analysis = load_federated_relation_analysis(
1594 open_participants(&participants)?,
1595 &RelationAnalysisQuery {
1596 relations: inbound_query,
1597 mode: crate::analysis::RelationAnalysisMode::Architecture,
1598 trace_target: None,
1599 vcs: None,
1600 include_communities: false,
1601 include_cycles: false,
1602 include_dead_code: false,
1603 entrypoint_profile: None,
1604 },
1605 None,
1606 )?;
1607 let (inbound_analysis, _) = inbound_analysis.fit_output(|report, _control| {
1608 serde_json::to_vec(report).map_err(ServiceError::from)
1609 })?;
1610 require(
1611 inbound_analysis.rendezvous.is_empty()
1612 && inbound_analysis.work.rendezvous_database_rows == 0,
1613 "inbound analysis scanned or returned unrelated external rendezvous",
1614 )?;
1615
1616 let resolved = load_federated_detailed_relations(
1617 open_participants(&participants)?,
1618 &relation_query(
1619 None,
1620 RelationResolutionFilter::Resolved,
1621 RelationDirection::Outbound,
1622 )?,
1623 None,
1624 )?;
1625 let (resolved, _) = resolved.fit_output(None, |report| {
1626 serde_json::to_string(report).map_err(ServiceError::from)
1627 })?;
1628 require(
1629 resolved.rendezvous.is_empty(),
1630 "federation ignored the requested resolution filter",
1631 )?;
1632
1633 let cursor = report
1634 .primary
1635 .continuation
1636 .ok_or("first federated page omitted its continuation")?;
1637 publish_fixture(
1638 &participants[3].0,
1639 &participants[3].1,
1640 IndexGeneration::new(2),
1641 1,
1642 )?;
1643 let stale = load_federated_detailed_relations(
1644 open_participants(&participants)?,
1645 &relation_query(
1646 Some(cursor),
1647 RelationResolutionFilter::External,
1648 RelationDirection::Outbound,
1649 )?,
1650 None,
1651 );
1652 require(
1653 matches!(
1654 stale,
1655 Err(ServiceError::RelationCursorStale {
1656 field: "federated graph generation"
1657 })
1658 ),
1659 "a changed secondary generation did not stale the outer cursor",
1660 )?;
1661
1662 let control = IndexWorkControl::new(IndexCancellation::new(), None);
1663 control.cancel();
1664 let canceled = load_federated_detailed_relations(
1665 open_participants(&participants)?,
1666 &relation_query(
1667 None,
1668 RelationResolutionFilter::External,
1669 RelationDirection::Outbound,
1670 )?,
1671 Some(&control),
1672 );
1673 require(canceled.is_err(), "pre-canceled federation returned rows")?;
1674 for (index, (_, database)) in participants.iter().enumerate() {
1675 let moved = database.with_extension(format!("closed-{index}"));
1676 fs::rename(database, &moved)?;
1677 fs::rename(moved, database)?;
1678 }
1679 Ok(())
1680 }
1681
1682 #[test]
1683 fn federation_deadline_interrupts_active_rendezvous_and_releases_snapshots()
1684 -> Result<(), Box<dyn Error>> {
1685 let temp = tempfile::tempdir()?;
1686 let primary_root = temp.path().join("primary");
1687 let primary_database = primary_root.join("projectatlas.db");
1688 publish_fixture(&primary_root, &primary_database, IndexGeneration::new(1), 1)?;
1689 let secondary_root = temp.path().join("secondary");
1690 let secondary_database = secondary_root.join("projectatlas.db");
1691 publish_fixture(
1692 &secondary_root,
1693 &secondary_database,
1694 IndexGeneration::new(1),
1695 usize::try_from(GraphLimits::MAX_ROWS)?,
1696 )?;
1697 let participants = vec![
1698 (primary_root, primary_database),
1699 (secondary_root, secondary_database),
1700 ];
1701 let mut query = relation_query(
1702 None,
1703 RelationResolutionFilter::External,
1704 RelationDirection::Outbound,
1705 )?;
1706 query.budget = query.budget.with_aggregate_limits(
1707 Some(GraphLimits::MAX_ROWS),
1708 None,
1709 None,
1710 None,
1711 None,
1712 None,
1713 )?;
1714 let control = IndexWorkControl::with_deadline(
1715 IndexCancellation::new(),
1716 Instant::now() + Duration::from_secs(1),
1717 );
1718 let family_query_active = Rc::new(Cell::new(false));
1719 let family_query_entered_live = Rc::new(Cell::new(false));
1720 let callback_entered_live = Rc::new(Cell::new(false));
1721 let callback_interrupted = Rc::new(Cell::new(false));
1722 let stores = open_participants(&participants)?;
1723 let deadline = observe_sqlite_read_progress(
1724 {
1725 let observer_control = control.clone();
1726 let family_query_active = Rc::clone(&family_query_active);
1727 let family_query_entered_live = Rc::clone(&family_query_entered_live);
1728 let callback_entered_live = Rc::clone(&callback_entered_live);
1729 let callback_interrupted = Rc::clone(&callback_interrupted);
1730 move |event| match event {
1731 SqliteReadProgressEvent::RepositoryRelationFamilyQueryEntered => {
1732 family_query_active.set(true);
1733 if observer_control
1734 .check(IndexWorkStage::RepositoryTraversal)
1735 .is_ok()
1736 {
1737 family_query_entered_live.set(true);
1738 }
1739 }
1740 SqliteReadProgressEvent::RepositoryRelationFamilyQueryExited => {
1741 family_query_active.set(false);
1742 }
1743 SqliteReadProgressEvent::CallbackEntered { stage }
1744 if family_query_active.get() && !callback_entered_live.get() =>
1745 {
1746 if observer_control.check(stage).is_ok() {
1747 callback_entered_live.set(true);
1748 while observer_control.check(stage).is_ok() {
1749 std::thread::sleep(Duration::from_millis(1));
1750 }
1751 }
1752 }
1753 SqliteReadProgressEvent::CallbackEvaluated {
1754 interrupted: true, ..
1755 } if family_query_active.get() && callback_entered_live.get() => {
1756 callback_interrupted.set(true);
1757 }
1758 _ => {}
1759 }
1760 },
1761 || load_federated_detailed_relations(stores, &query, Some(&control)),
1762 );
1763 require(
1764 matches!(
1765 deadline,
1766 Err(ServiceError::Db(DbError::IndexWork(
1767 projectatlas_core::IndexWorkFailure::DeadlineExceeded {
1768 stage: IndexWorkStage::RepositoryTraversal
1769 }
1770 )))
1771 ),
1772 "active rendezvous query was not interrupted with its typed deadline",
1773 )?;
1774 require(
1775 family_query_entered_live.get()
1776 && callback_entered_live.get()
1777 && callback_interrupted.get()
1778 && !family_query_active.get(),
1779 "rendezvous deadline did not enter a live family query and interrupt it through SQLite",
1780 )?;
1781 for (index, (_, database)) in participants.iter().enumerate() {
1782 let moved = database.with_extension(format!("deadline-closed-{index}"));
1783 fs::rename(database, &moved)?;
1784 fs::rename(moved, database)?;
1785 }
1786 Ok(())
1787 }
1788
1789 fn publish_fixture(
1791 root: &Path,
1792 database: &Path,
1793 generation: IndexGeneration,
1794 unrelated_relations: usize,
1795 ) -> Result<(), Box<dyn Error>> {
1796 fs::create_dir_all(root.join("src"))?;
1797 fs::write(root.join("src/same.rs"), "pub fn same() {}\n")?;
1798 fs::write(root.join("src/unrelated.rs"), "pub fn unrelated() {}\n")?;
1799 let mut store = AtlasStore::open_for_project(database, root)?;
1800 let project = store
1801 .project_instance_id()?
1802 .ok_or("federation fixture project identity is missing")?;
1803 let source = GraphEntity::new(
1804 project,
1805 EntitySelector::File {
1806 path: RepositoryFilePath::new(Path::new("src/same.rs"))?,
1807 },
1808 generation,
1809 )?;
1810 let unrelated_source = GraphEntity::new(
1811 project,
1812 EntitySelector::File {
1813 path: RepositoryFilePath::new(Path::new("src/unrelated.rs"))?,
1814 },
1815 generation,
1816 )?;
1817 let mut entities = vec![source.clone(), unrelated_source.clone()];
1818 let mut relations = Vec::new();
1819 for identity in ["package/a", "package/b", "package/c"] {
1820 let external = GraphEntity::new(
1821 project,
1822 EntitySelector::External {
1823 external: ExternalSelector {
1824 system: GraphIdentityText::new("registry.example")?,
1825 identity: GraphIdentityText::new(identity)?,
1826 },
1827 },
1828 generation,
1829 )?;
1830 relations.push(LogicalRelation::new(
1831 &source,
1832 GraphRelationKind::Legacy(RelationKind::Imports),
1833 RelationResolution::external(&external)?,
1834 projectatlas_core::graph::ConfidenceClass::Exact,
1835 Completeness::Complete,
1836 generation,
1837 )?);
1838 entities.push(external);
1839 }
1840 for index in 0..unrelated_relations {
1841 let identity = if unrelated_relations == 1 {
1842 "package/unrelated".to_string()
1843 } else {
1844 format!("package/unrelated/{index:05}")
1845 };
1846 let unrelated_external = GraphEntity::new(
1847 project,
1848 EntitySelector::External {
1849 external: ExternalSelector {
1850 system: GraphIdentityText::new("registry.example")?,
1851 identity: GraphIdentityText::new(identity)?,
1852 },
1853 },
1854 generation,
1855 )?;
1856 relations.push(LogicalRelation::new(
1857 &unrelated_source,
1858 GraphRelationKind::Legacy(RelationKind::Imports),
1859 RelationResolution::external(&unrelated_external)?,
1860 projectatlas_core::graph::ConfidenceClass::Exact,
1861 Completeness::Complete,
1862 generation,
1863 )?);
1864 entities.push(unrelated_external);
1865 }
1866 let mut publication = store.begin_index_publication("federation-fixture")?;
1867 publication.begin_scan_replacement()?;
1868 publication.upsert_scan_node_batch(&[
1869 fixture_folder_node("src"),
1870 fixture_file_node("src/same.rs"),
1871 fixture_file_node("src/unrelated.rs"),
1872 ])?;
1873 publication.upsert_file_content_classification_batch(&[
1874 projectatlas_db::FileContentClassification {
1875 path: "src/same.rs".to_string(),
1876 classification: ContentClassification::Source,
1877 },
1878 projectatlas_db::FileContentClassification {
1879 path: "src/unrelated.rs".to_string(),
1880 classification: ContentClassification::Documentation,
1881 },
1882 ])?;
1883 publication.finish_scan_replacement()?;
1884 publication.replace_repository_graph(project, &entities, &relations, &[], &[])?;
1885 publication.complete()?;
1886 Ok(())
1887 }
1888
1889 fn open_participants(
1891 participants: &[(PathBuf, PathBuf)],
1892 ) -> Result<Vec<FederatedStore>, Box<dyn Error>> {
1893 participants
1894 .iter()
1895 .map(|(root, database)| {
1896 Ok(FederatedStore::new(
1897 AtlasStore::open_read_only_for_project(database, root)?,
1898 database.clone(),
1899 root.clone(),
1900 FederatedInputWork::default(),
1901 )?)
1902 })
1903 .collect()
1904 }
1905
1906 fn relation_query(
1908 cursor: Option<String>,
1909 resolution: RelationResolutionFilter,
1910 direction: RelationDirection,
1911 ) -> Result<DetailedRelationQuery, Box<dyn Error>> {
1912 relation_query_with_selection(
1913 cursor,
1914 resolution,
1915 direction,
1916 ContentSelection::UnspecifiedLegacy,
1917 )
1918 }
1919
1920 fn relation_query_with_selection(
1922 cursor: Option<String>,
1923 resolution: RelationResolutionFilter,
1924 direction: RelationDirection,
1925 content_selection: ContentSelection,
1926 ) -> Result<DetailedRelationQuery, Box<dyn Error>> {
1927 Ok(DetailedRelationQuery {
1928 anchor: RelationAnchor::File {
1929 file: RepositoryFilePath::new(Path::new("src/same.rs"))?,
1930 },
1931 direction,
1932 relation: Some(GraphRelationKind::Legacy(RelationKind::Imports)),
1933 minimum_confidence: projectatlas_core::graph::ConfidenceClass::Low,
1934 resolution,
1935 include_occurrences: false,
1936 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
1937 2,
1938 1,
1939 1,
1940 512 * 1_024,
1941 )?)
1942 .with_aggregate_limits(Some(100), None, None, None, None, None)?,
1943 cursor,
1944 content_selection,
1945 })
1946 }
1947
1948 fn fixture_file_node(path: &str) -> Node {
1950 Node {
1951 path: path.to_string(),
1952 kind: NodeKind::File,
1953 parent_path: Some("src".to_string()),
1954 extension: Some("rs".to_string()),
1955 language: Some("Rust".to_string()),
1956 size_bytes: Some(17),
1957 mtime_ns: Some(1),
1958 content_hash: Some("fixture-hash".to_string()),
1959 }
1960 }
1961
1962 fn fixture_folder_node(path: &str) -> Node {
1964 Node {
1965 path: path.to_string(),
1966 kind: NodeKind::Folder,
1967 parent_path: Some(".".to_string()),
1968 extension: None,
1969 language: None,
1970 size_bytes: None,
1971 mtime_ns: Some(1),
1972 content_hash: None,
1973 }
1974 }
1975
1976 fn require(condition: bool, message: &str) -> Result<(), io::Error> {
1978 condition
1979 .then_some(())
1980 .ok_or_else(|| io::Error::other(message))
1981 }
1982}