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:
1 parent
6e1d5fa82e
commit
3ade2d14a8
13 files changed
+1428
-359
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user