MarkUs: Performance

PR #7678 — Resolving an N+1 query problem in the grading interface.


The Problem

The student listing page executed 200+ SQL queries where 1—2 would suffice. The pattern: an ActiveRecord query returns a collection of students, and the view iterates over the collection, accessing the .user association on each record. Without eager loading, Rails issues a separate SELECT for each association access.

# N+1: 1 query for students + N queries for users
students.each do |student|
  puts student.user.name
end

For a class of 200 students, this produces 201 SQL queries. Each query has fixed overhead (connection pooling, query parsing, network round-trip), making the total latency scale linearly with class size. At 50 concurrent TAs grading simultaneously, this creates connection pool exhaustion and observable page load degradation.


The Fix

Rails provides three eager loading strategies for eliminating N+1 queries:

MethodStrategySQL GeneratedUse When
includesPreload or left join (Rails decides)2 queries or 1 LEFT JOINDefault choice
preloadSeparate query per associationSELECT * FROM users WHERE id IN (...)Always want separate queries
eager_loadSingle LEFT OUTER JOIN1 query with JOINNeed to filter on association

The fix applied includes(:user) to the query scope:

# 2 queries total regardless of N
students.includes(:user).each do |student|
  puts student.user.name  # Already loaded in memory
end

Rails executes two queries: one for students and one batched query SELECT * FROM users WHERE id IN (1, 2, ..., N). The user records are cached in memory and associated with their student records, eliminating all subsequent queries.


Detection and Prevention

N+1 queries are detectable through several mechanisms:

Log analysis. Rails logs each SQL query. Repeated identical queries (differing only in the WHERE clause parameter) are a strong N+1 signal.

Bullet gem. Detects N+1 queries at runtime and logs warnings. Configured in development/test environments to surface eager loading opportunities.

Query count assertions. Test that a controller action executes a bounded number of queries:

assert_queries(2) do
  get :index
end

This prevents regressions: if a future change introduces an N+1, the test fails.


Performance Impact

The fix reduced page load time from ~3s to ~200ms for a 200-student class. The improvement is more pronounced at scale: the original O(N) query pattern becomes O(1) with eager loading, and the constant factor (2 queries vs 200+) eliminates connection pool contention during concurrent grading sessions.