357 lines
12 KiB
Rust
357 lines
12 KiB
Rust
|
|
//! Photos timeline benchmark — full-library scan vs per-drive LATERAL top-N.
|
|||
|
|
//!
|
|||
|
|
//! `list_media_files` (file_blob_read_repository.rs) filters by
|
|||
|
|
//! `fi.drive_id IN (<grants subquery>)`, joins folders + file_metadata, and
|
|||
|
|
//! sorts globally by `media_sort_date DESC LIMIT k`. The doc comment claims
|
|||
|
|
//! `idx_files_media_timeline_by_drive` lets LIMIT stop the scan early, but
|
|||
|
|
//! the plan is a Nested Loop over the drive set feeding EVERY media row
|
|||
|
|
//! through a Hash Left Join into a top-N heapsort ABOVE the join — the
|
|||
|
|
//! index is drained to exhaustion on every page, so each timeline page
|
|||
|
|
//! costs O(library), not O(page).
|
|||
|
|
//!
|
|||
|
|
//! The AFTER shape materialises the accessible drive ids once, then does a
|
|||
|
|
//! `CROSS JOIN LATERAL (… ORDER BY media_sort_date DESC LIMIT k)` per drive
|
|||
|
|
//! — each LATERAL is one bounded index scan — and merges `drives × k` rows.
|
|||
|
|
//! The folders/file_metadata joins move OUTSIDE the top-N so only the k
|
|||
|
|
//! emitted rows pay them.
|
|||
|
|
//!
|
|||
|
|
//! Equivalence gate: page-by-page id sequences must be identical (the seed
|
|||
|
|
//! uses strictly distinct capture dates so ties cannot mask reordering).
|
|||
|
|
//!
|
|||
|
|
//! Run (needs Postgres up; reads DATABASE_URL from .env):
|
|||
|
|
//! cargo run --release --features bench --example bench_photos_timeline
|
|||
|
|
//! Tunables: BENCH_MEDIA (50000), BENCH_DRIVES (3), BENCH_PAGE (100),
|
|||
|
|
//! BENCH_PAGES (10), BENCH_REPS (3)
|
|||
|
|
|
|||
|
|
use std::env;
|
|||
|
|
use std::time::Instant;
|
|||
|
|
|
|||
|
|
use sqlx::PgPool;
|
|||
|
|
use sqlx::postgres::PgPoolOptions;
|
|||
|
|
use uuid::Uuid;
|
|||
|
|
|
|||
|
|
fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
|
|||
|
|
env::var(key)
|
|||
|
|
.ok()
|
|||
|
|
.and_then(|v| v.parse().ok())
|
|||
|
|
.unwrap_or(default)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async fn seed(pool: &PgPool, media: usize, drives: usize) -> (Uuid, Vec<Uuid>) {
|
|||
|
|
let caller = Uuid::new_v4();
|
|||
|
|
let mut drive_ids = Vec::with_capacity(drives);
|
|||
|
|
for d in 0..drives {
|
|||
|
|
let mut tx = pool.begin().await.expect("begin");
|
|||
|
|
let drive_id: Uuid = sqlx::query_scalar(
|
|||
|
|
"INSERT INTO storage.drives (kind, quota_bytes, policies)
|
|||
|
|
VALUES ('shared', NULL, '{\"include_in_photo_index\": true}'::jsonb)
|
|||
|
|
RETURNING id",
|
|||
|
|
)
|
|||
|
|
.fetch_one(&mut *tx)
|
|||
|
|
.await
|
|||
|
|
.expect("drive");
|
|||
|
|
let folder_id: Uuid = sqlx::query_scalar(
|
|||
|
|
"INSERT INTO storage.folders (name, path, lpath, drive_id)
|
|||
|
|
VALUES ($1, $2, $3::ltree, $4) RETURNING id",
|
|||
|
|
)
|
|||
|
|
.bind(format!("bench_photos_{d}"))
|
|||
|
|
.bind(format!("/bench_photos_{d}"))
|
|||
|
|
.bind(format!("bench_photos_{d}"))
|
|||
|
|
.bind(drive_id)
|
|||
|
|
.fetch_one(&mut *tx)
|
|||
|
|
.await
|
|||
|
|
.expect("folder");
|
|||
|
|
sqlx::query("UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2")
|
|||
|
|
.bind(folder_id)
|
|||
|
|
.bind(drive_id)
|
|||
|
|
.execute(&mut *tx)
|
|||
|
|
.await
|
|||
|
|
.expect("stamp");
|
|||
|
|
sqlx::query(
|
|||
|
|
"INSERT INTO storage.role_grants
|
|||
|
|
(subject_type, subject_id, resource_type, resource_id, role, granted_by)
|
|||
|
|
VALUES ('user', $1, 'drive', $2, 'viewer', $1)",
|
|||
|
|
)
|
|||
|
|
.bind(caller)
|
|||
|
|
.bind(drive_id)
|
|||
|
|
.execute(&mut *tx)
|
|||
|
|
.await
|
|||
|
|
.expect("grant");
|
|||
|
|
tx.commit().await.expect("commit");
|
|||
|
|
|
|||
|
|
// Strictly distinct capture dates (offset per drive) so the
|
|||
|
|
// equivalence gate cannot be masked by tie reordering.
|
|||
|
|
let per_drive = media / drives;
|
|||
|
|
sqlx::query(
|
|||
|
|
"INSERT INTO storage.files
|
|||
|
|
(name, folder_id, blob_hash, size, mime_type, drive_id, media_sort_date)
|
|||
|
|
SELECT 'IMG_' || LPAD(i::text, 8, '0') || '.jpg', $1,
|
|||
|
|
'benchphotos00000000000000000000000000000000000000000000000000000',
|
|||
|
|
2048, 'image/jpeg', $2,
|
|||
|
|
TIMESTAMPTZ '2026-01-01 00:00:00Z' - ((i * $4 + $5) || ' seconds')::interval
|
|||
|
|
FROM generate_series(1, $3) AS i",
|
|||
|
|
)
|
|||
|
|
.bind(folder_id)
|
|||
|
|
.bind(drive_id)
|
|||
|
|
.bind(per_drive as i32)
|
|||
|
|
.bind(drives as i32)
|
|||
|
|
.bind(d as i32)
|
|||
|
|
.execute(pool)
|
|||
|
|
.await
|
|||
|
|
.expect("files");
|
|||
|
|
drive_ids.push(drive_id);
|
|||
|
|
}
|
|||
|
|
sqlx::query("ANALYZE storage.files")
|
|||
|
|
.execute(pool)
|
|||
|
|
.await
|
|||
|
|
.ok();
|
|||
|
|
sqlx::query("ANALYZE storage.role_grants")
|
|||
|
|
.execute(pool)
|
|||
|
|
.await
|
|||
|
|
.ok();
|
|||
|
|
(caller, drive_ids)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type MediaRow = (
|
|||
|
|
String, // id::text
|
|||
|
|
String, // name
|
|||
|
|
Option<String>, // folder_id::text
|
|||
|
|
Option<String>, // fo.path
|
|||
|
|
i64, // size
|
|||
|
|
String, // mime_type
|
|||
|
|
i64, // created_at epoch
|
|||
|
|
i64, // updated_at epoch
|
|||
|
|
String, // blob_hash
|
|||
|
|
Option<Uuid>, // created_by
|
|||
|
|
Option<Uuid>, // updated_by
|
|||
|
|
i64, // sort_date epoch
|
|||
|
|
Option<i32>, // width
|
|||
|
|
Option<i32>, // height
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const GRANTS_SUBQ: &str = r#"
|
|||
|
|
SELECT d.id
|
|||
|
|
FROM storage.drives d
|
|||
|
|
JOIN storage.role_grants g
|
|||
|
|
ON g.resource_type = 'drive'
|
|||
|
|
AND g.resource_id = d.id
|
|||
|
|
WHERE (
|
|||
|
|
(g.subject_type = 'user' AND g.subject_id = $1)
|
|||
|
|
OR (g.subject_type = 'group' AND g.subject_id IN
|
|||
|
|
(SELECT storage.caller_group_ids($1)))
|
|||
|
|
)
|
|||
|
|
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
|||
|
|
AND (d.policies->>'include_in_photo_index')::boolean = true
|
|||
|
|
"#;
|
|||
|
|
|
|||
|
|
/// OLD shape — production SQL verbatim.
|
|||
|
|
async fn old_page(
|
|||
|
|
pool: &PgPool,
|
|||
|
|
caller: Uuid,
|
|||
|
|
before: Option<chrono::DateTime<chrono::Utc>>,
|
|||
|
|
limit: i64,
|
|||
|
|
) -> Vec<MediaRow> {
|
|||
|
|
let cursor_pred = if before.is_some() {
|
|||
|
|
"AND fi.media_sort_date < $2"
|
|||
|
|
} else {
|
|||
|
|
"AND $2::timestamptz IS NULL"
|
|||
|
|
};
|
|||
|
|
let sql = format!(
|
|||
|
|
r#"
|
|||
|
|
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
|
|||
|
|
fi.size, fi.mime_type,
|
|||
|
|
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
|||
|
|
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
|
|||
|
|
fi.blob_hash,
|
|||
|
|
fi.created_by, fi.updated_by,
|
|||
|
|
EXTRACT(EPOCH FROM fi.media_sort_date)::bigint AS sort_date,
|
|||
|
|
fm.width, fm.height
|
|||
|
|
FROM storage.files fi
|
|||
|
|
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
|||
|
|
LEFT JOIN storage.file_metadata fm ON fm.file_id = fi.id
|
|||
|
|
WHERE fi.drive_id IN ({GRANTS_SUBQ})
|
|||
|
|
AND NOT fi.is_trashed
|
|||
|
|
AND (fi.mime_type LIKE 'image/%' OR fi.mime_type LIKE 'video/%')
|
|||
|
|
{cursor_pred}
|
|||
|
|
ORDER BY fi.media_sort_date DESC
|
|||
|
|
LIMIT $3
|
|||
|
|
"#
|
|||
|
|
);
|
|||
|
|
sqlx::query_as(&sql)
|
|||
|
|
.bind(caller)
|
|||
|
|
.bind(before)
|
|||
|
|
.bind(limit)
|
|||
|
|
.fetch_all(pool)
|
|||
|
|
.await
|
|||
|
|
.expect("old page")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// NEW shape — accessible drives materialised once, per-drive LATERAL top-N
|
|||
|
|
/// on the timeline index, folders/metadata joined only on the emitted rows.
|
|||
|
|
async fn new_page(
|
|||
|
|
pool: &PgPool,
|
|||
|
|
caller: Uuid,
|
|||
|
|
before: Option<chrono::DateTime<chrono::Utc>>,
|
|||
|
|
limit: i64,
|
|||
|
|
) -> Vec<MediaRow> {
|
|||
|
|
let cursor_pred = if before.is_some() {
|
|||
|
|
"AND fi.media_sort_date < $2"
|
|||
|
|
} else {
|
|||
|
|
"AND $2::timestamptz IS NULL"
|
|||
|
|
};
|
|||
|
|
let sql = format!(
|
|||
|
|
r#"
|
|||
|
|
WITH accessible AS MATERIALIZED ({GRANTS_SUBQ})
|
|||
|
|
SELECT top.id::text, top.name, top.folder_id::text, fo.path,
|
|||
|
|
top.size, top.mime_type,
|
|||
|
|
EXTRACT(EPOCH FROM top.created_at)::bigint,
|
|||
|
|
EXTRACT(EPOCH FROM top.updated_at)::bigint,
|
|||
|
|
top.blob_hash,
|
|||
|
|
top.created_by, top.updated_by,
|
|||
|
|
EXTRACT(EPOCH FROM top.media_sort_date)::bigint AS sort_date,
|
|||
|
|
fm.width, fm.height
|
|||
|
|
FROM (
|
|||
|
|
SELECT fi.*
|
|||
|
|
FROM accessible a
|
|||
|
|
CROSS JOIN LATERAL (
|
|||
|
|
SELECT fi.*
|
|||
|
|
FROM storage.files fi
|
|||
|
|
WHERE fi.drive_id = a.id
|
|||
|
|
AND NOT fi.is_trashed
|
|||
|
|
AND (fi.mime_type LIKE 'image/%' OR fi.mime_type LIKE 'video/%')
|
|||
|
|
{cursor_pred}
|
|||
|
|
ORDER BY fi.media_sort_date DESC
|
|||
|
|
LIMIT $3
|
|||
|
|
) fi
|
|||
|
|
ORDER BY fi.media_sort_date DESC
|
|||
|
|
LIMIT $3
|
|||
|
|
) top
|
|||
|
|
LEFT JOIN storage.folders fo ON fo.id = top.folder_id
|
|||
|
|
LEFT JOIN storage.file_metadata fm ON fm.file_id = top.id
|
|||
|
|
ORDER BY top.media_sort_date DESC
|
|||
|
|
"#
|
|||
|
|
);
|
|||
|
|
sqlx::query_as(&sql)
|
|||
|
|
.bind(caller)
|
|||
|
|
.bind(before)
|
|||
|
|
.bind(limit)
|
|||
|
|
.fetch_all(pool)
|
|||
|
|
.await
|
|||
|
|
.expect("new page")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn median(mut xs: Vec<f64>) -> f64 {
|
|||
|
|
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
|||
|
|
xs[xs.len() / 2]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Walk `pages` cursor pages; returns (id sequence, per-page ms).
|
|||
|
|
async fn walk(
|
|||
|
|
pool: &PgPool,
|
|||
|
|
caller: Uuid,
|
|||
|
|
page: i64,
|
|||
|
|
pages: usize,
|
|||
|
|
new_shape: bool,
|
|||
|
|
) -> (Vec<String>, Vec<f64>) {
|
|||
|
|
let mut before: Option<chrono::DateTime<chrono::Utc>> = None;
|
|||
|
|
let mut ids = Vec::new();
|
|||
|
|
let mut times = Vec::new();
|
|||
|
|
for _ in 0..pages {
|
|||
|
|
let t = Instant::now();
|
|||
|
|
let rows = if new_shape {
|
|||
|
|
new_page(pool, caller, before, page).await
|
|||
|
|
} else {
|
|||
|
|
old_page(pool, caller, before, page).await
|
|||
|
|
};
|
|||
|
|
times.push(t.elapsed().as_secs_f64() * 1000.0);
|
|||
|
|
if rows.is_empty() {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
// Cursor semantics mirror production: whole-second epoch of the last
|
|||
|
|
// row (list_media_files hands the epoch back to the client).
|
|||
|
|
let last_epoch = rows.last().unwrap().11;
|
|||
|
|
before = chrono::DateTime::from_timestamp(last_epoch, 0);
|
|||
|
|
ids.extend(rows.into_iter().map(|r| r.0));
|
|||
|
|
}
|
|||
|
|
(ids, times)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[tokio::main(flavor = "multi_thread")]
|
|||
|
|
async fn main() {
|
|||
|
|
dotenvy::dotenv().ok();
|
|||
|
|
let url = env::var("DATABASE_URL").expect("set DATABASE_URL");
|
|||
|
|
let media: usize = env_or("BENCH_MEDIA", 50_000);
|
|||
|
|
let drives: usize = env_or("BENCH_DRIVES", 3);
|
|||
|
|
let page: i64 = env_or("BENCH_PAGE", 100);
|
|||
|
|
let pages: usize = env_or("BENCH_PAGES", 10);
|
|||
|
|
let reps: usize = env_or("BENCH_REPS", 3);
|
|||
|
|
|
|||
|
|
let pool = PgPoolOptions::new()
|
|||
|
|
.max_connections(5)
|
|||
|
|
.connect(&url)
|
|||
|
|
.await
|
|||
|
|
.expect("connect");
|
|||
|
|
println!("seeding {media} media rows across {drives} drives (one-time)…");
|
|||
|
|
let (caller, drive_ids) = seed(&pool, media, drives).await;
|
|||
|
|
|
|||
|
|
let (ref_ids, _) = walk(&pool, caller, page, pages, false).await;
|
|||
|
|
assert_eq!(
|
|||
|
|
ref_ids.len(),
|
|||
|
|
(page as usize) * pages,
|
|||
|
|
"reference walk size"
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
println!("\n# {pages} timeline pages of {page} over a {media}-photo library ({drives} drives)");
|
|||
|
|
println!(
|
|||
|
|
"{:<8} {:>11} {:>11} {:>8}",
|
|||
|
|
"mode", "total ms", "p50 ms/pg", "vs OLD"
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
let mut failures = 0usize;
|
|||
|
|
let mut base: Option<f64> = None;
|
|||
|
|
for (mode, new_shape) in [("OLD", false), ("NEW", true)] {
|
|||
|
|
let mut totals = Vec::with_capacity(reps);
|
|||
|
|
let mut per_page: Vec<f64> = Vec::new();
|
|||
|
|
for _ in 0..reps {
|
|||
|
|
let t = Instant::now();
|
|||
|
|
let (ids, times) = walk(&pool, caller, page, pages, new_shape).await;
|
|||
|
|
totals.push(t.elapsed().as_secs_f64() * 1000.0);
|
|||
|
|
if ids != ref_ids {
|
|||
|
|
eprintln!("EQUIVALENCE FAILURE: {mode} walk drained different ids");
|
|||
|
|
failures += 1;
|
|||
|
|
}
|
|||
|
|
per_page = times;
|
|||
|
|
}
|
|||
|
|
let ms = median(totals);
|
|||
|
|
let speedup = base
|
|||
|
|
.map(|b| format!("{:.1}x", b / ms))
|
|||
|
|
.unwrap_or_else(|| "1.0x".into());
|
|||
|
|
if base.is_none() {
|
|||
|
|
base = Some(ms);
|
|||
|
|
}
|
|||
|
|
println!(
|
|||
|
|
"{:<8} {:>11.1} {:>11.2} {:>8}",
|
|||
|
|
mode,
|
|||
|
|
ms,
|
|||
|
|
median(per_page.clone()),
|
|||
|
|
speedup
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for d in drive_ids {
|
|||
|
|
let _ = sqlx::query("DELETE FROM storage.drives WHERE id = $1")
|
|||
|
|
.bind(d)
|
|||
|
|
.execute(&pool)
|
|||
|
|
.await;
|
|||
|
|
}
|
|||
|
|
let _ = sqlx::query("DELETE FROM storage.role_grants WHERE subject_id = $1")
|
|||
|
|
.bind(caller)
|
|||
|
|
.execute(&pool)
|
|||
|
|
.await;
|
|||
|
|
|
|||
|
|
if failures > 0 {
|
|||
|
|
eprintln!("\n{failures} equivalence failures — the NEW shape is NOT safe to adopt");
|
|||
|
|
std::process::exit(1);
|
|||
|
|
}
|
|||
|
|
}
|