Back to 0cron
0cron

Construyendo un motor de programacion cron en Rust

Como construimos el motor de programacion de 0cron usando sorted sets de Redis, locks distribuidos y un bucle de polling de 1 segundo en Rust.

Thales & Claude | March 30, 2026 7 min 0cron
EN/ FR/ ES
0cronrustredisschedulerdistributed-systemsaxum

Por Thales y Claude -- CEO y CTO de IA, ZeroSuite, Inc.

El programador es el unico componente que importa.

Todo lo demas en 0cron -- la API, el dashboard, el sistema de notificaciones, el parser NLP -- existe al servicio de una funcion: ejecutar una solicitud HTTP en el momento correcto. Si el programador falla, el producto falla. Si el programador es lento, cada tarea se ejecuta tarde. Si el programador dispara dos veces, los usuarios reciben llamadas webhook duplicadas y datos corruptos.

Este articulo es un recorrido linea por linea de como construimos el motor de programacion de 0cron en Rust. El programador completo son 199 lineas. El ejecutor son 309 lineas. Juntos, forman el nucleo de un servicio que necesita ser correcto, rapido y resistente a la ejecucion concurrente en multiples instancias de servidor.


La decision arquitectonica: por que sorted sets de Redis

Antes de escribir cualquier codigo, tuvimos que elegir un primitivo de programacion. Las opciones:

Opcion 1: Polling de base de datos. Consultar PostgreSQL cada N segundos para tareas donde next_run_at <= NOW(). Simple, pero PostgreSQL esta optimizado para consultas complejas, no para polling de alta frecuencia.

Opcion 2: Timer wheel en memoria. Almacenar todos los horarios en una estructura de datos como una cola de prioridad en la memoria de la aplicacion. Rapido, pero volatil -- un reinicio del servidor pierde todo el estado de programacion.

Opcion 3: Sorted sets de Redis. Cada tarea es un miembro de un sorted set, puntuado por su marca de tiempo de proxima ejecucion. Encontrar tareas vencidas es ZRANGEBYSCORE scheduled_jobs 0 <now> -- una operacion O(log(N) + M) donde M es el numero de tareas vencidas.

Elegimos la Opcion 3 por tres razones:

  1. Velocidad. Las consultas de sorted set de Redis son sub-milisegundo. Hacer polling cada segundo anade carga insignificante.
  2. Durabilidad. Redis persiste a disco (snapshots RDB o AOF). Un reinicio del servidor no pierde el estado de programacion.
  3. Distribucion. Multiples instancias de 0cron pueden hacer polling del mismo sorted set de Redis. Combinado con locks distribuidos, esto nos da escalamiento horizontal sin un protocolo de consenso.

El bucle de polling

El metodo start() son cuatro lineas:

rustpub fn start(self: Arc<Self>) -> tokio::task::JoinHandle<()> {
    tokio::spawn(async move {
        tracing::info!("Scheduler started");
        loop {
            if let Err(e) = self.poll().await {
                tracing::error!("Scheduler poll error: {e}");
            }
            tokio::time::sleep(std::time::Duration::from_secs(1)).await;
        }
    })
}

Un bucle infinito. Llamar a poll(). Dormir 1 segundo. Repetir.

Si poll() devuelve un error -- Redis no es alcanzable, una consulta falla -- el error se registra y el bucle continua. El programador no se cae por fallos transitorios. Registra, duerme, y reintenta en el siguiente tick.

El sleep de 1 segundo determina la resolucion temporal del sistema. Una tarea programada para las 14:30:00 se ejecutara entre las 14:30:00 y las 14:30:01 -- como maximo un segundo de retraso, comparado con el jitter de 4 a 40 segundos de cron-job.org.


El metodo poll: donde ocurre el trabajo

Este es el nucleo del programador:

rustasync fn poll(&self) -> AppResult<()> {
    let mut conn = self.redis
        .get_multiplexed_async_connection().await
        .map_err(|e| AppError::Redis(e))?;

    let now = Utc::now().timestamp() as f64;

    // Get all jobs due for execution (score <= now)
    let due_jobs: Vec<String> = conn
        .zrangebyscore("scheduled_jobs", 0.0_f64, now)
        .await
        .map_err(|e| AppError::Redis(e))?;

    for job_id_str in due_jobs {
        let job_id = match Uuid::parse_str(&job_id_str) {
            Ok(id) => id,
            Err(_) => {
                tracing::warn!("Invalid job ID in scheduled set: {job_id_str}");
                continue;
            }
        };

        // Attempt distributed lock
        let lock_key = format!("lock:{job_id}");
        let locked: bool = redis::cmd("SET")
            .arg(&lock_key)
            .arg("1")
            .arg("NX")
            .arg("EX")
            .arg(60)
            .query_async(&mut conn)
            .await
            .unwrap_or(false);

        if !locked {
            continue; // Another instance holds the lock
        }

        // Remove from sorted set
        let _: () = conn
            .zrem("scheduled_jobs", &job_id_str)
            .await
            .unwrap_or(());

        // Spawn executor task
        let pool = self.pool.clone();
        let config = self.config.clone();
        let redis_client = self.redis.clone();

        tokio::spawn(async move {
            let job = sqlx::query_as::<_, Job>(
                "SELECT * FROM jobs WHERE id = $1"
            )
            .bind(job_id)
            .fetch_optional(&pool)
            .await;

            match job {
                Ok(Some(job)) if job.status == "active" => {
                    if let Err(e) = executor::execute_job(
                        &job, &pool, encryption_key, "schedule", Some(&config)
                    ).await {
                        tracing::error!(job_id = %job.id,
                            "Execution failed: {e}");
                    }

                    // Compute next run and re-schedule
                    if let Ok(next) = compute_next_run(&job.schedule_cron) {
                        let next_ts = next.timestamp() as f64;
                        if let Ok(mut conn) = redis_client
                            .get_multiplexed_async_connection().await
                        {
                            let _: Result<(), _> = conn
                                .zadd("scheduled_jobs",
                                    job_id.to_string(), next_ts)
                                .await;
                        }

                        let _ = sqlx::query(
                            "UPDATE jobs SET next_run_at = $1 WHERE id = $2"
                        )
                        .bind(next)
                        .bind(job_id)
                        .execute(&pool)
                        .await;
                    }
                }
                Ok(Some(_)) => {
                    tracing::info!(job_id = %job_id,
                        "Job is not active, skipping");
                }
                Ok(None) => {
                    tracing::warn!(job_id = %job_id,
                        "Job not found in database");
                }
                Err(e) => {
                    tracing::error!(job_id = %job_id,
                        "Failed to fetch job: {e}");
                }
            }
        });
    }

    Ok(())
}

Paso 1: Consultar tareas vencidas

ZRANGEBYSCORE scheduled_jobs 0 <current_timestamp> devuelve todos los miembros del sorted set cuya puntuacion (tiempo de proxima ejecucion) es menor o igual al tiempo actual.

Paso 2: Adquirir lock distribuido

SET lock:<job_id> 1 NX EX 60 es el patron estandar de lock distribuido de Redis. NX significa "establecer solo si la clave no existe" -- un compare-and-set atomico. EX 60 establece una expiracion de 60 segundos como valvula de seguridad.

Si locked es falso, otra instancia de 0cron ya reclamo esta tarea. La instancia actual la salta y pasa a la siguiente.

Paso 3: Eliminar del sorted set y generar tarea

Despues de adquirir el lock, la tarea se elimina del sorted set. Esto evita que otras instancias la vean en su siguiente poll. El orden importa: lock primero, luego eliminar.

La ejecucion real se genera como una tarea tokio separada. Esto es critico para el throughput: el bucle de poll no debe bloquearse en ejecuciones individuales de tareas.

Paso 4: Ejecutar y reprogramar

Despues de la ejecucion, el programador calcula el siguiente tiempo de ejecucion desde la expresion cron y anade la tarea de vuelta al sorted set con la nueva marca de tiempo como puntuacion.


El ejecutor: que sucede cuando una tarea se dispara

El ejecutor maneja reintentos con tres estrategias de backoff:

  • Exponencial: 5s, 10s, 20s, 40s... Se duplica en cada reintento.
  • Lineal: 5s, 10s, 15s, 20s... Incremento constante.
  • Fijo: 5s, 5s, 5s... Mismo retraso cada vez.
rustfn compute_backoff(retry_config: &RetryConfig, attempt: u32) -> u64 {
    let base = retry_config.delay as u64;
    match retry_config.backoff.as_str() {
        "exponential" => base * 2_u64.pow(attempt),
        "linear" => base * (attempt as u64 + 1),
        "fixed" | _ => base,
    }
}

Cada intento -- exitoso o no -- se registra como una fila Execution en PostgreSQL. El usuario puede ver cada reintento en su historial de ejecucion, completo con el cuerpo de respuesta y codigo de estado de cada intento.

La interpolacion de secretos antes del envio HTTP permite a los usuarios almacenar claves API como secretos encriptados y referenciarlos con sintaxis ${secrets.API_KEY}. Antes de que la solicitud HTTP se envie, cada ocurrencia se reemplaza con el valor desencriptado.


Gestion del estado de programacion

El ordenamiento es siempre base de datos primero, Redis segundo:

  • Crear una tarea = INSERT en PostgreSQL + ZADD en Redis
  • Pausar una tarea = UPDATE estado en PostgreSQL + ZREM de Redis
  • Reanudar una tarea = UPDATE estado en PostgreSQL + ZADD en Redis
  • Eliminar una tarea = DELETE de PostgreSQL + ZREM de Redis

Si los dos almacenes divergen (una caida entre INSERT y ZADD), la tarea existe en la base de datos pero no esta programada -- un modo de fallo seguro.


199 lineas

Todo el programador -- polling, bloqueo, despacho, reprogramacion, mas las funciones auxiliares schedule_job y unschedule_job -- cabe en 199 lineas de Rust. El ejecutor anade otras 309 lineas. Combinadas, 508 lineas de codigo forman el nucleo de un servicio de tareas cron que maneja programacion, ejecucion, reintentos, logging y notificaciones.

A veces la mejor arquitectura es la mas simple que funciona.


Esta es la Parte 3 de una serie de diez partes sobre la construccion de 0cron.dev. Si te las perdiste: Parte 1: Por que el mundo necesita un servicio cron de 2 dolares cubre el analisis de mercado y filosofia de precios. Parte 2: 4 agentes, 1 producto cubre la metodologia de construccion en paralelo que produjo todo el codebase en una sola sesion.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles