defmodule AudioProxy.RenderCoordinator do @moduledoc """ One render per in-flight cache key, shared by everyone who asks for it. API doc §5: concurrent requests for the same not-yet-cached variant must not each spawn an encoder. This is the single-flight in front of `AudioProxy.Ffmpeg.Render` — a `Registry` of unique cache keys, a coordinator process per key, and a `DynamicSupervisor` holding them. ## Subscribing `subscribe/2` either starts the render or attaches to the running one: {:ok, :miss, render, []} # this caller started it {:ok, :coalesced, render, chunks} # it was already running; `chunks` is the catch-up `render` is the coordinator's pid and stands where `AudioProxy.Ffmpeg.Render`'s did. It broadcasts the pipeline's own contract, with itself as the handle: * `{:chunk, render, binary}` * `{:done, render, %{exit_status: 0}}` * `{:error, render, failure}` So a consumer written against the pipeline works here unchanged, except for the two calls that name a handle: `Render.ack/2` has no counterpart (see *Backlog* below) and `Render.cancel/1` becomes `unsubscribe/1`, because a subscriber leaving must not cancel a render other subscribers are still reading. ## The slot comes first A coordinator does not spawn its render until `AudioProxy.Semaphore` grants it a slot, and releases that slot from `terminate/2` — so the cap counts renders rather than requests, and a variant twenty clients are waiting on costs one. It asks with `Semaphore.request/2` rather than the blocking `acquire/1`, because this process must keep answering joins while it waits: the requests coalescing onto a queued render are exactly the ones that should not each take a slot of their own. So a coordinator has one phase more than the render does — `:queued`, before `:rendering` — and a subscriber cannot tell them apart, which is the point. It is waiting for bytes either way. The slot is asked for in `init/1`, which is what makes a full queue a *start* failure rather than a render failure: `subscribe/2` answers `{:error, {:queue_full, retry_after}}` and no coordinator is left registered under that key, so the next request asks the semaphore again instead of joining something that is never going to render. `AudioProxy.ErrorJSON` already renders that tuple as §5's 429, `Retry-After` and all. ## The start race Starting and joining are the same call. `DynamicSupervisor.start_child/2` either succeeds — this caller is the MISS, and its pid is already in the subscriber list before `init/1` returns, so no chunk can be produced before it is listening — or answers `{:error, {:already_started, pid}}`, which is the COALESCED path and a join. The join is a `call` that returns the backlog *and* registers the subscriber in one callback. That atomicity is the whole seam: a joiner cannot be handed a backlog and then miss the chunk that was broadcast while it was being handed one, and cannot be registered first and receive a chunk it will also find in its backlog. Losing the race to a coordinator that is stopping — a finished render past its linger, a failed one that has already unregistered — is a caught exit and a retry, which starts a fresh render. ## Backlog, and the memory bound Everything the render produces is retained, in order, so a subscriber that joins at any point receives the whole stream. Retaining it is not a cost the coalescing imposes: the variant-cache slice tees the same bytes to storage. Because the bytes are held anyway, the pipeline is acknowledged the moment a chunk arrives, and the pipeline's own high-water mark stops being the bound. What bounds this instead is `AP_MAX_VARIANT_BYTES`: a render whose output passes it fails for every subscriber rather than growing without limit. It is a ceiling on the *variant*, separate from `AP_MAX_SRC_BYTES`'s ceiling on the source, so a deployment can accept two-hour masters and still bound one render's retention to a preview's worth of bytes. It defaults to the effective `AP_MAX_SRC_BYTES`, so a deployment that sets neither is bounded exactly where it was before the two were separated. The breach is only detectable once the response has committed to `200` and begun streaming, so it is a failed request rather than a `413` — the source ceiling stays the only one enforced before a render starts. That is the reason the escalation is written down: the named-pipe (FIFO) pattern in `AudioProxy.Ffmpeg.Render`'s moduledoc spools instead of retaining, and nothing in the contract above changes when it lands. Raising the ceiling does not buy capacity. It bounds one render while the bill is `AP_MAX_CONCURRENCY × backlog`, so raising it licenses every slot to reach the larger figure — which turns one killed render into an exhausted container. `docs/capacity.md` has the arithmetic. ## Teardown Three ways this ends, and they differ in what happens to the key: * **Done** — the completion is broadcast, then the coordinator lingers briefly, still registered, so a request that passed its cache check a moment too late still gets the finished bytes instead of re-rendering. * **Failure** — broadcast to every current subscriber, then the key is unregistered *immediately*, so the next request retries rather than attaching to a corpse. * **Last subscriber gone** — nobody is listening, so the subprocess is cancelled. `unsubscribe/1` returns only once that has happened, which is what makes it usable as a barrier the way `Render.cancel/1` is. One subscriber dying never affects the others: it is removed and the render continues. ## The write-back tee is a subscriber When a variant store is configured and the spec carries `:metadata`, the coordinator starts an `AudioProxy.VariantStore.Tee` and registers it like any other subscriber — before the first chunk can exist, so the store receives the whole stream. That one fact is the disconnect policy: with a store, the last *client* leaving still leaves the tee counted, so the render completes into the store and the next request is a HIT; without one, the subscriber count reaches zero and the render is cancelled as before. A tee that dies is forgotten like any subscriber — and if no client remains either, the render is cancelled, because nothing is left that could profit from it. It starts with the *render*, not with the coordinator, which is only visible while a slot is being waited for. A queued coordinator has no tee, so a queued render every client has abandoned stops rather than holding its place to encode for the cache alone — which would spend a slot, under exactly the contention that made it queue, on a render no client wants. """ use GenServer require Logger alias AudioProxy.{Config, Semaphore} alias AudioProxy.Ffmpeg.{Render, RenderSupervisor} alias AudioProxy.VariantStore alias AudioProxy.VariantStore.Tee @registry __MODULE__.Registry @supervisor __MODULE__.Supervisor # How long a finished coordinator stays registered. Long enough to catch a # request that was already in flight when the render completed, short enough # that a hot key does not keep its bytes resident. A straggler that misses it # starts a fresh render — wasteful, never wrong. @linger 1_000 # Joining is a message to a live process that does no I/O in the callback, so # this only ever expires on a coordinator that is wedged. @join_timeout 5_000 # Leaving can block on the pipeline's kill discipline, which is `Render`'s # own worst case plus margin. A timeout here would hand back `:ok` for a # subprocess still being killed, so it is set above that worst case rather # than at something request-shaped. @unsubscribe_timeout 15_000 # Added to what `terminate/2` can block on for this process' shutdown budget, # so it can always finish the work it started. See `child_spec/1`. @shutdown_margin 2_000 # Retries of the whole start-or-join loop. Each one either starts a render or # joins a live coordinator; only a coordinator stopping in the window between # the two costs an attempt, so exhausting five means something is wrong that # retrying will not fix. @attempts 5 @typedoc "The coordinator, which is also the handle in the messages above." @type t :: pid() @typedoc "Whether this subscriber started the render or attached to it." @type status :: :miss | :coalesced @typedoc """ How to render, when this subscriber turns out to be the one starting it. The options of `AudioProxy.Ffmpeg.Render.start_link/1` less `:consumer`, which is the coordinator itself — plus `:metadata` (`t:AudioProxy.VariantStore.metadata/0`), which never reaches the pipeline: it is what the write-back tee stores alongside the bytes, and without it no tee is started. """ @type render_spec :: keyword() @typedoc """ What a subscriber receives. The pipeline's own three, plus `{:rendering, _}` — sent only to subscribers that were attached while this coordinator was waiting for a render slot, and meaning "bytes are now possible". A consumer that measures how long the render has been silent needs it, because until then there was no render. One that does not may ignore it. """ @type message :: {:rendering, t()} | {:chunk, t(), binary()} | {:done, t(), %{exit_status: 0}} | {:error, t(), Render.failure()} defstruct [ :key, :spec, :metadata, :render, :render_monitor, :info, :linger_timer, phase: :rendering, subscribers: %{}, backlog: [], bytes: 0 ] @doc """ The registry and supervisor this module needs, for the application tree. Start them after `AudioProxy.Ffmpeg.RenderSupervisor`: a coordinator spawns a render in `init/1`, and shutdown then tears the coordinators down first. """ @spec children() :: [Supervisor.child_spec() | {module(), term()}] def children do [ {Registry, keys: :unique, name: @registry}, {DynamicSupervisor, strategy: :one_for_one, name: @supervisor} ] end @doc """ How many renders are in flight right now. One per registered cache key, which is the same thing `AP_MAX_CONCURRENCY` counts: the twenty requests coalesced onto one encode are one render here. `AudioProxy.Metrics` samples it per scrape rather than maintaining a gauge from the render events, because a registry entry cannot outlive the process that holds it — an abnormal exit corrects this number for free, where an event-driven counter would be permanently one too high. Briefly includes a coordinator that has finished and not yet unregistered, which is the same window that lets a late request coalesce onto its backlog. """ @spec in_flight() :: non_neg_integer() def in_flight, do: Registry.count(@registry) @doc """ Subscribes the calling process to the render for `cache_key`, starting it from `spec` if it is not already running. Returns `{:ok, status, render, backlog}`. `backlog` is the bytes produced before this subscriber attached, in order, and is always empty for a `:miss`; a consumer must deliver it before the chunks that follow. Errors are the pipeline's own — `{:error, :ffmpeg_not_found}` and friends — plus `{:error, {:queue_full, retry_after}}` when `AudioProxy.Semaphore` had neither a slot nor room to wait, and `{:error, :coordinator_unavailable}` if the start-or-join loop could not settle, which means something is repeatedly killing coordinators. """ @spec subscribe(String.t(), render_spec()) :: {:ok, status(), t(), [binary()]} | {:error, term()} def subscribe(cache_key, spec) when is_binary(cache_key) and is_list(spec) do start_or_join(cache_key, spec, @attempts) end @doc """ Detaches the calling process from `render`. The render continues for whoever is left. If nobody is, it is cancelled and its subprocess killed before this returns — so, like `AudioProxy.Ffmpeg.Render.cancel/1`, this is a barrier and not a request. Detaching from a coordinator that has already stopped is `:ok`: that is the outcome asked for. """ @spec unsubscribe(t()) :: :ok def unsubscribe(render) do GenServer.call(render, :unsubscribe, @unsubscribe_timeout) catch :exit, _reason -> :ok end @doc false def child_spec(opts) do %{ id: __MODULE__, start: {__MODULE__, :start_link, [opts]}, # Never restarted, for the same reason a render is not: the byte stream # is gone and every subscriber has already been told. restart: :temporary, # Derived, not chosen, and derived from *both* calls `terminate/2` makes. # A budget below `Render.cancel/1`'s own timeout means the supervisor # brutal-kills this process mid-cancel on exactly the renders that need # cancelling most — the ones whose subprocess is ignoring SIGTERM. The # subprocess still dies (the pipeline monitors its consumer), but the # kill would be racing the guarantee instead of implementing it. The # release that follows the cancel has a bound of its own, and leaving it # out would put the sum back over the budget in the one case where both # are slow. shutdown: Render.cancel_timeout() + Semaphore.release_timeout() + @shutdown_margin } end @doc false def start_link(opts) do GenServer.start_link(__MODULE__, opts, name: via(Keyword.fetch!(opts, :key))) end defp via(key), do: {:via, Registry, {@registry, key}} defp start_or_join(_key, _spec, 0), do: {:error, :coordinator_unavailable} defp start_or_join(key, spec, attempts) do child = {__MODULE__, key: key, spec: spec, subscriber: self()} case DynamicSupervisor.start_child(@supervisor, child) do {:ok, pid} -> {:ok, :miss, pid, []} {:error, {:already_started, pid}} -> case join(pid) do {:ok, backlog} -> {:ok, :coalesced, pid, backlog} :gone -> start_or_join(key, spec, attempts - 1) end # `init/1` refuses a render it could not start as a `:shutdown` stop, so # a missing ffmpeg is an error tuple rather than a crash report on every # request. Unwrapped here, so callers see the pipeline's own reason. {:error, {:shutdown, reason}} -> {:error, reason} {:error, reason} -> {:error, reason} end end defp join(pid) do GenServer.call(pid, {:join, self()}, @join_timeout) catch # Stopping between the registry lookup and this call. Retrying starts a # fresh render, which is correct — the key is on its way out. :exit, _reason -> :gone end ## Server @impl true def init(opts) do # So that the supervisor's shutdown is a stop like any other and reaches # `terminate/2`, where the subprocess is killed. Process.flag(:trap_exit, true) key = Keyword.fetch!(opts, :key) {metadata, spec} = Keyword.pop(Keyword.fetch!(opts, :spec), :metadata) subscriber = Keyword.fetch!(opts, :subscriber) state = %__MODULE__{ key: key, spec: spec, metadata: metadata, # Registered before the first chunk can exist, so the subscriber that # started this render needs no backlog. The tee is registered the same # way, but not here — see `spawn_render/1`. subscribers: %{subscriber => Process.monitor(subscriber)} } # Asked for here rather than after `init/1` returns, so that a full queue is # a `start_child` error the requesting process gets back directly — a 429 # before a coordinator exists, rather than a coordinator whose only act is # to broadcast a failure. `request/2` answers immediately whichever way it # goes; only the *waiting* would block, and that is what `:queued` is. # # Before the tee, too: a coordinator that cannot get a slot stops without # ever running `terminate/2`, and it should not have opened a staging file # for a render that will not happen. case Semaphore.request() do :granted -> start_render(state) :queued -> {:ok, %{state | phase: :queued}} {:error, reason} -> {:stop, {:shutdown, reason}} end end # Started from `init/1`, so a start failure can still be a `{:stop, # {:shutdown, _}}` that `start_or_join/3` unwraps into the caller's own # `{:error, reason}`: no ffmpeg on `PATH` is a 500 with the reason in the log # and no crash report per request, exactly as it was before slots existed. defp start_render(state) do case spawn_render(state) do {:ok, state} -> {:ok, state} {:error, reason} -> {:stop, {:shutdown, reason}} end end # Started from a grant that arrived after `init/1` returned. The caller has # long since been handed a coordinator, so the only way left to report a # start failure is the failure broadcast every other failure uses. defp start_granted_render(state) do case spawn_render(state) do {:ok, state} -> {:noreply, state} {:error, reason} -> fail(state, %{ class: :render_failed, exit_status: nil, detail: "could not start render: #{inspect(reason)}" }) end end # Announced on every transition into `:rendering`, including the one that # happens inside `init/1` where there was no wait at all. A consumer's budget # for "the render has said nothing" cannot start when it subscribed, because # between subscribing and a byte being *possible* there may have been a queue; # this is the moment it becomes possible, and it has to be reported the same # way whether the queue was empty or not — a consumer that only heard about # the slow case would have to guess about the fast one. # # The tee is started here rather than in `init/1`, which is where the slot and # the write-back meet. Registering it before the render is spawned keeps the # guarantee it was given — no chunk can exist yet, so it needs no backlog — # while keeping its lifetime the render's rather than this process'. Starting # it while queued would open a staging file for a render that has not begun, # against a deadline of its own that assumes one has; worse, it would count as # a subscriber, so a queued render every client had abandoned would hold its # place, take a slot, and encode for the cache alone. Nothing designed that, # and under contention it is a slot taken from a request that has a client. defp spawn_render(state) do state = %{state | subscribers: maybe_tee(state.subscribers, state.key, state.metadata)} case RenderSupervisor.start_render(Keyword.put(state.spec, :consumer, self())) do {:ok, render} -> state = %{ state | phase: :rendering, render: render, render_monitor: Process.monitor(render) } broadcast(state, {:rendering, self()}) {:ok, state} {:error, reason} -> {:error, reason} end end # The tee exists exactly when there is somewhere to write and something to # write it with. Failing to start one is logged, not fatal: the render and # its clients owe nothing to the cache. defp maybe_tee(subscribers, key, metadata) do if metadata != nil and VariantStore.configured?() do case Tee.start(self(), key, metadata) do {:ok, tee} -> Map.put(subscribers, tee, Process.monitor(tee)) {:error, reason} -> Logger.warning("could not start variant store tee: #{inspect(reason)}") subscribers end else subscribers end end # `:queued` and `:rendering` are one thing from a subscriber's side: bytes are # coming, and none have arrived that this joiner is missing. They differ in # one respect only, and it is not about the bytes — a joiner that arrives # while the render is already under way never queued, so it is told so here # rather than waiting for the announcement it has already missed. Sent before # the reply, so it is in the joiner's mailbox by the time it starts reading. @impl true def handle_call({:join, pid}, _from, %__MODULE__{phase: :rendering} = state) do send(pid, {:rendering, self()}) {:reply, {:ok, backlog(state)}, monitor_subscriber(state, pid)} end def handle_call({:join, pid}, _from, %__MODULE__{phase: :queued} = state) do {:reply, {:ok, backlog(state)}, monitor_subscriber(state, pid)} end # The render is over and every byte is in the backlog, so this joiner is # complete the moment it has it. The terminal message is sent before the # reply and therefore lands in the caller's mailbox first; `GenServer.call/3` # selects on its own ref, so the joiner still gets the backlog first and # finds `{:done, _, _}` waiting when it starts consuming. def handle_call({:join, pid}, _from, %__MODULE__{phase: :completed} = state) do send(pid, {:done, self(), state.info}) {:reply, {:ok, backlog(state)}, state} end def handle_call(:unsubscribe, {pid, _tag}, state) do case forget_subscriber(state, pid) do %__MODULE__{phase: phase, subscribers: subscribers} = state when phase in [:queued, :rendering] and map_size(subscribers) == 0 -> # Nobody is reading. Replying from `terminate/2`'s far side is what # makes this a barrier: the subprocess is gone before the caller runs. {:stop, :normal, :ok, state} state -> {:reply, :ok, state} end end @impl true def handle_info({:chunk, render, data}, %__MODULE__{render: render} = state) do # Acknowledged immediately: the bytes are retained here regardless, so the # pipeline's high-water mark would only stall a stream this process has # already paid the memory for. The backlog cap is the bound instead. Render.ack(render, byte_size(data)) case retain(state, data) do {:ok, state} -> broadcast(state, {:chunk, self(), data}) {:noreply, state} {:error, failure} -> fail(state, failure) end end def handle_info({:done, render, info}, %__MODULE__{render: render} = state) do broadcast(state, {:done, self(), info}) Process.demonitor(state.render_monitor, [:flush]) # Here, not in `terminate/2`. A slot caps *encoders*, and this one's is # gone: `{:done, _, _}` arrives after the subprocess has exited and every # byte it wrote has been forwarded. What the linger below keeps alive is a # buffer being served from memory, which costs no CPU and must not cost a # slot — holding one across it would shrink effective concurrency by the # linger over the render's own duration, which for the preview-sized # renders v1 targets is most of it. `terminate/2` still releases, for the # paths that never get here; the second call is a no-op. Semaphore.release() {:noreply, %{ state | phase: :completed, info: info, render: nil, render_monitor: nil, linger_timer: Process.send_after(self(), :linger_expired, @linger) }} end def handle_info({:error, render, failure}, %__MODULE__{render: render} = state) do fail(state, failure) end # The pipeline died without saying anything — it cannot report its own crash. def handle_info( {:DOWN, monitor, :process, _pid, reason}, %__MODULE__{render_monitor: monitor} = state ) do fail(state, %{ class: :render_failed, exit_status: nil, detail: "render died without reporting: #{inspect(reason)}" }) end def handle_info({:DOWN, _monitor, :process, pid, _reason}, state) do case forget_subscriber(state, pid) do %__MODULE__{phase: phase, subscribers: subscribers} = state when phase in [:queued, :rendering] and map_size(subscribers) == 0 -> {:stop, :normal, state} state -> {:noreply, state} end end # The slot came free. Nothing about this is visible to a subscriber: it has # been waiting for bytes, and still is. def handle_info({Semaphore, :granted}, %__MODULE__{phase: :queued} = state) do start_granted_render(state) end def handle_info(:linger_expired, state), do: {:stop, :normal, state} # The only link is the supervisor's, so this is the shutdown path. Stopping # rather than ignoring it is what lets `terminate/2` kill the subprocess. def handle_info({:EXIT, _from, reason}, state), do: {:stop, reason, state} def handle_info(_message, state), do: {:noreply, state} @impl true def terminate(_reason, %__MODULE__{} = state) do # `cancel/1` returns once the subprocess is gone and tolerates a render that # already finished, so this is safe on every stop path. if state.render, do: Render.cancel(state.render) # After the cancel, not before: releasing first would hand the slot to a # waiter while this render's ffmpeg was still being killed, which is the # cap being exceeded by exactly the margin the kill discipline takes. # # Idempotent, and it covers a `:queued` coordinator that never held a slot # as well as one that did. The semaphore's own monitor is the backstop for # the stop paths that never reach here. Semaphore.release() :ok end ## Subscribers defp monitor_subscriber(state, pid) do if Map.has_key?(state.subscribers, pid) do state else %{state | subscribers: Map.put(state.subscribers, pid, Process.monitor(pid))} end end defp forget_subscriber(state, pid) do case Map.pop(state.subscribers, pid) do {nil, _subscribers} -> state {monitor, subscribers} -> Process.demonitor(monitor, [:flush]) %{state | subscribers: subscribers} end end defp broadcast(state, message) do Enum.each(state.subscribers, fn {pid, _monitor} -> send(pid, message) end) end ## Backlog defp backlog(state), do: Enum.reverse(state.backlog) defp retain(state, data) do bytes = state.bytes + byte_size(data) limit = Config.get(:max_variant_bytes) if bytes > limit do {:error, %{ class: :render_failed, exit_status: nil, detail: "render output exceeded the #{limit}-byte retention cap (AP_MAX_VARIANT_BYTES)" }} else {:ok, %{state | backlog: [data | state.backlog], bytes: bytes}} end end ## Failure # Told once to everyone currently attached, then the key goes immediately so # the next request renders again instead of joining something that is gone. defp fail(state, failure) do broadcast(state, {:error, self(), failure}) Registry.unregister(@registry, state.key) {:stop, :normal, state} end end