Scale Runs and Stages - 2026-10-24¶
Context¶
The Project Review listed two scale limits (A1, A2):
- A1.
qasight_api/cycle_execution_manager.pystarted a daemon thread for every execution. One run per cycle was enforced, but nothing capped how many cycles ran at once. On a team server, ten people pressing Run started ten sets of test subprocesses on one host. - A2.
engine/orchestrator.run_plan()ran stages one at a time, so a cycle with an independent unit stage, API stage and UI stage took the sum of all three. Item 3 in the Technical Debt Tracker.
Both fixes had to keep one engine entered through CycleRunService and stay small enough to explain.
Decision¶
- A bounded run pool in the API.
CycleExecutionManagerowns a FIFO queue andTESTO_MAX_CONCURRENT_RUNSworker threads (default2, started on the first run).POST /cycles/{cycle}/executionsand/adhoc-executionsstill answer202at once; the execution staysqueued(a status the API already had) until a worker picks it up. A cycle that is queued or running can't be started again (409, as before). Nothing else changes for clients: the SSE stream waits for the run's events file the same way. - Opt-in parallel stages. A cycle can set
max_parallel_stages: N(default1, today's behavior). WithN > 1,run_plan()runs up to N stages at once on aThreadPoolExecutor; each stage is still one subprocess, so the threads only wait on it. - One writer. Worker threads only call
run_stage()and return itsStageResult. Starting stages, writingevents.ndjsonand calling the renderer all happen on the calling thread, so no locks are needed and the NDJSON contract is unchanged. - Order. Stages start in config order as slots free up.
stage_finishedevents are written in completion order;PlanResult.stages(and soplan_result.json, the run record and reporters) stays in config order. - Failures.
fail_faststops new stages from starting after the first failure. Stages already running finish (their processes aren't killed), thenplan_abortedis written. An engine crash in one stage becomes an internal-failure result (exit4) without stopping the others. - Isolation. Each stage already writes to
artifacts/<cycle>/<stage>/, so the config loader rejects repeated stage names whenmax_parallel_stages > 1. Since 2026-10-31 it rejects them in sequential cycles too (ignoring case, for macOS and Windows): the second stage reset the first one's results folder, so a failed test could vanish from the run. - Output. Live output from several stages would interleave, so parallel cycles are buffered: each stage's panel and output tail prints when it finishes. Plan duration is the wall clock, or the longest stage when a
junitstage reports more test time than that.
Options considered¶
- An external queue (Redis + RQ/Celery) with a separate worker process. It survives restarts and scales across machines, but it adds a service to run, a second process that has to load the engine, and more to explain. Rejected for now; the pool is the same shape (a queue in front of N workers), so moving it out of process later only changes
CycleExecutionManager. - A semaphore around the existing thread per run. Smallest diff, but every queued run would still hold a thread. Rejected for the explicit queue.
- A dependency graph between stages (
needs:, like GitHub Actions). More powerful, harder to explain, and no cycle needs it yet. It can be added on top: the scheduler in_run_parallel()would start a stage when its dependencies are done instead of when a slot is free. - Parallel by default. Rejected: stages that share a
target_repocan fight over caches, ports or a test database, and only the cycle's author knows they are independent.
Consequences¶
- The queue lives in memory, like the execution registry. A restart drops queued runs; they never started, so nothing needs cleaning up. Running ones are handled by the existing orphan cleanup.
TESTO_MAX_CONCURRENT_RUNSis per server process. Running several API workers multiplies it.- Parallel stages share the host: N stages each with
workers: 4can start 4N test processes. The Deep Dive says so. - Tests:
tests/unit/test_cycle_execution_manager.py(cap, FIFO, no double queueing, env parsing),tests/unit/qasight/engine/test_orchestrator_parallel.py(cap, config order,fail_fast, crash isolation, duration, single renderer thread) andtests/unit/qasight/test_parallel_stages_config.py(parsing, unique names, JSON Schema).