Skip to content

feat: Add request parking to the atenet router#221

Open
Omer Yahud (omeryahud) wants to merge 8 commits into
agent-substrate:mainfrom
omeryahud:worktree-request-parking
Open

feat: Add request parking to the atenet router#221
Omer Yahud (omeryahud) wants to merge 8 commits into
agent-substrate:mainfrom
omeryahud:worktree-request-parking

Conversation

@omeryahud

@omeryahud Omer Yahud (omeryahud) commented Jun 11, 2026

Copy link
Copy Markdown

When a request targets a suspended actor, the router resumes it via the control plane before routing. A momentarily saturated worker pool makes ResumeActor return FailedPrecondition ("no free workers available"), which the router previously turned straight into a 503. In an oversubscribed system that shortage might pass quickly as another actor suspends and frees its worker, so failing fast was wasteful.

Park such requests instead: retry the resume on FailedPrecondition until the actor becomes routable or a bounded wait elapses, capped by a fixed-capacity admission lot that sheds excess load. On budget expiry the underlying capacity error is surfaced so the HTTP boundary maps it faithfully. singleflight still collapses concurrent waiters for the same actor into one resume RPC. Parking can be disabled to preserve the legacy fail-fast behavior.

Fixes #27

  • Tests pass
  • Appropriate changes to documentation are included in the PR

@google-cla

google-cla Bot commented Jun 11, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@bowei Bowei Du (bowei) self-assigned this Jun 12, 2026
@omeryahud
Omer Yahud (omeryahud) marked this pull request as ready for review June 15, 2026 08:24
@omeryahud
Omer Yahud (omeryahud) marked this pull request as draft June 15, 2026 08:24
@omeryahud
Omer Yahud (omeryahud) marked this pull request as ready for review June 15, 2026 09:39

@thockin Tim Hockin (thockin) 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.

Took a quick spin through this, emphasis on quick.

Comment thread docs/request-parking.md
| ------------------------------------- | --------------------------------- |
| `OK` | Route to worker |
| `Aborted` (concurrent resume) | Retry (always) |
| `FailedPrecondition` (no free worker) | **Park & retry** (when enabled) |

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.

FailedPrecondition can mean multiple things, I think we should think more carefully about what error code we want for this specific case. NotFound? ResourceExhausted?

Comment thread docs/request-parking.md Outdated
| `DeadlineExceeded` | Fail fast → `504` |
| `PermissionDenied` / `Unauthenticated`| Fail fast → `403` / `401` |

When parking is **disabled** (`--parking-enabled=false`), the router preserves

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.

Is this different than just setting --parking-max-parked=0 ?

// legacyResumeBudget is the total time the resumer spends retrying a resume when
// request parking is disabled. It preserves the historical fail-fast-on-capacity
// behavior (only concurrent-update conflicts are retried).
const legacyResumeBudget = 15 * time.Second

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.

I know it's not the scope of this PR, but we should not be carrying legacy yet :)

Also, the use of "Aborted" for concurrency is suspect...

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.

+1

Choose a reason for hiding this comment

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

+1

Comment thread docs/request-parking.md
@@ -0,0 +1,96 @@
# Request Parking (atenet router)

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.

[aside] we should figure out where the small design doc snippets should be organized.

Tim Hockin (@thockin) Julian Gutierrez Oschmann (@juli4n) -- regarding general repo organization.

@bowei Bowei Du (bowei) 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.

Reviewing in parts. I just took a look at parking.go

Comment thread cmd/atenet/internal/router/parking.go Outdated
// Default request-parking parameters. See parkingConfig for the meaning of each
// field; these are also the flag defaults wired up in NewCmd.
const (
defaultParkingEnabled = true

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.

Do we need this flag if to max parked is set to 0?

Comment thread cmd/atenet/internal/router/parking.go Outdated
parkOutcomeServed = "served" // resume succeeded and the request was routed
parkOutcomeTimeout = "timeout" // the request's deadline elapsed while parked
parkOutcomeCanceled = "canceled" // the client disconnected while parked
parkOutcomeError = "error" // resume failed (including park-budget exhaustion)

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.

I think we should track budget exhaustion explicitly

Comment thread cmd/atenet/internal/router/parking.go Outdated
// failing the request immediately. maxParked bounds how many requests may be
// parked at once so the router sheds load rather than queueing without bound.
type parkingConfig struct {
enabled bool

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.

see comment on maxParked ==0

Comment thread cmd/atenet/internal/router/parking.go Outdated
// ok=false means the lot is full and the request should be shed without
// waiting. When parking is disabled every request is admitted and no slot
// accounting or metrics are recorded.
func (l *parkingLot) enter(ctx context.Context) (release func(outcome string), ok bool) {

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.

we should type the outcome as a go enum (string typed) instead of raw string

e.g.

type parkingLotOutcome string

Comment thread cmd/atenet/internal/router/parking.go Outdated
l.metrics.recordRejected(ctx)
return nil, false
}
if atomic.CompareAndSwapInt64(&l.active, cur, cur+1) {

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.

Can we use mutex based exclusion for now instead of raw atomics?

At some point under a lot of load, we may want to switch to atomic vars, but I would like to take that step when we understand that this is worth taking on the complexity. This is not a statement about correctness of the current code, but it does complicate any future extensions or adjustments to the semantics of the parking lot code.

Comment thread cmd/atenet/internal/router/parking.go Outdated
// waiting. When parking is disabled every request is admitted and no slot
// accounting or metrics are recorded.
func (l *parkingLot) enter(ctx context.Context) (release func(outcome string), ok bool) {
if l == nil || !l.cfg.enabled {

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.

This might be overkill --

You have a nil behavior and a cfg.enabled behavior.

Also see my comment about maxParked == 0 case.

Comment thread cmd/atenet/internal/router/parking.go Outdated
// parkOutcome classifies a completed resume attempt for the wait-duration
// metric. A budget-exhausted park surfaces the underlying capacity error and is
// reported as parkOutcomeError.
func parkOutcome(err error) string {

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.

make outcome typed..

@omeryahud

Copy link
Copy Markdown
Author

Thanks Bowei Du (@bowei) & Tim Hockin (@thockin) !
I'll address your comments and let you know once this is ready for another review

@bowei Bowei Du (bowei) 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.

Generally lgtm, we should be able to merge after the comments are addressed.

}

// enter attempts to reserve a parking slot. On success it returns a release
// func and ok=true; the caller MUST invoke release exactly once (passing the

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.

nit: I think since you use sync.Once, calling release() multiple times technically doesn't matter -- only the first instance does anything. To be clear, code that is sloppy and calls it more than once is probably a signal of other badness.

// legacyResumeBudget is the total time the resumer spends retrying a resume when
// request parking is disabled. It preserves the historical fail-fast-on-capacity
// behavior (only concurrent-update conflicts are retried).
const legacyResumeBudget = 15 * time.Second

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.

+1

// parkEnabled makes transient worker-pool saturation (FailedPrecondition)
// retryable, so a request is parked and retried until budget rather than
// failing immediately.
parkEnabled bool

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.

I'm thinking instead of having extra flag, user can disable if park max == 0? What do you think? This should do the same thing?

// ("no free workers available") becomes retryable and the resume is retried for
// up to maxWait; a non-positive maxWait keeps the default budget. When disabled,
// the resumer preserves its legacy fail-fast-on-capacity behavior.
func withParking(enabled bool, maxWait time.Duration) resumerOption {

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.

Given there is only one option right now, this is somewhat overkill. Are we planning on having more options?

// (NotFound, Unavailable, DeadlineExceeded, ...) are returned to the caller so
// the HTTP boundary can map them with full fidelity.
func (r *ActorResumer) retryable(err error) bool {
switch status.Code(err) {

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.

would prefer if you did this:

if ! r.parkEnabled { return false }

then we don't have to worry about maintaining this in the switch

Comment thread cmd/atenet/internal/router/router.go Outdated
// Request parking: hold and retry requests whose actor cannot be served
// immediately due to transient worker-pool saturation, instead of failing
// fast. A non-positive ParkingMaxParked disables parking. See parkingConfig.
ParkingMaxWait time.Duration

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.

I think you call this budget in other places. Do we want to align the names?

I suggest:

  • ParkedRequestBudget
  • ParkedRequestMax

Comment thread demos/parking/load.sh
@@ -0,0 +1,149 @@
#!/usr/bin/env bash

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.

I would prefer if this was written as an e2e test.

You can add the e2e in a follow up PR.


// parkingFullErr returns a 503 reqError signaling that the router's parking lot
// is at capacity, so the request was shed without waiting. Clients should retry.
func parkingFullErr(actorID string) error {

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.

Leave a TODO -- this will need to include the atespace and actorID is being renamed to actorName.


// parkingFullErr returns a 503 reqError signaling that the router's parking lot
// is at capacity, so the request was shed without waiting. Clients should retry.
func parkingFullErr(actorID string) error {

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.

Leave a TODO -- this will need to include the atespace and actorID is being renamed to actorName.

Comment thread cmd/atenet/internal/router/extproc.go Outdated
// backpressure instead of queueing without bound.
release, ok := s.parking.enter(ctx)
if !ok {
return nil, metadata, "", "", "", parkingFullErr(actorID)

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.

add a log here that the parklot is full

@omeryahud
Omer Yahud (omeryahud) force-pushed the worktree-request-parking branch 2 times, most recently from 6c325aa to 644edfa Compare July 21, 2026 22:17
Comment thread cmd/atenet/internal/router.go Outdated
cmd.Flags().StringVar(&cfg.AteapiServerName, "ateapi-server-name", "", "SNI / hostname expected on the ateapi server cert. Optional.")
cmd.Flags().StringVar(&cfg.AteapiTokenFile, "ateapi-token-file", ateapiauth.DefaultServiceAccountTokenFile, "Projected SA token file used as Bearer credential. Required for jwt.")
cmd.Flags().DurationVar(&cfg.ParkingMaxWait, "parking-max-wait", 30*time.Second, "Maximum time a request may be parked (held and retried) waiting for its actor to become routable")
cmd.Flags().IntVar(&cfg.ParkingMaxParked, "parking-max-parked", 2048, "Maximum number of requests that may be parked simultaneously; excess requests are shed with 503. 0 disables parking (requests fail fast on worker-pool saturation)")

Choose a reason for hiding this comment

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

👍🏾 park by default seems reasonable


slog.InfoContext(ctx, "ResumeActor", slog.String("atespace", atespace), slog.String("actor", actorName))
actor, err := s.resumer.ResumeActor(ctx, atespace, actorName)
release(parkOutcomeFor(err))

Choose a reason for hiding this comment

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

nit: feels like something that should be deferred so future early returns or blocking code don't leave requests forever parked

// A 1-slot lot with the slot already occupied deterministically simulates a
// full lot without needing a concurrent in-flight request.
s := NewExtProcServer(50051, clientMock, nil, parkingConfig{maxWait: time.Second, maxParked: 1}, nil)
occupy, ok := s.parking.enter(context.Background())

Choose a reason for hiding this comment

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

nit: isn't occupy a callback to free the slot? The naming is a bit confusing

// (labeled by outcome); parking.rejected counts requests shed because the
// parking lot was full.
parkingActiveMetricName = "atenet.router.parking.active"
parkingWaitMetricName = "atenet.router.parking.wait.duration"

Choose a reason for hiding this comment

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

The cardinality of this feels like it could be explosive if it's every request. Maybe a histogram across all requests might be more scalable?

Saw that this is what's implemented. Might be good to add a suffix (e.g _duration_ms) or something but looks good wrt scale

// Park-wait outcomes, recorded on the parking.wait.duration histogram.
const (
parkOutcomeServed parkOutcome = "served" // resume succeeded and the request was routed
parkOutcomeBudgetExhausted parkOutcome = "budget_exhausted" // the park budget elapsed while still blocked on a retryable condition

Choose a reason for hiding this comment

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

Might be good to have a metric for this as well, separate from rejected since that's only shed requests

// a non-positive maxParked disables parking entirely.
type parkingConfig struct {
maxWait time.Duration
maxParked int

Choose a reason for hiding this comment

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

Super nit: would uint be better?

return func(outcome parkOutcome) {
once.Do(func() {
l.mu.Lock()
l.active--

Choose a reason for hiding this comment

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

nit: maybe add a lower bound to ensure this doesn't go negative. I know that would only happen because of other bugs though

// legacyResumeBudget is the total time the resumer spends retrying a resume when
// request parking is disabled. It preserves the historical fail-fast-on-capacity
// behavior (only concurrent-update conflicts are retried).
const legacyResumeBudget = 15 * time.Second

Choose a reason for hiding this comment

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

+1

@omeryahud

Copy link
Copy Markdown
Author

Will be addressing comments next week (after k8s code freeze)

When a request targets a suspended actor, the router resumes it via the
control plane before routing. A momentarily saturated worker pool makes
ResumeActor return FailedPrecondition ("no free workers available"), which
the router previously turned straight into a 503. In an oversubscribed
system that shortage usually clears within milliseconds as another actor
suspends and frees its worker, so failing fast was wasteful.

Park such requests instead: retry the resume on FailedPrecondition (in
addition to the existing Aborted conflict) until the actor becomes routable
or a bounded wait elapses, capped by a fixed-capacity admission lot that
sheds excess load. On budget expiry the underlying capacity error is
surfaced so the HTTP boundary maps it faithfully. singleflight still
collapses concurrent waiters for the same actor into one resume RPC.
Parking can be disabled to preserve the legacy fail-fast behavior.

- parking.go: parkingLot (bounded, non-blocking admission gate) + config
- resumer.go: retryable predicate + configurable park budget
- extproc.go: admit each request to the lot around the resume call
- metrics.go: parking.active / wait.duration / rejected instruments
- errors.go: parkingFullErr (503 "router at capacity")
- router.go: --parking-enabled / --parking-max-wait / --parking-max-parked
- status.go + dashboard.html: Request Parking status card
- docs/request-parking.md: feature documentation
Replace the bare-string park-outcome constants with a parkOutcome string type and rename the classifier to parkOutcomeFor, so the parking.wait.duration outcome label is type-checked rather than an arbitrary string.
wait.Backoff zeroes its Steps once the per-attempt delay reaches Cap, so the resume retry loop gave up after ~7 steps (~5s) regardless of --parking-max-wait. Drop the Cap and use a gentle backoff (500ms x1.1, no Cap) so the budget context alone bounds the wait; the slow growth keeps the inter-retry gap small (~3.5s by 30s) on its own. Add a regression test asserting the backoff sets no Cap.
The ext_proc filter hard-coded MessageTimeout=5s, so Envoy abandoned a parked request (HTTP 500) long before the router's park budget elapsed. Make it configurable via SetExtProcMessageTimeout and set it to --parking-max-wait + margin when parking is enabled, so Envoy holds the request open until the router itself resolves or sheds it.
An oversubscribed WorkerPool (2 workers, several actors) that exercises the router parking path: requests to a saturated pool park and retry instead of failing fast, and are served once capacity frees up. Includes load.sh and --deploy-demo-parking / --delete-demo-parking wiring in hack/install-ate.sh.
Review feedback: plain mutex-based exclusion is easier to reason about and to extend than the atomic CAS loop, and the lot is touched only twice per request around a resume that takes orders of magnitude longer, so the atomics bought nothing measurable.
Review feedback (thockin, bowei): parking had three overlapping disabled states -- a nil lot, an enabled flag, and an (accidental) maxParked=0 shed-everything mode. Collapse them into one: --parking-max-parked=0 disables parking, the boolean flag and the nil-lot special case are gone, and a zero-capacity lot can no longer reject every request without attempting a resume.
Review feedback: folding park-budget exhaustion into the generic 'error' outcome hid the one signal operators need from this metric -- that requests waited the full budget and the pool never freed (capacity problem), as opposed to resumes failing outright (fault). The resumer now marks the surfaced capacity error with a wrapper that unwraps to the underlying gRPC status, so the HTTP mapping is unchanged and the wait-duration histogram gains a budget_exhausted outcome label.
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.

Router needs to park requests and wait for capacity

4 participants