Skip to content

Commit fb3ab52

Browse files
committed
feat: distict latest resource collector
Signed-off-by: Attila Mészáros <a_meszaros@apple.com>
1 parent f852af5 commit fb3ab52

File tree

2 files changed

+320
-0
lines changed

2 files changed

+320
-0
lines changed

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtils.java

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,16 @@
1616
package io.javaoperatorsdk.operator.api.reconciler;
1717

1818
import java.lang.reflect.InvocationTargetException;
19+
import java.util.ArrayList;
20+
import java.util.Collection;
21+
import java.util.HashSet;
22+
import java.util.List;
23+
import java.util.Map;
24+
import java.util.Set;
1925
import java.util.function.Predicate;
2026
import java.util.function.UnaryOperator;
27+
import java.util.stream.Collector;
28+
import java.util.stream.Collectors;
2129

2230
import org.slf4j.Logger;
2331
import org.slf4j.LoggerFactory;
@@ -714,4 +722,47 @@ private static int validateResourceVersion(String v1) {
714722
}
715723
return v1Length;
716724
}
725+
726+
/**
727+
* Returns a collector that deduplicates Kubernetes objects by keeping only the one with the
728+
* latest metadata.resourceVersion for each unique name and namespace combination.
729+
*
730+
* @param <T> the type of HasMetadata objects
731+
* @return a collector that produces a collection of deduplicated Kubernetes objects
732+
*/
733+
public static <T extends HasMetadata> Collector<T, ?, Collection<T>> latestDistinct() {
734+
return Collectors.collectingAndThen(latestDistinctToMap(), Map::values);
735+
}
736+
737+
/**
738+
* Returns a collector that deduplicates Kubernetes objects by keeping only the one with the
739+
* latest metadata.resourceVersion for each unique name and namespace combination.
740+
*
741+
* @param <T> the type of HasMetadata objects
742+
* @return a collector that produces a List of deduplicated Kubernetes objects
743+
*/
744+
public static <T extends HasMetadata> Collector<T, ?, List<T>> latestDistinctList() {
745+
return Collectors.collectingAndThen(
746+
latestDistinctToMap(), map -> new ArrayList<>(map.values()));
747+
}
748+
749+
/**
750+
* Returns a collector that deduplicates Kubernetes objects by keeping only the one with the
751+
* latest metadata.resourceVersion for each unique name and namespace combination.
752+
*
753+
* @param <T> the type of HasMetadata objects
754+
* @return a collector that produces a Set of deduplicated Kubernetes objects
755+
*/
756+
public static <T extends HasMetadata> Collector<T, ?, Set<T>> latestDistinctSet() {
757+
return Collectors.collectingAndThen(latestDistinctToMap(), map -> new HashSet<>(map.values()));
758+
}
759+
760+
private static <T extends HasMetadata> Collector<T, ?, Map<ResourceID, T>> latestDistinctToMap() {
761+
return Collectors.toMap(
762+
resource ->
763+
new ResourceID(resource.getMetadata().getName(), resource.getMetadata().getNamespace()),
764+
resource -> resource,
765+
(existing, replacement) ->
766+
compareResourceVersions(existing, replacement) >= 0 ? existing : replacement);
767+
}
717768
}

operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtilsTest.java

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,12 @@
1515
*/
1616
package io.javaoperatorsdk.operator.api.reconciler;
1717

18+
import java.util.Collection;
1819
import java.util.Collections;
1920
import java.util.List;
21+
import java.util.Set;
2022
import java.util.function.UnaryOperator;
23+
import java.util.stream.Stream;
2124

2225
import org.junit.jupiter.api.BeforeEach;
2326
import org.junit.jupiter.api.Disabled;
@@ -27,6 +30,7 @@
2730

2831
import io.fabric8.kubernetes.api.model.HasMetadata;
2932
import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
33+
import io.fabric8.kubernetes.api.model.Pod;
3034
import io.fabric8.kubernetes.api.model.PodBuilder;
3135
import io.fabric8.kubernetes.client.KubernetesClient;
3236
import io.fabric8.kubernetes.client.KubernetesClientException;
@@ -440,6 +444,271 @@ void resourcePatchThrowsWhenEventSourceIsNotManagedInformer() {
440444
assertThat(exception.getMessage()).contains("ManagedInformerEventSource");
441445
}
442446

447+
@Test
448+
void latestDistinctKeepsOnlyLatestResourceVersion() {
449+
// Create multiple resources with same name and namespace but different versions
450+
HasMetadata pod1v1 =
451+
new PodBuilder()
452+
.withMetadata(
453+
new ObjectMetaBuilder()
454+
.withName("pod1")
455+
.withNamespace("default")
456+
.withResourceVersion("100")
457+
.build())
458+
.build();
459+
460+
HasMetadata pod1v2 =
461+
new PodBuilder()
462+
.withMetadata(
463+
new ObjectMetaBuilder()
464+
.withName("pod1")
465+
.withNamespace("default")
466+
.withResourceVersion("200")
467+
.build())
468+
.build();
469+
470+
HasMetadata pod1v3 =
471+
new PodBuilder()
472+
.withMetadata(
473+
new ObjectMetaBuilder()
474+
.withName("pod1")
475+
.withNamespace("default")
476+
.withResourceVersion("150")
477+
.build())
478+
.build();
479+
480+
// Create a resource with different name
481+
HasMetadata pod2v1 =
482+
new PodBuilder()
483+
.withMetadata(
484+
new ObjectMetaBuilder()
485+
.withName("pod2")
486+
.withNamespace("default")
487+
.withResourceVersion("100")
488+
.build())
489+
.build();
490+
491+
// Create a resource with same name but different namespace
492+
HasMetadata pod1OtherNsv1 =
493+
new PodBuilder()
494+
.withMetadata(
495+
new ObjectMetaBuilder()
496+
.withName("pod1")
497+
.withNamespace("other")
498+
.withResourceVersion("50")
499+
.build())
500+
.build();
501+
502+
Collection<HasMetadata> result =
503+
Stream.of(pod1v1, pod1v2, pod1v3, pod2v1, pod1OtherNsv1)
504+
.collect(ReconcileUtils.latestDistinct());
505+
506+
// Should have 3 resources: pod1 in default (latest version 200), pod2 in default, and pod1 in
507+
// other
508+
assertThat(result).hasSize(3);
509+
510+
// Find pod1 in default namespace - should have version 200
511+
HasMetadata pod1InDefault =
512+
result.stream()
513+
.filter(
514+
r ->
515+
"pod1".equals(r.getMetadata().getName())
516+
&& "default".equals(r.getMetadata().getNamespace()))
517+
.findFirst()
518+
.orElseThrow();
519+
assertThat(pod1InDefault.getMetadata().getResourceVersion()).isEqualTo("200");
520+
521+
// Find pod2 in default namespace - should exist
522+
HasMetadata pod2InDefault =
523+
result.stream()
524+
.filter(
525+
r ->
526+
"pod2".equals(r.getMetadata().getName())
527+
&& "default".equals(r.getMetadata().getNamespace()))
528+
.findFirst()
529+
.orElseThrow();
530+
assertThat(pod2InDefault.getMetadata().getResourceVersion()).isEqualTo("100");
531+
532+
// Find pod1 in other namespace - should exist
533+
HasMetadata pod1InOther =
534+
result.stream()
535+
.filter(
536+
r ->
537+
"pod1".equals(r.getMetadata().getName())
538+
&& "other".equals(r.getMetadata().getNamespace()))
539+
.findFirst()
540+
.orElseThrow();
541+
assertThat(pod1InOther.getMetadata().getResourceVersion()).isEqualTo("50");
542+
}
543+
544+
@Test
545+
void latestDistinctHandlesEmptyStream() {
546+
Collection<HasMetadata> result =
547+
Stream.<HasMetadata>empty().collect(ReconcileUtils.latestDistinct());
548+
549+
assertThat(result).isEmpty();
550+
}
551+
552+
@Test
553+
void latestDistinctHandlesSingleResource() {
554+
HasMetadata pod =
555+
new PodBuilder()
556+
.withMetadata(
557+
new ObjectMetaBuilder()
558+
.withName("pod1")
559+
.withNamespace("default")
560+
.withResourceVersion("100")
561+
.build())
562+
.build();
563+
564+
Collection<HasMetadata> result = Stream.of(pod).collect(ReconcileUtils.latestDistinct());
565+
566+
assertThat(result).hasSize(1);
567+
assertThat(result).contains(pod);
568+
}
569+
570+
@Test
571+
void latestDistinctComparesNumericVersionsCorrectly() {
572+
// Test that version 1000 is greater than version 999 (not lexicographic)
573+
HasMetadata podV999 =
574+
new PodBuilder()
575+
.withMetadata(
576+
new ObjectMetaBuilder()
577+
.withName("pod1")
578+
.withNamespace("default")
579+
.withResourceVersion("999")
580+
.build())
581+
.build();
582+
583+
HasMetadata podV1000 =
584+
new PodBuilder()
585+
.withMetadata(
586+
new ObjectMetaBuilder()
587+
.withName("pod1")
588+
.withNamespace("default")
589+
.withResourceVersion("1000")
590+
.build())
591+
.build();
592+
593+
Collection<HasMetadata> result =
594+
Stream.of(podV999, podV1000).collect(ReconcileUtils.latestDistinct());
595+
596+
assertThat(result).hasSize(1);
597+
HasMetadata resultPod = result.iterator().next();
598+
assertThat(resultPod.getMetadata().getResourceVersion()).isEqualTo("1000");
599+
}
600+
601+
@Test
602+
void latestDistinctListReturnsListType() {
603+
Pod pod1v1 =
604+
new PodBuilder()
605+
.withMetadata(
606+
new ObjectMetaBuilder()
607+
.withName("pod1")
608+
.withNamespace("default")
609+
.withResourceVersion("100")
610+
.build())
611+
.build();
612+
613+
Pod pod1v2 =
614+
new PodBuilder()
615+
.withMetadata(
616+
new ObjectMetaBuilder()
617+
.withName("pod1")
618+
.withNamespace("default")
619+
.withResourceVersion("200")
620+
.build())
621+
.build();
622+
623+
Pod pod2v1 =
624+
new PodBuilder()
625+
.withMetadata(
626+
new ObjectMetaBuilder()
627+
.withName("pod2")
628+
.withNamespace("default")
629+
.withResourceVersion("100")
630+
.build())
631+
.build();
632+
633+
List<Pod> result =
634+
Stream.of(pod1v1, pod1v2, pod2v1).collect(ReconcileUtils.latestDistinctList());
635+
636+
assertThat(result).isInstanceOf(List.class);
637+
assertThat(result).hasSize(2);
638+
639+
// Verify the list contains the correct resources
640+
Pod pod1 =
641+
result.stream()
642+
.filter(r -> "pod1".equals(r.getMetadata().getName()))
643+
.findFirst()
644+
.orElseThrow();
645+
assertThat(pod1.getMetadata().getResourceVersion()).isEqualTo("200");
646+
}
647+
648+
@Test
649+
void latestDistinctSetReturnsSetType() {
650+
Pod pod1v1 =
651+
new PodBuilder()
652+
.withMetadata(
653+
new ObjectMetaBuilder()
654+
.withName("pod1")
655+
.withNamespace("default")
656+
.withResourceVersion("100")
657+
.build())
658+
.build();
659+
660+
Pod pod1v2 =
661+
new PodBuilder()
662+
.withMetadata(
663+
new ObjectMetaBuilder()
664+
.withName("pod1")
665+
.withNamespace("default")
666+
.withResourceVersion("200")
667+
.build())
668+
.build();
669+
670+
Pod pod2v1 =
671+
new PodBuilder()
672+
.withMetadata(
673+
new ObjectMetaBuilder()
674+
.withName("pod2")
675+
.withNamespace("default")
676+
.withResourceVersion("100")
677+
.build())
678+
.build();
679+
680+
Set<Pod> result = Stream.of(pod1v1, pod1v2, pod2v1).collect(ReconcileUtils.latestDistinctSet());
681+
682+
assertThat(result).isInstanceOf(java.util.Set.class);
683+
assertThat(result).hasSize(2);
684+
685+
// Verify the set contains the correct resources
686+
Pod pod1 =
687+
result.stream()
688+
.filter(r -> "pod1".equals(r.getMetadata().getName()))
689+
.findFirst()
690+
.orElseThrow();
691+
assertThat(pod1.getMetadata().getResourceVersion()).isEqualTo("200");
692+
}
693+
694+
@Test
695+
void latestDistinctListHandlesEmptyStream() {
696+
List<HasMetadata> result =
697+
Stream.<HasMetadata>empty().collect(ReconcileUtils.latestDistinctList());
698+
699+
assertThat(result).isEmpty();
700+
assertThat(result).isInstanceOf(List.class);
701+
}
702+
703+
@Test
704+
void latestDistinctSetHandlesEmptyStream() {
705+
Set<HasMetadata> result =
706+
Stream.<HasMetadata>empty().collect(ReconcileUtils.latestDistinctSet());
707+
708+
assertThat(result).isEmpty();
709+
assertThat(result).isInstanceOf(Set.class);
710+
}
711+
443712
// naive performance test that compares the work case scenario for the parsing and non-parsing
444713
// variants
445714
@Test

0 commit comments

Comments
 (0)