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    /// The alternate predicate is only used when the first returns `None`, not when its
51    /// hash is unchanged. In particular, `generation.fallback(resource_version)` can still
52    /// suppress deletions for objects with an unchanged generation.
53    ///
54    /// Generation support generally depends on the resource type: Deployments and custom
55    /// resources normally populate it, while `ConfigMaps` and Secrets normally do not.
56    /// Thus, `generation.fallback(resource_version)` is useful as a shared policy across
57    /// resource types, but the fallback is redundant for a type known to always provide
58    /// generation. Generation is not normally a field that appears and disappears over
59    /// an object's lifetime, though support can vary by API implementation and version.
60    ///
61    /// Persisted objects returned by ordinary API-server list/watch requests have a
62    /// resource version, so reversing the order to `resource_version.fallback(generation)`
63    /// generally makes the generation fallback redundant. Locally constructed objects
64    /// and non-persisted API responses need not have a resource version.
65    ///
66    /// # Usage
67    ///
68    /// ```
69    /// # use k8s_openapi::api::core::v1::Pod;
70    /// use kube::runtime::{predicates, Predicate};
71    /// # fn blah<K>(a: impl Predicate<K>) {}
72    /// let pred = predicates::generation.fallback(predicates::resource_version);
73    /// blah::<Pod>(pred);
74    /// ```
75    fn fallback<F: Predicate<K>>(self, f: F) -> Fallback<Self, F>
76    where
77        Self: Sized,
78    {
79        Fallback(self, f)
80    }
81
82    /// Returns a `Predicate` that combines all available hashes
83    ///
84    /// # Usage
85    ///
86    /// ```
87    /// # use k8s_openapi::api::core::v1::Pod;
88    /// use kube::runtime::{predicates, Predicate};
89    /// # fn blah<K>(a: impl Predicate<K>) {}
90    /// let pred = predicates::labels.combine(predicates::annotations);
91    /// blah::<Pod>(pred);
92    /// ```
93    fn combine<F: Predicate<K>>(self, f: F) -> Combine<Self, F>
94    where
95        Self: Sized,
96    {
97        Combine(self, f)
98    }
99}
100
101impl<K, F: Fn(&K) -> Option<u64>> Predicate<K> for F {
102    fn hash_property(&self, obj: &K) -> Option<u64> {
103        (self)(obj)
104    }
105}
106
107/// See [`Predicate::fallback`]
108#[derive(Copy, Clone, Debug, PartialEq, Eq)]
109pub struct Fallback<A, B>(pub(super) A, pub(super) B);
110impl<A, B, K> Predicate<K> for Fallback<A, B>
111where
112    A: Predicate<K>,
113    B: Predicate<K>,
114{
115    fn hash_property(&self, obj: &K) -> Option<u64> {
116        self.0.hash_property(obj).or_else(|| self.1.hash_property(obj))
117    }
118}
119/// See [`Predicate::combine`]
120#[derive(Copy, Clone, Debug, PartialEq, Eq)]
121pub struct Combine<A, B>(pub(super) A, pub(super) B);
122impl<A, B, K> Predicate<K> for Combine<A, B>
123where
124    A: Predicate<K>,
125    B: Predicate<K>,
126{
127    fn hash_property(&self, obj: &K) -> Option<u64> {
128        match (self.0.hash_property(obj), self.1.hash_property(obj)) {
129            // pass on both missing properties so people can chain .fallback
130            (None, None) => None,
131            // but any other combination of properties are hashed together
132            (a, b) => Some(hash(&(a, b))),
133        }
134    }
135}
136
137/// Configuration for predicate filtering with cache TTL
138#[derive(Debug, Clone)]
139pub struct Config {
140    /// Time-to-live for cache entries
141    ///
142    /// Entries not seen for at least this long will be evicted from the cache.
143    /// Default is 1 hour.
144    ttl: Duration,
145}
146
147impl Config {
148    /// Set the time-to-live for cache entries
149    ///
150    /// Entries not seen for at least this long will be evicted from the cache.
151    #[must_use]
152    pub fn ttl(mut self, ttl: Duration) -> Self {
153        self.ttl = ttl;
154        self
155    }
156}
157
158impl Default for Config {
159    fn default() -> Self {
160        Self {
161            // Default to 1 hour TTL - long enough to avoid unnecessary reconciles
162            // but short enough to prevent unbounded memory growth
163            ttl: Duration::from_secs(3600),
164        }
165    }
166}
167
168/// Cache entry storing predicate hash and last access time
169#[derive(Debug, Clone)]
170struct CacheEntry {
171    hash: u64,
172    last_seen: Instant,
173}
174
175#[pin_project]
176/// Stream returned by the [`predicate_filter`](super::WatchStreamExt::predicate_filter) method.
177#[must_use = "streams do nothing unless polled"]
178pub struct PredicateFilter<St, K: Resource, P: Predicate<K>> {
179    #[pin]
180    stream: St,
181    predicate: P,
182    cache: HashMap<PredicateCacheKey, CacheEntry>,
183    config: Config,
184    last_evicted: Instant,
185    // K: Resource necessary to get .meta() of an object during polling
186    _phantom: PhantomData<K>,
187}
188impl<St, K, P> PredicateFilter<St, K, P>
189where
190    St: Stream<Item = Result<K, Error>>,
191    K: Resource,
192    P: Predicate<K>,
193{
194    pub(super) fn new(stream: St, predicate: P, config: Config) -> Self {
195        Self {
196            stream,
197            predicate,
198            cache: HashMap::new(),
199            config,
200            last_evicted: Instant::now(),
201            _phantom: PhantomData,
202        }
203    }
204}
205impl<St, K, P> Stream for PredicateFilter<St, K, P>
206where
207    St: Stream<Item = Result<K, Error>>,
208    K: Resource,
209    P: Predicate<K>,
210{
211    type Item = Result<K, Error>;
212
213    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
214        let mut me = self.project();
215
216        let now = Instant::now();
217        let ttl = me.config.ttl;
218        // Reclaim expired entries periodically instead of on every poll. Per-entry
219        // freshness is enforced below, so this scan is only memory hygiene and does
220        // not affect which events are emitted.
221        if now.duration_since(*me.last_evicted) >= ttl {
222            me.cache
223                .retain(|_, entry| now.duration_since(entry.last_seen) < ttl);
224            *me.last_evicted = now;
225        }
226
227        Poll::Ready(loop {
228            break match ready!(me.stream.as_mut().poll_next(cx)) {
229                Some(Ok(obj)) => {
230                    if let Some(val) = me.predicate.hash_property(&obj) {
231                        let key = PredicateCacheKey::from(obj.meta());
232
233                        // Upsert in a single lookup. An entry older than the TTL is
234                        // treated as absent, matching the evict-before-every-poll
235                        // semantics now that eviction is amortized.
236                        let changed = match me.cache.entry(key) {
237                            Entry::Occupied(mut e) => {
238                                let stale = now.duration_since(e.get().last_seen) >= ttl;
239                                let changed = stale || e.get().hash != val;
240                                e.insert(CacheEntry {
241                                    hash: val,
242                                    last_seen: now,
243                                });
244                                changed
245                            }
246                            Entry::Vacant(e) => {
247                                e.insert(CacheEntry {
248                                    hash: val,
249                                    last_seen: now,
250                                });
251                                true
252                            }
253                        };
254
255                        if changed {
256                            Some(Ok(obj))
257                        } else {
258                            continue;
259                        }
260                    } else {
261                        // if we can't evaluate predicate, always emit K
262                        Some(Ok(obj))
263                    }
264                }
265                Some(Err(err)) => Some(Err(err)),
266                None => return Poll::Ready(None),
267            };
268        })
269    }
270}
271
272/// Predicate functions for [`WatchStreamExt::predicate_filter`](crate::WatchStreamExt::predicate_filter)
273///
274/// These functions just return a hash of commonly compared values,
275/// to help decide whether to pass a watch event along or not.
276///
277/// # Choosing a predicate
278///
279/// | Predicate | Use case | Deletion behavior |
280/// | --- | --- | --- |
281/// | [`generation`] | Observe desired-state changes while ignoring status-only updates, for resources supporting generation | Can suppress deletion-related updates and deleted objects when generation is unchanged |
282/// | [`resource_version`] | Observe object revisions, including status-only changes | Retains observed deletes whose resource version differs from the cached version |
283/// | [`labels`], [`annotations`], [`finalizers`] | Observe changes to those fields | Can suppress deletions when the selected fields are unchanged |
284/// | [`fallback`](crate::Predicate::fallback) | Use another predicate when the first has no value | See [`Predicate::fallback`](crate::Predicate::fallback) for the deletion caveat |
285///
286/// # Deletion handling
287///
288/// Predicates operate on decoded objects (versus Events).
289/// After [`touched_objects`](crate::WatchStreamExt::touched_objects), an object from a
290/// delete event is filtered using the same rules as any other object. Deletion does not
291/// necessarily change `metadata.generation` or populate `metadata.deletionTimestamp`,
292/// so predicates based on unchanged fields can suppress that object.
293///
294/// Use [`resource_version`] when observed deletions should trigger reconciliation, for
295/// example to recreate a missing owned resource. The Kubernetes apiserver stamps the
296/// delete revision onto the deleted object. This predicate also passes status-only
297/// changes, so it does not provide the same filtering as [`generation`].
298///
299/// No predicate guarantees delivery of every deletion: events can be missed while a
300/// watcher is unavailable. Reconcilers should check current desired and actual state,
301/// and use a recovery mechanism such as periodic requeues when missed triggers matter.
302/// Use [finalizers](crate::finalizer::finalizer) for cleanup that must complete before
303/// deletion. The initial deletion marking bumps a positive generation, so a generation-only
304/// predicate lets that update through to start cleanup. It can still suppress the final
305/// delete event and the update that adds the finalizer, since adding a finalizer leaves
306/// generation unchanged. See the [finalizer documentation](crate::finalizer::finalizer).
307///
308/// Functional rewrite of the [controller-runtime/predicate module](https://github.com/kubernetes-sigs/controller-runtime/blob/main/pkg/predicate/predicate.go).
309pub mod predicates {
310    use super::hash;
311    use kube_client::{Resource, ResourceExt};
312
313    /// Hash the generation of a Resource K
314    ///
315    /// Useful for ignoring status-only updates on resources that support generation.
316    ///
317    /// Note that the final delete event need not change generation, so this can suppress
318    /// objects from delete events. See the [module documentation](crate::predicates).
319    pub fn generation<K: Resource>(obj: &K) -> Option<u64> {
320        obj.meta().generation.map(|g| hash(&g))
321    }
322
323    /// Hash the resource version of a Resource K
324    ///
325    /// Use this when observed deletions should trigger reconciliation: the Kubernetes
326    /// apiserver stamps the delete revision onto the deleted object. Status-only changes
327    /// also change resource version and pass this filter. This does not guarantee that
328    /// every deletion is observed; see the [module documentation](crate::predicates).
329    pub fn resource_version<K: Resource>(obj: &K) -> Option<u64> {
330        obj.meta().resource_version.as_ref().map(hash)
331    }
332
333    /// Hash the labels of a Resource K
334    pub fn labels<K: Resource>(obj: &K) -> Option<u64> {
335        Some(hash(obj.labels()))
336    }
337
338    /// Hash the annotations of a Resource K
339    pub fn annotations<K: Resource>(obj: &K) -> Option<u64> {
340        Some(hash(obj.annotations()))
341    }
342
343    /// Hash the finalizers of a Resource K
344    pub fn finalizers<K: Resource>(obj: &K) -> Option<u64> {
345        Some(hash(obj.finalizers()))
346    }
347}
348
349#[cfg(test)]
350pub(crate) mod tests {
351    use std::{pin::pin, task::Poll};
352
353    use super::{Config, Error, PredicateFilter, predicates};
354    use futures::{FutureExt, StreamExt, poll, stream};
355    use kube_client::Resource;
356    use serde_json::json;
357
358    #[tokio::test]
359    async fn predicate_filtering_hides_equal_predicate_values() {
360        use k8s_openapi::api::core::v1::Pod;
361        let mkobj = |g: i32| {
362            let p: Pod = serde_json::from_value(json!({
363                "apiVersion": "v1",
364                "kind": "Pod",
365                "metadata": {
366                    "name": "blog",
367                    "generation": Some(g),
368                },
369                "spec": {
370                    "containers": [{
371                      "name": "blog",
372                      "image": "clux/blog:0.1.0"
373                    }],
374                }
375            }))
376            .unwrap();
377            p
378        };
379        let data = stream::iter([
380            Ok(mkobj(1)),
381            Err(Error::NoResourceVersion),
382            Ok(mkobj(1)),
383            Ok(mkobj(2)),
384        ]);
385        let mut rx = pin!(PredicateFilter::new(
386            data,
387            predicates::generation,
388            Config::default()
389        ));
390
391        // mkobj(1) passed through
392        let first = rx.next().now_or_never().unwrap().unwrap().unwrap();
393        assert_eq!(first.meta().generation, Some(1));
394
395        // Error passed through
396        assert!(matches!(
397            poll!(rx.next()),
398            Poll::Ready(Some(Err(Error::NoResourceVersion)))
399        ));
400        // (no repeat mkobj(1) - same generation)
401        // mkobj(2) next
402        let second = rx.next().now_or_never().unwrap().unwrap().unwrap();
403        assert_eq!(second.meta().generation, Some(2));
404        assert!(matches!(poll!(rx.next()), Poll::Ready(None)));
405    }
406
407    #[tokio::test]
408    async fn predicate_filtering_should_handle_recreated_resources_with_same_generation() {
409        use k8s_openapi::api::core::v1::Pod;
410
411        let mkobj = |g: i32, uid: &str| {
412            let p: Pod = serde_json::from_value(json!({
413                "apiVersion": "v1",
414                "kind": "Pod",
415                "metadata": {
416                    "name": "blog",
417                    "namespace": "default",
418                    "generation": Some(g),
419                    "uid": uid,
420                },
421                "spec": {
422                    "containers": [{
423                      "name": "blog",
424                      "image": "clux/blog:0.1.0"
425                    }],
426                }
427            }))
428            .unwrap();
429            p
430        };
431
432        // Simulate: create (gen=1, uid=1) -> update (gen=1, uid=1) -> delete ->
433        // create (gen=1, uid=2) -> delete -> create (gen=2, uid=3)
434        let data = stream::iter([
435            Ok(mkobj(1, "uid-1")),
436            Ok(mkobj(1, "uid-1")),
437            Ok(mkobj(1, "uid-2")),
438            Ok(mkobj(2, "uid-3")),
439        ]);
440        let mut rx = pin!(PredicateFilter::new(
441            data,
442            predicates::generation,
443            Config::default()
444        ));
445
446        // mkobj(1, uid-1) passed through
447        let first = rx.next().now_or_never().unwrap().unwrap().unwrap();
448        assert_eq!(first.meta().generation, Some(1));
449        assert_eq!(first.meta().uid.as_deref(), Some("uid-1"));
450
451        // (no repeat mkobj(1, uid-1) - same UID and generation)
452        // mkobj(1, uid-2) next - different UID detected
453        let second = rx.next().now_or_never().unwrap().unwrap().unwrap();
454        assert_eq!(second.meta().generation, Some(1));
455        assert_eq!(second.meta().uid.as_deref(), Some("uid-2"));
456
457        // mkobj(2, uid-3) next
458        let third = rx.next().now_or_never().unwrap().unwrap().unwrap();
459        assert_eq!(third.meta().generation, Some(2));
460        assert_eq!(third.meta().uid.as_deref(), Some("uid-3"));
461
462        assert!(matches!(poll!(rx.next()), Poll::Ready(None)));
463    }
464
465    #[tokio::test]
466    async fn predicate_cache_ttl_evicts_expired_entries() {
467        use futures::{SinkExt, channel::mpsc};
468        use k8s_openapi::api::core::v1::Pod;
469        use std::time::Duration;
470
471        let mkobj = |g: i32, uid: &str| {
472            let p: Pod = serde_json::from_value(json!({
473                "apiVersion": "v1",
474                "kind": "Pod",
475                "metadata": {
476                    "name": "blog",
477                    "namespace": "default",
478                    "generation": Some(g),
479                    "uid": uid,
480                },
481                "spec": {
482                    "containers": [{
483                      "name": "blog",
484                      "image": "clux/blog:0.1.0"
485                    }],
486                }
487            }))
488            .unwrap();
489            p
490        };
491
492        // Use a very short TTL for testing
493        let config = Config::default().ttl(Duration::from_millis(50));
494
495        // Use a channel so we can send items with delays
496        let (mut tx, rx) = mpsc::unbounded();
497        let mut filtered = pin!(PredicateFilter::new(
498            rx.map(Ok::<_, Error>),
499            predicates::generation,
500            config
501        ));
502
503        // Send first object
504        tx.send(mkobj(1, "uid-1")).await.unwrap();
505        let first = filtered.next().now_or_never().unwrap().unwrap().unwrap();
506        assert_eq!(first.meta().generation, Some(1));
507
508        // Send same object immediately - should be filtered
509        tx.send(mkobj(1, "uid-1")).await.unwrap();
510        assert!(matches!(poll!(filtered.next()), Poll::Pending));
511
512        // Wait for TTL to expire
513        tokio::time::sleep(Duration::from_millis(100)).await;
514
515        // Send same object after TTL - should pass through due to eviction
516        tx.send(mkobj(1, "uid-1")).await.unwrap();
517        let second = filtered.next().now_or_never().unwrap().unwrap().unwrap();
518        assert_eq!(second.meta().generation, Some(1));
519    }
520
521    #[tokio::test]
522    async fn predicate_filter_compiles_with_dynamic_resource() {
523        use kube::api::DynamicObject;
524
525        let obj: DynamicObject = serde_json::from_value(json!({
526            "apiVersion": "v1",
527            "kind": "Pod",
528            "metadata": {
529                "name": "blog",
530                "generation": Some(1),
531            },
532        }))
533        .unwrap();
534        let data = stream::iter([Ok(obj)]);
535        let mut rx = pin!(PredicateFilter::new(
536            data,
537            predicates::generation,
538            Config::default()
539        ));
540
541        let first = rx.next().now_or_never().unwrap().unwrap().unwrap();
542        assert_eq!(first.meta().generation, Some(1));
543
544        // Mostly, this check is ensuring that predicate filters compile with
545        // `DynamicObject`, not testing any behaviour.
546    }
547}