feat(download): 分块路径写续传元数据并自证旧字节(M293)

台账 P21 第 2 条 + P19(b):分块路径此前进来先 `part.discard()`、失败也 `discard()`,
188MB 的 JDK 任何一次失败都从 0 重下;M289 又给预算耗尽加了「不再重试、不再换源」的
短路,慢链路上一次晚到的失败就变成「这个文件下不来」。

写这一侧:
* `ResumeMeta` 新增 `chunks: Option<Vec<ChunkRecord>>`(段区间 + 该段 SHA-1),
  由新增的 `chunk_resume::ChunkJournal` 每成功回收一段就原子落一次盘;
* `fetch_chunk` 顺带带回段摘要、`Content-Range` 总长与 ETag/Last-Modified,
  所以元数据里同时有 total / 源 URL / 分块清单 / 校验所需信息;
* 分块失败不再丢中间文件,只有 `RangeUnsupported`(单连接接不住这种形状)才丢。

判这一侧:
* `chunk_resume::decide`(纯函数)要求策略允许、URL / 总长 / 摘要 / 校验器全对得上,
  且每条记录精确命中本次分块规划里的一段,否则从头再来;
* `chunk_resume::verify_records` 按记录重读旧段、重算摘要:任何一段对不上就整份丢掉
  (元数据本身已不可信,同批其它记录也不再被信任);
* 续传的段请求带 `If-Range`,让服务器回答「内容变了没有」;
* 整文件摘要照旧在末尾整份重算,覆盖新下 + 复用的全部字节。

单连接路径零改动:`resume::decide` 的判定一字未动,只新增
「元数据里带分块清单 ⇒ 强行判 Restart 交给分块路径」这一条,避免预分配全长文件被
单连接的 `Complete` 误判成「已下满」。

判据(都是「服务端实际吐了多少字节」,不是「最终 hash 对」):
* 3/4 处失败后续传完成:41984 字节 / 40960 文件 = 1.025×(断言 < 1.5×);
* 变异(关掉续传同样夹具):72704 = 1.775×,`< 1.5×` 必红(已实测);
* 变异(去掉旧段摘要自证):坏段被复用 ⇒ 51200 ≠ 40960 必红(已实测);
* 变异(去掉总长比较):旧大小的段被复用 ⇒ 61440 ≠ 40960 必红(已实测)。
This commit is contained in:
AskaEth committed 2026-11-13 06:26:18 +08:00
1 parent 6e1d5fa82e
commit 3ade2d14a8
13 files changed
+1428 -359

No files matched your search

+147 -22
View File
@@ -1,19 +1,37 @@
//! 大文件多连接分块的编排与校验。
//!
//! 分块本身由 `chunked.rs` 负责(纯规划 + 单块抓取),这里负责并发、进度上报、
//! 失败清理与「整文件重算一次摘要」(分块是乱序写,只能整文件校验)。
//! 续传元数据的读写与「整文件重算一次摘要」(分块是乱序写,只能整文件校验)。
//!
//! # 续传(M293:台账 P21 第 2 条 + P19(b))
//!
//! 此前这里开头无条件 `part.discard()`、失败也 `discard()`,于是 188MB 的 JDK 任何一次失败
//! 都从 0 重下;M289 又给「预算耗尽」加了「不再重试、不再换源」的短路,慢链路上一次晚到的
//! 失败就变成「这个文件下不来」。现在:
//!
//! * 每落完一段就把它连同**这段字节的摘要**写进 `ResumeMeta::chunks`(`ChunkJournal`);
//! * 失败**不再**丢中间文件——已完成的段是几十分钟的字节,元数据里带着它们的自证记录;
//! * 下次进本函数先过 [`super::chunk_resume::decide`](身份)与 [`Self::verify_chunks`]
//! (旧字节自证),两道门都过才复用;任何一道不过就整份丢掉从头再来;
//! * 整文件摘要照旧在传输结束后整份重算,覆盖新下 + 复用的全部字节。
//!
//! 唯一仍然主动 `discard()` 的失败是 `RangeUnsupported`:服务器不支持 Range 时单连接路径
//! 接不住「预分配 + 乱序段」这种形状的文件(它只会截断重写),留着没意义。
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use super::atomic::PartFile;
use super::chunked;
use super::cache::now_ms;
use super::chunk_resume::{self, ChunkDecision, ChunkJournal};
use super::chunked::{self, ChunkFetch};
use super::engine::{AttemptOk, DownloadEngine};
use super::error::{DownloadError, DownloadErrorKind};
use super::progress::ProgressEvent;
use super::resume::{ChunkRecord, ResumeMeta};
use super::stall::{StallReason, StallReport, StallStage};
use super::types::{Candidate, DownloadId, DownloadRequest, Integrity};
use super::types::{Candidate, DownloadId, DownloadRequest, HashAlgo, Integrity};
use super::verify;
impl DownloadEngine {
@@ -38,53 +56,124 @@ impl DownloadEngine {
"可切分段不足,退化为单连接",
));
}
let integrity = req.integrity.as_ref();
// ① 身份:旧分块是不是**这次**下载的产物(纯判定,不碰盘)。
let meta_path = part.meta_path();
let previous = ResumeMeta::load(&meta_path);
let decision = chunk_resume::decide(
req.resume,
part.len(),
previous.as_ref(),
&candidate.url,
integrity.map(|item| (item.algo, item.hex.as_str())),
total,
&ranges,
);
// ② 自证:按记录重读那些字节、重算摘要。任何一段对不上就整份丢掉从头再来
// (理由见 `chunk_resume` 模块头:对不上说明这份元数据本身已经不可信)。
let mut done: Vec<ChunkRecord> = Vec::new();
match decision {
ChunkDecision::Reuse { done: records } => {
if self.verify_chunks(part, &records).await {
done = records;
} else {
part.discard();
}
}
ChunkDecision::Restart { .. } => part.discard(),
}
let reused: u64 = done.iter().map(ChunkRecord::len).sum();
// 续传时把旧校验器带上:这是 `If-Range` 的取值来源,也是「远端内容没变」的协议层证据。
let if_range = if done.is_empty() {
None
} else {
previous
.as_ref()
.and_then(|meta| meta.validator().if_range_value().map(str::to_string))
};
part.discard();
part.ensure_parent_async()
.await
.map_err(|err| io_err("创建下载目录失败", &err))?;
{
let file = tokio::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
let mut options = tokio::fs::OpenOptions::new();
options.create(true).write(true);
if done.is_empty() {
// 从头:先把可能存在的旧内容截掉,再预分配整长。
options.truncate(true);
}
let file = options
.open(part.path())
.await
.map_err(|err| io_err("预分配中间文件失败", &err))?;
// 续传时这一行是「归一化」:分块写是随机偏移,长度本该已经是 total;
// 若被外部截短/加长过,先摆正,缺的段下面的补抓会填满。
file.set_len(total)
.await
.map_err(|err| io_err("预分配长度失败", &err))?;
}
let counter = Arc::new(AtomicU64::new(0));
let mut journal = ChunkJournal::begin(
ResumeMeta {
url: candidate.url.clone(),
// 续传沿用旧校验器:新响应可能不给 ETag,而 `If-Range` 要靠它。
etag: previous.as_ref().and_then(|meta| meta.etag.clone()),
last_modified: previous
.as_ref()
.and_then(|meta| meta.last_modified.clone()),
total: Some(total),
algo: integrity.map_or(HashAlgo::Sha1, |item| item.algo),
hash: integrity.map_or_else(String::new, |item| item.hex.clone()),
saved_at_ms: now_ms(),
chunks: Some(done),
},
meta_path,
);
let counter = Arc::new(AtomicU64::new(reused));
let reporter = self.spawn_reporter(id, Arc::clone(&counter), total);
let deadlines = self.cfg.deadlines();
let todo = chunk_resume::remaining(&ranges, journal.done());
let mut set = tokio::task::JoinSet::new();
for range in ranges {
for range in todo {
let client = self.http.clone();
let url = candidate.url.clone();
let path = part.path().to_path_buf();
let progress = Arc::clone(&counter);
let if_range = if_range.clone();
set.spawn(async move {
chunked::fetch_chunk(&client, &url, range, &path, deadlines, &progress).await
chunked::fetch_chunk(
&client,
&url,
range,
&path,
deadlines,
&progress,
if_range.as_deref(),
)
.await
});
}
let failure = self
.await_chunks(&mut set, &counter, total, &candidate.url)
.await
.map(|err| err.with_source(candidate.source.as_str()));
.await_chunks(&mut set, &counter, total, &candidate.url, &mut journal)
.await;
reporter.abort();
if let Some(err) = failure {
part.discard();
return Err(err);
// 已完成的段连同自证摘要都在元数据里(`ChunkJournal` 每段落一次盘),中间文件留着,
// 下一次(甚至下次启动)接着下。只有「这个源不支持 Range」才把文件丢掉:
// 单连接路径接不住预分配 + 乱序段的形状。
if err.kind == DownloadErrorKind::RangeUnsupported {
part.discard();
}
return Err(err.with_source(candidate.source.as_str()));
}
// 分块是乱序写,只能整文件重算一次摘要。
self.verify_part(part, req.integrity.as_ref(), Some(total), id)
.await?;
// 分块是乱序写,只能整文件重算一次摘要——这一步覆盖新下的字节**与**复用的旧字节。
self.verify_part(part, integrity, Some(total), id).await?;
self.emit(ProgressEvent::Progress {
id,
downloaded: total,
@@ -92,7 +181,7 @@ impl DownloadEngine {
});
Ok(AttemptOk {
bytes: total,
resumed_from: 0,
resumed_from: reused,
})
}
@@ -105,12 +194,16 @@ impl DownloadEngine {
/// * **总期限**:`chunk_job_budget`(按平均吞吐下限反推,见 `stall` 模块头)用完。
///
/// 两者都归到阶段 [`StallStage::AwaitChunkJoin`],靠 [`StallReason`] 区分。
///
/// 成功的段在这里**顺手记账**([`ChunkJournal::record`]):这是唯一同时握着「段结果」与
/// 「段区间」的地方,落到元数据之后,本作业后面怎么挂都不影响这几段的续传价值。
async fn await_chunks(
&self,
set: &mut tokio::task::JoinSet<Result<u64, DownloadError>>,
set: &mut tokio::task::JoinSet<Result<ChunkFetch, DownloadError>>,
counter: &AtomicU64,
total: u64,
url: &str,
journal: &mut ChunkJournal,
) -> Option<DownloadError> {
let budget = self.cfg.chunk_job_budget(Some(total));
let hard_deadline = tokio::time::Instant::now() + budget;
@@ -124,7 +217,22 @@ impl DownloadEngine {
}
let wake = (last_progress + self.cfg.chunk_stall_timeout).min(hard_deadline);
match tokio::time::timeout_at(wake, set.join_next()).await {
Ok(Some(Ok(Ok(_)))) => {}
Ok(Some(Ok(Ok(fetched)))) => {
// 「这个源给的还是不是同一个文件」:`Content-Range` 的总长必须与我们规划的
// 一致,否则写下去的段根本拼不成目标文件(单连接路径同一处判据是收尾的
// `written != total`)。
if fetched.total.is_some_and(|server| server != total) {
set.abort_all();
return Some(DownloadError::new(
DownloadErrorKind::Network,
format!(
"分块总长不符:响应声明 {},本次规划 {total}({url})",
fetched.total.unwrap_or_default()
),
));
}
journal.record(fetched);
}
Ok(Some(Ok(Err(err)))) => {
set.abort_all();
return Some(err);
@@ -175,6 +283,23 @@ impl DownloadEngine {
}
}
/// 旧分块自证:按记录逐段重读、重算摘要。
///
/// `true` = 每一段都对得上,可以复用;`false` = 证明不了(摘要不符 / 盘上不够长 / 读盘失败 /
/// 超时),调用方必须整份丢掉。超时也归到「证明不了」:`spawn_blocking` 的线程不可取消,
/// 拿不到结果就不能拿旧字节去拼文件——宁可再生一遍字节。
async fn verify_chunks(&self, part: &PartFile, records: &[ChunkRecord]) -> bool {
let path = part.path().to_path_buf();
let records = records.to_vec();
let job = tokio::task::spawn_blocking(move || {
chunk_resume::verify_records(&path, &records).is_ok()
});
matches!(
tokio::time::timeout(self.cfg.verify_timeout, job).await,
Ok(Ok(true))
)
}
fn spawn_reporter(
&self,
id: DownloadId,