diff --git a/lib/tasks/db_benchmark.rake b/lib/tasks/db_benchmark.rake new file mode 100644 index 000000000..8e004f7da --- /dev/null +++ b/lib/tasks/db_benchmark.rake @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +# rubocop:disable Style/FormatStringToken +namespace :db do + desc 'Capture performance baseline for key database operations' + task benchmark: :environment do + require 'benchmark' + require 'json' + + results = {} + component = Component.joins(:rules).where('rules_count > 0').first + project = Project.joins(:components).first + + abort 'No component with rules found. Seed the database first.' unless component + abort 'No project with components found. Seed the database first.' unless project + + puts 'Benchmarking against:' + puts " Component: #{component.name} (#{component.rules_count} rules)" + puts " Project: #{project.name} (#{project.components.count} components)" + puts + + iterations = 10 + + # 1. Component show (full rule load with associations) + puts '1. Component show (full rule load)...' + results['component_show_ms'] = (Benchmark.measure do + iterations.times do + component.reload + component.rules.includes( + :reviews, :disa_rule_descriptions, :checks, + :satisfies, :satisfied_by, + srg_rule: %i[disa_rule_descriptions checks] + ).to_a + end + end.real / iterations * 1000).round(1) + + # 2. Paginated comments (triage table) + puts '2. Paginated comments...' + results['paginated_comments_ms'] = (Benchmark.measure do + iterations.times do + component.paginated_comments(triage_status: 'all', per_page: 25) + end + end.real / iterations * 1000).round(1) + + # 3. Pending comment counts (project index) + puts '3. Pending comment counts...' + component_ids = project.component_ids + results['pending_counts_ms'] = (Benchmark.measure do + iterations.times { Component.pending_comment_counts(component_ids) } + end.real / iterations * 1000).round(1) + + # 4. SQL query count for component show + puts '4. Counting queries for component show...' + query_count = 0 + counter = lambda do |_name, _start, _finish, _id, payload| + sql = payload[:sql].to_s + next if payload[:name] == 'SCHEMA' + next if sql.match?(/\A\s*(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)/i) + + query_count += 1 + end + ActiveSupport::Notifications.subscribed(counter, 'sql.active_record') do + component.reload + component.rules.includes( + :reviews, :disa_rule_descriptions, :checks + ).to_a + end + results['component_show_queries'] = query_count + + # 5. SQL query count for paginated_comments + puts '5. Counting queries for paginated_comments...' + query_count = 0 + ActiveSupport::Notifications.subscribed(counter, 'sql.active_record') do + component.paginated_comments(triage_status: 'all', per_page: 25) + end + results['paginated_comments_queries'] = query_count + + # 6. Component duplicate (write performance) + puts '6. Component duplicate...' + if component.rules.exists?(locked: false) + component.rules.update_all(locked: true) # rubocop:disable Rails/SkipsModelValidations + component.update_column(:released, true) # rubocop:disable Rails/SkipsModelValidations + end + results['component_duplicate_ms'] = (Benchmark.measure do + dup = component.duplicate( + new_name: 'Benchmark Dup', new_version: 99, + new_release: 99, new_title: 'Benchmark', new_description: 'perf test' + ) + dup&.destroy + end.real * 1000).round(1) + + # Print results + puts + puts '=' * 60 + puts 'PERFORMANCE BASELINE' + puts '=' * 60 + results.each do |key, value| + unit = key.end_with?('_queries') ? 'queries' : 'ms' + puts format(' %-35s %8s %s', key, value, unit) + end + puts '=' * 60 + + # Save to file + output_dir = Rails.root.join('tmp') + FileUtils.mkdir_p(output_dir) + output_path = output_dir.join("db_benchmark_#{Time.zone.now.strftime('%Y%m%d_%H%M%S')}.json") + File.write(output_path, JSON.pretty_generate( + captured_at: Time.now.iso8601, + component: { id: component.id, name: component.name, rules_count: component.rules_count }, + project: { id: project.id, name: project.name }, + iterations: iterations, + results: results + )) + puts "\nSaved to #{output_path}" + + # Compare with previous baseline if exists + previous = Dir.glob(output_dir.join('db_benchmark_*.json')).reverse[1] + if previous + prev_data = JSON.parse(File.read(previous)) + puts "\nComparison with #{File.basename(previous)}:" + prev_data['results'].each do |key, prev_val| + next unless results[key] + + curr = results[key].to_f + prev = prev_val.to_f + next if prev.zero? + + pct = ((curr - prev) / prev * 100).round(1) + indicator = if pct > 10 + "⚠️ +#{pct}% REGRESSION" + elsif pct < -10 + "✅ #{pct}% improvement" + else + "→ #{pct}% (within threshold)" + end + puts format(' %-35s %s', key, indicator) + end + end + end +end +# rubocop:enable Style/FormatStringToken diff --git a/lib/tasks/db_validate.rake b/lib/tasks/db_validate.rake new file mode 100644 index 000000000..aba8c2c60 --- /dev/null +++ b/lib/tasks/db_validate.rake @@ -0,0 +1,232 @@ +# frozen_string_literal: true + +# rubocop:disable Style/FormatStringToken +namespace :db do + desc 'Validate data integrity after migration phase' + task validate: :environment do + errors = [] + conn = ActiveRecord::Base.connection + + puts '=' * 60 + puts 'DATABASE INTEGRITY VALIDATION' + puts '=' * 60 + + # 1. Row counts + puts "\n--- Row Counts ---" + %w[ + users projects components security_requirements_guides stigs + base_rules reviews reactions rule_satisfactions memberships + audits additional_questions additional_answers + project_access_requests + ].each do |table| + next unless conn.table_exists?(table) + + count = conn.execute("SELECT COUNT(*) AS c FROM #{conn.quote_table_name(table)}").first['c'] + puts format(' %-40s %8d', table, count) + end + + # 2. Orphaned FK records + puts "\n--- Orphaned Record Checks ---" + orphan_checks = { + 'base_rules → components' => <<~SQL.squish, + SELECT COUNT(*) AS c FROM base_rules r + LEFT JOIN components c ON r.component_id = c.id + WHERE c.id IS NULL AND r.type = 'Rule' AND r.deleted_at IS NULL + SQL + 'base_rules → security_requirements_guides' => <<~SQL.squish, + SELECT COUNT(*) AS c FROM base_rules r + LEFT JOIN security_requirements_guides s ON r.security_requirements_guide_id = s.id + WHERE s.id IS NULL AND r.type = 'SrgRule' + SQL + 'reviews → users' => <<~SQL.squish, + SELECT COUNT(*) AS c FROM reviews r + LEFT JOIN users u ON r.user_id = u.id + WHERE r.user_id IS NOT NULL AND u.id IS NULL + SQL + 'reviews → base_rules (rule_id)' => <<~SQL.squish, + SELECT COUNT(*) AS c FROM reviews r + LEFT JOIN base_rules br ON r.rule_id = br.id + WHERE r.rule_id IS NOT NULL AND br.id IS NULL + SQL + 'reviews → responding_to (self-ref)' => <<~SQL.squish, + SELECT COUNT(*) AS c FROM reviews r + LEFT JOIN reviews parent ON r.responding_to_review_id = parent.id + WHERE r.responding_to_review_id IS NOT NULL AND parent.id IS NULL + SQL + 'reviews → duplicate_of (self-ref)' => <<~SQL.squish, + SELECT COUNT(*) AS c FROM reviews r + LEFT JOIN reviews target ON r.duplicate_of_review_id = target.id + WHERE r.duplicate_of_review_id IS NOT NULL AND target.id IS NULL + SQL + 'reactions → reviews' => <<~SQL.squish, + SELECT COUNT(*) AS c FROM reactions r + LEFT JOIN reviews rv ON r.review_id = rv.id + WHERE rv.id IS NULL + SQL + 'reactions → users' => <<~SQL.squish, + SELECT COUNT(*) AS c FROM reactions r + LEFT JOIN users u ON r.user_id = u.id + WHERE u.id IS NULL + SQL + 'memberships → users' => <<~SQL.squish, + SELECT COUNT(*) AS c FROM memberships m + LEFT JOIN users u ON m.user_id = u.id + WHERE u.id IS NULL + SQL + 'rule_satisfactions → base_rules (rule_id)' => <<~SQL.squish, + SELECT COUNT(*) AS c FROM rule_satisfactions rs + LEFT JOIN base_rules r ON rs.rule_id = r.id + WHERE r.id IS NULL + SQL + 'rule_satisfactions → base_rules (satisfied_by)' => <<~SQL.squish, + SELECT COUNT(*) AS c FROM rule_satisfactions rs + LEFT JOIN base_rules r ON rs.satisfied_by_rule_id = r.id + WHERE r.id IS NULL + SQL + 'components → projects' => <<~SQL.squish, + SELECT COUNT(*) AS c FROM components c + LEFT JOIN projects p ON c.project_id = p.id + WHERE p.id IS NULL + SQL + 'components → security_requirements_guides' => <<~SQL.squish + SELECT COUNT(*) AS c FROM components c + LEFT JOIN security_requirements_guides s ON c.security_requirements_guide_id = s.id + WHERE s.id IS NULL + SQL + } + + orphan_checks.each do |label, sql| + count = conn.execute(sql).first['c'].to_i + status = count.zero? ? '✓' : "✗ #{count} orphaned" + errors << "Orphaned: #{label} (#{count})" unless count.zero? + puts format(' %-50s %s', label, status) + end + + # 3. Unexpected NULLs in required fields + puts "\n--- NULL Checks (required fields) ---" + null_checks = { + 'base_rules.type (STI)' => + 'SELECT COUNT(*) AS c FROM base_rules WHERE type IS NULL', + 'base_rules.component_id (Rules)' => + "SELECT COUNT(*) AS c FROM base_rules WHERE type = 'Rule' AND component_id IS NULL AND deleted_at IS NULL", + 'reviews.action' => + 'SELECT COUNT(*) AS c FROM reviews WHERE action IS NULL', + 'components.project_id' => + 'SELECT COUNT(*) AS c FROM components WHERE project_id IS NULL', + 'components.prefix' => + "SELECT COUNT(*) AS c FROM components WHERE prefix IS NULL OR prefix = ''", + 'memberships.user_id' => + 'SELECT COUNT(*) AS c FROM memberships WHERE user_id IS NULL', + 'memberships.role' => + "SELECT COUNT(*) AS c FROM memberships WHERE role IS NULL OR role = ''" + } + + null_checks.each do |field, sql| + count = conn.execute(sql).first['c'].to_i + status = count.zero? ? '✓' : "✗ #{count} unexpected NULLs" + errors << "NULLs: #{field} (#{count})" unless count.zero? + puts format(' %-50s %s', field, status) + end + + # 4. Duplicate detection + puts "\n--- Duplicate Checks ---" + dup_checks = { + 'rule_satisfactions (rule_id, satisfied_by_rule_id)' => <<~SQL.squish, + SELECT COUNT(*) AS c FROM ( + SELECT rule_id, satisfied_by_rule_id + FROM rule_satisfactions + GROUP BY rule_id, satisfied_by_rule_id + HAVING COUNT(*) > 1 + ) dupes + SQL + 'reactions (review_id, user_id) uniqueness' => <<~SQL.squish, + SELECT COUNT(*) AS c FROM ( + SELECT review_id, user_id + FROM reactions + GROUP BY review_id, user_id + HAVING COUNT(*) > 1 + ) dupes + SQL + 'memberships (user_id, membership_type, membership_id)' => <<~SQL.squish + SELECT COUNT(*) AS c FROM ( + SELECT user_id, membership_type, membership_id + FROM memberships + GROUP BY user_id, membership_type, membership_id + HAVING COUNT(*) > 1 + ) dupes + SQL + } + + dup_checks.each do |label, sql| + count = conn.execute(sql).first['c'].to_i + status = count.zero? ? '✓' : "✗ #{count} duplicate sets" + errors << "Duplicates: #{label} (#{count})" unless count.zero? + puts format(' %-50s %s', label, status) + end + + # 5. Counter cache consistency + puts "\n--- Counter Cache Checks ---" + cache_sql = <<~SQL.squish + SELECT c.id, c.name, c.rules_count AS cached, + (SELECT COUNT(*) FROM base_rules r + WHERE r.component_id = c.id AND r.type = 'Rule' + AND r.deleted_at IS NULL) AS actual + FROM components c + WHERE c.rules_count != ( + SELECT COUNT(*) FROM base_rules r + WHERE r.component_id = c.id AND r.type = 'Rule' + AND r.deleted_at IS NULL + ) + SQL + mismatches = conn.execute(cache_sql).to_a + if mismatches.empty? + puts ' rules_count matches actual ✓' + else + mismatches.each do |m| + puts format(' %-50s ✗ cached=%d actual=%d', "Component #{m['id']} (#{m['name']})", m['cached'], m['actual']) + end + errors << "Counter cache drift: #{mismatches.size} components" + end + + # 6. Post-migration override tables (if they exist) + if conn.table_exists?('rule_check_overrides') + puts "\n--- Override Table Checks ---" + override_orphan_sql = <<~SQL.squish + SELECT COUNT(*) AS c FROM rule_check_overrides rco + LEFT JOIN base_rules r ON rco.rule_id = r.id + WHERE r.id IS NULL + SQL + count = conn.execute(override_orphan_sql).first['c'].to_i + status = count.zero? ? '✓' : "✗ #{count} orphaned" + errors << "Orphaned: rule_check_overrides (#{count})" unless count.zero? + puts format(' %-50s %s', 'rule_check_overrides → rules', status) + end + + if conn.table_exists?('rule_description_overrides') + override_orphan_sql = <<~SQL.squish + SELECT COUNT(*) AS c FROM rule_description_overrides rdo + LEFT JOIN base_rules r ON rdo.rule_id = r.id + WHERE r.id IS NULL + SQL + count = conn.execute(override_orphan_sql).first['c'].to_i + status = count.zero? ? '✓' : "✗ #{count} orphaned" + errors << "Orphaned: rule_description_overrides (#{count})" unless count.zero? + puts format(' %-50s %s', 'rule_description_overrides → rules', status) + end + + # Summary + puts + puts '=' * 60 + if errors.empty? + puts "ALL CHECKS PASSED ✓ (#{orphan_checks.size + null_checks.size + dup_checks.size + 1} checks)" + else + puts "#{errors.size} ISSUES FOUND:" + errors.each { |e| puts " ✗ #{e}" } + puts + puts 'Run db:validate after fixing to re-check.' + exit 1 + end + puts '=' * 60 + end +end +# rubocop:enable Style/FormatStringToken diff --git a/spec/support/query_counter.rb b/spec/support/query_counter.rb new file mode 100644 index 000000000..16d5ffd67 --- /dev/null +++ b/spec/support/query_counter.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module QueryCounter + def count_queries(&) + count = 0 + counter = lambda do |_name, _start, _finish, _id, payload| + sql = payload[:sql].to_s + next if payload[:name] == 'SCHEMA' + next if sql.match?(/\A\s*(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)/i) + + count += 1 + end + ActiveSupport::Notifications.subscribed(counter, 'sql.active_record', &) + count + end + + def assert_query_count_at_most(expected_max, message = nil, &) + actual = count_queries(&) + msg = message || "Expected at most #{expected_max} queries, got #{actual}" + expect(actual).to be <= expected_max, msg + end +end + +RSpec.configure do |config| + config.include QueryCounter, type: :request + config.include QueryCounter, type: :model +end