Skip to content

demos: add Envoy Rust dynamic module ingress implementation - #1329

Draft
botengyao wants to merge 3 commits into
agent-substrate:mainfrom
botengyao:envoy-rust-dynamic-module
Draft

demos: add Envoy Rust dynamic module ingress implementation#1329
botengyao wants to merge 3 commits into
agent-substrate:mainfrom
botengyao:envoy-rust-dynamic-module

Conversation

@botengyao

@botengyao botengyao commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

The ingress path calls ResumeActor on ate-apiserver for every request, including requests to an actor already running on a known worker, since singleflight only collapses concurrent callers. This adds a runnable comparison of that path against an Envoy Rust dynamic module.

Arm A runs the real atenet router and its real xds.go over ADS, so the baseline is the actual system. Measured against it, a module that caches the actor to worker binding raises throughput 11.5x and cuts ResumeActor calls by 99.7%. Removing the ext_proc hop alone accounts for only ~12% of that: the win is not making the call, not where the call is made.

Arm C is the shape worth landing. The module sits in front of ext_proc as a pure cache, answering hits and letting every miss fall through to the Go handler untouched, then learning the binding from the metadata ext_proc published. The dataplane never talks to ate-apiserver, and removing the filter restores today's behaviour exactly. See DESIGN.md; ANALYSIS.md surveys the rest of the tree, egress above all.

#1330

What

Adds demos/envoy-rust-dynamic-module/: a self-contained, runnable comparison of the ingress routing path against an Envoy Rust dynamic module. Nothing is wired into the build, the manifests, or .ko.yaml — no production code path changes.

Why

ingress.Handler calls ResumeActor on ate-apiserver for every request, including requests to an actor that is already RUNNING on a known worker, since singleflight collapses only concurrent callers and never sequential ones. ingress/resumer.go:182 names the cost outright:

the accepted cost of one control-plane RPC per hot actor

For a warm working set of actors, that is a control-plane round trip per request that changes nothing. Envoy's ext_proc client has no way to avoid it: the filter has no response cache, and its only cache-shaped fields
(disable_clear_route_cache, route_cache_action) concern the route cache. A dynamic module is the supported place to put one.

Results

Envoy v1.39.1 (the pinned version), 50 hot actors, concurrency 8, 20s after warmup, load generated inside the container network. Reproduced twice, within 2%.

Arm A runs the real atenet router binary configured by the real xds.go over ADS, so the baseline is the actual system rather than an approximation.

Arm RPS p50 p95 ResumeActor calls
A — pure ext_proc → Go router (today) 3,735 2.11 ms 2.53 ms 87,231
B1 — rust module, cache off 4,170 1.89 ms 2.26 ms 105,423
B2 — rust module, cache on, ext_proc removed 44,817 0.16 ms 0.28 ms 361
C — rust module + ext_proc together 42,766 0.17 ms 0.30 ms 262

The split matters more than the headline. Removing the gRPC hop alone (A→B1) is worth +12%. Not making the call (B1→B2) is worth 12×. The cache is the win; Rust is the delivery mechanism.

Arm C reaches 96% of the full replacement while keeping ext_proc in the chain, so the dataplane never talks to ate-apiserver. CPU per request drops ~6×, and atenet-router's own CPU goes from ~47% to ~1%.

These are laptop-VM numbers against a stub control plane; the shape transfers, the absolute figures do not.

The shape worth landing

Arm C, described in demos/envoy-rust-dynamic-module/DESIGN.md: the module is a cache in front of ext_proc, not a replacement for it.

  • Hit — publishes the same original_dst dynamic metadata ext_proc would have, marks the request, and calls clear_route_cache() so route selection lands on a route carrying ExtProcPerRoute.disabled. Verified: 16 requests
    produced 1 ext_proc.streams_started.
  • Miss — does nothing. ext_proc runs exactly as today, with its resumer, singleflight, parking, error bodies and metrics intact. The module then learns the binding from the metadata ext_proc published.

So it never holds an ate-apiserver credential, needs no new API surface, and cannot route anywhere ext_proc has not already routed. Landing it would be three additive edits to xds.go behind one flag defaulting off, with the generated xDS byte-identical when disabled.

Status and limitations

This is a prototype for discussion, not a proposal to enable anything.

  • The cache is TTL-only. Before it could front traffic it needs eviction on atunnel's 421 + X-Ate-Assignment-Stale and on upstream reset, and — the important part — eviction must re-run the slow path within the same request,
    because a cache hit skips the ResumeActor that wakes a suspended actor.
  • X-Ate-Assignment-Stale is currently forgeable: atunnel's ReverseProxy has no ModifyResponse stripping it from actor responses. That needs fixing before anything trusts the header.
  • The module is not sandboxed; a panic takes Envoy down, where a Go router crash
    fails only ext_proc streams.
  • The demo skips upstream mTLS to atunnel and uses a stub ate-apiserver.
  • The SDK is pinned to the Envoy commit of the image it loads into. Bumping the
    Envoy image requires rebuilding the module.

Related: the same missing-cache shape exists on egress, where egress.go:168 calls GetActor per CONNECT with no cache and no singleflight — see the TODO at egress.go:167 referencing #592.

Testing

go build ./... and go test ./cmd/atenet/... pass. The demo itself is verified by running it: ./bench/build.sh && docker compose up -d && ./bench/run.sh.

The ingress path calls ResumeActor on ate-apiserver for every request,
including requests to an actor already running on a known worker, since
singleflight only collapses concurrent callers. This adds a runnable
comparison of that path against an Envoy Rust dynamic module.

Arm A runs the real atenet router and its real xds.go over ADS, so the
baseline is the actual system. Measured against it, a module that caches
the actor to worker binding raises throughput 11.5x and cuts ResumeActor
calls by 99.7%. Removing the ext_proc hop alone accounts for only ~12%
of that: the win is not making the call, not where the call is made.

Arm C is the shape worth landing. The module sits in front of ext_proc as
a pure cache, answering hits and letting every miss fall through to the Go
handler untouched, then learning the binding from the metadata ext_proc
published. The dataplane never talks to ate-apiserver, and removing the
filter restores today's behaviour exactly. See DESIGN.md; ANALYSIS.md
surveys the rest of the tree, egress above all.

Prototype only, not wired into any build or deploy.
authority(), parse_actor_ref() and route_to() are pure functions of the
parsed filter config, but they lived on the filter that resolves misses
with an HTTP callout. The co-existence filter had to construct a throwaway
instance of that filter per request just to borrow them, which reads as
though the cache mode calls ate-apiserver. It never does. Move them onto
Config so neither mode names the other, and drop a duplicated route_to and
a per-request config clone with them.

Also add rust-module/DESIGN.md: the thread model and why the cache is
process-global, the two filter modes and which one is meant for production,
the rule that anything unrecognized falls through to ext_proc, and what is
still missing before the cache can front traffic.
The findings worth keeping are already in README.md and DESIGN.md, each
carrying its own source references. The long-form survey was generated
material that would go stale against the tree without anyone noticing.
@botengyao

botengyao commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

I prefer Arm B2 so we can get rid of / simplify (if it is mixed) the ext_proc server totally, but need to let envoy store the credentials to ate-api-server, and then it is C. If cache is reliable, most of the gain will come from the cache, not the hop.

@botengyao

Copy link
Copy Markdown
Contributor Author

@EItanya

Copy link
Copy Markdown
Collaborator

Why is this PR "open" if it's a prototype? Given the limitations on cI runners we asked to please hold off until it's ready.

@LiorLieberman

Copy link
Copy Markdown
Collaborator

Why is this PR "open" if it's a prototype? Given the limitations on cI runners we asked to please hold off until it's ready.

AFAIY the workflows are not running until someone hit the button, no?

@EItanya

Copy link
Copy Markdown
Collaborator

Why is this PR "open" if it's a prototype? Given the limitations on cI runners we asked to please hold off until it's ready.

AFAIY the workflows are not running until someone hit the button, no?

In this case yes, but it's also open which is confusing, that signals that it's ready for review

@bowei
Bowei Du (bowei) marked this pull request as draft August 31, 2026 19:34
@bowei

Copy link
Copy Markdown
Collaborator

You can convert this to a draft if it's a PoC. I think the Github CI still gets triggered though...

@botengyao

Copy link
Copy Markdown
Contributor Author

CI needs approval, draft is fine, once we decide the final approach will make it a final PR.

@botengyao

Copy link
Copy Markdown
Contributor Author

Why is this PR "open" if it's a prototype? Given the limitations on cI runners we asked to please hold off until it's ready.

not aware of, good to know, thanks.

@botengyao botengyao changed the title demos: add Envoy Rust dynamic module ingress prototype demos: add Envoy Rust dynamic module ingress implementation Aug 31, 2026
@BenTheElder

Copy link
Copy Markdown
Collaborator

CI needs approval, draft is fine, once we decide the final approach will make it a final PR.

Only because you're not a repo collaborator yet. You can share a link to your branch without opening a PR FWIW.

If you intend for a review pass, that is the time for a draft or real PR.

Unfortunately there isn't any way to full disable actions for drafts. You can make it skip most of the logic early but it still spawns a workflow to evaluate the conditional.

license = "Apache-2.0"

[dependencies]
# The SDK revision MUST match the Envoy binary it is loaded into. This rev is the

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

It is not a MUST. I think the official support for ABI is two releases. In practice it had never been broken.

Having said that, this is definitely preferable to be kept in sync. Can dependabot do this?

if let Some(entry) = cache().get(&key) {
if entry.expires_at > Instant::now() {
let worker_ip = entry.worker_ip.clone();
drop(entry);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Why does drop needed here?

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.

6 participants