You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@thrift.apache.org by "Dmytro Shteflyuk (Jira)" <ji...@apache.org> on 2026/10/16 21:30:00 UTC
[jira] [Created] (THRIFT-6431) Amortize Ruby MemoryBuffer compaction and release exhausted storage
Dmytro Shteflyuk created THRIFT-6431:
----------------------------------------
Summary: Amortize Ruby MemoryBuffer compaction and release exhausted storage
Key: THRIFT-6431
URL: https://issues.apache.org/jira/browse/THRIFT-6431
Project: Thrift
Issue Type: Bug
Components: Ruby - Library
Reporter: Dmytro Shteflyuk
Assignee: Dmytro Shteflyuk
h3. Problem
MemoryBufferTransport compacts its backing String after every 4 KiB consumed, even when a much larger unread backlog remains. Repeated small reads and appends therefore repeatedly replace the entire unread backlog.
Exhaustion below that threshold leaves the consumed String attached to the transport. On CRuby, a small shared suffix can keep a much larger backing allocation alive after all input has been consumed. Both the pure Ruby implementation and native extension use this compaction policy.
h3. Client impact
Long-lived memory transports can retain storage from previously consumed input. Rolling workloads with a large unread backlog also incur avoidable String allocation and copying.
h3. Reproduction
Save the following as reproduce_memory_buffer.rb and run it from lib/rb with:
{code}
bundle exec ruby -Ilib reproduce_memory_buffer.rb
{code}
This standalone diagnostic uses CRuby's objspace library:
{code:ruby}
$LOADED_FEATURES << "thrift_native.rb" # Exercise the pure Ruby implementation.
require "thrift"
require "objspace"
GC.start
before = ObjectSpace.count_objects_size[:T_STRING]
buffer = Thread.new do
transport = Thrift::MemoryBufferTransport.new("x" * (16 * 1024 * 1024 + 1024))
transport.read(16 * 1024 * 1024)
transport.read_all(1024)
transport
end.value
GC.start
puts "available=#{buffer.available} stored_bytes=#{buffer.instance_variable_get(:@buf).bytesize}"
puts "retained_string_bytes=#{ObjectSpace.count_objects_size[:T_STRING] - before}"
buffer = Thrift::MemoryBufferTransport.new("a" * 65_536)
chunk = "b" * 4096
replacement_lengths = 0
40.times do
buffer.write(chunk)
previous = buffer.instance_variable_get(:@buf)
buffer.read_all(chunk.bytesize)
current = buffer.instance_variable_get(:@buf)
replacement_lengths += current.bytesize unless current.equal?(previous)
end
puts "consumed_bytes=#{40 * chunk.bytesize} replacement_lengths=#{replacement_lengths}"
{code}
Testing on master commit {{7941f5426deb5eca8d8be0cdb0aed4312914d71a}} produces:
{code}
available=0 stored_bytes=1024
retained_string_bytes=16779217
consumed_bytes=163840 replacement_lengths=2621440
{code}
The String footprint includes diagnostic overhead and varies by Ruby version. Here an exhausted transport retains approximately 16 MiB. The rolling case replaces Strings totaling 2.5 MiB in logical length while consuming only 160 KiB; replacement_lengths measures those lengths, not physical allocations.
h3. Expected behavior
Release the transport's reference to exhausted storage, including shared backing storage. For nonempty buffers, compact only after consuming at least 4 KiB and at least as many bytes as remain unread, copying the suffix into independent storage.
Preserve returned bytes, caller-owned Strings, and existing read/error behavior. Keep ordinary byte reads inexpensive by caching the next compaction boundary in the pure Ruby implementation.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)