Skip to content

Commit 5a80ee4

Browse files
authored
Merge pull request #1976 from transformerlab/fix/job-delete-and-cli-commands
Fix job delete endpoint and add lab job delete CLI commands
2 parents 1088e41 + 2199191 commit 5a80ee4

16 files changed

Lines changed: 450 additions & 31 deletions

File tree

.agents/skills/transformerlab-cli/SKILL.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -505,7 +505,7 @@ Provider configs (`api_token`, `api_key`, `ssh_key_path`) contain secrets. If th
505505
3. **Use `--format json`** when you need to parse output, but be prepared to fall back to pretty output parsing if it doesn't work
506506
4. **`--no-interactive` on `task queue` silently uses the DEFAULT provider (Local).** There is no `--provider` flag. To target a specific provider, you must drive the interactive prompts (see "Selecting a provider" below).
507507
5. **`task add` has no `--yes` flag** — pipe `echo "y"` to confirm: `echo "y" | lab task add ./my-task`
508-
6. **Skip confirmation on destructive commands:** use `--no-interactive` for `provider delete`, and `--yes` / `-y` for `model delete` / `dataset delete` (the flag names differ — verify with `--help`)
508+
6. **Skip confirmation on destructive commands:** use `--no-interactive` for `provider delete`, `job delete`, and `job delete-all`; use `--yes` / `-y` for `model delete` / `dataset delete` (the flag names differ — verify with `--help`)
509509
7. **Never use `job monitor`** — it launches a TUI that blocks; use `job list` + `job task-logs` instead
510510
8. **Never use `task interactive`** unless the user specifically requests an interactive session
511511
9. **`job task-logs --follow`** streams continuously and blocks until the job finishes — use when the user wants real-time monitoring
@@ -662,6 +662,8 @@ This applies to launching jobs, fetching logs, checking cluster status, and ever
662662
| `lab job artifacts <id>` | List job artifacts | Yes |
663663
| `lab job download <id>` | Download artifacts (`--file` for glob) | Yes |
664664
| `lab job stop <id>` | Stop a running job | Yes |
665+
| `lab job delete <id>` | Delete a job (`--no-interactive` to skip prompt) | Yes |
666+
| `lab job delete-all` | Delete all jobs in the current experiment (`--no-interactive` to skip prompt) | Yes |
665667
| `lab provider list` | List compute providers | No |
666668
| `lab provider info <id>` | Show provider details | No |
667669
| `lab provider add` | Add a new provider | No |

api/localprovider_pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ dependencies = [
3232
"soundfile==0.13.1",
3333
"tensorboardX==2.6.2.2",
3434
"timm==1.0.15",
35-
"transformerlab==0.1.28",
35+
"transformerlab==0.1.29",
3636
"transformerlab-inference==0.2.52",
3737
"transformers==4.57.1",
3838
"wandb==0.23.1",

api/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ dependencies = [
2525
"python-dotenv==1.1.0",
2626
"python-multipart==0.0.20",
2727
"sqlalchemy[asyncio]==2.0.40",
28-
"transformerlab==0.1.28",
28+
"transformerlab==0.1.29",
2929
"transformerlab-inference==0.2.52",
3030
"uvicorn==0.35.0",
3131
"watchfiles==1.0.5",

api/test/api/test_experiment_jobs.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,11 @@ def test_job_creation_and_management(client):
3030
assert resp.status_code in (200, 404)
3131

3232
# Test job delete
33-
resp = client.get("/experiment/alpha/jobs/delete/1")
33+
resp = client.delete("/experiment/alpha/jobs/1")
3434
assert resp.status_code in (200, 404)
3535

3636
# Test delete all jobs
37-
resp = client.get("/experiment/alpha/jobs/delete_all")
37+
resp = client.delete("/experiment/alpha/jobs/delete_all")
3838
assert resp.status_code == 200
3939

4040

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Tests for job_service.job_delete and job_delete_all.
2+
3+
These regression tests cover the bug where:
4+
- job_delete swallowed exceptions (silent failure, stale cache)
5+
- The per-job cache was never invalidated after delete, so terminal-status
6+
jobs (COMPLETE/FAILED/STOPPED) cached for 7 days continued to appear in
7+
/jobs/list with their pre-delete status.
8+
"""
9+
10+
from unittest.mock import AsyncMock, MagicMock, patch
11+
12+
import pytest
13+
14+
from transformerlab.services import job_service
15+
16+
17+
@pytest.mark.asyncio
18+
async def test_job_delete_invalidates_cache():
19+
"""After job_delete the per-job cache key must be cleared."""
20+
fake_job = MagicMock()
21+
fake_job.delete = AsyncMock()
22+
23+
with (
24+
patch.object(job_service, "_resolve_full_job_id", AsyncMock(return_value="abc123")),
25+
patch.object(job_service.Job, "get", AsyncMock(return_value=fake_job)),
26+
patch.object(job_service.cache, "invalidate", AsyncMock()) as mock_invalidate,
27+
patch.object(job_service.cache, "delete", AsyncMock()) as mock_delete_cache,
28+
):
29+
await job_service.job_delete("abc123", experiment_id="exp1")
30+
31+
fake_job.delete.assert_awaited_once()
32+
mock_invalidate.assert_awaited_once_with("job:abc123")
33+
mock_delete_cache.assert_awaited_once_with(job_service._job_cache_key("abc123"))
34+
35+
36+
@pytest.mark.asyncio
37+
async def test_job_delete_propagates_filenotfound():
38+
"""job_delete must NOT swallow FileNotFoundError so the API can return 404."""
39+
with (
40+
patch.object(job_service, "_resolve_full_job_id", AsyncMock(return_value=None)),
41+
patch.object(
42+
job_service.Job,
43+
"get",
44+
AsyncMock(side_effect=FileNotFoundError("no such job")),
45+
),
46+
):
47+
with pytest.raises(FileNotFoundError):
48+
await job_service.job_delete("missing", experiment_id="exp1")
49+
50+
51+
@pytest.mark.asyncio
52+
async def test_job_delete_all_invalidates_cache_for_each_job():
53+
"""job_delete_all must invalidate the per-job cache for every job."""
54+
fake_experiment = MagicMock()
55+
fake_experiment.delete_all_jobs = AsyncMock()
56+
57+
with (
58+
patch.object(
59+
job_service,
60+
"_list_experiment_job_ids",
61+
AsyncMock(return_value=["jobA", "jobB", "jobC"]),
62+
),
63+
patch.object(job_service, "Experiment", return_value=fake_experiment),
64+
patch.object(job_service.cache, "invalidate", AsyncMock()) as mock_invalidate,
65+
patch.object(job_service.cache, "delete", AsyncMock()) as mock_delete_cache,
66+
):
67+
deleted = await job_service.job_delete_all(experiment_id="exp1")
68+
69+
assert deleted == 3
70+
fake_experiment.delete_all_jobs.assert_awaited_once()
71+
assert mock_invalidate.await_count == 3
72+
assert mock_delete_cache.await_count == 3
73+
invalidated_tags = {call.args[0] for call in mock_invalidate.await_args_list}
74+
assert invalidated_tags == {"job:jobA", "job:jobB", "job:jobC"}
75+
76+
77+
@pytest.mark.asyncio
78+
async def test_job_delete_all_handles_empty_experiment():
79+
"""job_delete_all on an empty experiment returns 0 and doesn't error."""
80+
fake_experiment = MagicMock()
81+
fake_experiment.delete_all_jobs = AsyncMock()
82+
83+
with (
84+
patch.object(job_service, "_list_experiment_job_ids", AsyncMock(return_value=[])),
85+
patch.object(job_service, "Experiment", return_value=fake_experiment),
86+
patch.object(job_service.cache, "invalidate", AsyncMock()) as mock_invalidate,
87+
):
88+
deleted = await job_service.job_delete_all(experiment_id="exp1")
89+
90+
assert deleted == 0
91+
mock_invalidate.assert_not_awaited()
92+
93+
94+
@pytest.mark.asyncio
95+
async def test_job_delete_all_returns_zero_for_none_experiment():
96+
"""Passing None as experiment_id should return 0 without side effects."""
97+
with patch.object(job_service.cache, "invalidate", AsyncMock()) as mock_invalidate:
98+
deleted = await job_service.job_delete_all(experiment_id=None)
99+
100+
assert deleted == 0
101+
mock_invalidate.assert_not_awaited()

api/test/api/test_jobs.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,12 @@ def test_jobs_list(client):
2121

2222

2323
def test_jobs_delete_all(client):
24-
resp = client.get("/experiment/1/jobs/delete_all")
24+
resp = client.delete("/experiment/1/jobs/delete_all")
2525
assert resp.status_code == 200
2626
data = resp.json()
27-
assert "message" in data or data == []
28-
if "message" in data:
29-
assert isinstance(data["message"], str)
27+
assert data.get("message") == "OK"
28+
assert "deleted" in data
29+
assert isinstance(data["deleted"], int)
3030

3131

3232
def test_jobs_get_by_id(client):
@@ -35,10 +35,16 @@ def test_jobs_get_by_id(client):
3535

3636

3737
def test_jobs_delete_by_id(client):
38-
resp = client.get("/experiment/1/jobs/delete/1")
38+
resp = client.delete("/experiment/1/jobs/1")
3939
assert resp.status_code in (200, 404)
4040

4141

42+
def test_jobs_delete_by_id_returns_404_when_missing(client):
43+
"""A nonexistent job id should now return 404 (not the previous silent 200)."""
44+
resp = client.delete("/experiment/1/jobs/nonexistent-job-id-xyz")
45+
assert resp.status_code == 404
46+
47+
4248
def test_jobs_get_template(client):
4349
resp = client.get("/experiment/1/jobs/template/1")
4450
assert resp.status_code in (200, 404)

api/transformerlab/routers/experiment/jobs.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import csv
33
from fnmatch import fnmatch
44
import json
5+
import logging
56
import os
67
import posixpath
78
from typing import List, Optional
@@ -41,6 +42,8 @@
4142
from transformerlab.services import asset_version_service
4243

4344

45+
logger = logging.getLogger(__name__)
46+
4447
router = APIRouter(prefix="/jobs", tags=["train"])
4548

4649

@@ -123,9 +126,14 @@ async def jobs_get_all(
123126
return jobs
124127

125128

126-
@router.get("/delete/{job_id}")
127-
async def job_delete(job_id: str, experimentId: str):
128-
await job_service.job_delete(job_id, experiment_id=experimentId)
129+
async def _job_delete_handler(job_id: str, experimentId: str) -> dict:
130+
try:
131+
await job_service.job_delete(job_id, experiment_id=experimentId)
132+
except FileNotFoundError:
133+
raise HTTPException(status_code=404, detail=f"Job {job_id} not found in experiment {experimentId}")
134+
except Exception as e:
135+
logger.exception("Failed to delete job (job_id=%s, experiment=%s)", job_id, experimentId)
136+
raise HTTPException(status_code=500, detail=f"Failed to delete job {job_id}: {e}")
129137
return {"message": "OK"}
130138

131139

@@ -174,10 +182,23 @@ async def stop_job(job_id: str, experimentId: str):
174182
return {"message": "OK"}
175183

176184

177-
@router.get("/delete_all")
185+
async def _job_delete_all_handler(experimentId: str) -> dict:
186+
try:
187+
deleted = await job_service.job_delete_all(experiment_id=experimentId)
188+
except Exception as e:
189+
logger.exception("Failed to delete all jobs (experiment=%s)", experimentId)
190+
raise HTTPException(status_code=500, detail=f"Failed to delete all jobs: {e}")
191+
return {"message": "OK", "deleted": deleted}
192+
193+
194+
@router.delete("/delete_all")
178195
async def job_delete_all(experimentId: str):
179-
await job_service.job_delete_all(experiment_id=experimentId)
180-
return {"message": "OK"}
196+
return await _job_delete_all_handler(experimentId)
197+
198+
199+
@router.delete("/{job_id}")
200+
async def job_delete(job_id: str, experimentId: str):
201+
return await _job_delete_handler(job_id, experimentId)
181202

182203

183204
@router.get("/{job_id}")

api/transformerlab/services/job_service.py

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -347,18 +347,51 @@ async def job_count_running():
347347
return await Job.count_running_jobs()
348348

349349

350-
async def job_delete_all(experiment_id):
351-
if experiment_id is not None:
352-
experiment = Experiment(experiment_id)
353-
await experiment.delete_all_jobs()
350+
async def job_delete_all(experiment_id) -> int:
351+
"""Soft-delete all jobs in an experiment.
352+
353+
Returns the number of jobs that existed when the operation started so the
354+
caller can report progress. Per-job cache entries are invalidated so terminal
355+
statuses cached for 7 days don't keep deleted jobs visible in /jobs/list.
356+
"""
357+
if experiment_id is None:
358+
return 0
359+
360+
job_ids = await _list_experiment_job_ids(experiment_id)
361+
if not job_ids:
362+
return 0
363+
364+
experiment = Experiment(experiment_id)
365+
await experiment.delete_all_jobs()
366+
367+
for job_id in job_ids:
368+
try:
369+
await cache.invalidate(f"job:{job_id}")
370+
await cache.delete(_job_cache_key(job_id))
371+
except Exception:
372+
logger.exception("Failed to invalidate cache after delete_all (job_id=%s)", job_id)
373+
374+
return len(job_ids)
354375

355376

356377
async def job_delete(job_id, experiment_id):
378+
"""Soft-delete a job (sets status to DELETED).
379+
380+
Raises FileNotFoundError if the job directory does not exist; other exceptions
381+
propagate so the caller can return a meaningful HTTP error.
382+
"""
383+
resolved_id = await _resolve_full_job_id(str(job_id), str(experiment_id))
384+
actual_id = resolved_id or str(job_id)
385+
386+
job = await Job.get(actual_id, experiment_id)
387+
await job.delete()
388+
389+
# Invalidate cache so subsequent listings/reads see the DELETED status.
357390
try:
358-
job = await Job.get(job_id, experiment_id)
359-
await job.delete()
360-
except Exception as e:
361-
print(f"Error deleting job {job_id}: {e}")
391+
await cache.invalidate(f"job:{actual_id}")
392+
await cache.delete(_job_cache_key(actual_id))
393+
except Exception:
394+
logger.exception("Failed to invalidate cache after job delete (job_id=%s)", actual_id)
362395

363396

364397
async def job_update_job_data_insert_key_value(job_id, key, value, experiment_id):

0 commit comments

Comments
 (0)