Back to sh0
sh0

Cron Jobs y entornos de vista previa: dos funcionalidades, cero tiempo de inactividad

Cómo construimos la programación de cron jobs con imposición de timeout y entornos de vista previa con integración de webhooks de PR -- desarrollados en paralelo usando aislamiento con git worktree.

Thales & Claude | March 30, 2026 9 min sh0
EN/ FR/ ES
cronpreview-environmentswebhooksdeploymentrustpaas

Algunas funcionalidades se construyen mejor en solitario. Te enfocas en un problema, razonas sobre sus casos límite, escribes el código, escribes los tests y lo envías. Otras funcionalidades se construyen mejor en paralelo -- cuando tocan dominios completamente diferentes y no comparten ningún archivo, ejecutarlas concurrentemente reduce el tiempo calendario a la mitad sin introducir conflictos de merge.

Las Fases 20 y 21 del desarrollo de sh0 fueron una construcción paralela. El sistema de exportación generaba configuraciones de despliegue para siete plataformas (Vercel, AWS, GCP, Kubernetes, Railway, Render, Docker Compose). El programador de cron ejecutaba trabajos recurrentes dentro de contenedores de aplicación con imposición de timeout. No compartían código, tablas de base de datos ni rutas API. Los ejecutamos como equipos de agentes paralelos en worktrees git aislados.

Este artículo se enfoca en el programador de cron y los entornos de vista previa -- dos funcionalidades que dieron a los usuarios de sh0 las herramientas que necesitaban para automatizar tareas recurrentes y probar pull requests antes de fusionar.

El programador de cron

Toda aplicación no trivial tiene tareas recurrentes: limpieza de base de datos, generación de informes, precalentamiento de caché, resúmenes por correo, reconstrucción de sitemaps. El enfoque estándar es un crontab del sistema, pero en un PaaS, los usuarios no tienen acceso SSH para editar crontabs. Necesitan un servicio de cron gestionado con interfaz, historial de ejecución y notificaciones de fallos.

El modelo de datos

Los cron jobs vivían junto a las aplicaciones en la base de datos. Cada trabajo pertenecía a una aplicación y especificaba una expresión cron, un comando a ejecutar y parámetros operacionales:

sql-- Migración 008: tabla cron_runs
CREATE TABLE cron_runs (
    id TEXT PRIMARY KEY,
    cron_job_id TEXT NOT NULL REFERENCES cron_jobs(id),
    status TEXT NOT NULL DEFAULT 'pending',
    started_at TEXT,
    finished_at TEXT,
    exit_code INTEGER,
    stdout TEXT,
    stderr TEXT,
    error TEXT,
    created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

La tabla cron_jobs (creada en una migración anterior) almacenaba la definición de la programación. La tabla cron_runs almacenaba el historial de ejecución -- una fila por invocación, con stdout, stderr, código de salida y tiempos capturados. Esta separación significaba que editar una programación cron no perdía su historial de ejecución.

El bucle del programador

El CronScheduler era una tarea en segundo plano que se ejecutaba cada 60 segundos. En cada ciclo, cargaba todos los cron jobs habilitados, verificaba cuáles estaban pendientes basándose en su expresión cron y último tiempo de ejecución, y generaba tareas de ejecución para los que necesitaban ejecutarse:

rustpub struct CronScheduler {
    db: Arc<DbPool>,
    docker: Arc<DockerClient>,
    processing: DashMap<String, bool>,
}

impl CronScheduler {
    pub async fn tick(&self) -> Result<()> {
        let jobs = CronJob::list_enabled(&self.db).await?;

        for job in jobs {
            // Guardia: omitir si ya está procesando
            if self.processing.contains_key(&job.id) {
                continue;
            }

            if self.is_due(&job)? {
                self.processing.insert(job.id.clone(), true);
                let db = self.db.clone();
                let docker = self.docker.clone();
                let processing = self.processing.clone();

                tokio::spawn(async move {
                    let result = execute_job(&db, &docker, &job).await;
                    processing.remove(&job.id);
                    if let Err(e) = result {
                        tracing::error!(job_id = %job.id, "Cron execution failed: {e}");
                    }
                });
            }
        }

        Ok(())
    }
}

La guardia de procesamiento DashMap era esencial. Sin ella, un trabajo que tardara más de 60 segundos en ejecutarse se generaría de nuevo en el siguiente ciclo, llevando a ejecuciones superpuestas. La guardia aseguraba que cada trabajo tuviera como máximo una ejecución activa en cualquier momento.

Normalización de expresiones cron

El crate cron en Rust espera expresiones cron de 7 campos (segundos, minutos, horas, día-del-mes, mes, día-de-la-semana, año), pero los usuarios escriben expresiones de 5 campos (minutos, horas, día-del-mes, mes, día-de-la-semana) -- el formato estándar usado por cada crontab existente.

Normalizamos la entrada del usuario anteponiendo 0 (segundos) y añadiendo * (año):

rustfn normalize_cron(expr: &str) -> String {
    let fields: Vec<&str> = expr.trim().split_whitespace().collect();
    match fields.len() {
        5 => format!("0 {} *", expr),  // 5 campos -> 7 campos
        6 => format!("0 {}", expr),     // 6 campos -> 7 campos
        7 => expr.to_string(),          // Ya 7 campos
        _ => expr.to_string(),          // Dejar que el parser capture el error
    }
}

Imposición de timeout

Los cron jobs de larga ejecución son una fuente común de agotamiento de recursos. Un script de respaldo que se cuelga, un generador de informes que entra en un bucle infinito, una tarea de limpieza que bloquea una tabla indefinidamente -- cualquiera de estos puede consumir recursos que la aplicación necesita para servir tráfico.

Cada cron job tenía un timeout configurable. La función de ejecución usaba tokio::time::timeout para imponerlo:

rustasync fn execute_job(
    db: &DbPool,
    docker: &DockerClient,
    job: &CronJob,
) -> Result<()> {
    let run = CronRun::create(db, &job.id).await?;
    CronJob::update_run_status(db, &job.id, "running").await?;

    let timeout_duration = Duration::from_secs(job.timeout_seconds as u64);

    let result = tokio::time::timeout(
        timeout_duration,
        docker.exec_in_container(&job.container_id, &job.command),
    ).await;

    match result {
        Ok(Ok(output)) => {
            // Truncar stdout/stderr a 64KB para prevenir inflamiento de BD
            let stdout = truncate(&output.stdout, 64 * 1024);
            let stderr = truncate(&output.stderr, 64 * 1024);
            CronRun::complete(db, &run.id, output.exit_code, &stdout, &stderr).await?;
        }
        Ok(Err(e)) => {
            CronRun::fail(db, &run.id, &format!("Execution error: {e}")).await?;
        }
        Err(_) => {
            CronRun::fail(db, &run.id, "Timeout exceeded").await?;
        }
    }

    // Podar ejecuciones antiguas (mantener últimas 100)
    CronRun::prune(db, &job.id, 100).await?;

    Ok(())
}

El truncamiento de 64 KB en stdout y stderr prevenía que un log desbordado inflara la base de datos. El paso de poda mantenía solo las últimas 100 ejecuciones por trabajo, asegurando que el historial de ejecución fuera útil pero acotado.

La API de cron y el panel

El sistema de cron exponía una API CRUD completa:

POST   /api/v1/cron-jobs              -- Crear un cron job
GET    /api/v1/cron-jobs              -- Listar todos los cron jobs
GET    /api/v1/apps/:id/cron-jobs     -- Listar cron jobs de una aplicación
GET    /api/v1/cron-jobs/:id          -- Obtener detalles del cron job
PATCH  /api/v1/cron-jobs/:id          -- Actualizar programación/comando/timeout
DELETE /api/v1/cron-jobs/:id          -- Eliminar cron job
POST   /api/v1/cron-jobs/:id/trigger  -- Disparar ejecución manual
GET    /api/v1/cron-jobs/:id/runs     -- Obtener historial de ejecución

El panel añadió una página dedicada de Cron Jobs accesible desde la barra lateral. Cada trabajo se mostraba como una tarjeta con la programación, comando, estado de la última ejecución y próxima ejecución programada. Un botón "Ejecutar ahora" disparaba la ejecución inmediata. Expandir un trabajo revelaba su historial de ejecución con marcas de tiempo, códigos de salida y salida truncada de stdout/stderr.

Entornos de vista previa

Los entornos de vista previa resuelven un problema de flujo de trabajo que todo equipo encuentra: quieres probar un pull request en un entorno realista antes de fusionarlo, pero levantar un servidor de prueba para cada PR es costoso y manual.

Los entornos de vista previa de sh0 funcionaban a través de integración de webhooks. Cuando un pull request se abría o actualizaba en GitHub o GitLab, el handler de webhooks:

  1. Detectaba el tipo de evento (pull_request.opened o pull_request.synchronize)
  2. Creaba una nueva aplicación con un subdominio único: pr-{número}-{nombre-app}.{dominio}
  3. Clonaba la rama del PR y la desplegaba a través del pipeline de compilación estándar
  4. Configuraba el enrutamiento Caddy para el subdominio de vista previa
  5. Publicaba un comentario en el PR con la URL de vista previa

Cuando el PR se cerraba o fusionaba, un webhook pull_request.closed disparaba la limpieza: la aplicación de vista previa se detenía, sus contenedores se eliminaban, sus volúmenes se borraban y su ruta Caddy se removía.

La URL de vista previa era determinista. El PR #42 para una aplicación llamada frontend siempre se desplegaría en pr-42-frontend.sh0.dev. Abrir el mismo PR de nuevo actualizaría la vista previa existente en lugar de crear un duplicado.

Aislamiento sin sobrecarga

Los entornos de vista previa reutilizaban el pipeline de despliegue existente por completo. Eran aplicaciones sh0 regulares con algunas propiedades especiales:

  • Su nombre se auto-generaba desde el número de PR y el nombre de la aplicación padre
  • Sus variables de entorno se heredaban de la aplicación padre con sobreescrituras
  • Su ciclo de vida estaba atado al estado del PR (abierto = desplegado, cerrado = destruido)
  • Se excluían del autoescalado y programaciones de respaldo

Esto significaba que los entornos de vista previa tenían paridad completa de funcionalidades con los despliegues de producción: dominios personalizados (si se configuraban), variables de entorno, conexiones de base de datos, montajes de volúmenes y verificaciones de salud. La vista previa no era un mock simplificado -- era la aplicación real ejecutando código real.

El sistema de exportación

Ejecutándose en paralelo con el programador de cron, el sistema de exportación era la Fase 20. Generaba configuraciones de despliegue para siete plataformas, permitiendo a los usuarios que superaran sh0 llevarse su configuración:

  • Docker Compose -- docker-compose.yml completo con servicios, volúmenes y redes
  • Vercel -- vercel.json con detección de framework
  • AWS -- Definición de tarea ECS en JSON
  • GCP -- service.yaml de Cloud Run
  • Kubernetes -- Manifiestos Deployment + Service + Ingress
  • Railway -- Configuración railway.json
  • Render -- Definición de servicio render.yaml

La declaración filosófica era intencional: sh0 no retendría tus datos como rehén. Si querías irte, el sistema de exportación te daba una ventaja en tu plataforma de destino.

Desarrollo paralelo y el merge

Ambas fases fueron desarrolladas por equipos de agentes paralelos trabajando en worktrees git aislados. El aislamiento fue posible porque las funcionalidades tenían cero superposición de archivos: diferentes tablas de base de datos, diferentes archivos de handler de API, diferentes archivos de comandos CLI, diferentes páginas de panel.

El merge al directorio de trabajo principal requirió correcciones manuales para algunos archivos compartidos:

  • types.rs necesitaba DTOs de CronJob que un agente asumió que el otro crearía
  • Un problema de patrón de referencia por préstamo en el handler de cron necesitaba una corrección .clone()
  • El router necesitaba ambos conjuntos de rutas añadidos a la misma función

Después del merge, 351 tests pasaron. La compilación del panel fue exitosa. Ambas funcionalidades funcionaban independientemente y no interferían entre sí.

El patrón de desarrollo paralelo se estaba convirtiendo en una herramienta fiable en nuestro flujo de trabajo. Las funcionalidades con cero superposición de archivos podían desarrollarse simultáneamente, reduciendo el tiempo real a la mitad. La clave era identificar el límite de superposición antes de comenzar -- y ser disciplinado en no cruzarlo.


Siguiente en la serie: Monitoreo y alertas: Email, Slack, Discord, Telegram, Webhooks -- cómo construimos un sistema de monitoreo con recolección periódica de estadísticas Docker, evaluación de alertas basada en umbrales y despacho multi-canal.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles