···136136 }
137137138138 // Profile invalidation: "profile:{actor_id}"
139139- if cache_key.starts_with("profile:") {
140140- // No-op for now - we don't cache profiles yet
141141- // When we add profile cache, parse actor_id and invalidate
142142- return;
139139+ if let Some(actor_id_str) = cache_key.strip_prefix("profile:") {
140140+ if let Ok(actor_id) = actor_id_str.parse::<i32>() {
141141+ state.profile_cache.invalidate(actor_id).await;
142142+ info!(actor_id = actor_id, "Invalidated profile cache");
143143+ return;
144144+ }
143145 }
144146145147 // Post invalidation: "post:{actor_id}:{rkey}"
146146- if cache_key.starts_with("post:") {
147147- // No-op for now - we don't cache individual posts yet
148148- // When we add post cache, parse actor_id:rkey and invalidate
149149- return;
148148+ if let Some(rest) = cache_key.strip_prefix("post:") {
149149+ if let Some((actor_id_str, rkey_str)) = rest.split_once(':') {
150150+ if let (Ok(actor_id), Ok(rkey)) = (actor_id_str.parse::<i32>(), rkey_str.parse::<i64>()) {
151151+ state.post_cache.invalidate(actor_id, rkey).await;
152152+ info!(actor_id = actor_id, rkey = rkey, "Invalidated post cache");
153153+ return;
154154+ }
155155+ }
150156 }
151157152158 // Feedgen invalidation: "feedgen:{actor_id}:{rkey}"
153153- if cache_key.starts_with("feedgen:") {
154154- // No-op for now - we don't cache feedgens yet
155155- // When we add feedgen cache, parse actor_id:rkey and invalidate
156156- return;
159159+ if let Some(rest) = cache_key.strip_prefix("feedgen:") {
160160+ if let Some((actor_id_str, rkey)) = rest.split_once(':') {
161161+ if let Ok(actor_id) = actor_id_str.parse::<i32>() {
162162+ state.feedgen_cache.invalidate(actor_id, rkey).await;
163163+ info!(actor_id = actor_id, rkey = rkey, "Invalidated feedgen cache");
164164+ return;
165165+ }
166166+ }
157167 }
158168159169 // List invalidation: "list:{actor_id}:{rkey}"
160160- if cache_key.starts_with("list:") {
161161- // No-op for now - we don't cache lists yet
162162- // When we add list cache, parse actor_id:rkey and invalidate
163163- return;
170170+ if let Some(rest) = cache_key.strip_prefix("list:") {
171171+ if let Some((actor_id_str, rkey)) = rest.split_once(':') {
172172+ if let Ok(actor_id) = actor_id_str.parse::<i32>() {
173173+ state.list_cache.invalidate(actor_id, rkey).await;
174174+ info!(actor_id = actor_id, rkey = rkey, "Invalidated list cache");
175175+ return;
176176+ }
177177+ }
164178 }
165179166180 // Starterpack invalidation: "starterpack:{actor_id}:{rkey}"
167167- if cache_key.starts_with("starterpack:") {
168168- // No-op for now - we don't cache starterpacks yet
169169- // When we add starterpack cache, parse actor_id:rkey and invalidate
170170- return;
181181+ if let Some(rest) = cache_key.strip_prefix("starterpack:") {
182182+ if let Some((actor_id_str, rkey)) = rest.split_once(':') {
183183+ if let Ok(actor_id) = actor_id_str.parse::<i32>() {
184184+ state.starterpack_cache.invalidate(actor_id, rkey).await;
185185+ info!(actor_id = actor_id, rkey = rkey, "Invalidated starterpack cache");
186186+ return;
187187+ }
188188+ }
171189 }
172190173191 // Labeler invalidation: "labeler:{actor_id}"
174174- if cache_key.starts_with("labeler:") {
175175- // No-op for now - we don't cache labelers yet
176176- // When we add labeler cache, parse actor_id and invalidate
177177- return;
192192+ if let Some(actor_id_str) = cache_key.strip_prefix("labeler:") {
193193+ if let Ok(actor_id) = actor_id_str.parse::<i32>() {
194194+ state.labeler_cache.invalidate(actor_id).await;
195195+ info!(actor_id = actor_id, "Invalidated labeler cache");
196196+ return;
197197+ }
178198 }
179199180200 warn!(cache_key = cache_key, "Unknown cache invalidation pattern");
+805
parakeet/src/entity_cache.rs
···11+//! Entity caching for various AT Protocol objects
22+//!
33+//! This is the ONLY approved way to get hydrated data in the application.
44+//! Direct hydration through StatefulHydrator should be avoided to ensure
55+//! all data access goes through the cache.
66+//!
77+//! ## Usage
88+//!
99+//! Instead of:
1010+//! ```
1111+//! let hydrator = StatefulHydrator::new(...);
1212+//! let profile = hydrator.hydrate_profile_detailed(did).await; // ❌ Bypasses cache!
1313+//! ```
1414+//!
1515+//! Always use:
1616+//! ```
1717+//! let profile = profile_cache.get_or_hydrate(actor_id, did, &hydrator).await; // ✅ Uses cache!
1818+//! ```
1919+//!
2020+//! ## Cache Strategy
2121+//!
2222+//! We cache the expensive "Detailed" variants:
2323+//! - `ProfileViewDetailed` - Full profile with counts, used in getProfile
2424+//! - `PostView` - Full post with stats, embeds, and viewer state
2525+//! - `GeneratorView`, `ListView`, etc. - Full entities with all metadata
2626+//!
2727+//! Cache invalidation is handled by database triggers via pg_notify.
2828+2929+use moka::future::Cache;
3030+use std::time::Duration;
3131+3232+// Import the actual types used in the XRPC endpoints
3333+use lexica::app_bsky::actor::ProfileViewDetailed;
3434+use lexica::app_bsky::feed::PostView;
3535+use lexica::app_bsky::feed::GeneratorView;
3636+use lexica::app_bsky::graph::ListView;
3737+use lexica::app_bsky::graph::StarterPackViewBasic;
3838+3939+use crate::hydration::StatefulHydrator;
4040+use crate::id_cache_helpers;
4141+use diesel_async::pooled_connection::deadpool::Pool;
4242+use diesel_async::AsyncPgConnection;
4343+use parakeet_db::id_cache::IdCache;
4444+use std::sync::Arc;
4545+4646+/// Parsed components of an AT-URI
4747+pub struct ParsedAtUri {
4848+ pub did: String,
4949+ pub collection: String,
5050+ pub rkey: String,
5151+}
5252+5353+/// Parse an AT-URI (at://did/collection/rkey) into its components
5454+pub fn parse_at_uri(uri: &str) -> Option<ParsedAtUri> {
5555+ // Expected format: at://did/collection/rkey
5656+ let uri = uri.strip_prefix("at://")?;
5757+ let parts: Vec<&str> = uri.split('/').collect();
5858+5959+ if parts.len() != 3 {
6060+ return None;
6161+ }
6262+6363+ Some(ParsedAtUri {
6464+ did: parts[0].to_string(),
6565+ collection: parts[1].to_string(),
6666+ rkey: parts[2].to_string(),
6767+ })
6868+}
6969+7070+/// Profile cache that handles hydration automatically
7171+///
7272+/// Uses actor_id as the cache key for consistency with database triggers
7373+///
7474+/// Usage:
7575+/// ```
7676+/// let profile = profile_cache.get_or_hydrate(actor_id, did, &hyd).await?;
7777+/// ```
7878+#[derive(Clone)]
7979+pub struct ProfileCache {
8080+ cache: Cache<i32, ProfileViewDetailed>, // Key is actor_id
8181+}
8282+8383+impl ProfileCache {
8484+ pub fn new(ttl_secs: u64, max_capacity: u64) -> Self {
8585+ let cache = Cache::builder()
8686+ .max_capacity(max_capacity)
8787+ .time_to_live(Duration::from_secs(ttl_secs))
8888+ .support_invalidation_closures()
8989+ .build();
9090+9191+ Self { cache }
9292+ }
9393+9494+ /// Get a profile from cache or hydrate it if not cached
9595+ ///
9696+ /// This is the primary API - it handles all caching logic internally
9797+ /// Takes both actor_id (for caching) and DID (for hydration)
9898+ pub async fn get_or_hydrate(
9999+ &self,
100100+ actor_id: i32,
101101+ did: String,
102102+ hydrator: &StatefulHydrator<'_>,
103103+ ) -> Option<ProfileViewDetailed> {
104104+ // Check cache first using actor_id
105105+ if let Some(profile) = self.cache.get(&actor_id).await {
106106+ tracing::debug!(actor_id, "Profile cache hit");
107107+ return Some(profile);
108108+ }
109109+110110+ // Cache miss - hydrate the profile using DID
111111+ tracing::debug!(actor_id, did = %did, "Profile cache miss, hydrating");
112112+113113+ // We're allowed to call the deprecated method here since we're the cache
114114+ #[allow(deprecated)]
115115+ let profile = hydrator.hydrate_profile_detailed(did).await?;
116116+117117+ // Store in cache using actor_id as key
118118+ self.cache.insert(actor_id, profile.clone()).await;
119119+ tracing::debug!(actor_id, "Profile cached");
120120+121121+ Some(profile)
122122+ }
123123+124124+ /// Get multiple profiles with caching
125125+ ///
126126+ /// This is the recommended way to get multiple profiles, ensuring each one
127127+ /// uses the cache individually
128128+ pub async fn get_or_hydrate_batch(
129129+ &self,
130130+ dids: Vec<String>,
131131+ pool: &Pool<AsyncPgConnection>,
132132+ id_cache: &Arc<IdCache>,
133133+ hydrator: &StatefulHydrator<'_>,
134134+ ) -> Vec<ProfileViewDetailed> {
135135+ let mut profiles = Vec::with_capacity(dids.len());
136136+137137+ for did in dids {
138138+ // Get actor_id for each DID
139139+ if let Ok(actor_id) = id_cache_helpers::get_actor_id_or_fetch(pool, id_cache, &did).await {
140140+ if let Some(profile) = self.get_or_hydrate(actor_id, did, hydrator).await {
141141+ profiles.push(profile);
142142+ }
143143+ }
144144+ }
145145+146146+ profiles
147147+ }
148148+149149+ pub async fn invalidate(&self, actor_id: i32) {
150150+ self.cache.invalidate(&actor_id).await;
151151+ tracing::debug!(actor_id, "Profile cache invalidated");
152152+ }
153153+154154+ /// Invalidate all entries (for testing/admin)
155155+ pub async fn invalidate_all(&self) {
156156+ self.cache.invalidate_all();
157157+ tracing::info!("All profile cache entries invalidated");
158158+ }
159159+}
160160+161161+/// Post cache that handles hydration automatically
162162+///
163163+/// Uses (actor_id, rkey) as the cache key for consistency with database triggers
164164+///
165165+/// Usage:
166166+/// ```
167167+/// let post = post_cache.get_or_hydrate(actor_id, rkey, uri, &hyd).await;
168168+/// ```
169169+#[derive(Clone)]
170170+pub struct PostCache {
171171+ cache: Cache<(i32, i64), PostView>, // Key is (actor_id, rkey)
172172+}
173173+174174+impl PostCache {
175175+ pub fn new(ttl_secs: u64, max_capacity: u64) -> Self {
176176+ let cache = Cache::builder()
177177+ .max_capacity(max_capacity)
178178+ .time_to_live(Duration::from_secs(ttl_secs))
179179+ .support_invalidation_closures()
180180+ .build();
181181+182182+ Self { cache }
183183+ }
184184+185185+ /// Get a single post from cache or hydrate it
186186+ ///
187187+ /// Takes both (actor_id, rkey) for caching and URI for hydration
188188+ pub async fn get_or_hydrate_single(
189189+ &self,
190190+ actor_id: i32,
191191+ rkey: i64,
192192+ uri: String,
193193+ hydrator: &StatefulHydrator<'_>,
194194+ ) -> Option<PostView> {
195195+ // Check cache first using (actor_id, rkey)
196196+ if let Some(post) = self.cache.get(&(actor_id, rkey)).await {
197197+ tracing::debug!(actor_id, rkey, "Post cache hit");
198198+ return Some(post);
199199+ }
200200+201201+ // Cache miss - hydrate the post using URI
202202+ tracing::debug!(actor_id, rkey, uri = %uri, "Post cache miss, hydrating");
203203+204204+ // We're allowed to call the deprecated method here since we're the cache
205205+ #[allow(deprecated)]
206206+ let posts = hydrator.hydrate_posts(vec![uri]).await;
207207+ let post = posts.into_values().next()?;
208208+209209+ // Store in cache using (actor_id, rkey) as key
210210+ self.cache.insert((actor_id, rkey), post.clone()).await;
211211+ tracing::debug!(actor_id, rkey, "Post cached");
212212+213213+ Some(post)
214214+ }
215215+216216+ /// Get multiple posts with caching by parsing URIs
217217+ ///
218218+ /// Parses AT-URIs (at://did/collection/rkey) to extract actor_id and rkey,
219219+ /// enabling individual post caching
220220+ ///
221221+ /// Returns a HashMap for efficient lookups while preserving the ability to iterate
222222+ pub async fn get_or_hydrate_from_uris(
223223+ &self,
224224+ uris: Vec<String>,
225225+ pool: &Pool<AsyncPgConnection>,
226226+ id_cache: &Arc<IdCache>,
227227+ hydrator: &StatefulHydrator<'_>,
228228+ ) -> std::collections::HashMap<String, PostView> {
229229+ let mut results = Vec::with_capacity(uris.len());
230230+ let mut missing_uris = Vec::new();
231231+ let mut missing_indices = Vec::new();
232232+233233+ // Try to get each post from cache
234234+ for (idx, uri) in uris.iter().enumerate() {
235235+ // Parse AT-URI: at://did/collection/rkey
236236+ if let Some(parsed) = parse_at_uri(uri) {
237237+ // Only process posts (app.bsky.feed.post collection)
238238+ if parsed.collection == "app.bsky.feed.post" {
239239+ // Get actor_id from DID
240240+ if let Ok(actor_id) = id_cache_helpers::get_actor_id_or_fetch(
241241+ pool,
242242+ id_cache,
243243+ &parsed.did
244244+ ).await {
245245+ // Try to parse rkey as i64 (TID)
246246+ if let Ok(rkey) = parsed.rkey.parse::<i64>() {
247247+ // Check cache
248248+ if let Some(post) = self.cache.get(&(actor_id, rkey)).await {
249249+ tracing::debug!(actor_id, rkey, "Post cache hit");
250250+ results.push(Some(post));
251251+ continue;
252252+ }
253253+ }
254254+ }
255255+ }
256256+ }
257257+258258+ // Cache miss or couldn't parse - need to hydrate
259259+ missing_uris.push(uri.clone());
260260+ missing_indices.push(idx);
261261+ results.push(None);
262262+ }
263263+264264+ // Hydrate missing posts if any
265265+ if !missing_uris.is_empty() {
266266+ tracing::debug!("Hydrating {} missing posts", missing_uris.len());
267267+268268+ // We're allowed to call the deprecated method here since we're the cache
269269+ #[allow(deprecated)]
270270+ let hydrated = hydrator.hydrate_posts(missing_uris).await;
271271+272272+ // Store hydrated posts in cache and results
273273+ for (idx, uri) in missing_indices.into_iter().zip(hydrated.keys()) {
274274+ if let Some(post) = hydrated.get(uri) {
275275+ // Try to cache it if we can parse the URI
276276+ if let Some(parsed) = parse_at_uri(uri) {
277277+ if parsed.collection == "app.bsky.feed.post" {
278278+ if let Ok(actor_id) = id_cache_helpers::get_actor_id_or_fetch(
279279+ pool,
280280+ id_cache,
281281+ &parsed.did
282282+ ).await {
283283+ if let Ok(rkey) = parsed.rkey.parse::<i64>() {
284284+ self.cache.insert((actor_id, rkey), post.clone()).await;
285285+ tracing::debug!(actor_id, rkey, "Post cached");
286286+ }
287287+ }
288288+ }
289289+ }
290290+291291+ results[idx] = Some(post.clone());
292292+ }
293293+ }
294294+ }
295295+296296+ // Build HashMap from successfully loaded posts
297297+ let mut posts_map = std::collections::HashMap::new();
298298+ for (uri, post_opt) in uris.into_iter().zip(results.into_iter()) {
299299+ if let Some(post) = post_opt {
300300+ posts_map.insert(uri, post);
301301+ }
302302+ }
303303+ posts_map
304304+ }
305305+306306+ pub async fn invalidate(&self, actor_id: i32, rkey: i64) {
307307+ self.cache.invalidate(&(actor_id, rkey)).await;
308308+ tracing::debug!(actor_id, rkey, "Post cache invalidated");
309309+ }
310310+311311+ /// Invalidate all entries (for testing/admin)
312312+ pub async fn invalidate_all(&self) {
313313+ self.cache.invalidate_all();
314314+ tracing::info!("All post cache entries invalidated");
315315+ }
316316+}
317317+318318+/// Feedgen cache using (actor_id, rkey) as key
319319+#[derive(Clone)]
320320+pub struct FeedgenCache {
321321+ cache: Cache<(i32, String), GeneratorView>,
322322+}
323323+324324+impl FeedgenCache {
325325+ pub fn new(ttl_secs: u64, max_capacity: u64) -> Self {
326326+ let cache = Cache::builder()
327327+ .max_capacity(max_capacity)
328328+ .time_to_live(Duration::from_secs(ttl_secs))
329329+ .support_invalidation_closures()
330330+ .build();
331331+332332+ Self { cache }
333333+ }
334334+335335+ /// Get a single feedgen from cache or hydrate it
336336+ pub async fn get_or_hydrate_single(
337337+ &self,
338338+ actor_id: i32,
339339+ rkey: String,
340340+ uri: String,
341341+ hydrator: &StatefulHydrator<'_>,
342342+ ) -> Option<GeneratorView> {
343343+ // Check cache first using (actor_id, rkey)
344344+ if let Some(feedgen) = self.cache.get(&(actor_id, rkey.clone())).await {
345345+ tracing::debug!(actor_id, rkey, "Feedgen cache hit");
346346+ return Some(feedgen);
347347+ }
348348+349349+ // Cache miss - hydrate the feedgen using URI
350350+ tracing::debug!(actor_id, rkey, uri = %uri, "Feedgen cache miss, hydrating");
351351+352352+ // We're allowed to call the deprecated method here since we're the cache
353353+ #[allow(deprecated)]
354354+ let feedgen = hydrator.hydrate_feedgen(uri).await?;
355355+356356+ // Store in cache using (actor_id, rkey) as key
357357+ self.cache.insert((actor_id, rkey.clone()), feedgen.clone()).await;
358358+ tracing::debug!(actor_id, rkey, "Feedgen cached");
359359+360360+ Some(feedgen)
361361+ }
362362+363363+ /// Get multiple feedgens with caching by parsing URIs
364364+ /// Returns a HashMap for efficient lookups
365365+ pub async fn get_or_hydrate_from_uris(
366366+ &self,
367367+ uris: Vec<String>,
368368+ pool: &Pool<AsyncPgConnection>,
369369+ id_cache: &Arc<IdCache>,
370370+ hydrator: &StatefulHydrator<'_>,
371371+ ) -> std::collections::HashMap<String, GeneratorView> {
372372+ let mut results = Vec::with_capacity(uris.len());
373373+ let mut missing_uris = Vec::new();
374374+ let mut missing_indices = Vec::new();
375375+376376+ // Try to get each feedgen from cache
377377+ for (idx, uri) in uris.iter().enumerate() {
378378+ // Parse AT-URI: at://did/collection/rkey
379379+ if let Some(parsed) = parse_at_uri(uri) {
380380+ // Only process feedgens (app.bsky.feed.generator collection)
381381+ if parsed.collection == "app.bsky.feed.generator" {
382382+ // Get actor_id from DID
383383+ if let Ok(actor_id) = id_cache_helpers::get_actor_id_or_fetch(
384384+ pool,
385385+ id_cache,
386386+ &parsed.did
387387+ ).await {
388388+ // Check cache
389389+ if let Some(feedgen) = self.cache.get(&(actor_id, parsed.rkey.clone())).await {
390390+ tracing::debug!(actor_id, rkey = parsed.rkey, "Feedgen cache hit");
391391+ results.push(Some(feedgen));
392392+ continue;
393393+ }
394394+ }
395395+ }
396396+ }
397397+398398+ // Cache miss or couldn't parse - need to hydrate
399399+ missing_uris.push(uri.clone());
400400+ missing_indices.push(idx);
401401+ results.push(None);
402402+ }
403403+404404+ // Hydrate missing feedgens if any
405405+ if !missing_uris.is_empty() {
406406+ tracing::debug!("Hydrating {} missing feedgens", missing_uris.len());
407407+408408+ // We're allowed to call the deprecated method here since we're the cache
409409+ #[allow(deprecated)]
410410+ let hydrated = hydrator.hydrate_feedgens(missing_uris).await;
411411+412412+ // Store hydrated feedgens in cache and results
413413+ for (idx, uri) in missing_indices.into_iter().zip(hydrated.keys()) {
414414+ if let Some(feedgen) = hydrated.get(uri) {
415415+ // Try to cache it if we can parse the URI
416416+ if let Some(parsed) = parse_at_uri(uri) {
417417+ if parsed.collection == "app.bsky.feed.generator" {
418418+ if let Ok(actor_id) = id_cache_helpers::get_actor_id_or_fetch(
419419+ pool,
420420+ id_cache,
421421+ &parsed.did
422422+ ).await {
423423+ self.cache.insert((actor_id, parsed.rkey.clone()), feedgen.clone()).await;
424424+ tracing::debug!(actor_id, rkey = parsed.rkey, "Feedgen cached");
425425+ }
426426+ }
427427+ }
428428+429429+ results[idx] = Some(feedgen.clone());
430430+ }
431431+ }
432432+ }
433433+434434+ // Build HashMap from successfully loaded feedgens
435435+ let mut feedgens_map = std::collections::HashMap::new();
436436+ for (uri, feedgen_opt) in uris.into_iter().zip(results.into_iter()) {
437437+ if let Some(feedgen) = feedgen_opt {
438438+ feedgens_map.insert(uri, feedgen);
439439+ }
440440+ }
441441+ feedgens_map
442442+ }
443443+444444+ pub async fn get(&self, actor_id: i32, rkey: &str) -> Option<GeneratorView> {
445445+ let feedgen = self.cache.get(&(actor_id, rkey.to_string())).await?;
446446+ tracing::debug!(actor_id, rkey, "Feedgen cache hit");
447447+ Some(feedgen)
448448+ }
449449+450450+ pub async fn set(&self, actor_id: i32, rkey: &str, feedgen: GeneratorView) {
451451+ self.cache.insert((actor_id, rkey.to_string()), feedgen).await;
452452+ tracing::debug!(actor_id, rkey, "Feedgen cached");
453453+ }
454454+455455+ pub async fn invalidate(&self, actor_id: i32, rkey: &str) {
456456+ self.cache.invalidate(&(actor_id, rkey.to_string())).await;
457457+ tracing::debug!(actor_id, rkey, "Feedgen cache invalidated");
458458+ }
459459+460460+ /// Invalidate all entries (for testing/admin)
461461+ pub async fn invalidate_all(&self) {
462462+ self.cache.invalidate_all();
463463+ tracing::info!("All feedgen cache entries invalidated");
464464+ }
465465+}
466466+467467+/// List cache using (actor_id, rkey) as key
468468+#[derive(Clone)]
469469+pub struct ListCache {
470470+ cache: Cache<(i32, String), ListView>,
471471+}
472472+473473+impl ListCache {
474474+ pub fn new(ttl_secs: u64, max_capacity: u64) -> Self {
475475+ let cache = Cache::builder()
476476+ .max_capacity(max_capacity)
477477+ .time_to_live(Duration::from_secs(ttl_secs))
478478+ .support_invalidation_closures()
479479+ .build();
480480+481481+ Self { cache }
482482+ }
483483+484484+ /// Get a single list from cache or hydrate it
485485+ pub async fn get_or_hydrate_single(
486486+ &self,
487487+ actor_id: i32,
488488+ rkey: String,
489489+ uri: String,
490490+ hydrator: &StatefulHydrator<'_>,
491491+ ) -> Option<ListView> {
492492+ // Check cache first using (actor_id, rkey)
493493+ if let Some(list) = self.cache.get(&(actor_id, rkey.clone())).await {
494494+ tracing::debug!(actor_id, rkey, "List cache hit");
495495+ return Some(list);
496496+ }
497497+498498+ // Cache miss - hydrate the list using URI
499499+ tracing::debug!(actor_id, rkey, uri = %uri, "List cache miss, hydrating");
500500+501501+ // We're allowed to call the deprecated method here since we're the cache
502502+ #[allow(deprecated)]
503503+ let list = hydrator.hydrate_list(uri).await?;
504504+505505+ // Store in cache using (actor_id, rkey) as key
506506+ self.cache.insert((actor_id, rkey.clone()), list.clone()).await;
507507+ tracing::debug!(actor_id, rkey, "List cached");
508508+509509+ Some(list)
510510+ }
511511+512512+ /// Get multiple lists with caching by parsing URIs
513513+ /// Returns a HashMap for efficient lookups
514514+ pub async fn get_or_hydrate_from_uris(
515515+ &self,
516516+ uris: Vec<String>,
517517+ pool: &Pool<AsyncPgConnection>,
518518+ id_cache: &Arc<IdCache>,
519519+ hydrator: &StatefulHydrator<'_>,
520520+ ) -> std::collections::HashMap<String, ListView> {
521521+ let mut results = Vec::with_capacity(uris.len());
522522+ let mut missing_uris = Vec::new();
523523+ let mut missing_indices = Vec::new();
524524+525525+ // Try to get each list from cache
526526+ for (idx, uri) in uris.iter().enumerate() {
527527+ // Parse AT-URI: at://did/collection/rkey
528528+ if let Some(parsed) = parse_at_uri(uri) {
529529+ // Only process lists (app.bsky.graph.list collection)
530530+ if parsed.collection == "app.bsky.graph.list" {
531531+ // Get actor_id from DID
532532+ if let Ok(actor_id) = id_cache_helpers::get_actor_id_or_fetch(
533533+ pool,
534534+ id_cache,
535535+ &parsed.did
536536+ ).await {
537537+ // Check cache
538538+ if let Some(list) = self.cache.get(&(actor_id, parsed.rkey.clone())).await {
539539+ tracing::debug!(actor_id, rkey = parsed.rkey, "List cache hit");
540540+ results.push(Some(list));
541541+ continue;
542542+ }
543543+ }
544544+ }
545545+ }
546546+547547+ // Cache miss or couldn't parse - need to hydrate
548548+ missing_uris.push(uri.clone());
549549+ missing_indices.push(idx);
550550+ results.push(None);
551551+ }
552552+553553+ // Hydrate missing lists if any
554554+ if !missing_uris.is_empty() {
555555+ tracing::debug!("Hydrating {} missing lists", missing_uris.len());
556556+557557+ // We're allowed to call the deprecated method here since we're the cache
558558+ #[allow(deprecated)]
559559+ let hydrated = hydrator.hydrate_lists(missing_uris).await;
560560+561561+ // Store hydrated lists in cache and results
562562+ for (idx, uri) in missing_indices.into_iter().zip(hydrated.keys()) {
563563+ if let Some(list) = hydrated.get(uri) {
564564+ // Try to cache it if we can parse the URI
565565+ if let Some(parsed) = parse_at_uri(uri) {
566566+ if parsed.collection == "app.bsky.graph.list" {
567567+ if let Ok(actor_id) = id_cache_helpers::get_actor_id_or_fetch(
568568+ pool,
569569+ id_cache,
570570+ &parsed.did
571571+ ).await {
572572+ self.cache.insert((actor_id, parsed.rkey.clone()), list.clone()).await;
573573+ tracing::debug!(actor_id, rkey = parsed.rkey, "List cached");
574574+ }
575575+ }
576576+ }
577577+578578+ results[idx] = Some(list.clone());
579579+ }
580580+ }
581581+ }
582582+583583+ // Build HashMap from successfully loaded lists
584584+ let mut lists_map = std::collections::HashMap::new();
585585+ for (uri, list_opt) in uris.into_iter().zip(results.into_iter()) {
586586+ if let Some(list) = list_opt {
587587+ lists_map.insert(uri, list);
588588+ }
589589+ }
590590+ lists_map
591591+ }
592592+593593+ pub async fn get(&self, actor_id: i32, rkey: &str) -> Option<ListView> {
594594+ let list = self.cache.get(&(actor_id, rkey.to_string())).await?;
595595+ tracing::debug!(actor_id, rkey, "List cache hit");
596596+ Some(list)
597597+ }
598598+599599+ pub async fn set(&self, actor_id: i32, rkey: &str, list: ListView) {
600600+ self.cache.insert((actor_id, rkey.to_string()), list).await;
601601+ tracing::debug!(actor_id, rkey, "List cached");
602602+ }
603603+604604+ pub async fn invalidate(&self, actor_id: i32, rkey: &str) {
605605+ self.cache.invalidate(&(actor_id, rkey.to_string())).await;
606606+ tracing::debug!(actor_id, rkey, "List cache invalidated");
607607+ }
608608+609609+ /// Invalidate all entries (for testing/admin)
610610+ pub async fn invalidate_all(&self) {
611611+ self.cache.invalidate_all();
612612+ tracing::info!("All list cache entries invalidated");
613613+ }
614614+}
615615+616616+/// Starterpack cache using (actor_id, rkey) as key
617617+#[derive(Clone)]
618618+pub struct StarterpackCache {
619619+ cache: Cache<(i32, String), StarterPackViewBasic>,
620620+}
621621+622622+impl StarterpackCache {
623623+ pub fn new(ttl_secs: u64, max_capacity: u64) -> Self {
624624+ let cache = Cache::builder()
625625+ .max_capacity(max_capacity)
626626+ .time_to_live(Duration::from_secs(ttl_secs))
627627+ .support_invalidation_closures()
628628+ .build();
629629+630630+ Self { cache }
631631+ }
632632+633633+ /// Get a single starterpack from cache or hydrate it
634634+ pub async fn get_or_hydrate_single(
635635+ &self,
636636+ actor_id: i32,
637637+ rkey: String,
638638+ uri: String,
639639+ hydrator: &StatefulHydrator<'_>,
640640+ ) -> Option<StarterPackViewBasic> {
641641+ // Check cache first using (actor_id, rkey)
642642+ if let Some(pack) = self.cache.get(&(actor_id, rkey.clone())).await {
643643+ tracing::debug!(actor_id, rkey, "Starterpack cache hit");
644644+ return Some(pack);
645645+ }
646646+647647+ // Cache miss - hydrate the starterpack using URI
648648+ tracing::debug!(actor_id, rkey, uri = %uri, "Starterpack cache miss, hydrating");
649649+650650+ // We're allowed to call the deprecated method here since we're the cache
651651+ #[allow(deprecated)]
652652+ let packs = hydrator.hydrate_starterpacks_basic(vec![uri]).await;
653653+ let pack = packs.into_values().next()?;
654654+655655+ // Store in cache using (actor_id, rkey) as key
656656+ self.cache.insert((actor_id, rkey.clone()), pack.clone()).await;
657657+ tracing::debug!(actor_id, rkey, "Starterpack cached");
658658+659659+ Some(pack)
660660+ }
661661+662662+ /// Get multiple starterpacks with caching by parsing URIs
663663+ /// Returns a HashMap for efficient lookups
664664+ pub async fn get_or_hydrate_from_uris(
665665+ &self,
666666+ uris: Vec<String>,
667667+ pool: &Pool<AsyncPgConnection>,
668668+ id_cache: &Arc<IdCache>,
669669+ hydrator: &StatefulHydrator<'_>,
670670+ ) -> std::collections::HashMap<String, StarterPackViewBasic> {
671671+ let mut results = Vec::with_capacity(uris.len());
672672+ let mut missing_uris = Vec::new();
673673+ let mut missing_indices = Vec::new();
674674+675675+ // Try to get each starterpack from cache
676676+ for (idx, uri) in uris.iter().enumerate() {
677677+ // Parse AT-URI: at://did/collection/rkey
678678+ if let Some(parsed) = parse_at_uri(uri) {
679679+ // Only process starterpacks (app.bsky.graph.starterpack collection)
680680+ if parsed.collection == "app.bsky.graph.starterpack" {
681681+ // Get actor_id from DID
682682+ if let Ok(actor_id) = id_cache_helpers::get_actor_id_or_fetch(
683683+ pool,
684684+ id_cache,
685685+ &parsed.did
686686+ ).await {
687687+ // Check cache
688688+ if let Some(pack) = self.cache.get(&(actor_id, parsed.rkey.clone())).await {
689689+ tracing::debug!(actor_id, rkey = parsed.rkey, "Starterpack cache hit");
690690+ results.push(Some(pack));
691691+ continue;
692692+ }
693693+ }
694694+ }
695695+ }
696696+697697+ // Cache miss or couldn't parse - need to hydrate
698698+ missing_uris.push(uri.clone());
699699+ missing_indices.push(idx);
700700+ results.push(None);
701701+ }
702702+703703+ // Hydrate missing starterpacks if any
704704+ if !missing_uris.is_empty() {
705705+ tracing::debug!("Hydrating {} missing starterpacks", missing_uris.len());
706706+707707+ // We're allowed to call the deprecated method here since we're the cache
708708+ #[allow(deprecated)]
709709+ let hydrated = hydrator.hydrate_starterpacks_basic(missing_uris).await;
710710+711711+ // Store hydrated starterpacks in cache and results
712712+ for (idx, uri) in missing_indices.into_iter().zip(hydrated.keys()) {
713713+ if let Some(pack) = hydrated.get(uri) {
714714+ // Try to cache it if we can parse the URI
715715+ if let Some(parsed) = parse_at_uri(uri) {
716716+ if parsed.collection == "app.bsky.graph.starterpack" {
717717+ if let Ok(actor_id) = id_cache_helpers::get_actor_id_or_fetch(
718718+ pool,
719719+ id_cache,
720720+ &parsed.did
721721+ ).await {
722722+ self.cache.insert((actor_id, parsed.rkey.clone()), pack.clone()).await;
723723+ tracing::debug!(actor_id, rkey = parsed.rkey, "Starterpack cached");
724724+ }
725725+ }
726726+ }
727727+728728+ results[idx] = Some(pack.clone());
729729+ }
730730+ }
731731+ }
732732+733733+ // Build HashMap from successfully loaded starterpacks
734734+ let mut packs_map = std::collections::HashMap::new();
735735+ for (uri, pack_opt) in uris.into_iter().zip(results.into_iter()) {
736736+ if let Some(pack) = pack_opt {
737737+ packs_map.insert(uri, pack);
738738+ }
739739+ }
740740+ packs_map
741741+ }
742742+743743+ pub async fn get(&self, actor_id: i32, rkey: &str) -> Option<StarterPackViewBasic> {
744744+ let pack = self.cache.get(&(actor_id, rkey.to_string())).await?;
745745+ tracing::debug!(actor_id, rkey, "Starterpack cache hit");
746746+ Some(pack)
747747+ }
748748+749749+ pub async fn set(&self, actor_id: i32, rkey: &str, pack: StarterPackViewBasic) {
750750+ self.cache.insert((actor_id, rkey.to_string()), pack).await;
751751+ tracing::debug!(actor_id, rkey, "Starterpack cached");
752752+ }
753753+754754+ pub async fn invalidate(&self, actor_id: i32, rkey: &str) {
755755+ self.cache.invalidate(&(actor_id, rkey.to_string())).await;
756756+ tracing::debug!(actor_id, rkey, "Starterpack cache invalidated");
757757+ }
758758+759759+ /// Invalidate all entries (for testing/admin)
760760+ pub async fn invalidate_all(&self) {
761761+ self.cache.invalidate_all();
762762+ tracing::info!("All starterpack cache entries invalidated");
763763+ }
764764+}
765765+766766+/// Labeler cache using actor_id as key
767767+/// Note: Labelers always use "self" as rkey
768768+#[derive(Clone)]
769769+pub struct LabelerCache {
770770+ cache: Cache<i32, serde_json::Value>, // Using Value since we don't have HydratedLabeler type
771771+}
772772+773773+impl LabelerCache {
774774+ pub fn new(ttl_secs: u64, max_capacity: u64) -> Self {
775775+ let cache = Cache::builder()
776776+ .max_capacity(max_capacity)
777777+ .time_to_live(Duration::from_secs(ttl_secs))
778778+ .support_invalidation_closures()
779779+ .build();
780780+781781+ Self { cache }
782782+ }
783783+784784+ pub async fn get(&self, actor_id: i32) -> Option<serde_json::Value> {
785785+ let labeler = self.cache.get(&actor_id).await?;
786786+ tracing::debug!(actor_id, "Labeler cache hit");
787787+ Some(labeler)
788788+ }
789789+790790+ pub async fn set(&self, actor_id: i32, labeler: serde_json::Value) {
791791+ self.cache.insert(actor_id, labeler).await;
792792+ tracing::debug!(actor_id, "Labeler cached");
793793+ }
794794+795795+ pub async fn invalidate(&self, actor_id: i32) {
796796+ self.cache.invalidate(&actor_id).await;
797797+ tracing::debug!(actor_id, "Labeler cache invalidated");
798798+ }
799799+800800+ /// Invalidate all entries (for testing/admin)
801801+ pub async fn invalidate_all(&self) {
802802+ self.cache.invalidate_all();
803803+ tracing::info!("All labeler cache entries invalidated");
804804+ }
805805+}
+8
parakeet/src/hydration/feedgen.rs
···5353}
54545555impl super::StatefulHydrator<'_> {
5656+ #[deprecated(
5757+ since = "0.1.0",
5858+ note = "Use FeedgenCache::get_or_hydrate_single() to ensure caching. Direct hydration bypasses the cache."
5959+ )]
5660 pub async fn hydrate_feedgen(&self, feedgen: String) -> Option<GeneratorView> {
5761 let labels = self.get_label(&feedgen).await;
5862 let viewer = self.get_feedgen_viewer_state(&feedgen).await;
···8589 ))
8690 }
87919292+ #[deprecated(
9393+ since = "0.1.0",
9494+ note = "Use FeedgenCache::get_or_hydrate_from_uris() to ensure caching. Direct hydration bypasses the cache."
9595+ )]
8896 pub async fn hydrate_feedgens(&self, feedgens: Vec<String>) -> HashMap<String, GeneratorView> {
8997 let labels = self.get_label_many(&feedgens).await;
9098 let viewers = self.get_feedgen_viewer_states(&feedgens).await;
+8
parakeet/src/hydration/list.rs
···160160 .collect()
161161 }
162162163163+ #[deprecated(
164164+ since = "0.1.0",
165165+ note = "Use ListCache::get_or_hydrate_single() to ensure caching. Direct hydration bypasses the cache."
166166+ )]
163167 pub async fn hydrate_list(&self, list: String) -> Option<ListView> {
164168 let labels = self.get_label(&list).await;
165169 let viewer = self.get_list_viewer_state(&list).await;
···189193 build_listview(enriched, count, profile, labels, viewer, &self.cdn)
190194 }
191195196196+ #[deprecated(
197197+ since = "0.1.0",
198198+ note = "Use ListCache::get_or_hydrate_from_uris() to ensure caching. Direct hydration bypasses the cache."
199199+ )]
192200 pub async fn hydrate_lists(&self, lists: Vec<String>) -> HashMap<String, ListView> {
193201 if lists.is_empty() {
194202 return HashMap::new();
+8
parakeet/src/hydration/posts/mod.rs
···308308 (results, reply_actor_cache)
309309 }
310310311311+ /// Hydrate multiple posts
312312+ ///
313313+ /// **DEPRECATED**: This method bypasses caching. Use PostCache::get_or_hydrate_from_uris() instead.
314314+ /// Direct hydration should only be called from within the cache system.
315315+ #[deprecated(
316316+ since = "0.1.0",
317317+ note = "Use PostCache::get_or_hydrate_from_uris() to ensure caching. Direct hydration bypasses the cache."
318318+ )]
311319 pub async fn hydrate_posts(&self, posts: Vec<String>) -> HashMap<String, PostView> {
312320 let (posts_data, actor_cache) = self.hydrate_posts_inner(posts).await;
313321
+8
parakeet/src/hydration/profile/mod.rs
···280280 .collect()
281281 }
282282283283+ /// Get detailed profile data
284284+ ///
285285+ /// **DEPRECATED**: This method bypasses caching. Use ProfileCache::get_or_hydrate() instead.
286286+ /// Direct hydration should only be called from within the cache system.
287287+ #[deprecated(
288288+ since = "0.1.0",
289289+ note = "Use ProfileCache::get_or_hydrate() to ensure caching. Direct hydration bypasses the cache."
290290+ )]
283291 pub async fn hydrate_profile_detailed(&self, did: String) -> Option<ProfileViewDetailed> {
284292 let labels = self.get_profile_label(&did).await;
285293 let viewer = self.get_profile_viewer_state(&did).await;
+8
parakeet/src/hydration/starter_packs.rs
···9999 Some(build_basic(enriched, creator, labels, list_item_count))
100100 }
101101102102+ #[deprecated(
103103+ since = "0.1.0",
104104+ note = "Use StarterpackCache::get_or_hydrate_from_uris() to ensure caching. Direct hydration bypasses the cache."
105105+ )]
102106 pub async fn hydrate_starterpacks_basic(
103107 &self,
104108 packs: Vec<String>,
···205209 .collect()
206210 }
207211212212+ #[deprecated(
213213+ since = "0.1.0",
214214+ note = "Use starterpack-specific cache or hydrate_starterpack_basic. StarterPackView (full) and StarterPackViewBasic are different types."
215215+ )]
208216 pub async fn hydrate_starterpack(&self, pack: String) -> Option<StarterPackView> {
209217 let labels = self.get_label(&pack).await;
210218
+7
parakeet/src/lib.rs
···1111pub mod cache_listener;
1212pub mod config;
1313pub mod db;
1414+pub mod entity_cache;
1415pub mod hydration;
1516pub mod id_cache_helpers;
1617pub mod loaders;
···3334 pub rate_limit_config: config::ConfigRateLimit,
3435 pub timeline_cache: Arc<timeline_cache::TimelineCache>,
3536 pub author_feed_cache: Arc<timeline_cache::AuthorFeedCache>,
3737+ pub profile_cache: Arc<entity_cache::ProfileCache>,
3838+ pub post_cache: Arc<entity_cache::PostCache>,
3939+ pub feedgen_cache: Arc<entity_cache::FeedgenCache>,
4040+ pub list_cache: Arc<entity_cache::ListCache>,
4141+ pub starterpack_cache: Arc<entity_cache::StarterpackCache>,
4242+ pub labeler_cache: Arc<entity_cache::LabelerCache>,
3643 pub http_client: reqwest::Client,
3744}
+14
parakeet/src/main.rs
···110110 // Initialize author feed cache (60 second TTL, 10k max items)
111111 let author_feed_cache = Arc::new(timeline_cache::AuthorFeedCache::new(60, 10_000));
112112113113+ // Initialize entity caches (60 second TTL, varying capacities)
114114+ let profile_cache = Arc::new(entity_cache::ProfileCache::new(60, 5_000));
115115+ let post_cache = Arc::new(entity_cache::PostCache::new(60, 10_000));
116116+ let feedgen_cache = Arc::new(entity_cache::FeedgenCache::new(60, 1_000));
117117+ let list_cache = Arc::new(entity_cache::ListCache::new(60, 2_000));
118118+ let starterpack_cache = Arc::new(entity_cache::StarterpackCache::new(60, 1_000));
119119+ let labeler_cache = Arc::new(entity_cache::LabelerCache::new(60, 500));
120120+113121 GlobalState {
114122 pool,
115123 dataloaders,
···122130 rate_limit_config: conf.rate_limit.clone(),
123131 timeline_cache,
124132 author_feed_cache,
133133+ profile_cache,
134134+ post_cache,
135135+ feedgen_cache,
136136+ list_cache,
137137+ starterpack_cache,
138138+ labeler_cache,
125139 http_client,
126140 }
127141 };
+198
parakeet/src/unified_cache.rs
···11+//! Unified caching system that owns hydration
22+//!
33+//! This module provides the ONLY way to get hydrated data in the application.
44+//! All hydration must go through the cache to ensure proper caching behavior.
55+66+use moka::future::Cache;
77+use std::sync::Arc;
88+use std::time::Duration;
99+1010+use diesel_async::pooled_connection::deadpool::Pool;
1111+use diesel_async::AsyncPgConnection;
1212+1313+use lexica::app_bsky::actor::ProfileViewDetailed;
1414+use lexica::app_bsky::feed::PostView;
1515+1616+use crate::hydration::StatefulHydrator;
1717+use crate::loaders::Dataloaders;
1818+use crate::xrpc::cdn::BskyCdn;
1919+use crate::id_cache_helpers;
2020+use parakeet_db::id_cache::IdCache;
2121+2222+/// The unified cache system that owns all hydration
2323+///
2424+/// This is the ONLY way to get hydrated data in the application.
2525+/// Direct access to hydration is not allowed to ensure caching is always used.
2626+#[derive(Clone)]
2727+pub struct UnifiedCache {
2828+ profile_cache: Cache<i32, ProfileViewDetailed>,
2929+ post_cache: Cache<(i32, i64), PostView>,
3030+3131+ // Dependencies needed for hydration
3232+ dataloaders: Arc<Dataloaders>,
3333+ cdn: Arc<BskyCdn>,
3434+ pool: Pool<AsyncPgConnection>,
3535+ id_cache: Arc<IdCache>,
3636+}
3737+3838+impl UnifiedCache {
3939+ pub fn new(
4040+ dataloaders: Arc<Dataloaders>,
4141+ cdn: Arc<BskyCdn>,
4242+ pool: Pool<AsyncPgConnection>,
4343+ id_cache: Arc<IdCache>,
4444+ profile_ttl: u64,
4545+ post_ttl: u64,
4646+ ) -> Self {
4747+ let profile_cache = Cache::builder()
4848+ .max_capacity(5_000)
4949+ .time_to_live(Duration::from_secs(profile_ttl))
5050+ .support_invalidation_closures()
5151+ .build();
5252+5353+ let post_cache = Cache::builder()
5454+ .max_capacity(10_000)
5555+ .time_to_live(Duration::from_secs(post_ttl))
5656+ .support_invalidation_closures()
5757+ .build();
5858+5959+ Self {
6060+ profile_cache,
6161+ post_cache,
6262+ dataloaders,
6363+ cdn,
6464+ pool,
6565+ id_cache,
6666+ }
6767+ }
6868+6969+ /// Get a profile - the ONLY way to get profile data
7070+ ///
7171+ /// This method handles caching and hydration internally.
7272+ /// No direct access to hydration is allowed.
7373+ pub async fn get_profile(
7474+ &self,
7575+ did: String,
7676+ labelers: &[String],
7777+ viewer_did: Option<String>,
7878+ ) -> Option<ProfileViewDetailed> {
7979+ // First, get the actor_id for caching
8080+ let actor_id = id_cache_helpers::get_actor_id_or_fetch(&self.pool, &self.id_cache, &did).await.ok()?;
8181+8282+ // Check cache
8383+ if let Some(profile) = self.profile_cache.get(&actor_id).await {
8484+ tracing::debug!(actor_id, "Profile cache hit");
8585+ return Some(profile);
8686+ }
8787+8888+ // Cache miss - hydrate using internal hydrator
8989+ tracing::debug!(actor_id, "Profile cache miss, hydrating");
9090+9191+ // Get viewer actor_id if provided
9292+ let viewer_actor_id = if let Some(ref viewer) = viewer_did {
9393+ id_cache_helpers::get_actor_id_or_fetch(&self.pool, &self.id_cache, viewer).await.ok()
9494+ } else {
9595+ None
9696+ };
9797+9898+ // Create hydrator for this request
9999+ let hydrator = StatefulHydrator::new(
100100+ &self.dataloaders,
101101+ &self.cdn,
102102+ labelers,
103103+ viewer_did,
104104+ viewer_actor_id,
105105+ ).await;
106106+107107+ // Hydrate the profile
108108+ let profile = hydrator.hydrate_profile_detailed(did).await?;
109109+110110+ // Store in cache
111111+ self.profile_cache.insert(actor_id, profile.clone()).await;
112112+ tracing::debug!(actor_id, "Profile cached");
113113+114114+ Some(profile)
115115+ }
116116+117117+ /// Get multiple profiles
118118+ ///
119119+ /// For now, this doesn't use individual caching but could be optimized
120120+ pub async fn get_profiles(
121121+ &self,
122122+ dids: Vec<String>,
123123+ labelers: &[String],
124124+ viewer_did: Option<String>,
125125+ ) -> Vec<ProfileViewDetailed> {
126126+ // Get viewer actor_id if provided
127127+ let viewer_actor_id = if let Some(ref viewer) = viewer_did {
128128+ id_cache_helpers::get_actor_id_or_fetch(&self.pool, &self.id_cache, viewer).await.ok()
129129+ } else {
130130+ None
131131+ };
132132+133133+ // Create hydrator for this request
134134+ let hydrator = StatefulHydrator::new(
135135+ &self.dataloaders,
136136+ &self.cdn,
137137+ labelers,
138138+ viewer_did,
139139+ viewer_actor_id,
140140+ ).await;
141141+142142+ // TODO: Use individual caching per profile
143143+ hydrator.hydrate_profiles_detailed(dids)
144144+ .await
145145+ .into_values()
146146+ .collect()
147147+ }
148148+149149+ /// Get posts from AT-URIs
150150+ pub async fn get_posts_from_uris(
151151+ &self,
152152+ uris: Vec<String>,
153153+ labelers: &[String],
154154+ viewer_did: Option<String>,
155155+ ) -> Vec<PostView> {
156156+ // This would use the same pattern as profiles
157157+ // For now, keeping it simple
158158+159159+ let viewer_actor_id = if let Some(ref viewer) = viewer_did {
160160+ id_cache_helpers::get_actor_id_or_fetch(&self.pool, &self.id_cache, viewer).await.ok()
161161+ } else {
162162+ None
163163+ };
164164+165165+ let hydrator = StatefulHydrator::new(
166166+ &self.dataloaders,
167167+ &self.cdn,
168168+ labelers,
169169+ viewer_did,
170170+ viewer_actor_id,
171171+ ).await;
172172+173173+ // TODO: Parse URIs and use individual post caching
174174+ hydrator.hydrate_posts(uris)
175175+ .await
176176+ .into_values()
177177+ .collect()
178178+ }
179179+180180+ /// Invalidate a profile by actor_id
181181+ pub async fn invalidate_profile(&self, actor_id: i32) {
182182+ self.profile_cache.invalidate(&actor_id).await;
183183+ tracing::debug!(actor_id, "Profile cache invalidated");
184184+ }
185185+186186+ /// Invalidate a post by actor_id and rkey
187187+ pub async fn invalidate_post(&self, actor_id: i32, rkey: i64) {
188188+ self.post_cache.invalidate(&(actor_id, rkey)).await;
189189+ tracing::debug!(actor_id, rkey, "Post cache invalidated");
190190+ }
191191+192192+ /// Clear all caches (for admin/testing)
193193+ pub async fn clear_all(&self) {
194194+ self.profile_cache.invalidate_all();
195195+ self.post_cache.invalidate_all();
196196+ tracing::info!("All caches cleared");
197197+ }
198198+}
+10-8
parakeet/src/xrpc/app_bsky/actor.rs
···4646 let mut conn = state.pool.get().await?;
4747 check_actor_status(&state.pool, &state.id_cache, &did).await?;
48484949- // Hydrate the profile from our data
5050- let profile = hyd
5151- .hydrate_profile_detailed(did)
4949+ // Get actor_id for cache key
5050+ let actor_id = crate::id_cache_helpers::get_actor_id_or_fetch(&state.pool, &state.id_cache, &did).await?;
5151+5252+ // Use the ergonomic cache API - it handles both caching and hydration
5353+ let profile = state.profile_cache
5454+ .get_or_hydrate(actor_id, did, &hyd)
5255 .await
5356 .ok_or_else(Error::not_found)?;
5457···85888689 let dids = get_actor_dids(&state.dataloaders, query.actors).await;
87908888- let profiles = hyd
8989- .hydrate_profiles_detailed(dids)
9090- .await
9191- .into_values()
9292- .collect();
9191+ // Use the cache's batch method instead of direct hydration
9292+ let profiles = state.profile_cache
9393+ .get_or_hydrate_batch(dids, &state.pool, &state.id_cache, &hyd)
9494+ .await;
93959496 Ok(Json(GetProfilesRes { profiles }).into_response())
9597}
+7-1
parakeet/src/xrpc/app_bsky/bookmark.rs
···214214 })
215215 .collect();
216216217217- let mut posts = hyd.hydrate_posts(uris).await;
217217+ // Use cache for post hydration (returns HashMap directly)
218218+ let mut posts = state.post_cache.get_or_hydrate_from_uris(
219219+ uris,
220220+ &state.pool,
221221+ &state.id_cache,
222222+ &hyd,
223223+ ).await;
218224219225 let bookmarks = results
220226 .into_iter()
+53-9
parakeet/src/xrpc/app_bsky/feed/feedgen.rs
···7575 })
7676 .collect();
77777878- let mut feeds = hyd.hydrate_feedgens(at_uris).await;
7878+ // Use cache for feedgen hydration (returns HashMap directly)
7979+ let mut feeds_map = state.feedgen_cache.get_or_hydrate_from_uris(
8080+ at_uris.clone(),
8181+ &state.pool,
8282+ &state.id_cache,
8383+ &hyd,
8484+ ).await;
79858086 let feeds = results
8187 .into_iter()
8288 .filter_map(|r| {
8389 let did = actor_id_to_did.get(&r.1)?;
8490 let at_uri = format!("at://{}/app.bsky.feed.generator/{}", did, r.2);
8585- feeds.remove(&at_uri)
9191+ feeds_map.remove(&at_uri)
8692 })
8793 .collect();
8894···117123 };
118124 let hyd = StatefulHydrator::new(&state.dataloaders, &state.cdn, &labelers, maybe_did, maybe_actor_id).await;
119125120120- let Some(view) = hyd.hydrate_feedgen(query.feed).await else {
126126+ // Parse the feed URI to extract actor_id and rkey for caching
127127+ let view = if let Some(parsed) = crate::entity_cache::parse_at_uri(&query.feed) {
128128+ if parsed.collection == "app.bsky.feed.generator" {
129129+ // Get actor_id from DID
130130+ if let Ok(actor_id) = crate::id_cache_helpers::get_actor_id_or_fetch(
131131+ &state.pool,
132132+ &state.id_cache,
133133+ &parsed.did
134134+ ).await {
135135+ // Use cache for single feedgen
136136+ state.feedgen_cache.get_or_hydrate_single(
137137+ actor_id,
138138+ parsed.rkey,
139139+ query.feed.clone(),
140140+ &hyd,
141141+ ).await
142142+ } else {
143143+ None
144144+ }
145145+ } else {
146146+ None
147147+ }
148148+ } else {
149149+ None
150150+ };
151151+152152+ let Some(view) = view else {
121153 return Err(Error::not_found());
122154 };
123155···154186 };
155187 let hyd = StatefulHydrator::new(&state.dataloaders, &state.cdn, &labelers, maybe_did, maybe_actor_id).await;
156188157157- let feeds = hyd
158158- .hydrate_feedgens(query.feeds)
159159- .await
160160- .into_values()
161161- .collect();
189189+ // Use cache for batch feedgen hydration (returns HashMap)
190190+ let feeds_map = state.feedgen_cache.get_or_hydrate_from_uris(
191191+ query.feeds,
192192+ &state.pool,
193193+ &state.id_cache,
194194+ &hyd,
195195+ ).await;
196196+197197+ // Convert to Vec for API response
198198+ let feeds = feeds_map.into_values().collect();
162199163200 Ok(Json(GetFeedGeneratorsRes { feeds }))
164201}
···251288 (None, None)
252289 };
253290 let hyd = StatefulHydrator::new(&state.dataloaders, &state.cdn, &labelers, maybe_did, maybe_actor_id).await;
254254- let mut feeds_map = hyd.hydrate_feedgens(page_uris.clone()).await;
291291+292292+ // Use cache for batch feedgen hydration (returns HashMap directly)
293293+ let mut feeds_map = state.feedgen_cache.get_or_hydrate_from_uris(
294294+ page_uris.clone(),
295295+ &state.pool,
296296+ &state.id_cache,
297297+ &hyd,
298298+ ).await;
255299256300 let feeds: Vec<GeneratorView> = page_uris
257301 .into_iter()
+20-5
parakeet/src/xrpc/app_bsky/feed/get_timeline.rs
···55use crate::GlobalState;
66use axum::extract::{Query, State};
77use axum::Json;
88-use lexica::app_bsky::feed::{FeedReasonRepost, FeedViewPost, FeedViewPostReason};
88+use lexica::app_bsky::feed::{FeedReasonRepost, FeedViewPost, FeedViewPostReason, PostView};
99use serde::{Deserialize, Serialize};
1010use std::collections::HashMap;
1111···189189 // OPTIMIZATION: Use the original (actor_id, rkey) results for repost query instead of re-parsing URIs
190190 let post_keys: Vec<(i32, i64)> = results.iter().map(|(_, actor_id, rkey)| (*actor_id, *rkey)).collect();
191191192192- // Parallelize: hydrate posts and fetch repost data concurrently
192192+ // Parallelize: hydrate posts (using cache) and fetch repost data concurrently
193193 step_timer = std::time::Instant::now();
194194 let (mut post_views, reposts_results) = tokio::join!(
195195- hyd.hydrate_posts(at_uris.clone()),
195195+ state.post_cache.get_or_hydrate_from_uris(
196196+ at_uris.clone(),
197197+ &state.pool,
198198+ &state.id_cache,
199199+ &hyd,
200200+ ),
196201 async {
197202 crate::db::get_timeline_reposts(&mut conn, &followed_actor_ids, &post_keys)
198203 .await
···321326 tracing::error!("Failed to get DB connection for repost hydration: {}", e);
322327 // Return posts without repost info by hydrating them
323328 let uri_count = at_uris.len();
324324- let mut post_views = hyd.hydrate_posts(at_uris.clone()).await;
329329+ let mut post_views = state.post_cache.get_or_hydrate_from_uris(
330330+ at_uris.clone(),
331331+ &state.pool,
332332+ &state.id_cache,
333333+ hyd,
334334+ ).await;
325335 let feed: Vec<FeedViewPost> = at_uris
326336 .into_iter()
327337 .filter_map(|uri| {
···346356347357 // Parallelize: hydrate posts and get followed actor_ids concurrently
348358 let (mut post_views, follows) = tokio::join!(
349349- hyd.hydrate_posts(at_uris.clone()),
359359+ state.post_cache.get_or_hydrate_from_uris(
360360+ at_uris.clone(),
361361+ &state.pool,
362362+ &state.id_cache,
363363+ hyd,
364364+ ),
350365 async {
351366 crate::db::get_followed_dids(&mut conn, user_actor_id)
352367 .await
+19-5
parakeet/src/xrpc/app_bsky/feed/posts/queries.rs
···3535 (None, None)
3636 };
3737 let hyd = StatefulHydrator::new(&state.dataloaders, &state.cdn, &labelers, maybe_did, maybe_actor_id).await;
3838- let posts = hyd.hydrate_posts(query.uris).await;
39384040- Ok(Json(PostsRes {
4141- posts: posts.into_values().collect(),
4242- }))
3939+ // Use the new method that parses URIs and caches individual posts
4040+ let posts_map = state.post_cache.get_or_hydrate_from_uris(
4141+ query.uris,
4242+ &state.pool,
4343+ &state.id_cache,
4444+ &hyd,
4545+ ).await;
4646+4747+ // Convert to Vec for API response
4848+ let posts = posts_map.into_values().collect();
4949+5050+ Ok(Json(PostsRes { posts }))
4351}
44524553#[derive(Debug, Deserialize)]
···166174167175 let cursor = uris.last().cloned();
168176169169- let mut posts_map = hyd.hydrate_posts(uris.clone()).await;
177177+ // Use cache for post hydration (returns HashMap directly)
178178+ let mut posts_map = state.post_cache.get_or_hydrate_from_uris(
179179+ uris.clone(),
180180+ &state.pool,
181181+ &state.id_cache,
182182+ &hyd,
183183+ ).await;
170184171185 let posts = uris
172186 .into_iter()