Skip to main content

linkrs_storage/engine/
config.rs

1//! Property Graph Configuration
2
3use std::time::Duration;
4
5use crate::compression::CompressionType;
6use linkrs_core::error::storage::StorageErrorKind;
7use linkrs_core::types::{AutoCompactConfig, Timestamp};
8use linkrs_core::StorageError;
9
10pub use crate::engine::freeze_decision::{FreezeDecisionEngine, FreezeDecisionInput};
11
12/// Default number of vertex table shards (hash partitions). Higher shard
13/// counts increase write concurrency but widen the internal ID space under
14/// imbalanced hash distribution (max ID <= num_shards * live_vertices).
15/// The default adapts to the available CPU parallelism, clamped within
16/// [1, 256] and rounded up to a power of two (required by shard-ID encoding).
17pub fn default_vertex_table_shards() -> usize {
18    std::thread::available_parallelism()
19        .map(|n| n.get())
20        .unwrap_or(1)
21        .clamp(1, 256)
22        .next_power_of_two()
23}
24
25/// Configuration for flush operations
26#[derive(Debug, Clone)]
27pub struct FlushConfig {
28    pub flush_threshold: usize,
29    pub flush_interval: Duration,
30    pub compression: CompressionType,
31}
32
33impl Default for FlushConfig {
34    fn default() -> Self {
35        Self {
36            flush_threshold: 50_000,
37            flush_interval: Duration::from_secs(30),
38            compression: CompressionType::Zstd { level: 3 },
39        }
40    }
41}
42
43/// Resource and maintenance limits shared by the storage engine components.
44#[derive(Debug, Clone)]
45pub struct ResourceConfig {
46    /// Hard total memory budget for the storage instance.
47    pub max_memory_bytes: u64,
48    /// Independent budget reserved for native indexes.
49    pub index_memory_bytes: u64,
50    /// Ratio at which background maintenance should be scheduled.
51    pub memory_soft_ratio: f64,
52    /// Ratio at which new work receives a capacity error.
53    pub memory_hard_ratio: f64,
54    /// Maximum number of active snapshot registrations.
55    pub max_active_snapshots: usize,
56    /// Maximum age allowed for a snapshot before it is rejected by the owner.
57    pub max_snapshot_age: Duration,
58    /// Maximum number of MVCC tombstones before backpressure is required.
59    pub max_tombstones: usize,
60    /// Maximum estimated tombstone memory.
61    pub max_tombstone_bytes: u64,
62    /// Number of index entries processed by one incremental GC pass.
63    pub index_gc_batch: usize,
64    /// Maximum duration for one maintenance operation.
65    pub operation_timeout: Duration,
66    /// Number of dirty operations that triggers a flush request.
67    pub dirty_flush_operations: u64,
68    /// Estimated dirty bytes that triggers a flush request.
69    pub dirty_flush_bytes: u64,
70    /// Record cache time-to-live.
71    pub cache_ttl: Option<Duration>,
72    /// Record cache time-to-idle.
73    pub cache_tti: Option<Duration>,
74    /// Ratio of hard_limit at which the disk spiller proactively evicts cold data.
75    /// Must satisfy soft_ratio < spill_threshold_ratio <= 1.0.
76    pub spill_threshold_ratio: f64,
77    /// When true, every Moka cache eviction immediately decrements the memory
78    /// accounting counter, eliminating accounting lag between refresh intervals.
79    pub cache_eviction_sync: bool,
80    /// Per-shard native-index buffer pool capacity in bytes.
81    pub index_pool_capacity_bytes: u64,
82    /// Enable chunk-level eviction under memory pressure.
83    pub index_eviction_enabled: bool,
84    /// Eviction high-water ratio: trigger eviction when usage/capacity exceeds this.
85    pub index_eviction_high_ratio: f64,
86    /// Eviction low-water target: evict down to this ratio of capacity.
87    pub index_eviction_low_ratio: f64,
88}
89
90impl Default for ResourceConfig {
91    fn default() -> Self {
92        Self {
93            max_memory_bytes: 512 * 1024 * 1024,
94            index_memory_bytes: 128 * 1024 * 1024,
95            memory_soft_ratio: 0.80,
96            memory_hard_ratio: 0.95,
97            max_active_snapshots: 1_000,
98            max_snapshot_age: Duration::from_secs(300),
99            max_tombstones: 1_000_000,
100            max_tombstone_bytes: 256 * 1024 * 1024,
101            index_gc_batch: 10_000,
102            operation_timeout: Duration::from_secs(30),
103            dirty_flush_operations: 50_000,
104            dirty_flush_bytes: 64 * 1024 * 1024,
105            cache_ttl: Some(Duration::from_secs(60)),
106            cache_tti: Some(Duration::from_secs(300)),
107            spill_threshold_ratio: 0.90,
108            cache_eviction_sync: true,
109            index_pool_capacity_bytes: 128 * 1024 * 1024,
110            index_eviction_enabled: true,
111            index_eviction_high_ratio: 0.85,
112            index_eviction_low_ratio: 0.65,
113        }
114    }
115}
116
117impl ResourceConfig {
118    pub fn validate(&self) -> Result<(), StorageError> {
119        crate::engine::resource_budget::MemoryBudget::new(
120            self.max_memory_bytes,
121            self.index_memory_bytes,
122            self.memory_soft_ratio,
123            self.memory_hard_ratio,
124        )?;
125        if self.max_active_snapshots == 0 {
126            return Err(StorageError::new(
127                StorageErrorKind::InvalidInput,
128                "max_active_snapshots must be greater than 0",
129            ));
130        }
131        if self.max_snapshot_age.is_zero() {
132            return Err(StorageError::new(
133                StorageErrorKind::InvalidInput,
134                "max_snapshot_age must be greater than 0",
135            ));
136        }
137        if self.max_tombstones == 0 || self.max_tombstone_bytes == 0 {
138            return Err(StorageError::new(
139                StorageErrorKind::InvalidInput,
140                "tombstone limits must be greater than 0",
141            ));
142        }
143        if self.index_gc_batch == 0 {
144            return Err(StorageError::new(
145                StorageErrorKind::InvalidInput,
146                "index_gc_batch must be greater than 0",
147            ));
148        }
149        if self.operation_timeout.is_zero() {
150            return Err(StorageError::new(
151                StorageErrorKind::InvalidInput,
152                "operation_timeout must be greater than 0",
153            ));
154        }
155        if self.dirty_flush_operations == 0 || self.dirty_flush_bytes == 0 {
156            return Err(StorageError::new(
157                StorageErrorKind::InvalidInput,
158                "dirty flush limits must be greater than 0",
159            ));
160        }
161        for (name, duration) in [("cache_ttl", self.cache_ttl), ("cache_tti", self.cache_tti)] {
162            if duration.is_some_and(|value| value.is_zero()) {
163                return Err(StorageError::new(
164                    StorageErrorKind::InvalidInput,
165                    format!("{name} must be greater than 0 when configured"),
166                ));
167            }
168        }
169        if !self.spill_threshold_ratio.is_finite()
170            || self.spill_threshold_ratio <= self.memory_soft_ratio
171            || self.spill_threshold_ratio > 1.0
172        {
173            return Err(StorageError::new(
174                StorageErrorKind::InvalidInput,
175                format!(
176                    "spill_threshold_ratio ({}) must satisfy soft_ratio < spill_ratio <= 1.0",
177                    self.spill_threshold_ratio
178                ),
179            ));
180        }
181        Ok(())
182    }
183}
184
185/// Unified Freeze Configuration for single-segment CSR compaction.
186///
187/// Consolidates all freeze-related settings in one place. There are no
188/// segments: the mutable CSR size (edge count plus memory) and the deletion
189/// ratio drive compaction decisions.
190#[derive(Debug, Clone)]
191pub struct FreezeConfig {
192    // ── Decision Thresholds ──
193    /// Freeze when mutable CSR edges exceed this count (out + in entries)
194    pub delta_edge_threshold: u64,
195    /// Freeze when mutable CSR memory exceeds this (in bytes)
196    pub delta_memory_threshold_bytes: u64,
197    /// Freeze when the tombstone deletion ratio reaches this (0.0-1.0), so
198    /// high-churn tables reclaim physical space without waiting for the
199    /// size thresholds above
200    pub deletion_threshold: f64,
201}
202
203impl FreezeConfig {
204    /// Create a conservative configuration (freeze often, merge rarely)
205    ///
206    /// Suitable for: Development, testing, or when fresh data is critical
207    /// - Small freeze threshold (50K edges)
208    /// - Very conservative memory usage
209    pub fn development() -> Self {
210        Self {
211            delta_edge_threshold: 50_000,
212            delta_memory_threshold_bytes: 128 * 1024 * 1024, // 128MB
213            deletion_threshold: 0.5,
214        }
215    }
216
217    /// Create a production configuration for small systems (< 1M edges)
218    ///
219    /// Suitable for: Small deployments, single-node systems
220    /// - Moderate freeze threshold (100K edges)
221    /// - Balanced memory and performance
222    pub fn production_small() -> Self {
223        Self {
224            delta_edge_threshold: 100_000,
225            delta_memory_threshold_bytes: 256 * 1024 * 1024, // 256MB
226            deletion_threshold: 0.2,
227        }
228    }
229
230    /// Create a production configuration for large systems (> 1M edges)
231    ///
232    /// Suitable for: Large deployments, long-running systems
233    /// - Large freeze threshold (500K edges)
234    /// - Optimized for sustained high throughput
235    pub fn production_large() -> Self {
236        Self {
237            delta_edge_threshold: 500_000,
238            delta_memory_threshold_bytes: 1_000_000_000, // 1GB
239            deletion_threshold: 0.3,
240        }
241    }
242
243    /// Validate configuration for consistency and correctness
244    ///
245    /// Checks:
246    /// - Thresholds are positive
247    /// - Deletion ratio is in [0.0, 1.0]
248    /// - Memory threshold is reasonable
249    pub fn validate(&self) -> Result<(), StorageError> {
250        if self.delta_edge_threshold == 0 {
251            return Err(StorageError::new(
252                StorageErrorKind::InvalidInput,
253                "delta_edge_threshold must be > 0",
254            ));
255        }
256
257        if self.delta_memory_threshold_bytes == 0 {
258            return Err(StorageError::new(
259                StorageErrorKind::InvalidInput,
260                "delta_memory_threshold_bytes must be > 0",
261            ));
262        }
263
264        if !(0.0..=1.0).contains(&self.deletion_threshold) {
265            return Err(StorageError::new(
266                StorageErrorKind::InvalidInput,
267                format!(
268                    "deletion_threshold must be in [0.0, 1.0], got {}",
269                    self.deletion_threshold
270                ),
271            ));
272        }
273
274        Ok(())
275    }
276}
277
278impl Default for FreezeConfig {
279    fn default() -> Self {
280        Self::production_small()
281    }
282}
283
284#[derive(Debug, Clone)]
285pub struct PropertyGraphConfig {
286    pub enable_cache: bool,
287    pub cache_memory: usize,
288    pub flush_config: FlushConfig,
289    pub resources: ResourceConfig,
290    pub freeze: FreezeConfig,
291    /// Automatic background vertex compaction (ID hole reclamation)
292    pub auto_compact: AutoCompactConfig,
293    /// Hash partitions per vertex label table
294    pub vertex_table_shards: usize,
295    /// Safety margin subtracted from the GC watermark timestamp.
296    /// Higher values are more conservative (keep more history).
297    pub gc_safety_margin: Timestamp,
298}
299
300impl Default for PropertyGraphConfig {
301    fn default() -> Self {
302        Self {
303            enable_cache: true,
304            cache_memory: 128 * 1024 * 1024,
305            flush_config: FlushConfig::default(),
306            resources: ResourceConfig::default(),
307            freeze: FreezeConfig::default(),
308            auto_compact: AutoCompactConfig::default(),
309            vertex_table_shards: default_vertex_table_shards(),
310            gc_safety_margin: 1,
311        }
312    }
313}
314
315impl PropertyGraphConfig {
316    /// Create a development configuration
317    pub fn development() -> Self {
318        let freeze = FreezeConfig::development();
319        // Validate configuration on creation for early failure detection
320        let _ = freeze.validate();
321        Self {
322            enable_cache: true,
323            cache_memory: 64 * 1024 * 1024, // 64MB for dev
324            flush_config: FlushConfig::default(),
325            resources: ResourceConfig {
326                max_memory_bytes: 256 * 1024 * 1024,
327                index_memory_bytes: 64 * 1024 * 1024,
328                ..Default::default()
329            },
330            freeze: freeze.clone(),
331            auto_compact: AutoCompactConfig::default(),
332            vertex_table_shards: default_vertex_table_shards(),
333            gc_safety_margin: 1,
334        }
335    }
336
337    /// Create a production configuration for small systems
338    pub fn production_small() -> Self {
339        let freeze = FreezeConfig::production_small();
340        // Validate configuration on creation for early failure detection
341        let _ = freeze.validate();
342        Self {
343            enable_cache: true,
344            cache_memory: 128 * 1024 * 1024,
345            flush_config: FlushConfig::default(),
346            resources: ResourceConfig::default(),
347            freeze: freeze.clone(),
348            auto_compact: AutoCompactConfig::default(),
349            vertex_table_shards: default_vertex_table_shards(),
350            gc_safety_margin: 1,
351        }
352    }
353
354    /// Create a production configuration for large systems
355    pub fn production_large() -> Self {
356        let freeze = FreezeConfig::production_large();
357        // Validate configuration on creation for early failure detection
358        let _ = freeze.validate();
359        Self {
360            enable_cache: true,
361            cache_memory: 256 * 1024 * 1024,
362            flush_config: FlushConfig::default(),
363            resources: ResourceConfig {
364                max_memory_bytes: 1024 * 1024 * 1024,
365                index_memory_bytes: 256 * 1024 * 1024,
366                ..Default::default()
367            },
368            freeze: freeze.clone(),
369            auto_compact: AutoCompactConfig::default(),
370            vertex_table_shards: default_vertex_table_shards(),
371            gc_safety_margin: 1,
372        }
373    }
374
375    /// Create a lightweight test configuration
376    ///
377    /// Uses minimal cache (8MB) and relaxed flush thresholds to reduce
378    /// resource usage in test environments.
379    pub fn test() -> Self {
380        Self {
381            enable_cache: true,
382            cache_memory: 8 * 1024 * 1024,
383            flush_config: FlushConfig {
384                flush_threshold: 100000,
385                flush_interval: Duration::from_secs(3600),
386                ..Default::default()
387            },
388            resources: ResourceConfig {
389                max_memory_bytes: 64 * 1024 * 1024,
390                index_memory_bytes: 16 * 1024 * 1024,
391                ..Default::default()
392            },
393            freeze: FreezeConfig {
394                delta_edge_threshold: 5000,
395                delta_memory_threshold_bytes: 16 * 1024 * 1024,
396                deletion_threshold: 0.5,
397            },
398            auto_compact: AutoCompactConfig::default(),
399            vertex_table_shards: default_vertex_table_shards(),
400            gc_safety_margin: 1,
401        }
402    }
403
404    /// Validate all configurations
405    pub fn validate(&self) -> Result<(), StorageError> {
406        if self.enable_cache && self.cache_memory == 0 {
407            return Err(StorageError::new(
408                StorageErrorKind::InvalidInput,
409                "cache_memory must be > 0 when cache is enabled",
410            ));
411        }
412        if self.enable_cache && self.cache_memory as u64 > self.resources.max_memory_bytes {
413            return Err(StorageError::new(
414                StorageErrorKind::InvalidInput,
415                "cache_memory cannot exceed max_memory_bytes",
416            ));
417        }
418        self.resources.validate()?;
419        if self.flush_config.flush_threshold == 0 {
420            return Err(StorageError::new(
421                StorageErrorKind::InvalidInput,
422                "flush_threshold must be > 0",
423            ));
424        }
425        if self.flush_config.flush_interval.is_zero() {
426            return Err(StorageError::new(
427                StorageErrorKind::InvalidInput,
428                "flush_interval must be > 0",
429            ));
430        }
431        self.freeze.validate()?;
432        if !(0.0..=1.0).contains(&self.auto_compact.min_hole_ratio) {
433            return Err(StorageError::new(
434                StorageErrorKind::InvalidInput,
435                "auto_compact min_hole_ratio must be in [0.0, 1.0]",
436            ));
437        }
438        if !(1..=256).contains(&self.vertex_table_shards)
439            || !self.vertex_table_shards.is_power_of_two()
440        {
441            return Err(StorageError::new(
442                StorageErrorKind::InvalidInput,
443                "vertex_table_shards must be a power of two in [1, 256]",
444            ));
445        }
446        Ok(())
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453
454    #[test]
455    fn test_freeze_config_development() {
456        let config = FreezeConfig::development();
457        assert_eq!(config.delta_edge_threshold, 50_000);
458        assert_eq!(config.delta_memory_threshold_bytes, 128 * 1024 * 1024);
459        assert_eq!(config.deletion_threshold, 0.5);
460    }
461
462    #[test]
463    fn test_freeze_config_production_small() {
464        let config = FreezeConfig::production_small();
465        assert_eq!(config.delta_edge_threshold, 100_000);
466        assert_eq!(config.delta_memory_threshold_bytes, 256 * 1024 * 1024);
467        assert_eq!(config.deletion_threshold, 0.2);
468    }
469
470    #[test]
471    fn test_freeze_config_production_large() {
472        let config = FreezeConfig::production_large();
473        assert_eq!(config.delta_edge_threshold, 500_000);
474        assert_eq!(config.delta_memory_threshold_bytes, 1_000_000_000);
475        assert_eq!(config.deletion_threshold, 0.3);
476    }
477
478    #[test]
479    fn test_freeze_config_validate_success() {
480        let config = FreezeConfig::default();
481        assert!(config.validate().is_ok());
482    }
483
484    #[test]
485    fn test_freeze_config_validate_zero_edge_threshold() {
486        let config = FreezeConfig {
487            delta_edge_threshold: 0,
488            ..Default::default()
489        };
490        assert!(config.validate().is_err());
491    }
492
493    #[test]
494    fn test_freeze_config_validate_zero_memory_threshold() {
495        let config = FreezeConfig {
496            delta_memory_threshold_bytes: 0,
497            ..Default::default()
498        };
499        assert!(config.validate().is_err());
500    }
501
502    #[test]
503    fn test_freeze_config_validate_invalid_deletion_threshold() {
504        let config = FreezeConfig {
505            deletion_threshold: 1.5,
506            ..Default::default()
507        };
508        assert!(config.validate().is_err());
509
510        let config = FreezeConfig {
511            deletion_threshold: -0.1,
512            ..Default::default()
513        };
514        assert!(config.validate().is_err());
515    }
516
517    #[test]
518    fn test_property_graph_config_development() {
519        let config = PropertyGraphConfig::development();
520        assert!(config.freeze.validate().is_ok());
521    }
522
523    #[test]
524    fn test_property_graph_config_production_small() {
525        let config = PropertyGraphConfig::production_small();
526        assert!(config.freeze.validate().is_ok());
527    }
528
529    #[test]
530    fn test_property_graph_config_production_large() {
531        let config = PropertyGraphConfig::production_large();
532        assert!(config.freeze.validate().is_ok());
533    }
534
535    #[test]
536    fn test_property_graph_config_validate() {
537        let config = PropertyGraphConfig::default();
538        assert!(config.validate().is_ok());
539    }
540
541    #[test]
542    fn test_resource_config_rejects_invalid_budget_relationships() {
543        let config = PropertyGraphConfig {
544            resources: ResourceConfig {
545                index_memory_bytes: 1024,
546                max_memory_bytes: 512,
547                ..Default::default()
548            },
549            ..PropertyGraphConfig::default()
550        };
551        assert!(config.validate().is_err());
552    }
553}