feat: RBAC for RTC write modes (Gerenciador ADMIN only, denials audited), central backpressure for heavy jobs (HEAVY_JOB_CONCURRENCY_LIMIT), concurrency simulation evidence

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gti
2026-09-25 09:46:21 -03:00
co-authored by Claude Sonnet 5
parent 7063c8c161
commit af5d9b4436
10 changed files with 274 additions and 13 deletions
+2
View File
@@ -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")
+19 -5
View File
@@ -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(
+23 -5
View File
@@ -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()
+3 -3
View File
@@ -2,7 +2,7 @@
{% block title %}cClassTrib | GTI Fiscal{% endblock %}
{% block content %}
<div class="detail-head"><div><p class="eyebrow"><a href="/agents/{{ agent_id }}">← Instalação</a> / REFORMA TRIBUTÁRIA</p><h1>Preenchimento de cClassTrib</h1>
<p class="muted">{{ explanation }}</p></div></div>
<p class="muted">{{ explanation }}</p>{% if not privileged %}<p class="muted"><strong>Somente leitura para o seu perfil:</strong> alterar para aprovação/automático, aprovar, aplicar e reverter exige administrador do GTI Gerenciador.</p>{% endif %}</div></div>
{% set rtc = ops.rtc %}
{% if rtc and rtc.products %}
@@ -18,7 +18,7 @@
<section class="panel"><div class="panel-title"><div><h2>Modo de preenchimento</h2><p class="muted">Decidido centralmente para esta instalação. Padrão: Desativada. Nenhuma configuração local no servidor do cliente.</p></div></div>
<form method="post" action="/agents/{{ agent_id }}/rtc-mode"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% for key, label in modes.items() %}<label style="display:block"><input type="radio" name="mode" value="{{ key }}" {% if key == mode %}checked{% endif %}> {{ label }}
{% for key, label in modes.items() %}<label style="display:block"><input type="radio" name="mode" value="{{ key }}" {% if key == mode %}checked{% endif %} {% if not privileged and key not in ("DISABLED", "DRY_RUN") %}disabled{% endif %}> {{ label }}
{% if key == "DISABLED" %}— apenas informa{% elif key == "DRY_RUN" %}— mostra os candidatos, não escreve{% elif key == "APPROVAL_REQUIRED" %}— escreve só o que for aprovado abaixo{% else %}— preenche os candidatos determinísticos{% endif %}</label>{% endfor %}
<p><input type="text" name="confirm" placeholder="digite PREENCHER (modo automático)" aria-label="Confirmar modo automático" size="30"> <button type="submit" class="primary-action">Salvar modo</button></p></form></section>
@@ -29,7 +29,7 @@
{% for c in rows %}<tr><td><input type="checkbox" name="ids" value="{{ c.id }}" aria-label="Selecionar {{ c.product_code }}"></td><td>{{ c.product_code }}</td><td>{{ c.description or "" }}</td><td>{{ c.ncm }}</td><td>{{ c.current_value or "vazio" }}</td><td>{{ c.proposed }}</td><td>{{ c.cst_ibscbs }}</td><td>{{ L.rule(c.rule_id) }}</td><td>{{ c.basis }}</td><td>{{ L.catalog("MATCH_SAFE") }}</td><td>{{ L.status(c.status) }}</td></tr>
{% else %}<tr><td colspan="10" class="muted">Nenhum candidato neste filtro.</td></tr>{% endfor %}</tbody></table>
<p>Página {{ page }} de {{ pages }}{% if page > 1 %} · <a href="?status={{ status }}&amp;page={{ page - 1 }}">anterior</a>{% endif %}{% if page < pages %} · <a href="?status={{ status }}&amp;page={{ page + 1 }}">próxima</a>{% endif %}</p>
<p><button type="submit" name="action" value="approve" class="secondary">Aprovar selecionados</button> <button type="submit" name="action" value="reject" class="caution">Rejeitar selecionados</button>
<p><button type="submit" name="action" value="approve" class="secondary" {% if not privileged %}disabled{% endif %}>Aprovar selecionados</button> <button type="submit" name="action" value="reject" class="caution">Rejeitar selecionados</button>
<button type="submit" name="action" value="approve" class="secondary" formaction="/agents/{{ agent_id }}/rtc-candidates?all=1">Aprovar todos os pendentes</button>
{% if mode == "APPROVAL_REQUIRED" %}<button type="submit" name="action" value="apply_approved" class="primary-action">Aplicar aprovados agora</button>{% endif %}</p></form></section>
@@ -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
+51
View File
@@ -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"]
+2
View File
@@ -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
+6
View File
@@ -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`.
@@ -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
}
]
}
+73
View File
@@ -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()