Files
bichon/crates/admin/src/migrate_v1.rs

429 lines
14 KiB
Rust

use std::path::PathBuf;
use bichon_blob::{Codec, Config, Engine};
use bichon_core::{
error::{code::ErrorCode, BichonResult},
migrate::write_storage_version,
raise_error,
};
use console::style;
use dialoguer::{theme::ColorfulTheme, Input};
use fjall::{Config as FjallConfig, Database};
use indicatif::{ProgressBar, ProgressStyle};
fn hex_key_to_raw(hex_bytes: &[u8]) -> BichonResult<[u8; 32]> {
let hex_str = std::str::from_utf8(hex_bytes).map_err(|e| {
raise_error!(
format!("invalid UTF-8 in fjall key: {e:#?}"),
ErrorCode::InternalError
)
})?;
let mut raw = [0u8; 32];
hex::decode_to_slice(hex_str, &mut raw).map_err(|e| {
raise_error!(
format!("invalid hex in fjall key '{hex_str}': {e:#?}"),
ErrorCode::InternalError
)
})?;
Ok(raw)
}
fn migrate_keyspace(
engine: &Engine,
db: &Database,
ks_name: &str,
label: &str,
) -> BichonResult<u64> {
let ks = db
.keyspace(ks_name, || {
panic!("{ks_name} keyspace not found in fjall database")
})
.map_err(|e| {
raise_error!(
format!("failed to open fjall keyspace '{ks_name}': {e:#?}"),
ErrorCode::InternalError
)
})?;
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::with_template("{spinner:.cyan} {msg} [{elapsed_precise}]")
.unwrap(),
);
pb.set_message(format!("Scanning {label} blobs..."));
let mut count: u64 = 0;
for item in ks.iter() {
let (key_bytes, value) = item.into_inner().map_err(|e| {
raise_error!(
format!("fjall iter error in '{ks_name}': {e:#?}"),
ErrorCode::InternalError
)
})?;
if value.is_empty() {
continue;
}
let raw_key = hex_key_to_raw(&key_bytes)?;
if let Err(e) = engine.put(raw_key, &value, Codec::Zstd) {
if matches!(e, bichon_blob::Error::ValueTooLarge { .. }) {
eprintln!(
"{}",
console::style(format!(
"WARN: skipping oversized blob key={} ({} bytes)",
hex::encode(raw_key),
value.len()
))
.yellow()
);
continue;
}
return Err(raise_error!(
format!("bichon-blob put error: {e:#?}"),
ErrorCode::InternalError
));
}
count += 1;
if count % 1000 == 0 {
pb.set_message(format!("{label}: {} blobs migrated...", count));
}
}
pb.finish_with_message(format!("{label}: {} blobs migrated", count));
Ok(count)
}
pub fn handle_migrate_v1(theme: &ColorfulTheme) {
println!(
"\n{}",
style("MIGRATION: Bichon v1.x Storage → v2.x (Fjall → bichon-blob)")
.bold()
.yellow()
);
println!(
"{}\n",
style("This migrates blob storage from the fjall engine to bichon-blob.\n\
Tantivy indexes and metadata (memdb) are NOT affected.")
.dim()
);
let root_dir: String = Input::with_theme(theme)
.with_prompt("Enter --bichon-root-dir (same value used by the old server)")
.validate_with(|input: &String| -> Result<(), &str> {
let path = PathBuf::from(input);
if !path.is_absolute() {
return Err("Path must be absolute.");
}
if !path.exists() {
return Err("Directory does not exist.");
}
Ok(())
})
.interact_text()
.unwrap();
let root_dir = PathBuf::from(root_dir.trim());
let data_base = {
let input: String = Input::with_theme(theme)
.with_prompt("Enter --bichon-data-dir (leave blank to use root directory)")
.allow_empty(true)
.interact_text()
.unwrap();
if input.trim().is_empty() {
root_dir.clone()
} else {
let path = PathBuf::from(input.trim());
if !path.exists() {
eprintln!(
"{}",
style(format!("Data directory does not exist: {}", path.display())).red()
);
return;
}
path
}
};
let fjall_path = data_base.join("bichon-storage");
let blob_path = fjall_path.join("blobs");
if !fjall_path.exists() {
println!(
"{}",
style(format!(
"Fjall database not found at '{}'. Is this really a v1.x install?",
fjall_path.display()
))
.red()
);
return;
}
if blob_path.exists() {
println!(
"{}",
style(format!(
"Target blob directory '{}' already exists.\n\
If you have already migrated, you can remove the old fjall files manually.\n\
Otherwise, delete this directory and re-run the migration.",
blob_path.display()
))
.yellow()
);
return;
}
// Open old Fjall database (read-only by nature of the iter API)
println!("\n{}", style("Opening fjall database...").dim());
let db = match Database::open(FjallConfig::new(&fjall_path)) {
Ok(db) => db,
Err(e) => {
println!(
"{}",
style(format!("Failed to open fjall database: {e:#?}")).red()
);
return;
}
};
// Open new bichon-blob engine
println!("{}", style("Initializing bichon-blob engine...").dim());
let mut config = Config::default();
config.default_codec = Codec::Zstd;
config.compress_threshold = 1024;
config.flush_interval_secs = 0;
config.gc_interval_secs = 0;
let engine = match Engine::open(&blob_path, config) {
Ok(e) => e,
Err(e) => {
println!(
"{}",
style(format!("Failed to open bichon-blob engine: {e:#?}")).red()
);
return;
}
};
// Migrate email blobs
let email_count = match migrate_keyspace(&engine, &db, "email", "Email") {
Ok(n) => n,
Err(e) => {
println!("{}", style(format!("Email migration failed: {e:#?}")).red());
let _ = engine.shutdown();
return;
}
};
// Migrate attachment blobs
let attach_count =
match migrate_keyspace(&engine, &db, "attachments", "Attachment") {
Ok(n) => n,
Err(e) => {
println!(
"{}",
style(format!("Attachment migration failed: {e:#?}")).red()
);
let _ = engine.shutdown();
return;
}
};
// Flush and shutdown
println!("\n{}", style("Flushing and shutting down blob engine...").dim());
if let Err(e) = engine.flush() {
println!("{}", style(format!("flush warning: {e:#?}")).yellow());
}
if let Err(e) = engine.shutdown() {
println!(
"{}",
style(format!("shutdown error: {e:#?}")).red()
);
return;
}
// Write STORAGE_VERSION = 2
if let Err(e) = write_storage_version(&root_dir, 2) {
println!(
"{}",
style(format!("Failed to write STORAGE_VERSION: {e:#?}")).red()
);
return;
}
println!(
"\n{}",
style(format!(
"Migration complete!\n Email blobs: {}\n Attachment blobs: {}\n Total: {}\n\n\
The old fjall database at '{}' is no longer used.\n\
You may delete it to free disk space after verifying everything works.",
email_count,
attach_count,
email_count + attach_count,
fjall_path.display()
))
.green()
.bold()
);
}
#[cfg(test)]
mod tests {
use super::*;
use bichon_core::utils::compute_content_hash;
// ── hex_key_to_raw ────────────────────────────────────────────────
#[test]
fn hex_key_to_raw_valid() {
// "hello" blake3 hex = 64 chars
let hash_hex = compute_content_hash(b"hello");
assert_eq!(hash_hex.len(), 64);
let raw = hex_key_to_raw(hash_hex.as_bytes()).unwrap();
// Decoding 64 hex chars → 32 bytes
assert_eq!(raw.len(), 32);
// Round-trip: raw → hex should match original
assert_eq!(hex::encode(raw), hash_hex);
}
#[test]
fn hex_key_to_raw_invalid_utf8() {
// 0xFF is not valid UTF-8
let invalid = vec![0xFFu8; 64];
let err = hex_key_to_raw(&invalid).unwrap_err();
assert!(err.to_string().contains("invalid UTF-8"));
}
#[test]
fn hex_key_to_raw_invalid_hex() {
// "zz" is valid UTF-8 but not valid hex
let invalid = b"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz";
let err = hex_key_to_raw(invalid).unwrap_err();
assert!(err.to_string().contains("invalid hex"));
}
#[test]
fn hex_key_to_raw_wrong_length() {
let short = b"abcd";
let err = hex_key_to_raw(short).unwrap_err();
assert!(err.to_string().contains("invalid hex"));
}
#[test]
fn hex_key_to_raw_different_content() {
let a = hex_key_to_raw(compute_content_hash(b"a").as_bytes()).unwrap();
let b = hex_key_to_raw(compute_content_hash(b"b").as_bytes()).unwrap();
assert_ne!(a, b);
}
// ── migrate_keyspace integration ──────────────────────────────────
#[test]
fn migrate_keyspace_end_to_end() {
let tmp = tempfile::tempdir().unwrap();
let fjall_path = tmp.path().join("fjall");
let blob_path = tmp.path().join("blobs");
use fjall::KeyspaceCreateOptions;
// --- Setup: create a Fjall database with test blobs ---
let fjall_db = Database::open(FjallConfig::new(&fjall_path)).unwrap();
let ks = fjall_db
.keyspace("test_ks", KeyspaceCreateOptions::default)
.unwrap();
// Insert test blobs using hex string keys (matching v1.x convention)
let mut expected: Vec<(String, Vec<u8>)> = Vec::new();
for i in 0..10 {
let data = format!("blob data {}", i).into_bytes();
let hash = compute_content_hash(&data);
ks.insert(hash.as_bytes(), data.clone()).unwrap();
expected.push((hash, data));
}
// --- Setup: create bichon-blob engine and run migration ---
{
let mut config = Config::default();
config.flush_interval_secs = 0;
config.gc_interval_secs = 0;
let engine = Engine::open(&blob_path, config).unwrap();
let count = migrate_keyspace(&engine, &fjall_db, "test_ks", "Test").unwrap();
assert_eq!(count, expected.len() as u64);
engine.flush().unwrap();
engine.shutdown().unwrap();
// engine dropped here → LOCK released
}
// --- Verify: re-open engine and check all blobs ---
let mut config = Config::default();
config.flush_interval_secs = 0;
config.gc_interval_secs = 0;
let engine2 = Engine::open(&blob_path, config).unwrap();
for (hex_hash, expected_data) in &expected {
let mut raw_key = [0u8; 32];
hex::decode_to_slice(hex_hash, &mut raw_key).unwrap();
let got = engine2.get(&raw_key).unwrap();
assert_eq!(
got.as_deref(),
Some(expected_data.as_slice()),
"mismatch for key {}",
hex_hash
);
}
// Verify non-existent key returns None
let fake_hash = compute_content_hash(b"nonexistent");
let mut fake_key = [0u8; 32];
hex::decode_to_slice(&fake_hash, &mut fake_key).unwrap();
assert!(engine2.get(&fake_key).unwrap().is_none());
engine2.shutdown().unwrap();
}
#[test]
fn migrate_empty_keyspace() {
let tmp = tempfile::tempdir().unwrap();
let fjall_path = tmp.path().join("fjall");
let blob_path = tmp.path().join("blobs");
let fjall_db = Database::open(FjallConfig::new(&fjall_path)).unwrap();
let _ks = fjall_db
.keyspace("empty_ks", fjall::KeyspaceCreateOptions::default)
.unwrap();
let mut config = Config::default();
config.flush_interval_secs = 0;
config.gc_interval_secs = 0;
let engine = Engine::open(&blob_path, config).unwrap();
let count = migrate_keyspace(&engine, &fjall_db, "empty_ks", "Empty").unwrap();
assert_eq!(count, 0);
engine.shutdown().unwrap();
}
#[test]
fn hex_key_roundtrip_with_real_content_hash() {
// Simulate the exact data flow from v1.x to v2.x
let eml_data = b"From: sender@example.com\r\nSubject: Test\r\n\r\nHello world";
let hash_hex = compute_content_hash(eml_data); // 64-char hex string
// v1.x: key stored as hash_hex.as_bytes()
let fjall_key = hash_hex.as_bytes().to_vec();
assert_eq!(fjall_key.len(), 64);
// Migration: hex decode → raw 32 bytes
let raw_key = hex_key_to_raw(&fjall_key).unwrap();
assert_eq!(raw_key.len(), 32);
// v2.x: engine.put(raw_key, data)
// Verify round-trip: raw_key → hex → compare
let hex_roundtrip = hex::encode(raw_key);
assert_eq!(hex_roundtrip, hash_hex);
}
}