Skip to content

Commit 58003b0

Browse files
authored
Merge pull request #55 from OLIOEX/keepalive-connections
feat: opt-in HTTP keep-alive via keep_alive_connections
2 parents 443f21e + f2e58c8 commit 58003b0

5 files changed

Lines changed: 261 additions & 8 deletions

File tree

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,29 @@ Here are some examples with inline comments that walk you through how to use the
2929

3030
Tests are also a good place to know how the the library works internally: [spec](spec)
3131

32+
### Keep-alive connections
33+
34+
By default, the client opens a fresh HTTP connection (and TLS handshake) for every request. For high-traffic applications this can dominate request latency. Setting `keep_alive_connections: true` enables persistent connections via the `:net_http_persistent` Faraday adapter:
35+
36+
```ruby
37+
Typesense::Client.new(
38+
api_key: ENV['TYPESENSE_API_KEY'],
39+
nodes: [{ host: 'localhost', port: 8108, protocol: 'https' }],
40+
connection_timeout_seconds: 3,
41+
num_retries: 1,
42+
keep_alive_connections: true
43+
)
44+
```
45+
46+
Notes:
47+
48+
- Connections are cached per `(thread, node)`. `Net::HTTP` is not thread-safe, so each thread maintains its own keep-alive socket to each Typesense node, and the existing node round-robin still works.
49+
- A cached connection is dropped automatically when a network error occurs, so retries open a fresh socket. We recommend setting `num_retries` to at least `1` so the gem can recover from a server- or load-balancer-side idle timeout transparently.
50+
- Idle sockets are closed after 30 seconds by default. Override with `keep_alive_idle_timeout_seconds` to match or stay under your load balancer's idle timeout.
51+
- The underlying `net_http_persistent` adapter holds at most `keep_alive_pool_size` sockets per origin (default `1`, which matches the per-`(thread, node)` cache above). The default of `1` is the safe choice for the vast majority of users — because we already cache one Faraday connection per `(thread, node)` and `Net::HTTP` is not thread-safe, a single socket per pool is all the adapter needs. Only raise this if you have a specific reason to keep additional sockets warm per origin (e.g. a non-standard concurrency model layered on top of this client); a larger pool will not increase request throughput on its own.
52+
- `keep_alive_idle_timeout_seconds` and `keep_alive_pool_size` are only valid when `keep_alive_connections: true`; setting them otherwise raises `Typesense::Error::MissingConfiguration`.
53+
- The option defaults to `false`, so upgrading the gem does not change behaviour until you opt in.
54+
3255
## Compatibility
3356

3457
| Typesense Server | typesense-ruby |

lib/typesense/api_call.rb

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,18 @@ def initialize(configuration)
1919
@healthcheck_interval_seconds = @configuration.healthcheck_interval_seconds
2020
@num_retries_per_request = @configuration.num_retries
2121
@retry_interval_seconds = @configuration.retry_interval_seconds
22+
@keep_alive_connections = @configuration.keep_alive_connections
23+
@keep_alive_idle_timeout_seconds = @configuration.keep_alive_idle_timeout_seconds
24+
@keep_alive_pool_size = @configuration.keep_alive_pool_size
2225

2326
@logger = @configuration.logger
2427

28+
# Per-instance key for the thread-local connection cache so multiple
29+
# Typesense::Client instances in the same process do not share sockets.
30+
@thread_connections_key = :"_typesense_api_call_connections_#{object_id}"
31+
32+
require 'faraday/net_http_persistent' if @keep_alive_connections
33+
2534
initialize_metadata_for_nodes
2635
@current_node_index = -1
2736
end
@@ -69,14 +78,11 @@ def perform_request(method, endpoint, query_parameters: nil, body_parameters: ni
6978
@logger.debug "Attempting #{method.to_s.upcase} request Try ##{num_tries} to Node #{node[:index]}"
7079

7180
begin
72-
conn = Faraday.new(uri_for(endpoint, node)) do |f|
73-
f.options.timeout = @connection_timeout_seconds
74-
f.options.open_timeout = @connection_timeout_seconds
75-
end
81+
conn, request_path = connection_and_path_for(endpoint, node)
7682

7783
headers = default_headers.merge(additional_headers)
7884

79-
response = conn.send(method) do |req|
85+
response = conn.send(method, request_path) do |req|
8086
req.headers = headers
8187
req.params = query_parameters unless query_parameters.nil?
8288
unless body_parameters.nil?
@@ -108,6 +114,9 @@ def perform_request(method, endpoint, query_parameters: nil, body_parameters: ni
108114
# Rescue network layer exceptions and HTTP 5xx errors, so the loop can continue.
109115
# Using loops for retries instead of rescue...retry to maintain consistency with client libraries in
110116
# other languages that might not support the same construct.
117+
# Drop the cached keep-alive connection (if any): the underlying socket is likely
118+
# half-closed and reusing it would just fail again on retry.
119+
discard_connection(node) if @keep_alive_connections
111120
set_node_healthcheck(node, is_healthy: false)
112121
last_exception = e
113122
@logger.warn "Request #{method}:#{uri_for(endpoint, node)} to Node #{node[:index]} failed due to \"#{e.class}: #{e.message}\""
@@ -125,6 +134,57 @@ def uri_for(endpoint, node)
125134
"#{node[:protocol]}://#{node[:host]}:#{node[:port]}#{endpoint}"
126135
end
127136

137+
# Returns [connection, request_path]. When keep-alive is enabled, the connection
138+
# is cached per (thread, node) and the path is appended at request time. When it
139+
# is disabled, the original behaviour is preserved: a fresh Faraday is built for
140+
# the full per-request URL, so existing callers and stubs see no change.
141+
def connection_and_path_for(endpoint, node)
142+
if @keep_alive_connections
143+
[connection_for(node), endpoint]
144+
else
145+
[build_one_shot_connection(endpoint, node), nil]
146+
end
147+
end
148+
149+
def build_one_shot_connection(endpoint, node)
150+
Faraday.new(uri_for(endpoint, node)) do |f|
151+
f.options.timeout = @connection_timeout_seconds
152+
f.options.open_timeout = @connection_timeout_seconds
153+
end
154+
end
155+
156+
# Net::HTTP is not thread-safe, so connections are cached per (thread, node)
157+
# rather than shared across threads.
158+
def connection_for(node)
159+
thread_connections[connection_key(node)] ||= build_keep_alive_connection(node)
160+
end
161+
162+
def discard_connection(node)
163+
conn = thread_connections.delete(connection_key(node))
164+
conn&.close if conn.respond_to?(:close)
165+
end
166+
167+
def thread_connections
168+
Thread.current[@thread_connections_key] ||= {}
169+
end
170+
171+
def connection_key(node)
172+
"#{node[:protocol]}://#{node[:host]}:#{node[:port]}"
173+
end
174+
175+
def build_keep_alive_connection(node)
176+
Faraday.new(url: connection_key(node)) do |f|
177+
f.options.timeout = @connection_timeout_seconds
178+
f.options.open_timeout = @connection_timeout_seconds
179+
# pool_size defaults to 1: we already cache one Faraday connection per
180+
# (thread, node), and Net::HTTP is not thread-safe — so a single socket
181+
# per pool is the safe default. Override only with a specific reason.
182+
f.adapter :net_http_persistent, pool_size: @keep_alive_pool_size do |http|
183+
http.idle_timeout = @keep_alive_idle_timeout_seconds
184+
end
185+
end
186+
end
187+
128188
## Attempts to find the next healthy node, looping through the list of nodes once.
129189
# But if no healthy nodes are found, it will just return the next node, even if it's unhealthy
130190
# so we can try the request for good measure, in case that node has become healthy since

lib/typesense/configuration.rb

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
module Typesense
66
class Configuration
7-
attr_accessor :nodes, :nearest_node, :connection_timeout_seconds, :healthcheck_interval_seconds, :num_retries, :retry_interval_seconds, :api_key, :logger, :log_level
7+
attr_accessor :nodes, :nearest_node, :connection_timeout_seconds, :healthcheck_interval_seconds, :num_retries, :retry_interval_seconds, :api_key, :logger, :log_level, :keep_alive_connections, :keep_alive_idle_timeout_seconds, :keep_alive_pool_size
88

99
def initialize(options = {})
1010
@nodes = options[:nodes] || []
@@ -14,23 +14,28 @@ def initialize(options = {})
1414
@num_retries = options[:num_retries] || (@nodes.length + (@nearest_node.nil? ? 0 : 1)) || 3
1515
@retry_interval_seconds = options[:retry_interval_seconds] || 0.1
1616
@api_key = options[:api_key]
17+
@keep_alive_connections = options.fetch(:keep_alive_connections, false)
18+
@keep_alive_idle_timeout_seconds = options[:keep_alive_idle_timeout_seconds] || 30
19+
@keep_alive_pool_size = options[:keep_alive_pool_size] || 1
1720

1821
@logger = options[:logger] || Logger.new($stdout)
1922
@log_level = options[:log_level] || Logger::WARN
2023
@logger.level = @log_level
2124

2225
show_deprecation_warnings(options)
23-
validate!
26+
validate!(options)
2427
end
2528

26-
def validate!
29+
def validate!(options = {})
2730
if @nodes.nil? ||
2831
@nodes.empty? ||
2932
@nodes.any? { |node| node_missing_parameters?(node) }
3033
raise Error::MissingConfiguration, 'Missing required configuration. Ensure that nodes[][:protocol], nodes[][:host] and nodes[][:port] are set.'
3134
end
3235

3336
raise Error::MissingConfiguration, 'Missing required configuration. Ensure that api_key is set.' if @api_key.nil?
37+
38+
validate_keep_alive_options!(options)
3439
end
3540

3641
private
@@ -39,6 +44,15 @@ def node_missing_parameters?(node)
3944
%i[protocol host port].any? { |attr| node.send(:[], attr).nil? }
4045
end
4146

47+
def validate_keep_alive_options!(options)
48+
return if @keep_alive_connections
49+
50+
dependent_keys = %i[keep_alive_idle_timeout_seconds keep_alive_pool_size].select { |k| options.key?(k) }
51+
return if dependent_keys.empty?
52+
53+
raise Error::MissingConfiguration, "#{dependent_keys.join(' and ')} require keep_alive_connections: true."
54+
end
55+
4256
def show_deprecation_warnings(options)
4357
@logger.warn 'Deprecation warning: timeout_seconds is now renamed to connection_timeout_seconds' unless options[:timeout_seconds].nil?
4458
@logger.warn 'Deprecation warning: master_node is now consolidated to nodes, starting with Typesense Server v0.12' unless options[:master_node].nil?

spec/typesense/api_call_spec.rb

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,4 +258,158 @@
258258
it_behaves_like 'General error handling', :delete
259259
it_behaves_like 'Node selection', :delete
260260
end
261+
262+
describe 'keep-alive connection caching' do
263+
subject(:api_call) { described_class.new(keep_alive_typesense.configuration) }
264+
265+
let(:keep_alive_typesense) do
266+
Typesense::Client.new(
267+
api_key: 'abcd',
268+
nodes: typesense.configuration.nodes,
269+
connection_timeout_seconds: 10,
270+
retry_interval_seconds: 0.01,
271+
log_level: Logger::ERROR,
272+
keep_alive_connections: true
273+
)
274+
end
275+
276+
let(:node) { keep_alive_typesense.configuration.nodes[0] }
277+
278+
before do
279+
keep_alive_typesense.configuration.nodes.each do |n|
280+
stub_request(:any, api_call.send(:uri_for, '/', n))
281+
.to_return(status: 200, body: JSON.dump('ok' => true), headers: { 'Content-Type' => 'application/json' })
282+
end
283+
end
284+
285+
it 'reuses the same Faraday connection across calls to the same node on the same thread' do
286+
first = api_call.send(:connection_for, node)
287+
second = api_call.send(:connection_for, node)
288+
289+
expect(second).to be(first)
290+
end
291+
292+
it 'caches connections separately per node' do
293+
first_node_conn = api_call.send(:connection_for, keep_alive_typesense.configuration.nodes[0])
294+
second_node_conn = api_call.send(:connection_for, keep_alive_typesense.configuration.nodes[1])
295+
296+
expect(second_node_conn).not_to be(first_node_conn)
297+
end
298+
299+
it 'isolates the cache per thread' do
300+
main_thread_conn = api_call.send(:connection_for, node)
301+
302+
other_thread_conn = Thread.new { api_call.send(:connection_for, node) }.value
303+
304+
expect(other_thread_conn).not_to be(main_thread_conn)
305+
end
306+
307+
it 'isolates the cache per ApiCall instance' do
308+
other_api_call = described_class.new(keep_alive_typesense.configuration)
309+
310+
expect(other_api_call.send(:connection_for, node))
311+
.not_to be(api_call.send(:connection_for, node))
312+
end
313+
314+
it 'evicts the cached connection when a network error occurs so retries open a fresh socket' do
315+
timeout_node = keep_alive_typesense.configuration.nodes[0]
316+
keep_alive_typesense.configuration.nodes.each do |n|
317+
stub_request(:any, api_call.send(:uri_for, '/', n)).to_timeout
318+
end
319+
320+
pre_call_conn = api_call.send(:connection_for, timeout_node)
321+
322+
begin
323+
api_call.get('/')
324+
rescue StandardError
325+
# expected: all nodes time out
326+
end
327+
328+
cache = Thread.current[api_call.instance_variable_get(:@thread_connections_key)] || {}
329+
expect(cache[api_call.send(:connection_key, timeout_node)]).to be_nil
330+
331+
post_retry_conn = api_call.send(:connection_for, timeout_node)
332+
expect(post_retry_conn).not_to be(pre_call_conn)
333+
end
334+
335+
it 'uses the configured timeouts on the cached connection' do
336+
conn = api_call.send(:connection_for, node)
337+
338+
expect(conn.options.timeout).to eq(keep_alive_typesense.configuration.connection_timeout_seconds)
339+
expect(conn.options.open_timeout).to eq(keep_alive_typesense.configuration.connection_timeout_seconds)
340+
end
341+
342+
it 'defaults the idle timeout to 30 seconds' do
343+
expect(keep_alive_typesense.configuration.keep_alive_idle_timeout_seconds).to eq(30)
344+
end
345+
346+
it 'honours a custom keep_alive_idle_timeout_seconds' do
347+
custom_client = Typesense::Client.new(
348+
api_key: 'abcd',
349+
nodes: typesense.configuration.nodes,
350+
connection_timeout_seconds: 10,
351+
log_level: Logger::ERROR,
352+
keep_alive_connections: true,
353+
keep_alive_idle_timeout_seconds: 5
354+
)
355+
356+
expect(custom_client.configuration.keep_alive_idle_timeout_seconds).to eq(5)
357+
expect(described_class.new(custom_client.configuration).instance_variable_get(:@keep_alive_idle_timeout_seconds)).to eq(5)
358+
end
359+
360+
it 'defaults the pool size to 1' do
361+
expect(keep_alive_typesense.configuration.keep_alive_pool_size).to eq(1)
362+
end
363+
364+
it 'honours a custom keep_alive_pool_size' do
365+
custom_client = Typesense::Client.new(
366+
api_key: 'abcd',
367+
nodes: typesense.configuration.nodes,
368+
connection_timeout_seconds: 10,
369+
log_level: Logger::ERROR,
370+
keep_alive_connections: true,
371+
keep_alive_pool_size: 5
372+
)
373+
374+
expect(custom_client.configuration.keep_alive_pool_size).to eq(5)
375+
expect(described_class.new(custom_client.configuration).instance_variable_get(:@keep_alive_pool_size)).to eq(5)
376+
end
377+
end
378+
379+
describe 'keep-alive disabled (default)' do
380+
it 'is off by default on the configuration' do
381+
expect(typesense.configuration.keep_alive_connections).to be(false)
382+
end
383+
384+
it 'builds a fresh Faraday connection per request' do
385+
stub_request(:any, api_call.send(:uri_for, '/', typesense.configuration.nodes[0]))
386+
.to_return(status: 200, body: JSON.dump('ok' => true), headers: { 'Content-Type' => 'application/json' })
387+
388+
api_call.get('/')
389+
390+
expect(Thread.current[api_call.instance_variable_get(:@thread_connections_key)]).to be_nil
391+
end
392+
393+
it 'raises when keep_alive_idle_timeout_seconds is set without keep_alive_connections' do
394+
expect do
395+
Typesense::Client.new(
396+
api_key: 'abcd',
397+
nodes: typesense.configuration.nodes,
398+
log_level: Logger::ERROR,
399+
keep_alive_idle_timeout_seconds: 5
400+
)
401+
end.to raise_error(Typesense::Error::MissingConfiguration, /keep_alive_connections: true/)
402+
end
403+
404+
it 'raises when keep_alive_pool_size is set without keep_alive_connections' do
405+
expect do
406+
Typesense::Client.new(
407+
api_key: 'abcd',
408+
nodes: typesense.configuration.nodes,
409+
log_level: Logger::ERROR,
410+
keep_alive_pool_size: 4
411+
)
412+
end.to raise_error(Typesense::Error::MissingConfiguration, /keep_alive_connections: true/)
413+
end
414+
end
261415
end

typesense.gemspec

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ Gem::Specification.new do |spec|
2828

2929
spec.add_dependency 'base64', '~> 0.2'
3030
spec.add_dependency 'faraday', '~> 2.8'
31+
spec.add_dependency 'faraday-net_http_persistent', '~> 2.0'
3132
spec.add_dependency 'json', '~> 2.9'
33+
spec.add_dependency 'net-http-persistent', '~> 4.0'
3234
spec.metadata['rubygems_mfa_required'] = 'true'
3335
end

0 commit comments

Comments
 (0)