diff --git a/central/app/config.py b/central/app/config.py index 6cef656..29b0501 100644 --- a/central/app/config.py +++ b/central/app/config.py @@ -27,6 +27,8 @@ class Settings: base_url: str = os.getenv("BASE_URL", "http://localhost:8000") # An Agent is considered ONLINE if a heartbeat arrived within this many seconds. agent_online_threshold_seconds: int = int(os.getenv("AGENT_ONLINE_THRESHOLD_SECONDS", "120")) + # Backpressure: how many FULL jobs (INITIAL_SYNC / REPROCESS_ALL: whole-database classification) may run at once across ALL Agents. Others wait as PENDING. <=0 disables the limit. + heavy_job_concurrency_limit: int = int(os.getenv("HEAVY_JOB_CONCURRENCY_LIMIT", "3")) agent_heartbeat_interval_seconds: int = int(os.getenv("AGENT_HEARTBEAT_INTERVAL_SECONDS", "40")) agent_offline_after_seconds: int = int(os.getenv("AGENT_OFFLINE_AFTER_SECONDS", "0")) agent_server_version: str = os.getenv("CURRENT_SERVER_VERSION", "1.0.0rc1") diff --git a/central/app/jobs.py b/central/app/jobs.py index 5e5e899..5ceed44 100644 --- a/central/app/jobs.py +++ b/central/app/jobs.py @@ -6,8 +6,9 @@ REPROCESS_ALL, REPROCESS_PRODUCTS); automation never authorizes Firebird writes. """ from datetime import datetime, timedelta, timezone -from sqlalchemy import select, update +from sqlalchemy import select, update, func, text +from .config import settings from .models import Installation, Job FULL_KINDS = ("INITIAL_SYNC", "REPROCESS_ALL") # read every product, reclassify all @@ -65,13 +66,26 @@ def claim_next_job(session, agent): recover_stale_running(session, agent) if session.scalar(select(Job.id).where(Job.agent_id == agent.id, Job.status == "RUNNING").limit(1)) is not None: return None - candidate = session.scalar( - select(Job.id) + pending = session.execute( + select(Job.id, Job.kind) .where(Job.agent_id == agent.id, Job.status == "PENDING") .order_by(Job.created_at.asc(), Job.id.asc()) - .limit(1) - ) + .limit(10) + ).all() + if not pending: + return None + limit = settings.heavy_job_concurrency_limit + heavy_slot_free = True + if limit > 0 and any(kind in FULL_KINDS for _, kind in pending): + if session.bind is not None and session.bind.dialect.name == "postgresql": + session.execute(text("SELECT pg_advisory_xact_lock(7305001)")) # serialize slot accounting across concurrent pollers; released at commit + running_heavy = session.scalar(select(func.count()).select_from(Job).where(Job.status == "RUNNING", Job.kind.in_(FULL_KINDS), + Job.started_at > datetime.now(timezone.utc) - STALE_RUNNING_AFTER)) or 0 + heavy_slot_free = running_heavy < limit + # oldest job this Agent may run now: a light job is never held behind a heavy one that is waiting for a slot + candidate = next((job_id for job_id, kind in pending if kind not in FULL_KINDS or heavy_slot_free), None) if candidate is None: + session.rollback() # releases the advisory lock; the heavy job stays PENDING (queue) and the Agent keeps polling return None now = datetime.now(timezone.utc) result = session.execute( diff --git a/central/app/web.py b/central/app/web.py index b666cd3..9495167 100644 --- a/central/app/web.py +++ b/central/app/web.py @@ -319,6 +319,16 @@ def router(session_factory): return RedirectResponse(f"/agents/{agent_id}?tab=jobs", status_code=303) finally: session.close() + def _is_privileged(admin) -> bool: + """Who may change what the Agent is allowed to WRITE. The identity comes from the Gerenciador SSO (`perfil`): only ADMIN. LOCAL break-glass accounts are administrators by construction. + A normal (PADRAO) collaborator can look at the control page and use DISABLED/DRY_RUN (read-only) but cannot approve, enable, apply or roll back.""" + return admin.auth_source == "LOCAL" or (admin.external_role or "").upper() == "ADMIN" + + def _deny(session, admin, tenant_id, action, target): + record_audit(session, admin_user_id=admin.id, tenant_id=tenant_id, action="RTC_DENIED", target_type="installation", target_id=target, context={"attempted": action, "role": admin.external_role or admin.auth_source}, severity="WARNING") + session.commit() + return HTMLResponse("Ação restrita a administradores do GTI Gerenciador (perfil ADMIN).", status_code=403) + def _rtc_target(session, agent_id): agent = session.get(Agent, agent_id) installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id)) if agent else None @@ -344,7 +354,7 @@ def router(session_factory): audits = list(session.scalars(select(RtcWriteAudit).where(RtcWriteAudit.installation_id == installation.id, RtcWriteAudit.outcome.in_(("APPLIED", "ROLLED_BACK"))).order_by(RtcWriteAudit.id.desc()).limit(25))) ops = _ops_view(session, installation, agent) return render(request, "rtc_control.html", {"agent_id": agent_id, "installation": installation, "mode": installation.rtc_write_mode, "modes": RTC_MODE_LABELS, "explanation": RTC_MODE_EXPLANATION, "ops": ops, - "rows": rows, "counts": counts, "status": status, "page": page, "pages": max(1, -(-total // size)), "total": total, "audits": audits, + "privileged": _is_privileged(auth_data[1]), "rows": rows, "counts": counts, "status": status, "page": page, "pages": max(1, -(-total // size)), "total": total, "audits": audits, "L": {"status": candidate_status_label, "outcome": audit_outcome_label, "reason": abort_reason_label, "rule": rtc_rule_label, "catalog": catalog_state_label}}) finally: session.close() @@ -361,13 +371,17 @@ def router(session_factory): mode = (values.get("mode") or "").strip().upper() if mode not in RTC_MODE_LABELS: return HTMLResponse("modo inválido", status_code=422) + if mode not in ("DISABLED", "DRY_RUN") and not _is_privileged(admin): + return _deny(session, admin, installation.tenant_id, f"rtc-mode:{mode}", installation.installation_id) + if installation.rtc_write_mode not in ("DISABLED", "DRY_RUN") and not _is_privileged(admin): + return _deny(session, admin, installation.tenant_id, f"rtc-mode:leave:{installation.rtc_write_mode}", installation.installation_id) if mode == "ENABLED" and (values.get("confirm") or "").strip().upper() != "PREENCHER": return HTMLResponse("Digite PREENCHER para confirmar o modo automático", status_code=422) previous = installation.rtc_write_mode installation.rtc_write_mode = mode installation.rtc_auto_apply_authorized = mode == "ENABLED" record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="RTC_WRITE_MODE", target_type="installation", target_id=installation.installation_id, - context={"from": previous, "to": mode}, severity="WARNING") + context={"from": previous, "to": mode, "operator": admin.display_name or admin.username, "role": admin.external_role or admin.auth_source}, severity="WARNING") session.commit() return RedirectResponse(f"/agents/{agent_id}/rtc", status_code=303) finally: session.close() @@ -382,6 +396,8 @@ def router(session_factory): agent, installation = _rtc_target(session, agent_id) if not agent or not installation: return HTMLResponse("Agent not found", status_code=404) action = values.get("action") + if not _is_privileged(admin): + return _deny(session, admin, installation.tenant_id, f"rtc-candidates:{action}", installation.installation_id) scope = RtcCandidate.installation_id == installation.id ids = [int(x) for x in parse_qs((await request.body()).decode()).get("ids", []) if x.isdigit()] select_all = values.get("all") == "1" or request.query_params.get("all") == "1" @@ -390,14 +406,14 @@ def router(session_factory): new, old = ("APPROVED", ("PENDING",)) if action == "approve" else ("REJECTED", ("PENDING", "APPROVED")) cond = scope & RtcCandidate.status.in_(old) & (RtcCandidate.id.in_(ids) if not select_all else RtcCandidate.status.in_(old)) changed = session.execute(update(RtcCandidate).where(cond).values(status=new)).rowcount - record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action=f"RTC_CANDIDATES_{action.upper()}", target_type="installation", target_id=installation.installation_id, context={"changed": changed, "all": select_all}, severity="WARNING") + record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action=f"RTC_CANDIDATES_{action.upper()}", target_type="installation", target_id=installation.installation_id, context={"changed": changed, "all": select_all, "operator": admin.display_name or admin.username}, severity="WARNING") elif action == "apply_approved": if installation.rtc_write_mode != "APPROVAL_REQUIRED": return HTMLResponse("Disponível apenas no modo Aprovação manual", status_code=409) codes = list(session.scalars(select(RtcCandidate.product_code).where(scope, RtcCandidate.status == "APPROVED").order_by(RtcCandidate.product_code).limit(500))) if codes: job = create_job(session, agent, "REPROCESS_PRODUCTS", {"codigos": codes, "reason": "RTC_APPLY_APPROVED", "requested_by": admin.id}) - record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="RTC_APPLY_APPROVED", target_type="installation", target_id=installation.installation_id, context={"job_id": job.id, "products": len(codes)}, severity="WARNING") + record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="RTC_APPLY_APPROVED", target_type="installation", target_id=installation.installation_id, context={"job_id": job.id, "products": len(codes), "operator": admin.display_name or admin.username}, severity="WARNING") else: return HTMLResponse("ação inválida", status_code=422) session.commit() @@ -414,6 +430,8 @@ def router(session_factory): require_csrf(web_session, values.get("csrf_token")) agent, installation = _rtc_target(session, agent_id) if not agent or not installation: return HTMLResponse("Agent not found", status_code=404) + if not _is_privileged(admin): + return _deny(session, admin, installation.tenant_id, "rtc-rollback", installation.installation_id) if (values.get("confirm") or "").strip().upper() != "REVERTER": return HTMLResponse("Digite REVERTER para confirmar", status_code=422) audit = session.get(RtcWriteAudit, int(values.get("audit_id") or 0)) @@ -422,7 +440,7 @@ def router(session_factory): audit.rollback_state = "REQUESTED" job = create_job(session, agent, "REPROCESS_PRODUCTS", {"codigos": [audit.product_code], "reason": "RTC_ROLLBACK", "requested_by": admin.id}) record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="RTC_ROLLBACK_REQUESTED", target_type="installation", target_id=installation.installation_id, - context={"audit_id": audit.id, "product_code": audit.product_code, "job_id": job.id}, severity="WARNING") + context={"audit_id": audit.id, "product_code": audit.product_code, "job_id": job.id, "operator": admin.display_name or admin.username}, severity="WARNING") session.commit() return RedirectResponse(f"/agents/{agent_id}/rtc", status_code=303) finally: session.close() diff --git a/central/templates/rtc_control.html b/central/templates/rtc_control.html index ead9e12..be0fba8 100644 --- a/central/templates/rtc_control.html +++ b/central/templates/rtc_control.html @@ -2,7 +2,7 @@ {% block title %}cClassTrib | GTI Fiscal{% endblock %} {% block content %}

← Instalação / REFORMA TRIBUTÁRIA

Preenchimento de cClassTrib

-

{{ explanation }}

+

{{ explanation }}

{% if not privileged %}

Somente leitura para o seu perfil: alterar para aprovação/automático, aprovar, aplicar e reverter exige administrador do GTI Gerenciador.

{% endif %} {% set rtc = ops.rtc %} {% if rtc and rtc.products %} @@ -18,7 +18,7 @@

Modo de preenchimento

Decidido centralmente para esta instalação. Padrão: Desativada. Nenhuma configuração local no servidor do cliente.

-{% for key, label in modes.items() %}
@@ -29,7 +29,7 @@ {% for c in rows %}{{ c.product_code }}{{ c.description or "" }}{{ c.ncm }}{{ c.current_value or "vazio" }}{{ c.proposed }}{{ c.cst_ibscbs }}{{ L.rule(c.rule_id) }}{{ c.basis }}{{ L.catalog("MATCH_SAFE") }}{{ L.status(c.status) }} {% else %}Nenhum candidato neste filtro.{% endfor %}

Página {{ page }} de {{ pages }}{% if page > 1 %} · anterior{% endif %}{% if page < pages %} · próxima{% endif %}

-

+

{% if mode == "APPROVAL_REQUIRED" %}{% endif %}

diff --git a/central/tests/test_heavy_job_backpressure.py b/central/tests/test_heavy_job_backpressure.py new file mode 100644 index 0000000..6383f14 --- /dev/null +++ b/central/tests/test_heavy_job_backpressure.py @@ -0,0 +1,67 @@ +"""Backpressure: at most HEAVY_JOB_CONCURRENCY_LIMIT whole-database jobs run at once across all Agents; the rest queue as PENDING.""" +from types import SimpleNamespace + +from sqlalchemy import select + +from central.app import jobs +from central.app.models import Agent, Job, Tenant +from central.tests.test_control_plane import setup_db + + +def _agents(session, n): + tenant = Tenant(name="Backpressure") + session.add(tenant) + session.flush() + out = [] + for i in range(n): + agent = Agent(tenant_id=tenant.id, name=f"a{i}", token_hash=f"t{i}".ljust(64, "x"), agent_version="1.2.1") + session.add(agent) + out.append(agent) + session.flush() + return out + + +def _limit(monkeypatch, value): + monkeypatch.setattr(jobs, "settings", SimpleNamespace(heavy_job_concurrency_limit=value)) + + +def test_only_the_configured_number_of_full_jobs_run_at_once_and_the_rest_wait(monkeypatch): + _limit(monkeypatch, 2) + with setup_db()() as session: + agents = _agents(session, 4) + queued = [jobs.create_job(session, a, "INITIAL_SYNC") for a in agents] + claimed = [jobs.claim_next_job(session, a) for a in agents] + assert [c is not None for c in claimed] == [True, True, False, False] # 2 running, 2 queued (backpressure) + assert [session.get(Job, q.id).status for q in queued] == ["RUNNING", "RUNNING", "PENDING", "PENDING"] + jobs.complete_job(session, session.get(Job, queued[0].id), {}) # a slot frees up + assert jobs.claim_next_job(session, agents[2]) is not None and jobs.claim_next_job(session, agents[3]) is None + + +def test_a_light_incremental_job_is_never_held_behind_a_queued_full_job(monkeypatch): + _limit(monkeypatch, 1) + with setup_db()() as session: + busy, waiting = _agents(session, 2) + jobs.create_job(session, busy, "REPROCESS_ALL") + assert jobs.claim_next_job(session, busy) is not None + full = jobs.create_job(session, waiting, "REPROCESS_ALL") + light = jobs.create_job(session, waiting, "REPROCESS_PRODUCTS", {"codigos": ["1"]}) + claimed = jobs.claim_next_job(session, waiting) + assert claimed is not None and claimed.id == light.id and session.get(Job, full.id).status == "PENDING" + + +def test_limit_zero_disables_backpressure_and_a_stale_running_job_does_not_hold_a_slot(monkeypatch): + from datetime import datetime, timedelta, timezone + _limit(monkeypatch, 0) + with setup_db()() as session: + agents = _agents(session, 3) + for a in agents: + jobs.create_job(session, a, "INITIAL_SYNC") + assert all(jobs.claim_next_job(session, a) is not None for a in agents) + _limit(monkeypatch, 1) + with setup_db()() as session: + dead, fresh = _agents(session, 2) + stale = jobs.create_job(session, dead, "INITIAL_SYNC") + session.execute(Job.__table__.update().where(Job.id == stale.id).values(status="RUNNING", started_at=datetime.now(timezone.utc) - timedelta(hours=3))) + session.commit() + jobs.create_job(session, fresh, "INITIAL_SYNC") + assert jobs.claim_next_job(session, fresh) is not None # the dead Agent's 3 h old RUNNING job does not block the queue diff --git a/central/tests/test_rtc_security.py b/central/tests/test_rtc_security.py index c231815..ea57919 100644 --- a/central/tests/test_rtc_security.py +++ b/central/tests/test_rtc_security.py @@ -97,3 +97,54 @@ def test_agent_can_reread_only_its_own_current_authorization_and_it_follows_the_ s.commit() body = client.get("/v1/agents/me/authorizations", headers=H).json() assert body["rtc_write_mode"] == "ENABLED" and set(body) == {"rtc_write_mode", "rtc_approved_codes", "rtc_rollbacks"} + + +def _become(client, role): + """Turn the logged-in test operator into a Gerenciador SSO collaborator with the given perfil (identity source of truth is the SSO).""" + from central.app.models import AdminUser + with client._session_factory() as s: + admin = s.scalars(select(AdminUser)).first() + admin.auth_source, admin.password_hash, admin.external_subject, admin.external_role = "GTI_GERENCIADOR", None, "sso-subject-1", role + s.commit() + + +def _post(client, path, **data): + return client.post(path, data={"csrf_token": client.cookies.get("gti_csrf"), **data}, follow_redirects=False) + + +def test_write_modes_are_restricted_to_gerenciador_admins_and_every_denial_is_audited(): + client, agent_id, _ = _panel_client() + _become(client, "PADRAO") + for mode, extra in (("ENABLED", {"confirm": "PREENCHER"}), ("APPROVAL_REQUIRED", {})): + assert _post(client, f"/agents/{agent_id}/rtc-mode", mode=mode, **extra).status_code == 403 + assert _post(client, f"/agents/{agent_id}/rtc-candidates", action="approve", all="1").status_code == 403 + assert _post(client, f"/agents/{agent_id}/rtc-candidates", action="apply_approved").status_code == 403 + assert _post(client, f"/agents/{agent_id}/rtc-rollback", audit_id="1", confirm="REVERTER").status_code == 403 + with client._session_factory() as s: + assert s.scalars(select(Installation)).one().rtc_write_mode == "DISABLED" + assert len([e for e in s.scalars(select(AuditEvent)) if e.action == "RTC_DENIED"]) == 5 + # read-only modes stay available to a normal collaborator, and the page tells them why the rest is disabled + assert _post(client, f"/agents/{agent_id}/rtc-mode", mode="DRY_RUN").status_code == 303 + page = client.get(f"/agents/{agent_id}/rtc").text + assert "Somente leitura para o seu perfil" in page and "disabled" in page + + +def test_a_normal_collaborator_cannot_switch_off_a_privileged_mode_either(): + client, agent_id, _ = _panel_client() + with client._session_factory() as s: + s.scalars(select(Installation)).one().rtc_write_mode = "ENABLED" + s.commit() + _become(client, "PADRAO") + assert _post(client, f"/agents/{agent_id}/rtc-mode", mode="DISABLED").status_code == 403 + with client._session_factory() as s: + assert s.scalars(select(Installation)).one().rtc_write_mode == "ENABLED" + + +def test_gerenciador_admin_can_change_modes_and_the_operator_is_recorded(): + client, agent_id, _ = _panel_client() + _become(client, "ADMIN") + assert _post(client, f"/agents/{agent_id}/rtc-mode", mode="ENABLED", confirm="PREENCHER").status_code == 303 + with client._session_factory() as s: + assert s.scalars(select(Installation)).one().rtc_write_mode == "ENABLED" + event = [e for e in s.scalars(select(AuditEvent)) if e.action == "RTC_WRITE_MODE"][-1] + assert event.context["to"] == "ENABLED" and event.context["role"] == "ADMIN" and event.context["operator"] diff --git a/deploy/hub/.env.example b/deploy/hub/.env.example index 7b45d0e..d9c2a71 100644 --- a/deploy/hub/.env.example +++ b/deploy/hub/.env.example @@ -43,3 +43,5 @@ FISCAL_INTERNAL_API_TOKEN= # Directory (host) where pg_dump backups are written by backup.sh BACKUP_DIR=./backups +# Max whole-database jobs (INITIAL_SYNC / REPROCESS_ALL) running at once across all Agents; others queue as PENDING. <=0 disables. +HEAVY_JOB_CONCURRENCY_LIMIT=3 diff --git a/docs/rtc_auto_apply.md b/docs/rtc_auto_apply.md index 14947df..2aa0f13 100644 --- a/docs/rtc_auto_apply.md +++ b/docs/rtc_auto_apply.md @@ -21,6 +21,12 @@ Sequência por produto (uma transação curta): reler tudo → recalcular a deci ## Reautorização imediata antes da escrita (1.2.1) O modo entregue no job pode envelhecer (operador desativa durante um lote). Antes de **cada** UPDATE o Agent relê a autorização atual no Hub (`GET /v1/agents/me/authorizations`, autenticado pelo token do próprio Agent; devolve só modo, aprovados e reversões da sua instalação; cache de 3 s). Se o modo mudou, se o produto perdeu a aprovação ou se o Hub não puder ser consultado, o produto é interrompido **sem escrever** (`AUTHORIZATION_CHANGED` / `AUTHORIZATION_UNVERIFIABLE`). Autorização não confirmada não é autorização. +## Quem pode mudar o que (RBAC, 1.2.1) +A identidade vem do SSO do GTI Gerenciador (`perfil`). **ADMIN** (e contas LOCAL de emergência) pode: `APPROVAL_REQUIRED`/`ENABLED`, aprovar/rejeitar, aplicar aprovados e reverter. Um colaborador **PADRAO** só vê a página e alterna `DISABLED`/`DRY_RUN` (somente leitura); tentativas restritas devolvem 403 e ficam auditadas (`RTC_DENIED`). Sair de um modo de escrita também é restrito a ADMIN. Toda mudança de modo, aprovação e reversão grava o operador e o perfil no evento de auditoria. + +## Backpressure de jobs pesados (1.2.1) +`HEAVY_JOB_CONCURRENCY_LIMIT` (padrão 3; `<=0` desliga): no máximo N jobs `INITIAL_SYNC`/`REPROCESS_ALL` rodam ao mesmo tempo em **todos** os Agents; os demais ficam `PENDING` (fila) e o Agent segue com heartbeat e polling. Um job leve nunca fica preso atrás de um pesado. Job RUNNING há mais de 1 h não ocupa vaga. Medição (8 Agents × 1.042 produtos, Hub de desenvolvimento): sem limite 51,9 produtos/s, CPU pico 149%, cada Agent até 160 s; com limite 3: 66,7 produtos/s, CPU pico 116%, cada Agent até 45 s (espera máx. 88 s), 0 falhas. + ## Preview e aprovação O Agent envia os candidatos (até 5.000 por job; `preview_truncated` sinaliza excesso) → tabela `rtc_candidates` (PENDING/APPROVED/REJECTED/APPLIED/STALE). O painel pagina no servidor (50/página), filtra por status, aprova/rejeita selecionados ou todos os pendentes e dispara "Aplicar aprovados agora". Uma proposta que muda invalida a aprovação anterior; produto que deixa de ser elegível numa avaliação completa vira `STALE`. diff --git a/docs/runs/final_corpus/concurrency_sim.json b/docs/runs/final_corpus/concurrency_sim.json new file mode 100644 index 0000000..5b4b759 --- /dev/null +++ b/docs/runs/final_corpus/concurrency_sim.json @@ -0,0 +1,28 @@ +{ + "agents": 8, + "products_per_agent": 1042, + "runs": [ + { + "limit": "none", + "wall_s": 160.6, + "products_per_s": 51.9, + "failures": 0, + "max_run_s": 160.6, + "max_queue_wait_s": 0.0, + "hub_cpu_peak_pct": 149.18, + "hub_cpu_avg_pct": 130.0, + "hub_mem_peak_mib": 89.19 + }, + { + "limit": 3, + "wall_s": 124.9, + "products_per_s": 66.7, + "failures": 0, + "max_run_s": 45.5, + "max_queue_wait_s": 88.4, + "hub_cpu_peak_pct": 115.81, + "hub_cpu_avg_pct": 71.3, + "hub_mem_peak_mib": 79.25 + } + ] +} \ No newline at end of file diff --git a/scripts/concurrency_sim.py b/scripts/concurrency_sim.py new file mode 100644 index 0000000..58fb0e5 --- /dev/null +++ b/scripts/concurrency_sim.py @@ -0,0 +1,73 @@ +"""Controlled concurrency simulation of whole-database classification against the LOCAL dev Hub. +N simulated Agents each classify M products (read-only Firebird sample + Hub /classify, exactly the Agent's HubClient path). +UNLIMITED = all N at once; LIMITED = only K at a time (what HEAVY_JOB_CONCURRENCY_LIMIT enforces at job claim). Samples the Hub container CPU/RAM (docker stats). + python scripts/concurrency_sim.py [--agents 8] [--products 2084] [--limit 3]""" +import argparse +import json +import os +import subprocess +import sys +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(ROOT), str(ROOT / "scripts"), str(ROOT / "agent" / "src")] +from dotenv import load_dotenv # noqa: E402 + +load_dotenv(ROOT / ".env") +import local_completion as lc # noqa: E402 +from gti_fiscal_agent import firebird_reader # noqa: E402 +from gti_fiscal_agent.client import HubClient # noqa: E402 +from gti_fiscal_agent.runner import to_hub_product # noqa: E402 + + +def sampler(stop, samples): + while not stop.is_set(): + out = subprocess.run(["docker", "stats", "--no-stream", "--format", "{{.CPUPerc}}|{{.MemUsage}}", "projeto-fiscal-hub-1"], capture_output=True, text=True).stdout.strip() + try: + cpu, mem = out.split("|"); samples.append((float(cpu.strip("%")), float(mem.split("/")[0].strip().replace("MiB", "").replace("GiB", "")) )) + except Exception: # noqa: BLE001 + pass + time.sleep(1) + + +def run(agents, products, limit): + gate = threading.Semaphore(limit if limit else agents) + results, samples, stop = [], [], threading.Event() + threading.Thread(target=sampler, args=(stop, samples), daemon=True).start() + + def one(i): + queued = time.time() + with gate: + started = time.time() + try: + client = HubClient(os.environ.get("HUB_URL", lc.HUB), os.environ["AGENT_DEV_TOKEN"]) + client.classify_batch(products) + return {"agent": i, "queue_wait_s": round(started - queued, 1), "run_s": round(time.time() - started, 1), "ok": True} + except Exception as error: # noqa: BLE001 + return {"agent": i, "queue_wait_s": round(started - queued, 1), "run_s": round(time.time() - started, 1), "ok": False, "error": type(error).__name__} + t0 = time.time() + with ThreadPoolExecutor(agents) as pool: + results = list(pool.map(one, range(agents))) + wall = time.time() - t0 + stop.set() + cpus = [c for c, _ in samples] or [0]; mems = [m for _, m in samples] or [0] + return {"limit": limit or "none", "wall_s": round(wall, 1), "products_per_s": round(agents * len(products) / wall, 1), "failures": sum(1 for r in results if not r["ok"]), + "max_run_s": max(r["run_s"] for r in results), "max_queue_wait_s": max(r["queue_wait_s"] for r in results), "hub_cpu_peak_pct": max(cpus), "hub_cpu_avg_pct": round(sum(cpus) / len(cpus), 1), "hub_mem_peak_mib": max(mems)} + + +def main(): + ap = argparse.ArgumentParser(); ap.add_argument("--agents", type=int, default=8); ap.add_argument("--products", type=int, default=2084); ap.add_argument("--limit", type=int, default=3) + a = ap.parse_args() + env = lc.env_for(lc.allowlist()["TESTESOFICIAIS"]) + base = firebird_reader.read_products(env) + sample = [to_hub_product(p) for p in (base * (a.products // len(base) + 1))[:a.products]] + out = {"agents": a.agents, "products_per_agent": a.products, "runs": [run(a.agents, sample, 0), run(a.agents, sample, a.limit)]} + print(json.dumps(out, indent=1)) + (ROOT / "docs" / "runs" / "final_corpus" / "concurrency_sim.json").write_text(json.dumps(out, indent=1), encoding="utf-8") + + +if __name__ == "__main__": + main()