Files
Oxicloud/benches/ROUND23.md
T
Claude 1ec7030cc7 perf: 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: 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
2026-07-20 15:25:42 +00:00

194 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
AFTER `std::process::exit(1)`s with `GATE 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:
```rust
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:
```rust
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:
```rust
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:
```rust
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:
```rust
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:
```rust
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_zip` per-item N+1** (the audit's #2, highest
raw-latency candidate): the file loop calls `get_file_with_perms` (itself
authz + `get_file` = 2 round-trips) per selected file, then
`add_file_entry_streamed` — which **re-authorizes** internally via
`get_file_stream_with_perms`. Collapsing the per-item metadata+authz into a
bulk `get_files_by_ids` + `check_files_read_batch` prefetch is a real win
(`2N+2M` serial 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 a `download_zip`
fixture 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`/`deserialize`
paths return the empty/default on SQL NULL where the old `row.get::<Value>`
would have panicked. Both columns are `NOT NULL DEFAULT` today 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/*.sql` in order; `pg_trgm` + `ltree`
extensions). `bench_round23_queries` seeds its own fixtures (unique
`bench23-*` markers) and tears them down (idempotent cleanup) around the run.
- **Build note:** this session's host intermittently `SIGILL`ed rustc/LLVM
codegen under the repo's default `-C target-cpu=native` (a `cascadelake` with
AVX-512 whose passthrough faulted after a host migration). All Round-23
builds/benches were run with `RUSTFLAGS="-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.toml` is 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 … rollback`
exit 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`.
- Verified beyond the benches: `cargo fmt --all --check` clean, `cargo clippy
--features bench --all-targets -D warnings` clean, and the touched modules'
unit tests pass (`contact`, `drive`, `subject_group`, `dedup`, auth profile).