use std::collections::HashMap;
use std::panic::{self, AssertUnwindSafe};
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender};
use std::sync::{Mutex, MutexGuard};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use uuid::Uuid;
use super::ejecucion;
use super::{Contexto, ErrorDelMotor};
use crate::db::entities::Ejecucion;
pub(crate) const CAPACIDAD_DE_LA_COLA: usize = 64;
const MOTIVO_DEL_PANICO: &str = "la copia se ha interrumpido por un pánico del trabajador: \
mira el registro del proceso para ver qué lo provocó";
pub(crate) struct Cola {
estado: Mutex<EstadoDeLaCola>,
}
struct EstadoDeLaCola {
parada: bool,
buzones: HashMap<Uuid, Buzon>,
}
struct Buzon {
emisor: SyncSender<Encolada>,
terminado: Receiver<()>,
hilo: JoinHandle<()>,
}
struct Encolada {
ejecucion: Ejecucion,
repository_id: Uuid,
}
struct FinDelTrabajador {
terminado: Receiver<()>,
hilo: JoinHandle<()>,
}
impl Cola {
pub(crate) fn nueva() -> Self {
Self {
estado: Mutex::new(EstadoDeLaCola {
parada: false,
buzones: HashMap::new(),
}),
}
}
pub(crate) fn esta_parada(&self) -> bool {
self.estado().parada
}
pub(crate) fn encolar(
&self,
contexto: &Contexto,
repository_id: Uuid,
ejecucion_a_encolar: Ejecucion,
) -> Result<(), ErrorDelMotor> {
let mut estado = self.estado();
if estado.parada {
return Err(ErrorDelMotor::MotorParado);
}
let buzon = match estado.buzones.entry(repository_id) {
std::collections::hash_map::Entry::Occupied(entrada) => entrada.into_mut(),
std::collections::hash_map::Entry::Vacant(hueco) => {
hueco.insert(Buzon::nuevo(contexto, repository_id)?)
}
};
buzon
.emisor
.try_send(Encolada {
ejecucion: ejecucion_a_encolar,
repository_id,
})
.map_err(|causa| match causa {
mpsc::TrySendError::Full(_) => {
ErrorDelMotor::ColaDelRepositorioLlena { repository_id }
}
mpsc::TrySendError::Disconnected(_) => ErrorDelMotor::SinQuienAtiendaLaCola {
repository_id,
motivo: "su trabajador ya no está".to_owned(),
},
})
}
pub(crate) fn parar(&self, espera: Duration) {
let Some(pendientes) = self.soltar() else {
return;
};
let limite = Instant::now() + espera;
let mut terminados = Vec::new();
let mut abandonados = 0_usize;
for trabajador in pendientes {
let restante = limite.saturating_duration_since(Instant::now());
match trabajador.terminado.recv_timeout(restante) {
Ok(()) => terminados.push(trabajador.hilo),
Err(RecvTimeoutError::Disconnected) => terminados.push(trabajador.hilo),
Err(RecvTimeoutError::Timeout) => {
abandonados += 1;
tracing::warn!(
"un trabajador no ha terminado dentro del plazo de parada: su copia sigue \
en marcha y el proceso acaba sin esperarlo más"
);
}
}
}
for hilo in terminados {
if hilo.join().is_err() {
tracing::warn!("un trabajador ha terminado con un pánico");
}
}
if abandonados == 0 {
tracing::info!("motor parado: no queda ningún trabajador en vuelo dentro del plazo");
} else {
tracing::warn!(
abandonados,
"motor parado: hay {abandonados} trabajadores todavía en vuelo y el proceso acaba \
sin esperarlos más"
);
}
}
fn soltar(&self) -> Option<Vec<FinDelTrabajador>> {
let mut estado = self.estado();
if estado.parada {
return None;
}
estado.parada = true;
Some(
std::mem::take(&mut estado.buzones)
.into_values()
.map(Buzon::soltar)
.collect(),
)
}
fn estado(&self) -> MutexGuard<'_, EstadoDeLaCola> {
match self.estado.lock() {
Ok(guardado) => guardado,
Err(veneno) => veneno.into_inner(),
}
}
}
impl Buzon {
fn nuevo(contexto: &Contexto, repository_id: Uuid) -> Result<Self, ErrorDelMotor> {
let (emisor, receptor) = mpsc::sync_channel(CAPACIDAD_DE_LA_COLA);
let (aviso, terminado) = mpsc::channel();
let contexto = contexto.clone();
let hilo = std::thread::Builder::new()
.name(format!("kesto-trabajador-{repository_id}"))
.spawn(move || trabajo_secuencial(contexto, receptor, aviso))
.map_err(|causa| ErrorDelMotor::SinQuienAtiendaLaCola {
repository_id,
motivo: format!("no se ha podido crear el hilo del trabajador: {causa}"),
})?;
Ok(Self {
emisor,
terminado,
hilo,
})
}
fn soltar(self) -> FinDelTrabajador {
let Self {
emisor,
terminado,
hilo,
} = self;
drop(emisor);
FinDelTrabajador { terminado, hilo }
}
}
fn trabajo_secuencial(contexto: Contexto, receptor: Receiver<Encolada>, aviso: mpsc::Sender<()>) {
while let Ok(encolada) = receptor.recv() {
atender(&contexto, &encolada);
}
let _ = aviso.send(());
}
fn atender(contexto: &Contexto, encolada: &Encolada) {
let intento = panic::catch_unwind(AssertUnwindSafe(|| {
ejecucion::ejecutar(contexto, &encolada.ejecucion, encolada.repository_id);
}));
if let Err(payload) = intento {
let detalle = texto_del_panico(&payload);
tracing::error!(
execution_id = %encolada.ejecucion.id,
job_id = %encolada.ejecucion.job_id,
detalle,
"la ejecución se ha interrumpido por un pánico: se apunta como fallida y el \
trabajador sigue con la siguiente"
);
ejecucion::fallar_si_no_termino(contexto, &encolada.ejecucion.id, MOTIVO_DEL_PANICO);
}
}
fn texto_del_panico(payload: &Box<dyn std::any::Any + Send>) -> String {
if let Some(texto) = payload.downcast_ref::<&'static str>() {
return (*texto).to_owned();
}
if let Some(texto) = payload.downcast_ref::<String>() {
return texto.clone();
}
"un pánico sin texto".to_owned()
}
#[cfg(test)]
mod tests {
use super::{Cola, texto_del_panico};
#[test]
fn una_cola_se_para_una_sola_vez() {
let cola = Cola::nueva();
assert!(!cola.esta_parada(), "una cola recién hecha acepta trabajo");
cola.parar(std::time::Duration::from_millis(10));
assert!(
cola.esta_parada(),
"después de parar, la cola tiene que dejar de aceptar trabajo"
);
cola.parar(std::time::Duration::from_millis(10));
assert!(cola.esta_parada(), "la segunda parada reabrió la cola");
}
#[test]
fn el_texto_de_un_panico_se_recoge_en_sus_dos_formas() {
let estatico: Box<dyn std::any::Any + Send> = Box::new("un texto de pánico");
let formateado: Box<dyn std::any::Any + Send> = Box::new(String::from("otro texto"));
assert_eq!(texto_del_panico(&estatico), "un texto de pánico");
assert_eq!(texto_del_panico(&formateado), "otro texto");
let propio: Box<dyn std::any::Any + Send> = Box::new(7_u8);
assert_eq!(
texto_del_panico(&propio),
"un pánico sin texto",
"un payload que no es texto tiene que decirse sin publicarse"
);
}
}