PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
 
Loading...
Searching...
No Matches
models.py
Go to the documentation of this file.
1"""!
2@file models.py
3@brief Storage constants, profiles, and the local artifact state marker.
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
25
26
27STORAGE_CONFIG_FILENAME = ".picurv-storage.yml"
28
29
30STORAGE_STATE_FILENAME = ".picurv-storage.json"
31
32
33STORAGE_LOCK_FILENAME = ".picurv-storage.lock.json"
34
35
36#: Schema 2 stores chunk payloads in a content-addressed blob store shared by every
37#: archive, so identical content is uploaded once and an interrupted upload resumes.
38STORAGE_SCHEMA_VERSION = 2
39
40
41REMOTE_OBJECTS_DIRECTORY = "objects"
42
43
44#: Content-addressed payload store, shared across every archive on this remote.
45REMOTE_BLOBS_DIRECTORY = "blobs"
46
47
48REMOTE_MANIFEST_FILENAME = "manifest.json"
49
50
51#: Artifact kinds a local PICurv directory can declare itself to be in its manifest.
52#: Not a user choice: it is written by the conductor and read back, never selected.
53STORAGE_ARTIFACT_TYPES = ("run", "study", "study-case")
54
55
56#: A run's own identity manifest, written by the conductor at the end of staging. It,
57#: not the directory name, is what says a directory is a run and what that run is
58#: called: a run directory can be renamed, copied, or restored under another name, and
59#: only the manifest survives that intact.
60RUN_MANIFEST_FILENAME = "manifest.json"
61
62
63#: A study's identity manifest. Same contract as RUN_MANIFEST_FILENAME, one level up.
64STUDY_MANIFEST_FILENAME = "study_manifest.json"
65
66
67REMOTE_COMPLETE_FILENAME = "COMPLETE"
68
69
70DEFAULT_PROFILE_NAME = "archive"
71
72
73DEFAULT_CHUNK_SIZE_GIB = 8.0
74
75
76DEFAULT_STORAGE_WORKERS = max(1, min(8, os.cpu_count() or 1))
77
78
79AUTO_NO_COMPRESSION_BYTES = 256 * 1024 * 1024
80
81
82AUTO_MAXIMUM_COMPRESSION_BYTES = 20 * 1024 * 1024 * 1024
83
84
85CHECKPOINT_DIRECTORY_PATTERN = re.compile(r"^step_(\d{12})$")
86
87
88INCOMPLETE_CHECKPOINT_PATTERN = re.compile(r"^\.step_\d{12}\.incomplete\.")
89
90
91ARCHIVE_ID_PATTERN = re.compile(r"^[0-9a-f]{32}$")
92
93
94KNOWN_CHECKPOINT_VERSION = 1
95
96
97#: Component assigned to any path the classifier does not recognize. Retained locally
98#: by every offload policy, so an unregistered file is reported rather than removed.
99UNCLASSIFIED_COMPONENT = "unclassified"
100
101
102#: Workspace identity file. Storage reads it to name a workspace archive; it never
103#: rewrites workspace configuration.
104WORKSPACE_CONFIG_FILENAME = ".picurv-workspace.yml"
105
106
107#: Directories a workspace archive never descends into. Runs and studies are their own
108#: artifacts with their own archives; duplicating them inside a workspace archive would
109#: store the same bytes twice and blur which object owns what.
110WORKSPACE_EXCLUDED_ROOTS = ("runs", "studies")
111
112
113#: Components no offload policy may prune, whatever it retains. "assets" is here
114#: because no named policy lists it as retained - an ordinary offload would otherwise
115#: delete the entire local asset store unconditionally, bypassing the reference-aware
116#: check that `storage prune --assets --unused-locally` performs before removing an
117#: object. Reclaiming assets is that command's job, deliberately, never an offload's.
118ALWAYS_RETAINED_COMPONENTS = frozenset(
119 {UNCLASSIFIED_COMPONENT, "workspace-config", "workspace-inputs", "assets"}
120)
121
122
123STORAGE_RESTORE_COMPONENTS = (
124 "inputs", "raw-output", "analysis", "visualization", "logs", "assets",
125 UNCLASSIFIED_COMPONENT, "workspace-config", "workspace-inputs",
126)
127
128
129#: Chunks a partial restore (`--checkpoint`/`--component`) always includes, whatever was
130#: selected: a run/study-case archive's own identity ("metadata"), or a workspace
131#: archive's own identity ("workspace-config"). Mirrors ALWAYS_RETAINED_COMPONENTS on
132#: the offload side - identity and config evidence stay available either way.
133ALWAYS_RESTORED_COMPONENTS = frozenset({"metadata", "workspace-config"})
134
135
136_PARALLEL_GZIP_VALUES = {"fast", "balanced"}
137
138
139class StorageError(RuntimeError):
140 """! @brief User-facing storage workflow failure. """
141
142
143def _utc_now() -> str:
144 """!
145 @brief Return a stable UTC timestamp for storage metadata.
146 @return Result produced by this operation.
147 """
148 return datetime.datetime.now(datetime.timezone.utc).isoformat()
149
150
151def _human_bytes(value: int) -> str:
152 """!
153 @brief Format a byte count for concise command output.
154 @param[in] value Value supplied through the `value` argument.
155 @return Result produced by this operation.
156 """
157 size = float(value)
158 for suffix in ("B", "KiB", "MiB", "GiB", "TiB", "PiB"):
159 if size < 1024.0 or suffix == "PiB":
160 return f"{size:.1f} {suffix}" if suffix != "B" else f"{int(size)} B"
161 size /= 1024.0
162 return f"{int(value)} B"
163
164
165def _sha256_file(path: str) -> str:
166 """!
167 @brief Calculate SHA-256 without loading a potentially large file into memory.
168 @param[in] path Value supplied through the `path` argument.
169 @return Result produced by this operation.
170 """
171 digest = hashlib.sha256()
172 with open(path, "rb") as stream:
173 while True:
174 block = stream.read(8 * 1024 * 1024)
175 if not block:
176 break
177 digest.update(block)
178 return digest.hexdigest()
179
180
181def _atomic_write_json(path: str, payload: dict) -> None:
182 """!
183 @brief Atomically replace a JSON state or manifest file.
184 @param[in] path Value supplied through the `path` argument.
185 @param[in] payload Value supplied through the `payload` argument.
186 """
187 path_abs = os.path.abspath(path)
188 os.makedirs(os.path.dirname(path_abs), exist_ok=True)
189 temporary = f"{path_abs}.tmp.{os.getpid()}"
190 with open(temporary, "w", encoding="utf-8") as stream:
191 json.dump(payload, stream, indent=2, sort_keys=True)
192 stream.write("\n")
193 stream.flush()
194 os.fsync(stream.fileno())
195 os.replace(temporary, path_abs)
196
197
198def _read_json(path: str):
199 """!
200 @brief Read a JSON mapping when present, otherwise return None.
201 @param[in] path Value supplied through the `path` argument.
202 @return Result produced by this operation.
203 """
204 try:
205 with open(path, "r", encoding="utf-8") as stream:
206 payload = json.load(stream)
207 except (OSError, ValueError):
208 return None
209 return payload if isinstance(payload, dict) else None
210
211
212def read_artifact_manifest(root_path: str):
213 """!
214 @brief Read a local artifact's own identity manifest, run or study.
215 @param[in] root_path Run, study, or study-member directory.
216 @return The parsed manifest mapping, or None when the directory has none.
217 """
218 root = os.path.abspath(root_path)
219 for filename in (RUN_MANIFEST_FILENAME, STUDY_MANIFEST_FILENAME):
220 payload = _read_json(os.path.join(root, filename))
221 if payload:
222 return payload
223 return None
224
225
226def read_artifact_identity(root_path: str) -> dict:
227 """!
228 @brief Resolve what a local artifact directory is, and what it calls itself.
229
230 @details The manifest is authoritative. The directory basename is a last resort,
231 reported as such through `identity_source`, so a caller can tell a real
232 identity from a guess instead of both looking the same.
233 @param[in] root_path Run, study, or study-member directory.
234 @return Mapping with `artifact_type`, `run_id`, `study_id`, `case_id`, and
235 `identity_source` ("manifest" or "directory-name").
236 """
237 root = os.path.abspath(root_path)
238 manifest = read_artifact_manifest(root)
239 if manifest:
240 artifact_type = manifest.get("artifact_type")
241 if artifact_type in STORAGE_ARTIFACT_TYPES:
242 return {
243 "artifact_type": artifact_type,
244 "run_id": manifest.get("run_id"),
245 "study_id": manifest.get("study_id"),
246 "case_id": manifest.get("case_id"),
247 "paths": manifest.get("paths") or {},
248 "identity_source": "manifest",
249 }
250 return {
251 "artifact_type": None,
252 "run_id": os.path.basename(root),
253 "study_id": os.path.basename(root),
254 "case_id": None,
255 "paths": {},
256 "identity_source": "directory-name",
257 }
258
259
260
261def _find_upwards(start: str, filename: str):
262 """!
263 @brief Find the nearest named file at or above a filesystem anchor.
264 @param[in] start Value supplied through the `start` argument.
265 @param[in] filename Value supplied through the `filename` argument.
266 @return Result produced by this operation.
267 """
268 current = os.path.abspath(start)
269 if os.path.isfile(current):
270 current = os.path.dirname(current)
271 while True:
272 candidate = os.path.join(current, filename)
273 if os.path.isfile(candidate):
274 return candidate
275 parent = os.path.dirname(current)
276 if parent == current:
277 return None
278 current = parent
279
280
281def storage_workspace_root(start: str = None) -> str:
282 """!
283 @brief Locate the initialized workspace a storage command is standing in.
284 @param[in] start Directory to search from; defaults to the current directory.
285 @return Absolute workspace root, or None when the command is not inside one.
286 """
287 marker = _find_upwards(start or os.getcwd(), WORKSPACE_CONFIG_FILENAME)
288 return os.path.dirname(marker) if marker else None
289
290
291def storage_config_origin(config_path: str, workspace_root: str = None) -> str:
292 """!
293 @brief Classify where an active storage configuration came from.
294
295 @details Discovery walks upward without stopping at the workspace boundary, which
296 is deliberate: one configuration is meant to be shareable across a
297 directory of campaigns. What makes that dangerous is silence, not sharing.
298 An offload uploads to the remote this file names and then prunes local
299 payload, so which file answered has to be visible at the point of use.
300 @param[in] config_path Resolved storage configuration path.
301 @param[in] workspace_root Owning workspace, or None to discover it from the cwd.
302 @return "workspace" when the file belongs to the workspace in scope, "shared" when
303 it was inherited from above it, and "unowned" when there is no workspace.
304 """
305 if not config_path:
306 return "unowned"
307 root = workspace_root if workspace_root is not None else storage_workspace_root()
308 if not root:
309 return "unowned"
310 resolved = os.path.abspath(config_path)
311 root = os.path.abspath(root)
312 return "workspace" if os.path.dirname(resolved) == root else "shared"
313
314
315def resolve_storage_config_path(explicit_path: str = None, require: bool = True) -> str:
316 """!
317 @brief Resolve an explicit or nearest workspace storage configuration.
318 @param[in] explicit_path Optional user-selected YAML path.
319 @param[in] require Whether a missing configuration is an error.
320 @return Result produced by this operation.
321 """
322 if explicit_path:
323 result = os.path.abspath(explicit_path)
324 else:
325 result = _find_upwards(os.getcwd(), STORAGE_CONFIG_FILENAME)
326 if result and os.path.isfile(result):
327 return result
328 if require:
329 raise StorageError(
330 "No PICurv storage configuration was found. Run "
331 "'picurv storage setup --remote <rclone-remote:path>' first or pass --storage-config."
332 )
333 return os.path.abspath(explicit_path or os.path.join(os.getcwd(), STORAGE_CONFIG_FILENAME))
334
335
336def load_storage_profile(profile_name: str = None, config_path: str = None) -> dict:
337 """!
338 @brief Load and validate one non-secret rclone storage profile.
339 @param[in] profile_name Value supplied through the `profile_name` argument.
340 @param[in] config_path Value supplied through the `config_path` argument.
341 @return Result produced by this operation.
342 """
343 resolved_config = resolve_storage_config_path(config_path)
344 with open(resolved_config, "r", encoding="utf-8") as stream:
345 payload = yaml.safe_load(stream) or {}
346 profiles = payload.get("profiles")
347 if not isinstance(profiles, dict):
348 raise StorageError(f"Storage config has no 'profiles' mapping: {resolved_config}")
349 selected = profile_name or payload.get("default_profile") or DEFAULT_PROFILE_NAME
350 profile = profiles.get(selected)
351 if not isinstance(profile, dict):
352 raise StorageError(f"Storage profile '{selected}' does not exist in {resolved_config}.")
353 remote = profile.get("remote")
354 if not isinstance(remote, str) or not remote.strip():
355 raise StorageError(f"Storage profile '{selected}' requires a non-empty remote.")
356 result = dict(profile)
357 result["name"] = selected
358 result["remote"] = remote.rstrip("/")
359 result["config_path"] = resolved_config
360 try:
361 chunk_size_gib = float(result.get("chunk_size_gib", DEFAULT_CHUNK_SIZE_GIB))
362 except (TypeError, ValueError) as exc:
363 raise StorageError(f"Storage profile '{selected}' chunk_size_gib must be numeric.") from exc
364 if chunk_size_gib <= 0.0:
365 raise StorageError(f"Storage profile '{selected}' chunk_size_gib must be positive.")
366 result["chunk_size_bytes"] = int(chunk_size_gib * 1024 ** 3)
367 try:
368 workers = int(result.get("workers", DEFAULT_STORAGE_WORKERS))
369 except (TypeError, ValueError) as exc:
370 raise StorageError(f"Storage profile '{selected}' workers must be an integer.") from exc
371 if workers <= 0:
372 raise StorageError(f"Storage profile '{selected}' workers must be positive.")
373 result["workers"] = workers
374 offload_policy = str(result.get("offload_policy", "metadata-only"))
375 if offload_policy not in STORAGE_OFFLOAD_POLICIES:
376 raise StorageError(
377 f"Storage profile '{selected}' offload_policy must be one of: "
378 + ", ".join(STORAGE_OFFLOAD_POLICIES)
379 )
380 result["offload_policy"] = offload_policy
381 result["keep_latest_checkpoint"] = bool(result.get("keep_latest_checkpoint", False))
382 return result
383
384
385def _state_path(root_path: str) -> str:
386 """!
387 @brief Return the local storage state marker path for an artifact root.
388 @param[in] root_path Value supplied through the `root_path` argument.
389 @return Result produced by this operation.
390 """
391 return os.path.join(os.path.abspath(root_path), STORAGE_STATE_FILENAME)
392
393
394def read_storage_state(root_path: str):
395 """!
396 @brief Read the nearest applicable storage marker for a run, study, or study member.
397 @param[in] root_path Value supplied through the `root_path` argument.
398 @return Result produced by this operation.
399 """
400 root = Path(os.path.abspath(root_path))
401 state = _read_json(_state_path(str(root)))
402 if state:
403 return state
404 # A whole-study archive owns every numbered member. Let ordinary run
405 # workflows see that parent state without treating unrelated ancestors as
406 # storage owners.
407 if root.parent.name == "cases":
408 return _read_json(_state_path(str(root.parent.parent)))
409 return None
410
411
412def is_artifact_cold(root_path: str) -> bool:
413 """!
414 @brief Return whether a local artifact marker says payload data was pruned.
415 @param[in] root_path Value supplied through the `root_path` argument.
416 @return Result produced by this operation.
417 """
418 state = read_storage_state(root_path)
419 return bool(state and state.get("local_pruned"))
420
421
422def storage_state_summary(root_path: str) -> dict:
423 """!
424 @brief Return a compact storage status for summarize and status commands.
425 @param[in] root_path Value supplied through the `root_path` argument.
426 @return Result produced by this operation.
427 """
428 state = read_storage_state(root_path)
429 if not state:
430 # A marker that exists but cannot be read is worse than none: the artifact
431 # claims a remote copy nobody can locate.
432 if os.path.exists(_state_path(root_path)):
433 return {"state": "BROKEN", "archive_id": None, "label": None,
434 "detail": "storage marker is present but unreadable"}
435 return {"state": "LOCAL", "archive_id": None, "label": None}
436 if not state.get("archive_id"):
437 return {"state": "BROKEN", "archive_id": None, "label": state.get("label"),
438 "detail": "storage marker names no archive"}
439 if state.get("local_pruned"):
440 status = "PARTIAL" if state.get("restored_components") else "COLD"
441 else:
442 status = "PROTECTED"
443 return {
444 "state": status,
445 "archive_id": state.get("archive_id"),
446 "label": state.get("label"),
447 "profile": state.get("profile"),
448 "remote": state.get("remote"),
449 }
450
451
452#: Output encodings shared with the rest of the CLI. Storage does not own this set.
453CLI_OUTPUT_FORMATS = ("text", "json")
454
455
456#: Archive compression policies `picurv storage` accepts. `auto` resolves to one of
457#: the concrete policies from the payload size.
458STORAGE_COMPRESSION_POLICIES = ("auto", "none", "fast", "balanced", "maximum")
459
460
461STORAGE_OFFLOAD_POLICIES = ("metadata-only", "restart-ready", "analysis-ready")
462
463
464#: Components whose local retention an offload may be told to decide individually.
465#: `metadata` is absent because every named policy retains it and a cold artifact that
466#: cannot say what it was is not recoverable; `assets`, `unclassified`, and the two
467#: workspace components are absent because ALWAYS_RETAINED_COMPONENTS protects them.
468#: "checkpoints" stands for every committed step at once, which is what a user means
469#: by it; a single step is still selected with --keep-latest-checkpoint.
470STORAGE_RETENTION_COMPONENTS = (
471 "checkpoints", "logs", "analysis", "visualization", "inputs", "raw-output",
472)
473
474
475#: Archive suffix each resolved compression policy produces. `auto` never appears
476#: here: it resolves to one of the concrete policies before an extension is needed.
477STORAGE_COMPRESSION_EXTENSIONS = {
478 "none": ".tar", "fast": ".tar.gz", "balanced": ".tar.gz", "maximum": ".tar.xz",
479}
480
481
482def _parse_tags(raw_tags) -> dict:
483 """!
484 @brief Parse repeatable KEY=VALUE tags into deterministic metadata.
485 @param[in] raw_tags Value supplied through the `raw_tags` argument.
486 @return Result produced by this operation.
487 """
488 tags = {}
489 for item in raw_tags or []:
490 key, separator, value = str(item).partition("=")
491 if not separator or not key.strip() or not value.strip():
492 raise StorageError(f"Invalid tag {item!r}; expected KEY=VALUE.")
493 tags[key.strip()] = value.strip()
494 return tags
User-facing storage workflow failure.
Definition models.py:139
read_artifact_manifest(str root_path)
Read a local artifact's own identity manifest, run or study.
Definition models.py:212
str _utc_now()
Return a stable UTC timestamp for storage metadata.
Definition models.py:143