max / makenotwork
17 files changed,
+458 insertions,
-145 deletions
| @@ -221,6 +221,19 @@ pub const SCAN_SPOOL_MAX_BYTES: u64 = 8 * 1024 * 1024 * 1024; | |||
| 221 | 221 | /// pending object. The scanner refuses if `statvfs(free) - expected_size` | |
| 222 | 222 | /// would drop below this threshold. | |
| 223 | 223 | pub const SCAN_SPOOL_FREE_RESERVE_BYTES: u64 = 2 * 1024 * 1024 * 1024; | |
| 224 | + | /// Slack added to a scan job's claimed object size when bounding how many bytes | |
| 225 | + | /// the spool writer will accept. The S3 object size is authoritative, but a | |
| 226 | + | /// small margin absorbs benign content-length/multipart rounding without | |
| 227 | + | /// letting an under-reported object stream the full `SCAN_SPOOL_MAX_BYTES` to | |
| 228 | + | /// scratch before the writer aborts. | |
| 229 | + | pub const SCAN_SPOOL_SLACK_BYTES: u64 = 16 * 1024 * 1024; // 16 MiB | |
| 230 | + | /// Maximum number of bytes fed to YARA in a single scan. yara-x's `Scanner` | |
| 231 | + | /// walks the whole slice, which demand-pages the entire mmap resident — so an | |
| 232 | + | /// 8 GiB object would otherwise pin 8 GiB of page cache per scan (× | |
| 233 | + | /// `SCAN_MAX_CONCURRENT`). Malware signatures cluster near a file's start, and | |
| 234 | + | /// ClamAV (streamed, uncapped) is the full-file backstop, so scanning a generous | |
| 235 | + | /// prefix is the right trade. Above this, YARA sees the prefix and logs the cap. | |
| 236 | + | pub const SCAN_YARA_MAX_BYTES: usize = 512 * 1024 * 1024; // 512 MiB | |
| 224 | 237 | ||
| 225 | 238 | // -- Caddy on-demand TLS -- | |
| 226 | 239 | // Caps concurrent cache-miss DB lookups in `/api/domains/caddy-ask`. Cache hits |
| @@ -257,6 +257,79 @@ pub async fn create_comment( | |||
| 257 | 257 | Ok(comment) | |
| 258 | 258 | } | |
| 259 | 259 | ||
| 260 | + | /// Bulk-insert issue comments in a single statement, then touch each affected | |
| 261 | + | /// issue's `updated_at` once. The git-push processor would otherwise issue one | |
| 262 | + | /// INSERT (and one updated_at UPDATE) per referenced commit, ~100-150 sequential | |
| 263 | + | /// writes per push (ultra-fuzz Run 6 S6). All comments share one author. | |
| 264 | + | #[tracing::instrument(skip_all)] | |
| 265 | + | pub async fn create_comments( | |
| 266 | + | pool: &PgPool, | |
| 267 | + | author_id: UserId, | |
| 268 | + | comments: &[(IssueId, String, String)], | |
| 269 | + | ) -> Result<()> { | |
| 270 | + | if comments.is_empty() { | |
| 271 | + | return Ok(()); | |
| 272 | + | } | |
| 273 | + | let issue_ids: Vec<sqlx::types::Uuid> = | |
| 274 | + | comments.iter().map(|(id, _, _)| *id.as_uuid()).collect(); | |
| 275 | + | let bodies_md: Vec<&str> = comments.iter().map(|(_, md, _)| md.as_str()).collect(); | |
| 276 | + | let bodies_html: Vec<&str> = comments.iter().map(|(_, _, html)| html.as_str()).collect(); | |
| 277 | + | ||
| 278 | + | sqlx::query( | |
| 279 | + | r#" | |
| 280 | + | INSERT INTO issue_comments (issue_id, author_user_id, body_markdown, body_html) | |
| 281 | + | SELECT u.issue_id, $1, u.body_md, u.body_html | |
| 282 | + | FROM UNNEST($2::uuid[], $3::text[], $4::text[]) AS u(issue_id, body_md, body_html) | |
| 283 | + | "#, | |
| 284 | + | ) | |
| 285 | + | .bind(author_id) | |
| 286 | + | .bind(&issue_ids) | |
| 287 | + | .bind(&bodies_md) | |
| 288 | + | .bind(&bodies_html) | |
| 289 | + | .execute(pool) | |
| 290 | + | .await?; | |
| 291 | + | ||
| 292 | + | // Touch updated_at once per distinct affected issue. | |
| 293 | + | let mut distinct = issue_ids; | |
| 294 | + | distinct.sort_unstable(); | |
| 295 | + | distinct.dedup(); | |
| 296 | + | sqlx::query("UPDATE issues SET updated_at = NOW() WHERE id = ANY($1)") | |
| 297 | + | .bind(&distinct) | |
| 298 | + | .execute(pool) | |
| 299 | + | .await?; | |
| 300 | + | ||
| 301 | + | Ok(()) | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | /// Bulk status update for the git-push processor. Each entry is `(issue, final | |
| 305 | + | /// status)`; the caller resolves repeated references to one final state so each | |
| 306 | + | /// issue appears at most once. | |
| 307 | + | #[tracing::instrument(skip_all)] | |
| 308 | + | pub async fn update_issue_statuses( | |
| 309 | + | pool: &PgPool, | |
| 310 | + | updates: &[(IssueId, IssueStatus)], | |
| 311 | + | ) -> Result<()> { | |
| 312 | + | if updates.is_empty() { | |
| 313 | + | return Ok(()); | |
| 314 | + | } | |
| 315 | + | let ids: Vec<sqlx::types::Uuid> = updates.iter().map(|(id, _)| *id.as_uuid()).collect(); | |
| 316 | + | let statuses: Vec<String> = updates.iter().map(|(_, s)| s.to_string()).collect(); | |
| 317 | + | ||
| 318 | + | sqlx::query( | |
| 319 | + | r#" | |
| 320 | + | UPDATE issues AS i SET status = u.status, updated_at = NOW() | |
| 321 | + | FROM UNNEST($1::uuid[], $2::text[]) AS u(id, status) | |
| 322 | + | WHERE i.id = u.id | |
| 323 | + | "#, | |
| 324 | + | ) | |
| 325 | + | .bind(&ids) | |
| 326 | + | .bind(&statuses) | |
| 327 | + | .execute(pool) | |
| 328 | + | .await?; | |
| 329 | + | ||
| 330 | + | Ok(()) | |
| 331 | + | } | |
| 332 | + | ||
| 260 | 333 | /// List all comments on an issue with author usernames. | |
| 261 | 334 | #[tracing::instrument(skip_all)] | |
| 262 | 335 | pub async fn list_comments( |
| @@ -218,6 +218,24 @@ pub async fn get_items_by_user(pool: &PgPool, user_id: UserId) -> Result<Vec<DbI | |||
| 218 | 218 | Ok(items) | |
| 219 | 219 | } | |
| 220 | 220 | ||
| 221 | + | /// Count a user's (non-deleted) items without materializing the rows. For | |
| 222 | + | /// callers that only need the total, this avoids fetching up to 500 full rows | |
| 223 | + | /// just to `.len()` them (ultra-fuzz Run 6 R6-Perf-M4). | |
| 224 | + | #[tracing::instrument(skip_all)] | |
| 225 | + | pub async fn count_items_by_user(pool: &PgPool, user_id: UserId) -> Result<i64> { | |
| 226 | + | let count: i64 = sqlx::query_scalar( | |
| 227 | + | r#" | |
| 228 | + | SELECT COUNT(*) FROM items i | |
| 229 | + | JOIN projects p ON i.project_id = p.id | |
| 230 | + | WHERE p.user_id = $1 AND i.deleted_at IS NULL | |
| 231 | + | "#, | |
| 232 | + | ) | |
| 233 | + | .bind(user_id) | |
| 234 | + | .fetch_one(pool) | |
| 235 | + | .await?; | |
| 236 | + | Ok(count) | |
| 237 | + | } | |
| 238 | + | ||
| 221 | 239 | /// Count items per project for all projects owned by a user. | |
| 222 | 240 | /// | |
| 223 | 241 | /// Returns `(project_id, count)` tuples. Used by the CLI to avoid N+1 queries. |
| @@ -123,6 +123,36 @@ pub async fn get_projects_by_user(pool: &PgPool, user_id: UserId) -> Result<Vec< | |||
| 123 | 123 | Ok(projects) | |
| 124 | 124 | } | |
| 125 | 125 | ||
| 126 | + | /// Of the given candidate slugs, return those already taken by a project owned | |
| 127 | + | /// by `user_id`. One indexed `slug = ANY($2)` query replaces a per-candidate | |
| 128 | + | /// point-query loop (ultra-fuzz Run 6 R6-Perf-M5). | |
| 129 | + | #[tracing::instrument(skip_all)] | |
| 130 | + | pub async fn filter_taken_slugs( | |
| 131 | + | pool: &PgPool, | |
| 132 | + | user_id: UserId, | |
| 133 | + | slugs: &[String], | |
| 134 | + | ) -> Result<Vec<String>> { | |
| 135 | + | let taken: Vec<String> = | |
| 136 | + | sqlx::query_scalar("SELECT slug FROM projects WHERE user_id = $1 AND slug = ANY($2)") | |
| 137 | + | .bind(user_id) | |
| 138 | + | .bind(slugs) | |
| 139 | + | .fetch_all(pool) | |
| 140 | + | .await?; | |
| 141 | + | Ok(taken) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | /// Count a user's projects without materializing the rows. For callers that only | |
| 145 | + | /// need the total (e.g. stats), this avoids fetching up to 500 full rows just to | |
| 146 | + | /// `.len()` them (ultra-fuzz Run 6 R6-Perf-M4). | |
| 147 | + | #[tracing::instrument(skip_all)] | |
| 148 | + | pub async fn count_projects_by_user(pool: &PgPool, user_id: UserId) -> Result<i64> { | |
| 149 | + | let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM projects WHERE user_id = $1") | |
| 150 | + | .bind(user_id) | |
| 151 | + | .fetch_one(pool) | |
| 152 | + | .await?; | |
| 153 | + | Ok(count) | |
| 154 | + | } | |
| 155 | + | ||
| 126 | 156 | /// Partially update a project's fields (COALESCE keeps existing values when `None`). | |
| 127 | 157 | /// | |
| 128 | 158 | /// When `features` is `Some`, the project_type is auto-derived from the new features. |
| @@ -1132,6 +1132,49 @@ pub async fn get_seller_contacts( | |||
| 1132 | 1132 | Ok(rows) | |
| 1133 | 1133 | } | |
| 1134 | 1134 | ||
| 1135 | + | /// One page of a seller's sharing-opted-in contacts for CSV export, newest | |
| 1136 | + | /// first. Paginated so the contacts export streams in bounded batches like its | |
| 1137 | + | /// sibling exports instead of buffering one capped query into a single `String` | |
| 1138 | + | /// (ultra-fuzz Run 6 R6-Perf-M2). `(MAX(completed_at), buyer_id)` ordering is | |
| 1139 | + | /// stable so OFFSET batches don't reorder. | |
| 1140 | + | #[tracing::instrument(skip_all)] | |
| 1141 | + | pub async fn get_seller_contacts_page( | |
| 1142 | + | pool: &PgPool, | |
| 1143 | + | seller_id: UserId, | |
| 1144 | + | limit: i64, | |
| 1145 | + | offset: i64, | |
| 1146 | + | ) -> Result<Vec<DbContactRow>> { | |
| 1147 | + | let rows = sqlx::query_as::<_, DbContactRow>( | |
| 1148 | + | r#" | |
| 1149 | + | SELECT | |
| 1150 | + | u.username, | |
| 1151 | + | u.email, | |
| 1152 | + | COUNT(*) AS total_purchases, | |
| 1153 | + | COALESCE(SUM(t.amount_cents), 0)::BIGINT AS total_spent_cents, | |
| 1154 | + | MAX(t.completed_at) AS last_purchase_at | |
| 1155 | + | FROM transactions t | |
| 1156 | + | JOIN users u ON u.id = t.buyer_id | |
| 1157 | + | WHERE t.seller_id = $1 | |
| 1158 | + | AND t.status = 'completed' | |
| 1159 | + | AND t.share_contact = true | |
| 1160 | + | AND NOT EXISTS ( | |
| 1161 | + | SELECT 1 FROM contact_revocations cr | |
| 1162 | + | WHERE cr.buyer_id = t.buyer_id AND cr.seller_id = t.seller_id | |
| 1163 | + | ) | |
| 1164 | + | GROUP BY t.buyer_id, u.username, u.email | |
| 1165 | + | ORDER BY MAX(t.completed_at) DESC, t.buyer_id | |
| 1166 | + | LIMIT $2 OFFSET $3 | |
| 1167 | + | "#, | |
| 1168 | + | ) | |
| 1169 | + | .bind(seller_id) | |
| 1170 | + | .bind(limit) | |
| 1171 | + | .bind(offset) | |
| 1172 | + | .fetch_all(pool) | |
| 1173 | + | .await?; | |
| 1174 | + | ||
| 1175 | + | Ok(rows) | |
| 1176 | + | } | |
| 1177 | + | ||
| 1135 | 1178 | /// Get seller transactions for CSV export, with conditional buyer email. | |
| 1136 | 1179 | /// | |
| 1137 | 1180 | /// Respects contact revocations: if a buyer revoked sharing, their email |
| @@ -0,0 +1,87 @@ | |||
| 1 | + | //! Background fan-out of a Stripe subscription operation across all of a | |
| 2 | + | //! creator's fan subscriptions. | |
| 3 | + | //! | |
| 4 | + | //! Pausing, suspending, or terminating a creator requires one Stripe call per | |
| 5 | + | //! fan subscription. Run inline on a request or webhook handler, that serial | |
| 6 | + | //! fan-out ties the hot path up for the duration of N Stripe round-trips — | |
| 7 | + | //! minutes for a large creator, and on the webhook path long enough to trip | |
| 8 | + | //! Stripe's delivery timeout and trigger a retry storm that re-runs the loop | |
| 9 | + | //! (ultra-fuzz Run 6 S1/S2/R6-Perf-M1). This module moves the loop onto the | |
| 10 | + | //! bounded background queue so the handler returns immediately. The operations | |
| 11 | + | //! are fire-and-forget — errors were only logged inline, and still are. | |
| 12 | + | ||
| 13 | + | use std::sync::Arc; | |
| 14 | + | ||
| 15 | + | use crate::background::BackgroundTx; | |
| 16 | + | use crate::payments::PaymentProvider; | |
| 17 | + | ||
| 18 | + | /// Which Stripe subscription operation to apply to each fan subscription. | |
| 19 | + | #[derive(Clone, Copy, Debug)] | |
| 20 | + | pub enum FanSubOp { | |
| 21 | + | /// Set (`true`) or clear (`false`) cancel_at_period_end — creator pause/unpause. | |
| 22 | + | CancelAtPeriodEnd(bool), | |
| 23 | + | /// Pause collection — admin suspend. | |
| 24 | + | Pause, | |
| 25 | + | /// Resume collection — admin unsuspend. | |
| 26 | + | Resume, | |
| 27 | + | /// Cancel outright — admin terminate. | |
| 28 | + | Cancel, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | impl FanSubOp { | |
| 32 | + | fn label(self) -> &'static str { | |
| 33 | + | match self { | |
| 34 | + | FanSubOp::CancelAtPeriodEnd(true) => "set_cancel_at_period_end", | |
| 35 | + | FanSubOp::CancelAtPeriodEnd(false) => "clear_cancel_at_period_end", | |
| 36 | + | FanSubOp::Pause => "pause", | |
| 37 | + | FanSubOp::Resume => "resume", | |
| 38 | + | FanSubOp::Cancel => "cancel", | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | async fn apply( | |
| 43 | + | self, | |
| 44 | + | stripe: &Arc<dyn PaymentProvider>, | |
| 45 | + | sub_id: &str, | |
| 46 | + | account_id: &str, | |
| 47 | + | ) -> crate::error::Result<()> { | |
| 48 | + | match self { | |
| 49 | + | FanSubOp::CancelAtPeriodEnd(c) => { | |
| 50 | + | stripe.set_cancel_at_period_end(sub_id, account_id, c).await | |
| 51 | + | } | |
| 52 | + | FanSubOp::Pause => stripe.pause_subscription(sub_id, account_id).await, | |
| 53 | + | FanSubOp::Resume => stripe.resume_subscription(sub_id, account_id).await, | |
| 54 | + | FanSubOp::Cancel => stripe.cancel_subscription(sub_id, account_id).await, | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /// Apply `op` to every subscription in `sub_ids` on the background queue. Returns | |
| 60 | + | /// immediately; the loop runs off the request/webhook hot path. Per-subscription | |
| 61 | + | /// failures are logged and do not abort the rest. No-op for an empty list. | |
| 62 | + | pub fn spawn_fan_sub_fanout( | |
| 63 | + | bg: &BackgroundTx, | |
| 64 | + | stripe: Arc<dyn PaymentProvider>, | |
| 65 | + | account_id: String, | |
| 66 | + | sub_ids: Vec<String>, | |
| 67 | + | op: FanSubOp, | |
| 68 | + | ) { | |
| 69 | + | if sub_ids.is_empty() { | |
| 70 | + | return; | |
| 71 | + | } | |
| 72 | + | bg.spawn("fan-sub stripe fan-out", async move { | |
| 73 | + | let total = sub_ids.len(); | |
| 74 | + | let mut failed = 0u32; | |
| 75 | + | for sub_id in &sub_ids { | |
| 76 | + | if let Err(e) = op.apply(&stripe, sub_id, &account_id).await { | |
| 77 | + | failed += 1; | |
| 78 | + | tracing::warn!(stripe_sub_id = %sub_id, op = op.label(), error = ?e, "fan subscription op failed"); | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | if failed > 0 { | |
| 82 | + | tracing::warn!(total, failed, op = op.label(), "fan subscription fan-out completed with failures"); | |
| 83 | + | } else { | |
| 84 | + | tracing::debug!(total, op = op.label(), "fan subscription fan-out completed"); | |
| 85 | + | } | |
| 86 | + | }); | |
| 87 | + | } |
| @@ -17,6 +17,7 @@ | |||
| 17 | 17 | mod checkout; | |
| 18 | 18 | mod checkout_metadata; | |
| 19 | 19 | mod connect; | |
| 20 | + | pub mod fan_ops; | |
| 20 | 21 | pub mod synckit_app_pricing; | |
| 21 | 22 | pub mod synckit_billing; | |
| 22 | 23 | mod webhooks; |
| @@ -172,19 +172,25 @@ pub(super) async fn admin_suspend_user( | |||
| 172 | 172 | // Record moderation action for audit trail | |
| 173 | 173 | db::moderation::create_action(&state.db, id, admin.id, ModerationActionType::Suspension, reason, None).await?; | |
| 174 | 174 | ||
| 175 | - | // Pause fan subscriptions to this creator's projects | |
| 175 | + | // Pause fan subscriptions to this creator's projects. The DB pause runs | |
| 176 | + | // inline (fast, single statement); the per-sub Stripe calls are fanned out | |
| 177 | + | // on the background queue so a creator with many fans doesn't stall the | |
| 178 | + | // admin request. | |
| 176 | 179 | if let Some(ref stripe) = state.stripe | |
| 177 | 180 | && let Some(ref account_id) = db_user.stripe_account_id | |
| 178 | 181 | { | |
| 179 | 182 | let subs = db::subscriptions::get_active_subscriptions_by_creator(&state.db, id).await?; | |
| 180 | - | let count = subs.len(); | |
| 181 | - | for sub in &subs { | |
| 182 | - | if let Err(e) = stripe.pause_subscription(&sub.stripe_subscription_id, account_id).await { | |
| 183 | - | tracing::error!(stripe_sub_id = %sub.stripe_subscription_id, error = ?e, "failed to pause subscription on Stripe"); | |
| 184 | - | } | |
| 185 | - | } | |
| 183 | + | let ids: Vec<String> = subs.into_iter().map(|s| s.stripe_subscription_id).collect(); | |
| 184 | + | let queued = ids.len(); | |
| 185 | + | crate::payments::fan_ops::spawn_fan_sub_fanout( | |
| 186 | + | &state.bg, | |
| 187 | + | std::sync::Arc::clone(stripe), | |
| 188 | + | account_id.clone(), | |
| 189 | + | ids, | |
| 190 | + | crate::payments::fan_ops::FanSubOp::Pause, | |
| 191 | + | ); | |
| 186 | 192 | let paused = db::subscriptions::pause_subscriptions_for_creator(&state.db, id).await?; | |
| 187 | - | tracing::info!(user_id = %id, stripe_paused = count, db_paused = paused, "paused fan subscriptions for suspended creator"); | |
| 193 | + | tracing::info!(user_id = %id, stripe_queued = queued, db_paused = paused, "paused fan subscriptions for suspended creator"); | |
| 188 | 194 | } | |
| 189 | 195 | ||
| 190 | 196 | // Send notification email (fire-and-forget) | |
| @@ -222,12 +228,16 @@ pub(super) async fn admin_unsuspend_user( | |||
| 222 | 228 | && let Some(ref account_id) = db_user.stripe_account_id | |
| 223 | 229 | { | |
| 224 | 230 | let resumed = db::subscriptions::resume_subscriptions_for_creator(&state.db, id).await?; | |
| 225 | - | for sub in &resumed { | |
| 226 | - | if let Err(e) = stripe.resume_subscription(&sub.stripe_subscription_id, account_id).await { | |
| 227 | - | tracing::error!(stripe_sub_id = %sub.stripe_subscription_id, error = ?e, "failed to resume subscription on Stripe"); | |
| 228 | - | } | |
| 229 | - | } | |
| 230 | - | tracing::info!(user_id = %id, resumed = resumed.len(), "resumed fan subscriptions for unsuspended creator"); | |
| 231 | + | let ids: Vec<String> = resumed.into_iter().map(|s| s.stripe_subscription_id).collect(); | |
| 232 | + | let queued = ids.len(); | |
| 233 | + | crate::payments::fan_ops::spawn_fan_sub_fanout( | |
| 234 | + | &state.bg, | |
| 235 | + | std::sync::Arc::clone(stripe), | |
| 236 | + | account_id.clone(), | |
| 237 | + | ids, | |
| 238 | + | crate::payments::fan_ops::FanSubOp::Resume, | |
| 239 | + | ); | |
| 240 | + | tracing::info!(user_id = %id, resumed = queued, "resumed fan subscriptions for unsuspended creator"); | |
| 231 | 241 | } | |
| 232 | 242 | ||
| 233 | 243 | tracing::info!(user_id = %id, "admin unsuspended user"); | |
| @@ -277,11 +287,18 @@ pub(super) async fn admin_terminate_user( | |||
| 277 | 287 | { | |
| 278 | 288 | let active_subs = db::subscriptions::get_active_subscriptions_by_creator(&state.db, id).await?; | |
| 279 | 289 | let paused_subs = db::subscriptions::get_paused_subscriptions_by_creator(&state.db, id).await?; | |
| 280 | - | for sub in active_subs.iter().chain(paused_subs.iter()) { | |
| 281 | - | if let Err(e) = stripe.cancel_subscription(&sub.stripe_subscription_id, account_id).await { | |
| 282 | - | tracing::error!(stripe_sub_id = %sub.stripe_subscription_id, error = ?e, "failed to cancel subscription on termination"); | |
| 283 | - | } | |
| 284 | - | } | |
| 290 | + | let ids: Vec<String> = active_subs | |
| 291 | + | .into_iter() | |
| 292 | + | .chain(paused_subs) | |
| 293 | + | .map(|s| s.stripe_subscription_id) | |
| 294 | + | .collect(); | |
| 295 | + | crate::payments::fan_ops::spawn_fan_sub_fanout( | |
| 296 | + | &state.bg, | |
| 297 | + | std::sync::Arc::clone(stripe), | |
| 298 | + | account_id.clone(), | |
| 299 | + | ids, | |
| 300 | + | crate::payments::fan_ops::FanSubOp::Cancel, | |
| 301 | + | ); | |
| 285 | 302 | } | |
| 286 | 303 | ||
| 287 | 304 | // Send termination email |
| @@ -683,32 +683,31 @@ pub(super) async fn export_contacts( | |||
| 683 | 683 | AuthUser(user): AuthUser, | |
| 684 | 684 | ) -> Result<Response> { | |
| 685 | 685 | let is_htmx = is_htmx_request(&headers); | |
| 686 | - | let contacts = db::transactions::get_seller_contacts(&state.db, user.id).await?; | |
| 687 | - | ||
| 688 | - | let mut csv_content = String::from("Username,Email,Purchases,Total Spent,Last Purchase\n"); | |
| 689 | - | for c in &contacts { | |
| 690 | - | csv_content.push_str(&format!( | |
| 691 | - | "{},{},{},{},{}\n", | |
| 692 | - | sanitize_csv_cell(&c.username), | |
| 693 | - | sanitize_csv_cell(&c.email), | |
| 694 | - | c.total_purchases, | |
| 695 | - | crate::formatting::format_dollars_plain(c.total_spent_cents), | |
| 696 | - | c.last_purchase_at.format("%Y-%m-%d"), | |
| 697 | - | )); | |
| 698 | - | } | |
| 699 | - | ||
| 700 | - | if is_htmx { | |
| 701 | - | let data_uri = format!( | |
| 702 | - | "data:text/csv;charset=utf-8,{}", | |
| 703 | - | urlencoding::encode(&csv_content) | |
| 704 | - | ); | |
| 705 | - | return Ok(ExportDownloadTemplate { | |
| 706 | - | data_uri, | |
| 707 | - | filename: "makenot-work-contacts.csv".to_string(), | |
| 708 | - | }.into_response()); | |
| 709 | - | } | |
| 710 | - | ||
| 711 | - | download_response(csv_content.into_bytes(), "makenot-work-contacts.csv", "text/csv") | |
| 686 | + | let pool = state.db.clone(); | |
| 687 | + | let uid = user.id; | |
| 688 | + | let rx = spawn_paginated_csv( | |
| 689 | + | "Username,Email,Purchases,Total Spent,Last Purchase\n", | |
| 690 | + | move |limit, offset| { | |
| 691 | + | let pool = pool.clone(); | |
| 692 | + | async move { | |
| 693 | + | let contacts = | |
| 694 | + | db::transactions::get_seller_contacts_page(&pool, uid, limit, offset).await?; | |
| 695 | + | let mut buf = String::new(); | |
| 696 | + | for c in &contacts { | |
| 697 | + | buf.push_str(&format!( | |
| 698 | + | "{},{},{},{},{}\n", | |
| 699 | + | sanitize_csv_cell(&c.username), | |
| 700 | + | sanitize_csv_cell(&c.email), | |
| 701 | + | c.total_purchases, | |
| 702 | + | crate::formatting::format_dollars_plain(c.total_spent_cents), | |
| 703 | + | c.last_purchase_at.format("%Y-%m-%d"), | |
| 704 | + | )); | |
| 705 | + | } | |
| 706 | + | Ok((buf, contacts.len())) | |
| 707 | + | } | |
| 708 | + | }, | |
| 709 | + | ); | |
| 710 | + | finish_csv(is_htmx, "makenot-work-contacts.csv", rx).await | |
| 712 | 711 | } | |
| 713 | 712 | ||
| 714 | 713 | #[cfg(test)] |
| @@ -161,8 +161,8 @@ pub(super) async fn creator_stats( | |||
| 161 | 161 | let comparison = | |
| 162 | 162 | db::analytics::get_period_comparison(&state.db, query.user_id, None, None, &range).await?; | |
| 163 | 163 | ||
| 164 | - | let projects = db::projects::get_projects_by_user(&state.db, query.user_id).await?; | |
| 165 | - | let items = db::items::get_items_by_user(&state.db, query.user_id).await?; | |
| 164 | + | let total_projects = db::projects::count_projects_by_user(&state.db, query.user_id).await?; | |
| 165 | + | let total_items = db::items::count_items_by_user(&state.db, query.user_id).await?; | |
| 166 | 166 | ||
| 167 | 167 | Ok(Json(CreatorStats { | |
| 168 | 168 | current_revenue_cents: comparison.current_revenue_cents.as_i64(), | |
| @@ -171,8 +171,8 @@ pub(super) async fn creator_stats( | |||
| 171 | 171 | previous_sales: comparison.previous_sales, | |
| 172 | 172 | current_followers: comparison.current_followers, | |
| 173 | 173 | previous_followers: comparison.previous_followers, | |
| 174 | - | total_projects: projects.len() as i64, | |
| 175 | - | total_items: items.len() as i64, | |
| 174 | + | total_projects, | |
| 175 | + | total_items, | |
| 176 | 176 | })) | |
| 177 | 177 | } | |
| 178 | 178 |
| @@ -314,22 +314,19 @@ pub(in crate::routes::api) async fn pause_creator( | |||
| 314 | 314 | tracing::warn!(error = ?e, "failed to cancel creator tier subscription on Stripe during pause"); | |
| 315 | 315 | } | |
| 316 | 316 | ||
| 317 | - | // Set cancel_at_period_end on all active fan subscriptions (connected account) | |
| 317 | + | // Set cancel_at_period_end on all active fan subscriptions (connected | |
| 318 | + | // account). Fanned out on the background queue so a creator with many | |
| 319 | + | // fans doesn't tie the request up for N serial Stripe round-trips. | |
| 318 | 320 | if let Some(ref stripe_account_id) = db_user.stripe_account_id { | |
| 319 | 321 | let fan_subs = db::subscriptions::get_active_subscriptions_by_creator(&state.db, user.id).await?; | |
| 320 | - | for sub in &fan_subs { | |
| 321 | - | if let Err(e) = stripe.set_cancel_at_period_end( | |
| 322 | - | &sub.stripe_subscription_id, | |
| 323 | - | stripe_account_id, | |
| 324 | - | true, | |
| 325 | - | ).await { | |
| 326 | - | tracing::warn!( | |
| 327 | - | stripe_sub_id = %sub.stripe_subscription_id, | |
| 328 | - | error = ?e, | |
| 329 | - | "failed to set cancel_at_period_end on fan subscription during pause" | |
| 330 | - | ); | |
| 331 | - | } | |
| 332 | - | } | |
| 322 | + | let ids = fan_subs.into_iter().map(|s| s.stripe_subscription_id).collect(); | |
| 323 | + | crate::payments::fan_ops::spawn_fan_sub_fanout( | |
| 324 | + | &state.bg, | |
| 325 | + | std::sync::Arc::clone(stripe), | |
| 326 | + | stripe_account_id.clone(), | |
| 327 | + | ids, | |
| 328 | + | crate::payments::fan_ops::FanSubOp::CancelAtPeriodEnd(true), | |
| 329 | + | ); | |
| 333 | 330 | } | |
| 334 | 331 | } | |
| 335 | 332 |
| @@ -33,23 +33,27 @@ async fn suggest_slugs( | |||
| 33 | 33 | }) | |
| 34 | 34 | .unwrap_or(base); | |
| 35 | 35 | ||
| 36 | - | let mut suggestions = Vec::new(); | |
| 37 | - | for n in 2..=10 { | |
| 38 | - | let candidate = format!("{}-{}", root, n); | |
| 39 | - | if let Ok(slug) = Slug::new(&candidate) { | |
| 40 | - | let taken = db::projects::get_project_by_user_and_slug(db, user_id, &slug) | |
| 41 | - | .await | |
| 42 | - | .map(|p| p.is_some()) | |
| 43 | - | .unwrap_or(true); | |
| 44 | - | if !taken { | |
| 45 | - | suggestions.push(candidate); | |
| 46 | - | if suggestions.len() >= 3 { | |
| 47 | - | break; | |
| 48 | - | } | |
| 49 | - | } | |
| 50 | - | } | |
| 36 | + | // Build all valid candidate slugs in order, then resolve availability with a | |
| 37 | + | // single `slug = ANY(...)` query instead of one point-query per candidate. | |
| 38 | + | let candidates: Vec<String> = (2..=10) | |
| 39 | + | .map(|n| format!("{}-{}", root, n)) | |
| 40 | + | .filter(|c| Slug::new(c).is_ok()) | |
| 41 | + | .collect(); | |
| 42 | + | if candidates.is_empty() { | |
| 43 | + | return Vec::new(); | |
| 51 | 44 | } | |
| 52 | - | suggestions | |
| 45 | + | ||
| 46 | + | // On a DB error, suggest nothing rather than risk offering a taken slug. | |
| 47 | + | let Ok(taken) = db::projects::filter_taken_slugs(db, user_id, &candidates).await else { | |
| 48 | + | return Vec::new(); | |
| 49 | + | }; | |
| 50 | + | let taken: std::collections::HashSet<String> = taken.into_iter().collect(); | |
| 51 | + | ||
| 52 | + | candidates | |
| 53 | + | .into_iter() | |
| 54 | + | .filter(|c| !taken.contains(c)) | |
| 55 | + | .take(3) | |
| 56 | + | .collect() | |
| 53 | 57 | } | |
| 54 | 58 | ||
| 55 | 59 | #[derive(Debug, Deserialize)] |
| @@ -199,70 +199,58 @@ pub(super) async fn process_push( | |||
| 199 | 199 | numbers.dedup(); | |
| 200 | 200 | let mut issues = db::issues::get_issues_by_numbers(&state.db, repo.id, &numbers).await?; | |
| 201 | 201 | ||
| 202 | - | // Now process DB operations (async-safe, git2 types are dropped) | |
| 202 | + | // Collect the DB writes and flush them in two bulk statements at the end | |
| 203 | + | // rather than one status UPDATE + one comment INSERT per referenced commit | |
| 204 | + | // (ultra-fuzz Run 6 S6). The local `issue.status` is still mutated as we go, | |
| 205 | + | // so repeated references to the same issue within one push observe the | |
| 206 | + | // running state; `status_changes` holds the final status to persist. | |
| 203 | 207 | let mut processed = 0u32; | |
| 208 | + | let mut status_changes: HashMap<db::IssueId, IssueStatus> = HashMap::new(); | |
| 209 | + | let mut comments: Vec<(db::IssueId, String, String)> = Vec::new(); | |
| 210 | + | ||
| 204 | 211 | for (oid_str, short_oid, refs) in &commit_refs { | |
| 205 | 212 | for issue_ref in refs { | |
| 206 | 213 | let Some(issue) = issues.get_mut(&issue_ref.number) else { | |
| 207 | 214 | continue; | |
| 208 | 215 | }; | |
| 209 | 216 | ||
| 210 | - | match issue_ref.action { | |
| 217 | + | let lead = match issue_ref.action { | |
| 211 | 218 | IssueRefAction::Close => { | |
| 212 | 219 | 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 | - | } | |
| 218 | - | } | |
| 219 | - | let body_md = format!( | |
| 220 | - | "Closed via commit [`{}`](/git/{}/{}/commit/{}) on `{}`.", | |
| 221 | - | short_oid, req.repo_owner, req.repo_name, oid_str, req.ref_name, | |
| 222 | - | ); | |
| 223 | - | let body_html = docengine::render_permissive(&body_md); | |
| 224 | - | if let Err(e) = db::issues::create_comment( | |
| 225 | - | &state.db, issue.id, owner_user.id, &body_md, &body_html, | |
| 226 | - | ).await { | |
| 227 | - | tracing::warn!(issue_id = %issue.id, error = ?e, "failed to create close comment via push"); | |
| 220 | + | issue.status = IssueStatus::Closed; | |
| 221 | + | status_changes.insert(issue.id, IssueStatus::Closed); | |
| 228 | 222 | } | |
| 223 | + | "Closed via" | |
| 229 | 224 | } | |
| 230 | 225 | IssueRefAction::Reopen => { | |
| 231 | 226 | 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 | - | } | |
| 237 | - | } | |
| 238 | - | let body_md = format!( | |
| 239 | - | "Reopened via commit [`{}`](/git/{}/{}/commit/{}) on `{}`.", | |
| 240 | - | short_oid, req.repo_owner, req.repo_name, oid_str, req.ref_name, | |
| 241 | - | ); | |
| 242 | - | let body_html = docengine::render_permissive(&body_md); | |
| 243 | - | if let Err(e) = db::issues::create_comment( | |
| 244 | - | &state.db, issue.id, owner_user.id, &body_md, &body_html, | |
| 245 | - | ).await { | |
| 246 | - | tracing::warn!(issue_id = %issue.id, error = ?e, "failed to create reopen comment via push"); | |
| 227 | + | issue.status = IssueStatus::Open; | |
| 228 | + | status_changes.insert(issue.id, IssueStatus::Open); | |
| 247 | 229 | } | |
| 230 | + | "Reopened via" | |
| 248 | 231 | } | |
| 249 | - | IssueRefAction::Reference => { | |
| 250 | - | let body_md = format!( | |
| 251 | - | "Referenced in commit [`{}`](/git/{}/{}/commit/{}) on `{}`.", | |
| 252 | - | short_oid, req.repo_owner, req.repo_name, oid_str, req.ref_name, | |
| 253 | - | ); | |
| 254 | - | let body_html = docengine::render_permissive(&body_md); | |
| 255 | - | if let Err(e) = db::issues::create_comment( | |
| 256 | - | &state.db, issue.id, owner_user.id, &body_md, &body_html, | |
| 257 | - | ).await { | |
| 258 | - | tracing::warn!(issue_id = %issue.id, error = ?e, "failed to create reference comment via push"); | |
| 259 | - | } | |
| 260 | - | } | |
| 261 | - | } | |
| 232 | + | IssueRefAction::Reference => "Referenced in", | |
| 233 | + | }; | |
| 234 | + | ||
| 235 | + | let body_md = format!( | |
| 236 | + | "{lead} commit [`{}`](/git/{}/{}/commit/{}) on `{}`.", | |
| 237 | + | short_oid, req.repo_owner, req.repo_name, oid_str, req.ref_name, | |
| 238 | + | ); | |
| 239 | + | let body_html = docengine::render_permissive(&body_md); | |
| 240 | + | comments.push((issue.id, body_md, body_html)); | |
| 262 | 241 | processed += 1; | |
| 263 | 242 | } | |
| 264 | 243 | } | |
| 265 | 244 | ||
| 245 | + | // Flush: final status per changed issue, then every comment, in two queries. | |
| 246 | + | let status_updates: Vec<(db::IssueId, IssueStatus)> = status_changes.into_iter().collect(); | |
| 247 | + | if let Err(e) = db::issues::update_issue_statuses(&state.db, &status_updates).await { | |
| 248 | + | tracing::warn!(error = ?e, "failed to bulk-update issue statuses via push"); | |
| 249 | + | } | |
| 250 | + | if let Err(e) = db::issues::create_comments(&state.db, owner_user.id, &comments).await { | |
| 251 | + | tracing::warn!(error = ?e, "failed to bulk-create issue comments via push"); | |
| 252 | + | } | |
| 253 | + | ||
| 266 | 254 | Ok((StatusCode::OK, Json(serde_json::json!({ "processed": processed })))) | |
| 267 | 255 | } | |
| 268 | 256 |
| @@ -533,24 +533,22 @@ pub(super) async fn handle_creator_tier_checkout_completed( | |||
| 533 | 533 | .await | |
| 534 | 534 | .with_context(|| format!("unpause creator {user_id}"))?; | |
| 535 | 535 | ||
| 536 | - | // Un-cancel active fan subscriptions (clear cancel_at_period_end) | |
| 536 | + | // Un-cancel active fan subscriptions (clear cancel_at_period_end). Fanned | |
| 537 | + | // out on the background queue: doing it inline here let a creator with | |
| 538 | + | // many fans stall the webhook past Stripe's delivery timeout, which | |
| 539 | + | // triggers a retry that re-runs the whole loop. | |
| 537 | 540 | if let (Some(stripe), Some(stripe_account_id)) = (&state.stripe, &db_user.stripe_account_id) { | |
| 538 | 541 | let fan_subs = db::subscriptions::get_active_subscriptions_by_creator(&state.db, user_id) | |
| 539 | 542 | .await | |
| 540 | 543 | .with_context(|| format!("fetch active fan subs for unpause {user_id}"))?; | |
| 541 | - | for fan_sub in &fan_subs { | |
| 542 | - | if let Err(e) = stripe.set_cancel_at_period_end( | |
| 543 | - | &fan_sub.stripe_subscription_id, | |
| 544 | - | stripe_account_id, | |
| 545 | - | false, | |
| 546 | - | ).await { | |
| 547 | - | tracing::warn!( | |
| 548 | - | stripe_sub_id = %fan_sub.stripe_subscription_id, | |
| 549 | - | error = ?e, | |
| 550 | - | "failed to clear cancel_at_period_end on fan sub during unpause" | |
| 551 | - | ); | |
| 552 | - | } | |
| 553 | - | } | |
| 544 | + | let ids = fan_subs.into_iter().map(|s| s.stripe_subscription_id).collect(); | |
| 545 | + | crate::payments::fan_ops::spawn_fan_sub_fanout( | |
| 546 | + | &state.bg, | |
| 547 | + | std::sync::Arc::clone(stripe), | |
| 548 | + | stripe_account_id.clone(), | |
| 549 | + | ids, | |
| 550 | + | crate::payments::fan_ops::FanSubOp::CancelAtPeriodEnd(false), | |
| 551 | + | ); | |
| 554 | 552 | } | |
| 555 | 553 | ||
| 556 | 554 | tracing::info!(user_id = %user_id, "creator auto-unpaused after re-subscribing to tier"); |
| @@ -106,6 +106,19 @@ enum ContentBuf { | |||
| 106 | 106 | /// `Err(detail)` is a mid-stream decode failure: the caller decides what that | |
| 107 | 107 | /// means for each dimension (ZIP bomb accounting uses a conservative estimate | |
| 108 | 108 | /// and continues; a single-stream compressor fails closed). | |
| 109 | + | /// Byte limit at which `tee_decompress` should stop expanding a stream for bomb | |
| 110 | + | /// accounting: the smaller of the 2 GB absolute cap and `compressed * MAX_RATIO`. | |
| 111 | + | /// A stream that blows past its ratio budget is flagged as a bomb regardless, so | |
| 112 | + | /// there is no reason to keep decompressing it up to the full 2 GB first. Falls | |
| 113 | + | /// back to the absolute cap when the compressed size is unknown (0). | |
| 114 | + | fn entry_bomb_stop_limit(compressed_size: u64) -> u64 { | |
| 115 | + | if compressed_size == 0 { | |
| 116 | + | return constants::SCAN_ZIP_MAX_UNCOMPRESSED; | |
| 117 | + | } | |
| 118 | + | let ratio_limit = compressed_size.saturating_mul(constants::SCAN_ZIP_MAX_RATIO as u64); | |
| 119 | + | constants::SCAN_ZIP_MAX_UNCOMPRESSED.min(ratio_limit) | |
| 120 | + | } | |
| 121 | + | ||
| 109 | 122 | fn tee_decompress( | |
| 110 | 123 | reader: &mut dyn Read, | |
| 111 | 124 | bomb_abs_limit: u64, | |
| @@ -308,12 +321,16 @@ fn walk_zip<R: std::io::Read + std::io::Seek>( | |||
| 308 | 321 | } | |
| 309 | 322 | ||
| 310 | 323 | // Decompress the entry exactly once, teeing the full size (bomb) and the | |
| 311 | - | // buffered prefix (content). | |
| 324 | + | // buffered prefix (content). Stop early once the entry blows past | |
| 325 | + | // `compressed * MAX_RATIO` rather than always decompressing to the 2 GB | |
| 326 | + | // absolute cap — a high-ratio entry is flagged below regardless, so there | |
| 327 | + | // is no reason to expand it fully first. | |
| 328 | + | let entry_stop_limit = entry_bomb_stop_limit(entry_compressed); | |
| 312 | 329 | let want_content = content && nested_res.is_none(); | |
| 313 | 330 | let (counted, content_buf, decode_err) = match archive.by_index(i) { | |
| 314 | 331 | Ok(mut entry) => match tee_decompress( | |
| 315 | 332 | &mut entry, | |
| 316 | - | constants::SCAN_ZIP_MAX_UNCOMPRESSED, | |
| 333 | + | entry_stop_limit, | |
| 317 | 334 | want_content, | |
| 318 | 335 | &mut interior_budget, | |
| 319 | 336 | ) { | |
| @@ -496,8 +513,12 @@ fn walk_compressed<R: Read>( | |||
| 496 | 513 | let ratio_limit = compressed_size.saturating_mul(constants::SCAN_ZIP_MAX_RATIO as u64); | |
| 497 | 514 | let mut interior_budget: u64 = constants::SCAN_ZIP_MAX_UNCOMPRESSED; | |
| 498 | 515 | ||
| 516 | + | // Stop expanding once the stream blows past `compressed * MAX_RATIO` rather | |
| 517 | + | // than always running to the 2 GB absolute cap; the ratio verdict below fires | |
| 518 | + | // identically on the early-stopped count. | |
| 519 | + | let stop_limit = entry_bomb_stop_limit(compressed_size); | |
| 499 | 520 | let (counted, content_buf, decode_err) = | |
| 500 | - | match tee_decompress(decoder.as_mut(), abs_limit, content, &mut interior_budget) { | |
| 521 | + | match tee_decompress(decoder.as_mut(), stop_limit, content, &mut interior_budget) { | |
| 501 | 522 | Ok((c, cb)) => (c, cb, None), | |
| 502 | 523 | Err((c, why)) => (c, None, Some(why)), | |
| 503 | 524 | }; |
| @@ -547,7 +547,23 @@ impl ScanPipeline { | |||
| 547 | 547 | layers.push(archive_layer); | |
| 548 | 548 | layers.push(archive_nested_layer); | |
| 549 | 549 | layers.push(match self.yara_rules { | |
| 550 | - | Some(ref rules) => yara::scan_with_yara(rules, data), | |
| 550 | + | Some(ref rules) => { | |
| 551 | + | // Cap YARA's input: it walks the whole slice, faulting the entire | |
| 552 | + | // mmap resident. ClamAV (streamed, uncapped) is the full-file | |
| 553 | + | // backstop, so scanning a generous prefix bounds peak RAM without | |
| 554 | + | // surrendering the floor. | |
| 555 | + | let yara_input = if data.len() > crate::constants::SCAN_YARA_MAX_BYTES { | |
| 556 | + | tracing::warn!( | |
| 557 | + | full_bytes = data.len(), | |
| 558 | + | capped_bytes = crate::constants::SCAN_YARA_MAX_BYTES, | |
| 559 | + | "YARA input capped; scanning prefix only (ClamAV scans the full file)" | |
| 560 | + | ); | |
| 561 | + | &data[..crate::constants::SCAN_YARA_MAX_BYTES] | |
| 562 | + | } else { | |
| 563 | + | data | |
| 564 | + | }; | |
| 565 | + | yara::scan_with_yara(rules, yara_input) | |
| 566 | + | } | |
| 551 | 567 | None => LayerResult { | |
| 552 | 568 | layer: "yara", | |
| 553 | 569 | verdict: LayerVerdict::Skip, |
| @@ -11,7 +11,7 @@ | |||
| 11 | 11 | ||
| 12 | 12 | use crate::constants::{ | |
| 13 | 13 | SCAN_SPOOL_FREE_RESERVE_BYTES, SCAN_SPOOL_MAX_BYTES, SCAN_SPOOL_ORPHAN_AGE_SECS, | |
| 14 | - | SCAN_WORKER_COUNT, | |
| 14 | + | SCAN_SPOOL_SLACK_BYTES, SCAN_WORKER_COUNT, | |
| 15 | 15 | }; | |
| 16 | 16 | use s3_storage::ByteStream; | |
| 17 | 17 | use std::path::{Path, PathBuf}; | |
| @@ -148,6 +148,14 @@ pub async fn download_into_tempfile( | |||
| 148 | 148 | // — funnels through a single cleanup that unlinks the partial file. The | |
| 149 | 149 | // earlier code only removed it on the over-cap branch, leaking a partial | |
| 150 | 150 | // tempfile on a write/flush/stream error until the orphan reaper swept it. | |
| 151 | + | // Bound the streamed bytes by the *claimed* object size plus a small slack, | |
| 152 | + | // not just the global ceiling: an object that under-reports its size must not | |
| 153 | + | // be allowed to stream the full `SCAN_SPOOL_MAX_BYTES` to scratch before we | |
| 154 | + | // abort (Run 6 R6-Perf-M3). `check_free_space`/worker already cap | |
| 155 | + | // `expected_size` at the global max, so this is always the tighter bound. | |
| 156 | + | let write_cap = expected_size | |
| 157 | + | .saturating_add(SCAN_SPOOL_SLACK_BYTES) | |
| 158 | + | .min(SCAN_SPOOL_MAX_BYTES); | |
| 151 | 159 | let write_result: Result<(), String> = async { | |
| 152 | 160 | let mut written: u64 = 0; | |
| 153 | 161 | while let Some(chunk) = stream | |
| @@ -156,9 +164,9 @@ pub async fn download_into_tempfile( | |||
| 156 | 164 | .map_err(|e| format!("read S3 stream: {e}"))? | |
| 157 | 165 | { | |
| 158 | 166 | written += chunk.len() as u64; | |
| 159 | - | if written > SCAN_SPOOL_MAX_BYTES { | |
| 167 | + | if written > write_cap { | |
| 160 | 168 | return Err(format!( | |
| 161 | - | "S3 stream exceeded SCAN_SPOOL_MAX_BYTES ({SCAN_SPOOL_MAX_BYTES} bytes); aborting scan spool" | |
| 169 | + | "S3 stream exceeded expected size bound ({write_cap} bytes, claimed {expected_size}); aborting scan spool" | |
| 162 | 170 | )); | |
| 163 | 171 | } | |
| 164 | 172 | file.write_all(&chunk) |