Skip to content

Stream anonymizedBallots from a DB cursor in random order#1424

Open
jacksonloper wants to merge 1 commit into
mainfrom
JacksonLoper/streamballots
Open

Stream anonymizedBallots from a DB cursor in random order#1424
jacksonloper wants to merge 1 commit into
mainfrom
JacksonLoper/streamballots

Conversation

@jacksonloper

Copy link
Copy Markdown
Collaborator

Problem

GET /API/Election/:id/anonymizedBallots loads every ballot into memory (selectAll()), then .filter().map(), then secureShuffle, then res.json — the full dataset is held roughly 3x at peak. For large elections (e.g. ~31k ballots ≈ 14 MB of JSON, ~30–50 MB of heap to build) this is one of the endpoints that OOMKills the pod and 502s users. Worse, V8 never returns the heap high-water mark to the OS, so one big request permanently inflates the pod's baseline.

Change

Stream the ballots row-by-row from a server-side Postgres cursor straight to the response, so peak memory is O(batch) instead of O(ballots):

  • ServiceLocator: pass pg-cursor to the Kysely PostgresDialect (this is what enables kysely's .stream(); no behavior change for existing queries).
  • BallotsDB.streamSubmittedBallotsByElectionID: new model method — cursor-backed (.stream(500)), filters head = true AND status = 'submitted' in SQL.
  • Random order moves into the database: the response order must not reveal ballot submission order (an admin could zip it against roll timestamps to deanonymize votes), but you can't shuffle a stream. The query orders by gen_random_uuid(), which is backed by Postgres's strong RNG (pg_strong_random), so this keeps a CSPRNG-grade shuffle — it just happens in Postgres memory instead of Node. ⚠️ Flagging explicitly since it swaps the privacy primitive from secureShuffle to the database RNG.
  • Controller: serializes {"ballots":[...]} incrementally via an async generator (batched into ~64 KB chunks) piped to the response with stream.pipeline, which handles backpressure (a slow client throttles the cursor rather than ballooning buffers) and destroys the cursor if the client disconnects mid-stream.
  • AnonymizedBallot.precinct typed string | null to match what the API has always returned ("precinct": null).
  • Mock ballot store now sets head = true on insert like the real store, and implements the new stream method.

Response shape

Unchanged: {"ballots": [{ballot_id, election_id, precinct, votes}, ...]}. The response is chunked (no Content-Length), which clients don't observe through fetch/axios.

Error semantics tradeoff

Since streaming sends the 200 and body incrementally, a mid-stream DB failure can no longer produce a 500 — the connection is aborted instead (client sees a network error rather than truncated-but-parseable JSON). Auth/permission errors are unaffected (checked before any bytes are written).

Testing

  • New anonymizedBallots.test.ts: returns all submitted ballots anonymized (exact key set per ballot), empty election, and 401 for non-public open elections — 139 backend tests pass.
  • npm run build passes for shared, backend, and frontend.

Relationship to other work

Independent of #1423 (response compression) — they compose (compression middleware compresses streamed chunks as they flow) and any merge conflict is trivial. The new cursor plumbing (pg-cursor dialect option + the model stream method) is also the foundation the planned compact tabulation loading (docs/ram-reduction.md §2.7) can reuse.

🤖 Generated with Claude Code

Previously the endpoint loaded every ballot into memory, filtered,
shuffled, and serialized the whole array — holding the dataset ~3x at
peak, which OOMs the pod on large elections.

Now the ballots are streamed row-by-row from a server-side Postgres
cursor (kysely .stream(), enabled via pg-cursor on the dialect) and
serialized incrementally to the response through stream.pipeline, so
peak memory is O(batch) instead of O(ballots) and backpressure from a
slow client throttles the cursor.

The in-memory secureShuffle is replaced by ORDER BY gen_random_uuid()
in the query, so the randomization that hides ballot submission order
now happens in Postgres (gen_random_uuid() is backed by a strong RNG)
before rows ever reach Node.

Also filters status='submitted' in SQL, makes the mock ballot store
mirror the real store's head=true insert behavior, and types
AnonymizedBallot.precinct as string|null to match what the API has
always returned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@netlify

netlify Bot commented Jul 7, 2026

Copy link
Copy Markdown

Deploy Preview for bettervoting ready!

Name Link
🔨 Latest commit 71b596c
🔍 Latest deploy log https://app.netlify.com/projects/bettervoting/deploys/6a4d2a1047353800082388cb
😎 Deploy Preview https://deploy-preview-1424--bettervoting.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@jacksonloper, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 47 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a9a308a4-2be7-4e0e-853b-896592e0d4cf

📥 Commits

Reviewing files that changed from the base of the PR and between fe84eb4 and 71b596c.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (8)
  • packages/backend/package.json
  • packages/backend/src/Controllers/Ballot/getAnonymizedBallotsByElectionIDController.ts
  • packages/backend/src/Models/Ballots.ts
  • packages/backend/src/Models/IBallotStore.ts
  • packages/backend/src/Models/__mocks__/Ballots.ts
  • packages/backend/src/ServiceLocator.ts
  • packages/backend/src/test/anonymizedBallots.test.ts
  • packages/shared/src/domain_model/Ballot.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch JacksonLoper/streamballots

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant