Skip to content

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çávelPROVISIONING_TRIGGER_URL / PROVISIONING_TRIGGER_SECRET ausentes ou o endpoint do orchestrator caído → enqueueOnboarding() retorna enqueued:false reason:"trigger_not_configured" (ou fetch_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, &lt;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 octopus do 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:

  1. Seleciona jobs stale via getStaleProvisioningJobs(): status IN (enqueued, failed, provisioning) E attempts < 8 E (last_attempt_at IS NULL OU last_attempt_at < now-5min), mais antigo primeiro, lote ≤ 25.
  2. Para cada linha com trigger_payload não-NULL, replica o payload original exato por enqueueOnboarding() (assim package_slug → quota de token correta — nunca um chute).
  3. Incrementa attempts e registra o resultado via recordProvisioningJob() (que nunca rebaixa uma linha já provisioned).
  4. Um job que depois der certo é virado para provisioned pelo callback e sai da varredura stale. Um job acima de MAX_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.

  1. Confirme a saúde do transporte: verifique se PROVISIONING_TRIGGER_URL / PROVISIONING_TRIGGER_SECRET estão setados no ambiente do customer-app e se o /trigger/onboarding do orchestrator responde. Se estavam ausentes, o last_error da linha mostrará trigger_not_configured.
  2. 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
  1. 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_id foram 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_quota deve ser a quota positiva do pacote. linkProvisionedAccount trata 0 como "manter a quota existente" (setada no checkout por applyTierFromStripe), então passar 0 não vai zerar uma conta paga — mas passe o valor real quando souber.

Use o secret dedicado PROVISIONING_CALLBACK_SECRET se setado; caso contrário o callback também aceita o compartilhado PROVISIONING_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:

  1. Recupere o pacote/quota da sessão Stripe (stripe checkout sessions retrieve cs_live_...) ou de customer_accounts (o tier setado no checkout).
  2. Se o tenant já foi provisionado, vincule à mão via 6a com o deployment_id / cell_id / tokens_quota recuperados.
  3. Se não foi provisionado, acione o orchestrator diretamente com o OnboardingInput recuperado, depois deixe o callback chegar (ou vincule à mão).

Não preencha trigger_payload com um chute e deixe o reconciler replicar — um package_slug errado 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) retorna 404 → 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_failing continua > 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:

  1. Pagine o on-call de billing / customer-app (severity: critical — cliente pago está preso; trate como impacto em receita).
  2. Capture evidência: a linha do §3.3, o JSON-resumo do reconcile (§4), e o estado do workflow OnboardingV1 do orchestrator/Temporal para aquele stripe_session_id.
  3. 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