diff --git a/packaging/src/kubernetes/pom.xml b/packaging/src/kubernetes/pom.xml index f1d8bbb84e5c..999d5ebcf0dc 100644 --- a/packaging/src/kubernetes/pom.xml +++ b/packaging/src/kubernetes/pom.xml @@ -93,6 +93,37 @@ log4j-core ${log4j2.version} + + + org.junit.jupiter + junit-jupiter-api + ${junit.jupiter.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.jupiter.version} + test + + + org.junit.jupiter + junit-jupiter-params + ${junit.jupiter.version} + test + + + org.mockito + mockito-core + ${mockito-core.version} + test + + + org.mockito + mockito-junit-jupiter + ${mockito-core.version} + test + src/java diff --git a/packaging/src/kubernetes/src/java/org/apache/hive/kubernetes/operator/dependent/HiveDependentResource.java b/packaging/src/kubernetes/src/java/org/apache/hive/kubernetes/operator/dependent/HiveDependentResource.java index a64440acab8e..7e5944f0564f 100644 --- a/packaging/src/kubernetes/src/java/org/apache/hive/kubernetes/operator/dependent/HiveDependentResource.java +++ b/packaging/src/kubernetes/src/java/org/apache/hive/kubernetes/operator/dependent/HiveDependentResource.java @@ -62,6 +62,7 @@ import org.apache.hive.kubernetes.operator.model.spec.SecretKeyRef; import org.apache.hive.kubernetes.operator.model.spec.ProbeSpec; import org.apache.hive.kubernetes.operator.util.ConfigUtils; +import org.apache.hive.kubernetes.operator.util.Workloads; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -161,10 +162,11 @@ protected R handleCreate(R desired, P primary, Context

context) { } /** - * Resolves the replica count to set in the desired workload spec. + * Resolves the replica count to set in the desired workload spec, and logs it when it differs + * from what the workload has now. *

- * Always returns an explicit value — never null. Returning null would cause - * JOSDK/SSA to omit spec.replicas, and Kubernetes would default it to 1. + * Returns a primitive so the value can never be null: a null spec.replicas would make JOSDK/SSA + * omit the field, and Kubernetes would default it to 1. *

* When autoscaling is enabled: * - On CREATE: returns initialReplicas (minReplicas for the component) @@ -173,7 +175,20 @@ protected R handleCreate(R desired, P primary, Context

context) { *

* When autoscaling is disabled: returns staticReplicas (the spec value). */ - protected Integer resolveReplicaCount(P primary, Context

context, + protected int resolveReplicaCount(P primary, Context

context, + AutoscalingSpec autoscaling, int staticReplicas, int initialReplicas) { + Optional existing = getSecondaryResource(primary, context); + int resolved = computeReplicaCount(primary, existing, autoscaling, + staticReplicas, initialReplicas); + // Without this, every scale of an HS2/Metastore Deployment reached the cluster silently: + // only the imperative LLAP path and the autoscaler logged, and a bare "Reconciled" line + // said nothing about the size. + Workloads.logReplicaChange(LOG, getComponentName(), primary.getMetadata().getNamespace(), + getSecondaryResourceName(primary, context), existing.orElse(null), resolved); + return resolved; + } + + private int computeReplicaCount(P primary, Optional existing, AutoscalingSpec autoscaling, int staticReplicas, int initialReplicas) { // Suspended cluster → 0 replicas (dependent resources natively respect suspend). // Exception: HMS stays running if includeMetastore=false in autoSuspend config. @@ -186,7 +201,6 @@ protected Integer resolveReplicaCount(P primary, Context

context, if (autoscaling == null || !autoscaling.isEnabled()) { return staticReplicas; } - Optional existing = getSecondaryResource(primary, context); if (existing.isPresent()) { // Check if the autoscaler has made a decision during this operator's lifecycle Integer managed = HiveClusterAutoscaler.getManagedReplicas( @@ -196,23 +210,15 @@ protected Integer resolveReplicaCount(P primary, Context

context, if (managed != null) { return managed; } - // Fallback: operator restarted and MANAGED_REPLICAS is empty — read current value - R resource = existing.get(); - if (resource instanceof io.fabric8.kubernetes.api.model.apps.Deployment d) { - return d.getSpec() != null && d.getSpec().getReplicas() != null - ? d.getSpec().getReplicas() : initialReplicas; - } - if (resource instanceof io.fabric8.kubernetes.api.model.apps.StatefulSet s) { - return s.getSpec() != null && s.getSpec().getReplicas() != null - ? s.getSpec().getReplicas() : initialReplicas; - } - return initialReplicas; + // Fallback: operator restarted and MANAGED_REPLICAS is empty — read current value. The + // workload exists, so spec.replicas is set unless something wrote it away; initialReplicas + // is the floor either way. + return Workloads.replicas(existing.get()).orElse(initialReplicas); } // First creation: start at minReplicas. return initialReplicas; } - /** * Returns the component name for this dependent (used for autoscaler replica lookup). * Subclasses should override if they manage a workload with autoscaling. diff --git a/packaging/src/kubernetes/src/java/org/apache/hive/kubernetes/operator/dependent/HiveServer2DeploymentDependent.java b/packaging/src/kubernetes/src/java/org/apache/hive/kubernetes/operator/dependent/HiveServer2DeploymentDependent.java index 6bc6291fd1fc..a8e8d953dd7a 100644 --- a/packaging/src/kubernetes/src/java/org/apache/hive/kubernetes/operator/dependent/HiveServer2DeploymentDependent.java +++ b/packaging/src/kubernetes/src/java/org/apache/hive/kubernetes/operator/dependent/HiveServer2DeploymentDependent.java @@ -232,7 +232,7 @@ protected Deployment desired(HiveCluster hiveCluster, AutoscalingSpec hs2Autoscaling = hs2.autoscaling(); int initialReplicas = hs2Autoscaling != null && hs2Autoscaling.isEnabled() ? Math.max(1, hs2Autoscaling.minReplicas()) : hs2.replicas(); - Integer replicas = resolveReplicaCount( + int replicas = resolveReplicaCount( hiveCluster, context, hs2Autoscaling, hs2.replicas(), initialReplicas); Deployment deployment = new DeploymentBuilder() diff --git a/packaging/src/kubernetes/src/java/org/apache/hive/kubernetes/operator/dependent/LlapResourceBuilder.java b/packaging/src/kubernetes/src/java/org/apache/hive/kubernetes/operator/dependent/LlapResourceBuilder.java index a2c10d3688c4..71acf0db4814 100644 --- a/packaging/src/kubernetes/src/java/org/apache/hive/kubernetes/operator/dependent/LlapResourceBuilder.java +++ b/packaging/src/kubernetes/src/java/org/apache/hive/kubernetes/operator/dependent/LlapResourceBuilder.java @@ -60,6 +60,7 @@ import org.apache.hive.kubernetes.operator.util.HadoopXmlBuilder; import org.apache.hive.kubernetes.operator.util.HiveConfigBuilder; import org.apache.hive.kubernetes.operator.util.Labels; +import org.apache.hive.kubernetes.operator.util.Workloads; import static org.apache.hive.kubernetes.operator.autoscaling.MetricsScraper.isPodReady; @@ -74,7 +75,6 @@ public class LlapResourceBuilder extends HiveDependentResource { private static final LlapResourceBuilder INSTANCE = new LlapResourceBuilder(); - private static final String TEZAM_INFIX = "-tezam-"; private static final String HIVE_CONFIG_VOLUME = "hive-config"; private static final String LLAP_CONFIG_VOLUME = "llap-config"; @@ -108,7 +108,7 @@ private static OwnerReference ownerRef(HiveCluster hc) { /** Resource name for a specific LLAP cluster: {clusterName}-{llapName}. */ public static String resourceName(HiveCluster hc, LlapSpec llap) { - return hc.getMetadata().getName() + "-" + llap.name(); + return Workloads.nameFor(hc, ConfigUtils.llapComponentKey(llap.name())); } /** ConfigMap name for a specific LLAP cluster. */ @@ -208,17 +208,22 @@ public static PodDisruptionBudget buildPdb(HiveCluster hc, LlapSpec llap) { /** TezAM Deployment/Service name for a specific LLAP cluster. */ public static String tezAmResourceName(HiveCluster hc, LlapSpec llap) { - return hc.getMetadata().getName() + TEZAM_INFIX + llap.name(); + return tezAmResourceName(hc, llap.name()); + } + + /** TezAM Deployment/Service name from an LLAP cluster name (used where only the name is in hand). */ + public static String tezAmResourceName(HiveCluster hc, String llapName) { + return Workloads.nameFor(hc, ConfigUtils.tezAmComponentKey(llapName)); } /** TezAM ConfigMap name for a specific LLAP cluster. */ public static String tezAmConfigMapName(HiveCluster hc, LlapSpec llap) { - return hc.getMetadata().getName() + TEZAM_INFIX + llap.name() + "-config"; + return tezAmResourceName(hc, llap) + "-config"; } /** TezAM PDB name for a specific LLAP cluster. */ public static String tezAmPdbName(HiveCluster hc, LlapSpec llap) { - return hc.getMetadata().getName() + TEZAM_INFIX + llap.name() + "-pdb"; + return tezAmResourceName(hc, llap) + "-pdb"; } /** Builds the PodDisruptionBudget for a per-LLAP-cluster TezAM. */ diff --git a/packaging/src/kubernetes/src/java/org/apache/hive/kubernetes/operator/dependent/MetastoreDeploymentDependent.java b/packaging/src/kubernetes/src/java/org/apache/hive/kubernetes/operator/dependent/MetastoreDeploymentDependent.java index 73afedd9dbae..e72240899cf2 100644 --- a/packaging/src/kubernetes/src/java/org/apache/hive/kubernetes/operator/dependent/MetastoreDeploymentDependent.java +++ b/packaging/src/kubernetes/src/java/org/apache/hive/kubernetes/operator/dependent/MetastoreDeploymentDependent.java @@ -142,7 +142,7 @@ protected Deployment desired(HiveCluster hiveCluster, AutoscalingSpec msAutoscaling = spec.metastore().autoscaling(); int initialReplicas = msAutoscaling != null && msAutoscaling.isEnabled() ? Math.max(1, msAutoscaling.minReplicas()) : spec.metastore().replicas(); - Integer replicas = resolveReplicaCount( + int replicas = resolveReplicaCount( hiveCluster, context, msAutoscaling, spec.metastore().replicas(), initialReplicas); Deployment deployment = new DeploymentBuilder() diff --git a/packaging/src/kubernetes/src/java/org/apache/hive/kubernetes/operator/reconciler/HiveClusterReconciler.java b/packaging/src/kubernetes/src/java/org/apache/hive/kubernetes/operator/reconciler/HiveClusterReconciler.java index 557ebe99d545..c7f447ef0ac9 100644 --- a/packaging/src/kubernetes/src/java/org/apache/hive/kubernetes/operator/reconciler/HiveClusterReconciler.java +++ b/packaging/src/kubernetes/src/java/org/apache/hive/kubernetes/operator/reconciler/HiveClusterReconciler.java @@ -60,6 +60,7 @@ import org.apache.hive.kubernetes.operator.model.status.ComponentStatus; import org.apache.hive.kubernetes.operator.util.ConfigUtils; import org.apache.hive.kubernetes.operator.util.Labels; +import org.apache.hive.kubernetes.operator.util.Workloads; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -593,18 +594,7 @@ private static int getMinScrapeInterval(HiveClusterSpec spec) { private void patchReplicas(KubernetesClient client, HiveCluster resource, String component, int replicas) { String namespace = resource.getMetadata().getNamespace(); - // Component keys use prefixes: "llap-{name}" → workload "{cluster}-{name}", - // "tezam-{name}" → workload "{cluster}-tezam-{name}". - String workloadName; - if (component.startsWith(ConfigUtils.COMPONENT_LLAP + "-")) { - String llapName = component.substring(ConfigUtils.COMPONENT_LLAP.length() + 1); - workloadName = resource.getMetadata().getName() + "-" + llapName; - } else if (component.startsWith(ConfigUtils.COMPONENT_TEZAM + "-")) { - String llapName = component.substring(ConfigUtils.COMPONENT_TEZAM.length() + 1); - workloadName = resource.getMetadata().getName() + "-tezam-" + llapName; - } else { - workloadName = resource.getMetadata().getName() + "-" + component; - } + String workloadName = Workloads.nameFor(resource, component); try { if (component.startsWith(ConfigUtils.COMPONENT_LLAP + "-")) { client.apps().statefulSets().inNamespace(namespace).withName(workloadName).scale(replicas); @@ -617,6 +607,27 @@ private void patchReplicas(KubernetesClient client, HiveCluster resource, } } + /** + * Reads the workload's current replica count from the API server and hands it to + * {@link Workloads#logReplicaChange}, so an SSA-driven scale (a user editing + * spec.llapClusters[i].replicas, a helm upgrade rewriting it) logs the same line the dependents' + * SSA does instead of reaching the StatefulSet/Deployment silently. Only the read is local to + * this class; the message and the "log nothing when unchanged" rule are shared. Read failures + * are swallowed at DEBUG: the SSA below runs either way, and a missing pre-scale line is not + * worth failing the reconcile over. + */ + private void logReplicaChange(KubernetesClient client, String ns, String workloadName, + String component, int desired, boolean isStatefulSet) { + try { + HasMetadata current = isStatefulSet + ? client.apps().statefulSets().inNamespace(ns).withName(workloadName).get() + : client.apps().deployments().inNamespace(ns).withName(workloadName).get(); + Workloads.logReplicaChange(LOG, component, ns, workloadName, current, desired); + } catch (Exception e) { + LOG.debug("Could not read current replicas for {}/{}: {}", ns, workloadName, e.getMessage()); + } + } + private void patchSuspendSpec(KubernetesClient client, HiveCluster resource, boolean suspend) { String ns = resource.getMetadata().getNamespace(); String name = resource.getMetadata().getName(); @@ -668,6 +679,9 @@ private void reconcileLlapClusters(HiveCluster resource, KubernetesClient client // brief scale-up-then-down on first create (K8s defaults to 1 if omitted). // resolveLlapReplicaCount already reads the autoscaler's managed value, // so this is always the correct replica count. + String llapWorkload = LlapResourceBuilder.resourceName(resource, llapSpec); + logReplicaChange(client, ns, llapWorkload, ConfigUtils.COMPONENT_LLAP, replicas, + /*isStatefulSet=*/true); client.apps().statefulSets().inNamespace(ns) .resource(LlapResourceBuilder.buildStatefulSet(resource, llapSpec, replicas)) .forceConflicts() @@ -687,6 +701,9 @@ private void reconcileLlapClusters(HiveCluster resource, KubernetesClient client client.services().inNamespace(ns) .resource(LlapResourceBuilder.buildTezAmService(resource, llapSpec)) .serverSideApply(); + String tezAmWorkload = LlapResourceBuilder.tezAmResourceName(resource, llapSpec); + logReplicaChange(client, ns, tezAmWorkload, ConfigUtils.COMPONENT_TEZAM, tezAmReplicas, + /*isStatefulSet=*/false); client.apps().deployments().inNamespace(ns) .resource(LlapResourceBuilder.buildTezAmDeployment(resource, llapSpec, tezAmReplicas)) .forceConflicts() @@ -910,8 +927,8 @@ private boolean isClusterIdle(HiveCluster resource, KubernetesClient client) { if (spec.tezAm().isEnabled()) { for (var llap : spec.llapClusters()) { if (llap.isEnabled() - && !isAtMinReplicas(client, ns, name + "-tezam-" + llap.name(), false, - llap.tezAm().autoscaling().minReplicas())) { + && !isAtMinReplicas(client, ns, LlapResourceBuilder.tezAmResourceName(resource, llap), + false, llap.tezAm().autoscaling().minReplicas())) { return false; } } diff --git a/packaging/src/kubernetes/src/java/org/apache/hive/kubernetes/operator/util/Workloads.java b/packaging/src/kubernetes/src/java/org/apache/hive/kubernetes/operator/util/Workloads.java new file mode 100644 index 000000000000..d098cf8d6c07 --- /dev/null +++ b/packaging/src/kubernetes/src/java/org/apache/hive/kubernetes/operator/util/Workloads.java @@ -0,0 +1,98 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hive.kubernetes.operator.util; + +import java.util.OptionalInt; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.api.model.apps.StatefulSet; +import org.apache.hive.kubernetes.operator.model.HiveCluster; +import org.slf4j.Logger; + +/** + * Helpers for the operator's workloads (Deployment/StatefulSet): reading fields without the + * null-guard boilerplate every caller would otherwise repeat, logging replica changes in one + * shape, and resolving the workload's K8s name from the autoscaler's component key. + */ +public final class Workloads { + + private Workloads() {} + + /** + * Returns spec.replicas from a Deployment or StatefulSet. Empty when the resource is absent, + * has no spec, or the field is unset — in practice that means "the workload isn't there yet", + * since the API server defaults spec.replicas on write. A non-workload resource is empty too. + */ + public static OptionalInt replicas(HasMetadata resource) { + Integer replicas = null; + if (resource instanceof Deployment d && d.getSpec() != null) { + replicas = d.getSpec().getReplicas(); + } else if (resource instanceof StatefulSet s && s.getSpec() != null) { + replicas = s.getSpec().getReplicas(); + } + return replicas == null ? OptionalInt.empty() : OptionalInt.of(replicas); + } + + /** + * Logs the replica count the operator is about to apply to {@code namespace/name}. One line + * covers both cases: {@code current} is the workload as it exists in the cluster, or null when + * it doesn't exist yet, which logs as {@code none -> N}. Nothing is logged when the count + * already matches, so silence means no scale is happening. + *

+ * Shared by every scale path — the dependents' SSA and the imperative LLAP/TezAM SSAs — so the + * operator log reads the same whichever one ran. The caller passes its own logger to keep the + * log category pointing at the code that is actually scaling. + */ + public static void logReplicaChange(Logger log, String component, String namespace, String name, + HasMetadata current, int desired) { + OptionalInt actual = replicas(current); + if (actual.isPresent() && actual.getAsInt() == desired) { + return; + } + log.info("Setting replica count for {} {}/{}: {} -> {}", component, namespace, name, + actual.isPresent() ? String.valueOf(actual.getAsInt()) : "none", desired); + } + + /** + * Maps an autoscaler component key to the K8s workload name it drives. Per-LLAP components + * carry the LLAP cluster name in their key ("llap-{name}", "tezam-{name}"); everything else + * (HS2, Metastore) is a plain "{cluster}-{component}". Kept here so every scale path — the + * autoscaler's `patchReplicas`, the imperative LLAP/TezAM SSAs, and the idle-check reads — + * resolves the name the same way. + *

+ */ + public static String nameFor(HiveCluster hc, String component) { + String cluster = hc.getMetadata().getName(); + if (component.startsWith(ConfigUtils.COMPONENT_LLAP + "-")) { + String llapName = component.substring(ConfigUtils.COMPONENT_LLAP.length() + 1); + return cluster + "-" + llapName; + } + if (component.startsWith(ConfigUtils.COMPONENT_TEZAM + "-")) { + String llapName = component.substring(ConfigUtils.COMPONENT_TEZAM.length() + 1); + return cluster + "-" + ConfigUtils.COMPONENT_TEZAM + "-" + llapName; + } + return cluster + "-" + component; + } +} diff --git a/packaging/src/kubernetes/src/test/java/org/apache/hive/kubernetes/operator/util/TestWorkloads.java b/packaging/src/kubernetes/src/test/java/org/apache/hive/kubernetes/operator/util/TestWorkloads.java new file mode 100644 index 000000000000..3d2e6d537796 --- /dev/null +++ b/packaging/src/kubernetes/src/test/java/org/apache/hive/kubernetes/operator/util/TestWorkloads.java @@ -0,0 +1,169 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hive.kubernetes.operator.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.util.OptionalInt; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder; +import io.fabric8.kubernetes.api.model.apps.StatefulSet; +import io.fabric8.kubernetes.api.model.apps.StatefulSetBuilder; +import org.apache.hive.kubernetes.operator.model.HiveCluster; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.slf4j.Logger; + +class TestWorkloads { + + @Test + void replicasReturnsValueFromDeployment() { + Deployment d = new DeploymentBuilder() + .withNewSpec().withReplicas(3).endSpec() + .build(); + assertEquals(OptionalInt.of(3), Workloads.replicas(d)); + } + + @Test + void replicasReturnsValueFromStatefulSet() { + StatefulSet s = new StatefulSetBuilder() + .withNewSpec().withReplicas(5).endSpec() + .build(); + assertEquals(OptionalInt.of(5), Workloads.replicas(s)); + } + + @Test + void replicasIsEmptyWhenDeploymentSpecMissing() { + // fabric8 Deployment with no spec set at all + Deployment d = new DeploymentBuilder().build(); + assertFalse(Workloads.replicas(d).isPresent()); + } + + @Test + void replicasIsEmptyWhenStatefulSetSpecMissing() { + StatefulSet s = new StatefulSetBuilder().build(); + assertFalse(Workloads.replicas(s).isPresent()); + } + + @Test + void replicasIsEmptyWhenDeploymentReplicasUnset() { + // spec present, but replicas field not set + Deployment d = new DeploymentBuilder().withNewSpec().endSpec().build(); + assertFalse(Workloads.replicas(d).isPresent()); + } + + @Test + void replicasIsEmptyWhenStatefulSetReplicasUnset() { + StatefulSet s = new StatefulSetBuilder().withNewSpec().endSpec().build(); + assertFalse(Workloads.replicas(s).isPresent()); + } + + @Test + void replicasIsEmptyForNonWorkloadResource() { + // Anything that's neither a Deployment nor a StatefulSet is empty, + // even if it happens to have a "spec" (e.g., a ConfigMap here has none). + ConfigMap cm = new ConfigMapBuilder() + .withNewMetadata().withName("cm").endMetadata() + .build(); + assertFalse(Workloads.replicas(cm).isPresent()); + } + + @Test + void replicasIsEmptyForNullResource() { + assertFalse(Workloads.replicas(null).isPresent()); + } + + @Test + void logReplicaChangeLogsTheDelta() { + Logger log = mock(Logger.class); + StatefulSet s = new StatefulSetBuilder().withNewSpec().withReplicas(12).endSpec().build(); + + Workloads.logReplicaChange(log, "llap", "ns", "hive-llap0", s, 15); + + verify(log).info(anyString(), eq("llap"), eq("ns"), eq("hive-llap0"), eq("12"), eq(15)); + } + + /** No workload yet: the same line reports the count the first create will set. */ + @Test + void logReplicaChangeLogsNoneAsTheFromValueOnFirstCreate() { + Logger log = mock(Logger.class); + + Workloads.logReplicaChange(log, "hiveserver2", "ns", "hive-hiveserver2", null, 2); + + verify(log).info(anyString(), eq("hiveserver2"), eq("ns"), eq("hive-hiveserver2"), + eq("none"), eq(2)); + } + + /** Silence is the signal that nothing is being scaled, so an unchanged count logs nothing. */ + @Test + void logReplicaChangeIsSilentWhenTheCountAlreadyMatches() { + Logger log = mock(Logger.class); + Deployment d = new DeploymentBuilder().withNewSpec().withReplicas(2).endSpec().build(); + + Workloads.logReplicaChange(log, "metastore", "ns", "hive-metastore", d, 2); + + verifyNoInteractions(log); + } + + /** + * Covers the three branches of {@link Workloads#nameFor}: + * + * The last two rows exercise the "no dash after the prefix" fall-through: a bare + * {@code "llap"} or {@code "tezam"} isn't a per-cluster component key and must land in the + * plain-{cluster}-{component} branch, not be treated as an empty LLAP name. + */ + @ParameterizedTest + @CsvSource({ + // component, expected workload name + "llap-llap0, hive-llap0", + "llap-my-llap-cluster, hive-my-llap-cluster", + "tezam-llap0, hive-tezam-llap0", + "tezam-my-llap-cluster, hive-tezam-my-llap-cluster", + "hiveserver2, hive-hiveserver2", + "metastore, hive-metastore", + "llap, hive-llap", + "tezam, hive-tezam", + }) + void nameForMapsComponentKeyToWorkloadName(String component, String expected) { + HiveCluster hc = hiveCluster("hive"); + assertEquals(expected, Workloads.nameFor(hc, component)); + } + + private static HiveCluster hiveCluster(String name) { + HiveCluster hc = new HiveCluster(); + hc.setMetadata(new ObjectMetaBuilder().withName(name).build()); + return hc; + } +}