Declare a cache section, allocate a shared cache memory named <name>, the
size of cache is mandatory (see keyword "total-max-size" below).
-early-hints <on/off> [ratio <integer>]
+early-hints <on/off/only> [ratio <integer>]
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
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 <seconds>
Define the maximum expiration duration. The expiration is set as the lowest
--- /dev/null
+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: </style.css>; 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: </terms>; rel=author" \
+ -bodylen 1000
+
+ rxreq
+ expect req.url == "/hints-url"
+ txresp \
+ -hdr "Cache-Control: public, max-age=300" \
+ -hdr "Link: </style.css>; rel=preload; as=style" \
+ -bodylen 1000
+
+ rxreq
+ expect req.url == "/no-hints-url"
+ txresp \
+ -hdr "Cache-Control: public, max-age=300" \
+ -hdr "Link: </terms>; 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: </hero.jpg>; rel=preload; as=image" \
+ -bodylen 200000
+
+ rxreq
+ expect req.url == "/big-body"
+ txresp \
+ -hdr "Cache-Control: public, max-age=300" \
+ -hdr "Link: </hero.jpg>; 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 == "</style.css>; 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 == "</hero.jpg>; 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
+}
/* 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)
return b_data(hint_buf);
}
+/*
+ * Walk a live HTX response's headers and accumulate Link values relevant
+ * for early hints into <hint_buf>. Returns the number of bytes written
+ * to <hint_buf>. 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 <hint_buf>'s content past
* sizeof(cache_entry), truncate the row to match, mark the entry as
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
}
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
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
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;
}
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;
}