- 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>
97 lines
3.5 KiB
PHP
97 lines
3.5 KiB
PHP
<?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;
|
|
}
|