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)