Load testing#
The repository ships a small, dependency-free load test for the survey submission pipeline, built on k6 (Grafana’s HTTP load testing tool). k6 is a single binary — nothing is installed into the buildout environment.
What it exercises#
The script in loadtest/survey-load.js simulates a realistic visitor
session against a running instance:
Login — POST
/demo/login_formwith the editor credentials (the Plone login form requires thebuttons.loginsubmit field).Read path — GET the survey viewer page; the page carries a fresh single-use
auth_tokenand a CSRF token in JSON script tags.Write path — POST
@@save-pollwith a valid answer set, including the auth and CSRF tokens. This runs the full server-side validation (external deno/bun binary) and the ZODB store subscribers.
Every submitted answer set passes the external validator, so failures shown by the test are real server-side problems, not payload rejections.
Running it#
k6 run loadtest/survey-load.js
Default: a constant-arrival-rate stress test — exactly 20 submissions/second for 60 seconds (~1200 iterations, up to 100 VUs). The arrival rate is enforced by k6’s executor, so the iteration count does not depend on how fast the server responds; iterations that cannot start in time are dropped and reported.
All settings can be overridden via environment variables:
K6_SURVEY_URLSurvey viewer URL to load (default: the multilingual demo survey).
K6_LOGIN_URLPlone login form URL (default:
http://localhost:8082/demo/login_form).K6_USER/K6_PASSWORDCredentials used for the per-VU login.
K6_RATESubmissions per second (default: 20).
K6_DURATIONTest duration (default: 60s).
The demo surveys are private, so a logged-in session is required.
Using an account with manager-level rights skips the auth-token checks
entirely (can_manage_portal_content bypasses trusted access and
token validation in views.py).
Pitfalls and known behaviour#
k6 session-cookie jar bug — on the k6 2.2.0 development build the default cookie jar drops the
__accookie between iterations, which silently logs every VU out after its first iteration. The script therefore manages the session cookie manually (redirects: 0plus an explicitCookieheader). Keep that workaround if the script is updated.Auth tokens are single-use — each iteration fetches a fresh token from the viewer page; a token must never be reused (replay protection returns 403).
ZODB ConflictError hotspot — at 5 VUs the submission storage (shared
BTrees.OOBTreebuckets) already produces conflicts; Zope retries them, but the retry re-executessave_pollwith an already consumed auth token, so every conflict surfaces as a client-visible 403 (observed: 9–12% of submissions at 5 VUs, growing with the stored result count). This is the current scaling ceiling of the write path.Submissions are real — every successful iteration stores a submission in the ZODB. Use a throwaway survey or clear results afterwards (
@@clear-results) to keep a demo site clean.
Observed baseline (2026-08, dev instance)#
Light load (5 VUs, stages, ~250 iterations):
Read path and successful submissions: p(95) ≈ 175 ms, avg ≈ 118 ms.
External validation: ~95 ms per submission.
~9–12% of submissions failed via the conflict/replay interaction described above; HTTP-level errors on the read path: 0.
Stress load (20 submissions/s for 60s, up to 100 VUs):
The instance saturates: p(95) latency ≈ 17.4 s (vs 175 ms at 5 VUs), avg ≈ 8.7 s on successful requests too — the whole request path degrades, not just the writes.
Target 1200 iterations: only ~317 completed, 884 dropped by the executor; ~481 submissions were stored server-side (dropped k6 iterations still sent their requests).
Server-side in 60s: 195 ZODB ConflictErrors and 187 auth-token replays; submit success rate ≈ 62%.
The ZODB conflict/replay interaction is the scaling ceiling: it caps the write path well below 20 submissions/s on a single-instance setup.