Runbook — Provisionamento Preso (ProvisioningJobsStuck)¶
Referenciado pelo alerta Prometheus
ProvisioningJobsStuck(k8s/74-token-ledger-prometheus-rules.yaml). Severidade: crítica.
Resumo¶
Um cliente que pagou não recebeu o tenant. Um checkout Stripe liquidado foi
registrado em provisioning_jobs, mas o callback de write-back HMAC do orchestrator Go
nunca virou aquela linha para provisioned. O cron reconcile-provisioning normalmente
re-aciona essas linhas automaticamente; este alerta significa que pelo menos uma linha
está presa além do orçamento de tentativas e precisa de um operador.
| Alerta | ProvisioningJobsStuck |
| Expr | tlsstress_provisioning_stuck > 0 por 30m |
| Origem do gauge | countStuckProvisioningJobs() via GET /api/metrics |
| Dono | On-call de customer-app / billing |
| Impacto ao cliente | Cliente pago fica preso — sem deployment dpl_, conta não active |
| Auto-recuperação | Cron reconcile-provisioning (a cada 10 min) — só até MAX_ATTEMPTS |
1. O que o alerta significa¶
O hand-off de provisionamento é um fluxo durável, de quatro atores. Cada checkout
liquidado produz exatamente uma linha em provisioning_jobs (única por stripe_session_id).
| Ator | Arquivo | Responsabilidade |
|---|---|---|
| Webhook Stripe | (handler do webhook de billing) | Persiste o hand-off via recordProvisioningJob() e chama enqueueOnboarding() |
| Enqueue do hand-off | src/lib/provisioning/enqueue.ts |
Faz HMAC-POST do OnboardingInput para o /trigger/onboarding do orchestrator |
| Orchestrator Go | Saga Temporal OnboardingV1 |
Cunha o dpl_ real, provisiona o tenant |
| Callback de write-back | src/app/api/internal/provisioning/callback/route.ts |
linkProvisionedAccount() → vira o job provisioned + conta active |
| Reconciler (watchdog) | src/app/api/cron/reconcile-provisioning/route.ts |
Re-aciona linhas presas num cron de 10 min |
Um job está PRESO (contado pelo gauge) quando é não-terminal
(enqueued / provisioning / failed) E uma de:
- sem progresso por > 15 minutos (
updated_at < now() - interval '15 minutes'), ou - já no/acima do teto de tentativas (
attempts >= 8).
Veja countStuckProvisioningJobs() em src/lib/db/queries.ts. O piso de 15-min / 8-tentativas
existe para que um job normal em voo (alguns minutos de idade) não dispare o gauge.
Por que um hand-off fica preso¶
- Trigger inalcançável —
PROVISIONING_TRIGGER_URL/PROVISIONING_TRIGGER_SECRETausentes ou o endpoint do orchestrator caído →enqueueOnboarding()retornaenqueued:false reason:"trigger_not_configured"(oufetch_error/http_5xx). - Orchestrator falhou no meio da saga — Temporal esgotou suas próprias tentativas;
o callback reportou
status:"failed"(markProvisioningJobFailed()). - Callback nunca chegou — o orchestrator provisionou mas seu POST de write-back nunca
alcançou
/api/internal/provisioning/callback(rede, assinatura inválida → 401, ou o secret do callback ausente → 503). - Payload NULL — uma linha pré-migração-0039 tem
trigger_payload = NULL; o reconciler se recusa a adivinhar o pacote/quota e a pula para tratamento manual.
2. Fluxo de decisão do operador¶
flowchart TD
A[ProvisioningJobsStuck disparou] --> B[Consultar provisioning_jobs<br/>WHERE status != 'provisioned']
B --> C{Alguma linha com<br/>trigger_payload IS NULL?}
C -- Sim --> D[Caminho payload-NULL<br/>ver Seção 6 — vincular manualmente]
C -- Não --> E{attempts >= 8?}
E -- Não, <8 --> F{last_attempt_at mais<br/>velho que 5 min?}
F -- Não --> G[Reconciler pega no<br/>próximo tick de 10 min — AGUARDE]
F -- Sim --> H[Forçar uma rodada de reconcile<br/>ver Seção 4]
E -- Sim, no teto --> I{O orchestrator está<br/>realmente alcançável agora?}
I -- Não --> J[Corrija o transporte primeiro:<br/>TRIGGER_URL/SECRET, saúde do orchestrator]
J --> K[Resete attempts, depois<br/>force reconcile — Seção 5]
I -- Sim --> L{O tenant foi de fato<br/>provisionado?<br/>checar orchestrator/Temporal}
L -- Sim, dpl_ existe --> M[Vincular o job à mão<br/>replay do callback — Seção 6]
L -- Não --> K
M --> N[Verificar conta active + dpl_ setado]
H --> N
K --> N
N --> O{Resolvido?}
O -- Sim --> P[Confirmar gauge → 0, fechar alerta]
O -- Não --> Q[Escalar — Seção 7]
3. Inspecionar provisioning_jobs¶
Read-only primeiro. Conecte pelo caminho admin dentro da VPC (egress do App Runner fica atrás do Cloudflare). Todas as queries batem no schema
octopusdo RDS de cliente.
3.1 Listar todo job não-provisionado, parada mais recente primeiro¶
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 Reproduzir o gauge (exatamente o que o alerta conta)¶
SELECT count(*) AS stuck
FROM provisioning_jobs
WHERE status IN ('enqueued', 'provisioning', 'failed')
AND (updated_at < now() - interval '15 minutes' OR attempts >= 8);
Isso deve casar com tlsstress_provisioning_stuck de GET /api/metrics.
3.3 Triagem de uma sessão específica¶
SELECT * FROM provisioning_jobs
WHERE stripe_session_id = 'cs_live_...';
Leia as colunas:
| Coluna | O que ela diz |
|---|---|
status |
enqueued (entregue, aguardando callback) · provisioning (orchestrator trabalhando) · failed (erro terminal reportado) · provisioned (PRONTO) |
attempts |
Contagem de re-acionamentos. >= 8 = passou de MAX_ATTEMPTS, o reconciler desistiu |
last_error |
trigger_not_configured · http_5xx · fetch_error · ou a string de erro do orchestrator |
deployment_id |
NULL até o callback escrever o dpl_ real |
trigger_payload |
NULL ⇒ reconciler não consegue replicar (Seção 6) |
last_attempt_at |
Dirige a checagem de 5 min de staleness que o reconciler usa |
4. Como o reconciler re-aciona (e como forçar uma rodada)¶
O cron reconcile-provisioning roda a cada 10 minutos (K8s CronJob
infra/cron/reconcile-provisioning-cronjob.yaml; prod = EventBridge → App Runner).
Cada tick faz POST em /api/cron/reconcile-provisioning com x-cron-secret: $CRON_SECRET.
Por rodada ele:
- Seleciona jobs stale via
getStaleProvisioningJobs():status IN (enqueued, failed, provisioning)Eattempts < 8E (last_attempt_at IS NULLOUlast_attempt_at < now-5min), mais antigo primeiro, lote ≤ 25. - Para cada linha com
trigger_payloadnão-NULL, replica o payload original exato porenqueueOnboarding()(assimpackage_slug→ quota de token correta — nunca um chute). - Incrementa
attemptse registra o resultado viarecordProvisioningJob()(que nunca rebaixa uma linha jáprovisioned). - Um job que depois der certo é virado para
provisionedpelo callback e sai da varredura stale. Um job acima deMAX_ATTEMPTS(8) para de ser retentado e permanece visível no gauge.
Forçar uma rodada de reconcile agora (sem esperar o tick de 10 min)¶
# In-cluster (staging/K8s) — criar um Job único a partir do 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
O endpoint sempre retorna 200 com um resumo JSON; leia-o:
{ "event": "provisioning.reconcile.run", "scanned": 3, "redriven": 2,
"still_failing": 0, "skipped_no_payload": 1, "stuck_total": 1 }
redriven> 0 → hand-offs replicados; aguarde o callback virá-los.skipped_no_payload> 0 → existem linhas com payload NULL → Seção 6.still_failing> 0 → o orchestrator/transporte ainda está caído → Seção 5/7.
5. Job no teto attempts >= 8 (o transporte estava caído)¶
Quando o transporte (URL/secret do trigger ou orchestrator) ficou caído por 8 tentativas, o reconciler para de re-acionar. Conserte o transporte primeiro, depois re-arme o job.
- Confirme a saúde do transporte: verifique se
PROVISIONING_TRIGGER_URL/PROVISIONING_TRIGGER_SECRETestão setados no ambiente do customer-app e se o/trigger/onboardingdo orchestrator responde. Se estavam ausentes, olast_errorda linha mostrarátrigger_not_configured. - Re-arme o job no teto para que o próximo reconcile o pegue (resete attempts; mantenha o payload). Verifique a linha primeiro, depois atualize por id:
-- Só faça isto DEPOIS de confirmar que o transporte voltou.
UPDATE provisioning_jobs
SET attempts = 0,
status = 'enqueued',
last_attempt_at = NULL,
updated_at = now()
WHERE id = '<job-uuid>'
AND status <> 'provisioned'; -- nunca toque numa linha provisioned
- Force um reconcile (Seção 4) e observe o callback virá-lo
provisioned.
Não resete attempts antes de o transporte estar consertado — você só vai queimar o orçamento de tentativas de novo e re-disparar o alerta.
6. Vincular um job preso à mão (callback nunca chegou) & linhas com payload NULL¶
Use isto quando o orchestrator de fato provisionou um dpl_ real mas o callback de
write-back nunca chegou ao app, ou para uma linha com payload NULL que o reconciler
se recusa a replicar.
6a. Preferido: replicar o callback do orchestrator (idempotente)¶
linkProvisionedAccount() é idempotente e é o único caminho de código que vincula
corretamente deployment_id + cell_id + seta tokens_quota (só positivo) + vira a
conta para active. Re-POST o mesmo callback que o orchestrator teria 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. O job estáprovisioned, a conta estáactive,dpl_/cell_idforam escritos. Pronto.404 {"error":"no provisioning job for stripe_session_id"}→ o webhook nunca registrou uma linha → escale (Seção 7), não invente uma.tokens_quotadeve ser a quota positiva do pacote.linkProvisionedAccounttrata0como "manter a quota existente" (setada no checkout porapplyTierFromStripe), então passar0não vai zerar uma conta paga — mas passe o valor real quando souber.
Use o secret dedicado
PROVISIONING_CALLBACK_SECRETse setado; caso contrário o callback também aceita o compartilhadoPROVISIONING_TRIGGER_SECRET.
6b. Linhas com payload NULL (pré-migração 0039)¶
Uma linha com trigger_payload IS NULL não pode ser replicada fielmente pelo reconciler
(ele a pula e loga provisioning.reconcile.skip_no_payload). Para resolver:
- Recupere o pacote/quota da sessão Stripe (
stripe checkout sessions retrieve cs_live_...) ou decustomer_accounts(o tier setado no checkout). - Se o tenant já foi provisionado, vincule à mão via 6a com o
deployment_id/cell_id/tokens_quotarecuperados. - Se não foi provisionado, acione o orchestrator diretamente com o
OnboardingInputrecuperado, depois deixe o callback chegar (ou vincule à mão).
Não preencha
trigger_payloadcom um chute e deixe o reconciler replicar — umpackage_slugerrado cunharia a quota de token errada. É exatamente por isso que o reconciler pula linhas NULL.
7. Escalonamento¶
Escale se qualquer uma destas valer:
- Um replay de callback
200(6a) retorna404→ o webhook nunca registrou o job (bug no caminho de signup/billing, não uma instabilidade de transporte). - O orchestrator não consegue confirmar se um
dpl_foi cunhado (estado de saga ambíguo). still_failingcontinua > 0 depois do transporte confirmado saudável.- Mais que um punhado de jobs presos simultaneamente (sistêmico — má config do trigger ou queda do orchestrator, não um caso isolado).
Passos:
- Pagine o on-call de billing / customer-app (
severity: critical— cliente pago está preso; trate como impacto em receita). - Capture evidência: a linha do §3.3, o JSON-resumo do reconcile (§4), e o estado do
workflow
OnboardingV1do orchestrator/Temporal para aquelestripe_session_id. - Se o orchestrator estiver caído, abra um P1 contra o orchestrator de provisionamento e segure as linhas (elas ficam visíveis no gauge — isso é por design).
8. Verificar resolução¶
-- O job deve estar provisioned com coordenadas reais:
SELECT status, deployment_id, cell_id, tokens_quota
FROM provisioning_jobs WHERE stripe_session_id = 'cs_live_...';
-- A conta deve estar active e vinculada a um dpl_ real:
SELECT status, deployment_id, tokens_quota
FROM customer_accounts WHERE id = '<account_id>';
Depois confirme que tlsstress_provisioning_stuck volta a 0 via GET /api/metrics
(ou re-rode a query do §3.2). O alerta limpa sozinho assim que o gauge zerar.
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 do hand-off:
src/lib/provisioning/enqueue.ts - Schema:
src/lib/db/schema.ts(provisioning_jobs) - Regra de alerta:
k8s/74-token-ledger-prometheus-rules.yaml(ProvisioningJobsStuck) - CronJob:
pkg/octopus/customer-app/infra/cron/reconcile-provisioning-cronjob.yaml