|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +require 'rails_helper' |
| 4 | + |
| 5 | +# AI generated to capture performance characteristics of ProxyExercise |
| 6 | +RSpec.describe ProxyExercise, skip: 'Slow tests, activate manually' do |
| 7 | + # Simple SQL capture helper using ActiveSupport notifications |
| 8 | + def capture_sql(&) |
| 9 | + events = [] |
| 10 | + callback = ->(_name, _start, _finish, _id, payload) { events << payload[:sql] } |
| 11 | + ActiveSupport::Notifications.subscribed(callback, 'sql.active_record', &) |
| 12 | + events |
| 13 | + end |
| 14 | + |
| 15 | + def query_count(&) |
| 16 | + capture_sql(&).size |
| 17 | + end |
| 18 | + |
| 19 | + describe 'performance characteristics' do |
| 20 | + describe '#count_files' do |
| 21 | + it 'uses an efficient COUNT query instead of loading all exercises' do |
| 22 | + pe = create(:proxy_exercise) |
| 23 | + create_list(:dummy, 20).each {|ex| pe.exercises << ex } |
| 24 | + |
| 25 | + # Warm-up (avoid counting queries from lazy loading of associations) |
| 26 | + pe.reload |
| 27 | + |
| 28 | + sqls = capture_sql { expect(pe.count_files).to eq(20) } |
| 29 | + |
| 30 | + # We expect a single COUNT(*) style query (plus, in some adapters, an implicit PRAGMA/SHOW if any) |
| 31 | + # Be tolerant: assert that at least one of the queries contains COUNT and total number is small |
| 32 | + has_count = sqls.any? {|sql| sql =~ /COUNT\s*\(/i } |
| 33 | + expect(has_count).to be(true), "Expected a COUNT query, got: #{sqls.inspect}" |
| 34 | + expect(sqls.size).to be <= 3 # 1 main COUNT + possible noise |
| 35 | + end |
| 36 | + end |
| 37 | + |
| 38 | + describe "#get_matching_exercise when algorithm is 'random'" do |
| 39 | + let(:user) { create(:learner) } |
| 40 | + |
| 41 | + it 'does not increase the number of SQL queries with a larger exercise pool' do |
| 42 | + pe_small = create(:proxy_exercise, algorithm: 'random') |
| 43 | + chosen_small = create(:dummy) |
| 44 | + others_small = create_list(:dummy, 9) |
| 45 | + pe_small.exercises << ([chosen_small] + others_small) |
| 46 | + allow(pe_small).to receive_message_chain(:exercises, :sample).and_return(chosen_small) |
| 47 | + |
| 48 | + pe_large = create(:proxy_exercise, algorithm: 'random') |
| 49 | + chosen_large = create(:dummy) |
| 50 | + others_large = create_list(:dummy, 99) |
| 51 | + pe_large.exercises << ([chosen_large] + others_large) |
| 52 | + allow(pe_large).to receive_message_chain(:exercises, :sample).and_return(chosen_large) |
| 53 | + |
| 54 | + # Measure queries for small pool |
| 55 | + small_queries = query_count { pe_small.get_matching_exercise(user) } |
| 56 | + |
| 57 | + # Measure queries for large pool |
| 58 | + large_queries = query_count { pe_large.get_matching_exercise(user) } |
| 59 | + |
| 60 | + # The random algorithm should not need additional queries proportional to the pool size. |
| 61 | + # Allow a tiny slack for adapter differences and inserts of assignment rows. |
| 62 | + expect(large_queries - small_queries).to be <= 2, |
| 63 | + "Expected roughly constant query count, got small=#{small_queries}, large=#{large_queries}" |
| 64 | + end |
| 65 | + end |
| 66 | + |
| 67 | + describe "#get_matching_exercise when algorithm is 'best_match'" do |
| 68 | + it 'reports current SQL query counts across pool sizes (opt-in via PERF=1)' do |
| 69 | + ActiveRecord.verbose_query_logs = true |
| 70 | + |
| 71 | + # Helper to build an exercise with given tags and difficulty |
| 72 | + def build_tagged_exercise(difficulty:, tags:) |
| 73 | + ex = create(:dummy, expected_difficulty: difficulty) |
| 74 | + tags.each do |tag| |
| 75 | + ExerciseTag.create!(exercise: ex, tag:, factor: 1) |
| 76 | + end |
| 77 | + ex |
| 78 | + end |
| 79 | + |
| 80 | + # Prepare a fixed tag universe to be stable across runs |
| 81 | + tags = (1..5).map {|i| Tag.create!(name: "perf-tag-#{i}-#{SecureRandom.hex(2)}") } |
| 82 | + |
| 83 | + # Build a small history of exercises the user has accessed, covering all tags |
| 84 | + user = create(:learner) |
| 85 | + seen_exercises = Array.new(5) {|i| build_tagged_exercise(difficulty: 2, tags: [tags[i]]) } |
| 86 | + # Create real submissions for the user to reflect accessed exercises (no stubbing) |
| 87 | + seen_exercises.each do |ex| |
| 88 | + Submission.create!(exercise: ex, contributor: user, cause: 'submit') |
| 89 | + end |
| 90 | + |
| 91 | + def build_pool(size:, tags:) |
| 92 | + pe = create(:proxy_exercise, algorithm: 'best_match') |
| 93 | + # Distribute tags and difficulties deterministically |
| 94 | + exercises = (1..size).map do |i| |
| 95 | + difficulty = 1 + (i % 3) # 1..3 |
| 96 | + assigned_tags = [tags[i % tags.size], tags[(i + 1) % tags.size]].uniq |
| 97 | + build_tagged_exercise(difficulty:, tags: assigned_tags) |
| 98 | + end |
| 99 | + pe.exercises << exercises |
| 100 | + pe |
| 101 | + end |
| 102 | + |
| 103 | + # Measure across multiple pool sizes |
| 104 | + sizes = [10, 30, 60] |
| 105 | + # sizes = [1] |
| 106 | + results = {} |
| 107 | + sizes.each do |n| |
| 108 | + pe = build_pool(size: n, tags:) |
| 109 | + # Warm up: ensure associations are loaded enough to avoid counting lazy first-time loads unrelated to algorithm |
| 110 | + pe.reload |
| 111 | + queries = query_count { pe.get_matching_exercise(user) } |
| 112 | + results[n] = queries |
| 113 | + end |
| 114 | + |
| 115 | + puts "BEST_MATCH query counts by pool size: #{results.inspect}" |
| 116 | + |
| 117 | + # Very loose sanity checks — this spec primarily reports the current optimization level. |
| 118 | + expect(results.values.all? {|q| q.positive? }).to be(true) |
| 119 | + end |
| 120 | + end |
| 121 | + |
| 122 | + describe "#get_matching_exercise 'best_match' with varying tag counts" do |
| 123 | + it 'reports current SQL query counts across different tags per exercise (opt-in via PERF=1)' do |
| 124 | + # skip('Set PERF=1 to run performance specs') unless ENV['PERF'] |
| 125 | + |
| 126 | + # Helper to build an exercise with given tags and difficulty |
| 127 | + def build_tagged_exercise(difficulty:, tags:) |
| 128 | + ex = create(:dummy, expected_difficulty: difficulty) |
| 129 | + tags.each do |tag| |
| 130 | + ExerciseTag.create!(exercise: ex, tag: tag, factor: 1) |
| 131 | + end |
| 132 | + ex |
| 133 | + end |
| 134 | + |
| 135 | + user = create(:learner) |
| 136 | + |
| 137 | + # Build a small history of exercises the user has accessed, covering some tags |
| 138 | + base_tags = (1..8).map {|i| Tag.create!(name: "var-tags-base-#{i}-#{SecureRandom.hex(2)}") } |
| 139 | + seen_exercises = base_tags.first(4).map {|t| build_tagged_exercise(difficulty: 2, tags: [t]) } |
| 140 | + # Create real submissions for the user to reflect accessed exercises (no stubbing) |
| 141 | + seen_exercises.each do |ex| |
| 142 | + Submission.create!(exercise: ex, contributor: user, cause: 'submit') |
| 143 | + end |
| 144 | + |
| 145 | + def build_pool_with_tag_count(size:, tag_universe:, tags_per_ex:) |
| 146 | + pe = create(:proxy_exercise, algorithm: 'best_match') |
| 147 | + exercises = (1..size).map do |i| |
| 148 | + difficulty = 1 + (i % 3) # 1..3 |
| 149 | + assigned = (0...tags_per_ex).map {|k| tag_universe[(i + k) % tag_universe.size] }.uniq |
| 150 | + build_tagged_exercise(difficulty: difficulty, tags: assigned) |
| 151 | + end |
| 152 | + pe.exercises << exercises |
| 153 | + pe |
| 154 | + end |
| 155 | + |
| 156 | + # Vary the number of tags per exercise while keeping pool size constant |
| 157 | + tags_per_exercise = [1, 2, 3, 5] |
| 158 | + tag_universe = (1..10).map {|i| Tag.create!(name: "var-tags-univ-#{i}-#{SecureRandom.hex(2)}") } |
| 159 | + results = {} |
| 160 | + tags_per_exercise.each do |k| |
| 161 | + pe = build_pool_with_tag_count(size: 30, tag_universe: tag_universe, tags_per_ex: k) |
| 162 | + # Warm up: ensure associations are loaded enough to avoid counting lazy first-time loads unrelated to algorithm |
| 163 | + pe.reload |
| 164 | + queries = query_count { pe.get_matching_exercise(user) } |
| 165 | + results[k] = queries |
| 166 | + end |
| 167 | + |
| 168 | + puts "BEST_MATCH query counts by tags per exercise: #{results.inspect}" |
| 169 | + |
| 170 | + # Loose sanity check |
| 171 | + expect(results.values.all? {|q| q.positive? }).to be(true) |
| 172 | + end |
| 173 | + end |
| 174 | + end |
| 175 | +end |
0 commit comments