lib/audio_proxy/variant_cache.ex
Rama main · réplica de lectura del repositorio original.
defmodule AudioProxy.VariantCache do
@moduledoc """
Serving a variant that is already in the store — everything a client can
observe about a cache HIT.
`AudioProxy.VariantStore` owns *where* the bytes are; this module owns what
happens when they turn out to be there. The split is the same one the API
doc draws: a store is a backend detail, a HIT is a contract.
## Two lines, and only two
`lookup/1` is the HIT check — a `c:AudioProxy.VariantStore.head/1` behind
the "is there a store at all" question, answering `:miss` for both
"unconfigured" and "not stored", because the render path does the same thing
either way. `serve/3` turns an entry into a response.
`serve/3` can still answer `:miss`: an entry can be evicted between the head
and the read, and nothing has been sent at that point, so the request falls
through to a render rather than failing.
Everything after the response head is committed, and there the only signal
left is an abnormal close — the same one, for the same reason, as a render
that fails after its 200 (§5). Two things reach it. A store read that
*raises* propagates and the adapter tears the connection down. A store read
that ends *short* would otherwise complete normally, so `write/3` counts the
bytes and exits when they fall short of the `Content-Length` already
promised; see the comment there for why a well-formed short body is the
worse outcome of the two.
## Proxy mode declares a length
A stored variant has a known size, and declaring it is the entire point of
caching one: `Content-Length` plus `Accept-Ranges: bytes` is what makes
seeking, resumption and progress reporting work, none of which a chunked
MISS can offer. Bandit streams a length-delimited body when the
`content-length` header is set before `Plug.Conn.send_chunked/2`, so the
bytes still leave as they are read — declared length and progressive
delivery are not a trade here.
Range handling is single-range only. A multipart/byteranges response would
buy nothing a media client asks for, and RFC 9110 §14.2 permits ignoring a
`Range` header outright, which is what a multi-range or malformed one gets.
A syntactically valid range that no byte of the variant can satisfy is a
`416` — the one status this module produces that is not a HIT's body.
## A HEAD on a HIT is the same answer without the read
RFC 9110 §9.3.2 lets a server omit only the header fields "determined only
while generating the content", and on a HIT none of them are: the store's
`c:AudioProxy.VariantStore.head/1` returned the size and the stored metadata
before any byte moved. So a HEAD gets the GET's head verbatim —
`Content-Length`, `Accept-Ranges`, `ETag`, `Content-Type`, `Cache-Control`,
`X-Audio-Proxy: HIT` — and no `get_stream/2` call at all. Redirect mode needs
nothing special: its `302` has no body to skip.
## The redirect is not the variant
Redirect mode answers `302` with a short-lived presigned URL — no longer
lived than the requesting URL itself, per `AudioProxy.Expiry` — and
`Cache-Control: no-store`. The no-store is load-bearing rather than
cautious: the redirect's `Location` *is* a credential with an expiry, and a
cached 302 hands out URLs that have already expired. The immutable
cache-control belongs to the variant bytes, which the store serves under its
own headers — the ones the write-back stored, so a followed redirect
delivers the same `Content-Type` and `Cache-Control` a proxied HIT would
have sent.
Backends without `:presign` never take this path. `AudioProxy.Config`
refuses that combination at boot, so the runtime check here is a belt on top
of a brace — and a presign that fails despite it proxies instead, because
the bytes are readable and the client asked for audio, not for a URL.
## What the client contract does not depend on
Not the backend, and not the serve mode. Both modes deliver the same bytes
under the same `Content-Type`, `Cache-Control` and `ETag`, and both are
range-capable — proxy mode because this module implements it, redirect mode
because the store does. What *does* change the observable response is the
cache state: the same URL is a chunked, non-seekable `200` while it is being
rendered and a length-declared, range-capable one afterwards. That is stated
in §5 as a contract clients must not assume their way around.
"""
import Plug.Conn
require Logger
alias AudioProxy.{Config, ErrorJSON, Expiry, LogHandler, VariantStore}
@typedoc "What `lookup/1` found, if anything."
@type hit :: {:ok, VariantStore.entry()} | :miss
# Only digits: `Integer.parse/1` would accept `+5` and a leading `-`, and a
# range spec is `1*DIGIT` (RFC 9110 §14.1) or it is not one.
#
# Both this match and the `String.to_integer/1` behind it are linear in the
# header's length, and the header is bounded by Bandit's own maximum field
# size — so a range spec cannot be made expensive by making it long.
@digits ~r/^\d+$/
@doc """
Reports whether the variant named by `key` is in the store, whole.
`:miss` covers every reason a request must render: no store configured,
nothing stored under the key, an entry whose metadata never landed.
"""
@spec lookup(VariantStore.key()) :: hit()
def lookup(key) do
if VariantStore.configured?() do
case VariantStore.head(key) do
{:ok, entry} -> {:ok, entry}
{:error, _not_found} -> :miss
end
else
:miss
end
end
@doc """
Serves a stored variant, per `AP_SERVE_MODE`.
`:miss` means the entry disappeared before a byte was sent and the caller
should render — see the moduledoc. Any returned conn is sent and halted.
"""
@spec serve(Plug.Conn.t(), VariantStore.key(), VariantStore.entry()) ::
{:ok, Plug.Conn.t()} | :miss
def serve(conn, key, entry) do
conn = assign(conn, :render_status, :hit)
if redirect?() do
redirect(conn, key, entry)
else
proxy(conn, key, entry)
end
end
defp redirect?,
do: Config.get(:serve_mode) == :redirect and :presign in VariantStore.capabilities()
## Redirect mode
defp redirect(conn, key, entry) do
# The `Location` is a credential the proxy will not be asked about again,
# so its life is capped by the URL that bought it: an unclamped presign
# would let a 30-second URL hand out an hour of storage access, and
# following it needs no signature of ours at all.
redirect(conn, key, entry, Expiry.clamp_ttl(Config.get(:presign_ttl), conn.assigns.options))
end
# Nothing left to sign for. Reachable in exactly one second — the one the
# URL's `exp` names, where `AudioProxy.Expiry.check/1` still passes because
# the boundary is exclusive — and a zero-second credential is not refused by
# the signer the way it might look: `ExAws.S3.presigned_url/5` returns
# `{:ok, "…&X-Amz-Expires=0&…"}` quite happily, and the store rejects it only
# when the client follows it. So the redirect would hand out a URL that is
# already dead instead of the bytes. Proxy them: the request was live when it
# arrived, and this is the same answer it would have got a second earlier.
defp redirect(conn, key, entry, 0), do: proxy(conn, key, entry)
defp redirect(conn, key, entry, ttl) do
case VariantStore.presign(key, expires_in: ttl) do
{:ok, url} ->
{:ok,
conn
|> mark_hit()
|> put_resp_header("location", url)
# Never the variant's immutable policy: this response is a pointer
# with an expiry, not the bytes it points at.
|> put_resp_header("cache-control", "no-store")
|> send_resp(302, "")
|> halt()}
# Configured for redirect against a backend that could not produce one.
# The variant is right there, so serve it rather than fail the request;
# the log line is what tells an operator their serve mode is not working.
# `reason` comes from a backend whose entire job is producing URLs that
# carry a live signature, so it is redacted like any other text this
# application did not build. `AudioProxy.LogHandler` makes that guarantee
# for the telemetry it consumes; a direct `Logger` call has to ask.
{:error, reason} ->
Logger.warning(
"could not presign variant #{key}, proxying instead: " <>
LogHandler.redact(inspect(reason))
)
proxy(conn, key, entry)
end
end
## Proxy mode
# A HEAD asks what a GET would answer, and on a HIT the store's own `head/1`
# has already said all of it — size and metadata both — so the answer is the
# GET's response head with the read left out. The headers are built by the
# same three calls `deliver/6` makes, in the same order, because parity is
# the requirement: a header added to the HIT below has to appear here or the
# set-difference test fails.
#
# `Range` is deliberately not consulted. §14.2 permits ignoring it, a HEAD
# carrying one is vanishingly rare, and answering 206 for a body that is not
# sent would only invite a client to believe something about a request it did
# not make.
defp proxy(%Plug.Conn{method: "HEAD"} = conn, _key, %{size: size, metadata: metadata}) do
{:ok,
conn
|> mark_hit()
|> describe(metadata)
|> put_resp_header("accept-ranges", "bytes")
|> put_resp_header("content-length", Integer.to_string(size))
|> send_resp(200, "")
|> halt()}
end
defp proxy(conn, key, %{size: size, metadata: metadata}) do
case requested_range(conn, size) do
:none ->
deliver(conn, key, nil, 200, size, metadata)
{:ok, {first, last} = range} ->
conn
|> put_resp_header("content-range", "bytes #{first}-#{last}/#{size}")
|> deliver(key, range, 206, last - first + 1, metadata)
:unsatisfiable ->
{:ok, unsatisfiable(conn, size)}
end
end
defp deliver(conn, key, range, status, length, metadata) do
case VariantStore.get_stream(key, range) do
{:ok, stream} ->
conn =
conn
|> mark_hit()
|> describe(metadata)
|> put_resp_header("accept-ranges", "bytes")
|> put_resp_header("content-length", Integer.to_string(length))
|> send_chunked(status)
{:ok, write(conn, stream, length)}
# Evicted between the head and the read. Nothing has been sent, so this
# is still a miss and the caller renders.
{:error, _gone_or_invalid} ->
:miss
end
end
# A store read that raises here is past the point of a graceful answer — the
# status line and the declared length are already on the wire — so it
# propagates and the adapter tears the connection down, which is §5's signal
# for a body that will not be completed.
#
# A read that ends *short* needs the same treatment and does not get it for
# free, which is why the bytes are counted. `Content-Length` has been
# promised; `Local.stream/3` halts rather than raises when the file turns out
# to be shorter than `head/1` said, so without this the response would end
# cleanly, well-formed, and a body short of its own declared length. Over a
# keep-alive connection that is worse than a truncated download: the client
# waits for bytes that never come and reads the next response as this one's
# body. Neither Bandit nor Plug reconciles the count, so this does.
defp write(conn, stream, declared) do
stream
|> Enum.reduce_while({conn, 0}, fn data, {conn, written} ->
case chunk(conn, data) do
{:ok, conn} ->
{:cont, {conn, written + byte_size(data)}}
# Nothing to clean up: no render is running, and the stream's own
# teardown closes the store handle as the enumeration unwinds. Not a
# short body either — nobody is left to be misled by one.
{:error, reason} ->
Logger.debug("client disconnected mid-hit: #{inspect(reason)}")
{:halt, {:gone, conn}}
end
end)
|> finish(declared)
end
defp finish({:gone, conn}, _declared), do: halt(conn)
defp finish({conn, declared}, declared), do: halt(conn)
# The reason is `:shutdown`-tagged and names both counts, so an operator
# reading the adapter's exit report can tell a store that moved under a read
# from a bug in this module. Same mechanism, and the same reasoning, as
# `AudioProxy.Plugs.RenderAction.abort/2`.
defp finish({_conn, written}, declared) do
Logger.warning(
"variant store delivered #{written} of #{declared} declared bytes; tearing down the response"
)
exit({:shutdown, {:variant_truncated, declared, written}})
end
# The variant's own headers, as the write-back stored them — not rebuilt
# from the options. That is what makes a proxied HIT and a followed redirect
# byte-identical in what they claim to be.
#
# `Cache-Control` is the one exception, and it has to be: the stored policy
# belongs to bytes every `exp` shares, so it cannot carry any one requester's
# remaining lifetime. The clamp is applied here, on the way out, to the value
# the store gave back — the same clamp `AudioProxy.Plugs.RenderAction` applies
# to the MISS that stored it.
defp describe(conn, metadata) do
conn
|> put_resp_content_type(metadata.content_type, nil)
|> put_resp_header(
"cache-control",
Expiry.clamp_cache_control(metadata.cache_control, conn.assigns.options)
)
|> put_resp_header("etag", metadata.etag)
end
# The one place that says "this response is a HIT", so it is also the one
# place that counts one. Every path that reaches it has committed — the
# presign came back, the store handed over a stream, or the range was
# refused against a variant that is demonstrably there — which is what keeps
# the entry evicted between `head/1` and the read out of the hit count: that
# request falls through to a render and is counted as a `:miss` by the
# action instead.
#
# A HEAD reports the verdict and is deliberately not counted, which is why
# reporting and counting are two calls here rather than one. The counters
# describe *delivering* variants: a HEAD delivered none, and its miss shape
# is not counted either (`AudioProxy.Plugs.RenderAction` emits only on the
# render path a HEAD never reaches). Counting the hit side alone — the side
# that happens to touch the store — would put probe traffic in a numerator
# whose denominator refuses it, and a client polling HEAD, which is exactly
# what this header exists for, would drive the ratio to 100%.
defp mark_hit(%Plug.Conn{method: "HEAD"} = conn), do: report_hit(conn)
defp mark_hit(conn) do
AudioProxy.Telemetry.cache_lookup(%{status: :hit, format: conn.assigns.options.format})
report_hit(conn)
end
defp report_hit(conn), do: put_resp_header(conn, "x-audio-proxy", "HIT")
## Range
# RFC 9110 §14.1. Anything this does not understand — another unit, several
# ranges, a malformed spec — is `:none`, which §14.2 permits and which
# answers the whole variant.
defp requested_range(conn, size) do
case get_req_header(conn, "range") do
[value] -> parse_range(value, size)
_absent_or_repeated -> :none
end
end
defp parse_range("bytes=" <> spec, size) do
case String.split(spec, ",") do
[single] -> parse_single(String.trim(single), size)
_multiple -> :none
end
end
defp parse_range(_other_unit, _size), do: :none
# A suffix range: the last `length` bytes, clamped to the whole variant.
# `bytes=-0` asks for nothing, which §14.1.2 makes unsatisfiable rather than
# empty.
defp parse_single("-" <> suffix, size) do
if suffix =~ @digits do
case String.to_integer(suffix) do
0 -> :unsatisfiable
length -> satisfiable(max(size - length, 0), size - 1, size)
end
else
:none
end
end
defp parse_single(spec, size) do
case String.split(spec, "-", parts: 2) do
[first, ""] ->
with {:ok, first} <- integer(first), do: satisfiable(first, size - 1, size)
[first, last] ->
with {:ok, first} <- integer(first),
{:ok, last} <- integer(last) do
# A last-byte-pos below first-byte-pos is not a range at all; §14.1.1
# calls it invalid, so it is ignored rather than refused.
if last < first, do: :none, else: satisfiable(first, min(last, size - 1), size)
end
_no_hyphen ->
:none
end
end
defp integer(text) do
if text =~ @digits, do: {:ok, String.to_integer(text)}, else: :none
end
# A first-byte-pos past the end has nothing to answer with — including on a
# zero-length variant, where `last` is -1 and every range is unsatisfiable.
defp satisfiable(first, last, size) when first < size and first <= last do
{:ok, {first, last}}
end
defp satisfiable(_first, _last, _size), do: :unsatisfiable
# The HIT header travels with the 416: the variant *was* found, and an
# operator reading the log should see the same thing the client did.
defp unsatisfiable(conn, size) do
conn
|> mark_hit()
|> ErrorJSON.halt_with({:range_not_satisfiable, size})
end
end