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 inalcanzable —
PROVISIONING_TRIGGER_URL/PROVISIONING_TRIGGER_SECRETsin definir o el endpoint del orchestrator caído →enqueueOnboarding()retornaenqueued:false reason:"trigger_not_configured"(ofetch_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, <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
octopusdel 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:
- Selecciona jobs stale vía
getStaleProvisioningJobs():status IN (enqueued, failed, provisioning)Yattempts < 8Y (last_attempt_at IS NULLOlast_attempt_at < now-5min), más antiguo primero, lote ≤ 25. - Para cada fila con
trigger_payloadno-NULL, reproduce el payload original exacto porenqueueOnboarding()(asípackage_slug→ cuota de token correcta — nunca una conjetura). - Incrementa
attemptsy registra el resultado víarecordProvisioningJob()(que nunca degrada una fila yaprovisioned). - Un job que luego tenga éxito es pasado a
provisionedpor el callback y sale del barrido stale. Un job sobreMAX_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.
- Confirma la salud del transporte: verifica que
PROVISIONING_TRIGGER_URL/PROVISIONING_TRIGGER_SECRETestén definidos en el entorno del customer-app y que el/trigger/onboardingdel orchestrator responda. Si estaban sin definir, ellast_errorde la fila mostrarátrigger_not_configured. - 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
- 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 sí 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_idquedaron 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_quotadebe ser la cuota positiva del paquete.linkProvisionedAccounttrata0como "dejar la cuota existente" (seteada en el checkout porapplyTierFromStripe), así que pasar0no pondrá en cero una cuenta paga — pero pasa el valor real cuando lo sepas.
Usa el secret dedicado
PROVISIONING_CALLBACK_SECRETsi está definido; en caso contrario el callback también acepta el compartidoPROVISIONING_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:
- Recupera el paquete/cuota de la sesión de Stripe (
stripe checkout sessions retrieve cs_live_...) o decustomer_accounts(el tier seteado en el checkout). - Si el tenant ya fue aprovisionado, vincula a mano vía 6a con el
deployment_id/cell_id/tokens_quotarecuperados. - Si no fue aprovisionado, acciona el orchestrator directamente con el
OnboardingInputrecuperado, luego deja que el callback llegue (o vincula a mano).
No rellenes
trigger_payloadcon una conjetura y dejes que el reconciler reproduzca — unpackage_slugequivocado 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) retorna404→ 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_failingsigue > 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:
- Pagina al on-call de billing / customer-app (
severity: critical— cliente pago atascado; trátalo como impacto en ingresos). - Captura evidencia: la fila del §3.3, el JSON-resumen del reconcile (§4), y el estado del
workflow
OnboardingV1del orchestrator/Temporal para esestripe_session_id. - 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