43 @brief Count what still refers to each published workspace asset.
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
49 @param[in] workspace_root Initialized workspace root.
50 @return Mapping of asset id to its reference counts and local object path.
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):
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,
66 for artifacts
in (
"runs",
"studies"):
67 root = os.path.join(workspace_root, artifacts)
68 if not os.path.isdir(root):
70 for lock_path
in Path(root).glob(
"**/inputs/assets.lock.yml"):
72 with open(lock_path,
"r", encoding=
"utf-8")
as stream:
73 lock = yaml.safe_load(stream)
or {}
74 except (OSError, ValueError):
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"))
82 entry[
"cold_runs" if cold
else "active_local_runs"] += 1
86def prune_unused_workspace_assets(workspace_root: str, profile: dict,
87 dry_run: bool =
False) -> list:
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.
96 for manifest
in list_remote_manifests(profile):
97 if manifest.get(
"artifact_type") !=
"workspace":
99 for asset_id
in manifest.get(
"workspace_assets")
or []:
100 protected.add(asset_id)
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)
145def list_remote_manifests(profile: dict) -> list:
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.
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}"
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):
161 manifests.append(_load_remote_manifest(profile, archive_id))
167def verify_remote_archive(profile: dict, archive_id: str) -> dict:
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.
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"):
179 f
"Archive {archive_id} chunk checksum mismatch: {chunk['name']} "
180 f
"(expected {chunk.get('sha256')}, got {actual})."