Skip to content

Commit 8464599

Browse files
Feature/add pod node event handler (llm-d-incubation#486)
I merged this PR since there have already been quite a few rounds of revisions from earlier comments. And I'll address the remaining comments in follow-up PRs. Thanks a lot again. == * Add Pod&Node event handler * fix llm-d-incubation#469 handle populator add/delete event * Make expectation timeout configurable at startup * Use launcher-config-name label for safer launcher Pod identification * Simplify work queue by replacing per-type items with a single sentinel * fix(launcher-populator): track pending expectations by Pod UID instead of count
1 parent 01277c3 commit 8464599

5 files changed

Lines changed: 625 additions & 37 deletions

File tree

cmd/launcher-populator/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ func main() {
4545
klog.InitFlags(flag.CommandLine)
4646
pflag.CommandLine.AddGoFlagSet(flag.CommandLine)
4747
common.AddKubernetesClientFlags(*pflag.CommandLine, loadingRules, overrides)
48+
expectationTimeout := pflag.Duration("expectation-timeout", launcherpopulator.DefaultExpectationTimeout,
49+
"How long to wait for the informer cache to reflect pending Pod mutations before falling back to a direct apiserver query")
4850
pflag.Parse()
4951

5052
// Create a context with cancellation signal
@@ -93,6 +95,7 @@ func main() {
9395
overrides.Context.Namespace,
9496
kubePreInformers.Core().V1(),
9597
fmaPreInformers,
98+
*expectationTimeout,
9699
)
97100
if err != nil {
98101
klog.Fatal(err)

config/validating-admission-policies/fma-immutable-fields.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ spec:
2727
oldObject.metadata.?annotations['dual-pods.llm-d.ai/vllm-config'].orValue('') == object.metadata.?annotations['dual-pods.llm-d.ai/vllm-config'].orValue('') &&
2828
oldObject.metadata.?annotations['dual-pods.llm-d.ai/isc-label-keys'].orValue('') == object.metadata.?annotations['dual-pods.llm-d.ai/isc-label-keys'].orValue('') &&
2929
oldObject.metadata.?annotations['dual-pods.llm-d.ai/isc-annotation-keys'].orValue('') == object.metadata.?annotations['dual-pods.llm-d.ai/isc-annotation-keys'].orValue('') &&
30-
oldObject.metadata.?labels['dual-pods.llm-d.ai/dual'].orValue('') == object.metadata.?labels['dual-pods.llm-d.ai/dual'].orValue('')
30+
oldObject.metadata.?labels['dual-pods.llm-d.ai/dual'].orValue('') == object.metadata.?labels['dual-pods.llm-d.ai/dual'].orValue('') &&
31+
oldObject.metadata.?labels['dual-pods.llm-d.ai/launcher-config-name'].orValue('') == object.metadata.?labels['dual-pods.llm-d.ai/launcher-config-name'].orValue('')
3132
)
3233
message: "One or more annotations/labels are managed by FMA controllers and cannot be modified directly."
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/*
2+
Copyright 2025 The llm-d Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package launcherpopulator
18+
19+
import (
20+
"sync"
21+
"time"
22+
23+
"k8s.io/apimachinery/pkg/types"
24+
"k8s.io/apimachinery/pkg/util/sets"
25+
)
26+
27+
// DefaultExpectationTimeout is the default duration to wait for the informer
28+
// cache to reflect pending mutations before falling back to a direct apiserver
29+
// query. This covers the normal watch propagation delay while bounding how
30+
// long the controller will defer reconciliation.
31+
const DefaultExpectationTimeout = 5 * time.Second
32+
33+
// ExpectationStatus represents the state of expectations for a given key.
34+
type ExpectationStatus int
35+
36+
const (
37+
// ExpectationsSatisfied means no pending mutations remain; the informer
38+
// cache is considered up-to-date and safe to read.
39+
ExpectationsSatisfied ExpectationStatus = iota
40+
// ExpectationsWaiting means pending mutations exist but the timeout has
41+
// not yet been reached. The caller should requeue and try again later.
42+
ExpectationsWaiting
43+
// ExpectationsTimedOut means pending mutations exist and the timeout has
44+
// passed. The caller should fall back to querying the apiserver directly.
45+
ExpectationsTimedOut
46+
)
47+
48+
// pendingExpectations tracks Pod create/delete mutations that the controller
49+
// has performed but whose effects have not yet been observed in the informer's
50+
// local cache. This prevents the controller from making incorrect decisions
51+
// based on stale informer cache state.
52+
type pendingExpectations struct {
53+
mu sync.Mutex
54+
entries map[NodeLauncherKey]*expectationEntry
55+
// timeout is how long to wait for the informer cache to reflect pending
56+
// mutations before falling back to a direct apiserver query.
57+
timeout time.Duration
58+
}
59+
60+
type expectationEntry struct {
61+
// pendingCreations tracks UIDs of Pods whose creation has been confirmed
62+
// by the apiserver but is not yet visible in the informer cache.
63+
pendingCreations sets.Set[types.UID]
64+
// pendingDeletions tracks UIDs of Pods whose deletion has been confirmed
65+
// by the apiserver but that are still visible in the informer cache.
66+
pendingDeletions sets.Set[types.UID]
67+
// deadline is the wall-clock time after which we consider the expectations
68+
// stale and fall back to querying the apiserver directly.
69+
deadline time.Time
70+
}
71+
72+
func newPendingExpectations(timeout time.Duration) *pendingExpectations {
73+
return &pendingExpectations{
74+
entries: make(map[NodeLauncherKey]*expectationEntry),
75+
timeout: timeout,
76+
}
77+
}
78+
79+
// expectCreation records that a Pod creation (identified by UID) is pending
80+
// for the given key. Call this immediately after a successful Create. The
81+
// expectation is cleared on the next check() call once the UID appears in
82+
// the informer cache.
83+
func (pe *pendingExpectations) expectCreation(key NodeLauncherKey, uid types.UID) {
84+
pe.mu.Lock()
85+
defer pe.mu.Unlock()
86+
e := pe.getOrCreate(key)
87+
e.pendingCreations.Insert(uid)
88+
e.deadline = time.Now().Add(pe.timeout)
89+
}
90+
91+
// expectDeletion records that a Pod deletion (identified by UID) is pending
92+
// for the given key. Call this immediately after a successful Delete. The
93+
// expectation is cleared on the next check() call once the UID is no longer
94+
// present in the informer cache.
95+
func (pe *pendingExpectations) expectDeletion(key NodeLauncherKey, uid types.UID) {
96+
pe.mu.Lock()
97+
defer pe.mu.Unlock()
98+
e := pe.getOrCreate(key)
99+
e.pendingDeletions.Insert(uid)
100+
e.deadline = time.Now().Add(pe.timeout)
101+
}
102+
103+
// check returns the current status of expectations for the given key. The
104+
// caller passes presentUIDs, the set of launcher Pod UIDs currently visible
105+
// in the informer cache for that key. check prunes pending entries that the
106+
// cache has caught up with: a creation whose UID is now present, and a
107+
// deletion whose UID is no longer present, are both satisfied.
108+
//
109+
// This formulation makes the informer cache the single source of truth for
110+
// reconciling expectations; no event-driven bookkeeping is required.
111+
func (pe *pendingExpectations) check(key NodeLauncherKey, presentUIDs sets.Set[types.UID]) ExpectationStatus {
112+
pe.mu.Lock()
113+
defer pe.mu.Unlock()
114+
e, ok := pe.entries[key]
115+
if !ok {
116+
return ExpectationsSatisfied
117+
}
118+
for uid := range e.pendingCreations {
119+
if presentUIDs.Has(uid) {
120+
e.pendingCreations.Delete(uid)
121+
}
122+
}
123+
for uid := range e.pendingDeletions {
124+
if !presentUIDs.Has(uid) {
125+
e.pendingDeletions.Delete(uid)
126+
}
127+
}
128+
if e.pendingCreations.Len() == 0 && e.pendingDeletions.Len() == 0 {
129+
delete(pe.entries, key)
130+
return ExpectationsSatisfied
131+
}
132+
if time.Now().After(e.deadline) {
133+
return ExpectationsTimedOut
134+
}
135+
return ExpectationsWaiting
136+
}
137+
138+
// reset clears all expectations for the given key. This is called after
139+
// falling back to an apiserver query, since the controller now has
140+
// authoritative state and no longer needs to track pending changes.
141+
func (pe *pendingExpectations) reset(key NodeLauncherKey) {
142+
pe.mu.Lock()
143+
defer pe.mu.Unlock()
144+
delete(pe.entries, key)
145+
}
146+
147+
func (pe *pendingExpectations) getOrCreate(key NodeLauncherKey) *expectationEntry {
148+
if e, ok := pe.entries[key]; ok {
149+
return e
150+
}
151+
e := &expectationEntry{
152+
pendingCreations: sets.New[types.UID](),
153+
pendingDeletions: sets.New[types.UID](),
154+
}
155+
pe.entries[key] = e
156+
return e
157+
}

0 commit comments

Comments
 (0)