Skip to main content

linkrs_server/
startup.rs

1//! Server startup functions
2//!
3//! Orchestrates storage, sync, transaction manager, and graph service initialization.
4
5use std::path::PathBuf;
6use std::sync::Arc;
7use std::time::Duration;
8
9#[cfg(feature = "embedding")]
10use llm_embedding::{EmbeddingConfig, EmbeddingService, OpenAICompatibleProvider};
11#[cfg(feature = "vector")]
12use log::warn;
13use log::{error, info};
14#[cfg(feature = "vector-qdrant")]
15use vector_client::VectorManager;
16
17use crate::config::Config;
18use crate::storage::{
19    GraphStorage, MetricsStorage, PersistenceConfig, PropertyGraphConfig, ResourceConfig,
20    StorageCommitOps, StorageTtlPurgeOps, SyncWrapper,
21};
22use crate::{GraphService, HttpServer};
23use linkrs_core::error::DBResult;
24use linkrs_core::types::set_bcrypt_cost;
25#[cfg(feature = "vector")]
26use linkrs_sync::backend::VectorBackend;
27use linkrs_transaction::{TransactionConfig, TransactionManager, TransactionManagerConfig};
28
29/// Start the service using the user configuration directory.
30pub async fn start_service() -> DBResult<()> {
31    let (config, config_path) = match Config::load_user_config() {
32        Ok(config) => {
33            let path = Config::user_config_path().ok().filter(|p| p.exists());
34            (config, path)
35        }
36        Err(e) => {
37            error!("Failed to load user config, using default config: {}", e);
38            (Config::default(), None)
39        }
40    };
41    start_service_with_config_path(config, config_path).await
42}
43
44/// Start the service using the configuration object.
45pub async fn start_service_with_config(config: Config) -> DBResult<()> {
46    start_service_with_config_path(config, None).await
47}
48
49/// Start the service using the configuration object, retaining the config
50/// file path so config-write endpoints can persist updates.
51pub async fn start_service_with_config_path(
52    config: Config,
53    config_path: Option<PathBuf>,
54) -> DBResult<()> {
55    info!("Initializing Linkrs service...");
56
57    config.validate().map_err(|error| {
58        linkrs_core::DBError::validation(format!("Invalid configuration: {}", error))
59    })?;
60
61    let mut config = config;
62    match config.resolve_bootstrap_password() {
63        Ok(source) => info!(
64            "Bootstrap account '{}' password source: {:?}",
65            config.server.auth.default_username, source
66        ),
67        Err(error) => {
68            return Err(linkrs_core::DBError::validation(format!(
69                "Failed to resolve the bootstrap password: {}",
70                error
71            )));
72        }
73    }
74    info!(
75        "Listeners: http {}:{}, grpc {}:{}",
76        config.http_bind_address(),
77        config.http_port(),
78        config.grpc_bind_address(),
79        config.grpc_port()
80    );
81
82    // Apply the bcrypt cost factor before any password hashing happens.
83    // The cost lives in a global atomic; hot updates via UPDATE CONFIGS
84    // re-store it, so later calls take effect for subsequent hashes.
85    set_bcrypt_cost(config.server.auth.bcrypt_cost);
86
87    info!(
88        "Log system has been initialized: {}/{}",
89        config.log_dir(),
90        config.log_basename()
91    );
92
93    // Create shared StatsManager for all components before wiring storage decorators.
94    let slow_query_config = config.to_slow_query_config();
95    let m = &config.monitoring;
96    let stats_manager = Arc::new(
97        linkrs_metrics::StatsManager::with_slow_query_logger(
98            m.enabled,
99            m.memory_cache_size,
100            m.slow_query_threshold_ms * 1000,
101            slow_query_config,
102        )
103        .expect("Failed to create StatsManager with slow query logger"),
104    );
105    stats_manager.set_timeseries_capacity(m.timeseries_retention_secs);
106    stats_manager.set_histogram_max_samples(m.histogram_max_samples);
107
108    let storage_path = PathBuf::from(config.storage_path());
109    let mut persistence_config = PersistenceConfig::for_work_dir(&storage_path)
110        .with_property_graph_config(property_graph_config_from_config(&config.storage));
111    persistence_config.sync_policy = Some(config.storage.sync_mode.to_sync_policy());
112    if config.storage.checkpoint_interval_secs > 0 {
113        persistence_config.auto_checkpoint_interval =
114            Duration::from_secs(config.storage.checkpoint_interval_secs);
115    }
116    let mut graph_storage =
117        GraphStorage::open_with_persistence_config(storage_path, persistence_config)?;
118    graph_storage = graph_storage.set_stats_manager(stats_manager.clone());
119    let version_manager = graph_storage.version_manager();
120    let inner_storage = Arc::new(MetricsStorage::new(graph_storage));
121    info!(
122        "Storage initialized (persistent mode at {}, metrics enabled)",
123        config.storage_path()
124    );
125
126    // Build the shared vector backend from configuration (Local or Qdrant).
127    #[cfg(feature = "vector")]
128    let (vector_backend, local_engine_handle): (
129        Option<VectorBackend>,
130        Option<Arc<simvec::LocalVectorEngine>>,
131    ) = if config.is_vector_enabled() {
132        match config.vector_config().engine {
133            linkrs_config::VectorEngineKind::Local => {
134                let data_dir = config.vector_data_dir();
135                match simvec::LocalVectorEngine::open(&data_dir) {
136                    Ok(engine) => {
137                        if let Some(hnsw) = linkrs_api::vector_config::local_hnsw_config(
138                            &config.vector_config().local,
139                        ) {
140                            engine.set_default_hnsw_config(hnsw);
141                        }
142                        if let Some(ivf) = linkrs_api::vector_config::local_ivf_config(
143                            &config.vector_config().local,
144                        ) {
145                            engine.set_default_ivf_config(ivf);
146                        }
147                        if let Some(quant) = linkrs_api::vector_config::local_quantization_config(
148                            &config.vector_config().local,
149                        ) {
150                            engine.set_default_quantization_config(quant);
151                        }
152                        info!("Local vector engine initialized at {}", data_dir.display());
153                        let arc = Arc::new(engine);
154                        let handle = Arc::clone(&arc);
155                        (Some(VectorBackend::from_local_arc(arc)), Some(handle))
156                    }
157                    Err(e) => {
158                        warn!(
159                            "Failed to open local vector engine at {}: {}. Vector search will be disabled.",
160                            data_dir.display(),
161                            e
162                        );
163                        (None, None)
164                    }
165                }
166            }
167            #[cfg(feature = "vector-qdrant")]
168            linkrs_config::VectorEngineKind::Qdrant => {
169                match VectorManager::new(config.vector_config().qdrant.clone()).await {
170                    Ok(vm) => {
171                        info!("VectorManager initialized");
172                        (Some(VectorBackend::qdrant(Arc::new(vm))), None)
173                    }
174                    Err(e) => {
175                        warn!(
176                            "Failed to create VectorManager: {}. Vector search will be disabled.",
177                            e
178                        );
179                        (None, None)
180                    }
181                }
182            }
183        }
184    } else {
185        (None, None)
186    };
187    #[cfg(not(feature = "vector"))]
188    let _vector_backend = (None::<()>, None::<()>);
189
190    // Forward local vector engine metrics into the shared StatsManager.
191    #[cfg(feature = "vector")]
192    if let Some(engine) = local_engine_handle {
193        crate::vector_metrics::spawn_vector_metrics_sampler(engine, stats_manager.clone());
194        info!("vector metrics sampling enabled");
195    }
196
197    // Forward remote (Qdrant) vector engine metrics into the shared StatsManager.
198    #[cfg(feature = "vector-qdrant")]
199    if let Some(manager) = vector_backend.as_ref().and_then(|b| b.as_qdrant_manager()) {
200        let conn = &manager.config().connection;
201        let http_port = conn.http_port.unwrap_or(6333);
202        crate::vector_metrics::spawn_remote_vector_metrics_sampler(
203            conn.host.clone(),
204            http_port,
205            conn.api_key.clone(),
206            stats_manager.clone(),
207        );
208        info!(
209            "remote vector metrics sampling enabled ({}:{})",
210            conn.host, http_port
211        );
212    }
213
214    let mut sync_manager = if config.fulltext.enabled || config.is_vector_enabled() {
215        use linkrs_sync::SyncManager;
216
217        let sync_manager = if config.fulltext.enabled {
218            #[cfg(feature = "fulltext")]
219            {
220                use linkrs_fulltext::manager::FulltextIndexManager;
221
222                let manager = Arc::new(
223                    FulltextIndexManager::new(config.fulltext.clone())
224                        .expect("Failed to create FulltextIndexManager"),
225                );
226
227                use linkrs_fulltext::SyncConfig;
228
229                let sync_config = SyncConfig {
230                    queue_size: config.fulltext.sync.queue_size,
231                    commit_interval_ms: config.fulltext.sync.commit_interval_ms,
232                    batch_size: config.fulltext.sync.batch_size,
233                    failure_policy: config.fulltext.sync.failure_policy,
234                };
235
236                let batch_config = linkrs_sync::batch::BatchConfig::from(sync_config.clone());
237                let sync_coordinator =
238                    Arc::new(linkrs_sync::fulltext_sync::FulltextSyncCoordinator::new(
239                        manager.clone(),
240                        batch_config,
241                    ));
242
243                let sync_manager = SyncManager::with_sync_config(sync_coordinator, sync_config);
244
245                // Attach vector coordinator if a backend is available
246                #[cfg(feature = "vector")]
247                let sync_manager = if let Some(backend) = &vector_backend {
248                    attach_vector_coordinator(sync_manager, backend.clone(), &config).map_err(
249                        |error| {
250                            linkrs_core::DBError::storage(format!(
251                                "Invalid vector embedding configuration: {}",
252                                error
253                            ))
254                        },
255                    )?
256                } else {
257                    sync_manager
258                };
259
260                Some(Arc::new(sync_manager))
261            }
262            #[cfg(not(feature = "fulltext"))]
263            {
264                let sync_manager = SyncManager::new_without_fulltext();
265
266                // Attach vector coordinator if a backend is available
267                #[cfg(feature = "vector")]
268                let sync_manager = if let Some(backend) = &vector_backend {
269                    attach_vector_coordinator(sync_manager, backend.clone(), &config).map_err(
270                        |error| {
271                            linkrs_core::DBError::storage(format!(
272                                "Invalid vector embedding configuration: {}",
273                                error
274                            ))
275                        },
276                    )?
277                } else {
278                    sync_manager
279                };
280
281                Some(Arc::new(sync_manager))
282            }
283        } else {
284            let sync_manager = SyncManager::new_without_fulltext();
285
286            // Attach vector coordinator if a backend is available
287            #[cfg(feature = "vector")]
288            let sync_manager = if let Some(backend) = &vector_backend {
289                attach_vector_coordinator(sync_manager, backend.clone(), &config).map_err(
290                    |error| {
291                        linkrs_core::DBError::storage(format!(
292                            "Invalid vector embedding configuration: {}",
293                            error
294                        ))
295                    },
296                )?
297            } else {
298                sync_manager
299            };
300
301            Some(Arc::new(sync_manager))
302        };
303
304        if sync_manager.is_some() {
305            info!("SyncManager initialized");
306        }
307
308        sync_manager
309    } else {
310        None
311    };
312
313    if let Some(manager) = sync_manager.as_mut().and_then(Arc::get_mut) {
314        manager.set_stats_manager(stats_manager.clone());
315        let outbox_path = PathBuf::from(config.storage_path()).join("outbox/outbox.sqlite");
316        if let Err(error) = manager.configure_outbox(outbox_path) {
317            return Err(linkrs_core::DBError::storage(format!(
318                "Failed to initialize sync outbox: {}",
319                error
320            )));
321        }
322        if let Err(error) = manager.retry_outbox_sync() {
323            error!("Initial outbox delivery will be retried later: {}", error);
324        }
325        let recovered = inner_storage
326            .recover_outbox_projection(manager)
327            .map_err(|error| {
328                linkrs_core::DBError::storage(format!(
329                    "Failed to recover the SQLite outbox projection: {}",
330                    error
331                ))
332            })?;
333        if recovered > 0 {
334            info!("Recovered {} outbox intents from committed WAL", recovered);
335        }
336    }
337
338    if let Some(manager) = sync_manager.as_ref() {
339        manager.start().await.map_err(|error| {
340            linkrs_core::DBError::storage(format!("Failed to start sync manager: {}", error))
341        })?;
342        // Fail stranded rebuild generations and collect rebuild scratch
343        // state left by a previous process. Idempotent; a failure is logged
344        // without blocking startup (recovery is re-runnable on demand).
345        match manager.recover_stale_rebuilds().await {
346            Ok(0) => {}
347            Ok(failed) => info!(
348                "Rebuild startup recovery failed {} stranded generation(s)",
349                failed
350            ),
351            Err(error) => log::warn!(
352                "Rebuild startup recovery reported an error: {}; continuing startup",
353                error
354            ),
355        }
356        // Retention loop (hourly by default, driven by OutboxRetentionConfig).
357        #[cfg(feature = "vector")]
358        {
359            let retention = config.vector.retention.clone();
360            if retention.enabled {
361                let interval_secs = retention.prune_interval_secs;
362                let grace = retention.grace_lsn_distance;
363                let max_age = retention.max_applied_age_ms;
364                info!(
365                    "Outbox retention enabled interval={}s grace={} max_age_ms={}",
366                    interval_secs, grace, max_age
367                );
368                let mgr = manager.clone();
369                tokio::spawn(async move {
370                    let interval = std::time::Duration::from_secs(interval_secs.max(60));
371                    loop {
372                        tokio::time::sleep(interval).await;
373                        match mgr.run_retention_once(grace, max_age) {
374                            Ok((pruned, archived, lsn)) if pruned + archived > 0 => {
375                                info!(
376                                    "Outbox retention pruned {} archived {} retention_lsn={}",
377                                    pruned, archived, lsn
378                                );
379                            }
380                            Ok(_) => {}
381                            Err(e) => {
382                                log::warn!("Outbox retention cycle failed: {}", e);
383                            }
384                        }
385                    }
386                });
387            }
388        }
389    }
390
391    let storage = if let Some(ref sync_manager) = sync_manager {
392        let sync_storage =
393            SyncWrapper::with_sync_manager((*inner_storage).clone(), sync_manager.clone());
394        info!("Sync enabled for fulltext and vector indexes");
395        Arc::new(sync_storage)
396    } else {
397        let sync_storage = SyncWrapper::new((*inner_storage).clone());
398        Arc::new(sync_storage)
399    };
400
401    // Opt-in background TTL purge. Off by default so deployments keep the
402    // explicit `PURGE EXPIRED` contract; a zero interval stays off too.
403    if config.storage.ttl_purge_enabled && config.storage.ttl_purge_interval_secs > 0 {
404        storage.start_ttl_purge(Duration::from_secs(config.storage.ttl_purge_interval_secs));
405        info!(
406            "Background TTL purge enabled (interval {}s)",
407            config.storage.ttl_purge_interval_secs
408        );
409    }
410
411    // Create a transaction manager
412    let txn_config = TransactionManagerConfig {
413        default_timeout: std::time::Duration::from_secs(config.transaction.default_timeout),
414        max_concurrent_transactions: config.transaction.max_concurrent_transactions,
415        auto_cleanup: true,
416        commit_retry_attempts: 3,
417        abort_retry_attempts: 3,
418        txn_config: TransactionConfig {
419            auto_commit: config.transaction.auto_commit,
420            ..TransactionConfig::default()
421        },
422        ..TransactionManagerConfig::default()
423    };
424
425    let mut transaction_manager = TransactionManager::with_shared_version_manager(
426        txn_config,
427        stats_manager.clone(),
428        version_manager,
429    );
430    if let Some(ref sync_manager) = sync_manager {
431        transaction_manager = transaction_manager.with_sync_manager(sync_manager.clone());
432    }
433    transaction_manager = transaction_manager.with_commit_sink(storage.clone());
434    let transaction_manager = Arc::new(transaction_manager);
435    let _cleanup_task = transaction_manager.start_auto_cleanup_task();
436    info!("Transaction manager initialized with StatsManager");
437
438    // Re-drive durable-but-unfinalized commits left by a previous process.
439    // Storage WAL replay already ran during `GraphStorage::open...` above,
440    // so page-level redo is complete; this completes the transaction-level
441    // half (storage finalization + commit-timestamp allocation) from the
442    // sidecar file next to the WAL directory. A failure is logged without
443    // blocking startup: recovery is re-runnable on demand.
444    transaction_manager.set_recovery_sidecar_path(
445        PathBuf::from(config.storage_path())
446            .join("wal")
447            .join("pending.sidecar"),
448    );
449    match transaction_manager.startup_recovery() {
450        Ok(0) => {}
451        Ok(recovered) => info!(
452            "Transaction startup recovery completed {} commit(s)",
453            recovered
454        ),
455        Err(error) => log::warn!(
456            "Transaction startup recovery reported an error: {}; continuing startup",
457            error
458        ),
459    }
460
461    // Create GraphService with shared VectorBackend to avoid duplicate initialization
462    #[cfg(feature = "vector")]
463    let graph_service = if let Some(backend) = &vector_backend {
464        GraphService::with_shared_vector_backend(
465            config.clone(),
466            storage.clone(),
467            transaction_manager.clone(),
468            stats_manager.clone(),
469            backend.clone(),
470        )
471        .await
472    } else {
473        GraphService::new_with_transaction_manager_and_stats(
474            config.clone(),
475            storage.clone(),
476            transaction_manager.clone(),
477            stats_manager.clone(),
478        )
479        .await
480    };
481
482    #[cfg(not(feature = "vector"))]
483    let graph_service = GraphService::new_with_transaction_manager_and_stats(
484        config.clone(),
485        storage.clone(),
486        transaction_manager.clone(),
487        stats_manager.clone(),
488    )
489    .await;
490    info!("Graph service initialized with transaction management");
491
492    // Inject StatsManager into FulltextIndexManager to enable search metrics
493    #[cfg(feature = "fulltext")]
494    if let Some(sync_api) = graph_service.sync_api() {
495        let fulltext_manager = sync_api.sync_manager().fulltext_manager();
496        let stats_manager = graph_service.get_stats_manager().clone();
497        fulltext_manager.set_stats_manager(stats_manager);
498        info!("StatsManager injected into FulltextIndexManager for search metrics");
499    }
500
501    // Create HTTP server
502    let http_server = Arc::new(HttpServer::new(
503        graph_service.clone(),
504        Arc::new(parking_lot::RwLock::new((*storage).clone())),
505        transaction_manager,
506        &config,
507        config_path,
508    ));
509    info!("HTTP server created");
510
511    let http_enabled = config.server.http.enabled;
512    let grpc_enabled = {
513        #[cfg(feature = "grpc")]
514        {
515            config.server.grpc.enabled
516        }
517        #[cfg(not(feature = "grpc"))]
518        {
519            false
520        }
521    };
522    if !http_enabled && !grpc_enabled {
523        return Err(linkrs_core::DBError::validation(
524            "at least one of http.enabled and grpc.enabled must be true",
525        ));
526    }
527
528    if http_enabled {
529        info!(
530            "Starting HTTP server on {}:{}",
531            config.http_bind_address(),
532            config.http_port()
533        );
534    }
535    if grpc_enabled {
536        info!(
537            "Starting gRPC server on {}:{}",
538            config.grpc_bind_address(),
539            config.grpc_port()
540        );
541    }
542
543    #[cfg(feature = "grpc")]
544    if let Err(e) = super::start_http_and_grpc_servers(http_server, &config).await {
545        error!("HTTP/gRPC server error: {}", e);
546    }
547    #[cfg(not(feature = "grpc"))]
548    if let Err(e) = super::start_http_server(http_server, &config).await {
549        error!("HTTP server error: {}", e);
550    }
551
552    super::shutdown_signal().await;
553
554    info!("Shutting down Linkrs service...");
555    graph_service.shutdown();
556    Ok(())
557}
558
559/// Resolve the effective embedding config: the backend-independent top
560/// level wins, `[vector.qdrant.embedding]` overrides it when both are set.
561#[cfg(feature = "embedding")]
562fn resolve_effective_embedding(_config: &Config) -> Option<EmbeddingConfig> {
563    let top = _config.vector_config().embedding.clone();
564    #[cfg(feature = "vector-qdrant")]
565    {
566        if let Some(qdrant) = _config.vector_config().qdrant.embedding.clone() {
567            return Some(qdrant);
568        }
569    }
570    top
571}
572
573/// Build the shared document-side service plus the optional query-side
574/// service carrying the query preprocessor override. Backend independent:
575/// local and remote engines share the same wiring.
576#[cfg(feature = "embedding")]
577fn build_embedding_services(
578    _config: &Config,
579    auto_embed: bool,
580) -> Result<
581    (
582        Option<Arc<linkrs_sync::vector_sync::EmbeddingService>>,
583        Option<Arc<linkrs_sync::vector_sync::EmbeddingService>>,
584    ),
585    String,
586> {
587    let Some(doc_config) = resolve_effective_embedding(_config) else {
588        if auto_embed {
589            return Err(
590                "vector.auto_embed_text requires [vector.embedding] with explicit dimension"
591                    .to_string(),
592            );
593        }
594        return Ok((None, None));
595    };
596    let build = |config: EmbeddingConfig| {
597        OpenAICompatibleProvider::new(config)
598            .map(|provider| {
599                Arc::new(EmbeddingService::new(
600                    provider,
601                    linkrs_sync::vector_sync::EMBEDDING_BATCH_SIZE,
602                ))
603            })
604            .map_err(|err| format!("Failed to create embedding service: {}", err))
605    };
606    let doc_service = match build(doc_config.clone()) {
607        Ok(service) => service,
608        Err(err) => {
609            if auto_embed {
610                return Err(err);
611            }
612            warn!("Failed to create embedding service: {}", err);
613            return Ok((None, None));
614        }
615    };
616    let query_service = match _config.vector_config().embedding_query_preprocessor.clone() {
617        Some(preprocessor) => {
618            let mut query_config = doc_config;
619            query_config.preprocessor = preprocessor;
620            match build(query_config) {
621                Ok(service) => Some(service),
622                Err(err) => {
623                    if auto_embed {
624                        return Err(err);
625                    }
626                    warn!("Failed to create query embedding service: {}", err);
627                    None
628                }
629            }
630        }
631        None => None,
632    };
633    Ok((Some(doc_service), query_service))
634}
635
636/// Build the optional post-recall rerank service. Absent config disables
637/// rerank; invalid config warns and disables. Startup never fails for rerank.
638#[cfg(feature = "rerank")]
639fn build_rerank_service(
640    _config: &Config,
641) -> Option<(
642    Arc<llm_rerank::CohereRerankProvider>,
643    linkrs_config::VectorRerankConfig,
644)> {
645    let rerank = _config.vector_config().rerank.clone()?;
646    if let Err(err) = rerank.validate() {
647        warn!(
648            "Invalid vector rerank configuration, rerank disabled: {}",
649            err
650        );
651        return None;
652    }
653    match llm_rerank::CohereRerankProvider::new(rerank.endpoint.clone()) {
654        Ok(provider) => {
655            info!("Vector rerank enabled (model {})", rerank.endpoint.model);
656            Some((Arc::new(provider), rerank))
657        }
658        Err(err) => {
659            warn!("Failed to create rerank service, rerank disabled: {}", err);
660            None
661        }
662    }
663}
664
665/// Attach a vector sync coordinator backed by `backend` to a SyncManager.
666#[cfg(feature = "vector")]
667fn attach_vector_coordinator(
668    sync_manager: linkrs_sync::SyncManager,
669    backend: VectorBackend,
670    _config: &Config,
671) -> Result<linkrs_sync::SyncManager, String> {
672    let handle = tokio::runtime::Handle::current();
673    let auto_embed = _config.vector_config().auto_embed_text;
674    #[cfg(feature = "embedding")]
675    let (embedding_service, query_service) = build_embedding_services(_config, auto_embed)
676        .map_err(|error| format!("Invalid vector embedding configuration: {}", error))?;
677    #[cfg(feature = "embedding")]
678    let vector_coordinator = {
679        let coordinator = linkrs_sync::vector_sync::VectorSyncCoordinator::new(
680            backend.clone(),
681            embedding_service,
682            handle,
683        );
684        let coordinator = match query_service {
685            Some(query) => coordinator.with_query_embedding_service(query),
686            None => coordinator,
687        };
688        #[cfg(feature = "rerank")]
689        let coordinator = match build_rerank_service(_config) {
690            Some((service, rerank)) => coordinator.with_rerank_service(service, rerank),
691            None => coordinator,
692        };
693        Arc::new(coordinator)
694    };
695    #[cfg(not(feature = "embedding"))]
696    let vector_coordinator = {
697        let coordinator = linkrs_sync::vector_sync::VectorSyncCoordinator::new_without_embedding(
698            backend.clone(),
699            handle,
700        );
701        #[cfg(feature = "rerank")]
702        let coordinator = match build_rerank_service(_config) {
703            Some((service, rerank)) => coordinator.with_rerank_service(service, rerank),
704            None => coordinator,
705        };
706        Arc::new(coordinator)
707    };
708    #[cfg(not(feature = "embedding"))]
709    if auto_embed {
710        return Err(
711            "vector.auto_embed_text requires the embedding build with a configured service"
712                .to_string(),
713        );
714    }
715    {
716        let granularity = _config.vector_config().collection.granularity;
717        vector_coordinator.set_granularity(granularity);
718        info!("Vector collection granularity: {:?}", granularity);
719    }
720    info!("Vector index sync enabled");
721    let mut manager = sync_manager.with_vector_coordinator(vector_coordinator);
722    manager.set_auto_embed_text(auto_embed);
723    if auto_embed {
724        info!("Vector write-time auto-embedding enabled");
725    }
726    // Backend-aware delivery policy: batch / lease / retries / concurrency
727    // derived from `VectorConfig` so `manager.rs` does not branch on `is_local`.
728    let policy = linkrs_sync::backend::BackendDeliveryPolicy::from_config(_config.vector_config());
729    info!(
730        "Vector delivery policy: {} batch={} lease_ms={} retries={} concurrency={}",
731        policy.policy_name,
732        policy.batch_size,
733        policy.lease_ms,
734        policy.max_retries,
735        policy.max_concurrency
736    );
737    manager.configure_backend_policy(policy);
738    Ok(manager)
739}
740
741fn property_graph_config_from_config(
742    storage: &crate::config::StorageConfig,
743) -> PropertyGraphConfig {
744    let mut config = PropertyGraphConfig::default();
745    let cache_memory = match usize::try_from(storage.max_memory_bytes) {
746        Ok(value) => value.min(config.cache_memory),
747        Err(_) => config.cache_memory,
748    };
749    config.cache_memory = cache_memory;
750    config.resources = ResourceConfig {
751        max_memory_bytes: storage.max_memory_bytes,
752        index_memory_bytes: storage.index_memory_bytes,
753        memory_soft_ratio: storage.memory_soft_ratio,
754        memory_hard_ratio: storage.memory_hard_ratio,
755        max_active_snapshots: storage.max_active_snapshots,
756        max_snapshot_age: Duration::from_secs(storage.max_snapshot_age_secs),
757        max_tombstones: storage.max_tombstones,
758        max_tombstone_bytes: storage.max_tombstone_bytes,
759        index_gc_batch: storage.index_gc_batch,
760        operation_timeout: Duration::from_secs(storage.operation_timeout_secs),
761        dirty_flush_operations: storage.dirty_flush_operations,
762        dirty_flush_bytes: storage.dirty_flush_bytes,
763        cache_ttl: (storage.cache_ttl_secs > 0)
764            .then(|| Duration::from_secs(storage.cache_ttl_secs)),
765        cache_tti: (storage.cache_tti_secs > 0)
766            .then(|| Duration::from_secs(storage.cache_tti_secs)),
767        index_pool_capacity_bytes: storage.index_pool_capacity_bytes,
768        index_eviction_enabled: storage.index_eviction_enabled,
769        index_eviction_high_ratio: storage.index_eviction_high_ratio,
770        index_eviction_low_ratio: storage.index_eviction_low_ratio,
771        ..config.resources
772    };
773    config
774}
775
776/// Execute a single query directly (for CLI / quick testing).
777pub async fn execute_query(query_str: &str) -> DBResult<()> {
778    info!("Executing query: {}", query_str);
779
780    let config = crate::config::Config::default();
781    let inner_storage = Arc::new(GraphStorage::new()?);
782
783    let sync_storage = SyncWrapper::new((*inner_storage).clone());
784    let storage = Arc::new(sync_storage);
785
786    let graph_service =
787        GraphService::<SyncWrapper<GraphStorage>>::new_for_test(config, storage).await;
788
789    let session = match graph_service
790        .get_session_manager()
791        .create_session("anonymous".into(), "127.0.0.1".to_string())
792        .await
793    {
794        Ok(session) => session,
795        Err(e) => {
796            error!("Failed to create session: {}", e);
797            return Err(linkrs_core::error::DBError::from(
798                crate::session::SessionError::manager_error(format!(
799                    "Failed to create session: {}",
800                    e
801                )),
802            ));
803        }
804    };
805
806    let session_id = session.id();
807
808    match graph_service.execute(session_id, query_str).await {
809        Ok(result) => {
810            info!("Query executed successfully: {:?}", result);
811        }
812        Err(e) => {
813            error!("Query execution error: {}", e);
814        }
815    }
816
817    Ok(())
818}