Skip to content

Commit 3139116

Browse files
committed
fix(jobspy): roda scrape em subprocesso real para restaurar a busca de vagas
O isolamento por multiprocessing.Process falhava em 100% dos termos em producao: a busca roda dentro de um ForkPoolWorker do Celery, que e um processo daemonico, e o multiprocessing proibe filhos de daemon ("daemonic processes are not allowed to have children"). O fetch diario passou a salvar zero vagas. Troca o worker por `python -m infra.jobspy._scrape_runner` via subprocess, que nao tem essa restricao e preserva a propriedade que motivou o isolamento: o timeout mata o processo de verdade, levando junto as threads inabortaveis do laco de paginacao do LinkedIn. - records_from_dataframe sai de service.py para infra/jobspy/records.py, importavel pelo runner sem arrastar structlog e o resto do servico - runner protege o stdout (canal do JSON) de prints de bibliotecas - timeout por termo sobe para 40s, agora que inclui o startup do interpretador - testes exercitam o protocolo com runners falsos, incluindo a prova de que o timeout mata o processo — mock in-process nao pega esse tipo de bug
1 parent 0e2154a commit 3139116

5 files changed

Lines changed: 361 additions & 176 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Thumbs.db
4848
# Pytest
4949
.pytest_cache/
5050
.coverage
51+
.coverage.*
5152
htmlcov/
5253
coverage.xml
5354
coverage.lcov
Lines changed: 232 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
1+
import io
12
import json
2-
import multiprocessing
3+
import os
4+
import sys
35
import threading
46
import time
7+
from contextlib import contextmanager
58
from unittest.mock import patch
69

710
import numpy as np
811
import pandas as pd
912
import pytest
1013

14+
from infra.jobspy import _scrape_runner, service as service_module
15+
from infra.jobspy.records import records_from_dataframe
1116
from infra.jobspy.service import JobSearchService
1217

1318

@@ -16,11 +21,6 @@ def service():
1621
return JobSearchService()
1722

1823

19-
@pytest.fixture
20-
def empty_df():
21-
return pd.DataFrame()
22-
23-
2424
@pytest.fixture
2525
def sample_df():
2626
return pd.DataFrame(
@@ -33,11 +33,155 @@ def sample_df():
3333
)
3434

3535

36+
@contextmanager
37+
def fake_runner(tmp_path, body: str):
38+
"""Substitui o runner real por um script controlado em `tmp_path`.
39+
40+
`_run_scrape` executa `python -m <_RUNNER_MODULE>` com cwd `_PROJECT_ROOT`;
41+
apontando os dois para o script falso exercitamos o protocolo de verdade
42+
(JSON no stdin, JSON no stdout, kill no timeout) sem tocar a rede.
43+
"""
44+
(tmp_path / "fake_runner.py").write_text(body)
45+
with (
46+
patch.dict(os.environ),
47+
patch.multiple(
48+
service_module,
49+
_PROJECT_ROOT=tmp_path,
50+
_RUNNER_MODULE="fake_runner",
51+
),
52+
):
53+
# O pytest-cov instrumenta subprocessos via COV_CORE_*/COVERAGE_PROCESS_*;
54+
# herdadas aqui, elas fazem o runner falso largar arquivos .coverage.*
55+
# órfãos no repo (ele roda com outro cwd, sem achar a config, e o combine
56+
# quebra ao juntar dados com e sem branch coverage).
57+
for key in [k for k in os.environ if k.startswith(("COV_CORE", "COVERAGE_"))]:
58+
del os.environ[key]
59+
yield
60+
61+
62+
# Runner falso que devolve uma vaga fixa, ignorando os kwargs recebidos.
63+
RUNNER_OK = """
64+
import json, sys
65+
json.load(sys.stdin)
66+
json.dump({"records": [{"title": "Python Developer"}]}, sys.stdout)
67+
"""
68+
69+
# Runner falso que trava — reproduz o laço infinito de paginação do LinkedIn.
70+
RUNNER_HANGS = """
71+
import time
72+
time.sleep(60)
73+
"""
74+
75+
76+
class TestRunScrape:
77+
"""Contrato entre `_run_scrape` e o processo runner."""
78+
79+
def test_returns_records_from_runner(self, tmp_path):
80+
with fake_runner(tmp_path, RUNNER_OK):
81+
status, payload = service_module._run_scrape({"search_term": "python"}, 30)
82+
83+
assert status == "ok"
84+
assert payload == [{"title": "Python Developer"}]
85+
86+
def test_forwards_kwargs_through_stdin(self, tmp_path):
87+
# O runner devolve os kwargs que recebeu, provando que a serialização
88+
# de ida chegou íntegra do outro lado.
89+
with fake_runner(
90+
tmp_path,
91+
"""
92+
import json, sys
93+
json.dump({"records": [json.load(sys.stdin)]}, sys.stdout)
94+
""",
95+
):
96+
status, payload = service_module._run_scrape(
97+
{"search_term": "python", "site_name": ["linkedin"]}, 30
98+
)
99+
100+
assert status == "ok"
101+
assert payload == [{"search_term": "python", "site_name": ["linkedin"]}]
102+
103+
def test_runner_error_payload_is_propagated(self, tmp_path):
104+
with fake_runner(
105+
tmp_path,
106+
"""
107+
import json, sys
108+
json.dump({"error": "RuntimeError: scraper explodiu"}, sys.stdout)
109+
""",
110+
):
111+
status, payload = service_module._run_scrape({}, 30)
112+
113+
assert status == "error"
114+
assert payload == "RuntimeError: scraper explodiu"
115+
116+
def test_death_without_output_is_reported_as_error(self, tmp_path):
117+
# Cenário do OOM killer: o processo some sem escrever nada no stdout.
118+
with fake_runner(
119+
tmp_path,
120+
"""
121+
import os, sys
122+
print("boom", file=sys.stderr)
123+
os._exit(9)
124+
""",
125+
):
126+
status, payload = service_module._run_scrape({}, 30)
127+
128+
assert status == "error"
129+
assert "codigo 9" in payload
130+
assert "boom" in payload
131+
132+
def test_garbage_on_stdout_is_reported_as_error(self, tmp_path):
133+
with fake_runner(tmp_path, "print('nao sou json')"):
134+
status, payload = service_module._run_scrape({}, 30)
135+
136+
assert status == "error"
137+
assert "saida invalida" in payload
138+
139+
def test_timeout_reports_timeout(self, tmp_path):
140+
with fake_runner(tmp_path, RUNNER_HANGS):
141+
started = time.monotonic()
142+
status, payload = service_module._run_scrape({}, 1)
143+
elapsed = time.monotonic() - started
144+
145+
assert (status, payload) == ("timeout", None)
146+
# Voltou no orçamento, não nos 60s do runner travado.
147+
assert elapsed < 15
148+
149+
def test_timeout_actually_kills_the_runner(self, tmp_path):
150+
"""Regressão do incidente de produção: um scrape travado seguia queimando
151+
CPU depois do timeout (thread inabortável dentro do worker Celery). O
152+
runner falso escreve num arquivo enquanto vive — se o arquivo continuar
153+
crescendo depois do timeout, o processo sobreviveu."""
154+
heartbeat = tmp_path / "heartbeat"
155+
with fake_runner(
156+
tmp_path,
157+
f"""
158+
import time
159+
with open({str(heartbeat)!r}, "a", buffering=1) as fh:
160+
while True:
161+
fh.write("tick\\n")
162+
time.sleep(0.05)
163+
""",
164+
):
165+
assert service_module._run_scrape({}, 1)[0] == "timeout"
166+
167+
size_after_timeout = heartbeat.stat().st_size
168+
time.sleep(0.5)
169+
assert heartbeat.stat().st_size == size_after_timeout
170+
171+
def test_hung_runner_leaves_no_thread_behind(self, tmp_path):
172+
threads_before = threading.active_count()
173+
174+
with fake_runner(tmp_path, RUNNER_HANGS):
175+
assert service_module._run_scrape({}, 1)[0] == "timeout"
176+
177+
assert threading.active_count() == threads_before
178+
179+
36180
@pytest.mark.django_db
37-
class TestJobSearchServiceNewParams:
181+
class TestJobSearchServiceParams:
38182
"""Os kwargs são verificados na fronteira `_run_scrape` porque `scrape_jobs`
39-
passou a ser chamado no subprocesso — um mock patcheado aqui registra a
40-
chamada na memória do filho, invisível para o processo de teste."""
183+
roda no processo runner — um mock patcheado aqui registra a chamada na
184+
memória do filho, invisível para o processo de teste."""
41185

42186
@patch("infra.jobspy.service._run_scrape")
43187
def test_search_accepts_new_params(self, mock_run, service):
@@ -89,9 +233,26 @@ def test_search_skips_term_on_error(self, mock_run, service):
89233
mock_run.return_value = ("error", "RuntimeError: boom")
90234
assert service.search(terms=["python"]) == []
91235

236+
@patch("infra.jobspy.service._run_scrape")
237+
def test_search_skips_term_on_timeout(self, mock_run, service):
238+
mock_run.return_value = ("timeout", None)
239+
assert service.search(terms=["python"]) == []
240+
241+
@patch("infra.jobspy.service._run_scrape")
242+
def test_search_continues_to_next_term_after_timeout(self, mock_run, service):
243+
mock_run.side_effect = [
244+
("timeout", None),
245+
("ok", [{"title": "Python Developer"}]),
246+
]
247+
248+
result = service.search(terms=["trava", "python"])
249+
250+
assert len(result) == 1
251+
assert result[0]["title"] == "Python Developer"
252+
92253
@patch("infra.jobspy.service._run_scrape")
93254
def test_search_survives_subprocess_start_failure(self, mock_run, service):
94-
# Falhar ao criar o subprocesso (fork sem memória, por exemplo) não pode
255+
# Falhar ao criar o subprocesso (sem memória, por exemplo) não pode
95256
# abortar os demais termos da busca.
96257
mock_run.side_effect = [
97258
OSError("Cannot allocate memory"),
@@ -104,111 +265,92 @@ def test_search_survives_subprocess_start_failure(self, mock_run, service):
104265
assert result[0]["title"] == "Python Developer"
105266

106267

107-
@pytest.mark.django_db
108-
class TestJobSearchServiceTimeout:
109-
@patch("infra.jobspy.service.scrape_jobs")
110-
def test_search_returns_empty_when_scrape_hangs(self, mock_scrape, service):
111-
# Simula scrape_jobs preso (o scraper do LinkedIn entra em laço infinito
112-
# de paginação quando o termo não retorna vagas novas). search() deve
113-
# abortar no timeout e seguir adiante com lista vazia.
114-
def slow_scrape(*args, **kwargs):
115-
time.sleep(30.0)
116-
return pd.DataFrame()
117-
118-
mock_scrape.side_effect = slow_scrape
119-
120-
result = service.search(terms=["python"], timeout=1)
121-
122-
assert result == []
123-
124-
@patch("infra.jobspy.service.scrape_jobs")
125-
def test_search_continues_to_next_term_after_timeout(self, mock_scrape, service, sample_df):
126-
# Primeiro termo trava, segundo retorna normalmente. Resultado deve
127-
# conter apenas o segundo.
128-
def side_effect(*args, **kwargs):
129-
if kwargs.get("search_term") == "trava":
130-
time.sleep(30.0)
131-
return pd.DataFrame()
132-
return sample_df
268+
class TestScrapeRunner:
269+
"""`_scrape_runner.main()` chamado no processo de teste, com o jobspy
270+
mockado — o que roda em produção é o mesmo código, só que via `python -m`."""
133271

134-
mock_scrape.side_effect = side_effect
272+
def _run_main(self, monkeypatch, scrape_kwargs: dict) -> tuple[int, dict]:
273+
response = io.StringIO()
274+
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(scrape_kwargs)))
275+
monkeypatch.setattr(sys, "stdout", response)
135276

136-
result = service.search(terms=["trava", "python"], timeout=1)
277+
code = _scrape_runner.main()
137278

138-
assert len(result) == 1
139-
assert result[0]["title"] == "Python Developer"
279+
return code, json.loads(response.getvalue())
140280

281+
@patch("jobspy.scrape_jobs")
282+
def test_main_emits_records(self, mock_scrape, monkeypatch, sample_df):
283+
mock_scrape.return_value = sample_df
141284

142-
@pytest.mark.django_db
143-
class TestJobSearchServiceIsolation:
144-
"""Regressão do incidente de produção: um scrape travado acumulava threads
145-
inabortáveis no worker Celery (~32/dia), saturando a CPU do host."""
285+
code, payload = self._run_main(monkeypatch, {"search_term": "python"})
146286

147-
@patch("infra.jobspy.service.scrape_jobs")
148-
def test_hung_scrape_leaves_no_thread_behind(self, mock_scrape, service):
149-
def hang(*args, **kwargs):
150-
time.sleep(30.0)
151-
return pd.DataFrame()
287+
assert code == 0
288+
assert payload["records"][0]["title"] == "Python Developer"
289+
assert mock_scrape.call_args.kwargs == {"search_term": "python"}
152290

153-
mock_scrape.side_effect = hang
291+
@patch("jobspy.scrape_jobs")
292+
def test_main_reports_scrape_failure(self, mock_scrape, monkeypatch):
293+
mock_scrape.side_effect = RuntimeError("scraper explodiu")
154294

155-
threads_before = threading.active_count()
156-
assert service.search(terms=["trava"], timeout=1) == []
157-
assert threading.active_count() == threads_before
295+
code, payload = self._run_main(monkeypatch, {"search_term": "python"})
158296

159-
@patch("infra.jobspy.service.scrape_jobs")
160-
def test_hung_scrape_leaves_no_orphan_process(self, mock_scrape, service):
161-
def hang(*args, **kwargs):
162-
time.sleep(30.0)
163-
return pd.DataFrame()
297+
assert code == 1
298+
assert payload["error"] == "RuntimeError: scraper explodiu"
164299

165-
mock_scrape.side_effect = hang
300+
@patch("jobspy.scrape_jobs")
301+
def test_main_keeps_stdout_clean(self, mock_scrape, monkeypatch, sample_df, capsys):
302+
# Uma lib que imprima no stdout não pode corromper a resposta JSON.
303+
def noisy_scrape(**kwargs):
304+
print("log solto do scraper")
305+
return sample_df
166306

167-
children_before = len(multiprocessing.active_children())
168-
assert service.search(terms=["trava"], timeout=1) == []
169-
assert len(multiprocessing.active_children()) == children_before
307+
mock_scrape.side_effect = noisy_scrape
170308

171-
@patch("infra.jobspy.service.scrape_jobs")
172-
def test_crash_in_subprocess_is_reported_as_error(self, mock_scrape, service):
173-
mock_scrape.side_effect = RuntimeError("scraper explodiu")
309+
code, payload = self._run_main(monkeypatch, {"search_term": "python"})
174310

175-
assert service.search(terms=["python"]) == []
311+
assert code == 0
312+
assert payload["records"][0]["title"] == "Python Developer"
313+
assert "log solto do scraper" in capsys.readouterr().err
176314

177-
@patch("infra.jobspy.service.scrape_jobs")
178-
def test_successful_search_leaves_no_orphan_process(self, mock_scrape, service, sample_df):
179-
mock_scrape.return_value = sample_df
315+
def test_main_reports_invalid_stdin(self, monkeypatch):
316+
response = io.StringIO()
317+
monkeypatch.setattr(sys, "stdin", io.StringIO("nao sou json"))
318+
monkeypatch.setattr(sys, "stdout", response)
180319

181-
children_before = len(multiprocessing.active_children())
182-
result = service.search(terms=["python"])
320+
code = _scrape_runner.main()
183321

184-
assert len(result) == 1
185-
assert len(multiprocessing.active_children()) == children_before
322+
assert code == 1
323+
assert "kwargs invalidos" in json.loads(response.getvalue())["error"]
186324

187325

188-
@pytest.mark.django_db
189-
class TestJobSearchServiceJsonSafe:
190-
@patch("infra.jobspy.service.scrape_jobs")
191-
def test_search_results_are_json_serializable(self, mock_scrape, service):
326+
class TestRecordsFromDataframe:
327+
def test_records_are_json_serializable(self):
192328
# Regressão: results são gravados em request.session no admin
193329
# (SearchTermAdmin.test_search). Django usa JSONSerializer por padrão,
194330
# então Timestamp, NaN e numpy scalars vindos de scrape_jobs precisam
195-
# estar normalizados antes de retornarem do service.
196-
mock_scrape.return_value = pd.DataFrame(
197-
{
198-
"title": ["Python Dev"],
199-
"company": ["Acme"],
200-
"date_posted": [pd.Timestamp("2026-04-26")],
201-
"salary": [np.nan],
202-
"applicants": [np.int64(42)],
203-
}
331+
# estar normalizados antes de chegarem ao service.
332+
records = records_from_dataframe(
333+
pd.DataFrame(
334+
{
335+
"title": ["Python Dev"],
336+
"company": ["Acme"],
337+
"date_posted": [pd.Timestamp("2026-04-26")],
338+
"salary": [np.nan],
339+
"applicants": [np.int64(42)],
340+
}
341+
)
204342
)
205343

206-
result = service.search(terms=["python"])
207-
208344
# Deve ser serializável sem default=str
209-
json.dumps(result)
345+
json.dumps(records)
210346

211-
record = result[0]
347+
record = records[0]
212348
assert isinstance(record["date_posted"], str)
213349
assert record["salary"] is None
214350
assert isinstance(record["applicants"], int)
351+
352+
def test_empty_dataframe_returns_empty_list(self):
353+
assert records_from_dataframe(pd.DataFrame()) == []
354+
355+
def test_none_returns_empty_list(self):
356+
assert records_from_dataframe(None) == []

0 commit comments

Comments
 (0)