Skip to content

Add read-replica routing for expensive read-only endpoints - #9528

Open
camd wants to merge 11 commits into
masterfrom
camd/read-replica-queries
Open

camd wants to merge 11 commits into
masterfrom
camd/read-replica-queries

Conversation

@camd

@camd camd commented May 18, 2026 •

Copy link
Copy Markdown
Collaborator

Route read-only endpoints to a PostgreSQL read replica

Summary

Adds an opt-in mechanism to serve a defined set of read-only GET endpoints from a PostgreSQL read replica instead of the primary, to relieve the primary of read load that competes with the data-ingestion write workload. Ships off by default and is controlled entirely by environment variables — no code change needed to turn it on or off.

How it works

  • ReadReplicaRouter (treeherder/config/db_routing.py) — a Django DB router that sends reads to a read_replica alias when a thread-local flag is set, but only for models whose app label is in an allow-list ({"perf", "model"}). Everything else stays on the primary.
  • ReadReplicaMixin — a viewset/APIView mixin that flips the flag for safe HTTP methods only (GET/HEAD/OPTIONS). Unsafe methods (POST/PUT/PATCH/DELETE) always use the primary, so a viewset can mix routed reads and primary writes safely.
  • Auto-fallback — on a replica OperationalError/InterfaceError, the request is retried once against the primary and a db_routing_fallback WARNING is logged. A degraded replica means "served from primary," not errors.
  • Kill switch — gated on READ_REPLICA_ENABLED; when off (or the replica URL is unset), the alias isn't registered and the router isn't installed (complete no-op). Celery, ETL, and management commands are untouched and always use the primary.

Endpoints routed

  • Jobs view polling (the busiest read traffic): /api/jobs/, /api/project/{repo}/push/, /api/project/{repo}/jobs/{id}/ (detail + similar/text-log actions).
  • Group summary: /api/groupsummary/ — a heavy day-wide multi-join aggregation, one of the most expensive reads in the app.
  • Perfherder reads: (not as expensive, low hanging fruit): framework, signatures, platforms, job-data, data, bug-template, issue-tracker, validity-dashboard, summary, alertsummary-tasks, perfcompare results.

Perfherder mutating endpoints (alert summaries, alerts, tags) intentionally stay on the primary so a user's write is immediately visible on re-read.

Consistency / safety

The routed jobs/push/group/Perfherder-read paths have no synchronous read-after-write: job actions go through Taskcluster + ingestion (not a direct DB write the user re-reads), and job classification updates failure_classification_id and last_modified in the same row, so a lagging replica returns either the whole old row or the whole new row — no torn reads. Polling tolerates replica lag via the existing cursor safety buffer; worst case a new job/push appears one poll cycle later.

Enabling (for deploy/app-config owners)

Set both (both required to activate):

Variable Value
READ_REPLICA_DATABASE_URL replica DSN, same format as DATABASE_URL
READ_REPLICA_ENABLED true

Verify from inside the app with ./manage.py dbshell --database=read_replica. Recommended rollout: enable in stage → soak several days → enable in prod. Kill switch is READ_REPLICA_ENABLED=false (no deploy). During soak, watch: primary DB CPU/connections (should drop), replica CPU (should rise — currently near-zero, ample headroom), and db_routing_fallback log volume (should stay ~0).

Testing

  • Unit tests for the router and mixin (tests/config/test_db_routing.py) — allow-list routing, safe-vs-unsafe methods, state cleanup, one-shot fallback.
  • End-to-end routing tests (tests/webapp/api/test_read_replica_routing.py) asserting per-alias query capture for the jobs, push, job-detail, group-summary, and Perfherder endpoints, plus a control proving a non-opted-in viewset stays on primary.
  • Existing job/push/group/Perfherder API suites updated to run with both aliases (transaction=True, databases=["default", "read_replica"]) since routed reads use a separate connection that only sees committed data.

Follow-ups (not in this PR)

Once proven in prod, the next routing candidates are the Intermittent Failures views (/api/failures/, /api/failuresbybug/, /api/failurecount/) — heavy date-range aggregations, GET-only, lag-tolerant, all in the already-allow-listed model app.

Next candidates

  • Tier 1 (do next): the Intermittent Failures views — Failures, FailuresByBug, FailureCount (intermittents_view.py). Heavy date-range Count/TruncDate aggregations, GET-only, highly lag-tolerant, all in the model app → no allow-list change. This is the standout.
  • Tier 2: InfraCompareView, NoteViewSet, BugJobMapViewSet, HashViewSet.tocommit, FilesBugzillaMapViewSet — with a flagged read-after-write nuance on notes/bug-job-map (classify→view) to decide on first.
  • Tier 3: small refdata endpoints — harmless but negligible.
  • Not routable without an allow-list change: ChangelogViewSet (changelog app), UserViewSet (auth app).
  • Key reassurance for the comment: every routable candidate is in {model, perf}, so they're all "add mixin + test," no router change.

@codecov-commenter

codecov-commenter commented May 18, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 59.77860% with 109 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.27%. Comparing base (5acee2d) to head (79651ce).

Files with missing lines Patch % Lines
tests/config/test_db_routing.py 26.98% 92 Missing ⚠️
treeherder/config/db_routing.py 71.15% 15 Missing ⚠️
treeherder/config/settings.py 60.00% 2 Missing ⚠️

❗ There is a different number of reports uploaded between BASE (5acee2d) and HEAD (79651ce). Click for more details.

HEAD has 1 upload less than BASE
Flag BASE (5acee2d) HEAD (79651ce)
5 4
Additional details and impacted files
@@             Coverage Diff             @@
##           master    #9528       +/-   ##
===========================================
- Coverage   82.98%   69.27%   -13.71%     
===========================================
  Files         670      669        -1     
  Lines       40875    41030      +155     
  Branches     3918     3933       +15     
===========================================
- Hits        33920    28425     -5495     
- Misses       6479    12129     +5650     
  Partials      476      476               

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread treeherder/config/db_routing.py Outdated
Comment thread docs/accessing_data.md Outdated
Comment thread tests/config/test_db_routing.py Outdated
@camd
camd force-pushed the camd/read-replica-queries branch from fb7dc10 to 5ed5343 Compare May 23, 2026 17:44
@camd

This comment was marked as resolved.

@camd camd self-assigned this May 25, 2026
@camd
camd force-pushed the camd/read-replica-queries branch from 5ed5343 to 4eeff5a Compare May 25, 2026 16:45
@camd
camd force-pushed the camd/read-replica-queries branch from be85438 to 295e3bd Compare June 14, 2026 14:56
@camd camd changed the title Add read-replica routing for Perfherder read-only endpoints Add read-replica routing for expensive read-only endpoints Jun 14, 2026
@camd
camd force-pushed the camd/read-replica-queries branch 4 times, most recently from b9f0688 to 8884b98 Compare July 3, 2026 23:43
@camd
camd force-pushed the camd/read-replica-queries branch from 8884b98 to 88fdb8e Compare July 18, 2026 17:21
@camd
camd force-pushed the camd/read-replica-queries branch from 88fdb8e to a3c367e Compare July 31, 2026 17:53
@netlify

netlify Bot commented Jul 31, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for treeherder ready!

Name Link
🔨 Latest commit 79651ce
🔍 Latest deploy log https://app.netlify.com/projects/treeherder/deploys/6ad79c1b0935e1465c4edba3
😎 Deploy Preview https://deploy-preview-9528--treeherder.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.

@camd
camd force-pushed the camd/read-replica-queries branch 2 times, most recently from 9d7a88a to 09f99ef Compare August 16, 2026 03:29
@camd
camd force-pushed the camd/read-replica-queries branch 2 times, most recently from 2e29f73 to c1adde3 Compare August 29, 2026 20:00
@camd
camd force-pushed the camd/read-replica-queries branch 2 times, most recently from 4f907fd to 57ace9d Compare September 11, 2026 20:54
@camd
camd force-pushed the camd/read-replica-queries branch 3 times, most recently from 5dcd8c5 to cc1f6fc Compare September 22, 2026 16:43
@camd
camd force-pushed the camd/read-replica-queries branch 2 times, most recently from 44488bb to a58b747 Compare October 1, 2026 16:21
@camd
camd force-pushed the camd/read-replica-queries branch 3 times, most recently from 8d0dcec to 9e72a86 Compare October 9, 2026 00:01
@camd
camd force-pushed the camd/read-replica-queries branch 2 times, most recently from 4771945 to 5c0c64f Compare October 13, 2026 16:13
camd added 11 commits October 20, 2026 09:16
Routes 10 read-only Perfherder viewsets to the read replica (PerformanceSignatureViewSet,
PerformancePlatformViewSet, PerformanceJobViewSet, PerformanceDatumViewSet,
PerformanceBugTemplateViewSet, PerformanceIssueTrackerViewSet, PerformanceSummary,
PerformanceAlertSummaryTasks, PerfCompareResults, TestSuiteHealthViewSet).
Adds integration tests for signature list and summary endpoints. Updates
test_perfcompare_api.py, test_performance_data_api.py, and
test_performance_bug_template_api.py to declare read_replica database access.
Two review fixes:

1. When the kill switch is off (READ_REPLICA_ENABLED=false or
   READ_REPLICA_DATABASE_URL unset), the mixin previously still wrapped
   dispatch, set the thread-local (a no-op without a router), and would
   emit a misleading "db_routing_fallback" log on any primary failure.
   The mixin now checks connections.databases for the alias and bypasses
   wrapping when absent.

2. The three test files that gained databases=["default","read_replica"]
   implicitly relied on fixtures pulling in transactional_db. Make the
   transactional requirement explicit with transaction=True so the
   behavior survives future fixture refactors.
The feature is a no-op locally with the default .env (both env vars
unset → no read_replica alias → mixin early-returns). Standard local
dev doesn't need to know about the routing. The design, code-level
docstring, and PR deploy notes cover the cases that do.
Successful replica routing was previously silent — only the fallback path
emitted a log line. Adds a single DEBUG-level log per routed request so a
developer (or ops) can grep \`db_routing routed_to=read_replica\` to confirm
which requests hit the replica.

Prod-silent by default (logger threshold is INFO); enable locally with
LOGGING_LEVEL=DEBUG.
Apply ReadReplicaMixin to JobsViewSet, JobsProjectViewSet, and PushViewSet so
the jobs-view polling GET endpoints read from the read_replica alias, relieving
the primary of the busiest read traffic in the app. Only safe HTTP methods
route; the existing one-shot fallback to primary is inherited unchanged. Gated
by READ_REPLICA_ENABLED, so this is a no-op until the replica is configured.

Existing tests that exercise these endpoints now need transaction=True and the
read_replica alias in their django_db marker, since the routed viewset reads
through a separate connection that only sees committed data.
Apply ReadReplicaMixin to SummaryByGroupName (/api/groupsummary/). This is one
of the heaviest read queries in the app: a multi-join aggregation over a full
day of jobs across autoland and mozilla-central, run once per platform, joining
Job/Push/JobType/JobLog/Group/GroupStatus. It is a pure GET reporting query and
tolerant of replica lag, so it is a strong candidate for offloading from the
primary. All tables are in the model app, already on the router allow-list.

The existing groupsummary tests need transaction=True and the read_replica
alias in their django_db marker, since the routed view reads through a separate
connection that only sees committed data.
@camd
camd force-pushed the camd/read-replica-queries branch from 5c0c64f to 79651ce Compare October 20, 2026 16:51

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants