1#![allow(clippy::disallowed_types)] use std::collections::HashMap;
5use std::path::PathBuf;
6use std::sync::Arc;
7
8use datafusion_expr::{AggregateUDF, ScalarUDF};
9use laminar_core::streaming::{BackpressureStrategy, StreamCheckpointConfig};
10
11use crate::config::LaminarConfig;
12use crate::db::{LaminarDB, RuntimeMode};
13use crate::error::DbError;
14use crate::profile::Profile;
15
16type ConnectorCallback = Box<
18 dyn FnOnce(
19 &laminar_connectors::registry::ConnectorRegistry,
20 ) -> Result<(), laminar_connectors::error::ConnectorError>
21 + Send,
22>;
23
24pub struct LaminarDbBuilder {
36 config: LaminarConfig,
37 config_vars: HashMap<String, String>,
38 connector_callbacks: Vec<ConnectorCallback>,
39 profile: Profile,
40 profile_explicit: bool,
41 delivery_explicit: bool,
42 object_store_url: Option<String>,
43 object_store_options: HashMap<String, String>,
44 custom_udfs: Vec<ScalarUDF>,
45 custom_udafs: Vec<AggregateUDF>,
46 #[cfg(feature = "cluster")]
47 cluster_controller: Option<std::sync::Arc<laminar_core::cluster::control::ClusterController>>,
48 #[cfg(feature = "cluster")]
49 shuffle_sender: Option<std::sync::Arc<laminar_core::shuffle::ShuffleSender>>,
50 #[cfg(feature = "cluster")]
51 shuffle_receiver: Option<std::sync::Arc<laminar_core::shuffle::ShuffleReceiver>>,
52 #[cfg(feature = "cluster")]
53 decision_store: Option<std::sync::Arc<laminar_core::cluster::control::CheckpointDecisionStore>>,
54 #[cfg(feature = "cluster")]
55 assignment_snapshot_store:
56 Option<std::sync::Arc<laminar_core::cluster::control::AssignmentSnapshotStore>>,
57 #[cfg(feature = "cluster")]
58 catalog_manifest_store:
59 Option<std::sync::Arc<laminar_core::cluster::control::CatalogManifestStore>>,
60 #[cfg(feature = "cluster")]
61 verified_cluster_namespaces: Option<laminar_core::cluster::control::VerifiedClusterNamespaces>,
62 #[cfg(all(feature = "cluster", test))]
63 unchecked_cluster_checkpoint_store: Option<Arc<dyn object_store::ObjectStore>>,
64 vnode_registry: Option<std::sync::Arc<laminar_core::state::VnodeRegistry>>,
65 key_groups: Option<laminar_core::state::KeyGroupCount>,
66 physical_optimizer_rules: Vec<
67 std::sync::Arc<dyn datafusion::physical_optimizer::PhysicalOptimizerRule + Send + Sync>,
68 >,
69 target_partitions: Option<usize>,
70 ai_runtime: Option<std::sync::Arc<crate::ai::AiRuntime>>,
71}
72
73impl LaminarDbBuilder {
74 #[must_use]
76 pub fn new() -> Self {
77 Self {
78 config: LaminarConfig::default(),
79 config_vars: HashMap::new(),
80 connector_callbacks: Vec::new(),
81 profile: Profile::default(),
82 profile_explicit: false,
83 delivery_explicit: false,
84 object_store_url: None,
85 object_store_options: HashMap::new(),
86 custom_udfs: Vec::new(),
87 custom_udafs: Vec::new(),
88 #[cfg(feature = "cluster")]
89 cluster_controller: None,
90 #[cfg(feature = "cluster")]
91 shuffle_sender: None,
92 #[cfg(feature = "cluster")]
93 shuffle_receiver: None,
94 #[cfg(feature = "cluster")]
95 decision_store: None,
96 #[cfg(feature = "cluster")]
97 assignment_snapshot_store: None,
98 #[cfg(feature = "cluster")]
99 catalog_manifest_store: None,
100 #[cfg(feature = "cluster")]
101 verified_cluster_namespaces: None,
102 #[cfg(all(feature = "cluster", test))]
103 unchecked_cluster_checkpoint_store: None,
104 vnode_registry: None,
105 key_groups: None,
106 physical_optimizer_rules: Vec::new(),
107 target_partitions: None,
108 ai_runtime: None,
109 }
110 }
111
112 #[must_use]
114 pub fn ai(mut self, runtime: std::sync::Arc<crate::ai::AiRuntime>) -> Self {
115 self.ai_runtime = Some(runtime);
116 self
117 }
118
119 #[must_use]
121 pub fn target_partitions(mut self, n: usize) -> Self {
122 self.target_partitions = Some(n);
123 self
124 }
125
126 #[must_use]
128 pub fn physical_optimizer_rule(
129 mut self,
130 rule: std::sync::Arc<
131 dyn datafusion::physical_optimizer::PhysicalOptimizerRule + Send + Sync,
132 >,
133 ) -> Self {
134 self.physical_optimizer_rules.push(rule);
135 self
136 }
137
138 #[must_use]
140 pub fn vnode_registry(
141 mut self,
142 registry: std::sync::Arc<laminar_core::state::VnodeRegistry>,
143 ) -> Self {
144 self.vnode_registry = Some(registry);
145 self
146 }
147
148 #[must_use]
152 pub fn key_groups(mut self, key_groups: laminar_core::state::KeyGroupCount) -> Self {
153 self.key_groups = Some(key_groups);
154 self
155 }
156
157 #[cfg(feature = "cluster")]
160 #[must_use]
161 pub fn cluster_controller(
162 mut self,
163 controller: std::sync::Arc<laminar_core::cluster::control::ClusterController>,
164 ) -> Self {
165 self.cluster_controller = Some(controller);
166 self
167 }
168
169 #[cfg(feature = "cluster")]
171 #[must_use]
172 pub fn shuffle_sender(
173 mut self,
174 sender: std::sync::Arc<laminar_core::shuffle::ShuffleSender>,
175 ) -> Self {
176 self.shuffle_sender = Some(sender);
177 self
178 }
179
180 #[cfg(feature = "cluster")]
182 #[must_use]
183 pub fn shuffle_receiver(
184 mut self,
185 receiver: std::sync::Arc<laminar_core::shuffle::ShuffleReceiver>,
186 ) -> Self {
187 self.shuffle_receiver = Some(receiver);
188 self
189 }
190
191 #[cfg(feature = "cluster")]
193 #[must_use]
194 pub fn decision_store(
195 mut self,
196 store: std::sync::Arc<laminar_core::cluster::control::CheckpointDecisionStore>,
197 ) -> Self {
198 self.decision_store = Some(store);
199 self
200 }
201
202 #[cfg(feature = "cluster")]
204 #[must_use]
205 pub fn assignment_snapshot_store(
206 mut self,
207 store: std::sync::Arc<laminar_core::cluster::control::AssignmentSnapshotStore>,
208 ) -> Self {
209 self.assignment_snapshot_store = Some(store);
210 self
211 }
212
213 #[cfg(feature = "cluster")]
215 #[must_use]
216 pub fn catalog_manifest_store(
217 mut self,
218 store: std::sync::Arc<laminar_core::cluster::control::CatalogManifestStore>,
219 ) -> Self {
220 self.catalog_manifest_store = Some(store);
221 self
222 }
223
224 #[cfg(feature = "cluster")]
226 #[must_use]
227 pub fn verified_cluster_namespaces(
228 mut self,
229 namespaces: laminar_core::cluster::control::VerifiedClusterNamespaces,
230 ) -> Self {
231 self.verified_cluster_namespaces = Some(namespaces);
232 self
233 }
234
235 #[cfg(all(feature = "cluster", test))]
237 #[must_use]
238 pub(crate) fn cluster_checkpoint_object_store(
239 mut self,
240 store: Arc<dyn object_store::ObjectStore>,
241 ) -> Self {
242 self.unchecked_cluster_checkpoint_store = Some(store);
243 self
244 }
245
246 #[must_use]
248 pub fn config_var(mut self, key: &str, value: &str) -> Self {
249 self.config_vars.insert(key.to_string(), value.to_string());
250 self
251 }
252
253 #[must_use]
256 pub fn http_auth_token(mut self, token: impl Into<String>) -> Self {
257 self.config.http_auth_token = Some(crate::config::SecretString::new(token));
258 self
259 }
260
261 #[must_use]
263 pub fn buffer_size(mut self, size: usize) -> Self {
264 self.config.default_buffer_size = size;
265 self
266 }
267
268 #[must_use]
270 pub fn backpressure(mut self, strategy: BackpressureStrategy) -> Self {
271 self.config.default_backpressure = strategy;
272 self
273 }
274
275 #[must_use]
277 pub fn storage_dir(mut self, path: impl Into<PathBuf>) -> Self {
278 self.config.storage_dir = Some(path.into());
279 self
280 }
281
282 #[must_use]
284 pub fn checkpoint(mut self, config: StreamCheckpointConfig) -> Self {
285 self.config.checkpoint = Some(config);
286 self
287 }
288
289 #[must_use]
294 pub fn profile(mut self, profile: Profile) -> Self {
295 self.profile = profile;
296 self.profile_explicit = true;
297 self
298 }
299
300 #[must_use]
305 pub fn object_store_url(mut self, url: impl Into<String>) -> Self {
306 self.object_store_url = Some(url.into());
307 self
308 }
309
310 #[must_use]
315 pub fn object_store_options(mut self, opts: HashMap<String, String>) -> Self {
316 self.object_store_options = opts;
317 self
318 }
319
320 #[must_use]
322 pub fn delivery_guarantee(
323 mut self,
324 guarantee: laminar_connectors::connector::DeliveryGuarantee,
325 ) -> Self {
326 self.config.delivery_guarantee = guarantee;
327 self.delivery_explicit = true;
328 self
329 }
330
331 #[must_use]
333 pub fn register_udf(mut self, udf: ScalarUDF) -> Self {
334 self.custom_udfs.push(udf);
335 self
336 }
337
338 #[must_use]
340 pub fn register_udaf(mut self, udaf: AggregateUDF) -> Self {
341 self.custom_udafs.push(udaf);
342 self
343 }
344
345 #[must_use]
350 pub fn push_source_max_bytes(mut self, bytes: usize) -> Self {
351 self.config.push_source_max_bytes = bytes;
352 self
353 }
354
355 #[must_use]
357 pub fn pipeline_channel_capacity(mut self, capacity: usize) -> Self {
358 self.config.pipeline_channel_capacity = Some(capacity);
359 self
360 }
361
362 #[must_use]
366 pub fn source_queue_max_bytes(mut self, bytes: usize) -> Self {
367 self.config.source_queue_max_bytes = bytes;
368 self
369 }
370
371 #[must_use]
373 pub fn pipeline_batch_window(mut self, window: std::time::Duration) -> Self {
374 self.config.pipeline_batch_window = Some(window);
375 self
376 }
377
378 #[must_use]
380 pub fn pipeline_drain_budget_ns(mut self, ns: u64) -> Self {
381 self.config.pipeline_drain_budget_ns = Some(ns);
382 self
383 }
384
385 #[must_use]
387 pub fn pipeline_query_budget_ns(mut self, ns: u64) -> Self {
388 self.config.pipeline_query_budget_ns = Some(ns);
389 self
390 }
391
392 #[must_use]
397 pub fn datafusion_memory_limit_bytes(mut self, bytes: usize) -> Self {
398 self.config.datafusion_memory_limit_bytes = bytes;
399 self
400 }
401
402 #[must_use]
405 pub fn reference_table_max_rows(mut self, rows: usize) -> Self {
406 self.config.reference_table_max_rows = rows;
407 self
408 }
409
410 #[must_use]
414 pub fn reference_table_max_bytes(mut self, bytes: usize) -> Self {
415 self.config.reference_table_max_bytes = bytes;
416 self
417 }
418
419 #[must_use]
422 pub fn materialized_view_max_rows(mut self, rows: usize) -> Self {
423 self.config.materialized_view_max_rows = rows;
424 self
425 }
426
427 #[must_use]
430 pub fn materialized_view_max_bytes(mut self, bytes: usize) -> Self {
431 self.config.materialized_view_max_bytes = bytes;
432 self
433 }
434
435 #[must_use]
438 pub fn materialized_view_read_cache_bytes(mut self, bytes: usize) -> Self {
439 self.config.materialized_view_read_cache_bytes = bytes;
440 self
441 }
442
443 #[must_use]
445 pub fn pipeline_max_input_buf_batches(mut self, batches: usize) -> Self {
446 self.config.pipeline_max_input_buf_batches = Some(batches);
447 self
448 }
449
450 #[must_use]
454 pub fn pipeline_max_input_buf_bytes(mut self, bytes: usize) -> Self {
455 self.config.pipeline_max_input_buf_bytes = Some(bytes);
456 self
457 }
458
459 #[must_use]
463 pub fn pipeline_max_managed_state_bytes(mut self, bytes: usize) -> Self {
464 self.config.pipeline_max_managed_state_bytes = Some(bytes);
465 self
466 }
467
468 #[must_use]
470 pub fn temporal_join_idle_history_retention(mut self, retention: std::time::Duration) -> Self {
471 self.config.temporal_join_idle_history_retention = Some(retention);
472 self
473 }
474
475 #[must_use]
477 pub fn source_idle_timeout(mut self, timeout: std::time::Duration) -> Self {
478 self.config.source_idle_timeout = Some(timeout);
479 self
480 }
481
482 #[must_use]
485 pub fn event_time_max_future_skew(mut self, skew: std::time::Duration) -> Self {
486 self.config.event_time_max_future_skew = skew;
487 self
488 }
489
490 #[must_use]
492 pub fn pipeline_backpressure_policy(
493 mut self,
494 policy: crate::config::BackpressurePolicy,
495 ) -> Self {
496 self.config.pipeline_backpressure_policy = policy;
497 self
498 }
499
500 #[must_use]
502 pub fn restart_policy(mut self, policy: crate::config::RestartPolicy) -> Self {
503 self.config.restart_policy = policy;
504 self
505 }
506
507 #[must_use]
510 pub fn register_connector(
511 mut self,
512 f: impl FnOnce(
513 &laminar_connectors::registry::ConnectorRegistry,
514 ) -> Result<(), laminar_connectors::error::ConnectorError>
515 + Send
516 + 'static,
517 ) -> Self {
518 self.connector_callbacks.push(Box::new(f));
519 self
520 }
521
522 #[allow(unknown_lints, clippy::unused_async, clippy::unused_async_trait_impl)]
529 pub async fn build(mut self) -> Result<Arc<LaminarDB>, DbError> {
530 let has_checkpoint_data_dir = self
531 .config
532 .checkpoint
533 .as_ref()
534 .and_then(|checkpoint| checkpoint.data_dir.as_ref())
535 .is_some();
536 if self.object_store_url.is_some() && has_checkpoint_data_dir {
537 return Err(DbError::Config(
538 "object_store_url conflicts with checkpoint.data_dir; configure exactly one checkpoint store"
539 .into(),
540 ));
541 }
542 #[cfg(feature = "cluster")]
543 if self.has_cluster_checkpoint_store() && self.object_store_url.is_some() {
544 return Err(DbError::Config(
545 "verified cluster namespaces conflict with object_store_url; configure exactly one checkpoint object store"
546 .into(),
547 ));
548 }
549 #[cfg(feature = "cluster")]
550 if self.has_cluster_checkpoint_store() && has_checkpoint_data_dir {
551 return Err(DbError::Config(
552 "verified cluster namespaces conflict with checkpoint.data_dir; configure exactly one checkpoint store"
553 .into(),
554 ));
555 }
556
557 self.config.object_store_url = self.object_store_url.take();
558 self.config.object_store_options = std::mem::take(&mut self.object_store_options);
559 if let Some(url) = self.config.object_store_url.as_deref().filter(|url| {
560 laminar_core::storage_location::StorageProvider::detect_uri(url)
561 == Some(laminar_core::storage_location::StorageProvider::Local)
562 }) {
563 laminar_core::checkpoint::object_store_builder::file_url_path(url)
564 .map_err(|error| DbError::Config(format!("checkpoint storage URL: {error}")))?;
565 }
566
567 if !self.profile_explicit {
568 self.profile = Profile::from_config(&self.config, false);
569 }
570
571 let runtime_mode = self.resolve_runtime_mode()?;
572 if runtime_mode.is_cluster() && !self.profile_explicit {
573 self.profile = Profile::Cluster;
574 }
575 if runtime_mode.is_cluster() && !self.delivery_explicit {
576 self.config.delivery_guarantee =
577 laminar_connectors::connector::DeliveryGuarantee::AtLeastOnce;
578 }
579
580 self.profile
581 .validate_features()
582 .map_err(|e| DbError::Config(e.to_string()))?;
583 Self::validate_cluster_delivery(runtime_mode, self.config.delivery_guarantee)?;
584 #[cfg(feature = "cluster")]
585 if runtime_mode == RuntimeMode::Cluster {
586 if self.config.object_store_url.is_some() || has_checkpoint_data_dir {
587 return Err(DbError::Config(
588 "[LDB-0011] cluster checkpoint storage must be supplied by a successful shared namespace proof; object_store_url and checkpoint.data_dir are not cluster authorities"
589 .into(),
590 ));
591 }
592 if !self.has_cluster_checkpoint_store() {
593 return Err(DbError::Config(
594 "[LDB-0011] cluster runtime requires VerifiedClusterNamespaces from the shared checkpoint namespace proof"
595 .into(),
596 ));
597 }
598 if let Some(namespaces) = &self.verified_cluster_namespaces {
599 let controller = self.cluster_controller.as_ref().ok_or_else(|| {
600 DbError::Config("cluster namespace proof requires a cluster controller".into())
601 })?;
602 let expected = laminar_core::checkpoint::CheckpointParticipant {
603 node_id: controller.instance_id().0,
604 boot_incarnation: controller.recovery_incarnation(),
605 };
606 if namespaces.local_participant() != expected {
607 let proved = namespaces.local_participant();
608 return Err(DbError::Config(format!(
609 "[LDB-0011] verified cluster namespaces belong to node {} boot {}, but the controller is node {} boot {}",
610 proved.node_id,
611 proved.boot_incarnation,
612 expected.node_id,
613 expected.boot_incarnation,
614 )));
615 }
616 }
617 }
618 #[cfg(feature = "cluster")]
619 let profile_object_store = self.config.object_store_url.as_deref().or_else(|| {
620 self.has_cluster_checkpoint_store()
621 .then_some("verified-cluster-checkpoint-store")
622 });
623 #[cfg(not(feature = "cluster"))]
624 let profile_object_store = self.config.object_store_url.as_deref();
625 self.profile
626 .validate_config(&self.config, profile_object_store)
627 .map_err(|e| DbError::Config(e.to_string()))?;
628
629 if runtime_mode == RuntimeMode::Local
630 && self.config.delivery_guarantee
631 != laminar_connectors::connector::DeliveryGuarantee::BestEffort
632 && self.config.object_store_url.as_deref().is_some_and(|url| {
633 laminar_core::storage_location::StorageProvider::detect_uri(url)
634 != Some(laminar_core::storage_location::StorageProvider::Local)
635 })
636 {
637 return Err(DbError::Config(
638 "[LDB-0014] a local replay-capable deployment with a shared cloud checkpoint namespace is not admitted until its writer lease is term-fenced; use a built-in or file:// local checkpoint directory, or best_effort delivery"
639 .into(),
640 ));
641 }
642
643 self.config.validate_backpressure_policy()?;
644 self.validate_vnode_topology(runtime_mode)?;
645 if let Some(key_groups) = self
646 .key_groups
647 .filter(|_| runtime_mode == RuntimeMode::Local && self.vnode_registry.is_none())
648 {
649 self.vnode_registry = Some(Arc::new(laminar_core::state::VnodeRegistry::single_owner(
650 u32::from(key_groups),
651 laminar_core::state::LOCAL_NODE_ID,
652 )));
653 }
654 #[cfg(feature = "cluster")]
655 self.bind_cluster_process_lease(runtime_mode)?;
656
657 self.profile.apply_defaults(&mut self.config);
658
659 #[cfg(feature = "cluster")]
660 let cluster_checkpoint_store = self.cluster_checkpoint_store();
661 let mut db = LaminarDB::open_with_config_and_vars_and_rules(
662 self.config,
663 self.config_vars,
664 &self.physical_optimizer_rules,
665 self.target_partitions,
666 runtime_mode,
667 )?;
668 for udf in self.custom_udfs {
669 db.register_custom_udf(udf)?;
670 }
671 for udaf in self.custom_udafs {
672 db.register_custom_udaf(udaf)?;
673 }
674 if let Some(runtime) = self.ai_runtime {
675 let handle = tokio::runtime::Handle::try_current().map_err(|_| {
676 DbError::InvalidOperation(
677 "LaminarDB::build() with an AI runtime must run inside a Tokio runtime"
678 .to_string(),
679 )
680 })?;
681 db.set_ai_runtime(runtime, handle);
682 }
683 for callback in self.connector_callbacks {
684 callback(db.connector_registry())?;
685 }
686 db.connector_registry().freeze();
687 #[cfg(feature = "cluster")]
688 if let Some(controller) = self.cluster_controller {
689 db.set_cluster_controller(controller)?;
690 }
691 #[cfg(feature = "cluster")]
692 if let Some(store) = cluster_checkpoint_store {
693 db.set_cluster_checkpoint_object_store(store)?;
694 }
695 #[cfg(feature = "cluster")]
696 if let Some(sender) = self.shuffle_sender {
697 db.set_shuffle_sender(sender);
698 }
699 #[cfg(feature = "cluster")]
700 if let Some(receiver) = self.shuffle_receiver {
701 db.set_shuffle_receiver(receiver);
702 }
703 #[cfg(feature = "cluster")]
704 if let Some(store) = self.decision_store {
705 db.set_decision_store(store);
706 }
707 #[cfg(feature = "cluster")]
708 if let Some(store) = self.assignment_snapshot_store {
709 db.set_assignment_snapshot_store(store);
710 }
711 #[cfg(feature = "cluster")]
712 if let Some(store) = self.catalog_manifest_store {
713 db.set_catalog_manifest_store(store);
714 }
715 if let Some(registry) = self.vnode_registry {
716 db.set_vnode_registry(registry);
717 }
718 Ok(Arc::new(db))
719 }
720
721 fn validate_vnode_topology(&self, runtime_mode: RuntimeMode) -> Result<(), DbError> {
722 let registry_key_groups = self
723 .vnode_registry
724 .as_ref()
725 .map(|registry| {
726 let registry_count = registry.vnode_count();
727 laminar_core::state::KeyGroupCount::try_from(registry_count).map_err(|_| {
728 DbError::Config(format!(
729 "vnode_registry count must be between 1 and {}, got {registry_count}",
730 laminar_core::state::MAX_KEY_GROUP_COUNT
731 ))
732 })
733 })
734 .transpose()?;
735 if let Some(registry) = self
736 .vnode_registry
737 .as_ref()
738 .filter(|_| runtime_mode == RuntimeMode::Local)
739 {
740 let assignment = registry.versioned_snapshot();
741 if let Some((vnode, owner)) = assignment
742 .owners()
743 .iter()
744 .copied()
745 .enumerate()
746 .find(|(_, owner)| *owner != laminar_core::state::LOCAL_NODE_ID)
747 {
748 return Err(DbError::Config(format!(
749 "local vnode {vnode} must be owned by {}, got {owner}",
750 laminar_core::state::LOCAL_NODE_ID.0
751 )));
752 }
753 }
754 if let (Some(configured), Some(registry)) = (self.key_groups, registry_key_groups) {
755 if configured != registry {
756 return Err(DbError::Config(format!(
757 "configured key-group count {configured} does not match vnode_registry count {registry}"
758 )));
759 }
760 }
761 Ok(())
762 }
763
764 #[cfg(feature = "cluster")]
765 fn bind_cluster_process_lease(&self, runtime_mode: RuntimeMode) -> Result<(), DbError> {
766 if !runtime_mode.is_cluster() {
767 return Ok(());
768 }
769 let controller = self.cluster_controller.as_ref().ok_or_else(|| {
770 DbError::Config("cluster runtime requires a cluster controller".into())
771 })?;
772 let deadline = controller.process_lease_deadline().ok_or_else(|| {
773 DbError::Config(
774 "cluster runtime requires one shared process lease deadline before construction"
775 .into(),
776 )
777 })?;
778 if !deadline.is_live() {
779 return Err(DbError::Config(
780 "cluster runtime process lease deadline is already expired".into(),
781 ));
782 }
783 match (&self.shuffle_sender, &self.shuffle_receiver) {
784 (Some(sender), Some(receiver)) => {
785 sender
786 .bind_process_lease_deadline_pair(receiver, deadline)
787 .map_err(|error| {
788 DbError::Config(format!(
789 "shuffle process lease does not match the controller: {error}"
790 ))
791 })?;
792 }
793 (None, None) => {}
794 _ => {
795 return Err(DbError::Config(
796 "cluster shuffle sender and receiver must be installed together".into(),
797 ));
798 }
799 }
800 Ok(())
801 }
802
803 #[cfg(feature = "cluster")]
804 fn has_cluster_checkpoint_store(&self) -> bool {
805 let has_store = self.verified_cluster_namespaces.is_some();
806 #[cfg(test)]
807 let has_store = has_store || self.unchecked_cluster_checkpoint_store.is_some();
808 has_store
809 }
810
811 #[cfg(feature = "cluster")]
812 fn cluster_checkpoint_store(&self) -> Option<Arc<dyn object_store::ObjectStore>> {
813 let store = self
814 .verified_cluster_namespaces
815 .as_ref()
816 .map(laminar_core::cluster::control::VerifiedClusterNamespaces::checkpoint_store);
817 #[cfg(test)]
818 let store = store.or_else(|| self.unchecked_cluster_checkpoint_store.clone());
819 store
820 }
821
822 fn resolve_runtime_mode(&self) -> Result<RuntimeMode, DbError> {
825 #[cfg(not(feature = "cluster"))]
826 {
827 if self.profile == Profile::Cluster {
828 return Err(DbError::Config(
829 "Profile::Cluster requires a cluster controller and a cluster-enabled build"
830 .into(),
831 ));
832 }
833 Ok(RuntimeMode::Local)
834 }
835
836 #[cfg(feature = "cluster")]
837 {
838 let has_controller = self.cluster_controller.is_some();
839 let has_cluster_only_handle = self.shuffle_sender.is_some()
840 || self.shuffle_receiver.is_some()
841 || self.decision_store.is_some()
842 || self.assignment_snapshot_store.is_some()
843 || self.catalog_manifest_store.is_some()
844 || self.has_cluster_checkpoint_store();
845
846 if has_cluster_only_handle && !has_controller {
847 return Err(DbError::Config(
848 "cluster-only stores and shuffle handles require a cluster controller".into(),
849 ));
850 }
851 if self.profile == Profile::Cluster && !has_controller {
852 return Err(DbError::Config(
853 "Profile::Cluster requires a cluster controller".into(),
854 ));
855 }
856 if self.profile_explicit && self.profile != Profile::Cluster && has_controller {
857 return Err(DbError::Config(format!(
858 "profile {} cannot be combined with a cluster controller; select Profile::Cluster or omit the explicit profile",
859 self.profile
860 )));
861 }
862
863 Ok(if has_controller {
864 RuntimeMode::Cluster
865 } else {
866 RuntimeMode::Local
867 })
868 }
869 }
870
871 fn validate_cluster_delivery(
876 runtime_mode: RuntimeMode,
877 guarantee: laminar_connectors::connector::DeliveryGuarantee,
878 ) -> Result<(), DbError> {
879 use laminar_connectors::connector::DeliveryGuarantee;
880
881 if !runtime_mode.is_cluster() {
882 return Ok(());
883 }
884 match guarantee {
885 DeliveryGuarantee::BestEffort => Err(DbError::Config(
886 "cluster mode requires at_least_once delivery; best_effort has no defined \
887 rebalance/state-loss contract"
888 .into(),
889 )),
890 DeliveryGuarantee::AtLeastOnce | DeliveryGuarantee::ExactlyOnce => Ok(()),
891 }
892 }
893}
894
895impl Default for LaminarDbBuilder {
896 fn default() -> Self {
897 Self::new()
898 }
899}
900
901impl std::fmt::Debug for LaminarDbBuilder {
902 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
903 f.debug_struct("LaminarDbBuilder")
904 .field("config", &self.config)
905 .field("profile", &self.profile)
906 .field("profile_explicit", &self.profile_explicit)
907 .field("delivery_explicit", &self.delivery_explicit)
908 .field("key_groups", &self.key_groups)
909 .field("object_store_url", &self.object_store_url)
910 .field(
911 "object_store_options_count",
912 &self.object_store_options.len(),
913 )
914 .field("config_vars_count", &self.config_vars.len())
915 .field("connector_callbacks", &self.connector_callbacks.len())
916 .field("custom_udfs", &self.custom_udfs.len())
917 .field("custom_udafs", &self.custom_udafs.len())
918 .finish_non_exhaustive()
919 }
920}
921
922#[cfg(test)]
923mod tests;