PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
 
Loading...
Searching...
No Matches
safety.py
Go to the documentation of this file.
1"""!
2@file safety.py
3@brief Refusals: active jobs, runtime locks, and cold-payload requirements.
4"""
5
6import argparse
7import base64
8import concurrent.futures
9import contextlib
10import datetime
11import errno
12import hashlib
13import json
14import os
15import re
16import shutil
17import socket
18import subprocess
19import sys
20import tarfile
21import tempfile
22import uuid
23from pathlib import Path
24import yaml
25from .models import (
26 STORAGE_LOCK_FILENAME,
27 StorageError,
28 _human_bytes,
29 _read_json,
30 _utc_now,
31 is_artifact_cold,
32 read_storage_state,
33)
34
35
36def cold_study_members(study_path: str) -> list:
37 """!
38 @brief Return numbered study members whose local payload was pruned.
39 @param[in] study_path Value supplied through the `study_path` argument.
40 @return Result produced by this operation.
41 """
42 cases_dir = Path(os.path.abspath(study_path)) / "cases"
43 if not cases_dir.is_dir():
44 return []
45 return [
46 child.name for child in sorted(cases_dir.iterdir())
47 if child.is_dir() and is_artifact_cold(str(child))
48 ]
49
50
51def require_storage_payload_local(
52 root_path: str,
53 operation: str,
54 checkpoint: int = None,
55 checkpoints=None,
56) -> None:
57 """!
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.
63 """
64 state = read_storage_state(root_path)
65 if not state or not state.get("local_pruned"):
66 return
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
76 )
77 if required_steps and not missing_steps:
78 return
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)
82 else:
83 # A full restore is clearer than printing hundreds of repeatable selectors.
84 suffix = ""
85 raise StorageError(
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}"
88 )
89
90
91def _require_free_space(directory: str, needed_bytes: int, purpose: str) -> None:
92 """!
93 @brief Refuse to begin staging work the destination filesystem cannot hold.
94
95 @details Packaging and restore both stage uncompressed bytes on disk before the
96 verified result replaces or uploads them. Discovering the filesystem was
97 too small only after chunks were partially written wastes the transfer
98 and can leave a staging directory to clean up by hand; checking first
99 turns that into an immediate, actionable refusal.
100 @param[in] directory Directory whose filesystem free space is checked; must exist.
101 @param[in] needed_bytes Estimated peak bytes the operation holds on disk at once.
102 @param[in] purpose Short present-tense phrase describing the operation, for the message.
103 @throws StorageError when free space is smaller than the estimate.
104 """
105 try:
106 free_bytes = shutil.disk_usage(directory).free
107 except OSError:
108 return
109 if free_bytes < needed_bytes:
110 raise StorageError(
111 f"Not enough free space at {directory} to {purpose}: need approximately "
112 f"{_human_bytes(needed_bytes)}, {_human_bytes(free_bytes)} free."
113 )
114
115
116def _lock_owner_active(metadata_path: str) -> bool:
117 """!
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.
121 """
122 owner = _read_json(metadata_path)
123 if not owner:
124 return True
125 host = owner.get("host")
126 pid = owner.get("pid")
127 if host and host != socket.gethostname():
128 return True
129 try:
130 pid = int(pid)
131 except (TypeError, ValueError):
132 return True
133 try:
134 os.kill(pid, 0)
135 except ProcessLookupError:
136 return False
137 except (PermissionError, OSError):
138 return True
139 return True
140
141
142def _collect_job_ids(payload) -> set:
143 """!
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.
147 """
148 result = set()
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}
158
159
160def _slurm_activity(root: str) -> dict:
161 """!
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.
165 """
166 job_ids = set()
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():
172 continue
173 for path in scheduler.glob("submission*.json"):
174 job_ids.update(_collect_job_ids(_read_json(str(path))))
175 if not job_ids:
176 return {"job_ids": [], "active": [], "unknown": False}
177 squeue = shutil.which("squeue")
178 if not squeue:
179 return {"job_ids": sorted(job_ids), "active": [], "unknown": True}
180 active = []
181 unknown_ids = []
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,
186 )
187 if result.returncode != 0:
188 detail = (result.stderr or result.stdout or "").lower()
189 # Slurm returns nonzero for a valid historical/purged job id. Such an id
190 # cannot be queued, so it is inactive rather than an ambiguous failure.
191 if "invalid job id" in detail or "invalid job/step" in detail:
192 continue
193 unknown_ids.append(recorded_id)
194 continue
195 for line in result.stdout.splitlines():
196 if not line.strip():
197 continue
198 job_id, _, state = line.partition("|")
199 active.append({"job_id": job_id.strip(), "state": state.strip() or "UNKNOWN"})
200 return {
201 "job_ids": sorted(job_ids),
202 "active": active,
203 "unknown": bool(unknown_ids),
204 "unknown_job_ids": unknown_ids,
205 }
206
207
208def _assert_archive_safe(inventory: dict) -> None:
209 """!
210 @brief Refuse to package a changing or scheduler-ambiguous artifact.
211 @param[in] inventory Value supplied through the `inventory` argument.
212 """
213 problems = []
214 if inventory["incomplete_checkpoints"]:
215 problems.append("incomplete checkpoint(s): " + ", ".join(inventory["incomplete_checkpoints"][:3]))
216 if inventory["active_locks"]:
217 problems.append("active runtime lock(s): " + ", ".join(inventory["active_locks"]))
218 if inventory["slurm"]["active"]:
219 problems.append(
220 "active Slurm job(s): " + ", ".join(
221 f"{item['job_id']} ({item['state']})" for item in inventory["slurm"]["active"]
222 )
223 )
224 if inventory["slurm"]["unknown"]:
225 problems.append(
226 "recorded Slurm job IDs could not be checked because squeue is unavailable or failed"
227 )
228 if problems:
229 raise StorageError("Artifact is not safe to archive/offload: " + "; ".join(problems) + ".")
230
231
232@contextlib.contextmanager
233def storage_operation_lock(root_path: str, operation: str):
234 """!
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.
238 """
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}.")
244 try:
245 os.remove(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
250 try:
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)
254 stream.write("\n")
255 yield
256 finally:
257 try:
258 os.remove(lock_path)
259 except FileNotFoundError:
260 pass
261
262
263@contextlib.contextmanager
264def runtime_stage_lock(root_path: str, stage: str):
265 """!
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.
269 """
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}.")
276 os.remove(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)
282 stream.write("\n")
283 try:
284 yield
285 finally:
286 try:
287 os.remove(lock_path)
288 except FileNotFoundError:
289 pass
User-facing storage workflow failure.
Definition models.py:139