Skip to content

Commit 8f0ecb2

Browse files
Defer baseline gates while retry jobs are active (#1051)
Co-authored-by: Charles Huang <77707222+GeeseGoo@users.noreply.github.com> Co-authored-by: kyle-compute <kylehvdbnl@gmail.com>
1 parent fd395d9 commit 8f0ecb2

5 files changed

Lines changed: 336 additions & 38 deletions

File tree

oddish/src/oddish/queue.py

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ class TrialSupersedeConflict(RuntimeError):
7777
TrialStatus.RUNNING,
7878
TrialStatus.RETRYING,
7979
)
80+
ACTIVE_WORKER_JOB_STATUSES = (
81+
WorkerJobStatus.QUEUED,
82+
WorkerJobStatus.RUNNING,
83+
WorkerJobStatus.RETRYING,
84+
WorkerJobStatus.BLOCKED,
85+
)
8086
ACTIVE_PIPELINE_STATUSES = (
8187
AnalysisStatus.PENDING,
8288
AnalysisStatus.QUEUED,
@@ -1487,13 +1493,14 @@ async def maybe_gate_llm_trials(session: AsyncSession, trial_id: str) -> bool:
14871493
14881494
Fires only when *trial_id* is a nop/oracle baseline. The decision is scoped
14891495
to the baseline's **(task version, experiment)**: when every baseline trial
1490-
for that task version in that experiment is terminal, evaluates them and —
1491-
if they validate the task (oracle passes, nop fails) — releases that scope's
1492-
BLOCKED LLM trials to QUEUED; otherwise cancels them and mirrors them to
1493-
FAILED so the task can advance. Scoping by experiment keeps concurrent sweeps
1494-
in different experiments from sharing each other's gate timing or verdict;
1495-
scoping by task version keeps an older version's baselines from validating a
1496-
newer version's (different code) LLM trials.
1496+
and its authoritative worker job for that task version in that experiment
1497+
are terminal, evaluates them and — if they validate the task (oracle passes,
1498+
nop fails) — releases that scope's BLOCKED LLM trials to QUEUED; otherwise
1499+
cancels them and mirrors them to FAILED so the task can advance. Scoping by
1500+
experiment keeps concurrent sweeps in different experiments from sharing
1501+
each other's gate timing or verdict; scoping by task version keeps an older
1502+
version's baselines from validating a newer version's (different code) LLM
1503+
trials.
14971504
14981505
A no-op when there are no BLOCKED LLM trials in this scope (the gate was
14991506
never armed) or other baselines are still running. Uses SELECT FOR UPDATE on
@@ -1595,6 +1602,23 @@ async def _resolve_baseline_gate_for_scope(
15951602
if not blocked_trial_ids:
15961603
return None
15971604

1605+
# worker_jobs is authoritative for scheduling state. During failure
1606+
# settlement the trial mirror can briefly read FAILED before the runner
1607+
# records the still-live job as RETRYING. Treat either active mirror as
1608+
# pending so that window cannot turn a retryable baseline into a permanent
1609+
# faulty gate verdict.
1610+
active_baseline_job = (
1611+
select(WorkerJobModel.id)
1612+
.where(
1613+
and_(
1614+
WorkerJobModel.kind == WorkerJobKind.TRIAL,
1615+
WorkerJobModel.subject_table == "trials",
1616+
WorkerJobModel.subject_id == TrialModel.id,
1617+
WorkerJobModel.status.in_(ACTIVE_WORKER_JOB_STATUSES),
1618+
)
1619+
)
1620+
.exists()
1621+
)
15981622
pending_baselines = await session.scalar(
15991623
select(func.count(TrialModel.id)).where(
16001624
and_(
@@ -1603,7 +1627,10 @@ async def _resolve_baseline_gate_for_scope(
16031627
TrialModel.task_version_id == task_version_id,
16041628
TrialModel.queue_key == NOP_ORACLE_QUEUE_KEY,
16051629
TrialModel.superseded_by_trial_id.is_(None),
1606-
TrialModel.status.in_(ACTIVE_TRIAL_STATUSES),
1630+
or_(
1631+
TrialModel.status.in_(ACTIVE_TRIAL_STATUSES),
1632+
active_baseline_job,
1633+
),
16071634
)
16081635
)
16091636
)

oddish/src/oddish/worker/local_runner.py

Lines changed: 81 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
get_session,
4444
)
4545
from oddish.core.harbor_artifacts import cache_write_tokens_from_trajectory
46+
from oddish.core.cost_basis import CANCELLED_HARBOR_STAGE
4647
from oddish.core.llm_key_fingerprint import platform_key_hash_for_provider
4748
from oddish.db.models import WorkerJobKind, WorkerJobModel, WorkerJobStatus
4849
from oddish.db.storage import resolve_task_directory
@@ -314,7 +315,8 @@ async def run_trial_locally(trial_id: str, *, dry_run: bool = False) -> None:
314315
"""Execute a probe trial in-process and mirror status to the DB.
315316
316317
Status transitions: ``QUEUED`` -> ``RUNNING`` -> ``SUCCESS``
317-
(or ``FAILED`` on exception, with ``error_message`` populated).
318+
(or ``FAILED`` on exception, with ``error_message`` populated) on both the
319+
trial and its worker job.
318320
319321
When ``dry_run`` is True, skips the actual Harbor call. Used in
320322
tests to exercise the status-transition path without spinning up
@@ -326,8 +328,9 @@ async def run_trial_locally(trial_id: str, *, dry_run: bool = False) -> None:
326328
# and a gated (BLOCKED) LLM trial is skipped until the baseline gate
327329
# releases it. ``run_trial_locally`` is the only dispatch entrypoint, so
328330
# this claim is the single choke point that prevents double-dispatch.
331+
claimed_at = datetime.now(timezone.utc)
329332
async with get_session() as session:
330-
claimed = (
333+
claimed_trial_id = (
331334
await session.execute(
332335
update(TrialModel)
333336
.where(
@@ -344,19 +347,47 @@ async def run_trial_locally(trial_id: str, *, dry_run: bool = False) -> None:
344347
)
345348
.values(
346349
status=TrialStatus.RUNNING,
347-
started_at=datetime.now(timezone.utc),
350+
started_at=claimed_at,
348351
)
349-
.returning(TrialModel.org_id, TrialModel.billed_user_id)
352+
.returning(TrialModel.id)
350353
)
351-
).one_or_none()
352-
if claimed is None:
354+
).scalar_one_or_none()
355+
if claimed_trial_id is not None:
356+
org_id, billed_user_id = (
357+
await session.execute(
358+
select(TrialModel.org_id, TrialModel.billed_user_id).where(
359+
TrialModel.id == trial_id
360+
)
361+
)
362+
).one()
363+
# Local mode bypasses the unified dispatcher, so it must mirror the
364+
# scheduling row itself. The baseline gate treats worker_jobs as
365+
# authoritative and must never mistake a completed local baseline's
366+
# original QUEUED row for work that can still retry.
367+
await session.execute(
368+
update(WorkerJobModel)
369+
.where(
370+
WorkerJobModel.kind == WorkerJobKind.TRIAL,
371+
WorkerJobModel.subject_table == "trials",
372+
WorkerJobModel.subject_id == trial_id,
373+
WorkerJobModel.status.in_(
374+
(WorkerJobStatus.QUEUED, WorkerJobStatus.RETRYING)
375+
),
376+
)
377+
.values(
378+
status=WorkerJobStatus.RUNNING,
379+
claimed_at=claimed_at,
380+
started_at=claimed_at,
381+
heartbeat_at=claimed_at,
382+
)
383+
)
384+
if claimed_trial_id is None:
353385
logger.info(
354386
"local_runner: trial %s not claimable (already dispatched, gated, "
355387
"or gone), skipping",
356388
trial_id,
357389
)
358390
return
359-
org_id, billed_user_id = claimed
360391
logger.info("local_runner: trial %s -> RUNNING", trial_id)
361392

362393
failure: Exception | None = None
@@ -368,6 +399,7 @@ async def run_trial_locally(trial_id: str, *, dry_run: bool = False) -> None:
368399
failure = exc
369400

370401
completed = False
402+
finished_at = datetime.now(timezone.utc)
371403
async with get_session() as session:
372404
trial = await session.get(TrialModel, trial_id, with_for_update=True)
373405
if trial is None:
@@ -382,7 +414,7 @@ async def run_trial_locally(trial_id: str, *, dry_run: bool = False) -> None:
382414
else:
383415
trial.status = TrialStatus.SUCCESS
384416
logger.info("local_runner: trial %s -> SUCCESS", trial_id)
385-
trial.finished_at = datetime.now(timezone.utc)
417+
trial.finished_at = finished_at
386418
completed = True
387419
else:
388420
logger.info(
@@ -391,6 +423,47 @@ async def run_trial_locally(trial_id: str, *, dry_run: bool = False) -> None:
391423
trial.status.value,
392424
)
393425

426+
# A cancellation or another terminal writer can win while Harbor is
427+
# still exiting. Preserve that trial outcome, but do not leave local
428+
# mode's scheduling row RUNNING forever: the baseline gate treats an
429+
# active worker job as authoritative retry evidence.
430+
if trial is not None and trial.status in (
431+
TrialStatus.SUCCESS,
432+
TrialStatus.FAILED,
433+
TrialStatus.SKIPPED,
434+
):
435+
cancelled = (
436+
trial.status == TrialStatus.SKIPPED
437+
or trial.harbor_stage == CANCELLED_HARBOR_STAGE
438+
)
439+
if cancelled:
440+
worker_job_status = WorkerJobStatus.CANCELLED
441+
elif trial.status == TrialStatus.SUCCESS:
442+
worker_job_status = WorkerJobStatus.SUCCESS
443+
else:
444+
worker_job_status = WorkerJobStatus.FAILED
445+
settled_at = trial.finished_at or finished_at
446+
await session.execute(
447+
update(WorkerJobModel)
448+
.where(
449+
WorkerJobModel.kind == WorkerJobKind.TRIAL,
450+
WorkerJobModel.subject_table == "trials",
451+
WorkerJobModel.subject_id == trial_id,
452+
WorkerJobModel.status == WorkerJobStatus.RUNNING,
453+
)
454+
.values(
455+
status=worker_job_status,
456+
finished_at=settled_at,
457+
heartbeat_at=settled_at,
458+
next_retry_at=None,
459+
error_message=(
460+
None
461+
if worker_job_status == WorkerJobStatus.SUCCESS
462+
else trial.error_message
463+
),
464+
)
465+
)
466+
394467
from oddish.core.quota_enforcement import enforce_trial_quotas_until_checked
395468

396469
# Enforce settled spend, but run hooks only for the winning completion.

oddish/src/oddish/workers/queue/cleanup.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -883,15 +883,14 @@ async def _advance_running_tasks_to_analysis(
883883

884884
# -----------------------------------------------------------------
885885
# 2b. Baseline gate backstop: (task_version, experiment) groups whose
886-
# nop/oracle baselines are all terminal but whose LLM trials are
887-
# still BLOCKED. Normally the last baseline's handler resolves the
888-
# gate; this re-drives it if that handler was killed first. The gate
889-
# is (task version, experiment)-scoped, so group + match BLOCKED LLM
890-
# trials by (task_id, task_version_id, experiment_id) and hand it one
891-
# representative baseline trial id per group. ``IS NOT DISTINCT
892-
# FROM`` so a NULL version/experiment still matches itself (plain
893-
# ``=`` would drop those scopes, unlike the ORM push path). Skipped
894-
# entirely when the gate is off so it never touches the hot path.
886+
# nop/oracle trial mirrors and worker jobs are all terminal but whose
887+
# LLM trials are still BLOCKED. Normally the last baseline's handler
888+
# resolves the gate; this re-drives it if that handler was killed first.
889+
# The gate is (task version, experiment)-scoped, so group + match BLOCKED
890+
# LLM trials by (task_id, task_version_id, experiment_id) and hand it one
891+
# representative baseline trial id per group. ``IS NOT DISTINCT FROM``
892+
# lets a NULL version/experiment match itself (plain ``=`` would drop
893+
# those scopes, unlike the ORM push path).
895894
# -----------------------------------------------------------------
896895
# Only run the heavy grouped scan when something is actually BLOCKED.
897896
# Runs regardless of the feature flag so a flag rollback can't strand
@@ -929,8 +928,19 @@ async def _advance_running_tasks_to_analysis(
929928
GROUP BY base.task_id, base.task_version_id,
930929
base.experiment_id
931930
HAVING COUNT(*) FILTER (
932-
WHERE base.status
933-
IN ('PENDING', 'QUEUED', 'RUNNING', 'RETRYING')
931+
WHERE base.status IN (
932+
'PENDING', 'QUEUED', 'RUNNING', 'RETRYING'
933+
)
934+
OR EXISTS (
935+
SELECT 1
936+
FROM worker_jobs baseline_job
937+
WHERE baseline_job.subject_table = 'trials'
938+
AND baseline_job.kind::text = 'TRIAL'
939+
AND baseline_job.subject_id = base.id
940+
AND baseline_job.status::text IN (
941+
'QUEUED', 'RUNNING', 'RETRYING', 'BLOCKED'
942+
)
943+
)
934944
) = 0
935945
"""
936946
),

0 commit comments

Comments
 (0)