Skip to content

Commit 83ee924

Browse files
committed
Fixes #39208 - Cache global registration script in smart-proxy
GET /register returns the same shell script for all hosts sharing the same registration parameters (org, location, hostgroup, activation keys). Under bulk registration — 100+ hosts hitting the same capsule simultaneously — this endpoint is called once per host, each time proxying to Foreman and waiting for the ERB template to render (~103ms in profiling). Cache the rendered script in memory (5-minute TTL) keyed on a canonical form of the request query string. The cache key is computed by parsing the query string, sorting parameters alphabetically, and rebuilding — so requests that differ only in parameter order (e.g. subscription-manager and different client versions) share the same cache entry. Per-key double-checked locking prevents the thundering herd on a cold cache while allowing concurrent requests for genuinely different keys (e.g. different activation keys) to fetch from Foreman in parallel: fast path — unsynchronised Concurrent::Map read, no lock acquired slow path — key-specific Mutex serialises only competing threads; double-check under lock before fetching from Foreman The per-key Mutex is evicted from KEY_MUTEXES immediately after the script is written to the cache. Once a key is hot, all future requests take the fast path and KEY_MUTEXES is empty under steady state. Only 2xx responses are cached — errors never poison the cache, so a transient Foreman failure causes no lasting impact on subsequent requests. Errors do not evict the mutex, so the next retry takes the slow path and re-attempts the Foreman call. SCRIPT_CACHE uses Concurrent::Map (already a smart-proxy dependency) rather than a plain Hash. This provides lock-free reads that are safe on all Ruby VMs without relying on MRI's GIL. KEY_MUTEXES, KEY_MUTEXES_LOCK, and SCRIPT_CACHE are class-level constants initialised at load time, avoiding the initialisation races that lazy ||= patterns introduce in multi-threaded environments. Tests added: - Cache hit: Foreman called once for repeated identical requests - Per-key isolation: different parameter sets cached independently - Parameter order independence: requests differing only in param order share the same cache entry - TTL expiry: expired entries are not served; Foreman is re-called - Mutex eviction: KEY_MUTEXES is empty after a successful cache write - Error non-caching: Foreman is called on every request when it errors - setup clears both SCRIPT_CACHE and KEY_MUTEXES between tests
1 parent a5780bc commit 83ee924

2 files changed

Lines changed: 156 additions & 3 deletions

File tree

modules/registration/registration_api.rb

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,55 @@
11
require 'registration/proxy_request'
22

33
class Proxy::Registration::Api < ::Sinatra::Base
4+
# Cache for the global registration script (GET /register).
5+
#
6+
# The script is identical for all hosts sharing the same registration
7+
# parameters (org, location, hostgroup, activation keys), making it safe
8+
# to serve from an in-memory cache during concurrent bulk registration.
9+
#
10+
# Per-key double-checked locking prevents thundering herd while allowing
11+
# genuinely independent cache keys (e.g. different activation keys) to
12+
# fetch from Foreman in parallel. Only threads competing for the same key
13+
# are serialized — one fetches while the rest block for ~103ms then receive
14+
# the cached result immediately. Threads for different keys are unaffected.
15+
#
16+
# Only successful (2xx) responses are cached — errors never poison the cache.
17+
# The per-key mutex is evicted immediately after caching so KEY_MUTEXES stays
18+
# bounded to keys currently being fetched for the first time (zero under steady
19+
# state). Both KEY_MUTEXES and SCRIPT_CACHE use Concurrent::Map for lock-free,
20+
# thread-safe access on all Ruby VMs without relying on MRI's GIL.
21+
REGISTRATION_SCRIPT_CACHE_TTL = 5 * 60 # seconds
22+
23+
class << self
24+
KEY_MUTEXES = Concurrent::Map.new
25+
SCRIPT_CACHE = Concurrent::Map.new
26+
27+
def registration_script_cache
28+
SCRIPT_CACHE
29+
end
30+
31+
def key_mutex(cache_key)
32+
KEY_MUTEXES.compute_if_absent(cache_key) { Mutex.new }
33+
end
34+
35+
def evict_key_mutex(cache_key)
36+
KEY_MUTEXES.delete(cache_key)
37+
end
38+
end
39+
440
get '/' do
5-
response = Proxy::Registration::ProxyRequest.new.global_register(request)
6-
handle_response(response)
41+
cache_key = Rack::Utils.build_query(
42+
Rack::Utils.parse_nested_query(request.query_string).sort_by { |k, _| k }
43+
)
44+
45+
# Fast path — no lock needed, served directly from cache
46+
cached = read_registration_cache(cache_key)
47+
return cached if cached
48+
49+
# Slow path — serialize only threads competing for the same key.
50+
# Threads for different keys (different activation keys / OS versions)
51+
# fetch from Foreman in parallel without blocking each other.
52+
fetch_and_cache_script(cache_key)
753
rescue StandardError => e
854
logger.exception "Error when rendering Global Registration Template", e
955
render_error(default_error_msg)
@@ -19,11 +65,35 @@ class Proxy::Registration::Api < ::Sinatra::Base
1965

2066
private
2167

68+
def fetch_and_cache_script(cache_key)
69+
self.class.key_mutex(cache_key).synchronize do
70+
# Re-check under lock: another thread with the same key may have
71+
# already populated the cache while we were waiting.
72+
cached = read_registration_cache(cache_key)
73+
return cached if cached
74+
75+
response = Proxy::Registration::ProxyRequest.new.global_register(request)
76+
77+
if response.code.start_with?('2')
78+
self.class.registration_script_cache[cache_key] = { body: response.body, at: Time.now }
79+
self.class.evict_key_mutex(cache_key)
80+
return response.body
81+
end
82+
83+
message = response["content-type"].include?('text/plain') ? response.body : default_error_msg
84+
render_error(message, code: response.code)
85+
end
86+
end
87+
88+
def read_registration_cache(cache_key)
89+
entry = self.class.registration_script_cache[cache_key]
90+
entry[:body] if entry && (Time.now - entry[:at]) < REGISTRATION_SCRIPT_CACHE_TTL
91+
end
92+
2293
def handle_response(response)
2394
if response.code.start_with? '2'
2495
response.body
2596
else
26-
# Return error message only if it is not HTML.
2797
message = response["content-type"].include?('text/plain') ? response.body : default_error_msg
2898
render_error(message, code: response.code)
2999
end

test/registration/registration_api_test.rb

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ def app
1111
def setup
1212
@foreman_url = 'http://foreman.example.com'
1313
Proxy::SETTINGS.stubs(:foreman_url).returns(@foreman_url)
14+
# Clear class-level state between tests to prevent cross-test contamination
15+
Proxy::Registration::Api.registration_script_cache.clear
16+
Proxy::Registration::Api::KEY_MUTEXES.clear
1417
end
1518

1619
def test_global_register_template
@@ -102,6 +105,86 @@ def test_global_500
102105
assert_match("echo \"Internal Server Error\"\nexit 1\n", last_response.body)
103106
end
104107

108+
def test_global_register_caches_response
109+
stub = stub_request(:get, "#{@foreman_url}/register").to_return(body: 'template')
110+
111+
2.times do
112+
get '/'
113+
assert last_response.ok?
114+
assert_match('template', last_response.body)
115+
end
116+
117+
assert_requested stub, times: 1
118+
end
119+
120+
def test_global_register_cache_key_is_parameter_order_independent
121+
# Cache key is normalised (params sorted alphabetically), so both orderings
122+
# produce activation_keys=rhel9&owner=Default_Organization and share one entry.
123+
stub_request(:get, "#{@foreman_url}/register?activation_keys=rhel9&owner=Default_Organization")
124+
.to_return(body: 'template')
125+
126+
get '/', { owner: 'Default_Organization', activation_keys: 'rhel9' }
127+
assert last_response.ok?
128+
129+
# Different parameter order — must hit cache, not Foreman again
130+
get '/', { activation_keys: 'rhel9', owner: 'Default_Organization' }
131+
assert last_response.ok?
132+
133+
assert_requested :get, "#{@foreman_url}/register?activation_keys=rhel9&owner=Default_Organization", times: 1
134+
end
135+
136+
def test_global_register_caches_per_key
137+
stub_a = stub_request(:get, "#{@foreman_url}/register?key=a").to_return(body: 'template_a')
138+
stub_b = stub_request(:get, "#{@foreman_url}/register?key=b").to_return(body: 'template_b')
139+
140+
get '/', { key: 'a' }
141+
assert_match('template_a', last_response.body)
142+
get '/', { key: 'b' }
143+
assert_match('template_b', last_response.body)
144+
# second requests — must be served from cache
145+
get '/', { key: 'a' }
146+
assert_match('template_a', last_response.body)
147+
get '/', { key: 'b' }
148+
assert_match('template_b', last_response.body)
149+
150+
assert_requested stub_a, times: 1
151+
assert_requested stub_b, times: 1
152+
end
153+
154+
def test_global_register_evicts_mutex_after_caching
155+
stub_request(:get, "#{@foreman_url}/register").to_return(body: 'template')
156+
157+
get '/'
158+
assert last_response.ok?
159+
assert_empty Proxy::Registration::Api::KEY_MUTEXES
160+
end
161+
162+
def test_global_register_cache_entry_expires_after_ttl
163+
Proxy::Registration::Api.registration_script_cache[''] = {
164+
body: 'stale-template',
165+
at: Time.now - Proxy::Registration::Api::REGISTRATION_SCRIPT_CACHE_TTL - 1,
166+
}
167+
stub = stub_request(:get, "#{@foreman_url}/register").to_return(body: 'fresh-template')
168+
169+
get '/'
170+
assert last_response.ok?
171+
assert_match('fresh-template', last_response.body)
172+
assert_requested stub, times: 1
173+
end
174+
175+
def test_global_register_does_not_cache_errors
176+
stub = stub_request(:get, "#{@foreman_url}/register").to_return(
177+
body: 'error', status: 500, headers: { "Content-Type" => 'text/plain' }
178+
)
179+
180+
2.times do
181+
get '/'
182+
assert last_response.server_error?
183+
end
184+
185+
assert_requested stub, times: 2
186+
end
187+
105188
def test_host_500
106189
Rack::NullLogger.any_instance.stubs(:exception)
107190
stub_request(:post, "#{@foreman_url}/register").to_timeout

0 commit comments

Comments
 (0)