Benchmarks¶
Every number on this page was measured, and the raw output is committed beside it. Nothing here is estimated.
Read this first: these are CI-runner numbers, not production numbers. The load generator, the database, the broker and the workers all share one 4-vCPU virtual machine, so the figures are a floor for what the same code does on separated hardware. They are useful for comparing a change against its parent, which is what they are used for below.
Environment¶
Both load-test runs:
| Runner | GitHub Actions ubuntu-latest |
| CPU | 4 vCPU, AMD EPYC 9V74 |
| Memory | 15 GiB (1.4 GiB used) |
| OS | Linux 6.17.0-1022-azure x86_64 |
| Docker | 28.0.4 |
| Python | 3.13.16 |
| Locust | 2.47.0 |
| Database | PostgreSQL 16-alpine, containerised on the same host |
| Broker | RabbitMQ 3.13-alpine, containerised on the same host |
| Web server | Django runserver (development server, not gunicorn) |
The runserver detail matters: it is single-threaded per request and is not what
a deployment runs. The trigger latency below is therefore pessimistic.
Raw environment capture: docs/development/benchmarks/scheduler-0.25s/environment.txt.
Load test¶
The command is the one in Load Testing:
uv run locust -f loadtests/locustfile.py --headless --only-summary \
--users 10 --spawn-rate 2 --run-time 60s \
--csv loadtest-results/run --host http://localhost:8000
The workload is a four-task diamond: seed → (left, right) → combine.
Each virtual user triggers a run and polls it to a terminal state.
Two configuration changes are made for the benchmark, and both are stated because they change what is being measured:
| Setting | Shipped default | Benchmark | Why |
|---|---|---|---|
THROTTLE_TRIGGER_RATE |
60/min |
100000/min |
10 users exhaust 60/min in the first second; the first attempt recorded 1469 429s and measured the throttle. |
SCHEDULER_INTERVAL |
5 |
0.25 |
See below — the poll cadence sets a floor on end-to-end latency. |
The shipped defaults are asserted by a unit test, so the knob cannot silently loosen the policy.
Result 1 — default scheduler interval (5 s)¶
10 users, 60 seconds, SCHEDULER_INTERVAL=5.
| Metric | Value |
|---|---|
| Triggers accepted | 31 |
| Runs completed | 31 (124 task runs, all four per run) |
| Failures | 0 |
| Throughput | 0.36 runs/s, 32.8 HTTP req/s |
POST /api/v1/runs/ median / p95 |
62 ms / 150 ms |
GET /api/v1/runs/<id>/ median / p95 |
44 ms / 130 ms |
| Run end-to-end median | 20 000 ms |
| Run end-to-end p95 / max | 21 000 ms / 21 012 ms |
Raw: docs/development/benchmarks/scheduler-5s/.
The 20-second figure is the scheduler's poll interval, not the engine. A four-task diamond needs four passes (seed, the two branches, the join, settle), and four passes at 5 s is 20 s. The minimum of 15 750 ms is the same arithmetic with a pass that happened to land early.
This is worth stating plainly because it is the single most important
operational fact about FlowForge's latency: a run cannot finish faster than
dependency_depth × scheduler_interval. The default trades latency for load,
which is the right trade for a batch workflow engine and the wrong one for
anything interactive.
Result 2 — scheduler interval 0.25 s¶
Same shape, SCHEDULER_INTERVAL=0.25, so the number reflects the pipeline rather
than the polling schedule.
| Metric | Value | vs Result 1 |
|---|---|---|
| Triggers accepted | 157 | 5.1× |
| Runs completed | 160 (640 task runs) | 5.2× |
| Failures | 0 | — |
| Throughput | 2.54 runs/s, 37.0 HTTP req/s | 7.0× runs/s |
POST /api/v1/runs/ median / p95 / p99 |
77 ms / 260 ms / 360 ms | p95 1.7× |
GET /api/v1/runs/<id>/ median / p95 |
51 ms / 170 ms | p95 1.3× |
| Run end-to-end median | 3 500 ms | 5.7× faster |
| Run end-to-end p95 / p99 / max | 4 100 / 4 300 / 4 424 ms | — |
| Task throughput | ~10.7 tasks/s | — |
Raw: docs/development/benchmarks/scheduler-0.25s/.
RUNS=160 and TASKS=640 come from a direct database count after the run, not
from Locust: they prove every accepted trigger produced a completed run, which
an HTTP-only report cannot show.
The trigger p95 rising from 150 ms to 260 ms while throughput rises 5× is the expected shape — more concurrent work, more contention on one 4-vCPU box with a single-threaded dev server.
Result 3 — the profiled bottleneck¶
What was wrong¶
transition_task_run records an event-log row through _record_task_run_event.
That helper accepts an optional organization_id and, when it is absent, fetches
it with a separate SELECT:
if organization_id is None:
organization_id = (
TaskRun.objects.filter(pk=task_run_id).values_list("organization_id", flat=True).first()
)
No caller ever passed it — even though every one of the five call sites is
iterating a queryset of TaskRun rows and therefore already holds the tenant.
The scheduler promotes every run on every pass, so on a 14-task diamond that is
13 wasted round trips per run.
Measurement¶
scripts/profile_scheduler.py --micro runs both call shapes in one process,
against the same rows, so the only difference is the change:
uv run python scripts/profile_scheduler.py --runs 4 --fanout 6 \
--micro --transitions 400
| Queries per transition | Median time per transition | |
|---|---|---|
| Before (lookup inside the recorder) | 5.00 | ~3 100 µs |
| After (tenant passed in) | 4.00 | ~1 890 µs |
| Change | −20% | ≈ −39% |
End to end, over 140 advance_workflow_run passes (10 runs × 14 tasks):
| Total queries | Per pass | Wasted lookups | |
|---|---|---|---|
| Before | 1 672 | 11.9 | 130 |
| After | 1 542 | 11.0 | 0 |
The query counts are exact and reproduce on every run. The wall-clock figures are the median of three samples on a laptop running SQLite; the spread between samples was ±15%, so treat the percentage as indicative and the query counts as the fact.
Why this bottleneck and not a bigger one¶
A full cProfile of schedule_once showed load_definition at ~19% of the
time — it re-queries the workflow's tasks and rebuilds the dependency graph on
every pass. Caching it would be faster, but it would weaken a deliberate
guarantee: load_definition re-validates the stored rows so a manual UPDATE
or a bad migration is caught at schedule time rather than surfacing later. The
query fix removes work without removing a check, so it is the one that was
taken. The cache is recorded as a limitation below rather than silently applied.
Reproducing¶
# the load test
docker compose up -d --build
docker compose exec -T web python manage.py loadtest_user
THROTTLE_TRIGGER_RATE=100000/min THROTTLE_READ_RATE=100000/min \
THROTTLE_AUTH_RATE=100000/min SCHEDULER_INTERVAL=0.25 \
docker compose up -d --build
uv run locust -f loadtests/locustfile.py --headless --only-summary \
--users 10 --spawn-rate 2 --run-time 60s \
--csv loadtest-results/run --host http://localhost:8000
# the profiling comparison
uv run python manage.py migrate --noinput
uv run python scripts/profile_scheduler.py --runs 4 --fanout 6 --micro
Both are also what CI runs: the loadtest job produces the artifacts committed
above, and the docker job runs the same stack end to end.
Limitations¶
- CI hardware, shared host. Load generator, database, broker and workers on one 4-vCPU VM. Absolute numbers are a floor.
- Django
runserver, not gunicorn. Trigger latency is pessimistic; a production WSGI server would be substantially faster per request. - One worker process,
prefetch=1. Task throughput scales with worker replicas; the figures above are for a single worker container. - 60 seconds, 10 users. Long enough to be stable, short enough that a warm-up transient is a visible fraction of the window.
- The result-1 environment was not captured to the artifact. At the time, the
job wrote it to
/tmp; that was fixed before result 2. Result 1 and result 2 ran on the sameubuntu-latestimage, but the CPU/memory lines in this page come from result 2's capture. load_definitionis still re-queried per pass. See above — a deliberate choice, not an oversight.