Skip to content

Commit 2386682

Browse files
RUBY-3669 Fix memory leak caused by cursors (#2995)
1 parent 604b735 commit 2386682

3 files changed

Lines changed: 67 additions & 5 deletions

File tree

lib/mongo/cluster/reapers/cursor_reaper.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,12 @@ def read_scheduled_kill_specs
116116
if @active_cursor_ids.include?(kill_spec.cursor_id)
117117
@to_kill[kill_spec.server_address] ||= Set.new
118118
@to_kill[kill_spec.server_address] << kill_spec
119+
else
120+
# Cursor was already closed; end the session immediately to release
121+
# references rather than waiting for the kill_spec to go out of scope.
122+
if (session = kill_spec.session) && session.implicit?
123+
session.end_session
124+
end
119125
end
120126
end
121127
rescue ThreadError

lib/mongo/session.rb

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,11 @@ def initialize(server_session, client, options = {})
9797
@server_session = server_session
9898
options = options.dup
9999

100-
@client = client.use(:admin)
100+
# Implicit sessions only need the cluster and client options (never run
101+
# transactions), so avoid creating a Mongo::Client clone to prevent
102+
# memory leaks: use the original client directly instead.
103+
@client = options[:implicit] ? client : client.use(:admin)
104+
@cluster = @client.cluster
101105
@options = options.dup.freeze
102106
@cluster_time = nil
103107
@state = NO_TRANSACTION_STATE
@@ -115,7 +119,7 @@ def initialize(server_session, client, options = {})
115119
attr_reader :client
116120

117121
def cluster
118-
@client.cluster
122+
@cluster
119123
end
120124

121125
# @return [ true | false ] Whether the session is configured for snapshot
@@ -384,12 +388,13 @@ def end_session
384388
end
385389
end
386390
if @server_session
387-
@client.cluster.session_pool.checkin(@server_session)
391+
cluster.session_pool.checkin(@server_session)
388392
end
389393
end
390394
ensure
391395
@server_session = nil
392396
@ended = true
397+
@client = nil
393398
end
394399

395400
# Executes the provided block in a transaction, retrying as necessary.
@@ -1099,8 +1104,8 @@ def update_state!
10991104
# @since 2.5.0
11001105
# @api private
11011106
def validate!(client)
1102-
check_matching_cluster!(client)
11031107
check_if_ended!
1108+
check_matching_cluster!(client)
11041109
self
11051110
end
11061111

@@ -1280,7 +1285,7 @@ def check_if_ended!
12801285
end
12811286

12821287
def check_matching_cluster!(client)
1283-
if @client.cluster != client.cluster
1288+
if cluster != client.cluster
12841289
raise Mongo::Error::InvalidSession.new(MISMATCHED_CLUSTER_ERROR_MSG)
12851290
end
12861291
end
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# frozen_string_literal: true
2+
# rubocop:todo all
3+
4+
require 'spec_helper'
5+
6+
describe 'Cursor memory leak - RUBY-3669' do
7+
# Regression test: when batch_size < limit, each find/each iteration
8+
# used to leak a Mongo::Client via the GC finalizer -> KillSpec -> Session
9+
# -> Session#@client chain. The fix clears @client in Session#end_session
10+
# and ensures CursorReaper discards stale KillSpecs cleanly.
11+
12+
# ObjectSpace is MRI-specific; on JRuby GC.start is not deterministic
13+
require_mri
14+
15+
let(:collection_name) { 'cursor_memory_leak_spec' }
16+
let(:collection) { authorized_client[collection_name] }
17+
18+
before do
19+
collection.delete_many
20+
collection.insert_many([{ a: 1 }, { a: 2 }, { a: 3 }])
21+
end
22+
23+
it 'does not leak Mongo::Client objects when batch_size < limit' do
24+
# Warm up: run once so any one-time initialization clients are created
25+
# before we start counting.
26+
collection.find(nil, batch_size: 2, limit: 3).each {}
27+
28+
GC.start
29+
GC.start
30+
GC.start
31+
sleep Mongo::Cluster::CursorReaper::FREQUENCY * 2 + 1
32+
33+
client_count_before = ObjectSpace.each_object(Mongo::Client).count
34+
35+
10.times do
36+
collection.find(nil, batch_size: 2, limit: 3).each {}
37+
end
38+
39+
# Give the GC and the periodic cursor reaper time to process finalizers
40+
# and discard stale KillSpecs.
41+
GC.start
42+
GC.start
43+
GC.start
44+
sleep Mongo::Cluster::CursorReaper::FREQUENCY * 2 + 1
45+
46+
client_count_after = ObjectSpace.each_object(Mongo::Client).count
47+
48+
expect(client_count_after).to be <= client_count_before,
49+
"Expected Mongo::Client count to stay at #{client_count_before} but got #{client_count_after} — possible memory leak"
50+
end
51+
end

0 commit comments

Comments
 (0)