Files
abap-llm/harness/loadtest.py
2026-10-05 13:26:13 +02:00

145 lines
5.8 KiB
Python

"""MCP parallelism test: N clients call read-only tools for D seconds; reports calls/s, latency and lock hits.
python3 -m harness.loadtest [--levels 1 2 4 6 8] [--seconds 20]
No object is created or changed. A "Concurrent call detected" answer is counted as a lock hit (no hidden retry),
then the client waits 0.3 s and calls again. Other runs on the same A4H change the numbers: read the result with that in mind.
"""
import argparse
import json
import os
import statistics
import threading
import time
from .adt_client import load_env
from .mcp_client import McpClient
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CALLS = [("sap_search_object", {"query": "CL_ABAP_CHAR_UTIL*", "objType": "CLAS"}),
("sap_pull_source", {"objectType": "INTF", "objectName": "IF_SERIALIZABLE_OBJECT"}),
("sap_object_structure", {"objectType": "CLAS", "objectName": "CL_ABAP_CHAR_UTILITIES"})]
HEAVY = [("sap_atc_run", {"objectType": "CLAS", "objectName": "CL_ABAP_CHAR_UTILITIES"}),
("sap_syntax_check", {"objectType": "CLAS", "objectName": "CL_ABAP_CHAR_UTILITIES"})]
def write_worker(idx, end, out):
"""create + push (activates) + delete a tiny class; latency = create + push."""
from .runner import delete_uris
lat, locks, errors = [], 0, 0
m = McpClient(timeout=300).open()
n = 0
def call(tool, args):
nonlocal locks
for _ in range(60):
body = {"jsonrpc": "2.0", "id": 1000 + n, "method": "tools/call", "params": {"name": tool, "arguments": args}}
res = m._post(body)
text = "\n".join(c.get("text", "") for c in res["result"].get("content", []))
if "Concurrent call detected" in text:
locks += 1
time.sleep(0.3)
continue
return text
return "lock retries exhausted"
try:
while time.time() < end:
n += 1
name = f"ZLT_LOAD_W{idx}_{n:03d}"
src = (f"CLASS {name.lower()} DEFINITION PUBLIC FINAL CREATE PUBLIC.\n PUBLIC SECTION.\n"
" METHODS run RETURNING VALUE(rv) TYPE i.\nENDCLASS.\n"
f"CLASS {name.lower()} IMPLEMENTATION.\n METHOD run.\n rv = 1.\n ENDMETHOD.\nENDCLASS.\n")
t0 = time.time()
try:
c = call("sap_create_object", {"objectType": "CLAS", "objectName": name, "packageName": "$TMP",
"description": "load test"})
p = call("sap_push_source", {"objectType": "CLAS", "objectName": name, "source": src})
ok = '"success":true' in p.replace(" ", "")
(lat.append(time.time() - t0) if ok else None)
errors += 0 if ok else 1
except Exception: # noqa: BLE001
errors += 1
try:
delete_uris([f"/sap/bc/adt/oo/classes/{name.lower()}"])
except Exception: # noqa: BLE001
pass
finally:
m.close()
out[idx] = {"calls": len(lat), "lat": lat, "locks": locks, "errors": errors}
def worker(idx, end, out):
if MODE == "write":
return write_worker(idx, end, out)
lat, locks, errors = [], 0, 0
try:
m = McpClient(timeout=120).open()
except Exception as e: # noqa: BLE001 session limit (8) or server down
out[idx] = {"calls": 0, "lat": [], "locks": 0, "errors": 1, "open_error": repr(e)[:120]}
return
n = 0
try:
while time.time() < end:
calls = HEAVY if MODE == "heavy" else CALLS
tool, args = calls[n % len(calls)]
n += 1
t0 = time.time()
body = {"jsonrpc": "2.0", "id": n + 1, "method": "tools/call", "params": {"name": tool, "arguments": args}}
try:
res = m._post(body)
text = "\n".join(c.get("text", "") for c in res["result"].get("content", [])) if "result" in res else str(res)
except Exception: # noqa: BLE001
errors += 1
continue
if "Concurrent call detected" in text:
locks += 1
time.sleep(0.3)
continue
lat.append(time.time() - t0)
finally:
m.close()
out[idx] = {"calls": len(lat), "lat": lat, "locks": locks, "errors": errors}
def level(n, seconds):
out, end = {}, time.time() + seconds
ts = [threading.Thread(target=worker, args=(i, end, out)) for i in range(n)]
t0 = time.time()
for t in ts:
t.start()
time.sleep(0.2)
for t in ts:
t.join()
wall = time.time() - t0
lat = sorted(x for r in out.values() for x in r["lat"])
calls = len(lat)
return {"clients": n, "calls": calls, "calls_per_s": round(calls / wall, 2),
"lat_median_s": round(statistics.median(lat), 2) if lat else None,
"lat_p95_s": round(lat[int(0.95 * len(lat))], 2) if lat else None,
"lock_hits": sum(r["locks"] for r in out.values()), "errors": sum(r["errors"] for r in out.values()),
"open_errors": [r["open_error"] for r in out.values() if r.get("open_error")]}
MODE = "light"
def main():
load_env(os.path.join(ROOT, ".env"))
ap = argparse.ArgumentParser()
ap.add_argument("--levels", type=int, nargs="*", default=[1, 2, 4, 6, 8])
ap.add_argument("--seconds", type=int, default=20)
ap.add_argument("--heavy", action="store_true", help="ATC and syntax check instead of light reads")
ap.add_argument("--write", action="store_true", help="create + activate + delete a tiny class (creates objects ZLT_LOAD_*)")
a = ap.parse_args()
global MODE
MODE = "write" if a.write else ("heavy" if a.heavy else "light")
for n in a.levels:
print(json.dumps(level(n, a.seconds)), flush=True)
time.sleep(3)
if __name__ == "__main__":
main()