Skip to content

Mntor 5319 - #6826

Draft
GregHilstonMozilla wants to merge 5 commits into
mainfrom
mntor-5319
Draft

Mntor 5319#6826
GregHilstonMozilla wants to merge 5 commits into
mainfrom
mntor-5319

Conversation

@GregHilstonMozilla

@GregHilstonMozilla GregHilstonMozilla commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

References:

Jira: MNTOR-5319

Incident doc: 2026-09-03 Monitor Website Unresponsive

Description

redisClient() declared a module-level singleton but never read it, so every call opened a new ioredis connection and abandoned the previous one. getAllBreachesFromDb() calls it per request, so the web tier leaked roughly one socket per request until Memorystore hit its 64,998 maxclients ceiling.

Deploys and pod restarts have been silently protecting prod from this, as the connections would be cleared out.

Testing Before and After

Built for production twice, once on main and once on this branch, pointed REDIS_URL at a throwaway docker run -d --name redis-demo -p 6379:6379 redis:7-alpine, then fired 300 requests at /en/breaches with concurrency 10 while sampling redis-cli info clients once a second.

Redis connections:

sec main this branch
1 0 1
5 60 1
10 134 1
15 210 1
20 283 1
22, load ends 300 1
30 300 1

One socket per request, and they are never released. That last row is the incident in miniature: nothing reclaims them until the pod dies.

Response times, same 300 requests. Sharing one connection instead of 300 was the thing I was most worried about, and it turns out to be slightly faster, because removing a TCP handshake per request more than pays for the serialised reply.

p50 p95 p99 max
main 688ms 807ms 977ms 996ms
this branch 683ms 763ms 831ms 966ms

Separately, stopping Redis for 8 seconds under a live client: main dies at t≈1.2s with Redis Could not connect after 4 attempts.

How to test

npx vitest run src/db/redis src/utils/hibp.test.ts

Each regression case fails on main: client.test.ts reports 100 connections for 100 calls, util.test.ts throws at retryStrategy(4), and hibp.test.ts gets [] instead of the Postgres rows.

Checklist (Definition of Done)

  • Localization strings (if needed) have been added. Not applicable, no user-facing strings.
  • Commits in this PR are minimal and have descriptive commit messages. Four commits, each with its own tests.
  • I've added or updated the relevant sections in readme and/or code comments. docs/architecture/flows/breach-read-path.md now distinguishes a cache miss from a cache fault, and retryStrategy carries a comment saying why it must never throw.
  • I've added a unit test to test for potential regressions of this bug. One per commit, each verified to fail on main.
  • If this PR implements a feature flag or experimentation, I've checked that it still works with the flag both on, and with the flag off. Not applicable, see above for why there is no flag.
  • If this PR implements a feature flag or experimentation, the Ship Behind Feature Flag status in Jira has been set. Not applicable.
  • Product Owner accepted the User Story (demo of functionality completed) or waived the privilege.
  • All acceptance criteria are met. Four of five. The restart-rate AC depends on the per-request JSON.parse, which is out of scope here.
  • Jira ticket has been updated (if needed) to match changes made during the development process.
  • Jira ticket has been updated (if needed) with suggestions for QA when this PR is deployed to stage.

Follow Up Action

Watch redis.googleapis.com/clients/connected for 24 to 48h after deploy. It should stay near its ~450 floor instead of climbing.

redisClient() declared a module-level singleton but never read it, so every
call built a new ioredis instance and abandoned the previous socket.
Sentry JS v10 removed the internal debug logger from @sentry/core and
renamed it `debug`; the `logger` export now points at the Structured Logs
API.
retryStrategy threw once times > 3. ioredis calls that callback outside any
try/catch, so the throw escaped as an uncaught exception and took the
process down, and a refused connection had no path back. Return a capped
backoff instead, so the connection heals on its own once Redis returns.

Extracted to a named export above the c8 ignore block so it can be tested
getAllBreachesFromDb swallowed a Redis fault and returned []. getBreaches()
reads that as "the breaches table is empty" and answers by fetching the
whole catalogue from HIBP and re-upserting it, per request.
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