Skip to content

Commit 59c42e6

Browse files
Preserve rollup invalidations across concurrent refreshes
Co-authored-by: Amp <amp@ampcode.com>
1 parent 7e8ef0e commit 59c42e6

9 files changed

Lines changed: 110 additions & 20 deletions

ARCHITECTURE.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,9 +162,13 @@ disposable.
162162
[`DashboardRollupRefreshService`](app/services/dashboard_rollup_refresh_service.rb)
163163
rebuilds totals, dimensions, weekly projects, project details, filter options,
164164
activity graph and today's stats from the user's non-archived heartbeats. It
165-
atomically replaces all of one user's rows in a transaction. The refresh job
166-
marks the user dirty before enqueue, coalesces scheduling with a cache key, and
167-
uses a per-user GoodJob concurrency limit. Heartbeat commits, soft-delete/
165+
reads and replaces one user's rows in a repeatable-read transaction (callers
166+
with an existing transaction own its isolation level). Invalidations increment
167+
the user's durable `dashboard_rollup_generation`; the total row records the
168+
generation it read. A newer invalidation cannot be acknowledged by an older
169+
refresh. The job coalesces enqueueing with a disposable cache key and schedules
170+
a follow-up if generations still differ after completion. GoodJob serialises
171+
execution per user while allowing a pending follow-up. Heartbeat commits, soft-delete/
168172
restore, timezone changes, and project archive changes schedule refreshes.
169173

170174
[`ProfileStatsService`](app/services/profile_stats_service.rb) is a thin

app/jobs/dashboard_rollup_refresh_job.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,20 @@ class DashboardRollupRefreshJob < ApplicationJob
44
include GoodJob::ActiveJobExtensions::Concurrency
55

66
good_job_control_concurrency_with(
7-
total_limit: 1, key: -> { "dashboard_rollup_refresh_job_#{arguments.first}" }
7+
perform_limit: 1, key: -> { "dashboard_rollup_refresh_job_#{arguments.first}" }
88
)
9+
retry_on ActiveRecord::SerializationFailure, ActiveRecord::Deadlocked,
10+
GoodJob::ActiveJobExtensions::Concurrency::ConcurrencyExceededError, wait: 5.seconds, attempts: 10
911

1012
DEFAULT_WAIT = 2.minutes
1113
ENQUEUE_CACHE_KEY_PREFIX = "dashboard_rollup_refresh_enqueued".freeze
1214

1315
def self.schedule_for(user_id, wait: DEFAULT_WAIT)
1416
DashboardRollup.mark_dirty(user_id)
17+
enqueue_for(user_id, wait:)
18+
end
19+
20+
def self.enqueue_for(user_id, wait: DEFAULT_WAIT)
1521
return unless Rails.cache.write(enqueue_cache_key(user_id), true, expires_in: wait + 1.minute, unless_exist: true)
1622
set(wait: wait).perform_later(user_id)
1723
end
@@ -22,7 +28,9 @@ def perform(user_id)
2228
user = User.find_by(id: user_id)
2329
return unless user
2430
DashboardRollupRefreshService.new(user:).call
31+
refreshed = true
2532
ensure
2633
Rails.cache.delete(self.class.enqueue_cache_key(user_id))
34+
self.class.enqueue_for(user_id) if refreshed && DashboardRollup.dirty?(user_id)
2735
end
2836
end

app/models/dashboard_rollup.rb

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ class DashboardRollup < ApplicationRecord
66
TODAY_STATS_DIMENSION = "today_stats".freeze
77
FILTER_OPTIONS_DIMENSION = "filter_options".freeze
88
CODING_RHYTHM_DIMENSION = "coding_rhythm".freeze
9-
DIRTY_CACHE_KEY_PREFIX = "dashboard_rollup_dirty".freeze
109

1110
belongs_to :user
1211

@@ -20,8 +19,17 @@ class DashboardRollup < ApplicationRecord
2019
def total_dimension? = dimension == TOTAL_DIMENSION
2120
def bucket = bucket_value_present ? bucket_value : nil
2221

23-
def self.dirty_cache_key(user_id) = "#{DIRTY_CACHE_KEY_PREFIX}_#{user_id}"
24-
def self.mark_dirty(user_id) = Rails.cache.write(dirty_cache_key(user_id), true, expires_in: 1.day, unless_exist: true)
25-
def self.clear_dirty(user_id) = Rails.cache.delete(dirty_cache_key(user_id))
26-
def self.dirty?(user_id) = Rails.cache.exist?(dirty_cache_key(user_id))
22+
def self.generation(user_id) = User.where(id: user_id).pick(:dashboard_rollup_generation)
23+
24+
def self.mark_dirty(user_id)
25+
User.where(id: user_id).update_all("dashboard_rollup_generation = dashboard_rollup_generation + 1")
26+
end
27+
28+
def self.dirty?(user_id)
29+
current_generation = generation(user_id)
30+
return false unless current_generation
31+
32+
payload = find_by(user_id: user_id, dimension: TOTAL_DIMENSION)&.payload
33+
payload&.fetch("source_generation", nil) != current_generation
34+
end
2735
end

app/services/dashboard_rollup_refresh_service.rb

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,27 @@ class DashboardRollupRefreshService < ApplicationService
44

55
def initialize(user:)
66
@user = user
7-
@scope = user.heartbeats_excluding_archived_projects
87
end
98

109
def call
10+
# When called inside a transaction, its owner also owns the isolation level.
11+
isolation = :repeatable_read unless DashboardRollup.connection.transaction_open?
12+
DashboardRollup.transaction(isolation:) do
13+
@scope = @user.heartbeats_excluding_archived_projects
14+
records = build_records(DashboardRollup.generation(@user.id))
15+
DashboardRollup.where(user_id: @user.id).delete_all
16+
DashboardRollup.insert_all!(records)
17+
end
18+
end
19+
20+
private
21+
22+
def build_records(generation)
1123
now = Time.current
1224
records = [
1325
build_record(dimension: DashboardRollup::TOTAL_DIMENSION, bucket: nil,
1426
total_seconds: @scope.duration_seconds, now:,
27+
payload: { source_generation: generation },
1528
source_heartbeats_count: @scope.count,
1629
source_max_heartbeat_time: @scope.maximum(:time)),
1730
build_record(dimension: DashboardRollup::FILTER_OPTIONS_DIMENSION, bucket: nil,
@@ -52,15 +65,9 @@ def call
5265
)
5366
end
5467

55-
DashboardRollup.transaction do
56-
DashboardRollup.where(user_id: @user.id).delete_all
57-
DashboardRollup.insert_all!(records)
58-
end
59-
DashboardRollup.clear_dirty(@user.id)
68+
records
6069
end
6170

62-
private
63-
6471
def build_record(
6572
dimension:,
6673
bucket:,

app/services/dashboard_stats.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ def aggregate_rollup_stale?(total_row)
266266

267267
def schedule_rollup_refresh(wait:)
268268
return if @rollup_refresh_scheduled
269-
DashboardRollupRefreshJob.schedule_for(user.id, wait: wait)
269+
DashboardRollupRefreshJob.enqueue_for(user.id, wait: wait)
270270
@rollup_refresh_scheduled = true
271271
end
272272

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class AddDashboardRollupGenerationToUsers < ActiveRecord::Migration[8.1]
2+
def change
3+
add_column :users, :dashboard_rollup_generation, :bigint, null: false, default: 0
4+
end
5+
end

db/schema.rb

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
require "test_helper"
2+
3+
class DashboardRollupConcurrencyTest < ActiveSupport::TestCase
4+
self.use_transactional_tests = false
5+
include ActiveJob::TestHelper
6+
7+
test "a correction during refresh leaves a coherent snapshot and a pending refresh" do
8+
original_cache = Rails.cache
9+
original_adapter = ActiveJob::Base.queue_adapter
10+
Rails.cache = ActiveSupport::Cache::MemoryStore.new
11+
ActiveJob::Base.queue_adapter = :test
12+
user = create(:user)
13+
create(:heartbeat, user: user, project: "api", language: "Ruby", time: 1_700_000_000.0)
14+
heartbeat = create(:heartbeat, user: user, project: "api", language: "Ruby", time: 1_700_000_060.0)
15+
DashboardRollupRefreshService.new(user: user).call
16+
clear_enqueued_jobs
17+
Rails.cache.clear
18+
DashboardRollupRefreshJob.schedule_for(user.id)
19+
main_thread = Thread.current
20+
corrected = false
21+
subscriber = lambda do |*args|
22+
payload = args.last
23+
if Thread.current == main_thread && !corrected && payload[:sql].include?('SELECT COUNT(*) FROM "heartbeats"')
24+
corrected = true
25+
Thread.new do
26+
ActiveRecord::Base.connection_pool.with_connection do
27+
Heartbeat.find(heartbeat.id).update!(language: "Python")
28+
end
29+
end.value
30+
end
31+
end
32+
33+
ActiveSupport::Notifications.subscribed(subscriber, "sql.active_record") do
34+
DashboardRollupRefreshJob.perform_now(user.id)
35+
end
36+
37+
assert corrected, "the concurrent correction must run during the aggregate reads"
38+
assert DashboardRollup.dirty?(user.id), "the newer invalidation must survive refresh completion"
39+
buckets = DashboardRollup.where(user: user, dimension: "language").to_h { |row| [ row.bucket, row.total_seconds ] }
40+
assert_equal({ "Ruby" => 60 }, buckets)
41+
assert_enqueued_jobs 2, only: DashboardRollupRefreshJob
42+
43+
DashboardRollupRefreshJob.perform_now(user.id)
44+
assert_not DashboardRollup.dirty?(user.id)
45+
assert_equal 60, DashboardRollup.find_by!(user: user, dimension: "language", bucket_value: "Python").total_seconds
46+
DashboardRollup.mark_dirty(user.id)
47+
Rails.cache.clear
48+
assert DashboardRollup.dirty?(user.id), "invalidations must survive cache loss"
49+
ensure
50+
DashboardRollup.where(user: user).delete_all if user
51+
Heartbeat.with_deleted.where(user: user).delete_all if user
52+
user&.destroy!
53+
clear_enqueued_jobs
54+
Rails.cache = original_cache
55+
ActiveJob::Base.queue_adapter = original_adapter
56+
end
57+
end

test/services/dashboard_stats_test.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,7 @@ def stats.grouped_durations_snapshot(_scope) = raise("expected rollup-backed das
359359
create_heartbeat(user, project: "beta", language: "javascript", editor: "zed", operating_system: "linux", category: "coding")
360360
end
361361

362-
DashboardRollup.clear_dirty(user.id)
362+
total_row.update!(payload: { source_generation: DashboardRollup.generation(user.id) })
363363
Rails.cache.delete(DashboardRollupRefreshJob.enqueue_cache_key(user.id))
364364

365365
stats = build_stats(user)

0 commit comments

Comments
 (0)