feat(DIARCHERS-1521): add MCP write endpoints + fix log/artifacts/trigger#628
Conversation
…eorder UserGlobalTokens
Three defects in the MCP backend surfaced during test-env integration
(build_3201), and the 5 MCP write endpoints promised by the
feat/mcp-restart-rerun branch name had never been implemented. Also
reordered the token UI to put MCP Tokens first. Bug identifiers
follow the internal tracker; Bug C is handled separately.
Bug E (get_job_log silent empty response):
- MCP endpoint only queried the `console` streaming table
- Scheduler archives logs to `job.console` on termination and DELETEs
the streaming rows, so every finished job returned HTTP 200 with
empty body
- Fixed with a two-stage lookup: `job.console` archive column first,
fall back to `console` streaming table for jobs still running
Bug F (list_job_artifacts returns 500 unconditionally):
- MCP endpoint queried a non-existent `archive` table
- `archive` is actually a jsonb[] column on the `job` table
(see migration 00009.sql)
- Rewrote the query to `SELECT archive FROM job` and unpack the
array, matching the legacy endpoint behavior
Bug G (POST /trigger creates zombie builds):
- MCP endpoint only inserted a build row without the seed
create_job_matrix job that the scheduler polls for
- Every trigger returned HTTP 200 + build_id but left a build that
never ran, punching holes in the build_number sequence
- Also hardcoded restart_counter=0, diverging from the schema
default of 1 and making the build invisible to legacy UI filters
- Fixed by adding source_upload_id resolution (upload projects
reuse the most recent source_upload; test projects allow NULL),
inserting the Create Jobs seed in the same transaction, moving
the success audit entry to after the seed job commits, and
removing the restart_counter=0 override
5 new MCP write endpoints, all requiring allow_trigger on the token,
sharing the trigger_build rate-limit bucket, and fully audited:
- POST /api/v1/mcp/projects/<id>/builds/<bid>/restart
- DELETE /api/v1/mcp/projects/<id>/builds/<bid>/abort
- POST /api/v1/mcp/projects/<id>/jobs/<jid>/restart (cascades downstream)
- POST /api/v1/mcp/projects/<id>/jobs/<jid>/rerun (does NOT cascade)
- DELETE /api/v1/mcp/projects/<id>/jobs/<jid>/abort
Handlers do not delegate to legacy /api/v1/* implementations which
hard-depend on g.token['type'] in ('user', 'project'); MCP handlers
use get_mcp_user_id() and reimplement the SQL directly. Shared
helper _restart_or_rerun_job() covers both the DAG-propagating
restart_job and the single-job rerun_job paths.
Dashboard UI:
- src/dashboard-client/src/components/user/UserGlobalTokens.vue:
moved MCP Tokens section to the top of the page, ahead of Global
Viewer Tokens and Project Tokens. Rationale: MCP Tokens is the
newest feature and the highest-traffic section for users
onboarding to the MCP integration.
Verified:
- Python syntax check passes on builds.py and jobs.py
- All 5 new handlers registered by Flask-RESTX (verified via ast walk)
- Existing MCP endpoints unaffected (legacy /api/v1/projects/*
handlers left untouched)
Follows: DIARCHERS-1498
Corresponding client PR: sparta-infra/InfraBox-mcp#6
liuwei08
left a comment
There was a problem hiding this comment.
Two high-severity concurrency bugs found during review. Both involve race conditions on write paths that could produce duplicate data or conflicting DB state under concurrent requests. See inline comments for details and suggested fixes.
| FROM build | ||
| WHERE build_number = %s AND project_id = %s | ||
| ''', [build['build_number'], project_id]) | ||
| restart_counter = row['restart_counter'] + 1 |
There was a problem hiding this comment.
Race condition: restart_counter is not computed atomically
Two concurrent restart requests for the same build_number will both read the same MAX(restart_counter), increment it independently to the same value, and then both attempt to insert a row with that value — causing either a unique-constraint violation or duplicate restart_counter entries.
The POST /trigger handler avoids this correctly by calling LOCK TABLE build IN EXCLUSIVE MODE before computing the next build_number. The same pattern must be applied here before the SELECT max(restart_counter).
Suggested fix:
g.db.execute('LOCK TABLE build IN EXCLUSIVE MODE')
row = g.db.execute_one_dict('''
SELECT max(restart_counter) AS restart_counter
FROM build
WHERE build_number = %s AND project_id = %s
''', [build['build_number'], project_id])
restart_counter = row['restart_counter'] + 1The lock must be acquired before the SELECT max(...), not after, so that no concurrent transaction can read the same max between the SELECT and the INSERT.
There was a problem hiding this comment.
Done — see 4d18d36. Added LOCK TABLE build IN EXCLUSIVE MODE before the SELECT max(restart_counter), held until commit (also fixed audit_mcp so it no longer commits/releases the caller's transaction, which would otherwise defeat this lock).
| project_id, job_id: target | ||
| rerun_only: if True, only the target job is cloned (dependents are NOT restarted). | ||
| if False, target job + all downstream dependents are cloned. | ||
| msg_who: string inserted into the audit console message |
There was a problem hiding this comment.
TOCTOU race: restarted flag check is not atomic
The guard if job['restarted']: abort(400, ...) (a few lines above) and the UPDATE job SET restarted = true here are two separate operations with no lock between them. Two concurrent requests for the same job_id can both pass the check (both read restarted = false), and then both proceed to clone the job, resulting in duplicate cloned rows in the build.
Suggested fix — use an atomic CAS update instead:
# Replace the SELECT + guard + UPDATE pattern with a single atomic operation.
updated = g.db.execute_one_dict('''
UPDATE job SET restarted = true
WHERE id = %s AND restarted = false
RETURNING id
''', [j['id']])
if not updated:
abort(409, 'Job %s has already been restarted by a concurrent request' % j['id'])This ensures only one request can claim the job: the second concurrent request will find no rows returned by the RETURNING clause and abort cleanly.
There was a problem hiding this comment.
Done — see 4d18d36. Replaced the read-then-check guard with an atomic CAS: UPDATE job SET restarted=true WHERE id=%s AND restarted=false RETURNING id; 0 rows → 409 so only one concurrent request wins.
…back) Addresses two race conditions raised by @liuwei08 in review, plus a root-cause transaction bug (and its two consequences) found while auditing the rest of the write paths. Bug A — MCPBuildRestart: restart_counter not computed atomically - SELECT max(restart_counter) + increment + INSERT had no table lock, so two concurrent restarts of the same build_number could both read the same max and insert duplicate restart_counter rows. - Fix: LOCK TABLE build IN EXCLUSIVE MODE before the SELECT (same pattern MCPTrigger already uses), held until commit. Bug B — restart_job/rerun_job: restarted flag TOCTOU - The `if job['restarted']: abort` check and the later `UPDATE job SET restarted=true` were separate operations; two concurrent requests could both pass the check and both clone. - Fix: atomic compare-and-set — UPDATE job SET restarted=true WHERE id=%s AND restarted=false RETURNING id; 0 rows returned -> 409, so only one request wins. Bug C (root cause, additional) — audit_mcp committed the caller's txn - audit_mcp() called g.db.commit() on the request connection. That ended the caller's transaction mid-handler, releasing any LOCK TABLE it held and committing partially-written rows. - Fix: audit_mcp now uses its OWN short-lived autocommit connection and never touches g.db. This makes Bug A's lock actually hold, and also fixes two consequences: - orphan build: on the seed-missing failure path, the inserted build is no longer committed by the failure audit (plus an explicit rollback() before aborting). - lost failure audits: audit on a fresh connection is unaffected by the caller's InFailedSqlTransaction state. Bug F (additional) — concurrent restarts share an unlocked snapshot - _restart_or_rerun_job read build_jobs without locking, so two concurrent restarts on the same build could clone overlapping downstream dependents twice. - Fix: SELECT id FROM build WHERE id=%s FOR UPDATE at the top of _restart_or_rerun_job, serializing restarts per build until commit. Files: - src/api/handlers/mcp/audit.py (Bug C + D + E) - src/api/handlers/mcp/routes/builds.py (Bug A + orphan-build rollback) - src/api/handlers/mcp/routes/jobs.py (Bug B + F) Not included (tracked separately, lower severity): token-limit TOCTOU, duplicate abort inserts, rate-limiter ZADD/ZCARD ordering. Follows: DIARCHERS-1498
|
Thanks @liuwei08 — both race conditions confirmed and fixed. While fixing them I audited the rest of the MCP write paths and found a root-cause transaction bug that would have made the fix for (1) ineffective, so I fixed that too. Summary (final state as of Your findings
Additional issues found & fixed
End-to-end verification — all 19 MCP tools re-tested on the test env (
|
liuwei08
left a comment
There was a problem hiding this comment.
One deterministic crash found: MAX(restart_counter) returns NULL when no build row matches the build_number, causing None + 1 TypeError on every such request. See inline comment.
| # until the commit at the end of this try block. Do NOT call audit_mcp() | ||
| # between here and the commit — audit uses its own connection now, but | ||
| # keeping the locked section audit-free avoids any future regression. | ||
| g.db.execute('LOCK TABLE build IN EXCLUSIVE MODE') |
There was a problem hiding this comment.
Crash bug: TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'
MAX() returns NULL (mapped to Python None by psycopg2) when no rows match the WHERE clause — which happens whenever build_number does not yet exist in the build table for this project (e.g. the very first restart attempt for a freshly-inserted build, or any edge case where build_number is unique so far).
None + 1 raises TypeError, the except block catches it and re-raises, Flask returns HTTP 500. This is a deterministic crash, not a race.
Fix:
restart_counter = (row['restart_counter'] or 0) + 1Note: row itself will never be None here because MAX() always returns a row (with a NULL value), so the row is None case does not apply — only the value inside the row needs the null guard.
There was a problem hiding this comment.
Good catch — fixed in 64be938: restart_counter = (row['restart_counter'] or 0) + 1, matching the COALESCE pattern MCPTrigger already uses for build_number. Agreed the row itself is never None (MAX always returns a row), only the value needed the guard.
…ounded retry The prior Bug C fix isolated audit onto its own connection but reused connect_db(), whose 'while True: connect except sleep(3)' loop has no timeout and never raises. A Postgres blip would make each audit call (2-3 per request) retry forever, hanging the request thread — worse than the bug it fixed. Switched to a private psycopg2.connect(connect_timeout=2) autocommit connection, closed in finally: - isolation preserved (never touches g.db, so the caller's LOCK TABLE and transaction boundary are intact) - fast-fail: a down/slow DB times out in 2s and the error is swallowed by the best-effort except, instead of hanging - NOT dbpool.get(): the request already holds g.db for its whole lifetime; borrowing a 2nd pooled connection here risks a hold-one- wait-another deadlock at max_size=10 under load (eventlet pool get() has no timeout). A private connection cannot exhaust the shared pool. - no autocommit leak: the connection is private and closed, never returned to the pool.
MAX() returns NULL (-> None) for an empty match set; None + 1 would raise TypeError -> 500. Guard with (row['restart_counter'] or 0) + 1, matching the COALESCE pattern MCPTrigger uses for build_number.
…ollback Replaces the dedicated-connection audit approach with the original shared-g.db + commit audit, and instead protects every failure audit with an explicit g.db.rollback() first. Rationale: keeps audit on the pooled request connection (no extra connection per audit call), while still fixing the two transaction bugs: - Poisoned-connection audit loss: after a failed SQL statement the connection is InFailedSqlTransaction and any further statement — including the audit INSERT — fails and is swallowed, losing the failure log. A rollback() before the audit clears the aborted state so the INSERT succeeds. - Stray-write commit: on paths that abort after an uncommitted write (e.g. restart_job/rerun_job after the restarted=true claim), the audit's commit would otherwise commit that partial write. rollback() discards it first. rollback() added before the failure/partial audit in every except block that can run after SQL executed — 17 sites total, covering both write endpoints (trigger/restart/abort build & job) and read endpoints (list/get builds, jobs, log, artifacts, stats, testruns, manifest, projects), since a failed SELECT poisons the connection just as a failed write does. The pre-write "not found"/"forbidden" audits and the seed- missing path (which already had its rollback) are unchanged. audit_mcp docstring now documents the four ordering invariants callers must uphold (attempt-before-write, success-after-commit, failure-rolls- back-first, no-audit-inside-a-lock).
|
Update on the audit fix (commit d511f8b). I've kept What the rollback approach fixes, and why it's applied everywhere:
Pre-write "not found"/"forbidden" audits and the seed-missing path (which already rolled back) are unchanged. I've documented the ordering invariants this relies on in the |
Summary
Fixes three defects in the MCP backend surfaced during test-env
integration (build_3201) and adds the 5 MCP write endpoints the
feat/mcp-restart-rerunbranch name promised.Branch base:
feat/mcp-restart-rerun. This PR therefore carriesthe 8 upstream commits from #617-#625 in addition to the single
commit authored here. Once #620 (traffic isolation) and #621
(restart/rerun endpoints) land on
master, the base can be switchedor this PR can be rebased.
DIARCHERS-1521 — Backend fixes
get_job_logsilent empty response: the MCP endpoint queriedonly the
consolestreaming table. The scheduler archives joblogs to
job.consoleon termination and DELETEs the streamingrows, so every finished job returned HTTP 200 with an empty body.
Fixed with a two-stage lookup:
job.consolearchive columnfirst, fall back to
consolefor jobs still running.list_job_artifactsreturns 500 unconditionally: the MCPendpoint queried a non-existent
archivetable.archiveis ajsonb[]column on thejobtable (migration00009.sql).Rewrote to
SELECT archive FROM joband unpack the array,matching the legacy endpoint behavior.
POST /triggercreates zombie builds: the MCP endpoint onlyinserted a
buildrow without the seedcreate_job_matrixjobthe scheduler polls for, so every trigger returned HTTP 200 +
build_id but the build never ran. Also hardcoded
restart_counter=0, diverging from schema default 1 and hidingthe build from legacy UI filters. Fixed by resolving
source_upload_idper project type (upload projects reuse themost recent source_upload; test projects allow NULL), inserting
the Create Jobs seed in the same transaction, moving the success
audit entry to after the seed job commits, and removing the
restart_counter=0override.5 new MCP write endpoints
All require
allow_trigger=trueon the MCP token, share thetrigger_buildrate-limit bucket (5 RPM), and are fully audited./builds/<bid>/restartMCPBuildRestart/builds/<bid>/abortMCPBuildAbort/jobs/<jid>/restartMCPJobRestart/jobs/<jid>/rerunMCPJobRerun/jobs/<jid>/abortMCPJobAbortHandlers do not delegate to the legacy
/api/v1/*implementations, which hard-depend on
g.token['type'] in ('user', 'project'). MCP handlers useget_mcp_user_id()and reimplement the SQL directly. Shared helper_restart_or_rerun_job()covers bothrestart_job(DAG-propagating)and
rerun_job(single-job) paths.Dashboard UI
src/dashboard-client/src/components/user/UserGlobalTokens.vue:moved the MCP Tokens section to the top of the page, ahead of Global
Viewer Tokens and Project Tokens. Rationale: MCP Tokens is the
newest feature and the highest-traffic section for users onboarding
to the MCP integration.
Concurrency hardening (review feedback)
Following review, the write paths were hardened against race conditions
under concurrent requests:
MCPBuildRestart— atomic restart_counter:LOCK TABLE build IN EXCLUSIVE MODEis now taken beforeSELECT max(restart_counter), sotwo concurrent restarts of the same build cannot read the same max and
insert duplicate rows. Matches the pattern
MCPTriggeralready uses;EXCLUSIVE MODEdoes not block reads.restart_job/rerun_job— atomic claim of the target job: theread-then-check on the
restartedflag was replaced by a compare-and-setUPDATE job SET restarted=true WHERE id=%s AND restarted=false RETURNING id;a concurrent loser matches zero rows and gets a clean 409.
_restart_or_rerun_job— per-build serialization: aSELECT id FROM build WHERE id=%s FOR UPDATErow lock serializesconcurrent restarts of the same build, so overlapping downstream
dependents cannot be cloned twice.
audit_mcp— failure audits guarded withrollback(): auditwrites share the request's
g.dbconnection. A failed SQL statement(write OR read) leaves the connection in
InFailedSqlTransaction, so alater failure-audit INSERT on the same connection would itself fail and
be swallowed — losing exactly the failure/error events that matter most.
Every failure/partial audit inside an
exceptblock now callsg.db.rollback()first (17 sites, across both write and read endpoints),which clears the aborted state so the audit INSERT succeeds and also
discards any stray uncommitted write (e.g. a
restarted=trueclaim on ajob-restart path that aborted before commit) so it is not committed by
the audit. Pre-write "not found"/"forbidden" audits and the seed-missing
path (which already rolled back) are unchanged. The
audit_mcpdocstringdocuments the ordering invariants callers must uphold (attempt-before-
write, success-after-commit, failure-rolls-back-first, no-audit-in-lock).