//! Idempotency key storage for safe POST retries. //! //! These functions are the storage layer for [`crate::metrics::idempotency_middleware`], //! the global POST/PUT replay guard mounted on the router: it reads //! [`get_cached_response`] before running a handler and writes [`store_response`] //! (fire-and-forget, via the background pool) on a successful 2xx/3xx response, //! scoped to `(key, user_id, method, path)`. The scheduler calls //! [`cleanup_expired`] on its daily tick. This is distinct from, and additive //! to, the structural checkout dedup (the `(buyer_id, item_id) WHERE //! status='pending'` partial unique index): the index makes a double checkout //! unrepresentable; this cache replays the *response* of any retried POST/PUT. //! Exercised end-to-end by `tests/workflows/idempotency.rs`. use crate::db::UserId; use crate::error::Result; use sqlx::PgPool; /// A cached idempotency response. #[derive(sqlx::FromRow)] pub struct CachedResponse { pub status_code: i16, pub response_body: String, } /// Look up a cached response for an idempotency key. /// Scoped to (key, user_id, method, path) to prevent cross-endpoint collisions. #[tracing::instrument(skip_all)] pub async fn get_cached_response( pool: &PgPool, key: &str, user_id: UserId, method: &str, path: &str, ) -> Result> { let row = sqlx::query_as!( CachedResponse, "SELECT status_code, response_body FROM idempotency_keys WHERE key = $1 AND user_id = $2 AND method = $3 AND path = $4", key, user_id as UserId, method, path, ) .fetch_optional(pool) .await?; Ok(row) } /// Store a response for an idempotency key. Uses ON CONFLICT to handle /// race conditions (first writer wins). #[tracing::instrument(skip_all)] pub async fn store_response( pool: &PgPool, key: &str, user_id: UserId, method: &str, path: &str, status_code: u16, response_body: &str, ) -> Result<()> { sqlx::query!( r#"INSERT INTO idempotency_keys (key, user_id, method, path, status_code, response_body) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (key, user_id, method, path) DO NOTHING"#, key, user_id as UserId, method, path, status_code as i16, response_body, ) .execute(pool) .await?; Ok(()) } /// Delete expired idempotency keys (older than 24 hours). #[tracing::instrument(skip_all)] pub async fn cleanup_expired(pool: &PgPool) -> Result { let result = sqlx::query!( "DELETE FROM idempotency_keys WHERE created_at < NOW() - INTERVAL '24 hours'", ) .execute(pool) .await?; Ok(result.rows_affected()) }