Files
Nexus/docs/design/specs/2026-05-17-retry-progress-design.md
T

196 lines
7.3 KiB
Markdown
Raw Normal View History

# 推送失败重试 + 实时进度 — 设计规格
> ⚠️ **已归档** — 2026-05-22
> 本文档引用的文件结构已过时:`server/database.py` 已拆分为 `server/domain/models/`PHP 前端 (`retry.php`/`servers.php`) 已迁移到 Tailwind CSS v4 + Alpine.js`datetime.utcnow()` 已弃用。功能已实现,保留本文档供历史参考。
## 背景
当前推送 `asyncio.gather` 等待全部完成后一次性返回,大数据量时前端无反馈。失败推送只记录 SyncLog,需人工回到服务器管理页重试。需:**实时进度 + 自动重试 + 一致性验证后消失**。
## 核心设计
### 数据模型
**新增 PushRetryJob 表:**
```python
class PushRetryJob(Base):
__tablename__ = "push_retry_jobs"
id = Column(Integer, primary_key=True, autoincrement=True)
server_id = Column(Integer, ForeignKey("servers.id"), nullable=False)
server_name = Column(String(100)) # 冗余,方便列表显示
source_path = Column(String(500))
target_path = Column(String(500))
status = Column(String(20), default="pending") # pending/retrying/abandoned
retry_count = Column(Integer, default=0)
max_retries = Column(Integer, default=100)
next_retry_at = Column(DateTime) # 初始 NOW()+5min
last_error = Column(Text)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
updated_at = Column(DateTime, default=datetime.datetime.utcnow)
```
### 生命周期
```
推送失败 ──→ INSERT job (status=pending, next_retry=NOW+5min)
后台 loop (60s) ──→ 扫描 next_retry_at <= NOW()
rsync -avz --stats (重新推送)
rsync -avzn --delete (一致性 dry-run)
┌───────────┴───────────┐
一致 不一致/错误
│ │
DELETE job retries < 100 ?
(消失) │
┌───────┴───────┐
是 否
│ │
next_retry status=abandoned
=NOW+5min (人工处理)
```
### 一致性验证命令
```bash
rsync -avzn --stats --delete --out-format='%i %n' source/ server:target/
```
- `-n` dry-run
- `--delete` 检测目标多余文件
- 输出 `Number of files: 0` → 完全一致
### 实时进度
**sync_engine 改动:**
`push_to_server` 新增 `progress_callback` 参数:
```python
async def push_to_server(server, source, target, progress_callback=None):
# rsync --out-format='%i %n' --info=progress2
# 逐行解析:
# >f+++++++++ file.txt → 文件新增
# .f...p..... file2.txt → 权限变更
for line in iter(proc.stdout.readline, b''):
if m := re.match(r'^\S+\s+(.+)$', line):
file_name = m.group(1)
transferred += 1
if progress_callback:
await progress_callback(server.id, {
"file": file_name, "transferred": transferred,
"total": total_files, # 从 --stats 预取
})
```
**WebSocket 推送路径:**
```
push_to_server → progress_callback → WebSocket manager → 前端
→ broadcast({server_id, progress})
```
### API 端点
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/servers/retry-jobs` | 重试队列列表 |
| POST | `/api/servers/retry-jobs/{id}/retry-now` | 立即重试单条 |
| POST | `/api/servers/retry-jobs/retry-all` | 批量重试全部 pending |
| DELETE | `/api/servers/retry-jobs/{id}` | 删除/放弃单条 |
| DELETE | `/api/servers/retry-jobs/clear-done` | 清除已完成/放弃的 |
### 前端
**retry.php — 重试队列页:**
```
┌─ 重试队列 (3) ───────────────────────────────────────┐
│ 服务器 源目录 目标目录 重试 操作 │
│ web-01 /www/wwwroot/proj /var/www/site 2 🔄⏭ │
│ api-01 /www/wwwroot/api /opt/api 0 🔄⏭ │
│ db-01 /data/backup /backup 5 🔄⏭ │
│ [全部重试] [清除] │
└──────────────────────────────────────────────────────┘
```
**servers.php 推送对话框改实时:**
```
┌─ 推送中... ─────────────────────────────────┐
│ web-01 ████████████░░░░░░ 120/350 文件 │
│ 45MB · ETA 2 分钟 │
│ web-02 ████████████████░░ 200/350 文件 │
│ 80MB · ETA 1 分钟 │
│ 完成 1/3 │
│ [后台运行] │
└──────────────────────────────────────────────┘
```
sidebar 角标:`重试队列 3`
### 后台服务
**新增 retry_engine.py**
```python
async def retry_loop():
while True:
db = SessionLocal()
jobs = db.query(PushRetryJob).filter(
PushRetryJob.status == "pending",
PushRetryJob.next_retry_at <= datetime.utcnow(),
).all()
for job in jobs:
job.status = "retrying"
db.commit()
# rsync 推送
result = await push_to_server(server, job.source_path, job.target_path)
# dry-run 一致性
consistent = await verify_consistency(server, job.source_path, job.target_path)
if consistent:
db.delete(job)
else:
job.retry_count += 1
if job.retry_count >= job.max_retries:
job.status = "abandoned"
else:
job.status = "pending"
job.next_retry_at = datetime.utcnow() + timedelta(minutes=5)
job.last_error = result.get("message", "")
db.commit()
db.close()
await asyncio.sleep(60)
```
`main.py` `lifespan` 中启动 `retry_loop`
### 文件统计
| 文件 | 改动 | 预估行数 |
|------|------|---------|
| `server/database.py` | PushRetryJob 模型 | +15 |
| `server/services/retry_engine.py` | 新建,retry loop + 一致性验证 | +120 |
| `server/api/servers.py` | push 端点 WebSocket 进度 + 失败写 job | +30 |
| `server/services/sync_engine.py` | 流式解析 rsync + progress_callback | +40 |
| `server/main.py` | 启动 retry_loop + WS manager | +15 |
| `web/retry.php` | 新建 | +80 |
| `web/servers.php` | 推送对话框实时进度条 | +60 |
| 各页面 sidebar | 加重试队列链接 + 角标 | +20 |
**预估:8 文件,~380 行。**
## 规格自检
- [x] 无占位符/TODO
- [x] 重试机制完整:失败 → INSERT → 定时 scan → rsync → verify → delete/retry/abandon
- [x] 进度即时:WebSocket + callback 逐文件推送
- [x] 一致性:rsync dry-run --delete0 个变化才消失
- [x] 前端独立页面 + sidebar 角标
- [x] 不在推送对话框卡住用户