perf(models): 支持模型多分片并发下载

模型下载改用多个 Range 分片并发传输,提升受单连接限速时的下载吞吐。
补充分片断点恢复、阶段化组装、ETag/大小/SHA 校验及操作互斥。
包含真实 HTTP 生命周期与前端错误映射测试。
This commit is contained in:
kilikilikid authored and GitHub committed 2026-08-20 14:08:38 +08:00
1 parent 00b04c8a3f
commit bad38dabe5
8 files changed
+2102 -141

No files matched your search

+12
View File
@@ -5310,9 +5310,21 @@ dependencies = [
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
+1 -1
View File
@@ -41,7 +41,7 @@ once_cell = "1"
flate2 = "1"
tar = "0.4"
bzip2 = "0.4"
tokio = { version = "1", features = ["rt"] }
tokio = { version = "1", features = ["rt", "macros", "time"] }
silero-vad-crs = { version = "=0.4.0", features = ["auto"] }
# GGUF / ggml 本地 ASR 引擎(transcribe.cpp 的官方 Rust 绑定,MIT)。
# dynamic-backends:各计算后端编成可 dlopen 的模块(隐含 shared,libtranscribe
+1 -1
View File
@@ -307,7 +307,7 @@ pub fn get_available_models() -> Vec<ModelInfo> {
// 这套转换所用的架构标签(如 `sensevoice`)。换了会在加载时报
// "unsupported architecture (status 5)",下载/校验和都正常也没用。
// 要换源,只能转存"transcribe.cpp 兼容"的那一份(就像现在这样),而不是另找重转的。
// size_bytes 会被下载器用于完整性校验,务必与实际文件一致;sha256 目前只作记录。
// size_bytes 会在下载前与服务器报告值核对;sha256 会在入库前校验完整文件,二者都必须准确。
const PARAKEET_UNIFIED_EN_Q4: GgufWeight = GgufWeight {
repo: "cswk/sayit-asr-gguf",
File diff suppressed because it is too large. Load diff
+10
View File
@@ -122,6 +122,7 @@ pub async fn download_model(
.ok_or_else(|| error_protocol::encode("download_failed", format!("Unknown model: {}", model_id)))?;
let dest_dir = downloader::model_dir(&model_id);
let _download_lease = downloader::acquire_model_path(&dest_dir)?;
// 如果模型有 archive_url,使用 tar.bz2 下载+解压
if let Some(ref archive_url) = model.archive_url {
@@ -147,6 +148,7 @@ pub async fn download_model(
&file.name,
&file.url,
file.size_bytes,
file.sha256.as_deref(),
&dest_dir,
(i + 1) as u32,
file_count,
@@ -174,6 +176,7 @@ pub async fn download_model(
#[tauri::command]
pub fn delete_model(model_id: String) -> Result<(), String> {
let model_path = downloader::model_dir(&model_id);
let _delete_lease = downloader::acquire_model_path(&model_path)?;
if model_path.exists() {
std::fs::remove_dir_all(&model_path)
.map_err(|e| format!("Failed to delete model: {}", e))?;
@@ -288,6 +291,13 @@ pub async fn set_models_dir(
None => downloader::default_models_dir(),
};
// 迁移、切换生效路径和持久化必须作为一个整体与下载/删除互斥。
let _storage_lease = if new_dir != old_dir {
Some(downloader::acquire_model_storage()?)
} else {
None
};
// 目标与当前一致:仅确保持久化的设置与之同步,不做迁移。
if new_dir != old_dir {
let old_for_task = old_dir.clone();
@@ -118,4 +118,16 @@ describe('describeDownloadError', () => {
it('校验失败建议换源重下', () => {
expect(describeDownloadError('sha256 mismatch').action).toBe('switch_source')
})
it('重复下载只建议稍后重试,不误导用户切换下载源', () => {
const result = describeDownloadError('sayit_error:download_busy:already downloading')
expect(result.code).toBe('download_busy')
expect(result.action).toBe('retry')
})
it('远端大小与目录不一致时建议切换下载源', () => {
const result = describeDownloadError('sayit_error:download_source_mismatch:size changed')
expect(result.code).toBe('download_source_mismatch')
expect(result.action).toBe('switch_source')
})
})
+20
View File
@@ -25,6 +25,8 @@ export type FriendlyErrorCode =
| 'provider_rate_limit'
| 'provider_no_model'
| 'download_network'
| 'download_busy'
| 'download_source_mismatch'
| 'download_no_space'
| 'download_permission'
| 'download_checksum'
@@ -62,6 +64,8 @@ const FRIENDLY_ERROR_CODES = new Set<FriendlyErrorCode>([
'provider_rate_limit',
'provider_no_model',
'download_network',
'download_busy',
'download_source_mismatch',
'download_no_space',
'download_permission',
'download_checksum',
@@ -230,6 +234,22 @@ export function describeProviderError(error: unknown): FriendlyError {
export function describeDownloadError(error: unknown): FriendlyError {
const { code: stableCode, text } = decodeError(error)
if (stableCode === 'download_busy') {
return {
code: 'download_busy',
message: t('err.download.generic'),
detail: text,
action: 'retry',
}
}
if (stableCode === 'download_source_mismatch') {
return {
code: 'download_source_mismatch',
message: t('err.download.checksum'),
detail: text,
action: 'switch_source',
}
}
if (stableCode === 'download_network' || isTimeout(text) || isNetworkFailure(text)) {
return {
code: 'download_network',
+27
View File
@@ -287,3 +287,30 @@ manifest 一致"、不证明"这个包是我们发的" —— 也就是说这个
**0.1.7 及更早的包测不了这条路。** 它们解析更新地址时不读数据库,只认编译期写死的官方地址,
所以改设置对它们无效 —— 要在那些版本上验证升级,只能从 DNS 层把那个名字指到别处。
具体做法在内部运维笔记里,不在这里。
---
## 2026-08 模型并发下载:独立分片恢复,再阶段化组装
**状态**:已实现
**背景**:模型文件要用多个 HTTP Range 并发下载,绕开 CDN 的单连接限速。最初方案让多个任务
写同一个预分配 `.par.part`,每次调用先重新截断文件;进程中断后无法区分哪些区间已经完整
落盘,也无法安全复用进度。
**为什么这样**:每段改用独立的 append-only chunk,JSON 状态同时绑定源 URL、总大小、
SHA-256、强 ETag、分片计划和下载/组装阶段。网络中断保留已完成字节;Range 协议不兼容、
远端表示变化或最终校验失败才清理并回落单流。组装按 chunk 依次复制,先
`flush + sync_data`,再持久化边界,最后删除已组装 chunk;重启时把输出回退到最后一个已记录
边界,因此不会把未确认字节当成完成。当前 catalog 的所有模型仍在最终 rename 前校验大小和
SHA-256。
下载、删除与模型目录迁移也必须在原生命令层互斥:前端按钮置灰不能阻止重复 Tauri command。
单个模型操作持有路径 lease 到校验、rename 和元数据写完;目录迁移持有全局 storage lease 到
路径切换和设置落库完成。
**被否掉的方案**:
- 共享预分配文件 —— 崩溃后稀疏空洞与有效的零字节不可区分,重试只能全部重下。
- 每次失败都立刻改走单流 —— 普通网络抖动会白白丢掉并发进度,还可能短时占用两份空间。
- 等完整组装成功后再统一删除全部 chunk —— 峰值磁盘接近模型大小的两倍;阶段化删除在持久化边界后即可释放空间。
- 只靠前端禁用按钮避免并发 —— 无法覆盖直接或重复调用原生命令的路径。