Skip to content

Commit c752c84

Browse files
committed
Merge branch 'master' into thread-safe-node-rotation
2 parents 65a826c + 58003b0 commit c752c84

12 files changed

Lines changed: 284 additions & 22 deletions

File tree

‎.github/workflows/tests.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ jobs:
1010
runs-on: ubuntu-latest
1111
strategy:
1212
matrix:
13-
ruby-version: ['3.0', '3.2', '3.3', '3.4']
13+
ruby-version: ['3.0', '3.2', '3.3', '3.4', '4.0']
1414
services:
1515
typesense:
16-
image: typesense/typesense:30.0.alpha1
16+
image: typesense/typesense:30.2
1717
ports:
1818
- 8108:8108
1919
volumes:

‎.rubocop.yml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,5 +47,13 @@ RSpec/ExampleLength:
4747
RSpec/MultipleExpectations:
4848
Enabled: false
4949

50+
RSpec/Output:
51+
Exclude:
52+
- spec/spec_helper.rb
53+
54+
Naming/PredicateMethod:
55+
Exclude:
56+
- spec/spec_helper.rb
57+
5058
Style/HashSyntax:
5159
Enabled: false # We still want to support older versions of Ruby

‎Gemfile‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,13 @@ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
66

77
# Dev dependencies
88
gem 'awesome_print', '~> 1.8'
9-
gem 'bundler', '~> 2.0'
10-
gem 'codecov', '~> 0.1'
11-
gem 'erb'
9+
gem 'bundler', '>= 2.0'
1210
gem 'guard', '~> 2.16'
1311
gem 'guard-rubocop', '~> 1.3'
1412
gem 'rake', '~> 13.0'
1513
gem 'rspec', '~> 3.9'
16-
gem 'rspec_junit_formatter', '~> 0.4'
17-
gem 'rspec-legacy_formatters', '~> 1.0' # For codecov formatter
18-
gem 'rubocop', '1.72.2'
19-
gem 'rubocop-rspec', '~> 3.6', require: false
14+
gem 'rubocop', '~> 1.86'
15+
gem 'rubocop-rspec', '~> 3.9', require: false
2016
gem 'simplecov', '~> 0.18'
2117
gem 'timecop', '~> 0.9'
2218
gem 'webmock', '~> 3.8'

‎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 |

‎docker-compose.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
services:
22
typesense:
3-
image: typesense/typesense:29.0
3+
image: typesense/typesense:30.2
44
ports:
55
- "127.0.0.1:8108:8108"
66
volumes:

‎lib/typesense/api_call.rb‎

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,20 @@ 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

2528
@nodes_mutex = Mutex.new
29+
30+
# Per-instance key for the thread-local connection cache so multiple
31+
# Typesense::Client instances in the same process do not share sockets.
32+
@thread_connections_key = :"_typesense_api_call_connections_#{object_id}"
33+
34+
require 'faraday/net_http_persistent' if @keep_alive_connections
35+
2636
initialize_metadata_for_nodes
2737
@current_node_index = -1
2838
end
@@ -70,14 +80,11 @@ def perform_request(method, endpoint, query_parameters: nil, body_parameters: ni
7080
@logger.debug "Attempting #{method.to_s.upcase} request Try ##{num_tries} to Node #{node[:index]}"
7181

7282
begin
73-
conn = Faraday.new(uri_for(endpoint, node)) do |f|
74-
f.options.timeout = @connection_timeout_seconds
75-
f.options.open_timeout = @connection_timeout_seconds
76-
end
83+
conn, request_path = connection_and_path_for(endpoint, node)
7784

7885
headers = default_headers.merge(additional_headers)
7986

80-
response = conn.send(method) do |req|
87+
response = conn.send(method, request_path) do |req|
8188
req.headers = headers
8289
req.params = query_parameters unless query_parameters.nil?
8390
unless body_parameters.nil?
@@ -109,6 +116,9 @@ def perform_request(method, endpoint, query_parameters: nil, body_parameters: ni
109116
# Rescue network layer exceptions and HTTP 5xx errors, so the loop can continue.
110117
# Using loops for retries instead of rescue...retry to maintain consistency with client libraries in
111118
# other languages that might not support the same construct.
119+
# Drop the cached keep-alive connection (if any): the underlying socket is likely
120+
# half-closed and reusing it would just fail again on retry.
121+
discard_connection(node) if @keep_alive_connections
112122
set_node_healthcheck(node, is_healthy: false)
113123
last_exception = e
114124
@logger.warn "Request #{method}:#{uri_for(endpoint, node)} to Node #{node[:index]} failed due to \"#{e.class}: #{e.message}\""
@@ -126,6 +136,57 @@ def uri_for(endpoint, node)
126136
"#{node[:protocol]}://#{node[:host]}:#{node[:port]}#{endpoint}"
127137
end
128138

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

0 commit comments

Comments
 (0)