Skip to content

Capacity: sizing a container

How much memory one audio_proxy container needs, as arithmetic over its configuration rather than a number somebody once observed.

This model describes the in-memory-backlog architecture — the one where a render’s output is retained in the coordinator process for the life of the render, which is what every released version through 0.3.x does. If a future version spools backlogs to disk, B_backlog below stops being the dominant term and this page is wrong for that version. Check that the version you run matches the banner before you size against it.

Find your workload in a row and your container’s memory limit in a column. The derivation is below and accounts for every cell; you do not need it to read one.

Three things the tables assume, all of them deliberate:

  • Worst case, not average. Every slot busy with that workload at the same moment, plus the backlogs still lingering from renders that just finished. It is the number that belongs in a memory limit, not the number a dashboard shows.
  • The column is the container’s limit, not the machine’s. A VM running anything else needs room for it on top.
  • This is a memory bound, not a throughput one. It answers how many renders fit in RAM, not how many a host can encode in parallel — those are different limits, and AP_MAX_CONCURRENCY defaults to schedulers online for the other one. Take the smaller of the two.

Each cell is the number to put in AP_MAX_CONCURRENCY — the largest setting whose worst case still fits the memory limit in the column heading. Worst case means every slot busy with that row’s workload at the same moment, plus the backlogs of renders that have just finished and are still holding them.

Output Variant 1 GiB 2 GiB 4 GiB 8 GiB 16 GiB 32 GiB
30 s f:mp3/br:128 24 56 > 64 > 64 > 64 > 64
f:opus/br:96 23 55 > 64 > 64 > 64 > 64
f:mp3/br:320 23 54 > 64 > 64 > 64 > 64
f:flac (44.1/16) 17 40 > 64 > 64 > 64 > 64
f:wav (44.1/16) 23 54 > 64 > 64 > 64 > 64
f:wav/bd:24 (48/24) 19 45 > 64 > 64 > 64 > 64
10 min f:mp3/br:128 30 > 64 > 64 > 64 > 64 > 64
f:opus/br:96 32 > 64 > 64 > 64 > 64 > 64
f:mp3/br:320 18 45 > 64 > 64 > 64 > 64
f:flac (44.1/16) 8 21 47 > 64 > 64 > 64
f:wav (44.1/16) 5 14 33 > 64 > 64 > 64
f:wav/bd:24 (48/24) 3 9 20 43 > 64 > 64
1 h f:mp3/br:128 9 24 53 > 64 > 64 > 64
f:opus/br:96 12 30 > 64 > 64 > 64 > 64
f:mp3/br:320 4 10 24 50 > 64 > 64
f:flac (44.1/16) 3 8 19 41 > 64
f:wav (44.1/16) 1 5 11 25 51
f:wav/bd:24 (48/24) 2 6 15 31
2 h f:mp3/br:128 5 13 29 62 > 64 > 64
f:opus/br:96 6 17 38 > 64 > 64 > 64
f:mp3/br:320 1 5 12 26 54 > 64
f:flac (44.1/16) 1 4 9 20 42
f:wav (44.1/16) 2 5 12 25
f:wav/bd:24 (48/24) refused refused refused refused refused refused

Reading one cell in full: 2 h of f:mp3/br:128 at 4 GiB says 29, so that deployment sets AP_MAX_CONCURRENCY=29. One render of that shape holds 14.4 MiB of ffmpeg, 109.9 MiB of retained output and 1.0 MiB of pipeline — 125.3 MiB between them. At 29 slots the worst case is 30 of those resident at once — the 1 extra being a render that has finished and released its slot but is still holding its backlog for the linger second — which with BEAM_base and T_ffmpeg on top comes to 4017.9 MiB against the limit’s 4096.0 MiB. One more slot would need 4143.2 MiB and does not fit, which is what makes 29 the maximum.

is a workload that does not fit at any concurrency on that limit. > 64 means memory has stopped being the binding constraint and CPU has become it: size those from cores, not from this table. refused is a render whose output crosses the 2 GB AP_MAX_VARIANT_BYTES retention cap and is killed partway through — there is no concurrency at which it works, on any host. See Long-form lossless.

The same model read the other way, for an operator who has fixed AP_MAX_CONCURRENCY and is buying a host. Here the column is the setting and each cell is the memory that setting needs — its worst case, again. Give the container at least that much; the worked examples round up to the next convenient size, and so should you.

Output Variant C = 1 C = 2 C = 4 C = 8 C = 16 C = 32
30 s f:mp3/br:128 292 MiB 323 MiB 387 MiB 514 MiB 767 MiB 1.2 GiB
f:opus/br:96 292 MiB 324 MiB 388 MiB 517 MiB 773 MiB 1.3 GiB
f:mp3/br:320 293 MiB 326 MiB 392 MiB 525 MiB 789 MiB 1.3 GiB
f:flac (44.1/16) 304 MiB 348 MiB 436 MiB 613 MiB 965 MiB 1.6 GiB
f:wav (44.1/16) 293 MiB 326 MiB 392 MiB 525 MiB 789 MiB 1.3 GiB
f:wav/bd:24 (48/24) 299 MiB 339 MiB 418 MiB 576 MiB 892 MiB 1.5 GiB
10 min f:mp3/br:128 309 MiB 334 MiB 383 MiB 481 MiB 677 MiB 1.0 GiB
f:opus/br:96 305 MiB 328 MiB 373 MiB 463 MiB 644 MiB 1005 MiB
f:mp3/br:320 337 MiB 375 MiB 451 MiB 605 MiB 911 MiB 1.5 GiB
f:flac (44.1/16) 420 MiB 499 MiB 659 MiB 978 MiB 1.6 GiB 2.8 GiB
f:wav (44.1/16) 485 MiB 597 MiB 822 MiB 1.2 GiB 2.1 GiB 3.9 GiB
f:wav/bd:24 (48/24) 613 MiB 789 MiB 1.1 GiB 1.8 GiB 3.2 GiB 5.9 GiB
1 h f:mp3/br:128 401 MiB 471 MiB 612 MiB 893 MiB 1.4 GiB 2.5 GiB
f:opus/br:96 374 MiB 431 MiB 544 MiB 772 MiB 1.2 GiB 2.1 GiB
f:mp3/br:320 565 MiB 718 MiB 1.0 GiB 1.6 GiB 2.8 GiB 5.2 GiB
f:flac (44.1/16) 1.0 GiB 1.4 GiB 2.1 GiB 3.6 GiB 6.6 GiB 12.6 GiB
f:wav (44.1/16) 1.5 GiB 2.1 GiB 3.3 GiB 5.7 GiB 10.5 GiB 20.1 GiB
f:wav/bd:24 (48/24) 2.2 GiB 3.2 GiB 5.1 GiB 9.0 GiB 16.9 GiB 32.5 GiB
2 h f:mp3/br:128 511 MiB 636 MiB 886 MiB 1.4 GiB 2.3 GiB 4.3 GiB
f:opus/br:96 456 MiB 554 MiB 750 MiB 1.1 GiB 1.9 GiB 3.4 GiB
f:mp3/br:320 840 MiB 1.1 GiB 1.7 GiB 2.8 GiB 5.1 GiB 9.6 GiB
f:flac (44.1/16) 1.7 GiB 2.4 GiB 3.9 GiB 6.8 GiB 12.7 GiB 24.4 GiB
f:wav (44.1/16) 2.6 GiB 3.8 GiB 6.2 GiB 11.0 GiB 20.6 GiB 39.7 GiB
f:wav/bd:24 (48/24) refused refused refused refused refused refused

Both tables assume plain renders. norm (single-pass loudnorm) adds roughly 55 MiB per slot — R_ffmpeg goes from about 11 MiB to about 66 MiB. On the preview rows that is most of the per-slot cost and divides them by about five; on the long-form rows it disappears into the backlog term.

Generated by bin/capacity-matrix from the formula below and the measured R_ffmpeg table; do not edit by hand.

Two adjustable variables are absent, and their absence is a finding rather than an omission.

AP_QUEUE_SIZE costs approximately nothing. A queued request is waiting for a slot: it holds no ffmpeg subprocess, no backlog and no pipeline buffer — a few kilobytes of coordinator state and a parked connection. Nothing in the formula scales with it. Sizing the queue is a latency and 429 decision (how long a client should wait before being told to come back), not a memory one, so it does not get a column here. Set it from how long your clients will tolerate waiting.

AP_MAX_VARIANT_BYTES bounds one render, not the total. It caps the bytes a single render may retain, which is why it decides whether a cell reads refused. It does not bound C × B_backlog, so it cannot be used as a container memory limit: eight renders each staying just under a 2 GB cap is 16 GB. The lever that bounds the total is AP_MAX_CONCURRENCY — raising the retention ceiling instead licenses every slot to reach the larger figure. AP_MAX_SRC_BYTES is not in the matrix at all: it bounds the source, and there is no source term in the model. See Two ceilings.

The derivation: output is the hazard, input is not

Section titled “The derivation: output is the hazard, input is not”

Everything from here down is where the cells above come from. It is worth reading before trusting them with a production limit, and it is not worth reading to find a number.

The instinct when sizing a transcoding proxy is to ask how large the source files are. That instinct is wrong here, and it is worth getting rid of before reading the formula.

Input never accumulates. Source bytes do not pass through the BEAM at all. audio_proxy hands ffmpeg a URL — a presigned S3 URL, an https:// origin, a path under AP_LOCAL_ROOT — and ffmpeg reads it itself, streaming through fixed-size buffers and issuing its own Range requests. A two-hour, 1.3 GB WAV master and a thirty-second clip cost the same resident memory to read. There is no term in the model for source size, and the measured table below is the evidence: the two-hour rows land within a few megabytes of the sixty-second ones.

Output accumulates, all of it, until the render ends. The render coordinator retains every chunk ffmpeg emits, in memory, for the whole render — that is what lets a second request for the same variant join a render already in flight and still receive a complete stream. Nothing trims the backlog as clients consume it, because a client that has not arrived yet would need the bytes an existing client already read. So the memory one render costs is the size of the variant it produces, and a variant’s size is duration × bitrate.

That asymmetry is the whole story. Sizing this proxy is sizing its outputs.

RAM ≈ BEAM_base + T_ffmpeg + (C + L) × (R_ffmpeg + B_backlog + H_pipeline) + U × part_size
Term What it is Where it comes from Value
BEAM_base The release at rest: ERTS, the supervision tree, Bandit’s acceptors Measured on the runtime image, idle and healthy ≈ 110 MiB anonymous
T_ffmpeg ffmpeg’s shared library text, resident while any render runs Measured; paid once, not per render — see Why it is not multiplied 50–130 MiB; budget 150 MiB, reclaimable
C Simultaneous ffmpeg processes AP_MAX_CONCURRENCY (default: schedulers online) your setting
L Completed renders still holding their backlog the coordinator’s linger window, 1 s see Why C is not enough
R_ffmpeg Private (anonymous) peak of one ffmpeg subprocess Measured; table below 10–18 MiB plain, 64–74 MiB with norm
B_backlog Retained output bytes for one render retained render output, capped by AP_MAX_VARIANT_BYTES min(variant size, AP_MAX_VARIANT_BYTES)
H_pipeline Forwarded-but-unacknowledged bytes plus the port’s read queue the pipeline’s high-water mark, 1 MiB ≤ 1 MiB, and in practice far less
U In-flight S3 write-back uploads Not reachable today — see The S3 write-back term 0 with a file:// store
part_size Bytes buffered per multipart part the multipart part size, 5 MiB 5 MiB, when U > 0

Everything here is a worst case, and deliberately so: it is the number that belongs in a container memory limit, not the number you expect to see in a dashboard.

The ffmpeg binary is 388 KB and the libraries behind it are 195 MB — libavcodec carries every decoder Debian builds. Those are file-backed pages, and every concurrent ffmpeg maps the same physical copy: eight renders do not cost eight libavcodecs. So the library text is a flat term next to BEAM_base rather than part of the per-render bracket, and R_ffmpeg in the table below is deliberately the anonymous (private) peak, which is the memory one additional render actually costs.

Only the pages actually touched become resident, so the term is a range rather than a number: 47 MiB measured for a single MP3 encode, around 130 MiB for containers exercising more of the codec surface, against a hard ceiling of the 195 MB on disk. Budget 150 MiB and treat a deployment using every format as the upper end. It is reclaimable — under pressure the kernel drops clean file pages and re-reads them rather than OOM-killing — so it is headroom, not a working set.

This is the one term in the model that is a judgement rather than a reading, and it is worth knowing why it cannot be measured on demand. What any single measurement captures is not the size of the library working set but how much of it that particular container had to fault in, which depends entirely on what ran before it. The same probe returns 47–93 MiB on a developer machine with a cold cache and 2.8 MiB on a CI runner where an earlier container had already warmed the same libraries — a thirty-fold spread with nothing behind it but cache history. A long-lived proxy container is the cold-cache case that keeps its pages, so 150 MiB is the figure to size against. bin/check-capacity predicts from that budget and prints what its host happened to charge beside it, precisely so the two can be seen to disagree.

Getting this wrong in either direction is the most expensive mistake available here: multiplying it inflates a 16-slot estimate by two gigabytes of memory nobody needs to buy, and dropping it under-sizes every deployment by the same 150 MiB.

The other terms are tens of megabytes. This one is the size of a variant, and a variant can be a gigabyte.

Variant Bitrate 30 s preview 60 min 120 min
f:mp3/br:128 128 kbps 480 KB 58 MB 115 MB
f:opus/br:96 96 kbps 360 KB 43 MB 86 MB
f:mp3/br:320 320 kbps 1.2 MB 144 MB 288 MB
f:flac (44.1/16 stereo) ~850 kbps 3.2 MB 380 MB 760 MB
f:wav (44.1/16 stereo) 1411 kbps 5.3 MB 635 MB 1.27 GB
f:wav/bd:24 (48/24 stereo) 2304 kbps 8.6 MB 1.04 GB 2.07 GB

A preview-shaped deployment never notices this term. A long-form deployment is sized by it and nothing else.

AP_MAX_CONCURRENCY caps encoders, not retained backlogs, and the two come apart at the end of a render. When ffmpeg exits, RenderCoordinator releases its semaphore slot immediately — a finished render should not hold a CPU slot while its bytes are served from memory — and then lingers for one second so a late request can still be served from the completed buffer.

During that second the coordinator holds its entire backlog while a fresh render already occupies the slot it gave up. So the number of full backlogs resident at once is C + L, where L is however many renders finished within the last second:

L ≤ C × (1 s / typical render duration)
  • Short renders (previews finishing in well under a second): budget L = C, i.e. size for 2C backlogs. The linger window can hold a complete second’s worth of turnover.
  • Long-form renders (a two-hour transcode taking a minute or more): L is a rounding error. Budget L = 1 and move on.

This is a real term, not a theoretical one, and it is the most common way an otherwise correct hand-calculation comes out a factor of two low.

Peak anonymous memory of one ffmpeg subprocess — the private cost of one more render — by output format and by whether norm (single-pass loudnorm, the heaviest filter the API offers) is applied. Shared library text is T_ffmpeg above and is deliberately not in these figures. Produced by bin/measure-ffmpeg-rss against the ffmpeg the runtime image ships; see Regenerating these tables.

Variant Options Source Peak RSS
mp3 f:mp3/br:128 60 s 10.9 MiB
mp3 + norm f:mp3/br:128/norm:ebu 60 s 64.5 MiB
opus f:opus/br:128 60 s 11.1 MiB
opus + norm f:opus/br:128/norm:ebu 60 s 66.8 MiB
ogg f:ogg/br:128 60 s 11.4 MiB
ogg + norm f:ogg/br:128/norm:ebu 60 s 67.4 MiB
aac f:aac/br:128 60 s 11.5 MiB
aac + norm f:aac/br:128/norm:ebu 60 s 65.0 MiB
m4a f:m4a/br:128 60 s 11.5 MiB
m4a + norm f:m4a/br:128/norm:ebu 60 s 65.1 MiB
flac f:flac 60 s 17.7 MiB
flac + norm f:flac/norm:ebu 60 s 73.5 MiB
wav f:wav 60 s 10.5 MiB
wav + norm f:wav/norm:ebu 60 s 55.8 MiB
mp3 128k f:mp3/br:128 2.0 h 14.4 MiB
opus 96k f:opus/br:96 2.0 h 14.7 MiB
flac f:flac 2.0 h 18.0 MiB
mp3 128k + norm f:mp3/br:128/norm:ebu 2.0 h 64.7 MiB

Peak anonymous memory — the private cost of one more render, which is what the model multiplies by concurrency. Measured on linux/arm64 against ffmpeg 7.1.5-0+deb13u1 from the pinned runtime image: the highest of 3 sampled runs per row, probe baseline subtracted. Regenerate with bin/measure-ffmpeg-rss.

Two things to read out of it:

  • norm costs roughly 55 MiB more, flat. Single-pass loudnorm buffers audio to measure loudness before it can correct it, and resamples to 192 kHz to do so (see the contributor notes for why the filter order is what it is). That is a fixed window, not a growing one — the two-hour norm row costs the same as the sixty-second one — but it is several times the cost of an unfiltered render, so a deployment where every request carries norm should size R_ffmpeg from the filtered column and not the plain one.
  • Duration does not appear. The bolded long-form rows are within a few megabytes of the sixty-second ones, and the small gap is sampling coverage rather than growth: a two-hour encode is polled a thousand times and a sixty-second one a few dozen, so the long rows simply get closer to the true peak. The claim being tested is not subtle — if output accumulated in ffmpeg the way it accumulates in the backlog, the two-hour MP3 row would read 115 MiB rather than 14. This is “input never accumulates”, measured rather than argued.

A note on how the numbers are taken, because the obvious method does not work. cgroup memory.peak counts page cache, and ffmpeg’s library text is page cache, charged to whichever container faults it in first — the same flac encode measured anywhere from 20 MiB to 148 MiB on that basis alone, an outlier that reads exactly like a finding about flac and is not one. The script therefore samples anonymous memory during the encode and reports the highest sample across several runs, which is both reproducible and the quantity the model actually needs. It refuses to publish a row that looks like a missed sample rather than quietly writing a small number into a table operators size containers from.

U × part_size is in the formula because the model should not need rewriting when the S3 variant store lands, but it is zero on every version that carries this document. The only merged AP_VARIANT_STORE backend is file://, which streams to a staging file on disk and buffers nothing in memory beyond one chunk.

An s3:// store uploads via multipart, grouping the stream into parts of exactly 5 MiB and holding one part at a time per upload. U is then the number of write-backs in flight, which is bounded by C + L for the same reason the backlog term is: one tee per coordinator. Add 5 MiB per concurrent render and the model still holds.

N clients requesting the same variant while it renders do not cost N backlogs. The coordinator runs one render per cache key and broadcasts each chunk to every subscriber; on the BEAM a binary of more than 64 bytes is reference-counted and shared, so a broadcast send copies a pointer rather than the audio. Ten subscribers to one render cost one backlog plus ten small process heaps.

The coalescing suite’s byte-identical-stream tests are the evidence that every subscriber really does receive the same bytes from the same render; this document does not re-prove it.

One caveat, because it is the exception that proves the rule: a client that joins a render already in flight is handed the backlog-so-far as a single contiguous binary, which is one transient copy of however much had accumulated at the moment it joined. It is freed as soon as the chunk is written to the socket, and it does not recur — every subsequent chunk is shared. For preview-sized variants it is invisible. For a long-form render being joined late by many clients at once, it is worth knowing that the peak can briefly exceed the model by roughly one backlog per simultaneous joiner.

There are two byte ceilings, checked at different moments against different things:

  1. AP_MAX_SRC_BYTES (default 2000000000 — 2 GB), against the source size, before a render starts. An oversized source is refused with 413 and nothing is spawned. It has nothing to say about output.
  2. AP_MAX_VARIANT_BYTES (default: whatever AP_MAX_SRC_BYTES resolves to), against the cumulative output bytes, inside RenderCoordinator.retain/2. A render whose output crosses it is killed and the request fails. This is B_backlog’s bound, and so the ceiling that decides whether a cell above reads refused.

The second exists because the first could not do both jobs at once. A catalogue of two-hour masters served as thirty-second previews needs the source ceiling above 1.3 GB to accept its own files — and while that number was also the retention cap, setting it there licensed every render to hold 1.3 GB of output, for a workload whose variants are 480 KB. Set AP_MAX_VARIANT_BYTES to what your outputs actually are and the two stop pulling against each other.

The default keeps them equal, so a deployment that sets neither, or only AP_MAX_SRC_BYTES, is bounded exactly where it was before the ceilings were separated.

Raising the retention ceiling does not buy capacity. This is the misreading with consequences. It bounds one render while the bill is C × B_backlog, so raising it to fit one large render licenses every concurrent slot to reach that size. A render that fails today fails alone, legibly, with the other renders surviving; raise the ceiling and it succeeds, two concurrent ones exhaust the container, and the kernel picks the victim — which is every in-flight render rather than the one that misbehaved. The lever that bounds the total is AP_MAX_CONCURRENCY, and the matrix above is where you find out what a total costs.

Neither default is a memory bound in any meaningful sense: at 2 GB with the default AP_MAX_CONCURRENCY on an 8-core host, the model’s worst case is over 16 GB. A deployment that has not thought about this has not been sized.

These are the matrix’s proof rather than its substitute: the same arithmetic, done by hand, on three shapes worth understanding. Where an example reads a little lower than the corresponding cell, it is because each one takes R_ffmpeg from the row for its own format while the matrix takes the largest row that format has — a matrix cannot know which of your renders is the long one, so it assumes all of them are.

1. Previews, the shape the defaults assume

Section titled “1. Previews, the shape the defaults assume”

30-second MP3 and Opus previews, AP_MAX_CONCURRENCY=8, no norm, file:// store.

B_backlog = 480 KB (30 s at 128 kbps, the larger of the two)
R_ffmpeg = 11 MiB (mp3, from the table)
H_pipeline = 1 MiB
per render ≈ 12.5 MiB
renders = C + L = 8 + 8 = 16 (short renders: budget L = C)
16 × 12.5 MiB = 200 MiB
RAM ≈ 110 MiB (BEAM) + 150 MiB (T_ffmpeg) + 200 MiB ≈ 460 MiB

Set the container limit to 768 MiB. The backlog term is nothing here; this deployment is sized by the BEAM, ffmpeg’s libraries, and eight copies of a small encoder. Note that the two flat terms together are larger than everything concurrency contributes — which is why a preview deployment gets cheaper per slot as it grows, and why halving AP_MAX_CONCURRENCY here saves very little.

2. Long-form lossy — feasible, and a decision

Section titled “2. Long-form lossy — feasible, and a decision”

Two-hour podcast episodes rendered full-length as f:mp3/br:128, AP_MAX_CONCURRENCY=16, no norm.

B_backlog = 115 MB ≈ 110 MiB (7200 s at 128 kbps)
R_ffmpeg = 11 MiB
H_pipeline = 1 MiB
per render ≈ 122 MiB
renders = C + L = 16 + 1 = 17 (long renders: L is a rounding error)
17 × 122 MiB ≈ 2.0 GiB
RAM ≈ 110 MiB (BEAM) + 150 MiB (T_ffmpeg) + 2.0 GiB ≈ 2.3 GiB

Set the container limit to 3 GiB. Opus at 96 kbps is 86 MB per episode instead of 115 MB and brings the same calculation to ≈ 1.8 GiB. Both flat terms have become noise: at full length the backlog is 90 % of the bill.

This is feasible, and it is a decision rather than a default: sixteen concurrent full-length renders is two gigabytes of audio held in memory purely so that a second listener requesting the same episode can join mid-render. If that coalescing benefit is not worth the RAM for your traffic, the lever is AP_MAX_CONCURRENCY — halve it and halve the memory, at the cost of queueing (and 429s past AP_QUEUE_SIZE).

3. Long-form lossless — fails the cap, loudly, by design

Section titled “3. Long-form lossless — fails the cap, loudly, by design”

Two-hour masters rendered full-length as f:wav/bd:24 at 48 kHz stereo.

B_backlog = 2.07 GB per render — and AP_MAX_VARIANT_BYTES defaults to 2.0 GB

A single render exceeds the retention cap before it finishes. What happens is not a slow degradation: retain/2 returns an error partway through, the render is killed, and the request fails with

render output exceeded the 2000000000-byte retention cap (AP_MAX_VARIANT_BYTES)

This is the intended behaviour and the right one — the alternative is a container that runs until the kernel OOM-kills it, taking every other in-flight render with it. But it means full-length lossless output is not a workload this architecture serves, whatever you set the cap to: raising AP_MAX_VARIANT_BYTES to 4 GB makes one render succeed and two concurrent ones exhaust an 8 GiB container. Raising AP_MAX_SRC_BYTES alone changes nothing here at all — the source was never the problem.

What to do instead, in order of preference:

  1. Serve lossless as trimmed excerpts. A t: option bounds the output, and the model bounds with it. Five-minute excerpts of 24-bit WAV are 86 MB each.
  2. Serve full length in a lossy format, per example 2.
  3. Wait for the spooled backlog. The escalation named in CLAUDE.md — the coordinator writing its backlog to a scratch file rather than holding it, with clients reading from the file — removes B_backlog from the model entirely and makes full-length lossless a disk question instead of a memory one. It is not built. It is an on-demand change for a deployment whose primary workload is long-form lossless, and it rewrites this page when it lands.
  • Page cache. A local:// source or a file:// variant store reads and writes through the page cache, which the kernel charges to the container’s cgroup and which memory.peak therefore counts — a workload can show hundreds of megabytes above the model’s prediction with none of it in use. It is reclaimable: under pressure the kernel drops it rather than OOM-killing. The model does not try to predict it, and the CI guard subtracts it (see below) rather than modelling it.
  • Disk. A file:// variant store grows without bound; nothing in audio_proxy expires it. That is a separate sizing question with a separate answer (a CDN, or a lifecycle rule on the bucket).
  • CPU and throughput. How many renders per second a container sustains is a different calculation. AP_MAX_CONCURRENCY defaults to schedulers online for a reason, and raising it to buy memory headroom is not free.

Three mechanisms, because a capacity document that drifts is worse than none.

The matrix is generated, and then checked against itself. Every cell at the top of this page is bin/capacity-matrix evaluating the formula below over the measured table below, using constants it shares with the CI guard (bin/capacity_model.rb — one copy, required by both). A hand-maintained matrix would be a second copy of the model, and the second copy is the one that goes stale.

Generating it is not quite enough, though, because the matrix runs the model backwards and an inversion is the easy thing to get subtly wrong — an off-by-one in the linger term, a rounding that goes up, a short-render row halved on the wrong side of the division. Each of those produces a table that looks entirely reasonable and over-promises. So bin/capacity-matrix --verify checks the two directions against each other on every cell: the published concurrency must fit the column’s limit, and one more slot must not. CI runs it, and it needs no image. It has already earned its place — it caught the published figures losing a slot to floating-point residue in about a fifth of the cells.

The measured table is regenerated from the image. bin/measure-ffmpeg-rss takes its ffmpeg from the pinned runtime image and its arguments from the same builder the proxy uses, so it cannot be stale about the encoder or about the arguments the encoder is handed.

CI runs the model against the built image. bin/check-capacity starts the release container with a known configuration, drives a concurrent workload through it — including a two-hour source, which is where the model has the most to be wrong about — and asserts that the container’s cgroup memory.peak, minus reclaimable page cache, stays under the prediction this page’s formula makes for that configuration.

On the reference workload — four concurrent two-hour MP3 renders plus eight short ones, AP_MAX_CONCURRENCY=4 — the model predicted 955 MiB and the container peaked at 748 MiB adjusted: 78 % of the prediction, on arm64.

Read that as the model being deliberately conservative rather than merely approximate, and the conservatism is almost entirely one term. Sizing T_ffmpeg at its 150 MiB budget when a given host charges 47 MiB accounts for most of the gap; strike that difference out and the remaining terms land within about ten per cent of the observation. Which is the property that matters — the formula is right about B_backlog, the term that decides whether a long-form deployment fits, and it errs high on the flat term where erring high costs a reader nothing but a slightly larger container.

The guard’s tolerance is a stated 1.5× headroom factor on the prediction, and the factor is written down here rather than buried so that nobody mistakes it for precision. It covers BEAM allocator slack (the BEAM returns freed binaries to the OS lazily), reference-counted binary collection lag, and CPU-architecture variation between the arm64 the table was measured on and the amd64 CI runs. It does not cover a new term appearing in the model — which is the point. A guard loose enough never to fire is not a guard.

The workload is a sample, not an exhaustive search: it validates the model’s shape — that the terms are the right terms and the coefficients are the right size — not every configuration an operator could choose. A failing guard means the model has stopped describing the code, and the fix is to reconcile the two, not to widen the factor.

The two measuring scripts need docker and a cgroup v2 host (memory.peak, kernel ≥ 5.19); none of them needs ffmpeg or Elixir installed. bin/capacity-matrix needs nothing at all — it is arithmetic over this document and a constants file.

Terminal window
# Measure R_ffmpeg on the current image and rewrite the measured table in place.
bin/measure-ffmpeg-rss --write docs/capacity.md
# Reuse already-built images (much faster while iterating).
SKIP_BUILD=1 bin/measure-ffmpeg-rss --write docs/capacity.md
# Regenerate the matrix from that table. Always second: it reads what the line
# above wrote.
bin/capacity-matrix --write docs/capacity.md
# Print the matrix without touching the document.
bin/capacity-matrix
# Assert every published cell inverts the model. No docker, under a second.
bin/capacity-matrix --verify
# Run the workload guard the way CI does.
SKIP_BUILD=1 bin/check-capacity
# Prove the guard still has teeth (asserts a retention-blind model is rejected).
SKIP_BUILD=1 bin/check-capacity --self-test

A different ffmpeg encodes differently and holds different memory, so regenerating both tables is a step in the pin-bump procedure — see VERSIONS.md.