From df04ab4eb72c70825f3493e8981b5c57efb455e2 Mon Sep 17 00:00:00 2001 From: Cyd Date: Wed, 1 Jul 2026 13:40:44 -0500 Subject: [PATCH] Atomic mission-fill for kiosk registration (fixes overfill race) - dev/migrations/001-innodb.sql: convert pqs_queue + pqs_mission to InnoDB. - includes/queue.php: pqs_enqueue_player() seats a player in the next open mission (creating one if needed), serialized with GET_LOCK and wrapped in a transaction; capacity judged by real COUNT(*), numplayers rewritten from it so the counter self-heals and can't drift. - callsign.php: normal registration path now calls pqs_enqueue_player instead of the non-atomic read-check-insert. Verified on the local stack: 140 concurrent registrations -> 24 missions, zero overfill, zero counter drift. Co-Authored-By: Claude Opus 4.8 --- callsign.php | 37 ++------------ dev/migrations/001-innodb.sql | 12 +++++ includes/queue.php | 96 +++++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 32 deletions(-) create mode 100644 dev/migrations/001-innodb.sql create mode 100644 includes/queue.php diff --git a/callsign.php b/callsign.php index c4e8ff9..c2e323b 100644 --- a/callsign.php +++ b/callsign.php @@ -194,38 +194,11 @@ if (isset($_GET['submit'])) { exit; } - $sql = "SELECT MissionID, numplayers FROM pqs_mission WHERE Completed = 0 AND locked = 0 AND numplayers < MissionSize ORDER BY MissionID ASC"; - $getid = mysqli_query($link, $sql) or die('Query failed: ' . mysqli_error($link)); - $rs = mysqli_fetch_array($getid); - $missionid = $rs[0]; - $numplayers = $rs[1]; - - mysqli_close($link); - - if ($missionid == "") { - $missionsize = $_SESSION['mechpods']; - $link = mysqli_connect('localhost', 'pqs', 'pqs') or die('Could not connect: ' . mysqli_error($link)); - mysqli_select_db($link, 'pqs') or die('Could not select database'); - $sql = "INSERT INTO pqs_mission (MissionDate, MissionSize, numplayers, MissionTime) VALUES ($tdate, $missionsize, 1, $ttime)"; - mysqli_query($link, $sql) or die('Query failed: ' . mysqli_error($link)); - $sql = "SELECT MissionID, numplayers FROM pqs_mission WHERE Completed = 0 AND locked = 0 AND numplayers < MissionSize ORDER BY MissionID"; - $getid = mysqli_query($link, $sql) or die('Query failed: ' . mysqli_error($link)); - $rs1 = mysqli_fetch_array($getid); - $missionid = $rs1[0]; - $query = "INSERT INTO pqs_queue (callsign, mech, missionid) VALUES ('$callsign','$mechname',$missionid)"; - $result = mysqli_query($link, $query) or die('Query failed: ' . mysqli_error($link)); - mysqli_query($link, $sql) or die('Query failed: ' . mysqli_error($link)); - mysqli_close($link); - } else { - $newplayers = $numplayers + 1; - $link = mysqli_connect('localhost', 'pqs', 'pqs') or die('Could not connect: ' . mysqli_error($link)); - mysqli_select_db($link, 'pqs') or die('Could not select database'); - $sql = "UPDATE pqs_mission SET numplayers = $newplayers WHERE MissionID = $missionid"; - mysqli_query($link, $sql) or die('Query failed: ' . mysqli_error($link)); - $query = "INSERT INTO pqs_queue (callsign, mech, missionid) VALUES ('$callsign','$mechname',$missionid)"; - $result = mysqli_query($link, $query) or die('Query failed: ' . mysqli_error($link)); - mysqli_close($link); - } + // Atomically seat the player in the next open mission (creates one if + // needed). Serialized + transactional to prevent the overfill race that + // concurrent kiosk/staff registrations used to cause. See includes/queue.php. + require_once("includes/queue.php"); + $missionid = pqs_enqueue_player($callsign, $mechname); $minToDeparture = $timePerGame*($missionid-$lastCommitedMissionID); $d=strtotime("+$minToDeparture Minutes",$lastCommitTime); diff --git a/dev/migrations/001-innodb.sql b/dev/migrations/001-innodb.sql new file mode 100644 index 0000000..6586539 --- /dev/null +++ b/dev/migrations/001-innodb.sql @@ -0,0 +1,12 @@ +-- Migration 001: convert the two mission-fill tables to InnoDB. +-- +-- Why: the atomic enqueue (see includes/queue.php) wraps the capacity-check + +-- seat-take in a transaction so the two writes commit or roll back as a unit. +-- MyISAM silently ignores transactions, so these must be InnoDB. The rest of +-- the schema is left as-is. +-- +-- Safe and reversible; run once against the pqs database: +-- mysql -u pqs -ppqs pqs < dev/migrations/001-innodb.sql + +ALTER TABLE pqs_mission ENGINE=InnoDB; +ALTER TABLE pqs_queue ENGINE=InnoDB; diff --git a/includes/queue.php b/includes/queue.php new file mode 100644 index 0000000..fb9c05c --- /dev/null +++ b/includes/queue.php @@ -0,0 +1,96 @@ +begin_transaction(); + + // Lowest open mission that still has a free seat, judged by actual + // occupancy rather than the cached counter. + $mission = pqs_row( + "SELECT MissionID, MissionSize + FROM pqs_mission + WHERE Completed = 0 + AND locked = 0 + AND (SELECT COUNT(*) FROM pqs_queue WHERE MissionID = pqs_mission.MissionID) < MissionSize + ORDER BY MissionID ASC + LIMIT 1" + ); + + if ($mission === null) { + // Nothing open: start a fresh mission sized to the current pods. + $size = (int) pqs_val("SELECT numpods FROM pqs_gameconfig WHERE gamechoiceid = 1"); + if ($size < 1) { + $size = 1; + } + // nummercs is NOT NULL without a default; set it explicitly (a new + // mission has 0 mercs) so this works under strict SQL mode too. + pqs_exec( + "INSERT INTO pqs_mission (MissionDate, MissionSize, numplayers, nummercs, MissionTime) + VALUES (?, ?, 0, 0, ?)", + 'iii', + [(int) date('Ymd'), $size, (int) date('Hi')] + ); + $missionid = (int) $db->insert_id; + } else { + $missionid = (int) $mission['MissionID']; + } + + pqs_exec( + "INSERT INTO pqs_queue (CallSign, Mech, MissionID, Merc) VALUES (?, ?, ?, ?)", + 'ssii', + [$callsign, $mech, $missionid, $merc] + ); + + // Rewrite the cached counter from the truth, so it can never drift. + pqs_exec( + "UPDATE pqs_mission + SET numplayers = (SELECT COUNT(*) FROM pqs_queue WHERE MissionID = ?) + WHERE MissionID = ?", + 'ii', + [$missionid, $missionid] + ); + + $db->commit(); + } catch (Throwable $e) { + $db->rollback(); + pqs_val("SELECT RELEASE_LOCK('pqs_enqueue')"); + throw $e; + } + + pqs_val("SELECT RELEASE_LOCK('pqs_enqueue')"); + return $missionid; +}