From 67726f88a60c30f8def36e2348d87b9675ebb60d Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Mon, 18 May 2026 21:00:18 +0200 Subject: [PATCH] fix(goals): clear state-dependent caches on AASM transition + harden sweep job - Goal: `display_status` and `projection_summary` memoize a value that depends on the AASM state column. Without resetting them after a transition the same instance keeps returning the pre-transition value. Hook `after_all_transitions :reset_state_dependent_caches!` undoes the memos so post-`archive!` / post-`pause!` reads see the new state. - SweepExpiredGoalPledgesJob: the inner rescue covered per-pledge failures but not cursor-phase failures (DB blip, OOM mid-batch). Add an outer rescue that reports + re-raises so Sentry sees the failure and Sidekiq retries the job. --- app/jobs/sweep_expired_goal_pledges_job.rb | 8 ++++++++ app/models/goal.rb | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/app/jobs/sweep_expired_goal_pledges_job.rb b/app/jobs/sweep_expired_goal_pledges_job.rb index 29a327683..bf2214764 100644 --- a/app/jobs/sweep_expired_goal_pledges_job.rb +++ b/app/jobs/sweep_expired_goal_pledges_job.rb @@ -3,6 +3,10 @@ class SweepExpiredGoalPledgesJob < ApplicationJob # Per-record rescue so one bad pledge (lock contention, missing FK, # stale row) doesn't abort the sweep and leave the rest open forever. + # The outer rescue catches query-phase failures (DB blip, OOM mid-cursor) + # so a single bad batch surfaces to Sentry rather than disappearing into + # Sidekiq's generic retry log. Re-raise after reporting so the retry + # behaviour still kicks in. def perform GoalPledge.open_and_expired_now.find_each do |pledge| pledge.expire! @@ -10,5 +14,9 @@ class SweepExpiredGoalPledgesJob < ApplicationJob Rails.logger.error("SweepExpiredGoalPledgesJob: pledge ##{pledge.id} expire failed: #{e.class}: #{e.message}") Sentry.capture_exception(e) if defined?(Sentry) end + rescue StandardError => e + Rails.logger.error("SweepExpiredGoalPledgesJob: cursor failed: #{e.class}: #{e.message}") + Sentry.capture_exception(e) if defined?(Sentry) + raise end end diff --git a/app/models/goal.rb b/app/models/goal.rb index c020e9982..044f8f70d 100644 --- a/app/models/goal.rb +++ b/app/models/goal.rb @@ -36,6 +36,8 @@ class Goal < ApplicationRecord end aasm column: :state do + after_all_transitions :reset_state_dependent_caches! + state :active, initial: true state :paused state :completed @@ -398,6 +400,16 @@ class Goal < ApplicationRecord end private + # Cleared after every AASM transition. The state column drives the + # display_status / projection_summary memos; without this the same + # instance keeps returning the pre-transition value if a controller + # calls archive! / pause! and then renders without reload. + def reset_state_dependent_caches! + %i[@display_status @projection_summary].each do |ivar| + remove_instance_variable(ivar) if instance_variable_defined?(ivar) + end + end + # K/M shorthand for narrow chart annotations (axis ticks, projection # short-form, pending-pledge badge). Locale-aware currency symbol via # Money so the chart matches the rest of the app for EUR/GBP families.