Skip to content

Commit 4ed936c

Browse files
authored
Merge pull request #1174 from transformerlab/fix/resume-checkpoint
Bring back resume from checkpoints
2 parents f138189 + fcc06a1 commit 4ed936c

6 files changed

Lines changed: 305 additions & 20 deletions

File tree

api/transformerlab/routers/compute_provider.py

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
mask_sensitive_config,
2626
ProviderTemplateLaunchRequest,
2727
ProviderTemplateFileUploadResponse,
28+
ResumeFromCheckpointRequest,
2829
)
2930
from transformerlab.shared.models.models import ProviderType, TeamComputeProvider
3031
from transformerlab.compute_providers.base import ComputeProvider
@@ -1703,6 +1704,230 @@ async def get_sweep_results(
17031704
}
17041705

17051706

1707+
@router.post("/jobs/{job_id}/resume_from_checkpoint")
1708+
async def resume_from_checkpoint(
1709+
job_id: str,
1710+
experimentId: str = Query(..., description="Experiment ID"),
1711+
request: ResumeFromCheckpointRequest = ...,
1712+
user_and_team=Depends(get_user_and_team),
1713+
session: AsyncSession = Depends(get_async_session),
1714+
):
1715+
"""
1716+
Resume a REMOTE job from a checkpoint by creating a new job with the same configuration
1717+
and setting parent_job_id and resumed_from_checkpoint in job_data.
1718+
"""
1719+
import json
1720+
from transformerlab.services import job_service
1721+
from lab.dirs import get_job_checkpoints_dir
1722+
from lab import storage
1723+
import time
1724+
1725+
# Get the original job
1726+
original_job = job_service.job_get(job_id)
1727+
if not original_job or str(original_job.get("experiment_id")) != str(experimentId):
1728+
raise HTTPException(status_code=404, detail="Job not found")
1729+
1730+
# Validate it's a REMOTE job
1731+
if original_job.get("type") != "REMOTE":
1732+
raise HTTPException(status_code=400, detail="Resume from checkpoint is only supported for REMOTE jobs")
1733+
1734+
# Get job_data
1735+
job_data = original_job.get("job_data") or {}
1736+
if not isinstance(job_data, dict):
1737+
try:
1738+
job_data = json.loads(job_data)
1739+
except json.JSONDecodeError:
1740+
job_data = {}
1741+
1742+
# Validate required fields for REMOTE job relaunch
1743+
provider_id = job_data.get("provider_id")
1744+
command = job_data.get("command")
1745+
if not provider_id or not command:
1746+
raise HTTPException(
1747+
status_code=400,
1748+
detail="Original job is missing required fields (provider_id or command) for resume",
1749+
)
1750+
1751+
# Verify checkpoint exists using workspace-aware path resolution
1752+
checkpoints_dir = get_job_checkpoints_dir(job_id)
1753+
checkpoint_path = storage.join(checkpoints_dir, request.checkpoint)
1754+
if not storage.exists(checkpoint_path):
1755+
raise HTTPException(status_code=404, detail=f"Checkpoint '{request.checkpoint}' not found")
1756+
1757+
# Get provider
1758+
team_id = user_and_team["team_id"]
1759+
provider = await get_team_provider(session, team_id, provider_id)
1760+
if not provider:
1761+
raise HTTPException(status_code=404, detail="Provider not found")
1762+
1763+
# Create new REMOTE job
1764+
initial_status = "INTERACTIVE" if job_data.get("subtype") == "interactive" else "LAUNCHING"
1765+
new_job_id = job_service.job_create(type="REMOTE", status=initial_status, experiment_id=experimentId, job_data={})
1766+
1767+
# Set parent_job_id and resumed_from_checkpoint in job_data
1768+
job_service.job_update_job_data_insert_key_value(new_job_id, "parent_job_id", job_id, experimentId)
1769+
job_service.job_update_job_data_insert_key_value(
1770+
new_job_id, "resumed_from_checkpoint", request.checkpoint, experimentId
1771+
)
1772+
1773+
# Copy all original job launch configuration
1774+
config_fields = [
1775+
"command",
1776+
"task_name",
1777+
"subtype",
1778+
"interactive_type",
1779+
"cpus",
1780+
"memory",
1781+
"disk_space",
1782+
"accelerators",
1783+
"num_nodes",
1784+
"setup",
1785+
"env_vars",
1786+
"file_mounts",
1787+
"parameters",
1788+
"provider_id",
1789+
"provider_type",
1790+
"provider_name",
1791+
"github_repo_url",
1792+
"github_directory",
1793+
"user_info",
1794+
"team_id",
1795+
]
1796+
1797+
for field in config_fields:
1798+
value = job_data.get(field)
1799+
if value is not None:
1800+
job_service.job_update_job_data_insert_key_value(new_job_id, field, value, experimentId)
1801+
1802+
# Relaunch via provider - replicate launch logic from compute_provider.py
1803+
try:
1804+
provider_instance = get_provider_instance(provider)
1805+
except Exception as exc:
1806+
await job_service.job_update_status(new_job_id, "FAILED", experimentId, error_msg=str(exc))
1807+
raise HTTPException(status_code=500, detail=f"Failed to initialize provider: {exc}") from exc
1808+
1809+
# Build cluster name
1810+
base_name = job_data.get("task_name") or provider.name
1811+
formatted_cluster_name = f"{_sanitize_cluster_basename(base_name)}-job-{new_job_id}"
1812+
1813+
# Get user info
1814+
user = user_and_team.get("user")
1815+
user_info = {}
1816+
if user:
1817+
if getattr(user, "first_name", None) or getattr(user, "last_name", None):
1818+
user_info["name"] = " ".join(
1819+
part for part in [getattr(user, "first_name", ""), getattr(user, "last_name", "")] if part
1820+
).strip()
1821+
if getattr(user, "email", None):
1822+
user_info["email"] = getattr(user, "email")
1823+
1824+
provider_display_name = job_data.get("provider_name") or provider.name
1825+
1826+
# Prepare environment variables
1827+
env_vars = (job_data.get("env_vars") or {}).copy()
1828+
env_vars["_TFL_JOB_ID"] = str(new_job_id)
1829+
env_vars["_TFL_EXPERIMENT_ID"] = experimentId
1830+
1831+
# Get TFL_STORAGE_URI from storage context
1832+
tfl_storage_uri = None
1833+
try:
1834+
storage_root = storage.root_uri()
1835+
if storage_root and any(storage_root.startswith(prefix) for prefix in ("s3://", "gs://", "gcs://", "abfs://")):
1836+
tfl_storage_uri = storage_root
1837+
except Exception:
1838+
pass
1839+
1840+
if tfl_storage_uri:
1841+
env_vars["TFL_STORAGE_URI"] = tfl_storage_uri
1842+
env_vars["_TFL_REMOTE_SKYPILOT_WORKSPACE"] = "true"
1843+
1844+
# Build setup script
1845+
setup_commands = []
1846+
# Add GitHub clone setup if enabled
1847+
github_repo_url = job_data.get("github_repo_url")
1848+
if github_repo_url:
1849+
workspace_dir = get_workspace_dir()
1850+
github_pat = read_github_pat_from_workspace(workspace_dir)
1851+
github_setup = generate_github_clone_setup(
1852+
repo_url=github_repo_url,
1853+
directory=job_data.get("github_directory"),
1854+
github_pat=github_pat,
1855+
)
1856+
setup_commands.append(github_setup)
1857+
1858+
# Add user-provided setup if any
1859+
original_setup = job_data.get("setup")
1860+
if original_setup:
1861+
setup_commands.append(original_setup)
1862+
1863+
final_setup = ";".join(setup_commands) if setup_commands else None
1864+
1865+
# Update job_data with launch configuration
1866+
launch_job_data = {
1867+
"task_name": job_data.get("task_name"),
1868+
"command": command,
1869+
"cluster_name": formatted_cluster_name,
1870+
"subtype": job_data.get("subtype"),
1871+
"interactive_type": job_data.get("interactive_type"),
1872+
"cpus": job_data.get("cpus"),
1873+
"memory": job_data.get("memory"),
1874+
"disk_space": job_data.get("disk_space"),
1875+
"accelerators": job_data.get("accelerators"),
1876+
"num_nodes": job_data.get("num_nodes"),
1877+
"setup": final_setup,
1878+
"env_vars": env_vars if env_vars else None,
1879+
"file_mounts": job_data.get("file_mounts") or None,
1880+
"parameters": job_data.get("parameters") or None,
1881+
"provider_id": provider.id,
1882+
"provider_type": provider.type,
1883+
"provider_name": provider_display_name,
1884+
"user_info": user_info or None,
1885+
"team_id": team_id,
1886+
"start_time": time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime()),
1887+
}
1888+
1889+
for key, value in launch_job_data.items():
1890+
if value is not None:
1891+
job_service.job_update_job_data_insert_key_value(new_job_id, key, value, experimentId)
1892+
1893+
# Build ClusterConfig
1894+
disk_size = None
1895+
if job_data.get("disk_space"):
1896+
try:
1897+
disk_size = int(job_data.get("disk_space"))
1898+
except (TypeError, ValueError):
1899+
disk_size = None
1900+
1901+
cluster_config = ClusterConfig(
1902+
cluster_name=formatted_cluster_name,
1903+
provider_name=provider_display_name,
1904+
provider_id=provider.id,
1905+
command=command,
1906+
setup=final_setup,
1907+
env_vars=env_vars,
1908+
cpus=job_data.get("cpus"),
1909+
memory=job_data.get("memory"),
1910+
accelerators=job_data.get("accelerators"),
1911+
num_nodes=job_data.get("num_nodes"),
1912+
disk_size=disk_size,
1913+
file_mounts=job_data.get("file_mounts") or {},
1914+
provider_config={"requested_disk_space": job_data.get("disk_space")},
1915+
)
1916+
1917+
# Launch cluster
1918+
try:
1919+
provider_instance.launch_cluster(formatted_cluster_name, cluster_config)
1920+
return {
1921+
"job_id": new_job_id,
1922+
"message": "Job relaunched from checkpoint",
1923+
"cluster_name": formatted_cluster_name,
1924+
}
1925+
except Exception as exc:
1926+
print(f"Failed to launch cluster: {exc}")
1927+
await job_service.job_update_status(new_job_id, "FAILED", experimentId, error_msg=str(exc))
1928+
raise HTTPException(status_code=500, detail=f"Failed to relaunch job: {exc}") from exc
1929+
1930+
17061931
@router.post("/{provider_id}/clusters/{cluster_name}/stop")
17071932
async def stop_cluster(
17081933
provider_id: str,

api/transformerlab/schemas/compute_providers.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,9 @@ class ProviderTemplateFileUploadResponse(BaseModel):
141141
status: str
142142
stored_path: str
143143
message: Optional[str] = None
144+
145+
146+
class ResumeFromCheckpointRequest(BaseModel):
147+
"""Request body for resuming a REMOTE job from a checkpoint."""
148+
149+
checkpoint: str = Field(..., description="Checkpoint filename to resume from")

lab-sdk/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "transformerlab"
7-
version = "0.0.60"
7+
version = "0.0.61"
88
description = "Python SDK for Transformer Lab"
99
readme = "README.md"
1010
requires-python = ">=3.10"

lab-sdk/src/lab/lab_facade.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -191,14 +191,26 @@ def get_parent_job_checkpoint_path(self, parent_job_id: str, checkpoint_name: st
191191
checkpoint_path = storage.join(checkpoints_dir, checkpoint_name)
192192

193193
# Security check: ensure the checkpoint path is within the checkpoints directory
194-
# Normalize paths using posixpath for cross-platform compatibility (works for both local and remote storage)
195-
checkpoint_path_normalized = posixpath.normpath(checkpoint_path).rstrip("/")
196-
checkpoints_dir_normalized = posixpath.normpath(checkpoints_dir).rstrip("/")
197-
198-
# Check if checkpoint path is strictly within checkpoints directory (not the directory itself)
199-
# For remote storage (s3://, etc.), ensure we're checking within the same bucket/path
200-
if not checkpoint_path_normalized.startswith(checkpoints_dir_normalized + "/"):
201-
return None
194+
# Handle remote storage URIs (s3://, gs://, etc.) differently from local paths
195+
is_remote = checkpoint_path.startswith(("s3://", "gs://", "gcs://", "abfs://"))
196+
197+
if is_remote:
198+
# For remote storage, normalize only the path portion after the protocol
199+
# posixpath.normpath breaks S3 URI format, so we need to handle it manually
200+
checkpoint_path_normalized = checkpoint_path.rstrip("/")
201+
checkpoints_dir_normalized = checkpoints_dir.rstrip("/")
202+
203+
# Ensure checkpoint path starts with checkpoints directory
204+
if not checkpoint_path_normalized.startswith(checkpoints_dir_normalized + "/"):
205+
return None
206+
else:
207+
# For local paths, use posixpath.normpath
208+
checkpoint_path_normalized = posixpath.normpath(checkpoint_path).rstrip("/")
209+
checkpoints_dir_normalized = posixpath.normpath(checkpoints_dir).rstrip("/")
210+
211+
# Check if checkpoint path is strictly within checkpoints directory
212+
if not checkpoint_path_normalized.startswith(checkpoints_dir_normalized + "/"):
213+
return None
202214

203215
if storage.exists(checkpoint_path_normalized):
204216
return checkpoint_path_normalized

src/renderer/components/Experiment/Train/ViewCheckpointsModal.tsx

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,27 +8,67 @@ import {
88
Box,
99
} from '@mui/joy';
1010
import { PlayIcon } from 'lucide-react';
11-
import { useAPI } from 'renderer/lib/transformerlab-api-sdk';
11+
import { useState } from 'react';
12+
import { useAPI, getAPIFullPath } from 'renderer/lib/transformerlab-api-sdk';
13+
import { fetchWithAuth } from 'renderer/lib/authContext';
1214
import { formatBytes } from 'renderer/lib/utils';
1315
import { useExperimentInfo } from 'renderer/lib/ExperimentInfoContext';
1416
import { useNotification } from 'renderer/components/Shared/NotificationSystem';
1517

1618
export default function ViewCheckpointsModal({ open, onClose, jobId }) {
1719
const { experimentInfo } = useExperimentInfo();
1820
const { addNotification } = useNotification();
21+
const [resumingCheckpoint, setResumingCheckpoint] = useState<string | null>(
22+
null,
23+
);
1924
const { data, isLoading: checkpointsLoading } = useAPI(
2025
'jobs',
2126
['getCheckpoints'],
2227
{ jobId, experimentId: experimentInfo?.id },
2328
);
2429

2530
const handleRestartFromCheckpoint = async (checkpoint) => {
26-
// TODO: Implement checkpoint resume functionality
27-
// This feature is currently disabled
28-
addNotification({
29-
type: 'info',
30-
message: 'Checkpoint resume functionality is not currently available',
31-
});
31+
if (!experimentInfo?.id) {
32+
addNotification({
33+
type: 'error',
34+
message: 'Experiment ID is required',
35+
});
36+
return;
37+
}
38+
39+
setResumingCheckpoint(checkpoint.filename);
40+
try {
41+
const url = getAPIFullPath('compute_provider', ['resumeFromCheckpoint'], {
42+
jobId,
43+
experimentId: experimentInfo.id,
44+
});
45+
46+
const response = await fetchWithAuth(url, {
47+
method: 'POST',
48+
body: JSON.stringify({ checkpoint: checkpoint.filename }),
49+
});
50+
51+
if (!response.ok) {
52+
const errorData = await response
53+
.json()
54+
.catch(() => ({ detail: 'Unknown error' }));
55+
throw new Error(errorData.detail || `HTTP ${response.status}`);
56+
}
57+
58+
const result = await response.json();
59+
addNotification({
60+
type: 'success',
61+
message: `Job ${result.job_id} queued to resume from checkpoint "${checkpoint.filename}"`,
62+
});
63+
onClose();
64+
} catch (error) {
65+
addNotification({
66+
type: 'error',
67+
message: `Failed to resume from checkpoint: ${error instanceof Error ? error.message : 'Unknown error'}`,
68+
});
69+
} finally {
70+
setResumingCheckpoint(null);
71+
}
3272
};
3373

3474
let noCheckpoints = false;
@@ -121,6 +161,8 @@ export default function ViewCheckpointsModal({ open, onClose, jobId }) {
121161
handleRestartFromCheckpoint(checkpoint)
122162
}
123163
startDecorator={<PlayIcon />}
164+
loading={resumingCheckpoint === checkpoint.filename}
165+
disabled={resumingCheckpoint !== null}
124166
>
125167
Restart training from here
126168
</Button>

src/renderer/lib/api-client/allEndpoints.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -202,10 +202,6 @@
202202
"method": "GET",
203203
"path": "experiment/{experimentId}/jobs/list?type={type}&status={status}"
204204
},
205-
"resumeFromCheckpoint": {
206-
"method": "POST",
207-
"path": "experiment/{experimentId}/jobs/{jobId}/resume_from_checkpoint"
208-
},
209205
"getSweepResults": {
210206
"method": "GET",
211207
"path": "experiment/{experimentId}/jobs/{jobId}/sweep_results"
@@ -308,6 +304,10 @@
308304
"method": "GET",
309305
"path": "compute_provider/{providerId}"
310306
},
307+
"resumeFromCheckpoint": {
308+
"method": "POST",
309+
"path": "compute_provider/jobs/{jobId}/resume_from_checkpoint?experimentId={experimentId}"
310+
},
311311
"update": {
312312
"method": "PATCH",
313313
"path": "compute_provider/{providerId}"

0 commit comments

Comments
 (0)