Skip to content

Commit 1a40c9d

Browse files
controlapi: Add worker pool metadata to workers (#403)
Decouple the actor resumption scheduler from Kubernetes by avoiding direct queries to WorkerPool CRDs. Instead, cache the worker pool's labels and sandbox class on the Worker object in Redis during pod synchronization, and update the scheduler to evaluate worker eligibility using these cached fields.
1 parent 8a34e8e commit 1a40c9d

9 files changed

Lines changed: 368 additions & 242 deletions

File tree

cmd/ateapi/internal/controlapi/functional_test.go

Lines changed: 13 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ func setupTest(t *testing.T, ns string) *testContext {
283283

284284
ctx, cancel := context.WithCancel(context.Background())
285285

286-
syncer := NewWorkerPoolSyncer(persistence, workerInformer)
286+
syncer := NewWorkerPoolSyncer(persistence, workerInformer, workerPoolLister)
287287
syncer.Start(ctx)
288288

289289
workerFactory.Start(ctx.Done())
@@ -1044,15 +1044,17 @@ func TestListActors_PageSizeValidation(t *testing.T) {
10441044

10451045
// TestListWorkers tests that workers mirrored to Redis are listed.
10461046
// Workflow:
1047-
// 1. Creates a mock worker Pod in Kubernetes.
1048-
// 2. Waits for the background WorkerPoolSyncer to mirror it to Redis.
1049-
// 3. Calls ListWorkers RPC.
1050-
// 4. Verifies that the worker appears in the response.
1047+
// 1. Creates a mock WorkerPool in Kubernetes.
1048+
// 2. Creates a mock worker Pod in Kubernetes belonging to that pool.
1049+
// 3. Waits for the background WorkerPoolSyncer to mirror it to Redis.
1050+
// 4. Calls ListWorkers RPC.
1051+
// 5. Verifies that the worker appears in the response.
10511052
func TestListWorkers(t *testing.T) {
10521053
ns := namespaceForTest("ns-list-workers")
10531054
tc := setupTest(t, ns)
10541055
defer tc.cleanup()
10551056

1057+
createWorkerPool(t, tc, ns, "pool1", map[string]string{"foo": "bar"})
10561058
createWorkerPod(t, tc, ns, "worker-1", "node1", "pool1")
10571059

10581060
listResp, err := tc.client.ListWorkers(context.Background(), &ateapipb.ListWorkersRequest{})
@@ -1075,6 +1077,8 @@ func TestListWorkers(t *testing.T) {
10751077
NodeName: "node1",
10761078
Ip: "127.0.0.1",
10771079
Version: 1,
1080+
SandboxClass: "gvisor",
1081+
Labels: map[string]string{"foo": "bar"},
10781082
},
10791083
}
10801084

@@ -1176,8 +1180,10 @@ func TestResumeActor(t *testing.T) {
11761180
Atespace: testAtespace,
11771181
},
11781182
},
1179-
Ip: "127.0.0.1",
1180-
NodeName: "node1",
1183+
Ip: "127.0.0.1",
1184+
NodeName: "node1",
1185+
SandboxClass: "gvisor",
1186+
Labels: map[string]string{poolLabelKey: ns},
11811187
}
11821188

11831189
if diff := cmp.Diff(wantWorker, actorWorker, protocmp.Transform(), protocmp.IgnoreFields(&ateapipb.Worker{}, "version"), protocmp.IgnoreFields(&ateapipb.Worker{}, "worker_pod_uid")); diff != "" {
@@ -1298,34 +1304,6 @@ func TestResumeActor_NoWorkers(t *testing.T) {
12981304
assertGrpcError(t, err, codes.FailedPrecondition, "no free workers available")
12991305
}
13001306

1301-
// TestResumeActor_NoEligiblePool tests the distinct failure mode from
1302-
// TestResumeActor_NoWorkers: here no WorkerPool's labels satisfy the
1303-
// template's WorkerSelector at all, so there isn't even a pool to look for
1304-
// free workers in.
1305-
func TestResumeActor_NoEligiblePool(t *testing.T) {
1306-
ns := namespaceForTest("ns-resume-no-eligible-pool")
1307-
tc := setupTest(t, ns)
1308-
defer tc.cleanup()
1309-
1310-
createTemplateWithSelector(t, tc, ns, "tmpl1", &metav1.LabelSelector{
1311-
MatchLabels: map[string]string{"nonexistent": ns},
1312-
})
1313-
1314-
createResp, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{
1315-
ActorRef: &ateapipb.ActorRef{Atespace: testAtespace, Name: "id1"},
1316-
ActorTemplateNamespace: ns,
1317-
ActorTemplateName: "tmpl1",
1318-
})
1319-
if err != nil {
1320-
t.Fatalf("CreateActor failed: %v", err)
1321-
}
1322-
1323-
_, err = tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{
1324-
ActorRef: &ateapipb.ActorRef{Atespace: testAtespace, Name: createResp.GetActor().GetActorId()},
1325-
})
1326-
assertGrpcError(t, err, codes.FailedPrecondition, "no worker pool matches the template's sandboxClass and the template/actor selectors")
1327-
}
1328-
13291307
// TestResumeActor_MultiPoolSelector exercises the AND-of-two-selectors path
13301308
// end to end: a template's WorkerSelector gates two pools, and the actor's
13311309
// worker_selector narrows to just one of them.

cmd/ateapi/internal/controlapi/syncer.go

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@ import (
1818
"context"
1919
"errors"
2020
"log/slog"
21+
"maps"
2122

2223
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store"
2324
"github.com/agent-substrate/substrate/internal/resources"
25+
listersv1alpha1 "github.com/agent-substrate/substrate/pkg/client/listers/api/v1alpha1"
2426
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
2527
"google.golang.org/grpc/codes"
2628
"google.golang.org/grpc/status"
@@ -31,15 +33,17 @@ import (
3133
// WorkerPoolSyncer reconciles the state of worker pods from Kubernetes Informer
3234
// into the store.
3335
type WorkerPoolSyncer struct {
34-
persistence store.Interface
35-
workerInformer cache.SharedIndexInformer
36+
persistence store.Interface
37+
workerInformer cache.SharedIndexInformer
38+
workerPoolLister listersv1alpha1.WorkerPoolLister
3639
}
3740

3841
// NewWorkerPoolSyncer creates a new WorkerPoolSyncer.
39-
func NewWorkerPoolSyncer(persistence store.Interface, workerInformer cache.SharedIndexInformer) *WorkerPoolSyncer {
42+
func NewWorkerPoolSyncer(persistence store.Interface, workerInformer cache.SharedIndexInformer, workerPoolLister listersv1alpha1.WorkerPoolLister) *WorkerPoolSyncer {
4043
return &WorkerPoolSyncer{
41-
persistence: persistence,
42-
workerInformer: workerInformer,
44+
persistence: persistence,
45+
workerInformer: workerInformer,
46+
workerPoolLister: workerPoolLister,
4347
}
4448
}
4549

@@ -113,17 +117,26 @@ func (s *WorkerPoolSyncer) syncWorkerToStore(ctx context.Context, pod *corev1.Po
113117
return
114118
}
115119

116-
w, err := s.persistence.GetWorker(ctx, pod.Namespace, pod.Labels[workerPodLabel], pod.Name)
120+
poolName := pod.Labels[workerPodLabel]
121+
pool, err := s.workerPoolLister.WorkerPools(pod.Namespace).Get(poolName)
122+
if err != nil {
123+
slog.ErrorContext(ctx, "Failed to get WorkerPool for worker pod", slog.String("worker", pod.Namespace+"/"+pod.Name), slog.String("pool", poolName), slog.Any("err", err))
124+
return
125+
}
126+
127+
w, err := s.persistence.GetWorker(ctx, pod.Namespace, poolName, pod.Name)
117128
if err != nil {
118129
if errors.Is(err, store.ErrNotFound) {
119130
slog.InfoContext(ctx, "Syncer: creating worker in store", slog.String("worker", pod.Namespace+"/"+pod.Name))
120131
worker := &ateapipb.Worker{
121132
WorkerNamespace: pod.Namespace,
122-
WorkerPool: pod.Labels[workerPodLabel],
133+
WorkerPool: poolName,
123134
WorkerPod: pod.Name,
124135
Ip: pod.Status.PodIP,
125136
WorkerPodUid: string(pod.UID),
126137
NodeName: pod.Spec.NodeName,
138+
SandboxClass: string(pool.Spec.SandboxClass),
139+
Labels: pool.GetLabels(),
127140
}
128141
// TODO(thockin): for now this is the only place Workers are
129142
// created. If/when this becomes a regular API, validation should
@@ -143,10 +156,25 @@ func (s *WorkerPoolSyncer) syncWorkerToStore(ctx context.Context, pod *corev1.Po
143156
return
144157
}
145158

159+
changed := false
146160
if w.Ip != pod.Status.PodIP {
147161
// TODO: I don't think this is possible, but handling this case so we can log it just in case we can reproduce it.
148162
slog.InfoContext(ctx, "Syncer: updating worker in store (IP changed)", slog.String("worker", pod.Namespace+"/"+pod.Name))
149163
w.Ip = pod.Status.PodIP
164+
changed = true
165+
}
166+
if w.SandboxClass != string(pool.Spec.SandboxClass) {
167+
slog.InfoContext(ctx, "Syncer: updating worker in store (SandboxClass changed)", slog.String("worker", pod.Namespace+"/"+pod.Name))
168+
w.SandboxClass = string(pool.Spec.SandboxClass)
169+
changed = true
170+
}
171+
if !maps.Equal(w.Labels, pool.GetLabels()) {
172+
slog.InfoContext(ctx, "Syncer: updating worker in store (labels changed)", slog.String("worker", pod.Namespace+"/"+pod.Name))
173+
w.Labels = pool.GetLabels()
174+
changed = true
175+
}
176+
177+
if changed {
150178
if err = s.persistence.UpdateWorker(ctx, w, w.Version); err != nil {
151179
slog.ErrorContext(ctx, "Failed to update worker in store", slog.Any("err", err))
152180
}

cmd/ateapi/internal/controlapi/syncer_test.go

Lines changed: 130 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,47 +18,75 @@ import (
1818
"context"
1919
"errors"
2020
"fmt"
21+
"maps"
2122
"testing"
2223
"time"
2324

2425
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store"
2526
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store/storetest"
27+
atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1"
28+
atefake "github.com/agent-substrate/substrate/pkg/client/clientset/versioned/fake"
29+
"github.com/agent-substrate/substrate/pkg/client/informers/externalversions"
2630
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
2731
corev1 "k8s.io/api/core/v1"
2832
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
33+
"k8s.io/apimachinery/pkg/runtime"
2934
"k8s.io/apimachinery/pkg/util/wait"
3035
"k8s.io/client-go/kubernetes/fake"
3136
)
3237

3338
// setupSyncerTest sets up a real store with fake Redis and a fake K8s client with informer.
34-
func setupSyncerTest(t *testing.T, ctx context.Context) (store.Interface, *fake.Clientset, func()) {
39+
func setupSyncerTest(t *testing.T, ctx context.Context, initPools ...*atev1alpha1.WorkerPool) (store.Interface, *fake.Clientset, *atefake.Clientset, func()) {
3540
t.Helper()
3641

3742
persistence, cleanup := storetest.SetupTestStore(t)
3843

3944
fakeK8s := fake.NewSimpleClientset()
4045
workerFactory, workerInformer := WorkerPodInformer(fakeK8s)
4146

42-
syncer := NewWorkerPoolSyncer(persistence, workerInformer)
47+
objects := make([]runtime.Object, len(initPools))
48+
for i, pool := range initPools {
49+
objects[i] = pool
50+
}
51+
//nolint:staticcheck // NewSimpleClientset is the only available fake clientset for versioned CRDs.
52+
fakeAte := atefake.NewSimpleClientset(objects...)
53+
ateInformerFactory := externalversions.NewSharedInformerFactory(fakeAte, 0)
54+
workerPoolLister := ateInformerFactory.Api().V1alpha1().WorkerPools().Lister()
55+
56+
syncer := NewWorkerPoolSyncer(persistence, workerInformer, workerPoolLister)
4357
syncer.Start(ctx)
4458

4559
workerFactory.Start(ctx.Done())
60+
ateInformerFactory.Start(ctx.Done())
61+
4662
workerFactory.WaitForCacheSync(ctx.Done())
63+
ateInformerFactory.WaitForCacheSync(ctx.Done())
4764

48-
return persistence, fakeK8s, cleanup
65+
return persistence, fakeK8s, fakeAte, cleanup
4966
}
5067

5168
func TestSyncer_Lifecycle(t *testing.T) {
5269
ctx, cancel := context.WithCancel(context.Background())
5370
defer cancel()
5471

55-
persistence, fakeK8s, cleanup := setupSyncerTest(t, ctx)
56-
defer cleanup()
57-
5872
ns := "ns-syncer-lifecycle"
5973
podName := "worker-unit-1"
6074
poolName := "pool1"
6175

76+
pool := &atev1alpha1.WorkerPool{
77+
ObjectMeta: metav1.ObjectMeta{
78+
Name: poolName,
79+
Namespace: ns,
80+
Labels: map[string]string{"foo": "bar"},
81+
},
82+
Spec: atev1alpha1.WorkerPoolSpec{
83+
SandboxClass: "gvisor",
84+
},
85+
}
86+
87+
persistence, fakeK8s, _, cleanup := setupSyncerTest(t, ctx, pool)
88+
defer cleanup()
89+
6290
// 1. Verify no workers in Redis initially
6391
workers, err := persistence.ListWorkers(context.Background())
6492
if err != nil {
@@ -127,7 +155,16 @@ func TestSyncer_Lifecycle(t *testing.T) {
127155
}
128156
return false, err
129157
}
130-
return w.Ip == "127.0.0.1", nil
158+
if w.Ip != "127.0.0.1" {
159+
return false, nil
160+
}
161+
if w.SandboxClass != "gvisor" {
162+
return false, fmt.Errorf("expected SandboxClass gvisor, got %q", w.SandboxClass)
163+
}
164+
if !maps.Equal(w.Labels, map[string]string{"foo": "bar"}) {
165+
return false, fmt.Errorf("expected labels map[foo:bar], got %v", w.Labels)
166+
}
167+
return true, nil
131168
})
132169
if err != nil {
133170
t.Fatalf("Worker not found in Redis after update: %v", err)
@@ -159,10 +196,20 @@ func TestSyncer_DeleteBoundWorker_ClearsActor(t *testing.T) {
159196
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
160197
defer cancel()
161198

162-
persistence, fakeK8s, cleanup := setupSyncerTest(t, ctx)
163-
defer cleanup()
164-
165199
ns, pool, pod, ip := "ns-orphan", "pool1", "worker-orphan", "10.0.0.1"
200+
workerPool := &atev1alpha1.WorkerPool{
201+
ObjectMeta: metav1.ObjectMeta{
202+
Name: pool,
203+
Namespace: ns,
204+
Labels: map[string]string{"foo": "bar"},
205+
},
206+
Spec: atev1alpha1.WorkerPoolSpec{
207+
SandboxClass: "gvisor",
208+
},
209+
}
210+
211+
persistence, fakeK8s, _, cleanup := setupSyncerTest(t, ctx, workerPool)
212+
defer cleanup()
166213
if _, err := fakeK8s.CoreV1().Pods(ns).Create(ctx,
167214
&corev1.Pod{
168215
ObjectMeta: metav1.ObjectMeta{
@@ -240,3 +287,76 @@ func TestSyncer_DeleteBoundWorker_ClearsActor(t *testing.T) {
240287
t.Errorf("External SnapshotUriPrefix must be preserved")
241288
}
242289
}
290+
291+
func TestSyncer_OmittedFields(t *testing.T) {
292+
ctx, cancel := context.WithCancel(context.Background())
293+
defer cancel()
294+
295+
ns := "ns-syncer-omitted"
296+
podName := "worker-unit-1"
297+
poolName := "pool1"
298+
299+
// Create a pool with omitted sandbox class and no labels
300+
pool := &atev1alpha1.WorkerPool{
301+
ObjectMeta: metav1.ObjectMeta{
302+
Name: poolName,
303+
Namespace: ns,
304+
},
305+
Spec: atev1alpha1.WorkerPoolSpec{
306+
// Spec has no SandboxClass and no Labels
307+
},
308+
}
309+
310+
persistence, fakeK8s, _, cleanup := setupSyncerTest(t, ctx, pool)
311+
defer cleanup()
312+
313+
// Create a pod
314+
pod := &corev1.Pod{
315+
ObjectMeta: metav1.ObjectMeta{
316+
Name: podName,
317+
Namespace: ns,
318+
UID: "08675309-4a65-6e6e-7973-6e756d626572",
319+
Labels: map[string]string{
320+
workerPodLabel: poolName,
321+
},
322+
},
323+
Spec: corev1.PodSpec{
324+
NodeName: "node1",
325+
Containers: []corev1.Container{{Name: "main", Image: "nginx"}},
326+
},
327+
Status: corev1.PodStatus{
328+
Phase: corev1.PodRunning,
329+
PodIP: "127.0.0.1",
330+
PodIPs: []corev1.PodIP{{IP: "127.0.0.1"}},
331+
},
332+
}
333+
334+
_, err := fakeK8s.CoreV1().Pods(ns).Create(context.Background(), pod, metav1.CreateOptions{})
335+
if err != nil {
336+
t.Fatalf("failed to create pod: %v", err)
337+
}
338+
339+
// Verify that it is created in Redis with empty SandboxClass and empty Labels
340+
err = wait.PollUntilContextTimeout(context.Background(), 100*time.Millisecond, 2*time.Second, true, func(ctx context.Context) (bool, error) {
341+
w, err := persistence.GetWorker(ctx, ns, poolName, podName)
342+
if err != nil {
343+
if errors.Is(err, store.ErrNotFound) {
344+
return false, nil
345+
}
346+
return false, err
347+
}
348+
if w.Ip != "127.0.0.1" {
349+
return false, nil
350+
}
351+
if w.SandboxClass != "" {
352+
return false, fmt.Errorf("expected SandboxClass to be empty, got %q", w.SandboxClass)
353+
}
354+
if len(w.Labels) != 0 {
355+
return false, fmt.Errorf("expected labels to be empty, got %v", w.Labels)
356+
}
357+
return true, nil
358+
})
359+
if err != nil {
360+
t.Fatalf("Worker state check failed: %v", err)
361+
}
362+
}

cmd/ateapi/internal/controlapi/workflow.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ func (w *ActorWorkflow) ResumeActor(ctx context.Context, atespace, id string, bo
166166

167167
steps := []WorkflowStep[*ResumeInput, *ResumeState]{
168168
&LoadActorForResumeStep{store: w.store, actorTemplateLister: w.actorTemplateLister},
169-
&AssignWorkerStep{store: w.store, workerCache: w.workerCache, workerPoolLister: w.workerPoolLister},
169+
&AssignWorkerStep{store: w.store, workerCache: w.workerCache},
170170
&CallAteletRestoreStep{store: w.store, dialer: w.dialer, kubeClient: w.kubeClient, secretCache: w.secretCache, workerPoolLister: w.workerPoolLister, sandboxConfigLister: w.sandboxConfigLister},
171171
&FinalizeRunningStep{store: w.store},
172172
}

0 commit comments

Comments
 (0)