perf(round25): in-place encrypted decrypt, dedup hash move, dead folder-Query, playlist N+1 fold, contact vcard over-fetch
Five benchmark-gated optimizations from a fresh six-way audit (benches/ROUND25.md), each with a BEFORE/AFTER gate that rolls back if AFTER does not beat BEFORE: - M1 EncryptedBlobBackend::decrypt_bytes: replace split_off (a fresh Vec + full ciphertext memcpy on every decrypted chunk, contradicting its own "in place" doc) with in-place detached decrypt + a zero-copy Bytes::slice past the nonce. Peak RAM per read drops from ~2x to ~1x the payload (-262KB/op at 256KiB; scales with blob size). Plaintext byte-identical; tamper/wrong-key tests pass. - M2 delta commit: move-unzip the owned chunk list instead of a third per-occurrence hash clone (-4000 allocs on a 4000-chunk commit). - M3 folder ZIP download: drop the dead Query<HashMap> extractor it never read (byte-identical response; 5->0 allocs/request). - Q1 public-playlist listing: fold the per-playlist COUNT(*) N+1 into one LEFT JOIN ... GROUP BY via a new inherent repo method (101 -> 1 round-trips, 36x wall on a 100-playlist page). - Q2 contact REST listings (paginated/search/by-group): stop over-fetching the multi-KB vcard TEXT the ContactDto discards, via a shared lite row mapper and narrowed SELECTs (6.4x wall on 1000 contacts with 8KiB vcards). The whole-book vCard export and CardDAV sync paths keep the column. Adds bench_round25_micro (counting allocator tracking count+bytes) and bench_round25_queries (live Postgres), both with equivalence gates and a rollback exit(1). Verified: cargo fmt clean, cargo clippy -D warnings clean, cargo test --lib --features bench = 529 passed / 0 failed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L8gs91AhmazoxMsDcNk3KT
This commit is contained in:
@@ -140,19 +140,18 @@ impl MusicStoragePort for MusicStorageAdapter {
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<PlaylistDto>, DomainError> {
|
||||
// One `LEFT JOIN … GROUP BY` instead of 1 listing + N per-playlist
|
||||
// `COUNT(*)` round-trips (up to 101 at limit=100) — benches/ROUND25.md §Q1.
|
||||
let playlists = self
|
||||
.playlist_repository
|
||||
.list_public_playlists(limit, offset)
|
||||
.list_public_playlists_with_counts(limit, offset)
|
||||
.await?;
|
||||
let mut result = Vec::new();
|
||||
for playlist in playlists {
|
||||
let dto = PlaylistDto::from(playlist);
|
||||
let track_count = self
|
||||
.get_track_count(&uuid::Uuid::parse_str(&dto.id).unwrap())
|
||||
.await?;
|
||||
result.push(dto.with_track_info(track_count, 0));
|
||||
}
|
||||
Ok(result)
|
||||
Ok(playlists
|
||||
.into_iter()
|
||||
.map(|(playlist, track_count)| {
|
||||
PlaylistDto::from(playlist).with_track_info(track_count, 0)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn user_has_access(&self, playlist_id: &str, user_id: Uuid) -> Result<bool, DomainError> {
|
||||
|
||||
@@ -21,8 +21,28 @@ impl ContactPgRepository {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
/// Maps a database row to a Contact domain entity
|
||||
/// Maps a database row to a Contact domain entity (reads the `vcard` column).
|
||||
fn row_to_contact(row: &sqlx::postgres::PgRow) -> Result<Contact, DomainError> {
|
||||
Self::row_to_contact_with_vcard(row, row.get("vcard"))
|
||||
}
|
||||
|
||||
/// Maps a row whose SELECT omitted the `vcard` column — used by the REST
|
||||
/// listings (paginated / search / by-group) whose `ContactDto` drops vcard
|
||||
/// anyway, so the multi-KB vCard TEXT (which can embed a base64 PHOTO) is
|
||||
/// never SELECTed, shipped over the wire, or allocated (benches/ROUND25.md
|
||||
/// §Q2). The domain `Contact` keeps an empty vcard; these paths never
|
||||
/// re-emit it. Do NOT use for CardDAV sync / whole-book export, which need
|
||||
/// the round-trip vCard.
|
||||
fn row_to_contact_lite(row: &sqlx::postgres::PgRow) -> Result<Contact, DomainError> {
|
||||
Self::row_to_contact_with_vcard(row, String::new())
|
||||
}
|
||||
|
||||
/// Shared row → `Contact` mapper; `vcard` is supplied by the caller so the
|
||||
/// TEXT column can be omitted from listings that don't consume it.
|
||||
fn row_to_contact_with_vcard(
|
||||
row: &sqlx::postgres::PgRow,
|
||||
vcard: String,
|
||||
) -> Result<Contact, DomainError> {
|
||||
// Decode each JSONB column straight into its typed Vec via
|
||||
// `sqlx::types::Json<T>` (a single `serde_json::from_slice` pass over
|
||||
// the raw JSONB bytes) instead of `row.get::<serde_json::Value>` +
|
||||
@@ -63,7 +83,7 @@ impl ContactPgRepository {
|
||||
row.get::<Option<String>, _>("photo_url"),
|
||||
row.get("birthday"),
|
||||
row.get("anniversary"),
|
||||
row.get("vcard"),
|
||||
vcard,
|
||||
row.get("etag"),
|
||||
row.get("created_at"),
|
||||
row.get("updated_at"),
|
||||
@@ -366,7 +386,7 @@ impl ContactRepository for ContactPgRepository {
|
||||
SELECT
|
||||
id, address_book_id, uid, full_name, first_name, last_name, nickname,
|
||||
email, phone, address, organization, title, notes, photo_url,
|
||||
birthday, anniversary, vcard, etag, created_at, updated_at
|
||||
birthday, anniversary, etag, created_at, updated_at
|
||||
FROM carddav.contacts
|
||||
WHERE address_book_id = $1
|
||||
ORDER BY full_name, first_name, last_name
|
||||
@@ -387,7 +407,7 @@ impl ContactRepository for ContactPgRepository {
|
||||
|
||||
let mut contacts = Vec::with_capacity(rows.len());
|
||||
for row in &rows {
|
||||
contacts.push(Self::row_to_contact(row)?);
|
||||
contacts.push(Self::row_to_contact_lite(row)?);
|
||||
}
|
||||
Ok(contacts)
|
||||
}
|
||||
@@ -429,7 +449,7 @@ impl ContactRepository for ContactPgRepository {
|
||||
SELECT
|
||||
c.id, c.address_book_id, c.uid, c.full_name, c.first_name, c.last_name, c.nickname,
|
||||
c.email, c.phone, c.address, c.organization, c.title, c.notes, c.photo_url,
|
||||
c.birthday, c.anniversary, c.vcard, c.etag, c.created_at, c.updated_at
|
||||
c.birthday, c.anniversary, c.etag, c.created_at, c.updated_at
|
||||
FROM carddav.contacts c
|
||||
INNER JOIN carddav.group_memberships m ON c.id = m.contact_id
|
||||
WHERE m.group_id = $1
|
||||
@@ -445,7 +465,7 @@ impl ContactRepository for ContactPgRepository {
|
||||
|
||||
let mut contacts = Vec::with_capacity(rows.len());
|
||||
for row in &rows {
|
||||
contacts.push(Self::row_to_contact(row)?);
|
||||
contacts.push(Self::row_to_contact_lite(row)?);
|
||||
}
|
||||
Ok(contacts)
|
||||
}
|
||||
@@ -462,9 +482,9 @@ impl ContactRepository for ContactPgRepository {
|
||||
SELECT
|
||||
id, address_book_id, uid, full_name, first_name, last_name, nickname,
|
||||
email, phone, address, organization, title, notes, photo_url,
|
||||
birthday, anniversary, vcard, etag, created_at, updated_at
|
||||
birthday, anniversary, etag, created_at, updated_at
|
||||
FROM carddav.contacts
|
||||
WHERE address_book_id = $1
|
||||
WHERE address_book_id = $1
|
||||
AND (
|
||||
full_name ILIKE $2
|
||||
OR first_name ILIKE $2
|
||||
@@ -485,7 +505,7 @@ impl ContactRepository for ContactPgRepository {
|
||||
|
||||
let mut contacts = Vec::with_capacity(rows.len());
|
||||
for row in &rows {
|
||||
contacts.push(Self::row_to_contact(row)?);
|
||||
contacts.push(Self::row_to_contact_lite(row)?);
|
||||
}
|
||||
Ok(contacts)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,21 @@ struct PlaylistRow {
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// A public playlist row carrying its aggregated track count, produced by the
|
||||
/// single `LEFT JOIN … GROUP BY` that replaces the per-playlist `COUNT(*)` N+1.
|
||||
#[derive(FromRow)]
|
||||
struct PublicPlaylistCountRow {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
owner_id: Uuid,
|
||||
is_public: bool,
|
||||
cover_file_id: Option<Uuid>,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
track_count: i64,
|
||||
}
|
||||
|
||||
#[derive(FromRow)]
|
||||
struct PlaylistItemRow {
|
||||
id: Uuid,
|
||||
@@ -79,6 +94,52 @@ impl PlaylistPgRepository {
|
||||
pub fn pool(&self) -> &PgPool {
|
||||
&self.pool
|
||||
}
|
||||
|
||||
/// Public playlists together with their track counts in a **single**
|
||||
/// round-trip. Replaces the adapter's 1 + N shape (one listing SELECT then
|
||||
/// one `SELECT COUNT(*) FROM audio.playlist_items` per returned playlist —
|
||||
/// up to 101 round-trips at `limit = 100`) with one `LEFT JOIN … GROUP BY`,
|
||||
/// backed by `idx_playlist_items_playlist_id` (benches/ROUND25.md §Q1).
|
||||
pub async fn list_public_playlists_with_counts(
|
||||
&self,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> PlaylistRepositoryResult<Vec<(Playlist, i64)>> {
|
||||
let rows = sqlx::query_as::<_, PublicPlaylistCountRow>(
|
||||
"SELECT p.id, p.name, p.description, p.owner_id, p.is_public, p.cover_file_id, \
|
||||
p.created_at, p.updated_at, COUNT(pi.id) AS track_count \
|
||||
FROM audio.playlists p \
|
||||
LEFT JOIN audio.playlist_items pi ON pi.playlist_id = p.id \
|
||||
WHERE p.is_public = TRUE \
|
||||
GROUP BY p.id \
|
||||
ORDER BY p.updated_at DESC LIMIT $1 OFFSET $2",
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::database_error(format!("Failed to list public playlists: {}", e))
|
||||
})?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
let track_count = row.track_count;
|
||||
Playlist::with_id(
|
||||
row.id,
|
||||
row.name,
|
||||
row.description,
|
||||
row.owner_id,
|
||||
row.is_public,
|
||||
row.cover_file_id,
|
||||
row.created_at,
|
||||
row.updated_at,
|
||||
)
|
||||
.map(|p| (p, track_count))
|
||||
.map_err(|e| DomainError::new(ErrorKind::InternalError, "Playlist", e.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaylistRepository for PlaylistPgRepository {
|
||||
|
||||
@@ -86,7 +86,7 @@ impl EncryptedBlobBackend {
|
||||
|
||||
/// Encrypt `data` into the on-disk layout: `[12-byte nonce][ciphertext + tag]`.
|
||||
///
|
||||
/// Single output buffer, mirroring the read side's `decrypt_in_place`:
|
||||
/// Single output buffer, mirroring the read side's in-place detached decrypt:
|
||||
/// the payload is copied exactly once and encrypted in place with the tag
|
||||
/// appended. The old shape let `cipher.encrypt` allocate a full ciphertext
|
||||
/// `Vec` and then copied it a second time behind the nonce — one extra
|
||||
@@ -106,22 +106,39 @@ fn encrypt_bytes(cipher: &Aes256Gcm, data: &[u8]) -> Result<Bytes, DomainError>
|
||||
|
||||
/// Decrypt the on-disk layout `[nonce][ciphertext + tag]` **in place**.
|
||||
///
|
||||
/// Consumes the encrypted buffer and reuses it for the plaintext, so peak
|
||||
/// RAM is one buffer — not ciphertext + plaintext side by side (which for
|
||||
/// legacy whole-file blobs would double a multi-hundred-MB allocation).
|
||||
/// Reuses the encrypted buffer for the plaintext, so peak RAM is one buffer —
|
||||
/// not ciphertext + plaintext side by side (which for legacy whole-file blobs
|
||||
/// would double a multi-hundred-MB allocation). The nonce and 16-byte GCM tag
|
||||
/// are lifted to the stack, the ciphertext body is decrypted in place via the
|
||||
/// detached API (mirroring the encrypt side's `encrypt_in_place_detached`), and
|
||||
/// the plaintext is returned as a zero-copy `Bytes::slice` past the nonce.
|
||||
///
|
||||
/// The prior shape did `encrypted.split_off(NONCE_SIZE)`, which allocated a
|
||||
/// fresh `Vec` and memcpy'd the entire ciphertext (up to a whole legacy blob)
|
||||
/// on every decrypted read — one full-payload allocation + copy the doc comment
|
||||
/// above claimed did not happen (benches/ROUND25.md §M1; ROUND11 §15 fixed only
|
||||
/// the encrypt side). Output plaintext is byte-identical.
|
||||
fn decrypt_bytes(cipher: &Aes256Gcm, mut encrypted: Vec<u8>) -> Result<Bytes, DomainError> {
|
||||
if encrypted.len() < NONCE_SIZE {
|
||||
let len = encrypted.len();
|
||||
if len < NONCE_SIZE + TAG_SIZE {
|
||||
return Err(DomainError::internal_error(
|
||||
"Encryption",
|
||||
"encrypted blob too short (missing nonce)",
|
||||
"encrypted blob too short (missing nonce/tag)",
|
||||
));
|
||||
}
|
||||
let mut ciphertext = encrypted.split_off(NONCE_SIZE); // `encrypted` keeps the nonce
|
||||
let nonce = Nonce::from_slice(&encrypted);
|
||||
// Nonce (first 12 bytes) and GCM tag (last 16 bytes) copied to the stack so
|
||||
// the middle can be borrowed mutably for in-place decryption.
|
||||
let mut nonce_buf = [0u8; NONCE_SIZE];
|
||||
nonce_buf.copy_from_slice(&encrypted[..NONCE_SIZE]);
|
||||
let nonce = Nonce::from_slice(&nonce_buf);
|
||||
let tag = aes_gcm::aead::Tag::<Aes256Gcm>::clone_from_slice(&encrypted[len - TAG_SIZE..]);
|
||||
cipher
|
||||
.decrypt_in_place(nonce, b"", &mut ciphertext)
|
||||
.decrypt_in_place_detached(nonce, b"", &mut encrypted[NONCE_SIZE..len - TAG_SIZE], &tag)
|
||||
.map_err(|e| DomainError::internal_error("Encryption", format!("decrypt failed: {e}")))?;
|
||||
Ok(Bytes::from(ciphertext))
|
||||
// Plaintext now lives at `encrypted[NONCE_SIZE..len - TAG_SIZE]`; drop the
|
||||
// tag and hand out a refcounted view past the nonce — no copy, no new alloc.
|
||||
encrypted.truncate(len - TAG_SIZE);
|
||||
Ok(Bytes::from(encrypted).slice(NONCE_SIZE..))
|
||||
}
|
||||
|
||||
/// Run a crypto closure inline for small payloads, on the blocking pool for
|
||||
|
||||
Reference in New Issue
Block a user