Ir al contenido principal
Laravel, shipping fast.
Capítulo 10 · Colas y trabajo en segundo plano

Jobs que pueden ejecutarse dos veces sin peligro

Julian Beaujardin

Tu job se ejecutará más de una vez, tarde o temprano. Un worker muere a mitad de ejecución durante un despliegue. Un job dura más que el retry_after de la conexión y la cola se lo entrega a un segundo worker mientras el primero todavía está terminando. Las colas de Laravel garantizan «al menos una vez», no «exactamente una vez», y ninguna opción de configuración cambia eso. Un job tiene que poder ejecutarse dos veces y dejar el mismo estado final que si se ejecutara una.

Los borrados perdonan. El Capítulo 3 hizo que el driver tratara un 404 al borrar como un éxito, así que borrar algo que ya no está no hace nada. Esa única decisión es la razón por la que DeleteLicense puede reintentar a ciegas.

Las creaciones son lo contrario. Llama al proveedor dos veces con la misma entrada y tendrás dos licencias. Un reintento tras un timeout, donde nunca viste la respuesta, es exactamente como se fabrican los duplicados.

Laravel te da tres herramientas, para tres situaciones distintas:

  • ShouldBeUnique en el job impide que se despache una segunda copia hasta que la primera haya terminado. Úsalo cuando la misma petición pueda llegar dos veces.
  • El middleware de job WithoutOverlapping impide que dos copias se ejecuten a la vez para la misma clave.
  • Cache::lock() es la reserva atómica que hay debajo de ambos, para cuando la necesites dentro de tu propio código.

Ninguna responde a la pregunta más difícil: ¿salió bien allá fuera el primer intento, antes de que el worker muriera?

Comprueba el registro duradero

Un bloqueo puede distinguir dos entregas de un job. No puede decirte si el proveedor ya hizo el trabajo. Para eso tienes que mirar lo que el proveedor confirmó.

Este es un paso del flujo de aprovisionamiento del hub, el job que monta el hosting:

// app/Jobs/CreateHosting.php
public function middleware(): array
{
    return [new SkipIfBatchCancelled];
}

public function handle(): void
{
    $this->site->refresh();

    if (isset($this->site->metadata['hosting_id'])) {
        return;
    }

    // ... llama al proveedor de hosting y guarda su ID
}

SkipIfBatchCancelled es un middleware de job que trae Laravel. Si un job hermano del mismo lote ya falló, este no arranca.

La guarda dentro de handle() es la comprobación de idempotencia, y mira el registro duradero de lo que el proveedor ya confirmó, no la cola. Una segunda entrega de este job lee la misma fila, ve el ID y termina.

Sin ella, un worker que muere entre la respuesta del proveedor y el save() produce dos entradas de hosting, una con seguimiento y otra invisible para todo lo que ocurra después, incluido el desmontaje. Eso no aparece en un test. Aparece tres semanas después como una línea en una factura de infraestructura que nadie sabe explicar.

La idempotencia a través de una red es una propiedad del registro duradero, no del job. Un job que pregunta «¿me he ejecutado antes?» vuelve a crear el recurso en cada reintento. Un job que pregunta «¿existe ya esto, según lo último que me lo dijo?» converge, por muchas veces que se ejecute.

Nombres que un reintento no puede dejar huérfanos

Esa guarda tiene un hueco. Funciona solo si llegó a hacerse la escritura que anota la respuesta del proveedor. Si el worker muere entre la confirmación del proveedor y tu save(), no hay nada contra lo que comprobar, y el siguiente intento crea otra vez.

Una guarda mejor no arreglará eso. Lo arreglará eliminar la necesidad de tenerla, haciendo que el nombre del recurso sea el registro:

// Mal: un nombre que cambia en cada intento
$name = Str::slug($site->name.'-'.now()->timestamp);

// Bien: un nombre derivado de algo estable
$name = Str::slug($site->name.'-'.$site->id);

La versión con marca de tiempo es el código que sale solo. También es aquella en la que cada reintento tiene una identidad nueva y es libre de dejar atrás lo que construyó el intento anterior. La segunda versión deriva el nombre del ID del propio sitio, que ya existe, es único y no cambia entre el intento uno y el intento cinco. Reinténtalo diez veces y llegas a la misma cadena. Si el recurso está ahí, el proveedor dice «ya existe» y lo tratas como un éxito.

Un nombre generado de nuevo en cada intento convierte una caída en un huérfano. Un nombre derivado de algo estable convierte la misma caída en una operación sin efecto. Es otra vez la clave natural del Capítulo 8, esta vez elegida por ti y no encontrada en los datos.

No se pudo cargar el audio. Inténtalo de nuevo en un momento.