Benchmark-gated, same rule as ROUND2-22: BEFORE/AFTER with a value-equivalence gate and rollback-on-regression. Two harnesses — bench_round23_micro (no Postgres; deterministic allocation gate) and bench_round23_queries (live Postgres; p50 latency + strict equivalence gate against seeded fixtures). See benches/ROUND23.md. - J1: contact_pg_repository::row_to_contact (+ the inlined contact_group sibling) decode the 3 JSONB columns via sqlx::types::Json<T> (one from_slice pass) instead of row.get::<serde_json::Value> + from_value (a throwaway Value DOM per column, walked a second time). Per contact row of every list / multiget / CardDAV sync. Micro 84 -> 33 allocs/op (2.15x); PG 3794 -> 2360 ns/contact (1.61x) on 500 real rows. - J2: DrivePolicies::from_value deserializes straight from the borrow (T::deserialize(&Value)) instead of from_value(value.clone()) — dropping the full-DOM clone on every drive-policy read (move/copy, share, grant); one-line body change, all 7 callers unchanged. Micro 5 -> 0 allocs/op (11.51x). - P1: get_user_profile overlaps the two independent caller+target reads with tokio::join! (self-case still a single fetch; caller-error precedence preserved via caller_res? first) instead of two serial round-trips. PG 577 -> 312 us/call (1.85x). - G1: subject_group remove_member computes the child's transitive-user recursive CTE once and reuses it for both the would-empty pre-check and the cache invalidation, instead of running the identical CTE twice (the edge delete is above the child, so its descendants can't change). PG 829 -> 412 us/removal (2.01x). - U1: dedup_service (store_loose_chunks final registration + the ingest run_rollback) reshapes the owned, dead-after Vec<(String,i64)> via into_iter().unzip() instead of cloning every 64-byte hash for the sync_blobs(&[String]) + UNNEST bind. Micro 256 -> 0 hash clones. Verified: cargo clippy --features bench --all-targets -D warnings clean, cargo fmt --all --check clean, cargo test --lib --features bench = 529 passed / 0 failed. The PG benches run against a local PostgreSQL 16 (schema applied from migrations/); every equivalence gate passes. The download_zip per-item N+1 (the audit's highest raw-latency candidate) is deferred to a dedicated pass: its fix moves the sole authorization inside the stream call, so it needs an AuthZ-ordering + anti-enumeration proof, not a perf banner. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DKyQ4AnYtgp1JtjzweyMeo
12 KiB
Round 23 — Postgres query-shape pass: typed JSONB decode, drive-policy borrow-deserialize, user-profile join!, subject-group CTE reuse, dedup unzip
Benchmark-gated, same rule as ROUND2–22: every change ships with a BEFORE/AFTER benchmark and a value equivalence gate; an AFTER that doesn't beat its BEFORE is rolled back (never applied). This round is the PostgreSQL pass — the candidates the earlier rounds deferred as "needs a database to bench" — so it ships two harnesses:
bench_round23_micro(no Postgres) — the deterministic allocation gate for the decode/clone candidates (counting global allocator; a non-winning AFTERstd::process::exit(1)s withGATE FAIL … rollback).bench_round23_queries(live Postgres) — end-to-end p50 latency + a strict equivalence gate (identical decoded rows / ids / user-sets from BEFORE and AFTER; a mismatch exits 1) against seeded fixtures.
Reproduce (the queries harness reads DATABASE_URL from .env):
cargo run --release --features bench --example bench_round23_micro
cargo run --release --features bench --example bench_round23_queries
Summary
| # | change | metric | before → after |
|---|---|---|---|
| J1 | contact_pg_repository::row_to_contact (+ the inlined contact_group_pg_repository sibling) decoded each of the 3 JSONB columns (email/phone/address) with row.get::<serde_json::Value> + serde_json::from_value::<Vec<Dto>> — a throwaway Value DOM built per column and then walked a second time to produce the typed Vec. Now row.try_get::<sqlx::types::Json<Vec<Dto>>> decodes the JSONB bytes straight into the typed Vec in one from_slice pass, no DOM. Runs per contact row of every contact list / multiget / CardDAV sync. |
micro allocs · PG p50 | 84 → 33 allocs/op (2.15× wall) · 3794 → 2360 ns/contact (1.61×) |
| J2 | DrivePolicies::from_value did serde_json::from_value(value.clone()) — cloning the entire policies Value DOM before walking it, on every drive-policy read (move/copy, shared-link creation, grant). Now DrivePolicies::deserialize(value) deserializes straight from the borrow (serde_json's Deserializer for &Value), no clone — a one-line body change, byte-identical, all 7 call sites unchanged. |
micro allocs | 5 → 0 allocs/op (11.51× wall) |
| P1 | AuthApplicationService::get_user_profile issued two independent, serial get_user_by_id point reads (caller then target; the self-case short-circuit compares input UUIDs, not fetched data). Now the self-case does a single fetch and the non-self path overlaps caller+target with tokio::join! (caller_res? first preserves the caller-error precedence). |
PG p50 | 577 → 312 µs/call (1.85×) |
| G1 | SubjectGroupService::remove_member ran the child group's transitive-user recursive CTE twice for a nested Group removal — once in the would-empty pre-check, once in invalidation_targets after the remove. The edge delete is above the child, so its descendants can't change; now the CTE runs once and the result is reused for both. |
PG p50 | 829 → 412 µs/removal (2.01×) |
| U1 | dedup_service (store_loose_chunks final registration + the ingest run_rollback) built Vec<String>/Vec<i64> by cloning every 64-byte hash out of an owned, dead-after Vec<(String,i64)> purely to reshape for sync_blobs(&[String]) + the UNNEST bind. Now into_iter().unzip() moves the hashes out — no per-hash content copy. |
micro allocs | 256 → 0 hash clones (1283 → 1027 allocs/op on a 256-chunk batch) |
The micro allocs/op is the deterministic gate (identical run to run); the PG p50 is single-machine, warm-pool, and noise-bounded. Every section carries a value-equivalence gate; the shipped source matches each AFTER arm.
[J1] Contact JSONB — typed Json<T> decode, no intermediate Value DOM
row_to_contact (reached by 11 call sites — every contact GET / list /
paginated list / multiget / CardDAV cursor stream / search / by-email /
by-group / create+update RETURNING) and the identical inlined block in
contact_group_pg_repository::get_contacts_in_group both did:
let email_json: JsonValue = row.get("email"); // sqlx JSONB → Value DOM (alloc tree)
let emails = serde_json::from_value::<Vec<EmailPersistenceDto>>(email_json) // walk the DOM again
.map(emails_from_persistence).unwrap_or_default();
// … same for phone, address
sqlx::types::Json<T> decodes the raw JSONB bytes with a single
serde_json::from_slice::<T> (sqlx-core 0.8.6 types/json.rs), skipping the
Value tree entirely:
let emails = row
.try_get::<sqlx::types::Json<Vec<EmailPersistenceDto>>, _>("email")
.map(|j| emails_from_persistence(j.0))
.unwrap_or_default();
try_get (not get) preserves the exact malformed-shape fallback — get
would panic on a decode error, whereas the old from_value(...).unwrap_or_default()
tolerated it. The columns are JSONB NOT NULL DEFAULT '[]', so SQL NULL never
occurs. Byte-identical: both paths run the same derived Deserialize<Vec<Dto>>
over the same bytes — the bench_round23_queries §Q1 gate asserts the two
decode the 500 seeded contacts field-for-field identically. The micro shows the
3 discarded DOMs/row (84 → 33 allocs); on the real rows the decode is 1.61×.
[J2] Drive policies — deserialize from the borrow, don't clone the DOM
DrivePolicies::from_value(value: &serde_json::Value) is called on every
drive-policy read (get_policies_for_file/_folder,
get_drive_id_and_policies_for_*, update_policies RETURNING, the ACL engine's
enforcement read, and Drive::typed_policies). It built the typed struct with
serde_json::from_value(value.clone()) — a full clone of the policies DOM
purely because from_value consumes its argument. serde_json implements
Deserializer for &Value, so the struct can be built straight from the
borrow:
use serde::Deserialize as _;
Self::deserialize(value).unwrap_or_default() // was: serde_json::from_value(value.clone())
Byte-identical (same derived Deserialize, same lenient unwrap_or_default
fallback that keeps unknown keys on disk), a one-line body change, and every
caller keeps its &Value argument unchanged — so typed_policies(&self)
(which only has a borrow of self.policies) also stops cloning. The micro
(a realistic bag with a preserved unknown key) drops 5 → 0 allocs/op.
[P1] get_user_profile — overlap the two independent reads with join!
The profile lookup fetched the caller and the target user in two serial
round-trips. The self-case (caller_id == target_id) is decided by comparing
the input UUIDs, so on the common non-self path the two reads are
independent — query 2 never depends on query 1. AFTER:
if caller_id == target_id { // self: one fetch, unchanged
let caller = self.user_storage.get_user_by_id(caller_id).await?;
return Ok(UserDto::from(caller));
}
let (caller_res, target_res) = tokio::join!( // non-self: overlap
self.user_storage.get_user_by_id(caller_id),
self.user_storage.get_user_by_id(target_id));
let caller = caller_res?; // caller-error precedence preserved
let target = match target_res { … }; // identical NotFound→anonymized-404 + audit
Every observable outcome is preserved (self still 1 fetch, the anti-enumeration
audit unchanged). The §Q4 gate asserts identical ids from both shapes; two
warm-pool serial reads vs the join! measured 1.85×.
[G1] remove_member — compute the child's transitive users once, reuse it
For a nested GroupMember::Group(child_id) removal the child's transitive-user
set (a recursive WITH RECURSIVE CTE over subject_group_members) was computed
twice: once in the would-empty self-defense pre-check, and again inside
invalidation_targets after remove_member deleted the parent→child edge. That
edge is above the child, so the child's own descendants are unchanged —
verified empirically on the live DB (child set {u2,u3} identical before and
after the edge delete). AFTER computes the CTE once, up front, and reuses it for
both the pre-check and the cache-invalidation set (invalidation_targets stays
for add_member). The §Q6 gate asserts the child set is both stable and the
expected {u2,u3}; 2 CTEs vs 1 measured 2.01× on the seeded 3-level tree.
[U1] dedup hash reshape — move via unzip, don't clone
store_loose_chunks's final registration and the ingest run_rollback both
reshaped an owned Vec<(String,i64)> (dead after the block) into the
Vec<String> + Vec<i64> that sync_blobs(&[String]) and the UNNEST bind
need, by cloning every 64-char hash:
let hashes: Vec<String> = new_rows.iter().map(|(h, _)| h.clone()).collect(); // N clones
let sizes: Vec<i64> = new_rows.iter().map(|(_, s)| *s).collect();
Since the source is owned and never read again, into_iter().unzip() moves the
hashes out — 0 per-hash content copies:
let (hashes, sizes): (Vec<String>, Vec<i64>) = new_rows.into_iter().unzip();
Byte-identical rows inserted; the micro (256 distinct new chunks) drops exactly
the 256 hash clones. (This is the move-not-borrow refinement of the ROUND21 §R2
&[&str] pattern — sync_blobs takes &[String], so a borrow would force a
port-signature change across 6 backends, whereas the move needs none.)
Not shipped — deferred to a dedicated pass
batch_operations::download_zipper-item N+1 (the audit's #2, highest raw-latency candidate): the file loop callsget_file_with_perms(itself authz +get_file= 2 round-trips) per selected file, thenadd_file_entry_streamed— which re-authorizes internally viaget_file_stream_with_perms. Collapsing the per-item metadata+authz into a bulkget_files_by_ids+check_files_read_batchprefetch is a real win (2N+2Mserial round-trips → ~3 batch queries), but it moves the sole authorization from before the stream to inside it, so it needs a careful AuthZ-ordering + anti-enumeration proof (the project's rule: authz lives in the service layer, denials audit-log and return the anti-enum shape). That is its own validated pass, not a perf banner — queued with adownload_zipfixture that seeds a large multi-select and asserts identical ZIP entry set+order across the change.- Contact/Drive JSONB — the SQL-NULL edge: the typed
try_get/deserializepaths return the empty/default on SQL NULL where the oldrow.get::<Value>would have panicked. Both columns areNOT NULL DEFAULTtoday so this never fires; noted only so a future nullable-column change re-checks it.
Environment / methodology
- A local PostgreSQL 16 dev instance was provisioned for this round
(schema applied via the 67
migrations/*.sqlin order;pg_trgm+ltreeextensions).bench_round23_queriesseeds its own fixtures (uniquebench23-*markers) and tears them down (idempotent cleanup) around the run. - Build note: this session's host intermittently
SIGILLed rustc/LLVM codegen under the repo's default-C target-cpu=native(acascadelakewith AVX-512 whose passthrough faulted after a host migration). All Round-23 builds/benches were run withRUSTFLAGS="-C target-cpu=x86-64-v3"(AVX2, no AVX-512) to sidestep it. This is a local build-flag override only — the checked-in.cargo/config.tomlis unchanged, and the primary gate (allocs/op) is target-cpu-independent; the PG p50 comparisons use the same flag for both arms, so the relative speedups hold. - Each micro section: BEFORE (verbatim shipped-before shape) vs AFTER (verbatim
shipped-after shape) + a value-equivalence assert + a
GATE FAIL … rollbackexit if the AFTER fails to reduce allocations. Each PG section: BEFORE vs AFTER shape against real seeded rows + an equivalence gate (mismatch → exit 1)- p50 over
BENCH_PASSES.
- p50 over
- Verified beyond the benches:
cargo fmt --all --checkclean,cargo clippy --features bench --all-targets -D warningsclean, and the touched modules' unit tests pass (contact,drive,subject_group,dedup, auth profile).