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/__init__.py +87 -0
- flexlock/api.py +1444 -0
- flexlock/backends/__init__.py +11 -0
- flexlock/backends/base.py +41 -0
- flexlock/backends/pbs.py +219 -0
- flexlock/backends/slurm.py +333 -0
- flexlock/cli.py +953 -0
- flexlock/config.py +121 -0
- flexlock/context.py +7 -0
- flexlock/data_hash.py +310 -0
- flexlock/debug.py +473 -0
- flexlock/diff.py +253 -0
- flexlock/diff_cli.py +155 -0
- flexlock/exceptions.py +50 -0
- flexlock/export.py +134 -0
- flexlock/fingerprint.py +129 -0
- flexlock/flexcli.py +163 -0
- flexlock/freeze.py +291 -0
- flexlock/git_utils.py +196 -0
- flexlock/index.py +300 -0
- flexlock/load_stage.py +60 -0
- flexlock/mlflow.py +192 -0
- flexlock/parallel.py +378 -0
- flexlock/query.py +739 -0
- flexlock/report.py +78 -0
- flexlock/report_template.html +246 -0
- flexlock/resolvers.py +248 -0
- flexlock/run_cli.py +44 -0
- flexlock/run_record.py +173 -0
- flexlock/runner.py +571 -0
- flexlock/save_dir.py +232 -0
- flexlock/skills/flexlock-new-stage/SKILL.md +57 -0
- flexlock/skills/flexlock-report/SKILL.md +50 -0
- flexlock/skills/flexlock-run-and-watch/SKILL.md +60 -0
- flexlock/skills/flexlock-survey/SKILL.md +61 -0
- flexlock/snapshot.py +245 -0
- flexlock/status_cli.py +299 -0
- flexlock/taskdb.py +685 -0
- flexlock/utils.py +710 -0
- flexlock/worker.py +228 -0
- flexlock/worker_cli.py +143 -0
- flexlock-0.8.2.dist-info/METADATA +357 -0
- flexlock-0.8.2.dist-info/RECORD +45 -0
- flexlock-0.8.2.dist-info/WHEEL +4 -0
- flexlock-0.8.2.dist-info/entry_points.txt +7 -0
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
|