This is an automated email from the ASF dual-hosted git repository.
masaori335 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 48fc842914 slice: purge every block of an object, not just those
before a gap (#13475)
48fc842914 is described below
commit 48fc8429140dc9da2bc237c7fb890fb4a0bbd54d
Author: Masaori Koshiba <[email protected]>
AuthorDate: Fri Aug 7 11:41:14 2026 +0900
slice: purge every block of an object, not just those before a gap (#13475)
* slice: purge every block of an object, not just those before a gap
A PURGE is meant to discard the object, but the block walk stopped at the
first block that was not in cache, so any object whose cached blocks were
not a contiguous run from block 0 was only partially purged, and the client
still got a 200. A gap in the middle left every block behind it cached; an
uncached first block purged nothing and relayed that block's 404; and a
"bytes=-N" purge deleted the head while leaving the tail it had named.
The stop was load-bearing. The walk's only other terminator needs the object
length, which slice only ever learned from a 206's Content-Range, and a
PURGE
response has none. So the core now reports the removed object's extent as
X-Purged-Content-Range on a PURGE cache hit, and the walk learns where the
object ends from the blocks it is already deleting. It is not Content-Range
itself, since that header on a 200 is meaningless under RFC 9110 and
cache_range_requests reads the pair as a stored 206 and rewrites the status.
PURGE gets its own state machine in the plugin, so it no longer routes
through handleFirstServerHeader, whose double duty as "form and emit the
client response" is what leaked the 404. A 404 for a block is stepped over,
nothing is written downstream until the walk finishes, and the response is
then synthesized: 200 if any block was removed, 404 if none was. The extent
is taken as a maximum rather than the first value seen, since blocks of one
object disagree when the origin object was replaced in place.
Until some block reports an extent the walk has no end but a miss bound, so
add --purge-probe-blocks, default 8, capping consecutive uncached blocks. It
never limits how many blocks a purge removes. A per-request override named
by
--purge-probe-header, default X-Slice-Purge-Probe, lets an operator who
knows
the object size widen it. A suffix range names its blocks by distance from
an
end slice does not know yet, so such a purge is widened to the whole object,
a superset of what was asked. A PURGE whose Range cannot be parsed is
refused
with a 400 rather than guessing which blocks were meant.
Tests cover the traversal over gaps, an uncached first block, both
open-ended
range forms, blocks that disagree about the object length, the miss bound
and
its override, and the refusal. They measure on the origin rather than the
response body, since a purged block and a surviving block are
indistinguishable
to the client. Two further tests reproduce the client-visible failures of an
origin object replaced in place under a child/parent hierarchy, which is how
this problem was found.
* Doc: Fix example of HTTP/1.1 messages
* slice: pace the two PURGE request-validation error logs
Both values are client supplied, so a bad one repeats as fast as
requests arrive. Route them through Config::canLogError() like the
other slice error paths.
* slice: let a PURGE range bound the walk before any extent is known
The requested range end comes from the client's Range header, but the
walk only consulted it once some block had reported the object's extent,
which only a block that was actually removed can do. A closed-range
PURGE whose leading blocks were uncached therefore ran past its range
end and removed blocks the client never named.
* slice: do not report a partial PURGE as a success
A block PURGE answering neither 200 nor 404 was read as "already
absent", so a 403 from ip_allow or a 502 counted as a miss and the walk
carried on to answer 200 on the strength of blocks it had removed
earlier, telling the client the object was gone while part of it was
still cached. Such a status says nothing about the blocks behind it
either, so the walk now stops there and reports it.
---
doc/admin-guide/plugins/slice.en.rst | 99 ++-
doc/admin-guide/storage/index.en.rst | 17 +-
plugins/slice/Config.cc | 26 +-
plugins/slice/Config.h | 6 +
plugins/slice/Data.h | 23 +-
plugins/slice/HttpHeader.cc | 26 +
plugins/slice/HttpHeader.h | 11 +
plugins/slice/client.cc | 70 ++
plugins/slice/response.cc | 15 +
plugins/slice/response.h | 6 +
plugins/slice/server.cc | 213 ++++-
plugins/slice/server.h | 11 +
plugins/slice/util.cc | 8 +-
src/proxy/http/HttpTransact.cc | 19 +
.../replay/slice_purge_gaps_client.replay.yaml | 895 +++++++++++++++++++++
.../replay/slice_purge_gaps_server.replay.yaml | 586 ++++++++++++++
.../slice_stale_generation_client.replay.yaml | 206 +++++
.../slice_stale_generation_server.replay.yaml | 286 +++++++
.../slice/rules/purge_block_failure.conf | 41 +
.../pluginTest/slice/slice_purge_gaps.test.py | 346 ++++++++
.../slice/slice_stale_generation.test.py | 327 ++++++++
21 files changed, 3206 insertions(+), 31 deletions(-)
diff --git a/doc/admin-guide/plugins/slice.en.rst
b/doc/admin-guide/plugins/slice.en.rst
index 40d606129c..21edd7eb20 100644
--- a/doc/admin-guide/plugins/slice.en.rst
+++ b/doc/admin-guide/plugins/slice.en.rst
@@ -186,7 +186,21 @@ The slice plugin supports the following options::
that causes `cache_range_requests` to be bypassed in such requests, and
allow ATS to handle those range requests internally.
-
+ --purge-probe-blocks=<int> (optional)
+ Default is 8
+ How many consecutive uncached slice blocks a PURGE walks, before any
block
+ has reported the object's extent, until it concludes that nothing
about the
+ object is cached. May be overridden per request with the header named
by
+ ``--purge-probe-header``. See `Purge Requests`_.
+ -q for short
+
+ --purge-probe-header=<string> (optional)
+ Default is X-Slice-Purge-Probe
+ Name of the request header a PURGE may use to override
+ ``--purge-probe-blocks`` for that request. A malformed value is
ignored
+ in favour of the configured default. Slice strips this header from the
+ block requests it issues.
+ -H for short
Examples::
@@ -338,17 +352,78 @@ requests may end up being served from temporally
different assets.
Purge Requests
--------------
-The slice plugin supports PURGE requests, discarding the requested object from
cache.
-If a range is given in the client request, only the slice blocks from the
-requested range will be purged (if in cache). If not, all of the blocks will
be discarded
-from the cache.
-
-If a block receives a 404, indicating the requested block to be purged is not
in the cache,
-slice will not continue to purge the following blocks.
-
-The functionality works with `--ref-relative` both enabled and disabled. If
`--ref-relative` is
-disabled (using slice 0 as the reference block), requesting to PURGE a block
that does not have
-slice 0 in its range will still PURGE the slice 0 block, as the reference
block is always processed.
+The slice plugin supports PURGE requests, discarding the requested object from
+cache. Without a range every block is discarded; with a range, the blocks that
+range covers are. Two cases below purge more than the range names: a suffix
range,
+and block 0 when ``--ref-relative`` is disabled.
+
+Slice issues one PURGE per block and walks every block it was asked for,
whether
+or not each one is currently cached. A block that is already absent answers 404
+internally; that is simply noted and the walk continues, so a gap left by
+per-block eviction cannot leave the blocks behind it in cache.
+
+Slice learns where the object ends from the blocks it removes. PURGE is a
Traffic
+Server extension, so a successful block purge reports the removed object's
extent
+in a ``X-Purged-Content-Range`` header, and the walk continues to the last
block that
+extent implies. Blocks of one object can disagree about its length when the
origin
+object has been replaced in place; slice takes the largest extent any block
+reports, so the longer generation's tail is not left behind.
+
+Until some block has reported an extent, a walk over an open-ended range has no
+end but the miss bound: it stops after ``--purge-probe-blocks`` consecutive
+uncached blocks and reports that nothing was found. That is what bounds a PURGE
+for a URL which is not cached at all.
+
+An operator often knows more about the object than the plugin does, since the
+block count is just the object's size divided by the block size. That count
can be
+supplied per request with the header named by ``--purge-probe-header``, default
+``X-Slice-Purge-Probe``::
+
+ PURGE /obj HTTP/1.1
+ X-Slice-Purge-Probe: 64
+
+This only changes how long the walk keeps going without having found anything;
it
+never limits how many blocks are purged once an extent is known.
+
+The bound has to be able to span a whole object, because in the worst case only
+the object's last block is still cached, so the value an operator wants is the
+object's size divided by the block size: a 10 GB object in 1 MB blocks needs
+10240. There is no ceiling on it beyond that, since reaching the bound costs
one
+internal cache lookup per block and PURGE is already restricted by
+:file:`ip_allow.yaml`. A malformed value is ignored in favour of the configured
+default, and slice strips the header from the block requests it issues.
+
+If the bound is reached, slice logs that it gave up and reports ``404`` even
though
+later blocks may still be cached. Raise ``--purge-probe-blocks``, or send the
+override, for objects whose leading blocks are routinely absent.
+
+A client range that is already closed, such as ``bytes=0-6399999999``, bounds
the
+walk directly, and is clamped against the object's extent as soon as some block
+reports one. An over-estimate therefore costs no extra block PURGEs beyond the
end
+of the object, and if no block is cached at all the miss bound stops the walk.
+
+A suffix range, ``bytes=-<n>``, names its blocks by their distance from an end
+slice does not know yet, and purging is the only way it could find out. Rather
+than guess at the start, such a purge is widened to the whole object: a
superset of
+what was asked for, so the named blocks certainly go. A ``GET`` with the same
header
+is unaffected and still returns exactly the last *n* bytes.
+
+The response is sent once the walk is complete: ``200`` if at least one block
was
+removed, ``404`` if none was found. This matches what Traffic Server reports
for a
+PURGE of an object that is not sliced.
+
+A block whose PURGE returns neither ``200`` nor ``404`` — a ``403`` from
+:file:`ip_allow.yaml`, for instance, or a ``502`` — says nothing about whether
that
+block was cached, and nothing about the blocks behind it. The walk stops there
and that
+status is reported in place of ``200``, so the two success statuses keep
meaning what
+they say: ``200`` that the object is gone and ``404`` that it was not there,
never that
+this proxy could not tell. Blocks the walk had already removed stay removed,
and blocks
+behind the failing one are left cached, so such a PURGE is worth repeating once
+whatever refused it has been dealt with.
+
+The functionality works with ``--ref-relative`` both enabled and disabled.
With it
+disabled, block 0 is always the first block walked, so a PURGE whose range
does not
+cover block 0 still purges it.
Conditional Slicing
-------------------
diff --git a/doc/admin-guide/storage/index.en.rst
b/doc/admin-guide/storage/index.en.rst
index aa3754df5e..af6d23de06 100644
--- a/doc/admin-guide/storage/index.en.rst
+++ b/doc/admin-guide/storage/index.en.rst
@@ -304,7 +304,7 @@ from any other IP, we connect to the daemon via localhost:
::
> Host: example.com
> Accept: */*
>
- < HTTP/1.1 200 Ok
+ < HTTP/1.1 200 OK
< Date: Thu, 08 Jan 2010 20:32:07 GMT
< Connection: keep-alive
@@ -312,6 +312,21 @@ The next time Traffic Server receives a request for the
removed object,
it will contact the origin server to retrieve a new copy, which will replace
the previously cached version in Traffic Server.
+If the removed object was stored as a partial response, that is if it carried a
+``Content-Range``, then the ``200 OK`` also reports that range back in a
+``X-Purged-Content-Range`` header::
+
+ < HTTP/1.1 200 OK
+ < X-Purged-Content-Range: bytes 0-1048575/9437184
+
+This lets a caller that holds one piece of a larger resource learn the whole
+resource's extent without a second lookup. It is what allows the
+:ref:`admin-plugins-slice` plugin to purge an object block by block and know
which
+block is the last one. The range is reported under its own header name rather
than
+as ``Content-Range``, because ``Content-Range`` on a ``200`` response has no
+meaning under :rfc:`9110` and is read by other components as a sign that a
stored
+partial response is being served.
+
This procedure only removes the index to the object from a specific Traffic
Server
cache. While the object remains on disk, Traffic Server will no longer able to
find
the object. The next request for that object will result in a fresh copy of the
diff --git a/plugins/slice/Config.cc b/plugins/slice/Config.cc
index b95ace3bab..b5e19f7189 100644
--- a/plugins/slice/Config.cc
+++ b/plugins/slice/Config.cc
@@ -27,8 +27,9 @@
namespace
{
-constexpr std::string_view DefaultSliceSkipHeader = {"X-Slicer-Info"};
-constexpr std::string_view DefaultCrrIdentHeader = {"X-Crr-Ident"};
+constexpr std::string_view DefaultSliceSkipHeader = {"X-Slicer-Info"};
+constexpr std::string_view DefaultCrrIdentHeader = {"X-Crr-Ident"};
+constexpr std::string_view DefaultPurgeProbeHeader = {"X-Slice-Purge-Probe"};
} // namespace
Config::~Config()
@@ -121,13 +122,15 @@ Config::fromArgs(int const argc, char const *const argv[])
{const_cast<char *>("minimum-size"), required_argument, nullptr,
'm'},
{const_cast<char *>("metadata-cache-size"), required_argument, nullptr,
'z'},
{const_cast<char *>("stats-prefix"), required_argument, nullptr,
'x'},
+ {const_cast<char *>("purge-probe-blocks"), required_argument, nullptr,
'q'},
+ {const_cast<char *>("purge-probe-header"), required_argument, nullptr,
'H'},
{nullptr, 0, nullptr, 0
},
};
// getopt assumes args start at '1' so this hack is needed
char *const *argvp = (const_cast<char *const *>(argv) - 1);
for (;;) {
- int const opt = getopt_long(argc + 1, argvp, "b:de:g:i:lm:p:r:s:t:x:z:",
longopts, nullptr);
+ int const opt = getopt_long(argc + 1, argvp,
"b:de:g:H:i:lm:p:q:r:s:t:x:z:", longopts, nullptr);
if (-1 == opt) {
break;
}
@@ -248,6 +251,19 @@ Config::fromArgs(int const argc, char const *const argv[])
stat_prefix = optarg;
DEBUG_LOG("Stat prefix: %s", stat_prefix.c_str());
} break;
+ case 'q': {
+ int const blocksread = atoi(optarg);
+ if (0 < blocksread) {
+ m_purge_probe_blocks = blocksread;
+ DEBUG_LOG("Using purge probe blocks %d", m_purge_probe_blocks);
+ } else {
+ ERROR_LOG("Invalid purge-probe-blocks: %s", optarg);
+ }
+ } break;
+ case 'H': {
+ m_purge_probe_header.assign(optarg);
+ DEBUG_LOG("Using purge probe header %s", optarg);
+ } break;
default:
break;
}
@@ -275,6 +291,10 @@ Config::fromArgs(int const argc, char const *const argv[])
m_skip_header = DefaultSliceSkipHeader;
DEBUG_LOG("Using default slice skip header %s", m_skip_header.c_str());
}
+ if (m_purge_probe_header.empty()) {
+ m_purge_probe_header = DefaultPurgeProbeHeader;
+ DEBUG_LOG("Using default purge probe header %s",
m_purge_probe_header.c_str());
+ }
if (m_min_size_to_slice > 0) {
if (m_oscache.has_value()) {
diff --git a/plugins/slice/Config.h b/plugins/slice/Config.h
index 4fd15a2506..f9888e6640 100644
--- a/plugins/slice/Config.h
+++ b/plugins/slice/Config.h
@@ -36,6 +36,8 @@ struct Config {
static constexpr int64_t const blockbytesmax = 1024 * 1024 * 128; //
128MB
static constexpr int64_t const blockbytesdefault = 1024 * 1024; // 1MB
+ static constexpr int const purgeprobeblocksdefault = 8;
+
int64_t m_blockbytes{blockbytesdefault};
std::string m_remaphost; // remap host to use for loopback slice GET
std::string m_regexstr; // regex string for things to slice (default all)
@@ -49,8 +51,12 @@ struct Config {
bool m_head_strip_range{false}; // strip range header for head requests
uint64_t m_min_size_to_slice{0}; // Only strip objects larger than this
+ // consecutive uncached blocks a purge tolerates before giving up on the
object
+ int m_purge_probe_blocks{purgeprobeblocksdefault};
+
std::string m_skip_header;
std::string m_crr_ident_header;
+ std::string m_purge_probe_header; // request header overriding
m_purge_probe_blocks
// Convert optarg to bytes
static int64_t bytesFrom(char const *const valstr);
diff --git a/plugins/slice/Data.h b/plugins/slice/Data.h
index 0c19dfaafa..a3fd8e1cf4 100644
--- a/plugins/slice/Data.h
+++ b/plugins/slice/Data.h
@@ -77,6 +77,11 @@ struct Data {
int64_t m_blockskip{0}; // number of bytes to skip in this block
int64_t m_blockconsumed{0}; // body bytes consumed
+ int64_t m_purge_hits{0}; // blocks a purge actually
removed
+ int m_purge_misses{0}; // consecutive uncached
blocks the walk has seen
+ int m_purge_miss_bound{0}; // from the config or the
request header
+ TSHttpStatus m_purge_error{TS_HTTP_STATUS_NONE}; // block failure that ended
the walk
+
BlockState m_blockstate{Pending}; // is there an active slice block
int64_t m_bytestosend{0}; // header + content bytes to send
@@ -109,11 +114,25 @@ struct Data {
memset(&m_client_ip, 0, sizeof(m_client_ip));
}
- // Check if response only expects header
+ // HEAD only; a purge sends just a header too but never reaches the transfer
path
bool
onlyHeader() const
{
- return (m_method_type == TS_HTTP_METHOD_HEAD || m_method_type ==
TS_HTTP_METHOD_PURGE);
+ return m_method_type == TS_HTTP_METHOD_HEAD;
+ }
+
+ bool
+ is_purge() const
+ {
+ return m_method_type == TS_HTTP_METHOD_PURGE;
+ }
+
+ // The purge range, closed against the object length once known. m_req_range
+ // stays as sent so a longer extent can widen the walk; a clamp could only
shrink.
+ Range
+ purge_range() const
+ {
+ return (m_contentlen < 0) ? m_req_range :
m_req_range.intersectedWith(Range(0, m_contentlen));
}
~Data()
diff --git a/plugins/slice/HttpHeader.cc b/plugins/slice/HttpHeader.cc
index d08e76db7a..e075036a24 100644
--- a/plugins/slice/HttpHeader.cc
+++ b/plugins/slice/HttpHeader.cc
@@ -326,6 +326,32 @@ HttpHeader::toString() const
/////// HdrMgr
+bool
+HdrMgr::create_response(TSHttpStatus const status)
+{
+ resetHeader();
+
+ if (nullptr == m_buffer) {
+ m_buffer = TSMBufferCreate();
+ }
+
+ m_lochdr = TSHttpHdrCreate(m_buffer);
+ if (nullptr == m_lochdr) {
+ return false;
+ }
+
+ TSHttpHdrTypeSet(m_buffer, m_lochdr, TS_HTTP_TYPE_RESPONSE);
+ TSHttpHdrVersionSet(m_buffer, m_lochdr, TS_HTTP_VERSION(1, 1));
+ TSHttpHdrStatusSet(m_buffer, m_lochdr, status);
+
+ char const *const reason = TSHttpHdrReasonLookup(status);
+ if (nullptr != reason) {
+ TSHttpHdrReasonSet(m_buffer, m_lochdr, reason, strlen(reason));
+ }
+
+ return true;
+}
+
TSParseResult
HdrMgr::populateFrom(TSHttpParser const http_parser, TSIOBufferReader const
reader, HeaderParseFunc const parsefunc,
int64_t *const bytes)
diff --git a/plugins/slice/HttpHeader.h b/plugins/slice/HttpHeader.h
index c52738e5d1..0fd50c779a 100644
--- a/plugins/slice/HttpHeader.h
+++ b/plugins/slice/HttpHeader.h
@@ -39,6 +39,10 @@
constexpr std::string_view SLICE_CRR_HEADER = {"Slice-Crr-Status"};
constexpr std::string_view SLICE_CRR_VAL = "1";
+// extent of the object a PURGE removed, reported by ATS on a successful purge.
+// Emitted by HttpTransact::delete_all_document_alternates_and_return.
+constexpr std::string_view PURGED_CONTENT_RANGE = {"X-Purged-Content-Range"};
+
/**
Designed to be a cheap throwaway struct which allows a
consumer to make various calls to manipulate headers.
@@ -207,6 +211,13 @@ struct HdrMgr {
}
}
+ /** Create an owned HTTP/1.1 response header with the given status.
+ *
+ * For a response slice forms itself, with no server response to relay. An
+ * intercept is an HTTP/1.x channel, so the version is not negotiable.
+ */
+ bool create_response(TSHttpStatus const status);
+
void
resetHeader()
{
diff --git a/plugins/slice/client.cc b/plugins/slice/client.cc
index 1494ffa38a..5c5933cd10 100644
--- a/plugins/slice/client.cc
+++ b/plugins/slice/client.cc
@@ -19,9 +19,49 @@
#include "client.h"
#include "Config.h"
+#include "server.h"
#include "util.h"
+#include "swoc/TextView.h"
+
+#include <cctype>
#include <cinttypes>
+#include <limits>
+
+namespace
+{
+// Miss bound for this purge: the request header when usable, else the config
value.
+int
+purge_miss_bound(HttpHeader const &header, Config *const conf)
+{
+ char probestr[64];
+ int probelen = sizeof(probestr);
+
+ if (!header.valueForKey(conf->m_purge_probe_header.data(),
conf->m_purge_probe_header.size(), probestr, &probelen)) {
+ return conf->m_purge_probe_blocks;
+ }
+
+ swoc::TextView value{probestr, static_cast<size_t>(probelen)};
+ // isspace is only defined for values representable as unsigned char
+ value.trim_if([](char c) { return 0 != isspace(static_cast<unsigned
char>(c)); });
+
+ swoc::TextView parsed;
+ intmax_t const blocks = swoc::svtoi(value, &parsed, 10);
+
+ // parsed must cover the whole value: "8abc" is a mistake, not eight blocks
+ if (parsed.size() != value.size() || blocks <= 0 ||
std::numeric_limits<int>::max() < blocks) {
+ // paced: the value is client supplied, so a bad one repeats as fast as
requests arrive
+ if (conf->canLogError()) {
+ ERROR_LOG("Ignoring invalid %.*s value '%.*s'",
static_cast<int>(conf->m_purge_probe_header.size()),
+ conf->m_purge_probe_header.data(), probelen, probestr);
+ }
+ return conf->m_purge_probe_blocks;
+ }
+
+ return static_cast<int>(blocks);
+}
+
+} // namespace
// this is called once per transaction when the client sends a req header
bool
@@ -94,6 +134,31 @@ handle_client_req(TSCont contp, TSEvent event, Data *const
data)
data->m_req_range = rangebe;
+ if (data->is_purge()) {
+ // The substituted range covers block 0, so walking it would delete the
head
+ if (TS_HTTP_STATUS_REQUESTED_RANGE_NOT_SATISFIABLE ==
data->m_statustype) {
+ // paced: the range is client supplied, so a bad one repeats as fast
as requests arrive
+ if (data->m_config->canLogError()) {
+ ERROR_LOG("Refusing PURGE with an unparseable range");
+ }
+ finish_purge(contp, data, TS_HTTP_STATUS_BAD_REQUEST);
+ return true;
+ }
+
+ data->m_purge_miss_bound = purge_miss_bound(header, data->m_config);
+ DEBUG_LOG("%p Purge miss bound %d block(s)", data,
data->m_purge_miss_bound);
+
+ // A suffix range cannot know its start block, so purge a superset:
everything
+ if (data->m_req_range.isEndBytes()) {
+ data->m_req_range = Range(0, Range::maxval);
+ data->m_blocknum = 0;
+ DEBUG_LOG("%p Purge suffix range widened to the whole object", data);
+ }
+
+ // The miss bound is for this proxy to act on, not to propagate
+ header.removeKey(conf->m_purge_probe_header.data(),
conf->m_purge_probe_header.size());
+ }
+
// remove ATS keys to avoid 404 loop
header.removeKey(TS_MIME_FIELD_VIA, TS_MIME_LEN_VIA);
header.removeKey(TS_MIME_FIELD_X_FORWARDED_FOR,
TS_MIME_LEN_X_FORWARDED_FOR);
@@ -126,6 +191,11 @@ handle_client_resp(TSCont contp, TSEvent event, Data
*const data)
{
switch (event) {
case TS_EVENT_VCONN_WRITE_READY: {
+ // finish_purge writes the whole response at once; nothing to throttle or
pull
+ if (data->is_purge()) {
+ break;
+ }
+
switch (data->m_blockstate) {
case BlockState::Fail:
case BlockState::PendingRef:
diff --git a/plugins/slice/response.cc b/plugins/slice/response.cc
index 94081cf7b2..a34ef3a6cb 100644
--- a/plugins/slice/response.cc
+++ b/plugins/slice/response.cc
@@ -86,6 +86,21 @@ string502(int const httpver)
return msg;
}
+// Form the response to a sliced PURGE, once every block has been walked
+bool
+form_purge_response(HdrMgr &hdrmgr, TSHttpStatus const status)
+{
+ if (!hdrmgr.create_response(status)) {
+ return false;
+ }
+
+ // The core adds Date, Age, Server and Connection to an intercept's response
+ HttpHeader header(hdrmgr.m_buffer, hdrmgr.m_lochdr);
+ header.setKeyVal(TS_MIME_FIELD_CONTENT_LENGTH, TS_MIME_LEN_CONTENT_LENGTH,
"0", 1);
+
+ return true;
+}
+
void
form416HeaderAndBody(HttpHeader &header, int64_t const contentlen, std::string
const &bodystr)
{
diff --git a/plugins/slice/response.h b/plugins/slice/response.h
index c116d22e21..569af1120b 100644
--- a/plugins/slice/response.h
+++ b/plugins/slice/response.h
@@ -25,4 +25,10 @@ std::string string502(int const httpver);
std::string const &bodyString416();
+/** Fill hdrmgr with the response to a sliced PURGE, for the caller to print.
+ *
+ * The header is owned by hdrmgr and destroyed with it.
+ */
+bool form_purge_response(HdrMgr &hdrmgr, TSHttpStatus const status);
+
void form416HeaderAndBody(HttpHeader &header, int64_t const contentlen,
std::string const &bodystr);
diff --git a/plugins/slice/server.cc b/plugins/slice/server.cc
index 7105d5157b..2b25f22c01 100644
--- a/plugins/slice/server.cc
+++ b/plugins/slice/server.cc
@@ -32,7 +32,7 @@
namespace
{
ContentRange
-contentRangeFrom(HttpHeader const &header)
+content_range_for_key(HttpHeader const &header, char const *const key, int
const keylen)
{
ContentRange bcr;
@@ -42,22 +42,25 @@ contentRangeFrom(HttpHeader const &header)
char rangestr[1024];
int rangelen = sizeof(rangestr);
- // look for expected Content-Range field
- bool const hasContentRange(header.valueForKey(TS_MIME_FIELD_CONTENT_RANGE,
TS_MIME_LEN_CONTENT_RANGE, rangestr, &rangelen));
-
- if (!hasContentRange) {
- DEBUG_LOG("invalid response header, no Content-Range");
+ if (!header.valueForKey(key, keylen, rangestr, &rangelen)) {
+ DEBUG_LOG("invalid response header, no %.*s", keylen, key);
} else {
// ensure null termination
rangestr[rangelen] = '\0';
if (!bcr.fromStringClosed(rangestr)) {
- DEBUG_LOG("invalid response header, malformed Content-Range, %s",
rangestr);
+ DEBUG_LOG("invalid response header, malformed %.*s, %s", keylen, key,
rangestr);
}
}
return bcr;
}
+ContentRange
+contentRangeFrom(HttpHeader const &header)
+{
+ return content_range_for_key(header, TS_MIME_FIELD_CONTENT_RANGE,
TS_MIME_LEN_CONTENT_RANGE);
+}
+
int64_t
contentLengthFrom(HttpHeader const &header)
{
@@ -136,7 +139,7 @@ handleFirstServerHeader(Data *const data, TSCont const
contp)
int64_t const hlen = TSHttpHdrLengthGet(header.m_buffer, header.m_lochdr);
int64_t const clen = contentLengthFrom(header);
if (TS_HTTP_STATUS_OK == header.status() && data->onlyHeader()) {
- DEBUG_LOG("HEAD/PURGE request stripped Range header: expects 200");
+ DEBUG_LOG("HEAD request stripped Range header: expects 200");
data->m_bytestosend = hlen;
data->m_blockexpected = 0;
TSVIONBytesSet(output_vio, hlen);
@@ -499,12 +502,201 @@ handleNextServerHeader(Data *const data)
return true;
}
+// Take the largest extent any block reports: blocks disagree when the origin
+// object was replaced in place, and the shorter one would leave a tail cached.
+void
+note_purge_extent(Data *const data, int64_t const length)
+{
+ if (length <= data->m_contentlen) {
+ return;
+ }
+
+ data->m_contentlen = length;
+ DEBUG_LOG("purge extent now %" PRId64 ", walking through block %" PRId64,
length,
+ data->purge_range().lastBlockFor(data->m_config->m_blockbytes));
+}
+
+// A block that could not be purged is not a block that was absent, so the
client must
+// not be told the object is gone. The walk stops here: the rest of the object
is
+// unknown, and a 5xx often means the cache is in no state to be asked again.
+void
+note_purge_failure(Data *const data, TSHttpStatus const status)
+{
+ data->m_purge_error = status;
+
+ // paced: a config refusing PURGE would otherwise log once per purge request
+ if (data->m_config->canLogError()) {
+ ERROR_LOG("Purge of block %" PRId64 " failed (%d), the object may be left
partly cached", data->m_blocknum, status);
+ }
+}
+
+// Record what the block response said, without answering the client.
+void
+note_purge_block_result(Data *const data)
+{
+ HttpHeader const header(data->m_resp_hdrmgr.m_buffer,
data->m_resp_hdrmgr.m_lochdr);
+ DEBUG_LOG("Purge block header\n%s", header.toString().c_str());
+
+ TSHttpStatus const status = header.status();
+
+ if (TS_HTTP_STATUS_OK == status) {
+ ++data->m_purge_hits;
+ data->m_purge_misses = 0;
+
+ // Not Content-Range: cache_range_requests reads that on a 200 as a stored
206
+ // and rewrites the status
+ ContentRange const purgedcr = content_range_for_key(header,
PURGED_CONTENT_RANGE.data(), PURGED_CONTENT_RANGE.size());
+ if (purgedcr.isValid() && 0 < purgedcr.m_length) {
+ note_purge_extent(data, purgedcr.m_length);
+ } else {
+ DEBUG_LOG("Purged block %" PRId64 " reported no usable extent",
data->m_blocknum);
+ }
+ } else if (TS_HTTP_STATUS_NOT_FOUND == status) {
+ // Already absent. The walk used to stop here, leaving every later block
cached.
+ ++data->m_purge_misses;
+ DEBUG_LOG("Purge block %" PRId64 " was not cached", data->m_blocknum);
+ } else {
+ // Anything else is a refusal or a failure, which says nothing about the
block
+ note_purge_failure(data, status);
+ }
+}
+
+// Issue the next purge, or answer the client if the walk is over.
+void
+advance_purge(TSCont const contp, Data *const data)
+{
+ // A block that could not be purged says nothing about the ones behind it
+ if (TS_HTTP_STATUS_NONE != data->m_purge_error) {
+ finish_purge(contp, data);
+ return;
+ }
+
+ int64_t const blockbytes = data->m_config->m_blockbytes;
+ Range const range = data->purge_range();
+
+ ++data->m_blocknum;
+ int64_t const firstblock = range.firstBlockFor(blockbytes);
+ if (data->m_blocknum < firstblock) {
+ data->m_blocknum = firstblock;
+ }
+
+ // The requested range bounds the walk whether or not the extent is known yet
+ if (!range.blockIsInside(blockbytes, data->m_blocknum)) {
+ finish_purge(contp, data);
+ return;
+ }
+
+ // An open ended range has no such bound until some block reports an extent
+ if (data->m_contentlen < 0 && data->m_purge_miss_bound <=
data->m_purge_misses) {
+ DEBUG_LOG("purge gave up after %d consecutive uncached block(s)",
data->m_purge_misses);
+ finish_purge(contp, data);
+ return;
+ }
+
+ data->m_blockstate = BlockState::Pending;
+ if (!request_block(contp, data)) {
+ note_purge_failure(data, TS_HTTP_STATUS_INTERNAL_SERVER_ERROR);
+ finish_purge(contp, data);
+ }
+}
+
} // namespace
+// Answer the client once every block has been walked. Nothing is written
+// downstream before this, so one uncached block cannot leak a 404 to the
client.
+// A non-NONE status overrides the outcome of the walk.
+void
+finish_purge(TSCont const contp, Data *const data, TSHttpStatus const status)
+{
+ data->m_upstream.close();
+ data->m_blockstate = BlockState::Done;
+
+ // A block that could not be purged outranks the hits: 200 has to keep
meaning that
+ // the object is gone, and 404 that it was never there
+ TSHttpStatus const reply = (TS_HTTP_STATUS_NONE != status) ?
status :
+ (TS_HTTP_STATUS_NONE != data->m_purge_error) ?
data->m_purge_error :
+ (0 < data->m_purge_hits) ?
TS_HTTP_STATUS_OK :
+
TS_HTTP_STATUS_NOT_FOUND;
+
+ DEBUG_LOG("purge removed %" PRId64 " block(s), answering %d",
data->m_purge_hits, reply);
+
+ if (!data->m_dnstream.isOpen()) {
+ shutdown(contp, data);
+ return;
+ }
+
+ HdrMgr synthmgr;
+ if (!form_purge_response(synthmgr, reply)) {
+ ERROR_LOG("Failed forming the purge response");
+ shutdown(contp, data);
+ return;
+ }
+
+ HttpHeader const synth(synthmgr.m_buffer, synthmgr.m_lochdr);
+ int const hlen = synth.byteSize();
+
+ data->m_dnstream.setupVioWrite(contp, hlen);
+ TSHttpHdrPrint(synthmgr.m_buffer, synthmgr.m_lochdr,
data->m_dnstream.m_write.m_iobuf);
+ data->m_bytessent = hlen;
+ TSVIOReenable(data->m_dnstream.m_write.m_vio);
+}
+
+// A purge walks blocks instead of transferring them, so it runs its own
machine
+void
+handle_purge_resp(TSCont const contp, TSEvent const event, Data *const data)
+{
+ switch (event) {
+ case TS_EVENT_VCONN_READ_READY:
+ case TS_EVENT_VCONN_READ_COMPLETE: {
+ if (!data->m_server_block_header_parsed) {
+ int64_t consumed = 0;
+ TSIOBufferReader const reader = data->m_upstream.m_read.m_reader;
+ TSVIO const input_vio = data->m_upstream.m_read.m_vio;
+ TSParseResult const res =
data->m_resp_hdrmgr.populateFrom(data->m_http_parser, reader,
TSHttpHdrParseResp, &consumed);
+
+ TSVIONDoneSet(input_vio, TSVIONDoneGet(input_vio) + consumed);
+
+ if (TS_PARSE_CONT == res) {
+ return;
+ }
+
+ data->m_server_block_header_parsed = true;
+ note_purge_block_result(data);
+ }
+
+ // No block PURGE response has a body worth reading, but drop whatever
arrives
+ // so the upstream read cannot stall on a full buffer
+ data->m_upstream.m_read.drainReader();
+ } break;
+
+ case TS_EVENT_VCONN_EOS: {
+ if (!data->m_server_block_header_parsed) {
+ // No response at all is not evidence the block was absent
+ note_purge_failure(data, TS_HTTP_STATUS_BAD_GATEWAY);
+ DEBUG_LOG("Purge block %" PRId64 " ended with no response header",
data->m_blocknum);
+ }
+
+ // The next block cannot be requested while this one holds the upstream
+ data->m_upstream.close();
+ advance_purge(contp, data);
+ } break;
+
+ default: {
+ DEBUG_LOG("%p handle_purge_resp unhandled event: %s", data,
TSHttpEventNameLookup(event));
+ } break;
+ }
+}
+
// this is called every time the server has data for us
void
handle_server_resp(TSCont contp, TSEvent event, Data *const data)
{
+ // A purge never transfers content, so it gets its own state machine
+ if (data->is_purge()) {
+ handle_purge_resp(contp, event, data);
+ return;
+ }
+
switch (event) {
case TS_EVENT_VCONN_READ_READY: {
if (data->m_blockstate == BlockState::Passthru) {
@@ -691,10 +883,7 @@ handle_server_resp(TSCont contp, TSEvent event, Data
*const data)
// isn't keeping up
bool start_next_block = false;
- if (data->m_method_type == TS_HTTP_METHOD_PURGE) {
- // for PURGE requests, clients won't request more data (no body
content)
- start_next_block = true;
- } else if (data->m_dnstream.m_write.isOpen()) {
+ if (data->m_dnstream.m_write.isOpen()) {
// check throttle condition
TSVIO const output_vio = data->m_dnstream.m_write.m_vio;
int64_t const output_done = TSVIONDoneGet(output_vio);
diff --git a/plugins/slice/server.h b/plugins/slice/server.h
index c0d77e4803..667b854875 100644
--- a/plugins/slice/server.h
+++ b/plugins/slice/server.h
@@ -35,3 +35,14 @@
*/
void handle_server_resp(TSCont contp, TSEvent event, Data *const data);
+
+/** Walk the object's slice blocks issuing a PURGE for each.
+ *
+ * A purge transfers no content, so it runs a separate state machine: it walks
+ * every block whether or not each is cached, taking the object's extent from
the
+ * ones it removes, and answers the client only once the walk is done.
+ */
+void handle_purge_resp(TSCont contp, TSEvent event, Data *const data);
+
+// Answer a purge: 200 if any block was removed, 404 if none was, or status if
given
+void finish_purge(TSCont contp, Data *const data, TSHttpStatus const status =
TS_HTTP_STATUS_NONE);
diff --git a/plugins/slice/util.cc b/plugins/slice/util.cc
index 2f3a578629..96b6060f8b 100644
--- a/plugins/slice/util.cc
+++ b/plugins/slice/util.cc
@@ -138,7 +138,7 @@ request_block(TSCont contp, Data *const data)
}
header.removeKey(SLICE_CRR_HEADER.data(), SLICE_CRR_HEADER.size());
- if (data->m_config->m_prefetchcount > 0 && data->m_req_range.m_beg >= 0 &&
+ if (!data->is_purge() && data->m_config->m_prefetchcount > 0 &&
data->m_req_range.m_beg >= 0 &&
data->m_blocknum ==
data->m_req_range.firstBlockFor(data->m_config->m_blockbytes)) {
header.setKeyVal(SLICE_CRR_HEADER.data(), SLICE_CRR_HEADER.size(),
SLICE_CRR_VAL.data(), SLICE_CRR_VAL.size());
}
@@ -232,6 +232,12 @@ request_block(TSCont contp, Data *const data)
bool
reader_avail_more_than(TSIOBufferReader const reader, int64_t bytes)
{
+ // A purge refused before opening an upstream has no reader, and
TSIOBufferReaderStart
+ // does not tolerate a null one
+ if (nullptr == reader) {
+ return false;
+ }
+
TSIOBufferBlock block = TSIOBufferReaderStart(reader);
if (nullptr == block) {
diff --git a/src/proxy/http/HttpTransact.cc b/src/proxy/http/HttpTransact.cc
index 4aaa8b81a5..09b16decb5 100644
--- a/src/proxy/http/HttpTransact.cc
+++ b/src/proxy/http/HttpTransact.cc
@@ -7472,6 +7472,25 @@
HttpTransact::delete_all_document_alternates_and_return(State *s, bool cache_hit
build_response(s, &s->hdr_info.client_response,
s->client_info.http_version,
(cache_hit == true) ? HTTPStatus::OK :
HTTPStatus::NOT_FOUND);
+ // Report what was removed, so a caller holding one piece of a larger
resource
+ // can learn its extent without a second lookup. Not Content-Range
itself: on
+ // a 200 that is meaningless per RFC 9110, and cache_range_requests
reads the
+ // pair as a stored 206 being served as 200 and rewrites the status.
+ if (cache_hit == true && s->method == HTTP_WKSIDX_PURGE &&
s->cache_info.object_read != nullptr) {
+ // read by the slice plugin as PURGED_CONTENT_RANGE in
plugins/slice/HttpHeader.h
+ static constexpr std::string_view
PURGED_CONTENT_RANGE{"X-Purged-Content-Range"};
+ HTTPHdr *const cached_response =
s->cache_info.object_read->response_get();
+
+ if (cached_response != nullptr) {
+ auto
value{cached_response->value_get(static_cast<std::string_view>(MIME_FIELD_CONTENT_RANGE))};
+ if (!value.empty()) {
+ s->hdr_info.client_response.value_set(PURGED_CONTENT_RANGE, value);
+ TxnDbg(dbg_ctl_http_trans, "PURGE reporting
X-Purged-Content-Range: %.*s", static_cast<int>(value.length()),
+ value.data());
+ }
+ }
+ }
+
return true;
} else {
if (valid_max_forwards) {
diff --git
a/tests/gold_tests/pluginTest/slice/replay/slice_purge_gaps_client.replay.yaml
b/tests/gold_tests/pluginTest/slice/replay/slice_purge_gaps_client.replay.yaml
new file mode 100644
index 0000000000..18b768f37a
--- /dev/null
+++
b/tests/gold_tests/pluginTest/slice/replay/slice_purge_gaps_client.replay.yaml
@@ -0,0 +1,895 @@
+# 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.
+
+#
+# Client side of slice_purge_gaps.test.py. Each transaction is one phase,
selected
+# by uuid with the verifier-client --keys option; several phases share a run
where
+# they are independent. The uuid also picks which origin transaction the block
+# requests slice derives from will match.
+#
+# Blocks are 10 bytes: block 0 is "a", 1 is "b", 2 is "c", 4 is "e". Every
proxy
+# runs --ref-relative, so a ranged GET touches only the blocks its range covers
+# and a fill phase can leave a chosen block uncached.
+#
+
+meta:
+ version: "1.0"
+
+sessions:
+- transactions:
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /hole
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-9]
+ - [uuid, hole-fill-0]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 0-9/30, as: equal}]
+ content:
+ encoding: plain
+ data: 'aaaaaaaaaa'
+ verify: {as: equal}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /hole
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=20-29]
+ - [uuid, hole-fill-2]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 20-29/30, as: equal}]
+ content:
+ encoding: plain
+ data: 'cccccccccc'
+ verify: {as: equal}
+
+ - client-request:
+ method: PURGE
+ version: "1.1"
+ url: /hole
+ headers:
+ fields:
+ - [Host, slice]
+ - [uuid, hole-purge]
+ proxy-response:
+ status: 200
+ headers:
+ fields:
+ - [Content-Length, {value: '0', as: equal}]
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /hole
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-9]
+ - [uuid, hole-check-0]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 0-9/30, as: equal}]
+ content:
+ encoding: plain
+ data: 'aaaaaaaaaa'
+ verify: {as: equal}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /hole
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=20-29]
+ - [uuid, hole-check-2]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 20-29/30, as: equal}]
+ content:
+ encoding: plain
+ data: 'cccccccccc'
+ verify: {as: equal}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /nofirst
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=10-19]
+ - [uuid, nofirst-fill-1]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 10-19/30, as: equal}]
+ content:
+ encoding: plain
+ data: 'bbbbbbbbbb'
+ verify: {as: equal}
+
+ - client-request:
+ method: PURGE
+ version: "1.1"
+ url: /nofirst
+ headers:
+ fields:
+ - [Host, slice]
+ - [uuid, nofirst-purge]
+ proxy-response:
+ status: 200
+ headers:
+ fields:
+ - [Content-Length, {value: '0', as: equal}]
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /nofirst
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=10-19]
+ - [uuid, nofirst-check-1]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 10-19/30, as: equal}]
+ content:
+ encoding: plain
+ data: 'bbbbbbbbbb'
+ verify: {as: equal}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /mixed
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-9]
+ - [uuid, mixed-fill-0]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 0-9/30, as: equal}]
+ content:
+ encoding: plain
+ data: 'aaaaaaaaaa'
+ verify: {as: equal}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /mixed
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=10-19]
+ - [uuid, mixed-fill-1]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 10-19/50, as: equal}]
+ content:
+ encoding: plain
+ data: 'bbbbbbbbbb'
+ verify: {as: equal}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /mixed
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=40-49]
+ - [uuid, mixed-fill-4]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 40-49/50, as: equal}]
+ content:
+ encoding: plain
+ data: 'eeeeeeeeee'
+ verify: {as: equal}
+
+ - client-request:
+ method: PURGE
+ version: "1.1"
+ url: /mixed
+ headers:
+ fields:
+ - [Host, slice]
+ - [uuid, mixed-purge]
+ proxy-response:
+ status: 200
+ headers:
+ fields:
+ - [Content-Length, {value: '0', as: equal}]
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /mixed
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=40-49]
+ - [uuid, mixed-check-4]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 40-49/50, as: equal}]
+ content:
+ encoding: plain
+ data: 'eeeeeeeeee'
+ verify: {as: equal}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /mixed
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-9]
+ - [uuid, mixed-check-0]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 0-9/30, as: equal}]
+ content:
+ encoding: plain
+ data: 'aaaaaaaaaa'
+ verify: {as: equal}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /mixed
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=10-19]
+ - [uuid, mixed-check-1]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 10-19/50, as: equal}]
+ content:
+ encoding: plain
+ data: 'bbbbbbbbbb'
+ verify: {as: equal}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /ranged
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-9]
+ - [uuid, ranged-fill-0]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 0-9/30, as: equal}]
+ content:
+ encoding: plain
+ data: 'aaaaaaaaaa'
+ verify: {as: equal}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /ranged
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=20-29]
+ - [uuid, ranged-fill-2]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 20-29/30, as: equal}]
+ content:
+ encoding: plain
+ data: 'cccccccccc'
+ verify: {as: equal}
+
+ - client-request:
+ method: PURGE
+ version: "1.1"
+ url: /ranged
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-29]
+ - [uuid, ranged-purge]
+ proxy-response:
+ status: 200
+ headers:
+ fields:
+ - [Content-Length, {value: '0', as: equal}]
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /ranged
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-9]
+ - [uuid, ranged-check-0]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 0-9/30, as: equal}]
+ content:
+ encoding: plain
+ data: 'aaaaaaaaaa'
+ verify: {as: equal}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /ranged
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=20-29]
+ - [uuid, ranged-check-2]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 20-29/30, as: equal}]
+ content:
+ encoding: plain
+ data: 'cccccccccc'
+ verify: {as: equal}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /openend
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-9]
+ - [uuid, openend-fill-0]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 0-9/30, as: equal}]
+ content:
+ encoding: plain
+ data: 'aaaaaaaaaa'
+ verify: {as: equal}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /openend
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=20-29]
+ - [uuid, openend-fill-2]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 20-29/30, as: equal}]
+ content:
+ encoding: plain
+ data: 'cccccccccc'
+ verify: {as: equal}
+
+ - client-request:
+ method: PURGE
+ version: "1.1"
+ url: /openend
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=20-]
+ - [uuid, openend-purge]
+ proxy-response:
+ status: 200
+ headers:
+ fields:
+ - [Content-Length, {value: '0', as: equal}]
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /openend
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=20-29]
+ - [uuid, openend-check-2]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 20-29/30, as: equal}]
+ content:
+ encoding: plain
+ data: 'cccccccccc'
+ verify: {as: equal}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /openend
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-9]
+ - [uuid, openend-check-0]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 0-9/30, as: equal}]
+ content:
+ encoding: plain
+ data: 'aaaaaaaaaa'
+ verify: {as: equal}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /outside
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=10-19]
+ - [uuid, outside-fill-1]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 10-19/30, as: equal}]
+ content:
+ encoding: plain
+ data: 'bbbbbbbbbb'
+ verify: {as: equal}
+
+ - client-request:
+ method: PURGE
+ version: "1.1"
+ url: /outside
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-9]
+ - [uuid, outside-purge]
+ proxy-response:
+ status: 404
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /outside
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=10-19]
+ - [uuid, outside-check-1]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 10-19/30, as: equal}]
+ content:
+ encoding: plain
+ data: 'bbbbbbbbbb'
+ verify: {as: equal}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /endbytes
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-9]
+ - [uuid, endbytes-fill-0]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 0-9/30, as: equal}]
+ content:
+ encoding: plain
+ data: 'aaaaaaaaaa'
+ verify: {as: equal}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /endbytes
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=20-29]
+ - [uuid, endbytes-fill-2]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 20-29/30, as: equal}]
+ content:
+ encoding: plain
+ data: 'cccccccccc'
+ verify: {as: equal}
+
+ - client-request:
+ method: PURGE
+ version: "1.1"
+ url: /endbytes
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=-10]
+ - [uuid, endbytes-purge]
+ proxy-response:
+ status: 200
+ headers:
+ fields:
+ - [Content-Length, {value: '0', as: equal}]
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /endbytes
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-9]
+ - [uuid, endbytes-check-0]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 0-9/30, as: equal}]
+ content:
+ encoding: plain
+ data: 'aaaaaaaaaa'
+ verify: {as: equal}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /endbytes
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=20-29]
+ - [uuid, endbytes-check-2]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 20-29/30, as: equal}]
+ content:
+ encoding: plain
+ data: 'cccccccccc'
+ verify: {as: equal}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /sparse
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=40-49]
+ - [uuid, sparse-fill-4]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 40-49/50, as: equal}]
+ content:
+ encoding: plain
+ data: 'eeeeeeeeee'
+ verify: {as: equal}
+
+ - client-request:
+ method: PURGE
+ version: "1.1"
+ url: /sparse
+ headers:
+ fields:
+ - [Host, slice]
+ - [X-Slice-Purge-Probe, not-a-number]
+ - [uuid, sparse-purge-narrow]
+ proxy-response:
+ status: 404
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /sparse
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=40-49]
+ - [uuid, sparse-check-alive]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 40-49/50, as: equal}]
+ content:
+ encoding: plain
+ data: 'eeeeeeeeee'
+ verify: {as: equal}
+
+ - client-request:
+ method: PURGE
+ version: "1.1"
+ url: /sparse
+ headers:
+ fields:
+ - [Host, slice]
+ - [X-Slice-Purge-Probe, '8']
+ - [uuid, sparse-purge-wide]
+ proxy-response:
+ status: 200
+ headers:
+ fields:
+ - [Content-Length, {value: '0', as: equal}]
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /sparse
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=40-49]
+ - [uuid, sparse-check-gone]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 40-49/50, as: equal}]
+ content:
+ encoding: plain
+ data: 'eeeeeeeeee'
+ verify: {as: equal}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /badrange
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-9]
+ - [uuid, badrange-fill-0]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 0-9/30, as: equal}]
+ content:
+ encoding: plain
+ data: 'aaaaaaaaaa'
+ verify: {as: equal}
+
+ - client-request:
+ method: PURGE
+ version: "1.1"
+ url: /badrange
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=not-a-range]
+ - [uuid, badrange-purge]
+ proxy-response:
+ status: 400
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /badrange
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-9]
+ - [uuid, badrange-check-0]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 0-9/30, as: equal}]
+ content:
+ encoding: plain
+ data: 'aaaaaaaaaa'
+ verify: {as: equal}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /failblock
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-9]
+ - [uuid, failblock-fill-0]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 0-9/40, as: equal}]
+ content:
+ encoding: plain
+ data: 'aaaaaaaaaa'
+ verify: {as: equal}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /failblock
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=30-39]
+ - [uuid, failblock-fill-3]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 30-39/40, as: equal}]
+ content:
+ encoding: plain
+ data: 'dddddddddd'
+ verify: {as: equal}
+
+ - client-request:
+ method: PURGE
+ version: "1.1"
+ url: /failblock
+ headers:
+ fields:
+ - [Host, slice]
+ - [uuid, failblock-purge]
+ proxy-response:
+ status: 500
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /failblock
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-9]
+ - [uuid, failblock-check-0]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 0-9/40, as: equal}]
+ content:
+ encoding: plain
+ data: 'aaaaaaaaaa'
+ verify: {as: equal}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /failblock
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=30-39]
+ - [uuid, failblock-check-3]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 30-39/40, as: equal}]
+ content:
+ encoding: plain
+ data: 'dddddddddd'
+ verify: {as: equal}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /denied
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-9]
+ - [uuid, denied-fill-0]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 0-9/30, as: equal}]
+ content:
+ encoding: plain
+ data: 'aaaaaaaaaa'
+ verify: {as: equal}
+
+ - client-request:
+ method: PURGE
+ version: "1.1"
+ url: /denied
+ headers:
+ fields:
+ - [Host, slice]
+ - [uuid, denied-purge]
+ proxy-response:
+ status: 403
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /denied
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-9]
+ - [uuid, denied-check-0]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 0-9/30, as: equal}]
+ content:
+ encoding: plain
+ data: 'aaaaaaaaaa'
+ verify: {as: equal}
diff --git
a/tests/gold_tests/pluginTest/slice/replay/slice_purge_gaps_server.replay.yaml
b/tests/gold_tests/pluginTest/slice/replay/slice_purge_gaps_server.replay.yaml
new file mode 100644
index 0000000000..b60525e8f8
--- /dev/null
+++
b/tests/gold_tests/pluginTest/slice/replay/slice_purge_gaps_server.replay.yaml
@@ -0,0 +1,586 @@
+# 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.
+
+#
+# Origin side of slice_purge_gaps.test.py.
+#
+# Keyed on "{url}{field.range}{field.uuid}", so one transaction answers one
block
+# of one phase. Blocks are 10 bytes: block 0 is "a", 1 is "b", 2 is "c", 4 is
"e".
+#
+# PURGE is answered by ATS and the plugin issues no other request kind, so
neither
+# appears here.
+#
+# A check phase for a block expected to SURVIVE its purge is deliberately
absent:
+# if such a block were wrongly purged, ATS would ask for an unregistered key
and
+# the client's own expectation would fail too.
+#
+
+meta:
+ version: "1.0"
+
+sessions:
+- transactions:
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /hole
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-9]
+ - [uuid, hole-fill-0]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 0-9/30]
+ - [Content-Length, '10']
+ - [Cache-Control, 'public, max-age=86400']
+ content: {encoding: plain, data: 'aaaaaaaaaa', size: 10}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /hole
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=20-29]
+ - [uuid, hole-fill-2]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 20-29/30]
+ - [Content-Length, '10']
+ - [Cache-Control, 'public, max-age=86400']
+ content: {encoding: plain, data: 'cccccccccc', size: 10}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /hole
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-9]
+ - [uuid, hole-check-0]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 0-9/30]
+ - [Content-Length, '10']
+ - [Cache-Control, 'public, max-age=86400']
+ content: {encoding: plain, data: 'aaaaaaaaaa', size: 10}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /hole
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=20-29]
+ - [uuid, hole-check-2]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 20-29/30]
+ - [Content-Length, '10']
+ - [Cache-Control, 'public, max-age=86400']
+ content: {encoding: plain, data: 'cccccccccc', size: 10}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /nofirst
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=10-19]
+ - [uuid, nofirst-fill-1]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 10-19/30]
+ - [Content-Length, '10']
+ - [Cache-Control, 'public, max-age=86400']
+ content: {encoding: plain, data: 'bbbbbbbbbb', size: 10}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /nofirst
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=10-19]
+ - [uuid, nofirst-check-1]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 10-19/30]
+ - [Content-Length, '10']
+ - [Cache-Control, 'public, max-age=86400']
+ content: {encoding: plain, data: 'bbbbbbbbbb', size: 10}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /mixed
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-9]
+ - [uuid, mixed-fill-0]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 0-9/30]
+ - [Content-Length, '10']
+ - [Cache-Control, 'public, max-age=86400']
+ content: {encoding: plain, data: 'aaaaaaaaaa', size: 10}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /mixed
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=10-19]
+ - [uuid, mixed-fill-1]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 10-19/50]
+ - [Content-Length, '10']
+ - [Cache-Control, 'public, max-age=86400']
+ content: {encoding: plain, data: 'bbbbbbbbbb', size: 10}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /mixed
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=40-49]
+ - [uuid, mixed-fill-4]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 40-49/50]
+ - [Content-Length, '10']
+ - [Cache-Control, 'public, max-age=86400']
+ content: {encoding: plain, data: 'eeeeeeeeee', size: 10}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /mixed
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=40-49]
+ - [uuid, mixed-check-4]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 40-49/50]
+ - [Content-Length, '10']
+ - [Cache-Control, 'public, max-age=86400']
+ content: {encoding: plain, data: 'eeeeeeeeee', size: 10}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /ranged
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-9]
+ - [uuid, ranged-fill-0]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 0-9/30]
+ - [Content-Length, '10']
+ - [Cache-Control, 'public, max-age=86400']
+ content: {encoding: plain, data: 'aaaaaaaaaa', size: 10}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /ranged
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=20-29]
+ - [uuid, ranged-fill-2]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 20-29/30]
+ - [Content-Length, '10']
+ - [Cache-Control, 'public, max-age=86400']
+ content: {encoding: plain, data: 'cccccccccc', size: 10}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /ranged
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-9]
+ - [uuid, ranged-check-0]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 0-9/30]
+ - [Content-Length, '10']
+ - [Cache-Control, 'public, max-age=86400']
+ content: {encoding: plain, data: 'aaaaaaaaaa', size: 10}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /ranged
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=20-29]
+ - [uuid, ranged-check-2]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 20-29/30]
+ - [Content-Length, '10']
+ - [Cache-Control, 'public, max-age=86400']
+ content: {encoding: plain, data: 'cccccccccc', size: 10}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /openend
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-9]
+ - [uuid, openend-fill-0]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 0-9/30]
+ - [Content-Length, '10']
+ - [Cache-Control, 'public, max-age=86400']
+ content: {encoding: plain, data: 'aaaaaaaaaa', size: 10}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /openend
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=20-29]
+ - [uuid, openend-fill-2]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 20-29/30]
+ - [Content-Length, '10']
+ - [Cache-Control, 'public, max-age=86400']
+ content: {encoding: plain, data: 'cccccccccc', size: 10}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /openend
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=20-29]
+ - [uuid, openend-check-2]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 20-29/30]
+ - [Content-Length, '10']
+ - [Cache-Control, 'public, max-age=86400']
+ content: {encoding: plain, data: 'cccccccccc', size: 10}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /outside
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=10-19]
+ - [uuid, outside-fill-1]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 10-19/30]
+ - [Content-Length, '10']
+ - [Cache-Control, 'public, max-age=86400']
+ content: {encoding: plain, data: 'bbbbbbbbbb', size: 10}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /endbytes
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-9]
+ - [uuid, endbytes-fill-0]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 0-9/30]
+ - [Content-Length, '10']
+ - [Cache-Control, 'public, max-age=86400']
+ content: {encoding: plain, data: 'aaaaaaaaaa', size: 10}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /endbytes
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=20-29]
+ - [uuid, endbytes-fill-2]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 20-29/30]
+ - [Content-Length, '10']
+ - [Cache-Control, 'public, max-age=86400']
+ content: {encoding: plain, data: 'cccccccccc', size: 10}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /endbytes
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-9]
+ - [uuid, endbytes-check-0]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 0-9/30]
+ - [Content-Length, '10']
+ - [Cache-Control, 'public, max-age=86400']
+ content: {encoding: plain, data: 'aaaaaaaaaa', size: 10}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /endbytes
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=20-29]
+ - [uuid, endbytes-check-2]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 20-29/30]
+ - [Content-Length, '10']
+ - [Cache-Control, 'public, max-age=86400']
+ content: {encoding: plain, data: 'cccccccccc', size: 10}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /sparse
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=40-49]
+ - [uuid, sparse-fill-4]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 40-49/50]
+ - [Content-Length, '10']
+ - [Cache-Control, 'public, max-age=86400']
+ content: {encoding: plain, data: 'eeeeeeeeee', size: 10}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /sparse
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=40-49]
+ - [uuid, sparse-check-gone]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 40-49/50]
+ - [Content-Length, '10']
+ - [Cache-Control, 'public, max-age=86400']
+ content: {encoding: plain, data: 'eeeeeeeeee', size: 10}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /badrange
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-9]
+ - [uuid, badrange-fill-0]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 0-9/30]
+ - [Content-Length, '10']
+ - [Cache-Control, 'public, max-age=86400']
+ content: {encoding: plain, data: 'aaaaaaaaaa', size: 10}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /failblock
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-9]
+ - [uuid, failblock-fill-0]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 0-9/40]
+ - [Content-Length, '10']
+ - [Cache-Control, 'public, max-age=86400']
+ content: {encoding: plain, data: 'aaaaaaaaaa', size: 10}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /failblock
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=30-39]
+ - [uuid, failblock-fill-3]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 30-39/40]
+ - [Content-Length, '10']
+ - [Cache-Control, 'public, max-age=86400']
+ content: {encoding: plain, data: 'dddddddddd', size: 10}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /failblock
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-9]
+ - [uuid, failblock-check-0]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 0-9/40]
+ - [Content-Length, '10']
+ - [Cache-Control, 'public, max-age=86400']
+ content: {encoding: plain, data: 'aaaaaaaaaa', size: 10}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /denied
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-9]
+ - [uuid, denied-fill-0]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 0-9/30]
+ - [Content-Length, '10']
+ - [Cache-Control, 'public, max-age=86400']
+ content: {encoding: plain, data: 'aaaaaaaaaa', size: 10}
diff --git
a/tests/gold_tests/pluginTest/slice/replay/slice_stale_generation_client.replay.yaml
b/tests/gold_tests/pluginTest/slice/replay/slice_stale_generation_client.replay.yaml
new file mode 100644
index 0000000000..b000ceda3b
--- /dev/null
+++
b/tests/gold_tests/pluginTest/slice/replay/slice_stale_generation_client.replay.yaml
@@ -0,0 +1,206 @@
+# 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.
+
+#
+# Client side of slice_stale_generation.test.py. Each transaction is one phase,
+# selected with the verifier-client --keys option, and its uuid also selects
+# which generation the origin serves for the block requests that slice derives
+# from it.
+#
+# Generation 1 is 32 bytes of "a" with ETag "v1". Generation 2, which replaces
+# it under the same URL once the cache is filled, is 64 bytes of "b" with ETag
+# "v2".
+#
+
+meta:
+ version: "1.0"
+
+sessions:
+- transactions:
+
+ #
+ # Fill the cache while the origin holds generation 1.
+ #
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /obj
+ headers:
+ fields:
+ - [Host, slice]
+ - [uuid, fill]
+ proxy-response:
+ status: 200
+ headers:
+ fields:
+ - [Content-Length, {value: '32', as: equal}]
+ - [ETag, {value: '"v1"', as: equal}]
+ content:
+ encoding: plain
+ data: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
+ verify: {as: equal}
+
+ #
+ # The bug. Bytes 16-47 are entirely present in the current 64 byte object,
but
+ # the cached reference block still reports the old 32 byte length, so the
range
+ # is clipped to the stale object's end and served with the stale ETag as a
+ # fresh hit. Correct would be: 206, bytes 16-47/64, ETag "v2", 32 bytes of
"b".
+ #
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /obj
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=16-47]
+ - [x-debug, x-cache]
+ - [uuid, clipped]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 16-31/32, as: equal}]
+ - [Content-Length, {value: '16', as: equal}]
+ - [ETag, {value: '"v1"', as: equal}]
+ - [X-Cache, {value: hit-fresh, as: prefix}]
+ content:
+ encoding: plain
+ data: 'aaaaaaaaaaaaaaaa'
+ verify: {as: equal}
+
+ #
+ # Worse: a range that starts past the stale length is refused outright, even
+ # though those bytes exist in the current object. Correct would be: 206,
+ # bytes 32-63/64.
+ #
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /obj
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=32-63]
+ - [uuid, unsatisfiable]
+ proxy-response:
+ status: 416
+ headers:
+ fields:
+ - [Content-Range, {value: '*/32', as: equal}]
+ - [ETag, {as: absent}]
+
+ #
+ # Control: a path first fetched after the object was replaced is served
+ # correctly, so the two phases above are measuring the stale cached
generation
+ # and not a broken plugin or harness.
+ #
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /fresh
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=16-47]
+ - [x-debug, x-cache]
+ - [uuid, control]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 16-47/64, as: equal}]
+ - [Content-Length, {value: '32', as: equal}]
+ - [ETag, {value: '"v2"', as: equal}]
+ content:
+ encoding: plain
+ data: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
+ verify: {as: equal}
+
+ #
+ # Cache an interior block of /mixed at generation 1, for
+ # SliceMixedGenerationTest. The reference block is cached with a one second
+ # lifetime and the interior block with a day, so only the reference block
+ # revalidates later.
+ #
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /mixed
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=16-31]
+ - [uuid, fill-interior]
+ proxy-response:
+ status: 206
+ headers:
+ fields:
+ - [Content-Range, {value: bytes 16-31/32, as: equal}]
+ - [Content-Length, {value: '16', as: equal}]
+ - [ETag, {value: '"v1"', as: equal}]
+ content:
+ encoding: plain
+ data: 'aaaaaaaaaaaaaaaa'
+ verify: {as: equal}
+
+ #
+ # The mixed generation failure. The reference block revalidates to generation
+ # 2 while the cached interior block is still generation 1, so slice
advertises
+ # the current object correctly and then cannot deliver it: the interior
block's
+ # Content-Range disagrees, the self heal refetches the interior and then the
+ # reference and gets the same blocks back, and the transaction is aborted
with
+ # the response header already on the wire. The client is left holding a well
+ # formed 206 that promises 16 bytes and delivers none.
+ #
+ # This is the production symptom behind the block walk's "curl exit 18":
+ # a correct looking Content-Range of .../7031250004 followed by a body that
+ # stops early.
+ #
+ # The client is left with nothing parsable: slice aborts the transaction, and
+ # the response header it had already formed from the reference block never
+ # reaches the wire. There is deliberately no proxy-response node here,
because
+ # no response arrives to verify. The test asserts the failure on the
+ # verifier-client's own output and in diags.log instead.
+ #
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /mixed
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=16-31]
+ - [x-debug, x-cache]
+ - [uuid, mixed]
+
+ # A second child with a completely cold cache, pointed at the same parent. It
+ # never saw the previous generation, yet it fails identically, because the
two
+ # blocks it fetches come from the parent and the parent is holding one of
each.
+ # This is the incident's shape: the mixed set existed in exactly one place,
the
+ # parent, and every child node inherited it. It also shows slice cannot
evict:
+ # the earlier abort left the mixed pair on the parent untouched.
+ #
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /mixed
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=16-31]
+ - [x-debug, x-cache]
+ - [uuid, cold-child]
diff --git
a/tests/gold_tests/pluginTest/slice/replay/slice_stale_generation_server.replay.yaml
b/tests/gold_tests/pluginTest/slice/replay/slice_stale_generation_server.replay.yaml
new file mode 100644
index 0000000000..21d778a1f9
--- /dev/null
+++
b/tests/gold_tests/pluginTest/slice/replay/slice_stale_generation_server.replay.yaml
@@ -0,0 +1,286 @@
+# 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.
+
+#
+# Origin side of slice_stale_generation.test.py.
+#
+# The server is keyed on "{url}{field.range}{field.uuid}", so each slice block
+# request selects a transaction by the block's byte range and by the phase uuid
+# that the client propagated through the plugin. The phase uuid is what
replaces
+# the origin object: the fill phase is answered with generation 1 (32 bytes,
+# ETag "v1"), every later phase with generation 2 (64 bytes, ETag "v2").
+#
+# For the two phases that read the already cached object there is deliberately
+# only one transaction each, the reference block. Those exist so the origin
+# really does hold the new object, but the test asserts the server never
+# receives them: the cached blocks are fresh for a day, so nothing revalidates.
+# If slice did go upstream it would either pick up generation 2, failing the
+# client side assertions, or ask for an unregistered block and get a 404,
+# failing the diags.log assertion.
+#
+
+meta:
+ version: "1.0"
+
+sessions:
+- transactions:
+
+ #
+ # Phase fill: the origin holds generation 1, cached in 16 byte blocks with a
+ # day of freshness.
+ #
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /obj
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-15]
+ - [uuid, fill]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 0-15/32]
+ - [Content-Length, '16']
+ - [ETag, '"v1"']
+ - [Last-Modified, 'Mon, 01 Jun 2026 00:00:00 GMT']
+ - [Cache-Control, 'public, max-age=86400']
+ - [Accept-Ranges, bytes]
+ content: {encoding: plain, data: 'aaaaaaaaaaaaaaaa', size: 16}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /obj
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=16-31]
+ - [uuid, fill]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 16-31/32]
+ - [Content-Length, '16']
+ - [ETag, '"v1"']
+ - [Last-Modified, 'Mon, 01 Jun 2026 00:00:00 GMT']
+ - [Cache-Control, 'public, max-age=86400']
+ - [Accept-Ranges, bytes]
+ content: {encoding: plain, data: 'aaaaaaaaaaaaaaaa', size: 16}
+
+ #
+ # The object is replaced here. Generation 2 is longer, with a new ETag and
+ # Last-Modified, under the same URL. The reference block is registered for
+ # each phase that reads the cached object, so the origin genuinely holds the
+ # new object, but the test asserts these keys are never requested.
+ #
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /obj
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-15]
+ - [uuid, clipped]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 0-15/64]
+ - [Content-Length, '16']
+ - [ETag, '"v2"']
+ - [Last-Modified, 'Wed, 08 Jul 2026 23:54:41 GMT']
+ - [Cache-Control, 'public, max-age=86400']
+ - [Accept-Ranges, bytes]
+ content: {encoding: plain, data: 'bbbbbbbbbbbbbbbb', size: 16}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /obj
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-15]
+ - [uuid, unsatisfiable]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 0-15/64]
+ - [Content-Length, '16']
+ - [ETag, '"v2"']
+ - [Last-Modified, 'Wed, 08 Jul 2026 23:54:41 GMT']
+ - [Cache-Control, 'public, max-age=86400']
+ - [Accept-Ranges, bytes]
+ content: {encoding: plain, data: 'bbbbbbbbbbbbbbbb', size: 16}
+
+ #
+ # Phase control: a path first requested after the object was replaced, so
+ # nothing about it is cached and the client must see generation 2.
+ #
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /fresh
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-15]
+ - [uuid, control]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 0-15/64]
+ - [Content-Length, '16']
+ - [ETag, '"v2"']
+ - [Last-Modified, 'Wed, 08 Jul 2026 23:54:41 GMT']
+ - [Cache-Control, 'public, max-age=86400']
+ - [Accept-Ranges, bytes]
+ content: {encoding: plain, data: 'bbbbbbbbbbbbbbbb', size: 16}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /fresh
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=16-31]
+ - [uuid, control]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 16-31/64]
+ - [Content-Length, '16']
+ - [ETag, '"v2"']
+ - [Last-Modified, 'Wed, 08 Jul 2026 23:54:41 GMT']
+ - [Cache-Control, 'public, max-age=86400']
+ - [Accept-Ranges, bytes]
+ content: {encoding: plain, data: 'bbbbbbbbbbbbbbbb', size: 16}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /fresh
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=32-47]
+ - [uuid, control]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 32-47/64]
+ - [Content-Length, '16']
+ - [ETag, '"v2"']
+ - [Last-Modified, 'Wed, 08 Jul 2026 23:54:41 GMT']
+ - [Cache-Control, 'public, max-age=86400']
+ - [Accept-Ranges, bytes]
+ content: {encoding: plain, data: 'bbbbbbbbbbbbbbbb', size: 16}
+
+ #
+ # Phase fill-interior, used by SliceMixedGenerationTest: /mixed at generation
+ # 1. The reference block is given a one second freshness lifetime so that
+ # later only it revalidates. In production the reference block was evicted
and
+ # refetched after the replacement while the interior blocks survived; a short
+ # lifetime reaches the same end state without depending on eviction.
+ #
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /mixed
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-15]
+ - [uuid, fill-interior]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 0-15/32]
+ - [Content-Length, '16']
+ - [ETag, '"v1"']
+ - [Last-Modified, 'Mon, 01 Jun 2026 00:00:00 GMT']
+ - [Cache-Control, 'public, max-age=1']
+ - [Accept-Ranges, bytes]
+ content: {encoding: plain, data: 'aaaaaaaaaaaaaaaa', size: 16}
+
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /mixed
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=16-31]
+ - [uuid, fill-interior]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 16-31/32]
+ - [Content-Length, '16']
+ - [ETag, '"v1"']
+ - [Last-Modified, 'Mon, 01 Jun 2026 00:00:00 GMT']
+ - [Cache-Control, 'public, max-age=86400']
+ - [Accept-Ranges, bytes]
+ content: {encoding: plain, data: 'aaaaaaaaaaaaaaaa', size: 16}
+
+ #
+ # Phase mixed: the object has been replaced. Only the reference block is
+ # stale, so only it revalidates, and it comes back as generation 2 while the
+ # cached interior block is still generation 1. That is the disagreement slice
+ # cannot heal.
+ #
+ - client-request:
+ method: GET
+ version: "1.1"
+ url: /mixed
+ headers:
+ fields:
+ - [Host, slice]
+ - [Range, bytes=0-15]
+ - [uuid, mixed]
+ server-response:
+ status: 206
+ reason: Partial Content
+ headers:
+ fields:
+ - [Content-Range, bytes 0-15/64]
+ - [Content-Length, '16']
+ - [ETag, '"v2"']
+ - [Last-Modified, 'Wed, 08 Jul 2026 23:54:41 GMT']
+ - [Cache-Control, 'public, max-age=86400']
+ - [Accept-Ranges, bytes]
+ content: {encoding: plain, data: 'bbbbbbbbbbbbbbbb', size: 16}
diff --git a/tests/gold_tests/pluginTest/slice/rules/purge_block_failure.conf
b/tests/gold_tests/pluginTest/slice/rules/purge_block_failure.conf
new file mode 100644
index 0000000000..1737d1dede
--- /dev/null
+++ b/tests/gold_tests/pluginTest/slice/rules/purge_block_failure.conf
@@ -0,0 +1,41 @@
+#
+# 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.
+#
+
+# Make a block PURGE fail, which the core will not do on its own.
+#
+# slice re-issues every block as its own PURGE through this same remap rule,
adding
+# Range and its skip header on the way, so a rule can single out one block or
all of
+# them without ever matching the client's own PURGE. set-status fires at the
remap
+# pseudo-hook, before any cache lookup, so it applies whether or not the block
is
+# cached and leaves a cached one where it was.
+
+# One block mid-walk, behind a block that will be removed and in front of one
that must
+# not be. Gated on the method: this object's fill and check GETs are ranged
too.
+cond %{REMAP_PSEUDO_HOOK} [AND]
+cond %{METHOD} =PURGE [AND]
+cond %{CLIENT-URL:PATH} /failblock/ [AND]
+cond %{CLIENT-HEADER:Range} ="bytes=10-19"
+ set-status 500
+
+# Every block, as ip_allow.yaml refusing PURGE would. The walk stops at the
first, so
+# only block 0 is ever asked for, and nothing is removed at all.
+cond %{REMAP_PSEUDO_HOOK} [AND]
+cond %{METHOD} =PURGE [AND]
+cond %{CLIENT-URL:PATH} /denied/ [AND]
+cond %{CLIENT-HEADER:X-Slicer-Info} ="" [NOT]
+ set-status 403
diff --git a/tests/gold_tests/pluginTest/slice/slice_purge_gaps.test.py
b/tests/gold_tests/pluginTest/slice/slice_purge_gaps.test.py
new file mode 100644
index 0000000000..a8ff70c046
--- /dev/null
+++ b/tests/gold_tests/pluginTest/slice/slice_purge_gaps.test.py
@@ -0,0 +1,346 @@
+"""Verify a PURGE traverses every slice block, not just the ones before a
gap."""
+
+# 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 = __doc__
+
+Test.SkipUnless(
+ Condition.PluginExists('slice.so'),
+ Condition.PluginExists('cache_range_requests.so'),
+ Condition.PluginExists('header_rewrite.so'),
+)
+Test.ContinueOnFail = True
+
+
+class SlicePurgeGapsTest:
+ """Verify a PURGE removes every block it was asked for.
+
+ Slice turns one client PURGE into one PURGE per block. The walk used to
stop
+ at the first block that was not cached, because it had no other end
+ condition: m_contentlen was only ever set from a 206's Content-Range, a
PURGE
+ response carried none, and so m_req_range.blockIsInside() was true for
every
+ block number. The 404 stood in for a length the plugin never learned.
+
+ ATS now reports the removed object's extent as X-Purged-Content-Range, so
the
+ walk learns where the object ends from the blocks it is already deleting,
and
+ a 404 is merely noted and stepped over. The requested range bounds the walk
+ throughout; until some block reports an extent, an open ended one is
bounded
+ only by a limit on consecutive misses. A block response that is neither
200 nor
+ 404 is a failure rather than an absence, and says nothing about the blocks
+ behind it, so the walk stops there and reports that status.
+
+ Each object below exercises one thing, and each is purged exactly once,
+ because a purge consumes the state it is measured against.
+
+ /hole blocks 0 and 2 cached, block 1 absent. A 404 mid-walk must not
end
+ it, so block 2 goes too.
+ /nofirst only block 1 cached. The walk steps over block 0's 404, picks
the
+ extent up from block 1, and the client must not be handed that
404.
+ /mixed blocks reporting a 30 byte and a 50 byte object, as happens when
+ the origin object is replaced in place. The walk must follow the
+ largest extent reported, not the first.
+ /ranged same gap as /hole, purged with a closed range. Already bounded,
so
+ this isolates the 404 handling from the extent discovery.
+ /openend purged with "bytes=20-". The start is stated, so only block 2
may
+ go and block 0 must survive.
+ /outside only block 1 cached, purged for block 0 alone. No block is
removed,
+ so no extent is ever reported and only the range can end the
walk
+ before it reaches a block the client did not name.
+ /endbytes purged with "bytes=-10". A suffix range cannot know which block
it
+ starts at, so it is widened to the whole object and every block
+ goes.
+ /sparse only block 4 cached, on a proxy whose miss bound is 2, so the
+ default walk cannot reach it. Covers the bound, its per-request
+ override, and the fallback when the override is malformed.
+ /badrange purged with an unparseable range, which must be refused rather
+ than silently applied to block 0.
+ /failblock blocks 0 and 3 cached, with a 500 injected for block 1's PURGE.
Block
+ 0 is removed, the walk stops at block 1, block 3 is left alone,
and
+ the client hears the 500 rather than the 200 block 0 earned.
+ /denied block 0 cached, with a 403 injected for every block PURGE, as
+ ip_allow refusing PURGE would. The walk stops at its first
block, so
+ nothing is removed and the client must not be told 404.
+
+ Whether a block was purged is measured on the origin, not on the response
+ body: the origin serves each check phase exactly what the matching fill
phase
+ served, so a purged block and a surviving block are indistinguishable to
the
+ client, and the only difference is whether the origin was asked again.
+ """
+
+ _client_replay: str = 'replay/slice_purge_gaps_client.replay.yaml'
+ _server_replay: str = 'replay/slice_purge_gaps_server.replay.yaml'
+
+ # The core answers a block PURGE with nothing but 200 or 404, so a failure
has to
+ # be injected to be tested at all.
+ _fail_rules: str = 'purge_block_failure.conf'
+
+ _block_bytes: int = 10
+
+ # Low enough that the default walk cannot reach /sparse's block 4, which is
+ # what makes the per-request override observable.
+ _low_miss_bound: int = 2
+
+ # Keyed on the block range as well as the phase uuid, so one origin
+ # transaction answers one block of one phase.
+ _origin_key_format: str = '--format "{url}{field.range}{field.uuid}"'
+
+ def __init__(self) -> None:
+ """Declare the origin and the two proxies."""
+ self._started = False
+ self._configure_origin()
+ self._ts = self._make_ts('ts', fail_rules=True)
+ self._ts_bound = self._make_ts('ts-bound',
miss_bound=self._low_miss_bound)
+
+ self._ts.Disk.traffic_out.Content = Testers.ContainsExpression(
+ 'Purge suffix range widened to the whole object',
+ 'A suffix range purge should be widened rather than guessing at
its start.')
+
+ self._ts_bound.Disk.traffic_out.Content = Testers.ContainsExpression(
+ f'gave up after {self._low_miss_bound} consecutive uncached block',
+ 'The walk should stop at its configured miss bound rather than
scanning the whole range.')
+ self._ts_bound.Disk.diags_log.Content = Testers.ContainsExpression(
+ 'Ignoring invalid X-Slice-Purge-Probe', 'A malformed override
should be rejected, not acted on.')
+
+ # A purge issues nothing but block PURGEs. request_block logs every
request
+ # header it builds at debug, so an only-if-cached here would mean a
+ # read-only length probe had been reintroduced.
+ for ts in (self._ts, self._ts_bound):
+ ts.Disk.traffic_out.Content += Testers.ExcludesExpression(
+ 'only-if-cached', 'A purge should not issue a read-only length
probe.')
+
+ def _configure_origin(self) -> None:
+ """Configure the origin."""
+ self._origin = Test.MakeVerifierServerProcess('origin',
self._server_replay, other_args=self._origin_key_format)
+
+ # ATS answers PURGE itself and the plugin issues no other request
kind, so
+ # neither may ever be seen upstream.
+ self._origin.Streams.stdout += Testers.ExcludesExpression(
+ 'PURGE', 'A PURGE should be answered by ATS and never forwarded to
the origin.')
+ self._origin.Streams.stdout += Testers.ExcludesExpression('HEAD /', 'A
purge should never issue a HEAD upstream.')
+
+ def _make_ts(self, label: str, miss_bound: int = None, fail_rules: bool =
False) -> 'Process':
+ """Create a proxy that slices in front of cache_range_requests.
+
+ --ref-relative keeps a ranged GET from dragging block 0 in as a
reference
+ block, which is what lets a fill phase leave a chosen block uncached.
+
+ :param label: process name suffix.
+ :param miss_bound: --purge-probe-blocks value, or None for the default.
+ :param fail_rules: load the header_rewrite rules that fail a block
PURGE.
+ """
+ ts = Test.MakeATSProcess(label, enable_cache=True)
+
+ rules = ''
+ if fail_rules:
+ ts.Setup.CopyAs(f'rules/{self._fail_rules}', Test.RunDirectory)
+ rules = f' @plugin=header_rewrite.so
@pparam={Test.RunDirectory}/{self._fail_rules}'
+
+ bound = '' if miss_bound is None else f'
@pparam=--purge-probe-blocks={miss_bound}'
+ ts.Disk.remap_config.AddLine(
+ f'map http://slice/
http://127.0.0.1:{self._origin.Variables.http_port}/'
+ f'{rules} @plugin=slice.so
@pparam=--blockbytes-test={self._block_bytes} @pparam=--ref-relative{bound}'
+ ' @plugin=cache_range_requests.so')
+ ts.Disk.records_config.update(
+ {
+ 'proxy.config.diags.debug.enabled': 1,
+ 'proxy.config.diags.debug.tags': 'slice|cache_range_requests',
+ })
+ return ts
+
+ def _run(self, summary: str, phases: str, ts: 'Process' = None) -> None:
+ """Add a TestRun replaying one or more phases, in replay file order.
+
+ :param summary: TestRun description.
+ :param phases: space separated verifier-client keys.
+ :param ts: proxy to replay against, defaulting to the ordinary one.
+ """
+ ts = self._ts if ts is None else ts
+ tr = Test.AddTestRun(summary)
+ if not self._started:
+ tr.Processes.Default.StartBefore(self._origin)
+ tr.Processes.Default.StartBefore(self._ts)
+ tr.Processes.Default.StartBefore(self._ts_bound)
+ self._started = True
+ tr.AddVerifierClientProcess(
+ f"client-{phases.replace(' ', '-')}", self._client_replay,
http_ports=[ts.Variables.port], keys=phases)
+ tr.StillRunningAfter = ts
+
+ def _origin_saw(self, phase: str, block: str, why: str) -> None:
+ """Assert the origin was asked for a block under a given phase."""
+ self._origin.Streams.stdout += Testers.ContainsExpression(f'request
with key /{block}{phase}', why)
+
+ def _fill(self, summary: str, url: str, blocks: list, ts: 'Process' =
None) -> None:
+ """Cache the given blocks of an object, and prove each fill happened.
+
+ The fill assertions carry weight: a check phase only observes that the
+ origin was asked, which is equally true of a block that was purged and
one
+ that was never cached at all. Asserting the fill reached the origin is
what
+ makes the later check mean "removed" rather than merely "absent".
+ """
+ self._run(summary, ' '.join(f'{url}-fill-{block}' for block in
blocks), ts)
+ for block in blocks:
+ self._origin_saw(
+ f'{url}-fill-{block}', f'{url}bytes={block * 10}-{block * 10 +
9}',
+ f'Block {block} of /{url} should have been fetched and
cached.')
+
+ def _purged(self, phase: str, block: str, why: str) -> None:
+ """Assert a phase's block request reached the origin, so it was
purged."""
+ self._origin_saw(phase, block, why)
+
+ def _survived(self, phase: str, block: str, why: str) -> None:
+ """Assert a phase's block request never reached the origin, so it
survived.
+
+ The origin has no transaction registered for such a phase either, so a
+ wrongly purged block fails the client's own expectation as well.
+ """
+ self._origin.Streams.stdout += Testers.ExcludesExpression(f'request
with key /{block}{phase}', why)
+
+ def _gap_mid_walk(self) -> None:
+ """A 404 in the middle of the walk must not end it."""
+ self._fill('Cache blocks 0 and 2 of /hole, leaving block 1 uncached',
'hole', [0, 2])
+ self._run('PURGE the whole /hole object', 'hole-purge')
+ self._run('Both cached blocks of /hole were purged', 'hole-check-0
hole-check-2')
+ self._purged('hole-check-0', 'holebytes=0-9', 'Block 0 is in front of
the gap, so it should be purged.')
+ self._purged('hole-check-2', 'holebytes=20-29', 'A PURGE should
traverse blocks behind an uncached one.')
+
+ def _uncached_first_block(self) -> None:
+ """A miss on the first block must not stop the purge before it
starts."""
+ self._fill('Cache only block 1 of /nofirst', 'nofirst', [1])
+ self._run('PURGE /nofirst, whose block 0 is not cached',
'nofirst-purge')
+ self._run('Block 1 of /nofirst was purged', 'nofirst-check-1')
+ self._purged('nofirst-check-1', 'nofirstbytes=10-19', 'An uncached
first block should not stop the purge.')
+
+ def _largest_extent_wins(self) -> None:
+ """A block reporting a longer object widens the walk."""
+ self._fill('Cache blocks 0, 1 and 4 of /mixed, disagreeing about its
length', 'mixed', [0, 1, 4])
+ self._run('PURGE /mixed', 'mixed-purge')
+ self._run('Block 4 of /mixed was purged, so the walk took the longer
extent', 'mixed-check-4')
+ self._purged('mixed-check-4', 'mixedbytes=40-49', 'The walk should
follow the largest extent any block reports.')
+
+ def _closed_range(self) -> None:
+ """A closed range bounds the walk itself, isolating the 404
step-over."""
+ self._fill('Cache blocks 0 and 2 of /ranged, leaving block 1
uncached', 'ranged', [0, 2])
+ self._run('PURGE /ranged with a closed range spanning the whole
object', 'ranged-purge')
+ self._run('Both cached blocks of /ranged were purged', 'ranged-check-0
ranged-check-2')
+ self._purged('ranged-check-0', 'rangedbytes=0-9', 'A closed range
purge should remove the blocks it covers.')
+ self._purged('ranged-check-2', 'rangedbytes=20-29', 'A 404 should not
end a closed range purge either.')
+
+ def _open_ended_range(self) -> None:
+ """A "bytes=N-" purge states its start, so it purges only what it
names."""
+ self._fill('Cache blocks 0 and 2 of /openend', 'openend', [0, 2])
+ self._run('PURGE /openend from byte 20 on', 'openend-purge')
+ self._run('Block 2 of /openend went and block 0 stayed',
'openend-check-2 openend-check-0')
+ self._purged('openend-check-2', 'openendbytes=20-29', 'The block
covering the range should be purged.')
+ self._survived('openend-check-0', 'openendbytes=0-9', 'A purge should
not remove blocks before its stated start.')
+
+ def _range_bounds_the_walk(self) -> None:
+ """A range ends the walk even before any block has reported an extent.
+
+ /outside has only block 1 cached and is purged for block 0 alone.
Nothing
+ is ever removed, so no block reports an extent, and the miss bound
would
+ otherwise carry the walk past the end of the named range and into a
block
+ the client never asked to remove.
+ """
+ self._fill('Cache only block 1 of /outside', 'outside', [1])
+ self._run('PURGE /outside for block 0 only, which is not cached',
'outside-purge')
+ self._run('Block 1 of /outside survived a purge that did not name it',
'outside-check-1')
+ self._survived(
+ 'outside-check-1', 'outsidebytes=10-19', 'A purge should stop at
the end of its range, not at the miss bound.')
+
+ def _suffix_range(self) -> None:
+ """A "bytes=-N" purge is widened to the whole object."""
+ self._fill('Cache blocks 0 and 2 of /endbytes', 'endbytes', [0, 2])
+ self._run('PURGE the last 10 bytes of /endbytes', 'endbytes-purge')
+ self._run('Every cached block of /endbytes went, not just the named
tail', 'endbytes-check-0 endbytes-check-2')
+ self._purged('endbytes-check-2', 'endbytesbytes=20-29', 'The block
covering the suffix range must be purged.')
+ self._purged(
+ 'endbytes-check-0', 'endbytesbytes=0-9',
+ 'A widened suffix purge removes the whole object, which is a
superset of what was named.')
+
+ def _miss_bound_and_override(self) -> None:
+ """The miss bound stops a walk that has found nothing, and is
overridable.
+
+ /sparse has only block 4 of five cached, out of reach of this proxy's
+ configured bound of two. Nothing about the remap changes between the
two
+ purges below; only the request header does.
+ """
+ ts = self._ts_bound
+ self._fill('Cache only block 4 of /sparse, out of reach of the
configured bound', 'sparse', [4], ts)
+
+ self._run('A purge with a malformed override falls back to the
configured bound', 'sparse-purge-narrow', ts)
+ self._run('Block 4 of /sparse survived the too-narrow purge',
'sparse-check-alive', ts)
+ self._survived('sparse-check-alive', 'sparsebytes=40-49', 'A walk that
gave up before block 4 should not have purged it.')
+
+ self._run('PURGE /sparse with an override wide enough to reach block
4', 'sparse-purge-wide', ts)
+ self._run('Block 4 of /sparse was purged once the bound reached it',
'sparse-check-gone', ts)
+ self._purged('sparse-check-gone', 'sparsebytes=40-49', 'A request
supplied bound should let the walk reach block 4.')
+
+ def _unparseable_range(self) -> None:
+ """A purge whose range cannot be parsed is refused, not guessed at.
+
+ An unparseable range leaves the plugin's range covering block 0 only,
so
+ walking it would delete the head of the object and report success. A
purge
+ is destructive, so it is rejected instead.
+ """
+ self._fill('Cache block 0 of /badrange', 'badrange', [0])
+ self._run('A PURGE with an unparseable range is refused',
'badrange-purge')
+ self._run('Block 0 of /badrange survived the refused purge',
'badrange-check-0')
+ self._survived('badrange-check-0', 'badrangebytes=0-9', 'A refused
purge must not have removed anything.')
+ self._ts.Disk.diags_log.Content = Testers.ContainsExpression(
+ 'Refusing PURGE with an unparseable range', 'The refusal should be
visible in the error log.')
+
+ def _block_failure(self) -> None:
+ """A block that could not be purged is not a block that was absent.
+
+ Only a 404 says the block was not cached. Any other status says the
walk could
+ not tell, and says nothing about the blocks behind it either, so it
stops there
+ and the client hears that status rather than the 200 the earlier
blocks earned.
+ Both failures are injected with header_rewrite, since the core answers
a block
+ PURGE with nothing but 200 or 404 on its own.
+ """
+ self._fill('Cache blocks 0 and 3 of /failblock', 'failblock', [0, 3])
+ self._run('PURGE /failblock, whose block 1 answers 500',
'failblock-purge')
+ self._run('/failblock was purged up to the failing block only',
'failblock-check-0 failblock-check-3')
+ self._purged('failblock-check-0', 'failblockbytes=0-9', 'A block
removed before the failure should stay removed.')
+ self._survived(
+ 'failblock-check-3', 'failblockbytes=30-39',
+ 'The walk should stop at the failing block, leaving what is behind
it cached.')
+ self._ts.Disk.diags_log.Content += Testers.ContainsExpression(
+ 'Purge of block 1 failed', 'The block that ended the walk should
be logged.')
+
+ self._fill('Cache block 0 of /denied', 'denied', [0])
+ self._run('PURGE /denied, whose every block answers 403',
'denied-purge')
+ self._run('Block 0 of /denied survived the refused purge',
'denied-check-0')
+ self._survived('denied-check-0', 'deniedbytes=0-9', 'A purge refused
at its first block must not remove anything.')
+ self._ts.Disk.diags_log.Content += Testers.ContainsExpression(
+ 'Purge of block 0 failed', 'A purge refused outright should be
logged rather than reported as a 404.')
+
+ def run(self) -> None:
+ """Configure the test runs."""
+ self._gap_mid_walk()
+ self._uncached_first_block()
+ self._largest_extent_wins()
+ self._closed_range()
+ self._open_ended_range()
+ self._range_bounds_the_walk()
+ self._suffix_range()
+ self._unparseable_range()
+ self._miss_bound_and_override()
+ self._block_failure()
+
+
+SlicePurgeGapsTest().run()
diff --git a/tests/gold_tests/pluginTest/slice/slice_stale_generation.test.py
b/tests/gold_tests/pluginTest/slice/slice_stale_generation.test.py
new file mode 100644
index 0000000000..0538127939
--- /dev/null
+++ b/tests/gold_tests/pluginTest/slice/slice_stale_generation.test.py
@@ -0,0 +1,327 @@
+"""Verify slice serves a stale object identity after the origin object
changes."""
+
+# 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 = __doc__
+
+Test.SkipUnless(
+ Condition.PluginExists('slice.so'),
+ Condition.PluginExists('cache_range_requests.so'),
+ Condition.PluginExists('xdebug.so'),
+)
+Test.ContinueOnFail = False
+
+
+class SliceHierarchyTest:
+ """Build the child/parent hierarchy the incident ran on.
+
+ Both tiers load slice with the same block size, and both put
+ cache_range_requests behind it, as the affected property does.
+
+ The parent only behaves as a slicing proxy for requests that arrive without
+ slice's skip header. The child stamps that header onto every block request
it
+ issues (client.cc:66), so slice returns immediately on the parent
+ (slice.cc:48) and a child block request is handled there by
+ cache_range_requests alone: look up this exact Range, forward it on a miss,
+ store whatever 206 comes back. The parent therefore holds N independent
+ per-Range objects with no shared identity, and cannot notice, refuse or
+ reconcile a version mix. That is where the mixed set lived in the incident.
+
+ A client hitting the parent directly is a different path: the parent does
+ slice that request, forms its own reference block and clamps against it.
+ """
+
+ _server_replay: str = 'replay/slice_stale_generation_server.replay.yaml'
+ _client_replay: str = 'replay/slice_stale_generation_client.replay.yaml'
+
+ _block_bytes: int = 16
+
+ _origin_key_format: str = '--format "{url}{field.range}{field.uuid}"'
+
+ def __init__(self, name: str) -> None:
+ """Declare the origin, the parent and the child.
+
+ :param name: suffix distinguishing this hierarchy's processes.
+ """
+ self._name = name
+ self._configure_dns()
+ self._configure_origin()
+ self._configure_parent()
+ self._configure_child()
+
+ def _configure_dns(self) -> None:
+ """Configure a DNS server so neither tier consults resolv.conf."""
+ self._dns = Test.MakeDNServer(f'dns-{self._name}', default='127.0.0.1')
+
+ def _configure_origin(self) -> None:
+ """Configure the origin.
+
+ The server is keyed on the block's byte range and on the phase uuid
that
+ slice propagates from the client request, so one replay file answers
+ every block request of every phase and can replace the object between
+ phases without holding any state.
+ """
+ self._origin = Test.MakeVerifierServerProcess(
+ f'origin-{self._name}', self._server_replay,
other_args=self._origin_key_format)
+
+ def _slice_remap(self, source: str, upstream: str) -> str:
+ """Build a remap rule carrying slice in front of
cache_range_requests."""
+ return (
+ f'map {source} {upstream}'
+ f' @plugin=slice.so @pparam=--blockbytes-test={self._block_bytes}'
+ ' @plugin=cache_range_requests.so')
+
+ def _records(self, ts: 'Process', debug: int) -> None:
+ """Apply the records.yaml settings common to both tiers."""
+ ts.Disk.records_config.update(
+ {
+ 'proxy.config.diags.debug.enabled': debug,
+ 'proxy.config.diags.debug.tags': 'slice|cache_range_requests',
+ 'proxy.config.dns.nameservers':
f'127.0.0.1:{self._dns.Variables.Port}',
+ 'proxy.config.dns.resolv_conf': 'NULL',
+ 'proxy.config.http.parent_proxy.self_detect': 0,
+ })
+
+ def _configure_parent(self) -> None:
+ """Configure the parent, which the child's block requests reach."""
+ self._parent = Test.MakeATSProcess(f'ts-parent-{self._name}')
+ self._parent.Disk.remap_config.AddLine(
+ self._slice_remap('http://origin.test/',
f'http://127.0.0.1:{self._origin.Variables.http_port}/'))
+ self._parent.Disk.plugin_config.AddLine('xdebug.so --enable=x-cache')
+ self._records(self._parent, debug=1)
+
+ # slice is loaded on the parent, but every block request the child
sends
+ # carries the skip header, so slice returns immediately and the request
+ # is handled by cache_range_requests alone. Plugin debug output lands
in
+ # traffic.out, not diags.log.
+ self._parent.Disk.traffic_out.Content = Testers.ContainsExpression(
+ 'slice passing GET or HEAD request through to next plugin',
+ "The child's block requests should bypass the parent's slice.")
+ self._parent.Disk.traffic_out.Content += Testers.ExcludesExpression(
+ 'slice accepting and slicing', 'The parent should never slice a
child block request.')
+
+ def _make_child(self, label: str) -> 'Process':
+ """Create a child tier that slices and forwards to the parent."""
+ ts = Test.MakeATSProcess(f'ts-{label}-{self._name}')
+ ts.Disk.remap_config.AddLine(self._slice_remap('http://slice/',
'http://origin.test/'))
+ ts.Disk.parent_config.AddLine(
+ f'dest_domain=. parent=127.0.0.1:{self._parent.Variables.port}'
+ ' round_robin=consistent_hash go_direct=false')
+ ts.Disk.plugin_config.AddLine('xdebug.so --enable=x-cache')
+ self._records(ts, debug=1)
+ return ts
+
+ def _configure_child(self) -> None:
+ """Configure the child, which slices and forwards to the parent."""
+ self._child = self._make_child('child')
+
+ def _start_hierarchy(self, tr: 'TestRun') -> None:
+ """Bring up origin, parent and child for the first TestRun."""
+ tr.Processes.Default.StartBefore(self._dns)
+ tr.Processes.Default.StartBefore(self._origin)
+ tr.Processes.Default.StartBefore(self._parent)
+ tr.Processes.Default.StartBefore(self._child)
+
+ def _replay_phase(self, tr: 'TestRun', phase: str, ts: 'Process' = None)
-> 'Process':
+ """Replay the client transaction for one phase against a child."""
+ ts = self._child if ts is None else ts
+ return tr.AddVerifierClientProcess(
+ f'client-{phase}-{self._name}', self._client_replay,
http_ports=[ts.Variables.port], keys=phase)
+
+ def _still_running(self, tr: 'TestRun') -> None:
+ """Assert both tiers survive the TestRun."""
+ tr.StillRunningAfter = self._child
+ tr.StillRunningAfter = self._parent
+
+
+class SliceStaleGenerationTest(SliceHierarchyTest):
+ """Verify a cached reference block pins a stale object identity.
+
+ The plugin takes the whole object length from the reference block's
+ Content-Range and clips the client range to it, so the cached reference
+ block, not the origin, defines the object's identity for every request::
+
+ server.cc handleFirstServerHeader:
+ data->m_contentlen = blockcr.m_length;
+ data->m_req_range.m_end = std::min(data->m_contentlen,
data->m_req_range.m_end);
+
+ Replacing the object under the same URL therefore forks every cache into
one
+ that filled before the replacement and one that filled after, for as long
as
+ the reference block stays fresh. On the stale side, ranges that exist in
the
+ current object are answered against the stale length with the stale ETag:
+ clipped short, or refused with a 416. Neither path logs a block stitch
error,
+ because handleNextServerHeader only complains when blocks disagree with
each
+ other and here they are uniformly stale.
+
+ Modelled on an incident where a versioned, year-cacheable object was
replaced
+ in place. Two edges seven hours apart on either side of the replacement
served
+ object lengths 4043309056 and 7031250004 for the same URL, the stale one
+ clipping a 64 MiB range request down to 16 MiB.
+ """
+
+ # The reference block the origin holds at the new generation for each phase
+ # that reads the cached object. The test asserts the origin never gets
these.
+ _unreachable_keys = ('/objbytes=0-15clipped',
'/objbytes=0-15unsatisfiable')
+
+ def __init__(self) -> None:
+ """Declare the hierarchy and its assertions."""
+ super().__init__('stale')
+
+ for key in self._unreachable_keys:
+ self._origin.Streams.stdout += Testers.ExcludesExpression(
+ f'request with key {key}', 'The stale object should never be
refetched after the origin object changed.')
+
+ # Debug is enabled on the child, so Config::canLogError cannot
suppress a
+ # block stitch error by pacing. The stale response is served with none.
+ self._child.Disk.diags_log.Content = Testers.ExcludesExpression(
+ 'logSliceError', 'The stale response should be served with no
block stitch error.')
+ self._child.Disk.diags_log.Content += Testers.ExcludesExpression(
+ 'Mismatch/Bad block Content-Range', 'The stale blocks agree with
each other, so nothing should mismatch.')
+
+ def _fill_cache(self) -> None:
+ """Cache the whole object while the origin holds the first
generation."""
+ tr = Test.AddTestRun('Cache the object while the origin holds the
first generation')
+ self._start_hierarchy(tr)
+ self._replay_phase(tr, 'fill')
+ self._still_running(tr)
+
+ def _verify_clipped_range(self) -> None:
+ """A range inside the current object is clipped to the stale length."""
+ tr = Test.AddTestRun('A range inside the current object is clipped to
the stale length')
+ self._replay_phase(tr, 'clipped')
+ self._still_running(tr)
+
+ def _verify_unsatisfiable_range(self) -> None:
+ """A range past the stale length is refused with a 416."""
+ tr = Test.AddTestRun('A range past the stale length is refused with a
416')
+ self._replay_phase(tr, 'unsatisfiable')
+ self._still_running(tr)
+
+ def _verify_uncached_object(self) -> None:
+ """An object first fetched after the replacement is served
correctly."""
+ tr = Test.AddTestRun('An object first fetched after the replacement is
served correctly')
+ self._replay_phase(tr, 'control')
+ self._still_running(tr)
+
+ def run(self) -> None:
+ """Configure the test runs."""
+ self._fill_cache()
+ self._verify_clipped_range()
+ self._verify_unsatisfiable_range()
+ self._verify_uncached_object()
+
+
+class SliceMixedGenerationTest(SliceHierarchyTest):
+ """Verify the parent stores a version mix and the child cannot recover.
+
+ The other failure mode from the same origin object replacement, and the one
+ the parent's per-Range cache makes possible. Only the reference block is
+ refetched after the replacement, so the parent ends up holding two blocks
of
+ one object at two different generations, both fresh, with nothing to relate
+ them. It serves each on request without complaint.
+
+ The child is the only tier that compares blocks, and only against its own
+ reference block. It forms the client response header from the reference
block,
+ which is correct for the current object, and only then discovers that the
+ interior block belongs to the previous one::
+
+ server.cc handleNextServerHeader:
+ if (!blockcr.isValid() || blockcr.m_length != data->m_contentlen) {
+ logSliceError("Mismatch/Bad block Content-Range", data, header);
+
+ The self heal refetches the reference block, which is already the newest
one
+ the parent holds, so the same block comes back and the interior block still
+ disagrees with it. The second mismatch is where slice gives up.
+
+ The upstream is aborted. Slice can abort but cannot evict, so the mixed
pair
+ on the parent survives. The final TestRun proves where the damage actually
+ lives: a second child with a completely cold cache, pointed at the same
+ parent, fails identically. It never saw the previous generation; it simply
+ inherits the mix from the one place that holds it. That is the incident's
+ shape, where all 105 blocks hashed to a single parent and every one of the
32
+ child nodes served the same broken object.
+ """
+
+ # The reference block is cached with a one second lifetime, so let it
expire.
+ _expiry_wait: int = 2
+
+ def __init__(self) -> None:
+ """Declare the hierarchy and its assertions."""
+ super().__init__('mixed')
+ self._cold_child = self._make_child('cold-child')
+
+ # Unlike the stale case, the child does report this one: first the
interior
+ # block against the reference block, then the refetch against the
interior.
+ self._child.Disk.diags_log.Content = Testers.ContainsExpression(
+ 'Mismatch/Bad block
Content-Range.*blk_range="16-31".*etag_got="%22v1%22"',
+ 'The interior block should disagree with the reference block.')
+ self._child.Disk.diags_log.Content += Testers.ContainsExpression(
+ 'Mismatch/Bad block
Content-Range.*blk_range="0-15".*etag_got="%22v2%22"',
+ 'The refetched reference block should disagree in turn, leaving no
way out.')
+
+ # The parent never compares blocks, so it never complains about the mix
+ # it is storing and serving to the child.
+ self._parent.Disk.diags_log.Content += Testers.ExcludesExpression(
+ 'logSliceError', 'The parent should not notice the version mix it
holds.')
+ self._parent.Disk.diags_log.Content += Testers.ExcludesExpression(
+ 'Mismatch/Bad block Content-Range', 'The parent should not compare
blocks at all.')
+
+ def _fill_interior_block(self) -> None:
+ """Cache an interior block at the first generation, on both tiers."""
+ tr = Test.AddTestRun('Cache an interior block at the first generation')
+ self._start_hierarchy(tr)
+ self._replay_phase(tr, 'fill-interior')
+ self._still_running(tr)
+
+ def _verify_aborted_response(self) -> None:
+ """The reference block moves on and the transaction cannot be
completed."""
+ tr = Test.AddTestRun('A mixed generation object cannot be delivered
and the request fails')
+ client = self._replay_phase(tr, 'mixed')
+ # Let the reference block go stale so that only it is revalidated.
+ tr.Processes.Default.Command = f'sleep {self._expiry_wait}; ' +
tr.Processes.Default.Command
+ # Slice aborts the transaction, so the client never reads a response at
+ # all: not a short body, no response header. verifier-client exits 1.
+ client.ReturnCode = 1
+ client.Streams.stdout += Testers.ContainsExpression(
+ 'Failed to find a well-formed, completed HTTP response:
PARSE_INCOMPLETE',
+ 'The client should not receive a parsable response.')
+ client.Streams.stdout += Testers.ContainsExpression(
+ 'Failed HTTP/1 transaction with key: mixed', 'The transaction
should fail.')
+ self._still_running(tr)
+
+ def _verify_mix_is_on_the_parent(self) -> None:
+ """A cold child fails identically, because the mix lives on the
parent."""
+ tr = Test.AddTestRun('A second child with a cold cache inherits the
mix from the parent')
+ tr.Processes.Default.StartBefore(self._cold_child)
+ client = self._replay_phase(tr, 'cold-child', ts=self._cold_child)
+ client.ReturnCode = 1
+ client.Streams.stdout += Testers.ContainsExpression(
+ 'Failed HTTP/1 transaction with key: cold-child', 'A node that
never saw the old generation should fail the same way.')
+ self._cold_child.Disk.diags_log.Content = Testers.ContainsExpression(
+ 'Mismatch/Bad block Content-Range', 'The cold child should hit the
same mismatch.')
+ tr.StillRunningAfter = self._cold_child
+ self._still_running(tr)
+
+ def run(self) -> None:
+ """Configure the test runs."""
+ self._fill_interior_block()
+ self._verify_aborted_response()
+ self._verify_mix_is_on_the_parent()
+
+
+SliceStaleGenerationTest().run()
+SliceMixedGenerationTest().run()