1use openraft::anyerror::AnyError;
2use openraft::storage::RaftStateMachine;
3use openraft::{
4 Entry, EntryPayload, LogId, OptionalSend, RaftSnapshotBuilder, Snapshot, SnapshotMeta,
5 StorageError, StorageIOError, StoredMembership,
6};
7use serde::{Deserialize, Serialize};
8use std::io::Cursor;
9use std::sync::Arc;
10use tokio::sync::RwLock;
11
12use tatara_core::cluster::types::{
13 ClusterCommand, ClusterResponse, ClusterState, JobVersionEntry, NodeId,
14};
15use tatara_core::domain::event::{Event, EventKind};
16use tatara_core::domain::job::{JobSpec, JobStatus};
17use tatara_core::domain::source::SourceStatus;
18
19openraft::declare_raft_types!(
20 pub TypeConfig:
21 D = ClusterCommand,
22 R = ClusterResponse,
23 Node = openraft::BasicNode,
24 NodeId = NodeId,
25 Entry = Entry<TypeConfig>,
26 SnapshotData = Cursor<Vec<u8>>,
27);
28
29fn io_read_sm<E: std::error::Error + 'static>(e: &E) -> StorageError<NodeId> {
30 StorageIOError::<NodeId>::read_state_machine(AnyError::new(e)).into()
31}
32
33fn io_read_snap<E: std::error::Error + 'static>(e: &E) -> StorageError<NodeId> {
34 StorageIOError::<NodeId>::read_snapshot(None, AnyError::new(e)).into()
35}
36
37pub struct StateMachine {
39 state: Arc<RwLock<StateMachineData>>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, Default)]
43pub struct StateMachineData {
44 pub last_applied_log: Option<LogId<NodeId>>,
45 pub last_membership: StoredMembership<NodeId, openraft::BasicNode>,
46 pub cluster_state: ClusterState,
47}
48
49impl StateMachine {
50 pub fn new() -> Self {
51 Self {
52 state: Arc::new(RwLock::new(StateMachineData::default())),
53 }
54 }
55
56 pub fn state(&self) -> Arc<RwLock<StateMachineData>> {
57 self.state.clone()
58 }
59}
60
61impl RaftSnapshotBuilder<TypeConfig> for StateMachine {
62 async fn build_snapshot(&mut self) -> Result<Snapshot<TypeConfig>, StorageError<NodeId>> {
63 let data = self.state.read().await;
64 let bytes = serde_json::to_vec(&*data).map_err(|e| io_read_sm(&e))?;
65
66 let last_applied = data.last_applied_log;
67 let membership = data.last_membership.clone();
68
69 let snapshot_id = format!(
70 "{}-{}",
71 last_applied.map(|l| l.index).unwrap_or(0),
72 chrono::Utc::now().timestamp()
73 );
74
75 Ok(Snapshot {
76 meta: SnapshotMeta {
77 last_log_id: last_applied,
78 last_membership: membership,
79 snapshot_id,
80 },
81 snapshot: Box::new(Cursor::new(bytes)),
82 })
83 }
84}
85
86impl RaftStateMachine<TypeConfig> for StateMachine {
87 type SnapshotBuilder = Self;
88
89 async fn applied_state(
90 &mut self,
91 ) -> Result<
92 (
93 Option<LogId<NodeId>>,
94 StoredMembership<NodeId, openraft::BasicNode>,
95 ),
96 StorageError<NodeId>,
97 > {
98 let data = self.state.read().await;
99 Ok((data.last_applied_log, data.last_membership.clone()))
100 }
101
102 async fn apply<I>(&mut self, entries: I) -> Result<Vec<ClusterResponse>, StorageError<NodeId>>
103 where
104 I: IntoIterator<Item = Entry<TypeConfig>> + OptionalSend,
105 {
106 let mut responses = Vec::new();
107 let mut data = self.state.write().await;
108
109 for entry in entries {
110 data.last_applied_log = Some(entry.log_id);
111
112 if let EntryPayload::Membership(ref membership) = entry.payload {
113 data.last_membership =
114 StoredMembership::new(Some(entry.log_id), membership.clone());
115 responses.push(ClusterResponse::Ok);
116 continue;
117 }
118
119 let resp = if let EntryPayload::Normal(cmd) = entry.payload {
120 apply_command(&mut data.cluster_state, cmd)
121 } else {
122 ClusterResponse::Ok
123 };
124
125 responses.push(resp);
126 }
127
128 Ok(responses)
129 }
130
131 async fn get_snapshot_builder(&mut self) -> Self::SnapshotBuilder {
132 StateMachine {
133 state: self.state.clone(),
134 }
135 }
136
137 async fn begin_receiving_snapshot(
138 &mut self,
139 ) -> Result<Box<Cursor<Vec<u8>>>, StorageError<NodeId>> {
140 Ok(Box::new(Cursor::new(Vec::new())))
141 }
142
143 async fn install_snapshot(
144 &mut self,
145 meta: &SnapshotMeta<NodeId, openraft::BasicNode>,
146 snapshot: Box<Cursor<Vec<u8>>>,
147 ) -> Result<(), StorageError<NodeId>> {
148 let bytes = snapshot.into_inner();
149 let new_data: StateMachineData =
150 serde_json::from_slice(&bytes).map_err(|e| io_read_snap(&e))?;
151
152 let mut data = self.state.write().await;
153 *data = new_data;
154 data.last_applied_log = meta.last_log_id;
155 data.last_membership = meta.last_membership.clone();
156
157 Ok(())
158 }
159
160 async fn get_current_snapshot(
161 &mut self,
162 ) -> Result<Option<Snapshot<TypeConfig>>, StorageError<NodeId>> {
163 let data = self.state.read().await;
164
165 if data.last_applied_log.is_none() {
166 return Ok(None);
167 }
168
169 let bytes = serde_json::to_vec(&*data).map_err(|e| io_read_sm(&e))?;
170
171 let snapshot_id = format!(
172 "{}-snap",
173 data.last_applied_log.map(|l| l.index).unwrap_or(0)
174 );
175
176 Ok(Some(Snapshot {
177 meta: SnapshotMeta {
178 last_log_id: data.last_applied_log,
179 last_membership: data.last_membership.clone(),
180 snapshot_id,
181 },
182 snapshot: Box::new(Cursor::new(bytes)),
183 }))
184 }
185}
186
187fn apply_command(state: &mut ClusterState, cmd: ClusterCommand) -> ClusterResponse {
188 match cmd {
189 ClusterCommand::PutJob(job) => {
190 let job_clone = job.clone();
191
192 let spec = JobSpec {
194 id: job.id.clone(),
195 job_type: job.job_type.clone(),
196 groups: job.groups.clone(),
197 constraints: job.constraints.clone(),
198 meta: job.meta.clone(),
199 };
200 let entry = JobVersionEntry {
201 version: job.version,
202 spec,
203 status: job.status.clone(),
204 submitted_at: job.submitted_at,
205 };
206 state
207 .job_history
208 .entry(job.id.clone())
209 .or_default()
210 .push(entry);
211
212 state.events.push(Event::new(
214 EventKind::JobSubmitted,
215 serde_json::json!({
216 "job_id": &job.id,
217 "version": job.version,
218 "job_type": &job.job_type,
219 }),
220 ));
221
222 state.jobs.insert(job.id.clone(), job);
223 ClusterResponse::Job(job_clone)
224 }
225 ClusterCommand::UpdateJobStatus { job_id, status } => {
226 if let Some(job) = state.jobs.get_mut(&job_id) {
227 let old_status = job.status.clone();
228 job.status = status.clone();
229
230 if old_status != status {
232 job.version += 1;
233
234 let kind = match &status {
236 JobStatus::Dead => EventKind::JobStopped,
237 _ => EventKind::JobUpdated,
238 };
239 state.events.push(Event::new(
240 kind,
241 serde_json::json!({
242 "job_id": &job_id,
243 "old_status": &old_status,
244 "new_status": &status,
245 "version": job.version,
246 }),
247 ));
248 }
249
250 ClusterResponse::Job(job.clone())
251 } else {
252 ClusterResponse::Error(format!("Job not found: {}", job_id))
253 }
254 }
255 ClusterCommand::PutAllocation(alloc) => {
256 let alloc_clone = alloc.clone();
257
258 state.events.push(Event::new(
260 EventKind::AllocationPlaced,
261 serde_json::json!({
262 "alloc_id": alloc.id.to_string(),
263 "job_id": &alloc.job_id,
264 "node_id": &alloc.node_id,
265 "group": &alloc.group_name,
266 }),
267 ));
268
269 state.allocations.insert(alloc.id, alloc);
270 ClusterResponse::Allocation(alloc_clone)
271 }
272 ClusterCommand::UpdateAllocation {
273 alloc_id,
274 state: alloc_state,
275 task_states,
276 } => {
277 if let Some(alloc) = state.allocations.get_mut(&alloc_id) {
278 let old_state = alloc.state.clone();
279 alloc.state = alloc_state.clone();
280 alloc.task_states = task_states;
281
282 if old_state != alloc_state {
284 let kind = match &alloc_state {
285 tatara_core::domain::allocation::AllocationState::Running => {
286 EventKind::AllocationStarted
287 }
288 tatara_core::domain::allocation::AllocationState::Failed => {
289 EventKind::AllocationFailed
290 }
291 tatara_core::domain::allocation::AllocationState::Complete => {
292 EventKind::AllocationCompleted
293 }
294 _ => EventKind::AllocationPlaced,
295 };
296 state.events.push(Event::new(
297 kind,
298 serde_json::json!({
299 "alloc_id": alloc_id.to_string(),
300 "job_id": &alloc.job_id,
301 "old_state": &old_state,
302 "new_state": &alloc_state,
303 }),
304 ));
305 }
306
307 ClusterResponse::Allocation(alloc.clone())
308 } else {
309 ClusterResponse::Error(format!("Allocation not found: {}", alloc_id))
310 }
311 }
312 ClusterCommand::RegisterNode(meta) => {
313 state.events.push(Event::new(
314 EventKind::NodeJoined,
315 serde_json::json!({
316 "node_id": meta.node_id,
317 "hostname": &meta.hostname,
318 }),
319 ));
320 state.nodes.insert(meta.node_id, meta);
321 ClusterResponse::Ok
322 }
323 ClusterCommand::RemoveNode(node_id) => {
324 state.events.push(Event::new(
325 EventKind::NodeLeft,
326 serde_json::json!({ "node_id": node_id }),
327 ));
328 state.nodes.remove(&node_id);
329 ClusterResponse::Ok
330 }
331 ClusterCommand::AdvertiseChunk { hash, node_id } => {
332 state.data_index.entry(hash).or_default().push(node_id);
333 ClusterResponse::Ok
334 }
335 ClusterCommand::RemoveChunkAdvertisement { hash, node_id } => {
336 if let Some(holders) = state.data_index.get_mut(&hash) {
337 holders.retain(|&id| id != node_id);
338 if holders.is_empty() {
339 state.data_index.remove(&hash);
340 }
341 }
342 ClusterResponse::Ok
343 }
344
345 ClusterCommand::EmitEvent(event) => {
347 state.events.push(event);
348 ClusterResponse::Ok
349 }
350
351 ClusterCommand::RollbackJob { job_id, version } => {
352 let history = state.job_history.get(&job_id);
353 let target = history.and_then(|h| h.iter().find(|e| e.version == version));
354
355 match target {
356 Some(entry) => {
357 if let Some(job) = state.jobs.get_mut(&job_id) {
358 job.groups = entry.spec.groups.clone();
359 job.constraints = entry.spec.constraints.clone();
360 job.meta = entry.spec.meta.clone();
361 job.version += 1;
362 job.status = JobStatus::Pending; state.events.push(Event::new(
365 EventKind::JobUpdated,
366 serde_json::json!({
367 "job_id": &job_id,
368 "rolled_back_to": version,
369 "new_version": job.version,
370 }),
371 ));
372
373 ClusterResponse::Job(job.clone())
374 } else {
375 ClusterResponse::Error(format!("Job not found: {}", job_id))
376 }
377 }
378 None => ClusterResponse::Error(format!(
379 "Version {} not found for job {}",
380 version, job_id
381 )),
382 }
383 }
384
385 ClusterCommand::PutRelease(release) => {
386 let release_clone = release.clone();
387 state.releases.insert(release.id, release);
388 ClusterResponse::Release(release_clone)
389 }
390
391 ClusterCommand::UpdateReleaseStatus { release_id, status } => {
392 if let Some(release) = state.releases.get_mut(&release_id) {
393 release.status = status;
394 ClusterResponse::Release(release.clone())
395 } else {
396 ClusterResponse::Error(format!("Release not found: {}", release_id))
397 }
398 }
399
400 ClusterCommand::DrainNode { node_id } => {
401 if let Some(node) = state.nodes.get_mut(&node_id) {
402 node.eligible = false;
403 state.events.push(Event::new(
404 EventKind::NodeDraining,
405 serde_json::json!({
406 "node_id": node_id,
407 "hostname": &node.hostname,
408 }),
409 ));
410 ClusterResponse::Ok
411 } else {
412 ClusterResponse::Error(format!("Node not found: {}", node_id))
413 }
414 }
415
416 ClusterCommand::SetNodeEligibility { node_id, eligible } => {
417 if let Some(node) = state.nodes.get_mut(&node_id) {
418 node.eligible = eligible;
419 ClusterResponse::Ok
420 } else {
421 ClusterResponse::Error(format!("Node not found: {}", node_id))
422 }
423 }
424
425 ClusterCommand::PutSource(source) => {
427 let source_clone = source.clone();
428 state.events.push(Event::new(
429 EventKind::SourceCreated,
430 serde_json::json!({
431 "source_id": source.id.to_string(),
432 "name": &source.name,
433 "flake_ref": &source.flake_ref,
434 }),
435 ));
436 state.sources.insert(source.id, source);
437 ClusterResponse::Source(source_clone)
438 }
439
440 ClusterCommand::UpdateSource {
441 source_id,
442 status,
443 last_rev,
444 last_error,
445 managed_jobs,
446 } => {
447 if let Some(source) = state.sources.get_mut(&source_id) {
448 let old_status = source.status.clone();
449 source.status = status.clone();
450 if let Some(rev) = last_rev {
451 source.last_rev = Some(rev);
452 }
453 source.last_error = last_error;
454 if let Some(jobs) = managed_jobs {
455 source.managed_jobs = jobs;
456 }
457 source.last_reconciled_at = Some(chrono::Utc::now());
458
459 let kind = match &status {
461 SourceStatus::Ready => EventKind::SourceReconciled,
462 SourceStatus::Failed => EventKind::SourceFailed,
463 SourceStatus::Suspended if old_status != SourceStatus::Suspended => {
464 EventKind::SourceSuspended
465 }
466 SourceStatus::Pending if old_status == SourceStatus::Suspended => {
467 EventKind::SourceResumed
468 }
469 _ => EventKind::SourceReconciled,
470 };
471 state.events.push(Event::new(
472 kind,
473 serde_json::json!({
474 "source_id": source_id.to_string(),
475 "name": &source.name,
476 "status": &status,
477 }),
478 ));
479
480 ClusterResponse::Source(source.clone())
481 } else {
482 ClusterResponse::Error(format!("Source not found: {}", source_id))
483 }
484 }
485
486 ClusterCommand::DeleteSource { source_id } => {
487 if let Some(source) = state.sources.remove(&source_id) {
488 state.events.push(Event::new(
489 EventKind::SourceReconciled,
490 serde_json::json!({
491 "source_id": source_id.to_string(),
492 "name": &source.name,
493 "action": "deleted",
494 }),
495 ));
496 ClusterResponse::Ok
497 } else {
498 ClusterResponse::Error(format!("Source not found: {}", source_id))
499 }
500 }
501
502 ClusterCommand::ProposeAllocations {
504 expected_generation,
505 allocations,
506 job_status_updates,
507 } => {
508 if state.scheduling_generation != expected_generation {
509 return ClusterResponse::Error(format!(
510 "scheduling generation conflict: expected {}, current {}",
511 expected_generation, state.scheduling_generation
512 ));
513 }
514 state.scheduling_generation += 1;
515 for alloc in allocations {
516 state.desired_allocations.insert(alloc.alloc_id, alloc);
517 }
518 for (job_id, status) in job_status_updates {
519 if let Some(job) = state.jobs.get_mut(&job_id) {
520 job.status = status;
521 }
522 }
523 ClusterResponse::Ok
524 }
525
526 ClusterCommand::SetDesiredAllocation(desired) => {
527 state.desired_allocations.insert(desired.alloc_id, desired);
528 ClusterResponse::Ok
529 }
530
531 ClusterCommand::RemoveDesiredAllocation { alloc_id } => {
532 state.desired_allocations.remove(&alloc_id);
533 ClusterResponse::Ok
534 }
535
536 ClusterCommand::ReportObservation {
537 node_id,
538 alloc_id,
539 phase,
540 observation_seq,
541 } => {
542 let observed = tatara_core::domain::lifecycle::ObservedAllocationState {
543 alloc_id,
544 node_id: format!("{node_id}"),
545 phase,
546 observed_at: chrono::Utc::now(),
547 observation_seq,
548 };
549 state.observed_allocations.insert(alloc_id, observed);
550 ClusterResponse::Ok
551 }
552
553 ClusterCommand::BatchObservations {
554 node_id,
555 observations,
556 observation_seq,
557 } => {
558 for (alloc_id, phase) in observations {
559 let observed = tatara_core::domain::lifecycle::ObservedAllocationState {
560 alloc_id,
561 node_id: format!("{}", node_id),
562 phase,
563 observed_at: chrono::Utc::now(),
564 observation_seq,
565 };
566 state.observed_allocations.insert(alloc_id, observed);
567 }
568 ClusterResponse::Ok
569 }
570
571 ClusterCommand::ReportNodePhase { node_id, phase } => {
572 state.node_phases.insert(node_id, phase);
573 ClusterResponse::Ok
574 }
575
576 ClusterCommand::Heartbeat {
577 node_id,
578 timestamp,
579 allocation_summary: _,
580 } => {
581 if let Some(node) = state.nodes.get_mut(&node_id) {
582 node.joined_at = timestamp; }
585 ClusterResponse::Ok
586 }
587 }
588}