discourse/lib/upcoming_changes.rb
Keegan George 0a55413ea4
PERF: Cache has_unseen_features? to avoid per-request git shell-outs (#39532)
Previously, `DiscourseUpdates.has_unseen_features?` ran the full new
features pipeline on every request for staff users — parsing JSON,
shelling out to `git merge-base` per entry via `GitUtils.has_commit?`,
merging with upcoming changes, and sorting — taking 300-400ms to produce
a single boolean.

In this update, the max `created_at` timestamp across all valid merged
features is computed once and cached in Redis, reducing
`has_unseen_features?` to two Redis GETs and an integer comparison; the
cache is invalidated when the daily job fetches new features, on
deploy/restart, and when an upcoming change transitions to permanent.
2026-04-24 10:16:46 -07:00

387 lines
14 KiB
Ruby

# frozen_string_literal: true
module UpcomingChanges
def self.user_enabled_reasons
@user_enabled_reasons ||=
::Enum.new(
enabled_for_everyone: :enabled_for_everyone,
enabled_for_no_one: :enabled_for_no_one,
in_specific_groups: :in_specific_groups,
not_in_specific_groups: :not_in_specific_groups,
)
end
def self.statuses
@statuses ||=
::Enum.new(
conceptual: -100,
experimental: 0,
alpha: 100,
beta: 200,
stable: 300,
permanent: 500,
never: 9999,
)
end
# Mostly used for testing, to allow stubbing the SiteSetting provider,
# like for SiteSettingExtension spec. This is not ideal, but the SiteSettingExtension spec
# is extremely gnarly.
def self.settings_provider
SiteSetting
end
def self.previous_status_value(status)
status_value = self.statuses[status.to_sym]
self.statuses.values.select { |value| value < status_value }.max || -100
end
def self.previous_status(status)
self.statuses.keys.select { |key| self.statuses[key] < self.statuses[status.to_sym] }.last ||
:conceptual
end
def self.image_exists?(change_setting_name)
File.exist?(File.join(Rails.public_path, self.image_path(change_setting_name)))
end
def self.image_path(change_setting_name)
plugin_name = settings_provider.plugins[change_setting_name.to_sym]
if plugin_name.present?
File.join("plugins", plugin_name, "images", "upcoming_changes", "#{change_setting_name}.png")
else
File.join("images", "upcoming_changes", "#{change_setting_name}.png")
end
end
def self.image_data(change_setting_name, include_file_path: false)
width, height = nil, nil
full_file_path = File.join(Rails.public_path, image_path(change_setting_name))
File.open(full_file_path, "rb") do |file|
image_info = FastImage.new(file)
width, height = image_info.size
end
data = { url: "#{Discourse.base_url}/#{image_path(change_setting_name)}", width:, height: }
data[:file_path] = full_file_path if include_file_path
data
end
def self.change_metadata(change_setting_name)
change_setting_name = change_setting_name.to_sym
settings_provider.upcoming_change_metadata[change_setting_name] || {}
end
def self.not_yet_stable?(change_setting_name)
change_status_value(change_setting_name) < UpcomingChanges.statuses[:stable]
end
def self.stable_or_permanent?(change_setting_name)
change_status_value(change_setting_name) >= UpcomingChanges.statuses[:stable]
end
def self.meets_or_exceeds_status?(change_setting_name, status)
change_status_value(change_setting_name) >= UpcomingChanges.statuses[status]
end
def self.change_status_value(change_setting_name)
UpcomingChanges.statuses[change_status(change_setting_name)]
end
def self.change_status(change_setting_name)
change_metadata(change_setting_name)[:status]
end
def self.history_for(change_setting_name)
change_setting_name = change_setting_name.to_sym
UserHistory.where(
action: UserHistory.actions[:upcoming_change_toggled],
subject: change_setting_name,
).order(created_at: :desc)
end
def self.exists?(change_setting_name)
change_metadata(change_setting_name.to_sym).present?
end
# We dynamically determine if an upcoming change is enabled
# or disabled based on the current status of the change as well
# as whether the admin has manually toggled the change.
#
# @param change_setting_name [Symbol] The name of the upcoming change
# @return [Boolean]
def self.enabled?(change_setting_name)
change_setting_name = change_setting_name.to_sym
# An admin has modified the setting and a value is stored
# in the database, since the default for upcoming changes
# is false.
#
# If the change is permanent though, the admin has no choice
# in the matter.
if settings_provider.setting_modified_from_default?(change_setting_name) &&
UpcomingChanges.change_status(change_setting_name) != :permanent
settings_provider.current[change_setting_name]
# The change has reached the promotion status and is forcibly
# enabled, admins can still disable it.
elsif UpcomingChanges.meets_or_exceeds_status?(
change_setting_name,
settings_provider.promote_upcoming_changes_on_status.to_sym,
) || UpcomingChanges.change_status(change_setting_name) == :permanent
true
else
# Otherwise use the default value, which for upcoming changes
# is false.
settings_provider.defaults[change_setting_name]
end
end
def self.has_groups?(change_setting_name)
group_ids_for(change_setting_name).present?
end
def self.group_ids_for(change_setting_name)
change_setting_name = change_setting_name.to_sym
settings_provider.site_setting_group_ids[change_setting_name].presence || []
end
# Checks if a given upcoming change is enabled for a user,
# which can be either enabled for everyone, enabled for certain groups,
# or disabled for everyone. The user's group membership is used to determine
# if the upcoming change is enabled for them if the upcoming change is
# enabled for certain groups.
#
# @param change_setting_name [Symbol] The name of the upcoming change
# @param user [User] The user to check if the upcoming change is enabled for
# @return [Boolean]
def self.enabled_for_user?(change_setting_name, user)
change_setting_name = change_setting_name.to_sym
setting_enabled = UpcomingChanges.enabled?(change_setting_name)
# Anon users can only have upcoming changes enabled if it's set for Everyone
if user.blank?
return false if UpcomingChanges.has_groups?(change_setting_name)
else
if UpcomingChanges.has_groups?(change_setting_name)
return(
setting_enabled && user.in_any_groups?(UpcomingChanges.group_ids_for(change_setting_name))
)
end
end
setting_enabled
end
# Calculates the current state of all upcoming changes for a given user,
# including the reason why a change is or isn't enabled for them, and
# if it's due to group membership, which groups are relevant.
#
# The acting_guardian is used to determine group visibility. This is
# mostly used to show a list of upcoming changes for a user in the admin
# interface.
#
# @param user [User] The user to get the upcoming changes for
# @param acting_guardian [Guardian] The current user's guardian
# @return [Array<Hash>]
#
# @example
# stats_for_user(user: user, acting_guardian: admin)
# # => [
# # {
# # name: "new_feature",
# # humanized_name: "New Feature",
# # description: "This is a new feature",
# # enabled: true,
# # specific_groups: ["Group 1", "Group 2"],
# # reason: :in_specific_groups
# # },
# # {
# # name: "another_feature",
# # humanized_name: "Another Feature",
# # description: "This is another feature",
# # enabled: false,
# # specific_groups: [],
# # reason: :enabled_for_no_one
# # },
# # ]
def self.stats_for_user(user:, acting_guardian:)
guardian_visible_group_ids = Group.visible_groups(acting_guardian.user).pluck(:id)
user_belonging_to_group_ids = user.belonging_to_group_ids
settings_provider.upcoming_change_site_settings.filter_map do |name|
next if UpcomingChanges.change_status(name) == :conceptual
enabled = user.upcoming_change_enabled?(name)
has_groups = UpcomingChanges.has_groups?(name)
specific_groups = []
reason =
if has_groups
visible_group_ids =
UpcomingChanges.group_ids_for(name) & guardian_visible_group_ids &
user_belonging_to_group_ids
specific_groups = Group.where(id: visible_group_ids).pluck(:name)
if enabled
UpcomingChanges.user_enabled_reasons[:in_specific_groups]
else
UpcomingChanges.user_enabled_reasons[:not_in_specific_groups]
end
elsif enabled
UpcomingChanges.user_enabled_reasons[:enabled_for_everyone]
else
UpcomingChanges.user_enabled_reasons[:enabled_for_no_one]
end
{
name:,
humanized_name: settings_provider.humanized_name(name),
description: settings_provider.description(name),
enabled:,
specific_groups:,
reason:,
}
end
end
# For a given setting, we need to determine the enabled for value
# for the UI based on the setting value, and if the setting is enabled
# for certain groups, we need the actual group records to display in the UI.
# Mostly a utility method.
#
# @param setting_name [Symbol] The name of the setting
# @param setting_value [Boolean] The value of the setting
# @param upcoming_change_selected_groups [Hash] A hash of group ids to group names
# across all upcoming changes.
# @return [Hash] The enabled for value and the setting groups
#
# @example
# enabled_for_with_groups(:new_feature, true, { 1 => "Group 1", 2 => "Group 2" })
def self.enabled_for_with_groups(setting_name, setting_value, upcoming_change_selected_groups)
group_ids_for_setting = settings_provider.site_setting_group_ids[setting_name]
setting_groups =
upcoming_change_selected_groups.values_at(*group_ids_for_setting).join(
",",
) if group_ids_for_setting.present?
enabled_for =
if !setting_value
"no_one"
elsif setting_groups.blank?
"everyone"
else
if group_ids_for_setting == [Group::AUTO_GROUPS[:staff]]
# Have to do this because the staff auto group name is localized
upcoming_change_selected_groups[Group::AUTO_GROUPS[:staff]]
else
"groups"
end
end
{ enabled_for:, setting_groups: }
end
def self.clear_caches!
Discourse.cache.delete(current_statuses_cache_key)
Discourse.cache.delete(permanent_upcoming_changes_cache_key)
DiscourseUpdates.clear_latest_new_feature_created_at_cache
end
def self.current_statuses_cache_key
"upcoming_changes_current_statuses::#{Discourse.git_version}"
end
# This also only changes once per deploy, so we can cache to the git version
# to save time in other places in the codebase when we have to figure out
# when an upcoming change moved to its current status.
#
# This cache is automatically cleared when UpcomingChanges::Action::TrackNotifyStatusChanges
# is called, since that adds new UpcomingChangeEvent records.
def self.current_statuses
Discourse
.cache
.fetch(current_statuses_cache_key) do
results = DB.query(<<-SQL, status_changed: UpcomingChangeEvent.event_types[:status_changed])
WITH latest_status_changes AS (
SELECT upcoming_change_name, MAX(created_at) as created_at
FROM upcoming_change_events
WHERE event_type = :status_changed
GROUP BY upcoming_change_name
ORDER BY MAX(created_at) DESC
)
SELECT latest_status_changes.upcoming_change_name, latest_status_changes.created_at, upcoming_change_events.event_data->>'new_value' as new_value
FROM latest_status_changes
INNER JOIN upcoming_change_events ON upcoming_change_events.upcoming_change_name = latest_status_changes.upcoming_change_name AND upcoming_change_events.created_at = latest_status_changes.created_at
ORDER BY latest_status_changes.created_at DESC
SQL
results.each_with_object({}) do |result, statuses|
statuses[result.upcoming_change_name] = {
status: result.new_value,
changed_at: result.created_at,
}
end
end
end
def self.permanent_upcoming_changes_cache_key
"upcoming_changes_permanent::#{Discourse.git_version}"
end
# These don't change except on deploy, so we can cache to the git version
# to save time in other places in the codebase when we have to figure out
# whether a change is permanent or not.
def self.permanent_upcoming_changes
Discourse
.cache
.fetch(permanent_upcoming_changes_cache_key) do
result =
UpcomingChanges::List.call(
guardian: Discourse.system_user.guardian,
options: {
filter_statuses: [:permanent],
},
)
if !result.success? && Rails.env.local?
puts result.inspect_steps
raise
end
result.upcoming_changes
end
end
# No point in notifying admins on brand new sites, the upcoming change system
# is more about notifying admins of changes to established sites.
#
# Of course we don't care about this in development, we need to test notifications,
# and we can stub this method in rspec.
def self.should_notify_admins?
Migration::Helpers.existing_site? || Rails.env.development?
end
# Some upcoming changes have a depends_on relationship with other settings,
# where it doesn't make sense to show the dependent settings in the site
# settings UI unless the upcoming change is enabled.
#
# This is done via depends_on and depends_behavior: hidden in site_settings.yml.
def self.find_dependents_for_change(change_setting_name)
settings_provider.type_supervisor.dependencies.dependents(change_setting_name.to_s)
end
# Some upcoming changes when enabled will override the default value
# of another setting.
#
# This is done via upcoming_change_default_override in site_settings.yml.
def self.find_related_default_override_for_change(change_setting_name)
settings_provider
.upcoming_change_default_overrides
.find { |_, override| override[:upcoming_change] == change_setting_name.to_sym }
&.first
end
end