Keep jobs running across queue restarts - #35
Conversation
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Team Run ID: Comment |
d796613 to
898ae95
Compare
rounakbende10
left a comment
There was a problem hiding this comment.
Code Review: Keep jobs running across queue restarts
Reviewed: all 4 changed files (460+, 127-). Overall this is a well-structured approach to job recovery -- the new COMPLETING/FAILING transitional states, deterministic instance naming, and retry-until-success cleanup loops are sound design choices. Below are the issues I found, ordered by severity.
Critical (confidence 90-100)
1. adopt_running_instance is not protected by self._lock (confidence: 95)
acquire_instance correctly holds self._lock for the entire provision-and-track sequence, but adopt_running_instance mutates self._instances without acquiring the lock. If the idle_cleanup_loop runs concurrently (it holds the lock while iterating _instances), this creates a race: the cleanup loop could delete or evict the instance between the instance_exists check and the dict write, or the dict mutation could interfere with the cleanup iteration.
# Current (no lock):
async def adopt_running_instance(self, model_name, gpu_config):
instance_name = self._pick_instance_name()
if not await self._manager.instance_exists(instance_name):
raise ...
self._instances[instance_name] = NebiusInstanceState(...)
return instance_nameFix: Wrap the body in async with self._lock:, matching acquire_instance.
2. Double _active_job assignment creates a stale-reference window (confidence: 92)
In _worker, _active_job is set before _process_queued_job is awaited, and then _process_queued_job also sets _active_job at its start. The OpenshiftJob object created in _worker (line ~603 in the diff) is a throwaway -- it is never used for actual oc commands. But between the moment _worker sets _active_job and _process_queued_job overwrites it, if a DELETE /jobs/{job_id} request arrives, the cancellation handler will call _active_job[1].cancel() on the outer task, which is the processing task -- this part works. However the _active_job[2] (the OpenshiftJob) reference is the stale throwaway, so any code that uses _active_job[2] for cleanup before _process_queued_job has a chance to overwrite it would operate on the wrong object.
This is not immediately exploitable because delete_job only uses _active_job[0] (job_id) and _active_job[1] (task), but it is a latent bug if future code trusts _active_job[2].
Fix: Remove the _active_job assignment from _worker and let _process_queued_job be the sole owner. Or remove _active_job from _worker entirely and only set it in _process_queued_job.
Important (confidence 80-89)
3. _retry_cancellation and _retry_terminal_job can spin forever if cleanup is permanently broken (confidence: 88)
Both retry loops run indefinitely with a 5-second sleep when cleanup keeps failing. If the OpenShift cluster is misconfigured or the namespace is deleted, these loops block the serial job queue forever -- no subsequent job can ever start.
async def _retry_terminal_job(...):
while not await _finish_terminal_job(...):
await asyncio.sleep(5)Suggestion: Add a maximum retry count or a total timeout (e.g., 10 minutes). After exhausting retries, force the terminal status with a cleanup-failed error and move on. This preserves the serial queue's liveness.
4. _delete_recovered_nebius also spins forever (confidence: 87)
Same pattern as above:
async def _delete_recovered_nebius(job_id):
while True:
try:
await _nebius.delete_recovered_instance()
return
except Exception:
await asyncio.sleep(5)If the Nebius API is permanently unreachable, this blocks the startup path or the processing of recovered terminal jobs indefinitely.
Suggestion: Same as above -- add a retry cap. At worst, log a loud warning and let the idle cleanup loop handle it later.
5. Removal of shutdown Nebius cleanup is intentional but undocumented (confidence: 85)
The old code deleted all Nebius instances on graceful shutdown. The new code removes this entirely, relying on the idle cleanup loop to eventually delete VMs. This is the correct trade-off for surviving restarts (you don't want to kill VMs that active jobs are using), but it means that if the queue service is permanently decommissioned (not just restarted), Nebius VMs will leak until the idle timeout fires. Since terminationGracePeriodSeconds: 60 and Recreate strategy are set, there is a window during rolling restarts where no cleanup loop is running.
Suggestion: Add a comment explaining this design choice. Consider a separate "drain" endpoint or an explicit "delete all VMs" admin action for decommissioning.
6. _delete_harbor_pods iterates all harbor pods in the namespace (confidence: 82)
The new implementation fetches all pods matching app=harbor,harbor-session and then filters by HARBOR_PARENT env var in Python. For namespaces with many concurrent jobs (or leftover pods), this could be slow and memory-intensive compared to the old label-selector approach. The clean_legacy_pods fallback (cleaning pods that lack HARBOR_PARENT entirely) is pragmatic for migration but could accidentally delete pods from other jobs that haven't been updated yet.
) or (self._clean_legacy_pods and not has_parent):
pod_names.append(pod["metadata"]["name"])Suggestion: After migration stabilizes, remove the clean_legacy_pods fallback or gate it behind a flag to prevent cross-job interference.
7. is_resume variable used before definition in _process_queued_job for non-Nebius non-adopt path (confidence: 80)
In _process_queued_job, is_resume is only defined inside the elif nebius_gpu_config is not None and _nebius: block (the non-adopt Nebius provisioning branch). But at line ~526, _run_job is called with openrouter=is_openrouter(server_url) -- this is fine. However, the flow through the function for a non-Nebius, non-adopt job skips all the blocks that define model_config, so model_config remains None and the --model-max-len check is safely skipped. This is correct but fragile -- is_resume is scoped to a branch that only executes for Nebius jobs, making the code hard to follow.
Minor observations (not blocking)
- The
exec envchange in the deployment YAML is good -- ensures SIGTERM propagates to uvicorn rather than the parent shell. backoffLimit: 0on Job specs is a good addition -- prevents Kubernetes from restarting failed pods, which would confuse the monitoring loop.- The
_build_parent_env_shell_stephelper for resume jobs correctly propagatesHARBOR_PARENTinto the resumed config. - The CancelledError handling in
_run_oc_command(job.py) correctly terminates the subprocess before re-raising -- good defensive coding. ORDER BY rowidon list queries is a nice touch for deterministic ordering.
Summary
The core recovery logic is sound: jobs persist their state in SQLite, the new process re-enqueues non-terminal jobs, and the COMPLETING/FAILING states prevent double-cleanup. The main concerns are (1) the missing lock on adopt_running_instance, (2) infinite retry loops that can block the queue, and (3) the double _active_job assignment pattern. Items 1 and 2 should be fixed before merge; item 3 is a latent risk worth cleaning up.
|
Implemented in separate commits:
Verification:
|
1. Unbounded retry loops in adoption pathsThe two
The cleanup paths in this same PR correctly use 2. CancelledError swallowed in
|
Primarily written with GPT 5.6 Sol and OpenCode