| 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 |
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 |
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(),
|