From 7c449c91bc16307691d6516d6647d047b7c51936 Mon Sep 17 00:00:00 2001 From: Jeremy Walker Date: Fri, 21 Aug 2026 17:57:11 +0200 Subject: [PATCH 1/5] Render the fallback icon for icons that don't exist Lots of exercises and tracks have never had an icon drawn for them. We were emitting a URL for the file anyway, the browser got a 403 from S3, and only then did our onerror handlers swap in the fallback. S3 errors aren't cacheable at the CDN, so every page view hit the origin again. flower-field.svg alone generated 130k requests in a week, and there are ~100 other slugs doing the same. The icons repo now publishes a manifest.json of everything in the bucket. We read it (cached for an hour) and point straight at the local fallback asset when there's no icon. This fails open: if the manifest can't be retrieved we assume every icon exists, which leaves us on the old behaviour rather than rendering the fallback for every icon on the site. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NCycuWDzu2wXg9RbJsBZUP --- app/commands/icons/retrieve_manifest.rb | 43 ++++++++++++++++++ app/models/exercise.rb | 2 +- app/models/generic_exercise.rb | 2 +- app/models/icons.rb | 35 +++++++++++++++ app/models/track.rb | 2 +- test/commands/icons/retrieve_manifest_test.rb | 45 +++++++++++++++++++ test/models/icons_test.rb | 41 +++++++++++++++++ test/test_helper.rb | 6 +++ 8 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 app/commands/icons/retrieve_manifest.rb create mode 100644 app/models/icons.rb create mode 100644 test/commands/icons/retrieve_manifest_test.rb create mode 100644 test/models/icons_test.rb diff --git a/app/commands/icons/retrieve_manifest.rb b/app/commands/icons/retrieve_manifest.rb new file mode 100644 index 0000000000..e6a0c30cf0 --- /dev/null +++ b/app/commands/icons/retrieve_manifest.rb @@ -0,0 +1,43 @@ +# Retrieves the set of icon paths that actually exist in the icons bucket. +# +# The manifest is published to the bucket root by the sync workflow in the +# exercism/website-icons repo, so it updates whenever icons are added or +# removed, without needing anything deployed here. +# +# This fails open: if we can't retrieve the manifest we return an empty set, +# which callers treat as "assume everything exists". That keeps us on the old +# behaviour (request the icon, let the browser's onerror handler fall back) +# rather than rendering the fallback for every icon on the site. +class Icons::RetrieveManifest + include Mandate + + def call + cached = Rails.cache.read(CACHE_KEY) + return cached if cached + + paths = retrieve + + # Don't hold onto a failure for the full expiry. A blip would otherwise + # leave us checking nothing for an hour. + Rails.cache.write(CACHE_KEY, paths, expires_in: paths.empty? ? FAILURE_CACHE_EXPIRY : CACHE_EXPIRY) + paths + end + + private + def retrieve + paths = JSON.parse(RestClient.get(url).body) + raise TypeError, "Icons manifest is not an array" unless paths.is_a?(Array) + + paths.to_set + rescue StandardError => e + Sentry.capture_exception(e) + Set.new + end + + def url = "#{Exercism.config.website_icons_host}/manifest.json" + + CACHE_KEY = "Icons::RetrieveManifest".freeze + CACHE_EXPIRY = 1.hour.freeze + FAILURE_CACHE_EXPIRY = 1.minute.freeze + private_constant :CACHE_KEY, :CACHE_EXPIRY, :FAILURE_CACHE_EXPIRY +end diff --git a/app/models/exercise.rb b/app/models/exercise.rb index 996989fb49..7006f2fc5c 100644 --- a/app/models/exercise.rb +++ b/app/models/exercise.rb @@ -167,7 +167,7 @@ def difficulty_category end end - def icon_url = "#{Exercism.config.website_icons_host}/exercises/#{icon_name}.svg" + def icon_url = Icons.url_for("exercises/#{icon_name}.svg", fallback: Icons::MISSING_EXERCISE_ICON) memoize def mentoring_notes diff --git a/app/models/generic_exercise.rb b/app/models/generic_exercise.rb index 5131395f37..b5524cb278 100644 --- a/app/models/generic_exercise.rb +++ b/app/models/generic_exercise.rb @@ -11,7 +11,7 @@ def status = super.to_sym def git = Git::ProblemSpecifications::Exercise.new(slug) def url = "https://github.com/exercism/problem-specifications/tree/main/exercises/#{slug}" - def icon_url = "#{Exercism.config.website_icons_host}/exercises/#{slug}.svg" + def icon_url = Icons.url_for("exercises/#{slug}.svg", fallback: Icons::MISSING_EXERCISE_ICON) def self.for!(slug) = find_by!(slug:) diff --git a/app/models/icons.rb b/app/models/icons.rb new file mode 100644 index 0000000000..2bebd3d0d4 --- /dev/null +++ b/app/models/icons.rb @@ -0,0 +1,35 @@ +# Builds URLs for icons hosted in the icons bucket. +# +# Lots of exercises and tracks have never had an icon drawn for them. Without a +# check here we'd emit a URL for a file that doesn't exist, the browser would +# request it and get a 403, and only then would our onerror handlers swap in the +# fallback. S3 errors aren't cacheable at the CDN, so every page view hits the +# origin again: those missing icons were generating millions of requests a week. +# +# So we consult the manifest of icons that really exist and point straight at +# the local fallback asset when there isn't one. +module Icons + extend Propshaft::Helper + + MISSING_EXERCISE_ICON = "graphics/missing-exercise.svg".freeze + MISSING_TRACK_ICON = "graphics/missing-track.svg".freeze + + def self.url_for(path, fallback:) + return fallback_url(fallback) unless exists?(path) + + "#{Exercism.config.website_icons_host}/#{path}" + end + + def self.exists?(path) + manifest = Icons::RetrieveManifest.() + + # An empty manifest means we couldn't retrieve it. Assume the icon exists. + return true if manifest.empty? + + manifest.include?(path) + end + + def self.fallback_url(fallback) + "#{Rails.application.config.action_controller.asset_host}#{compute_asset_path(fallback)}" + end +end diff --git a/app/models/track.rb b/app/models/track.rb index 728a707f83..8fc8c87ca8 100644 --- a/app/models/track.rb +++ b/app/models/track.rb @@ -118,7 +118,7 @@ def trophies Track::Trophy.for_track(self) end - def icon_url = "#{Exercism.config.website_icons_host}/tracks/#{slug}.svg" + def icon_url = Icons.url_for("tracks/#{slug}.svg", fallback: Icons::MISSING_TRACK_ICON) def highlightjs_language super || slug diff --git a/test/commands/icons/retrieve_manifest_test.rb b/test/commands/icons/retrieve_manifest_test.rb new file mode 100644 index 0000000000..5400860455 --- /dev/null +++ b/test/commands/icons/retrieve_manifest_test.rb @@ -0,0 +1,45 @@ +require 'test_helper' + +class Icons::RetrieveManifestTest < ActiveSupport::TestCase + test "retrieves and caches the manifest" do + stub = stub_manifest(["exercises/bob.svg", "tracks/ruby.svg"]) + + assert_equal Set["exercises/bob.svg", "tracks/ruby.svg"], Icons::RetrieveManifest.() + + # The second call should come from the cache + Icons::RetrieveManifest.() + assert_requested stub, times: 1 + end + + test "returns an empty set when the manifest can't be retrieved" do + stub_request(:get, MANIFEST_URL).to_return(status: 500) + Sentry.expects(:capture_exception).once + + assert_empty Icons::RetrieveManifest.() + end + + test "returns an empty set when the manifest isn't an array" do + stub_request(:get, MANIFEST_URL).to_return(status: 200, body: { exercises: [] }.to_json) + Sentry.expects(:capture_exception).once + + assert_empty Icons::RetrieveManifest.() + end + + test "retries sooner after a failure" do + stub_request(:get, MANIFEST_URL).to_return(status: 500) + Sentry.stubs(:capture_exception) + + assert_empty Icons::RetrieveManifest.() + + travel 2.minutes do + stub_manifest(["exercises/bob.svg"]) + assert_equal Set["exercises/bob.svg"], Icons::RetrieveManifest.() + end + end + + MANIFEST_URL = "https://assets.exercism.org/manifest.json".freeze + + def stub_manifest(paths) + stub_request(:get, MANIFEST_URL).to_return(status: 200, body: paths.to_json) + end +end diff --git a/test/models/icons_test.rb b/test/models/icons_test.rb new file mode 100644 index 0000000000..f456adbcb1 --- /dev/null +++ b/test/models/icons_test.rb @@ -0,0 +1,41 @@ +require 'test_helper' + +class IconsTest < ActiveSupport::TestCase + test "url_for returns the bucket url when the icon exists" do + stub_manifest(["exercises/bob.svg"]) + + assert_equal "https://assets.exercism.org/exercises/bob.svg", + Icons.url_for("exercises/bob.svg", fallback: Icons::MISSING_EXERCISE_ICON) + end + + test "url_for returns the fallback when the icon doesn't exist" do + stub_manifest(["exercises/bob.svg"]) + + url = Icons.url_for("exercises/flower-field.svg", fallback: Icons::MISSING_EXERCISE_ICON) + assert_includes url, "missing-exercise" + refute_includes url, "flower-field" + end + + test "url_for assumes the icon exists when the manifest is unavailable" do + stub_request(:get, "https://assets.exercism.org/manifest.json").to_return(status: 500) + Sentry.stubs(:capture_exception) + + assert_equal "https://assets.exercism.org/exercises/flower-field.svg", + Icons.url_for("exercises/flower-field.svg", fallback: Icons::MISSING_EXERCISE_ICON) + end + + test "exercise and track icon urls use the manifest" do + stub_manifest(["tracks/ruby.svg"]) + + track = create :track, slug: :ruby + exercise = create :practice_exercise, track:, slug: :flower_field, icon_name: 'flower-field' + + assert_equal "https://assets.exercism.org/tracks/ruby.svg", track.icon_url + assert_includes exercise.icon_url, "missing-exercise" + end + + def stub_manifest(paths) + stub_request(:get, "https://assets.exercism.org/manifest.json"). + to_return(status: 200, body: paths.to_json) + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 3952333868..32a54a7d24 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -176,6 +176,12 @@ class ActiveSupport::TestCase # the way the transactional fixtures roll back the database. Rails.cache.clear + # Almost no test cares about the icons manifest, but anything that renders + # an icon url reaches for it. Default to an empty manifest (which means + # "assume every icon exists") and let the tests that care stub their own. + stub_request(:get, "#{Exercism.config.website_icons_host}/manifest.json"). + to_return(status: 200, body: "[]") + # We do it like this (rather than stub/unstub) so that we # can have this method globally without disabling mocha's # protections against unstubbing unecessary methods. From 272bfcd60836d5d90cb214b4015dc1d29fe5953e Mon Sep 17 00:00:00 2001 From: Jeremy Walker Date: Fri, 21 Aug 2026 18:00:41 +0200 Subject: [PATCH 2/5] Memoize the manifest paths Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NCycuWDzu2wXg9RbJsBZUP --- app/commands/icons/retrieve_manifest.rb | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/app/commands/icons/retrieve_manifest.rb b/app/commands/icons/retrieve_manifest.rb index e6a0c30cf0..026b5a5187 100644 --- a/app/commands/icons/retrieve_manifest.rb +++ b/app/commands/icons/retrieve_manifest.rb @@ -15,8 +15,6 @@ def call cached = Rails.cache.read(CACHE_KEY) return cached if cached - paths = retrieve - # Don't hold onto a failure for the full expiry. A blip would otherwise # leave us checking nothing for an hour. Rails.cache.write(CACHE_KEY, paths, expires_in: paths.empty? ? FAILURE_CACHE_EXPIRY : CACHE_EXPIRY) @@ -24,11 +22,12 @@ def call end private - def retrieve - paths = JSON.parse(RestClient.get(url).body) - raise TypeError, "Icons manifest is not an array" unless paths.is_a?(Array) + memoize + def paths + parsed = JSON.parse(RestClient.get(url).body) + raise TypeError, "Icons manifest is not an array" unless parsed.is_a?(Array) - paths.to_set + parsed.to_set rescue StandardError => e Sentry.capture_exception(e) Set.new From ad7d038e2f5d40d09c7087b0a842ff4fa9b03c75 Mon Sep 17 00:00:00 2001 From: Jeremy Walker Date: Fri, 21 Aug 2026 18:02:53 +0200 Subject: [PATCH 3/5] Replace the Icons model with commands Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NCycuWDzu2wXg9RbJsBZUP --- app/commands/icons/check_exists.rb | 18 ++++++++++ app/commands/icons/determine_url_for.rb | 27 ++++++++++++++ app/models/exercise.rb | 2 +- app/models/generic_exercise.rb | 2 +- app/models/icons.rb | 35 ------------------- app/models/track.rb | 2 +- config/initializers/zeitwerk.rb | 5 +++ test/commands/icons/check_exists_test.rb | 26 ++++++++++++++ .../icons/determine_url_for_test.rb} | 14 ++++---- 9 files changed, 86 insertions(+), 45 deletions(-) create mode 100644 app/commands/icons/check_exists.rb create mode 100644 app/commands/icons/determine_url_for.rb delete mode 100644 app/models/icons.rb create mode 100644 config/initializers/zeitwerk.rb create mode 100644 test/commands/icons/check_exists_test.rb rename test/{models/icons_test.rb => commands/icons/determine_url_for_test.rb} (64%) diff --git a/app/commands/icons/check_exists.rb b/app/commands/icons/check_exists.rb new file mode 100644 index 0000000000..6c7fea980a --- /dev/null +++ b/app/commands/icons/check_exists.rb @@ -0,0 +1,18 @@ +# Checks whether an icon actually exists in the icons bucket. +class Icons::CheckExists + include Mandate + + initialize_with :path + + def call + # An empty manifest means we couldn't retrieve it, so assume the icon exists + # and leave the browser's onerror handler to fall back if it doesn't. + return true if manifest.empty? + + manifest.include?(path) + end + + private + memoize + def manifest = Icons::RetrieveManifest.() +end diff --git a/app/commands/icons/determine_url_for.rb b/app/commands/icons/determine_url_for.rb new file mode 100644 index 0000000000..35d1a1028a --- /dev/null +++ b/app/commands/icons/determine_url_for.rb @@ -0,0 +1,27 @@ +# Builds the URL for an icon hosted in the icons bucket. +# +# Lots of exercises and tracks have never had an icon drawn for them. Without a +# check here we'd emit a URL for a file that doesn't exist, the browser would +# request it and get a 403, and only then would our onerror handlers swap in the +# fallback. S3 errors aren't cacheable at the CDN, so every page view hits the +# origin again: those missing icons were generating millions of requests a week. +# +# So we point straight at the local fallback asset when there isn't an icon. +class Icons::DetermineURLFor + include Mandate + include Propshaft::Helper + + MISSING_EXERCISE_ICON = "graphics/missing-exercise.svg".freeze + MISSING_TRACK_ICON = "graphics/missing-track.svg".freeze + + initialize_with :path, :fallback + + def call + return fallback_url unless Icons::CheckExists.(path) + + "#{Exercism.config.website_icons_host}/#{path}" + end + + private + def fallback_url = "#{Rails.application.config.action_controller.asset_host}#{compute_asset_path(fallback)}" +end diff --git a/app/models/exercise.rb b/app/models/exercise.rb index 7006f2fc5c..06a845697c 100644 --- a/app/models/exercise.rb +++ b/app/models/exercise.rb @@ -167,7 +167,7 @@ def difficulty_category end end - def icon_url = Icons.url_for("exercises/#{icon_name}.svg", fallback: Icons::MISSING_EXERCISE_ICON) + def icon_url = Icons::DetermineURLFor.("exercises/#{icon_name}.svg", Icons::DetermineURLFor::MISSING_EXERCISE_ICON) memoize def mentoring_notes diff --git a/app/models/generic_exercise.rb b/app/models/generic_exercise.rb index b5524cb278..c5e9424823 100644 --- a/app/models/generic_exercise.rb +++ b/app/models/generic_exercise.rb @@ -11,7 +11,7 @@ def status = super.to_sym def git = Git::ProblemSpecifications::Exercise.new(slug) def url = "https://github.com/exercism/problem-specifications/tree/main/exercises/#{slug}" - def icon_url = Icons.url_for("exercises/#{slug}.svg", fallback: Icons::MISSING_EXERCISE_ICON) + def icon_url = Icons::DetermineURLFor.("exercises/#{slug}.svg", Icons::DetermineURLFor::MISSING_EXERCISE_ICON) def self.for!(slug) = find_by!(slug:) diff --git a/app/models/icons.rb b/app/models/icons.rb deleted file mode 100644 index 2bebd3d0d4..0000000000 --- a/app/models/icons.rb +++ /dev/null @@ -1,35 +0,0 @@ -# Builds URLs for icons hosted in the icons bucket. -# -# Lots of exercises and tracks have never had an icon drawn for them. Without a -# check here we'd emit a URL for a file that doesn't exist, the browser would -# request it and get a 403, and only then would our onerror handlers swap in the -# fallback. S3 errors aren't cacheable at the CDN, so every page view hits the -# origin again: those missing icons were generating millions of requests a week. -# -# So we consult the manifest of icons that really exist and point straight at -# the local fallback asset when there isn't one. -module Icons - extend Propshaft::Helper - - MISSING_EXERCISE_ICON = "graphics/missing-exercise.svg".freeze - MISSING_TRACK_ICON = "graphics/missing-track.svg".freeze - - def self.url_for(path, fallback:) - return fallback_url(fallback) unless exists?(path) - - "#{Exercism.config.website_icons_host}/#{path}" - end - - def self.exists?(path) - manifest = Icons::RetrieveManifest.() - - # An empty manifest means we couldn't retrieve it. Assume the icon exists. - return true if manifest.empty? - - manifest.include?(path) - end - - def self.fallback_url(fallback) - "#{Rails.application.config.action_controller.asset_host}#{compute_asset_path(fallback)}" - end -end diff --git a/app/models/track.rb b/app/models/track.rb index 8fc8c87ca8..f57d463063 100644 --- a/app/models/track.rb +++ b/app/models/track.rb @@ -118,7 +118,7 @@ def trophies Track::Trophy.for_track(self) end - def icon_url = Icons.url_for("tracks/#{slug}.svg", fallback: Icons::MISSING_TRACK_ICON) + def icon_url = Icons::DetermineURLFor.("tracks/#{slug}.svg", Icons::DetermineURLFor::MISSING_TRACK_ICON) def highlightjs_language super || slug diff --git a/config/initializers/zeitwerk.rb b/config/initializers/zeitwerk.rb new file mode 100644 index 0000000000..3a1a19dffb --- /dev/null +++ b/config/initializers/zeitwerk.rb @@ -0,0 +1,5 @@ +# We don't add URL as a global acronym because that would rename existing +# classes (Cloudflare::PurgeUrls et al), so we inflect the individual files. +Rails.autoloaders.each do |autoloader| + autoloader.inflector.inflect("determine_url_for" => "DetermineURLFor") +end diff --git a/test/commands/icons/check_exists_test.rb b/test/commands/icons/check_exists_test.rb new file mode 100644 index 0000000000..2476e2af91 --- /dev/null +++ b/test/commands/icons/check_exists_test.rb @@ -0,0 +1,26 @@ +require 'test_helper' + +class Icons::CheckExistsTest < ActiveSupport::TestCase + test "true when the icon is in the manifest" do + stub_manifest(["exercises/bob.svg"]) + + assert Icons::CheckExists.("exercises/bob.svg") + end + + test "false when the icon isn't in the manifest" do + stub_manifest(["exercises/bob.svg"]) + + refute Icons::CheckExists.("exercises/flower-field.svg") + end + + test "true for everything when the manifest is empty" do + stub_manifest([]) + + assert Icons::CheckExists.("exercises/flower-field.svg") + end + + def stub_manifest(paths) + stub_request(:get, "https://assets.exercism.org/manifest.json"). + to_return(status: 200, body: paths.to_json) + end +end diff --git a/test/models/icons_test.rb b/test/commands/icons/determine_url_for_test.rb similarity index 64% rename from test/models/icons_test.rb rename to test/commands/icons/determine_url_for_test.rb index f456adbcb1..d991010cbb 100644 --- a/test/models/icons_test.rb +++ b/test/commands/icons/determine_url_for_test.rb @@ -1,27 +1,27 @@ require 'test_helper' -class IconsTest < ActiveSupport::TestCase - test "url_for returns the bucket url when the icon exists" do +class Icons::DetermineURLForTest < ActiveSupport::TestCase + test "returns the bucket url when the icon exists" do stub_manifest(["exercises/bob.svg"]) assert_equal "https://assets.exercism.org/exercises/bob.svg", - Icons.url_for("exercises/bob.svg", fallback: Icons::MISSING_EXERCISE_ICON) + Icons::DetermineURLFor.("exercises/bob.svg", Icons::DetermineURLFor::MISSING_EXERCISE_ICON) end - test "url_for returns the fallback when the icon doesn't exist" do + test "returns the fallback when the icon doesn't exist" do stub_manifest(["exercises/bob.svg"]) - url = Icons.url_for("exercises/flower-field.svg", fallback: Icons::MISSING_EXERCISE_ICON) + url = Icons::DetermineURLFor.("exercises/flower-field.svg", Icons::DetermineURLFor::MISSING_EXERCISE_ICON) assert_includes url, "missing-exercise" refute_includes url, "flower-field" end - test "url_for assumes the icon exists when the manifest is unavailable" do + test "assumes the icon exists when the manifest is unavailable" do stub_request(:get, "https://assets.exercism.org/manifest.json").to_return(status: 500) Sentry.stubs(:capture_exception) assert_equal "https://assets.exercism.org/exercises/flower-field.svg", - Icons.url_for("exercises/flower-field.svg", fallback: Icons::MISSING_EXERCISE_ICON) + Icons::DetermineURLFor.("exercises/flower-field.svg", Icons::DetermineURLFor::MISSING_EXERCISE_ICON) end test "exercise and track icon urls use the manifest" do From 954fdcae088f663c147b3f8d31267118f14fcf03 Mon Sep 17 00:00:00 2001 From: Jeremy Walker Date: Fri, 21 Aug 2026 18:05:11 +0200 Subject: [PATCH 4/5] Use DetermineUrlFor rather than a custom inflection Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NCycuWDzu2wXg9RbJsBZUP --- app/commands/icons/determine_url_for.rb | 2 +- app/models/exercise.rb | 2 +- app/models/generic_exercise.rb | 2 +- app/models/track.rb | 2 +- config/initializers/zeitwerk.rb | 5 ----- test/commands/icons/determine_url_for_test.rb | 8 ++++---- 6 files changed, 8 insertions(+), 13 deletions(-) delete mode 100644 config/initializers/zeitwerk.rb diff --git a/app/commands/icons/determine_url_for.rb b/app/commands/icons/determine_url_for.rb index 35d1a1028a..4b49f652ee 100644 --- a/app/commands/icons/determine_url_for.rb +++ b/app/commands/icons/determine_url_for.rb @@ -7,7 +7,7 @@ # origin again: those missing icons were generating millions of requests a week. # # So we point straight at the local fallback asset when there isn't an icon. -class Icons::DetermineURLFor +class Icons::DetermineUrlFor include Mandate include Propshaft::Helper diff --git a/app/models/exercise.rb b/app/models/exercise.rb index 06a845697c..e599809fd5 100644 --- a/app/models/exercise.rb +++ b/app/models/exercise.rb @@ -167,7 +167,7 @@ def difficulty_category end end - def icon_url = Icons::DetermineURLFor.("exercises/#{icon_name}.svg", Icons::DetermineURLFor::MISSING_EXERCISE_ICON) + def icon_url = Icons::DetermineUrlFor.("exercises/#{icon_name}.svg", Icons::DetermineUrlFor::MISSING_EXERCISE_ICON) memoize def mentoring_notes diff --git a/app/models/generic_exercise.rb b/app/models/generic_exercise.rb index c5e9424823..41729ba495 100644 --- a/app/models/generic_exercise.rb +++ b/app/models/generic_exercise.rb @@ -11,7 +11,7 @@ def status = super.to_sym def git = Git::ProblemSpecifications::Exercise.new(slug) def url = "https://github.com/exercism/problem-specifications/tree/main/exercises/#{slug}" - def icon_url = Icons::DetermineURLFor.("exercises/#{slug}.svg", Icons::DetermineURLFor::MISSING_EXERCISE_ICON) + def icon_url = Icons::DetermineUrlFor.("exercises/#{slug}.svg", Icons::DetermineUrlFor::MISSING_EXERCISE_ICON) def self.for!(slug) = find_by!(slug:) diff --git a/app/models/track.rb b/app/models/track.rb index f57d463063..7290377cc9 100644 --- a/app/models/track.rb +++ b/app/models/track.rb @@ -118,7 +118,7 @@ def trophies Track::Trophy.for_track(self) end - def icon_url = Icons::DetermineURLFor.("tracks/#{slug}.svg", Icons::DetermineURLFor::MISSING_TRACK_ICON) + def icon_url = Icons::DetermineUrlFor.("tracks/#{slug}.svg", Icons::DetermineUrlFor::MISSING_TRACK_ICON) def highlightjs_language super || slug diff --git a/config/initializers/zeitwerk.rb b/config/initializers/zeitwerk.rb deleted file mode 100644 index 3a1a19dffb..0000000000 --- a/config/initializers/zeitwerk.rb +++ /dev/null @@ -1,5 +0,0 @@ -# We don't add URL as a global acronym because that would rename existing -# classes (Cloudflare::PurgeUrls et al), so we inflect the individual files. -Rails.autoloaders.each do |autoloader| - autoloader.inflector.inflect("determine_url_for" => "DetermineURLFor") -end diff --git a/test/commands/icons/determine_url_for_test.rb b/test/commands/icons/determine_url_for_test.rb index d991010cbb..ff063380da 100644 --- a/test/commands/icons/determine_url_for_test.rb +++ b/test/commands/icons/determine_url_for_test.rb @@ -1,17 +1,17 @@ require 'test_helper' -class Icons::DetermineURLForTest < ActiveSupport::TestCase +class Icons::DetermineUrlForTest < ActiveSupport::TestCase test "returns the bucket url when the icon exists" do stub_manifest(["exercises/bob.svg"]) assert_equal "https://assets.exercism.org/exercises/bob.svg", - Icons::DetermineURLFor.("exercises/bob.svg", Icons::DetermineURLFor::MISSING_EXERCISE_ICON) + Icons::DetermineUrlFor.("exercises/bob.svg", Icons::DetermineUrlFor::MISSING_EXERCISE_ICON) end test "returns the fallback when the icon doesn't exist" do stub_manifest(["exercises/bob.svg"]) - url = Icons::DetermineURLFor.("exercises/flower-field.svg", Icons::DetermineURLFor::MISSING_EXERCISE_ICON) + url = Icons::DetermineUrlFor.("exercises/flower-field.svg", Icons::DetermineUrlFor::MISSING_EXERCISE_ICON) assert_includes url, "missing-exercise" refute_includes url, "flower-field" end @@ -21,7 +21,7 @@ class Icons::DetermineURLForTest < ActiveSupport::TestCase Sentry.stubs(:capture_exception) assert_equal "https://assets.exercism.org/exercises/flower-field.svg", - Icons::DetermineURLFor.("exercises/flower-field.svg", Icons::DetermineURLFor::MISSING_EXERCISE_ICON) + Icons::DetermineUrlFor.("exercises/flower-field.svg", Icons::DetermineUrlFor::MISSING_EXERCISE_ICON) end test "exercise and track icon urls use the manifest" do From 9d877f6b7fd2d91f7b9f0c65000e99c14d32235f Mon Sep 17 00:00:00 2001 From: Jeremy Walker Date: Fri, 21 Aug 2026 18:23:26 +0200 Subject: [PATCH 5/5] Read the icons manifest from S3 No sense going back out through Cloudflare for it, and it means the manifest doesn't need to be public. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NCycuWDzu2wXg9RbJsBZUP --- Gemfile | 2 +- Gemfile.lock | 4 +-- app/commands/icons/retrieve_manifest.rb | 14 +++++--- test/commands/icons/check_exists_test.rb | 11 ++---- test/commands/icons/determine_url_for_test.rb | 14 ++------ test/commands/icons/retrieve_manifest_test.rb | 27 ++++----------- test/test_helper.rb | 34 +++++++++++++++---- 7 files changed, 54 insertions(+), 52 deletions(-) diff --git a/Gemfile b/Gemfile index 0573e98a75..f7fd884b60 100644 --- a/Gemfile +++ b/Gemfile @@ -41,7 +41,7 @@ gem 'kaminari' gem 'oj', '~> 3.14.0' # Setup dependencies -gem 'exercism-config', '>= 0.136.0' +gem 'exercism-config', '>= 0.137.0' # gem 'exercism-config', path: '../config' # Model-level dependencies diff --git a/Gemfile.lock b/Gemfile.lock index 907791f402..43a425da30 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -195,7 +195,7 @@ GEM et-orbi (1.2.11) tzinfo event_stream_parser (1.0.0) - exercism-config (0.136.0) + exercism-config (0.137.0) aws-sdk-dynamodb (~> 1.0) aws-sdk-secretsmanager (~> 1.0) mandate @@ -684,7 +684,7 @@ DEPENDENCIES devise (~> 4.7) discourse_api doorkeeper (~> 5.8) - exercism-config (>= 0.136.0) + exercism-config (>= 0.137.0) factory_bot_rails friendly_id (~> 5.4.0) geocoder (~> 1.8) diff --git a/app/commands/icons/retrieve_manifest.rb b/app/commands/icons/retrieve_manifest.rb index 026b5a5187..abb53a4744 100644 --- a/app/commands/icons/retrieve_manifest.rb +++ b/app/commands/icons/retrieve_manifest.rb @@ -1,6 +1,6 @@ # Retrieves the set of icon paths that actually exist in the icons bucket. # -# The manifest is published to the bucket root by the sync workflow in the +# The manifest is published to the bucket by the sync workflow in the # exercism/website-icons repo, so it updates whenever icons are added or # removed, without needing anything deployed here. # @@ -24,7 +24,7 @@ def call private memoize def paths - parsed = JSON.parse(RestClient.get(url).body) + parsed = JSON.parse(manifest) raise TypeError, "Icons manifest is not an array" unless parsed.is_a?(Array) parsed.to_set @@ -33,10 +33,16 @@ def paths Set.new end - def url = "#{Exercism.config.website_icons_host}/manifest.json" + def manifest + Exercism.s3_client.get_object( + bucket: Exercism.config.aws_icons_bucket, + key: MANIFEST_KEY + ).body.read + end CACHE_KEY = "Icons::RetrieveManifest".freeze + MANIFEST_KEY = "manifest.json".freeze CACHE_EXPIRY = 1.hour.freeze FAILURE_CACHE_EXPIRY = 1.minute.freeze - private_constant :CACHE_KEY, :CACHE_EXPIRY, :FAILURE_CACHE_EXPIRY + private_constant :CACHE_KEY, :MANIFEST_KEY, :CACHE_EXPIRY, :FAILURE_CACHE_EXPIRY end diff --git a/test/commands/icons/check_exists_test.rb b/test/commands/icons/check_exists_test.rb index 2476e2af91..37e6f86acc 100644 --- a/test/commands/icons/check_exists_test.rb +++ b/test/commands/icons/check_exists_test.rb @@ -2,25 +2,20 @@ class Icons::CheckExistsTest < ActiveSupport::TestCase test "true when the icon is in the manifest" do - stub_manifest(["exercises/bob.svg"]) + setup_s3_icons_manifest!(["exercises/bob.svg"]) assert Icons::CheckExists.("exercises/bob.svg") end test "false when the icon isn't in the manifest" do - stub_manifest(["exercises/bob.svg"]) + setup_s3_icons_manifest!(["exercises/bob.svg"]) refute Icons::CheckExists.("exercises/flower-field.svg") end test "true for everything when the manifest is empty" do - stub_manifest([]) + setup_s3_icons_manifest!([]) assert Icons::CheckExists.("exercises/flower-field.svg") end - - def stub_manifest(paths) - stub_request(:get, "https://assets.exercism.org/manifest.json"). - to_return(status: 200, body: paths.to_json) - end end diff --git a/test/commands/icons/determine_url_for_test.rb b/test/commands/icons/determine_url_for_test.rb index ff063380da..c3a685d866 100644 --- a/test/commands/icons/determine_url_for_test.rb +++ b/test/commands/icons/determine_url_for_test.rb @@ -2,14 +2,14 @@ class Icons::DetermineUrlForTest < ActiveSupport::TestCase test "returns the bucket url when the icon exists" do - stub_manifest(["exercises/bob.svg"]) + setup_s3_icons_manifest!(["exercises/bob.svg"]) assert_equal "https://assets.exercism.org/exercises/bob.svg", Icons::DetermineUrlFor.("exercises/bob.svg", Icons::DetermineUrlFor::MISSING_EXERCISE_ICON) end test "returns the fallback when the icon doesn't exist" do - stub_manifest(["exercises/bob.svg"]) + setup_s3_icons_manifest!(["exercises/bob.svg"]) url = Icons::DetermineUrlFor.("exercises/flower-field.svg", Icons::DetermineUrlFor::MISSING_EXERCISE_ICON) assert_includes url, "missing-exercise" @@ -17,15 +17,12 @@ class Icons::DetermineUrlForTest < ActiveSupport::TestCase end test "assumes the icon exists when the manifest is unavailable" do - stub_request(:get, "https://assets.exercism.org/manifest.json").to_return(status: 500) - Sentry.stubs(:capture_exception) - assert_equal "https://assets.exercism.org/exercises/flower-field.svg", Icons::DetermineUrlFor.("exercises/flower-field.svg", Icons::DetermineUrlFor::MISSING_EXERCISE_ICON) end test "exercise and track icon urls use the manifest" do - stub_manifest(["tracks/ruby.svg"]) + setup_s3_icons_manifest!(["tracks/ruby.svg"]) track = create :track, slug: :ruby exercise = create :practice_exercise, track:, slug: :flower_field, icon_name: 'flower-field' @@ -33,9 +30,4 @@ class Icons::DetermineUrlForTest < ActiveSupport::TestCase assert_equal "https://assets.exercism.org/tracks/ruby.svg", track.icon_url assert_includes exercise.icon_url, "missing-exercise" end - - def stub_manifest(paths) - stub_request(:get, "https://assets.exercism.org/manifest.json"). - to_return(status: 200, body: paths.to_json) - end end diff --git a/test/commands/icons/retrieve_manifest_test.rb b/test/commands/icons/retrieve_manifest_test.rb index 5400860455..3edf5c9ea0 100644 --- a/test/commands/icons/retrieve_manifest_test.rb +++ b/test/commands/icons/retrieve_manifest_test.rb @@ -2,44 +2,31 @@ class Icons::RetrieveManifestTest < ActiveSupport::TestCase test "retrieves and caches the manifest" do - stub = stub_manifest(["exercises/bob.svg", "tracks/ruby.svg"]) + setup_s3_icons_manifest!(["exercises/bob.svg", "tracks/ruby.svg"]) assert_equal Set["exercises/bob.svg", "tracks/ruby.svg"], Icons::RetrieveManifest.() - # The second call should come from the cache - Icons::RetrieveManifest.() - assert_requested stub, times: 1 + # The second call should come from the cache, not the bucket + setup_s3_icons_manifest!(["exercises/leap.svg"]) + assert_equal Set["exercises/bob.svg", "tracks/ruby.svg"], Icons::RetrieveManifest.() end - test "returns an empty set when the manifest can't be retrieved" do - stub_request(:get, MANIFEST_URL).to_return(status: 500) - Sentry.expects(:capture_exception).once - + test "returns an empty set when there's no manifest" do assert_empty Icons::RetrieveManifest.() end test "returns an empty set when the manifest isn't an array" do - stub_request(:get, MANIFEST_URL).to_return(status: 200, body: { exercises: [] }.to_json) - Sentry.expects(:capture_exception).once + setup_s3_icons_manifest!({ exercises: [] }) assert_empty Icons::RetrieveManifest.() end test "retries sooner after a failure" do - stub_request(:get, MANIFEST_URL).to_return(status: 500) - Sentry.stubs(:capture_exception) - assert_empty Icons::RetrieveManifest.() travel 2.minutes do - stub_manifest(["exercises/bob.svg"]) + setup_s3_icons_manifest!(["exercises/bob.svg"]) assert_equal Set["exercises/bob.svg"], Icons::RetrieveManifest.() end end - - MANIFEST_URL = "https://assets.exercism.org/manifest.json".freeze - - def stub_manifest(paths) - stub_request(:get, MANIFEST_URL).to_return(status: 200, body: paths.to_json) - end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 32a54a7d24..f981e0139f 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -176,12 +176,6 @@ class ActiveSupport::TestCase # the way the transactional fixtures roll back the database. Rails.cache.clear - # Almost no test cares about the icons manifest, but anything that renders - # an icon url reaches for it. Default to an empty manifest (which means - # "assume every icon exists") and let the tests that care stub their own. - stub_request(:get, "#{Exercism.config.website_icons_host}/manifest.json"). - to_return(status: 200, body: "[]") - # We do it like this (rather than stub/unstub) so that we # can have this method globally without disabling mocha's # protections against unstubbing unecessary methods. @@ -193,6 +187,7 @@ class ActiveSupport::TestCase teardown do reset_opensearch! + remove_s3_icons_manifest! Bullet.perform_out_of_channel_notifications if Bullet.notification? Bullet.end_request @@ -301,6 +296,33 @@ def create_tooling_job!(submission, type, params = {}) ) end + # The icons bucket and its manifest live in production. Tests that care about + # icon urls create the bucket in LocalStack and upload their own manifest. + # Everything else gets a NoSuchBucket, which the command treats as "assume + # every icon exists" and so leaves urls untouched. + def setup_s3_icons_manifest!(paths) + @__uploaded_s3_icons_manifest__ = true + + begin + Exercism.s3_client.create_bucket(bucket: Exercism.config.aws_icons_bucket) + rescue Aws::S3::Errors::BucketAlreadyOwnedByYou, Aws::S3::Errors::BucketAlreadyExists + # Already there from a previous test + end + + upload_to_s3(Exercism.config.aws_icons_bucket, ICONS_MANIFEST_KEY, paths.to_json) + end + + # S3 isn't rolled back between tests the way the database is, so a manifest + # left behind would change icon urls in every test that ran after it. + def remove_s3_icons_manifest! + return unless @__uploaded_s3_icons_manifest__ + + Exercism.s3_client.delete_object(bucket: Exercism.config.aws_icons_bucket, key: ICONS_MANIFEST_KEY) + @__uploaded_s3_icons_manifest__ = false + end + + ICONS_MANIFEST_KEY = "manifest.json".freeze + # The cache bucket is created by terraform in production. Locally the # config key doesn't exist, so tests set it and create the bucket in # LocalStack on demand.