Skip to content

Commit 8d92d7f

Browse files
kpumukcodex
andauthored
THRIFT-6431: Amortize Ruby MemoryBuffer compaction and release exhausted storage (#4020)
Client: rb Compact unread storage only after consuming at least 4 KiB and at least as many bytes as remain. Detach copied suffixes and replace exhausted storage, including after partial reads. Cache the next compaction boundary in pure Ruby to keep ordinary byte reads inexpensive. Cover rolling reads, input ownership, resets and exhaustion, and update diagnostic expectations for released storage. Co-authored-by: OpenAI Codex (gpt-6.1-sol) <codex@openai.com>
1 parent f95a929 commit 8d92d7f

3 files changed

Lines changed: 157 additions & 43 deletions

File tree

‎lib/rb/ext/memory_buffer.c‎

Lines changed: 26 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,21 @@ VALUE rb_thrift_memory_buffer_read_all(VALUE self, VALUE length_value);
4040
VALUE rb_thrift_memory_buffer_read_byte(VALUE self);
4141
VALUE rb_thrift_memory_buffer_read_into_buffer(VALUE self, VALUE buffer_value, VALUE size_value);
4242

43+
static void compact_buffer(VALUE self, VALUE buf, long index) {
44+
long remaining = RSTRING_LEN(buf) - index;
45+
if (remaining == 0) {
46+
/* Drop exhausted storage, including shared suffixes of larger strings. */
47+
rb_ivar_set(self, buf_ivar_id, rb_str_new(NULL, 0));
48+
index = 0;
49+
} else if (index >= GARBAGE_BUFFER_SIZE && index >= remaining) {
50+
/* Bound copying by bytes consumed and detach the unread suffix now. */
51+
rb_ivar_set(self, buf_ivar_id, rb_str_new(RSTRING_PTR(buf) + index, remaining));
52+
index = 0;
53+
}
54+
rb_ivar_set(self, index_ivar_id, LONG2NUM(index));
55+
RB_GC_GUARD(buf);
56+
}
57+
4358
VALUE rb_thrift_memory_buffer_write(VALUE self, VALUE str) {
4459
VALUE buf = GET_BUF(self);
4560
str = force_binary_encoding(str);
@@ -65,11 +80,7 @@ VALUE rb_thrift_memory_buffer_read(VALUE self, VALUE length_value) {
6580
} else {
6681
index += (long)length;
6782
}
68-
if (index >= GARBAGE_BUFFER_SIZE) {
69-
rb_ivar_set(self, buf_ivar_id, rb_funcall(buf, slice_method_id, 2, LONG2NUM(index), LONG2NUM(RSTRING_LEN(buf) - 1)));
70-
index = 0;
71-
}
72-
rb_ivar_set(self, index_ivar_id, LONG2NUM(index));
83+
compact_buffer(self, buf, index);
7384

7485
if (RSTRING_LEN(data) < length) {
7586
rb_raise(rb_eEOFError, "Not enough bytes remain in memory buffer");
@@ -96,73 +107,61 @@ VALUE rb_thrift_memory_buffer_read_all(VALUE self, VALUE length_value) {
96107
VALUE data = rb_str_subseq(buf, index, (long)length);
97108

98109
index += (long)length;
99-
if (index >= GARBAGE_BUFFER_SIZE) {
100-
rb_ivar_set(self, buf_ivar_id, rb_str_subseq(buf, index, RSTRING_LEN(buf) - index));
101-
index = 0;
102-
}
103-
rb_ivar_set(self, index_ivar_id, LONG2NUM(index));
110+
compact_buffer(self, buf, index);
104111

105112
return data;
106113
}
107114

108115
VALUE rb_thrift_memory_buffer_read_byte(VALUE self) {
109116
VALUE index_value = rb_ivar_get(self, index_ivar_id);
110-
int index = FIX2INT(index_value);
117+
long index = NUM2LONG(index_value);
111118

112119
VALUE buf = GET_BUF(self);
113120
if (index >= RSTRING_LEN(buf)) {
114121
rb_raise(rb_eEOFError, "Not enough bytes remain in memory buffer");
115122
}
116123
unsigned char byte = (unsigned char)RSTRING_PTR(buf)[index++];
117124

118-
if (index >= GARBAGE_BUFFER_SIZE) {
119-
rb_ivar_set(self, buf_ivar_id, rb_funcall(buf, slice_method_id, 2, INT2FIX(index), INT2FIX(RSTRING_LEN(buf) - 1)));
120-
index = 0;
121-
}
122-
rb_ivar_set(self, index_ivar_id, INT2FIX(index));
125+
compact_buffer(self, buf, index);
123126

124127
return INT2FIX(byte);
125128
}
126129

127130
VALUE rb_thrift_memory_buffer_read_into_buffer(VALUE self, VALUE buffer_value, VALUE size_value) {
128131
int i = 0;
129132
int size = FIX2INT(size_value);
130-
int index;
133+
long index;
131134
VALUE buf = GET_BUF(self);
132135

133-
index = FIX2INT(rb_ivar_get(self, index_ivar_id));
136+
index = NUM2LONG(rb_ivar_get(self, index_ivar_id));
134137
if (size > 0) {
135138
if (index >= RSTRING_LEN(buf)) {
136-
rb_ivar_set(self, index_ivar_id, INT2FIX(index));
139+
compact_buffer(self, buf, index);
137140
rb_raise(rb_eEOFError, "Not enough bytes remain in memory buffer");
138141
}
139142
StringValue(buffer_value);
140143
if (RSTRING_LEN(buffer_value) == 0) {
141-
rb_ivar_set(self, index_ivar_id, INT2FIX(index));
144+
rb_ivar_set(self, index_ivar_id, LONG2NUM(index));
142145
rb_raise(rb_eIndexError, "index 0 out of string");
143146
}
144147
rb_str_modify(buffer_value);
145148
}
146149

147150
while (i < size) {
148151
if (index >= RSTRING_LEN(buf)) {
149-
rb_ivar_set(self, index_ivar_id, INT2FIX(index));
152+
compact_buffer(self, buf, index);
150153
rb_raise(rb_eEOFError, "Not enough bytes remain in memory buffer");
151154
}
152155
if (i >= RSTRING_LEN(buffer_value)) {
153-
rb_ivar_set(self, index_ivar_id, INT2FIX(index));
156+
compact_buffer(self, buf, index);
154157
rb_raise(rb_eIndexError, "index %d out of string", i);
155158
}
156159
char byte = RSTRING_PTR(buf)[index++];
157160
((char*)RSTRING_PTR(buffer_value))[i] = byte;
158161
i++;
159162
}
160163

161-
if (index >= GARBAGE_BUFFER_SIZE) {
162-
rb_ivar_set(self, buf_ivar_id, rb_funcall(buf, slice_method_id, 2, INT2FIX(index), INT2FIX(RSTRING_LEN(buf) - 1)));
163-
index = 0;
164-
}
165-
rb_ivar_set(self, index_ivar_id, INT2FIX(index));
164+
compact_buffer(self, buf, index);
166165

167166
return INT2FIX(i);
168167
}

‎lib/rb/lib/thrift/transport/memory_buffer_transport.rb‎

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class MemoryBufferTransport < BaseTransport
2929
def initialize(buffer = nil)
3030
@buf = buffer ? Bytes.force_binary_encoding(buffer.dup) : Bytes.empty_byte_buffer
3131
@index = 0
32+
@compact_at = 0
3233
end
3334

3435
def open?
@@ -49,6 +50,7 @@ def peek
4950
def reset_buffer(new_buf = "")
5051
@buf.replace Bytes.force_binary_encoding(new_buf)
5152
@index = 0
53+
@compact_at = 0
5254
end
5355

5456
def available
@@ -61,10 +63,7 @@ def read(len)
6163
data = @buf.slice(@index, len)
6264
@index += len
6365
@index = @buf.size if @index > @buf.size
64-
if @index >= GARBAGE_BUFFER_SIZE
65-
@buf = @buf.slice(@index..-1)
66-
@index = 0
67-
end
66+
compact_buffer if @index >= @compact_at
6867
if data.size < len
6968
raise EOFError, "Not enough bytes remain in buffer"
7069
end
@@ -81,10 +80,7 @@ def read_byte
8180
raise EOFError.new("Not enough bytes remain in buffer") if @index >= @buf.size
8281
val = Bytes.get_string_byte(@buf, @index)
8382
@index += 1
84-
if @index >= GARBAGE_BUFFER_SIZE
85-
@buf = @buf.slice(@index..-1)
86-
@index = 0
87-
end
83+
compact_buffer if @index >= @compact_at
8884
val
8985
end
9086

@@ -99,11 +95,9 @@ def read_into_buffer(buffer, size)
9995
@index += 1
10096
i += 1
10197
end
102-
if @index >= GARBAGE_BUFFER_SIZE
103-
@buf = @buf.slice(@index..-1)
104-
@index = 0
105-
end
10698
i
99+
ensure
100+
compact_buffer if @index >= @compact_at
107101
end
108102

109103
def write(wbuf)
@@ -131,5 +125,26 @@ def inspect_buffer
131125
def to_s
132126
"memory"
133127
end
128+
129+
private
130+
131+
def compact_buffer
132+
remaining = @buf.bytesize - @index
133+
if remaining == 0
134+
# Replace rather than clear: even a small suffix can share a large input.
135+
@buf = Bytes.empty_byte_buffer
136+
@index = 0
137+
elsif @index >= GARBAGE_BUFFER_SIZE && @index >= remaining
138+
# Copy at most as many bytes as have been consumed since the last copy.
139+
# Appending to fresh storage also detaches a shared suffix immediately.
140+
@buf = Bytes.empty_byte_buffer << @buf.slice(@index, remaining)
141+
@index = 0
142+
end
143+
# Cache the next boundary for pure Ruby reads. Appends can only move it
144+
# forward, so an earlier cached boundary stays safe until rechecked.
145+
size = @buf.bytesize
146+
half = (size + 1) / 2
147+
@compact_at = size < GARBAGE_BUFFER_SIZE ? size : (half < GARBAGE_BUFFER_SIZE ? GARBAGE_BUFFER_SIZE : half)
148+
end
134149
end
135150
end

‎lib/rb/spec/base_transport_spec.rb‎

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,7 @@
445445
(0..bytes.length).each do |position|
446446
@buffer.reset_buffer(input)
447447
@buffer.read(position)
448-
expected = spellings.dup
448+
expected = position == bytes.length ? [] : spellings.dup
449449
expected.insert(position, ">") if position < bytes.length
450450

451451
output = @buffer.inspect_buffer
@@ -474,9 +474,9 @@
474474
@buffer.read_byte
475475
expect(@buffer.inspect_buffer).to eq("0 > f 10 ff")
476476
@buffer.read(3)
477-
expect(@buffer.inspect_buffer).to eq("0 f 10 ff")
477+
expect(@buffer.inspect_buffer).to eq("")
478478
@buffer.write([128].pack("C"))
479-
expect(@buffer.inspect_buffer).to eq("0 f 10 ff > 80")
479+
expect(@buffer.inspect_buffer).to eq("> 80")
480480
@buffer.reset_buffer([1].pack("C"))
481481
expect(@buffer.inspect_buffer).to eq("> 1")
482482
end
@@ -487,7 +487,7 @@
487487

488488
expect(@buffer.inspect_buffer).to eq("> 0 ff")
489489
expect(@buffer.read(2)).to eq([0, 255].pack("C*"))
490-
expect(@buffer.inspect_buffer).to eq("0 ff")
490+
expect(@buffer.inspect_buffer).to eq("")
491491
end
492492

493493
it "keeps dumps independent across calls and subclasses without consuming input" do
@@ -680,6 +680,106 @@
680680

681681
expect { @buffer.read_all(3_397_380_576) }.to raise_error(EOFError)
682682
end
683+
684+
{
685+
read: ->(transport, size) { transport.read(size) },
686+
read_all: ->(transport, size) { transport.read_all(size) },
687+
read_byte: ->(transport, size) { Array.new(size) { transport.read_byte }.pack("C*") },
688+
read_into_buffer: ->(transport, size) do
689+
destination = "\0".b * size
690+
transport.read_into_buffer(destination, size)
691+
destination
692+
end,
693+
}.each do |operation, consume|
694+
it "releases exhausted storage through #{operation}, including a shared suffix" do
695+
@buffer.reset_buffer(("a" * 65_536) + ("z" * 1024))
696+
@buffer.read(65_536)
697+
consume.call(@buffer, 1024)
698+
699+
expect(@buffer.available).to eq(0)
700+
expect(@buffer.instance_variable_get(:@buf)).to be_empty
701+
expect { @buffer.read_byte }.to raise_error(EOFError)
702+
expect(@buffer.read(0)).to eq("")
703+
@buffer.write("new")
704+
expect(@buffer.read_all(3)).to eq("new")
705+
end
706+
707+
it "preserves a rolling backlog through #{operation}" do
708+
backlog = 65_536
709+
chunk = "b" * 4096
710+
@buffer.reset_buffer("a" * backlog)
711+
expected = ("a" * backlog) + (chunk * 40)
712+
40.times do |iteration|
713+
@buffer.write(chunk)
714+
expect(consume.call(@buffer, chunk.bytesize)).to eq(expected.slice!(0, chunk.bytesize))
715+
expect(@buffer.available).to eq(backlog)
716+
expected_index = ((iteration + 1) * chunk.bytesize) % backlog
717+
expect(@buffer.instance_variable_get(:@index)).to eq(expected_index)
718+
end
719+
720+
expect(@buffer.read_all(backlog)).to eq("b" * backlog)
721+
end
722+
723+
it "releases exhausted storage after resetting a partially read buffer through #{operation}" do
724+
@buffer.reset_buffer("x" * 65_536)
725+
expect(consume.call(@buffer, 1)).to eq("x")
726+
@buffer.reset_buffer("abc")
727+
728+
expect(consume.call(@buffer, 3)).to eq("abc")
729+
expect(@buffer.available).to eq(0)
730+
expect(@buffer.instance_variable_get(:@buf)).to be_empty
731+
@buffer.write("new")
732+
expect(@buffer.read_all(3)).to eq("new")
733+
end
734+
735+
it "preserves a caller-owned shared suffix through #{operation}" do
736+
source = ("x" * 65_536).byteslice(-1024, 1024).freeze
737+
@buffer = Thrift::MemoryBufferTransport.new(source)
738+
consume.call(@buffer, 1024)
739+
740+
expect(source).to eq("x" * 1024)
741+
expect(@buffer.available).to eq(0)
742+
@buffer.write("new")
743+
expect(@buffer.read_all(3)).to eq("new")
744+
end
745+
end
746+
747+
it "preserves a small unread suffix across compaction and subsequent writes" do
748+
@buffer.reset_buffer(("a" * 65_536) + ("z" * 1024))
749+
expect(@buffer.read(65_536)).to eq("a" * 65_536)
750+
@buffer.write("tail")
751+
752+
expect(@buffer.available).to eq(1028)
753+
expect(@buffer.read_all(1028)).to eq(("z" * 1024) + "tail")
754+
end
755+
756+
it "preserves returned strings and caller input across compaction, exhaustion and reset" do
757+
source = (("\xff" * 32_768) + ("z" * 8192)).b.freeze
758+
@buffer.reset_buffer(source)
759+
first = @buffer.read(32_768)
760+
@buffer.write("tail")
761+
last = @buffer.read_all(@buffer.available)
762+
@buffer.reset_buffer("replacement")
763+
@buffer.write("!")
764+
765+
expect(first).to eq("\xff".b * 32_768)
766+
expect(last).to eq(("z" * 8192) + "tail")
767+
first.setbyte(0, 0)
768+
expect(source.getbyte(0)).to eq(255)
769+
expect(@buffer.read_all(@buffer.available)).to eq("replacement!")
770+
end
771+
772+
it "releases storage after a partial read exhausts input" do
773+
@buffer.reset_buffer("abc")
774+
expect { @buffer.read(4) }.to raise_error(EOFError)
775+
expect(@buffer.instance_variable_get(:@buf)).to be_empty
776+
777+
@buffer.reset_buffer("abc")
778+
destination = +"xxxx"
779+
expect { @buffer.read_into_buffer(destination, 4) }.to raise_error(EOFError)
780+
expect(destination).to eq("abcx")
781+
expect(@buffer.instance_variable_get(:@buf)).to be_empty
782+
end
683783
end
684784

685785
describe Thrift::IOStreamTransport do

0 commit comments

Comments
 (0)