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, selected_project_binding};
10use projectatlas_core::graph::{
11 ExtendedRelationKind, ExternalSelector, GraphLimitKind, GraphRelationKind, LogicalRelation,
12 ProjectInstanceId, RelationResolution,
13};
14use projectatlas_core::symbols::RelationKind;
15use projectatlas_core::{
16 IndexGeneration, IndexWorkControl, IndexWorkStage, normalize_native_path_display,
17};
18use projectatlas_db::{AtlasStore, DbError, RepositoryGraphRelationQuery};
19use serde::{Deserialize, Serialize};
20use std::collections::{BTreeMap, BTreeSet};
21use std::fs;
22use std::path::PathBuf;
23use std::time::{Duration, Instant};
24
25const MIN_FEDERATED_ROOTS: usize = 2;
27const MAX_FEDERATED_ROOTS: usize = 8;
29pub const MAX_FEDERATED_DATABASE_BYTES: u64 = 64 * 1_024 * 1_024 * 1_024;
31pub const MAX_FEDERATED_INPUT_BYTES: u64 = 16 * 1_024 * 1_024 * 1_024;
33const MAX_FEDERATED_CLOSE_MS: u64 = 1_000;
35
36const FEDERATED_CURSOR_VERSION: u16 = 1;
38const FEDERATED_CURSOR_MAX_BYTES: usize = 128 * 1_024;
40const FEDERATED_ROOT_DIGEST_DOMAIN: &str = "projectatlas:federated-root:v1";
42
43const FEDERATED_RENDEZVOUS_RELATIONS: [GraphRelationKind; 6] = [
45 GraphRelationKind::Legacy(RelationKind::Imports),
46 GraphRelationKind::Legacy(RelationKind::Calls),
47 GraphRelationKind::Legacy(RelationKind::DependsOn),
48 GraphRelationKind::Extended(ExtendedRelationKind::RoutesTo),
49 GraphRelationKind::Extended(ExtendedRelationKind::Configures),
50 GraphRelationKind::Extended(ExtendedRelationKind::Deploys),
51];
52
53pub fn validate_federated_root_count(count: usize) -> ServiceResult<()> {
59 if (MIN_FEDERATED_ROOTS..=MAX_FEDERATED_ROOTS).contains(&count) {
60 Ok(())
61 } else {
62 Err(ServiceError::InvalidInput(format!(
63 "federation requires {MIN_FEDERATED_ROOTS}..={MAX_FEDERATED_ROOTS} explicit ordered roots"
64 )))
65 }
66}
67
68#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize)]
70pub struct FederatedInputWork {
71 pub filesystem_entries: u64,
73 pub filesystem_bytes: u64,
75 pub sqlite_read_statements: u64,
77 pub decoded_nodes: u64,
79 pub elapsed_ms: u64,
81}
82
83impl FederatedInputWork {
84 fn checked_add(self, other: Self) -> ServiceResult<Self> {
86 Ok(Self {
87 filesystem_entries: checked_sum(
88 self.filesystem_entries,
89 other.filesystem_entries,
90 "federated filesystem-entry work",
91 )?,
92 filesystem_bytes: checked_sum(
93 self.filesystem_bytes,
94 other.filesystem_bytes,
95 "federated input bytes",
96 )?,
97 sqlite_read_statements: checked_sum(
98 self.sqlite_read_statements,
99 other.sqlite_read_statements,
100 "federated freshness statements",
101 )?,
102 decoded_nodes: checked_sum(
103 self.decoded_nodes,
104 other.decoded_nodes,
105 "federated freshness nodes",
106 )?,
107 elapsed_ms: checked_sum(
108 self.elapsed_ms,
109 other.elapsed_ms,
110 "federated freshness time",
111 )?,
112 })
113 }
114}
115
116pub struct FederatedStore {
118 store: AtlasStore,
120 database_path: PathBuf,
122 root: PathBuf,
124 database_bytes: u64,
126 input_work: FederatedInputWork,
128}
129
130impl FederatedStore {
131 pub fn new(
138 store: AtlasStore,
139 database_path: PathBuf,
140 root: PathBuf,
141 input_work: FederatedInputWork,
142 ) -> ServiceResult<Self> {
143 if !store.is_read_only() || !store.has_active_read_snapshot() {
144 return Err(ServiceError::InvalidInput(
145 "federation accepts only read-only stores with active snapshots".to_string(),
146 ));
147 }
148 let binding = selected_project_binding(&store)?;
149 if binding.project_root != normalize_native_path_display(&root) {
150 return Err(ServiceError::InvalidInput(
151 "federated store does not match its explicit root".to_string(),
152 ));
153 }
154 let metadata = fs::metadata(&database_path).map_err(|source| ServiceError::Io {
155 path: database_path.clone(),
156 source,
157 })?;
158 if !metadata.is_file() {
159 return Err(ServiceError::InvalidInput(
160 "federated database path is not a regular file".to_string(),
161 ));
162 }
163 Ok(Self {
164 store,
165 database_path,
166 root,
167 database_bytes: metadata.len(),
168 input_work,
169 })
170 }
171
172 #[must_use]
174 pub const fn store(&self) -> &AtlasStore {
175 &self.store
176 }
177
178 pub fn finish(self) -> ServiceResult<()> {
184 self.store.finish_index_read_snapshot().map_err(Into::into)
185 }
186}
187
188#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
190pub struct FederatedParticipant {
191 pub order: u32,
193 pub project: ProjectInstanceId,
195 pub generation: IndexGeneration,
197 pub authored_purpose_revision: u64,
199}
200
201#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
203pub struct FederatedRelationEvidence {
204 pub project: ProjectInstanceId,
206 pub generation: IndexGeneration,
208 pub source: projectatlas_core::graph::GraphEntity,
210 pub relation: LogicalRelation,
212}
213
214#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
216pub struct FederatedRendezvous {
217 pub relation: GraphRelationKind,
219 pub external: ExternalSelector,
221 pub evidence: Vec<FederatedRelationEvidence>,
223}
224
225#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
227pub struct FederatedRelationWork {
228 pub input: FederatedInputWork,
230 pub participating_database_bytes: u64,
232 pub simultaneously_open_snapshots: u32,
234 pub rendezvous_database_rows: u64,
236 pub rendezvous_database_bytes: u64,
238 pub rendezvous_relations: u32,
240 pub intermediate_bytes: u64,
242 pub close_ms: u64,
244 pub elapsed_ms: u64,
246 pub rendered_output_bytes: u64,
248}
249
250#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
252pub struct FederatedDetailedRelationReport {
253 pub participants: Vec<FederatedParticipant>,
255 pub primary: DetailedRelationReport,
257 pub rendezvous: Vec<FederatedRendezvous>,
259 pub truncated: bool,
261 pub reached_limits: Vec<GraphLimitKind>,
263 pub work: FederatedRelationWork,
265}
266
267#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
269pub struct FederatedAnalysisReport {
270 pub participants: Vec<FederatedParticipant>,
272 pub primary: RelationAnalysisReport,
274 pub rendezvous: Vec<FederatedRendezvous>,
276 pub truncated: bool,
278 pub reached_limits: Vec<GraphLimitKind>,
280 pub work: FederatedRelationWork,
282}
283
284pub struct FederatedDetailedRelationDraft {
286 primary: DetailedRelationPageDraft,
288 context: FederatedContext,
290}
291
292impl FederatedDetailedRelationDraft {
293 pub fn fit_output<F, E>(
300 self,
301 control: Option<&IndexWorkControl>,
302 mut encode: F,
303 ) -> Result<(FederatedDetailedRelationReport, String), E>
304 where
305 F: FnMut(&FederatedDetailedRelationReport) -> Result<String, E>,
306 E: From<ServiceError>,
307 {
308 let context = self.context;
309 let (primary, encoded) = self.primary.fit_output(control, |primary| {
310 let report = context.detailed_report(primary.clone()).map_err(E::from)?;
311 encode(&report)
312 })?;
313 let report = context.detailed_report(primary).map_err(E::from)?;
314 Ok((report, encoded))
315 }
316}
317
318pub struct FederatedAnalysisDraft {
320 primary: RelationAnalysisDraft,
322 context: FederatedContext,
324}
325
326impl FederatedAnalysisDraft {
327 pub fn fit_output<F, E, O>(self, mut encode: F) -> Result<(FederatedAnalysisReport, O), E>
334 where
335 F: FnMut(&FederatedAnalysisReport, &IndexWorkControl) -> Result<O, E>,
336 E: From<ServiceError>,
337 O: AsRef<[u8]>,
338 {
339 let context = self.context;
340 let control = self.primary.control().clone();
341 let (primary, encoded) = self.primary.fit_output(|primary, control| {
342 let report = context
343 .analysis_report(primary.clone(), Some(control))
344 .map_err(E::from)?;
345 encode(&report, control)
346 })?;
347 check_control(Some(&control)).map_err(E::from)?;
348 let report = context
349 .analysis_report(primary, Some(&control))
350 .map_err(E::from)?;
351 Ok((report, encoded))
352 }
353}
354
355#[derive(Clone)]
357struct FederatedContext {
358 participants: Vec<FederatedParticipant>,
360 cursor_participants: Vec<FederatedCursorParticipant>,
362 rendezvous: Vec<FederatedRendezvous>,
364 rendezvous_limits: Vec<GraphLimitKind>,
366 base_work: FederatedRelationWork,
368 rendezvous_identity_bytes: u64,
370 budget: DetailedRelationBudget,
372}
373
374impl FederatedContext {
375 fn detailed_report(
377 &self,
378 mut primary: DetailedRelationReport,
379 ) -> ServiceResult<FederatedDetailedRelationReport> {
380 primary.continuation = wrap_continuation(
381 primary.continuation.as_deref(),
382 &self.cursor_participants,
383 self.budget,
384 )?;
385 let mut reached_limits = primary.reached_limits.clone();
386 for limit in &self.rendezvous_limits {
387 push_limit(&mut reached_limits, *limit);
388 }
389 let mut work = self.base_work.clone();
390 work.rendered_output_bytes = primary.work.rendered_output_bytes;
391 work.intermediate_bytes = federated_intermediate_bytes(
392 primary
393 .work
394 .intermediate_bytes
395 .saturating_add(self.rendezvous_identity_bytes),
396 &self.participants,
397 &self.rendezvous,
398 primary.continuation.as_deref(),
399 self.budget,
400 )?;
401 Ok(FederatedDetailedRelationReport {
402 participants: self.participants.clone(),
403 truncated: primary.truncated || !self.rendezvous_limits.is_empty(),
404 primary,
405 rendezvous: self.rendezvous.clone(),
406 reached_limits,
407 work,
408 })
409 }
410
411 fn analysis_report(
413 &self,
414 mut primary: RelationAnalysisReport,
415 control: Option<&IndexWorkControl>,
416 ) -> ServiceResult<FederatedAnalysisReport> {
417 check_control(control)?;
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 check_control(control)?;
426 push_limit(&mut reached_limits, *limit);
427 }
428 let mut work = self.base_work.clone();
429 work.rendered_output_bytes = primary.work.rendered_output_bytes;
430 work.intermediate_bytes = federated_intermediate_bytes(
431 primary
432 .work
433 .peak_intermediate_bytes
434 .saturating_add(self.rendezvous_identity_bytes),
435 &self.participants,
436 &self.rendezvous,
437 primary.continuation.as_deref(),
438 self.budget,
439 )?;
440 check_control(control)?;
441 Ok(FederatedAnalysisReport {
442 participants: self.participants.clone(),
443 truncated: primary.truncated || !self.rendezvous_limits.is_empty(),
444 primary,
445 rendezvous: self.rendezvous.clone(),
446 reached_limits,
447 work,
448 })
449 }
450}
451
452#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
454#[serde(deny_unknown_fields)]
455struct FederatedCursor {
456 version: u16,
458 participants: Vec<FederatedCursorParticipant>,
460 inner: String,
462}
463
464#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
466#[serde(deny_unknown_fields)]
467struct FederatedCursorParticipant {
468 project: ProjectInstanceId,
470 root_digest: [u8; 32],
472 generation: IndexGeneration,
474 authored_purpose_revision: u64,
476}
477
478struct CapturedFederation {
480 participants: Vec<FederatedParticipant>,
482 cursor_participants: Vec<FederatedCursorParticipant>,
484 input_work: FederatedInputWork,
486 database_bytes: u64,
488}
489
490struct RendezvousLoad {
492 rows: Vec<FederatedRendezvous>,
494 database_rows: u64,
496 database_bytes: u64,
498 relation_rows: u32,
500 reached_limits: Vec<GraphLimitKind>,
502}
503
504struct ClosedParticipant {
506 database_path: PathBuf,
508 root: PathBuf,
510 cursor: FederatedCursorParticipant,
512}
513
514pub fn load_federated_detailed_relations(
521 stores: Vec<FederatedStore>,
522 query: &DetailedRelationQuery,
523 control: Option<&IndexWorkControl>,
524) -> ServiceResult<FederatedDetailedRelationDraft> {
525 let started = Instant::now();
526 let budget = query.budget;
527 let captured = capture_federation(&stores, control)?;
528 let mut primary_query = query.clone();
529 primary_query.cursor = decode_continuation(
530 query.cursor.as_deref(),
531 &captured.cursor_participants,
532 budget,
533 )?;
534 let operation: ServiceResult<(DetailedRelationPageDraft, RendezvousLoad, u64)> = (|| {
535 check_control(control)?;
536 let primary = super::relations::load_detailed_relation_page(
537 stores[0].store(),
538 &primary_query,
539 control,
540 )?;
541 let candidate = primary.report_for_prefix(primary.candidate_rows())?;
542 let rendezvous_identities = external_relation_identities(&candidate);
543 let rendezvous_identity_bytes = serialized_equivalent_bytes(&rendezvous_identities)?;
544 let primary_edges = u64::from(candidate.work.inspected_edges);
545 let primary_rows = u64::from(candidate.work.database_returned_rows);
546 let remaining_edges = u64::from(budget.edges()).saturating_sub(primary_edges);
547 let rendezvous = load_rendezvous(
548 &stores,
549 query,
550 &rendezvous_identities,
551 remaining_edges,
552 aggregate_row_limit(budget).saturating_sub(primary_rows),
553 budget
554 .intermediate_bytes()
555 .saturating_sub(candidate.work.intermediate_bytes)
556 .saturating_sub(rendezvous_identity_bytes),
557 started,
558 control,
559 )?;
560 Ok((primary, rendezvous, rendezvous_identity_bytes))
561 })();
562 let (closed, close_ms, close_error) = close_participants(stores);
563 let (primary, rendezvous, rendezvous_identity_bytes) = operation?;
564 if let Some(error) = close_error {
565 return Err(error);
566 }
567 revalidate_participants(&closed, control)?;
568 let base_work = base_work(&captured, &rendezvous, close_ms, elapsed_ms(started))?;
569 Ok(FederatedDetailedRelationDraft {
570 primary,
571 context: FederatedContext {
572 participants: captured.participants,
573 cursor_participants: captured.cursor_participants,
574 rendezvous: rendezvous.rows,
575 rendezvous_limits: rendezvous.reached_limits,
576 base_work,
577 rendezvous_identity_bytes,
578 budget,
579 },
580 })
581}
582
583pub fn load_federated_relation_analysis(
590 stores: Vec<FederatedStore>,
591 query: &RelationAnalysisQuery,
592 control: Option<&IndexWorkControl>,
593) -> ServiceResult<FederatedAnalysisDraft> {
594 let started = Instant::now();
595 let budget = query.relations.budget;
596 let captured = capture_federation(&stores, control)?;
597 let mut primary_query = query.clone();
598 primary_query.relations.cursor = decode_continuation(
599 query.relations.cursor.as_deref(),
600 &captured.cursor_participants,
601 budget,
602 )?;
603 let operation: ServiceResult<(RelationAnalysisDraft, RendezvousLoad)> = (|| {
604 check_control(control)?;
605 let mut primary = super::analysis::load_relation_analysis_for_federation(
606 stores[0].store(),
607 &primary_query,
608 control,
609 )?;
610 let rendezvous_identities = primary.take_external_relation_identities();
611 let candidate = primary.candidate_report();
612 let primary_edges = u64::from(candidate.work.relations.inspected_edges)
613 .saturating_add(u64::from(candidate.work.closure_inspected_edges));
614 let primary_rows = u64::from(candidate.work.relations.database_returned_rows)
615 .saturating_add(u64::from(candidate.work.closure_inspected_edges));
616 let remaining_edges = u64::from(budget.edges()).saturating_sub(primary_edges);
617 let rendezvous = load_rendezvous(
618 &stores,
619 &query.relations,
620 &rendezvous_identities,
621 remaining_edges,
622 aggregate_row_limit(budget).saturating_sub(primary_rows),
623 budget
624 .intermediate_bytes()
625 .saturating_sub(candidate.work.peak_intermediate_bytes),
626 started,
627 control,
628 )?;
629 Ok((primary, rendezvous))
630 })();
631 let (closed, close_ms, close_error) = close_participants(stores);
632 let (primary, rendezvous) = 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(FederatedAnalysisDraft {
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: 0,
647 budget,
648 },
649 })
650}
651
652fn capture_federation(
654 stores: &[FederatedStore],
655 control: Option<&IndexWorkControl>,
656) -> ServiceResult<CapturedFederation> {
657 validate_federated_root_count(stores.len())?;
658 let mut roots = BTreeSet::new();
659 let mut projects = Vec::new();
660 let mut participants = Vec::with_capacity(stores.len());
661 let mut cursor_participants = Vec::with_capacity(stores.len());
662 let mut input_work = FederatedInputWork::default();
663 let mut database_bytes = 0_u64;
664 for (order, participant) in stores.iter().enumerate() {
665 check_control(control)?;
666 if !participant.store.is_read_only() || !participant.store.has_active_read_snapshot() {
667 return Err(ServiceError::InvalidInput(
668 "federated participant lost its read-only snapshot".to_string(),
669 ));
670 }
671 let binding = selected_project_binding(&participant.store)?;
672 let root_digest = federated_root_digest(&binding.project_root);
673 if !roots.insert(root_digest) || projects.contains(&binding.project_instance_id) {
674 return Err(ServiceError::InvalidInput(
675 "federated roots or project identities must be unique".to_string(),
676 ));
677 }
678 projects.push(binding.project_instance_id);
679 let generation = participant
680 .store
681 .repository_graph_generation()?
682 .ok_or_else(|| {
683 ServiceError::InvalidInput(
684 "federated root has no complete repository graph generation".to_string(),
685 )
686 })?;
687 let authored_purpose_revision = participant.store.authored_purpose_revision()?;
688 let order = u32::try_from(order).map_err(|_overflow| {
689 ServiceError::InvalidInput("federated root order overflowed".to_string())
690 })?;
691 participants.push(FederatedParticipant {
692 order,
693 project: binding.project_instance_id,
694 generation,
695 authored_purpose_revision,
696 });
697 cursor_participants.push(FederatedCursorParticipant {
698 project: binding.project_instance_id,
699 root_digest,
700 generation,
701 authored_purpose_revision,
702 });
703 input_work = input_work.checked_add(participant.input_work)?;
704 database_bytes = checked_sum(
705 database_bytes,
706 participant.database_bytes,
707 "participating database bytes",
708 )?;
709 if input_work.filesystem_bytes > MAX_FEDERATED_INPUT_BYTES {
710 return Err(ServiceError::InvalidInput(format!(
711 "federated source verification exceeds {MAX_FEDERATED_INPUT_BYTES} bytes"
712 )));
713 }
714 if database_bytes > MAX_FEDERATED_DATABASE_BYTES {
715 return Err(ServiceError::InvalidInput(format!(
716 "participating databases exceed {MAX_FEDERATED_DATABASE_BYTES} bytes"
717 )));
718 }
719 }
720 Ok(CapturedFederation {
721 participants,
722 cursor_participants,
723 input_work,
724 database_bytes,
725 })
726}
727
728fn load_rendezvous(
730 stores: &[FederatedStore],
731 query: &DetailedRelationQuery,
732 identities: &BTreeSet<ExternalRelationIdentity>,
733 mut remaining_edges: u64,
734 mut remaining_rows: u64,
735 mut remaining_intermediate_bytes: u64,
736 started: Instant,
737 control: Option<&IndexWorkControl>,
738) -> ServiceResult<RendezvousLoad> {
739 if identities.is_empty()
740 || !matches!(
741 query.resolution,
742 super::relations::RelationResolutionFilter::Any
743 | super::relations::RelationResolutionFilter::External
744 )
745 {
746 return Ok(RendezvousLoad {
747 rows: Vec::new(),
748 database_rows: 0,
749 database_bytes: 0,
750 relation_rows: 0,
751 reached_limits: Vec::new(),
752 });
753 }
754 let deadline = started
755 .checked_add(Duration::from_millis(query.budget.deadline_ms()))
756 .unwrap_or(started);
757 let request_control = relation_request_control(control, deadline);
758 let control = Some(&request_control);
759 let families = query.relation.map_or_else(
760 || FEDERATED_RENDEZVOUS_RELATIONS.to_vec(),
761 |relation| vec![relation],
762 );
763 let mut groups: BTreeMap<(String, String, String), FederatedRendezvous> = BTreeMap::new();
764 let mut database_rows = 0_u64;
765 let mut database_bytes = 0_u64;
766 let mut reached_limits = Vec::new();
767 let mut queries_left = stores.len().saturating_mul(families.len());
768 'queries: for family in families {
769 for participant in stores {
770 if elapsed_ms(started) >= query.budget.deadline_ms() {
771 push_limit(&mut reached_limits, GraphLimitKind::Deadline);
772 break 'queries;
773 }
774 check_control(control)?;
775 if remaining_edges == 0 {
776 push_limit(&mut reached_limits, GraphLimitKind::Edges);
777 break 'queries;
778 }
779 if remaining_rows == 0 {
780 push_limit(&mut reached_limits, GraphLimitKind::Rows);
781 break 'queries;
782 }
783 if remaining_intermediate_bytes == 0 {
784 push_limit(&mut reached_limits, GraphLimitKind::IntermediateBytes);
785 break 'queries;
786 }
787 let fair_share = remaining_edges
788 .div_ceil(u64::try_from(queries_left).unwrap_or(u64::MAX))
789 .max(1)
790 .min(u64::from(projectatlas_core::graph::GraphLimits::MAX_ROWS));
791 let limit = u32::try_from(fair_share).map_err(|_overflow| {
792 ServiceError::InvalidInput("federated relation page limit overflowed".to_string())
793 })?;
794 let page = participant.store.repository_graph_relation_rows(
795 RepositoryGraphRelationQuery::Family { relation: family },
796 limit,
797 control,
798 )?;
799 queries_left = queries_left.saturating_sub(1);
800 let decoded_rows = u64::try_from(page.rows.len()).map_err(|_overflow| {
801 ServiceError::InvalidInput("federated database row count overflowed".to_string())
802 })?;
803 let inspected_rows = decoded_rows.saturating_add(u64::from(page.truncated));
804 database_rows = checked_sum(database_rows, inspected_rows, "federated database rows")?;
805 if inspected_rows > remaining_rows {
806 return Err(ServiceError::InvalidInput(
807 "federated database row budget was exhausted".to_string(),
808 ));
809 }
810 remaining_rows = remaining_rows.saturating_sub(inspected_rows);
811 remaining_edges = remaining_edges.saturating_sub(decoded_rows);
812 if page.truncated {
813 push_limit(&mut reached_limits, GraphLimitKind::Edges);
814 }
815 for row in page.rows {
816 let encoded_bytes = serialized_equivalent_bytes(&(&row.source, &row.relation))?;
817 if encoded_bytes > remaining_intermediate_bytes {
818 push_limit(&mut reached_limits, GraphLimitKind::IntermediateBytes);
819 break 'queries;
820 }
821 remaining_intermediate_bytes =
822 remaining_intermediate_bytes.saturating_sub(encoded_bytes);
823 database_bytes =
824 checked_sum(database_bytes, encoded_bytes, "federated decoded bytes")?;
825 if !super::relations::relation_matches(&row.relation, query) {
826 continue;
827 }
828 let RelationResolution::External { external, .. } = row.relation.resolution()
829 else {
830 continue;
831 };
832 let key = (
833 family.as_str().to_string(),
834 external.system.as_str().to_string(),
835 external.identity.as_str().to_string(),
836 );
837 if !identities.contains(&key) {
838 continue;
839 }
840 let project = row.relation.key().project();
841 let generation = row.relation.generation();
842 let group = groups.entry(key).or_insert_with(|| FederatedRendezvous {
843 relation: family,
844 external: external.clone(),
845 evidence: Vec::new(),
846 });
847 group.evidence.push(FederatedRelationEvidence {
848 project,
849 generation,
850 source: row.source,
851 relation: row.relation,
852 });
853 }
854 }
855 }
856 let rows = groups
857 .into_values()
858 .filter(|group| {
859 let mut projects = Vec::new();
860 for evidence in &group.evidence {
861 if !projects.contains(&evidence.project) {
862 projects.push(evidence.project);
863 }
864 }
865 projects.len() >= 2
866 })
867 .collect::<Vec<_>>();
868 let relation_rows = u32::try_from(rows.iter().map(|row| row.evidence.len()).sum::<usize>())
869 .map_err(|_overflow| {
870 ServiceError::InvalidInput("federated rendezvous count overflowed".to_string())
871 })?;
872 Ok(RendezvousLoad {
873 rows,
874 database_rows,
875 database_bytes,
876 relation_rows,
877 reached_limits,
878 })
879}
880
881fn close_participants(
883 stores: Vec<FederatedStore>,
884) -> (Vec<ClosedParticipant>, u64, Option<ServiceError>) {
885 let started = Instant::now();
886 let mut closed = Vec::with_capacity(stores.len());
887 let mut first_error = None;
888 for participant in stores {
889 let binding = participant.store.captured_project_binding();
890 let generation = participant.store.repository_graph_generation();
891 let purpose_revision = participant.store.authored_purpose_revision();
892 if let Err(error) = participant.store.finish_index_read_snapshot()
893 && first_error.is_none()
894 {
895 first_error = Some(ServiceError::Db(error));
896 }
897 drop(participant.store);
898 match (binding, generation, purpose_revision) {
899 (Ok(binding), Ok(Some(generation)), Ok(authored_purpose_revision)) => {
900 closed.push(ClosedParticipant {
901 database_path: participant.database_path,
902 root: participant.root,
903 cursor: FederatedCursorParticipant {
904 project: binding.project_instance_id,
905 root_digest: federated_root_digest(&binding.project_root),
906 generation,
907 authored_purpose_revision,
908 },
909 });
910 }
911 (Err(error), _, _) | (_, Err(error), _) | (_, _, Err(error))
912 if first_error.is_none() =>
913 {
914 first_error = Some(ServiceError::Db(error));
915 }
916 _ => {
917 if first_error.is_none() {
918 first_error = Some(ServiceError::InvalidInput(
919 "federated participant lost its graph generation before close".to_string(),
920 ));
921 }
922 }
923 }
924 }
925 let elapsed = elapsed_ms(started);
926 if elapsed > MAX_FEDERATED_CLOSE_MS && first_error.is_none() {
927 first_error = Some(ServiceError::InvalidInput(format!(
928 "federated snapshots took {elapsed} ms to close; limit is {MAX_FEDERATED_CLOSE_MS} ms"
929 )));
930 }
931 (closed, elapsed, first_error)
932}
933
934fn revalidate_participants(
936 participants: &[ClosedParticipant],
937 control: Option<&IndexWorkControl>,
938) -> ServiceResult<()> {
939 for participant in participants {
940 check_control(control)?;
941 let store =
942 AtlasStore::open_read_only_for_project(&participant.database_path, &participant.root)?;
943 let binding = selected_project_binding(&store)?;
944 let generation = store.repository_graph_generation()?.ok_or_else(|| {
945 ServiceError::InvalidInput(
946 "federated root lost its complete graph generation".to_string(),
947 )
948 })?;
949 let purpose_revision = store.authored_purpose_revision()?;
950 let current = FederatedCursorParticipant {
951 project: binding.project_instance_id,
952 root_digest: federated_root_digest(&binding.project_root),
953 generation,
954 authored_purpose_revision: purpose_revision,
955 };
956 store.finish_index_read_snapshot()?;
957 drop(store);
958 if current.project != participant.cursor.project
959 || current.root_digest != participant.cursor.root_digest
960 {
961 return Err(ServiceError::RelationCursorStale {
962 field: "federated project binding",
963 });
964 }
965 if current.generation != participant.cursor.generation {
966 return Err(ServiceError::RelationCursorStale {
967 field: "federated graph generation",
968 });
969 }
970 if current.authored_purpose_revision != participant.cursor.authored_purpose_revision {
971 return Err(ServiceError::RelationCursorStale {
972 field: "federated authored-purpose revision",
973 });
974 }
975 }
976 Ok(())
977}
978
979fn base_work(
981 captured: &CapturedFederation,
982 rendezvous: &RendezvousLoad,
983 close_ms: u64,
984 elapsed_ms: u64,
985) -> ServiceResult<FederatedRelationWork> {
986 Ok(FederatedRelationWork {
987 input: captured.input_work,
988 participating_database_bytes: captured.database_bytes,
989 simultaneously_open_snapshots: u32::try_from(captured.participants.len()).map_err(
990 |_overflow| {
991 ServiceError::InvalidInput("federated open-snapshot count overflowed".to_string())
992 },
993 )?,
994 rendezvous_database_rows: rendezvous.database_rows,
995 rendezvous_database_bytes: rendezvous.database_bytes,
996 rendezvous_relations: rendezvous.relation_rows,
997 intermediate_bytes: 0,
998 close_ms,
999 elapsed_ms: captured.input_work.elapsed_ms.saturating_add(elapsed_ms),
1000 rendered_output_bytes: 0,
1001 })
1002}
1003
1004fn decode_continuation(
1006 encoded: Option<&str>,
1007 expected: &[FederatedCursorParticipant],
1008 budget: DetailedRelationBudget,
1009) -> ServiceResult<Option<String>> {
1010 let Some(encoded) = encoded else {
1011 return Ok(None);
1012 };
1013 if encoded.is_empty()
1014 || encoded.len() > FEDERATED_CURSOR_MAX_BYTES
1015 || encoded.len() > budget.intermediate_bytes() as usize
1016 {
1017 return Err(ServiceError::RelationCursorInvalid {
1018 reason: "federated cursor length is empty or above the product ceiling",
1019 });
1020 }
1021 let cursor: FederatedCursor =
1022 serde_json::from_str(encoded).map_err(|_source| ServiceError::RelationCursorInvalid {
1023 reason: "federated cursor JSON is malformed or contains unknown fields",
1024 })?;
1025 if cursor.version != FEDERATED_CURSOR_VERSION {
1026 return Err(ServiceError::RelationCursorStale {
1027 field: "federated cursor version",
1028 });
1029 }
1030 if cursor.participants.len() != expected.len() {
1031 return Err(ServiceError::RelationCursorStale {
1032 field: "federated roots",
1033 });
1034 }
1035 for (actual, expected) in cursor.participants.iter().zip(expected) {
1036 if actual.project != expected.project || actual.root_digest != expected.root_digest {
1037 return Err(ServiceError::RelationCursorStale {
1038 field: "federated roots",
1039 });
1040 }
1041 if actual.generation != expected.generation {
1042 return Err(ServiceError::RelationCursorStale {
1043 field: "federated graph generation",
1044 });
1045 }
1046 if actual.authored_purpose_revision != expected.authored_purpose_revision {
1047 return Err(ServiceError::RelationCursorStale {
1048 field: "federated authored-purpose revision",
1049 });
1050 }
1051 }
1052 if cursor.inner.is_empty() {
1053 return Err(ServiceError::RelationCursorInvalid {
1054 reason: "federated cursor omitted its inner continuation",
1055 });
1056 }
1057 Ok(Some(cursor.inner))
1058}
1059
1060fn wrap_continuation(
1062 inner: Option<&str>,
1063 participants: &[FederatedCursorParticipant],
1064 budget: DetailedRelationBudget,
1065) -> ServiceResult<Option<String>> {
1066 let Some(inner) = inner else {
1067 return Ok(None);
1068 };
1069 let encoded = serde_json::to_string(&FederatedCursor {
1070 version: FEDERATED_CURSOR_VERSION,
1071 participants: participants.to_vec(),
1072 inner: inner.to_string(),
1073 })?;
1074 if encoded.len() > FEDERATED_CURSOR_MAX_BYTES
1075 || encoded.len() > budget.intermediate_bytes() as usize
1076 {
1077 return Err(ServiceError::RelationCursorInvalid {
1078 reason: "encoded federated cursor exceeds the intermediate-state ceiling",
1079 });
1080 }
1081 Ok(Some(encoded))
1082}
1083
1084fn federated_intermediate_bytes(
1086 primary_bytes: u64,
1087 participants: &[FederatedParticipant],
1088 rendezvous: &[FederatedRendezvous],
1089 cursor: Option<&str>,
1090 budget: DetailedRelationBudget,
1091) -> ServiceResult<u64> {
1092 let cursor_bytes = u64::try_from(cursor.map_or(0, str::len)).map_err(|_overflow| {
1093 ServiceError::InvalidInput("federated cursor byte count overflowed".to_string())
1094 })?;
1095 let federation_bytes = checked_sum(
1096 serialized_equivalent_bytes(&(participants, rendezvous))?,
1097 cursor_bytes,
1098 "federated intermediate bytes",
1099 )?;
1100 let total = checked_sum(
1101 primary_bytes,
1102 federation_bytes,
1103 "federated intermediate bytes",
1104 )?;
1105 if total > budget.intermediate_bytes() {
1106 return Err(ServiceError::InvalidInput(
1107 "federated aggregate intermediate-byte budget was exhausted".to_string(),
1108 ));
1109 }
1110 Ok(total)
1111}
1112
1113fn aggregate_row_limit(budget: DetailedRelationBudget) -> u64 {
1115 u64::from(budget.nodes())
1116 .saturating_add(u64::from(budget.edges()))
1117 .saturating_add(u64::from(budget.occurrences_total()))
1118 .saturating_add(u64::from(budget.page_rows()))
1119}
1120
1121fn federated_root_digest(root: &str) -> [u8; 32] {
1123 let mut hasher = blake3::Hasher::new();
1124 hasher.update(FEDERATED_ROOT_DIGEST_DOMAIN.as_bytes());
1125 hasher.update(&[0]);
1126 hasher.update(root.as_bytes());
1127 *hasher.finalize().as_bytes()
1128}
1129
1130fn check_control(control: Option<&IndexWorkControl>) -> ServiceResult<()> {
1132 if let Some(control) = control {
1133 control
1134 .check(IndexWorkStage::RepositoryTraversal)
1135 .map_err(DbError::from)?;
1136 }
1137 Ok(())
1138}
1139
1140fn push_limit(limits: &mut Vec<GraphLimitKind>, limit: GraphLimitKind) {
1142 if !limits.contains(&limit) {
1143 limits.push(limit);
1144 }
1145}
1146
1147fn checked_sum(left: u64, right: u64, context: &'static str) -> ServiceResult<u64> {
1149 left.checked_add(right)
1150 .ok_or_else(|| ServiceError::InvalidInput(format!("{context} overflowed")))
1151}
1152
1153fn elapsed_ms(started: Instant) -> u64 {
1155 u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
1156}
1157
1158#[cfg(test)]
1159mod tests {
1160 use super::*;
1161 use crate::relations::{RelationAnchor, RelationDirection, RelationResolutionFilter};
1162 use projectatlas_core::graph::{
1163 Completeness, EntitySelector, GraphEntity, GraphIdentityText, GraphLimits,
1164 RepositoryFilePath,
1165 };
1166 use projectatlas_core::symbols::RelationKind;
1167 use projectatlas_core::{IndexCancellation, Node, NodeKind};
1168 use projectatlas_db::sqlite_progress_test_observer::{
1169 SqliteReadProgressEvent, observe_sqlite_read_progress,
1170 };
1171 use std::cell::Cell;
1172 use std::error::Error;
1173 use std::io;
1174 use std::path::Path;
1175 use std::rc::Rc;
1176
1177 #[test]
1178 fn federation_is_project_qualified_fresh_bounded_and_handle_free() -> Result<(), Box<dyn Error>>
1179 {
1180 let temp = tempfile::tempdir()?;
1181 let mut participants = Vec::new();
1182 for index in 0..4 {
1183 let root = temp.path().join(format!("project-{index}"));
1184 let database = root.join("projectatlas.db");
1185 publish_fixture(&root, &database, IndexGeneration::new(1), 1)?;
1186 participants.push((root, database));
1187 }
1188 let before = participants
1189 .iter()
1190 .map(|(_, database)| fs::read(database))
1191 .collect::<Result<Vec<_>, _>>()?;
1192 let query = relation_query(
1193 None,
1194 RelationResolutionFilter::External,
1195 RelationDirection::Outbound,
1196 )?;
1197 let draft =
1198 load_federated_detailed_relations(open_participants(&participants)?, &query, None)?;
1199 let (report, encoded) = draft.fit_output(None, |report| {
1200 serde_json::to_string(report).map_err(ServiceError::from)
1201 })?;
1202 require(
1203 report.participants.len() == 4
1204 && report.work.simultaneously_open_snapshots == 4
1205 && report.rendezvous.len() == 2
1206 && report.rendezvous.iter().all(|row| row.evidence.len() == 4),
1207 "many-root rendezvous or snapshot accounting changed",
1208 )?;
1209 let projects = report
1210 .rendezvous
1211 .iter()
1212 .flat_map(|row| row.evidence.iter().map(|evidence| evidence.project))
1213 .collect::<BTreeSet<_>>();
1214 require(
1215 projects.len() == 4
1216 && report
1217 .rendezvous
1218 .iter()
1219 .flat_map(|row| &row.evidence)
1220 .all(|evidence| {
1221 matches!(
1222 evidence.source.selector(),
1223 EntitySelector::File { path } if path.as_str() == "src/same.rs"
1224 )
1225 }),
1226 "same relative paths collapsed across project identities",
1227 )?;
1228 require(
1229 encoded.len() <= query.budget.output_bytes() as usize
1230 && report.work.intermediate_bytes <= query.budget.intermediate_bytes(),
1231 "federated output escaped its aggregate byte budgets",
1232 )?;
1233 let rendezvous_identities = external_relation_identities(&report.primary);
1234 let encoded_identity_bytes =
1235 u64::try_from(serde_json::to_vec(&rendezvous_identities)?.len())?;
1236 require(
1237 !rendezvous_identities.is_empty()
1238 && serialized_equivalent_bytes(&rendezvous_identities)? == encoded_identity_bytes,
1239 "streamed federation identity accounting diverged from exact JSON bytes",
1240 )?;
1241 let after = participants
1242 .iter()
1243 .map(|(_, database)| fs::read(database))
1244 .collect::<Result<Vec<_>, _>>()?;
1245 require(
1246 before == after,
1247 "read-only federation changed database bytes",
1248 )?;
1249
1250 let mut analysis = load_federated_relation_analysis(
1251 open_participants(&participants)?,
1252 &RelationAnalysisQuery {
1253 relations: query,
1254 mode: crate::analysis::RelationAnalysisMode::Architecture,
1255 trace_target: None,
1256 vcs: None,
1257 include_communities: false,
1258 include_cycles: false,
1259 include_dead_code: false,
1260 },
1261 None,
1262 )?;
1263 require(
1264 analysis
1265 .primary
1266 .take_external_relation_identities()
1267 .is_empty(),
1268 "federated analysis retained temporary rendezvous identities through output fitting",
1269 )?;
1270 let (analysis, _) = analysis.fit_output(|report, _control| {
1271 serde_json::to_vec(report).map_err(ServiceError::from)
1272 })?;
1273 require(
1274 analysis.rendezvous.len() == 2
1275 && analysis
1276 .rendezvous
1277 .iter()
1278 .flat_map(|row| &row.evidence)
1279 .all(|evidence| {
1280 matches!(
1281 evidence.source.selector(),
1282 EntitySelector::File { path } if path.as_str() == "src/same.rs"
1283 )
1284 }),
1285 "analysis rendezvous escaped the primary anchored traversal",
1286 )?;
1287
1288 let inbound_query = relation_query(
1289 None,
1290 RelationResolutionFilter::External,
1291 RelationDirection::Inbound,
1292 )?;
1293 let inbound = load_federated_detailed_relations(
1294 open_participants(&participants)?,
1295 &inbound_query,
1296 None,
1297 )?;
1298 let (inbound, _) = inbound.fit_output(None, |report| {
1299 serde_json::to_string(report).map_err(ServiceError::from)
1300 })?;
1301 require(
1302 inbound.rendezvous.is_empty() && inbound.work.rendezvous_database_rows == 0,
1303 "inbound traversal scanned or returned unrelated external rendezvous",
1304 )?;
1305 let inbound_analysis = load_federated_relation_analysis(
1306 open_participants(&participants)?,
1307 &RelationAnalysisQuery {
1308 relations: inbound_query,
1309 mode: crate::analysis::RelationAnalysisMode::Architecture,
1310 trace_target: None,
1311 vcs: None,
1312 include_communities: false,
1313 include_cycles: false,
1314 include_dead_code: false,
1315 },
1316 None,
1317 )?;
1318 let (inbound_analysis, _) = inbound_analysis.fit_output(|report, _control| {
1319 serde_json::to_vec(report).map_err(ServiceError::from)
1320 })?;
1321 require(
1322 inbound_analysis.rendezvous.is_empty()
1323 && inbound_analysis.work.rendezvous_database_rows == 0,
1324 "inbound analysis scanned or returned unrelated external rendezvous",
1325 )?;
1326
1327 let resolved = load_federated_detailed_relations(
1328 open_participants(&participants)?,
1329 &relation_query(
1330 None,
1331 RelationResolutionFilter::Resolved,
1332 RelationDirection::Outbound,
1333 )?,
1334 None,
1335 )?;
1336 let (resolved, _) = resolved.fit_output(None, |report| {
1337 serde_json::to_string(report).map_err(ServiceError::from)
1338 })?;
1339 require(
1340 resolved.rendezvous.is_empty(),
1341 "federation ignored the requested resolution filter",
1342 )?;
1343
1344 let cursor = report
1345 .primary
1346 .continuation
1347 .ok_or("first federated page omitted its continuation")?;
1348 publish_fixture(
1349 &participants[3].0,
1350 &participants[3].1,
1351 IndexGeneration::new(2),
1352 1,
1353 )?;
1354 let stale = load_federated_detailed_relations(
1355 open_participants(&participants)?,
1356 &relation_query(
1357 Some(cursor),
1358 RelationResolutionFilter::External,
1359 RelationDirection::Outbound,
1360 )?,
1361 None,
1362 );
1363 require(
1364 matches!(
1365 stale,
1366 Err(ServiceError::RelationCursorStale {
1367 field: "federated graph generation"
1368 })
1369 ),
1370 "a changed secondary generation did not stale the outer cursor",
1371 )?;
1372
1373 let control = IndexWorkControl::new(IndexCancellation::new(), None);
1374 control.cancel();
1375 let canceled = load_federated_detailed_relations(
1376 open_participants(&participants)?,
1377 &relation_query(
1378 None,
1379 RelationResolutionFilter::External,
1380 RelationDirection::Outbound,
1381 )?,
1382 Some(&control),
1383 );
1384 require(canceled.is_err(), "pre-canceled federation returned rows")?;
1385 for (index, (_, database)) in participants.iter().enumerate() {
1386 let moved = database.with_extension(format!("closed-{index}"));
1387 fs::rename(database, &moved)?;
1388 fs::rename(moved, database)?;
1389 }
1390 Ok(())
1391 }
1392
1393 #[test]
1394 fn federation_deadline_interrupts_active_rendezvous_and_releases_snapshots()
1395 -> Result<(), Box<dyn Error>> {
1396 let temp = tempfile::tempdir()?;
1397 let primary_root = temp.path().join("primary");
1398 let primary_database = primary_root.join("projectatlas.db");
1399 publish_fixture(&primary_root, &primary_database, IndexGeneration::new(1), 1)?;
1400 let secondary_root = temp.path().join("secondary");
1401 let secondary_database = secondary_root.join("projectatlas.db");
1402 publish_fixture(
1403 &secondary_root,
1404 &secondary_database,
1405 IndexGeneration::new(1),
1406 usize::try_from(GraphLimits::MAX_ROWS)?,
1407 )?;
1408 let participants = vec![
1409 (primary_root, primary_database),
1410 (secondary_root, secondary_database),
1411 ];
1412 let mut query = relation_query(
1413 None,
1414 RelationResolutionFilter::External,
1415 RelationDirection::Outbound,
1416 )?;
1417 query.budget = query.budget.with_aggregate_limits(
1418 Some(GraphLimits::MAX_ROWS),
1419 None,
1420 None,
1421 None,
1422 None,
1423 None,
1424 )?;
1425 let control = IndexWorkControl::with_deadline(
1426 IndexCancellation::new(),
1427 Instant::now() + Duration::from_secs(1),
1428 );
1429 let family_query_active = Rc::new(Cell::new(false));
1430 let family_query_entered_live = Rc::new(Cell::new(false));
1431 let callback_entered_live = Rc::new(Cell::new(false));
1432 let callback_interrupted = Rc::new(Cell::new(false));
1433 let stores = open_participants(&participants)?;
1434 let deadline = observe_sqlite_read_progress(
1435 {
1436 let observer_control = control.clone();
1437 let family_query_active = Rc::clone(&family_query_active);
1438 let family_query_entered_live = Rc::clone(&family_query_entered_live);
1439 let callback_entered_live = Rc::clone(&callback_entered_live);
1440 let callback_interrupted = Rc::clone(&callback_interrupted);
1441 move |event| match event {
1442 SqliteReadProgressEvent::RepositoryRelationFamilyQueryEntered => {
1443 family_query_active.set(true);
1444 if observer_control
1445 .check(IndexWorkStage::RepositoryTraversal)
1446 .is_ok()
1447 {
1448 family_query_entered_live.set(true);
1449 }
1450 }
1451 SqliteReadProgressEvent::RepositoryRelationFamilyQueryExited => {
1452 family_query_active.set(false);
1453 }
1454 SqliteReadProgressEvent::CallbackEntered { stage }
1455 if family_query_active.get() && !callback_entered_live.get() =>
1456 {
1457 if observer_control.check(stage).is_ok() {
1458 callback_entered_live.set(true);
1459 while observer_control.check(stage).is_ok() {
1460 std::thread::sleep(Duration::from_millis(1));
1461 }
1462 }
1463 }
1464 SqliteReadProgressEvent::CallbackEvaluated {
1465 interrupted: true, ..
1466 } if family_query_active.get() && callback_entered_live.get() => {
1467 callback_interrupted.set(true);
1468 }
1469 _ => {}
1470 }
1471 },
1472 || load_federated_detailed_relations(stores, &query, Some(&control)),
1473 );
1474 require(
1475 matches!(
1476 deadline,
1477 Err(ServiceError::Db(DbError::IndexWork(
1478 projectatlas_core::IndexWorkFailure::DeadlineExceeded {
1479 stage: IndexWorkStage::RepositoryTraversal
1480 }
1481 )))
1482 ),
1483 "active rendezvous query was not interrupted with its typed deadline",
1484 )?;
1485 require(
1486 family_query_entered_live.get()
1487 && callback_entered_live.get()
1488 && callback_interrupted.get()
1489 && !family_query_active.get(),
1490 "rendezvous deadline did not enter a live family query and interrupt it through SQLite",
1491 )?;
1492 for (index, (_, database)) in participants.iter().enumerate() {
1493 let moved = database.with_extension(format!("deadline-closed-{index}"));
1494 fs::rename(database, &moved)?;
1495 fs::rename(moved, database)?;
1496 }
1497 Ok(())
1498 }
1499
1500 fn publish_fixture(
1502 root: &Path,
1503 database: &Path,
1504 generation: IndexGeneration,
1505 unrelated_relations: usize,
1506 ) -> Result<(), Box<dyn Error>> {
1507 fs::create_dir_all(root.join("src"))?;
1508 fs::write(root.join("src/same.rs"), "pub fn same() {}\n")?;
1509 fs::write(root.join("src/unrelated.rs"), "pub fn unrelated() {}\n")?;
1510 let mut store = AtlasStore::open_for_project(database, root)?;
1511 let project = store
1512 .project_instance_id()?
1513 .ok_or("federation fixture project identity is missing")?;
1514 let source = GraphEntity::new(
1515 project,
1516 EntitySelector::File {
1517 path: RepositoryFilePath::new(Path::new("src/same.rs"))?,
1518 },
1519 generation,
1520 )?;
1521 let unrelated_source = GraphEntity::new(
1522 project,
1523 EntitySelector::File {
1524 path: RepositoryFilePath::new(Path::new("src/unrelated.rs"))?,
1525 },
1526 generation,
1527 )?;
1528 let mut entities = vec![source.clone(), unrelated_source.clone()];
1529 let mut relations = Vec::new();
1530 for identity in ["package/a", "package/b", "package/c"] {
1531 let external = GraphEntity::new(
1532 project,
1533 EntitySelector::External {
1534 external: ExternalSelector {
1535 system: GraphIdentityText::new("registry.example")?,
1536 identity: GraphIdentityText::new(identity)?,
1537 },
1538 },
1539 generation,
1540 )?;
1541 relations.push(LogicalRelation::new(
1542 &source,
1543 GraphRelationKind::Legacy(RelationKind::Imports),
1544 RelationResolution::external(&external)?,
1545 projectatlas_core::graph::ConfidenceClass::Exact,
1546 Completeness::Complete,
1547 generation,
1548 )?);
1549 entities.push(external);
1550 }
1551 for index in 0..unrelated_relations {
1552 let identity = if unrelated_relations == 1 {
1553 "package/unrelated".to_string()
1554 } else {
1555 format!("package/unrelated/{index:05}")
1556 };
1557 let unrelated_external = GraphEntity::new(
1558 project,
1559 EntitySelector::External {
1560 external: ExternalSelector {
1561 system: GraphIdentityText::new("registry.example")?,
1562 identity: GraphIdentityText::new(identity)?,
1563 },
1564 },
1565 generation,
1566 )?;
1567 relations.push(LogicalRelation::new(
1568 &unrelated_source,
1569 GraphRelationKind::Legacy(RelationKind::Imports),
1570 RelationResolution::external(&unrelated_external)?,
1571 projectatlas_core::graph::ConfidenceClass::Exact,
1572 Completeness::Complete,
1573 generation,
1574 )?);
1575 entities.push(unrelated_external);
1576 }
1577 let mut publication = store.begin_index_publication("federation-fixture")?;
1578 publication.begin_scan_replacement()?;
1579 publication.upsert_scan_node_batch(&[
1580 fixture_folder_node("src"),
1581 fixture_file_node("src/same.rs"),
1582 fixture_file_node("src/unrelated.rs"),
1583 ])?;
1584 publication.finish_scan_replacement()?;
1585 publication.replace_repository_graph(project, &entities, &relations, &[], &[])?;
1586 publication.complete()?;
1587 Ok(())
1588 }
1589
1590 fn open_participants(
1592 participants: &[(PathBuf, PathBuf)],
1593 ) -> Result<Vec<FederatedStore>, Box<dyn Error>> {
1594 participants
1595 .iter()
1596 .map(|(root, database)| {
1597 Ok(FederatedStore::new(
1598 AtlasStore::open_read_only_for_project(database, root)?,
1599 database.clone(),
1600 root.clone(),
1601 FederatedInputWork::default(),
1602 )?)
1603 })
1604 .collect()
1605 }
1606
1607 fn relation_query(
1609 cursor: Option<String>,
1610 resolution: RelationResolutionFilter,
1611 direction: RelationDirection,
1612 ) -> Result<DetailedRelationQuery, Box<dyn Error>> {
1613 Ok(DetailedRelationQuery {
1614 anchor: RelationAnchor::File {
1615 file: RepositoryFilePath::new(Path::new("src/same.rs"))?,
1616 },
1617 direction,
1618 relation: Some(GraphRelationKind::Legacy(RelationKind::Imports)),
1619 minimum_confidence: projectatlas_core::graph::ConfidenceClass::Low,
1620 resolution,
1621 include_occurrences: false,
1622 budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
1623 2,
1624 1,
1625 1,
1626 512 * 1_024,
1627 )?)
1628 .with_aggregate_limits(Some(100), None, None, None, None, None)?,
1629 cursor,
1630 })
1631 }
1632
1633 fn fixture_file_node(path: &str) -> Node {
1635 Node {
1636 path: path.to_string(),
1637 kind: NodeKind::File,
1638 parent_path: Some("src".to_string()),
1639 extension: Some("rs".to_string()),
1640 language: Some("Rust".to_string()),
1641 size_bytes: Some(17),
1642 mtime_ns: Some(1),
1643 content_hash: Some("fixture-hash".to_string()),
1644 }
1645 }
1646
1647 fn fixture_folder_node(path: &str) -> Node {
1649 Node {
1650 path: path.to_string(),
1651 kind: NodeKind::Folder,
1652 parent_path: Some(".".to_string()),
1653 extension: None,
1654 language: None,
1655 size_bytes: None,
1656 mtime_ns: Some(1),
1657 content_hash: None,
1658 }
1659 }
1660
1661 fn require(condition: bool, message: &str) -> Result<(), io::Error> {
1663 condition
1664 .then_some(())
1665 .ok_or_else(|| io::Error::other(message))
1666 }
1667}