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")