Skip to content

Runbook — Aprovisionamiento Atascado (ProvisioningJobsStuck)

Referenciado por la alerta Prometheus ProvisioningJobsStuck (k8s/74-token-ledger-prometheus-rules.yaml). Severidad: crítica.

Resumen

Un cliente que pagó no recibió su tenant. Un checkout de Stripe liquidado quedó registrado en provisioning_jobs, pero el callback de write-back HMAC del orchestrator Go nunca pasó esa fila a provisioned. El cron reconcile-provisioning normalmente re-acciona esas filas de forma automática; esta alerta significa que al menos una fila está atascada más allá de su presupuesto de reintentos y necesita un operador.

Alerta ProvisioningJobsStuck
Expr tlsstress_provisioning_stuck > 0 durante 30m
Origen del gauge countStuckProvisioningJobs() vía GET /api/metrics
Dueño On-call de customer-app / billing
Impacto al cliente Cliente pago atascado — sin deployment dpl_, cuenta no active
Auto-recuperación Cron reconcile-provisioning (cada 10 min) — solo hasta MAX_ATTEMPTS

1. Qué significa la alerta

El hand-off de aprovisionamiento es un flujo durable, de cuatro actores. Cada checkout liquidado produce exactamente una fila en provisioning_jobs (única por stripe_session_id).

Actor Archivo Responsabilidad
Webhook Stripe (handler del webhook de billing) Persiste el hand-off vía recordProvisioningJob() y llama a enqueueOnboarding()
Enqueue del hand-off src/lib/provisioning/enqueue.ts Hace HMAC-POST del OnboardingInput al /trigger/onboarding del orchestrator
Orchestrator Go Saga Temporal OnboardingV1 Acuña el dpl_ real, aprovisiona el tenant
Callback de write-back src/app/api/internal/provisioning/callback/route.ts linkProvisionedAccount() → pasa el job a provisioned + cuenta active
Reconciler (watchdog) src/app/api/cron/reconcile-provisioning/route.ts Re-acciona filas atascadas en un cron de 10 min

Un job está ATASCADO (contado por el gauge) cuando es no-terminal (enqueued / provisioning / failed) Y una de:

  • sin progreso por > 15 minutos (updated_at < now() - interval '15 minutes'), o
  • ya en/sobre el tope de reintentos (attempts >= 8).

Ver countStuckProvisioningJobs() en src/lib/db/queries.ts. El piso de 15-min / 8-intentos existe para que un job normal en vuelo (de pocos minutos) no dispare el gauge.

Por qué un hand-off se atasca

  • Trigger inalcanzablePROVISIONING_TRIGGER_URL / PROVISIONING_TRIGGER_SECRET sin definir o el endpoint del orchestrator caído → enqueueOnboarding() retorna enqueued:false reason:"trigger_not_configured" (o fetch_error / http_5xx).
  • Orchestrator falló a mitad de saga — Temporal agotó sus propios reintentos; el callback reportó status:"failed" (markProvisioningJobFailed()).
  • El callback nunca llegó — el orchestrator aprovisionó pero su POST de write-back nunca alcanzó /api/internal/provisioning/callback (red, firma inválida → 401, o el secret del callback sin definir → 503).
  • Payload NULL — una fila anterior a la migración-0039 tiene trigger_payload = NULL; el reconciler se niega a adivinar el paquete/cuota y la salta para tratamiento manual.

2. Flujo de decisión del operador

flowchart TD
    A[ProvisioningJobsStuck disparó] --> B[Consultar provisioning_jobs<br/>WHERE status != 'provisioned']
    B --> C{¿Alguna fila con<br/>trigger_payload IS NULL?}
    C -- Sí --> D[Camino payload-NULL<br/>ver Sección 6 — vincular manualmente]
    C -- No --> E{¿attempts >= 8?}
    E -- No, &lt;8 --> F{¿last_attempt_at más<br/>viejo que 5 min?}
    F -- No --> G[El reconciler lo toma<br/>en el próximo tick de 10 min — ESPERAR]
    F -- Sí --> H[Forzar una corrida de reconcile<br/>ver Sección 4]
    E -- Sí, en el tope --> I{¿El orchestrator está<br/>realmente alcanzable ahora?}
    I -- No --> J[Arregla el transporte primero:<br/>TRIGGER_URL/SECRET, salud del orchestrator]
    J --> K[Resetear attempts, luego<br/>forzar reconcile — Sección 5]
    I -- Sí --> L{¿El tenant se aprovisionó<br/>realmente?<br/>revisar orchestrator/Temporal}
    L -- Sí, dpl_ existe --> M[Vincular el job a mano<br/>replay del callback — Sección 6]
    L -- No --> K
    M --> N[Verificar cuenta active + dpl_ seteado]
    H --> N
    K --> N
    N --> O{¿Resuelto?}
    O -- Sí --> P[Confirmar gauge → 0, cerrar alerta]
    O -- No --> Q[Escalar — Sección 7]

3. Inspeccionar provisioning_jobs

Solo-lectura primero. Conéctate por la ruta admin dentro de la VPC (el egress de App Runner está detrás de Cloudflare). Todas las consultas pegan al schema octopus del RDS de cliente.

3.1 Listar todo job no-aprovisionado, atasco más reciente primero

SELECT
  id,
  account_id,
  stripe_session_id,
  status,
  attempts,
  last_error,
  deployment_id,
  cell_id,
  (trigger_payload IS NULL) AS payload_missing,
  last_attempt_at,
  updated_at,
  created_at
FROM provisioning_jobs
WHERE status <> 'provisioned'
ORDER BY updated_at ASC;

3.2 Reproducir el gauge (exactamente lo que la alerta cuenta)

SELECT count(*) AS stuck
FROM provisioning_jobs
WHERE status IN ('enqueued', 'provisioning', 'failed')
  AND (updated_at < now() - interval '15 minutes' OR attempts >= 8);

Esto debe coincidir con tlsstress_provisioning_stuck de GET /api/metrics.

3.3 Triaje de una sesión específica

SELECT * FROM provisioning_jobs
WHERE stripe_session_id = 'cs_live_...';

Lee las columnas:

Columna Qué te dice
status enqueued (entregado, esperando callback) · provisioning (orchestrator trabajando) · failed (error terminal reportado) · provisioned (LISTO)
attempts Conteo de re-accionamientos. >= 8 = pasó MAX_ATTEMPTS, el reconciler se rindió
last_error trigger_not_configured · http_5xx · fetch_error · o la cadena de error del orchestrator
deployment_id NULL hasta que el callback escribe el dpl_ real
trigger_payload NULL ⇒ el reconciler no puede reproducir (Sección 6)
last_attempt_at Maneja la verificación de 5 min de staleness que usa el reconciler

4. Cómo re-acciona el reconciler (y cómo forzar una corrida)

El cron reconcile-provisioning corre cada 10 minutos (K8s CronJob infra/cron/reconcile-provisioning-cronjob.yaml; prod = EventBridge → App Runner). Cada tick hace POST a /api/cron/reconcile-provisioning con x-cron-secret: $CRON_SECRET.

Por corrida:

  1. Selecciona jobs stale vía getStaleProvisioningJobs(): status IN (enqueued, failed, provisioning) Y attempts < 8 Y (last_attempt_at IS NULL O last_attempt_at < now-5min), más antiguo primero, lote ≤ 25.
  2. Para cada fila con trigger_payload no-NULL, reproduce el payload original exacto por enqueueOnboarding() (así package_slug → cuota de token correcta — nunca una conjetura).
  3. Incrementa attempts y registra el resultado vía recordProvisioningJob() (que nunca degrada una fila ya provisioned).
  4. Un job que luego tenga éxito es pasado a provisioned por el callback y sale del barrido stale. Un job sobre MAX_ATTEMPTS (8) deja de reintentarse y permanece visible en el gauge.

Forzar una corrida de reconcile ahora (sin esperar el tick de 10 min)

# In-cluster (staging/K8s) — crear un Job único a partir del CronJob:
kubectl create job -n customer-app --from=cronjob/reconcile-provisioning \
  reconcile-provisioning-manual-$(date +%s)
kubectl logs -n customer-app -l app.kubernetes.io/name=reconcile-provisioning --tail=50

El endpoint siempre retorna 200 con un resumen JSON; léelo:

{ "event": "provisioning.reconcile.run", "scanned": 3, "redriven": 2,
  "still_failing": 0, "skipped_no_payload": 1, "stuck_total": 1 }
  • redriven > 0 → hand-offs reproducidos; espera a que el callback los pase.
  • skipped_no_payload > 0 → existen filas con payload NULL → Sección 6.
  • still_failing > 0 → el orchestrator/transporte sigue caído → Sección 5/7.

5. Job en el tope attempts >= 8 (el transporte estaba caído)

Cuando el transporte (URL/secret del trigger u orchestrator) estuvo caído por 8 intentos, el reconciler deja de re-accionar. Arregla el transporte primero, luego re-arma el job.

  1. Confirma la salud del transporte: verifica que PROVISIONING_TRIGGER_URL / PROVISIONING_TRIGGER_SECRET estén definidos en el entorno del customer-app y que el /trigger/onboarding del orchestrator responda. Si estaban sin definir, el last_error de la fila mostrará trigger_not_configured.
  2. Re-arma el job en el tope para que el próximo reconcile lo tome (resetea attempts; conserva el payload). Verifica la fila primero, luego actualiza por id:
-- Haz esto SOLO DESPUÉS de confirmar que el transporte volvió.
UPDATE provisioning_jobs
SET attempts = 0,
    status = 'enqueued',
    last_attempt_at = NULL,
    updated_at = now()
WHERE id = '<job-uuid>'
  AND status <> 'provisioned';   -- nunca toques una fila provisioned
  1. Fuerza un reconcile (Sección 4) y observa al callback pasarlo a provisioned.

No resetees attempts antes de arreglar el transporte — solo quemarás el presupuesto de reintentos de nuevo y re-dispararás la alerta.


6. Vincular un job atascado a mano (el callback nunca llegó) & filas con payload NULL

Usa esto cuando el orchestrator aprovisionó un dpl_ real pero el callback de write-back nunca llegó a la app, o para una fila con payload NULL que el reconciler se niega a reproducir.

6a. Preferido: reproducir el callback del orchestrator (idempotente)

linkProvisionedAccount() es idempotente y es el único camino de código que vincula correctamente deployment_id + cell_id + setea tokens_quota (solo positivo) + pasa la cuenta a active. Re-POST el mismo callback que el orchestrator habría enviado:

BODY='{"stripe_session_id":"cs_live_...","status":"provisioned",
"deployment_id":"dpl_...","cell_id":"cell-...","tokens_quota":1100000}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$PROVISIONING_CALLBACK_SECRET" | awk '{print $2}')
curl -sS -X POST https://app.tlsstress.art/api/internal/provisioning/callback \
  -H "Content-Type: application/json" \
  -H "X-Provisioning-Signature: $SIG" \
  --data "$BODY"
  • 200 {"ok":true,"status":"provisioned"} → vinculado. El job está provisioned, la cuenta está active, dpl_/cell_id quedaron escritos. Listo.
  • 404 {"error":"no provisioning job for stripe_session_id"} → el webhook nunca registró una fila → escala (Sección 7), no inventes una.
  • tokens_quota debe ser la cuota positiva del paquete. linkProvisionedAccount trata 0 como "dejar la cuota existente" (seteada en el checkout por applyTierFromStripe), así que pasar 0 no pondrá en cero una cuenta paga — pero pasa el valor real cuando lo sepas.

Usa el secret dedicado PROVISIONING_CALLBACK_SECRET si está definido; en caso contrario el callback también acepta el compartido PROVISIONING_TRIGGER_SECRET.

6b. Filas con payload NULL (anteriores a la migración 0039)

Una fila con trigger_payload IS NULL no puede reproducirse fielmente por el reconciler (la salta y registra provisioning.reconcile.skip_no_payload). Para resolver:

  1. Recupera el paquete/cuota de la sesión de Stripe (stripe checkout sessions retrieve cs_live_...) o de customer_accounts (el tier seteado en el checkout).
  2. Si el tenant ya fue aprovisionado, vincula a mano vía 6a con el deployment_id / cell_id / tokens_quota recuperados.
  3. Si no fue aprovisionado, acciona el orchestrator directamente con el OnboardingInput recuperado, luego deja que el callback llegue (o vincula a mano).

No rellenes trigger_payload con una conjetura y dejes que el reconciler reproduzca — un package_slug equivocado acuñaría la cuota de token equivocada. Por eso exactamente el reconciler salta las filas NULL.


7. Escalamiento

Escala si se cumple cualquiera de estas:

  • Un replay de callback 200 (6a) retorna 404 → el webhook nunca registró el job (bug en el camino de signup/billing, no una inestabilidad de transporte).
  • El orchestrator no puede confirmar si se acuñó un dpl_ (estado de saga ambiguo).
  • still_failing sigue > 0 después de confirmar que el transporte está sano.
  • Más de un puñado de jobs atascados simultáneamente (sistémico — mala config del trigger o caída del orchestrator, no un caso aislado).

Pasos:

  1. Pagina al on-call de billing / customer-app (severity: critical — cliente pago atascado; trátalo como impacto en ingresos).
  2. Captura evidencia: la fila del §3.3, el JSON-resumen del reconcile (§4), y el estado del workflow OnboardingV1 del orchestrator/Temporal para ese stripe_session_id.
  3. Si el orchestrator está caído, abre un P1 contra el orchestrator de aprovisionamiento y retén las filas (permanecen visibles en el gauge — eso es por diseño).

8. Verificar la resolución

-- El job debe estar provisioned con coordenadas reales:
SELECT status, deployment_id, cell_id, tokens_quota
FROM provisioning_jobs WHERE stripe_session_id = 'cs_live_...';

-- La cuenta debe estar active y vinculada a un dpl_ real:
SELECT status, deployment_id, tokens_quota
FROM customer_accounts WHERE id = '<account_id>';

Luego confirma que tlsstress_provisioning_stuck vuelve a 0 vía GET /api/metrics (o re-ejecuta la consulta del §3.2). La alerta se limpia sola en cuanto el gauge llega a 0.


Relacionado

  • Reconciler: src/app/api/cron/reconcile-provisioning/route.ts
  • Queries: src/lib/db/queries.ts (getStaleProvisioningJobs, countStuckProvisioningJobs, linkProvisionedAccount, recordProvisioningJob, markProvisioningJobFailed)
  • Callback de write-back: src/app/api/internal/provisioning/callback/route.ts
  • Enqueue del hand-off: src/lib/provisioning/enqueue.ts
  • Schema: src/lib/db/schema.ts (provisioning_jobs)
  • Regla de alerta: k8s/74-token-ledger-prometheus-rules.yaml (ProvisioningJobsStuck)
  • CronJob: pkg/octopus/customer-app/infra/cron/reconcile-provisioning-cronjob.yaml