This is an automated email from the ASF dual-hosted git repository.
Jackie-Jiang pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git
The following commit(s) were added to refs/heads/master by this push:
new e16a0c9ac94 Run multi-server quickstarts in separate JVMs (#20202)
e16a0c9ac94 is described below
commit e16a0c9ac943716222e43705ab99c63e3bea1035
Author: Xiaotian (Jackie) Jiang <[email protected]>
AuthorDate: Tue Nov 10 20:49:29 2026 -0800
Run multi-server quickstarts in separate JVMs (#20202)
---
.github/workflows/scripts/.pinot_quickstart.sh | 2 +-
docker/images/pinot/README.md | 32 +++
.../java/org/apache/pinot/tools/Quickstart.java | 25 +-
.../tools/admin/command/QuickstartRunner.java | 79 +++++-
.../admin/command/QuickstartServerProcess.java | 290 +++++++++++++++++++
.../tools/admin/command/QuickstartRunnerTest.java | 113 ++++++++
.../admin/command/QuickstartServerHeapTest.java | 130 +++++++++
.../admin/command/QuickstartServerProcessTest.java | 306 +++++++++++++++++++++
8 files changed, 959 insertions(+), 18 deletions(-)
diff --git a/.github/workflows/scripts/.pinot_quickstart.sh
b/.github/workflows/scripts/.pinot_quickstart.sh
index dad7e5fb258..bb86f776d43 100755
--- a/.github/workflows/scripts/.pinot_quickstart.sh
+++ b/.github/workflows/scripts/.pinot_quickstart.sh
@@ -264,7 +264,7 @@ wait_for_query 8000 \
'.resultTable.rows[0][0] == 97889' \
'multi-stage count(*) on baseballStats' || PASS=0
-# Ordered so the assertion does not depend on how segments are spread across
the 3 servers. Every row must have
+# Ordered so the assertion does not depend on how segments are spread across
the servers. Every row must have
# resolved a team name, otherwise the join produced rows without matching the
dimension table.
wait_for_query 8000 \
'SET useMultistageEngine=true; SELECT a.playerName, a.teamID, b.teamName
FROM baseballStats_OFFLINE AS a JOIN dimBaseballTeams_OFFLINE AS b ON a.teamID
= b.teamID ORDER BY a.playerName, a.teamID LIMIT 10' \
diff --git a/docker/images/pinot/README.md b/docker/images/pinot/README.md
index 1208627767b..7f4260bd296 100644
--- a/docker/images/pinot/README.md
+++ b/docker/images/pinot/README.md
@@ -133,6 +133,38 @@ docker run -p 8098:8098 pinot:release-0.1.0 StartServer
-zkAddress 172.17.0.2:21
## QuickStart
+### Heap sizes for forked quickstarts
+
+Quickstarts that fork their servers run a parent JVM for the other services
and a separate JVM for each server.
+`JAVA_OPTS` sets the parent JVM's options. Its heap options are not copied to
the server JVMs. Set these system properties
+in the parent's `JAVA_OPTS` to configure each server's heap:
+
+| System property | Default | Child JVM option with defaults |
+| --- | --- | --- |
+| `pinot.quickstart.server.xmx` | `1g` | `-Xmx1g` |
+| `pinot.quickstart.server.xms` | Resolved `pinot.quickstart.server.xmx` value
| `-Xms1g` |
+
+Values use Java heap-size syntax, such as `256m` or `2g`. The initial heap
must not exceed the maximum heap.
+By default, each server uses equal initial and maximum heaps. Setting only
`pinot.quickstart.server.xmx=512m` gives
+each child `-Xms512m -Xmx512m`. Set `pinot.quickstart.server.xms` explicitly
to use a different initial heap.
+These properties affect forked quickstart servers only; they do not configure
separately launched `StartServer` commands.
+
+The Docker image defaults the parent JVM to `-Xms4G -Xmx4G`. With the default
child heap limits, the two-server BATCH
+quickstart therefore has a combined maximum Java heap of **6 GiB**: 4 GiB for
the parent plus 1 GiB for each server.
+A four-server quickstart has a combined maximum heap of **8 GiB**. Allocate
additional container memory for direct
+buffers, memory-mapped segment pages, metaspace, thread stacks, and other
native memory; these heap totals are not
+container memory limits or estimates of current memory usage.
+
+For example, configure equal 2 GiB initial and maximum heaps for both the
parent and each server:
+
+```SHELL
+docker run --rm --name pinot-quickstart \
+ -p 127.0.0.1:9000:9000 \
+ -e JAVA_OPTS="-Xms2g -Xmx2g -Dpinot.admin.system.exit=true
-Dpinot.quickstart.server.xmx=2g" \
+ pinot:latest QuickStart -type batch
+```
+
+For a local distribution, pass the same `JAVA_OPTS` environment variable to
`bin/pinot-admin.sh QuickStart -type batch`.
### Use docker compose to bring up Pinot stack
diff --git a/pinot-tools/src/main/java/org/apache/pinot/tools/Quickstart.java
b/pinot-tools/src/main/java/org/apache/pinot/tools/Quickstart.java
index 6e8e1224f6d..594169f89fc 100644
--- a/pinot-tools/src/main/java/org/apache/pinot/tools/Quickstart.java
+++ b/pinot-tools/src/main/java/org/apache/pinot/tools/Quickstart.java
@@ -32,6 +32,8 @@ import org.apache.pinot.spi.utils.CommonConstants;
import org.apache.pinot.tools.admin.PinotAdministrator;
import org.apache.pinot.tools.admin.command.QuickstartRunner;
import org.apache.pinot.tools.utils.SampleQueries;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
/// The basic Batch/Offline Quickstart.
@@ -40,6 +42,7 @@ import org.apache.pinot.tools.utils.SampleQueries;
/// a dedicated quickstart each: the multi-stage engine, JSON indexes, complex
type handling, timestamp indexes,
/// lookup joins and vector similarity. The types those quickstarts used to
own are kept as deprecated aliases.
public class Quickstart extends QuickStartBase {
+ private static final Logger LOGGER =
LoggerFactory.getLogger(Quickstart.class);
protected static final Map<String, String> OPTIONS_TO_USE_MSE =
Map.of("queryOptions",
CommonConstants.Broker.Request.QueryOptionKey.USE_MULTISTAGE_ENGINE +
"=true");
@@ -112,10 +115,9 @@ public class Quickstart extends QuickStartBase {
new QuickstartRunner(quickstartTableRequests, 1, 1,
getNumQuickstartRunnerServers(), 1, quickstartRunnerDir,
true, getAuthProvider(),
getConfigOverrides(), _zkExternalAddress, true,
getClusterConfigOverrides(),
- this::getIndividualBrokerConfigOverridesFunction,
this::getIndividualServerConfigOverridesFunction);
+ this::getIndividualBrokerConfigOverridesFunction,
this::getIndividualServerConfigOverridesFunction)
+ .setForkServers(getNumQuickstartRunnerServers() > 1);
- printStatus(Color.CYAN, "***** Starting Zookeeper, controller, broker,
server and minion *****");
- runner.startAll();
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
printStatus(Color.GREEN, "***** Shutting down quick start *****");
@@ -125,6 +127,18 @@ public class Quickstart extends QuickStartBase {
e.printStackTrace();
}
}));
+ printStatus(Color.CYAN, "***** Starting Zookeeper, controller, broker,
server and minion *****");
+ try {
+ runner.startAll();
+ } catch (Exception e) {
+ LOGGER.error("Failed to start quickstart", e);
+ try {
+ runner.stop();
+ } catch (Exception cleanupException) {
+ e.addSuppressed(cleanupException);
+ }
+ throw e;
+ }
if (!CollectionUtils.isEmpty(quickstartTableRequests)) {
printStatus(Color.CYAN, "***** Bootstrap tables *****");
@@ -144,13 +158,12 @@ public class Quickstart extends QuickStartBase {
QuickstartRunner.DEFAULT_CONTROLLER_PORT));
}
- /// Three servers so that the multi-stage sample queries below actually
exercise a cross-server exchange, which is
- /// what the merged MULTI_STAGE and JOIN quickstarts used to set up.
+ /// Two servers in separate JVMs exercise cross-server exchanges without
sharing server-local static state.
///
/// This is the default for every quickstart extending this class, not just
`-type BATCH`. Subclasses that do not
/// need distributed execution can override it; [TPCHQuickStart] for
instance runs on a single server.
protected int getNumQuickstartRunnerServers() {
- return 3;
+ return 2;
}
@Override
diff --git
a/pinot-tools/src/main/java/org/apache/pinot/tools/admin/command/QuickstartRunner.java
b/pinot-tools/src/main/java/org/apache/pinot/tools/admin/command/QuickstartRunner.java
index c23f9b51810..b72d5ec1a1d 100644
---
a/pinot-tools/src/main/java/org/apache/pinot/tools/admin/command/QuickstartRunner.java
+++
b/pinot-tools/src/main/java/org/apache/pinot/tools/admin/command/QuickstartRunner.java
@@ -28,6 +28,7 @@ import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.IntFunction;
import org.apache.commons.io.FileUtils;
import org.apache.helix.HelixManager;
@@ -86,7 +87,10 @@ public class QuickstartRunner {
private final List<Integer> _controllerPorts = new ArrayList<>();
private final List<Integer> _brokerPorts = new ArrayList<>();
- private boolean _isStopped = false;
+ private final List<QuickstartServerProcess> _serverProcesses = new
CopyOnWriteArrayList<>();
+ private boolean _forkServers;
+ private boolean _stopRequested;
+ private boolean _isStopped;
public QuickstartRunner(List<QuickstartTableRequest> tableRequests, int
numControllers, int numBrokers,
int numServers, int numMinions, File tempDir, Map<String, Object>
configOverrides)
@@ -202,6 +206,13 @@ public class QuickstartRunner {
}
}
+ /// Starts servers in separate JVMs, isolating server-local static state
such as dimension-table managers.
+ /// Configure this before calling [#startAll].
+ public QuickstartRunner setForkServers(boolean forkServers) {
+ _forkServers = forkServers;
+ return this;
+ }
+
private void startServers()
throws Exception {
for (int i = 0; i < _numServers; i++) {
@@ -222,7 +233,18 @@ public class QuickstartRunner {
} else {
serverStarter.setConfigOverrides(_configOverrides);
}
- if (!serverStarter.execute()) {
+ if (_forkServers) {
+ QuickstartServerProcess process;
+ synchronized (_serverProcesses) {
+ if (_stopRequested) {
+ throw new IllegalStateException("Quickstart stopped while starting
server " + i);
+ }
+ process =
QuickstartServerProcess.launch(serverStarter.getServerConf(),
+ new File(_tempDir, "server-process-" + i));
+ _serverProcesses.add(process);
+ }
+ process.awaitReady();
+ } else if (!serverStarter.execute()) {
throw new RuntimeException("Failed to start Server");
}
}
@@ -248,26 +270,42 @@ public class QuickstartRunner {
public void startAll()
throws Exception {
- registerDefaultPinotFS();
- if (_zkExternalAddress == null) {
- startZookeeper();
+ try {
+ registerDefaultPinotFS();
+ if (_zkExternalAddress == null) {
+ startZookeeper();
+ }
+ startControllers();
+ startBrokers();
+ startServers();
+ startMinions();
+ } catch (Exception e) {
+ try {
+ stopServerProcesses();
+ } catch (Exception cleanupException) {
+ e.addSuppressed(cleanupException);
+ }
+ throw e;
}
- startControllers();
- startBrokers();
- startServers();
- startMinions();
}
- public void stop()
+ public synchronized void stop()
throws Exception {
if (_isStopped) {
return;
}
+ synchronized (_serverProcesses) {
+ _stopRequested = true;
+ }
+ stopServerProcesses();
// TODO: Stop Minion
StopProcessCommand stopper = new StopProcessCommand(false);
if (_zkExternalAddress == null) {
- stopper.stopController().stopBroker().stopServer().stopZookeeper();
+ stopper.stopController().stopBroker().stopZookeeper();
+ if (!_forkServers) {
+ stopper.stopServer();
+ }
}
stopper.execute();
if (_deleteExistingData) {
@@ -276,6 +314,25 @@ public class QuickstartRunner {
_isStopped = true;
}
+ private void stopServerProcesses()
+ throws Exception {
+ Exception failure = null;
+ for (QuickstartServerProcess process : _serverProcesses) {
+ try {
+ process.stop();
+ } catch (Exception e) {
+ if (failure == null) {
+ failure = e;
+ } else {
+ failure.addSuppressed(e);
+ }
+ }
+ }
+ if (failure != null) {
+ throw failure;
+ }
+ }
+
public void createServerTenantWith(int numOffline, int numRealtime, String
tenantName)
throws Exception {
new AddTenantCommand().setControllerUrl("http://localhost:" +
_controllerPorts.get(0)).setName(tenantName)
diff --git
a/pinot-tools/src/main/java/org/apache/pinot/tools/admin/command/QuickstartServerProcess.java
b/pinot-tools/src/main/java/org/apache/pinot/tools/admin/command/QuickstartServerProcess.java
new file mode 100644
index 00000000000..f3f21b82b76
--- /dev/null
+++
b/pinot-tools/src/main/java/org/apache/pinot/tools/admin/command/QuickstartServerProcess.java
@@ -0,0 +1,290 @@
+/**
+ * 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.pinot.tools.admin.command;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.RandomAccessFile;
+import java.lang.management.ManagementFactory;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import org.apache.commons.configuration2.PropertiesConfiguration;
+import org.apache.commons.configuration2.convert.LegacyListDelimiterHandler;
+import org.apache.commons.configuration2.io.FileHandler;
+import org.apache.pinot.spi.utils.CommonConstants;
+import org.apache.pinot.tools.admin.PinotAdministrator;
+
+
+/// Owns one quickstart server JVM, its configuration, and its startup log.
Startup waits for the server's readiness
+/// endpoint. The child exits when its parent's stdin pipe closes, including
when the parent is killed abruptly.
+/// [#stop] is synchronized and can be called repeatedly or concurrently with
waiting for readiness.
+final class QuickstartServerProcess {
+ static final String STARTED_FILE_NAME = "server.started";
+ static final String INITIAL_HEAP_PROPERTY = "pinot.quickstart.server.xms";
+ static final String MAX_HEAP_PROPERTY = "pinot.quickstart.server.xmx";
+ private static final String DEFAULT_MAX_HEAP = "1g";
+ private static final Duration STARTUP_TIMEOUT = Duration.ofMinutes(2);
+ private static final long SHUTDOWN_TIMEOUT_SECONDS = 30;
+ private static final long FORCED_SHUTDOWN_TIMEOUT_SECONDS = 10;
+ private static final long PARENT_LOSS_SHUTDOWN_TIMEOUT_SECONDS = 5;
+
+ private final Process _process;
+ private final File _logFile;
+ private final File _startedFile;
+ private final int _port;
+
+ private QuickstartServerProcess(Process process, File logFile, File
startedFile, int port) {
+ _process = process;
+ _logFile = logFile;
+ _startedFile = startedFile;
+ _port = port;
+ }
+
+ static QuickstartServerProcess launch(Map<String, Object> effectiveConfig,
File processDir)
+ throws Exception {
+ Files.createDirectories(processDir.toPath());
+ File configFile = new File(processDir, "server.properties");
+ writeConfig(effectiveConfig, configFile);
+ int port =
Integer.parseInt(effectiveConfig.get(CommonConstants.Server.CONFIG_OF_ADMIN_API_PORT).toString());
+ return launch(buildCommand(configFile, processDir), processDir, port);
+ }
+
+ static void writeConfig(Map<String, Object> effectiveConfig, File configFile)
+ throws Exception {
+ PropertiesConfiguration configuration = new PropertiesConfiguration();
+ configuration.setListDelimiterHandler(new LegacyListDelimiterHandler(','));
+ configuration.getLayout().setForceSingleLine(true);
+ effectiveConfig.forEach(configuration::setProperty);
+ new FileHandler(configuration).save(configFile);
+ }
+
+ static List<String> buildCommand(File configFile, File processDir) {
+ List<String> command = new ArrayList<>();
+ File javaExecutable = new File(System.getProperty("java.home"),
"bin/java");
+ command.add(javaExecutable.getAbsolutePath());
+ String maxHeap = System.getProperty(MAX_HEAP_PROPERTY, DEFAULT_MAX_HEAP);
+ command.add("-Xms" + System.getProperty(INITIAL_HEAP_PROPERTY, maxHeap));
+ command.add("-Xmx" + maxHeap);
+ Iterator<String> parentArguments =
ManagementFactory.getRuntimeMXBean().getInputArguments().iterator();
+ while (parentArguments.hasNext()) {
+ String argument = parentArguments.next();
+ for (String option : List.of("--add-opens", "--add-exports",
"--add-modules", "--enable-native-access")) {
+ if (argument.startsWith(option + "=")) {
+ command.add(argument);
+ break;
+ }
+ if (argument.equals(option) && parentArguments.hasNext()) {
+ command.add(argument);
+ command.add(parentArguments.next());
+ break;
+ }
+ }
+ }
+ for (String name : System.getProperties().stringPropertyNames()) {
+ if (name.startsWith("plugins.") || name.startsWith("log4j.") ||
name.startsWith("log4j2.")
+ || name.startsWith("io.netty.") ||
name.startsWith("io.grpc.netty.")) {
+ command.add("-D" + name + "=" + System.getProperty(name));
+ }
+ }
+ command.add("-Djava.io.tmpdir=" + new File(processDir,
"tmp").getAbsolutePath());
+ command.add("-Dpinot.admin.system.exit=true");
+ command.add("-cp");
+ command.add(System.getProperty("java.class.path"));
+ command.add(QuickstartServerProcess.class.getName());
+ command.add(configFile.getAbsolutePath());
+ command.add(new File(processDir, STARTED_FILE_NAME).getAbsolutePath());
+ return command;
+ }
+
+ static QuickstartServerProcess launch(List<String> command, File processDir,
int port)
+ throws IOException {
+ Files.createDirectories(new File(processDir, "tmp").toPath());
+ File logDir = new File(processDir, "logs");
+ Files.createDirectories(logDir.toPath());
+ File logFile = new File(processDir, "server.log");
+ File startedFile = new File(processDir, STARTED_FILE_NAME);
+ Files.deleteIfExists(startedFile.toPath());
+ ProcessBuilder processBuilder = new
ProcessBuilder(command).redirectErrorStream(true).redirectOutput(logFile);
+ processBuilder.environment().put("LOG_ROOT", logDir.getAbsolutePath());
+ processBuilder.environment().put("PINOT_COMPONENT", "server");
+ Process process = processBuilder.start();
+ return new QuickstartServerProcess(process, logFile, startedFile, port);
+ }
+
+ void awaitReady()
+ throws Exception {
+ awaitReady(STARTUP_TIMEOUT);
+ }
+
+ void awaitReady(Duration timeout)
+ throws Exception {
+ try {
+ waitForReadiness(timeout);
+ } catch (Exception e) {
+ try {
+ stop();
+ } catch (Exception stopException) {
+ e.addSuppressed(stopException);
+ }
+ addLogTail(e);
+ if (e instanceof InterruptedException) {
+ Thread.currentThread().interrupt();
+ }
+ throw e;
+ }
+ }
+
+ private void waitForReadiness(Duration timeout)
+ throws IOException, InterruptedException {
+ long deadline = System.nanoTime() + timeout.toNanos();
+ URI healthUri = URI.create("http://127.0.0.1:" + _port +
"/health/readiness");
+ while (System.nanoTime() < deadline) {
+ if (!_process.isAlive()) {
+ throw new IOException("Quickstart server exited with code " +
_process.exitValue() + "; see logs in "
+ + _logFile.getParentFile());
+ }
+ // An existing service on the same port must not make a child that has
not finished starting look ready.
+ if (!_startedFile.isFile()) {
+ Thread.sleep(200);
+ continue;
+ }
+ HttpURLConnection connection = (HttpURLConnection)
healthUri.toURL().openConnection();
+ connection.setConnectTimeout(1000);
+ connection.setReadTimeout(1000);
+ try {
+ if (connection.getResponseCode() == HttpURLConnection.HTTP_OK) {
+ try (InputStream response = connection.getInputStream()) {
+ if ("OK".equals(new String(response.readNBytes(3),
StandardCharsets.UTF_8).trim()) && _process.isAlive()) {
+ return;
+ }
+ }
+ }
+ } catch (IOException e) {
+ // The admin listener may not be bound yet; retry until readiness or
the startup deadline.
+ } finally {
+ connection.disconnect();
+ }
+ Thread.sleep(200);
+ }
+ throw new IOException("Timed out waiting for quickstart server readiness
at " + healthUri + "; see logs in "
+ + _logFile.getParentFile());
+ }
+
+ private void addLogTail(Exception failure) {
+ // Keep useful diagnostics in the exception even if the quickstart's
shutdown hook removes its working directory.
+ addLogTail(failure, _logFile);
+ File runtimeLog = new File(_logFile.getParentFile(),
"logs/pinot-server.log");
+ if (runtimeLog.isFile()) {
+ addLogTail(failure, runtimeLog);
+ }
+ }
+
+ private static void addLogTail(Exception failure, File logFile) {
+ try (RandomAccessFile log = new RandomAccessFile(logFile, "r")) {
+ int length = (int) Math.min(log.length(), 8192);
+ if (length > 0) {
+ byte[] bytes = new byte[length];
+ log.seek(log.length() - length);
+ log.readFully(bytes);
+ failure.addSuppressed(new IOException("Quickstart server log tail from
" + logFile + ":\n"
+ + new String(bytes, StandardCharsets.UTF_8)));
+ }
+ } catch (IOException e) {
+ failure.addSuppressed(e);
+ }
+ }
+
+ Process getProcess() {
+ return _process;
+ }
+
+ synchronized void stop()
+ throws IOException, InterruptedException {
+ if (!_process.isAlive()) {
+ return;
+ }
+ // Process.destroy() also closes stdin, which would trigger the shorter
parent-loss deadline during normal stop.
+ _process.toHandle().destroy();
+ try {
+ if (!_process.waitFor(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
+ _process.destroyForcibly();
+ if (!_process.waitFor(FORCED_SHUTDOWN_TIMEOUT_SECONDS,
TimeUnit.SECONDS)) {
+ throw new IOException("Quickstart server did not exit after forced
shutdown; see " + _logFile);
+ }
+ }
+ } catch (InterruptedException e) {
+ _process.destroyForcibly();
+ try {
+ _process.waitFor(FORCED_SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ } catch (InterruptedException interruptedAgain) {
+ e.addSuppressed(interruptedAgain);
+ } finally {
+ Thread.currentThread().interrupt();
+ }
+ throw e;
+ }
+ }
+
+ static void watchParent() {
+ Thread.ofPlatform().daemon().name("quickstart-parent-watch").start(() -> {
+ try {
+ while (System.in.read() != -1) {
+ // The parent keeps this pipe open for the lifetime of the server.
+ }
+ exitAfterParentLoss(0);
+ } catch (IOException e) {
+ exitAfterParentLoss(1);
+ }
+ });
+ }
+
+ private static void exitAfterParentLoss(int exitCode) {
+ // Embedded ZooKeeper may have died with the parent. Server shutdown hooks
can then block on Helix retries,
+ // including if shutdown is already in progress, so enforce the deadline
independently of System.exit().
+ Thread.ofPlatform().daemon().name("quickstart-parent-loss-halt").start(()
-> {
+ try {
+ TimeUnit.SECONDS.sleep(PARENT_LOSS_SHUTDOWN_TIMEOUT_SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ Runtime.getRuntime().halt(exitCode);
+ });
+ System.exit(exitCode);
+ }
+
+ public static void main(String[] args) {
+ watchParent();
+ PinotAdministrator.main(new String[]{"StartServer", "-configFile",
args[0]});
+ try {
+ Files.createFile(new File(args[1]).toPath());
+ } catch (IOException e) {
+ e.printStackTrace();
+ System.exit(1);
+ }
+ }
+}
diff --git
a/pinot-tools/src/test/java/org/apache/pinot/tools/admin/command/QuickstartRunnerTest.java
b/pinot-tools/src/test/java/org/apache/pinot/tools/admin/command/QuickstartRunnerTest.java
new file mode 100644
index 00000000000..ceacaa447c6
--- /dev/null
+++
b/pinot-tools/src/test/java/org/apache/pinot/tools/admin/command/QuickstartRunnerTest.java
@@ -0,0 +1,113 @@
+/**
+ * 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.pinot.tools.admin.command;
+
+import java.lang.reflect.Field;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import org.apache.commons.io.FileUtils;
+import org.mockito.MockedConstruction;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import static org.mockito.Mockito.RETURNS_SELF;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.withSettings;
+import static org.testng.Assert.assertEquals;
+
+
+/// Verifies quickstart process ownership during shutdown. Tests use
thread-scoped construction mocks and private
+/// temporary directories so they never scan real PID files, kill processes,
or change global system properties.
+public class QuickstartRunnerTest {
+ @DataProvider
+ public Object[][] forkModes() {
+ return new Object[][]{{false}, {true}};
+ }
+
+ @Test(dataProvider = "forkModes")
+ public void testExternalZookeeperSkipsGlobalPidScanning(boolean forkServers)
+ throws Exception {
+ Path directory = Files.createTempDirectory("quickstart-runner-test");
+ try (MockedConstruction<StopProcessCommand> stoppers =
+ mockConstruction(StopProcessCommand.class,
withSettings().defaultAnswer(RETURNS_SELF))) {
+ QuickstartRunner runner = newRunner(directory,
"external-zookeeper:2181", forkServers);
+ QuickstartServerProcess ownedServer =
mock(QuickstartServerProcess.class);
+ if (forkServers) {
+ addOwnedServer(runner, ownedServer);
+ }
+ runner.stop();
+ runner.stop();
+
+ assertEquals(stoppers.constructed().size(), 1);
+ StopProcessCommand stopper = stoppers.constructed().getFirst();
+ verify(stopper).execute();
+ // Selecting any role enables a global PID scan and can stop processes
belonging to another local cluster.
+ verifyNoMoreInteractions(stopper);
+ if (forkServers) {
+ verify(ownedServer).stop();
+ }
+ verifyNoMoreInteractions(ownedServer);
+ } finally {
+ FileUtils.deleteDirectory(directory.toFile());
+ }
+ }
+
+ @Test(dataProvider = "forkModes")
+ public void testEmbeddedZookeeperKeepsLegacyCleanup(boolean forkServers)
+ throws Exception {
+ Path directory = Files.createTempDirectory("quickstart-runner-test");
+ try (MockedConstruction<StopProcessCommand> stoppers =
+ mockConstruction(StopProcessCommand.class,
withSettings().defaultAnswer(RETURNS_SELF))) {
+ newRunner(directory, null, forkServers).stop();
+
+ assertEquals(stoppers.constructed().size(), 1);
+ StopProcessCommand stopper = stoppers.constructed().getFirst();
+ verify(stopper).stopController();
+ verify(stopper).stopBroker();
+ verify(stopper).stopZookeeper();
+ if (!forkServers) {
+ verify(stopper).stopServer();
+ }
+ verify(stopper).execute();
+ verifyNoMoreInteractions(stopper);
+ } finally {
+ FileUtils.deleteDirectory(directory.toFile());
+ }
+ }
+
+ private static QuickstartRunner newRunner(Path directory, String
externalZookeeper, boolean forkServers)
+ throws Exception {
+ return new QuickstartRunner(List.of(), 0, 0, 0, 0, directory.toFile(),
true, null, Map.of(), externalZookeeper,
+ false, Map.of()).setForkServers(forkServers);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static void addOwnedServer(QuickstartRunner runner,
QuickstartServerProcess server)
+ throws Exception {
+ // Register an owned process without starting a real cluster; this field
is declared with the same element type.
+ Field field = QuickstartRunner.class.getDeclaredField("_serverProcesses");
+ field.setAccessible(true);
+ ((List<QuickstartServerProcess>) field.get(runner)).add(server);
+ }
+}
diff --git
a/pinot-tools/src/test/java/org/apache/pinot/tools/admin/command/QuickstartServerHeapTest.java
b/pinot-tools/src/test/java/org/apache/pinot/tools/admin/command/QuickstartServerHeapTest.java
new file mode 100644
index 00000000000..e2b11a31384
--- /dev/null
+++
b/pinot-tools/src/test/java/org/apache/pinot/tools/admin/command/QuickstartServerHeapTest.java
@@ -0,0 +1,130 @@
+/**
+ * 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.pinot.tools.admin.command;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.lang.management.ManagementFactory;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Properties;
+import java.util.concurrent.TimeUnit;
+import org.apache.commons.io.FileUtils;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertTrue;
+
+
+/// Verifies the heap limits used by actual child JVMs. Tests execute
sequentially because constructing their commands
+/// temporarily changes the dedicated quickstart heap system properties,
restoring them before launching a child.
+public class QuickstartServerHeapTest {
+ @DataProvider
+ public Object[][] heapSizes() {
+ return new Object[][]{
+ {null, null, "1g", "1g", 1024L * 1024 * 1024},
+ {null, "512m", "512m", "512m", 512L * 1024 * 1024},
+ {"64m", "192m", "64m", "192m", 192L * 1024 * 1024}
+ };
+ }
+
+ @Test(dataProvider = "heapSizes", timeOut = 30_000)
+ public void testChildHeap(String initialHeap, String maxHeap, String
expectedInitialHeap, String expectedMaxHeap,
+ long expectedMaxBytes)
+ throws Exception {
+ Path processDir = Files.createTempDirectory("quickstart-server-heap-test");
+ Process process = null;
+ try {
+ Files.createDirectory(processDir.resolve("tmp"));
+ Path reportFile = processDir.resolve("heap.properties");
+ List<String> command = buildProbeCommand(processDir, reportFile,
initialHeap, maxHeap);
+ Path logFile = processDir.resolve("probe.log");
+ process = new
ProcessBuilder(command).redirectErrorStream(true).redirectOutput(logFile.toFile()).start();
+ assertTrue(process.waitFor(15, TimeUnit.SECONDS), "Heap probe did not
exit");
+ assertEquals(process.exitValue(), 0, Files.readString(logFile));
+ Properties report = new Properties();
+ try (InputStream input = Files.newInputStream(reportFile)) {
+ report.load(input);
+ }
+ List<String> arguments =
List.of(report.getProperty("arguments").split("\n"));
+ assertEquals(arguments.stream().filter(argument ->
argument.startsWith("-Xms")).toList(),
+ List.of("-Xms" + expectedInitialHeap));
+ assertEquals(arguments.stream().filter(argument ->
argument.startsWith("-Xmx")).toList(),
+ List.of("-Xmx" + expectedMaxHeap));
+ long maxBytes = Long.parseLong(report.getProperty("maxMemory"));
+ assertTrue(maxBytes <= expectedMaxBytes, "Child exceeded its configured
heap limit: " + maxBytes);
+ // Some collectors exclude a survivor region from Runtime.maxMemory().
+ assertTrue(maxBytes >= expectedMaxBytes * 3 / 4, "Child did not apply
the requested heap limit: " + maxBytes);
+ } finally {
+ if (process != null && process.isAlive()) {
+ process.destroyForcibly();
+ process.waitFor(10, TimeUnit.SECONDS);
+ }
+ FileUtils.deleteDirectory(processDir.toFile());
+ }
+ }
+
+ private static List<String> buildProbeCommand(Path processDir, Path
reportFile, String initialHeap, String maxHeap) {
+ String previousInitialHeap =
System.getProperty(QuickstartServerProcess.INITIAL_HEAP_PROPERTY);
+ String previousMaxHeap =
System.getProperty(QuickstartServerProcess.MAX_HEAP_PROPERTY);
+ List<String> command;
+ try {
+ setProperty(QuickstartServerProcess.INITIAL_HEAP_PROPERTY, initialHeap);
+ setProperty(QuickstartServerProcess.MAX_HEAP_PROPERTY, maxHeap);
+ command = new
ArrayList<>(QuickstartServerProcess.buildCommand(processDir.resolve("server.properties").toFile(),
+ processDir.toFile()));
+ } finally {
+ setProperty(QuickstartServerProcess.INITIAL_HEAP_PROPERTY,
previousInitialHeap);
+ setProperty(QuickstartServerProcess.MAX_HEAP_PROPERTY, previousMaxHeap);
+ }
+ command.set(command.indexOf("-cp") + 1,
+ System.getProperty("surefire.test.class.path",
System.getProperty("java.class.path")));
+ int mainIndex = command.indexOf(QuickstartServerProcess.class.getName());
+ command.subList(mainIndex, command.size()).clear();
+ command.addAll(List.of(HeapProbe.class.getName(), reportFile.toString()));
+ return command;
+ }
+
+ private static void setProperty(String name, String value) {
+ if (value == null) {
+ System.clearProperty(name);
+ } else {
+ System.setProperty(name, value);
+ }
+ }
+
+ /// Reports this JVM's heap settings and exits. The probe only runs on its
main thread.
+ public static final class HeapProbe {
+ private HeapProbe() {
+ }
+
+ public static void main(String[] args)
+ throws Exception {
+ Properties report = new Properties();
+ report.setProperty("arguments", String.join("\n",
ManagementFactory.getRuntimeMXBean().getInputArguments()));
+ report.setProperty("maxMemory",
Long.toString(Runtime.getRuntime().maxMemory()));
+ try (OutputStream output = Files.newOutputStream(Path.of(args[0]))) {
+ report.store(output, null);
+ }
+ }
+ }
+}
diff --git
a/pinot-tools/src/test/java/org/apache/pinot/tools/admin/command/QuickstartServerProcessTest.java
b/pinot-tools/src/test/java/org/apache/pinot/tools/admin/command/QuickstartServerProcessTest.java
new file mode 100644
index 00000000000..203d8e2b0c4
--- /dev/null
+++
b/pinot-tools/src/test/java/org/apache/pinot/tools/admin/command/QuickstartServerProcessTest.java
@@ -0,0 +1,306 @@
+/**
+ * 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.pinot.tools.admin.command;
+
+import com.sun.net.httpserver.HttpServer;
+import java.io.File;
+import java.io.IOException;
+import java.lang.management.ManagementFactory;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import org.apache.commons.io.FileUtils;
+import org.apache.pinot.spi.env.PinotConfiguration;
+import org.apache.pinot.tools.utils.PinotConfigUtils;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNotEquals;
+import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.expectThrows;
+
+
+/// Exercises child JVM readiness and cleanup without starting a Pinot
cluster. Test methods run sequentially because
+/// they share the per-method temporary directory; the HTTP child handles
requests on its own executor.
+public class QuickstartServerProcessTest {
+ private Path _processDir;
+ private Path _pidFile;
+
+ @BeforeMethod
+ public void setUp()
+ throws Exception {
+ _processDir = Files.createTempDirectory("quickstart-server-process-test");
+ _pidFile = _processDir.resolve("child.pid");
+ }
+
+ @AfterMethod(alwaysRun = true)
+ public void tearDown()
+ throws Exception {
+ // Also clean up a child if an assertion fails before the helper can stop
it.
+ if (Files.exists(_pidFile)) {
+ ProcessHandle process = ProcessHandle.of(readPid()).orElse(null);
+ if (process != null && process.isAlive()) {
+ process.destroyForcibly();
+ process.onExit().get(10, TimeUnit.SECONDS);
+ }
+ }
+ FileUtils.deleteDirectory(_processDir.toFile());
+ }
+
+ @Test(timeOut = 30_000)
+ public void testReadyChildAndRepeatedStop()
+ throws Exception {
+ int port = availablePort();
+ QuickstartServerProcess server = startServer("ready", port,
Duration.ofSeconds(15));
+ try {
+ assertNotEquals(readPid(), ProcessHandle.current().pid());
+ assertEquals(server.getProcess().pid(), readPid());
+ assertTrue(server.getProcess().isAlive());
+ assertTrue(Files.exists(_processDir.resolve("health-checked")));
+ List<String> arguments =
Files.readAllLines(_processDir.resolve("jvm-arguments"));
+ assertEquals(arguments.stream().filter(argument ->
argument.startsWith("-Xmx")).toList(), List.of("-Xmx1g"));
+ assertFalse(arguments.stream().anyMatch(argument ->
argument.startsWith("-agentlib:")
+ || argument.startsWith("-javaagent:")));
+ } finally {
+ server.stop();
+ }
+ server.stop();
+ assertFalse(server.getProcess().isAlive());
+ }
+
+ @Test(timeOut = 30_000)
+ public void testEarlyExitIsReported()
+ throws Exception {
+ int port = availablePort();
+ IOException failure = expectThrows(IOException.class,
+ () -> startServer("exit", port, Duration.ofSeconds(15)));
+ assertTrue(failure.getMessage().contains("23"), failure.getMessage());
+ assertTrue(failure.getMessage().contains(_processDir.toString()),
failure.getMessage());
+ assertFalse(isChildAlive());
+ }
+
+ @Test(timeOut = 30_000)
+ public void testReadinessTimeoutStopsChild()
+ throws Exception {
+ int port = availablePort();
+ expectThrows(IOException.class,
+ () -> startServer("unready", port, Duration.ofSeconds(5)));
+ assertTrue(Files.exists(_processDir.resolve("health-checked")), "The child
must run but remain unready");
+ assertFalse(isChildAlive(), "A failed startup must not leave its child
running");
+ }
+
+ @Test(timeOut = 30_000)
+ public void testExistingHealthyListenerDoesNotMakeChildReady()
+ throws Exception {
+ HttpServer existingServer = HttpServer.create(new
InetSocketAddress("127.0.0.1", 0), 0);
+ existingServer.createContext("/health/readiness", exchange -> {
+ byte[] body = "OK".getBytes(StandardCharsets.UTF_8);
+ exchange.sendResponseHeaders(200, body.length);
+ exchange.getResponseBody().write(body);
+ exchange.close();
+ });
+ existingServer.start();
+ try {
+ int port = existingServer.getAddress().getPort();
+ // A marker from a previous run must not be accepted either.
+
Files.writeString(_processDir.resolve(QuickstartServerProcess.STARTED_FILE_NAME),
"stale");
+ IOException failure = expectThrows(IOException.class,
+ () -> startServer("no-marker", port, Duration.ofSeconds(5)));
+ assertTrue(failure.getMessage().contains("Timed out"),
failure.getMessage());
+ assertFalse(isChildAlive(), "A child that did not start its own server
must be stopped");
+ } finally {
+ existingServer.stop(0);
+ }
+ }
+
+ @Test(timeOut = 30_000)
+ public void testParentPipeClosureStopsChild()
+ throws Exception {
+ int port = availablePort();
+ QuickstartServerProcess server = startServer("watch-parent", port,
Duration.ofSeconds(15));
+ try {
+ server.getProcess().getOutputStream().close();
+ assertTrue(server.getProcess().waitFor(10, TimeUnit.SECONDS), "The child
must exit when its parent disappears");
+ assertEquals(server.getProcess().exitValue(), 0);
+ } finally {
+ server.stop();
+ }
+ }
+
+ @DataProvider
+ public Object[][] shutdownStates() {
+ return new Object[][]{{false}, {true}};
+ }
+
+ @Test(timeOut = 30_000)
+ public void testNormalStopPreservesGracefulShutdownDeadline()
+ throws Exception {
+ int port = availablePort();
+ QuickstartServerProcess server = startServer("watch-parent-slow-shutdown",
port, Duration.ofSeconds(15));
+ try {
+ server.stop();
+ assertTrue(Files.exists(_processDir.resolve("shutdown-completed")),
+ "Normal stop must allow hooks longer than the parent-loss deadline
to finish");
+ } finally {
+ server.getProcess().destroyForcibly();
+ assertTrue(server.getProcess().waitFor(10, TimeUnit.SECONDS));
+ }
+ }
+
+ @Test(dataProvider = "shutdownStates", timeOut = 40_000)
+ public void testParentPipeClosureStopsChildWithBlockingShutdownHook(boolean
shutdownAlreadyStarted)
+ throws Exception {
+ int port = availablePort();
+ QuickstartServerProcess server =
startServer("watch-parent-blocking-shutdown", port, Duration.ofSeconds(15));
+ try {
+ if (shutdownAlreadyStarted) {
+ server.getProcess().toHandle().destroy();
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+ while (!Files.exists(_processDir.resolve("shutdown-started")) &&
System.nanoTime() < deadline) {
+ Thread.sleep(20);
+ }
+ assertTrue(Files.exists(_processDir.resolve("shutdown-started")),
"Shutdown hook must have started before EOF");
+ assertTrue(server.getProcess().isAlive(), "The shutdown hook must
still be blocked");
+ }
+ server.getProcess().getOutputStream().close();
+ assertTrue(server.getProcess().waitFor(10, TimeUnit.SECONDS),
+ "A blocking shutdown hook must not leave the child alive after its
parent disappears");
+ assertTrue(Files.exists(_processDir.resolve("shutdown-started")),
"Graceful shutdown must be attempted first");
+ assertEquals(server.getProcess().exitValue(), 0);
+ } finally {
+ // Force cleanup on assertion failure: the deliberately blocked hook
cannot finish a graceful stop.
+ server.getProcess().destroyForcibly();
+ assertTrue(server.getProcess().waitFor(10, TimeUnit.SECONDS));
+ }
+ }
+
+ @Test
+ public void testConfigRoundTrip()
+ throws Exception {
+ File configFile = _processDir.resolve("server.properties").toFile();
+ String directory = _processDir.resolve("data with
spaces=and-unicode-\u00e9").toString();
+ Map<String, Object> properties = Map.of("pinot.server.instance.dataDir",
directory,
+ "pinot.server.adminapi.port", 7500,
"pinot.server.instance.enableThreadCpuTimeMeasurement", true,
+ "test.list", List.of("first", "second"), "test.literal",
"comma,value=with:punctuation");
+ QuickstartServerProcess.writeConfig(properties, configFile);
+ PinotConfiguration loaded = new
PinotConfiguration(PinotConfigUtils.readConfigFromFile(configFile.toString()));
+ assertEquals(loaded.getProperty("pinot.server.instance.dataDir"),
directory);
+ assertEquals(loaded.getProperty("pinot.server.adminapi.port", 0), 7500);
+
assertTrue(loaded.getProperty("pinot.server.instance.enableThreadCpuTimeMeasurement",
false));
+ assertEquals(loaded.getCommaSeparatedList("test.list", List.of()),
List.of("first", "second"));
+ assertEquals(loaded.getProperty("test.literal"),
properties.get("test.literal"));
+ }
+
+ private QuickstartServerProcess startServer(String mode, int port, Duration
timeout)
+ throws Exception {
+ QuickstartServerProcess server =
+ QuickstartServerProcess.launch(childCommand(mode, port),
_processDir.toFile(), port);
+ server.awaitReady(timeout);
+ return server;
+ }
+
+ private List<String> childCommand(String mode, int port) {
+ List<String> command = new
ArrayList<>(QuickstartServerProcess.buildCommand(
+ _processDir.resolve("server.properties").toFile(),
_processDir.toFile()));
+ command.set(command.indexOf("-cp") + 1,
+ System.getProperty("surefire.test.class.path",
System.getProperty("java.class.path")));
+ int mainIndex = command.indexOf(QuickstartServerProcess.class.getName());
+ command.subList(mainIndex, command.size()).clear();
+ command.addAll(List.of(TestServer.class.getName(), mode,
Integer.toString(port), _pidFile.toString()));
+ return command;
+ }
+
+ private static int availablePort()
+ throws Exception {
+ try (ServerSocket socket = new ServerSocket(0)) {
+ return socket.getLocalPort();
+ }
+ }
+
+ private long readPid()
+ throws Exception {
+ return Long.parseLong(Files.readString(_pidFile));
+ }
+
+ private boolean isChildAlive()
+ throws Exception {
+ return
ProcessHandle.of(readPid()).map(ProcessHandle::isAlive).orElse(false);
+ }
+
+ /// Minimal child process used by these tests. Its mode and paths are
immutable after startup; HTTP requests only
+ /// write an idempotent readiness marker.
+ public static final class TestServer {
+ private TestServer() {
+ }
+
+ public static void main(String[] args)
+ throws Exception {
+ String mode = args[0];
+ Path pidFile = Path.of(args[2]);
+ if (mode.equals("watch-parent-blocking-shutdown") ||
mode.equals("watch-parent-slow-shutdown")) {
+ Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+ try {
+ Files.writeString(pidFile.resolveSibling("shutdown-started"),
"started");
+ if (mode.equals("watch-parent-blocking-shutdown")) {
+ new CountDownLatch(1).await();
+ } else {
+ TimeUnit.SECONDS.sleep(7);
+ Files.writeString(pidFile.resolveSibling("shutdown-completed"),
"completed");
+ }
+ } catch (IOException | InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ }));
+ }
+ if (mode.startsWith("watch-parent")) {
+ QuickstartServerProcess.watchParent();
+ }
+ Files.writeString(pidFile, Long.toString(ProcessHandle.current().pid()));
+ Files.write(pidFile.resolveSibling("jvm-arguments"),
ManagementFactory.getRuntimeMXBean().getInputArguments());
+ if (mode.equals("exit")) {
+ System.exit(23);
+ }
+ if (mode.equals("no-marker")) {
+ new CountDownLatch(1).await();
+ }
+ HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1",
Integer.parseInt(args[1])), 0);
+ server.createContext("/health/readiness", exchange -> {
+ Files.writeString(pidFile.resolveSibling("health-checked"), "checked");
+ byte[] body = "OK".getBytes(StandardCharsets.UTF_8);
+ exchange.sendResponseHeaders(mode.equals("unready") ? 503 : 200,
body.length);
+ exchange.getResponseBody().write(body);
+ exchange.close();
+ });
+ server.start();
+
Files.writeString(pidFile.resolveSibling(QuickstartServerProcess.STARTED_FILE_NAME),
"started");
+ }
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]