Files
substrate/cmd/atenet/internal/router/extproc.go
T
Julian Gutierrez Oschmann 2dc1fc6266 Introduce an ActorRef type to bundle actor atespace and name.
The pattern of passing a tuple of atespace and actor name together
is across the whole codebase. This simplifies both callers and callees
by leting them pass a single field that bundles both.

The ActorRef is the actor specific, typed,
in-process version of the ObjectRef we have in our gRPC API.
2026-07-28 12:33:23 -07:00

212 lines
6.8 KiB
Go

// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package router
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net"
"time"
extprocv3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3"
envoy_type "github.com/envoyproxy/go-control-plane/envoy/type/v3"
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/propagation"
"google.golang.org/grpc"
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
)
// ExtProcServer implements the Envoy external processing gRPC server
// to dynamically manage actor activations based on request traffic.
type ExtProcServer struct {
port int
apiClient ateapipb.ControlClient
recorder *QueryRecorder
resumer *ActorResumer
routeDuration metric.Float64Histogram
}
func NewExtProcServer(port int, apiClient ateapipb.ControlClient, routeDuration metric.Float64Histogram) *ExtProcServer {
return &ExtProcServer{
port: port,
apiClient: apiClient,
recorder: NewQueryRecorder(100),
resumer: NewActorResumer(apiClient),
routeDuration: routeDuration,
}
}
func (s *ExtProcServer) Serve(ctx context.Context, lis net.Listener) error {
grpcServer := grpc.NewServer(
grpc.StatsHandler(otelgrpc.NewServerHandler()),
)
extprocv3.RegisterExternalProcessorServer(grpcServer, s)
errChan := make(chan error, 1)
go func() {
if err := grpcServer.Serve(lis); err != nil {
errChan <- err
}
}()
select {
case <-ctx.Done():
grpcServer.GracefulStop()
return nil
case err := <-errChan:
return err
}
}
func (s *ExtProcServer) Process(stream extprocv3.ExternalProcessor_ProcessServer) error {
for {
req, err := stream.Recv()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
resp := &extprocv3.ProcessingResponse{}
switch reqType := req.Request.(type) {
case *extprocv3.ProcessingRequest_RequestHeaders:
start := time.Now()
hResponse, rqm, target, tmplNs, tmplName, err := s.handleRequestHeaders(stream.Context(), reqType.RequestHeaders)
elapsed := time.Since(start)
if err != nil {
slog.ErrorContext(stream.Context(), "Error during ext_proc RequestHeaders processing", slog.String("err", err.Error()))
var reqErr *reqError
if errors.As(err, &reqErr) {
resp = immediateResponse(envoy_type.StatusCode(reqErr.statusCode), reqErr.Error())
} else {
resp = immediateResponse(envoy_type.StatusCode_InternalServerError, err.Error())
}
s.recordRouteDuration(stream.Context(), elapsed, tmplNs, tmplName, classifyOutcome(err))
s.recorder.AddRouterRequest(start, elapsed, "Error", "-", rqm)
} else {
resp.Response = &extprocv3.ProcessingResponse_RequestHeaders{RequestHeaders: hResponse}
s.recordRouteDuration(stream.Context(), elapsed, tmplNs, tmplName, "ok")
s.recorder.AddRouterRequest(start, elapsed, "Route ok", target, rqm)
}
default:
// No modification for other processing states, but log because this should
// not be called.
slog.Error("Unexpected request type", slog.String("reqType", fmt.Sprintf("%T", reqType)))
resp.Response = &extprocv3.ProcessingResponse_RequestHeaders{
RequestHeaders: &extprocv3.HeadersResponse{
Response: &extprocv3.CommonResponse{},
},
}
}
if err := stream.Send(resp); err != nil {
return err
}
}
}
func (s *ExtProcServer) handleRequestHeaders(
ctx context.Context,
reqHeaders *extprocv3.HttpHeaders,
) (*extprocv3.HeadersResponse, *requestMetadata, string, string, string, error) {
metadata := newRequestMetadata(reqHeaders.Headers.GetHeaders())
slog.InfoContext(ctx, "Request", slog.String("host", metadata.host))
// Envoy doesn't propagate trace context into the ext_proc gRPC
// stream's metadata — the per-request traceparent arrives in the
// HTTP headers carried inside the ProcessingRequest payload. Extract
// from there so our span links to the Envoy ingress span.
ctx = otel.GetTextMapPropagator().Extract(ctx, propagation.MapCarrier(metadata.headers))
ctx, span := otel.Tracer(routerServiceName).Start(ctx, "ExtProc.RequestHeaders")
defer span.End()
actorRef, err := parseActorRef(metadata.host)
if err != nil {
// Host is invalid, respond with 404.
return nil, metadata, "", "", "", invalidHostErr(metadata.host, err)
}
slog.InfoContext(ctx, "ResumeActor", slog.Any("actor", actorRef))
actor, err := s.resumer.ResumeActor(ctx, actorRef)
if err != nil {
return nil, metadata, "", "", "", mapResumeError(actorRef.Name, err)
}
// Actor template identity, used as low-cardinality route-latency metric
// attributes (see recordRouteDuration).
tmplNs := actor.GetActorTemplateNamespace()
tmplName := actor.GetActorTemplateName()
workerIP := actor.GetAteomPodIp()
slog.InfoContext(ctx, "ResumeActor result",
slog.Any("actor", actorRef),
slog.String("status", actor.GetStatus().String()),
slog.String("workerIP", workerIP))
if ip := net.ParseIP(workerIP); ip == nil {
return nil, metadata, "", tmplNs, tmplName, newReqError(envoy_type.StatusCode_InternalServerError,
"actor %q routing failed", actorRef.Name)
}
// TODO(bowei) -- handle more than port 80 on the actor.
targetAddr := net.JoinHostPort(workerIP, "80")
slog.InfoContext(ctx, "Route ok", slog.Any("actor", actorRef), slog.String("targetAddr", targetAddr))
// Route by rewriting the :authority header.
mutation := &extprocv3.HeaderMutation{}
addAuthorityMutation(targetAddr, mutation)
return &extprocv3.HeadersResponse{
Response: &extprocv3.CommonResponse{
HeaderMutation: mutation,
},
}, metadata, targetAddr, tmplNs, tmplName, nil
}
func (s *ExtProcServer) recordRouteDuration(ctx context.Context, d time.Duration, tmplNs, tmplName, outcome string) {
if s.routeDuration == nil {
return
}
s.routeDuration.Record(ctx, d.Seconds(), metric.WithAttributes(
attribute.String("actor_template_namespace", tmplNs),
attribute.String("actor_template_name", tmplName),
attribute.String("outcome", outcome),
))
}
func classifyOutcome(err error) string {
switch {
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
return "cancelled"
default:
var re *reqError
if errors.As(err, &re) && re.statusCode == int(envoy_type.StatusCode_NotFound) {
return "not_found"
}
return "error"
}
}