Skip to content

Commit 527a20b

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 share the same cache entry and the same Foreman response, regardless of how the client ordered them. The implementation uses three clearly separated layers: get '/' — handles errors only; delegates to registration_script registration_script — owns the cache key and the business logic (what to cache and what to do on a miss); raises ScriptFetchError on non-200 so errors never reach the cache write cache(key, &block) — owns the mechanism: per-key double-checked locking via a block abstraction that keeps the caller free of locking concerns Per-key locking allows concurrent requests for genuinely different keys (e.g. different activation keys) to fetch from Foreman in parallel, while serialising only threads competing for the same key. The per-key Mutex is evicted from KEY_MUTEXES immediately after caching — once a key is hot, all future requests take the lock-free fast path and KEY_MUTEXES is empty under steady state. Only HTTP 200 responses are cached. Non-200 responses raise ScriptFetchError out of the cache block, which is rescued in get '/' and rendered via handle_response without poisoning the cache. Both KEY_MUTEXES and SCRIPT_CACHE use Concurrent::Map (already a smart-proxy dependency) for lock-free, thread-safe access on all Ruby VMs without relying on MRI's GIL. 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 527a20b

2 files changed

Lines changed: 158 additions & 4 deletions

File tree

modules/registration/registration_api.rb

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,50 @@
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.
13+
#
14+
# Only HTTP 200 responses are cached — errors are raised out of the cache
15+
# block so they never poison the cache. The per-key mutex is evicted
16+
# immediately after caching so KEY_MUTEXES stays bounded to keys currently
17+
# being fetched for the first time (zero under steady state).
18+
REGISTRATION_SCRIPT_CACHE_TTL = 5 * 60 # seconds
19+
KEY_MUTEXES = Concurrent::Map.new
20+
SCRIPT_CACHE = Concurrent::Map.new
21+
22+
class ScriptFetchError < StandardError
23+
attr_reader :response
24+
def initialize(response)
25+
super()
26+
@response = response
27+
end
28+
end
29+
30+
class << self
31+
def registration_script_cache
32+
SCRIPT_CACHE
33+
end
34+
35+
def key_mutex(cache_key)
36+
KEY_MUTEXES.compute_if_absent(cache_key) { Mutex.new }
37+
end
38+
39+
def evict_key_mutex(cache_key)
40+
KEY_MUTEXES.delete(cache_key)
41+
end
42+
end
43+
444
get '/' do
5-
response = Proxy::Registration::ProxyRequest.new.global_register(request)
6-
handle_response(response)
45+
registration_script
46+
rescue ScriptFetchError => e
47+
handle_response(e.response)
748
rescue StandardError => e
849
logger.exception "Error when rendering Global Registration Template", e
950
render_error(default_error_msg)
@@ -19,11 +60,41 @@ class Proxy::Registration::Api < ::Sinatra::Base
1960

2061
private
2162

63+
def registration_script
64+
cache_key = Rack::Utils.build_query(
65+
Rack::Utils.parse_nested_query(request.query_string).sort_by { |k, _| k }
66+
)
67+
cache(cache_key) do
68+
response = Proxy::Registration::ProxyRequest.new.global_register(request)
69+
raise ScriptFetchError.new(response) unless response.code == '200'
70+
response.body
71+
end
72+
end
73+
74+
def cache(key, &block)
75+
value = read_registration_cache(key)
76+
return value if value
77+
78+
self.class.key_mutex(key).synchronize do
79+
value = read_registration_cache(key)
80+
return value if value
81+
82+
result = block.call
83+
self.class.registration_script_cache[key] = { body: result, at: Time.now }
84+
self.class.evict_key_mutex(key)
85+
result
86+
end
87+
end
88+
89+
def read_registration_cache(cache_key)
90+
entry = self.class.registration_script_cache[cache_key]
91+
entry[:body] if entry && (Time.now - entry[:at]) < REGISTRATION_SCRIPT_CACHE_TTL
92+
end
93+
2294
def handle_response(response)
23-
if response.code.start_with? '2'
95+
if response.code.start_with?('2')
2496
response.body
2597
else
26-
# Return error message only if it is not HTML.
2798
message = response["content-type"].include?('text/plain') ? response.body : default_error_msg
2899
render_error(message, code: response.code)
29100
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)