196 lines
7.3 KiB
Markdown
196 lines
7.3 KiB
Markdown
|
|
# 推送失败重试 + 实时进度 — 设计规格
|
|||
|
|
|
|||
|
|
> ⚠️ **已归档** — 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 --delete,0 个变化才消失
|
|||
|
|
- [x] 前端独立页面 + sidebar 角标
|
|||
|
|
- [x] 不在推送对话框卡住用户
|