Skip to main content

alopex_embedded/
columnar_api.rs

1//! カラムナーストレージの埋め込み API 拡張。
2
3use std::collections::hash_map::DefaultHasher;
4use std::hash::{Hash, Hasher};
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7
8use alopex_core::columnar::encoding::{Column, LogicalType};
9use alopex_core::columnar::encoding_v2::Bitmap;
10use alopex_core::columnar::kvs_bridge::{ColumnarKvsBridge, StreamingSegmentLayoutPreflightV08};
11use alopex_core::columnar::segment_v08::ChunkedSegmentAccessV08;
12use alopex_core::columnar::segment_v2::{
13    RecordBatch as CoreRecordBatch, Schema as CoreSchema, SegmentWriterV2,
14};
15use alopex_core::storage::format::AlopexFileWriter;
16use alopex_core::{StorageFactory, StorageMode as CoreStorageMode};
17use alopex_dataframe::io::ColumnarSegmentBatchSourceFactory;
18use alopex_dataframe::physical::{
19    ColumnarSegmentBatchPlan, ColumnarSegmentCursor, ColumnarSegmentProvider, DataFrameStream,
20    PlanSubject, SourceLimit,
21};
22use alopex_dataframe::{DataFrameError, Result as DataFrameResult};
23use arrow::array::{
24    ArrayRef, BinaryArray, BooleanArray, FixedSizeBinaryBuilder, Float32Array, Float64Array,
25    Int64Array,
26};
27use arrow::datatypes::{DataType, Field, Schema as ArrowSchema, SchemaRef};
28use arrow::record_batch::RecordBatch as ArrowRecordBatch;
29
30use crate::{Database, Error, Result, SegmentConfigV2, Transaction, TxnMode};
31
32#[cfg(test)]
33thread_local! {
34    static LAST_READ_COLUMN_INDICES: std::cell::RefCell<Option<Vec<usize>>> =
35        const { std::cell::RefCell::new(None) };
36}
37
38#[cfg(test)]
39fn record_read_column_indices(indices: &[usize]) {
40    LAST_READ_COLUMN_INDICES.with(|last| {
41        *last.borrow_mut() = Some(indices.to_vec());
42    });
43}
44
45/// セグメント統計情報。
46#[derive(Debug, Clone)]
47pub struct ColumnarSegmentStats {
48    /// セグメント内の行数。
49    pub row_count: usize,
50    /// セグメント内のカラム数。
51    pub column_count: usize,
52    /// セグメントのサイズ(バイト)。
53    pub size_bytes: usize,
54}
55
56/// カラムナーインデックス種別。
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum ColumnarIndexType {
59    /// 最小値/最大値インデックス。
60    Minmax,
61    /// Bloom フィルタインデックス。
62    Bloom,
63}
64
65impl ColumnarIndexType {
66    /// 文字列表現を返す。
67    pub fn as_str(&self) -> &'static str {
68        match self {
69            Self::Minmax => "minmax",
70            Self::Bloom => "bloom",
71        }
72    }
73}
74
75/// カラムナーインデックス情報。
76#[derive(Debug, Clone)]
77pub struct ColumnarIndexInfo {
78    /// 対象カラム名。
79    pub column: String,
80    /// インデックス種別。
81    pub index_type: ColumnarIndexType,
82}
83
84/// カラムナー関連設定。
85#[derive(Debug, Clone)]
86pub struct EmbeddedConfig {
87    /// データパス(Disk モード時に必須)。
88    pub path: Option<PathBuf>,
89    /// カラムナーストレージモード。
90    pub storage_mode: StorageMode,
91    /// InMemory モードのメモリ上限(バイト)。
92    pub memory_limit: Option<usize>,
93    /// セグメント設定。
94    pub segment_config: SegmentConfigV2,
95}
96
97impl EmbeddedConfig {
98    /// ディスクモードで初期化。
99    pub fn disk(path: PathBuf) -> Self {
100        Self {
101            path: Some(path),
102            storage_mode: StorageMode::Disk,
103            memory_limit: None,
104            segment_config: SegmentConfigV2::default(),
105        }
106    }
107
108    /// インメモリモードで初期化(無制限)。
109    pub fn in_memory() -> Self {
110        Self {
111            path: None,
112            storage_mode: StorageMode::InMemory,
113            memory_limit: None,
114            segment_config: SegmentConfigV2::default(),
115        }
116    }
117
118    /// インメモリモードでメモリ上限を設定。
119    pub fn in_memory_with_limit(limit: usize) -> Self {
120        Self {
121            path: None,
122            storage_mode: StorageMode::InMemory,
123            memory_limit: Some(limit),
124            segment_config: SegmentConfigV2::default(),
125        }
126    }
127
128    /// セグメント設定を上書き。
129    pub fn with_segment_config(mut self, cfg: SegmentConfigV2) -> Self {
130        self.segment_config = cfg;
131        self
132    }
133}
134
135/// カラムナー用ストレージモード。
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum StorageMode {
138    /// KVS 経由でディスク永続化。
139    Disk,
140    /// 完全インメモリ保持。
141    InMemory,
142}
143
144impl Database {
145    /// 構成付きでデータベースを開く(カラムナー機能を初期化)。
146    pub fn open_with_config(config: EmbeddedConfig) -> Result<Self> {
147        let store = match config.storage_mode {
148            StorageMode::Disk => {
149                let path = config.path.clone().ok_or_else(|| {
150                    Error::Core(alopex_core::Error::InvalidFormat(
151                        "disk mode requires a path".into(),
152                    ))
153                })?;
154                let path = crate::disk_data_dir_path(&path);
155                StorageFactory::create(CoreStorageMode::Disk { path, config: None })
156                    .map_err(Error::Core)?
157            }
158            StorageMode::InMemory => StorageFactory::create(CoreStorageMode::Memory {
159                max_size: config.memory_limit,
160            })
161            .map_err(Error::Core)?,
162        };
163
164        Ok(Self::init(
165            store,
166            config.storage_mode,
167            config.memory_limit,
168            config.segment_config,
169        ))
170    }
171
172    /// 現在のカラムナーストレージモードを返す。
173    pub fn storage_mode(&self) -> StorageMode {
174        self.columnar_mode
175    }
176
177    /// カラムナーセグメントを書き込む。
178    pub fn write_columnar_segment(&self, table: &str, batch: CoreRecordBatch) -> Result<u64> {
179        let mut writer = SegmentWriterV2::new(self.segment_config.clone());
180        writer
181            .write_batch(batch)
182            .map_err(|e| Error::Core(e.into()))?;
183        let segment = writer.finish().map_err(|e| Error::Core(e.into()))?;
184        let table_id = table_id(table)?;
185
186        match self.columnar_mode {
187            StorageMode::Disk => self
188                .columnar_bridge
189                .write_segment(table_id, &segment)
190                .map_err(|e| Error::Core(e.into())),
191            StorageMode::InMemory => {
192                let store = self.columnar_memory.as_ref().ok_or_else(|| {
193                    Error::Core(alopex_core::Error::InvalidFormat(
194                        "in-memory columnar store is not initialized".into(),
195                    ))
196                })?;
197                store
198                    .write_segment(table_id, segment)
199                    .map_err(|e| Error::Core(e.into()))
200            }
201        }
202    }
203
204    /// カラムナーセグメントを書き込む(構成上書き)。
205    pub fn write_columnar_segment_with_config(
206        &self,
207        table: &str,
208        batch: CoreRecordBatch,
209        config: SegmentConfigV2,
210    ) -> Result<u64> {
211        let mut writer = SegmentWriterV2::new(config);
212        writer
213            .write_batch(batch)
214            .map_err(|e| Error::Core(e.into()))?;
215        let segment = writer.finish().map_err(|e| Error::Core(e.into()))?;
216        let table_id = table_id(table)?;
217
218        match self.columnar_mode {
219            StorageMode::Disk => self
220                .columnar_bridge
221                .write_segment(table_id, &segment)
222                .map_err(|e| Error::Core(e.into())),
223            StorageMode::InMemory => {
224                let store = self.columnar_memory.as_ref().ok_or_else(|| {
225                    Error::Core(alopex_core::Error::InvalidFormat(
226                        "in-memory columnar store is not initialized".into(),
227                    ))
228                })?;
229                store
230                    .write_segment(table_id, segment)
231                    .map_err(|e| Error::Core(e.into()))
232            }
233        }
234    }
235
236    /// カラムナーセグメントを読み取る(カラム名指定オプション付き)。
237    pub fn read_columnar_segment(
238        &self,
239        table: &str,
240        segment_id: u64,
241        columns: Option<&[&str]>,
242    ) -> Result<Vec<CoreRecordBatch>> {
243        let table_id = table_id(table)?;
244        match self.columnar_mode {
245            StorageMode::Disk => {
246                let read_indices: Vec<usize> = if let Some(names) = columns {
247                    let segment = self
248                        .columnar_bridge
249                        .read_segment_raw(table_id, segment_id)
250                        .map_err(|e| Error::Core(e.into()))?;
251                    resolve_indices_from_schema(&segment.meta.schema, names)?
252                } else {
253                    let column_count = self
254                        .columnar_bridge
255                        .column_count(table_id, segment_id)
256                        .map_err(|e| Error::Core(e.into()))?;
257                    (0..column_count).collect()
258                };
259
260                #[cfg(test)]
261                record_read_column_indices(&read_indices);
262
263                self.columnar_bridge
264                    .read_segment(table_id, segment_id, &read_indices)
265                    .map_err(|e| Error::Core(e.into()))
266            }
267            StorageMode::InMemory => {
268                let store = self.columnar_memory.as_ref().ok_or_else(|| {
269                    Error::Core(alopex_core::Error::InvalidFormat(
270                        "in-memory columnar store is not initialized".into(),
271                    ))
272                })?;
273                let read_indices: Vec<usize> = if let Some(names) = columns {
274                    let schema = store
275                        .schema(table_id, segment_id)
276                        .map_err(|e| Error::Core(e.into()))?;
277                    resolve_indices_from_schema(&schema, names)?
278                } else {
279                    let column_count = store
280                        .column_count(table_id, segment_id)
281                        .map_err(|e| Error::Core(e.into()))?;
282                    (0..column_count).collect()
283                };
284
285                #[cfg(test)]
286                record_read_column_indices(&read_indices);
287
288                store
289                    .read_segment(table_id, segment_id, &read_indices)
290                    .map_err(|e| Error::Core(e.into()))
291            }
292        }
293    }
294
295    /// InMemory モード時のメモリ使用量を返す。Disk モードでは None。
296    pub fn in_memory_usage(&self) -> Option<u64> {
297        if self.columnar_mode == StorageMode::InMemory {
298            self.columnar_memory.as_ref().map(|m| m.memory_usage())
299        } else {
300            None
301        }
302    }
303
304    /// メモリ上限付きでインメモリ DB を開く。
305    pub fn open_in_memory_with_limit(limit: usize) -> Result<Self> {
306        Self::open_with_config(EmbeddedConfig::in_memory_with_limit(limit))
307    }
308
309    /// テーブル名から内部 ID を解決する。
310    pub fn resolve_table_id(&self, table: &str) -> Result<u32> {
311        table_id(table)
312    }
313
314    /// Builds a bounded DataFrame factory for a provisioned V08 columnar segment.
315    ///
316    /// Legacy V2 segment blobs and in-memory columnar storage deliberately
317    /// return `requires_v08_chunked_layout`; neither is converted into an
318    /// artificial streaming source.
319    pub fn columnar_segment_streaming_factory_v08(
320        &self,
321        table: &str,
322        segment_id: u64,
323    ) -> Result<ColumnarSegmentBatchSourceFactory> {
324        let table_id = table_id(table)?;
325        self.columnar_segment_streaming_factory_v08_by_ids(table_id, segment_id)
326    }
327
328    /// Builds a bounded DataFrame factory from the canonical `{table_id}:{segment_id}` handle.
329    ///
330    /// This is the same local V08-only path as [`Self::columnar_segment_streaming_factory_v08`],
331    /// but preserves the public segment identity used by the catalog and Python `LocalScan`
332    /// without requiring a lossy reverse lookup of a table name.
333    pub fn columnar_segment_streaming_factory_v08_by_id(
334        &self,
335        segment_id: &str,
336    ) -> Result<ColumnarSegmentBatchSourceFactory> {
337        let (table_id, segment_id) = parse_segment_id(segment_id)?;
338        self.columnar_segment_streaming_factory_v08_by_ids(table_id, segment_id)
339    }
340
341    fn columnar_segment_streaming_factory_v08_by_ids(
342        &self,
343        table_id: u32,
344        segment_id: u64,
345    ) -> Result<ColumnarSegmentBatchSourceFactory> {
346        match self.columnar_mode {
347            StorageMode::Disk => match self
348                .columnar_bridge
349                .streaming_layout_preflight_v08(table_id, segment_id)
350                .map_err(|error| Error::DataFrame(map_v08_error(error)))?
351            {
352                StreamingSegmentLayoutPreflightV08::Available => Ok(
353                    ColumnarSegmentBatchSourceFactory::new(Arc::new(EmbeddedV08SegmentProvider {
354                        bridge: self.columnar_bridge.clone(),
355                        table_id,
356                        segment_id,
357                    })),
358                ),
359                StreamingSegmentLayoutPreflightV08::RequiresV08ChunkedLayout => {
360                    Err(Error::DataFrame(DataFrameError::streaming_unsupported(
361                        "columnar_segment_scan",
362                        "requires_v08_chunked_layout",
363                    )))
364                }
365                StreamingSegmentLayoutPreflightV08::NotFound => {
366                    Err(Error::DataFrame(DataFrameError::streaming_unsupported(
367                        "columnar_segment_scan",
368                        "v08_segment_not_found",
369                    )))
370                }
371            },
372            StorageMode::InMemory => Err(Error::DataFrame(DataFrameError::streaming_unsupported(
373                "columnar_segment_scan",
374                "requires_v08_chunked_layout",
375            ))),
376        }
377    }
378
379    /// Opens a bounded `DataFrameStream` for a provisioned V08 columnar segment.
380    pub fn stream_columnar_segment_v08(
381        &self,
382        table: &str,
383        segment_id: u64,
384        options: alopex_dataframe::physical::budget::StreamOptions,
385    ) -> Result<DataFrameStream> {
386        let factory = self.columnar_segment_streaming_factory_v08(table, segment_id)?;
387        DataFrameStream::from_factory(&factory, options).map_err(Error::DataFrame)
388    }
389
390    /// Opens a bounded V08 segment stream from the canonical `{table_id}:{segment_id}` handle.
391    pub fn stream_columnar_segment_v08_by_id(
392        &self,
393        segment_id: &str,
394        options: alopex_dataframe::physical::budget::StreamOptions,
395    ) -> Result<DataFrameStream> {
396        let factory = self.columnar_segment_streaming_factory_v08_by_id(segment_id)?;
397        DataFrameStream::from_factory(&factory, options).map_err(Error::DataFrame)
398    }
399
400    /// Scan a columnar segment by string ID.
401    ///
402    /// The segment ID format is `{table_id}:{segment_id}` (e.g., "12345:1").
403    /// Returns rows as a vector of SqlValue vectors.
404    pub fn scan_columnar_segment(
405        &self,
406        segment_id: &str,
407    ) -> Result<Vec<Vec<alopex_sql::SqlValue>>> {
408        let (table_id, seg_id) = parse_segment_id(segment_id)?;
409        let all_indices: Vec<usize> = match self.columnar_mode {
410            StorageMode::Disk => {
411                let count = self
412                    .columnar_bridge
413                    .column_count(table_id, seg_id)
414                    .map_err(|e| Error::Core(e.into()))?;
415                (0..count).collect()
416            }
417            StorageMode::InMemory => {
418                let store = self.columnar_memory.as_ref().ok_or_else(|| {
419                    Error::Core(alopex_core::Error::InvalidFormat(
420                        "in-memory columnar store is not initialized".into(),
421                    ))
422                })?;
423                let count = store
424                    .column_count(table_id, seg_id)
425                    .map_err(|e| Error::Core(e.into()))?;
426                (0..count).collect()
427            }
428        };
429
430        let batches = match self.columnar_mode {
431            StorageMode::Disk => self
432                .columnar_bridge
433                .read_segment(table_id, seg_id, &all_indices)
434                .map_err(|e| Error::Core(e.into()))?,
435            StorageMode::InMemory => self
436                .columnar_memory
437                .as_ref()
438                .ok_or_else(|| {
439                    Error::Core(alopex_core::Error::InvalidFormat(
440                        "in-memory columnar store is not initialized".into(),
441                    ))
442                })?
443                .read_segment(table_id, seg_id, &all_indices)
444                .map_err(|e| Error::Core(e.into()))?,
445        };
446
447        // Convert RecordBatch to Vec<Vec<SqlValue>>
448        let mut rows = Vec::new();
449        for batch in batches {
450            let num_rows = batch.num_rows();
451            for row_idx in 0..num_rows {
452                let mut row = Vec::with_capacity(batch.columns.len());
453                for col in &batch.columns {
454                    let sql_val = column_value_to_sql_value(col, row_idx);
455                    row.push(sql_val);
456                }
457                rows.push(row);
458            }
459        }
460        Ok(rows)
461    }
462
463    /// Scan a columnar segment by string ID, returning RecordBatches for streaming (FR-7).
464    ///
465    /// This method returns raw `RecordBatch` objects, allowing the caller to iterate
466    /// over rows without materializing all data upfront. Use this for large datasets
467    /// where streaming is required.
468    ///
469    /// The segment ID format is `{table_id}:{segment_id}` (e.g., "12345:1").
470    pub fn scan_columnar_segment_batches(&self, segment_id: &str) -> Result<Vec<CoreRecordBatch>> {
471        let (table_id, seg_id) = parse_segment_id(segment_id)?;
472        let all_indices: Vec<usize> = match self.columnar_mode {
473            StorageMode::Disk => {
474                let count = self
475                    .columnar_bridge
476                    .column_count(table_id, seg_id)
477                    .map_err(|e| Error::Core(e.into()))?;
478                (0..count).collect()
479            }
480            StorageMode::InMemory => {
481                let store = self.columnar_memory.as_ref().ok_or_else(|| {
482                    Error::Core(alopex_core::Error::InvalidFormat(
483                        "in-memory columnar store is not initialized".into(),
484                    ))
485                })?;
486                let count = store
487                    .column_count(table_id, seg_id)
488                    .map_err(|e| Error::Core(e.into()))?;
489                (0..count).collect()
490            }
491        };
492
493        match self.columnar_mode {
494            StorageMode::Disk => self
495                .columnar_bridge
496                .read_segment(table_id, seg_id, &all_indices)
497                .map_err(|e| Error::Core(e.into())),
498            StorageMode::InMemory => self
499                .columnar_memory
500                .as_ref()
501                .ok_or_else(|| {
502                    Error::Core(alopex_core::Error::InvalidFormat(
503                        "in-memory columnar store is not initialized".into(),
504                    ))
505                })?
506                .read_segment(table_id, seg_id, &all_indices)
507                .map_err(|e| Error::Core(e.into())),
508        }
509    }
510
511    /// Create a streaming row iterator over a columnar segment (FR-7).
512    ///
513    /// This returns a `ColumnarRowIterator` that yields rows one at a time from
514    /// the underlying RecordBatches, without materializing all rows upfront.
515    ///
516    /// The segment ID format is `{table_id}:{segment_id}` (e.g., "12345:1").
517    pub fn scan_columnar_segment_streaming(&self, segment_id: &str) -> Result<ColumnarRowIterator> {
518        let batches = self.scan_columnar_segment_batches(segment_id)?;
519        Ok(ColumnarRowIterator::new(batches))
520    }
521
522    /// Get statistics for a columnar segment by string ID.
523    ///
524    /// The segment ID format is `{table_id}:{segment_id}` (e.g., "12345:1").
525    pub fn get_columnar_segment_stats(&self, segment_id: &str) -> Result<ColumnarSegmentStats> {
526        let (table_id, seg_id) = parse_segment_id(segment_id)?;
527
528        match self.columnar_mode {
529            StorageMode::Disk => {
530                let column_count = self
531                    .columnar_bridge
532                    .column_count(table_id, seg_id)
533                    .map_err(|e| Error::Core(e.into()))?;
534                let batches = self
535                    .columnar_bridge
536                    .read_segment(table_id, seg_id, &(0..column_count).collect::<Vec<_>>())
537                    .map_err(|e| Error::Core(e.into()))?;
538                let row_count: usize = batches.iter().map(|b| b.num_rows()).sum();
539
540                Ok(ColumnarSegmentStats {
541                    row_count,
542                    column_count,
543                    size_bytes: 0, // Size not available in current implementation
544                })
545            }
546            StorageMode::InMemory => {
547                let store = self.columnar_memory.as_ref().ok_or_else(|| {
548                    Error::Core(alopex_core::Error::InvalidFormat(
549                        "in-memory columnar store is not initialized".into(),
550                    ))
551                })?;
552                let column_count = store
553                    .column_count(table_id, seg_id)
554                    .map_err(|e| Error::Core(e.into()))?;
555                let batches = store
556                    .read_segment(table_id, seg_id, &(0..column_count).collect::<Vec<_>>())
557                    .map_err(|e| Error::Core(e.into()))?;
558                let row_count: usize = batches.iter().map(|b| b.num_rows()).sum();
559
560                Ok(ColumnarSegmentStats {
561                    row_count,
562                    column_count,
563                    size_bytes: 0, // Size not available in current implementation
564                })
565            }
566        }
567    }
568
569    /// List all columnar segments.
570    ///
571    /// Returns segment IDs in the format `{table_id}:{segment_id}`.
572    pub fn list_columnar_segments(&self) -> Result<Vec<String>> {
573        match self.columnar_mode {
574            StorageMode::Disk => {
575                let segments = self
576                    .columnar_bridge
577                    .list_segments()
578                    .map_err(|e| Error::Core(e.into()))?;
579                Ok(segments
580                    .into_iter()
581                    .map(|(table_id, seg_id)| format!("{}:{}", table_id, seg_id))
582                    .collect())
583            }
584            StorageMode::InMemory => {
585                let store = self.columnar_memory.as_ref().ok_or_else(|| {
586                    Error::Core(alopex_core::Error::InvalidFormat(
587                        "in-memory columnar store is not initialized".into(),
588                    ))
589                })?;
590                let segments = store.list_segments();
591                Ok(segments
592                    .into_iter()
593                    .map(|(table_id, seg_id)| format!("{}:{}", table_id, seg_id))
594                    .collect())
595            }
596        }
597    }
598
599    /// Create a columnar index for a segment/column.
600    pub fn create_columnar_index(
601        &self,
602        segment_id: &str,
603        column: &str,
604        index_type: ColumnarIndexType,
605    ) -> Result<()> {
606        let _ = self.get_columnar_segment_stats(segment_id)?;
607        let key = columnar_index_key(segment_id, column);
608        let value = index_type.as_str().as_bytes().to_vec();
609        let mut txn = self.begin(TxnMode::ReadWrite)?;
610        txn.put(&key, &value)?;
611        txn.commit()?;
612        Ok(())
613    }
614
615    /// List columnar indexes for a segment.
616    pub fn list_columnar_indexes(&self, segment_id: &str) -> Result<Vec<ColumnarIndexInfo>> {
617        let _ = self.get_columnar_segment_stats(segment_id)?;
618        let prefix = columnar_index_prefix(segment_id);
619        let mut txn = self.begin(TxnMode::ReadOnly)?;
620        let mut entries = Vec::new();
621        for (key, value) in txn.scan_prefix(&prefix)? {
622            let column = parse_index_column(segment_id, &key)?;
623            let index_type = parse_index_type(&value)?;
624            entries.push(ColumnarIndexInfo { column, index_type });
625        }
626        txn.commit()?;
627        Ok(entries)
628    }
629
630    /// Drop a columnar index for a segment/column.
631    pub fn drop_columnar_index(&self, segment_id: &str, column: &str) -> Result<()> {
632        let _ = self.get_columnar_segment_stats(segment_id)?;
633        let key = columnar_index_key(segment_id, column);
634        let mut txn = self.begin(TxnMode::ReadWrite)?;
635        let exists = txn.get(&key)?.is_some();
636        if !exists {
637            txn.rollback()?;
638            return Err(Error::IndexNotFound(format!(
639                "columnar index {}:{}",
640                segment_id, column
641            )));
642        }
643        txn.delete(&key)?;
644        txn.commit()?;
645        Ok(())
646    }
647
648    /// InMemory モードのセグメントをファイルへフラッシュする。
649    pub fn flush_in_memory_segment_to_file(
650        &self,
651        table: &str,
652        segment_id: u64,
653        path: &Path,
654    ) -> Result<()> {
655        let store = self
656            .columnar_memory
657            .as_ref()
658            .ok_or(Error::NotInMemoryMode)?;
659        let table_id = table_id(table)?;
660        store
661            .flush_to_segment_file(table_id, segment_id, path)
662            .map_err(|e| Error::Core(e.into()))
663    }
664
665    /// InMemory モードのセグメントを KVS へフラッシュする。
666    pub fn flush_in_memory_segment_to_kvs(&self, table: &str, segment_id: u64) -> Result<u64> {
667        let store = self
668            .columnar_memory
669            .as_ref()
670            .ok_or(Error::NotInMemoryMode)?;
671        let table_id = table_id(table)?;
672        store
673            .flush_to_kvs(table_id, segment_id, &self.columnar_bridge)
674            .map_err(|e| Error::Core(e.into()))
675    }
676
677    /// InMemory モードのセグメントを `.alopex` ファイルへフラッシュする。
678    pub fn flush_in_memory_segment_to_alopex(
679        &self,
680        table: &str,
681        segment_id: u64,
682        writer: &mut AlopexFileWriter,
683    ) -> Result<u32> {
684        let store = self
685            .columnar_memory
686            .as_ref()
687            .ok_or(Error::NotInMemoryMode)?;
688        let table_id = table_id(table)?;
689        store
690            .flush_to_alopex(table_id, segment_id, writer)
691            .map_err(|e| Error::Core(e.into()))
692    }
693}
694
695impl<'a> Transaction<'a> {
696    /// 現在のカラムナーストレージモードを返す。
697    pub fn storage_mode(&self) -> StorageMode {
698        self.db.storage_mode()
699    }
700
701    /// カラムナーセグメントを書き込む(トランザクションコンテキスト利用)。
702    pub fn write_columnar_segment(&self, table: &str, batch: CoreRecordBatch) -> Result<u64> {
703        self.db.write_columnar_segment(table, batch)
704    }
705
706    /// カラムナーセグメントを読み取る(トランザクションコンテキスト利用)。
707    pub fn read_columnar_segment(
708        &self,
709        table: &str,
710        segment_id: u64,
711        columns: Option<&[&str]>,
712    ) -> Result<Vec<CoreRecordBatch>> {
713        self.db.read_columnar_segment(table, segment_id, columns)
714    }
715}
716
717fn table_id(table: &str) -> Result<u32> {
718    if table.is_empty() {
719        return Err(Error::TableNotFound("table name is empty".into()));
720    }
721    let mut hasher = DefaultHasher::new();
722    table.hash(&mut hasher);
723    Ok((hasher.finish() & 0xffff_ffff) as u32)
724}
725
726fn resolve_indices_from_schema(
727    schema: &alopex_core::columnar::segment_v2::Schema,
728    names: &[&str],
729) -> Result<Vec<usize>> {
730    let mut indices = Vec::with_capacity(names.len());
731    for name in names {
732        let pos = schema
733            .columns
734            .iter()
735            .position(|c| c.name == *name)
736            .ok_or_else(|| {
737                Error::Core(alopex_core::Error::InvalidFormat(format!(
738                    "column not found: {name}"
739                )))
740            })?;
741        indices.push(pos);
742    }
743    Ok(indices)
744}
745
746const COLUMNAR_INDEX_PREFIX: &str = "__alopex_columnar_index__:";
747
748fn columnar_index_key(segment: &str, column: &str) -> Vec<u8> {
749    let mut key =
750        String::with_capacity(COLUMNAR_INDEX_PREFIX.len() + segment.len() + column.len() + 1);
751    key.push_str(COLUMNAR_INDEX_PREFIX);
752    key.push_str(segment);
753    key.push(':');
754    key.push_str(column);
755    key.into_bytes()
756}
757
758fn columnar_index_prefix(segment: &str) -> Vec<u8> {
759    let mut key = String::with_capacity(COLUMNAR_INDEX_PREFIX.len() + segment.len() + 1);
760    key.push_str(COLUMNAR_INDEX_PREFIX);
761    key.push_str(segment);
762    key.push(':');
763    key.into_bytes()
764}
765
766fn parse_index_column(segment: &str, key: &[u8]) -> Result<String> {
767    let prefix = columnar_index_prefix(segment);
768    if !key.starts_with(&prefix) {
769        return Err(Error::Core(alopex_core::Error::InvalidFormat(
770            "columnar index key is invalid".into(),
771        )));
772    }
773    let suffix = &key[prefix.len()..];
774    String::from_utf8(suffix.to_vec()).map_err(|_| {
775        Error::Core(alopex_core::Error::InvalidFormat(
776            "columnar index column is not valid UTF-8".into(),
777        ))
778    })
779}
780
781fn parse_index_type(raw: &[u8]) -> Result<ColumnarIndexType> {
782    let value = std::str::from_utf8(raw).map_err(|_| {
783        Error::Core(alopex_core::Error::InvalidFormat(
784            "columnar index type is not valid UTF-8".into(),
785        ))
786    })?;
787    match value {
788        "minmax" => Ok(ColumnarIndexType::Minmax),
789        "bloom" => Ok(ColumnarIndexType::Bloom),
790        other => Err(Error::Core(alopex_core::Error::InvalidFormat(format!(
791            "unknown columnar index type: {other}"
792        )))),
793    }
794}
795
796/// セグメントID文字列をパースする。
797///
798/// フォーマット: `{table_id}:{segment_id}` (例: "12345:1")
799fn parse_segment_id(segment_id: &str) -> Result<(u32, u64)> {
800    let parts: Vec<&str> = segment_id.split(':').collect();
801    if parts.len() != 2 {
802        return Err(Error::Core(alopex_core::Error::InvalidFormat(format!(
803            "invalid segment ID format: expected 'table_id:segment_id', got '{}'",
804            segment_id
805        ))));
806    }
807
808    let table_id: u32 = parts[0].parse().map_err(|_| {
809        Error::Core(alopex_core::Error::InvalidFormat(format!(
810            "invalid table_id in segment ID: '{}'",
811            parts[0]
812        )))
813    })?;
814
815    let seg_id: u64 = parts[1].parse().map_err(|_| {
816        Error::Core(alopex_core::Error::InvalidFormat(format!(
817            "invalid segment_id in segment ID: '{}'",
818            parts[1]
819        )))
820    })?;
821
822    Ok((table_id, seg_id))
823}
824
825/// カラム値を SqlValue に変換する。
826fn column_value_to_sql_value(col: &Column, row_idx: usize) -> alopex_sql::SqlValue {
827    match col {
828        Column::Int64(vals) => vals
829            .get(row_idx)
830            .map(|&v| alopex_sql::SqlValue::BigInt(v))
831            .unwrap_or(alopex_sql::SqlValue::Null),
832        Column::Float32(vals) => vals
833            .get(row_idx)
834            .map(|&v| alopex_sql::SqlValue::Float(v))
835            .unwrap_or(alopex_sql::SqlValue::Null),
836        Column::Float64(vals) => vals
837            .get(row_idx)
838            .map(|&v| alopex_sql::SqlValue::Double(v))
839            .unwrap_or(alopex_sql::SqlValue::Null),
840        Column::Bool(vals) => vals
841            .get(row_idx)
842            .map(|&v| alopex_sql::SqlValue::Boolean(v))
843            .unwrap_or(alopex_sql::SqlValue::Null),
844        Column::Binary(vals) => vals
845            .get(row_idx)
846            .map(|v| alopex_sql::SqlValue::Blob(v.clone()))
847            .unwrap_or(alopex_sql::SqlValue::Null),
848        Column::Fixed { values, .. } => values
849            .get(row_idx)
850            .map(|v| alopex_sql::SqlValue::Blob(v.clone()))
851            .unwrap_or(alopex_sql::SqlValue::Null),
852    }
853}
854
855struct EmbeddedV08SegmentProvider {
856    bridge: ColumnarKvsBridge,
857    table_id: u32,
858    segment_id: u64,
859}
860
861impl ColumnarSegmentProvider for EmbeddedV08SegmentProvider {
862    fn source_limits(&self) -> Vec<SourceLimit> {
863        vec![
864            SourceLimit {
865                source: PlanSubject::ColumnarSegmentScan,
866                code: "stable_row_group_order",
867                description: "V08 streaming preserves provisioned row-group and row order",
868            },
869            SourceLimit {
870                source: PlanSubject::ColumnarSegmentScan,
871                code: "requires_v08_chunked_layout",
872                description: "legacy V2 blobs and in-memory columnar storage are rejected before streaming reads",
873            },
874            SourceLimit {
875                source: PlanSubject::ColumnarSegmentScan,
876                code: "row_group_must_fit_resource_bound",
877                description: "each V08 row-group decoded and Arrow upper bound is reserved before chunk fetch",
878            },
879        ]
880    }
881
882    fn open(&self, metadata_limit_bytes: u64) -> DataFrameResult<Box<dyn ColumnarSegmentCursor>> {
883        let access = self
884            .bridge
885            .open_v08_segment_with_metadata_limit(
886                self.table_id,
887                self.segment_id,
888                metadata_limit_bytes,
889            )
890            .map_err(map_v08_error)?;
891        let schema = core_schema_to_arrow(access.schema())?;
892        let projection = (0..access.schema().column_count()).collect();
893        Ok(Box::new(EmbeddedV08SegmentCursor {
894            access,
895            schema,
896            projection,
897            next_row_group: 0,
898            closed: false,
899        }))
900    }
901}
902
903struct EmbeddedV08SegmentCursor {
904    access: ChunkedSegmentAccessV08,
905    schema: SchemaRef,
906    projection: Vec<usize>,
907    next_row_group: usize,
908    closed: bool,
909}
910
911impl ColumnarSegmentCursor for EmbeddedV08SegmentCursor {
912    fn schema(&self) -> SchemaRef {
913        self.schema.clone()
914    }
915
916    fn metadata_footprint_upper_bound(&self) -> u64 {
917        self.access.metadata_footprint_upper_bound()
918    }
919
920    fn next_batch_plan(&self) -> DataFrameResult<Option<ColumnarSegmentBatchPlan>> {
921        if self.closed || self.next_row_group == self.access.row_group_count() {
922            return Ok(None);
923        }
924        let row_group = self
925            .access
926            .row_group(self.next_row_group)
927            .map_err(map_v08_error)?;
928        Ok(Some(ColumnarSegmentBatchPlan {
929            decoded_bytes: row_group.decoded_bytes,
930            arrow_allocation_upper_bound: row_group.arrow_allocation_upper_bound,
931        }))
932    }
933
934    fn next_batch(&mut self) -> DataFrameResult<Option<ArrowRecordBatch>> {
935        if self.closed || self.next_row_group == self.access.row_group_count() {
936            return Ok(None);
937        }
938        let core_batch = self
939            .access
940            .read_row_group(self.next_row_group, &self.projection)
941            .map_err(map_v08_error)?;
942        let batch = core_batch_to_arrow(core_batch, self.schema.clone())?;
943        self.next_row_group = self.next_row_group.saturating_add(1);
944        Ok(Some(batch))
945    }
946
947    fn close(&mut self) -> DataFrameResult<()> {
948        self.closed = true;
949        Ok(())
950    }
951}
952
953fn core_schema_to_arrow(schema: &CoreSchema) -> DataFrameResult<SchemaRef> {
954    let fields = schema
955        .columns
956        .iter()
957        .map(|column| {
958            Ok(Field::new(
959                &column.name,
960                arrow_type(column.logical_type)?,
961                column.nullable,
962            ))
963        })
964        .collect::<DataFrameResult<Vec<_>>>()?;
965    Ok(Arc::new(ArrowSchema::new(fields)))
966}
967
968fn arrow_type(logical_type: LogicalType) -> DataFrameResult<DataType> {
969    match logical_type {
970        LogicalType::Int64 => Ok(DataType::Int64),
971        LogicalType::Float32 => Ok(DataType::Float32),
972        LogicalType::Float64 => Ok(DataType::Float64),
973        LogicalType::Bool => Ok(DataType::Boolean),
974        LogicalType::Binary => Ok(DataType::Binary),
975        LogicalType::Fixed(length) => Ok(DataType::FixedSizeBinary(i32::from(length))),
976    }
977}
978
979fn core_batch_to_arrow(
980    batch: CoreRecordBatch,
981    schema: SchemaRef,
982) -> DataFrameResult<ArrowRecordBatch> {
983    if batch.schema.columns.len() != schema.fields().len()
984        || batch.columns.len() != schema.fields().len()
985        || batch.null_bitmaps.len() != schema.fields().len()
986    {
987        return Err(DataFrameError::schema_mismatch(
988            "V08 decoded batch does not match its provisioned schema",
989        ));
990    }
991
992    let arrays = batch
993        .columns
994        .iter()
995        .zip(batch.null_bitmaps.iter())
996        .map(|(column, bitmap)| core_column_to_arrow(column, bitmap.as_ref()))
997        .collect::<DataFrameResult<Vec<_>>>()?;
998    ArrowRecordBatch::try_new(schema, arrays).map_err(|source| DataFrameError::Arrow { source })
999}
1000
1001fn core_column_to_arrow(column: &Column, bitmap: Option<&Bitmap>) -> DataFrameResult<ArrayRef> {
1002    let valid = |index| bitmap.is_none_or(|bitmap| bitmap.is_valid(index));
1003    match column {
1004        Column::Int64(values) => Ok(Arc::new(Int64Array::from(
1005            values
1006                .iter()
1007                .enumerate()
1008                .map(|(index, value)| valid(index).then_some(*value))
1009                .collect::<Vec<_>>(),
1010        )) as ArrayRef),
1011        Column::Float32(values) => Ok(Arc::new(Float32Array::from(
1012            values
1013                .iter()
1014                .enumerate()
1015                .map(|(index, value)| valid(index).then_some(*value))
1016                .collect::<Vec<_>>(),
1017        )) as ArrayRef),
1018        Column::Float64(values) => Ok(Arc::new(Float64Array::from(
1019            values
1020                .iter()
1021                .enumerate()
1022                .map(|(index, value)| valid(index).then_some(*value))
1023                .collect::<Vec<_>>(),
1024        )) as ArrayRef),
1025        Column::Bool(values) => Ok(Arc::new(BooleanArray::from(
1026            values
1027                .iter()
1028                .enumerate()
1029                .map(|(index, value)| valid(index).then_some(*value))
1030                .collect::<Vec<_>>(),
1031        )) as ArrayRef),
1032        Column::Binary(values) => Ok(Arc::new(BinaryArray::from(
1033            values
1034                .iter()
1035                .enumerate()
1036                .map(|(index, value)| valid(index).then_some(value.as_slice()))
1037                .collect::<Vec<_>>(),
1038        )) as ArrayRef),
1039        Column::Fixed { len, values } => {
1040            let byte_width = i32::try_from(*len).map_err(|_| {
1041                DataFrameError::schema_mismatch("V08 fixed binary length does not fit Arrow")
1042            })?;
1043            let mut builder = FixedSizeBinaryBuilder::with_capacity(values.len(), byte_width);
1044            for (index, value) in values.iter().enumerate() {
1045                if valid(index) {
1046                    builder
1047                        .append_value(value)
1048                        .map_err(|source| DataFrameError::Arrow { source })?;
1049                } else {
1050                    builder.append_null();
1051                }
1052            }
1053            Ok(Arc::new(builder.finish()) as ArrayRef)
1054        }
1055    }
1056}
1057
1058fn map_v08_error(error: alopex_core::columnar::ColumnarError) -> DataFrameError {
1059    match error {
1060        alopex_core::columnar::ColumnarError::RequiresV08ChunkedLayout => {
1061            DataFrameError::streaming_unsupported(
1062                "columnar_segment_scan",
1063                "requires_v08_chunked_layout",
1064            )
1065        }
1066        alopex_core::columnar::ColumnarError::NotFound => {
1067            DataFrameError::streaming_unsupported("columnar_segment_scan", "v08_segment_not_found")
1068        }
1069        error => DataFrameError::schema_mismatch(format!("V08 columnar segment error: {error}")),
1070    }
1071}
1072
1073// ============================================================================
1074// ColumnarRowIterator - FR-7 Streaming Row Iterator
1075// ============================================================================
1076
1077/// Streaming row iterator for columnar segments (FR-7 compliant).
1078///
1079/// This iterator yields rows one at a time from pre-loaded RecordBatches,
1080/// avoiding the need to materialize all rows into `Vec<Vec<SqlValue>>` upfront.
1081pub struct ColumnarRowIterator {
1082    /// Pre-loaded RecordBatches.
1083    batches: Vec<CoreRecordBatch>,
1084    /// Current batch index.
1085    batch_idx: usize,
1086    /// Current row index within the batch.
1087    row_idx: usize,
1088}
1089
1090impl ColumnarRowIterator {
1091    /// Create a new row iterator from RecordBatches.
1092    pub fn new(batches: Vec<CoreRecordBatch>) -> Self {
1093        Self {
1094            batches,
1095            batch_idx: 0,
1096            row_idx: 0,
1097        }
1098    }
1099
1100    /// Returns the total number of batches.
1101    pub fn batch_count(&self) -> usize {
1102        self.batches.len()
1103    }
1104
1105    /// Returns the current batch being iterated, if any.
1106    pub fn current_batch(&self) -> Option<&CoreRecordBatch> {
1107        self.batches.get(self.batch_idx)
1108    }
1109}
1110
1111impl Iterator for ColumnarRowIterator {
1112    type Item = Vec<alopex_sql::SqlValue>;
1113
1114    fn next(&mut self) -> Option<Self::Item> {
1115        loop {
1116            // Check if we've exhausted all batches
1117            if self.batch_idx >= self.batches.len() {
1118                return None;
1119            }
1120
1121            let batch = &self.batches[self.batch_idx];
1122            let row_count = batch.num_rows();
1123
1124            // Check if we've exhausted the current batch
1125            if self.row_idx >= row_count {
1126                self.batch_idx += 1;
1127                self.row_idx = 0;
1128                continue;
1129            }
1130
1131            // Convert current row
1132            let row_idx = self.row_idx;
1133            self.row_idx += 1;
1134
1135            let mut row = Vec::with_capacity(batch.columns.len());
1136            for col in &batch.columns {
1137                let sql_val = column_value_to_sql_value(col, row_idx);
1138                row.push(sql_val);
1139            }
1140            return Some(row);
1141        }
1142    }
1143}
1144
1145#[cfg(test)]
1146mod tests {
1147    use super::*;
1148    use alopex_core::columnar::encoding::{Column, LogicalType};
1149    use alopex_core::columnar::encoding_v2::{create_encoder, EncodingV2};
1150    use alopex_core::columnar::error::{ColumnarError, Result as ColumnarResult};
1151    use alopex_core::columnar::kvs_bridge::key_layout;
1152    use alopex_core::columnar::segment_v08::{
1153        ChecksummedMetadataV08, StreamingChunkMetaV08, StreamingRowGroupV08,
1154        StreamingSegmentDirectoryV08, StreamingSegmentHeaderV08,
1155        STREAMING_SEGMENT_LAYOUT_VERSION_V08, STREAMING_SEGMENT_MAGIC_V08,
1156    };
1157    use alopex_core::columnar::segment_v2::{ColumnSchema, Schema, SegmentReaderV2, SegmentSource};
1158    use alopex_core::kv::{KVStore, KVTransaction};
1159    use alopex_core::storage::compression::CompressionV2;
1160    use alopex_core::storage::format::bincode_config;
1161    use alopex_core::storage::format::{AlopexFileWriter, FileFlags, FileVersion};
1162    use alopex_core::TxnManager;
1163    use arrow::array::Array;
1164    use bincode::Options;
1165    use crc32fast::Hasher;
1166    use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
1167    use std::sync::Arc;
1168    use tempfile::tempdir;
1169
1170    fn make_batch() -> CoreRecordBatch {
1171        let schema = Schema {
1172            columns: vec![
1173                ColumnSchema {
1174                    name: "id".into(),
1175                    logical_type: LogicalType::Int64,
1176                    nullable: false,
1177                    fixed_len: None,
1178                },
1179                ColumnSchema {
1180                    name: "val".into(),
1181                    logical_type: LogicalType::Int64,
1182                    nullable: false,
1183                    fixed_len: None,
1184                },
1185            ],
1186        };
1187        CoreRecordBatch::new(
1188            schema,
1189            vec![
1190                Column::Int64(vec![1, 2, 3]),
1191                Column::Int64(vec![10, 20, 30]),
1192            ],
1193            vec![None, None],
1194        )
1195    }
1196
1197    fn make_wide_batch(column_count: usize, row_count: usize) -> CoreRecordBatch {
1198        let schema = Schema {
1199            columns: (0..column_count)
1200                .map(|idx| ColumnSchema {
1201                    name: format!("c{idx}"),
1202                    logical_type: LogicalType::Int64,
1203                    nullable: false,
1204                    fixed_len: None,
1205                })
1206                .collect(),
1207        };
1208        let columns = (0..column_count)
1209            .map(|idx| {
1210                Column::Int64(
1211                    (0..row_count)
1212                        .map(|row| (idx as i64 * 1_000_000) + row as i64)
1213                        .collect(),
1214                )
1215            })
1216            .collect();
1217        CoreRecordBatch::new(schema, columns, vec![None; column_count])
1218    }
1219
1220    fn decoded_payload_bytes(batches: &[CoreRecordBatch]) -> usize {
1221        batches
1222            .iter()
1223            .flat_map(|batch| batch.columns.iter())
1224            .map(|column| match column {
1225                Column::Int64(values) => values.len() * std::mem::size_of::<i64>(),
1226                Column::Float32(values) => values.len() * std::mem::size_of::<f32>(),
1227                Column::Float64(values) => values.len() * std::mem::size_of::<f64>(),
1228                Column::Bool(values) => values.len() * std::mem::size_of::<bool>(),
1229                Column::Binary(values) => values.iter().map(Vec::len).sum(),
1230                Column::Fixed { values, .. } => values.iter().map(Vec::len).sum(),
1231            })
1232            .sum()
1233    }
1234
1235    fn checksum(bytes: &[u8]) -> u32 {
1236        let mut hasher = Hasher::new();
1237        hasher.update(bytes);
1238        hasher.finalize()
1239    }
1240
1241    fn metadata_checksum<T: serde::Serialize>(value: &T) -> u32 {
1242        checksum(&bincode_config().serialize(value).unwrap())
1243    }
1244
1245    fn checksummed_metadata<T: serde::Serialize + Clone>(value: T) -> Vec<u8> {
1246        let checksum = metadata_checksum(&value);
1247        bincode_config()
1248            .serialize(&ChecksummedMetadataV08 { value, checksum })
1249            .unwrap()
1250    }
1251
1252    fn provision_v08_int64_fixture(db: &Database, table_id: u32, segment_id: u64) {
1253        let schema = Schema {
1254            columns: vec![ColumnSchema {
1255                name: "value".into(),
1256                logical_type: LogicalType::Int64,
1257                nullable: false,
1258                fixed_len: None,
1259            }],
1260        };
1261        let chunk_bytes = create_encoder(EncodingV2::Plain)
1262            .encode(&Column::Int64(vec![41, 42]), None)
1263            .unwrap();
1264        let chunk = StreamingChunkMetaV08 {
1265            column_index: 0,
1266            encoding: EncodingV2::Plain,
1267            compression: CompressionV2::None,
1268            encoded_bytes: chunk_bytes.len() as u64,
1269            decoded_bytes: chunk_bytes.len() as u64,
1270            checksum: checksum(&chunk_bytes),
1271        };
1272        let row_group = StreamingRowGroupV08 {
1273            row_start: 0,
1274            row_count: 2,
1275            encoded_bytes: chunk.encoded_bytes,
1276            decoded_bytes: chunk.decoded_bytes,
1277            arrow_allocation_upper_bound: 4096,
1278            chunks: vec![chunk],
1279        };
1280        let directory = StreamingSegmentDirectoryV08 {
1281            row_groups: vec![row_group],
1282        };
1283        let header = StreamingSegmentHeaderV08 {
1284            magic: STREAMING_SEGMENT_MAGIC_V08,
1285            format_version: STREAMING_SEGMENT_LAYOUT_VERSION_V08,
1286            row_count: 2,
1287            column_count: 1,
1288            row_group_count: 1,
1289            schema_checksum: metadata_checksum(&schema),
1290            directory_checksum: metadata_checksum(&directory),
1291        };
1292
1293        let manager = db.store.txn_manager();
1294        let mut txn = manager.begin(TxnMode::ReadWrite).unwrap();
1295        txn.put(
1296            key_layout::v08_header_key(table_id, segment_id),
1297            checksummed_metadata(header),
1298        )
1299        .unwrap();
1300        txn.put(
1301            key_layout::v08_schema_key(table_id, segment_id),
1302            checksummed_metadata(schema),
1303        )
1304        .unwrap();
1305        txn.put(
1306            key_layout::v08_directory_key(table_id, segment_id),
1307            checksummed_metadata(directory),
1308        )
1309        .unwrap();
1310        txn.put(
1311            key_layout::v08_chunk_key(table_id, segment_id, 0, 0),
1312            chunk_bytes,
1313        )
1314        .unwrap();
1315        manager.commit(txn).unwrap();
1316    }
1317
1318    #[test]
1319    fn v08_factory_rejects_legacy_v2_before_stream_open() {
1320        let dir = tempfile::tempdir().unwrap();
1321        let db =
1322            Database::open_with_config(EmbeddedConfig::disk(dir.path().join("wal.log"))).unwrap();
1323        let segment_id = db.write_columnar_segment("legacy", make_batch()).unwrap();
1324
1325        assert!(matches!(
1326            db.columnar_segment_streaming_factory_v08("legacy", segment_id),
1327            Err(Error::DataFrame(DataFrameError::StreamingUnsupported { reason, .. }))
1328                if reason == "requires_v08_chunked_layout"
1329        ));
1330    }
1331
1332    #[test]
1333    fn v08_adapter_converts_nullable_core_columns_to_arrow() {
1334        let schema = CoreSchema {
1335            columns: vec![
1336                ColumnSchema {
1337                    name: "id".into(),
1338                    logical_type: LogicalType::Int64,
1339                    nullable: true,
1340                    fixed_len: None,
1341                },
1342                ColumnSchema {
1343                    name: "payload".into(),
1344                    logical_type: LogicalType::Binary,
1345                    nullable: false,
1346                    fixed_len: None,
1347                },
1348            ],
1349        };
1350        let batch = CoreRecordBatch::new(
1351            schema.clone(),
1352            vec![
1353                Column::Int64(vec![10, 20]),
1354                Column::Binary(vec![b"a".to_vec(), b"b".to_vec()]),
1355            ],
1356            vec![Some(Bitmap::from_bools(&[true, false])), None],
1357        );
1358
1359        let arrow_schema = core_schema_to_arrow(&schema).unwrap();
1360        let arrow_batch = core_batch_to_arrow(batch, arrow_schema).unwrap();
1361        let ids = arrow_batch.columns()[0]
1362            .as_any()
1363            .downcast_ref::<Int64Array>()
1364            .unwrap();
1365        let payload = arrow_batch.columns()[1]
1366            .as_any()
1367            .downcast_ref::<BinaryArray>()
1368            .unwrap();
1369        assert_eq!(ids.value(0), 10);
1370        assert!(ids.is_null(1));
1371        assert_eq!(payload.value(1), b"b");
1372    }
1373
1374    #[test]
1375    fn v08_database_adapter_streams_a_provisioned_row_group() {
1376        let dir = tempfile::tempdir().unwrap();
1377        let db =
1378            Database::open_with_config(EmbeddedConfig::disk(dir.path().join("wal.log"))).unwrap();
1379        let table_id = db.resolve_table_id("v08").unwrap();
1380        provision_v08_int64_fixture(&db, table_id, 77);
1381        let options = alopex_dataframe::physical::budget::StreamOptions::new(
1382            64 * 1024,
1383            std::num::NonZeroUsize::new(1).unwrap(),
1384            std::num::NonZeroUsize::new(2).unwrap(),
1385        );
1386
1387        let mut stream = db.stream_columnar_segment_v08("v08", 77, options).unwrap();
1388        let batch = stream.next_batch().unwrap().unwrap();
1389        let values = batch.column("value").unwrap().to_arrow();
1390        let values = values[0].as_any().downcast_ref::<Int64Array>().unwrap();
1391        assert_eq!(values.value(0), 41);
1392        assert_eq!(values.value(1), 42);
1393        assert!(stream.next_batch().unwrap().is_none());
1394
1395        let mut by_id = db
1396            .stream_columnar_segment_v08_by_id(&format!("{table_id}:77"), options)
1397            .unwrap();
1398        let batch = by_id.next_batch().unwrap().unwrap();
1399        let values = batch.column("value").unwrap().to_arrow();
1400        let values = values[0].as_any().downcast_ref::<Int64Array>().unwrap();
1401        assert_eq!(values.value(0), 41);
1402        assert_eq!(values.value(1), 42);
1403        assert!(by_id.next_batch().unwrap().is_none());
1404    }
1405
1406    #[derive(Debug, Clone)]
1407    struct CountingSegmentSource {
1408        data: Arc<Vec<u8>>,
1409        bytes: Arc<AtomicU64>,
1410        calls: Arc<AtomicUsize>,
1411    }
1412
1413    impl CountingSegmentSource {
1414        fn new(data: Vec<u8>) -> Self {
1415            Self {
1416                data: Arc::new(data),
1417                bytes: Arc::new(AtomicU64::new(0)),
1418                calls: Arc::new(AtomicUsize::new(0)),
1419            }
1420        }
1421
1422        fn reset(&self) {
1423            self.bytes.store(0, Ordering::Relaxed);
1424            self.calls.store(0, Ordering::Relaxed);
1425        }
1426
1427        fn bytes(&self) -> u64 {
1428            self.bytes.load(Ordering::Relaxed)
1429        }
1430
1431        fn calls(&self) -> usize {
1432            self.calls.load(Ordering::Relaxed)
1433        }
1434    }
1435
1436    impl SegmentSource for CountingSegmentSource {
1437        fn read_range(&self, offset: u64, len: u64) -> ColumnarResult<Vec<u8>> {
1438            self.bytes.fetch_add(len, Ordering::Relaxed);
1439            self.calls.fetch_add(1, Ordering::Relaxed);
1440            let start = offset as usize;
1441            let end = start + len as usize;
1442            if end > self.data.len() {
1443                return Err(ColumnarError::InvalidFormat("range out of bounds".into()));
1444            }
1445            Ok(self.data[start..end].to_vec())
1446        }
1447
1448        fn total_size(&self) -> u64 {
1449            self.data.len() as u64
1450        }
1451    }
1452
1453    fn measured_segment_read(data: Vec<u8>, columns: &[usize]) -> ColumnarResult<(u64, usize)> {
1454        let source = CountingSegmentSource::new(data);
1455        let reader = SegmentReaderV2::open(Box::new(source.clone()))?;
1456        source.reset();
1457        let batches = reader.read_columns(columns)?;
1458        if batches.is_empty() {
1459            return Err(ColumnarError::InvalidFormat("segment is empty".into()));
1460        }
1461        Ok((source.bytes(), source.calls()))
1462    }
1463
1464    fn reset_last_read_column_indices() {
1465        LAST_READ_COLUMN_INDICES.with(|last| {
1466            *last.borrow_mut() = None;
1467        });
1468    }
1469
1470    fn last_read_column_indices() -> Vec<usize> {
1471        LAST_READ_COLUMN_INDICES.with(|last| {
1472            last.borrow()
1473                .clone()
1474                .expect("read_columnar_segment should record read indices")
1475        })
1476    }
1477
1478    #[test]
1479    fn write_read_disk_mode() {
1480        let dir = tempdir().unwrap();
1481        let wal = dir.path().join("wal.log");
1482        let cfg = EmbeddedConfig::disk(wal);
1483        let db = Database::open_with_config(cfg).unwrap();
1484        let seg_id = db.write_columnar_segment("tbl", make_batch()).unwrap();
1485        let batches = db.read_columnar_segment("tbl", seg_id, None).unwrap();
1486        assert_eq!(batches[0].num_rows(), 3);
1487    }
1488
1489    #[test]
1490    fn read_with_column_names() {
1491        let dir = tempdir().unwrap();
1492        let wal = dir.path().join("wal.log");
1493        let cfg = EmbeddedConfig::disk(wal);
1494        let db = Database::open_with_config(cfg).unwrap();
1495        let seg_id = db.write_columnar_segment("tbl", make_batch()).unwrap();
1496        let batches = db
1497            .read_columnar_segment("tbl", seg_id, Some(&["val"]))
1498            .unwrap();
1499        assert_eq!(batches[0].columns.len(), 1);
1500        if let Column::Int64(vals) = &batches[0].columns[0] {
1501            assert_eq!(vals, &vec![10, 20, 30]);
1502        } else {
1503            panic!("expected int64");
1504        }
1505    }
1506
1507    #[test]
1508    fn disk_projection_pushes_selected_columns_to_segment_reader() {
1509        let dir = tempdir().unwrap();
1510        let wal = dir.path().join("wal.log");
1511        let cfg = EmbeddedConfig::disk(wal);
1512        let db = Database::open_with_config(cfg).unwrap();
1513        let seg_id = db
1514            .write_columnar_segment("wide_tbl", make_wide_batch(12, 4096))
1515            .unwrap();
1516        let table_id = table_id("wide_tbl").unwrap();
1517        let raw = db
1518            .columnar_bridge
1519            .read_segment_raw(table_id, seg_id)
1520            .unwrap();
1521
1522        let all_indices: Vec<usize> = (0..12).collect();
1523        let projection = [2, 7, 10];
1524        let (full_read_bytes, full_read_calls) =
1525            measured_segment_read(raw.data.clone(), &all_indices).unwrap();
1526        let (projected_read_bytes, projected_read_calls) =
1527            measured_segment_read(raw.data, &projection).unwrap();
1528
1529        reset_last_read_column_indices();
1530        let full_batches = db.read_columnar_segment("wide_tbl", seg_id, None).unwrap();
1531        assert_eq!(last_read_column_indices(), all_indices);
1532
1533        reset_last_read_column_indices();
1534        let projected_batches = db
1535            .read_columnar_segment("wide_tbl", seg_id, Some(&["c2", "c7", "c10"]))
1536            .unwrap();
1537        let pushed_indices = last_read_column_indices();
1538        let full_payload_bytes = decoded_payload_bytes(&full_batches);
1539        let projected_payload_bytes = decoded_payload_bytes(&projected_batches);
1540
1541        eprintln!(
1542            "projection pushed_indices={pushed_indices:?} full_read_bytes={full_read_bytes} projected_read_bytes={projected_read_bytes} full_read_calls={full_read_calls} projected_read_calls={projected_read_calls} full_payload_bytes={full_payload_bytes} projected_payload_bytes={projected_payload_bytes}"
1543        );
1544
1545        assert_eq!(pushed_indices, projection);
1546        assert!(projected_read_bytes < full_read_bytes);
1547        assert!(projected_payload_bytes < full_payload_bytes);
1548        assert_eq!(projected_batches[0].schema.columns.len(), projection.len());
1549    }
1550
1551    #[test]
1552    fn in_memory_projection_pushes_selected_columns_to_segment_reader() {
1553        let db = Database::open_with_config(EmbeddedConfig::in_memory()).unwrap();
1554        let seg_id = db
1555            .write_columnar_segment("wide_mem_tbl", make_wide_batch(12, 4096))
1556            .unwrap();
1557
1558        let all_indices: Vec<usize> = (0..12).collect();
1559        let projection = [2, 7, 10];
1560
1561        reset_last_read_column_indices();
1562        let full_batches = db
1563            .read_columnar_segment("wide_mem_tbl", seg_id, None)
1564            .unwrap();
1565        assert_eq!(last_read_column_indices(), all_indices);
1566
1567        reset_last_read_column_indices();
1568        let projected_batches = db
1569            .read_columnar_segment("wide_mem_tbl", seg_id, Some(&["c2", "c7", "c10"]))
1570            .unwrap();
1571        let pushed_indices = last_read_column_indices();
1572        let full_payload_bytes = decoded_payload_bytes(&full_batches);
1573        let projected_payload_bytes = decoded_payload_bytes(&projected_batches);
1574
1575        eprintln!(
1576            "in_memory_projection pushed_indices={pushed_indices:?} full_payload_bytes={full_payload_bytes} projected_payload_bytes={projected_payload_bytes}"
1577        );
1578
1579        assert_eq!(pushed_indices, projection);
1580        assert!(projected_payload_bytes < full_payload_bytes);
1581        assert_eq!(projected_batches[0].schema.columns.len(), projection.len());
1582    }
1583
1584    #[test]
1585    fn in_memory_limit_rejects_large_segment() {
1586        let cfg = EmbeddedConfig::in_memory_with_limit(1);
1587        let db = Database::open_with_config(cfg).unwrap();
1588        let err = db
1589            .write_columnar_segment("tbl", make_batch())
1590            .expect_err("should exceed limit");
1591        assert!(format!("{err}").contains("memory limit exceeded"));
1592    }
1593
1594    #[test]
1595    fn storage_mode_flags() {
1596        let dir = tempdir().unwrap();
1597        let wal = dir.path().join("wal.log");
1598        let disk = Database::open_with_config(EmbeddedConfig::disk(wal)).unwrap();
1599        assert!(matches!(disk.storage_mode(), StorageMode::Disk));
1600
1601        let mem = Database::open_with_config(EmbeddedConfig::in_memory()).unwrap();
1602        assert!(matches!(mem.storage_mode(), StorageMode::InMemory));
1603    }
1604
1605    #[test]
1606    fn transaction_write_and_read() {
1607        let dir = tempdir().unwrap();
1608        let wal = dir.path().join("wal.log");
1609        let db = Database::open_with_config(EmbeddedConfig::disk(wal)).unwrap();
1610        let txn = db.begin(crate::TxnMode::ReadWrite).unwrap();
1611        let seg_id = txn.write_columnar_segment("tbl_txn", make_batch()).unwrap();
1612        txn.commit().unwrap();
1613
1614        let batches = db
1615            .read_columnar_segment("tbl_txn", seg_id, Some(&["id"]))
1616            .unwrap();
1617        assert_eq!(batches[0].num_rows(), 3);
1618    }
1619
1620    #[test]
1621    fn flush_in_memory_paths() {
1622        let dir = tempdir().unwrap();
1623        let db = Database::open_with_config(EmbeddedConfig::in_memory()).unwrap();
1624        let seg_id = db.write_columnar_segment("mem_tbl", make_batch()).unwrap();
1625
1626        // flush to file
1627        let file_path = dir.path().join("seg.bin");
1628        db.flush_in_memory_segment_to_file("mem_tbl", seg_id, &file_path)
1629            .unwrap();
1630        let bytes = std::fs::read(&file_path).unwrap();
1631        assert!(!bytes.is_empty());
1632
1633        // flush to kvs
1634        let kv_id = db
1635            .flush_in_memory_segment_to_kvs("mem_tbl", seg_id)
1636            .unwrap();
1637        assert_eq!(kv_id, 0);
1638
1639        // flush to .alopex
1640        let alo_path = dir.path().join("out.alopex");
1641        let mut writer =
1642            AlopexFileWriter::new(alo_path.clone(), FileVersion::CURRENT, FileFlags(0)).unwrap();
1643        db.flush_in_memory_segment_to_alopex("mem_tbl", seg_id, &mut writer)
1644            .unwrap();
1645        writer.finalize().unwrap();
1646        assert!(alo_path.exists());
1647    }
1648
1649    #[test]
1650    fn flush_not_in_memory_mode_errors() {
1651        let dir = tempdir().unwrap();
1652        let wal = dir.path().join("wal.log");
1653        let db = Database::open_with_config(EmbeddedConfig::disk(wal)).unwrap();
1654        let err = db
1655            .flush_in_memory_segment_to_kvs("tbl", 0)
1656            .expect_err("should error");
1657        assert!(matches!(err, Error::NotInMemoryMode));
1658    }
1659}