mirror of
https://github.com/agent-substrate/substrate.git
synced 2026-11-10 10:13:50 +08:00
Fixes #1882 When a worker pod's ateom container restarts, every sandbox it was hosting is lost, but the control plane kept reporting those Actors as running. This change: - **Tracks ateom restarts:** each Worker gets an `epoch`, set by the syncer from the ateom container's restart count. It can only increase. - **Stamps each binding:** every Actor assignment records the Worker's epoch at bind time. - **Crashes stale Actors:** a new reconciler in ateapi watches for Workers whose epoch has risen past `status.observed_epoch`. It crashes the Actors bound during earlier epochs, releases their assignments, then advances `observed_epoch`. [ x ] Tests pass [ x ] Appropriate changes to documentation are included in the PR
325 lines
14 KiB
Go
325 lines
14 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 controlapi
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/agent-substrate/substrate/cmd/ateapi/internal/apivalidation"
|
|
"github.com/agent-substrate/substrate/cmd/ateapi/internal/defaults"
|
|
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store"
|
|
"github.com/agent-substrate/substrate/internal/resources"
|
|
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/proto"
|
|
"k8s.io/apimachinery/pkg/util/validation/field"
|
|
)
|
|
|
|
// ListWorkerActorAssignments lists the Actors a Worker hosts. The assignments are a
|
|
// subresource rather than a field on Worker, so this is the only way to read
|
|
// them and neither GetWorker nor ListWorkers grows with occupancy.
|
|
func (s *RPCService) ListWorkerActorAssignments(ctx context.Context, req *ateapipb.ListWorkerActorAssignmentsRequest) (*ateapipb.ListWorkerActorAssignmentsResponse, error) {
|
|
if errs := apivalidation.ValidateListWorkerActorAssignmentsRequest(ctx, req); len(errs) > 0 {
|
|
return nil, resources.ToGRPCStatusError(errs)
|
|
}
|
|
name := req.GetWorker().GetName()
|
|
|
|
// The Worker is read first so a listing against one that does not exist is
|
|
// NOT_FOUND rather than an empty page, which a caller cannot tell from a
|
|
// Worker hosting nothing.
|
|
if _, err := s.impl.GetWorker(ctx, name); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return nil, status.Errorf(codes.NotFound, "Worker %s not found", name)
|
|
}
|
|
return nil, fmt.Errorf("while fetching worker %s: %w", name, err)
|
|
}
|
|
|
|
page, err := s.impl.ListWorkerAssignments(ctx, name,
|
|
store.ListOptions{PageSize: effectivePageSize(req.GetPageSize()), PageToken: req.GetPageToken()})
|
|
if err != nil {
|
|
return nil, mapListError(fmt.Errorf("while listing the assignments of worker %s: %w", name, err))
|
|
}
|
|
return &ateapipb.ListWorkerActorAssignmentsResponse{
|
|
ActorAssignments: page.Items,
|
|
NextPageToken: page.NextPageToken,
|
|
}, nil
|
|
}
|
|
|
|
func (s *RPCService) ListWorkers(ctx context.Context, req *ateapipb.ListWorkersRequest) (*ateapipb.ListWorkersResponse, error) {
|
|
if errs := apivalidation.ValidateListWorkersRequest(ctx, req); len(errs) > 0 {
|
|
return nil, resources.ToGRPCStatusError(errs)
|
|
}
|
|
|
|
page, err := s.impl.ListWorkers(ctx, store.ListOptions{PageSize: effectivePageSize(req.GetPageSize()), PageToken: req.GetPageToken()})
|
|
if err != nil {
|
|
return nil, mapListError(fmt.Errorf("while listing workers in db: %w", err))
|
|
}
|
|
return &ateapipb.ListWorkersResponse{
|
|
Workers: page.Items,
|
|
NextPageToken: page.NextPageToken,
|
|
}, nil
|
|
}
|
|
|
|
func (s *ServiceImpl) ListWorkers(ctx context.Context, opts store.ListOptions) (store.ListResponse[*ateapipb.Worker], error) {
|
|
return s.store.ListWorkers(ctx, opts)
|
|
}
|
|
|
|
func (s *RPCService) GetWorker(ctx context.Context, req *ateapipb.GetWorkerRequest) (*ateapipb.Worker, error) {
|
|
if errs := apivalidation.ValidateGetWorkerRequest(ctx, req); len(errs) > 0 {
|
|
return nil, resources.ToGRPCStatusError(errs)
|
|
}
|
|
name := req.GetWorker().GetName()
|
|
|
|
worker, err := s.impl.GetWorker(ctx, name)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return nil, status.Errorf(codes.NotFound, "Worker %s not found", name)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("while getting worker: %w", err)
|
|
}
|
|
return worker, nil
|
|
}
|
|
|
|
// GetWorker returns the Worker with the Actors it hosts, read separately since
|
|
// the assignments are their own records. The one read that pays O(assignments).
|
|
// A failed read is an error, not a Worker reported as hosting nothing: the
|
|
// caller cannot tell those apart.
|
|
func (s *ServiceImpl) GetWorker(ctx context.Context, name string) (*ateapipb.Worker, error) {
|
|
return s.store.GetWorker(ctx, name)
|
|
}
|
|
|
|
func (s *RPCService) CreateWorker(ctx context.Context, req *ateapipb.CreateWorkerRequest) (*ateapipb.Worker, error) {
|
|
// First scrub any fields that callers are not allowed to set. status is
|
|
// output-only, so whatever the request carried there is replaced rather
|
|
// than rejected.
|
|
inWorker := req.Worker
|
|
if inWorker != nil { // otherwise validation will flag it
|
|
scrubResourceMetadataForCreate(inWorker.Metadata)
|
|
inWorker.Status = nil
|
|
defaults.Apply(inWorker)
|
|
}
|
|
|
|
// Validate the request, including the object within it.
|
|
if errs := apivalidation.ValidateCreateWorkerRequest(ctx, req); len(errs) > 0 {
|
|
return nil, resources.ToGRPCStatusError(errs)
|
|
}
|
|
|
|
// Handle the creation, including validation of the final stored object.
|
|
return s.impl.CreateWorker(ctx, inWorker)
|
|
}
|
|
|
|
func (s *ServiceImpl) CreateWorker(ctx context.Context, inWorker *ateapipb.Worker) (*ateapipb.Worker, error) {
|
|
// A Worker is registered only once its pod is Ready and has an IP, which
|
|
// makes ACTIVE the only state it can be born in.
|
|
outWorker := proto.CloneOf(inWorker)
|
|
// A new Worker hosts no Actors, so none are left from an earlier epoch.
|
|
outWorker.Status = &ateapipb.WorkerStatus{
|
|
State: ateapipb.WorkerState_WORKER_STATE_ACTIVE,
|
|
ObservedEpoch: inWorker.GetEpoch(),
|
|
}
|
|
|
|
// Capacity is left unset: a Worker holds nothing until its own ateom says
|
|
// what it has, through WorkerService.SetWorkerCapacity. Nothing is placed
|
|
// on it in the meantime, which is the point -- the alternative is guessing
|
|
// on the Worker's behalf and placing against the guess.
|
|
|
|
// Verify that the result is properly valid before storing it.
|
|
if errs := apivalidation.ValidateWorkerUpdate(ctx, field.NewPath("worker"), outWorker, inWorker, true); len(errs) > 0 {
|
|
return nil, toGRPCInternalError(errs)
|
|
}
|
|
|
|
// Save the data in the storage layer.
|
|
created, err := s.store.CreateWorker(ctx, outWorker)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrAlreadyExists) {
|
|
return nil, status.Errorf(codes.AlreadyExists, "Worker %s already exists", inWorker.GetMetadata().GetName())
|
|
}
|
|
return nil, fmt.Errorf("while creating worker: %w", err)
|
|
}
|
|
return created, nil
|
|
}
|
|
|
|
// UpdateWorker replaces the stored Worker with the one the request carries.
|
|
// Only labels and epoch are the caller's to change; a request that alters an
|
|
// immutable field — including by leaving it unset, which would clear it — is
|
|
// rejected.
|
|
// The service layer enforces that with declarative validation against the
|
|
// stored worker inside the update transaction.
|
|
func (s *RPCService) UpdateWorker(ctx context.Context, req *ateapipb.UpdateWorkerRequest) (*ateapipb.Worker, error) {
|
|
// First scrub any fields that callers are not allowed to set.
|
|
inWorker := req.Worker
|
|
if inWorker != nil { // otherwise validation will flag it
|
|
scrubResourceMetadataForUpdate(inWorker.Metadata)
|
|
inWorker.Status = nil
|
|
}
|
|
|
|
// Validate the request.
|
|
if errs := apivalidation.ValidateUpdateWorkerRequest(ctx, req); len(errs) > 0 {
|
|
return nil, resources.ToGRPCStatusError(errs)
|
|
}
|
|
|
|
return s.mutateWorker(ctx, inWorker.GetMetadata().GetName(), store.PreconditionFrom(inWorker), func(toUpdate *ateapipb.Worker) error {
|
|
// Status and metadata are server-owned fields.
|
|
status, metadata := toUpdate.GetStatus(), toUpdate.GetMetadata()
|
|
// Reset + merge from the input worker.
|
|
proto.Reset(toUpdate)
|
|
proto.Merge(toUpdate, inWorker)
|
|
// Restore status and metadata from the server.
|
|
toUpdate.Status = status
|
|
toUpdate.Metadata = metadata
|
|
// Defaults are re-applied to the merged object, so a defaulted field
|
|
// the request left unset is defaulted again rather than cleared.
|
|
defaults.Apply(toUpdate)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *ServiceImpl) UpdateWorker(ctx context.Context, name string, precondition store.Precondition, mutate func(toUpdate *ateapipb.Worker) error) (*ateapipb.Worker, error) {
|
|
return s.store.UpdateWorker(ctx, name, precondition, func(toUpdate *ateapipb.Worker) error {
|
|
// Apply the mutation function to the stored value.
|
|
oldVal := proto.CloneOf(toUpdate)
|
|
if err := mutate(toUpdate); err != nil {
|
|
return err
|
|
}
|
|
newVal := toUpdate
|
|
|
|
// Validate the mutated value before doing any further work. This is
|
|
// what enforces the immutable fields, since only the stored worker
|
|
// gives declarative validation an old value to compare against.
|
|
if errs := apivalidation.ValidateWorkerUpdate(ctx, field.NewPath("worker"), newVal, oldVal, false); len(errs) > 0 {
|
|
return resources.ToGRPCStatusError(errs)
|
|
}
|
|
|
|
// Do any further work on the resource.
|
|
|
|
// Validate the final value before storing it.
|
|
if errs := apivalidation.ValidateWorkerUpdate(ctx, field.NewPath("worker"), newVal, oldVal, true); len(errs) > 0 {
|
|
return toGRPCInternalError(errs)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// The assignment operations are pass-throughs: an assignment is its own record,
|
|
// so binding and releasing are single store calls rather than a read-modify-write
|
|
// of the Worker.
|
|
func (s *ServiceImpl) BindActorToWorker(ctx context.Context, workerName string, assignment *ateapipb.ActorAssignment, admit func(*ateapipb.Worker) error) error {
|
|
return s.store.BindActorToWorker(ctx, workerName, assignment, admit)
|
|
}
|
|
|
|
func (s *ServiceImpl) ReleaseActorFromWorker(ctx context.Context, workerName string, actorUID string) (*ateapipb.Worker, error) {
|
|
return s.store.ReleaseActorFromWorker(ctx, workerName, actorUID)
|
|
}
|
|
|
|
func (s *ServiceImpl) GetWorkerAssignment(ctx context.Context, workerName, actorUID string) (*ateapipb.ActorAssignment, error) {
|
|
return s.store.GetWorkerAssignment(ctx, workerName, actorUID)
|
|
}
|
|
|
|
func (s *ServiceImpl) ListWorkerAssignments(ctx context.Context, workerName string, opts store.ListOptions) (store.ListResponse[*ateapipb.ActorAssignment], error) {
|
|
return s.store.ListWorkerAssignments(ctx, workerName, opts)
|
|
}
|
|
|
|
func (s *ServiceImpl) FindWorkerHostingActor(ctx context.Context, actorUID string) (string, error) {
|
|
return s.store.FindWorkerHostingActor(ctx, actorUID)
|
|
}
|
|
|
|
func (s *RPCService) DeleteWorker(ctx context.Context, req *ateapipb.DeleteWorkerRequest) (*ateapipb.Worker, error) {
|
|
if errs := apivalidation.ValidateDeleteWorkerRequest(ctx, req); len(errs) > 0 {
|
|
return nil, resources.ToGRPCStatusError(errs)
|
|
}
|
|
// The delete releases the Actor bound to this Worker before removing the
|
|
// record, so it is a workflow rather than a single store call.
|
|
return s.workerWorkflow.DeleteWorker(ctx, req.GetWorker().GetName(), toDeletePreconditions(req.GetOptions()))
|
|
}
|
|
|
|
func (s *ServiceImpl) DeleteWorker(ctx context.Context, name string, precondition store.DeletePreconditions) (*ateapipb.Worker, error) {
|
|
return s.store.DeleteWorker(ctx, name, precondition)
|
|
}
|
|
|
|
func (s *RPCService) DrainWorker(ctx context.Context, req *ateapipb.DrainWorkerRequest) (*ateapipb.Worker, error) {
|
|
if errs := apivalidation.ValidateDrainWorkerRequest(ctx, req); len(errs) > 0 {
|
|
return nil, resources.ToGRPCStatusError(errs)
|
|
}
|
|
name := req.GetWorker().GetName()
|
|
|
|
// A DrainWorkerRequest names a worker and carries no guards, so the ones
|
|
// the store requires come from a read here rather than from the client. A
|
|
// write that lands in between is reported as a conflict for the caller to
|
|
// retry, the same as any other guarded update.
|
|
observed, err := s.impl.GetWorker(ctx, name)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return nil, status.Errorf(codes.NotFound, "Worker %s not found", name)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("while getting worker to drain: %w", err)
|
|
}
|
|
|
|
return s.mutateWorker(ctx, name, store.PreconditionFrom(observed), func(toUpdate *ateapipb.Worker) error {
|
|
if toUpdate.GetStatus().GetState() == ateapipb.WorkerState_WORKER_STATE_DRAINING {
|
|
// already draining, do nothing
|
|
return &workerUnchanged{worker: proto.Clone(toUpdate).(*ateapipb.Worker)}
|
|
}
|
|
toUpdate.Status.State = ateapipb.WorkerState_WORKER_STATE_DRAINING
|
|
// The assignments are left alone: a draining Worker keeps its Actors
|
|
// until something releases them. Draining only stops new placements.
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// mutateWorker runs mutate against the named Worker and translates what comes
|
|
// back into the RPC's result. A mutation that found nothing to do reports the
|
|
// Worker it saw; anything else is a store error.
|
|
func (s *RPCService) mutateWorker(ctx context.Context, name string, precondition store.Precondition, mutate func(toUpdate *ateapipb.Worker) error) (*ateapipb.Worker, error) {
|
|
worker, err := s.impl.UpdateWorker(ctx, name, precondition, mutate)
|
|
if err == nil {
|
|
return worker, nil
|
|
}
|
|
|
|
var unchanged *workerUnchanged
|
|
if errors.As(err, &unchanged) {
|
|
return unchanged.worker, nil
|
|
}
|
|
switch {
|
|
case errors.Is(err, store.ErrNotFound):
|
|
return nil, status.Errorf(codes.NotFound, "Worker %s not found", name)
|
|
case errors.Is(err, store.ErrUIDConflict):
|
|
return nil, status.Errorf(codes.Aborted, "Worker %s is not the one the request describes", name)
|
|
case errors.Is(err, store.ErrVersionConflict):
|
|
return nil, status.Error(codes.Aborted, "concurrent update conflict, please retry")
|
|
case errors.Is(err, store.ErrPreconditionRequired):
|
|
return nil, status.Errorf(codes.InvalidArgument, "while updating worker %s: %v", name, err)
|
|
}
|
|
return nil, fmt.Errorf("while updating worker: %w", err)
|
|
}
|
|
|
|
// workerUnchanged ends an UpdateWorker mutation that found its work already
|
|
// done. The store hands a mutation's error straight back and leaves the Worker —
|
|
// and its version — untouched, which is what lets DrainWorker be idempotent: a
|
|
// call with nothing left to do costs no version bump. worker is a copy, because
|
|
// the store is free to reuse or discard the message once mutate returns.
|
|
type workerUnchanged struct {
|
|
worker *ateapipb.Worker
|
|
}
|
|
|
|
func (u *workerUnchanged) Error() string { return "worker is already in the requested state" }
|
|
|
|
func (s *ServiceImpl) WatchWorkers(ctx context.Context) (*store.WorkerWatch, error) {
|
|
return s.store.WatchWorkers(ctx)
|
|
}
|