fix(cas): exists 返回 Result 以区分不存在与存储故障

CasRepository::exists(core trait)与底层 Storage::exists 原返回裸 bool,
FileSystemStorage 用 try_exists(...).unwrap_or(false)、infra 适配层吞掉解析/
初始化错误返回 false,调用方无法区分“对象不存在”和权限/IO/初始化故障。

将整条链改为 Result<bool>:
- storage trait 与 FileSystemStorage:try_exists 的 IO 错误经 ? 传播;
- repository FileSystemCasRepository::exists 及 store/add_reference 内部调用;
- core CasRepository::exists trait;
- infra 适配层:解析失败→InvalidArgument、初始化/引擎错误经 map_error 传播。
Ok(false) 仅表示确实不存在。

新增单测:合法但不存在的对象返回 Ok(false),非法 ObjectId 返回 Err(而非静默
false);同步更新各层 exists 断言。

对应 issue #18 维护清单 2-1。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 08:53:54 -07:00
co-authored by Claude Fable 5
parent c362fd80f4
commit 48ffc08cdf
6 changed files with 48 additions and 28 deletions
+5 -2
View File
@@ -153,11 +153,14 @@ pub trait CasRepository: Send + Sync {
/// # 示例 /// # 示例
/// ///
/// ```rust,ignore /// ```rust,ignore
/// if repository.exists(&object_id).await { /// if repository.exists(&object_id).await? {
/// println!("Object exists"); /// println!("Object exists");
/// } /// }
/// ``` /// ```
async fn exists(&self, id: &ObjectId) -> bool; ///
/// 返回 `Result` 以便调用方区分“对象不存在”(`Ok(false)`)与底层存储/IO 故障
/// (`Err`),而不是把故障静默当成不存在。
async fn exists(&self, id: &ObjectId) -> crate::Result<bool>;
/// 增加引用计数 /// 增加引用计数
/// ///
+7 -7
View File
@@ -37,7 +37,7 @@ impl FileSystemCasRepository {
/// 存储对象并增加引用计数。 /// 存储对象并增加引用计数。
pub async fn store(&self, data: &[u8]) -> Result<Hash> { pub async fn store(&self, data: &[u8]) -> Result<Hash> {
let hash = compute_hash(data); let hash = compute_hash(data);
let existed = self.storage.exists(&hash).await; let existed = self.storage.exists(&hash).await?;
let stored_hash = self.storage.put(data).await?; let stored_hash = self.storage.put(data).await?;
// 单条 UPSERT 原子建行并 +1:对象行不会在 store 期间以 ref_count=0 // 单条 UPSERT 原子建行并 +1:对象行不会在 store 期间以 ref_count=0
@@ -75,13 +75,13 @@ impl FileSystemCasRepository {
} }
/// 检查对象是否存在。 /// 检查对象是否存在。
pub async fn exists(&self, hash: &Hash) -> bool { pub async fn exists(&self, hash: &Hash) -> Result<bool> {
self.storage.exists(hash).await self.storage.exists(hash).await
} }
/// 增加引用计数。 /// 增加引用计数。
pub async fn add_reference(&self, hash: &Hash) -> Result<u64> { pub async fn add_reference(&self, hash: &Hash) -> Result<u64> {
if !self.storage.exists(hash).await { if !self.storage.exists(hash).await? {
return Err(CasError::ObjectNotFound(hash.to_string())); return Err(CasError::ObjectNotFound(hash.to_string()));
} }
@@ -169,8 +169,8 @@ mod tests {
repo.remove_reference(&delete).await.unwrap(); repo.remove_reference(&delete).await.unwrap();
assert_eq!(repo.gc().await.unwrap(), 1); assert_eq!(repo.gc().await.unwrap(), 1);
assert!(repo.exists(&keep).await); assert!(repo.exists(&keep).await.unwrap());
assert!(!repo.exists(&delete).await); assert!(!repo.exists(&delete).await.unwrap());
assert!(matches!( assert!(matches!(
repo.get_reference_count(&delete).await, repo.get_reference_count(&delete).await,
Err(CasError::ObjectNotFound(_)) Err(CasError::ObjectNotFound(_))
@@ -209,7 +209,7 @@ mod tests {
assert_eq!(repo.get_reference_count(&hash).await.unwrap(), 1); assert_eq!(repo.get_reference_count(&hash).await.unwrap(), 1);
assert!(repo.gc_candidates().await.unwrap().is_empty()); assert!(repo.gc_candidates().await.unwrap().is_empty());
assert_eq!(repo.gc().await.unwrap(), 0); assert_eq!(repo.gc().await.unwrap(), 0);
assert!(repo.exists(&hash).await); assert!(repo.exists(&hash).await.unwrap());
} }
#[tokio::test] #[tokio::test]
@@ -224,7 +224,7 @@ mod tests {
assert_eq!(repo.add_reference(&hash).await.unwrap(), 1); assert_eq!(repo.add_reference(&hash).await.unwrap(), 1);
// gc 的原子闸门应跳过它,对象文件保留。 // gc 的原子闸门应跳过它,对象文件保留。
assert_eq!(repo.gc().await.unwrap(), 0); assert_eq!(repo.gc().await.unwrap(), 0);
assert!(repo.exists(&hash).await); assert!(repo.exists(&hash).await.unwrap());
assert_eq!(repo.get_reference_count(&hash).await.unwrap(), 1); assert_eq!(repo.get_reference_count(&hash).await.unwrap(), 1);
} }
+9 -8
View File
@@ -20,7 +20,10 @@ pub trait Storage: Send + Sync {
async fn get(&self, hash: &Hash) -> Result<Vec<u8>>; async fn get(&self, hash: &Hash) -> Result<Vec<u8>>;
/// 检查对象文件是否存在。 /// 检查对象文件是否存在。
async fn exists(&self, hash: &Hash) -> bool; ///
/// 返回 `Result` 以便区分“确实不存在”(`Ok(false)`)与底层 IO 故障(`Err`),
/// 避免把权限或其它 IO 错误静默当成不存在。
async fn exists(&self, hash: &Hash) -> Result<bool>;
/// 删除对象文件。 /// 删除对象文件。
async fn delete(&self, hash: &Hash) -> Result<()>; async fn delete(&self, hash: &Hash) -> Result<()>;
@@ -115,7 +118,7 @@ impl FileSystemStorage {
} }
async fn validate_existing_object(&self, hash: &Hash) -> Result<bool> { async fn validate_existing_object(&self, hash: &Hash) -> Result<bool> {
if !self.exists(hash).await { if !self.exists(hash).await? {
return Ok(false); return Ok(false);
} }
@@ -249,10 +252,8 @@ impl Storage for FileSystemStorage {
Ok(data) Ok(data)
} }
async fn exists(&self, hash: &Hash) -> bool { async fn exists(&self, hash: &Hash) -> Result<bool> {
tokio::fs::try_exists(self.object_path(hash)) Ok(tokio::fs::try_exists(self.object_path(hash)).await?)
.await
.unwrap_or(false)
} }
async fn delete(&self, hash: &Hash) -> Result<()> { async fn delete(&self, hash: &Hash) -> Result<()> {
@@ -347,10 +348,10 @@ mod tests {
let hash = storage.put(b"Test data").await.unwrap(); let hash = storage.put(b"Test data").await.unwrap();
assert!(storage.exists(&hash).await); assert!(storage.exists(&hash).await.unwrap());
let deleted = storage.delete(&hash).await; let deleted = storage.delete(&hash).await;
deleted.unwrap(); deleted.unwrap();
assert!(!storage.exists(&hash).await); assert!(!storage.exists(&hash).await.unwrap());
} }
#[tokio::test] #[tokio::test]
+25 -9
View File
@@ -92,14 +92,13 @@ impl CasRepository for FileSystemCasRepository {
Ok(data) Ok(data)
} }
async fn exists(&self, id: &ObjectId) -> bool { async fn exists(&self, id: &ObjectId) -> bat_core::Result<bool> {
let Ok(hash) = Self::parse_object_id(id) else { let hash = Self::parse_object_id(id)?;
return false; self.engine()
}; .await?
let Ok(engine) = self.engine().await else { .exists(&hash)
return false; .await
}; .map_err(Self::map_error)
engine.exists(&hash).await
} }
async fn add_reference(&self, id: &ObjectId) -> bat_core::Result<u64> { async fn add_reference(&self, id: &ObjectId) -> bat_core::Result<u64> {
@@ -176,7 +175,7 @@ mod tests {
assert_eq!(repo.remove_reference(&id).await.unwrap(), 0); assert_eq!(repo.remove_reference(&id).await.unwrap(), 0);
assert_eq!(repo.gc().await.unwrap(), 1); assert_eq!(repo.gc().await.unwrap(), 1);
assert!(!repo.exists(&id).await); assert!(!repo.exists(&id).await.unwrap());
let missing = repo.get(&id).await; let missing = repo.get(&id).await;
assert!(matches!(missing, Err(bat_core::Error::NotFound(_)))); assert!(matches!(missing, Err(bat_core::Error::NotFound(_))));
} }
@@ -191,6 +190,23 @@ mod tests {
assert!(matches!(error, bat_core::Error::InvalidArgument(_))); assert!(matches!(error, bat_core::Error::InvalidArgument(_)));
} }
#[tokio::test]
async fn exists_distinguishes_missing_from_invalid_id() {
let temp_dir = TempDir::new().unwrap();
let repo = FileSystemCasRepository::new(temp_dir.path());
repo.init().await.unwrap();
// 合法但不存在的对象:Ok(false),而非静默错误。
let absent = repo.store(b"absent-seed").await.unwrap();
repo.remove_reference(&absent).await.unwrap();
repo.gc().await.unwrap();
assert!(!repo.exists(&absent).await.unwrap());
// 非法 ObjectId:返回 Err 而非 Ok(false),让调用方能区分故障。
let error = repo.exists(&"not-a-hash".to_string()).await.unwrap_err();
assert!(matches!(error, bat_core::Error::InvalidArgument(_)));
}
#[tokio::test] #[tokio::test]
async fn store_returns_blake3_hex_object_id() { async fn store_returns_blake3_hex_object_id() {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
+1 -1
View File
@@ -576,7 +576,7 @@ mod tests {
assert_eq!(report.imported.len(), 4); assert_eq!(report.imported.len(), 4);
assert_eq!(report.skipped, vec!["synthetic/catalog.json".to_string()]); assert_eq!(report.skipped, vec!["synthetic/catalog.json".to_string()]);
assert!(cas.exists(&report.imported[0].object_id).await); assert!(cas.exists(&report.imported[0].object_id).await.unwrap());
assert_eq!(report.imported[0].resource_type, ResourceType::AssetBundle); assert_eq!(report.imported[0].resource_type, ResourceType::AssetBundle);
assert_eq!( assert_eq!(
report.imported[0].category, report.imported[0].category,
@@ -92,7 +92,7 @@ async fn imports_synthetic_addressables_catalog_bundle_against_golden() {
.unwrap(); .unwrap();
assert_eq!(report.imported.len(), 1); assert_eq!(report.imported.len(), 1);
assert!(cas.exists(&report.imported[0].object_id).await); assert!(cas.exists(&report.imported[0].object_id).await.unwrap());
let indexed = resources.list(ResourceQuery::all()).await.unwrap(); let indexed = resources.list(ResourceQuery::all()).await.unwrap();
let actual = json!({ let actual = json!({