Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
* 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.zeppelin.interpreter;

import org.apache.zeppelin.interpreter.remote.RemoteInterpreterProcess;

/**
* Point-in-time status snapshot of a single interpreter process as seen by the Zeppelin server.
* Built purely from in-memory server state without contacting the process, so {@code started}
* reflects whether a process handle exists, not whether the process is currently reachable.
* Reachability is intentionally out of scope here to keep the read path non-blocking.
*/
public class InterpreterProcessStatus {
private final String settingId;
private final String settingName;
private final String groupId;
private final int numSessions;
private final boolean started;
private String host;
private int port = -1;
private String startTime;
private long uptimeSeconds;
private String errorMessage;

public InterpreterProcessStatus(ManagedInterpreterGroup group) {
InterpreterSetting setting = group.getInterpreterSetting();
this.settingId = setting.getId();
this.settingName = setting.getName();
this.groupId = group.getId();
this.numSessions = group.getSessionNum();
RemoteInterpreterProcess process = group.getInterpreterProcess();
this.started = process != null;
if (started) {
Comment on lines +47 to +48

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ManagedInterpreterGroup.getOrCreateInterpreterProcess() assigns remoteInterpreterProcess = createInterpreterProcess(...) before start(), and the reader does not take interpreterProcessCreationLock. A call during a launch can therefore observe a handle whose host and port are still at their initial values (null / -1 at RemoteInterpreterManagedProcess:35-36).

A single started boolean does not let a consumer tell that window apart from a fully started process, so you may want a second field. Conveniently ManagedInterpreterGroup.isLaunchingInterpreterProcess() already exists, or the state could be derived from whether port has been filled in. The latter looks more robust for separating "handle created / awaiting registration / registered" at no extra cost, though you may prefer the simplicity of one boolean, so I will leave it as a matter of taste.

Combined with the uptime in the note above, this would also make "stuck awaiting registration" visible on its own, which catches a fair amount without any remote probe.

this.host = process.getHost();
this.port = process.getPort();
Comment on lines +49 to +50

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of the values this snapshot reads are non-volatile and are written on a different thread from the one reading them:

  • ManagedInterpreterGroup.remoteInterpreterProcess: written inside a synchronized block, read without the lock
  • RemoteInterpreterManagedProcess.host / port: written by the Thrift registration callback, read by the REST thread
  • errorMessage: written by the YarnAppMonitor scheduler thread or the K8s path, read by the REST thread

All of this predates the PR, so it is not something this change introduced. I mention it because this API is the first place that state becomes a documented contract, so reporting a stale value now has a visible consequence. It also feeds directly into the phase decision if you take the port-based approach above.

A few volatile modifiers look close to free here, but if that feels out of scope it seems fine as follow-up. Your call.

this.startTime = process.getStartTime();
this.uptimeSeconds = (System.currentTimeMillis() - process.getStartTimeMs()) / 1000;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

startTimeMs is stamped in the RemoteInterpreterProcess constructor, and RemoteInterpreterRunningProcess is constructed fresh in two places:

  • RecoveryUtils:94 (reconnecting to a process that survived a server restart)
  • StandardInterpreterLauncher:59 (connecting to an already running interpreter)

In those cases uptimeSeconds measures time since the server created the handle rather than process uptime, so right after recovery a Spark process that has been up for hours would report an uptime of a few seconds.

The existing getStartTime() string has the same limitation, so this is not new, but the name uptimeSeconds reads as process lifetime and may be easier to misinterpret. You have been upfront about the limits of started in the Javadoc, and one option is to do the same here, or to rename the field to reflect the server's point of view. Which is better probably depends on how you plan to use the value, so I will leave the call to you.

One small thing alongside it: startTime and startTimeMs each call new Date() / System.currentTimeMillis() independently. Deriving startTime from startTimeMs would keep the two consistent.

this.errorMessage = process.getErrorMessage();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR describes "No remote probe. Built from in-memory server state only, so a stuck interpreter cannot block the call" as the core contract of this endpoint, but this line may not hold to it. Two separate things seem to be going on.

First, getErrorMessage() is a synchronous remote call in some launchers:

  • K8sRemoteInterpreterProcess:525 -> getPodPhase() -> client.pods()...get() (a call to the API server)
  • DockerInterpreterProcess:495 -> client.inspectContainer() (a call to the daemon)

errorMessage is only populated when started == true, so this fires precisely in the healthy case, once per group, serially. If the API server is slow the whole endpoint waits on it, which looks like the bounded liveness probe that was deferred to ZEPPELIN-6576 leaking into this PR.

Second, on the default launcher this value may not be an error signal at all. ProcessLauncher:139 reads:

if (!StringUtils.isBlank(processOutput.getProcessExecutionOutput())) {
  return processOutput.getProcessExecutionOutput();
}

and stopCatchLaunchOutput() only stops appending, it does not clear the launchOutput buffer (ProcessLauncher:180, ExecRemoteInterpreterProcess:215). So a healthy interpreter keeps a non-null errorMessage for as long as its launch output is retained, and for something like Spark that string would be carried in the JSON on every poll, once per group. Please correct me if I have misread this path.

Dropping the field would remove both the blocking call and the payload concern, and would make the contract in the PR description true on every launcher. Adding diagnostics later, alongside the bounded probe in 6576, may be a more natural fit. What do you think?

If you would rather address it here, there is another option. The failure information is already pushed onto the process object:

  • RemoteInterpreterManagedProcess:101, processStopped(String)
  • callers: YarnAppMonitor:81 (a background thread polls YARN and pushes the diagnostics), K8sRemoteInterpreterProcess:169/183/191/211

The cost of finding out has therefore already been paid by a watcher, and a reader only needs to read a field. Holding that value in the base class behind a final accessor that a launcher cannot override would let the snapshot take the cheap path while getErrorMessage() keeps its current behaviour for paragraph error reporting.

That direction has limits worth stating too. Docker records nothing at all (no watcher, no processStopped call), and K8s only records failures during start() and stop(), so a pod that dies after starting is not captured. Closing those gaps is follow-up work in any case, and when it comes up it might be worth considering whether the answer is a probe on the read path or having each launcher detect the death and push it, the way YarnAppMonitor already does and the way the existing PodPhaseWatcher could if it were kept for the pod's lifetime. Perhaps one for the 6576 discussion.

}
}

public String getSettingId() {
return settingId;
}

public String getSettingName() {
return settingName;
}

public String getGroupId() {
return groupId;
}

public int getNumSessions() {
return numSessions;
}

public boolean isStarted() {
return started;
}

public String getHost() {
return host;
}

public int getPort() {
return port;
}

public String getStartTime() {
return startTime;
}

public long getUptimeSeconds() {
return uptimeSeconds;
}

public String getErrorMessage() {
return errorMessage;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,17 @@ public List<ManagedInterpreterGroup> getAllInterpreterGroup() {
return interpreterGroups;
}

/**
* Snapshot the status of every running interpreter group. Uses in-memory state only
*/
public List<InterpreterProcessStatus> getInterpreterProcessStatuses() {
List<InterpreterProcessStatus> statuses = new ArrayList<>();
for (ManagedInterpreterGroup group : getAllInterpreterGroup()) {
statuses.add(new InterpreterProcessStatus(group));
}
return statuses;
}

// TODO(zjffdu) Current approach is not optimized. we have to iterate all interpreter settings.
public void removeInterpreterGroup(String intpGroupId) {
for (InterpreterSetting interpreterSetting : interpreterSettings.values()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public abstract class RemoteInterpreterProcess implements InterpreterClient, Aut
protected int intpEventServerPort;
private PooledRemoteClient<Client> remoteClient;
private String startTime;
private final long startTimeMs;

public RemoteInterpreterProcess(int connectTimeout,
int connectionPoolSize,
Expand All @@ -52,6 +53,7 @@ public RemoteInterpreterProcess(int connectTimeout,
this.intpEventServerHost = intpEventServerHost;
this.intpEventServerPort = intpEventServerPort;
this.startTime = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
this.startTimeMs = System.currentTimeMillis();
this.remoteClient = new PooledRemoteClient<>(() -> {
TSocket transport = new TSocket(getHost(), getPort());
try {
Expand All @@ -71,6 +73,10 @@ public int getConnectTimeout() {
public String getStartTime() {
return startTime;
}

public long getStartTimeMs() {
return startTimeMs;
}

@Override
public void close() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,17 @@ public Response listSettings() {
return new JsonResponse<>(Status.OK, "", interpreterSettingManager.get()).build();
}

/**
* List the runtime status of all running interpreter processes.
*/
@GET
@Path("status")
@ZeppelinApi
public Response getInterpreterProcessStatus() {
return new JsonResponse<>(Status.OK, "",
interpreterSettingManager.getInterpreterProcessStatuses()).build();
}

/**
* Get a setting.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@
import java.util.Map;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
import static org.mockito.Mockito.mock;
Expand Down Expand Up @@ -262,6 +264,28 @@ void testRestartShared() throws InterpreterException {
assertEquals(0, interpreterSetting.getAllInterpreterGroups().size());
}

@Test
void testGetInterpreterProcessStatuses() throws InterpreterException {
// no interpreter group has been created yet
assertTrue(interpreterSettingManager.getInterpreterProcessStatuses().isEmpty());

InterpreterSetting interpreterSetting = interpreterSettingManager.getByName("test");
interpreterSetting.getOption().setPerUser("shared");
interpreterSetting.getOption().setPerNote("shared");
interpreterSetting.getOrCreateSession("user1", note1Id);

List<InterpreterProcessStatus> statuses =
interpreterSettingManager.getInterpreterProcessStatuses();
assertEquals(1, statuses.size());
InterpreterProcessStatus status = statuses.get(0);
assertEquals("test", status.getSettingName());
assertEquals(1, status.getNumSessions());
// process starts lazily on first interpret, so it is not started at this point
assertFalse(status.isStarted());
assertNull(status.getHost());
assertEquals(-1, status.getPort());
}

@Test
void testRestartPerUserIsolated() throws InterpreterException {
InterpreterSetting interpreterSetting = interpreterSettingManager.getByName("test");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,17 @@ void getSettings() throws IOException {
get.close();
}

@Test
void testGetInterpreterProcessStatus() throws IOException {
// when
CloseableHttpResponse get = httpGet("/interpreter/status");
// then
assertThat(get, isAllowed());
JsonArray body = getArrayBodyFieldFromResponse(EntityUtils.toString(get.getEntity(), StandardCharsets.UTF_8));
assertNotNull(body);
get.close();
}

@Test
void testGetNonExistInterpreterSetting() throws IOException {
// when
Expand Down
Loading