Bound Gitea download cache with scheduled cleanup

This commit is contained in:
hellcat 2026-10-22 13:03:30 +08:00
parent 3790243fa9
commit 48dc3ba652
5 changed files with 199 additions and 1 deletions

View File

@ -217,4 +217,15 @@ Nodehub仍运行`507cbf7`,已在今天00:15正常生成20261022四站10份文
恢复发布批次`20261022045639-8d73d22a4e86`(北京时间12:56:39准备),URI 2592、Clash 2559、Sing-box 2373,16份本地产物。随后重放既有Nodehub同步,四站均成功,10份当日订阅落盘与当前批次哈希匹配(Clash仅既有trim差异),Nodehub服务器侧10个精确HTTPS地址全部200、响应哈希与文件一致。已完成恢复和服务器侧验收,未做线上页面验收或节点联网测速。回滚产物可用原current批次`20261021160648-829d2ad82938`,代码回退点`21ab4b84307056716664d8a0bc1e95d5daea448d`;代码回滚不自动回滚Nodehub文件。
本次仅按已证实的满盘故障清理三个可再生下载缓存,没有修改共享Gitea的缓存保留策略。未来缓存仍可能增长,阶段日志会记录剩余空间,但不等于已建立磁盘容量治理或自动清理服务。
初次恢复仅清理三个可再生缓存。用户随后明确授权为这些缓存加自动清理,实施规则见下一节。
### Gitea下载缓存自动清理
源码与单元测试在`tools/maintenance/gitea_archive_cache.py`、`test_gitea_archive_cache.py`,systemd unit同目录。仅处理`/var/lib/gitea/data/repo-archive/<数字仓库ID>/<哈希前两位>/<40位哈希>.zip|.tar.gz|.bundle`。每5分钟执行:删除超过1小时缓存;仍超过512MiB时从旧到新淘汰。保护正在被进程打开的inode和生成不足10分钟的文件;不跟随符号链接,未知文件保留并计数,因此存在受保护文件时512MiB是清理目标,不是硬配额。只记录计数/字节,不输出仓库内容;执行互斥,重检文件身份和修改时间后删除,目录异常或无法检查打开文件时失败。不会接触Git对象库、数据库、附件、备份或订阅产物。
安装目标:`/usr/local/sbin/gitea-archive-cache-cleaner`,units为`/etc/systemd/system/gitea-archive-cache-cleaner.{service,timer}`。独立任务无需重启Gitea;原每5分钟`log-budget-cleaner`保持原样。日志用`journalctl -u gitea-archive-cache-cleaner.service`;手工预览为`/usr/local/sbin/gitea-archive-cache-cleaner --dry-run`(需root,运行目录由service创建)。容量超标但只剩保护文件时返回失败并记录,不为达配额删除正在使用的文件。
回退:先`systemctl disable --now gitea-archive-cache-cleaner.timer`,停止service,再按部署备份恢复/移除本次独立脚本和两个unit并`systemctl daemon-reload`;无需回退免费节点业务代码。已删除缓存可按需重新生成。部署前检查生产HEAD、并行状态及旧文件,保留安装前状态和验收记录到服务器私有备份目录;本地5项测试覆盖过期/容量淘汰、打开文件/新文件/未知文件保护、符号链接、预览与重复执行。
当前状态:本地测试通过,尚待Git部署与线上真实清理和timer验收。

View File

@ -0,0 +1,20 @@
[Unit]
Description=Bound Gitea regenerable download archive cache
After=local-fs.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/gitea-archive-cache-cleaner
User=root
UMask=0077
Nice=10
IOSchedulingClass=idle
TimeoutStartSec=90
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
RuntimeDirectory=gitea-archive-cache-cleaner
RuntimeDirectoryMode=0700
RuntimeDirectoryPreserve=yes
ReadWritePaths=/var/lib/gitea/data/repo-archive /run/gitea-archive-cache-cleaner
PrivateTmp=true

View File

@ -0,0 +1,11 @@
[Unit]
Description=Check Gitea download archive cache every five minutes
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
AccuracySec=15s
Unit=gitea-archive-cache-cleaner.service
[Install]
WantedBy=timers.target

View File

@ -0,0 +1,101 @@
#!/usr/bin/python3
"""Bound only Gitea's regenerable repository download archives, never repositories."""
import argparse
import fcntl
import json
import os
from pathlib import Path
import re
import stat
import time
ROOT = Path('/var/lib/gitea/data/repo-archive')
BUDGET = 512 * 1024 * 1024
TTL = 3600
GRACE = 600
ARCHIVE = re.compile(r'([0-9a-f]{40})\.(?:zip|tar\.gz|bundle)\Z')
def open_inodes():
result = set()
for proc in Path('/proc').iterdir():
if not proc.name.isdigit():
continue
try:
for fd in (proc / 'fd').iterdir():
try:
s = fd.stat()
if stat.S_ISREG(s.st_mode):
result.add((s.st_dev, s.st_ino))
except (FileNotFoundError, ProcessLookupError):
pass
except (FileNotFoundError, ProcessLookupError):
pass
return result
def clean(root, now, opened, dry_run=False, budget=BUDGET, ttl=TTL, grace=GRACE):
if root.is_symlink() or root.resolve() != root or not root.is_dir():
raise RuntimeError('cache_root_missing_or_symlink')
files = []
unknown = 0
for base, dirs, names in os.walk(root, followlinks=False):
dirs[:] = [d for d in dirs if not Path(base, d).is_symlink()]
for name in names:
p = Path(base, name)
try:
s = p.lstat()
except FileNotFoundError:
continue
if not stat.S_ISREG(s.st_mode):
continue
parts = p.relative_to(root).parts
match = ARCHIVE.fullmatch(name)
known = len(parts) == 3 and parts[0].isdigit() and match and parts[1] == match[1][:2]
if not known:
unknown += 1
files.append((p, s, bool(known)))
total = sum(s.st_size for _, s, _ in files)
before = total
removed = 0
protected = 0
for p, s, known in sorted(files, key=lambda row: row[1].st_mtime):
age = now - s.st_mtime
if not known or (s.st_dev, s.st_ino) in opened or age < grace:
protected += 1
continue
if age < ttl and total <= budget:
continue
try:
current = p.lstat()
except FileNotFoundError:
continue
if (current.st_dev, current.st_ino, current.st_size, current.st_mtime_ns) != (s.st_dev, s.st_ino, s.st_size, s.st_mtime_ns):
continue
if not dry_run:
p.unlink()
total -= s.st_size
removed += 1
return dict(dry_run=dry_run, before_bytes=before, after_bytes=total,
removed_files=removed, protected_files=protected, unknown_files=unknown,
budget_bytes=budget, over_budget=total > budget)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
with open('/run/gitea-archive-cache-cleaner/cleanup.lock', 'w') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return
result = clean(ROOT, time.time(), open_inodes(), args.dry_run)
result['free_bytes'] = os.statvfs(ROOT).f_bavail * os.statvfs(ROOT).f_frsize
print(json.dumps(result), flush=True)
if result['over_budget']:
raise SystemExit('Cache remains over budget: fresh/open/unknown files are protected')
if __name__ == '__main__':
main()

View File

@ -0,0 +1,55 @@
import importlib.util
import os
from pathlib import Path
import tempfile
import unittest
spec = importlib.util.spec_from_file_location('cache', Path(__file__).with_name('gitea_archive_cache.py'))
cache = importlib.util.module_from_spec(spec)
spec.loader.exec_module(cache)
class CleanerTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name).resolve()
self.now = 10000
def tearDown(self):
self.temp.cleanup()
def file(self, number, age, size=20):
sha = f'{number:040x}'
p = self.root / '1' / sha[:2] / (sha + '.zip')
p.parent.mkdir(parents=True, exist_ok=True)
p.write_bytes(b'x' * size)
os.utime(p, (self.now-age, self.now-age))
return p
def test_expiry_preserves_open_recent_unknown_and_symlink_targets(self):
old = self.file(1, 4000)
active = self.file(2, 4000)
fresh = self.file(3, 100)
unknown = self.root / 'repository.git'; unknown.write_bytes(b'protected')
(self.root/'outside').symlink_to('/tmp', target_is_directory=True)
s = active.stat()
r = cache.clean(self.root, self.now, {(s.st_dev, s.st_ino)}, budget=1000)
self.assertFalse(old.exists())
for p in (active, fresh, unknown): self.assertTrue(p.exists())
self.assertEqual(1, r['removed_files'])
def test_capacity_evicts_oldest_then_stops(self):
old = self.file(1, 1200); newer = self.file(2, 1000); newest = self.file(3, 700)
r = cache.clean(self.root, self.now, set(), budget=40)
self.assertFalse(old.exists()); self.assertTrue(newer.exists()); self.assertTrue(newest.exists())
self.assertEqual(40, r['after_bytes'])
def test_dry_run_does_not_delete_and_repeated_cleanup_is_idempotent(self):
p = self.file(1, 4000)
r = cache.clean(self.root, self.now, set(), dry_run=True)
self.assertTrue(p.exists()); self.assertEqual(1, r['removed_files'])
cache.clean(self.root, self.now, set())
self.assertEqual(0, cache.clean(self.root, self.now, set())['removed_files'])
def test_fresh_files_may_exceed_budget_without_deletion(self):
p = self.file(1, 100)
r = cache.clean(self.root, self.now, set(), budget=1)
self.assertTrue(p.exists()); self.assertTrue(r['over_budget'])
def test_symlink_root_is_rejected(self):
link = self.root/'link'; link.symlink_to(self.root, target_is_directory=True)
with self.assertRaises(RuntimeError): cache.clean(link, self.now, set())
if __name__ == '__main__': unittest.main()