Skip to content

Quantize auction transport timeouts to stabilize Fastly backend names#865

Open
prk-Jr wants to merge 7 commits into
mainfrom
quantize-auction-transport-timeouts
Open

Quantize auction transport timeouts to stabilize Fastly backend names#865
prk-Jr wants to merge 7 commits into
mainfrom
quantize-auction-transport-timeouts

Conversation

@prk-Jr

@prk-Jr prk-Jr commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Fastly dynamic backend names deliberately embed the first-byte and between-bytes timeouts so a registration can never be silently reused with a different transport configuration. But deriving those timeouts from the remaining wall-clock auction budget minted a new backend name on nearly every request — defeating cross-request TCP/TLS connection reuse to bidder hosts (Fastly pools connections per backend name) and accumulating registrations toward the per-service dynamic backend limit.
  • Compute the effective transport timeout in three regimes: the provider's configured timeout verbatim when the budget allows (a constant, already name-stable), the remaining budget floored to 250ms buckets when the budget is the binding constraint, and exact passthrough for sub-quantum remainders so publishers with sub-250ms configured budgets keep launching. The value feeds both the backend name and the registered configuration, so they cannot diverge; rounding down never extends a transport cap past the auction deadline, which the mediator and dispatched-collect paths rely on to bound the </body> hold.
  • Pin predict_name == ensure on the Fastly platform backend with a Viceroy test, since the orchestrator maps SSP responses back to providers by predicted backend name.

Changes

File Change
crates/trusted-server-core/src/auction/orchestrator.rs Add TRANSPORT_TIMEOUT_QUANTUM_MS, quantize_transport_timeout_ms, and effective_transport_timeout_ms; apply at the four provider-launch sites (parallel, dispatch, synchronous mediator, collect-path mediator); extend the test stub with an optional timeout recorder and add quantization, sub-quantum-passthrough, and mediator-budget tests
crates/trusted-server-adapter-fastly/src/platform.rs Add predict_name_matches_ensured_backend_name test pinning the predicted name to the registered name for an identical spec

Closes

Closes #847

Test plan

  • cargo test-fastly && cargo test-axum
  • cargo clippy-fastly && cargo clippy-axum
  • cargo fmt --all -- --check
  • JS tests: cd crates/trusted-server-js/lib && npx vitest run
  • JS format: cd crates/trusted-server-js/lib && npm run format
  • Docs format: cd docs && npm run format
  • WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Manual testing via fastly compute serve
  • Other: cargo test-cloudflare && cargo test-spin, cargo clippy-cloudflare && cargo clippy-cloudflare-wasm && cargo clippy-spin-native && cargo clippy-spin-wasm, and the integration parity suite (cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity) all pass

Checklist

  • Changes follow CLAUDE.md conventions
  • No unwrap() in production code — use expect("should ...")
  • Uses tracing macros (not println!)
  • New code has tests
  • No secrets or credentials committed

Fastly dynamic backend names embed the first-byte and between-bytes
timeouts so a registration can never be silently reused with a different
transport configuration. Deriving those timeouts from the remaining
wall-clock auction budget minted a new backend name on nearly every
request, defeating cross-request TCP/TLS connection reuse (Fastly pools
connections per backend name) and accumulating registrations toward the
per-service dynamic backend limit.

Compute the effective transport timeout from the configured provider
timeout verbatim when the budget allows, floor the budget-bound value to
250ms buckets otherwise, and pass sub-quantum remainders through exactly
so publishers with sub-250ms configured budgets keep launching. The
quantized value feeds both the backend name and the registered
configuration, so they cannot diverge. Rounding down never extends a
transport cap past the auction deadline, which the mediator and
dispatched-collect paths rely on to bound the </body> hold.

Also add a Fastly platform test pinning predict_name == ensure for the
same spec, since the orchestrator maps responses back to providers by
predicted backend name.

Fixes #847
@prk-Jr prk-Jr self-assigned this Jul 8, 2026
ChristianPavilonis

This comment was marked as low quality.

@ChristianPavilonis ChristianPavilonis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Reviewed the timeout quantization change against the PR's actual main base. The arithmetic is internally consistent, but the current implementation has correctness and operational risks that should be addressed before merge. Detailed findings are attached inline.

Comment thread crates/trusted-server-core/src/auction/orchestrator.rs Outdated
Comment thread crates/trusted-server-core/src/auction/orchestrator.rs Outdated
Comment thread crates/trusted-server-core/src/auction/orchestrator.rs Outdated
Comment thread crates/trusted-server-core/src/auction/orchestrator.rs Outdated
Comment thread crates/trusted-server-core/src/auction/orchestrator.rs Outdated
Resolve the PR review by making transport-timeout canonicalization a
platform capability and hardening auction backend-name correlation.

- Move quantization behind PlatformBackend::canonicalize_transport_timeout_ms.
  Fastly floors budget-derived timeouts to a 250ms quantum with a bounded
  sub-quantum ladder [200,150,100,50]; other adapters use the exact remaining
  budget so bidder deadlines (Prebid tmax, APS timeout) are not shortened
  where no connection-pooling benefit exists.
- Bound sub-quantum backend-name cardinality: exact 1-249ms values no longer
  pass through, capping the budget-derived names a single origin can mint
  toward the per-service dynamic backend limit.
- Add a provider discriminator to PlatformBackendSpec, folded into every
  adapter's backend name, so two providers sharing one origin no longer
  collide on the response-correlation key. Reject a duplicate
  backend_to_provider insertion with an attributed launch failure instead of
  silently overwriting and misattributing a response.
- Make the orchestrator call-site tests deterministic: record predicted and
  registered transport timeouts separately and assert exact equality via a
  controllable platform backend, and enumerate the sub-quantum ladder to
  assert a bounded name cardinality.
- Correct the timeout-semantics comments that overstated absolute-deadline
  enforcement; the Fastly connect/first-byte/between-bytes timeouts bound
  connection, first-byte, and inactivity, not total response time. A true
  absolute deadline carried through the platform HTTP API remains follow-up
  work (#849).

@ChristianPavilonis ChristianPavilonis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Reviewed current HEAD 5512d850 against main. The Fastly-only canonicalization and provider-discriminator wiring are directionally correct, but two high-risk backend identity/cardinality issues and two medium reliability/compatibility issues remain; details are inline.

Documentation

The PR description no longer describes HEAD: it still claims exact sub-quantum passthrough and a two-file change, while HEAD uses a [200, 150, 100, 50] ladder, skips values below 50 ms, adds a platform trait capability and discriminator, and changes 15 files. Please refresh the Summary, Changes table, and test plan.

Comment thread crates/trusted-server-adapter-fastly/src/backend.rs
Comment thread crates/trusted-server-adapter-fastly/src/platform.rs
Comment thread crates/trusted-server-adapter-fastly/src/backend.rs Outdated
Comment thread crates/trusted-server-core/src/auction/orchestrator.rs
prk-Jr added 3 commits July 16, 2026 17:55
Address the auction transport-timeout review findings:

- Derive dynamic backend names from a bounded readable prefix plus a
  SHA-256 digest of the complete backend spec, so distinct specs never
  alias to one name. Name equality now implies spec equality, making
  NameInUse reuse provably safe, and the name is bounded to Fastly's
  255-char limit regardless of host or discriminator length.
- Bound budget-derived transport-timeout cardinality with a globally
  finite ladder: keep the 250ms quantum through 2000ms, then snap larger
  budgets to a small fixed set of coarse rungs so a large configured
  ceiling can no longer mint hundreds of dynamic backends.
- Reject duplicate configured providers at startup, and check the
  predicted backend name before request_bids fires the outbound send,
  retaining the post-launch check as defense against a provider that
  resolves to an unexpected name.
…sport-timeouts

# Conflicts:
#	crates/trusted-server-adapter-fastly/src/backend.rs
#	crates/trusted-server-core/src/auction/orchestrator.rs
The backend name now carries a SHA-256 digest suffix, so the three
platform predict_name tests that pinned the exact/ending name string no
longer hold. Assert the readable body via starts_with/contains instead.
These were missed earlier because the local run filtered the platform
suite too narrowly; CI aborted on the first panic.

@aram356 aram356 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Reviewed HEAD 77f3521 against main. All nine findings from the two earlier review rounds are verified fixed on this HEAD: the digest-based backend naming makes name equality imply spec equality (and I confirmed the only Backend::builder call in the Fastly adapter flows through it), the discriminator keeps same-origin providers on distinct names on all four adapters, cardinality is globally bounded by the sub-quantum and coarse ladders, duplicate providers are rejected at startup with a pre-launch guard on both paths, canonicalization is Fastly-only via the platform trait, and the timeout wiring tests are deterministic. Remaining items are one documentation blocker and a handful of non-blocking suggestions, inline.

Blocking

🔧 wrench

  • Stale PR description: the description still describes the pre-review design — "exact passthrough for sub-quantum remainders so publishers with sub-250ms configured budgets keep launching" was removed in 5512d85 (budget-derived sub-quantum values now snap to the [200, 150, 100, 50] ladder; only configured sub-250ms constants pass through). It also predates most of what the PR now contains: digest-based backend naming, provider discriminators, the coarse ladder, duplicate-provider startup rejection, and the platform-capability split. The changes table still places the quantization helpers in orchestrator.rs, but they live in the Fastly adapter now. Please rewrite the Summary/Changes to describe HEAD.

Non-blocking

⛏ nitpick

  • ensure() doc drift: the doc comment still says the name is "derived from the scheme, host, port, certificate setting, first_byte_timeout, and between_bytes_timeout" — no digest, no discriminator, no Host override (crates/trusted-server-adapter-fastly/src/backend.rs:328-334). compute_name carries the accurate description; this paragraph should defer to it instead of restating a stale subset.

CI Status

  • fmt: PASS
  • clippy: PASS
  • rust tests (fastly/axum/cloudflare/spin + parity + CLI): PASS
  • js tests: PASS
  • integration/browser tests: PASS

/// the greatest rung no larger than the remaining budget, and anything above
/// the top rung clamps to it. Rounding down never extends a transport cap past
/// the remaining budget.
const TRANSPORT_TIMEOUT_COARSE_LADDER_MS: [u32; 8] =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thinking — The coarse ladder's worst-case haircut is ~50%: a remaining budget of 9,999ms snaps to 5,000ms, and 19,999ms to 10,000ms. On the mediator path this value is the effective bound (there is no select-loop backstop), so a mediator can lose half its usable transport window to bucketing. A denser geometric ladder (ratio ≈1.3, e.g. [2000, 2500, 3250, 4250, 5500, 7000, 9000, 12000, 15500, 20000, 26000, 34000, 44000, 60000]) stays globally finite at ~14 rungs while capping the loss near 25%. If the 50% loss is an accepted trade-off, a sentence in the const doc saying so would keep the next reader from re-deriving it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented rather than densified, in bfde031. The const doc now records the worst-case ~50% haircut with the 9,999→5,000ms example and why it is accepted: on this ladder a denser rung set buys back at most half a bucket of transport window while growing the backend-name space proportionally, and the 8 rungs here already cover the practical mediator budgets. If a real config shows up living just under a rung we can add that rung then, grounded in the observed budget.

}

#[test]
fn canonicalize_budget_derived_names_stay_within_a_safe_cardinality() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ refactor — Both enumeration loops (here and in canonicalize_budget_derived_names_stay_bounded_for_large_ceiling) assert quantum alignment and cardinality, but not the invariant the mediator </body> hold bound actually relies on: the canonical value never exceeds the remaining budget. The loops already visit every reachable input, so one line in each closes the gap:

assert!(
    value <= remaining,
    "canonical value {value}ms must not extend past the remaining {remaining}ms budget"
);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in bfde031 — both enumeration loops now assert value <= remaining on every reachable input, with a comment tying the assertion to the mediator </body> hold bound it protects.

// (its backend name canonicalizes identically), so the duplicate is
// detected only after the second outbound send has already fired. Reject
// it at startup instead.
let mut seen = HashSet::new();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thinking — This rejects a provider listed twice in providers, but providers = ["x"], mediator = "x" still validates: the same provider is then called twice per auction — once in the bidding phase and again as the mediator. If a provider acting as its own mediator is never a legitimate configuration, the same startup validation should reject the overlap:

if let Some(mediator_name) = &self.config.mediator {
    if seen.contains(mediator_name.as_str()) {
        return Err(/* mediator also listed as a provider */);
    }
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed never legitimate before adding the guard: a mediator's own demand already flows through its mediation response (winning bids are extracted from the mediator's reply), so listing the same provider as both bidder and mediator only buys a duplicate outbound call. Startup validation now rejects the overlap in bfde031, with rejects_mediator_also_listed_as_provider covering it.

digest
.iter()
.take(SPEC_DIGEST_HEX_LEN / 2)
.map(|byte| format!("{byte:02x}"))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpickformat!("{byte:02x}") allocates a fresh String per byte before collecting, and this runs on every predict_name and ensure (twice per auction launch). Writing into one pre-sized buffer avoids the 16 small allocations:

let mut hex = String::with_capacity(SPEC_DIGEST_HEX_LEN);
for byte in digest.iter().take(SPEC_DIGEST_HEX_LEN / 2) {
    write!(hex, "{byte:02x}").expect("should write hex digit to string");
}
hex

(with use core::fmt::Write as _;)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bfde031 — hex digits are now written into one String::with_capacity(SPEC_DIGEST_HEX_LEN) buffer via core::fmt::Write.

- Reject a mediator that is also listed in [auction].providers at startup;
  the overlap would fire the same provider twice per auction and its demand
  already flows through the mediation response
- Assert in both cardinality enumeration tests that canonical values never
  exceed the remaining budget (the mediator </body> hold bound invariant)
- Document the accepted worst-case rounding haircut on the coarse ladder
- Write the spec digest hex into one pre-sized buffer instead of allocating
  per byte, and defer the ensure() doc to compute_name
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Refactor Fastly backend name to exlcude extraneous identifiers

3 participants