Skip to main content
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)
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.
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. * 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): 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).
Last modified on October 6, 2026