Files
adaptador-planilhas/scripts/worker.ts
T
camilafagundesandClaude Opus 5 ca8a1112f0 Corrige quatro defeitos de runtime do worker e dos scripts operacionais
Os quatro apareceram ao exercitar o layout de runtime da imagem — apenas
node_modules de produção, src, scripts e migrations — e nenhum aparecia na
suíte de testes nem no build. Os dois primeiros formavam uma armadilha mútua.

1. O CLI do `tsx` cria processo filho (PIDs medidos: 20840 contra 21096).
   O SIGTERM do `docker stop` pararia no pai, o worker nunca rodaria o
   handler, o lock esperaria o TTL de 120s e o `stop_grace_period` só
   atrasaria um SIGKILL. Passa a ser `node --import tsx`, mesmo processo.
2. Top-level await nos scripts falha com `--import tsx`, porque sem
   `"type": "module"` o esbuild gera CJS. `migrate`, `worker`, `seed-staging`,
   `flag`, `cleanup` e `check-bucket` não executariam: a migração não rodaria
   e o worker não subiria. `runScript`/`runWithDb` resolvem sem mudar o
   module type do projeto.
3. `import "server-only"` em `worker-loop.ts`: o pacote lança fora do bundler
   do Next, e o worker morria no start.
4. `@react-pdf/renderer` carregado no import de `process-job.ts` falha com
   ERR_PACKAGE_PATH_NOT_EXPORTED ao resolver a hifenização fora do bundler,
   derrubando worker e cleanup. O PDF sai sob demanda no download, então o
   import passou a ser tardio, dentro do ramo que o usa.

Além disso:

- O loop do worker saiu do script para `worker-loop.ts`, testável sem subir
  processo. O script cuida só de sinais, cadência e bootstrap.
- Desligamento gracioso espera o tick corrente para o `finally` liberar o
  lock. Um job que ainda não começou fica `queued`: nada pela metade.
- Heartbeat por tick, lido pelo healthcheck do contêiner. Processo vivo mas
  travado passa em qualquer verificação de PID.
- O worker recusa iniciar com migration pendente, e relê o contexto do dono a
  cada job: papel e feature flag podem ter mudado desde o enfileiramento.
- `verify-config`, `check-bucket`, `seed-staging` e `smoke-staging` novos. O
  smoke tem fase autenticada que só roda com credencial fornecida pela
  operação e, sem ela, reporta PENDENTE em vez de aprovado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 20:39:15 -03:00

74 lines
2.9 KiB
TypeScript

import path from "node:path";
import { jobRunsInline } from "../src/config/env";
import { pendingMigrations } from "../src/infra/db/migration-manifest";
import { createCoordinator } from "../src/infra/redis";
import { createStorage } from "../src/infra/storage";
import { createWorker } from "../src/server/spreadsheet-adapter/worker-loop";
import { bootstrapScript, describeTarget, openOperationalDb, runScript } from "./_bootstrap";
/**
* Processo do worker. Só bootstrap, sinais e cadência: a lógica do tick está em
* `src/server/spreadsheet-adapter/worker-loop.ts`, que é testável sem subir
* processo.
*
* Precisa ser executado com `node --import tsx scripts/worker.ts`, não com o
* CLI do `tsx`: o CLI cria um processo filho e o SIGTERM do contêiner pararia
* no pai, sem nunca chegar ao handler que espera o lock ser liberado.
*/
const TICK_MS = Number(process.env.ADAPTER_WORKER_TICK_MS ?? 2000);
const BATCH = Number(process.env.ADAPTER_WORKER_BATCH ?? 20);
const GRACE_MS = Number(process.env.ADAPTER_WORKER_GRACE_MS ?? 20000);
const HEARTBEAT = process.env.ADAPTER_WORKER_HEARTBEAT ?? path.join("/tmp", "adaptador-worker-heartbeat");
bootstrapScript("worker");
if (jobRunsInline()) {
console.warn("[worker] ADAPTER_JOB_MODE não é worker. Este processo só é útil com a fila ligada.");
}
runScript("worker", async () => {
const db = await openOperationalDb();
// O worker não aplica migrations: isso é passo explícito e aprovado do deploy.
const pending = await pendingMigrations(db);
if (pending.length > 0) {
await db.close().catch(() => undefined);
throw new Error(`migrations pendentes em ${describeTarget()}: ${pending.join(", ")}. Rode npm run migrate antes.`);
}
const deps = { db, storage: createStorage(), coordinator: createCoordinator() };
const worker = createWorker(deps, {
batch: BATCH,
heartbeatPath: HEARTBEAT,
onError: (scope, error) => {
// Nome e escopo do erro. Nunca conteúdo de célula.
console.error("worker", scope, error instanceof Error ? error.name : "error");
},
});
const timer = setInterval(() => {
void worker.tick();
}, TICK_MS);
let stopping = false;
async function shutdown(signal: string) {
if (stopping) return;
stopping = true;
console.log(`[worker] ${signal} recebido. Encerrando.`);
clearInterval(timer);
const clean = await worker.shutdown(GRACE_MS);
if (!clean) console.warn(`[worker] tick não terminou em ${GRACE_MS}ms. Encerrando de todo modo.`);
await db.close().catch(() => undefined);
console.log("[worker] encerrado.");
process.exit(0);
}
process.on("SIGTERM", () => void shutdown("SIGTERM"));
process.on("SIGINT", () => void shutdown("SIGINT"));
console.log(
`[worker] pronto. intervalo=${TICK_MS}ms lote=${BATCH} carencia=${GRACE_MS}ms alvo=${describeTarget()} heartbeat=${HEARTBEAT}`,
);
await worker.tick();
});