Keep workers online during long-running downloads

This commit is contained in:
erik
2026-09-04 14:53:37 -05:00
parent d9e2012e22
commit 632599b201
57 changed files with 8847 additions and 21 deletions
Binary file not shown.
+2 -2
View File
@@ -2,8 +2,8 @@
BOT_API_TOKEN=dev-token-change-me
BOT_API_URL=https://pys1.builker.xyz/bot-descarga
# Imaging (hostname interno; requiere VPN/DNS o IP alcanzable)
BOT_IMAGING_URL=http://serviciosimaging:8180/
# Imaging (requiere acceso a la red interna de Salud Total)
BOT_IMAGING_URL=https://serviciosimaging.saludtotal.loc/
# MinIO — bucket pys-cuentas-medicas: sin credenciales (PutObject anónimo)
MINIO_ENDPOINT=https://s3.builker.xyz
@@ -7,7 +7,7 @@ from pathlib import Path
@dataclass(frozen=True)
class Settings:
url_sistema: str = "http://serviciosimaging:8180/"
url_sistema: str = "https://serviciosimaging.saludtotal.loc/"
peso_minimo_bytes: int = 10_000
max_intentos_click: int = 3
max_intentos_pdf: int = 2
+47 -2
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import socket
import tempfile
import threading
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
@@ -15,6 +16,9 @@ JOB_COMPLETED = "completed"
JOB_FAILED = "failed"
JOB_CANCELLED = "cancelled"
CONTROL_REQUEST_TIMEOUT_SECONDS = 5.0
ACTIVE_JOB_HEARTBEAT_SECONDS = 10.0
class RemoteApiClient:
"""Cliente HTTP hacia la API en IPSExtractor (vía gateway). Sin puertos locales."""
@@ -49,6 +53,7 @@ class RemoteApiClient:
r = self._client.post(
self._url("/workers/heartbeat"),
json={"worker_id": worker_id},
timeout=CONTROL_REQUEST_TIMEOUT_SECONDS,
)
r.raise_for_status()
return r.json()
@@ -70,11 +75,18 @@ class RemoteApiClient:
return dest
def post_event(self, job_id: str, event: Dict[str, Any]) -> None:
r = self._client.post(self._url(f"/jobs/{job_id}/events"), json=event)
r = self._client.post(
self._url(f"/jobs/{job_id}/events"),
json=event,
timeout=CONTROL_REQUEST_TIMEOUT_SECONDS,
)
r.raise_for_status()
def cancel_flag(self, job_id: str) -> bool:
r = self._client.get(self._url(f"/jobs/{job_id}/cancel-flag"))
r = self._client.get(
self._url(f"/jobs/{job_id}/cancel-flag"),
timeout=CONTROL_REQUEST_TIMEOUT_SECONDS,
)
r.raise_for_status()
return bool(r.json().get("cancel_requested"))
@@ -93,12 +105,14 @@ class WorkerAgent:
worker_id: str,
token: str,
poll_seconds: float = 8.0,
heartbeat_seconds: float = ACTIVE_JOB_HEARTBEAT_SECONDS,
staging_root: Optional[Path] = None,
log_func=None,
):
self.api = RemoteApiClient(api_url, token)
self.worker_id = worker_id
self.poll_seconds = poll_seconds
self.heartbeat_seconds = max(1.0, float(heartbeat_seconds))
self.staging_root = staging_root or Path(tempfile.gettempdir()) / "bot_descarga"
self.minio = WorkerMinioUploader(MinioSettings.from_env())
self._running = True
@@ -129,6 +143,25 @@ class WorkerAgent:
self.api.close()
self._log(f"[worker {self.worker_id}] detenido")
def _heartbeat_while_job_runs(self, stop_event: threading.Event) -> None:
"""Mantiene al worker online mientras el procesamiento bloqueante continúa."""
connection_failed = False
while not stop_event.is_set():
try:
self.api.heartbeat(self.worker_id)
if connection_failed:
self._log(f"[worker {self.worker_id}] conexión con servidor restablecida")
connection_failed = False
except Exception as e:
# Una caída temporal del gateway no debe cancelar la descarga local.
if not connection_failed:
self._log(
f"[worker {self.worker_id}] heartbeat temporalmente no disponible: {e}; "
"el job continúa"
)
connection_failed = True
stop_event.wait(self.heartbeat_seconds)
def _run_job(self, job: dict) -> None:
job_id = job["id"]
downloads_prefix = (job.get("downloads_prefix") or "").strip() or f"downloads/{job_id}/"
@@ -140,6 +173,15 @@ class WorkerAgent:
self._log(f"[worker {self.worker_id}] job {job_id} iniciado (prefix={downloads_prefix})")
heartbeat_stop = threading.Event()
heartbeat_thread = threading.Thread(
target=self._heartbeat_while_job_runs,
args=(heartbeat_stop,),
name=f"heartbeat-{self.worker_id}",
daemon=True,
)
heartbeat_thread.start()
try:
self.api.download_excel(job_id, excel_path)
@@ -265,3 +307,6 @@ class WorkerAgent:
)
except Exception:
pass
finally:
heartbeat_stop.set()
heartbeat_thread.join(timeout=CONTROL_REQUEST_TIMEOUT_SECONDS + 1.0)
+72
View File
@@ -0,0 +1,72 @@
"""Construye un ZIP reproducible y limpio del Bot de Descarga."""
from __future__ import annotations
import argparse
import hashlib
from pathlib import Path
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_OUTPUT = ROOT.parent / "bot_descarga.zip"
TOP_LEVEL_FILES = (
".env.example",
".gitignore",
"README.md",
"requirements.txt",
"run_gui.py",
"main.py",
)
EXCLUDED_PARTS = {"__pycache__", ".pytest_cache", ".venv", "data", "logs", "out"}
EXCLUDED_SUFFIXES = {".pyc", ".pyo", ".log"}
def package_files(root: Path) -> list[Path]:
files: list[Path] = []
for name in TOP_LEVEL_FILES:
path = root / name
if path.is_file():
files.append(path)
package_root = root / "bot_descarga"
for path in package_root.rglob("*"):
relative = path.relative_to(root)
if not path.is_file() or any(part in EXCLUDED_PARTS for part in relative.parts):
continue
if path.suffix.lower() in EXCLUDED_SUFFIXES:
continue
files.append(path)
return sorted(files, key=lambda path: path.relative_to(root).as_posix())
def build_package(root: Path = ROOT, output: Path = DEFAULT_OUTPUT) -> Path:
output.parent.mkdir(parents=True, exist_ok=True)
with ZipFile(output, "w", compression=ZIP_DEFLATED, compresslevel=9) as archive:
for source in package_files(root):
relative = source.relative_to(root).as_posix()
info = ZipInfo(f"bot_descarga/{relative}", date_time=(2020, 1, 1, 0, 0, 0))
info.compress_type = ZIP_DEFLATED
info.external_attr = 0o644 << 16
archive.writestr(info, source.read_bytes())
return output
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
args = parser.parse_args()
target = build_package(output=args.output.resolve())
print(f"Paquete: {target}")
print(f"SHA256: {sha256(target)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+66
View File
@@ -0,0 +1,66 @@
"""Construye y publica el Bot de Descarga en el prefijo latest de MinIO."""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
import boto3
from botocore.config import Config
from package_bot import DEFAULT_OUTPUT, build_package, sha256
DEFAULT_ENDPOINT = "https://s3.builker.xyz"
DEFAULT_BUCKET = "apps-versions"
DEFAULT_KEY = "BotDescargaPYSCuentasMedicas/latest/bot_descarga.zip"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
parser.add_argument("--build-only", action="store_true")
args = parser.parse_args()
artifact = build_package(output=args.output.resolve())
print(f"Paquete validado: {artifact} ({sha256(artifact)})")
if args.build_only:
return 0
access = os.getenv("MINIO_ACCESS_KEY") or os.getenv("AWS_ACCESS_KEY_ID")
secret = os.getenv("MINIO_SECRET_KEY") or os.getenv("AWS_SECRET_ACCESS_KEY")
if not access or not secret:
print("Faltan MINIO_ACCESS_KEY/MINIO_SECRET_KEY para publicar.", file=sys.stderr)
return 2
endpoint = os.getenv("MINIO_ENDPOINT", DEFAULT_ENDPOINT).rstrip("/")
bucket = os.getenv("MINIO_APPS_BUCKET", DEFAULT_BUCKET)
key = os.getenv("DOWNLOAD_PACKAGE_KEY", DEFAULT_KEY).strip("/")
client = boto3.client(
"s3",
endpoint_url=endpoint,
aws_access_key_id=access,
aws_secret_access_key=secret,
region_name=os.getenv("MINIO_REGION", "us-east-1"),
config=Config(s3={"addressing_style": "path"}),
)
client.upload_file(
str(artifact),
bucket,
key,
ExtraArgs={"ContentType": "application/zip"},
)
remote = client.head_object(Bucket=bucket, Key=key)
remote_size = int(remote.get("ContentLength") or 0)
if remote_size != artifact.stat().st_size:
print(
f"La verificación remota falló: local={artifact.stat().st_size}, remoto={remote_size}.",
file=sys.stderr,
)
return 3
print(f"Publicado: {endpoint}/{bucket}/{key}")
print(f"Verificado: {remote_size} bytes")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -50,7 +50,7 @@ def test_browser_start_failure_sets_fatal_error(monkeypatch):
svc = DownloadService(
ruta_excel="dummy.xlsx",
carpeta_descarga=".",
settings=Settings(url_sistema="http://serviciosimaging:8180/"),
settings=Settings(url_sistema="https://serviciosimaging.saludtotal.loc/"),
event_callback=lambda e: events.append(e),
)
summary = svc.run()
@@ -0,0 +1,123 @@
import threading
import time
from pathlib import Path
import bot_descarga.worker.agent as agent_module
from bot_descarga.models.resultado import RunSummary
from bot_descarga.worker.agent import WorkerAgent
class FakeApi:
def __init__(self, failures: int = 0):
self.calls = 0
self.failures = failures
def heartbeat(self, worker_id: str) -> dict:
self.calls += 1
if self.calls <= self.failures:
raise RuntimeError("gateway temporalmente no disponible")
return {"worker_id": worker_id}
def make_agent(api: FakeApi) -> WorkerAgent:
agent = WorkerAgent.__new__(WorkerAgent)
agent.api = api
agent.worker_id = "zero0"
agent.heartbeat_seconds = 0.01
agent._log = lambda _message: None
return agent
def run_briefly(agent: WorkerAgent) -> threading.Thread:
stop_event = threading.Event()
thread = threading.Thread(
target=agent._heartbeat_while_job_runs,
args=(stop_event,),
daemon=True,
)
thread.start()
deadline = time.monotonic() + 1.0
while agent.api.calls < 3 and time.monotonic() < deadline:
time.sleep(0.01)
stop_event.set()
thread.join(timeout=1.0)
return thread
def test_active_job_sends_repeated_heartbeats() -> None:
api = FakeApi()
thread = run_briefly(make_agent(api))
assert api.calls >= 3
assert not thread.is_alive()
def test_transient_heartbeat_failure_does_not_stop_loop() -> None:
api = FakeApi(failures=1)
thread = run_briefly(make_agent(api))
assert api.calls >= 3
assert not thread.is_alive()
def test_successful_job_stays_completed_and_stops_heartbeat(monkeypatch, tmp_path: Path) -> None:
class JobApi(FakeApi):
def __init__(self):
super().__init__()
self.patches = []
def download_excel(self, _job_id: str, dest: Path) -> Path:
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(b"xlsx")
return dest
def cancel_flag(self, _job_id: str) -> bool:
return False
def post_event(self, _job_id: str, _event: dict) -> None:
return None
def patch_job(self, job_id: str, **body) -> dict:
self.patches.append((job_id, body))
return body
class FakeMinio:
def write_manifest(self, *_args, **_kwargs) -> None:
return None
class FakeDownloadService:
def __init__(self, **_kwargs):
pass
def run(self) -> RunSummary:
return RunSummary(total=1, exitosos=1)
api = JobApi()
agent = make_agent(api)
agent.staging_root = tmp_path
agent.minio = FakeMinio()
agent._uploaded_keys = []
monkeypatch.setattr(agent_module, "DownloadService", FakeDownloadService)
agent._run_job({"id": "job-1", "excel_key": "input.xlsx"})
calls_after_job = api.calls
time.sleep(0.03)
assert api.patches == [
(
"job-1",
{
"status": "completed",
"result": {
"total": 1,
"exitosos": 1,
"fallidos": 0,
"cancelled": False,
"downloads_prefix": "downloads/job-1/",
"uploaded_count": 0,
},
"error": None,
},
)
]
assert api.calls == calls_after_job
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 901 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB