Make MockEventQueue run jobs inline; drop test-suite leak workarounds - #1491
Make MockEventQueue run jobs inline; drop test-suite leak workarounds#1491jacksonloper wants to merge 1 commit into
Conversation
Fixes #1481. MockEventQueue drained its jobs on a self-rearming 1-second timer that nothing awaited. Because Jest isolates module registries but not the event loop, each file's chain kept running after the file ended — against the previous file's app, DBs and logger. That is the leak `--forceExit` was papering over, and the reason `npm test` ran the whole suite in one process (`--detectOpenHandles` forces `runInBand`). Worse, the leaked jobs never did their work. Once a file has torn down, `console.info` throws, and the "MEQ: Processing job" log sits inside processNextJob's try — so ~4 jobs per run aborted before their handler ran and were silently discarded. The timer was not modelling async delivery, it was randomly dropping jobs. Jobs now run inline: publish() resolves once its handlers are done. Tests that need to observe the gap between "enqueued" and "handled" use pause()/resume(), which is deterministic. This mirrors production no worse than before — pg-boss polls at 500ms with teamSize 5, so the mock's serial 1000ms modelled neither its latency nor its concurrency. Removed the waits this made unnecessary. All of them were already no-ops: ballots are persisted synchronously in castVoteController before the response, and the queue only mails receipts. Verified by neutering waitUntilJobsFinished with the old 1s delay intact — all 47 tests in the four affected files still passed. finalizeElection's 4-second sleep was waiting on a false premise: finalize publishes nothing at all, since invites go out through POST /Election/:id/sendInvites. Added ballotReceiptEmail.test.ts. Nothing had ever asserted on the queue's output — the mock EmailService records into sentEmails and no test read it — so handleCastVoteEvent, the only consumer the suite exercises, had no coverage. Both tests were mutation-checked: dropping the sendEmails call fails both, and sending the receipt inline instead of queueing fails only the second. Also switched emailRoll to th.expressApp instead of a second makeApp(), which was re-registering all three handlers and printing three "already have handler" errors every run. Measured on this machine, 50 consecutive runs: 15.3s -> 2.8s, 0 failures (issue reports 4/50 before), no late-log warnings, no duplicate-handler errors, and Jest now exits on its own with no flags. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGsUoD9JCefu95Aw7HieW
✅ Deploy Preview for bettervoting ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
📝 WalkthroughSummary by CodeRabbit
WalkthroughThe mock event queue now processes jobs through an inline, re-entrant-safe drain loop. It exposes pending job counts and asynchronously drains jobs on resume. Handler failures include job and queue details. Tests now validate asynchronous ballot receipt delivery and remove obsolete queue waits and fixed delays. The backend test command no longer uses Jest force-exit or open-handle detection options. Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/backend/src/Services/EventQueue/MockEventQueue.ts`:
- Around line 64-65: Update the interaction between publish() and drain() in
MockEventQueue so a publish invoked by a currently running handler does not
resolve until its queued handler has executed. Replace or refine the _draining
early return in drain(), and define consistent nested-publish behavior while
preserving completion semantics for both top-level and nested jobs.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: bb58472f-c0f1-40bd-808b-95b4f0790c0b
📒 Files selected for processing (8)
packages/backend/package.jsonpackages/backend/src/Services/EventQueue/MockEventQueue.tspackages/backend/src/test/anonymizedBallots.test.tspackages/backend/src/test/ballotReceiptEmail.test.tspackages/backend/src/test/emailRoll.test.tspackages/backend/src/test/finalizeElection.test.tspackages/backend/src/test/idRoll.test.tspackages/backend/src/test/writeIns.test.ts
💤 Files with no reviewable changes (4)
- packages/backend/src/test/finalizeElection.test.ts
- packages/backend/src/test/anonymizedBallots.test.ts
- packages/backend/src/test/writeIns.test.ts
- packages/backend/src/test/idRoll.test.ts
| if (this._paused || this._draining){ | ||
| return; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
Preserve publish() completion semantics for nested jobs.
When a handler awaits publish(), drain() returns at Line 64 because _draining is true. The nested publish() then resolves before its handler runs. The outer drain cannot process that job until the current handler returns. This violates the stated publish() completion contract.
Define nested publish semantics explicitly, or change the drain design so nested publish() calls do not report completion before their jobs finish.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/backend/src/Services/EventQueue/MockEventQueue.ts` around lines 64 -
65, Update the interaction between publish() and drain() in MockEventQueue so a
publish invoked by a currently running handler does not resolve until its queued
handler has executed. Replace or refine the _draining early return in drain(),
and define consistent nested-publish behavior while preserving completion
semantics for both top-level and nested jobs.
Fixes #1481.
What was wrong
MockEventQueuedrained its jobs on a self-rearming 1-second timer that nothing awaited:Jest isolates module registries but not the event loop. Each file got a fresh queue, fresh mock DBs and a fresh app, but the previous file's chain kept running — against the previous file's objects — for roughly one second per queued job. That's the leak
--forceExitwas papering over, and the reason the whole suite ran in one process:--detectOpenHandlesforcesrunInBand(@jest/core/build/testSchedulerHelper.js:29).The leaked jobs also never did their work. Once a file has torn down,
console.infothrows, and the"MEQ: Processing job"log sits insideprocessNextJob'stry. So roughly 4 jobs per run aborted before their handler ran and were silently discarded, and thecatch's own log threw again. The timer wasn't modelling async delivery — it was randomly dropping about a third of the jobs.What changed
Jobs run inline.
publish()resolves once its handlers have finished. Tests that need to observe the gap between "enqueued" and "handled" usepause()/resume(), which is deterministic and needs no timer. This is no less faithful to production than before: pg-boss polls atnewJobCheckInterval: 500withteamSize: 5(PGBossEventQueue.ts:39), so the mock's strictly-serial 1000 ms modelled neither its latency nor its concurrency.Removed the waits this made unnecessary — the six
waitUntilJobsFinished()call sites andfinalizeElection's 4-second sleep.Every one of them was already a no-op, which I verified rather than assumed: with the old 1 s delay left intact and
waitUntilJobsFinishedneutered to a no-op, all 47 tests in the four affected files still passed. The reason is structural — ballots are persisted synchronously atcastVoteController.ts:256, before the response; the queue only mails receipts.finalizeElection's sleep was worse than decorative. Its comment said "Wait a few seconds for job queue to process emails", but finalize publishes nothing at all — invites go out throughPOST /Election/:id/sendInvites. It burned 4 s of a 5 s timeout waiting for jobs that were never created, and it's the one test the issue records failing on a genuine timeout.Added
ballotReceiptEmail.test.ts. Nothing in the suite had ever asserted on the queue's output: the mockEmailServicerecords intosentEmailsandTestHelper:47wires it up, but no test read it, and bothemailService.clear()calls were commented out. SohandleCastVoteEvent— the only consumer the suite exercises at all — had zero coverage. The receipt is worth pinning down becausecastVoteControllerdeliberately scrubsballot_idfrom the HTTP response, making the email the only channel that carries it back to the voter.Both tests are mutation-checked, so they aren't vacuous:
sendEmailscall inhandleCastVoteEventDropped both jest flags.
--detectOpenHandleswas reporting nothing while serialising 22 files into one process;--forceExitis no longer needed because nothing is left scheduled.Switched
emailRolltoth.expressAppinstead of a secondmakeApp(), which was re-registering all three handlers and printing threealready have handlererrors every run. (This was cited in the issue as evidence of cross-file collision — it isn't; it's one file callingmakeApp()twice.)Results
Measured locally, 50 consecutive runs:
already have handlererrors--forceExit151 tests, 23 suites,
tscclean.One honest caveat on the flake number: 0/50 is consistent with the fix working, but if the true rate were still 8% you'd see a clean 50 about 1.5% of the time. The mechanism that selected 404 vs 400 vs ECONNRESET was never pinned down in #1481 and isn't pinned down here either — what this PR proves is that the leak is gone and the suite exits on its own. If a flake resurfaces, the remaining suspect is supertest's one-ephemeral-listener-per-request pattern (
request(app)callshttp.createServeron every call), not the queue.Follow-ups not in this PR
handleSendInviteEventandhandleSendEmailEventare subscribed and never invoked by any test — no test hits/sendInvites,/sendEmails, or/sendInvite/:voter_id. Worth covering with the same drain pattern.sendInvitesController.ts:136readsemailResponse.lengthandemailResponse[0][0].statusCodefromEmailService.sendEmails, but the mock returnsundefined. That path will throw on the mock the moment a test drives it — the fake's return contract never matched what the production code expects, because nothing ever exercised it.MockEventQueue.tsstill lives beside the real implementation insrc/Services/EventQueue/rather than a__mocks__/directory, so it compiles intobuild/. Left alone here to keep the diff focused.🤖 Generated with Claude Code
https://claude.ai/code/session_01ECGsUoD9JCefu95Aw7HieW