perf(models): 支持模型多分片并发下载
模型下载改用多个 Range 分片并发传输,提升受单连接限速时的下载吞吐。 补充分片断点恢复、阶段化组装、ETag/大小/SHA 校验及操作互斥。 包含真实 HTTP 生命周期与前端错误映射测试。
This commit is contained in:
1 parent
00b04c8a3f
commit
bad38dabe5
8 files changed
+2102
-141
No files matched your search
Generated
+12
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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 —— 峰值磁盘接近模型大小的两倍;阶段化删除在持久化边界后即可释放空间。
|
||||
- 只靠前端禁用按钮避免并发 —— 无法覆盖直接或重复调用原生命令的路径。
|
||||
Reference in new issue
Block a user