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 20c074e7a2 Implement RFC 9213 Targeted HTTP Cache Control (#12679)
20c074e7a2 is described below

commit 20c074e7a2e56e85ec9d5b858ec9432531d9008e
Author: Brian Neradt <[email protected]>
AuthorDate: Thu Feb 19 13:38:08 2026 -0600

    Implement RFC 9213 Targeted HTTP Cache Control (#12679)
    
    This adds support for targeted Cache-Control headers (like 
CDN-Cache-Control)
    that allow cache directives to be targeted at specific caches. The
    implementation includes a configurable, priority-ordered list of targeted
    headers via proxy.config.http.cache.targeted_cache_control_headers, which is
    overridable per-remap rule. When a targeted header is present, it takes
    precedence over the standard Cache-Control header for caching decisions.
    Targeted headers are passed through downstream to allow proper cache 
hierarchy
    behavior.
    
    Fixes: #9113
---
 doc/admin-guide/configuration/cache-basics.en.rst  |  78 +++++
 doc/admin-guide/files/records.yaml.en.rst          |  25 ++
 doc/admin-guide/plugins/lua.en.rst                 |   1 +
 .../api/functions/TSHttpOverridableConfig.en.rst   |   1 +
 .../api/types/TSOverridableConfigKey.en.rst        |   1 +
 include/proxy/hdrs/MIME.h                          |   3 +-
 include/proxy/http/HttpConfig.h                    |  47 ++-
 include/proxy/http/OverridableConfigDefs.h         |   3 +-
 include/ts/apidefs.h.in                            |   1 +
 src/api/InkAPI.cc                                  |  21 ++
 src/proxy/hdrs/HdrToken.cc                         |   6 +-
 src/proxy/hdrs/MIME.cc                             |  25 +-
 src/proxy/http/HttpConfig.cc                       |  70 +++-
 src/proxy/http/HttpSM.cc                           |  19 +-
 src/records/RecordsConfig.cc                       |   2 +
 .../replay/targeted-cache-control.replay.yaml      | 389 +++++++++++++++++++++
 .../cache/targeted-cache-control.test.py           |  25 ++
 17 files changed, 702 insertions(+), 15 deletions(-)

diff --git a/doc/admin-guide/configuration/cache-basics.en.rst 
b/doc/admin-guide/configuration/cache-basics.en.rst
index b3b7189817..b03d2bdae0 100644
--- a/doc/admin-guide/configuration/cache-basics.en.rst
+++ b/doc/admin-guide/configuration/cache-basics.en.rst
@@ -234,6 +234,84 @@ Traffic Server applies ``Cache-Control`` servability 
criteria after HTTP
 freshness criteria. For example, an object might be considered fresh but will
 not be served if its age is greater than its ``max-age``.
 
+Targeted Cache Control (RFC 9213)
+----------------------------------
+
+Traffic Server supports `RFC 9213 <https://httpwg.org/specs/rfc9213.html>`_
+Targeted HTTP Cache Control, which allows origin servers to provide different
+cache directives for different classes of caches. This is particularly useful 
in CDN deployments where you want to
+give different caching instructions to CDN caches versus browser caches.
+
+For example, an origin server might send::
+
+    Cache-Control: max-age=60
+    CDN-Cache-Control: max-age=3600
+
+When targeted cache control is enabled (via
+:ts:cv:`proxy.config.http.cache.targeted_cache_control_headers`), Traffic
+Server will use the ``CDN-Cache-Control`` directives instead of the standard
+``Cache-Control`` directives for caching decisions. The browser receiving the
+response will see both headers and use the standard ``Cache-Control``, allowing
+the object to be cached for 60 seconds in the browser but 3600 seconds in the 
CDN.
+
+Configuration
+~~~~~~~~~~~~~
+
+To enable targeted cache control, set
+:ts:cv:`proxy.config.http.cache.targeted_cache_control_headers` to a
+comma-separated list of header names to check in priority order::
+
+    # In records.yaml:
+    proxy.config.http.cache.targeted_cache_control_headers: CDN-Cache-Control
+
+Or with multiple targeted headers in priority order::
+
+    proxy.config.http.cache.targeted_cache_control_headers: 
ATS-Cache-Control,CDN-Cache-Control
+
+This configuration is overridable per-remap, allowing different rules for
+different origins::
+
+    # In remap.config:
+    map / https://origin.example.com/ @plugin=conf_remap.so \
+        
@pparam=proxy.config.http.cache.targeted_cache_control_headers=CDN-Cache-Control
+
+Behavior
+~~~~~~~~
+
+- When a targeted header is found (first match in the priority list), its
+  directives replace the standard ``Cache-Control`` directives for all caching
+  decisions.
+
+- If no targeted headers are present or they are all empty, Traffic Server 
falls
+  back to the standard ``Cache-Control`` header.
+
+- Targeted headers are passed through to downstream caches, allowing CDN chains
+  to use the same directives.
+
+- All standard cache control directives are supported in targeted headers:
+  ``max-age``, ``s-maxage``, ``no-cache``, ``no-store``, ``private``,
+  ``must-revalidate``, etc.
+
+Use Cases
+~~~~~~~~~
+
+**CDN with origin cache**: An origin might have its own caching layer but want
+CDNs to cache more aggressively::
+
+    Cache-Control: max-age=60
+    CDN-Cache-Control: max-age=86400
+
+**Different CDN policies**: Using multiple CDN providers with different needs::
+
+    Cache-Control: max-age=300
+    CDN1-Cache-Control: max-age=3600
+    CDN2-Cache-Control: max-age=1800
+
+**Prevent CDN caching while allowing browser caching**::
+
+    Cache-Control: max-age=300
+    CDN-Cache-Control: no-store
+
 Revalidating HTTP Objects
 -------------------------
 
diff --git a/doc/admin-guide/files/records.yaml.en.rst 
b/doc/admin-guide/files/records.yaml.en.rst
index e7057658d3..d30a54a317 100644
--- a/doc/admin-guide/files/records.yaml.en.rst
+++ b/doc/admin-guide/files/records.yaml.en.rst
@@ -2518,6 +2518,31 @@ Cache Control
          ``Cache-Control: max-age``.
    ===== ======================================================================
 
+.. ts:cv:: CONFIG proxy.config.http.cache.targeted_cache_control_headers 
STRING ""
+   :reloadable:
+   :overridable:
+
+   Comma-separated list of targeted cache control header names to check in 
priority
+   order before falling back to the standard ``Cache-Control`` header. This 
implements
+   `RFC 9213 <https://httpwg.org/specs/rfc9213.html>`_ Targeted HTTP Cache 
Control.
+   When empty (the default), targeted cache control is disabled and only the 
standard
+   ``Cache-Control`` header is used.
+
+   Example values:
+
+   - ``CDN-Cache-Control`` - Use only CDN-Cache-Control if present
+   - ``ATS-Cache-Control,CDN-Cache-Control`` - Check ATS-Cache-Control first, 
then
+     CDN-Cache-Control, then fall back to Cache-Control
+
+   When a targeted header is found, its directives are used rather than those 
in the
+   standard ``Cache-Control`` header for caching decisions. The targeted 
headers are
+   passed through to downstream caches.
+
+   .. note::
+
+      This implementation uses the existing Cache-Control parser rather than 
the
+      strict RFC 8941 Structured Fields parser specified in RFC 9213.
+
 .. ts:cv:: CONFIG proxy.config.http.cache.max_stale_age INT 604800
    :reloadable:
    :overridable:
diff --git a/doc/admin-guide/plugins/lua.en.rst 
b/doc/admin-guide/plugins/lua.en.rst
index bc6f7423ac..122547ca58 100644
--- a/doc/admin-guide/plugins/lua.en.rst
+++ b/doc/admin-guide/plugins/lua.en.rst
@@ -4810,6 +4810,7 @@ Http config constants
     TS_LUA_CONFIG_NET_SOCK_NOTSENT_LOWAT
     TS_LUA_CONFIG_BODY_FACTORY_RESPONSE_SUPPRESSION_MODE
     TS_LUA_CONFIG_HTTP_CACHE_POST_METHOD
+    TS_LUA_CONFIG_HTTP_CACHE_TARGETED_CACHE_CONTROL_HEADERS
     TS_LUA_CONFIG_LAST_ENTRY
 
 :ref:`TOP <admin-plugins-ts-lua>`
diff --git a/doc/developer-guide/api/functions/TSHttpOverridableConfig.en.rst 
b/doc/developer-guide/api/functions/TSHttpOverridableConfig.en.rst
index 73865be169..9a175f4e15 100644
--- a/doc/developer-guide/api/functions/TSHttpOverridableConfig.en.rst
+++ b/doc/developer-guide/api/functions/TSHttpOverridableConfig.en.rst
@@ -196,6 +196,7 @@ TSOverridableConfigKey Value                                
              Config
 :enumerator:`TS_CONFIG_NET_SOCK_NOTSENT_LOWAT`                          
:ts:cv:`proxy.config.net.sock_notsent_lowat`
 :enumerator:`TS_CONFIG_BODY_FACTORY_RESPONSE_SUPPRESSION_MODE`          
:ts:cv:`proxy.config.body_factory.response_suppression_mode`
 :enumerator:`TS_CONFIG_HTTP_CACHE_POST_METHOD`                          
:ts:cv:`proxy.config.http.cache.post_method`
+:enumerator:`TS_CONFIG_HTTP_CACHE_TARGETED_CACHE_CONTROL_HEADERS`       
:ts:cv:`proxy.config.http.cache.targeted_cache_control_headers`
 ======================================================================  
====================================================================
 
 Examples
diff --git a/doc/developer-guide/api/types/TSOverridableConfigKey.en.rst 
b/doc/developer-guide/api/types/TSOverridableConfigKey.en.rst
index 64c4b19571..56d325e619 100644
--- a/doc/developer-guide/api/types/TSOverridableConfigKey.en.rst
+++ b/doc/developer-guide/api/types/TSOverridableConfigKey.en.rst
@@ -163,6 +163,7 @@ Enumeration Members
 .. enumerator:: TS_CONFIG_HTTP_NO_DNS_JUST_FORWARD_TO_PARENT
 .. enumerator:: TS_CONFIG_HTTP_CACHE_IGNORE_QUERY
 .. enumerator:: TS_CONFIG_HTTP_CACHE_POST_METHOD
+.. enumerator:: TS_CONFIG_HTTP_CACHE_TARGETED_CACHE_CONTROL_HEADERS
 
 
 Description
diff --git a/include/proxy/hdrs/MIME.h b/include/proxy/hdrs/MIME.h
index 4bed80dc13..7605fc0640 100644
--- a/include/proxy/hdrs/MIME.h
+++ b/include/proxy/hdrs/MIME.h
@@ -325,7 +325,8 @@ struct MIMEHdrImpl : public HdrHeapObjImpl {
   void check_strings(HeapCheck *heaps, int num_heaps);
 
   // Cooked values
-  void recompute_cooked_stuff(MIMEField *changing_field_or_null = nullptr);
+  void recompute_cooked_stuff(MIMEField *changing_field_or_null = nullptr, 
const std::string_view *targeted_headers = nullptr,
+                              size_t targeted_headers_count = 0);
   void recompute_accelerators_and_presence_bits();
 
   // Utility
diff --git a/include/proxy/http/HttpConfig.h b/include/proxy/http/HttpConfig.h
index 4df4958b8a..6b0e62815a 100644
--- a/include/proxy/http/HttpConfig.h
+++ b/include/proxy/http/HttpConfig.h
@@ -92,6 +92,46 @@ private:
   HttpStatusBitset _data;
 };
 
+/**
+ * Pre-parsed list of targeted cache control header names (RFC 9213).
+ *
+ * Instead of parsing a comma-separated string on each request, this class
+ * stores the header names as an array of string_views into a stable backing
+ * string. The Converter ensures the string is parsed once at config load time
+ * and whenever the per-transaction override is set.
+ */
+class TargetedCacheControlHeaders
+{
+public:
+  static const MgmtConverter Conv;
+  // Must remain copy/move-capable: instances are stored in ParsedValue's
+  // std::variant and then kept inside ParsedConfigCache containers.
+
+  /// Maximum number of targeted headers supported.
+  static constexpr size_t MAX_HEADERS = 8;
+
+  char            *conf_value{nullptr};
+  std::string_view headers[MAX_HEADERS];
+  size_t           count{0};
+
+  /// Parse a comma-separated header list into the headers array.
+  void parse(std::string_view src);
+
+  /// Return a pointer to the parsed headers array.
+  const std::string_view *
+  get_headers() const
+  {
+    return headers;
+  }
+
+  /// Return the number of parsed headers.
+  size_t
+  get_count() const
+  {
+    return count;
+  }
+};
+
 struct HttpStatsBlock {
   // Need two stats for these for counts and times
   Metrics::Counter::AtomicType *background_fill_bytes_aborted;
@@ -552,6 +592,8 @@ struct OverridableHttpConfigParams {
   MgmtByte cache_range_write              = 0;
   MgmtByte allow_multi_range              = 0;
 
+  TargetedCacheControlHeaders targeted_cache_control_headers;
+
   MgmtByte ignore_accept_mismatch          = 0;
   MgmtByte ignore_accept_language_mismatch = 0;
   MgmtByte ignore_accept_encoding_mismatch = 0;
@@ -886,7 +928,9 @@ public:
    */
   struct ParsedValue {
     std::string conf_value_storage{}; // Owns the string data.
-    std::variant<std::monostate, HostResData, HttpStatusCodeList, 
HttpForwarded::OptionBitSet, MgmtByte> parsed{};
+    std::variant<std::monostate, HostResData, HttpStatusCodeList, 
HttpForwarded::OptionBitSet, MgmtByte,
+                 TargetedCacheControlHeaders>
+      parsed{};
   };
 
   /** Return the parsed value for the configuration.
@@ -987,6 +1031,7 @@ inline HttpConfigParams::~HttpConfigParams()
   ats_free(oride.host_res_data.conf_value);
   ats_free(oride.negative_caching_list.conf_value);
   ats_free(oride.negative_revalidating_list.conf_value);
+  ats_free(oride.targeted_cache_control_headers.conf_value);
 
   delete connect_ports;
   delete redirect_actions_map;
diff --git a/include/proxy/http/OverridableConfigDefs.h 
b/include/proxy/http/OverridableConfigDefs.h
index 52440b40b1..d70c4c54ca 100644
--- a/include/proxy/http/OverridableConfigDefs.h
+++ b/include/proxy/http/OverridableConfigDefs.h
@@ -249,6 +249,7 @@
   X(HTTP_NEGATIVE_CACHING_LIST,                     negative_caching_list,     
                 "proxy.config.http.negative_caching_list",                     
   STRING, HttpStatusCodeList_Conv) \
   X(HTTP_CONNECT_ATTEMPTS_RETRY_BACKOFF_BASE,       
connect_attempts_retry_backoff_base,        
"proxy.config.http.connect_attempts_retry_backoff_base",          INT,    
GENERIC) \
   X(HTTP_NEGATIVE_REVALIDATING_LIST,                
negative_revalidating_list,                 
"proxy.config.http.negative_revalidating_list",                   STRING, 
HttpStatusCodeList_Conv) \
-  X(HTTP_CACHE_POST_METHOD,                         cache_post_method,         
                 "proxy.config.http.cache.post_method",                         
   INT,    GENERIC)
+  X(HTTP_CACHE_POST_METHOD,                         cache_post_method,         
                 "proxy.config.http.cache.post_method",                         
   INT,    GENERIC) \
+  X(HTTP_CACHE_TARGETED_CACHE_CONTROL_HEADERS,      
targeted_cache_control_headers,             
"proxy.config.http.cache.targeted_cache_control_headers",         STRING, 
TargetedCacheControlHeaders_Conv)
 
 // clang-format on
diff --git a/include/ts/apidefs.h.in b/include/ts/apidefs.h.in
index 078ce0eb69..5515aa7665 100644
--- a/include/ts/apidefs.h.in
+++ b/include/ts/apidefs.h.in
@@ -908,6 +908,7 @@ enum TSOverridableConfigKey {
   TS_CONFIG_HTTP_CONNECT_ATTEMPTS_RETRY_BACKOFF_BASE,
   TS_CONFIG_HTTP_NEGATIVE_REVALIDATING_LIST,
   TS_CONFIG_HTTP_CACHE_POST_METHOD,
+  TS_CONFIG_HTTP_CACHE_TARGETED_CACHE_CONTROL_HEADERS,
   TS_CONFIG_LAST_ENTRY,
 };
 
diff --git a/src/api/InkAPI.cc b/src/api/InkAPI.cc
index 1c832eb9dd..462d15cf38 100644
--- a/src/api/InkAPI.cc
+++ b/src/api/InkAPI.cc
@@ -7298,6 +7298,10 @@ _memberp_to_generic(MgmtFloat *ptr, MgmtConverter const 
*&conv) -> typename std:
 #define _CONF_CASE_HttpTransact_HOST_RES_CONV(KEY, MEMBER)                     
 \
   case TS_CONFIG_##KEY: ret = &overridableHttpConfig->MEMBER; conv = 
&HttpTransact::HOST_RES_CONV; break;
 
+// Custom converter: Parses/formats targeted cache control header lists.
+#define _CONF_CASE_TargetedCacheControlHeaders_Conv(KEY, MEMBER)               
 \
+  case TS_CONFIG_##KEY: ret = &overridableHttpConfig->MEMBER; conv = 
&TargetedCacheControlHeaders::Conv; break;
+
 // Dispatcher: Routes to _CONF_CASE_<CONV> based on the CONV parameter.
 #define _CONF_CASE_DISPATCH(KEY, MEMBER, RECORD_NAME, DATA_TYPE, CONV) 
_CONF_CASE_##CONV(KEY, MEMBER)
 
@@ -7348,6 +7352,7 @@ _conf_to_memberp(TSOverridableConfigKey conf, 
OverridableHttpConfigParams *overr
 #undef _CONF_CASE_ConnectionTracker_MAX_SERVER_CONV
 #undef _CONF_CASE_ConnectionTracker_SERVER_MATCH_CONV
 #undef _CONF_CASE_HttpTransact_HOST_RES_CONV
+#undef _CONF_CASE_TargetedCacheControlHeaders_Conv
 #undef _CONF_CASE_DISPATCH
 
 // 2nd little helper function to find the struct member for getting.
@@ -7561,6 +7566,22 @@ TSHttpTxnConfigStringSet(TSHttpTxn txnp, 
TSOverridableConfigKey conf, const char
       s->t_state.my_txn_conf().host_res_data = 
std::get<HostResData>(parsed.parsed);
     }
     break;
+  case TS_CONFIG_HTTP_CACHE_TARGETED_CACHE_CONTROL_HEADERS:
+    if (value && length > 0) {
+      auto &parsed = ParsedConfigCache::lookup(conf, std::string_view(value, 
length));
+      // This is intentionally a non-owning copy of the parsed representation.
+      // ParsedConfigCache::ParsedValue owns the backing string in 
conf_value_storage,
+      // and TargetedCacheControlHeaders stores string_view entries into that 
stable
+      // storage. The per-transaction override struct in HttpTransact::State 
is raw
+      // storage (not an owning/destructed HttpConfigParams object), so this 
path
+      // does not free conf_value. Reusing the cached parsed object avoids 
reparsing
+      // and avoids allocating/duplicating a second backing string on every txn
+      // override update, while preserving valid lifetimes for all string_view 
data.
+      s->t_state.my_txn_conf().targeted_cache_control_headers = 
std::get<TargetedCacheControlHeaders>(parsed.parsed);
+    } else {
+      s->t_state.my_txn_conf().targeted_cache_control_headers = 
TargetedCacheControlHeaders{};
+    }
+    break;
   default: {
     if (value && length > 0) {
       return _eval_conv(&(s->t_state.my_txn_conf()), conf, value, length);
diff --git a/src/proxy/hdrs/HdrToken.cc b/src/proxy/hdrs/HdrToken.cc
index 6e5d44cf11..712a5ed7f7 100644
--- a/src/proxy/hdrs/HdrToken.cc
+++ b/src/proxy/hdrs/HdrToken.cc
@@ -125,7 +125,10 @@ const char *const _hdrtoken_strs[] = {
   "br",
 
   // RFC-8878
-  "zstd"};
+  "zstd",
+
+  // RFC-9213 Targeted Cache Control
+  "CDN-Cache-Control"};
 
 HdrTokenTypeBinding _hdrtoken_strs_type_initializers[] = {
   {"file",                 HdrTokenType::SCHEME        },
@@ -267,6 +270,7 @@ HdrTokenFieldInfo _hdrtoken_strs_field_initializers[] = {
   {"Forwarded",                 MIME_SLOTID_NONE,                
MIME_PRESENCE_NONE,                (HdrTokenInfoFlags::COMMAS | 
HdrTokenInfoFlags::MULTVALS)                              },
   {"Sec-WebSocket-Key",         MIME_SLOTID_NONE,                
MIME_PRESENCE_NONE,                HdrTokenInfoFlags::NONE                      
                                          },
   {"Sec-WebSocket-Version",     MIME_SLOTID_NONE,                
MIME_PRESENCE_NONE,                HdrTokenInfoFlags::NONE                      
                                          },
+  {"CDN-Cache-Control",         MIME_SLOTID_NONE,                
MIME_PRESENCE_NONE,                (HdrTokenInfoFlags::COMMAS | 
HdrTokenInfoFlags::MULTVALS)                              },
   {nullptr,                     0,                               0,            
                     HdrTokenInfoFlags::NONE                                    
                            },
 };
 
diff --git a/src/proxy/hdrs/MIME.cc b/src/proxy/hdrs/MIME.cc
index 8eae5d7852..869468a8cc 100644
--- a/src/proxy/hdrs/MIME.cc
+++ b/src/proxy/hdrs/MIME.cc
@@ -3713,7 +3713,8 @@ MIMEHdrImpl::recompute_accelerators_and_presence_bits()
 ////////////////////////////////////////////////////////
 
 void
-MIMEHdrImpl::recompute_cooked_stuff(MIMEField *changing_field_or_null)
+MIMEHdrImpl::recompute_cooked_stuff(MIMEField *changing_field_or_null, const 
std::string_view *targeted_headers,
+                                    size_t targeted_headers_count)
 {
   int         len, tlen;
   const char *s;
@@ -3725,13 +3726,27 @@ MIMEHdrImpl::recompute_cooked_stuff(MIMEField 
*changing_field_or_null)
 
   mime_hdr_cooked_stuff_init(this, changing_field_or_null);
 
-  //////////////////////////////////////////////////
-  // (1) cook the Cache-Control header if present //
-  //////////////////////////////////////////////////
+  /////////////////////////////////////////////////////////////////////////////
+  // (1) cook the Cache-Control header (or targeted variant) if present     //
+  /////////////////////////////////////////////////////////////////////////////
 
   // to be safe, recompute unless you know this call is for other cooked field
   if ((changing_field_or_null == nullptr) || 
(changing_field_or_null->m_wks_idx != MIME_WKSIDX_PRAGMA)) {
-    field = mime_hdr_field_find(this, 
static_cast<std::string_view>(MIME_FIELD_CACHE_CONTROL));
+    ink_assert(targeted_headers != nullptr || targeted_headers_count == 0);
+    field = nullptr;
+
+    // Check for targeted cache control headers first (in priority order).
+    for (size_t i = 0; i < targeted_headers_count; ++i) {
+      field = mime_hdr_field_find(this, targeted_headers[i]);
+      if (field) {
+        break;
+      }
+    }
+
+    // If no targeted header was found, fall back to standard Cache-Control.
+    if (!field) {
+      field = mime_hdr_field_find(this, 
static_cast<std::string_view>(MIME_FIELD_CACHE_CONTROL));
+    }
 
     if (field) {
       // try pathpaths first -- unlike most other fastpaths, this one
diff --git a/src/proxy/http/HttpConfig.cc b/src/proxy/http/HttpConfig.cc
index 957cb0da7f..1e83c4f1eb 100644
--- a/src/proxy/http/HttpConfig.cc
+++ b/src/proxy/http/HttpConfig.cc
@@ -696,6 +696,52 @@ const MgmtConverter HttpStatusCodeList::Conv{
   }};
 // clang-format on
 
+/////////////////////////////////////////////////////////////
+//
+// TargetedCacheControlHeaders implementation
+//
+/////////////////////////////////////////////////////////////
+
+void
+TargetedCacheControlHeaders::parse(std::string_view src)
+{
+  size_t dropped = 0;
+
+  count = 0;
+  swoc::TextView config_view{src};
+
+  while (config_view) {
+    swoc::TextView header_name = 
config_view.take_prefix_at(',').trim_if(&isspace);
+    if (!header_name.empty()) {
+      if (count < MAX_HEADERS) {
+        headers[count++] = std::string_view{header_name.data(), 
header_name.size()};
+      } else {
+        ++dropped;
+      }
+    }
+  }
+
+  if (dropped > 0) {
+    Warning("Ignoring %zu headers for 
proxy.config.http.cache.targeted_cache_control_headers (maximum is %zu).", 
dropped,
+            MAX_HEADERS);
+  }
+}
+
+// clang-format off
+const MgmtConverter TargetedCacheControlHeaders::Conv{
+  [](const void *data) -> std::string_view {
+    const TargetedCacheControlHeaders *hdrs = static_cast<const 
TargetedCacheControlHeaders *>(data);
+    return hdrs->conf_value ? hdrs->conf_value : "";
+  },
+  [](void *data, std::string_view src) -> void {
+    TargetedCacheControlHeaders *hdrs = 
static_cast<TargetedCacheControlHeaders *>(data);
+    // Keep conf_value and parse source consistent; headers[] points into src.
+    // The caller is responsible for src lifetime for as long as this object 
is used.
+    hdrs->conf_value = const_cast<char *>(src.data());
+    hdrs->parse(src);
+  }};
+// clang-format on
+
 /////////////////////////////////////////////////////////////
 //
 // ParsedConfigCache implementation
@@ -786,6 +832,13 @@ ParsedConfigCache::parse(TSOverridableConfigKey key, 
std::string_view value)
     break;
   }
 
+  case TS_CONFIG_HTTP_CACHE_TARGETED_CACHE_CONTROL_HEADERS: {
+    TargetedCacheControlHeaders targeted_headers{};
+    TargetedCacheControlHeaders::Conv.store_string(&targeted_headers, 
result.conf_value_storage);
+    result.parsed = targeted_headers;
+    break;
+  }
+
   default:
     // No special parsing needed for this config.
     break;
@@ -1095,6 +1148,11 @@ HttpConfig::startup()
   HttpEstablishStaticConfigByte(c.oride.cache_required_headers, 
"proxy.config.http.cache.required_headers");
   HttpEstablishStaticConfigByte(c.oride.cache_range_lookup, 
"proxy.config.http.cache.range.lookup");
   HttpEstablishStaticConfigByte(c.oride.cache_range_write, 
"proxy.config.http.cache.range.write");
+  
HttpEstablishStaticConfigStringAlloc(c.oride.targeted_cache_control_headers.conf_value,
+                                       
"proxy.config.http.cache.targeted_cache_control_headers");
+  if (c.oride.targeted_cache_control_headers.conf_value) {
+    
c.oride.targeted_cache_control_headers.parse(c.oride.targeted_cache_control_headers.conf_value);
+  }
 
   HttpEstablishStaticConfigStringAlloc(c.connect_ports_string, 
"proxy.config.http.connect_ports");
 
@@ -1400,10 +1458,14 @@ HttpConfig::reconfigure()
   params->max_payload_iobuf_index        = m_master.max_payload_iobuf_index;
   params->max_msg_iobuf_index            = m_master.max_msg_iobuf_index;
 
-  params->oride.cache_required_headers = m_master.oride.cache_required_headers;
-  params->oride.cache_range_lookup     = 
INT_TO_BOOL(m_master.oride.cache_range_lookup);
-  params->oride.cache_range_write      = 
INT_TO_BOOL(m_master.oride.cache_range_write);
-  params->oride.allow_multi_range      = m_master.oride.allow_multi_range;
+  params->oride.cache_required_headers                    = 
m_master.oride.cache_required_headers;
+  params->oride.cache_range_lookup                        = 
INT_TO_BOOL(m_master.oride.cache_range_lookup);
+  params->oride.cache_range_write                         = 
INT_TO_BOOL(m_master.oride.cache_range_write);
+  params->oride.targeted_cache_control_headers.conf_value = 
ats_strdup(m_master.oride.targeted_cache_control_headers.conf_value);
+  if (params->oride.targeted_cache_control_headers.conf_value) {
+    
params->oride.targeted_cache_control_headers.parse(params->oride.targeted_cache_control_headers.conf_value);
+  }
+  params->oride.allow_multi_range = m_master.oride.allow_multi_range;
 
   params->connect_ports_string = ats_strdup(m_master.connect_ports_string);
   params->connect_ports        = 
parse_ports_list(params->connect_ports_string);
diff --git a/src/proxy/http/HttpSM.cc b/src/proxy/http/HttpSM.cc
index d9cfb66292..c6fd71a3cf 100644
--- a/src/proxy/http/HttpSM.cc
+++ b/src/proxy/http/HttpSM.cc
@@ -1639,7 +1639,17 @@ HttpSM::handle_api_return()
   case HttpTransact::StateMachineAction_t::API_READ_REQUEST_HDR:
   case HttpTransact::StateMachineAction_t::REQUEST_BUFFER_READ_COMPLETE:
   case HttpTransact::StateMachineAction_t::API_OS_DNS:
+    call_transact_and_set_next_state(nullptr);
+    return;
   case HttpTransact::StateMachineAction_t::API_READ_RESPONSE_HDR:
+    // Plugins may mutate response cache-control headers in this hook. Re-cook
+    // targeted cache-control after hooks so downstream cache decisions see the
+    // final header state with targeted precedence.
+    if (t_state.hdr_info.server_response.m_mime) {
+      t_state.hdr_info.server_response.m_mime->recompute_cooked_stuff(
+        nullptr, 
t_state.txn_conf->targeted_cache_control_headers.get_headers(),
+        t_state.txn_conf->targeted_cache_control_headers.get_count());
+    }
     call_transact_and_set_next_state(nullptr);
     return;
   case HttpTransact::StateMachineAction_t::API_TUNNEL_START:
@@ -2100,14 +2110,18 @@ HttpSM::state_read_server_response_header(int event, 
void *data)
   }
     // fallthrough
 
-  case ParseResult::DONE:
-
+  case ParseResult::DONE: {
     if (!t_state.hdr_info.server_response.check_hdr_implements()) {
       t_state.http_return_code = HTTPStatus::BAD_GATEWAY;
       call_transact_and_set_next_state(HttpTransact::BadRequest);
       break;
     }
 
+    // Recompute cooked cache control with targeted headers.
+    t_state.hdr_info.server_response.m_mime->recompute_cooked_stuff(nullptr,
+                                                                    
t_state.txn_conf->targeted_cache_control_headers.get_headers(),
+                                                                    
t_state.txn_conf->targeted_cache_control_headers.get_count());
+
     SMDbg(dbg_ctl_http_seq, "Done parsing server response header");
 
     // Now that we know that we have all of the origin server
@@ -2138,6 +2152,7 @@ HttpSM::state_read_server_response_header(int event, void 
*data)
       server_entry->read_vio->disable(); // Disable the read until we finish 
the tunnel
     }
     break;
+  }
   case ParseResult::CONT:
     ink_assert(server_entry->eos == false);
     server_entry->read_vio->reenable();
diff --git a/src/records/RecordsConfig.cc b/src/records/RecordsConfig.cc
index 88526c08ad..f0cefd209e 100644
--- a/src/records/RecordsConfig.cc
+++ b/src/records/RecordsConfig.cc
@@ -643,6 +643,8 @@ static constexpr RecordElement RecordsConfig[] =
   ,
   {RECT_CONFIG, "proxy.config.http.cache.range.write", RECD_INT, "0", 
RECU_NULL, RR_NULL, RECC_NULL, nullptr, RECA_NULL}
   ,
+  {RECT_CONFIG, "proxy.config.http.cache.targeted_cache_control_headers", 
RECD_STRING, "", RECU_DYNAMIC, RR_NULL, RECC_NULL, nullptr, RECA_NULL}
+  ,
 
   //        ########################
   //        # heuristic expiration #
diff --git a/tests/gold_tests/cache/replay/targeted-cache-control.replay.yaml 
b/tests/gold_tests/cache/replay/targeted-cache-control.replay.yaml
new file mode 100644
index 0000000000..90300f79e3
--- /dev/null
+++ b/tests/gold_tests/cache/replay/targeted-cache-control.replay.yaml
@@ -0,0 +1,389 @@
+#  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.
+
+# Test targeted cache control headers per RFC 9213
+# Test that CDN-Cache-Control overrides Cache-Control when configured
+
+meta:
+  version: "1.0"
+
+# Configuration section for autest integration
+autest:
+  description: 'Test targeted cache control headers per RFC 9213'
+
+  dns:
+    name: 'dns'
+
+  server:
+    name: 'server'
+
+  client:
+    name: 'client'
+
+  ats:
+    name: 'ts'
+    enable_cache: true
+
+    records_config:
+      proxy.config.diags.debug.enabled: 1
+      proxy.config.diags.debug.tags: 'http|cache'
+      proxy.config.http.cache.http: 1
+      proxy.config.http.cache.required_headers: 0
+      proxy.config.http.cache.targeted_cache_control_headers: 
'ATS-Cache-Control,CDN-Cache-Control'
+
+    remap_config:
+      - from: "http://example.com/";
+        to: "http://backend.example.com:{SERVER_HTTP_PORT}/";
+
+      - from: "http://acme.com/";
+        to: "http://backend.acme.com:{SERVER_HTTP_PORT}/";
+        plugins:
+          - name: "conf_remap.so"
+            args:
+              - 
"proxy.config.http.cache.targeted_cache_control_headers=ACME-Cache-Control"
+
+
+sessions:
+- transactions:
+
+  #############################################################################
+  # Test 1: CDN-Cache-Control with higher max-age overrides Cache-Control
+  #############################################################################
+  - client-request:
+      method: GET
+      url: /targeted/test1
+      version: '1.1'
+      headers:
+        fields:
+        - [Host, example.com]
+        - [uuid, targeted-test1-request1]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [Content-Type, text/plain]
+        - [Content-Length, "14"]
+        - [Cache-Control, "max-age=1"]
+        - [CDN-Cache-Control, "max-age=30"] # Should be used.
+        - [Connection, close]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ Cache-Control, { value: "max-age=1", as: equal } ]
+        - [ CDN-Cache-Control, { value: "max-age=30", as: equal } ]
+
+  #############################################################################
+  # Test 2: Priority order - first targeted header wins (verify lowercase).
+  #############################################################################
+  - client-request:
+      method: GET
+      url: /targeted/test2
+      version: '1.1'
+      headers:
+        fields:
+        - [Host, example.com]
+        - [uuid, targeted-test2-request1]
+
+    # Test lowercase *cache-control.
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [Content-Type, text/plain]
+        - [Content-Length, "14"]
+        - [cache-control, "no-store"]
+        - [cdn-cache-control, "no-store"]
+        - [ats-cache-control, "max-age=30"]  # Should take precedence. Test 
lowercase.
+        - [Connection, close]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ cache-control, { value: "no-store", as: equal } ]
+        - [ cdn-cache-control, { value: "no-store", as: equal } ]
+        - [ ats-cache-control, { value: "max-age=30", as: equal } ]
+
+
+  #############################################################################
+  # Test 3: no-store in targeted header
+  #############################################################################
+  - client-request:
+      method: GET
+      url: /targeted/test3
+      version: '1.1'
+      headers:
+        fields:
+        - [Host, example.com]
+        - [uuid, targeted-test3-request1]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [Content-Type, text/plain]
+        - [Content-Length, "14"]
+        - [Cache-Control, "max-age=3600"]
+        - [CDN-Cache-Control, "no-store"] # Should be used.
+        - [Connection, close]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ Cache-Control, { value: "max-age=3600", as: equal } ]
+        - [ CDN-Cache-Control, { value: "no-store", as: equal } ]
+
+  #############################################################################
+  # Test 4: Vanilla Cache-Control should still function.
+  #############################################################################
+  - client-request:
+      method: GET
+      url: /targeted/test4
+      version: '1.1'
+      headers:
+        fields:
+        - [Host, example.com]
+        - [uuid, targeted-test4-request1]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [Content-Type, text/plain]
+        - [Content-Length, "14"]
+        - [Cache-Control, "max-age=30"]
+        - [Connection, close]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ Cache-Control, { value: "max-age=30", as: equal } ]
+
+
+  #############################################################################
+  # Test 5: conf_remap.so override with ACME-Cache-Control
+  #############################################################################
+  - client-request:
+      method: GET
+      url: /acme/test1
+      version: '1.1'
+      headers:
+        fields:
+        - [Host, acme.com]
+        - [uuid, acme-test1-request1]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [Content-Type, text/plain]
+        - [Content-Length, "14"]
+        - [Cache-Control, "no-store"]
+        - [CDN-Cache-Control, "no-store"]
+        - [ATS-Cache-Control, "no-store"]
+        - [ACME-Cache-Control, "max-age=30"]  # Should be used due to override
+        - [Connection, close]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ Cache-Control, { value: "no-store", as: equal } ]
+        - [ CDN-Cache-Control, { value: "no-store", as: equal } ]
+        - [ ATS-Cache-Control, { value: "no-store", as: equal } ]
+        - [ ACME-Cache-Control, { value: "max-age=30", as: equal } ]
+
+  #############################################################################
+  # Test 6: Verify ACME-Cache-Control override works (no-cache)
+  #############################################################################
+  - client-request:
+      method: GET
+      url: /acme/test2
+      version: '1.1'
+      headers:
+        fields:
+        - [Host, acme.com]
+        - [uuid, acme-test2-request1]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [Content-Type, text/plain]
+        - [Content-Length, "14"]
+        - [Cache-Control, "max-age=3600"]
+        - [ACME-Cache-Control, "no-store"]  # Should prevent caching
+        - [Connection, close]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ Cache-Control, { value: "max-age=3600", as: equal } ]
+        - [ ACME-Cache-Control, { value: "no-store", as: equal } ]
+
+  #############################################################################
+  # Now verify the correct cache behavior from above.
+  #############################################################################
+  - client-request:
+      # Delay to exceed Cache-Control but not CDN-Cache-Control.
+      delay: 2s
+
+      method: GET
+      url: /targeted/test1
+      version: '1.1'
+      headers:
+        fields:
+        - [Host, example.com]
+        - [uuid, targeted-test1-request2]
+
+    # Should not reach the origin.
+    server-response:
+      status: 404
+      reason: Not Found
+
+    # Expect the cached 200 response.
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ Cache-Control, { value: "max-age=1", as: equal } ]
+        - [ CDN-Cache-Control, { value: "max-age=30", as: equal } ]
+
+  - client-request:
+      method: GET
+      url: /targeted/test2
+      version: '1.1'
+      headers:
+        fields:
+        - [Host, example.com]
+        - [uuid, targeted-test2-request2]
+
+    # Should not reach the origin.
+    server-response:
+      status: 404
+      reason: Not Found
+
+    # Expect the cached 200 response since ATS-Cache-Control takes precedence
+    # over CDN-Cache-Control and Cache-Control.
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ Cache-Control, { value: "no-store", as: equal } ]
+        - [ CDN-Cache-Control, { value: "no-store", as: equal } ]
+        - [ ATS-Cache-Control, { value: "max-age=30", as: equal } ]
+
+  - client-request:
+      method: GET
+      url: /targeted/test3
+      version: '1.1'
+      headers:
+        fields:
+        - [Host, example.com]
+        - [uuid, targeted-test3-request2]
+
+    # Since CDN-Cache-Control is no-store, the request should reach the origin.
+    server-response:
+      status: 404
+      reason: Not Found
+
+    proxy-response:
+      status: 404
+      headers:
+        fields:
+        - [ Cache-Control, { as: absent } ]
+        - [ CDN-Cache-Control, { as: absent } ]
+
+  - client-request:
+      method: GET
+      url: /targeted/test4
+      version: '1.1'
+      headers:
+        fields:
+        - [Host, example.com]
+        - [uuid, targeted-test4-request2]
+
+    # Should not reach the origin.
+    server-response:
+      status: 404
+      reason: Not Found
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ Cache-Control, { value: "max-age=30", as: equal } ]
+
+  # Verify that ACME-Cache-Control: max-age=30 allowed caching.
+  - client-request:
+      method: GET
+      url: /acme/test1
+      version: '1.1'
+      headers:
+        fields:
+        - [Host, acme.com]
+        - [uuid, acme-test1-request2]
+
+    # The origin should not receive the request since ATS will reply out of 
cache.
+    server-response:
+      status: 404
+      reason: Not Found
+
+    # Expect the cached 200 response.
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ Cache-Control, { value: "no-store", as: equal } ]
+        - [ CDN-Cache-Control, { value: "no-store", as: equal } ]
+        - [ ATS-Cache-Control, { value: "no-store", as: equal } ]
+        - [ ACME-Cache-Control, { value: "max-age=30", as: equal } ]
+
+  # On the other hand, verify that ACME-Cache-Control: no-store prevents 
caching.
+  - client-request:
+      method: GET
+      url: /acme/test2
+      version: '1.1'
+      headers:
+        fields:
+        - [Host, acme.com]
+        - [uuid, acme-test2-request2]
+
+    server-response:
+      status: 404
+      reason: Not Found
+
+    # Expect the 404 from the origin server, not the cached 200 response.
+    proxy-response:
+      status: 404
+      headers:
+        fields:
+        - [ Cache-Control, { as: absent } ]
+        - [ CDN-Cache-Control, { as: absent } ]
+        - [ ATS-Cache-Control, { as: absent } ]
+        - [ ACME-Cache-Control, { as: absent } ]
diff --git a/tests/gold_tests/cache/targeted-cache-control.test.py 
b/tests/gold_tests/cache/targeted-cache-control.test.py
new file mode 100644
index 0000000000..3bcd84047b
--- /dev/null
+++ b/tests/gold_tests/cache/targeted-cache-control.test.py
@@ -0,0 +1,25 @@
+'''Test targeted cache control headers per RFC 9213.'''
+
+#  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.
+
+Test.Summary = '''
+Test targeted cache control headers per RFC 9213.
+Verifies that CDN-Cache-Control and other targeted headers can override
+standard Cache-Control when properly configured.
+'''
+
+Test.ATSReplayTest(replay_file="replay/targeted-cache-control.replay.yaml")


Reply via email to