Skip to main content

graph_storage/
gear.rs

1//! Composition root: probes the server, selects the store and engine
2//! implementations, and publishes the in-process client.
3
4use std::sync::{Arc, OnceLock};
5
6use async_trait::async_trait;
7use authz_resolver_sdk::pep::PolicyEnforcer;
8use toolkit::api::OpenApiRegistry;
9use toolkit::{
10    DatabaseCapability, Gear, GearCtx, Healthcheck, HealthcheckResult, RestApiCapability,
11};
12use tracing::{debug, error, info, warn};
13
14use graph_storage_sdk::GraphStorageClientV1;
15use graph_storage_sdk::plugin_api::EmbeddingProviderV1;
16
17use crate::api::rest::routes;
18use crate::config::{EmbeddingProviderKind, GraphStorageConfig};
19use crate::domain::embedding::SpaceState;
20use crate::domain::local_client::GraphStorageLocalClient;
21use crate::domain::service::GraphServices;
22use crate::infra::embedding::fake::FakeEmbeddingProvider;
23use crate::infra::engine::PgGraphEngine;
24use crate::infra::store::{PgGraphStore, spaces};
25
26/// The graph-storage gear.
27#[toolkit::gear(name = "graph-storage", deps = [authz_resolver], capabilities = [db, rest])]
28pub struct GraphStorage {
29    services: OnceLock<Arc<GraphServices>>,
30}
31
32impl Default for GraphStorage {
33    fn default() -> Self {
34        Self {
35            services: OnceLock::new(),
36        }
37    }
38}
39
40#[async_trait]
41impl Gear for GraphStorage {
42    async fn init(&self, ctx: &GearCtx) -> anyhow::Result<()> {
43        let cfg = ctx.config_or_default::<GraphStorageConfig>()?.validated()?;
44        debug!(
45            traversal_hop = ?cfg.traversal_hop,
46            ingest_max_nodes = cfg.ingest_max_nodes,
47            "loaded graph-storage configuration"
48        );
49
50        // The configured vector width must be the width the schema was
51        // migrated with, or every stored vector is incomparable with every
52        // query vector.
53        let migrated = crate::infra::store::ingest::migrated_embedding_dimension();
54        if cfg.embedding_dimension != migrated {
55            anyhow::bail!(
56                "graph-storage.embedding_dimension is {} but the schema was migrated with {migrated}; \
57                 vector search would compare incomparable vectors",
58                cfg.embedding_dimension
59            );
60        }
61
62        // Acquiring the database capability is what makes the platform run
63        // this gear's migrations before the REST phase; declaring `db` alone
64        // is silently insufficient.
65        let db_raw = ctx.db_required()?;
66        let db = Arc::new(db_raw.db());
67
68        // SQL/PGQ is a probed backend capability, not a gear requirement:
69        // the property-graph migration is skipped on an older server, and the
70        // engine then serves every hop on the fallback backend.
71        let pgq_available = crate::infra::engine::probe_pgq(&db).await;
72        if !pgq_available {
73            match cfg.traversal_hop {
74                crate::config::HopStrategy::Auto => warn!(
75                    "this server does not provide SQL/PGQ; traversal will use the two-query hop"
76                ),
77                crate::config::HopStrategy::Pgq => error!(
78                    "traversal_hop is `pgq` and this server does not provide SQL/PGQ; the gear \
79                     reports not ready and refuses traversal rather than substitute another \
80                     backend"
81                ),
82                crate::config::HopStrategy::TwoQuery => {}
83            }
84        }
85
86        let store = Arc::new(PgGraphStore::new(
87            Arc::clone(&db),
88            cfg.clone(),
89            pgq_available,
90        ));
91        let engine = Arc::new(PgGraphEngine::new(Arc::clone(&store)));
92        let enforcer = PolicyEnforcer::new(ctx.client_hub().get()?);
93
94        let provider = select_embedding_provider(&cfg).await?;
95        let embedding = resolve_embedding_space(&db, provider, &cfg).await?;
96
97        let services = Arc::new(GraphServices::new(cfg, store, engine, enforcer, embedding));
98        self.services
99            .set(Arc::clone(&services))
100            .map_err(|_| anyhow::anyhow!("{} gear already initialized", Self::MODULE_NAME))?;
101
102        ctx.client_hub()
103            .register::<dyn GraphStorageClientV1>(Arc::new(GraphStorageLocalClient::new(services)));
104
105        info!(pgq_available, "graph-storage gear initialized");
106        Ok(())
107    }
108}
109
110/// Pick the deployment's one embedding provider.
111///
112/// One per deployment, per the single-embedding-space constraint. A
113/// misconfigured choice fails the boot rather than falling back: silently
114/// substituting the fake would fill the graph with vectors that rank nothing
115/// meaningfully, and the deployment would look healthy the whole time.
116///
117/// # Errors
118///
119/// No provider configured at all; an `onnx` deployment whose artifacts are
120/// missing or unloadable, or one built without the `onnx` feature.
121async fn select_embedding_provider(
122    cfg: &GraphStorageConfig,
123) -> anyhow::Result<Arc<dyn EmbeddingProviderV1>> {
124    let Some(kind) = cfg.embedding_provider else {
125        anyhow::bail!(
126            "graph-storage.embedding_provider is not set; name one of `fake`, `onnx` or \
127             `remote` -- the gear does not fall back to the fake, whose vectors rank nothing \
128             meaningfully"
129        );
130    };
131    match kind {
132        EmbeddingProviderKind::Fake => {
133            warn!(
134                "graph-storage.embedding_provider is `fake`: vector search will answer, \
135                 but its ranking carries no semantics"
136            );
137            Ok(Arc::new(FakeEmbeddingProvider::new(
138                cfg.embedding_dimension,
139            )))
140        }
141        EmbeddingProviderKind::Onnx => onnx_provider(cfg).await,
142        EmbeddingProviderKind::Remote => remote_provider(cfg),
143    }
144}
145
146#[cfg(feature = "remote")]
147fn remote_provider(cfg: &GraphStorageConfig) -> anyhow::Result<Arc<dyn EmbeddingProviderV1>> {
148    let named = |key: &str, value: &Option<String>| -> anyhow::Result<String> {
149        value.clone().ok_or_else(|| {
150            anyhow::anyhow!("graph-storage.{key} is required by the `remote` embedding provider")
151        })
152    };
153    let mut config = remote_embedding_plugin::RemoteProviderConfig::new(
154        named("embedding_remote_base_url", &cfg.embedding_remote_base_url)?,
155        named("embedding_remote_model", &cfg.embedding_remote_model)?,
156    );
157    config.dimension = cfg.embedding_dimension;
158    config.request_dimensions = cfg.embedding_remote_request_dimensions;
159    config.batch_size = cfg.embedding_remote_batch_size as usize;
160    config.timeout = std::time::Duration::from_secs(cfg.embedding_remote_timeout_secs);
161
162    // The credential is named, not carried: the config file (and its dump)
163    // holds the variable's name, the process environment holds the value.
164    if let Some(variable) = &cfg.embedding_remote_api_key_env {
165        let value = std::env::var(variable).map_err(|_| {
166            anyhow::anyhow!(
167                "graph-storage.embedding_remote_api_key_env names {variable}, which is not set \
168                 in this process's environment"
169            )
170        })?;
171        if value.trim().is_empty() {
172            anyhow::bail!(
173                "graph-storage.embedding_remote_api_key_env names {variable}, which is empty"
174            );
175        }
176        config = config.with_api_key(value);
177    }
178
179    let provider = remote_embedding_plugin::RemoteEmbeddingProvider::new(config)?;
180    info!(
181        endpoint = %provider.endpoint(),
182        model = %provider.embedding_space().model_artifact,
183        "configured the remote embedding provider"
184    );
185    Ok(Arc::new(provider))
186}
187
188#[cfg(not(feature = "remote"))]
189fn remote_provider(_cfg: &GraphStorageConfig) -> anyhow::Result<Arc<dyn EmbeddingProviderV1>> {
190    anyhow::bail!(
191        "graph-storage.embedding_provider is `remote` but this binary was built without the \
192         `remote` feature; rebuild with it or choose another provider"
193    )
194}
195
196#[cfg(feature = "onnx")]
197async fn onnx_provider(cfg: &GraphStorageConfig) -> anyhow::Result<Arc<dyn EmbeddingProviderV1>> {
198    let named = |key: &str, value: &Option<String>| -> anyhow::Result<String> {
199        value.clone().ok_or_else(|| {
200            anyhow::anyhow!("graph-storage.{key} is required by the `onnx` embedding provider")
201        })
202    };
203    let mut config = onnx_embedding_plugin::OnnxProviderConfig::new(
204        named("embedding_model_path", &cfg.embedding_model_path)?,
205        named("embedding_tokenizer_path", &cfg.embedding_tokenizer_path)?,
206    );
207    config.dimension = cfg.embedding_dimension;
208
209    // A `RuntimeHung` here has leaked a thread that cannot be joined, so the
210    // process must end rather than retry. Returning the error does that: the
211    // platform aborts the boot.
212    let provider = onnx_embedding_plugin::OnnxEmbeddingProvider::load(config).await?;
213    info!(
214        model = %provider.embedding_space().model_artifact,
215        "loaded the in-process ONNX embedding provider"
216    );
217    Ok(Arc::new(provider))
218}
219
220#[cfg(not(feature = "onnx"))]
221#[expect(
222    clippy::unused_async,
223    reason = "one signature for both builds; the feature-enabled arm is async"
224)]
225async fn onnx_provider(_cfg: &GraphStorageConfig) -> anyhow::Result<Arc<dyn EmbeddingProviderV1>> {
226    anyhow::bail!(
227        "graph-storage.embedding_provider is `onnx` but this binary was built without the \
228         `onnx` feature; rebuild with it or choose another provider"
229    )
230}
231
232/// Reconcile the provider against the space the stored vectors belong to.
233///
234/// A mismatch does not stop the gear: only the vector arm is incomparable, and
235/// every other path serves the same rows it always did. It stops *that arm*,
236/// loudly, which is what `fr-embedding-dim-guard` asks for — the readiness
237/// surface that should also report it does not exist yet (a known gap of this iteration).
238async fn resolve_embedding_space(
239    db: &toolkit_db::secure::Db,
240    provider: Arc<dyn EmbeddingProviderV1>,
241    cfg: &GraphStorageConfig,
242) -> anyhow::Result<crate::domain::embedding::EmbeddingCoordinator> {
243    // The provider's own width against the migrated column, before anything
244    // is written: a provider of the wrong width cannot produce one storable
245    // vector, so this is a configuration error rather than a runtime one.
246    if provider.dimension() != cfg.embedding_dimension {
247        anyhow::bail!(
248            "the embedding provider declares {} dimensions but \
249             graph-storage.embedding_dimension is {}",
250            provider.dimension(),
251            cfg.embedding_dimension
252        );
253    }
254
255    let state = match spaces::resolve(db, provider.embedding_space()).await? {
256        spaces::SpaceResolution::Active { epoch } => {
257            info!(
258                epoch,
259                identity = %provider.embedding_space().identity_hash,
260                model = %provider.embedding_space().model_artifact,
261                "embedding space active"
262            );
263            SpaceState::Active { epoch }
264        }
265        spaces::SpaceResolution::Mismatched {
266            recorded_identity,
267            recorded_epoch,
268        } => {
269            error!(
270                recorded_epoch,
271                recorded_identity = %recorded_identity,
272                active_identity = %provider.embedding_space().identity_hash,
273                "stored vectors belong to a different embedding space than the configured \
274                 provider; vector search is blocked until the graph is re-embedded"
275            );
276            SpaceState::Blocked
277        }
278    };
279
280    Ok(crate::domain::embedding::EmbeddingCoordinator::new(
281        provider,
282        state,
283        cfg.embedding_input_max_bytes,
284    ))
285}
286
287impl DatabaseCapability for GraphStorage {
288    fn migrations(&self) -> Vec<Box<dyn sea_orm_migration::MigrationTrait>> {
289        use sea_orm_migration::MigratorTrait;
290        crate::infra::storage::migrations::Migrator::migrations()
291    }
292}
293
294impl RestApiCapability for GraphStorage {
295    fn register_rest(
296        &self,
297        _ctx: &GearCtx,
298        router: axum::Router,
299        openapi: &dyn OpenApiRegistry,
300    ) -> anyhow::Result<axum::Router> {
301        let services = self
302            .services
303            .get()
304            .ok_or_else(|| anyhow::anyhow!("graph-storage services are not initialized"))?
305            .clone();
306        Ok(routes::register_routes(router, openapi, services))
307    }
308
309    /// Readiness through the platform's own `/readyz` and `/health`, which
310    /// the gateway can serve on a listener of its own, apart from the API.
311    /// One composite check, as the platform asks: the gear's aggregate, not
312    /// its rows. The per-component detail stays on the gear's route.
313    fn healthcheck(&self, _ctx: &GearCtx) -> Option<Arc<dyn Healthcheck>> {
314        let services = self.services.get()?.clone();
315        Some(Arc::new(PlatformReadiness { services }))
316    }
317}
318
319struct PlatformReadiness {
320    services: Arc<GraphServices>,
321}
322
323#[async_trait]
324impl Healthcheck for PlatformReadiness {
325    fn name(&self) -> &'static str {
326        "graph-storage"
327    }
328
329    async fn check(&self) -> HealthcheckResult {
330        platform_result(&self.services.readiness().await)
331    }
332}
333
334/// The gear's readiness as the platform reads it.
335///
336/// Not ready is `unhealthy`: the pod leaves rotation. Ready with any row
337/// degraded or unhealthy -- a space mismatch, an unavailable provider, a
338/// preferred backend absent -- is `degraded`, which keeps it in rotation:
339/// the platform asks that a dependency the gear can serve around not evict
340/// every pod. A capability this build does not ship is not a fault. The
341/// message names components only, never a row's text, because `/health` is
342/// unauthenticated.
343fn platform_result(readiness: &graph_storage_sdk::models::Readiness) -> HealthcheckResult {
344    use graph_storage_sdk::models::ReadinessState;
345
346    let named = |fatal: bool| {
347        readiness
348            .components
349            .iter()
350            .filter(|row| {
351                if fatal {
352                    row.fatal()
353                } else {
354                    matches!(
355                        row.state,
356                        ReadinessState::Degraded | ReadinessState::Unhealthy
357                    )
358                }
359            })
360            .map(|row| row.component.as_str())
361            .collect::<Vec<_>>()
362            .join(", ")
363    };
364    if !readiness.ready {
365        return HealthcheckResult::unhealthy(format!("not ready: {}", named(true)))
366            .with_code("graph_storage.not_ready");
367    }
368    let degraded = named(false);
369    if degraded.is_empty() {
370        HealthcheckResult::healthy()
371    } else {
372        HealthcheckResult::degraded(format!("degraded: {degraded}"))
373            .with_code("graph_storage.degraded")
374    }
375}
376
377#[cfg(test)]
378mod platform_readiness_tests {
379    use graph_storage_sdk::models::{
380        ComponentReadiness, DATABASE, DYNAMIC_INDEXES, EMBEDDING_SPACE, Readiness, ReadinessState,
381    };
382    use toolkit::HealthcheckStatus;
383
384    use super::platform_result;
385
386    fn row(component: &str, state: ReadinessState) -> ComponentReadiness {
387        ComponentReadiness::new(
388            component,
389            state,
390            "a problem text the platform must not see",
391            "what it blocks",
392            "recovery",
393        )
394    }
395
396    #[test]
397    fn all_healthy_is_healthy_and_a_missing_capability_is_not_a_fault() {
398        let result = platform_result(&Readiness::of(vec![
399            ComponentReadiness::healthy(DATABASE),
400            row(DYNAMIC_INDEXES, ReadinessState::NotImplemented),
401        ]));
402        assert_eq!(result.status, HealthcheckStatus::Healthy, "{result:?}");
403        assert_eq!(result.code, None);
404    }
405
406    /// The row the matrix keeps ready while unhealthy stays in rotation.
407    #[test]
408    fn a_space_mismatch_degrades_and_names_only_the_component() {
409        let result = platform_result(&Readiness::of(vec![
410            ComponentReadiness::healthy(DATABASE),
411            row(EMBEDDING_SPACE, ReadinessState::Unhealthy),
412        ]));
413        assert_eq!(result.status, HealthcheckStatus::Degraded, "{result:?}");
414        assert_eq!(result.code.as_deref(), Some("graph_storage.degraded"));
415        let message = result.message.unwrap_or_default();
416        assert!(message.contains(EMBEDDING_SPACE), "{message}");
417        assert!(!message.contains("problem text"), "{message}");
418    }
419
420    #[test]
421    fn not_ready_is_unhealthy_and_names_what_blocks_it() {
422        let result = platform_result(&Readiness::of(vec![
423            row(DATABASE, ReadinessState::Unhealthy),
424            row(EMBEDDING_SPACE, ReadinessState::Unhealthy),
425        ]));
426        assert_eq!(result.status, HealthcheckStatus::Unhealthy, "{result:?}");
427        assert_eq!(result.code.as_deref(), Some("graph_storage.not_ready"));
428        assert_eq!(result.message, Some(format!("not ready: {DATABASE}")));
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    //! The boot-time path an operator depends on to get the provider they
435    //! configured: the plugins' own suites construct their providers
436    //! directly, so without these nothing ever ran `select_embedding_provider`
437    //! or either feature arm behind it.
438
439    use super::{EmbeddingProviderKind, GraphStorageConfig, select_embedding_provider};
440
441    fn with(kind: Option<EmbeddingProviderKind>) -> GraphStorageConfig {
442        GraphStorageConfig {
443            embedding_provider: kind,
444            ..GraphStorageConfig::default()
445        }
446    }
447
448    /// Unset is refused rather than quietly served by the fake, whose
449    /// vectors rank nothing -- and the refusal names what to set.
450    #[tokio::test]
451    async fn an_unset_provider_is_refused_and_says_what_to_set() {
452        let refused = select_embedding_provider(&with(None))
453            .await
454            .err()
455            .expect("no provider is not a default");
456        let message = refused.to_string();
457        for named in ["embedding_provider", "fake", "onnx", "remote"] {
458            assert!(message.contains(named), "{named} is named: {message}");
459        }
460    }
461
462    #[tokio::test]
463    async fn the_fake_is_wired_at_the_configured_dimension() {
464        let config = GraphStorageConfig {
465            embedding_dimension: 16,
466            ..with(Some(EmbeddingProviderKind::Fake))
467        };
468        let provider = select_embedding_provider(&config)
469            .await
470            .expect("the fake needs nothing");
471        assert_eq!(provider.embedding_space().dimension, 16);
472    }
473
474    /// Built with `remote`: the configuration becomes a remote provider,
475    /// and each key it cannot do without is refused by name.
476    #[cfg(feature = "remote")]
477    #[tokio::test]
478    async fn remote_is_wired_from_the_configuration() {
479        let configured = GraphStorageConfig {
480            // Loopback, so no credential rule applies and nothing is sent:
481            // constructing the provider validates, it does not call out.
482            embedding_dimension: 32,
483            embedding_remote_base_url: Some("http://127.0.0.1:9/v1".to_owned()),
484            embedding_remote_model: Some("wired-model".to_owned()),
485            ..with(Some(EmbeddingProviderKind::Remote))
486        };
487        let provider = select_embedding_provider(&configured)
488            .await
489            .expect("a complete remote configuration boots");
490        // The remote identity is the model *at* its endpoint -- the same model
491        // name behind two endpoints is two spaces -- so this one comparison
492        // proves both configured values reached the provider.
493        assert_eq!(
494            provider.embedding_space().model_artifact,
495            "wired-model@http://127.0.0.1:9/v1/embeddings"
496        );
497        assert_eq!(provider.embedding_space().dimension, 32);
498
499        for (missing, key) in [
500            (
501                GraphStorageConfig {
502                    embedding_remote_base_url: None,
503                    ..configured.clone()
504                },
505                "embedding_remote_base_url",
506            ),
507            (
508                GraphStorageConfig {
509                    embedding_remote_model: None,
510                    ..configured.clone()
511                },
512                "embedding_remote_model",
513            ),
514        ] {
515            let refused = select_embedding_provider(&missing)
516                .await
517                .err()
518                .expect("a required key is required");
519            assert!(refused.to_string().contains(key), "{key}: {refused}");
520        }
521
522        // A credential variable that names nothing in this environment is a
523        // boot failure, not a provider that fails every request later.
524        let unset = GraphStorageConfig {
525            embedding_remote_api_key_env: Some(
526                "GRAPH_STORAGE_TEST_CREDENTIAL_THAT_IS_NEVER_SET".to_owned(),
527            ),
528            ..configured
529        };
530        let refused = select_embedding_provider(&unset)
531            .await
532            .err()
533            .expect("an unset credential variable stops the boot");
534        assert!(
535            refused.to_string().contains("not set"),
536            "the refusal says why: {refused}"
537        );
538    }
539
540    /// Built without `remote`, asking for it is a clear refusal rather than
541    /// a silent substitution.
542    #[cfg(not(feature = "remote"))]
543    #[tokio::test]
544    async fn remote_without_the_feature_says_so() {
545        let refused = select_embedding_provider(&with(Some(EmbeddingProviderKind::Remote)))
546            .await
547            .err()
548            .expect("a provider the binary lacks is refused");
549        assert!(
550            refused.to_string().contains("`remote` feature"),
551            "{refused}"
552        );
553    }
554
555    /// Built with `onnx`: a missing artifact is refused by name before
556    /// anything is loaded. Loading real artifacts is the ONNX lane's case
557    /// below, since only that lane has them.
558    #[cfg(feature = "onnx")]
559    #[tokio::test]
560    async fn onnx_names_the_artifact_it_is_missing() {
561        let refused = select_embedding_provider(&with(Some(EmbeddingProviderKind::Onnx)))
562            .await
563            .err()
564            .expect("no artifacts, no provider");
565        assert!(
566            refused.to_string().contains("embedding_model_path"),
567            "{refused}"
568        );
569    }
570
571    /// With the artifacts the ONNX lane downloads, the configured provider
572    /// is the one that loads. Skipped where they are absent, unless the
573    /// lane requires it -- a green run that quietly skipped would prove
574    /// nothing about the wiring.
575    #[cfg(feature = "onnx")]
576    #[tokio::test]
577    async fn onnx_is_wired_from_the_configuration() {
578        let (Ok(model), Ok(tokenizer)) = (
579            std::env::var("GRAPH_STORAGE_ONNX_MODEL"),
580            std::env::var("GRAPH_STORAGE_ONNX_TOKENIZER"),
581        ) else {
582            assert!(
583                std::env::var("GRAPH_STORAGE_ONNX_REQUIRED").is_err(),
584                "GRAPH_STORAGE_ONNX_REQUIRED is set but the model artifacts are not"
585            );
586            eprintln!("no ONNX artifacts in this environment - skipping");
587            return;
588        };
589        let configured = GraphStorageConfig {
590            embedding_model_path: Some(model),
591            embedding_tokenizer_path: Some(tokenizer),
592            ..with(Some(EmbeddingProviderKind::Onnx))
593        };
594        let provider = select_embedding_provider(&configured)
595            .await
596            .expect("the downloaded artifacts load");
597        assert_eq!(
598            provider.embedding_space().dimension,
599            configured.embedding_dimension
600        );
601    }
602
603    #[cfg(not(feature = "onnx"))]
604    #[tokio::test]
605    async fn onnx_without_the_feature_says_so() {
606        let refused = select_embedding_provider(&with(Some(EmbeddingProviderKind::Onnx)))
607            .await
608            .err()
609            .expect("a provider the binary lacks is refused");
610        assert!(refused.to_string().contains("`onnx` feature"), "{refused}");
611    }
612}