Commit 7bbe3ea6 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: tournaments progress without an external scheduler

api/cron.php still exists as the belt-and-braces tick, but an event must not
depend on someone remembering to wire one up — especially not the one running on
Saturday. The engine now also advances off ordinary traffic, the same way the
matchmaking queue already sweeps its own stale rows:

- browsing the tournament list starts any tournament whose start time has passed
- a player opening their game, or checking their pending games, forfeits an
  opponent who never turned up and closes the round if that was the last board

Both are idempotent and throttled (once every 15-20s per tournament, via APCu or
a temp file), so they cost one query every few seconds rather than one per
request. A game that has actually begun is never forfeited by this path — the
server clock settles those.

Also fixes tournamentEnvInt: `getenv($k) ?: $default` silently discarded a
configured value of "0", because "0" is falsy in PHP.

tests/run.sh now clears its ports before starting and aborts if a server fails
to bind. A stale server from an earlier run had been serving the previous
revision on those ports, so the suite was quietly testing old code — the run
looked like a product failure and was not one.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 33fd4d40
......@@ -37,7 +37,7 @@ $db = supabaseService();
$report = ['started' => [], 'advanced' => [], 'forfeited' => [], 'flagged' => [], 'errors' => []];
// How long a player has to appear for their game before losing it by no-show.
$NO_SHOW_SECONDS = (int)(getenv('TOURNAMENT_NO_SHOW_SECONDS') ?: 600);
$NO_SHOW_SECONDS = tournamentNoShowSeconds();
// ---------------------------------------------------------------------------
// 1. Start tournaments that are due
......
......@@ -52,6 +52,12 @@ function handleCreateOrJoin($db, string $userId, array $input): void {
$tournament = tournamentLoad($db, $tournamentId);
if (!$tournament) jsonError('Tournament not found', 404);
// A player arriving is a good moment to resolve no-shows and advance the
// round, so the person who did turn up is not left waiting on one who did not.
tournamentTick($db, $tournament);
$tournament = tournamentLoad($db, $tournamentId) ?: $tournament;
if ($tournament['status'] !== TOURNAMENT_STATUS_IN_PROGRESS) {
jsonError('Tournament is ' . $tournament['status'], 409);
}
......@@ -195,7 +201,10 @@ function handleMyPending($db, string $userId): void {
$pending = [];
foreach ($regs as $reg) {
$tournament = tournamentLoad($db, $reg['tournament_id']);
if (!$tournament || $tournament['status'] !== TOURNAMENT_STATUS_IN_PROGRESS) continue;
if (!$tournament) continue;
tournamentTick($db, $tournament);
$tournament = tournamentLoad($db, $reg['tournament_id']) ?: $tournament;
if ($tournament['status'] !== TOURNAMENT_STATUS_IN_PROGRESS) continue;
$found = tournamentPendingPairingFor($db, $tournament, $userId);
if (!$found) continue;
......
......@@ -17,6 +17,12 @@ if ($method === 'GET') {
$db = supabaseService();
if ($action === 'list') {
// Start anything that is due. Doing this off ordinary traffic means a
// tournament begins on time even if no external scheduler is running —
// it is throttled, idempotent, and the same pattern the matchmaking
// queue already uses for its stale sweep.
tournamentStartDueTournaments($db);
$tournaments = $db->get('el3ab_tournaments', [
'select' => 'id,name,game_key,format,time_control,status,max_players,swiss_rounds,rounds_total,starts_at,prize_pool_coins,entry_fee_coins,updated_at',
'status' => 'neq.cancelled',
......
......@@ -816,6 +816,120 @@ function tournamentForfeitPairing($sdb, string $tournamentId, int $roundNumber,
return true;
}
// ---------------------------------------------------------------------------
// Self-healing ticks
//
// api/cron.php is the belt-and-braces scheduler, but a tournament must not
// depend on someone remembering to wire one up. These run opportunistically off
// ordinary traffic — the same pattern the matchmaking queue already uses for its
// stale sweep — so the event progresses as long as anybody is using the app.
// Both are idempotent and cheap, and both are throttled so they cost one query
// every few seconds rather than one per request.
// ---------------------------------------------------------------------------
/**
* Read an integer from the environment.
*
* Note the explicit false/'' check: `getenv($k) ?: $default` silently discards a
* configured value of "0", because "0" is falsy in PHP.
*/
function tournamentEnvInt(string $name, int $default): int {
$v = getenv($name);
if ($v === false || $v === '') return $default;
return (int)$v;
}
/** How long a player has to appear for their game before losing it by no-show. */
function tournamentNoShowSeconds(): int {
return max(60, tournamentEnvInt('TOURNAMENT_NO_SHOW_SECONDS', 600));
}
/**
* Start any tournament whose start time has passed and which has enough players.
* Throttled to at most once every $throttleSeconds across the whole process pool.
*/
function tournamentStartDueTournaments($sdb, ?int $throttleSeconds = null): array {
$throttleSeconds = $throttleSeconds ?? tournamentEnvInt('TOURNAMENT_TICK_SECONDS', 20);
if (!tournamentThrottle('start_due', $throttleSeconds)) return [];
$due = $sdb->get('el3ab_tournaments', [
'status' => 'in.(' . TOURNAMENT_STATUS_REGISTRATION . ',' . TOURNAMENT_STATUS_DRAFT . ')',
'auto_start' => 'is.true',
'select' => 'id,name,starts_at',
'limit' => 25,
]);
if (!is_array($due) || isset($due['error'])) return [];
$started = [];
foreach ($due as $t) {
if (empty($t['starts_at']) || strtotime($t['starts_at']) > time()) continue;
$res = tournamentStart($sdb, $t['id']);
if ($res['ok']) $started[] = $t['id'];
}
return $started;
}
/**
* Move one tournament forward: forfeit anyone who never turned up for their
* game, then advance the round if that was the last one outstanding.
*
* Called when a player interacts with the tournament, so the player who *did*
* show up is the one whose activity resolves their absent opponent.
*/
function tournamentTick($sdb, array $tournament, ?int $throttleSeconds = null): void {
if ($tournament['status'] !== TOURNAMENT_STATUS_IN_PROGRESS) return;
$throttleSeconds = $throttleSeconds ?? tournamentEnvInt('TOURNAMENT_TICK_SECONDS', 15);
if (!tournamentThrottle('tick:' . $tournament['id'], $throttleSeconds)) return;
$roundNumber = (int)($tournament['current_round'] ?? 0);
if ($roundNumber < 1) return;
$round = tournamentRound($sdb, $tournament['id'], $roundNumber);
if (!$round || $round['status'] !== ROUND_STATUS_IN_PROGRESS) return;
$roundStarted = strtotime($round['started_at'] ?? 'now');
$grace = tournamentNoShowSeconds();
if (time() - $roundStarted > $grace) {
foreach ($round['pairings'] as $p) {
if (!empty($p['is_bye']) || ($p['result'] ?? null) !== null) continue;
$matchId = tournamentMatchIdFor($tournament['id'], $roundNumber, (int)($p['board'] ?? 1));
$rows = $sdb->get('matches', ['id' => 'eq.' . $matchId, 'select' => 'id,status,move_count', 'limit' => 1]);
$match = (is_array($rows) && !empty($rows) && !isset($rows['error'])) ? $rows[0] : null;
// A game with moves in it is being played — leave it alone; the
// server clock will settle it if someone walks away mid-game.
if ($match && (int)($match['move_count'] ?? 0) > 0) continue;
if ($match && $match['status'] === 'completed') continue;
// Nobody opened the board, or it was opened and never played.
tournamentForfeitPairing($sdb, $tournament['id'], $roundNumber, $p['pairing_id'] ?? '', $p['white_id']);
}
}
tournamentMaybeAdvance($sdb, $tournament['id']);
}
/**
* Returns true at most once per $seconds for a given key, across processes.
* Uses APCu when available, otherwise a temp file — no extra services needed.
*/
function tournamentThrottle(string $key, int $seconds): bool {
if ($seconds <= 0) return true; // throttling disabled
$name = 'el3ab_tt_' . hash('sha256', $key);
if (function_exists('apcu_add')) {
return apcu_add($name, time(), $seconds);
}
$file = sys_get_temp_dir() . '/' . $name;
$last = is_file($file) ? (int)@file_get_contents($file) : 0;
if (time() - $last < $seconds) return false;
@file_put_contents($file, (string)time(), LOCK_EX);
return true;
}
/**
* The pairing a player owes a game in, for the current round.
* Returns null when they have already finished, have a bye, or are not playing.
......
......@@ -11,6 +11,10 @@ putenv('SUPABASE_ANON_KEY=test-anon');
putenv('SUPABASE_SERVICE_KEY=test-service');
putenv('CRON_SECRET=testsecret');
putenv('TOURNAMENT_NO_SHOW_SECONDS=2');
// The self-healing ticks are throttled to once every 15s in production.
// Disable the throttle here so the suite is deterministic rather than
// dependent on sleeping past an interval.
putenv('TOURNAMENT_TICK_SECONDS=0');
require_once __DIR__ . '/pgshim.php';
......
......@@ -20,8 +20,8 @@ PASS=0
FAIL=0
cleanup() {
[ -n "${API_PID:-}" ] && kill "$API_PID" 2>/dev/null
[ -n "${AUTH_PID:-}" ] && kill "$AUTH_PID" 2>/dev/null
[ -n "${API_PID:-}" ] && kill "$API_PID" 2>/dev/null && wait "$API_PID" 2>/dev/null
[ -n "${AUTH_PID:-}" ] && kill "$AUTH_PID" 2>/dev/null && wait "$AUTH_PID" 2>/dev/null
pg_ctl -D "$PGDATA" stop -m immediate >/dev/null 2>&1
rm -rf "$PGDATA"
}
......@@ -37,6 +37,24 @@ run() {
fi
}
# A server left over from an earlier run would keep serving the OLD code on
# these ports and the suite would silently test the previous revision. Clear
# them first and refuse to continue if they do not free up.
step "Clearing ports $APIPORT and $AUTHPORT"
pkill -f "php .*-S 127.0.0.1:$APIPORT" 2>/dev/null
pkill -f "php .*-S 127.0.0.1:$AUTHPORT" 2>/dev/null
pg_ctl -D "$PGDATA" stop -m immediate >/dev/null 2>&1
for _ in $(seq 1 20); do
lsof -nP -iTCP:$APIPORT -iTCP:$AUTHPORT -sTCP:LISTEN >/dev/null 2>&1 || break
sleep 0.5
done
if lsof -nP -iTCP:$APIPORT -iTCP:$AUTHPORT -sTCP:LISTEN >/dev/null 2>&1; then
echo " ports still in use — refusing to run against a stale server"
lsof -nP -iTCP:$APIPORT -iTCP:$AUTHPORT -sTCP:LISTEN
exit 1
fi
echo " clear"
step "Starting a disposable Postgres on port $PGPORT"
rm -rf "$PGDATA"
initdb -D "$PGDATA" -U postgres --auth=trust >/dev/null 2>&1
......@@ -54,6 +72,11 @@ AUTH_PID=$!
php -d display_errors=Off -S 127.0.0.1:$APIPORT router.php > .server.log 2>&1 &
API_PID=$!
sleep 2
if grep -q "Address already in use" .server.log .auth.log 2>/dev/null; then
echo " a server failed to bind — aborting rather than testing stale code"
cat .server.log .auth.log
exit 1
fi
echo " api on :$APIPORT, auth stub on :$AUTHPORT"
step "Tests"
......
......@@ -367,6 +367,46 @@ await call('game.php', { action: 'heartbeat', match_id: hm.match_id }, P[3]);
const hs2 = JSON.parse(sql(`SELECT game_state FROM matches WHERE id='${hm.match_id}'`));
check('a heartbeat does not clobber a standing draw offer', hs2.draw_offer === P[2], JSON.stringify(hs2));
console.log('\n=== 16. tournaments progress without any external scheduler ===');
// Everything below runs purely off ordinary player/API traffic — no cron.php.
r = await call('tournament-admin.php', {
action: 'create', name: 'No Cron Cup', format: 'swiss', time_control: 'blitz_3_0',
rounds: 2, min_players: 4, max_players: 8, auto_start: true,
}, P[0]);
const ncid = r.data.tournament.id;
for (const p of P) await call('tournaments.php', { action: 'register', tournament_id: ncid }, p);
sql(`UPDATE el3ab_tournaments SET starts_at = now() - interval '1 minute' WHERE id='${ncid}'`);
// Merely listing tournaments (what the lobby does) must start a due one.
await get('tournaments.php', { action: 'list' }, P[0]);
let ncT = JSON.parse(sql(`SELECT row_to_json(t) FROM (SELECT status, current_round FROM el3ab_tournaments WHERE id='${ncid}') t`));
check('browsing the tournament list started the due tournament',
ncT.status === 'in_progress' && ncT.current_round === 1, JSON.stringify(ncT));
// One board plays; the other pair never shows. The round must still close.
const ncPairs = (await get('swiss.php', { action: 'pairings', tournament_id: ncid }, P[0])).data.pairings;
const played = ncPairs[0];
const j = await call('tournament-match.php', { action: 'create-or-join', tournament_id: ncid }, played.white_id);
await call('game.php', { action: 'resign', match_id: j.data.match_id }, played.black_id);
ncT = JSON.parse(sql(`SELECT row_to_json(t) FROM (SELECT status, current_round FROM el3ab_tournaments WHERE id='${ncid}') t`));
check('the round does not advance while a game is genuinely outstanding',
ncT.current_round === 1, JSON.stringify(ncT));
// Age the round past the no-show grace period, then have a player check in.
sql(`UPDATE el3ab_tournament_rounds SET started_at = now() - interval '2 hours' WHERE tournament_id='${ncid}' AND round_number=1`);
await get('tournament-match.php', { action: 'my-pending' }, played.white_id);
ncT = JSON.parse(sql(`SELECT row_to_json(t) FROM (SELECT status, current_round FROM el3ab_tournaments WHERE id='${ncid}') t`));
check('a player checking in forfeited the no-show and advanced the round',
ncT.current_round === 2 || ncT.status === 'completed', JSON.stringify(ncT));
const ncRound1 = JSON.parse(sql(`SELECT pairings FROM el3ab_tournament_rounds WHERE tournament_id='${ncid}' AND round_number=1`));
check('every round-1 board has a result', ncRound1.every(p => p.is_bye || p.result !== null),
JSON.stringify(ncRound1.map(p => p.result)));
check('the forfeited board is marked as such',
ncRound1.some(p => p.reason === 'forfeit'), JSON.stringify(ncRound1.map(p => p.reason)));
// ---------------------------------------------------------------------------
console.log('');
if (failures.length) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment