Skip to content

Commit e3f46f0

Browse files
authored
Chewy.close_client closes Elasticsearch connections (#1036)
The per-thread Chewy.client keeps its connections open until the GC reclaims the dead thread's client, leaking file descriptors in long-lived multi-threaded processes (e.g. Sidekiq, which churns threads). Neither elasticsearch-ruby nor elastic-transport expose a way to close connections, so close them via Faraday and drop the thread-local. Refs #951.
1 parent ca4f6f5 commit e3f46f0

6 files changed

Lines changed: 97 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
### New Features
66

7+
* [#1036](https://github.com/toptal/chewy/issues/1036): Add `Chewy.close_client` and `Chewy::ElasticClient#close` to explicitly close connections to Elasticsearch, avoiding file descriptor leaks in long-lived multi-threaded processes (e.g. Sidekiq). ([@AlfonsoUceda][])
8+
79
### Bug Fixes
810

911
### Changes
@@ -898,6 +900,7 @@
898900
[@AgeevAndrew]: https://github.com/AgeevAndrew
899901
[@aglushkov]: https://github.com/aglushkov
900902
[@AlexVPopov]: https://github.com/AlexVPopov
903+
[@AlfonsoUceda]: https://github.com/AlfonsoUceda
901904
[@AndreySavelyev]: https://github.com/AndreySavelyev
902905
[@afg419]: https://github.com/afg419
903906
[@arion]: https://github.com/arion

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,23 @@ development:
133133
ca_file: './tmp/http_ca.crt'
134134
```
135135
136+
### Closing connections
137+
138+
`Chewy.client` is memoized per thread, so every thread that touches Chewy gets
139+
its own client with its own connections to Elasticsearch. Neither
140+
`elasticsearch-ruby` nor `elastic-transport` expose a public way to close those
141+
connections, so they are only released when Ruby's garbage collector reclaims
142+
the client. In long-lived, multi-threaded processes that frequently spawn and
143+
discard threads (e.g. Sidekiq, which replaces a thread on job failure), this can
144+
leak file descriptors.
145+
146+
Use `Chewy.close_client` to close the current thread's connections and drop its
147+
client. The next `Chewy.client` call rebuilds a fresh one:
148+
149+
```ruby
150+
Chewy.close_client
151+
```
152+
136153
### Index
137154

138155
Create `app/chewy/users_index.rb` with User Index:

lib/chewy.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,21 @@ def client
102102
Chewy.current[:chewy_client] ||= Chewy::ElasticClient.new
103103
end
104104

105+
# Closes the current thread's client connections to Elasticsearch and
106+
# drops the thread-local client, so the next `Chewy.client` call builds a
107+
# fresh one.
108+
#
109+
# Useful in long-lived multi-threaded processes (e.g. Sidekiq) where the
110+
# per-thread client would otherwise keep its connections open until the
111+
# dead thread is garbage collected, leaking file descriptors.
112+
def close_client
113+
client = Chewy.current[:chewy_client]
114+
return unless client
115+
116+
client.close
117+
Chewy.current[:chewy_client] = nil
118+
end
119+
105120
# Sends wait_for_status request to ElasticSearch with status
106121
# defined in configuration.
107122
#

lib/chewy/elastic_client.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,21 @@ def initialize(elastic_client = self.class.build_es_client)
1212
@elastic_client = elastic_client
1313
end
1414

15+
# Closes the underlying connections to Elasticsearch.
16+
#
17+
# Neither elasticsearch-ruby nor elastic-transport expose a public method
18+
# to close connections, so they are only released when Ruby's garbage
19+
# collector reclaims the client instance. This reaches down to the Faraday
20+
# connection of every transport connection and closes it explicitly, which
21+
# is useful to avoid file descriptor leaks in long-lived processes that
22+
# build a client per thread (e.g. Sidekiq workers).
23+
def close
24+
@elastic_client.transport.connections.each do |connection|
25+
faraday = connection.connection
26+
faraday.close if faraday.respond_to?(:close)
27+
end
28+
end
29+
1530
private
1631

1732
def method_missing(name, *args, **kwargs, &block)

spec/chewy/elastic_client_spec.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,24 @@
2323
Chewy.client.search({index: ['products'], body: {size: 0}}).to_a
2424
end
2525
end
26+
27+
describe '#close' do
28+
let(:faraday_connection) { double(:faraday_connection) }
29+
let(:connection) { double(:connection, connection: faraday_connection) }
30+
let(:transport) { double(:transport, connections: [connection]) }
31+
let(:elastic_client) { double(:elastic_client, transport: transport) }
32+
let(:client) { described_class.new(elastic_client) }
33+
34+
it 'closes every underlying Faraday connection' do
35+
allow(faraday_connection).to receive(:respond_to?).with(:close).and_return(true)
36+
expect(faraday_connection).to receive(:close)
37+
client.close
38+
end
39+
40+
it 'skips connections that do not support close' do
41+
allow(faraday_connection).to receive(:respond_to?).with(:close).and_return(false)
42+
expect(faraday_connection).not_to receive(:close)
43+
expect { client.close }.not_to raise_error
44+
end
45+
end
2646
end

spec/chewy_spec.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,33 @@
5858
after { Chewy.current[:chewy_client] = initial_client }
5959
end
6060

61+
describe '.close_client' do
62+
let!(:initial_client) { Chewy.current[:chewy_client] }
63+
64+
after { Chewy.current[:chewy_client] = initial_client }
65+
66+
context 'when a client exists for the current thread' do
67+
let(:client) { instance_double(Chewy::ElasticClient) }
68+
69+
before { Chewy.current[:chewy_client] = client }
70+
71+
specify 'closes the client and clears the thread-local' do
72+
expect(client).to receive(:close)
73+
Chewy.close_client
74+
expect(Chewy.current[:chewy_client]).to be_nil
75+
end
76+
end
77+
78+
context 'when no client exists for the current thread' do
79+
before { Chewy.current[:chewy_client] = nil }
80+
81+
specify 'does nothing' do
82+
expect { Chewy.close_client }.not_to raise_error
83+
expect(Chewy.current[:chewy_client]).to be_nil
84+
end
85+
end
86+
end
87+
6188
describe '.create_indices' do
6289
before do
6390
stub_index(:cities)

0 commit comments

Comments
 (0)