Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ TIME_FILTER=r604800
SEARCH_KEYWORDS=UX Designer,UI Designer,Product Manager,Product Owner

# Scraping behavior
SCRAPER_MAX_CONCURRENCY=12
GOMAXPROCS=2
GOMEMLIMIT=1500MiB
WAIT_BETWEEN_SEARCHES_MS=5000
PAGE_TIMEOUT_MS=10000
MAX_PAGES_PER_KEYWORD=5
Expand Down
35 changes: 35 additions & 0 deletions SCRAPER.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,26 @@ Docker: há um `Dockerfile` em `scraper-go/`. No Docker Compose, configure `VALK

No Compose da raiz, o serviço escuta em <http://localhost:8081>.

### Limites globais de execução

O scraper possui um orçamento global de concorrência por execução controlado por `SCRAPER_MAX_CONCURRENCY`.

- Padrão interno: `12`, usado quando a variável não está definida.
- Valor válido: inteiro positivo.
- Valores inválidos explícitos (`""`, `0`, negativo ou não numérico) fazem a aplicação falhar no startup, sem fallback silencioso.
- Cron e `POST /admin/scrape` usam o limite global configurado.
- `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.

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

- `GOMAXPROCS=2`: limita a quantidade de threads do Go executando código simultaneamente.
- `GOMEMLIMIT=1500MiB`: define uma meta de memória para o runtime e influencia o garbage collector.
- `mem_limit: 2g`: limite externo do container. `GOMEMLIMIT` não substitui esse limite; ele fica abaixo de `2g` para preservar margem operacional.
- `cpus: 1.5`: limita o container a 1,5 CPU.

## Endpoints HTTP

O serviço expõe endpoints HTTP (implementação em `cmd/server` e arquivos associados). Principais rotas:
Expand Down Expand Up @@ -271,6 +291,7 @@ Fluxo principal:
Concorrência e resiliência:

- Semáforos por adaptador (ex.: LinkedIn usa um semáforo de 5 simultâneos para proteção).
- Orçamento global por execução via `SCRAPER_MAX_CONCURRENCY`, aplicado antes do cache e do pipeline.
- Tratamento de status 429 com backoff; aborta apenas a keyword afetada em caso de falhas persistentes.
- Uso de `inflight` para evitar que múltiplas requisições idênticas disparem scrapes simultâneos.
- Slots rotativos reduzem o número de keywords/queries por rodada em fontes caras, preservando cobertura progressiva em execuções futuras.
Expand Down Expand Up @@ -319,10 +340,24 @@ Boas práticas nos adaptadores:

- Há testes e fixtures (ex.: `internal/keywords/keywords.test.json`) para validar normalização.
- Recomenda-se executar `go test ./...` dentro de `scraper-go`.
- Para validar a configuração final do Compose, execute na raiz:

```bash
docker compose \
-f docker-compose.infra.yml \
-f docker-compose.yml \
-f docker-compose.migrate.yml \
config
```

Confirme no serviço `scraper-go` os equivalentes de `SCRAPER_MAX_CONCURRENCY=12`, `GOMAXPROCS=2`, `GOMEMLIMIT=1500MiB`, `cpus: 1.5` e `mem_limit: 2g`.

## Variáveis de ambiente importantes

- `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.
- `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.
- `ADZUNA_APP_ID` / `ADZUNA_APP_KEY` — Adzuna API.
- `LINKEDIN_KEYWORD_SLOT_SIZE` — quantidade máxima de keywords do LinkedIn por execução quando a busca vier com uma lista grande. Padrão: `30`.
Expand Down
5 changes: 5 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@ services:
context: ./scraper-go
dockerfile: Dockerfile
container_name: vagas-scraper-go
cpus: 1.5
mem_limit: 2g
env_file:
- ./.env
volumes:
- ./.env:/app/.env:ro
environment:
- GO_SCRAPER_ADDR=:8081
- VALKEY_URL=redis://valkey:6379/0
- SCRAPER_MAX_CONCURRENCY=${SCRAPER_MAX_CONCURRENCY-12}
- GOMAXPROCS=${GOMAXPROCS:-2}
- GOMEMLIMIT=${GOMEMLIMIT:-1500MiB}
- GUPY_ENABLED=${GUPY_ENABLED:-true}
- GUPY_RAW_DISCOVERY_ENABLED=${GUPY_RAW_DISCOVERY_ENABLED:-true}
- GUPY_FULL_SWEEP_ENABLED=${GUPY_FULL_SWEEP_ENABLED:-true}
Expand Down
40 changes: 23 additions & 17 deletions scraper-go/cmd/server/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/redis/go-redis/v9"

"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/cache"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/config"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/domain"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/keywords"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/pipeline"
Expand All @@ -20,7 +21,7 @@ const (
scrapeTimeout = 15 * time.Minute
)

func handleScrape(adapterList []ports.JobSource, kwStore *keywords.Store, c cache.Cache, rdb *redis.Client) http.HandlerFunc {
func handleScrape(adapterList []ports.JobSource, kwStore *keywords.Store, c cache.Cache, rdb *redis.Client, runtimeCfg config.RuntimeConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req domain.ScrapeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Expand All @@ -40,25 +41,12 @@ func handleScrape(adapterList []ports.JobSource, kwStore *keywords.Store, c cach
ctx, cancel := context.WithTimeout(r.Context(), scrapeTimeout)
defer cancel()

config := pipeline.SearchConfig{
Keywords: req.Keywords,
SearchLocation: req.SearchLocation,
SearchGeoID: req.SearchGeoID,
SearchLanguage: req.SearchLanguage,
JobTypes: req.JobTypes,
TimeFilter: req.TimeFilter,
RemoteOnly: req.RemoteOnly,
Sources: req.Sources,
ResultsPerPage: req.ResultsPerPage,
MaxPagesPerKeyword: req.MaxPagesPerKeyword,
WaitBetweenSearchesMs: req.WaitBetweenSearchesMs,
PageTimeoutMs: req.PageTimeoutMs,
MaxConcurrency: req.MaxConcurrency,
}
searchConfig := searchConfigFromRequest(req, runtimeCfg.MaxConcurrency)
slogScrapeStart("public_endpoint", runtimeCfg.MaxConcurrency, req.MaxConcurrency, searchConfig.MaxConcurrency, len(searchConfig.Keywords), len(adapterList))

start := time.Now()

result, err := pipeline.SearchJobs(ctx, c, config, adapterList, scrapeTTL, rdb)
result, err := pipeline.SearchJobs(ctx, c, searchConfig, adapterList, scrapeTTL, rdb)
if err != nil {
http.Error(w, "Erro ao buscar vagas.", http.StatusInternalServerError)
return
Expand All @@ -76,6 +64,24 @@ func handleScrape(adapterList []ports.JobSource, kwStore *keywords.Store, c cach
}
}

func searchConfigFromRequest(req domain.ScrapeRequest, globalMaxConcurrency int) pipeline.SearchConfig {
return pipeline.SearchConfig{
Keywords: req.Keywords,
SearchLocation: req.SearchLocation,
SearchGeoID: req.SearchGeoID,
SearchLanguage: req.SearchLanguage,
JobTypes: req.JobTypes,
TimeFilter: req.TimeFilter,
RemoteOnly: req.RemoteOnly,
Sources: req.Sources,
ResultsPerPage: req.ResultsPerPage,
MaxPagesPerKeyword: req.MaxPagesPerKeyword,
WaitBetweenSearchesMs: req.WaitBetweenSearchesMs,
PageTimeoutMs: req.PageTimeoutMs,
MaxConcurrency: config.ResolveEffectiveConcurrency(req.MaxConcurrency, globalMaxConcurrency),
}
}

func handleHealth(c cache.Cache) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
Expand Down
56 changes: 56 additions & 0 deletions scraper-go/cmd/server/handlers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package main

import (
"testing"

"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/domain"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/pipeline"
"github.com/stretchr/testify/assert"
)

func TestSearchConfigFromRequestUsesGlobalLimitWhenRequestMissing(t *testing.T) {
cfg := searchConfigFromRequest(domain.ScrapeRequest{}, 12)

assert.Equal(t, 12, cfg.MaxConcurrency)
}

func TestSearchConfigFromRequestUsesGlobalLimitWhenRequestIsNotPositive(t *testing.T) {
assert.Equal(t, 12, searchConfigFromRequest(domain.ScrapeRequest{MaxConcurrency: 0}, 12).MaxConcurrency)
assert.Equal(t, 12, searchConfigFromRequest(domain.ScrapeRequest{MaxConcurrency: -1}, 12).MaxConcurrency)
}

func TestSearchConfigFromRequestPreservesRequestBelowLimit(t *testing.T) {
cfg := searchConfigFromRequest(domain.ScrapeRequest{MaxConcurrency: 8}, 12)

assert.Equal(t, 8, cfg.MaxConcurrency)
}

func TestSearchConfigFromRequestCapsRequestAboveLimitBeforeCacheKey(t *testing.T) {
req := domain.ScrapeRequest{
Keywords: []string{"go"},
SearchLocation: "Brasil",
MaxConcurrency: 40,
}

cfg := searchConfigFromRequest(req, 12)

assert.Equal(t, 12, cfg.MaxConcurrency)
assert.Contains(t, pipeline.BuildCacheKey(cfg), ":12")
}

func TestSearchConfigFromRequestUsesSameCacheKeyForRequestsAboveLimit(t *testing.T) {
base := domain.ScrapeRequest{
Keywords: []string{"go"},
SearchLocation: "Brasil",
}

req40 := base
req40.MaxConcurrency = 40
req100 := base
req100.MaxConcurrency = 100

key40 := pipeline.BuildCacheKey(searchConfigFromRequest(req40, 12))
key100 := pipeline.BuildCacheKey(searchConfigFromRequest(req100, 12))

assert.Equal(t, key40, key100)
}
49 changes: 48 additions & 1 deletion scraper-go/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ package main
import (
"log/slog"
"os"
"runtime"
"runtime/debug"
"strconv"

"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/config"
)

func main() {
Expand All @@ -11,6 +16,13 @@ func main() {

loadEnv()

runtimeCfg, err := config.LoadRuntimeConfig()
if err != nil {
slog.Error("configuração inválida do scraper", "error", err)
os.Exit(1)
}
logRuntimeConfig(runtimeCfg)

// newRedisClient() está em server.go — usa ParseURL corretamente
// e valida a conexão com Ping antes de retornar.
rdb, err := newRedisClient()
Expand All @@ -25,5 +37,40 @@ func main() {
adapterList := buildAdapters(rdb)
slog.Info("servidor inicializado", "adapters_total", len(adapterList))

run(adapterList)
run(adapterList, runtimeCfg)
}

func logRuntimeConfig(cfg config.RuntimeConfig) {
_, gomaxprocsSet := os.LookupEnv("GOMAXPROCS")
_, gomemlimitSet := os.LookupEnv("GOMEMLIMIT")

memLimit := debug.SetMemoryLimit(-1)

slog.Info("scraper runtime configurado",
"max_concurrency", cfg.MaxConcurrency,
"max_concurrency_source", cfg.MaxConcurrencySource,
"gomaxprocs_effective", runtime.GOMAXPROCS(0),
"gomaxprocs_source", envSource(gomaxprocsSet),
"gomemlimit_effective_bytes", memLimit,
"gomemlimit_effective", formatBytes(memLimit),
"gomemlimit_source", envSource(gomemlimitSet),
)
}

func envSource(set bool) string {
if set {
return "environment"
}
return "go_runtime_default"
}

func formatBytes(value int64) string {
if value < 0 {
return "unlimited"
}
const mib = 1024 * 1024
if value%mib == 0 {
return strconv.FormatInt(value/mib, 10) + "MiB"
}
return strconv.FormatInt(value, 10) + "B"
}
17 changes: 17 additions & 0 deletions scraper-go/cmd/server/runtime_log.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package main

import "log/slog"

func slogScrapeStart(origin string, configured, requested, effective, keywords, adapters int) {
attrs := []any{
"origin", origin,
"max_concurrency_configured", configured,
"max_concurrency_effective", effective,
"keywords", keywords,
"adapters", adapters,
}
if requested != 0 {
attrs = append(attrs, "max_concurrency_requested", requested)
}
slog.Info("scraper execução iniciada", attrs...)
}
6 changes: 4 additions & 2 deletions scraper-go/cmd/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"time"

"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/cache"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/config"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/cronjob"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/jobstore"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/keywords"
Expand All @@ -19,7 +20,7 @@ import (
"github.com/redis/go-redis/v9"
)

func run(adapterList []ports.JobSource) {
func run(adapterList []ports.JobSource, runtimeCfg config.RuntimeConfig) {
addr := os.Getenv("GO_SCRAPER_ADDR")
if addr == "" {
addr = ":8081"
Expand All @@ -44,6 +45,7 @@ func run(adapterList []ports.JobSource) {

// ── Scheduler (cronjob) ──
schedulerCfg := cronjob.DefaultConfig()
schedulerCfg.MaxConcurrency = runtimeCfg.MaxConcurrency
scheduler := cronjob.New(schedulerCfg, kwStore, jobStore, adapterList, rdb)

scheduler.OnComplete = func(kws []string, scraped, saved int, duration time.Duration) {
Expand All @@ -54,7 +56,7 @@ func run(adapterList []ports.JobSource) {
mux := http.NewServeMux()

// Públicas
mux.Handle("POST /scrape", handleScrape(adapterList, kwStore, c, rdb))
mux.Handle("POST /scrape", handleScrape(adapterList, kwStore, c, rdb, runtimeCfg))
mux.Handle("GET /health", handleHealth(c))
mux.Handle("GET /metrics", promhttp.Handler())
mux.Handle("GET /api/keywords", handleGetKeywords(kwStore))
Expand Down
Loading
Loading