From 475a69d2241e863e5e66b4669a4b84097c8782b3 Mon Sep 17 00:00:00 2001 From: Maxime Henrion Date: Tue, 30 Jun 2026 11:33:37 -0400 Subject: [PATCH] MEDIUM: cache: add support for hints-only HTTP caches The "early-hints" option can now be set to "only", in which case the cache will only store data relevant to the emission of 103 Early Hints responses. Hint records are extracted on the fly from the live response HTX rather than going through a full-storage-then-strip cycle: the entry is created as CACHE_EF_STRIPPED directly, with hint records written past the cache_entry header at storage time. Response bodies are never stored. Because entries are stripped from the start, eviction in this mode never has to strip a full entry; the full LRU stays empty and cache_make_room() falls through to evicting the oldest stripped entry when the cache fills up. The max-object-size check (which gates caching based on the upstream- announced Content-Length) is bypassed in this mode: the actual stored entry is just the hint records (typically under 1 KB), so applying it to the body size would needlessly discard hints for arbitrarily large responses. As a hints-only entry is never served as a response, the backend response is forwarded unchanged in this mode: in particular its Age header is kept rather than stripped as it is for a stored response. --- doc/configuration.txt | 13 ++- reg-tests/cache/early_hints_only.vtc | 161 +++++++++++++++++++++++++++ src/cache.c | 121 ++++++++++++++------ 3 files changed, 261 insertions(+), 34 deletions(-) create mode 100644 reg-tests/cache/early_hints_only.vtc diff --git a/doc/configuration.txt b/doc/configuration.txt index 3ced6c90e..e1c5b0e8b 100644 --- a/doc/configuration.txt +++ b/doc/configuration.txt @@ -20249,7 +20249,7 @@ cache Declare a cache section, allocate a shared cache memory named , the size of cache is mandatory (see keyword "total-max-size" below). -early-hints [ratio ] +early-hints [ratio ] Enable or disable support for HTTP 103 Early Hints responses (see RFC 8297). When enabled, the cache remembers relevant "Link" response headers (with a "rel" parameter of preload, preconnect, dns-prefetch, modulepreload or @@ -20261,11 +20261,20 @@ early-hints [ratio ] Individual "cache-use" rules may opt out of this behavior with the "no-early-hints" keyword. The default value is off (disabled). + When "early-hints" is set to "only", no responses are stored in the cache. + Instead, if a backend response contains relevant "Link" headers as defined + above, the cache stores those in order to emit a 103 Early Hints response if + that same URL is accessed later. Since the response body is never stored in + this mode, the "max-object-size" limit does not apply: hints are extracted + and stored regardless of the response's size. + The optional "ratio" argument sets the percentage of total cache blocks that may be reserved for hints-only entries (1 to 99, default 25). Higher values keep hints alive longer at the cost of less space for full responses; lower values prioritize full responses. This argument only makes sense when - "early-hints" is set to on, and is ignored otherwise. + "early-hints" is set to on, and is ignored otherwise. In "only" mode every + stored entry is a hints entry, so the whole cache is available to them and + this ratio has no practical effect. max-age Define the maximum expiration duration. The expiration is set as the lowest diff --git a/reg-tests/cache/early_hints_only.vtc b/reg-tests/cache/early_hints_only.vtc new file mode 100644 index 000000000..8e468fefc --- /dev/null +++ b/reg-tests/cache/early_hints_only.vtc @@ -0,0 +1,161 @@ +varnishtest "Cache HTTP 103 Early Hints: 'only' mode" +# Verify the "early-hints only" cache option. In this mode: +# - The cache never stores response bodies. Every request reaches the +# backend, even if a hints entry already exists for the URL. +# - Hint-worthy Link headers seen on the first response are extracted +# and stored as a CACHE_EF_STRIPPED hints entry, ready to be emitted +# as a 103 Early Hints response on subsequent requests for that URL. +# - Responses with no hint-worthy Link header are not cached at all, +# so subsequent requests for those URLs also hit the backend and +# emit no 103. +# - The "max-object-size" check is bypassed (the actual stored entry +# is just the hint records, regardless of the announced body size). + +feature ignore_unknown_macro +feature cmd "command -v socat" + +server s1 { + # /hints-url is requested twice: once to populate the hints entry, + # once to confirm the body still comes from the backend on replay. + rxreq + expect req.url == "/hints-url" + txresp \ + -hdr "Cache-Control: public, max-age=300" \ + -hdr "Link: ; rel=preload; as=style" \ + -bodylen 1000 + + # /no-hints-url uses a non-hint-worthy rel. Both requests must + # reach the backend because nothing gets cached for this URL. + rxreq + expect req.url == "/no-hints-url" + txresp \ + -hdr "Cache-Control: public, max-age=300" \ + -hdr "Link: ; rel=author" \ + -bodylen 1000 + + rxreq + expect req.url == "/hints-url" + txresp \ + -hdr "Cache-Control: public, max-age=300" \ + -hdr "Link: ; rel=preload; as=style" \ + -bodylen 1000 + + rxreq + expect req.url == "/no-hints-url" + txresp \ + -hdr "Cache-Control: public, max-age=300" \ + -hdr "Link: ; rel=author" \ + -bodylen 1000 + + # /big-body has a body larger than max-object-size below. In + # full-storage mode this would be rejected; in "only" mode it + # must still produce a hints entry. + rxreq + expect req.url == "/big-body" + txresp \ + -hdr "Cache-Control: public, max-age=300" \ + -hdr "Link: ; rel=preload; as=image" \ + -bodylen 200000 + + rxreq + expect req.url == "/big-body" + txresp \ + -hdr "Cache-Control: public, max-age=300" \ + -hdr "Link: ; rel=preload; as=image" \ + -bodylen 200000 +} -start + +haproxy h1 -conf { + global + .if feature(THREAD) + thread-groups 1 + .endif + stats socket "${tmpdir}/h1/stats" level admin + + defaults + mode http + timeout connect "${HAPROXY_TEST_TIMEOUT-5s}" + timeout client "${HAPROXY_TEST_TIMEOUT-5s}" + timeout server "${HAPROXY_TEST_TIMEOUT-5s}" + + frontend fe + bind "fd@${fe}" + default_backend test + + backend test + http-request cache-use my_cache + server www ${s1_addr}:${s1_port} + http-response cache-store my_cache + + cache my_cache + total-max-size 1 + max-age 300 + max-object-size 100000 + early-hints only +} -start + + +client c1 -connect ${h1_fe_sock} { + # First request: nothing cached yet, backend hit, no 103. The + # hints entry is created as a side effect of the response. + txreq -url "/hints-url" + rxresp + expect resp.status == 200 + expect resp.bodylen == 1000 + + # /no-hints-url: same backend hit, no 103. link_is_hint() returns + # 0 for "rel=author", so cache_extract_hints_from_htx() yields no + # records and nothing is stored. + txreq -url "/no-hints-url" + rxresp + expect resp.status == 200 + expect resp.bodylen == 1000 + + # Replay /hints-url. The hints entry now exists, so a 103 fires + # first carrying the stored Link header. The request still goes + # to the backend (the body is not cached) and we receive the 200 + # from there. + txreq -url "/hints-url" + rxresphdrs + expect resp.status == 103 + expect resp.http.link == "; rel=preload; as=style" + rxresp + expect resp.status == 200 + expect resp.bodylen == 1000 + + # Replay /no-hints-url. Nothing was cached, so no 103. The + # backend is hit again. + txreq -url "/no-hints-url" + rxresp + expect resp.status == 200 + expect resp.bodylen == 1000 + + # First request to /big-body: body (200KB) exceeds max-object-size + # (100KB), which in "only" mode is not a barrier since we never + # store the body. A hints entry must be created. + txreq -url "/big-body" + rxresp + expect resp.status == 200 + expect resp.bodylen == 200000 + + # Replay /big-body: 103 with the cached Link header, then the full + # 200 from the backend (body still not cached). + txreq -url "/big-body" + rxresphdrs + expect resp.status == 103 + expect resp.http.link == "; rel=preload; as=image" + rxresp + expect resp.status == 200 + expect resp.bodylen == 200000 +} -run + +# Two 103s were emitted: the /hints-url and /big-body replays. +shell { + hits=$(echo "show stat" | socat -t 5 "${tmpdir}/h1/stats" - | \ + awk -F, 'NR==1 { for (i=1; i<=NF; i++) if ($i=="cache_hint_hits") c=i } + $1=="test" && $2=="BACKEND" { print $c }') + if [ "$hits" != "2" ]; then + echo "expected 2 cache hint hits, got '$hits'" + exit 1 + fi +} diff --git a/src/cache.c b/src/cache.c index a19d19e23..da56966b7 100644 --- a/src/cache.c +++ b/src/cache.c @@ -48,6 +48,7 @@ /* Flags for configuration. */ #define CACHE_CF_VARY_PROCESSING 0x00000001 /* manage Vary header (disabled by default) */ #define CACHE_CF_EARLY_HINTS 0x00000002 /* enable HTTP 103 Early Hints (disabled by default) */ +#define CACHE_CF_EARLY_HINTS_ONLY 0x00000004 /* skip body storage; implies CACHE_CF_EARLY_HINTS */ /* Soft cap on the number of cache blocks that may be held by hints entries. */ #define CACHE_HINTS_CAP(cache) ((cache)->maxblocks * (cache)->early_hints_ratio / 100) @@ -1262,6 +1263,34 @@ static int cache_extract_hints(struct shared_context *shctx, return b_data(hint_buf); } +/* + * Walk a live HTX response's headers and accumulate Link values relevant + * for early hints into . Returns the number of bytes written + * to . Used by the hints-only storage path, where hints have + * to be extracted before the response is committed to the cache. + */ +static int cache_extract_hints_from_htx(struct htx *htx, struct buffer *hint_buf) +{ + int32_t pos; + + for (pos = htx_get_first(htx); pos != -1; pos = htx_get_next(htx, pos)) { + struct htx_blk *blk = htx_get_blk(htx, pos); + enum htx_blk_type type = htx_get_blk_type(blk); + + if (type == HTX_BLK_EOH) + break; + if (type == HTX_BLK_HDR) { + struct ist name = htx_get_blk_name(htx, blk); + if (isteq(name, ist("link"))) { + struct ist value = htx_get_blk_value(htx, blk); + cache_extract_link_hints(value, hint_buf); + } + } + } + + return b_data(hint_buf); +} + /* * Strip a full cache entry in place: persist 's content past * sizeof(cache_entry), truncate the row to match, mark the entry as @@ -1615,8 +1644,9 @@ enum act_return http_action_store_cache(struct act_rule *rule, struct proxy *px, htx = htxbuf(&s->res.buf); /* Do not cache too big objects. */ - if ((msg->flags & HTTP_MSGF_CNT_LEN) && shctx->max_obj_size > 0 && - s->scb->sedesc->kip > shctx->max_obj_size) + if ((msg->flags & HTTP_MSGF_CNT_LEN) && + !(cache->flags & CACHE_CF_EARLY_HINTS_ONLY) && + shctx->max_obj_size > 0 && s->scb->sedesc->kip > shctx->max_obj_size) goto out; /* Only a subset of headers are supported in our Vary implementation. If @@ -1718,7 +1748,12 @@ enum act_return http_action_store_cache(struct act_rule *rule, struct proxy *px, } else goto out; - http_remove_header(htx, &ctx); + /* The cache serves its copies with a freshly computed Age, so + * the stored response's Age is stripped. In hints-only mode the + * response is only passed through, never served, so leave it. + */ + if (!(cache->flags & CACHE_CF_EARLY_HINTS_ONLY)) + http_remove_header(htx, &ctx); } /* Build a last-modified time that will be stored in the cache_entry and @@ -1726,33 +1761,41 @@ enum act_return http_action_store_cache(struct act_rule *rule, struct proxy *px, object->last_modified = get_last_modified_time(htx); chunk_reset(&trash); - for (pos = htx_get_first(htx); pos != -1; pos = htx_get_next(htx, pos)) { - struct htx_blk *blk = htx_get_blk(htx, pos); - enum htx_blk_type type = htx_get_blk_type(blk); - uint32_t sz = htx_get_blksz(blk); - - hdrs_len += sizeof(*blk) + sz; - chunk_memcat(&trash, (char *)&blk->info, sizeof(blk->info)); - chunk_memcat(&trash, htx_get_blk_ptr(htx, blk), sz); - - /* Look for optional ETag header. - * We need to store the offset of the ETag value in order for - * future conditional requests to be able to perform ETag - * comparisons. */ - if (type == HTX_BLK_HDR) { - struct ist header_name = htx_get_blk_name(htx, blk); - if (isteq(header_name, ist("etag"))) { - object->etag_length = sz - istlen(header_name); - object->etag_offset = sizeof(struct cache_entry) + b_data(&trash) - sz + istlen(header_name); + if (cache->flags & CACHE_CF_EARLY_HINTS_ONLY) { + cache_extract_hints_from_htx(htx, &trash); + if (b_data(&trash) == 0) + goto out; + } else { + for (pos = htx_get_first(htx); pos != -1; pos = htx_get_next(htx, pos)) { + struct htx_blk *blk = htx_get_blk(htx, pos); + enum htx_blk_type type = htx_get_blk_type(blk); + uint32_t sz = htx_get_blksz(blk); + + hdrs_len += sizeof(*blk) + sz; + chunk_memcat(&trash, (char *)&blk->info, sizeof(blk->info)); + chunk_memcat(&trash, htx_get_blk_ptr(htx, blk), sz); + + /* Look for optional ETag header. + * We need to store the offset of the ETag value in order for + * future conditional requests to be able to perform ETag + * comparisons. */ + if (type == HTX_BLK_HDR) { + struct ist header_name = htx_get_blk_name(htx, blk); + if (isteq(header_name, ist("etag"))) { + object->etag_length = sz - istlen(header_name); + object->etag_offset = sizeof(struct cache_entry) + + b_data(&trash) - sz + + istlen(header_name); + } } + if (type == HTX_BLK_EOH) + break; } - if (type == HTX_BLK_EOH) - break; - } - /* Do not cache objects if the headers are too big. */ - if (hdrs_len > htx->size - global.tune.maxrewrite) - goto out; + /* Do not cache objects if the headers are too big. */ + if (hdrs_len > htx->size - global.tune.maxrewrite) + goto out; + } /* If the response has a secondary_key, fill its key part related to * encodings with the actual encoding of the response. This way any @@ -1778,13 +1821,24 @@ enum act_return http_action_store_cache(struct act_rule *rule, struct proxy *px, if (shctx_row_data_append(shctx, first, (unsigned char *)trash.area, trash.data) < 0) goto out; + /* store latest value and expiration time */ + object->latest_validation = date.tv_sec; + object->expire = date.tv_sec + effective_maxage; + + if (cache->flags & CACHE_CF_EARLY_HINTS_ONLY) { + /* Finalize hints-only entry. */ + shctx_wrlock(shctx); + object->flags |= CACHE_EF_COMPLETE | CACHE_EF_STRIPPED; + cache->hints_blocks += first->block_count; + cache_row_reattach(cache, first); + shctx_wrunlock(shctx); + return ACT_RET_CONT; + } + /* register the buffer in the filter ctx for filling it with data*/ if (cache_ctx) { cache_ctx->first_block = first; LIST_INIT(&cache_ctx->detached_head); - /* store latest value and expiration time */ - object->latest_validation = date.tv_sec; - object->expire = date.tv_sec + effective_maxage; return ACT_RET_CONT; } @@ -2880,10 +2934,13 @@ int cfg_parse_cache(const char *file, int linenum, char **args, int kwm) if (strcmp(args[1], "on") == 0) { tmp_cache_config->flags |= CACHE_CF_EARLY_HINTS; + tmp_cache_config->flags &= ~CACHE_CF_EARLY_HINTS_ONLY; } else if (strcmp(args[1], "off") == 0) { - tmp_cache_config->flags &= ~CACHE_CF_EARLY_HINTS; + tmp_cache_config->flags &= ~(CACHE_CF_EARLY_HINTS | CACHE_CF_EARLY_HINTS_ONLY); + } else if (strcmp(args[1], "only") == 0) { + tmp_cache_config->flags |= CACHE_CF_EARLY_HINTS | CACHE_CF_EARLY_HINTS_ONLY; } else { - ha_warning("parsing [%s:%d]: '%s' expects \"on\" or \"off\" (enable or disable HTTP 103 Early Hints support).\n", + ha_warning("parsing [%s:%d]: '%s' expects \"on\", \"off\" or \"only\" (enable or disable HTTP 103 Early Hints support, or store only hints).\n", file, linenum, args[0]); err_code |= ERR_WARN; } -- 2.47.3