Async-background: Un sistema ligero de trabajos en segundo plano para Ruby sin dependencias externas
Los desarrolladores de aplicaciones Ruby a menudo necesitan ejecutar trabajos en segundo plano, pero las soluciones listas como Sidekiq requieren infraestructura adicional. La nueva gema async-background ofrece una alternativa que se ejecuta en Async sin Redis ni Postgres, con control granular de recursos.
Por qué las soluciones existentes se quedaban cortas
Sidekiq se usa típicamente para trabajos en segundo plano en Ruby, pero requiere Redis. Para proyectos pequeños con solo unos pocos trabajos, eso es excesivo: un contenedor extra en docker-compose, otro punto de fallo y costos de infraestructura. Además, Sidekiq no permite una distribución flexible de carga entre workers —un informe nocturno pesado puede ralentizar el procesamiento de correos de usuarios.
Otras opciones también tienen sus inconvenientes:
whenever— genera un crontab del sistema, sin cola ni integración con la app.rufus-scheduler— se ejecuta en hilos separados, causando cambios de contexto y problemas de duplicación en apps Async con múltiples procesos.good_jobysolid_queue— atados a ActiveRecord y Postgres. Esto crea riesgos: los trabajos en segundo plano compiten con la carga transaccional por recursos de la base de datos, lo que potencialmente ralentiza la app principal.
Por lo tanto, había necesidad de una solución que combine programaciones cron, intervalos y una cola dinámica sin dependencias externas, más control sobre la utilización de recursos.
Principios de diseño clave
El autor de la gema delineó seis principios fundamentales:
- Bucle de eventos único. El planificador se ejecuta en el mismo bucle reactivo que la app, sin hilos separados.
- Cero infraestructura. La cola se almacena en disco (SQLite en modo WAL o un archivo en
tmp/), sin Redis ni Postgres. - Dependencias opcionales. Funciones extra (como métricas) se añaden vía gemas opcionales.
- Seguridad multi-proceso sin coordinador. Asegura que un trabajo se ejecute solo en un proceso al usar Falcon (forks).
- Persistencia como seguro, velocidad como mecanismo separado. La fiabilidad proviene del almacenamiento SQLite, la velocidad de los wakeups vía socket UNIX.
- Utilización gestionada de workers. Los desarrolladores pueden asignar trabajos a workers específicos vía variables de entorno y config YAML.
Estos principios permitieron construir una gema que se integra perfectamente en apps Async existentes sin infraestructura extra.
Características arquitectónicas
MinHeap en lugar de polling de ticks
En lugar de un bucle con sleep 1 (despertando cada segundo para comprobar todos los trabajos), se usa un min-heap binario (MinHeap). Esto permite:
- Obtener el siguiente trabajo en tiempo O(1) con
peek. - Actualizar el heap tras ejecutar un trabajo en tiempo O(log n) con
replace_top. - Dormir con precisión hasta el siguiente evento, ahorrando recursos de CPU.
La implementación tiene solo 74 líneas y no tiene dependencias externas.
Dos escalas temporales
Para trabajos basados en intervalos (p. ej., every: 60), se usan relojes monotónicos (CLOCK_MONOTONIC) para evitar problemas con la sincronización de hora (NTP). Para trabajos cron (p. ej., cron: "0 3 *"), se usa el tiempo de reloj de pared (Time.now) ya que el horario está ligado al tiempo del calendario.
En código, se ve así:
next_run_at = if task_config[:interval]
now + jitter + task_config[:interval]
else
now_wall = Time.now
wall_wait = task_config[:cron].next_time(now_wall).to_f - now_wall.to_f
now + jitter + [wall_wait, MIN_SLEEP_TIME].max
end
Sharding vía CRC32
Al usar Falcon (que hace forks), los trabajos deben ejecutarse solo en un proceso. El sharding determinista lo logra: se calcula Zlib.crc32(name) % total_workers + 1 para el nombre del trabajo. Si coincide con el índice del worker actual, se carga el trabajo.
assigned = config['worker']&.to_i || ((Zlib.crc32(name) % total_workers) + 1)
next unless assigned == worker_index
Esto elimina la necesidad de bloqueos distribuidos.
Skip-on-Overlap
Si un trabajo dura más que su intervalo (p. ej., 90 segundos para un intervalo de 60 segundos), la nueva instancia se omite. En su lugar, se registra el trabajo actual, se omite y se reprograme para el siguiente slot:
if entry.running
logger.warn('Async::Background') { "#{entry.name}: skipped, previous run still active" }
metrics.job_skipped(entry)
entry.reschedule(monotonic_now)
heap.replace_top(entry)
next
end
Cola: SQLite + UNIX Socket
La cola dinámica usa dos mecanismos:
- SQLite — fuente de verdad. Los trabajos se persisten en disco para fiabilidad.
- UNIX socket — para notificaciones instantáneas. Tras añadir un trabajo a la BD, se envía una señal vía el socket para que el worker lo procese inmediatamente.
El polling cada 5 segundos actúa como respaldo en caso de pérdida de señal.
Recuperación de fallos
Si un worker falla a mitad de un trabajo, el estado queda en "running". Al reiniciar, Queue::Store#recover encuentra esos registros y los devuelve a la cola.
Puntos clave
- Sin dependencias de servicios externos. Async-background depende solo del sistema de archivos, simplificando el despliegue.
- Control de recursos. Aislar workers para diferentes tipos de trabajos (cron, cola) previene contención de recursos.
- Fiabilidad. Los trabajos no se pierden gracias a la persistencia SQLite y mecanismos de recuperación.
- Flexibilidad. La config YAML y variables de entorno facilitan ajustar la distribución de trabajos.
- Rendimiento. Min-heap y sockets UNIX ofrecen baja latencia y uso eficiente de CPU.
— Editorial Team
Aún no hay comentarios.