Skip to main content

superstac_engine/
engine.rs

1use parking_lot::Mutex;
2use reqwest::Client;
3use std::sync::{
4    atomic::{AtomicBool, Ordering},
5    Arc,
6};
7
8use std::collections::HashMap;
9
10use crate::{
11    capabilities,
12    discovery::{self, CollectionAvailability},
13    health::HealthManager,
14    types::SharedStorage,
15};
16use superstac_core::{
17    errors::SuperSTACError,
18    models::catalog::{Catalog, CatalogFilters},
19    storages::factory::StorageBackend,
20};
21use std::time::Duration;
22
23use superstac_search::{
24    executor::SearchExecutor,
25    options::{FederationOptions, RetryPolicy},
26    query::SearchQuery,
27    response::SearchResponse,
28};
29
30/// Runtime entry point. Wraps a storage backend, runs background health
31/// checks and `/collections` introspection, and exposes the federated search
32/// + discovery API.
33///
34/// Construct, call `start()`, then use `search()` / `list_collections()` /
35/// `describe_collection()`. `start()` is idempotent — `search()` will call
36/// it for you if needed.
37pub struct SuperSTACEngine {
38    storage: SharedStorage,
39    client: Client,
40    health_manager: Option<HealthManager>,
41    executor: SearchExecutor,
42    started: AtomicBool,
43}
44
45/// User-Agent attached to every outbound HTTP request. Set as a default
46/// header on the shared reqwest client at engine construction. When
47/// per-catalog headers are added later, this must be filtered out of any
48/// user-supplied set so it can't be overridden.
49const USER_AGENT: &str = concat!("superstac/", env!("CARGO_PKG_VERSION"));
50
51impl SuperSTACEngine {
52    /// Build an engine over the given storage. Construct the storage via
53    /// `superstac_config::init_from_yaml` or directly via
54    /// [`superstac_core::models::storage::Storage::init`].
55    pub fn new(storage: Box<dyn StorageBackend + Send + Sync>) -> Self {
56        let client = Client::builder()
57            .user_agent(USER_AGENT)
58            .build()
59            .expect("failed to build reqwest client");
60
61        Self {
62            storage: Arc::new(Mutex::new(storage)),
63            client: client.clone(),
64            health_manager: Some(HealthManager::new(client.clone())),
65            executor: SearchExecutor::new(client),
66            started: AtomicBool::new(false),
67        }
68    }
69
70    /// Run health checks against all catalogs, then introspect `/collections`
71    /// on the healthy ones. Idempotent — safe to call multiple times.
72    pub async fn start(&self) -> Result<(), SuperSTACError> {
73        if self.started.load(Ordering::Relaxed) {
74            return Ok(());
75        }
76
77        if let Some(manager) = &self.health_manager {
78            let catalogs = {
79                let storage = self.storage.lock();
80                storage.list_catalogs(None)?
81            };
82
83            manager.start(Arc::clone(&self.storage), catalogs).await?;
84        }
85
86        // Introspect each healthy catalog's /collections so we can do source
87        // selection at search time. Failures here are non-fatal — affected
88        // catalogs remain `supported_collections = None` (pass-through).
89        self.introspect_capabilities().await?;
90
91        self.started.store(true, Ordering::Relaxed);
92
93        let catalog_count = self.storage.lock().list_catalogs(None)?.len();
94        tracing::info!(catalogs = catalog_count, "engine ready");
95
96        Ok(())
97    }
98
99    /// Cancel background health-monitor tasks. Doesn't drop the storage.
100    pub async fn shutdown(&self) {
101        tracing::debug!("shutting down engine");
102
103        if let Some(manager) = &self.health_manager {
104            manager.stop_all().await;
105        }
106
107        self.started.store(false, Ordering::Relaxed);
108    }
109
110    async fn introspect_capabilities(&self) -> Result<(), SuperSTACError> {
111        let healthy_catalogs = {
112            let storage = self.storage.lock();
113            storage.list_catalogs(Some(CatalogFilters {
114                available: Some(true),
115                ..CatalogFilters::default()
116            }))?
117        };
118
119        for catalog in healthy_catalogs {
120            match capabilities::fetch_supported_collections(&self.client, &catalog).await {
121                Ok(set) => {
122                    tracing::debug!(
123                        catalog = %catalog.id,
124                        collections = set.len(),
125                        "introspected /collections"
126                    );
127                    let mut storage = self.storage.lock();
128                    if let Err(e) =
129                        storage.update_supported_collections(&catalog.id, Some(set))
130                    {
131                        tracing::warn!(
132                            catalog = %catalog.id,
133                            error = %e,
134                            "failed to persist /collections result"
135                        );
136                    }
137                }
138                Err(e) => {
139                    tracing::warn!(
140                        catalog = %catalog.id,
141                        error = %e,
142                        "/collections introspection failed; catalog will pass-through"
143                    );
144                }
145            }
146        }
147
148        Ok(())
149    }
150
151    /// `start()` if we haven't already. Called automatically by `search()`
152    /// and the discovery methods.
153    pub async fn ensure_started(&self) -> Result<(), SuperSTACError> {
154        if !self.started.load(Ordering::Relaxed) {
155            self.start().await?;
156        }
157
158        Ok(())
159    }
160
161    /// Federated search. Applies source selection (filter catalogs that
162    /// can't possibly serve the requested collections), then fans out.
163    pub async fn search(&self, query: SearchQuery) -> Result<SearchResponse, SuperSTACError> {
164        self.ensure_started().await?;
165
166        let (candidate_catalogs, options) = {
167            let storage = self.storage.lock();
168            let settings = storage.get_settings();
169
170            let catalogs = if let Some(true) = settings.search_healthy_catalogs_only {
171                storage.list_catalogs(Some(CatalogFilters {
172                    available: Some(true),
173                    ..CatalogFilters::default()
174                }))?
175            } else {
176                storage.list_catalogs(None)?
177            };
178
179            let options = FederationOptions {
180                deduplicate: settings.deduplicate_items.unwrap_or(true),
181                unify_response: settings.unify_response.unwrap_or(true),
182                max_concurrent: settings.max_concurrent_catalogs.unwrap_or(8),
183                per_catalog_timeout: Duration::from_secs(
184                    settings.per_catalog_timeout_seconds.unwrap_or(30),
185                ),
186                retry: RetryPolicy {
187                    max_attempts: settings.max_retry_attempts.unwrap_or(2),
188                    initial_backoff: Duration::from_millis(
189                        settings.retry_initial_backoff_ms.unwrap_or(100),
190                    ),
191                    max_backoff: Duration::from_millis(
192                        settings.retry_max_backoff_ms.unwrap_or(2000),
193                    ),
194                },
195                max_items_per_catalog: settings.max_items_per_catalog.unwrap_or(1000),
196            };
197
198            (catalogs, options)
199        };
200
201        // Compute unsupported collections against the full candidate set
202        // before source-selection filtering.
203        let unsupported =
204            discovery::unsupported_collections(&candidate_catalogs, &query.collections);
205
206        // Source selection: drop catalogs whose introspected collection set
207        // doesn't overlap the requested collections. Catalogs with no
208        // introspection data pass through.
209        let catalogs: Vec<Catalog> = candidate_catalogs
210            .into_iter()
211            .filter(|c| c.supports_any_of(&query.collections))
212            .collect();
213
214        let mut response = self
215            .executor
216            .federated_search(catalogs, query, options)
217            .await?;
218        response.metadata.unsupported_collections = unsupported;
219
220        Ok(response)
221    }
222
223    /// Aggregated view: every collection ID known across healthy catalogs,
224    /// with the catalogs that serve each.
225    pub async fn list_collections(&self) -> Result<Vec<CollectionAvailability>, SuperSTACError> {
226        self.ensure_started().await?;
227        let catalogs = {
228            let storage = self.storage.lock();
229            storage.list_catalogs(None)?
230        };
231        Ok(discovery::aggregate_collections(&catalogs))
232    }
233
234    /// IDs of every catalog whose introspected `/collections` includes
235    /// `collection_id`. Catalogs not yet introspected are excluded.
236    pub async fn catalogs_supporting(
237        &self,
238        collection_id: &str,
239    ) -> Result<Vec<String>, SuperSTACError> {
240        self.ensure_started().await?;
241        let catalogs = {
242            let storage = self.storage.lock();
243            storage.list_catalogs(None)?
244        };
245        Ok(discovery::catalogs_supporting(&catalogs, collection_id))
246    }
247
248    /// Per-catalog inventory: catalog ID -> sorted canonical collection IDs.
249    /// Catalogs without introspection data are omitted.
250    pub async fn collections_by_catalog(
251        &self,
252    ) -> Result<HashMap<String, Vec<String>>, SuperSTACError> {
253        self.ensure_started().await?;
254        let catalogs = {
255            let storage = self.storage.lock();
256            storage.list_catalogs(None)?
257        };
258        Ok(discovery::collections_by_catalog(&catalogs))
259    }
260
261    /// Fetch full collection metadata from a specific catalog. The
262    /// `collection_id` is interpreted as canonical and resolved to the
263    /// catalog's local name via `collection_aliases`. Returns `None` if the
264    /// catalog returns 404 for the collection.
265    pub async fn describe_collection(
266        &self,
267        catalog_id: &str,
268        collection_id: &str,
269    ) -> Result<Option<stac::Collection>, SuperSTACError> {
270        self.ensure_started().await?;
271
272        let catalog = {
273            let storage = self.storage.lock();
274            storage.get_catalog(catalog_id)?.clone()
275        };
276
277        let local_id = catalog.resolve_collection(collection_id).to_string();
278
279        let stac_client =
280            stac_io::api::Client::with_client(self.client.clone(), &catalog.url)
281                .map_err(|e| SuperSTACError::SearchFailed(format!("stac client init: {}", e)))?;
282
283        stac_client
284            .collection(&local_id)
285            .await
286            .map_err(|e| SuperSTACError::SearchFailed(format!("fetch collection: {}", e)))
287    }
288}