fix(scraper): endurecer memoria — PDF cap 15MB en executor, contenido cap 300k chars
Un batch de 20 fuentes concurrentes con un documento de 98k palabras y varios PDFs grandes mató el pod (OOMKilled, límite 1Gi) el 2026-07-10 en plena investigación. - _extract_pdf: cap bajado de 50MB a 15MB, verificado también sobre el body real (Content-Length puede faltar); pdfplumber movido a run_in_executor (es síncrono y congelaba el event loop, misma clase de bug que DDGS) con flush_cache() por página. - _mark_scraped: contenido truncado a settings.max_content_length (300k chars) antes de guardarlo en source_contents — libros enteros inflan RAM y DB sin aportar al RAG. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d31badeb5b
commit
ae56227c03
@@ -47,6 +47,8 @@ class Settings(BaseSettings):
|
|||||||
request_timeout: int = Field(30)
|
request_timeout: int = Field(30)
|
||||||
request_delay: float = Field(1.0) # seconds between requests
|
request_delay: float = Field(1.0) # seconds between requests
|
||||||
min_content_length: int = Field(200) # chars
|
min_content_length: int = Field(200) # chars
|
||||||
|
# Libros/dumps enteros (100k+ palabras) inflan RAM y DB sin aportar al RAG
|
||||||
|
max_content_length: int = Field(300_000) # chars
|
||||||
|
|
||||||
# Fuentes opcionales — desactivadas por defecto: la IP del homelab está
|
# Fuentes opcionales — desactivadas por defecto: la IP del homelab está
|
||||||
# bloqueada por Reddit (403) y YouTube (transcripts vacíos), eran peso muerto.
|
# bloqueada por Reddit (403) y YouTube (transcripts vacíos), eran peso muerto.
|
||||||
|
|||||||
@@ -628,6 +628,11 @@ class ExhaustiveScraper:
|
|||||||
error="Content too short or empty")
|
error="Content too short or empty")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if len(content) > settings.max_content_length:
|
||||||
|
logger.info("Content truncated", source_id=source_id,
|
||||||
|
original_length=len(content), url=url[:60])
|
||||||
|
content = content[:settings.max_content_length]
|
||||||
|
|
||||||
word_count = len(content.split())
|
word_count = len(content.split())
|
||||||
|
|
||||||
await self.db.save_source_content(source_id, content)
|
await self.db.save_source_content(source_id, content)
|
||||||
@@ -819,30 +824,49 @@ class ExhaustiveScraper:
|
|||||||
entries=len(entries), added=added)
|
entries=len(entries), added=added)
|
||||||
return added
|
return added
|
||||||
|
|
||||||
|
# pdfplumber es síncrono y CPU-intensivo: parsear inline congela el event
|
||||||
|
# loop, y con PDFs grandes el pico de RAM puede matar el pod (OOM con
|
||||||
|
# límite de 1-2Gi). Ejecutar SIEMPRE vía run_in_executor.
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_pdf_sync(path: str) -> str:
|
||||||
|
import pdfplumber
|
||||||
|
|
||||||
|
with pdfplumber.open(path) as pdf:
|
||||||
|
pages = []
|
||||||
|
for page in pdf.pages[:50]: # max 50 pages
|
||||||
|
pages.append(page.extract_text() or "")
|
||||||
|
page.flush_cache() # pdfplumber cachea objetos de página: liberar
|
||||||
|
return "\n\n".join(pages)
|
||||||
|
|
||||||
async def _extract_pdf(self, url: str) -> tuple[Optional[str], Optional[str]]:
|
async def _extract_pdf(self, url: str) -> tuple[Optional[str], Optional[str]]:
|
||||||
"""Download and extract PDF text"""
|
"""Download and extract PDF text"""
|
||||||
import pdfplumber
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
max_pdf_bytes = 15 * 1024 * 1024 # varios PDFs concurrentes en RAM: cap agresivo
|
||||||
|
|
||||||
http = await self._get_http()
|
http = await self._get_http()
|
||||||
try:
|
try:
|
||||||
async with http.get(url) as resp:
|
async with http.get(url) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
return None, None
|
return None, None
|
||||||
content_length = int(resp.headers.get("content-length", 0))
|
content_length = int(resp.headers.get("content-length", 0))
|
||||||
if content_length > 50 * 1024 * 1024: # skip PDFs > 50MB
|
if content_length > max_pdf_bytes:
|
||||||
return None, None
|
return None, None
|
||||||
pdf_bytes = await resp.read()
|
pdf_bytes = await resp.read()
|
||||||
|
# Sin Content-Length el check anterior no protege
|
||||||
|
if len(pdf_bytes) > max_pdf_bytes:
|
||||||
|
return None, None
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
|
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
|
||||||
f.write(pdf_bytes)
|
f.write(pdf_bytes)
|
||||||
tmp_path = f.name
|
tmp_path = f.name
|
||||||
|
del pdf_bytes
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with pdfplumber.open(tmp_path) as pdf:
|
loop = asyncio.get_running_loop()
|
||||||
pages = [p.extract_text() or "" for p in pdf.pages[:50]] # max 50 pages
|
text = await loop.run_in_executor(None, self._parse_pdf_sync, tmp_path)
|
||||||
text = "\n\n".join(pages)
|
|
||||||
return text, url.split("/")[-1]
|
return text, url.split("/")[-1]
|
||||||
finally:
|
finally:
|
||||||
os.unlink(tmp_path)
|
os.unlink(tmp_path)
|
||||||
|
|||||||
Reference in New Issue
Block a user