Files
JBD 864488c67d Replace Redis Streams queue and controller with direct execution and resource locking
Transition the AX architecture from asynchronous event queues (Redis Streams
and the `ax-controller` worker pool) to direct, synchronous reconciliation in
`ax-server` guarded by fine-grained distributed locks.

Key changes:
- Implement per-resource locking (`lock:task`, `lock:workspace`, `lock:model`)
  using Redis distributed locks (`SET NX PX` with token validation) and an
  in-memory locker for testing.
- Move Substrate reconciliation directly into `ax-server` for Task, Workspace,
  and Model lifecycle methods (`Create`, `Resume`, `Suspend`, `Delete`).
- Decommission `ax-controller`, `deploy/ax-controller.yaml`, and Redis Stream
  event publishing/consuming interfaces.
- Simplify `ax delete` and `ax watch` in the CLI to eliminate two-phase
  polling loops now that operations complete synchronously.
- Update `demo.sh`, build configurations (`Makefile`, `.ko.yaml`), and
  documentation to reflect the single-binary control plane architecture.
2026-09-27 15:23:17 -07:00

603 lines
18 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 server
import (
"context"
"errors"
"log/slog"
"net/http"
"strings"
"github.com/google/ax/internal/lock"
"github.com/google/ax/internal/store"
"github.com/google/ax/pkg/apis/v1alpha1"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
)
// Reconciler coordinates sandbox/actor lifecycles on Agent Substrate directly.
type Reconciler interface {
Reconcile(ctx context.Context, task *v1alpha1.Task, workspaces ...*v1alpha1.Workspace) (*v1alpha1.Task, error)
ReconcileDelete(ctx context.Context, atespace, taskName string) error
}
// Options configures the AX API Server.
type Options struct {
Locker lock.Locker
Reconciler Reconciler
}
// Server provides the gRPC API for AX.
type Server struct {
v1alpha1.UnimplementedAXServer
store store.Store
locker lock.Locker
reconciler Reconciler
grpcServer *grpc.Server
}
// NewServer creates a new AX API server.
func NewServer(s store.Store, opts ...Options) *Server {
var opt Options
if len(opts) > 0 {
opt = opts[0]
}
locker := opt.Locker
if locker == nil {
locker = lock.NewMemoryLocker()
}
srv := &Server{
store: s,
locker: locker,
reconciler: opt.Reconciler,
grpcServer: grpc.NewServer(),
}
v1alpha1.RegisterAXServer(srv.grpcServer, srv)
return srv
}
// GRPCServer returns the underlying gRPC server instance.
func (s *Server) GRPCServer() *grpc.Server {
return s.grpcServer
}
// Handler returns the HTTP handler for the server, routing gRPC and HTTP health checks.
func (s *Server) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ProtoMajor == 2 && strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc") {
s.grpcServer.ServeHTTP(w, r)
return
}
if r.URL.Path == "/healthz" {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok\n"))
return
}
http.NotFound(w, r)
})
}
// --- gRPC AXServer implementation ---
// --- Tasks ---
func (s *Server) GetTask(ctx context.Context, req *v1alpha1.GetTaskRequest) (*v1alpha1.Task, error) {
if req == nil {
return nil, status.Error(codes.InvalidArgument, "missing request")
}
atespace := req.Atespace
if atespace == "" {
atespace = "default"
}
task, err := s.store.GetTask(ctx, atespace, req.Name)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, status.Errorf(codes.NotFound, "task %q not found in atespace %q", req.Name, atespace)
}
return nil, status.Errorf(codes.Internal, "getting task: %v", err)
}
return task, nil
}
func (s *Server) ListTasks(ctx context.Context, req *v1alpha1.ListTasksRequest) (*v1alpha1.ListTasksResponse, error) {
atespace := ""
limit := int64(50)
offset := int64(0)
if req != nil {
atespace = req.Atespace
if req.Limit > 0 {
limit = req.Limit
}
if req.Offset >= 0 {
offset = req.Offset
}
}
tasks, err := s.store.ListTasks(ctx, atespace, limit, offset)
if err != nil {
return nil, status.Errorf(codes.Internal, "listing tasks: %v", err)
}
return &v1alpha1.ListTasksResponse{Tasks: tasks}, nil
}
func (s *Server) CreateTask(ctx context.Context, req *v1alpha1.CreateTaskRequest) (*v1alpha1.Task, error) {
if req == nil || req.Task == nil {
return nil, status.Error(codes.InvalidArgument, "task required")
}
task := req.Task
if err := v1alpha1.ValidateTask(task); err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
if task.Metadata == nil {
task.Metadata = &v1alpha1.ObjectMeta{}
}
atespace := task.Metadata.Atespace
if atespace == "" {
atespace = "default"
task.Metadata.Atespace = atespace
}
taskName := task.Metadata.GetName()
// Acquire exclusive lock for this task
unlock, err := s.locker.Lock(ctx, "task", atespace, taskName)
if err != nil {
return nil, status.Errorf(codes.Aborted, "locking task %s/%s: %v", atespace, taskName, err)
}
defer unlock()
_, err = s.store.GetTask(ctx, atespace, taskName)
if err == nil {
return nil, status.Errorf(codes.FailedPrecondition, "task %s/%s already exists and is immutable", atespace, taskName)
}
if !errors.Is(err, store.ErrNotFound) {
return nil, status.Errorf(codes.Internal, "checking existing task: %v", err)
}
if task.Metadata.CreationTimestamp == nil {
task.Metadata.CreationTimestamp = timestamppb.Now()
}
if task.Status == nil {
task.Status = &v1alpha1.TaskStatus{}
}
task.Status.Phase = "Suspended"
if err := s.store.SaveTask(ctx, task); err != nil {
return nil, status.Errorf(codes.Internal, "saving task: %v", err)
}
// Directly reconcile with Substrate
if s.reconciler != nil {
workspaces := s.fetchWorkspaces(ctx, atespace, task)
reconciled, err := s.reconciler.Reconcile(ctx, task, workspaces...)
if err != nil {
slog.Error("direct reconcile error on create task", "task", taskName, "error", err)
task.Status.Phase = "Failed"
_ = s.store.UpdateTaskStatus(ctx, atespace, taskName, task.Status)
return nil, status.Errorf(codes.Internal, "provisioning task on substrate: %v", err)
}
task.Status = reconciled.Status
if err := s.store.UpdateTaskStatus(ctx, atespace, taskName, task.Status); err != nil {
return nil, status.Errorf(codes.Internal, "updating task status: %v", err)
}
}
return task, nil
}
func (s *Server) DeleteTask(ctx context.Context, req *v1alpha1.DeleteTaskRequest) (*v1alpha1.DeleteTaskResponse, error) {
if req == nil {
return nil, status.Error(codes.InvalidArgument, "missing request")
}
atespace := req.Atespace
if atespace == "" {
atespace = "default"
}
taskName := req.Name
// Acquire exclusive lock for this task
unlock, err := s.locker.Lock(ctx, "task", atespace, taskName)
if err != nil {
return nil, status.Errorf(codes.Aborted, "locking task %s/%s: %v", atespace, taskName, err)
}
defer unlock()
task, err := s.store.GetTask(ctx, atespace, taskName)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, status.Errorf(codes.NotFound, "task %q not found in atespace %q", taskName, atespace)
}
return nil, status.Errorf(codes.Internal, "getting task: %v", err)
}
// Mark the task as terminating while actor cleanup is in progress.
if task.Status == nil {
task.Status = &v1alpha1.TaskStatus{}
}
task.Status.Phase = v1alpha1.PhaseTerminating
if err := s.store.UpdateTaskStatus(ctx, atespace, taskName, task.Status); err != nil {
return nil, status.Errorf(codes.Internal, "updating task status: %v", err)
}
// Directly clean up Substrate actor and templates
if s.reconciler != nil {
if err := s.reconciler.ReconcileDelete(ctx, atespace, taskName); err != nil {
if errors.Is(err, context.Canceled) {
return nil, status.Error(codes.Canceled, err.Error())
}
if errors.Is(err, context.DeadlineExceeded) {
return nil, status.Error(codes.DeadlineExceeded, err.Error())
}
if st, ok := status.FromError(err); ok && st.Code() != codes.Unknown {
return nil, err
}
return nil, status.Errorf(codes.Internal, "cleaning up substrate resources for task %s/%s: %v", atespace, taskName, err)
}
}
if err := s.store.DeleteTask(ctx, atespace, taskName); err != nil {
return nil, status.Errorf(codes.Internal, "deleting task record: %v", err)
}
return &v1alpha1.DeleteTaskResponse{}, nil
}
func (s *Server) SuspendTask(ctx context.Context, req *v1alpha1.SuspendTaskRequest) (*v1alpha1.Task, error) {
if req == nil {
return nil, status.Error(codes.InvalidArgument, "missing request")
}
atespace := req.Atespace
if atespace == "" {
atespace = "default"
}
taskName := req.Name
// Acquire exclusive lock for this task
unlock, err := s.locker.Lock(ctx, "task", atespace, taskName)
if err != nil {
return nil, status.Errorf(codes.Aborted, "locking task %s/%s: %v", atespace, taskName, err)
}
defer unlock()
task, err := s.store.GetTask(ctx, atespace, taskName)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, status.Errorf(codes.NotFound, "task %q not found in atespace %q", taskName, atespace)
}
return nil, status.Errorf(codes.Internal, "getting task: %v", err)
}
if task.Status == nil {
task.Status = &v1alpha1.TaskStatus{}
}
task.Status.Phase = "Suspended"
if s.reconciler != nil {
workspaces := s.fetchWorkspaces(ctx, atespace, task)
reconciled, err := s.reconciler.Reconcile(ctx, task, workspaces...)
if err != nil {
return nil, status.Errorf(codes.Internal, "suspending task on substrate: %v", err)
}
task.Status = reconciled.Status
if err := s.store.UpdateTaskStatus(ctx, atespace, taskName, task.Status); err != nil {
return nil, status.Errorf(codes.Internal, "updating task status: %v", err)
}
} else {
if err := s.store.SaveTask(ctx, task); err != nil {
return nil, status.Errorf(codes.Internal, "suspending task: %v", err)
}
}
return task, nil
}
func (s *Server) ResumeTask(ctx context.Context, req *v1alpha1.ResumeTaskRequest) (*v1alpha1.Task, error) {
if req == nil {
return nil, status.Error(codes.InvalidArgument, "missing request")
}
atespace := req.Atespace
if atespace == "" {
atespace = "default"
}
taskName := req.Name
// Acquire exclusive lock for this task
unlock, err := s.locker.Lock(ctx, "task", atespace, taskName)
if err != nil {
return nil, status.Errorf(codes.Aborted, "locking task %s/%s: %v", atespace, taskName, err)
}
defer unlock()
task, err := s.store.GetTask(ctx, atespace, taskName)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, status.Errorf(codes.NotFound, "task %q not found in atespace %q", taskName, atespace)
}
return nil, status.Errorf(codes.Internal, "getting task: %v", err)
}
if task.Status == nil {
task.Status = &v1alpha1.TaskStatus{}
}
task.Status.Phase = "Running"
if s.reconciler != nil {
workspaces := s.fetchWorkspaces(ctx, atespace, task)
reconciled, err := s.reconciler.Reconcile(ctx, task, workspaces...)
if err != nil {
task.Status.Phase = "Failed"
_ = s.store.UpdateTaskStatus(ctx, atespace, taskName, task.Status)
return nil, status.Errorf(codes.Internal, "resuming task on substrate: %v", err)
}
task.Status = reconciled.Status
if err := s.store.UpdateTaskStatus(ctx, atespace, taskName, task.Status); err != nil {
return nil, status.Errorf(codes.Internal, "updating task status: %v", err)
}
} else {
if err := s.store.SaveTask(ctx, task); err != nil {
return nil, status.Errorf(codes.Internal, "resuming task: %v", err)
}
}
return task, nil
}
func (s *Server) fetchWorkspaces(ctx context.Context, atespace string, task *v1alpha1.Task) []*v1alpha1.Workspace {
var workspaces []*v1alpha1.Workspace
if task.Spec == nil {
return workspaces
}
for _, ref := range task.Spec.WorkspaceRefs() {
if ref.Name == "" {
continue
}
if wsp, err := s.store.GetWorkspace(ctx, atespace, ref.Name); err == nil {
workspaces = append(workspaces, wsp)
}
}
return workspaces
}
func (s *Server) WatchTask(req *v1alpha1.WatchTaskRequest, stream grpc.ServerStreamingServer[v1alpha1.WatchTaskResponse]) error {
if req == nil {
return status.Error(codes.InvalidArgument, "missing request")
}
atespace := req.Atespace
if atespace == "" {
atespace = "default"
}
ctx := stream.Context()
ch, closer, err := s.store.WatchTask(ctx, atespace, req.Name)
if err != nil {
return status.Errorf(codes.Internal, "watching task: %v", err)
}
defer closer.Close()
if initial, err := s.store.GetTask(ctx, atespace, req.Name); err == nil {
if err := stream.Send(&v1alpha1.WatchTaskResponse{Task: initial, Action: "INITIAL"}); err != nil {
return err
}
}
for {
select {
case <-ctx.Done():
return ctx.Err()
case task, ok := <-ch:
if !ok {
return nil
}
if err := stream.Send(&v1alpha1.WatchTaskResponse{Task: task, Action: "MODIFIED"}); err != nil {
return err
}
if task.Status != nil && (task.Status.Phase == "Failed" || task.Status.Phase == "Completed") {
return nil
}
}
}
}
// --- Workspaces ---
func (s *Server) GetWorkspace(ctx context.Context, req *v1alpha1.GetWorkspaceRequest) (*v1alpha1.Workspace, error) {
if req == nil {
return nil, status.Error(codes.InvalidArgument, "missing request")
}
atespace := req.Atespace
if atespace == "" {
atespace = "default"
}
ws, err := s.store.GetWorkspace(ctx, atespace, req.Name)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, status.Errorf(codes.NotFound, "workspace %q not found in atespace %q", req.Name, atespace)
}
return nil, status.Errorf(codes.Internal, "getting workspace: %v", err)
}
return ws, nil
}
func (s *Server) ListWorkspaces(ctx context.Context, req *v1alpha1.ListWorkspacesRequest) (*v1alpha1.ListWorkspacesResponse, error) {
atespace := ""
if req != nil {
atespace = req.Atespace
}
workspaces, err := s.store.ListWorkspaces(ctx, atespace)
if err != nil {
return nil, status.Errorf(codes.Internal, "listing workspaces: %v", err)
}
return &v1alpha1.ListWorkspacesResponse{Workspaces: workspaces}, nil
}
func (s *Server) UpdateWorkspace(ctx context.Context, req *v1alpha1.UpdateWorkspaceRequest) (*v1alpha1.Workspace, error) {
if req == nil || req.Workspace == nil {
return nil, status.Error(codes.InvalidArgument, "workspace required")
}
if err := v1alpha1.ValidateWorkspace(req.Workspace); err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
req.Workspace.Metadata = defaultMetadata(req.Workspace.Metadata, func(atespace, name string) *v1alpha1.ObjectMeta {
existing, err := s.store.GetWorkspace(ctx, atespace, name)
if err != nil {
return nil
}
return existing.GetMetadata()
})
atespace := req.Workspace.Metadata.Atespace
wsName := req.Workspace.Metadata.Name
// Acquire exclusive lock for this workspace
unlock, err := s.locker.Lock(ctx, "workspace", atespace, wsName)
if err != nil {
return nil, status.Errorf(codes.Aborted, "locking workspace %s/%s: %v", atespace, wsName, err)
}
defer unlock()
if err := s.store.SaveWorkspace(ctx, req.Workspace); err != nil {
return nil, status.Errorf(codes.Internal, "saving workspace: %v", err)
}
return req.Workspace, nil
}
func (s *Server) DeleteWorkspace(ctx context.Context, req *v1alpha1.DeleteWorkspaceRequest) (*v1alpha1.DeleteWorkspaceResponse, error) {
if req == nil {
return nil, status.Error(codes.InvalidArgument, "missing request")
}
atespace := req.Atespace
if atespace == "" {
atespace = "default"
}
wsName := req.Name
// Acquire exclusive lock for this workspace
unlock, err := s.locker.Lock(ctx, "workspace", atespace, wsName)
if err != nil {
return nil, status.Errorf(codes.Aborted, "locking workspace %s/%s: %v", atespace, wsName, err)
}
defer unlock()
if err := s.store.DeleteWorkspace(ctx, atespace, wsName); err != nil {
return nil, status.Errorf(codes.Internal, "deleting workspace: %v", err)
}
return &v1alpha1.DeleteWorkspaceResponse{}, nil
}
// --- Models ---
func (s *Server) GetModel(ctx context.Context, req *v1alpha1.GetModelRequest) (*v1alpha1.Model, error) {
if req == nil {
return nil, status.Error(codes.InvalidArgument, "missing request")
}
atespace := req.Atespace
if atespace == "" {
atespace = "default"
}
model, err := s.store.GetModel(ctx, atespace, req.Name)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, status.Errorf(codes.NotFound, "model %q not found in atespace %q", req.Name, atespace)
}
return nil, status.Errorf(codes.Internal, "getting model: %v", err)
}
return model, nil
}
func (s *Server) ListModels(ctx context.Context, req *v1alpha1.ListModelsRequest) (*v1alpha1.ListModelsResponse, error) {
atespace := ""
if req != nil {
atespace = req.Atespace
}
models, err := s.store.ListModels(ctx, atespace)
if err != nil {
return nil, status.Errorf(codes.Internal, "listing models: %v", err)
}
return &v1alpha1.ListModelsResponse{Models: models}, nil
}
func (s *Server) UpdateModel(ctx context.Context, req *v1alpha1.UpdateModelRequest) (*v1alpha1.Model, error) {
if req == nil || req.Model == nil {
return nil, status.Error(codes.InvalidArgument, "model required")
}
if err := v1alpha1.ValidateModel(req.Model); err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
req.Model.Metadata = defaultMetadata(req.Model.Metadata, func(atespace, name string) *v1alpha1.ObjectMeta {
existing, err := s.store.GetModel(ctx, atespace, name)
if err != nil {
return nil
}
return existing.GetMetadata()
})
atespace := req.Model.Metadata.Atespace
modelName := req.Model.Metadata.Name
// Acquire exclusive lock for this model
unlock, err := s.locker.Lock(ctx, "model", atespace, modelName)
if err != nil {
return nil, status.Errorf(codes.Aborted, "locking model %s/%s: %v", atespace, modelName, err)
}
defer unlock()
if err := s.store.SaveModel(ctx, req.Model); err != nil {
return nil, status.Errorf(codes.Internal, "saving model: %v", err)
}
return req.Model, nil
}
func (s *Server) DeleteModel(ctx context.Context, req *v1alpha1.DeleteModelRequest) (*v1alpha1.DeleteModelResponse, error) {
if req == nil {
return nil, status.Error(codes.InvalidArgument, "missing request")
}
atespace := req.Atespace
if atespace == "" {
atespace = "default"
}
modelName := req.Name
// Acquire exclusive lock for this model
unlock, err := s.locker.Lock(ctx, "model", atespace, modelName)
if err != nil {
return nil, status.Errorf(codes.Aborted, "locking model %s/%s: %v", atespace, modelName, err)
}
defer unlock()
if err := s.store.DeleteModel(ctx, atespace, modelName); err != nil {
return nil, status.Errorf(codes.Internal, "deleting model: %v", err)
}
return &v1alpha1.DeleteModelResponse{}, nil
}
func defaultMetadata(meta *v1alpha1.ObjectMeta, existing func(atespace, name string) *v1alpha1.ObjectMeta) *v1alpha1.ObjectMeta {
if meta == nil {
meta = &v1alpha1.ObjectMeta{}
}
if meta.Atespace == "" {
meta.Atespace = "default"
}
if meta.CreationTimestamp == nil {
var prev *v1alpha1.ObjectMeta
if existing != nil {
prev = existing(meta.Atespace, meta.Name)
}
if prev.GetCreationTimestamp() != nil {
meta.CreationTimestamp = prev.GetCreationTimestamp()
} else {
meta.CreationTimestamp = timestamppb.Now()
}
}
return meta
}