跳转到内容
AsterDrive Developer Docs开发者

上传完成契约矩阵

本文档记录 AsterDrive 当前上传链路的完成契约。它是 issue #369 的开发者向基线,不改变公开 API,也不声明已经完成统一重构。

上传路径分成两组:

  • 普通 HTTP multipart 上传:入口在 storage::multipart,直接在一次请求内落到正式文件。
  • upload session 型上传:入口在 upload::{init, chunk, complete},先持久化 upload_session,complete 阶段再把临时对象或 staging 文件收口成正式文件。

最终落文件时必须保持三个不变量:

  • 正式文件、blob/version、配额和 upload session 状态不能互相脱节。
  • 实际计费大小必须来自当前路径能信任的最终字节来源,而不是只信任客户端声明。
  • 已写入但未完成 DB 收口的对象要有明确 cleanup 或孤儿回收归属。
锚点当前职责备注
storage::store_from_temp_with_hints从服务端临时文件创建或覆盖文件;可走本地 dedup 或 non-dedup preuploaded blob普通 multipart server path、local direct 会落到这里
storage::store_preuploaded_nondedup从已经写入 driver 的 non-dedup blob 创建或覆盖文件streaming direct 会落到这里
storage_core::finalize_upload_session_blob_with_actor_username在一个 DB 边界里创建文件、更新配额、把 session 标记 completedlocal chunked、stream relay chunked 直接使用
storage_core::finalize_upload_session_file为 opaque object 找到或创建 blob,再调用 session finalize,并发布 storage change eventpresigned single、presigned object multipart、relay object multipart、provider direct resumable 使用
upload::shared::run_upload_completion_stagecomplete 前把 session 从 expected status 切到 assembling;失败后按错误类型恢复或标 failed所有 upload session complete 路径共享

src/services/files/upload/complete/contract.rs 定义 upload-session complete 阶段本地使用的 VerifiedUploadedBlob。所有 session 型 complete 路径在进入 DB finalization 前,必须先把当前 transport 已经验证过的最终对象表达成这个类型。

该类型显式携带:

  • size:已验证的逻辑计费字节数。
  • policy_id:最终 blob 所属的 storage policy。
  • storage_path:已经写入或已经 complete 的对象路径。
  • source:content-addressed dedup、opaque object 或 preuploaded non-dedup blob。
  • cleanup:DB finalize 失败后要删除对象、清理 preuploaded blob、保留给 orphan GC,还是保留已完成 multipart object。

当前 VerifiedUploadedBlob 覆盖 presigned single、presigned object multipart、relay object multipart、provider direct resumable、local chunked 和 stream relay chunked。

src/services/workspace/storage/store/contract.rs 定义非 session 型 store_from_temp 路径使用的 VerifiedTempStoreBlob,覆盖普通 multipart/server path 和 local direct 最终进入 store_from_temp_with_hints 的落账契约。它把 content-addressed dedup、preuploaded non-dedup、staged dedup rollback、preuploaded cleanup 这些以前散在 persist.rs 里的约定集中起来。

storage::store_preuploaded_nondedup 使用本地 VerifiedPreuploadedNondedupStoreBlob 覆盖 streaming direct 的最终落账契约,校验 verified size、policy、storage path 和 prepared blob 一致后再进入 DB finalization。

新建的 server-managed chunked session 不再为每个 chunk 保存一份 payload,也不会在 Complete 阶段重新拼写一份完整文件。Init、Chunk PUT 和 Complete 共享以下目录契约:

<upload_temp_dir>/<upload_id>/
├── .offset-staging-v1 # 唯一内容载体,Init 时预分配到 total_size
├── .chunk_0.lock # 同一 chunk 的跨任务/进程排他锁
└── .chunk_1.lock # 其他 lock 文件按需创建

offset-staging 的本地 receipt 存在 upload_session_parts

part_number = chunk_number + 1
etag = aster-drive-offset-staging-receipt-v1
size = expected_chunk_size

upload_sessions.session_kind 是所有可操作 session 的权威数据面字段。它必须是合法的非空值;Complete、Chunk PUT、Progress 和 lifecycle 都直接校验显式 kind,不再根据临时文件、policy transport 或 assembled 推断路径。

Init 根据 connector-owned PolicyUploadTransport 持久化执行计划,不根据 DriverType 猜路径。当前值包括:

session_kind数据面完成计划
offset_staging本地 .offset-staging-v1 + DB receipt本地 staging finalize
stream_stagingstaging file + connector stream relaystream relay finalize
provider_relay_multipart / remote_relay_multipartprovider multipart parts + DB ETagrelay multipart complete
provider_presigned_single / remote_presigned_singleprovider temp objectpresigned single complete
provider_presigned_multipart / remote_presigned_multipartprovider multipart partspresigned multipart complete
provider_direct_resumableprovider upload session(浏览器直传 range)provider resumable complete

从 0.5.0 起 session_kindNOT NULL。升级迁移遇到 null 或非法 kind 会直接失败并保留原行;部署方需要先清理这些过期 session。显式 kind 与 multipart 字段组合不一致时,接口返回 upload.session_corrupted,不会降级到另一条数据面。

同一 chunk 先取得 .chunk_N.lock,不同 chunk 使用不同锁,因此可以并行写各自 offset。取得锁后按下面的顺序提交:

  1. .offset-staging-v1chunk_number * chunk_size 位置完整写入 payload。
  2. 对 staging file 执行 sync_data,先保证内容持久化。
  3. 开启只包含数据库 SQL 的短 writer transaction。
  4. upload_session_parts insert-only 登记本地 chunk receipt。这里复用 (upload_id, part_number) 唯一键;本地 receipt 使用保留的 offset-staging 标识作为 etag,object multipart 仍保存 provider ETag。
  5. 只有 receipt 首次插入时才增加 upload_sessions.received_count,然后提交 transaction。

收到重复 Chunk PUT 时仍会完整校验 payload 大小。若 receipt 已存在,服务端会 drain/忽略请求 body,校验 receipt 后直接返回当前进度,不覆盖已提交 range,也不重复计数。

中断位置可见状态重试行为
staging range 写入完成前receipt 缺失,range 可能部分写入在同一 offset 完整覆盖,不计数
staging sync_data 后、DB transaction 前durable range 存在,receipt 缺失重新完整覆盖,然后登记 receipt
DB receipt transaction 提交后客户端未收到响应receipt 和内容都存在重试校验请求大小,返回当前进度,不重写、不重复计数
receipt row 缺失但 range 仍完整receipt 缺失,received_count 可能滞后重试完整覆盖并补登记,只计一次
receipt row 损坏receipt 存在但 sentinel/size 不匹配Chunk PUT 和 Complete 明确报损坏,不静默覆盖
staging file 被截断receipt 可能完整但内容载体长度错误Complete 拒绝并保留失败状态,避免把短文件当成完整上传

Complete 必须同时校验:

  • upload_session_parts 中恰好有 total_chunks 条本地 receipt,part 序号连续,sentinel 和 size 与每个 chunk 一致;
  • .offset-staging-v1 是普通文件;
  • staging file 长度等于 session.total_size

Local completion 直接消费这份 staging file:开启 content_dedup 时会先流式计算 SHA-256,再按 content-addressed key promote;关闭 dedup 时把同一 staging file 写入预分配的独立 Blob。两种情况都不会再完整写一份 assembled 文件。需要 generic stream upload 的 connector 从 staging file 串流到目标 driver。S3-compatible、Azure Blob、Tencent COS 等已经协商到 provider relay multipart 的 session 不走这条本地 staging 路径。

0.5.0 不读取或迁移 0.4.x 的 payload-per-chunk session,也不创建或复用 assembled。升级迁移会在发现 null/非法 session_kind 时停止,旧 session 的清理责任属于部署方。

OneDrive(Microsoft Graph)这类 provider 自己提供 upload session:浏览器拿到 provider 签发的 upload URL 后按 range 直传,字节不经过 AsterDrive。这条路径和 presigned 的区别在于 provider session 是有状态、可查询进度的,和 relay multipart 的区别在于 AsterDrive 完全不 relay 数据面。

  • 只有 connector 声明 ProviderResumable(FrontendDirect) transport,且 driver.extensions().provider_resumable 存在、frontend_direct_upload = true 时才进入本路径;否则回退其他 transport 或直接报错,不静默降级。
  • chunk_size 采用 provider 的 default_fragment_size,并校验 min/max/alignment;total_chunks 按它计算。前端必须按 next_expected_ranges 顺序上传,不能并发乱序写 range。
  • Init 调用 create_frontend_upload_session(object_temp_key) 创建 provider session。object_temp_key 由 storage policy 对应 connector descriptor 的 object_naming 能力生成:opaque_uuid 使用 files/{upload_id}original_filename 使用 files/{upload_id}/{normalized_filename}。OneDrive 属于后者,因此 Graph item 保留原始文件名;命名规则统一由 descriptor 能力提供,上传 service 仅消费解析结果。upload URL 本身等价于写凭据,因此加密存入 upload_sessions.provider_session_ciphertext:密钥用 auth.storage_credential_secret_key,AAD 为 upload_session:{upload_id}:provider_resumable
  • expires_at 取 provider session 过期时间和默认 24 小时的较小值。
  • DB 持久化失败(包括 upload_id 冲突重试)时必须调用 abort_frontend_upload_session 回收 provider session,不允许泄露悬挂 session。

浏览器直接向 upload URL PUTContent-Range 的 fragment,不带 AsterDrive 凭据。range 冲突(416)和瞬时失败由前端按 next_expected_ranges 重试。Provider 在最后一个 range 接收后隐式完成 session(implicit_completion),对象直接落在 object_temp_key,没有独立的 complete 请求发给 provider。

Progress 不解本地 receipt,而是解密 provider_session_ciphertext 后调用 query_frontend_upload_session,把 provider 返回的 next_expected_ranges 换算成已完成 chunk 列表。provider session 过期后被 query 会返回 NotFound:此时用 object_temp_key 的存在性区分“尚未提交”与“已隐式完成”——对象已存在则按全部 chunk 完成返回,否则把错误透传给客户端重建 session。

  • Complete 读取 object_temp_key 的对象 metadata,实际 size 必须等于 session.total_size(最后一个 range 可能未提交时对象不存在,报“final range may not have committed”)。
  • verified blob 使用 VerifiedUploadedBlob::precommitted_provider_object:source 为 opaque object,cleanup 为 DeleteStorageObjectOnDbFailure,之后走 finalize_verified_opaque_upload_session -> finalize_upload_session_file,quota 在 DB 事务内原子落账。
  • 取消或过期清理解密 ciphertext 后调用 provider abort(driver 声明 abort_supported 时)。abort 失败按错误分类处理:provider 侧 session 已不存在视为清理完成;其他错误只记日志并把 session 保留为 deferred(等待重试或人工干预),不当场丢弃本地记录。
上传模式 / transport初始状态和写入位置trusted size sourcequota precheck / atomic chargefinalize functioncleanup / idempotency
regular multipart/server path不创建 upload session;upload_with_hints 读取 actix_multipart::Multipart 到 runtime temp file服务端读取 multipart body 时累计的 size;如有 declared_size,必须和累计值相等policy resolved by actual size;preuploaded non-dedup blob 会在对象写入前 precheck;DB 事务内再次 check_quota,再 update_storage_usedstore_from_temp_with_hints -> store::from_temp / persist_temp_store / write_file_record_from_temp请求临时文件在 store_from_temp_with_hints 返回后删除;preuploaded 对象在 DB 失败时 cleanup;dedup staged 对象只有在确认没有 blob row 引用时回滚,否则交给 orphan GC
local direct不创建 upload session;local policy 且有 declared_size 时直接写入 local staging path写入 local staging file 时累计的 size,必须等于 declared_size;dedup 时同流计算 hash使用已解析 local policy;和 server path 一样通过 store_from_temp_with_hints 做 precheck / 事务内 atomic chargeupload_local_direct -> store_from_temp_with_hints写入、大小不匹配、空文件或 store 结束后删除 staging file;重复请求不会通过 session 幂等,只按普通创建语义处理
streaming direct不创建 upload session;relay request body 到 driver 的 prepared non-dedup blobdriver metadata(storage_path).size,必须等于 declared_size,并再次检查 policy max file sizerelay 前先用 declared_size 做 quota precheck;metadata 复验后再用 actual_size precheck;DB 事务内再次 check_quotaupdate_storage_usedupload_streaming_direct -> store_preuploaded_nondedupstorage upload、relay、metadata、size validation、quota validation 或 DB finalize 失败时 cleanup prepared blob;成功后按正式 blob 管理
local chunked / offset stagingsession status uploading;Init 预创建 .offset-staging-v1,Chunk PUT 按 offset 写 range 并登记 DB receipt每块必须等于 expected_chunk_size_for_upload;Complete 校验全部 receipt 和 staging file 的 session.total_size;dedup 时从 staging 流式计算 SHA-256chunk receipt 与 received_count 在只含 SQL 的短 writer transaction 内幂等登记;最终 quota 仍由 finalize_upload_session_blob_with_actor_username 原子落账complete_chunked_upload_with_actor_username -> finalize_chunked_upload_session -> load_offset_staging_file -> stage_chunked_temp_file -> persist_chunked_uploadstaging range 先 sync_data;receipt 是唯一 completion index,唯一键避免重试重复计数;Complete 成功后删除 upload temp dir
presigned singlesession status presigned;客户端 PUT 到 object_temp_keycomplete 前读取 temp object metadata;copy 到 final key 后再次读取 final object metadata;两者都必须等于 session.total_sizecomplete 阶段没有独立 quota precheck;finalize_upload_session_file 在 DB 事务内创建 blob/file、atomic charge、标 completedcomplete_presigned_upload -> copy_presigned_object_to_final_key -> finalize_verified_opaque_upload_session -> finalize_upload_session_filetemp object 缺失或大小不匹配会失败,大小不匹配会尝试删除 temp object;DB finalize 失败后删除 copied final object;成功后 best-effort 删除 temp object;completed retry 通过 find_file_by_session 返回已有文件
presigned object multipartsession status presigned;客户端直传 object multipart parts,complete 时客户端回传 partsprovider list_uploaded_part_details 的 part size 求和,必须等于 session.total_size;multipart complete 后再读 object metadatamultipart complete 前先用 part size total check_quotafinalize_upload_session_file 在 DB 事务内 atomic charge、标 completedcomplete_presigned_multipart -> complete_object_multipart_upload_session -> finalize_verified_opaque_upload_session -> finalize_upload_session_filecompleted parts 和 provider uploaded parts 必须连续且数量匹配;preflight size/parts/quota 失败会 abort multipart;complete 出现 retryable storage error 且 object 已存在时继续 finalize;multipart object 一旦 complete,VerifiedUploadedBlob.cleanup = RetainCompletedMultipartObject,因此 finalize_upload_session_file/DB finalize 失败后不删除已完成对象,留给后续重试或 orphan cleanup;completed retry 返回已有文件
relay object multipartsession status uploading;每个 chunk 由服务端 relay 到 object multipart,并把 part metadata 写入 upload_session_partschunk 阶段按 expected_chunk_size_for_upload 验每个 payload;complete 阶段读取服务端 parts 清单,再用 provider part details 求和,必须等于 session.total_sizechunk 阶段不 charge;complete multipart 前用 verified part total precheck;finalize_upload_session_file 在 DB 事务内 atomic charge、标 completedcomplete_relay_multipart -> complete_object_multipart_upload_session -> finalize_verified_opaque_upload_session -> finalize_upload_session_filepart claim 防止同一 part 并发重复上传;upload 或 DB 写 part metadata 失败会 release claim;complete preflight 失败会 abort multipart;multipart object 一旦 complete,VerifiedUploadedBlob.cleanup = RetainCompletedMultipartObject,因此 finalize_upload_session_file/DB finalize 失败后不删除已完成对象,留给后续重试或 orphan cleanup;completed retry 返回已有文件
provider direct resumablesession status uploading;Init 创建 provider upload session,upload URL 加密存 provider_session_ciphertext;浏览器按 next_expected_ranges 顺序直传 range,不经过 AsterDriveprovider 隐式完成后读 object_temp_key metadata,必须等于 session.total_sizecomplete 阶段没有独立 quota precheck;finalize_upload_session_file 在 DB 事务内创建 blob/file、atomic charge、标 completedcomplete_provider_resumable_upload -> finalize_verified_opaque_upload_session -> finalize_upload_session_fileInit 持久化失败 abort provider session;取消/过期解密 ciphertext 后 provider abort;DB finalize 失败删除已提交对象;progress 用 provider query 恢复,session 404 时按对象存在性判断隐式完成;completed retry 返回已有文件
remote/follower upload transportsremote policy 通过 remote driver 暴露 direct、presigned、presigned multipart 或 relay multipart;session 状态和 object_temp_key / object_multipart_id 与对应 object-storage transport 相同direct relay 使用 streaming direct metadata;remote presigned single 使用 temp/final metadata;remote presigned multipart 和 remote relay multipart 使用 provider part details + final metadata与实际选择的 transport 相同;remote relay direct 走 store_preuploaded_nondedup,remote presigned / multipart 走 upload session finalizeinit_remote_upload 只选择 transport;完成阶段复用 upload_streaming_directcomplete_presigned_uploadcomplete_presigned_multipartcomplete_relay_multipartcleanup/idempotency 继承实际 transport;remote/follower 的特殊性只在 driver/protocol 层,产品层不应新增一套平行 finalize 语义

upload::complete::plan::determine_completion_plan 使用已解析的 UploadSessionKind 选择完成计划;session 状态只负责幂等、assembling、过期和失败错误:

  • completed -> ReturnCompleted,通过 find_file_by_session 幂等返回已有文件,不应再次 charge quota。
  • provider_presigned_single / remote_presigned_single -> CompletePresigned
  • provider_presigned_multipart / remote_presigned_multipart -> CompletePresignedMultipart,客户端必须提交 parts。
  • provider_relay_multipart / remote_relay_multipart -> CompleteRelayMultipart,parts 来自服务端已保存的 upload_session_parts
  • provider_direct_resumable -> CompleteProviderResumable,parts 不存在于服务端;以 provider 侧对象 metadata 为完成依据。
  • offset_staging / stream_staging -> CompleteChunked,要求 received_count == total_chunks

run_upload_completion_stage 会先把 expected status 切到 assembling。非 retryable 失败会把 session 标为 failed;retryable storage error 会尝试恢复到原状态,允许客户端重试。

本文件只是当前契约基线,后续代码迁移仍需要完成这些 acceptance criteria:

  • session complete 路径继续使用 VerifiedUploadedBlob 或同等明确的 verified finalization input;新 complete 入口必须显式声明 verified size、policy、storage path/blob source 和 DB finalize failure cleanup plan。
  • store_from_temp 路径继续使用 VerifiedTempStoreBlob 或同等明确的 verified finalization input;新 temp-store 入口必须显式声明 staged dedup/preuploaded cleanup 责任。
  • store_preuploaded_nondedup 路径继续使用 VerifiedPreuploadedNondedupStoreBlob 或同等明确的 verified finalization input;新 preuploaded store 入口必须显式校验 prepared blob 的 size/policy/storage path 一致性。
  • 每个被迁移路径都要补 quota、size mismatch 和 DB finalize failure cleanup 测试;completed retry 不重复计费 只适用于 session complete flow。
  • offset-staging 改动必须覆盖:不同 chunk 确实并行、同一 chunk 确实排他、partial range 覆盖、receipt 缺失、receipt 损坏和 staging 截断。并发测试需要 barrier/failpoint 证明任务进入了关键区,不能只用 join! 假设发生过竞争。
  • 保持 public API request/response、session status 语义和现有成功上传行为不变。