Project

General

Profile

Patch #44274 » journal-benchmark-script.patch

Florian Walchshofer, 2026-10-18 21:29

View differences:

extra/benchmarks/bench_journals.rb
1
# frozen_string_literal: true
2

  
3
# Benchmark of the journal queries affected by the Journal/Issue decoupling (#44274).
4
#
5
# Runs with the same data on both variants, eg.:
6
#
7
#   git checkout 7.0-stable         && bin/rails r extra/benchmarks/bench_journals.rb
8
#   git checkout journal-decoupling && bin/rails r extra/benchmarks/bench_journals.rb
9
#   CLEANUP=1 bin/rails r extra/benchmarks/bench_journals.rb
10
#
11
# The benchmark data is created on the first run and kept (and analyzed),
12
# so that both variants run against the same data and statistics.
13
# Use CLEANUP=1 to remove it. Do not run it on a production database.
14
#
15
# Environment variables:
16
# * PROJECTS (default 20), ISSUES (default 20000), JOURNALS_PER_ISSUE (default 10):
17
#   size of the data, used when the data is created
18
# * ITER (default 10): iterations of each benchmark, the median is reported
19
# * ONLY: comma separated names of the benchmarks to run (default all)
20
# * EXPLAIN=1: prints the query plans of the visible journals query
21

  
22
require 'benchmark'
23

  
24
# Do not measure the SQL logging (eg. verbose query logs in development)
25
ActiveRecord::Base.logger = nil
26

  
27
PREFIX = 'bench-journals'
28
projects_count = (ENV['PROJECTS'] || '20').to_i
29
issues_count = (ENV['ISSUES'] || '20000').to_i
30
journals_per_issue = (ENV['JOURNALS_PER_ISSUE'] || '10').to_i
31
iterations = (ENV['ITER'] || '10').to_i
32

  
33
def bench_projects
34
  Project.where("identifier LIKE ?", "#{PREFIX}-%").order(:id)
35
end
36

  
37
def bench_users
38
  User.where("login LIKE ?", "#{PREFIX.tr('-', '_')}_%").order(:id)
39
end
40

  
41
def cleanup
42
  projects = bench_projects.to_a
43
  project_ids = projects.map(&:id)
44
  issue_ids = Issue.where(:project_id => project_ids).pluck(:id)
45
  ActiveRecord::Base.transaction do
46
    journal_ids = Journal.where(:journalized_type => 'Issue', :journalized_id => issue_ids).pluck(:id)
47
    journal_ids.each_slice(10_000) do |ids|
48
      JournalDetail.where(:journal_id => ids).delete_all
49
      Journal.where(:id => ids).delete_all
50
    end
51
    Issue.where(:id => issue_ids).delete_all
52
    member_ids = Member.where(:project_id => project_ids).pluck(:id)
53
    MemberRole.where(:member_id => member_ids).delete_all
54
    Member.where(:id => member_ids).delete_all
55
    EnabledModule.where(:project_id => project_ids).delete_all
56
    Project.connection.execute("DELETE FROM projects_trackers WHERE project_id IN (#{project_ids.join(',')})") if project_ids.any?
57
    Project.where(:id => project_ids).delete_all
58
    bench_users.each(&:destroy)
59
    Role.where("name LIKE ?", "#{PREFIX} %").delete_all
60
  end
61
  Project.rebuild_tree! if Project.respond_to?(:rebuild_tree!)
62
  puts "Benchmark data removed (#{issue_ids.size} issues)."
63
end
64

  
65
def create_data(projects_count, issues_count, journals_per_issue)
66
  now = Time.now.utc
67
  rand = Random.new(44274)
68
  tracker = Tracker.order(:position).first || raise('No tracker found, load the default data')
69
  statuses = IssueStatus.order(:position).to_a
70
  priority = IssuePriority.default || IssuePriority.first || raise('No issue priority found')
71
  admin = User.where(:admin => true).active.first || raise('No active admin found')
72

  
73
  ActiveRecord::Base.transaction do
74
    manager = Role.create!(:name => "#{PREFIX} manager", :permissions => [:view_issues, :view_private_notes])
75
    reporter = Role.create!(:name => "#{PREFIX} reporter", :permissions => [:view_issues])
76
    member = User.new(:firstname => 'Bench', :lastname => 'Member', :mail => "#{PREFIX}-member@example.net")
77
    member.login = "#{PREFIX.tr('-', '_')}_member"
78
    member.save!
79

  
80
    projects = Array.new(projects_count) do |i|
81
      project = Project.new(:name => "Bench journals #{i}", :identifier => "#{PREFIX}-#{i}",
82
                            :is_public => i % 4 != 0)
83
      project.enabled_module_names = ['issue_tracking']
84
      project.trackers = [tracker]
85
      project.save!
86
      # The member is manager of a quarter of the projects, reporter of another quarter
87
      if i % 4 == 1
88
        Member.create!(:project => project, :principal => member, :roles => [manager])
89
      elsif i % 4 == 2
90
        Member.create!(:project => project, :principal => member, :roles => [reporter])
91
      end
92
      project
93
    end
94
    print "Projects: #{projects.size}\nInserting issues"
95

  
96
    issues_count.times.each_slice(5_000) do |slice|
97
      rows = slice.map do |i|
98
        created_on = now - rand.rand(365 * 24 * 3600)
99
        {
100
          :project_id => projects[i % projects.size].id, :tracker_id => tracker.id,
101
          :subject => "Bench issue #{i}", :author_id => admin.id,
102
          :status_id => statuses[i % statuses.size].id, :priority_id => priority.id,
103
          :is_private => (i % 10 == 0), :lft => 1, :rgt => 2, :lock_version => 0, :done_ratio => 0,
104
          :created_on => created_on, :updated_on => created_on
105
        }
106
      end
107
      Issue.insert_all(rows)
108
      print '.'
109
    end
110
    Issue.where(:project_id => projects.map(&:id)).update_all("root_id = id")
111
    puts
112

  
113
    print "Inserting journals"
114
    users = [admin.id, member.id]
115
    Issue.where(:project_id => projects.map(&:id)).in_batches(:of => 1_000) do |batch|
116
      journal_rows = []
117
      batch.pluck(:id, :created_on).each do |issue_id, issue_created_on|
118
        journals_per_issue.times do |j|
119
          kind = rand.rand(10)
120
          journal_rows << {
121
            :journalized_type => 'Issue', :journalized_id => issue_id, :user_id => users[j % 2],
122
            :notes => (kind < 6 ? "Note #{j} on issue #{issue_id}" : ''),
123
            :private_notes => (kind == 0),
124
            :created_on => issue_created_on + ((j + 1) * 3600), :updated_on => issue_created_on + ((j + 1) * 3600)
125
          }
126
        end
127
      end
128
      Journal.insert_all(journal_rows)
129
      # :returning is not supported by all databases
130
      ids = Journal.where(:journalized_type => 'Issue', :journalized_id => batch.select(:id)).order(:id).pluck(:id)
131
      # A third of the journals without notes is a status change
132
      detail_rows = ids.each_with_index.filter_map do |id, index|
133
        next unless index % 3 == 0
134

  
135
        {:journal_id => id, :property => 'attr', :prop_key => 'status_id',
136
         :old_value => statuses.first.id.to_s, :value => statuses.last.id.to_s}
137
      end
138
      JournalDetail.insert_all(detail_rows) if detail_rows.any?
139
      print '.'
140
    end
141
    puts
142
  end
143
end
144

  
145
if ENV['CLEANUP']
146
  cleanup
147
  exit
148
end
149

  
150
if bench_projects.empty?
151
  create_data(projects_count, issues_count, journals_per_issue)
152
  conn = ActiveRecord::Base.connection
153
  %w(issues journals journal_details projects members member_roles).each do |table|
154
    case conn.adapter_name
155
    when /postgres/i then conn.execute("ANALYZE #{table}")
156
    when /mysql|trilogy/i then conn.execute("ANALYZE TABLE #{table}")
157
    end
158
  end
159
end
160

  
161
projects = bench_projects.to_a
162
project_ids = projects.map(&:id)
163
member = bench_users.first
164
admin = User.where(:admin => true).active.first
165
anonymous = User.anonymous
166
bench_issue_ids = Issue.where(:project_id => project_ids).order(:id).pluck(:id)
167
bench_journals = Journal.where(:journalized_type => 'Issue', :journalized_id => bench_issue_ids)
168
latest = bench_journals.maximum(:created_on)
169
sample_journal_id = bench_journals.where(:private_notes => false).order(:id).offset(bench_journals.count / 2).pick(:id)
170

  
171
variant = `git rev-parse --abbrev-ref HEAD 2>/dev/null`.strip
172
variant = Journal.respond_to?(:joins_journalized) ? 'journal-decoupling' : 'legacy' if variant.empty?
173
puts "variant=#{variant} ruby=#{RUBY_VERSION} rails=#{Rails.version} db=#{ActiveRecord::Base.connection.adapter_name} " \
174
     "projects=#{projects.size} issues=#{bench_issue_ids.size} journals=#{bench_journals.count} iter=#{iterations}"
175

  
176
if ENV['EXPLAIN']
177
  [admin, member].each do |user|
178
    puts "\nEXPLAIN Journal.visible(#{user.login}).order(id DESC).limit(25):"
179
    puts Journal.visible(user).order(:id => :desc).limit(25).explain.inspect
180
  end
181
end
182

  
183
benchmarks = {
184
  'visible_count' => lambda {|user| Journal.visible(user).count},
185
  'visible_latest_25' => lambda {|user| Journal.visible(user).reorder(:id => :desc).limit(25).ids.sum},
186
  'visible_find' => lambda {|user| Journal.visible(user).where(:id => sample_journal_id).ids.sum},
187
  'activity_project' => lambda do |user|
188
    Redmine::Activity::Fetcher.new(user, :project => projects[1], :with_subprojects => true).
189
      events(latest - 30.days, latest).size
190
  end,
191
  'activity_all_30d' => lambda {|user| Redmine::Activity::Fetcher.new(user).events(latest - 30.days, latest).size},
192
  'activity_last_by_project' => lambda {|user| Redmine::Activity::Fetcher.new(user).events(nil, nil, :last_by_project => true).size},
193
  'issue_query_journals' => lambda do |user|
194
    User.current = user
195
    IssueQuery.new(:name => '_').journals(:order => "#{Journal.table_name}.created_on DESC", :limit => 25).sum(&:id)
196
  end,
197
  'issue_last_notes_25' => lambda do |user|
198
    issues = Issue.where(:id => bench_issue_ids.last(25)).to_a
199
    Issue.load_visible_last_notes(issues, user)
200
    Issue.load_visible_last_updated_by(issues, user)
201
    issues.sum {|issue| issue.last_notes.to_s.size}
202
  end
203
}
204

  
205
puts
206
puts format('%-26s %-10s %10s %10s %10s %14s', 'benchmark', 'user', 'median_ms', 'min_ms', 'max_ms', 'result')
207
only = ENV['ONLY'].to_s.split(',').map(&:strip).reject(&:empty?)
208
benchmarks.slice!(*only) if only.any?
209
benchmarks.each do |name, block|
210
  {'admin' => admin, 'member' => member, 'anonymous' => anonymous}.each do |label, user|
211
    result = block.call(user) # warm up
212
    times = ApplicationRecord.uncached do
213
      Array.new(iterations) { Benchmark.realtime { block.call(user) } * 1000 }
214
    end.sort
215
    median = times.size.odd? ? times[times.size / 2] : (times[(times.size / 2) - 1] + times[times.size / 2]) / 2
216
    puts format('%-26s %-10s %10.2f %10.2f %10.2f %14s', name, label, median, times.first, times.last, result)
217
  end
218
end
219
User.current = nil
(2-2/4)