mirror of
https://github.com/agent-substrate/substrate.git
synced 2026-10-02 03:24:42 +08:00
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.
212 lines
6.8 KiB
Go
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"
|
|
}
|
|
}
|