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
@@ -0,0 +1,235 @@
|
||||
//! 分块下载的**续传判定与旧字节自证**。
|
||||
//!
|
||||
//! 台账 P21 第 2 条 + P19(b):分块路径此前**不写**续传元数据、每次进 `attempt_chunked` 先
|
||||
//! `part.discard()`,于是 188MB 的 JDK 任何一次失败都从 0 重下。这里补上「记住落了哪些段」,
|
||||
//! 但**不是**把旧字节当可信——分块路径的旧字节比单连接的更可疑,原因有三条:
|
||||
//!
|
||||
//! 1. 中间文件被 `set_len(total)` 预分配过,长度恒等于总长:`resume::decide` 的「长度 = 已下」
|
||||
//! 这条前提在这里根本不成立(它会判成 `Complete`,然后整文件校验把一份没下完的文件判死);
|
||||
//! 2. 段是**乱序**写的,磁盘上「有字节」不等于「这一段是完整的」——进程被强杀时正写到一半的段
|
||||
//! 与写完了的段在文件里长得一模一样;
|
||||
//! 3. 同一个 URL 的响应可能在两次尝试之间换了内容(镜像回源、滚动发布)。
|
||||
//!
|
||||
//! 所以复用旧段要过两道门,缺一不可:
|
||||
//!
|
||||
//! * **身份**([`decide`],纯函数):策略允许续传、元数据在、URL / 总长 / 摘要算法 / 期望摘要
|
||||
//! 全部与本次请求一致、有 ETag 或 Last-Modified 作校验器(续传请求会带 `If-Range`,让服务器
|
||||
//! 自己回答「内容变了没有」),并且每条记录都能**精确对上**本次分块规划里的某一段;
|
||||
//! * **自证**([`verify_records`]):按记录重读那段字节、重算摘要,与记录比对。
|
||||
//!
|
||||
//! 两道门任一不过 ⇒ 丢掉整份中间文件**从头再来**(不是「只补坏的那一段」):
|
||||
//! 摘要对不上意味着这份元数据本身已经不可信(盘上数据在腐坏,或者元数据是崩溃前写的、
|
||||
//! 盘上并没有落那样),同一份元数据里的**其它**记录同样没有理由被信任。这条与
|
||||
//! [`super::resume`] 的保守立场一致——重下 100MB 也比交付一个坏文件便宜。
|
||||
//!
|
||||
//! 最后还有第三道保险:整文件摘要在传输结束后**整份重算**(`transfer_chunk::verify_part`),
|
||||
//! 覆盖新下的字节与复用的字节。前两道门只保证「旧字节是我们写的」,第三道门才保证
|
||||
//! 「我们写的是对的那份」。
|
||||
//!
|
||||
//! 写的这一侧是本文末的 [`ChunkJournal`]:每成功回收一段就落一次盘(原子写),失败时元数据
|
||||
//! 里已经有「哪几段、各自摘要是什么」,于是下一次尝试能接着下而不是从 0。
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{self, Read, Seek, SeekFrom};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::cache::now_ms;
|
||||
use super::chunked::{self, ByteRange, ChunkFetch};
|
||||
use super::resume::{ChunkRecord, RestartReason, ResumeMeta};
|
||||
use super::types::{HashAlgo, ResumePolicy};
|
||||
use super::verify;
|
||||
|
||||
/// 复用旧分块的判定结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ChunkDecision {
|
||||
/// 这些段可以复用——**但调用方仍须先跑 [`verify_records`]**,本判定只看不碰盘。
|
||||
Reuse {
|
||||
/// 已落盘且对得上本次规划的分块记录。
|
||||
done: Vec<ChunkRecord>,
|
||||
},
|
||||
/// 从头下载(调用方要丢掉整份中间文件)。
|
||||
Restart {
|
||||
/// 原因(诊断用;与单连接路径共用同一个枚举)。
|
||||
reason: RestartReason,
|
||||
},
|
||||
}
|
||||
|
||||
/// 判定能否复用旧分块(纯函数:不读盘、不看时钟)。
|
||||
///
|
||||
/// `part_len` 为 0 表示没有中间文件(或它是 0 字节)。`planned` 是本次要用的分块规划,
|
||||
/// 记录必须**逐一精确命中**其中的段:连接数变了、总长变了、或记录本身畸形,都判从头再来。
|
||||
#[must_use]
|
||||
pub fn decide(
|
||||
policy: ResumePolicy,
|
||||
part_len: u64,
|
||||
meta: Option<&ResumeMeta>,
|
||||
url: &str,
|
||||
integrity: Option<(HashAlgo, &str)>,
|
||||
total: u64,
|
||||
planned: &[ByteRange],
|
||||
) -> ChunkDecision {
|
||||
let restart = |reason| ChunkDecision::Restart { reason };
|
||||
if policy == ResumePolicy::Never {
|
||||
return restart(RestartReason::PolicyNever);
|
||||
}
|
||||
if part_len == 0 {
|
||||
return restart(RestartReason::NoPart);
|
||||
}
|
||||
let Some(meta) = meta else {
|
||||
return restart(RestartReason::NoMeta);
|
||||
};
|
||||
// 没有分块清单的元数据(单连接路径写的)证明不了「乱序段各自到哪」,一律从头来。
|
||||
let Some(records) = meta.chunks.as_ref() else {
|
||||
return restart(RestartReason::NoMeta);
|
||||
};
|
||||
if meta.url != url {
|
||||
return restart(RestartReason::UrlChanged);
|
||||
}
|
||||
if let Some((algo, hash)) = integrity {
|
||||
if meta.algo != algo || !meta.hash.eq_ignore_ascii_case(hash) {
|
||||
return restart(RestartReason::HashChanged);
|
||||
}
|
||||
}
|
||||
// 总长变了就是另一个文件:旧段的偏移全部作废(只比 URL 抓不住这个)。
|
||||
if meta.total != Some(total) {
|
||||
return restart(RestartReason::SizeChanged);
|
||||
}
|
||||
// 没有校验器就无法让服务器回答「内容变了没有」,只能从头下(与单连接同一立场)。
|
||||
if meta.validator().if_range_value().is_none() {
|
||||
return restart(RestartReason::NoValidator);
|
||||
}
|
||||
// 每条记录必须是规划里**原样**的一段,且不许重复:段边界一旦漂移,
|
||||
// 「已落盘多少」就说不清了(长度恰好但位置错的文件会被整文件校验兜住,但那已经是白下)。
|
||||
let mut done: Vec<ChunkRecord> = Vec::with_capacity(records.len());
|
||||
for record in records {
|
||||
if record.is_empty() || record.end >= total {
|
||||
return restart(RestartReason::ChunkLayout);
|
||||
}
|
||||
if !planned
|
||||
.iter()
|
||||
.any(|range| range.start == record.start && range.end == record.end)
|
||||
{
|
||||
return restart(RestartReason::ChunkLayout);
|
||||
}
|
||||
if done
|
||||
.iter()
|
||||
.any(|seen| seen.start == record.start && seen.end == record.end)
|
||||
{
|
||||
return restart(RestartReason::ChunkLayout);
|
||||
}
|
||||
done.push(record.clone());
|
||||
}
|
||||
ChunkDecision::Reuse { done }
|
||||
}
|
||||
|
||||
/// 本次还要抓的段 = 规划 − 已落盘([`decide`] 已保证 `done ⊆ planned` 且边界精确)。
|
||||
#[must_use]
|
||||
pub fn remaining(planned: &[ByteRange], done: &[ChunkRecord]) -> Vec<ByteRange> {
|
||||
planned
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|range| {
|
||||
!done
|
||||
.iter()
|
||||
.any(|record| record.start == range.start && record.end == range.end)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 按记录逐段重读、重算摘要:返回**可复用的字节数**。
|
||||
///
|
||||
/// 任一记录对不上(摘要不符 / 读不满一段 / 读盘失败)就返回 `Err`,调用方必须整份丢掉。
|
||||
/// 同步阻塞 IO:异步路径要把它放进 `spawn_blocking` 并套上 `verify_timeout`
|
||||
/// (与 `transfer_chunk::verify_part` 同一套做法)。
|
||||
pub fn verify_records(path: &Path, records: &[ChunkRecord]) -> io::Result<u64> {
|
||||
let mut file = File::open(path)?;
|
||||
let mut buffer = vec![0_u8; 64 * 1024];
|
||||
let mut verified = 0_u64;
|
||||
for record in records {
|
||||
let mut hasher = verify::Hasher::new(chunked::CHUNK_DIGEST);
|
||||
file.seek(SeekFrom::Start(record.start))?;
|
||||
let mut left = record.len();
|
||||
while left > 0 {
|
||||
let want = usize::try_from(left.min(buffer.len() as u64)).unwrap_or(buffer.len());
|
||||
let read = file.read(&mut buffer[..want])?;
|
||||
if read == 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
format!(
|
||||
"分块 [{}..={}] 在盘上不够长(缺 {left} 字节)",
|
||||
record.start, record.end
|
||||
),
|
||||
));
|
||||
}
|
||||
hasher.update(&buffer[..read]);
|
||||
left -= read as u64;
|
||||
}
|
||||
let actual = hasher.finish();
|
||||
if !verify::matches(&record.sha1, &actual) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"分块 [{}..={}] 摘要不符:记录 {} 实得 {actual}",
|
||||
record.start, record.end, record.sha1
|
||||
),
|
||||
));
|
||||
}
|
||||
verified += record.len();
|
||||
}
|
||||
Ok(verified)
|
||||
}
|
||||
|
||||
/// 续传元数据的增量写手:每落完一段就把「这一段已落盘、摘要是它」写进元数据。
|
||||
///
|
||||
/// 为什么不收工再写:进程被强杀 / 断电时收工那次写根本不会发生,而已经落盘的段是好几分钟到
|
||||
/// 几十分钟的字节。代价是每段一次小文件原子写(`plan` 最多切 `MAX_CONNECTIONS` = 16 段,
|
||||
/// 单个作业最多 16 次),而且**只有成功回收的段才记**——写到一半被 abort 的段不会被记,
|
||||
/// 下次整段重抓(于是「记录里的段」不会出现半截字节)。
|
||||
pub struct ChunkJournal {
|
||||
/// 元数据本体(`chunks` 随进度增长)。
|
||||
meta: ResumeMeta,
|
||||
/// 落盘路径。
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl ChunkJournal {
|
||||
/// 建账并立刻落一次盘。
|
||||
///
|
||||
/// 这一次写还担着第二个作用:元数据里 `chunks: Some(..)` 是「这个中间文件属于分块路径」的
|
||||
/// 标记,下次尝试靠它躲开单连接路径「长度 = 已下满」的误判(预分配文件长度恒等于总长)。
|
||||
pub fn begin(meta: ResumeMeta, path: PathBuf) -> Self {
|
||||
let mut journal = Self { meta, path };
|
||||
journal.save();
|
||||
journal
|
||||
}
|
||||
|
||||
/// 已落盘的段。
|
||||
pub fn done(&self) -> &[ChunkRecord] {
|
||||
self.meta.chunks.as_deref().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 记一个已落盘段(顺带补上服务器校验器:只有第一次拿到的算数,续传沿用旧的那个)。
|
||||
pub fn record(&mut self, fetched: ChunkFetch) {
|
||||
if self.meta.etag.is_none() {
|
||||
self.meta.etag = fetched.etag;
|
||||
}
|
||||
if self.meta.last_modified.is_none() {
|
||||
self.meta.last_modified = fetched.last_modified;
|
||||
}
|
||||
self.meta
|
||||
.chunks
|
||||
.get_or_insert_with(Vec::new)
|
||||
.push(ChunkRecord {
|
||||
start: fetched.range.start,
|
||||
end: fetched.range.end,
|
||||
sha1: fetched.sha1,
|
||||
});
|
||||
self.save();
|
||||
}
|
||||
|
||||
/// 落盘(元数据写失败不影响下载本身:续传只是加速,不是正确性前提)。
|
||||
pub fn save(&mut self) {
|
||||
self.meta.saved_at_ms = now_ms();
|
||||
let _ = self.meta.save(&self.path);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user