perf(authz): cache resource owner lookups in PgAclEngine
The owner short-circuit in PgAclEngine::check ran a PK query (SELECT user_id FROM storage.folders/files WHERE id=$1) on every authorization check of a folder/file — the common case, since users mostly act on their own resources. Memoise it in an owner_cache (moka, TTL 300s, 100k cap). The owner column is immutable, so this is safe: the cache maps resource -> real owner and can never grant a non-owner access (a different caller's owner==uid test fails against the cached owner and falls through to grants); a hard-deleted resource that briefly resolves to its former owner simply fails later at execution with NotFound. The per-check sql_queries counter now increments only on a miss. Removes 1 DB query + 1 pool-connection acquisition per owner check. Magnitude is deployment-specific (query latency x whether the pool is contended); see benches/ACL-OWNER-CACHE.md. Also adds two DB perf-investigation harnesses, gated behind the `bench` feature (need the dev Postgres; zero prod impact): - examples/bench_db_pool.rs + benches/DB-POOL.md — pool size vs tail latency - examples/bench_owner_cache.rs + benches/ACL-OWNER-CACHE.md — owner query vs cache Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user