defmodule AudioProxy.VariantStore do @moduledoc """ The contract a variant store backend implements, and the dispatch to the configured one. A completed render is written back under its cache key so the next request for the same variant is served from storage instead of re-rendered. Where that storage *is* — a local directory, an S3 bucket — is a backend behind this behaviour, selected by the scheme of `AP_VARIANT_STORE`: scheme module slice file:// AudioProxy.VariantStore.Local add-variant-store s3:// AudioProxy.VariantStore.S3 add-s3-variant-store The shape deliberately mirrors `AudioProxy.Source.Type`: one module per scheme, five callbacks, and a new backend is a registration rather than an edit to the render path. Cache behaviour never depends on where source audio comes from — a `file://` store caches renders of `s3://` sources and vice versa. ## The callbacks, in the order a request meets them * `c:head/1` — is this variant stored, and what would serving it need to know? The HIT check. * `c:get_stream/2` — the bytes, or a Range slice of them, as a lazy stream. Proxy-mode serving. * `c:presign/2` — a URL the client can be redirected to. Redirect-mode serving, and only for backends that advertise it (see below). * `c:put_stream/3` — the write-back: bytes plus the response metadata they must be served with. * `c:capabilities/0` — what this backend can do beyond the mandatory four. ## Metadata travels with the bytes `c:put_stream/3` takes a `t:metadata/0` because a store that keeps only bytes cannot serve a redirected fetch correctly: a player handed `application/octet-stream` may refuse to decode it. Every backend persists the metadata; a backend that cannot attach it to a *direct* fetch (the redirect case) simply does not advertise `:presign`, and `AudioProxy.Config` refuses `AP_SERVE_MODE=redirect` against it at boot. ## Atomic or absent A `c:put_stream/3` that does not run to completion — the render failed, the write errored — must leave nothing readable under the key. Each backend implements that with whatever its storage offers: the local backend stages into a temp file inside the store and `File.rename/2`s on completion; the S3 backend gets it from the protocol, since an object does not exist until its upload completes and a failed multipart is aborted. `c:head/1` answering `{:ok, _}` therefore always means the whole variant, with its metadata. """ alias AudioProxy.Config @typedoc "The cache key a variant is stored under — see `AudioProxy.CacheKey`." @type key :: String.t() @typedoc """ The response headers a stored variant must be served with: its content type, the immutable cache-control the render endpoint would have sent, and the ETag (the quoted cache key). Stored alongside the bytes so a store-direct fetch serves correctly without the proxy in the path. """ @type metadata :: %{ content_type: String.t(), cache_control: String.t(), etag: String.t() } @typedoc "What `c:head/1` reports about a stored variant." @type entry :: %{size: non_neg_integer(), metadata: metadata()} @typedoc """ An inclusive byte range (`{first, last}`, RFC 9110 §14), or `nil` for the whole variant. A `last` past the end is truncated, as Range semantics ask; a `first` past the end is `:invalid_range`. """ @type range :: {non_neg_integer(), non_neg_integer()} | nil @typedoc """ What a backend can do beyond the mandatory callbacks. * `:presign` — `c:presign/2` produces working URLs, which is what redirect serving needs. A backend without it is proxy-mode only. """ @type capability :: :presign @typedoc """ The parsed `AP_VARIANT_STORE` value, as `AudioProxy.Config` holds it. `{:module, backend}` is the test seam described at `backend_for/1`, not a value any environment can produce. """ @type config :: {:file, Path.t()} | {:s3, String.t()} | {:module, module()} @doc "Reports whether the variant named by `key` is stored, whole, with its metadata." @callback head(key()) :: {:ok, entry()} | {:error, :not_found} @doc """ Streams the stored variant's bytes, or the Range slice of them. The stream is lazy and bounded-memory: chunks, never the whole variant at once. """ @callback get_stream(key(), range()) :: {:ok, Enumerable.t()} | {:error, :not_found | :invalid_range} @doc """ Writes a variant: consumes `chunks` (an enumerable of binaries) to completion, then commits bytes and metadata under `key` — atomically, so a reader sees the whole variant or nothing. If `chunks` raises — which is how the write-back tee signals a render that failed or was cancelled mid-stream — the write is aborted, anything staged is discarded, and the exception comes back as `{:error, exception}`. """ @callback put_stream(key(), Enumerable.t(), metadata()) :: :ok | {:error, term()} @doc """ Produces a URL a client can fetch the stored variant from directly. `{:error, :unsupported}` from a backend without the `:presign` capability. """ @callback presign(key(), keyword()) :: {:ok, String.t()} | {:error, term()} @doc "The optional abilities this backend has. See `t:capability/0`." @callback capabilities() :: [capability()] @doc "Reports whether `AP_VARIANT_STORE` is configured at all." @spec configured?() :: boolean() def configured?, do: Config.get(:variant_store) != nil @doc """ The backend module for a parsed store value. Used by `AudioProxy.Config` to validate the serve mode against the backend's capabilities at boot, before anything is stored. """ @spec backend_for(config()) :: module() def backend_for({:file, _root}), do: AudioProxy.VariantStore.Local def backend_for({:s3, _bucket}), do: AudioProxy.VariantStore.S3 # A backend that names itself. `AudioProxy.Config` never produces this shape # — `AP_VARIANT_STORE` is a URL, and every scheme maps to a module above — so # it is not configuration surface. It is the seam the serving path is tested # through, without a store: the `s3://` backend can presign for real, but # only against a running Garage, and the redirect branch's own behaviour — # the `no-store`, the TTL it passes down, the proxy-instead fallback when a # presign fails — is not S3's to demonstrate. def backend_for({:module, module}) when is_atom(module), do: module @doc "The configured backend module. Callers must check `configured?/0` first." @spec backend() :: module() def backend, do: backend_for(Config.get(:variant_store)) @doc "`c:head/1` on the configured backend." @spec head(key()) :: {:ok, entry()} | {:error, :not_found} def head(key), do: backend().head(key) @doc "`c:get_stream/2` on the configured backend." @spec get_stream(key(), range()) :: {:ok, Enumerable.t()} | {:error, :not_found | :invalid_range} def get_stream(key, range), do: backend().get_stream(key, range) @doc "`c:put_stream/3` on the configured backend." @spec put_stream(key(), Enumerable.t(), metadata()) :: :ok | {:error, term()} def put_stream(key, chunks, metadata), do: backend().put_stream(key, chunks, metadata) @doc "`c:presign/2` on the configured backend." @spec presign(key(), keyword()) :: {:ok, String.t()} | {:error, term()} def presign(key, opts \\ []), do: backend().presign(key, opts) @doc "`c:capabilities/0` of the configured backend." @spec capabilities() :: [capability()] def capabilities, do: backend().capabilities() end