diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ededed192eb..909843307a5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -126,7 +126,14 @@ jobs:
export DOCKER_BUILDKIT=1
export COMPOSE_DOCKER_CLI_BUILD=1
docker compose -f docker-compose.atlas-base.yml build
- docker compose -f docker-compose.atlas.yml -f docker-compose.atlas-hadoop.yml -f docker-compose.atlas-hive.yml up -d --wait
+ if ! docker compose -f docker-compose.atlas.yml -f docker-compose.atlas-hadoop.yml -f docker-compose.atlas-hive.yml up -d --wait --wait-timeout 300; then
+ echo "=== compose up --wait failed; dumping service logs ==="
+ for container in atlas-hbase atlas-hadoop atlas-zk atlas-kafka atlas-db atlas-hive atlas; do
+ echo "--- logs: ${container} ---"
+ docker logs --tail 200 "${container}" 2>&1 || true
+ done
+ exit 1
+ fi
- name: Check status of containers and remove them
run: |
diff --git a/addons/trino-extractor/README.md b/addons/trino-extractor/README.md
new file mode 100644
index 00000000000..0f617232028
--- /dev/null
+++ b/addons/trino-extractor/README.md
@@ -0,0 +1,185 @@
+# Trino Extractor — integration tests
+
+## Live integration test (`TrinoExtractorIT`)
+
+`TrinoExtractorIT` runs `./bin/run-trino-extractor.sh` from a **standalone tarball** (same `lib/` layout as distro), not the Maven test classpath. It skips automatically when no live stack is configured.
+
+**CI / default `mvn verify`:** integration tests are **skipped** (`skipITs=true`). Enable them with the `trino-extractor-it` profile below.
+
+**Run against Atlas + Trino lab** (e.g. ranger-docker Trino on `:8080`, Atlas on `:21000`):
+
+```bash
+export ATLAS_REST_URL=http://localhost:21000
+export TRINO_JDBC_URL=jdbc:trino://localhost:8080/
+export ATLAS_USERNAME=admin
+export ATLAS_PASSWORD=atlasR0cks!
+export TRINO_EXTRACTOR_SCHEMA=hr
+export TRINO_EXTRACTOR_TABLE=trino_pii_lab
+export TRINO_EXTRACTOR_COLUMN=ssn
+
+mvn -pl addons/trino-extractor -Ptrino-extractor-it verify
+```
+
+The `-Ptrino-extractor-it` profile builds the tarball in `pre-integration-test`, then runs `TrinoExtractorIT`. Optional: `TRINO_EXTRACTOR_TARBALL=/path/to/apache-atlas-*-trino-extractor.tar.gz` to reuse a pre-built distro artifact.
+
+### Test coverage map
+
+| Original testcase | Test class | Method | Default run |
+|-------------------|------------|--------|-------------|
+| Invalid arguments | `TrinoExtractorIT` | `testInvalidArguments` | Live (Atlas up) |
+| Invalid cron expression | `TrinoExtractorIT` | `testInvalidCronExpression` | Live |
+| Valid catalog run | `TrinoExtractorIT` | `testValidRegisteredCatalogRun` | Live |
+| Instance creation | `TrinoExtractorIT` | `testInstanceCreation` | Live |
+| Catalog creation | `TrinoExtractorIT` | `testCatalogCreation` | Live |
+| Schema creation | `TrinoExtractorIT` | `testSchemaCreation` | Live |
+| Table creation | `TrinoExtractorIT` | `testTableCreation` | Live |
+| Without cron expression | `TrinoExtractorIT` | `testWithoutCronExpression` | Live |
+| Unregistered catalog via CLI `-c` | `TrinoExtractorIT` | `testUnregisteredCatalogViaCommandLine` | Live |
+| Hook entity linked | `TrinoExtractorIT` | `testHookEntityLinkedToTrinoColumn` | Skip unless `TRINO_IT_HOOK_ENABLED=1` |
+| Tag propagated | `TrinoExtractorIT` | `testTagPropagatedToTrinoColumn` | Skip unless `TRINO_IT_TAG_PROPAGATION=1` |
+| Deleted table / schema / catalog | `TrinoExtractorIT` | `testDeleted*` | Skip unless `TRINO_IT_DELETE_SYNC=1` |
+| Rename catalog / schema (stale cleanup) | `TrinoExtractorIT` | `testRename*` | Skip unless `TRINO_IT_DELETE_SYNC=1` |
+| Deleted column | `TrinoExtractorIT` | `testDeletedColumnNotSupportedYet` | Skip (not implemented in extractor) |
+| Cron overlap guard | `TrinoExtractorTest` | `testMetadataJobDisallowConcurrentExecution` | Unit (`mvn test`) |
+| Cron validation | `TrinoExtractorTest` | `testInvalidCronExpressionRejected` | Unit |
+| Tarball Jersey classpath | `TrinoExtractorIT` | `testTarballJerseyClasspath` | Live |
+
+### Unit tests (no Atlas/Trino required)
+
+```bash
+mvn -pl addons/trino-extractor test -Dtest=TrinoExtractorTest
+```
+
+No live services are needed — only JDK and Maven.
+
+---
+
+## Components required to run tests
+
+What must be up depends on which tests you run.
+
+### Unit tests only
+
+| Required | Not required |
+|----------|----------------|
+| JDK + Maven | Atlas, Trino, Kafka, Hive, Ranger |
+
+```bash
+mvn -pl addons/trino-extractor test -Dtest=TrinoExtractorTest
+```
+
+### Default integration tests (`TrinoExtractorIT`)
+
+Set `ATLAS_REST_URL` and run:
+
+```bash
+mvn -pl addons/trino-extractor -Ptrino-extractor-it verify
+```
+
+#### Must be running
+
+| Component | Role | Default |
+|-----------|------|---------|
+| **Atlas server** | REST API — create/update/assert `trino_*` entities | `http://localhost:21000` |
+| **Atlas backend store** | Postgres or HBase (started with Atlas) | — |
+| **Trino coordinator** | JDBC metadata source for the extractor | `jdbc:trino://localhost:8080/` |
+| **Hive metastore** | Backing store for the Trino `hive` catalog | e.g. `ranger-hive` in docker lab |
+| **Trino `hive` catalog** | Catalog the extractor queries | `hive` |
+| **Schema / table / column** | Objects imported into Atlas | `hr.trino_pii_lab.ssn` |
+
+```text
+Trino Extractor IT --REST--> Atlas (:21000)
+ |
+ +--JDBC--> Trino (:8080) --> hive catalog --> Hive metastore
+```
+
+#### Minimum environment variables
+
+```bash
+export ATLAS_REST_URL=http://localhost:21000
+export TRINO_JDBC_URL=jdbc:trino://localhost:8080/
+export ATLAS_USERNAME=admin
+export ATLAS_PASSWORD=atlasR0cks!
+
+# Optional — defaults match ranger-docker lab
+export TRINO_EXTRACTOR_CATALOG=hive
+export TRINO_EXTRACTOR_SCHEMA=hr
+export TRINO_EXTRACTOR_TABLE=trino_pii_lab
+export TRINO_EXTRACTOR_COLUMN=ssn
+export ATLAS_TRINO_NAMESPACE=dev
+```
+
+#### Not required for default ITs
+
+| Component | Why not |
+|-----------|---------|
+| **Kafka** | Extractor uses Trino JDBC + Atlas REST only |
+| **Hive hook** | Default tests only create `trino_*` entities |
+| **Ranger** | Not used by extractor ITs |
+| **TagSync** | Only for optional tag propagation test |
+| **Pre-built distro tarball** | Maven profile `trino-extractor-it` builds the IT tarball |
+
+If `ATLAS_REST_URL` is unset or Atlas is unreachable, `TrinoExtractorIT` **skips** (suite passes without live tests).
+
+### Optional integration tests (extra flags)
+
+| Flag | Extra components | What it verifies |
+|------|------------------|------------------|
+| `TRINO_IT_HOOK_ENABLED=1` | HiveServer2 with **Atlas Hive hook**; `hive_column` in Atlas (default namespace `cm`) | `trino_column` links to `hive_column` |
+| `TRINO_IT_TAG_PROPAGATION=1` | Hook linkage + Atlas classification API | PII tag propagates to `trino_column` |
+| `TRINO_IT_DELETE_SYNC=1` | Atlas only (stale entities seeded via REST) | Stale `trino_table` / `trino_schema` / `trino_catalog` removal |
+
+TagSync and Ranger are **not** required even for tag propagation ITs (Atlas REST only).
+
+### Test tier matrix
+
+| Tier | Atlas | Trino | Hive MS + table | Hive hook | Kafka | Ranger | TagSync |
+|------|-------|-------|-----------------|-----------|-------|--------|---------|
+| Unit (`TrinoExtractorTest`) | — | — | — | — | — | — | — |
+| Default IT | Yes | Yes | Yes (`hr.trino_pii_lab`) | — | — | — | — |
+| Hook IT | Yes | Yes | Yes | Yes | —* | — | — |
+| Tag propagation IT | Yes | Yes | Yes | Yes | —* | — | — |
+| Delete sync IT | Yes | Yes** | — | — | — | — | — |
+
+\* Kafka only if your Hive hook setup uses it; not required by the extractor itself.
+\*\* Trino still needed for extractor JDBC; stale entities are seeded via Atlas REST.
+
+### Recommended docker lab (default ITs)
+
+Atlas coexist + ranger-docker stack:
+
+| Service | Port | Purpose |
+|---------|------|---------|
+| `atlas` | 21000 | Atlas REST + graph store |
+| Atlas Postgres (or HBase) | — | Atlas persistence |
+| `ranger-trino` | 8080 | Trino JDBC |
+| `ranger-hive` | — | Hive metastore for `hive` catalog |
+
+Create the test table if missing:
+
+```sql
+CREATE TABLE hr.trino_pii_lab (id INT, ssn STRING, address STRING);
+```
+
+Verify Trino sees it:
+
+```bash
+docker exec ranger-trino trino --user admin \
+ --execute "SELECT ssn FROM hive.hr.trino_pii_lab LIMIT 1"
+```
+
+### Pre-flight checks
+
+```bash
+# Atlas up
+curl -u admin:atlasR0cks! http://localhost:21000/api/atlas/v2/types/trino_column
+
+# Trino up
+docker exec ranger-trino trino --user admin --execute "SHOW SCHEMAS FROM hive"
+
+# Table exists
+docker exec ranger-trino trino --user admin \
+ --execute "DESCRIBE hive.hr.trino_pii_lab"
+```
+
+For full Atlas + Ranger + TagSync E2E (beyond these ITs), see `dev-support/atlas-docker/README-TRINO-ATLAS-RANGER-E2E.md`.
diff --git a/addons/trino-extractor/pom.xml b/addons/trino-extractor/pom.xml
index 9a15f3edf08..3ddce7de561 100644
--- a/addons/trino-extractor/pom.xml
+++ b/addons/trino-extractor/pom.xml
@@ -35,7 +35,7 @@
+ * Enable with {@code mvn -pl addons/trino-extractor -Ptrino-extractor-it verify} and set
+ * {@code ATLAS_REST_URL}. Optional flags: {@code TRINO_IT_HOOK_ENABLED=1},
+ * {@code TRINO_IT_TAG_PROPAGATION=1}.
+ */
public class TrinoExtractorIT {
- /* List of testcases
- Invalid Arguments
- Invalid cron expression
- Test valid Catalog to be run
- Test Instance creation
- Test catalog creation
- Test schema creation
- Test table creation
- Test of hook is enabled, hook entity if created, is connected to Trino entity
- Test cron doesn't trigger new job, before earlier thread completes
- Test without cron expression
- Test even if catalog is not registered, it should run if passed from commandLine
- Deleted table
- Deleted catalog
- Deleted column
- Deleted schema
- Rename catalog
- Rename schema
- Tag propagated*/
+ private static TrinoExtractorITSupport.LiveStackConfig config;
+ private static Path workDir;
+
+ @BeforeClass
+ public static void setUpClass() throws Exception {
+ config = TrinoExtractorITSupport.loadLiveStackConfig();
+ workDir = TrinoExtractorITSupport.prepareExtractorWorkDir(config);
+ }
+
+ @AfterClass
+ public static void tearDownClass() throws Exception {
+ TrinoExtractorITSupport.deleteWorkDir(workDir);
+ workDir = null;
+ }
+
+ @BeforeMethod
+ public void resetExtractorProperties() throws Exception {
+ TrinoExtractorITSupport.rewriteProperties(workDir, config, null);
+ }
+
+ @Test(priority = 1)
+ public void testTarballJerseyClasspath() throws Exception {
+ TrinoExtractorITSupport.assertTarballJerseyClasspath(workDir);
+ }
+
+ @Test(priority = 2)
+ public void testInvalidArguments() throws Exception {
+ int exitCode = TrinoExtractorITSupport.runExtractorJava(workDir, config, "-c", config.catalog, "unexpected-arg");
+ assertNotEquals(exitCode, 0, "unrecognized Java argument should fail");
+ }
+
+ @Test(priority = 2)
+ public void testInvalidCronExpression() throws Exception {
+ long startMs = System.currentTimeMillis();
+ int exitCode = TrinoExtractorITSupport.runExtractorJava(workDir, config, "--cronExpression", "not-a-valid-cron", "-c", config.catalog, "-s", config.schema, "-t", config.table);
+ long elapsedSec = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis() - startMs);
+
+ assertNotEquals(exitCode, 0, "invalid cron expression should fail");
+ assertTrue(elapsedSec < 120, "invalid cron should fail quickly, took " + elapsedSec + "s");
+ }
+
+ @Test(priority = 3)
+ public void testWithoutCronExpression() throws Exception {
+ assertEquals(runSingleTableExtract(), 0);
+ }
+
+ @Test(priority = 3)
+ public void testTableCreation() throws Exception {
+ assertEquals(runSingleTableExtract(), 0);
+
+ String columnQn = TrinoExtractorITSupport.trinoQualifiedName(config, "trino_column", config.catalog, config.schema, config.table, config.column);
+ TrinoExtractorITSupport.assertEntityExists(config, "trino_column", columnQn);
+ }
+
+ @Test(priority = 3, dependsOnMethods = "testTableCreation")
+ public void testSchemaCreation() throws Exception {
+ String schemaQn = TrinoExtractorITSupport.trinoQualifiedName(config, "trino_schema", config.catalog, config.schema);
+ TrinoExtractorITSupport.assertEntityExists(config, "trino_schema", schemaQn);
+ }
+
+ @Test(priority = 3, dependsOnMethods = "testTableCreation")
+ public void testCatalogCreation() throws Exception {
+ String catalogQn = TrinoExtractorITSupport.trinoQualifiedName(config, "trino_catalog", config.catalog);
+ TrinoExtractorITSupport.assertEntityExists(config, "trino_catalog", catalogQn);
+ }
+
+ @Test(priority = 3, dependsOnMethods = "testTableCreation")
+ public void testInstanceCreation() throws Exception {
+ TrinoExtractorITSupport.assertEntityExists(config, "trino_instance", config.trinoNamespace);
+ }
+
+ @Test(priority = 4)
+ public void testValidRegisteredCatalogRun() throws Exception {
+ Map
+ * 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.atlas.trino.cli;
+
+import org.apache.commons.io.FileUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.testng.SkipException;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.attribute.PosixFilePermission;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Properties;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
+
+/**
+ * Shared helpers for live Trino extractor integration tests (tarball subprocess + Atlas REST).
+ */
+final class TrinoExtractorITSupport {
+ private static final Logger LOG = LoggerFactory.getLogger(TrinoExtractorITSupport.class);
+
+ static final String DEFAULT_TRINO_JDBC_URL = "jdbc:trino://localhost:8080/";
+ static final String DEFAULT_ATLAS_USER = "admin";
+ static final String DEFAULT_ATLAS_PASS = "atlasR0cks!";
+ static final String DEFAULT_NAMESPACE = "dev";
+ static final String DEFAULT_CATALOG = "hive";
+ static final String DEFAULT_SCHEMA = "hr";
+ static final String DEFAULT_TABLE = "trino_pii_lab";
+ static final String DEFAULT_COLUMN = "ssn";
+ static final String DEFAULT_HIVE_NAMESPACE = "cm";
+
+ private TrinoExtractorITSupport() {
+ }
+
+ static LiveStackConfig loadLiveStackConfig() {
+ String atlasRestUrl = firstNonBlank(System.getenv("ATLAS_REST_URL"), System.getenv("ATLAS_URL"));
+
+ if (atlasRestUrl == null) {
+ throw new SkipException("Set ATLAS_REST_URL (or ATLAS_URL) to run TrinoExtractorIT against a live Atlas");
+ }
+
+ LiveStackConfig config = new LiveStackConfig();
+ config.atlasRestUrl = normalizeAtlasUrl(atlasRestUrl);
+ config.atlasUsername = firstNonBlank(System.getenv("ATLAS_USERNAME"), System.getenv("ATLAS_USER"), DEFAULT_ATLAS_USER);
+ config.atlasPassword = firstNonBlank(System.getenv("ATLAS_PASSWORD"), System.getenv("ATLAS_PASS"), DEFAULT_ATLAS_PASS);
+ config.trinoJdbcUrl = firstNonBlank(System.getenv("TRINO_JDBC_URL"), DEFAULT_TRINO_JDBC_URL);
+ config.trinoNamespace = firstNonBlank(System.getenv("ATLAS_TRINO_NAMESPACE"), DEFAULT_NAMESPACE);
+ config.catalog = firstNonBlank(System.getenv("TRINO_EXTRACTOR_CATALOG"), DEFAULT_CATALOG);
+ config.schema = firstNonBlank(System.getenv("TRINO_EXTRACTOR_SCHEMA"), DEFAULT_SCHEMA);
+ config.table = firstNonBlank(System.getenv("TRINO_EXTRACTOR_TABLE"), DEFAULT_TABLE);
+ config.column = firstNonBlank(System.getenv("TRINO_EXTRACTOR_COLUMN"), DEFAULT_COLUMN);
+ config.hiveNamespace = firstNonBlank(System.getenv("ATLAS_HIVE_NAMESPACE"), DEFAULT_HIVE_NAMESPACE);
+
+ if (!isAtlasReachable(config)) {
+ throw new SkipException("Atlas is not reachable at " + config.atlasRestUrl);
+ }
+
+ return config;
+ }
+
+ static Path prepareExtractorWorkDir(LiveStackConfig config) throws Exception {
+ Path tarball = resolveTarball();
+ Path workDir = Files.createTempDirectory("atlas-trino-extractor-it-");
+ unpackTarball(tarball, workDir);
+ writeExtractorProperties(workDir, config, null);
+ makeExecutable(workDir.resolve("bin").resolve("run-trino-extractor.sh"));
+ LOG.info("Prepared extractor workdir={} tarball={}", workDir, tarball);
+ return workDir;
+ }
+
+ static void deleteWorkDir(Path workDir) throws IOException {
+ if (workDir != null) {
+ FileUtils.deleteDirectory(workDir.toFile());
+ }
+ }
+
+ static void assertTarballJerseyClasspath(Path workDir) throws IOException {
+ Path libDir = workDir.resolve("lib");
+ assertTrue(Files.isDirectory(libDir), "tarball lib/ directory missing");
+
+ List
+ * 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.atlas.trino.cli;
+
+import org.quartz.CronExpression;
+import org.quartz.DisallowConcurrentExecution;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertTrue;
+
+/**
+ * Unit tests for Trino extractor CLI behaviour that do not require a live Atlas/Trino stack.
+ */
+public class TrinoExtractorTest {
+ @Test
+ public void testInvalidCronExpressionRejected() {
+ assertFalse(CronExpression.isValidExpression("not-a-valid-cron"));
+ }
+
+ @Test
+ public void testValidCronExpressionAccepted() {
+ assertTrue(CronExpression.isValidExpression("0 0 2 * * ?"));
+ }
+
+ @Test
+ public void testMetadataJobDisallowConcurrentExecution() throws Exception {
+ DisallowConcurrentExecution annotation = TrinoExtractor.MetadataJob.class.getAnnotation(DisallowConcurrentExecution.class);
+ assertNotNull(annotation, "MetadataJob must disallow overlapping cron executions");
+ }
+}
diff --git a/dev-support/atlas-docker/Dockerfile.atlas-base b/dev-support/atlas-docker/Dockerfile.atlas-base
index abc2b68d39d..a7af4284450 100644
--- a/dev-support/atlas-docker/Dockerfile.atlas-base
+++ b/dev-support/atlas-docker/Dockerfile.atlas-base
@@ -22,7 +22,7 @@ ARG ATLAS_BASE_JAVA_VERSION
# Install tzdata, Python, Java
RUN apt-get update && \
- DEBIAN_FRONTEND="noninteractive" apt-get -y install tzdata vim\
+ DEBIAN_FRONTEND="noninteractive" apt-get -y install tzdata vim wget curl \
python3 python3-pip openjdk-8-jdk openjdk-11-jdk openjdk-17-jdk bc iputils-ping ssh pdsh
# Set environment variables
diff --git a/dev-support/atlas-docker/Dockerfile.atlas-hbase b/dev-support/atlas-docker/Dockerfile.atlas-hbase
index a05bda3a197..c4e7e791079 100644
--- a/dev-support/atlas-docker/Dockerfile.atlas-hbase
+++ b/dev-support/atlas-docker/Dockerfile.atlas-hbase
@@ -24,6 +24,7 @@ COPY ./dist/apache-atlas-${ATLAS_VERSION}-hbase-hook.tar.gz /home/atlas/dist/
COPY ./downloads/hbase-${HBASE_VERSION}-bin.tar.gz /home/atlas/dist/
COPY ./scripts/atlas-hbase-setup.sh /home/atlas/scripts/
+COPY ./scripts/atlas-hbase-healthcheck.sh /home/atlas/scripts/
COPY ./scripts/atlas-hbase.sh /home/atlas/scripts/
COPY ./scripts/hbase-site.xml /home/atlas/scripts/
COPY ./scripts/atlas-hbase-application.properties /home/atlas/scripts/
@@ -38,7 +39,8 @@ RUN tar xvfz /home/atlas/dist/hbase-${HBASE_VERSION}-bin.tar.gz --directory=/opt
ln -s /opt/apache-atlas-hbase-hook/hook/hbase/hbase-bridge-shim-${ATLAS_VERSION}.jar /opt/hbase/lib/ && \
ln -s /opt/apache-atlas-hbase-hook/hook/hbase/atlas-hbase-plugin-impl /opt/hbase/lib/atlas-hbase-plugin-impl && \
cp /home/atlas/scripts/hbase-site.xml /opt/hbase/conf/hbase-site.xml && \
- cp -f /home/atlas/scripts/atlas-hbase-application.properties /opt/hbase/conf/atlas-application.properties
+ cp -f /home/atlas/scripts/atlas-hbase-application.properties /opt/hbase/conf/atlas-application.properties && \
+ chmod +x /home/atlas/scripts/atlas-hbase-healthcheck.sh /home/atlas/scripts/atlas-hbase.sh
ENV HBASE_HOME=/opt/hbase
ENV PATH=/usr/java/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/opt/hbase/bin
diff --git a/dev-support/atlas-docker/docker-compose.atlas-hbase.yml b/dev-support/atlas-docker/docker-compose.atlas-hbase.yml
index 9d46702c289..82bdf10dcef 100644
--- a/dev-support/atlas-docker/docker-compose.atlas-hbase.yml
+++ b/dev-support/atlas-docker/docker-compose.atlas-hbase.yml
@@ -27,11 +27,11 @@ services:
condition: service_started
restart: unless-stopped
healthcheck:
- test: [ "CMD-SHELL", "wget -q --spider http://localhost:16030/rs-status && wget -q --spider http://localhost:16010/master-status" ]
- interval: 30s
- timeout: 10s
- retries: 30
- start_period: 40s
+ test: [ "CMD-SHELL", "/home/atlas/scripts/atlas-hbase-healthcheck.sh" ]
+ interval: 10s
+ timeout: 15s
+ retries: 24
+ start_period: 120s
environment:
- HBASE_VERSION
- ATLAS_VERSION
diff --git a/dev-support/atlas-docker/scripts/atlas-hbase-healthcheck.sh b/dev-support/atlas-docker/scripts/atlas-hbase-healthcheck.sh
new file mode 100644
index 00000000000..ddab3059563
--- /dev/null
+++ b/dev-support/atlas-docker/scripts/atlas-hbase-healthcheck.sh
@@ -0,0 +1,33 @@
+#!/bin/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.
+
+# Docker healthcheck: HBase Master (16010) and RegionServer (16030) info pages.
+check_url() {
+ local url="$1"
+ if command -v wget >/dev/null 2>&1; then
+ wget -q --spider "${url}"
+ return $?
+ fi
+ if command -v curl >/dev/null 2>&1; then
+ curl -sf -o /dev/null "${url}"
+ return $?
+ fi
+ return 1
+}
+
+check_url "http://localhost:16010/master-status" \
+ && check_url "http://localhost:16030/rs-status"
diff --git a/dev-support/atlas-docker/scripts/atlas-hbase.sh b/dev-support/atlas-docker/scripts/atlas-hbase.sh
index e139a937756..3c986c80c28 100755
--- a/dev-support/atlas-docker/scripts/atlas-hbase.sh
+++ b/dev-support/atlas-docker/scripts/atlas-hbase.sh
@@ -33,21 +33,34 @@ fi
su -c "${HBASE_HOME}/bin/start-hbase.sh" hbase
-HBASE_MASTER_PID=""
-for attempt in $(seq 1 60); do
- HBASE_MASTER_PID=`ps -ef | grep -v grep | grep -i "org.apache.hadoop.hbase.master.HMaster" | awk '{print $2}'`
-
- if [ -n "${HBASE_MASTER_PID}" ]; then
+echo "Waiting for HBase Master and RegionServer (up to 180s)..."
+READY=false
+for attempt in $(seq 1 90); do
+ if ${ATLAS_SCRIPTS}/atlas-hbase-healthcheck.sh 2>/dev/null; then
+ echo "HBase master and regionserver ready (~$((attempt * 2))s)"
+ READY=true
break
fi
-
sleep 2
done
-if [ -z "${HBASE_MASTER_PID}" ]; then
- echo "HBase HMaster failed to start" >&2
+if [ "${READY}" != "true" ]; then
+ echo "HBase health endpoints not ready within 180s" >&2
+ ls -la ${HBASE_HOME}/logs/ 2>/dev/null || true
+ tail -80 ${HBASE_HOME}/logs/* 2>/dev/null || true
exit 1
fi
-# prevent the container from exiting
-tail --pid=$HBASE_MASTER_PID -f /dev/null
+# Keep container alive while HBase JVMs are running (do not tail only HMaster PID).
+while true; do
+ MASTER_PID=$(ps -ef | grep -v grep | grep -i "org.apache.hadoop.hbase.master.HMaster" | awk '{print $2}')
+ RS_PID=$(ps -ef | grep -v grep | grep -i "org.apache.hadoop.hbase.regionserver.HRegionServer" | awk '{print $2}')
+
+ if [ -z "${MASTER_PID}" ] && [ -z "${RS_PID}" ]; then
+ echo "HBase master and regionserver processes exited" >&2
+ tail -80 ${HBASE_HOME}/logs/* 2>/dev/null || true
+ exit 1
+ fi
+
+ sleep 30
+done
diff --git a/webapp/src/test/java/org/apache/atlas/notification/ImportTaskListenerImplTest.java b/webapp/src/test/java/org/apache/atlas/notification/ImportTaskListenerImplTest.java
index c0fdfddf574..422f7d4cf5b 100644
--- a/webapp/src/test/java/org/apache/atlas/notification/ImportTaskListenerImplTest.java
+++ b/webapp/src/test/java/org/apache/atlas/notification/ImportTaskListenerImplTest.java
@@ -91,10 +91,7 @@ public class ImportTaskListenerImplTest {
public void setup() throws Exception {
MockitoAnnotations.openMocks(this);
- importRequest = mock(AtlasAsyncImportRequest.class);
-
- when(importRequest.getImportId()).thenReturn("import123");
- when(importRequest.getTopicName()).thenReturn("topic1");
+ importRequest = createImportRequestMock("import123", "topic1");
requestQueue = mock(BlockingDeque.class);
asyncImportService = mock(AsyncImportService.class);
@@ -109,8 +106,7 @@ public void setup() throws Exception {
public void resetMocks() throws AtlasException {
MockitoAnnotations.openMocks(this);
- when(importRequest.getImportId()).thenReturn("import123");
- when(importRequest.getTopicName()).thenReturn("topic1");
+ importRequest = createImportRequestMock("import123", "topic1");
when(asyncImportService.fetchImportRequestByImportId(any(String.class))).thenReturn(importRequest);
importTaskListener = new ImportTaskListenerImpl(asyncImportService, notificationHookConsumer, requestQueue);
@@ -119,7 +115,7 @@ public void resetMocks() throws AtlasException {
@AfterMethod
public void teardown() throws Exception {
shutdownImportExecutor(importTaskListener);
- Mockito.reset(asyncImportService, notificationHookConsumer, requestQueue, importRequest);
+ Mockito.reset(asyncImportService, notificationHookConsumer, requestQueue);
}
@Test
@@ -293,10 +289,11 @@ public void testStartAsyncImportIfAvailable_WithInvalidStatus() throws Exception
@Test
public void testStartImportConsumer_Successful() throws Exception {
- Mockito.doReturn("import123").when(importRequest).getImportId();
- when(importRequest.getStatus()).thenReturn(WAITING);
- when(importRequest.getTopicName()).thenReturn("topic1");
- when(asyncImportService.fetchImportRequestByImportId("import123")).thenReturn(importRequest);
+ AtlasAsyncImportRequest request = createImportRequestMock("import123", "topic1");
+
+ when(request.getStatus()).thenReturn(WAITING);
+ when(asyncImportService.fetchImportRequestByImportId("import123")).thenReturn(request);
+ when(requestQueue.poll(anyLong(), any(TimeUnit.class))).thenReturn("import123");
CountDownLatch consumerStarted = new CountDownLatch(1);
@@ -305,14 +302,9 @@ public void testStartImportConsumer_Successful() throws Exception {
return null;
}).when(notificationHookConsumer).startAsyncImportConsumer(any(), anyString(), anyString());
- ExecutorService realExecutor = java.util.concurrent.Executors.newSingleThreadExecutor();
- Field executorField = ImportTaskListenerImpl.class.getDeclaredField("executorService");
+ setExecutorService(importTaskListener, synchronousExecutor());
- executorField.setAccessible(true);
- executorField.set(importTaskListener, realExecutor);
- when(requestQueue.poll(anyLong(), any(TimeUnit.class))).thenReturn("import123");
-
- importTaskListener.onReceiveImportRequest(importRequest);
+ importTaskListener.onReceiveImportRequest(request);
assertTrue(consumerStarted.await(5, TimeUnit.SECONDS), "startAsyncImportConsumer was not invoked");
@@ -321,35 +313,29 @@ public void testStartImportConsumer_Successful() throws Exception {
@Test
public void testStartImportConsumer_Failure() throws Exception {
- when(importRequest.getImportId()).thenReturn("import123");
- when(importRequest.getStatus()).thenReturn(WAITING);
- when(importRequest.getTopicName()).thenReturn("topic1");
- when(asyncImportService.fetchImportRequestByImportId("import123")).thenReturn(importRequest);
+ AtlasAsyncImportRequest request = createImportRequestMock("import123", "topic1");
+
+ when(request.getStatus()).thenReturn(WAITING);
+ when(asyncImportService.fetchImportRequestByImportId("import123")).thenReturn(request);
+ when(requestQueue.poll(anyLong(), any(TimeUnit.class))).thenReturn("import123").thenReturn(null);
CountDownLatch consumerClosed = new CountDownLatch(1);
doThrow(new RuntimeException("Consumer failed")).when(notificationHookConsumer).startAsyncImportConsumer(NotificationInterface.NotificationType.ASYNC_IMPORT, "import123", "topic1");
doAnswer(invocation -> {
- Object newStatus = invocation.getArgument(0);
- when(importRequest.getStatus()).thenReturn((AtlasAsyncImportRequest.ImportStatus) newStatus);
+ when(request.getStatus()).thenReturn(invocation.getArgument(0));
return null;
- }).when(importRequest).setStatus(any());
+ }).when(request).setStatus(any());
doAnswer(invocation -> {
consumerClosed.countDown();
return null;
}).when(notificationHookConsumer).closeImportConsumer(anyString(), anyString());
- ExecutorService realExecutor = java.util.concurrent.Executors.newSingleThreadExecutor();
- Field executorField = ImportTaskListenerImpl.class.getDeclaredField("executorService");
+ setExecutorService(importTaskListener, synchronousExecutor());
- executorField.setAccessible(true);
- executorField.set(importTaskListener, realExecutor);
-
- when(requestQueue.poll(anyLong(), any(TimeUnit.class))).thenReturn("import123");
-
- importTaskListener.onReceiveImportRequest(importRequest);
+ importTaskListener.onReceiveImportRequest(request);
assertTrue(consumerClosed.await(5, TimeUnit.SECONDS), "closeImportConsumer was not invoked");
@@ -584,20 +570,15 @@ public void testStartInternalIsNonBlocking() throws InterruptedException {
@Test
public void testImportNotProcessedWhenPassive() throws Exception {
- Mockito.doReturn("import123").when(importRequest).getImportId();
when(importRequest.getStatus()).thenReturn(WAITING);
when(requestQueue.poll(anyLong(), any(TimeUnit.class))).thenReturn("import123");
importTaskListener.instanceIsPassive();
importTaskListener.onReceiveImportRequest(importRequest);
- Thread.sleep(200);
verify(notificationHookConsumer, never()).startAsyncImportConsumer(any(), anyString(), anyString());
}
@Test
public void testExecutorNotRecreatedWhenPassive() throws Exception {
- Mockito.doReturn("import123").when(importRequest).getImportId();
- when(importRequest.getStatus()).thenReturn(WAITING);
- when(requestQueue.poll(anyLong(), any(TimeUnit.class))).thenReturn("import123");
when(importRequest.getStatus()).thenReturn(WAITING);
when(requestQueue.poll(anyLong(), any(TimeUnit.class))).thenReturn("import123");
importTaskListener.instanceIsPassive();
@@ -608,7 +589,6 @@ public void testExecutorNotRecreatedWhenPassive() throws Exception {
exec.shutdownNow();
}
importTaskListener.onReceiveImportRequest(importRequest);
- Thread.sleep(200);
ExecutorService execAfter = (ExecutorService) executorField.get(importTaskListener);
// Should remain null when passive
assertTrue(execAfter == null);
@@ -744,6 +724,34 @@ public void ensureExecutorAliveReturnsNullWhenPassiveEvenUnderConcurrency() thro
callers.shutdownNow();
}
+ private AtlasAsyncImportRequest createImportRequestMock(String importId, String topicName) {
+ AtlasAsyncImportRequest request = mock(AtlasAsyncImportRequest.class);
+
+ when(request.getImportId()).thenReturn(importId);
+ when(request.getTopicName()).thenReturn(topicName);
+
+ return request;
+ }
+
+ private ExecutorService synchronousExecutor() {
+ ExecutorService executor = mock(ExecutorService.class);
+
+ doAnswer(invocation -> {
+ Runnable task = invocation.getArgument(0);
+ task.run();
+ return null;
+ }).when(executor).submit(any(Runnable.class));
+
+ return executor;
+ }
+
+ private void setExecutorService(ImportTaskListenerImpl listener, ExecutorService executor) throws Exception {
+ Field executorField = ImportTaskListenerImpl.class.getDeclaredField("executorService");
+
+ executorField.setAccessible(true);
+ executorField.set(listener, executor);
+ }
+
private void shutdownImportExecutor(ImportTaskListenerImpl listener) throws Exception {
if (listener == null) {
return;