Copilot commented on code in PR #8517:
URL: https://github.com/apache/hbase/pull/8517#discussion_r3918687423
##########
dev-support/read-replica/cluster1/conf/zoo.cfg:
##########
@@ -0,0 +1,3 @@
+clientPort=2181
+clientPortAddress=hbase-docker
+server.1=hbase-docker:2181
Review Comment:
ZooKeeper 3.8 expects `server.<id>=host:quorumPort:electionPort`; this
two-field value is malformed and `QuorumPeerConfig.parseProperties` will reject
it before HBase can start. Use separate quorum and election ports (the standard
defaults are 2888 and 3888).
##########
dev-support/read-replica/cluster2/conf/zoo.cfg:
##########
@@ -0,0 +1,3 @@
+clientPort=2181
+clientPortAddress=hbase-docker-2
+server.1=hbase-docker-2:2181
Review Comment:
ZooKeeper 3.8 expects `server.<id>=host:quorumPort:electionPort`; this
two-field value is malformed and `QuorumPeerConfig.parseProperties` will reject
it before HBase can start. Use separate quorum and election ports (the standard
defaults are 2888 and 3888).
##########
dev-support/read-replica/Dockerfile:
##########
@@ -0,0 +1,131 @@
+# Stage 0: Cache Maven dependencies
+ARG BASE_IMAGE=registry.access.redhat.com/ubi8/openjdk-17:1.23
+FROM ${BASE_IMAGE} AS cache-stage
+
+# Switch to user root (UID 0)
+# hadolint ignore=DL3002
+USER 0
+
+# Install necessary packages for building Maven dependencies
+# hadolint ignore=DL3041
+RUN microdnf update -y && microdnf install -y \
+ maven \
+ git \
+ hostname \
+ diffutils \
+ && microdnf clean all
+
+# Copy the entire source code to cache dependencies
+COPY ./hbase /opt/hbase-src
+
+WORKDIR /opt/hbase-src
+
+# Download and cache all dependencies
+RUN mvn clean install -DskipTests -Dskip.license.check=true
+
+# Stage 1: Build the HBase source code
+FROM ${BASE_IMAGE} AS build-stage
+
+# Switch to user root (UID 0)
+# hadolint ignore=DL3002
+USER 0
+
+# Install necessary build packages
+# hadolint ignore=DL3041
+RUN microdnf update -y && microdnf install -y \
+ maven \
+ git \
+ hostname \
+ diffutils \
+ && microdnf clean all
+
+# Copy the cached Maven dependencies
+COPY --from=cache-stage /root/.m2 /root/.m2
+
+# Copy the HBase source code
+COPY ./hbase /opt/hbase-src
+
+WORKDIR /opt/hbase-src
+
+# Build HBase source code using cached dependencies and enable parallel build
+RUN mvn clean package -DskipTests -Dskip.license.check=true assembly:single -T
1C
+
+# Stage 2: Create the final Docker image
+FROM ${BASE_IMAGE}
+
+# Switch to user root (UID 0)
+USER 0
+
+# Set environment variables
+ENV HBASE_HOME=/opt/hbase
+ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk \
+ HBASE_USER=hbase \
+ HBASE_CONF_DIR=${HBASE_HOME}/conf \
+ HBASE_LIB_DIR=${HBASE_HOME}/lib \
+ HBASE_LOGS_DIR=${HBASE_HOME}/logs \
+ DATA_DIR=/data-store
+
+# Install necessary runtime packages
+# hadolint ignore=DL3041
+RUN microdnf update -y && microdnf install -y \
+ unzip \
+ gzip \
+ wget \
+ hostname \
+ maven \
+ git \
+ diffutils \
+ vim \
+ openssh-clients \
+ python3 \
+ procps \
+ && microdnf clean all
+
+# Copy the built HBase binaries from the build-stage
+COPY --from=build-stage
/opt/hbase-src/hbase-assembly/target/hbase-4.0.0-alpha-1-SNAPSHOT-bin.tar.gz
/opt/
+
+# Extract HBase binaries
+RUN tar -xzf /opt/hbase-4.0.0-alpha-1-SNAPSHOT-bin.tar.gz -C /opt \
+ && ln -s /opt/hbase-4.0.0-alpha-1-SNAPSHOT /opt/hbase \
+ && rm /opt/hbase-4.0.0-alpha-1-SNAPSHOT-bin.tar.gz
Review Comment:
This hard-coded master version contradicts the Jenkins stage's `branch-3`
condition: branch-3 currently has revision `3.1.0-SNAPSHOT`, while the assembly
name is derived from `${project.version}` (`hbase-assembly/pom.xml:35`). That
build cannot find this source archive, and the nightly will also break at the
next master version bump. Select the non-client assembly archive without
embedding a version.
##########
dev-support/hbase_nightly_read_replica_test.sh:
##########
@@ -0,0 +1,117 @@
+#!/usr/bin/env bash
+# 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.
+#
+# Run the read-replica Docker integration test suite.
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPLICA_DIR="${SCRIPT_DIR}/read-replica"
+export HBASE_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
+
+export HBASE_IMAGE="hbase-read-replica:${BUILD_NUMBER:-local}"
+
+echo "Script dir: ${SCRIPT_DIR}"
+echo "Replica dir: ${REPLICA_DIR}"
+echo "HBase root: ${HBASE_ROOT}"
+
+echo "Changing to replica dir: REPLICA_DIR"
+cd "${REPLICA_DIR}"
+
+echo "Sourcing environment file: $(pwd)/.env"
+set -a
+source .env
+set +a
+
+echo "HBASE_IMAGE=${HBASE_IMAGE}"
+echo "ACTIVE_CLUSTER_CONF_DIR=${ACTIVE_CLUSTER_CONF_DIR}"
+echo "REPLICA_CLUSTER_CONF_DIR=${REPLICA_CLUSTER_CONF_DIR}"
+echo "DOCKER_COMPOSE_FILE=${DOCKER_COMPOSE_FILE}"
+echo "HBASE_DATA_STORE_ROOT=${HBASE_DATA_STORE_ROOT}"
+echo "realpath of HBASE_DATA_STORE_ROOT=$(realpath ${HBASE_DATA_STORE_ROOT})"
+
+echo "Removing HBase log directories from mounted volumes that may exist from
a previous test run:"
+echo "ACTIVE_CLUSTER_LOGS_DIR=${ACTIVE_CLUSTER_LOGS_DIR}"
+echo "REPLICA_CLUSTER_LOGS_DIR=${REPLICA_CLUSTER_LOGS_DIR}"
+rm -rf ${ACTIVE_CLUSTER_LOGS_DIR} ${REPLICA_CLUSTER_LOGS_DIR}
Review Comment:
These directories are immediately used as bind-mount sources, but after
deletion a rootful Docker daemon recreates missing host directories as
root-owned and non-writable by the image's UID 1000. HBase can then fail to
create its log files and the containers exit. Recreate the directories with
writable permissions before `docker compose up`.
##########
dev-support/read-replica/python/scripts/test_dual_active_cluster_startup.py:
##########
@@ -0,0 +1,140 @@
+#!/usr/bin/env python3
+"""
+Verifies that two clusters cannot both start with read-only mode disabled
(both as active clusters)
+on the same shared data store. One cluster must fail to start, with the
HMaster process not
+running, and an error logged to the master log.
+
+Usage: python3 ./python/scripts/test_dual_active_cluster_startup.py
+"""
+import argparse
+import os
+import time
+
+from python.src.environment_loader import get_env
+from python.src.hbase_docker_client import HBaseDockerClient
+from python.src.logger_config import get_logger
+from python.src.utils import load_env_and_set_up_clients, log_script_start,
log_script_end
+
+logger = get_logger(__name__)
+
+STARTUP_WAIT_SECONDS = 60
+EXPECTED_ERROR_MSG = "Another cluster is running in active (read-write) mode
on this storage location"
+CLUSTER1_SERVICE_NAME = "hbase"
+CLUSTER2_SERVICE_NAME = "hbase2"
+
+
+def is_process_running(cluster: HBaseDockerClient, process_name: str) -> bool:
+ output = cluster.run_docker_exec_command("jps")
+ return process_name in output
+
+
+def check_cluster_processes(cluster: HBaseDockerClient) -> bool:
+ hmaster_running = is_process_running(cluster, "HMaster")
+ logger.info(f" {cluster.name}: HMaster={'running' if hmaster_running else
'down'}")
+ return hmaster_running
+
+
+def assert_error_in_master_log(cluster: HBaseDockerClient):
+ logger.info(f"Checking {cluster.name} master log for expected error
message")
+ log_output = cluster.run_docker_exec_command(
+ "cat /opt/hbase/logs/hbase-*-master-*.log || true"
Review Comment:
This reads every master log retained in the mounted directory across all
four iterations. When a cluster is the second starter again, an expected error
from its earlier iteration remains in this output, so an unrelated current
startup crash can satisfy the assertion. Restrict the check to bytes/log files
produced after the current start attempt, or clear that stopped cluster's logs
before starting it.
##########
dev-support/read-replica/python/src/hbase_docker_client.py:
##########
@@ -0,0 +1,623 @@
+#!/usr/bin/env python3
+import ast
+import logging
+import re
+from concurrent.futures import ThreadPoolExecutor, TimeoutError as
FuturesTimeoutError
+
+import docker
+import requests
+import subprocess
+import time
+import xml.etree.ElementTree as ET
+
+from .logger_config import get_logger
+
+logger = get_logger(__name__)
+
+
+class DockerExecCommandError(Exception):
+ pass
+
+
+class HBaseShellCommandError(DockerExecCommandError):
+ pass
+
+
+class DockerExecCommandTimeoutError(DockerExecCommandError):
+ pass
+
+
+class HBaseDockerClient:
+ def __init__(self, container_name: str, local_conf: str, hbase_ui_port:
int = 16010,
+ cluster_name: str = "HBase Cluster", max_retries: int = 12,
sleep_time: int = 5,
+ hbase_host: str = "localhost") -> None:
+ self._container_name = container_name
+ self._local_conf = local_conf
+ self._hbase_ui_port = hbase_ui_port
+ self._cluster_name = cluster_name
+ self._max_retries = max_retries
+ self._sleep_time = sleep_time
+ self._hbase_host = hbase_host
+ self._docker_client = docker.from_env()
+
+ @property
+ def name(self) -> str:
+ return self._cluster_name
+
+ def run_docker_exec_command(self, bash_cmd: str, timeout: int | None =
None) -> str:
+ """
+ Uses the Docker SDK to exec a Bash command in the object's Docker
container.
+ Equivalent to: docker exec <container> bash -c <bash_cmd>
+ """
+ cmd = ["bash", "-c", bash_cmd]
+ cmd_str = f"docker exec {self._container_name} bash -c {bash_cmd}"
+ logger.debug(f"Running command on {self._cluster_name}: {cmd_str}")
+
+ try:
+ container =
self._docker_client.containers.get(self._container_name)
+
+ if timeout is not None:
+ with ThreadPoolExecutor(max_workers=1) as pool:
+ future = pool.submit(container.exec_run, cmd, demux=True)
+ try:
+ result = future.result(timeout=timeout)
+ except FuturesTimeoutError:
+ raise DockerExecCommandTimeoutError(
+ f"Command timed out after {timeout}s on
{self._cluster_name} "
+ f"({self._container_name}): {bash_cmd}\n"
+ f"The command used to run this was: {cmd_str}\n"
+ )
+ else:
+ result = container.exec_run(cmd, demux=True)
+ except DockerExecCommandError:
+ raise
+ except docker.errors.DockerException as e:
+ raise DockerExecCommandError(
+ f"The following command failed on {self._cluster_name}
({self._container_name}): {bash_cmd}\n"
+ f"The command used to run this was: {cmd_str}\n"
+ f"Docker error: {e}\n"
+ )
+
+ exit_code, (stdout, stderr) = result
+ stdout_str = (stdout or b'').decode('utf-8')
+ if exit_code != 0:
+ raise DockerExecCommandError(
+ f"The following command failed on {self._cluster_name}
({self._container_name}): {bash_cmd}\n"
+ f"The command used to run this was: {cmd_str}\n"
+ f"The command's STDERR was:\n{(stderr or
b'').decode('utf-8')}\n"
+ f"The command's STDOUT was:\n{stdout_str}\n"
+ )
+ return stdout_str
+
+ def run_hbase_shell_command(self, hbase_cmd: str, timeout: int | None =
None) -> str:
+ """
+ Uses 'docker exec' to run the provided HBase shell command in the
object's Docker container.
+ The command looks like: docker exec <container> bash -c hbase shell -n
<<< "<hbase_cmd>"
+ """
+ hbase_shell_cmd = f'''hbase shell -n <<< "{hbase_cmd}"'''
+ try:
+ return self.run_docker_exec_command(hbase_shell_cmd,
timeout=timeout)
+ except DockerExecCommandTimeoutError:
+ # DockerExecCommandTimeoutError is a subclass of
DockerExecCommandError, so we need to make sure
+ # it's specifically caught and re-raised. Otherwise, it's
swallowed when catching DockerExecCommandError
+ raise
+ except DockerExecCommandError as e:
+ raise HBaseShellCommandError(e)
+
+ def _get_pid_from_jps(self, process_name: str) -> int | None:
+ """Runs jps inside the container and returns the PID of the named
process, or None."""
+ try:
+ output = self.run_docker_exec_command("jps")
+ for line in output.strip().splitlines():
+ parts = line.split()
+ if len(parts) == 2 and parts[1] == process_name:
+ return int(parts[0])
+ except DockerExecCommandError:
+ pass
+ return None
+
+ def wait_for_hbase_ui(self) -> bool:
+ """Checks for a 200 OK on the HBase Master UI."""
+ # Read HBASE_HOST from environment, falling back to 'localhost' for
host-native execution
+ url = f"http://{self._hbase_host}:{self._hbase_ui_port}"
+ logger.info(f"Waiting for HBase UI: {self._cluster_name} on {url}")
+ last_exception = None
+ for attempt in range(1, self._max_retries + 1):
+ try:
+ response = requests.get(url)
+ if response.status_code == 200:
+ logger.info(f"SUCCESS: {self._cluster_name} UI is up.")
+ return True
+ except requests.exceptions.ConnectionError as e:
+ last_exception = e
+ logging.info(f"Waiting {self._sleep_time} seconds before
requesting HBase UI again")
+ time.sleep(self._sleep_time)
+
+ raise RuntimeError(f"\nTIMEOUT: {self._cluster_name} UI failed to
respond after "
+ f"{self._max_retries} attempts. "
+ f"Last raised exception was: {last_exception}")
+
+ def wait_for_master_initialization(self) -> bool:
+ """Waits for the current HMaster process to log 'Master has completed
initialization'."""
+ logger.info(f"Waiting for Master initialization: {self._cluster_name}
({self._container_name})")
+ for attempt in range(1, self._max_retries + 1):
+ pid = self._get_pid_from_jps("HMaster")
+ if pid is not None:
+ awk_cmd = (
+ f"awk '/env:JVM_PID={pid}/{{seen=1; found=0}} "
+ f"seen && /Master has completed initialization/{{found=1}}
"
+ f"END{{exit !found}}' /opt/hbase/logs/hbase-*-master-*.log"
+ )
+ try:
+ self.run_docker_exec_command(awk_cmd)
+ logger.info(f"SUCCESS: {self._cluster_name} Master has
completed initialization.")
+ return True
+ except DockerExecCommandError:
+ pass
+ logging.info(f"Waiting {self._sleep_time} seconds before checking
Master initialization again")
+ time.sleep(self._sleep_time)
+
+ raise RuntimeError(
+ f"\nTIMEOUT: {self._cluster_name} Master failed to initialize
after "
+ f"{self._max_retries} attempts.")
+
+ def wait_for_region_server_initialization(self) -> bool:
+ """Waits for the current HRegionServer process to log 'Serving as'
message."""
+ logger.info(f"Waiting for RegionServer initialization:
{self._cluster_name} ({self._container_name})")
+ for attempt in range(1, self._max_retries + 1):
+ pid = self._get_pid_from_jps("HRegionServer")
+ if pid is not None:
+ awk_cmd = (
+ f"awk '/env:JVM_PID={pid}/{{seen=1; found=0}} "
+ f"seen && /Serving as {self._container_name},/{{found=1}} "
+ f"END{{exit !found}}'
/opt/hbase/logs/hbase-*-regionserver-*.log"
+ )
+ try:
+ self.run_docker_exec_command(awk_cmd)
+ logger.info(f"SUCCESS: {self._cluster_name} RegionServer
is serving.")
+ return True
+ except DockerExecCommandError:
+ pass
+ logging.info(f"Waiting {self._sleep_time} seconds before checking
RegionServer initialization again")
+ time.sleep(self._sleep_time)
+
+ raise RuntimeError(
+ f"\nTIMEOUT: {self._cluster_name} RegionServer failed to
initialize after "
+ f"{self._max_retries} attempts.")
+
+ def check_server_status(self, desired_status: dict | None = None) -> bool:
+ """Runs 'status' inside the HBase shell and validates the output."""
+ if desired_status is None:
+ desired_status = {'masters': '1', 'region_servers': '1',
'dead_servers': '0'}
+ logger.info(f"Validating Cluster Status: {self._cluster_name}
({self._container_name})")
+ for attempt in range(1, self._max_retries + 1):
+ try:
+ output = self.get_hbase_status()
+
+ # The cluster's status should have 1 active master, 1 region
server,
+ # and no dead servers
+ validations = {
+ "Active Master": f"{desired_status['masters']} active
master" in output,
+ "Region Server": f"{desired_status['region_servers']}
servers" in output,
+ "No Dead Servers": f"{desired_status['dead_servers']}
dead" in output
+ }
+
+ if all(validations.values()):
+ for check, status in validations.items():
+ logger.info(f" [PASS] {check}")
+ logger.info(f"SUCCESS: {self._cluster_name} is fully
operational.")
+ return True
+ else:
+ logger.warning(f"{self._cluster_name} is responding, but
not all "
+ f"components are ready...")
+ logger.info(f"HBase 'status' command output:\n{output}")
+
+ except HBaseShellCommandError:
+ pass
+
+ logging.info(f"Waiting {self._sleep_time} seconds before getting
status on {self.name} again")
+ time.sleep(self._sleep_time)
+
+ raise RuntimeError(
+ f"\nTIMEOUT: {self._cluster_name} shell check failed after
{self._max_retries} attempts.")
+
+ def get_hbase_status(self) -> str:
+ logger.debug(f"Getting status of {self.name}")
+ return self.run_hbase_shell_command("status")
+
+ def wait_for_cluster_to_start(self) -> None:
+ """curls the cluster's HBase UI to make sure it is up and then makes
sure all desired servers are up"""
+ self.wait_for_hbase_ui()
+ self.wait_for_master_initialization()
+ self.wait_for_region_server_initialization()
+ self.check_server_status()
+
+ def create_table(self, table_name: str, column_family: str) -> bool:
+ logger.info(f"Creating table '{table_name}' on {self._cluster_name}")
+ create_cmd = f"create '{table_name}', '{column_family}'"
+ output = self.run_hbase_shell_command(create_cmd)
+
+ if f"Created table {table_name}" not in output:
+ logger.error(f"Could not create table '{table_name}' on
{self._cluster_name}")
+ return False
+ return True
+
+ def disable_table(self, table_name: str) -> None:
+ logger.debug(f"Disabling table '{table_name}' on {self.name}")
+ self.run_hbase_shell_command(f"disable '{table_name}'")
+
+ def drop_table(self, table_name: str) -> None:
+ logger.info(f"Dropping table '{table_name}' on {self.name}")
+ self.run_hbase_shell_command(f"drop '{table_name}'")
+
+ def list_tables(self) -> list:
+ """Gets the list of HBase tables and returns it as a Python list"""
+ logger.debug(f"Getting the list of tables in HBase on {self.name}")
+ pattern = r'\[(.*?)\]'
+ output = self.run_hbase_shell_command("list")
+ output = output.replace('\n', ' ')
+ match = re.search(pattern, output)
+ return ast.literal_eval(match.group(0))
+
+ def list_regions(self, table_name: str) -> str:
+ """Gets list of regions and their info for the provided table"""
+ logger.info(f"Getting list of regions for table '{table_name}'")
+ return self.run_hbase_shell_command(f"list_regions '{table_name}'")
+
+ def put(self, table_name: str, row: str, column: str, data: str, spec_map:
str | None = None) -> None:
+ """
+ Performs an HBase put command.
+ :param table_name: the table we are inserting data into
+ :param row: row of the table we are inserting data into
+ :param column: column of the table we are inserting data into
+ :param data: the actual data we are inserting (as a string)
+ :param spec_map: additional attributes input as a string
+ (e.g. "{ATTRIBUTES=>{'my-key'=>'my-value'}}")
+ """
+ logger.info(f"Adding data to table '{table_name}' on {self.name}")
+ put_cmd = f"put '{table_name}', '{row}', '{column}', '{data}'"
+ if spec_map:
+ put_cmd += f", {spec_map}"
+ self.run_hbase_shell_command(put_cmd)
+
+ def get(self, table_name: str, row: str, column: str | None = None,
spec_map: str | None = None) -> str:
+ logger.info(f"Getting data from table '{table_name}' on {self.name}")
+ get_cmd = f"get '{table_name}', '{row}'"
+ if column:
+ get_cmd += f", '{column}'"
+ if spec_map:
+ get_cmd += f", {spec_map}"
+ output = self.run_hbase_shell_command(get_cmd)
+ logger.debug(f"Got data:\n{output}")
+ return output
+
+ def delete(self, table_name: str, row: str, column: str, timestamp: int |
None = None,
+ spec_map: str | None = None) -> None:
+ logger.info(f"Deleting data from table '{table_name}' on {self.name}")
+ delete_cmd = f"delete '{table_name}', '{row}', '{column}'"
+ if timestamp:
+ delete_cmd += f", {table_name}"
Review Comment:
When a timestamp is supplied, this appends the table name instead of the
timestamp, producing an invalid HBase shell command such as `..., my_table`;
timestamp `0` is also silently omitted by the truthiness check. The optional
timestamp API therefore does not construct the requested delete.
##########
dev-support/read-replica/python/src/hbase_docker_client.py:
##########
@@ -0,0 +1,623 @@
+#!/usr/bin/env python3
+import ast
+import logging
+import re
+from concurrent.futures import ThreadPoolExecutor, TimeoutError as
FuturesTimeoutError
+
+import docker
+import requests
+import subprocess
+import time
+import xml.etree.ElementTree as ET
+
+from .logger_config import get_logger
+
+logger = get_logger(__name__)
+
+
+class DockerExecCommandError(Exception):
+ pass
+
+
+class HBaseShellCommandError(DockerExecCommandError):
+ pass
+
+
+class DockerExecCommandTimeoutError(DockerExecCommandError):
+ pass
+
+
+class HBaseDockerClient:
+ def __init__(self, container_name: str, local_conf: str, hbase_ui_port:
int = 16010,
+ cluster_name: str = "HBase Cluster", max_retries: int = 12,
sleep_time: int = 5,
+ hbase_host: str = "localhost") -> None:
+ self._container_name = container_name
+ self._local_conf = local_conf
+ self._hbase_ui_port = hbase_ui_port
+ self._cluster_name = cluster_name
+ self._max_retries = max_retries
+ self._sleep_time = sleep_time
+ self._hbase_host = hbase_host
+ self._docker_client = docker.from_env()
+
+ @property
+ def name(self) -> str:
+ return self._cluster_name
+
+ def run_docker_exec_command(self, bash_cmd: str, timeout: int | None =
None) -> str:
+ """
+ Uses the Docker SDK to exec a Bash command in the object's Docker
container.
+ Equivalent to: docker exec <container> bash -c <bash_cmd>
+ """
+ cmd = ["bash", "-c", bash_cmd]
+ cmd_str = f"docker exec {self._container_name} bash -c {bash_cmd}"
+ logger.debug(f"Running command on {self._cluster_name}: {cmd_str}")
+
+ try:
+ container =
self._docker_client.containers.get(self._container_name)
+
+ if timeout is not None:
+ with ThreadPoolExecutor(max_workers=1) as pool:
+ future = pool.submit(container.exec_run, cmd, demux=True)
+ try:
+ result = future.result(timeout=timeout)
+ except FuturesTimeoutError:
+ raise DockerExecCommandTimeoutError(
+ f"Command timed out after {timeout}s on
{self._cluster_name} "
+ f"({self._container_name}): {bash_cmd}\n"
+ f"The command used to run this was: {cmd_str}\n"
+ )
+ else:
+ result = container.exec_run(cmd, demux=True)
+ except DockerExecCommandError:
+ raise
+ except docker.errors.DockerException as e:
+ raise DockerExecCommandError(
+ f"The following command failed on {self._cluster_name}
({self._container_name}): {bash_cmd}\n"
+ f"The command used to run this was: {cmd_str}\n"
+ f"Docker error: {e}\n"
+ )
+
+ exit_code, (stdout, stderr) = result
+ stdout_str = (stdout or b'').decode('utf-8')
+ if exit_code != 0:
+ raise DockerExecCommandError(
+ f"The following command failed on {self._cluster_name}
({self._container_name}): {bash_cmd}\n"
+ f"The command used to run this was: {cmd_str}\n"
+ f"The command's STDERR was:\n{(stderr or
b'').decode('utf-8')}\n"
+ f"The command's STDOUT was:\n{stdout_str}\n"
+ )
+ return stdout_str
+
+ def run_hbase_shell_command(self, hbase_cmd: str, timeout: int | None =
None) -> str:
+ """
+ Uses 'docker exec' to run the provided HBase shell command in the
object's Docker container.
+ The command looks like: docker exec <container> bash -c hbase shell -n
<<< "<hbase_cmd>"
+ """
+ hbase_shell_cmd = f'''hbase shell -n <<< "{hbase_cmd}"'''
+ try:
+ return self.run_docker_exec_command(hbase_shell_cmd,
timeout=timeout)
+ except DockerExecCommandTimeoutError:
+ # DockerExecCommandTimeoutError is a subclass of
DockerExecCommandError, so we need to make sure
+ # it's specifically caught and re-raised. Otherwise, it's
swallowed when catching DockerExecCommandError
+ raise
+ except DockerExecCommandError as e:
+ raise HBaseShellCommandError(e)
+
+ def _get_pid_from_jps(self, process_name: str) -> int | None:
+ """Runs jps inside the container and returns the PID of the named
process, or None."""
+ try:
+ output = self.run_docker_exec_command("jps")
+ for line in output.strip().splitlines():
+ parts = line.split()
+ if len(parts) == 2 and parts[1] == process_name:
+ return int(parts[0])
+ except DockerExecCommandError:
+ pass
+ return None
+
+ def wait_for_hbase_ui(self) -> bool:
+ """Checks for a 200 OK on the HBase Master UI."""
+ # Read HBASE_HOST from environment, falling back to 'localhost' for
host-native execution
+ url = f"http://{self._hbase_host}:{self._hbase_ui_port}"
+ logger.info(f"Waiting for HBase UI: {self._cluster_name} on {url}")
+ last_exception = None
+ for attempt in range(1, self._max_retries + 1):
+ try:
+ response = requests.get(url)
+ if response.status_code == 200:
+ logger.info(f"SUCCESS: {self._cluster_name} UI is up.")
+ return True
+ except requests.exceptions.ConnectionError as e:
+ last_exception = e
Review Comment:
Each readiness attempt can block indefinitely because Requests has no
default timeout. If the UI accepts a connection but stalls, retries never
advance and this stage can consume the pipeline's global timeout. Give the
request a bounded timeout and handle all request failures as retryable here.
##########
dev-support/read-replica/python/src/hbase_docker_client.py:
##########
@@ -0,0 +1,623 @@
+#!/usr/bin/env python3
+import ast
+import logging
+import re
+from concurrent.futures import ThreadPoolExecutor, TimeoutError as
FuturesTimeoutError
+
+import docker
+import requests
+import subprocess
+import time
+import xml.etree.ElementTree as ET
+
+from .logger_config import get_logger
+
+logger = get_logger(__name__)
+
+
+class DockerExecCommandError(Exception):
+ pass
+
+
+class HBaseShellCommandError(DockerExecCommandError):
+ pass
+
+
+class DockerExecCommandTimeoutError(DockerExecCommandError):
+ pass
+
+
+class HBaseDockerClient:
+ def __init__(self, container_name: str, local_conf: str, hbase_ui_port:
int = 16010,
+ cluster_name: str = "HBase Cluster", max_retries: int = 12,
sleep_time: int = 5,
+ hbase_host: str = "localhost") -> None:
+ self._container_name = container_name
+ self._local_conf = local_conf
+ self._hbase_ui_port = hbase_ui_port
+ self._cluster_name = cluster_name
+ self._max_retries = max_retries
+ self._sleep_time = sleep_time
+ self._hbase_host = hbase_host
+ self._docker_client = docker.from_env()
+
+ @property
+ def name(self) -> str:
+ return self._cluster_name
+
+ def run_docker_exec_command(self, bash_cmd: str, timeout: int | None =
None) -> str:
+ """
+ Uses the Docker SDK to exec a Bash command in the object's Docker
container.
+ Equivalent to: docker exec <container> bash -c <bash_cmd>
+ """
+ cmd = ["bash", "-c", bash_cmd]
+ cmd_str = f"docker exec {self._container_name} bash -c {bash_cmd}"
+ logger.debug(f"Running command on {self._cluster_name}: {cmd_str}")
+
+ try:
+ container =
self._docker_client.containers.get(self._container_name)
+
+ if timeout is not None:
+ with ThreadPoolExecutor(max_workers=1) as pool:
+ future = pool.submit(container.exec_run, cmd, demux=True)
+ try:
+ result = future.result(timeout=timeout)
+ except FuturesTimeoutError:
+ raise DockerExecCommandTimeoutError(
+ f"Command timed out after {timeout}s on
{self._cluster_name} "
+ f"({self._container_name}): {bash_cmd}\n"
+ f"The command used to run this was: {cmd_str}\n"
+ )
Review Comment:
The timeout is not actually bounded: leaving this `with` block calls
`ThreadPoolExecutor.shutdown(wait=True)`, so after `future.result` times out
the code still waits for `exec_run` to finish before raising. This defeats the
60-second flush guard and can hang the nightly stage until the global 16-hour
timeout. Shut the executor down without waiting on the timeout path.
##########
dev-support/read-replica/Dockerfile:
##########
@@ -0,0 +1,131 @@
+# Stage 0: Cache Maven dependencies
+ARG BASE_IMAGE=registry.access.redhat.com/ubi8/openjdk-17:1.23
+FROM ${BASE_IMAGE} AS cache-stage
+
+# Switch to user root (UID 0)
+# hadolint ignore=DL3002
+USER 0
+
+# Install necessary packages for building Maven dependencies
+# hadolint ignore=DL3041
+RUN microdnf update -y && microdnf install -y \
+ maven \
+ git \
+ hostname \
+ diffutils \
+ && microdnf clean all
+
+# Copy the entire source code to cache dependencies
+COPY ./hbase /opt/hbase-src
Review Comment:
This “cache” layer is keyed on the entire HBase source tree, so every source
change invalidates it and reruns a full `mvn clean install`; the next stage
then performs another full Maven package build. For a nightly that normally
tests a new commit, this provides no dependency-cache benefit and roughly
doubles build work. Use a persistent BuildKit Maven cache in the build stage,
or copy only dependency descriptors before resolving dependencies.
##########
dev-support/read-replica/Dockerfile:
##########
@@ -0,0 +1,131 @@
+# Stage 0: Cache Maven dependencies
Review Comment:
This new file lacks the ASF license header, as do most substantive files
added under `dev-support/read-replica`. The root Apache RAT configuration scans
non-hidden files (`pom.xml:2084-2151`), so release/license checks will reject
these files. Add the standard ASF header to every new substantive Python,
shell, Docker, YAML, requirements, XML, and ZooKeeper configuration file; the
new log4j files and nightly wrapper show the expected header.
##########
dev-support/read-replica/.env:
##########
@@ -0,0 +1,33 @@
+# The name of the HBase Docker image
+HBASE_IMAGE=${HBASE_IMAGE:-kgeisz/hbase-docker:read-replica-jenkins}
+# The name of the HBase docker container
+HBASE_CONTAINER_NAME=hbase-docker
+# This is the host running the hbase-docker containers. Use localhost if the
containers
+# are running locally. If they are started by another container, such as a
Jenkins
+# container in a Docker-out-of-Docker setup, then try setting this to
host.docker.internal.
+HBASE_HOST=localhost
Review Comment:
The preceding comment says callers can set this for Docker-out-of-Docker,
but sourcing `.env` unconditionally overwrites any supplied `HBASE_HOST`. Such
a Jenkins agent will still probe its own localhost and fail readiness checks.
Preserve an existing environment override, as is already done for `HBASE_IMAGE`.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]