Skip to content

Commit bf75598

Browse files
authored
Merge branch 'main' into add/trainium_support
2 parents 2ac4e8c + 3542950 commit bf75598

2 files changed

Lines changed: 97 additions & 5 deletions

File tree

api/transformerlab/services/local_provider_queue.py

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66

77
from pydantic import BaseModel
88

9-
from transformerlab.compute_providers.models import ClusterConfig
9+
from transformerlab.compute_providers.models import ClusterConfig, ClusterState
1010
from transformerlab.services import job_service, quota_service
1111
from transformerlab.services.provider_service import get_provider_by_id, get_provider_instance
1212
from transformerlab.db.session import async_session
1313
from lab import dirs as lab_dirs
14-
from lab.job_status import JobStatus
14+
from lab.job_status import JobStatus, TERMINAL_STATUSES
1515

1616
logger = 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

4451
async def enqueue_local_launch(
4552
job_id: str,
@@ -95,7 +102,15 @@ async def _run_and_drain(item: LocalLaunchWorkItem) -> None:
95102

96103

97104
async 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)

docs/task-execution/03-job-dispatch.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,24 +111,36 @@ This wrapper reports `live_status` (started/finished/crashed) back to the API an
111111
## Local provider queueing
112112

113113
The local provider uses a serialized asyncio queue to ensure only one local job runs at a time.
114+
Remote providers run jobs in parallel (bounded by `remote_provider_queue`'s semaphore); only the
115+
local provider is serialized, because all local jobs share the one host machine.
114116

115117
**Source:** `api/transformerlab/services/local_provider_queue.py`
116118

117119
```
118120
enqueue_local_launch(job_id, provider, cluster_config, ...)
119121
120122
├── Add item to asyncio.Queue
121-
└── Lazy-start _local_launch_worker() if not already running
123+
└── Lazy-start the drain loop if not already running
122124
123-
└── _local_launch_worker() [infinite loop]
125+
└── _run_and_drain() [processes one item, then drains the next]
124126
125127
└── _process_launch_item()
126128
├── Transition job: WAITING → LAUNCHING (or INTERACTIVE)
127129
├── get_provider_instance()
128130
├── provider.launch_cluster() [in thread executor]
131+
├── Wait for the job's process to EXIT before returning
132+
│ (batch jobs only — interactive servers are exempt)
129133
└── Release quota hold on completion
130134
```
131135

136+
**Why the wait matters:** `launch_cluster()` spawns the job as a detached background
137+
process and returns as soon as it has *started* — not when it finishes. Without the
138+
explicit completion wait, the drain loop would launch the next queued job immediately,
139+
so multiple local jobs would execute in parallel and could exhaust the machine. Batch
140+
(`LAUNCHING`) jobs therefore hold the queue until their process exits; interactive jobs
141+
(`INTERACTIVE`) are long-lived servers and return as soon as the cluster is up so they
142+
don't block the queue. Tune the completion poll interval with `TFL_LOCAL_JOB_POLL_INTERVAL`.
143+
132144
## Quota management
133145

134146
For REMOTE jobs, quota is checked and held before launch:

0 commit comments

Comments
 (0)