Skip to content

fix(validator): commit evaluation progress so it is visible while the run is in flight (#251)#252

Open
kai392 wants to merge 1 commit into
mini-router:mainfrom
kai392:fix/critical-issue-progress-never-committed
Open

fix(validator): commit evaluation progress so it is visible while the run is in flight (#251)#252
kai392 wants to merge 1 commit into
mini-router:mainfrom
kai392:fix/critical-issue-progress-never-committed

Conversation

@kai392

@kai392 kai392 commented Jul 24, 2026

Copy link
Copy Markdown

Fixes #251

Root cause

_touch_progress() ended in session.flush(), and the worker claimed its job with session.flush() too. A flush writes into the worker's own transaction — and process_once() opens one session and does not commit until after the job body returns, which is up to EVAL_TIMEOUT_SECONDS (7200s by default) later.

Under Postgres READ COMMITTED the API process cannot read uncommitted rows, so for the whole run:

  • every parsed [submission] item i/N line was recorded and then invisible — current_phase, current_message, current_progress_current, current_progress_total stayed null on /api/submissions/{id} and on the public submission page until the evaluation had already finished;
  • /api/jobs reported a claimed job as queued, with claimed_by/claimed_at/heartbeat_at unset;
  • submission.status = "running" never surfaced.

The entire _PROGRESS_*_RE / _consume_progress_line path exists only to drive that live progress, so it was doing nothing observable in production.

Fix approach

Two one-line changes, flush() -> commit():

  • services/eval_runner.py::_touch_progress — progress updates are only useful if another process can read them mid-run.
  • worker.py::process_once — commit the claim before the long job body starts.

Why this is safe for job claiming

Committing releases the SELECT ... FOR UPDATE row lock earlier than before, but that lock was never what kept other workers off the row — the WHERE status == "queued" predicate is. After the commit the row is durably running, so a competing worker's ... WHERE status = 'queued' FOR UPDATE SKIP LOCKED no longer matches it. In the old code the claim was not durable at all, so a worker that died mid-run silently released it back to queued.

Impact

Restores live progress reporting on the public submission page and in /api/jobs, and stops a single evaluation from holding one Postgres transaction open for hours — the contention that ensure_schema()'s lock_timeout and its _add_column_if_missing catalog pre-check were added to work around.

Tests

New file validator/tests/test_progress_visibility.py (new file on purpose — keeps this PR conflict-free with #250, which touches test_eval_runner.py):

  • test_touch_progress_is_committed_not_just_flushed
  • test_worker_commits_the_claim_before_the_job_body_runs — drives process_once() with a stubbed evaluator and asserts a commit has already happened when the job body starts.

Both fail on main and pass here.

Risk / tradeoffs

  • Crash semantics change. Previously a hard worker kill (SIGKILL, not an exception) aborted the transaction and returned the job to queued; now it stays running. The existing exception handler still marks the job failed explicitly, so only an un-catchable kill is affected. attempts / max_attempts / heartbeat_at are already on JobQueue for a reaper to use. Note this is the opposite end of Validator worker re-queues submissions forever after uncaught evaluation errors #50, which argues the old auto-requeue is itself a bug ("retried forever, blocking the evaluation queue") — this change removes that particular symptom but deliberately does not implement the dead-lettering Validator worker re-queues submissions forever after uncaught evaluation errors #50 asks for.
  • Sync-submit path. With SYNC_EVAL_ON_SUBMIT=true (non-default), a mid-run commit means a later failure no longer rolls the EvaluationRun row away; the attempt stays recorded instead of vanishing. That seems preferable, but flagging it.
  • Not included: _local_attempt() still does not forward on_line to _run_bash_stream(), so local / local-fallback runs parse no progress lines at all. Kept out to stay reviewable — happy to send it separately.

Verification

The validator job needs POSIX pty and a Postgres service, neither available on my machine, so I ran the suite locally against SQLite with those modules stubbed:

  • with the fix: 35 passed, 3 failures;
  • without the fix: 33 passed, 5 failures.

The same 3 failures occur on unmodified main — they come from my SQLite stand-in conftest lacking the real fixture's TRUNCATE reset, not from this change. The delta is exactly the 2 new tests. ruff check is clean on all changed files.

I also confirmed the validator_session fixture stays isolated under this change: it binds the Session to a Connection that already has an open transaction, so SQLAlchemy 2.0 joins via SAVEPOINT and an inner session.commit() is still undone by the fixture's outer transaction.rollback().

🤖 Generated with Claude Code

@kai392

kai392 commented Jul 24, 2026

Copy link
Copy Markdown
Author

Two notes for reviewers:

CI needs approval. CI is sitting at action_required — as on every fork PR here, a maintainer has to click Approve and run before test-router / test-validator / web will report. The PR automation / register_submission failure is pre-existing and unrelated: actions/checkout now refuses to check out fork code from a pull_request_target workflow without allow-unsafe-pr-checkout: true, so it fails on every fork PR (same failure on #248, #247, #246 …). That is what #233 fixes; nothing here touches the workflow.

Overlap with #250. These two are independent fixes in the same file and the code merges cleanly in either order — I verified with a trial merge that services/eval_runner.py auto-merges (the hunks are ~350 lines apart) and the test files are disjoint (#250 touches test_eval_runner.py, this one adds a new test_progress_visibility.py, deliberately). The only conflict is docs/JOURNAL.md, where both add a dated entry at the top anchor. Whichever lands first, ping me — or just merge it — and I will rebase the other within the hour; it is a single-hunk resolution. Happy to drop the JOURNAL entry from one of them instead if you would rather not deal with it at all.

…flight

`_touch_progress()` ended in `session.flush()` and the worker claimed its job
with `session.flush()` too. A flush only writes into the worker's own
transaction, and `process_once()` holds that single transaction open across the
whole job body — up to `EVAL_TIMEOUT_SECONDS`, 2h by default. Under Postgres
READ COMMITTED the API process cannot read uncommitted rows, so:

* every parsed `[submission] item i/N` progress line was invisible until the run
  had already finished, leaving `current_phase` / `current_message` /
  `current_progress_*` blank on `/api/submissions/{id}` and on the public
  submission page for the entire evaluation;
* `/api/jobs` reported a claimed job as `queued`, and the submission as
  un-started, for hours.

Commit in both places instead. Committing the claim also makes it durable and
releases the `SELECT ... FOR UPDATE` row lock early; the committed
`queued` -> `running` transition is what keeps other workers off the row, so
`skip_locked` claiming is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kai392

kai392 commented Jul 24, 2026

Copy link
Copy Markdown
Author

CI is green on this branch. I enabled Actions on my fork and ran this repo's unmodified .github/workflows/ci.yml against this branch's exact commit (776b300) on GitHub-hosted runners, including the real postgres:16 service — so the two new tests in test_progress_visibility.py are now verified against Postgres, not just my local SQLite stand-in:

job result
test-router ✅ success
test-validator (Postgres 16) ✅ success
web (tsc build + lint) ✅ success

Run: https://github.com/kai392/minirouter/actions/runs/30106137472

That also settles the one risk I flagged earlier: the validator_session fixture stays isolated under the added session.commit() calls on real Postgres, not just SQLite.

I also ran the state after both #250 and this PR are merged (JOURNAL conflict resolved by keeping both entries) — all three jobs green: https://github.com/kai392/minirouter/actions/runs/30106669053

The remaining red PR automation / register_submission is not fixable from any PR branch: pull_request_target runs the workflow definition from base main, so it fails identically on every fork PR here and only #233 can make it green. The CI check shows action_required pending maintainer approval for a first-time contributor.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Live evaluation progress is never visible: the worker holds one uncommitted transaction for the whole run

1 participant