Keep workers online during long-running downloads
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 901 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 20 KiB |