From 183e78d4757c46eb7b19a82d9a01964e882c2deb Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 12 May 2026 10:59:51 +0800 Subject: [PATCH] perf: batch recent play counts for gallery lists --- .../spacetime-module/src/custom_world/mod.rs | 44 ++++- .../crates/spacetime-module/src/puzzle.rs | 30 +++- .../spacetime-module/src/runtime/profile.rs | 165 ++++++++++++++++-- 3 files changed, 213 insertions(+), 26 deletions(-) diff --git a/server-rs/crates/spacetime-module/src/custom_world/mod.rs b/server-rs/crates/spacetime-module/src/custom_world/mod.rs index 48c7300c..8f9cf75a 100644 --- a/server-rs/crates/spacetime-module/src/custom_world/mod.rs +++ b/server-rs/crates/spacetime-module/src/custom_world/mod.rs @@ -1,5 +1,5 @@ use crate::*; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; #[spacetimedb::table( accessor = custom_world_profile, @@ -1549,11 +1549,26 @@ fn list_custom_world_gallery_snapshots( ) -> Result, String> { sync_missing_custom_world_gallery_entries(ctx)?; - let mut entries = ctx + let entries = ctx .db .custom_world_gallery_entry() .iter() - .map(|row| build_custom_world_gallery_entry_snapshot(ctx, &row)) + .collect::>(); + let profile_ids = entries + .iter() + .map(|row| row.profile_id.clone()) + .collect::>(); + let recent_play_counts = count_recent_public_work_plays_for_profiles( + ctx, + "custom-world", + &profile_ids, + ctx.timestamp.to_micros_since_unix_epoch(), + ); + let mut entries = entries + .iter() + .map(|row| { + build_custom_world_gallery_entry_snapshot_with_recent_counts(row, &recent_play_counts) + }) .collect::>(); entries.sort_by(|left, right| { @@ -5078,6 +5093,19 @@ fn build_custom_world_draft_card_snapshot( fn build_custom_world_gallery_entry_snapshot( ctx: &ReducerContext, row: &CustomWorldGalleryEntry, +) -> CustomWorldGalleryEntrySnapshot { + let recent_play_counts = count_recent_public_work_plays_for_profiles( + ctx, + "custom-world", + &[row.profile_id.clone()], + ctx.timestamp.to_micros_since_unix_epoch(), + ); + build_custom_world_gallery_entry_snapshot_with_recent_counts(row, &recent_play_counts) +} + +fn build_custom_world_gallery_entry_snapshot_with_recent_counts( + row: &CustomWorldGalleryEntry, + recent_play_counts: &HashMap, ) -> CustomWorldGalleryEntrySnapshot { CustomWorldGalleryEntrySnapshot { profile_id: row.profile_id.clone(), @@ -5095,12 +5123,10 @@ fn build_custom_world_gallery_entry_snapshot( play_count: row.play_count, remix_count: row.remix_count, like_count: row.like_count, - recent_play_count_7d: count_recent_public_work_plays( - ctx, - "custom-world", - &row.profile_id, - ctx.timestamp.to_micros_since_unix_epoch(), - ), + recent_play_count_7d: recent_play_counts + .get(&row.profile_id) + .copied() + .unwrap_or(0), published_at_micros: row.published_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index 1e635a27..626f1263 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -1,8 +1,9 @@ use crate::runtime::{ ProfilePlayedWorkUpsertInput, ProfileSaveArchiveUpsertInput, PublicWorkLikeRecordInput, PublicWorkPlayRecordInput, add_profile_observed_play_time, count_recent_public_work_plays, - grant_profile_wallet_points, record_public_work_like, record_public_work_play, - upsert_profile_played_work, upsert_profile_save_archive, + count_recent_public_work_plays_for_profiles, grant_profile_wallet_points, + record_public_work_like, record_public_work_play, upsert_profile_played_work, + upsert_profile_save_archive, }; use module_puzzle::{ PUZZLE_MAX_TAG_COUNT, PUZZLE_NEXT_LEVEL_MODE_NONE, PUZZLE_NEXT_LEVEL_MODE_SAME_WORK, @@ -1392,12 +1393,21 @@ fn delete_puzzle_work_tx( fn list_puzzle_gallery_tx(ctx: &TxContext) -> Result, String> { let now_micros = ctx.timestamp.to_micros_since_unix_epoch(); - let mut items = ctx + let rows = ctx .db .puzzle_work_profile() .iter() .filter(|row| row.publication_status == PuzzlePublicationStatus::Published) - .map(|row| build_puzzle_work_profile_from_row_with_recent_count(ctx, &row, now_micros)) + .collect::>(); + let profile_ids = rows + .iter() + .map(|row| row.profile_id.clone()) + .collect::>(); + let recent_play_counts = + count_recent_public_work_plays_for_profiles(ctx, "puzzle", &profile_ids, now_micros); + let mut items = rows + .iter() + .map(|row| build_puzzle_work_profile_from_row_with_recent_counts(row, &recent_play_counts)) .collect::, _>>()?; items.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros)); Ok(items) @@ -2268,6 +2278,18 @@ fn build_puzzle_work_profile_from_row_with_recent_count( Ok(profile) } +fn build_puzzle_work_profile_from_row_with_recent_counts( + row: &PuzzleWorkProfileRow, + recent_play_counts: &std::collections::HashMap, +) -> Result { + let mut profile = build_puzzle_work_profile_from_row_without_recent_count(row)?; + profile.recent_play_count_7d = recent_play_counts + .get(&row.profile_id) + .copied() + .unwrap_or(0); + Ok(profile) +} + fn build_puzzle_work_profile_from_row_without_recent_count( row: &PuzzleWorkProfileRow, ) -> Result { diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 76e6b623..6f9c093c 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -1,4 +1,5 @@ use crate::*; +use std::collections::{HashMap, HashSet}; const PUBLIC_WORK_PLAY_DAY_MICROS: i64 = 86_400_000_000; const PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS: i64 = 7; @@ -1299,25 +1300,94 @@ pub(crate) fn count_recent_public_work_plays( profile_id: &str, now_micros: i64, ) -> u32 { + count_recent_public_work_plays_for_profiles( + ctx, + source_type, + &[profile_id.to_string()], + now_micros, + ) + .remove(profile_id.trim()) + .unwrap_or(0) +} + +pub(crate) fn count_recent_public_work_plays_for_profiles( + ctx: &ReducerContext, + source_type: &str, + profile_ids: &[String], + now_micros: i64, +) -> HashMap { let source_type = source_type.trim(); - let profile_id = profile_id.trim(); - if source_type.is_empty() || profile_id.is_empty() { - return 0; + if source_type.is_empty() || profile_ids.is_empty() { + return HashMap::new(); } let current_day = public_work_play_day_from_micros(now_micros); let first_day = current_day.saturating_sub(PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS - 1); - - ctx.db - .public_work_play_daily_stat() + let requested_profile_ids = profile_ids .iter() - .filter(|row| { - row.source_type == source_type - && row.profile_id == profile_id - && row.played_day >= first_day - && row.played_day <= current_day - }) - .fold(0u32, |total, row| total.saturating_add(row.play_count)) + .map(|profile_id| profile_id.trim()) + .filter(|profile_id| !profile_id.is_empty()) + .collect::>(); + let mut counts = HashMap::new(); + + for profile_id in requested_profile_ids { + let mut total = 0u32; + for played_day in first_day..=current_day { + let day_total = ctx + .db + .public_work_play_daily_stat() + .by_public_work_play_daily_stat_work_day() + .filter((source_type, profile_id, played_day)) + .fold(0u32, |sum, row| sum.saturating_add(row.play_count)); + total = total.saturating_add(day_total); + } + if total > 0 { + counts.insert(profile_id.to_string(), total); + } + } + + counts +} + +#[cfg(test)] +fn build_recent_public_work_play_counts( + rows: impl IntoIterator, + source_type: &str, + profile_ids: &[String], + now_micros: i64, +) -> HashMap { + let source_type = source_type.trim(); + if source_type.is_empty() || profile_ids.is_empty() { + return HashMap::new(); + } + + let requested_profile_ids = profile_ids + .iter() + .map(|profile_id| profile_id.trim()) + .filter(|profile_id| !profile_id.is_empty()) + .collect::>(); + if requested_profile_ids.is_empty() { + return HashMap::new(); + } + + let current_day = public_work_play_day_from_micros(now_micros); + let first_day = current_day.saturating_sub(PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS - 1); + let mut counts = HashMap::new(); + + for row in rows { + if row.source_type != source_type + || !requested_profile_ids.contains(row.profile_id.as_str()) + || row.played_day < first_day + || row.played_day > current_day + { + continue; + } + + let entry = counts.entry(row.profile_id.clone()).or_insert(0u32); + *entry = entry.saturating_add(row.play_count); + } + + counts } fn public_work_play_day_from_micros(value: i64) -> i64 { @@ -1336,6 +1406,75 @@ fn build_public_work_like_id(source_type: &str, profile_id: &str, user_id: &str) format!("{source_type}:{profile_id}:{user_id}") } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recent_public_work_play_counts_group_requested_profiles_in_window() { + let now_micros = PUBLIC_WORK_PLAY_DAY_MICROS * 10; + let updated_at = Timestamp::from_micros_since_unix_epoch(now_micros); + let rows = vec![ + PublicWorkPlayDailyStat { + stat_id: "puzzle:profile-a:10".to_string(), + source_type: "puzzle".to_string(), + owner_user_id: "user-a".to_string(), + profile_id: "profile-a".to_string(), + played_day: 10, + play_count: 3, + updated_at, + }, + PublicWorkPlayDailyStat { + stat_id: "puzzle:profile-a:4".to_string(), + source_type: "puzzle".to_string(), + owner_user_id: "user-a".to_string(), + profile_id: "profile-a".to_string(), + played_day: 4, + play_count: 5, + updated_at, + }, + PublicWorkPlayDailyStat { + stat_id: "puzzle:profile-a:3".to_string(), + source_type: "puzzle".to_string(), + owner_user_id: "user-a".to_string(), + profile_id: "profile-a".to_string(), + played_day: 3, + play_count: 99, + updated_at, + }, + PublicWorkPlayDailyStat { + stat_id: "custom-world:profile-a:10".to_string(), + source_type: "custom-world".to_string(), + owner_user_id: "user-a".to_string(), + profile_id: "profile-a".to_string(), + played_day: 10, + play_count: 7, + updated_at, + }, + PublicWorkPlayDailyStat { + stat_id: "puzzle:profile-b:9".to_string(), + source_type: "puzzle".to_string(), + owner_user_id: "user-b".to_string(), + profile_id: "profile-b".to_string(), + played_day: 9, + play_count: 11, + updated_at, + }, + ]; + + let counts = build_recent_public_work_play_counts( + rows, + "puzzle", + &["profile-a".to_string(), "profile-b".to_string()], + now_micros, + ); + + assert_eq!(counts.get("profile-a"), Some(&8)); + assert_eq!(counts.get("profile-b"), Some(&11)); + assert_eq!(counts.get("profile-c"), None); + } +} + fn ensure_profile_dashboard_state(ctx: &ReducerContext, user_id: &str, updated_at: Timestamp) { if ctx .db