66
77from pydantic import BaseModel
88
9- from transformerlab .compute_providers .models import ClusterConfig
9+ from transformerlab .compute_providers .models import ClusterConfig , ClusterState
1010from transformerlab .services import job_service , quota_service
1111from transformerlab .services .provider_service import get_provider_by_id , get_provider_instance
1212from transformerlab .db .session import async_session
1313from lab import dirs as lab_dirs
14- from lab .job_status import JobStatus
14+ from lab .job_status import JobStatus , TERMINAL_STATUSES
1515
1616logger = logging .getLogger (__name__ )
1717
@@ -40,6 +40,13 @@ class LocalLaunchWorkItem(BaseModel):
4040_LAUNCH_MAX_WORKERS = int (os .environ .get ("TFL_LAUNCH_MAX_WORKERS" , "2" ))
4141_launch_executor = ThreadPoolExecutor (max_workers = _LAUNCH_MAX_WORKERS , thread_name_prefix = "local-launch" )
4242
43+ # How often (seconds) to check whether a launched local job's process has exited.
44+ # This gates how quickly the queue advances to the next job once one finishes.
45+ _COMPLETION_POLL_INTERVAL = float (os .environ .get ("TFL_LOCAL_JOB_POLL_INTERVAL" , "3" ))
46+
47+ # Cluster states that mean the local job's process is no longer running.
48+ _FINISHED_CLUSTER_STATES = {ClusterState .DOWN , ClusterState .FAILED , ClusterState .STOPPED }
49+
4350
4451async def enqueue_local_launch (
4552 job_id : str ,
@@ -95,7 +102,15 @@ async def _run_and_drain(item: LocalLaunchWorkItem) -> None:
95102
96103
97104async def _process_launch_item (item : LocalLaunchWorkItem ) -> None :
98- """Process a single local launch work item."""
105+ """Process a single local launch work item.
106+
107+ For batch (non-interactive) jobs this does NOT return until the launched job's
108+ process has exited, so the drain loop keeps the local provider strictly serial:
109+ one job executes at a time. Interactive jobs are long-lived and return as soon as
110+ the cluster is up so they don't block the queue forever.
111+ """
112+ provider_instance = None
113+ launched_ok = False
99114 async with async_session () as session :
100115 lab_dirs .set_organization_id (item .team_id )
101116 try :
@@ -204,6 +219,7 @@ def _launch_with_org_context():
204219 percent = 100 ,
205220 message = "Local cluster started" ,
206221 )
222+ launched_ok = True
207223 except Exception as exc : # noqa: BLE001
208224 print (f"[local_provider_queue] Job { item .job_id } : unexpected error while processing launch item: { exc } " )
209225 if item .quota_hold_id :
@@ -220,3 +236,67 @@ def _launch_with_org_context():
220236 await session .commit ()
221237 finally :
222238 lab_dirs .set_organization_id (None )
239+
240+ # Serialize EXECUTION, not just launch: launch_cluster() spawns the job as a
241+ # detached background process and returns immediately. Block here until that
242+ # process exits so the drain loop won't start the next local job until this one
243+ # is done. Interactive jobs are long-lived servers, so they skip the wait.
244+ if launched_ok and provider_instance is not None and item .initial_status != JobStatus .INTERACTIVE :
245+ await _wait_for_local_job_completion (provider_instance , item )
246+
247+
248+ async def _wait_for_local_job_completion (provider_instance , item : LocalLaunchWorkItem ) -> None :
249+ """Block until the local job's launched process has exited.
250+
251+ Detects completion two ways and returns when either fires:
252+ - the per-job process is gone (get_cluster_status reports a finished state), or
253+ - the job reached a terminal status out-of-band (e.g. the user stopped it, or the
254+ background status poller already finalized it).
255+ """
256+ # get_cluster_status reads the per-job pid file from extra_config["workspace_dir"],
257+ # which is per-job and lives in the cluster_config rather than the provider record.
258+ workspace_dir = (item .cluster_config .provider_config or {}).get ("workspace_dir" )
259+ if workspace_dir and hasattr (provider_instance , "extra_config" ):
260+ provider_instance .extra_config ["workspace_dir" ] = workspace_dir
261+
262+ def _check_cluster_state () -> ClusterState :
263+ lab_dirs .set_organization_id (item .team_id )
264+ try :
265+ return provider_instance .get_cluster_status (item .cluster_name ).state
266+ finally :
267+ lab_dirs .set_organization_id (None )
268+
269+ consecutive_errors = 0
270+ lab_dirs .set_organization_id (item .team_id )
271+ try :
272+ while True :
273+ await asyncio .sleep (_COMPLETION_POLL_INTERVAL )
274+
275+ # Out-of-band terminal status (stopped/cancelled/already finalized).
276+ try :
277+ job = await job_service .job_get (item .job_id , item .experiment_id )
278+ if job and job .get ("status" ) in TERMINAL_STATUSES :
279+ print (f"[local_provider_queue] Job { item .job_id } : terminal status reached; releasing queue" )
280+ return
281+ except Exception as exc : # noqa: BLE001
282+ print (f"[local_provider_queue] Job { item .job_id } : status read failed during wait: { exc } " )
283+
284+ # Primary signal: has the launched process exited?
285+ try :
286+ state = await asyncio .to_thread (_check_cluster_state )
287+ consecutive_errors = 0
288+ if state in _FINISHED_CLUSTER_STATES :
289+ print (f"[local_provider_queue] Job { item .job_id } : process exited (state={ state } ); releasing queue" )
290+ return
291+ except Exception as exc : # noqa: BLE001
292+ consecutive_errors += 1
293+ print (
294+ f"[local_provider_queue] Job { item .job_id } : status check failed during wait "
295+ f"({ consecutive_errors } ): { exc } "
296+ )
297+ # Don't wedge the queue forever if status checks keep failing.
298+ if consecutive_errors >= 3 :
299+ print (f"[local_provider_queue] Job { item .job_id } : giving up wait after repeated errors" )
300+ return
301+ finally :
302+ lab_dirs .set_organization_id (None )
0 commit comments