PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
 
Loading...
Searching...
No Matches
catalog.py
Go to the documentation of this file.
1"""!
2@file catalog.py
3@brief The remote archive catalog and workspace asset references.
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 ARCHIVE_ID_PATTERN,
27 REMOTE_MANIFEST_FILENAME,
28 REMOTE_OBJECTS_DIRECTORY,
29 StorageError,
30 is_artifact_cold,
31)
32from .transport import (
33 _chunk_remote_path,
34 _load_remote_manifest,
35 _remote_join,
36 _remote_sha256,
37)
38from . import transport as _transport
39
40
41def workspace_asset_references(workspace_root: str) -> dict:
42 """!
43 @brief Count what still refers to each published workspace asset.
44
45 @details A local copy may be removed once nothing local needs it and a verified
46 remote copy exists. Runs that are themselves cold still *reference* the
47 asset - that is what keeps the remote copy alive - but they do not keep
48 the local one.
49 @param[in] workspace_root Initialized workspace root.
50 @return Mapping of asset id to its reference counts and local object path.
51 """
52 references = {}
53 objects_root = os.path.join(workspace_root, "assets", "objects")
54 if os.path.isdir(objects_root):
55 for kind in sorted(os.listdir(objects_root)):
56 kind_root = os.path.join(objects_root, kind)
57 if not os.path.isdir(kind_root):
58 continue
59 for asset_id in sorted(os.listdir(kind_root)):
60 object_root = os.path.join(kind_root, asset_id)
61 if os.path.isdir(object_root):
62 references[asset_id] = {
63 "asset_id": asset_id, "kind": kind, "object": object_root,
64 "active_local_runs": 0, "cold_runs": 0,
65 }
66 for artifacts in ("runs", "studies"):
67 root = os.path.join(workspace_root, artifacts)
68 if not os.path.isdir(root):
69 continue
70 for lock_path in Path(root).glob("**/inputs/assets.lock.yml"):
71 try:
72 with open(lock_path, "r", encoding="utf-8") as stream:
73 lock = yaml.safe_load(stream) or {}
74 except (OSError, ValueError):
75 continue
76 run_root = lock_path.parent.parent
77 cold = is_artifact_cold(str(run_root))
78 for reference in (lock.get("assets") or {}).values():
79 entry = references.get(reference.get("asset_id"))
80 if entry is None:
81 continue
82 entry["cold_runs" if cold else "active_local_runs"] += 1
83 return references
84
85
86def prune_unused_workspace_assets(workspace_root: str, profile: dict,
87 dry_run: bool = False) -> list:
88 """!
89 @brief Remove local asset objects that nothing local needs and storage has verified.
90 @param[in] workspace_root Initialized workspace root.
91 @param[in] profile Resolved storage profile.
92 @param[in] dry_run Report the decision without removing anything.
93 @return Removal decisions, one per published asset.
94 """
95 protected = set()
96 for manifest in list_remote_manifests(profile):
97 if manifest.get("artifact_type") != "workspace":
98 continue
99 for asset_id in manifest.get("workspace_assets") or []:
100 protected.add(asset_id)
101 decisions = []
102 for entry in workspace_asset_references(workspace_root).values():
103 verified = entry["asset_id"] in protected
104 removable = verified and entry["active_local_runs"] == 0
105 decision = {**entry, "remote_protection": "verified" if verified else "none",
106 "local_removal": "safe" if removable else "blocked"}
107 if removable and not dry_run:
108 shutil.rmtree(entry["object"], ignore_errors=True)
109 decision["removed"] = True
110 decisions.append(decision)
111 return decisions
112
113
114def _find_reusable_archive(profile: dict, target: dict, fingerprint: str) -> dict:
115 """!
116 @brief Find a completed archive of this artifact whose content is already current.
117 @param[in] profile Resolved storage profile.
118 @param[in] target Local artifact target.
119 @param[in] fingerprint Inventory fingerprint of the artifact as it stands now.
120 @return Matching remote manifest, or None.
121 """
122 if not fingerprint:
123 return None
124 identity = (target["artifact_type"], target.get("run_id"),
125 target.get("study_id"), target.get("case_id"))
126 try:
127 candidates = list_remote_manifests(profile)
128 except StorageError as exc:
129 # Reuse is an optimization. A remote that cannot be listed - not yet created,
130 # briefly unreachable - must fall through to a normal upload, never fail here.
131 print(f"[INFO] Could not check for a reusable archive ({exc}); uploading.")
132 return None
133 newest = None
134 for manifest in candidates:
135 if manifest.get("inventory_sha256") != fingerprint:
136 continue
137 if (manifest.get("artifact_type"), manifest.get("run_id"),
138 manifest.get("study_id"), manifest.get("case_id")) != identity:
139 continue
140 if newest is None or str(manifest.get("created_at", "")) > str(newest.get("created_at", "")):
141 newest = manifest
142 return newest
143
144
145def list_remote_manifests(profile: dict) -> list:
146 """!
147 @brief Enumerate completed archive manifests from the remote catalog.
148 @param[in] profile Value supplied through the `profile` argument.
149 @return Result produced by this operation.
150 """
151 root = _remote_join(profile["remote"], REMOTE_OBJECTS_DIRECTORY)
152 result = _transport._run_rclone([
153 "lsf", root, "--recursive", "--files-only", "--include", f"*/{REMOTE_MANIFEST_FILENAME}"
154 ])
155 manifests = []
156 for relative in sorted(line.strip() for line in result.stdout.splitlines() if line.strip()):
157 archive_id = relative.split("/", 1)[0]
158 if not ARCHIVE_ID_PATTERN.fullmatch(archive_id):
159 continue
160 try:
161 manifests.append(_load_remote_manifest(profile, archive_id))
162 except StorageError:
163 continue
164 return manifests
165
166
167def verify_remote_archive(profile: dict, archive_id: str) -> dict:
168 """!
169 @brief Verify the completion marker and every stored chunk checksum.
170 @param[in] profile Value supplied through the `profile` argument.
171 @param[in] archive_id Value supplied through the `archive_id` argument.
172 @return Result produced by this operation.
173 """
174 manifest = _load_remote_manifest(profile, archive_id)
175 for chunk in manifest.get("chunks", []):
176 actual = _remote_sha256(_chunk_remote_path(profile, archive_id, chunk))
177 if actual != chunk.get("sha256"):
178 raise StorageError(
179 f"Archive {archive_id} chunk checksum mismatch: {chunk['name']} "
180 f"(expected {chunk.get('sha256')}, got {actual})."
181 )
182 return manifest
183
184
185def resolve_workspace_archive_id(profile: dict, workspace_id: str) -> str:
186 """!
187 @brief Find the newest complete workspace archive carrying one workspace identity.
188 @param[in] profile Resolved storage profile.
189 @param[in] workspace_id Workspace identity recorded at archive time.
190 @return Archive id.
191 @throws StorageError when no such archive exists.
192 """
193 matches = [
194 manifest for manifest in list_remote_manifests(profile)
195 if manifest.get("artifact_type") == "workspace"
196 and manifest.get("workspace_id") == workspace_id
197 ]
198 if not matches:
199 raise StorageError(f"No workspace archive found for identity {workspace_id!r}.")
200 return max(matches, key=lambda item: str(item.get("created_at", "")))["archive_id"]
User-facing storage workflow failure.
Definition models.py:139
dict workspace_asset_references(str workspace_root)
Count what still refers to each published workspace asset.
Definition catalog.py:41