1use 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
18pub 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 pub fn with_state(state: ClusterState) -> Self {
35 Self {
36 state: RwLock::new(state),
37 }
38 }
39
40 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 pub async fn put_job(&self, job: Job) -> Result<Job> {
110 let mut state = self.state.write().await;
111
112 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 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 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 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 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 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 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 pub async fn emit_event(&self, event: Event) {
243 let mut state = self.state.write().await;
244 state.events.push(event);
245 }
246
247 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 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 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 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 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 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); 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 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}