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 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-01 13:49:10 -05:00
co-authored by Claude Opus 4.8
parent b6e6feca32
commit c82978776d
3 changed files with 278 additions and 190 deletions
+249 -78
View File
@@ -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-<n>"; 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;
}
});
}