Skip to main content

max / makenotwork

Memoize discover facets and CDN-cache /economics (ultra-fuzz Run 12 Performance) The public /discover page recomputed five full-catalog aggregate scans on every anonymous hit, holding ~5 pool connections per request via try_join! — the crate's largest DB amplifier (scale-gated: negligible at alpha, bites at 10k+ items). - Serve the four viewer-independent facet counts (item-type, tag, ai-tier, price-range) from a short-TTL in-process memo keyed by the filter inputs, following the existing SITEMAP_CACHE pattern (no new dependency). Repeated anonymous loads of the same filter view become a map lookup instead of five scans; followed_tag_ids stays per-viewer and fresh. Expiry-only invalidation with a bounded map. - /economics is a viewer-independent marketing page that was falling through to private/no-cache; add it to is_public_page so the CDN caches it. Session-store caching (moka) and OFFSET keyset pagination were deferred by decision (a new dependency / follow-up before the catalog grows large). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-01 14:34 UTC
Commit: c5a54baa408fe269cdc03ca5f55f802f94df0788
Parent: 7a8c406
2 files changed, +94 insertions, -21 deletions
@@ -67,7 +67,10 @@ pub async fn cache_control_middleware(request: Request, next: Next) -> Response
67 67
68 68 /// Returns true for public content pages that benefit from CDN caching.
69 69 fn is_public_page(path: &str) -> bool {
70 - matches!(path, "/" | "/discover" | "/pricing" | "/source")
70 + // `/economics` is a viewer-independent marketing page (aggregate counts only);
71 + // it was falling through to `private, no-cache` and recomputing on every hit
72 + // (ultra-fuzz Run 12 doubledown Perf 1b). Let the CDN cache it like the others.
73 + matches!(path, "/" | "/discover" | "/pricing" | "/source" | "/economics")
71 74 || path.starts_with("/p/")
72 75 || path.starts_with("/i/")
73 76 || path.starts_with("/u/")
@@ -7,6 +7,10 @@ use serde::{Deserialize, Serialize};
7 7 use sqlx::PgPool;
8 8 use tower_sessions::Session;
9 9
10 + use std::collections::HashMap;
11 + use std::sync::{Mutex, OnceLock};
12 + use std::time::{Duration, Instant};
13 +
10 14 use crate::{
11 15 auth::MaybeUserUnverified,
12 16 constants,
@@ -18,6 +22,81 @@ use crate::{
18 22 AppState,
19 23 };
20 24
25 + /// The four viewer-independent discover facet results (item-type, tag, ai-tier, and
26 + /// price-range counts). `followed_tag_ids` is deliberately excluded — it is
27 + /// per-viewer and always computed fresh.
28 + type FacetBundle = (
29 + Vec<db::DbItemTypeCount>,
30 + Vec<db::DbTagCount>,
31 + Vec<db::DbItemTypeCount>,
32 + db::DbPriceRangeCounts,
33 + );
34 +
35 + /// Cache key: the filter inputs every facet query keys off. Same filters ->
36 + /// identical viewer-independent counts, so they can be shared across viewers.
37 + type FacetKey = (Option<String>, Option<ItemType>, Option<String>, Option<i32>, Option<i32>);
38 +
39 + /// Short TTL for the facet memo. The discover facets are the hottest, most
40 + /// cacheable aggregate on the busiest public page; recomputing five full-catalog
41 + /// scans on every anonymous hit is the crate's largest DB amplifier (ultra-fuzz
42 + /// Run 12 Performance). Expiry-only invalidation (no bust on catalog change) — a
43 + /// count that is up to a minute stale on the discover sidebar is harmless.
44 + const FACET_CACHE_TTL: Duration = Duration::from_secs(60);
45 + /// Bound the memo so a wide spread of filter combinations can't grow it without
46 + /// limit; on overflow, drop expired entries first, then clear if still full.
47 + const FACET_CACHE_MAX: usize = 512;
48 +
49 + static FACET_CACHE: OnceLock<Mutex<HashMap<FacetKey, (Instant, FacetBundle)>>> = OnceLock::new();
50 +
51 + /// The four viewer-independent discover facets, memoized for [`FACET_CACHE_TTL`].
52 + /// On a miss the four queries still run (concurrently); the memo makes the common
53 + /// case — repeated anonymous loads of the same filter view — a single map lookup
54 + /// instead of five full-catalog aggregate scans holding five pool connections.
55 + #[allow(clippy::too_many_arguments)]
56 + async fn cached_facets(
57 + db: &PgPool,
58 + search: Option<&str>,
59 + item_type: Option<ItemType>,
60 + tag: Option<&str>,
61 + min_price: Option<i32>,
62 + max_price: Option<i32>,
63 + ) -> Result<FacetBundle> {
64 + let key: FacetKey = (
65 + search.map(str::to_string),
66 + item_type,
67 + tag.map(str::to_string),
68 + min_price,
69 + max_price,
70 + );
71 +
72 + let cache = FACET_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
73 + if let Ok(guard) = cache.lock()
74 + && let Some((at, bundle)) = guard.get(&key)
75 + && at.elapsed() < FACET_CACHE_TTL
76 + {
77 + return Ok(bundle.clone());
78 + }
79 +
80 + let bundle: FacetBundle = tokio::try_join!(
81 + db::discover::get_item_type_counts(db, search, tag, min_price, max_price),
82 + db::tags::get_tag_counts(db, search, item_type),
83 + db::discover::get_ai_tier_counts(db, search, item_type, tag),
84 + db::discover::get_price_range_counts(db, search, item_type, tag),
85 + )?;
86 +
87 + if let Ok(mut guard) = cache.lock() {
88 + if guard.len() >= FACET_CACHE_MAX {
89 + guard.retain(|_, (at, _)| at.elapsed() < FACET_CACHE_TTL);
90 + if guard.len() >= FACET_CACHE_MAX {
91 + guard.clear();
92 + }
93 + }
94 + guard.insert(key, (Instant::now(), bundle.clone()));
95 + }
96 +
97 + Ok(bundle)
98 + }
99 +
21 100 /// Deserialize an empty string as `None` instead of failing to parse.
22 101 ///
23 102 /// HTML form inputs send `field=` (empty string) when blank, which fails
@@ -331,27 +410,18 @@ pub(super) async fn discover(
331 410 };
332 411
333 412 let (type_filters, tag_filters, ai_tier_filters, price_counts) = if data.mode == "items" {
334 - // Run all facet queries in parallel to reduce DB round-trips
413 + // The four facet counts are viewer-independent -> served from a short-TTL
414 + // memo (ultra-fuzz Run 12 Performance). Only `followed_tag_ids` is
415 + // per-viewer, so it is computed fresh (and only when logged in).
335 416 let viewer_id = maybe_user.as_ref().map(|u| u.id);
336 - let (type_counts, tag_counts, followed_tag_ids, ai_counts, price_counts) = tokio::try_join!(
337 - db::discover::get_item_type_counts(
338 - &state.db, search_filter, tag_filter, query.min_price, query.max_price,
339 - ),
340 - db::tags::get_tag_counts(&state.db, search_filter, item_type_filter),
341 - async {
342 - if let Some(uid) = viewer_id {
343 - db::follows::get_followed_tag_ids(&state.db, uid).await
344 - } else {
345 - Ok(std::collections::HashSet::new())
346 - }
347 - },
348 - db::discover::get_ai_tier_counts(
349 - &state.db, search_filter, item_type_filter, tag_filter,
350 - ),
351 - db::discover::get_price_range_counts(
352 - &state.db, search_filter, item_type_filter, tag_filter,
353 - ),
354 - )?;
417 + let (type_counts, tag_counts, ai_counts, price_counts) = cached_facets(
418 + &state.db, search_filter, item_type_filter, tag_filter, query.min_price, query.max_price,
419 + ).await?;
420 + let followed_tag_ids = if let Some(uid) = viewer_id {
421 + db::follows::get_followed_tag_ids(&state.db, uid).await?
422 + } else {
423 + std::collections::HashSet::new()
424 + };
355 425
356 426 let mut type_filters: Vec<FilterCategory> = vec![FilterCategory {
357 427 name: "All".to_string(),