Skip to main content

tatara_engine/cluster/
store.rs

1use anyhow::Result;
2use std::sync::Arc;
3use std::time::Duration;
4use tokio::sync::RwLock;
5use tracing::{debug, warn};
6
7use super::raft_node::RaftCluster;
8use super::raft_sm::StateMachineData;
9use tatara_core::cluster::types::{
10    ClusterCommand, ClusterResponse, JobVersionEntry, NodeId, NodeMeta,
11};
12use tatara_core::domain::allocation::{Allocation, AllocationState, TaskState};
13use tatara_core::domain::event::{Event, EventKind};
14use tatara_core::domain::job::{Job, JobStatus};
15use tatara_core::domain::release::{Release, ReleaseStatus};
16use tatara_core::domain::source::{Source, SourceStatus};
17
18/// Cluster-backed store that reads from the in-memory Raft state machine
19/// and writes through Raft consensus with full propagation tracking.
20///
21/// All state feeds into the in-memory data structure (Raft SM).
22/// API reads come from memory (eventually consistent by default).
23/// Writes are not complete until confirmed propagated across the entire cluster.
24pub struct ClusterStore {
25    raft: Arc<RaftCluster>,
26    /// Direct reference to the in-memory state for fast reads.
27    state: Arc<RwLock<StateMachineData>>,
28    /// How long to wait for full propagation before giving up.
29    propagation_timeout: Duration,
30}
31
32/// Result of a write operation including propagation status.
33#[derive(Debug)]
34pub struct WriteResult<T> {
35    pub value: T,
36    /// The Raft log index this write was committed at.
37    pub log_index: u64,
38    /// Whether the write has been confirmed propagated to ALL nodes.
39    pub fully_propagated: bool,
40    /// Number of nodes that have applied this write.
41    pub propagated_count: usize,
42    /// Total number of nodes in the cluster.
43    pub total_nodes: usize,
44}
45
46impl ClusterStore {
47    pub fn new(raft: Arc<RaftCluster>) -> Self {
48        let state = raft.read_local_sync();
49
50        Self {
51            raft,
52            state,
53            propagation_timeout: Duration::from_secs(10),
54        }
55    }
56
57    /// Set the maximum time to wait for full propagation on writes.
58    pub fn with_propagation_timeout(mut self, timeout: Duration) -> Self {
59        self.propagation_timeout = timeout;
60        self
61    }
62
63    // ── Reads (from in-memory state machine — eventually consistent) ──
64
65    pub async fn get_job(&self, id: &str) -> Option<Job> {
66        let data = self.state.read().await;
67        data.cluster_state.jobs.get(id).cloned()
68    }
69
70    pub async fn list_jobs(&self) -> Vec<Job> {
71        let data = self.state.read().await;
72        data.cluster_state.jobs.values().cloned().collect()
73    }
74
75    pub async fn get_allocation(&self, id: &uuid::Uuid) -> Option<Allocation> {
76        let data = self.state.read().await;
77        data.cluster_state.allocations.get(id).cloned()
78    }
79
80    pub async fn list_allocations(&self) -> Vec<Allocation> {
81        let data = self.state.read().await;
82        data.cluster_state.allocations.values().cloned().collect()
83    }
84
85    pub async fn list_allocations_for_job(&self, job_id: &str) -> Vec<Allocation> {
86        let data = self.state.read().await;
87        data.cluster_state
88            .allocations
89            .values()
90            .filter(|a| a.job_id == job_id)
91            .cloned()
92            .collect()
93    }
94
95    pub async fn get_node_meta(&self, id: &NodeId) -> Option<NodeMeta> {
96        let data = self.state.read().await;
97        data.cluster_state.nodes.get(id).cloned()
98    }
99
100    pub async fn list_nodes(&self) -> Vec<NodeMeta> {
101        let data = self.state.read().await;
102        data.cluster_state.nodes.values().cloned().collect()
103    }
104
105    /// Get job version history.
106    pub async fn get_job_history(&self, job_id: &str) -> Vec<JobVersionEntry> {
107        let data = self.state.read().await;
108        data.cluster_state
109            .job_history
110            .get(job_id)
111            .cloned()
112            .unwrap_or_default()
113    }
114
115    /// List events with optional filtering.
116    pub async fn list_events(
117        &self,
118        kind: Option<&EventKind>,
119        since: Option<chrono::DateTime<chrono::Utc>>,
120    ) -> Vec<Event> {
121        let data = self.state.read().await;
122        data.cluster_state
123            .events
124            .query(kind, since)
125            .into_iter()
126            .cloned()
127            .collect()
128    }
129
130    /// List all releases.
131    pub async fn list_releases(&self) -> Vec<Release> {
132        let data = self.state.read().await;
133        data.cluster_state.releases.values().cloned().collect()
134    }
135
136    /// Get a specific release.
137    pub async fn get_release(&self, id: &uuid::Uuid) -> Option<Release> {
138        let data = self.state.read().await;
139        data.cluster_state.releases.get(id).cloned()
140    }
141
142    /// List all sources.
143    pub async fn list_sources(&self) -> Vec<Source> {
144        let data = self.state.read().await;
145        data.cluster_state.sources.values().cloned().collect()
146    }
147
148    /// Get a specific source.
149    pub async fn get_source(&self, id: &uuid::Uuid) -> Option<Source> {
150        let data = self.state.read().await;
151        data.cluster_state.sources.get(id).cloned()
152    }
153
154    /// Get a source by name.
155    pub async fn get_source_by_name(&self, name: &str) -> Option<Source> {
156        let data = self.state.read().await;
157        data.cluster_state
158            .sources
159            .values()
160            .find(|s| s.name == name)
161            .cloned()
162    }
163
164    /// Get the full cluster state (for adapter/convergence reads).
165    pub async fn state(&self) -> tatara_core::cluster::types::ClusterState {
166        let data = self.state.read().await;
167        data.cluster_state.clone()
168    }
169
170    /// Check if this node is the Raft leader.
171    pub async fn is_leader(&self) -> bool {
172        self.raft.is_leader().await
173    }
174
175    /// Linearizable read — confirms leadership first. Use for operations
176    /// that absolutely need the latest state (rare).
177    pub async fn get_job_linearizable(&self, id: &str) -> Result<Option<Job>> {
178        let state = self.raft.read_state().await?;
179        let data = state.read().await;
180        Ok(data.cluster_state.jobs.get(id).cloned())
181    }
182
183    // ── Writes (through Raft with propagation tracking) ──
184
185    /// Submit a job. Waits for full cluster propagation.
186    pub async fn put_job(&self, job: Job) -> Result<WriteResult<Job>> {
187        let resp = self.raft.write(ClusterCommand::PutJob(job)).await?;
188        let job = match resp {
189            ClusterResponse::Job(j) => j,
190            ClusterResponse::Error(e) => anyhow::bail!("Failed to put job: {}", e),
191            _ => anyhow::bail!("Unexpected response from Raft"),
192        };
193
194        let log_index = self.current_commit_index().await;
195        let prop = self.await_propagation(log_index).await;
196
197        Ok(WriteResult {
198            value: job,
199            log_index,
200            fully_propagated: prop.fully_propagated,
201            propagated_count: prop.propagated_count,
202            total_nodes: prop.total_nodes,
203        })
204    }
205
206    /// Update job status. Waits for full cluster propagation.
207    pub async fn update_job_status(
208        &self,
209        job_id: &str,
210        status: JobStatus,
211    ) -> Result<WriteResult<Job>> {
212        let resp = self
213            .raft
214            .write(ClusterCommand::UpdateJobStatus {
215                job_id: job_id.to_string(),
216                status,
217            })
218            .await?;
219
220        let job = match resp {
221            ClusterResponse::Job(j) => j,
222            ClusterResponse::Error(e) => anyhow::bail!("Failed to update job: {}", e),
223            _ => anyhow::bail!("Unexpected response from Raft"),
224        };
225
226        let log_index = self.current_commit_index().await;
227        let prop = self.await_propagation(log_index).await;
228
229        Ok(WriteResult {
230            value: job,
231            log_index,
232            fully_propagated: prop.fully_propagated,
233            propagated_count: prop.propagated_count,
234            total_nodes: prop.total_nodes,
235        })
236    }
237
238    /// Submit an allocation. Waits for full cluster propagation.
239    pub async fn put_allocation(&self, alloc: Allocation) -> Result<WriteResult<Allocation>> {
240        let resp = self
241            .raft
242            .write(ClusterCommand::PutAllocation(alloc))
243            .await?;
244
245        let alloc = match resp {
246            ClusterResponse::Allocation(a) => a,
247            ClusterResponse::Error(e) => anyhow::bail!("Failed to put allocation: {}", e),
248            _ => anyhow::bail!("Unexpected response from Raft"),
249        };
250
251        let log_index = self.current_commit_index().await;
252        let prop = self.await_propagation(log_index).await;
253
254        Ok(WriteResult {
255            value: alloc,
256            log_index,
257            fully_propagated: prop.fully_propagated,
258            propagated_count: prop.propagated_count,
259            total_nodes: prop.total_nodes,
260        })
261    }
262
263    /// Update allocation state. Waits for full cluster propagation.
264    pub async fn update_allocation_state(
265        &self,
266        alloc_id: uuid::Uuid,
267        state: AllocationState,
268        task_states: std::collections::HashMap<String, TaskState>,
269    ) -> Result<WriteResult<Allocation>> {
270        let resp = self
271            .raft
272            .write(ClusterCommand::UpdateAllocation {
273                alloc_id,
274                state,
275                task_states,
276            })
277            .await?;
278
279        let alloc = match resp {
280            ClusterResponse::Allocation(a) => a,
281            ClusterResponse::Error(e) => anyhow::bail!("Failed to update allocation: {}", e),
282            _ => anyhow::bail!("Unexpected response from Raft"),
283        };
284
285        let log_index = self.current_commit_index().await;
286        let prop = self.await_propagation(log_index).await;
287
288        Ok(WriteResult {
289            value: alloc,
290            log_index,
291            fully_propagated: prop.fully_propagated,
292            propagated_count: prop.propagated_count,
293            total_nodes: prop.total_nodes,
294        })
295    }
296
297    /// Register a node in the cluster.
298    pub async fn register_node(&self, meta: NodeMeta) -> Result<WriteResult<()>> {
299        self.raft.write(ClusterCommand::RegisterNode(meta)).await?;
300
301        let log_index = self.current_commit_index().await;
302        let prop = self.await_propagation(log_index).await;
303
304        Ok(WriteResult {
305            value: (),
306            log_index,
307            fully_propagated: prop.fully_propagated,
308            propagated_count: prop.propagated_count,
309            total_nodes: prop.total_nodes,
310        })
311    }
312
313    /// Advertise a chunk in the content-addressed data index.
314    pub async fn advertise_chunk(&self, hash: String, node_id: NodeId) -> Result<()> {
315        self.raft
316            .write(ClusterCommand::AdvertiseChunk { hash, node_id })
317            .await?;
318        Ok(())
319    }
320
321    /// Emit an event into the cluster event ring.
322    pub async fn emit_event(&self, event: Event) -> Result<()> {
323        self.raft.write(ClusterCommand::EmitEvent(event)).await?;
324        Ok(())
325    }
326
327    /// Rollback a job to a previous version.
328    pub async fn rollback_job(&self, job_id: &str, version: u64) -> Result<WriteResult<Job>> {
329        let resp = self
330            .raft
331            .write(ClusterCommand::RollbackJob {
332                job_id: job_id.to_string(),
333                version,
334            })
335            .await?;
336
337        let job = match resp {
338            ClusterResponse::Job(j) => j,
339            ClusterResponse::Error(e) => anyhow::bail!("{}", e),
340            _ => anyhow::bail!("Unexpected response from Raft"),
341        };
342
343        let log_index = self.current_commit_index().await;
344        let prop = self.await_propagation(log_index).await;
345
346        Ok(WriteResult {
347            value: job,
348            log_index,
349            fully_propagated: prop.fully_propagated,
350            propagated_count: prop.propagated_count,
351            total_nodes: prop.total_nodes,
352        })
353    }
354
355    /// Create a release.
356    pub async fn put_release(&self, release: Release) -> Result<WriteResult<Release>> {
357        let resp = self.raft.write(ClusterCommand::PutRelease(release)).await?;
358
359        let release = match resp {
360            ClusterResponse::Release(r) => r,
361            ClusterResponse::Error(e) => anyhow::bail!("Failed to put release: {}", e),
362            _ => anyhow::bail!("Unexpected response from Raft"),
363        };
364
365        let log_index = self.current_commit_index().await;
366        let prop = self.await_propagation(log_index).await;
367
368        Ok(WriteResult {
369            value: release,
370            log_index,
371            fully_propagated: prop.fully_propagated,
372            propagated_count: prop.propagated_count,
373            total_nodes: prop.total_nodes,
374        })
375    }
376
377    /// Update release status.
378    pub async fn update_release_status(
379        &self,
380        release_id: uuid::Uuid,
381        status: ReleaseStatus,
382    ) -> Result<WriteResult<Release>> {
383        let resp = self
384            .raft
385            .write(ClusterCommand::UpdateReleaseStatus { release_id, status })
386            .await?;
387
388        let release = match resp {
389            ClusterResponse::Release(r) => r,
390            ClusterResponse::Error(e) => anyhow::bail!("{}", e),
391            _ => anyhow::bail!("Unexpected response from Raft"),
392        };
393
394        let log_index = self.current_commit_index().await;
395        let prop = self.await_propagation(log_index).await;
396
397        Ok(WriteResult {
398            value: release,
399            log_index,
400            fully_propagated: prop.fully_propagated,
401            propagated_count: prop.propagated_count,
402            total_nodes: prop.total_nodes,
403        })
404    }
405
406    /// Drain a node (set ineligible + emit event).
407    pub async fn drain_node(&self, node_id: NodeId) -> Result<()> {
408        let resp = self
409            .raft
410            .write(ClusterCommand::DrainNode { node_id })
411            .await?;
412        match resp {
413            ClusterResponse::Ok => Ok(()),
414            ClusterResponse::Error(e) => anyhow::bail!("{}", e),
415            _ => Ok(()),
416        }
417    }
418
419    /// Set node scheduling eligibility.
420    pub async fn set_node_eligibility(&self, node_id: NodeId, eligible: bool) -> Result<()> {
421        let resp = self
422            .raft
423            .write(ClusterCommand::SetNodeEligibility { node_id, eligible })
424            .await?;
425        match resp {
426            ClusterResponse::Ok => Ok(()),
427            ClusterResponse::Error(e) => anyhow::bail!("{}", e),
428            _ => Ok(()),
429        }
430    }
431
432    /// Create a source.
433    pub async fn put_source(&self, source: Source) -> Result<WriteResult<Source>> {
434        let resp = self.raft.write(ClusterCommand::PutSource(source)).await?;
435
436        let source = match resp {
437            ClusterResponse::Source(s) => s,
438            ClusterResponse::Error(e) => anyhow::bail!("Failed to put source: {}", e),
439            _ => anyhow::bail!("Unexpected response from Raft"),
440        };
441
442        let log_index = self.current_commit_index().await;
443        let prop = self.await_propagation(log_index).await;
444
445        Ok(WriteResult {
446            value: source,
447            log_index,
448            fully_propagated: prop.fully_propagated,
449            propagated_count: prop.propagated_count,
450            total_nodes: prop.total_nodes,
451        })
452    }
453
454    /// Update source status, revision, error, and managed jobs.
455    pub async fn update_source(
456        &self,
457        source_id: uuid::Uuid,
458        status: SourceStatus,
459        last_rev: Option<String>,
460        last_error: Option<String>,
461        managed_jobs: Option<std::collections::HashMap<String, String>>,
462    ) -> Result<WriteResult<Source>> {
463        let resp = self
464            .raft
465            .write(ClusterCommand::UpdateSource {
466                source_id,
467                status,
468                last_rev,
469                last_error,
470                managed_jobs,
471            })
472            .await?;
473
474        let source = match resp {
475            ClusterResponse::Source(s) => s,
476            ClusterResponse::Error(e) => anyhow::bail!("{}", e),
477            _ => anyhow::bail!("Unexpected response from Raft"),
478        };
479
480        let log_index = self.current_commit_index().await;
481        let prop = self.await_propagation(log_index).await;
482
483        Ok(WriteResult {
484            value: source,
485            log_index,
486            fully_propagated: prop.fully_propagated,
487            propagated_count: prop.propagated_count,
488            total_nodes: prop.total_nodes,
489        })
490    }
491
492    /// Delete a source.
493    pub async fn delete_source(&self, source_id: uuid::Uuid) -> Result<()> {
494        let resp = self
495            .raft
496            .write(ClusterCommand::DeleteSource { source_id })
497            .await?;
498        match resp {
499            ClusterResponse::Ok => Ok(()),
500            ClusterResponse::Error(e) => anyhow::bail!("{}", e),
501            _ => Ok(()),
502        }
503    }
504
505    // ── Propagation tracking ──
506
507    /// Get the current commit index from Raft metrics.
508    async fn current_commit_index(&self) -> u64 {
509        let metrics = self.raft.raft.metrics().borrow().clone();
510        metrics.last_applied.map(|l| l.index).unwrap_or(0)
511    }
512
513    /// Wait until all nodes in the cluster have applied up to `target_index`.
514    ///
515    /// Returns propagation status. A write is only considered fully complete
516    /// when ALL nodes have confirmed application of the write.
517    async fn await_propagation(&self, target_index: u64) -> PropagationStatus {
518        let deadline = tokio::time::Instant::now() + self.propagation_timeout;
519        let mut interval = tokio::time::interval(Duration::from_millis(100));
520
521        loop {
522            interval.tick().await;
523
524            let status = self.check_propagation(target_index).await;
525
526            if status.fully_propagated {
527                debug!(
528                    log_index = target_index,
529                    nodes = status.total_nodes,
530                    "Write fully propagated to all nodes"
531                );
532                return status;
533            }
534
535            if tokio::time::Instant::now() >= deadline {
536                warn!(
537                    log_index = target_index,
538                    propagated = status.propagated_count,
539                    total = status.total_nodes,
540                    "Write propagation timed out — not all nodes confirmed"
541                );
542                return status;
543            }
544        }
545    }
546
547    /// Check current propagation status for a given log index.
548    async fn check_propagation(&self, target_index: u64) -> PropagationStatus {
549        let metrics = self.raft.raft.metrics().borrow().clone();
550
551        // Count voters and learners from replication state
552        let replication = metrics.replication;
553
554        match replication {
555            Some(ref rep) => {
556                let total = rep.len() + 1; // +1 for the leader itself
557                let leader_applied = metrics.last_applied.map(|l| l.index).unwrap_or(0);
558
559                let mut propagated = if leader_applied >= target_index {
560                    1 // Leader has applied
561                } else {
562                    0
563                };
564
565                for (_node_id, log_id_opt) in rep.iter() {
566                    if let Some(log_id) = log_id_opt {
567                        if log_id.index >= target_index {
568                            propagated += 1;
569                        }
570                    }
571                }
572
573                PropagationStatus {
574                    fully_propagated: propagated >= total,
575                    propagated_count: propagated,
576                    total_nodes: total,
577                }
578            }
579            None => {
580                // Single node — propagation is immediate
581                PropagationStatus {
582                    fully_propagated: true,
583                    propagated_count: 1,
584                    total_nodes: 1,
585                }
586            }
587        }
588    }
589}
590
591struct PropagationStatus {
592    fully_propagated: bool,
593    propagated_count: usize,
594    total_nodes: usize,
595}