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
+821
-112
No files matched your search
+18
-7
@@ -2,19 +2,30 @@
|
||||
"name": "adaptador-planilhas",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.12 <25"
|
||||
},
|
||||
"packageManager": "npm@11.6.2",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"start:standalone": "node .next/standalone/server.js",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"migrate": "tsx scripts/migrate.ts",
|
||||
"seed": "tsx scripts/seed-dev.ts",
|
||||
"worker": "tsx scripts/worker.ts",
|
||||
"cleanup": "tsx scripts/cleanup-temp.ts",
|
||||
"flag": "tsx scripts/set-flag.ts"
|
||||
"test:services": "vitest run --config vitest.services.config.ts",
|
||||
"migrate": "node --import=tsx scripts/migrate.ts",
|
||||
"migrate:check": "node --import=tsx scripts/migrate.ts --dry-run",
|
||||
"seed": "node --import=tsx scripts/seed-dev.ts",
|
||||
"seed:staging": "node --import=tsx scripts/seed-staging.ts",
|
||||
"worker": "node --import=tsx scripts/worker.ts",
|
||||
"cleanup": "node --import=tsx scripts/cleanup-temp.ts",
|
||||
"flag": "node --import=tsx scripts/set-flag.ts",
|
||||
"verify:config": "node --import=tsx scripts/verify-config.ts",
|
||||
"check:bucket": "node --import=tsx scripts/check-bucket.ts",
|
||||
"smoke:staging": "node --import=tsx scripts/smoke-staging.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -31,7 +42,8 @@
|
||||
"postgres": "^3.4.7",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"server-only": "^0.0.1"
|
||||
"server-only": "^0.0.1",
|
||||
"tsx": "^4.20.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.14",
|
||||
@@ -46,7 +58,6 @@
|
||||
"eslint-config-next": "^16.0.0",
|
||||
"jsdom": "^27.0.0",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import path from "node:path";
|
||||
import { adapterEnv, identityShimEnabled, isDeployedEnv, requireDatabaseUrl } from "../src/config/env";
|
||||
import { exitOnInvalidConfig } from "../src/config/fail-fast";
|
||||
import { createFileDb } from "../src/infra/db/pglite";
|
||||
import { createPostgresDb } from "../src/infra/db/postgres";
|
||||
import type { Db } from "../src/infra/db/types";
|
||||
|
||||
/**
|
||||
* Banco para os scripts operacionais. Em staging e produção `DATABASE_URL` é
|
||||
* obrigatória: cair em PGlite com um segredo ausente faria uma migração
|
||||
* silenciosa num arquivo local parecer sucesso.
|
||||
*/
|
||||
export async function openOperationalDb(): Promise<Db> {
|
||||
const databaseUrl = requireDatabaseUrl();
|
||||
if (databaseUrl) return createPostgresDb(databaseUrl);
|
||||
if (isDeployedEnv()) {
|
||||
throw new Error("DATABASE_URL é obrigatória fora de development.");
|
||||
}
|
||||
const dataDir = path.join(process.cwd(), ".data", "pglite");
|
||||
const { mkdir } = await import("node:fs/promises");
|
||||
await mkdir(dataDir, { recursive: true });
|
||||
return createFileDb(dataDir);
|
||||
}
|
||||
|
||||
/** Recusa configuração inválida antes de tocar em qualquer recurso externo. */
|
||||
export function bootstrapScript(name: string): void {
|
||||
exitOnInvalidConfig(process.env);
|
||||
console.log(`[${name}] ambiente=${adapterEnv()} shim_identidade=${identityShimEnabled() ? "sim" : "nao"}`);
|
||||
}
|
||||
|
||||
export function describeTarget(): string {
|
||||
return process.env.DATABASE_URL ? "postgres" : "pglite local";
|
||||
}
|
||||
|
||||
/**
|
||||
* Executa o corpo do script.
|
||||
*
|
||||
* Existe para que nenhum script use top-level await: `tsx` compila `.ts` como
|
||||
* CommonJS quando o `package.json` não declara `"type": "module"`, e aí top-level
|
||||
* await é erro de transformação. Isso impedia rodar os scripts com
|
||||
* `node --import tsx`, que é a forma que entrega SIGTERM ao nosso processo — o
|
||||
* CLI do `tsx` cria um processo filho e o sinal não chega ao handler.
|
||||
*/
|
||||
export function runScript(name: string, body: () => Promise<void>): void {
|
||||
body().then(
|
||||
() => {
|
||||
process.exitCode ??= 0;
|
||||
},
|
||||
(error: unknown) => {
|
||||
const label = error instanceof Error ? `${error.name}: ${error.message}` : "erro";
|
||||
console.error(`[${name}] ${label}`);
|
||||
process.exit(1);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Abre o banco, executa e fecha, mesmo em caso de falha. */
|
||||
export function runWithDb(name: string, body: (db: Db) => Promise<void>): void {
|
||||
runScript(name, async () => {
|
||||
const db = await openOperationalDb();
|
||||
try {
|
||||
await body(db);
|
||||
} finally {
|
||||
await db.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { assertPrivateBucket } from "../src/infra/storage/supabase";
|
||||
import { runScript } from "./_bootstrap";
|
||||
|
||||
/** Confere que o bucket existe e é privado. Não cria bucket. */
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
const bucket = process.env.SUPABASE_STORAGE_BUCKET;
|
||||
if (!url || !key || !bucket) {
|
||||
console.error("Defina NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY e SUPABASE_STORAGE_BUCKET.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
runScript("check-bucket", async () => {
|
||||
await assertPrivateBucket(url, key, bucket);
|
||||
console.log(`bucket "${bucket}" existe e é privado`);
|
||||
});
|
||||
@@ -1,12 +1,11 @@
|
||||
import path from "node:path";
|
||||
import { createFileDb } from "../src/infra/db/pglite";
|
||||
import { createPostgresDb } from "../src/infra/db/postgres";
|
||||
import { MemoryCoordinator } from "../src/infra/redis/memory";
|
||||
import { createCoordinator } from "../src/infra/redis";
|
||||
import { createStorage } from "../src/infra/storage";
|
||||
import { cleanupExpired } from "../src/server/spreadsheet-adapter/process-job";
|
||||
import { bootstrapScript, runWithDb } from "./_bootstrap";
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite"));
|
||||
const removed = await cleanupExpired({ db, storage: createStorage(), coordinator: new MemoryCoordinator() });
|
||||
console.log(`temporários expirados: ${removed}`);
|
||||
await db.close();
|
||||
bootstrapScript("cleanup-temp");
|
||||
|
||||
runWithDb("cleanup-temp", async (db) => {
|
||||
const removed = await cleanupExpired({ db, storage: createStorage(), coordinator: createCoordinator() });
|
||||
console.log(`temporários expirados: ${removed}`);
|
||||
});
|
||||
+27
-9
@@ -1,10 +1,28 @@
|
||||
import { createFileDb } from "../src/infra/db/pglite";
|
||||
import { createPostgresDb } from "../src/infra/db/postgres";
|
||||
import { migrate } from "../src/infra/db/migrate";
|
||||
import path from "node:path";
|
||||
import { identityShimEnabled } from "../src/config/env";
|
||||
import { migrate, pendingMigrations } from "../src/infra/db/migrate";
|
||||
import { bootstrapScript, describeTarget, runWithDb } from "./_bootstrap";
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite"));
|
||||
const ran = await migrate(db);
|
||||
console.log(ran.length ? `migrations: ${ran.join(", ")}` : "migrations: nada pendente");
|
||||
await db.close();
|
||||
/**
|
||||
* Aplica as migrations pendentes. `--dry-run` apenas lista: usado no pipeline
|
||||
* antes do passo manual de migração, para que ninguém descubra o que vai rodar
|
||||
* só depois de ter rodado.
|
||||
*/
|
||||
const dryRun = process.argv.includes("--dry-run");
|
||||
|
||||
bootstrapScript("migrate");
|
||||
|
||||
runWithDb("migrate", async (db) => {
|
||||
const pending = await pendingMigrations(db);
|
||||
console.log(`alvo: ${describeTarget()}`);
|
||||
console.log(`shim de identidade: ${identityShimEnabled() ? "SERÁ aplicado" : "NÃO será aplicado"}`);
|
||||
if (pending.length === 0) {
|
||||
console.log("migrations: nada pendente");
|
||||
return;
|
||||
}
|
||||
if (dryRun) {
|
||||
console.log(`migrations pendentes (dry-run, nada aplicado): ${pending.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
const ran = await migrate(db);
|
||||
console.log(ran.length ? `migrations aplicadas: ${ran.join(", ")}` : "migrations: nada pendente");
|
||||
});
|
||||
+13
-9
@@ -1,12 +1,16 @@
|
||||
import path from "node:path";
|
||||
import { createFileDb } from "../src/infra/db/pglite";
|
||||
import { createPostgresDb } from "../src/infra/db/postgres";
|
||||
import { adapterEnv } from "../src/config/env";
|
||||
import { migrate } from "../src/infra/db/migrate";
|
||||
import { ensureDevFixtures } from "../src/dev/fixtures";
|
||||
import { bootstrapScript, runWithDb } from "./_bootstrap";
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite"));
|
||||
await migrate(db);
|
||||
await ensureDevFixtures(db);
|
||||
console.log("fixtures de desenvolvimento conferidas");
|
||||
await db.close();
|
||||
bootstrapScript("seed-dev");
|
||||
if (adapterEnv() !== "development") {
|
||||
console.error("seed-dev só roda em development. Para homologação use `npm run seed:staging`.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
runWithDb("seed-dev", async (db) => {
|
||||
await migrate(db);
|
||||
await ensureDevFixtures(db);
|
||||
console.log("fixtures de desenvolvimento conferidas");
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { adapterEnv, isProductionEnv } from "../src/config/env";
|
||||
import { migrate } from "../src/infra/db/migrate";
|
||||
import type { Db } from "../src/infra/db/types";
|
||||
import { bootstrapScript, describeTarget, openOperationalDb, runScript } from "./_bootstrap";
|
||||
|
||||
/**
|
||||
* Seed idempotente de homologação.
|
||||
*
|
||||
* Não cria conta, senha ou sessão: as identidades vivem no Supabase Auth do
|
||||
* projeto de homologação e são criadas pela operação no painel do Supabase.
|
||||
* Este script apenas liga aqueles `user_id` já existentes a workspaces e
|
||||
* papéis. Rodar duas vezes produz o mesmo estado.
|
||||
*
|
||||
* Arquivo: `ADAPTER_STAGING_SEED_FILE` (padrão `config/staging-seed.json`).
|
||||
* Modelo sem segredo em `config/staging-seed.example.json`.
|
||||
*/
|
||||
|
||||
type SeedWorkspace = { id: string; name: string; moduleEnabled: boolean; allowNonAdminModelUpload?: boolean };
|
||||
type SeedUser = { id: string; displayName: string; isSuperadmin?: boolean };
|
||||
type SeedMember = { workspaceId: string; userId: string; role: "owner" | "admin" | "gestor" | "agent" };
|
||||
type SeedFile = { workspaces: SeedWorkspace[]; users: SeedUser[]; members: SeedMember[] };
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const ROLES = new Set(["owner", "admin", "gestor", "agent"]);
|
||||
|
||||
function assertSeed(seed: SeedFile): void {
|
||||
const workspaceIds = new Set<string>();
|
||||
for (const workspace of seed.workspaces) {
|
||||
if (!UUID_RE.test(workspace.id)) throw new Error(`workspace.id não é UUID: ${workspace.id}`);
|
||||
if (!workspace.name?.trim()) throw new Error(`workspace ${workspace.id} sem nome`);
|
||||
workspaceIds.add(workspace.id.toLowerCase());
|
||||
}
|
||||
const userIds = new Set<string>();
|
||||
for (const user of seed.users) {
|
||||
if (!UUID_RE.test(user.id)) {
|
||||
throw new Error(`users[].id deve ser o UUID do usuário no Supabase Auth: ${user.id}`);
|
||||
}
|
||||
if (!user.displayName?.trim()) throw new Error(`usuário ${user.id} sem displayName`);
|
||||
userIds.add(user.id.toLowerCase());
|
||||
}
|
||||
for (const member of seed.members) {
|
||||
if (!ROLES.has(member.role)) throw new Error(`papel inválido: ${member.role}`);
|
||||
if (!workspaceIds.has(member.workspaceId.toLowerCase())) {
|
||||
throw new Error(`membro aponta workspace fora do seed: ${member.workspaceId}`);
|
||||
}
|
||||
if (!userIds.has(member.userId.toLowerCase())) {
|
||||
throw new Error(`membro aponta usuário fora do seed: ${member.userId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function applySeed(db: Db, seed: SeedFile): Promise<void> {
|
||||
await db.withService(async (query) => {
|
||||
for (const workspace of seed.workspaces) {
|
||||
await query.query(
|
||||
`insert into public.workspaces (id, name) values ($1, $2)
|
||||
on conflict (id) do update set name = excluded.name`,
|
||||
[workspace.id, workspace.name],
|
||||
);
|
||||
await query.query(
|
||||
`insert into public.workspace_module_flags (workspace_id, module_key, enabled)
|
||||
values ($1, 'spreadsheet_adapter', $2)
|
||||
on conflict (workspace_id, module_key) do update set enabled = excluded.enabled, updated_at = now()`,
|
||||
[workspace.id, workspace.moduleEnabled],
|
||||
);
|
||||
if (workspace.allowNonAdminModelUpload !== undefined) {
|
||||
await query.query(
|
||||
`insert into public.spreadsheet_adapter_settings (workspace_id, allow_non_admin_model_upload)
|
||||
values ($1, $2)
|
||||
on conflict (workspace_id) do update set allow_non_admin_model_upload = excluded.allow_non_admin_model_upload`,
|
||||
[workspace.id, workspace.allowNonAdminModelUpload],
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const user of seed.users) {
|
||||
await query.query(
|
||||
`insert into public.profiles (id, display_name, is_superadmin) values ($1, $2, $3)
|
||||
on conflict (id) do update set display_name = excluded.display_name, is_superadmin = excluded.is_superadmin`,
|
||||
[user.id, user.displayName, user.isSuperadmin ?? false],
|
||||
);
|
||||
}
|
||||
for (const member of seed.members) {
|
||||
await query.query(
|
||||
`insert into public.workspace_members (workspace_id, user_id, role) values ($1, $2, $3)
|
||||
on conflict (workspace_id, user_id) do update set role = excluded.role`,
|
||||
[member.workspaceId, member.userId, member.role],
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bootstrapScript("seed-staging");
|
||||
|
||||
if (isProductionEnv()) {
|
||||
console.error("seed-staging não roda em produção. As associações de produção são da plataforma Elo Pro.");
|
||||
process.exit(1);
|
||||
}
|
||||
if (adapterEnv() === "development" && process.env.ADAPTER_SEED_FORCE !== "1") {
|
||||
console.error("Em development use `npm run seed`. Para forçar este seed, defina ADAPTER_SEED_FORCE=1.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const file = process.env.ADAPTER_STAGING_SEED_FILE ?? path.join("config", "staging-seed.json");
|
||||
|
||||
runScript("seed-staging", async () => {
|
||||
let seed: SeedFile;
|
||||
try {
|
||||
seed = JSON.parse(await readFile(path.resolve(process.cwd(), file), "utf8")) as SeedFile;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Não foi possível ler o seed em "${file}". Copie config/staging-seed.example.json e preencha com os UUID do Supabase Auth.`,
|
||||
);
|
||||
console.error(error instanceof Error ? error.name : "erro");
|
||||
process.exit(1);
|
||||
}
|
||||
seed.workspaces ??= [];
|
||||
seed.users ??= [];
|
||||
seed.members ??= [];
|
||||
assertSeed(seed);
|
||||
|
||||
const db = await openOperationalDb();
|
||||
try {
|
||||
const ran = await migrate(db);
|
||||
if (ran.length > 0) console.log(`migrations aplicadas antes do seed: ${ran.join(", ")}`);
|
||||
await applySeed(db, seed);
|
||||
console.log(
|
||||
`seed de homologação aplicado em ${describeTarget()}: ${seed.workspaces.length} workspace(s), ${seed.users.length} identidade(s), ${seed.members.length} associação(ões)`,
|
||||
);
|
||||
} finally {
|
||||
await db.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
+17
-10
@@ -1,16 +1,23 @@
|
||||
import path from "node:path";
|
||||
import { createFileDb } from "../src/infra/db/pglite";
|
||||
import { createPostgresDb } from "../src/infra/db/postgres";
|
||||
import { bootstrapScript, describeTarget, runWithDb } from "./_bootstrap";
|
||||
|
||||
const workspaceId = process.argv[2];
|
||||
const enabled = process.argv[3] !== "off";
|
||||
if (!workspaceId) {
|
||||
const action = process.argv[3];
|
||||
if (!workspaceId || !action || !["on", "off"].includes(action)) {
|
||||
console.error("uso: npm run flag -- <workspace_id> on|off");
|
||||
process.exit(1);
|
||||
}
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite"));
|
||||
await db.withService(async (query) => {
|
||||
const enabled = action === "on";
|
||||
|
||||
bootstrapScript("set-flag");
|
||||
|
||||
runWithDb("set-flag", async (db) => {
|
||||
const existing = await db.withService((query) =>
|
||||
query.query<{ name: string }>(`select name from public.workspaces where id = $1`, [workspaceId]),
|
||||
);
|
||||
if (!existing[0]) {
|
||||
throw new Error(`workspace ${workspaceId} não existe em ${describeTarget()}. Nada foi alterado.`);
|
||||
}
|
||||
await db.withService(async (query) => {
|
||||
await query.query(
|
||||
`insert into public.workspace_module_flags (workspace_id, module_key, enabled)
|
||||
values ($1, 'spreadsheet_adapter', $2)
|
||||
@@ -25,6 +32,6 @@ await db.withService(async (query) => {
|
||||
limit 1`,
|
||||
[workspaceId, enabled],
|
||||
);
|
||||
});
|
||||
console.log(`spreadsheet_adapter ${enabled ? "ligado" : "desligado"} para "${existing[0].name}" (${workspaceId})`);
|
||||
});
|
||||
console.log(`spreadsheet_adapter ${enabled ? "ligado" : "desligado"}`);
|
||||
await db.close();
|
||||
@@ -0,0 +1,259 @@
|
||||
import { runScript } from "./_bootstrap";
|
||||
|
||||
/**
|
||||
* Smoke de homologação contra o ambiente em pé.
|
||||
*
|
||||
* Fase 1 (sempre): saúde, recusa sem sessão, cabeçalhos, ausência de dev auth.
|
||||
* Fase 2 (só com credencial fornecida): login real no Supabase Auth de
|
||||
* homologação, resolução de workspace e leitura autenticada do módulo.
|
||||
*
|
||||
* Nenhuma credencial é inventada aqui. A fase 2 só roda quando a operação
|
||||
* fornece as variáveis abaixo, com uma conta criada no painel do Supabase:
|
||||
*
|
||||
* ADAPTER_SMOKE_URL URL pública, ex. https://hml-ada.elopro.app
|
||||
* ADAPTER_SMOKE_EMAIL e-mail de uma conta de teste de homologação
|
||||
* ADAPTER_SMOKE_PASSWORD senha dessa conta
|
||||
* ADAPTER_SMOKE_WORKSPACE (opcional) UUID do workspace esperado
|
||||
*
|
||||
* Sem ADAPTER_SMOKE_EMAIL/PASSWORD a fase 2 é reportada como PENDENTE, nunca
|
||||
* como aprovada. O envio de planilha, o processamento e o download seguem no
|
||||
* roteiro manual do runbook: exigem arquivo e a flag do módulo ligada.
|
||||
*/
|
||||
|
||||
type Check = { name: string; ok: boolean; detail: string; pending?: boolean };
|
||||
|
||||
const base = (process.env.ADAPTER_SMOKE_URL ?? "").replace(/\/$/, "");
|
||||
const email = process.env.ADAPTER_SMOKE_EMAIL;
|
||||
const password = process.env.ADAPTER_SMOKE_PASSWORD;
|
||||
const expectedWorkspace = process.env.ADAPTER_SMOKE_WORKSPACE;
|
||||
|
||||
if (!base) {
|
||||
console.error("Defina ADAPTER_SMOKE_URL com a URL pública do ambiente.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const checks: Check[] = [];
|
||||
|
||||
function record(name: string, ok: boolean, detail: string) {
|
||||
checks.push({ name, ok, detail });
|
||||
console.log(`${ok ? "PASS" : "FAIL"} ${name} — ${detail}`);
|
||||
}
|
||||
|
||||
function pending(name: string, detail: string) {
|
||||
checks.push({ name, ok: false, detail, pending: true });
|
||||
console.log(`PENDENTE ${name} — ${detail}`);
|
||||
}
|
||||
|
||||
async function fetchJson(path: string, init?: RequestInit): Promise<{ status: number; body: unknown; response: Response }> {
|
||||
const response = await fetch(`${base}${path}`, {
|
||||
redirect: "manual",
|
||||
...init,
|
||||
headers: { accept: "application/json", ...(init?.headers ?? {}) },
|
||||
});
|
||||
const text = await response.text();
|
||||
let body: unknown = text;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
/* resposta não-JSON entra como texto */
|
||||
}
|
||||
return { status: response.status, body, response };
|
||||
}
|
||||
|
||||
/** Junta os cookies de Set-Cookie para a próxima requisição. */
|
||||
function collectCookies(response: Response, jar: Map<string, string>): void {
|
||||
const raw = typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : [];
|
||||
for (const line of raw) {
|
||||
const [pair] = line.split(";");
|
||||
const index = pair.indexOf("=");
|
||||
if (index > 0) jar.set(pair.slice(0, index).trim(), pair.slice(index + 1).trim());
|
||||
}
|
||||
}
|
||||
|
||||
function cookieHeader(jar: Map<string, string>): string {
|
||||
return [...jar.entries()].map(([name, value]) => `${name}=${value}`).join("; ");
|
||||
}
|
||||
|
||||
runScript("smoke-staging", async () => {
|
||||
// ---------------------------------------------------------------- fase 1
|
||||
console.log(`== fase 1: ambiente em ${base} ==`);
|
||||
|
||||
try {
|
||||
const live = await fetchJson("/api/health/live");
|
||||
const body = live.body as { status?: string };
|
||||
record("health/live", live.status === 200 && body?.status === "live", `status ${live.status}`);
|
||||
} catch (error) {
|
||||
record("health/live", false, error instanceof Error ? error.message : "falha de rede");
|
||||
}
|
||||
|
||||
let environment: string | undefined;
|
||||
try {
|
||||
const ready = await fetchJson("/api/health/ready");
|
||||
const body = ready.body as {
|
||||
status?: string;
|
||||
environment?: string;
|
||||
checks?: Record<string, string>;
|
||||
reasons?: Record<string, string>;
|
||||
configIssues?: string[];
|
||||
};
|
||||
environment = body?.environment;
|
||||
record(
|
||||
"health/ready",
|
||||
ready.status === 200 && body?.status === "ready",
|
||||
`status ${ready.status} checks=${JSON.stringify(body?.checks ?? {})} reasons=${JSON.stringify(body?.reasons ?? {})}`,
|
||||
);
|
||||
record("ambiente é staging", body?.environment === "staging", `environment=${body?.environment ?? "?"}`);
|
||||
record(
|
||||
"sem pendência de configuração",
|
||||
(body?.configIssues ?? []).length === 0,
|
||||
(body?.configIssues ?? []).join("; ") || "nenhuma",
|
||||
);
|
||||
} catch (error) {
|
||||
record("health/ready", false, error instanceof Error ? error.message : "falha de rede");
|
||||
}
|
||||
|
||||
try {
|
||||
const anon = await fetchJson("/api/spreadsheet-adapter/models");
|
||||
record("API sem sessão recusa", anon.status === 401, `status ${anon.status}`);
|
||||
} catch (error) {
|
||||
record("API sem sessão recusa", false, error instanceof Error ? error.message : "falha de rede");
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await fetch(`${base}/adaptador`, { redirect: "manual" });
|
||||
const location = page.headers.get("location") ?? "";
|
||||
record(
|
||||
"/adaptador sem sessão redireciona ao login",
|
||||
page.status >= 300 && page.status < 400 && location.includes("/login"),
|
||||
`status ${page.status} location=${location || "(vazio)"}`,
|
||||
);
|
||||
} catch (error) {
|
||||
record("/adaptador sem sessão redireciona ao login", false, error instanceof Error ? error.message : "falha de rede");
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${base}/api/health/live`, { redirect: "manual" });
|
||||
const required = [
|
||||
"content-security-policy",
|
||||
"strict-transport-security",
|
||||
"x-content-type-options",
|
||||
"x-frame-options",
|
||||
"referrer-policy",
|
||||
"permissions-policy",
|
||||
];
|
||||
const missing = required.filter((header) => !response.headers.get(header));
|
||||
record("cabeçalhos de segurança", missing.length === 0, missing.length ? `faltam: ${missing.join(", ")}` : "todos presentes");
|
||||
} catch (error) {
|
||||
record("cabeçalhos de segurança", false, error instanceof Error ? error.message : "falha de rede");
|
||||
}
|
||||
|
||||
// Dev auth não pode existir em homologação pública, em nenhuma circunstância.
|
||||
try {
|
||||
const dev = await fetch(`${base}/api/dev/session`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
userId: "00000000-0000-4000-8000-000000000000",
|
||||
workspaceId: "00000000-0000-4000-8000-000000000000",
|
||||
}),
|
||||
});
|
||||
record("dev auth indisponível", dev.status === 404, `status ${dev.status}`);
|
||||
} catch (error) {
|
||||
record("dev auth indisponível", false, error instanceof Error ? error.message : "falha de rede");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- fase 2
|
||||
console.log("");
|
||||
console.log("== fase 2: login real no Supabase Auth de homologação ==");
|
||||
|
||||
if (!email || !password) {
|
||||
pending(
|
||||
"login real",
|
||||
"defina ADAPTER_SMOKE_EMAIL e ADAPTER_SMOKE_PASSWORD de uma conta criada no painel do Supabase de homologação",
|
||||
);
|
||||
pending("resolução de workspace autenticada", "depende do login real");
|
||||
pending("leitura autenticada do módulo", "depende do login real");
|
||||
} else {
|
||||
const jar = new Map<string, string>();
|
||||
let logged = false;
|
||||
try {
|
||||
const login = await fetch(`${base}/api/auth/staging/session`, {
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
collectCookies(login, jar);
|
||||
logged = login.status === 200 && jar.size > 0;
|
||||
record("login real", logged, `status ${login.status} cookies=${jar.size}`);
|
||||
} catch (error) {
|
||||
record("login real", false, error instanceof Error ? error.message : "falha de rede");
|
||||
}
|
||||
|
||||
if (logged) {
|
||||
try {
|
||||
const workspaces = await fetchJson("/api/auth/workspace", { headers: { cookie: cookieHeader(jar) } });
|
||||
const body = workspaces.body as { memberships?: { workspaceId: string; workspaceName: string; role: string }[] };
|
||||
const list = body?.memberships ?? [];
|
||||
record(
|
||||
"resolução de workspace autenticada",
|
||||
workspaces.status === 200 && list.length > 0,
|
||||
`status ${workspaces.status} associações=${list.length}`,
|
||||
);
|
||||
if (expectedWorkspace) {
|
||||
record(
|
||||
"workspace esperado está associado",
|
||||
list.some((item) => item.workspaceId.toLowerCase() === expectedWorkspace.toLowerCase()),
|
||||
`esperado=${expectedWorkspace}`,
|
||||
);
|
||||
}
|
||||
if (list.length > 0) {
|
||||
const chosen = expectedWorkspace ?? list[0].workspaceId;
|
||||
const select = await fetch(`${base}/api/auth/workspace`, {
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
headers: { "Content-Type": "application/json", cookie: cookieHeader(jar) },
|
||||
body: JSON.stringify({ workspaceId: chosen }),
|
||||
});
|
||||
collectCookies(select, jar);
|
||||
record("seleção de workspace aceita", select.status === 200, `status ${select.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
record("resolução de workspace autenticada", false, error instanceof Error ? error.message : "falha de rede");
|
||||
}
|
||||
|
||||
try {
|
||||
const models = await fetchJson("/api/spreadsheet-adapter/models", { headers: { cookie: cookieHeader(jar) } });
|
||||
// 200 com o módulo ligado; 403 FEATURE_DISABLED é resposta correta
|
||||
// enquanto a feature flag do workspace continua desligada.
|
||||
const body = models.body as { error?: { code?: string } };
|
||||
const aceitavel = models.status === 200 || body?.error?.code === "FEATURE_DISABLED";
|
||||
record(
|
||||
"leitura autenticada do módulo",
|
||||
aceitavel,
|
||||
`status ${models.status} code=${body?.error?.code ?? "ok"}`,
|
||||
);
|
||||
} catch (error) {
|
||||
record("leitura autenticada do módulo", false, error instanceof Error ? error.message : "falha de rede");
|
||||
}
|
||||
} else {
|
||||
pending("resolução de workspace autenticada", "login não concluiu");
|
||||
pending("leitura autenticada do módulo", "login não concluiu");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- resumo
|
||||
const failed = checks.filter((check) => !check.ok && !check.pending);
|
||||
const pendentes = checks.filter((check) => check.pending);
|
||||
console.log("");
|
||||
console.log(`${checks.length - failed.length - pendentes.length}/${checks.length} aprovadas, ${pendentes.length} pendentes, ${failed.length} falhas.`);
|
||||
console.log(`ambiente reportado: ${environment ?? "desconhecido"}`);
|
||||
console.log("");
|
||||
console.log("Continua fora deste script e obrigatório no runbook:");
|
||||
console.log(" - envio de modelo e de fonte pela interface");
|
||||
console.log(" - processamento assíncrono com o worker e download XLSX, CSV e PDF");
|
||||
console.log(" - troca de workspace e sessão expirada no navegador");
|
||||
console.log(" - evento controlado no Sentry, confirmado sem conteúdo de célula");
|
||||
console.log(" - ensaio de rollback de imagem");
|
||||
if (failed.length > 0) process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { adapterEnv, collectConfigIssues, identityShimEnabled, jobRunsInline, stagingAuthEnabled } from "../src/config/env";
|
||||
|
||||
/**
|
||||
* Confere a configuração sem tocar em recurso externo. Usado no pipeline antes
|
||||
* do deploy e dentro do container no primeiro start.
|
||||
*/
|
||||
const issues = collectConfigIssues(process.env);
|
||||
console.log(`ambiente: ${adapterEnv()}`);
|
||||
console.log(`modo de job: ${jobRunsInline() ? "inline" : "worker"}`);
|
||||
console.log(`shim de identidade: ${identityShimEnabled() ? "aplicar" : "não aplicar"}`);
|
||||
console.log(`login próprio de homologação: ${stagingAuthEnabled() ? "ligado" : "desligado"}`);
|
||||
if (issues.length === 0) {
|
||||
console.log("configuração aceita");
|
||||
process.exit(0);
|
||||
}
|
||||
console.error("configuração recusada:");
|
||||
for (const issue of issues) console.error(` - ${issue.variable}: ${issue.message}`);
|
||||
process.exit(1);
|
||||
+64
-67
@@ -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 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.");
|
||||
}
|
||||
const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite"));
|
||||
await migrate(db);
|
||||
const deps = { db, storage: createStorage(), coordinator: createCoordinator() };
|
||||
|
||||
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);
|
||||
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.`);
|
||||
}
|
||||
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 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}`,
|
||||
);
|
||||
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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await tick();
|
||||
setInterval(() => {
|
||||
void tick();
|
||||
}, 2000);
|
||||
await worker.tick();
|
||||
});
|
||||
@@ -7,7 +7,6 @@ import { canUseSpreadsheetAdapter } from "@/domain/spreadsheet-adapter/permissio
|
||||
import { transformRows } from "@/domain/spreadsheet-adapter/transform";
|
||||
import type { JobProgress, OutputFormat, RequestContext } from "@/domain/spreadsheet-adapter/types";
|
||||
import { buildCsv, buildXlsx } from "@/infra/export/tabular";
|
||||
import { buildPdf } from "@/infra/export/pdf";
|
||||
import type { Query } from "@/infra/db/types";
|
||||
import { lockKey } from "@/infra/redis/redis";
|
||||
import { sanitizeAuditMetadata } from "@/infra/observability/scrub";
|
||||
@@ -282,6 +281,10 @@ export async function downloadOutput(deps: AppDeps, ctx: RequestContext, jobId:
|
||||
const csv = (await deps.storage.get(file.output_csv_key)).toString("utf8");
|
||||
const grid = parseCsvText(csv);
|
||||
const headers = grid[0] ?? [];
|
||||
// Import tardio: o worker não gera PDF (o PDF sai sob demanda no download),
|
||||
// e carregar @react-pdf/renderer fora do bundler do Next falha ao resolver
|
||||
// o subpath de hifenização. Sem isto o processo do worker nem subia.
|
||||
const { buildPdf } = await import("@/infra/export/pdf");
|
||||
const body = await buildPdf(file.model_name, headers, grid.slice(1));
|
||||
return { filename, contentType: CONTENT_TYPE.pdf, body: body.body };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { asJson } from "./db-utils";
|
||||
import { cleanupExpired, listQueuedJobIds, readJobForWorker, recoverStaleJobs, runProcess } from "./process-job";
|
||||
import type { AppDeps } from "./deps";
|
||||
import type { JobProgress, RequestContext, Role } from "@/domain/spreadsheet-adapter/types";
|
||||
|
||||
/**
|
||||
* Loop do worker, fora do script de entrada para poder ser testado sem subir
|
||||
* um processo. O script `scripts/worker.ts` só cuida de sinais e de bootstrap.
|
||||
*
|
||||
* Sem `import "server-only"` de propósito: este módulo roda também no processo
|
||||
* do worker, que é Node puro. O pacote `server-only` lança quando é carregado
|
||||
* fora do bundler do Next, e isso derrubava o worker no start.
|
||||
*/
|
||||
|
||||
export type WorkerOptions = {
|
||||
/** Máximo de ids retirados da fila do Redis por tick. */
|
||||
batch?: number;
|
||||
/** Caminho do arquivo de heartbeat lido pelo health check do contêiner. */
|
||||
heartbeatPath?: string | null;
|
||||
/** Injetável para teste. */
|
||||
writeHeartbeat?: (path: string) => Promise<void>;
|
||||
log?: (message: string) => void;
|
||||
onError?: (scope: string, error: unknown) => void;
|
||||
};
|
||||
|
||||
export type TickResult = {
|
||||
/** Jobs que o worker tentou processar neste tick. */
|
||||
processed: string[];
|
||||
/** Temporários expirados removidos neste tick. */
|
||||
cleaned: number;
|
||||
recovered: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_BATCH = 20;
|
||||
|
||||
async function defaultWriteHeartbeat(target: string): Promise<void> {
|
||||
const { writeFile } = await import("node:fs/promises");
|
||||
await writeFile(target, new Date().toISOString(), "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Contexto do dono do job, relido do banco a cada execução. Nunca vem do
|
||||
* payload do job: papel, flag do módulo e configuração podem ter mudado entre
|
||||
* o enfileiramento e o processamento.
|
||||
*/
|
||||
async function loadContext(deps: AppDeps, userId: string, workspaceId: string): Promise<RequestContext | null> {
|
||||
const rows = await deps.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`,
|
||||
[userId, workspaceId],
|
||||
),
|
||||
);
|
||||
const found = rows[0];
|
||||
if (!found) return null;
|
||||
return {
|
||||
userId,
|
||||
displayName: found.display_name,
|
||||
isSuperadmin: found.is_superadmin,
|
||||
workspaceId,
|
||||
workspaceName: found.workspace_name,
|
||||
role: found.role,
|
||||
moduleEnabled: found.module_enabled,
|
||||
allowNonAdminModelUpload: found.allow_upload,
|
||||
};
|
||||
}
|
||||
|
||||
export function createWorker(deps: AppDeps, options: WorkerOptions = {}) {
|
||||
const batch = options.batch ?? DEFAULT_BATCH;
|
||||
const heartbeatPath = options.heartbeatPath ?? null;
|
||||
const writeHeartbeat = options.writeHeartbeat ?? defaultWriteHeartbeat;
|
||||
const onError = options.onError ?? (() => undefined);
|
||||
|
||||
let running = true;
|
||||
let inTick = false;
|
||||
|
||||
async function tick(): Promise<TickResult> {
|
||||
if (!running || inTick) return { processed: [], cleaned: 0, recovered: false };
|
||||
inTick = true;
|
||||
const processed: string[] = [];
|
||||
let cleaned = 0;
|
||||
let recovered = false;
|
||||
try {
|
||||
recovered = await recoverStaleJobs(deps)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
onError("recover", error);
|
||||
return false;
|
||||
});
|
||||
cleaned = await cleanupExpired(deps).catch((error) => {
|
||||
onError("cleanup", error);
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Duas origens para o mesmo trabalho: a fila do Redis é o caminho rápido,
|
||||
// a varredura do Postgres garante que nada se perde se o Redis reiniciar.
|
||||
const queued = new Set(
|
||||
await listQueuedJobIds(deps).catch((error) => {
|
||||
onError("list", error);
|
||||
return [] as string[];
|
||||
}),
|
||||
);
|
||||
for (let index = 0; index < batch; index += 1) {
|
||||
const jobId = await deps.coordinator.dequeue().catch(() => null);
|
||||
if (!jobId) break;
|
||||
queued.add(jobId);
|
||||
}
|
||||
|
||||
for (const jobId of queued) {
|
||||
if (!running) break;
|
||||
const rows = await readJobForWorker(deps, jobId).catch((error) => {
|
||||
onError("read", error);
|
||||
return [];
|
||||
});
|
||||
const row = rows[0];
|
||||
if (!row) continue;
|
||||
const progress = asJson<JobProgress | null>(row.progress, null);
|
||||
if (!progress?.sourceId) continue;
|
||||
const ctx = await loadContext(deps, row.user_id, row.workspace_id).catch((error) => {
|
||||
onError("context", error);
|
||||
return null;
|
||||
});
|
||||
if (!ctx) continue;
|
||||
await runProcess(deps, ctx, jobId, progress.sourceId)
|
||||
.then(() => {
|
||||
processed.push(jobId);
|
||||
})
|
||||
.catch((error) => {
|
||||
onError("process", error);
|
||||
});
|
||||
}
|
||||
|
||||
if (heartbeatPath) {
|
||||
await writeHeartbeat(heartbeatPath).catch((error) => onError("heartbeat", error));
|
||||
}
|
||||
} finally {
|
||||
inTick = false;
|
||||
}
|
||||
return { processed, cleaned, recovered };
|
||||
}
|
||||
|
||||
/**
|
||||
* Para de aceitar trabalho novo e espera o tick corrente terminar, para que o
|
||||
* `finally` do processamento libere o lock. Sem essa espera o job ficaria em
|
||||
* `processing` até o lock vencer por TTL.
|
||||
*/
|
||||
async function shutdown(graceMs = 20_000, sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))): Promise<boolean> {
|
||||
running = false;
|
||||
const deadline = Date.now() + graceMs;
|
||||
while (inTick && Date.now() < deadline) {
|
||||
await sleep(100);
|
||||
}
|
||||
return !inTick;
|
||||
}
|
||||
|
||||
return {
|
||||
tick,
|
||||
shutdown,
|
||||
isRunning: () => running,
|
||||
isBusy: () => inTick,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user