Compare commits

...
5 Commits
Author SHA1 Message Date
rustmailer 9f9fc71d16 bump to v1.0.1 2026-05-16 22:19:15 +08:00
rustmailer b2a75643da fix(bichon-admin): reduce memory usage during data migration 2026-05-16 22:17:03 +08:00
rustmailer 37a38a2910 Update README.md 2026-05-15 12:21:06 +08:00
rustmailer 8817ed96f6 Update README.md 2026-05-15 12:19:50 +08:00
rustmailer 1ee2eade3a fix: rename bichonctl to bichon-cli 2026-05-15 12:07:26 +08:00
19 changed files with 563 additions and 207 deletions
Generated
+6 -5
View File
@@ -293,7 +293,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]] [[package]]
name = "bichon-admin" name = "bichon-admin"
version = "1.0.0" version = "1.0.1"
dependencies = [ dependencies = [
"bichon-core", "bichon-core",
"console", "console",
@@ -301,6 +301,7 @@ dependencies = [
"indicatif", "indicatif",
"itertools", "itertools",
"memdb", "memdb",
"mimalloc",
"native_db", "native_db",
"native_model", "native_model",
"serde", "serde",
@@ -311,7 +312,7 @@ dependencies = [
[[package]] [[package]]
name = "bichon-cli" name = "bichon-cli"
version = "1.0.0" version = "1.0.1"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bichon-core", "bichon-core",
@@ -337,7 +338,7 @@ dependencies = [
[[package]] [[package]]
name = "bichon-core" name = "bichon-core"
version = "1.0.0" version = "1.0.1"
dependencies = [ dependencies = [
"async-imap", "async-imap",
"base64 0.22.1", "base64 0.22.1",
@@ -395,7 +396,7 @@ dependencies = [
[[package]] [[package]]
name = "bichon-server" name = "bichon-server"
version = "1.0.0" version = "1.0.1"
dependencies = [ dependencies = [
"bichon-core", "bichon-core",
"bichon-smtp", "bichon-smtp",
@@ -420,7 +421,7 @@ dependencies = [
[[package]] [[package]]
name = "bichon-smtp" name = "bichon-smtp"
version = "1.0.0" version = "1.0.1"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bichon-core", "bichon-core",
+1 -1
View File
@@ -11,7 +11,7 @@ members = [
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
version = "1.0.0" version = "1.0.1"
edition = "2021" edition = "2021"
[workspace.dependencies] [workspace.dependencies]
+16 -10
View File
@@ -39,7 +39,13 @@
<p align="center">A self-hosted email archiving server built in Rust. Download emails from IMAP accounts, builds a full-text search index, and serves a REST API with an embedded WebUI. Purpose-built for long-term preservation, unified cross-account search, and programmatic access to archived email.</p> <p align="center">A self-hosted email archiving server built in Rust. Download emails from IMAP accounts, builds a full-text search index, and serves a REST API with an embedded WebUI. Purpose-built for long-term preservation, unified cross-account search, and programmatic access to archived email.</p>
[![Watch the demo](https://img.youtube.com/vi/fMlayXo3Bo0/maxresdefault.jpg)](https://www.youtube.com/watch?v=fMlayXo3Bo0) <p align="center">
<a href="https://www.youtube.com/watch?v=fMlayXo3Bo0">
<img src="https://img.youtube.com/vi/fMlayXo3Bo0/maxresdefault.jpg" alt="Watch the demo"/>
</a>
<br/>
<em>▶ Click to watch the demo</em>
</p>
> [!NOTE] > [!NOTE]
> Bichon is an **archiver**, not an email client. It does not send, compose, forward, or reply to emails. Its optional SMTP server is for **receiving** emails only. > Bichon is an **archiver**, not an email client. It does not send, compose, forward, or reply to emails. Its optional SMTP server is for **receiving** emails only.
@@ -94,7 +100,7 @@
- **Multi-User RBAC**: 5 built-in roles (Admin, Manager, Member, AccountManager, AccountViewer) plus custom roles with 22 granular permissions. - **Multi-User RBAC**: 5 built-in roles (Admin, Manager, Member, AccountManager, AccountViewer) plus custom roles with 22 granular permissions.
- **Account-Level Isolation**: Grant users access to specific accounts with scoped roles. Permissions enforced at the API layer. - **Account-Level Isolation**: Grant users access to specific accounts with scoped roles. Permissions enforced at the API layer.
- **CLI Import Tools**: Import from EML directories, MBOX files (including Gmail variants), Thunderbird profiles, and Outlook PST files. - **CLI Import Tools**: Import from EML directories, MBOX files (including Gmail variants), Thunderbird profiles, and Outlook PST files.
- **CLI Export**: Download account data as MBOX via `bichonctl`. - **CLI Export**: Download account data as MBOX via `bichon-cli`.
- **Bulk Restore**: Restore emails in bulk back to their original IMAP accounts. - **Bulk Restore**: Restore emails in bulk back to their original IMAP accounts.
- **Embedded SMTP Server**: Receive emails directly at the gateway level. STARTTLS or TLS encryption. AUTH PLAIN/LOGIN with API token authentication. - **Embedded SMTP Server**: Receive emails directly at the gateway level. STARTTLS or TLS encryption. AUTH PLAIN/LOGIN with API token authentication.
- **Admin Tooling**: Password reset for locked-out admins. Non-destructive v0.3.7 to v1.0 data migration. - **Admin Tooling**: Password reset for locked-out admins. Non-destructive v0.3.7 to v1.0 data migration.
@@ -340,10 +346,10 @@ On first start, Bichon creates a built-in admin user:
## CLI Tools ## CLI Tools
### bichonctl — Import & Export ### bichon-cli — Import & Export
```bash ```bash
./bichonctl --config config.toml ./bichon-cli --config config.toml
``` ```
Creates a `config.toml` on first run with your server URL and API token. Creates a `config.toml` on first run with your server URL and API token.
@@ -391,12 +397,12 @@ All `/api/v1/*` endpoints require `Authorization: Bearer <token>`.
| Format | Tool | Notes | | Format | Tool | Notes |
|--------|------|-------| |--------|------|-------|
| **EML Directory** | `bichonctl` | Recursive `.eml` scan; preserves folder hierarchy | | **EML Directory** | `bichon-cli` | Recursive `.eml` scan; preserves folder hierarchy |
| **MBOX** | `bichonctl` | Single-file streaming import; supports Gmail's MBOX variant | | **MBOX** | `bichon-cli` | Single-file streaming import; supports Gmail's MBOX variant |
| **Thunderbird** | `bichonctl` | Reads directly from local Thunderbird profile directory | | **Thunderbird** | `bichon-cli` | Reads directly from local Thunderbird profile directory |
| **PST** | `bichonctl` | Outlook Personal Storage (`.pst`) file parsing | | **PST** | `bichon-cli` | Outlook Personal Storage (`.pst`) file parsing |
| **API Import** | `POST /api/v1/import` | Base64-encoded EML payloads for programmatic use | | **API Import** | `POST /api/v1/import` | Base64-encoded EML payloads for programmatic use |
| **MBOX Export** | `bichonctl` | Download account data as `.mbox` file | | **MBOX Export** | `bichon-cli` | Download account data as `.mbox` file |
All imports flow through the Bichon REST API. The server parses MIME, extracts metadata, indexes content into Tantivy, deduplicates by BLAKE3 content hash, and stores raw blobs in Fjall. All imports flow through the Bichon REST API. The server parses MIME, extracts metadata, indexes content into Tantivy, deduplicates by BLAKE3 content hash, and stores raw blobs in Fjall.
@@ -410,7 +416,7 @@ bichon/
│ ├── memdb/ Embedded key-value database layer (WAL, transactions) │ ├── memdb/ Embedded key-value database layer (WAL, transactions)
│ ├── core/ Library — IMAP sync, search, storage, auth, models │ ├── core/ Library — IMAP sync, search, storage, auth, models
│ ├── server/ Binary — Poem web server + embedded WebUI (rust-embed) │ ├── server/ Binary — Poem web server + embedded WebUI (rust-embed)
│ ├── cli/ Binary — bichonctl import/export CLI │ ├── cli/ Binary — bichon-cli import/export CLI
│ └── admin/ Binary — bichon-admin password reset & migration │ └── admin/ Binary — bichon-admin password reset & migration
└── web/ React + TypeScript + Vite + ShadCN UI frontend └── web/ React + TypeScript + Vite + ShadCN UI frontend
``` ```
+1
View File
@@ -18,3 +18,4 @@ serde_json.workspace = true
itertools.workspace = true itertools.workspace = true
snafu.workspace = true snafu.workspace = true
memdb.workspace = true memdb.workspace = true
mimalloc = "0.1.50"
+9 -1
View File
@@ -18,6 +18,7 @@
use console::style; use console::style;
use dialoguer::{theme::ColorfulTheme, Select}; use dialoguer::{theme::ColorfulTheme, Select};
use mimalloc::MiMalloc;
use crate::{migrate::handle_migration, reset::handle_reset_password}; use crate::{migrate::handle_migration, reset::handle_reset_password};
@@ -25,8 +26,15 @@ pub mod meta;
pub mod migrate; pub mod migrate;
pub mod reset; pub mod reset;
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
fn main() {
run_interactive();
}
#[tokio::main] #[tokio::main]
async fn main() { async fn run_interactive() {
let theme = ColorfulTheme::default(); let theme = ColorfulTheme::default();
println!( println!(
"\n{}\n", "\n{}\n",
+170 -40
View File
@@ -1,7 +1,7 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use bichon_core::migrate::{ use bichon_core::migrate::{
do_migrate, is_tantivy_index_dir, count_eml_segments, do_migrate_segment, is_tantivy_index_dir,
store::{LegacyDirs, NewDirs}, store::{LegacyDirs, NewDirs},
}; };
use console::style; use console::style;
@@ -234,8 +234,7 @@ pub fn handle_migration(theme: &ColorfulTheme) {
); );
eprintln!( eprintln!(
"{}", "{}",
style("Aborting migration. No changes have been made to Tantivy data.") style("Aborting migration. No changes have been made to Tantivy data.").yellow()
.yellow()
); );
return; return;
} }
@@ -246,48 +245,179 @@ pub fn handle_migration(theme: &ColorfulTheme) {
style("").yellow(), style("").yellow(),
style("Step 2: Migrating email index and blob data...").cyan() style("Step 2: Migrating email index and blob data...").cyan()
); );
let pb = ProgressBar::new(0);
pb.set_style(ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {msg}")
.unwrap()
.progress_chars("#>-"));
let legacy = LegacyDirs::new(index_path, data_path);
let new_dirs = NewDirs::new(new_index_path, new_data_path);
if let Err(e) = do_migrate(legacy, new_dirs, |msg| {
if let Some(data) = msg.strip_prefix("PROGRESS:") {
let parts: Vec<&str> = data.split(':').collect();
if parts.len() == 2 {
let migrated = parts[0].parse::<u64>().unwrap_or(0);
let skipped = parts[1].parse::<u64>().unwrap_or(0);
pb.set_position(migrated + skipped); println!(
pb.set_message(format!( "\n{} {}",
"Migrated: {}, {} {}", style("").blue(),
style(migrated).green(), style("Batch size controls memory usage during migration:").dim()
style(skipped).red(), );
style("skipped").dim() println!(
)); " {} 1000 — ~500MB RAM (slower, low memory)",
} style("").dim()
} else if let Some(total) = msg.strip_prefix("TOTAL:") { );
pb.set_length(total.parse().unwrap_or(0)); println!(" {} 3000 — ~1GB RAM (recommended)", style("").dim());
} else if msg.starts_with("WARN:") { println!(
pb.println(format!("{} {}", style("").yellow(), &msg[5..])); " {} 5000 — ~2GB RAM (faster, high memory)",
} else if let Some(done_data) = msg.strip_prefix("DONE:") { style("").dim()
let parts: Vec<&str> = done_data.split(':').collect(); );
pb.finish_with_message(format!( println!(
"Migration finished. Total: {}, Skipped: {}", " {} Note: actual memory usage depends on your average email size.",
parts.get(0).unwrap_or(&"0"), style("").yellow()
parts.get(1).unwrap_or(&"0") );
)); println!(
" {} If your mailbox contains many large attachments, use a smaller batch size.\n",
style(" ").dim()
);
let batch_size: u32 = {
let input: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Enter batch size (affects memory usage, see notes above)")
.default("3000".to_string())
.validate_with(|s: &String| match s.trim().parse::<usize>() {
Ok(n) if n > 0 => Ok(()),
_ => Err("Please enter a valid positive number"),
})
.interact_text()
.unwrap_or("3000".to_string());
input.trim().parse::<u32>().unwrap_or(3000)
};
println!(
"{} Using batch size: {}\n",
style("").green(),
style(batch_size).cyan().bold()
);
println!(
"{} Using batch size: {}\n",
style("").green(),
style(batch_size).cyan().bold()
);
let legacy = LegacyDirs::new(index_path.clone(), data_path.clone());
let total_segments = match count_eml_segments(&legacy) {
Ok(n) => n,
Err(e) => {
eprintln!(
"\n{} Failed to count EML segments:\n{:?}",
style("").red().bold(),
e
);
return;
} }
}) { };
eprintln!(
"\n{} Migration failed:\n{:?}", if total_segments == 0 {
style("").red().bold(), println!(
style(e).red() "{} {}",
style("").green(),
style("No EML segments found. Nothing to migrate.").bold()
); );
return; return;
} }
println!(
"{} EML segments to migrate: {}",
style("").yellow(),
style(total_segments).cyan()
);
let pb = ProgressBar::new(total_segments as u64);
pb.set_style(
ProgressStyle::default_bar()
.template(
"{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {msg}",
)
.unwrap()
.progress_chars("#>-"),
);
let mut grand_total_migrated: usize = 0;
let mut grand_total_skipped: usize = 0;
for seg_idx in 0..total_segments {
let seg_total: std::cell::Cell<usize> = std::cell::Cell::new(0);
pb.set_message(format!("Segment {}/{}", seg_idx + 1, total_segments));
let legacy = LegacyDirs::new(index_path.clone(), data_path.clone());
match do_migrate_segment(
batch_size,
legacy,
NewDirs::new(new_index_path.clone(), new_data_path.clone()),
seg_idx,
|msg| {
if let Some(data) = msg.strip_prefix("TOTAL:") {
seg_total.set(data.parse().unwrap_or(0));
} else if let Some(data) = msg.strip_prefix("PHASE1:") {
let parts: Vec<&str> = data.split('/').collect();
let scanned: usize = parts.get(0).and_then(|s| s.parse().ok()).unwrap_or(0);
let total: usize = parts
.get(1)
.and_then(|s| s.split_once(" skipped:").map(|(n, _)| n))
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let skipped: usize = data
.split_once("skipped:")
.and_then(|(_, s)| s.parse().ok())
.unwrap_or(0);
let pct = if total > 0 {
(scanned * 100) / total
} else {
0
};
pb.set_message(format!(
"Segment {}/{} [scanning {}/{} skipped:{} {}%]",
seg_idx + 1,
total_segments,
scanned,
total,
skipped,
pct,
));
} else if let Some(data) = msg.strip_prefix("PROGRESS:") {
let parts: Vec<&str> = data.split(':').collect();
let migrated: usize = parts.get(0).and_then(|s| s.parse().ok()).unwrap_or(0);
let total = seg_total.get();
let pct = if total > 0 {
(migrated * 100) / total
} else {
0
};
pb.set_message(format!(
"Segment {}/{} [migrating {}/{} {}%]",
seg_idx + 1,
total_segments,
migrated,
total,
pct,
));
} else if let Some(warn) = msg.strip_prefix("WARN:") {
pb.println(format!("{} {}", style("").yellow(), warn));
} else if let Some(done_data) = msg.strip_prefix("DONE:") {
let parts: Vec<&str> = done_data.split(':').collect();
let migrated: usize = parts.get(0).and_then(|s| s.parse().ok()).unwrap_or(0);
let skipped: usize = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
grand_total_migrated += migrated;
grand_total_skipped += skipped;
}
},
) {
Ok(()) => {}
Err(e) => {
pb.finish_with_message(format!("{}", style("Migration failed.").red()));
eprintln!("\n{} {:?}", style("").red().bold(), e);
return;
}
}
pb.set_position((seg_idx + 1) as u64);
}
pb.finish_with_message(format!(
"Migration finished. Total: {}, Skipped: {}",
grand_total_migrated, grand_total_skipped
));
println!( println!(
"{} {}", "{} {}",
style("").green(), style("").green(),
+2 -2
View File
@@ -1,4 +1,4 @@
use crate::BichonCtlConfig; use crate::BichonCliConfig;
use bichon_core::{base64_encode, envelope::meta::BichonMetadata, store::envelope::Envelope}; use bichon_core::{base64_encode, envelope::meta::BichonMetadata, store::envelope::Envelope};
use chrono::{TimeZone, Utc}; use chrono::{TimeZone, Utc};
use reqwest::Client; use reqwest::Client;
@@ -6,7 +6,7 @@ use tokio::io::AsyncWriteExt;
pub async fn download_and_export_with_json_header( pub async fn download_and_export_with_json_header(
client: &Client, client: &Client,
config: &BichonCtlConfig, config: &BichonCliConfig,
envelope: Envelope, envelope: Envelope,
file: &mut tokio::fs::File, file: &mut tokio::fs::File,
) -> bool { ) -> bool {
+2 -2
View File
@@ -5,11 +5,11 @@ use bichon_core::{
}; };
use reqwest::Client; use reqwest::Client;
use crate::BichonCtlConfig; use crate::BichonCliConfig;
pub async fn search_messages( pub async fn search_messages(
client: &Client, client: &Client,
config: &BichonCtlConfig, config: &BichonCliConfig,
page: u64, page: u64,
page_size: u64, page_size: u64,
) -> Option<DataPage<Envelope>> { ) -> Option<DataPage<Envelope>> {
+2 -2
View File
@@ -21,11 +21,11 @@ use reqwest::Client;
use bichon_core::import::BatchEmlRequest; use bichon_core::import::BatchEmlRequest;
use crate::BichonCtlConfig; use crate::BichonCliConfig;
pub async fn send_batch_request( pub async fn send_batch_request(
client: &Client, client: &Client,
config: &BichonCtlConfig, config: &BichonCliConfig,
account_id: u64, account_id: u64,
folder: &str, folder: &str,
emls: Vec<String>, emls: Vec<String>,
+2 -2
View File
@@ -1,11 +1,11 @@
use bichon_core::account::stats::AccountStats; use bichon_core::account::stats::AccountStats;
use reqwest::Client; use reqwest::Client;
use crate::BichonCtlConfig; use crate::BichonCliConfig;
pub async fn fetch_account_stats( pub async fn fetch_account_stats(
client: &Client, client: &Client,
config: &BichonCtlConfig, config: &BichonCliConfig,
account_id: u64, account_id: u64,
) -> Option<AccountStats> { ) -> Option<AccountStats> {
let url = format!("{}/api/v1/accounts/{}/stats", config.base_url, account_id); let url = format!("{}/api/v1/accounts/{}/stats", config.base_url, account_id);
+2 -2
View File
@@ -27,10 +27,10 @@ use bichon_core::{
users::{permissions::Permission, view::UserView}, users::{permissions::Permission, view::UserView},
}; };
use crate::BichonCtlConfig; use crate::BichonCliConfig as BichonCliConfig;
pub async fn verify_user_and_get_account( pub async fn verify_user_and_get_account(
config: &BichonCtlConfig, config: &BichonCliConfig,
theme: &ColorfulTheme, theme: &ColorfulTheme,
only_nosync: bool, only_nosync: bool,
) -> MinimalAccount { ) -> MinimalAccount {
+3 -3
View File
@@ -29,10 +29,10 @@ use reqwest::Client;
use bichon_core::base64_encode_url_safe; use bichon_core::base64_encode_url_safe;
use crate::{BichonCtlConfig, api::sender::send_batch_request}; use crate::{BichonCliConfig, api::sender::send_batch_request};
pub async fn handle_eml_directory_import( pub async fn handle_eml_directory_import(
config: &BichonCtlConfig, config: &BichonCliConfig,
account_id: u64, account_id: u64,
theme: &ColorfulTheme, theme: &ColorfulTheme,
) { ) {
@@ -100,7 +100,7 @@ fn scan_dir(
} }
async fn process_and_upload( async fn process_and_upload(
config: &BichonCtlConfig, config: &BichonCliConfig,
account_id: u64, account_id: u64,
tasks: HashMap<String, Vec<PathBuf>>, tasks: HashMap<String, Vec<PathBuf>>,
) { ) {
+2 -2
View File
@@ -1,7 +1,7 @@
use crate::api::download::download_and_export_with_json_header; use crate::api::download::download_and_export_with_json_header;
use crate::api::search::search_messages; use crate::api::search::search_messages;
use crate::api::stats::fetch_account_stats; use crate::api::stats::fetch_account_stats;
use crate::BichonCtlConfig; use crate::BichonCliConfig;
use bichon_core::account::payload::MinimalAccount; use bichon_core::account::payload::MinimalAccount;
use console::style; use console::style;
use dialoguer::Confirm; use dialoguer::Confirm;
@@ -12,7 +12,7 @@ use std::path::{Path, PathBuf};
use sysinfo::Disks; use sysinfo::Disks;
pub async fn handle_account_export( pub async fn handle_account_export(
config: &BichonCtlConfig, config: &BichonCliConfig,
account: MinimalAccount, account: MinimalAccount,
theme: &ColorfulTheme, theme: &ColorfulTheme,
) { ) {
+5 -5
View File
@@ -39,7 +39,7 @@ pub mod thunderbird;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command( #[command(
name = "bichonctl", name = "bichon-cli",
author = "rustmailer", author = "rustmailer",
version = bichon_version!(), version = bichon_version!(),
about = "A CLI tool to import email data into Bichon service" about = "A CLI tool to import email data into Bichon service"
@@ -57,7 +57,7 @@ pub struct BichonCli {
} }
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct BichonCtlConfig { pub struct BichonCliConfig {
pub base_url: String, pub base_url: String,
pub api_token: String, pub api_token: String,
} }
@@ -67,11 +67,11 @@ async fn main() {
let cli = BichonCli::parse(); let cli = BichonCli::parse();
let theme = ColorfulTheme::default(); let theme = ColorfulTheme::default();
let config_path = &cli.config; let config_path = &cli.config;
let mut current_config: Option<BichonCtlConfig> = None; let mut current_config: Option<BichonCliConfig> = None;
if config_path.exists() { if config_path.exists() {
if let Ok(content) = fs::read_to_string(config_path) { if let Ok(content) = fs::read_to_string(config_path) {
if let Ok(config) = toml::from_str::<BichonCtlConfig>(&content) { if let Ok(config) = toml::from_str::<BichonCliConfig>(&content) {
println!("{}", style("✔ Existing configuration found:").green()); println!("{}", style("✔ Existing configuration found:").green());
println!(" Base URL: {}", style(&config.base_url).yellow()); println!(" Base URL: {}", style(&config.base_url).yellow());
println!(" API Token: {}", style(&config.api_token).yellow()); println!(" API Token: {}", style(&config.api_token).yellow());
@@ -105,7 +105,7 @@ async fn main() {
.interact_text() .interact_text()
.unwrap(); .unwrap();
let conf = BichonCtlConfig { let conf = BichonCliConfig {
base_url: url, base_url: url,
api_token: token, api_token: token,
}; };
+3 -3
View File
@@ -22,7 +22,7 @@ use std::path::PathBuf;
use crate::api::sender::send_batch_request; use crate::api::sender::send_batch_request;
use crate::mbox::gmail::determine_folder; use crate::mbox::gmail::determine_folder;
use crate::mbox::reader::MboxFile; use crate::mbox::reader::MboxFile;
use crate::BichonCtlConfig; use crate::BichonCliConfig;
use bichon_core::base64_encode_url_safe; use bichon_core::base64_encode_url_safe;
use bichon_core::envelope::meta::{parse_bichon_metadata, BichonMetadata}; use bichon_core::envelope::meta::{parse_bichon_metadata, BichonMetadata};
use console::style; use console::style;
@@ -36,7 +36,7 @@ pub mod gmail;
pub mod reader; pub mod reader;
pub async fn handle_mbox_single_file_import( pub async fn handle_mbox_single_file_import(
config: &BichonCtlConfig, config: &BichonCliConfig,
account_id: u64, account_id: u64,
theme: &ColorfulTheme, theme: &ColorfulTheme,
) { ) {
@@ -120,7 +120,7 @@ pub async fn handle_mbox_single_file_import(
pub async fn run_import( pub async fn run_import(
account_id: u64, account_id: u64,
mbox_path: &PathBuf, mbox_path: &PathBuf,
config: &BichonCtlConfig, config: &BichonCliConfig,
target_folder: Option<String>, target_folder: Option<String>,
) { ) {
let client = Client::new(); let client = Client::new();
+5 -5
View File
@@ -25,7 +25,7 @@ use outlook_pst::ltp::prop_context::PropertyValue;
use crate::api::sender::send_batch_request; use crate::api::sender::send_batch_request;
use crate::pst::encoding::decode_subject; use crate::pst::encoding::decode_subject;
use crate::BichonCtlConfig; use crate::BichonCliConfig;
use bichon_core::base64_encode_url_safe; use bichon_core::base64_encode_url_safe;
use dialoguer::Confirm; use dialoguer::Confirm;
use outlook_pst::messaging::attachment::AttachmentProperties; use outlook_pst::messaging::attachment::AttachmentProperties;
@@ -60,7 +60,7 @@ pub struct EmailAttachment {
pub data: Option<Vec<u8>>, pub data: Option<Vec<u8>>,
} }
pub async fn handle_pst_import(config: &BichonCtlConfig, account_id: u64, theme: &ColorfulTheme) { pub async fn handle_pst_import(config: &BichonCliConfig, account_id: u64, theme: &ColorfulTheme) {
let path_str: String = Input::with_theme(theme) let path_str: String = Input::with_theme(theme)
.with_prompt("Enter the path to your SINGLE .pst file") .with_prompt("Enter the path to your SINGLE .pst file")
.validate_with(|input: &String| { .validate_with(|input: &String| {
@@ -115,7 +115,7 @@ pub async fn handle_pst_import(config: &BichonCtlConfig, account_id: u64, theme:
} }
} }
async fn parse_pst(pst_path: PathBuf, config: &BichonCtlConfig, account_id: u64) { async fn parse_pst(pst_path: PathBuf, config: &BichonCliConfig, account_id: u64) {
let client = Client::new(); let client = Client::new();
let pst_store = match outlook_pst::open_store(&pst_path) { let pst_store = match outlook_pst::open_store(&pst_path) {
@@ -161,7 +161,7 @@ fn process_folder_recursively<'a>(
client: &'a Client, client: &'a Client,
folder: &'a Rc<dyn Folder>, folder: &'a Rc<dyn Folder>,
parent_path: &'a str, parent_path: &'a str,
config: &'a BichonCtlConfig, config: &'a BichonCliConfig,
account_id: u64, account_id: u64,
) -> Pin<Box<dyn Future<Output = ()> + 'a>> { ) -> Pin<Box<dyn Future<Output = ()> + 'a>> {
Box::pin(async move { Box::pin(async move {
@@ -407,7 +407,7 @@ fn extract_recipients_list(message: &Rc<dyn Message>) -> (Vec<String>, Vec<Strin
async fn send_to_bichon( async fn send_to_bichon(
client: &Client, client: &Client,
config: &BichonCtlConfig, config: &BichonCliConfig,
account_id: u64, account_id: u64,
folder_path: &str, folder_path: &str,
emls: Vec<String>, emls: Vec<String>,
+2 -2
View File
@@ -18,12 +18,12 @@
use std::{collections::HashMap, path::PathBuf}; use std::{collections::HashMap, path::PathBuf};
use crate::{mbox::run_import, BichonCtlConfig}; use crate::{mbox::run_import, BichonCliConfig};
use console::style; use console::style;
use dialoguer::{theme::ColorfulTheme, Confirm, Input}; use dialoguer::{theme::ColorfulTheme, Confirm, Input};
pub async fn handle_thunderbird_import( pub async fn handle_thunderbird_import(
config: &BichonCtlConfig, config: &BichonCliConfig,
account_id: u64, account_id: u64,
theme: &ColorfulTheme, theme: &ColorfulTheme,
) { ) {
+164 -80
View File
@@ -1,4 +1,4 @@
use std::path::PathBuf; use std::{collections::HashMap, path::PathBuf};
use crate::{ use crate::{
error::{code::ErrorCode, BichonResult}, error::{code::ErrorCode, BichonResult},
@@ -10,7 +10,11 @@ use crate::{
settings::cli::SETTINGS, settings::cli::SETTINGS,
}; };
use tantivy::{ use tantivy::{
collector::TopDocs, query::AllQuery, schema::Value, DocAddress, Index, TantivyDocument, collector::TopDocs,
columnar::Column,
query::TermQuery,
schema::{IndexRecordOption, Value},
DocAddress, Index, TantivyDocument, Term,
}; };
pub mod legacy; pub mod legacy;
@@ -43,6 +47,18 @@ 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.
/// 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())
}
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);
@@ -97,12 +113,22 @@ fn is_dir_not_empty(path: &PathBuf) -> std::io::Result<bool> {
Ok(entries.next().is_some()) Ok(entries.next().is_some())
} }
const PAGE_SIZE: usize = 100; /// Migrate all documents from a single EML segment to the new storage layout.
///
pub fn do_migrate<F>(legacy: LegacyDirs, new_dirs: NewDirs, mut on_progress: F) -> BichonResult<()> /// 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,
new_dirs: NewDirs,
segment_index: usize,
mut on_progress: F,
) -> BichonResult<()>
where where
F: FnMut(&str), F: FnMut(&str),
{ {
// ── open legacy indices ────────────────────────────────────────────
let envelope_index = Index::open_in_dir(&legacy.envelope_dir) let envelope_index = Index::open_in_dir(&legacy.envelope_dir)
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?; .map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
let eml_index = Index::open_in_dir(&legacy.eml_dir) let eml_index = Index::open_in_dir(&legacy.eml_dir)
@@ -118,114 +144,172 @@ where
let envelope_searcher = envelope_reader.searcher(); let envelope_searcher = envelope_reader.searcher();
let eml_searcher = eml_reader.searcher(); let eml_searcher = eml_reader.searcher();
let total_count = envelope_searcher.num_docs();
on_progress(&format!("TOTAL:{}", total_count));
let ef = SchemaTools::envelope_fields(); let ef = SchemaTools::envelope_fields();
let mf = SchemaTools::eml_fields(); let mf = SchemaTools::eml_fields();
let mut writer = NewIndexWriter::open(new_dirs)?; 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 mut offset = 0usize; let num_docs = eml_segment.num_docs();
let mut total_migrated = 0usize; if num_docs == 0 {
let mut total_skipped = 0usize; on_progress("TOTAL:0");
on_progress("DONE:0:0");
return Ok(());
}
loop { on_progress(&format!("TOTAL:{}", num_docs));
let page: Vec<(_, DocAddress)> = envelope_searcher
.search( let max_doc = eml_segment.max_doc();
&AllQuery, let ff = eml_segment.fast_fields();
&TopDocs::with_limit(PAGE_SIZE) let f_id_col: Column<u64> = ff.u64("id").map_err(|e| {
.and_offset(offset) raise_error!(
.order_by_score(), 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))?; .map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
if page.is_empty() { if let Some((_, addr)) = hits.first() {
break; let env_doc: TantivyDocument = envelope_searcher
} .doc(*addr)
let fetched = page.len();
for (_, doc_address) in page {
let doc: TantivyDocument = envelope_searcher
.doc(doc_address)
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?; .map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
let uid = env_doc
let eid = match doc.get_first(ef.f_id).and_then(|v| v.as_u64()) {
Some(v) => v,
None => {
total_skipped += 1;
continue;
}
};
let account_id = match doc.get_first(ef.f_account_id).and_then(|v| v.as_u64()) {
Some(v) => v,
None => {
total_skipped += 1;
continue;
}
};
let mailbox_id = doc
.get_first(ef.f_mailbox_id)
.and_then(|v| v.as_u64())
.unwrap_or(0);
let uid = doc
.get_first(ef.f_uid) .get_first(ef.f_uid)
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
.unwrap_or(0) as u32; .unwrap_or(0) as u32;
let internal_date = doc let internal_date = env_doc
.get_first(ef.f_internal_date) .get_first(ef.f_internal_date)
.and_then(|v| v.as_i64()) .and_then(|v| v.as_i64())
.unwrap_or(0); .unwrap_or(0);
envelope_map.insert(eid, (uid, internal_date));
env_scanned += 1;
} else {
env_skipped += 1;
}
let eml_term = tantivy::Term::from_field_u64(mf.f_id, eid); if env_scanned % 10 == 0 {
let eml_query = on_progress(&format!(
tantivy::query::TermQuery::new(eml_term, tantivy::schema::IndexRecordOption::Basic); "PHASE1:{}/{} skipped:{}",
let eml_hits: Vec<(_, DocAddress)> = eml_searcher env_scanned, max_doc, env_skipped
.search(&eml_query, &TopDocs::with_limit(1).order_by_score()) ));
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?; }
}
let eml_bytes = match eml_hits.first() { // Free the envelope index before the heavy EML processing.
Some((_, addr)) => { drop(envelope_searcher);
let eml_doc: TantivyDocument = eml_searcher drop(envelope_reader);
.doc(*addr) drop(envelope_index);
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
match eml_doc.get_first(mf.f_eml).and_then(|v| v.as_bytes()) { // ── Phase 2: process EML docs, streaming one at a time ─────────────
Some(b) => b.to_vec(), let mut writer = NewIndexWriter::open(new_dirs)?;
None => {
on_progress(&format!("WARN: Account {} ID {} eml field missing", account_id, eid)); let mut total_migrated = 0usize;
total_skipped += 1; let mut total_skipped = 0usize;
continue;
} // 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 => { None => {
on_progress(&format!("WARN:Account {} ID {} eml not found", account_id, eid)); on_progress(&format!("WARN: eid {} envelope not found", eid));
total_skipped += 1; total_skipped += 1;
continue; continue;
} }
}; };
if let Err(e) = writer.ingest(&eml_bytes, account_id, mailbox_id, uid, internal_date) { let eml_doc: TantivyDocument = store_reader
on_progress(&format!("ERROR:Account {} ID {} ingest failed: {}", account_id, eid, e)); .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; total_skipped += 1;
continue; continue;
} }
total_migrated += 1; total_migrated += 1;
if total_migrated % 100 == 0 || total_migrated == total_count as usize { if total_migrated % 10 == 0 || total_migrated as u32 == num_docs {
on_progress(&format!("PROGRESS:{}:{}", total_migrated, total_skipped)); on_progress(&format!("PROGRESS:{}:{}", total_migrated, num_docs));
} }
} }
offset += fetched; drop(store_reader);
if fetched < PAGE_SIZE {
break; // Flush Fjall buffers via ingestion API — bypasses memtable/WAL.
} writer.flush_fjall_buffers()?;
chunk_start = chunk_end;
} }
writer.commit()?; writer.finish_writers()?;
on_progress(&format!("DONE:{}:{}", total_migrated, total_skipped));
Ok(()) Ok(())
} }
+165 -39
View File
@@ -1,4 +1,4 @@
use std::path::PathBuf; use std::{path::PathBuf, time::Instant};
use bytes::Bytes; use bytes::Bytes;
use mail_parser::MimeHeaders; use mail_parser::MimeHeaders;
@@ -13,7 +13,10 @@ use fjall::{
CompressionType, Database, Keyspace, KeyspaceCreateOptions, KvSeparationOptions, CompressionType, Database, Keyspace, KeyspaceCreateOptions, KvSeparationOptions,
}; };
use mail_parser::MessageParser; use mail_parser::MessageParser;
use tantivy::{Index, IndexWriter, TantivyDocument}; use tantivy::{
indexer::{LogMergePolicy, NoMergePolicy},
Index, IndexWriter, TantivyDocument,
};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
@@ -121,14 +124,16 @@ pub fn detach_attachments_standalone(
} }
pub struct NewIndexWriter { pub struct NewIndexWriter {
pub envelope_writer: IndexWriter, pub envelope_writer: Option<IndexWriter>,
pub attachment_writer: IndexWriter, pub attachment_writer: Option<IndexWriter>,
pub email_ks: Keyspace, pub email_ks: Keyspace,
pub attachment_ks: Keyspace, pub attachment_ks: Keyspace,
pending: usize, pending: usize,
email_buf: Vec<(String, Vec<u8>)>,
attachment_buf: Vec<(String, Vec<u8>)>,
} }
const COMMIT_THRESHOLD: usize = 500; //const COMMIT_THRESHOLD: usize = 500;
impl NewIndexWriter { impl NewIndexWriter {
pub fn open(dirs: NewDirs) -> BichonResult<Self> { pub fn open(dirs: NewDirs) -> BichonResult<Self> {
@@ -153,9 +158,15 @@ impl NewIndexWriter {
.register("euro", EuroTokenizer::new()); .register("euro", EuroTokenizer::new());
let envelope_writer = envelope_index let envelope_writer = envelope_index
.writer_with_num_threads(2, 128 * 1024 * 1024) .writer_with_num_threads(3, 256 * 1024 * 1024)
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?; .map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
// let mut merge_policy = LogMergePolicy::default();
// merge_policy.set_min_num_segments(25);
// merge_policy.set_min_layer_size(10_000);
// merge_policy.set_max_docs_before_merge(100_000);
envelope_writer.set_merge_policy(Box::new(NoMergePolicy));
// ── attachment index ───────────────────────────────────────────── // ── attachment index ─────────────────────────────────────────────
std::fs::create_dir_all(&dirs.attachment_dir) std::fs::create_dir_all(&dirs.attachment_dir)
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?; .map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
@@ -176,21 +187,30 @@ impl NewIndexWriter {
.tokenizers() .tokenizers()
.register("euro", EuroTokenizer::new()); .register("euro", EuroTokenizer::new());
let attachment_writer = attachment_index let attachment_writer = attachment_index
.writer_with_num_threads(2, 64 * 1024 * 1024) .writer_with_num_threads(3, 256 * 1024 * 1024)
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?; .map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
// let mut merge_policy = LogMergePolicy::default();
// merge_policy.set_min_num_segments(25);
// merge_policy.set_min_layer_size(10_000);
// merge_policy.set_max_docs_before_merge(100_000);
attachment_writer.set_merge_policy(Box::new(NoMergePolicy));
// ── blob store ─────────────────────────────────────────────────── // ── blob store ───────────────────────────────────────────────────
std::fs::create_dir_all(&dirs.storage_dir) std::fs::create_dir_all(&dirs.storage_dir)
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?; .map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
let db = Database::builder(&dirs.storage_dir) let db = Database::builder(&dirs.storage_dir)
.cache_size(64 * 1024 * 1024) .cache_size(8 * 1024 * 1024)
.journal_compression(CompressionType::None)
.max_journaling_size(64 * 1024 * 1024)
.open() .open()
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?; .map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
let email_ks = db let email_ks = db
.keyspace("email", || { .keyspace("email", || {
KeyspaceCreateOptions::default() KeyspaceCreateOptions::default()
.max_memtable_size(16 * 1024 * 1024) .max_memtable_size(4 * 1024 * 1024)
.data_block_size_policy(BlockSizePolicy::all(4 * 1024)) .data_block_size_policy(BlockSizePolicy::all(4 * 1024))
.data_block_compression_policy(CompressionPolicy::all(CompressionType::Lz4)) .data_block_compression_policy(CompressionPolicy::all(CompressionType::Lz4))
.with_kv_separation(Some( .with_kv_separation(Some(
@@ -205,7 +225,7 @@ impl NewIndexWriter {
let attachment_ks = db let attachment_ks = db
.keyspace("attachments", || { .keyspace("attachments", || {
KeyspaceCreateOptions::default() KeyspaceCreateOptions::default()
.max_memtable_size(16 * 1024 * 1024) .max_memtable_size(4 * 1024 * 1024)
.data_block_size_policy(BlockSizePolicy::all(4 * 1024)) .data_block_size_policy(BlockSizePolicy::all(4 * 1024))
.data_block_compression_policy(CompressionPolicy::all(CompressionType::Lz4)) .data_block_compression_policy(CompressionPolicy::all(CompressionType::Lz4))
.with_kv_separation(Some( .with_kv_separation(Some(
@@ -218,11 +238,13 @@ impl NewIndexWriter {
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?; .map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
Ok(Self { Ok(Self {
envelope_writer, envelope_writer: Some(envelope_writer),
attachment_writer, attachment_writer: Some(attachment_writer),
email_ks, email_ks,
attachment_ks, attachment_ks,
pending: 0, pending: 0,
email_buf: Vec::new(),
attachment_buf: Vec::new(),
}) })
} }
@@ -299,23 +321,11 @@ impl NewIndexWriter {
// ── detach attachments → blob ────────────────────────────────────── // ── detach attachments → blob ──────────────────────────────────────
let (stripped_eml, attachment_output) = detach_attachments_standalone(eml_bytes, &message); let (stripped_eml, attachment_output) = detach_attachments_standalone(eml_bytes, &message);
if !self // Buffer for bulk ingestion — sorted + flushed later.
.email_ks self.email_buf
.contains_key(&email_content_hash) .push((email_content_hash.clone(), stripped_eml));
.unwrap_or(false)
{
self.email_ks
.insert(&email_content_hash, stripped_eml.as_slice())
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
}
// write attachment blobs
for (hash, data) in &attachment_output.blobs { for (hash, data) in &attachment_output.blobs {
if !self.attachment_ks.contains_key(hash).unwrap_or(false) { self.attachment_buf.push((hash.clone(), data.to_vec()));
self.attachment_ks
.insert(hash, data.as_ref())
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
}
} }
// ── build envelope doc ──────────────────────────────────────────── // ── build envelope doc ────────────────────────────────────────────
@@ -390,35 +400,151 @@ impl NewIndexWriter {
let envelope_doc = ea.to_document(&text, 0)?; let envelope_doc = ea.to_document(&text, 0)?;
self.envelope_writer self.envelope_writer
.as_mut()
.unwrap()
.add_document(envelope_doc) .add_document(envelope_doc)
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?; .map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
for doc in attachment_docs { for doc in attachment_docs {
self.attachment_writer self.attachment_writer
.as_mut()
.unwrap()
.add_document(doc) .add_document(doc)
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?; .map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
} }
self.pending += 1; self.pending += 1;
if self.pending >= COMMIT_THRESHOLD { // if self.pending >= COMMIT_THRESHOLD {
self.commit()?; // self.commit()?;
} // }
Ok(()) Ok(())
} }
pub fn commit(&mut self) -> BichonResult<()> { /// Commit pending Tantivy documents (mid-stream) — frees the in-memory
/// term dictionary / postings that accumulate in the IndexWriter.
fn commit_tantivy(&mut self) -> BichonResult<()> {
if self.pending == 0 { if self.pending == 0 {
return Ok(()); return Ok(());
} }
self.envelope_writer println!("Tantivy committing... this may take 2-3 minutes, please wait.");
.commit() let start = Instant::now();
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?; if let Some(writer) = self.envelope_writer.as_mut() {
self.attachment_writer writer
.commit() .commit()
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?; .map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
tracing::info!(count = self.pending, "committed batch"); }
if let Some(writer) = self.attachment_writer.as_mut() {
writer
.commit()
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
}
println!("tantivy commit elasped: {:#?}", start.elapsed());
tracing::info!(count = self.pending, "committed tantivy batch");
self.pending = 0; self.pending = 0;
Ok(()) Ok(())
} }
/// Final commit + segment merge for Tantivy writers (called once at end).
pub fn finish_writers(&mut self) -> BichonResult<()> {
self.commit_tantivy()?;
for (name, writer_opt) in [
("envelope", &mut self.envelope_writer),
("attachment", &mut self.attachment_writer),
] {
if let Some(writer) = writer_opt.as_mut() {
let reader = writer
.index()
.reader()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let seg_ids: Vec<_> = reader
.searcher()
.segment_readers()
.iter()
.map(|r| r.segment_id())
.collect();
println!("merging {} {} segments...", seg_ids.len(), name);
if seg_ids.len() > 1 {
let _ = writer.merge(&seg_ids);
}
}
if let Some(writer) = writer_opt.take() {
println!("waiting for {} merge to finish...", name);
let start = std::time::Instant::now();
let _ = writer.wait_merging_threads();
println!("{} merge done: {:#?}", name, start.elapsed());
}
}
Ok(())
}
/// Sort buffered (hash, data) pairs, dedup, and write via Fjall's
/// ingestion API — writes SSTables directly, bypassing memtable and WAL.
/// Also commits the Tantivy writers to bound their in-memory state.
pub fn flush_fjall_buffers(&mut self) -> BichonResult<()> {
self.commit_tantivy()?;
if !self.email_buf.is_empty() {
self.email_buf.sort_by(|a, b| a.0.cmp(&b.0));
self.email_buf.dedup_by(|a, b| a.0 == b.0);
let mut ingestion = self.email_ks.start_ingestion().map_err(|e| {
raise_error!(
format!("email ingestion start: {e:#?}"),
ErrorCode::InternalError
)
})?;
for (hash, data) in &self.email_buf {
ingestion
.write(hash.as_bytes(), data.as_slice())
.map_err(|e| {
raise_error!(
format!("email ingestion write: {e:#?}"),
ErrorCode::InternalError
)
})?;
}
ingestion.finish().map_err(|e| {
raise_error!(
format!("email ingestion finish: {e:#?}"),
ErrorCode::InternalError
)
})?;
self.email_buf.clear();
}
if !self.attachment_buf.is_empty() {
self.attachment_buf.sort_by(|a, b| a.0.cmp(&b.0));
self.attachment_buf.dedup_by(|a, b| a.0 == b.0);
let mut ingestion = self.attachment_ks.start_ingestion().map_err(|e| {
raise_error!(
format!("attachment ingestion start: {e:#?}"),
ErrorCode::InternalError
)
})?;
for (hash, data) in &self.attachment_buf {
ingestion
.write(hash.as_bytes(), data.as_slice())
.map_err(|e| {
raise_error!(
format!("attachment ingestion write: {e:#?}"),
ErrorCode::InternalError
)
})?;
}
ingestion.finish().map_err(|e| {
raise_error!(
format!("attachment ingestion finish: {e:#?}"),
ErrorCode::InternalError
)
})?;
self.attachment_buf.clear();
}
Ok(())
}
} }