use chrono::{DateTime, Utc}; use sqlx::PgPool; use uuid::Uuid; // Row shape for `event`: every field is populated by sqlx from `SELECT *` / `RETURNING *`. Several // (`slug`, `cover_image_path`, `export_epoch`, `created_at`) are not read through this struct today // — callers that need them query the column directly — but they are part of the row and stay here so // the struct keeps mirroring the table. #[allow(dead_code)] #[derive(Debug, sqlx::FromRow)] pub struct Event { pub id: Uuid, pub slug: String, pub name: String, pub cover_image_path: Option, pub is_active: bool, pub uploads_locked_at: Option>, pub export_released_at: Option>, /// Monotonic generation counter for the keepsake. Bumped in the SAME UPDATE as any change to /// `export_released_at` (release and reopen are its only writers). An export is downloadable /// iff a `done` `export_job` row carries this exact epoch — readiness is derived from that, /// never stored, so it cannot drift and no worker can resurrect it. See migration 014. pub export_epoch: i64, pub created_at: DateTime, } impl Event { pub async fn find_by_slug(pool: &PgPool, slug: &str) -> Result, sqlx::Error> { sqlx::query_as::<_, Self>("SELECT * FROM event WHERE slug = $1") .bind(slug) .fetch_optional(pool) .await } /// Insert the event, or return the existing row if another request won the race. /// /// `ON CONFLICT`, not a bare INSERT. `slug` is UNIQUE (migration 002), and the only callers are /// `/join` and `/admin/login` — both of which run before the row exists, at the one moment the /// app is most concurrent: the QR code goes up and every phone in the room posts `/join` within /// the same second. A check-then-insert loses that race by construction, and the losers got a /// bare unique violation surfaced as a 500 on the very first screen of the event. /// /// `DO UPDATE SET slug = EXCLUDED.slug` is a deliberate no-op write: `DO NOTHING` returns no /// row on conflict, which would put the loser right back at square one. It touches only `slug`, /// so `name`, `export_epoch` and the lock/release timestamps are never disturbed by a late /// arrival. pub async fn create(pool: &PgPool, slug: &str, name: &str) -> Result { sqlx::query_as::<_, Self>( "INSERT INTO event (slug, name) VALUES ($1, $2) ON CONFLICT (slug) DO UPDATE SET slug = EXCLUDED.slug RETURNING *", ) .bind(slug) .bind(name) .fetch_one(pool) .await } /// Reads first so the common case (the row already exists, i.e. every join after the first) /// stays a plain SELECT and never takes a row lock. pub async fn find_or_create( pool: &PgPool, slug: &str, name: &str, ) -> Result { if let Some(event) = Self::find_by_slug(pool, slug).await? { return Ok(event); } Self::create(pool, slug, name).await } } #[cfg(test)] mod tests { use super::*; /// The QR code goes up and every phone posts `/join` in the same second, before the event row /// exists. `find_or_create` reads first, so all of them miss, and all of them insert. /// /// With a bare `INSERT`, exactly one wins and the rest get a unique violation on `slug` — /// surfaced as a 500 on the first screen of the event, for everyone but the winner. There is no /// retry on that path and nothing in the UI explains it. #[sqlx::test] async fn concurrent_first_joins_all_get_the_same_event(pool: PgPool) { let racers: Vec<_> = (0..16) .map(|_| { let pool = pool.clone(); tokio::spawn( async move { Event::find_or_create(&pool, "wedding", "Hochzeit").await }, ) }) .collect(); let mut ids = Vec::new(); for r in racers { let event = r .await .expect("task panicked") .expect("a concurrent first join must not fail — this is the QR-scan burst"); ids.push(event.id); } assert_eq!(ids.len(), 16); assert!( ids.iter().all(|id| *id == ids[0]), "every racer must land on ONE event row, not create rivals" ); let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM event WHERE slug = 'wedding'") .fetch_one(&pool) .await .expect("count"); assert_eq!(count, 1, "exactly one event row may exist for a slug"); } /// A late arrival must not clobber the row it collides with — the no-op `DO UPDATE` exists to /// return the loser a row, not to let it rewrite one mid-event. #[sqlx::test] async fn a_late_create_does_not_disturb_the_existing_row(pool: PgPool) { let first = Event::find_or_create(&pool, "wedding", "Hochzeit") .await .expect("first"); sqlx::query("UPDATE event SET name = $1, export_epoch = 7 WHERE id = $2") .bind("Anna und Ben") .bind(first.id) .execute(&pool) .await .expect("simulate a live event"); let late = Event::create(&pool, "wedding", "Hochzeit") .await .expect("a colliding insert must still return the row"); assert_eq!(late.id, first.id); assert_eq!(late.name, "Anna und Ben", "the name must survive"); assert_eq!(late.export_epoch, 7, "and so must the export epoch"); } }