Repository navigation
multi actor worker support #1836
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Scott Funkenhauser (sfunkenhauser)
merged 12 commits into
agent-substrate:main
from
BenTheElder:multi-actor-worker
Sep 24, 2026
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
9689908
actorlock: serialize lifecycle RPCs per actor
BenTheElder f99a482
atunnel: route requests for multiple actors
BenTheElder b92111e
ateom-gvisor: host multiple actors per worker
BenTheElder 34448a0
ateom-microvm: host multiple actors per worker
BenTheElder cfa610f
ateom: advertise the configured actor limit
BenTheElder cda9a5a
e2e: test two actors sharing a worker
BenTheElder 6933cca
atunnel: bind egress listeners to their activation
BenTheElder 697da4c
ateomcgroup: share cgroup delegation between runtimes
BenTheElder 3471539
ateom-microvm: give each actor its own cgroup
BenTheElder 78a60d1
ateom-microvm: kill an actor's leftover processes by cgroup
BenTheElder d9c45b3
ateom-microvm: stop an actor's previous VM before re-hosting it
BenTheElder 2ee007b
docs: describe workers that host several actors
BenTheElder File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,190 @@ | ||
| //go:build linux | ||
|
|
||
| // 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 main | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "log/slog" | ||
| "net" | ||
|
|
||
| "google.golang.org/grpc/codes" | ||
| "google.golang.org/grpc/status" | ||
|
|
||
| "github.com/agent-substrate/substrate/internal/ateomnet" | ||
| "github.com/agent-substrate/substrate/internal/atunnel" | ||
| "github.com/agent-substrate/substrate/internal/resources" | ||
| ) | ||
|
|
||
| // hostedActor holds one actor's attribution, network, and runtime state. | ||
| type hostedActor struct { | ||
| // Attribution is available during boot. | ||
| attribution resources.ActorAttribution | ||
| network *ateomnet.SandboxSession | ||
| // Set once the containers exist. | ||
| session *workloadSession | ||
| } | ||
|
|
||
| // admitActor reserves capacity before network setup. An actor that is already | ||
| // hosted keeps its slot; its old network is returned for the caller to close. | ||
| func (s *AteomService) admitActor(attribution resources.ActorAttribution) (*hostedActor, *ateomnet.SandboxSession, error) { | ||
| s.actorsMu.Lock() | ||
| defer s.actorsMu.Unlock() | ||
| var stale *ateomnet.SandboxSession | ||
| if old, ok := s.actors[attribution.UID]; ok { | ||
| stale = old.network | ||
| } else if len(s.actors)+s.draining >= s.maxActors { | ||
| return nil, nil, status.Errorf(codes.ResourceExhausted, "worker is full: %d actors", s.maxActors) | ||
| } | ||
| hosted := &hostedActor{attribution: attribution} | ||
| s.actors[attribution.UID] = hosted | ||
| return hosted, stale, nil | ||
| } | ||
|
|
||
| // hostActor sets up the actor's network, replacing any stale one. | ||
| func (s *AteomService) hostActor(ctx context.Context, attribution resources.ActorAttribution) (*hostedActor, error) { | ||
| uid := attribution.UID | ||
| if uid == "" { | ||
| return nil, fmt.Errorf("actor UID is required") | ||
| } | ||
|
|
||
| hosted, stale, err := s.admitActor(attribution) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| if stale != nil { | ||
| if err := stale.Close(ctx); err != nil { | ||
| s.actorsMu.Lock() | ||
| delete(s.actors, uid) | ||
| s.actorsMu.Unlock() | ||
| return nil, fmt.Errorf("while clearing stale state for actor %s: %w", uid, err) | ||
| } | ||
| } | ||
|
|
||
| session, err := ateomnet.ServeSandbox(ctx, ateomnet.SandboxNetworkConfig{ | ||
| ActorUID: uid, | ||
| Veth: true, | ||
| EgressPort: s.atunnelEgressPort, | ||
| DNSPort: atunnel.DNSPort, | ||
| }, s.atunnelEgress, s.dnsRelay) | ||
| if err != nil { | ||
| s.actorsMu.Lock() | ||
| delete(s.actors, uid) | ||
| s.actorsMu.Unlock() | ||
| return nil, fmt.Errorf("while setting up the sandbox network: %w", err) | ||
| } | ||
|
|
||
| // Use the actor's gateway as its DNS resolver. | ||
| if _, err := actorResolvConf(uid); err != nil { | ||
| _ = session.Close(ctx) | ||
| s.actorsMu.Lock() | ||
| delete(s.actors, uid) | ||
| s.actorsMu.Unlock() | ||
| return nil, err | ||
| } | ||
|
|
||
| s.actorsMu.Lock() | ||
| hosted.network = session | ||
| s.actorsMu.Unlock() | ||
| return hosted, nil | ||
| } | ||
|
|
||
| // unhostActor removes an actor and its network. Repeated calls are safe. | ||
| func (s *AteomService) unhostActor(ctx context.Context, actorUID string) error { | ||
| s.actorsMu.Lock() | ||
| hosted, ok := s.actors[actorUID] | ||
| delete(s.actors, actorUID) | ||
| if ok { | ||
| // Retain capacity until network cleanup completes. | ||
| s.draining++ | ||
| } | ||
| s.actorsMu.Unlock() | ||
| if !ok { | ||
| return nil | ||
| } | ||
| defer func() { | ||
| s.actorsMu.Lock() | ||
| s.draining-- | ||
| s.actorsMu.Unlock() | ||
| }() | ||
|
|
||
| removeActorResolvConf(ctx, actorUID) | ||
| if hosted.network == nil { | ||
| return nil | ||
| } | ||
| if err := hosted.network.Close(ctx); err != nil { | ||
| slog.WarnContext(ctx, "Failed to remove the sandbox network", slog.Any("err", err)) | ||
| return err | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // lookupActor returns the actor or nil without taking its lifecycle lock. | ||
| func (s *AteomService) lookupActor(actorUID string) *hostedActor { | ||
| s.actorsMu.RLock() | ||
| defer s.actorsMu.RUnlock() | ||
| return s.actors[actorUID] | ||
| } | ||
|
|
||
| // hostedActors returns a snapshot of the actor map. | ||
| func (s *AteomService) hostedActors() []*hostedActor { | ||
| s.actorsMu.RLock() | ||
| defer s.actorsMu.RUnlock() | ||
| out := make([]*hostedActor, 0, len(s.actors)) | ||
| for _, hosted := range s.actors { | ||
| out = append(out, hosted) | ||
| } | ||
| return out | ||
| } | ||
|
|
||
| // hostedSessions returns the runsc sessions for shutdown. | ||
| func (s *AteomService) hostedSessions() []*workloadSession { | ||
| s.actorsMu.RLock() | ||
| defer s.actorsMu.RUnlock() | ||
| var out []*workloadSession | ||
| for _, hosted := range s.actors { | ||
| if hosted.session != nil { | ||
| out = append(out, hosted.session) | ||
| } | ||
| } | ||
| return out | ||
| } | ||
|
|
||
| // setSession records the runsc state for an actor once its containers exist. | ||
| func (s *AteomService) setSession(actorUID string, session *workloadSession) { | ||
| s.actorsMu.Lock() | ||
| defer s.actorsMu.Unlock() | ||
| if hosted, ok := s.actors[actorUID]; ok { | ||
| hosted.session = session | ||
| } | ||
| } | ||
|
|
||
| // sandboxDialer looks up the actor on each dial. | ||
| func (s *AteomService) sandboxDialer(actorUID string) atunnel.DialFunc { | ||
| return func(ctx context.Context, network, address string) (net.Conn, error) { | ||
| s.actorsMu.RLock() | ||
| var session *ateomnet.SandboxSession | ||
| if hosted := s.actors[actorUID]; hosted != nil { | ||
| session = hosted.network | ||
| } | ||
| s.actorsMu.RUnlock() | ||
| if session == nil { | ||
| return nil, fmt.Errorf("actor %s is not hosted here", actorUID) | ||
| } | ||
| return session.Dialer()(ctx, network, address) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| //go:build linux | ||
|
|
||
| // 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 main | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "sync" | ||
| "testing" | ||
|
|
||
| "google.golang.org/grpc/codes" | ||
| "google.golang.org/grpc/status" | ||
| "time" | ||
|
|
||
| "github.com/agent-substrate/substrate/internal/actorlock" | ||
| "github.com/agent-substrate/substrate/internal/resources" | ||
| ) | ||
|
|
||
| func TestAdmitActorEnforcesTheCeilingConcurrently(t *testing.T) { | ||
| const ceiling = 8 | ||
| s := &AteomService{ | ||
| locks: actorlock.New(), | ||
| actors: map[string]*hostedActor{}, | ||
| maxActors: ceiling, | ||
| } | ||
|
|
||
| var wg sync.WaitGroup | ||
| for i := range ceiling * 4 { | ||
| wg.Add(1) | ||
| go func() { | ||
| defer wg.Done() | ||
| _, _, _ = s.admitActor(resources.ActorAttribution{UID: fmt.Sprintf("actor-%d", i)}) | ||
| }() | ||
| } | ||
| wg.Wait() | ||
|
|
||
| if got := len(s.hostedActors()); got != ceiling { | ||
| t.Errorf("admitted %d actors, want the ceiling of %d", got, ceiling) | ||
| } | ||
| } | ||
|
|
||
| // Re-admitting an actor that is already hosted keeps its slot, so it succeeds | ||
| // on a full worker and a retry cannot lose its place to another actor. | ||
| func TestAdmitActorKeepsAHostedActorsSlot(t *testing.T) { | ||
| s := &AteomService{actors: map[string]*hostedActor{}, maxActors: 1} | ||
| first, _, err := s.admitActor(resources.ActorAttribution{UID: "actor-a"}) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| again, _, err := s.admitActor(resources.ActorAttribution{UID: "actor-a"}) | ||
| if err != nil { | ||
| t.Fatalf("re-admitting a hosted actor on a full worker: %v", err) | ||
| } | ||
| if again == first { | ||
| t.Error("re-admission kept the old record; readers could not tell the incarnations apart") | ||
| } | ||
| // ResourceExhausted, so the control plane treats it as a capacity miss. | ||
| if _, _, err := s.admitActor(resources.ActorAttribution{UID: "actor-b"}); status.Code(err) != codes.ResourceExhausted { | ||
| t.Errorf("admitting past the ceiling: got %v, want ResourceExhausted", err) | ||
| } | ||
| if got := len(s.hostedActors()); got != 1 { | ||
| t.Errorf("hosting %d actors, want 1", got) | ||
| } | ||
| } | ||
|
|
||
| // TestDrainingActorsStillCountAgainstTheCeiling checks capacity during teardown. | ||
| func TestDrainingActorsStillCountAgainstTheCeiling(t *testing.T) { | ||
| s := &AteomService{ | ||
| locks: actorlock.New(), | ||
| actors: map[string]*hostedActor{}, | ||
| maxActors: 1, | ||
| draining: 1, | ||
| } | ||
| if _, err := s.hostActor(context.Background(), resources.ActorAttribution{UID: "actor-a"}); err == nil { | ||
| t.Error("admitted an actor while a draining one still held the only place") | ||
| } | ||
| } | ||
|
|
||
| // TestGracefulShutdownWaitsForInFlightRPCs verifies checkpoints finish before shutdown. | ||
| func TestGracefulShutdownWaitsForInFlightRPCs(t *testing.T) { | ||
| s := &AteomService{ | ||
| locks: actorlock.New(), | ||
| inFlight: actorlock.NewInFlight(), | ||
| actors: map[string]*hostedActor{}, | ||
| } | ||
| release := s.inFlight.Add("actor-a", rpcCheckpointWorkload, nil) | ||
|
|
||
| returned := make(chan struct{}) | ||
| go func() { | ||
| defer close(returned) | ||
| s.gracefulShutdown(context.Background()) | ||
| }() | ||
|
|
||
| select { | ||
| case <-returned: | ||
| t.Fatal("gracefulShutdown returned while a checkpoint was still in flight") | ||
| case <-time.After(250 * time.Millisecond): | ||
| } | ||
|
|
||
| release() | ||
| select { | ||
| case <-returned: | ||
| case <-time.After(30 * time.Second): | ||
| t.Error("gracefulShutdown did not return after the checkpoint finished") | ||
| } | ||
| } | ||
|
|
||
| func TestGracefulShutdownCancelsEveryStartup(t *testing.T) { | ||
| s := &AteomService{ | ||
| locks: actorlock.New(), | ||
| inFlight: actorlock.NewInFlight(), | ||
| actors: map[string]*hostedActor{}, | ||
| } | ||
| canceled := make(chan string, 2) | ||
| for _, uid := range []string{"actor-a", "actor-b"} { | ||
| defer s.inFlight.Add(uid, rpcRunWorkload, func() { canceled <- uid })() | ||
| } | ||
|
|
||
| cancelStartups(context.Background(), s.inFlight) | ||
|
|
||
| seen := map[string]bool{} | ||
| for range 2 { | ||
| select { | ||
| case uid := <-canceled: | ||
| seen[uid] = true | ||
| case <-time.After(5 * time.Second): | ||
| t.Fatalf("only %v were canceled", seen) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // TestBeginRPCRegistersBeforeCheckingForADrain checks registration and drain rejection. | ||
| func TestBeginRPCRegistersBeforeCheckingForADrain(t *testing.T) { | ||
| s := &AteomService{locks: actorlock.New(), inFlight: actorlock.NewInFlight()} | ||
|
|
||
| release, err := s.beginRPC("actor-a", rpcRunWorkload, func() {}) | ||
| if err != nil { | ||
| t.Fatalf("beginRPC on a live ateom: %v", err) | ||
| } | ||
| if _, ok := s.inFlight.Names()["actor-a"]; !ok { | ||
| t.Error("an admitted RPC is not visible to shutdown") | ||
| } | ||
| release() | ||
|
|
||
| // Draining: refused, and nothing left behind for shutdown to wait on. | ||
| s.shuttingDown.Store(true) | ||
| if _, err := s.beginRPC("actor-b", rpcRunWorkload, func() {}); err == nil { | ||
| t.Error("beginRPC admitted an RPC while draining") | ||
| } | ||
| if got := s.inFlight.Names(); len(got) != 0 { | ||
| t.Errorf("a refused RPC stayed registered: %v", got) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.