From b0098fb6cf08fee30d64a1d1e828b81cb694f3d3 Mon Sep 17 00:00:00 2001 From: bneradt Date: Thu, 13 Aug 2026 14:53:38 -0500 Subject: [PATCH 1/2] Add percentage limit for stale cache age A fixed stale-age window can let short-lived cached responses remain usable for many times their original freshness lifetime. This makes serving stale content disproportionately risky for responses with a short max-age. This adds an optional percentage limit and applies the smaller of the percentage-based and absolute stale-age windows. The default keeps the existing behavior, while transaction overrides and supported scripting interfaces can opt in per use case. Fixes: #12252 --- doc/admin-guide/files/records.yaml.en.rst | 49 ++++++-- doc/admin-guide/plugins/lua.en.rst | 1 + .../functions/TSHttpOverridableConfig.en.rst | 1 + .../api/types/TSOverridableConfigKey.en.rst | 1 + include/cripts/Configs.hpp | 1 + include/proxy/http/HttpConfig.h | 1 + include/proxy/http/OverridableConfigDefs.h | 3 +- include/ts/apidefs.h.in | 1 + src/proxy/http/HttpConfig.cc | 4 +- src/proxy/http/HttpTransact.cc | 14 ++- src/records/RecordsConfig.cc | 6 +- .../cache/proxy_serve_stale.test.py | 3 + .../replay/max_stale_age_percent.replay.yaml | 117 ++++++++++++++++++ .../gold_tests/records/gold/full_records.yaml | 1 + .../records/legacy_config/full_records.config | 1 + 15 files changed, 186 insertions(+), 18 deletions(-) create mode 100644 tests/gold_tests/cache/replay/max_stale_age_percent.replay.yaml diff --git a/doc/admin-guide/files/records.yaml.en.rst b/doc/admin-guide/files/records.yaml.en.rst index 25c4bec22ef..12a1a4d46c2 100644 --- a/doc/admin-guide/files/records.yaml.en.rst +++ b/doc/admin-guide/files/records.yaml.en.rst @@ -2032,9 +2032,10 @@ Origin Server Connect Attempts :overridable: Specifies how long (in seconds) |TS| remembers that an origin server was unreachable. - During this window, if a stale cached response exists and its age is within the cached response's ``max-age`` plus - :ts:cv:`proxy.config.http.cache.max_stale_age`, |TS| serves the stale content directly - without attempting to contact the origin server. + During this window, if a stale cached response exists and its age is within the limits configured by + :ts:cv:`proxy.config.http.cache.max_stale_age` and + :ts:cv:`proxy.config.http.cache.max_stale_age_percent`, |TS| serves the stale content directly without attempting + to contact the origin server. .. ts:cv:: CONFIG proxy.config.http.uncacheable_requests_bypass_parent INT 1 :reloadable: @@ -2128,8 +2129,8 @@ Negative Response Caching current stale content is preserved and served. Note this is considered only on a revalidation of already cached content. A revalidation failure means a connection failure or a 50x response code. When considering replying with a stale response in these negative revalidating circumstances, - |TS| will respect the :ts:cv:`proxy.config.http.cache.max_stale_age` configuration and will not - use a cached response older than ``max_stale_age`` seconds plus ``max-age`` of cached content. + |TS| will respect the :ts:cv:`proxy.config.http.cache.max_stale_age` and + :ts:cv:`proxy.config.http.cache.max_stale_age_percent` limits. A value of ``0`` disables serving stale content and a value of ``1`` enables keeping and serving stale content if revalidation fails. @@ -2155,7 +2156,8 @@ Negative Response Caching behavior with regard to whether it considers a stale object to be fresh enough to serve out of cache when revalidation fails. As mentioned above in :ts:cv:`proxy.config.http.negative_revalidating_enabled`, - :ts:cv:`proxy.config.http.cache.max_stale_age` is used for that determination. + :ts:cv:`proxy.config.http.cache.max_stale_age` and + :ts:cv:`proxy.config.http.cache.max_stale_age_percent` are used for that determination. This configuration defaults to 1,800 seconds (30 minutes). @@ -2654,7 +2656,26 @@ Cache Control :reloadable: :overridable: - The maximum age in seconds allowed for a stale response before it cannot be cached. + The maximum number of seconds that a stale response can be served after its ``max-age`` freshness + lifetime has passed. + + When :ts:cv:`proxy.config.http.cache.max_stale_age_percent` is enabled, the smaller of the + absolute and percentage limits is used. + +.. ts:cv:: CONFIG proxy.config.http.cache.max_stale_age_percent INT 0 + :reloadable: + :overridable: + + Limits the stale response lifetime to a percentage of its ``max-age`` freshness lifetime. The + valid range is ``0`` to ``100``. A value of ``0`` disables the percentage limit. When enabled, + |TS| uses the smaller of this limit and + :ts:cv:`proxy.config.http.cache.max_stale_age`. + + For example, a value of ``25`` allows a response with a one-hour freshness lifetime to be served + stale for up to 15 minutes, provided that the absolute stale age limit is at least 15 minutes. + + Responses without ``max-age`` are controlled by :ts:cv:`proxy.config.http.cache.max_stale_age` + alone. .. ts:cv:: CONFIG proxy.config.http.cache.guaranteed_min_lifetime INT 0 :reloadable: @@ -3241,13 +3262,15 @@ Dynamic Content & Content Negotiation ===== ====================================================================== ``0`` Default. Disable cache and go to origin server. ``1`` Return a ``502`` error on a cache miss. - ``2`` Serve stale if object's age is under ``max-age`` + - :ts:cv:`proxy.config.http.cache.max_stale_age`. Otherwise, go to - origin server. + ``2`` Serve stale if the object's age is within the + :ts:cv:`proxy.config.http.cache.max_stale_age` and + :ts:cv:`proxy.config.http.cache.max_stale_age_percent` limits. + Otherwise, go to the origin server. ``3`` Return a ``502`` error on a cache miss or serve stale on a cache - revalidate if object's age is under ``max-age`` + - :ts:cv:`proxy.config.http.cache.max_stale_age`. Otherwise, go to - origin server. + revalidate if the object's age is within the + :ts:cv:`proxy.config.http.cache.max_stale_age` and + :ts:cv:`proxy.config.http.cache.max_stale_age_percent` limits. + Otherwise, go to the origin server. ``4`` Return a ``502`` error on either a cache miss or on a revalidation. ``5`` Retry Cache Read on a Cache Write Lock failure. This option together with :ts:cv:`proxy.config.cache.enable_read_while_writer` configuration diff --git a/doc/admin-guide/plugins/lua.en.rst b/doc/admin-guide/plugins/lua.en.rst index c5e7f784ce3..0716e89c115 100644 --- a/doc/admin-guide/plugins/lua.en.rst +++ b/doc/admin-guide/plugins/lua.en.rst @@ -4822,6 +4822,7 @@ Http config constants 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_HTTP_CACHE_MAX_STALE_AGE_PERCENT TS_LUA_CONFIG_LAST_ENTRY :ref:`TOP ` diff --git a/doc/developer-guide/api/functions/TSHttpOverridableConfig.en.rst b/doc/developer-guide/api/functions/TSHttpOverridableConfig.en.rst index 43d98b40ad7..19e992d2599 100644 --- a/doc/developer-guide/api/functions/TSHttpOverridableConfig.en.rst +++ b/doc/developer-guide/api/functions/TSHttpOverridableConfig.en.rst @@ -199,6 +199,7 @@ TSOverridableConfigKey Value Confi :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` +:enumerator:`TS_CONFIG_HTTP_CACHE_MAX_STALE_AGE_PERCENT` :ts:cv:`proxy.config.http.cache.max_stale_age_percent` ======================================================================== ==================================================================== Examples diff --git a/doc/developer-guide/api/types/TSOverridableConfigKey.en.rst b/doc/developer-guide/api/types/TSOverridableConfigKey.en.rst index 4ce7c1712d6..d9ae419c296 100644 --- a/doc/developer-guide/api/types/TSOverridableConfigKey.en.rst +++ b/doc/developer-guide/api/types/TSOverridableConfigKey.en.rst @@ -166,6 +166,7 @@ Enumeration Members .. enumerator:: TS_CONFIG_HTTP_CACHE_IGNORE_QUERY .. enumerator:: TS_CONFIG_HTTP_CACHE_POST_METHOD .. enumerator:: TS_CONFIG_HTTP_CACHE_TARGETED_CACHE_CONTROL_HEADERS +.. enumerator:: TS_CONFIG_HTTP_CACHE_MAX_STALE_AGE_PERCENT Description diff --git a/include/cripts/Configs.hpp b/include/cripts/Configs.hpp index 79460327e7d..013f84a9d92 100644 --- a/include/cripts/Configs.hpp +++ b/include/cripts/Configs.hpp @@ -101,6 +101,7 @@ class Proxy cripts::IntConfig max_open_write_retries{"proxy.config.http.cache.max_open_write_retries"}; cripts::IntConfig max_open_write_retry_timeout{"proxy.config.http.cache.max_open_write_retry_timeout"}; cripts::IntConfig max_stale_age{"proxy.config.http.cache.max_stale_age"}; + cripts::IntConfig max_stale_age_percent{"proxy.config.http.cache.max_stale_age_percent"}; cripts::IntConfig open_read_retry_time{"proxy.config.http.cache.open_read_retry_time"}; cripts::IntConfig open_write_fail_action{"proxy.config.http.cache.open_write_fail_action"}; diff --git a/include/proxy/http/HttpConfig.h b/include/proxy/http/HttpConfig.h index 5bf58f7d7d0..887ac1cb642 100644 --- a/include/proxy/http/HttpConfig.h +++ b/include/proxy/http/HttpConfig.h @@ -705,6 +705,7 @@ struct OverridableHttpConfigParams { MgmtInt cache_guaranteed_min_lifetime = 0; MgmtInt cache_guaranteed_max_lifetime = 31536000; MgmtInt cache_max_stale_age = 604800; + MgmtInt cache_max_stale_age_percent = 0; /////////////////////////////////////////////////// // connection variables. timeouts are in seconds // diff --git a/include/proxy/http/OverridableConfigDefs.h b/include/proxy/http/OverridableConfigDefs.h index bf00fc3def9..864461b1c8f 100644 --- a/include/proxy/http/OverridableConfigDefs.h +++ b/include/proxy/http/OverridableConfigDefs.h @@ -252,6 +252,7 @@ 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_TARGETED_CACHE_CONTROL_HEADERS, targeted_cache_control_headers, "proxy.config.http.cache.targeted_cache_control_headers", STRING, TargetedCacheControlHeaders_Conv) \ - X(SSL_CLIENT_CA_CERT_PATH, ssl_client_ca_cert_path, "proxy.config.ssl.client.CA.cert.path", STRING, NONE) + X(SSL_CLIENT_CA_CERT_PATH, ssl_client_ca_cert_path, "proxy.config.ssl.client.CA.cert.path", STRING, NONE) \ + X(HTTP_CACHE_MAX_STALE_AGE_PERCENT, cache_max_stale_age_percent, "proxy.config.http.cache.max_stale_age_percent", INT, GENERIC) // clang-format on diff --git a/include/ts/apidefs.h.in b/include/ts/apidefs.h.in index f34ffdb1a3f..8e110942b01 100644 --- a/include/ts/apidefs.h.in +++ b/include/ts/apidefs.h.in @@ -918,6 +918,7 @@ enum TSOverridableConfigKey { TS_CONFIG_HTTP_CACHE_POST_METHOD, TS_CONFIG_HTTP_CACHE_TARGETED_CACHE_CONTROL_HEADERS, TS_CONFIG_SSL_CLIENT_CA_CERT_PATH, + TS_CONFIG_HTTP_CACHE_MAX_STALE_AGE_PERCENT, TS_CONFIG_LAST_ENTRY, }; diff --git a/src/proxy/http/HttpConfig.cc b/src/proxy/http/HttpConfig.cc index f0b1b535c32..08a3ed23633 100644 --- a/src/proxy/http/HttpConfig.cc +++ b/src/proxy/http/HttpConfig.cc @@ -1109,6 +1109,7 @@ HttpConfig::startup() HttpEstablishStaticConfigLongLong(c.oride.cache_guaranteed_max_lifetime, "proxy.config.http.cache.guaranteed_max_lifetime"); HttpEstablishStaticConfigLongLong(c.oride.cache_max_stale_age, "proxy.config.http.cache.max_stale_age"); + HttpEstablishStaticConfigLongLong(c.oride.cache_max_stale_age_percent, "proxy.config.http.cache.max_stale_age_percent"); HttpEstablishStaticConfigByte(c.oride.srv_enabled, "proxy.config.srv_enabled"); @@ -1456,7 +1457,8 @@ HttpConfig::reconfigure() params->oride.cache_guaranteed_min_lifetime = m_master.oride.cache_guaranteed_min_lifetime; params->oride.cache_guaranteed_max_lifetime = m_master.oride.cache_guaranteed_max_lifetime; - params->oride.cache_max_stale_age = m_master.oride.cache_max_stale_age; + params->oride.cache_max_stale_age = m_master.oride.cache_max_stale_age; + params->oride.cache_max_stale_age_percent = m_master.oride.cache_max_stale_age_percent; params->oride.srv_enabled = m_master.oride.srv_enabled; diff --git a/src/proxy/http/HttpTransact.cc b/src/proxy/http/HttpTransact.cc index 79e9fc5fa26..e638ed14a4c 100644 --- a/src/proxy/http/HttpTransact.cc +++ b/src/proxy/http/HttpTransact.cc @@ -6396,8 +6396,20 @@ HttpTransact::is_stale_cache_response_returnable(State *s) time_t current_age = HttpTransactCache::calculate_document_age(s->cache_info.object_read->request_sent_time_get(), s->cache_info.object_read->response_received_time_get(), cached_response, cached_response->get_date(), s->current.now); + + MgmtInt max_age = get_max_age(cached_response); + MgmtInt max_stale_age = s->txn_conf->cache_max_stale_age; + MgmtInt max_stale_percentage = + std::clamp(s->txn_conf->cache_max_stale_age_percent, static_cast(0), static_cast(100)); + + if (max_stale_percentage > 0 && max_age >= 0) { + MgmtInt percent_max_stale_age = max_age * max_stale_percentage / 100; + + max_stale_age = std::min(max_stale_age, percent_max_stale_age); + } + // Negative age is overflow - if ((current_age < 0) || (current_age > s->txn_conf->cache_max_stale_age + get_max_age(cached_response))) { + if ((current_age < 0) || (current_age > max_age + max_stale_age)) { TxnDbg(dbg_ctl_http_trans, "document age is too large %" PRId64, (int64_t)current_age); return false; } diff --git a/src/records/RecordsConfig.cc b/src/records/RecordsConfig.cc index f011c0ccd74..1eec9cce822 100644 --- a/src/records/RecordsConfig.cc +++ b/src/records/RecordsConfig.cc @@ -633,8 +633,8 @@ static constexpr RecordElement RecordsConfig[] = // # // # 0 - default. disable cache and goto origin // # 1 - return error if cache miss - // # 2 - serve stale until proxy.config.http.cache.max_stale_age, then goto origin, if revalidate - // # 3 - return error if cache miss or serve stale until proxy.config.http.cache.max_stale_age, then goto origin, if revalidate + // # 2 - serve stale within the configured stale age limits, then goto origin, if revalidate + // # 3 - return error if cache miss or serve stale within the configured stale age limits, then goto origin, if revalidate // # 4 - return error if cache miss or if revalidate // # 5 - retry cache read (read-while-writer) on write lock failure, goto origin if retries exhausted // # 6 - retry cache read on write lock failure, if retries exhausted serve stale if allowed, otherwise goto origin @@ -660,6 +660,8 @@ static constexpr RecordElement RecordsConfig[] = , {RECT_CONFIG, "proxy.config.http.cache.max_stale_age", RECD_INT, "604800", RECU_DYNAMIC, RR_NULL, RECC_NULL, nullptr, RECA_NULL} , + {RECT_CONFIG, "proxy.config.http.cache.max_stale_age_percent", RECD_INT, "0", RECU_DYNAMIC, RR_NULL, RECC_INT, "[0-100]", RECA_NULL} + , {RECT_CONFIG, "proxy.config.http.cache.range.lookup", RECD_INT, "1", RECU_NULL, RR_NULL, RECC_NULL, nullptr, RECA_NULL} , {RECT_CONFIG, "proxy.config.http.cache.range.write", RECD_INT, "0", RECU_NULL, RR_NULL, RECC_NULL, nullptr, RECA_NULL} diff --git a/tests/gold_tests/cache/proxy_serve_stale.test.py b/tests/gold_tests/cache/proxy_serve_stale.test.py index 95e620a2046..3ff64c7af33 100644 --- a/tests/gold_tests/cache/proxy_serve_stale.test.py +++ b/tests/gold_tests/cache/proxy_serve_stale.test.py @@ -23,5 +23,8 @@ # Verify that stale content is served when the parent is down. Test.ATSReplayTest(replay_file="replay/proxy_serve_stale.replay.yaml") +# Verify that stale content respects the percentage maximum stale age. +Test.ATSReplayTest(replay_file="replay/max_stale_age_percent.replay.yaml") + # Verify that stale content is served when the origin server is down. Test.ATSReplayTest(replay_file="replay/proxy_serve_stale_origin_down.replay.yaml") diff --git a/tests/gold_tests/cache/replay/max_stale_age_percent.replay.yaml b/tests/gold_tests/cache/replay/max_stale_age_percent.replay.yaml new file mode 100644 index 00000000000..f2f0174018d --- /dev/null +++ b/tests/gold_tests/cache/replay/max_stale_age_percent.replay.yaml @@ -0,0 +1,117 @@ +# 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. + +meta: + version: "1.0" + + blocks: + + - origin_response: &origin_response + server-response: + status: 500 + reason: "Internal Server Error" + headers: + fields: + - [ Content-Length, 16 ] + - [ X-Response, should_not_see ] + +autest: + description: 'Verify the percentage maximum stale age limit' + + dns: + name: 'dns-max-stale-age-percent' + + server: + name: 'server-max-stale-age-percent' + + client: + name: 'client-max-stale-age-percent' + + ats: + name: 'ts-max-stale-age-percent' + process_config: + enable_cache: true + + records_config: + proxy.config.http.push_method_enabled: 1 + proxy.config.http.parent_proxy.fail_threshold: 1 + proxy.config.http.parent_proxy.total_connect_attempts: 1 + proxy.config.http.cache.max_stale_age: 100 + proxy.config.http.cache.max_stale_age_percent: 50 + proxy.config.http.parent_proxy.self_detect: 0 + + parent_config: + - 'dest_domain=. parent="localhost:82" round_robin=consistent_hash go_direct=false' + + remap_config: + - from: "http://example.com/" + to: "http://localhost:{SERVER_HTTP_PORT}/" + +sessions: +- transactions: + + # Populate the cache with a four-second freshness lifetime. + - client-request: + method: "PUSH" + version: "1.1" + url: /percentage + headers: + fields: + - [ Host, example.com ] + - [ uuid, push ] + - [ Content-Length, 73 ] + content: + encoding: plain + data: "HTTP/1.1 200 OK\nContent-Length: 6\nCache-Control: public,max-age=4\n\nCACHED" + + <<: *origin_response + + proxy-response: + status: 201 + + # At five seconds old, the object is stale but remains within the two-second + # percentage stale window. + - client-request: + delay: 5s + method: "GET" + version: "1.1" + url: /percentage + headers: + fields: + - [ Host, example.com ] + - [ uuid, within_percentage_limit ] + + <<: *origin_response + + proxy-response: + status: 200 + + # At eight seconds old, the object exceeds max-age (4 seconds) plus 50% + # (2 seconds), even though the absolute stale age limit is 100 seconds. + - client-request: + delay: 3s + method: "GET" + version: "1.1" + url: /percentage + headers: + fields: + - [ Host, example.com ] + - [ uuid, past_percentage_limit ] + + <<: *origin_response + + proxy-response: + status: 502 diff --git a/tests/gold_tests/records/gold/full_records.yaml b/tests/gold_tests/records/gold/full_records.yaml index 1123e2cbb12..d1502ce4b8e 100644 --- a/tests/gold_tests/records/gold/full_records.yaml +++ b/tests/gold_tests/records/gold/full_records.yaml @@ -163,6 +163,7 @@ records: max_open_read_retries: -1 max_open_write_retries: 1 max_stale_age: 604800 + max_stale_age_percent: 0 open_read_retry_time: 10 open_write_fail_action: 0 post_method: 0 diff --git a/tests/gold_tests/records/legacy_config/full_records.config b/tests/gold_tests/records/legacy_config/full_records.config index d7b640dae32..128ce0c8623 100644 --- a/tests/gold_tests/records/legacy_config/full_records.config +++ b/tests/gold_tests/records/legacy_config/full_records.config @@ -183,6 +183,7 @@ CONFIG proxy.config.http.cache.open_write_fail_action INT 0 CONFIG proxy.config.http.cache.when_to_revalidate INT 0 CONFIG proxy.config.http.cache.required_headers INT 2 CONFIG proxy.config.http.cache.max_stale_age INT 604800 +CONFIG proxy.config.http.cache.max_stale_age_percent INT 0 CONFIG proxy.config.http.cache.range.lookup INT 1 CONFIG proxy.config.http.cache.range.write INT 0 CONFIG proxy.config.http.cache.heuristic_min_lifetime INT 3600 From 0bdc03073fe6703f50a280933cd5080bd434bc80 Mon Sep 17 00:00:00 2001 From: bneradt Date: Thu, 13 Aug 2026 15:04:40 -0500 Subject: [PATCH 2/2] Clarify zero stale age percentage A zero percentage can be mistaken for disabling stale responses, when it actually leaves the absolute stale-age limit in control. This clarifies the behavior with a concrete example using the default seven-day absolute limit. --- doc/admin-guide/files/records.yaml.en.rst | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/doc/admin-guide/files/records.yaml.en.rst b/doc/admin-guide/files/records.yaml.en.rst index 12a1a4d46c2..28afb39afc7 100644 --- a/doc/admin-guide/files/records.yaml.en.rst +++ b/doc/admin-guide/files/records.yaml.en.rst @@ -2671,8 +2671,13 @@ Cache Control |TS| uses the smaller of this limit and :ts:cv:`proxy.config.http.cache.max_stale_age`. - For example, a value of ``25`` allows a response with a one-hour freshness lifetime to be served - stale for up to 15 minutes, provided that the absolute stale age limit is at least 15 minutes. + For example, a value of ``25`` allows a response with a one-hour freshness lifetime + (``Cache-Control: max-age=3600``) to be served stale for up to 15 minutes (25% of one hour), + provided that the absolute stale age limit is at least 15 minutes. + + A value of ``0`` applies no percentage limit. For example, with the default + :ts:cv:`proxy.config.http.cache.max_stale_age` of seven days, a response with a one-hour + freshness lifetime can be served stale for up to seven days after that freshness lifetime ends. Responses without ``max-age`` are controlled by :ts:cv:`proxy.config.http.cache.max_stale_age` alone.