Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ SEARCH_KEYWORDS=UX Designer,UI Designer,Product Manager,Product Owner

# Scraping behavior
SCRAPER_MAX_CONCURRENCY=12
SCRAPER_RUN_LOCK_TTL=120s
SCRAPER_RUN_LOCK_RENEW_INTERVAL=30s
GOMAXPROCS=2
GOMEMLIMIT=1500MiB
WAIT_BETWEEN_SEARCHES_MS=5000
Expand Down
5 changes: 5 additions & 0 deletions BACKEND.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,10 @@ Base: `/`
- `PATCH /admin/users/:id/unblock` — desbloqueia usuário.
- `POST /admin/users/:id/reset` — reseta credenciais/senha conforme regra do serviço.
- `POST /admin/scrapers/run` — dispara execução dos scrapers.
- Sucesso: `202` com `{ ok: true, message }`.
- Execução concorrente: `409` com `{ ok: false, code: "SCRAPER_ALREADY_RUNNING", message }`.
- Lock/Valkey indisponível: `503` com `{ ok: false, code: "SCRAPER_RUN_LOCK_UNAVAILABLE", message }`.
- `POST /admin/scrapers/:id/run` — aplica o mesmo contrato ao scraper nomeado (`go-scraper`).
- `GET /admin/observability/metrics` — visão de métricas administrativas.
- `GET /admin/observability/dashboards` — lista dashboards de observabilidade.
- `GET /admin/audit` — consulta logs de auditoria.
Expand Down Expand Up @@ -250,6 +254,7 @@ Definidas/consumidas em `src/config.ts` e outros módulos:
- `goScraper.ts` faz POST em `${GO_SCRAPER_URL}/scrape` com `ScrapeParams` e valida `ScrapeResponse`.
- `goKeywords.ts` consulta e publica keywords via endpoints do serviço Go (`/api/keywords`).
- O backend lê os índices criados pelo scraper no Valkey, incluindo `scraper:jobs:keyword:*`, `scraper:jobs:family:*`, `scraper:jobs:technology:*` e `scraper:jobs:seniority:*`.
- Disparos administrativos usam `scraperClient` e preservam os códigos operacionais do serviço Go. O código `SCRAPER_ALREADY_RUNNING` é um conflito esperado; `SCRAPER_RUN_LOCK_UNAVAILABLE` indica política fail-closed e não inicia coleta.

## Banco de dados

Expand Down
40 changes: 37 additions & 3 deletions SCRAPER.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ go run ./cmd/server

Docker: há um `Dockerfile` em `scraper-go/`. No Docker Compose, configure `VALKEY_URL=redis://valkey:6379/0` no `.env` da raiz para que o scraper acesse o Valkey pelo nome do serviço na rede Docker.

No Compose da raiz, o serviço escuta em <http://localhost:8081>.
No Compose da raiz, a porta `8081` fica exposta apenas na rede interna `vagas-net`; ela não é publicada no host. O backend acessa o serviço por `http://scraper-go:8081`. Para testes locais diretos, execute o binário fora do Compose ou use um override de desenvolvimento que publique a porta somente em interface confiável.

### Limites globais de execução

Expand All @@ -206,7 +206,26 @@ O scraper possui um orçamento global de concorrência por execução controlado
- `POST /scrape` preserva o contrato atual: quando `maxConcurrency` não é informado, ou vem como `0`/negativo, usa o limite global; quando vem positivo abaixo do teto, usa o valor solicitado; quando vem acima do teto, usa o teto global.
- A concorrência efetiva é calculada antes da chave de cache e é o mesmo valor usado pelo pipeline, logs e semáforo.

O semáforo global atual é criado uma vez por chamada do pipeline. Portanto, o limite é por execução: duas execuções simultâneas ainda podem possuir dois semáforos independentes com a mesma capacidade. Lock entre cron/manual, prevenção de simultaneidade e limites por provider pertencem às próximas sub-issues.
O semáforo global atual é criado uma vez por chamada do pipeline. O lock distribuído abaixo impede que duas execuções mantenham semáforos independentes ao mesmo tempo; limites específicos por provider permanecem para uma sub-issue posterior.

### Lock distribuído de execução

Todas as origens que iniciam adapters compartilham o lock `scraper:run:lock` no Valkey:

- cron (`source=cron`);
- disparo administrativo (`source=admin_manual`);
- cache miss de `POST /scrape` (`source=public_endpoint`).

Cache hits de `POST /scrape` não executam adapters e, por isso, não adquirem o lock. A aquisição usa `SET ... NX PX` com token aleatório por execução. Renovação e liberação usam scripts Lua que comparam o token; não existe `DEL` incondicional. O estado informativo fica no hash `scraper:run:state`, com `runId`, `source`, `startedAt` e `lockExpiresAt`, e possui o mesmo TTL do lock.

Configuração:

- `SCRAPER_RUN_LOCK_TTL`: padrão `120s`;
- `SCRAPER_RUN_LOCK_RENEW_INTERVAL`: padrão `30s`, obrigatoriamente positivo e menor que o TTL.

O mecanismo é fail-closed: se o Valkey não confirmar a aquisição, nenhum adapter é iniciado. Erros temporários de renovação são tolerados até a margem segura; perda confirmada do token ou ausência de confirmação antes dessa margem cancela o contexto da execução. A liberação ocorre no encerramento e só remove chaves pertencentes ao token atual; em crash abrupto, o TTL é a proteção final. No graceful shutdown, o scheduler deixa de aceitar novos disparos e aguarda as execuções ativas liberarem o lock antes do processo encerrar.

O token proprietário nunca é gravado no estado operacional nem nos logs. Um `runId` independente identifica a execução para observabilidade sem expor a credencial usada pelos scripts de renovação e liberação.

No Docker Compose de produção, o serviço `scraper-go` também define:

Expand All @@ -224,7 +243,7 @@ O serviço expõe endpoints HTTP (implementação em `cmd/server` e arquivos ass
- GET `/metrics` — métricas Prometheus.
- GET `/api/keywords` — retorna as keywords atualmente carregadas.
- POST `/api/keywords` — atualiza/persiste as keywords (aceita `keywords: string[]`).
- POST `/admin/scrape` — dispara uma execução manual em background; retorna 409 se já houver execução em andamento.
- POST `/admin/scrape` — dispara uma execução manual em background; retorna `409` com `SCRAPER_ALREADY_RUNNING` se já houver execução e `503` com `SCRAPER_RUN_LOCK_UNAVAILABLE` se o Valkey não confirmar a aquisição.
- GET `/admin/scrape/status` — informa se existe uma execução em andamento.
- GET `/admin/jobs/count` — retorna a quantidade de vagas persistidas no Valkey.
- GET `/admin/jobs` — lista uma amostra das vagas persistidas no Valkey; aceita `limit`.
Expand Down Expand Up @@ -356,6 +375,8 @@ Confirme no serviço `scraper-go` os equivalentes de `SCRAPER_MAX_CONCURRENCY=12

- `VALKEY_URL` — conexão Redis/Valkey. Em Docker Compose, use `redis://valkey:6379/0`; em execução local fora do Docker, use uma URL acessível pelo host, por exemplo `redis://localhost:6379/0`.
- `SCRAPER_MAX_CONCURRENCY` — teto global de concorrência por execução. Padrão: `12`. Configuração explícita inválida impede a inicialização.
- `SCRAPER_RUN_LOCK_TTL` — duração do lock distribuído. Padrão: `120s`.
- `SCRAPER_RUN_LOCK_RENEW_INTERVAL` — intervalo de renovação. Padrão: `30s`; deve ser menor que `SCRAPER_RUN_LOCK_TTL`.
- `GOMAXPROCS` — limite efetivo de threads executando código Go simultaneamente. Valor inicial no Compose: `2`.
- `GOMEMLIMIT` — meta de memória do runtime/GC. Valor inicial no Compose: `1500MiB`; não substitui `mem_limit` do container.
- `JOOBLE_API_KEY` — Jooble integration.
Expand All @@ -376,3 +397,16 @@ Confirme no serviço `scraper-go` os equivalentes de `SCRAPER_MAX_CONCURRENCY=12
- Projetado para rodar frequentemente; use caching e indexação para reduzir chamadas repetidas.
- Monitorar erros 429 e ajustar `WaitBetweenSearchesMs` / semáforos por adaptador.
- Verifique logs estruturados (slog JSON) e `/metrics` para métricas de sucesso/falhas por adaptador.
- Logs `scraper run lock acquired`, `scraper execution skipped`, `scraper run lock lost` e `scraper run lock released` identificam `source` e `run_id`.

### Verificação operacional do lock

Durante uma execução controlada:

```bash
docker exec vagas-valkey valkey-cli GET scraper:run:lock
docker exec vagas-valkey valkey-cli PTTL scraper:run:lock
docker exec vagas-valkey valkey-cli HGETALL scraper:run:state
```

Uma segunda execução manual deve retornar conflito sem iniciar adapters. Após o término, as duas chaves devem desaparecer. Nunca remova a chave manualmente apenas porque ela existe: primeiro confirme que não há processo correspondente ativo e registre valor e TTL.
20 changes: 19 additions & 1 deletion backend/src/modules/admin/scrapers/scraperClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,25 @@ const TIMEOUT_MS = 5000;

/** Lançado quando o Go scraper responde 409 (já em execução) */
export class ScraperAlreadyRunningError extends Error {
constructor(message = "scraper já está em execução") {
readonly code = "SCRAPER_ALREADY_RUNNING";

constructor(message = "Já existe uma execução do scraper em andamento.") {
super(message);
this.name = "ScraperAlreadyRunningError";
}
}

export class ScraperRunLockUnavailableError extends Error {
readonly code = "SCRAPER_RUN_LOCK_UNAVAILABLE";

constructor(
message = "Não foi possível confirmar a disponibilidade do scraper.",
) {
super(message);
this.name = "ScraperRunLockUnavailableError";
}
}

async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${config.scraperUrl}${path}`, {
...init,
Expand All @@ -28,6 +41,11 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
throw new ScraperAlreadyRunningError(body?.message);
}

if (response.status === 503) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sugestão de ajuste: atualmente qualquer resposta 503 é convertida em ScraperRunLockUnavailableError, embora o contrato também defina SCRAPER_RUN_LOCK_LOST.

Como este PR já está em revisão para ajuste, sugiro discriminar pelo body.code em vez de depender apenas do status HTTP, preservando corretamente SCRAPER_RUN_LOCK_UNAVAILABLE e SCRAPER_RUN_LOCK_LOST.

Isso evita que futuros erros operacionais 503 sejam classificados incorretamente como indisponibilidade do lock.

const body = await response.json().catch(() => null);
throw new ScraperRunLockUnavailableError(body?.message);
}

if (!response.ok) {
throw new Error(`scraper respondeu HTTP ${response.status}`);
}
Expand Down
39 changes: 34 additions & 5 deletions backend/src/modules/admin/scrapers/scrapers.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { Request, Response } from "express";
import type { AuditService } from "../audit/audit.service";
import { ScraperAlreadyRunningError } from "./scraperClient";
import {
ScraperAlreadyRunningError,
ScraperRunLockUnavailableError,
} from "./scraperClient";
import { ScrapersService } from "./scrapers.service";

export class ScrapersController {
Expand All @@ -18,7 +21,19 @@ export class ScrapersController {
res.status(202).json(result);
} catch (error) {
if (error instanceof ScraperAlreadyRunningError) {
res.status(409).json({ ok: false, message: error.message });
res.status(409).json({
ok: false,
code: error.code,
message: error.message,
});
return;
}
if (error instanceof ScraperRunLockUnavailableError) {
res.status(503).json({
ok: false,
code: error.code,
message: error.message,
});
return;
}
res.status(500).json({ ok: false, message: "erro ao iniciar scraper" });
Expand All @@ -38,12 +53,25 @@ export class ScrapersController {
res.status(202).json({ ...result, scraper: scraperName });
} catch (error) {
if (error instanceof ScraperAlreadyRunningError) {
res.status(409).json({ ok: false, message: error.message });
res.status(409).json({
ok: false,
code: error.code,
message: error.message,
});
return;
}
if (error instanceof ScraperRunLockUnavailableError) {
res.status(503).json({
ok: false,
code: error.code,
message: error.message,
});
return;
}

const message =
error instanceof Error && error.message.startsWith("scraper desconhecido")
error instanceof Error &&
error.message.startsWith("scraper desconhecido")
? error.message
: "erro ao iniciar scraper";

Expand Down Expand Up @@ -87,7 +115,8 @@ export class ScrapersController {
async listJobs(req: Request, res: Response): Promise<void> {
try {
const rawLimit = Number(req.query?.limit);
const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : undefined;
const limit =
Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : undefined;
const result = await this.scrapersService.getJobs(limit);

this.auditService.fromRequest(req, "scrapers.read", {
Expand Down
11 changes: 9 additions & 2 deletions backend/src/modules/admin/scrapers/scrapers.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { ScraperAlreadyRunningError, scraperClient } from "./scraperClient";
import {
ScraperAlreadyRunningError,
ScraperRunLockUnavailableError,
scraperClient,
} from "./scraperClient";
import type {
AdminScraper,
GetJobsResult,
Expand All @@ -13,7 +17,10 @@ export class ScrapersService {
try {
return await scraperClient.triggerScrape();
} catch (error) {
if (error instanceof ScraperAlreadyRunningError) {
if (
error instanceof ScraperAlreadyRunningError ||
error instanceof ScraperRunLockUnavailableError
) {
// repropaga para o controller decidir o status HTTP (409)
throw error;
}
Expand Down
11 changes: 11 additions & 0 deletions backend/src/modules/admin/scrapers/scrapers.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ export const TriggerScrapeResultSchema = z.object({
});
export type TriggerScrapeResult = z.infer<typeof TriggerScrapeResultSchema>;

export const TriggerScrapeErrorSchema = z.object({
ok: z.literal(false),
code: z.enum([
"SCRAPER_ALREADY_RUNNING",
"SCRAPER_RUN_LOCK_UNAVAILABLE",
"SCRAPER_RUN_LOCK_LOST",
]),
message: z.string(),
});
export type TriggerScrapeError = z.infer<typeof TriggerScrapeErrorSchema>;

// --- ScraperStatus ---
export const ScraperStatusSchema = z.object({
name: z.string().optional(),
Expand Down
Loading
Loading