perf: batch recent play counts for gallery lists

This commit is contained in:
2026-05-12 10:59:51 +08:00
parent 612d105a23
commit 183e78d475
3 changed files with 213 additions and 26 deletions

View File

@@ -1,5 +1,5 @@
use crate::*; use crate::*;
use std::collections::HashSet; use std::collections::{HashMap, HashSet};
#[spacetimedb::table( #[spacetimedb::table(
accessor = custom_world_profile, accessor = custom_world_profile,
@@ -1549,11 +1549,26 @@ fn list_custom_world_gallery_snapshots(
) -> Result<Vec<CustomWorldGalleryEntrySnapshot>, String> { ) -> Result<Vec<CustomWorldGalleryEntrySnapshot>, String> {
sync_missing_custom_world_gallery_entries(ctx)?; sync_missing_custom_world_gallery_entries(ctx)?;
let mut entries = ctx let entries = ctx
.db .db
.custom_world_gallery_entry() .custom_world_gallery_entry()
.iter() .iter()
.map(|row| build_custom_world_gallery_entry_snapshot(ctx, &row)) .collect::<Vec<_>>();
let profile_ids = entries
.iter()
.map(|row| row.profile_id.clone())
.collect::<Vec<_>>();
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::<Vec<_>>(); .collect::<Vec<_>>();
entries.sort_by(|left, right| { entries.sort_by(|left, right| {
@@ -5078,6 +5093,19 @@ fn build_custom_world_draft_card_snapshot(
fn build_custom_world_gallery_entry_snapshot( fn build_custom_world_gallery_entry_snapshot(
ctx: &ReducerContext, ctx: &ReducerContext,
row: &CustomWorldGalleryEntry, 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<String, u32>,
) -> CustomWorldGalleryEntrySnapshot { ) -> CustomWorldGalleryEntrySnapshot {
CustomWorldGalleryEntrySnapshot { CustomWorldGalleryEntrySnapshot {
profile_id: row.profile_id.clone(), profile_id: row.profile_id.clone(),
@@ -5095,12 +5123,10 @@ fn build_custom_world_gallery_entry_snapshot(
play_count: row.play_count, play_count: row.play_count,
remix_count: row.remix_count, remix_count: row.remix_count,
like_count: row.like_count, like_count: row.like_count,
recent_play_count_7d: count_recent_public_work_plays( recent_play_count_7d: recent_play_counts
ctx, .get(&row.profile_id)
"custom-world", .copied()
&row.profile_id, .unwrap_or(0),
ctx.timestamp.to_micros_since_unix_epoch(),
),
published_at_micros: row.published_at.to_micros_since_unix_epoch(), published_at_micros: row.published_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
} }

View File

@@ -1,8 +1,9 @@
use crate::runtime::{ use crate::runtime::{
ProfilePlayedWorkUpsertInput, ProfileSaveArchiveUpsertInput, PublicWorkLikeRecordInput, ProfilePlayedWorkUpsertInput, ProfileSaveArchiveUpsertInput, PublicWorkLikeRecordInput,
PublicWorkPlayRecordInput, add_profile_observed_play_time, count_recent_public_work_plays, PublicWorkPlayRecordInput, add_profile_observed_play_time, count_recent_public_work_plays,
grant_profile_wallet_points, record_public_work_like, record_public_work_play, count_recent_public_work_plays_for_profiles, grant_profile_wallet_points,
upsert_profile_played_work, upsert_profile_save_archive, record_public_work_like, record_public_work_play, upsert_profile_played_work,
upsert_profile_save_archive,
}; };
use module_puzzle::{ use module_puzzle::{
PUZZLE_MAX_TAG_COUNT, PUZZLE_NEXT_LEVEL_MODE_NONE, PUZZLE_NEXT_LEVEL_MODE_SAME_WORK, 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<Vec<PuzzleWorkProfile>, String> { fn list_puzzle_gallery_tx(ctx: &TxContext) -> Result<Vec<PuzzleWorkProfile>, String> {
let now_micros = ctx.timestamp.to_micros_since_unix_epoch(); let now_micros = ctx.timestamp.to_micros_since_unix_epoch();
let mut items = ctx let rows = ctx
.db .db
.puzzle_work_profile() .puzzle_work_profile()
.iter() .iter()
.filter(|row| row.publication_status == PuzzlePublicationStatus::Published) .filter(|row| row.publication_status == PuzzlePublicationStatus::Published)
.map(|row| build_puzzle_work_profile_from_row_with_recent_count(ctx, &row, now_micros)) .collect::<Vec<_>>();
let profile_ids = rows
.iter()
.map(|row| row.profile_id.clone())
.collect::<Vec<_>>();
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::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
items.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros)); items.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros));
Ok(items) Ok(items)
@@ -2268,6 +2278,18 @@ fn build_puzzle_work_profile_from_row_with_recent_count(
Ok(profile) Ok(profile)
} }
fn build_puzzle_work_profile_from_row_with_recent_counts(
row: &PuzzleWorkProfileRow,
recent_play_counts: &std::collections::HashMap<String, u32>,
) -> Result<PuzzleWorkProfile, String> {
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( fn build_puzzle_work_profile_from_row_without_recent_count(
row: &PuzzleWorkProfileRow, row: &PuzzleWorkProfileRow,
) -> Result<PuzzleWorkProfile, String> { ) -> Result<PuzzleWorkProfile, String> {

View File

@@ -1,4 +1,5 @@
use crate::*; use crate::*;
use std::collections::{HashMap, HashSet};
const PUBLIC_WORK_PLAY_DAY_MICROS: i64 = 86_400_000_000; const PUBLIC_WORK_PLAY_DAY_MICROS: i64 = 86_400_000_000;
const PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS: i64 = 7; const PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS: i64 = 7;
@@ -1299,25 +1300,94 @@ pub(crate) fn count_recent_public_work_plays(
profile_id: &str, profile_id: &str,
now_micros: i64, now_micros: i64,
) -> u32 { ) -> 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<String, u32> {
let source_type = source_type.trim(); let source_type = source_type.trim();
let profile_id = profile_id.trim(); if source_type.is_empty() || profile_ids.is_empty() {
if source_type.is_empty() || profile_id.is_empty() { return HashMap::new();
return 0;
} }
let current_day = public_work_play_day_from_micros(now_micros); 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 first_day = current_day.saturating_sub(PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS - 1);
let requested_profile_ids = profile_ids
ctx.db
.public_work_play_daily_stat()
.iter() .iter()
.filter(|row| { .map(|profile_id| profile_id.trim())
row.source_type == source_type .filter(|profile_id| !profile_id.is_empty())
&& row.profile_id == profile_id .collect::<HashSet<_>>();
&& row.played_day >= first_day let mut counts = HashMap::new();
&& row.played_day <= current_day
}) for profile_id in requested_profile_ids {
.fold(0u32, |total, row| total.saturating_add(row.play_count)) 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<Item = PublicWorkPlayDailyStat>,
source_type: &str,
profile_ids: &[String],
now_micros: i64,
) -> HashMap<String, u32> {
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::<HashSet<_>>();
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 { 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}") 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) { fn ensure_profile_dashboard_state(ctx: &ReducerContext, user_id: &str, updated_at: Timestamp) {
if ctx if ctx
.db .db