Latest commit

History

45 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

HelmsDeep

HelmsDeepHTTP Endpoint Load Measurement System, Determining Each Endpoint's Performance — drives a stepped ramp of concurrent users against a single NCATS Translator component and reports the max sustainable concurrency (the "knee") — the highest load where the service still meets a latency/error SLO.

The Translator stack cascades ARS → ARAs → KPs, so a run targets exactly one layer at a time (testing a higher layer already loads everything beneath it). All three workflows are wired up: Retriever (KP), Shepherd (ARA), and the asynchronous ARS.

In plain language: HelmsDeep slowly turns up the number of simultaneous users hitting a service and watches when it starts getting too slow or too error-prone. The single headline number it reports — the knee, or "max sustainable concurrency" — is the largest number of simultaneous users the service handled while still staying fast enough and erroring rarely enough. Above that load, the service is overwhelmed. If you only read one thing, read the "How to read the results" section.

Install

pip install -e .# Python >= 3.12; installs locust

Run a workflow

# Retriever (KP) — sync lookup queries, scalar parameters.tier per query
helmsdeep --targets kps \
--host https://your-retriever-service.example.org \
--csv-prefix run1
# Shepherd (ARA) — sync creative-mode (inferred) queries, cache bypassed
helmsdeep --targets aras \
--host https://your-ara-service.example.org \
--csv-prefix run1
# ARS — async submit/poll/merge of inferred queries (host = the ARS API base)
helmsdeep --targets ars \
--host https://ars.ci.transltr.io/ars/api \
--csv-prefix run1
# Pathfinder — its own heavier run type (ARA/ARS only); pins two endpoints and# asks for connecting paths. Sync via the ARA, async via the ARS.
helmsdeep --targets aras_pathfinder \
--host https://your-ara-service.example.org \
--csv-prefix pf1
helmsdeep --targets ars_pathfinder \
--host https://ars.ci.transltr.io/ars/api \
--csv-prefix pf1
# Mixed capacity profile (ARA/ARS only) — 2/3 inferred MVP1+MVP2, 1/3 Pathfinder# in one blended workload, ramped to 30 → 45 → 60 concurrent users and judged# against pass/fail acceptance checkpoints.
helmsdeep --targets aras_mixed \
--host https://your-ara-service.example.org \
--csv-prefix mix1
helmsdeep --targets ars_mixed \
--host https://ars.ci.transltr.io/ars/api \
--csv-prefix mix1
  • --targets selects the layer: kps (Retriever), aras (Shepherd), ars, the Pathfinder run types aras_pathfinder / ars_pathfinder (ARA/ARS only), or the mixed capacity profile aras_mixed / ars_mixed (ARA/ARS only — see "Mixed capacity profile").
  • --host is required — the base URL of the target service. For kps/aras the /query path is appended; for ars the host is the API base and the tool uses /submit then /messages/{pk}.
  • --csv-prefix is optional; it falls back to the LOCUST_CSV_PREFIX env var, then to trapi_run.
  • --time-budget / --quick compress a run to fit a wall clock — see "Shorter runs" below.

The load profile (users, spawn rate, duration) is driven by the StepLoad shape, not by CLI flags — so there is intentionally no -u/-r/-t. The ramp and knee threshold are per target in helmsdeep/config.py (stages and p99_slo_ms), since cost profiles differ wildly by layer; edit them there.

An optional per-target cooldown_s inserts a quiet gap between stages: users ramp to 0 so slow in-flight queries drain (counted under the stage that launched them) before the next stage starts clean, instead of bleeding into it. It defaults to 0 (cheap KP lookups don't bleed) and is set on the expensive ARA/ARS/Pathfinder targets.

Shorter runs (--time-budget / --quick)

A full run is long on purpose: slow queries need long holds to accumulate enough samples for a trustworthy percentile (ars_mixed takes ~70 minutes). When you don't need that — checking a host, corpus, or config change end to end, or taking a rough read — compress it:

# Smoke run: the whole ramp in about 10 minutes.
helmsdeep --targets ars_mixed --host https://ars.ci.transltr.io/ars/api --quick
# Or name your own budget: 30m, 1h, 90s, or plain seconds.
helmsdeep --targets aras_mixed --host https://your-ara.example.org --time-budget 30m

--quick is shorthand for --time-budget 10m. Both scale the durations — stage holds, cooldowns, and the poll/timeout caps that gate how long one query may run — by a single factor. The shape is untouched: same user counts, same stages, same SLOs, same checkpoints. So --targets ars_mixed --time-budget 30m still ramps 10 → 30 → 45 → 60 users and still judges all three checkpoints; each stage just gets ~6 minutes instead of ~15.

The CLI prints the compressed plan before it starts, so you know what you're committing to:

Compressed run: 1h10m -> 30m02s (time scale 0.43), budget 30m00s
Stages: 10u x 4m17s -> 30u x 6m26s -> 45u x 6m26s -> 60u x 6m26s, 2m09s cooldown between
Per-query cap cut to 4m17s -- a query slower than that is recorded as a
timeout failure, so error rates are not comparable to a full run's.

What you give up. Two things, and they matter:

  1. Sample count, and with it the tail. A p99 over a handful of queries is noise. The mean and p50 hold up far better than p95/p99.
  2. What counts as a failure. The per-query caps shrink with everything else, so a query that would have finished in 4 minutes is a timeout when the cap is 2. Error rates — and therefore checkpoint verdicts — are not comparable to a full run's.

Every output is stamped with config.time_scale (1.0 = a full run), and the printed summary leads with a warning banner. Don't quote a compressed run's knee as a capacity result, and don't gate CI on its checkpoints.

Compression only ever speeds a run up: a budget above the target's natural duration is ignored (kps already runs in 7 minutes, so --quick leaves it alone). Stages won't shrink below a 30-second floor, so a budget too small to fit them simply overruns — the CLI says so and prints the real projected duration.

Want each stage to get a specific amount of time? The budget is for the whole run, including cooldowns, so divide accordingly — or edit the per-target stages in config.py directly, which is the only way to change the shape (dropping the baseline stage from a mixed run, say, to give the three checkpoints 10 minutes each).

Outputs

Written to the working directory by the standalone/master node:

  • <prefix>_stages.csv — one row per stage (overall metrics), including a stage_start column with the stage's wall-clock start time (ISO 8601 UTC)
  • <prefix>_by_qtype.csv — one row per (stage, query type)
  • <prefix>_summary.json — config (including which target was measured and the time_scale it ran at), all stages, and the chosen knee (plus checkpoints for targets that define them)
  • <prefix>_checkpoints.csvtargets with acceptance criteria (aras_mixed / ars_mixed): one row per checkpoint with its PASS / FAIL verdict
  • <prefix>_ars_health.csvARS only: per-stage health signals (see below)
  • <prefix>_ars_queries.csvARS only: one row per logical query with its pk, the HTTP status of each step, the terminal ARS status, any intermediate (retried, non-fatal) errors it hit along the way, and a ready-made URL for pulling that query up (see below)
  • <prefix>_ars_completion.csvARS only: one row per logical query with its end-to-end response time and whether it eventually finished — polled past the max_poll_s failure threshold up to completion_max_poll_s (see below)
  • <prefix>_report.html — Locust's native, self-contained HTML report (latency-over-time charts, request/failure tables). Open it in any browser. See the caveat under "How to read the results": its totals are blended across the whole run, so the authoritative ceiling is the knee in summary.json, not the HTML aggregate.
  • a printed summary table ending in the headline max sustainable concurrency

For a guide to interpreting every one of these — written for non-specialists — see "How to read the results" below.

Mixed capacity profile (aras_mixed / ars_mixed)

Every other target characterizes one query class in isolation to find the knee. The mixed profile answers a different, operational question: can the system hold a target concurrency when both query classes arrive together, the way they do in production?

  • Corpus — one blended workload: 2/3 inferred MVP1+MVP2 creative queries and 1/3 Pathfinder. MVP1/MVP2 keep their relative SHEPHERD_CORPUS weights (so retuning that mix carries over); only the 2:1 split is imposed. by_qtype.csv still breaks the two classes out separately, so you can see which one drives the tail.

  • Ramp — a short low-load baseline stage, then the three concurrency levels the profile exists to judge: 30 → 45 → 60 simultaneous users. The baseline exists so a failure at 30 can be read against a healthy reference (is 30 slow, or is everything slow?).

  • Verdict — each level carries pass/fail acceptance checkpoints instead of only feeding the knee:

    UsersGoalCriteria
    30sustain peak loadp99 ≤ 300 000 ms and errors ≤ 1%
    45headroom above peakp99 ≤ 300 000 ms and errors ≤ 1%
    60no substantial failures under overloaderrors ≤ 5% (latency not judged — slowdown is expected here, failures are not)

The verdicts land in <prefix>_checkpoints.csv, in summary.json (checkpoints plus a single checkpoints_passed boolean), and in the printed report:

ACCEPTANCE CHECKPOINTS
usr p99 bar err% bar verdict goal
30 184203 300000 0.4 1.0 PASS sustain peak load
45 291560 300000 0.9 1.0 PASS headroom above peak
60 402118 n/a 3.1 5.0 PASS no substantial failures under overload
RESULT: all 3 checkpoints met.

A missed checkpoint prints why (-> p99 331402ms > 300000ms) and makes the run exit non-zero, so it can gate a CI/acceptance job. A checkpoint whose stage recorded no completed requests is reported as NO DATA rather than a silent pass. The knee is still computed and reported alongside, unchanged.

Checkpoints are a generic per-target feature (checkpoints in config.py), not special-cased to this profile: any target can define them, and targets that don't behave exactly as before. Edit the users, goals, and bars there — the ARA and ARS variants carry the same three checkpoints so the two layers are directly comparable.

Layering still applies:ars_mixed fans out to the ARAs, so run one or the other — never both at once.

ARS async workflow and health signals

The ars target is asynchronous: each logical query is POST /submit → poll GET /messages/{pk}?trace=y (every poll_interval_s, capped at max_poll_s, default 15 min) until status is Done/Error → fetch GET /messages/{merged_pk} and count fields.data.message.results. Latency is the wall-clock submit→terminal time; one measurement is recorded per logical query. A single ars_query Locust request event carries that full wall-clock, so Locust's native stats table shows the true per-query time — the individual ars_submit/ars_poll/ars_merge calls also appear there as per-step diagnostics (e.g. ars_merge times only the final merge fetch, not the whole query), but aren't double-counted as the query time.

Because the ARS is what real users hit, the run also captures health signals to flag silent downstream breakage, written to <prefix>_ars_health.csv and summary.json (ars_health + a human-readable red_flags list):

  • result-count variation (min/mean/max + coefficient of variation) across identical queries;
  • zero-result Done count — by default a Done with 0 results is treated as a failure (counts against the error rate and the knee) and flagged; the per-target zero_result_is_failure flag can score it as a success instead (see "Is an empty answer a failure?");
  • response size (merged-message bytes, mean/max);
  • result drop under load — flagged when the mean result count falls sharply as concurrency rises across stages.

Is an empty answer a failure?

A terminal Done that carries 0 results is scored as a failure by default: under load an empty answer set usually means a downstream agent silently dropped out, which is exactly what the knee should catch. But that conflates a silent break with a query whose answer set is legitimately empty, so it is a per-target switch in config.py:

"zero_result_is_failure": True, # the default; set False to score only# transport/protocol outcomes as failures

With False, only submit errors, an Error status, and Timeout count as failures. Note this shifts more than the error rate: a failed request contributes no latency sample, so a zero-result query that stops being a failure starts feeding the percentile pool — mean, p99, and therefore the Little's-Law concurrency all move too. The two policies give two different knees, which is the point: run both to see how much of your ceiling is empty answers versus broken transport.

Whichever policy is in force, the zero-result query is still counted in ars_health.csv (zero_result_done), still raises a red flag, and still carries the Done with 0 results note in the per-query debug log. The policy itself is recorded in summary.json (config.zero_result_is_failure) and printed above the ARS health table, so a run's numbers can't be read under the wrong assumption:

ARS HEALTH (per stage)
0-result 'Done' scored as: FAILURE (zero_result_is_failure=True)

Per-query debug log (ars_queries.csv)

The aggregate reports tell you that 3% of queries failed at 45 users. This file tells you which ones, and hands you what you need to go look at them: one row per logical ARS query, written for every query — successful, failed, or timed out.

ColumnWhat it's for
queryQuery id. Joins to ars_completion.csv on the same column.
pkThe ARS primary key. Empty only when the submit never returned one.
stage, usersWhich stage the query is attributed to (the stage active when it reached its outcome — the same rule the per-stage stats use).
qtypeWhich corpus query it was (mvp1_heavy, pathfinder_drug_disease, …).
submit_startWhen it was submitted (ISO 8601 UTC) — line it up against service logs.
latency_sWall clock from submit to terminal status.
ars_statusDone / Error / Timeout / SubmitError / NoPK.
failedWhether it counted against the error rate. A Done with 0 results is failed=True under the default zero_result_is_failure policy — see below.
submit_http, poll_http, merge_httpHTTP status of the submit, the last poll, and the merged fetch. Separates "the ARS said no" from "the HTTP call broke".
pollsHow many status polls it took — a large number on a slow query says it was genuinely working, not stuck.
intermediate_error_countHow many non-fatal errors the query hit on the way to its outcome (0 = it ran clean). The poll loop retries through these, so they never decide ars_status — but they say a query only limped to Done.
result_count, response_bytesSize of the merged answer.
intermediate_errorsThose same errors in words, tallied: poll HTTP 502 x3; merge HTTP 500. Covers polls that came back non-200 (poll request failed when there was no HTTP response at all), poll bodies that wouldn't parse, and a merged fetch that 500'd, went missing (Done without merged_version) or wouldn't parse. Empty on a clean query.
errorWhy it failed, in words — the terminal reason, empty on success (as opposed to intermediate_errors, which is the trouble on the way there). Also carries Done with 0 results when a target scores that as a success — the note is what makes the pk worth opening either way.
message_urlFull URL to that query's message. Paste it into a browser or curl it.

The printed summary also points straight at the failures, so you don't have to open the file to start:

FAILED QUERIES (12 of 380; first 5 shown, all in run1_ars_queries.csv)
stage 2 pathfinder_drug_disease [Timeout] pk=8f3c… -- no terminal status within 600s (last: Running)
https://ars.ci.transltr.io/ars/api/messages/8f3c…?trace=y

Intermediate errors get their own line, because most of the queries carrying them succeeded — they never appear in the failed block, and a run that only limped to green otherwise reads as clean:

INTERMEDIATE ERRORS: 34 of 380 queries hit retried, non-fatal errors (intermediate_errors column in run1_ars_queries.csv)
stage 4 mvp1_heavy [Done] pk=a01f… -- poll HTTP 502 x3

Useful slices, once you have the file:

# Every failed query, as a table
awk -F, 'NR==1 || $9=="True"' run1_ars_queries.csv | column -s, -t
# The pks of everything that timed out at the top stage
awk -F, '$8=="Timeout" && $4==60 {print $2}' run1_ars_queries.csv
# Slowest queries, with their URLs
sort -t, -k7 -gr run1_ars_queries.csv | head -5 | cut -d, -f2,7,8,19
# Queries that succeeded but hit non-fatal errors on the way
awk -F, 'NR==1 || ($14+0 > 0 && $9=="False")' run1_ars_queries.csv | column -s, -t

On the relationship to ars_completion.csv: this file records what happened within the measurement window (a query not terminal by max_poll_s is a Timeout row here, which is exactly how the knee scores it). The completion sidecar separately answers whether that same query eventually finished. Join them on query. A timed-out query whose extended poll was still running at shutdown appears here but not there — which is precisely the query you most want the pk of.

ARS completion tracking (ars_completion.csv)

max_poll_s (default 6 min for ars) is the failure threshold: a query that hasn't reached a terminal status by then is recorded as a Timeout failure in the main stats and the knee — unchanged. But a timed-out query isn't necessarily stuck; the ARS may still finish it, just slowly. To capture that, once a query blows max_poll_s a background poller keeps watching it up to completion_max_poll_s (default 10 min) and records the outcome to <prefix>_ars_completion.csv — one row per logical query:

  • total_response_s — true end-to-end submit→terminal time (up to completion_max_poll_s);
  • finished — whether it ever reached a terminal status (Done or Error);
  • within_slo — whether it finished inside max_poll_s (i.e. was not a Timeout failure in the main stats); a slow-but-eventually-done query is finished=True, within_slo=False;
  • status — the terminal status (Done/Error/Timeout/SubmitError/NoPK).

summary.json carries a completion roll-up (finished, finished-within-SLO, finished-after-SLO, never-finished, submit-failed), and two red flags surface the key distinctions: queries that exceeded the SLO but did finish (slow, not stuck) and queries that never finished even within completion_max_poll_s. This separates slow from broken — a service that eventually answers every query is degraded differently than one that drops them. Set completion_max_poll_s equal to (or omit it, defaulting to) max_poll_s to turn extended tracking off; the sidecar then just marks each timed-out query finished=False. Any query still being polled when the test ends is drained for a bounded window at shutdown; ones still unfinished after that are absent from the file.

How to read the results

This section is for anyone — technical or not — who has a run's output files in hand and wants to answer one question: how much load can this service take?

Plain-language key

A few terms show up everywhere in the outputs. Here's what each one means, without the jargon:

TermWhat it means
StageOne step of the ramp. Each stage pins a fixed number of simultaneous users for a fixed time, then the next stage adds more. The run climbs stage by stage until the service struggles.
Users / concurrencyHow many simultaneous users are hammering the service. "Users" is what we asked for in a stage; concurrency is the effective number actually in flight, computed from the measured throughput and speed.
RPSRequests Per Second — how many queries the service actually completed each second during that stage. Higher is faster.
LatencyHow long one query took, in milliseconds (1000 ms = 1 second).
mean / p50 / p95 / p99Different ways to summarize latency. mean is the average. p50 (median) is the typical query. p95 / p99 are the slow tail: "95% (or 99%) of queries were at least this fast." p99 is the one we hold to a standard, because it captures the bad experiences, not just the average.
Error rateThe fraction of queries that failed (e.g. 0.02 = 2%). For the ARS, a query that finishes but returns zero answers also counts as a failure by default — a per-target flag can change that, and the run says which policy applied.
SLOService Level Objective — the line in the sand for "acceptable." Here it's a p99 latency cap (e.g. 60000 ms = 60 s) plus a max error rate (default 1%). A stage "passes" only if it stays under both.
CheckpointA pass/fail question asked of one specific load level ("does 30 simultaneous users still work?"), with its own latency/error bars. Only the mixed capacity profile defines these; other runs just report the knee.
KneeThe highest-load stage that still passes the SLO. It's the headline result: the most simultaneous users the service handled while staying fast enough and reliable enough. Past the knee, things fall apart.

Start here: summary.json

Open <prefix>_summary.json first. Two fields tell you almost everything:

  • max_sustainable_concurrency — the headline number. This is the answer to "how much can this service take?"
  • knee — the stage that number came from (its users, latency, error rate, etc.). This is the last healthy stage of the ramp.

If knee is null (and max_sustainable_concurrency is empty), the service failed even at the lightest load — it was already too slow or too error-prone in the very first stage. That's a strong signal something is wrong (or the SLO is set tighter than the service can ever meet).

The same file also echoes the config that produced the run (which target, which endpoint, the SLO, the ramp stages) so a result is self-documenting.

On a run with acceptance criteria (aras_mixed / ars_mixed), two more fields answer the pass/fail question directly: checkpoints_passed (one boolean for the whole run) and checkpoints (per-level verdicts and why). See "Mixed capacity profile".

Reading stages.csv — the story of the ramp

<prefix>_stages.csv has one row per stage and shows the service degrading as load climbs. Here's an illustrative example (SLO: p99 ≤ 60 000 ms, errors ≤ 1%):

stageusersrpsmean_msp99_mserror_rateconcurrency
154.81 0402 1000.0005.0
2109.11 0903 4000.0009.9
32017.01 1709 8000.00419.9
44022.01 80041 0000.00839.6 ← knee
58019.04 20078 0000.140

How to read it:

  • Top to bottom = more load. Each stage adds users.
  • Watch two columns: p99_ms and error_rate. As long as both stay under the SLO (here 60 000 ms and 0.01), the service is coping.
  • The knee is the last "green" row — stage 4 above. Its concurrency (≈ 40) is the headline number.
  • The row after the knee shows the cliff: at stage 5, p99 latency blew past the 60 s cap and the error rate jumped to 14%. Notice RPS actually dropped (19 vs 22) even though we added users — a classic sign the service is saturated and thrashing, not going faster.

A healthy run shows latency rising gently and errors near zero until a clear knee, then a sharp cliff. If the very first row already breaks the SLO, you get no knee (see above).

Why not just trust Locust's own totals? During a ramp, an overall average would blend the easy early stages with the saturated late ones and hide the knee. That's why HelmsDeep reports per stage. (It's also why the HTML report's blended totals aren't the official number — see below.)

by_qtype.csv — which query is the bottleneck

<prefix>_by_qtype.csv breaks each stage down by type of query (e.g. simple single-hop lookups vs. heavier multi-hop or "what treats this disease?" queries). Use it to answer "what's slowing things down?" — often one query shape saturates long before the others, and that's where to focus.

ars_health.csv and red_flags (ARS runs only)

The ARS is what real users actually hit, and it can fail silently — a query can come back "successful" but with fewer answers than it should, or zero. <prefix>_ars_health.csv and the red_flags list in summary.json exist to catch exactly this. In plain terms they flag things like:

  • Answers quietly disappearing under load — the average number of results per query drops sharply as concurrency rises (something downstream is dropping out).
  • "Done" but empty — queries that finished but returned zero answers (counted as failures).
  • Wild result-count swings — identical queries returning very different amounts, a sign of instability.

If red_flags is non-empty, the service may be degrading in a way the raw latency/error numbers alone wouldn't reveal — read those flags.

The HTML report (<prefix>_report.html)

This is Locust's built-in report. Just double-click it to open in a browser. It gives you nice visuals: response-time and request-rate charts over the whole run, a table of every request type, and a list of any failures. It's great for a quick visual feel and for sharing a screenshot.

Important caveat: the HTML report's headline totals are blended across the entire run — it averages the gentle early stages together with the overwhelmed late ones. That makes its single "average response time" / "total RPS" numbers not the right ones to quote. For the official ceiling, always read the knee in summary.json (and the per-stage story in stages.csv). Think of the HTML as the pictures, and summary.json/stages.csv as the verdict.

Tuning notes

  • Swap the CURIEs. The corpus in helmsdeep/trapi_corpus.py uses a few real MONDO/CHEBI entities; replace them with entities your target service actually knows about, or queries return empty and won't reflect real cost.

  • Tier is per query (Retriever only). Retriever exposes parameters.tier (0 or 1) to pick its backend graph. RETRIEVER_CORPUS pairs multi-hop shapes with tier 0 and single-hop shapes with tier 1. ARA queries carry no tier.

  • Shepherd/ARS send inferred + bypass_cache, mixing MVP1 and MVP2.SHEPHERD_CORPUS (also used for ars) holds creative-mode queries (knowledge_type: "inferred", bypass_cache: true) split evenly between two Translator templates, with entities varied per request to spread load and avoid cache-warming. by_qtype.csv breaks out latency per template/tier.

    • MVP1 — "what treats disease X?" (chemical -[treats]-> disease): the pinned disease is sampled from size-tiered pools (heavy/medium/light), so cost tracks answer-set size. Heavy is a curated list of common disease hubs; the long-tail pool is ~1000 real MONDO CURIEs in curie_list.json.
    • MVP2 — chemical⇄gene "affects" (biolink:affects, inferred, with object_aspect/object_direction qualifiers): both edge directions (chemical→gene and gene→chemical), with the gene (curated GENES pool) and qualifier combo varied per request.

    These are far heavier than KP lookups, so the aras target ships a gentler ramp and a looser p99_slo_ms (see config.py). Tune the per-template weights and entity pools (HEAVY_DISEASES, GENES, the tiers) to your real traffic.

  • ARS reuses the Shepherd corpus (ARS_CORPUS = SHEPHERD_CORPUS) — the same inferred query the ARS fans out to its ARAs. Its poll cadence and per-query timeout (poll_interval_s, max_poll_s) are tunable in config.py.

  • Pathfinder is its own run type (aras_pathfinder / ars_pathfinder, ARA/ARS only). PATHFINDER_CORPUS sends a single drug↔disease shape that pins two endpoints and asks for connecting paths (a paths map in the query_graph, not edges); the (chemical, disease) pair varies per request from a curated CHEM_DISEASE_PAIRS list — swap these for pairs your service knows, and keep them plausibly connected so paths come back non-empty (on ARS a zero-result Done is a failure by default). It's the heaviest query class, so its targets ship the gentlest ramps and loosest SLOs (and, for ars_pathfinder, the longest max_poll_s); tune them in config.py.

  • The mixed profile blends the two ARA/ARS classes (aras_mixed / ars_mixed). MIXED_CORPUS is computed from SHEPHERD_CORPUS + PATHFINDER_CORPUS at the INFERRED_PATHFINDER_RATIO (2:1), so it tracks any retuning of the inferred mix. Change the blend by editing that ratio; change what counts as a pass by editing the per-target checkpoints in config.py. Because 1/3 of the mix is Pathfinder — the heaviest class — and p99 is a tail statistic, both mixed targets inherit the looser Pathfinder SLO. aras_mixed also raises request_timeout_s above the default 210 s: a sync target whose p99 SLO sits near the HTTP timeout records slow queries as client timeouts (errors) instead of latencies.

  • Adjust corpus weights in RETRIEVER_CORPUS / SHEPHERD_CORPUS to match your traffic mix.

Running the engine directly

You can also invoke the locustfile without the CLI (defaults to the kps target):

LOCUST_CSV_PREFIX=run1 \
locust -f helmsdeep/trapi_loadtest.py --headless \
--host https://your-retriever-service.example.org \
--html run1_report.html

The output-file prefix comes from the LOCUST_CSV_PREFIX env var (locust has no --csv-prefix flag). Set LOADTEST_TARGET to choose a different (implemented) layer. Add --html <name>.html yourself to get the HTML report (the helmsdeep CLI passes this for you automatically, as <prefix>_report.html).

About

Load Tester of Translator Services

Resources

Stars

0 stars

Watchers

4 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Latest commit

History

45 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

HelmsDeep

HelmsDeepHTTP Endpoint Load Measurement System, Determining Each Endpoint's Performance — drives a stepped ramp of concurrent users against a single NCATS Translator component and reports the max sustainable concurrency (the "knee") — the highest load where the service still meets a latency/error SLO.

The Translator stack cascades ARS → ARAs → KPs, so a run targets exactly one layer at a time (testing a higher layer already loads everything beneath it). All three workflows are wired up: Retriever (KP), Shepherd (ARA), and the asynchronous ARS.

In plain language: HelmsDeep slowly turns up the number of simultaneous users hitting a service and watches when it starts getting too slow or too error-prone. The single headline number it reports — the knee, or "max sustainable concurrency" — is the largest number of simultaneous users the service handled while still staying fast enough and erroring rarely enough. Above that load, the service is overwhelmed. If you only read one thing, read the "How to read the results" section.

Install

pip install -e .# Python >= 3.12; installs locust

Run a workflow

# Retriever (KP) — sync lookup queries, scalar parameters.tier per query
helmsdeep --targets kps \
--host https://your-retriever-service.example.org \
--csv-prefix run1
# Shepherd (ARA) — sync creative-mode (inferred) queries, cache bypassed
helmsdeep --targets aras \
--host https://your-ara-service.example.org \
--csv-prefix run1
# ARS — async submit/poll/merge of inferred queries (host = the ARS API base)
helmsdeep --targets ars \
--host https://ars.ci.transltr.io/ars/api \
--csv-prefix run1
# Pathfinder — its own heavier run type (ARA/ARS only); pins two endpoints and# asks for connecting paths. Sync via the ARA, async via the ARS.
helmsdeep --targets aras_pathfinder \
--host https://your-ara-service.example.org \
--csv-prefix pf1
helmsdeep --targets ars_pathfinder \
--host https://ars.ci.transltr.io/ars/api \
--csv-prefix pf1
# Mixed capacity profile (ARA/ARS only) — 2/3 inferred MVP1+MVP2, 1/3 Pathfinder# in one blended workload, ramped to 30 → 45 → 60 concurrent users and judged# against pass/fail acceptance checkpoints.
helmsdeep --targets aras_mixed \
--host https://your-ara-service.example.org \
--csv-prefix mix1
helmsdeep --targets ars_mixed \
--host https://ars.ci.transltr.io/ars/api \
--csv-prefix mix1
  • --targets selects the layer: kps (Retriever), aras (Shepherd), ars, the Pathfinder run types aras_pathfinder / ars_pathfinder (ARA/ARS only), or the mixed capacity profile aras_mixed / ars_mixed (ARA/ARS only — see "Mixed capacity profile").
  • --host is required — the base URL of the target service. For kps/aras the /query path is appended; for ars the host is the API base and the tool uses /submit then /messages/{pk}.
  • --csv-prefix is optional; it falls back to the LOCUST_CSV_PREFIX env var, then to trapi_run.
  • --time-budget / --quick compress a run to fit a wall clock — see "Shorter runs" below.

The load profile (users, spawn rate, duration) is driven by the StepLoad shape, not by CLI flags — so there is intentionally no -u/-r/-t. The ramp and knee threshold are per target in helmsdeep/config.py (stages and p99_slo_ms), since cost profiles differ wildly by layer; edit them there.

An optional per-target cooldown_s inserts a quiet gap between stages: users ramp to 0 so slow in-flight queries drain (counted under the stage that launched them) before the next stage starts clean, instead of bleeding into it. It defaults to 0 (cheap KP lookups don't bleed) and is set on the expensive ARA/ARS/Pathfinder targets.

Shorter runs (--time-budget / --quick)

A full run is long on purpose: slow queries need long holds to accumulate enough samples for a trustworthy percentile (ars_mixed takes ~70 minutes). When you don't need that — checking a host, corpus, or config change end to end, or taking a rough read — compress it:

# Smoke run: the whole ramp in about 10 minutes.
helmsdeep --targets ars_mixed --host https://ars.ci.transltr.io/ars/api --quick
# Or name your own budget: 30m, 1h, 90s, or plain seconds.
helmsdeep --targets aras_mixed --host https://your-ara.example.org --time-budget 30m

--quick is shorthand for --time-budget 10m. Both scale the durations — stage holds, cooldowns, and the poll/timeout caps that gate how long one query may run — by a single factor. The shape is untouched: same user counts, same stages, same SLOs, same checkpoints. So --targets ars_mixed --time-budget 30m still ramps 10 → 30 → 45 → 60 users and still judges all three checkpoints; each stage just gets ~6 minutes instead of ~15.

The CLI prints the compressed plan before it starts, so you know what you're committing to:

Compressed run: 1h10m -> 30m02s (time scale 0.43), budget 30m00s
Stages: 10u x 4m17s -> 30u x 6m26s -> 45u x 6m26s -> 60u x 6m26s, 2m09s cooldown between
Per-query cap cut to 4m17s -- a query slower than that is recorded as a
timeout failure, so error rates are not comparable to a full run's.

What you give up. Two things, and they matter:

  1. Sample count, and with it the tail. A p99 over a handful of queries is noise. The mean and p50 hold up far better than p95/p99.
  2. What counts as a failure. The per-query caps shrink with everything else, so a query that would have finished in 4 minutes is a timeout when the cap is 2. Error rates — and therefore checkpoint verdicts — are not comparable to a full run's.

Every output is stamped with config.time_scale (1.0 = a full run), and the printed summary leads with a warning banner. Don't quote a compressed run's knee as a capacity result, and don't gate CI on its checkpoints.

Compression only ever speeds a run up: a budget above the target's natural duration is ignored (kps already runs in 7 minutes, so --quick leaves it alone). Stages won't shrink below a 30-second floor, so a budget too small to fit them simply overruns — the CLI says so and prints the real projected duration.

Want each stage to get a specific amount of time? The budget is for the whole run, including cooldowns, so divide accordingly — or edit the per-target stages in config.py directly, which is the only way to change the shape (dropping the baseline stage from a mixed run, say, to give the three checkpoints 10 minutes each).

Outputs

Written to the working directory by the standalone/master node:

  • <prefix>_stages.csv — one row per stage (overall metrics), including a stage_start column with the stage's wall-clock start time (ISO 8601 UTC)
  • <prefix>_by_qtype.csv — one row per (stage, query type)
  • <prefix>_summary.json — config (including which target was measured and the time_scale it ran at), all stages, and the chosen knee (plus checkpoints for targets that define them)
  • <prefix>_checkpoints.csvtargets with acceptance criteria (aras_mixed / ars_mixed): one row per checkpoint with its PASS / FAIL verdict
  • <prefix>_ars_health.csvARS only: per-stage health signals (see below)
  • <prefix>_ars_queries.csvARS only: one row per logical query with its pk, the HTTP status of each step, the terminal ARS status, any intermediate (retried, non-fatal) errors it hit along the way, and a ready-made URL for pulling that query up (see below)
  • <prefix>_ars_completion.csvARS only: one row per logical query with its end-to-end response time and whether it eventually finished — polled past the max_poll_s failure threshold up to completion_max_poll_s (see below)
  • <prefix>_report.html — Locust's native, self-contained HTML report (latency-over-time charts, request/failure tables). Open it in any browser. See the caveat under "How to read the results": its totals are blended across the whole run, so the authoritative ceiling is the knee in summary.json, not the HTML aggregate.
  • a printed summary table ending in the headline max sustainable concurrency

For a guide to interpreting every one of these — written for non-specialists — see "How to read the results" below.

Mixed capacity profile (aras_mixed / ars_mixed)

Every other target characterizes one query class in isolation to find the knee. The mixed profile answers a different, operational question: can the system hold a target concurrency when both query classes arrive together, the way they do in production?

  • Corpus — one blended workload: 2/3 inferred MVP1+MVP2 creative queries and 1/3 Pathfinder. MVP1/MVP2 keep their relative SHEPHERD_CORPUS weights (so retuning that mix carries over); only the 2:1 split is imposed. by_qtype.csv still breaks the two classes out separately, so you can see which one drives the tail.

  • Ramp — a short low-load baseline stage, then the three concurrency levels the profile exists to judge: 30 → 45 → 60 simultaneous users. The baseline exists so a failure at 30 can be read against a healthy reference (is 30 slow, or is everything slow?).

  • Verdict — each level carries pass/fail acceptance checkpoints instead of only feeding the knee:

    UsersGoalCriteria
    30sustain peak loadp99 ≤ 300 000 ms and errors ≤ 1%
    45headroom above peakp99 ≤ 300 000 ms and errors ≤ 1%
    60no substantial failures under overloaderrors ≤ 5% (latency not judged — slowdown is expected here, failures are not)

The verdicts land in <prefix>_checkpoints.csv, in summary.json (checkpoints plus a single checkpoints_passed boolean), and in the printed report:

ACCEPTANCE CHECKPOINTS
usr p99 bar err% bar verdict goal
30 184203 300000 0.4 1.0 PASS sustain peak load
45 291560 300000 0.9 1.0 PASS headroom above peak
60 402118 n/a 3.1 5.0 PASS no substantial failures under overload
RESULT: all 3 checkpoints met.

A missed checkpoint prints why (-> p99 331402ms > 300000ms) and makes the run exit non-zero, so it can gate a CI/acceptance job. A checkpoint whose stage recorded no completed requests is reported as NO DATA rather than a silent pass. The knee is still computed and reported alongside, unchanged.

Checkpoints are a generic per-target feature (checkpoints in config.py), not special-cased to this profile: any target can define them, and targets that don't behave exactly as before. Edit the users, goals, and bars there — the ARA and ARS variants carry the same three checkpoints so the two layers are directly comparable.

Layering still applies:ars_mixed fans out to the ARAs, so run one or the other — never both at once.

ARS async workflow and health signals

The ars target is asynchronous: each logical query is POST /submit → poll GET /messages/{pk}?trace=y (every poll_interval_s, capped at max_poll_s, default 15 min) until status is Done/Error → fetch GET /messages/{merged_pk} and count fields.data.message.results. Latency is the wall-clock submit→terminal time; one measurement is recorded per logical query. A single ars_query Locust request event carries that full wall-clock, so Locust's native stats table shows the true per-query time — the individual ars_submit/ars_poll/ars_merge calls also appear there as per-step diagnostics (e.g. ars_merge times only the final merge fetch, not the whole query), but aren't double-counted as the query time.

Because the ARS is what real users hit, the run also captures health signals to flag silent downstream breakage, written to <prefix>_ars_health.csv and summary.json (ars_health + a human-readable red_flags list):

  • result-count variation (min/mean/max + coefficient of variation) across identical queries;
  • zero-result Done count — by default a Done with 0 results is treated as a failure (counts against the error rate and the knee) and flagged; the per-target zero_result_is_failure flag can score it as a success instead (see "Is an empty answer a failure?");
  • response size (merged-message bytes, mean/max);
  • result drop under load — flagged when the mean result count falls sharply as concurrency rises across stages.

Is an empty answer a failure?

A terminal Done that carries 0 results is scored as a failure by default: under load an empty answer set usually means a downstream agent silently dropped out, which is exactly what the knee should catch. But that conflates a silent break with a query whose answer set is legitimately empty, so it is a per-target switch in config.py:

"zero_result_is_failure": True, # the default; set False to score only# transport/protocol outcomes as failures

With False, only submit errors, an Error status, and Timeout count as failures. Note this shifts more than the error rate: a failed request contributes no latency sample, so a zero-result query that stops being a failure starts feeding the percentile pool — mean, p99, and therefore the Little's-Law concurrency all move too. The two policies give two different knees, which is the point: run both to see how much of your ceiling is empty answers versus broken transport.

Whichever policy is in force, the zero-result query is still counted in ars_health.csv (zero_result_done), still raises a red flag, and still carries the Done with 0 results note in the per-query debug log. The policy itself is recorded in summary.json (config.zero_result_is_failure) and printed above the ARS health table, so a run's numbers can't be read under the wrong assumption:

ARS HEALTH (per stage)
0-result 'Done' scored as: FAILURE (zero_result_is_failure=True)

Per-query debug log (ars_queries.csv)

The aggregate reports tell you that 3% of queries failed at 45 users. This file tells you which ones, and hands you what you need to go look at them: one row per logical ARS query, written for every query — successful, failed, or timed out.

ColumnWhat it's for
queryQuery id. Joins to ars_completion.csv on the same column.
pkThe ARS primary key. Empty only when the submit never returned one.
stage, usersWhich stage the query is attributed to (the stage active when it reached its outcome — the same rule the per-stage stats use).
qtypeWhich corpus query it was (mvp1_heavy, pathfinder_drug_disease, …).
submit_startWhen it was submitted (ISO 8601 UTC) — line it up against service logs.
latency_sWall clock from submit to terminal status.
ars_statusDone / Error / Timeout / SubmitError / NoPK.
failedWhether it counted against the error rate. A Done with 0 results is failed=True under the default zero_result_is_failure policy — see below.
submit_http, poll_http, merge_httpHTTP status of the submit, the last poll, and the merged fetch. Separates "the ARS said no" from "the HTTP call broke".
pollsHow many status polls it took — a large number on a slow query says it was genuinely working, not stuck.
intermediate_error_countHow many non-fatal errors the query hit on the way to its outcome (0 = it ran clean). The poll loop retries through these, so they never decide ars_status — but they say a query only limped to Done.
result_count, response_bytesSize of the merged answer.
intermediate_errorsThose same errors in words, tallied: poll HTTP 502 x3; merge HTTP 500. Covers polls that came back non-200 (poll request failed when there was no HTTP response at all), poll bodies that wouldn't parse, and a merged fetch that 500'd, went missing (Done without merged_version) or wouldn't parse. Empty on a clean query.
errorWhy it failed, in words — the terminal reason, empty on success (as opposed to intermediate_errors, which is the trouble on the way there). Also carries Done with 0 results when a target scores that as a success — the note is what makes the pk worth opening either way.
message_urlFull URL to that query's message. Paste it into a browser or curl it.

The printed summary also points straight at the failures, so you don't have to open the file to start:

FAILED QUERIES (12 of 380; first 5 shown, all in run1_ars_queries.csv)
stage 2 pathfinder_drug_disease [Timeout] pk=8f3c… -- no terminal status within 600s (last: Running)
https://ars.ci.transltr.io/ars/api/messages/8f3c…?trace=y

Intermediate errors get their own line, because most of the queries carrying them succeeded — they never appear in the failed block, and a run that only limped to green otherwise reads as clean:

INTERMEDIATE ERRORS: 34 of 380 queries hit retried, non-fatal errors (intermediate_errors column in run1_ars_queries.csv)
stage 4 mvp1_heavy [Done] pk=a01f… -- poll HTTP 502 x3

Useful slices, once you have the file:

# Every failed query, as a table
awk -F, 'NR==1 || $9=="True"' run1_ars_queries.csv | column -s, -t
# The pks of everything that timed out at the top stage
awk -F, '$8=="Timeout" && $4==60 {print $2}' run1_ars_queries.csv
# Slowest queries, with their URLs
sort -t, -k7 -gr run1_ars_queries.csv | head -5 | cut -d, -f2,7,8,19
# Queries that succeeded but hit non-fatal errors on the way
awk -F, 'NR==1 || ($14+0 > 0 && $9=="False")' run1_ars_queries.csv | column -s, -t

On the relationship to ars_completion.csv: this file records what happened within the measurement window (a query not terminal by max_poll_s is a Timeout row here, which is exactly how the knee scores it). The completion sidecar separately answers whether that same query eventually finished. Join them on query. A timed-out query whose extended poll was still running at shutdown appears here but not there — which is precisely the query you most want the pk of.

ARS completion tracking (ars_completion.csv)

max_poll_s (default 6 min for ars) is the failure threshold: a query that hasn't reached a terminal status by then is recorded as a Timeout failure in the main stats and the knee — unchanged. But a timed-out query isn't necessarily stuck; the ARS may still finish it, just slowly. To capture that, once a query blows max_poll_s a background poller keeps watching it up to completion_max_poll_s (default 10 min) and records the outcome to <prefix>_ars_completion.csv — one row per logical query:

  • total_response_s — true end-to-end submit→terminal time (up to completion_max_poll_s);
  • finished — whether it ever reached a terminal status (Done or Error);
  • within_slo — whether it finished inside max_poll_s (i.e. was not a Timeout failure in the main stats); a slow-but-eventually-done query is finished=True, within_slo=False;
  • status — the terminal status (Done/Error/Timeout/SubmitError/NoPK).

summary.json carries a completion roll-up (finished, finished-within-SLO, finished-after-SLO, never-finished, submit-failed), and two red flags surface the key distinctions: queries that exceeded the SLO but did finish (slow, not stuck) and queries that never finished even within completion_max_poll_s. This separates slow from broken — a service that eventually answers every query is degraded differently than one that drops them. Set completion_max_poll_s equal to (or omit it, defaulting to) max_poll_s to turn extended tracking off; the sidecar then just marks each timed-out query finished=False. Any query still being polled when the test ends is drained for a bounded window at shutdown; ones still unfinished after that are absent from the file.

How to read the results

This section is for anyone — technical or not — who has a run's output files in hand and wants to answer one question: how much load can this service take?

Plain-language key

A few terms show up everywhere in the outputs. Here's what each one means, without the jargon:

TermWhat it means
StageOne step of the ramp. Each stage pins a fixed number of simultaneous users for a fixed time, then the next stage adds more. The run climbs stage by stage until the service struggles.
Users / concurrencyHow many simultaneous users are hammering the service. "Users" is what we asked for in a stage; concurrency is the effective number actually in flight, computed from the measured throughput and speed.
RPSRequests Per Second — how many queries the service actually completed each second during that stage. Higher is faster.
LatencyHow long one query took, in milliseconds (1000 ms = 1 second).
mean / p50 / p95 / p99Different ways to summarize latency. mean is the average. p50 (median) is the typical query. p95 / p99 are the slow tail: "95% (or 99%) of queries were at least this fast." p99 is the one we hold to a standard, because it captures the bad experiences, not just the average.
Error rateThe fraction of queries that failed (e.g. 0.02 = 2%). For the ARS, a query that finishes but returns zero answers also counts as a failure by default — a per-target flag can change that, and the run says which policy applied.
SLOService Level Objective — the line in the sand for "acceptable." Here it's a p99 latency cap (e.g. 60000 ms = 60 s) plus a max error rate (default 1%). A stage "passes" only if it stays under both.
CheckpointA pass/fail question asked of one specific load level ("does 30 simultaneous users still work?"), with its own latency/error bars. Only the mixed capacity profile defines these; other runs just report the knee.
KneeThe highest-load stage that still passes the SLO. It's the headline result: the most simultaneous users the service handled while staying fast enough and reliable enough. Past the knee, things fall apart.

Start here: summary.json

Open <prefix>_summary.json first. Two fields tell you almost everything:

  • max_sustainable_concurrency — the headline number. This is the answer to "how much can this service take?"
  • knee — the stage that number came from (its users, latency, error rate, etc.). This is the last healthy stage of the ramp.

If knee is null (and max_sustainable_concurrency is empty), the service failed even at the lightest load — it was already too slow or too error-prone in the very first stage. That's a strong signal something is wrong (or the SLO is set tighter than the service can ever meet).

The same file also echoes the config that produced the run (which target, which endpoint, the SLO, the ramp stages) so a result is self-documenting.

On a run with acceptance criteria (aras_mixed / ars_mixed), two more fields answer the pass/fail question directly: checkpoints_passed (one boolean for the whole run) and checkpoints (per-level verdicts and why). See "Mixed capacity profile".

Reading stages.csv — the story of the ramp

<prefix>_stages.csv has one row per stage and shows the service degrading as load climbs. Here's an illustrative example (SLO: p99 ≤ 60 000 ms, errors ≤ 1%):

stageusersrpsmean_msp99_mserror_rateconcurrency
154.81 0402 1000.0005.0
2109.11 0903 4000.0009.9
32017.01 1709 8000.00419.9
44022.01 80041 0000.00839.6 ← knee
58019.04 20078 0000.140

How to read it:

  • Top to bottom = more load. Each stage adds users.
  • Watch two columns: p99_ms and error_rate. As long as both stay under the SLO (here 60 000 ms and 0.01), the service is coping.
  • The knee is the last "green" row — stage 4 above. Its concurrency (≈ 40) is the headline number.
  • The row after the knee shows the cliff: at stage 5, p99 latency blew past the 60 s cap and the error rate jumped to 14%. Notice RPS actually dropped (19 vs 22) even though we added users — a classic sign the service is saturated and thrashing, not going faster.

A healthy run shows latency rising gently and errors near zero until a clear knee, then a sharp cliff. If the very first row already breaks the SLO, you get no knee (see above).

Why not just trust Locust's own totals? During a ramp, an overall average would blend the easy early stages with the saturated late ones and hide the knee. That's why HelmsDeep reports per stage. (It's also why the HTML report's blended totals aren't the official number — see below.)

by_qtype.csv — which query is the bottleneck

<prefix>_by_qtype.csv breaks each stage down by type of query (e.g. simple single-hop lookups vs. heavier multi-hop or "what treats this disease?" queries). Use it to answer "what's slowing things down?" — often one query shape saturates long before the others, and that's where to focus.

ars_health.csv and red_flags (ARS runs only)

The ARS is what real users actually hit, and it can fail silently — a query can come back "successful" but with fewer answers than it should, or zero. <prefix>_ars_health.csv and the red_flags list in summary.json exist to catch exactly this. In plain terms they flag things like:

  • Answers quietly disappearing under load — the average number of results per query drops sharply as concurrency rises (something downstream is dropping out).
  • "Done" but empty — queries that finished but returned zero answers (counted as failures).
  • Wild result-count swings — identical queries returning very different amounts, a sign of instability.

If red_flags is non-empty, the service may be degrading in a way the raw latency/error numbers alone wouldn't reveal — read those flags.

The HTML report (<prefix>_report.html)

This is Locust's built-in report. Just double-click it to open in a browser. It gives you nice visuals: response-time and request-rate charts over the whole run, a table of every request type, and a list of any failures. It's great for a quick visual feel and for sharing a screenshot.

Important caveat: the HTML report's headline totals are blended across the entire run — it averages the gentle early stages together with the overwhelmed late ones. That makes its single "average response time" / "total RPS" numbers not the right ones to quote. For the official ceiling, always read the knee in summary.json (and the per-stage story in stages.csv). Think of the HTML as the pictures, and summary.json/stages.csv as the verdict.

Tuning notes

  • Swap the CURIEs. The corpus in helmsdeep/trapi_corpus.py uses a few real MONDO/CHEBI entities; replace them with entities your target service actually knows about, or queries return empty and won't reflect real cost.

  • Tier is per query (Retriever only). Retriever exposes parameters.tier (0 or 1) to pick its backend graph. RETRIEVER_CORPUS pairs multi-hop shapes with tier 0 and single-hop shapes with tier 1. ARA queries carry no tier.

  • Shepherd/ARS send inferred + bypass_cache, mixing MVP1 and MVP2.SHEPHERD_CORPUS (also used for ars) holds creative-mode queries (knowledge_type: "inferred", bypass_cache: true) split evenly between two Translator templates, with entities varied per request to spread load and avoid cache-warming. by_qtype.csv breaks out latency per template/tier.

    • MVP1 — "what treats disease X?" (chemical -[treats]-> disease): the pinned disease is sampled from size-tiered pools (heavy/medium/light), so cost tracks answer-set size. Heavy is a curated list of common disease hubs; the long-tail pool is ~1000 real MONDO CURIEs in curie_list.json.
    • MVP2 — chemical⇄gene "affects" (biolink:affects, inferred, with object_aspect/object_direction qualifiers): both edge directions (chemical→gene and gene→chemical), with the gene (curated GENES pool) and qualifier combo varied per request.

    These are far heavier than KP lookups, so the aras target ships a gentler ramp and a looser p99_slo_ms (see config.py). Tune the per-template weights and entity pools (HEAVY_DISEASES, GENES, the tiers) to your real traffic.

  • ARS reuses the Shepherd corpus (ARS_CORPUS = SHEPHERD_CORPUS) — the same inferred query the ARS fans out to its ARAs. Its poll cadence and per-query timeout (poll_interval_s, max_poll_s) are tunable in config.py.

  • Pathfinder is its own run type (aras_pathfinder / ars_pathfinder, ARA/ARS only). PATHFINDER_CORPUS sends a single drug↔disease shape that pins two endpoints and asks for connecting paths (a paths map in the query_graph, not edges); the (chemical, disease) pair varies per request from a curated CHEM_DISEASE_PAIRS list — swap these for pairs your service knows, and keep them plausibly connected so paths come back non-empty (on ARS a zero-result Done is a failure by default). It's the heaviest query class, so its targets ship the gentlest ramps and loosest SLOs (and, for ars_pathfinder, the longest max_poll_s); tune them in config.py.

  • The mixed profile blends the two ARA/ARS classes (aras_mixed / ars_mixed). MIXED_CORPUS is computed from SHEPHERD_CORPUS + PATHFINDER_CORPUS at the INFERRED_PATHFINDER_RATIO (2:1), so it tracks any retuning of the inferred mix. Change the blend by editing that ratio; change what counts as a pass by editing the per-target checkpoints in config.py. Because 1/3 of the mix is Pathfinder — the heaviest class — and p99 is a tail statistic, both mixed targets inherit the looser Pathfinder SLO. aras_mixed also raises request_timeout_s above the default 210 s: a sync target whose p99 SLO sits near the HTTP timeout records slow queries as client timeouts (errors) instead of latencies.

  • Adjust corpus weights in RETRIEVER_CORPUS / SHEPHERD_CORPUS to match your traffic mix.

Running the engine directly

You can also invoke the locustfile without the CLI (defaults to the kps target):

LOCUST_CSV_PREFIX=run1 \
locust -f helmsdeep/trapi_loadtest.py --headless \
--host https://your-retriever-service.example.org \
--html run1_report.html

The output-file prefix comes from the LOCUST_CSV_PREFIX env var (locust has no --csv-prefix flag). Set LOADTEST_TARGET to choose a different (implemented) layer. Add --html <name>.html yourself to get the HTML report (the helmsdeep CLI passes this for you automatically, as <prefix>_report.html).

About

Load Tester of Translator Services

Resources

Stars

0 stars

Watchers

4 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

45 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

HelmsDeep

HelmsDeepHTTP Endpoint Load Measurement System, Determining Each Endpoint's Performance — drives a stepped ramp of concurrent users against a single NCATS Translator component and reports the max sustainable concurrency (the "knee") — the highest load where the service still meets a latency/error SLO.

The Translator stack cascades ARS → ARAs → KPs, so a run targets exactly one layer at a time (testing a higher layer already loads everything beneath it). All three workflows are wired up: Retriever (KP), Shepherd (ARA), and the asynchronous ARS.

In plain language: HelmsDeep slowly turns up the number of simultaneous users hitting a service and watches when it starts getting too slow or too error-prone. The single headline number it reports — the knee, or "max sustainable concurrency" — is the largest number of simultaneous users the service handled while still staying fast enough and erroring rarely enough. Above that load, the service is overwhelmed. If you only read one thing, read the "How to read the results" section.

Install

pip install -e .# Python >= 3.12; installs locust

Run a workflow

# Retriever (KP) — sync lookup queries, scalar parameters.tier per query
helmsdeep --targets kps \
--host https://your-retriever-service.example.org \
--csv-prefix run1
# Shepherd (ARA) — sync creative-mode (inferred) queries, cache bypassed
helmsdeep --targets aras \
--host https://your-ara-service.example.org \
--csv-prefix run1
# ARS — async submit/poll/merge of inferred queries (host = the ARS API base)
helmsdeep --targets ars \
--host https://ars.ci.transltr.io/ars/api \
--csv-prefix run1
# Pathfinder — its own heavier run type (ARA/ARS only); pins two endpoints and# asks for connecting paths. Sync via the ARA, async via the ARS.
helmsdeep --targets aras_pathfinder \
--host https://your-ara-service.example.org \
--csv-prefix pf1
helmsdeep --targets ars_pathfinder \
--host https://ars.ci.transltr.io/ars/api \
--csv-prefix pf1
# Mixed capacity profile (ARA/ARS only) — 2/3 inferred MVP1+MVP2, 1/3 Pathfinder# in one blended workload, ramped to 30 → 45 → 60 concurrent users and judged# against pass/fail acceptance checkpoints.
helmsdeep --targets aras_mixed \
--host https://your-ara-service.example.org \
--csv-prefix mix1
helmsdeep --targets ars_mixed \
--host https://ars.ci.transltr.io/ars/api \
--csv-prefix mix1
  • --targets selects the layer: kps (Retriever), aras (Shepherd), ars, the Pathfinder run types aras_pathfinder / ars_pathfinder (ARA/ARS only), or the mixed capacity profile aras_mixed / ars_mixed (ARA/ARS only — see "Mixed capacity profile").
  • --host is required — the base URL of the target service. For kps/aras the /query path is appended; for ars the host is the API base and the tool uses /submit then /messages/{pk}.
  • --csv-prefix is optional; it falls back to the LOCUST_CSV_PREFIX env var, then to trapi_run.
  • --time-budget / --quick compress a run to fit a wall clock — see "Shorter runs" below.

The load profile (users, spawn rate, duration) is driven by the StepLoad shape, not by CLI flags — so there is intentionally no -u/-r/-t. The ramp and knee threshold are per target in helmsdeep/config.py (stages and p99_slo_ms), since cost profiles differ wildly by layer; edit them there.

An optional per-target cooldown_s inserts a quiet gap between stages: users ramp to 0 so slow in-flight queries drain (counted under the stage that launched them) before the next stage starts clean, instead of bleeding into it. It defaults to 0 (cheap KP lookups don't bleed) and is set on the expensive ARA/ARS/Pathfinder targets.

Shorter runs (--time-budget / --quick)

A full run is long on purpose: slow queries need long holds to accumulate enough samples for a trustworthy percentile (ars_mixed takes ~70 minutes). When you don't need that — checking a host, corpus, or config change end to end, or taking a rough read — compress it:

# Smoke run: the whole ramp in about 10 minutes.
helmsdeep --targets ars_mixed --host https://ars.ci.transltr.io/ars/api --quick
# Or name your own budget: 30m, 1h, 90s, or plain seconds.
helmsdeep --targets aras_mixed --host https://your-ara.example.org --time-budget 30m

--quick is shorthand for --time-budget 10m. Both scale the durations — stage holds, cooldowns, and the poll/timeout caps that gate how long one query may run — by a single factor. The shape is untouched: same user counts, same stages, same SLOs, same checkpoints. So --targets ars_mixed --time-budget 30m still ramps 10 → 30 → 45 → 60 users and still judges all three checkpoints; each stage just gets ~6 minutes instead of ~15.

The CLI prints the compressed plan before it starts, so you know what you're committing to:

Compressed run: 1h10m -> 30m02s (time scale 0.43), budget 30m00s
Stages: 10u x 4m17s -> 30u x 6m26s -> 45u x 6m26s -> 60u x 6m26s, 2m09s cooldown between
Per-query cap cut to 4m17s -- a query slower than that is recorded as a
timeout failure, so error rates are not comparable to a full run's.

What you give up. Two things, and they matter:

  1. Sample count, and with it the tail. A p99 over a handful of queries is noise. The mean and p50 hold up far better than p95/p99.
  2. What counts as a failure. The per-query caps shrink with everything else, so a query that would have finished in 4 minutes is a timeout when the cap is 2. Error rates — and therefore checkpoint verdicts — are not comparable to a full run's.

Every output is stamped with config.time_scale (1.0 = a full run), and the printed summary leads with a warning banner. Don't quote a compressed run's knee as a capacity result, and don't gate CI on its checkpoints.

Compression only ever speeds a run up: a budget above the target's natural duration is ignored (kps already runs in 7 minutes, so --quick leaves it alone). Stages won't shrink below a 30-second floor, so a budget too small to fit them simply overruns — the CLI says so and prints the real projected duration.

Want each stage to get a specific amount of time? The budget is for the whole run, including cooldowns, so divide accordingly — or edit the per-target stages in config.py directly, which is the only way to change the shape (dropping the baseline stage from a mixed run, say, to give the three checkpoints 10 minutes each).

Outputs

Written to the working directory by the standalone/master node:

  • <prefix>_stages.csv — one row per stage (overall metrics), including a stage_start column with the stage's wall-clock start time (ISO 8601 UTC)
  • <prefix>_by_qtype.csv — one row per (stage, query type)
  • <prefix>_summary.json — config (including which target was measured and the time_scale it ran at), all stages, and the chosen knee (plus checkpoints for targets that define them)
  • <prefix>_checkpoints.csvtargets with acceptance criteria (aras_mixed / ars_mixed): one row per checkpoint with its PASS / FAIL verdict
  • <prefix>_ars_health.csvARS only: per-stage health signals (see below)
  • <prefix>_ars_queries.csvARS only: one row per logical query with its pk, the HTTP status of each step, the terminal ARS status, any intermediate (retried, non-fatal) errors it hit along the way, and a ready-made URL for pulling that query up (see below)
  • <prefix>_ars_completion.csvARS only: one row per logical query with its end-to-end response time and whether it eventually finished — polled past the max_poll_s failure threshold up to completion_max_poll_s (see below)
  • <prefix>_report.html — Locust's native, self-contained HTML report (latency-over-time charts, request/failure tables). Open it in any browser. See the caveat under "How to read the results": its totals are blended across the whole run, so the authoritative ceiling is the knee in summary.json, not the HTML aggregate.
  • a printed summary table ending in the headline max sustainable concurrency

For a guide to interpreting every one of these — written for non-specialists — see "How to read the results" below.

Mixed capacity profile (aras_mixed / ars_mixed)

Every other target characterizes one query class in isolation to find the knee. The mixed profile answers a different, operational question: can the system hold a target concurrency when both query classes arrive together, the way they do in production?

  • Corpus — one blended workload: 2/3 inferred MVP1+MVP2 creative queries and 1/3 Pathfinder. MVP1/MVP2 keep their relative SHEPHERD_CORPUS weights (so retuning that mix carries over); only the 2:1 split is imposed. by_qtype.csv still breaks the two classes out separately, so you can see which one drives the tail.

  • Ramp — a short low-load baseline stage, then the three concurrency levels the profile exists to judge: 30 → 45 → 60 simultaneous users. The baseline exists so a failure at 30 can be read against a healthy reference (is 30 slow, or is everything slow?).

  • Verdict — each level carries pass/fail acceptance checkpoints instead of only feeding the knee:

    UsersGoalCriteria
    30sustain peak loadp99 ≤ 300 000 ms and errors ≤ 1%
    45headroom above peakp99 ≤ 300 000 ms and errors ≤ 1%
    60no substantial failures under overloaderrors ≤ 5% (latency not judged — slowdown is expected here, failures are not)

The verdicts land in <prefix>_checkpoints.csv, in summary.json (checkpoints plus a single checkpoints_passed boolean), and in the printed report:

ACCEPTANCE CHECKPOINTS
usr p99 bar err% bar verdict goal
30 184203 300000 0.4 1.0 PASS sustain peak load
45 291560 300000 0.9 1.0 PASS headroom above peak
60 402118 n/a 3.1 5.0 PASS no substantial failures under overload
RESULT: all 3 checkpoints met.

A missed checkpoint prints why (-> p99 331402ms > 300000ms) and makes the run exit non-zero, so it can gate a CI/acceptance job. A checkpoint whose stage recorded no completed requests is reported as NO DATA rather than a silent pass. The knee is still computed and reported alongside, unchanged.

Checkpoints are a generic per-target feature (checkpoints in config.py), not special-cased to this profile: any target can define them, and targets that don't behave exactly as before. Edit the users, goals, and bars there — the ARA and ARS variants carry the same three checkpoints so the two layers are directly comparable.

Layering still applies:ars_mixed fans out to the ARAs, so run one or the other — never both at once.

ARS async workflow and health signals

The ars target is asynchronous: each logical query is POST /submit → poll GET /messages/{pk}?trace=y (every poll_interval_s, capped at max_poll_s, default 15 min) until status is Done/Error → fetch GET /messages/{merged_pk} and count fields.data.message.results. Latency is the wall-clock submit→terminal time; one measurement is recorded per logical query. A single ars_query Locust request event carries that full wall-clock, so Locust's native stats table shows the true per-query time — the individual ars_submit/ars_poll/ars_merge calls also appear there as per-step diagnostics (e.g. ars_merge times only the final merge fetch, not the whole query), but aren't double-counted as the query time.

Because the ARS is what real users hit, the run also captures health signals to flag silent downstream breakage, written to <prefix>_ars_health.csv and summary.json (ars_health + a human-readable red_flags list):

  • result-count variation (min/mean/max + coefficient of variation) across identical queries;
  • zero-result Done count — by default a Done with 0 results is treated as a failure (counts against the error rate and the knee) and flagged; the per-target zero_result_is_failure flag can score it as a success instead (see "Is an empty answer a failure?");
  • response size (merged-message bytes, mean/max);
  • result drop under load — flagged when the mean result count falls sharply as concurrency rises across stages.

Is an empty answer a failure?

A terminal Done that carries 0 results is scored as a failure by default: under load an empty answer set usually means a downstream agent silently dropped out, which is exactly what the knee should catch. But that conflates a silent break with a query whose answer set is legitimately empty, so it is a per-target switch in config.py:

"zero_result_is_failure": True, # the default; set False to score only# transport/protocol outcomes as failures

With False, only submit errors, an Error status, and Timeout count as failures. Note this shifts more than the error rate: a failed request contributes no latency sample, so a zero-result query that stops being a failure starts feeding the percentile pool — mean, p99, and therefore the Little's-Law concurrency all move too. The two policies give two different knees, which is the point: run both to see how much of your ceiling is empty answers versus broken transport.

Whichever policy is in force, the zero-result query is still counted in ars_health.csv (zero_result_done), still raises a red flag, and still carries the Done with 0 results note in the per-query debug log. The policy itself is recorded in summary.json (config.zero_result_is_failure) and printed above the ARS health table, so a run's numbers can't be read under the wrong assumption:

ARS HEALTH (per stage)
0-result 'Done' scored as: FAILURE (zero_result_is_failure=True)

Per-query debug log (ars_queries.csv)

The aggregate reports tell you that 3% of queries failed at 45 users. This file tells you which ones, and hands you what you need to go look at them: one row per logical ARS query, written for every query — successful, failed, or timed out.

ColumnWhat it's for
queryQuery id. Joins to ars_completion.csv on the same column.
pkThe ARS primary key. Empty only when the submit never returned one.
stage, usersWhich stage the query is attributed to (the stage active when it reached its outcome — the same rule the per-stage stats use).
qtypeWhich corpus query it was (mvp1_heavy, pathfinder_drug_disease, …).
submit_startWhen it was submitted (ISO 8601 UTC) — line it up against service logs.
latency_sWall clock from submit to terminal status.
ars_statusDone / Error / Timeout / SubmitError / NoPK.
failedWhether it counted against the error rate. A Done with 0 results is failed=True under the default zero_result_is_failure policy — see below.
submit_http, poll_http, merge_httpHTTP status of the submit, the last poll, and the merged fetch. Separates "the ARS said no" from "the HTTP call broke".
pollsHow many status polls it took — a large number on a slow query says it was genuinely working, not stuck.
intermediate_error_countHow many non-fatal errors the query hit on the way to its outcome (0 = it ran clean). The poll loop retries through these, so they never decide ars_status — but they say a query only limped to Done.
result_count, response_bytesSize of the merged answer.
intermediate_errorsThose same errors in words, tallied: poll HTTP 502 x3; merge HTTP 500. Covers polls that came back non-200 (poll request failed when there was no HTTP response at all), poll bodies that wouldn't parse, and a merged fetch that 500'd, went missing (Done without merged_version) or wouldn't parse. Empty on a clean query.
errorWhy it failed, in words — the terminal reason, empty on success (as opposed to intermediate_errors, which is the trouble on the way there). Also carries Done with 0 results when a target scores that as a success — the note is what makes the pk worth opening either way.
message_urlFull URL to that query's message. Paste it into a browser or curl it.

The printed summary also points straight at the failures, so you don't have to open the file to start:

FAILED QUERIES (12 of 380; first 5 shown, all in run1_ars_queries.csv)
stage 2 pathfinder_drug_disease [Timeout] pk=8f3c… -- no terminal status within 600s (last: Running)
https://ars.ci.transltr.io/ars/api/messages/8f3c…?trace=y

Intermediate errors get their own line, because most of the queries carrying them succeeded — they never appear in the failed block, and a run that only limped to green otherwise reads as clean:

INTERMEDIATE ERRORS: 34 of 380 queries hit retried, non-fatal errors (intermediate_errors column in run1_ars_queries.csv)
stage 4 mvp1_heavy [Done] pk=a01f… -- poll HTTP 502 x3

Useful slices, once you have the file:

# Every failed query, as a table
awk -F, 'NR==1 || $9=="True"' run1_ars_queries.csv | column -s, -t
# The pks of everything that timed out at the top stage
awk -F, '$8=="Timeout" && $4==60 {print $2}' run1_ars_queries.csv
# Slowest queries, with their URLs
sort -t, -k7 -gr run1_ars_queries.csv | head -5 | cut -d, -f2,7,8,19
# Queries that succeeded but hit non-fatal errors on the way
awk -F, 'NR==1 || ($14+0 > 0 && $9=="False")' run1_ars_queries.csv | column -s, -t

On the relationship to ars_completion.csv: this file records what happened within the measurement window (a query not terminal by max_poll_s is a Timeout row here, which is exactly how the knee scores it). The completion sidecar separately answers whether that same query eventually finished. Join them on query. A timed-out query whose extended poll was still running at shutdown appears here but not there — which is precisely the query you most want the pk of.

ARS completion tracking (ars_completion.csv)

max_poll_s (default 6 min for ars) is the failure threshold: a query that hasn't reached a terminal status by then is recorded as a Timeout failure in the main stats and the knee — unchanged. But a timed-out query isn't necessarily stuck; the ARS may still finish it, just slowly. To capture that, once a query blows max_poll_s a background poller keeps watching it up to completion_max_poll_s (default 10 min) and records the outcome to <prefix>_ars_completion.csv — one row per logical query:

  • total_response_s — true end-to-end submit→terminal time (up to completion_max_poll_s);
  • finished — whether it ever reached a terminal status (Done or Error);
  • within_slo — whether it finished inside max_poll_s (i.e. was not a Timeout failure in the main stats); a slow-but-eventually-done query is finished=True, within_slo=False;
  • status — the terminal status (Done/Error/Timeout/SubmitError/NoPK).

summary.json carries a completion roll-up (finished, finished-within-SLO, finished-after-SLO, never-finished, submit-failed), and two red flags surface the key distinctions: queries that exceeded the SLO but did finish (slow, not stuck) and queries that never finished even within completion_max_poll_s. This separates slow from broken — a service that eventually answers every query is degraded differently than one that drops them. Set completion_max_poll_s equal to (or omit it, defaulting to) max_poll_s to turn extended tracking off; the sidecar then just marks each timed-out query finished=False. Any query still being polled when the test ends is drained for a bounded window at shutdown; ones still unfinished after that are absent from the file.

How to read the results

This section is for anyone — technical or not — who has a run's output files in hand and wants to answer one question: how much load can this service take?

Plain-language key

A few terms show up everywhere in the outputs. Here's what each one means, without the jargon:

TermWhat it means
StageOne step of the ramp. Each stage pins a fixed number of simultaneous users for a fixed time, then the next stage adds more. The run climbs stage by stage until the service struggles.
Users / concurrencyHow many simultaneous users are hammering the service. "Users" is what we asked for in a stage; concurrency is the effective number actually in flight, computed from the measured throughput and speed.
RPSRequests Per Second — how many queries the service actually completed each second during that stage. Higher is faster.
LatencyHow long one query took, in milliseconds (1000 ms = 1 second).
mean / p50 / p95 / p99Different ways to summarize latency. mean is the average. p50 (median) is the typical query. p95 / p99 are the slow tail: "95% (or 99%) of queries were at least this fast." p99 is the one we hold to a standard, because it captures the bad experiences, not just the average.
Error rateThe fraction of queries that failed (e.g. 0.02 = 2%). For the ARS, a query that finishes but returns zero answers also counts as a failure by default — a per-target flag can change that, and the run says which policy applied.
SLOService Level Objective — the line in the sand for "acceptable." Here it's a p99 latency cap (e.g. 60000 ms = 60 s) plus a max error rate (default 1%). A stage "passes" only if it stays under both.
CheckpointA pass/fail question asked of one specific load level ("does 30 simultaneous users still work?"), with its own latency/error bars. Only the mixed capacity profile defines these; other runs just report the knee.
KneeThe highest-load stage that still passes the SLO. It's the headline result: the most simultaneous users the service handled while staying fast enough and reliable enough. Past the knee, things fall apart.

Start here: summary.json

Open <prefix>_summary.json first. Two fields tell you almost everything:

  • max_sustainable_concurrency — the headline number. This is the answer to "how much can this service take?"
  • knee — the stage that number came from (its users, latency, error rate, etc.). This is the last healthy stage of the ramp.

If knee is null (and max_sustainable_concurrency is empty), the service failed even at the lightest load — it was already too slow or too error-prone in the very first stage. That's a strong signal something is wrong (or the SLO is set tighter than the service can ever meet).

The same file also echoes the config that produced the run (which target, which endpoint, the SLO, the ramp stages) so a result is self-documenting.

On a run with acceptance criteria (aras_mixed / ars_mixed), two more fields answer the pass/fail question directly: checkpoints_passed (one boolean for the whole run) and checkpoints (per-level verdicts and why). See "Mixed capacity profile".

Reading stages.csv — the story of the ramp

<prefix>_stages.csv has one row per stage and shows the service degrading as load climbs. Here's an illustrative example (SLO: p99 ≤ 60 000 ms, errors ≤ 1%):

stageusersrpsmean_msp99_mserror_rateconcurrency
154.81 0402 1000.0005.0
2109.11 0903 4000.0009.9
32017.01 1709 8000.00419.9
44022.01 80041 0000.00839.6 ← knee
58019.04 20078 0000.140

How to read it:

  • Top to bottom = more load. Each stage adds users.
  • Watch two columns: p99_ms and error_rate. As long as both stay under the SLO (here 60 000 ms and 0.01), the service is coping.
  • The knee is the last "green" row — stage 4 above. Its concurrency (≈ 40) is the headline number.
  • The row after the knee shows the cliff: at stage 5, p99 latency blew past the 60 s cap and the error rate jumped to 14%. Notice RPS actually dropped (19 vs 22) even though we added users — a classic sign the service is saturated and thrashing, not going faster.

A healthy run shows latency rising gently and errors near zero until a clear knee, then a sharp cliff. If the very first row already breaks the SLO, you get no knee (see above).

Why not just trust Locust's own totals? During a ramp, an overall average would blend the easy early stages with the saturated late ones and hide the knee. That's why HelmsDeep reports per stage. (It's also why the HTML report's blended totals aren't the official number — see below.)

by_qtype.csv — which query is the bottleneck

<prefix>_by_qtype.csv breaks each stage down by type of query (e.g. simple single-hop lookups vs. heavier multi-hop or "what treats this disease?" queries). Use it to answer "what's slowing things down?" — often one query shape saturates long before the others, and that's where to focus.

ars_health.csv and red_flags (ARS runs only)

The ARS is what real users actually hit, and it can fail silently — a query can come back "successful" but with fewer answers than it should, or zero. <prefix>_ars_health.csv and the red_flags list in summary.json exist to catch exactly this. In plain terms they flag things like:

  • Answers quietly disappearing under load — the average number of results per query drops sharply as concurrency rises (something downstream is dropping out).
  • "Done" but empty — queries that finished but returned zero answers (counted as failures).
  • Wild result-count swings — identical queries returning very different amounts, a sign of instability.

If red_flags is non-empty, the service may be degrading in a way the raw latency/error numbers alone wouldn't reveal — read those flags.

The HTML report (<prefix>_report.html)

This is Locust's built-in report. Just double-click it to open in a browser. It gives you nice visuals: response-time and request-rate charts over the whole run, a table of every request type, and a list of any failures. It's great for a quick visual feel and for sharing a screenshot.

Important caveat: the HTML report's headline totals are blended across the entire run — it averages the gentle early stages together with the overwhelmed late ones. That makes its single "average response time" / "total RPS" numbers not the right ones to quote. For the official ceiling, always read the knee in summary.json (and the per-stage story in stages.csv). Think of the HTML as the pictures, and summary.json/stages.csv as the verdict.

Tuning notes

  • Swap the CURIEs. The corpus in helmsdeep/trapi_corpus.py uses a few real MONDO/CHEBI entities; replace them with entities your target service actually knows about, or queries return empty and won't reflect real cost.

  • Tier is per query (Retriever only). Retriever exposes parameters.tier (0 or 1) to pick its backend graph. RETRIEVER_CORPUS pairs multi-hop shapes with tier 0 and single-hop shapes with tier 1. ARA queries carry no tier.

  • Shepherd/ARS send inferred + bypass_cache, mixing MVP1 and MVP2.SHEPHERD_CORPUS (also used for ars) holds creative-mode queries (knowledge_type: "inferred", bypass_cache: true) split evenly between two Translator templates, with entities varied per request to spread load and avoid cache-warming. by_qtype.csv breaks out latency per template/tier.

    • MVP1 — "what treats disease X?" (chemical -[treats]-> disease): the pinned disease is sampled from size-tiered pools (heavy/medium/light), so cost tracks answer-set size. Heavy is a curated list of common disease hubs; the long-tail pool is ~1000 real MONDO CURIEs in curie_list.json.
    • MVP2 — chemical⇄gene "affects" (biolink:affects, inferred, with object_aspect/object_direction qualifiers): both edge directions (chemical→gene and gene→chemical), with the gene (curated GENES pool) and qualifier combo varied per request.

    These are far heavier than KP lookups, so the aras target ships a gentler ramp and a looser p99_slo_ms (see config.py). Tune the per-template weights and entity pools (HEAVY_DISEASES, GENES, the tiers) to your real traffic.

  • ARS reuses the Shepherd corpus (ARS_CORPUS = SHEPHERD_CORPUS) — the same inferred query the ARS fans out to its ARAs. Its poll cadence and per-query timeout (poll_interval_s, max_poll_s) are tunable in config.py.

  • Pathfinder is its own run type (aras_pathfinder / ars_pathfinder, ARA/ARS only). PATHFINDER_CORPUS sends a single drug↔disease shape that pins two endpoints and asks for connecting paths (a paths map in the query_graph, not edges); the (chemical, disease) pair varies per request from a curated CHEM_DISEASE_PAIRS list — swap these for pairs your service knows, and keep them plausibly connected so paths come back non-empty (on ARS a zero-result Done is a failure by default). It's the heaviest query class, so its targets ship the gentlest ramps and loosest SLOs (and, for ars_pathfinder, the longest max_poll_s); tune them in config.py.

  • The mixed profile blends the two ARA/ARS classes (aras_mixed / ars_mixed). MIXED_CORPUS is computed from SHEPHERD_CORPUS + PATHFINDER_CORPUS at the INFERRED_PATHFINDER_RATIO (2:1), so it tracks any retuning of the inferred mix. Change the blend by editing that ratio; change what counts as a pass by editing the per-target checkpoints in config.py. Because 1/3 of the mix is Pathfinder — the heaviest class — and p99 is a tail statistic, both mixed targets inherit the looser Pathfinder SLO. aras_mixed also raises request_timeout_s above the default 210 s: a sync target whose p99 SLO sits near the HTTP timeout records slow queries as client timeouts (errors) instead of latencies.

  • Adjust corpus weights in RETRIEVER_CORPUS / SHEPHERD_CORPUS to match your traffic mix.

Running the engine directly

You can also invoke the locustfile without the CLI (defaults to the kps target):

LOCUST_CSV_PREFIX=run1 \
locust -f helmsdeep/trapi_loadtest.py --headless \
--host https://your-retriever-service.example.org \
--html run1_report.html

The output-file prefix comes from the LOCUST_CSV_PREFIX env var (locust has no --csv-prefix flag). Set LOADTEST_TARGET to choose a different (implemented) layer. Add --html <name>.html yourself to get the HTML report (the helmsdeep CLI passes this for you automatically, as <prefix>_report.html).

About

Load Tester of Translator Services

Resources

Stars

0 stars

Watchers

4 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

45 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

HelmsDeep

HelmsDeepHTTP Endpoint Load Measurement System, Determining Each Endpoint's Performance — drives a stepped ramp of concurrent users against a single NCATS Translator component and reports the max sustainable concurrency (the "knee") — the highest load where the service still meets a latency/error SLO.

The Translator stack cascades ARS → ARAs → KPs, so a run targets exactly one layer at a time (testing a higher layer already loads everything beneath it). All three workflows are wired up: Retriever (KP), Shepherd (ARA), and the asynchronous ARS.

In plain language: HelmsDeep slowly turns up the number of simultaneous users hitting a service and watches when it starts getting too slow or too error-prone. The single headline number it reports — the knee, or "max sustainable concurrency" — is the largest number of simultaneous users the service handled while still staying fast enough and erroring rarely enough. Above that load, the service is overwhelmed. If you only read one thing, read the "How to read the results" section.

Install

pip install -e .# Python >= 3.12; installs locust

Run a workflow

# Retriever (KP) — sync lookup queries, scalar parameters.tier per query
helmsdeep --targets kps \
--host https://your-retriever-service.example.org \
--csv-prefix run1
# Shepherd (ARA) — sync creative-mode (inferred) queries, cache bypassed
helmsdeep --targets aras \
--host https://your-ara-service.example.org \
--csv-prefix run1
# ARS — async submit/poll/merge of inferred queries (host = the ARS API base)
helmsdeep --targets ars \
--host https://ars.ci.transltr.io/ars/api \
--csv-prefix run1
# Pathfinder — its own heavier run type (ARA/ARS only); pins two endpoints and# asks for connecting paths. Sync via the ARA, async via the ARS.
helmsdeep --targets aras_pathfinder \
--host https://your-ara-service.example.org \
--csv-prefix pf1
helmsdeep --targets ars_pathfinder \
--host https://ars.ci.transltr.io/ars/api \
--csv-prefix pf1
# Mixed capacity profile (ARA/ARS only) — 2/3 inferred MVP1+MVP2, 1/3 Pathfinder# in one blended workload, ramped to 30 → 45 → 60 concurrent users and judged# against pass/fail acceptance checkpoints.
helmsdeep --targets aras_mixed \
--host https://your-ara-service.example.org \
--csv-prefix mix1
helmsdeep --targets ars_mixed \
--host https://ars.ci.transltr.io/ars/api \
--csv-prefix mix1
  • --targets selects the layer: kps (Retriever), aras (Shepherd), ars, the Pathfinder run types aras_pathfinder / ars_pathfinder (ARA/ARS only), or the mixed capacity profile aras_mixed / ars_mixed (ARA/ARS only — see "Mixed capacity profile").
  • --host is required — the base URL of the target service. For kps/aras the /query path is appended; for ars the host is the API base and the tool uses /submit then /messages/{pk}.
  • --csv-prefix is optional; it falls back to the LOCUST_CSV_PREFIX env var, then to trapi_run.
  • --time-budget / --quick compress a run to fit a wall clock — see "Shorter runs" below.

The load profile (users, spawn rate, duration) is driven by the StepLoad shape, not by CLI flags — so there is intentionally no -u/-r/-t. The ramp and knee threshold are per target in helmsdeep/config.py (stages and p99_slo_ms), since cost profiles differ wildly by layer; edit them there.

An optional per-target cooldown_s inserts a quiet gap between stages: users ramp to 0 so slow in-flight queries drain (counted under the stage that launched them) before the next stage starts clean, instead of bleeding into it. It defaults to 0 (cheap KP lookups don't bleed) and is set on the expensive ARA/ARS/Pathfinder targets.

Shorter runs (--time-budget / --quick)

A full run is long on purpose: slow queries need long holds to accumulate enough samples for a trustworthy percentile (ars_mixed takes ~70 minutes). When you don't need that — checking a host, corpus, or config change end to end, or taking a rough read — compress it:

# Smoke run: the whole ramp in about 10 minutes.
helmsdeep --targets ars_mixed --host https://ars.ci.transltr.io/ars/api --quick
# Or name your own budget: 30m, 1h, 90s, or plain seconds.
helmsdeep --targets aras_mixed --host https://your-ara.example.org --time-budget 30m

--quick is shorthand for --time-budget 10m. Both scale the durations — stage holds, cooldowns, and the poll/timeout caps that gate how long one query may run — by a single factor. The shape is untouched: same user counts, same stages, same SLOs, same checkpoints. So --targets ars_mixed --time-budget 30m still ramps 10 → 30 → 45 → 60 users and still judges all three checkpoints; each stage just gets ~6 minutes instead of ~15.

The CLI prints the compressed plan before it starts, so you know what you're committing to:

Compressed run: 1h10m -> 30m02s (time scale 0.43), budget 30m00s
Stages: 10u x 4m17s -> 30u x 6m26s -> 45u x 6m26s -> 60u x 6m26s, 2m09s cooldown between
Per-query cap cut to 4m17s -- a query slower than that is recorded as a
timeout failure, so error rates are not comparable to a full run's.

What you give up. Two things, and they matter:

  1. Sample count, and with it the tail. A p99 over a handful of queries is noise. The mean and p50 hold up far better than p95/p99.
  2. What counts as a failure. The per-query caps shrink with everything else, so a query that would have finished in 4 minutes is a timeout when the cap is 2. Error rates — and therefore checkpoint verdicts — are not comparable to a full run's.

Every output is stamped with config.time_scale (1.0 = a full run), and the printed summary leads with a warning banner. Don't quote a compressed run's knee as a capacity result, and don't gate CI on its checkpoints.

Compression only ever speeds a run up: a budget above the target's natural duration is ignored (kps already runs in 7 minutes, so --quick leaves it alone). Stages won't shrink below a 30-second floor, so a budget too small to fit them simply overruns — the CLI says so and prints the real projected duration.

Want each stage to get a specific amount of time? The budget is for the whole run, including cooldowns, so divide accordingly — or edit the per-target stages in config.py directly, which is the only way to change the shape (dropping the baseline stage from a mixed run, say, to give the three checkpoints 10 minutes each).

Outputs

Written to the working directory by the standalone/master node:

  • <prefix>_stages.csv — one row per stage (overall metrics), including a stage_start column with the stage's wall-clock start time (ISO 8601 UTC)
  • <prefix>_by_qtype.csv — one row per (stage, query type)
  • <prefix>_summary.json — config (including which target was measured and the time_scale it ran at), all stages, and the chosen knee (plus checkpoints for targets that define them)
  • <prefix>_checkpoints.csvtargets with acceptance criteria (aras_mixed / ars_mixed): one row per checkpoint with its PASS / FAIL verdict
  • <prefix>_ars_health.csvARS only: per-stage health signals (see below)
  • <prefix>_ars_queries.csvARS only: one row per logical query with its pk, the HTTP status of each step, the terminal ARS status, any intermediate (retried, non-fatal) errors it hit along the way, and a ready-made URL for pulling that query up (see below)
  • <prefix>_ars_completion.csvARS only: one row per logical query with its end-to-end response time and whether it eventually finished — polled past the max_poll_s failure threshold up to completion_max_poll_s (see below)
  • <prefix>_report.html — Locust's native, self-contained HTML report (latency-over-time charts, request/failure tables). Open it in any browser. See the caveat under "How to read the results": its totals are blended across the whole run, so the authoritative ceiling is the knee in summary.json, not the HTML aggregate.
  • a printed summary table ending in the headline max sustainable concurrency

For a guide to interpreting every one of these — written for non-specialists — see "How to read the results" below.

Mixed capacity profile (aras_mixed / ars_mixed)

Every other target characterizes one query class in isolation to find the knee. The mixed profile answers a different, operational question: can the system hold a target concurrency when both query classes arrive together, the way they do in production?

  • Corpus — one blended workload: 2/3 inferred MVP1+MVP2 creative queries and 1/3 Pathfinder. MVP1/MVP2 keep their relative SHEPHERD_CORPUS weights (so retuning that mix carries over); only the 2:1 split is imposed. by_qtype.csv still breaks the two classes out separately, so you can see which one drives the tail.

  • Ramp — a short low-load baseline stage, then the three concurrency levels the profile exists to judge: 30 → 45 → 60 simultaneous users. The baseline exists so a failure at 30 can be read against a healthy reference (is 30 slow, or is everything slow?).

  • Verdict — each level carries pass/fail acceptance checkpoints instead of only feeding the knee:

    UsersGoalCriteria
    30sustain peak loadp99 ≤ 300 000 ms and errors ≤ 1%
    45headroom above peakp99 ≤ 300 000 ms and errors ≤ 1%
    60no substantial failures under overloaderrors ≤ 5% (latency not judged — slowdown is expected here, failures are not)

The verdicts land in <prefix>_checkpoints.csv, in summary.json (checkpoints plus a single checkpoints_passed boolean), and in the printed report:

ACCEPTANCE CHECKPOINTS
usr p99 bar err% bar verdict goal
30 184203 300000 0.4 1.0 PASS sustain peak load
45 291560 300000 0.9 1.0 PASS headroom above peak
60 402118 n/a 3.1 5.0 PASS no substantial failures under overload
RESULT: all 3 checkpoints met.

A missed checkpoint prints why (-> p99 331402ms > 300000ms) and makes the run exit non-zero, so it can gate a CI/acceptance job. A checkpoint whose stage recorded no completed requests is reported as NO DATA rather than a silent pass. The knee is still computed and reported alongside, unchanged.

Checkpoints are a generic per-target feature (checkpoints in config.py), not special-cased to this profile: any target can define them, and targets that don't behave exactly as before. Edit the users, goals, and bars there — the ARA and ARS variants carry the same three checkpoints so the two layers are directly comparable.

Layering still applies:ars_mixed fans out to the ARAs, so run one or the other — never both at once.

ARS async workflow and health signals

The ars target is asynchronous: each logical query is POST /submit → poll GET /messages/{pk}?trace=y (every poll_interval_s, capped at max_poll_s, default 15 min) until status is Done/Error → fetch GET /messages/{merged_pk} and count fields.data.message.results. Latency is the wall-clock submit→terminal time; one measurement is recorded per logical query. A single ars_query Locust request event carries that full wall-clock, so Locust's native stats table shows the true per-query time — the individual ars_submit/ars_poll/ars_merge calls also appear there as per-step diagnostics (e.g. ars_merge times only the final merge fetch, not the whole query), but aren't double-counted as the query time.

Because the ARS is what real users hit, the run also captures health signals to flag silent downstream breakage, written to <prefix>_ars_health.csv and summary.json (ars_health + a human-readable red_flags list):

  • result-count variation (min/mean/max + coefficient of variation) across identical queries;
  • zero-result Done count — by default a Done with 0 results is treated as a failure (counts against the error rate and the knee) and flagged; the per-target zero_result_is_failure flag can score it as a success instead (see "Is an empty answer a failure?");
  • response size (merged-message bytes, mean/max);
  • result drop under load — flagged when the mean result count falls sharply as concurrency rises across stages.

Is an empty answer a failure?

A terminal Done that carries 0 results is scored as a failure by default: under load an empty answer set usually means a downstream agent silently dropped out, which is exactly what the knee should catch. But that conflates a silent break with a query whose answer set is legitimately empty, so it is a per-target switch in config.py:

"zero_result_is_failure": True, # the default; set False to score only# transport/protocol outcomes as failures

With False, only submit errors, an Error status, and Timeout count as failures. Note this shifts more than the error rate: a failed request contributes no latency sample, so a zero-result query that stops being a failure starts feeding the percentile pool — mean, p99, and therefore the Little's-Law concurrency all move too. The two policies give two different knees, which is the point: run both to see how much of your ceiling is empty answers versus broken transport.

Whichever policy is in force, the zero-result query is still counted in ars_health.csv (zero_result_done), still raises a red flag, and still carries the Done with 0 results note in the per-query debug log. The policy itself is recorded in summary.json (config.zero_result_is_failure) and printed above the ARS health table, so a run's numbers can't be read under the wrong assumption:

ARS HEALTH (per stage)
0-result 'Done' scored as: FAILURE (zero_result_is_failure=True)

Per-query debug log (ars_queries.csv)

The aggregate reports tell you that 3% of queries failed at 45 users. This file tells you which ones, and hands you what you need to go look at them: one row per logical ARS query, written for every query — successful, failed, or timed out.

ColumnWhat it's for
queryQuery id. Joins to ars_completion.csv on the same column.
pkThe ARS primary key. Empty only when the submit never returned one.
stage, usersWhich stage the query is attributed to (the stage active when it reached its outcome — the same rule the per-stage stats use).
qtypeWhich corpus query it was (mvp1_heavy, pathfinder_drug_disease, …).
submit_startWhen it was submitted (ISO 8601 UTC) — line it up against service logs.
latency_sWall clock from submit to terminal status.
ars_statusDone / Error / Timeout / SubmitError / NoPK.
failedWhether it counted against the error rate. A Done with 0 results is failed=True under the default zero_result_is_failure policy — see below.
submit_http, poll_http, merge_httpHTTP status of the submit, the last poll, and the merged fetch. Separates "the ARS said no" from "the HTTP call broke".
pollsHow many status polls it took — a large number on a slow query says it was genuinely working, not stuck.
intermediate_error_countHow many non-fatal errors the query hit on the way to its outcome (0 = it ran clean). The poll loop retries through these, so they never decide ars_status — but they say a query only limped to Done.
result_count, response_bytesSize of the merged answer.
intermediate_errorsThose same errors in words, tallied: poll HTTP 502 x3; merge HTTP 500. Covers polls that came back non-200 (poll request failed when there was no HTTP response at all), poll bodies that wouldn't parse, and a merged fetch that 500'd, went missing (Done without merged_version) or wouldn't parse. Empty on a clean query.
errorWhy it failed, in words — the terminal reason, empty on success (as opposed to intermediate_errors, which is the trouble on the way there). Also carries Done with 0 results when a target scores that as a success — the note is what makes the pk worth opening either way.
message_urlFull URL to that query's message. Paste it into a browser or curl it.

The printed summary also points straight at the failures, so you don't have to open the file to start:

FAILED QUERIES (12 of 380; first 5 shown, all in run1_ars_queries.csv)
stage 2 pathfinder_drug_disease [Timeout] pk=8f3c… -- no terminal status within 600s (last: Running)
https://ars.ci.transltr.io/ars/api/messages/8f3c…?trace=y

Intermediate errors get their own line, because most of the queries carrying them succeeded — they never appear in the failed block, and a run that only limped to green otherwise reads as clean:

INTERMEDIATE ERRORS: 34 of 380 queries hit retried, non-fatal errors (intermediate_errors column in run1_ars_queries.csv)
stage 4 mvp1_heavy [Done] pk=a01f… -- poll HTTP 502 x3

Useful slices, once you have the file:

# Every failed query, as a table
awk -F, 'NR==1 || $9=="True"' run1_ars_queries.csv | column -s, -t
# The pks of everything that timed out at the top stage
awk -F, '$8=="Timeout" && $4==60 {print $2}' run1_ars_queries.csv
# Slowest queries, with their URLs
sort -t, -k7 -gr run1_ars_queries.csv | head -5 | cut -d, -f2,7,8,19
# Queries that succeeded but hit non-fatal errors on the way
awk -F, 'NR==1 || ($14+0 > 0 && $9=="False")' run1_ars_queries.csv | column -s, -t

On the relationship to ars_completion.csv: this file records what happened within the measurement window (a query not terminal by max_poll_s is a Timeout row here, which is exactly how the knee scores it). The completion sidecar separately answers whether that same query eventually finished. Join them on query. A timed-out query whose extended poll was still running at shutdown appears here but not there — which is precisely the query you most want the pk of.

ARS completion tracking (ars_completion.csv)

max_poll_s (default 6 min for ars) is the failure threshold: a query that hasn't reached a terminal status by then is recorded as a Timeout failure in the main stats and the knee — unchanged. But a timed-out query isn't necessarily stuck; the ARS may still finish it, just slowly. To capture that, once a query blows max_poll_s a background poller keeps watching it up to completion_max_poll_s (default 10 min) and records the outcome to <prefix>_ars_completion.csv — one row per logical query:

  • total_response_s — true end-to-end submit→terminal time (up to completion_max_poll_s);
  • finished — whether it ever reached a terminal status (Done or Error);
  • within_slo — whether it finished inside max_poll_s (i.e. was not a Timeout failure in the main stats); a slow-but-eventually-done query is finished=True, within_slo=False;
  • status — the terminal status (Done/Error/Timeout/SubmitError/NoPK).

summary.json carries a completion roll-up (finished, finished-within-SLO, finished-after-SLO, never-finished, submit-failed), and two red flags surface the key distinctions: queries that exceeded the SLO but did finish (slow, not stuck) and queries that never finished even within completion_max_poll_s. This separates slow from broken — a service that eventually answers every query is degraded differently than one that drops them. Set completion_max_poll_s equal to (or omit it, defaulting to) max_poll_s to turn extended tracking off; the sidecar then just marks each timed-out query finished=False. Any query still being polled when the test ends is drained for a bounded window at shutdown; ones still unfinished after that are absent from the file.

How to read the results

This section is for anyone — technical or not — who has a run's output files in hand and wants to answer one question: how much load can this service take?

Plain-language key

A few terms show up everywhere in the outputs. Here's what each one means, without the jargon:

TermWhat it means
StageOne step of the ramp. Each stage pins a fixed number of simultaneous users for a fixed time, then the next stage adds more. The run climbs stage by stage until the service struggles.
Users / concurrencyHow many simultaneous users are hammering the service. "Users" is what we asked for in a stage; concurrency is the effective number actually in flight, computed from the measured throughput and speed.
RPSRequests Per Second — how many queries the service actually completed each second during that stage. Higher is faster.
LatencyHow long one query took, in milliseconds (1000 ms = 1 second).
mean / p50 / p95 / p99Different ways to summarize latency. mean is the average. p50 (median) is the typical query. p95 / p99 are the slow tail: "95% (or 99%) of queries were at least this fast." p99 is the one we hold to a standard, because it captures the bad experiences, not just the average.
Error rateThe fraction of queries that failed (e.g. 0.02 = 2%). For the ARS, a query that finishes but returns zero answers also counts as a failure by default — a per-target flag can change that, and the run says which policy applied.
SLOService Level Objective — the line in the sand for "acceptable." Here it's a p99 latency cap (e.g. 60000 ms = 60 s) plus a max error rate (default 1%). A stage "passes" only if it stays under both.
CheckpointA pass/fail question asked of one specific load level ("does 30 simultaneous users still work?"), with its own latency/error bars. Only the mixed capacity profile defines these; other runs just report the knee.
KneeThe highest-load stage that still passes the SLO. It's the headline result: the most simultaneous users the service handled while staying fast enough and reliable enough. Past the knee, things fall apart.

Start here: summary.json

Open <prefix>_summary.json first. Two fields tell you almost everything:

  • max_sustainable_concurrency — the headline number. This is the answer to "how much can this service take?"
  • knee — the stage that number came from (its users, latency, error rate, etc.). This is the last healthy stage of the ramp.

If knee is null (and max_sustainable_concurrency is empty), the service failed even at the lightest load — it was already too slow or too error-prone in the very first stage. That's a strong signal something is wrong (or the SLO is set tighter than the service can ever meet).

The same file also echoes the config that produced the run (which target, which endpoint, the SLO, the ramp stages) so a result is self-documenting.

On a run with acceptance criteria (aras_mixed / ars_mixed), two more fields answer the pass/fail question directly: checkpoints_passed (one boolean for the whole run) and checkpoints (per-level verdicts and why). See "Mixed capacity profile".

Reading stages.csv — the story of the ramp

<prefix>_stages.csv has one row per stage and shows the service degrading as load climbs. Here's an illustrative example (SLO: p99 ≤ 60 000 ms, errors ≤ 1%):

stageusersrpsmean_msp99_mserror_rateconcurrency
154.81 0402 1000.0005.0
2109.11 0903 4000.0009.9
32017.01 1709 8000.00419.9
44022.01 80041 0000.00839.6 ← knee
58019.04 20078 0000.140

How to read it:

  • Top to bottom = more load. Each stage adds users.
  • Watch two columns: p99_ms and error_rate. As long as both stay under the SLO (here 60 000 ms and 0.01), the service is coping.
  • The knee is the last "green" row — stage 4 above. Its concurrency (≈ 40) is the headline number.
  • The row after the knee shows the cliff: at stage 5, p99 latency blew past the 60 s cap and the error rate jumped to 14%. Notice RPS actually dropped (19 vs 22) even though we added users — a classic sign the service is saturated and thrashing, not going faster.

A healthy run shows latency rising gently and errors near zero until a clear knee, then a sharp cliff. If the very first row already breaks the SLO, you get no knee (see above).

Why not just trust Locust's own totals? During a ramp, an overall average would blend the easy early stages with the saturated late ones and hide the knee. That's why HelmsDeep reports per stage. (It's also why the HTML report's blended totals aren't the official number — see below.)

by_qtype.csv — which query is the bottleneck

<prefix>_by_qtype.csv breaks each stage down by type of query (e.g. simple single-hop lookups vs. heavier multi-hop or "what treats this disease?" queries). Use it to answer "what's slowing things down?" — often one query shape saturates long before the others, and that's where to focus.

ars_health.csv and red_flags (ARS runs only)

The ARS is what real users actually hit, and it can fail silently — a query can come back "successful" but with fewer answers than it should, or zero. <prefix>_ars_health.csv and the red_flags list in summary.json exist to catch exactly this. In plain terms they flag things like:

  • Answers quietly disappearing under load — the average number of results per query drops sharply as concurrency rises (something downstream is dropping out).
  • "Done" but empty — queries that finished but returned zero answers (counted as failures).
  • Wild result-count swings — identical queries returning very different amounts, a sign of instability.

If red_flags is non-empty, the service may be degrading in a way the raw latency/error numbers alone wouldn't reveal — read those flags.

The HTML report (<prefix>_report.html)

This is Locust's built-in report. Just double-click it to open in a browser. It gives you nice visuals: response-time and request-rate charts over the whole run, a table of every request type, and a list of any failures. It's great for a quick visual feel and for sharing a screenshot.

Important caveat: the HTML report's headline totals are blended across the entire run — it averages the gentle early stages together with the overwhelmed late ones. That makes its single "average response time" / "total RPS" numbers not the right ones to quote. For the official ceiling, always read the knee in summary.json (and the per-stage story in stages.csv). Think of the HTML as the pictures, and summary.json/stages.csv as the verdict.

Tuning notes

  • Swap the CURIEs. The corpus in helmsdeep/trapi_corpus.py uses a few real MONDO/CHEBI entities; replace them with entities your target service actually knows about, or queries return empty and won't reflect real cost.

  • Tier is per query (Retriever only). Retriever exposes parameters.tier (0 or 1) to pick its backend graph. RETRIEVER_CORPUS pairs multi-hop shapes with tier 0 and single-hop shapes with tier 1. ARA queries carry no tier.

  • Shepherd/ARS send inferred + bypass_cache, mixing MVP1 and MVP2.SHEPHERD_CORPUS (also used for ars) holds creative-mode queries (knowledge_type: "inferred", bypass_cache: true) split evenly between two Translator templates, with entities varied per request to spread load and avoid cache-warming. by_qtype.csv breaks out latency per template/tier.

    • MVP1 — "what treats disease X?" (chemical -[treats]-> disease): the pinned disease is sampled from size-tiered pools (heavy/medium/light), so cost tracks answer-set size. Heavy is a curated list of common disease hubs; the long-tail pool is ~1000 real MONDO CURIEs in curie_list.json.
    • MVP2 — chemical⇄gene "affects" (biolink:affects, inferred, with object_aspect/object_direction qualifiers): both edge directions (chemical→gene and gene→chemical), with the gene (curated GENES pool) and qualifier combo varied per request.

    These are far heavier than KP lookups, so the aras target ships a gentler ramp and a looser p99_slo_ms (see config.py). Tune the per-template weights and entity pools (HEAVY_DISEASES, GENES, the tiers) to your real traffic.

  • ARS reuses the Shepherd corpus (ARS_CORPUS = SHEPHERD_CORPUS) — the same inferred query the ARS fans out to its ARAs. Its poll cadence and per-query timeout (poll_interval_s, max_poll_s) are tunable in config.py.

  • Pathfinder is its own run type (aras_pathfinder / ars_pathfinder, ARA/ARS only). PATHFINDER_CORPUS sends a single drug↔disease shape that pins two endpoints and asks for connecting paths (a paths map in the query_graph, not edges); the (chemical, disease) pair varies per request from a curated CHEM_DISEASE_PAIRS list — swap these for pairs your service knows, and keep them plausibly connected so paths come back non-empty (on ARS a zero-result Done is a failure by default). It's the heaviest query class, so its targets ship the gentlest ramps and loosest SLOs (and, for ars_pathfinder, the longest max_poll_s); tune them in config.py.

  • The mixed profile blends the two ARA/ARS classes (aras_mixed / ars_mixed). MIXED_CORPUS is computed from SHEPHERD_CORPUS + PATHFINDER_CORPUS at the INFERRED_PATHFINDER_RATIO (2:1), so it tracks any retuning of the inferred mix. Change the blend by editing that ratio; change what counts as a pass by editing the per-target checkpoints in config.py. Because 1/3 of the mix is Pathfinder — the heaviest class — and p99 is a tail statistic, both mixed targets inherit the looser Pathfinder SLO. aras_mixed also raises request_timeout_s above the default 210 s: a sync target whose p99 SLO sits near the HTTP timeout records slow queries as client timeouts (errors) instead of latencies.

  • Adjust corpus weights in RETRIEVER_CORPUS / SHEPHERD_CORPUS to match your traffic mix.

Running the engine directly

You can also invoke the locustfile without the CLI (defaults to the kps target):

LOCUST_CSV_PREFIX=run1 \
locust -f helmsdeep/trapi_loadtest.py --headless \
--host https://your-retriever-service.example.org \
--html run1_report.html

The output-file prefix comes from the LOCUST_CSV_PREFIX env var (locust has no --csv-prefix flag). Set LOADTEST_TARGET to choose a different (implemented) layer. Add --html <name>.html yourself to get the HTML report (the helmsdeep CLI passes this for you automatically, as <prefix>_report.html).

About

Load Tester of Translator Services

Resources

Stars

0 stars

Watchers

4 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Latest commit

History

45 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

HelmsDeep

HelmsDeepHTTP Endpoint Load Measurement System, Determining Each Endpoint's Performance — drives a stepped ramp of concurrent users against a single NCATS Translator component and reports the max sustainable concurrency (the "knee") — the highest load where the service still meets a latency/error SLO.

The Translator stack cascades ARS → ARAs → KPs, so a run targets exactly one layer at a time (testing a higher layer already loads everything beneath it). All three workflows are wired up: Retriever (KP), Shepherd (ARA), and the asynchronous ARS.

In plain language: HelmsDeep slowly turns up the number of simultaneous users hitting a service and watches when it starts getting too slow or too error-prone. The single headline number it reports — the knee, or "max sustainable concurrency" — is the largest number of simultaneous users the service handled while still staying fast enough and erroring rarely enough. Above that load, the service is overwhelmed. If you only read one thing, read the "How to read the results" section.

Install

pip install -e .# Python >= 3.12; installs locust

Run a workflow

# Retriever (KP) — sync lookup queries, scalar parameters.tier per query
helmsdeep --targets kps \
--host https://your-retriever-service.example.org \
--csv-prefix run1
# Shepherd (ARA) — sync creative-mode (inferred) queries, cache bypassed
helmsdeep --targets aras \
--host https://your-ara-service.example.org \
--csv-prefix run1
# ARS — async submit/poll/merge of inferred queries (host = the ARS API base)
helmsdeep --targets ars \
--host https://ars.ci.transltr.io/ars/api \
--csv-prefix run1
# Pathfinder — its own heavier run type (ARA/ARS only); pins two endpoints and# asks for connecting paths. Sync via the ARA, async via the ARS.
helmsdeep --targets aras_pathfinder \
--host https://your-ara-service.example.org \
--csv-prefix pf1
helmsdeep --targets ars_pathfinder \
--host https://ars.ci.transltr.io/ars/api \
--csv-prefix pf1
# Mixed capacity profile (ARA/ARS only) — 2/3 inferred MVP1+MVP2, 1/3 Pathfinder# in one blended workload, ramped to 30 → 45 → 60 concurrent users and judged# against pass/fail acceptance checkpoints.
helmsdeep --targets aras_mixed \
--host https://your-ara-service.example.org \
--csv-prefix mix1
helmsdeep --targets ars_mixed \
--host https://ars.ci.transltr.io/ars/api \
--csv-prefix mix1
  • --targets selects the layer: kps (Retriever), aras (Shepherd), ars, the Pathfinder run types aras_pathfinder / ars_pathfinder (ARA/ARS only), or the mixed capacity profile aras_mixed / ars_mixed (ARA/ARS only — see "Mixed capacity profile").
  • --host is required — the base URL of the target service. For kps/aras the /query path is appended; for ars the host is the API base and the tool uses /submit then /messages/{pk}.
  • --csv-prefix is optional; it falls back to the LOCUST_CSV_PREFIX env var, then to trapi_run.
  • --time-budget / --quick compress a run to fit a wall clock — see "Shorter runs" below.

The load profile (users, spawn rate, duration) is driven by the StepLoad shape, not by CLI flags — so there is intentionally no -u/-r/-t. The ramp and knee threshold are per target in helmsdeep/config.py (stages and p99_slo_ms), since cost profiles differ wildly by layer; edit them there.

An optional per-target cooldown_s inserts a quiet gap between stages: users ramp to 0 so slow in-flight queries drain (counted under the stage that launched them) before the next stage starts clean, instead of bleeding into it. It defaults to 0 (cheap KP lookups don't bleed) and is set on the expensive ARA/ARS/Pathfinder targets.

Shorter runs (--time-budget / --quick)

A full run is long on purpose: slow queries need long holds to accumulate enough samples for a trustworthy percentile (ars_mixed takes ~70 minutes). When you don't need that — checking a host, corpus, or config change end to end, or taking a rough read — compress it:

# Smoke run: the whole ramp in about 10 minutes.
helmsdeep --targets ars_mixed --host https://ars.ci.transltr.io/ars/api --quick
# Or name your own budget: 30m, 1h, 90s, or plain seconds.
helmsdeep --targets aras_mixed --host https://your-ara.example.org --time-budget 30m

--quick is shorthand for --time-budget 10m. Both scale the durations — stage holds, cooldowns, and the poll/timeout caps that gate how long one query may run — by a single factor. The shape is untouched: same user counts, same stages, same SLOs, same checkpoints. So --targets ars_mixed --time-budget 30m still ramps 10 → 30 → 45 → 60 users and still judges all three checkpoints; each stage just gets ~6 minutes instead of ~15.

The CLI prints the compressed plan before it starts, so you know what you're committing to:

Compressed run: 1h10m -> 30m02s (time scale 0.43), budget 30m00s
Stages: 10u x 4m17s -> 30u x 6m26s -> 45u x 6m26s -> 60u x 6m26s, 2m09s cooldown between
Per-query cap cut to 4m17s -- a query slower than that is recorded as a
timeout failure, so error rates are not comparable to a full run's.

What you give up. Two things, and they matter:

  1. Sample count, and with it the tail. A p99 over a handful of queries is noise. The mean and p50 hold up far better than p95/p99.
  2. What counts as a failure. The per-query caps shrink with everything else, so a query that would have finished in 4 minutes is a timeout when the cap is 2. Error rates — and therefore checkpoint verdicts — are not comparable to a full run's.

Every output is stamped with config.time_scale (1.0 = a full run), and the printed summary leads with a warning banner. Don't quote a compressed run's knee as a capacity result, and don't gate CI on its checkpoints.

Compression only ever speeds a run up: a budget above the target's natural duration is ignored (kps already runs in 7 minutes, so --quick leaves it alone). Stages won't shrink below a 30-second floor, so a budget too small to fit them simply overruns — the CLI says so and prints the real projected duration.

Want each stage to get a specific amount of time? The budget is for the whole run, including cooldowns, so divide accordingly — or edit the per-target stages in config.py directly, which is the only way to change the shape (dropping the baseline stage from a mixed run, say, to give the three checkpoints 10 minutes each).

Outputs

Written to the working directory by the standalone/master node:

  • <prefix>_stages.csv — one row per stage (overall metrics), including a stage_start column with the stage's wall-clock start time (ISO 8601 UTC)
  • <prefix>_by_qtype.csv — one row per (stage, query type)
  • <prefix>_summary.json — config (including which target was measured and the time_scale it ran at), all stages, and the chosen knee (plus checkpoints for targets that define them)
  • <prefix>_checkpoints.csvtargets with acceptance criteria (aras_mixed / ars_mixed): one row per checkpoint with its PASS / FAIL verdict
  • <prefix>_ars_health.csvARS only: per-stage health signals (see below)
  • <prefix>_ars_queries.csvARS only: one row per logical query with its pk, the HTTP status of each step, the terminal ARS status, any intermediate (retried, non-fatal) errors it hit along the way, and a ready-made URL for pulling that query up (see below)
  • <prefix>_ars_completion.csvARS only: one row per logical query with its end-to-end response time and whether it eventually finished — polled past the max_poll_s failure threshold up to completion_max_poll_s (see below)
  • <prefix>_report.html — Locust's native, self-contained HTML report (latency-over-time charts, request/failure tables). Open it in any browser. See the caveat under "How to read the results": its totals are blended across the whole run, so the authoritative ceiling is the knee in summary.json, not the HTML aggregate.
  • a printed summary table ending in the headline max sustainable concurrency

For a guide to interpreting every one of these — written for non-specialists — see "How to read the results" below.

Mixed capacity profile (aras_mixed / ars_mixed)

Every other target characterizes one query class in isolation to find the knee. The mixed profile answers a different, operational question: can the system hold a target concurrency when both query classes arrive together, the way they do in production?

  • Corpus — one blended workload: 2/3 inferred MVP1+MVP2 creative queries and 1/3 Pathfinder. MVP1/MVP2 keep their relative SHEPHERD_CORPUS weights (so retuning that mix carries over); only the 2:1 split is imposed. by_qtype.csv still breaks the two classes out separately, so you can see which one drives the tail.

  • Ramp — a short low-load baseline stage, then the three concurrency levels the profile exists to judge: 30 → 45 → 60 simultaneous users. The baseline exists so a failure at 30 can be read against a healthy reference (is 30 slow, or is everything slow?).

  • Verdict — each level carries pass/fail acceptance checkpoints instead of only feeding the knee:

    UsersGoalCriteria
    30sustain peak loadp99 ≤ 300 000 ms and errors ≤ 1%
    45headroom above peakp99 ≤ 300 000 ms and errors ≤ 1%
    60no substantial failures under overloaderrors ≤ 5% (latency not judged — slowdown is expected here, failures are not)

The verdicts land in <prefix>_checkpoints.csv, in summary.json (checkpoints plus a single checkpoints_passed boolean), and in the printed report:

ACCEPTANCE CHECKPOINTS
usr p99 bar err% bar verdict goal
30 184203 300000 0.4 1.0 PASS sustain peak load
45 291560 300000 0.9 1.0 PASS headroom above peak
60 402118 n/a 3.1 5.0 PASS no substantial failures under overload
RESULT: all 3 checkpoints met.

A missed checkpoint prints why (-> p99 331402ms > 300000ms) and makes the run exit non-zero, so it can gate a CI/acceptance job. A checkpoint whose stage recorded no completed requests is reported as NO DATA rather than a silent pass. The knee is still computed and reported alongside, unchanged.

Checkpoints are a generic per-target feature (checkpoints in config.py), not special-cased to this profile: any target can define them, and targets that don't behave exactly as before. Edit the users, goals, and bars there — the ARA and ARS variants carry the same three checkpoints so the two layers are directly comparable.

Layering still applies:ars_mixed fans out to the ARAs, so run one or the other — never both at once.

ARS async workflow and health signals

The ars target is asynchronous: each logical query is POST /submit → poll GET /messages/{pk}?trace=y (every poll_interval_s, capped at max_poll_s, default 15 min) until status is Done/Error → fetch GET /messages/{merged_pk} and count fields.data.message.results. Latency is the wall-clock submit→terminal time; one measurement is recorded per logical query. A single ars_query Locust request event carries that full wall-clock, so Locust's native stats table shows the true per-query time — the individual ars_submit/ars_poll/ars_merge calls also appear there as per-step diagnostics (e.g. ars_merge times only the final merge fetch, not the whole query), but aren't double-counted as the query time.

Because the ARS is what real users hit, the run also captures health signals to flag silent downstream breakage, written to <prefix>_ars_health.csv and summary.json (ars_health + a human-readable red_flags list):

  • result-count variation (min/mean/max + coefficient of variation) across identical queries;
  • zero-result Done count — by default a Done with 0 results is treated as a failure (counts against the error rate and the knee) and flagged; the per-target zero_result_is_failure flag can score it as a success instead (see "Is an empty answer a failure?");
  • response size (merged-message bytes, mean/max);
  • result drop under load — flagged when the mean result count falls sharply as concurrency rises across stages.

Is an empty answer a failure?

A terminal Done that carries 0 results is scored as a failure by default: under load an empty answer set usually means a downstream agent silently dropped out, which is exactly what the knee should catch. But that conflates a silent break with a query whose answer set is legitimately empty, so it is a per-target switch in config.py:

"zero_result_is_failure": True, # the default; set False to score only# transport/protocol outcomes as failures

With False, only submit errors, an Error status, and Timeout count as failures. Note this shifts more than the error rate: a failed request contributes no latency sample, so a zero-result query that stops being a failure starts feeding the percentile pool — mean, p99, and therefore the Little's-Law concurrency all move too. The two policies give two different knees, which is the point: run both to see how much of your ceiling is empty answers versus broken transport.

Whichever policy is in force, the zero-result query is still counted in ars_health.csv (zero_result_done), still raises a red flag, and still carries the Done with 0 results note in the per-query debug log. The policy itself is recorded in summary.json (config.zero_result_is_failure) and printed above the ARS health table, so a run's numbers can't be read under the wrong assumption:

ARS HEALTH (per stage)
0-result 'Done' scored as: FAILURE (zero_result_is_failure=True)

Per-query debug log (ars_queries.csv)

The aggregate reports tell you that 3% of queries failed at 45 users. This file tells you which ones, and hands you what you need to go look at them: one row per logical ARS query, written for every query — successful, failed, or timed out.

ColumnWhat it's for
queryQuery id. Joins to ars_completion.csv on the same column.
pkThe ARS primary key. Empty only when the submit never returned one.
stage, usersWhich stage the query is attributed to (the stage active when it reached its outcome — the same rule the per-stage stats use).
qtypeWhich corpus query it was (mvp1_heavy, pathfinder_drug_disease, …).
submit_startWhen it was submitted (ISO 8601 UTC) — line it up against service logs.
latency_sWall clock from submit to terminal status.
ars_statusDone / Error / Timeout / SubmitError / NoPK.
failedWhether it counted against the error rate. A Done with 0 results is failed=True under the default zero_result_is_failure policy — see below.
submit_http, poll_http, merge_httpHTTP status of the submit, the last poll, and the merged fetch. Separates "the ARS said no" from "the HTTP call broke".
pollsHow many status polls it took — a large number on a slow query says it was genuinely working, not stuck.
intermediate_error_countHow many non-fatal errors the query hit on the way to its outcome (0 = it ran clean). The poll loop retries through these, so they never decide ars_status — but they say a query only limped to Done.
result_count, response_bytesSize of the merged answer.
intermediate_errorsThose same errors in words, tallied: poll HTTP 502 x3; merge HTTP 500. Covers polls that came back non-200 (poll request failed when there was no HTTP response at all), poll bodies that wouldn't parse, and a merged fetch that 500'd, went missing (Done without merged_version) or wouldn't parse. Empty on a clean query.
errorWhy it failed, in words — the terminal reason, empty on success (as opposed to intermediate_errors, which is the trouble on the way there). Also carries Done with 0 results when a target scores that as a success — the note is what makes the pk worth opening either way.
message_urlFull URL to that query's message. Paste it into a browser or curl it.

The printed summary also points straight at the failures, so you don't have to open the file to start:

FAILED QUERIES (12 of 380; first 5 shown, all in run1_ars_queries.csv)
stage 2 pathfinder_drug_disease [Timeout] pk=8f3c… -- no terminal status within 600s (last: Running)
https://ars.ci.transltr.io/ars/api/messages/8f3c…?trace=y

Intermediate errors get their own line, because most of the queries carrying them succeeded — they never appear in the failed block, and a run that only limped to green otherwise reads as clean:

INTERMEDIATE ERRORS: 34 of 380 queries hit retried, non-fatal errors (intermediate_errors column in run1_ars_queries.csv)
stage 4 mvp1_heavy [Done] pk=a01f… -- poll HTTP 502 x3

Useful slices, once you have the file:

# Every failed query, as a table
awk -F, 'NR==1 || $9=="True"' run1_ars_queries.csv | column -s, -t
# The pks of everything that timed out at the top stage
awk -F, '$8=="Timeout" && $4==60 {print $2}' run1_ars_queries.csv
# Slowest queries, with their URLs
sort -t, -k7 -gr run1_ars_queries.csv | head -5 | cut -d, -f2,7,8,19
# Queries that succeeded but hit non-fatal errors on the way
awk -F, 'NR==1 || ($14+0 > 0 && $9=="False")' run1_ars_queries.csv | column -s, -t

On the relationship to ars_completion.csv: this file records what happened within the measurement window (a query not terminal by max_poll_s is a Timeout row here, which is exactly how the knee scores it). The completion sidecar separately answers whether that same query eventually finished. Join them on query. A timed-out query whose extended poll was still running at shutdown appears here but not there — which is precisely the query you most want the pk of.

ARS completion tracking (ars_completion.csv)

max_poll_s (default 6 min for ars) is the failure threshold: a query that hasn't reached a terminal status by then is recorded as a Timeout failure in the main stats and the knee — unchanged. But a timed-out query isn't necessarily stuck; the ARS may still finish it, just slowly. To capture that, once a query blows max_poll_s a background poller keeps watching it up to completion_max_poll_s (default 10 min) and records the outcome to <prefix>_ars_completion.csv — one row per logical query:

  • total_response_s — true end-to-end submit→terminal time (up to completion_max_poll_s);
  • finished — whether it ever reached a terminal status (Done or Error);
  • within_slo — whether it finished inside max_poll_s (i.e. was not a Timeout failure in the main stats); a slow-but-eventually-done query is finished=True, within_slo=False;
  • status — the terminal status (Done/Error/Timeout/SubmitError/NoPK).

summary.json carries a completion roll-up (finished, finished-within-SLO, finished-after-SLO, never-finished, submit-failed), and two red flags surface the key distinctions: queries that exceeded the SLO but did finish (slow, not stuck) and queries that never finished even within completion_max_poll_s. This separates slow from broken — a service that eventually answers every query is degraded differently than one that drops them. Set completion_max_poll_s equal to (or omit it, defaulting to) max_poll_s to turn extended tracking off; the sidecar then just marks each timed-out query finished=False. Any query still being polled when the test ends is drained for a bounded window at shutdown; ones still unfinished after that are absent from the file.

How to read the results

This section is for anyone — technical or not — who has a run's output files in hand and wants to answer one question: how much load can this service take?

Plain-language key

A few terms show up everywhere in the outputs. Here's what each one means, without the jargon:

TermWhat it means
StageOne step of the ramp. Each stage pins a fixed number of simultaneous users for a fixed time, then the next stage adds more. The run climbs stage by stage until the service struggles.
Users / concurrencyHow many simultaneous users are hammering the service. "Users" is what we asked for in a stage; concurrency is the effective number actually in flight, computed from the measured throughput and speed.
RPSRequests Per Second — how many queries the service actually completed each second during that stage. Higher is faster.
LatencyHow long one query took, in milliseconds (1000 ms = 1 second).
mean / p50 / p95 / p99Different ways to summarize latency. mean is the average. p50 (median) is the typical query. p95 / p99 are the slow tail: "95% (or 99%) of queries were at least this fast." p99 is the one we hold to a standard, because it captures the bad experiences, not just the average.
Error rateThe fraction of queries that failed (e.g. 0.02 = 2%). For the ARS, a query that finishes but returns zero answers also counts as a failure by default — a per-target flag can change that, and the run says which policy applied.
SLOService Level Objective — the line in the sand for "acceptable." Here it's a p99 latency cap (e.g. 60000 ms = 60 s) plus a max error rate (default 1%). A stage "passes" only if it stays under both.
CheckpointA pass/fail question asked of one specific load level ("does 30 simultaneous users still work?"), with its own latency/error bars. Only the mixed capacity profile defines these; other runs just report the knee.
KneeThe highest-load stage that still passes the SLO. It's the headline result: the most simultaneous users the service handled while staying fast enough and reliable enough. Past the knee, things fall apart.

Start here: summary.json

Open <prefix>_summary.json first. Two fields tell you almost everything:

  • max_sustainable_concurrency — the headline number. This is the answer to "how much can this service take?"
  • knee — the stage that number came from (its users, latency, error rate, etc.). This is the last healthy stage of the ramp.

If knee is null (and max_sustainable_concurrency is empty), the service failed even at the lightest load — it was already too slow or too error-prone in the very first stage. That's a strong signal something is wrong (or the SLO is set tighter than the service can ever meet).

The same file also echoes the config that produced the run (which target, which endpoint, the SLO, the ramp stages) so a result is self-documenting.

On a run with acceptance criteria (aras_mixed / ars_mixed), two more fields answer the pass/fail question directly: checkpoints_passed (one boolean for the whole run) and checkpoints (per-level verdicts and why). See "Mixed capacity profile".

Reading stages.csv — the story of the ramp

<prefix>_stages.csv has one row per stage and shows the service degrading as load climbs. Here's an illustrative example (SLO: p99 ≤ 60 000 ms, errors ≤ 1%):

stageusersrpsmean_msp99_mserror_rateconcurrency
154.81 0402 1000.0005.0
2109.11 0903 4000.0009.9
32017.01 1709 8000.00419.9
44022.01 80041 0000.00839.6 ← knee
58019.04 20078 0000.140

How to read it:

  • Top to bottom = more load. Each stage adds users.
  • Watch two columns: p99_ms and error_rate. As long as both stay under the SLO (here 60 000 ms and 0.01), the service is coping.
  • The knee is the last "green" row — stage 4 above. Its concurrency (≈ 40) is the headline number.
  • The row after the knee shows the cliff: at stage 5, p99 latency blew past the 60 s cap and the error rate jumped to 14%. Notice RPS actually dropped (19 vs 22) even though we added users — a classic sign the service is saturated and thrashing, not going faster.

A healthy run shows latency rising gently and errors near zero until a clear knee, then a sharp cliff. If the very first row already breaks the SLO, you get no knee (see above).

Why not just trust Locust's own totals? During a ramp, an overall average would blend the easy early stages with the saturated late ones and hide the knee. That's why HelmsDeep reports per stage. (It's also why the HTML report's blended totals aren't the official number — see below.)

by_qtype.csv — which query is the bottleneck

<prefix>_by_qtype.csv breaks each stage down by type of query (e.g. simple single-hop lookups vs. heavier multi-hop or "what treats this disease?" queries). Use it to answer "what's slowing things down?" — often one query shape saturates long before the others, and that's where to focus.

ars_health.csv and red_flags (ARS runs only)

The ARS is what real users actually hit, and it can fail silently — a query can come back "successful" but with fewer answers than it should, or zero. <prefix>_ars_health.csv and the red_flags list in summary.json exist to catch exactly this. In plain terms they flag things like:

  • Answers quietly disappearing under load — the average number of results per query drops sharply as concurrency rises (something downstream is dropping out).
  • "Done" but empty — queries that finished but returned zero answers (counted as failures).
  • Wild result-count swings — identical queries returning very different amounts, a sign of instability.

If red_flags is non-empty, the service may be degrading in a way the raw latency/error numbers alone wouldn't reveal — read those flags.

The HTML report (<prefix>_report.html)

This is Locust's built-in report. Just double-click it to open in a browser. It gives you nice visuals: response-time and request-rate charts over the whole run, a table of every request type, and a list of any failures. It's great for a quick visual feel and for sharing a screenshot.

Important caveat: the HTML report's headline totals are blended across the entire run — it averages the gentle early stages together with the overwhelmed late ones. That makes its single "average response time" / "total RPS" numbers not the right ones to quote. For the official ceiling, always read the knee in summary.json (and the per-stage story in stages.csv). Think of the HTML as the pictures, and summary.json/stages.csv as the verdict.

Tuning notes

  • Swap the CURIEs. The corpus in helmsdeep/trapi_corpus.py uses a few real MONDO/CHEBI entities; replace them with entities your target service actually knows about, or queries return empty and won't reflect real cost.

  • Tier is per query (Retriever only). Retriever exposes parameters.tier (0 or 1) to pick its backend graph. RETRIEVER_CORPUS pairs multi-hop shapes with tier 0 and single-hop shapes with tier 1. ARA queries carry no tier.

  • Shepherd/ARS send inferred + bypass_cache, mixing MVP1 and MVP2.SHEPHERD_CORPUS (also used for ars) holds creative-mode queries (knowledge_type: "inferred", bypass_cache: true) split evenly between two Translator templates, with entities varied per request to spread load and avoid cache-warming. by_qtype.csv breaks out latency per template/tier.

    • MVP1 — "what treats disease X?" (chemical -[treats]-> disease): the pinned disease is sampled from size-tiered pools (heavy/medium/light), so cost tracks answer-set size. Heavy is a curated list of common disease hubs; the long-tail pool is ~1000 real MONDO CURIEs in curie_list.json.
    • MVP2 — chemical⇄gene "affects" (biolink:affects, inferred, with object_aspect/object_direction qualifiers): both edge directions (chemical→gene and gene→chemical), with the gene (curated GENES pool) and qualifier combo varied per request.

    These are far heavier than KP lookups, so the aras target ships a gentler ramp and a looser p99_slo_ms (see config.py). Tune the per-template weights and entity pools (HEAVY_DISEASES, GENES, the tiers) to your real traffic.

  • ARS reuses the Shepherd corpus (ARS_CORPUS = SHEPHERD_CORPUS) — the same inferred query the ARS fans out to its ARAs. Its poll cadence and per-query timeout (poll_interval_s, max_poll_s) are tunable in config.py.

  • Pathfinder is its own run type (aras_pathfinder / ars_pathfinder, ARA/ARS only). PATHFINDER_CORPUS sends a single drug↔disease shape that pins two endpoints and asks for connecting paths (a paths map in the query_graph, not edges); the (chemical, disease) pair varies per request from a curated CHEM_DISEASE_PAIRS list — swap these for pairs your service knows, and keep them plausibly connected so paths come back non-empty (on ARS a zero-result Done is a failure by default). It's the heaviest query class, so its targets ship the gentlest ramps and loosest SLOs (and, for ars_pathfinder, the longest max_poll_s); tune them in config.py.

  • The mixed profile blends the two ARA/ARS classes (aras_mixed / ars_mixed). MIXED_CORPUS is computed from SHEPHERD_CORPUS + PATHFINDER_CORPUS at the INFERRED_PATHFINDER_RATIO (2:1), so it tracks any retuning of the inferred mix. Change the blend by editing that ratio; change what counts as a pass by editing the per-target checkpoints in config.py. Because 1/3 of the mix is Pathfinder — the heaviest class — and p99 is a tail statistic, both mixed targets inherit the looser Pathfinder SLO. aras_mixed also raises request_timeout_s above the default 210 s: a sync target whose p99 SLO sits near the HTTP timeout records slow queries as client timeouts (errors) instead of latencies.

  • Adjust corpus weights in RETRIEVER_CORPUS / SHEPHERD_CORPUS to match your traffic mix.

Running the engine directly

You can also invoke the locustfile without the CLI (defaults to the kps target):

LOCUST_CSV_PREFIX=run1 \
locust -f helmsdeep/trapi_loadtest.py --headless \
--host https://your-retriever-service.example.org \
--html run1_report.html

The output-file prefix comes from the LOCUST_CSV_PREFIX env var (locust has no --csv-prefix flag). Set LOADTEST_TARGET to choose a different (implemented) layer. Add --html <name>.html yourself to get the HTML report (the helmsdeep CLI passes this for you automatically, as <prefix>_report.html).

About

Load Tester of Translator Services

Resources

Stars

0 stars

Watchers

4 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

45 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

HelmsDeep

HelmsDeepHTTP Endpoint Load Measurement System, Determining Each Endpoint's Performance — drives a stepped ramp of concurrent users against a single NCATS Translator component and reports the max sustainable concurrency (the "knee") — the highest load where the service still meets a latency/error SLO.

The Translator stack cascades ARS → ARAs → KPs, so a run targets exactly one layer at a time (testing a higher layer already loads everything beneath it). All three workflows are wired up: Retriever (KP), Shepherd (ARA), and the asynchronous ARS.

In plain language: HelmsDeep slowly turns up the number of simultaneous users hitting a service and watches when it starts getting too slow or too error-prone. The single headline number it reports — the knee, or "max sustainable concurrency" — is the largest number of simultaneous users the service handled while still staying fast enough and erroring rarely enough. Above that load, the service is overwhelmed. If you only read one thing, read the "How to read the results" section.

Install

pip install -e .# Python >= 3.12; installs locust

Run a workflow

# Retriever (KP) — sync lookup queries, scalar parameters.tier per query
helmsdeep --targets kps \
--host https://your-retriever-service.example.org \
--csv-prefix run1
# Shepherd (ARA) — sync creative-mode (inferred) queries, cache bypassed
helmsdeep --targets aras \
--host https://your-ara-service.example.org \
--csv-prefix run1
# ARS — async submit/poll/merge of inferred queries (host = the ARS API base)
helmsdeep --targets ars \
--host https://ars.ci.transltr.io/ars/api \
--csv-prefix run1
# Pathfinder — its own heavier run type (ARA/ARS only); pins two endpoints and# asks for connecting paths. Sync via the ARA, async via the ARS.
helmsdeep --targets aras_pathfinder \
--host https://your-ara-service.example.org \
--csv-prefix pf1
helmsdeep --targets ars_pathfinder \
--host https://ars.ci.transltr.io/ars/api \
--csv-prefix pf1
# Mixed capacity profile (ARA/ARS only) — 2/3 inferred MVP1+MVP2, 1/3 Pathfinder# in one blended workload, ramped to 30 → 45 → 60 concurrent users and judged# against pass/fail acceptance checkpoints.
helmsdeep --targets aras_mixed \
--host https://your-ara-service.example.org \
--csv-prefix mix1
helmsdeep --targets ars_mixed \
--host https://ars.ci.transltr.io/ars/api \
--csv-prefix mix1
  • --targets selects the layer: kps (Retriever), aras (Shepherd), ars, the Pathfinder run types aras_pathfinder / ars_pathfinder (ARA/ARS only), or the mixed capacity profile aras_mixed / ars_mixed (ARA/ARS only — see "Mixed capacity profile").
  • --host is required — the base URL of the target service. For kps/aras the /query path is appended; for ars the host is the API base and the tool uses /submit then /messages/{pk}.
  • --csv-prefix is optional; it falls back to the LOCUST_CSV_PREFIX env var, then to trapi_run.
  • --time-budget / --quick compress a run to fit a wall clock — see "Shorter runs" below.

The load profile (users, spawn rate, duration) is driven by the StepLoad shape, not by CLI flags — so there is intentionally no -u/-r/-t. The ramp and knee threshold are per target in helmsdeep/config.py (stages and p99_slo_ms), since cost profiles differ wildly by layer; edit them there.

An optional per-target cooldown_s inserts a quiet gap between stages: users ramp to 0 so slow in-flight queries drain (counted under the stage that launched them) before the next stage starts clean, instead of bleeding into it. It defaults to 0 (cheap KP lookups don't bleed) and is set on the expensive ARA/ARS/Pathfinder targets.

Shorter runs (--time-budget / --quick)

A full run is long on purpose: slow queries need long holds to accumulate enough samples for a trustworthy percentile (ars_mixed takes ~70 minutes). When you don't need that — checking a host, corpus, or config change end to end, or taking a rough read — compress it:

# Smoke run: the whole ramp in about 10 minutes.
helmsdeep --targets ars_mixed --host https://ars.ci.transltr.io/ars/api --quick
# Or name your own budget: 30m, 1h, 90s, or plain seconds.
helmsdeep --targets aras_mixed --host https://your-ara.example.org --time-budget 30m

--quick is shorthand for --time-budget 10m. Both scale the durations — stage holds, cooldowns, and the poll/timeout caps that gate how long one query may run — by a single factor. The shape is untouched: same user counts, same stages, same SLOs, same checkpoints. So --targets ars_mixed --time-budget 30m still ramps 10 → 30 → 45 → 60 users and still judges all three checkpoints; each stage just gets ~6 minutes instead of ~15.

The CLI prints the compressed plan before it starts, so you know what you're committing to:

Compressed run: 1h10m -> 30m02s (time scale 0.43), budget 30m00s
Stages: 10u x 4m17s -> 30u x 6m26s -> 45u x 6m26s -> 60u x 6m26s, 2m09s cooldown between
Per-query cap cut to 4m17s -- a query slower than that is recorded as a
timeout failure, so error rates are not comparable to a full run's.

What you give up. Two things, and they matter:

  1. Sample count, and with it the tail. A p99 over a handful of queries is noise. The mean and p50 hold up far better than p95/p99.
  2. What counts as a failure. The per-query caps shrink with everything else, so a query that would have finished in 4 minutes is a timeout when the cap is 2. Error rates — and therefore checkpoint verdicts — are not comparable to a full run's.

Every output is stamped with config.time_scale (1.0 = a full run), and the printed summary leads with a warning banner. Don't quote a compressed run's knee as a capacity result, and don't gate CI on its checkpoints.

Compression only ever speeds a run up: a budget above the target's natural duration is ignored (kps already runs in 7 minutes, so --quick leaves it alone). Stages won't shrink below a 30-second floor, so a budget too small to fit them simply overruns — the CLI says so and prints the real projected duration.

Want each stage to get a specific amount of time? The budget is for the whole run, including cooldowns, so divide accordingly — or edit the per-target stages in config.py directly, which is the only way to change the shape (dropping the baseline stage from a mixed run, say, to give the three checkpoints 10 minutes each).

Outputs

Written to the working directory by the standalone/master node:

  • <prefix>_stages.csv — one row per stage (overall metrics), including a stage_start column with the stage's wall-clock start time (ISO 8601 UTC)
  • <prefix>_by_qtype.csv — one row per (stage, query type)
  • <prefix>_summary.json — config (including which target was measured and the time_scale it ran at), all stages, and the chosen knee (plus checkpoints for targets that define them)
  • <prefix>_checkpoints.csvtargets with acceptance criteria (aras_mixed / ars_mixed): one row per checkpoint with its PASS / FAIL verdict
  • <prefix>_ars_health.csvARS only: per-stage health signals (see below)
  • <prefix>_ars_queries.csvARS only: one row per logical query with its pk, the HTTP status of each step, the terminal ARS status, any intermediate (retried, non-fatal) errors it hit along the way, and a ready-made URL for pulling that query up (see below)
  • <prefix>_ars_completion.csvARS only: one row per logical query with its end-to-end response time and whether it eventually finished — polled past the max_poll_s failure threshold up to completion_max_poll_s (see below)
  • <prefix>_report.html — Locust's native, self-contained HTML report (latency-over-time charts, request/failure tables). Open it in any browser. See the caveat under "How to read the results": its totals are blended across the whole run, so the authoritative ceiling is the knee in summary.json, not the HTML aggregate.
  • a printed summary table ending in the headline max sustainable concurrency

For a guide to interpreting every one of these — written for non-specialists — see "How to read the results" below.

Mixed capacity profile (aras_mixed / ars_mixed)

Every other target characterizes one query class in isolation to find the knee. The mixed profile answers a different, operational question: can the system hold a target concurrency when both query classes arrive together, the way they do in production?

  • Corpus — one blended workload: 2/3 inferred MVP1+MVP2 creative queries and 1/3 Pathfinder. MVP1/MVP2 keep their relative SHEPHERD_CORPUS weights (so retuning that mix carries over); only the 2:1 split is imposed. by_qtype.csv still breaks the two classes out separately, so you can see which one drives the tail.

  • Ramp — a short low-load baseline stage, then the three concurrency levels the profile exists to judge: 30 → 45 → 60 simultaneous users. The baseline exists so a failure at 30 can be read against a healthy reference (is 30 slow, or is everything slow?).

  • Verdict — each level carries pass/fail acceptance checkpoints instead of only feeding the knee:

    UsersGoalCriteria
    30sustain peak loadp99 ≤ 300 000 ms and errors ≤ 1%
    45headroom above peakp99 ≤ 300 000 ms and errors ≤ 1%
    60no substantial failures under overloaderrors ≤ 5% (latency not judged — slowdown is expected here, failures are not)

The verdicts land in <prefix>_checkpoints.csv, in summary.json (checkpoints plus a single checkpoints_passed boolean), and in the printed report:

ACCEPTANCE CHECKPOINTS
usr p99 bar err% bar verdict goal
30 184203 300000 0.4 1.0 PASS sustain peak load
45 291560 300000 0.9 1.0 PASS headroom above peak
60 402118 n/a 3.1 5.0 PASS no substantial failures under overload
RESULT: all 3 checkpoints met.

A missed checkpoint prints why (-> p99 331402ms > 300000ms) and makes the run exit non-zero, so it can gate a CI/acceptance job. A checkpoint whose stage recorded no completed requests is reported as NO DATA rather than a silent pass. The knee is still computed and reported alongside, unchanged.

Checkpoints are a generic per-target feature (checkpoints in config.py), not special-cased to this profile: any target can define them, and targets that don't behave exactly as before. Edit the users, goals, and bars there — the ARA and ARS variants carry the same three checkpoints so the two layers are directly comparable.

Layering still applies:ars_mixed fans out to the ARAs, so run one or the other — never both at once.

ARS async workflow and health signals

The ars target is asynchronous: each logical query is POST /submit → poll GET /messages/{pk}?trace=y (every poll_interval_s, capped at max_poll_s, default 15 min) until status is Done/Error → fetch GET /messages/{merged_pk} and count fields.data.message.results. Latency is the wall-clock submit→terminal time; one measurement is recorded per logical query. A single ars_query Locust request event carries that full wall-clock, so Locust's native stats table shows the true per-query time — the individual ars_submit/ars_poll/ars_merge calls also appear there as per-step diagnostics (e.g. ars_merge times only the final merge fetch, not the whole query), but aren't double-counted as the query time.

Because the ARS is what real users hit, the run also captures health signals to flag silent downstream breakage, written to <prefix>_ars_health.csv and summary.json (ars_health + a human-readable red_flags list):

  • result-count variation (min/mean/max + coefficient of variation) across identical queries;
  • zero-result Done count — by default a Done with 0 results is treated as a failure (counts against the error rate and the knee) and flagged; the per-target zero_result_is_failure flag can score it as a success instead (see "Is an empty answer a failure?");
  • response size (merged-message bytes, mean/max);
  • result drop under load — flagged when the mean result count falls sharply as concurrency rises across stages.

Is an empty answer a failure?

A terminal Done that carries 0 results is scored as a failure by default: under load an empty answer set usually means a downstream agent silently dropped out, which is exactly what the knee should catch. But that conflates a silent break with a query whose answer set is legitimately empty, so it is a per-target switch in config.py:

"zero_result_is_failure": True, # the default; set False to score only# transport/protocol outcomes as failures

With False, only submit errors, an Error status, and Timeout count as failures. Note this shifts more than the error rate: a failed request contributes no latency sample, so a zero-result query that stops being a failure starts feeding the percentile pool — mean, p99, and therefore the Little's-Law concurrency all move too. The two policies give two different knees, which is the point: run both to see how much of your ceiling is empty answers versus broken transport.

Whichever policy is in force, the zero-result query is still counted in ars_health.csv (zero_result_done), still raises a red flag, and still carries the Done with 0 results note in the per-query debug log. The policy itself is recorded in summary.json (config.zero_result_is_failure) and printed above the ARS health table, so a run's numbers can't be read under the wrong assumption:

ARS HEALTH (per stage)
0-result 'Done' scored as: FAILURE (zero_result_is_failure=True)

Per-query debug log (ars_queries.csv)

The aggregate reports tell you that 3% of queries failed at 45 users. This file tells you which ones, and hands you what you need to go look at them: one row per logical ARS query, written for every query — successful, failed, or timed out.

ColumnWhat it's for
queryQuery id. Joins to ars_completion.csv on the same column.
pkThe ARS primary key. Empty only when the submit never returned one.
stage, usersWhich stage the query is attributed to (the stage active when it reached its outcome — the same rule the per-stage stats use).
qtypeWhich corpus query it was (mvp1_heavy, pathfinder_drug_disease, …).
submit_startWhen it was submitted (ISO 8601 UTC) — line it up against service logs.
latency_sWall clock from submit to terminal status.
ars_statusDone / Error / Timeout / SubmitError / NoPK.
failedWhether it counted against the error rate. A Done with 0 results is failed=True under the default zero_result_is_failure policy — see below.
submit_http, poll_http, merge_httpHTTP status of the submit, the last poll, and the merged fetch. Separates "the ARS said no" from "the HTTP call broke".
pollsHow many status polls it took — a large number on a slow query says it was genuinely working, not stuck.
intermediate_error_countHow many non-fatal errors the query hit on the way to its outcome (0 = it ran clean). The poll loop retries through these, so they never decide ars_status — but they say a query only limped to Done.
result_count, response_bytesSize of the merged answer.
intermediate_errorsThose same errors in words, tallied: poll HTTP 502 x3; merge HTTP 500. Covers polls that came back non-200 (poll request failed when there was no HTTP response at all), poll bodies that wouldn't parse, and a merged fetch that 500'd, went missing (Done without merged_version) or wouldn't parse. Empty on a clean query.
errorWhy it failed, in words — the terminal reason, empty on success (as opposed to intermediate_errors, which is the trouble on the way there). Also carries Done with 0 results when a target scores that as a success — the note is what makes the pk worth opening either way.
message_urlFull URL to that query's message. Paste it into a browser or curl it.

The printed summary also points straight at the failures, so you don't have to open the file to start:

FAILED QUERIES (12 of 380; first 5 shown, all in run1_ars_queries.csv)
stage 2 pathfinder_drug_disease [Timeout] pk=8f3c… -- no terminal status within 600s (last: Running)
https://ars.ci.transltr.io/ars/api/messages/8f3c…?trace=y

Intermediate errors get their own line, because most of the queries carrying them succeeded — they never appear in the failed block, and a run that only limped to green otherwise reads as clean:

INTERMEDIATE ERRORS: 34 of 380 queries hit retried, non-fatal errors (intermediate_errors column in run1_ars_queries.csv)
stage 4 mvp1_heavy [Done] pk=a01f… -- poll HTTP 502 x3

Useful slices, once you have the file:

# Every failed query, as a table
awk -F, 'NR==1 || $9=="True"' run1_ars_queries.csv | column -s, -t
# The pks of everything that timed out at the top stage
awk -F, '$8=="Timeout" && $4==60 {print $2}' run1_ars_queries.csv
# Slowest queries, with their URLs
sort -t, -k7 -gr run1_ars_queries.csv | head -5 | cut -d, -f2,7,8,19
# Queries that succeeded but hit non-fatal errors on the way
awk -F, 'NR==1 || ($14+0 > 0 && $9=="False")' run1_ars_queries.csv | column -s, -t

On the relationship to ars_completion.csv: this file records what happened within the measurement window (a query not terminal by max_poll_s is a Timeout row here, which is exactly how the knee scores it). The completion sidecar separately answers whether that same query eventually finished. Join them on query. A timed-out query whose extended poll was still running at shutdown appears here but not there — which is precisely the query you most want the pk of.

ARS completion tracking (ars_completion.csv)

max_poll_s (default 6 min for ars) is the failure threshold: a query that hasn't reached a terminal status by then is recorded as a Timeout failure in the main stats and the knee — unchanged. But a timed-out query isn't necessarily stuck; the ARS may still finish it, just slowly. To capture that, once a query blows max_poll_s a background poller keeps watching it up to completion_max_poll_s (default 10 min) and records the outcome to <prefix>_ars_completion.csv — one row per logical query:

  • total_response_s — true end-to-end submit→terminal time (up to completion_max_poll_s);
  • finished — whether it ever reached a terminal status (Done or Error);
  • within_slo — whether it finished inside max_poll_s (i.e. was not a Timeout failure in the main stats); a slow-but-eventually-done query is finished=True, within_slo=False;
  • status — the terminal status (Done/Error/Timeout/SubmitError/NoPK).

summary.json carries a completion roll-up (finished, finished-within-SLO, finished-after-SLO, never-finished, submit-failed), and two red flags surface the key distinctions: queries that exceeded the SLO but did finish (slow, not stuck) and queries that never finished even within completion_max_poll_s. This separates slow from broken — a service that eventually answers every query is degraded differently than one that drops them. Set completion_max_poll_s equal to (or omit it, defaulting to) max_poll_s to turn extended tracking off; the sidecar then just marks each timed-out query finished=False. Any query still being polled when the test ends is drained for a bounded window at shutdown; ones still unfinished after that are absent from the file.

How to read the results

This section is for anyone — technical or not — who has a run's output files in hand and wants to answer one question: how much load can this service take?

Plain-language key

A few terms show up everywhere in the outputs. Here's what each one means, without the jargon:

TermWhat it means
StageOne step of the ramp. Each stage pins a fixed number of simultaneous users for a fixed time, then the next stage adds more. The run climbs stage by stage until the service struggles.
Users / concurrencyHow many simultaneous users are hammering the service. "Users" is what we asked for in a stage; concurrency is the effective number actually in flight, computed from the measured throughput and speed.
RPSRequests Per Second — how many queries the service actually completed each second during that stage. Higher is faster.
LatencyHow long one query took, in milliseconds (1000 ms = 1 second).
mean / p50 / p95 / p99Different ways to summarize latency. mean is the average. p50 (median) is the typical query. p95 / p99 are the slow tail: "95% (or 99%) of queries were at least this fast." p99 is the one we hold to a standard, because it captures the bad experiences, not just the average.
Error rateThe fraction of queries that failed (e.g. 0.02 = 2%). For the ARS, a query that finishes but returns zero answers also counts as a failure by default — a per-target flag can change that, and the run says which policy applied.
SLOService Level Objective — the line in the sand for "acceptable." Here it's a p99 latency cap (e.g. 60000 ms = 60 s) plus a max error rate (default 1%). A stage "passes" only if it stays under both.
CheckpointA pass/fail question asked of one specific load level ("does 30 simultaneous users still work?"), with its own latency/error bars. Only the mixed capacity profile defines these; other runs just report the knee.
KneeThe highest-load stage that still passes the SLO. It's the headline result: the most simultaneous users the service handled while staying fast enough and reliable enough. Past the knee, things fall apart.

Start here: summary.json

Open <prefix>_summary.json first. Two fields tell you almost everything:

  • max_sustainable_concurrency — the headline number. This is the answer to "how much can this service take?"
  • knee — the stage that number came from (its users, latency, error rate, etc.). This is the last healthy stage of the ramp.

If knee is null (and max_sustainable_concurrency is empty), the service failed even at the lightest load — it was already too slow or too error-prone in the very first stage. That's a strong signal something is wrong (or the SLO is set tighter than the service can ever meet).

The same file also echoes the config that produced the run (which target, which endpoint, the SLO, the ramp stages) so a result is self-documenting.

On a run with acceptance criteria (aras_mixed / ars_mixed), two more fields answer the pass/fail question directly: checkpoints_passed (one boolean for the whole run) and checkpoints (per-level verdicts and why). See "Mixed capacity profile".

Reading stages.csv — the story of the ramp

<prefix>_stages.csv has one row per stage and shows the service degrading as load climbs. Here's an illustrative example (SLO: p99 ≤ 60 000 ms, errors ≤ 1%):

stageusersrpsmean_msp99_mserror_rateconcurrency
154.81 0402 1000.0005.0
2109.11 0903 4000.0009.9
32017.01 1709 8000.00419.9
44022.01 80041 0000.00839.6 ← knee
58019.04 20078 0000.140

How to read it:

  • Top to bottom = more load. Each stage adds users.
  • Watch two columns: p99_ms and error_rate. As long as both stay under the SLO (here 60 000 ms and 0.01), the service is coping.
  • The knee is the last "green" row — stage 4 above. Its concurrency (≈ 40) is the headline number.
  • The row after the knee shows the cliff: at stage 5, p99 latency blew past the 60 s cap and the error rate jumped to 14%. Notice RPS actually dropped (19 vs 22) even though we added users — a classic sign the service is saturated and thrashing, not going faster.

A healthy run shows latency rising gently and errors near zero until a clear knee, then a sharp cliff. If the very first row already breaks the SLO, you get no knee (see above).

Why not just trust Locust's own totals? During a ramp, an overall average would blend the easy early stages with the saturated late ones and hide the knee. That's why HelmsDeep reports per stage. (It's also why the HTML report's blended totals aren't the official number — see below.)

by_qtype.csv — which query is the bottleneck

<prefix>_by_qtype.csv breaks each stage down by type of query (e.g. simple single-hop lookups vs. heavier multi-hop or "what treats this disease?" queries). Use it to answer "what's slowing things down?" — often one query shape saturates long before the others, and that's where to focus.

ars_health.csv and red_flags (ARS runs only)

The ARS is what real users actually hit, and it can fail silently — a query can come back "successful" but with fewer answers than it should, or zero. <prefix>_ars_health.csv and the red_flags list in summary.json exist to catch exactly this. In plain terms they flag things like:

  • Answers quietly disappearing under load — the average number of results per query drops sharply as concurrency rises (something downstream is dropping out).
  • "Done" but empty — queries that finished but returned zero answers (counted as failures).
  • Wild result-count swings — identical queries returning very different amounts, a sign of instability.

If red_flags is non-empty, the service may be degrading in a way the raw latency/error numbers alone wouldn't reveal — read those flags.

The HTML report (<prefix>_report.html)

This is Locust's built-in report. Just double-click it to open in a browser. It gives you nice visuals: response-time and request-rate charts over the whole run, a table of every request type, and a list of any failures. It's great for a quick visual feel and for sharing a screenshot.

Important caveat: the HTML report's headline totals are blended across the entire run — it averages the gentle early stages together with the overwhelmed late ones. That makes its single "average response time" / "total RPS" numbers not the right ones to quote. For the official ceiling, always read the knee in summary.json (and the per-stage story in stages.csv). Think of the HTML as the pictures, and summary.json/stages.csv as the verdict.

Tuning notes

  • Swap the CURIEs. The corpus in helmsdeep/trapi_corpus.py uses a few real MONDO/CHEBI entities; replace them with entities your target service actually knows about, or queries return empty and won't reflect real cost.

  • Tier is per query (Retriever only). Retriever exposes parameters.tier (0 or 1) to pick its backend graph. RETRIEVER_CORPUS pairs multi-hop shapes with tier 0 and single-hop shapes with tier 1. ARA queries carry no tier.

  • Shepherd/ARS send inferred + bypass_cache, mixing MVP1 and MVP2.SHEPHERD_CORPUS (also used for ars) holds creative-mode queries (knowledge_type: "inferred", bypass_cache: true) split evenly between two Translator templates, with entities varied per request to spread load and avoid cache-warming. by_qtype.csv breaks out latency per template/tier.

    • MVP1 — "what treats disease X?" (chemical -[treats]-> disease): the pinned disease is sampled from size-tiered pools (heavy/medium/light), so cost tracks answer-set size. Heavy is a curated list of common disease hubs; the long-tail pool is ~1000 real MONDO CURIEs in curie_list.json.
    • MVP2 — chemical⇄gene "affects" (biolink:affects, inferred, with object_aspect/object_direction qualifiers): both edge directions (chemical→gene and gene→chemical), with the gene (curated GENES pool) and qualifier combo varied per request.

    These are far heavier than KP lookups, so the aras target ships a gentler ramp and a looser p99_slo_ms (see config.py). Tune the per-template weights and entity pools (HEAVY_DISEASES, GENES, the tiers) to your real traffic.

  • ARS reuses the Shepherd corpus (ARS_CORPUS = SHEPHERD_CORPUS) — the same inferred query the ARS fans out to its ARAs. Its poll cadence and per-query timeout (poll_interval_s, max_poll_s) are tunable in config.py.

  • Pathfinder is its own run type (aras_pathfinder / ars_pathfinder, ARA/ARS only). PATHFINDER_CORPUS sends a single drug↔disease shape that pins two endpoints and asks for connecting paths (a paths map in the query_graph, not edges); the (chemical, disease) pair varies per request from a curated CHEM_DISEASE_PAIRS list — swap these for pairs your service knows, and keep them plausibly connected so paths come back non-empty (on ARS a zero-result Done is a failure by default). It's the heaviest query class, so its targets ship the gentlest ramps and loosest SLOs (and, for ars_pathfinder, the longest max_poll_s); tune them in config.py.

  • The mixed profile blends the two ARA/ARS classes (aras_mixed / ars_mixed). MIXED_CORPUS is computed from SHEPHERD_CORPUS + PATHFINDER_CORPUS at the INFERRED_PATHFINDER_RATIO (2:1), so it tracks any retuning of the inferred mix. Change the blend by editing that ratio; change what counts as a pass by editing the per-target checkpoints in config.py. Because 1/3 of the mix is Pathfinder — the heaviest class — and p99 is a tail statistic, both mixed targets inherit the looser Pathfinder SLO. aras_mixed also raises request_timeout_s above the default 210 s: a sync target whose p99 SLO sits near the HTTP timeout records slow queries as client timeouts (errors) instead of latencies.

  • Adjust corpus weights in RETRIEVER_CORPUS / SHEPHERD_CORPUS to match your traffic mix.

Running the engine directly

You can also invoke the locustfile without the CLI (defaults to the kps target):

LOCUST_CSV_PREFIX=run1 \
locust -f helmsdeep/trapi_loadtest.py --headless \
--host https://your-retriever-service.example.org \
--html run1_report.html

The output-file prefix comes from the LOCUST_CSV_PREFIX env var (locust has no --csv-prefix flag). Set LOADTEST_TARGET to choose a different (implemented) layer. Add --html <name>.html yourself to get the HTML report (the helmsdeep CLI passes this for you automatically, as <prefix>_report.html).

About

Load Tester of Translator Services

Resources

Stars

0 stars

Watchers

4 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

45 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

HelmsDeep

HelmsDeepHTTP Endpoint Load Measurement System, Determining Each Endpoint's Performance — drives a stepped ramp of concurrent users against a single NCATS Translator component and reports the max sustainable concurrency (the "knee") — the highest load where the service still meets a latency/error SLO.

The Translator stack cascades ARS → ARAs → KPs, so a run targets exactly one layer at a time (testing a higher layer already loads everything beneath it). All three workflows are wired up: Retriever (KP), Shepherd (ARA), and the asynchronous ARS.

In plain language: HelmsDeep slowly turns up the number of simultaneous users hitting a service and watches when it starts getting too slow or too error-prone. The single headline number it reports — the knee, or "max sustainable concurrency" — is the largest number of simultaneous users the service handled while still staying fast enough and erroring rarely enough. Above that load, the service is overwhelmed. If you only read one thing, read the "How to read the results" section.

Install

pip install -e .# Python >= 3.12; installs locust

Run a workflow

# Retriever (KP) — sync lookup queries, scalar parameters.tier per query
helmsdeep --targets kps \
--host https://your-retriever-service.example.org \
--csv-prefix run1
# Shepherd (ARA) — sync creative-mode (inferred) queries, cache bypassed
helmsdeep --targets aras \
--host https://your-ara-service.example.org \
--csv-prefix run1
# ARS — async submit/poll/merge of inferred queries (host = the ARS API base)
helmsdeep --targets ars \
--host https://ars.ci.transltr.io/ars/api \
--csv-prefix run1
# Pathfinder — its own heavier run type (ARA/ARS only); pins two endpoints and# asks for connecting paths. Sync via the ARA, async via the ARS.
helmsdeep --targets aras_pathfinder \
--host https://your-ara-service.example.org \
--csv-prefix pf1
helmsdeep --targets ars_pathfinder \
--host https://ars.ci.transltr.io/ars/api \
--csv-prefix pf1
# Mixed capacity profile (ARA/ARS only) — 2/3 inferred MVP1+MVP2, 1/3 Pathfinder# in one blended workload, ramped to 30 → 45 → 60 concurrent users and judged# against pass/fail acceptance checkpoints.
helmsdeep --targets aras_mixed \
--host https://your-ara-service.example.org \
--csv-prefix mix1
helmsdeep --targets ars_mixed \
--host https://ars.ci.transltr.io/ars/api \
--csv-prefix mix1
  • --targets selects the layer: kps (Retriever), aras (Shepherd), ars, the Pathfinder run types aras_pathfinder / ars_pathfinder (ARA/ARS only), or the mixed capacity profile aras_mixed / ars_mixed (ARA/ARS only — see "Mixed capacity profile").
  • --host is required — the base URL of the target service. For kps/aras the /query path is appended; for ars the host is the API base and the tool uses /submit then /messages/{pk}.
  • --csv-prefix is optional; it falls back to the LOCUST_CSV_PREFIX env var, then to trapi_run.
  • --time-budget / --quick compress a run to fit a wall clock — see "Shorter runs" below.

The load profile (users, spawn rate, duration) is driven by the StepLoad shape, not by CLI flags — so there is intentionally no -u/-r/-t. The ramp and knee threshold are per target in helmsdeep/config.py (stages and p99_slo_ms), since cost profiles differ wildly by layer; edit them there.

An optional per-target cooldown_s inserts a quiet gap between stages: users ramp to 0 so slow in-flight queries drain (counted under the stage that launched them) before the next stage starts clean, instead of bleeding into it. It defaults to 0 (cheap KP lookups don't bleed) and is set on the expensive ARA/ARS/Pathfinder targets.

Shorter runs (--time-budget / --quick)

A full run is long on purpose: slow queries need long holds to accumulate enough samples for a trustworthy percentile (ars_mixed takes ~70 minutes). When you don't need that — checking a host, corpus, or config change end to end, or taking a rough read — compress it:

# Smoke run: the whole ramp in about 10 minutes.
helmsdeep --targets ars_mixed --host https://ars.ci.transltr.io/ars/api --quick
# Or name your own budget: 30m, 1h, 90s, or plain seconds.
helmsdeep --targets aras_mixed --host https://your-ara.example.org --time-budget 30m

--quick is shorthand for --time-budget 10m. Both scale the durations — stage holds, cooldowns, and the poll/timeout caps that gate how long one query may run — by a single factor. The shape is untouched: same user counts, same stages, same SLOs, same checkpoints. So --targets ars_mixed --time-budget 30m still ramps 10 → 30 → 45 → 60 users and still judges all three checkpoints; each stage just gets ~6 minutes instead of ~15.

The CLI prints the compressed plan before it starts, so you know what you're committing to:

Compressed run: 1h10m -> 30m02s (time scale 0.43), budget 30m00s
Stages: 10u x 4m17s -> 30u x 6m26s -> 45u x 6m26s -> 60u x 6m26s, 2m09s cooldown between
Per-query cap cut to 4m17s -- a query slower than that is recorded as a
timeout failure, so error rates are not comparable to a full run's.

What you give up. Two things, and they matter:

  1. Sample count, and with it the tail. A p99 over a handful of queries is noise. The mean and p50 hold up far better than p95/p99.
  2. What counts as a failure. The per-query caps shrink with everything else, so a query that would have finished in 4 minutes is a timeout when the cap is 2. Error rates — and therefore checkpoint verdicts — are not comparable to a full run's.

Every output is stamped with config.time_scale (1.0 = a full run), and the printed summary leads with a warning banner. Don't quote a compressed run's knee as a capacity result, and don't gate CI on its checkpoints.

Compression only ever speeds a run up: a budget above the target's natural duration is ignored (kps already runs in 7 minutes, so --quick leaves it alone). Stages won't shrink below a 30-second floor, so a budget too small to fit them simply overruns — the CLI says so and prints the real projected duration.

Want each stage to get a specific amount of time? The budget is for the whole run, including cooldowns, so divide accordingly — or edit the per-target stages in config.py directly, which is the only way to change the shape (dropping the baseline stage from a mixed run, say, to give the three checkpoints 10 minutes each).

Outputs

Written to the working directory by the standalone/master node:

  • <prefix>_stages.csv — one row per stage (overall metrics), including a stage_start column with the stage's wall-clock start time (ISO 8601 UTC)
  • <prefix>_by_qtype.csv — one row per (stage, query type)
  • <prefix>_summary.json — config (including which target was measured and the time_scale it ran at), all stages, and the chosen knee (plus checkpoints for targets that define them)
  • <prefix>_checkpoints.csvtargets with acceptance criteria (aras_mixed / ars_mixed): one row per checkpoint with its PASS / FAIL verdict
  • <prefix>_ars_health.csvARS only: per-stage health signals (see below)
  • <prefix>_ars_queries.csvARS only: one row per logical query with its pk, the HTTP status of each step, the terminal ARS status, any intermediate (retried, non-fatal) errors it hit along the way, and a ready-made URL for pulling that query up (see below)
  • <prefix>_ars_completion.csvARS only: one row per logical query with its end-to-end response time and whether it eventually finished — polled past the max_poll_s failure threshold up to completion_max_poll_s (see below)
  • <prefix>_report.html — Locust's native, self-contained HTML report (latency-over-time charts, request/failure tables). Open it in any browser. See the caveat under "How to read the results": its totals are blended across the whole run, so the authoritative ceiling is the knee in summary.json, not the HTML aggregate.
  • a printed summary table ending in the headline max sustainable concurrency

For a guide to interpreting every one of these — written for non-specialists — see "How to read the results" below.

Mixed capacity profile (aras_mixed / ars_mixed)

Every other target characterizes one query class in isolation to find the knee. The mixed profile answers a different, operational question: can the system hold a target concurrency when both query classes arrive together, the way they do in production?

  • Corpus — one blended workload: 2/3 inferred MVP1+MVP2 creative queries and 1/3 Pathfinder. MVP1/MVP2 keep their relative SHEPHERD_CORPUS weights (so retuning that mix carries over); only the 2:1 split is imposed. by_qtype.csv still breaks the two classes out separately, so you can see which one drives the tail.

  • Ramp — a short low-load baseline stage, then the three concurrency levels the profile exists to judge: 30 → 45 → 60 simultaneous users. The baseline exists so a failure at 30 can be read against a healthy reference (is 30 slow, or is everything slow?).

  • Verdict — each level carries pass/fail acceptance checkpoints instead of only feeding the knee:

    UsersGoalCriteria
    30sustain peak loadp99 ≤ 300 000 ms and errors ≤ 1%
    45headroom above peakp99 ≤ 300 000 ms and errors ≤ 1%
    60no substantial failures under overloaderrors ≤ 5% (latency not judged — slowdown is expected here, failures are not)

The verdicts land in <prefix>_checkpoints.csv, in summary.json (checkpoints plus a single checkpoints_passed boolean), and in the printed report:

ACCEPTANCE CHECKPOINTS
usr p99 bar err% bar verdict goal
30 184203 300000 0.4 1.0 PASS sustain peak load
45 291560 300000 0.9 1.0 PASS headroom above peak
60 402118 n/a 3.1 5.0 PASS no substantial failures under overload
RESULT: all 3 checkpoints met.

A missed checkpoint prints why (-> p99 331402ms > 300000ms) and makes the run exit non-zero, so it can gate a CI/acceptance job. A checkpoint whose stage recorded no completed requests is reported as NO DATA rather than a silent pass. The knee is still computed and reported alongside, unchanged.

Checkpoints are a generic per-target feature (checkpoints in config.py), not special-cased to this profile: any target can define them, and targets that don't behave exactly as before. Edit the users, goals, and bars there — the ARA and ARS variants carry the same three checkpoints so the two layers are directly comparable.

Layering still applies:ars_mixed fans out to the ARAs, so run one or the other — never both at once.

ARS async workflow and health signals

The ars target is asynchronous: each logical query is POST /submit → poll GET /messages/{pk}?trace=y (every poll_interval_s, capped at max_poll_s, default 15 min) until status is Done/Error → fetch GET /messages/{merged_pk} and count fields.data.message.results. Latency is the wall-clock submit→terminal time; one measurement is recorded per logical query. A single ars_query Locust request event carries that full wall-clock, so Locust's native stats table shows the true per-query time — the individual ars_submit/ars_poll/ars_merge calls also appear there as per-step diagnostics (e.g. ars_merge times only the final merge fetch, not the whole query), but aren't double-counted as the query time.

Because the ARS is what real users hit, the run also captures health signals to flag silent downstream breakage, written to <prefix>_ars_health.csv and summary.json (ars_health + a human-readable red_flags list):

  • result-count variation (min/mean/max + coefficient of variation) across identical queries;
  • zero-result Done count — by default a Done with 0 results is treated as a failure (counts against the error rate and the knee) and flagged; the per-target zero_result_is_failure flag can score it as a success instead (see "Is an empty answer a failure?");
  • response size (merged-message bytes, mean/max);
  • result drop under load — flagged when the mean result count falls sharply as concurrency rises across stages.

Is an empty answer a failure?

A terminal Done that carries 0 results is scored as a failure by default: under load an empty answer set usually means a downstream agent silently dropped out, which is exactly what the knee should catch. But that conflates a silent break with a query whose answer set is legitimately empty, so it is a per-target switch in config.py:

"zero_result_is_failure": True, # the default; set False to score only# transport/protocol outcomes as failures

With False, only submit errors, an Error status, and Timeout count as failures. Note this shifts more than the error rate: a failed request contributes no latency sample, so a zero-result query that stops being a failure starts feeding the percentile pool — mean, p99, and therefore the Little's-Law concurrency all move too. The two policies give two different knees, which is the point: run both to see how much of your ceiling is empty answers versus broken transport.

Whichever policy is in force, the zero-result query is still counted in ars_health.csv (zero_result_done), still raises a red flag, and still carries the Done with 0 results note in the per-query debug log. The policy itself is recorded in summary.json (config.zero_result_is_failure) and printed above the ARS health table, so a run's numbers can't be read under the wrong assumption:

ARS HEALTH (per stage)
0-result 'Done' scored as: FAILURE (zero_result_is_failure=True)

Per-query debug log (ars_queries.csv)

The aggregate reports tell you that 3% of queries failed at 45 users. This file tells you which ones, and hands you what you need to go look at them: one row per logical ARS query, written for every query — successful, failed, or timed out.

ColumnWhat it's for
queryQuery id. Joins to ars_completion.csv on the same column.
pkThe ARS primary key. Empty only when the submit never returned one.
stage, usersWhich stage the query is attributed to (the stage active when it reached its outcome — the same rule the per-stage stats use).
qtypeWhich corpus query it was (mvp1_heavy, pathfinder_drug_disease, …).
submit_startWhen it was submitted (ISO 8601 UTC) — line it up against service logs.
latency_sWall clock from submit to terminal status.
ars_statusDone / Error / Timeout / SubmitError / NoPK.
failedWhether it counted against the error rate. A Done with 0 results is failed=True under the default zero_result_is_failure policy — see below.
submit_http, poll_http, merge_httpHTTP status of the submit, the last poll, and the merged fetch. Separates "the ARS said no" from "the HTTP call broke".
pollsHow many status polls it took — a large number on a slow query says it was genuinely working, not stuck.
intermediate_error_countHow many non-fatal errors the query hit on the way to its outcome (0 = it ran clean). The poll loop retries through these, so they never decide ars_status — but they say a query only limped to Done.
result_count, response_bytesSize of the merged answer.
intermediate_errorsThose same errors in words, tallied: poll HTTP 502 x3; merge HTTP 500. Covers polls that came back non-200 (poll request failed when there was no HTTP response at all), poll bodies that wouldn't parse, and a merged fetch that 500'd, went missing (Done without merged_version) or wouldn't parse. Empty on a clean query.
errorWhy it failed, in words — the terminal reason, empty on success (as opposed to intermediate_errors, which is the trouble on the way there). Also carries Done with 0 results when a target scores that as a success — the note is what makes the pk worth opening either way.
message_urlFull URL to that query's message. Paste it into a browser or curl it.

The printed summary also points straight at the failures, so you don't have to open the file to start:

FAILED QUERIES (12 of 380; first 5 shown, all in run1_ars_queries.csv)
stage 2 pathfinder_drug_disease [Timeout] pk=8f3c… -- no terminal status within 600s (last: Running)
https://ars.ci.transltr.io/ars/api/messages/8f3c…?trace=y

Intermediate errors get their own line, because most of the queries carrying them succeeded — they never appear in the failed block, and a run that only limped to green otherwise reads as clean:

INTERMEDIATE ERRORS: 34 of 380 queries hit retried, non-fatal errors (intermediate_errors column in run1_ars_queries.csv)
stage 4 mvp1_heavy [Done] pk=a01f… -- poll HTTP 502 x3

Useful slices, once you have the file:

# Every failed query, as a table
awk -F, 'NR==1 || $9=="True"' run1_ars_queries.csv | column -s, -t
# The pks of everything that timed out at the top stage
awk -F, '$8=="Timeout" && $4==60 {print $2}' run1_ars_queries.csv
# Slowest queries, with their URLs
sort -t, -k7 -gr run1_ars_queries.csv | head -5 | cut -d, -f2,7,8,19
# Queries that succeeded but hit non-fatal errors on the way
awk -F, 'NR==1 || ($14+0 > 0 && $9=="False")' run1_ars_queries.csv | column -s, -t

On the relationship to ars_completion.csv: this file records what happened within the measurement window (a query not terminal by max_poll_s is a Timeout row here, which is exactly how the knee scores it). The completion sidecar separately answers whether that same query eventually finished. Join them on query. A timed-out query whose extended poll was still running at shutdown appears here but not there — which is precisely the query you most want the pk of.

ARS completion tracking (ars_completion.csv)

max_poll_s (default 6 min for ars) is the failure threshold: a query that hasn't reached a terminal status by then is recorded as a Timeout failure in the main stats and the knee — unchanged. But a timed-out query isn't necessarily stuck; the ARS may still finish it, just slowly. To capture that, once a query blows max_poll_s a background poller keeps watching it up to completion_max_poll_s (default 10 min) and records the outcome to <prefix>_ars_completion.csv — one row per logical query:

  • total_response_s — true end-to-end submit→terminal time (up to completion_max_poll_s);
  • finished — whether it ever reached a terminal status (Done or Error);
  • within_slo — whether it finished inside max_poll_s (i.e. was not a Timeout failure in the main stats); a slow-but-eventually-done query is finished=True, within_slo=False;
  • status — the terminal status (Done/Error/Timeout/SubmitError/NoPK).

summary.json carries a completion roll-up (finished, finished-within-SLO, finished-after-SLO, never-finished, submit-failed), and two red flags surface the key distinctions: queries that exceeded the SLO but did finish (slow, not stuck) and queries that never finished even within completion_max_poll_s. This separates slow from broken — a service that eventually answers every query is degraded differently than one that drops them. Set completion_max_poll_s equal to (or omit it, defaulting to) max_poll_s to turn extended tracking off; the sidecar then just marks each timed-out query finished=False. Any query still being polled when the test ends is drained for a bounded window at shutdown; ones still unfinished after that are absent from the file.

How to read the results

This section is for anyone — technical or not — who has a run's output files in hand and wants to answer one question: how much load can this service take?

Plain-language key

A few terms show up everywhere in the outputs. Here's what each one means, without the jargon:

TermWhat it means
StageOne step of the ramp. Each stage pins a fixed number of simultaneous users for a fixed time, then the next stage adds more. The run climbs stage by stage until the service struggles.
Users / concurrencyHow many simultaneous users are hammering the service. "Users" is what we asked for in a stage; concurrency is the effective number actually in flight, computed from the measured throughput and speed.
RPSRequests Per Second — how many queries the service actually completed each second during that stage. Higher is faster.
LatencyHow long one query took, in milliseconds (1000 ms = 1 second).
mean / p50 / p95 / p99Different ways to summarize latency. mean is the average. p50 (median) is the typical query. p95 / p99 are the slow tail: "95% (or 99%) of queries were at least this fast." p99 is the one we hold to a standard, because it captures the bad experiences, not just the average.
Error rateThe fraction of queries that failed (e.g. 0.02 = 2%). For the ARS, a query that finishes but returns zero answers also counts as a failure by default — a per-target flag can change that, and the run says which policy applied.
SLOService Level Objective — the line in the sand for "acceptable." Here it's a p99 latency cap (e.g. 60000 ms = 60 s) plus a max error rate (default 1%). A stage "passes" only if it stays under both.
CheckpointA pass/fail question asked of one specific load level ("does 30 simultaneous users still work?"), with its own latency/error bars. Only the mixed capacity profile defines these; other runs just report the knee.
KneeThe highest-load stage that still passes the SLO. It's the headline result: the most simultaneous users the service handled while staying fast enough and reliable enough. Past the knee, things fall apart.

Start here: summary.json

Open <prefix>_summary.json first. Two fields tell you almost everything:

  • max_sustainable_concurrency — the headline number. This is the answer to "how much can this service take?"
  • knee — the stage that number came from (its users, latency, error rate, etc.). This is the last healthy stage of the ramp.

If knee is null (and max_sustainable_concurrency is empty), the service failed even at the lightest load — it was already too slow or too error-prone in the very first stage. That's a strong signal something is wrong (or the SLO is set tighter than the service can ever meet).

The same file also echoes the config that produced the run (which target, which endpoint, the SLO, the ramp stages) so a result is self-documenting.

On a run with acceptance criteria (aras_mixed / ars_mixed), two more fields answer the pass/fail question directly: checkpoints_passed (one boolean for the whole run) and checkpoints (per-level verdicts and why). See "Mixed capacity profile".

Reading stages.csv — the story of the ramp

<prefix>_stages.csv has one row per stage and shows the service degrading as load climbs. Here's an illustrative example (SLO: p99 ≤ 60 000 ms, errors ≤ 1%):

stageusersrpsmean_msp99_mserror_rateconcurrency
154.81 0402 1000.0005.0
2109.11 0903 4000.0009.9
32017.01 1709 8000.00419.9
44022.01 80041 0000.00839.6 ← knee
58019.04 20078 0000.140

How to read it:

  • Top to bottom = more load. Each stage adds users.
  • Watch two columns: p99_ms and error_rate. As long as both stay under the SLO (here 60 000 ms and 0.01), the service is coping.
  • The knee is the last "green" row — stage 4 above. Its concurrency (≈ 40) is the headline number.
  • The row after the knee shows the cliff: at stage 5, p99 latency blew past the 60 s cap and the error rate jumped to 14%. Notice RPS actually dropped (19 vs 22) even though we added users — a classic sign the service is saturated and thrashing, not going faster.

A healthy run shows latency rising gently and errors near zero until a clear knee, then a sharp cliff. If the very first row already breaks the SLO, you get no knee (see above).

Why not just trust Locust's own totals? During a ramp, an overall average would blend the easy early stages with the saturated late ones and hide the knee. That's why HelmsDeep reports per stage. (It's also why the HTML report's blended totals aren't the official number — see below.)

by_qtype.csv — which query is the bottleneck

<prefix>_by_qtype.csv breaks each stage down by type of query (e.g. simple single-hop lookups vs. heavier multi-hop or "what treats this disease?" queries). Use it to answer "what's slowing things down?" — often one query shape saturates long before the others, and that's where to focus.

ars_health.csv and red_flags (ARS runs only)

The ARS is what real users actually hit, and it can fail silently — a query can come back "successful" but with fewer answers than it should, or zero. <prefix>_ars_health.csv and the red_flags list in summary.json exist to catch exactly this. In plain terms they flag things like:

  • Answers quietly disappearing under load — the average number of results per query drops sharply as concurrency rises (something downstream is dropping out).
  • "Done" but empty — queries that finished but returned zero answers (counted as failures).
  • Wild result-count swings — identical queries returning very different amounts, a sign of instability.

If red_flags is non-empty, the service may be degrading in a way the raw latency/error numbers alone wouldn't reveal — read those flags.

The HTML report (<prefix>_report.html)

This is Locust's built-in report. Just double-click it to open in a browser. It gives you nice visuals: response-time and request-rate charts over the whole run, a table of every request type, and a list of any failures. It's great for a quick visual feel and for sharing a screenshot.

Important caveat: the HTML report's headline totals are blended across the entire run — it averages the gentle early stages together with the overwhelmed late ones. That makes its single "average response time" / "total RPS" numbers not the right ones to quote. For the official ceiling, always read the knee in summary.json (and the per-stage story in stages.csv). Think of the HTML as the pictures, and summary.json/stages.csv as the verdict.

Tuning notes

  • Swap the CURIEs. The corpus in helmsdeep/trapi_corpus.py uses a few real MONDO/CHEBI entities; replace them with entities your target service actually knows about, or queries return empty and won't reflect real cost.

  • Tier is per query (Retriever only). Retriever exposes parameters.tier (0 or 1) to pick its backend graph. RETRIEVER_CORPUS pairs multi-hop shapes with tier 0 and single-hop shapes with tier 1. ARA queries carry no tier.

  • Shepherd/ARS send inferred + bypass_cache, mixing MVP1 and MVP2.SHEPHERD_CORPUS (also used for ars) holds creative-mode queries (knowledge_type: "inferred", bypass_cache: true) split evenly between two Translator templates, with entities varied per request to spread load and avoid cache-warming. by_qtype.csv breaks out latency per template/tier.

    • MVP1 — "what treats disease X?" (chemical -[treats]-> disease): the pinned disease is sampled from size-tiered pools (heavy/medium/light), so cost tracks answer-set size. Heavy is a curated list of common disease hubs; the long-tail pool is ~1000 real MONDO CURIEs in curie_list.json.
    • MVP2 — chemical⇄gene "affects" (biolink:affects, inferred, with object_aspect/object_direction qualifiers): both edge directions (chemical→gene and gene→chemical), with the gene (curated GENES pool) and qualifier combo varied per request.

    These are far heavier than KP lookups, so the aras target ships a gentler ramp and a looser p99_slo_ms (see config.py). Tune the per-template weights and entity pools (HEAVY_DISEASES, GENES, the tiers) to your real traffic.

  • ARS reuses the Shepherd corpus (ARS_CORPUS = SHEPHERD_CORPUS) — the same inferred query the ARS fans out to its ARAs. Its poll cadence and per-query timeout (poll_interval_s, max_poll_s) are tunable in config.py.

  • Pathfinder is its own run type (aras_pathfinder / ars_pathfinder, ARA/ARS only). PATHFINDER_CORPUS sends a single drug↔disease shape that pins two endpoints and asks for connecting paths (a paths map in the query_graph, not edges); the (chemical, disease) pair varies per request from a curated CHEM_DISEASE_PAIRS list — swap these for pairs your service knows, and keep them plausibly connected so paths come back non-empty (on ARS a zero-result Done is a failure by default). It's the heaviest query class, so its targets ship the gentlest ramps and loosest SLOs (and, for ars_pathfinder, the longest max_poll_s); tune them in config.py.

  • The mixed profile blends the two ARA/ARS classes (aras_mixed / ars_mixed). MIXED_CORPUS is computed from SHEPHERD_CORPUS + PATHFINDER_CORPUS at the INFERRED_PATHFINDER_RATIO (2:1), so it tracks any retuning of the inferred mix. Change the blend by editing that ratio; change what counts as a pass by editing the per-target checkpoints in config.py. Because 1/3 of the mix is Pathfinder — the heaviest class — and p99 is a tail statistic, both mixed targets inherit the looser Pathfinder SLO. aras_mixed also raises request_timeout_s above the default 210 s: a sync target whose p99 SLO sits near the HTTP timeout records slow queries as client timeouts (errors) instead of latencies.

  • Adjust corpus weights in RETRIEVER_CORPUS / SHEPHERD_CORPUS to match your traffic mix.

Running the engine directly

You can also invoke the locustfile without the CLI (defaults to the kps target):

LOCUST_CSV_PREFIX=run1 \
locust -f helmsdeep/trapi_loadtest.py --headless \
--host https://your-retriever-service.example.org \
--html run1_report.html

The output-file prefix comes from the LOCUST_CSV_PREFIX env var (locust has no --csv-prefix flag). Set LOADTEST_TARGET to choose a different (implemented) layer. Add --html <name>.html yourself to get the HTML report (the helmsdeep CLI passes this for you automatically, as <prefix>_report.html).

About

Load Tester of Translator Services

Resources

Stars

0 stars

Watchers

4 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Latest commit

History

45 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

HelmsDeep

HelmsDeepHTTP Endpoint Load Measurement System, Determining Each Endpoint's Performance — drives a stepped ramp of concurrent users against a single NCATS Translator component and reports the max sustainable concurrency (the "knee") — the highest load where the service still meets a latency/error SLO.

The Translator stack cascades ARS → ARAs → KPs, so a run targets exactly one layer at a time (testing a higher layer already loads everything beneath it). All three workflows are wired up: Retriever (KP), Shepherd (ARA), and the asynchronous ARS.

In plain language: HelmsDeep slowly turns up the number of simultaneous users hitting a service and watches when it starts getting too slow or too error-prone. The single headline number it reports — the knee, or "max sustainable concurrency" — is the largest number of simultaneous users the service handled while still staying fast enough and erroring rarely enough. Above that load, the service is overwhelmed. If you only read one thing, read the "How to read the results" section.

Install

pip install -e .# Python >= 3.12; installs locust

Run a workflow

# Retriever (KP) — sync lookup queries, scalar parameters.tier per query
helmsdeep --targets kps \
--host https://your-retriever-service.example.org \
--csv-prefix run1
# Shepherd (ARA) — sync creative-mode (inferred) queries, cache bypassed
helmsdeep --targets aras \
--host https://your-ara-service.example.org \
--csv-prefix run1
# ARS — async submit/poll/merge of inferred queries (host = the ARS API base)
helmsdeep --targets ars \
--host https://ars.ci.transltr.io/ars/api \
--csv-prefix run1
# Pathfinder — its own heavier run type (ARA/ARS only); pins two endpoints and# asks for connecting paths. Sync via the ARA, async via the ARS.
helmsdeep --targets aras_pathfinder \
--host https://your-ara-service.example.org \
--csv-prefix pf1
helmsdeep --targets ars_pathfinder \
--host https://ars.ci.transltr.io/ars/api \
--csv-prefix pf1
# Mixed capacity profile (ARA/ARS only) — 2/3 inferred MVP1+MVP2, 1/3 Pathfinder# in one blended workload, ramped to 30 → 45 → 60 concurrent users and judged# against pass/fail acceptance checkpoints.
helmsdeep --targets aras_mixed \
--host https://your-ara-service.example.org \
--csv-prefix mix1
helmsdeep --targets ars_mixed \
--host https://ars.ci.transltr.io/ars/api \
--csv-prefix mix1
  • --targets selects the layer: kps (Retriever), aras (Shepherd), ars, the Pathfinder run types aras_pathfinder / ars_pathfinder (ARA/ARS only), or the mixed capacity profile aras_mixed / ars_mixed (ARA/ARS only — see "Mixed capacity profile").
  • --host is required — the base URL of the target service. For kps/aras the /query path is appended; for ars the host is the API base and the tool uses /submit then /messages/{pk}.
  • --csv-prefix is optional; it falls back to the LOCUST_CSV_PREFIX env var, then to trapi_run.
  • --time-budget / --quick compress a run to fit a wall clock — see "Shorter runs" below.

The load profile (users, spawn rate, duration) is driven by the StepLoad shape, not by CLI flags — so there is intentionally no -u/-r/-t. The ramp and knee threshold are per target in helmsdeep/config.py (stages and p99_slo_ms), since cost profiles differ wildly by layer; edit them there.

An optional per-target cooldown_s inserts a quiet gap between stages: users ramp to 0 so slow in-flight queries drain (counted under the stage that launched them) before the next stage starts clean, instead of bleeding into it. It defaults to 0 (cheap KP lookups don't bleed) and is set on the expensive ARA/ARS/Pathfinder targets.

Shorter runs (--time-budget / --quick)

A full run is long on purpose: slow queries need long holds to accumulate enough samples for a trustworthy percentile (ars_mixed takes ~70 minutes). When you don't need that — checking a host, corpus, or config change end to end, or taking a rough read — compress it:

# Smoke run: the whole ramp in about 10 minutes.
helmsdeep --targets ars_mixed --host https://ars.ci.transltr.io/ars/api --quick
# Or name your own budget: 30m, 1h, 90s, or plain seconds.
helmsdeep --targets aras_mixed --host https://your-ara.example.org --time-budget 30m

--quick is shorthand for --time-budget 10m. Both scale the durations — stage holds, cooldowns, and the poll/timeout caps that gate how long one query may run — by a single factor. The shape is untouched: same user counts, same stages, same SLOs, same checkpoints. So --targets ars_mixed --time-budget 30m still ramps 10 → 30 → 45 → 60 users and still judges all three checkpoints; each stage just gets ~6 minutes instead of ~15.

The CLI prints the compressed plan before it starts, so you know what you're committing to:

Compressed run: 1h10m -> 30m02s (time scale 0.43), budget 30m00s
Stages: 10u x 4m17s -> 30u x 6m26s -> 45u x 6m26s -> 60u x 6m26s, 2m09s cooldown between
Per-query cap cut to 4m17s -- a query slower than that is recorded as a
timeout failure, so error rates are not comparable to a full run's.

What you give up. Two things, and they matter:

  1. Sample count, and with it the tail. A p99 over a handful of queries is noise. The mean and p50 hold up far better than p95/p99.
  2. What counts as a failure. The per-query caps shrink with everything else, so a query that would have finished in 4 minutes is a timeout when the cap is 2. Error rates — and therefore checkpoint verdicts — are not comparable to a full run's.

Every output is stamped with config.time_scale (1.0 = a full run), and the printed summary leads with a warning banner. Don't quote a compressed run's knee as a capacity result, and don't gate CI on its checkpoints.

Compression only ever speeds a run up: a budget above the target's natural duration is ignored (kps already runs in 7 minutes, so --quick leaves it alone). Stages won't shrink below a 30-second floor, so a budget too small to fit them simply overruns — the CLI says so and prints the real projected duration.

Want each stage to get a specific amount of time? The budget is for the whole run, including cooldowns, so divide accordingly — or edit the per-target stages in config.py directly, which is the only way to change the shape (dropping the baseline stage from a mixed run, say, to give the three checkpoints 10 minutes each).

Outputs

Written to the working directory by the standalone/master node:

  • <prefix>_stages.csv — one row per stage (overall metrics), including a stage_start column with the stage's wall-clock start time (ISO 8601 UTC)
  • <prefix>_by_qtype.csv — one row per (stage, query type)
  • <prefix>_summary.json — config (including which target was measured and the time_scale it ran at), all stages, and the chosen knee (plus checkpoints for targets that define them)
  • <prefix>_checkpoints.csvtargets with acceptance criteria (aras_mixed / ars_mixed): one row per checkpoint with its PASS / FAIL verdict
  • <prefix>_ars_health.csvARS only: per-stage health signals (see below)
  • <prefix>_ars_queries.csvARS only: one row per logical query with its pk, the HTTP status of each step, the terminal ARS status, any intermediate (retried, non-fatal) errors it hit along the way, and a ready-made URL for pulling that query up (see below)
  • <prefix>_ars_completion.csvARS only: one row per logical query with its end-to-end response time and whether it eventually finished — polled past the max_poll_s failure threshold up to completion_max_poll_s (see below)
  • <prefix>_report.html — Locust's native, self-contained HTML report (latency-over-time charts, request/failure tables). Open it in any browser. See the caveat under "How to read the results": its totals are blended across the whole run, so the authoritative ceiling is the knee in summary.json, not the HTML aggregate.
  • a printed summary table ending in the headline max sustainable concurrency

For a guide to interpreting every one of these — written for non-specialists — see "How to read the results" below.

Mixed capacity profile (aras_mixed / ars_mixed)

Every other target characterizes one query class in isolation to find the knee. The mixed profile answers a different, operational question: can the system hold a target concurrency when both query classes arrive together, the way they do in production?

  • Corpus — one blended workload: 2/3 inferred MVP1+MVP2 creative queries and 1/3 Pathfinder. MVP1/MVP2 keep their relative SHEPHERD_CORPUS weights (so retuning that mix carries over); only the 2:1 split is imposed. by_qtype.csv still breaks the two classes out separately, so you can see which one drives the tail.

  • Ramp — a short low-load baseline stage, then the three concurrency levels the profile exists to judge: 30 → 45 → 60 simultaneous users. The baseline exists so a failure at 30 can be read against a healthy reference (is 30 slow, or is everything slow?).

  • Verdict — each level carries pass/fail acceptance checkpoints instead of only feeding the knee:

    UsersGoalCriteria
    30sustain peak loadp99 ≤ 300 000 ms and errors ≤ 1%
    45headroom above peakp99 ≤ 300 000 ms and errors ≤ 1%
    60no substantial failures under overloaderrors ≤ 5% (latency not judged — slowdown is expected here, failures are not)

The verdicts land in <prefix>_checkpoints.csv, in summary.json (checkpoints plus a single checkpoints_passed boolean), and in the printed report:

ACCEPTANCE CHECKPOINTS
usr p99 bar err% bar verdict goal
30 184203 300000 0.4 1.0 PASS sustain peak load
45 291560 300000 0.9 1.0 PASS headroom above peak
60 402118 n/a 3.1 5.0 PASS no substantial failures under overload
RESULT: all 3 checkpoints met.

A missed checkpoint prints why (-> p99 331402ms > 300000ms) and makes the run exit non-zero, so it can gate a CI/acceptance job. A checkpoint whose stage recorded no completed requests is reported as NO DATA rather than a silent pass. The knee is still computed and reported alongside, unchanged.

Checkpoints are a generic per-target feature (checkpoints in config.py), not special-cased to this profile: any target can define them, and targets that don't behave exactly as before. Edit the users, goals, and bars there — the ARA and ARS variants carry the same three checkpoints so the two layers are directly comparable.

Layering still applies:ars_mixed fans out to the ARAs, so run one or the other — never both at once.

ARS async workflow and health signals

The ars target is asynchronous: each logical query is POST /submit → poll GET /messages/{pk}?trace=y (every poll_interval_s, capped at max_poll_s, default 15 min) until status is Done/Error → fetch GET /messages/{merged_pk} and count fields.data.message.results. Latency is the wall-clock submit→terminal time; one measurement is recorded per logical query. A single ars_query Locust request event carries that full wall-clock, so Locust's native stats table shows the true per-query time — the individual ars_submit/ars_poll/ars_merge calls also appear there as per-step diagnostics (e.g. ars_merge times only the final merge fetch, not the whole query), but aren't double-counted as the query time.

Because the ARS is what real users hit, the run also captures health signals to flag silent downstream breakage, written to <prefix>_ars_health.csv and summary.json (ars_health + a human-readable red_flags list):

  • result-count variation (min/mean/max + coefficient of variation) across identical queries;
  • zero-result Done count — by default a Done with 0 results is treated as a failure (counts against the error rate and the knee) and flagged; the per-target zero_result_is_failure flag can score it as a success instead (see "Is an empty answer a failure?");
  • response size (merged-message bytes, mean/max);
  • result drop under load — flagged when the mean result count falls sharply as concurrency rises across stages.

Is an empty answer a failure?

A terminal Done that carries 0 results is scored as a failure by default: under load an empty answer set usually means a downstream agent silently dropped out, which is exactly what the knee should catch. But that conflates a silent break with a query whose answer set is legitimately empty, so it is a per-target switch in config.py:

"zero_result_is_failure": True, # the default; set False to score only# transport/protocol outcomes as failures

With False, only submit errors, an Error status, and Timeout count as failures. Note this shifts more than the error rate: a failed request contributes no latency sample, so a zero-result query that stops being a failure starts feeding the percentile pool — mean, p99, and therefore the Little's-Law concurrency all move too. The two policies give two different knees, which is the point: run both to see how much of your ceiling is empty answers versus broken transport.

Whichever policy is in force, the zero-result query is still counted in ars_health.csv (zero_result_done), still raises a red flag, and still carries the Done with 0 results note in the per-query debug log. The policy itself is recorded in summary.json (config.zero_result_is_failure) and printed above the ARS health table, so a run's numbers can't be read under the wrong assumption:

ARS HEALTH (per stage)
0-result 'Done' scored as: FAILURE (zero_result_is_failure=True)

Per-query debug log (ars_queries.csv)

The aggregate reports tell you that 3% of queries failed at 45 users. This file tells you which ones, and hands you what you need to go look at them: one row per logical ARS query, written for every query — successful, failed, or timed out.

ColumnWhat it's for
queryQuery id. Joins to ars_completion.csv on the same column.
pkThe ARS primary key. Empty only when the submit never returned one.
stage, usersWhich stage the query is attributed to (the stage active when it reached its outcome — the same rule the per-stage stats use).
qtypeWhich corpus query it was (mvp1_heavy, pathfinder_drug_disease, …).
submit_startWhen it was submitted (ISO 8601 UTC) — line it up against service logs.
latency_sWall clock from submit to terminal status.
ars_statusDone / Error / Timeout / SubmitError / NoPK.
failedWhether it counted against the error rate. A Done with 0 results is failed=True under the default zero_result_is_failure policy — see below.
submit_http, poll_http, merge_httpHTTP status of the submit, the last poll, and the merged fetch. Separates "the ARS said no" from "the HTTP call broke".
pollsHow many status polls it took — a large number on a slow query says it was genuinely working, not stuck.
intermediate_error_countHow many non-fatal errors the query hit on the way to its outcome (0 = it ran clean). The poll loop retries through these, so they never decide ars_status — but they say a query only limped to Done.
result_count, response_bytesSize of the merged answer.
intermediate_errorsThose same errors in words, tallied: poll HTTP 502 x3; merge HTTP 500. Covers polls that came back non-200 (poll request failed when there was no HTTP response at all), poll bodies that wouldn't parse, and a merged fetch that 500'd, went missing (Done without merged_version) or wouldn't parse. Empty on a clean query.
errorWhy it failed, in words — the terminal reason, empty on success (as opposed to intermediate_errors, which is the trouble on the way there). Also carries Done with 0 results when a target scores that as a success — the note is what makes the pk worth opening either way.
message_urlFull URL to that query's message. Paste it into a browser or curl it.

The printed summary also points straight at the failures, so you don't have to open the file to start:

FAILED QUERIES (12 of 380; first 5 shown, all in run1_ars_queries.csv)
stage 2 pathfinder_drug_disease [Timeout] pk=8f3c… -- no terminal status within 600s (last: Running)
https://ars.ci.transltr.io/ars/api/messages/8f3c…?trace=y

Intermediate errors get their own line, because most of the queries carrying them succeeded — they never appear in the failed block, and a run that only limped to green otherwise reads as clean:

INTERMEDIATE ERRORS: 34 of 380 queries hit retried, non-fatal errors (intermediate_errors column in run1_ars_queries.csv)
stage 4 mvp1_heavy [Done] pk=a01f… -- poll HTTP 502 x3

Useful slices, once you have the file:

# Every failed query, as a table
awk -F, 'NR==1 || $9=="True"' run1_ars_queries.csv | column -s, -t
# The pks of everything that timed out at the top stage
awk -F, '$8=="Timeout" && $4==60 {print $2}' run1_ars_queries.csv
# Slowest queries, with their URLs
sort -t, -k7 -gr run1_ars_queries.csv | head -5 | cut -d, -f2,7,8,19
# Queries that succeeded but hit non-fatal errors on the way
awk -F, 'NR==1 || ($14+0 > 0 && $9=="False")' run1_ars_queries.csv | column -s, -t

On the relationship to ars_completion.csv: this file records what happened within the measurement window (a query not terminal by max_poll_s is a Timeout row here, which is exactly how the knee scores it). The completion sidecar separately answers whether that same query eventually finished. Join them on query. A timed-out query whose extended poll was still running at shutdown appears here but not there — which is precisely the query you most want the pk of.

ARS completion tracking (ars_completion.csv)

max_poll_s (default 6 min for ars) is the failure threshold: a query that hasn't reached a terminal status by then is recorded as a Timeout failure in the main stats and the knee — unchanged. But a timed-out query isn't necessarily stuck; the ARS may still finish it, just slowly. To capture that, once a query blows max_poll_s a background poller keeps watching it up to completion_max_poll_s (default 10 min) and records the outcome to <prefix>_ars_completion.csv — one row per logical query:

  • total_response_s — true end-to-end submit→terminal time (up to completion_max_poll_s);
  • finished — whether it ever reached a terminal status (Done or Error);
  • within_slo — whether it finished inside max_poll_s (i.e. was not a Timeout failure in the main stats); a slow-but-eventually-done query is finished=True, within_slo=False;
  • status — the terminal status (Done/Error/Timeout/SubmitError/NoPK).

summary.json carries a completion roll-up (finished, finished-within-SLO, finished-after-SLO, never-finished, submit-failed), and two red flags surface the key distinctions: queries that exceeded the SLO but did finish (slow, not stuck) and queries that never finished even within completion_max_poll_s. This separates slow from broken — a service that eventually answers every query is degraded differently than one that drops them. Set completion_max_poll_s equal to (or omit it, defaulting to) max_poll_s to turn extended tracking off; the sidecar then just marks each timed-out query finished=False. Any query still being polled when the test ends is drained for a bounded window at shutdown; ones still unfinished after that are absent from the file.

How to read the results

This section is for anyone — technical or not — who has a run's output files in hand and wants to answer one question: how much load can this service take?

Plain-language key

A few terms show up everywhere in the outputs. Here's what each one means, without the jargon:

TermWhat it means
StageOne step of the ramp. Each stage pins a fixed number of simultaneous users for a fixed time, then the next stage adds more. The run climbs stage by stage until the service struggles.
Users / concurrencyHow many simultaneous users are hammering the service. "Users" is what we asked for in a stage; concurrency is the effective number actually in flight, computed from the measured throughput and speed.
RPSRequests Per Second — how many queries the service actually completed each second during that stage. Higher is faster.
LatencyHow long one query took, in milliseconds (1000 ms = 1 second).
mean / p50 / p95 / p99Different ways to summarize latency. mean is the average. p50 (median) is the typical query. p95 / p99 are the slow tail: "95% (or 99%) of queries were at least this fast." p99 is the one we hold to a standard, because it captures the bad experiences, not just the average.
Error rateThe fraction of queries that failed (e.g. 0.02 = 2%). For the ARS, a query that finishes but returns zero answers also counts as a failure by default — a per-target flag can change that, and the run says which policy applied.
SLOService Level Objective — the line in the sand for "acceptable." Here it's a p99 latency cap (e.g. 60000 ms = 60 s) plus a max error rate (default 1%). A stage "passes" only if it stays under both.
CheckpointA pass/fail question asked of one specific load level ("does 30 simultaneous users still work?"), with its own latency/error bars. Only the mixed capacity profile defines these; other runs just report the knee.
KneeThe highest-load stage that still passes the SLO. It's the headline result: the most simultaneous users the service handled while staying fast enough and reliable enough. Past the knee, things fall apart.

Start here: summary.json

Open <prefix>_summary.json first. Two fields tell you almost everything:

  • max_sustainable_concurrency — the headline number. This is the answer to "how much can this service take?"
  • knee — the stage that number came from (its users, latency, error rate, etc.). This is the last healthy stage of the ramp.

If knee is null (and max_sustainable_concurrency is empty), the service failed even at the lightest load — it was already too slow or too error-prone in the very first stage. That's a strong signal something is wrong (or the SLO is set tighter than the service can ever meet).

The same file also echoes the config that produced the run (which target, which endpoint, the SLO, the ramp stages) so a result is self-documenting.

On a run with acceptance criteria (aras_mixed / ars_mixed), two more fields answer the pass/fail question directly: checkpoints_passed (one boolean for the whole run) and checkpoints (per-level verdicts and why). See "Mixed capacity profile".

Reading stages.csv — the story of the ramp

<prefix>_stages.csv has one row per stage and shows the service degrading as load climbs. Here's an illustrative example (SLO: p99 ≤ 60 000 ms, errors ≤ 1%):

stageusersrpsmean_msp99_mserror_rateconcurrency
154.81 0402 1000.0005.0
2109.11 0903 4000.0009.9
32017.01 1709 8000.00419.9
44022.01 80041 0000.00839.6 ← knee
58019.04 20078 0000.140

How to read it:

  • Top to bottom = more load. Each stage adds users.
  • Watch two columns: p99_ms and error_rate. As long as both stay under the SLO (here 60 000 ms and 0.01), the service is coping.
  • The knee is the last "green" row — stage 4 above. Its concurrency (≈ 40) is the headline number.
  • The row after the knee shows the cliff: at stage 5, p99 latency blew past the 60 s cap and the error rate jumped to 14%. Notice RPS actually dropped (19 vs 22) even though we added users — a classic sign the service is saturated and thrashing, not going faster.

A healthy run shows latency rising gently and errors near zero until a clear knee, then a sharp cliff. If the very first row already breaks the SLO, you get no knee (see above).

Why not just trust Locust's own totals? During a ramp, an overall average would blend the easy early stages with the saturated late ones and hide the knee. That's why HelmsDeep reports per stage. (It's also why the HTML report's blended totals aren't the official number — see below.)

by_qtype.csv — which query is the bottleneck

<prefix>_by_qtype.csv breaks each stage down by type of query (e.g. simple single-hop lookups vs. heavier multi-hop or "what treats this disease?" queries). Use it to answer "what's slowing things down?" — often one query shape saturates long before the others, and that's where to focus.

ars_health.csv and red_flags (ARS runs only)

The ARS is what real users actually hit, and it can fail silently — a query can come back "successful" but with fewer answers than it should, or zero. <prefix>_ars_health.csv and the red_flags list in summary.json exist to catch exactly this. In plain terms they flag things like:

  • Answers quietly disappearing under load — the average number of results per query drops sharply as concurrency rises (something downstream is dropping out).
  • "Done" but empty — queries that finished but returned zero answers (counted as failures).
  • Wild result-count swings — identical queries returning very different amounts, a sign of instability.

If red_flags is non-empty, the service may be degrading in a way the raw latency/error numbers alone wouldn't reveal — read those flags.

The HTML report (<prefix>_report.html)

This is Locust's built-in report. Just double-click it to open in a browser. It gives you nice visuals: response-time and request-rate charts over the whole run, a table of every request type, and a list of any failures. It's great for a quick visual feel and for sharing a screenshot.

Important caveat: the HTML report's headline totals are blended across the entire run — it averages the gentle early stages together with the overwhelmed late ones. That makes its single "average response time" / "total RPS" numbers not the right ones to quote. For the official ceiling, always read the knee in summary.json (and the per-stage story in stages.csv). Think of the HTML as the pictures, and summary.json/stages.csv as the verdict.

Tuning notes

  • Swap the CURIEs. The corpus in helmsdeep/trapi_corpus.py uses a few real MONDO/CHEBI entities; replace them with entities your target service actually knows about, or queries return empty and won't reflect real cost.

  • Tier is per query (Retriever only). Retriever exposes parameters.tier (0 or 1) to pick its backend graph. RETRIEVER_CORPUS pairs multi-hop shapes with tier 0 and single-hop shapes with tier 1. ARA queries carry no tier.

  • Shepherd/ARS send inferred + bypass_cache, mixing MVP1 and MVP2.SHEPHERD_CORPUS (also used for ars) holds creative-mode queries (knowledge_type: "inferred", bypass_cache: true) split evenly between two Translator templates, with entities varied per request to spread load and avoid cache-warming. by_qtype.csv breaks out latency per template/tier.

    • MVP1 — "what treats disease X?" (chemical -[treats]-> disease): the pinned disease is sampled from size-tiered pools (heavy/medium/light), so cost tracks answer-set size. Heavy is a curated list of common disease hubs; the long-tail pool is ~1000 real MONDO CURIEs in curie_list.json.
    • MVP2 — chemical⇄gene "affects" (biolink:affects, inferred, with object_aspect/object_direction qualifiers): both edge directions (chemical→gene and gene→chemical), with the gene (curated GENES pool) and qualifier combo varied per request.

    These are far heavier than KP lookups, so the aras target ships a gentler ramp and a looser p99_slo_ms (see config.py). Tune the per-template weights and entity pools (HEAVY_DISEASES, GENES, the tiers) to your real traffic.

  • ARS reuses the Shepherd corpus (ARS_CORPUS = SHEPHERD_CORPUS) — the same inferred query the ARS fans out to its ARAs. Its poll cadence and per-query timeout (poll_interval_s, max_poll_s) are tunable in config.py.

  • Pathfinder is its own run type (aras_pathfinder / ars_pathfinder, ARA/ARS only). PATHFINDER_CORPUS sends a single drug↔disease shape that pins two endpoints and asks for connecting paths (a paths map in the query_graph, not edges); the (chemical, disease) pair varies per request from a curated CHEM_DISEASE_PAIRS list — swap these for pairs your service knows, and keep them plausibly connected so paths come back non-empty (on ARS a zero-result Done is a failure by default). It's the heaviest query class, so its targets ship the gentlest ramps and loosest SLOs (and, for ars_pathfinder, the longest max_poll_s); tune them in config.py.

  • The mixed profile blends the two ARA/ARS classes (aras_mixed / ars_mixed). MIXED_CORPUS is computed from SHEPHERD_CORPUS + PATHFINDER_CORPUS at the INFERRED_PATHFINDER_RATIO (2:1), so it tracks any retuning of the inferred mix. Change the blend by editing that ratio; change what counts as a pass by editing the per-target checkpoints in config.py. Because 1/3 of the mix is Pathfinder — the heaviest class — and p99 is a tail statistic, both mixed targets inherit the looser Pathfinder SLO. aras_mixed also raises request_timeout_s above the default 210 s: a sync target whose p99 SLO sits near the HTTP timeout records slow queries as client timeouts (errors) instead of latencies.

  • Adjust corpus weights in RETRIEVER_CORPUS / SHEPHERD_CORPUS to match your traffic mix.

Running the engine directly

You can also invoke the locustfile without the CLI (defaults to the kps target):

LOCUST_CSV_PREFIX=run1 \
locust -f helmsdeep/trapi_loadtest.py --headless \
--host https://your-retriever-service.example.org \
--html run1_report.html

The output-file prefix comes from the LOCUST_CSV_PREFIX env var (locust has no --csv-prefix flag). Set LOADTEST_TARGET to choose a different (implemented) layer. Add --html <name>.html yourself to get the HTML report (the helmsdeep CLI passes this for you automatically, as <prefix>_report.html).

About

Load Tester of Translator Services

Resources

Stars

0 stars

Watchers

4 watching

Forks

Releases

Packages

Contributors

Languages