Ir al contenido principal
Laravel, shipping fast.
Capitulo 16 · Trabajo que sobrevive a la red

La idempotencia tiene que sobrevivir al salto, no solo al reintento

Julian Beaujardin

La clave de reclamación con Cache::add() del capítulo 7 funciona cuando el efecto secundario que protege vive enteramente dentro de la transacción de api-server. Un repositorio creado en un alojamiento externo no vive ahí. La clave puede distinguir dos entregas duplicadas del mismo job, pero no puede decirte si la primera entrega ya tuvo éxito en el servicio hoja antes de que el worker que la ejecutaba muriera. Para eso necesitas comprobar el estado de la hoja, no el de tu cola.

// app/Jobs/ForgeSiteCreateJob.php
public function handle(): void
{
    // A sibling in the same provisioning batch failed (allowFailures(false)
    // cancels the batch): stop before creating/mutating any external resource,
    // otherwise an in-flight job can leave an orphan the teardown already ran past.
    if ($this->batch()?->cancelled()) {
        return;
    }

    $this->site->refresh();

    /** @var array<string, mixed> $metadata */
    $metadata = (array) $this->site->metadata;

    // Idempotency guard: if a previous attempt already created the Forge site
    // and the listener persisted its ID to metadata, skip creation to avoid a
    // 422 from Forge.
    if (isset($metadata['site']['id'])) {
        return;
    }

    // ...build the payload and call the hosting leaf service
}

Recorre de qué se defiende cada pieza:

  • $this->batch()?->cancelled(): el lote paralelo en el que corre este job se configuró con allowFailures(false). Si un paso hermano ya falló, esta guarda detiene el job antes de que cree nada nuevo junto a un intento condenado: un huérfano del que el camino de desmantelamiento nunca fue informado.
  • isset($metadata['site']['id']): la comprobación de idempotencia de verdad, y no es contra la cola, es contra el registro duradero de lo que el servicio hoja ya confirmó. El listener del primer intento escribió ese identificador en los metadatos en cuanto la hoja respondió. Una segunda entrega de este job —caída de worker o reintento manual— lee ese mismo estado duradero y vuelve de inmediato sin hacer nada.

Compáralo con la versión ingenua, que es exactamente lo que parece «simplemente reintenta» antes de que hayas pensado en qué hay al otro lado de la llamada:

// Bad: retries without checking whether the first attempt already landed
public function handle(): void
{
    $api = ForgeNewAPI::boot(apiKey: $this->token);
    $result = $api->createSite(server_id: $this->serverId, payload: $this->buildPayload());
    $this->site->metadata = array_merge($this->site->metadata, ['site' => $result]);
    $this->site->save();
}

Ejecuta esto dos veces —la muerte de un worker entre la llamada y el guardado es la forma más fácil de llegar ahí— y obtienes dos entradas de alojamiento apuntando al mismo sitio: una registrada en los metadatos y otra invisible para todo lo que api-server haga después, incluido el desmantelamiento. No es un fallo que aparezca en una prueba. Es un fallo que aparece tres semanas después como una línea en una factura de infraestructura que nadie sabe explicar.

La regla que hay debajo: la idempotencia en una cadena de varios servicios es una propiedad del registro duradero, no del job. Un job que comprueba «¿he corrido ya?» contra su propio contador de intentos volverá a crear tan contento el mismo recurso externo cada vez que reintente. Un job que comprueba «¿existe ya este recurso, según lo último que me lo dijo?» converge, corra las veces que corra.