feat: implement idempotent put and delete operations by checking existing heads before committing new entries
This commit is contained in:
@@ -383,4 +383,35 @@ mod tests {
|
||||
|
||||
let _ = std::fs::remove_dir_all(data_dir.base());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_idempotent_put_and_delete() {
|
||||
let data_dir = temp_data_dir("idempotent");
|
||||
|
||||
let (node, _) = LatticeNodeBuilder { data_dir: data_dir.clone() }
|
||||
.build()
|
||||
.expect("create node");
|
||||
let store_id = node.init().expect("init");
|
||||
let (store, _) = node.open_store(store_id).expect("open store");
|
||||
|
||||
// Put twice with same value - second should be idempotent
|
||||
let seq1 = store.put(b"/key", b"value").expect("put 1");
|
||||
assert_eq!(seq1, 1);
|
||||
|
||||
let seq2 = store.put(b"/key", b"value").expect("put 2");
|
||||
assert_eq!(seq2, 1, "Second put with same value should be idempotent (no new entry)");
|
||||
|
||||
assert_eq!(store.log_seq(), 1, "Log should have 1 entry, not 2");
|
||||
|
||||
// Delete twice - second should be idempotent
|
||||
let seq3 = store.delete(b"/key").expect("delete 1");
|
||||
assert_eq!(seq3, 2);
|
||||
|
||||
let seq4 = store.delete(b"/key").expect("delete 2");
|
||||
assert_eq!(seq4, 2, "Second delete should be idempotent (no new entry)");
|
||||
|
||||
assert_eq!(store.log_seq(), 2, "Log should have 2 entries, not 3");
|
||||
|
||||
let _ = std::fs::remove_dir_all(data_dir.base());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,12 +145,24 @@ impl StoreActor {
|
||||
|
||||
fn do_put(&mut self, key: &[u8], value: &[u8]) -> Result<u64, StoreActorError> {
|
||||
let heads = self.store.get_heads(key)?;
|
||||
|
||||
// Idempotency check (pure function)
|
||||
if !Store::needs_put(&heads, value) {
|
||||
return Ok(self.sigchain.len()); // Idempotent, no new entry
|
||||
}
|
||||
|
||||
let parent_hashes: Vec<Vec<u8>> = heads.iter().map(|h| h.hash.clone()).collect();
|
||||
self.commit_entry(parent_hashes, |b| b.put(key.to_vec(), value.to_vec()))
|
||||
}
|
||||
|
||||
fn do_delete(&mut self, key: &[u8]) -> Result<u64, StoreActorError> {
|
||||
let heads = self.store.get_heads(key)?;
|
||||
|
||||
// Idempotency check (pure function)
|
||||
if !Store::needs_delete(&heads) {
|
||||
return Ok(self.sigchain.len()); // Idempotent, no new entry
|
||||
}
|
||||
|
||||
let parent_hashes: Vec<Vec<u8>> = heads.iter().map(|h| h.hash.clone()).collect();
|
||||
self.commit_entry(parent_hashes, |b| b.delete(key.to_vec()))
|
||||
}
|
||||
|
||||
@@ -237,6 +237,23 @@ impl Store {
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
/// Check if a put operation is needed given current heads
|
||||
/// Returns false if the winning head has the same value (idempotent)
|
||||
pub fn needs_put(heads: &[HeadInfo], value: &[u8]) -> bool {
|
||||
match Self::pick_winner(heads) {
|
||||
Some(winner) => winner.value != value, // Skip if winner already has value
|
||||
None => true, // No heads = need put
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a delete operation is needed given current heads
|
||||
/// Returns false if no heads or winning head is already a tombstone (idempotent)
|
||||
pub fn needs_delete(heads: &[HeadInfo]) -> bool {
|
||||
match Self::pick_winner(heads) {
|
||||
Some(winner) => !winner.tombstone, // Skip if winner is already tombstone
|
||||
None => false, // No heads = nothing to delete
|
||||
}
|
||||
}
|
||||
|
||||
/// Get author state for a specific author
|
||||
pub fn author_state(&self, author: &[u8; 32]) -> Result<Option<AuthorState>, StoreError> {
|
||||
@@ -952,4 +969,94 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&backup_path);
|
||||
let _ = std::fs::remove_file(&log_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_needs_put_empty_heads() {
|
||||
// No heads = need put
|
||||
let heads: Vec<HeadInfo> = vec![];
|
||||
assert!(Store::needs_put(&heads, b"value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_needs_put_same_value() {
|
||||
// Single head with same value = idempotent, no put needed
|
||||
let heads = vec![HeadInfo {
|
||||
value: b"hello".to_vec(),
|
||||
hlc: 1000,
|
||||
author: [1u8; 32].to_vec(),
|
||||
hash: [2u8; 32].to_vec(),
|
||||
tombstone: false,
|
||||
}];
|
||||
assert!(!Store::needs_put(&heads, b"hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_needs_put_different_value() {
|
||||
// Single head with different value = need put
|
||||
let heads = vec![HeadInfo {
|
||||
value: b"hello".to_vec(),
|
||||
hlc: 1000,
|
||||
author: [1u8; 32].to_vec(),
|
||||
hash: [2u8; 32].to_vec(),
|
||||
tombstone: false,
|
||||
}];
|
||||
assert!(Store::needs_put(&heads, b"world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_needs_put_multiple_heads_winner_matches() {
|
||||
// Multiple heads where WINNER has our value = idempotent
|
||||
// Winner is highest HLC (1001), value "v2"
|
||||
let heads = vec![
|
||||
HeadInfo {
|
||||
value: b"v1".to_vec(),
|
||||
hlc: 1000,
|
||||
author: [1u8; 32].to_vec(),
|
||||
hash: [2u8; 32].to_vec(),
|
||||
tombstone: false,
|
||||
},
|
||||
HeadInfo {
|
||||
value: b"v2".to_vec(),
|
||||
hlc: 1001, // Winner (highest HLC)
|
||||
author: [3u8; 32].to_vec(),
|
||||
hash: [4u8; 32].to_vec(),
|
||||
tombstone: false,
|
||||
},
|
||||
];
|
||||
assert!(!Store::needs_put(&heads, b"v2")); // Winner has value = skip
|
||||
assert!(Store::needs_put(&heads, b"v1")); // Winner doesn't have value = put
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_needs_delete_empty_heads() {
|
||||
// No heads = idempotent, no delete needed
|
||||
let heads: Vec<HeadInfo> = vec![];
|
||||
assert!(!Store::needs_delete(&heads));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_needs_delete_with_heads() {
|
||||
// Has non-tombstone heads = need delete
|
||||
let heads = vec![HeadInfo {
|
||||
value: b"data".to_vec(),
|
||||
hlc: 1000,
|
||||
author: [1u8; 32].to_vec(),
|
||||
hash: [2u8; 32].to_vec(),
|
||||
tombstone: false,
|
||||
}];
|
||||
assert!(Store::needs_delete(&heads));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_needs_delete_tombstone_is_winner() {
|
||||
// Winning head is already tombstone = no delete needed
|
||||
let heads = vec![HeadInfo {
|
||||
value: vec![],
|
||||
hlc: 1000,
|
||||
author: [1u8; 32].to_vec(),
|
||||
hash: [2u8; 32].to_vec(),
|
||||
tombstone: true,
|
||||
}];
|
||||
assert!(!Store::needs_delete(&heads));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user