Skip to content

We should hold onto requests if there are too many hammering a sandbox - #30

Open
udnay wants to merge 1 commit into
mainfrom
hold-connections-if-too-many-in-flight-connections
Open

udnay wants to merge 1 commit into
mainfrom
hold-connections-if-too-many-in-flight-connections

Conversation

@udnay

@udnay udnay commented Nov 12, 2025

Copy link
Copy Markdown
Contributor

This enables skipper to track how many requests each instance has in-flight as it hearbeats.

  • If some instances are below the in-flight max then we choose one at random.
  • If all instances are past the limit then it will hold onto the request to allow the instance to work off some of the in-flight requests.
  • After a max backoff we dispatch the request anyway

Note

Adds per-instance in-flight tracking and throttled, least-busy instance selection (with short backoff), plus tenant-labeled metrics and related test coverage.

  • Controller:
    • Instance selection: handleInstance now throttles when all instances exceed TargetInFlightRequests (short backoff) and picks the least-busy acceptable instance; scales if none ready.
    • Load awareness: aggregates per-instance load from router heartbeats via RouterHeartbeats.Combined() and inFlightPerInstance.
    • Scale activity: tracks recent scale actions (scaleActivity, scaleActivityWindow) to gate throttling decisions.
  • Router:
    • Heartbeat payloads: include InFlightPerInstance; request forwarding wraps increments/decrements of per-instance and total in-flight counts.
    • Refactors forwarding via forward, incrementInFlight, decrementInFlight.
  • Metrics/Telemetry:
    • Add tenant label to controller/router metrics (waiting_for_unassigned_pods, assignments_total, scale_ups_total, scale_downs_total, heartbeats_total, requests_total, requests_in_flight).
  • Tests:
    • Add tests for throttling, least-busy selection, and per-instance aggregation in server_test.go and router_test.go.

Written by Cursor Bugbot for commit 1d47d37. This will update automatically on new commits. Configure here.

@udnay

udnay commented Nov 12, 2025

Copy link
Copy Markdown
Contributor Author

This change is part of the following stack:

Change managed by git-spice.

Comment thread internal/router/router.go
r.incrementInFlight(fn, instance)
defer r.decrementInFlight(fn, instance)
return r.roundTripper.RoundTrip(req)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Retries Inflate In-Flight Request Counters

The forward method increments and decrements in-flight counters on each call, but it's invoked inside the retry loop in RoundTrip. When a request retries, the counters are incremented and decremented multiple times for the same logical request, causing incorrect in-flight metrics. The counters should be managed at the ServeHTTP level (once per logical request) rather than per retry attempt.

Fix in Cursor Fix in Web

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.

I think the location is fine, when we do an attempt we count it as in-flight. We aren't double counting because if that call fails we decrement the in-flight requests.

@udnay
udnay force-pushed the hold-connections-if-too-many-in-flight-connections branch from a9699be to dc9f046 Compare November 12, 2025 22:02
"go.opentelemetry.io/otel/attribute"
)

const (

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.

These numbers were all picked at random

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.

@udnay
udnay requested review from airhorns and scott-rc November 12, 2025 22:04
Comment thread internal/controller/scale.go Outdated
}
}
if len(combined.InFlightPerInstance) == 0 {
combined.InFlightPerInstance = nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Heartbeat Timeout Breaks Scaling Decisions.

When all router heartbeats for a function have been garbage collected due to timeout, Combined returns a heartbeat with an empty Function field (zero value). This causes calculateDesiredInstances to use zero values for scale parameters like TargetInFlightRequests, MinInstances, and MaxInstances, leading to incorrect scaling decisions. The function should preserve the Function field from one of the heartbeats or handle the empty map case differently.

Fix in Cursor Fix in Web

… going to any single sandbox and we can/want to scale up
@udnay
udnay force-pushed the hold-connections-if-too-many-in-flight-connections branch from dc9f046 to 1d47d37 Compare November 13, 2025 00:36
@airhorns

airhorns commented Nov 14, 2025

Copy link
Copy Markdown
Contributor

I fear that using the heartbeats as a way to track the inflight count will be too slow. We don't heartbeat super often (5s) and the inflight count changes super frequently such that the controller is always going to have a somewhat out of date view of the inflight count. I think the result is that pods can still get hammered for 5s.

What do you think about instead making the reloader-proxy enforce it server side within the pod, and reply with a 503 i can't serve this request right now, and have the router retry the other pods, and then if there are no pods left to try, ask the controller to scale up?

@scott-rc scott-rc 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.

I left some comments, but the approach overall looks good 👍

Name: "waiting_for_unassigned_pods",
Help: "The number of functions that are waiting for an unassigned pod",
}, []string{"function_deployment"})
}, []string{"function_deployment", "function_tenant"})

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.

How do feel about moving these function_tenant label additions to a separate PR?

I avoided adding the function_tenant label in the first place because it's technically unbounded, we can have an infinite number of tenants, and prometheus advises against using labels with unbounded values:

Image

Comment on lines +760 to 762
if len(combined.InFlightPerInstance) == 0 {
combined.InFlightPerInstance = map[string]int{}
}

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: This is redundant.

Suggested change
if len(combined.InFlightPerInstance) == 0 {
combined.InFlightPerInstance = map[string]int{}
}

Comment on lines +116 to 119
if len(instances) == 0 {
http.Error(rw, "no ready instances", http.StatusServiceUnavailable)
return
}

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: Let's log the error before returning it so we can easily track when this happens in Axiom.

Suggested change
if len(instances) == 0 {
http.Error(rw, "no ready instances", http.StatusServiceUnavailable)
return
}
if len(instances) == 0 {
log.Error(ctx, "no ready instances")
http.Error(rw, "no ready instances", http.StatusServiceUnavailable)
return
}

Comment on lines +136 to +145
combined := routerHeartbeats.Combined()
if len(combined.InFlightPerInstance) == 0 {
return map[string]int{}
}

loads := make(map[string]int, len(combined.InFlightPerInstance))
for instance, count := range combined.InFlightPerInstance {
loads[instance] = count
}
return loads

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: simpler to just do this

Suggested change
combined := routerHeartbeats.Combined()
if len(combined.InFlightPerInstance) == 0 {
return map[string]int{}
}
loads := make(map[string]int, len(combined.InFlightPerInstance))
for instance, count := range combined.InFlightPerInstance {
loads[instance] = count
}
return loads
return maps.Clone(routerHeartbeats.Combined().InFlightPerInstance)

Comment on lines +167 to +169
if !ctrl.isRecentlyScaling(fn) {
return false
}

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.

I would remove this ctrl.isRecentlyScaling check and all its dependencies imo.

  1. ctrl.markScaleActivity is only called on the controller that is responsible for the function in the hashring, so this could return false when it's really true.
  2. We know all the existing instances are above their threshold and giving them a moment to complete some requests should help them recover regardless of whether we just scaled or not

"go.opentelemetry.io/otel/attribute"
)

const (

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.

@scott-rc

Copy link
Copy Markdown
Contributor

I fear that using the heartbeats as a way to track the inflight count will be too slow. We don't heartbeat super often (5s) and the inflight count changes super frequently such that the controller is always going to have a somewhat out of date view of the inflight count. I think the result is that pods can still get hammered for 5s.

This is true, but we can lower the heartbeat interval to 1s and see if that adds any issues -- I have a feeling it won't.

If it does cause issues, I can try and get this gRPC implementation out which should make request overhead even smaller 😅

@airhorns

Copy link
Copy Markdown
Contributor

I still think that a 1s window is a long enough time for the inflight count to change pretty drastically. Scott how do you feel about the "make the sandbox feedback to any caller that there are too many open connections, and have the callers retry a different sandbox" approach instead?

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.

3 participants