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#[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
43pub trait Predicate<K> {
45 fn hash_property(&self, obj: &K) -> Option<u64>;
47
48 fn fallback<F: Predicate<K>>(self, f: F) -> Fallback<Self, F>
60 where
61 Self: Sized,
62 {
63 Fallback(self, f)
64 }
65
66 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#[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#[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 (None, None) => None,
115 (a, b) => Some(hash(&(a, b))),
117 }
118 }
119}
120
121#[derive(Debug, Clone)]
123pub struct Config {
124 ttl: Duration,
129}
130
131impl Config {
132 #[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 ttl: Duration::from_secs(3600),
148 }
149 }
150}
151
152#[derive(Debug, Clone)]
154struct CacheEntry {
155 hash: u64,
156 last_seen: Instant,
157}
158
159#[pin_project]
160#[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 _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 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 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 Some(Ok(obj))
248 }
249 }
250 Some(Err(err)) => Some(Err(err)),
251 None => return Poll::Ready(None),
252 };
253 })
254 }
255}
256
257pub mod predicates {
264 use super::hash;
265 use kube_client::{Resource, ResourceExt};
266
267 pub fn generation<K: Resource>(obj: &K) -> Option<u64> {
269 obj.meta().generation.map(|g| hash(&g))
270 }
271
272 pub fn resource_version<K: Resource>(obj: &K) -> Option<u64> {
274 obj.meta().resource_version.as_ref().map(hash)
275 }
276
277 pub fn labels<K: Resource>(obj: &K) -> Option<u64> {
279 Some(hash(obj.labels()))
280 }
281
282 pub fn annotations<K: Resource>(obj: &K) -> Option<u64> {
284 Some(hash(obj.annotations()))
285 }
286
287 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 let first = rx.next().now_or_never().unwrap().unwrap().unwrap();
337 assert_eq!(first.meta().generation, Some(1));
338
339 assert!(matches!(
341 poll!(rx.next()),
342 Poll::Ready(Some(Err(Error::NoResourceVersion)))
343 ));
344 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 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 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 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 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 let config = Config::default().ttl(Duration::from_millis(50));
438
439 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 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 tx.send(mkobj(1, "uid-1")).await.unwrap();
454 assert!(matches!(poll!(filtered.next()), Poll::Pending));
455
456 tokio::time::sleep(Duration::from_millis(100)).await;
458
459 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}