Skip to main content

kube_runtime/utils/
predicate.rs

1use crate::watcher::Error;
2use core::{
3    pin::Pin,
4    task::{Context, Poll, ready},
5};
6use futures::Stream;
7use kube_client::{Resource, api::ObjectMeta};
8use pin_project::pin_project;
9use std::{
10    collections::{
11        HashMap,
12        hash_map::{DefaultHasher, Entry},
13    },
14    hash::{Hash, Hasher},
15    marker::PhantomData,
16    time::{Duration, Instant},
17};
18
19fn hash<T: Hash + ?Sized>(t: &T) -> u64 {
20    let mut hasher = DefaultHasher::new();
21    t.hash(&mut hasher);
22    hasher.finish()
23}
24
25/// Private cache key that includes UID in equality for predicate filtering
26#[derive(Debug, Clone, PartialEq, Eq, Hash)]
27struct PredicateCacheKey {
28    name: String,
29    namespace: Option<String>,
30    uid: Option<String>,
31}
32
33impl From<&ObjectMeta> for PredicateCacheKey {
34    fn from(meta: &ObjectMeta) -> Self {
35        Self {
36            name: meta.name.clone().unwrap_or_default(),
37            namespace: meta.namespace.clone(),
38            uid: meta.uid.clone(),
39        }
40    }
41}
42
43/// A predicate is a hasher of Kubernetes objects stream filtering
44pub trait Predicate<K> {
45    /// A predicate only needs to implement optional hashing when keys exist
46    fn hash_property(&self, obj: &K) -> Option<u64>;
47
48    /// Returns a `Predicate` that falls back to an alternate property if the first does not exist
49    ///
50    /// # Usage
51    ///
52    /// ```
53    /// # use k8s_openapi::api::core::v1::Pod;
54    /// use kube::runtime::{predicates, Predicate};
55    /// # fn blah<K>(a: impl Predicate<K>) {}
56    /// let pred = predicates::generation.fallback(predicates::resource_version);
57    /// blah::<Pod>(pred);
58    /// ```
59    fn fallback<F: Predicate<K>>(self, f: F) -> Fallback<Self, F>
60    where
61        Self: Sized,
62    {
63        Fallback(self, f)
64    }
65
66    /// Returns a `Predicate` that combines all available hashes
67    ///
68    /// # Usage
69    ///
70    /// ```
71    /// # use k8s_openapi::api::core::v1::Pod;
72    /// use kube::runtime::{predicates, Predicate};
73    /// # fn blah<K>(a: impl Predicate<K>) {}
74    /// let pred = predicates::labels.combine(predicates::annotations);
75    /// blah::<Pod>(pred);
76    /// ```
77    fn combine<F: Predicate<K>>(self, f: F) -> Combine<Self, F>
78    where
79        Self: Sized,
80    {
81        Combine(self, f)
82    }
83}
84
85impl<K, F: Fn(&K) -> Option<u64>> Predicate<K> for F {
86    fn hash_property(&self, obj: &K) -> Option<u64> {
87        (self)(obj)
88    }
89}
90
91/// See [`Predicate::fallback`]
92#[derive(Copy, Clone, Debug, PartialEq, Eq)]
93pub struct Fallback<A, B>(pub(super) A, pub(super) B);
94impl<A, B, K> Predicate<K> for Fallback<A, B>
95where
96    A: Predicate<K>,
97    B: Predicate<K>,
98{
99    fn hash_property(&self, obj: &K) -> Option<u64> {
100        self.0.hash_property(obj).or_else(|| self.1.hash_property(obj))
101    }
102}
103/// See [`Predicate::combine`]
104#[derive(Copy, Clone, Debug, PartialEq, Eq)]
105pub struct Combine<A, B>(pub(super) A, pub(super) B);
106impl<A, B, K> Predicate<K> for Combine<A, B>
107where
108    A: Predicate<K>,
109    B: Predicate<K>,
110{
111    fn hash_property(&self, obj: &K) -> Option<u64> {
112        match (self.0.hash_property(obj), self.1.hash_property(obj)) {
113            // pass on both missing properties so people can chain .fallback
114            (None, None) => None,
115            // but any other combination of properties are hashed together
116            (a, b) => Some(hash(&(a, b))),
117        }
118    }
119}
120
121/// Configuration for predicate filtering with cache TTL
122#[derive(Debug, Clone)]
123pub struct Config {
124    /// Time-to-live for cache entries
125    ///
126    /// Entries not seen for at least this long will be evicted from the cache.
127    /// Default is 1 hour.
128    ttl: Duration,
129}
130
131impl Config {
132    /// Set the time-to-live for cache entries
133    ///
134    /// Entries not seen for at least this long will be evicted from the cache.
135    #[must_use]
136    pub fn ttl(mut self, ttl: Duration) -> Self {
137        self.ttl = ttl;
138        self
139    }
140}
141
142impl Default for Config {
143    fn default() -> Self {
144        Self {
145            // Default to 1 hour TTL - long enough to avoid unnecessary reconciles
146            // but short enough to prevent unbounded memory growth
147            ttl: Duration::from_secs(3600),
148        }
149    }
150}
151
152/// Cache entry storing predicate hash and last access time
153#[derive(Debug, Clone)]
154struct CacheEntry {
155    hash: u64,
156    last_seen: Instant,
157}
158
159#[pin_project]
160/// Stream returned by the [`predicate_filter`](super::WatchStreamExt::predicate_filter) method.
161#[must_use = "streams do nothing unless polled"]
162pub struct PredicateFilter<St, K: Resource, P: Predicate<K>> {
163    #[pin]
164    stream: St,
165    predicate: P,
166    cache: HashMap<PredicateCacheKey, CacheEntry>,
167    config: Config,
168    last_evicted: Instant,
169    // K: Resource necessary to get .meta() of an object during polling
170    _phantom: PhantomData<K>,
171}
172impl<St, K, P> PredicateFilter<St, K, P>
173where
174    St: Stream<Item = Result<K, Error>>,
175    K: Resource,
176    P: Predicate<K>,
177{
178    pub(super) fn new(stream: St, predicate: P, config: Config) -> Self {
179        Self {
180            stream,
181            predicate,
182            cache: HashMap::new(),
183            config,
184            last_evicted: Instant::now(),
185            _phantom: PhantomData,
186        }
187    }
188}
189impl<St, K, P> Stream for PredicateFilter<St, K, P>
190where
191    St: Stream<Item = Result<K, Error>>,
192    K: Resource,
193    K::DynamicType: Default + Eq + Hash,
194    P: Predicate<K>,
195{
196    type Item = Result<K, Error>;
197
198    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
199        let mut me = self.project();
200
201        let now = Instant::now();
202        let ttl = me.config.ttl;
203        // Reclaim expired entries periodically instead of on every poll. Per-entry
204        // freshness is enforced below, so this scan is only memory hygiene and does
205        // not affect which events are emitted.
206        if now.duration_since(*me.last_evicted) >= ttl {
207            me.cache
208                .retain(|_, entry| now.duration_since(entry.last_seen) < ttl);
209            *me.last_evicted = now;
210        }
211
212        Poll::Ready(loop {
213            break match ready!(me.stream.as_mut().poll_next(cx)) {
214                Some(Ok(obj)) => {
215                    if let Some(val) = me.predicate.hash_property(&obj) {
216                        let key = PredicateCacheKey::from(obj.meta());
217
218                        // Upsert in a single lookup. An entry older than the TTL is
219                        // treated as absent, matching the evict-before-every-poll
220                        // semantics now that eviction is amortized.
221                        let changed = match me.cache.entry(key) {
222                            Entry::Occupied(mut e) => {
223                                let stale = now.duration_since(e.get().last_seen) >= ttl;
224                                let changed = stale || e.get().hash != val;
225                                e.insert(CacheEntry {
226                                    hash: val,
227                                    last_seen: now,
228                                });
229                                changed
230                            }
231                            Entry::Vacant(e) => {
232                                e.insert(CacheEntry {
233                                    hash: val,
234                                    last_seen: now,
235                                });
236                                true
237                            }
238                        };
239
240                        if changed {
241                            Some(Ok(obj))
242                        } else {
243                            continue;
244                        }
245                    } else {
246                        // if we can't evaluate predicate, always emit K
247                        Some(Ok(obj))
248                    }
249                }
250                Some(Err(err)) => Some(Err(err)),
251                None => return Poll::Ready(None),
252            };
253        })
254    }
255}
256
257/// Predicate functions for [`WatchStreamExt::predicate_filter`](crate::WatchStreamExt::predicate_filter)
258///
259/// These functions just return a hash of commonly compared values,
260/// to help decide whether to pass a watch event along or not.
261///
262/// Functional rewrite of the [controller-runtime/predicate module](https://github.com/kubernetes-sigs/controller-runtime/blob/main/pkg/predicate/predicate.go).
263pub mod predicates {
264    use super::hash;
265    use kube_client::{Resource, ResourceExt};
266
267    /// Hash the generation of a Resource K
268    pub fn generation<K: Resource>(obj: &K) -> Option<u64> {
269        obj.meta().generation.map(|g| hash(&g))
270    }
271
272    /// Hash the resource version of a Resource K
273    pub fn resource_version<K: Resource>(obj: &K) -> Option<u64> {
274        obj.meta().resource_version.as_ref().map(hash)
275    }
276
277    /// Hash the labels of a Resource K
278    pub fn labels<K: Resource>(obj: &K) -> Option<u64> {
279        Some(hash(obj.labels()))
280    }
281
282    /// Hash the annotations of a Resource K
283    pub fn annotations<K: Resource>(obj: &K) -> Option<u64> {
284        Some(hash(obj.annotations()))
285    }
286
287    /// Hash the finalizers of a Resource K
288    pub fn finalizers<K: Resource>(obj: &K) -> Option<u64> {
289        Some(hash(obj.finalizers()))
290    }
291}
292
293#[cfg(test)]
294pub(crate) mod tests {
295    use std::{pin::pin, task::Poll};
296
297    use super::{Config, Error, PredicateFilter, predicates};
298    use futures::{FutureExt, StreamExt, poll, stream};
299    use kube_client::Resource;
300    use serde_json::json;
301
302    #[tokio::test]
303    async fn predicate_filtering_hides_equal_predicate_values() {
304        use k8s_openapi::api::core::v1::Pod;
305        let mkobj = |g: i32| {
306            let p: Pod = serde_json::from_value(json!({
307                "apiVersion": "v1",
308                "kind": "Pod",
309                "metadata": {
310                    "name": "blog",
311                    "generation": Some(g),
312                },
313                "spec": {
314                    "containers": [{
315                      "name": "blog",
316                      "image": "clux/blog:0.1.0"
317                    }],
318                }
319            }))
320            .unwrap();
321            p
322        };
323        let data = stream::iter([
324            Ok(mkobj(1)),
325            Err(Error::NoResourceVersion),
326            Ok(mkobj(1)),
327            Ok(mkobj(2)),
328        ]);
329        let mut rx = pin!(PredicateFilter::new(
330            data,
331            predicates::generation,
332            Config::default()
333        ));
334
335        // mkobj(1) passed through
336        let first = rx.next().now_or_never().unwrap().unwrap().unwrap();
337        assert_eq!(first.meta().generation, Some(1));
338
339        // Error passed through
340        assert!(matches!(
341            poll!(rx.next()),
342            Poll::Ready(Some(Err(Error::NoResourceVersion)))
343        ));
344        // (no repeat mkobj(1) - same generation)
345        // mkobj(2) next
346        let second = rx.next().now_or_never().unwrap().unwrap().unwrap();
347        assert_eq!(second.meta().generation, Some(2));
348        assert!(matches!(poll!(rx.next()), Poll::Ready(None)));
349    }
350
351    #[tokio::test]
352    async fn predicate_filtering_should_handle_recreated_resources_with_same_generation() {
353        use k8s_openapi::api::core::v1::Pod;
354
355        let mkobj = |g: i32, uid: &str| {
356            let p: Pod = serde_json::from_value(json!({
357                "apiVersion": "v1",
358                "kind": "Pod",
359                "metadata": {
360                    "name": "blog",
361                    "namespace": "default",
362                    "generation": Some(g),
363                    "uid": uid,
364                },
365                "spec": {
366                    "containers": [{
367                      "name": "blog",
368                      "image": "clux/blog:0.1.0"
369                    }],
370                }
371            }))
372            .unwrap();
373            p
374        };
375
376        // Simulate: create (gen=1, uid=1) -> update (gen=1, uid=1) -> delete ->
377        // create (gen=1, uid=2) -> delete -> create (gen=2, uid=3)
378        let data = stream::iter([
379            Ok(mkobj(1, "uid-1")),
380            Ok(mkobj(1, "uid-1")),
381            Ok(mkobj(1, "uid-2")),
382            Ok(mkobj(2, "uid-3")),
383        ]);
384        let mut rx = pin!(PredicateFilter::new(
385            data,
386            predicates::generation,
387            Config::default()
388        ));
389
390        // mkobj(1, uid-1) passed through
391        let first = rx.next().now_or_never().unwrap().unwrap().unwrap();
392        assert_eq!(first.meta().generation, Some(1));
393        assert_eq!(first.meta().uid.as_deref(), Some("uid-1"));
394
395        // (no repeat mkobj(1, uid-1) - same UID and generation)
396        // mkobj(1, uid-2) next - different UID detected
397        let second = rx.next().now_or_never().unwrap().unwrap().unwrap();
398        assert_eq!(second.meta().generation, Some(1));
399        assert_eq!(second.meta().uid.as_deref(), Some("uid-2"));
400
401        // mkobj(2, uid-3) next
402        let third = rx.next().now_or_never().unwrap().unwrap().unwrap();
403        assert_eq!(third.meta().generation, Some(2));
404        assert_eq!(third.meta().uid.as_deref(), Some("uid-3"));
405
406        assert!(matches!(poll!(rx.next()), Poll::Ready(None)));
407    }
408
409    #[tokio::test]
410    async fn predicate_cache_ttl_evicts_expired_entries() {
411        use futures::{SinkExt, channel::mpsc};
412        use k8s_openapi::api::core::v1::Pod;
413        use std::time::Duration;
414
415        let mkobj = |g: i32, uid: &str| {
416            let p: Pod = serde_json::from_value(json!({
417                "apiVersion": "v1",
418                "kind": "Pod",
419                "metadata": {
420                    "name": "blog",
421                    "namespace": "default",
422                    "generation": Some(g),
423                    "uid": uid,
424                },
425                "spec": {
426                    "containers": [{
427                      "name": "blog",
428                      "image": "clux/blog:0.1.0"
429                    }],
430                }
431            }))
432            .unwrap();
433            p
434        };
435
436        // Use a very short TTL for testing
437        let config = Config::default().ttl(Duration::from_millis(50));
438
439        // Use a channel so we can send items with delays
440        let (mut tx, rx) = mpsc::unbounded();
441        let mut filtered = pin!(PredicateFilter::new(
442            rx.map(Ok::<_, Error>),
443            predicates::generation,
444            config
445        ));
446
447        // Send first object
448        tx.send(mkobj(1, "uid-1")).await.unwrap();
449        let first = filtered.next().now_or_never().unwrap().unwrap().unwrap();
450        assert_eq!(first.meta().generation, Some(1));
451
452        // Send same object immediately - should be filtered
453        tx.send(mkobj(1, "uid-1")).await.unwrap();
454        assert!(matches!(poll!(filtered.next()), Poll::Pending));
455
456        // Wait for TTL to expire
457        tokio::time::sleep(Duration::from_millis(100)).await;
458
459        // Send same object after TTL - should pass through due to eviction
460        tx.send(mkobj(1, "uid-1")).await.unwrap();
461        let second = filtered.next().now_or_never().unwrap().unwrap().unwrap();
462        assert_eq!(second.meta().generation, Some(1));
463    }
464}