- Add worker-api/ with TypeScript + Wrangler setup - D1 database schema (bots, matches, jobs, rating_history) - Glicko-2 rating system implementation with unit tests - Job coordination endpoints (claim, heartbeat, result, fail) - Bot management endpoints (register, list, update, rotate-key) - Cron handlers (matchmaker, health checker, stale job reaper) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
228 lines
6.5 KiB
TypeScript
228 lines
6.5 KiB
TypeScript
// Cron Job Handlers
|
|
|
|
import type { Env, Bot } from './types';
|
|
|
|
/**
|
|
* Matchmaker cron: Create match jobs for bots that need games
|
|
* Runs every minute
|
|
*/
|
|
export async function runMatchmaker(env: Env): Promise<{ created: number }> {
|
|
const now = new Date().toISOString();
|
|
|
|
// Get bots that are healthy and have played fewer than 10 matches today
|
|
// For simplicity, we'll just pair bots randomly for now
|
|
// A more sophisticated system would consider rating proximity
|
|
|
|
// Get active bots (healthy, played at least one match or registered recently)
|
|
const bots = await env.DB.prepare(
|
|
`SELECT id, rating, matches_played FROM bots
|
|
WHERE health_status = 'healthy'
|
|
ORDER BY RANDOM()
|
|
LIMIT 10`
|
|
).all<Bot>();
|
|
|
|
if (!bots.results || bots.results.length < 2) {
|
|
return { created: 0 };
|
|
}
|
|
|
|
// Get a random map
|
|
const map = await env.DB.prepare(
|
|
'SELECT id FROM maps ORDER BY RANDOM() LIMIT 1'
|
|
).first<{ id: string }>();
|
|
|
|
if (!map) {
|
|
return { created: 0 };
|
|
}
|
|
|
|
let created = 0;
|
|
|
|
// Create matches in pairs
|
|
for (let i = 0; i < bots.results.length - 1; i += 2) {
|
|
const bot1 = bots.results[i];
|
|
const bot2 = bots.results[i + 1];
|
|
|
|
// Check if these bots already have a pending match together
|
|
const existingMatch = await env.DB.prepare(
|
|
`SELECT m.id FROM matches m
|
|
JOIN match_participants mp1 ON m.id = mp1.match_id
|
|
JOIN match_participants mp2 ON m.id = mp2.match_id
|
|
WHERE m.status = 'pending'
|
|
AND mp1.bot_id = ? AND mp2.bot_id = ?`
|
|
)
|
|
.bind(bot1.id, bot2.id)
|
|
.first();
|
|
|
|
if (existingMatch) {
|
|
continue; // Skip this pair
|
|
}
|
|
|
|
// Create match
|
|
const matchId = crypto.randomUUID();
|
|
await env.DB.prepare(
|
|
`INSERT INTO matches (id, status, map_id, created_at)
|
|
VALUES (?, 'pending', ?, ?)`
|
|
)
|
|
.bind(matchId, map.id, now)
|
|
.run();
|
|
|
|
// Get bot ratings for participants
|
|
const bot1Data = await env.DB.prepare(
|
|
'SELECT rating, rating_deviation FROM bots WHERE id = ?'
|
|
)
|
|
.bind(bot1.id)
|
|
.first<{ rating: number; rating_deviation: number }>();
|
|
|
|
const bot2Data = await env.DB.prepare(
|
|
'SELECT rating, rating_deviation FROM bots WHERE id = ?'
|
|
)
|
|
.bind(bot2.id)
|
|
.first<{ rating: number; rating_deviation: number }>();
|
|
|
|
if (!bot1Data || !bot2Data) continue;
|
|
|
|
// Create participants (player_index 0 and 1)
|
|
await env.DB.prepare(
|
|
`INSERT INTO match_participants (id, match_id, bot_id, player_index, score, rating_before, rating_deviation_before)
|
|
VALUES (?, ?, ?, 0, 0, ?, ?)`
|
|
)
|
|
.bind(crypto.randomUUID(), matchId, bot1.id, bot1Data.rating, bot1Data.rating_deviation)
|
|
.run();
|
|
|
|
await env.DB.prepare(
|
|
`INSERT INTO match_participants (id, match_id, bot_id, player_index, score, rating_before, rating_deviation_before)
|
|
VALUES (?, ?, ?, 1, 0, ?, ?)`
|
|
)
|
|
.bind(crypto.randomUUID(), matchId, bot2.id, bot2Data.rating, bot2Data.rating_deviation)
|
|
.run();
|
|
|
|
// Create job
|
|
await env.DB.prepare(
|
|
`INSERT INTO jobs (id, match_id, status, created_at)
|
|
VALUES (?, ?, 'pending', ?)`
|
|
)
|
|
.bind(crypto.randomUUID(), matchId, now)
|
|
.run();
|
|
|
|
created++;
|
|
}
|
|
|
|
return { created };
|
|
}
|
|
|
|
/**
|
|
* Health checker cron: Ping bot endpoints to check health
|
|
* Runs every 15 minutes
|
|
*/
|
|
export async function runHealthChecker(env: Env): Promise<{ checked: number }> {
|
|
const bots = await env.DB.prepare(
|
|
`SELECT id, endpoint_url FROM bots WHERE health_status != 'unhealthy' OR last_health_check IS NULL`
|
|
).all<{ id: string; endpoint_url: string }>();
|
|
|
|
let checked = 0;
|
|
const now = new Date().toISOString();
|
|
|
|
for (const bot of bots.results || []) {
|
|
try {
|
|
// Simple health check - just try to connect
|
|
const response = await fetch(bot.endpoint_url, {
|
|
method: 'GET',
|
|
signal: AbortSignal.timeout(5000), // 5 second timeout
|
|
});
|
|
|
|
const status = response.ok ? 'healthy' : 'unhealthy';
|
|
|
|
await env.DB.prepare(
|
|
`UPDATE bots SET health_status = ?, last_health_check = ? WHERE id = ?`
|
|
)
|
|
.bind(status, now, bot.id)
|
|
.run();
|
|
|
|
checked++;
|
|
} catch {
|
|
// Connection failed
|
|
await env.DB.prepare(
|
|
`UPDATE bots SET health_status = 'unhealthy', last_health_check = ? WHERE id = ?`
|
|
)
|
|
.bind(now, bot.id)
|
|
.run();
|
|
|
|
checked++;
|
|
}
|
|
}
|
|
|
|
return { checked };
|
|
}
|
|
|
|
/**
|
|
* Stale job reaper: Reclaim jobs that have timed out
|
|
* Runs every 5 minutes
|
|
*/
|
|
export async function runStaleJobReaper(env: Env): Promise<{ reclaimed: number }> {
|
|
const now = new Date();
|
|
const staleThreshold = new Date(now.getTime() - 5 * 60 * 1000); // 5 minutes ago
|
|
const staleThresholdStr = staleThreshold.toISOString();
|
|
|
|
// Find jobs that have been claimed but haven't had a heartbeat in 5 minutes
|
|
const staleJobs = await env.DB.prepare(
|
|
`SELECT id, match_id FROM jobs
|
|
WHERE status = 'claimed'
|
|
AND heartbeat_at < ?`
|
|
)
|
|
.bind(staleThresholdStr)
|
|
.all<{ id: string; match_id: string }>();
|
|
|
|
let reclaimed = 0;
|
|
|
|
for (const job of staleJobs.results || []) {
|
|
// Reset the job to pending so another worker can claim it
|
|
await env.DB.prepare(
|
|
`UPDATE jobs SET
|
|
status = 'pending',
|
|
worker_id = NULL,
|
|
claimed_at = NULL,
|
|
heartbeat_at = NULL
|
|
WHERE id = ?`
|
|
)
|
|
.bind(job.id)
|
|
.run();
|
|
|
|
// Reset match status to pending
|
|
await env.DB.prepare(
|
|
`UPDATE matches SET status = 'pending', started_at = NULL WHERE id = ?`
|
|
)
|
|
.bind(job.match_id)
|
|
.run();
|
|
|
|
reclaimed++;
|
|
}
|
|
|
|
return { reclaimed };
|
|
}
|
|
|
|
/**
|
|
* Dispatch cron handler based on event type
|
|
*/
|
|
export async function handleCron(
|
|
env: Env,
|
|
cron: string
|
|
): Promise<{ success: boolean; result: unknown }> {
|
|
// Parse cron expression to determine which handler to run
|
|
// */1 * * * * = matchmaker (every minute)
|
|
// */5 * * * * = stale job reaper (every 5 minutes)
|
|
// */15 * * * * = health checker (every 15 minutes)
|
|
|
|
// The cron expression is passed, but we need to determine the type
|
|
// For simplicity, we'll check the pattern
|
|
if (cron === '*/1 * * * *' || cron.includes('*/1')) {
|
|
const result = await runMatchmaker(env);
|
|
return { success: true, result };
|
|
} else if (cron === '*/5 * * * *' || cron.includes('*/5')) {
|
|
const result = await runStaleJobReaper(env);
|
|
return { success: true, result };
|
|
} else if (cron === '*/15 * * * *' || cron.includes('*/15')) {
|
|
const result = await runHealthChecker(env);
|
|
return { success: true, result };
|
|
}
|
|
|
|
return { success: false, result: 'Unknown cron pattern' };
|
|
}
|