max / makenotwork
5 files changed,
+110 insertions,
-35 deletions
| @@ -57,14 +57,15 @@ pub async fn mark_event_processed(pool: &PgPool, event_id: &str) -> Result<()> { | |||
| 57 | 57 | Ok(()) | |
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | - | /// Serialize concurrent redeliveries of one webhook event. | |
| 60 | + | /// Serialize concurrent redeliveries of one webhook event, without blocking. | |
| 61 | 61 | /// | |
| 62 | - | /// Returns a transaction holding a per-event `pg_advisory_xact_lock`. Hold the | |
| 63 | - | /// returned guard across the whole dedup-read -> process -> mark sequence: a | |
| 64 | - | /// second concurrent delivery of the same event blocks in this call until the | |
| 65 | - | /// first delivery returns (releasing the lock) and, by then, has durably | |
| 66 | - | /// committed its [`mark_event_processed`] row — so the second delivery's dedup | |
| 67 | - | /// read sees it and short-circuits. | |
| 62 | + | /// Returns `Some(tx)` holding a per-event `pg_advisory_xact_lock` when the lock | |
| 63 | + | /// is free, or `None` when another delivery of the *same* event already holds it. | |
| 64 | + | /// Hold the returned guard across the whole dedup-read -> process -> mark | |
| 65 | + | /// sequence: while it is held, a second concurrent delivery of the same event | |
| 66 | + | /// gets `None` here and the caller returns 503, so Stripe redelivers after the | |
| 67 | + | /// first delivery has committed its [`mark_event_processed`] row — and the | |
| 68 | + | /// redelivery's dedup read then short-circuits. | |
| 68 | 69 | /// | |
| 69 | 70 | /// This is the structural counterpart to the check-then-act dedup read. On its | |
| 70 | 71 | /// own that read has a TOCTOU window: two concurrent deliveries both observe | |
| @@ -73,24 +74,35 @@ pub async fn mark_event_processed(pool: &PgPool, event_id: &str) -> Result<()> { | |||
| 73 | 74 | /// and concurrent double-processing is impossible — a future non-idempotent | |
| 74 | 75 | /// handler cannot double-fire on a redelivery race. | |
| 75 | 76 | /// | |
| 77 | + | /// Why *try* rather than block (`pg_advisory_xact_lock`): a blocking acquire | |
| 78 | + | /// would park the pooled connection for the whole time the in-flight delivery | |
| 79 | + | /// runs — including its outbound Stripe calls — so a redelivery storm on one hot | |
| 80 | + | /// event could pin several connections just *waiting*. `pg_try_advisory_xact_lock` | |
| 81 | + | /// returns immediately; the loser sheds its connection and lets Stripe's own | |
| 82 | + | /// backoff redeliver, which is strictly cheaper than holding a conn to win a race | |
| 83 | + | /// the dedup marker will settle anyway. Only same-event contention is affected — | |
| 84 | + | /// distinct events hash to distinct keys and never contend. | |
| 85 | + | /// | |
| 76 | 86 | /// Robustness: the lock is transaction-scoped, so dropping the guard on any | |
| 77 | 87 | /// early return, `?`, or panic rolls the (write-free) transaction back and | |
| 78 | - | /// releases the lock. It cannot leak the way a pooled session lock would. The | |
| 79 | - | /// key is namespaced (`stripe_webhook:` prefix) so it shares no space with the | |
| 80 | - | /// other `hashtextextended` advisory locks in the codebase (reports, oauth). | |
| 88 | + | /// releases the lock. It cannot leak the way a pooled session lock would. When | |
| 89 | + | /// the lock is *not* acquired the returned `tx` is dropped here holding nothing, | |
| 90 | + | /// so there is no lock to leak. The key is namespaced (`stripe_webhook:` prefix) | |
| 91 | + | /// so it shares no space with the other `hashtextextended` advisory locks in the | |
| 92 | + | /// codebase (reports, oauth). | |
| 81 | 93 | #[tracing::instrument(skip_all)] | |
| 82 | - | pub async fn lock_event<'a>( | |
| 94 | + | pub async fn try_lock_event<'a>( | |
| 83 | 95 | pool: &'a PgPool, | |
| 84 | 96 | event_id: &str, | |
| 85 | - | ) -> Result<Transaction<'a, Postgres>> { | |
| 97 | + | ) -> Result<Option<Transaction<'a, Postgres>>> { | |
| 86 | 98 | let mut tx = pool.begin().await?; | |
| 87 | - | sqlx::query( | |
| 88 | - | "SELECT pg_advisory_xact_lock(hashtextextended('stripe_webhook:' || $1::text, 0))", | |
| 99 | + | let acquired = sqlx::query_scalar::<_, bool>( | |
| 100 | + | "SELECT pg_try_advisory_xact_lock(hashtextextended('stripe_webhook:' || $1::text, 0))", | |
| 89 | 101 | ) | |
| 90 | 102 | .bind(event_id) | |
| 91 | - | .execute(&mut *tx) | |
| 103 | + | .fetch_one(&mut *tx) | |
| 92 | 104 | .await?; | |
| 93 | - | Ok(tx) | |
| 105 | + | Ok(acquired.then_some(tx)) | |
| 94 | 106 | } | |
| 95 | 107 | ||
| 96 | 108 | /// Delete processed-event dedup markers older than `days`. These markers only |
| @@ -47,14 +47,20 @@ pub(in crate::routes::stripe) async fn webhook( | |||
| 47 | 47 | ||
| 48 | 48 | // Serialize concurrent redeliveries of this event id. Held across the whole | |
| 49 | 49 | // dedup-read -> process -> mark sequence below, the lock makes the dedup read | |
| 50 | - | // race-free: a second delivery of the same event blocks until this one | |
| 51 | - | // returns and has durably committed its processed-event mark, then reads it | |
| 52 | - | // and skips. Dropping `_event_lock` on any return path rolls the (write-free) | |
| 53 | - | // lock transaction back and releases it — it cannot leak. See | |
| 54 | - | // `db::webhook_events::lock_event`. On acquisition failure we return 503 so | |
| 55 | - | // Stripe retries, matching the dedup-read failure path. | |
| 56 | - | let _event_lock = match db::webhook_events::lock_event(&state.db, &event.id).await { | |
| 57 | - | Ok(tx) => tx, | |
| 50 | + | // race-free. We *try* the lock rather than block on it: if a second delivery | |
| 51 | + | // of the same event arrives while this one is mid-flight, it gets `None` and | |
| 52 | + | // returns 503 immediately instead of parking a pooled connection for the | |
| 53 | + | // duration of the in-flight handler (Run 23 Conc/Perf). Stripe redelivers | |
| 54 | + | // after this one commits its processed-event mark, and the redelivery's dedup | |
| 55 | + | // read then short-circuits. Dropping `_event_lock` on any return path rolls | |
| 56 | + | // the (write-free) lock transaction back and releases it — it cannot leak. | |
| 57 | + | // See `db::webhook_events::try_lock_event`. | |
| 58 | + | let _event_lock = match db::webhook_events::try_lock_event(&state.db, &event.id).await { | |
| 59 | + | Ok(Some(tx)) => tx, | |
| 60 | + | Ok(None) => { | |
| 61 | + | tracing::info!(event_id = %event.id, "concurrent delivery of this webhook event is in flight; returning 503 for redelivery"); | |
| 62 | + | return Ok(StatusCode::SERVICE_UNAVAILABLE); | |
| 63 | + | } | |
| 58 | 64 | Err(e) => { | |
| 59 | 65 | tracing::error!(event_id = %event.id, error = ?e, "failed to acquire webhook event lock, returning 503 for retry"); | |
| 60 | 66 | return Ok(StatusCode::SERVICE_UNAVAILABLE); |
| @@ -49,12 +49,17 @@ pub(super) async fn webhook_v2( | |||
| 49 | 49 | tracing::info!(event_type = %thin.event_type, event_id = %thin.id, "received v2 thin event"); | |
| 50 | 50 | ||
| 51 | 51 | // Serialize concurrent redeliveries of this event id (see | |
| 52 | - | // `db::webhook_events::lock_event`). Held across dedup-read -> process -> | |
| 53 | - | // mark, it makes the check-then-act read below race-free. Dropping | |
| 54 | - | // `_event_lock` on any return releases the lock; on acquisition failure we | |
| 55 | - | // return 503 so Stripe retries. | |
| 56 | - | let _event_lock = match db::webhook_events::lock_event(&state.db, &thin.id).await { | |
| 57 | - | Ok(tx) => tx, | |
| 52 | + | // `db::webhook_events::try_lock_event`). Held across dedup-read -> process -> | |
| 53 | + | // mark, it makes the check-then-act read below race-free. We *try* the lock | |
| 54 | + | // rather than block: a same-event delivery arriving mid-flight gets `None` and | |
| 55 | + | // returns 503 immediately instead of parking a pooled connection (Run 23 | |
| 56 | + | // Conc/Perf). Dropping `_event_lock` on any return releases the lock. | |
| 57 | + | let _event_lock = match db::webhook_events::try_lock_event(&state.db, &thin.id).await { | |
| 58 | + | Ok(Some(tx)) => tx, | |
| 59 | + | Ok(None) => { | |
| 60 | + | tracing::info!(event_id = %thin.id, "concurrent delivery of this v2 event is in flight; returning 503 for redelivery"); | |
| 61 | + | return Ok(StatusCode::SERVICE_UNAVAILABLE); | |
| 62 | + | } | |
| 58 | 63 | Err(e) => { | |
| 59 | 64 | tracing::error!(event_id = %thin.id, error = ?e, "failed to acquire v2 webhook event lock, returning 503 for retry"); | |
| 60 | 65 | return Ok(StatusCode::SERVICE_UNAVAILABLE); |
| @@ -47,12 +47,18 @@ pub(super) async fn retry_failed_webhooks(state: &AppState) { | |||
| 47 | 47 | }; | |
| 48 | 48 | ||
| 49 | 49 | // Hold the per-event lock across dedup-read -> process -> mark, exactly | |
| 50 | - | // like the live handler. On lock failure, leave the row queued for the | |
| 51 | - | // next tick. Named binding (not bare `_`) so the guard lives — and holds | |
| 52 | - | // the lock — for the whole iteration. | |
| 50 | + | // like the live handler. We *try* the lock: if a live redelivery (or | |
| 51 | + | // another tick) already holds it, leave this row queued and move on rather | |
| 52 | + | // than blocking the retry loop on a pooled connection. Named binding (not | |
| 53 | + | // bare `_`) so the guard lives — and holds the lock — for the whole | |
| 54 | + | // iteration. | |
| 53 | 55 | let _event_lock = match &stripe_event_id { | |
| 54 | - | Some(eid) => match db::webhook_events::lock_event(&state.db, eid).await { | |
| 55 | - | Ok(tx) => Some(tx), | |
| 56 | + | Some(eid) => match db::webhook_events::try_lock_event(&state.db, eid).await { | |
| 57 | + | Ok(Some(tx)) => Some(tx), | |
| 58 | + | Ok(None) => { | |
| 59 | + | tracing::info!(event_id = %event.id, "webhook event locked by another worker; leaving queued for next tick"); | |
| 60 | + | continue; | |
| 61 | + | } | |
| 56 | 62 | Err(e) => { | |
| 57 | 63 | tracing::error!(event_id = %event.id, error = ?e, "failed to lock webhook event for retry; will retry next tick"); | |
| 58 | 64 | continue; |
| @@ -103,6 +103,52 @@ async fn mark_event_processed_concurrent_duplicates_leave_one_row() { | |||
| 103 | 103 | ); | |
| 104 | 104 | } | |
| 105 | 105 | ||
| 106 | + | /// `try_lock_event` must serialize same-event deliveries without blocking: while | |
| 107 | + | /// one delivery holds the per-event lock, a second delivery of the *same* event | |
| 108 | + | /// gets `None` (→ the handler returns 503 for Stripe to redeliver) rather than | |
| 109 | + | /// parking a connection, while a *different* event acquires its lock freely. When | |
| 110 | + | /// the first guard drops, the same event becomes lockable again (Run 23 Conc/Perf: | |
| 111 | + | /// blocking `pg_advisory_xact_lock` → non-blocking `pg_try_advisory_xact_lock`). | |
| 112 | + | #[tokio::test] | |
| 113 | + | async fn try_lock_event_is_non_blocking_and_per_event() { | |
| 114 | + | let db = TestDb::new().await; | |
| 115 | + | ||
| 116 | + | // First delivery of event A wins the lock and holds it. | |
| 117 | + | let held = webhook_events::try_lock_event(&db.pool, "evt_lock_A") | |
| 118 | + | .await | |
| 119 | + | .expect("first try_lock must not error") | |
| 120 | + | .expect("first delivery acquires the lock"); | |
| 121 | + | ||
| 122 | + | // A second, concurrent delivery of the SAME event must not acquire it — and | |
| 123 | + | // must return immediately (None), not block. | |
| 124 | + | assert!( | |
| 125 | + | webhook_events::try_lock_event(&db.pool, "evt_lock_A") | |
| 126 | + | .await | |
| 127 | + | .expect("contended try_lock must not error") | |
| 128 | + | .is_none(), | |
| 129 | + | "a same-event delivery must get None while the lock is held" | |
| 130 | + | ); | |
| 131 | + | ||
| 132 | + | // A DIFFERENT event hashes to a different key and is unaffected. | |
| 133 | + | assert!( | |
| 134 | + | webhook_events::try_lock_event(&db.pool, "evt_lock_B") | |
| 135 | + | .await | |
| 136 | + | .expect("distinct-event try_lock must not error") | |
| 137 | + | .is_some(), | |
| 138 | + | "a distinct event must acquire its own lock" | |
| 139 | + | ); | |
| 140 | + | ||
| 141 | + | // Releasing the first guard frees event A for the next delivery. | |
| 142 | + | drop(held); | |
| 143 | + | assert!( | |
| 144 | + | webhook_events::try_lock_event(&db.pool, "evt_lock_A") | |
| 145 | + | .await | |
| 146 | + | .expect("post-release try_lock must not error") | |
| 147 | + | .is_some(), | |
| 148 | + | "the lock must be re-acquirable once the holder drops" | |
| 149 | + | ); | |
| 150 | + | } | |
| 151 | + | ||
| 106 | 152 | #[tokio::test] | |
| 107 | 153 | async fn prune_processed_events_respects_retention_boundary() { | |
| 108 | 154 | let db = TestDb::new().await; |