project-nomad/admin/app/controllers/benchmark_controller.ts
Matt Moyles 2aa55a43ec
fix(benchmark): report run status from the queue so the double-run guard works
Benchmarks execute as a BullMQ job in the queue worker, but
/api/benchmark/status and the 409 double-run guards read the web
process's in-memory BenchmarkService, which never sees the run. Status
always read idle, and a second POST /api/benchmark/run was accepted
mid-flight and queued behind the first.

Status now comes from the queue, the one place both processes can see.
The worker publishes each stage on its job via job.updateProgress(),
and resolveBenchmarkStatus() reports the active job's stage, "starting"
for a job still queued, or the local status for a sync=true run. The
status endpoint and all three run guards read it. If the queue can't be
read they fall back to the local status, as before.

Closes #1326
2026-10-09 13:54:37 -04:00

316 lines
9.4 KiB
TypeScript

import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import { BenchmarkService } from '#services/benchmark_service'
import { runBenchmarkValidator, submitBenchmarkValidator } from '#validators/benchmark'
import { RunBenchmarkJob } from '#jobs/run_benchmark_job'
import { resolveBenchmarkStatus } from '../utils/benchmark_status.js'
import type { BenchmarkType } from '../../types/benchmark.js'
import { randomUUID } from 'node:crypto'
import logger from '@adonisjs/core/services/logger'
@inject()
export default class BenchmarkController {
constructor(private benchmarkService: BenchmarkService) {}
/**
* Start a benchmark run (async via job queue, or sync if specified)
*/
async run({ request, response }: HttpContext) {
const payload = await request.validateUsing(runBenchmarkValidator)
const benchmarkType: BenchmarkType = payload.benchmark_type || 'full'
const runSync = request.input('sync') === 'true' || request.input('sync') === true
// Check if a benchmark is already running. A sync run is the no-Redis path, and
// an unreachable Redis makes a queue read wait rather than fail, so it doesn't ask.
const status = runSync ? this.benchmarkService.getStatus() : await this.currentStatus()
if (status.status !== 'idle') {
return response.status(409).send({
success: false,
error: 'A benchmark is already running',
current_benchmark_id: status.benchmarkId,
})
}
// Run synchronously if requested (useful for local dev without Redis)
if (runSync) {
try {
let result
switch (benchmarkType) {
case 'full':
result = await this.benchmarkService.runFullBenchmark()
break
case 'system':
result = await this.benchmarkService.runSystemBenchmarks()
break
case 'ai':
result = await this.benchmarkService.runAIBenchmark()
break
default:
result = await this.benchmarkService.runFullBenchmark()
}
return response.send({
success: true,
benchmark_id: result.benchmark_id,
nomad_score: result.nomad_score,
result,
})
} catch (error) {
logger.error({ err: error }, '[BenchmarkController] Benchmark run failed')
return response.status(500).send({
success: false,
error: 'An internal error occurred while running the benchmark.',
})
}
}
// Generate benchmark ID and dispatch job (async)
const benchmarkId = randomUUID()
const { job, created } = await RunBenchmarkJob.dispatch({
benchmark_id: benchmarkId,
benchmark_type: benchmarkType,
include_ai: benchmarkType === 'full' || benchmarkType === 'ai',
})
return response.status(201).send({
success: true,
job_id: job?.id || benchmarkId,
benchmark_id: benchmarkId,
message: created
? `${benchmarkType} benchmark started`
: 'Benchmark job already exists',
})
}
/**
* Run a system-only benchmark (CPU, memory, disk)
*/
async runSystem({ response }: HttpContext) {
const status = await this.currentStatus()
if (status.status !== 'idle') {
return response.status(409).send({
success: false,
error: 'A benchmark is already running',
})
}
const benchmarkId = randomUUID()
await RunBenchmarkJob.dispatch({
benchmark_id: benchmarkId,
benchmark_type: 'system',
include_ai: false,
})
return response.status(201).send({
success: true,
benchmark_id: benchmarkId,
message: 'System benchmark started',
})
}
/**
* Run an AI-only benchmark
*/
async runAI({ response }: HttpContext) {
const status = await this.currentStatus()
if (status.status !== 'idle') {
return response.status(409).send({
success: false,
error: 'A benchmark is already running',
})
}
const benchmarkId = randomUUID()
await RunBenchmarkJob.dispatch({
benchmark_id: benchmarkId,
benchmark_type: 'ai',
include_ai: true,
})
return response.status(201).send({
success: true,
benchmark_id: benchmarkId,
message: 'AI benchmark started',
})
}
/**
* Get all benchmark results
*/
async results({}: HttpContext) {
const results = await this.benchmarkService.getAllResults()
return {
results,
total: results.length,
}
}
/**
* Get the latest benchmark result
*/
async latest({}: HttpContext) {
const result = await this.benchmarkService.getLatestResult()
if (!result) {
return { result: null }
}
return { result }
}
/**
* Get a specific benchmark result by ID
*/
async show({ params, response }: HttpContext) {
const result = await this.benchmarkService.getResultById(params.id)
if (!result) {
return response.status(404).send({
error: 'Benchmark result not found',
})
}
return { result }
}
/**
* Submit benchmark results to central repository
*/
async submit({ request, response }: HttpContext) {
const payload = await request.validateUsing(submitBenchmarkValidator)
const anonymous = request.input('anonymous') === true || request.input('anonymous') === 'true'
try {
const submitResult = await this.benchmarkService.submitToRepository(payload.benchmark_id, anonymous)
return response.send({
success: true,
repository_id: submitResult.repository_id,
percentile: submitResult.percentile,
})
} catch (error) {
// Pass through the status code from the service if available, otherwise default to 400
const statusCode = (error as any).statusCode || 400
logger.error({ err: error }, '[BenchmarkController] Benchmark submit failed')
// Surface a clear, actionable reason to the UI instead of a generic failure.
// The rate limiter (429) is the most common cause, so name it explicitly;
// otherwise pass through the underlying detail (repository error or a service
// validation message) and fall back to a safe generic only when we have none.
let errorMessage: string
if (statusCode === 429) {
errorMessage = 'You can only submit one benchmark per hour. Please wait a bit and try again.'
} else {
errorMessage =
(error as any).detail || (error as any).message || 'Failed to submit benchmark results.'
}
return response.status(statusCode).send({
success: false,
error: errorMessage,
})
}
}
/**
* Update builder tag for a benchmark result
*/
async updateBuilderTag({ request, response }: HttpContext) {
const benchmarkId = request.input('benchmark_id')
const builderTag = request.input('builder_tag')
if (!benchmarkId) {
return response.status(400).send({
success: false,
error: 'benchmark_id is required',
})
}
const result = await this.benchmarkService.getResultById(benchmarkId)
if (!result) {
return response.status(404).send({
success: false,
error: 'Benchmark result not found',
})
}
// Validate builder tag format if provided
if (builderTag) {
const tagPattern = /^[A-Za-z]+-[A-Za-z]+-\d{4}$/
if (!tagPattern.test(builderTag)) {
return response.status(400).send({
success: false,
error: 'Invalid builder tag format. Expected: Word-Word-0000',
})
}
}
result.builder_tag = builderTag || null
await result.save()
return response.send({
success: true,
builder_tag: result.builder_tag,
})
}
/**
* Get comparison stats from central repository
*/
async comparison({}: HttpContext) {
const stats = await this.benchmarkService.getComparisonStats()
return { stats }
}
/**
* Get current benchmark status
*/
async status({}: HttpContext) {
return this.currentStatus()
}
/**
* The run status as both processes see it: benchmarks execute in the queue
* worker, so it comes from the queue, not this process's BenchmarkService (#1326).
* A queue read that errors falls back to the local status, as before this change.
*/
private async currentStatus() {
const local = this.benchmarkService.getStatus()
try {
return resolveBenchmarkStatus(await RunBenchmarkJob.getInFlight(), local)
} catch (error) {
logger.warn({ err: error }, '[BenchmarkController] Could not read the benchmark queue')
return local
}
}
/**
* Whether to show the dashboard "re-run under Score v2" banner
*/
async rerunBanner({}: HttpContext) {
return { show: await this.benchmarkService.shouldShowRerunBanner() }
}
/**
* Get benchmark settings
*/
async settings({}: HttpContext) {
const { default: BenchmarkSetting } = await import('#models/benchmark_setting')
return await BenchmarkSetting.getAllSettings()
}
/**
* Update benchmark settings
*/
async updateSettings({ request, response }: HttpContext) {
const { default: BenchmarkSetting } = await import('#models/benchmark_setting')
const body = request.body()
if (body.allow_anonymous_submission !== undefined) {
await BenchmarkSetting.setValue(
'allow_anonymous_submission',
body.allow_anonymous_submission ? 'true' : 'false'
)
}
return response.send({
success: true,
settings: await BenchmarkSetting.getAllSettings(),
})
}
}