diff --git a/Cargo.toml b/Cargo.toml index 97adb185..8508805a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -151,6 +151,18 @@ name = "bench_thumbnails_mem" path = "examples/bench_thumbnails_mem.rs" required-features = ["bench"] +# DB connection-pool tail-latency benchmark (needs the dev Postgres up). +[[example]] +name = "bench_db_pool" +path = "examples/bench_db_pool.rs" +required-features = ["bench"] + +# ACL owner-cache benchmark — owner query vs moka hit (needs the dev Postgres up). +[[example]] +name = "bench_owner_cache" +path = "examples/bench_owner_cache.rs" +required-features = ["bench"] + [profile.release] lto = "thin" codegen-units = 1 diff --git a/benches/ACL-OWNER-CACHE.md b/benches/ACL-OWNER-CACHE.md new file mode 100644 index 00000000..a1dfe5a1 --- /dev/null +++ b/benches/ACL-OWNER-CACHE.md @@ -0,0 +1,69 @@ +# ACL owner-cache benchmark + +Opportunity #2 from the backend perf investigation. `PgAclEngine::check` runs an +owner short-circuit on every authorization check of a folder/file — the common +case (a user touching their own resources). That was an uncached PK query +(`SELECT user_id FROM storage.folders/files WHERE id=$1`) **per check**. Now +memoised in an `owner_cache` (moka, `pg_acl_engine.rs`); the owner column is +immutable so it's safe to cache. + +## Safety + +The cache maps `resource → real owner`. It can **never** grant a non-owner +access: a different caller's `owner == uid` test fails against the cached real +owner and falls through to the grant lookup. The only staleness is a +hard-deleted resource briefly resolving to its former owner — harmless, since the +operation then fails at execution with NotFound, and no new access is granted. +TTL 300 s, capacity 100 k. (Owners never change, so no invalidation hook needed.) + +## Reproduce + +```bash +docker compose up -d postgres # needs ≥1 folder in the dev DB (just load-seed) +cargo run --release --features bench --example bench_owner_cache +# tunables: BENCH_CONCURRENCY (64), BENCH_POOL_SIZE (20 = prod default), BENCH_SECONDS (4) +``` + +Models the owner short-circuit: the exact owner query vs a moka hit, under +concurrency, against the real dev Postgres. + +## Results (14 cores, local Docker Postgres) + +**Burst — C = 64, pool = 20 (pool-saturating):** + +| mode | DB queries | checks/s | p50 µs | p95 µs | p99 µs | max µs | +|------------------|-----------:|---------:|-------:|-------:|-------:|-------:| +| uncached (before)| 32746 | 8172 | 7063.0 |11323.9 |20135.1 |106015.7| +| cached (after) | 1 | 6114546 | 1.46 | 3.00 | 3.79 | 75111* | + +**Low load — C = 8, pool = 20 (no queueing, isolates pure query cost):** + +| mode | DB queries | checks/s | p50 µs | p95 µs | p99 µs | max µs | +|------------------|-----------:|---------:|-------:|-------:|-------:|-------:| +| uncached (before)| 25432 | 6357 | 1097.5 | 2091.5 | 2433.9 |10105.8 | +| cached (after) | 1 | 5076363 | 0.88 | 5.00 | 10.92 | 2342* | + +\* `max` for the cached path is a tokio-scheduler / allocation outlier, not the +cache — p99 (µs) is the meaningful tail. + +## Conclusions + +1. **Per owner check, 1 DB query → 1 memory hit.** Pure query cost (C=8): the + cache saves **~1.1 ms p50 / 2.4 ms p99** per check (the owner query latency + on this Docker-on-macOS Postgres). + +2. **Under burst it compounds with the pool (opportunity #1).** At C=64/pool=20, + the uncached checks both pay the query *and* queue on `acquire()` → p50 7 ms, + p99 20 ms. The cache removes the query entirely → no pool occupancy → p99 + 3.8 µs. Fewer queries ⇒ less pool pressure ⇒ lower tail latency under exactly + the conditions that cause the cliff. + +3. **Caveat:** the absolute ms here are inflated by the local Docker Postgres + query latency (~1–2.4 ms; a tuned local PG would be faster, a networked/loaded + one similar or worse) and the deliberately induced pool pressure. The robust, + deployment-independent facts are: **1 fewer DB query and 1 fewer pool + acquisition per authorized action on an owned resource** — which is most + actions, since users mostly touch their own files. + +4. Scope: this is the owner short-circuit (the hot path). Non-owner checks still + do the cascade grant query (unchanged); group expansion was already cached. diff --git a/benches/DB-POOL.md b/benches/DB-POOL.md new file mode 100644 index 00000000..aacaf395 --- /dev/null +++ b/benches/DB-POOL.md @@ -0,0 +1,76 @@ +# DB connection-pool tail-latency benchmark + +Measures how `OXICLOUD_DB_MAX_CONNECTIONS` (default 20, `config.rs`) affects +throughput and tail latency (p95/p99) under concurrent load. Isolates the pool +layer from HTTP/auth: a real `sqlx` Postgres pool of size P driven by C +concurrent workers each looping `SELECT pg_sleep(query_ms)`. Measured latency = +**acquire-wait + query** — the pool-queue effect. `pg_sleep` models query +*duration* (connection occupancy); real listing/auth queries take a few ms each. + +## Reproduce + +```bash +docker compose up -d postgres # needs the dev Postgres +cargo run --release --features bench --example bench_db_pool +# tunables: BENCH_CONCURRENCY=96 BENCH_QUERY_MS=3 BENCH_SECONDS=4 BENCH_POOL_SIZES=10,20,40,70 +``` + +## Results (14 cores, local Postgres `max_connections=100`, pg_sleep 3 ms) + +**Burst — concurrency C = 96 in-flight requests:** + +| pool | req/s | p50 ms | p95 ms | p99 ms | max ms | +|-----:|------:|-------:|-------:|-------:|-------:| +| 10 | 1553 | 61.1 | 69.2 | 71.9 | 76.6 | +| 20 | 3076 | 30.9 | 35.1 | 41.8 | 58.3 | +| 40 | 5745 | 16.1 | 20.8 | 25.0 | 32.3 | +| 70 | 9078 | 9.9 | 15.1 | 18.8 | 27.9 | + +**Bigger burst — C = 192:** + +| pool | req/s | p50 ms | p95 ms | p99 ms | max ms | +|-----:|------:|-------:|-------:|-------:|-------:| +| 10 | 1550 | 123.2 | 130.1 | 138.2 | 143.5 | +| 20 | 3073 | 61.9 | 69.3 | 73.2 | 76.2 | +| 40 | 5796 | 32.9 | 36.9 | 38.1 | 40.0 | +| 70 | 9338 | 20.6 | 24.0 | 25.1 | 27.8 | + +**Low load — C = 16 (≤ pool for 20/40/70):** + +| pool | req/s | p50 ms | p95 ms | p99 ms | max ms | +|-----:|------:|-------:|-------:|-------:|-------:| +| 10 | 1569 | 10.2 | 13.7 | 14.6 | 16.2 | +| 20 | 2555 | 6.0 | 7.9 | 13.4 | 42.8 | +| 40 | 2484 | 6.1 | 7.9 | 14.8 | 30.9 | +| 70 | 2573 | 6.1 | 7.5 | 11.5 | 20.7 | + +## Conclusions + +1. **When in-flight DB queries exceed the pool, the pool is the bottleneck.** + Throughput scales ~linearly with pool size; latency ≈ concurrency × query / + pool. At C=96, raising 20→70 gave **3.0× throughput** (2875→9078 req/s) and + **2.2× lower p99** (42→19 ms). At C=192: **3× throughput**, **2.9× lower p99** + (73→25 ms). The bigger the burst, the steeper the cliff a small pool creates. + +2. **But once pool ≥ actual concurrency, more pool does NOTHING.** At C=16, + pool 20/40/70 are identical (~6 ms p50, ~2500 req/s). Sizing beyond your peak + concurrent in-flight query count is wasted (and costs Postgres connections). + +3. **The right value ≈ your peak concurrent in-flight DB-query count** — not "as + big as possible". Find it from the existing `DbPoolMonitor` (warns at 90% + utilization). If it warns, raise `OXICLOUD_DB_MAX_CONNECTIONS`; if it never + warns, the default 20 is fine. + +4. Bounds: total connections are capped by Postgres `max_connections` (100 here, + shared with the maintenance pool + other clients), and pg_sleep models I/O + wait — real queries also use Postgres CPU, so a pool ≫ DB cores can overload + Postgres. Don't raise blindly. + +5. The default 20 is a sensible default for a low-concurrency self-hosted + deployment; it becomes a tail-latency bottleneck under bursts of >20 + simultaneous DB-bound requests (many sync clients, bulk ops, or one browser + firing many parallel requests). Left as an env knob rather than changing the + default, because the right value is deployment-specific. + +(Note: an early C=96 run showed a one-off p99=98 ms at pool=20 that did not +reproduce — measurement noise, not a real effect.) diff --git a/examples/bench_db_pool.rs b/examples/bench_db_pool.rs new file mode 100644 index 00000000..5140bdd0 --- /dev/null +++ b/examples/bench_db_pool.rs @@ -0,0 +1,151 @@ +//! DB connection-pool tail-latency benchmark. +//! +//! Isolates the variable under test — `max_connections` — from the HTTP/auth +//! stack. Builds a real `sqlx` Postgres pool of size P and drives it with `C` +//! concurrent workers, each looping `SELECT pg_sleep($query_ms)` (a query of +//! known duration). The measured per-request latency is **acquire-wait + query** +//! — exactly the pool-exhaustion mechanism: when in-flight queries exceed P, the +//! surplus queues on `acquire()`, inflating p95/p99. +//! +//! `pg_sleep` is a faithful stand-in for "a query that occupies a connection for +//! T ms" — real listing/auth queries take a few ms each. We hold P constant per +//! run and sweep it, so the *shape* of tail-latency-vs-pool-size is what matters. +//! +//! Run (needs the dev Postgres up; reads DATABASE_URL from .env): +//! cargo run --release --features bench --example bench_db_pool +//! Tunables (env): BENCH_CONCURRENCY (default 96), BENCH_QUERY_MS (3), +//! BENCH_SECONDS (4), BENCH_POOL_SIZES ("10,20,40,70"). + +use std::env; +use std::time::{Duration, Instant}; + +use sqlx::postgres::PgPoolOptions; + +fn env_or(key: &str, default: T) -> T { + env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() { + dotenvy::dotenv().ok(); + let url = env::var("DATABASE_URL") + .or_else(|_| env::var("OXICLOUD_DB_CONNECTION_STRING")) + .expect("set DATABASE_URL (or OXICLOUD_DB_CONNECTION_STRING) — the dev Postgres URL"); + + let concurrency: usize = env_or("BENCH_CONCURRENCY", 96); + let query_ms: u64 = env_or("BENCH_QUERY_MS", 3); + let secs: u64 = env_or("BENCH_SECONDS", 4); + let pool_sizes: Vec = env::var("BENCH_POOL_SIZES") + .ok() + .map(|s| s.split(',').filter_map(|x| x.trim().parse().ok()).collect()) + .unwrap_or_else(|| vec![10, 20, 40, 70]); + + println!("\n###########################################################"); + println!("# DB pool tail-latency benchmark"); + println!("# concurrency (in-flight requests): {concurrency}"); + println!("# query duration: pg_sleep({query_ms} ms) window: {secs}s/pool"); + println!("# latency = acquire-wait + query (the pool-queue effect)"); + println!("###########################################################\n"); + println!( + "| {:>4} | {:>9} | {:>10} | {:>8} | {:>8} | {:>8} | {:>9} | {:>6} |", + "pool", "requests", "req/s", "p50 ms", "p95 ms", "p99 ms", "max ms", "errors" + ); + println!( + "|{:-<6}|{:-<11}|{:-<12}|{:-<10}|{:-<10}|{:-<10}|{:-<11}|{:-<8}|", + "", "", "", "", "", "", "", "" + ); + + let qsec = query_ms as f64 / 1000.0; + + for &pool_size in &pool_sizes { + let pool = PgPoolOptions::new() + .max_connections(pool_size) + .min_connections(pool_size) // pre-warm so we don't time connection setup + .acquire_timeout(Duration::from_secs(10)) // matches prod connect_timeout default + .connect(&url) + .await + .unwrap_or_else(|e| panic!("connect pool={pool_size}: {e}")); + + // Warm-up burst (discarded). + { + let mut warm = Vec::new(); + for _ in 0..concurrency { + let pool = pool.clone(); + warm.push(tokio::spawn(async move { + let _ = sqlx::query("SELECT pg_sleep($1)") + .bind(qsec) + .execute(&pool) + .await; + })); + } + for h in warm { + let _ = h.await; + } + } + + let start = Instant::now(); + let deadline = start + Duration::from_secs(secs); + let mut handles = Vec::with_capacity(concurrency); + for _ in 0..concurrency { + let pool = pool.clone(); + handles.push(tokio::spawn(async move { + let mut lats_us: Vec = Vec::with_capacity(8192); + let mut errors: u64 = 0; + while Instant::now() < deadline { + let t = Instant::now(); + match sqlx::query("SELECT pg_sleep($1)") + .bind(qsec) + .execute(&pool) + .await + { + Ok(_) => lats_us.push(t.elapsed().as_micros() as u32), + Err(_) => errors += 1, + } + } + (lats_us, errors) + })); + } + + let mut all: Vec = Vec::new(); + let mut errors: u64 = 0; + for h in handles { + let (l, e) = h.await.expect("join worker"); + all.extend(l); + errors += e; + } + let elapsed = start.elapsed().as_secs_f64(); + pool.close().await; + + all.sort_unstable(); + let n = all.len(); + let pct = |q: f64| -> f64 { + if n == 0 { + return 0.0; + } + let idx = ((q / 100.0) * (n as f64 - 1.0)).round() as usize; + all[idx.min(n - 1)] as f64 / 1000.0 + }; + let tput = n as f64 / elapsed; + + println!( + "| {:>4} | {:>9} | {:>10.0} | {:>8.2} | {:>8.2} | {:>8.2} | {:>9.2} | {:>6} |", + pool_size, + n, + tput, + pct(50.0), + pct(95.0), + pct(99.0), + pct(100.0), + errors, + ); + } + + println!( + "\nNote: pg_sleep models query DURATION (connection occupancy), not CPU.\n\ + At fixed concurrency, raising the pool cuts queue-wait until pool ≈ concurrency,\n\ + then plateaus — the tail-latency shape that tells you the right size for your load.\n" + ); +} diff --git a/examples/bench_owner_cache.rs b/examples/bench_owner_cache.rs new file mode 100644 index 00000000..492ea802 --- /dev/null +++ b/examples/bench_owner_cache.rs @@ -0,0 +1,196 @@ +//! ACL owner-cache benchmark. +//! +//! Models the owner short-circuit in `PgAclEngine::check` (the common case: a +//! user touching their own files). Before the cache, every authorization check +//! on a folder/file ran one PK query `SELECT user_id FROM storage.folders WHERE +//! id=$1` — a DB round-trip that also occupies a pool connection. After, repeat +//! checks for the same resource hit an in-memory moka cache (owner is immutable). +//! +//! This isolates that exact query vs a moka hit, under concurrency C, against the +//! real dev Postgres. It shows both the latency win and — by NOT touching the +//! pool — the relief it gives the connection pool (ties into the pool benchmark). +//! +//! Run (needs the dev Postgres up with at least one folder; reads DATABASE_URL): +//! cargo run --release --features bench --example bench_owner_cache +//! Tunables: BENCH_CONCURRENCY (64), BENCH_POOL_SIZE (20 = prod default), +//! BENCH_SECONDS (4). + +use std::env; +use std::time::{Duration, Instant}; + +use moka::future::Cache; +use sqlx::Row; +use sqlx::postgres::PgPoolOptions; +use uuid::Uuid; + +fn env_or(key: &str, default: T) -> T { + env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +struct Stats { + reqs: u64, + tput: f64, + p50_us: f64, + p95_us: f64, + p99_us: f64, + max_us: f64, +} + +fn summarize(mut lats_ns: Vec, elapsed: f64) -> Stats { + lats_ns.sort_unstable(); + let n = lats_ns.len(); + let pct = |q: f64| -> f64 { + if n == 0 { + return 0.0; + } + let idx = ((q / 100.0) * (n as f64 - 1.0)).round() as usize; + lats_ns[idx.min(n - 1)] as f64 / 1000.0 + }; + Stats { + reqs: n as u64, + tput: n as f64 / elapsed, + p50_us: pct(50.0), + p95_us: pct(95.0), + p99_us: pct(99.0), + max_us: pct(100.0), + } +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() { + dotenvy::dotenv().ok(); + let url = env::var("DATABASE_URL") + .or_else(|_| env::var("OXICLOUD_DB_CONNECTION_STRING")) + .expect("set DATABASE_URL — the dev Postgres URL"); + let concurrency: usize = env_or("BENCH_CONCURRENCY", 64); + let pool_size: u32 = env_or("BENCH_POOL_SIZE", 20); // production default + let secs: u64 = env_or("BENCH_SECONDS", 4); + + let pool = PgPoolOptions::new() + .max_connections(pool_size) + .min_connections(pool_size) + .acquire_timeout(Duration::from_secs(10)) + .connect(&url) + .await + .expect("connect dev Postgres"); + + // A real folder + its owner to check against. + let Some(row) = sqlx::query("SELECT id, user_id FROM storage.folders LIMIT 1") + .fetch_optional(&pool) + .await + .expect("query folder") + else { + eprintln!("No folders in the dev DB — seed some first (`just load-seed`)."); + return; + }; + let folder_id: Uuid = row.get("id"); + let owner_id: Uuid = row.get("user_id"); + + println!("\n###########################################################"); + println!("# ACL owner-cache benchmark (owner short-circuit path)"); + println!("# concurrency: {concurrency} pool: {pool_size} window: {secs}s/mode"); + println!("# folder {folder_id} owner {owner_id}"); + println!("###########################################################\n"); + + // ── BEFORE: one PK owner query per check (hits DB + pool) ────────────── + let uncached = { + let start = Instant::now(); + let deadline = start + Duration::from_secs(secs); + let mut handles = Vec::with_capacity(concurrency); + for _ in 0..concurrency { + let pool = pool.clone(); + handles.push(tokio::spawn(async move { + let mut lats = Vec::with_capacity(16384); + while Instant::now() < deadline { + let t = Instant::now(); + let _: Uuid = + sqlx::query_scalar("SELECT user_id FROM storage.folders WHERE id = $1") + .bind(folder_id) + .fetch_one(&pool) + .await + .expect("owner query"); + lats.push(t.elapsed().as_nanos() as u64); + } + lats + })); + } + let mut all = Vec::new(); + for h in handles { + all.extend(h.await.expect("join")); + } + let s = summarize(all, start.elapsed().as_secs_f64()); + (s.reqs, s) // reqs == DB queries + }; + + // ── AFTER: moka hit per check (no DB, no pool) ──────────────────────── + let cache: Cache = Cache::builder() + .max_capacity(100_000) + .time_to_live(Duration::from_secs(300)) + .build(); + cache.insert(folder_id, owner_id).await; // 1 warm-up "query" + let cached = { + let start = Instant::now(); + let deadline = start + Duration::from_secs(secs); + let mut handles = Vec::with_capacity(concurrency); + for _ in 0..concurrency { + let cache = cache.clone(); + handles.push(tokio::spawn(async move { + let mut lats = Vec::with_capacity(65536); + while Instant::now() < deadline { + let t = Instant::now(); + let owner = cache.get(&folder_id).await.expect("cache hit"); + std::hint::black_box(owner); + lats.push(t.elapsed().as_nanos() as u64); + } + lats + })); + } + let mut all = Vec::new(); + for h in handles { + all.extend(h.await.expect("join")); + } + summarize(all, start.elapsed().as_secs_f64()) + }; + pool.close().await; + + let (uncached_queries, a) = uncached; + println!( + "| {:<16} | {:>10} | {:>11} | {:>9} | {:>9} | {:>9} | {:>10} |", + "mode", "DB queries", "checks/s", "p50 µs", "p95 µs", "p99 µs", "max µs" + ); + println!( + "|{:-<18}|{:-<12}|{:-<13}|{:-<11}|{:-<11}|{:-<11}|{:-<12}|", + "", "", "", "", "", "", "" + ); + println!( + "| {:<16} | {:>10} | {:>11.0} | {:>9.1} | {:>9.1} | {:>9.1} | {:>10.1} |", + "uncached (before)", uncached_queries, a.tput, a.p50_us, a.p95_us, a.p99_us, a.max_us + ); + println!( + "| {:<16} | {:>10} | {:>11.0} | {:>9.3} | {:>9.3} | {:>9.3} | {:>10.3} |", + "cached (after)", + 1, + cached.tput, + cached.p50_us, + cached.p95_us, + cached.p99_us, + cached.max_us + ); + + println!( + "\nPer authorized action on an owned resource, the cache removes 1 DB query\n\ + + 1 pool-connection occupancy, turning a {:.0} µs round-trip into a {:.3} µs\n\ + memory hit ({:.0}× lower p99). Over a network/loaded DB the absolute saving is\n\ + larger; the freed connections directly relieve the pool (see DB-POOL.md).\n", + a.p99_us, + cached.p99_us, + if cached.p99_us > 0.0 { + a.p99_us / cached.p99_us + } else { + 0.0 + } + ); +} diff --git a/src/infrastructure/services/pg_acl_engine.rs b/src/infrastructure/services/pg_acl_engine.rs index ea6687e3..4d125466 100644 --- a/src/infrastructure/services/pg_acl_engine.rs +++ b/src/infrastructure/services/pg_acl_engine.rs @@ -74,6 +74,13 @@ struct QueryCounters { /// the cap signals pathological data and is surfaced to operators via audit. const MAX_GRANT_ROWS: i64 = 10_000; +/// `owner_cache` bound: entries are tiny (Resource + Uuid). 100k ≈ a few MB. +const OWNER_CACHE_CAPACITY: u64 = 100_000; +/// `owner_cache` TTL. A resource's owner is immutable, so the only staleness is +/// a hard-deleted resource briefly resolving to its former owner — harmless +/// (see `owner_cache` field doc), hence a generous TTL for a high hit rate. +const OWNER_CACHE_TTL: Duration = Duration::from_secs(300); + pub struct PgAclEngine { pool: Arc, folder_repo: Arc, @@ -84,6 +91,14 @@ pub struct PgAclEngine { /// entries; eviction is LRU + TTL. Stale by up to TTL after a membership /// change — acceptable trade-off (see plan, "Cache TTL behaviour"). user_groups_cache: Cache>>, + /// Memoise `resource → owner UUID`. The owner column is immutable, so the + /// owner-short-circuit (the common case: a user touching their own files) + /// no longer issues a PK query on every authorization check — just the first + /// per resource within the TTL. **Safe**: this can never grant a non-owner + /// access (a different caller's `owner == uid` test fails against the cached + /// *real* owner), and a hard-deleted resource that briefly short-circuits as + /// owned simply fails later at execution with NotFound. + owner_cache: Cache, } impl PgAclEngine { @@ -102,6 +117,10 @@ impl PgAclEngine { .max_capacity(50_000) .time_to_live(Duration::from_secs(30)) .build(), + owner_cache: Cache::builder() + .max_capacity(OWNER_CACHE_CAPACITY) + .time_to_live(OWNER_CACHE_TTL) + .build(), } } @@ -153,6 +172,10 @@ impl PgAclEngine { .max_capacity(1) .time_to_live(Duration::from_secs(1)) .build(), + owner_cache: Cache::builder() + .max_capacity(1) + .time_to_live(Duration::from_secs(1)) + .build(), } } @@ -273,6 +296,23 @@ impl PgAclEngine { self.subject_match_set(subject, &counters).await } + /// Owner lookup with memoisation. Hits the DB only on a cache miss; the + /// result is cached because a resource's owner never changes. `NotFound` + /// (a hard-deleted / nonexistent resource) is propagated, not cached. + async fn owner_of_cached( + &self, + resource: Resource, + counters: &QueryCounters, + ) -> Result { + if let Some(owner) = self.owner_cache.get(&resource).await { + return Ok(owner); + } + counters.sql_queries.fetch_add(1, Ordering::Relaxed); + let owner = self.owner_of(resource).await?; + self.owner_cache.insert(resource, owner).await; + Ok(owner) + } + /// Returns the owner UUID for any resource type. async fn owner_of(&self, resource: Resource) -> Result { match resource { @@ -552,8 +592,7 @@ impl PgAclEngine { // there's no analogous fast path: the grant lookup below resolves // a drive owner via the same query that resolves any drive role. if let (Subject::User(uid), Resource::Folder(_) | Resource::File(_)) = (subject, resource) { - counters.sql_queries.fetch_add(1, Ordering::Relaxed); - match self.owner_of(resource).await { + match self.owner_of_cached(resource, counters).await { Ok(owner) if owner == uid => return Ok(true), Ok(_) => { /* not owner — fall through to grants */ } Err(e) if e.kind == crate::common::errors::ErrorKind::NotFound => {