From c82978776d3a500aabb7564aa1843ed5d09a3236 Mon Sep 17 00:00:00 2001 From: Cyd Date: Wed, 1 Jul 2026 13:49:10 -0500 Subject: [PATCH] Route all remaining fill paths through the shared atomic queue Completes the overfill-race fix (item #1). New queue.php functions, all under the same GET_LOCK + transaction and self-healing numplayers: - pqs_add_player_to_mission (staff 'new' -> registration.php) - pqs_add_player_to_missions (staff 'add to N missions' -> registration.php) - pqs_enqueue_group (kiosk group leader -> callsign.php) - pqs_claim_group_slot (kiosk group member -> callsign.php) callsign.php and registration.php now call these instead of their own non-atomic read-check-insert blocks. Verified on the local stack: scenario test (leader/member/staff paths) and 120 mixed concurrent solo+group writers x3 -> zero overfill, zero drift; HTTP smoke of leader/member/new paths all seat correctly. Co-Authored-By: Claude Opus 4.8 --- callsign.php | 68 ++-------- includes/queue.php | 327 ++++++++++++++++++++++++++++++++++----------- registration.php | 73 +++------- 3 files changed, 278 insertions(+), 190 deletions(-) diff --git a/callsign.php b/callsign.php index c2e323b..a8abc06 100644 --- a/callsign.php +++ b/callsign.php @@ -111,16 +111,17 @@ if (isset($_GET['submit'])) { $lastCommitTime = $rs2[1]; if($isMember){ - $replaceWho = $_GET['who']; - $sql = "SELECT ID, MissionID FROM pqs_queue WHERE `CallSign` like '$replaceWho-%' LIMIT 1"; - $result = mysqli_query($link, $sql) or die('Query failed: ' . mysqli_error($link)); - $line = mysqli_fetch_array($result); - $replaceID = $line[0]; - $missionid = $line[1]; - - $sql = "UPDATE `pqs_queue` SET `CallSign`='$callsign',`Mech`='$mechname' WHERE `ID` = '$replaceID'"; - $result = mysqli_query($link, $sql) or die('Query failed: ' . mysqli_error($link)); - mysqli_close($link); + // Atomically claim an unclaimed slot reserved by the group leader. + require_once("includes/queue.php"); + $missionid = pqs_claim_group_slot($_GET['who'], $callsign, $mechname); + if ($missionid === null) { + echo ""; + echo "


"; + echo ""; + echo "

Sorry $_GET[callsign], there are no open slots left in that group.

Please register on your own or ask the group leader to reserve more slots.

"; + echo ""; + exit; + } $minToDeparture = $timePerGame*($missionid-$lastCommitedMissionID); $d=strtotime("+$minToDeparture Minutes",$lastCommitTime); @@ -136,50 +137,9 @@ if (isset($_GET['submit'])) { if($isLeader){ - $sql = "SELECT MissionID, numplayers FROM pqs_mission WHERE Completed = 0 AND locked = 0 AND ($hm + 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, $hm, $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 DESC"; - $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)); - $i = 2; - while ($i <= $hm){ - $query = "INSERT INTO pqs_queue (callsign, mech, missionid) VALUES ('$callsign-$i','$mechname',$missionid)"; - $result = mysqli_query($link, $query) or die('Query failed: ' . mysqli_error($link)); - $i ++; - } - mysqli_query($link, $sql) or die('Query failed: ' . mysqli_error($link)); - mysqli_close($link); - } else { - $newplayers = $numplayers + $hm; - $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)); - $i = 2; - while ($i <= $hm){ - $query = "INSERT INTO pqs_queue (callsign, mech, missionid) VALUES ('$callsign-$i','$mechname',$missionid)"; - $result = mysqli_query($link, $query) or die('Query failed: ' . mysqli_error($link)); - $i ++; - } - - mysqli_close($link); - } + // Atomically reserve $hm seats (leader + placeholders) in one mission. + require_once("includes/queue.php"); + $missionid = pqs_enqueue_group($callsign, $mechname, $hm); $minToDeparture = $timePerGame*($missionid-$lastCommitedMissionID); $d=strtotime("+$minToDeparture Minutes",$lastCommitTime); diff --git a/includes/queue.php b/includes/queue.php index fb9c05c..a9200c3 100644 --- a/includes/queue.php +++ b/includes/queue.php @@ -2,95 +2,266 @@ /** * Queue domain logic for PQS. * - * The core operation is "seat a player in the next open mission" — the thing - * that races when the kiosk (callsign.php) and the staff console - * (registration.php) add players at the same time. See docs/ARCHITECTURE.md §7. + * Every path that adds players to a mission goes through here so they all share + * one advisory lock and can't race each other into overfilling a mission or + * corrupting the numplayers counter. See docs/ARCHITECTURE.md §7. + * + * The kiosk (callsign.php) and the staff console (registration.php) are the + * concurrent writers; before this, each did its own read-check-insert. */ require_once __DIR__ . '/db.php'; /** - * Atomically seat a player in the lowest-numbered open mission, creating a new - * mission (sized to the current pod count) if none has room. Returns the - * MissionID the player landed in. - * - * Concurrency safety comes from two layers: - * 1. GET_LOCK serializes the whole find-or-create-insert critical section, so - * no two registrations can interleave their capacity check and insert — - * this is what prevents overfilling a mission or racing to create - * duplicate missions. - * 2. An InnoDB transaction makes the mission update + queue insert commit (or - * roll back) as a unit, so a mid-way failure can't leave numplayers and - * the queue out of sync. - * - * Capacity is checked against the real COUNT(*) of queued rows, and numplayers - * is rewritten to that count after inserting, so the denormalized counter is - * self-healing and can't drift the mission into over/under-fill. + * Run $fn while holding the global enqueue lock, guaranteeing release even if + * $fn throws. Serializes all seat-taking across every request/connection. */ -function pqs_enqueue_player(string $callsign, string $mech, int $merc = 0): int +function pqs_with_queue_lock(callable $fn) { - $db = pqs_db(); - if ((int) pqs_val("SELECT GET_LOCK('pqs_enqueue', 10)") !== 1) { throw new RuntimeException('Could not acquire enqueue lock (timeout)'); } - try { - $db->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(); + return $fn(); + } finally { pqs_val("SELECT RELEASE_LOCK('pqs_enqueue')"); - throw $e; } - - pqs_val("SELECT RELEASE_LOCK('pqs_enqueue')"); - return $missionid; +} + +/** Recompute a mission's numplayers from actual queue occupancy (self-healing). */ +function pqs_sync_numplayers(int $missionid): void +{ + pqs_exec( + "UPDATE pqs_mission + SET numplayers = (SELECT COUNT(*) FROM pqs_queue WHERE MissionID = ?) + WHERE MissionID = ?", + 'ii', + [$missionid, $missionid] + ); +} + +/** Create a new mission sized to the current pod count; return its MissionID. */ +function pqs_create_mission(): int +{ + $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 (0 for a new + // mission) 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')] + ); + return (int) pqs_db()->insert_id; +} + +/** + * Atomically seat one player in the lowest-numbered open mission that has room + * (creating one if none does). Returns the MissionID. Used by the kiosk's + * normal registration. + */ +function pqs_enqueue_player(string $callsign, string $mech, int $merc = 0): int +{ + return pqs_with_queue_lock(function () use ($callsign, $mech, $merc) { + $db = pqs_db(); + $db->begin_transaction(); + try { + $mission = pqs_row( + "SELECT MissionID + 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" + ); + $missionid = $mission === null ? pqs_create_mission() : (int) $mission['MissionID']; + + pqs_exec( + "INSERT INTO pqs_queue (CallSign, Mech, MissionID, Merc) VALUES (?, ?, ?, ?)", + 'ssii', + [$callsign, $mech, $missionid, $merc] + ); + pqs_sync_numplayers($missionid); + + $db->commit(); + return $missionid; + } catch (Throwable $e) { + $db->rollback(); + throw $e; + } + }); +} + +/** + * Atomically add a player to a SPECIFIC mission (staff "new" action). Does not + * pick a mission or enforce capacity — staff may intentionally overfill — but + * shares the lock so it can't race with kiosk registrations, and keeps + * numplayers accurate. + */ +function pqs_add_player_to_mission(int $missionid, string $callsign, string $mech, int $merc = 0): void +{ + pqs_with_queue_lock(function () use ($missionid, $callsign, $mech, $merc) { + $db = pqs_db(); + $db->begin_transaction(); + try { + pqs_exec( + "INSERT INTO pqs_queue (CallSign, Mech, MissionID, Merc) VALUES (?, ?, ?, ?)", + 'ssii', + [$callsign, $mech, $missionid, $merc] + ); + pqs_sync_numplayers($missionid); + $db->commit(); + } catch (Throwable $e) { + $db->rollback(); + throw $e; + } + }); +} + +/** + * Staff "add to multiple missions": place $callsign in the next $count missions + * after $startmission, filling existing missions that have room and creating + * new ones at the tail as needed. Runs as one atomic unit under the shared lock. + */ +function pqs_add_player_to_missions(int $startmission, int $count, string $callsign, string $mech, int $merc = 0): void +{ + if ($count < 1) { + return; + } + pqs_with_queue_lock(function () use ($startmission, $count, $callsign, $mech, $merc) { + $db = pqs_db(); + $db->begin_transaction(); + try { + $placed = 0; + $mid = $startmission + 1; + while ($placed < $count) { + $maxmission = (int) pqs_val("SELECT MAX(MissionID) FROM pqs_mission"); + if ($mid <= $maxmission) { + $row = pqs_row( + "SELECT MissionSize, + (SELECT COUNT(*) FROM pqs_queue WHERE MissionID = ?) AS filled + FROM pqs_mission WHERE MissionID = ?", + 'ii', + [$mid, $mid] + ); + if ($row !== null && (int) $row['filled'] < (int) $row['MissionSize']) { + pqs_exec( + "INSERT INTO pqs_queue (CallSign, Mech, MissionID, Merc) VALUES (?, ?, ?, ?)", + 'ssii', + [$callsign, $mech, $mid, $merc] + ); + pqs_sync_numplayers($mid); + $placed++; + } + // full or missing: fall through and try the next mission + } else { + $mid = pqs_create_mission(); + pqs_exec( + "INSERT INTO pqs_queue (CallSign, Mech, MissionID, Merc) VALUES (?, ?, ?, ?)", + 'ssii', + [$callsign, $mech, $mid, $merc] + ); + pqs_sync_numplayers($mid); + $placed++; + } + $mid++; + } + $db->commit(); + } catch (Throwable $e) { + $db->rollback(); + throw $e; + } + }); +} + +/** + * Atomically reserve a group of $count seats in one mission for a leader: + * inserts the leader ($callsign) plus placeholder rows "$callsign-2" .. + * "$callsign-$count" that members later claim. Picks the lowest open mission + * that can fit the whole group, else creates one. Returns the MissionID. + */ +function pqs_enqueue_group(string $callsign, string $mech, int $count): int +{ + if ($count < 1) { + $count = 1; + } + return pqs_with_queue_lock(function () use ($callsign, $mech, $count) { + $db = pqs_db(); + $db->begin_transaction(); + try { + $mission = pqs_row( + "SELECT MissionID + 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", + 'i', + [$count] + ); + $missionid = $mission === null ? pqs_create_mission() : (int) $mission['MissionID']; + + pqs_exec( + "INSERT INTO pqs_queue (CallSign, Mech, MissionID) VALUES (?, ?, ?)", + 'ssi', + [$callsign, $mech, $missionid] + ); + for ($i = 2; $i <= $count; $i++) { + pqs_exec( + "INSERT INTO pqs_queue (CallSign, Mech, MissionID) VALUES (?, ?, ?)", + 'ssi', + ["$callsign-$i", $mech, $missionid] + ); + } + pqs_sync_numplayers($missionid); + + $db->commit(); + return $missionid; + } catch (Throwable $e) { + $db->rollback(); + throw $e; + } + }); +} + +/** + * Atomically claim one unclaimed placeholder slot reserved by leader $who, + * replacing it with this member's $callsign/$mech. Returns the MissionID the + * member landed in, or null if no free slot remained. Serialized so two members + * can't grab the same slot. + */ +function pqs_claim_group_slot(string $who, string $callsign, string $mech): ?int +{ + return pqs_with_queue_lock(function () use ($who, $callsign, $mech) { + $db = pqs_db(); + $db->begin_transaction(); + try { + // Unclaimed placeholders still look like "who-"; a claimed one has + // been renamed to the member's real callsign. + $slot = pqs_row( + "SELECT ID, MissionID FROM pqs_queue WHERE CallSign LIKE ? ORDER BY ID ASC LIMIT 1", + 's', + ["$who-%"] + ); + if ($slot === null) { + $db->commit(); + return null; + } + pqs_exec( + "UPDATE pqs_queue SET CallSign = ?, Mech = ? WHERE ID = ?", + 'ssi', + [$callsign, $mech, (int) $slot['ID']] + ); + $db->commit(); + return (int) $slot['MissionID']; + } catch (Throwable $e) { + $db->rollback(); + throw $e; + } + }); } diff --git a/registration.php b/registration.php index ddb0d8b..e6237fe 100644 --- a/registration.php +++ b/registration.php @@ -86,67 +86,24 @@ if (isset($_POST['edit'])) { } if (isset($_POST['add'])) { - if (isset($_POST['playerid'])) $playerid = $_POST['playerid']; - if (isset($_POST['callsign'])) $callsign = str_replace("'", "'", $_POST['callsign']); - if (isset($_POST['missionid'])) $missionid = $_POST['missionid']; - if (isset($_POST['merc'])) $merc = $_POST['merc']; - if (isset($_POST['mech'])) $mech = $_POST['mech']; - if (isset($_POST['nummissions'])) $nummissions = $_POST['nummissions']; - - $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 = "SELECT MAX(MissionID) FROM pqs_mission"; - $result = mysqli_query($link, $sql) or die('Update player in Queue failed: ' . mysqli_error($link)); - $rs = mysqli_fetch_array($result); - $maxmission = $rs[0]; - $newmissionid = $missionid + 1; - - for ($i = 1; $i <= $nummissions;) { - if ($newmissionid <= $maxmission) { - $sql = "SELECT numplayers FROM pqs_mission WHERE MissionID = $newmissionid"; - $result = mysqli_query($link, $sql) or die('Update player in Queue failed: ' . mysqli_error($link)); - $rs = mysqli_fetch_array($result); - if ($rs[0] < $missionsize) { - $sql = "UPDATE pqs_mission SET numplayers = numplayers + 1 WHERE MissionID = $newmissionid"; - $result = mysqli_query($link, $sql) or die('Update player in Queue failed: ' . mysqli_error($link)); - $sql = "INSERT INTO pqs_queue (CallSign, Mech, MissionID, Merc) VALUES ('$callsign', '$mech', $newmissionid, $merc)"; - $result = mysqli_query($link, $sql) or die('Update player in Queue failed: ' . mysqli_error($link)); - $i++; - } - } else { - $sql = "INSERT INTO pqs_mission (MissionDate, MissionSize, numplayers, nummercs, MissionTime) VALUES ($tdate, $missionsize, 1, $merc, $ttime)"; - mysqli_query($link, $sql) or die('Query failed: ' . mysqli_error($link)); - $sql = "INSERT INTO pqs_queue (CallSign, Mech, MissionID, Merc) VALUES ('$callsign', '$mech', $newmissionid, $merc)"; - $result = mysqli_query($link, $sql) or die('Update player in Queue failed: ' . mysqli_error($link)); - $i++; - } - $newmissionid++; - } - mysqli_close($link); + // Add one player to the next N missions after $missionid (atomic). + require_once("includes/queue.php"); + $callsign = isset($_POST['callsign']) ? $_POST['callsign'] : ''; + $mech = isset($_POST['mech']) ? $_POST['mech'] : ''; + $merc = isset($_POST['merc']) ? (int)$_POST['merc'] : 0; + $startmission = isset($_POST['missionid']) ? (int)$_POST['missionid'] : 0; + $nummissions = isset($_POST['nummissions']) ? (int)$_POST['nummissions'] : 0; + pqs_add_player_to_missions($startmission, $nummissions, $callsign, $mech, $merc); } if (isset($_POST['new'])) { - if (isset($_POST['callsign'])) $callsign = str_replace("'", "''", $_POST['callsign']); - if(is_null($callsign)){ $callsign = 'empty'; } - if (isset($_POST['mech'])) $mech = $_POST['mech']; - if (isset($_POST['missionid'])) $missionid = $_POST['missionid']; - - $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 = "SELECT numplayers FROM pqs_mission WHERE MissionID = '$missionid'"; - $getid = mysqli_query($link, $sql) or die('Query failed: ' . mysqli_error($link)); - $rs = mysqli_fetch_array($getid); - $numplayers = $rs[0]; - $newplayers = $numplayers+1; - $sql = "UPDATE pqs_mission SET numplayers='$newplayers' WHERE MissionID=$missionid"; - mysqli_query($link, $sql) or die('Update player in Queue failed: ' . mysqli_error($link)); - - $sql = "INSERT INTO pqs_queue (callsign, mech, missionid) VALUES ('$callsign','$mech',$missionid)"; - mysqli_query($link, $sql) or die('Update player in Queue failed: ' . mysqli_error($link)); - - mysqli_close($link); + // Add one player to a specific mission (atomic). + require_once("includes/queue.php"); + $callsign = isset($_POST['callsign']) ? $_POST['callsign'] : ''; + if ($callsign === '') { $callsign = 'empty'; } + $mech = isset($_POST['mech']) ? $_POST['mech'] : ''; + $missionid = isset($_POST['missionid']) ? (int)$_POST['missionid'] : 0; + pqs_add_player_to_mission($missionid, $callsign, $mech); } if (isset($_POST['editgame'])) {