feat: allow configuring the DATA frame overhead threshold - #950
Conversation
`data_frame_budget` is configurable, but the payload length below which a
DATA frame is charged against it is a fixed 256 bytes. A peer that
legitimately receives many small DATA frames — a server-sent event stream
whose events are tens to low hundreds of bytes each is the ordinary case —
is charged on every frame, and cannot express "frames this small are normal
here" other than by raising the budget.
Raising the budget does not express it cleanly, because whether such a
stream survives depends on the connection window as well as the frame size.
Frames of payload `L < T` each consume `T - L` until the application reads
them, and the number that can sit unread is bounded by the connection window
`W`, so the worst-case simultaneous charge is `W * (T - L) / L`. With the
default `Auto` budget `B = max(W / 2, 25_600)`, for `W >= 51_200` the stream
avoids the budget only when
W * (T - L) / L <= W / 2 <=> 2T <= 3L <=> L >= (2/3) * T
`W` cancels, which is the improvement `Auto` already made — but the residual
cutoff is `(2/3) * 256`, about 171 bytes, and it is a property of `T`, not
something the budget can move. Any budget a user picks to get under it is
back to being a number derived from `W`.
Making `T` itself configurable removes both dependencies: frames at or above
`T` are never charged at all, so setting `T` at or below the smallest payload
the peer legitimately receives makes the accounting independent of both the
frame size and the window, and it stays correct if someone later widens the
window.
The addition is symmetric with `data_frame_budget`: a field on both builders,
plumbed through `proto::Config` into `streams::Config` and `Counts`, which
now reads the threshold from config instead of the constant. The default is
unchanged (`DEFAULT_DATA_FRAME_OVERHEAD_THRESHOLD`, 256), so behaviour for
anyone who does not call the setter is byte-identical. `0` is rejected,
since it would disable the charge for every payload size.
Lowering the threshold does not weaken the empty-DATA-frame limit: empty
frames are counted against `MAX_RECV_EMPTY_DATA_FRAMES` and never touch this
budget, so the number a peer may send is the same for every threshold. That
is asserted rather than assumed.
Four tests are added next to the existing budget tests: frames at or above a
lowered threshold stop being charged, frames below it are still charged (and
still exhaust the budget), record and release use the same threshold so the
budget does not drift, and the empty-frame limit is identical across
thresholds.
|
Thanks for the write-up! Though, I'm not sure adjusting the overhead threshold is something that should be configurable. It's meant to represent internally how much extra cost in resources holding onto a frame takes, especially when it doesn't other "cost" the window size. For instance, if we can do some work in the future to reduce the memory layout of Why is adjusting the budget itself not sufficient? |
|
Thanks for taking a look. Raising the budget does work numerically — the part I couldn't make safe is that the value you have to pick is a function of the connection window. For a payload The other reason is granularity. Raising On One caveat I should state plainly: I have not characterised the payload-size distribution of the streams involved, so I'm not claiming a specific typical size — only that they can fall below the |
Motivation
data_frame_budgetis configurable, but the payload length below which a DATA frame ischarged against it is a fixed 256 bytes (
DEFAULT_DATA_FRAME_OVERHEAD_THRESHOLD). A peerthat legitimately receives many small DATA frames — a server-sent event stream whose events
are tens to low hundreds of bytes each is the ordinary case — is charged on every frame,
and today has no way to say "frames this small are normal here" other than by raising the
budget.
Raising the budget does not express it cleanly, because whether such a stream survives
depends on the connection window as well as the frame size. A frame of payload
L < Tconsumes
T - Luntil the application reads it, and the number that can sit unread isbounded by the connection window
W, so the worst-case simultaneous charge isW * (T - L) / L. With theAutobudgetB = max(W / 2, 25_600), forW >= 51_200thestream avoids the budget exactly when
Wcancels — which is the improvementAutoalready made. But the residual cutoff is(2/3) * 256, about 171 bytes, and it is a property ofT, not something the budgetcan move. Any budget value picked to get under it is back to being a number derived from
W, so it silently becomes wrong the day someone widens the window.Making
Titself configurable removes both dependencies. Frames at or aboveTarenever charged at all, so setting
Tat or below the smallest payload the peer legitimatelyreceives makes the accounting independent of both the frame size and the window.
Solution
The addition is deliberately symmetric with
data_frame_budget:data_frame_overhead_threshold()setter on bothserver::Builderandclient::Builder, right next todata_frame_budget();proto::Configintostreams::Config;Countsreads the threshold from config instead of the constant, in bothrecord_data_frameandrelease_data_frame.The default is unchanged. Anyone who does not call the setter gets
DEFAULT_DATA_FRAME_OVERHEAD_THRESHOLD(256) and byte-identical behaviour.0is rejectedwith a panic, since it would disable the charge for every payload size.
Lowering the threshold does not weaken the empty-DATA-frame limit. Empty frames are
counted against
MAX_RECV_EMPTY_DATA_FRAMESand never touch this budget, so the number apeer may send is identical for every threshold. That is asserted rather than assumed, since
it is the property someone reviewing a lowered threshold will want to check first.
Tests
Four tests next to the existing budget tests, covering both directions:
a_lowered_threshold_stops_charging_frames_at_or_above_it— the point of the setting;a_lowered_threshold_still_charges_frames_below_it— shortening the charged range mustnot turn the charge off for the sizes that remain inside it;
released_frames_replenish_at_the_configured_threshold— record and release must use thesame threshold, or the budget drifts over the life of the connection;
the_threshold_does_not_change_the_empty_data_frame_limit— checked across[1, 16, 256, 1024].cargo test --libpasses (446 tests),cargo fmt --checkis clean, andcargo clippy --all-featuresreports the same warnings asmaster(no new ones).I am happy to adjust the naming, the doc wording, or the panic-vs-
Resultchoice for0towhatever fits the crate's conventions.