max / makenotwork
- Co-Authored-By
- Claude Opus 4.8 <noreply@anthropic.com>
17 files changed,
+680 insertions,
-253 deletions
| @@ -229,9 +229,17 @@ | |||
| 229 | 229 | /// | |
| 230 | 230 | /// Returns username, display_name, what they follow (user/project), and when. | |
| 231 | 231 | #[tracing::instrument(skip_all)] | |
| 232 | - | pub async fn get_followers_for_export( | |
| 232 | + | /// One page of a creator's followers for CSV export, newest first. | |
| 233 | + | /// | |
| 234 | + | /// Paginated (`LIMIT`/`OFFSET`) so the export streams in bounded batches rather | |
| 235 | + | /// than materializing every follower in one query, and so the per-row email | |
| 236 | + | /// `EXISTS` reveal runs only for a bounded page at a time (ultra-fuzz Run 4 S1). | |
| 237 | + | /// Stable `(created_at, follower_id)` ordering keeps OFFSET batches consistent. | |
| 238 | + | pub async fn get_followers_for_export_page( | |
| 233 | 239 | pool: &PgPool, | |
| 234 | 240 | user_id: UserId, | |
| 241 | + | limit: i64, | |
| 242 | + | offset: i64, | |
| 235 | 243 | ) -> Result<Vec<FollowerExportRow>> { | |
| 236 | 244 | let rows = sqlx::query_as::<_, FollowerExportRow>( | |
| 237 | 245 | r#" | |
| @@ -255,10 +263,13 @@ | |||
| 255 | 263 | JOIN users u ON u.id = f.follower_id | |
| 256 | 264 | WHERE (f.target_type = 'user' AND f.target_id = $1) | |
| 257 | 265 | OR (f.target_type = 'project' AND f.target_id IN (SELECT id FROM projects WHERE user_id = $1)) | |
| 258 | - | ORDER BY f.created_at DESC | |
| 266 | + | ORDER BY f.created_at DESC, f.follower_id DESC | |
| 267 | + | LIMIT $2 OFFSET $3 | |
| 259 | 268 | "#, | |
| 260 | 269 | ) | |
| 261 | 270 | .bind(user_id) | |
| 271 | + | .bind(limit) | |
| 272 | + | .bind(offset) | |
| 262 | 273 | .fetch_all(pool) | |
| 263 | 274 | .await?; | |
| 264 | 275 |
| @@ -323,9 +323,16 @@ | |||
| 323 | 323 | /// - The recipient (collaborator receiving a share), or | |
| 324 | 324 | /// - The seller/tip recipient (owner who owes collaborators) | |
| 325 | 325 | #[tracing::instrument(skip(pool))] | |
| 326 | - | pub async fn get_splits_for_export( | |
| 326 | + | /// One page of a user's revenue splits for CSV export, newest first. | |
| 327 | + | /// | |
| 328 | + | /// Paginated so the splits export streams in bounded batches instead of loading | |
| 329 | + | /// every split in one query (ultra-fuzz Run 4 S1). Stable `(created_at, id)` | |
| 330 | + | /// ordering keeps OFFSET batches consistent. | |
| 331 | + | pub async fn get_splits_for_export_page( | |
| 327 | 332 | pool: &PgPool, | |
| 328 | 333 | user_id: UserId, | |
| 334 | + | limit: i64, | |
| 335 | + | offset: i64, | |
| 329 | 336 | ) -> Result<Vec<DbSplitExportRow>> { | |
| 330 | 337 | let rows = sqlx::query_as::<_, DbSplitExportRow>( | |
| 331 | 338 | r#" | |
| @@ -338,10 +345,13 @@ | |||
| 338 | 345 | LEFT JOIN tips tip ON tip.id = rs.tip_id | |
| 339 | 346 | WHERE rs.recipient_id = $1 | |
| 340 | 347 | OR COALESCE(t.seller_id, tip.recipient_id) = $1 | |
| 341 | - | ORDER BY rs.created_at DESC | |
| 348 | + | ORDER BY rs.created_at DESC, rs.id DESC | |
| 349 | + | LIMIT $2 OFFSET $3 | |
| 342 | 350 | "#, | |
| 343 | 351 | ) | |
| 344 | 352 | .bind(user_id) | |
| 353 | + | .bind(limit) | |
| 354 | + | .bind(offset) | |
| 345 | 355 | .fetch_all(pool) | |
| 346 | 356 | .await?; | |
| 347 | 357 |
| @@ -664,9 +664,14 @@ | |||
| 664 | 664 | /// | |
| 665 | 665 | /// Returns username, display_name, tier name, subscription status, and when. | |
| 666 | 666 | #[tracing::instrument(skip_all)] | |
| 667 | - | pub async fn get_project_subscribers_for_export( | |
| 667 | + | /// One page of a creator's project subscribers for CSV export, newest first. | |
| 668 | + | /// Paginated for bounded-memory streaming (ultra-fuzz Run 4 S1); stable | |
| 669 | + | /// `(created_at, id)` ordering keeps OFFSET batches consistent. | |
| 670 | + | pub async fn get_project_subscribers_for_export_page( | |
| 668 | 671 | pool: &PgPool, | |
| 669 | 672 | user_id: UserId, | |
| 673 | + | limit: i64, | |
| 674 | + | offset: i64, | |
| 670 | 675 | ) -> Result<Vec<SubscriberExportRow>> { | |
| 671 | 676 | let rows = sqlx::query_as!( | |
| 672 | 677 | SubscriberExportRow, | |
| @@ -678,9 +683,12 @@ | |||
| 678 | 683 | JOIN users u ON u.id = s.subscriber_id | |
| 679 | 684 | JOIN subscription_tiers t ON t.id = s.tier_id | |
| 680 | 685 | WHERE s.project_id IN (SELECT id FROM projects WHERE user_id = $1) | |
| 681 | - | ORDER BY s.created_at DESC | |
| 686 | + | ORDER BY s.created_at DESC, s.id DESC | |
| 687 | + | LIMIT $2 OFFSET $3 | |
| 682 | 688 | "#, | |
| 683 | 689 | user_id as UserId, | |
| 690 | + | limit, | |
| 691 | + | offset, | |
| 684 | 692 | ) | |
| 685 | 693 | .fetch_all(pool) | |
| 686 | 694 | .await?; | |
| @@ -693,9 +701,14 @@ | |||
| 693 | 701 | /// Returns project name, tier name, price, subscriber username, status, | |
| 694 | 702 | /// billing period dates, and cancellation date. | |
| 695 | 703 | #[tracing::instrument(skip_all)] | |
| 696 | - | pub async fn get_subscriptions_for_export( | |
| 704 | + | /// One page of a creator's subscriptions for CSV export, newest first. | |
| 705 | + | /// Paginated for bounded-memory streaming (ultra-fuzz Run 4 S1); stable | |
| 706 | + | /// `(created_at, id)` ordering keeps OFFSET batches consistent. | |
| 707 | + | pub async fn get_subscriptions_for_export_page( | |
| 697 | 708 | pool: &PgPool, | |
| 698 | 709 | user_id: UserId, | |
| 710 | + | limit: i64, | |
| 711 | + | offset: i64, | |
| 699 | 712 | ) -> Result<Vec<SubscriptionExportRow>> { | |
| 700 | 713 | let rows = sqlx::query_as!( | |
| 701 | 714 | SubscriptionExportRow, | |
| @@ -711,9 +724,12 @@ | |||
| 711 | 724 | JOIN subscription_tiers t ON t.id = s.tier_id | |
| 712 | 725 | JOIN projects p ON p.id = s.project_id | |
| 713 | 726 | WHERE s.project_id IN (SELECT id FROM projects WHERE user_id = $1) | |
| 714 | - | ORDER BY s.created_at DESC | |
| 727 | + | ORDER BY s.created_at DESC, s.id DESC | |
| 728 | + | LIMIT $2 OFFSET $3 | |
| 715 | 729 | "#, | |
| 716 | 730 | user_id as UserId, | |
| 731 | + | limit, | |
| 732 | + | offset, | |
| 717 | 733 | ) | |
| 718 | 734 | .fetch_all(pool) | |
| 719 | 735 | .await?; |
| @@ -359,6 +359,43 @@ | |||
| 359 | 359 | Ok(txs) | |
| 360 | 360 | } | |
| 361 | 361 | ||
| 362 | + | /// One page of a buyer's purchases for CSV export, newest first. | |
| 363 | + | /// | |
| 364 | + | /// Paginated so the purchases export streams in bounded batches rather than | |
| 365 | + | /// loading the buyer's whole history with `limit: None` (ultra-fuzz Run 4 S1). | |
| 366 | + | /// Stable `(created_at, id)` ordering keeps OFFSET batches consistent. | |
| 367 | + | pub async fn get_buyer_transactions_for_export_page( | |
| 368 | + | pool: &PgPool, | |
| 369 | + | buyer_id: UserId, | |
| 370 | + | limit: i64, | |
| 371 | + | offset: i64, | |
| 372 | + | ) -> Result<Vec<DbTransaction>> { | |
| 373 | + | let txs = sqlx::query_as!( | |
| 374 | + | DbTransaction, | |
| 375 | + | r#" | |
| 376 | + | SELECT | |
| 377 | + | id AS "id: TransactionId", buyer_id AS "buyer_id: UserId", seller_id AS "seller_id: UserId", | |
| 378 | + | item_id AS "item_id: ItemId", amount_cents AS "amount_cents: Cents", platform_fee_cents AS "platform_fee_cents: Cents", | |
| 379 | + | currency, status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id, | |
| 380 | + | created_at AS "created_at: chrono::DateTime<chrono::Utc>", completed_at AS "completed_at: chrono::DateTime<chrono::Utc>", | |
| 381 | + | item_title, seller_username, share_contact, project_id AS "project_id: ProjectId", | |
| 382 | + | parent_transaction_id AS "parent_transaction_id: TransactionId", promo_code_id AS "promo_code_id: PromoCodeId", | |
| 383 | + | guest_email, claim_token AS "claim_token: ClaimToken", claimed_by AS "claimed_by: UserId", | |
| 384 | + | download_token AS "download_token: DownloadToken" | |
| 385 | + | FROM transactions WHERE buyer_id = $1 | |
| 386 | + | ORDER BY created_at DESC, id DESC | |
| 387 | + | LIMIT $2 OFFSET $3 | |
| 388 | + | "#, | |
| 389 | + | buyer_id as UserId, | |
| 390 | + | limit, | |
| 391 | + | offset, | |
| 392 | + | ) | |
| 393 | + | .fetch_all(pool) | |
| 394 | + | .await?; | |
| 395 | + | ||
| 396 | + | Ok(txs) | |
| 397 | + | } | |
| 398 | + | ||
| 362 | 399 | /// List transactions where the user is the seller, newest first. | |
| 363 | 400 | /// | |
| 364 | 401 | /// Pass `limit: None` for all rows (exports), or `Some(n)` for dashboard display. | |
| @@ -1100,9 +1137,19 @@ | |||
| 1100 | 1137 | /// Respects contact revocations: if a buyer revoked sharing, their email | |
| 1101 | 1138 | /// is hidden even if `share_contact` was true on the transaction. | |
| 1102 | 1139 | #[tracing::instrument(skip_all)] | |
| 1103 | - | pub async fn get_seller_transactions_for_export( | |
| 1140 | + | /// One page of a seller's sales for CSV export, newest first. | |
| 1141 | + | /// | |
| 1142 | + | /// Paginated (`LIMIT`/`OFFSET`) so the export streams in bounded batches instead | |
| 1143 | + | /// of loading the seller's entire transaction history into memory in one query | |
| 1144 | + | /// (ultra-fuzz Run 4 S1). The `(created_at, id)` ordering is stable so OFFSET | |
| 1145 | + | /// batches don't reorder. (Keyset pagination would avoid OFFSET's deep-scan cost | |
| 1146 | + | /// and is the future optimization; OFFSET is sufficient at current scale and | |
| 1147 | + | /// keeps peak memory + per-query result bounded, which is the DoS fix.) | |
| 1148 | + | pub async fn get_seller_transactions_for_export_page( | |
| 1104 | 1149 | pool: &PgPool, | |
| 1105 | 1150 | seller_id: UserId, | |
| 1151 | + | limit: i64, | |
| 1152 | + | offset: i64, | |
| 1106 | 1153 | ) -> Result<Vec<DbTransactionExportRow>> { | |
| 1107 | 1154 | let rows = sqlx::query_as!( | |
| 1108 | 1155 | DbTransactionExportRow, | |
| @@ -1120,9 +1167,12 @@ | |||
| 1120 | 1167 | FROM transactions t | |
| 1121 | 1168 | LEFT JOIN users u ON u.id = t.buyer_id | |
| 1122 | 1169 | WHERE t.seller_id = $1 | |
| 1123 | - | ORDER BY t.created_at DESC | |
| 1170 | + | ORDER BY t.created_at DESC, t.id DESC | |
| 1171 | + | LIMIT $2 OFFSET $3 | |
| 1124 | 1172 | "#, | |
| 1125 | 1173 | seller_id as UserId, | |
| 1174 | + | limit, | |
| 1175 | + | offset, | |
| 1126 | 1176 | ) | |
| 1127 | 1177 | .fetch_all(pool) | |
| 1128 | 1178 | .await?; | |
| @@ -1130,6 +1180,33 @@ | |||
| 1130 | 1180 | Ok(rows) | |
| 1131 | 1181 | } | |
| 1132 | 1182 | ||
| 1183 | + | /// All of a seller's export rows accumulated into one Vec, bounded to | |
| 1184 | + | /// `EXPORT_ACCUMULATE_CAP` rows. For admin / internal-API callers that need the | |
| 1185 | + | /// full set in memory; the public creator-facing export streams page-by-page via | |
| 1186 | + | /// [`get_seller_transactions_for_export_page`] instead of materializing here. | |
| 1187 | + | pub async fn get_seller_transactions_for_export( | |
| 1188 | + | pool: &PgPool, | |
| 1189 | + | seller_id: UserId, | |
| 1190 | + | ) -> Result<Vec<DbTransactionExportRow>> { | |
| 1191 | + | /// Page size for the accumulating fetch. | |
| 1192 | + | const PAGE: i64 = 5_000; | |
| 1193 | + | /// Cap so even an admin/internal export can't load an unbounded result set. | |
| 1194 | + | const EXPORT_ACCUMULATE_CAP: usize = 1_000_000; | |
| 1195 | + | ||
| 1196 | + | let mut all = Vec::new(); | |
| 1197 | + | let mut offset = 0i64; | |
| 1198 | + | loop { | |
| 1199 | + | let page = get_seller_transactions_for_export_page(pool, seller_id, PAGE, offset).await?; | |
| 1200 | + | let n = page.len(); | |
| 1201 | + | all.extend(page); | |
| 1202 | + | offset += n as i64; | |
| 1203 | + | if (n as i64) < PAGE || all.len() >= EXPORT_ACCUMULATE_CAP { | |
| 1204 | + | break; | |
| 1205 | + | } | |
| 1206 | + | } | |
| 1207 | + | Ok(all) | |
| 1208 | + | } | |
| 1209 | + | ||
| 1133 | 1210 | /// Platform-wide revenue stats: total completed revenue, completed count, refunded count. | |
| 1134 | 1211 | #[tracing::instrument(skip_all)] | |
| 1135 | 1212 | pub async fn get_platform_revenue_stats(pool: &PgPool) -> Result<(i64, i64, i64)> { |
| @@ -457,7 +457,16 @@ | |||
| 457 | 457 | let urlhaus_key = self.abuse_ch_auth_key.clone(); | |
| 458 | 458 | let urlhaus_fut = async move { | |
| 459 | 459 | if urlhaus_enabled { | |
| 460 | - | urlhaus::check_urlhaus(&urlhaus_map, urlhaus_key.as_deref()).await | |
| 460 | + | // Host extraction walks the memory-mapped buffer and can | |
| 461 | + | // page-fault; run it on the blocking pool so it doesn't stall a | |
| 462 | + | // tokio worker, then do only the network lookups async | |
| 463 | + | // (ultra-fuzz Run 4 Perf S2). | |
| 464 | + | let hosts = tokio::task::spawn_blocking(move || { | |
| 465 | + | urlhaus::extract_unique_hosts(&urlhaus_map[..], urlhaus::MAX_HOSTS_PER_FILE) | |
| 466 | + | }) | |
| 467 | + | .await | |
| 468 | + | .unwrap_or_default(); | |
| 469 | + | urlhaus::check_urlhaus_hosts(hosts, urlhaus_key.as_deref()).await | |
| 461 | 470 | } else { | |
| 462 | 471 | LayerResult { | |
| 463 | 472 | layer: "urlhaus", |
| @@ -9,7 +9,10 @@ | |||
| 9 | 9 | //! Nothing in the scan pipeline calls this module yet; it is wired up in | |
| 10 | 10 | //! later chunks of the scanner-streaming refactor. | |
| 11 | 11 | ||
| 12 | - | use crate::constants::{SCAN_SPOOL_FREE_RESERVE_BYTES, SCAN_SPOOL_MAX_BYTES, SCAN_SPOOL_ORPHAN_AGE_SECS}; | |
| 12 | + | use crate::constants::{ | |
| 13 | + | SCAN_SPOOL_FREE_RESERVE_BYTES, SCAN_SPOOL_MAX_BYTES, SCAN_SPOOL_ORPHAN_AGE_SECS, | |
| 14 | + | SCAN_WORKER_COUNT, | |
| 15 | + | }; | |
| 13 | 16 | use s3_storage::ByteStream; | |
| 14 | 17 | use std::path::{Path, PathBuf}; | |
| 15 | 18 | use tokio::fs::{File, OpenOptions}; | |
| @@ -74,10 +77,16 @@ | |||
| 74 | 77 | }; | |
| 75 | 78 | let free = fs2::available_space(probe) | |
| 76 | 79 | .map_err(|e| format!("statvfs {}: {e}", probe.display()))?; | |
| 77 | - | if free.saturating_sub(expected_size) < SCAN_SPOOL_FREE_RESERVE_BYTES { | |
| 80 | + | // The check and the write aren't atomic: with `SCAN_WORKER_COUNT` workers, up | |
| 81 | + | // to that many spool downloads can pass this check and then write at once. | |
| 82 | + | // Reserve headroom for ALL of them rather than just this one, so concurrent | |
| 83 | + | // workers can't collectively overrun the free-space floor (ultra-fuzz Run 4 | |
| 84 | + | // Perf TOCTOU). Conservative by at most `(workers-1) * expected`. | |
| 85 | + | let concurrent_reserve = expected_size.saturating_mul(SCAN_WORKER_COUNT as u64); | |
| 86 | + | if free.saturating_sub(concurrent_reserve) < SCAN_SPOOL_FREE_RESERVE_BYTES { | |
| 78 | 87 | return Err(format!( | |
| 79 | - | "scan spool volume short on space: free={} expected={} reserve={}", | |
| 80 | - | free, expected_size, SCAN_SPOOL_FREE_RESERVE_BYTES | |
| 88 | + | "scan spool volume short on space: free={} expected={} workers={} reserve={}", | |
| 89 | + | free, expected_size, SCAN_WORKER_COUNT, SCAN_SPOOL_FREE_RESERVE_BYTES | |
| 81 | 90 | )); | |
| 82 | 91 | } | |
| 83 | 92 | Ok(()) |
| @@ -23,7 +23,7 @@ | |||
| 23 | 23 | ||
| 24 | 24 | const URLHAUS_API_URL: &str = "https://urlhaus-api.abuse.ch/v1/host/"; | |
| 25 | 25 | /// Cap per-upload host lookups to keep within free-tier quotas and bound work. | |
| 26 | - | const MAX_HOSTS_PER_FILE: usize = 5; | |
| 26 | + | pub(crate) const MAX_HOSTS_PER_FILE: usize = 5; | |
| 27 | 27 | /// Cap the byte window we scan for URLs; URLs in the wild fit easily here. | |
| 28 | 28 | const MAX_SCAN_BYTES: usize = 4 * 1024 * 1024; | |
| 29 | 29 | /// Minimum printable-ASCII run length to consider as a potential string. | |
| @@ -31,6 +31,25 @@ | |||
| 31 | 31 | ||
| 32 | 32 | /// Check the file for URLs that appear in URLhaus's known-bad index. | |
| 33 | 33 | pub async fn check_urlhaus(data: &[u8], auth_key: Option<&str>) -> LayerResult { | |
| 34 | + | if auth_key.is_none() { | |
| 35 | + | return LayerResult { | |
| 36 | + | layer: "urlhaus", | |
| 37 | + | verdict: LayerVerdict::Skip, | |
| 38 | + | detail: Some("No abuse.ch Auth-Key configured".to_string()), | |
| 39 | + | }; | |
| 40 | + | } | |
| 41 | + | // In-memory path (small uploads, tests): extraction is cheap on a heap | |
| 42 | + | // buffer. The large-file mmap path extracts inside `spawn_blocking` and calls | |
| 43 | + | // `check_urlhaus_hosts` directly (ultra-fuzz Run 4 Perf S2). | |
| 44 | + | let hosts = extract_unique_hosts(data, MAX_HOSTS_PER_FILE); | |
| 45 | + | check_urlhaus_hosts(hosts, auth_key).await | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /// URLhaus lookup over already-extracted hosts. Split from [`check_urlhaus`] so | |
| 49 | + | /// the mmap scan path can run the page-fault-prone host extraction off the async | |
| 50 | + | /// runtime (in `spawn_blocking`) and pass the result here, leaving only the | |
| 51 | + | /// network lookups on the runtime. | |
| 52 | + | pub(crate) async fn check_urlhaus_hosts(hosts: Vec<String>, auth_key: Option<&str>) -> LayerResult { | |
| 34 | 53 | let Some(key) = auth_key else { | |
| 35 | 54 | return LayerResult { | |
| 36 | 55 | layer: "urlhaus", | |
| @@ -39,7 +58,6 @@ | |||
| 39 | 58 | }; | |
| 40 | 59 | }; | |
| 41 | 60 | ||
| 42 | - | let hosts = extract_unique_hosts(data, MAX_HOSTS_PER_FILE); | |
| 43 | 61 | if hosts.is_empty() { | |
| 44 | 62 | return LayerResult { | |
| 45 | 63 | layer: "urlhaus", | |
| @@ -160,7 +178,7 @@ | |||
| 160 | 178 | ||
| 161 | 179 | /// Pull printable-ASCII URL hosts out of the byte buffer. Cap at `max` unique | |
| 162 | 180 | /// hosts to bound per-upload work and free-tier quota use. | |
| 163 | - | fn extract_unique_hosts(data: &[u8], max: usize) -> Vec<String> { | |
| 181 | + | pub(crate) fn extract_unique_hosts(data: &[u8], max: usize) -> Vec<String> { | |
| 164 | 182 | let scan = if data.len() > MAX_SCAN_BYTES { &data[..MAX_SCAN_BYTES] } else { data }; | |
| 165 | 183 | ||
| 166 | 184 | let mut hosts: HashSet<String> = HashSet::new(); |
| @@ -309,6 +309,13 @@ | |||
| 309 | 309 | /// Permanently delete items that were soft-deleted more than 7 days ago. | |
| 310 | 310 | /// Cleans up S3 objects (item files + version files) and decrements storage | |
| 311 | 311 | /// before DB deletion to prevent orphaned storage and accounting drift. | |
| 312 | + | /// | |
| 313 | + | /// Accepted residual (ultra-fuzz Run 4 Perf, decision 2026-06-23): this gathers | |
| 314 | + | /// all expired-item S3 keys into one Vec before the batch delete. It is a daily | |
| 315 | + | /// cron off the request path; steady-state (7-day window) is small, so the only | |
| 316 | + | /// large allocation is transient, after a rare mass-delete event. Bounding it | |
| 317 | + | /// per-tick means coupling a LIMIT on the key-gather to the same slice as the | |
| 318 | + | /// CASCADE delete; deferred as low-value for a cron path. Revisit if it matters. | |
| 312 | 319 | pub(super) async fn purge_expired_deleted_items(state: &AppState) { | |
| 313 | 320 | // Collect S3 keys from items AND their versions before CASCADE delete destroys the data | |
| 314 | 321 | let mut all_s3_keys: Vec<(String, String)> = Vec::new(); |
| @@ -96,6 +96,14 @@ | |||
| 96 | 96 | // Pin advisory lock to a dedicated connection held for the entire tick. | |
| 97 | 97 | // pg_try_advisory_lock is session-scoped — holding the connection prevents | |
| 98 | 98 | // another instance from acquiring the lock until this tick completes. | |
| 99 | + | // | |
| 100 | + | // Accepted residual (ultra-fuzz Run 4 Perf, decision 2026-06-23): this | |
| 101 | + | // borrows 1 of the request pool's connections for the tick's duration | |
| 102 | + | // (effective serving capacity 24/25 during a tick). The architecturally | |
| 103 | + | // cleaner shape is a dedicated 1-connection side-pool for the lock, but | |
| 104 | + | // that change touches the deploy-critical scheduler's connection | |
| 105 | + | // lifecycle for a small steady-state gain; revisit if pool-pressure | |
| 106 | + | // metrics show it matters. | |
| 99 | 107 | let mut lock_conn = match state.db.acquire().await { | |
| 100 | 108 | Ok(conn) => conn, | |
| 101 | 109 | Err(e) => { |