max / makenotwork
10 files changed,
+158 insertions,
-69 deletions
| @@ -321,17 +321,22 @@ pub async fn reorder_collection_items( | |||
| 321 | 321 | collection_id: CollectionId, | |
| 322 | 322 | item_ids: &[ItemId], | |
| 323 | 323 | ) -> Result<()> { | |
| 324 | + | let ids: Vec<uuid::Uuid> = item_ids.iter().map(|id| *id.as_uuid()).collect(); | |
| 324 | 325 | let mut tx = pool.begin().await?; | |
| 325 | - | for (index, item_id) in item_ids.iter().enumerate() { | |
| 326 | - | sqlx::query( | |
| 327 | - | "UPDATE collection_items SET position = $1 WHERE collection_id = $2 AND item_id = $3", | |
| 328 | - | ) | |
| 329 | - | .bind(index as i32) | |
| 330 | - | .bind(collection_id) | |
| 331 | - | .bind(item_id) | |
| 332 | - | .execute(&mut *tx) | |
| 333 | - | .await?; | |
| 334 | - | } | |
| 326 | + | // Single `UNNEST ... WITH ORDINALITY` update instead of one query per item — | |
| 327 | + | // atomic within the same transaction that bumps the collection's timestamp. | |
| 328 | + | sqlx::query( | |
| 329 | + | r#" | |
| 330 | + | UPDATE collection_items AS ci | |
| 331 | + | SET position = ord.pos::int - 1 | |
| 332 | + | FROM UNNEST($1::uuid[]) WITH ORDINALITY AS ord(id, pos) | |
| 333 | + | WHERE ci.collection_id = $2 AND ci.item_id = ord.id | |
| 334 | + | "#, | |
| 335 | + | ) | |
| 336 | + | .bind(&ids) | |
| 337 | + | .bind(collection_id) | |
| 338 | + | .execute(&mut *tx) | |
| 339 | + | .await?; | |
| 335 | 340 | ||
| 336 | 341 | sqlx::query("UPDATE collections SET updated_at = NOW() WHERE id = $1") | |
| 337 | 342 | .bind(collection_id) |
| @@ -92,19 +92,25 @@ pub async fn delete_custom_link(pool: &PgPool, id: CustomLinkId, user_id: UserId | |||
| 92 | 92 | } | |
| 93 | 93 | ||
| 94 | 94 | /// Reorder a user's custom links by assigning sort_order from the given ID sequence. | |
| 95 | - | /// Wrapped in a transaction so a crash mid-reorder doesn't leave inconsistent state. | |
| 95 | + | /// | |
| 96 | + | /// A single `UNNEST ... WITH ORDINALITY` update — inherently atomic (no partial | |
| 97 | + | /// ordering on failure) and one round-trip instead of one query per link, so the | |
| 98 | + | /// prior explicit transaction is no longer needed. | |
| 96 | 99 | #[tracing::instrument(skip_all)] | |
| 97 | 100 | pub async fn reorder_custom_links(pool: &PgPool, user_id: UserId, link_ids: &[CustomLinkId]) -> Result<()> { | |
| 98 | - | let mut tx = pool.begin().await?; | |
| 99 | - | for (index, link_id) in link_ids.iter().enumerate() { | |
| 100 | - | sqlx::query("UPDATE custom_links SET sort_order = $1 WHERE id = $2 AND user_id = $3") | |
| 101 | - | .bind(index as i32) | |
| 102 | - | .bind(link_id) | |
| 103 | - | .bind(user_id) | |
| 104 | - | .execute(&mut *tx) | |
| 105 | - | .await?; | |
| 106 | - | } | |
| 107 | - | tx.commit().await?; | |
| 101 | + | let ids: Vec<uuid::Uuid> = link_ids.iter().map(|id| *id.as_uuid()).collect(); | |
| 102 | + | sqlx::query( | |
| 103 | + | r#" | |
| 104 | + | UPDATE custom_links AS c | |
| 105 | + | SET sort_order = ord.pos::int - 1 | |
| 106 | + | FROM UNNEST($1::uuid[]) WITH ORDINALITY AS ord(id, pos) | |
| 107 | + | WHERE c.id = ord.id AND c.user_id = $2 | |
| 108 | + | "#, | |
| 109 | + | ) | |
| 110 | + | .bind(&ids) | |
| 111 | + | .bind(user_id) | |
| 112 | + | .execute(pool) | |
| 113 | + | .await?; | |
| 108 | 114 | ||
| 109 | 115 | Ok(()) | |
| 110 | 116 | } |
| @@ -98,18 +98,24 @@ pub async fn delete(pool: &PgPool, section_id: ItemSectionId) -> Result<()> { | |||
| 98 | 98 | } | |
| 99 | 99 | ||
| 100 | 100 | /// Reorder sections by setting sort_order from an ordered list of IDs. | |
| 101 | + | /// | |
| 102 | + | /// A single `UNNEST ... WITH ORDINALITY` update: atomic (no partial ordering on | |
| 103 | + | /// failure) and one round-trip instead of one query per section. | |
| 101 | 104 | #[tracing::instrument(skip_all)] | |
| 102 | 105 | pub async fn reorder(pool: &PgPool, item_id: ItemId, section_ids: &[ItemSectionId]) -> Result<()> { | |
| 103 | - | for (i, id) in section_ids.iter().enumerate() { | |
| 104 | - | sqlx::query( | |
| 105 | - | "UPDATE item_sections SET sort_order = $1, updated_at = now() WHERE id = $2 AND item_id = $3", | |
| 106 | - | ) | |
| 107 | - | .bind(i as i32) | |
| 108 | - | .bind(id) | |
| 109 | - | .bind(item_id) | |
| 110 | - | .execute(pool) | |
| 111 | - | .await?; | |
| 112 | - | } | |
| 106 | + | let ids: Vec<uuid::Uuid> = section_ids.iter().map(|id| *id.as_uuid()).collect(); | |
| 107 | + | sqlx::query( | |
| 108 | + | r#" | |
| 109 | + | UPDATE item_sections AS s | |
| 110 | + | SET sort_order = ord.pos::int - 1, updated_at = now() | |
| 111 | + | FROM UNNEST($1::uuid[]) WITH ORDINALITY AS ord(id, pos) | |
| 112 | + | WHERE s.id = ord.id AND s.item_id = $2 | |
| 113 | + | "#, | |
| 114 | + | ) | |
| 115 | + | .bind(&ids) | |
| 116 | + | .bind(item_id) | |
| 117 | + | .execute(pool) | |
| 118 | + | .await?; | |
| 113 | 119 | ||
| 114 | 120 | Ok(()) | |
| 115 | 121 | } |
| @@ -169,28 +169,36 @@ pub async fn create_default_lists( | |||
| 169 | 169 | Ok(()) | |
| 170 | 170 | } | |
| 171 | 171 | ||
| 172 | - | /// Subscribe an email address (without a user account) to a mailing list. | |
| 173 | - | /// Used for importing subscriber lists from external platforms. | |
| 174 | - | /// Returns `true` if a new row was created, `false` if the email already exists. | |
| 175 | - | #[tracing::instrument(skip_all)] | |
| 176 | - | pub async fn subscribe_by_email( | |
| 172 | + | /// Batch-subscribe many emails to a list in a single statement. | |
| 173 | + | /// | |
| 174 | + | /// Used by the import pipeline, where subscribing one email per query is an | |
| 175 | + | /// N+1 storm (a 100k-subscriber import would issue 100k sequential INSERTs on | |
| 176 | + | /// the shared pool). Emails are lowercased and deduplicated within the batch; | |
| 177 | + | /// `ON CONFLICT DO NOTHING` skips ones already subscribed. Returns the number of | |
| 178 | + | /// rows actually inserted (new subscribers). | |
| 179 | + | #[tracing::instrument(skip_all, fields(count = emails.len()))] | |
| 180 | + | pub async fn subscribe_many_by_email( | |
| 177 | 181 | pool: &PgPool, | |
| 178 | 182 | list_id: MailingListId, | |
| 179 | - | email: &str, | |
| 180 | - | ) -> Result<bool> { | |
| 183 | + | emails: &[String], | |
| 184 | + | ) -> Result<u64> { | |
| 185 | + | if emails.is_empty() { | |
| 186 | + | return Ok(0); | |
| 187 | + | } | |
| 188 | + | let lowered: Vec<String> = emails.iter().map(|e| e.to_lowercase()).collect(); | |
| 181 | 189 | let result = sqlx::query( | |
| 182 | 190 | r#" | |
| 183 | 191 | INSERT INTO mailing_list_subscribers (list_id, email) | |
| 184 | - | VALUES ($1, $2) | |
| 192 | + | SELECT $1, sub_email FROM UNNEST($2::text[]) AS sub_email | |
| 185 | 193 | ON CONFLICT DO NOTHING | |
| 186 | 194 | "#, | |
| 187 | 195 | ) | |
| 188 | 196 | .bind(list_id) | |
| 189 | - | .bind(email.to_lowercase()) | |
| 197 | + | .bind(&lowered) | |
| 190 | 198 | .execute(pool) | |
| 191 | 199 | .await?; | |
| 192 | 200 | ||
| 193 | - | Ok(result.rows_affected() > 0) | |
| 201 | + | Ok(result.rows_affected()) | |
| 194 | 202 | } | |
| 195 | 203 | ||
| 196 | 204 | /// Convenience: find the content list for a project and subscribe a user. |
| @@ -1,7 +1,10 @@ | |||
| 1 | 1 | //! Generic import pipeline: takes an `ImportPayload` and creates MNW entities. | |
| 2 | 2 | //! | |
| 3 | - | //! Processing happens in chunks of 50 rows with progress updates after each chunk. | |
| 4 | - | //! Individual row failures are logged but don't abort the import. | |
| 3 | + | //! Items are processed in chunks with progress updates after each chunk; | |
| 4 | + | //! subscribers are written in batched `UNNEST` inserts (see `SUBSCRIBER_BATCH_SIZE`) | |
| 5 | + | //! rather than one query per email. Individual item failures are logged but don't | |
| 6 | + | //! abort the import; a hard error (e.g. mailing-list creation) propagates and the | |
| 7 | + | //! caller (`routes/api/imports.rs`) marks the job `failed` via `fail_import_job`. | |
| 5 | 8 | ||
| 6 | 9 | use sqlx::PgPool; | |
| 7 | 10 | ||
| @@ -12,6 +15,13 @@ use crate::error::Result; | |||
| 12 | 15 | /// Chunk size for progress updates. | |
| 13 | 16 | const CHUNK_SIZE: usize = 50; | |
| 14 | 17 | ||
| 18 | + | /// Batch size for the subscriber insert. Subscribers are written with a single | |
| 19 | + | /// `UNNEST` INSERT per batch (not one query per email), so this is bounded by | |
| 20 | + | /// how many parameters we want in one statement rather than progress-update | |
| 21 | + | /// cadence — 1,000 keeps the array small while collapsing a 100k-row import | |
| 22 | + | /// from 100k INSERTs to 100. | |
| 23 | + | const SUBSCRIBER_BATCH_SIZE: usize = 1_000; | |
| 24 | + | ||
| 15 | 25 | /// Run the full import pipeline for a job. | |
| 16 | 26 | /// | |
| 17 | 27 | /// Creates MNW entities (tiers, items, tags, mailing list subscribers) from the | |
| @@ -67,18 +77,22 @@ pub async fn run_import( | |||
| 67 | 77 | ) | |
| 68 | 78 | .await?; | |
| 69 | 79 | ||
| 70 | - | for chunk in payload.subscribers.chunks(CHUNK_SIZE) { | |
| 71 | - | for sub in chunk { | |
| 72 | - | match db::mailing_lists::subscribe_by_email(pool, list.id, &sub.email).await { | |
| 73 | - | Ok(true) => created += 1, | |
| 74 | - | Ok(false) => skipped += 1, | |
| 75 | - | Err(e) => { | |
| 76 | - | errors.push(format!("Subscriber '{}': {}", sub.email, e)); | |
| 77 | - | skipped += 1; | |
| 78 | - | } | |
| 80 | + | for chunk in payload.subscribers.chunks(SUBSCRIBER_BATCH_SIZE) { | |
| 81 | + | let emails: Vec<String> = chunk.iter().map(|s| s.email.clone()).collect(); | |
| 82 | + | match db::mailing_lists::subscribe_many_by_email(pool, list.id, &emails).await { | |
| 83 | + | Ok(inserted) => { | |
| 84 | + | let inserted = inserted as i32; | |
| 85 | + | created += inserted; | |
| 86 | + | skipped += chunk.len() as i32 - inserted; | |
| 87 | + | } | |
| 88 | + | Err(e) => { | |
| 89 | + | // A batch failure is a hard DB error (not a per-row skip); surface | |
| 90 | + | // it and count the whole chunk as skipped rather than N+1-retrying. | |
| 91 | + | errors.push(format!("Subscriber batch ({} rows): {}", chunk.len(), e)); | |
| 92 | + | skipped += chunk.len() as i32; | |
| 79 | 93 | } | |
| 80 | - | processed += 1; | |
| 81 | 94 | } | |
| 95 | + | processed += chunk.len() as i32; | |
| 82 | 96 | update_progress(pool, job_id, processed, created, skipped).await; | |
| 83 | 97 | } | |
| 84 | 98 |
| @@ -202,8 +202,18 @@ async fn main() { | |||
| 202 | 202 | ||
| 203 | 203 | // Initialize Stripe client if configured | |
| 204 | 204 | let stripe: Option<std::sync::Arc<dyn makenotwork::payments::PaymentProvider>> = if let Some(ref stripe_config) = config.stripe { | |
| 205 | - | tracing::info!("Stripe payments initialized"); | |
| 206 | - | Some(std::sync::Arc::new(StripeClient::new(stripe_config))) | |
| 205 | + | match StripeClient::new(stripe_config) { | |
| 206 | + | Ok(client) => { | |
| 207 | + | tracing::info!("Stripe payments initialized"); | |
| 208 | + | Some(std::sync::Arc::new(client) as std::sync::Arc<dyn makenotwork::payments::PaymentProvider>) | |
| 209 | + | } | |
| 210 | + | Err(e) => { | |
| 211 | + | // Invalid client config is a boot invariant violation — exit | |
| 212 | + | // without restart rather than run with payments silently broken. | |
| 213 | + | tracing::error!(error = %e, "Failed to build Stripe client — exiting without restart"); | |
| 214 | + | std::process::exit(2); | |
| 215 | + | } | |
| 216 | + | } | |
| 207 | 217 | } else { | |
| 208 | 218 | tracing::info!("Stripe not configured. Payments will be unavailable."); | |
| 209 | 219 | None |
| @@ -311,6 +311,13 @@ impl StripeClient { | |||
| 311 | 311 | transaction_id: crate::db::TransactionId, | |
| 312 | 312 | ) -> Result<()> { | |
| 313 | 313 | let acct = Self::parse_account_id(connected_account_id)?; | |
| 314 | + | // Deterministic idempotency key (`refund-{transaction_id}`), mirroring the | |
| 315 | + | // platform-credit transfer below: a retry after a crash or transient | |
| 316 | + | // failure returns the same refund rather than double-debiting the | |
| 317 | + | // creator's connected balance. A transaction is refunded in full exactly | |
| 318 | + | // once, so keying on its id is the correct dedup scope. | |
| 319 | + | let key = IdempotencyKey::new(format!("refund-{transaction_id}")) | |
| 320 | + | .map_err(|e| AppError::Internal(anyhow::anyhow!("invalid idempotency key: {e}")))?; | |
| 314 | 321 | let metadata = std::collections::HashMap::from([( | |
| 315 | 322 | "mnw_transaction_id".to_string(), | |
| 316 | 323 | transaction_id.to_string(), | |
| @@ -321,6 +328,7 @@ impl StripeClient { | |||
| 321 | 328 | .metadata(metadata) | |
| 322 | 329 | .customize() | |
| 323 | 330 | .account_id(acct) | |
| 331 | + | .request_strategy(RequestStrategy::Idempotent(key)) | |
| 324 | 332 | .send(&self.client) | |
| 325 | 333 | .await | |
| 326 | 334 | .map_err(|e| { |
| @@ -28,9 +28,18 @@ pub use synckit_app_pricing::{quote_price_cents, SyncBillingInterval, ANNUAL_MUL | |||
| 28 | 28 | pub use synckit_billing::SynckitSubResult; | |
| 29 | 29 | pub use webhooks::*; | |
| 30 | 30 | ||
| 31 | - | use stripe::Client; | |
| 31 | + | use std::time::Duration; | |
| 32 | + | ||
| 33 | + | use stripe::{Client, ClientBuilder}; | |
| 32 | 34 | use crate::config::StripeConfig; | |
| 33 | 35 | ||
| 36 | + | /// Per-attempt HTTP timeout for outbound Stripe calls. The async client has no | |
| 37 | + | /// timeout by default, so a hung connection would otherwise stall the caller | |
| 38 | + | /// indefinitely — on a webhook handler that holds the response open and invites | |
| 39 | + | /// Stripe's retry storm. 30s matches async-stripe's own blocking-client default; | |
| 40 | + | /// the request strategy still retries a timed-out attempt where permitted. | |
| 41 | + | const STRIPE_HTTP_TIMEOUT: Duration = Duration::from_secs(30); | |
| 42 | + | ||
| 34 | 43 | /// Stripe client wrapper for payment operations | |
| 35 | 44 | #[derive(Clone)] | |
| 36 | 45 | pub struct StripeClient { | |
| @@ -39,13 +48,20 @@ pub struct StripeClient { | |||
| 39 | 48 | } | |
| 40 | 49 | ||
| 41 | 50 | impl StripeClient { | |
| 42 | - | /// Create a new Stripe client from configuration | |
| 43 | - | pub fn new(config: &StripeConfig) -> Self { | |
| 44 | - | let client = Client::new(&config.secret_key); | |
| 45 | - | StripeClient { | |
| 51 | + | /// Create a new Stripe client from configuration. | |
| 52 | + | /// | |
| 53 | + | /// Fallible because the builder validates the client config; a build error is | |
| 54 | + | /// an internal invariant violation (the secret key comes from validated | |
| 55 | + | /// config), so it is classified `Internal` and surfaces at boot. | |
| 56 | + | pub fn new(config: &StripeConfig) -> Result<Self> { | |
| 57 | + | let client = ClientBuilder::new(&config.secret_key) | |
| 58 | + | .timeout(STRIPE_HTTP_TIMEOUT) | |
| 59 | + | .build() | |
| 60 | + | .map_err(|e| AppError::Internal(anyhow::anyhow!("failed to build Stripe client: {e}")))?; | |
| 61 | + | Ok(StripeClient { | |
| 46 | 62 | client, | |
| 47 | 63 | config: config.clone(), | |
| 48 | - | } | |
| 64 | + | }) | |
| 49 | 65 | } | |
| 50 | 66 | ||
| 51 | 67 | /// Parse a connected account ID string into an `AccountId`. |
| @@ -271,24 +271,39 @@ pub(super) async fn guest_download( | |||
| 271 | 271 | .await? | |
| 272 | 272 | .ok_or(AppError::NotFound)?; | |
| 273 | 273 | ||
| 274 | + | // Resolve the downloadable file and the scan status that gates it. Items | |
| 275 | + | // deliver either via an inline audio/video key or via a version file | |
| 276 | + | // (software / digital-file items). A guest download token is item-scoped, so | |
| 277 | + | // a version-delivered item resolves to its current file — the same file an | |
| 278 | + | // authenticated buyer gets from the item page. Previously this path checked | |
| 279 | + | // only the inline keys, so a guest who bought a version-delivered item got a | |
| 280 | + | // token that 404'd despite having paid (Run 15 fulfillment gap). | |
| 281 | + | let (s3_key, scan_status) = match item.audio_s3_key.clone().or(item.video_s3_key.clone()) { | |
| 282 | + | Some(key) => (key, item.scan_status), | |
| 283 | + | None => db::versions::get_versions_by_item(&state.db, item_id) | |
| 284 | + | .await? | |
| 285 | + | .into_iter() | |
| 286 | + | .filter_map(|v| v.s3_key.map(|k| (k, v.scan_status, v.is_current, v.created_at))) | |
| 287 | + | // Prefer the current version, then the most recent, matching what the | |
| 288 | + | // item page hands an authenticated buyer. | |
| 289 | + | .max_by(|a, b| a.2.cmp(&b.2).then(a.3.cmp(&b.3))) | |
| 290 | + | .map(|(key, status, _, _)| (key, status)) | |
| 291 | + | .ok_or(AppError::NotFound)?, | |
| 292 | + | }; | |
| 293 | + | ||
| 274 | 294 | // Gate on scan status, exactly as the authenticated download path does | |
| 275 | 295 | // (downloads.rs). A guest is never the creator, so there is no preview | |
| 276 | 296 | // exemption: only Clean content is downloadable — Pending/HeldForReview/ | |
| 277 | 297 | // Quarantined are withheld until the scan clears (Sec NOTE, Run 7; the guest | |
| 278 | 298 | // path previously skipped this quarantine check entirely). | |
| 279 | - | if item.scan_status != db::FileScanStatus::Clean { | |
| 299 | + | if scan_status != db::FileScanStatus::Clean { | |
| 280 | 300 | return Err(AppError::NotFound); | |
| 281 | 301 | } | |
| 282 | 302 | ||
| 283 | - | // Get the S3 key for the content | |
| 284 | - | let s3_key = item.audio_s3_key.as_deref() | |
| 285 | - | .or(item.video_s3_key.as_deref()) | |
| 286 | - | .ok_or_else(|| AppError::NotFound)?; | |
| 287 | - | ||
| 288 | 303 | let s3 = state.s3.as_ref() | |
| 289 | 304 | .ok_or_else(|| AppError::ServiceUnavailable("File storage is not configured".to_string()))?; | |
| 290 | 305 | ||
| 291 | - | let download_url = s3.presign_download(&crate::storage::S3Key::from_stored(s3_key), Some(3600)).await?; | |
| 306 | + | let download_url = s3.presign_download(&crate::storage::S3Key::from_stored(&s3_key), Some(3600)).await?; | |
| 292 | 307 | ||
| 293 | 308 | Ok(Redirect::temporary(&download_url).into_response()) | |
| 294 | 309 | } |
| @@ -168,7 +168,8 @@ impl TestHarness { | |||
| 168 | 168 | webhook_secret: vec![stripe::TEST_WEBHOOK_SECRET.to_string()], | |
| 169 | 169 | webhook_secret_v2: Some(stripe::TEST_WEBHOOK_SECRET_V2.to_string()), | |
| 170 | 170 | }; | |
| 171 | - | let stripe_client: Arc<dyn PaymentProvider> = Arc::new(StripeClient::new(&stripe_config)); | |
| 171 | + | let stripe_client: Arc<dyn PaymentProvider> = | |
| 172 | + | Arc::new(StripeClient::new(&stripe_config).expect("test Stripe client builds")); | |
| 172 | 173 | Self::build(BuildOptions { | |
| 173 | 174 | stripe_client: Some(stripe_client), | |
| 174 | 175 | ..Default::default() |