Skip to content
58 changes: 44 additions & 14 deletions xet_client/src/cas_client/simulation/deletion_controls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,35 @@ use xet_core_structures::merklehash::MerkleHash;

use crate::error::Result;

/// An opaque 32-byte tag used for conditional deletion (compare-and-delete).
/// An opaque 32-byte etag used for conditional deletion (compare-and-delete), standing in
/// for S3's ETag.
///
/// Implementations should derive this from object metadata/content with enough entropy
/// to reduce false matches when objects are rapidly rewritten.
pub type ObjectTag = [u8; 32];
/// Derived from what is stored, not from when it was written: a byte-identical re-upload
/// leaves it unchanged, as S3's content-derived ETag does. A caller that needs to tell a
/// re-upload apart cannot do it from this value — see [`LAST_UPLOAD_TAG_KEY`].
pub type ObjectETag = [u8; 32];

/// S3-style `(key, value)` tag set attached to an object.
///
/// Distinct from [`ObjectETag`]: writing this leaves the object's bytes, and so its
/// [`ObjectETag`], untouched. That is what lets a caller record something about an object
/// without invalidating anything keyed on its content.
pub type ObjectTagSet = Vec<(String, String)>;

/// Tag key CAS stamps with the unix seconds of a xorb's most recent write.
///
/// Every simulated xorb upload stamps this, as CAS does. What the value
/// *means* is the reader's business — xet-core only reproduces the stamp.
pub const LAST_UPLOAD_TAG_KEY: &str = "last-upload";

/// The tag set CAS puts on a xorb it has just written.
pub fn last_upload_tag_set_now() -> ObjectTagSet {
let unix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default();
vec![(LAST_UPLOAD_TAG_KEY.to_string(), unix.to_string())]
}

/// Trait for clients that support deletion and integrity operations on shards and file entries.
///
Expand Down Expand Up @@ -41,19 +65,25 @@ pub trait DeletionControlableClient: Send + Sync {
/// Deletes a XORB by hash.
async fn delete_xorb(&self, hash: &MerkleHash);

/// Returns all XORB hashes with their associated object tags.
async fn list_xorbs_and_tags(&self) -> Result<Vec<(MerkleHash, ObjectTag)>>;
/// Returns all XORB hashes with their associated object etags.
async fn list_xorbs_and_etags(&self) -> Result<Vec<(MerkleHash, ObjectETag)>>;

/// Deletes a XORB only if its current etag matches the provided etag.
/// Returns `Ok(true)` if deleted, `Ok(false)` if the etag did not match.
async fn delete_xorb_if_etag_matches(&self, hash: &MerkleHash, etag: &ObjectETag) -> Result<bool>;

/// Returns a XORB's tag set, empty if it has none.
async fn get_xorb_tag_set(&self, hash: &MerkleHash) -> Result<ObjectTagSet>;

/// Deletes a XORB only if its current tag matches the provided tag.
/// Returns `Ok(true)` if deleted, `Ok(false)` if the tag did not match.
async fn delete_xorb_if_tag_matches(&self, hash: &MerkleHash, tag: &ObjectTag) -> Result<bool>;
/// Replaces a XORB's tag set wholesale, as S3 `PutObjectTagging` does.
async fn set_xorb_tag_set(&self, hash: &MerkleHash, tags: ObjectTagSet) -> Result<()>;

/// Returns all shard hashes with their associated object tags.
async fn list_shards_with_tags(&self) -> Result<Vec<(MerkleHash, ObjectTag)>>;
/// Returns all shard hashes with their associated object etags.
async fn list_shards_with_etags(&self) -> Result<Vec<(MerkleHash, ObjectETag)>>;

/// Deletes a shard only if its current tag matches the provided tag.
/// Returns `Ok(true)` if deleted, `Ok(false)` if the tag did not match.
async fn delete_shard_if_tag_matches(&self, hash: &MerkleHash, tag: &ObjectTag) -> Result<bool>;
/// Deletes a shard only if its current etag matches the provided etag.
/// Returns `Ok(true)` if deleted, `Ok(false)` if the etag did not match.
async fn delete_shard_if_etag_matches(&self, hash: &MerkleHash, etag: &ObjectETag) -> Result<bool>;

/// Verifies referential integrity of all shards on disk.
async fn verify_integrity(&self) -> Result<()>;
Expand Down
48 changes: 24 additions & 24 deletions xet_client/src/cas_client/simulation/deletion_unit_testing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use std::sync::Arc;
use xet_core_structures::merklehash::MerkleHash;

use super::client_testing_utils::RandomFileContents;
use super::deletion_controls::ObjectTag;
use super::deletion_controls::ObjectETag;
use super::{ClientTestingUtils, DeletionControlableClient, DirectAccessClient};

/// Runs all common DeletionControlableClient tests using a factory that creates fresh clients.
Expand Down Expand Up @@ -340,19 +340,19 @@ async fn test_verify_integrity_after_file_deletion<C: DirectAccessClient + Delet
client.verify_integrity().await.unwrap();
}

/// Tests that list_xorbs_and_tags returns entries matching list_xorbs with non-zero tags.
/// Tests that list_xorbs_and_etags returns entries matching list_xorbs with non-zero tags.
async fn test_list_xorbs_and_tags<C: DirectAccessClient + DeletionControlableClient + 'static>(client: Arc<C>) {
assert!(client.list_xorbs_and_tags().await.unwrap().is_empty());
assert!(client.list_xorbs_and_etags().await.unwrap().is_empty());

let file = client.upload_random_file(&[(1, (0, 3))], 2048).await.unwrap();
let expected_xorbs = expected_xorb_hashes(&[&file]);

let xorbs_and_tags = client.list_xorbs_and_tags().await.unwrap();
let listed_hashes: HashSet<MerkleHash> = xorbs_and_tags.iter().map(|(h, _)| *h).collect();
let xorbs_and_etags = client.list_xorbs_and_etags().await.unwrap();
let listed_hashes: HashSet<MerkleHash> = xorbs_and_etags.iter().map(|(h, _)| *h).collect();
assert_eq!(listed_hashes, expected_xorbs);

let zero_tag: ObjectTag = [0u8; 32];
for (_, tag) in &xorbs_and_tags {
let zero_tag: ObjectETag = [0u8; 32];
for (_, tag) in &xorbs_and_etags {
assert_ne!(tag, &zero_tag, "Tag should be non-zero");
}
}
Expand All @@ -362,32 +362,32 @@ async fn test_delete_xorb_if_tag_matches<C: DirectAccessClient + DeletionControl
let file = client.upload_random_file(&[(1, (0, 2))], 2048).await.unwrap();
let xorb_hash = file.terms[0].xorb_hash;

let xorbs_and_tags = client.list_xorbs_and_tags().await.unwrap();
let (_, correct_tag) = xorbs_and_tags.iter().find(|(h, _)| *h == xorb_hash).unwrap();
let xorbs_and_etags = client.list_xorbs_and_etags().await.unwrap();
let (_, correct_tag) = xorbs_and_etags.iter().find(|(h, _)| *h == xorb_hash).unwrap();

let wrong_tag: ObjectTag = [0xFFu8; 32];
let deleted = client.delete_xorb_if_tag_matches(&xorb_hash, &wrong_tag).await.unwrap();
let wrong_tag: ObjectETag = [0xFFu8; 32];
let deleted = client.delete_xorb_if_etag_matches(&xorb_hash, &wrong_tag).await.unwrap();
assert!(!deleted, "Wrong tag should not delete the xorb");
assert!(client.xorb_exists(&xorb_hash).await.unwrap(), "Xorb should still exist after wrong tag");

let deleted = client.delete_xorb_if_tag_matches(&xorb_hash, correct_tag).await.unwrap();
let deleted = client.delete_xorb_if_etag_matches(&xorb_hash, correct_tag).await.unwrap();
assert!(deleted, "Correct tag should delete the xorb");
assert!(!client.xorb_exists(&xorb_hash).await.unwrap(), "Xorb should be gone after correct tag");
}

/// Tests that list_shards_with_tags returns entries matching list_shard_entries with non-zero tags.
/// Tests that list_shards_with_etags returns entries matching list_shard_entries with non-zero tags.
async fn test_list_shards_with_tags<C: DirectAccessClient + DeletionControlableClient + 'static>(client: Arc<C>) {
assert!(client.list_shards_with_tags().await.unwrap().is_empty());
assert!(client.list_shards_with_etags().await.unwrap().is_empty());

client.upload_random_file(&[(1, (0, 3))], 2048).await.unwrap();

let shards_and_tags = client.list_shards_with_tags().await.unwrap();
let shards_and_tags = client.list_shards_with_etags().await.unwrap();
let shard_entries = client.list_shard_entries().await.unwrap();
let listed_hashes: HashSet<MerkleHash> = shards_and_tags.iter().map(|(h, _)| *h).collect();
let expected_hashes: HashSet<MerkleHash> = shard_entries.into_iter().collect();
assert_eq!(listed_hashes, expected_hashes);

let zero_tag: ObjectTag = [0u8; 32];
let zero_tag: ObjectETag = [0u8; 32];
for (_, tag) in &shards_and_tags {
assert_ne!(tag, &zero_tag, "Tag should be non-zero");
}
Expand All @@ -397,16 +397,16 @@ async fn test_list_shards_with_tags<C: DirectAccessClient + DeletionControlableC
async fn test_delete_shard_if_tag_matches<C: DirectAccessClient + DeletionControlableClient + 'static>(client: Arc<C>) {
let _file = client.upload_random_file(&[(1, (0, 2))], 2048).await.unwrap();

let shards_and_tags = client.list_shards_with_tags().await.unwrap();
let shards_and_tags = client.list_shards_with_etags().await.unwrap();
assert!(!shards_and_tags.is_empty());
let (shard_hash, correct_tag) = &shards_and_tags[0];

let wrong_tag: ObjectTag = [0xFFu8; 32];
let deleted = client.delete_shard_if_tag_matches(shard_hash, &wrong_tag).await.unwrap();
assert!(!deleted, "Wrong tag should not delete the shard");
assert!(!client.list_shard_entries().await.unwrap().is_empty(), "Shard should still exist after wrong tag");
let wrong_tag: ObjectETag = [0xFFu8; 32];
let deleted = client.delete_shard_if_etag_matches(shard_hash, &wrong_tag).await.unwrap();
assert!(!deleted, "Wrong etag should not delete the shard");
assert!(!client.list_shard_entries().await.unwrap().is_empty(), "Shard should still exist after wrong etag");

let deleted = client.delete_shard_if_tag_matches(shard_hash, correct_tag).await.unwrap();
assert!(deleted, "Correct tag should delete the shard");
assert!(client.list_shard_entries().await.unwrap().is_empty(), "Shard should be gone after correct tag");
let deleted = client.delete_shard_if_etag_matches(shard_hash, correct_tag).await.unwrap();
assert!(deleted, "Correct etag should delete the shard");
assert!(client.list_shard_entries().await.unwrap().is_empty(), "Shard should be gone after correct etag");
}
Loading
Loading