Skip to main content

tatara_testing/
store.rs

1//! In-memory store that mirrors the ClusterStore API without requiring Raft.
2//!
3//! This is the "virtualized tatara" — a complete, functional implementation of
4//! the tatara state machine that runs entirely in-memory. It applies the same
5//! state transitions as the Raft state machine but without consensus overhead.
6
7use anyhow::Result;
8use std::collections::HashMap;
9use tokio::sync::RwLock;
10use uuid::Uuid;
11
12use tatara_core::cluster::types::{ClusterState, JobVersionEntry, NodeMeta};
13use tatara_core::domain::allocation::{Allocation, AllocationState, TaskState};
14use tatara_core::domain::event::{Event, EventKind};
15use tatara_core::domain::job::{Job, JobSpec, JobStatus};
16use tatara_core::domain::release::{Release, ReleaseStatus};
17
18/// In-memory store that provides the same read/write interface as ClusterStore
19/// but backed by a simple RwLock instead of Raft consensus.
20///
21/// State transitions mirror `src/cluster/raft_sm.rs` exactly.
22pub struct InMemoryStore {
23    state: RwLock<ClusterState>,
24}
25
26impl InMemoryStore {
27    pub fn new() -> Self {
28        Self {
29            state: RwLock::new(ClusterState::default()),
30        }
31    }
32
33    /// Create a store pre-populated with initial state.
34    pub fn with_state(state: ClusterState) -> Self {
35        Self {
36            state: RwLock::new(state),
37        }
38    }
39
40    // ── Reads ──
41
42    pub async fn get_job(&self, id: &str) -> Option<Job> {
43        let state = self.state.read().await;
44        state.jobs.get(id).cloned()
45    }
46
47    pub async fn list_jobs(&self) -> Vec<Job> {
48        let state = self.state.read().await;
49        state.jobs.values().cloned().collect()
50    }
51
52    pub async fn get_allocation(&self, id: &Uuid) -> Option<Allocation> {
53        let state = self.state.read().await;
54        state.allocations.get(id).cloned()
55    }
56
57    pub async fn list_allocations(&self) -> Vec<Allocation> {
58        let state = self.state.read().await;
59        state.allocations.values().cloned().collect()
60    }
61
62    pub async fn list_allocations_for_job(&self, job_id: &str) -> Vec<Allocation> {
63        let state = self.state.read().await;
64        state
65            .allocations
66            .values()
67            .filter(|a| a.job_id == job_id)
68            .cloned()
69            .collect()
70    }
71
72    pub async fn list_nodes(&self) -> Vec<NodeMeta> {
73        let state = self.state.read().await;
74        state.nodes.values().cloned().collect()
75    }
76
77    pub async fn get_job_history(&self, job_id: &str) -> Vec<JobVersionEntry> {
78        let state = self.state.read().await;
79        state.job_history.get(job_id).cloned().unwrap_or_default()
80    }
81
82    pub async fn list_events(
83        &self,
84        kind: Option<&EventKind>,
85        since: Option<chrono::DateTime<chrono::Utc>>,
86    ) -> Vec<Event> {
87        let state = self.state.read().await;
88        state
89            .events
90            .query(kind, since)
91            .into_iter()
92            .cloned()
93            .collect()
94    }
95
96    pub async fn list_releases(&self) -> Vec<Release> {
97        let state = self.state.read().await;
98        state.releases.values().cloned().collect()
99    }
100
101    pub async fn get_release(&self, id: &Uuid) -> Option<Release> {
102        let state = self.state.read().await;
103        state.releases.get(id).cloned()
104    }
105
106    // ── Writes (mirrors raft_sm.rs apply logic) ──
107
108    /// Submit a job. Mirrors PutJob command from raft_sm.
109    pub async fn put_job(&self, job: Job) -> Result<Job> {
110        let mut state = self.state.write().await;
111
112        // Save version history snapshot
113        let entry = JobVersionEntry {
114            version: job.version,
115            spec: JobSpec {
116                id: job.id.clone(),
117                job_type: job.job_type.clone(),
118                groups: job.groups.clone(),
119                constraints: job.constraints.clone(),
120                meta: job.meta.clone(),
121            },
122            status: job.status.clone(),
123            submitted_at: job.submitted_at,
124        };
125        state
126            .job_history
127            .entry(job.id.clone())
128            .or_default()
129            .push(entry);
130
131        // Emit event
132        state.events.push(Event::new(
133            EventKind::JobSubmitted,
134            serde_json::json!({
135                "job_id": job.id,
136                "version": job.version,
137            }),
138        ));
139
140        state.jobs.insert(job.id.clone(), job.clone());
141        Ok(job)
142    }
143
144    /// Update job status. Mirrors UpdateJobStatus command.
145    pub async fn update_job_status(&self, job_id: &str, status: JobStatus) -> Result<Job> {
146        let mut state = self.state.write().await;
147
148        let job = state
149            .jobs
150            .get_mut(job_id)
151            .ok_or_else(|| anyhow::anyhow!("Job not found: {}", job_id))?;
152
153        job.status = status.clone();
154        job.version += 1;
155        let result = job.clone();
156
157        // Emit event
158        let kind = match status {
159            JobStatus::Dead => EventKind::JobStopped,
160            _ => EventKind::JobUpdated,
161        };
162        state.events.push(Event::new(
163            kind,
164            serde_json::json!({
165                "job_id": job_id,
166                "status": format!("{:?}", status),
167            }),
168        ));
169
170        Ok(result)
171    }
172
173    /// Submit an allocation. Mirrors PutAllocation command.
174    pub async fn put_allocation(&self, alloc: Allocation) -> Result<Allocation> {
175        let mut state = self.state.write().await;
176
177        state.events.push(Event::new(
178            EventKind::AllocationPlaced,
179            serde_json::json!({
180                "alloc_id": alloc.id.to_string(),
181                "job_id": alloc.job_id,
182                "node_id": alloc.node_id,
183            }),
184        ));
185
186        state.allocations.insert(alloc.id, alloc.clone());
187        Ok(alloc)
188    }
189
190    /// Update allocation state. Mirrors UpdateAllocation command.
191    pub async fn update_allocation_state(
192        &self,
193        alloc_id: Uuid,
194        new_state: AllocationState,
195        task_states: HashMap<String, TaskState>,
196    ) -> Result<Allocation> {
197        let mut state = self.state.write().await;
198
199        let alloc = state
200            .allocations
201            .get_mut(&alloc_id)
202            .ok_or_else(|| anyhow::anyhow!("Allocation not found: {}", alloc_id))?;
203
204        let kind = match new_state {
205            AllocationState::Running => EventKind::AllocationStarted,
206            AllocationState::Complete => EventKind::AllocationCompleted,
207            AllocationState::Failed => EventKind::AllocationFailed,
208            _ => EventKind::AllocationPlaced,
209        };
210
211        alloc.state = new_state;
212        alloc.task_states = task_states;
213        let result = alloc.clone();
214
215        state.events.push(Event::new(
216            kind,
217            serde_json::json!({
218                "alloc_id": alloc_id.to_string(),
219            }),
220        ));
221
222        Ok(result)
223    }
224
225    /// Register a node. Mirrors RegisterNode command.
226    pub async fn register_node(&self, meta: NodeMeta) -> Result<()> {
227        let mut state = self.state.write().await;
228
229        state.events.push(Event::new(
230            EventKind::NodeJoined,
231            serde_json::json!({
232                "node_id": meta.node_id,
233                "hostname": meta.hostname,
234            }),
235        ));
236
237        state.nodes.insert(meta.node_id, meta);
238        Ok(())
239    }
240
241    /// Emit a raw event.
242    pub async fn emit_event(&self, event: Event) {
243        let mut state = self.state.write().await;
244        state.events.push(event);
245    }
246
247    /// Rollback a job to a previous version. Mirrors RollbackJob command.
248    pub async fn rollback_job(&self, job_id: &str, version: u64) -> Result<Job> {
249        let mut state = self.state.write().await;
250
251        let history = state
252            .job_history
253            .get(job_id)
254            .ok_or_else(|| anyhow::anyhow!("Job not found: {}", job_id))?;
255
256        let entry = history
257            .iter()
258            .find(|e| e.version == version)
259            .ok_or_else(|| anyhow::anyhow!("Version {} not found for job {}", version, job_id))?
260            .clone();
261
262        let job = state
263            .jobs
264            .get_mut(job_id)
265            .ok_or_else(|| anyhow::anyhow!("Job not found: {}", job_id))?;
266
267        job.groups = entry.spec.groups;
268        job.constraints = entry.spec.constraints;
269        job.meta = entry.spec.meta;
270        job.version += 1;
271        job.status = JobStatus::Pending;
272        let result = job.clone();
273
274        state.events.push(Event::new(
275            EventKind::JobUpdated,
276            serde_json::json!({
277                "job_id": job_id,
278                "action": "rollback",
279                "target_version": version,
280            }),
281        ));
282
283        Ok(result)
284    }
285
286    /// Create a release. Mirrors PutRelease command.
287    pub async fn put_release(&self, release: Release) -> Result<Release> {
288        let mut state = self.state.write().await;
289        state.releases.insert(release.id, release.clone());
290        Ok(release)
291    }
292
293    /// Update release status. Mirrors UpdateReleaseStatus command.
294    pub async fn update_release_status(
295        &self,
296        release_id: Uuid,
297        status: ReleaseStatus,
298    ) -> Result<Release> {
299        let mut state = self.state.write().await;
300
301        let release = state
302            .releases
303            .get_mut(&release_id)
304            .ok_or_else(|| anyhow::anyhow!("Release not found: {}", release_id))?;
305
306        release.status = status;
307        Ok(release.clone())
308    }
309
310    /// Drain a node (set ineligible + emit event). Mirrors DrainNode command.
311    pub async fn drain_node(&self, node_id: u64) -> Result<()> {
312        let mut state = self.state.write().await;
313
314        if let Some(node) = state.nodes.get_mut(&node_id) {
315            node.eligible = false;
316            state.events.push(Event::new(
317                EventKind::NodeDraining,
318                serde_json::json!({ "node_id": node_id }),
319            ));
320            Ok(())
321        } else {
322            anyhow::bail!("Node not found: {}", node_id)
323        }
324    }
325
326    /// Set node eligibility. Mirrors SetNodeEligibility command.
327    pub async fn set_node_eligibility(&self, node_id: u64, eligible: bool) -> Result<()> {
328        let mut state = self.state.write().await;
329
330        if let Some(node) = state.nodes.get_mut(&node_id) {
331            node.eligible = eligible;
332            Ok(())
333        } else {
334            anyhow::bail!("Node not found: {}", node_id)
335        }
336    }
337
338    /// Direct access to the state for assertions in tests.
339    pub async fn state(&self) -> tokio::sync::RwLockReadGuard<'_, ClusterState> {
340        self.state.read().await
341    }
342}
343
344impl Default for InMemoryStore {
345    fn default() -> Self {
346        Self::new()
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use crate::fixtures;
354    use tatara_core::domain::job::{JobType, Resources, Task, TaskConfig, TaskGroup};
355
356    #[tokio::test]
357    async fn test_put_and_get_job() {
358        let store = InMemoryStore::new();
359        let job = fixtures::job("test-job");
360
361        store.put_job(job.clone()).await.unwrap();
362
363        let retrieved = store.get_job("test-job").await;
364        assert!(retrieved.is_some());
365        assert_eq!(retrieved.unwrap().id, "test-job");
366    }
367
368    #[tokio::test]
369    async fn test_job_version_history() {
370        let store = InMemoryStore::new();
371        let job = fixtures::job("versioned-job");
372
373        store.put_job(job).await.unwrap();
374        store
375            .update_job_status("versioned-job", JobStatus::Running)
376            .await
377            .unwrap();
378
379        let history = store.get_job_history("versioned-job").await;
380        assert_eq!(history.len(), 1); // Initial submission
381        assert_eq!(history[0].version, 1);
382    }
383
384    #[tokio::test]
385    async fn test_events_emitted_on_job_submit() {
386        let store = InMemoryStore::new();
387        let job = fixtures::job("event-job");
388
389        store.put_job(job).await.unwrap();
390
391        let events = store
392            .list_events(Some(&EventKind::JobSubmitted), None)
393            .await;
394        assert_eq!(events.len(), 1);
395        assert_eq!(events[0].payload["job_id"], "event-job");
396    }
397
398    #[tokio::test]
399    async fn test_rollback_job() {
400        let store = InMemoryStore::new();
401        let job = fixtures::job_with_group("rollback-test", "web", 3, 500, 256);
402
403        store.put_job(job).await.unwrap();
404        store
405            .update_job_status("rollback-test", JobStatus::Running)
406            .await
407            .unwrap();
408
409        // Rollback to version 1
410        let rolled_back = store.rollback_job("rollback-test", 1).await.unwrap();
411        assert_eq!(rolled_back.status, JobStatus::Pending);
412        assert_eq!(rolled_back.groups[0].count, 3);
413    }
414
415    #[tokio::test]
416    async fn test_release_lifecycle() {
417        let store = InMemoryStore::new();
418
419        let mut release = Release::new(
420            "myapp".to_string(),
421            "github:user/myapp".to_string(),
422            "job-1".to_string(),
423        );
424        release.status = ReleaseStatus::Active;
425
426        let created = store.put_release(release).await.unwrap();
427        assert_eq!(created.status, ReleaseStatus::Active);
428
429        let updated = store
430            .update_release_status(created.id, ReleaseStatus::Superseded)
431            .await
432            .unwrap();
433        assert_eq!(updated.status, ReleaseStatus::Superseded);
434    }
435
436    #[tokio::test]
437    async fn test_node_drain() {
438        let store = InMemoryStore::new();
439        let node = fixtures::node_meta(1, "test-node", 4000, 8192);
440
441        store.register_node(node).await.unwrap();
442        store.drain_node(1).await.unwrap();
443
444        let nodes = store.list_nodes().await;
445        assert!(!nodes[0].eligible);
446
447        let events = store
448            .list_events(Some(&EventKind::NodeDraining), None)
449            .await;
450        assert_eq!(events.len(), 1);
451    }
452}