PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
 
Loading...
Searching...
No Matches
Functions
picurv_cli.storage.safety Namespace Reference

Functions

list cold_study_members (str study_path)
 Return numbered study members whose local payload was pruned.
 
None require_storage_payload_local (str root_path, str operation, int checkpoint=None, checkpoints=None)
 Reject a workflow that requires payload currently held in cold storage.
 
None _require_free_space (str directory, int needed_bytes, str purpose)
 Refuse to begin staging work the destination filesystem cannot hold.
 
bool _lock_owner_active (str metadata_path)
 Conservatively determine whether a solver/post/storage owner marker is active.
 
set _collect_job_ids (payload)
 Recursively collect submitted Slurm job IDs from scheduler metadata.
 
dict _slurm_activity (str root)
 Query live Slurm state for every job recorded below an artifact scheduler directory.
 
None _assert_archive_safe (dict inventory)
 Refuse to package a changing or scheduler-ambiguous artifact.
 
 storage_operation_lock (str root_path, str operation)
 Hold an exclusive local storage-operation marker for one artifact.
 
 runtime_stage_lock (str root_path, str stage)
 Mark a locally executed solver/post stage as active for storage safety.
 

Function Documentation

◆ cold_study_members()

list picurv_cli.storage.safety.cold_study_members ( str  study_path)

Return numbered study members whose local payload was pruned.

Parameters
[in]study_pathValue supplied through the study_path argument.
Returns
Result produced by this operation.

Definition at line 36 of file safety.py.

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

◆ require_storage_payload_local()

None picurv_cli.storage.safety.require_storage_payload_local ( str  root_path,
str  operation,
int   checkpoint = None,
  checkpoints = None 
)

Reject a workflow that requires payload currently held in cold storage.

Parameters
[in]root_pathRun or study-member directory checked by an existing workflow.
[in]operationHuman-readable consuming operation.
[in]checkpointOptional single required checkpoint step.
[in]checkpointsOptional iterable of every required checkpoint step.

Definition at line 51 of file safety.py.

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

◆ _require_free_space()

None picurv_cli.storage.safety._require_free_space ( str  directory,
int  needed_bytes,
str  purpose 
)
protected

Refuse to begin staging work the destination filesystem cannot hold.

Packaging and restore both stage uncompressed bytes on disk before the verified result replaces or uploads them. Discovering the filesystem was too small only after chunks were partially written wastes the transfer and can leave a staging directory to clean up by hand; checking first turns that into an immediate, actionable refusal.

Parameters
[in]directoryDirectory whose filesystem free space is checked; must exist.
[in]needed_bytesEstimated peak bytes the operation holds on disk at once.
[in]purposeShort present-tense phrase describing the operation, for the message.
Exceptions
StorageErrorwhen free space is smaller than the estimate.

Definition at line 91 of file safety.py.

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

◆ _lock_owner_active()

bool picurv_cli.storage.safety._lock_owner_active ( str  metadata_path)
protected

Conservatively determine whether a solver/post/storage owner marker is active.

Parameters
[in]metadata_pathValue supplied through the metadata_path argument.
Returns
Result produced by this operation.

Definition at line 116 of file safety.py.

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

◆ _collect_job_ids()

set picurv_cli.storage.safety._collect_job_ids (   payload)
protected

Recursively collect submitted Slurm job IDs from scheduler metadata.

Parameters
[in]payloadValue supplied through the payload argument.
Returns
Result produced by this operation.

Definition at line 142 of file safety.py.

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

◆ _slurm_activity()

dict picurv_cli.storage.safety._slurm_activity ( str  root)
protected

Query live Slurm state for every job recorded below an artifact scheduler directory.

Parameters
[in]rootValue supplied through the root argument.
Returns
Result produced by this operation.

Definition at line 160 of file safety.py.

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

◆ _assert_archive_safe()

None picurv_cli.storage.safety._assert_archive_safe ( dict  inventory)
protected

Refuse to package a changing or scheduler-ambiguous artifact.

Parameters
[in]inventoryValue supplied through the inventory argument.

Definition at line 208 of file safety.py.

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

◆ storage_operation_lock()

picurv_cli.storage.safety.storage_operation_lock ( str  root_path,
str  operation 
)

Hold an exclusive local storage-operation marker for one artifact.

Parameters
[in]root_pathValue supplied through the root_path argument.
[in]operationValue supplied through the operation argument.

Definition at line 233 of file safety.py.

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

◆ runtime_stage_lock()

picurv_cli.storage.safety.runtime_stage_lock ( str  root_path,
str  stage 
)

Mark a locally executed solver/post stage as active for storage safety.

Parameters
[in]root_pathRun directory used as the runtime working directory.
[in]stageRuntime stage label; storage currently uses this for solver execution.

Definition at line 264 of file safety.py.

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