From 62f059921d1ea35548840160929e4dfcb762e1b3 Mon Sep 17 00:00:00 2001 From: cbb Date: Tue, 13 Oct 2026 20:32:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v1.4.0=20=E2=80=94=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E8=87=AA=E5=8A=A8=E9=87=8D=E8=AF=95=EF=BC=88?= =?UTF-8?q?=E6=AF=8F=2060s=20=E4=B8=80=E6=AC=A1=EF=BC=8C=E6=AC=A1=E6=95=B0?= =?UTF-8?q?=E5=8F=AF=E8=B0=83=E9=BB=98=E8=AE=A4=205=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 独立重试线程:整轮同步有仓库失败时,把失败仓库排入重试队列, 每隔 60 秒并行重试一次(复用并行度设置),与同步间隔无关—— 即使同步间隔是 1 天,网络抖动也能在几分钟内自愈 - 重试计数:每个仓库连续失败计数,达到上限(默认 5,可调 0~20, 0=不重试)后放弃并记日志,等下一轮同步或手动同步再次尝试; 手动同步成功立即退出重试队列;被其他同步占用(busy)不计次数 - 设置页新增「失败重试次数」;状态页新增「失败重试」统计卡片, 重试中的仓库显示「⏳ 重试中 x/N」;日志记录排程/重试/放弃全过程 - 修复 retry_next_at 缺少 global 声明导致重试每 2 秒空转的 bug (线上实测发现:next_in 显示 0 而非 60) - manifest 1.3.0 → 1.4.0 --- GiteaHubSync/app/backend/panel.py | 191 ++++++++++++++++++++++++++++-- GiteaHubSync/manifest | 2 +- README.md | 3 + 3 files changed, 187 insertions(+), 9 deletions(-) diff --git a/GiteaHubSync/app/backend/panel.py b/GiteaHubSync/app/backend/panel.py index 3629fbe..c9ef80c 100644 --- a/GiteaHubSync/app/backend/panel.py +++ b/GiteaHubSync/app/backend/panel.py @@ -54,6 +54,10 @@ MAX_INTERVAL = 86400 # 最大同步间隔(秒,1 天) DEFAULT_WORKERS = 3 # 同时同步的项目数默认值 MIN_WORKERS = 1 MAX_WORKERS = 8 +RETRY_INTERVAL = 60 # 同步失败后的自动重试间隔(秒) +DEFAULT_MAX_RETRIES = 5 # 失败重试次数默认值 +MIN_MAX_RETRIES = 0 # 0 = 不重试 +MAX_MAX_RETRIES = 20 PANEL_PORT = int(os.environ.get("PANEL_PORT", "8899")) START_TS = time.time() NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,100}$") @@ -67,7 +71,7 @@ ENV["GIT_TERMINAL_PROMPT"] = "0" # ---------------- 应用设置(settings.json) ---------------- cfg = {"gitea_user": LEGACY_GITEA_USER, "gitea_pass": "", "auto_create": True, "interval": DEFAULT_INTERVAL, "gitea_host": DEFAULT_GITEA_HOST, - "max_workers": DEFAULT_WORKERS} + "max_workers": DEFAULT_WORKERS, "max_retries": DEFAULT_MAX_RETRIES} def norm_gitea_host(h): @@ -141,6 +145,22 @@ def valid_workers(v): return False +def max_retries(): + """失败自动重试次数,始终返回合法值""" + try: + n = int(cfg.get("max_retries", DEFAULT_MAX_RETRIES)) + except Exception: + n = DEFAULT_MAX_RETRIES + return min(MAX_MAX_RETRIES, max(MIN_MAX_RETRIES, n)) + + +def valid_retries(v): + try: + return MIN_MAX_RETRIES <= int(v) <= MAX_MAX_RETRIES + except Exception: + return False + + def load_settings(): try: d = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) @@ -152,6 +172,8 @@ def load_settings(): cfg["interval"] = int(d["interval"]) if valid_workers(d.get("max_workers")): cfg["max_workers"] = int(d["max_workers"]) + if valid_retries(d.get("max_retries")): + cfg["max_retries"] = int(d["max_retries"]) h = norm_gitea_host(d.get("gitea_host")) if h: cfg["gitea_host"] = h @@ -263,7 +285,7 @@ def scrub(s, acc): state = {"enabled": True, "syncing": False, "last_check": None, "checks": 0, "last_result": "尚未运行", "loop_last": None} -stats = {"total_checks": 0, "total_pushes": 0, "total_failures": 0} +stats = {"total_checks": 0, "total_pushes": 0, "total_failures": 0, "total_retries": 0} op_lock = threading.Lock() # 整轮同步互斥(同一轮只跑一次) stats_lock = threading.Lock() # 保护 stats 计数 log_lock = threading.Lock() # 保护日志文件写入 @@ -511,7 +533,7 @@ def fingerprint(repo): def sync_repo(name): - """返回 'changed' / 'same' / 'error:...';同一仓库并发调用时后到者直接返回""" + """返回 'changed' / 'same' / 'busy' / 'error:...';同一仓库并发调用时后到者返回 busy""" if not valid_name(name): return "error: 非法仓库名" acc = active_account() @@ -519,7 +541,7 @@ def sync_repo(name): return "error: 没有可用的 GitHub 账号,请到设置页添加" with repo_busy_lock: if name in repo_in_progress: - return "error: 该仓库正在同步中,请稍候" + return "busy" repo_in_progress.add(name) try: return _sync_repo_locked(name, acc) @@ -603,6 +625,13 @@ def run_sync(repos=None): errs.append(f"{r} {res}") elif res == "changed": changed += 1 + # 'same' / 'busy' 不计失败 + failed_names = [r for r in repos if results.get(r, "").startswith("error")] + with retry_lock: + for r in repos: + if r not in failed_names: + retry_queue.pop(r, None) # 成功的仓库退出重试队列 + schedule_retries(failed_names) if errs: bump_stats(total_failures=len(errs)) if changed: @@ -622,6 +651,102 @@ def run_sync(repos=None): state["syncing"] = False +# ---------------- 失败自动重试(独立于同步间隔,默认每 60s 一次) ---------------- +retry_lock = threading.Lock() +retry_queue = {} # {仓库名: 已重试次数} +retry_next_at = 0.0 # 下次重试时间戳 + + +def clear_retry(name): + with retry_lock: + retry_queue.pop(name, None) + + +def schedule_retries(failed): + """整轮同步有失败时,把失败仓库排入自动重试(每轮重新计数)""" + global retry_next_at + mr = max_retries() + if mr <= 0 or not failed: + return + with retry_lock: + for name in failed: + retry_queue.setdefault(name, 0) + retry_next_at = time.time() + RETRY_INTERVAL + log(f"同步失败,已排程自动重试: {', '.join(failed)}" + f"(每 {RETRY_INTERVAL} 秒一次,最多 {mr} 次)") + + +def retry_snapshot(): + """重试状态(供前端展示)""" + with retry_lock: + if not retry_queue: + return {"pending": [], "attempts": {}, "max": max_retries(), "next_in": None} + return {"pending": sorted(retry_queue.keys()), + "attempts": dict(retry_queue), + "max": max_retries(), + "next_in": max(0, int(retry_next_at - time.time()))} + + +def retry_round(): + """跑一轮重试:并行重试队列中的仓库;成功移出,busy 不计数,失败计数+1,超上限放弃""" + global retry_next_at + with retry_lock: + batch = dict(retry_queue) + if not batch: + return + results = {} + workers = max(1, min(sync_workers(), len(batch))) + + def worker(name): + try: + results[name] = sync_repo(name) + except Exception as e: + results[name] = f"error: {e}" + + with ThreadPoolExecutor(max_workers=workers, thread_name_prefix="ghs-retry") as ex: + list(ex.map(worker, batch)) + mr = max_retries() + ok_names, failed, busy, gave_up = [], [], [], [] + with retry_lock: + for name, res in results.items(): + if res in ("changed", "same"): + retry_queue.pop(name, None) + ok_names.append(name) + elif res == "busy": + busy.append(name) # 被其他同步占用,不计数 + else: + n = batch.get(name, 0) + 1 + if n >= mr: + retry_queue.pop(name, None) + gave_up.append((name, n)) + else: + retry_queue[name] = n + failed.append((name, n)) + attempts = len(results) - len(busy) + if retry_queue: + retry_next_at = time.time() + RETRY_INTERVAL + if attempts: + bump_stats(total_retries=attempts) + if ok_names: + log(f"重试成功: {', '.join(ok_names)}") + for name, n in failed: + log(f"重试失败: {name}(第 {n}/{mr} 次,{RETRY_INTERVAL} 秒后再试)") + for name, n in gave_up: + log(f"重试 {n} 次仍失败,放弃自动重试: {name}(下一轮同步或手动同步会再次尝试)") + + +def retry_loop(): + while True: + time.sleep(2) + try: + with retry_lock: + due = bool(retry_queue) and time.time() >= retry_next_at + if due: + retry_round() + except Exception as e: + log(f"重试线程异常: {e}") + + def loop(): while True: state["loop_last"] = time.time() @@ -716,6 +841,7 @@ def global_status(ui_url=None): "uptime": fmt_uptime(now - START_TS), "interval": sync_interval(), "max_workers": sync_workers(), + "retry": retry_snapshot(), "last_check": state["last_check"], "next_in": next_in, "checks": state["checks"], @@ -962,6 +1088,7 @@ PAGE_STATUS = """
累计检查轮数
—
累计实际同步
—
累计失败次数
—
+
失败重试
—
镜像总占用
—
@@ -992,12 +1119,19 @@ async function refresh(){ document.getElementById('sChecks').textContent=s.stats.total_checks; document.getElementById('sPushes').textContent=s.stats.total_pushes; document.getElementById('sFails').textContent=s.stats.total_failures; + const rt=s.retry||{}; + document.getElementById('sRetry').textContent=(rt.pending&&rt.pending.length) + ?(rt.pending.length+' 个待重试 · '+rt.next_in+'s后(最多'+rt.max+'次)') + :'无待重试'; document.getElementById('sSize').textContent=s.total_size_mb+' MB'; document.getElementById('sTarget').textContent='同步目标: '+(s.account?s.account.gh_user+'('+s.account.method+')':'未配置')+' · '+(s.max_workers||1)+' 个并行 · Gitea '+(s.gitea_online?'在线 ✓':'离线 ✗'); document.getElementById('tb').innerHTML=s.repos.map(r=>{ - const st=r.status==='已同步'?'✓ 已同步':'⏳ 未同步'; + const att=(rt.attempts||{})[r.name]; + const stTxt=r.status==='已同步'?'✓ 已同步':(att!=null?('⏳ 重试中 '+att+'/'+(rt.max||5)):'⏳ 未同步'); + const stCls=r.status==='已同步'?'ok':'warn'; + const st=`${stTxt}`; const c=r.last_commit?`
${esc(r.last_commit.hash)} ${esc(r.last_commit.msg)} · ${esc(r.last_commit.author)} · ${esc(r.last_commit.time)}
`:'
—
'; - return `${esc(r.name)}${r.status==='已同步'?'✓ 已同步':'⏳ 未同步'}${esc(r.head||'—')}${r.branches} / ${r.tags}${r.last_push||'—'}${c}${r.size_mb} MB`; + return `${esc(r.name)}${st}${esc(r.head||'—')}${r.branches} / ${r.tags}${r.last_push||'—'}${c}${r.size_mb} MB`; }).join('')||'还没有同步项目,去项目管理添加'; }catch(e){document.getElementById('tb').innerHTML='刷新失败: '+e+''} } @@ -1144,6 +1278,14 @@ PAGE_SETTINGS = """
+
+ + +
+
失败重试次数
+
+
+
@@ -1166,6 +1308,9 @@ async function refresh(){ const wk=document.getElementById('syncWorkers'); wk.value=s.max_workers||3;wk.min=s.min_workers||1;wk.max=s.max_workers_limit||8; document.getElementById('workersTip').textContent='每轮最多 '+(s.max_workers||3)+' 个项目同时克隆/推送(1~'+(s.max_workers_limit||8)+'),项目多时能显著加快;网络或 NAS 负载高就调小'; + const rt=document.getElementById('syncRetries'); + rt.value=(s.max_retries==null?5:s.max_retries);rt.min=0;rt.max=20; + document.getElementById('retriesTip').textContent='同步失败后每隔 '+(s.retry_interval||60)+' 秒自动重试失败的仓库,最多 '+(s.max_retries==null?5:s.max_retries)+' 次(0 = 不重试);与同步间隔无关,专治网络抖动'; const cur=(s.accounts||[]).find(a=>a.id===s.active); // 当前账号无 Token 时,顶部提示为什么不能自动建库、怎么解锁 document.getElementById('accBanner').innerHTML=(cur&&!cur.has_token) @@ -1273,6 +1418,15 @@ async function saveWorkers(){ }catch(e){showMsg('msgCfg','请求失败: '+e,false)} finally{b.disabled=false;b.textContent='保存并发';refresh();} } +async function saveRetries(){ + const v=parseInt(document.getElementById('syncRetries').value,10); + const b=document.getElementById('btnRetries');b.disabled=true;b.textContent='⏳ 保存中…'; + try{ + const r=await (await fetch('api/settings/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({max_retries:v})})).json(); + showMsg('msgCfg',r.msg||JSON.stringify(r),!!r.ok); + }catch(e){showMsg('msgCfg','请求失败: '+e,false)} + finally{b.disabled=false;b.textContent='保存次数';refresh();} +} refresh(); """ @@ -1346,6 +1500,8 @@ class Handler(BaseHTTPRequestHandler): "max_workers": sync_workers(), "min_workers": MIN_WORKERS, "max_workers_limit": MAX_WORKERS, + "max_retries": max_retries(), + "retry_interval": RETRY_INTERVAL, "gitea_host": gitea_host(), "gitea_url": gitea_ui(self.headers.get("Host")), "active": active_id, @@ -1390,6 +1546,10 @@ class Handler(BaseHTTPRequestHandler): if not valid_workers(data.get("max_workers")): self._json({"ok": False, "msg": f"同时同步项目数须为 {MIN_WORKERS} ~ {MAX_WORKERS} 之间的整数"}) return + if "max_retries" in data: + if not valid_retries(data.get("max_retries")): + self._json({"ok": False, "msg": f"失败重试次数须为 {MIN_MAX_RETRIES} ~ {MAX_MAX_RETRIES} 之间的整数"}) + return # 预校验:新地址先探连通性;涉及账号的改动再用新凭据调一次 /user 验证 trial = dict(cfg) if new_user is not None: @@ -1441,6 +1601,11 @@ class Handler(BaseHTTPRequestHandler): cfg["max_workers"] = int(data.get("max_workers")) if cfg["max_workers"] != old_w: log(f"同时同步项目数已调整: {old_w} -> {cfg['max_workers']}") + if "max_retries" in data: + old_r = max_retries() + cfg["max_retries"] = int(data.get("max_retries")) + if cfg["max_retries"] != old_r: + log(f"失败重试次数已调整: {old_r} -> {cfg['max_retries']}") save_settings() notes = [] if new_host is not None: @@ -1545,13 +1710,17 @@ class Handler(BaseHTTPRequestHandler): # 与正在进行的整轮同步互不阻塞,实现多项目同时同步 res = sync_repo(name) state["last_check"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - if res.startswith("error"): + if res == "busy": + self._json({"ok": False, "msg": f"{name} 正在同步中,请稍候"}) + elif res.startswith("error"): bump_stats(total_failures=1) self._json({"ok": False, "msg": f"{name} {res}"}) elif res == "changed": bump_stats(total_pushes=1) + clear_retry(name) # 手动同步成功,退出自动重试队列 self._json({"ok": True, "msg": f"{name} 已同步到 GitHub"}) else: + clear_retry(name) self._json({"ok": True, "msg": f"{name} 已是最新,无需同步"}) elif path == "/api/repos/add": name = (data.get("name") or "").strip() @@ -1580,12 +1749,16 @@ class Handler(BaseHTTPRequestHandler): save_repos(repos) log(f"{name}: 已加入同步列表") res = sync_repo(name) - if res.startswith("error"): + if res == "busy": + self._json({"ok": True, "msg": f"{name} 添加成功(该仓库正在同步中,稍后刷新看结果)"}) + elif res.startswith("error"): self._json({"ok": False, "msg": f"已加入列表,但首次同步失败:{res}"}) elif res == "changed": bump_stats(total_pushes=1) + clear_retry(name) self._json({"ok": True, "msg": f"{name} 添加成功,已完成首次同步"}) else: + clear_retry(name) self._json({"ok": True, "msg": f"{name} 添加成功,内容已是最新"}) elif path == "/api/repos/remove": name = (data.get("name") or "").strip() @@ -1595,6 +1768,7 @@ class Handler(BaseHTTPRequestHandler): return repos = [r for r in repos if r != name] save_repos(repos) + clear_retry(name) log(f"{name}: 已从同步列表移除") self._json({"ok": True, "msg": f"{name} 已移除(GitHub 仓库与本地数据保留)"}) else: @@ -1642,6 +1816,7 @@ def main(): f"({acc['gh_user'] if acc else '-'}/{'Token' if acc and acc.get('token') else 'SSH'}), " f"Gitea 用户={cfg['gitea_user']}, 地址={gitea_host()}, 自动创建={'开' if cfg.get('auto_create') else '关'})") threading.Thread(target=loop, daemon=True).start() + threading.Thread(target=retry_loop, daemon=True).start() server = ThreadingHTTPServer(("0.0.0.0", PANEL_PORT), Handler) log(f"控制面板监听 :{PANEL_PORT}") server.serve_forever() diff --git a/GiteaHubSync/manifest b/GiteaHubSync/manifest index 5b9f50b..1c31bde 100644 --- a/GiteaHubSync/manifest +++ b/GiteaHubSync/manifest @@ -1,5 +1,5 @@ appname = GiteaHubSync -version = 1.3.0 +version = 1.4.0 display_name = Gitea→GitHub 同步助手 desc = 每次推送到飞牛 Gitea 的代码,10 秒内自动镜像到 GitHub 的 7 个私有仓库。内置控制面板:自动同步开关、立即同步、仓库状态、同步日志,一键打开 Gitea。数据全部保存在 NAS 本地。 platform = all diff --git a/README.md b/README.md index 28c8d26..fc49ba2 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,9 @@ NAS 重启自动恢复。控制面板带自动同步开关、立即同步、仓 - **同步间隔可调**:设置页可改检查间隔(5 秒 ~ 1 天,默认 10 秒),改完立即生效 - **多项目并行同步**:每轮最多 N 个项目同时克隆/推送(默认 3,可调 1~8); 在状态页点单个仓库的「同步」也不再被整轮同步阻塞(同一仓库有防重入) +- **失败自动重试**:同步失败后每隔 1 分钟自动重试失败的仓库,最多 N 次 + (默认 5,可调 0~20,0 = 不重试);重试与同步间隔无关,专治网络抖动, + 重试中的仓库在状态页显示「重试中 x/N」 - **Gitea 地址可改**:设置页可改同步引擎访问的 Gitea 地址(默认 `http://127.0.0.1:3000`), 保存前自动探测连通性;改地址后已有镜像的拉取地址自动更新,「打开 Gitea」按钮随访问地址自适应 - **自动创建仓库**:Gitea 上的新仓库加入同步时,若 GitHub 上不存在同名仓库,