This is an automated email from the ASF dual-hosted git repository.

zhoujinsong pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/amoro.git


The following commit(s) were added to refs/heads/master by this push:
     new 56c34f61e [AMORO-4313] AIP-5 Phase 5: observability metrics for 
dynamic allocation (#4314)
56c34f61e is described below

commit 56c34f61ece921d83ab6d32ca0a8f6715f0447fd
Author: Jiwon Park <[email protected]>
AuthorDate: Mon Aug 17 12:46:58 2026 +0900

    [AMORO-4313] AIP-5 Phase 5: observability metrics for dynamic allocation 
(#4314)
    
    * [AMORO-4313] Expose backlog duration from DynamicAllocationState for the 
backlog gauge
    
    Signed-off-by: Jiwon Park <[email protected]>
    
    * [AMORO-4313] Add per-group dynamic allocation metrics with source-backed 
gauges and scale counters
    
    Signed-off-by: Jiwon Park <[email protected]>
    
    * [AMORO-4313] Wire dynamic allocation metrics into the scale keeper watch 
lifecycle
    
    Signed-off-by: Jiwon Park <[email protected]>
    
    * [AMORO-4313] Add idle-optimizer and config-invalid gauges to the 
optimizer group metrics
    
    Signed-off-by: Jiwon Park <[email protected]>
    
    * [AMORO-4313] Document dynamic allocation metrics in the metrics table
    
    Signed-off-by: Jiwon Park <[email protected]>
    
    ---------
    
    Signed-off-by: Jiwon Park <[email protected]>
---
 .../amoro/server/DefaultOptimizingService.java     |  97 ++++++-
 .../server/optimizing/OptimizerGroupMetrics.java   |  42 +++
 .../optimizing/dra/DynamicAllocationConfig.java    |  25 ++
 .../optimizing/dra/DynamicAllocationMetrics.java   | 142 +++++++++++
 .../optimizing/dra/DynamicAllocationState.java     |   9 +
 .../amoro/server/TestOptimizerScaleKeeper.java     | 282 +++++++++++++++++++++
 .../server/optimizing/TestOptimizingQueue.java     |  61 +++++
 .../server/optimizing/dra/TestComputeScaleUp.java  |  25 ++
 .../dra/TestDynamicAllocationConfig.java           |  47 ++++
 .../dra/TestDynamicAllocationMetrics.java          | 134 ++++++++++
 docs/user-guides/metrics.md                        |  17 ++
 11 files changed, 876 insertions(+), 5 deletions(-)

diff --git 
a/amoro-ams/src/main/java/org/apache/amoro/server/DefaultOptimizingService.java 
b/amoro-ams/src/main/java/org/apache/amoro/server/DefaultOptimizingService.java
index 2e9aa4a28..eb943e605 100644
--- 
a/amoro-ams/src/main/java/org/apache/amoro/server/DefaultOptimizingService.java
+++ 
b/amoro-ams/src/main/java/org/apache/amoro/server/DefaultOptimizingService.java
@@ -41,11 +41,13 @@ import org.apache.amoro.server.catalog.CatalogManager;
 import org.apache.amoro.server.dashboard.model.OptimizerResourceInfo;
 import org.apache.amoro.server.ha.HighAvailabilityContainer;
 import org.apache.amoro.server.manager.AbstractOptimizerContainer;
+import org.apache.amoro.server.manager.MetricManager;
 import org.apache.amoro.server.optimizing.OptimizingProcess;
 import org.apache.amoro.server.optimizing.OptimizingQueue;
 import org.apache.amoro.server.optimizing.OptimizingStatus;
 import org.apache.amoro.server.optimizing.TaskRuntime;
 import org.apache.amoro.server.optimizing.dra.DynamicAllocationConfig;
+import org.apache.amoro.server.optimizing.dra.DynamicAllocationMetrics;
 import org.apache.amoro.server.optimizing.dra.DynamicAllocationState;
 import org.apache.amoro.server.optimizing.dra.PendingRegistrations;
 import org.apache.amoro.server.persistence.StatedPersistentBase;
@@ -1136,21 +1138,81 @@ public class DefaultOptimizingService extends 
StatedPersistentBase
         new ConcurrentHashMap<>();
     private final Set<String> watchedGroups = ConcurrentHashMap.newKeySet();
     private final Map<String, Integer> planningBoundStreaks = new 
ConcurrentHashMap<>();
+    private final Map<String, DynamicAllocationMetrics> metricsByGroup = new 
ConcurrentHashMap<>();
 
     public OptimizerScaleKeeper(String threadName) {
       super(threadName);
     }
 
-    /** Start watching a group if dynamic allocation is effectively enabled on 
it. Idempotent. */
-    public void watch(ResourceGroup resourceGroup) {
+    /**
+     * Start watching a group if dynamic allocation is effectively enabled on 
it. Idempotent.
+     *
+     * <p>Watch and unwatch are serialized: metric registration is not 
idempotent (re-registering a
+     * live key throws), so an unlocked watch/unwatch interleaving could 
strand a registration that
+     * the other side never saw — after which every re-watch of the group 
throws before queueing its
+     * scale task, leaving it watched-but-dead until a restart. These are rare 
control-plane calls;
+     * the lock costs nothing on the scaling hot path.
+     */
+    public synchronized void watch(ResourceGroup resourceGroup) {
+      if (stopped) {
+        // A watch arriving after dispose — an in-flight config-sync run or 
the round's re-check
+        // racing a leader hand-off — must not register metrics from a dead 
service: the keys
+        // would outlive it in the global registry and fail the next leader's 
watch.
+        return;
+      }
       if (!DynamicAllocationConfig.isEffectivelyEnabled(resourceGroup)) {
+        // Propagate a disable on the config-entry path itself: the 
round-driven unwatch runs on
+        // the leader only, so a follower relying on it would keep the group's 
drain blocks and
+        // exported metrics until failover.
+        unwatch(resourceGroup.getName());
         return;
       }
-      if (watchedGroups.add(resourceGroup.getName())) {
+      if (!watchedGroups.contains(resourceGroup.getName())) {
+        // Register metrics before marking the group watched: a failed 
registration must leave
+        // the group rewatchable, not watched-but-dead with every retry 
swallowed by the entry.
+        registerMetrics(resourceGroup.getName());
+        watchedGroups.add(resourceGroup.getName());
         suspendingQueue.add(new DraScaleTask(resourceGroup.getName(), 0));
       }
     }
 
+    /**
+     * Register the group's DRA gauges and counters, keyed by the keeper's own 
state: unlike the
+     * queue-scoped {@code OptimizerGroupMetrics} they live with the watch, so 
a group handed back
+     * to the legacy floor keeper stops exporting scaling metrics it no longer 
produces.
+     */
+    private void registerMetrics(String groupName) {
+      DynamicAllocationMetrics metrics =
+          new DynamicAllocationMetrics(
+              groupName,
+              MetricManager.getInstance().getGlobalRegistry(),
+              new DynamicAllocationMetrics.Source() {
+                @Override
+                public int pendingRemovalOptimizers() {
+                  return (int)
+                      pendingRemovalTokens.stream()
+                          .map(authOptimizers::get)
+                          .filter(
+                              optimizer ->
+                                  optimizer != null && 
groupName.equals(optimizer.getGroupName()))
+                          .count();
+                }
+
+                @Override
+                public int effectiveThreads() {
+                  return getTotalQuota(groupName) + pendingThreads(groupName);
+                }
+
+                @Override
+                public long backlogDurationMs() {
+                  DynamicAllocationState state = scaleStates.get(groupName);
+                  return state == null ? 0 : 
state.backlogDurationMs(System.currentTimeMillis());
+                }
+              });
+      metrics.register();
+      metricsByGroup.put(groupName, metrics);
+    }
+
     /** Clear the boot-window accounting of a registered optimizer 
(AMS-launched ones only). */
     public void onOptimizerRegistered(OptimizerInstance optimizer) {
       if (optimizer.getResourceId() == null) {
@@ -1162,10 +1224,14 @@ public class DefaultOptimizingService extends 
StatedPersistentBase
       }
     }
 
-    private void unwatch(String groupName) {
+    private synchronized void unwatch(String groupName) {
       watchedGroups.remove(groupName);
       scaleStates.remove(groupName);
       planningBoundStreaks.remove(groupName);
+      DynamicAllocationMetrics metrics = metricsByGroup.remove(groupName);
+      if (metrics != null) {
+        metrics.unregister();
+      }
       // A drain block left behind would starve the group's pods forever once 
the legacy floor
       // keeper resumes duty for the disabled group: re-admit them to task 
assignment.
       authOptimizers.values().stream()
@@ -1182,11 +1248,23 @@ public class DefaultOptimizingService extends 
StatedPersistentBase
      * must go too: leaving it would leak the entry and, if a group with the 
same name is created
      * before the next evaluation, suppress its scale-up with the old group's 
phantom capacity.
      */
-    public void onGroupDeleted(String groupName) {
+    public synchronized void onGroupDeleted(String groupName) {
       unwatch(groupName);
       pendingRegistrations.remove(groupName);
     }
 
+    /**
+     * The global metric registry outlives this service: on a leader hand-off 
the next leader's
+     * fresh service watches the same groups, and any keys left behind here 
would make that watch
+     * throw, leaving the group watched-but-dead until a JVM restart.
+     */
+    @Override
+    public synchronized void dispose() {
+      super.dispose();
+      metricsByGroup.values().forEach(DynamicAllocationMetrics::unregister);
+      metricsByGroup.clear();
+    }
+
     @Override
     protected void processTask(DraScaleTask task) {
       ResourceGroup resourceGroup;
@@ -1303,6 +1381,11 @@ public class DefaultOptimizingService extends 
StatedPersistentBase
       if (victim == null) {
         return;
       }
+      // The drain start is the scale-down action; the eventual removal only 
completes it.
+      DynamicAllocationMetrics metrics = metricsByGroup.get(groupName);
+      if (metrics != null) {
+        metrics.incScaleDown();
+      }
       beginGracefulDrain(victim, now + config.getDrainTimeout().toMillis());
       // Only a snapshot taken after the token entered the pending-removal set 
can prove idleness:
       // the pre-insert one may miss a task fetched by a long-poll racing the 
drain start.
@@ -1400,6 +1483,10 @@ public class DefaultOptimizingService extends 
StatedPersistentBase
         }
         return;
       }
+      DynamicAllocationMetrics metrics = metricsByGroup.get(groupName);
+      if (metrics != null) {
+        metrics.incScaleUp();
+      }
       int threadsPerInstance = config.getExecutorParallelism();
       LOG.info(
           "Dynamic allocation scaling out group {}: {} instance(s) of {} 
thread(s), effective threads {}",
diff --git 
a/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/OptimizerGroupMetrics.java
 
b/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/OptimizerGroupMetrics.java
index 5b70a4e83..4b640d4da 100644
--- 
a/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/OptimizerGroupMetrics.java
+++ 
b/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/OptimizerGroupMetrics.java
@@ -32,13 +32,17 @@ import org.apache.amoro.metrics.Metric;
 import org.apache.amoro.metrics.MetricDefine;
 import org.apache.amoro.metrics.MetricKey;
 import org.apache.amoro.metrics.MetricRegistry;
+import org.apache.amoro.server.optimizing.dra.DynamicAllocationConfig;
+import org.apache.amoro.server.optimizing.dra.DynamicAllocationState;
 import org.apache.amoro.server.resource.OptimizerInstance;
 import org.apache.amoro.shade.guava32.com.google.common.collect.ImmutableMap;
 import org.apache.amoro.shade.guava32.com.google.common.collect.Lists;
 
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Collectors;
 
 /** Metrics manager for an optimizer group. */
 public class OptimizerGroupMetrics {
@@ -104,6 +108,21 @@ public class OptimizerGroupMetrics {
           .withTags(GROUP_TAG)
           .build();
 
+  public static final MetricDefine OPTIMIZER_GROUP_IDLE_OPTIMIZERS =
+      defineGauge("optimizer_group_idle_optimizers")
+          .withDescription(
+              "Number of optimizer instances with no in-flight task in 
optimizer group")
+          .withTags(GROUP_TAG)
+          .build();
+
+  public static final MetricDefine OPTIMIZER_GROUP_CONFIG_INVALID =
+      defineGauge("optimizer_group_config_invalid")
+          .withDescription(
+              "1 while the group's dynamic allocation configuration is invalid 
and the "
+                  + "fail-safe fallback is active, else 0")
+          .withTags(GROUP_TAG)
+          .build();
+
   private final String groupName;
   private final MetricRegistry registry;
   private final OptimizingQueue optimizingQueue;
@@ -199,6 +218,29 @@ public class OptimizerGroupMetrics {
                 optimizerInstances.values().stream()
                     .mapToLong(OptimizerInstance::getThreadCount)
                     .sum());
+    registerMetric(
+        registry,
+        OPTIMIZER_GROUP_IDLE_OPTIMIZERS,
+        (Gauge<Long>)
+            () -> {
+              Set<String> busyTokens =
+                  optimizingQueue
+                      .collectTasks(task -> 
DynamicAllocationState.occupiesThread(task.getStatus()))
+                      .stream()
+                      .map(TaskRuntime::getToken)
+                      .collect(Collectors.toSet());
+              return optimizerInstances.keySet().stream()
+                  .filter(token -> !busyTokens.contains(token))
+                  .count();
+            });
+    registerMetric(
+        registry,
+        OPTIMIZER_GROUP_CONFIG_INVALID,
+        (Gauge<Integer>)
+            () ->
+                
DynamicAllocationConfig.isConfigInvalid(optimizingQueue.getOptimizerGroup())
+                    ? 1
+                    : 0);
   }
 
   public void unregister() {
diff --git 
a/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/dra/DynamicAllocationConfig.java
 
b/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/dra/DynamicAllocationConfig.java
index cfa0e2f1d..813a3f5db 100644
--- 
a/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/dra/DynamicAllocationConfig.java
+++ 
b/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/dra/DynamicAllocationConfig.java
@@ -218,6 +218,31 @@ public class DynamicAllocationConfig {
     }
   }
 
+  /**
+   * Whether the group opted into dynamic allocation but its configuration 
does not hold, i.e. the
+   * fail-safe fallback of {@link #isEffectivelyEnabled(ResourceGroup)} is 
active. Read side of the
+   * config-invalid gauge. A group that never opted in is not invalid, 
whatever its other properties
+   * parse to — its leftover values are inert and must not alarm.
+   */
+  public static boolean isConfigInvalid(ResourceGroup group) {
+    boolean enabled =
+        PropertyUtil.propertyAsBoolean(
+            group.getProperties(),
+            OptimizerProperties.DYNAMIC_ALLOCATION_ENABLED,
+            OptimizerProperties.DYNAMIC_ALLOCATION_ENABLED_DEFAULT);
+    if (!enabled) {
+      return false;
+    }
+    try {
+      parse(group).validate();
+      return false;
+    } catch (RuntimeException e) {
+      // Not just IllegalArgumentException: duration parsing can throw 
ArithmeticException on
+      // overflow, and a gauge read must never propagate — it would abort the 
whole scrape.
+      return true;
+    }
+  }
+
   /**
    * The min-parallelism property key that {@link 
#resolveMinParallelism(ResourceGroup)} actually
    * reads for this group. Writers updating the effective value (e.g. the 
keeper's auto-reset) must
diff --git 
a/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/dra/DynamicAllocationMetrics.java
 
b/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/dra/DynamicAllocationMetrics.java
new file mode 100644
index 000000000..40ddf3cc3
--- /dev/null
+++ 
b/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/dra/DynamicAllocationMetrics.java
@@ -0,0 +1,142 @@
+/*
+ * 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.amoro.server.optimizing.dra;
+
+import static org.apache.amoro.metrics.MetricDefine.defineCounter;
+import static org.apache.amoro.metrics.MetricDefine.defineGauge;
+
+import org.apache.amoro.metrics.Counter;
+import org.apache.amoro.metrics.Gauge;
+import org.apache.amoro.metrics.Metric;
+import org.apache.amoro.metrics.MetricDefine;
+import org.apache.amoro.metrics.MetricKey;
+import org.apache.amoro.metrics.MetricRegistry;
+import org.apache.amoro.shade.guava32.com.google.common.collect.ImmutableMap;
+import org.apache.amoro.shade.guava32.com.google.common.collect.Lists;
+
+import java.util.List;
+
+/**
+ * Per-group metrics of dynamic allocation (AIP-5). Their sources — the 
pending-removal set, the
+ * boot-window accounting and the backlog timer — are owned by the scale 
keeper, so unlike the
+ * queue-scoped {@code OptimizerGroupMetrics} these are registered when the 
keeper starts watching a
+ * group and unregistered when it stops; gauge reads go through the injected 
{@link Source}.
+ */
+public class DynamicAllocationMetrics {
+
+  static final String GROUP_TAG = "group";
+
+  public static final MetricDefine OPTIMIZER_GROUP_PENDING_REMOVAL_OPTIMIZERS =
+      defineGauge("optimizer_group_pending_removal_optimizers")
+          .withDescription("Number of optimizer instances in graceful drain in 
optimizer group")
+          .withTags(GROUP_TAG)
+          .build();
+
+  public static final MetricDefine OPTIMIZER_GROUP_EFFECTIVE_THREADS =
+      defineGauge("optimizer_group_effective_threads")
+          .withDescription(
+              "Number of registered threads plus threads of optimizers pending 
registration "
+                  + "in optimizer group")
+          .withTags(GROUP_TAG)
+          .build();
+
+  public static final MetricDefine OPTIMIZER_GROUP_BACKLOG_DURATION_MS =
+      defineGauge("optimizer_group_backlog_duration_ms")
+          .withDescription(
+              "Duration in milliseconds since demand first exceeded capacity 
in optimizer "
+                  + "group, 0 while there is no backlog")
+          .withTags(GROUP_TAG)
+          .build();
+
+  public static final MetricDefine OPTIMIZER_GROUP_SCALE_UP_TOTAL =
+      defineCounter("optimizer_group_scale_up_total")
+          .withDescription(
+              "Cumulative count of attempted scale-up actions in optimizer 
group, one per "
+                  + "scale-out round regardless of instance count or request 
outcome")
+          .withTags(GROUP_TAG)
+          .build();
+
+  public static final MetricDefine OPTIMIZER_GROUP_SCALE_DOWN_TOTAL =
+      defineCounter("optimizer_group_scale_down_total")
+          .withDescription(
+              "Cumulative count of scale-down actions in optimizer group, one 
per drain start")
+          .withTags(GROUP_TAG)
+          .build();
+
+  /** Read side of the gauges, implemented by the owner of the scaling state. 
*/
+  public interface Source {
+
+    /** Number of this group's optimizer instances currently in graceful 
drain. */
+    int pendingRemovalOptimizers();
+
+    /** Registered threads plus threads of optimizers pending registration. */
+    int effectiveThreads();
+
+    /** Duration since demand first exceeded capacity, {@code 0} while there 
is no backlog. */
+    long backlogDurationMs();
+  }
+
+  private final String groupName;
+  private final MetricRegistry registry;
+  private final Source source;
+  private final Counter scaleUpTotal = new Counter();
+  private final Counter scaleDownTotal = new Counter();
+  private final List<MetricKey> registeredMetricKeys = Lists.newArrayList();
+
+  public DynamicAllocationMetrics(String groupName, MetricRegistry registry, 
Source source) {
+    this.groupName = groupName;
+    this.registry = registry;
+    this.source = source;
+  }
+
+  public void register() {
+    try {
+      registerMetric(
+          OPTIMIZER_GROUP_PENDING_REMOVAL_OPTIMIZERS,
+          (Gauge<Integer>) source::pendingRemovalOptimizers);
+      registerMetric(OPTIMIZER_GROUP_EFFECTIVE_THREADS, (Gauge<Integer>) 
source::effectiveThreads);
+      registerMetric(OPTIMIZER_GROUP_BACKLOG_DURATION_MS, (Gauge<Long>) 
source::backlogDurationMs);
+      registerMetric(OPTIMIZER_GROUP_SCALE_UP_TOTAL, scaleUpTotal);
+      registerMetric(OPTIMIZER_GROUP_SCALE_DOWN_TOTAL, scaleDownTotal);
+    } catch (Exception e) {
+      // Roll back any metrics that were partially registered before the 
failure so that a retry
+      // finds a clean state.
+      unregister();
+      throw e;
+    }
+  }
+
+  public void unregister() {
+    registeredMetricKeys.forEach(registry::unregister);
+    registeredMetricKeys.clear();
+  }
+
+  public void incScaleUp() {
+    scaleUpTotal.inc();
+  }
+
+  public void incScaleDown() {
+    scaleDownTotal.inc();
+  }
+
+  private void registerMetric(MetricDefine define, Metric metric) {
+    registeredMetricKeys.add(
+        registry.register(define, ImmutableMap.of(GROUP_TAG, groupName), 
metric));
+  }
+}
diff --git 
a/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/dra/DynamicAllocationState.java
 
b/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/dra/DynamicAllocationState.java
index e0cd45057..034d7c92c 100644
--- 
a/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/dra/DynamicAllocationState.java
+++ 
b/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/dra/DynamicAllocationState.java
@@ -145,6 +145,15 @@ public final class DynamicAllocationState {
     return lastEvalDemandActive;
   }
 
+  /**
+   * How long demand has exceeded capacity as of {@code nowMs}, or {@code 0} 
while there is no
+   * backlog. Read side of the backlog-duration gauge: derived at scrape time 
rather than pushed by
+   * the keeper, whose rounds are seconds apart.
+   */
+  public long backlogDurationMs(long nowMs) {
+    return backlogSinceMs < 0 ? 0 : nowMs - backlogSinceMs;
+  }
+
   /**
    * Update per-token idle observations from this round's snapshot. A token 
with in-flight tasks has
    * its busy timestamp refreshed; a token seen for the first time is seeded 
with {@code nowMs}
diff --git 
a/amoro-ams/src/test/java/org/apache/amoro/server/TestOptimizerScaleKeeper.java 
b/amoro-ams/src/test/java/org/apache/amoro/server/TestOptimizerScaleKeeper.java
index ab913232c..2522d212a 100644
--- 
a/amoro-ams/src/test/java/org/apache/amoro/server/TestOptimizerScaleKeeper.java
+++ 
b/amoro-ams/src/test/java/org/apache/amoro/server/TestOptimizerScaleKeeper.java
@@ -24,12 +24,20 @@ import org.apache.amoro.TableFormat;
 import org.apache.amoro.TableTestHelper;
 import org.apache.amoro.catalog.BasicCatalogTestHelper;
 import org.apache.amoro.catalog.CatalogTestHelper;
+import org.apache.amoro.config.Configurations;
+import org.apache.amoro.metrics.Counter;
+import org.apache.amoro.metrics.Metric;
+import org.apache.amoro.metrics.MetricDefine;
+import org.apache.amoro.metrics.MetricKey;
 import org.apache.amoro.resource.ResourceContainer;
 import org.apache.amoro.resource.ResourceGroup;
+import org.apache.amoro.server.manager.MetricManager;
+import org.apache.amoro.server.optimizing.dra.DynamicAllocationMetrics;
 import org.apache.amoro.server.resource.ContainerMetadata;
 import org.apache.amoro.server.resource.Containers;
 import org.apache.amoro.server.resource.OptimizerInstance;
 import org.apache.amoro.server.table.AMSTableTestBase;
+import org.apache.amoro.shade.guava32.com.google.common.collect.ImmutableMap;
 import org.apache.amoro.shade.guava32.com.google.common.collect.Maps;
 import org.apache.iceberg.common.DynFields;
 import org.junit.After;
@@ -233,6 +241,14 @@ public class TestOptimizerScaleKeeper extends 
AMSTableTestBase {
         2,
         scaleOutCallCount.get(),
         "the deficit must be requested exactly once while the pods are still 
booting");
+    @SuppressWarnings("unchecked")
+    org.apache.amoro.metrics.Gauge<Integer> effectiveThreads =
+        (org.apache.amoro.metrics.Gauge<Integer>)
+            
draMetric(DynamicAllocationMetrics.OPTIMIZER_GROUP_EFFECTIVE_THREADS, 
group.getName());
+    Assertions.assertEquals(
+        2,
+        effectiveThreads.getValue().intValue(),
+        "requested-but-unregistered threads must count as effective");
   }
 
   /**
@@ -492,6 +508,272 @@ public class TestOptimizerScaleKeeper extends 
AMSTableTestBase {
         "the cooldown expiry should admit the next removal");
   }
 
+  private Metric draMetric(MetricDefine define, String groupName) {
+    return MetricManager.getInstance()
+        .getGlobalRegistry()
+        .getMetrics()
+        .get(new MetricKey(define, ImmutableMap.of("group", groupName)));
+  }
+
+  /**
+   * The keeper owns the DRA metric lifecycle: watching a group registers its 
gauges and counters,
+   * and a disable — which hands the group back to the legacy floor keeper — 
removes them.
+   */
+  @Test
+  public void testDraMetricsRegisteredOnWatchAndRemovedOnDisable() throws 
InterruptedException {
+    resourceAvailable.set(true);
+    scaleOutCallCount.set(0);
+    ResourceGroup group = buildDraResourceGroup(TEST_GROUP_NAME + "-12", 0, 1);
+    optimizerManager().createResourceGroup(group);
+    optimizingService().createResourceGroup(group);
+
+    Assertions.assertNotNull(
+        draMetric(
+            
DynamicAllocationMetrics.OPTIMIZER_GROUP_PENDING_REMOVAL_OPTIMIZERS, 
group.getName()));
+    Assertions.assertNotNull(
+        draMetric(DynamicAllocationMetrics.OPTIMIZER_GROUP_EFFECTIVE_THREADS, 
group.getName()));
+    Assertions.assertNotNull(
+        
draMetric(DynamicAllocationMetrics.OPTIMIZER_GROUP_BACKLOG_DURATION_MS, 
group.getName()));
+    Assertions.assertNotNull(
+        draMetric(DynamicAllocationMetrics.OPTIMIZER_GROUP_SCALE_UP_TOTAL, 
group.getName()));
+    Assertions.assertNotNull(
+        draMetric(DynamicAllocationMetrics.OPTIMIZER_GROUP_SCALE_DOWN_TOTAL, 
group.getName()));
+
+    Map<String, String> legacyProps = Maps.newHashMap();
+    legacyProps.put("memory", "1024");
+    ResourceGroup disabled =
+        new ResourceGroup.Builder(group.getName(), MOCK_CONTAINER_NAME)
+            .addProperties(legacyProps)
+            .build();
+    optimizerManager().updateResourceGroup(disabled);
+    optimizingService().updateResourceGroup(disabled);
+    Thread.sleep(500);
+
+    Assertions.assertNull(
+        draMetric(DynamicAllocationMetrics.OPTIMIZER_GROUP_EFFECTIVE_THREADS, 
group.getName()),
+        "a disabled group's DRA metrics must go with its watch");
+    Assertions.assertNull(
+        draMetric(DynamicAllocationMetrics.OPTIMIZER_GROUP_SCALE_UP_TOTAL, 
group.getName()));
+  }
+
+  /**
+   * A disable unwatches on the config-entry path itself, not on the keeper's 
next round: the
+   * round-driven unwatch runs on the leader only, so without this a follower 
would keep exporting
+   * the group's DRA metrics until failover.
+   */
+  @Test
+  public void testDisableUnwatchesOnUpdatePathWithoutKeeperRound() throws 
InterruptedException {
+    resourceAvailable.set(true);
+    scaleOutCallCount.set(0);
+    ResourceGroup group = buildSlowDraResourceGroup(TEST_GROUP_NAME + "-15", 
0);
+    optimizerManager().createResourceGroup(group);
+    optimizingService().createResourceGroup(group);
+    Assertions.assertNotNull(
+        draMetric(DynamicAllocationMetrics.OPTIMIZER_GROUP_SCALE_UP_TOTAL, 
group.getName()));
+    // Let the delay-0 first round pass; the next one is minutes away (600s 
cadence), so any
+    // removal observed below must come from the update path, not from a 
keeper round.
+    Thread.sleep(200);
+
+    Map<String, String> legacyProps = Maps.newHashMap();
+    legacyProps.put("memory", "1024");
+    ResourceGroup disabled =
+        new ResourceGroup.Builder(group.getName(), MOCK_CONTAINER_NAME)
+            .addProperties(legacyProps)
+            .build();
+    optimizerManager().updateResourceGroup(disabled);
+    optimizingService().updateResourceGroup(disabled);
+
+    Assertions.assertNull(
+        draMetric(DynamicAllocationMetrics.OPTIMIZER_GROUP_SCALE_UP_TOTAL, 
group.getName()),
+        "the update path must unwatch a disabled group on every node");
+  }
+
+  /** A scale-out round counts as one scale-up action, and the gauges read the 
keeper's state. */
+  @Test
+  public void testScaleUpRoundIncrementsCounterAndGaugesReadState() throws 
InterruptedException {
+    resourceAvailable.set(true);
+    scaleOutCallCount.set(0);
+    ResourceGroup group = buildDraResourceGroup(TEST_GROUP_NAME + "-13", 2, 1);
+    optimizerManager().createResourceGroup(group);
+    optimizingService().createResourceGroup(group);
+    Thread.sleep(500);
+
+    Counter scaleUp =
+        (Counter)
+            draMetric(DynamicAllocationMetrics.OPTIMIZER_GROUP_SCALE_UP_TOTAL, 
group.getName());
+    Assertions.assertEquals(
+        1, scaleUp.getCount(), "one floor-deficit round is exactly one 
scale-up action");
+    @SuppressWarnings("unchecked")
+    org.apache.amoro.metrics.Gauge<Integer> effectiveThreads =
+        (org.apache.amoro.metrics.Gauge<Integer>)
+            
draMetric(DynamicAllocationMetrics.OPTIMIZER_GROUP_EFFECTIVE_THREADS, 
group.getName());
+    Assertions.assertEquals(
+        2,
+        effectiveThreads.getValue().intValue(),
+        "registered floor capacity should be visible as effective threads");
+  }
+
+  /** The pending-removal gauge tracks instances through drain start, retry, 
and removal. */
+  @Test
+  public void testPendingRemovalGaugeCountsDrainingInstances() {
+    resourceAvailable.set(true);
+    scaleOutCallCount.set(0);
+    ResourceGroup group = buildSlowDraResourceGroup(TEST_GROUP_NAME + "-16", 
0);
+    optimizerManager().createResourceGroup(group);
+    optimizingService().createResourceGroup(group);
+    OptimizerInstance optimizer = registerOptimizer(group.getName(), 1);
+    @SuppressWarnings("unchecked")
+    org.apache.amoro.metrics.Gauge<Integer> pendingRemoval =
+        (org.apache.amoro.metrics.Gauge<Integer>)
+            draMetric(
+                
DynamicAllocationMetrics.OPTIMIZER_GROUP_PENDING_REMOVAL_OPTIMIZERS,
+                group.getName());
+    Assertions.assertEquals(0, pendingRemoval.getValue().intValue());
+
+    // A failing release keeps the instance draining, exactly the stuck state 
the gauge is for.
+    mockContainer.setReleaseAvailable(false);
+    optimizingService().beginGracefulDrain(optimizer.getToken(), 
Long.MAX_VALUE);
+    optimizingService().executeRemoval(optimizer.getToken());
+    Assertions.assertEquals(1, pendingRemoval.getValue().intValue());
+
+    mockContainer.setReleaseAvailable(true);
+    optimizingService().executeRemoval(optimizer.getToken());
+    Assertions.assertEquals(0, pendingRemoval.getValue().intValue());
+  }
+
+  /** Starting a drain counts as one scale-down action. */
+  @Test
+  public void testScaleDownIncrementsCounter() {
+    resourceAvailable.set(true);
+    scaleOutCallCount.set(0);
+    ResourceGroup group = buildSlowDraResourceGroup(TEST_GROUP_NAME + "-14", 
0);
+    optimizerManager().createResourceGroup(group);
+    optimizingService().createResourceGroup(group);
+    registerOptimizer(group.getName(), 1);
+
+    long t0 = System.currentTimeMillis();
+    optimizingService().evaluateDynamicAllocation(group.getName(), t0);
+    Counter scaleDown =
+        (Counter)
+            
draMetric(DynamicAllocationMetrics.OPTIMIZER_GROUP_SCALE_DOWN_TOTAL, 
group.getName());
+    Assertions.assertEquals(0, scaleDown.getCount());
+
+    optimizingService().evaluateDynamicAllocation(group.getName(), t0 + 
1_300_000L);
+    Assertions.assertEquals(
+        1, scaleDown.getCount(), "the drain start is the scale-down action, 
counted once");
+  }
+
+  /**
+   * A watch whose metric registration fails must leave no residue: the group 
stays unwatched, so
+   * the next config pass can watch it again instead of finding a 
watched-but-dead entry that
+   * swallows every retry until a restart.
+   */
+  @Test
+  public void testWatchFailureLeavesGroupRewatchable() {
+    ResourceGroup group = buildSlowDraResourceGroup(TEST_GROUP_NAME + "-17", 
0);
+    optimizerManager().createResourceGroup(group);
+    // Occupy one of the group's DRA keys so the watch's registration fails 
midway.
+    MetricKey conflict =
+        MetricManager.getInstance()
+            .getGlobalRegistry()
+            .register(
+                DynamicAllocationMetrics.OPTIMIZER_GROUP_SCALE_UP_TOTAL,
+                ImmutableMap.of("group", group.getName()),
+                new Counter());
+    Assertions.assertThrows(
+        RuntimeException.class, () -> 
optimizingService().createResourceGroup(group));
+    Assertions.assertNull(
+        draMetric(
+            
DynamicAllocationMetrics.OPTIMIZER_GROUP_PENDING_REMOVAL_OPTIMIZERS, 
group.getName()),
+        "a failed watch must roll back the metrics it managed to register");
+    MetricManager.getInstance().getGlobalRegistry().unregister(conflict);
+
+    optimizingService().updateResourceGroup(group);
+    Assertions.assertNotNull(
+        draMetric(DynamicAllocationMetrics.OPTIMIZER_GROUP_SCALE_UP_TOTAL, 
group.getName()),
+        "the group must be rewatchable after a failed watch");
+    Assertions.assertNotNull(
+        draMetric(
+            
DynamicAllocationMetrics.OPTIMIZER_GROUP_PENDING_REMOVAL_OPTIMIZERS, 
group.getName()));
+  }
+
+  /**
+   * The global metric registry outlives the service on a leader hand-off: a 
disposed service must
+   * unregister its DRA metrics, or the next leader's watch of the same group 
throws on the leftover
+   * keys and the group goes watched-but-dead until a JVM restart.
+   */
+  @Test
+  public void testDisposeUnregistersDraMetrics() {
+    ResourceGroup group = buildSlowDraResourceGroup(TEST_GROUP_NAME + "-18", 
0);
+    optimizerManager().createResourceGroup(group);
+    DefaultOptimizingService formerLeader =
+        new DefaultOptimizingService(
+            new Configurations(), catalogManager(), optimizerManager(), 
tableService(), null, null);
+    formerLeader.createResourceGroup(group);
+    Assertions.assertNotNull(
+        draMetric(DynamicAllocationMetrics.OPTIMIZER_GROUP_SCALE_UP_TOTAL, 
group.getName()));
+
+    formerLeader.dispose();
+    Assertions.assertNull(
+        draMetric(DynamicAllocationMetrics.OPTIMIZER_GROUP_SCALE_UP_TOTAL, 
group.getName()),
+        "a disposed service must not leave DRA metrics behind for the next 
leader");
+
+    // The next leader takes over the group without colliding with leftover 
keys.
+    optimizingService().createResourceGroup(group);
+    Assertions.assertNotNull(
+        draMetric(DynamicAllocationMetrics.OPTIMIZER_GROUP_SCALE_UP_TOTAL, 
group.getName()));
+  }
+
+  /**
+   * A watch arriving after dispose — an in-flight config-sync run or the 
keeper round's re-check
+   * racing a leader hand-off — must not register metrics from the dead 
service: the keys would
+   * outlive it in the global registry and fail the next leader's watch. Only 
never-watched groups
+   * are exposed (a watched group's entry survives in watchedGroups and 
swallows the call), which is
+   * exactly the racing paths' state: a new or re-enabled group, or one the 
round just unwatched.
+   */
+  @Test
+  public void testWatchAfterDisposeDoesNotRegisterMetrics() {
+    ResourceGroup group = buildSlowDraResourceGroup(TEST_GROUP_NAME + "-19", 
0);
+    DefaultOptimizingService formerLeader =
+        new DefaultOptimizingService(
+            new Configurations(), catalogManager(), optimizerManager(), 
tableService(), null, null);
+    formerLeader.dispose();
+
+    formerLeader.updateResourceGroup(group);
+    Assertions.assertNull(
+        draMetric(DynamicAllocationMetrics.OPTIMIZER_GROUP_SCALE_UP_TOTAL, 
group.getName()),
+        "a watch arriving after dispose must not register metrics from a dead 
service");
+  }
+
+  /**
+   * watch() is documented as idempotent: an update of an already-watched 
group with a still-enabled
+   * config — routine property tuning — must re-enter watch() as a no-op 
instead of colliding with
+   * the group's live metric keys.
+   */
+  @Test
+  public void testEnabledUpdateReentersWatchIdempotently() {
+    ResourceGroup group = buildSlowDraResourceGroup(TEST_GROUP_NAME + "-20", 
0);
+    optimizerManager().createResourceGroup(group);
+    optimizingService().createResourceGroup(group);
+    Metric before =
+        draMetric(DynamicAllocationMetrics.OPTIMIZER_GROUP_SCALE_UP_TOTAL, 
group.getName());
+    Assertions.assertNotNull(before);
+
+    Map<String, String> properties = Maps.newHashMap(group.getProperties());
+    properties.put(OptimizerProperties.DYNAMIC_ALLOCATION_MAX_PARALLELISM, 
"6");
+    ResourceGroup updated =
+        new ResourceGroup.Builder(group.getName(), MOCK_CONTAINER_NAME)
+            .addProperties(properties)
+            .build();
+    optimizerManager().updateResourceGroup(updated);
+    optimizingService().updateResourceGroup(updated);
+
+    Assertions.assertSame(
+        before,
+        draMetric(DynamicAllocationMetrics.OPTIMIZER_GROUP_SCALE_UP_TOTAL, 
group.getName()),
+        "an enabled-to-enabled update must keep the group's live metrics, not 
re-register them");
+  }
+
   /**
    * Disabling dynamic allocation mid-drain re-admits the draining pod to task 
assignment: once the
    * legacy floor keeper resumes duty for the group, a leftover drain block 
would starve the pod
diff --git 
a/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/TestOptimizingQueue.java
 
b/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/TestOptimizingQueue.java
index 938b97783..3955e335e 100644
--- 
a/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/TestOptimizingQueue.java
+++ 
b/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/TestOptimizingQueue.java
@@ -20,8 +20,10 @@ package org.apache.amoro.server.optimizing;
 
 import static 
org.apache.amoro.server.optimizing.OptimizerGroupMetrics.GROUP_TAG;
 import static 
org.apache.amoro.server.optimizing.OptimizerGroupMetrics.OPTIMIZER_GROUP_COMMITTING_TABLES;
+import static 
org.apache.amoro.server.optimizing.OptimizerGroupMetrics.OPTIMIZER_GROUP_CONFIG_INVALID;
 import static 
org.apache.amoro.server.optimizing.OptimizerGroupMetrics.OPTIMIZER_GROUP_EXECUTING_TABLES;
 import static 
org.apache.amoro.server.optimizing.OptimizerGroupMetrics.OPTIMIZER_GROUP_EXECUTING_TASKS;
+import static 
org.apache.amoro.server.optimizing.OptimizerGroupMetrics.OPTIMIZER_GROUP_IDLE_OPTIMIZERS;
 import static 
org.apache.amoro.server.optimizing.OptimizerGroupMetrics.OPTIMIZER_GROUP_IDLE_TABLES;
 import static 
org.apache.amoro.server.optimizing.OptimizerGroupMetrics.OPTIMIZER_GROUP_MEMORY_BYTES_ALLOCATED;
 import static 
org.apache.amoro.server.optimizing.OptimizerGroupMetrics.OPTIMIZER_GROUP_OPTIMIZER_INSTANCES;
@@ -31,6 +33,7 @@ import static 
org.apache.amoro.server.optimizing.OptimizerGroupMetrics.OPTIMIZER
 import static 
org.apache.amoro.server.optimizing.OptimizerGroupMetrics.OPTIMIZER_GROUP_THREADS;
 
 import org.apache.amoro.BasicTableTestHelper;
+import org.apache.amoro.OptimizerProperties;
 import org.apache.amoro.ServerTableIdentifier;
 import org.apache.amoro.TableFormat;
 import org.apache.amoro.TableTestHelper;
@@ -58,6 +61,7 @@ import org.apache.amoro.server.table.AMSTableTestBase;
 import org.apache.amoro.server.table.DefaultTableRuntime;
 import org.apache.amoro.shade.guava32.com.google.common.collect.ImmutableMap;
 import org.apache.amoro.shade.guava32.com.google.common.collect.Lists;
+import org.apache.amoro.shade.guava32.com.google.common.collect.Maps;
 import org.apache.amoro.table.MixedTable;
 import org.apache.amoro.table.TableProperties;
 import org.apache.amoro.table.UnkeyedTable;
@@ -778,6 +782,63 @@ public class TestOptimizingQueue extends AMSTableTestBase {
     queue.dispose();
   }
 
+  /** An optimizer is idle while it has no in-flight (SCHEDULED/ACKED) task. */
+  @Test
+  public void testIdleOptimizersMetric() {
+    DefaultTableRuntime tableRuntime = initTableWithFiles();
+    OptimizingQueue queue = buildOptimizingGroupService(tableRuntime);
+    MetricRegistry registry = MetricManager.getInstance().getGlobalRegistry();
+    Map<String, String> tagValues = ImmutableMap.of(GROUP_TAG, 
testResourceGroup().getName());
+    Gauge<Long> idleOptimizersGauge =
+        (Gauge<Long>)
+            registry.getMetrics().get(new 
MetricKey(OPTIMIZER_GROUP_IDLE_OPTIMIZERS, tagValues));
+
+    OptimizerRegisterInfo registerInfo =
+        new OptimizerRegisterInfo(
+            2, 2048, System.currentTimeMillis(), 
testResourceGroup().getName());
+    final OptimizerInstance optimizer = new OptimizerInstance(registerInfo, 
"test_container");
+    queue.addOptimizer(optimizer);
+    Assert.assertEquals(1, idleOptimizersGauge.getValue().longValue());
+
+    OptimizerThread thread =
+        new OptimizerThread(1, null) {
+          @Override
+          public String getToken() {
+            return optimizer.getToken();
+          }
+        };
+    Assert.assertNotNull(queue.pollTask(thread, MAX_POLLING_TIME));
+    Assert.assertEquals(
+        "an optimizer holding an in-flight task is not idle",
+        0,
+        idleOptimizersGauge.getValue().longValue());
+
+    queue.removeOptimizer(optimizer);
+    queue.dispose();
+  }
+
+  /** The gauge flips when a config update leaves an opted-in group with an 
invalid DRA config. */
+  @Test
+  public void testConfigInvalidMetric() {
+    OptimizingQueue queue = buildOptimizingGroupService();
+    MetricRegistry registry = MetricManager.getInstance().getGlobalRegistry();
+    Map<String, String> tagValues = ImmutableMap.of(GROUP_TAG, 
testResourceGroup().getName());
+    Gauge<Integer> configInvalidGauge =
+        (Gauge<Integer>)
+            registry.getMetrics().get(new 
MetricKey(OPTIMIZER_GROUP_CONFIG_INVALID, tagValues));
+    Assert.assertEquals(0, configInvalidGauge.getValue().intValue());
+
+    Map<String, String> props = Maps.newHashMap();
+    props.put(OptimizerProperties.DYNAMIC_ALLOCATION_ENABLED, "true");
+    // Enabled without max-parallelism: invalid, running under the startup 
fail-safe fallback.
+    queue.updateOptimizerGroup(
+        new ResourceGroup.Builder(testResourceGroup().getName(), "local")
+            .addProperties(props)
+            .build());
+    Assert.assertEquals(1, configInvalidGauge.getValue().intValue());
+    queue.dispose();
+  }
+
   @Test
   public void testProcessCloseKeepsLastOptimizedSnapshotId() {
     DefaultTableRuntime tableRuntime = initTableWithFiles();
diff --git 
a/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/dra/TestComputeScaleUp.java
 
b/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/dra/TestComputeScaleUp.java
index 58e852f0f..75ae08c90 100644
--- 
a/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/dra/TestComputeScaleUp.java
+++ 
b/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/dra/TestComputeScaleUp.java
@@ -82,6 +82,31 @@ public class TestComputeScaleUp {
     Assertions.assertEquals(0, state.computeScaleUp(8, 2, 3, 0, config(0, 100, 
2), T0));
   }
 
+  // --- backlogDurationMs: read-side accessor for the backlog gauge ---
+
+  @Test
+  void backlogDurationIsZeroWithoutDemand() {
+    DynamicAllocationState state = new DynamicAllocationState();
+    Assertions.assertEquals(0, state.backlogDurationMs(T0));
+  }
+
+  @Test
+  void backlogDurationTracksSinceDemandFirstObserved() {
+    DynamicAllocationState state = new DynamicAllocationState();
+    DynamicAllocationConfig config = config(0, 100, 2);
+    state.computeScaleUp(2, 2, 5, 0, config, T0);
+    Assertions.assertEquals(BACKLOG_MS - 1, state.backlogDurationMs(T0 + 
BACKLOG_MS - 1));
+  }
+
+  @Test
+  void backlogDurationResetsWhenDemandClears() {
+    DynamicAllocationState state = new DynamicAllocationState();
+    DynamicAllocationConfig config = config(0, 100, 2);
+    state.computeScaleUp(2, 2, 5, 0, config, T0);
+    state.computeScaleUp(8, 2, 0, 0, config, T0 + 10_000);
+    Assertions.assertEquals(0, state.backlogDurationMs(T0 + 10_000));
+  }
+
   // --- immediate demand (Layer 1): backlog timer, ramp, clamp ---
 
   @Test
diff --git 
a/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/dra/TestDynamicAllocationConfig.java
 
b/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/dra/TestDynamicAllocationConfig.java
index e0eec2a03..fefef83f6 100644
--- 
a/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/dra/TestDynamicAllocationConfig.java
+++ 
b/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/dra/TestDynamicAllocationConfig.java
@@ -304,6 +304,53 @@ public class TestDynamicAllocationConfig {
     
Assertions.assertFalse(DynamicAllocationConfig.isEffectivelyEnabled(group(props)));
   }
 
+  // --- isConfigInvalid: read side of the optimizer_group_config_invalid 
gauge ---
+
+  @Test
+  void validEnabledConfigIsNotInvalid() {
+    
Assertions.assertFalse(DynamicAllocationConfig.isConfigInvalid(group(enabledProps())));
+  }
+
+  @Test
+  void groupThatNeverOptedInIsNotInvalid() {
+    Assertions.assertFalse(DynamicAllocationConfig.isConfigInvalid(group(new 
HashMap<>())));
+  }
+
+  @Test
+  void enabledConfigFailingValidationIsInvalid() {
+    Map<String, String> props = new HashMap<>();
+    props.put(OptimizerProperties.DYNAMIC_ALLOCATION_ENABLED, "true");
+    // Enabled without max-parallelism: the startup fail-safe silently 
disables DRA, so this is
+    // exactly the state the gauge must surface.
+    
Assertions.assertTrue(DynamicAllocationConfig.isConfigInvalid(group(props)));
+  }
+
+  @Test
+  void enabledConfigFailingParseIsInvalid() {
+    Map<String, String> props = enabledProps();
+    props.put(OptimizerProperties.DYNAMIC_ALLOCATION_DRAIN_TIMEOUT, 
"not-a-duration");
+    
Assertions.assertTrue(DynamicAllocationConfig.isConfigInvalid(group(props)));
+  }
+
+  @Test
+  void enabledConfigOverflowingDurationIsInvalid() {
+    Map<String, String> props = enabledProps();
+    // Duration parsing multiplies the unit out (Math.multiplyExact), so an 
absurd value throws
+    // ArithmeticException instead of IllegalArgumentException. The gauge read 
must report it as
+    // invalid, not propagate: a gauge exception aborts the whole metrics 
scrape.
+    props.put(OptimizerProperties.DYNAMIC_ALLOCATION_DRAIN_TIMEOUT, 
Long.MAX_VALUE + "min");
+    
Assertions.assertTrue(DynamicAllocationConfig.isConfigInvalid(group(props)));
+  }
+
+  @Test
+  void disabledGroupWithMalformedPropertiesIsNotInvalid() {
+    Map<String, String> props = new HashMap<>();
+    props.put(OptimizerProperties.DYNAMIC_ALLOCATION_DRAIN_TIMEOUT, 
"not-a-duration");
+    // Whatever its leftover properties parse to, a group that has not opted 
in is not in the
+    // fail-safe fallback and must not alarm.
+    
Assertions.assertFalse(DynamicAllocationConfig.isConfigInvalid(group(props)));
+  }
+
   @Test
   void effectiveMinParallelismKeyPrefersNamespacedWhenPresent() {
     Map<String, String> props = new HashMap<>();
diff --git 
a/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/dra/TestDynamicAllocationMetrics.java
 
b/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/dra/TestDynamicAllocationMetrics.java
new file mode 100644
index 000000000..c5240b2de
--- /dev/null
+++ 
b/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/dra/TestDynamicAllocationMetrics.java
@@ -0,0 +1,134 @@
+/*
+ * 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.amoro.server.optimizing.dra;
+
+import org.apache.amoro.metrics.Counter;
+import org.apache.amoro.metrics.Gauge;
+import org.apache.amoro.metrics.Metric;
+import org.apache.amoro.metrics.MetricDefine;
+import org.apache.amoro.metrics.MetricKey;
+import org.apache.amoro.metrics.MetricRegistry;
+import org.apache.amoro.shade.guava32.com.google.common.collect.ImmutableMap;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link DynamicAllocationMetrics}: gauge values are read through 
the injected {@link
+ * DynamicAllocationMetrics.Source}, counters accumulate through the inc 
hooks, and unregister
+ * removes every metric of the group.
+ */
+public class TestDynamicAllocationMetrics {
+
+  private static class FakeSource implements DynamicAllocationMetrics.Source {
+    int pendingRemovalOptimizers;
+    int effectiveThreads;
+    long backlogDurationMs;
+
+    @Override
+    public int pendingRemovalOptimizers() {
+      return pendingRemovalOptimizers;
+    }
+
+    @Override
+    public int effectiveThreads() {
+      return effectiveThreads;
+    }
+
+    @Override
+    public long backlogDurationMs() {
+      return backlogDurationMs;
+    }
+  }
+
+  private final MetricRegistry registry = new MetricRegistry();
+  private final FakeSource source = new FakeSource();
+  private final DynamicAllocationMetrics metrics =
+      new DynamicAllocationMetrics("group1", registry, source);
+
+  private Metric metric(MetricDefine define) {
+    return registry.getMetrics().get(new MetricKey(define, 
ImmutableMap.of("group", "group1")));
+  }
+
+  @Test
+  void gaugesReflectSourceValues() {
+    metrics.register();
+    source.pendingRemovalOptimizers = 2;
+    source.effectiveThreads = 7;
+    source.backlogDurationMs = 45_000L;
+
+    Gauge<?> pendingRemoval =
+        (Gauge<?>) 
metric(DynamicAllocationMetrics.OPTIMIZER_GROUP_PENDING_REMOVAL_OPTIMIZERS);
+    Gauge<?> effectiveThreads =
+        (Gauge<?>) 
metric(DynamicAllocationMetrics.OPTIMIZER_GROUP_EFFECTIVE_THREADS);
+    Gauge<?> backlogDuration =
+        (Gauge<?>) 
metric(DynamicAllocationMetrics.OPTIMIZER_GROUP_BACKLOG_DURATION_MS);
+    Assertions.assertEquals(2, ((Number) 
pendingRemoval.getValue()).intValue());
+    Assertions.assertEquals(7, ((Number) 
effectiveThreads.getValue()).intValue());
+    Assertions.assertEquals(45_000L, ((Number) 
backlogDuration.getValue()).longValue());
+  }
+
+  @Test
+  void countersStartAtZeroAndAccumulate() {
+    metrics.register();
+    Counter scaleUp = (Counter) 
metric(DynamicAllocationMetrics.OPTIMIZER_GROUP_SCALE_UP_TOTAL);
+    Counter scaleDown = (Counter) 
metric(DynamicAllocationMetrics.OPTIMIZER_GROUP_SCALE_DOWN_TOTAL);
+    Assertions.assertEquals(0, scaleUp.getCount());
+    Assertions.assertEquals(0, scaleDown.getCount());
+
+    metrics.incScaleUp();
+    metrics.incScaleUp();
+    metrics.incScaleDown();
+    Assertions.assertEquals(2, scaleUp.getCount());
+    Assertions.assertEquals(1, scaleDown.getCount());
+  }
+
+  @Test
+  void registerRollsBackPartialRegistrationOnFailure() {
+    // Occupy one of the group's keys so register() fails midway through its 
five metrics.
+    MetricKey conflict =
+        registry.register(
+            DynamicAllocationMetrics.OPTIMIZER_GROUP_SCALE_UP_TOTAL,
+            ImmutableMap.of("group", "group1"),
+            new Counter());
+    Assertions.assertThrows(RuntimeException.class, metrics::register);
+    Assertions.assertNull(
+        
metric(DynamicAllocationMetrics.OPTIMIZER_GROUP_PENDING_REMOVAL_OPTIMIZERS),
+        "a failed register() must not leave partially registered metrics 
behind");
+    
Assertions.assertNull(metric(DynamicAllocationMetrics.OPTIMIZER_GROUP_EFFECTIVE_THREADS));
+    
Assertions.assertNull(metric(DynamicAllocationMetrics.OPTIMIZER_GROUP_BACKLOG_DURATION_MS));
+
+    registry.unregister(conflict);
+    metrics.register();
+    
Assertions.assertNotNull(metric(DynamicAllocationMetrics.OPTIMIZER_GROUP_SCALE_UP_TOTAL));
+    Assertions.assertNotNull(
+        
metric(DynamicAllocationMetrics.OPTIMIZER_GROUP_PENDING_REMOVAL_OPTIMIZERS));
+  }
+
+  @Test
+  void unregisterRemovesAllGroupMetrics() {
+    metrics.register();
+    metrics.unregister();
+    Assertions.assertNull(
+        
metric(DynamicAllocationMetrics.OPTIMIZER_GROUP_PENDING_REMOVAL_OPTIMIZERS));
+    
Assertions.assertNull(metric(DynamicAllocationMetrics.OPTIMIZER_GROUP_EFFECTIVE_THREADS));
+    
Assertions.assertNull(metric(DynamicAllocationMetrics.OPTIMIZER_GROUP_BACKLOG_DURATION_MS));
+    
Assertions.assertNull(metric(DynamicAllocationMetrics.OPTIMIZER_GROUP_SCALE_UP_TOTAL));
+    
Assertions.assertNull(metric(DynamicAllocationMetrics.OPTIMIZER_GROUP_SCALE_DOWN_TOTAL));
+  }
+}
diff --git a/docs/user-guides/metrics.md b/docs/user-guides/metrics.md
index ee6c11e21..31c1e10c3 100644
--- a/docs/user-guides/metrics.md
+++ b/docs/user-guides/metrics.md
@@ -77,6 +77,23 @@ Amoro has supported built-in metrics to measure status of 
table self-optimizing
 | optimizer_group_optimizer_instances     | Gauge  | group | Number of 
optimizer instances in optimizer group |
 | optimizer_group_memory_bytes_allocated  | Gauge  | group | Memory bytes 
allocated in optimizer group        |
 | optimizer_group_threads                 | Gauge  | group | Number of total 
threads in optimizer group       |
+| optimizer_group_idle_optimizers         | Gauge  | group | Number of 
optimizer instances with no in-flight task in optimizer group |
+| optimizer_group_config_invalid          | Gauge  | group | 1 while the 
group's dynamic allocation configuration is invalid and the fail-safe fallback 
is active, else 0 |
+
+The following metrics are exported only while dynamic allocation is 
effectively enabled on the group
+(see [Managing optimizers](../managing-optimizers/)); the counters count 
attempted scaling actions, so
+one scale-out round — whatever its instance count and whether its resource 
requests succeed — and one
+drain start each count 1. A rising `scale_up_total` without a matching rise of
+`optimizer_group_optimizer_instances`, sustained beyond the pod boot window, 
signals failing
+resource requests; short gaps are normal while requested pods are still 
booting.
+
+| Metric Name                               | Type    | Tags  | Description    
                                    |
+|-------------------------------------------|---------|-------|----------------------------------------------------|
+| optimizer_group_pending_removal_optimizers | Gauge  | group | Number of 
optimizer instances in graceful drain in optimizer group |
+| optimizer_group_effective_threads         | Gauge   | group | Number of 
registered threads plus threads of optimizers pending registration in optimizer 
group |
+| optimizer_group_backlog_duration_ms       | Gauge   | group | Duration in 
milliseconds since demand first exceeded capacity in optimizer group, 0 while 
there is no backlog |
+| optimizer_group_scale_up_total            | Counter | group | Cumulative 
count of attempted scale-up actions in optimizer group |
+| optimizer_group_scale_down_total          | Counter | group | Cumulative 
count of scale-down actions in optimizer group |
 
 ## Orphan Files Cleaning metrics
 

Reply via email to