Fix sharded-eval poll loop: stop killing healthy QUEUED shards, reconcile before failing
Browse filesThe 45-min poll deadline cancelled shards that were merely QUEUED waiting
for a10g capacity β throwing away their GPU queue position β then, after
the retry rounds, marked the row failed while leaving jobs running, so
their outputs completed late and stranded in staging. This failed
otherwise-successful submissions under GPU load.
- Poll loop never cancels healthy QUEUED/RUNNING shards; only ERROR shards
re-dispatch. Each job's EVAL_JOB_TIMEOUT is the real RUNNING bound.
- Replace the per-round reaper with one generous env-tunable backstop
(default 6h) that only stops an unbounded loop.
- Reconcile-before-fail: always attempt the merge from whatever landed;
a shard flagged ERROR/timeout may still have written all its fixtures
(outputs are the source of truth). Only fail on genuinely-missing ones.
- Cancel orphan jobs on the true-failure path so nothing keeps billing.
- Boot sweep now reconciles stranded pending rows (finalize if artifacts
are complete) instead of blindly failing them β self-heals across Space
restarts, which is what stranded the recovered submission.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@@ -234,19 +234,20 @@ SHARD_BUCKET_PREFIX = os.getenv(
|
|
| 234 |
# overwrites its upload prefix), so one cheap retry absorbs a transient
|
| 235 |
# job/runtime blip without re-running the shards that already passed.
|
| 236 |
SHARD_MAX_RETRIES = 1
|
| 237 |
-
# Whole-fan-out poll
|
| 238 |
-
# ``EVAL_JOB_TIMEOUT``
|
| 239 |
-
#
|
| 240 |
-
#
|
| 241 |
-
|
| 242 |
-
#
|
| 243 |
-
#
|
| 244 |
-
#
|
| 245 |
-
#
|
| 246 |
-
#
|
| 247 |
-
#
|
| 248 |
-
|
| 249 |
-
|
|
|
|
| 250 |
|
| 251 |
# One HfApi client per process. HF_TOKEN is picked up from the env at
|
| 252 |
# construction time and reused for every call.
|
|
@@ -1164,7 +1165,42 @@ def _run_worker_sharded(
|
|
| 1164 |
failures = _poll_shards_until_done(
|
| 1165 |
submission_id, submission_blob_url, shards,
|
| 1166 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1167 |
if failures:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1168 |
reason = ("sharded eval failed: " + "; ".join(failures))[
|
| 1169 |
:FAILURE_REASON_MAX_CHARS
|
| 1170 |
]
|
|
@@ -1172,17 +1208,18 @@ def _run_worker_sharded(
|
|
| 1172 |
progress.publish(
|
| 1173 |
submission_id, progress.FAILED, _failed_progress_message(reason),
|
| 1174 |
)
|
| 1175 |
-
logger.warning(
|
|
|
|
|
|
|
|
|
|
| 1176 |
return
|
| 1177 |
|
| 1178 |
-
|
| 1179 |
-
|
| 1180 |
-
|
| 1181 |
-
|
| 1182 |
-
|
| 1183 |
-
|
| 1184 |
-
submission_id, list(shards.keys()), fixture_names,
|
| 1185 |
-
)
|
| 1186 |
_flip_row_to_completed(submission_id, summary)
|
| 1187 |
progress.publish(
|
| 1188 |
submission_id, progress.COMPLETED, _completed_progress_message(summary),
|
|
@@ -1291,13 +1328,13 @@ def _dispatch_shard(
|
|
| 1291 |
|
| 1292 |
|
| 1293 |
def _cancel_shard_job(state: dict[str, Any]) -> None:
|
| 1294 |
-
"""Best-effort cancel of a shard's in-flight job
|
| 1295 |
|
| 1296 |
-
Used on the
|
| 1297 |
-
|
| 1298 |
-
|
| 1299 |
-
logged and ignored, since
|
| 1300 |
-
|
| 1301 |
"""
|
| 1302 |
job_id = state.get("job_id")
|
| 1303 |
if not job_id:
|
|
@@ -1310,11 +1347,24 @@ def _cancel_shard_job(state: dict[str, Any]) -> None:
|
|
| 1310 |
namespace=EVAL_JOB_NAMESPACE,
|
| 1311 |
token=_jobs_token(),
|
| 1312 |
)
|
| 1313 |
-
logger.info("Cancelled
|
| 1314 |
except Exception as e: # noqa: BLE001 - cancel is best-effort
|
| 1315 |
logger.warning("Could not cancel shard job %s: %s", job_id, e)
|
| 1316 |
|
| 1317 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1318 |
def _poll_shards_until_done(
|
| 1319 |
submission_id: str,
|
| 1320 |
submission_blob_url: str,
|
|
@@ -1322,24 +1372,29 @@ def _poll_shards_until_done(
|
|
| 1322 |
) -> list[str]:
|
| 1323 |
"""Poll every shard to terminal, retrying only ERROR shards.
|
| 1324 |
|
| 1325 |
-
|
| 1326 |
-
|
| 1327 |
-
|
| 1328 |
-
|
| 1329 |
-
|
| 1330 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1331 |
Transient ``inspect_job`` failures retry up to
|
| 1332 |
:data:`JOB_POLL_MAX_CONSECUTIVE_ERRORS` before raising.
|
| 1333 |
|
| 1334 |
-
|
| 1335 |
-
|
| 1336 |
-
|
| 1337 |
-
and the window resets, up to :data:`SHARD_DEADLINE_RETRY_ROUNDS`
|
| 1338 |
-
rounds, before the submission is finally failed. All-or-nothing is
|
| 1339 |
-
preserved: the list is non-empty unless every shard COMPLETED.
|
| 1340 |
"""
|
| 1341 |
deadline = time.monotonic() + SHARD_POLL_DEADLINE_SECONDS
|
| 1342 |
-
deadline_rounds_left = SHARD_DEADLINE_RETRY_ROUNDS
|
| 1343 |
consecutive_errors = 0
|
| 1344 |
last_done = -1
|
| 1345 |
total = len(shards)
|
|
@@ -1402,46 +1457,22 @@ def _poll_shards_until_done(
|
|
| 1402 |
"Shard %s FAILED after %d attempt(s): %s",
|
| 1403 |
shard_id, st["attempts"], st["message"],
|
| 1404 |
)
|
|
|
|
|
|
|
|
|
|
| 1405 |
|
| 1406 |
if time.monotonic() >= deadline:
|
| 1407 |
-
|
| 1408 |
-
|
| 1409 |
-
|
| 1410 |
-
|
| 1411 |
-
|
| 1412 |
-
deadline_rounds_left -= 1
|
| 1413 |
-
logger.warning(
|
| 1414 |
-
"Poll deadline (%ds) hit for %s with %d straggler shard(s) "
|
| 1415 |
-
"%s; re-dispatching (%d round(s) left).",
|
| 1416 |
-
SHARD_POLL_DEADLINE_SECONDS, submission_id,
|
| 1417 |
-
len(stragglers), stragglers, deadline_rounds_left,
|
| 1418 |
-
)
|
| 1419 |
-
for sid in stragglers:
|
| 1420 |
-
st = shards[sid]
|
| 1421 |
-
_cancel_shard_job(st)
|
| 1422 |
-
# Give the replacement a fresh ERROR-retry budget too.
|
| 1423 |
-
st["attempts"] = 0
|
| 1424 |
-
_dispatch_shard(
|
| 1425 |
-
submission_id, submission_blob_url, sid, st,
|
| 1426 |
-
)
|
| 1427 |
-
progress.publish(
|
| 1428 |
-
submission_id,
|
| 1429 |
-
progress.RUNNING,
|
| 1430 |
-
f"GPU capacity was tight β retrying {len(stragglers)} "
|
| 1431 |
-
f"straggler chunk(s) (round "
|
| 1432 |
-
f"{SHARD_DEADLINE_RETRY_ROUNDS - deadline_rounds_left} of "
|
| 1433 |
-
f"{SHARD_DEADLINE_RETRY_ROUNDS})β¦",
|
| 1434 |
-
)
|
| 1435 |
-
deadline = time.monotonic() + SHARD_POLL_DEADLINE_SECONDS
|
| 1436 |
-
last_done = -1 # force a progress republish on the next sweep
|
| 1437 |
-
continue
|
| 1438 |
for shard_id, st in shards.items():
|
| 1439 |
if st["stage"] not in ("COMPLETED", "FAILED"):
|
| 1440 |
st["stage"] = "FAILED"
|
| 1441 |
st["message"] = (
|
| 1442 |
-
f"
|
| 1443 |
-
f"
|
| 1444 |
-
f"{SHARD_DEADLINE_RETRY_ROUNDS} retry round(s)"
|
| 1445 |
)
|
| 1446 |
break
|
| 1447 |
time.sleep(JOB_POLL_INTERVAL_SECONDS)
|
|
@@ -1806,21 +1837,94 @@ def _flip_row_to_failed(submission_id: str, reason: str) -> None:
|
|
| 1806 |
)
|
| 1807 |
|
| 1808 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1809 |
# ---------------------------------------------------------------------------
|
| 1810 |
# Boot-time stuck-pending sweep
|
| 1811 |
# ---------------------------------------------------------------------------
|
| 1812 |
|
| 1813 |
|
| 1814 |
def _sweep_stuck_pending() -> None:
|
| 1815 |
-
"""
|
| 1816 |
-
|
| 1817 |
-
A ``pending`` row whose worker died (Space restart, OOM, crash)
|
| 1818 |
-
|
| 1819 |
-
|
| 1820 |
-
|
| 1821 |
-
|
| 1822 |
-
|
| 1823 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1824 |
"""
|
| 1825 |
try:
|
| 1826 |
body = _download_results_jsonl()
|
|
@@ -1862,19 +1966,22 @@ def _sweep_stuck_pending() -> None:
|
|
| 1862 |
stuck_ids.append(sid)
|
| 1863 |
|
| 1864 |
if not stuck_ids:
|
| 1865 |
-
logger.info("
|
| 1866 |
return
|
| 1867 |
|
| 1868 |
logger.warning(
|
| 1869 |
-
"
|
| 1870 |
len(stuck_ids), stuck_ids,
|
| 1871 |
)
|
| 1872 |
for sid in stuck_ids:
|
| 1873 |
try:
|
| 1874 |
-
|
|
|
|
|
|
|
|
|
|
| 1875 |
except Exception as e: # noqa: BLE001 - log + carry on per-row
|
| 1876 |
logger.exception(
|
| 1877 |
-
"
|
| 1878 |
sid, type(e).__name__, e,
|
| 1879 |
)
|
| 1880 |
|
|
|
|
| 234 |
# overwrites its upload prefix), so one cheap retry absorbs a transient
|
| 235 |
# job/runtime blip without re-running the shards that already passed.
|
| 236 |
SHARD_MAX_RETRIES = 1
|
| 237 |
+
# Whole-fan-out poll backstop. This is NOT a per-round reaper: each shard
|
| 238 |
+
# job carries its own ``EVAL_JOB_TIMEOUT`` (which bounds RUNNING work and
|
| 239 |
+
# turns a genuinely-hung job into ERROR), and a shard sitting QUEUED is
|
| 240 |
+
# healthy β it is just waiting for a10g-large capacity past the account's
|
| 241 |
+
# ~8 concurrent slots. Cancelling a QUEUED shard only throws away its place
|
| 242 |
+
# in the GPU queue and makes a capacity crunch worse, so the poll loop never
|
| 243 |
+
# does that. This constant is a single generous absolute ceiling (hours),
|
| 244 |
+
# purely so the poll loop can't spin forever if a shard never reaches a
|
| 245 |
+
# terminal stage; on hitting it the caller still reconciles against durable
|
| 246 |
+
# staging artifacts before declaring failure. Env-tunable for very large
|
| 247 |
+
# fan-outs behind a deep GPU queue.
|
| 248 |
+
SHARD_POLL_DEADLINE_SECONDS = int(
|
| 249 |
+
os.getenv("CADGENBENCH_SHARD_POLL_DEADLINE_SECONDS", str(6 * 60 * 60))
|
| 250 |
+
)
|
| 251 |
|
| 252 |
# One HfApi client per process. HF_TOKEN is picked up from the env at
|
| 253 |
# construction time and reused for every call.
|
|
|
|
| 1165 |
failures = _poll_shards_until_done(
|
| 1166 |
submission_id, submission_blob_url, shards,
|
| 1167 |
)
|
| 1168 |
+
|
| 1169 |
+
# Reconcile-before-fail: outputs are the source of truth. Even when a
|
| 1170 |
+
# shard was flagged ERROR or caught by the poll backstop, its fixtures
|
| 1171 |
+
# may all be in staging (it completed late, or wrote results before
|
| 1172 |
+
# erroring). Always attempt the merge from whatever landed; only fail
|
| 1173 |
+
# if the merged set genuinely can't cover every expected fixture.
|
| 1174 |
if failures:
|
| 1175 |
+
logger.warning(
|
| 1176 |
+
"Sharded eval for %s: %d shard(s) did not COMPLETE (%s); "
|
| 1177 |
+
"reconciling from staging before deciding.",
|
| 1178 |
+
submission_id, len(failures), "; ".join(failures),
|
| 1179 |
+
)
|
| 1180 |
+
progress.publish(
|
| 1181 |
+
submission_id,
|
| 1182 |
+
progress.RUNNING,
|
| 1183 |
+
"Some chunks reported issues β checking for late resultsβ¦",
|
| 1184 |
+
)
|
| 1185 |
+
else:
|
| 1186 |
+
progress.publish(
|
| 1187 |
+
submission_id,
|
| 1188 |
+
progress.RUNNING,
|
| 1189 |
+
"All chunks evaluated β merging resultsβ¦",
|
| 1190 |
+
)
|
| 1191 |
+
|
| 1192 |
+
try:
|
| 1193 |
+
summary = _merge_shards_and_publish(
|
| 1194 |
+
submission_id, list(shards.keys()), fixture_names,
|
| 1195 |
+
)
|
| 1196 |
+
except Exception as merge_err: # noqa: BLE001 - map to a failed row
|
| 1197 |
+
if not failures:
|
| 1198 |
+
# Every shard COMPLETED but the merge itself broke: a real
|
| 1199 |
+
# error, surfaced to _run_worker's handler.
|
| 1200 |
+
raise
|
| 1201 |
+
# Genuinely incomplete. Tear down any shard job still running so
|
| 1202 |
+
# it stops billing now that the submission is failing, then fail.
|
| 1203 |
+
_cancel_orphan_shard_jobs(shards)
|
| 1204 |
reason = ("sharded eval failed: " + "; ".join(failures))[
|
| 1205 |
:FAILURE_REASON_MAX_CHARS
|
| 1206 |
]
|
|
|
|
| 1208 |
progress.publish(
|
| 1209 |
submission_id, progress.FAILED, _failed_progress_message(reason),
|
| 1210 |
)
|
| 1211 |
+
logger.warning(
|
| 1212 |
+
"Sharded eval for %s failed; reconcile could not finalize "
|
| 1213 |
+
"(%s): %s", submission_id, merge_err, reason,
|
| 1214 |
+
)
|
| 1215 |
return
|
| 1216 |
|
| 1217 |
+
if failures:
|
| 1218 |
+
logger.info(
|
| 1219 |
+
"Sharded eval for %s reconciled: all fixtures present in "
|
| 1220 |
+
"staging despite %d non-completed shard(s).",
|
| 1221 |
+
submission_id, len(failures),
|
| 1222 |
+
)
|
|
|
|
|
|
|
| 1223 |
_flip_row_to_completed(submission_id, summary)
|
| 1224 |
progress.publish(
|
| 1225 |
submission_id, progress.COMPLETED, _completed_progress_message(summary),
|
|
|
|
| 1328 |
|
| 1329 |
|
| 1330 |
def _cancel_shard_job(state: dict[str, Any]) -> None:
|
| 1331 |
+
"""Best-effort cancel of a shard's in-flight job.
|
| 1332 |
|
| 1333 |
+
Used on the final failure path (via :func:`_cancel_orphan_shard_jobs`)
|
| 1334 |
+
so a shard still QUEUED/RUNNING when the submission is being failed is
|
| 1335 |
+
torn down instead of left to run and keep billing. Best-effort: a
|
| 1336 |
+
failure is logged and ignored, since a lingering job only ever
|
| 1337 |
+
overwrites its own idempotent staging prefix.
|
| 1338 |
"""
|
| 1339 |
job_id = state.get("job_id")
|
| 1340 |
if not job_id:
|
|
|
|
| 1347 |
namespace=EVAL_JOB_NAMESPACE,
|
| 1348 |
token=_jobs_token(),
|
| 1349 |
)
|
| 1350 |
+
logger.info("Cancelled orphan shard job %s", job_id)
|
| 1351 |
except Exception as e: # noqa: BLE001 - cancel is best-effort
|
| 1352 |
logger.warning("Could not cancel shard job %s: %s", job_id, e)
|
| 1353 |
|
| 1354 |
|
| 1355 |
+
def _cancel_orphan_shard_jobs(shards: dict[str, dict[str, Any]]) -> None:
|
| 1356 |
+
"""Cancel every shard job that never reached COMPLETED.
|
| 1357 |
+
|
| 1358 |
+
Called once, on the final failure path, after reconcile has confirmed
|
| 1359 |
+
the submission cannot be finalized. A shard still QUEUED/RUNNING (or
|
| 1360 |
+
one flagged ERROR/FAILED whose HF job lingers) is cancelled so it stops
|
| 1361 |
+
consuming GPU/billing after the submission has already been failed.
|
| 1362 |
+
"""
|
| 1363 |
+
for st in shards.values():
|
| 1364 |
+
if st.get("stage") != "COMPLETED" and st.get("job_id"):
|
| 1365 |
+
_cancel_shard_job(st)
|
| 1366 |
+
|
| 1367 |
+
|
| 1368 |
def _poll_shards_until_done(
|
| 1369 |
submission_id: str,
|
| 1370 |
submission_blob_url: str,
|
|
|
|
| 1372 |
) -> list[str]:
|
| 1373 |
"""Poll every shard to terminal, retrying only ERROR shards.
|
| 1374 |
|
| 1375 |
+
A single thread sweeps all shards each tick (``inspect_job`` calls
|
| 1376 |
+
are cheap). A shard that is QUEUED (waiting for a10g-large capacity
|
| 1377 |
+
past the account's concurrent-job cap) or RUNNING is *healthy* and
|
| 1378 |
+
is simply waited on β its own job ``EVAL_JOB_TIMEOUT`` bounds RUNNING
|
| 1379 |
+
work, and HF turns a genuinely-hung job into ERROR. Only an ERROR
|
| 1380 |
+
shard is re-dispatched, up to :data:`SHARD_MAX_RETRIES`. The loop
|
| 1381 |
+
never cancels a healthy QUEUED/RUNNING shard: doing so throws away
|
| 1382 |
+
its place in the GPU queue and makes a capacity crunch strictly
|
| 1383 |
+
worse (the bug that stranded otherwise-successful runs).
|
| 1384 |
+
|
| 1385 |
+
Returns a list of ``"<shard_id>: <reason>"`` for shards that did not
|
| 1386 |
+
COMPLETE (empty means every shard COMPLETED). This is a *signal, not
|
| 1387 |
+
a verdict*: the caller reconciles against durable staging artifacts
|
| 1388 |
+
before declaring failure, because a shard flagged ERROR or caught by
|
| 1389 |
+
the backstop may still have written all of its per-fixture outputs.
|
| 1390 |
Transient ``inspect_job`` failures retry up to
|
| 1391 |
:data:`JOB_POLL_MAX_CONSECUTIVE_ERRORS` before raising.
|
| 1392 |
|
| 1393 |
+
:data:`SHARD_POLL_DEADLINE_SECONDS` is a single generous absolute
|
| 1394 |
+
backstop (hours), not a per-round reaper: it only stops the loop
|
| 1395 |
+
spinning forever if a shard never reaches a terminal stage.
|
|
|
|
|
|
|
|
|
|
| 1396 |
"""
|
| 1397 |
deadline = time.monotonic() + SHARD_POLL_DEADLINE_SECONDS
|
|
|
|
| 1398 |
consecutive_errors = 0
|
| 1399 |
last_done = -1
|
| 1400 |
total = len(shards)
|
|
|
|
| 1457 |
"Shard %s FAILED after %d attempt(s): %s",
|
| 1458 |
shard_id, st["attempts"], st["message"],
|
| 1459 |
)
|
| 1460 |
+
# QUEUED / RUNNING (or any other non-terminal stage): healthy,
|
| 1461 |
+
# just not done. Wait β the job's own EVAL_JOB_TIMEOUT bounds
|
| 1462 |
+
# it. No Space-side cancellation or re-dispatch.
|
| 1463 |
|
| 1464 |
if time.monotonic() >= deadline:
|
| 1465 |
+
# Absolute backstop reached. Do NOT cancel here β a shard may
|
| 1466 |
+
# be finishing right now, and its outputs are the source of
|
| 1467 |
+
# truth. Mark the non-terminal ones so the caller reconciles
|
| 1468 |
+
# against staging; genuinely-orphaned jobs are torn down on
|
| 1469 |
+
# the failure path once reconcile confirms they're incomplete.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1470 |
for shard_id, st in shards.items():
|
| 1471 |
if st["stage"] not in ("COMPLETED", "FAILED"):
|
| 1472 |
st["stage"] = "FAILED"
|
| 1473 |
st["message"] = (
|
| 1474 |
+
f"shard did not reach a terminal stage within the "
|
| 1475 |
+
f"{SHARD_POLL_DEADLINE_SECONDS}s poll backstop"
|
|
|
|
| 1476 |
)
|
| 1477 |
break
|
| 1478 |
time.sleep(JOB_POLL_INTERVAL_SECONDS)
|
|
|
|
| 1837 |
)
|
| 1838 |
|
| 1839 |
|
| 1840 |
+
# ---------------------------------------------------------------------------
|
| 1841 |
+
# Reconcile: finalize a submission from durable artifacts alone
|
| 1842 |
+
# ---------------------------------------------------------------------------
|
| 1843 |
+
|
| 1844 |
+
|
| 1845 |
+
def _expected_fixtures(submission_id: str) -> list[str]:
|
| 1846 |
+
"""The fixture (sample) names a submission was expected to cover.
|
| 1847 |
+
|
| 1848 |
+
Read from the submitted ``submissions/<id>.zip`` top-level sample
|
| 1849 |
+
dirs β the same set the eval fans out over. Used to verify staging
|
| 1850 |
+
shards are complete before finalizing a reconstructed run, without
|
| 1851 |
+
threading the in-memory fixture list through a dead worker.
|
| 1852 |
+
"""
|
| 1853 |
+
zip_path = hf_hub_download(
|
| 1854 |
+
repo_id=HF_SUBMISSIONS_REPO,
|
| 1855 |
+
repo_type="dataset",
|
| 1856 |
+
filename=f"{SUBMISSIONS_DIR}/{submission_id}.zip",
|
| 1857 |
+
force_download=True,
|
| 1858 |
+
)
|
| 1859 |
+
with zipfile.ZipFile(zip_path) as zf:
|
| 1860 |
+
tops = {n.split("/")[0] for n in zf.namelist() if "/" in n}
|
| 1861 |
+
return sorted(
|
| 1862 |
+
t for t in tops
|
| 1863 |
+
if t and t != "meta.json" and not t.startswith("__")
|
| 1864 |
+
)
|
| 1865 |
+
|
| 1866 |
+
|
| 1867 |
+
def _reconcile_submission(submission_id: str) -> tuple[str, str]:
|
| 1868 |
+
"""Try to finalize a stranded submission from artifacts alone.
|
| 1869 |
+
|
| 1870 |
+
Outputs are the source of truth: a submission whose eval actually
|
| 1871 |
+
produced results β a published report bundle, or staging shards that
|
| 1872 |
+
cover every expected fixture β is completed even though its worker
|
| 1873 |
+
died and left the row ``pending``. Only a submission with no usable
|
| 1874 |
+
artifacts is failed. Idempotent and safe to re-run.
|
| 1875 |
+
|
| 1876 |
+
Returns ``(outcome, detail)`` with ``outcome`` in
|
| 1877 |
+
``{"completed", "failed"}``; the row flip is performed here.
|
| 1878 |
+
"""
|
| 1879 |
+
# 1. Single-job (or already-merged) path: a complete report bundle
|
| 1880 |
+
# means the eval finished and only the row flip was lost.
|
| 1881 |
+
try:
|
| 1882 |
+
summary = _fetch_run_summary_from_report(submission_id)
|
| 1883 |
+
except Exception: # noqa: BLE001 - no usable report; try shard merge
|
| 1884 |
+
summary = None
|
| 1885 |
+
if summary is not None:
|
| 1886 |
+
_flip_row_to_completed(submission_id, summary)
|
| 1887 |
+
return "completed", "recovered from an existing report bundle"
|
| 1888 |
+
|
| 1889 |
+
# 2. Sharded path: merge the staging shards iff they cover every
|
| 1890 |
+
# expected fixture. _merge_shards_and_publish raises otherwise.
|
| 1891 |
+
try:
|
| 1892 |
+
expected = _expected_fixtures(submission_id)
|
| 1893 |
+
except Exception as e: # noqa: BLE001 - can't even read the submission
|
| 1894 |
+
reason = f"reconcile could not read submission fixtures: {e}"
|
| 1895 |
+
_flip_row_to_failed(submission_id, reason[:FAILURE_REASON_MAX_CHARS])
|
| 1896 |
+
return "failed", reason
|
| 1897 |
+
try:
|
| 1898 |
+
summary = _merge_shards_and_publish(submission_id, [], expected)
|
| 1899 |
+
except Exception as e: # noqa: BLE001 - no complete artifacts
|
| 1900 |
+
reason = f"{STUCK_PENDING_REASON}; no complete artifacts to reconcile"
|
| 1901 |
+
_flip_row_to_failed(submission_id, reason[:FAILURE_REASON_MAX_CHARS])
|
| 1902 |
+
return "failed", f"{reason} ({e})"
|
| 1903 |
+
|
| 1904 |
+
_flip_row_to_completed(submission_id, summary)
|
| 1905 |
+
_cleanup_shard_artifacts(submission_id)
|
| 1906 |
+
return "completed", f"merged {len(expected)} fixtures from staging"
|
| 1907 |
+
|
| 1908 |
+
|
| 1909 |
# ---------------------------------------------------------------------------
|
| 1910 |
# Boot-time stuck-pending sweep
|
| 1911 |
# ---------------------------------------------------------------------------
|
| 1912 |
|
| 1913 |
|
| 1914 |
def _sweep_stuck_pending() -> None:
|
| 1915 |
+
"""Reconcile pending rows whose worker died, then decide their fate.
|
| 1916 |
+
|
| 1917 |
+
A ``pending`` row whose worker died (Space restart, OOM, crash) has
|
| 1918 |
+
no one to finalize it. At boot no worker thread from the previous
|
| 1919 |
+
process survives, so any such stale row is orphaned. Rather than
|
| 1920 |
+
blindly failing it β which is exactly how a fully-successful eval got
|
| 1921 |
+
marked failed with its outputs stranded β each stale row is run
|
| 1922 |
+
through :func:`_reconcile_submission`: if its report bundle or
|
| 1923 |
+
staging shards are complete it is finalized to ``completed``, else it
|
| 1924 |
+
is failed. The "submitted_at older than 30 min" gate avoids racing a
|
| 1925 |
+
submission whose worker is legitimately starting in *this* process.
|
| 1926 |
+
Runs once per process at module-import time inside a daemon thread so
|
| 1927 |
+
app boot doesn't block on the Hub read.
|
| 1928 |
"""
|
| 1929 |
try:
|
| 1930 |
body = _download_results_jsonl()
|
|
|
|
| 1966 |
stuck_ids.append(sid)
|
| 1967 |
|
| 1968 |
if not stuck_ids:
|
| 1969 |
+
logger.info("Boot reconcile sweep: nothing stale")
|
| 1970 |
return
|
| 1971 |
|
| 1972 |
logger.warning(
|
| 1973 |
+
"Boot reconcile sweep: reconciling %d stale pending row(s): %s",
|
| 1974 |
len(stuck_ids), stuck_ids,
|
| 1975 |
)
|
| 1976 |
for sid in stuck_ids:
|
| 1977 |
try:
|
| 1978 |
+
outcome, detail = _reconcile_submission(sid)
|
| 1979 |
+
logger.warning(
|
| 1980 |
+
"Boot reconcile: %s -> %s (%s)", sid, outcome, detail,
|
| 1981 |
+
)
|
| 1982 |
except Exception as e: # noqa: BLE001 - log + carry on per-row
|
| 1983 |
logger.exception(
|
| 1984 |
+
"Boot reconcile failed for %s (%s: %s)",
|
| 1985 |
sid, type(e).__name__, e,
|
| 1986 |
)
|
| 1987 |
|