> ## Documentation Index
> Fetch the complete documentation index at: https://skaum.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Load Testing (Concurrent Voting)

> Measuring the SQLite write-contention ceiling for burst voting (VOT-374)

tilvotering runs on SQLite in every environment. SQLite serializes writes, and
`Vote.cast!` deliberately serializes vote writes per poll via a `BEGIN IMMEDIATE`
transaction + `poll.lock!` (VOT-353). The launch question this harness answers:

> When 100–500 delegates submit a vote on the **same open poll** within a few
> seconds, does the app drain that burst without surfacing `SQLITE_BUSY` to users
> and within acceptable latency?

## The harness

* Task: `lib/tasks/load_test.rake`
* Implementation: `lib/load_test.rb` (`LoadTest::Harness`, `LoadTest::Reporter`)

```bash theme={null}
# One scenario: N participants, C concurrent workers
RAILS_MAX_THREADS=20 bin/rails "load_test:votes[100,5]"
RAILS_MAX_THREADS=20 bin/rails "load_test:votes[500,10]"

# Full sweep (100 & 500 across several concurrency levels)
RAILS_MAX_THREADS=20 bin/rails "load_test:sweep"

# Remove all seed data the harness created
bin/rails "load_test:cleanup"
```

Set `RAILS_MAX_THREADS` to at least the concurrency you test so the ActiveRecord
connection pool never becomes the bottleneck (the harness warns if it is too
small).

### What it does

1. Seeds an **isolated, clearly-marked** load-test org + meeting + one open
   binary poll + N eligible participants. Org slug prefix `loadtest-`, title
   prefix `[LOADTEST]`. Safe to re-run; `load_test:cleanup` removes everything.
2. Submits N votes as a burst through a **bounded worker pool** of size
   `concurrency`, each worker checking out its own DB connection and calling the
   real `Vote.cast!` write funnel.
3. Measures per-vote latency (p50/p95/p99/max), total drain time, throughput,
   and counts `SQLITE_BUSY` / lock / other errors and any votes that failed to
   persist.

### Which layer it exercises (and caveats)

The harness drives `Vote.cast!` **directly** — the real transaction + `poll.lock!`
contention point — **not** the full HTTP/controller stack. It omits session auth,
routing, param parsing and the Turbo broadcast that `Meetings::VotesController`
performs after each cast. Therefore:

* The measured latency is a **lower bound** on end-to-end per-request latency.
* It is a **faithful** measure of the DB **write-contention ceiling**, which is
  the part that can actually fail under burst (the rest of the stack scales
  horizontally with Puma; the single SQLite writer does not).

**Concurrency model.** Naively spawning 500 threads each with its own connection
would just exhaust the AR pool and measure pool starvation. In production the DB
sees at most (Puma workers × threads) concurrent writers; excess requests QUEUE
at Puma while SQLite drains serially. The bounded worker pool models exactly that.

**Environment caveat.** Runs were executed in `development` with the app's
production-like SQLite pragmas (WAL, `synchronous=normal`, the `timeout: 5000`
busy handler, tuned `mmap`/`cache`) — verified live via `verify_sqlite_config!`.
This is **not** the production VPS disk; absolute numbers on slower/faster storage
will differ, but the serialization behavior and the shape of the ceiling hold.

> [!NOTE]
>
> `PRAGMA busy_timeout` reports `0` even though a 5 s wait is configured. Rails
> 8.1's SQLite adapter installs the wait as a Ruby-level **busy handler** on the
> connection object, not the C-level pragma. The effective wait is the `timeout`
> key in `config/database.yml` (5000 ms). The measured zero busy-errors below
> confirm the handler is active.

## Measured findings

Development env, production-like pragmas, WAL confirmed active. Latency in ms;
`busy` = `SQLITE_BUSY` errors; `failed` = votes that did not persist.

| N | conc | drain (ms) | throughput (v/s) | p50 | p95 | p99 | busy | failed |
| - | - | -: | -: | -: | -: | -: | -: | -: |
| 100 | 3 | 630 | 159 | 18 | 34 | 39 | 0 | 0 |
| 100 | 5 | 693 | 144 | 7 | 20 | 105 | 0 | 0 |
| 100 | 10 | 730 | 137 | 7 | 692\* | 719\* | 0 | 0 |
| 500 | 5 | 3365 | 149 | 7 | 8 | 22 | 0 | 0 |
| 500 | 8 | 3129 | 160 | 34 | 140 | 200 | 0 | 0 |
| 500 | 16 | 3290 | 152 | 6 | 11 | 3254\* | 0 | 0 |

\* The tail spikes at high concurrency are a **measurement artifact**: with more
workers than SQLite can serve in parallel, the last worker's final cast waits
behind the entire serialized queue, inflating the single p99 sample. Real users
would not all be the "last in line"; the honest per-vote experience is the p50
(\~7–34 ms) and the total **drain time**.

### Headline numbers

* **100 participants (standard tier):** burst drains in **\~0.6–0.7 s**, \~150
  votes/sec, p95 ≈ 20–34 ms, **zero** busy errors, **zero** failed votes.
* **500 participants (pro tier):** burst drains in **\~3.1–3.4 s**, \~150–160
  votes/sec, p95 ≈ 8–140 ms, **zero** busy errors, **zero** failed votes.

Throughput plateaus at **\~150 votes/sec regardless of concurrency** — the direct,
expected signature of SQLite serializing writes. Adding workers does not increase
throughput (it only lengthens the tail of whoever is last in the queue).

## Verdict

**SQLite comfortably handles both the standard-tier (100) and pro-tier (500)
targets.** No tuning was required: the existing WAL + `timeout: 5000` busy handler
already absorbs a 500-vote same-poll burst with zero `SQLITE_BUSY` surfaced to
users and zero lost votes, draining in \~3 seconds.

At \~150 votes/sec the write funnel would only become marginal well beyond the
current pro-tier ceiling — roughly a sustained burst of **1000+** simultaneous
same-poll voters expecting sub-second drain. That is the point at which the
PostgreSQL migration the codebase already anticipates (see `config/database.yml`
"database-agnostic" note) would be worth revisiting. For the launch scale, **no
migration is needed.**

### VOT-435: the meeting-state check (2026-10-06)

`Vote` now also checks that the meeting accepts votes (preparing or active),
inside the vote transaction. The state is read with the agenda item the
validations load anyway, in one joined query, so a cast still runs six
statements. Interleaved A/B runs on the same machine (development env,
production-like pragmas, `load_test:votes[500,…]`, three runs each):

| Variant | conc | drain (ms) | throughput (v/s) | busy | failed |
| - | - | -: | -: | -: | -: |
| Before VOT-435 | 5 | 3198–3290 | 152–156 | 0 | 0 |
| Status column only (shipped) | 5 | 3325–3343 | 150 | 0 | 0 |
| Whole meeting (`eager_load`, rejected) | 5 | 3469–3543 | 141–144 | 0 | 0 |
| Before VOT-435 | 8 | 3227–3257 | 154 | 0 | 0 |
| Status column only (shipped) | 8 | 3335–3347 | 149–150 | 0 | 0 |
| Whole meeting (`eager_load`, rejected) | 8 | 3444–3496 | 143–145 | 0 | 0 |

The shipped check costs about 3% of throughput (one run per variant hit
machine noise and is left out). Instantiating the whole meeting cost about 8%,
which is why only its `status` column is read.

### Tuning levers (if a future scale needs them)

* **Larger `timeout`** in `config/database.yml` — buys more wait before a writer
  gives up; already generous at 5 s.
* **AR pool = Puma threads** — keep `RAILS_MAX_THREADS` aligned so the pool never
  starves before the DB does.
* **WAL checkpoint tuning** — under a very long sustained write stream, watch WAL
  growth; the app already sets `journal_size_limit`.
* **Move to PostgreSQL** — the real answer above \~1000 concurrent same-poll
  writers; the write path (`Vote.cast!` + `poll.lock!`) is already Postgres-safe
  (`SELECT … FOR UPDATE`).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.