This is an automated email from the ASF dual-hosted git repository.
tbonelee pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git
The following commit(s) were added to refs/heads/master by this push:
new b572a07156 [ZEPPELIN-5876] Implement
DockerInterpreterProcess.isAlive() using container state
b572a07156 is described below
commit b572a07156324c6ae9c08da6ac82c9215877741f
Author: HyeonUk Kang <[email protected]>
AuthorDate: Mon Jul 20 23:03:00 2026 +0900
[ZEPPELIN-5876] Implement DockerInterpreterProcess.isAlive() using
container state
### What is this PR for?
The Docker launcher's isAlive() isn't really implemented. It just calls
isRunning(), which only checks whether the interpreter's Thrift port accepts a
connection (checkIfRemoteEndpointAccessible). That tells you the port is open,
not that the process behind it is alive. A container can be OOMKilled or
already gone and still look alive if the port happens to answer for a moment.
It also doesn't match the InterpreterClient contract, which says isAlive should
reflect process status and s [...]
While I was there I filled in getErrorMessage(), which always returned
null. When the container isn't running it now says why: OOMKilled, or a
non-zero exit code. That logic lives in a small describeContainerFailure()
helper to keep getErrorMessage() short.
One change is test-only: the docker field is now 'VisibleForTesting', so
tests can inject a mock DockerClient and skip needing a real daemon.
### What type of PR is it?
Feature
### Todos
- [x] Implement isAlive() from the container's actual state (running/paused)
- [x] Implement getErrorMessage() (OOMKilled / exit code)
- [x] Unit tests with a mocked DockerClient
### What is the Jira issue?
[[ZEPPELIN-5876]](https://issues.apache.org/jira/browse/ZEPPELIN-5876)
### How should this be tested?
Automated unit tests in DockerInterpreterProcessTest :
- isAlive_trueWhenContainerRunning - running == true → alive
- isAlive_falseWhenContainerExitedOrOomKilled — running == false → not alive
- getErrorMessage_reportsOomKilled - oomKilled == true → message contains
the reason
### Screenshots (if appropriate)
### Questions:
* Does the license files need to update? - no
* Is there breaking changes for older versions? - no
* Does this needs documentation? - no
Closes #5316 from hyunw9/ZEPPELIN-5876.
Signed-off-by: ChanHo Lee <[email protected]>
---
.../launcher/DockerInterpreterProcess.java | 47 +++++++++++++-
.../launcher/DockerInterpreterProcessTest.java | 71 ++++++++++++++++++----
2 files changed, 104 insertions(+), 14 deletions(-)
diff --git
a/zeppelin-plugins/launcher/docker/src/main/java/org/apache/zeppelin/interpreter/launcher/DockerInterpreterProcess.java
b/zeppelin-plugins/launcher/docker/src/main/java/org/apache/zeppelin/interpreter/launcher/DockerInterpreterProcess.java
index 3004ae13f7..9c86a67608 100644
---
a/zeppelin-plugins/launcher/docker/src/main/java/org/apache/zeppelin/interpreter/launcher/DockerInterpreterProcess.java
+++
b/zeppelin-plugins/launcher/docker/src/main/java/org/apache/zeppelin/interpreter/launcher/DockerInterpreterProcess.java
@@ -46,6 +46,7 @@ import com.spotify.docker.client.exceptions.DockerException;
import com.spotify.docker.client.messages.Container;
import com.spotify.docker.client.messages.ContainerConfig;
import com.spotify.docker.client.messages.ContainerCreation;
+import com.spotify.docker.client.messages.ContainerState;
import com.spotify.docker.client.messages.ExecCreation;
import com.spotify.docker.client.messages.HostConfig;
import com.spotify.docker.client.messages.PortBinding;
@@ -466,8 +467,20 @@ public class DockerInterpreterProcess extends
RemoteInterpreterProcess {
@Override
public boolean isAlive() {
- //TODO(ZEPPELIN-5876): Implement it more accurately
- return isRunning();
+ DockerClient client = docker;
+ if (client == null) {
+ return false;
+ }
+ try {
+ ContainerState state = client.inspectContainer(containerName).state();
+ return Boolean.TRUE.equals(state.running()) ||
Boolean.TRUE.equals(state.paused());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return false;
+ } catch (Exception e) {
+ LOGGER.warn("Failed to inspect container {} for liveness",
containerName, e);
+ return false;
+ }
}
@Override
@@ -480,6 +493,36 @@ public class DockerInterpreterProcess extends
RemoteInterpreterProcess {
@Override
public String getErrorMessage() {
+ DockerClient client = docker;
+ if (client == null) {
+ return null;
+ }
+ try {
+ ContainerState state = client.inspectContainer(containerName).state();
+ return describeContainerFailure(state);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return null;
+ } catch (Exception e) {
+ LOGGER.warn("Failed to inspect container {} for error message",
containerName, e);
+ return null;
+ }
+ }
+
+ // Returns null when the container is still running or exited cleanly,
+ // otherwise a human-readable reason for the failure.
+ private String describeContainerFailure(ContainerState state) {
+ if (Boolean.TRUE.equals(state.running())) {
+ return null;
+ }
+ if (Boolean.TRUE.equals(state.oomKilled())) {
+ return "Interpreter container " + containerName
+ + " was OOMKilled (exitCode=" + state.exitCode() + ")";
+ }
+ Long exitCode = state.exitCode();
+ if (exitCode != null && exitCode != 0) {
+ return "Interpreter container " + containerName + " exited with code " +
exitCode;
+ }
return null;
}
diff --git
a/zeppelin-plugins/launcher/docker/src/test/java/org/apache/zeppelin/interpreter/launcher/DockerInterpreterProcessTest.java
b/zeppelin-plugins/launcher/docker/src/test/java/org/apache/zeppelin/interpreter/launcher/DockerInterpreterProcessTest.java
index ea5b5bd84a..a4b0b4910b 100644
---
a/zeppelin-plugins/launcher/docker/src/test/java/org/apache/zeppelin/interpreter/launcher/DockerInterpreterProcessTest.java
+++
b/zeppelin-plugins/launcher/docker/src/test/java/org/apache/zeppelin/interpreter/launcher/DockerInterpreterProcessTest.java
@@ -20,6 +20,8 @@ import com.spotify.docker.client.DockerClient;
import com.spotify.docker.client.exceptions.DockerException;
import com.spotify.docker.client.messages.ContainerConfig;
import com.spotify.docker.client.messages.ContainerCreation;
+import com.spotify.docker.client.messages.ContainerInfo;
+import com.spotify.docker.client.messages.ContainerState;
import org.apache.zeppelin.conf.ZeppelinConfiguration;
import org.apache.zeppelin.conf.ZeppelinConfiguration.ConfVars;
import org.apache.zeppelin.interpreter.InterpreterOption;
@@ -69,7 +71,17 @@ class DockerInterpreterProcessTest {
5000, 10);
}
- // stop() must always close the DockerClient, even when killing the container
+ // Stubs docker.inspectContainer(...).state() and returns the ContainerState
mock
+ // so each test can set running/paused/oomKilled/exitCode as needed.
+ private ContainerState stubContainerState(DockerClient mockDocker) throws
Exception {
+ ContainerInfo info = mock(ContainerInfo.class);
+ ContainerState state = mock(ContainerState.class);
+ when(mockDocker.inspectContainer(anyString())).thenReturn(info);
+ when(info.state()).thenReturn(state);
+ return state;
+ }
+
+ // #1: stop() must always close the DockerClient, even when killing the
container
// fails, so the underlying HTTP socket / file descriptors are never leaked.
@Test
void stop_alwaysClosesDockerClient_evenWhenKillContainerFails() throws
Exception {
@@ -83,26 +95,23 @@ class DockerInterpreterProcessTest {
verify(mockDocker, times(1)).close();
}
- // When start() fails after the container has been started (e.g. file copy /
exec
- // fails), the container must be cleaned up instead of being left orphaned.
+ // #2: when start() fails after the container has been started, it must be
rolled
+ // back (kill + remove) instead of being left orphaned.
@Test
void start_removesContainer_whenContainerPreparationFails() throws Exception
{
DockerInterpreterProcess intp = spy(newProcess());
DockerClient mockDocker = mock(DockerClient.class);
doReturn(mockDocker).when(intp).createDockerClient(anyString());
- // No pre-existing container to remove.
when(mockDocker.listContainers(any())).thenReturn(Collections.emptyList());
- // Container is created and started successfully...
when(mockDocker.createContainer(any(ContainerConfig.class), anyString()))
.thenReturn(ContainerCreation.builder().id("test-container-id").build());
- // ...but preparing it (the first exec inside the container) fails.
+ // Container is created and started, then preparation (the first exec)
fails.
doThrow(new DockerException("exec failed"))
.when(mockDocker).execCreate(anyString(), any(String[].class), any());
assertThrows(IOException.class, () -> intp.start("user1"));
- // The container was started, so start() must roll it back before
returning.
verify(mockDocker).startContainer("test-container-id");
verify(mockDocker).killContainer(anyString());
verify(mockDocker).removeContainer(anyString());
@@ -115,19 +124,57 @@ class DockerInterpreterProcessTest {
doReturn(mockDocker).when(intp).createDockerClient(anyString());
when(mockDocker.listContainers(any())).thenReturn(Collections.emptyList());
- // Container is created...
when(mockDocker.createContainer(any(ContainerConfig.class), anyString()))
.thenReturn(ContainerCreation.builder().id("test-container-id").build());
- // ...but fails to start, so it is created-but-not-running.
- doThrow(new DockerException("start
failed")).when(mockDocker).startContainer(anyString());
- // Killing a non-running container fails, but removeContainer must still
fire.
- doThrow(new DockerException("not
running")).when(mockDocker).killContainer(anyString());
+ // Container is created and started, then preparation (the first exec)
fails.
+ doThrow(new DockerException("exec failed"))
+ .when(mockDocker).execCreate(anyString(), any(String[].class), any());
+ // ...and killing the container during cleanup fails too.
+ doThrow(new DockerException("kill
failed")).when(mockDocker).killContainer(anyString());
assertThrows(IOException.class, () -> intp.start("user1"));
+ // removeContainer must still fire despite the kill failure.
+ verify(mockDocker).killContainer(anyString());
verify(mockDocker).removeContainer(anyString());
}
+ // isAlive() reflects the container's actual state from the Docker daemon,
+ // not just whether the Thrift port is reachable.
+ @Test
+ void isAlive_trueWhenContainerRunning() throws Exception {
+ DockerInterpreterProcess intp = newProcess();
+ DockerClient mockDocker = mock(DockerClient.class);
+ intp.docker = mockDocker;
+ ContainerState state = stubContainerState(mockDocker);
+ when(state.running()).thenReturn(true);
+
+ assertTrue(intp.isAlive());
+ }
+
+ @Test
+ void isAlive_falseWhenContainerNotRunning() throws Exception {
+ DockerInterpreterProcess intp = newProcess();
+ DockerClient mockDocker = mock(DockerClient.class);
+ intp.docker = mockDocker;
+ ContainerState state = stubContainerState(mockDocker);
+ when(state.running()).thenReturn(false);
+
+ assertFalse(intp.isAlive());
+ }
+
+ @Test
+ void getErrorMessage_reportsOomKilled() throws Exception {
+ DockerInterpreterProcess intp = newProcess();
+ DockerClient mockDocker = mock(DockerClient.class);
+ intp.docker = mockDocker;
+ ContainerState state = stubContainerState(mockDocker);
+ when(state.oomKilled()).thenReturn(true);
+ when(state.exitCode()).thenReturn(137L);
+
+ assertTrue(intp.getErrorMessage().contains("OOMKilled"));
+ }
+
@Test
void testCreateIntpProcess() throws IOException {
DockerInterpreterLauncher launcher