store: lock-refresh-write pattern to prevent duplicate UUIDs
All write paths (upsert_node, upsert_provenance, delete_node, rename_node, ingest_units) now hold StoreLock across the full refresh→check→write cycle. This prevents the race where two concurrent processes both see a key as "new" and create separate UUIDs for it. Adds append_nodes_unlocked() and append_relations_unlocked() for callers already holding the lock. Adds refresh_nodes() to replay log tail under lock before deciding create vs update. Also adds find_duplicates() for detecting existing duplicates in the log (replays full log, groups live nodes by key).
This commit is contained in:
parent
8bbc246b3d
commit
37ae37667b
3 changed files with 121 additions and 10 deletions
|
|
@ -8,13 +8,17 @@ use super::types::*;
|
|||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
impl Store {
|
||||
/// Add or update a node (appends to log + updates cache)
|
||||
/// Add or update a node (appends to log + updates cache).
|
||||
/// Holds StoreLock across refresh + check + write to prevent duplicate UUIDs.
|
||||
pub fn upsert_node(&mut self, mut node: Node) -> Result<(), String> {
|
||||
let _lock = StoreLock::acquire()?;
|
||||
self.refresh_nodes()?;
|
||||
|
||||
if let Some(existing) = self.nodes.get(&node.key) {
|
||||
node.uuid = existing.uuid;
|
||||
node.version = existing.version + 1;
|
||||
}
|
||||
self.append_nodes(&[node.clone()])?;
|
||||
self.append_nodes_unlocked(&[node.clone()])?;
|
||||
self.uuid_to_key.insert(node.uuid, node.key.clone());
|
||||
self.nodes.insert(node.key.clone(), node);
|
||||
Ok(())
|
||||
|
|
@ -38,7 +42,11 @@ impl Store {
|
|||
}
|
||||
|
||||
/// Upsert with explicit provenance (for agent-created nodes).
|
||||
/// Holds StoreLock across refresh + check + write to prevent duplicate UUIDs.
|
||||
pub fn upsert_provenance(&mut self, key: &str, content: &str, provenance: Provenance) -> Result<&'static str, String> {
|
||||
let _lock = StoreLock::acquire()?;
|
||||
self.refresh_nodes()?;
|
||||
|
||||
if let Some(existing) = self.nodes.get(key) {
|
||||
if existing.content == content {
|
||||
return Ok("unchanged");
|
||||
|
|
@ -47,13 +55,13 @@ impl Store {
|
|||
node.content = content.to_string();
|
||||
node.provenance = provenance;
|
||||
node.version += 1;
|
||||
self.append_nodes(std::slice::from_ref(&node))?;
|
||||
self.append_nodes_unlocked(std::slice::from_ref(&node))?;
|
||||
self.nodes.insert(key.to_string(), node);
|
||||
Ok("updated")
|
||||
} else {
|
||||
let mut node = new_node(key, content);
|
||||
node.provenance = provenance;
|
||||
self.append_nodes(std::slice::from_ref(&node))?;
|
||||
self.append_nodes_unlocked(std::slice::from_ref(&node))?;
|
||||
self.uuid_to_key.insert(node.uuid, node.key.clone());
|
||||
self.nodes.insert(key.to_string(), node);
|
||||
Ok("created")
|
||||
|
|
@ -61,13 +69,17 @@ impl Store {
|
|||
}
|
||||
|
||||
/// Soft-delete a node (appends deleted version, removes from cache).
|
||||
/// Holds StoreLock across refresh + write to see concurrent creates.
|
||||
pub fn delete_node(&mut self, key: &str) -> Result<(), String> {
|
||||
let _lock = StoreLock::acquire()?;
|
||||
self.refresh_nodes()?;
|
||||
|
||||
let node = self.nodes.get(key)
|
||||
.ok_or_else(|| format!("No node '{}'", key))?;
|
||||
let mut deleted = node.clone();
|
||||
deleted.deleted = true;
|
||||
deleted.version += 1;
|
||||
self.append_nodes(std::slice::from_ref(&deleted))?;
|
||||
self.append_nodes_unlocked(std::slice::from_ref(&deleted))?;
|
||||
self.nodes.remove(key);
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -79,10 +91,15 @@ impl Store {
|
|||
/// on relations, and created_at is preserved untouched.
|
||||
///
|
||||
/// Appends: (new_key, v+1) + (old_key, deleted, v+1) + updated relations.
|
||||
/// Holds StoreLock across refresh + write to prevent races.
|
||||
pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> Result<(), String> {
|
||||
if old_key == new_key {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let _lock = StoreLock::acquire()?;
|
||||
self.refresh_nodes()?;
|
||||
|
||||
if self.nodes.contains_key(new_key) {
|
||||
return Err(format!("Key '{}' already exists", new_key));
|
||||
}
|
||||
|
|
@ -112,10 +129,10 @@ impl Store {
|
|||
})
|
||||
.collect();
|
||||
|
||||
// Persist (each append acquires its own file lock)
|
||||
self.append_nodes(&[renamed.clone(), tombstone])?;
|
||||
// Persist under single lock
|
||||
self.append_nodes_unlocked(&[renamed.clone(), tombstone])?;
|
||||
if !updated_rels.is_empty() {
|
||||
self.append_relations(&updated_rels)?;
|
||||
self.append_relations_unlocked(&updated_rels)?;
|
||||
}
|
||||
|
||||
// Update in-memory cache
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue