Skip to content

Commit b4cb6cb

Browse files
committed
fix(core): large response body logging causes memory growth
hold_body_chunk accumulated every response body chunk into a Lua table and, once the size limit was reached or on eof, concatenated the whole table into a single string with table.concat. For large bodies (the issue reports ~128K), this keeps both the list of chunks and the final concatenated string alive at the same time, and every concat allocates a fresh string, causing excessive memory allocation/retention. Rewrite the accumulation to use a LuaJIT string.buffer (require("string.buffer")): chunks are appended with buffer:put and the final body is produced with buffer:get, which avoids holding the chunk table plus an intermediate concatenated copy. For the truncation path buffer:get(max_resp_body_bytes) returns exactly the first max_resp_body_bytes bytes; the buffer is then dropped once `done` is set. All existing semantics are preserved: the hold_the_copy flag, max_resp_body_bytes truncation, the done flag (later chunks/eof return nil after truncation), the single-chunk eof path and the per-buffer-key storage. Add t/core/response-hold-body.t covering multi-chunk accumulation, byte truncation across chunks, the done flag, the single-chunk eof case and small-body passthrough. Fixes #11244
1 parent b79329d commit b4cb6cb

2 files changed

Lines changed: 275 additions & 12 deletions

File tree

apisix/core/response.lua

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,11 @@ local error = error
3535
local select = select
3636
local type = type
3737
local ngx_exit = ngx.exit
38-
local concat_tab = table.concat
3938
local str_sub = string.sub
4039
local tonumber = tonumber
4140
local clear_tab = require("table.clear")
4241
local pairs = pairs
42+
local str_buffer = require("string.buffer")
4343
local ngx_var = ngx.var
4444
local table = require("apisix.core.table")
4545

@@ -348,21 +348,23 @@ function _M.hold_body_chunk(ctx, hold_the_copy, max_resp_body_bytes, body_buffer
348348

349349
if not body_buffer then
350350
body_buffer = {
351-
chunk,
352-
n = 1,
353-
bytes = #chunk,
351+
buf = str_buffer.new(),
352+
bytes = 0,
354353
}
355354
ctx._body_buffer[buffer_key] = body_buffer
356-
else
357-
local n = body_buffer.n + 1
358-
body_buffer.n = n
359-
body_buffer[n] = chunk
360-
body_buffer.bytes = body_buffer.bytes + #chunk
361355
end
356+
-- Accumulate chunks into a LuaJIT string buffer instead of a Lua
357+
-- table of chunks. This avoids retaining every chunk plus the final
358+
-- concatenated string at the same time, which previously caused
359+
-- excessive memory growth for large response bodies (e.g. ~128K).
360+
body_buffer.buf:put(chunk)
361+
body_buffer.bytes = body_buffer.bytes + #chunk
362362

363363
if max_resp_body_bytes and body_buffer.bytes >= max_resp_body_bytes then
364-
local body_data = concat_tab(body_buffer, "", 1, body_buffer.n)
365-
body_data = str_sub(body_data, 1, max_resp_body_bytes)
364+
-- get() consumes the first max_resp_body_bytes bytes from the
365+
-- buffer; the remaining bytes are dropped together with the buffer
366+
-- once `done` is set, so no further accumulation happens.
367+
local body_data = body_buffer.buf:get(max_resp_body_bytes)
366368
body_buffer.done = true
367369
return body_data
368370
end
@@ -380,7 +382,7 @@ function _M.hold_body_chunk(ctx, hold_the_copy, max_resp_body_bytes, body_buffer
380382
return nil
381383
end
382384

383-
local body_data = concat_tab(body_buffer, "", 1, body_buffer.n)
385+
local body_data = body_buffer.buf:get()
384386
ctx._body_buffer[buffer_key] = nil
385387
return body_data
386388
end

t/core/response-hold-body.t

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one or more
3+
# contributor license agreements. See the NOTICE file distributed with
4+
# this work for additional information regarding copyright ownership.
5+
# The ASF licenses this file to You under the Apache License, Version 2.0
6+
# (the "License"); you may not use this file except in compliance with
7+
# the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
use t::APISIX 'no_plan';
18+
19+
repeat_each(1);
20+
no_long_string();
21+
no_root_location();
22+
log_level("info");
23+
24+
run_tests;
25+
26+
__DATA__
27+
28+
=== TEST 1: hold_body_chunk accumulates a large multi-chunk body (string.buffer)
29+
--- config
30+
location = /t {
31+
content_by_lua_block {
32+
local core = require("apisix.core")
33+
local ctx = {_plugin_name = "test"}
34+
35+
-- Drive the body_filter chunks through a mocked ngx.arg, the same
36+
-- way ngx feeds body_filter_by_lua: N data chunks then an eof marker
37+
-- where ngx.arg[1] == "".
38+
local t = ngx.arg
39+
local metatable = getmetatable(t)
40+
local chunk = string.rep("a", 16 * 1024)
41+
local total_chunks = 8
42+
local idx_call = 0
43+
setmetatable(t, {__index = function(_, idx)
44+
local step = math.floor(idx_call / 2) -- which filter call
45+
local which = idx_call % 2 -- 0 -> arg[1], 1 -> arg[2]
46+
if idx == 1 then
47+
idx_call = idx_call + 1
48+
if step < total_chunks then
49+
return chunk
50+
end
51+
return "" -- eof marker chunk
52+
end
53+
-- idx == 2 (eof flag)
54+
idx_call = idx_call + 1
55+
return step >= total_chunks
56+
end, __newindex = metatable.__newindex})
57+
58+
local final_body
59+
for i = 1, total_chunks + 1 do
60+
local res = core.response.hold_body_chunk(ctx, true, nil)
61+
if res then
62+
final_body = res
63+
end
64+
end
65+
setmetatable(t, metatable)
66+
67+
ngx.say("len: ", final_body and #final_body or "nil")
68+
ngx.say("ok: ", final_body == string.rep("a", 16 * 1024 * total_chunks))
69+
}
70+
}
71+
--- request
72+
GET /t
73+
--- response_body
74+
len: 131072
75+
ok: true
76+
77+
78+
79+
=== TEST 2: hold_body_chunk truncates at max_resp_body_bytes across chunks
80+
--- config
81+
location = /t {
82+
content_by_lua_block {
83+
local core = require("apisix.core")
84+
local ctx = {_plugin_name = "test"}
85+
86+
local t = ngx.arg
87+
local metatable = getmetatable(t)
88+
local chunk = string.rep("b", 50 * 1024)
89+
local total_chunks = 4 -- 200K total, far above the limit
90+
local max = 128 * 1024 -- 131072
91+
local idx_call = 0
92+
setmetatable(t, {__index = function(_, idx)
93+
local step = math.floor(idx_call / 2)
94+
if idx == 1 then
95+
idx_call = idx_call + 1
96+
if step < total_chunks then
97+
return chunk
98+
end
99+
return ""
100+
end
101+
idx_call = idx_call + 1
102+
return step >= total_chunks
103+
end, __newindex = metatable.__newindex})
104+
105+
local truncated
106+
for i = 1, total_chunks + 1 do
107+
local res = core.response.hold_body_chunk(ctx, true, max)
108+
if res and not truncated then
109+
truncated = res
110+
end
111+
end
112+
setmetatable(t, metatable)
113+
114+
ngx.say("len: ", truncated and #truncated or "nil")
115+
ngx.say("ok: ", truncated == string.rep("b", max))
116+
}
117+
}
118+
--- request
119+
GET /t
120+
--- response_body
121+
len: 131072
122+
ok: true
123+
124+
125+
126+
=== TEST 3: after truncation, later chunks and eof return nil (done flag)
127+
--- config
128+
location = /t {
129+
content_by_lua_block {
130+
local core = require("apisix.core")
131+
local ctx = {_plugin_name = "test"}
132+
133+
local t = ngx.arg
134+
local metatable = getmetatable(t)
135+
local chunk = string.rep("c", 100 * 1024)
136+
local max = 128 * 1024
137+
-- 4 filter calls:
138+
-- call1: 100K chunk, below max -> nil
139+
-- call2: +100K = 200K >= max -> truncated 128K
140+
-- call3: another chunk, buffer done -> nil
141+
-- call4: eof marker, buffer done -> nil
142+
local plan = {
143+
{chunk, false},
144+
{chunk, false},
145+
{chunk, false},
146+
{"", true},
147+
}
148+
local call = 0
149+
local part = 1
150+
setmetatable(t, {__index = function(_, idx)
151+
local cur = plan[call + 1]
152+
if idx == 1 then
153+
return cur[1]
154+
end
155+
-- arg[2]: advance to next call after reading eof flag
156+
local eof = cur[2]
157+
call = call + 1
158+
return eof
159+
end, __newindex = metatable.__newindex})
160+
161+
local r = {}
162+
for i = 1, 4 do
163+
r[i] = core.response.hold_body_chunk(ctx, true, max)
164+
end
165+
setmetatable(t, metatable)
166+
167+
ngx.say("r1: ", r[1] == nil)
168+
ngx.say("r2_len: ", r[2] and #r[2] or "nil")
169+
ngx.say("r2_ok: ", r[2] == string.rep("c", max))
170+
ngx.say("r3: ", r[3] == nil)
171+
ngx.say("r4: ", r[4] == nil)
172+
}
173+
}
174+
--- request
175+
GET /t
176+
--- response_body
177+
r1: true
178+
r2_len: 131072
179+
r2_ok: true
180+
r3: true
181+
r4: true
182+
183+
184+
185+
=== TEST 4: single chunk that is also eof, truncated to max_resp_body_bytes
186+
--- config
187+
location = /t {
188+
content_by_lua_block {
189+
local core = require("apisix.core")
190+
local ctx = {_plugin_name = "test"}
191+
192+
local t = ngx.arg
193+
local metatable = getmetatable(t)
194+
local chunk = string.rep("d", 200 * 1024)
195+
local max = 128 * 1024
196+
-- single body_filter call: chunk present AND eof true, body above max.
197+
-- The chunk is buffered, the byte limit is hit and a truncated body
198+
-- is returned before the eof branch runs.
199+
setmetatable(t, {__index = function(_, idx)
200+
if idx == 1 then return chunk end
201+
return true
202+
end, __newindex = metatable.__newindex})
203+
204+
local res = core.response.hold_body_chunk(ctx, true, max)
205+
setmetatable(t, metatable)
206+
207+
ngx.say("len: ", res and #res or "nil")
208+
ngx.say("ok: ", res == string.rep("d", max))
209+
}
210+
}
211+
--- request
212+
GET /t
213+
--- response_body
214+
len: 131072
215+
ok: true
216+
217+
218+
219+
=== TEST 5: small body, single chunk + eof, no max returns full body
220+
--- config
221+
location = /t {
222+
content_by_lua_block {
223+
-- Same pattern as t/core/response.t TEST 8: arg[1] is the data on the
224+
-- first call and "" on the eof call where arg[2] becomes true.
225+
local t = ngx.arg
226+
local metatable = getmetatable(t)
227+
local count = 0
228+
setmetatable(t, {__index = function(_, idx)
229+
if count == 0 then
230+
if idx == 1 then
231+
return "hello "
232+
end
233+
count = count + 1
234+
return false
235+
end
236+
if count == 1 then
237+
if idx == 1 then
238+
return "world\n"
239+
end
240+
count = count + 1
241+
return true
242+
end
243+
return metatable.__index(t, idx)
244+
end, __newindex = metatable.__newindex})
245+
246+
ngx.print("A")
247+
}
248+
body_filter_by_lua_block {
249+
local core = require("apisix.core")
250+
ngx.ctx._plugin_name = "test"
251+
local final_body = core.response.hold_body_chunk(ngx.ctx)
252+
if not final_body then
253+
return
254+
end
255+
ngx.arg[1] = final_body
256+
}
257+
}
258+
--- request
259+
GET /t
260+
--- response_body
261+
hello world

0 commit comments

Comments
 (0)