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 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-01 13:40:44 -05:00
co-authored by Claude Opus 4.8
parent 061291c951
commit df04ab4eb7
3 changed files with 113 additions and 32 deletions
+5 -32
View File
@@ -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);
+12
View File
@@ -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;
+96
View File
@@ -0,0 +1,96 @@
<?php
/**
* 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.
*/
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.
*/
function pqs_enqueue_player(string $callsign, string $mech, int $merc = 0): int
{
$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();
pqs_val("SELECT RELEASE_LOCK('pqs_enqueue')");
throw $e;
}
pqs_val("SELECT RELEASE_LOCK('pqs_enqueue')");
return $missionid;
}