This is an automated email from the ASF dual-hosted git repository.
bneradt pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficserver.git
The following commit(s) were added to refs/heads/master by this push:
new d0119c4647 Cache empty chunked responses (#13410)
d0119c4647 is described below
commit d0119c46475e4d846e86b5f543baecaa9e7a67f9
Author: Brian Neradt <[email protected]>
AuthorDate: Tue Aug 4 14:32:11 2026 -0500
Cache empty chunked responses (#13410)
Empty chunked responses can complete without starting a cache write VIO,
or can start with an unknown length that is later finalized at zero.
ATS treats both as empty unwritten entries and sends later requests back
to origin. AuTests can also cross a log-rolling boundary before checking
custom logs, producing an unrelated intermittent failure.
This patch starts zero-byte cache writes and recognizes successfully
closed write VIOs whose final length is zero. This preserves the
empty-document state while keeping header-only cache updates distinct.
This also disables log rolling for stale-response log assertions and
covers negative and successful empty responses.
Fixes: #11313
---
src/iocore/cache/CacheVC.cc | 19 +++++++-
src/iocore/cache/P_CacheInternal.h | 1 +
src/proxy/http/HttpTunnel.cc | 14 ++++--
...negative-caching-300-second-timeout.replay.yaml | 51 +++++++++++++++++++++-
.../stale_response/stale_response.test.py | 2 +
5 files changed, 82 insertions(+), 5 deletions(-)
diff --git a/src/iocore/cache/CacheVC.cc b/src/iocore/cache/CacheVC.cc
index 83bc9ea8d6..b7443703b1 100644
--- a/src/iocore/cache/CacheVC.cc
+++ b/src/iocore/cache/CacheVC.cc
@@ -213,6 +213,14 @@ CacheVC::do_io_write(Continuation *c, int64_t nbytes,
IOBufferReader *abuf, bool
#ifdef DEBUG
ink_assert(!c || c->mutex->thread_holding);
#endif
+ if (nbytes == 0) {
+ // A zero-byte write represents an empty document, while closing without a
+ // write represents a header-only update.
+ f.allow_empty_doc = 1;
+ if (alternate.valid()) {
+ alternate.object_size_set(0);
+ }
+ }
if (c && !trigger && !recursive) {
trigger = c->mutex->thread_holding->schedule_imm_local(this);
}
@@ -223,6 +231,14 @@ void
CacheVC::do_io_close(int alerrno)
{
ink_assert(mutex->thread_holding == this_ethread());
+ if (alerrno == -1 && vio.op == VIO::WRITE && vio.get_reader() != nullptr &&
vio.nbytes == 0) {
+ // The write may have started with an unknown length and been finalized
+ // at zero after the response framing was parsed.
+ f.allow_empty_doc = 1;
+ if (alternate.valid()) {
+ alternate.object_size_set(0);
+ }
+ }
int previous_closed = closed;
closed = (alerrno == -1) ? 1 : -1; // Stupid default arguments
DDbg(dbg_ctl_cache_close, "do_io_close %p %d %d", this, alerrno, closed);
@@ -1063,7 +1079,8 @@ CacheVC::set_http_info(CacheHTTPInfo *ainfo)
}
MIMEField *field =
ainfo->m_alt->m_response_hdr.field_find(static_cast<std::string_view>(MIME_FIELD_CONTENT_LENGTH));
- if ((field && !field->value_get_int64()) ||
ainfo->m_alt->m_response_hdr.status_get() == HTTPStatus::NO_CONTENT) {
+ if ((field && !field->value_get_int64()) ||
ainfo->m_alt->m_response_hdr.status_get() == HTTPStatus::NO_CONTENT ||
+ (f.allow_empty_doc && vio.nbytes == 0)) {
f.allow_empty_doc = 1;
// Set the object size here to zero in case this is a cache replace where
the new object
// length is zero but the old object was not.
diff --git a/src/iocore/cache/P_CacheInternal.h
b/src/iocore/cache/P_CacheInternal.h
index c030fe211d..f831ed5158 100644
--- a/src/iocore/cache/P_CacheInternal.h
+++ b/src/iocore/cache/P_CacheInternal.h
@@ -323,6 +323,7 @@ CacheVC::die()
{
if (vio.op == VIO::WRITE) {
if (f.update && total_len) {
+ ink_assert(alternate.valid());
alternate.object_key_set(earliest_key);
}
if (!is_io_in_progress()) {
diff --git a/src/proxy/http/HttpTunnel.cc b/src/proxy/http/HttpTunnel.cc
index 2f0b0afcc0..1aba464157 100644
--- a/src/proxy/http/HttpTunnel.cc
+++ b/src/proxy/http/HttpTunnel.cc
@@ -1212,9 +1212,17 @@ HttpTunnel::producer_run(HttpTunnelProducer *p)
}
if (c_write == 0) {
- // Nothing to do, call back the cleanup handlers
- c->write_vio = nullptr;
- consumer_handler(VC_EVENT_WRITE_COMPLETE, c);
+ // Cache writes need a VIO even when the body is empty so that closing
the
+ // cache VC commits the response metadata instead of aborting the write.
+ if (c->vc_type == HttpTunnelType_t::CACHE_WRITE) {
+ c->write_vio = c->vc->do_io_write(this, 0, c->buffer_reader);
+ if (c->write_vio == nullptr) {
+ consumer_handler(VC_EVENT_ERROR, c);
+ }
+ } else {
+ c->write_vio = nullptr;
+ consumer_handler(VC_EVENT_WRITE_COMPLETE, c);
+ }
} else {
// In the client half close case, all the data that will be sent
// from the client is already in the buffer. Go ahead and set
diff --git
a/tests/gold_tests/cache/replay/negative-caching-300-second-timeout.replay.yaml
b/tests/gold_tests/cache/replay/negative-caching-300-second-timeout.replay.yaml
index 53c7d58b16..f7774728f7 100644
---
a/tests/gold_tests/cache/replay/negative-caching-300-second-timeout.replay.yaml
+++
b/tests/gold_tests/cache/replay/negative-caching-300-second-timeout.replay.yaml
@@ -40,6 +40,18 @@ meta:
# transaction.
delay: 100ms
+ - request_200_item: &request_200_item
+ client-request:
+ method: "GET"
+ version: "1.1"
+ scheme: "http"
+ url: /path/200_empty_chunked
+ headers:
+ fields:
+ - [ Host, example.com ]
+
+ delay: 100ms
+
sessions:
- transactions:
@@ -47,13 +59,16 @@ sessions:
<<: *request_404_item
# Populate the cache with a 404 response.
+ # Verify that an empty chunked response is cached (issue #11313).
server-response:
status: 404
reason: "Not Found"
headers:
fields:
- - [ Content-Length, 32 ]
+ - [ Transfer-Encoding, chunked ]
- [ Cache-Control, max-age=300 ]
+ content:
+ size: 0
proxy-response:
status: 404
@@ -77,3 +92,37 @@ sessions:
# Expect the cached 404 response.
proxy-response:
status: 404
+
+ - all: { headers: { fields: [[ uuid, 23 ]]}}
+ <<: *request_200_item
+
+ # The empty chunked-body behavior is not specific to negative responses.
+ server-response:
+ status: 200
+ reason: OK
+ headers:
+ fields:
+ - [ Transfer-Encoding, chunked ]
+ - [ Cache-Control, max-age=300 ]
+ content:
+ size: 0
+
+ proxy-response:
+ status: 200
+
+ - all: { headers: { fields: [[ uuid, 24 ]]}}
+ <<: *request_200_item
+
+ proxy-request:
+ expect: absent
+
+ server-response:
+ status: 502
+ reason: Bad Gateway
+ headers:
+ fields:
+ - [ Content-Length, 0 ]
+
+ # Expect the cached 200 response.
+ proxy-response:
+ status: 200
diff --git a/tests/gold_tests/pluginTest/stale_response/stale_response.test.py
b/tests/gold_tests/pluginTest/stale_response/stale_response.test.py
index fb72c45723..246b55dacc 100644
--- a/tests/gold_tests/pluginTest/stale_response/stale_response.test.py
+++ b/tests/gold_tests/pluginTest/stale_response/stale_response.test.py
@@ -134,6 +134,8 @@ class TestStaleResponse:
"proxy.config.http.server_session_sharing.pool": "global",
# Turn off negative revalidating so that we can test
stale-if-error.
"proxy.config.http.negative_revalidating_enabled": 0,
+ # Keep the active log filename available for the final content
check if the test spans UTC midnight.
+ "proxy.config.log.rolling_enabled": 0,
})
ts.Disk.remap_config.AddLine(f"map /
http://127.0.0.1:{self._server.Variables.http_port}/ {remap_plugin_config}")