Skip to content

feat: Add request parking to the atenet router - #221

Merged
Bowei Du (bowei) merged 21 commits into
agent-substrate:mainfrom
omeryahud:worktree-request-parking
Jul 29, 2026
Merged

feat: Add request parking to the atenet router#221
Bowei Du (bowei) merged 21 commits into
agent-substrate:mainfrom
omeryahud:worktree-request-parking

Conversation

@omeryahud

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

Copy link
Copy Markdown
Contributor

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 Outdated
| ------------------------------------- | --------------------------------- |
| `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 ?

Comment thread cmd/atenet/internal/router/resumer.go Outdated
// 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

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

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
Contributor 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.

Comment thread cmd/atenet/internal/router/resumer.go Outdated
// 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?

Comment thread cmd/atenet/internal/router/resumer.go Outdated
// ("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)")

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.

👍🏾 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))

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: 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())

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: isn't occupy a callback to free the slot? The naming is a bit confusing

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.

yes, this should be called release

// (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"

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.

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

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.

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

Comment thread cmd/atenet/internal/router/parking.go Outdated
// a non-positive maxParked disables parking entirely.
type parkingConfig struct {
maxWait time.Duration
maxParked int

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.

Super nit: would uint be better?

Comment thread cmd/atenet/internal/router/parking.go Outdated
return func(outcome parkOutcome) {
once.Do(func() {
l.mu.Lock()
l.active--

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: maybe add a lower bound to ensure this doesn't go negative. I know that would only happen because of other bugs though

Comment thread cmd/atenet/internal/router/resumer.go Outdated
// 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

@omeryahud

Copy link
Copy Markdown
Contributor Author

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

@omeryahud

Copy link
Copy Markdown
Contributor Author

Bowei Du (@bowei) Tim Hockin (@thockin) Keith Mattix II (@keithmattix)
Hi guys, this is ready for another review.

I've reworked it quite a bit, and still have a couple of minor things to flesh out, including an e2e test I want to add.

Please let me know what you think

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

just a few quick things

Comment thread cmd/atenet/internal/router/cmd.go Outdated
cmd.Flags().DurationVar(&cfg.ParkedRequestRetryInterval, "parked-request-retry-interval", defaultParkedRequestRetryInterval, "Delay before a parked request's first resume retry")
cmd.Flags().Float64Var(&cfg.ParkedRequestRetryFactor, "parked-request-retry-factor", defaultParkedRequestRetryFactor, "Multiplier applied to the retry delay after each attempt; must be >= 1")
cmd.Flags().Float64Var(&cfg.ParkedRequestRetryJitter, "parked-request-retry-jitter", defaultParkedRequestRetryJitter, "Random fraction in [0, 1) added to each retry delay to de-synchronize parked requests")
cmd.Flags().IntVar(&cfg.ExtProcMaxRequests, "extproc-max-requests", defaultExtProcMaxRequests, "Circuit-breaker max_requests for Envoy's ext_proc cluster; every parked request holds one slot for its full wait, so this must be >= --parked-request-max (the excess is fast-path headroom)")

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.

Does this make sense to calculate from parked-request-max vs as its own flag?

// the ext_proc cluster. Every parked request holds one slot for its entire
// wait, so this must be >= ParkedRequestMax (validated at startup); the
// excess is fast-path headroom for requests to already-running actors.
ExtProcMaxRequests int

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 statement above -- does this need to be separate from the ParkedRequestMax or we can do something that calculates it? Like set it to 120% of ParkedRequestMax?

Comment thread cmd/atenet/internal/router/config.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 ParkedRequestMax disables parking. See parkingConfig.
ParkedRequestBudget 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.

Let's put the parkedxxx flags together like this:

type ParkedRequestConfig struct {
  Budget
  Max
  RetryInterval
  ...
}

This will make the prefix in the code and easier to deal with.


// validate rejects flag combinations that would make the router misbehave
// rather than merely differ.
func (c routerConfig) validate() 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.

put the validate on the parkRequestConfig (see comment above)

// 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())

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.

yes, this should be called release

@omeryahud

Copy link
Copy Markdown
Contributor Author

Bowei Du (@bowei) Thanks for the comments! I've addressed them all, including e2e test

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.
- Adopt the ParkedRequest* vocabulary for parking flags and config (bowei's suggestion): --parked-request-budget / --parked-request-max, matching fields and default consts.
- Make the parked-retry backoff configurable: --parked-request-retry-interval/-factor/-jitter, validated at startup (factor >= 1, jitter in [0,1)); the backoff still has no cap and no attempt limit, so the budget alone bounds the wait.
- Resolve the effective parking config once in Run() so the resumer's retry loop and the Envoy ext_proc timeout always agree, even when the budget flag is set non-positive.
- Drop timeline-relative wording from docs and identifiers (failFastResumeBudget, fail-fast behavior).
- Guard the parking-lot counter against going negative, loudly.
- Document exactly when the wait-duration metric is recorded and what each outcome label means.
The budget-exhaustion wrap was gated on errors.Is(err, context.DeadlineExceeded), but when the park budget expires while a ResumeActor RPC is in flight, gRPC surfaces a *status* error with code DeadlineExceeded that does not match the context sentinel. The wrap was skipped and the client saw a generic 504 timeout (metric outcome 'error') instead of the intended 503 'no free workers available' (outcome 'budget_exhausted') — exactly the misreporting the wrapper exists to prevent, on the path that only appears when ateapi is slow, i.e. under the load parking targets. Gate on the budget context itself, which is the loop's only deadline source and covers both landing spots. The new regression test blocks the mock RPC until the budget cancels it and returns status.FromContextError, as a real gRPC client does.
Pins both halves of the resumer's detached-context design, which had no coverage: a caller that disconnects while parked receives context.Canceled (classified as the 'canceled' parking outcome) without aborting the shared in-flight resume, and a caller arriving after the disconnect is served by that same single RPC.
A parked request could fail on an ateapi blip despite having budget remaining: retryable() rejected Unavailable, so a control-plane rolling restart failed every in-flight parked request on the single most common transient condition — against the feature's purpose of riding out momentary conditions. Make Unavailable retryable while parking is enabled (the budget still bounds the wait); disabled mode keeps the fail-fast behavior. On budget exhaustion the wrapped Unavailable maps to 503 via the existing path.
The budget clock starts with a flight's first caller; requests de-duplicated onto an in-flight resume share its remaining budget and outcome, so a late joiner can see budget_exhausted after waiting far less than a full budget itself. That trade is inherent to collapsing a hot actor's requests into one control-plane RPC — state it explicitly in the design doc, the flight comment, and the flag help instead of implying a per-request guarantee. Also notes that wait-duration samples record each request's own parked time, so sub-budget budget_exhausted samples are expected under sustained saturation.
The ext_proc cluster sets no explicit circuit_breakers, so Envoy's default max_requests=1024 applies — and every parked request holds one ext_proc stream, i.e. one active request against that cluster. A 2048 lot was therefore half unreachable: requests 1025+ would be rejected by Envoy itself, with 503s that never reach the lot and never count in parking.rejected. Set the default to 1024 to match, and document the coupling at the constant, at buildCluster, and in the design doc, including what raising the flag beyond 1024 requires (an explicit circuit_breakers.max_requests on the cluster). Also drop Unavailable from the docs' non-retryable examples — it became retryable-while-parked in the previous commit.
…ed flag

Add --extproc-max-requests (default 2048) and set circuit_breakers.max_requests on the ext_proc cluster from it, replacing the implicit Envoy default and the hand-maintained doc coupling with a guarantee. Every request's header exchange occupies one slot briefly and every parked request holds one for its entire wait, so startup validation enforces extproc-max-requests >= parked-request-max — a breaker below the lot silently truncates it with Envoy-generated 503s that bypass parking.rejected. The default leaves the lot's worth of fast-path headroom (1024 lot / 2048 breaker), so a saturated lot cannot starve requests to already-running actors.
…tuses

mapResumeError collapsed two real cases into a generic 500: a park budget spent entirely on Aborted conflicts (the wrapped Aborted unwraps past budgetExhaustedError and hit the default arm), and a bare context sentinel from the caller's own context ending (status.Code classifies those Unknown). Add an Aborted arm — 503 with the gRPC description preserved, since 'another operation is in progress' is actionable and retryable — and explicit sentinel checks: Canceled maps to 408 (Envoy's StatusCode enum defines no 499; the stream is dead so the code is observability-only) and DeadlineExceeded to 504.

Writing the test exposed a related body regression: status.Convert on a wrapping error replaces the description with the wrapper's full 'rpc error: ...' string, so budget-exhausted 503 bodies had carried that prefix since the wrapper was introduced. The new statusDescription helper unwraps to the status first; both 503 arms use it, and a regression row pins the clean capacity body through the wrapper.
Envoy drops plain Value in ext_proc header mutations, so the content-type header on every immediate response — all 404/5xx error bodies this router generates — has been arriving with an empty value. Found live while verifying the parking demo (a new header set the same way came back empty; content-type turned out to have been silently broken all along). Use RawValue, matching how addAuthorityMutation already encodes the authority rewrite, and pin the encoding with a regression test.
Review feedback (bowei): the five ParkedRequest* fields on routerConfig become a single ParkedRequestConfig struct — the flags keep their shared prefix and the fields now travel together through the router config, the parking lot, and the resumer. This also collapses the internal parkingConfig into the same type (one config type instead of two mirrors), moves the parked-request validation onto the struct with routerConfig.validate delegating to it, and renames the lot-full test's 'occupy' variable to 'release' per the review thread.
…fault

Review feedback (bowei): rather than a fully independent flag, --extproc-max-requests now defaults to 0 = derive twice --parked-request-max, floored at Envoy's own default of 1024 — the lot always fits and keeps an equal share of fast-path headroom at any size, including a small or disabled lot. An absolute-percentage derivation like 120% under-provisions the fast path at small lots, which is why the headroom equals the lot instead. Explicit values still override and keep the >= lot validation, so operators who need a specific breaker retain control.
The follow-up bowei asked for in place of demos/parking/load.sh: exercise parking through the real Envoy → ext_proc → ateapi → worker path. A per-test 1-worker pool (runtime copied from the installed counter demo, uniquely labeled for scheduler isolation) is oversubscribed by two actors:

- ParkThenServed occupies the worker with actor A, requests suspended actor B (which parks), frees the worker only once the request is OBSERVABLY parked — a new StatuszClient reads the router's parking gauge over a status-port port-forward, so the synchronization point is state, not sleeps — and asserts B is served with the counter greeting inside the budget window, and that the slot is released.
- BudgetExhaustion reuses the resulting state (B holds the only worker), requests A with no relief, and asserts the router's own verdict: 503 with 'no free workers available' and text/plain, in a window whose lower bound proves parking happened and whose upper bound proves the router answered before Envoy's ext_proc timeout could.

Runs on the router's default parking configuration; flag-dependent scenarios (lot-full shed, parking disabled, custom budgets) deliberately remain unit tests because the shared router cannot be reconfigured per test. Verified live on a KinD cluster: served after 0.86s, budget exhausted at 5.006s.
Upstream now addresses actors with resources.ActorRef (2dc1fc6) and
formats client-facing error bodies as 'actor <atespace>/<name> ...'
(f83b65d). Update the parking tests' ResumeActor call sites and
expected message strings accordingly.
@bowei
Bowei Du (bowei) merged commit 6fc3467 into agent-substrate:main Jul 29, 2026
11 checks passed
Bowei Du (bowei) added a commit to bowei/agent-substrate that referenced this pull request Aug 6, 2026
Use a fake clock and synctext to avoid waiting in real-time in the
unit test.

Addresses review comments left over from agent-substrate#221.
haiyanmeng pushed a commit that referenced this pull request Aug 7, 2026
Use a fake clock and synctext to avoid waiting in real-time in the unit
test.

Addresses review comments left over from #221.
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