use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum OpsJobKind {
RollingUpgrade,
NodeReplacement,
TabletMovement,
OnlineIndexBuild,
SchemaBackfill,
Backup,
RestoreVerification,
KeyRotation,
StatsCollection,
LeaderRebalance,
}
impl OpsJobKind {
pub const ALL: [OpsJobKind; 10] = [
Self::RollingUpgrade,
Self::NodeReplacement,
Self::TabletMovement,
Self::OnlineIndexBuild,
Self::SchemaBackfill,
Self::Backup,
Self::RestoreVerification,
Self::KeyRotation,
Self::StatsCollection,
Self::LeaderRebalance,
];
pub fn name(self) -> &'static str {
match self {
Self::RollingUpgrade => "rolling_upgrade",
Self::NodeReplacement => "node_replacement",
Self::TabletMovement => "tablet_movement",
Self::OnlineIndexBuild => "online_index_build",
Self::SchemaBackfill => "schema_backfill",
Self::Backup => "backup",
Self::RestoreVerification => "restore_verification",
Self::KeyRotation => "key_rotation",
Self::StatsCollection => "stats_collection",
Self::LeaderRebalance => "leader_rebalance",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum OpsJobState {
Pending,
Running,
Paused,
Cancelling,
Succeeded,
Failed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OpsJob {
pub job_id: String,
pub kind: OpsJobKind,
pub state: OpsJobState,
pub phase: u32,
pub progress: String,
pub params: BTreeMap<String, String>,
}
#[derive(Debug, Default, Clone)]
pub struct OpsJobStore {
jobs: BTreeMap<String, OpsJob>,
next: u64,
}
impl OpsJobStore {
pub fn new() -> Self {
Self::default()
}
pub fn submit(&mut self, kind: OpsJobKind, params: BTreeMap<String, String>) -> OpsJob {
self.next += 1;
let job_id = format!("ops-{}-{}", kind.name(), self.next);
let job = OpsJob {
job_id: job_id.clone(),
kind,
state: OpsJobState::Pending,
phase: 0,
progress: "submitted".into(),
params,
};
self.jobs.insert(job_id, job.clone());
job
}
pub fn start(&mut self, job_id: &str) -> Result<&OpsJob, OpsJobError> {
let job = self.jobs.get_mut(job_id).ok_or(OpsJobError::NotFound)?;
match job.state {
OpsJobState::Pending | OpsJobState::Paused => {
job.state = OpsJobState::Running;
job.progress = "running".into();
Ok(job)
}
other => Err(OpsJobError::InvalidTransition {
from: format!("{other:?}"),
to: "Running".into(),
}),
}
}
pub fn advance_phase(
&mut self,
job_id: &str,
phase: u32,
progress: impl Into<String>,
) -> Result<&OpsJob, OpsJobError> {
let job = self.jobs.get_mut(job_id).ok_or(OpsJobError::NotFound)?;
if job.state != OpsJobState::Running {
return Err(OpsJobError::InvalidTransition {
from: format!("{:?}", job.state),
to: "advance_phase".into(),
});
}
job.phase = phase;
job.progress = progress.into();
Ok(job)
}
pub fn succeed(&mut self, job_id: &str) -> Result<&OpsJob, OpsJobError> {
let job = self.jobs.get_mut(job_id).ok_or(OpsJobError::NotFound)?;
if job.state != OpsJobState::Running {
return Err(OpsJobError::InvalidTransition {
from: format!("{:?}", job.state),
to: "Succeeded".into(),
});
}
job.state = OpsJobState::Succeeded;
job.progress = "succeeded".into();
Ok(job)
}
pub fn pause(&mut self, job_id: &str) -> Result<&OpsJob, OpsJobError> {
let job = self.jobs.get_mut(job_id).ok_or(OpsJobError::NotFound)?;
if job.state != OpsJobState::Running {
return Err(OpsJobError::InvalidTransition {
from: format!("{:?}", job.state),
to: "Paused".into(),
});
}
job.state = OpsJobState::Paused;
Ok(job)
}
pub fn get(&self, job_id: &str) -> Option<&OpsJob> {
self.jobs.get(job_id)
}
pub fn list(&self) -> Vec<&OpsJob> {
self.jobs.values().collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum OpsJobError {
#[error("ops job not found")]
NotFound,
#[error("invalid transition from {from} to {to}")]
InvalidTransition {
from: String,
to: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backup_job_is_resumable_across_phases() {
let mut store = OpsJobStore::new();
let job = store.submit(OpsJobKind::Backup, BTreeMap::new());
let id = job.job_id.clone();
store.start(&id).unwrap();
store.advance_phase(&id, 1, "pin meta").unwrap();
store.advance_phase(&id, 2, "tablet snapshots").unwrap();
store.pause(&id).unwrap();
store.start(&id).unwrap();
assert_eq!(store.get(&id).unwrap().phase, 2);
store.advance_phase(&id, 6, "publish manifest").unwrap();
store.succeed(&id).unwrap();
assert_eq!(store.get(&id).unwrap().state, OpsJobState::Succeeded);
}
#[test]
fn all_spec_ops_kinds_exist() {
assert_eq!(OpsJobKind::ALL.len(), 10);
assert!(OpsJobKind::ALL.iter().any(|k| k.name() == "key_rotation"));
}
}