cqlite_core/storage/mod.rs
1//! Storage engine implementation for CQLite
2
3/// Shared, bytes-bounded, sharded decompressed-chunk cache (issue #1567).
4pub mod cache;
5pub mod sstable;
6
7// Canonical partition-key (de)serialization, shared by the read (query) path
8// and the write engine so the two never drift (Issue #586). Always compiled —
9// the scan path needs it even without `write-support`.
10pub mod partition_key_codec;
11
12// M5: Write engine and serialization (Issue #359)
13#[cfg(feature = "write-support")]
14pub mod serialization;
15#[cfg(feature = "write-support")]
16pub mod write_engine;
17
18// REPL data access components (Issue #249: CLI-specific)
19#[cfg(feature = "cli-helpers")]
20pub mod repl_data_api;
21pub mod schema_discovery;
22pub mod sstable_data_manager;
23
24use std::path::{Path, PathBuf};
25use std::sync::Arc;
26#[cfg(feature = "state_machine")]
27use tokio::sync::RwLock;
28
29use crate::platform::Platform;
30use crate::{
31 types::{CellWriteMetadata, TableId},
32 Config, Result, RowKey, ScanRow,
33};
34// `Value` is only referenced by the experimental write API (`put` / `BatchOperation`);
35// gate the import so the default build does not flag it unused (issue #1334).
36#[cfg(feature = "experimental")]
37use crate::types::Value;
38
39// Test/feature-gated whole-table-scan invocation counter (issue #1691).
40//
41// Counts entries into the two whole-table scan initiators — `StorageEngine::scan`
42// (materializing) and `StorageEngine::scan_stream` (bounded streaming). It exists to
43// pin the retirement of `execute_parallel_table_scan`: a `TableScan` plan must issue
44// exactly ONE whole-table pass, not the 4× duplicate passes the retired multi-worker
45// path produced.
46//
47// It is thread-local, mirroring the "thread-local invocation counter reflects exactly
48// this future's calls" pattern (issue #831 / `access_path`). A test drives its
49// measured operation on a current-thread Tokio runtime, so both `scan` and
50// `scan_stream` increment on the *calling* thread (the count records the entry, not
51// the spawned producer's work). This isolates the count from the thousands of other
52// lib tests running on their own threads — a process-global atomic would be polluted
53// by any concurrent test that scans. Zero-overhead in release: the body compiles to a
54// no-op and the cell is not even linked.
55#[cfg(any(test, feature = "work-counters"))]
56thread_local! {
57 static TABLE_SCAN_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
58}
59
60/// Record one whole-table scan initiation (`scan` / `scan_stream`) on the current
61/// thread. Unconditional at the call site; the body is a no-op in release builds
62/// (issue #1691).
63#[inline(always)]
64pub(crate) fn record_table_scan_call() {
65 #[cfg(any(test, feature = "work-counters"))]
66 TABLE_SCAN_CALLS.with(|c| c.set(c.get().saturating_add(1)));
67}
68
69/// Number of whole-table scan initiations on the current thread since the last
70/// [`reset_table_scan_calls`] (issue #1691; test/feature builds only).
71#[cfg(any(test, feature = "work-counters"))]
72pub fn table_scan_call_count() -> u64 {
73 TABLE_SCAN_CALLS.with(|c| c.get())
74}
75
76/// Clear the current thread's whole-table scan counter before a measured operation
77/// (issue #1691; test/feature builds only).
78#[cfg(any(test, feature = "work-counters"))]
79pub fn reset_table_scan_calls() {
80 TABLE_SCAN_CALLS.with(|c| c.set(0));
81}
82
83/// Main storage engine that coordinates all storage components
84///
85/// NOTE: Issue #176 removed write infrastructure (compaction, manifest).
86/// This is now a read-only storage layer focused on SSTable access.
87#[derive(Debug)]
88pub struct StorageEngine {
89 /// SSTable manager for persistent storage
90 sstables: Arc<sstable::SSTableManager>,
91
92 /// Platform abstraction
93 #[allow(dead_code)]
94 _platform: Arc<Platform>,
95
96 /// Storage configuration
97 #[allow(dead_code)]
98 config: Config,
99
100 /// Schema registry for schema-aware operations (feature-gated)
101 #[cfg(feature = "state_machine")]
102 schema_registry: Arc<RwLock<Option<Arc<RwLock<crate::schema::SchemaRegistry>>>>>,
103}
104
105impl StorageEngine {
106 /// Open a storage engine at the given path
107 ///
108 /// This method discovers SSTables by scanning the storage directory.
109 /// For pre-discovered SSTables, use `open_with_sstables` instead.
110 ///
111 /// NOTE: Issue #176 removed write infrastructure (compaction, manifest).
112 /// This is now a read-only storage layer focused on SSTable access.
113 // `skip_all` (not an explicit skip list): the `schema_registry` parameter is
114 // `#[cfg(feature = "state_machine")]`-gated, so naming it in `skip(...)` would
115 // reference a nonexistent binding in the minimal build. `skip_all` records no
116 // args as fields regardless of cfg; the `fields(...)` below are set in the body.
117 #[tracing::instrument(
118 name = "storage.engine.open",
119 skip_all,
120 fields(sstables = tracing::field::Empty, bytes = tracing::field::Empty)
121 )]
122 pub async fn open(
123 path: &Path,
124 config: &Config,
125 platform: Arc<Platform>,
126 #[cfg(feature = "state_machine")] schema_registry: Option<
127 Arc<RwLock<crate::schema::SchemaRegistry>>,
128 >,
129 ) -> Result<Self> {
130 // Create storage directory if it doesn't exist
131 crate::observability::record_result("reader", platform.fs().create_dir_all(path).await)?;
132
133 // Initialize SSTable manager with schema registry
134 let sstables = Arc::new(crate::observability::record_result(
135 "reader",
136 sstable::SSTableManager::new(
137 path,
138 config,
139 platform.clone(),
140 #[cfg(feature = "state_machine")]
141 schema_registry.clone(),
142 )
143 .await,
144 )?);
145
146 Self::record_discovery_metrics(&sstables).await;
147
148 Ok(Self {
149 sstables,
150 _platform: platform,
151 config: config.clone(),
152 #[cfg(feature = "state_machine")]
153 schema_registry: Arc::new(RwLock::new(schema_registry)),
154 })
155 }
156
157 /// Emit SSTable-discovery telemetry (issue #1034) for a freshly built
158 /// manager: total SSTables discovered, their on-disk byte total, and the
159 /// number of logical tables. Best-effort — a stats failure is logged and the
160 /// open continues, since telemetry must never change open behaviour.
161 async fn record_discovery_metrics(sstables: &sstable::SSTableManager) {
162 use crate::observability::{self as obs, catalog};
163 match sstables.stats().await {
164 Ok(stats) => {
165 tracing::Span::current().record("sstables", stats.sstable_count as u64);
166 tracing::Span::current().record("bytes", stats.total_size);
167 obs::add_counter(
168 catalog::STORAGE_OPEN_SSTABLES,
169 stats.sstable_count as u64,
170 &[],
171 );
172 obs::add_counter(catalog::STORAGE_OPEN_BYTES, stats.total_size, &[]);
173 obs::add_counter(catalog::STORAGE_OPEN_TABLES, stats.total_tables, &[]);
174 }
175 Err(e) => {
176 log::debug!("storage.engine.open: discovery metrics unavailable: {}", e);
177 }
178 }
179 }
180
181 /// Open a storage engine with pre-discovered SSTable table directories
182 ///
183 /// This method is used when SSTables have been discovered externally (e.g., by DiscoveryService)
184 /// and allows the storage engine to be initialized with specific table directories rather than
185 /// scanning the storage directory. Each table directory will be scanned for Data.db files.
186 ///
187 /// # Arguments
188 /// * `path` - Base storage path for manifest and SSTable operations
189 /// * `discovered_table_dirs` - Vector of table directory paths (each containing SSTable files)
190 /// * `config` - Storage configuration
191 /// * `platform` - Platform abstraction for I/O operations
192 ///
193 /// # Returns
194 /// A StorageEngine instance with all components initialized, including SSTable readers
195 /// for all Data.db files found in the discovered table directories.
196 ///
197 /// # Example
198 /// ```no_run
199 /// # use std::path::{Path, PathBuf};
200 /// # use std::sync::Arc;
201 /// # use cqlite_core::{Config, Platform, storage::StorageEngine};
202 /// # async fn example() -> cqlite_core::Result<()> {
203 /// let config = Config::default();
204 /// let platform = Arc::new(Platform::new(&config).await?);
205 /// let storage_path = Path::new("/var/lib/cqlite/storage");
206 /// let discovered_table_dirs = vec![
207 /// PathBuf::from("/var/lib/cassandra/keyspace1/table1-abc123"),
208 /// PathBuf::from("/var/lib/cassandra/keyspace1/table2-def456"),
209 /// ];
210 ///
211 /// let engine = StorageEngine::open_with_sstables(
212 /// storage_path,
213 /// discovered_table_dirs,
214 /// &config,
215 /// platform,
216 /// #[cfg(feature = "state_machine")]
217 /// None,
218 /// ).await?;
219 /// # Ok(())
220 /// # }
221 /// ```
222 // `skip_all`: see the note on `open` — the cfg-gated `schema_registry` cannot
223 // be named in an explicit `skip(...)` without breaking the minimal build.
224 #[tracing::instrument(
225 name = "storage.engine.open",
226 skip_all,
227 fields(sstables = tracing::field::Empty, bytes = tracing::field::Empty)
228 )]
229 pub async fn open_with_sstables(
230 path: &Path,
231 discovered_table_dirs: Vec<PathBuf>,
232 config: &Config,
233 platform: Arc<Platform>,
234 #[cfg(feature = "state_machine")] schema_registry: Option<
235 Arc<RwLock<crate::schema::SchemaRegistry>>,
236 >,
237 ) -> Result<Self> {
238 // Create storage directory if it doesn't exist
239 crate::observability::record_result("reader", platform.fs().create_dir_all(path).await)?;
240
241 // Initialize SSTable manager with pre-discovered paths and schema registry
242 let sstables = Arc::new(crate::observability::record_result(
243 "reader",
244 sstable::SSTableManager::new_from_discovered_paths(
245 path,
246 discovered_table_dirs,
247 config,
248 platform.clone(),
249 #[cfg(feature = "state_machine")]
250 schema_registry.clone(),
251 )
252 .await,
253 )?);
254
255 Self::record_discovery_metrics(&sstables).await;
256
257 Ok(Self {
258 sstables,
259 _platform: platform,
260 config: config.clone(),
261 #[cfg(feature = "state_machine")]
262 schema_registry: Arc::new(RwLock::new(schema_registry)),
263 })
264 }
265
266 /// Insert a key-value pair
267 ///
268 /// NOTE: Write functionality removed in Issue #175 (WAL/MemTable infrastructure deleted).
269 /// This method is feature-gated behind 'experimental' but currently unimplemented.
270 #[cfg(feature = "experimental")]
271 pub async fn put(&self, _table_id: &TableId, _key: RowKey, _value: Value) -> Result<()> {
272 Err(crate::error::Error::UnsupportedFormat(
273 "Write operations (put) removed in Issue #175 - WAL and MemTable infrastructure deleted".to_string()
274 ))
275 }
276
277 /// Get a value by key
278 pub async fn get(&self, table_id: &TableId, key: &RowKey) -> Result<Option<ScanRow>> {
279 // Check SSTables
280 self.sstables.get(table_id, key).await
281 }
282
283 /// Re-scan the data directory and atomically apply added/removed SSTable
284 /// generations to the held reader set (issue #1749).
285 ///
286 /// # Freshness contract
287 ///
288 /// A `StorageEngine` (and the [`Database`](crate::Database) built on it)
289 /// snapshots the discovered SSTable generations **at open**; it does not
290 /// re-scan on its own. This is the ONLY way the reader set changes for a
291 /// long-lived handle. Re-runs the same TOC/filename-based discovery `open`
292 /// used — no content sniffing.
293 ///
294 /// - Added generations become queryable; removed generations stop being
295 /// queried; unchanged generations keep their warm parsed state.
296 /// - **In-flight queries are unaffected**: a scan already running holds its
297 /// own `Arc` reader clones and completes against the pre-refresh set;
298 /// queries started after the refresh see the new set.
299 /// - **Atomic / fail-closed**: if any newly discovered generation fails to
300 /// open (e.g. a corrupt `Statistics.db`, issue #1626), the typed error is
301 /// returned and the previously held reader set is left fully unchanged.
302 pub async fn refresh(&self) -> Result<sstable::RefreshReport> {
303 self.sstables.refresh_tables().await
304 }
305
306 /// Delete a key
307 ///
308 /// NOTE: Write functionality removed in Issue #175 (WAL/MemTable infrastructure deleted).
309 /// This method is feature-gated behind 'experimental' but currently unimplemented.
310 #[cfg(feature = "experimental")]
311 pub async fn delete(&self, _table_id: &TableId, _key: RowKey) -> Result<()> {
312 Err(crate::error::Error::UnsupportedFormat(
313 "Write operations (delete) removed in Issue #175 - WAL and MemTable infrastructure deleted".to_string()
314 ))
315 }
316
317 /// Scan a range of keys
318 ///
319 /// # Arguments
320 /// * `table_id` - The table to scan
321 /// * `start_key` - Optional start key for range scan
322 /// * `end_key` - Optional end key for range scan
323 /// * `limit` - Optional limit on number of results
324 /// * `schema` - Optional table schema for schema-aware parsing. When provided,
325 /// enables accurate type detection and avoids heuristic-based parsing.
326 /// Strongly recommended for Cassandra 5.0+ formats.
327 pub async fn scan(
328 &self,
329 table_id: &TableId,
330 start_key: Option<&RowKey>,
331 end_key: Option<&RowKey>,
332 limit: Option<usize>,
333 schema: Option<&crate::schema::TableSchema>,
334 ) -> Result<Vec<(RowKey, ScanRow)>> {
335 record_table_scan_call();
336 // Scan SSTables directly
337 self.sstables
338 .scan(table_id, start_key, end_key, limit, schema)
339 .await
340 }
341
342 /// Partition-targeted scan for a fully-constrained `WHERE pk = ?` (Issue #949).
343 ///
344 /// Returns only the rows for the single partition identified by the raw
345 /// `partition_key` bytes, after pruning the SSTable set down to those whose
346 /// bloom filter / BTI trie admit the key — so unrelated SSTables are never
347 /// parsed. Output matches filtering the full [`scan`](Self::scan) result to the
348 /// partition. Delegates to [`SSTableManager::scan_partition`] (which has a
349 /// bloom-prune implementation for the default build and a scan-and-filter
350 /// fallback for the `tombstones` build, so callers need no cfg branching).
351 ///
352 /// Returns `(rows, engaged)`: `engaged` is `true` only when the call actually
353 /// pruned the SSTable set to partition candidates (the default build). The
354 /// `tombstones` build returns `false` because it full-scans and retains with no
355 /// prune, so the caller reports an honest fallback access path (Epic #951).
356 pub async fn scan_partition(
357 &self,
358 table_id: &TableId,
359 partition_key: &[u8],
360 schema: Option<&crate::schema::TableSchema>,
361 ) -> Result<(Vec<(RowKey, ScanRow)>, bool)> {
362 self.sstables
363 .scan_partition(table_id, partition_key, schema)
364 .await
365 }
366
367 /// Clustering-slice-aware partition-targeted scan (Issue #954, Epic #951).
368 ///
369 /// Like [`scan_partition`](Self::scan_partition) but pushes a single-column
370 /// clustering-key restriction (`ck </>/= ?` / two-bound range) down to a
371 /// within-partition seek when the candidate's authoritative row index supports
372 /// it, so a wide-partition slice decodes O(matched rows + index) rather than
373 /// the whole partition. Returns `(rows, clustering_seek_engaged)`: the rows are
374 /// the full partition (or a clustering-narrowed superset) so the caller's
375 /// post-scan filter yields byte-identical output, and the bool reports whether
376 /// the clustering narrowing actually engaged (for the `ClusteringSlice` access
377 /// path). Delegates to [`SSTableManager::scan_partition_clustering`].
378 #[cfg(not(feature = "tombstones"))]
379 pub async fn scan_partition_clustering(
380 &self,
381 table_id: &TableId,
382 partition_key: &[u8],
383 clustering: Option<&crate::storage::sstable::reader::ClusteringSlice>,
384 schema: Option<&crate::schema::TableSchema>,
385 ) -> Result<(Vec<(RowKey, ScanRow)>, bool)> {
386 self.sstables
387 .scan_partition_clustering(table_id, partition_key, clustering, schema)
388 .await
389 }
390
391 /// Reverse single-partition clustering scan for a BIG (`nb`) wide partition
392 /// (Issue #1184). Returns `Ok(Some(rows))` in DESCENDING clustering order when
393 /// the BIG promoted-index reverse iterator applied, or `Ok(None)` to tell the
394 /// caller to keep the in-memory `ORDER BY DESC` sort (small / BTI /
395 /// multi-generation cases). Delegates to
396 /// [`SSTableManager::scan_partition_clustering_reverse`].
397 #[cfg(not(feature = "tombstones"))]
398 pub async fn scan_partition_clustering_reverse(
399 &self,
400 table_id: &TableId,
401 partition_key: &[u8],
402 schema: Option<&crate::schema::TableSchema>,
403 ) -> Result<Option<Vec<(RowKey, ScanRow)>>> {
404 self.sstables
405 .scan_partition_clustering_reverse(table_id, partition_key, schema)
406 .await
407 }
408
409 /// Partition-targeted, metadata-carrying scan for a fully-constrained
410 /// `WHERE pk = ?` WRITETIME/TTL projection (Issue #962).
411 ///
412 /// The metadata sibling of [`scan_partition`](Self::scan_partition): returns
413 /// only the rows for the single partition identified by the raw
414 /// `partition_key` bytes, WITH per-cell write metadata, after pruning the
415 /// SSTable set down to the candidates whose bloom filter / BTI trie admit the
416 /// key — so a `SELECT WRITETIME(col) ... WHERE pk = ?` never opens all N
417 /// SSTables. Output matches filtering the full
418 /// [`scan_with_cell_metadata`](Self::scan_with_cell_metadata) result to the
419 /// partition; cross-generation reconciliation runs over the pruned candidates.
420 /// Delegates to [`SSTableManager::scan_partition_with_cell_metadata`].
421 ///
422 /// Returns `(rows, engaged)`: `engaged` is `true` only when the call pruned the
423 /// SSTable set to partition candidates (the default build). The `tombstones`
424 /// build returns `false` because it full-scans with metadata and retains with no
425 /// prune, so the caller reports an honest fallback access path (Epic #951).
426 pub async fn scan_partition_with_cell_metadata(
427 &self,
428 table_id: &TableId,
429 partition_key: &[u8],
430 schema: Option<&crate::schema::TableSchema>,
431 ) -> Result<(
432 Vec<(
433 RowKey,
434 ScanRow,
435 std::collections::HashMap<String, CellWriteMetadata>,
436 )>,
437 bool,
438 )> {
439 self.sstables
440 .scan_partition_with_cell_metadata(table_id, partition_key, schema)
441 .await
442 }
443
444 /// Scan a table and return per-cell write metadata alongside row values.
445 ///
446 /// Delegates to [`SSTableManager::scan_with_cell_metadata`]. Used when
447 /// `ProjectionFlags::include_cell_metadata` is set (issue #693).
448 pub async fn scan_with_cell_metadata(
449 &self,
450 table_id: &TableId,
451 start_key: Option<&RowKey>,
452 end_key: Option<&RowKey>,
453 limit: Option<usize>,
454 schema: Option<&crate::schema::TableSchema>,
455 ) -> Result<
456 Vec<(
457 RowKey,
458 ScanRow,
459 std::collections::HashMap<String, CellWriteMetadata>,
460 )>,
461 > {
462 self.sstables
463 .scan_with_cell_metadata(table_id, start_key, end_key, limit, schema)
464 .await
465 }
466
467 /// Streaming scan (issue #790): return a bounded channel that yields
468 /// `(RowKey, ScanRow)` entries lazily in key (token) order, instead of the
469 /// materializing [`scan`](Self::scan) that returns the whole `Vec`.
470 ///
471 /// Live heap is bounded by `buffer_size` rows rather than growing O(rows),
472 /// so streaming a large `SELECT *` no longer holds the entire result set in
473 /// memory at once. Delegates to [`SSTableManager::scan_stream`].
474 ///
475 /// [`SSTableManager::scan_stream`]: sstable::SSTableManager::scan_stream
476 pub async fn scan_stream(
477 &self,
478 table_id: &TableId,
479 start_key: Option<&RowKey>,
480 end_key: Option<&RowKey>,
481 schema: Option<&crate::schema::TableSchema>,
482 buffer_size: usize,
483 ) -> Result<tokio::sync::mpsc::Receiver<Result<(RowKey, ScanRow)>>> {
484 record_table_scan_call();
485 self.sstables
486 .scan_stream(table_id, start_key, end_key, schema, buffer_size)
487 .await
488 }
489
490 /// Flush MemTable to SSTable
491 ///
492 /// NOTE: Write functionality removed in Issue #175 (WAL/MemTable infrastructure deleted).
493 /// This method is feature-gated behind 'experimental' but currently unimplemented.
494 #[allow(dead_code)]
495 #[cfg(feature = "experimental")]
496 async fn flush_memtable(&self) -> Result<()> {
497 Err(crate::error::Error::UnsupportedFormat(
498 "Write operations (flush_memtable) removed in Issue #175 - WAL and MemTable infrastructure deleted".to_string()
499 ))
500 }
501
502 /// Force flush all pending writes
503 ///
504 /// NOTE: Write functionality removed in Issue #175 (WAL/MemTable infrastructure deleted).
505 /// This method is feature-gated behind 'experimental' but currently unimplemented.
506 #[cfg(feature = "experimental")]
507 pub async fn flush(&self) -> Result<()> {
508 Err(crate::error::Error::UnsupportedFormat(
509 "Write operations (flush) removed in Issue #175 - WAL and MemTable infrastructure deleted".to_string()
510 ))
511 }
512
513 /// Perform manual compaction
514 #[cfg(feature = "experimental")]
515 pub async fn compact(&self) -> Result<()> {
516 // TODO: Implement proper compaction logic
517 // This would need to identify candidates and call CompactionManager::run_compaction
518 Ok(())
519 }
520
521 /// Get storage statistics
522 ///
523 /// NOTE: Issue #176 removed compaction stats (compaction.rs deleted).
524 pub async fn stats(&self) -> Result<StorageStats> {
525 let sstable_stats = self.sstables.stats().await?;
526
527 Ok(StorageStats {
528 sstables: sstable_stats,
529 })
530 }
531
532 /// Batch write operations for better performance
533 ///
534 /// NOTE: Write functionality removed in Issue #175 (WAL/MemTable infrastructure deleted).
535 /// This method is feature-gated behind 'experimental' but currently unimplemented.
536 #[cfg(feature = "experimental")]
537 pub async fn batch_write(&mut self, _operations: Vec<BatchOperation>) -> Result<()> {
538 Err(crate::error::Error::UnsupportedFormat(
539 "Write operations (batch_write) removed in Issue #175 - WAL and MemTable infrastructure deleted".to_string()
540 ))
541 }
542
543 /// Explicit batch flush
544 ///
545 /// NOTE: Write functionality removed in Issue #175 (WAL/MemTable infrastructure deleted).
546 /// This method is feature-gated behind 'experimental' but currently unimplemented.
547 #[cfg(feature = "experimental")]
548 pub async fn flush_batch(&mut self) -> Result<()> {
549 Err(crate::error::Error::UnsupportedFormat(
550 "Write operations (flush_batch) removed in Issue #175 - WAL and MemTable infrastructure deleted".to_string()
551 ))
552 }
553
554 /// Get batch writer statistics
555 ///
556 /// NOTE: Write functionality removed in Issue #175 (WAL/MemTable infrastructure deleted).
557 /// This method is feature-gated behind 'experimental' but currently unimplemented.
558 #[cfg(feature = "experimental")]
559 pub fn batch_stats(&self) -> Option<()> {
560 None
561 }
562
563 /// Shutdown the storage engine
564 ///
565 /// NOTE: Issue #176 removed compaction shutdown (compaction.rs deleted).
566 /// Issue #175 removed flush operations (WAL/MemTable deleted).
567 pub async fn shutdown(&self) -> Result<()> {
568 // Nothing to shutdown - read-only storage layer
569 Ok(())
570 }
571
572 /// Set the schema registry for schema-aware operations
573 ///
574 /// This method propagates the schema registry to the SSTable manager,
575 /// which will apply it to all SSTable readers for schema-aware parsing.
576 #[cfg(feature = "state_machine")]
577 pub async fn set_schema_registry(
578 &self,
579 registry: Arc<RwLock<crate::schema::SchemaRegistry>>,
580 ) -> Result<()> {
581 // Store in our field
582 {
583 let mut schema_reg = self.schema_registry.write().await;
584 *schema_reg = Some(registry.clone());
585 }
586
587 // Propagate to SSTable manager
588 self.sstables.set_schema_registry(registry).await
589 }
590}
591
592/// Batch operation types
593#[cfg(feature = "experimental")]
594#[derive(Debug, Clone)]
595pub enum BatchOperation {
596 /// Put operation
597 Put {
598 table_id: TableId,
599 key: RowKey,
600 value: Value,
601 },
602 /// Delete operation
603 Delete { table_id: TableId, key: RowKey },
604 /// Merge operation
605 Merge {
606 table_id: TableId,
607 key: RowKey,
608 value: Value,
609 },
610}
611
612/// Storage engine statistics
613///
614/// NOTE: Issue #176 removed compaction statistics (compaction.rs deleted).
615#[derive(Debug, Clone)]
616pub struct StorageStats {
617 /// SSTable statistics
618 pub sstables: sstable::SSTableStats,
619}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624 use tempfile::TempDir;
625
626 #[tokio::test]
627 async fn test_storage_engine_creation() {
628 let temp_dir = TempDir::new().unwrap();
629 let config = Config::test_config();
630 let platform = Arc::new(Platform::new(&config).await.unwrap());
631
632 let storage = StorageEngine::open(
633 temp_dir.path(),
634 &config,
635 platform,
636 #[cfg(feature = "state_machine")]
637 None,
638 )
639 .await
640 .unwrap();
641 let stats = storage.stats().await.unwrap();
642
643 assert_eq!(stats.sstables.sstable_count, 0);
644 storage.shutdown().await.unwrap();
645 }
646
647 #[tokio::test]
648 async fn test_storage_engine_with_discovered_sstables() {
649 let temp_dir = TempDir::new().unwrap();
650 let config = Config::test_config();
651 let platform = Arc::new(Platform::new(&config).await.unwrap());
652
653 // Create an empty list of discovered SSTables for this test
654 let discovered_paths = Vec::new();
655
656 let storage = StorageEngine::open_with_sstables(
657 temp_dir.path(),
658 discovered_paths,
659 &config,
660 platform,
661 #[cfg(feature = "state_machine")]
662 None,
663 )
664 .await
665 .unwrap();
666
667 let stats = storage.stats().await.unwrap();
668
669 // Should have 0 SSTables since we provided an empty list
670 assert_eq!(stats.sstables.sstable_count, 0);
671 storage.shutdown().await.unwrap();
672 }
673
674 #[tokio::test]
675 #[cfg(all(feature = "legacy-heuristics", feature = "experimental"))]
676 async fn test_batch_operations() {
677 let temp_dir = TempDir::new().unwrap();
678 let config = Config::default();
679 let platform = Arc::new(Platform::new(&config).await.unwrap());
680
681 let mut storage = StorageEngine::open(
682 temp_dir.path(),
683 &config,
684 platform,
685 #[cfg(feature = "state_machine")]
686 None,
687 )
688 .await
689 .unwrap();
690
691 // Test batch write operations
692 let batch_ops = vec![
693 BatchOperation::Put {
694 table_id: TableId::new("test_table"),
695 key: RowKey::from("key1"),
696 value: Value::Text("value1".to_string()),
697 },
698 BatchOperation::Put {
699 table_id: TableId::new("test_table"),
700 key: RowKey::from("key2"),
701 value: Value::Text("value2".to_string()),
702 },
703 BatchOperation::Delete {
704 table_id: TableId::new("test_table"),
705 key: RowKey::from("key3"),
706 },
707 ];
708
709 storage.batch_write(batch_ops).await.unwrap();
710 storage.shutdown().await.unwrap();
711 }
712
713 #[tokio::test]
714 #[cfg(all(feature = "legacy-heuristics", feature = "experimental"))]
715 async fn test_batch_operations_fallback() {
716 // Add timeout to prevent hanging in parallel test execution
717 tokio::time::timeout(std::time::Duration::from_secs(30), async {
718 let temp_dir = TempDir::new().unwrap();
719 let mut config = Config::default();
720 // Force fallback path by setting small threshold so batch writer is not initialized
721 config.storage.memtable_size_threshold = 1024; // 1KB - smaller than 1MB threshold
722 let platform = Arc::new(Platform::new(&config).await.unwrap());
723
724 let mut storage = StorageEngine::open(
725 temp_dir.path(),
726 &config,
727 platform,
728 #[cfg(feature = "state_machine")]
729 None,
730 )
731 .await
732 .unwrap();
733
734 // Test batch write operations (should use fallback path)
735 let batch_ops = vec![
736 BatchOperation::Put {
737 table_id: TableId::new("test_table"),
738 key: RowKey::from("key1"),
739 value: Value::Text("value1".to_string()),
740 },
741 BatchOperation::Put {
742 table_id: TableId::new("test_table"),
743 key: RowKey::from("key2"),
744 value: Value::Text("value2".to_string()),
745 },
746 BatchOperation::Delete {
747 table_id: TableId::new("test_table"),
748 key: RowKey::from("key3"),
749 },
750 ];
751
752 storage.batch_write(batch_ops).await.unwrap();
753 storage.shutdown().await.unwrap();
754 })
755 .await
756 .expect("Test should complete within 30 seconds");
757 }
758}