Skip to content

Commit d9352d5

Browse files
authored
Replace full-flush Route cache with LRU and add a configurable max cache size (#724)
1 parent c673b39 commit d9352d5

File tree

4 files changed

+217
-7
lines changed

4 files changed

+217
-7
lines changed

spec/route_handler_spec.cr

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,4 +179,114 @@ describe "Kemal::RouteHandler" do
179179
client_response.body.should eq("home page")
180180
client_response.headers.has_key?("Location").should eq(true)
181181
end
182+
183+
context "LRU cache" do
184+
it "evicts least recently used entries instead of clearing entirely" do
185+
# Use a small capacity to make the test fast and deterministic
186+
small_capacity = 8
187+
# Replace the cache instance with a smaller-capacity LRU for this test
188+
Kemal::RouteHandler::INSTANCE.cached_routes = Kemal::LRUCache(String, Radix::Result(Kemal::Route)).new(small_capacity)
189+
190+
# Define more routes than capacity
191+
0.upto(15) do |i|
192+
get "/lru_eviction_#{i}" do
193+
"ok"
194+
end
195+
end
196+
197+
# Access the first `small_capacity` routes to fill the cache
198+
0.upto(small_capacity - 1) do |i|
199+
Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_eviction_#{i}")
200+
end
201+
202+
Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq small_capacity
203+
204+
# Access some new routes to trigger eviction
205+
small_capacity.upto(small_capacity + 3) do |i|
206+
Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_eviction_#{i}")
207+
end
208+
209+
# Cache should still be capped at capacity
210+
Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq small_capacity
211+
end
212+
213+
it "retains recently used keys and evicts the least recently used" do
214+
small_capacity = 4
215+
Kemal::RouteHandler::INSTANCE.cached_routes = Kemal::LRUCache(String, Radix::Result(Kemal::Route)).new(small_capacity)
216+
217+
0.upto(5) do |i|
218+
get "/lru_recency_#{i}" do
219+
"ok"
220+
end
221+
end
222+
223+
# Fill cache with 0..3
224+
0.upto(3) do |i|
225+
Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_recency_#{i}")
226+
end
227+
228+
# Touch 0 and 1 to make them most recent
229+
[0, 1].each do |i|
230+
Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_recency_#{i}")
231+
end
232+
233+
# Insert 4 -> should evict least recent among {2,3}
234+
Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_recency_4")
235+
236+
# Insert 5 -> should evict the other of {2,3}
237+
Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_recency_5")
238+
239+
# Now 0 and 1 must still resolve from cache, and size is capped
240+
Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq small_capacity
241+
242+
# A fresh lookup for 0 and 1 should be cache hits and not raise
243+
Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_recency_0").found?.should be_true
244+
Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_recency_1").found?.should be_true
245+
end
246+
247+
it "caches HEAD fallback GET lookups without growing beyond 1 for same path" do
248+
cap = 16
249+
Kemal::RouteHandler::INSTANCE.cached_routes = Kemal::LRUCache(String, Radix::Result(Kemal::Route)).new(cap)
250+
251+
get "/head_fallback" do
252+
"ok"
253+
end
254+
255+
# First HEAD should fallback to GET and cache one entry keyed by HEAD+path
256+
Kemal::RouteHandler::INSTANCE.lookup_route("HEAD", "/head_fallback").found?.should be_true
257+
Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq 1
258+
259+
# Second HEAD lookup should be a cache hit; size must remain 1
260+
Kemal::RouteHandler::INSTANCE.lookup_route("HEAD", "/head_fallback").found?.should be_true
261+
Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq 1
262+
end
263+
264+
it "keeps size capped under heavy churn with large capacity" do
265+
large_capacity = 4096
266+
Kemal::RouteHandler::INSTANCE.cached_routes = Kemal::LRUCache(String, Radix::Result(Kemal::Route)).new(large_capacity)
267+
268+
0.upto(12000) do |i|
269+
get "/lru_heavy_#{i}" do
270+
"ok"
271+
end
272+
end
273+
274+
# Fill and churn beyond capacity
275+
0.upto(11999) do |i|
276+
Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_heavy_#{i}")
277+
end
278+
279+
Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq large_capacity
280+
281+
# Additional churn should not increase size
282+
12000.upto(14000) do |i|
283+
get "/lru_heavy_more_#{i}" do
284+
"ok"
285+
end
286+
Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_heavy_more_#{i}")
287+
end
288+
289+
Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq large_capacity
290+
end
291+
end
182292
end

spec/spec_helper.cr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,6 @@ Spec.after_each do
9393
Kemal.config.clear
9494
Kemal::FilterHandler::INSTANCE.tree = Radix::Tree(Array(Kemal::FilterHandler::FilterBlock)).new
9595
Kemal::RouteHandler::INSTANCE.routes = Radix::Tree(Route).new
96-
Kemal::RouteHandler::INSTANCE.cached_routes = Hash(String, Radix::Result(Route)).new
96+
Kemal::RouteHandler::INSTANCE.cached_routes = Kemal::LRUCache(String, Radix::Result(Kemal::Route)).new(Kemal.config.max_route_cache_size)
9797
Kemal::WebSocketHandler::INSTANCE.routes = Radix::Tree(WebSocket).new
9898
end

src/kemal/config.cr

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ module Kemal
2626
property serve_static : (Bool | Hash(String, Bool))
2727
property static_headers : (HTTP::Server::Context, String, File::Info -> Void)?
2828
property? powered_by_header : Bool = true
29+
property max_route_cache_size : Int32
2930

3031
def initialize
3132
@app_name = "Kemal"
@@ -43,6 +44,7 @@ module Kemal
4344
@running = false
4445
@shutdown_message = true
4546
@handler_position = 0
47+
@max_route_cache_size = 1024
4648
end
4749

4850
@[Deprecated("Use standard library Log")]
@@ -69,6 +71,7 @@ module Kemal
6971
@router_included = false
7072
@handler_position = 0
7173
@default_handlers_setup = false
74+
@max_route_cache_size = 1024
7275
HANDLERS.clear
7376
CUSTOM_HANDLERS.clear
7477
FILTER_HANDLERS.clear

src/kemal/route_handler.cr

Lines changed: 103 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,114 @@
11
require "radix"
22

33
module Kemal
4+
# Small, private LRU cache used by the router to avoid full cache clears
5+
# when many distinct paths are accessed. Keeps get/put at O(1).
6+
# This is intentionally minimal and file-local to avoid API surface.
7+
class LRUCache(K, V)
8+
# Doubly-linked list node
9+
class Node(K, V)
10+
property key : K
11+
property value : V
12+
property prev : Node(K, V)?
13+
property next : Node(K, V)?
14+
15+
def initialize(@key : K, @value : V)
16+
@prev = nil
17+
@next = nil
18+
end
19+
end
20+
21+
@capacity : Int32
22+
@map : Hash(K, Node(K, V))
23+
@head : Node(K, V)? # most-recent
24+
@tail : Node(K, V)? # least-recent
25+
26+
def initialize(@capacity : Int32)
27+
@map = Hash(K, Node(K, V)).new
28+
@head = nil
29+
@tail = nil
30+
end
31+
32+
def size : Int32
33+
@map.size
34+
end
35+
36+
def get(key : K) : V?
37+
if node = @map[key]?
38+
move_to_front(node)
39+
return node.value
40+
end
41+
nil
42+
end
43+
44+
def put(key : K, value : V) : Nil
45+
if node = @map[key]?
46+
node.value = value
47+
move_to_front(node)
48+
return
49+
end
50+
51+
# Evict before adding to avoid unnecessary hash resize
52+
evict_if_at_capacity
53+
54+
node = Node(K, V).new(key, value)
55+
@map[key] = node
56+
insert_front(node)
57+
end
58+
59+
private def insert_front(node : Node(K, V))
60+
node.prev = nil
61+
node.next = @head
62+
@head.try(&.prev=(node))
63+
@head = node
64+
@tail = node if @tail.nil?
65+
end
66+
67+
private def move_to_front(node : Node(K, V))
68+
return if node == @head
69+
70+
# unlink
71+
prev = node.prev
72+
nxt = node.next
73+
prev.try(&.next=(nxt))
74+
nxt.try(&.prev=(prev))
75+
76+
# fix tail if needed
77+
if node == @tail
78+
@tail = prev
79+
end
80+
81+
insert_front(node)
82+
end
83+
84+
private def evict_if_at_capacity
85+
return if @map.size < @capacity
86+
87+
if lru = @tail
88+
# unlink tail
89+
prev = lru.prev
90+
if prev
91+
prev.next = nil
92+
@tail = prev
93+
else
94+
# only one element
95+
@head = nil
96+
@tail = nil
97+
end
98+
@map.delete(lru.key)
99+
end
100+
end
101+
end
102+
4103
class RouteHandler
5104
include HTTP::Handler
6105

7-
INSTANCE = new
8-
CACHED_ROUTES_LIMIT = 1024
106+
INSTANCE = new
9107
property routes, cached_routes
10108

11109
def initialize
12110
@routes = Radix::Tree(Route).new
13-
@cached_routes = Hash(String, Radix::Result(Route)).new
111+
@cached_routes = LRUCache(String, Radix::Result(Route)).new(Kemal.config.max_route_cache_size)
14112
end
15113

16114
def call(context : HTTP::Server::Context)
@@ -26,7 +124,7 @@ module Kemal
26124
def lookup_route(verb : String, path : String)
27125
lookup_path = radix_path(verb, path)
28126

29-
if cached_route = @cached_routes[lookup_path]?
127+
if cached_route = @cached_routes.get(lookup_path)
30128
return cached_route
31129
end
32130

@@ -38,8 +136,7 @@ module Kemal
38136
end
39137

40138
if route.found?
41-
@cached_routes.clear if @cached_routes.size == CACHED_ROUTES_LIMIT
42-
@cached_routes[lookup_path] = route
139+
@cached_routes.put(lookup_path, route)
43140
end
44141

45142
route

0 commit comments

Comments
 (0)