Skip to content

Commit fefb11c

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 fefb11c

2 files changed

Lines changed: 155 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
19+
# steady state). See evict_key_mutex for the thread-safety analysis.
20+
REGISTRATION_SCRIPT_CACHE_TTL = 5 * 60 # seconds
21+
22+
class << self
23+
KEY_MUTEXES_LOCK = Mutex.new
24+
KEY_MUTEXES = {}
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_LOCK.synchronize { KEY_MUTEXES[cache_key] ||= Mutex.new }
33+
end
34+
35+
def evict_key_mutex(cache_key)
36+
KEY_MUTEXES_LOCK.synchronize { 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: 82 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,85 @@ 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+
stub = stub_request(:get, "#{@foreman_url}/register")
122+
.with(query: { 'owner' => 'Default_Organization', 'activation_keys' => 'rhel9' })
123+
.to_return(body: 'template')
124+
125+
get '/', { owner: 'Default_Organization', activation_keys: 'rhel9' }
126+
assert last_response.ok?
127+
128+
# Different parameter order — must hit cache, not Foreman again
129+
get '/', { activation_keys: 'rhel9', owner: 'Default_Organization' }
130+
assert last_response.ok?
131+
132+
assert_requested stub, times: 1
133+
end
134+
135+
def test_global_register_caches_per_key
136+
stub_a = stub_request(:get, "#{@foreman_url}/register?key=a").to_return(body: 'template_a')
137+
stub_b = stub_request(:get, "#{@foreman_url}/register?key=b").to_return(body: 'template_b')
138+
139+
get '/', { key: 'a' }
140+
assert_match('template_a', last_response.body)
141+
get '/', { key: 'b' }
142+
assert_match('template_b', last_response.body)
143+
# second requests — must be served from cache
144+
get '/', { key: 'a' }
145+
assert_match('template_a', last_response.body)
146+
get '/', { key: 'b' }
147+
assert_match('template_b', last_response.body)
148+
149+
assert_requested stub_a, times: 1
150+
assert_requested stub_b, times: 1
151+
end
152+
153+
def test_global_register_evicts_mutex_after_caching
154+
stub_request(:get, "#{@foreman_url}/register").to_return(body: 'template')
155+
156+
get '/'
157+
assert last_response.ok?
158+
assert_empty Proxy::Registration::Api::KEY_MUTEXES
159+
end
160+
161+
def test_global_register_cache_entry_expires_after_ttl
162+
Proxy::Registration::Api.registration_script_cache[''] = {
163+
body: 'stale-template',
164+
at: Time.now - Proxy::Registration::Api::REGISTRATION_SCRIPT_CACHE_TTL - 1
165+
}
166+
stub = stub_request(:get, "#{@foreman_url}/register").to_return(body: 'fresh-template')
167+
168+
get '/'
169+
assert last_response.ok?
170+
assert_match('fresh-template', last_response.body)
171+
assert_requested stub, times: 1
172+
end
173+
174+
def test_global_register_does_not_cache_errors
175+
stub = stub_request(:get, "#{@foreman_url}/register").to_return(
176+
body: 'error', status: 500, headers: { "Content-Type" => 'text/plain' }
177+
)
178+
179+
2.times do
180+
get '/'
181+
assert last_response.server_error?
182+
end
183+
184+
assert_requested stub, times: 2
185+
end
186+
105187
def test_host_500
106188
Rack::NullLogger.any_instance.stubs(:exception)
107189
stub_request(:post, "#{@foreman_url}/register").to_timeout

0 commit comments

Comments
 (0)