705bb1e6b3
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/462
116 lines
4.7 KiB
Python
116 lines
4.7 KiB
Python
"""定向回收 Gitea 1.26.4 不会自动过期的本仓库缓存上传块。"""
|
|
|
|
import base64
|
|
import binascii
|
|
import math
|
|
from pathlib import Path
|
|
import re
|
|
import stat
|
|
|
|
|
|
JOBS = {
|
|
"ai-game-creator-shell-rust-lane-1",
|
|
"ai-game-creator-shell-rust-lane-2",
|
|
"ai-game-creator-shell-rust-smoke",
|
|
"ai-game-creator-shell-rust-crates",
|
|
"backend-tests",
|
|
"native-shell-tests",
|
|
}
|
|
CHUNK = re.compile(r"block-([1-9][0-9]*)-([1-9][0-9]*)-(0|[1-9][0-9]*)-([A-Za-z0-9_-]+={0,2})")
|
|
BLOCKLIST = re.compile(r"([1-9][0-9]*)-([1-9][0-9]*)-blocklist")
|
|
MARKER = re.compile(r"genarrative-rust-cache-v1:([a-z0-9-]+):(0|[1-9][0-9]*):([0-9]{8})")
|
|
|
|
|
|
def decode_owner(encoded):
|
|
"""Gitea 文件名编码为 URL-base64(生产者上传的标准 base64 blockid)。"""
|
|
try:
|
|
block_id = base64.b64decode(encoded, altchars=b"-_", validate=True)
|
|
if base64.urlsafe_b64encode(block_id).decode() != encoded:
|
|
return None
|
|
marker = base64.b64decode(block_id, validate=True)
|
|
if base64.b64encode(marker) != block_id:
|
|
return None
|
|
match = MARKER.fullmatch(marker.decode("ascii"))
|
|
if match and match[1] in JOBS:
|
|
return match[1], int(match[2])
|
|
except (binascii.Error, UnicodeError, ValueError):
|
|
pass
|
|
return None
|
|
|
|
|
|
def _unchanged_old_regular(path, previous, cutoff):
|
|
try:
|
|
current = path.lstat()
|
|
except FileNotFoundError:
|
|
return False
|
|
return (
|
|
stat.S_ISREG(current.st_mode)
|
|
and current.st_mtime < cutoff
|
|
and (current.st_ino, current.st_size, current.st_mtime_ns)
|
|
== (previous.st_ino, previous.st_size, previous.st_mtime_ns)
|
|
)
|
|
|
|
|
|
def cleanup_upload_chunks(storage_root: Path, eligible_runs: set[int], cutoff_timestamp: float) -> int:
|
|
"""调用方先用仓库 API 确认这些 master run 已结束超过保留期限。"""
|
|
storage_root = Path(storage_root)
|
|
if not storage_root.is_absolute() or storage_root.resolve(strict=True) != storage_root:
|
|
raise ValueError("artifact storage root must be an existing absolute real directory")
|
|
if not stat.S_ISDIR(storage_root.lstat().st_mode):
|
|
raise ValueError("artifact storage root must be a directory")
|
|
if not math.isfinite(cutoff_timestamp) or cutoff_timestamp <= 0:
|
|
raise ValueError("invalid artifact cleanup cutoff")
|
|
if any(type(run_id) is not int or run_id <= 0 for run_id in eligible_runs):
|
|
raise ValueError("invalid eligible run ID")
|
|
temporary = storage_root / "tmp-upload"
|
|
if not temporary.exists() and not temporary.is_symlink():
|
|
return 0
|
|
if temporary.is_symlink() or not temporary.is_dir():
|
|
raise ValueError("artifact temporary path must be a real directory")
|
|
|
|
deleted = 0
|
|
for run_id in sorted(eligible_runs):
|
|
directory = temporary / f"run-{run_id}-v4"
|
|
if directory.is_symlink() or not directory.is_dir():
|
|
continue
|
|
groups = {}
|
|
unknown = False
|
|
for path in directory.iterdir():
|
|
chunk = CHUNK.fullmatch(path.name)
|
|
blocklist = BLOCKLIST.fullmatch(path.name)
|
|
match = chunk or blocklist
|
|
if match and int(match[1]) == run_id:
|
|
artifact_id = int(match[2])
|
|
owner = decode_owner(chunk[4]) if chunk else None
|
|
owned = owner is not None if chunk else True
|
|
else:
|
|
# 若仍能识别 artifact ID,仅保护该组;无法识别时保护整个 run。
|
|
prefix = re.match(rf"(?:block-)?{run_id}-([1-9][0-9]*)-", path.name)
|
|
if not prefix:
|
|
unknown = True
|
|
break
|
|
artifact_id, owner, owned = int(prefix[1]), None, False
|
|
group = groups.setdefault(artifact_id, {"files": [], "owners": set(), "safe": True})
|
|
try:
|
|
info = path.lstat()
|
|
except FileNotFoundError:
|
|
group["safe"] = False
|
|
continue
|
|
group["safe"] &= owned and stat.S_ISREG(info.st_mode) and info.st_mtime < cutoff_timestamp
|
|
if owner:
|
|
group["owners"].add(owner)
|
|
group["files"].append((path, info))
|
|
if unknown:
|
|
continue
|
|
for group in groups.values():
|
|
if not group["safe"] or len(group["owners"]) != 1:
|
|
continue
|
|
files = group["files"]
|
|
if not all(_unchanged_old_regular(path, info, cutoff_timestamp) for path, info in files):
|
|
continue
|
|
# 保留目录,不使用递归删除;没有所属块证明的孤立 blocklist 也不会删除。
|
|
for path, _ in files:
|
|
path.unlink()
|
|
deleted += 1
|
|
return deleted
|