Skip to content

feat(flowcontrol): support scoped request queue TTLs - #2649

Open
nt591 wants to merge 3 commits into
llm-d:mainfrom
nt591:nzt/scoped-ttl
Open

feat(flowcontrol): support scoped request queue TTLs#2649
nt591 wants to merge 3 commits into
llm-d:mainfrom
nt591:nzt/scoped-ttl

Conversation

@nt591

@nt591 nt591 commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

/kind feature

What this PR does / why we need it:
Allow operators to select queue-wait bounds per priority band and callers to shorten that bound through the inference TTL header.

My problem: I want to be able to buffer latency-insensitive work longer than latency-sensitive work, so I'd like to use higher priority bands with lower TTLs to serve interactive traffic, with low priority / higher TTL options for more async workflows.

Side fix: commit e0db7a7 addresses what looks like a potential regression in #2284, where I THINK a request can pass controller submission, get buffered, expire, but the processor doesn't double check expiry time. If that's by design, I can back out my change.

Which issue(s) this PR fixes:
Fixes #2316

Release note (write NONE if no user-facing change):

Introduce `defaultRequestTTL` flow control band configuration. Allows each priority band to define their own TTLs, defaulting to the global option if unset.

Allow operators to select queue-wait bounds per priority band and callers to shorten that bound through the inference TTL header.

Signed-off-by: Nikhil Thomas <nikhil.thomas@mistral.ai>
Account for enqueue-channel residence in the active queue-wait budget so expired requests cannot dispatch.

Signed-off-by: Nikhil Thomas <nikhil.thomas@mistral.ai>
@nt591
nt591 requested review from ahg-g and vMaroon September 1, 2026 17:27
@nt591 nt591 changed the title Nzt/scoped ttl feat(flowcontrol): support scoped request queue TTLs Sep 1, 2026
@github-actions github-actions Bot added size/XL Denotes a PR that changes 500-999 lines, ignoring generated files. kind/feature Categorizes issue or PR as related to a new feature. area/epp area/scheduling area/flowcontrol and removed kind/feature Categorizes issue or PR as related to a new feature. labels Sep 1, 2026
@ahg-g

ahg-g commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

/assign @LukeAVanDrie

if isExpired(item, p.clock.Now(), regime, p.noEndpointRequestTTL) {
p.finalizeAndRecordDrop(item, expiryError(regime.empty))
return
}

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.

was this a bug?

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.

sorry, you mention this already in the issue description.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yes, I understand the code it's a latent bug that probably only matters under very tight TTLs

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

+1; thanks for closing this!

// DefaultRequestTTL returns the queue-wait bound configured for the leased priority band and whether it was set.
func (c *connection) DefaultRequestTTL() (time.Duration, bool) {
c.registry.mu.RLock()
defer c.registry.mu.RUnlock()

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.

accessing the lock of another entity here is error prone, I think we should put the part that requires locking in a function implemented by the registry, and we call the func here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

addressed in 763f151, thanks

Addresses a concern that accessing a lock for another entity would be error prone.

Signed-off-by: Nikhil Thomas <nikhil.thomas@mistral.ai>

@LukeAVanDrie LukeAVanDrie left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for driving this!

Both new scopes only narrow the saturation-regime budget. isExpired switches to the global noEndpointRequestTTL whenever the pool is empty, and the context backstop is max(saturationTTL, noEndpointRequestTTL), so a request carrying x-llm-d-inference-ttl: 2s during a scale-from-zero still waits up to noEndpointRequestTTL.

That is consistent for the band value, since it overrides defaultRequestTTL which has the same scope. For the request header it is less obvious: the header expresses caller intent, which does not depend on the regime. I do not think this PR needs to change behavior, but the apix field doc and README should say what the scope is.

Also, the release notes only mentions the band-level defaultRequestTTL. The x-llm-d-inference-ttl header is user-facing too, so let's include it there.

MaxRequests *resource.Quantity `json:"maxRequests,omitempty"`

// +optional
// DefaultRequestTTL bounds how long a request may wait in this priority band before it is evicted.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This reads as the whole queue-wait bound, but it only replaces DefaultRequestTTL, which applies while the pool has endpoints. NoEndpointRequestTTL stays global and still governs the empty-pool regime for every band, and "0s" here does not make waiting unbounded while the pool is empty. Suggest mirroring the scope language the global field uses:

Suggested change
// DefaultRequestTTL bounds how long a request may wait in this priority band before it is evicted.
// DefaultRequestTTL replaces the global DefaultRequestTTL for this priority band: the queue-wait bound
// while the candidate pool has endpoints. NoEndpointRequestTTL is not band-scoped and still governs
// queue wait while the pool is empty. If omitted, the global DefaultRequestTTL is used; "0s" disables
// eviction in this band while the pool has endpoints.

request is shed. Keep it under the client or gateway deadline, and size it to the time-to-first-token
budget you are willing to spend waiting on a saturated pool.
budget you are willing to spend waiting on a saturated pool. Priority-band entries and templates
may replace the global value, including with `0s` for unbounded queue wait. Clients may shorten the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same scope point for the operator-facing doc. The header shortens the saturation budget only; a client asking for 2s during a scale-from-zero still waits up to noEndpointRequestTTL. Worth a sentence so nobody reads the header as a hard queue deadline.

E.g.,

Clients may shorten the selected operator bound with x-llm-d-inference-ttl using Go duration syntax. Both scopes narrow the saturation budget only; noEndpointRequestTTL stays global and is not shortened by the header.

) error {
reqID := item.OriginalRequest().ID()
select {
case <-ctx.Done():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit (non-blocking): The reason this early exit exists is not obvious from the code: Submit is non-blocking and would otherwise hand an already-expired item to the processor.

	// Submit does not observe ctx, so an item whose budget expired during lease acquisition must be
	// rejected here rather than handed to the processor.
	select {
	case <-ctx.Done():
...

initialEffectiveTTL := time.Duration(0)
if rawTTL, ok := metadata.GetLowerCaseHeaderValue(reqCtx.Request.Headers, metadata.InferenceTTLHeaderKey); ok {
parsedTTL, err := time.ParseDuration(strings.TrimSpace(rawTTL))
if err == nil && parsedTTL > 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit (non-blocking): A malformed or non-positive value is dropped with no signal, so an operator debugging why their TTL header did nothing has nowhere to look. The predicted-latency plugin logs its unparseable SLO headers at DEBUG and continues; same pattern would fit here:

		if err == nil && parsedTTL > 0 {
			initialEffectiveTTL = parsedTTL
		} else {
			logger.V(logutil.DEBUG).Info("Ignoring invalid request TTL header",
				"requestID", reqCtx.SchedulingRequest.RequestID, "value", rawTTL, "err", err)
		}

@LukeAVanDrie LukeAVanDrie left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved with some small doc / logging nits; thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/epp area/flowcontrol area/scheduling kind/feature Categorizes issue or PR as related to a new feature. size/XL Denotes a PR that changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Flow-control]: Support per-priority-band and per-request flow control queue TTL'

3 participants