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.
flexlock/mlflow.py ADDED
@@ -0,0 +1,192 @@
1
+ """MLflow integration for FlexLock."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Dict, Optional, Any
6
+ from contextlib import contextmanager
7
+ from loguru import logger
8
+ from omegaconf import OmegaConf
9
+
10
+
11
+ def _flatten_dict(
12
+ d: Dict[str, Any], parent_key: str = "", sep: str = "."
13
+ ) -> Dict[str, Any]:
14
+ """Flatten a nested dictionary into a single-level dict with dot-separated keys."""
15
+ items = []
16
+ for k, v in d.items():
17
+ new_key = f"{parent_key}{sep}{k}" if parent_key else k
18
+ if isinstance(v, dict):
19
+ items.extend(_flatten_dict(v, new_key, sep=sep).items())
20
+ else:
21
+ items.append((new_key, v))
22
+ return dict(items)
23
+
24
+
25
+ @contextmanager
26
+ def mlflow_context(
27
+ save_dir: str | Path,
28
+ experiment_name: str = os.environ.get("MLFLOW_EXPERIMENT_NAME", "Default"),
29
+ run_name: str = None,
30
+ tags: Dict[str, str] = None,
31
+ log_config: bool = True,
32
+ log_artifacts: bool = True,
33
+ ):
34
+ """
35
+ Manages a "Shadow Run" in MLflow linked to a physical directory.
36
+
37
+ This context manager implements the "Always New + Deprecate Old" strategy,
38
+ treating the MLflow Run as a temporary view of the persistent physical state
39
+ (the save_dir).
40
+
41
+ Behavior:
42
+ 1. SEARCH: Finds the currently 'active' run for this directory.
43
+ 2. CREATE: Starts a completely new run.
44
+ 3. INHERIT: Logs existing config/artifacts found in save_dir to the new run.
45
+ 4. INHERIT TAGS: Copies user tags (e.g., pipeline tags) from previous run.
46
+ 5. SWAP: Marks new run 'active', marks old run 'deprecated'.
47
+
48
+ Args:
49
+ save_dir: The directory containing the run data (e.g., outputs/exp/run_01)
50
+ experiment_name: MLflow experiment name (default: "Default")
51
+ run_name: Name for this MLflow run (default: save_dir.name)
52
+ tags: Additional tags to add to the run (overrides inherited tags)
53
+ log_config: If True, logs parameters from run.lock file
54
+ log_artifacts: If True, logs standard artifacts (logs, lock file)
55
+
56
+ Yields:
57
+ mlflow.ActiveRun: The active MLflow run object, or None if MLflow not available
58
+
59
+ Note:
60
+ User tags (not starting with 'flexlock.' or 'mlflow.') are automatically
61
+ inherited from the previous active run. This is useful for pipeline workflows
62
+ where you want tags like 'pipeline_run=xyz' to persist across stages.
63
+ Explicitly passing a tag via the `tags` parameter will override the inherited value.
64
+
65
+ Example:
66
+ >>> # Stage 1: Train (creates run with pipeline tag)
67
+ >>> with mlflow_context(save_dir, tags={"pipeline_run": "run_001"}):
68
+ ... mlflow.log_metric("train_loss", 0.32)
69
+ ...
70
+ >>> # Stage 2: Evaluate (inherits pipeline_run tag automatically)
71
+ >>> with mlflow_context(save_dir): # pipeline_run=run_001 inherited!
72
+ ... mlflow.log_metric("test_accuracy", 0.95)
73
+ """
74
+ try:
75
+ import mlflow
76
+ from mlflow.tracking import MlflowClient
77
+ except ImportError:
78
+ logger.warning(
79
+ "MLflow not installed. To use mlflow_context, install with: pip install mlflow"
80
+ )
81
+ yield None
82
+ return
83
+
84
+ save_dir = Path(save_dir).resolve()
85
+ logical_id = save_dir.as_posix()
86
+ client = MlflowClient()
87
+
88
+ # Ensure Experiment Exists
89
+ mlflow.set_experiment(experiment_name)
90
+ exp = client.get_experiment_by_name(experiment_name)
91
+
92
+ # 1. FIND PREVIOUS ACTIVE RUN
93
+ # We look for runs pointing to this directory that are currently active
94
+ prev_run_id = None
95
+ prev_run_tags = {}
96
+ try:
97
+ filter_str = f"tags.`flexlock.dir` = '{logical_id}' AND tags.`flexlock.status` = 'active'"
98
+ runs = client.search_runs(
99
+ experiment_ids=[exp.experiment_id],
100
+ filter_string=filter_str,
101
+ max_results=1,
102
+ order_by=["start_time DESC"],
103
+ )
104
+ if runs:
105
+ prev_run_id = runs[0].info.run_id
106
+ # Get the previous run's tags to inherit user tags (like pipeline tags)
107
+ prev_run = client.get_run(prev_run_id)
108
+ prev_run_tags = {
109
+ k: v
110
+ for k, v in prev_run.data.tags.items()
111
+ if not k.startswith("flexlock.") and not k.startswith("mlflow.")
112
+ }
113
+ logger.info(f"Found previous active run: {prev_run_id}")
114
+ if prev_run_tags:
115
+ logger.debug(f"Inheriting tags from previous run: {prev_run_tags}")
116
+ except Exception as e:
117
+ logger.debug(f"No previous run found or search failed: {e}")
118
+
119
+ # 2. START NEW RUN
120
+ # Always a fresh ID
121
+ run = mlflow.start_run(run_name=run_name or save_dir.name)
122
+ run_id = run.info.run_id
123
+ logger.info(f"Started new MLflow run {run_id} for {logical_id}")
124
+
125
+ try:
126
+ # 3. SET METADATA
127
+ default_tags = {
128
+ "flexlock.dir": logical_id,
129
+ "flexlock.status": "active", # It claims the throne immediately
130
+ }
131
+ if prev_run_id:
132
+ default_tags["flexlock.supersedes"] = prev_run_id
133
+
134
+ # Inherit user tags from previous run (e.g., pipeline tags)
135
+ # These act as defaults that can be overridden by explicit tags parameter
136
+ inherited_and_current_tags = {**prev_run_tags, **(tags or {})}
137
+ default_tags.update(inherited_and_current_tags)
138
+
139
+ mlflow.set_tags(default_tags)
140
+
141
+ # 4. INHERIT STATE (Pull forward artifacts from disk)
142
+ # This solves the "Empty Run" problem. Even if this is just a plotting script,
143
+ # we log the run.lock and logs from the folder so this run looks complete.
144
+ if log_config:
145
+ lock_path = save_dir / "run.lock"
146
+ if lock_path.exists():
147
+ try:
148
+ # Log Config Params
149
+ cfg_obj = OmegaConf.load(lock_path)
150
+ content = cfg_obj.get("config", cfg_obj) # Handle nested or flat
151
+ flat_params = _flatten_dict(
152
+ OmegaConf.to_container(content, resolve=True)
153
+ )
154
+
155
+ # Sanitize (truncate long strings to avoid MLflow param length limits)
156
+ clean_params = {k: str(v)[:250] for k, v in flat_params.items()}
157
+ mlflow.log_params(clean_params)
158
+ logger.info(f"Logged parameters from {lock_path}")
159
+
160
+ # Log the lock file itself
161
+ mlflow.log_artifact(str(lock_path))
162
+ logger.info(f"Logged artifact: {lock_path}")
163
+ except Exception as e:
164
+ logger.warning(f"MLflow config logging warning: {e}")
165
+
166
+ if log_artifacts:
167
+ # Auto-log standard files if they exist on disk
168
+ for fname in ["experiment.log", "stderr.log", "stdout.log"]:
169
+ fpath = save_dir / fname
170
+ if fpath.exists():
171
+ mlflow.log_artifact(str(fpath))
172
+ logger.info(f"Logged artifact: {fpath}")
173
+
174
+ # Yield control to user code (to log metrics, plots, etc.)
175
+ yield run
176
+
177
+ except Exception:
178
+ # If user code crashes, we let it fail normally
179
+ raise
180
+
181
+ finally:
182
+ mlflow.end_run()
183
+
184
+ # 5. DEPRECATE OLD RUN (The atomic swap)
185
+ # Only do this if the new run finished successfully (reached this block)
186
+ if prev_run_id:
187
+ try:
188
+ client.set_tag(prev_run_id, "flexlock.status", "deprecated")
189
+ client.set_tag(prev_run_id, "flexlock.superseded_by", run_id)
190
+ logger.info(f"Superseded previous MLflow run {prev_run_id}")
191
+ except Exception as e:
192
+ logger.warning(f"Failed to deprecate run {prev_run_id}: {e}")
flexlock/parallel.py ADDED
@@ -0,0 +1,378 @@
1
+ """Manages parallel task execution for FlexLock."""
2
+
3
+ from pathlib import Path
4
+ from omegaconf import OmegaConf, DictConfig
5
+ from loguru import logger
6
+ import hashlib
7
+ from flexlock.taskdb import queue_tasks, pending_count, get_status_counts_by_tag
8
+ from flexlock.taskdb import _hash_task as _taskdb_hash_task
9
+ from flexlock.worker import worker_loop
10
+ from flexlock.backends.slurm import SlurmBackend
11
+ from flexlock.backends.pbs import PBSBackend
12
+ import multiprocessing
13
+ from multiprocessing import Process
14
+ import yaml
15
+ from typing import Any, List
16
+ from flexlock.snapshot import snapshot
17
+ from flexlock.utils import extract_tracking_info
18
+ from flexlock import config
19
+
20
+
21
+ def load_tasks(tasks: str, tasks_key: str, cfg: DictConfig) -> List[Any]:
22
+ """Load tasks from a file or from the config."""
23
+ if tasks:
24
+ all_tasks = []
25
+ for t in tasks:
26
+ p_tasks = Path(t)
27
+ if not p_tasks.exists():
28
+ raise FileNotFoundError(f"Tasks file not found: {t}")
29
+ if p_tasks.suffix == ".txt":
30
+ all_tasks.extend(
31
+ [line.strip() for line in p_tasks.read_text().splitlines()]
32
+ )
33
+ elif p_tasks.suffix in [".yaml", ".yml"]:
34
+ with p_tasks.open() as f:
35
+ all_tasks.extend(yaml.safe_load(f))
36
+ else:
37
+ raise ValueError(f"Unsupported tasks file format: {p_tasks.suffix}")
38
+ return all_tasks
39
+ elif tasks_key:
40
+ return OmegaConf.select(cfg, tasks_key)
41
+ return []
42
+
43
+
44
+ def _default_tag(tasks, save_dir) -> str:
45
+ """Deterministic 12-hex-char tag for a sweep.
46
+
47
+ Stable across re-runs of the same (tasks, save_dir) pair, so resume-aware
48
+ INSERT OR IGNORE dedup works correctly when a sweep is restarted.
49
+ """
50
+ task_ids = "".join(sorted(_taskdb_hash_task(t) for t in tasks))
51
+ return hashlib.sha1(f"{task_ids}{save_dir}".encode()).hexdigest()[:12]
52
+
53
+
54
+ class ParallelExecutor:
55
+ """Manages the execution of tasks in parallel using a centralized task queue.
56
+
57
+ This class orchestrates task execution across different backends (local, Slurm, PBS)
58
+ by interacting with a SQLite database for task management. It supports dynamic task
59
+ distribution (pull model) and result aggregation.
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ func,
65
+ tasks: List[Any],
66
+ task_target: str | None,
67
+ cfg: DictConfig,
68
+ n_jobs: int = 1,
69
+ slurm_config: str | None = None,
70
+ pbs_config: str | None = None,
71
+ local_workers: int | None = None,
72
+ isolated: bool = False,
73
+ tag: str | None = None,
74
+ note: str | None = None,
75
+ ):
76
+ """Initializes the ParallelExecutor.
77
+
78
+ Args:
79
+ func: The function to execute for each task.
80
+ tasks: A list of tasks to be executed.
81
+ task_target: The OmegaConf path to merge task-specific configurations into.
82
+ cfg: The base OmegaConf configuration.
83
+ n_jobs: Number of parallel jobs for local execution.
84
+ slurm_config: Path to the Slurm configuration file.
85
+ pbs_config: Path to the PBS configuration file.
86
+ local_workers: Number of local worker processes to spawn.
87
+ isolated: If True, always run in a spawned subprocess even when n_jobs=1.
88
+ Use this for stages that initialise GPU/CUDA so that the CUDA context
89
+ is confined to the child and never leaks into the parent process.
90
+ tag: Tag scoping this sweep's rows in the shared task DB. When ``None``
91
+ a deterministic hash of the task list + save_dir is used so that
92
+ re-runs reuse the same tag (resume-safe). Pass an explicit human-
93
+ readable string (e.g. ``"extract"``) to target this sweep from the
94
+ ``flexlock-worker --tags`` CLI.
95
+ """
96
+ self.func = func
97
+ self.tasks = tasks
98
+ self.task_target = task_target
99
+ self.cfg = cfg
100
+ self.n_jobs = n_jobs
101
+ self.local_workers = local_workers
102
+ self.isolated = isolated
103
+ self.note = note
104
+
105
+ self.save_dir = Path(cfg.save_dir)
106
+ self.db_path = self.save_dir / "run.lock.tasks.db"
107
+
108
+ self.tag = tag if tag is not None else _default_tag(tasks, self.save_dir)
109
+
110
+ if self.db_path.exists():
111
+ existing_by_tag = get_status_counts_by_tag(self.db_path)
112
+ existing_tags = set(existing_by_tag.keys())
113
+ if existing_tags and self.tag not in existing_tags:
114
+ # Different sweeps coexisting in one DB — expected, log at INFO.
115
+ logger.info(
116
+ f"Task DB already exists with tag(s) {existing_tags!r}: {self.db_path}\n"
117
+ f" New sweep (tag={self.tag!r}) will add {len(tasks)} rows alongside them."
118
+ )
119
+ elif self.tag in existing_tags:
120
+ logger.info(
121
+ f"Resuming sweep (tag={self.tag!r}) in existing DB: {self.db_path}\n"
122
+ f" Tasks with matching hashes will be skipped (INSERT OR IGNORE)."
123
+ )
124
+ else:
125
+ logger.warning(
126
+ f"Task DB already exists: {self.db_path}\n"
127
+ f" New sweep (tag={self.tag!r}) has {len(tasks)} tasks — tasks with "
128
+ f"matching hashes will be silently skipped (INSERT OR IGNORE).\n"
129
+ f" If this is a different sweep reusing the same save_dir, "
130
+ f"consider using a per-sweep subdirectory to avoid DB collisions."
131
+ )
132
+
133
+ queue_tasks(self.db_path, tasks, tag=self.tag)
134
+ logger.info(f"Queued {len(tasks)} tasks (tag={self.tag!r})")
135
+
136
+ # ----- backend -----
137
+ self.job = None # set in run() once an HPC job is submitted
138
+ self.backend = None
139
+ if slurm_config:
140
+ p = OmegaConf.to_container(OmegaConf.load(slurm_config), resolve=True)
141
+ self.backend = SlurmBackend(folder=self.save_dir / "slurm_logs", **p)
142
+ elif pbs_config:
143
+ p = OmegaConf.to_container(OmegaConf.load(pbs_config), resolve=True)
144
+ self.backend = PBSBackend(folder=self.save_dir / "pbs_logs", **p)
145
+
146
+ def _run_locally(self):
147
+ num_workers = self.local_workers or self.n_jobs
148
+ if num_workers == 1 and not self.isolated:
149
+ worker_loop(self.func, self.cfg, self.task_target, self.db_path,
150
+ tags=[self.tag])
151
+ else:
152
+ # Use 'spawn' instead of the default 'fork' to avoid inheriting
153
+ # GPU/CUDA contexts and threading locks from the parent process.
154
+ # fork + CUDA (or any initialised GPU library) causes deadlocks
155
+ # in child processes.
156
+ ctx = multiprocessing.get_context("spawn")
157
+ procs = [
158
+ ctx.Process(
159
+ target=worker_loop,
160
+ args=(self.func, self.cfg, self.task_target, self.db_path,
161
+ [self.tag]),
162
+ )
163
+ for _ in range(num_workers)
164
+ ]
165
+ for p in procs:
166
+ p.start()
167
+ try:
168
+ for p in procs:
169
+ p.join()
170
+ except KeyboardInterrupt:
171
+ from .taskdb import mark_orphans_interrupted
172
+
173
+ logger.warning(
174
+ "Keyboard interrupt — terminating worker processes. "
175
+ "In-flight tasks will be marked interrupted."
176
+ )
177
+ for p in procs:
178
+ p.terminate()
179
+ for p in procs:
180
+ p.join(timeout=10)
181
+ # Force-kill any that didn't exit cleanly.
182
+ for p in procs:
183
+ if p.is_alive():
184
+ p.kill()
185
+ p.join()
186
+ # Workers killed via SIGTERM don't run their own interrupt
187
+ # handler, so reconcile here: any task still 'running' is
188
+ # owned by a now-dead worker.
189
+ mark_orphans_interrupted(self.db_path, tags=[self.tag])
190
+ raise
191
+
192
+ def _wait_for_completion(
193
+ self, timeout: int = None, poll_interval: int = None
194
+ ) -> bool:
195
+ """
196
+ Wait for all tasks to complete by polling the database.
197
+
198
+ Args:
199
+ timeout: Maximum time to wait in seconds (None = no timeout)
200
+ poll_interval: How often to check status in seconds (defaults to config.POLL_INTERVAL)
201
+
202
+ Returns:
203
+ bool: True if all tasks completed successfully, False on timeout or failure
204
+ """
205
+ if poll_interval is None:
206
+ poll_interval = config.POLL_INTERVAL
207
+ import time
208
+ from .taskdb import get_status_counts, mark_orphans_interrupted
209
+
210
+ logger.info("Waiting for tasks to complete...")
211
+ start_time = time.time()
212
+ last_log_time = start_time
213
+
214
+ job_id = self.job.job_id if self.job is not None else None
215
+ # Require the job to look terminal on this many consecutive polls
216
+ # before reconciling, so a single racy/transient status read (e.g.
217
+ # squeue+sacct both momentarily blank right after submission, or a
218
+ # qstat hiccup) can never reap a healthy job.
219
+ terminal_confirmations = 0
220
+ TERMINAL_DEBOUNCE = 3
221
+
222
+ try:
223
+ while True:
224
+ status_counts = get_status_counts(self.db_path, tags=[self.tag])
225
+ pending = status_counts.get("pending", 0)
226
+ running = status_counts.get("running", 0)
227
+ done = status_counts.get("done", 0)
228
+ failed = status_counts.get("failed", 0)
229
+ interrupted = status_counts.get("interrupted", 0)
230
+ total = pending + running + done + failed + interrupted
231
+
232
+ # All terminal — nothing left in flight.
233
+ if pending == 0 and running == 0:
234
+ issues = failed + interrupted
235
+ if issues:
236
+ logger.warning(
237
+ f"Sweep finished with issues: {failed} failed, {interrupted} interrupted"
238
+ )
239
+ else:
240
+ logger.success(f"All {done} tasks completed successfully")
241
+ return issues == 0
242
+
243
+ # Reconcile against the scheduler's ground truth: only when the
244
+ # job is *confirmed* ended (not merely 'unknown'/unreported) yet
245
+ # tasks remain pending/running did the worker die (wall-time,
246
+ # OOM, crash) without finishing them. Debounced to ignore
247
+ # transient status reads.
248
+ if job_id is not None and self.backend is not None:
249
+ if self.backend.is_terminal(job_id):
250
+ terminal_confirmations += 1
251
+ else:
252
+ terminal_confirmations = 0
253
+
254
+ if terminal_confirmations >= TERMINAL_DEBOUNCE:
255
+ orphaned = mark_orphans_interrupted(
256
+ self.db_path, job_id=job_id, tags=[self.tag]
257
+ )
258
+ logger.warning(
259
+ f"HPC job {job_id} has ended but "
260
+ f"{pending} task(s) pending / {running} running. "
261
+ f"Marked {orphaned} orphaned task(s) interrupted. "
262
+ f"Re-run pending/interrupted tasks with: "
263
+ f"flexlock-worker --task-db {self.db_path} --reclaim"
264
+ )
265
+ return False
266
+
267
+ # Log progress periodically
268
+ elapsed = time.time() - start_time
269
+ if elapsed - (last_log_time - start_time) >= config.LOG_FREQUENCY:
270
+ progress = (done + failed + interrupted) / total * 100 if total > 0 else 0
271
+ logger.info(
272
+ f"Progress: {progress:.1f}% "
273
+ f"(pending={pending}, running={running}, done={done}, "
274
+ f"failed={failed}, interrupted={interrupted})"
275
+ )
276
+ last_log_time = time.time()
277
+
278
+ # Check timeout
279
+ if timeout and elapsed > timeout:
280
+ logger.warning(
281
+ f"Timeout after {timeout}s "
282
+ f"(pending={pending}, running={running})"
283
+ )
284
+ return False
285
+
286
+ time.sleep(poll_interval)
287
+ except KeyboardInterrupt:
288
+ logger.warning(
289
+ "Keyboard interrupt received while waiting. "
290
+ "The submitted job keeps running on the cluster; "
291
+ f"monitor with: flexlock-status {self.db_path}"
292
+ )
293
+ return False
294
+
295
+ def run(self, wait: bool = True, timeout: int = None, poll_interval: int = None):
296
+ """
297
+ Execute tasks via backend or locally.
298
+
299
+ Args:
300
+ wait: If True, blocks until all tasks complete (default: True for local, optional for HPC)
301
+ timeout: Maximum time to wait in seconds (None = no timeout, applies only if wait=True)
302
+ poll_interval: How often to check task status in seconds (defaults to config.POLL_INTERVAL, applies only if wait=True)
303
+
304
+ Returns:
305
+ bool: True if tasks completed successfully (or not waiting), False on timeout/failure
306
+ """
307
+ from flexlock.taskdb import dump_to_yaml
308
+
309
+ if pending_count(self.db_path, tags=[self.tag]) == 0:
310
+ logger.info("All tasks already completed.")
311
+ dump_to_yaml(self.db_path, self.save_dir / "run.lock.tasks",
312
+ tags=[self.tag])
313
+ return True
314
+
315
+ # 1. Prepare Root Directory
316
+ root_dir = Path(self.cfg.save_dir) # e.g., outputs/sweep_name
317
+ root_dir.mkdir(parents=True, exist_ok=True)
318
+
319
+ # 2. CREATE MASTER SNAPSHOT
320
+ # This captures the Code state ONCE for the whole sweep
321
+ # We assume the Main Process has the correct context (repos, etc.)
322
+ repos, data, _ = extract_tracking_info(self.cfg)
323
+ # Set default repos if none specified
324
+ snapshot(self.cfg, repos=repos, data=data, save_path=root_dir, note=self.note)
325
+
326
+ # 3. Populate SQLite DB
327
+ # Store 'root_dir' in the DB so workers know where the Master Lock is.
328
+ # This is handled by the existing queue_tasks call in __init__
329
+
330
+ try:
331
+ logger.info(
332
+ f"Use 'flexlock-status {self.db_path}' to monitor task progress"
333
+ )
334
+
335
+ if self.backend is None:
336
+ # Local execution - always completes synchronously
337
+ logger.info("Running locally (pull-from-DB)")
338
+ self._run_locally()
339
+
340
+ # All local workers have exited. Any task still 'running' was
341
+ # owned by a worker that died (hard crash / OOM) without
342
+ # recording a terminal state — reconcile it now.
343
+ from .taskdb import get_status_counts, mark_orphans_interrupted
344
+
345
+ orphaned = mark_orphans_interrupted(self.db_path, tags=[self.tag])
346
+ if orphaned:
347
+ logger.warning(
348
+ f"Marked {orphaned} task(s) interrupted "
349
+ f"(worker exited without finishing them)."
350
+ )
351
+
352
+ status_counts = get_status_counts(self.db_path, tags=[self.tag])
353
+ failed = status_counts.get("failed", 0)
354
+ interrupted = status_counts.get("interrupted", 0)
355
+ success = (failed + interrupted) == 0
356
+ else:
357
+ # HPC backend execution
358
+ # Fixed args for worker_loop (as tuple for *args)
359
+ fixed_args = (self.func, self.cfg, self.task_target, self.db_path,
360
+ [self.tag])
361
+ self.job = self.backend.submit(worker_loop, *fixed_args)
362
+ logger.info(
363
+ f"Submitted {self.backend.__class__.__name__} job {self.job.job_id}"
364
+ )
365
+
366
+ # Wait for completion if requested
367
+ if wait:
368
+ success = self._wait_for_completion(timeout, poll_interval)
369
+ else:
370
+ logger.info("Job submitted (not waiting for completion)")
371
+ success = True
372
+
373
+ finally:
374
+ # Dump tasks to YAML after all jobs are submitted (or completed locally)
375
+ dump_to_yaml(self.db_path, self.save_dir / "run.lock.tasks",
376
+ tags=[self.tag])
377
+
378
+ return success