Skip to main content

tatara_engine/cluster/
raft_sm.rs

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
37/// Raft state machine backed by in-memory ClusterState.
38pub 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            // Save version history snapshot
193            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            // Emit event
213            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                // Auto-increment version on status change
231                if old_status != status {
232                    job.version += 1;
233
234                    // Emit event
235                    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            // Emit event
259            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                // Emit event based on state transition
283                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        // ── New commands ──
346        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; // Re-schedule
363
364                        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        // ── Sources ──
426        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                // Emit appropriate event
460                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        // ── Distributed state machine commands ──
503        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                // Update the node's last known activity
583                node.joined_at = timestamp; // reuse field for last heartbeat
584            }
585            ClusterResponse::Ok
586        }
587    }
588}