stasis-rs 0.7.0

Durable AI orchestration framework with runtime jobs, lineage, and memory integration
Documentation
use std::collections::HashMap;
use std::sync::{Arc, RwLock};

use async_trait::async_trait;
use chrono::{DateTime, Duration, Utc};

use crate::domain::errors::{Result, StasisError};
use crate::domain::runtime::cluster_node::{ClusterNode, ClusterNodeHeartbeat, NewClusterNode};
use crate::ports::outbound::runtime::cluster_node_store::ClusterNodeStore;

#[derive(Clone, Default)]
pub struct InMemoryClusterNodeStore {
    nodes: Arc<RwLock<HashMap<String, ClusterNode>>>,
}

#[async_trait]
impl ClusterNodeStore for InMemoryClusterNodeStore {
    async fn register(&self, node: NewClusterNode) -> Result<ClusterNode> {
        let record = node.into_record();
        let mut nodes = self.nodes.write().map_err(|_| {
            StasisError::PortFailure("cluster node store lock poisoned".to_string())
        })?;

        if nodes.contains_key(&record.node_id) {
            return Err(StasisError::PortFailure(format!(
                "cluster node already exists: {}",
                record.node_id
            )));
        }

        nodes.insert(record.node_id.clone(), record.clone());
        Ok(record)
    }

    async fn heartbeat(&self, heartbeat: ClusterNodeHeartbeat) -> Result<Option<ClusterNode>> {
        let mut nodes = self.nodes.write().map_err(|_| {
            StasisError::PortFailure("cluster node store lock poisoned".to_string())
        })?;

        let Some(node) = nodes.get_mut(&heartbeat.node_id) else {
            return Ok(None);
        };

        node.heartbeat_at = heartbeat.heartbeat_at;
        node.lease_expires_at =
            heartbeat.heartbeat_at + Duration::seconds(heartbeat.lease_ttl_seconds.max(1));
        if let Some(queue_ownership) = heartbeat.queue_ownership {
            node.queue_ownership = queue_ownership;
        }
        if let Some(capability_tags) = heartbeat.capability_tags {
            node.capability_tags = capability_tags;
        }
        if heartbeat.metadata.is_some() {
            node.metadata = heartbeat.metadata;
        }
        node.updated_at = heartbeat.heartbeat_at;

        Ok(Some(node.clone()))
    }

    async fn get(&self, node_id: &str) -> Result<Option<ClusterNode>> {
        let nodes = self.nodes.read().map_err(|_| {
            StasisError::PortFailure("cluster node store lock poisoned".to_string())
        })?;
        Ok(nodes.get(node_id).cloned())
    }

    async fn list(&self) -> Result<Vec<ClusterNode>> {
        let nodes = self.nodes.read().map_err(|_| {
            StasisError::PortFailure("cluster node store lock poisoned".to_string())
        })?;
        let mut out = nodes.values().cloned().collect::<Vec<_>>();
        out.sort_by(|a, b| a.node_id.cmp(&b.node_id));
        Ok(out)
    }

    async fn remove(&self, node_id: &str) -> Result<bool> {
        let mut nodes = self.nodes.write().map_err(|_| {
            StasisError::PortFailure("cluster node store lock poisoned".to_string())
        })?;
        Ok(nodes.remove(node_id).is_some())
    }

    async fn prune_expired(&self, now: DateTime<Utc>) -> Result<u64> {
        let mut nodes = self.nodes.write().map_err(|_| {
            StasisError::PortFailure("cluster node store lock poisoned".to_string())
        })?;

        let before = nodes.len();
        nodes.retain(|_, node| node.lease_expires_at >= now);
        Ok((before - nodes.len()) as u64)
    }
}