mirror of
https://github.com/flectar/mail.git
synced 2026-11-13 02:24:19 +00:00
295 lines
11 KiB
Rust
295 lines
11 KiB
Rust
//! Performance validation at scale: 100k messages / ~30k threads.
|
|
//! Gated: FLECTAR_MAIL_TEST_PERF=1 (seeding takes ~10-30s).
|
|
|
|
use flectar_mail_core::db::{Db, repo};
|
|
use flectar_mail_core::models::{ThreadCursor, View};
|
|
use std::time::Instant;
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn hundred_k_messages_stay_instant() {
|
|
if std::env::var("FLECTAR_MAIL_TEST_PERF").as_deref() != Ok("1") {
|
|
eprintln!("skipping (set FLECTAR_MAIL_TEST_PERF=1 to run)");
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let db = Db::open(&tmp.path().join("perf.db")).unwrap();
|
|
|
|
const MESSAGES: i64 = 100_000;
|
|
const THREADS: i64 = 30_000;
|
|
|
|
let seed_start = Instant::now();
|
|
db.write(move |conn| {
|
|
let tx = conn.transaction()?;
|
|
tx.execute(
|
|
"INSERT INTO accounts (id, email, display_name, provider, auth_kind, username,
|
|
incoming_host, incoming_port, smtp_host, smtp_port, created_at, mail_protocol, settings_json)
|
|
VALUES (1, 'perf@example.com', NULL, 'generic', 'password', 'perf',
|
|
'localhost', 993, 'localhost', 587, 0, 'imap', '{\"connection\":{\"incomingSecurity\":\"tls\",\"smtpSecurity\":\"tls\"}}')",
|
|
[],
|
|
)?;
|
|
tx.execute(
|
|
"INSERT INTO folders (id, account_id, path, role) VALUES (1, 1, 'INBOX', 'inbox')",
|
|
[],
|
|
)?;
|
|
|
|
let words = [
|
|
"meeting", "invoice", "deploy", "quarterly", "flight", "review", "budget",
|
|
"onboarding", "design", "roadmap", "offsite", "launch", "metrics", "hiring",
|
|
];
|
|
let now: i64 = 1_760_000_000_000;
|
|
|
|
{
|
|
let mut ins_thread = tx.prepare(
|
|
"INSERT INTO threads (id, account_id, subject_norm, last_message_at) VALUES (?1, 1, ?2, 0)",
|
|
)?;
|
|
for t in 1..=THREADS {
|
|
let subj = format!(
|
|
"{} {} update {t}",
|
|
words[(t as usize * 7) % words.len()],
|
|
words[(t as usize * 13) % words.len()]
|
|
);
|
|
ins_thread.execute(rusqlite::params![t, subj])?;
|
|
}
|
|
}
|
|
{
|
|
let mut ins_msg = tx.prepare(
|
|
"INSERT INTO messages (id, account_id, thread_id, folder_id, uid, message_id,
|
|
subject, from_name, from_addr, date, is_read, is_automated, snippet, body_state)
|
|
VALUES (?1, 1, ?2, 1, ?1, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'cached')",
|
|
)?;
|
|
let mut ins_body = tx.prepare(
|
|
"INSERT INTO message_bodies (message_id, text_body) VALUES (?1, ?2)",
|
|
)?;
|
|
for m in 1..=MESSAGES {
|
|
let t = (m % THREADS) + 1;
|
|
let w1 = words[(m as usize * 3) % words.len()];
|
|
let w2 = words[(m as usize * 11) % words.len()];
|
|
let subject = format!("{w1} {w2} update {t}");
|
|
let body = format!(
|
|
"Hi team, quick note about the {w1} and the {w2}. \
|
|
Numbers attached, let me know what you think. ({m})"
|
|
);
|
|
ins_msg.execute(rusqlite::params![
|
|
m,
|
|
t,
|
|
format!("<perf{m}@example.com>"),
|
|
subject,
|
|
format!("Sender {}", m % 500),
|
|
format!("sender{}@example.com", m % 500),
|
|
now - (MESSAGES - m) * 60_000, // one per minute going back
|
|
(m % 3 != 0) as i64,
|
|
(m % 5 == 0) as i64,
|
|
format!("quick note about the {w1} and the {w2}"),
|
|
])?;
|
|
ins_body.execute(rusqlite::params![m, body])?;
|
|
}
|
|
}
|
|
// FTS index in one statement.
|
|
tx.execute(
|
|
"INSERT INTO messages_fts (rowid, subject, from_text, to_text, body)
|
|
SELECT m.id, m.subject, COALESCE(m.from_name,'') || ' ' || COALESCE(m.from_addr,''),
|
|
'', COALESCE(b.text_body, '')
|
|
FROM messages m LEFT JOIN message_bodies b ON b.message_id = m.id",
|
|
[],
|
|
)?;
|
|
// Thread aggregates in bulk.
|
|
tx.execute(
|
|
"UPDATE threads SET
|
|
last_message_at = (SELECT MAX(date) FROM messages WHERE thread_id = threads.id),
|
|
message_count = (SELECT COUNT(*) FROM messages WHERE thread_id = threads.id),
|
|
unread_count = (SELECT COUNT(*) FROM messages WHERE thread_id = threads.id AND is_read = 0),
|
|
snippet = (SELECT snippet FROM messages WHERE thread_id = threads.id ORDER BY date DESC LIMIT 1)",
|
|
[],
|
|
)?;
|
|
tx.commit()?;
|
|
Ok(())
|
|
})
|
|
.await
|
|
.unwrap();
|
|
eprintln!(
|
|
"seeded {MESSAGES} messages / {THREADS} threads in {:?}",
|
|
seed_start.elapsed()
|
|
);
|
|
|
|
// Warm-up (first query pays page-cache costs).
|
|
db.read(|conn| {
|
|
repo::threads::list(
|
|
conn,
|
|
&repo::threads::ListArgs {
|
|
view: View::Inbox,
|
|
tab: None,
|
|
account_id: None,
|
|
account_ids: None,
|
|
folder_id: None,
|
|
cursor: None,
|
|
limit: 50,
|
|
},
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
// 1. Inbox page.
|
|
let t0 = Instant::now();
|
|
let page = db
|
|
.read(|conn| {
|
|
repo::threads::list(
|
|
conn,
|
|
&repo::threads::ListArgs {
|
|
view: View::Inbox,
|
|
tab: None,
|
|
account_id: None,
|
|
account_ids: None,
|
|
folder_id: None,
|
|
cursor: None,
|
|
limit: 50,
|
|
},
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let list_ms = t0.elapsed().as_millis();
|
|
assert_eq!(page.threads.len(), 50);
|
|
|
|
// 2. Exact sidebar total without materializing every thread summary.
|
|
let t0 = Instant::now();
|
|
let inbox_count = db
|
|
.read(|conn| {
|
|
repo::threads::count(
|
|
conn,
|
|
&repo::threads::ListArgs {
|
|
view: View::Inbox,
|
|
tab: None,
|
|
account_id: None,
|
|
account_ids: None,
|
|
folder_id: None,
|
|
cursor: None,
|
|
limit: 1,
|
|
},
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let count_ms = t0.elapsed().as_millis();
|
|
assert_eq!(inbox_count, THREADS as usize);
|
|
|
|
// All native sidebar badges are refreshed together after mutations.
|
|
let t0 = Instant::now();
|
|
let badges = db
|
|
.read(|conn| {
|
|
repo::counts::mailbox_badge_counts(
|
|
conn,
|
|
flectar_mail_core::models::MailGrouping::Conversations,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let badge_ms = t0.elapsed().as_millis();
|
|
assert_eq!(badges.len(), 1);
|
|
assert_eq!(badges[0].inbox, THREADS / 3);
|
|
|
|
// 3. Paginated deep fetch (cursor into the middle).
|
|
let t0 = Instant::now();
|
|
let deep = db
|
|
.read({
|
|
let cursor = page.threads.last().unwrap().last_message_at;
|
|
move |conn| {
|
|
repo::threads::list(
|
|
conn,
|
|
&repo::threads::ListArgs {
|
|
view: View::Inbox,
|
|
tab: None,
|
|
account_id: None,
|
|
account_ids: None,
|
|
folder_id: None,
|
|
cursor: Some(ThreadCursor {
|
|
last_message_at: cursor - 500_000_000,
|
|
thread_id: i64::MAX,
|
|
}),
|
|
limit: 50,
|
|
},
|
|
)
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let deep_ms = t0.elapsed().as_millis();
|
|
assert_eq!(deep.threads.len(), 50);
|
|
|
|
// 4. FTS search across all 100k bodies.
|
|
let t0 = Instant::now();
|
|
let hits = db
|
|
.read(|conn| {
|
|
let q = flectar_mail_core::search::parse("quarterly numbers");
|
|
repo::search::search(conn, &q, 50)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let search_ms = t0.elapsed().as_millis();
|
|
assert!(!hits.is_empty(), "search should hit");
|
|
|
|
// 5. Chronological FTS across the complete result set, matching the mail
|
|
// list UI path. "update" appears in every subject, so this guards the
|
|
// worst-case cost of ordering all matching threads by newest message.
|
|
let t0 = Instant::now();
|
|
let chronological = db
|
|
.read(|conn| {
|
|
let q = flectar_mail_core::search::parse("update");
|
|
repo::search::chronological(conn, &q, 50)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let chronological_ms = t0.elapsed().as_millis();
|
|
assert_eq!(chronological.len(), 50);
|
|
|
|
// 6. Search with operator filter.
|
|
let t0 = Instant::now();
|
|
let hits2 = db
|
|
.read(|conn| {
|
|
let q = flectar_mail_core::search::parse("from:sender42 meeting");
|
|
repo::search::search(conn, &q, 50)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let op_ms = t0.elapsed().as_millis();
|
|
let _ = hits2;
|
|
|
|
// 7. Full hybrid pipeline (lexical + OR fallback + RRF + personal boosts
|
|
// + hydration) - the path the search screen hits per keystroke.
|
|
let t0 = Instant::now();
|
|
let hits3 = db
|
|
.read(|conn| {
|
|
let q = flectar_mail_core::search::parse("quarterly numbers");
|
|
let lex = repo::search::lexical_thread_ids(conn, &q, repo::search::candidate_cap(60))?;
|
|
repo::search::fuse(conn, &q, lex, &[], 60)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let hybrid_ms = t0.elapsed().as_millis();
|
|
assert!(!hits3.is_empty());
|
|
|
|
eprintln!(
|
|
"perf: inbox page {list_ms}ms · inbox count {count_ms}ms · live badges {badge_ms}ms · deep page {deep_ms}ms · fts {search_ms}ms · chronological fts {chronological_ms}ms · fts+operator {op_ms}ms · hybrid {hybrid_ms}ms"
|
|
);
|
|
assert!(list_ms < 50, "inbox page took {list_ms}ms (budget 50ms)");
|
|
assert!(
|
|
count_ms < 100,
|
|
"inbox count took {count_ms}ms (budget 100ms)"
|
|
);
|
|
assert!(
|
|
badge_ms < 150,
|
|
"live sidebar badges took {badge_ms}ms (budget 150ms)"
|
|
);
|
|
assert!(deep_ms < 50, "deep page took {deep_ms}ms (budget 50ms)");
|
|
assert!(search_ms < 100, "search took {search_ms}ms (budget 100ms)");
|
|
assert!(
|
|
chronological_ms < 150,
|
|
"chronological search took {chronological_ms}ms (budget 150ms)"
|
|
);
|
|
assert!(op_ms < 150, "operator search took {op_ms}ms (budget 150ms)");
|
|
assert!(
|
|
hybrid_ms < 150,
|
|
"hybrid search took {hybrid_ms}ms (budget 150ms)"
|
|
);
|
|
}
|