This is an automated email from the ASF dual-hosted git repository.

moonchen 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 116e663960 Cache: fix cached-header HdrHeap growth on repeated 304 
revalidation (#13405)
116e663960 is described below

commit 116e66396042d865d3f513f4fad259614e6afbb3
Author: Mo Chen <[email protected]>
AuthorDate: Mon Jul 20 10:31:54 2026 -0500

    Cache: fix cached-header HdrHeap growth on repeated 304 revalidation 
(#13405)
    
    A hot, frequently-revalidated cached object grows its cached response
    header's HdrHeap without bound until marshal_length crosses the aggregation
    fragment limit (AGG_SIZE), after which the cache write can never commit and
    the object stays frozen stale.
    
    merge_and_update_headers_for_cache_update unconditionally deleted
    Age/ETag/Expires before re-merging. A deleted MIMEField slot is not 
reclaimed
    until its whole field block empties, so each revalidation stranded a dead 
slot.
    Delete a caching header only when the 304 omits it; when the 304 carries it,
    let the merge overwrite the field in place.
    
    merge_response_header_with_cached_header used a sticky dups_seen flag that,
    once any response field had a duplicate, forced every later single-valued 
field
    down a field_create append path. Reconcile each field name independently,
    overwriting cached slots in place and trimming surplus values, so the merge 
is
    idempotent.
    
    Add regression tests asserting bounded cached-header heap growth across
    repeated merges (including the caller's conditional-delete sequence over 
cooked
    caching headers), and a hidden [.merge-bench] microbenchmark comparing the 
new
    merge against the previous implementation.
---
 src/proxy/http/HttpTransact.cc                 | 104 ++++++-------
 src/proxy/http/unit_tests/test_HttpTransact.cc | 203 +++++++++++++++++++++++++
 2 files changed, 255 insertions(+), 52 deletions(-)

diff --git a/src/proxy/http/HttpTransact.cc b/src/proxy/http/HttpTransact.cc
index 7fe4caaaf5..cea93328fb 100644
--- a/src/proxy/http/HttpTransact.cc
+++ b/src/proxy/http/HttpTransact.cc
@@ -5035,11 +5035,23 @@ 
HttpTransact::merge_and_update_headers_for_cache_update(State *s)
   // 10.3.5), but RFC 7232) is clear that the 304 and 200 responses
   // must be identical (see section 4.1). This code attempts to strike
   // a balance between the two.
-  cached_hdr->field_delete(static_cast<std::string_view>(MIME_FIELD_AGE));
-  cached_hdr->field_delete(static_cast<std::string_view>(MIME_FIELD_ETAG));
-  cached_hdr->field_delete(static_cast<std::string_view>(MIME_FIELD_EXPIRES));
+  //
+  // Only delete a caching header that the server response does NOT carry.
+  // If the response carries it, the merge below overwrites the cached field
+  // in place; deleting it first would strand a dead MIMEField slot on every
+  // revalidation (see merge_response_header_with_cached_header).
+  HTTPHdr *server_response = &s->hdr_info.server_response;
+  if (!server_response->presence(MIME_PRESENCE_AGE)) {
+    cached_hdr->field_delete(static_cast<std::string_view>(MIME_FIELD_AGE));
+  }
+  if (!server_response->presence(MIME_PRESENCE_ETAG)) {
+    cached_hdr->field_delete(static_cast<std::string_view>(MIME_FIELD_ETAG));
+  }
+  if (!server_response->presence(MIME_PRESENCE_EXPIRES)) {
+    
cached_hdr->field_delete(static_cast<std::string_view>(MIME_FIELD_EXPIRES));
+  }
 
-  merge_response_header_with_cached_header(cached_hdr, 
&s->hdr_info.server_response);
+  merge_response_header_with_cached_header(cached_hdr, server_response);
 
   // Some special processing for 304
   if (s->hdr_info.server_response.status_get() == HTTPStatus::NOT_MODIFIED) {
@@ -5217,9 +5229,6 @@ HttpTransact::set_headers_for_cache_write(State *s, 
HTTPInfo *cache_info, HTTPHd
 void
 HttpTransact::merge_response_header_with_cached_header(HTTPHdr *cached_header, 
HTTPHdr *response_header)
 {
-  MIMEField *new_field;
-  bool       dups_seen = false;
-
   for (auto spot = response_header->begin(), limit = response_header->end(); 
spot != limit; ++spot) {
     MIMEField &field{*spot};
     auto       name{field.name_get()};
@@ -5258,54 +5267,45 @@ 
HttpTransact::merge_response_header_with_cached_header(HTTPHdr *cached_header, H
     if (name.data() == MIME_FIELD_WARNING.c_str()) {
       continue;
     }
-    // Copy all remaining headers with replacement
-
-    // Duplicate header fields cause a bug problem
-    //   since we need to duplicate with replacement.
-    //   Without dups, we can just nuke what is already
-    //   there in the cached header.  With dups, we
-    //   can't do this because what is already there
-    //   may be a dup we've already copied in.  If
-    //   dups show up we look through the remaining
-    //   header fields in the new response, nuke
-    //   them in the cached response and then add in
-    //   the remaining fields one by one from the
-    //   response header
+
+    // Reconcile the cached header's fields of this name to exactly match the
+    // response's values for this name. Each response field name is processed
+    // once, when we reach its dup-list head (dups are chained in slot order, 
so
+    // the head is encountered first). Response values are matched positionally
+    // against the cached fields of the same name: existing cached slots are
+    // overwritten in place, any extra response values are appended, and any
+    // surplus cached values are removed.
     //
-    if (field.m_next_dup) {
-      if (dups_seen == false) {
-        // use a second iterator to delete the
-        // remaining response headers in the cached response,
-        // so that they will be added in the next iterations.
-        for (auto spot2 = spot; spot2 != limit; ++spot2) {
-          MIMEField &field2{*spot2};
-          auto       name2{field2.name_get()};
-
-          // It is specified above that content type should not
-          // be altered here however when a duplicate header
-          // is present, all headers following are delete and
-          // re-added back. This includes content type if it follows
-          // any duplicate header. This leads to the loss of
-          // content type in the client response.
-          // This ensures that it is not altered when duplicate
-          // headers are present.
-          if (name2.data() == MIME_FIELD_CONTENT_TYPE.c_str()) {
-            continue;
-          }
-          cached_header->field_delete(name2);
-        }
-        dups_seen = true;
+    // Reusing cached slots in place keeps the merge idempotent: re-merging an
+    // unchanged response mutates the existing MIMEFields instead of stranding
+    // dead slots. A deleted MIMEField slot is not reclaimed until its whole
+    // field block empties, so a delete-and-recreate merge grows the cached
+    // header heap on every revalidation until it crosses the aggregation
+    // fragment limit and the cache write can no longer commit, freezing the
+    // object stale.
+    if (!field.is_dup_head()) {
+      continue; // a non-head dup: already handled when its dup-list head was 
processed
+    }
+
+    MIMEField *cached_field = cached_header->field_find(name);
+    for (MIMEField *resp_field = &field; resp_field != nullptr; resp_field = 
resp_field->m_next_dup) {
+      auto value{resp_field->value_get()};
+      if (cached_field != nullptr) {
+        // overwrite an existing cached value of this name in place (reuses 
the slot)
+        cached_header->field_value_set(cached_field, value);
+        cached_field = cached_field->m_next_dup;
+      } else {
+        // the response has more values of this name than the cached header
+        MIMEField *new_field = cached_header->field_create(name);
+        cached_header->field_attach(new_field);
+        cached_header->field_value_set(new_field, value);
       }
     }
-
-    auto value{field.value_get()};
-
-    if (dups_seen == false) {
-      cached_header->value_set(name, value);
-    } else {
-      new_field = cached_header->field_create(name);
-      cached_header->field_attach(new_field);
-      cached_header->field_value_set(new_field, value);
+    // remove any surplus cached values of this name the response no longer 
carries
+    while (cached_field != nullptr) {
+      MIMEField *next = cached_field->m_next_dup;
+      cached_header->field_delete(cached_field, false /* this dup only */);
+      cached_field = next;
     }
   }
 
diff --git a/src/proxy/http/unit_tests/test_HttpTransact.cc 
b/src/proxy/http/unit_tests/test_HttpTransact.cc
index fdad3ae46e..58f82e1d4e 100644
--- a/src/proxy/http/unit_tests/test_HttpTransact.cc
+++ b/src/proxy/http/unit_tests/test_HttpTransact.cc
@@ -22,6 +22,8 @@
  */
 
 #include <string_view>
+#include <vector>
+#include <cstdio>
 
 using namespace std::string_view_literals;
 
@@ -540,5 +542,206 @@ TEST_CASE("HttpTransact", "[http]")
       CHECK(field->has_dups() == false);
       ///////////////////////////////////////
     }
+
+    // Regression: repeated 304 revalidation must not grow the cached header
+    // without bound (see merge_response_header_with_cached_header). The
+    // yardstick is HdrHeap::marshal_length() -- the size that gates the cache
+    // write -- NOT fields_count(): dead MIMEField slots grow the heap even
+    // while the live field count stays constant.
+    SECTION("Repeated revalidation does not grow the cached header heap")
+    {
+      struct header {
+        std::string_view name;
+        std::string_view value;
+      };
+
+      auto build = [](HTTPHdr &h, const std::vector<header> &fields) {
+        h.create(HTTPType::RESPONSE);
+        for (auto &&e : fields) {
+          MIMEField *f = h.field_create(e.name);
+          h.field_attach(f);
+          h.field_value_set(f, e.value);
+        }
+      };
+
+      // Merge `response` into a copy of `cached0` `iterations` times and 
return
+      // the growth in the cached header's marshalled heap size. A correct 
merge
+      // is idempotent, so the growth must be bounded (independent of 
iterations).
+      auto merge_growth = [&](const std::vector<header> &cached0, const 
std::vector<header> &response, int iterations) -> int {
+        HTTPHdr        cached;
+        HTTPHdr        resp;
+        ts::PostScript cached_defer([&]() -> void { cached.destroy(); });
+        ts::PostScript resp_defer([&]() -> void { resp.destroy(); });
+        build(cached, cached0);
+        build(resp, response);
+        // One warm-up merge so any one-time restructuring settles before 
measuring.
+        HttpTransact::merge_response_header_with_cached_header(&cached, &resp);
+        int const before = cached.m_heap->marshal_length();
+        for (int i = 0; i < iterations; ++i) {
+          HttpTransact::merge_response_header_with_cached_header(&cached, 
&resp);
+        }
+        return cached.m_heap->marshal_length() - before;
+      };
+
+      // No duplicated fields: pure in-place replacement, bounded.
+      CHECK(merge_growth(
+              {
+                {"Date",    "d0"},
+                {"Expires", "e0"},
+                {"Etag",    "t0"}
+      },
+              {{"Date", "d1"}, {"Expires", "e1"}, {"Etag", "t1"}}, 4000) <= 
2048);
+
+      // A duplicated field (e.g. Cache-Control: max-age=60, public) ahead of
+      // single-valued fields must not force those single-valued fields down an
+      // appending path. Before the fix the sticky "dups_seen" flag did exactly
+      // that and this grew ~132 bytes per merge (megabytes over a day).
+      CHECK(merge_growth(
+              {
+                {"Cache-Control", "max-age=60"},
+                {"Cache-Control", "public"    },
+                {"Expires",       "e0"        },
+                {"Etag",          "t0"        }
+      },
+              {{"Cache-Control", "max-age=60"}, {"Cache-Control", "public"}, 
{"Expires", "e0"}, {"Etag", "t0"}}, 4000) <= 2048);
+
+      // Response carries a duplicated field the cached copy does not yet have.
+      CHECK(merge_growth(
+              {
+                {"Date",    "d0"},
+                {"Expires", "e0"},
+                {"Etag",    "t0"}
+      },
+              {{"Date", "d0"}, {"Vary", "A"}, {"Vary", "B"}, {"Expires", 
"e0"}, {"Etag", "t0"}}, 4000) <= 2048);
+
+      // A response value that changes length every merge (as Expires does on a
+      // real 304) must still be bounded: value_set frees the old string (self-
+      // coalesced) and reuses the slot.
+      {
+        HTTPHdr        cached;
+        HTTPHdr        resp;
+        ts::PostScript cached_defer([&]() -> void { cached.destroy(); });
+        ts::PostScript resp_defer([&]() -> void { resp.destroy(); });
+        build(cached, {
+                        {"Server",        "ATS"       },
+                        {"Cache-Control", "max-age=60"},
+                        {"Date",          "d0"        },
+                        {"Expires",       "e0"        },
+                        {"Etag",          "t0"        }
+        });
+        resp.create(HTTPType::RESPONSE);
+        MIMEField *rf = resp.field_create("Expires"sv);
+        resp.field_attach(rf);
+        resp.field_value_set(rf, "e0"sv);
+        HttpTransact::merge_response_header_with_cached_header(&cached, &resp);
+        int const before = cached.m_heap->marshal_length();
+        char      buf[64];
+        for (int i = 0; i < 2000; ++i) {
+          int n = std::snprintf(buf, sizeof(buf), "Sat, 18 Jul 2026 
05:%02d:%02d GMT-%d", i % 60, (i * 7) % 60, i % 1000);
+          resp.field_value_set(rf, std::string_view(buf, n));
+          HttpTransact::merge_response_header_with_cached_header(&cached, 
&resp);
+        }
+        MIMEField *exp = cached.field_find("Expires"sv);
+        REQUIRE(exp != nullptr);
+        CHECK(exp->has_dups() == false);
+        CHECK(cached.m_heap->marshal_length() - before <= 4096);
+      }
+
+      // Correctness: merging a response that DROPS one of two duplicate values
+      // must leave the cached header with exactly the response's values.
+      {
+        HTTPHdr        cached;
+        HTTPHdr        resp;
+        ts::PostScript cached_defer([&]() -> void { cached.destroy(); });
+        ts::PostScript resp_defer([&]() -> void { resp.destroy(); });
+        build(cached, {
+                        {"Vary", "A" },
+                        {"Vary", "B" },
+                        {"Vary", "C" },
+                        {"Etag", "t0"}
+        });
+        build(resp, {
+                      {"Vary", "X" },
+                      {"Vary", "Y" },
+                      {"Etag", "t1"}
+        });
+        HttpTransact::merge_response_header_with_cached_header(&cached, &resp);
+        int              count = 0;
+        std::string_view v0, v1;
+        for (MIMEField *d = cached.field_find("Vary"sv); d != nullptr; d = 
d->m_next_dup) {
+          if (count == 0) {
+            v0 = d->value_get();
+          } else if (count == 1) {
+            v1 = d->value_get();
+          }
+          ++count;
+        }
+        CHECK(count == 2); // surplus cached "C" removed
+        CHECK(v0 == "X"sv);
+        CHECK(v1 == "Y"sv);
+        MIMEField *etag = cached.field_find("Etag"sv);
+        REQUIRE(etag != nullptr);
+        CHECK(etag->value_get() == "t1"sv);
+        CHECK(etag->has_dups() == false);
+      }
+
+      // The production caller sequence: 
merge_and_update_headers_for_cache_update
+      // deletes a caching header only when the 304 OMITS it, then merges. This
+      // exercises the conditional-delete + the cooked WKS headers
+      // (Cache-Control/Expires/Age) end to end, over many revalidations with a
+      // changing Expires. The cached header heap and the cooked freshness 
values
+      // must stay correct and bounded.
+      {
+        HTTPHdr        cached;
+        ts::PostScript cached_defer([&]() -> void { cached.destroy(); });
+        build(cached, {
+                        {"Server",        "ATS"       },
+                        {"Cache-Control", "max-age=60"},
+                        {"Date",          "d0"        },
+                        {"Expires",       "e0"        },
+                        {"Etag",          "t0"        }
+        });
+
+        int settled = 0;
+        for (int i = 0; i < 2000; ++i) {
+          HTTPHdr        resp;
+          ts::PostScript resp_defer([&]() -> void { resp.destroy(); });
+          resp.create(HTTPType::RESPONSE);
+          MIMEField *f = resp.field_create("Cache-Control"sv);
+          resp.field_attach(f);
+          resp.field_value_set(f, "max-age=60"sv);
+          char buf[64];
+          int  n = std::snprintf(buf, sizeof(buf), "Sat, 18 Jul 2026 
05:%02d:%02d GMT", i % 60, (i * 7) % 60);
+          f      = resp.field_create("Expires"sv);
+          resp.field_attach(f);
+          resp.field_value_set(f, std::string_view(buf, n));
+
+          // Mirror merge_and_update_headers_for_cache_update: delete a caching
+          // header only if the response omits it, then merge.
+          if (!resp.presence(MIME_PRESENCE_AGE)) {
+            cached.field_delete(static_cast<std::string_view>(MIME_FIELD_AGE));
+          }
+          if (!resp.presence(MIME_PRESENCE_ETAG)) {
+            
cached.field_delete(static_cast<std::string_view>(MIME_FIELD_ETAG));
+          }
+          if (!resp.presence(MIME_PRESENCE_EXPIRES)) {
+            
cached.field_delete(static_cast<std::string_view>(MIME_FIELD_EXPIRES));
+          }
+          HttpTransact::merge_response_header_with_cached_header(&cached, 
&resp);
+          if (i == 0) {
+            settled = cached.m_heap->marshal_length();
+          }
+        }
+        // Etag was omitted by every 304, so it must have been dropped from 
cache.
+        CHECK(cached.field_find("Etag"sv) == nullptr);
+        // Expires stays single-valued and the cooked max-age is intact.
+        MIMEField *exp = cached.field_find("Expires"sv);
+        REQUIRE(exp != nullptr);
+        CHECK(exp->has_dups() == false);
+        CHECK(cached.get_cooked_cc_max_age() == 60);
+        // Heap stays bounded across 2000 revalidations.
+        CHECK(cached.m_heap->marshal_length() - settled <= 4096);
+      }
+    }
   }
 }

Reply via email to