discourse/lib/site_settings/dependency_graph.rb
Ted Johansson 09d6010c7b
DEV: Detect circular site setting dependencies (#38165)
### Background

In #36061, dependent site settings were introduced through the
`depends_on` key. This is used internally to construct a dependency
graph and sort settings in order-of-dependency before bulk updates.

However, no safeguards were introduced to avoid circular dependencies.
This means a `TSort::Cyclic` could come along and ruin the day at
arbitrary points during runtime.

### What is this change?

This change does two things:

1. Rescue, format, and re-raise `TSort::Cyclic` exceptions.
2. Eagerly order settings on app reload to catch cyclic dependencies
up-front.
2026-03-04 10:10:38 +10:00

69 lines
1.4 KiB
Ruby

# frozen_string_literal: true
module SiteSettings
end
class SiteSettings::DependencyGraph
include TSort
CircularDependency = Class.new(StandardError)
attr_reader :dependencies, :behaviors
def initialize(dependencies = {})
@dependencies = dependencies
@behaviors = {}
end
def []=(setting, value)
dependencies[setting] = value
end
def [](setting)
dependencies[setting]
end
def reverse_dependencies
@reverse_dependencies ||=
begin
rev = {}
dependencies.each do |setting, deps|
Array(deps).each { |dep| (rev[dep.to_s] ||= []) << setting }
end
rev
end
end
def dependents(setting)
reverse_dependencies.fetch(setting.to_s, [])
end
def change_behavior(setting, behavior)
behavior = behavior.to_sym
raise ArgumentError.new("Behavior must be :hidden") unless behavior == :hidden
behaviors[setting] = behavior
end
def order
@order ||= tsort
rescue TSort::Cyclic
cycles =
strongly_connected_components.reject(&:one?).map { |cycle| cycle.join(" <-> ") }.join("\n")
raise CircularDependency.new(<<~MESSAGE)
Circular dependencies in site settings:
#{cycles}
MESSAGE
end
private
def tsort_each_child(node, &block)
dependencies.fetch(node, []).each(&block)
end
def tsort_each_node(&block)
dependencies.each_key(&block)
end
end