From c4b1ef2f5859a212d6ba260b7a66386d8d096686 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 1 Mar 2026 21:18:59 -0500 Subject: [PATCH 1/6] Add bust_cache param to fix stale featured workshops on home page Featured workshop IDs are cached for 1 year and the before_save invalidation can race with the save transaction, leaving stale data. Visiting /?bust_cache=true now clears and repopulates the cache. Co-Authored-By: Claude Opus 4.6 --- README.md | 12 +++++ app/controllers/home/workshops_controller.rb | 4 +- app/views/home/_workshops.html.erb | 2 +- spec/requests/home/workshops_spec.rb | 47 ++++++++++++++++++++ 4 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 spec/requests/home/workshops_spec.rb diff --git a/README.md b/README.md index 7816e5bee..16bee54e7 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,18 @@ This is a Rails 8.1.0 application built with: For detailed setup and development instructions, please see our [CONTRIBUTING.md](CONTRIBUTING.md) guide. +## Production Maintenance + +### Featured workshops not showing on the home page + +Featured workshop IDs are cached for up to 1 year. The cache auto-invalidates when a workshop's `featured`, `publicly_featured`, or `published` flags change, but stale data can persist if the invalidation races with the save transaction. To force a refresh, visit: + +``` +/?bust_cache=true +``` + +This clears and repopulates the cache for all users. + ## Orphaned Reports When users are deleted from the system, their reports are automatically assigned to a special diff --git a/app/controllers/home/workshops_controller.rb b/app/controllers/home/workshops_controller.rb index f60262130..90984c459 100644 --- a/app/controllers/home/workshops_controller.rb +++ b/app/controllers/home/workshops_controller.rb @@ -2,12 +2,14 @@ module Home class WorkshopsController < ApplicationController def index authorize! :home + Rails.cache.delete("featured_and_publicly_featured_workshop_ids") if params[:bust_cache] == "true" + ids = Rails.cache.fetch("featured_and_publicly_featured_workshop_ids", expires_in: 1.year) do Workshop.featured_or_publicly_featured.pluck(:id) end base_scope = Workshop.includes(:windows_type, primary_asset: { file_attachment: :blob }, gallery_assets: { file_attachment: :blob }) - .where(id: ids) + .where(id: ids) @workshops = authorized_scope(base_scope, with: HomePolicy).decorate @workshops = @workshops.sort { |x, y| Date.parse(y.date) <=> Date.parse(x.date) } diff --git a/app/views/home/_workshops.html.erb b/app/views/home/_workshops.html.erb index 106743d11..9376401e7 100644 --- a/app/views/home/_workshops.html.erb +++ b/app/views/home/_workshops.html.erb @@ -6,7 +6,7 @@ title: title, subtitle: "Spotlights from our curriculum" %> - <%= turbo_frame_tag "home_workshops", src: home_workshops_path do %> + <%= turbo_frame_tag "home_workshops", src: home_workshops_path(bust_cache: params[:bust_cache].presence) do %> <%= render "workshops_cards_skeleton" %> <% end %> diff --git a/spec/requests/home/workshops_spec.rb b/spec/requests/home/workshops_spec.rb new file mode 100644 index 000000000..5c98dcfa9 --- /dev/null +++ b/spec/requests/home/workshops_spec.rb @@ -0,0 +1,47 @@ +require "rails_helper" + +RSpec.describe "/home/workshops", type: :request do + let(:user) { create(:user) } + let!(:windows_type) { create(:windows_type) } + + before { sign_in user } + + describe "GET /home/workshops" do + it "returns featured workshops" do + workshop = create(:workshop, :published, featured: true, windows_type: windows_type) + + get home_workshops_path + + expect(response).to have_http_status(:ok) + expect(response.body).to include(workshop.title) + end + + it "does not return unfeatured workshops" do + create(:workshop, :published, featured: false, windows_type: windows_type) + + get home_workshops_path + + expect(response).to have_http_status(:ok) + expect(response.body).to include("No workshops available right now.") + end + + context "with bust_cache=true" do + it "clears and repopulates the featured workshop cache" do + # Prime the cache with no featured workshops + get home_workshops_path + expect(response.body).to include("No workshops available right now.") + + # Feature a workshop after the cache is set + create(:workshop, :published, featured: true, windows_type: windows_type) + + # Without bust_cache, stale cache returns no workshops + get home_workshops_path + expect(response.body).to include("No workshops available right now.") + + # With bust_cache=true, the cache is cleared and workshops appear + get home_workshops_path, params: { bust_cache: "true" } + expect(response.body).not_to include("No workshops available right now.") + end + end + end +end From 648372421d53ef1fb8241e2b9c963918910ec910 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 1 Mar 2026 21:21:12 -0500 Subject: [PATCH 2/6] Restrict bust_cache to site admins Co-Authored-By: Claude Opus 4.6 --- app/controllers/home/workshops_controller.rb | 4 +++- spec/requests/home/workshops_spec.rb | 20 +++++++++++++------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/app/controllers/home/workshops_controller.rb b/app/controllers/home/workshops_controller.rb index 90984c459..7d61f9a26 100644 --- a/app/controllers/home/workshops_controller.rb +++ b/app/controllers/home/workshops_controller.rb @@ -2,7 +2,9 @@ module Home class WorkshopsController < ApplicationController def index authorize! :home - Rails.cache.delete("featured_and_publicly_featured_workshop_ids") if params[:bust_cache] == "true" + if params[:bust_cache] == "true" && current_user&.super_user? + Rails.cache.delete("featured_and_publicly_featured_workshop_ids") + end ids = Rails.cache.fetch("featured_and_publicly_featured_workshop_ids", expires_in: 1.year) do Workshop.featured_or_publicly_featured.pluck(:id) diff --git a/spec/requests/home/workshops_spec.rb b/spec/requests/home/workshops_spec.rb index 5c98dcfa9..5324604a0 100644 --- a/spec/requests/home/workshops_spec.rb +++ b/spec/requests/home/workshops_spec.rb @@ -26,22 +26,28 @@ end context "with bust_cache=true" do - it "clears and repopulates the featured workshop cache" do + let(:admin) { create(:user, :admin) } + + before do # Prime the cache with no featured workshops get home_workshops_path - expect(response.body).to include("No workshops available right now.") - # Feature a workshop after the cache is set create(:workshop, :published, featured: true, windows_type: windows_type) + end - # Without bust_cache, stale cache returns no workshops - get home_workshops_path - expect(response.body).to include("No workshops available right now.") + it "clears the cache for admins" do + sign_in admin - # With bust_cache=true, the cache is cleared and workshops appear get home_workshops_path, params: { bust_cache: "true" } + expect(response.body).not_to include("No workshops available right now.") end + + it "does not clear the cache for non-admins" do + get home_workshops_path, params: { bust_cache: "true" } + + expect(response.body).to include("No workshops available right now.") + end end end end From ec8c62e45ccec3bbd648ae17be442d832df2c2f0 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 8 Mar 2026 08:00:13 -0400 Subject: [PATCH 3/6] Use ActionPolicy manage? check instead of super_user? directly Co-Authored-By: Claude Opus 4.6 --- app/controllers/home/workshops_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/home/workshops_controller.rb b/app/controllers/home/workshops_controller.rb index 7d61f9a26..0c48422d3 100644 --- a/app/controllers/home/workshops_controller.rb +++ b/app/controllers/home/workshops_controller.rb @@ -2,7 +2,7 @@ module Home class WorkshopsController < ApplicationController def index authorize! :home - if params[:bust_cache] == "true" && current_user&.super_user? + if params[:bust_cache] == "true" && allowed_to?(:manage?, with: ApplicationPolicy) Rails.cache.delete("featured_and_publicly_featured_workshop_ids") end From 8a4470c2a68031ac1e6cbdf91418d93ea954d4b2 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 8 Mar 2026 08:00:46 -0400 Subject: [PATCH 4/6] Note admin-only restriction for bust_cache in README Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 16bee54e7..9cb1cafa5 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Featured workshop IDs are cached for up to 1 year. The cache auto-invalidates wh /?bust_cache=true ``` -This clears and repopulates the cache for all users. +This clears and repopulates the cache for all users. Only site admins (users who pass the `ApplicationPolicy#manage?` check) can use this param. ## Orphaned Reports From 1f025ee9deb3086aac357e9857f0b7ca0b217681 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 8 Mar 2026 13:58:59 -0400 Subject: [PATCH 5/6] Use MemoryStore in bust_cache tests so cache actually persists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test env uses :null_store by default, so the stale cache scenario was untestable — every request re-executed the fetch block. Co-Authored-By: Claude Opus 4.6 --- spec/requests/home/workshops_spec.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/spec/requests/home/workshops_spec.rb b/spec/requests/home/workshops_spec.rb index 5324604a0..472cc58ff 100644 --- a/spec/requests/home/workshops_spec.rb +++ b/spec/requests/home/workshops_spec.rb @@ -27,6 +27,15 @@ context "with bust_cache=true" do let(:admin) { create(:user, :admin) } + let(:cache_key) { "featured_and_publicly_featured_workshop_ids" } + + around do |example| + original_store = Rails.cache + Rails.cache = ActiveSupport::Cache::MemoryStore.new + example.run + ensure + Rails.cache = original_store + end before do # Prime the cache with no featured workshops From 2ca39a7ea5c795d94aca3a79126e2f354ebb738e Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 8 Mar 2026 14:15:23 -0400 Subject: [PATCH 6/6] Re-seed stale cache after factory clears it in bust_cache test The workshop factory's before_save callback invalidates the cache when featured changes, so we manually write stale data back to simulate the race condition the test is verifying. Co-Authored-By: Claude Opus 4.6 --- spec/requests/home/workshops_spec.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/requests/home/workshops_spec.rb b/spec/requests/home/workshops_spec.rb index 472cc58ff..18d7c71dc 100644 --- a/spec/requests/home/workshops_spec.rb +++ b/spec/requests/home/workshops_spec.rb @@ -40,8 +40,10 @@ before do # Prime the cache with no featured workshops get home_workshops_path - # Feature a workshop after the cache is set + # Feature a workshop — this triggers before_save which clears the cache, + # so we re-write stale (empty) data to simulate a race condition create(:workshop, :published, featured: true, windows_type: windows_type) + Rails.cache.write(cache_key, [], expires_in: 1.year) end it "clears the cache for admins" do