use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use sqlx::SqlitePool;
use thiserror::Error;
use tokio::sync::Mutex;
use tokio_cron_scheduler::{Job, JobScheduler, JobSchedulerError, JobToRunAsync};
use uuid::Uuid;
use super::validacion::validar;
use crate::db::DbError;
use crate::db::entities::Tareas;
use crate::engine::Motor;
pub const PERIODO_DE_RECONCILIACION: Duration = Duration::from_secs(30);
pub const PLAZO_DE_PARADA: Duration = Duration::from_secs(2);
#[derive(Clone)]
pub struct Ajustes {
pub periodo: Duration,
}
impl Ajustes {
pub fn nuevos() -> Self {
Self {
periodo: PERIODO_DE_RECONCILIACION,
}
}
pub fn con_periodo(mut self, periodo: Duration) -> Self {
self.periodo = periodo;
self
}
}
#[derive(Debug, Error)]
pub enum ErrorDelPlanificador {
#[error("el reloj del planificador no ha respondido: {motivo}")]
Reloj {
motivo: String,
},
#[error("la base no ha podido leerse para planificar: {causa}")]
Base {
#[source]
causa: DbError,
},
}
#[derive(Clone)]
pub struct Planificador(pub(super) Arc<Interior>);
pub(super) struct Interior {
pub(super) motor: Motor,
pub(super) tareas: Tareas,
pub(super) ajustes: Ajustes,
pub(super) reloj: Mutex<JobScheduler>,
pub(super) abierta: AtomicBool,
pub(super) estado: Mutex<Estado>,
}
impl Interior {
pub(super) fn sigue_abierta(&self) -> bool {
self.abierta.load(Ordering::SeqCst)
}
pub(super) fn cerrar_la_puerta(&self) {
self.abierta.store(false, Ordering::SeqCst);
}
}
pub(super) struct Estado {
pub(super) planificadas: HashMap<Uuid, Uuid>,
pub(super) expresiones: HashMap<Uuid, String>,
}
impl Estado {
pub(super) fn aplicar_retirada(
&mut self,
job_id: Uuid,
desenlace: Result<(), JobSchedulerError>,
) -> Result<(), JobSchedulerError> {
match desenlace {
Ok(()) => {
self.planificadas.remove(&job_id);
self.expresiones.remove(&job_id);
Ok(())
}
Err(error) => Err(error),
}
}
}
impl Planificador {
pub async fn arrancar(
motor: Motor,
base: SqlitePool,
ajustes: Ajustes,
) -> Result<Self, ErrorDelPlanificador> {
let reloj = JobScheduler::new()
.await
.map_err(|error| ErrorDelPlanificador::Reloj {
motivo: error.to_string(),
})?;
let planificador = Self(Arc::new(Interior {
motor,
tareas: Tareas::nuevo(base),
ajustes,
reloj: Mutex::new(reloj),
abierta: AtomicBool::new(true),
estado: Mutex::new(Estado {
planificadas: HashMap::new(),
expresiones: HashMap::new(),
}),
}));
planificador.sincronizar().await?;
planificador
.0
.reloj
.lock()
.await
.start()
.await
.map_err(|error| ErrorDelPlanificador::Reloj {
motivo: error.to_string(),
})?;
tracing::info!(
tareas = planificador.planificadas().len(),
"planificador en marcha"
);
Ok(planificador)
}
async fn sincronizar(&self) -> Result<(), ErrorDelPlanificador> {
let tareas = self
.0
.tareas
.listar()
.await
.map_err(|causa| ErrorDelPlanificador::Base { causa })?;
let mut estado = self.0.estado.lock().await;
if !self.0.sigue_abierta() {
return Ok(());
}
let mut deseadas: HashMap<Uuid, String> = HashMap::new();
for tarea in &tareas {
if !tarea.enabled {
continue;
}
match validar(&tarea.cron_expression) {
Ok(agenda) => {
deseadas.insert(tarea.id, agenda.normalizada().to_owned());
}
Err(error) => {
tracing::error!(
job_id = %tarea.id,
cron_expression = %tarea.cron_expression,
motivo = %error.motivo(),
"la expresión de la tarea no se puede interpretar: se salta su planificación"
);
}
}
}
let mut reloj = self.0.reloj.lock().await;
let planificadas: Vec<Uuid> = estado.planificadas.keys().copied().collect();
for job_id in planificadas {
match deseadas.get(&job_id) {
None => {
if let Some(mango) = estado.planificadas.get(&job_id).copied()
&& let Err(error) =
estado.aplicar_retirada(job_id, reloj.remove(&mango).await)
{
tracing::warn!(
job_id = %job_id,
%error,
"el mango de una tarea que sobra no se ha podido quitar del reloj: se \
reintentará en la siguiente pasada"
);
}
}
Some(expresion) => {
let igual = estado
.expresiones
.get(&job_id)
.is_some_and(|con_la_que_esta| con_la_que_esta == expresion);
if !igual {
if let Some(mango_viejo) = estado.planificadas.get(&job_id).copied()
&& let Err(error) =
estado.aplicar_retirada(job_id, reloj.remove(&mango_viejo).await)
{
tracing::warn!(
job_id = %job_id,
%error,
"el mango viejo no se ha podido quitar del reloj al reemplazar la \
planificación: no se planifica la nueva hasta confirmarlo, y se \
reintentará en la siguiente pasada"
);
}
}
}
}
}
for (job_id, expresion) in &deseadas {
if estado.planificadas.contains_key(job_id) {
continue;
}
let mango = self.planificar_una(&mut reloj, *job_id, expresion).await?;
estado.planificadas.insert(*job_id, mango);
estado.expresiones.insert(*job_id, expresion.clone());
}
Ok(())
}
async fn planificar_una(
&self,
reloj: &mut JobScheduler,
job_id: Uuid,
expresion: &str,
) -> Result<Uuid, ErrorDelPlanificador> {
let planificador = self.clone();
let disparo: Box<JobToRunAsync> = Box::new(
move |_mango: Uuid, _reloj: JobScheduler| -> Pin<Box<dyn Future<Output = ()> + Send>> {
let planificador = planificador.clone();
Box::pin(async move {
planificador.disparar(job_id).await;
})
},
);
let job =
Job::new_async(expresion, disparo).map_err(|error| ErrorDelPlanificador::Reloj {
motivo: error.to_string(),
})?;
reloj
.add(job)
.await
.map_err(|error| ErrorDelPlanificador::Reloj {
motivo: error.to_string(),
})
}
pub async fn reconciliar(&self) -> Result<(), ErrorDelPlanificador> {
match self.sincronizar().await {
Ok(()) => Ok(()),
Err(ErrorDelPlanificador::Base { causa }) => Err(ErrorDelPlanificador::Base { causa }),
Err(error @ ErrorDelPlanificador::Reloj { .. }) => {
tracing::warn!(
%error,
"el reloj del planificador ha fallado al reconciliar: se reintentará en el \
siguiente período"
);
Ok(())
}
}
}
pub fn vigilar(&self) {
let planificador = self.clone();
let periodo = self.0.ajustes.periodo;
tokio::spawn(async move {
let mut intervalo = tokio::time::interval(periodo);
intervalo.tick().await;
loop {
intervalo.tick().await;
if planificador.esta_parado() {
return;
}
if let Err(error) = planificador.reconciliar().await {
tracing::warn!(
%error,
"la reconciliación periódica no ha podido leer la base"
);
}
}
});
}
pub async fn parar(&self, plazo: Duration) {
self.0.cerrar_la_puerta();
match tokio::time::timeout(plazo, self.0.estado.lock()).await {
Ok(_guardia) => {}
Err(_) => {
tracing::warn!(
plazo = ?plazo,
"el planificador no ha drenado su disparo en vuelo dentro del plazo: se apaga \
igualmente (la puerta ya está cerrada)"
);
}
}
match tokio::time::timeout(plazo, self.0.reloj.lock()).await {
Ok(mut reloj) => {
if let Err(error) = reloj.shutdown().await {
tracing::warn!(%error, "el reloj de la biblioteca no se ha apagado limpiamente");
}
}
Err(_) => {
tracing::warn!(
plazo = ?plazo,
"el candado del reloj no se ha podido tomar dentro del plazo para apagarlo: se \
continúa con el apagado"
);
}
}
tracing::info!("planificador parado");
}
pub fn planificadas(&self) -> Vec<Uuid> {
match self.0.estado.try_lock() {
Ok(estado) => {
let mut ids: Vec<Uuid> = estado.planificadas.keys().copied().collect();
ids.sort();
ids
}
Err(_) => Vec::new(),
}
}
pub fn esta_parado(&self) -> bool {
!self.0.sigue_abierta()
}
#[cfg(test)]
pub(super) fn mangos(&self) -> Vec<Uuid> {
match self.0.estado.try_lock() {
Ok(estado) => {
let mut mangos: Vec<Uuid> = estado.planificadas.values().copied().collect();
mangos.sort();
mangos
}
Err(_) => Vec::new(),
}
}
}
#[cfg(test)]
mod tests {
use std::time::{Duration, Instant};
use uuid::Uuid;
use super::Planificador;
use crate::scheduler::pruebas::{Aparato, CRON_DE_CINCO};
use crate::test_support::capturar_log;
const PLAZO_DE_PARADA: Duration = Duration::from_secs(1);
const PLAZO_DEL_TICK: Duration = Duration::from_secs(5);
const INTERVALO_DE_MIRADA: Duration = Duration::from_millis(50);
const PLAZO_DEL_FINAL: Duration = Duration::from_secs(30);
async fn proxima_del_reloj(planificador: &Planificador, mango: Uuid) -> Option<String> {
let mut reloj = planificador.0.reloj.lock().await;
reloj
.next_tick_for_job(mango)
.await
.expect("el reloj tiene que poder decir cuándo dispara su tarea")
.map(|cuando| format!("{cuando:?}"))
}
#[tokio::test]
async fn una_tarea_activa_queda_planificada_al_arrancar() {
let aparato = Aparato::nuevo().await;
let tarea = aparato.tarea(CRON_DE_CINCO, true).await;
let planificador = aparato.planificador().await;
assert!(
planificador.planificadas().contains(&tarea),
"la tarea activa no quedó planificada: {:?}",
planificador.planificadas()
);
let mangos = planificador.mangos();
assert_eq!(
mangos.len(),
1,
"la tarea planificada no tiene un mango vivo en el reloj: {mangos:?}"
);
assert!(
proxima_del_reloj(&planificador, mangos[0]).await.is_some(),
"la tarea está planificada pero el reloj no le ha calculado su próxima ejecución"
);
}
#[tokio::test]
async fn una_tarea_desactivada_no_se_planifica_ni_crea_ejecucion() {
let aparato = Aparato::nuevo().await;
let activa = aparato.tarea(CRON_DE_CINCO, true).await;
let desactivada = aparato.tarea(CRON_DE_CINCO, false).await;
let planificador = aparato.planificador().await;
assert!(
planificador.planificadas().contains(&activa),
"la tarea activa de la misma base no quedó planificada: {:?}",
planificador.planificadas()
);
assert!(
!planificador.planificadas().contains(&desactivada),
"una tarea con `enabled = 0` quedó planificada: {:?}",
planificador.planificadas()
);
planificador
.reconciliar()
.await
.expect("una reconciliación sin fallo de base no puede dar error");
assert!(
!planificador.planificadas().contains(&desactivada),
"la reconciliación planificó una tarea con `enabled = 0`"
);
assert_eq!(
aparato.ejecuciones_de(&desactivada).await,
0,
"una tarea desactivada creó una ejecución"
);
}
#[tokio::test]
async fn una_expresion_invalida_se_registra_y_las_demas_siguen_planificadas() {
let aparato = Aparato::nuevo().await;
let buena_una = aparato.tarea(CRON_DE_CINCO, true).await;
let mala = aparato.tarea("a b c d e", true).await;
let buena_dos = aparato.tarea("30 4 * * *", true).await;
let (buffer, guardia) = capturar_log();
let planificador = aparato.planificador().await;
let registro = buffer.texto();
drop(guardia);
assert!(
registro.contains(&mala.to_string()),
"el error de la expresión inválida no nombra la tarea: {registro}"
);
assert!(
registro.contains("a b c d e"),
"el error de la expresión inválida no nombra la expresión: {registro}"
);
assert!(
planificador.planificadas().contains(&buena_una)
&& planificador.planificadas().contains(&buena_dos),
"las dos tareas válidas no quedaron planificadas: {:?}",
planificador.planificadas()
);
assert!(
!planificador.planificadas().contains(&mala),
"la tarea con la expresión inválida quedó planificada: {:?}",
planificador.planificadas()
);
}
#[tokio::test]
async fn una_tarea_nueva_entra_en_la_planificacion_al_reconciliar() {
let aparato = Aparato::nuevo().await;
let planificador = aparato.planificador().await;
assert!(
planificador.planificadas().is_empty(),
"el planificador arrancó con tareas que no existían: {:?}",
planificador.planificadas()
);
let nueva = aparato.tarea(CRON_DE_CINCO, true).await;
planificador
.reconciliar()
.await
.expect("una reconciliación sin fallo de base no puede dar error");
assert!(
planificador.planificadas().contains(&nueva),
"la tarea dada de alta después de arrancar no entró en la planificación: {:?}",
planificador.planificadas()
);
}
#[tokio::test]
async fn un_borrado_sale_de_la_planificacion_al_reconciliar() {
let aparato = Aparato::nuevo().await;
let tarea = aparato.tarea(CRON_DE_CINCO, true).await;
let planificador = aparato.planificador().await;
assert!(planificador.planificadas().contains(&tarea));
assert!(
aparato
.tareas
.borrar(&tarea)
.await
.expect("el borrado de la tarea no puede fallar"),
"la tarea de la prueba tenía que existir para borrarse"
);
planificador
.reconciliar()
.await
.expect("una reconciliación sin fallo de base no puede dar error");
assert!(
!planificador.planificadas().contains(&tarea),
"la tarea borrada siguió planificada: {:?}",
planificador.planificadas()
);
assert!(
planificador.mangos().is_empty(),
"el borrado dejó un mango vivo en el reloj: {:?}",
planificador.mangos()
);
assert_eq!(
aparato.ejecuciones_de(&tarea).await,
0,
"una tarea borrada no puede tener ejecuciones nuevas"
);
}
#[tokio::test]
async fn un_cambio_de_cron_reemplaza_la_planificacion_sin_duplicarla() {
let aparato = Aparato::nuevo().await;
let tarea = aparato.tarea(CRON_DE_CINCO, true).await;
let planificador = aparato.planificador().await;
let mangos_de_antes = planificador.mangos();
assert_eq!(
mangos_de_antes.len(),
1,
"la tarea tenía que estar planificada una sola vez: {mangos_de_antes:?}"
);
let mango_viejo = mangos_de_antes[0];
let antes = proxima_del_reloj(&planificador, mango_viejo).await;
assert!(
antes.is_some(),
"la tarea planificada no tenía próxima ejecución"
);
aparato.cambiar_cron(&tarea, "30 4 * * *").await;
planificador
.reconciliar()
.await
.expect("una reconciliación sin fallo de base no puede dar error");
let mangos_de_despues = planificador.mangos();
assert_eq!(
mangos_de_despues.len(),
1,
"el cambio de expresión dejó la tarea planificada dos veces: {mangos_de_despues:?}"
);
assert!(
planificador.planificadas().contains(&tarea),
"la tarea con la expresión nueva dejó de estar planificada"
);
let mango_nuevo = mangos_de_despues[0];
assert_ne!(
mango_nuevo, mango_viejo,
"el reemplazo reutilizó el mango viejo"
);
assert!(
proxima_del_reloj(&planificador, mango_viejo)
.await
.is_none(),
"el mango de la planificación vieja sigue vivo en el reloj"
);
let despues = proxima_del_reloj(&planificador, mango_nuevo).await;
assert!(
despues.is_some(),
"la planificación nueva no tiene próxima ejecución"
);
assert_ne!(
despues, antes,
"la próxima ejecución no es la de la expresión nueva: sigue siendo la de la vieja"
);
}
#[tokio::test]
async fn desactivar_retira_la_planificacion_y_reactivar_la_devuelve() {
let aparato = Aparato::nuevo().await;
let tarea = aparato.tarea(CRON_DE_CINCO, true).await;
let planificador = aparato.planificador().await;
assert!(planificador.planificadas().contains(&tarea));
aparato.cambiar_enabled(&tarea, false).await;
planificador
.reconciliar()
.await
.expect("una reconciliación sin fallo de base no puede dar error");
assert!(
!planificador.planificadas().contains(&tarea),
"una tarea con `enabled = 0` siguió planificada: {:?}",
planificador.planificadas()
);
aparato.cambiar_enabled(&tarea, true).await;
planificador
.reconciliar()
.await
.expect("una reconciliación sin fallo de base no puede dar error");
assert!(
planificador.planificadas().contains(&tarea),
"la tarea reactivada no volvió a la planificación: {:?}",
planificador.planificadas()
);
}
#[tokio::test]
async fn tras_parar_no_se_crea_ninguna_ejecucion_nueva() {
let aparato = Aparato::nuevo().await;
let tarea = aparato.tarea(CRON_DE_CINCO, true).await;
let planificador = aparato.planificador().await;
assert!(planificador.planificadas().contains(&tarea));
planificador.parar(PLAZO_DE_PARADA).await;
assert!(
planificador.esta_parado(),
"tras parar, el planificador sigue aceptando trabajo"
);
planificador.disparar(tarea).await;
assert_eq!(
aparato.ejecuciones_de(&tarea).await,
0,
"la parada del planificador dejó una ejecución creada"
);
}
#[tokio::test]
async fn un_tick_real_del_reloj_crea_la_ejecucion_de_su_tarea() {
let aparato = Aparato::nuevo().await;
let tarea = aparato.tarea("* * * * * *", true).await;
let planificador = aparato.planificador().await;
let limite = Instant::now() + PLAZO_DEL_TICK;
while aparato.ejecuciones_de(&tarea).await == 0 {
assert!(
Instant::now() < limite,
"el tick del reloj no creó la ejecución de su tarea en {PLAZO_DEL_TICK:?}: el \
disparo de la biblioteca no llega a la tarea de Kesto"
);
tokio::time::sleep(INTERVALO_DE_MIRADA).await;
}
let limite = Instant::now() + PLAZO_DEL_FINAL;
loop {
let estado = aparato.estado_de_la_ejecucion(&tarea).await;
if estado == "success" || estado == "failed" {
break;
}
assert!(
Instant::now() < limite,
"la ejecución que creó el tick no llegó a un estado terminal en {PLAZO_DEL_FINAL:?}: \
{estado}"
);
tokio::time::sleep(INTERVALO_DE_MIRADA).await;
}
planificador.parar(PLAZO_DE_PARADA).await;
}
const PLAZO_QUE_VENCE: Duration = Duration::from_millis(10);
#[tokio::test]
async fn la_puerta_queda_cerrada_aunque_el_drenaje_venza_el_plazo() {
let aparato = Aparato::nuevo().await;
let planificador = aparato.planificador().await;
let guardia = planificador.0.estado.lock().await;
planificador.parar(PLAZO_QUE_VENCE).await;
assert!(
planificador.esta_parado(),
"la puerta quedó abierta porque el plazo venció antes de adquirir el candado"
);
drop(guardia);
}
#[tokio::test]
async fn un_disparo_tras_una_parada_que_vencio_el_plazo_no_encola() {
let aparato = Aparato::nuevo().await;
let tarea = aparato.tarea(CRON_DE_CINCO, true).await;
let planificador = aparato.planificador().await;
let guardia = planificador.0.estado.lock().await;
planificador.parar(PLAZO_QUE_VENCE).await;
drop(guardia);
planificador.disparar(tarea).await;
assert_eq!(
aparato.ejecuciones_de(&tarea).await,
0,
"un disparo tras una parada que venció el plazo creó una ejecución"
);
}
#[test]
fn una_retirada_que_el_reloj_no_confirma_deja_el_mango_en_la_tabla() {
use std::collections::HashMap;
use tokio_cron_scheduler::JobSchedulerError;
use super::Estado;
let job_id = Uuid::new_v4();
let mango = Uuid::new_v4();
let mut estado = Estado {
planificadas: HashMap::from([(job_id, mango)]),
expresiones: HashMap::from([(job_id, CRON_DE_CINCO.to_owned())]),
};
let fallo = estado.aplicar_retirada(job_id, Err(JobSchedulerError::RemoveShutdownNotifier));
assert!(
fallo.is_err(),
"una retirada que el reloj no confirma no puede darse por hecha"
);
assert_eq!(
estado.planificadas.get(&job_id),
Some(&mango),
"la retirada no confirmada perdió el mango: la tarea seguiría planificada y sin forma \
de retirarla"
);
assert!(
estado.expresiones.contains_key(&job_id),
"la retirada no confirmada borró la expresión: la siguiente pasada no podría reintentar"
);
assert!(
estado.aplicar_retirada(job_id, Ok(())).is_ok(),
"una retirada confirmada no puede dar error"
);
assert!(
estado.planificadas.is_empty() && estado.expresiones.is_empty(),
"la retirada confirmada no quitó la entrada de la tabla: {:?}",
estado.planificadas
);
}
}