perf(round29): cache-serve borrow-probe, NC REPORT href buffer, auth per-req allocs, DB over-fetch
Seven behaviour-preserving allocation / copy / bandwidth cuts, each behind a counting-allocator BEFORE/AFTER gate that exit(1)s unless AFTER allocates strictly fewer than BEFORE (benches/ROUND29.md, examples/bench_round29_micro.rs). - [B] Content-cache serve fast path (optimized_inner Tier 1 + get_file_range_preloaded — the video-scrub hot path): probe the cache with a borrow first and build the owned get_or_load args (quoted-etag / key / id Strings) only on a miss, instead of allocating them before every probe and discarding them on a hit. 6 -> 0 allocs per cache hit. Splits get_or_load into get + load_and_cache so the miss path is not re-probed and the hit/miss stat counters stay byte-identical. Also drops the unconditional content_hash/name clones that ran for the >=10 MB streaming tier that used neither. - [A] NextCloud REPORT emit loops: per-row href String (and format! per folder row) -> one reused href_buf via nc_href_into / nc_collection_href_into with the URL-encoded user computed once per page. 1497 fewer allocs on a 500-row page. - [C] read_full: a single-frame blob is returned zero-copy instead of a second whole-payload memcpy into a fresh BytesMut; multi-frame path unchanged. - [D] login-lockout key: to_lowercase()+format! -> one pre-sized ASCII buffer (non-ASCII keeps str::to_lowercase). 3 -> 1 alloc/req, byte-identical key. - [E] NC composite-username parse: owned clone/to_string -> &str borrow of the already-owned raw_username. 1 -> 0 alloc on the common no-marker path. - [F] get_contacts_in_group: stop SELECTing the discarded multi-KB vcard column (the live method ROUND25 §Q2 missed; ContactDto has no vcard field). - [G] count_admin_users: add count_users_by_role -> scalar COUNT(*) instead of hydrating every admin's full row (incl. up-to-512 KiB avatar + ui_preferences JSONB) only to .len() it, on a bootstrap-polled status endpoint. All seven gates pass; cargo fmt --check and cargo clippy --all-features --all-targets -D warnings clean. §F/§G additionally validated against a live PostgreSQL 16 with the full migration set (query validity, result equivalence, 600000 -> 8 byte wire delta on the admin count). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LhpDZxSQTAGnAqCHUdtG5N
This commit is contained in:
@@ -196,10 +196,10 @@ impl ContactGroupRepository for ContactGroupPgRepository {
|
||||
) -> ContactRepositoryResult<Vec<Contact>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
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 gm ON c.id = gm.contact_id
|
||||
WHERE gm.group_id = $1
|
||||
@@ -253,7 +253,13 @@ impl ContactGroupRepository for ContactGroupPgRepository {
|
||||
row.get::<Option<String>, _>("photo_url"),
|
||||
row.get("birthday"),
|
||||
row.get("anniversary"),
|
||||
row.get("vcard"),
|
||||
// vcard column intentionally NOT selected — the sole live caller
|
||||
// (`list_contacts_in_group`) maps to `ContactDto`, which has no
|
||||
// vcard field, so fetching the multi-KB serialized vCard (with an
|
||||
// embedded base64 PHOTO) only to drop it wastes bandwidth + a
|
||||
// per-row String. Mirrors `row_to_contact_lite` (benches/ROUND29.md
|
||||
// §F / ROUND25 §Q2, applied to the LIVE group method this time).
|
||||
String::new(),
|
||||
row.get("etag"),
|
||||
row.get("created_at"),
|
||||
row.get("updated_at"),
|
||||
|
||||
@@ -810,6 +810,15 @@ impl UserRepository for UserPgRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Counts users by role with a scalar `COUNT(*)` — no row hydration.
|
||||
async fn count_users_by_role(&self, role: &str) -> UserRepositoryResult<i64> {
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM auth.users WHERE role::text = $1")
|
||||
.bind(role)
|
||||
.fetch_one(&*self.pool)
|
||||
.await
|
||||
.map_err(Self::map_sqlx_error)
|
||||
}
|
||||
|
||||
/// Lists users by role
|
||||
async fn list_users_by_role(&self, role: &str) -> UserRepositoryResult<Vec<User>> {
|
||||
let rows = sqlx::query(
|
||||
@@ -1157,6 +1166,12 @@ impl UserStoragePort for UserPgRepository {
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn count_users_by_role(&self, role: &str) -> Result<i64, DomainError> {
|
||||
UserRepository::count_users_by_role(self, role)
|
||||
.await
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn delete_user(&self, user_id: Uuid) -> Result<(), DomainError> {
|
||||
UserRepository::delete_user(self, user_id)
|
||||
.await
|
||||
|
||||
@@ -182,7 +182,30 @@ impl FileContentCache {
|
||||
if let Some(hit) = self.get(&cache_key).await {
|
||||
return Ok(hit);
|
||||
}
|
||||
self.load_and_cache(cache_key, etag, content_type, load)
|
||||
.await
|
||||
}
|
||||
|
||||
/// The populate-on-miss half of [`Self::get_or_load`], with single-flight
|
||||
/// coalescing but WITHOUT the leading `get` probe.
|
||||
///
|
||||
/// Hot read paths that have *already* probed the cache with [`Self::get`]
|
||||
/// (a borrow) call this directly on the miss branch — they then build the
|
||||
/// owned `cache_key` / `etag` / `content_type` (each a heap allocation)
|
||||
/// only when they are actually needed to populate, so a cache HIT allocates
|
||||
/// none of them (benches/ROUND29.md §B). Because the caller's own `get`
|
||||
/// already counted the hit/miss, this method does not re-probe — keeping the
|
||||
/// hit/miss stat counts identical to a single `get_or_load` call.
|
||||
pub async fn load_and_cache<F>(
|
||||
&self,
|
||||
cache_key: String,
|
||||
etag: Arc<str>,
|
||||
content_type: Arc<str>,
|
||||
load: F,
|
||||
) -> Result<(Bytes, Arc<str>, Arc<str>), DomainError>
|
||||
where
|
||||
F: Future<Output = Result<Bytes, DomainError>>,
|
||||
{
|
||||
// Slow path: coalesce concurrent misses into a single `load`.
|
||||
let entry = self
|
||||
.cache
|
||||
|
||||
@@ -68,7 +68,24 @@ impl LoginLockoutService {
|
||||
fn key(username: &str, client_ip: &str) -> String {
|
||||
// `|` is not valid in either a username or an IP literal so it makes
|
||||
// the username/ip boundary unambiguous.
|
||||
format!("{}|{}", username.to_lowercase(), client_ip)
|
||||
//
|
||||
// The lowercased composite is written into ONE pre-sized buffer instead
|
||||
// of the `to_lowercase()` (alloc) + `format!` (alloc) two-step. App
|
||||
// passwords authenticate with an already-lowercase ASCII username in
|
||||
// ~all traffic, so the fast branch covers it; the rare non-ASCII branch
|
||||
// keeps `str::to_lowercase` for exact Unicode (e.g. final-sigma)
|
||||
// semantics. Byte-identical key either way (benches/ROUND29.md §D).
|
||||
if username.is_ascii() {
|
||||
let mut k = String::with_capacity(username.len() + 1 + client_ip.len());
|
||||
for &b in username.as_bytes() {
|
||||
k.push(b.to_ascii_lowercase() as char);
|
||||
}
|
||||
k.push('|');
|
||||
k.push_str(client_ip);
|
||||
k
|
||||
} else {
|
||||
format!("{}|{}", username.to_lowercase(), client_ip)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the (account, IP) pair is currently locked.
|
||||
|
||||
Reference in New Issue
Block a user