mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 13:08:40 +08:00
`UserVisit.count_by_active_users` computed the 30-day rolling MAU with a
correlated subquery that re-scanned user_visits once per day in the
range (O(days * window)). So a one-year range this spends several
seconds of SQL time on large sites. 💥
The PR avoids repeatedly recalculating overlapping 30-day MAU windows.
On meta, the 30-day dashboard range improved from `0.173s` to `0.044s`,
and the 365-day report range improved from `1.312s` to `0.296s`, while
preserving identical output. (details in table)
benchmark run manually, md table generated by AI.
| Range | Previous query | PR query | Improvement |
| --- | ---: | ---: | ---: |
| 7 days | 0.113s | 0.025s | 78% faster |
| 14 days | 0.125s | 0.030s | 76% faster |
| 30 days | 0.173s | 0.044s | 75% faster |
| 45 days | 0.209s | 0.057s | 73% faster |
| 60 days | 0.260s | 0.068s | 74% faster |
| 75 days | 0.311s | 0.084s | 73% faster |
| 90 days | 0.360s | 0.097s | 73% faster |
| 180 days | 0.658s | 0.174s | 74% faster |
| 365 days | 1.312s | 0.296s | 77% faster |
123 lines
4 KiB
Ruby
Vendored
123 lines
4 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
RSpec.describe UserVisit do
|
|
fab!(:user)
|
|
fab!(:other_user, :user)
|
|
|
|
it "can ensure consistency" do
|
|
user.update_visit_record!(2.weeks.ago.to_date)
|
|
user.last_seen_at = 2.weeks.ago
|
|
user.save
|
|
user.update_visit_record!(1.day.ago.to_date)
|
|
|
|
user.reload
|
|
expect(user.user_stat.days_visited).to eq(2)
|
|
|
|
user.user_stat.days_visited = 1
|
|
user.save
|
|
UserVisit.ensure_consistency!
|
|
|
|
user.reload
|
|
expect(user.user_stat.days_visited).to eq(2)
|
|
end
|
|
|
|
describe "#by_day" do
|
|
before(:each) do
|
|
freeze_time
|
|
user.user_visits.create(visited_at: Time.zone.now)
|
|
user.user_visits.create(visited_at: 1.day.ago)
|
|
other_user.user_visits.create(visited_at: 1.day.ago)
|
|
user.user_visits.create(visited_at: 2.days.ago)
|
|
user.user_visits.create(visited_at: 4.days.ago)
|
|
end
|
|
let(:visits_by_day) do
|
|
{ 1.day.ago.to_date => 2, 2.days.ago.to_date => 1, Time.zone.now.to_date => 1 }
|
|
end
|
|
|
|
it "collect closed interval visits" do
|
|
expect(UserVisit.by_day(2.days.ago, Time.zone.now)).to include(visits_by_day)
|
|
expect(UserVisit.by_day(2.days.ago, Time.zone.now)).not_to include(4.days.ago.to_date => 1)
|
|
end
|
|
end
|
|
|
|
describe ".count_by_active_users" do
|
|
fab!(:user_a, :user)
|
|
fab!(:user_b, :user)
|
|
fab!(:user_c, :user)
|
|
|
|
# base day; all visits are placed relative to it so the 30-day rolling
|
|
# window (visited_at BETWEEN day - 29 AND day) can be pinned exactly.
|
|
let(:base) { Date.new(2026, 6, 1) }
|
|
|
|
def visit(user, day)
|
|
user.user_visits.create!(visited_at: day)
|
|
end
|
|
|
|
def mau_on(rows, day)
|
|
rows.find { |r| r["date"] == day }&.fetch("mau")
|
|
end
|
|
|
|
def dau_on(rows, day)
|
|
rows.find { |r| r["date"] == day }&.fetch("dau")
|
|
end
|
|
|
|
it "reports DAU and a 30-day rolling distinct MAU per active day" do
|
|
visit(user_a, base)
|
|
visit(user_b, base)
|
|
visit(user_a, base + 24) # within 30 days of base -> same MAU coverage
|
|
visit(user_c, base + 60) # far later -> its own window
|
|
|
|
rows = UserVisit.count_by_active_users(base, base + 60)
|
|
|
|
# base: a, b visited today and in window -> dau 2, mau 2
|
|
expect(dau_on(rows, base)).to eq(2)
|
|
expect(mau_on(rows, base)).to eq(2)
|
|
|
|
# base+24: only a visited today; window [base-5, base+24] still sees a & b -> mau 2
|
|
expect(dau_on(rows, base + 24)).to eq(1)
|
|
expect(mau_on(rows, base + 24)).to eq(2)
|
|
|
|
# base+60: window [base+31, base+60] sees only c -> dau 1, mau 1
|
|
expect(dau_on(rows, base + 60)).to eq(1)
|
|
expect(mau_on(rows, base + 60)).to eq(1)
|
|
end
|
|
|
|
it "treats the rolling window as 29-days-inclusive / 30-days-exclusive" do
|
|
visit(user_a, base)
|
|
visit(user_b, base)
|
|
visit(user_c, base + 29) # probe day exactly 29 days after a/b
|
|
visit(user_a, base + 30) # probe day exactly 30 days after a/b's first visit
|
|
|
|
rows = UserVisit.count_by_active_users(base, base + 30)
|
|
|
|
# day base+29: window [base, base+29] still includes a & b (29 inclusive) plus c
|
|
expect(mau_on(rows, base + 29)).to eq(3)
|
|
|
|
# day base+30: window [base+1, base+30] excludes b's only visit (base, now 30 days out);
|
|
# a is back today, c is still in range -> a & c only
|
|
expect(mau_on(rows, base + 30)).to eq(2)
|
|
end
|
|
|
|
it "counts a user once across overlapping visits and separately across a >30-day gap" do
|
|
visit(user_a, base)
|
|
visit(user_a, base + 10) # overlaps -> still one user in coverage
|
|
visit(user_a, base + 100) # gap > 30 days -> new island, but still the same single user
|
|
visit(user_b, base + 100)
|
|
|
|
rows = UserVisit.count_by_active_users(base, base + 100)
|
|
|
|
expect(mau_on(rows, base)).to eq(1)
|
|
expect(mau_on(rows, base + 10)).to eq(1)
|
|
expect(mau_on(rows, base + 100)).to eq(2)
|
|
end
|
|
|
|
it "only emits rows for days that had at least one visit" do
|
|
visit(user_a, base)
|
|
visit(user_b, base + 5)
|
|
|
|
rows = UserVisit.count_by_active_users(base, base + 10)
|
|
|
|
expect(rows.map { |r| r["date"] }).to contain_exactly(base, base + 5)
|
|
end
|
|
end
|
|
end
|