Pular para conteúdo

Jobs Travados

Como resolver jobs que ficam travados em PROCESSING.

Problema

Jobs marcados como PROCESSING mas que não avançam há muito tempo.

Identificar Jobs Travados

Query Básica

SELECT 
  id,
  "eventKey",
  "processingAt",
  NOW() - "processingAt" as stuck_for,
  attempts,
  "lockedBy"
FROM "OutboxJob"
WHERE status = 'PROCESSING'
  AND "processingAt" < NOW() - INTERVAL '5 minutes'
ORDER BY "processingAt";

Query Detalhada

SELECT 
  j.id,
  j."eventKey",
  j."processingAt",
  NOW() - j."processingAt" as stuck_for,
  j.attempts,
  j."lockedBy",
  e."storeCode",
  e."totalAmount",
  r.name as restaurant
FROM "OutboxJob" j
JOIN "NayaxEvent" e ON j."eventKey" = e."transactionKey"
LEFT JOIN "Restaurant" r ON e."restaurantId" = r.id
WHERE j.status = 'PROCESSING'
  AND j."processingAt" < NOW() - INTERVAL '5 minutes'
ORDER BY j."processingAt";

Solução 1: Aguardar o Reaper (Automático)

O sistema tem um Reaper que roda a cada 20 segundos e reseta automaticamente jobs travados.

Como Funciona

// Roda a cada 20 segundos
@Cron('*/20 * * * * *')
async reaper() {
  const thresholdMs = 120000; // 2 minutos
  const cutoff = new Date(Date.now() - thresholdMs);

  const stuck = await this.prisma.outboxJob.findMany({
    where: {
      status: 'PROCESSING',
      processingAt: { lt: cutoff }
    }
  });

  // Reseta para FAILED com backoff
  for (const job of stuck) {
    await this.prisma.outboxJob.update({
      where: { id: job.id },
      data: {
        status: 'FAILED',
        nextRunAt: new Date(Date.now() + backoff),
        processingAt: null,
        lockedBy: null
      }
    });
  }
}

Verificar se Reaper está Rodando

# Ver logs do reaper
tail -f logs/app.log | grep -i reaper

# Deve aparecer a cada 20 segundos:
# [JobsProcessor] Reaper: resetting 3 stuck PROCESSING jobs
# ou
# [JobsProcessor] Reaper: no stuck jobs

Solução 2: Reset Manual

Se precisar resetar imediatamente:

Resetar Job Específico

UPDATE "OutboxJob"
SET 
  status = 'FAILED',
  "nextRunAt" = NOW(),
  "processingAt" = NULL,
  "lockedBy" = NULL,
  "lastError" = 'Manual reset - job stuck',
  "updatedAt" = NOW()
WHERE id = 'job_abc123';

Resetar Todos Jobs Travados

UPDATE "OutboxJob"
SET 
  status = 'FAILED',
  "nextRunAt" = NOW(),
  "processingAt" = NULL,
  "lockedBy" = NULL,
  "lastError" = 'Manual reset - stuck > 5 minutes',
  "updatedAt" = NOW()
WHERE status = 'PROCESSING'
  AND "processingAt" < NOW() - INTERVAL '5 minutes';

Forçar Reprocessamento Imediato

UPDATE "OutboxJob"
SET 
  status = 'PENDING',
  "nextRunAt" = NOW(),
  "processingAt" = NULL,
  "lockedBy" = NULL,
  attempts = 0,
  "lastError" = NULL,
  "updatedAt" = NOW()
WHERE id = 'job_abc123';

Solução 3: Verificar Processo

Verificar se Aplicação Está Rodando

# Verificar processos Node
ps aux | grep node

# Verificar com PM2
pm2 list

# Verificar logs
tail -f logs/app.log

Verificar se JobsProcessor Está Ativo

# Deve aparecer logs a cada 20 segundos
tail -f logs/app.log | grep -i "tick\|processor"

# Logs esperados:
# [JobsProcessor] tick: start
# [JobsProcessor] tick: fetched 5 job(s)
# [JobsProcessor] tick: end in 1234ms

Restart da Aplicação

# Development
npm run start:dev

# Production com PM2
pm2 restart nayax-saipos

# Production sem PM2
killall node
npm run start:prod

Causas Comuns

1. Aplicação Caiu

Sintoma: Nenhum log novo, processo não está rodando

Solução: Restart da aplicação

2. Lock de Banco

Sintoma: Jobs não avançam mas aplicação está rodando

Verificar:

SELECT * FROM pg_locks WHERE granted = false;

Solução: Kill transações travadas ou restart PostgreSQL

3. Timeout na API Saipos

Sintoma: Job trava sempre no mesmo ponto

Verificar logs:

tail -f logs/app.log | grep "SAIPOS"

Solução: Aumentar timeout ou verificar rede

4. Memory Leak

Sintoma: Aplicação lenta, jobs acumulando

Verificar:

# Uso de memória
ps aux | grep node

# Com PM2
pm2 monit

Solução: Restart aplicação, investigar leak

5. Dead Lock no Banco

Sintoma: Jobs não avançam, queries lentas

Verificar:

SELECT * FROM pg_stat_activity WHERE state = 'active';

Solução: Restart PostgreSQL

Prevenção

1. Monitoramento

Configure alertas para jobs travados:

-- Crie uma view
CREATE VIEW stuck_jobs_alert AS
SELECT COUNT(*) as stuck_count
FROM "OutboxJob"
WHERE status = 'PROCESSING'
  AND "processingAt" < NOW() - INTERVAL '10 minutes';

-- Use em script de monitoramento

2. Healthcheck

Adicione healthcheck que verifica jobs:

@Get('health')
async health() {
  const stuckJobs = await this.prisma.outboxJob.count({
    where: {
      status: 'PROCESSING',
      processingAt: { lt: new Date(Date.now() - 600000) } // 10 min
    }
  });

  if (stuckJobs > 0) {
    throw new ServiceUnavailableException(`${stuckJobs} stuck jobs`);
  }

  return { status: 'ok' };
}

3. Ajustar TTL

Se jobs precisam de mais tempo:

# Aumentar de 2 para 5 minutos
PROCESSING_TTL_MS=300000

4. Timeout na API Saipos

Configure timeout adequado:

const response = await axios.post(url, body, {
  timeout: 30000 // 30 segundos
});

Debug de Job Específico

Passo a Passo

  1. Identificar job:

    SELECT * FROM "OutboxJob" 
    WHERE id = 'job_abc123';
    

  2. Ver histórico:

    SELECT * FROM "OutboxJob" 
    WHERE "eventKey" = (
      SELECT "eventKey" FROM "OutboxJob" WHERE id = 'job_abc123'
    )
    ORDER BY "createdAt" DESC;
    

  3. Ver logs do período:

    # Timestamp do processingAt
    grep "job_abc123" logs/app.log
    
    # Ou
    grep "eventKey_xyz" logs/app.log
    

  4. Verificar processo que travou:

    -- Ver qual worker pegou o job
    SELECT "lockedBy" FROM "OutboxJob" WHERE id = 'job_abc123';
    
    -- Verificar se processo ainda existe
    ps aux | grep <PID>
    

Quando Escalar

Se jobs continuam travando frequentemente:

  1. Aumentar workers: Escalar horizontalmente
  2. Otimizar queries: Adicionar índices
  3. Aumentar recursos: CPU/RAM
  4. Separar workers: Jobs em serviço separado

Métricas Importantes

-- Taxa de jobs travados
SELECT 
  COUNT(*) FILTER (WHERE status = 'PROCESSING' AND "processingAt" < NOW() - INTERVAL '5 minutes') as stuck,
  COUNT(*) FILTER (WHERE status = 'PROCESSING') as processing,
  ROUND(
    COUNT(*) FILTER (WHERE status = 'PROCESSING' AND "processingAt" < NOW() - INTERVAL '5 minutes')::numeric / 
    NULLIF(COUNT(*) FILTER (WHERE status = 'PROCESSING'), 0) * 100, 
    2
  ) as stuck_percentage
FROM "OutboxJob";

-- Tempo médio em PROCESSING
SELECT 
  AVG(EXTRACT(EPOCH FROM (COALESCE("updatedAt", NOW()) - "processingAt"))) as avg_seconds,
  MAX(EXTRACT(EPOCH FROM (COALESCE("updatedAt", NOW()) - "processingAt"))) as max_seconds
FROM "OutboxJob"
WHERE status = 'PROCESSING';

Próximos Passos