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 try:
109 shutil.rmtree(entry["object"])
110 decision["removed"] = True
111 except OSError as exc:
112 # Partly deleted is still present: report it rather than count it as gone.
113 decision["removed"] = False
114 decision["error"] = str(exc)
115 decisions.append(decision)
116 return decisions
117
118
119def _find_reusable_archive(profile: dict, target: dict, fingerprint: str,
120 compression: str = None) -> dict:
121 """!
122 @brief Find a completed archive of this artifact whose content is already current.
123 @param[in] profile Resolved storage profile.
124 @param[in] target Local artifact target.
125 @param[in] fingerprint Inventory fingerprint of the artifact as it stands now.
126 @param[in] compression Resolved compression the archive must have been made with;
127 an archive at another level is not what was asked for.
128 @return Matching remote manifest, or None.
129 """
130 if not fingerprint:
131 return None
132 identity = (target["artifact_type"], target.get("run_id"),
133 target.get("study_id"), target.get("case_id"))
134 try:
135 candidates = list_remote_manifests(profile)
136 except StorageError as exc:
137 # Reuse is an optimization. A remote that cannot be listed - not yet created,
138 # briefly unreachable - must fall through to a normal upload, never fail here.
139 print(f"[INFO] Could not check for a reusable archive ({exc}); uploading.")
140 return None
141 newest = None
142 for manifest in candidates:
143 if manifest.get("inventory_sha256") != fingerprint:
144 continue
145 if compression and manifest.get("compression") != compression:
146 continue
147 if (manifest.get("artifact_type"), manifest.get("run_id"),
148 manifest.get("study_id"), manifest.get("case_id")) != identity:
149 continue
150 if newest is None or str(manifest.get("created_at", "")) > str(newest.get("created_at", "")):
151 newest = manifest
152 return newest
153
154
155def list_remote_manifests(profile: dict) -> list:
156 """!
157 @brief Enumerate completed archive manifests from the remote catalog.
158 @param[in] profile Value supplied through the `profile` argument.
159 @return Result produced by this operation.
160 """
161 root = _remote_join(profile["remote"], REMOTE_OBJECTS_DIRECTORY)
162 result = _transport._run_rclone([
163 "lsf", root, "--recursive", "--files-only", "--include", f"*/{REMOTE_MANIFEST_FILENAME}"
164 ])
165 manifests = []
166 for relative in sorted(line.strip() for line in result.stdout.splitlines() if line.strip()):
167 archive_id = relative.split("/", 1)[0]
168 if not ARCHIVE_ID_PATTERN.fullmatch(archive_id):
169 continue
170 try:
171 manifests.append(_load_remote_manifest(profile, archive_id))
172 except StorageError:
173 continue
174 return manifests
175
176
177def verify_remote_archive(profile: dict, archive_id: str) -> dict:
178 """!
179 @brief Verify the completion marker and every stored chunk checksum.
180 @param[in] profile Value supplied through the `profile` argument.
181 @param[in] archive_id Value supplied through the `archive_id` argument.
182 @return Result produced by this operation.
183 """
184 manifest = _load_remote_manifest(profile, archive_id)
185 for chunk in manifest.get("chunks", []):
186 actual = _remote_sha256(_chunk_remote_path(profile, archive_id, chunk))
187 if actual != chunk.get("sha256"):
188 raise StorageError(
189 f"Archive {archive_id} chunk checksum mismatch: {chunk['name']} "
190 f"(expected {chunk.get('sha256')}, got {actual})."
191 )
192 return manifest
193
194
195def resolve_workspace_archive_id(profile: dict, workspace_id: str) -> str:
196 """!
197 @brief Find the newest complete workspace archive carrying one workspace identity.
198 @param[in] profile Resolved storage profile.
199 @param[in] workspace_id Workspace identity recorded at archive time.
200 @return Archive id.
201 @throws StorageError when no such archive exists.
202 """
203 matches = [
204 manifest for manifest in list_remote_manifests(profile)
205 if manifest.get("artifact_type") == "workspace"
206 and manifest.get("workspace_id") == workspace_id
207 ]
208 if not matches:
209 raise StorageError(f"No workspace archive found for identity {workspace_id!r}.")
210 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