This is an automated email from the ASF dual-hosted git repository.
czy006 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 89d27bcd6 [AMORO-4198][optimizer] Support graceful shutdown for
in-progress tasks (#4197)
89d27bcd6 is described below
commit 89d27bcd67541a6d3cc3a983cd9fe09a6e9c5cfb
Author: Jiwon Park <[email protected]>
AuthorDate: Mon Jul 6 13:46:37 2026 +0900
[AMORO-4198][optimizer] Support graceful shutdown for in-progress tasks
(#4197)
* [AMORO][optimizer] Support graceful shutdown for in-progress tasks
On SIGTERM the optimizer flips its stopped flag and returns immediately,
so in-flight task results are silently dropped (completeTask is gated by
isStarted). On K8s this is compounded by `sh -c` swallowing SIGTERM, a
30s default grace period, and Hadoop's FileSystem cache cleanup racing
JVM shutdown hooks.
- Optimizer.stopOptimizing: join executors with a deadline, force
interrupt only on timeout; keep toucher alive so AMS heartbeats
continue while tasks drain.
- OptimizerExecutor.completeTask: best-effort direct call after stop so
the in-flight result still reaches AMS.
- SparkOptimizer / StandaloneOptimizer: register on Hadoop
ShutdownHookManager (priority above FS_CACHE / SparkContext) with an
explicit per-hook timeout.
- OptimizerConfig: new -st / --shutdown-timeout-ms (default 600s).
- KubernetesOptimizerContainer: `sh -c 'exec <args>'` and an explicit
terminationGracePeriodSeconds derived from -st + 30s buffer; user
podTemplate values are respected.
- optimizer.sh start-foreground: exec $CMDS so java gets PID 1.
Signed-off-by: Jiwon Park <[email protected]>
* [AMORO][optimizer] Wire shutdown-timeout-ms group property to -st startup
arg and K8s grace period
The -st/--shutdown-timeout-ms option was parsed by the optimizer but AMS
never emitted it into the startup args, so the configured shutdown timeout
was unreachable for AMS-spawned optimizers and the K8s grace period was
pinned at the default.
- Emit '-st <value>' in buildOptimizerStartupArgsString when the optimizer
group sets the shutdown-timeout-ms property (same pattern as -hb), which
wires all containers sharing the args builder.
- Derive terminationGracePeriodSeconds from the same resource property that
drives the -st arg via PropertyUtil, instead of re-parsing the rendered
CLI string, and remove parseShutdownTimeoutMs. Reading from one source
guarantees the pod's SIGKILL deadline can never fall below the shutdown
timeout the optimizer JVM actually honors.
Signed-off-by: Jiwon Park <[email protected]>
* [AMORO][optimizer] Add graceful drain to Flink optimizer on job
cancellation
FlinkExecutor.close() previously interrupted the executor thread after a
hardcoded 5s join, killing in-progress tasks on job cancellation and
silently ignoring the -st/--shutdown-timeout-ms option on the Flink engine.
- close() now stops the executor first, waits up to the configured
shutdown timeout for the in-progress task to complete (so its result is
reported to AMS), and only then force-interrupts.
- The drain absorbs interrupts of the thread running close(): Flink's
cancellation machinery (TaskCanceler/TaskInterrupter) interrupts the
task thread to unblock I/O, which must not abort the drain early.
- The drain budget is capped safely below Flink's cancellation watchdog
(task.cancellation.timeout, default 180s), which would otherwise fail
the whole TaskManager; operators wanting longer drains must raise both
settings, as documented.
- Document the shutdown-timeout-ms group property, the -st option for
Flink/Spark external optimizers, and the interaction with
task.cancellation.timeout.
Signed-off-by: Jiwon Park <[email protected]>
* [AMORO][optimizer] Harden the shutdown drain so finished task results
survive
Fixes several defects in the graceful-shutdown paths that could still lose
the result of a task that completed during the drain, or leave the process
in a bad state:
- Optimizer.startOptimizing published the executorThreads array before
filling its elements; a concurrent shutdown hook could observe null
elements and skip joining executor threads that were already running
tasks. Build the array fully, then publish, then start the threads.
- The force-interrupt pass in stopOptimizing interleaved interrupt with
per-thread 1s joins: when the stopping thread was itself interrupted
(e.g. the shutdown-hook watchdog cancelling a timed-out hook), join()
threw instantly and executor threads after the first were never
interrupted; the per-thread joins also overshot the deadline by up to N
seconds. Interrupt all survivors first, then await them against one
shared deadline, restoring the caller's interrupt status only after
cleanup completes.
- waitAShortTime restored the interrupt flag unconditionally, but no
caller loop ever clears it: a stray interrupt while still running turned
every subsequent sleep into an instant return, busy-spinning the
poll/retry loops. Preserve the flag only once the operator is stopped.
- completeTask checked isStarted() once at entry and fell back to a single
best-effort call after stop, so a result finishing during shutdown was
dropped on a stop/report race or one transient AMS error. It now retries
through callAuthenticatedAmsWithDrain: a bounded window anchored at the
moment shutdown is observed, reusing the existing transient-error and
auth handling, aborted early by the shutdown force-interrupt. The mock
AMS gains transient-failure injection to cover this.
- The toucher stays alive during the drain for heartbeats, but scale-down
unregisters the optimizer before the pod receives SIGTERM; the resulting
auth error made the toucher re-register a ghost optimizer and rotate the
executors' token so their final completeTask was rejected. Drain mode
now skips re-registration once the token becomes invalid.
- The graceful-shutdown test timed stop() with fixed sleeps against the
poll cadence (~0.5s margins on CI); slow test tasks now signal a latch
when execute() begins so the test stops the optimizer deterministically.
Signed-off-by: Jiwon Park <[email protected]>
* [AMORO][optimizer] Keep the optimizer registration alive during the Flink
drain
Flink cancels the FlinkToucher source before downstream operators, so
heartbeats stop the moment the drain in FlinkExecutor.close() begins. AMS
then expires the optimizer after optimizer.heart-beat-timeout (default
1 min) and, in the same sweep, unregisters it and resets its in-flight
tasks — so any task finishing later than that lost its result even though
the drain was still waiting, capping the effective drain window at the
heartbeat timeout instead of the configured shutdown timeout.
The drain loop now sends a best-effort touch with the existing token
between join slices, at the configured heartbeat interval. Touching keeps
the registration and the task assignment alive for exactly as long as the
drain runs; it never re-registers (consistent with the drain-mode
toucher), and once close() returns the touches stop and AMS cleans up
through the normal expiration path.
Signed-off-by: Jiwon Park <[email protected]>
---------
Signed-off-by: Jiwon Park <[email protected]>
---
.../server/manager/AbstractOptimizerContainer.java | 5 +
.../manager/KubernetesOptimizerContainer.java | 46 +++-
.../manager/TestKubernetesOptimizerContainer.java | 277 ++++++++++++++++++++-
.../java/org/apache/amoro/OptimizerProperties.java | 2 +
.../apache/amoro/MockAmoroManagementServer.java | 15 ++
.../common/AbstractOptimizerOperator.java | 44 +++-
.../apache/amoro/optimizer/common/Optimizer.java | 88 ++++++-
.../amoro/optimizer/common/OptimizerConfig.java | 18 ++
.../amoro/optimizer/common/OptimizerExecutor.java | 17 +-
.../amoro/optimizer/common/OptimizerToucher.java | 55 +++-
.../amoro/optimizer/common/OptimizerTestBase.java | 1 +
.../common/TestAbstractOptimizerOperator.java | 57 +++++
.../amoro/optimizer/common/TestOptimizer.java | 139 +++++++++++
.../optimizer/common/TestOptimizerExecutor.java | 99 ++++++++
.../optimizer/common/TestOptimizerToucher.java | 32 +++
.../amoro/optimizer/flink/FlinkExecutor.java | 94 ++++++-
.../amoro/optimizer/flink/FlinkOptimizer.java | 3 +-
.../optimizer/flink/FlinkOptimizerExecutor.java | 21 ++
.../amoro/optimizer/flink/TestFlinkExecutor.java | 178 +++++++++++++
.../flink/TestFlinkOptimizerExecutor.java | 66 +++++
.../amoro/optimizer/spark/SparkOptimizer.java | 26 ++
.../optimizer/standalone/StandaloneOptimizer.java | 24 ++
dist/src/main/amoro-bin/bin/optimizer.sh | 2 +-
docs/admin-guides/managing-optimizers.md | 4 +
24 files changed, 1278 insertions(+), 35 deletions(-)
diff --git
a/amoro-ams/src/main/java/org/apache/amoro/server/manager/AbstractOptimizerContainer.java
b/amoro-ams/src/main/java/org/apache/amoro/server/manager/AbstractOptimizerContainer.java
index 05745c79b..118fa25cc 100644
---
a/amoro-ams/src/main/java/org/apache/amoro/server/manager/AbstractOptimizerContainer.java
+++
b/amoro-ams/src/main/java/org/apache/amoro/server/manager/AbstractOptimizerContainer.java
@@ -92,6 +92,11 @@ public abstract class AbstractOptimizerContainer implements
InternalResourceCont
.append(" -hb ")
.append(resource.getProperties().get(OptimizerProperties.OPTIMIZER_HEART_BEAT_INTERVAL));
}
+ if
(resource.getProperties().containsKey(OptimizerProperties.OPTIMIZER_SHUTDOWN_TIMEOUT_MS))
{
+ stringBuilder
+ .append(" -st ")
+
.append(resource.getProperties().get(OptimizerProperties.OPTIMIZER_SHUTDOWN_TIMEOUT_MS));
+ }
if (PropertyUtil.propertyAsBoolean(
resource.getProperties(),
OptimizerProperties.OPTIMIZER_EXTEND_DISK_STORAGE,
diff --git
a/amoro-ams/src/main/java/org/apache/amoro/server/manager/KubernetesOptimizerContainer.java
b/amoro-ams/src/main/java/org/apache/amoro/server/manager/KubernetesOptimizerContainer.java
index 850fba4eb..832668811 100644
---
a/amoro-ams/src/main/java/org/apache/amoro/server/manager/KubernetesOptimizerContainer.java
+++
b/amoro-ams/src/main/java/org/apache/amoro/server/manager/KubernetesOptimizerContainer.java
@@ -34,10 +34,12 @@ import io.fabric8.kubernetes.client.Config;
import io.fabric8.kubernetes.client.ConfigBuilder;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.fabric8.kubernetes.client.KubernetesClientBuilder;
+import org.apache.amoro.OptimizerProperties;
import org.apache.amoro.resource.Resource;
import org.apache.amoro.shade.guava32.com.google.common.base.Preconditions;
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.amoro.utils.PropertyUtil;
import org.apache.commons.io.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -75,6 +77,13 @@ public class KubernetesOptimizerContainer extends
AbstractOptimizerContainer {
private static final String EXTRA_PROPERTY_PREFIX = "extra.";
+ /**
+ * Buffer added on top of optimizer's shutdown-timeout when deriving K8s
+ * terminationGracePeriodSeconds, to leave room for thread join, best-effort
task result
+ * reporting, and log flush before SIGKILL.
+ */
+ static final long TERMINATION_GRACE_BUFFER_SECONDS = 30L;
+
private static final Map<String, String> EXTRA_PROPERTY_DEFAULTS = new
HashMap<>();
static {
@@ -86,6 +95,16 @@ public class KubernetesOptimizerContainer extends
AbstractOptimizerContainer {
EXTRA_PROPERTY_PREFIX + key, EXTRA_PROPERTY_DEFAULTS.getOrDefault(key,
null));
}
+ static long resolveTerminationGracePeriodSeconds(Map<String, String>
properties) {
+ long shutdownTimeoutMs =
+ PropertyUtil.propertyAsLong(
+ properties,
+ OptimizerProperties.OPTIMIZER_SHUTDOWN_TIMEOUT_MS,
+ OptimizerProperties.OPTIMIZER_SHUTDOWN_TIMEOUT_MS_DEFAULT);
+ long shutdownTimeoutSeconds = (shutdownTimeoutMs + 999L) / 1000L;
+ return shutdownTimeoutSeconds + TERMINATION_GRACE_BUFFER_SECONDS;
+ }
+
private KubernetesClient client;
@Override
@@ -118,6 +137,7 @@ public class KubernetesOptimizerContainer extends
AbstractOptimizerContainer {
String groupName = argsList.get("groupName").toString();
String resourceId = argsList.get("resourceId").toString();
String startUpArgs = argsList.get("startUpArgs").toString();
+ long terminationGraceSeconds = (long)
argsList.get("terminationGraceSeconds");
String kubernetesName = NAME_PREFIX + resourceId;
Deployment deployment;
@@ -136,7 +156,8 @@ public class KubernetesOptimizerContainer extends
AbstractOptimizerContainer {
resourceId,
startUpArgs,
memory,
- imagePullSecretsList);
+ imagePullSecretsList,
+ terminationGraceSeconds);
} else {
deployment =
initPodTemplateWithoutConfig(
@@ -147,7 +168,8 @@ public class KubernetesOptimizerContainer extends
AbstractOptimizerContainer {
resourceId,
startUpArgs,
memory,
- imagePullSecretsList);
+ imagePullSecretsList,
+ terminationGraceSeconds);
}
client.apps().deployments().inNamespace(namespace).resource(deployment).create();
@@ -206,6 +228,11 @@ public class KubernetesOptimizerContainer extends
AbstractOptimizerContainer {
argsList.put("resourceId", resourceId);
argsList.put("groupName", groupName);
argsList.put("startUpArgs", startUpArgs);
+ // Derive the grace period from the same resource properties that drive
the -st startup arg
+ // (buildOptimizerStartupArgsString), so the pod's SIGKILL deadline can
never fall below the
+ // shutdown timeout the optimizer JVM actually honors.
+ argsList.put(
+ "terminationGraceSeconds",
resolveTerminationGracePeriodSeconds(resource.getProperties()));
return argsList;
}
@@ -218,7 +245,8 @@ public class KubernetesOptimizerContainer extends
AbstractOptimizerContainer {
String resourceId,
String startUpArgs,
long memory,
- List<LocalObjectReference> imagePullSecretsList) {
+ List<LocalObjectReference> imagePullSecretsList,
+ long terminationGraceSeconds) {
DeploymentBuilder deploymentBuilder =
new DeploymentBuilder()
@@ -234,11 +262,12 @@ public class KubernetesOptimizerContainer extends
AbstractOptimizerContainer {
.addToLabels("AmoroResourceId", resourceId)
.endMetadata()
.withNewSpec()
+ .withTerminationGracePeriodSeconds(terminationGraceSeconds)
.addNewContainer()
.withName("optimizer")
.withImage(image)
.withImagePullPolicy(pullPolicy)
- .withCommand("sh", "-c", startUpArgs)
+ .withCommand("sh", "-c", "exec " + startUpArgs)
.withResources(
new ResourceRequirementsBuilder()
.withLimits(
@@ -288,7 +317,8 @@ public class KubernetesOptimizerContainer extends
AbstractOptimizerContainer {
String resourceId,
String startUpArgs,
long memory,
- List<LocalObjectReference> imagePullSecretsList) {
+ List<LocalObjectReference> imagePullSecretsList,
+ long terminationGraceSeconds) {
podTemplate
.getTemplate()
.getMetadata()
@@ -305,7 +335,7 @@ public class KubernetesOptimizerContainer extends
AbstractOptimizerContainer {
container.setName("optimizer");
container.setImage(image);
container.setImagePullPolicy(pullPolicy);
- container.setCommand(new ArrayList<>(Arrays.asList("sh", "-c",
startUpArgs)));
+ container.setCommand(new ArrayList<>(Arrays.asList("sh", "-c", "exec " +
startUpArgs)));
ResourceRequirements resourceRequirements = new ResourceRequirements();
resourceRequirements.setLimits(
@@ -324,6 +354,10 @@ public class KubernetesOptimizerContainer extends
AbstractOptimizerContainer {
podTemplate.getTemplate().getSpec().setImagePullSecrets(imagePullSecretsList);
}
+ if (podTemplate.getTemplate().getSpec().getTerminationGracePeriodSeconds()
== null) {
+
podTemplate.getTemplate().getSpec().setTerminationGracePeriodSeconds(terminationGraceSeconds);
+ }
+
DeploymentSpec deploymentSpec = new DeploymentSpec();
deploymentSpec.setTemplate(podTemplate.getTemplate());
diff --git
a/amoro-ams/src/test/java/org/apache/amoro/server/manager/TestKubernetesOptimizerContainer.java
b/amoro-ams/src/test/java/org/apache/amoro/server/manager/TestKubernetesOptimizerContainer.java
index 417887052..7c117b847 100644
---
a/amoro-ams/src/test/java/org/apache/amoro/server/manager/TestKubernetesOptimizerContainer.java
+++
b/amoro-ams/src/test/java/org/apache/amoro/server/manager/TestKubernetesOptimizerContainer.java
@@ -130,6 +130,7 @@ public class TestKubernetesOptimizerContainer {
String groupName = argsList.get("groupName").toString();
String resourceId = argsList.get("resourceId").toString();
String startUpArgs = argsList.get("startUpArgs").toString();
+ long terminationGraceSeconds = (long)
argsList.get("terminationGraceSeconds");
Deployment deployment =
kubernetesOptimizerContainer.initPodTemplateFromFrontEnd(
@@ -141,7 +142,8 @@ public class TestKubernetesOptimizerContainer {
resourceId,
startUpArgs,
memory,
- imagePullSecretsList);
+ imagePullSecretsList,
+ terminationGraceSeconds);
Assert.assertEquals(1, deployment.getSpec().getReplicas().intValue());
Assert.assertNotEquals(
@@ -217,6 +219,8 @@ public class TestKubernetesOptimizerContainer {
String resourceId = resource.getResourceId();
String groupName = resource.getGroupName();
+ long terminationGraceSeconds =
+
KubernetesOptimizerContainer.resolveTerminationGracePeriodSeconds(groupProperties);
Assert.assertEquals(1,
podTemplate.getTemplate().getSpec().getContainers().size());
@@ -234,7 +238,8 @@ public class TestKubernetesOptimizerContainer {
resourceId,
startUpArgs,
memory,
- imagePullSecretsList);
+ imagePullSecretsList,
+ terminationGraceSeconds);
Assert.assertEquals("amoro-optimizer-" + resourceId,
deployment.getMetadata().getName());
Assert.assertEquals(
@@ -278,6 +283,269 @@ public class TestKubernetesOptimizerContainer {
.toString());
}
+ @Test
+ public void testContainerCommandUsesExecForSignalForwarding() {
+ ResourceType resourceType = ResourceType.OPTIMIZER;
+ Map<String, String> properties = Maps.newHashMap();
+ properties.put("memory", "1024");
+ Resource resource =
+ new Resource.Builder("KubernetesContainer", "k8s", resourceType)
+ .setMemoryMb(1024)
+ .setThreadCount(1)
+ .setProperties(properties)
+ .build();
+ groupProperties.putAll(resource.getProperties());
+
+ Map<String, Object> argsList =
+ kubernetesOptimizerContainer.generatePodStartArgs(resource,
groupProperties);
+ String image = argsList.get(IMAGE).toString();
+ String pullPolicy = argsList.get(PULL_POLICY).toString();
+ List<LocalObjectReference> imagePullSecretsList =
+ (List<LocalObjectReference>) argsList.get(PULL_SECRETS);
+ int cpuLimit = (int) argsList.get("cpuLimit");
+ long memory = (long) argsList.get(MEMORY_PROPERTY);
+ String groupName = argsList.get("groupName").toString();
+ String resourceId = argsList.get("resourceId").toString();
+ String startUpArgs = argsList.get("startUpArgs").toString();
+ long terminationGraceSeconds = (long)
argsList.get("terminationGraceSeconds");
+
+ Deployment deployment =
+ kubernetesOptimizerContainer.initPodTemplateWithoutConfig(
+ image,
+ pullPolicy,
+ cpuLimit,
+ groupName,
+ resourceId,
+ startUpArgs,
+ memory,
+ imagePullSecretsList,
+ terminationGraceSeconds);
+
+ List<String> command =
+
deployment.getSpec().getTemplate().getSpec().getContainers().get(0).getCommand();
+ Assert.assertEquals(Arrays.asList("sh", "-c", "exec " + startUpArgs),
command);
+ }
+
+ @Test
+ public void testContainerCommandUsesExecWithPodTemplate() {
+ PodTemplate podTemplate =
+ kubernetesOptimizerContainer.initPodTemplateFromLocal(groupProperties);
+
+ ResourceType resourceType = ResourceType.OPTIMIZER;
+ Map<String, String> properties = Maps.newHashMap();
+ properties.put("memory", "1024");
+ Resource resource =
+ new Resource.Builder("KubernetesContainer", "k8s", resourceType)
+ .setMemoryMb(1024)
+ .setThreadCount(1)
+ .setProperties(properties)
+ .build();
+ groupProperties.putAll(resource.getProperties());
+
+ Map<String, Object> argsList =
+ kubernetesOptimizerContainer.generatePodStartArgs(resource,
groupProperties);
+ String image = argsList.get(IMAGE).toString();
+ String pullPolicy = argsList.get(PULL_POLICY).toString();
+ List<LocalObjectReference> imagePullSecretsList =
+ (List<LocalObjectReference>) argsList.get(PULL_SECRETS);
+ int cpuLimit = (int) argsList.get("cpuLimit");
+ long memory = (long) argsList.get(MEMORY_PROPERTY);
+ String groupName = argsList.get("groupName").toString();
+ String resourceId = argsList.get("resourceId").toString();
+ String startUpArgs = argsList.get("startUpArgs").toString();
+ long terminationGraceSeconds = (long)
argsList.get("terminationGraceSeconds");
+
+ Deployment deployment =
+ kubernetesOptimizerContainer.initPodTemplateFromFrontEnd(
+ podTemplate,
+ image,
+ pullPolicy,
+ cpuLimit,
+ groupName,
+ resourceId,
+ startUpArgs,
+ memory,
+ imagePullSecretsList,
+ terminationGraceSeconds);
+
+ List<String> command =
+
deployment.getSpec().getTemplate().getSpec().getContainers().get(0).getCommand();
+ Assert.assertEquals(Arrays.asList("sh", "-c", "exec " + startUpArgs),
command);
+ }
+
+ @Test
+ public void testTerminationGracePeriodFromDefaultShutdownTimeout() {
+ ResourceType resourceType = ResourceType.OPTIMIZER;
+ Map<String, String> properties = Maps.newHashMap();
+ properties.put("memory", "1024");
+ Resource resource =
+ new Resource.Builder("KubernetesContainer", "k8s", resourceType)
+ .setMemoryMb(1024)
+ .setThreadCount(1)
+ .setProperties(properties)
+ .build();
+ groupProperties.putAll(resource.getProperties());
+
+ Map<String, Object> argsList =
+ kubernetesOptimizerContainer.generatePodStartArgs(resource,
groupProperties);
+ String image = argsList.get(IMAGE).toString();
+ String pullPolicy = argsList.get(PULL_POLICY).toString();
+ List<LocalObjectReference> imagePullSecretsList =
+ (List<LocalObjectReference>) argsList.get(PULL_SECRETS);
+ int cpuLimit = (int) argsList.get("cpuLimit");
+ long memory = (long) argsList.get(MEMORY_PROPERTY);
+ String groupName = argsList.get("groupName").toString();
+ String resourceId = argsList.get("resourceId").toString();
+ String startUpArgs = argsList.get("startUpArgs").toString();
+ long terminationGraceSeconds = (long)
argsList.get("terminationGraceSeconds");
+
+ Assert.assertFalse(
+ "startUpArgs should not contain the -st flag when the property is
unset, but was: "
+ + startUpArgs,
+ startUpArgs.contains(" -st "));
+
+ Deployment deployment =
+ kubernetesOptimizerContainer.initPodTemplateWithoutConfig(
+ image,
+ pullPolicy,
+ cpuLimit,
+ groupName,
+ resourceId,
+ startUpArgs,
+ memory,
+ imagePullSecretsList,
+ terminationGraceSeconds);
+
+ Long grace =
deployment.getSpec().getTemplate().getSpec().getTerminationGracePeriodSeconds();
+ Assert.assertNotNull(grace);
+ // default shutdown-timeout = 600_000ms → 600s + 30s buffer
+ Assert.assertEquals(630L, grace.longValue());
+ }
+
+ @Test
+ public void testShutdownTimeoutPropertyWiredToStartupArgsAndGracePeriod() {
+ ResourceType resourceType = ResourceType.OPTIMIZER;
+ Map<String, String> properties = Maps.newHashMap();
+ properties.put("memory", "1024");
+ properties.put(OptimizerProperties.OPTIMIZER_SHUTDOWN_TIMEOUT_MS,
"120000");
+ Resource resource =
+ new Resource.Builder("KubernetesContainer", "k8s", resourceType)
+ .setMemoryMb(1024)
+ .setThreadCount(1)
+ .setProperties(properties)
+ .build();
+ groupProperties.putAll(resource.getProperties());
+
+ Map<String, Object> argsList =
+ kubernetesOptimizerContainer.generatePodStartArgs(resource,
groupProperties);
+ String image = argsList.get(IMAGE).toString();
+ String pullPolicy = argsList.get(PULL_POLICY).toString();
+ List<LocalObjectReference> imagePullSecretsList =
+ (List<LocalObjectReference>) argsList.get(PULL_SECRETS);
+ int cpuLimit = (int) argsList.get("cpuLimit");
+ long memory = (long) argsList.get(MEMORY_PROPERTY);
+ String groupName = argsList.get("groupName").toString();
+ String resourceId = argsList.get("resourceId").toString();
+ String startUpArgs = argsList.get("startUpArgs").toString();
+ long terminationGraceSeconds = (long)
argsList.get("terminationGraceSeconds");
+
+ Assert.assertTrue(
+ "startUpArgs should contain the -st flag, but was: " + startUpArgs,
+ startUpArgs.contains(" -st 120000"));
+
+ Deployment deployment =
+ kubernetesOptimizerContainer.initPodTemplateWithoutConfig(
+ image,
+ pullPolicy,
+ cpuLimit,
+ groupName,
+ resourceId,
+ startUpArgs,
+ memory,
+ imagePullSecretsList,
+ terminationGraceSeconds);
+
+ Long grace =
deployment.getSpec().getTemplate().getSpec().getTerminationGracePeriodSeconds();
+ // 120_000ms → 120s + 30s buffer
+ Assert.assertEquals(Long.valueOf(150L), grace);
+ }
+
+ @Test
+ public void testContainerLevelShutdownTimeoutDoesNotDesyncGracePeriod() {
+ ResourceType resourceType = ResourceType.OPTIMIZER;
+ Map<String, String> properties = Maps.newHashMap();
+ properties.put("memory", "1024");
+ Resource resource =
+ new Resource.Builder("KubernetesContainer", "k8s", resourceType)
+ .setMemoryMb(1024)
+ .setThreadCount(1)
+ .setProperties(properties)
+ .build();
+ groupProperties.putAll(resource.getProperties());
+ // Simulate a container-level property: present in the merged group
properties but NOT in
+ // the resource properties that drive the -st startup arg. The grace
period must stay in
+ // sync with the arg (i.e. keep the default), not silently shrink below
the JVM's timeout.
+ groupProperties.put(OptimizerProperties.OPTIMIZER_SHUTDOWN_TIMEOUT_MS,
"120000");
+
+ Map<String, Object> argsList =
+ kubernetesOptimizerContainer.generatePodStartArgs(resource,
groupProperties);
+ String startUpArgs = argsList.get("startUpArgs").toString();
+ long terminationGraceSeconds = (long)
argsList.get("terminationGraceSeconds");
+
+ Assert.assertFalse(
+ "startUpArgs should not contain -st for a container-level property: "
+ startUpArgs,
+ startUpArgs.contains(" -st "));
+ // 600_000ms default → 600s + 30s buffer, consistent with the args the pod
receives
+ Assert.assertEquals(630L, terminationGraceSeconds);
+ }
+
+ @Test
+ public void testTerminationGracePeriodFromUserPodTemplateRespected() {
+ PodTemplate podTemplate =
+ kubernetesOptimizerContainer.initPodTemplateFromLocal(groupProperties);
+ podTemplate.getTemplate().getSpec().setTerminationGracePeriodSeconds(900L);
+
+ ResourceType resourceType = ResourceType.OPTIMIZER;
+ Map<String, String> properties = Maps.newHashMap();
+ properties.put("memory", "1024");
+ Resource resource =
+ new Resource.Builder("KubernetesContainer", "k8s", resourceType)
+ .setMemoryMb(1024)
+ .setThreadCount(1)
+ .setProperties(properties)
+ .build();
+ groupProperties.putAll(resource.getProperties());
+
+ Map<String, Object> argsList =
+ kubernetesOptimizerContainer.generatePodStartArgs(resource,
groupProperties);
+ String image = argsList.get(IMAGE).toString();
+ String pullPolicy = argsList.get(PULL_POLICY).toString();
+ List<LocalObjectReference> imagePullSecretsList =
+ (List<LocalObjectReference>) argsList.get(PULL_SECRETS);
+ int cpuLimit = (int) argsList.get("cpuLimit");
+ long memory = (long) argsList.get(MEMORY_PROPERTY);
+ String groupName = argsList.get("groupName").toString();
+ String resourceId = argsList.get("resourceId").toString();
+ String startUpArgs = argsList.get("startUpArgs").toString();
+ long terminationGraceSeconds = (long)
argsList.get("terminationGraceSeconds");
+
+ Deployment deployment =
+ kubernetesOptimizerContainer.initPodTemplateFromFrontEnd(
+ podTemplate,
+ image,
+ pullPolicy,
+ cpuLimit,
+ groupName,
+ resourceId,
+ startUpArgs,
+ memory,
+ imagePullSecretsList,
+ terminationGraceSeconds);
+
+ Long grace =
deployment.getSpec().getTemplate().getSpec().getTerminationGracePeriodSeconds();
+ Assert.assertEquals(Long.valueOf(900L), grace);
+ }
+
@Test
public void testAMSWithConfigMap() throws Exception {
ConfigMap configMap = buildConfigMap();
@@ -325,6 +593,8 @@ public class TestKubernetesOptimizerContainer {
String resourceId = resource.getResourceId();
String groupName = resource.getGroupName();
+ long terminationGraceSeconds =
+
KubernetesOptimizerContainer.resolveTerminationGracePeriodSeconds(groupProperties);
Assert.assertEquals(1,
podTemplate.getTemplate().getSpec().getContainers().size());
Assert.assertEquals(
@@ -340,7 +610,8 @@ public class TestKubernetesOptimizerContainer {
resourceId,
startUpArgs,
memory,
- imagePullSecretsList);
+ imagePullSecretsList,
+ terminationGraceSeconds);
// Assert the Deployment
Assert.assertEquals("amoro-optimizer-" + resourceId,
deployment.getMetadata().getName());
diff --git
a/amoro-common/src/main/java/org/apache/amoro/OptimizerProperties.java
b/amoro-common/src/main/java/org/apache/amoro/OptimizerProperties.java
index dd4a6f624..3c5e7f900 100644
--- a/amoro-common/src/main/java/org/apache/amoro/OptimizerProperties.java
+++ b/amoro-common/src/main/java/org/apache/amoro/OptimizerProperties.java
@@ -49,4 +49,6 @@ public class OptimizerProperties {
public static final String OPTIMIZER_CACHE_TIMEOUT = "cache-timeout";
public static final String OPTIMIZER_CACHE_TIMEOUT_DEFAULT = "10min";
public static final String OPTIMIZER_MASTER_SLAVE_MODE_ENABLED =
"master-slave-mode-enabled";
+ public static final String OPTIMIZER_SHUTDOWN_TIMEOUT_MS =
"shutdown-timeout-ms";
+ public static final long OPTIMIZER_SHUTDOWN_TIMEOUT_MS_DEFAULT = 600_000L;
// 10 min
}
diff --git
a/amoro-common/src/test/java/org/apache/amoro/MockAmoroManagementServer.java
b/amoro-common/src/test/java/org/apache/amoro/MockAmoroManagementServer.java
index ae8f46285..058b59854 100644
--- a/amoro-common/src/test/java/org/apache/amoro/MockAmoroManagementServer.java
+++ b/amoro-common/src/test/java/org/apache/amoro/MockAmoroManagementServer.java
@@ -412,6 +412,15 @@ public class MockAmoroManagementServer implements Runnable
{
new ConcurrentHashMap<>();
private final Map<String, List<OptimizingTaskResult>> completedTasks =
new ConcurrentHashMap<>();
+ private final AtomicInteger completeTaskFailures = new AtomicInteger(0);
+
+ /**
+ * Injects transient failures: the next {@code count} completeTask calls
throw a retryable
+ * error.
+ */
+ public void failNextCompleteTasks(int count) {
+ completeTaskFailures.set(count);
+ }
public void cleanUp() {}
@@ -449,6 +458,12 @@ public class MockAmoroManagementServer implements Runnable
{
@Override
public void completeTask(String authToken, OptimizingTaskResult
taskResult) throws TException {
checkToken(authToken);
+ if (completeTaskFailures.getAndUpdate(c -> c > 0 ? c - 1 : 0) > 0) {
+ throw new AmoroException(
+ ErrorCodes.PERSISTENCE_ERROR_CODE,
+ "InjectedTransientError",
+ "injected transient error for testing");
+ }
executingTasks.get(authToken).remove(taskResult.getThreadId());
if (!completedTasks.containsKey(authToken)) {
completedTasks.putIfAbsent(authToken, new CopyOnWriteArrayList<>());
diff --git
a/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/AbstractOptimizerOperator.java
b/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/AbstractOptimizerOperator.java
index af002bc0f..93acb42b4 100644
---
a/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/AbstractOptimizerOperator.java
+++
b/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/AbstractOptimizerOperator.java
@@ -33,6 +33,7 @@ import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.BooleanSupplier;
public class AbstractOptimizerOperator implements Serializable {
private static final Logger LOG =
LoggerFactory.getLogger(AbstractOptimizerOperator.class);
@@ -219,11 +220,44 @@ public class AbstractOptimizerOperator implements
Serializable {
*/
protected <T> T callAuthenticatedAms(String amsUrl,
AmsAuthenticatedCallOperation<T> operation)
throws TException {
+ return callAuthenticatedAms(amsUrl, operation, this::isStarted);
+ }
+
+ /**
+ * Variant for the shutdown drain: keeps the retry loop alive for a bounded
window even after
+ * stop(), so a result produced by an in-flight task is not dropped on the
first transient error
+ * or because stop() landed between the caller's check and the retry loop.
The window is anchored
+ * at the moment stop() is first observed, not at call entry — a call that
already spent time
+ * retrying while running still gets the full drain window once shutdown
begins. Aborts early when
+ * the calling thread is force-interrupted by the shutdown deadline.
+ */
+ protected <T> T callAuthenticatedAmsWithDrain(
+ String amsUrl, AmsAuthenticatedCallOperation<T> operation, long
drainTimeoutMs)
+ throws TException {
+ long[] drainDeadline = {-1};
+ return callAuthenticatedAms(
+ amsUrl,
+ operation,
+ () -> {
+ if (isStarted()) {
+ return true;
+ }
+ if (drainDeadline[0] < 0) {
+ drainDeadline[0] = System.currentTimeMillis() + drainTimeoutMs;
+ }
+ return System.currentTimeMillis() < drainDeadline[0]
+ && !Thread.currentThread().isInterrupted();
+ });
+ }
+
+ private <T> T callAuthenticatedAms(
+ String amsUrl, AmsAuthenticatedCallOperation<T> operation,
BooleanSupplier proceed)
+ throws TException {
// Per-node retry budget: in master-slave mode, limit consecutive
shouldRetryLater retries so
// that a permanently unreachable node does not block the multi-node
polling loop indefinitely.
int consecutiveRetries = 0;
- while (isStarted()) {
+ while (proceed.getAsBoolean()) {
if (tokenIsReady()) {
String token = getToken();
try {
@@ -345,7 +379,13 @@ public class AbstractOptimizerOperator implements
Serializable {
try {
TimeUnit.MILLISECONDS.sleep(waitTime);
} catch (InterruptedException e) {
- // ignore
+ // Preserve the flag only when stopping, so the shutdown path skips
residual sleeps.
+ // While still running, swallow a stray interrupt as before: no caller
loop clears
+ // the flag, so preserving it would turn every later sleep into an
instant return
+ // and the poll/retry loops into a busy spin.
+ if (!isStarted()) {
+ Thread.currentThread().interrupt();
+ }
}
}
diff --git
a/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/Optimizer.java
b/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/Optimizer.java
index fbff34fc4..cdf1f2a45 100644
---
a/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/Optimizer.java
+++
b/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/Optimizer.java
@@ -33,6 +33,7 @@ public class Optimizer {
private final OptimizerConfig config;
private final OptimizerToucher toucher;
private final OptimizerExecutor[] executors;
+ private volatile Thread[] executorThreads;
public Optimizer(OptimizerConfig config) {
this(config, () -> new OptimizerToucher(config), (i) -> new
OptimizerExecutor(config, i));
@@ -54,20 +55,89 @@ public class Optimizer {
public void startOptimizing() {
LOG.info("Starting optimizer with configuration:{}", config);
- Arrays.stream(executors)
- .forEach(
- optimizerExecutor -> {
- new Thread(
- optimizerExecutor::start,
- String.format("Optimizer-executor-%d",
optimizerExecutor.getThreadId()))
- .start();
- });
+ Thread[] threads = new Thread[executors.length];
+ for (int i = 0; i < executors.length; i++) {
+ threads[i] =
+ new Thread(
+ executors[i]::start,
+ String.format("Optimizer-executor-%d",
executors[i].getThreadId()));
+ }
+ // Publish the fully-built array before starting any thread: a concurrent
shutdown hook
+ // reads executorThreads, and volatile orders only the array reference,
not element
+ // writes made after publication.
+ executorThreads = threads;
+ for (Thread thread : threads) {
+ thread.start();
+ }
toucher.withTokenChangeListener(new SetTokenToExecutors()).start();
}
public void stopOptimizing() {
- toucher.stop();
+ LOG.info("Stopping optimizer, waiting for in-progress tasks to
complete...");
+ // Stop executors first so they don't poll new tasks, but keep the toucher
alive
+ // so it keeps sending heartbeats. Otherwise AMS hits its
heartbeat-timeout during
+ // long-running in-flight tasks, unregisters this optimizer, and the
subsequent
+ // best-effort completeTask fails with "Optimizer has not been
authenticated".
+ // Drain mode stops the toucher from re-registering if AMS has already
unregistered
+ // this optimizer (scale-down releases the resource before the pod gets
SIGTERM).
+ toucher.enterDrainMode();
Arrays.stream(executors).forEach(OptimizerExecutor::stop);
+
+ Thread[] threads = executorThreads;
+ if (threads == null) {
+ toucher.stop();
+ LOG.info("Optimizer stopped (no executor threads to wait for)");
+ return;
+ }
+
+ long shutdownTimeoutMs = config.getShutdownTimeoutMs();
+ boolean selfInterrupted = !joinAll(threads, shutdownTimeoutMs);
+
+ // Force-interrupt pass: interrupt ALL survivors first, then wait for them
against one
+ // shared deadline. Interleaving interrupt with per-thread joins would
both serialize
+ // the waits (N extra seconds past the deadline) and, when this thread was
itself
+ // interrupted above, skip interrupting the remaining threads because
join() throws
+ // instantly while the interrupt flag is set.
+ boolean anyAlive = false;
+ for (Thread t : threads) {
+ if (t.isAlive()) {
+ anyAlive = true;
+ LOG.warn(
+ "Executor thread {} did not terminate within {}ms timeout,
force-interrupting",
+ t.getName(),
+ shutdownTimeoutMs);
+ t.interrupt();
+ }
+ }
+ if (anyAlive) {
+ selfInterrupted |= !joinAll(threads, 1_000);
+ }
+ toucher.stop();
+ if (selfInterrupted) {
+ Thread.currentThread().interrupt();
+ }
+ LOG.info("Optimizer stopped");
+ }
+
+ /**
+ * Joins all threads against one shared deadline. Returns false if the
calling thread was
+ * interrupted while waiting; the interrupt flag is left cleared so
subsequent joins work.
+ */
+ private static boolean joinAll(Thread[] threads, long timeoutMs) {
+ long deadline = System.currentTimeMillis() + timeoutMs;
+ for (Thread t : threads) {
+ long remaining = deadline - System.currentTimeMillis();
+ if (remaining <= 0) {
+ return true;
+ }
+ try {
+ t.join(remaining);
+ } catch (InterruptedException e) {
+ LOG.warn("Interrupted while waiting for executor thread {} to finish",
t.getName());
+ return false;
+ }
+ }
+ return true;
}
public OptimizerToucher getToucher() {
diff --git
a/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/OptimizerConfig.java
b/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/OptimizerConfig.java
index 2c70a2652..ebe8c63d7 100644
---
a/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/OptimizerConfig.java
+++
b/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/OptimizerConfig.java
@@ -109,6 +109,15 @@ public class OptimizerConfig implements Serializable {
usage = "Enable master-slave mode")
private boolean masterSlaveMode = false;
+ @Option(
+ name = "-st",
+ aliases = "--" + OptimizerProperties.OPTIMIZER_SHUTDOWN_TIMEOUT_MS,
+ usage =
+ "Graceful shutdown timeout(ms) — wait this long for in-progress
tasks before "
+ + "force-interrupting. Default 600000 (10min). Align with K8s "
+ + "terminationGracePeriodSeconds in containerized deployments.")
+ private long shutdownTimeoutMs =
OptimizerProperties.OPTIMIZER_SHUTDOWN_TIMEOUT_MS_DEFAULT;
+
public OptimizerConfig() {}
public OptimizerConfig(String[] args) throws CmdLineException {
@@ -228,6 +237,14 @@ public class OptimizerConfig implements Serializable {
this.masterSlaveMode = masterSlaveMode;
}
+ public long getShutdownTimeoutMs() {
+ return shutdownTimeoutMs;
+ }
+
+ public void setShutdownTimeoutMs(long shutdownTimeoutMs) {
+ this.shutdownTimeoutMs = shutdownTimeoutMs;
+ }
+
@Override
public String toString() {
return MoreObjects.toStringHelper(this)
@@ -245,6 +262,7 @@ public class OptimizerConfig implements Serializable {
.add("cacheMaxEntrySize", cacheMaxEntrySize)
.add("cacheTimeout", cacheTimeout)
.add("masterSlaveMode", masterSlaveMode)
+ .add("shutdownTimeoutMs", shutdownTimeoutMs)
.toString();
}
}
diff --git
a/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/OptimizerExecutor.java
b/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/OptimizerExecutor.java
index 26eda902c..2868cf083 100644
---
a/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/OptimizerExecutor.java
+++
b/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/OptimizerExecutor.java
@@ -46,6 +46,14 @@ public class OptimizerExecutor extends
AbstractOptimizerOperator {
private static final Logger LOG =
LoggerFactory.getLogger(OptimizerExecutor.class);
protected static final int ERROR_MESSAGE_MAX_LENGTH = 4000;
+ /**
+ * How long completeTask keeps retrying through transient AMS errors,
measured from the moment
+ * shutdown is first observed by the retry loop. Sized to ride out a short
network blip or AMS
+ * failover; note the force-interrupt issued at the stopOptimizing deadline
aborts the window
+ * early, so retries never extend the overall shutdown beyond the configured
timeout.
+ */
+ static final long COMPLETE_TASK_DRAIN_TIMEOUT_MS = 30_000L;
+
private final int threadId;
public OptimizerExecutor(OptimizerConfig config, int threadId) {
@@ -250,12 +258,17 @@ public class OptimizerExecutor extends
AbstractOptimizerOperator {
protected void completeTask(String amsUrl, OptimizingTaskResult
optimizingTaskResult) {
try {
- callAuthenticatedAms(
+ // Report the result even when shutdown was requested before or during
this call: a
+ // finished task is exactly what the graceful drain exists to preserve.
The bounded
+ // drain window keeps retrying through transient errors instead of
dropping the result
+ // on the first failure.
+ callAuthenticatedAmsWithDrain(
amsUrl,
(client, token) -> {
client.completeTask(token, optimizingTaskResult);
return null;
- });
+ },
+ COMPLETE_TASK_DRAIN_TIMEOUT_MS);
LOG.info(
"Optimizer executor[{}] completed task[{}](status: {}) to AMS {}",
threadId,
diff --git
a/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/OptimizerToucher.java
b/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/OptimizerToucher.java
index 9fd24e22e..b6a75e111 100644
---
a/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/OptimizerToucher.java
+++
b/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/OptimizerToucher.java
@@ -35,6 +35,9 @@ public class OptimizerToucher extends
AbstractOptimizerOperator {
private transient TokenChangeListener tokenChangeListener;
private final Map<String, String> registerProperties = Maps.newHashMap();
private final long startTime;
+ private transient volatile Thread runnerThread;
+ private volatile boolean draining = false;
+ private transient boolean drainTokenLossLogged = false;
public OptimizerToucher(OptimizerConfig config) {
super(config);
@@ -54,21 +57,59 @@ public class OptimizerToucher extends
AbstractOptimizerOperator {
public void start() {
LOG.info("Starting optimizer toucher with configuration:{}", getConfig());
- while (isStarted()) {
- try {
- if (checkToken()) {
- touch();
+ runnerThread = Thread.currentThread();
+ try {
+ while (isStarted()) {
+ try {
+ if (checkToken()) {
+ touch();
+ }
+ waitAShortTime(getConfig().getHeartBeat());
+ } catch (Throwable t) {
+ LOG.error("Optimizer toucher got an unexpected error", t);
}
- waitAShortTime(getConfig().getHeartBeat());
- } catch (Throwable t) {
- LOG.error("Optimizer toucher got an unexpected error", t);
}
+ } finally {
+ runnerThread = null;
}
LOG.info("Optimizer toucher stopped");
}
+ /**
+ * Enters drain mode: keep heartbeating with the current token but never
re-register. During
+ * graceful shutdown AMS may have already unregistered this optimizer (a
scale-down releases the
+ * resource before the pod receives SIGTERM); re-registering on the
resulting auth error would
+ * create a ghost optimizer AMS never asked for and rotate the executors'
token, so their final
+ * completeTask would be rejected as coming from the wrong optimizer.
+ */
+ public void enterDrainMode() {
+ this.draining = true;
+ }
+
+ @Override
+ public void stop() {
+ super.stop();
+ // Wake the runner immediately if it is sleeping in waitAShortTime, so the
heartbeat
+ // loop terminates without waiting up to one full heartbeat interval.
waitAShortTime
+ // preserves the interrupt flag so the loop exits cleanly on its next
isStarted() check.
+ Thread t = runnerThread;
+ if (t != null) {
+ t.interrupt();
+ }
+ }
+
private boolean checkToken() {
if (!tokenIsReady()) {
+ if (draining) {
+ if (!drainTokenLossLogged) {
+ drainTokenLossLogged = true;
+ LOG.warn(
+ "Optimizer token became invalid while draining; skip
re-registering to AMS {} "
+ + "to avoid creating a ghost optimizer",
+ getConfig().getAmsUrl());
+ }
+ return false;
+ }
LOG.info(
"Registering optimizer to AMS {} (group: {}, mode: {}, threads: {},
memory: {}MB) ...",
getConfig().getAmsUrl(),
diff --git
a/amoro-optimizer/amoro-optimizer-common/src/test/java/org/apache/amoro/optimizer/common/OptimizerTestBase.java
b/amoro-optimizer/amoro-optimizer-common/src/test/java/org/apache/amoro/optimizer/common/OptimizerTestBase.java
index 95b0cbc58..d8b83db54 100644
---
a/amoro-optimizer/amoro-optimizer-common/src/test/java/org/apache/amoro/optimizer/common/OptimizerTestBase.java
+++
b/amoro-optimizer/amoro-optimizer-common/src/test/java/org/apache/amoro/optimizer/common/OptimizerTestBase.java
@@ -43,5 +43,6 @@ public abstract class OptimizerTestBase {
TEST_AMS.getOptimizerHandler().getPendingTasks().clear();
TEST_AMS.getOptimizerHandler().getExecutingTasks().clear();
TEST_AMS.getOptimizerHandler().getCompletedTasks().clear();
+ TEST_AMS.getOptimizerHandler().failNextCompleteTasks(0);
}
}
diff --git
a/amoro-optimizer/amoro-optimizer-common/src/test/java/org/apache/amoro/optimizer/common/TestAbstractOptimizerOperator.java
b/amoro-optimizer/amoro-optimizer-common/src/test/java/org/apache/amoro/optimizer/common/TestAbstractOptimizerOperator.java
new file mode 100644
index 000000000..326ac6004
--- /dev/null
+++
b/amoro-optimizer/amoro-optimizer-common/src/test/java/org/apache/amoro/optimizer/common/TestAbstractOptimizerOperator.java
@@ -0,0 +1,57 @@
+/*
+ * 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.optimizer.common;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestAbstractOptimizerOperator {
+
+ @Test
+ public void testStrayInterruptWhileStartedIsSwallowed() {
+ AbstractOptimizerOperator operator = new AbstractOptimizerOperator(new
OptimizerConfig());
+ Thread.currentThread().interrupt();
+ try {
+ operator.waitAShortTime(10);
+ // A stray interrupt while the operator is still running must not leave
the flag set,
+ // otherwise every subsequent sleep returns instantly and the poll/retry
loops busy-spin
+ // for the remaining lifetime of the process.
+ Assertions.assertFalse(
+ Thread.currentThread().isInterrupted(),
+ "Interrupt flag must be swallowed while the operator is started");
+ } finally {
+ Thread.interrupted();
+ }
+ }
+
+ @Test
+ public void testInterruptDuringShutdownIsPreserved() {
+ AbstractOptimizerOperator operator = new AbstractOptimizerOperator(new
OptimizerConfig());
+ operator.stop();
+ Thread.currentThread().interrupt();
+ try {
+ operator.waitAShortTime(10);
+ Assertions.assertTrue(
+ Thread.currentThread().isInterrupted(),
+ "Interrupt flag must be preserved after stop so shutdown skips
residual sleeps");
+ } finally {
+ Thread.interrupted();
+ }
+ }
+}
diff --git
a/amoro-optimizer/amoro-optimizer-common/src/test/java/org/apache/amoro/optimizer/common/TestOptimizer.java
b/amoro-optimizer/amoro-optimizer-common/src/test/java/org/apache/amoro/optimizer/common/TestOptimizer.java
index 1feeef57f..c7cf05bed 100644
---
a/amoro-optimizer/amoro-optimizer-common/src/test/java/org/apache/amoro/optimizer/common/TestOptimizer.java
+++
b/amoro-optimizer/amoro-optimizer-common/src/test/java/org/apache/amoro/optimizer/common/TestOptimizer.java
@@ -23,7 +23,9 @@ import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.util.List;
+import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
public class TestOptimizer extends OptimizerTestBase {
@@ -48,4 +50,141 @@ public class TestOptimizer extends OptimizerTestBase {
Assertions.assertEquals(2, taskResults.size());
optimizer.stopOptimizing();
}
+
+ @Test
+ public void testGracefulShutdown() throws InterruptedException {
+ OptimizerConfig optimizerConfig =
+ OptimizerTestHelpers.buildOptimizerConfig(TEST_AMS.getServerUrl());
+ Optimizer optimizer = new Optimizer(optimizerConfig);
+ Thread optimizerThread = new Thread(optimizer::startOptimizing);
+ optimizerThread.start();
+ TimeUnit.SECONDS.sleep(1);
+
+ TEST_AMS
+ .getOptimizerHandler()
+
.offerTask(TestOptimizerExecutor.TestOptimizingInput.successInput(1).toTask(0,
0));
+ TimeUnit.MILLISECONDS.sleep(OptimizerTestHelpers.CALL_AMS_INTERVAL * 5);
+
+ optimizer.stopOptimizing();
+ optimizerThread.join(5000);
+
+ Assertions.assertFalse(optimizerThread.isAlive(), "Optimizer thread should
have terminated");
+
+ String token = optimizer.getToucher().getToken();
+ List<OptimizingTaskResult> taskResults =
+ TEST_AMS.getOptimizerHandler().getCompletedTasks().get(token);
+ Assertions.assertNotNull(taskResults, "Task results should be reported
before shutdown");
+ Assertions.assertEquals(1, taskResults.size());
+ }
+
+ @Test
+ public void testGracefulShutdownWaitsForInProgressTask() throws
InterruptedException {
+ OptimizerConfig optimizerConfig =
+ OptimizerTestHelpers.buildOptimizerConfig(TEST_AMS.getServerUrl());
+ Optimizer optimizer = new Optimizer(optimizerConfig);
+ Thread optimizerThread = new Thread(optimizer::startOptimizing);
+ optimizerThread.start();
+ TimeUnit.SECONDS.sleep(1);
+
+ long taskExecutionMs = 3_000;
+ CountDownLatch executionStarted = new CountDownLatch(1);
+ TestOptimizerExecutor.slowExecutionStartedLatch = executionStarted;
+ try {
+ TEST_AMS
+ .getOptimizerHandler()
+ .offerTask(
+ TestOptimizerExecutor.TestOptimizingInput.slowSuccessInput(1,
taskExecutionMs)
+ .toTask(0, 0));
+
+ // Wait until the executor has polled, acked and entered execute(), so
stop is called
+ // while the task is deterministically in progress.
+ Assertions.assertTrue(
+ executionStarted.await(10, TimeUnit.SECONDS),
+ "Task should have started executing before stop");
+ } finally {
+ TestOptimizerExecutor.slowExecutionStartedLatch = null;
+ }
+
+ long startStop = System.currentTimeMillis();
+ optimizer.stopOptimizing();
+ long elapsed = System.currentTimeMillis() - startStop;
+ optimizerThread.join(5_000);
+
+ Assertions.assertFalse(optimizerThread.isAlive(), "Optimizer thread should
have terminated");
+ Assertions.assertTrue(
+ elapsed >= 1_000,
+ "stopOptimizing should block until in-progress task completes
(elapsed=" + elapsed + "ms)");
+
+ String token = optimizer.getToucher().getToken();
+ List<OptimizingTaskResult> taskResults =
+ TEST_AMS.getOptimizerHandler().getCompletedTasks().get(token);
+ Assertions.assertNotNull(taskResults, "Task results should be reported
before shutdown");
+ Assertions.assertEquals(1, taskResults.size());
+ Assertions.assertNull(
+ taskResults.get(0).getErrorMessage(),
+ "In-progress task must complete successfully, not be interrupted");
+ }
+
+ @Test
+ public void testForceInterruptReachesAllExecutorsWhenStopperInterrupted()
+ throws InterruptedException {
+ OptimizerConfig optimizerConfig =
+ OptimizerTestHelpers.buildOptimizerConfig(TEST_AMS.getServerUrl());
+ optimizerConfig.setExecutionParallel(2);
+
+ CountDownLatch executorsRunning = new CountDownLatch(2);
+ AtomicBoolean[] executorInterrupted = {new AtomicBoolean(), new
AtomicBoolean()};
+ Thread[] executorThreads = new Thread[2];
+ Optimizer optimizer =
+ new Optimizer(
+ optimizerConfig,
+ () -> new OptimizerToucher(optimizerConfig),
+ (i) ->
+ new OptimizerExecutor(optimizerConfig, i) {
+ @Override
+ public void start() {
+ executorThreads[getThreadId()] = Thread.currentThread();
+ executorsRunning.countDown();
+ try {
+ TimeUnit.HOURS.sleep(1);
+ } catch (InterruptedException e) {
+ executorInterrupted[getThreadId()].set(true);
+ }
+ }
+ });
+ Thread optimizerThread = new Thread(optimizer::startOptimizing);
+ optimizerThread.start();
+ try {
+ Assertions.assertTrue(
+ executorsRunning.await(5, TimeUnit.SECONDS), "Executor threads
should have started");
+
+ Thread stopper = new Thread(optimizer::stopOptimizing, "stopper");
+ stopper.start();
+ // Let the stopper enter its join-with-deadline wait, then interrupt it
the same way
+ // the Hadoop ShutdownHookManager watchdog cancels a hook that exceeded
its timeout.
+ TimeUnit.MILLISECONDS.sleep(500);
+ stopper.interrupt();
+ stopper.join(10_000);
+ Assertions.assertFalse(
+ stopper.isAlive(), "stopOptimizing should return promptly after
being interrupted");
+
+ long deadline = System.currentTimeMillis() + 5_000;
+ while (System.currentTimeMillis() < deadline
+ && !(executorInterrupted[0].get() && executorInterrupted[1].get())) {
+ TimeUnit.MILLISECONDS.sleep(50);
+ }
+ Assertions.assertTrue(
+ executorInterrupted[0].get(), "First executor thread must be
force-interrupted");
+ Assertions.assertTrue(
+ executorInterrupted[1].get(),
+ "All executor threads must be force-interrupted, not only the
first");
+ optimizerThread.join(5_000);
+ } finally {
+ for (Thread t : executorThreads) {
+ if (t != null) {
+ t.interrupt();
+ }
+ }
+ }
+ }
}
diff --git
a/amoro-optimizer/amoro-optimizer-common/src/test/java/org/apache/amoro/optimizer/common/TestOptimizerExecutor.java
b/amoro-optimizer/amoro-optimizer-common/src/test/java/org/apache/amoro/optimizer/common/TestOptimizerExecutor.java
index 08f5a370c..866ad655a 100644
---
a/amoro-optimizer/amoro-optimizer-common/src/test/java/org/apache/amoro/optimizer/common/TestOptimizerExecutor.java
+++
b/amoro-optimizer/amoro-optimizer-common/src/test/java/org/apache/amoro/optimizer/common/TestOptimizerExecutor.java
@@ -36,12 +36,20 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Map;
+import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
public class TestOptimizerExecutor extends OptimizerTestBase {
private static final String FAILED_TASK_MESSAGE = "Execute Task failed";
+ /**
+ * When set, slow test tasks count this down as soon as execute() begins,
letting tests wait
+ * deterministically for "the task is now in progress" instead of sleeping
fixed intervals.
+ */
+ static volatile CountDownLatch slowExecutionStartedLatch;
+
private OptimizerExecutor optimizerExecutor;
@BeforeEach
@@ -85,6 +93,71 @@ public class TestOptimizerExecutor extends OptimizerTestBase
{
Assertions.assertEquals(1, output.inputId());
}
+ @Test
+ public void testCompleteTaskRetriesTransientErrorDuringShutdownDrain()
throws TException {
+ TEST_AMS.getOptimizerHandler().authenticate(new OptimizerRegisterInfo());
+ String token =
+
TEST_AMS.getOptimizerHandler().getRegisteredOptimizers().keySet().iterator().next();
+ optimizerExecutor.setToken(token);
+ TEST_AMS.getOptimizerHandler().ackTask(token, 0, new OptimizingTaskId(0,
0));
+
+ // Shutdown already requested when the in-flight task finishes and reports
its result.
+ optimizerExecutor.stop();
+ TEST_AMS.getOptimizerHandler().failNextCompleteTasks(2);
+
+ OptimizingTaskResult result = new OptimizingTaskResult(new
OptimizingTaskId(0, 0), 0);
+ optimizerExecutor.completeTask(TEST_AMS.getServerUrl(), result);
+
+ Assertions.assertNotNull(
+ TEST_AMS.getOptimizerHandler().getCompletedTasks().get(token),
+ "Task result must be reported despite transient AMS errors during the
shutdown drain");
+ Assertions.assertEquals(
+ 1,
TEST_AMS.getOptimizerHandler().getCompletedTasks().get(token).size());
+ }
+
+ @Test
+ public void testDrainWindowAnchorsAtStopTime() throws InterruptedException,
TException {
+ TEST_AMS.getOptimizerHandler().authenticate(new OptimizerRegisterInfo());
+ String token =
+
TEST_AMS.getOptimizerHandler().getRegisteredOptimizers().keySet().iterator().next();
+ optimizerExecutor.setToken(token);
+ TEST_AMS.getOptimizerHandler().ackTask(token, 0, new OptimizingTaskId(0,
0));
+
+ // AMS keeps failing while the optimizer is still running, for longer than
the drain budget.
+ TEST_AMS.getOptimizerHandler().failNextCompleteTasks(Integer.MAX_VALUE);
+ OptimizingTaskResult result = new OptimizingTaskResult(new
OptimizingTaskId(0, 0), 0);
+ AtomicReference<Exception> error = new AtomicReference<>();
+ Thread caller =
+ new Thread(
+ () -> {
+ try {
+ optimizerExecutor.callAuthenticatedAmsWithDrain(
+ TEST_AMS.getServerUrl(),
+ (client, callToken) -> {
+ client.completeTask(callToken, result);
+ return null;
+ },
+ 2_000);
+ } catch (Exception e) {
+ error.set(e);
+ }
+ });
+ caller.start();
+ TimeUnit.MILLISECONDS.sleep(2_500);
+
+ // Shutdown begins and AMS recovers: the drain window must start counting
from now,
+ // not from call entry (which is already past the 2s budget).
+ optimizerExecutor.stop();
+ TEST_AMS.getOptimizerHandler().failNextCompleteTasks(0);
+ caller.join(5_000);
+
+ Assertions.assertFalse(caller.isAlive(), "Drain call should have
finished");
+ Assertions.assertNull(
+ error.get(), "Result must be reported within a full drain window
anchored at stop time");
+ Assertions.assertEquals(
+ 1,
TEST_AMS.getOptimizerHandler().getCompletedTasks().get(token).size());
+ }
+
@Test
public void testExecuteTaskFailed() throws InterruptedException, TException {
TEST_AMS.getOptimizerHandler().authenticate(new OptimizerRegisterInfo());
@@ -108,10 +181,16 @@ public class TestOptimizerExecutor extends
OptimizerTestBase {
public static class TestOptimizingInput extends BaseOptimizingInput {
private final int inputId;
private final boolean executeSuccess;
+ private final long executionTimeMs;
private TestOptimizingInput(int inputId, boolean executeSuccess) {
+ this(inputId, executeSuccess, 0L);
+ }
+
+ private TestOptimizingInput(int inputId, boolean executeSuccess, long
executionTimeMs) {
this.inputId = inputId;
this.executeSuccess = executeSuccess;
+ this.executionTimeMs = executionTimeMs;
}
public static TestOptimizingInput successInput(int inputId) {
@@ -122,10 +201,18 @@ public class TestOptimizerExecutor extends
OptimizerTestBase {
return new TestOptimizingInput(inputId, false);
}
+ public static TestOptimizingInput slowSuccessInput(int inputId, long
executionTimeMs) {
+ return new TestOptimizingInput(inputId, true, executionTimeMs);
+ }
+
private int inputId() {
return inputId;
}
+ private long executionTimeMs() {
+ return executionTimeMs;
+ }
+
public OptimizingTask toTask(long processId, int taskId) {
OptimizingTask optimizingTask = new OptimizingTask(new
OptimizingTaskId(processId, taskId));
optimizingTask.setTaskInput(SerializationUtil.simpleSerialize(this));
@@ -159,6 +246,18 @@ public class TestOptimizerExecutor extends
OptimizerTestBase {
@Override
public TestOptimizingOutput execute() {
+ if (input.executionTimeMs() > 0) {
+ CountDownLatch latch = slowExecutionStartedLatch;
+ if (latch != null) {
+ latch.countDown();
+ }
+ try {
+ Thread.sleep(input.executionTimeMs());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException("Task was interrupted before
completion", e);
+ }
+ }
if (input.executeSuccess) {
return new TestOptimizingOutput(input.inputId());
} else {
diff --git
a/amoro-optimizer/amoro-optimizer-common/src/test/java/org/apache/amoro/optimizer/common/TestOptimizerToucher.java
b/amoro-optimizer/amoro-optimizer-common/src/test/java/org/apache/amoro/optimizer/common/TestOptimizerToucher.java
index 749dec499..76a38714e 100644
---
a/amoro-optimizer/amoro-optimizer-common/src/test/java/org/apache/amoro/optimizer/common/TestOptimizerToucher.java
+++
b/amoro-optimizer/amoro-optimizer-common/src/test/java/org/apache/amoro/optimizer/common/TestOptimizerToucher.java
@@ -62,6 +62,38 @@ public class TestOptimizerToucher extends OptimizerTestBase {
optimizerToucher.stop();
}
+ @Test
+ public void testNoReRegistrationInDrainMode() throws InterruptedException {
+ OptimizerConfig optimizerConfig =
+ OptimizerTestHelpers.buildOptimizerConfig(TEST_AMS.getServerUrl());
+ OptimizerToucher optimizerToucher = new OptimizerToucher(optimizerConfig);
+ TestTokenChangeListener tokenChangeListener = new
TestTokenChangeListener();
+ optimizerToucher.withTokenChangeListener(tokenChangeListener);
+ new Thread(optimizerToucher::start).start();
+ try {
+ tokenChangeListener.waitForTokenChange();
+ Assertions.assertEquals(1,
TEST_AMS.getOptimizerHandler().getRegisteredOptimizers().size());
+
+ // Drain begins (graceful shutdown keeps the toucher alive for
heartbeats), then AMS
+ // unregisters this optimizer the way a scale-down does before deleting
the pod.
+ optimizerToucher.enterDrainMode();
+ TEST_AMS.getOptimizerHandler().getRegisteredOptimizers().clear();
+
+ // Give the heartbeat loop several cycles to hit the auth error.
+ TimeUnit.MILLISECONDS.sleep(3_000);
+
+ Assertions.assertTrue(
+ TEST_AMS.getOptimizerHandler().getRegisteredOptimizers().isEmpty(),
+ "Toucher must not re-register a ghost optimizer while draining");
+ Assertions.assertEquals(
+ 1,
+ tokenChangeListener.tokenList().size(),
+ "Executor tokens must not be rotated during drain");
+ } finally {
+ optimizerToucher.stop();
+ }
+ }
+
private void validateRegisteredOptimizer(
String token, OptimizerConfig registerConfig, Map<String, String>
optimizerProperties) {
Map<String, OptimizerRegisterInfo> registeredOptimizerMap =
diff --git
a/amoro-optimizer/amoro-optimizer-flink/src/main/java/org/apache/amoro/optimizer/flink/FlinkExecutor.java
b/amoro-optimizer/amoro-optimizer-flink/src/main/java/org/apache/amoro/optimizer/flink/FlinkExecutor.java
index ddf3b1842..90733ab25 100644
---
a/amoro-optimizer/amoro-optimizer-flink/src/main/java/org/apache/amoro/optimizer/flink/FlinkExecutor.java
+++
b/amoro-optimizer/amoro-optimizer-flink/src/main/java/org/apache/amoro/optimizer/flink/FlinkExecutor.java
@@ -19,8 +19,10 @@
package org.apache.amoro.optimizer.flink;
import static
org.apache.flink.configuration.HighAvailabilityOptions.HA_CLUSTER_ID;
+import static
org.apache.flink.configuration.TaskManagerOptions.TASK_CANCELLATION_TIMEOUT;
import static
org.apache.flink.configuration.TaskManagerOptions.TASK_MANAGER_RESOURCE_ID;
+import org.apache.amoro.optimizer.common.OptimizerConfig;
import org.apache.amoro.optimizer.common.OptimizerExecutor;
import org.apache.flink.streaming.api.operators.AbstractStreamOperator;
import org.apache.flink.streaming.api.operators.OneInputStreamOperator;
@@ -29,14 +31,26 @@ import
org.apache.flink.streaming.runtime.streamrecord.StreamRecord;
public class FlinkExecutor extends AbstractStreamOperator<Void>
implements OneInputStreamOperator<String, Void> {
+ /**
+ * Kept below Flink's cancellation watchdog deadline
(task.cancellation.timeout) so the
+ * force-interrupt and thread exit complete before Flink fails the whole
TaskManager.
+ */
+ static final long CANCELLATION_SAFETY_MARGIN_MS = 10_000L;
+
private final OptimizerExecutor[] allExecutors;
+ private final long shutdownTimeoutMs;
+ private final long heartbeatIntervalMs;
private FlinkOptimizerExecutor executor;
private String optimizeGroupName;
private Thread optimizerThread;
+ private long drainTimeoutMs;
- public FlinkExecutor(OptimizerExecutor[] allExecutors, String
optimizeGroupName) {
+ public FlinkExecutor(
+ OptimizerExecutor[] allExecutors, String optimizeGroupName,
OptimizerConfig config) {
this.allExecutors = allExecutors;
this.optimizeGroupName = optimizeGroupName;
+ this.shutdownTimeoutMs = config.getShutdownTimeoutMs();
+ this.heartbeatIntervalMs = config.getHeartBeat();
}
@Override
@@ -62,6 +76,12 @@ public class FlinkExecutor extends
AbstractStreamOperator<Void>
// add label optimize_group;
getMetricGroup().getAllVariables().put("<optimizer_group>",
optimizeGroupName);
executor.initOperatorMetric(getMetricGroup());
+ long taskCancellationTimeoutMs =
+ getRuntimeContext()
+ .getTaskManagerRuntimeInfo()
+ .getConfiguration()
+ .get(TASK_CANCELLATION_TIMEOUT);
+ drainTimeoutMs = effectiveDrainTimeoutMs(shutdownTimeoutMs,
taskCancellationTimeoutMs);
optimizerThread =
new Thread(() -> executor.start(), "flink-optimizer-executor-" +
subTaskIndex);
optimizerThread.setDaemon(true);
@@ -73,14 +93,80 @@ public class FlinkExecutor extends
AbstractStreamOperator<Void>
if (executor != null) {
executor.stop();
}
- if (optimizerThread != null && optimizerThread.isAlive()) {
+ // The FlinkToucher source is cancelled before this operator, so
heartbeats have already
+ // stopped when the drain begins. Keep the registration alive by touching
AMS with the
+ // existing token from here — otherwise AMS expires the optimizer after
its heartbeat
+ // timeout (default 1 min) and resets the in-flight task, dropping the
drained result.
+ Runnable drainHeartbeat = executor != null ? executor::bestEffortTouch :
null;
+ drainThenForceStop(optimizerThread, drainTimeoutMs, heartbeatIntervalMs,
drainHeartbeat);
+ }
+
+ /**
+ * Bounds the graceful drain by Flink's cancellation watchdog: when a
cancelled task has not
+ * terminated within task.cancellation.timeout (default 180s), Flink kills
the entire TaskManager,
+ * so waiting any longer than that would turn a graceful drain into a TM
failure. A value of 0
+ * disables the watchdog, in which case the full shutdown timeout applies.
+ */
+ static long effectiveDrainTimeoutMs(long shutdownTimeoutMs, long
taskCancellationTimeoutMs) {
+ if (taskCancellationTimeoutMs <= 0) {
+ return shutdownTimeoutMs;
+ }
+ long cap = taskCancellationTimeoutMs - CANCELLATION_SAFETY_MARGIN_MS;
+ return Math.max(0, Math.min(shutdownTimeoutMs, cap));
+ }
+
+ static void drainThenForceStop(
+ Thread optimizerThread,
+ long drainTimeoutMs,
+ long heartbeatIntervalMs,
+ Runnable drainHeartbeat) {
+ if (optimizerThread == null || !optimizerThread.isAlive()) {
+ return;
+ }
+ // Flink's cancellation machinery (TaskCanceler/TaskInterrupter)
interrupts the task thread
+ // running close() to unblock I/O; absorb those interrupts and keep
waiting until the drain
+ // deadline — the budget is capped below the cancellation watchdog, so
close() always
+ // returns before Flink escalates to failing the TaskManager.
+ boolean selfInterrupted =
+ joinUntil(optimizerThread, drainTimeoutMs, heartbeatIntervalMs,
drainHeartbeat);
+ if (optimizerThread.isAlive()) {
+ LOG.warn(
+ "Optimizer executor thread {} did not finish in-progress work within
{}ms, "
+ + "force-interrupting",
+ optimizerThread.getName(),
+ drainTimeoutMs);
optimizerThread.interrupt();
+ selfInterrupted |= joinUntil(optimizerThread, 1_000, 1_000, null);
+ }
+ if (selfInterrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ /**
+ * Joins the thread until the deadline, absorbing interrupts of the calling
thread and running the
+ * heartbeat action between join slices. Returns whether an interrupt was
absorbed so the caller
+ * can restore the flag afterwards.
+ */
+ private static boolean joinUntil(
+ Thread thread, long timeoutMs, long heartbeatIntervalMs, Runnable
heartbeat) {
+ boolean interrupted = false;
+ long deadline = System.currentTimeMillis() + timeoutMs;
+ while (thread.isAlive()) {
+ long remaining = deadline - System.currentTimeMillis();
+ if (remaining <= 0) {
+ break;
+ }
try {
- optimizerThread.join(5000);
+ thread.join(Math.min(remaining, heartbeatIntervalMs));
} catch (InterruptedException e) {
- Thread.currentThread().interrupt();
+ interrupted = true;
+ }
+ if (heartbeat != null && thread.isAlive()) {
+ heartbeat.run();
}
}
+ return interrupted;
}
@Override
diff --git
a/amoro-optimizer/amoro-optimizer-flink/src/main/java/org/apache/amoro/optimizer/flink/FlinkOptimizer.java
b/amoro-optimizer/amoro-optimizer-flink/src/main/java/org/apache/amoro/optimizer/flink/FlinkOptimizer.java
index 6b46e7dda..4b6c2bab8 100644
---
a/amoro-optimizer/amoro-optimizer-flink/src/main/java/org/apache/amoro/optimizer/flink/FlinkOptimizer.java
+++
b/amoro-optimizer/amoro-optimizer-flink/src/main/java/org/apache/amoro/optimizer/flink/FlinkOptimizer.java
@@ -57,7 +57,8 @@ public class FlinkOptimizer extends Optimizer {
.transform(
FlinkExecutor.class.getName(),
Types.VOID,
- new FlinkExecutor(optimizer.getExecutors(),
optimizerConfig.getGroupName()))
+ new FlinkExecutor(
+ optimizer.getExecutors(), optimizerConfig.getGroupName(),
optimizerConfig))
.setParallelism(optimizerConfig.getExecutionParallel())
.addSink(new DiscardingSink<>())
.name("Optimizer empty sink")
diff --git
a/amoro-optimizer/amoro-optimizer-flink/src/main/java/org/apache/amoro/optimizer/flink/FlinkOptimizerExecutor.java
b/amoro-optimizer/amoro-optimizer-flink/src/main/java/org/apache/amoro/optimizer/flink/FlinkOptimizerExecutor.java
index cb2fff27e..88ecddd79 100644
---
a/amoro-optimizer/amoro-optimizer-flink/src/main/java/org/apache/amoro/optimizer/flink/FlinkOptimizerExecutor.java
+++
b/amoro-optimizer/amoro-optimizer-flink/src/main/java/org/apache/amoro/optimizer/flink/FlinkOptimizerExecutor.java
@@ -20,6 +20,7 @@ package org.apache.amoro.optimizer.flink;
import org.apache.amoro.api.OptimizingTask;
import org.apache.amoro.api.OptimizingTaskResult;
+import org.apache.amoro.client.OptimizingClientPools;
import org.apache.amoro.optimizer.common.OptimizerConfig;
import org.apache.amoro.optimizer.common.OptimizerExecutor;
import org.apache.amoro.shade.guava32.com.google.common.base.Strings;
@@ -53,6 +54,26 @@ public class FlinkOptimizerExecutor extends
OptimizerExecutor {
runtimeContext.put(key, value);
}
+ /**
+ * Best-effort heartbeat with the current token, usable after stop(). Flink
cancels the
+ * FlinkToucher source before this operator, so the drain in {@link
FlinkExecutor#close()} keeps
+ * the registration alive by touching AMS directly — otherwise AMS expires
the optimizer after its
+ * heartbeat timeout and resets the in-flight task, dropping the drained
result. Failures are
+ * swallowed; the next drain heartbeat simply retries. This never
re-registers, consistent with
+ * the drain-mode toucher.
+ */
+ void bestEffortTouch() {
+ String currentToken = getToken();
+ if (currentToken == null) {
+ return;
+ }
+ try {
+
OptimizingClientPools.getClient(getConfig().getAmsUrl()).touch(currentToken);
+ } catch (Exception e) {
+ LOG.debug("Best-effort touch to AMS failed, will retry on the next drain
heartbeat", e);
+ }
+ }
+
public void initOperatorMetric(MetricGroup metricGroup) {
this.operatorMetricGroup = metricGroup;
taskCounter =
this.operatorMetricGroup.addGroup("amoro").addGroup("optimizer").counter("tasks");
diff --git
a/amoro-optimizer/amoro-optimizer-flink/src/test/java/org/apache/amoro/optimizer/flink/TestFlinkExecutor.java
b/amoro-optimizer/amoro-optimizer-flink/src/test/java/org/apache/amoro/optimizer/flink/TestFlinkExecutor.java
new file mode 100644
index 000000000..82fb8c64e
--- /dev/null
+++
b/amoro-optimizer/amoro-optimizer-flink/src/test/java/org/apache/amoro/optimizer/flink/TestFlinkExecutor.java
@@ -0,0 +1,178 @@
+/*
+ * 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.optimizer.flink;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+public class TestFlinkExecutor {
+
+ @Test
+ public void testDrainWaitsForInProgressTask() throws InterruptedException {
+ AtomicBoolean taskCompleted = new AtomicBoolean(false);
+ AtomicBoolean interrupted = new AtomicBoolean(false);
+ Thread worker =
+ new Thread(
+ () -> {
+ try {
+ TimeUnit.MILLISECONDS.sleep(500);
+ taskCompleted.set(true);
+ } catch (InterruptedException e) {
+ interrupted.set(true);
+ }
+ });
+ worker.start();
+
+ FlinkExecutor.drainThenForceStop(worker, 10_000, 10_000, null);
+
+ Assertions.assertFalse(worker.isAlive(), "Worker thread should have
terminated");
+ Assertions.assertTrue(taskCompleted.get(), "In-progress task should
complete within budget");
+ Assertions.assertFalse(interrupted.get(), "Task within budget must not be
interrupted");
+ }
+
+ @Test
+ public void testDrainKeepsHeartbeatingWhileWaiting() throws
InterruptedException {
+ AtomicInteger heartbeats = new AtomicInteger();
+ Thread worker =
+ new Thread(
+ () -> {
+ try {
+ TimeUnit.MILLISECONDS.sleep(900);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ });
+ worker.start();
+
+ FlinkExecutor.drainThenForceStop(worker, 10_000, 200,
heartbeats::incrementAndGet);
+
+ Assertions.assertFalse(worker.isAlive(), "Worker thread should have
terminated");
+ Assertions.assertTrue(
+ heartbeats.get() >= 2,
+ "Drain must keep sending heartbeats periodically, got " +
heartbeats.get());
+ }
+
+ @Test
+ public void testDrainForceInterruptsAfterTimeout() throws
InterruptedException {
+ AtomicBoolean interrupted = new AtomicBoolean(false);
+ Thread worker =
+ new Thread(
+ () -> {
+ try {
+ TimeUnit.MINUTES.sleep(10);
+ } catch (InterruptedException e) {
+ interrupted.set(true);
+ }
+ });
+ worker.start();
+
+ long start = System.currentTimeMillis();
+ FlinkExecutor.drainThenForceStop(worker, 300, 10_000, null);
+ long elapsed = System.currentTimeMillis() - start;
+
+ Assertions.assertFalse(worker.isAlive(), "Worker thread should have been
force-stopped");
+ Assertions.assertTrue(interrupted.get(), "Task exceeding budget should be
interrupted");
+ Assertions.assertTrue(
+ elapsed < 5_000, "Drain should return promptly after budget, elapsed="
+ elapsed + "ms");
+ }
+
+ @Test
+ public void testDrainSurvivesCallerInterrupts() throws InterruptedException {
+ AtomicBoolean taskCompleted = new AtomicBoolean(false);
+ AtomicBoolean workerInterrupted = new AtomicBoolean(false);
+ Thread worker =
+ new Thread(
+ () -> {
+ try {
+ TimeUnit.MILLISECONDS.sleep(800);
+ taskCompleted.set(true);
+ } catch (InterruptedException e) {
+ workerInterrupted.set(true);
+ }
+ });
+ worker.start();
+
+ // Simulate Flink's TaskCanceler/TaskInterrupter interrupting the task
thread that is
+ // running close() during job cancellation: the drain must absorb it and
keep waiting.
+ Thread drainThread = Thread.currentThread();
+ Thread canceler =
+ new Thread(
+ () -> {
+ try {
+ TimeUnit.MILLISECONDS.sleep(200);
+ } catch (InterruptedException e) {
+ return;
+ }
+ drainThread.interrupt();
+ });
+ canceler.start();
+ try {
+ FlinkExecutor.drainThenForceStop(worker, 10_000, 10_000, null);
+
+ Assertions.assertTrue(
+ Thread.interrupted(), "Self-interrupt must be restored after the
drain completes");
+ Assertions.assertFalse(worker.isAlive(), "Worker thread should have
terminated");
+ Assertions.assertTrue(
+ taskCompleted.get(), "Drain must keep waiting across caller
interrupts");
+ Assertions.assertFalse(
+ workerInterrupted.get(), "Task within budget must not be
force-interrupted");
+ } finally {
+ Thread.interrupted();
+ canceler.interrupt();
+ }
+ }
+
+ @Test
+ public void testDrainToleratesDeadOrNullThread() {
+ FlinkExecutor.drainThenForceStop(null, 1_000, 1_000, null);
+
+ Thread finished = new Thread(() -> {});
+ finished.start();
+ FlinkExecutor.drainThenForceStop(finished, 1_000, 1_000, null);
+ }
+
+ @Test
+ public void testEffectiveDrainTimeoutCappedByCancellationTimeout() {
+ // Flink kills the TaskManager when a cancelled task does not terminate
within
+ // task.cancellation.timeout (default 180s), so the drain budget must stay
below it.
+ Assertions.assertEquals(
+ 180_000 - FlinkExecutor.CANCELLATION_SAFETY_MARGIN_MS,
+ FlinkExecutor.effectiveDrainTimeoutMs(600_000, 180_000));
+ }
+
+ @Test
+ public void testEffectiveDrainTimeoutUsesShutdownTimeoutWhenSmaller() {
+ Assertions.assertEquals(60_000,
FlinkExecutor.effectiveDrainTimeoutMs(60_000, 180_000));
+ }
+
+ @Test
+ public void testEffectiveDrainTimeoutUnlimitedCancellationWatchdog() {
+ // task.cancellation.timeout = 0 disables Flink's cancellation watchdog
entirely.
+ Assertions.assertEquals(600_000,
FlinkExecutor.effectiveDrainTimeoutMs(600_000, 0));
+ }
+
+ @Test
+ public void testEffectiveDrainTimeoutNeverNegative() {
+ Assertions.assertEquals(0, FlinkExecutor.effectiveDrainTimeoutMs(600_000,
1_000));
+ }
+}
diff --git
a/amoro-optimizer/amoro-optimizer-flink/src/test/java/org/apache/amoro/optimizer/flink/TestFlinkOptimizerExecutor.java
b/amoro-optimizer/amoro-optimizer-flink/src/test/java/org/apache/amoro/optimizer/flink/TestFlinkOptimizerExecutor.java
new file mode 100644
index 000000000..5f85ac41b
--- /dev/null
+++
b/amoro-optimizer/amoro-optimizer-flink/src/test/java/org/apache/amoro/optimizer/flink/TestFlinkOptimizerExecutor.java
@@ -0,0 +1,66 @@
+/*
+ * 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.optimizer.flink;
+
+import org.apache.amoro.TestAms;
+import org.apache.amoro.api.OptimizerRegisterInfo;
+import org.apache.amoro.optimizer.common.OptimizerConfig;
+import org.apache.amoro.shade.thrift.org.apache.thrift.TException;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.kohsuke.args4j.CmdLineException;
+
+public class TestFlinkOptimizerExecutor {
+
+ private static final TestAms TEST_AMS = new TestAms();
+
+ @BeforeAll
+ public static void startTestAms() throws Exception {
+ TEST_AMS.before();
+ }
+
+ @AfterAll
+ public static void stopTestAms() {
+ TEST_AMS.after();
+ }
+
+ @Test
+ public void testBestEffortTouchWorksWhileStoppedAndSwallowsErrors()
+ throws CmdLineException, TException {
+ OptimizerConfig config =
+ new OptimizerConfig(new String[] {"-a", TEST_AMS.getServerUrl(), "-p",
"1", "-g", "g1"});
+ FlinkOptimizerExecutor executor = new FlinkOptimizerExecutor(config, 0);
+ TEST_AMS.getOptimizerHandler().authenticate(new OptimizerRegisterInfo());
+ String token =
+
TEST_AMS.getOptimizerHandler().getRegisteredOptimizers().keySet().iterator().next();
+ executor.setToken(token);
+ executor.stop();
+
+ // Must work after stop: the drain in FlinkExecutor.close() uses it to
keep the
+ // registration alive while the in-flight task finishes.
+ executor.bestEffortTouch();
+
+ // Must swallow failures: unknown token (AMS already expired us) and
missing token.
+ TEST_AMS.getOptimizerHandler().getRegisteredOptimizers().clear();
+ executor.bestEffortTouch();
+ executor.setToken(null);
+ executor.bestEffortTouch();
+ }
+}
diff --git
a/amoro-optimizer/amoro-optimizer-spark/src/main/java/org/apache/amoro/optimizer/spark/SparkOptimizer.java
b/amoro-optimizer/amoro-optimizer-spark/src/main/java/org/apache/amoro/optimizer/spark/SparkOptimizer.java
index 2eb38c028..0e67830bf 100644
---
a/amoro-optimizer/amoro-optimizer-spark/src/main/java/org/apache/amoro/optimizer/spark/SparkOptimizer.java
+++
b/amoro-optimizer/amoro-optimizer-spark/src/main/java/org/apache/amoro/optimizer/spark/SparkOptimizer.java
@@ -22,12 +22,15 @@ import org.apache.amoro.optimizer.common.Optimizer;
import org.apache.amoro.optimizer.common.OptimizerConfig;
import org.apache.amoro.optimizer.common.OptimizerToucher;
import org.apache.amoro.resource.Resource;
+import org.apache.hadoop.util.ShutdownHookManager;
import org.apache.spark.api.java.JavaSparkContext;
import org.apache.spark.sql.SparkSession;
import org.apache.spark.util.Utils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.util.concurrent.TimeUnit;
+
/** The {@code SparkOptimizer} acts as an entrypoint of the spark program */
public class SparkOptimizer extends Optimizer {
private static final Logger LOG =
LoggerFactory.getLogger(SparkOptimizer.class);
@@ -65,6 +68,29 @@ public class SparkOptimizer extends Optimizer {
OptimizerToucher toucher = optimizer.getToucher();
toucher.withRegisterProperty(Resource.PROPERTY_JOB_ID,
spark.sparkContext().applicationId());
+ // Register with Hadoop's ShutdownHookManager so that our hook runs to
completion
+ // before downstream cleanup. ShutdownHookManager runs hooks in priority
descending
+ // order, sequentially. Two cleanups must come AFTER our graceful shutdown:
+ // - Hadoop FileSystem cache close (priority FS_CACHE = 10) — would
otherwise
+ // close in-flight HDFS writers and cause ClosedChannelException on
flush.
+ // - SparkContext.stop (Spark's SPARK_CONTEXT_SHUTDOWN_PRIORITY = 50) —
would
+ // otherwise tear down executors mid-task, failing in-flight RDD
actions.
+ // Use SPARK_CONTEXT_SHUTDOWN_PRIORITY + 10 to guarantee both ordering
constraints
+ // in a single value (60 > 50 > 10).
+ // Pass an explicit timeout — the 2-arg overload uses
hadoop.service.shutdown.timeout
+ // (default 30s), which would cancel our hook well before stopOptimizing's
+ // shutdownTimeoutMs deadline.
+ int shutdownPriority =
+
org.apache.spark.util.ShutdownHookManager.SPARK_CONTEXT_SHUTDOWN_PRIORITY() +
10;
+ ShutdownHookManager.get()
+ .addShutdownHook(
+ () -> {
+ LOG.info("Received shutdown signal, initiating graceful
shutdown...");
+ optimizer.stopOptimizing();
+ },
+ shutdownPriority,
+ config.getShutdownTimeoutMs() + 10_000L,
+ TimeUnit.MILLISECONDS);
LOG.info("Starting the spark optimizer with configuration:{}", config);
optimizer.startOptimizing();
}
diff --git
a/amoro-optimizer/amoro-optimizer-standalone/src/main/java/org/apache/amoro/optimizer/standalone/StandaloneOptimizer.java
b/amoro-optimizer/amoro-optimizer-standalone/src/main/java/org/apache/amoro/optimizer/standalone/StandaloneOptimizer.java
index b38d0fc42..cc7723a80 100644
---
a/amoro-optimizer/amoro-optimizer-standalone/src/main/java/org/apache/amoro/optimizer/standalone/StandaloneOptimizer.java
+++
b/amoro-optimizer/amoro-optimizer-standalone/src/main/java/org/apache/amoro/optimizer/standalone/StandaloneOptimizer.java
@@ -21,13 +21,20 @@ package org.apache.amoro.optimizer.standalone;
import org.apache.amoro.optimizer.common.Optimizer;
import org.apache.amoro.optimizer.common.OptimizerConfig;
import org.apache.amoro.resource.Resource;
+import org.apache.hadoop.fs.FileSystem;
+import org.apache.hadoop.util.ShutdownHookManager;
import org.kohsuke.args4j.CmdLineException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import java.lang.management.ManagementFactory;
import java.lang.management.RuntimeMXBean;
+import java.util.concurrent.TimeUnit;
public class StandaloneOptimizer {
+ private static final Logger LOG =
LoggerFactory.getLogger(StandaloneOptimizer.class);
+
public static void main(String[] args) throws CmdLineException {
OptimizerConfig optimizerConfig = new OptimizerConfig(args);
Optimizer optimizer = new Optimizer(optimizerConfig);
@@ -39,6 +46,23 @@ public class StandaloneOptimizer {
RuntimeMXBean runtimeMXBean = ManagementFactory.getRuntimeMXBean();
String processId = runtimeMXBean.getName().split("@")[0];
optimizer.getToucher().withRegisterProperty(Resource.PROPERTY_JOB_ID,
processId);
+
+ // Register with Hadoop's ShutdownHookManager (priority > FS_CACHE) so
that our hook
+ // runs to completion before Hadoop closes cached FileSystems. JVM Runtime
shutdown
+ // hooks fire concurrently and lose this race, causing in-flight writers
to hit
+ // ClosedChannelException during row-group flush.
+ // Pass an explicit timeout — the 2-arg overload uses
hadoop.service.shutdown.timeout
+ // (default 30s), which would cancel our hook well before stopOptimizing's
+ // shutdownTimeoutMs deadline.
+ ShutdownHookManager.get()
+ .addShutdownHook(
+ () -> {
+ LOG.info("Received shutdown signal, initiating graceful
shutdown...");
+ optimizer.stopOptimizing();
+ },
+ FileSystem.SHUTDOWN_HOOK_PRIORITY + 10,
+ optimizerConfig.getShutdownTimeoutMs() + 10_000L,
+ TimeUnit.MILLISECONDS);
optimizer.startOptimizing();
}
}
diff --git a/dist/src/main/amoro-bin/bin/optimizer.sh
b/dist/src/main/amoro-bin/bin/optimizer.sh
index 8e01c619c..30591bd90 100755
--- a/dist/src/main/amoro-bin/bin/optimizer.sh
+++ b/dist/src/main/amoro-bin/bin/optimizer.sh
@@ -96,7 +96,7 @@ start() {
}
start-foreground() {
- $CMDS
+ exec $CMDS
}
#0:pid bad and proc OK; 1:pid ok and proc bad; 2:pid bad
diff --git a/docs/admin-guides/managing-optimizers.md
b/docs/admin-guides/managing-optimizers.md
index fd708e8c2..35a0d935f 100644
--- a/docs/admin-guides/managing-optimizers.md
+++ b/docs/admin-guides/managing-optimizers.md
@@ -289,6 +289,7 @@ The optimizer group supports the following properties:
| cache-max-entry-size | All | No | 64mb
| Max
entry size in optimizer cache.
[...]
| cache-timeout | All | No | 10min
| Timeout
in optimizer cache.
[...]
| min-parallelism | All | No | 0
| The
minimum total parallelism (CPU cores) that the optimizer group should maintain.
When the total cores of running optimizers fall below this value,
`OptimizerGroupKeeper` will automatically scale out new optimizers. Set to `0`
to disable auto-scaling. Note: The behavior of the auto-scaling mechanism is
controlled by the AMS-level configu [...]
+| shutdown-timeout-ms | All | No | 600000(10min)
|
Graceful shutdown timeout in milliseconds. On shutdown the optimizer waits up
to this long for in-progress tasks to complete before force-interrupting them.
For Kubernetes optimizers, the pod's `terminationGracePeriodSeconds` is derived
from this value plus a 30s buffer. For Flink optimizers, the effective wait is
additionally capped below [...]
| memory | Local | Yes | N/A
| The max
memory of JVM for local optimizer, in MBs.
[...]
| flink-conf.\<key\> | Flink | No | N/A
| Any
flink config options could be overwritten, priority is optimizing-group >
optimizing-container > flink-conf.yaml.
[...]
| spark-conf.\<key\> | Spark | No | N/A
| Any
spark config options could be overwritten, priority is optimizing-group >
optimizing-container > spark-defaults.conf.
[...]
@@ -298,6 +299,7 @@ To better utilize the resources of Flink Optimizer, it is
recommended to add the
* Set `flink-conf.taskmanager.memory.managed.size` to `32mb` as Flink
optimizer does not have any computation logic, it does not need to occupy
managed memory.
* Set `flink-conf.taskmanager.memory.network.max` to `32mb` as there is no
need for communication between operators in Flink Optimizer.
* Set `flink-conf.taskmanager.memory.network.min` to `32mb` as there is no
need for communication between operators in Flink Optimizer.
+* When using `shutdown-timeout-ms` with a Flink Optimizer, also raise
`flink-conf.task.cancellation.timeout` (default 180000) accordingly — the
graceful drain on job cancellation is capped below Flink's cancellation
watchdog, which otherwise fails the whole TaskManager.
{{< /hint >}}
### Edit optimizer group
@@ -360,6 +362,7 @@ The description of the relevant parameters is shown in the
following table:
| -cmts | No | Max total size in optimier cache, default 128MB.
|
| -cmes | No | Max entry size in optimizer cache, default 64MB.
|
| -ct | No | Timeout in optimizer cache, default 10Min.
|
+| -st | No | Graceful shutdown timeout(ms) — on job cancellation,
wait this long for in-progress tasks to complete before force-interrupting
them, default 600000(ms). The effective wait is capped below
task.cancellation.timeout (default 180000). |
Or you can submit optimizer in your own Spark task development platform or
local Spark environment with the following configuration. The main parameters
include:
@@ -391,3 +394,4 @@ The description of the relevant parameters is shown in the
following table:
| -cmts | No | Max total size in optimier cache, default 128MB.
|
| -cmes | No | Max entry size in optimizer cache, default 64MB.
|
| -ct | No | Timeout in optimizer cache, default 10Min.
|
+| -st | No | Graceful shutdown timeout(ms) — on shutdown, wait this
long for in-progress tasks to complete before force-interrupting them, default
600000(ms).
|