From 7d85020068d7a2b75cdbd4eac1c7ff730ad84a02 Mon Sep 17 00:00:00 2001 From: Masaori Koshiba Date: Wed, 13 May 2026 10:09:27 +0900 Subject: [PATCH 1/4] Fix RangeTransform on stale-revalidate --- src/proxy/Transform.cc | 8 +- src/proxy/http/HttpSM.cc | 30 +++++ .../headers/range_transform.test.py | 18 +++ .../replays/range_transform.replay.yaml | 116 ++++++++++++++++++ 4 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 tests/gold_tests/headers/range_transform.test.py create mode 100644 tests/gold_tests/headers/replays/range_transform.replay.yaml diff --git a/src/proxy/Transform.cc b/src/proxy/Transform.cc index b2b9abbb0e7..109593b70e7 100644 --- a/src/proxy/Transform.cc +++ b/src/proxy/Transform.cc @@ -808,7 +808,7 @@ RangeTransform::RangeTransform(ProxyMutex *mut, RangeRecord *ranges, int num_fie SET_HANDLER(&RangeTransform::handle_event); m_num_chars_for_cl = num_chars_for_int(m_range_content_length); - Dbg(dbg_ctl_http_trans, "RangeTransform creation finishes"); + Dbg(dbg_ctl_http_trans, "RangeTransform init: %" PRId64 "-%" PRId64 "/%" PRId64, ranges->_start, ranges->_end, content_length); } /*------------------------------------------------------------------------- @@ -925,8 +925,9 @@ RangeTransform::transform_to_range() if (toskip > 0) { reader->consume(toskip); - *done_byte += toskip; - avail = reader->read_avail(); + m_write_vio.ndone += toskip; + *done_byte += toskip; + avail = reader->read_avail(); } } @@ -939,6 +940,7 @@ RangeTransform::transform_to_range() m_output_buf->write(reader, tosend); reader->consume(tosend); + m_write_vio.ndone += tosend; m_done += tosend; *done_byte += tosend; diff --git a/src/proxy/http/HttpSM.cc b/src/proxy/http/HttpSM.cc index 12af2570b2f..4bfc7d9a670 100644 --- a/src/proxy/http/HttpSM.cc +++ b/src/proxy/http/HttpSM.cc @@ -5091,6 +5091,36 @@ HttpSM::do_range_setup_if_necessary() if (t_state.cache_info.action == HttpTransact::CacheAction_t::REPLACE) { if (t_state.hdr_info.server_response.status_get() == HTTPStatus::OK) { Dbg(dbg_ctl_http_range, "Serving transform after stale cache re-serve"); + + // Ranges and range_output_cl were computed against the stale cached object size. If the fresh origin Content-Length + // differs, re-parse against the fresh value so the outgoing Content-Length/Content- Range reflect the body we are + // actually about to send. Without this, a shrunk origin body leaves Content-Length advertising the stale cached size + // while only the shorter fresh body is delivered. + const int64_t fresh_cl = t_state.hdr_info.server_response.get_content_length(); + const int64_t cached_cl = t_state.cache_info.object_read ? t_state.cache_info.object_read->object_size_get() : -1; + if (fresh_cl > 0 && fresh_cl != cached_cl) { + SMDbg(dbg_ctl_http_range, "Re-parsing range against fresh origin Content-Length %" PRId64 " (was %" PRId64 ")", + fresh_cl, cached_cl); + delete[] t_state.ranges; + t_state.ranges = nullptr; + t_state.num_range_fields = 0; + t_state.range_setup = HttpTransact::RangeSetup_t::NONE; + t_state.range_output_cl = 0; + parse_range_done = false; + + std::string_view content_type = + t_state.hdr_info.server_response.value_get(static_cast(MIME_FIELD_CONTENT_TYPE)); + parse_range_and_compare(field, fresh_cl); + calculate_output_cl(content_type.length(), num_chars_for_int(fresh_cl)); + + if (t_state.range_setup != HttpTransact::RangeSetup_t::REQUESTED) { + // Re-parse yielded e.g. RANGE_NOT_SATISFIABLE (entire range + // past fresh body); let downstream handling take over without + // installing the transform. + return; + } + } + do_transform = true; } else { Dbg(dbg_ctl_http_range, "Not transforming after revalidate"); diff --git a/tests/gold_tests/headers/range_transform.test.py b/tests/gold_tests/headers/range_transform.test.py new file mode 100644 index 00000000000..63ef0ea8038 --- /dev/null +++ b/tests/gold_tests/headers/range_transform.test.py @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +tr = Test.ATSReplayTest(replay_file="replays/range_transform.replay.yaml") +tr.Processes.Default.TimeOut = 10 diff --git a/tests/gold_tests/headers/replays/range_transform.replay.yaml b/tests/gold_tests/headers/replays/range_transform.replay.yaml new file mode 100644 index 00000000000..740f7b1b1d9 --- /dev/null +++ b/tests/gold_tests/headers/replays/range_transform.replay.yaml @@ -0,0 +1,116 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Verify RangeTransform on stale-revalidate +# +# Preconditions for the bug: +# 1. Client request has a `Range` header. +# 2. Cached object is stale, so ATS revalidates. +# 3. Origin returns `200 OK` with the full body (not 304, not 206). +# 4. The fresh `Content-Length` differs from the cached object size. + +meta: + version: '1.0' + +autest: + description: 'Verify RangeTransform' + + dns: + name: "dns-range-transform" + + server: + name: "server-range-transform" + + client: + name: "client-range-transform" + + ats: + name: "ts-range-transform" + + process_config: + enable_cache: true + + records_config: + proxy.config.http.wait_for_cache: 1 + proxy.config.http.cache.required_headers: 0 + proxy.config.diags.debug.enabled: 1 + proxy.config.diags.debug.tags: 'http' + + remap_config: + - from: "http://example.com/" + to: "http://backend.example.com:{SERVER_HTTP_PORT}/" + + log_validation: + traffic_out: + contains: + - expression: 'perform_transform_cache_write_action CacheAction_t::REPLACE' + description: 'Stale cache is replaced via RangeTransform' + +sessions: + # Prime cache with BIG body. + - transactions: + - client-request: + method: "GET" + version: "1.1" + url: /obj + headers: + fields: + - [Host, example.com] + - [uuid, prime] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [Date, "Mon, 01 Jan 2026 00:00:00 GMT"] + - [Cache-Control, "max-age=1, public"] + - [Content-Type, application/octet-stream] + - [Content-Length, 64097] + content: + size: 64097 + + proxy-response: + status: 200 + + # Stale revalidate: origin body shrunk to 40000, Range end (64096) unreachable. + - transactions: + - client-request: + delay: 1500ms + method: "GET" + version: "1.1" + url: /obj + headers: + fields: + - [Host, example.com] + - [uuid, range-revalidate] + - [Range, "bytes=0-64096"] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [Date, "Mon, 01 Jan 2026 00:00:05 GMT"] + - [Cache-Control, "max-age=1, public"] + - [Content-Type, application/octet-stream] + - [Content-Length, 40000] + content: + size: 40000 + + proxy-response: + status: 206 + reason: Partial Content From da8e755918e15dbcac1523784d8f0d58637bc44b Mon Sep 17 00:00:00 2001 From: Masaori Koshiba Date: Thu, 14 May 2026 12:55:21 +0900 Subject: [PATCH 2/4] Fix comments and add grown response test case --- src/proxy/http/HttpSM.cc | 5 +-- .../replays/range_transform.replay.yaml | 39 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/proxy/http/HttpSM.cc b/src/proxy/http/HttpSM.cc index 4bfc7d9a670..ca39a7595e6 100644 --- a/src/proxy/http/HttpSM.cc +++ b/src/proxy/http/HttpSM.cc @@ -5093,9 +5093,8 @@ HttpSM::do_range_setup_if_necessary() Dbg(dbg_ctl_http_range, "Serving transform after stale cache re-serve"); // Ranges and range_output_cl were computed against the stale cached object size. If the fresh origin Content-Length - // differs, re-parse against the fresh value so the outgoing Content-Length/Content- Range reflect the body we are - // actually about to send. Without this, a shrunk origin body leaves Content-Length advertising the stale cached size - // while only the shorter fresh body is delivered. + // differs, re-parse the Range against the fresh value so the outgoing Content-Length/Content-Range match the body + // actually being sent. Without this, Content-Length/Content-Range advertise the stale cached size. const int64_t fresh_cl = t_state.hdr_info.server_response.get_content_length(); const int64_t cached_cl = t_state.cache_info.object_read ? t_state.cache_info.object_read->object_size_get() : -1; if (fresh_cl > 0 && fresh_cl != cached_cl) { diff --git a/tests/gold_tests/headers/replays/range_transform.replay.yaml b/tests/gold_tests/headers/replays/range_transform.replay.yaml index 740f7b1b1d9..c3a3a05c259 100644 --- a/tests/gold_tests/headers/replays/range_transform.replay.yaml +++ b/tests/gold_tests/headers/replays/range_transform.replay.yaml @@ -114,3 +114,42 @@ sessions: proxy-response: status: 206 reason: Partial Content + headers: + fields: + - [Content-Range, {value: "bytes 0-39999/40000", as: equal}] + - [Content-Length, {value: 40000, as: equal}] + + + # Stale revalidate: origin body grown to 80000 (larger than cached). Range stays + # satisfiable, but Content-Range total must reflect fresh 80000, not stale size. + - transactions: + - client-request: + delay: 1500ms + method: "GET" + version: "1.1" + url: /obj + headers: + fields: + - [Host, example.com] + - [uuid, range-revalidate-grown] + - [Range, "bytes=0-64096"] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [Date, "Mon, 01 Jan 2024 00:00:05 GMT"] + - [Cache-Control, "max-age=1, public"] + - [Content-Type, application/octet-stream] + - [Content-Length, 80000] + content: + size: 80000 + + proxy-response: + status: 206 + reason: Partial Content + headers: + fields: + - [Content-Range, {value: "bytes 0-64096/80000", as: equal}] + - [Content-Length, {value: 64097, as: equal}] From fec5b29ef7d01fbb147e63948364c9a19431d5d9 Mon Sep 17 00:00:00 2001 From: Masaori Koshiba Date: Fri, 15 May 2026 10:38:36 +0900 Subject: [PATCH 3/4] Add error cases of range-not-satisfiable --- src/proxy/http/HttpSM.cc | 17 +++-- .../replays/range_transform.replay.yaml | 70 ++++++++++++++++++- 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/src/proxy/http/HttpSM.cc b/src/proxy/http/HttpSM.cc index ca39a7595e6..cd7f755ae3f 100644 --- a/src/proxy/http/HttpSM.cc +++ b/src/proxy/http/HttpSM.cc @@ -5095,9 +5095,15 @@ HttpSM::do_range_setup_if_necessary() // Ranges and range_output_cl were computed against the stale cached object size. If the fresh origin Content-Length // differs, re-parse the Range against the fresh value so the outgoing Content-Length/Content-Range match the body // actually being sent. Without this, Content-Length/Content-Range advertise the stale cached size. - const int64_t fresh_cl = t_state.hdr_info.server_response.get_content_length(); + const int64_t fresh_cl = t_state.hdr_info.server_response.get_content_length(); + if (fresh_cl == 0) { + // Re-parse yielded e.g. RANGE_NOT_SATISFIABLE (entire range past fresh body); let downstream handling take over + // without installing the transform. + Dbg(dbg_ctl_http_range, "Not transforming: fresh response body is empty"); + return; + } const int64_t cached_cl = t_state.cache_info.object_read ? t_state.cache_info.object_read->object_size_get() : -1; - if (fresh_cl > 0 && fresh_cl != cached_cl) { + if (fresh_cl != cached_cl) { SMDbg(dbg_ctl_http_range, "Re-parsing range against fresh origin Content-Length %" PRId64 " (was %" PRId64 ")", fresh_cl, cached_cl); delete[] t_state.ranges; @@ -5113,9 +5119,10 @@ HttpSM::do_range_setup_if_necessary() calculate_output_cl(content_type.length(), num_chars_for_int(fresh_cl)); if (t_state.range_setup != HttpTransact::RangeSetup_t::REQUESTED) { - // Re-parse yielded e.g. RANGE_NOT_SATISFIABLE (entire range - // past fresh body); let downstream handling take over without - // installing the transform. + // Re-parse yielded e.g. RANGE_NOT_SATISFIABLE (entire range past fresh body); let downstream handling take over + // without installing the transform. + Dbg(dbg_ctl_http_range, "Not transforming: parse_range_and_compare set t_state.range_setup=%d", + HttpTransact::RangeSetup_t::REQUESTED); return; } } diff --git a/tests/gold_tests/headers/replays/range_transform.replay.yaml b/tests/gold_tests/headers/replays/range_transform.replay.yaml index c3a3a05c259..d4376dfabcd 100644 --- a/tests/gold_tests/headers/replays/range_transform.replay.yaml +++ b/tests/gold_tests/headers/replays/range_transform.replay.yaml @@ -139,7 +139,7 @@ sessions: reason: OK headers: fields: - - [Date, "Mon, 01 Jan 2024 00:00:05 GMT"] + - [Date, "Mon, 01 Jan 2026 00:00:10 GMT"] - [Cache-Control, "max-age=1, public"] - [Content-Type, application/octet-stream] - [Content-Length, 80000] @@ -153,3 +153,71 @@ sessions: fields: - [Content-Range, {value: "bytes 0-64096/80000", as: equal}] - [Content-Length, {value: 64097, as: equal}] + + # Error Cases: when requested range is unsatisfiable, choices are below from RFC 9110. Our choice is B. + # A). Return 416 Range Not Satisfiable + # B). Ignore Range header, return 200 OK + + # Stale revalidate: new origin body is empty + - transactions: + - client-request: + delay: 1500ms + method: "GET" + version: "1.1" + url: /obj + headers: + fields: + - [Host, example.com] + - [uuid, range-revalidate-empty] + - [Range, "bytes=0-64096"] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [Date, "Mon, 01 Jan 2026 00:00:20 GMT"] + - [Cache-Control, "max-age=1, public"] + - [Content-Type, application/octet-stream] + - [Content-Length, 0] + content: + size: 0 + + proxy-response: + status: 200 + reason: OK + headers: + fields: + - [Content-Length, 40000] + + # Stale revalidate: new origin body is smaller than requested range + - transactions: + - client-request: + delay: 1500ms + method: "GET" + version: "1.1" + url: /obj + headers: + fields: + - [Host, example.com] + - [uuid, range-revalidate-out-of-range] + - [Range, "bytes=60000-64096"] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [Date, "Mon, 01 Jan 2026 00:00:30 GMT"] + - [Cache-Control, "max-age=1, public"] + - [Content-Type, application/octet-stream] + - [Content-Length, 40000] + content: + size: 40000 + + proxy-response: + status: 200 + reason: OK + headers: + fields: + - [Content-Length, 40000] From ae8019f459f1d24e1a528150042b1529c880207b Mon Sep 17 00:00:00 2001 From: Masaori Koshiba Date: Fri, 15 May 2026 10:49:54 +0900 Subject: [PATCH 4/4] Fix build --- src/proxy/http/HttpSM.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/proxy/http/HttpSM.cc b/src/proxy/http/HttpSM.cc index cd7f755ae3f..96c520e28a5 100644 --- a/src/proxy/http/HttpSM.cc +++ b/src/proxy/http/HttpSM.cc @@ -5122,7 +5122,7 @@ HttpSM::do_range_setup_if_necessary() // Re-parse yielded e.g. RANGE_NOT_SATISFIABLE (entire range past fresh body); let downstream handling take over // without installing the transform. Dbg(dbg_ctl_http_range, "Not transforming: parse_range_and_compare set t_state.range_setup=%d", - HttpTransact::RangeSetup_t::REQUESTED); + static_cast(HttpTransact::RangeSetup_t::REQUESTED)); return; } }