flexlock 0.8.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ """Backend system for executing FlexLock jobs."""
2
+
3
+ from .base import Backend
4
+ from .pbs import PBSBackend
5
+ from .slurm import SlurmBackend
6
+
7
+ __all__ = [
8
+ "Backend",
9
+ "PBSBackend",
10
+ "SlurmBackend",
11
+ ]
@@ -0,0 +1,41 @@
1
+ """Base classes for FlexLock backend integrations."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any, List
5
+
6
+
7
+ class Job:
8
+ """Abstract base class representing a submitted job."""
9
+
10
+ @property
11
+ def job_id(self) -> str:
12
+ """The unique identifier for the job."""
13
+ ...
14
+
15
+
16
+ class JobEnvironment:
17
+ """Abstract base class providing environment information for a running job."""
18
+
19
+ @property
20
+ def global_rank(self) -> int:
21
+ """The global rank of the current process within the job (0-indexed)."""
22
+ ...
23
+
24
+ @property
25
+ def world_size(self) -> int:
26
+ """The total number of processes in the job."""
27
+ ...
28
+
29
+
30
+ class Backend(ABC):
31
+ """Abstract base class for FlexLock job submission backends."""
32
+
33
+ @abstractmethod
34
+ def submit(self, fn, *args, **kwargs) -> Job:
35
+ """Submits a single function for execution."""
36
+ ...
37
+
38
+ @abstractmethod
39
+ def environment(self) -> JobEnvironment:
40
+ """Returns a JobEnvironment object providing environment-specific variables."""
41
+ ...
@@ -0,0 +1,219 @@
1
+ """PBS backend for FlexLock parallel execution."""
2
+
3
+ import cloudpickle, subprocess, os
4
+ from pathlib import Path
5
+ import secrets
6
+ import time
7
+ from .base import Backend, Job, JobEnvironment
8
+ from loguru import logger
9
+
10
+
11
+ class PBSJob(Job):
12
+ """Represents a PBS job."""
13
+
14
+ def __init__(self, job_id, backend=None):
15
+ self._id = job_id
16
+ self._backend = backend
17
+
18
+ @property
19
+ def job_id(self):
20
+ return self._id
21
+
22
+ def status(self):
23
+ """Get current job status."""
24
+ if self._backend:
25
+ return self._backend.check_status(self._id)
26
+ return "unknown"
27
+
28
+ def wait(self, timeout=None, poll_interval=5):
29
+ """Wait for job to complete."""
30
+ if self._backend:
31
+ return self._backend.wait_for_job(self._id, timeout, poll_interval)
32
+ return False
33
+
34
+ def cancel(self):
35
+ """Cancel the job."""
36
+ if self._backend:
37
+ return self._backend.cancel_job(self._id)
38
+ return False
39
+
40
+
41
+ class PBSBackend(Backend):
42
+ """Implements the FlexLock backend for PBS (Portable Batch System) job submission."""
43
+
44
+ def __init__(
45
+ self,
46
+ folder: Path,
47
+ startup_lines: list[str],
48
+ configure_logging: bool = True,
49
+ configure_name: bool = True,
50
+ python_exe: str = "python",
51
+ ):
52
+ self.folder = folder
53
+ self.folder.mkdir(parents=True, exist_ok=True)
54
+ self.startup_lines = startup_lines
55
+ self.configure_logging = configure_logging
56
+ self.configure_name = configure_name
57
+ self.python_exe = python_exe
58
+
59
+ def _make_script(self, pickled_path: Path) -> str:
60
+ """Generates the PBS submission script content."""
61
+ lines = ["#!/bin/bash"]
62
+
63
+ if self.configure_name:
64
+ lines.extend(
65
+ [
66
+ f"#PBS -N {self.folder.parent.stem}",
67
+ ]
68
+ )
69
+ if self.configure_logging:
70
+ lines.extend(
71
+ [
72
+ f"#PBS -o {self.folder.absolute() / 'pbs.out'}",
73
+ f"#PBS -e {self.folder.absolute() / 'pbs.err'}",
74
+ ]
75
+ )
76
+ lines.extend(self.startup_lines)
77
+ python_script = [
78
+ "import cloudpickle, sys, os",
79
+ f"with open('{pickled_path}', 'rb') as f:",
80
+ " fn, a, kw = cloudpickle.load(f)",
81
+ "fn(*a, **kw)",
82
+ ]
83
+ python_code = "\n".join(python_script)
84
+
85
+ lines.extend(
86
+ [
87
+ f"{self.python_exe} - <<'PY'\n{python_code}\nPY",
88
+ ]
89
+ )
90
+
91
+ return "\n".join(lines)
92
+
93
+ def submit(self, fn, *args, **kwargs):
94
+ """Submits a single function for execution as a PBS job."""
95
+ data = (fn, args, kwargs)
96
+ pkl_path = self.folder / f"task_{secrets.token_hex(4)}.pkl"
97
+ with open(pkl_path, "wb") as f:
98
+ cloudpickle.dump(data, f)
99
+
100
+ script_path = self.folder / f"job_{secrets.token_hex(4)}.pbs"
101
+ script_path.write_text(self._make_script(pkl_path))
102
+
103
+ out = subprocess.check_output(["qsub", str(script_path)], text=True).strip()
104
+ job_id = out
105
+ return PBSJob(job_id, backend=self)
106
+
107
+ def check_status(self, job_id: str) -> str:
108
+ """
109
+ Check the status of a PBS job.
110
+
111
+ Returns:
112
+ Status string: 'Q' (queued), 'R' (running), 'C' (completed), 'E' (exiting), 'H' (held), or 'unknown'
113
+ """
114
+ try:
115
+ # Use qstat -x to get info about finished jobs too
116
+ out = subprocess.check_output(
117
+ ["qstat", "-x", job_id], text=True, stderr=subprocess.DEVNULL
118
+ )
119
+ lines = out.strip().split("\n")
120
+ if len(lines) > 2: # Header + job line
121
+ # Parse the status from qstat output
122
+ job_line = lines[2] # Skip two header lines
123
+ parts = job_line.split()
124
+ if len(parts) >= 10:
125
+ return parts[9] # Job state is usually the 10th column
126
+ except subprocess.CalledProcessError:
127
+ # Job not found, likely completed and cleaned up
128
+ return "C"
129
+ except Exception as e:
130
+ logger.warning(f"Failed to check PBS job status for {job_id}: {e}")
131
+ return "unknown"
132
+
133
+ # States that mean the job has definitively ended: C(completed) F(finished).
134
+ # Everything else — active (Q/R/H/E/...) or "unknown" — is NOT terminal.
135
+ TERMINAL_STATES = frozenset({"C", "F"})
136
+
137
+ def is_terminal(self, job_id: str) -> bool:
138
+ """Return True only when PBS confirms the job has ended.
139
+
140
+ Conservative on purpose (see ``SlurmBackend.is_terminal``): ambiguous
141
+ reads return ``False`` so a healthy job is never reconciled away. The
142
+ controller additionally debounces across several polls, which guards
143
+ against ``qstat`` transiently failing (``check_status`` maps a
144
+ not-found job to ``"C"``).
145
+ """
146
+ return self.check_status(job_id).strip().upper() in self.TERMINAL_STATES
147
+
148
+ def wait_for_job(self, job_id: str, timeout=None, poll_interval=5) -> bool:
149
+ """
150
+ Wait for a PBS job to complete.
151
+
152
+ Args:
153
+ job_id: PBS job identifier
154
+ timeout: Maximum time to wait in seconds (None for no timeout)
155
+ poll_interval: Time between status checks in seconds
156
+
157
+ Returns:
158
+ True if job completed successfully, False otherwise
159
+ """
160
+ start_time = time.time()
161
+ logger.info(f"Waiting for PBS job {job_id} to complete...")
162
+
163
+ while True:
164
+ status = self.check_status(job_id)
165
+
166
+ # Completed states
167
+ if status in ["C", "completed"]:
168
+ logger.info(f"PBS job {job_id} completed")
169
+ return True
170
+
171
+ # Failed states
172
+ if status in ["E", "F", "failed"]:
173
+ logger.error(f"PBS job {job_id} failed with status: {status}")
174
+ return False
175
+
176
+ # Check timeout
177
+ if timeout and (time.time() - start_time) > timeout:
178
+ logger.error(f"PBS job {job_id} timed out after {timeout}s")
179
+ return False
180
+
181
+ # Still running or queued
182
+ if status in ["Q", "R", "H"]:
183
+ logger.debug(f"PBS job {job_id} status: {status}")
184
+ else:
185
+ logger.debug(f"PBS job {job_id} unknown status: {status}")
186
+
187
+ time.sleep(poll_interval)
188
+
189
+ def cancel_job(self, job_id: str) -> bool:
190
+ """
191
+ Cancel a PBS job.
192
+
193
+ Args:
194
+ job_id: PBS job identifier
195
+
196
+ Returns:
197
+ True if cancellation succeeded, False otherwise
198
+ """
199
+ try:
200
+ subprocess.check_call(["qdel", job_id], stderr=subprocess.DEVNULL)
201
+ logger.info(f"Cancelled PBS job {job_id}")
202
+ return True
203
+ except subprocess.CalledProcessError as e:
204
+ logger.error(f"Failed to cancel PBS job {job_id}: {e}")
205
+ return False
206
+
207
+ def environment(self):
208
+ """Returns a JobEnvironment object providing PBS-specific environment variables."""
209
+
210
+ class Env(JobEnvironment):
211
+ @property
212
+ def global_rank(self):
213
+ return int(os.getenv("OMPI_COMM_WORLD_RANK", 0))
214
+
215
+ @property
216
+ def world_size(self):
217
+ return int(os.getenv("OMPI_COMM_WORLD_SIZE", 1))
218
+
219
+ return Env()
@@ -0,0 +1,333 @@
1
+ """Slurm backend for FlexLock parallel execution."""
2
+
3
+ import cloudpickle, subprocess, os
4
+ import re
5
+ from pathlib import Path
6
+ import secrets # Better random for filenames
7
+ import time
8
+ from .base import Backend, Job, JobEnvironment
9
+ from loguru import logger
10
+
11
+
12
+ # Heuristics for recognising environment-activation lines in startup_lines.
13
+ # These commands prepare the compute-node shell (PATH, libs, interpreters)
14
+ # before the pickled task runs. Missing them is the wiki's most common
15
+ # silent-failure mode ("import fails on node, looks fine locally").
16
+ _ENV_ACTIVATION_PATTERNS = (
17
+ re.compile(r"\beval\s"), # eval "$(...)" — pixi shell-hook, conda hook
18
+ re.compile(r"\bsource\s"), # source venv/bin/activate
19
+ re.compile(r"\bconda\s+activate\b"),
20
+ re.compile(r"\bmamba\s+activate\b"),
21
+ re.compile(r"\bmodule\s+(load|add)\b"), # HPC module systems
22
+ re.compile(r"\bpixi\s+(run|shell)\b"),
23
+ re.compile(r"\bspack\s+load\b"),
24
+ )
25
+
26
+
27
+ def validate_slurm_script(
28
+ script: str, *, expects_gpu: bool = False
29
+ ) -> list[str]:
30
+ """Return a list of human-readable warning strings for a Slurm script.
31
+
32
+ Empty list means no concerns. Each warning is independent — callers
33
+ decide whether to log or print. Validation is intentionally lenient
34
+ (warn, never fail) because power-user setups may intentionally omit
35
+ pieces this function expects.
36
+ """
37
+ warnings: list[str] = []
38
+
39
+ sbatch_lines = [
40
+ ln.strip() for ln in script.splitlines() if ln.strip().startswith("#SBATCH")
41
+ ]
42
+ body_lines = [
43
+ ln for ln in script.splitlines()
44
+ if ln.strip() and not ln.strip().startswith(("#!", "#SBATCH"))
45
+ ]
46
+
47
+ # 1. --partition is the most common forgotten directive — jobs land on
48
+ # the default queue, often a CPU one when a GPU was wanted.
49
+ if not any("--partition" in ln or "-p " in ln for ln in sbatch_lines):
50
+ warnings.append(
51
+ "No --partition directive found. The job will land on the "
52
+ "cluster's default queue, which may not be the one you want."
53
+ )
54
+
55
+ # 2. cd to the submission dir keeps relative paths in user code working
56
+ # from the compute node.
57
+ if not any(re.search(r"\bcd\b", ln) for ln in body_lines):
58
+ warnings.append(
59
+ "No 'cd' command in startup_lines. Relative paths in your "
60
+ "code will resolve against the compute node's HOME, not the "
61
+ "submission directory. Add 'cd $SLURM_SUBMIT_DIR' to "
62
+ "startup_lines."
63
+ )
64
+
65
+ # 3. Some form of env activation is almost always needed.
66
+ if not any(p.search(ln) for ln in body_lines for p in _ENV_ACTIVATION_PATTERNS):
67
+ warnings.append(
68
+ "No environment-activation command detected in startup_lines "
69
+ "(eval, source, conda/mamba activate, module load, pixi run/shell). "
70
+ "Python imports may fail on the compute node."
71
+ )
72
+
73
+ # 4. If the caller flagged this as a GPU config but the script doesn't
74
+ # request a GPU, surface it.
75
+ if expects_gpu and not any(
76
+ "--gres" in ln or "--gpus" in ln for ln in sbatch_lines
77
+ ):
78
+ warnings.append(
79
+ "GPU config requested but no --gres or --gpus directive in "
80
+ "startup_lines. The job will run on CPU silently."
81
+ )
82
+
83
+ return warnings
84
+
85
+
86
+ class SlurmJob(Job):
87
+ """Represents a Slurm job."""
88
+
89
+ def __init__(self, job_id, backend=None):
90
+ self._id = job_id
91
+ self._backend = backend
92
+
93
+ @property
94
+ def job_id(self):
95
+ return self._id
96
+
97
+ def status(self):
98
+ """Get current job status."""
99
+ if self._backend:
100
+ return self._backend.check_status(self._id)
101
+ return "unknown"
102
+
103
+ def wait(self, timeout=None, poll_interval=5):
104
+ """Wait for job to complete."""
105
+ if self._backend:
106
+ return self._backend.wait_for_job(self._id, timeout, poll_interval)
107
+ return False
108
+
109
+ def cancel(self):
110
+ """Cancel the job."""
111
+ if self._backend:
112
+ return self._backend.cancel_job(self._id)
113
+ return False
114
+
115
+
116
+ class SlurmBackend(Backend):
117
+ """Implements the FlexLock backend for Slurm job submission."""
118
+
119
+ def __init__(
120
+ self,
121
+ folder: Path,
122
+ startup_lines: list[str],
123
+ configure_logging: bool = True,
124
+ python_exe="python",
125
+ ):
126
+ self.folder = folder
127
+ self.folder.mkdir(parents=True, exist_ok=True)
128
+ self.startup_lines = startup_lines
129
+ self.configure_logging = configure_logging
130
+ self.python_exe = python_exe
131
+
132
+ def render_script(self, pickled_path: "Path | str | None" = None) -> str:
133
+ """Render the would-be Slurm script without submitting.
134
+
135
+ ``pickled_path`` defaults to a placeholder path so users can preview
136
+ the script (e.g. via ``dry_run=True``) before any pickling happens.
137
+ """
138
+ return self._make_script(Path(pickled_path or "<pickled-task.pkl>"))
139
+
140
+ def _make_script(self, pickled_path: Path) -> str:
141
+ """Generates the Slurm submission script content."""
142
+ lines = ["#!/bin/bash"]
143
+ # SBATCH directives must all come before any shell commands —
144
+ # Slurm stops parsing directives at the first non-comment, non-blank line.
145
+ if self.configure_logging:
146
+ lines.extend(
147
+ [
148
+ f"#SBATCH --output={self.folder.absolute() / 'slurm.out'}",
149
+ f"#SBATCH --error={self.folder.absolute() / 'slurm.err'}",
150
+ ]
151
+ )
152
+ lines.extend(self.startup_lines)
153
+
154
+ python_script = [
155
+ "import cloudpickle, sys, os",
156
+ # Make the submission directory importable so that _target_ strings
157
+ # like 'train.main' resolve in the worker as they do on the
158
+ # submitting machine. Append (not insert-at-0): cwd is a fallback
159
+ # only — it must never shadow installed packages, or a local file
160
+ # colliding with a dependency torch/lightning imports lazily during
161
+ # CUDA init can break the GPU stack in confusing ways.
162
+ f"sys.path.append({str(Path.cwd().resolve())!r})",
163
+ f"with open('{pickled_path}', 'rb') as f:",
164
+ " data = cloudpickle.load(f)",
165
+ " fn, a, kw = data",
166
+ "fn(*a, **kw)",
167
+ ]
168
+ python_code = "\n".join(python_script)
169
+ lines.extend(
170
+ [
171
+ f"{self.python_exe} - <<'PY'\n{python_code}\nPY",
172
+ ]
173
+ )
174
+ return "\n".join(lines)
175
+
176
+ def submit(self, fn, *args, **kwargs):
177
+ """Submits a single function for execution as a Slurm job."""
178
+ data = (fn, args, kwargs)
179
+ pkl_path = self.folder / f"task_{secrets.token_hex(4)}.pkl"
180
+ with open(pkl_path, "wb") as f:
181
+ cloudpickle.dump(data, f)
182
+
183
+ script_path = self.folder / f"job_{secrets.token_hex(4)}.slurm"
184
+ script = self._make_script(pkl_path)
185
+ script_path.write_text(script)
186
+
187
+ for w in validate_slurm_script(script):
188
+ logger.warning(f"Slurm config: {w}")
189
+
190
+ out = subprocess.check_output(["sbatch", str(script_path)], text=True).strip()
191
+ job_id = out.split()[-1]
192
+ return SlurmJob(job_id, backend=self)
193
+
194
+ def check_status(self, job_id: str) -> str:
195
+ """
196
+ Check the status of a Slurm job.
197
+
198
+ Returns:
199
+ Status string: 'PENDING', 'RUNNING', 'COMPLETED', 'FAILED', 'CANCELLED', or 'unknown'
200
+ """
201
+ try:
202
+ # Use squeue for running/pending jobs
203
+ out = subprocess.check_output(
204
+ ["squeue", "-j", job_id, "-h", "-o", "%T"],
205
+ text=True,
206
+ stderr=subprocess.DEVNULL,
207
+ )
208
+ status = out.strip()
209
+ if status:
210
+ return status
211
+ except subprocess.CalledProcessError:
212
+ pass # Job not in queue, check sacct
213
+
214
+ try:
215
+ # Use sacct for completed jobs
216
+ out = subprocess.check_output(
217
+ ["sacct", "-j", job_id, "-n", "-o", "State"],
218
+ text=True,
219
+ stderr=subprocess.DEVNULL,
220
+ )
221
+ status = out.strip().split("\n")[0].strip()
222
+ if status:
223
+ return status
224
+ except subprocess.CalledProcessError:
225
+ pass
226
+
227
+ logger.warning(f"Could not determine status for Slurm job {job_id}")
228
+ return "unknown"
229
+
230
+ # States that mean the job has definitively ended. Anything else —
231
+ # active (PENDING/RUNNING/...) OR ambiguous ("unknown") — is NOT terminal.
232
+ TERMINAL_STATES = frozenset({
233
+ "COMPLETED", "FAILED", "TIMEOUT", "CANCELLED", "NODE_FAIL",
234
+ "PREEMPTED", "OUT_OF_MEMORY", "BOOT_FAIL", "DEADLINE", "REVOKED",
235
+ "SPECIAL_EXIT",
236
+ })
237
+
238
+ def is_terminal(self, job_id: str) -> bool:
239
+ """Return True only when the scheduler confirms the job has ended.
240
+
241
+ Used by the controller to decide whether tasks still marked
242
+ ``running``/``pending`` have been orphaned by a dead job. This is
243
+ deliberately conservative: ``unknown`` (e.g. ``squeue`` doesn't list a
244
+ just-submitted job yet and ``sacct`` hasn't recorded it) and all
245
+ active states return ``False``, so a transient/racy status read never
246
+ causes a healthy job to be reconciled away.
247
+ """
248
+ raw = self.check_status(job_id).strip()
249
+ if not raw:
250
+ return False
251
+ # Normalize forms like "CANCELLED by 12345" / "CANCELLED+".
252
+ token = raw.upper().split()[0].rstrip("+")
253
+ return token in self.TERMINAL_STATES
254
+
255
+ def wait_for_job(self, job_id: str, timeout=None, poll_interval=5) -> bool:
256
+ """
257
+ Wait for a Slurm job to complete.
258
+
259
+ Args:
260
+ job_id: Slurm job identifier
261
+ timeout: Maximum time to wait in seconds (None for no timeout)
262
+ poll_interval: Time between status checks in seconds
263
+
264
+ Returns:
265
+ True if job completed successfully, False otherwise
266
+ """
267
+ start_time = time.time()
268
+ logger.info(f"Waiting for Slurm job {job_id} to complete...")
269
+
270
+ while True:
271
+ status = self.check_status(job_id)
272
+
273
+ # Completed states
274
+ if status in ["COMPLETED", "completed"]:
275
+ logger.info(f"Slurm job {job_id} completed")
276
+ return True
277
+
278
+ # Failed states
279
+ if status in [
280
+ "FAILED",
281
+ "TIMEOUT",
282
+ "CANCELLED",
283
+ "NODE_FAIL",
284
+ "PREEMPTED",
285
+ "OUT_OF_MEMORY",
286
+ ]:
287
+ logger.error(f"Slurm job {job_id} failed with status: {status}")
288
+ return False
289
+
290
+ # Check timeout
291
+ if timeout and (time.time() - start_time) > timeout:
292
+ logger.error(f"Slurm job {job_id} timed out after {timeout}s")
293
+ return False
294
+
295
+ # Still running or pending
296
+ if status in ["PENDING", "RUNNING", "CONFIGURING"]:
297
+ logger.debug(f"Slurm job {job_id} status: {status}")
298
+ else:
299
+ logger.debug(f"Slurm job {job_id} unknown status: {status}")
300
+
301
+ time.sleep(poll_interval)
302
+
303
+ def cancel_job(self, job_id: str) -> bool:
304
+ """
305
+ Cancel a Slurm job.
306
+
307
+ Args:
308
+ job_id: Slurm job identifier
309
+
310
+ Returns:
311
+ True if cancellation succeeded, False otherwise
312
+ """
313
+ try:
314
+ subprocess.check_call(["scancel", job_id], stderr=subprocess.DEVNULL)
315
+ logger.info(f"Cancelled Slurm job {job_id}")
316
+ return True
317
+ except subprocess.CalledProcessError as e:
318
+ logger.error(f"Failed to cancel Slurm job {job_id}: {e}")
319
+ return False
320
+
321
+ def environment(self):
322
+ """Returns a JobEnvironment object providing Slurm-specific environment variables."""
323
+
324
+ class Env(JobEnvironment):
325
+ @property
326
+ def global_rank(self):
327
+ return int(os.getenv("SLURM_PROCID", 0))
328
+
329
+ @property
330
+ def world_size(self):
331
+ return int(os.getenv("SLURM_NTASKS", 1))
332
+
333
+ return Env()