max / makenotwork
6 files changed,
+75 insertions,
-16 deletions
| @@ -85,6 +85,28 @@ pub async fn get_issue_by_number( | |||
| 85 | 85 | Ok(issue) | |
| 86 | 86 | } | |
| 87 | 87 | ||
| 88 | + | /// Fetch multiple issues by number in a single query, keyed by number. Used by | |
| 89 | + | /// the git push-refs handler so a push touching many commits doesn't run one | |
| 90 | + | /// `get_issue_by_number` per commit-reference (N+1). | |
| 91 | + | #[tracing::instrument(skip_all)] | |
| 92 | + | pub async fn get_issues_by_numbers( | |
| 93 | + | pool: &PgPool, | |
| 94 | + | repo_id: GitRepoId, | |
| 95 | + | numbers: &[i32], | |
| 96 | + | ) -> Result<std::collections::HashMap<i32, DbIssue>> { | |
| 97 | + | if numbers.is_empty() { | |
| 98 | + | return Ok(std::collections::HashMap::new()); | |
| 99 | + | } | |
| 100 | + | let issues = sqlx::query_as::<_, DbIssue>( | |
| 101 | + | "SELECT * FROM issues WHERE repo_id = $1 AND number = ANY($2)", | |
| 102 | + | ) | |
| 103 | + | .bind(repo_id) | |
| 104 | + | .bind(numbers) | |
| 105 | + | .fetch_all(pool) | |
| 106 | + | .await?; | |
| 107 | + | Ok(issues.into_iter().map(|i| (i.number, i)).collect()) | |
| 108 | + | } | |
| 109 | + | ||
| 88 | 110 | /// List issues with author username and comment count. Returns (issues, total_count). | |
| 89 | 111 | #[tracing::instrument(skip_all)] | |
| 90 | 112 | pub async fn list_issues( |
| @@ -167,6 +167,7 @@ impl MtClient { | |||
| 167 | 167 | } | |
| 168 | 168 | ||
| 169 | 169 | /// Create or retrieve an existing community on MT. | |
| 170 | + | #[tracing::instrument(skip_all)] | |
| 170 | 171 | pub async fn create_community( | |
| 171 | 172 | &self, | |
| 172 | 173 | req: &CreateCommunityRequest, | |
| @@ -175,6 +176,7 @@ impl MtClient { | |||
| 175 | 176 | } | |
| 176 | 177 | ||
| 177 | 178 | /// Create a discussion thread on MT linked to MNW content. | |
| 179 | + | #[tracing::instrument(skip_all)] | |
| 178 | 180 | pub async fn create_thread( | |
| 179 | 181 | &self, | |
| 180 | 182 | req: &CreateThreadRequest, | |
| @@ -183,6 +185,7 @@ impl MtClient { | |||
| 183 | 185 | } | |
| 184 | 186 | ||
| 185 | 187 | /// Add a reply to an existing thread on MT. | |
| 188 | + | #[tracing::instrument(skip_all, fields(thread_id = %thread_id))] | |
| 186 | 189 | pub async fn create_post( | |
| 187 | 190 | &self, | |
| 188 | 191 | thread_id: MtThreadId, | |
| @@ -193,6 +196,7 @@ impl MtClient { | |||
| 193 | 196 | } | |
| 194 | 197 | ||
| 195 | 198 | /// Get thread stats (post count + last activity). | |
| 199 | + | #[tracing::instrument(skip_all, fields(thread_id = %thread_id))] | |
| 196 | 200 | pub async fn get_thread_stats( | |
| 197 | 201 | &self, | |
| 198 | 202 | thread_id: MtThreadId, |
| @@ -71,11 +71,25 @@ pub(super) async fn admin_decide_appeal( | |||
| 71 | 71 | && let Some(ref account_id) = db_user.stripe_account_id | |
| 72 | 72 | { | |
| 73 | 73 | let resumed = db::subscriptions::resume_subscriptions_for_creator(&state.db, user_id).await?; | |
| 74 | + | // Resume each paused subscription on Stripe with bounded concurrency, so | |
| 75 | + | // a creator with many fans doesn't serialize one network round-trip per | |
| 76 | + | // subscription (the appeal handler would otherwise block for N x RTT). | |
| 77 | + | const RESUME_PARALLELISM: usize = 8; | |
| 78 | + | let mut set = tokio::task::JoinSet::new(); | |
| 74 | 79 | for sub in &resumed { | |
| 75 | - | if let Err(e) = stripe.resume_subscription(&sub.stripe_subscription_id, account_id).await { | |
| 76 | - | tracing::error!(stripe_sub_id = %sub.stripe_subscription_id, error = ?e, "failed to resume subscription on appeal approval"); | |
| 80 | + | if set.len() >= RESUME_PARALLELISM { | |
| 81 | + | let _ = set.join_next().await; | |
| 77 | 82 | } | |
| 83 | + | let stripe = stripe.clone(); | |
| 84 | + | let account_id = account_id.clone(); | |
| 85 | + | let sub_id = sub.stripe_subscription_id.clone(); | |
| 86 | + | set.spawn(async move { | |
| 87 | + | if let Err(e) = stripe.resume_subscription(&sub_id, &account_id).await { | |
| 88 | + | tracing::error!(stripe_sub_id = %sub_id, error = ?e, "failed to resume subscription on appeal approval"); | |
| 89 | + | } | |
| 90 | + | }); | |
| 78 | 91 | } | |
| 92 | + | while set.join_next().await.is_some() {} | |
| 79 | 93 | if !resumed.is_empty() { | |
| 80 | 94 | tracing::info!(user_id = %user_id, resumed = resumed.len(), "resumed fan subscriptions on appeal approval"); | |
| 81 | 95 | } |
| @@ -187,21 +187,34 @@ pub(super) async fn process_push( | |||
| 187 | 187 | .await | |
| 188 | 188 | .context("git push-refs walk task")??; | |
| 189 | 189 | ||
| 190 | + | // Batch-fetch every referenced issue once (deduped across commits) so a push | |
| 191 | + | // touching many commits doesn't run one get_issue_by_number per reference. | |
| 192 | + | // The local map is mutated as statuses change, so repeated references to the | |
| 193 | + | // same issue see its current state without re-querying. | |
| 194 | + | let mut numbers: Vec<i32> = commit_refs | |
| 195 | + | .iter() | |
| 196 | + | .flat_map(|(_, _, refs)| refs.iter().map(|r| r.number)) | |
| 197 | + | .collect(); | |
| 198 | + | numbers.sort_unstable(); | |
| 199 | + | numbers.dedup(); | |
| 200 | + | let mut issues = db::issues::get_issues_by_numbers(&state.db, repo.id, &numbers).await?; | |
| 201 | + | ||
| 190 | 202 | // Now process DB operations (async-safe, git2 types are dropped) | |
| 191 | 203 | let mut processed = 0u32; | |
| 192 | 204 | for (oid_str, short_oid, refs) in &commit_refs { | |
| 193 | 205 | for issue_ref in refs { | |
| 194 | - | let issue = match db::issues::get_issue_by_number(&state.db, repo.id, issue_ref.number).await { | |
| 195 | - | Ok(Some(i)) => i, | |
| 196 | - | _ => continue, | |
| 206 | + | let Some(issue) = issues.get_mut(&issue_ref.number) else { | |
| 207 | + | continue; | |
| 197 | 208 | }; | |
| 198 | 209 | ||
| 199 | 210 | match issue_ref.action { | |
| 200 | 211 | IssueRefAction::Close => { | |
| 201 | - | if issue.status == IssueStatus::Open | |
| 202 | - | && let Err(e) = db::issues::update_issue_status(&state.db, issue.id, IssueStatus::Closed).await | |
| 203 | - | { | |
| 204 | - | tracing::warn!(issue_id = %issue.id, error = ?e, "failed to close issue via push"); | |
| 212 | + | if issue.status == IssueStatus::Open { | |
| 213 | + | if let Err(e) = db::issues::update_issue_status(&state.db, issue.id, IssueStatus::Closed).await { | |
| 214 | + | tracing::warn!(issue_id = %issue.id, error = ?e, "failed to close issue via push"); | |
| 215 | + | } else { | |
| 216 | + | issue.status = IssueStatus::Closed; | |
| 217 | + | } | |
| 205 | 218 | } | |
| 206 | 219 | let body_md = format!( | |
| 207 | 220 | "Closed via commit [`{}`](/git/{}/{}/commit/{}) on `{}`.", | |
| @@ -215,10 +228,12 @@ pub(super) async fn process_push( | |||
| 215 | 228 | } | |
| 216 | 229 | } | |
| 217 | 230 | IssueRefAction::Reopen => { | |
| 218 | - | if issue.status == IssueStatus::Closed | |
| 219 | - | && let Err(e) = db::issues::update_issue_status(&state.db, issue.id, IssueStatus::Open).await | |
| 220 | - | { | |
| 221 | - | tracing::warn!(issue_id = %issue.id, error = ?e, "failed to reopen issue via push"); | |
| 231 | + | if issue.status == IssueStatus::Closed { | |
| 232 | + | if let Err(e) = db::issues::update_issue_status(&state.db, issue.id, IssueStatus::Open).await { | |
| 233 | + | tracing::warn!(issue_id = %issue.id, error = ?e, "failed to reopen issue via push"); | |
| 234 | + | } else { | |
| 235 | + | issue.status = IssueStatus::Open; | |
| 236 | + | } | |
| 222 | 237 | } | |
| 223 | 238 | let body_md = format!( | |
| 224 | 239 | "Reopened via commit [`{}`](/git/{}/{}/commit/{}) on `{}`.", |
| @@ -199,9 +199,6 @@ pub fn spawn_scheduler( | |||
| 199 | 199 | } | |
| 200 | 200 | } | |
| 201 | 201 | ||
| 202 | - | // Enforce post-grace item hiding (canceled 30+ days ago) | |
| 203 | - | integrity::enforce_post_grace_hiding(&state).await; | |
| 204 | - | ||
| 205 | 202 | // Clean up expired idempotency keys (every tick is fine — cheap DELETE) | |
| 206 | 203 | if let Err(e) = db::idempotency::cleanup_expired(&state.db).await { | |
| 207 | 204 | tracing::error!(error = ?e, "failed to clean up expired idempotency keys"); | |
| @@ -263,6 +260,12 @@ pub fn spawn_scheduler( | |||
| 263 | 260 | integrity::check_stale_subscriptions(&state).await; | |
| 264 | 261 | integrity::check_email_bounce_spike(&state).await; | |
| 265 | 262 | ||
| 263 | + | // Enforce post-grace item hiding (canceled 30+ days ago). Daily | |
| 264 | + | // is ample for a 30-day grace window, and it's self-draining | |
| 265 | + | // (mark_grace_enforced), so running it every 60s tick only | |
| 266 | + | // re-issued an empty query — moved here off the hot tick path. | |
| 267 | + | integrity::enforce_post_grace_hiding(&state).await; | |
| 268 | + | ||
| 266 | 269 | // Prune session records inactive for 90+ days | |
| 267 | 270 | let session_threshold = chrono::Utc::now() - chrono::Duration::days(90); | |
| 268 | 271 | match db::sessions::prune_expired_sessions(&state.db, session_threshold).await { |
| @@ -41,6 +41,7 @@ impl WamClient { | |||
| 41 | 41 | ||
| 42 | 42 | /// Create a ticket in WAM. Errors are logged but never propagated — WAM | |
| 43 | 43 | /// is a best-effort notification channel, not a critical path. | |
| 44 | + | #[tracing::instrument(skip_all, fields(source = %source, priority = %priority))] | |
| 44 | 45 | pub async fn create_ticket( | |
| 45 | 46 | &self, | |
| 46 | 47 | title: &str, |