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>
This commit is contained in:
1 parent
d43f370359
commit
ca8a1112f0
13 files changed
+835
-126
No files matched your search
+65
-68
@@ -1,76 +1,73 @@
|
||||
import path from "node:path";
|
||||
import { createFileDb } from "../src/infra/db/pglite";
|
||||
import { createPostgresDb } from "../src/infra/db/postgres";
|
||||
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 { migrate } from "../src/infra/db/migrate";
|
||||
import { cleanupExpired, listQueuedJobIds, readJobForWorker, recoverStaleJobs, runProcess } from "../src/server/spreadsheet-adapter/process-job";
|
||||
import { asJson } from "../src/server/spreadsheet-adapter/db-utils";
|
||||
import type { JobProgress, RequestContext, Role } from "../src/domain/spreadsheet-adapter/types";
|
||||
import { createWorker } from "../src/server/spreadsheet-adapter/worker-loop";
|
||||
import { bootstrapScript, describeTarget, openOperationalDb, runScript } from "./_bootstrap";
|
||||
|
||||
import { assertProductionEnv } from "../src/config/env";
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
assertProductionEnv(process.env);
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl && process.env.NODE_ENV === "production") {
|
||||
throw new Error("DATABASE_URL é obrigatório para o worker.");
|
||||
}
|
||||
const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite"));
|
||||
await migrate(db);
|
||||
const deps = { db, storage: createStorage(), coordinator: createCoordinator() };
|
||||
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");
|
||||
|
||||
async function tick() {
|
||||
await recoverStaleJobs(deps).catch((error) => {
|
||||
console.error("worker", error instanceof Error ? error.name : "error");
|
||||
});
|
||||
await cleanupExpired(deps).catch((error) => {
|
||||
console.error("worker", "cleanup", error instanceof Error ? error.name : "error");
|
||||
});
|
||||
const queued = new Set(await listQueuedJobIds(deps));
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
const jobId = await deps.coordinator.dequeue().catch(() => null);
|
||||
if (!jobId) break;
|
||||
queued.add(jobId);
|
||||
}
|
||||
for (const jobId of queued) {
|
||||
const rows = await readJobForWorker(deps, jobId);
|
||||
const row = rows[0];
|
||||
if (!row) continue;
|
||||
const progress = asJson<JobProgress | null>(row.progress, null);
|
||||
if (!progress?.sourceId) continue;
|
||||
const profile = await db.withService((query) =>
|
||||
query.query<{ display_name: string; is_superadmin: boolean; role: Role; workspace_name: string; module_enabled: boolean; allow_upload: boolean }>(
|
||||
`select p.display_name, p.is_superadmin, w.name as workspace_name, m.role,
|
||||
coalesce(f.enabled, false) as module_enabled,
|
||||
coalesce(s.allow_non_admin_model_upload, false) as allow_upload
|
||||
from public.profiles p
|
||||
join public.workspace_members m on m.user_id = p.id and m.workspace_id = $2
|
||||
join public.workspaces w on w.id = $2
|
||||
left join public.workspace_module_flags f on f.workspace_id = w.id and f.module_key = 'spreadsheet_adapter'
|
||||
left join public.spreadsheet_adapter_settings s on s.workspace_id = w.id
|
||||
where p.id = $1`,
|
||||
[row.user_id, row.workspace_id],
|
||||
),
|
||||
);
|
||||
const found = profile[0];
|
||||
if (!found) continue;
|
||||
const ctx: RequestContext = {
|
||||
userId: row.user_id,
|
||||
displayName: found.display_name,
|
||||
isSuperadmin: found.is_superadmin,
|
||||
workspaceId: row.workspace_id,
|
||||
workspaceName: found.workspace_name,
|
||||
role: found.role,
|
||||
moduleEnabled: found.module_enabled,
|
||||
allowNonAdminModelUpload: found.allow_upload,
|
||||
};
|
||||
await runProcess(deps, ctx, jobId, progress.sourceId).catch((error) => {
|
||||
console.error("worker", error instanceof Error ? error.name : "error");
|
||||
});
|
||||
}
|
||||
bootstrapScript("worker");
|
||||
if (jobRunsInline()) {
|
||||
console.warn("[worker] ADAPTER_JOB_MODE não é worker. Este processo só é útil com a fila ligada.");
|
||||
}
|
||||
|
||||
await tick();
|
||||
setInterval(() => {
|
||||
void tick();
|
||||
}, 2000);
|
||||
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();
|
||||
});
|
||||
Reference in new issue
Block a user