Skip to main content

argenus/
fdb.rs

1//! FDB (Flanking Database) Format Module
2//!
3//! Converts TSV flanking sequences to compressed binary FDB format.
4//! Uses external sorting with LZ4 compression for temporary files
5//! and zstd compression for final gene blocks.
6//!
7//! # File Format
8//! ```text
9//! [Header]
10//!   - Magic: "FLANKDB\0" (8 bytes)
11//!   - Version: u32 (4 bytes)
12//!   - Gene count: u32 (4 bytes)
13//!   - Index offset: u64 (8 bytes)
14//! [Gene Blocks]
15//!   - Zstd-compressed TSV data per gene
16//! [Index]
17//!   - Gene name → (offset, compressed_len, record_count)
18//! ```
19//!
20//! # Pipeline
21//! 1. Read TSV line by line → FlankingRecord
22//! 2. External sort by gene name (parallel, LZ4-compressed temp files)
23//! 3. Stream sorted records → group by gene → zstd compress → write FDB
24//!
25//! # Performance
26//! - Memory-efficient: External sort handles files larger than RAM
27//! - Compression: ~10x reduction in file size
28
29use anyhow::{Context, Result};
30use extsort_iter::*;
31use std::cmp::Ordering;
32use std::fs::File;
33use std::io::{BufRead, BufReader, BufWriter, Seek, SeekFrom, Write};
34use std::path::Path;
35
36const MAGIC: &[u8; 8] = b"FLANKDB\0";
37const VERSION: u32 = 2; // Version 2: new TSV column format
38
39/// Flanking record for sorting
40/// Keep this compact - extsort-iter buffers these in memory
41#[derive(Clone)]
42pub struct FlankingRecord {
43    /// Gene name (sort key)
44    pub gene: String,
45    /// Full TSV line (excluding header)
46    pub line: String,
47}
48
49impl PartialEq for FlankingRecord {
50    fn eq(&self, other: &Self) -> bool {
51        self.gene == other.gene
52    }
53}
54
55impl Eq for FlankingRecord {}
56
57impl PartialOrd for FlankingRecord {
58    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
59        Some(self.cmp(other))
60    }
61}
62
63impl Ord for FlankingRecord {
64    fn cmp(&self, other: &Self) -> Ordering {
65        self.gene.cmp(&other.gene)
66    }
67}
68
69/// Builds an FDB file from TSV input.
70///
71/// Performs external sorting and zstd compression to create
72/// an indexed, compressed flanking database.
73///
74/// # Arguments
75/// * `tsv_path` - Input TSV file (Gene\tContig\tGenus\tStart\tEnd\tUpstream\tDownstream)
76/// * `fdb_path` - Output FDB file path
77/// * `buffer_size_mb` - Memory buffer size in MB for external sort (default: 1024)
78/// * `threads` - Number of threads for parallel sorting
79///
80/// # Example
81/// ```no_run
82/// use argenus::fdb;
83/// use std::path::Path;
84///
85/// fdb::build(
86///     Path::new("flanking.tsv"),
87///     Path::new("flanking.fdb"),
88///     1024,  // 1GB buffer
89///     8,     // 8 threads
90/// ).unwrap();
91/// ```
92pub fn build(
93    tsv_path: &Path,
94    fdb_path: &Path,
95    buffer_size_mb: usize,
96    threads: usize,
97) -> Result<()> {
98    eprintln!("Building FDB from TSV...");
99    eprintln!("  Input: {}", tsv_path.display());
100    eprintln!("  Output: {}", fdb_path.display());
101    eprintln!("  Buffer: {} MB, Threads: {}", buffer_size_mb, threads);
102
103    // Set rayon thread pool
104    rayon::ThreadPoolBuilder::new()
105        .num_threads(threads)
106        .build_global()
107        .ok(); // Ignore if already set
108
109    // Create temp directory for sort runs
110    let temp_dir = tempfile::Builder::new()
111        .prefix("fdb_sort_")
112        .tempdir()
113        .context("Failed to create temp directory")?;
114
115    eprintln!("  Temp dir: {}", temp_dir.path().display());
116
117    // Phase 1: Read TSV and create record iterator
118    eprintln!("\n[Phase 1] Reading TSV and external sorting...");
119
120    let file = File::open(tsv_path).context("Failed to open TSV file")?;
121    let file_size = file.metadata()?.len();
122    let reader = BufReader::with_capacity(8 * 1024 * 1024, file);
123    let mut lines = reader.lines();
124
125    // Read and save header
126    let header = lines
127        .next()
128        .ok_or_else(|| anyhow::anyhow!("Empty TSV file"))??;
129
130    // Create record iterator
131    let record_iter = lines.filter_map(|line_result| {
132        let line = line_result.ok()?;
133        if line.is_empty() {
134            return None;
135        }
136        // Extract gene name (first column)
137        let gene = line.split('\t').next()?.to_string();
138        if gene.is_empty() {
139            return None;
140        }
141        Some(FlankingRecord { gene, line })
142    });
143
144    // Configure external sort
145    // Buffer size in bytes, with LZ4 compression for temp files
146    let buffer_bytes = buffer_size_mb * 1024 * 1024;
147    let config = ExtsortConfig::with_buffer_size(buffer_bytes)
148        .compress_lz4_flex();
149
150    // Perform parallel external sort
151    let sorted_iter = record_iter
152        .par_external_sort(config)
153        .context("External sort failed")?;
154
155    // Phase 2: Build FDB from sorted records
156    eprintln!("[Phase 2] Building FDB from sorted records...");
157
158    let mut output = BufWriter::with_capacity(4 * 1024 * 1024, File::create(fdb_path)?);
159
160    // Write header placeholder
161    output.write_all(MAGIC)?;
162    output.write_all(&VERSION.to_le_bytes())?;
163    output.write_all(&0u32.to_le_bytes())?; // gene_count placeholder
164    output.write_all(&0u64.to_le_bytes())?; // index_offset placeholder
165
166    // Index entries: (gene, offset, compressed_len, record_count)
167    let mut index_entries: Vec<(String, u64, u32, u32)> = Vec::new();
168    let mut compressor = zstd::bulk::Compressor::new(3)?;
169
170    let mut current_gene: Option<String> = None;
171    let mut current_records: Vec<String> = Vec::new();
172    let mut total_records = 0u64;
173    let mut gene_count = 0u32;
174
175    for record in sorted_iter {
176        total_records += 1;
177
178        if current_gene.as_ref() != Some(&record.gene) {
179            // Write previous gene block if exists
180            if let Some(prev_gene) = current_gene.take() {
181                write_gene_block(
182                    &mut output,
183                    &mut compressor,
184                    &header,
185                    &prev_gene,
186                    &current_records,
187                    &mut index_entries,
188                )?;
189                gene_count += 1;
190
191                if gene_count.is_multiple_of(1000) {
192                    eprintln!(
193                        "  Processed {} genes, {} records...",
194                        gene_count, total_records
195                    );
196                }
197            }
198
199            current_gene = Some(record.gene);
200            current_records.clear();
201        }
202
203        current_records.push(record.line);
204    }
205
206    // Write last gene block
207    if let Some(gene) = current_gene {
208        write_gene_block(
209            &mut output,
210            &mut compressor,
211            &header,
212            &gene,
213            &current_records,
214            &mut index_entries,
215        )?;
216        gene_count += 1;
217    }
218
219    eprintln!("  Total: {} genes, {} records", gene_count, total_records);
220
221    // Phase 3: Write index
222    eprintln!("[Phase 3] Writing index...");
223
224    let index_offset = output.stream_position()?;
225
226    for (gene, offset, comp_len, record_count) in &index_entries {
227        let gene_bytes = gene.as_bytes();
228        output.write_all(&(gene_bytes.len() as u16).to_le_bytes())?;
229        output.write_all(gene_bytes)?;
230        output.write_all(&offset.to_le_bytes())?;
231        output.write_all(&comp_len.to_le_bytes())?;
232        output.write_all(&record_count.to_le_bytes())?;
233    }
234
235    // Update header with actual values
236    output.seek(SeekFrom::Start(12))?;
237    output.write_all(&gene_count.to_le_bytes())?;
238    output.write_all(&index_offset.to_le_bytes())?;
239    output.flush()?;
240
241    // Cleanup temp directory (automatically done by tempfile)
242    drop(temp_dir);
243
244    // Summary
245    let input_size = file_size;
246    let output_size = std::fs::metadata(fdb_path)?.len();
247    let ratio = input_size as f64 / output_size as f64;
248
249    eprintln!("\n=== FDB Build Complete ===");
250    eprintln!("  Input:  {:.2} MB", input_size as f64 / 1024.0 / 1024.0);
251    eprintln!("  Output: {:.2} MB", output_size as f64 / 1024.0 / 1024.0);
252    eprintln!("  Compression ratio: {:.1}x", ratio);
253    eprintln!("  Genes: {}", gene_count);
254    eprintln!("  Records: {}", total_records);
255
256    Ok(())
257}
258
259/// Build FDB from a pre-sorted TSV file (streaming, memory-efficient).
260///
261/// This function reads the TSV line by line without loading everything into memory.
262/// The input TSV MUST be sorted by gene name (first column).
263///
264/// # Arguments
265/// * `tsv_path` - Input TSV file (MUST be pre-sorted by gene name)
266/// * `fdb_path` - Output FDB file path
267pub fn build_from_sorted(
268    tsv_path: &Path,
269    fdb_path: &Path,
270) -> Result<()> {
271    eprintln!("Building FDB from pre-sorted TSV (streaming mode)...");
272    eprintln!("  Input: {}", tsv_path.display());
273    eprintln!("  Output: {}", fdb_path.display());
274
275    let file = File::open(tsv_path).context("Failed to open TSV file")?;
276    let file_size = file.metadata()?.len();
277    let reader = BufReader::with_capacity(8 * 1024 * 1024, file);
278    let mut lines = reader.lines();
279
280    // Read header
281    let header = lines
282        .next()
283        .ok_or_else(|| anyhow::anyhow!("Empty TSV file"))??;
284
285    // Create output file
286    let mut output = BufWriter::with_capacity(4 * 1024 * 1024, File::create(fdb_path)?);
287
288    // Write header placeholder
289    output.write_all(MAGIC)?;
290    output.write_all(&VERSION.to_le_bytes())?;
291    output.write_all(&0u32.to_le_bytes())?; // gene_count placeholder
292    output.write_all(&0u64.to_le_bytes())?; // index_offset placeholder
293
294    let mut index_entries: Vec<(String, u64, u32, u32)> = Vec::new();
295    let mut compressor = zstd::bulk::Compressor::new(3)?;
296
297    let mut current_gene: Option<String> = None;
298    let mut current_records: Vec<String> = Vec::new();
299    let mut total_records = 0u64;
300    let mut gene_count = 0u32;
301
302    eprintln!("\n[Streaming] Processing sorted TSV...");
303
304    for line_result in lines {
305        let line = match line_result {
306            Ok(l) => l,
307            Err(_) => continue,
308        };
309        if line.is_empty() {
310            continue;
311        }
312
313        // Extract gene name (first column)
314        let gene = match line.split('\t').next() {
315            Some(g) if !g.is_empty() => g.to_string(),
316            _ => continue,
317        };
318
319        total_records += 1;
320
321        // Check if we've moved to a new gene
322        if current_gene.as_ref() != Some(&gene) {
323            // Write previous gene block
324            if let Some(prev_gene) = current_gene.take() {
325                write_gene_block(
326                    &mut output,
327                    &mut compressor,
328                    &header,
329                    &prev_gene,
330                    &current_records,
331                    &mut index_entries,
332                )?;
333                gene_count += 1;
334
335                if gene_count % 1000 == 0 {
336                    eprintln!(
337                        "  Processed {} genes, {} records...",
338                        gene_count, total_records
339                    );
340                }
341            }
342
343            current_gene = Some(gene);
344            current_records.clear();
345        }
346
347        current_records.push(line);
348    }
349
350    // Write last gene block
351    if let Some(gene) = current_gene {
352        write_gene_block(
353            &mut output,
354            &mut compressor,
355            &header,
356            &gene,
357            &current_records,
358            &mut index_entries,
359        )?;
360        gene_count += 1;
361    }
362
363    eprintln!("  Total: {} genes, {} records", gene_count, total_records);
364
365    // Write index
366    eprintln!("[Writing index]");
367    let index_offset = output.stream_position()?;
368
369    for (gene, offset, comp_len, record_count) in &index_entries {
370        let gene_bytes = gene.as_bytes();
371        output.write_all(&(gene_bytes.len() as u16).to_le_bytes())?;
372        output.write_all(gene_bytes)?;
373        output.write_all(&offset.to_le_bytes())?;
374        output.write_all(&comp_len.to_le_bytes())?;
375        output.write_all(&record_count.to_le_bytes())?;
376    }
377
378    // Update header with actual values
379    output.seek(SeekFrom::Start(12))?;
380    output.write_all(&gene_count.to_le_bytes())?;
381    output.write_all(&index_offset.to_le_bytes())?;
382    output.flush()?;
383
384    // Summary
385    let output_size = std::fs::metadata(fdb_path)?.len();
386    let ratio = file_size as f64 / output_size as f64;
387
388    eprintln!("\n=== FDB Build Complete (Streaming) ===");
389    eprintln!("  Input:  {:.2} MB", file_size as f64 / 1024.0 / 1024.0);
390    eprintln!("  Output: {:.2} MB", output_size as f64 / 1024.0 / 1024.0);
391    eprintln!("  Compression ratio: {:.1}x", ratio);
392    eprintln!("  Genes: {}", gene_count);
393    eprintln!("  Records: {}", total_records);
394
395    Ok(())
396}
397
398/// Write a compressed gene block to FDB
399fn write_gene_block(
400    output: &mut BufWriter<File>,
401    compressor: &mut zstd::bulk::Compressor<'_>,
402    header: &str,
403    gene: &str,
404    records: &[String],
405    index_entries: &mut Vec<(String, u64, u32, u32)>,
406) -> Result<()> {
407    // Build block content: header + records
408    let mut content = String::with_capacity(records.len() * 3000);
409    content.push_str(header);
410    content.push('\n');
411    for record in records {
412        content.push_str(record);
413        content.push('\n');
414    }
415
416    // Compress with zstd
417    let compressed = compressor.compress(content.as_bytes())?;
418    let offset = output.stream_position()?;
419    output.write_all(&compressed)?;
420
421    // Add to index
422    index_entries.push((
423        gene.to_string(),
424        offset,
425        compressed.len() as u32,
426        records.len() as u32,
427    ));
428
429    Ok(())
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    #[test]
437    fn test_flanking_record_ordering() {
438        let r1 = FlankingRecord {
439            gene: "aac(6')".to_string(),
440            line: "test1".to_string(),
441        };
442        let r2 = FlankingRecord {
443            gene: "blaTEM".to_string(),
444            line: "test2".to_string(),
445        };
446        assert!(r1 < r2);
447    }
448}