mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
feat: replace fjall with bichon-blob for blob storage
- use a single Engine instance for email + attachment blobs - add delete_batch, gc_if_needed, background flush to blob crate - fix Entry.raw_size storing compressed length instead of original - move fjall-dependent migration code from core to admin crate - add STORAGE_VERSION file for layout version detection
This commit is contained in:
Generated
+14
-7
@@ -303,16 +303,23 @@ version = "1.6.2"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bichon-core",
|
"bichon-core",
|
||||||
"bichon-memdb",
|
"bichon-memdb",
|
||||||
|
"bytes 1.12.0",
|
||||||
|
"chrono",
|
||||||
"console",
|
"console",
|
||||||
"dialoguer",
|
"dialoguer",
|
||||||
|
"fjall",
|
||||||
"indicatif",
|
"indicatif",
|
||||||
"itertools 0.15.0",
|
"itertools 0.15.0",
|
||||||
|
"mail-parser",
|
||||||
"native_db",
|
"native_db",
|
||||||
"native_model",
|
"native_model",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"snafu",
|
"snafu",
|
||||||
|
"tantivy",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -361,6 +368,7 @@ version = "1.6.2"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"async-imap",
|
"async-imap",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"bichon-blob",
|
||||||
"bichon-memdb",
|
"bichon-memdb",
|
||||||
"blake3",
|
"blake3",
|
||||||
"bytes 1.12.0",
|
"bytes 1.12.0",
|
||||||
@@ -373,7 +381,6 @@ dependencies = [
|
|||||||
"deunicode",
|
"deunicode",
|
||||||
"email_address",
|
"email_address",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"fjall",
|
|
||||||
"futures",
|
"futures",
|
||||||
"governor",
|
"governor",
|
||||||
"hex",
|
"hex",
|
||||||
@@ -3581,9 +3588,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick_cache"
|
name = "quick_cache"
|
||||||
version = "0.6.21"
|
version = "0.6.24"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5a70b1b8b47e31d0498ecbc3c5470bb931399a8bfed1fd79d1717a61ce7f96e3"
|
checksum = "b9c6658afe513a3b484e3abfdaa0d03ef3c0bbf017542c178dd55f94eb3051f9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"equivalent",
|
"equivalent",
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.16.1",
|
||||||
@@ -5370,9 +5377,9 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "varint-rs"
|
name = "varint-rs"
|
||||||
version = "2.2.0"
|
version = "2.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f54a172d0620933a27a4360d3db3e2ae0dd6cceae9730751a036bbf182c4b23"
|
checksum = "bfa6c38708f6257f1ec2ca7e5a11f9bbf58a27d7060078b6b333624968183d96"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vcpkg"
|
name = "vcpkg"
|
||||||
@@ -6054,9 +6061,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xxhash-rust"
|
name = "xxhash-rust"
|
||||||
version = "0.8.15"
|
version = "0.8.16"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
|
checksum = "4d93c89cdc2d3a63c3ec48ffe926931bdc069eafa8e4402fe6d8f790c9d1e576"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yasna"
|
name = "yasna"
|
||||||
|
|||||||
@@ -18,3 +18,10 @@ serde_json.workspace = true
|
|||||||
itertools.workspace = true
|
itertools.workspace = true
|
||||||
snafu.workspace = true
|
snafu.workspace = true
|
||||||
bichon-memdb.workspace = true
|
bichon-memdb.workspace = true
|
||||||
|
fjall.workspace = true
|
||||||
|
mail-parser.workspace = true
|
||||||
|
bytes.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
tantivy = { version = "0.26.1", features = ["zstd-compression", "quickwit"] }
|
||||||
|
chrono.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use tantivy::schema::{FacetOptions, Field, Schema, FAST, INDEXED, STORED, STRING, TEXT};
|
use tantivy::schema::{FacetOptions, Field, Schema, FAST, INDEXED, STORED, STRING, TEXT};
|
||||||
|
|
||||||
use crate::migrate::legacy::fields::{EmlFields, EnvelopeFields, *};
|
use crate::legacy::fields::{EmlFields, EnvelopeFields, *};
|
||||||
|
|
||||||
pub struct SchemaTools;
|
pub struct SchemaTools;
|
||||||
|
|
||||||
@@ -21,8 +21,10 @@ use dialoguer::{theme::ColorfulTheme, Select};
|
|||||||
|
|
||||||
use crate::{migrate::handle_migration, reset::handle_reset_password};
|
use crate::{migrate::handle_migration, reset::handle_reset_password};
|
||||||
|
|
||||||
|
pub mod legacy;
|
||||||
pub mod meta;
|
pub mod meta;
|
||||||
pub mod migrate;
|
pub mod migrate;
|
||||||
|
pub mod migrate_store;
|
||||||
pub mod reset;
|
pub mod reset;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+228
-10
@@ -1,12 +1,23 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::{collections::HashMap, path::PathBuf};
|
||||||
|
|
||||||
use bichon_core::migrate::{
|
use bichon_core::{
|
||||||
count_eml_segments, do_migrate_segment, is_tantivy_index_dir,
|
error::{code::ErrorCode, BichonResult},
|
||||||
store::{LegacyDirs, NewDirs, NewIndexWriter},
|
migrate::is_tantivy_index_dir,
|
||||||
|
raise_error,
|
||||||
};
|
};
|
||||||
use console::style;
|
use console::style;
|
||||||
use dialoguer::{theme::ColorfulTheme, Confirm, Input};
|
use dialoguer::{theme::ColorfulTheme, Confirm, Input};
|
||||||
use indicatif::{ProgressBar, ProgressStyle};
|
use indicatif::{ProgressBar, ProgressStyle};
|
||||||
|
use tantivy::{
|
||||||
|
collector::TopDocs,
|
||||||
|
columnar::Column,
|
||||||
|
query::TermQuery,
|
||||||
|
schema::{IndexRecordOption, Value},
|
||||||
|
DocAddress, Index, TantivyDocument, Term,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::legacy::schema::SchemaTools;
|
||||||
|
use crate::migrate_store::{LegacyDirs, NewDirs, NewIndexWriter};
|
||||||
|
|
||||||
pub fn handle_migration(theme: &ColorfulTheme) {
|
pub fn handle_migration(theme: &ColorfulTheme) {
|
||||||
println!(
|
println!(
|
||||||
@@ -21,7 +32,7 @@ pub fn handle_migration(theme: &ColorfulTheme) {
|
|||||||
style(
|
style(
|
||||||
"This tool migrates data from the legacy v0.3.7 Tantivy-based storage \
|
"This tool migrates data from the legacy v0.3.7 Tantivy-based storage \
|
||||||
architecture to the new v1.x \
|
architecture to the new v1.x \
|
||||||
separated index and Fjall-backed storage format."
|
separated index and blob-backed storage format."
|
||||||
)
|
)
|
||||||
.dim()
|
.dim()
|
||||||
);
|
);
|
||||||
@@ -35,8 +46,8 @@ pub fn handle_migration(theme: &ColorfulTheme) {
|
|||||||
New v1.x architecture:\n\
|
New v1.x architecture:\n\
|
||||||
• mail indexes stored in Tantivy\n\
|
• mail indexes stored in Tantivy\n\
|
||||||
• attachment indexes stored in Tantivy\n\
|
• attachment indexes stored in Tantivy\n\
|
||||||
• raw message data stored in Fjall\n\
|
• raw message data stored in blob engine\n\
|
||||||
• attachment blobs stored in Fjall"
|
• attachment blobs stored in blob engine"
|
||||||
)
|
)
|
||||||
.dim()
|
.dim()
|
||||||
);
|
);
|
||||||
@@ -54,7 +65,7 @@ pub fn handle_migration(theme: &ColorfulTheme) {
|
|||||||
let root_dir_str: String = Input::with_theme(theme)
|
let root_dir_str: String = Input::with_theme(theme)
|
||||||
.with_prompt("Enter --bichon-root-dir (same value used by the old server)")
|
.with_prompt("Enter --bichon-root-dir (same value used by the old server)")
|
||||||
.validate_with(|input: &String| -> Result<(), &str> {
|
.validate_with(|input: &String| -> Result<(), &str> {
|
||||||
let path = Path::new(input);
|
let path = PathBuf::from(input);
|
||||||
if !path.is_absolute() {
|
if !path.is_absolute() {
|
||||||
return Err("Path must be absolute.");
|
return Err("Path must be absolute.");
|
||||||
}
|
}
|
||||||
@@ -81,7 +92,7 @@ pub fn handle_migration(theme: &ColorfulTheme) {
|
|||||||
if input.is_empty() {
|
if input.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let path = Path::new(input);
|
let path = PathBuf::from(input);
|
||||||
if !path.is_absolute() {
|
if !path.is_absolute() {
|
||||||
return Err("Path must be absolute.");
|
return Err("Path must be absolute.");
|
||||||
}
|
}
|
||||||
@@ -119,7 +130,7 @@ pub fn handle_migration(theme: &ColorfulTheme) {
|
|||||||
if input.is_empty() {
|
if input.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let path = Path::new(input);
|
let path = PathBuf::from(input);
|
||||||
if !path.is_absolute() {
|
if !path.is_absolute() {
|
||||||
return Err("Path must be absolute.");
|
return Err("Path must be absolute.");
|
||||||
}
|
}
|
||||||
@@ -447,3 +458,210 @@ pub fn is_legacy_data_layout_with_paths(
|
|||||||
|
|
||||||
Ok(envelope_result || eml_result)
|
Ok(envelope_result || eml_result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return the number of segments in the legacy EML Tantivy index.
|
||||||
|
/// Each segment can be passed to `do_migrate_segment` for bounded-memory batch migration.
|
||||||
|
pub fn count_eml_segments(legacy: &LegacyDirs) -> BichonResult<usize> {
|
||||||
|
let eml_index = Index::open_in_dir(&legacy.eml_dir)
|
||||||
|
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
|
||||||
|
let reader = eml_index
|
||||||
|
.reader()
|
||||||
|
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
|
||||||
|
let searcher = reader.searcher();
|
||||||
|
Ok(searcher.segment_readers().len())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Migrate all documents from a single EML segment to the new storage layout.
|
||||||
|
///
|
||||||
|
/// This is the core of the batch migration strategy: each Process B invocation
|
||||||
|
/// handles exactly one EML segment, so peak memory is bounded by that segment's
|
||||||
|
/// size regardless of the total archive size.
|
||||||
|
pub fn do_migrate_segment<F>(
|
||||||
|
batch_size: u32,
|
||||||
|
legacy: LegacyDirs,
|
||||||
|
writer: &mut NewIndexWriter,
|
||||||
|
segment_index: usize,
|
||||||
|
mut on_progress: F,
|
||||||
|
) -> BichonResult<()>
|
||||||
|
where
|
||||||
|
F: FnMut(&str),
|
||||||
|
{
|
||||||
|
// ── open legacy indices ────────────────────────────────────────────
|
||||||
|
let envelope_index = Index::open_in_dir(&legacy.envelope_dir)
|
||||||
|
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
|
||||||
|
let eml_index = Index::open_in_dir(&legacy.eml_dir)
|
||||||
|
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
|
||||||
|
|
||||||
|
let envelope_reader = envelope_index
|
||||||
|
.reader()
|
||||||
|
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
|
||||||
|
let eml_reader = eml_index
|
||||||
|
.reader()
|
||||||
|
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
|
||||||
|
|
||||||
|
let envelope_searcher = envelope_reader.searcher();
|
||||||
|
let eml_searcher = eml_reader.searcher();
|
||||||
|
|
||||||
|
let ef = SchemaTools::envelope_fields();
|
||||||
|
let mf = SchemaTools::eml_fields();
|
||||||
|
|
||||||
|
let eml_segments = eml_searcher.segment_readers();
|
||||||
|
let eml_segment = eml_segments.get(segment_index).ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
format!(
|
||||||
|
"segment index {} out of range ({} segments)",
|
||||||
|
segment_index,
|
||||||
|
eml_segments.len()
|
||||||
|
),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let num_docs = eml_segment.num_docs();
|
||||||
|
if num_docs == 0 {
|
||||||
|
on_progress("TOTAL:0");
|
||||||
|
on_progress("DONE:0:0");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
on_progress(&format!("TOTAL:{}", num_docs));
|
||||||
|
|
||||||
|
let max_doc = eml_segment.max_doc();
|
||||||
|
let ff = eml_segment.fast_fields();
|
||||||
|
let f_id_col: Column<u64> = ff.u64("id").map_err(|e| {
|
||||||
|
raise_error!(
|
||||||
|
format!("failed to open f_id fast field: {e:#?}"),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// ── Phase 1: build eid → (uid, internal_date) from envelope, then drop it ──
|
||||||
|
let mut envelope_map: HashMap<u64, (u32, i64)> = HashMap::with_capacity(num_docs as usize);
|
||||||
|
|
||||||
|
let mut env_scanned = 0u32;
|
||||||
|
let mut env_skipped = 0u32;
|
||||||
|
for doc_id in 0..max_doc {
|
||||||
|
if eml_segment.is_deleted(doc_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let eid = f_id_col.values.get_val(doc_id);
|
||||||
|
|
||||||
|
let term = Term::from_field_u64(ef.f_id, eid);
|
||||||
|
let query = TermQuery::new(term, IndexRecordOption::Basic);
|
||||||
|
let hits: Vec<(_, DocAddress)> = envelope_searcher
|
||||||
|
.search(&query, &TopDocs::with_limit(1).order_by_score())
|
||||||
|
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
|
||||||
|
|
||||||
|
if let Some((_, addr)) = hits.first() {
|
||||||
|
let env_doc: TantivyDocument = envelope_searcher
|
||||||
|
.doc(*addr)
|
||||||
|
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
|
||||||
|
let uid = env_doc
|
||||||
|
.get_first(ef.f_uid)
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0) as u32;
|
||||||
|
let internal_date = env_doc
|
||||||
|
.get_first(ef.f_internal_date)
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.unwrap_or(0);
|
||||||
|
envelope_map.insert(eid, (uid, internal_date));
|
||||||
|
env_scanned += 1;
|
||||||
|
} else {
|
||||||
|
env_skipped += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if env_scanned % 10 == 0 {
|
||||||
|
on_progress(&format!(
|
||||||
|
"PHASE1:{}/{} skipped:{}",
|
||||||
|
env_scanned, max_doc, env_skipped
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free the envelope index before the heavy EML processing.
|
||||||
|
drop(envelope_searcher);
|
||||||
|
drop(envelope_reader);
|
||||||
|
drop(envelope_index);
|
||||||
|
|
||||||
|
// ── Phase 2: process EML docs, streaming one at a time ─────────────
|
||||||
|
let mut total_migrated = 0usize;
|
||||||
|
let mut total_skipped = 0usize;
|
||||||
|
|
||||||
|
let mut chunk_start = 0u32;
|
||||||
|
|
||||||
|
while chunk_start < max_doc {
|
||||||
|
let chunk_end = (chunk_start + batch_size).min(max_doc);
|
||||||
|
let store_reader = eml_segment
|
||||||
|
.get_store_reader(2)
|
||||||
|
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
|
||||||
|
|
||||||
|
for doc_id in chunk_start..chunk_end {
|
||||||
|
if eml_segment.is_deleted(doc_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let eid = f_id_col.values.get_val(doc_id);
|
||||||
|
|
||||||
|
let (uid, internal_date) = match envelope_map.get(&eid) {
|
||||||
|
Some(v) => *v,
|
||||||
|
None => {
|
||||||
|
on_progress(&format!("WARN: eid {} envelope not found", eid));
|
||||||
|
total_skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let eml_doc: TantivyDocument = store_reader
|
||||||
|
.get(doc_id)
|
||||||
|
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
|
||||||
|
|
||||||
|
let account_id = match eml_doc.get_first(mf.f_account_id).and_then(|v| v.as_u64()) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
on_progress(&format!("WARN: eid {} account_id missing", eid));
|
||||||
|
total_skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mailbox_id = eml_doc
|
||||||
|
.get_first(mf.f_mailbox_id)
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
// Borrow directly from eml_doc — no .to_vec() clone.
|
||||||
|
let eml_bytes = match eml_doc.get_first(mf.f_eml).and_then(|v| v.as_bytes()) {
|
||||||
|
Some(b) => b,
|
||||||
|
None => {
|
||||||
|
on_progress(&format!("WARN: eid {} eml bytes missing", eid));
|
||||||
|
total_skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = writer.ingest(eml_bytes, account_id, mailbox_id, uid, internal_date) {
|
||||||
|
on_progress(&format!(
|
||||||
|
"ERROR: Account {} eid {} ingest failed: {}",
|
||||||
|
account_id, eid, e
|
||||||
|
));
|
||||||
|
total_skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
total_migrated += 1;
|
||||||
|
|
||||||
|
if total_migrated % 10 == 0 || total_migrated as u32 == num_docs {
|
||||||
|
on_progress(&format!("PROGRESS:{}:{}", total_migrated, num_docs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(store_reader);
|
||||||
|
|
||||||
|
// Flush blob buffers via ingestion API — bypasses memtable/WAL.
|
||||||
|
writer.flush_fjall_buffers()?;
|
||||||
|
|
||||||
|
chunk_start = chunk_end;
|
||||||
|
}
|
||||||
|
|
||||||
|
on_progress(&format!("DONE:{}:{}", total_migrated, total_skipped));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::{path::PathBuf, time::Instant};
|
|||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use mail_parser::MimeHeaders;
|
use mail_parser::MimeHeaders;
|
||||||
|
|
||||||
use crate::{
|
use bichon_core::{
|
||||||
envelope::extractor::extract_references, message::content::AttachmentInfo,
|
envelope::extractor::extract_references, message::content::AttachmentInfo,
|
||||||
store::tantivy::tokenizers::EuroTokenizer, utils::compute_content_hash,
|
store::tantivy::tokenizers::EuroTokenizer, utils::compute_content_hash,
|
||||||
};
|
};
|
||||||
@@ -16,7 +16,7 @@ use mail_parser::MessageParser;
|
|||||||
use tantivy::{indexer::NoMergePolicy, Index, IndexWriter, TantivyDocument};
|
use tantivy::{indexer::NoMergePolicy, Index, IndexWriter, TantivyDocument};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use bichon_core::{
|
||||||
common::AddrVec,
|
common::AddrVec,
|
||||||
envelope::extractor::{compute_thread_id, generate_message_id},
|
envelope::extractor::{compute_thread_id, generate_message_id},
|
||||||
error::{code::ErrorCode, BichonResult},
|
error::{code::ErrorCode, BichonResult},
|
||||||
@@ -284,7 +284,7 @@ impl NewIndexWriter {
|
|||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
message
|
message
|
||||||
.body_html(0)
|
.body_html(0)
|
||||||
.map(|html| crate::utils::html::extract_text(html.into_owned()))
|
.map(|html| bichon_core::utils::html::extract_text(html.into_owned()))
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
@@ -188,8 +188,9 @@ impl Engine {
|
|||||||
self.shared.config.compression_level,
|
self.shared.config.compression_level,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let original_len = value.len() as u32;
|
||||||
let (segment_id, offset, data_size) =
|
let (segment_id, offset, data_size) =
|
||||||
inner.append_entry(key, &data, 0, actual_codec)?;
|
inner.append_entry(key, &data, original_len, 0, actual_codec)?;
|
||||||
|
|
||||||
let record = IndexRecord::new(key, segment_id, offset, data_size, 0);
|
let record = IndexRecord::new(key, segment_id, offset, data_size, 0);
|
||||||
self.shared.bucket_store.insert(record)?;
|
self.shared.bucket_store.insert(record)?;
|
||||||
@@ -226,7 +227,7 @@ impl Engine {
|
|||||||
let mut inner = self.shared.inner.write().unwrap();
|
let mut inner = self.shared.inner.write().unwrap();
|
||||||
|
|
||||||
let (segment_id, offset, data_size) =
|
let (segment_id, offset, data_size) =
|
||||||
inner.append_entry(*key, &[], 1, Codec::None)?;
|
inner.append_entry(*key, &[], 0, 1, Codec::None)?;
|
||||||
|
|
||||||
let record = IndexRecord::new(*key, segment_id, offset, data_size, 1);
|
let record = IndexRecord::new(*key, segment_id, offset, data_size, 1);
|
||||||
self.shared.bucket_store.insert(record)?;
|
self.shared.bucket_store.insert(record)?;
|
||||||
@@ -256,7 +257,7 @@ impl Engine {
|
|||||||
|
|
||||||
for key in keys {
|
for key in keys {
|
||||||
let (segment_id, offset, data_size) =
|
let (segment_id, offset, data_size) =
|
||||||
inner.append_entry(*key, &[], 1, Codec::None)?;
|
inner.append_entry(*key, &[], 0, 1, Codec::None)?;
|
||||||
|
|
||||||
let entry_end = offset + ENTRY_HEADER_SIZE as u64 + data_size as u64;
|
let entry_end = offset + ENTRY_HEADER_SIZE as u64 + data_size as u64;
|
||||||
records.push(IndexRecord::new(*key, segment_id, offset, data_size, 1));
|
records.push(IndexRecord::new(*key, segment_id, offset, data_size, 1));
|
||||||
@@ -298,8 +299,9 @@ impl Engine {
|
|||||||
self.shared.config.compression_level,
|
self.shared.config.compression_level,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let original_len = value.len() as u32;
|
||||||
let (segment_id, offset, data_size) =
|
let (segment_id, offset, data_size) =
|
||||||
inner.append_entry(*key, &data, 0, actual_codec)?;
|
inner.append_entry(*key, &data, original_len, 0, actual_codec)?;
|
||||||
|
|
||||||
let entry_end = offset + ENTRY_HEADER_SIZE as u64 + data_size as u64;
|
let entry_end = offset + ENTRY_HEADER_SIZE as u64 + data_size as u64;
|
||||||
records.push(IndexRecord::new(*key, segment_id, offset, data_size, 0));
|
records.push(IndexRecord::new(*key, segment_id, offset, data_size, 0));
|
||||||
@@ -453,6 +455,7 @@ impl EngineInner {
|
|||||||
&mut self,
|
&mut self,
|
||||||
key: [u8; 32],
|
key: [u8; 32],
|
||||||
data: &[u8],
|
data: &[u8],
|
||||||
|
raw_size: u32,
|
||||||
flags: u8,
|
flags: u8,
|
||||||
codec: Codec,
|
codec: Codec,
|
||||||
) -> Result<(u32, u64, u32)> {
|
) -> Result<(u32, u64, u32)> {
|
||||||
@@ -464,7 +467,7 @@ impl EngineInner {
|
|||||||
let entry = if flags == 1 {
|
let entry = if flags == 1 {
|
||||||
Entry::tombstone(key)
|
Entry::tombstone(key)
|
||||||
} else {
|
} else {
|
||||||
Entry::new(key, data, flags, codec)
|
Entry::new(key, data, raw_size, flags, codec)
|
||||||
};
|
};
|
||||||
|
|
||||||
let data_size = entry.data.len() as u32;
|
let data_size = entry.data.len() as u32;
|
||||||
|
|||||||
@@ -19,13 +19,14 @@ pub struct Entry {
|
|||||||
|
|
||||||
impl Entry {
|
impl Entry {
|
||||||
/// Create a normal data entry.
|
/// Create a normal data entry.
|
||||||
pub fn new(key: [u8; 32], raw_data: &[u8], flags: u8, codec: Codec) -> Self {
|
/// `raw_size` is the original uncompressed size; `data` is what goes to disk.
|
||||||
|
pub fn new(key: [u8; 32], data: &[u8], raw_size: u32, flags: u8, codec: Codec) -> Self {
|
||||||
Self {
|
Self {
|
||||||
flags,
|
flags,
|
||||||
codec,
|
codec,
|
||||||
key,
|
key,
|
||||||
raw_size: raw_data.len() as u32,
|
raw_size,
|
||||||
data: raw_data.to_vec(),
|
data: data.to_vec(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,7 +461,7 @@ mod tests {
|
|||||||
let key = [0xAAu8; 32];
|
let key = [0xAAu8; 32];
|
||||||
let data = b"hello world".to_vec();
|
let data = b"hello world".to_vec();
|
||||||
|
|
||||||
let entry = Entry::new(key, &data, 0, Codec::None);
|
let entry = Entry::new(key, &data, data.len() as u32, 0, Codec::None);
|
||||||
{
|
{
|
||||||
let mut writer = SegmentWriter::create(path.clone(), 1).unwrap();
|
let mut writer = SegmentWriter::create(path.clone(), 1).unwrap();
|
||||||
writer.append(&entry).unwrap();
|
writer.append(&entry).unwrap();
|
||||||
@@ -506,7 +507,7 @@ mod tests {
|
|||||||
.map(|i| {
|
.map(|i| {
|
||||||
let mut key = [0u8; 32];
|
let mut key = [0u8; 32];
|
||||||
key[0] = i;
|
key[0] = i;
|
||||||
Entry::new(key, &vec![i; 100], 0, Codec::None)
|
Entry::new(key, &vec![i; 100], 100, 0, Codec::None)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ bytes.workspace = true
|
|||||||
mail-send.workspace = true
|
mail-send.workspace = true
|
||||||
blake3.workspace = true
|
blake3.workspace = true
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
fjall.workspace = true
|
bichon-blob.workspace = true
|
||||||
tracing-log.workspace = true
|
tracing-log.workspace = true
|
||||||
tokio-util.workspace = true
|
tokio-util.workspace = true
|
||||||
whichlang = "0.1.1"
|
whichlang = "0.1.1"
|
||||||
|
|||||||
+32
-254
@@ -1,24 +1,24 @@
|
|||||||
use std::{collections::HashMap, path::PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::{
|
use crate::settings::cli::SETTINGS;
|
||||||
error::{code::ErrorCode, BichonResult},
|
|
||||||
migrate::{
|
|
||||||
legacy::schema::SchemaTools,
|
|
||||||
store::{LegacyDirs, NewIndexWriter},
|
|
||||||
},
|
|
||||||
raise_error,
|
|
||||||
settings::cli::SETTINGS,
|
|
||||||
};
|
|
||||||
use tantivy::{
|
|
||||||
collector::TopDocs,
|
|
||||||
columnar::Column,
|
|
||||||
query::TermQuery,
|
|
||||||
schema::{IndexRecordOption, Value},
|
|
||||||
DocAddress, Index, TantivyDocument, Term,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub mod legacy;
|
/// Current storage layout version.
|
||||||
pub mod store;
|
/// - 1: fjall-based blob storage (post v0.3.7 migration)
|
||||||
|
/// - 2: bichon-blob based storage
|
||||||
|
pub const CURRENT_STORAGE_VERSION: u32 = 2;
|
||||||
|
|
||||||
|
const VERSION_FILE: &str = "STORAGE_VERSION";
|
||||||
|
|
||||||
|
/// Read the storage layout version from `root_dir/STORAGE_VERSION`.
|
||||||
|
pub fn read_storage_version(root_dir: &Path) -> Option<u32> {
|
||||||
|
let content = std::fs::read_to_string(root_dir.join(VERSION_FILE)).ok()?;
|
||||||
|
content.trim().parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the storage layout version to `root_dir/STORAGE_VERSION`.
|
||||||
|
pub fn write_storage_version(root_dir: &Path, version: u32) -> std::io::Result<()> {
|
||||||
|
std::fs::write(root_dir.join(VERSION_FILE), format!("{}\n", version))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_tantivy_index_dir(dir: &PathBuf) -> std::io::Result<bool> {
|
pub fn is_tantivy_index_dir(dir: &PathBuf) -> std::io::Result<bool> {
|
||||||
if !dir.exists() || !dir.is_dir() {
|
if !dir.exists() || !dir.is_dir() {
|
||||||
@@ -47,28 +47,17 @@ pub fn is_tantivy_index_dir(dir: &PathBuf) -> std::io::Result<bool> {
|
|||||||
Ok(has_meta_json && match_count >= 3)
|
Ok(has_meta_json && match_count >= 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the number of segments in the legacy EML Tantivy index.
|
/// Check whether the data layout is compatible with the current server.
|
||||||
/// Each segment can be passed to `do_migrate_segment` for bounded-memory batch migration.
|
/// Returns `false` only when legacy v0.3.7 data is detected and migration is required.
|
||||||
pub fn count_eml_segments(legacy: &LegacyDirs) -> BichonResult<usize> {
|
|
||||||
let eml_index = Index::open_in_dir(&legacy.eml_dir)
|
|
||||||
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
|
|
||||||
let reader = eml_index
|
|
||||||
.reader()
|
|
||||||
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
|
|
||||||
let searcher = reader.searcher();
|
|
||||||
Ok(searcher.segment_readers().len())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn check_data_status() -> std::io::Result<bool> {
|
pub fn check_data_status() -> std::io::Result<bool> {
|
||||||
let root_dir = PathBuf::from(&SETTINGS.bichon_root_dir);
|
let root_dir = PathBuf::from(&SETTINGS.bichon_root_dir);
|
||||||
|
|
||||||
let new_indices_base = SETTINGS
|
// 1. Version file takes precedence
|
||||||
.bichon_index_dir
|
if let Some(version) = read_storage_version(&root_dir) {
|
||||||
.as_ref()
|
return Ok(version >= 1);
|
||||||
.map(PathBuf::from)
|
}
|
||||||
.unwrap_or_else(|| root_dir.clone());
|
|
||||||
let new_indices_path = new_indices_base.join("bichon-indices");
|
|
||||||
|
|
||||||
|
// 2. No version file — check for existing v1.x-style storage (fjall era)
|
||||||
let new_data_base = SETTINGS
|
let new_data_base = SETTINGS
|
||||||
.bichon_data_dir
|
.bichon_data_dir
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -76,14 +65,13 @@ pub fn check_data_status() -> std::io::Result<bool> {
|
|||||||
.unwrap_or_else(|| root_dir.clone());
|
.unwrap_or_else(|| root_dir.clone());
|
||||||
let new_storage_path = new_data_base.join("bichon-storage");
|
let new_storage_path = new_data_base.join("bichon-storage");
|
||||||
|
|
||||||
let has_new_indices = is_tantivy_index_dir(&new_indices_path.join("attachment_metadata"))?
|
if is_dir_not_empty(&new_storage_path)? {
|
||||||
&& is_tantivy_index_dir(&new_indices_path.join("mail_metadata"))?;
|
// Existing v1.x install predates version file — mark it
|
||||||
let has_new_storage = is_dir_not_empty(&new_storage_path)?;
|
let _ = write_storage_version(&root_dir, 1);
|
||||||
|
|
||||||
if has_new_indices && has_new_storage {
|
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Check for legacy v0.3.7 Tantivy layout
|
||||||
let legacy_index_root = SETTINGS
|
let legacy_index_root = SETTINGS
|
||||||
.bichon_index_dir
|
.bichon_index_dir
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -99,9 +87,9 @@ pub fn check_data_status() -> std::io::Result<bool> {
|
|||||||
let has_legacy_data = is_tantivy_index_dir(&legacy_data_root)?;
|
let has_legacy_data = is_tantivy_index_dir(&legacy_data_root)?;
|
||||||
|
|
||||||
if has_legacy_index || has_legacy_data {
|
if has_legacy_index || has_legacy_data {
|
||||||
Ok(false)
|
Ok(false) // Needs migration
|
||||||
} else {
|
} else {
|
||||||
Ok(true)
|
Ok(true) // Fresh install
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,213 +100,3 @@ fn is_dir_not_empty(path: &PathBuf) -> std::io::Result<bool> {
|
|||||||
let mut entries = std::fs::read_dir(path)?;
|
let mut entries = std::fs::read_dir(path)?;
|
||||||
Ok(entries.next().is_some())
|
Ok(entries.next().is_some())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Migrate all documents from a single EML segment to the new storage layout.
|
|
||||||
///
|
|
||||||
/// This is the core of the batch migration strategy: each Process B invocation
|
|
||||||
/// handles exactly one EML segment, so peak memory is bounded by that segment's
|
|
||||||
/// size regardless of the total archive size.
|
|
||||||
pub fn do_migrate_segment<F>(
|
|
||||||
batch_size: u32,
|
|
||||||
legacy: LegacyDirs,
|
|
||||||
writer: &mut NewIndexWriter,
|
|
||||||
segment_index: usize,
|
|
||||||
mut on_progress: F,
|
|
||||||
) -> BichonResult<()>
|
|
||||||
where
|
|
||||||
F: FnMut(&str),
|
|
||||||
{
|
|
||||||
// ── open legacy indices ────────────────────────────────────────────
|
|
||||||
let envelope_index = Index::open_in_dir(&legacy.envelope_dir)
|
|
||||||
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
|
|
||||||
let eml_index = Index::open_in_dir(&legacy.eml_dir)
|
|
||||||
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
|
|
||||||
|
|
||||||
let envelope_reader = envelope_index
|
|
||||||
.reader()
|
|
||||||
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
|
|
||||||
let eml_reader = eml_index
|
|
||||||
.reader()
|
|
||||||
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
|
|
||||||
|
|
||||||
let envelope_searcher = envelope_reader.searcher();
|
|
||||||
let eml_searcher = eml_reader.searcher();
|
|
||||||
|
|
||||||
let ef = SchemaTools::envelope_fields();
|
|
||||||
let mf = SchemaTools::eml_fields();
|
|
||||||
|
|
||||||
let eml_segments = eml_searcher.segment_readers();
|
|
||||||
let eml_segment = eml_segments.get(segment_index).ok_or_else(|| {
|
|
||||||
raise_error!(
|
|
||||||
format!(
|
|
||||||
"segment index {} out of range ({} segments)",
|
|
||||||
segment_index,
|
|
||||||
eml_segments.len()
|
|
||||||
),
|
|
||||||
ErrorCode::InternalError
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let num_docs = eml_segment.num_docs();
|
|
||||||
if num_docs == 0 {
|
|
||||||
on_progress("TOTAL:0");
|
|
||||||
on_progress("DONE:0:0");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
on_progress(&format!("TOTAL:{}", num_docs));
|
|
||||||
|
|
||||||
let max_doc = eml_segment.max_doc();
|
|
||||||
let ff = eml_segment.fast_fields();
|
|
||||||
let f_id_col: Column<u64> = ff.u64("id").map_err(|e| {
|
|
||||||
raise_error!(
|
|
||||||
format!("failed to open f_id fast field: {e:#?}"),
|
|
||||||
ErrorCode::InternalError
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// ── Phase 1: build eid → (uid, internal_date) from envelope, then drop it ──
|
|
||||||
let mut envelope_map: HashMap<u64, (u32, i64)> = HashMap::with_capacity(num_docs as usize);
|
|
||||||
|
|
||||||
let mut env_scanned = 0u32;
|
|
||||||
let mut env_skipped = 0u32;
|
|
||||||
for doc_id in 0..max_doc {
|
|
||||||
if eml_segment.is_deleted(doc_id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let eid = f_id_col.values.get_val(doc_id);
|
|
||||||
|
|
||||||
let term = Term::from_field_u64(ef.f_id, eid);
|
|
||||||
let query = TermQuery::new(term, IndexRecordOption::Basic);
|
|
||||||
let hits: Vec<(_, DocAddress)> = envelope_searcher
|
|
||||||
.search(&query, &TopDocs::with_limit(1).order_by_score())
|
|
||||||
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
|
|
||||||
|
|
||||||
if let Some((_, addr)) = hits.first() {
|
|
||||||
let env_doc: TantivyDocument = envelope_searcher
|
|
||||||
.doc(*addr)
|
|
||||||
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
|
|
||||||
let uid = env_doc
|
|
||||||
.get_first(ef.f_uid)
|
|
||||||
.and_then(|v| v.as_u64())
|
|
||||||
.unwrap_or(0) as u32;
|
|
||||||
let internal_date = env_doc
|
|
||||||
.get_first(ef.f_internal_date)
|
|
||||||
.and_then(|v| v.as_i64())
|
|
||||||
.unwrap_or(0);
|
|
||||||
envelope_map.insert(eid, (uid, internal_date));
|
|
||||||
env_scanned += 1;
|
|
||||||
} else {
|
|
||||||
env_skipped += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if env_scanned % 10 == 0 {
|
|
||||||
on_progress(&format!(
|
|
||||||
"PHASE1:{}/{} skipped:{}",
|
|
||||||
env_scanned, max_doc, env_skipped
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Free the envelope index before the heavy EML processing.
|
|
||||||
drop(envelope_searcher);
|
|
||||||
drop(envelope_reader);
|
|
||||||
drop(envelope_index);
|
|
||||||
|
|
||||||
// ── Phase 2: process EML docs, streaming one at a time ─────────────
|
|
||||||
let mut total_migrated = 0usize;
|
|
||||||
let mut total_skipped = 0usize;
|
|
||||||
|
|
||||||
// Recreate the StoreReader periodically to bound any internal caches.
|
|
||||||
//const CHUNK_SIZE: u32 = 3000;
|
|
||||||
let mut chunk_start = 0u32;
|
|
||||||
|
|
||||||
while chunk_start < max_doc {
|
|
||||||
let chunk_end = (chunk_start + batch_size).min(max_doc);
|
|
||||||
let store_reader = eml_segment
|
|
||||||
.get_store_reader(2)
|
|
||||||
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
|
|
||||||
|
|
||||||
for doc_id in chunk_start..chunk_end {
|
|
||||||
if eml_segment.is_deleted(doc_id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let eid = f_id_col.values.get_val(doc_id);
|
|
||||||
|
|
||||||
let (uid, internal_date) = match envelope_map.get(&eid) {
|
|
||||||
Some(v) => *v,
|
|
||||||
None => {
|
|
||||||
on_progress(&format!("WARN: eid {} envelope not found", eid));
|
|
||||||
total_skipped += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let eml_doc: TantivyDocument = store_reader
|
|
||||||
.get(doc_id)
|
|
||||||
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
|
|
||||||
|
|
||||||
let account_id = match eml_doc.get_first(mf.f_account_id).and_then(|v| v.as_u64()) {
|
|
||||||
Some(v) => v,
|
|
||||||
None => {
|
|
||||||
on_progress(&format!("WARN: eid {} account_id missing", eid));
|
|
||||||
total_skipped += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mailbox_id = eml_doc
|
|
||||||
.get_first(mf.f_mailbox_id)
|
|
||||||
.and_then(|v| v.as_u64())
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
// Borrow directly from eml_doc — no .to_vec() clone.
|
|
||||||
let eml_bytes = match eml_doc.get_first(mf.f_eml).and_then(|v| v.as_bytes()) {
|
|
||||||
Some(b) => b,
|
|
||||||
None => {
|
|
||||||
on_progress(&format!("WARN: eid {} eml bytes missing", eid));
|
|
||||||
total_skipped += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(e) = writer.ingest(eml_bytes, account_id, mailbox_id, uid, internal_date) {
|
|
||||||
on_progress(&format!(
|
|
||||||
"ERROR: Account {} eid {} ingest failed: {}",
|
|
||||||
account_id, eid, e
|
|
||||||
));
|
|
||||||
total_skipped += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
total_migrated += 1;
|
|
||||||
|
|
||||||
if total_migrated % 10 == 0 || total_migrated as u32 == num_docs {
|
|
||||||
on_progress(&format!("PROGRESS:{}:{}", total_migrated, num_docs));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
drop(store_reader);
|
|
||||||
|
|
||||||
// Flush Fjall buffers via ingestion API — bypasses memtable/WAL.
|
|
||||||
writer.flush_fjall_buffers()?;
|
|
||||||
|
|
||||||
chunk_start = chunk_end;
|
|
||||||
}
|
|
||||||
|
|
||||||
on_progress(&format!("DONE:{}:{}", total_migrated, total_skipped));
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_real_tantivy_dir() {
|
|
||||||
let path = PathBuf::from(r"D:\test-data\envelope");
|
|
||||||
let result = is_tantivy_index_dir(&path).unwrap();
|
|
||||||
println!("is tantivy index dir: {}", result);
|
|
||||||
assert!(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
use crate::context::Initialize;
|
use crate::context::Initialize;
|
||||||
|
use crate::migrate::{write_storage_version, CURRENT_STORAGE_VERSION};
|
||||||
use crate::settings::cli::SETTINGS;
|
use crate::settings::cli::SETTINGS;
|
||||||
use crate::{
|
use crate::{
|
||||||
error::{code::ErrorCode, BichonResult},
|
error::{code::ErrorCode, BichonResult},
|
||||||
@@ -62,6 +63,14 @@ impl Initialize for DataDirManager {
|
|||||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
std::fs::create_dir_all(&DATA_DIR_MANAGER.storage_dir)
|
std::fs::create_dir_all(&DATA_DIR_MANAGER.storage_dir)
|
||||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
|
||||||
|
// Write STORAGE_VERSION on fresh install (no existing data)
|
||||||
|
let version_path = DATA_DIR_MANAGER.root_dir.join("STORAGE_VERSION");
|
||||||
|
if !version_path.exists() && !DATA_DIR_MANAGER.storage_dir.join("blobs").exists() {
|
||||||
|
write_storage_version(&DATA_DIR_MANAGER.root_dir, CURRENT_STORAGE_VERSION)
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,17 +16,17 @@
|
|||||||
// You should have received a copy of the GNU Affero General Public License
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
use crate::raise_error;
|
||||||
use crate::{
|
use crate::{
|
||||||
common::signal::SIGNAL_MANAGER,
|
common::signal::SIGNAL_MANAGER,
|
||||||
envelope::extractor::reattach_eml_content_self_healing,
|
envelope::extractor::reattach_eml_content_self_healing,
|
||||||
error::{code::ErrorCode, BichonResult},
|
error::{code::ErrorCode, BichonResult},
|
||||||
settings::dir::DATA_DIR_MANAGER,
|
settings::dir::DATA_DIR_MANAGER,
|
||||||
};
|
};
|
||||||
use crate::raise_error;
|
use bichon_blob::{Codec, Config, Engine};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use fjall::{CompressionType, Database, Keyspace, KeyspaceCreateOptions, KvSeparationOptions, config::{BlockSizePolicy, CompressionPolicy}};
|
|
||||||
|
|
||||||
use std::{io::Cursor, sync::LazyLock};
|
use std::{io::Cursor, sync::Arc, sync::LazyLock};
|
||||||
use tokio::{
|
use tokio::{
|
||||||
sync::{mpsc, Mutex},
|
sync::{mpsc, Mutex},
|
||||||
task::{self, JoinHandle},
|
task::{self, JoinHandle},
|
||||||
@@ -41,33 +41,48 @@ pub struct DetachedEmail {
|
|||||||
|
|
||||||
pub struct BlobManager {
|
pub struct BlobManager {
|
||||||
sender: mpsc::Sender<DetachedEmail>,
|
sender: mpsc::Sender<DetachedEmail>,
|
||||||
db: Database,
|
engine: Arc<Engine>,
|
||||||
email_keyspace: Keyspace,
|
|
||||||
attachments_keyspace: Keyspace,
|
|
||||||
handle: Mutex<Option<JoinHandle<()>>>,
|
handle: Mutex<Option<JoinHandle<()>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn hex_to_key(hex: &str) -> BichonResult<[u8; 32]> {
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
hex::decode_to_slice(hex, &mut key).map_err(|e| {
|
||||||
|
raise_error!(
|
||||||
|
format!("invalid content hash '{hex}': {e:#?}"),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
impl BlobManager {
|
impl BlobManager {
|
||||||
pub async fn shutdown(&self) {
|
pub async fn shutdown(&self) {
|
||||||
let mut guard = self.handle.lock().await;
|
let mut guard = self.handle.lock().await;
|
||||||
if let Some(handle) = guard.take() {
|
if let Some(handle) = guard.take() {
|
||||||
let _ = handle.await;
|
let _ = handle.await;
|
||||||
}
|
}
|
||||||
|
if let Err(e) = self.engine.shutdown() {
|
||||||
|
tracing::error!("blob engine shutdown error: {}", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn process_detached_email(
|
fn process_detached_email(eml: DetachedEmail, engine: &Engine) {
|
||||||
eml: DetachedEmail,
|
|
||||||
email_ks: &Keyspace,
|
|
||||||
attach_ks: &Keyspace,
|
|
||||||
) {
|
|
||||||
let (email_hash, email_data) = eml.email;
|
let (email_hash, email_data) = eml.email;
|
||||||
match email_ks.contains_key(&email_hash) {
|
let email_key = match hex_to_key(&email_hash) {
|
||||||
|
Ok(k) => k,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("{:#?}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match engine.exists(&email_key) {
|
||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
if let Err(e) = email_ks.insert(email_hash, email_data) {
|
if let Err(e) = engine.put(email_key, &email_data, Codec::Lz4) {
|
||||||
tracing::error!("CRITICAL: Failed to insert email: {:?}", e);
|
tracing::error!("CRITICAL: Failed to insert email blob: {:?}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => tracing::error!("Fjall email_ks error: {:?}", e),
|
Err(e) => tracing::error!("blob engine error: {:?}", e),
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
tracing::debug!("Email blob already exists (dedup): {}", &email_hash);
|
tracing::debug!("Email blob already exists (dedup): {}", &email_hash);
|
||||||
}
|
}
|
||||||
@@ -75,13 +90,20 @@ impl BlobManager {
|
|||||||
|
|
||||||
if let Some(attachments) = eml.attachments {
|
if let Some(attachments) = eml.attachments {
|
||||||
for (a_hash, a_data) in attachments {
|
for (a_hash, a_data) in attachments {
|
||||||
match attach_ks.contains_key(&a_hash) {
|
let a_key = match hex_to_key(&a_hash) {
|
||||||
|
Ok(k) => k,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("{:#?}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match engine.exists(&a_key) {
|
||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
if let Err(e) = attach_ks.insert(a_hash, a_data) {
|
if let Err(e) = engine.put(a_key, &a_data, Codec::Lz4) {
|
||||||
tracing::error!("CRITICAL: Failed to insert attachment: {:?}", e);
|
tracing::error!("CRITICAL: Failed to insert attachment blob: {:?}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => tracing::error!("Fjall attach_ks error: {:?}", e),
|
Err(e) => tracing::error!("blob engine error: {:?}", e),
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
tracing::debug!("Attachment blob already exists (dedup): {}", &a_hash);
|
tracing::debug!("Attachment blob already exists (dedup): {}", &a_hash);
|
||||||
}
|
}
|
||||||
@@ -91,57 +113,21 @@ impl BlobManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let db = Database::builder(&DATA_DIR_MANAGER.storage_dir)
|
let blob_dir = DATA_DIR_MANAGER.storage_dir.join("blobs");
|
||||||
.cache_size(64 * 1024 * 1024)
|
|
||||||
.max_cached_files(Some(400))
|
|
||||||
.journal_compression(CompressionType::None)
|
|
||||||
.max_journaling_size(64 * 1024 * 1024)
|
|
||||||
.open()
|
|
||||||
.expect("Failed to initialize Fjall database: Check if the directory exists and has write permissions.");
|
|
||||||
|
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.default_codec = Codec::Zstd;
|
||||||
|
config.compress_threshold = 1024;
|
||||||
|
config.flush_interval_secs = 60;
|
||||||
|
|
||||||
let email_keyspace = db
|
let engine = Engine::open(&blob_dir, config)
|
||||||
.keyspace("email", || {
|
.expect("Failed to initialize blob engine: Check disk space and permissions.");
|
||||||
KeyspaceCreateOptions::default()
|
|
||||||
.max_memtable_size(16 * 1024 * 1024)
|
|
||||||
.data_block_size_policy(BlockSizePolicy::all(4 * 1024))
|
|
||||||
.data_block_compression_policy(
|
|
||||||
CompressionPolicy::all(CompressionType::Lz4)
|
|
||||||
)
|
|
||||||
.with_kv_separation(Some(
|
|
||||||
KvSeparationOptions::default()
|
|
||||||
.separation_threshold(1024)
|
|
||||||
.compression(CompressionType::Lz4)
|
|
||||||
.file_target_size(512 * 1024 * 1024)
|
|
||||||
.staleness_threshold(0.5)
|
|
||||||
.age_cutoff(0.6),
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.expect("Failed to open 'email' keyspace: The partition metadata might be corrupted or inaccessible.");
|
|
||||||
|
|
||||||
let attachments_keyspace = db
|
let engine = Arc::new(engine);
|
||||||
.keyspace("attachments", || {
|
|
||||||
KeyspaceCreateOptions::default()
|
|
||||||
.data_block_size_policy(BlockSizePolicy::all(4 * 1024))
|
|
||||||
.data_block_compression_policy(
|
|
||||||
CompressionPolicy::all(CompressionType::Lz4)
|
|
||||||
)
|
|
||||||
.with_kv_separation(Some(
|
|
||||||
KvSeparationOptions::default()
|
|
||||||
.separation_threshold(1024)
|
|
||||||
.compression(CompressionType::Lz4)
|
|
||||||
.file_target_size(512 * 1024 * 1024)
|
|
||||||
.staleness_threshold(0.5)
|
|
||||||
.age_cutoff(0.6),
|
|
||||||
))
|
|
||||||
.max_memtable_size(16 * 1024 * 1024)
|
|
||||||
})
|
|
||||||
.expect("Failed to open 'attachments' keyspace: Check disk space for blob storage initialization.");
|
|
||||||
|
|
||||||
let (sender, mut receiver) = mpsc::channel::<DetachedEmail>(100);
|
let (sender, mut receiver) = mpsc::channel::<DetachedEmail>(100);
|
||||||
|
|
||||||
let email_ks = email_keyspace.clone();
|
let engine_bg = Arc::clone(&engine);
|
||||||
let attach_ks = attachments_keyspace.clone();
|
|
||||||
let handler = task::spawn(async move {
|
let handler = task::spawn(async move {
|
||||||
let mut shutdown = SIGNAL_MANAGER.subscribe();
|
let mut shutdown = SIGNAL_MANAGER.subscribe();
|
||||||
loop {
|
loop {
|
||||||
@@ -153,18 +139,17 @@ impl BlobManager {
|
|||||||
while let Ok(next_eml) = receiver.try_recv() {
|
while let Ok(next_eml) = receiver.try_recv() {
|
||||||
batch.push(next_eml);
|
batch.push(next_eml);
|
||||||
}
|
}
|
||||||
let email_ks = email_ks.clone();
|
let engine_bg = Arc::clone(&engine_bg);
|
||||||
let attach_ks = attach_ks.clone();
|
|
||||||
if let Err(e) = tokio::task::spawn_blocking(move || {
|
if let Err(e) = tokio::task::spawn_blocking(move || {
|
||||||
for eml in batch {
|
for eml in batch {
|
||||||
Self::process_detached_email(eml, &email_ks, &attach_ks);
|
Self::process_detached_email(eml, &engine_bg);
|
||||||
}
|
}
|
||||||
}).await {
|
}).await {
|
||||||
tracing::error!("BlobManager: spawn_blocking join error: {:#?}", e);
|
tracing::error!("BlobManager: spawn_blocking join error: {:#?}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
tracing::info!("BlobManager: All senders dropped, closing storage.");
|
tracing::info!("BlobManager: All senders dropped, closing blob storage.");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,17 +165,16 @@ impl BlobManager {
|
|||||||
remaining.len()
|
remaining.len()
|
||||||
);
|
);
|
||||||
if !remaining.is_empty() {
|
if !remaining.is_empty() {
|
||||||
let email_ks = email_ks.clone();
|
let engine_bg = Arc::clone(&engine_bg);
|
||||||
let attach_ks = attach_ks.clone();
|
|
||||||
if let Err(e) = tokio::task::spawn_blocking(move || {
|
if let Err(e) = tokio::task::spawn_blocking(move || {
|
||||||
for eml in remaining {
|
for eml in remaining {
|
||||||
Self::process_detached_email(eml, &email_ks, &attach_ks);
|
Self::process_detached_email(eml, &engine_bg);
|
||||||
}
|
}
|
||||||
}).await {
|
}).await {
|
||||||
tracing::error!("BlobManager: shutdown spawn_blocking join error: {:#?}", e);
|
tracing::error!("BlobManager: shutdown spawn_blocking join error: {:#?}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tracing::info!("BlobManager: All remaining tasks processed. Closing Fjall.");
|
tracing::info!("BlobManager: All remaining tasks processed. Closing blob engine.");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,30 +183,30 @@ impl BlobManager {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
sender,
|
sender,
|
||||||
db,
|
engine,
|
||||||
email_keyspace,
|
|
||||||
attachments_keyspace,
|
|
||||||
handle: Mutex::new(Some(handler)),
|
handle: Mutex::new(Some(handler)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn queue(&self, email: DetachedEmail) {
|
pub async fn queue(&self, email: DetachedEmail) {
|
||||||
if let Err(e) = self.sender.send(email).await {
|
if let Err(e) = self.sender.send(email).await {
|
||||||
tracing::error!("BlobManager channel closed, email lost: {:#?}", e);
|
tracing::error!("BlobManager channel closed, email lost: {:#?}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_email(&self, content_hash: &str) -> BichonResult<Option<Bytes>> {
|
pub fn get_email(&self, content_hash: &str) -> BichonResult<Option<Bytes>> {
|
||||||
self.email_keyspace
|
self.get(content_hash)
|
||||||
.get(content_hash)
|
|
||||||
.map(|user_value| user_value.map(|s| s.into()))
|
|
||||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_attachment(&self, content_hash: &str) -> BichonResult<Option<Bytes>> {
|
pub fn get_attachment(&self, content_hash: &str) -> BichonResult<Option<Bytes>> {
|
||||||
self.attachments_keyspace
|
self.get(content_hash)
|
||||||
.get(content_hash)
|
}
|
||||||
.map(|user_value| user_value.map(|s| s.into()))
|
|
||||||
|
fn get(&self, content_hash: &str) -> BichonResult<Option<Bytes>> {
|
||||||
|
let key = hex_to_key(content_hash)?;
|
||||||
|
self.engine
|
||||||
|
.get(&key)
|
||||||
|
.map(|v| v.map(Bytes::from))
|
||||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,17 +219,23 @@ impl BlobManager {
|
|||||||
I1: IntoIterator,
|
I1: IntoIterator,
|
||||||
I1::Item: AsRef<str>,
|
I1::Item: AsRef<str>,
|
||||||
I2: IntoIterator,
|
I2: IntoIterator,
|
||||||
I2::Item: AsRef<str> {
|
I2::Item: AsRef<str>,
|
||||||
let mut batch = self.db.batch();
|
{
|
||||||
for hash in email_content_hashes {
|
let mut keys: Vec<[u8; 32]> = email_content_hashes
|
||||||
batch.remove(&self.email_keyspace, hash.as_ref());
|
.into_iter()
|
||||||
|
.map(|h| hex_to_key(h.as_ref()))
|
||||||
|
.collect::<BichonResult<_>>()?;
|
||||||
|
|
||||||
|
for h in attachment_content_hashes {
|
||||||
|
keys.push(hex_to_key(h.as_ref())?);
|
||||||
}
|
}
|
||||||
for hash in attachment_content_hashes {
|
|
||||||
batch.remove(&self.attachments_keyspace, hash.as_ref());
|
if !keys.is_empty() {
|
||||||
|
self.engine
|
||||||
|
.delete_batch(&keys)
|
||||||
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
}
|
}
|
||||||
batch
|
|
||||||
.commit()
|
|
||||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user