fix(download): 同源换表示时不再沿用上一份的校验器 + 三条 p3 收口(M293 r2)
复审 r2(p2-1):r1 的 `same_source` 只挡了「源不同」,没挡「同一 URL、换了一份表示」。
`done` 为空(Restart:`SizeChanged` / `HashChanged` / `ChunkLayout` …)时没有任何字节要接着用,
但 `journal.begin` 仍把上一份表示的 ETag 写进**新一轮**元数据;`record` 又只在缺值时写 ⇒ 本轮
3 段(新内容)配的是旧校验器 ⇒ 重试拿它发 `If-Range` ⇒ 服务器按 RFC 7233 回 200 + 完整正文 ⇒
`RangeUnsupported` ⇒ 丢整份 ⇒ 单连接从头下。实测 113664 字节(2.775×),比「干脆不续传」的
72704(1.775×)还多一整份。
修法:「谁能当校验器」收成一个 `carry`,两个维度缺一不可 ——
let carry = if done.is_empty() { None } else { same_source };
`if_range`、`etag`、`last_modified` 三处全部走它。清掉之后本轮响应里的真实校验器由
`ChunkJournal::record` 填进来;最坏也只是 `NoValidator` ⇒ 整份重下(1.775× 基线),不会更坏。
文档(`chunk_resume` 模块头的「校验器跟着源与表示走」、`record`、`carry` 处)同步改成两维口径。
判据(新用例 `chunked/resume.rs::stale_validator_from_the_previous_representation_is_not_carried`):
同一 URL、盘上是上一份表示的元数据(旧总长/旧段区间/旧 ETag `"v1"`/旧摘要)⇒ 本轮因
`HashChanged` 走 Restart、服务器此刻发 `"v2"`。断言:重试的 `If-Range` 只许是 `"v2"`、字节数
41984(1.025×,而不是 113664)、hits 5、resumed_from 30720、终态文件逐字节正确。
变异(两种,均实测红):
* 只把元数据那两行退回 `same_source`(`if_range` 仍按 `done` 门控)⇒ 复现复审的探针数字:
PROBE stale_validator if_range=["\"v1\""] hits=6 bytes=113664 resumed_from=0,
断言红在 `left: ["\"v1\""] right: ["\"v2\""]`;
* 把 `carry` 整体退回 `same_source`(连带首轮也发 `If-Range`)⇒ 红在
`left: ["\"v1\"","\"v1\"","\"v1\"","\"v1\""]`。
三条 p3:
* p3-1 `Last-Modified` 支路端到端补上了:夹具新增 `last_modified` 字段 / `with_last_modified()`
/ 发出该响应头,`validator_ok` 扩展成「ETag 或 HTTP-date 比得上」(RFC 7233 的弱校验器兜底)。
新用例 `chunked/last_modified.rs`:服务器**只给** Last-Modified,重试必须带这个日期发
`If-Range`、只补 1 段(41984 = 1.025×)。变异(`record` 不写 last_modified)⇒ 红在
「只该在重试那一次发条件请求:[]」(变成 NoValidator 干净重下)。用例同时写明它抓不住真实
服务器的 HTTP-date 比较语义。
* p3-2 `ChunkJournal::save` 的同步原子写:判断「可以接受」并给了量级 —— 一个作业 ≤17 次
(16 段 + 建账)、每次 1–3 KiB,SSD ≈ ≤30 ms / 网络盘 ≈ ≤1 s,摊在分钟到小时的作业上占比
1e-4 量级;且顺序有意义(record-then-trust),挪 `spawn_blocking` 要为几毫秒引入跨 await
加锁的顺序风险;与单连接路径同口径。退路写在文档里。
* p3-3 `integrity=None` 时末尾摘要空转的一致性边界写进 `chunk_resume` 模块头「已知代价」+
`verify_part` 文档:只剩旧段自证与 `If-Range` 两道,「同长度换内容」不可察觉;现实入口是
上游没给合法 sha1 的 `ModFile`(`addon/online/download.rs` 只在 sha1 合法时才设 integrity),
以及 `Library` 元数据 / `download.bin` 缓存等无摘要调用点;要收紧属于产品决策。
字节判据复测(三条探针形态):
healthy bytes=41984 size=40960 ratio=1.025 hits=5 resumed_from=30720
nofeature bytes=72704 size=40960 ratio=1.775 hits=8 resumed_from=0
staleval bytes=41984 size=40960 ratio=1.025 hits=5 resumed_from=30720 if_range=["\"v2\""]
即 stale-validator 那条从 2.775× 回到 1.025×,healthy 档未变差。
门禁:lib 903 / engine_mock 17 / chunked 22 / resume 12 / partfile 7 / cache 6 全绿;
clippy -D warnings 0、fmt --check 0。改动的单文件最大 391 行(chunked_test.rs),无超 400。
This commit is contained in:
1 parent
ffca0f4454
commit
1004af81ca
7 files changed
+288
-33
No files matched your search
@@ -29,14 +29,20 @@
|
||||
//! 写的这一侧是本文末的 [`ChunkJournal`]:每成功回收一段就落一次盘(原子写),失败时元数据
|
||||
//! 里已经有「哪几段、各自摘要是什么」,于是下一次尝试能接着下而不是从 0。
|
||||
//!
|
||||
//! # 校验器跟着**源**走(复审 r1 p2-1)
|
||||
//! # 校验器跟着**源**与**表示**走(复审 r1 + r2 的 p2-1)
|
||||
//!
|
||||
//! 校验器(ETag / Last-Modified)是「某个 URL 上那个表示」的属性,不是这个文件的属性:镜像 /
|
||||
//! CDN / 对象存储各自生成,跨源几乎必然不同。把它当成文件的属性(例如换源后原样沿用)会让
|
||||
//! 身份门的「有校验器」被一个不相干的值满足,于是拿 A 的 ETag 去问 B —— 按 RFC 7233 服务器回
|
||||
//! 200 + 完整正文,分块路径拿不到 Range,整份丢掉退化单连接从头下(本 mission 要消灭的形态)。
|
||||
//! 所以沿用旧校验器的唯一条件是 `meta.url == 当前源 URL`,判定在口径上只有一处
|
||||
//! (`transfer_chunk` 的 `same_source`)。
|
||||
//! 校验器(ETag / Last-Modified)是「某个 URL 上**某一份表示**」的属性,不是这个文件的属性。
|
||||
//! 它有两个独立的失效维度,缺一不可:
|
||||
//!
|
||||
//! * **源**:镜像 / CDN / 对象存储各自生成,跨源几乎必然不同。把 A 的校验器带进源 B 的元数据,
|
||||
//! 身份门的「有校验器」就被一个不相干的值满足,于是拿 A 的 ETag 去问 B;
|
||||
//! * **表示**:同一个 URL 也可能换了一份表示(重新发布、CDN 回源到新对象)。`done` 为空表示这
|
||||
//! 一轮没有任何字节要接着用,此时上一份元数据里的校验器描述的不是这次要下的东西。
|
||||
//!
|
||||
//! 两条路的终点相同:按 RFC 7233 §3.2,服务器对不上的 `If-Range` 会回 200 + 完整正文,分块路径
|
||||
//! 拿不到 Range ⇒ `RangeUnsupported` ⇒ 整份丢掉退化单连接从头下(本 mission 要消灭的形态;r2 实测
|
||||
//! 同源换表示那条是 2.775×,比不续传的 1.775× 还费)。所以沿用旧校验器的条件只有一处判定
|
||||
//! (`transfer_chunk` 的 `carry` = 「同源 **且** `done` 非空」),写元数据与发 `If-Range` 都走它。
|
||||
//!
|
||||
//! # 已知代价(复审 r1 p3-3)
|
||||
//!
|
||||
@@ -46,6 +52,15 @@
|
||||
//! 「进度从复用字节开始」之前,所以界面会看起来停在原地(未做专门进度事件,见 mission 报告
|
||||
//! 的跟进项)。要省掉这次读只有一条路:相信元数据里的摘要而不复核——那等于把「旧字节可信」
|
||||
//! 当前提,与上面的自证要求直接冲突,**明确不做**。
|
||||
//! * **请求没带期望摘要时,末尾那道整文件摘要空转**(`verify_part` 在 `integrity: None` 时直接
|
||||
//! `Ok`)。此时「续传后重新校验」只剩两道:旧段自证(只能证明「这些字节是我们写下去的」)与
|
||||
//! `If-Range`(只能证明「服务器那份表示没变」,而服务器忽略该头时就失效)。边界很清楚:
|
||||
//! **长度相同、内容换了一份**的文件在这种配置下无法察觉(末尾没有摘要可比)。这不是本 mission
|
||||
//! 引入的(单连接路径一直如此,`transfer.rs` 零改动),但分块路径继承了它。现实入口是
|
||||
//! `ModFile` 上游没给合法 sha1 的时候(`addon/online/download.rs` 只在 sha1 合法时才设
|
||||
//! `integrity`,而 mod / 整合包正会走分块);`Library` 元数据、`download.bin` 缓存也有无摘要
|
||||
//! 调用点。要收紧就得要求「分块路径必须带 integrity」,那会让这类下载退回单连接或直接失败——
|
||||
//! 属于产品决策,不在这里单方面改。
|
||||
//! * **超时后的读盘线程不可取消**:[`verify_records`] 跑在 `spawn_blocking` 上,调用方到达
|
||||
//! `verify_timeout` 后会丢弃中间文件并重写同一路径,而那个线程可能还在读、还持着句柄
|
||||
//! (与 `transfer_chunk::verify_part` 里记的那条同族)。假设未验:Windows 上可能让随后同名
|
||||
@@ -236,9 +251,9 @@ impl ChunkJournal {
|
||||
/// 校验器**只在还没有值时写入**,这是刻意的(复审 r1 问过):同一个 URL 的续传里如果服务器
|
||||
/// 换了 ETag,保留旧值反而是保守选择——下一次带着旧 ETag 发 `If-Range` 会被服务器判「变了」
|
||||
/// 而回 200,于是干净重下;若改成覆盖成新值,下一次会把「旧字节 + 新表示」当成同一份续下去,
|
||||
/// 只能等末尾整文件摘要才发现(那已经是白下)。跨源的情况根本不走到这里:换源时
|
||||
/// `transfer_chunk` 的 `same_source` 过滤已经让 `etag` 从 `None` 起步,这里填的是**本源的**
|
||||
/// 校验器。
|
||||
/// 只能等末尾整文件摘要才发现(那已经是白下)。跨源、跨表示的情况根本不走到这里:
|
||||
/// `transfer_chunk` 的 `carry`(同源 **且** `done` 非空)已经让 `etag` 从 `None` 起步,
|
||||
/// 这里填的只可能是**本源、本轮响应**带来的校验器。
|
||||
pub fn record(&mut self, fetched: ChunkFetch) {
|
||||
if self.meta.etag.is_none() {
|
||||
self.meta.etag = fetched.etag;
|
||||
@@ -258,6 +273,21 @@ impl ChunkJournal {
|
||||
}
|
||||
|
||||
/// 落盘(元数据写失败不影响下载本身:续传只是加速,不是正确性前提)。
|
||||
///
|
||||
/// **同步写**(`write_atomic`:建 tmp → 写 → `sync_all` → rename)跑在 async 任务里。判断是
|
||||
/// 「可以接受」,量级与理由(复审 r2 p3-2):
|
||||
/// * 一个作业最多 `MAX_CONNECTIONS`(16) 段 + 建账那次 = **≤17 次**,每次 1–3 KiB(一条记录
|
||||
/// 约 80 字节)。本地 SSD 每次约 1–2 ms ⇒ 整作业 ≤ ~30 ms;网络盘上一次 rename 可能几十
|
||||
/// ms ⇒ ≤ ~1 s,摊在「几分钟到几小时」的作业上(落完一段本来就要几十秒到几分钟),占比
|
||||
/// 1e-4 量级;
|
||||
/// * 顺序有意义:必须**先**把「这一段已落盘 + 摘要」写到盘上,才谈得上「记账可信」
|
||||
/// (record-then-trust)。挪进 `spawn_blocking` 就得让 journal 跨 await 被拥有或加锁,
|
||||
/// 为几毫秒的 executor 占用引入一处顺序风险,不划算;
|
||||
/// * 与单连接路径同口径:那边也是在 async 里同步 `meta.save`(`transfer.rs`)。只改分块侧会
|
||||
/// 让两条路径的行为分叉。
|
||||
///
|
||||
/// 退路:真出现 executor 饥饿(并发下载很多 + 网络盘)时,把 `record()` 整体挪进
|
||||
/// `spawn_blocking`、journal 变成 `Arc<Mutex<_>>` 是可行方向,那时再收敛。
|
||||
pub fn save(&mut self) {
|
||||
self.meta.saved_at_ms = now_ms();
|
||||
let _ = self.meta.save(&self.path);
|
||||
|
||||
@@ -90,28 +90,29 @@ impl DownloadEngine {
|
||||
}
|
||||
ChunkDecision::Restart { .. } => part.discard(),
|
||||
}
|
||||
// 校验器是**源**的属性(镜像/CDN/对象存储各自生成 ETag),不是这个文件的属性:只有
|
||||
// 「记下它的那个 URL 就是现在这个源」时才能沿用,否则当「没有校验器」。
|
||||
// 校验器既是**源**的属性(镜像/CDN/对象存储各自生成 ETag),也是**一份表示**的属性——
|
||||
// 两件事缺一不可,所以「谁能当校验器」只有 `carry` 这一个判定点:
|
||||
//
|
||||
// 为什么必须挡(复审 r1 p2-1):换源时 `decide` 判 `UrlChanged`、删盘重来,但 `previous`
|
||||
// 还在内存里。把 A 的校验器写进 `url=B` 的新元数据之后,身份门的 `NoValidator` 会被一个
|
||||
// 不相干的校验器满足,于是拿 **A 的 ETag** 去对 B 发 `If-Range`:按 RFC 7233 §3.2,
|
||||
// 服务器对不上就回 200 + 完整正文 ⇒ `fetch_chunk` 判 `RangeUnsupported` ⇒ 丢掉整份 ⇒
|
||||
// 单连接从头下——正是本 mission 要消灭的「从 0 重下」,只是换了条路径进来。
|
||||
// * **源**(复审 r1 p2-1):`previous.url` 不是当前源就当没有。把 A 的校验器写进 `url=B`
|
||||
// 的新元数据之后,身份门的 `NoValidator` 会被一个不相干的值满足,于是拿 **A 的 ETag**
|
||||
// 去对 B 发 `If-Range`:按 RFC 7233 §3.2,服务器对不上就回 200 + 完整正文 ⇒
|
||||
// `fetch_chunk` 判 `RangeUnsupported` ⇒ 丢掉整份 ⇒ 单连接从头下。
|
||||
// * **表示**(复审 r2 p2-1):`done` 为空表示这一轮没有任何字节要接着用(`Restart`,或
|
||||
// 第一次跑),那上一份元数据里的校验器描述的是**另一份**表示。留着它这一轮看不出问题
|
||||
// (`if_range` 本来就不发),但它会被原样写进新元数据,而 `record` 只在缺值时写 ⇒ 本轮
|
||||
// 响应的真实校验器永远盖不上它 ⇒ 下一次续传拿一个已知可能不匹配的值去问服务器 ⇒ 同上
|
||||
// 那条 200/丢整份的路。实测那是 2.775×,比「干脆不续传」的 1.775× 还费一整份。
|
||||
// 清掉之后,本轮响应里的真实校验器由 `ChunkJournal::record` 填进来;最坏也只是
|
||||
// `NoValidator` ⇒ 整份重下(1.775× 那条基线),不会更坏。
|
||||
//
|
||||
// 三种修法里选「校验器与 URL 绑定」(复审的选项 A)而不是「换源时清空」(B)或「多源各记
|
||||
// 一份」(C):`ResumeMeta` 本来就有 `url` 字段,绑定语义只需一个 `filter` 就能表达,且
|
||||
// 「这个校验器属于哪个源」在读取处即可判定,不必再加一个可能与 `url` 漂移的来源字段;
|
||||
// 而 B 只是 A 在「URL 变了」这一特例上的写法,条件写在读取处比分散到写入处更难漏。
|
||||
// 为什么不选复审给的另两种修法:「换源时清空」(B)只是「源」这一维的特例写法,条件放在
|
||||
// 读取处比散到写入处更难漏;「校验器加来源字段」(C)会在 `ResumeMeta` 里多一份可与
|
||||
// `url` 漂移的真相,而当前模型是「一个中间文件 = 一个目标」,没有并存多源的形态。
|
||||
let same_source = previous.as_ref().filter(|meta| meta.url == candidate.url);
|
||||
// 续传时把**同源**的旧校验器带上:这是 `If-Range` 的取值来源,也是「远端内容没变」的
|
||||
// 协议层证据。(`done` 非空 ⇒ `decide` 已保证 `previous.url == candidate.url`,
|
||||
// 这里仍走同一个 `same_source`,让「谁能当校验器」只有一个判定点。)
|
||||
let if_range = if done.is_empty() {
|
||||
None
|
||||
} else {
|
||||
same_source.and_then(|meta| meta.validator().if_range_value().map(str::to_string))
|
||||
};
|
||||
let carry = if done.is_empty() { None } else { same_source };
|
||||
// 续传时把**同源、同一份表示**的旧校验器带上:这是 `If-Range` 的取值来源,也是「远端
|
||||
// 内容没变」的协议层证据。
|
||||
let if_range = carry.and_then(|meta| meta.validator().if_range_value().map(str::to_string));
|
||||
|
||||
part.ensure_parent_async()
|
||||
.await
|
||||
@@ -137,9 +138,10 @@ impl DownloadEngine {
|
||||
let mut journal = ChunkJournal::begin(
|
||||
ResumeMeta {
|
||||
url: candidate.url.clone(),
|
||||
// 只沿用**同一个源**的旧校验器;换了源就当没有,让下面每一段的响应自己去填。
|
||||
etag: same_source.and_then(|meta| meta.etag.clone()),
|
||||
last_modified: same_source.and_then(|meta| meta.last_modified.clone()),
|
||||
// 只沿用「同一个源 + 同一份表示」的旧校验器(见上面的 `carry`);其余情况当没有,
|
||||
// 让下面每一段的响应自己去填。
|
||||
etag: carry.and_then(|meta| meta.etag.clone()),
|
||||
last_modified: carry.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()),
|
||||
@@ -244,6 +246,12 @@ impl DownloadEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// 整文件摘要(有界、分类见下)。
|
||||
///
|
||||
/// `integrity: None` 时**直接返回 `Ok`**:这类请求没有任何端到端的摘要可比,末尾这一步
|
||||
/// 空转。两条路径都如此(单连接一直如此,本 mission 未改)。此时「续传后重新校验」只剩
|
||||
/// 旧段自证 + `If-Range` 两道,`chunk_resume` 模块头的「已知代价」写了这个一致性边界
|
||||
/// (长度相同、内容换了一份的文件不可察觉;现实入口是没有上游 sha1 的 `ModFile`)。
|
||||
pub(crate) async fn verify_part(
|
||||
&self,
|
||||
part: &mut PartFile,
|
||||
|
||||
Reference in new issue
Block a user