max / makenotwork
4 files changed,
+105 insertions,
-11 deletions
| @@ -208,8 +208,9 @@ pub async fn create_thread_with_op( | |||
| 208 | 208 | /// Insert a reply and bump the thread's last_activity_at atomically. | |
| 209 | 209 | /// | |
| 210 | 210 | /// Opening posts are created with the thread via [`create_thread_with_op`]; | |
| 211 | - | /// this is for replies only. Reply counts are computed live from non-removed | |
| 212 | - | /// posts at read time (no denormalized counter to maintain). | |
| 211 | + | /// this is for replies only. The display reply count derives from the | |
| 212 | + | /// denormalized `threads.post_count`, which the m029 trigger maintains on every | |
| 213 | + | /// post INSERT/DELETE/removed_at change — no counter to update by hand here. | |
| 213 | 214 | #[tracing::instrument(skip_all)] | |
| 214 | 215 | pub async fn create_post( | |
| 215 | 216 | pool: &PgPool, |
| @@ -884,6 +884,7 @@ pub async fn get_user_profile_in_community( | |||
| 884 | 884 | JOIN categories c ON c.id = t.category_id | |
| 885 | 885 | WHERE p.author_id = u.mnw_account_id | |
| 886 | 886 | AND c.community_id = co.id | |
| 887 | + | AND p.removed_at IS NULL | |
| 887 | 888 | AND p.deleted_at IS NULL | |
| 888 | 889 | AND t.deleted_at IS NULL) AS post_count, | |
| 889 | 890 | (SELECT COUNT(*) FROM post_endorsements pe | |
| @@ -892,6 +893,7 @@ pub async fn get_user_profile_in_community( | |||
| 892 | 893 | JOIN categories c ON c.id = t.category_id | |
| 893 | 894 | WHERE p.author_id = u.mnw_account_id | |
| 894 | 895 | AND c.community_id = co.id | |
| 896 | + | AND p.removed_at IS NULL | |
| 895 | 897 | AND p.deleted_at IS NULL | |
| 896 | 898 | AND t.deleted_at IS NULL) AS endorsement_count | |
| 897 | 899 | FROM users u | |
| @@ -935,6 +937,7 @@ pub async fn get_user_activity_in_community( | |||
| 935 | 937 | JOIN categories c ON c.id = t.category_id | |
| 936 | 938 | WHERE c.community_id = $1 | |
| 937 | 939 | AND p.author_id = $2 | |
| 940 | + | AND p.removed_at IS NULL | |
| 938 | 941 | AND p.deleted_at IS NULL | |
| 939 | 942 | AND t.deleted_at IS NULL | |
| 940 | 943 | ORDER BY p.created_at DESC | |
| @@ -972,6 +975,7 @@ pub async fn get_user_membership_summary( | |||
| 972 | 975 | JOIN categories c ON c.id = t.category_id | |
| 973 | 976 | WHERE p.author_id = $1 | |
| 974 | 977 | AND c.community_id = co.id | |
| 978 | + | AND p.removed_at IS NULL | |
| 975 | 979 | AND p.deleted_at IS NULL | |
| 976 | 980 | AND t.deleted_at IS NULL) AS post_count | |
| 977 | 981 | FROM memberships m |
| @@ -81,20 +81,29 @@ async fn main() { | |||
| 81 | 81 | let session_store = PostgresStore::new(pool); | |
| 82 | 82 | session_store.migrate().await.expect("failed to migrate session store"); | |
| 83 | 83 | ||
| 84 | - | let deletion_task = tokio::task::spawn( | |
| 85 | - | session_store | |
| 84 | + | // Both background loops run under panic supervision: a panic inside a sweep | |
| 85 | + | // would otherwise silently kill the worker for the life of the process | |
| 86 | + | // (S3 orphans pile up / the session table grows unbounded) while the server | |
| 87 | + | // keeps serving. `supervise` restarts the worker after a panic. | |
| 88 | + | let deletion_store = session_store.clone(); | |
| 89 | + | let deletion_task = supervise("session-expiry-sweep", move || { | |
| 90 | + | deletion_store | |
| 86 | 91 | .clone() | |
| 87 | - | .continuously_delete_expired(tokio::time::Duration::from_secs(3600)), | |
| 88 | - | ); | |
| 92 | + | .continuously_delete_expired(tokio::time::Duration::from_secs(3600)) | |
| 93 | + | }); | |
| 89 | 94 | ||
| 90 | 95 | // Reconcile sweep: purge S3 objects for removed images (backlog + retries). | |
| 91 | 96 | // Only meaningful when S3 is configured. | |
| 92 | 97 | let purge_task = state.s3.as_ref().map(|s3| { | |
| 93 | - | tokio::task::spawn(multithreaded::maintenance::continuously_purge_removed_images( | |
| 94 | - | state.db.clone(), | |
| 95 | - | s3.clone(), | |
| 96 | - | tokio::time::Duration::from_secs(6 * 3600), | |
| 97 | - | )) | |
| 98 | + | let db = state.db.clone(); | |
| 99 | + | let s3 = s3.clone(); | |
| 100 | + | supervise("image-purge-sweep", move || { | |
| 101 | + | multithreaded::maintenance::continuously_purge_removed_images( | |
| 102 | + | db.clone(), | |
| 103 | + | s3.clone(), | |
| 104 | + | tokio::time::Duration::from_secs(6 * 3600), | |
| 105 | + | ) | |
| 106 | + | }) | |
| 98 | 107 | }); | |
| 99 | 108 | ||
| 100 | 109 | let session_layer = SessionManagerLayer::new(session_store) | |
| @@ -162,6 +171,37 @@ async fn main() { | |||
| 162 | 171 | } | |
| 163 | 172 | } | |
| 164 | 173 | ||
| 174 | + | /// Spawn a never-returning background loop under panic supervision. | |
| 175 | + | /// | |
| 176 | + | /// The inner future is run in its own task so a panic surfaces here as a | |
| 177 | + | /// `JoinError` instead of silently killing the worker; on a panic — or an | |
| 178 | + | /// unexpected normal return — it is logged and restarted after a short backoff. | |
| 179 | + | /// `factory` is `Fn` so it can rebuild the future (cloning any captured handles) | |
| 180 | + | /// on each restart. Aborting the returned handle (at shutdown) stops the loop. | |
| 181 | + | fn supervise<F, Fut>(name: &'static str, factory: F) -> tokio::task::JoinHandle<()> | |
| 182 | + | where | |
| 183 | + | F: Fn() -> Fut + Send + 'static, | |
| 184 | + | Fut: std::future::Future + Send + 'static, | |
| 185 | + | Fut::Output: Send, | |
| 186 | + | { | |
| 187 | + | const RESTART_BACKOFF: std::time::Duration = std::time::Duration::from_secs(5); | |
| 188 | + | tokio::task::spawn(async move { | |
| 189 | + | loop { | |
| 190 | + | match tokio::task::spawn(factory()).await { | |
| 191 | + | Ok(_) => { | |
| 192 | + | tracing::error!(task = name, "background task returned unexpectedly; restarting"); | |
| 193 | + | } | |
| 194 | + | Err(e) if e.is_panic() => { | |
| 195 | + | tracing::error!(task = name, "background task panicked; restarting"); | |
| 196 | + | } | |
| 197 | + | // Cancelled: the supervisor itself is being aborted at shutdown. | |
| 198 | + | Err(_) => return, | |
| 199 | + | } | |
| 200 | + | tokio::time::sleep(RESTART_BACKOFF).await; | |
| 201 | + | } | |
| 202 | + | }) | |
| 203 | + | } | |
| 204 | + | ||
| 165 | 205 | async fn shutdown_signal() { | |
| 166 | 206 | use tokio::signal; | |
| 167 | 207 | let ctrl_c = async { signal::ctrl_c().await.expect("failed to install Ctrl+C handler") }; |
| @@ -198,6 +198,55 @@ async fn test_membership_summary_post_count() { | |||
| 198 | 198 | } | |
| 199 | 199 | ||
| 200 | 200 | #[tokio::test] | |
| 201 | + | async fn profile_counts_exclude_mod_removed_posts() { | |
| 202 | + | // Mod-removed posts (removed_at set) must not inflate profile/membership | |
| 203 | + | // post counts or the activity feed — the live hide column is removed_at, | |
| 204 | + | // not the (unused-for-posts) deleted_at (audit B1/B2). | |
| 205 | + | let mut h = TestHarness::new().await; | |
| 206 | + | let user_id = h.login_as("removee").await; | |
| 207 | + | let mod_id = h.login_as("themod").await; | |
| 208 | + | let comm_id = h.create_community("Mod Test", "modtest").await; | |
| 209 | + | let cat_id = h.create_category(comm_id, "General", "general").await; | |
| 210 | + | h.add_membership(user_id, comm_id, "member").await; | |
| 211 | + | ||
| 212 | + | // OP + two replies = 3 posts; then mod-remove one reply. | |
| 213 | + | let thread_id = h | |
| 214 | + | .create_thread_with_post(cat_id, user_id, "Thread", "OP body") | |
| 215 | + | .await; | |
| 216 | + | mt_db::mutations::create_post(&h.db, thread_id, user_id, "kept reply", "<p>kept</p>") | |
| 217 | + | .await | |
| 218 | + | .unwrap(); | |
| 219 | + | let removed_post = | |
| 220 | + | mt_db::mutations::create_post(&h.db, thread_id, user_id, "bad reply", "<p>bad</p>") | |
| 221 | + | .await | |
| 222 | + | .unwrap(); | |
| 223 | + | assert!( | |
| 224 | + | mt_db::mutations::mod_remove_post(&h.db, removed_post, mod_id) | |
| 225 | + | .await | |
| 226 | + | .unwrap() | |
| 227 | + | ); | |
| 228 | + | ||
| 229 | + | // Membership summary: 3 created, 1 removed → 2. | |
| 230 | + | let summaries = mt_db::queries::get_user_membership_summary(&h.db, user_id) | |
| 231 | + | .await | |
| 232 | + | .unwrap(); | |
| 233 | + | assert_eq!(summaries[0].post_count, 2, "removed post excluded from summary"); | |
| 234 | + | ||
| 235 | + | // Profile counts: same exclusion on the per-community post_count. | |
| 236 | + | let profile = mt_db::queries::get_user_profile_in_community(&h.db, "modtest", "removee") | |
| 237 | + | .await | |
| 238 | + | .unwrap() | |
| 239 | + | .expect("profile exists"); | |
| 240 | + | assert_eq!(profile.post_count, 2, "removed post excluded from profile count"); | |
| 241 | + | ||
| 242 | + | // Activity feed must not surface the removed post. | |
| 243 | + | let activity = mt_db::queries::get_user_activity_in_community(&h.db, comm_id, user_id, 50) | |
| 244 | + | .await | |
| 245 | + | .unwrap(); | |
| 246 | + | assert_eq!(activity.len(), 2, "removed post excluded from activity feed"); | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | #[tokio::test] | |
| 201 | 250 | async fn test_membership_summary_excludes_suspended() { | |
| 202 | 251 | let mut h = TestHarness::new().await; | |
| 203 | 252 |