projectatlas_service/
federation.rs

1//! Call-scoped read-only composition across explicitly supplied project roots.
2
3use 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
25/// Minimum number of explicit roots that constitutes a federated call.
26const MIN_FEDERATED_ROOTS: usize = 2;
27/// Maximum roots and simultaneously open read snapshots in one call.
28const MAX_FEDERATED_ROOTS: usize = 8;
29/// Maximum total size of participating project databases.
30pub const MAX_FEDERATED_DATABASE_BYTES: u64 = 64 * 1_024 * 1_024 * 1_024;
31/// Maximum current source bytes inspected while validating all roots.
32pub const MAX_FEDERATED_INPUT_BYTES: u64 = 16 * 1_024 * 1_024 * 1_024;
33/// Maximum time allowed to finish and drop every participating read snapshot.
34const MAX_FEDERATED_CLOSE_MS: u64 = 1_000;
35
36/// Current opaque federated-continuation representation.
37const FEDERATED_CURSOR_VERSION: u16 = 1;
38/// Absolute serialized federated-continuation ceiling.
39const FEDERATED_CURSOR_MAX_BYTES: usize = 128 * 1_024;
40/// Domain separator for machine-path-free root binding.
41const FEDERATED_ROOT_DIGEST_DOMAIN: &str = "projectatlas:federated-root:v1";
42
43/// Relation families whose exact typed external identities can rendezvous across roots.
44const 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
53/// Validate the explicit root count before an adapter opens any database.
54///
55/// # Errors
56///
57/// Returns an error unless the call supplies between two and eight roots.
58pub 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/// Exact read-only freshness work completed before one root joined federation.
69#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize)]
70pub struct FederatedInputWork {
71    /// Repository entries inspected by exact source verification.
72    pub filesystem_entries: u64,
73    /// Current source bytes hashed by exact source verification.
74    pub filesystem_bytes: u64,
75    /// `SQLite` statements used by exact source verification.
76    pub sqlite_read_statements: u64,
77    /// Indexed nodes decoded by exact source verification.
78    pub decoded_nodes: u64,
79    /// Wall time spent opening and verifying this participant.
80    pub elapsed_ms: u64,
81}
82
83impl FederatedInputWork {
84    /// Add one participant's verification work without counter overflow.
85    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
116/// One already verified root-bound read-only store admitted by an adapter.
117pub struct FederatedStore {
118    /// Root-bound read-only database handle.
119    store: AtlasStore,
120    /// Database reopened after snapshot close for generation validation.
121    database_path: PathBuf,
122    /// Canonical explicit project root.
123    root: PathBuf,
124    /// Participating database file size.
125    database_bytes: u64,
126    /// Exact adapter-owned freshness work.
127    input_work: FederatedInputWork,
128}
129
130impl FederatedStore {
131    /// Admit one root-bound read-only snapshot into a later federated call.
132    ///
133    /// # Errors
134    ///
135    /// Returns an error when the store is writable, has no active read snapshot,
136    /// names another root, or its database path is not an existing regular file.
137    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    /// Borrow the verified primary store while an adapter constructs exact selectors.
173    #[must_use]
174    pub const fn store(&self) -> &AtlasStore {
175        &self.store
176    }
177
178    /// Close this participant without producing a federated result.
179    ///
180    /// # Errors
181    ///
182    /// Returns an error when `SQLite` cannot finish the active read snapshot.
183    pub fn finish(self) -> ServiceResult<()> {
184        self.store.finish_index_read_snapshot().map_err(Into::into)
185    }
186}
187
188/// One ordered participant retained without exposing its machine-local root.
189#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
190pub struct FederatedParticipant {
191    /// Zero-based order supplied by the caller.
192    pub order: u32,
193    /// Project-qualified identity captured by the read-only store.
194    pub project: ProjectInstanceId,
195    /// Complete graph generation captured for this root.
196    pub generation: IndexGeneration,
197    /// Authored-purpose revision captured for this root.
198    pub authored_purpose_revision: u64,
199}
200
201/// One project-qualified relation supporting an exact typed external rendezvous.
202#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
203pub struct FederatedRelationEvidence {
204    /// Participating project identity.
205    pub project: ProjectInstanceId,
206    /// Participating graph generation.
207    pub generation: IndexGeneration,
208    /// Exact local source entity that emitted the relation.
209    pub source: projectatlas_core::graph::GraphEntity,
210    /// Complete typed logical relation.
211    pub relation: LogicalRelation,
212}
213
214/// Exact shared external identity observed in at least two projects.
215#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
216pub struct FederatedRendezvous {
217    /// Closed relation family shared by the evidence.
218    pub relation: GraphRelationKind,
219    /// Exact typed external namespace and identity; similar text is not enough.
220    pub external: ExternalSelector,
221    /// Deterministically ordered project-qualified evidence.
222    pub evidence: Vec<FederatedRelationEvidence>,
223}
224
225/// Aggregate bounded work for one federated response.
226#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
227pub struct FederatedRelationWork {
228    /// Exact source-verification work across all roots.
229    pub input: FederatedInputWork,
230    /// Aggregate bytes of participating project database files.
231    pub participating_database_bytes: u64,
232    /// Maximum read snapshots held simultaneously.
233    pub simultaneously_open_snapshots: u32,
234    /// Relation rows decoded by cross-root rendezvous reads.
235    pub rendezvous_database_rows: u64,
236    /// Serialized-equivalent bytes decoded by cross-root rendezvous reads.
237    pub rendezvous_database_bytes: u64,
238    /// Exact typed rendezvous evidence retained in memory.
239    pub rendezvous_relations: u32,
240    /// Peak aggregate primary and federation-owned intermediate bytes.
241    pub intermediate_bytes: u64,
242    /// Milliseconds spent finishing and dropping every participant snapshot.
243    pub close_ms: u64,
244    /// Total verification, query, and close elapsed milliseconds.
245    pub elapsed_ms: u64,
246    /// Exact bytes emitted by the selected adapter envelope.
247    pub rendered_output_bytes: u64,
248}
249
250/// Federated detailed-relation response on the existing relation route.
251#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
252pub struct FederatedDetailedRelationReport {
253    /// Ordered validated participants.
254    pub participants: Vec<FederatedParticipant>,
255    /// Existing detailed relation result anchored in the first root.
256    pub primary: DetailedRelationReport,
257    /// Exact typed cross-root external rendezvous evidence.
258    pub rendezvous: Vec<FederatedRendezvous>,
259    /// Whether primary traversal or cross-root rendezvous work was truncated.
260    pub truncated: bool,
261    /// Stable aggregate limits reached by either stage.
262    pub reached_limits: Vec<GraphLimitKind>,
263    /// Exact aggregate work for this response.
264    pub work: FederatedRelationWork,
265}
266
267/// Federated closed-analysis response on the existing relation route.
268#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
269pub struct FederatedAnalysisReport {
270    /// Ordered validated participants.
271    pub participants: Vec<FederatedParticipant>,
272    /// Existing analysis result anchored in the first root.
273    pub primary: RelationAnalysisReport,
274    /// Exact typed cross-root external rendezvous evidence.
275    pub rendezvous: Vec<FederatedRendezvous>,
276    /// Whether primary analysis or cross-root rendezvous work was truncated.
277    pub truncated: bool,
278    /// Stable aggregate limits reached by either stage.
279    pub reached_limits: Vec<GraphLimitKind>,
280    /// Exact aggregate work for this response.
281    pub work: FederatedRelationWork,
282}
283
284/// Unrendered federated detailed response that fits the exact adapter envelope.
285pub struct FederatedDetailedRelationDraft {
286    /// Existing first-root relation draft.
287    primary: DetailedRelationPageDraft,
288    /// Immutable participant and rendezvous state.
289    context: FederatedContext,
290}
291
292impl FederatedDetailedRelationDraft {
293    /// Fit the largest primary-row prefix to the exact federated adapter envelope.
294    ///
295    /// # Errors
296    ///
297    /// Returns an adapter error when rendering fails, cancellation fires, a
298    /// cursor cannot be wrapped, or the empty federated envelope is oversized.
299    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
318/// Unrendered federated analysis response that fits the exact adapter envelope.
319pub struct FederatedAnalysisDraft {
320    /// Existing first-root analysis draft.
321    primary: RelationAnalysisDraft,
322    /// Immutable participant and rendezvous state.
323    context: FederatedContext,
324}
325
326impl FederatedAnalysisDraft {
327    /// Fit the largest analysis-finding prefix to the exact federated envelope.
328    ///
329    /// # Errors
330    ///
331    /// Returns an adapter error when rendering fails, cancellation fires, a
332    /// cursor cannot be wrapped, or the empty federated envelope is oversized.
333    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/// Shared immutable state used while fitting either adapter envelope.
356#[derive(Clone)]
357struct FederatedContext {
358    /// Ordered public participant identities.
359    participants: Vec<FederatedParticipant>,
360    /// Complete participant identity bound into continuations.
361    cursor_participants: Vec<FederatedCursorParticipant>,
362    /// Call-only typed external identity groups.
363    rendezvous: Vec<FederatedRendezvous>,
364    /// Aggregate limits reached while reading rendezvous evidence.
365    rendezvous_limits: Vec<GraphLimitKind>,
366    /// Work completed before adapter output fitting.
367    base_work: FederatedRelationWork,
368    /// Temporary exact identity-set bytes retained during rendezvous discovery.
369    rendezvous_identity_bytes: u64,
370    /// Existing aggregate relation and output budget.
371    budget: DetailedRelationBudget,
372}
373
374impl FederatedContext {
375    /// Compose one detailed envelope and bind its inner continuation.
376    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    /// Compose one analysis envelope and bind its inner continuation.
412    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/// Versioned outer continuation for an ordered participant set.
453#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
454#[serde(deny_unknown_fields)]
455struct FederatedCursor {
456    /// Outer wire version.
457    version: u16,
458    /// Ordered roots, projects, and generations captured by the first page.
459    participants: Vec<FederatedCursorParticipant>,
460    /// Existing relation or analysis continuation.
461    inner: String,
462}
463
464/// Machine-path-free participant identity retained by a continuation.
465#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
466#[serde(deny_unknown_fields)]
467struct FederatedCursorParticipant {
468    /// Durable project identity.
469    project: ProjectInstanceId,
470    /// Domain-separated exact root digest.
471    root_digest: [u8; 32],
472    /// Complete graph generation.
473    generation: IndexGeneration,
474    /// Authored-purpose generation affecting hydration.
475    authored_purpose_revision: u64,
476}
477
478/// Participant metadata captured while every snapshot remains open.
479struct CapturedFederation {
480    /// Public project-qualified participant state.
481    participants: Vec<FederatedParticipant>,
482    /// Continuation-bound participant state.
483    cursor_participants: Vec<FederatedCursorParticipant>,
484    /// Aggregate exact-source verification work.
485    input_work: FederatedInputWork,
486    /// Aggregate participating database bytes.
487    database_bytes: u64,
488}
489
490/// Bounded cross-root rendezvous rows and measured work.
491struct RendezvousLoad {
492    /// Retained exact typed groups.
493    rows: Vec<FederatedRendezvous>,
494    /// Database rows inspected.
495    database_rows: u64,
496    /// Serialized-equivalent decoded database bytes.
497    database_bytes: u64,
498    /// Retained logical relation evidence rows.
499    relation_rows: u32,
500    /// Aggregate ceilings reached during the read.
501    reached_limits: Vec<GraphLimitKind>,
502}
503
504/// Closed participant details used for post-read generation validation.
505struct ClosedParticipant {
506    /// Exact database path.
507    database_path: PathBuf,
508    /// Canonical expected root.
509    root: PathBuf,
510    /// Binding captured before snapshot close.
511    cursor: FederatedCursorParticipant,
512}
513
514/// Load one federated detailed relation page through already verified stores.
515///
516/// # Errors
517///
518/// Returns an error before any response is produced when any participant,
519/// cursor, budget, query, cleanup, or post-read generation validation fails.
520pub 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
583/// Load one federated closed analysis through already verified stores.
584///
585/// # Errors
586///
587/// Returns an error before any response is produced when any participant,
588/// cursor, budget, query, cleanup, or post-read generation validation fails.
589pub 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
652/// Validate all open participants and capture their ordered snapshot identity.
653fn 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
728/// Load exact typed external identities shared by at least two projects.
729fn 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
881/// Finish and drop every captured participant snapshot.
882fn 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
934/// Reopen each database sequentially and reject a changed participant.
935fn 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
979/// Compose work measured before adapter-specific output fitting.
980fn 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
1004/// Decode and validate an outer continuation against every current root.
1005fn 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
1060/// Bind an existing inner continuation to the complete participant set.
1061fn 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
1084/// Charge retained federation state against the existing intermediate ceiling.
1085fn 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
1113/// Derive the aggregate row-work ceiling from the existing typed budget.
1114fn 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
1121/// Bind a root without returning its machine-local path.
1122fn 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
1130/// Observe request cancellation at a repository-traversal boundary.
1131fn 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
1140/// Retain one stable reached-limit value.
1141fn push_limit(limits: &mut Vec<GraphLimitKind>, limit: GraphLimitKind) {
1142    if !limits.contains(&limit) {
1143        limits.push(limit);
1144    }
1145}
1146
1147/// Add two work counters without silent overflow.
1148fn 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
1153/// Return a saturating millisecond duration for public work reports.
1154fn 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    /// Publish three anchored imports plus the requested unrelated same-family imports.
1501    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    /// Open every fixture through the production root-bound read-only adapter.
1591    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    /// Build one bounded anchored relation request.
1608    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    /// Build one indexed source-file row.
1634    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    /// Build one indexed source-folder row.
1648    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    /// Return one readable test failure.
1662    fn require(condition: bool, message: &str) -> Result<(), io::Error> {
1663        condition
1664            .then_some(())
1665            .ok_or_else(|| io::Error::other(message))
1666    }
1667}