51def require_storage_payload_local(
54 checkpoint: int =
None,
58 @brief Reject a workflow that requires payload currently held in cold storage.
59 @param[in] root_path Run or study-member directory checked by an existing workflow.
60 @param[in] operation Human-readable consuming operation.
61 @param[in] checkpoint Optional single required checkpoint step.
62 @param[in] checkpoints Optional iterable of every required checkpoint step.
64 state = read_storage_state(root_path)
65 if not state
or not state.get(
"local_pruned"):
67 required_steps = set()
68 if checkpoint
is not None:
69 required_steps.add(int(checkpoint))
70 if checkpoints
is not None:
71 required_steps.update(int(step)
for step
in checkpoints)
72 restored = set(state.get(
"restored_components")
or [])
73 restored.update(state.get(
"retained_components")
or [])
74 missing_steps = sorted(
75 step
for step
in required_steps
if f
"checkpoint:{step}" not in restored
77 if required_steps
and not missing_steps:
79 archive_id = state.get(
"archive_id",
"<archive-id>")
80 if missing_steps
and len(missing_steps) <= 8:
81 suffix =
"".join(f
" --checkpoint {step}" for step
in missing_steps)
86 f
"{operation} requires payload archived from {os.path.abspath(root_path)}. Restore it first with:\n"
87 f
" picurv storage restore --archive-id {archive_id}{suffix}"
116def _lock_owner_active(metadata_path: str) -> bool:
118 @brief Conservatively determine whether a solver/post/storage owner marker is active.
119 @param[in] metadata_path Value supplied through the `metadata_path` argument.
120 @return Result produced by this operation.
122 owner = _read_json(metadata_path)
125 host = owner.get(
"host")
126 pid = owner.get(
"pid")
127 if host
and host != socket.gethostname():
131 except (TypeError, ValueError):
135 except ProcessLookupError:
137 except (PermissionError, OSError):
142def _collect_job_ids(payload) -> set:
144 @brief Recursively collect submitted Slurm job IDs from scheduler metadata.
145 @param[in] payload Value supplied through the `payload` argument.
146 @return Result produced by this operation.
149 if isinstance(payload, dict):
150 if payload.get(
"submitted")
and payload.get(
"job_id")
is not None:
151 result.add(str(payload[
"job_id"]).strip())
152 for value
in payload.values():
153 result.update(_collect_job_ids(value))
154 elif isinstance(payload, list):
155 for value
in payload:
156 result.update(_collect_job_ids(value))
157 return {item
for item
in result
if item}
160def _slurm_activity(root: str) -> dict:
162 @brief Query live Slurm state for every job recorded below an artifact scheduler directory.
163 @param[in] root Value supplied through the `root` argument.
164 @return Result produced by this operation.
167 scheduler_dirs = [Path(root) /
"scheduler"]
168 if (Path(root) /
"cases").is_dir():
169 scheduler_dirs.extend((Path(root) /
"cases").glob(
"*/scheduler"))
170 for scheduler
in scheduler_dirs:
171 if not scheduler.is_dir():
173 for path
in scheduler.glob(
"submission*.json"):
174 job_ids.update(_collect_job_ids(_read_json(str(path))))
176 return {
"job_ids": [],
"active": [],
"unknown":
False}
177 squeue = shutil.which(
"squeue")
179 return {
"job_ids": sorted(job_ids),
"active": [],
"unknown":
True}
182 for recorded_id
in sorted(job_ids):
183 result = subprocess.run(
184 [squeue,
"-h",
"-j", recorded_id,
"-o",
"%i|%T"],
185 text=
True, capture_output=
True, check=
False,
187 if result.returncode != 0:
188 detail = (result.stderr
or result.stdout
or "").lower()
191 if "invalid job id" in detail
or "invalid job/step" in detail:
193 unknown_ids.append(recorded_id)
195 for line
in result.stdout.splitlines():
198 job_id, _, state = line.partition(
"|")
199 active.append({
"job_id": job_id.strip(),
"state": state.strip()
or "UNKNOWN"})
201 "job_ids": sorted(job_ids),
203 "unknown": bool(unknown_ids),
204 "unknown_job_ids": unknown_ids,
233def storage_operation_lock(root_path: str, operation: str):
235 @brief Hold an exclusive local storage-operation marker for one artifact.
236 @param[in] root_path Value supplied through the `root_path` argument.
237 @param[in] operation Value supplied through the `operation` argument.
239 root = os.path.abspath(root_path)
240 lock_path = os.path.join(root, STORAGE_LOCK_FILENAME)
241 if os.path.exists(lock_path):
242 if _lock_owner_active(lock_path):
243 raise StorageError(f
"Another storage operation owns {lock_path}.")
246 except OSError
as exc:
247 raise StorageError(f
"Unable to remove stale storage lock {lock_path}: {exc}")
from exc
248 payload = {
"operation": operation,
"pid": os.getpid(),
"host": socket.gethostname(),
"started_at": _utc_now()}
249 flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
251 descriptor = os.open(lock_path, flags, 0o600)
252 with os.fdopen(descriptor,
"w", encoding=
"utf-8")
as stream:
253 json.dump(payload, stream, indent=2, sort_keys=
True)
259 except FileNotFoundError:
263@contextlib.contextmanager
264def runtime_stage_lock(root_path: str, stage: str):
266 @brief Mark a locally executed solver/post stage as active for storage safety.
267 @param[in] root_path Run directory used as the runtime working directory.
268 @param[in] stage Runtime stage label; storage currently uses this for solver execution.
270 scheduler = os.path.join(os.path.abspath(root_path),
"scheduler")
271 os.makedirs(scheduler, exist_ok=
True)
272 lock_path = os.path.join(scheduler, f
"{stage}.lock.json")
273 if os.path.exists(lock_path):
274 if _lock_owner_active(lock_path):
275 raise StorageError(f
"A {stage} runtime stage already owns {lock_path}.")
277 payload = {
"stage": stage,
"pid": os.getpid(),
"host": socket.gethostname(),
"started_at": _utc_now()}
278 flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
279 descriptor = os.open(lock_path, flags, 0o600)
280 with os.fdopen(descriptor,
"w", encoding=
"utf-8")
as stream:
281 json.dump(payload, stream, indent=2, sort_keys=
True)
288 except FileNotFoundError: